Почему функция скобок Haskell работает в исполняемых файлах, но не очищается в тестах? - PullRequest
10 голосов
/ 14 января 2020

Я наблюдаю очень странное поведение, когда функция 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. Что происходит?

1 Ответ

6 голосов
/ 15 января 2020

Когда вы используете stack run, Stack эффективно использует системный вызов exec для передачи управления исполняемому файлу, поэтому процесс для нового исполняемого файла заменяет запущенный процесс стека, так же, как если бы вы запускали исполняемый файл непосредственно из оболочка. Вот как выглядит дерево процессов после stack run. В частности, обратите внимание, что исполняемый файл является прямым потомком оболочки Bash. Что еще более важно, обратите внимание, что приоритетной группой процессов терминала (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

В результате, когда вы нажимаете Ctrl- C, чтобы прервать процесс, запущенный либо под stack run, либо непосредственно из оболочки, сигнал SIGINT доставляется только процессу bracket-test-exe. Это вызывает асинхронное исключение UserInterrupt. Способ bracket работает, когда:

bracket
  acquire
  (\() -> release)
  (\() -> body)

получает асинхронное исключение при обработке body, запускает release и затем повторно вызывает исключение. С вашими вложенными вызовами bracket это приводит к прерыванию внутреннего тела, обработке внутреннего освобождения, повторному вызову исключения для прерывания внешнего тела, обработке внешнего выпуска и, наконец, повторному вызову исключения для завершения программа. (Если бы после внешней bracket в вашей функции main было больше действий, они не были бы выполнены.)

С другой стороны, когда вы используете stack test, стек использует 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 и bracket-test-test получают сигнал. bracket-test-test начнет обработку сигнала и запустит финализаторы, как описано выше. Однако здесь есть условие гонки, потому что когда stack test прервано, оно находится в середине withProcessWait, что определяется более или менее следующим образом:

withProcessWait config f =
  bracket
    (startProcess config)
    stopProcess
    (\p -> f p <* waitExitCode p)

, поэтому, когда его bracket прерывается он вызывает stopProcess, что завершает дочерний процесс, посылая ему сигнал SIGTERM. В отличие от SIGINT, это не вызывает асинхронного исключения. Он просто немедленно завершает работу ребенка, прежде чем он сможет завершить sh запуск любых финализаторов.

Я не могу придумать особенно простой способ обойти это. Одним из способов является использование средств 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 просто продолжит работу.

Альтернативой может быть попытка обработать SIGTERM и оставить работающий дочерний процесс для выполнения очистки, даже если stack test процесс завершен. Это немного уродливо, так как процесс будет как бы очищаться в фоновом режиме, пока вы смотрите на приглашение оболочки.

...