为什么 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。怎么回事?

当您使用 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-teststack 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 提示符时,该过程会在后台进行清理。