@@ -265,24 +265,30 @@ export const make = Effect.gen(function* () {
265265 }
266266
267267 const spawn = ( command : ChildProcess . StandardCommand , opts : NodeChildProcess . SpawnOptions ) =>
268- Effect . callback < readonly [ NodeChildProcess . ChildProcess , ExitSignal ] , PlatformError . PlatformError > ( ( resume ) => {
269- const signal = Deferred . makeUnsafe < readonly [ code : number | null , signal : NodeJS . Signals | null ] > ( )
268+ Effect . callback <
269+ readonly [ NodeChildProcess . ChildProcess , ExitSignal , ExitSignal ] ,
270+ PlatformError . PlatformError
271+ > ( ( resume ) => {
272+ const exited = Deferred . makeUnsafe < readonly [ code : number | null , signal : NodeJS . Signals | null ] > ( )
273+ const closed = Deferred . makeUnsafe < readonly [ code : number | null , signal : NodeJS . Signals | null ] > ( )
270274 const proc = launch ( command . command , command . args , opts )
271- let end = false
272275 let exit : readonly [ code : number | null , signal : NodeJS . Signals | null ] | undefined
273276 proc . on ( "error" , ( err ) => {
274277 resume ( Effect . fail ( toPlatformError ( "spawn" , err , command ) ) )
275278 } )
279+ // A detached descendant can hold the inherited stdio open after this process
280+ // exits. Report the exit promptly, while keeping "close" for kill/escalation
281+ // and for failed spawns where no exit event is emitted.
276282 proc . on ( "exit" , ( ...args ) => {
277283 exit = args
284+ Deferred . doneUnsafe ( exited , Exit . succeed ( args ) )
278285 } )
279286 proc . on ( "close" , ( ...args ) => {
280- if ( end ) return
281- end = true
282- Deferred . doneUnsafe ( signal , Exit . succeed ( exit ?? args ) )
287+ Deferred . doneUnsafe ( closed , Exit . succeed ( exit ?? args ) )
288+ Deferred . doneUnsafe ( exited , Exit . succeed ( exit ?? args ) )
283289 } )
284290 proc . on ( "spawn" , ( ) => {
285- resume ( Effect . succeed ( [ proc , signal ] ) )
291+ resume ( Effect . succeed ( [ proc , exited , closed ] ) )
286292 } )
287293 return Effect . sync ( ( ) => {
288294 proc . kill ( "SIGTERM" )
@@ -321,26 +327,62 @@ export const make = Effect.gen(function* () {
321327 return Effect . fail ( toPlatformError ( "kill" , new Error ( "Failed to kill child process" ) , command ) )
322328 } )
323329
324- const timeout =
325- (
326- proc : NodeChildProcess . ChildProcess ,
327- command : ChildProcess . StandardCommand ,
328- opts : ChildProcess . KillOptions | undefined ,
329- ) =>
330- < A , E , R > (
331- f : (
332- command : ChildProcess . StandardCommand ,
333- proc : NodeChildProcess . ChildProcess ,
334- signal : NodeJS . Signals ,
335- ) => Effect . Effect < A , E , R > ,
336- ) => {
337- const signal = opts ?. killSignal ?? "SIGTERM"
338- if ( Predicate . isUndefined ( opts ?. forceKillAfter ) ) return f ( command , proc , signal )
339- return Effect . timeoutOrElse ( f ( command , proc , signal ) , {
340- duration : opts . forceKillAfter ,
341- orElse : ( ) => f ( command , proc , "SIGKILL" ) ,
342- } )
343- }
330+ const terminate = (
331+ command : ChildProcess . StandardCommand ,
332+ proc : NodeChildProcess . ChildProcess ,
333+ exited : ExitSignal ,
334+ opts ?: ChildProcess . KillOptions ,
335+ ) => {
336+ const sig = opts ?. killSignal ?? "SIGTERM"
337+ const send = ( s : NodeJS . Signals ) => Effect . catch ( killGroup ( command , proc , s ) , ( ) => killOne ( command , proc , s ) )
338+ // Wait for the process to exit, not for its stdio to close: a detached
339+ // descendant can keep the inherited stdio open long after the process is
340+ // gone, so waiting on `closed` here would hang kill()/teardown forever.
341+ const attempt = send ( sig ) . pipe ( Effect . andThen ( Deferred . await ( exited ) ) , Effect . asVoid )
342+ const grace = opts ?. forceKillAfter
343+ if ( ! grace ) return attempt
344+ return Effect . timeoutOrElse ( attempt , {
345+ duration : grace ,
346+ // SIGKILL is the last resort: signal the group, then wait for the process
347+ // to exit only up to the same grace. The process should die immediately on
348+ // SIGKILL, but never block teardown if it somehow does not.
349+ orElse : ( ) =>
350+ send ( "SIGKILL" ) . pipe (
351+ Effect . andThen (
352+ Effect . timeoutOrElse ( Deferred . await ( exited ) , {
353+ duration : grace ,
354+ orElse : ( ) => Effect . void ,
355+ } ) ,
356+ ) ,
357+ Effect . asVoid ,
358+ ) ,
359+ } )
360+ }
361+
362+ // Best-effort cleanup for a process that has already exited. Signal its group
363+ // to sweep any descendants it left behind, but never block on `closed`: a
364+ // descendant that escaped the group (or ignores the signal) keeps the
365+ // inherited stdio open, which would otherwise hang scope teardown forever.
366+ const reap = (
367+ command : ChildProcess . StandardCommand ,
368+ proc : NodeChildProcess . ChildProcess ,
369+ closed : ExitSignal ,
370+ opts ?: ChildProcess . KillOptions ,
371+ ) => {
372+ const sig = opts ?. killSignal ?? "SIGTERM"
373+ const send = ( s : NodeJS . Signals ) => Effect . catch ( killGroup ( command , proc , s ) , ( ) => killOne ( command , proc , s ) )
374+ if ( ! opts ?. forceKillAfter ) return send ( sig )
375+ // Give the group a grace period to drain, then force-kill stragglers.
376+ return send ( sig ) . pipe (
377+ Effect . andThen (
378+ Effect . timeoutOrElse ( Deferred . await ( closed ) , {
379+ duration : opts . forceKillAfter ,
380+ orElse : ( ) => send ( "SIGKILL" ) ,
381+ } ) ,
382+ ) ,
383+ Effect . asVoid ,
384+ )
385+ }
344386
345387 const source = ( handle : ChildProcessHandle , from : ChildProcess . PipeFromOption | undefined ) => {
346388 const opt = from ?? "stdout"
@@ -370,7 +412,7 @@ export const make = Effect.gen(function* () {
370412 const extra = fds ( command . options )
371413 const dir = yield * cwd ( command . options )
372414
373- const [ proc , signal ] = yield * Effect . acquireRelease (
415+ const [ proc , exited ] = yield * Effect . acquireRelease (
374416 spawn ( command , {
375417 cwd : dir ,
376418 env : env ( command . options ) ,
@@ -379,26 +421,16 @@ export const make = Effect.gen(function* () {
379421 shell : command . options . shell ,
380422 windowsHide : process . platform === "win32" ,
381423 } ) ,
382- Effect . fnUntraced ( function * ( [ proc , signal ] ) {
383- const done = yield * Deferred . isDone ( signal )
384- const kill = timeout ( proc , command , command . options )
424+ Effect . fnUntraced ( function * ( [ proc , exited , closed ] ) {
425+ const done = yield * Deferred . isDone ( exited )
385426 if ( done ) {
386- const [ code ] = yield * Deferred . await ( signal )
427+ const [ code ] = yield * Deferred . await ( exited )
387428 if ( process . platform === "win32" ) return yield * Effect . void
388- if ( code !== 0 && Predicate . isNotNull ( code ) ) return yield * Effect . ignore ( kill ( killGroup ) )
429+ if ( code !== 0 && Predicate . isNotNull ( code ) )
430+ return yield * Effect . ignore ( reap ( command , proc , closed , command . options ) )
389431 return yield * Effect . void
390432 }
391- const send = ( s : NodeJS . Signals ) =>
392- Effect . catch ( killGroup ( command , proc , s ) , ( ) => killOne ( command , proc , s ) )
393- const sig = command . options . killSignal ?? "SIGTERM"
394- const attempt = send ( sig ) . pipe ( Effect . andThen ( Deferred . await ( signal ) ) , Effect . asVoid )
395- const escalated = command . options . forceKillAfter
396- ? Effect . timeoutOrElse ( attempt , {
397- duration : command . options . forceKillAfter ,
398- orElse : ( ) => send ( "SIGKILL" ) . pipe ( Effect . andThen ( Deferred . await ( signal ) ) , Effect . asVoid ) ,
399- } )
400- : attempt
401- return yield * Effect . ignore ( escalated )
433+ return yield * Effect . ignore ( terminate ( command , proc , exited , command . options ) )
402434 } ) ,
403435 )
404436
@@ -413,8 +445,8 @@ export const make = Effect.gen(function* () {
413445 all : out . all ,
414446 getInputFd : fd . getInputFd ,
415447 getOutputFd : fd . getOutputFd ,
416- isRunning : Effect . map ( Deferred . isDone ( signal ) , ( done ) => ! done ) ,
417- exitCode : Effect . flatMap ( Deferred . await ( signal ) , ( [ code , signal ] ) => {
448+ isRunning : Effect . map ( Deferred . isDone ( exited ) , ( done ) => ! done ) ,
449+ exitCode : Effect . flatMap ( Deferred . await ( exited ) , ( [ code , signal ] ) => {
418450 if ( Predicate . isNotNull ( code ) ) return Effect . succeed ( ExitCode ( code ) )
419451 return Effect . fail (
420452 toPlatformError (
@@ -424,17 +456,7 @@ export const make = Effect.gen(function* () {
424456 ) ,
425457 )
426458 } ) ,
427- kill : ( opts ?: ChildProcess . KillOptions ) => {
428- const sig = opts ?. killSignal ?? "SIGTERM"
429- const send = ( s : NodeJS . Signals ) =>
430- Effect . catch ( killGroup ( command , proc , s ) , ( ) => killOne ( command , proc , s ) )
431- const attempt = send ( sig ) . pipe ( Effect . andThen ( Deferred . await ( signal ) ) , Effect . asVoid )
432- if ( ! opts ?. forceKillAfter ) return attempt
433- return Effect . timeoutOrElse ( attempt , {
434- duration : opts . forceKillAfter ,
435- orElse : ( ) => send ( "SIGKILL" ) . pipe ( Effect . andThen ( Deferred . await ( signal ) ) , Effect . asVoid ) ,
436- } )
437- } ,
459+ kill : ( opts ?: ChildProcess . KillOptions ) => terminate ( command , proc , exited , opts ) ,
438460 unref : Effect . sync ( ( ) => {
439461 if ( ref ) {
440462 proc . unref ( )
0 commit comments