Process.join() and Process.expect() currently settle on Node's child-process close event. That event occurs only after the child exits and its stdio streams close. There is no way to observe the child's exit event independently.
This matters whenever stdio outlives the direct child. A descendant can inherit stdout or stderr and keep its write end open, or stream/event delivery can lag under host load. In either case the command has exited, but join() has not settled.
Reproduction
A deadline intended to bound command runtime currently bounds runtime plus pipe lifetime:
import{main,race,sleep}from"effection";import{exec}from"@effectionx/process";awaitmain(function*(){constresult=yield*race([exec('bash -c "(sleep 30 &); echo partial; exit 1"').join(),sleep(1_000),]);// bash printed "partial" and exited within milliseconds, but the backgrounded// sleep inherited stdout. join() waits for that descriptor to close, so the// timeout branch wins.console.log(result);// undefined});The direct child and its output pipes have different lifetimes:
t ≈ 5ms bash exits → exited(): { code: 1 }
t ≈ 30s sleep exits and the → join(): { code: 1 }
last writer closes
Node documents this distinction directly: exit is emitted after the child ends and stdio may still be open; close is emitted after exit and stdio closure.
This gap caused a downstream deadline-bounded exec implementation to bypass @effectionx/process and use node:child_process directly (taras/executable.md#343, #344, and #345).
Decided API
Add one explicit process-lifetime operation:
interfaceProcess{exited(): Operation<ExitStatus>;}Usage:
constprocess=yield*exec(command);conststatus=yield*process.exited();
exited():
- settles on the direct child's
exit event; - returns only
ExitStatus; - does not wait for stdout or stderr EOF;
- is independent of output collection;
- must be registered early enough that a fast process cannot exit before observation begins;
- must be replayable so calls made after exit receive the same result.
Spawn-error behavior must be defined and covered by tests.
Existing behavior remains unchanged
Process.join() remains close-settled.Process.expect() remains close-settled and throws for unsuccessful status.Exec.join() remains the existing output-capturing helper.Exec.expect() remains the existing output-capturing helper.- Existing callers retain the output-completeness guarantee associated with
close.
The documentation for join() and expect() should explicitly distinguish close-settled completion from exit observation.
Explicit non-goals
No settle option on join()
We will not add Exec.join({ settle: "exit" }), Process.join({ settle: "exit" }), or another configurable settle mode, now or later.
Exit status and output remain orthogonal: callers that need exit observation use Process.exited(); callers that need close-settled completion use join() or expect().
Process will not be an operation
Process will not implement Operation<ExitStatus>. The double-yield form below will not be supported:
yield*yield*exec(command);
Exit observation is deliberately explicit and searchable through Process.exited().
Teardown consequence
The POSIX teardown currently sends SIGTERM to the process group and waits for the stdout/stderr pumps. Pipe EOF can be observed before Node delivers the direct child's exit/reaping event, leaving a narrow window in which the dead child is still visible as a zombie. This was measured downstream at the EOF observation point (taras/executable.md#338).
The new exited() primitive makes exit/reaping observable, but changing teardown guarantees is separate from defining the API and requires an explicit escalation policy for processes that do not terminate gracefully.
PR split
API PR
The API PR adds Process.exited(), eager/replayable exit observation, documentation, and lifecycle tests.
Because Process is a portable public interface, the method itself must be available in both the POSIX and win32 implementations. This PR does not change Windows shutdown ordering or force-kill behavior.
Separate Windows teardown PR
Windows-specific teardown work belongs in a separate PR.
The current win32 teardown waits for stdout/stderr EOF before checking whether it needs to run taskkill /T /F. If a descendant inherits an output handle, EOF can remain pending and the fallback tree kill becomes unreachable. The follow-up PR should define graceful-shutdown bounds, reorder escalation so it is reachable without EOF, and add Windows-specific coverage.
Acceptance criteria
Process.exited() settles on child exit without waiting for inherited stdout/stderr handles to close.Process.join() remains pending until close in the inherited-writer reproduction.- Exit observation is eager and replayable.
- Exit status and spawn-error behavior are tested.
- POSIX and win32 expose the same public
Process.exited() API. - Existing
join() and expect() behavior does not change. - Tests use deterministic lifecycle handshakes rather than sleep-based synchronization.
- No configurable
join() settle mode is introduced. Process does not become an Operation.
Process.join()andProcess.expect()currently settle on Node's child-processcloseevent. That event occurs only after the child exits and its stdio streams close. There is no way to observe the child'sexitevent independently.This matters whenever stdio outlives the direct child. A descendant can inherit stdout or stderr and keep its write end open, or stream/event delivery can lag under host load. In either case the command has exited, but
join()has not settled.Reproduction
A deadline intended to bound command runtime currently bounds runtime plus pipe lifetime:
The direct child and its output pipes have different lifetimes:
Node documents this distinction directly:
exitis emitted after the child ends and stdio may still be open;closeis emitted after exit and stdio closure.This gap caused a downstream deadline-bounded exec implementation to bypass
@effectionx/processand usenode:child_processdirectly (taras/executable.md#343, #344, and #345).Decided API
Add one explicit process-lifetime operation:
Usage:
exited():exitevent;ExitStatus;Spawn-error behavior must be defined and covered by tests.
Existing behavior remains unchanged
Process.join()remains close-settled.Process.expect()remains close-settled and throws for unsuccessful status.Exec.join()remains the existing output-capturing helper.Exec.expect()remains the existing output-capturing helper.close.The documentation for
join()andexpect()should explicitly distinguish close-settled completion from exit observation.Explicit non-goals
No settle option on
join()We will not add
Exec.join({ settle: "exit" }),Process.join({ settle: "exit" }), or another configurable settle mode, now or later.Exit status and output remain orthogonal: callers that need exit observation use
Process.exited(); callers that need close-settled completion usejoin()orexpect().Processwill not be an operationProcesswill not implementOperation<ExitStatus>. The double-yield form below will not be supported:Exit observation is deliberately explicit and searchable through
Process.exited().Teardown consequence
The POSIX teardown currently sends SIGTERM to the process group and waits for the stdout/stderr pumps. Pipe EOF can be observed before Node delivers the direct child's exit/reaping event, leaving a narrow window in which the dead child is still visible as a zombie. This was measured downstream at the EOF observation point (taras/executable.md#338).
The new
exited()primitive makes exit/reaping observable, but changing teardown guarantees is separate from defining the API and requires an explicit escalation policy for processes that do not terminate gracefully.PR split
API PR
The API PR adds
Process.exited(), eager/replayable exit observation, documentation, and lifecycle tests.Because
Processis a portable public interface, the method itself must be available in both the POSIX and win32 implementations. This PR does not change Windows shutdown ordering or force-kill behavior.Separate Windows teardown PR
Windows-specific teardown work belongs in a separate PR.
The current win32 teardown waits for stdout/stderr EOF before checking whether it needs to run
taskkill /T /F. If a descendant inherits an output handle, EOF can remain pending and the fallback tree kill becomes unreachable. The follow-up PR should define graceful-shutdown bounds, reorder escalation so it is reachable without EOF, and add Windows-specific coverage.Acceptance criteria
Process.exited()settles on child exit without waiting for inherited stdout/stderr handles to close.Process.join()remains pending until close in the inherited-writer reproduction.Process.exited()API.join()andexpect()behavior does not change.join()settle mode is introduced.Processdoes not become anOperation.