packages feed

heni-0.1.0.0: src/Test/Heni/Internal/Process/Posix.hs

-- | Narrow POSIX group termination. Detached descendants require a sandbox.
module Test.Heni.Internal.Process.Posix (stopGroup, exitOutcome) where

import Control.Concurrent (threadDelay)
import Control.Exception
import Data.Text (Text)
import Data.Text qualified as Text
import GHC.Clock (getMonotonicTimeNSec)
import System.Exit (ExitCode (..))
import System.IO.Error (isDoesNotExistError)
import System.Posix.Signals
import System.Process (Pid, ProcessHandle, getProcessExitCode)
import Test.Heni.Command (ProcessOutcome (..))

exitOutcome :: ExitCode -> ProcessOutcome
exitOutcome ExitSuccess = Exited 0
exitOutcome (ExitFailure n) | n < 0 = Signaled (negate n)
exitOutcome (ExitFailure n) = Exited n

-- | Signal only while the owned group remains observable. Once absence has
-- been observed, never send another signal to that transient numeric identity.
-- Reap the direct child independently; all waits have explicit bounds.
stopGroup :: Int -> Maybe Pid -> ProcessHandle -> IO [Text]
stopGroup grace pid process = do
  initial <- groupState
  termErrors <- case initial of Right False -> pure []; _ -> send sigTERM
  deadline <- (+ fromIntegral grace * 1000) <$> getMonotonicTimeNSec
  remaining <- case initial of
    Right False -> pure (Right False)
    _ -> waitGroup deadline
  killErrors <- case remaining of Right False -> pure []; _ -> send sigKILL
  reapDeadline <- (+ fromIntegral grace * 1000) <$> getMonotonicTimeNSec
  final <- case remaining of Right False -> pure (Right False); _ -> waitGroup reapDeadline
  reaped <- reap reapDeadline
  -- Darwin can temporarily return EPERM for a group whose members are exiting.
  -- Only proven disappearance clears these provisional observation/send errors.
  let shutdownErrors = case final of
        Right False -> []
        _ -> either pure (const []) initial ++ termErrors ++ either pure (const []) final ++ killErrors
  pure (shutdownErrors ++ ["direct child could not be reaped within shutdown budget" | not reaped])
  where
    send signal = case pid of
      Nothing -> pure ["missing owned process group identity"]
      Just group -> do
        result <- try (signalProcessGroup signal group)
        case result of
          Right () -> pure []
          Left e | isDoesNotExistError e -> pure []
          Left (e :: IOException) -> do
            -- A disappearing group can make a redundant signal fail. Accept
            -- only definite absence, never a permission/observation failure.
            after <- groupState
            pure $ case after of
              Right False -> []
              _ -> ["sending signal " <> Text.pack (show signal) <> " to owned group: " <> Text.pack (displayException e)]
    groupState = case pid of
      Nothing -> pure (Left "missing owned process group identity")
      Just group -> do
        result <- try (signalProcessGroup nullSignal group) :: IO (Either IOException ())
        pure $ case result of
          Right () -> Right True
          Left e | isDoesNotExistError e -> Right False
          Left e -> Left ("probing owned process group: " <> Text.pack (displayException e))
    waitGroup deadline = do
      _ <- getProcessExitCode process
      state <- groupState
      now <- getMonotonicTimeNSec
      case state of
        Right False -> pure state
        _ | now >= deadline -> pure state
        _ -> threadDelay 5000 >> waitGroup deadline
    reap deadline = do
      result <- getProcessExitCode process
      now <- getMonotonicTimeNSec
      case result of
        Just _ -> pure True
        Nothing | now >= deadline -> pure False
        Nothing -> threadDelay 5000 >> reap deadline