packages feed

cradle-0.0.0.0: src/Cradle/ProcessConfiguration.hs

{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}

module Cradle.ProcessConfiguration
  ( ProcessConfiguration (..),
    StdinConfig (..),
    OutputStreamConfig (..),
    silenceDefault,
    addHandle,
    cmd,
    ProcessResult (..),
    runProcess,
  )
where

import Control.Concurrent
import Control.Exception
import Control.Monad
import Data.ByteString (ByteString, hGetContents, hGetSome, hPut, null)
import System.Environment (getEnvironment)
import System.Exit
import System.IO (Handle)
import System.Posix.Internals (hostIsThreaded)
import System.Process
  ( CreateProcess (..),
    ProcessHandle,
    StdStream (..),
    cleanupProcess,
    createProcess_,
    proc,
    waitForProcess,
  )

data ProcessConfiguration = ProcessConfiguration
  { executable :: String,
    arguments :: [String],
    environmentModification :: Maybe ([(String, String)] -> [(String, String)]),
    workingDir :: Maybe FilePath,
    throwOnError :: Bool,
    stdinConfig :: StdinConfig,
    stdoutConfig :: OutputStreamConfig,
    stderrConfig :: OutputStreamConfig,
    delegateCtlc :: Bool
  }

data StdinConfig
  = InheritStdin
  | UseStdinHandle Handle
  | NoStdinStream

data OutputStreamConfig = OutputStreamConfig
  { capture :: Bool,
    -- | Handles that the user set for the output stream.
    --
    -- @Nothing@ means use the default behavior (which depends on the @capture@
    -- field).
    setHandles :: Maybe [Handle]
  }
  deriving stock (Show)

defaultOutputStreamConfig :: OutputStreamConfig
defaultOutputStreamConfig = OutputStreamConfig False Nothing

silenceDefault :: OutputStreamConfig -> OutputStreamConfig
silenceDefault config =
  config
    { setHandles = case setHandles config of
        Nothing -> Just []
        Just handles -> Just handles
    }

addHandle :: Handle -> OutputStreamConfig -> OutputStreamConfig
addHandle handle config =
  config
    { setHandles = case setHandles config of
        Nothing -> Just [handle]
        Just hs -> Just $ handle : hs
    }

cmd :: String -> ProcessConfiguration
cmd executable =
  ProcessConfiguration
    { executable = executable,
      arguments = [],
      environmentModification = Nothing,
      workingDir = Nothing,
      throwOnError = True,
      stdinConfig = InheritStdin,
      stdoutConfig = defaultOutputStreamConfig,
      stderrConfig = defaultOutputStreamConfig,
      delegateCtlc = False
    }

data ProcessResult = ProcessResult
  { exitCode :: ExitCode,
    stdout :: Maybe ByteString,
    stderr :: Maybe ByteString,
    processConfiguration :: ProcessConfiguration
  }

runProcess :: ProcessConfiguration -> IO ProcessResult
runProcess config = do
  assertThreadedRuntime
  let stdoutHandler = outputStreamHandler $ stdoutConfig config
      stderrHandler = outputStreamHandler $ stderrConfig config
  environment <- forM (environmentModification config) $ \f -> do
    f <$> getEnvironment
  withCreateProcess
    "Cradle.run"
    ( (proc (executable config) (arguments config))
        { cwd = workingDir config,
          std_in = case stdinConfig config of
            InheritStdin -> Inherit
            UseStdinHandle handle -> UseHandle handle
            NoStdinStream -> NoStream,
          std_out = stdStream stdoutHandler,
          std_err = stdStream stderrHandler,
          delegate_ctlc = delegateCtlc config,
          env = environment
        }
    )
    $ \_ mStdout mStderr handle -> do
      waitForStdoutCapture <- startCapturing stdoutHandler mStdout
      waitForStderrCapture <- startCapturing stderrHandler mStderr
      exitCode <- waitForProcess handle
      throwWhenNonZero config exitCode
      stdout <- waitForStdoutCapture
      stderr <- waitForStderrCapture
      return $
        ProcessResult
          { stdout,
            stderr,
            exitCode,
            processConfiguration = config
          }

