packages feed

hinagata-cli-0.1.0.0: src/Hinagata/Cli/Process.hs

-- | Generic, shell-free child execution with a private PostgreSQL password channel.
module Hinagata.Cli.Process
  ( ChildSpec (..),
    ChildReport (..),
    ChildFailure (..),
    CancellationScope,
    CommandCancelled (..),
    ChildCleanupException (..),
    withCancellationScope,
    withCommandCancellation,
    withCooperativeCancellation,
    cancellationSignal,
    checkCancellation,
    waitCancellation,
    runChild,
    runChildWithCleanupCheck,
    withRegistration,
    parseOverlay,
  )
where

import Control.Applicative ((<|>))
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async qualified as Async
import Control.Concurrent.MVar (MVar, modifyMVar, modifyMVar_, newEmptyMVar, newMVar, readMVar, tryPutMVar, tryReadMVar)
import Control.Exception (Exception (..), IOException, SomeException, asyncExceptionFromException, asyncExceptionToException, bracket, bracket_, finally, mask, throwIO, try)
import Control.Monad (unless, void)
import Data.List (isPrefixOf)
import Data.Text qualified as Text
import Data.Text.IO qualified as TextIO
import Hinagata.Connection
import Hinagata.Types (databaseNameText, portNumber)
import System.Directory (getTemporaryDirectory, removeFile)
import System.Environment (getEnvironment)
import System.Exit (ExitCode (..))
import System.FilePath ((</>))
import System.IO (IOMode (ReadMode), hClose, hFlush, hIsClosed, stderr, withFile)
import System.IO.Error (isDoesNotExistError)
import System.Posix.Files (ownerReadMode, ownerWriteMode, setFileMode, unionFileModes)
import System.Posix.Signals (Handler (Catch), Signal, installHandler, nullSignal, sigINT, sigKILL, sigTERM, signalProcessGroup)
import System.Posix.Temp (mkstemp)
import System.Posix.Types qualified
import System.Process (CreateProcess (..), ProcessHandle, StdStream (Inherit, UseHandle), createProcess, getPid, getProcessExitCode, proc, waitForProcess)
import System.Timeout (timeout)

data ChildSpec = ChildSpec
  { executable :: !FilePath,
    arguments :: ![String],
    workingDirectory :: !(Maybe FilePath),
    environment :: ![(String, String)],
    target :: !ConnectionTarget,
    identifiers :: !(Maybe (Text.Text, Text.Text)),
    deadlineMs :: !(Maybe Int),
    stdoutToStderr :: !Bool
  }

data ChildReport = ChildReport
  { childExit :: !(Maybe ExitCode),
    timedOut :: !Bool,
    cancelledBy :: !(Maybe Int),
    cleanupFailure :: !(Maybe Text.Text)
  }
  deriving stock (Eq, Show)

data ChildFailure = ChildSpawnFailed | ChildIdentityUnavailable
  deriving stock (Eq, Show)

-- | Explicit hook overlays cannot replace the managed PostgreSQL connection
-- or inject a shell through argument splitting.
parseOverlay :: [Text.Text] -> Either Text.Text [(String, String)]
parseOverlay = traverse parseOne
  where
    parseOne entry =
      let (name, rest) = Text.breakOn "=" entry
       in if not (validName name) || Text.null rest || Text.any (== '\0') rest
            then Left "hook environment entries must be NAME=VALUE without NUL"
            else
              if "PG" `Text.isPrefixOf` name || "HINAGATA_" `Text.isPrefixOf` name
                then Left "hook environment cannot override managed PG* or HINAGATA_* variables"
                else Right (Text.unpack name, Text.unpack (Text.drop 1 rest))

    validName name = case Text.uncons name of
      Nothing -> False
      Just (initial, following) -> (initial == '_' || asciiLetter initial) && Text.all (\character -> character == '_' || asciiLetter character || asciiDigit character) following
    asciiLetter character = ('A' <= character && character <= 'Z') || ('a' <= character && character <= 'z')
    asciiDigit character = '0' <= character && character <= '9'

-- | One command owns process-wide handlers. Registration and first-signal
-- observation share a lock, so spawning cannot miss concurrent cancellation.
data ScopeState = ScopeState
  { selectedSignal :: !(Maybe Int),
    nextRegistration :: !Int,
    registrations :: ![(Int, MVar Int)],
    cooperative :: !Int,
    deferRoot :: !Bool
  }

newtype CancellationScope = CancellationScope (MVar ScopeState)

data CommandCancelled = CommandCancelled !Int
  deriving stock (Show)

instance Exception CommandCancelled where
  toException = asyncExceptionToException
  fromException = asyncExceptionFromException

data ChildCleanupException = ChildCleanupException
  deriving stock (Show)
  deriving anyclass (Exception)

