Skip to content

The PUBLISHED CLI puts its own stdout/stderr on the blocking write path every time it spawns a child with inherited stdio #14874

Description

@os-trump

Filed by the domain:cli dev on #14832 (session session_016yfqQh2dBgPAymYd7xipza), out of scope for that card and deliberately not fixed there. Unassigned and ungraded — triage's.

The mechanism, measured on #14832

Node sets O_NONBLOCK on fd 2 when it opens the pipe, so a write to a reader that has stopped reading is BUFFERED rather than parking the thread. libuv clears that flag again in the pre-exec of any child spawned with inherited stdio — deliberately, because a child expects blocking stdio — and inheriting is dup2, so the flag lives on an open file description the spawner shares. Clearing it for the child clears it for the spawner too.

Measured directly, from inside a child whose stderr was a pipe nobody read:

1. before touching process.stderr:                        O_NONBLOCK=false
2. after materialising process.stderr:                    O_NONBLOCK=true
3. after spawnSync(node -e 0, { stdio: 'inherit' }):      O_NONBLOCK=false   ← the clearing
4. after handle.setBlocking(false):                       O_NONBLOCK=true    ← restored

and the consequence, in a matched A/B on the same fixture (only step 4 differs): left blocking, the writer parks in write(2) on the first full-pipe write, 0 interval ticks fire, the event loop is dead and only a SIGKILL ends it; restored, 29 ticks fire, 192 KiB sit pending in userland, the process exits on its own.

#14832 is that hang reaching bin/run-dev.js through tsx, which spawns the esbuild service on a cold transform cache. The published CLI does its own spawning, so it does not need tsx to get there.

Where the published path does it

bin/run.js ships (it is the bin target; files names only dist, README.md, CHANGELOG.md), and the commands behind it spawn with inherited stdio in at least:

file line stdio
packages/cli/src/commands/dev.ts 221 { stdio: 'inherit' }
packages/cli/src/commands/dev.ts 470 { stdio: ['inherit', 'inherit', 'inherit', 'ipc'] }
packages/cli/src/commands/dev.ts 582 execSync(command, { stdio: 'inherit', cwd })
packages/cli/src/commands/start.ts 241, 444 { stdio: 'inherit', … }
packages/cli/src/commands/environments/bind.ts 84 { stdio: 'inherit', env: process.env }
packages/cli/src/commands/init.ts 873 execSync(pm install, { stdio: 'inherit', … })

os dev spawning os serve is the headline case: after that spawn, the long-lived parent's OWN stdout and stderr are on the blocking path for the rest of the run.

Why this matters beyond a test

src/utils/format.ts already states the hazard in its own words, as a reason to refuse setBlocking(true):

the same binary runs os serve / os dev, and a blocking write to a pipe whose reader is slow blocks the event loop — it would trade truncated JSON for a server that stalls on its own logs.

That refusal is correct and is not the issue. The issue is that the premise it protects — that nobody has put stderr in blocking mode — is not actually held on the published path: the CLI turns it on itself, as a side effect of spawning, with nothing in any file saying so. Any consumer that pipes os dev and reads it slowly (a CI log collector, a backgrounded runner, a supervisor that stops draining while it does work) can park the CLI in the kernel. It is not a crash and not a timeout: the process is alive, idle, and unresponsive, with an empty log.

What would settle it, cheaply

⛔ Not prescribing the fix. Two measurements decide whether this is real in the field rather than only in principle:

  1. Run os dev from the built CLI with its stdout/stderr piped to a reader that stops reading after the spawn, and sample /proc/PID/syscall plus /proc/PID/fdinfo/1 and .../2. The reading to look for is syscall=1(write) on the main thread with O_NONBLOCK=false.
  2. Do the same with the spawn changed to pipe + manual forwarding, as the negative control.

If it reproduces, the repair is the same shape #14832 landed — re-assert non-blocking mode on the write path — but on the published entry point, where it needs its own review, its own pin and a changeset that actually ships. That is precisely why it is a separate card and not a rider: #14832 is a p1 queue blocker and its diff should not also carry a shipped behaviour change to os dev.

Not to be confused with

Activity

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

Metadata

Metadata

Assignees

Labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions