Почему функция скобок Haskell работает в исполняемых файлах, но не может быть очищена в тестах?

10

Я вижу очень странное поведение, когда bracketфункция Haskell ведет себя по-разному в зависимости от того, используется ли она 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
Я не уверен. Я заметил один интересный факт: если я вскрою фактический скомпилированный тестовый исполняемый файл .stack-workи запускаю его напрямую, то проблема не возникает. Это происходит только при работе под stack test.
Том
Я могу догадаться, что происходит, но я вообще не использую стек. Это просто предположение, основанное на поведении. 1) stack testзапускает рабочие потоки для обработки тестов. 2) обработчик SIGINT убивает основной поток. 3) Программы на Haskell завершаются, когда основной поток завершает работу, игнорируя любые дополнительные потоки. 2 - поведение по умолчанию в SIGINT для программ, скомпилированных GHC. 3, как работают потоки в Haskell. 1 - полное предположение.
Карл

Ответы:

6

При использовании stack runStack эффективно использует execсистемный вызов для передачи управления исполняемому файлу, поэтому процесс для нового исполняемого файла заменяет запущенный процесс Stack, как если бы вы запускали исполняемый файл непосредственно из оболочки. Вот как выглядит дерево процессов после 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, 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и 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этому, это не вызывает асинхронного исключения. Он просто немедленно завершает дочерний процесс, обычно до того, как он может завершить выполнение финализаторов.

Я не могу придумать особенно простой способ обойти это. Одним из способов является использование средств 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процесса. Это немного уродливо, поскольку процесс будет как бы очищаться в фоновом режиме, пока вы смотрите на приглашение оболочки.

К. А. Бур
источник
Спасибо за подробный ответ! К вашему сведению, я подал ошибку в стеке по этому поводу здесь: github.com/commercialhaskell/stack/issues/5144 . Кажется, что реальное исправление было бы для stack testзапуска процессов с delegate_ctlcопцией от System.Process(или что-то подобное).
Том