withCancellationScope :: (CancellationScope -> IO a) -> IO a
withCancellationScope callback = mask $ \restore -> do
  scope@(CancellationScope state) <- CancellationScope <$> newMVar (ScopeState Nothing 0 [] 0 False)
  let notify number = modifyMVar_ state $ \record@ScopeState {selectedSignal, registrations, cooperative, deferRoot} -> do
        let selected = selectedSignal <|> Just number
            deferred = case selectedSignal of
              Just _ -> deferRoot
              Nothing -> cooperative > 0 || not (null registrations)
        mapM_ (\(_, marker) -> void (tryPutMVar marker (maybe number id selected))) registrations
        pure record {selectedSignal = selected, deferRoot = deferred}
      settle = do
        ScopeState {registrations = children} <- readMVar state
        unless (null children) (threadDelay 10000 >> settle)
  previousInt <- installHandler sigINT (Catch (notify 2)) Nothing
  previousTerm <- installHandler sigTERM (Catch (notify 15)) Nothing
  restore (callback scope)
    `finally` ( settle `finally` do
                  _ <- installHandler sigINT previousInt Nothing
                  _ <- installHandler sigTERM previousTerm Nothing
                  pure ()
              )

cancellationSignal :: CancellationScope -> IO (Maybe Int)
cancellationSignal (CancellationScope state) = do
  ScopeState {selectedSignal = signal} <- readMVar state
  pure signal

checkCancellation :: CancellationScope -> IO ()
checkCancellation scope = cancellationSignal scope >>= maybe (pure ()) (throwIO . CommandCancelled)

waitCancellation :: CancellationScope -> IO Int
waitCancellation scope = cancellationSignal scope >>= maybe (threadDelay 10000 >> waitCancellation scope) pure

-- | The root cancels native work once and joins cleanup. If the first signal
-- arrives inside an existing child/validation supervisor, that supervisor owns
-- delivery through its typed outcome; the root only joins it. This decision is
-- latched with the signal, so unregistering cannot cause a second interruption.
withCommandCancellation :: CancellationScope -> IO a -> IO a
withCommandCancellation scope@(CancellationScope state) action =
  Async.withAsync (checkCancellation scope >> action) $ \worker -> do
    raced <- Async.race (Async.waitCatch worker) (waitCancellation scope)
    completed <- case raced of
      Left result -> pure result
      Right signal -> do
        ScopeState {deferRoot} <- readMVar state
        unless deferRoot (Async.cancelWith worker (CommandCancelled signal))
        Async.waitCatch worker
    either throwIO pure completed

-- | Delegate first-signal delivery to an existing supervisor. Its cleanup and
-- typed outcome must settle before leaving this region.
withCooperativeCancellation :: CancellationScope -> IO a -> IO a
withCooperativeCancellation (CancellationScope state) = bracket_ enter leave
  where
    enter = modifyMVar_ state (\record -> pure record {cooperative = cooperative record + 1})
    leave = modifyMVar_ state (\record -> pure record {cooperative = cooperative record - 1})

withRegistration :: CancellationScope -> (MVar Int -> IO a) -> IO a
withRegistration (CancellationScope state) callback = bracket register unregister (callback . snd)
  where
    register = do
      marker <- newEmptyMVar
      modifyMVar state $ \record@ScopeState {selectedSignal, nextRegistration, registrations} -> do
        mapM_ (void . tryPutMVar marker) selectedSignal
        pure (record {nextRegistration = nextRegistration + 1, registrations = (nextRegistration, marker) : registrations}, (nextRegistration, marker))
    unregister (key, _) = modifyMVar_ state $ \record@ScopeState {registrations} ->
      pure record {registrations = filter ((/= key) . fst) registrations}

runChild :: CancellationScope -> ChildSpec -> IO (Either ChildFailure ChildReport)
runChild = runChildWithCleanupCheck (pure True)

