Skip to content

Commit c91c6eb

Browse files
Fix ENOENT 127 code; stop forever block on ENOENT; cleanup (#15)
* Reference CP functions via module alias * Convert ENOENT to 127; don't block forever * Define processFinishedFiber more locally * Define timeout refs more locally * Use simpler kill * Start processFinished as fiber then join later * Define mkStdIoFiber more locally * Add type sig to mainFiber * Bump node to lts
1 parent 9b4a489 commit c91c6eb

File tree

3 files changed

+78
-66
lines changed

3 files changed

+78
-66
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ jobs:
2222
- name: Set up Node toolchain
2323
uses: actions/setup-node@v3
2424
with:
25-
node-version: "16"
25+
node-version: "lts/*"
2626

2727
- name: Cache NPM dependencies
2828
uses: actions/cache@v3

src/Node/Library/Execa.purs

Lines changed: 72 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ import Foreign.Object (Object)
4242
import Foreign.Object as Object
4343
import Node.Buffer (Buffer)
4444
import Node.Buffer as Buffer
45-
import Node.ChildProcess (ChildProcess, kill', pid)
45+
import Node.ChildProcess (ChildProcess)
4646
import Node.ChildProcess as CP
4747
import Node.ChildProcess.Aff (waitSpawned)
4848
import Node.ChildProcess.Types (Exit(..), KillSignal, StdIO, customShell, fromKillSignal, fromKillSignal', stringSignal)
@@ -62,6 +62,7 @@ import Node.Process as Process
6262
import Node.Stream (Readable, Writable, destroy)
6363
import Node.Stream as Stream
6464
import Node.UnsafeChildProcess.Unsafe as Unsafe
65+
import Partial.Unsafe (unsafeCrashWith)
6566
import Record as Record
6667
import Type.Proxy (Proxy(..))
6768
import Unsafe.Coerce (unsafeCoerce)
@@ -321,12 +322,7 @@ execa file args buildOptions = do
321322
}
322323
)
323324
stdinErrRef <- liftEffect $ Ref.new Nothing
324-
timeoutRef <- liftEffect $ Ref.new Nothing
325325
canceledRef <- liftEffect $ Ref.new false
326-
clearKillOnTimeoutRef <- liftEffect $ Ref.new (mempty :: Effect Unit)
327-
let
328-
clearKillOnTimeout :: Effect Unit
329-
clearKillOnTimeout = join $ Ref.read clearKillOnTimeoutRef
330326
spawnedFiber <- suspendAff $ waitSpawned spawned
331327

332328
processSpawnedFiber <- do
@@ -338,7 +334,7 @@ execa file args buildOptions = do
338334
( do
339335
liftEffect do
340336
removal <- SignalExit.onExit \_ _ -> do
341-
void $ execaKill (Just $ stringSignal "SIGTERM") Nothing spawned
337+
void $ CP.kill' (stringSignal "SIGTERM") spawned
342338
Ref.write (Just removal) removeHandlerRef
343339
joinFiber spawnedFiber
344340
)
@@ -350,70 +346,61 @@ execa file args buildOptions = do
350346
let
351347
cancel :: Aff Unit
352348
cancel = liftEffect do
353-
killSucceeded <- execaKill (Just $ stringSignal "SIGTERM") Nothing spawned
349+
killSucceeded <- CP.kill' (stringSignal "SIGTERM") spawned
354350
when killSucceeded do
355351
Ref.write true canceledRef
356352

357-
let
358-
processFinishedFiber :: Aff Exit
359-
processFinishedFiber = makeAff \done -> do
360-
spawned # once_ CP.exitH \exitResult -> do
361-
clearKillOnTimeout
362-
done $ Right exitResult
363-
pure nonCanceler
364-
365-
let
366-
mkStdIoFiber
367-
:: Readable ()
368-
-> Aff (Fiber { text :: String, error :: Maybe Error })
369-
mkStdIoFiber stream = forkAff do
370-
streamResult <- getStreamBuffer stream { maxBuffer: Just parsed.options.maxBuffer }
371-
text <- liftEffect do
372-
buf <- handleOutput { stripFinalNewline: parsed.options.stripFinalNewline } streamResult.buffer
373-
text <- Buffer.toString parsed.options.encoding buf
374-
when (isJust streamResult.inputError) do
375-
destroy stream
376-
pure text
377-
pure { text, error: streamResult.inputError }
378-
379353
let
380354
mainFiber
381355
:: Maybe (Pid -> Aff Unit)
382-
-> Aff _
356+
-> Aff ExecaResult
383357
mainFiber postSpawn = do
384358
res <- joinFiber processSpawnedFiber
385359
case res of
386-
Left err -> do
387-
exit <- processFinishedFiber
360+
Left err -> liftEffect do
361+
-- If the process fails to spawn, an `exit` event will not be emitted.
362+
-- So, get that information via `exitCode`/`signalCode` and combine here.
363+
let gotENOENT = SystemError.code err == "ENOENT"
364+
unfixedExitCode' <- CP.exitCode spawned
365+
signalCode' <- CP.signalCode spawned
388366
let
389-
exitResult :: { exit :: Maybe Int, signal :: Maybe KillSignal }
390-
exitResult = case exit of
391-
Normally i -> { exit: Just i, signal: Nothing }
392-
BySignal s -> { exit: Nothing, signal: Just s }
393-
liftEffect do
394-
canceled <- Ref.read canceledRef
395-
killed' <- CP.killed spawned
396-
timeout <- Ref.read timeoutRef
397-
pure $
398-
mkExecaResult
399-
{ spawnError: Just err
400-
, pid: Nothing
401-
, stdinErr: Nothing
402-
, stdoutErr: Nothing
403-
, stderrErr: Nothing
404-
, exitStatus: exit
405-
, exitCode: exitResult.exit
406-
, signal: exitResult.signal <|> timeout
407-
, stdout: ""
408-
, stderr: ""
409-
, command
410-
, escapedCommand
411-
, execaOptions: parsed.options
412-
, timedOut: false
413-
, canceled
414-
, killed: killed'
415-
}
367+
exitCode' = case unfixedExitCode' of
368+
Just _ | gotENOENT -> Just 127
369+
x -> x
370+
371+
exitStatus :: Exit
372+
exitStatus = case exitCode', signalCode' of
373+
Just i, _ -> Normally i
374+
_, Just s -> BySignal $ stringSignal s
375+
_, _ -> unsafeCrashWith $ "Impossible: either exit or signal should be non-null"
376+
canceled <- Ref.read canceledRef
377+
killed' <- CP.killed spawned
378+
pure $
379+
mkExecaResult
380+
{ spawnError: Just err
381+
, pid: Nothing
382+
, stdinErr: Nothing
383+
, stdoutErr: Nothing
384+
, stderrErr: Nothing
385+
, exitStatus
386+
, exitCode: exitCode'
387+
, signal: map stringSignal signalCode'
388+
, stdout: ""
389+
, stderr: ""
390+
, command
391+
, escapedCommand
392+
, execaOptions: parsed.options
393+
, timedOut: false
394+
, canceled
395+
, killed: killed'
396+
}
416397
Right pid -> do
398+
timeoutRef <- liftEffect $ Ref.new Nothing
399+
clearKillOnTimeoutRef <- liftEffect $ Ref.new (mempty :: Effect Unit)
400+
let
401+
clearKillOnTimeout :: Effect Unit
402+
clearKillOnTimeout = join $ Ref.read clearKillOnTimeoutRef
403+
417404
-- Setup a timeout if there is one.
418405
-- It'll be cleared when the process finishes.
419406
void $ forkAff do
@@ -423,7 +410,7 @@ execa file args buildOptions = do
423410
tid <- setTimeout ((unsafeCoerce :: Milliseconds -> Int) milliseconds) do
424411
killed' <- CP.killed spawned
425412
unless killed' do
426-
void $ execaKill (Just signal) Nothing spawned
413+
void $ CP.kill' signal spawned
427414
mbPid <- CP.pid spawned
428415
for_ mbPid \_ -> do
429416
-- stdin/out/err only exist if child process has spawned
@@ -445,13 +432,33 @@ execa file args buildOptions = do
445432
-- allow end-user to use child process before code is finished.
446433
for_ postSpawn \callback -> callback pid
447434

435+
processFinishedFiber :: Fiber Exit <- forkAff $ makeAff \done -> do
436+
spawned # once_ CP.exitH \exitResult -> do
437+
clearKillOnTimeout
438+
done $ Right exitResult
439+
pure nonCanceler
440+
441+
let
442+
mkStdIoFiber
443+
:: Readable ()
444+
-> Aff (Fiber { text :: String, error :: Maybe Error })
445+
mkStdIoFiber stream = forkAff do
446+
streamResult <- getStreamBuffer stream { maxBuffer: Just parsed.options.maxBuffer }
447+
text <- liftEffect do
448+
buf <- handleOutput { stripFinalNewline: parsed.options.stripFinalNewline } streamResult.buffer
449+
text <- Buffer.toString parsed.options.encoding buf
450+
when (isJust streamResult.inputError) do
451+
destroy stream
452+
pure text
453+
pure { text, error: streamResult.inputError }
454+
448455
-- Setup fibers to get stdout/stderr
449456
stdoutFiber <- mkStdIoFiber (CP.stdout spawned)
450457
stderrFiber <- mkStdIoFiber (CP.stderr spawned)
451458

452459
-- now wait for the result
453460
result <- sequential $ { exit: _, stdout: _, stderr: _ }
454-
<$> (parallel $ processFinishedFiber)
461+
<$> (parallel $ joinFiber processFinishedFiber)
455462
<*> (parallel $ joinFiber stdoutFiber)
456463
<*> (parallel $ joinFiber stderrFiber)
457464

@@ -529,7 +536,7 @@ execa file args buildOptions = do
529536
void $ Stream.pipe (CP.stderr spawned) Process.stderr
530537
}
531538
, waitSpawned: do
532-
mbPid <- liftEffect $ pid spawned
539+
mbPid <- liftEffect $ CP.pid spawned
533540
case mbPid of
534541
Just p -> pure $ Right p
535542
Nothing -> waitSpawned spawned
@@ -714,15 +721,15 @@ execaKill
714721
execaKill mbKillSignal forceKillAfterTimeout cp = do
715722
let
716723
killSignal = fromMaybe (stringSignal "SIGTERM") mbKillSignal
717-
killSignalSucceeded <- kill' killSignal cp
724+
killSignalSucceeded <- CP.kill' killSignal cp
718725
let
719726
mbTimeout = do
720727
guard $ isSigTerm killSignal
721728
guard killSignalSucceeded
722729
forceKillAfterTimeout
723730
for_ mbTimeout \(Milliseconds timeout) -> do
724731
t <- runEffectFn2 setTimeoutImpl (floor timeout) do
725-
void $ kill' (stringSignal "SIGKILL") cp
732+
void $ CP.kill' (stringSignal "SIGKILL") cp
726733
t.unref
727734
pure killSignalSucceeded
728735
where

test/Test/Node/Library/Execa.purs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ spec = describe "execa" do
4141
case result.exit of
4242
Normally 0 -> result.stdout `shouldEqual` "test"
4343
_ -> fail result.message
44+
it "ENOENT should produce exit code 127" do
45+
result <- _.getResult =<< execa "this-does-not-exist" [] identity
46+
case result.exit of
47+
Normally 127 -> mempty
48+
_ -> fail $ "Should have gotten exit 127. " <> show result
4449
describe "using sleep files" do
4550
let
4651
shellCmd = if isWindows then "pwsh" else "sh"

0 commit comments

Comments
 (0)