withCreateProcess :: String -> CreateProcess -> (Maybe Handle -> Maybe Handle -> Maybe Handle -> ProcessHandle -> IO a) -> IO a
withCreateProcess message createProcess action =
  bracket
    (createProcess_ message createProcess)
    cleanupProcess
    (\(mStdin, mStdout, mStderr, processHandle) -> action mStdin mStdout mStderr processHandle)

data OutputStreamHandler = OutputStreamHandler
  { stdStream :: StdStream,
    startCapturing :: Maybe Handle -> IO (IO (Maybe ByteString))
  }

maxBufferSize :: Int
maxBufferSize = 1024 * 1024

outputStreamHandler :: OutputStreamConfig -> OutputStreamHandler
outputStreamHandler config =
  OutputStreamHandler
    { stdStream = case config of
        OutputStreamConfig False Nothing -> Inherit
        OutputStreamConfig False (Just [sink]) -> UseHandle sink
        OutputStreamConfig _ _ -> CreatePipe,
      startCapturing = case config of
        OutputStreamConfig False Nothing -> expectNoHandle $ return $ return Nothing
        OutputStreamConfig True Nothing -> expectHandle $ \handle -> do
          mvar <- newEmptyMVar
          _ <- forkIO $ hGetContents handle >>= putMVar mvar
          return $ Just <$> readMVar mvar
        OutputStreamConfig False (Just []) -> expectHandle $ \_handle -> return $ return Nothing
        OutputStreamConfig False (Just [_sink]) -> expectNoHandle $ return $ return Nothing
        OutputStreamConfig False (Just sinks) -> expectHandle $ \handle -> do
          mvar <- newEmptyMVar
          _ <- forkIO $ do
            let loop = do
                  chunk <- hGetSome handle maxBufferSize
                  if Data.ByteString.null chunk
                    then return ()
                    else do
                      forM_ sinks $ \sink -> hPut sink chunk
                      loop
            loop
            putMVar mvar ()
          return $ do
            readMVar mvar
            return Nothing
        OutputStreamConfig True (Just sinks) -> expectHandle $ \handle -> do
          mvar <- newEmptyMVar
          _ <- forkIO $ do
            let loop acc = do
                  chunk <- hGetSome handle maxBufferSize
                  if Data.ByteString.null chunk
                    then return acc
                    else do
                      forM_ sinks $ \sink -> hPut sink chunk
                      loop $ acc <> chunk
            loop mempty >>= putMVar mvar
          return $ Just <$> readMVar mvar
    }
  where
    expectNoHandle :: IO a -> Maybe Handle -> IO a
    expectNoHandle action = \case
      Just _ -> throwIO $ ErrorCall "outputStreamHandler: pipe created unexpectedly"
      Nothing -> action

    expectHandle :: (Handle -> IO a) -> Maybe Handle -> IO a
    expectHandle action = \case
      Nothing -> throwIO $ ErrorCall "outputStreamHandler: pipe not created unexpectedly"
      Just handle -> action handle

assertThreadedRuntime :: IO ()
assertThreadedRuntime =
  when (not hostIsThreaded) $ do
    throwIO $ ErrorCall "Cradle needs the ghc's threaded runtime system to work correctly. Use the ghc option '-threaded'."

throwWhenNonZero :: ProcessConfiguration -> ExitCode -> IO ()
throwWhenNonZero config exitCode = do
  when (throwOnError config) $ do
    case exitCode of
      ExitSuccess -> return ()
      ExitFailure exitCode -> do
        throwIO $
          ErrorCall $
            "command failed with exitcode "
              <> show exitCode
              <> ": "
              <> executable config