-- | Internal test seam: a negative proof forces retention after real cleanup.
-- There is deliberately no environment or configuration switch for this.
runChildWithCleanupCheck :: IO Bool -> CancellationScope -> ChildSpec -> IO (Either ChildFailure ChildReport)
runChildWithCleanupCheck cleanupCheck scope specification@ChildSpec {target} = do
  checkCancellation scope
  withRegistration scope $ \marker -> withPasswordFile target $ \passwordPath -> do
    inherited <- getEnvironment
    let forwarded = filter (\(name, _) -> not ("PG" `isPrefixOf` name || "HINAGATA_" `isPrefixOf` name)) inherited
        connectionEnvironment =
          [ ("PGHOST", Text.unpack (hostText (connectionHost target))),
            ("PGPORT", show (portNumber (connectionPort target))),
            ("PGUSER", Text.unpack (connectionUser target)),
            ("PGDATABASE", Text.unpack (databaseNameText (connectionDatabase target)))
          ]
        identityEnvironment = case identifiers specification of
          Nothing -> []
          Just (runId, leaseId) -> [("HINAGATA_RUN_ID", Text.unpack runId), ("HINAGATA_LEASE_ID", Text.unpack leaseId)]
        passwordEnvironment = maybe [] (\path -> [("PGPASSFILE", path)]) passwordPath
        overlayNames = map fst (environment specification)
        processEnvironment = environment specification <> connectionEnvironment <> identityEnvironment <> passwordEnvironment <> filter (\(name, _) -> name `notElem` overlayNames) forwarded
    withFile "/dev/null" ReadMode $ \nullInput -> mask $ \restore -> do
      checkCancellation scope
      let child =
            (proc (executable specification) (arguments specification))
              { cwd = workingDirectory specification,
                env = Just processEnvironment,
                std_in = UseHandle nullInput,
                std_out = if stdoutToStderr specification then UseHandle stderr else Inherit,
                create_group = True,
                delegate_ctlc = False
              }
      spawned <- try @IOException (createProcess child)
      case spawned of
        Left _ -> pure (Left ChildSpawnFailed)
        Right (_, _, _, handle) -> do
          maybePid <- getPid handle
          case maybePid of
            Nothing -> pure (Left ChildIdentityUnavailable)
            Just pid -> do
              let stop = do
                    signal <- cancellationSignal scope
                    attempted <- try @IOException $ do
                      stopped <- stopGroup (maybe sigTERM (\number -> if number == 2 then sigINT else sigTERM) signal) pid handle
                      _ <- timeout 2000000 (try @IOException (waitForProcess handle))
                      pure stopped
                    proven <- cleanupCheck
                    pure (if proven then either (const (Just "child process cleanup could not be observed")) id attempted else Just "child process group could not be proven stopped")
                  wait = do
                    signal <- tryReadMVar marker
                    case signal of
                      Just _ -> pure Nothing
                      Nothing -> do
                        completed <- getProcessExitCode handle
                        case completed of
                          Just status -> pure (Just status)
                          Nothing -> threadDelay 10000 >> wait
                  supervise = do
                    waited <- case deadlineMs specification of
                      Nothing -> Just <$> restore wait
                      Just milliseconds -> restore (timeout (milliseconds * 1000) wait)
                    stopped <- stop
                    signal <- cancellationSignal scope
                    pure (Right (ChildReport (waited >>= id) (waited == Nothing) signal stopped))
              result <- try @SomeException supervise
              case result of
                Right report -> pure report
                Left exception -> do
                  stopped <- stop
                  case stopped of
                    Just _ -> throwIO ChildCleanupException
                    Nothing -> throwIO exception

-- | Bounded escalation also reaps the leader while checking the group.
stopGroup :: System.Posix.Signals.Signal -> System.Posix.Types.ProcessID -> ProcessHandle -> IO (Maybe Text.Text)
stopGroup signal groupId handle = do
  alive <- groupAlive groupId
  if not alive
    then pure Nothing
    else do
      _ <- try @IOException (signalProcessGroup signal groupId)
      stopped <- waitGone 40
      if stopped
        then pure Nothing
        else do
          _ <- try @IOException (signalProcessGroup sigKILL groupId)
          killed <- waitGone 40
          pure (if killed then Nothing else Just "child process group could not be proven stopped")
  where
    waitGone attempts = do
      _ <- getProcessExitCode handle
      alive <- groupAlive groupId
      if not alive then pure True else if attempts <= (0 :: Int) then pure False else threadDelay 50000 >> waitGone (attempts - 1)

groupAlive :: System.Posix.Types.ProcessID -> IO Bool
groupAlive groupId = do
  result <- try @IOException (signalProcessGroup nullSignal groupId)
  pure $ case result of
    Right () -> True
    Left problem -> not (isDoesNotExistError problem)

withPasswordFile :: ConnectionTarget -> (Maybe FilePath -> IO a) -> IO a
withPasswordFile target callback = case connectionPassword target of
  Nothing -> callback Nothing
  Just password -> do
    temporary <- getTemporaryDirectory
    bracket (mkstemp (temporary </> "hinagata-pgpass-")) removePassword $ \(path, handle) -> do
      setFileMode path (ownerReadMode `unionFileModes` ownerWriteMode)
      TextIO.hPutStrLn handle (passwordLine target password)
      hFlush handle
      hClose handle
      callback (Just path)
  where
    removePassword (path, handle) = do
      closed <- hIsClosed handle
      unless closed (hClose handle)
      removeFile path

passwordLine :: ConnectionTarget -> Secret -> Text.Text
passwordLine target password =
  Text.intercalate ":" (map escape [hostText (connectionHost target), Text.pack (show (portNumber (connectionPort target))), databaseNameText (connectionDatabase target), connectionUser target, secretText password])
  where
    escape = Text.concatMap (\character -> if character == ':' || character == '\\' then Text.pack ['\\', character] else Text.singleton character)