-
Notifications
You must be signed in to change notification settings - Fork 213
/
Launcher.hs
380 lines (346 loc) · 12.6 KB
/
Launcher.hs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
{-# LANGUAGE CPP #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE ScopedTypeVariables #-}
{- HLINT ignore "Unused LANGUAGE pragma" -}
-- |
-- Copyright: © 2018-2020 IOHK
-- License: Apache-2.0
--
-- This module contains a mechanism for launching external processes, ensuring
-- that they are terminated on exceptions.
module Cardano.Launcher
( Command (..)
, StdStream(..)
, ProcessHasExited(..)
, withBackendProcess
, withBackendCreateProcess
-- * Logging
, LauncherLog(..)
) where
import Prelude
import Cardano.BM.Data.Severity
( Severity (..) )
import Cardano.BM.Data.Tracer
( HasPrivacyAnnotation (..), HasSeverityAnnotation (..) )
import Cardano.Startup
( killProcess )
import Control.Monad
( join, void )
import Control.Monad.IO.Class
( liftIO )
import Control.Monad.IO.Unlift
( MonadUnliftIO (..) )
import Control.Tracer
( Tracer, contramap, traceWith )
import Data.Either.Combinators
( leftToMaybe )
import Data.List
( isPrefixOf )
import Data.Text
( Text )
import Data.Text.Class
( ToText (..) )
import Fmt
( Buildable (..)
, Builder
, blockListF'
, fmt
, indentF
, (+|)
, (+||)
, (|+)
, (||+)
)
import GHC.Generics
( Generic )
import System.Exit
( ExitCode (..) )
import System.IO
( Handle )
import System.Process
( cleanupProcess, getPid )
import UnliftIO.Async
( race )
import UnliftIO.Concurrent
( forkIO, forkIOWithUnmask, killThread, threadDelay )
import UnliftIO.Exception
( Exception
, IOException
, bracket
, bracket_
, finally
, onException
, tryJust
)
import UnliftIO.MVar
( newEmptyMVar, putMVar, readMVar )
import UnliftIO.Process
( CmdSpec (..)
, CreateProcess (..)
, ProcessHandle
, StdStream (..)
, createProcess
, proc
, waitForProcess
)
import qualified Data.Text as T
-- | Represent a command to execute. Args are provided as a list where options
-- are expected to be prefixed with `--` or `-`. For example:
--
-- @
-- Command "cardano-wallet"
-- [ "server"
-- , "--port", "8080"
-- , "--network", "mainnet"
-- ] (return ())
-- Inherit
-- Inherit
-- @
data Command = Command
{ cmdName :: String
, cmdArgs :: [String]
, cmdSetup :: IO ()
-- ^ An extra action to run _before_ the command
, cmdInput :: StdStream
-- ^ Input to supply to command
, cmdOutput :: StdStream
-- ^ What to do with stdout & stderr
} deriving (Generic)
instance Show Command where
show = show . build
instance Eq Command where
a == b = build a == build b
-- | Format a command nicely with one argument / option per line.
--
-- e.g.
--
-- >>> fmt $ buildCommand "cardano-wallet-server" ["--port", "8080", "--network", "mainnet"] (return ())
-- cardano-wallet-server
-- --port 8080
-- --network mainnet
buildCommand :: String -> [String] -> Builder
buildCommand name args = mconcat [build name, "\n", indentF 4 argsBuilder]
where
argsBuilder = blockListF' "" build $ snd $ foldl buildOptions ("", []) args
buildOptions :: (String, [String]) -> String -> (String, [String])
buildOptions ("", grp) arg =
(arg, grp)
buildOptions (partial, grp) arg =
if ("--" `isPrefixOf` partial) && not ("--" `isPrefixOf` arg) then
("", grp ++ [partial <> " " <> arg])
else
(arg, grp ++ [partial])
instance Buildable Command where
build (Command name args _ _ _) = buildCommand name args
-- | ProcessHasExited is used by a monitoring thread to signal that the process
-- has exited.
data ProcessHasExited
= ProcessDidNotStart String IOException
| ProcessHasExited String ExitCode
deriving (Show, Eq)
instance Exception ProcessHasExited
-- | Starts a command in the background and then runs an action. If the action
-- finishes (through an exception or otherwise) then the process is terminated
-- (see 'withCreateProcess') for details. If the process exits, the action is
-- cancelled. The return type reflects those two cases.
--
-- The action receives the 'ProcessHandle' and stdin 'Handle' as arguments.
withBackendProcess
:: MonadUnliftIO m
=> Tracer m LauncherLog
-- ^ Logging
-> Command
-- ^ 'Command' description
-> (Maybe Handle -> ProcessHandle -> m a)
-- ^ Action to execute while process is running.
-> m (Either ProcessHasExited a)
withBackendProcess tr (Command name args before std_in std_out) action =
liftIO before >> withBackendCreateProcess tr process action
where
process = (proc name args) { std_in, std_out, std_err = std_out }
-- | A variant of 'withBackendProcess' which accepts a general 'CreateProcess'
-- object. This version also has nicer async properties than
-- 'System.Process.withCreateProcess'.
--
-- This function should ensure:
--
-- 1. If the action finishes or throws an exception, then the process is also
-- terminated.
--
-- 2. After the process is sent the signal to terminate, this function will
-- block until the process has actually exited - unless that takes longer
-- than the 5 second timeout. After the timeout has lapsed, the process will
-- be sent a kill signal.
--
-- 3. If the process exits, then the action is cancelled.
--
-- fixme: This is more or less a reimplementation of
-- 'System.Process.Typed.withProcessWait' (except for wait timeout). The
-- launcher code should be converted to use @typed-process@.
withBackendCreateProcess
:: forall m a. (MonadUnliftIO m)
=> Tracer m LauncherLog
-- ^ Logging
-> CreateProcess
-- ^ 'Command' description
-> (Maybe Handle -> ProcessHandle -> m a)
-- ^ Action to execute while process is running.
-> m (Either ProcessHasExited a)
withBackendCreateProcess tr process action = do
traceWith tr $ MsgLauncherStart name args
exitVar <- newEmptyMVar
res <- fmap join $ tryJust spawnPredicate $ bracket
(createProcess process)
(cleanupProcessAndWait (readMVar exitVar)) $
\(mstdin, _, _, ph) -> do
pid <- maybe "-" (T.pack . show) <$> liftIO (getPid ph)
let tr' = contramap (WithProcessInfo name pid) tr
let tr'' = contramap MsgLauncherWait tr'
traceWith tr' MsgLauncherStarted
interruptibleWaitForProcess tr'' ph (putMVar exitVar)
race (ProcessHasExited name <$> readMVar exitVar) $ bracket_
(traceWith tr' MsgLauncherAction)
(traceWith tr' MsgLauncherActionDone)
(action mstdin ph)
traceWith tr $ MsgLauncherFinish (leftToMaybe res)
pure res
where
-- Exceptions resulting from the @exec@ call for this command. The most
-- likely exception is that the command does not exist. We don't want to
-- catch exceptions thrown by the action. I couldn't find a better way of
-- doing this.
spawnPredicate :: IOException -> Maybe ProcessHasExited
spawnPredicate e
| name `isPrefixOf` show e = Just (ProcessDidNotStart name e)
| otherwise = Nothing
-- Run the 'cleanupProcess' function from the process library, but wait for
-- the process to exit, rather than immediately returning. If the process
-- doesn't exit after timeout, kill it, to avoid blocking indefinitely.
cleanupProcessAndWait getExitStatus ps@(_, _, _, ph) = do
traceWith tr MsgLauncherCleanup
liftIO $ cleanupProcess ps
let timeoutSecs = 5
-- Async exceptions are currently masked because this is running in a
-- bracket cleanup handler. We fork a thread and unmask so that the
-- timeout can be cancelled.
tid <- forkIOWithUnmask $ \unmask -> unmask $ do
threadDelay (timeoutSecs * 1000 * 1000)
traceWith tr (MsgLauncherCleanupTimedOut timeoutSecs)
liftIO (getPid ph >>= mapM_ killProcess)
void getExitStatus `finally` killThread tid
traceWith tr MsgLauncherCleanupFinished
-- Wraps 'waitForProcess' in another thread. This works around the unwanted
-- behaviour of the process library on Windows where 'waitForProcess' seems
-- to block all concurrent async actions in the thread.
interruptibleWaitForProcess
:: Tracer m WaitForProcessLog
-> ProcessHandle
-> (ExitCode -> m ())
-> m ()
interruptibleWaitForProcess tr' ph onExit =
void $ forkIO (waitThread `onException` continue)
where
waitThread = do
traceWith tr' MsgWaitBefore
status <- waitForProcess ph
traceWith tr' (MsgWaitAfter status)
onExit status
continue = do
traceWith tr' MsgWaitCancelled
onExit (ExitFailure 256)
(name, args) = getCreateProcessNameArgs process
-- | Recover the command name and arguments from a 'proc', just for logging.
getCreateProcessNameArgs :: CreateProcess -> (FilePath, [String])
getCreateProcessNameArgs process = case cmdspec process of
ShellCommand cmd -> (cmd, [])
RawCommand cmd args -> (cmd, args)
{-------------------------------------------------------------------------------
Logging
-------------------------------------------------------------------------------}
data LauncherLog
= MsgLauncherStart String [String]
| WithProcessInfo String Text LaunchedProcessLog
| MsgLauncherCleanup
| MsgLauncherCleanupTimedOut Int
| MsgLauncherCleanupFinished
| MsgLauncherFinish (Maybe ProcessHasExited)
deriving (Show, Eq, Generic)
data LaunchedProcessLog
= MsgLauncherStarted
| MsgLauncherAction
| MsgLauncherActionDone
| MsgLauncherWait WaitForProcessLog
deriving (Show, Eq, Generic)
data WaitForProcessLog
= MsgWaitBefore
| MsgWaitAfter ExitCode
| MsgWaitCancelled
deriving (Show, Eq, Generic)
instance HasPrivacyAnnotation LauncherLog
instance HasSeverityAnnotation LauncherLog where
getSeverityAnnotation = \case
MsgLauncherStart _ _ -> Notice
WithProcessInfo _ _ msg -> getSeverityAnnotation msg
MsgLauncherFinish Nothing -> Debug
MsgLauncherFinish (Just (ProcessDidNotStart _ _)) -> Error
MsgLauncherFinish (Just (ProcessHasExited _ st)) -> case st of
ExitSuccess -> Notice
ExitFailure _ -> Error
MsgLauncherCleanup -> Debug
MsgLauncherCleanupTimedOut _ -> Notice
MsgLauncherCleanupFinished -> Debug
instance HasPrivacyAnnotation LaunchedProcessLog
instance HasSeverityAnnotation LaunchedProcessLog where
getSeverityAnnotation = \case
MsgLauncherStarted -> Info
MsgLauncherWait msg -> getSeverityAnnotation msg
MsgLauncherAction -> Debug
MsgLauncherActionDone -> Notice
instance HasPrivacyAnnotation WaitForProcessLog
instance HasSeverityAnnotation WaitForProcessLog where
getSeverityAnnotation = \case
MsgWaitBefore -> Debug
MsgWaitAfter _ -> Debug
MsgWaitCancelled -> Debug
instance ToText ProcessHasExited where
toText (ProcessHasExited name code) =
"Child process "+|name|+" exited with "+|statusText code|+""
toText (ProcessDidNotStart name _e) =
"Could not start "+|name|+""
instance ToText LauncherLog where
toText ll = fmt $ case ll of
MsgLauncherStart cmd args ->
"Starting process "+|buildCommand cmd args|+""
WithProcessInfo name pid msg ->
"["+|name|+"."+|pid|+"] "+|toText msg|+""
MsgLauncherFinish Nothing ->
"Action finished"
MsgLauncherFinish (Just exited) -> build $ toText exited
MsgLauncherCleanup ->
"Begin process cleanup"
MsgLauncherCleanupTimedOut t ->
"Timed out waiting for process to exit after "+|t|+" seconds"
MsgLauncherCleanupFinished ->
"Process cleanup finished"
instance ToText LaunchedProcessLog where
toText = \case
MsgLauncherStarted -> "Process started"
MsgLauncherAction -> "Running withBackend action"
MsgLauncherWait msg -> toText msg
MsgLauncherActionDone -> "withBackend action done. Terminating child process"
instance ToText WaitForProcessLog where
toText = \case
MsgWaitBefore ->
"Waiting for process to exit"
MsgWaitAfter status -> fmt $
"Process exited with "+|statusText status|+""
MsgWaitCancelled ->
"There was an exception waiting for the process"
statusText :: ExitCode -> Text
statusText ExitSuccess = "success"
statusText (ExitFailure n)
| n >= 0 = fmt $ "code "+||n||+" (failure)"
| otherwise = fmt $ "signal "+||(-n)||+""