packages feed

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

-- | Bounded commands, owned POSIX groups and independent stdout/stderr evidence.
module Test.Heni.Process
  ( Stream (..),
    LogArtifact (..),
    ProcessOptions (..),
    defaultProcessOptions,
    ProcessReport (..),
    ProcessAborted (..),
    runCommand,
    validateExecutable,
  )
where

import Control.Concurrent
import Control.Exception hiding (handle)
import Control.Monad (unless, when)
import Data.ByteString qualified as BS
import Data.IORef
import Data.List (isPrefixOf, nub)
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text
import GHC.Clock (getMonotonicTimeNSec)
import System.Directory qualified as Dir
import System.Environment (getEnvironment)
import System.FilePath (isPathSeparator, (</>))
import System.IO
import System.Process qualified as P
import System.Timeout (timeout)
import Test.Heni.Case (ConfigError (..), mkRelativeTarget, targetPath)
import Test.Heni.Command
import Test.Heni.Expectation
import Test.Heni.Internal.Process.Posix
import Test.Heni.Workspace

-- | Streams remain separate; a witness cannot span stdout and stderr.
data Stream = Stdout | Stderr deriving stock (Eq, Show)

-- | Bounded retained bytes and total byte count; paths are portable basenames.
data LogArtifact = LogArtifact
  {stream :: !Stream, path :: !FilePath, bytesSeen :: !Integer, bytesRetained :: !Int, truncated :: !Bool}
  deriving stock (Eq, Show)

-- | Observation callbacks may fail but cannot alter process outcomes.
data ProcessOptions = ProcessOptions
  { logDirectory :: !FilePath,
    logPrefix :: !String,
    graceBudget :: !StageBudget,
    witnesses :: ![Text],
    environmentRemovals :: ![String],
    observer :: !(Stream -> BS.ByteString -> IO ())
  }

-- | A 200ms grace period and no diagnostic matching; specify an owned log directory.
defaultProcessOptions :: FilePath -> String -> ProcessOptions
defaultProcessOptions directory prefix = ProcessOptions directory prefix grace [] [] (\_ _ -> pure ())
  where
    grace = either (error . show) id (mkStageBudget 200000)

-- | Primary stage facts plus bounded log metadata and secondary shutdown errors.
data ProcessReport = ProcessReport
  {evidence :: !StageEvidence, logs :: ![LogArtifact], preservedWitnesses :: ![Text], errors :: ![Text]}
  deriving stock (Eq, Show)

-- | Cancellation retains final process evidence. Campaigns finalize their ledger,
-- then rethrow cause to honor the original asynchronous exception.
data ProcessAborted = ProcessAborted {report :: !ProcessReport, cause :: !SomeException}
  deriving stock (Show)
  deriving anyclass (Exception)

-- | Resolve host tools before execution. Explicit ./workspace programs are
-- validated structurally here and resolved after the build, inside its workspace.
-- Launch races are still handled in IO.
validateExecutable :: Command -> IO (Either ConfigError ())
validateExecutable command@Command {executable} = case validateCommand command of
  Left e -> pure (Left e)
  Right () | "./" `isPrefixOf` executable -> pure (Right ())
  Right () -> do
    found <- Dir.findExecutable executable
    pure $ maybe (Left (ConfigError "executable" "executable is unavailable")) (const (Right ())) found

data Capture = Capture !Integer !BS.ByteString !BS.ByteString ![Text]

-- | Run literal executable/argv in an exclusive workspace. Logs are bounded per
-- stream. Exact requested witnesses are scanned through all bytes and retained
-- separately, so log truncation alone cannot erase a proved witness.
runCommand :: ProcessOptions -> Workspace -> Stage -> Command -> IO ProcessReport
runCommand ProcessOptions {logDirectory, logPrefix, graceBudget, witnesses, environmentRemovals, observer} workspace stage command@Command {executable, arguments, workingDirectory, environment, budget, output = OutputPolicy {limitBytes}} = do
  started <- getMonotonicTimeNSec
  let failed why = pure (ProcessReport (StageEvidence stage (LaunchFailed why) "" False unknownSelection Nothing 0 []) [] [] [why])
  case validateCommand command of
    Left e -> failed (Text.pack (show e))
    Right () | null logPrefix || any isPathSeparator logPrefix || logPrefix `elem` [".", ".."] -> failed "invalid log prefix"
    Right () | sum (map (BS.length . Text.encodeUtf8) witnesses) > 65536 || any Text.null witnesses -> failed "diagnostic witnesses must be nonempty and total at most 64KiB"
    Right () -> do
      launch <- try $ do
        cwd <- maybe (pure (workspaceRoot workspace)) (checkedWorkspacePath workspace) workingDirectory `catch` workspaceLaunchError
        isDirectory <- Dir.doesDirectoryExist cwd
        unless isDirectory (ioError (userError "working directory does not exist"))
        when ("./" `isPrefixOf` executable) $ do
          let relative = maybe "" ((++ "/") . targetPath) workingDirectory ++ drop 2 executable
          target <- either (ioError . userError . show) pure (mkRelativeTarget relative)
          _ <- checkedWorkspacePath workspace target `catch` workspaceLaunchError
          pure ()
        inherited <- getEnvironment
        let overrides = Map.fromList environment
            effective = Map.toList (overrides `Map.union` foldr Map.delete (Map.fromList inherited) environmentRemovals)
        Dir.createDirectoryIfMissing True logDirectory
        withBinaryFile (logDirectory </> logPrefix ++ ".stdout.log") WriteMode $ \outLog ->
          withBinaryFile (logDirectory </> logPrefix ++ ".stderr.log") WriteMode $ \errLog ->
            supervise started cwd effective outLog errLog
      case launch of
        Left (e :: IOException) -> failed (Text.pack (show e))
        Right result -> pure result
  where
    workspaceLaunchError (e :: WorkspaceError) = ioError (userError (show e))
    supervise started cwd effective outLog errLog = mask $ \restore -> do
      -- Dependency-owned record update is isolated to the process adapter; no
      -- label instances propagate through the public library.
      let spec =
            (P.proc executable arguments)
              { P.cwd = Just cwd,
                P.env = Just effective,
                P.std_in = P.NoStream,
                P.std_out = P.CreatePipe,
                P.std_err = P.CreatePipe,
                P.create_group = True,
                P.close_fds = True
              }
      (_, maybeOut, maybeErr, process) <- P.createProcess spec
      pid <- P.getPid process
      case (maybeOut, maybeErr) of
        (Just stdoutHandle, Just stderrHandle) -> do
          outRef <- newIORef (Capture 0 BS.empty BS.empty [])
          errRef <- newIORef (Capture 0 BS.empty BS.empty [])
          outDone <- newEmptyMVar
          errDone <- newEmptyMVar
          outThread <- forkFinally (restore (drain Stdout stdoutHandle outLog outRef)) (putMVar outDone)
          errThread <- forkFinally (restore (drain Stderr stderrHandle errLog errRef)) (putMVar errDone)
          let poll = do
                readerFailure <- firstFailure [outDone, errDone]
                case readerFailure of
                  Just e -> throwIO e
                  Nothing -> do
                    exit <- P.getProcessExitCode process
                    now <- getMonotonicTimeNSec
                    case exit of
                      Just code -> pure (exitOutcome code)
                      Nothing | now - started >= fromIntegral (budgetMicroseconds budget) * 1000 -> pure Timeout
                      Nothing -> threadDelay 5000 >> poll
          primary <- try (restore poll) :: IO (Either SomeException ProcessOutcome)
          deferred <- newIORef Nothing
          let defer :: forall b. IO b -> IO b
              defer action =
                action `catch` \(e :: SomeAsyncException) -> do
                  writeIORef deferred (Just (toException e))
                  defer action
          -- Cleanup runs masked and has no blocking operation without a bound.
          shutdown <- uninterruptibleMask_ (stopGroup (budgetMicroseconds graceBudget) pid process)
          let finish tid handle done = do
                joined <- timeout (budgetMicroseconds graceBudget) (readMVar done)
                case joined of
                  Nothing -> do
                    _ <- timeout (budgetMicroseconds graceBudget) (killThread tid)
                    _ <- timeout (budgetMicroseconds graceBudget) (readMVar done)
                    closeQuietly handle
                    pure ["output reader did not finish within shutdown budget"]
                  Just (Left e) -> closeQuietly handle >> pure [Text.pack (displayException e)]
                  Just (Right ()) -> closeQuietly handle >> pure []
          outErrors <- defer (finish outThread stdoutHandle outDone)
          errErrors <- defer (finish errThread stderrHandle errDone)
          flushResult <- defer (try (hFlush outLog >> hFlush errLog)) :: IO (Either IOException ())
          secondaryCancellation <- readIORef deferred
          outCapture <- readIORef outRef
          errCapture <- readIORef errRef
          ended <- getMonotonicTimeNSec
          let found = nub (captureWitnesses outCapture ++ captureWitnesses errCapture)
              -- Only exact byte-matched witnesses enter classifier text. Lenient
              -- display decoding of malformed UTF-8 must never invent a match.
              outputText = Text.intercalate "\n" found
              secondary = shutdown ++ outErrors ++ errErrors ++ either (\e -> [Text.pack (show e)]) (const []) flushResult
              primaryErrors = either (\e -> case fromException e :: Maybe SomeAsyncException of Just _ -> []; Nothing -> [Text.pack (displayException e)]) (const []) primary
              outcome = either (const Cancelled) id primary
              unavailable = not (null (primaryErrors ++ secondary))
              stageEvidence = StageEvidence stage outcome outputText unavailable unknownSelection Nothing (fromIntegral (ended - started) / 1e9) [logPrefix ++ ".stdout.log", logPrefix ++ ".stderr.log"]
              result = ProcessReport stageEvidence [artifact Stdout outCapture, artifact Stderr errCapture] found (primaryErrors ++ secondary)
          case primary of
            Left e | Just (_ :: SomeAsyncException) <- fromException e -> throwIO (ProcessAborted result e)
            _ -> case secondaryCancellation of
              Just e -> throwIO (ProcessAborted result e)
              Nothing -> pure result
        _ -> do
          _ <- stopGroup (budgetMicroseconds graceBudget) pid process
          ioError (userError "process library did not supply requested output pipes")
    captureWitnesses (Capture _ _ _ found) = found
    captureTruncated (Capture count b _ _) = count > fromIntegral (BS.length b)
    artifact stream capture@(Capture count b _ _) = LogArtifact stream (logPrefix ++ suffix stream) count (BS.length b) (captureTruncated capture)
    suffix Stdout = ".stdout.log"
    suffix Stderr = ".stderr.log"
    drain stream input logHandle ref = do
      chunk <- BS.hGetSome input 32768
      unless (BS.null chunk) $ do
        Capture count kept tailBytes found <- readIORef ref
        let retain = BS.take (max 0 (limitBytes - BS.length kept)) chunk
            haystack = tailBytes <> chunk
            newFound = nub (found ++ [w | w <- witnesses, Text.encodeUtf8 w `BS.isInfixOf` haystack])
            overlap = maximum (0 : map ((subtract 1) . BS.length . Text.encodeUtf8) witnesses)
            nextTail = BS.drop (max 0 (BS.length haystack - overlap)) haystack
        BS.hPut logHandle retain
        writeIORef ref (Capture (count + fromIntegral (BS.length chunk)) (kept <> retain) nextTail newFound)
        observer stream chunk
        drain stream input logHandle ref

firstFailure :: [MVar (Either SomeException ())] -> IO (Maybe SomeException)
firstFailure [] = pure Nothing
firstFailure (done : rest) = do
  result <- tryReadMVar done
  case result of Just (Left e) -> pure (Just e); _ -> firstFailure rest

closeQuietly :: Handle -> IO ()
closeQuietly handle = do
  _ <- try (hClose handle) :: IO (Either IOException ())
  pure ()