interprocess-0.2.1.0: test/Tools/Runner.hs
{-# LANGUAGE GADTs #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}
module Tools.Runner (runTests, TestSpec (..)) where
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async
import qualified Control.Concurrent.MVar as StdMVar
import Control.Concurrent.Process.StoredMVar
import Control.Exception (SomeException, catch,
evaluate)
import Control.Monad (foldM, forever, when)
import Control.Monad.STM (atomically)
import qualified Data.ByteString.Char8 as BS
import Data.ByteString.Lazy.Char8 (unpack)
import Data.Foldable (fold)
import Data.IORef (atomicModifyIORef',
newIORef)
import qualified Data.List as List
import qualified Data.Map as Map
import Data.Tuple (swap)
import Foreign.Storable (Storable)
import System.Environment (getArgs,
getExecutablePath)
import System.Exit
import System.IO
import System.Mem (performMajorGC)
import System.Process.Typed
import Text.Read (readMaybe)
import Tools.Handle
import Tools.TestResult
{- |
Specification of a single test.
Defines the test name and the list of roles to be given to subprocesses.
The test spawns as many subprocesses as there are elements in the roles list.
All of them share a single IPC MVar for communication. In addition, the role
actions are parameterized by the role key `k`, which is passed via the command-line
arguments using the Show-Read instances.
-}
data TestSpec where
TestSpec :: forall k ctx
. (Show k, Read k, Eq k, Storable ctx)
=> String -> [(k, k -> StoredMVar ctx -> IO TestResult)] -> TestSpec
-- | Set the maximum time the test can take in milliseconds.
-- The test process will be forcefully terminated after the time limit elapses.
WithTimeLimit :: Int -> TestSpec -> TestSpec
-- | Repeat the test the specified number of times.
Repeat :: Int -> TestSpec -> TestSpec
-- | Get the name of the test.
testName :: TestSpec -> String
testName (TestSpec name _) = name
testName (WithTimeLimit _ s) = testName s
testName (Repeat _ s) = testName s
stripSpec :: TestSpec -> TestSpec
stripSpec (WithTimeLimit _ s) = stripSpec s
stripSpec (Repeat _ s) = stripSpec s
stripSpec s = s
data SpecParams = SpecParams
{ exec :: String -- ^ Executable name
, timeout :: Int -- ^ Terminate process after this amount of milliseconds
, processStartTimeout :: Int -- ^ Fail if the child process not all child processes start within this time
}
defaultSpecParams :: String -> SpecParams
defaultSpecParams execFile = SpecParams
{ exec = execFile
, timeout = 1000
, processStartTimeout = 10000
}
-- | A random string for a handshake between the processes.
startToken :: String
startToken = "KxmpaDIlwnSlfp01==123=123=1"
-- | Run all tests in the list. Use it as the `main`:
--
-- @
-- main :: IO ()
-- main = runTests [...]
-- @
runTests :: [TestSpec] -> IO ()
runTests specs = do
execFile <- getExecutablePath
args <- getArgs
case args of
-- run all tests
[] -> do
results <- mapM (runSpec $ defaultSpecParams execFile) specs
case fold results of
Success -> exitSuccess
Failure _ -> exitFailure
-- run particular test routine
[n, sk, sm] -> do
hSetBuffering stdout LineBuffering
putStrLn startToken
returnedToken <- getLine
when (startToken /= returnedToken) $ fail "Child start handshake failed."
TestSpec _ roles <- case List.find ((== n) . testName) specs of
Nothing -> fail $ "Unknown test name: " ++ show n
Just spec -> pure $ stripSpec spec
k <- case readMaybe sk of
Nothing -> fail $ "Couldn't read the role " ++ show sk
Just x -> pure x
m <- case readMaybe sm of
Nothing -> fail $ "Couldn't read the mvar name " ++ show sm
Just ref -> lookupMVar ref
case List.find ((== k) . fst) roles of
Nothing -> fail $ "Unknown test role " ++ show k ++ " for test " ++ show n
Just (_ , action) -> action k m >>= finish
-- unexpected
_ -> fail $ "Expected zero or three arguments, got " ++ show args
runSpec :: SpecParams -> TestSpec -> IO TestResult
runSpec pams (WithTimeLimit t s) = runSpec (pams {timeout = t}) s
runSpec pams (Repeat n s) = do
putStrLn $ "[" ++ testName s ++ "] Running " ++ show n ++ " times..."
r <- foldM go Success [1..n]
putStrLn $ "[" ++ testName s ++ "] Result: " ++ displayResult r
return r
where
go Success i = do
(out, r) <- redirectHandle stdout $ do
r <- runSpec pams s
r `seq` performMajorGC
return r
case r of
Success -> pure ()
_ -> do
putStrLn $ "[" ++ testName s ++ "] Failed on test iteration " ++ show i
BS.putStrLn out
return r
go failure _ = pure failure
runSpec pams (TestSpec name (roles :: [(k, k -> StoredMVar ctx -> IO TestResult)])) = do
putStrLn $ "[" ++ name ++ "] Running..."
comms <- newEmptyMVar :: IO (StoredMVar ctx)
childrenLeft <- newIORef $ length roles
readyToGo <- StdMVar.newEmptyMVar :: IO (StdMVar.MVar ())
let k = show $ mVarName comms
r <- runConcurrently $ foldMap (\(l, r) -> go childrenLeft readyToGo l [name, r, k]) labelsroles
r `seq` comms `seq` performMajorGC
putStrLn $ "[" ++ name ++ "] Result: " ++ displayResult r
return r
where
roleCount :: Map.Map String Int
roleCount = Map.filter (> 1) $
List.foldl' (\m (k, _) -> Map.alter (Just . maybe 1 succ) (show k) m) mempty roles
labelsroles :: [(String, String)]
labelsroles = snd $ List.mapAccumR (\m (k, _) -> swap $ Map.alterF (
-- Maybe Int -> ((String, String), Maybe Int)
maybe ((show k, show k), Nothing) (\i -> ((show k ++ "-" ++ show i, show k), Just (pred i)))
) (show k) m ) roleCount roles
withLabel label s = " [" ++ label ++ "] " ++ s
channelHandle i handle = forever (hGetLine handle >>= (putStrLn . withLabel i))
`catch` (const $ return mempty :: IOError -> IO ())
mkConf x
= setStdout createPipe
$ setStdin createPipe
$ setStderr byteStringOutput
$ proc (exec pams) x
errToResult :: String -> SomeException -> IO TestResult
errToResult label = return . Failure . withLabel label . show
messWithProcess label x action = withProcessTerm (mkConf x) action `catch` errToResult label
go childrenLeft readyToGo label x = Concurrently
$ messWithProcess label x $ \p -> hGetLine (getStdout p) >>= \handshake ->
if handshake /= startToken
then return (Failure $ withLabel label ("Unexpected starting token: " ++ handshake))
else withAsyncBound (channelHandle label (getStdout p)) $ \sink -> do
iAmLast <- atomicModifyIORef' childrenLeft (\n -> (n - 1, n == 1))
when iAmLast $ StdMVar.putMVar readyToGo ()
ready <- race (threadDelay $ 1000 * processStartTimeout pams) (StdMVar.readMVar readyToGo)
case ready of
Left () -> return $ Failure $ withLabel label "child process didn't start in time"
Right () -> do
hPutStrLn (getStdin p) handshake
hClose (getStdin p)
r <- race (threadDelay $ 1000 * timeout pams) $ do
ecode <- (Right <$> waitExitCode p) `catch` (return . Left)
case ecode of
Right ExitSuccess -> pure Success
Right (ExitFailure _) -> do
errs <- atomically (getStderr p)
return $ Failure (unlines . map (withLabel label) $ lines $ unpack errs)
Left e -> errToResult label e
_ <- evaluate r
wait sink
hFlush stdout
hFlush stderr
case r of
Left () -> return $ Failure $ withLabel label $
"the process did not finish in " ++ show (timeout pams) ++ " milliseconds"
Right s -> return s