Skip to content

process: no way to observe exit — join() settles on close (exit + stdio EOF), which can outlive the command #228

Description

@taras

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions