packages feed

baikai-claude-0.7.1.1: test/CliProcessFixture.hs

{-# LANGUAGE CPP #-}

-- | Real, offline subprocess fixtures shared by core and adapter regressions.
-- Keep the copies in each package identical: each source distribution must
-- contain its own test dependencies.
module CliProcessFixture
  ( Fixture (..),
    withFixture,
    awaitReady,
    assertStopped,
    cancelAndJoin,
    processState,
    writeExecutable,
  )
where

import Control.Concurrent (forkFinally, forkIO, killThread, threadDelay)
import Control.Concurrent.MVar (readMVar)
import Control.Concurrent.MVar qualified
import Control.Exception (SomeAsyncException, bracket, finally, fromException, try)
import Control.Monad (forM_, unless, void)
import Data.List (isInfixOf)
import System.Directory (doesFileExist, getPermissions, setOwnerExecutable, setPermissions)
import System.Exit (ExitCode (..))
import System.FilePath ((</>))
import System.IO.Temp (withSystemTempDirectory)
import System.Process qualified as P
import System.Timeout (timeout)
import Test.Tasty.HUnit (assertBool, assertFailure)
#ifndef mingw32_HOST_OS
import System.Posix.Signals (sigKILL, signalProcess)
#endif

data Fixture = Fixture
  { directory :: FilePath,
    executable :: FilePath,
    parentFile :: FilePath,
    childFile :: FilePath,
    readyFile :: FilePath
  }

-- The optional prefix can implement a successful normal invocation and hang
-- only in --version. Every fixture sleeps 30s so success cannot be natural expiry.
withFixture :: Bool -> Bool -> String -> (Fixture -> IO a) -> IO a
withFixture resistant early prefix use = withSystemTempDirectory "baikai-cli-cancellation" $ \dir -> do
  let sleeper = dir </> "sleeper"
      parent = dir </> "parent"
      child = dir </> "child"
      ready = dir </> "ready"
      body =
        unlines $
          ["#!/bin/sh", prefix]
            <> ["trap '' INT TERM" | resistant]
            <> [ "echo $$ > '" <> parent <> "'",
                 "sh -c 'echo $$ > \"" <> child <> "\"; exec \"" <> sleeper <> "\" 30' &",
                 "while [ ! -s '" <> child <> "' ]; do sleep 0.01; done",
                 "echo ready > '" <> ready <> "'"
               ]
            <> [if early then "exit 0" else "wait"]
  P.callProcess "/bin/ln" ["-s", "/bin/sleep", sleeper]
  exe <- writeExecutable dir "vendor" body
  let fixture = Fixture dir exe parent child ready
  -- Capture process identities before use returns/fails; identity comparison in
  -- emergency cleanup avoids signalling a PID reused after a fixture exits.
  bracket (pure fixture) cleanupFixture use

cleanupFixture :: Fixture -> IO ()
cleanupFixture fixture = forM_ [childFile fixture, parentFile fixture] $ \file -> do
  exists <- doesFileExist file
  if not exists
    then pure ()
    else do
      pid <- read <$> readFile file
      state <- processState pid
      -- Both the script and sleeper executable have this invocation's unique
      -- directory in their command line, including after exec. A reused PID
      -- belonging to a different invocation cannot match that identity.
      (_, identity, _) <- P.readProcessWithExitCode "/bin/ps" ["-p", show pid, "-o", "lstart=,command="] ""
      let ours = (directory fixture <> "/") `isInfixOf` identity
      unless (null state || 'Z' `elem` state || not ours) $ do
        (_, current, _) <- P.readProcessWithExitCode "/bin/ps" ["-p", show pid, "-o", "lstart=,command="] ""
        if current /= identity
          then pure ()
          else do
            killFixturePid pid

killFixturePid :: Int -> IO ()
#ifndef mingw32_HOST_OS
killFixturePid pid = void (try (signalProcess sigKILL (fromIntegral pid)) :: IO (Either IOError ()))
#else
killFixturePid _ = pure ()
#endif

writeExecutable :: FilePath -> String -> String -> IO FilePath
writeExecutable dir name body = do
  let path = dir </> name
  writeFile path body
  perms <- getPermissions path
  setPermissions path (setOwnerExecutable True perms)
  pure path

awaitReady :: Fixture -> IO (Int, Int)
awaitReady fixture = do
  ready <- timeout 2000000 poll
  case ready of
    Nothing -> assertFailure "fixture did not publish readiness within two seconds"
    Just () -> (,) <$> (read <$> readFile (parentFile fixture)) <*> (read <$> readFile (childFile fixture))
  where
    poll = do
      exists <- doesFileExist (readyFile fixture)
      if exists then pure () else threadDelay 10000 >> poll

processState :: Int -> IO String
processState pid = do
  (code, out, err) <- P.readProcessWithExitCode "/bin/ps" ["-p", show pid, "-o", "stat="] ""
  case code of
    ExitSuccess -> pure out
    ExitFailure 1 | null out && null err -> pure ""
    _ -> assertFailure ("ps observation failed: " <> show (code, out, err))

assertStopped :: (Int, Int) -> IO ()
assertStopped (parent, child) = do
  parentState <- processState parent
  assertBool ("direct child remains: " <> parentState) (null parentState)
  childState <- processState child
  assertBool ("descendant is running: " <> childState) (null childState || 'Z' `elem` childState)
  -- Removal of an adopted zombie is diagnostic, distinct from termination.
  unless (null childState) $ do
    removed <- timeout 200000 (let poll = processState child >>= \s -> if null s then pure () else threadDelay 10000 >> poll in poll)
    case removed of
      Nothing -> putStrLn "fixture descendant stopped; adoptive parent has not yet reaped zombie"
      Just () -> pure ()

cancelAndJoin :: Bool -> IO a -> Fixture -> ((Int, Int) -> IO ()) -> IO ()
cancelAndJoin repeated action fixture after = do
  done <- Control.Concurrent.MVar.newEmptyMVar
  tid <- forkFinally (void action) (Control.Concurrent.MVar.putMVar done)
  let stop = void (forkIO (killThread tid))
  ( do
      pids <- awaitReady fixture
      stop
      if repeated then void (forkIO (threadDelay 40000 >> killThread tid)) else pure ()
      -- Keep the original 200ms observation without using it as acknowledgement.
      threadDelay 200000
      diagnostic <- processState (snd pids)
      putStrLn ("descendant state at 200ms: " <> show diagnostic)
      terminal <- timeout 2800000 (readMVar done)
      case terminal of
        Just (Left e) | Just _ <- (fromException e :: Maybe SomeAsyncException) -> after pids
        other -> assertFailure ("expected acknowledged async cancellation within 3s, got " <> show other)
    )
    `finally` do
      stop
      cleanupFixture fixture
      joined <- timeout 3000000 (readMVar done)
      case joined of
        Nothing -> assertFailure "fixture worker failed to finish after emergency cleanup"
        Just _ -> pure ()