pms-infra-cmdrun-0.1.1.0: src/PMS/Infra/CmdRun/DS/Utility.hs
{-# LANGUAGE CPP #-}
{-# LANGUAGE OverloadedStrings #-}
module PMS.Infra.CmdRun.DS.Utility where
import System.Log.FastLogger
import qualified Control.Exception.Safe as E
import qualified Control.Concurrent as CC
import Control.Monad.IO.Class
import Control.Monad.Except
import Control.Monad.Reader
import qualified Control.Concurrent.STM as STM
import System.Exit
import Control.Lens
import System.Process
import System.IO
import Control.Concurrent.Async (async, cancel, concurrently, wait)
import qualified Data.ByteString as BS
import qualified PMS.Domain.Model.DM.Type as DM
import qualified PMS.Domain.Model.DS.Utility as DM
import PMS.Infra.CmdRun.DM.Type
-- |
--
runApp :: DM.DomainData -> AppData -> TimedFastLogger -> AppContext a -> IO (Either DM.ErrorData a)
runApp domDat appDat logger ctx =
DM.runFastLoggerT domDat logger
$ runExceptT
$ flip runReaderT domDat
$ runReaderT ctx appDat
-- |
--
liftIOE :: IO a -> AppContext a
liftIOE f = liftIO (go f) >>= liftEither
where
go :: IO b -> IO (Either String b)
go x = E.catchAny (Right <$> x) errHdl
errHdl :: E.SomeException -> IO (Either String a)
errHdl = return . Left . show
-- |
--
toolsCallResponse :: STM.TQueue DM.McpResponse
-> DM.JsonRpcRequest
-> ExitCode
-> String
-> String
-> IO ()
toolsCallResponse resQ jsonRpc code outStr errStr = do
let content = [ DM.McpToolsCallResponseResultContent "text" outStr
, DM.McpToolsCallResponseResultContent "text" errStr
]
result = DM.McpToolsCallResponseResult {
DM._contentMcpToolsCallResponseResult = content
, DM._isErrorMcpToolsCallResponseResult = (ExitSuccess /= code)
}
resDat = DM.McpToolsCallResponseData jsonRpc result
res = DM.McpToolsCallResponse resDat
STM.atomically $ STM.writeTQueue resQ res
-- |
--
errorToolsCallResponse :: DM.JsonRpcRequest -> String -> AppContext ()
errorToolsCallResponse jsonRpc errStr = do
let content = [ DM.McpToolsCallResponseResultContent "text" errStr ]
result = DM.McpToolsCallResponseResult {
DM._contentMcpToolsCallResponseResult = content
, DM._isErrorMcpToolsCallResponseResult = True
}
resDat = DM.McpToolsCallResponseData jsonRpc result
res = DM.McpToolsCallResponse resDat
resQ <- view DM.responseQueueDomainData <$> lift ask
liftIOE $ STM.atomically $ STM.writeTQueue resQ res
-- | Run a shell command and collect stdout and stderr concurrently.
-- Reading stdout and stderr sequentially risks a deadlock when the child
-- process produces large stderr output: the OS pipe buffer fills up,
-- blocking the process before it can write to stdout, which in turn
-- prevents hGetContents hout from returning.
-- Using 'concurrently' drains both handles in separate threads so neither
-- pipe can stall the other.
runCommandBS :: String -> IO (ExitCode, BS.ByteString, BS.ByteString)
runCommandBS cmd = do
(hout, herr, ph) <- openCommandPipes cmd
(out, err) <- concurrently
(BS.hGetContents hout) -- read stdout in one thread
(BS.hGetContents herr) -- read stderr concurrently
exitCode <- waitForProcess ph
return (exitCode, out, err)
-- | Run a shell command with a timeout and collect stdout and stderr concurrently.
-- The timeout is handled while the ProcessHandle is still available, so the
-- child process and pipe readers can be released immediately.
runCommandBSWithTimeout :: Int -> String -> IO (ExitCode, BS.ByteString, BS.ByteString)
runCommandBSWithTimeout tout cmd = do
(hout, herr, ph) <- openCommandPipes cmd
aOut <- async (BS.hGetContents hout)
aErr <- async (BS.hGetContents herr)
ret <- waitForProcessOrTimeout tout ph
case ret of
Just exitCode -> do
out <- wait aOut
err <- wait aErr
return (exitCode, out, err)
Nothing -> do
terminateProcess ph
cancel aOut
cancel aErr
return (ExitFailure 1, BS.empty, "timeout occurred.")
where
waitForProcessOrTimeout :: Int -> ProcessHandle -> IO (Maybe ExitCode)
waitForProcessOrTimeout timeoutMicrosec ph = go timeoutMicrosec
where
pollIntervalMicrosec :: Int
pollIntervalMicrosec = 100 * 1000
go :: Int -> IO (Maybe ExitCode)
go remaining
| remaining <= 0 = return Nothing
| otherwise = do
mExitCode <- getProcessExitCode ph
case mExitCode of
Just exitCode -> return $ Just exitCode
Nothing -> do
let waitMicrosec = min pollIntervalMicrosec remaining
CC.threadDelay waitMicrosec
go (remaining - waitMicrosec)
openCommandPipes :: String -> IO (Handle, Handle, ProcessHandle)
openCommandPipes cmd = do
(hin, hout, herr, ph) <- createShellProcess cmd
hClose hin
hSetBinaryMode hout True -- disable newline translation (important on Windows)
hSetBinaryMode herr True -- disable newline translation (important on Windows)
return (hout, herr, ph)
-- | Create a shell process for the given command string.
-- On Windows, the .bat scripts are expected to call "chcp 65001" at their
-- start to switch the code page to UTF-8, so that both built-in commands
-- (echo, move, copy, etc.) and external commands (tree.com, xcopy.exe, etc.)
-- emit UTF-8 output that can be decoded uniformly with decodeUtf8With.
createShellProcess :: String -> IO (Handle, Handle, Handle, ProcessHandle)
createShellProcess cmd = do
let procSpec = shell cmd
(mHin, mHout, mHerr, ph) <- createProcess procSpec {
std_in = CreatePipe
, std_out = CreatePipe
, std_err = CreatePipe
, use_process_jobs = True
}
case (mHin, mHout, mHerr) of
(Just hin, Just hout, Just herr) -> return (hin, hout, herr, ph)
_ -> E.throwString "createShellProcess: failed to create process pipes."