Dlaczego funkcja nawiasów Haskella działa w plikach wykonywalnych, ale nie jest czyszczona w testach?

10

Widzę bardzo dziwne zachowanie, w którym bracketfunkcja Haskella zachowuje się różnie, w zależności od tego, stack runczy stack testjest używana.

Rozważ następujący kod, w którym dwa zagnieżdżone nawiasy klamrowe są używane do tworzenia i czyszczenia kontenerów 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
                     )
          )

Kiedy uruchamiam to stack runi przerywam Ctrl+C, otrzymuję oczekiwany wynik:

Inside both brackets, sleeping!
^CInner release
container2
Done with inner release
Outer release
container1
Done with outer release

I mogę zweryfikować, że oba kontenery Docker są tworzone, a następnie usuwane.

Jeśli jednak wkleję ten sam kod do testu i uruchomię stack test, nastąpi tylko (część) pierwszego czyszczenia:

Inside both brackets, sleeping!
^CInner release
container2

Powoduje to, że na moim komputerze nadal działa kontener Docker. Co się dzieje?

Tomek
źródło
Czy test stosu używa wątków?
Carl
1
Nie jestem pewny. Zauważyłem jeden interesujący fakt: jeśli wykopię aktualnie skompilowany plik wykonywalny testu .stack-worki uruchomię go bezpośrednio, problem się nie pojawi. Zdarza się to tylko podczas biegania pod stack test.
tom
Mogę zgadnąć, co się dzieje, ale w ogóle nie używam stosu. To tylko zgadywanie oparte na zachowaniu. 1) stack testuruchamia wątki robocze do obsługi testów. 2) moduł obsługi SIGINT zabija główny wątek. 3) Programy Haskell kończą się, gdy robi to główny wątek, ignorując wszelkie dodatkowe wątki. 2 jest domyślnym zachowaniem SIGINT dla programów skompilowanych przez GHC. 3 to, jak działają wątki w Haskell. 1 jest całkowitym domysłem.
Carl

Odpowiedzi:

6

Kiedy używasz stack run, Stack efektywnie używa execwywołania systemowego do przeniesienia kontroli do pliku wykonywalnego, więc proces nowego pliku wykonywalnego zastępuje działający proces Stack, tak jakbyś uruchomił plik wykonywalny bezpośrednio z powłoki. Oto, jak wygląda drzewo procesu stack run. W szczególności zauważ, że plik wykonywalny jest bezpośrednim potomkiem powłoki Bash. Co bardziej krytyczne, należy pamiętać, że pierwszoplanową grupą procesów terminalu (TPGID) jest 17996, a jedynym procesem w tej grupie procesów (PGID) jest bracket-test-exeproces.

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

W rezultacie, gdy naciśniesz Ctrl-C, aby przerwać proces działający pod stack runpowłoką lub bezpośrednio z powłoki, sygnał SIGINT jest dostarczany tylko do bracket-test-exeprocesu. Rodzi to UserInterruptwyjątek asynchroniczny . Sposób bracketdziała, gdy:

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

odbiera asynchroniczny wyjątek podczas przetwarzania body, uruchamia się, releasea następnie ponownie zgłasza wyjątek. W przypadku zagnieżdżonych bracketwywołań skutkuje to przerwaniem wewnętrznego ciała, przetworzeniem wewnętrznego wydania, ponownym podniesieniem wyjątku w celu przerwania zewnętrznego ciała i przetworzeniem zewnętrznego wydania, a na koniec ponownym zgłoszeniem wyjątku w celu zakończenia programu. (Gdyby bracketw twojej mainfunkcji było więcej akcji , nie byłyby one wykonane).

Z drugiej strony, gdy używasz stack test, Stack używa withProcessWaitdo uruchomienia pliku wykonywalnego jako proces potomny stack testprocesu. W poniższym drzewie procesów zwróć uwagę, że bracket-test-testjest to proces potomny stack test. Krytycznie, pierwszą grupą procesów terminala jest 18050, a ta grupa procesów obejmuje zarówno stack testproces, jak i bracket-test-testproces.

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

Kiedy trafisz Ctrl-C w terminalu, sygnał SIGINT wysyłany jest do wszystkich procesów w grupie procesów planie terminalu więc zarówno stack testi bracket-test-testdostać sygnał. bracket-test-testrozpocznie przetwarzanie sygnału i uruchomi finalizatory, jak opisano powyżej. Istnieje jednak warunek wyścigu, ponieważ gdy stack testzostanie przerwany, jest w środku, withProcessWaitktóry jest zdefiniowany mniej więcej w następujący sposób:

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

więc gdy bracketjest przerwany, wywołuje, stopProcessco kończy proces potomny wysyłając mu SIGTERMsygnał. W przeciwieństwie do SIGINTtego nie wywołuje to asynchronicznego wyjątku. Po prostu natychmiast kończy to dziecko, na ogół zanim może zakończyć uruchamianie jakichkolwiek finalizatorów.

Nie mogę wymyślić szczególnie łatwego sposobu obejścia tego. Jednym ze sposobów jest wykorzystanie urządzeń System.Posixdo umieszczenia procesu we własnej grupie procesów:

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"

Teraz Ctrl-C spowoduje dostarczenie SIGINT tylko do bracket-test-testprocesu. Sprząta, przywróci oryginalną grupę procesów pierwszego planu, aby wskazywała na stack testproces, i zakończy działanie. Spowoduje to, że test się nie powiedzie i stack testpo prostu będzie działać.

Alternatywą może być próba obsłużenia SIGTERMi utrzymania procesu potomnego w celu przeprowadzenia czyszczenia, nawet po zakończeniu stack testprocesu. Jest to trochę brzydkie, ponieważ proces będzie przypominał czyszczenie w tle podczas patrzenia na monit powłoki.

KA Buhr
źródło
Dzięki za szczegółową odpowiedź! Do Twojej wiadomości zgłosiłem błąd stosu: github.com/commercialhaskell/stack/issues/5144 . Wygląda na to, że prawdziwą poprawką byłoby stack testuruchamianie procesów z delegate_ctlcopcją System.Process(lub coś podobnego).
tom