Warum funktioniert die Klammerfunktion von Haskell in ausführbaren Dateien, kann jedoch in Tests nicht bereinigt werden?

10

Ich sehe ein sehr seltsames Verhalten, bei dem sich Haskells bracketFunktion je nach Verwendung stack runoder stack testVerwendung unterschiedlich verhält .

Betrachten Sie den folgenden Code, in dem zwei verschachtelte Klammern zum Erstellen und Bereinigen von Docker-Containern verwendet werden:

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
                     )
          )

Wenn ich dies mit ausführe stack runund mit unterbreche Ctrl+C, erhalte ich die erwartete Ausgabe:

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

Und ich kann überprüfen, ob beide Docker-Container erstellt und dann entfernt wurden.

Wenn ich jedoch genau denselben Code in einen Test einfüge und ausführe stack test, erfolgt nur (ein Teil) der ersten Bereinigung:

Inside both brackets, sleeping!
^CInner release
container2

Dies führt dazu, dass ein Docker-Container auf meinem Computer ausgeführt wird. Was ist los?

Tom
quelle
Verwendet der Stapeltest Threads?
Carl
1
Ich bin mir nicht sicher. Ich habe eine interessante Tatsache bemerkt: Wenn ich die tatsächlich kompilierte ausführbare Testdatei unter grabe .stack-workund sie direkt ausführe, tritt das Problem nicht auf. Es passiert nur, wenn man unter läuft stack test.
Tom
Ich kann mir vorstellen, was los ist, aber ich benutze Stack überhaupt nicht. Es ist nur eine Vermutung, die auf Verhalten basiert. 1) stack testStartet Worker-Threads, um Tests durchzuführen. 2) Der SIGINT-Handler beendet den Haupt-Thread. 3) Haskell-Programme werden beendet, wenn der Haupt-Thread dies tut, wobei zusätzliche Threads ignoriert werden. 2 ist das Standardverhalten von SIGINT für von GHC kompilierte Programme. 3 ist, wie Threads in Haskell funktionieren. 1 ist eine vollständige Vermutung.
Carl

Antworten:

6

Wenn Sie verwenden stack run, verwendet Stack effektiv einen execSystemaufruf, um die Steuerung auf die ausführbare Datei zu übertragen, sodass der Prozess für die neue ausführbare Datei den ausgeführten Stack-Prozess ersetzt, so als würden Sie die ausführbare Datei direkt von der Shell ausführen. So sieht der Prozessbaum aus stack run. Beachten Sie insbesondere, dass die ausführbare Datei ein direktes untergeordnetes Element der Bash-Shell ist. Beachten Sie kritischer, dass die Vordergrundprozessgruppe (TPGID) des Terminals 17996 ist und der einzige Prozess in dieser Prozessgruppe (PGID) der bracket-test-exeProzess ist.

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

Wenn Sie also Strg-C drücken, um den Prozess zu unterbrechen, der entweder unter stack runoder direkt von der Shell ausgeführt wird, wird das SIGINT-Signal nur an den bracket-test-exeProzess gesendet . Dies löst eine asynchrone UserInterruptAusnahme aus. Der Weg bracketfunktioniert, wenn:

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

Empfängt während der Verarbeitung eine asynchrone Ausnahme body, wird ausgeführt releaseund löst die Ausnahme erneut aus. Bei Ihren verschachtelten bracketAufrufen führt dies dazu, dass der innere Körper unterbrochen, die innere Freigabe verarbeitet, die Ausnahme erneut ausgelöst wird, um den äußeren Körper zu unterbrechen, die äußere Freigabe verarbeitet und schließlich die Ausnahme erneut ausgelöst wird, um das Programm zu beenden. (Wenn bracketin Ihrer mainFunktion mehr Aktionen nach dem Äußeren folgen würden, würden diese nicht ausgeführt.)

Wenn Sie dagegen stack testStack verwenden withProcessWait, wird die ausführbare Datei als untergeordneter Prozess des stack testProzesses gestartet . Beachten Sie im folgenden Prozessbaum, dass bracket-test-testes sich um einen untergeordneten Prozess von handelt stack test. Entscheidend ist, dass die Vordergrundprozessgruppe des Terminals 18050 ist und diese Prozessgruppe sowohl den stack testProzess als auch den bracket-test-testProzess umfasst.

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

Wenn Sie drücken Sie Strg-C im Terminal wird das SIGINT Signal gesendet alle Prozesse im Bereich des Terminals Vordergrund Prozessgruppe so beide stack testund bracket-test-testbekommen das Signal. bracket-test-teststartet die Verarbeitung des Signals und führt die Finalisierer wie oben beschrieben aus. Hier gibt es jedoch eine Rennbedingung, denn wenn sie stack testunterbrochen wird, ist sie in der Mitte withProcessWaitmehr oder weniger wie folgt definiert:

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

Wenn bracketes unterbrochen wird, ruft es auf, stopProcesswodurch der untergeordnete Prozess durch Senden des SIGTERMSignals beendet wird. Im SIGINTGegensatz dazu löst dies keine asynchrone Ausnahme aus. Das Kind wird nur sofort beendet, in der Regel bevor die Finalisierer ausgeführt werden können.

Ich kann mir keinen besonders einfachen Weg vorstellen, dies zu umgehen. Eine Möglichkeit besteht darin, die Einrichtungen System.Posixzu verwenden, um den Prozess in eine eigene Prozessgruppe einzuteilen:

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"

Jetzt führt Strg-C dazu, dass SIGINT nur an den bracket-test-testProzess geliefert wird. Es wird bereinigt, die ursprüngliche Vordergrundprozessgruppe wiederhergestellt, um auf den Prozess zu verweisen stack test, und beendet. Dies führt dazu, dass der Test fehlschlägt und stack testeinfach weiterläuft.

Eine Alternative wäre, zu versuchen SIGTERM, den untergeordneten Prozess zu handhaben und am Laufen zu halten, um eine Bereinigung durchzuführen, selbst wenn der stack testProzess beendet wurde. Dies ist etwas hässlich, da der Prozess im Hintergrund aufgeräumt wird, während Sie die Shell-Eingabeaufforderung betrachten.

KA Buhr
quelle
Danke für die ausführliche Antwort! Zu Ihrer Information, ich habe hier einen Stack-Fehler eingereicht: github.com/commercialhaskell/stack/issues/5144 . Es scheint, als ob die eigentliche Lösung darin besteht, stack testProzesse mit der delegate_ctlcOption von System.Process(oder ähnlichem) zu starten .
Tom