Skip to content

child_process.spawnSync blocks until stdio EOF even after the child is reaped — please document (detached descendant can hold it open forever) #67197

Open

Description

@putao520

Upstream issue draft (nodejs/node) — spawnSync stdio EOF semantics documentation

Verdict framing (E3 diagnosis, ISSUE #241): this is NOT filed as a runtime bug.
The behavior is defensible (a pipe write-end held by a detached descendant is
indistinguishable from the child still writing), and timeout is the sanctioned
bound. The gap is documentation: nothing in the child_process.spawnSync docs
states that the call waits for stdio EOF, not merely child exit, and that
fds inherited by detached descendants of the child can hold the wait open
indefinitely after the child has been reaped.


Title: child_process.spawnSync blocks until stdio EOF even after the child is reaped — please document (detached descendant can hold it open forever)

Version: v24.19.0
Platform: Linux 7.0.0-28-generic x86_64

Minimal reproduction

// repro.mjs — run with: node repro.mjs
import { spawnSync } from 'node:child_process';

const childScript = `
  const { spawn } = await import('node:child_process');
  // grandchild: detached, inherits stdout/stderr write-ends, outlives the child
  const gc = spawn(process.execPath, ['-e', 'setInterval(()=>{},1e6)'], {
    detached: true, stdio: ['ignore', 'inherit', 'inherit'],
  });
  gc.unref();
  process.stdout.write('child-done\\n');
`;

const r = spawnSync(process.execPath, ['-e', childScript], {
  encoding: 'utf8',
  stdio: ['ignore', 'pipe', 'pipe'],
  timeout: 15000,
});
console.log('status =', r.status);        // 0  — child exit was collected
console.log('stdout =', r.stdout);        // "child-done\n" — output fully received
console.log('error  =', r.error?.code);   // ETIMEDOUT — yet the call still hit the full 15s timeout

Observed on v24.19.0: status is collected (child exited and was reaped) and the
child's stdout payload is fully received, but the call still blocks for the entire
timeout window. Without timeout it blocks forever, with the main thread
native-blocked in SyncProcessRunner::Spawn → uv_run → epoll_pwait(-1).
ps at hang time shows no child of the caller (the direct child is reaped; the
fd holder was reparented to init under its own session).

Why this matters in practice

  • Test harnesses (e.g. node --test suites) that call spawnSync/execFileSync
    without timeout can hang irrevocably: a test-level timeout cannot fire
    because the JS event loop is native-blocked inside the sync call.
  • The only in-band bound is the timeout option; nothing in the docs suggests
    why it would ever be needed for a fast-exiting child.

Request

Document on child_process.spawnSync (and execFileSync/execSync):

  1. the call returns only after the child exits and stdout/stderr reach EOF;
  2. file descriptors inherited by any descendant the child leaves behind (e.g.
    detached grandchildren) hold the wait open after the child is reaped;
  3. timeout is the way to bound this.

Happy to file as a PR against doc/api/child_process.md if a maintainer confirms
the semantics are as-intended.

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