packages feed

hurl-workbench-core-0.1.0.0: src/HurlWorkbench/Process/Run.hs

-- | Acquire a POSIX group ID before any waiter can reap the leader. All owned
-- output handles are caller-managed files, so termination never waits on a pipe.
module HurlWorkbench.Process.Run
  ( OwnedProcess (..),
    spawnGroup,
    terminateGroup,
    commandConfig,
    runCommand,
    CommandOutcome (..),
    getExitCode,
    waitExitCode,
    getExitCodeSTM,
    waitExitCodeSTM,
  )
where

import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (Async, asyncWithUnmask, wait)
import Control.Concurrent.STM
import Control.Exception (IOException, displayException, mask, onException, try)
import Data.Generics.Labels ()
import Data.Text qualified as Text
import Data.Time.Clock (NominalDiffTime, addUTCTime, getCurrentTime)
import HurlWorkbench.Prelude
import HurlWorkbench.Service.Resolve
import HurlWorkbench.Workspace.Types (ServiceName)
import System.Exit (ExitCode (..))
import System.IO (Handle, IOMode (ReadWriteMode), withBinaryFile)
import System.IO.Error (isDoesNotExistError)
import System.Posix.Signals (nullSignal, sigKILL, sigTERM, signalProcessGroup)
import System.Posix.Types (ProcessID)
import System.Process qualified as Process
import System.Timeout qualified as Timeout

data OwnedProcess = OwnedProcess
  { processId :: !ProcessID,
    completion :: !(TMVar (Either IOException ExitCode)),
    reaper :: !(Async ())
  }
  deriving stock (Generic)

data CommandOutcome = CommandPassed | CommandFailed | CommandTimedOut | CommandStartFailed | CommandSkipped | CommandCancelled
  deriving stock (Generic, Eq, Show)

commandConfig :: ResolvedCommand -> Process.CreateProcess
commandConfig command =
  (Process.proc (command ^. #executable) (command ^. #arguments))
    { Process.create_group = True,
      Process.std_in = Process.NoStream,
      Process.std_out = Process.NoStream,
      Process.std_err = Process.NoStream,
      Process.cwd = Just (command ^. #workingDirectory),
      Process.env = Just (command ^. #environment)
    }

spawnGroup :: ServiceName -> Process.CreateProcess -> IO (Either ServiceError OwnedProcess)
spawnGroup name config = mask $ \_ -> do
  -- createProcess_ leaves UseHandle inputs open in the parent. No pipe handles
  -- or background waiter exist until the immutable group ID has been captured.
  started <- try @IOException $ withBinaryFile "/dev/null" ReadWriteMode $ \nullHandle -> do
    let output stream = case stream of Process.NoStream -> Process.UseHandle nullHandle; other -> other
    (_, _, _, handle) <-
      Process.createProcess_
        "owned process"
        config
          { Process.create_group = True,
            Process.std_out = output (Process.std_out config),
            Process.std_err = output (Process.std_err config)
          }
    pure handle
  case started of
    Left _ -> pure (Left (ServiceSpawnFailed "<redacted>" "process launch failed"))
    Right handle ->
      Process.getPid handle >>= \case
        Nothing -> do
          _ <- Timeout.timeout 1000000 (Process.terminateProcess handle >> Process.waitForProcess handle)
          pure (Left (ServiceProcessIdUnavailable name))
        Just pid -> do
          completion <- newEmptyTMVarIO
          reaper <- asyncWithUnmask $ \unmask -> do
            result <- try @IOException (unmask (Process.waitForProcess handle))
            atomically (putTMVar completion result)
          pure (Right (OwnedProcess pid completion reaper))

waitExitCodeSTM :: OwnedProcess -> STM ExitCode
waitExitCodeSTM owned = readTMVar (owned ^. #completion) >>= either throwSTM pure

getExitCodeSTM :: OwnedProcess -> STM (Maybe ExitCode)
getExitCodeSTM owned = tryReadTMVar (owned ^. #completion) >>= traverse (either throwSTM pure)

waitExitCode :: OwnedProcess -> IO ExitCode
waitExitCode = atomically . waitExitCodeSTM

getExitCode :: OwnedProcess -> IO (Maybe ExitCode)
getExitCode = atomically . getExitCodeSTM

-- | TERM and KILL address the group even if the leader has already exited.
-- The finite budget includes leader reaping and joining its one waiter.
terminateGroup :: ServiceName -> NominalDiffTime -> OwnedProcess -> IO (Either ServiceError ())
terminateGroup name budget owned = do
  attempted <- try @IOException terminate
  pure (either (const (Left failure)) id attempted)
  where
    failure = ServiceShutdownFailed name (Just (owned ^. #processId)) "process group cleanup failed or timed out"
    terminate = do
      finished <- Timeout.timeout micros $ do
        term <- signal sigTERM
        began <- getCurrentTime
        gone <- waitGroup (addUTCTime (budget / 2) began)
        killed <- if gone then pure "" else signal sigKILL
        _ <- waitExitCode owned
        wait (owned ^. #reaper)
        pure (term <> killed)
      pure $ case finished of
        Just "" -> Right ()
        Just message -> Left (ServiceShutdownFailed name (Just (owned ^. #processId)) message)
        Nothing -> Left failure
    micros = max 1 (floor (budget * 1000000))
    signal sig =
      try @IOException (signalProcessGroup sig (owned ^. #processId)) >>= \case
        Right () -> pure ""
        Left err | isDoesNotExistError err -> pure ""
        Left err -> do
          -- Darwin can report EPERM while an empty group is disappearing. Only
          -- suppress the failed signal once ESRCH positively confirms absence;
          -- a group that still exists or remains uncertain is a cleanup failure.
          vanished <- Timeout.timeout (max 1 (min 10000 (micros `div` 4))) confirmAbsent
          pure $ if vanished == Just True then "" else (if sig == sigTERM then "SIGTERM: " else "SIGKILL: ") <> Text.pack (displayException err)
    confirmAbsent =
      try @IOException (signalProcessGroup nullSignal (owned ^. #processId)) >>= \case
        Left err | isDoesNotExistError err -> pure True
        Left _ -> threadDelay 1000 >> confirmAbsent
        Right () -> threadDelay 1000 >> confirmAbsent
    waitGroup deadline = do
      alive <- try @IOException (signalProcessGroup nullSignal (owned ^. #processId))
      now <- getCurrentTime
      case alive of
        Left err | isDoesNotExistError err -> pure True
        Left _ -> pure False
        Right () | now >= deadline -> pure False
        Right () -> threadDelay 10000 >> waitGroup deadline

runCommand :: ServiceName -> NominalDiffTime -> ResolvedCommand -> Handle -> Handle -> IO (CommandOutcome, Maybe ServiceError)
runCommand name budget command stdout stderr = mask $ \restore -> do
  spawnGroup name (commandConfig command) {Process.std_out = Process.UseHandle stdout, Process.std_err = Process.UseHandle stderr} >>= \case
    Left _ -> pure (CommandStartFailed, Nothing)
    Right owned -> do
      let cleanupBudget = min 1 (budget / 4)
          cleanup = terminateGroup name cleanupBudget owned
      result <- restore (Timeout.timeout (max 1 (floor ((budget - cleanupBudget) * 1000000))) (waitExitCode owned)) `onException` void cleanup
      shutdown <- cleanup
      pure (case result of Just ExitSuccess -> CommandPassed; Just _ -> CommandFailed; Nothing -> CommandTimedOut, either Just (const Nothing) shutdown)