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:
- 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. - 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
Filed by the
domain:clidev on #14832 (sessionsession_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_NONBLOCKon 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 isdup2, 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:
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.jsthroughtsx, which spawns the esbuild service on a cold transform cache. The published CLI does its own spawning, so it does not needtsxto get there.Where the published path does it
bin/run.jsships (it is thebintarget;filesnames onlydist,README.md,CHANGELOG.md), and the commands behind it spawn with inherited stdio in at least:packages/cli/src/commands/dev.ts{ stdio: 'inherit' }packages/cli/src/commands/dev.ts{ stdio: ['inherit', 'inherit', 'inherit', 'ipc'] }packages/cli/src/commands/dev.tsexecSync(command, { stdio: 'inherit', cwd })packages/cli/src/commands/start.ts{ stdio: 'inherit', … }packages/cli/src/commands/environments/bind.ts{ stdio: 'inherit', env: process.env }packages/cli/src/commands/init.tsexecSync(pm install, { stdio: 'inherit', … })os devspawningos serveis 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.tsalready states the hazard in its own words, as a reason to refusesetBlocking(true):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 devand 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:
os devfrom the built CLI with its stdout/stderr piped to a reader that stops reading after the spawn, and sample/proc/PID/syscallplus/proc/PID/fdinfo/1and.../2. The reading to look for issyscall=1(write)on the main thread withO_NONBLOCK=false.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
os devin an unbuilt workspace sometimes HANGS instead of exiting 2 when its reader goes away — measured at 180103 ms against a 7046 ms calibration on the same runner #14832 — the same mechanism,bin/run-dev.js(not published), reader PAUSED, symptom is a hang. Fixed there.os devdies of an uncaughtwrite EPIPE(exit 1) when its stderr read end is CLOSED — every other reader gets exit 2, and the drain is never reached #14858 — same file, reader CLOSED, symptom is an uncaughtwrite EPIPEand exit 1 at ~1.4 s. A different defect; measured unchanged byos devin an unbuilt workspace sometimes HANGS instead of exiting 2 when its reader goes away — measured at 180103 ms against a 7046 ms calibration on the same runner #14832's fix (exit 1 at 1362-1482 ms, 6 of 6, after that fix landed on the branch).