为什么 Haskell 的括号函数在可执行文件中有效,但在测试中无法清理?
Why does Haskell's bracket function work in executables but fail to clean up in tests?
我看到一个非常奇怪的行为,其中 Haskell 的 bracket
函数根据使用的是 stack run
还是 stack test
表现不同。
考虑以下代码,其中两个嵌套括号用于创建和清理 Docker 个容器:
module Main where
import Control.Concurrent
import Control.Exception
import System.Process
main :: IO ()
main = do
bracket (callProcess "docker" ["run", "-d", "--name", "container1", "registry:2"])
(\() -> do
putStrLn "Outer release"
callProcess "docker" ["rm", "-f", "container1"]
putStrLn "Done with outer release"
)
(\() -> do
bracket (callProcess "docker" ["run", "-d", "--name", "container2", "registry:2"])
(\() -> do
putStrLn "Inner release"
callProcess "docker" ["rm", "-f", "container2"]
putStrLn "Done with inner release"
)
(\() -> do
putStrLn "Inside both brackets, sleeping!"
threadDelay 300000000
)
)
当我用 stack run
运行 并用 Ctrl+C
中断时,我得到了预期的输出:
Inside both brackets, sleeping!
^CInner release
container2
Done with inner release
Outer release
container1
Done with outer release
而且我可以验证两个 Docker 容器都已创建然后删除。
但是,如果我将完全相同的代码粘贴到测试中 运行 stack test
,只有(部分)第一次清理发生:
Inside both brackets, sleeping!
^CInner release
container2
这导致 Docker 容器留在我的机器上 运行ning。怎么回事?
- 我已确保将完全相同的
ghc-options
传递给两者。
- 完整的演示回购:https://github.com/thomasjm/bracket-issue
当您使用 stack run
时,Stack 有效地使用 exec
系统调用将控制权转移给可执行文件,因此新可执行文件的进程替换了 运行ning Stack 进程,就像您直接从 shell 运行 可执行文件一样。这是 stack run
之后进程树的样子。请特别注意,可执行文件是 Bash shell 的直接子项。更关键的是,请注意终端的前台进程组 (TPGID) 是 17996,并且该进程组 (PGID) 中唯一的进程是 bracket-test-exe
进程。
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
13816 13831 13831 13831 pts/3 17996 Ss 2001 0:00 | \_ /bin/bash --noediting -i
13831 17996 17996 13831 pts/3 17996 Sl+ 2001 0:00 | | \_ .../.stack-work/.../bracket-test-exe
因此,当您在 stack run
下或直接从 shell 按下 Ctrl-C 中断进程 运行ning 时,SIGINT 信号仅传递给bracket-test-exe
进程。这会引发异步 UserInterrupt
异常。 bracket
的工作方式,当:
bracket
acquire
(\() -> release)
(\() -> body)
在处理 body
时收到异步异常,它 运行 发出 release
然后重新引发异常。对于嵌套的 bracket
调用,这具有中断内部主体、处理内部释放、重新引发异常以中断外部主体、处理外部释放、最后重新引发异常的效果终止程序。 (如果在 main
函数中的外部 bracket
之后有更多操作,则不会执行它们。)
另一方面,当您使用 stack test
时,Stack 使用 withProcessWait
将可执行文件作为 stack test
进程的子进程启动。在下面的进程树中,请注意 bracket-test-test
是 stack test
的子进程。关键是,终端的前台进程组是18050,这个进程组既包括stack test
进程,又包括bracket-test-test
进程。
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
13816 13831 13831 13831 pts/3 18050 Ss 2001 0:00 | \_ /bin/bash --noediting -i
13831 18050 18050 13831 pts/3 18050 Sl+ 2001 0:00 | | \_ stack test
18050 18060 18050 13831 pts/3 18050 Sl+ 2001 0:00 | | \_ .../.stack-work/.../bracket-test-test
当您在终端中按下 Ctrl-C 时,SIGINT 信号会发送到终端前台进程组中的 所有 进程,因此 stack test
和 [=31] =] 得到信号。 bracket-test-test
将开始处理信号和 运行ning 终结器,如上所述。但是,这里存在竞争条件,因为当 stack test
被中断时,它位于 withProcessWait
的中间,其定义大致如下:
withProcessWait config f =
bracket
(startProcess config)
stopProcess
(\p -> f p <* waitExitCode p)
因此,当其 bracket
被中断时,它会调用 stopProcess
并通过向其发送 SIGTERM
信号来终止子进程。与 SIGINT
不同,这不会引发异步异常。它只是立即终止子进程,通常是在它完成 运行 任何终结器之前。
我想不出一个特别简单的方法来解决这个问题。一种方法是使用System.Posix
中的工具将进程放入自己的进程组:
main :: IO ()
main = do
-- save old terminal foreground process group
oldpgid <- getTerminalProcessGroupID (Fd 2)
-- get our PID
mypid <- getProcessID
let -- put us in our own foreground process group
handleInt = setTerminalProcessGroupID (Fd 2) mypid >> createProcessGroupFor mypid
-- restore the old foreground process gorup
releaseInt = setTerminalProcessGroupID (Fd 2) oldpgid
bracket
(handleInt >> putStrLn "acquire")
(\() -> threadDelay 1000000 >> putStrLn "release" >> releaseInt)
(\() -> putStrLn "between" >> threadDelay 60000000)
putStrLn "finished"
现在,Ctrl-C 将导致 SIGINT 仅传送到 bracket-test-test
进程。它将清理、恢复原始前台进程组以指向 stack test
进程,然后终止。这将导致测试失败,stack test
只会保持 运行ning.
另一种方法是尝试处理 SIGTERM
并让子进程 运行 执行清理,即使 stack test
进程已终止。这有点丑陋,因为当您查看 shell 提示符时,该过程会在后台进行清理。
我看到一个非常奇怪的行为,其中 Haskell 的 bracket
函数根据使用的是 stack run
还是 stack test
表现不同。
考虑以下代码,其中两个嵌套括号用于创建和清理 Docker 个容器:
module Main where
import Control.Concurrent
import Control.Exception
import System.Process
main :: IO ()
main = do
bracket (callProcess "docker" ["run", "-d", "--name", "container1", "registry:2"])
(\() -> do
putStrLn "Outer release"
callProcess "docker" ["rm", "-f", "container1"]
putStrLn "Done with outer release"
)
(\() -> do
bracket (callProcess "docker" ["run", "-d", "--name", "container2", "registry:2"])
(\() -> do
putStrLn "Inner release"
callProcess "docker" ["rm", "-f", "container2"]
putStrLn "Done with inner release"
)
(\() -> do
putStrLn "Inside both brackets, sleeping!"
threadDelay 300000000
)
)
当我用 stack run
运行 并用 Ctrl+C
中断时,我得到了预期的输出:
Inside both brackets, sleeping!
^CInner release
container2
Done with inner release
Outer release
container1
Done with outer release
而且我可以验证两个 Docker 容器都已创建然后删除。
但是,如果我将完全相同的代码粘贴到测试中 运行 stack test
,只有(部分)第一次清理发生:
Inside both brackets, sleeping!
^CInner release
container2
这导致 Docker 容器留在我的机器上 运行ning。怎么回事?
- 我已确保将完全相同的
ghc-options
传递给两者。 - 完整的演示回购:https://github.com/thomasjm/bracket-issue
当您使用 stack run
时,Stack 有效地使用 exec
系统调用将控制权转移给可执行文件,因此新可执行文件的进程替换了 运行ning Stack 进程,就像您直接从 shell 运行 可执行文件一样。这是 stack run
之后进程树的样子。请特别注意,可执行文件是 Bash shell 的直接子项。更关键的是,请注意终端的前台进程组 (TPGID) 是 17996,并且该进程组 (PGID) 中唯一的进程是 bracket-test-exe
进程。
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
13816 13831 13831 13831 pts/3 17996 Ss 2001 0:00 | \_ /bin/bash --noediting -i
13831 17996 17996 13831 pts/3 17996 Sl+ 2001 0:00 | | \_ .../.stack-work/.../bracket-test-exe
因此,当您在 stack run
下或直接从 shell 按下 Ctrl-C 中断进程 运行ning 时,SIGINT 信号仅传递给bracket-test-exe
进程。这会引发异步 UserInterrupt
异常。 bracket
的工作方式,当:
bracket
acquire
(\() -> release)
(\() -> body)
在处理 body
时收到异步异常,它 运行 发出 release
然后重新引发异常。对于嵌套的 bracket
调用,这具有中断内部主体、处理内部释放、重新引发异常以中断外部主体、处理外部释放、最后重新引发异常的效果终止程序。 (如果在 main
函数中的外部 bracket
之后有更多操作,则不会执行它们。)
另一方面,当您使用 stack test
时,Stack 使用 withProcessWait
将可执行文件作为 stack test
进程的子进程启动。在下面的进程树中,请注意 bracket-test-test
是 stack test
的子进程。关键是,终端的前台进程组是18050,这个进程组既包括stack test
进程,又包括bracket-test-test
进程。
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
13816 13831 13831 13831 pts/3 18050 Ss 2001 0:00 | \_ /bin/bash --noediting -i
13831 18050 18050 13831 pts/3 18050 Sl+ 2001 0:00 | | \_ stack test
18050 18060 18050 13831 pts/3 18050 Sl+ 2001 0:00 | | \_ .../.stack-work/.../bracket-test-test
当您在终端中按下 Ctrl-C 时,SIGINT 信号会发送到终端前台进程组中的 所有 进程,因此 stack test
和 [=31] =] 得到信号。 bracket-test-test
将开始处理信号和 运行ning 终结器,如上所述。但是,这里存在竞争条件,因为当 stack test
被中断时,它位于 withProcessWait
的中间,其定义大致如下:
withProcessWait config f =
bracket
(startProcess config)
stopProcess
(\p -> f p <* waitExitCode p)
因此,当其 bracket
被中断时,它会调用 stopProcess
并通过向其发送 SIGTERM
信号来终止子进程。与 SIGINT
不同,这不会引发异步异常。它只是立即终止子进程,通常是在它完成 运行 任何终结器之前。
我想不出一个特别简单的方法来解决这个问题。一种方法是使用System.Posix
中的工具将进程放入自己的进程组:
main :: IO ()
main = do
-- save old terminal foreground process group
oldpgid <- getTerminalProcessGroupID (Fd 2)
-- get our PID
mypid <- getProcessID
let -- put us in our own foreground process group
handleInt = setTerminalProcessGroupID (Fd 2) mypid >> createProcessGroupFor mypid
-- restore the old foreground process gorup
releaseInt = setTerminalProcessGroupID (Fd 2) oldpgid
bracket
(handleInt >> putStrLn "acquire")
(\() -> threadDelay 1000000 >> putStrLn "release" >> releaseInt)
(\() -> putStrLn "between" >> threadDelay 60000000)
putStrLn "finished"
现在,Ctrl-C 将导致 SIGINT 仅传送到 bracket-test-test
进程。它将清理、恢复原始前台进程组以指向 stack test
进程,然后终止。这将导致测试失败,stack test
只会保持 运行ning.
另一种方法是尝试处理 SIGTERM
并让子进程 运行 执行清理,即使 stack test
进程已终止。这有点丑陋,因为当您查看 shell 提示符时,该过程会在后台进行清理。