{-# LANGUAGE ScopedTypeVariables #-} {-| Shell is a threaded manager that can run external processes and call functions of the `Driver` on process output/err/exit. -} module Shellout (Driver(..), Shell, TaskName, Cmd, new) where import Control.Concurrent (MVar, newEmptyMVar, putMVar, takeMVar) import Control.Concurrent.Async (Async, async, link) import Control.Concurrent.STM (TQueue, atomically, newTQueue, readTQueue, tryReadTQueue, writeTQueue) import Control.Monad (unless) import Data.Text (Text, unpack) import Data.Text.IO (hGetLine) import GHC.IO.Handle (Handle) import System.Exit (ExitCode (..), exitWith) import System.IO (hIsEOF) import System.Process.Typed (closed, createPipe, getStderr, getStdout, setStderr, setStdin, setStdout, shell, waitExitCode, withProcess) -------------------------------------------------------------------------------- -- TYPES -- | Driver is a collection of functions that -- describe what to do on process output data Driver a = Driver { -- | (optionally) do something with the task name, and initialize -- data that will be passed between your handlers. you could store -- things like the task name, a spinner's position and last spin time, etc. initialState :: Text -> a -- | what to do when polling returns `Nothing`, so it's waiting on -- more output from the task, but the task still hasn't exited. -- usually it's sleep. , handleNothing :: a -> IO a -- | what to do on stdout from the shell command. You could colorize it, -- append it to list of output (you could keep a list in the `a`), etc. , handleOut :: a -> Text -> IO a -- | what to do on stderr. Same things go as stdout. , handleErr :: a -> Text -> IO a -- | what to do when a task completes successfully , handleSuccess :: a -> IO () -- | what to do when a task doesn't complete successfully , handleFailure :: a -> IO () } -- | The output of running an external process data Output = Msg Text | Err Text | Success | Failure Int -- | Processor has an input channel (for sending commands) -- and an output channel (for reading the output) data Processor = Processor (TQueue Text) (TQueue Output) -- | Shell takes a task name and an external command and -- executes the given callbacks in the provided driver type Shell = (TaskName -> Cmd -> IO ()) -- | Task is the description of an external process type TaskName = Text -- | Cmd is the external command like 'cat file.txt' to run type Cmd = Text -------------------------------------------------------------------------------- -- API -- | creates a new processor to run external processes in, -- spawns a thread to run the processor loop, then returns -- a `Shell` that can send commands to the processor loop, -- and react to them with functions from the `Driver` new :: Driver a -> IO Shell new driver = do processor <- Processor <$> newChan <*> newChan _ <- spawn $ processorLoop processor pure $ execute processor driver where newChan = atomically newTQueue -------------------------------------------------------------------------------- -- EXECUTE / OUTPUT LOOP -- | executes the given command in the processor execute :: forall a. Processor -> Driver a -> TaskName -> Cmd -> IO () execute (Processor input output) driver task cmd = do send input cmd -- send the command to the Processor thread loop (initialState driver task) -- start the output loop where -- | try to read from the channel, returning Nothing if no value available maybeReceive = atomically . tryReadTQueue loop :: a -> IO () loop acc = do out <- maybeReceive output case out of Nothing -> do newAcc <- handleNothing driver acc loop newAcc Just (Msg msg) -> do newAcc <- handleOut driver acc msg loop newAcc Just (Err msg) -> do newAcc <- handleErr driver acc msg loop newAcc Just Success -> handleSuccess driver acc Just (Failure c) -> do handleFailure driver acc exitWith $ ExitFailure c -------------------------------------------------------------------------------- -- | PROCESSOR LOOP -- | run the command, putting any stdout, stderr, and exits into the output channel. -- will wait until stdout and stderr are empty to write the exit code. processorLoop :: Processor -> IO () processorLoop processor@(Processor input output) = do cmd <- atomically $ readTQueue input -- receive input let config = setStdin closed $ setStdout createPipe $ setStderr createPipe $ shell (unpack cmd) withProcess config $ \p -> do stdoutLock <- newEmptyMVar -- create locks for stdout/stderr stderrLock <- newEmptyMVar _ <- spawn $ sendOutput Msg (getStdout p) stdoutLock _ <- spawn $ sendOutput Err (getStderr p) stderrLock code <- waitExitCode p -- wait for the exit and output locks takeMVar stdoutLock takeMVar stderrLock let result = case code of ExitSuccess -> Success ExitFailure i -> Failure i send output result processorLoop processor where -- | read from the handle until it's empty, writing the result -- (wrapped in the given wrapper type) to the output channel -- and then releasing the given lock sendOutput :: (Text -> Output) -> Handle -> MVar () -> IO () sendOutput wrap handle lock = do let loop = do isDone <- hIsEOF handle unless isDone $ do out <- hGetLine handle send output $ wrap out loop loop putMVar lock () -- release the lock -------------------------------------------------------------------------------- -- THREAD AND CHANNEL HELPERS -- | new async thread, linked to calling thread (will rethrow exceptions on linked thread) spawn :: IO a -> IO (Async a) spawn x = do thread <- async x link thread pure thread -- | write to the channel send :: TQueue a -> a -> IO () send x = atomically . writeTQueue x