Skip to content

Commit ffea19c

Browse files
claude[bot]claude
andauthored
fix(cli): drain run-dev.js stderr before oclif exits on top of the unbuilt-workspace diagnostic (#14051)
* wip(cli): drain stderr before the failure path exits * wip(cli): case 4 reproduces the stalled-parent truncation * chore(cli): changeset for the stderr drain fix * fix(cli): bound the stderr drain by progress, not by a deadline that never armed * fix(cli): derive the drain bound from measured runtime; pin the mirror case * chore(cli): changeset reflects the progress bound --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9e1b2de commit ffea19c

3 files changed

Lines changed: 325 additions & 6 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
`bin/run-dev.js` now waits for its stderr writes to reach the pipe before oclif's
6+
`handle()` exits on top of them, and bounds that wait by PROGRESS rather than by a
7+
deadline.
8+
9+
The unbuilt-workspace diagnostic (`objectstack: NOT A MISSING COMMAND` plus the one
10+
build command that fixes it) is written after ~138 KB of oclif `ModuleLoadError`
11+
blocks, because `settings.debug` is on for this entry point. A pipe holds 64 KiB and
12+
`handle()` ends in `process.exit()`, which drops whatever has not drained — so a
13+
parent that is slow to read got exactly one buffer and lost the diagnostic *and*
14+
oclif's own `command … not found`. Measured against a stalled reader: 64721 bytes
15+
captured, both lines gone. Interactively it never reproduced, because a TTY is
16+
written synchronously. This is the #6531 defect (`src/utils/format.ts`, `emitJson`)
17+
on stderr instead of stdout, fixed the way that module prescribes — at the write,
18+
since there is no hook between `handle()`'s `console.error` and its `process.exit`.
19+
20+
⚠️ Waiting is only safe if something bounds the wait. The bound is a NO-PROGRESS
21+
window, not a deadline: a deadline cannot tell a reader that is merely slow from one
22+
that is absent, and those want opposite answers. A live reader keeps draining however
23+
slowly (measured worst case: a vitest worker's event loop never stalled beyond 61 ms
24+
under real suite load); an absent one drains nothing, ever. Exceeding the bound
25+
degrades to the prompt-but-lossy behaviour this file had before the drain existed —
26+
never to a hang.
27+
28+
Scope: `bin/run-dev.js` is the repo's SOURCE entry point, used by gates and e2e
29+
suites; it is not published (`files` names only `dist`, and the `bin` target is
30+
`bin/run.js`). The shipped binary's failure path writes one short line with no
31+
backlog ahead of it and is not affected.

packages/cli/bin/run-dev.js

Lines changed: 108 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,115 @@
1313
// exactly as it did.
1414
import { flush, handle, run, settings } from '@oclif/core';
1515

16+
/**
17+
* How long stderr may make NO PROGRESS before this shim stops waiting for it.
18+
*
19+
* A wall-clock deadline is the wrong instrument: it cannot tell a reader that
20+
* is merely SLOW from one that is ABSENT, and those two want opposite answers —
21+
* the slow one must be waited for (it is the whole point of this file), the
22+
* absent one must never be (nobody is reading, so the bytes are worthless).
23+
* Progress separates them exactly: a live reader keeps draining however slowly,
24+
* an absent one drains nothing, ever.
25+
*
26+
* ## Where the number comes from — re-derive it, do not bump it
27+
*
28+
* • A live reader under real load never stalled longer than **61 ms**: two
29+
* samples of a vitest worker's event loop while this package's e2e suite
30+
* ran beside it (12 680 and 9 663 samples of a 10 ms timer; p999 = 24/25 ms,
31+
* max = 61/57 ms).
32+
* • The stall that actually loses these bytes has to span the CLI's WHOLE
33+
* RUN, not just an instant: the bulk (~138 KB of oclif warnings) is emitted
34+
* during `Config.load()`, the diagnostic ~3 s later, and the bytes are only
35+
* lost if the child exits while the reader is away. So the bound must
36+
* outlast a whole run. Measured child runtime: **1.0 s idle, 6.9 s on a
37+
* contended box** (same container, other agents building).
38+
*
39+
* 15 s is ~2.2x that worst measured runtime, so a stall long enough to cause
40+
* the bug is still waited out, and ~245x the worst measured live-reader stall,
41+
* so a merely slow reader is never cut off.
42+
*
43+
* ⚠️ Exceeding the bound degrades to the behaviour this file had BEFORE the
44+
* drain existed — lossy, but prompt. It can never degrade to a hang, which is
45+
* the property that matters: the failure this replaced was unbounded.
46+
*/
47+
const STDERR_DRAIN_STALL_MS = 15_000;
48+
49+
/** How often progress is sampled — comfortably under the 61 ms above. */
50+
const STDERR_DRAIN_POLL_MS = 50;
51+
52+
/**
53+
* Write to stderr and WAIT for the bytes to reach it.
54+
*
55+
* ⚠️ `process.stderr.write(x)` followed by an exit is the #6531 defect, and
56+
* this shim had it. When stderr is a **pipe** node buffers the write
57+
* asynchronously and `process.exit` tears the process down with the buffer only
58+
* partly drained; `src/utils/format.ts` carries the whole argument for stdout
59+
* (`emitJson`). One thing makes it worse here: `settings.debug` is on, so
60+
* oclif's `displayWarnings()` has already queued ~138 KB of `ModuleLoadError`
61+
* blocks AHEAD of these lines. Measured on the #12964 repro with a reader that
62+
* was not draining: the pipe delivered exactly one 64 KiB buffer and everything
63+
* after it was lost — this diagnostic AND oclif's own `command … not found`,
64+
* which `handle()` writes a moment later and which the same tear-down takes.
65+
* That is why the merge queue saw it and a developer's terminal never does: a
66+
* TTY is written synchronously, a captured pipe is not.
67+
*
68+
* The `write` callback fires only once this chunk **and everything queued ahead
69+
* of it** has been handed to the pipe, so awaiting it drains that backlog too —
70+
* which is what leaves an empty buffer for `handle()`'s own write. The fix
71+
* belongs at the write rather than at the exit because there is no hook between
72+
* `handle()`'s `console.error` and its `process.exit`.
73+
*
74+
* ⛔ Deliberately NOT `process.stderr._handle.setBlocking(true)`: `format.ts`
75+
* records why — the same binary runs `os serve` / `os dev`, and a blocking
76+
* write to a pipe with a slow reader stalls the event loop.
77+
*/
78+
function writeStderr(text) {
79+
return new Promise((resolve) => {
80+
let poll;
81+
let settled = false;
82+
const finish = () => {
83+
if (settled) return;
84+
settled = true;
85+
clearInterval(poll);
86+
resolve();
87+
};
88+
89+
// ⛔ The return value is deliberately NOT consulted. `write()` returns true
90+
// when the internal buffer sits below the highWaterMark, which is NOT the
91+
// same as the bytes having reached the pipe — and an earlier version of this
92+
// shim read it as "already flushed" and returned early. Measured: it
93+
// returned TRUE with writableLength = 7621, so the bound meant to cap the
94+
// wait was never armed at all and a reader that never drained hung the
95+
// process indefinitely (observed alive at 25 s, 30 s and 60 s). The
96+
// callback is the only thing that means "flushed"; it also fires on EPIPE,
97+
// which is what releases the closed-reader paths promptly.
98+
process.stderr.write(text, finish);
99+
100+
let fewestPending = process.stderr.writableLength;
101+
let lastProgressAt = Date.now();
102+
poll = setInterval(() => {
103+
const pending = process.stderr.writableLength;
104+
if (pending < fewestPending) {
105+
fewestPending = pending;
106+
lastProgressAt = Date.now();
107+
return;
108+
}
109+
if (Date.now() - lastProgressAt >= STDERR_DRAIN_STALL_MS) finish();
110+
}, STDERR_DRAIN_POLL_MS);
111+
112+
// ⛔ NOT unref'd, on purpose. `finish()` always clears it, so it cannot
113+
// outlive the wait — and an unref'd detector is exactly the silent no-op
114+
// this function already shipped once: a bound that never runs is
115+
// indistinguishable from one that never trips.
116+
});
117+
}
118+
16119
/** See `bin/run.js` — the same lazy import, against `src/` instead of `dist/`. */
17120
async function announceInvocationFailure(error) {
18121
try {
19122
const { invocationFailureLine } = await import('../src/utils/invocation.ts');
20123
const line = invocationFailureLine(error, process.argv.slice(2));
21-
if (line) process.stderr.write(`${line}\n`);
124+
if (line) await writeStderr(`${line}\n`);
22125
} catch {
23126
// Stay quiet rather than replacing oclif's report with an error about the
24127
// reporter itself.
@@ -53,9 +156,10 @@ async function announceUnbuiltWorkspace(error) {
53156
import('../../../scripts/cli-unbuilt-workspace-lead.mjs'),
54157
import('../src/utils/invocation.ts'),
55158
]);
56-
for (const line of unbuiltWorkspaceLines(error, moduleLoadFailures, INVOCATION_PREFIX) ?? []) {
57-
process.stderr.write(`${line}\n`);
58-
}
159+
// One write, so the drain that matters happens once, immediately before
160+
// `handle()` gets its turn at the same pipe.
161+
const lines = unbuiltWorkspaceLines(error, moduleLoadFailures, INVOCATION_PREFIX) ?? [];
162+
if (lines.length) await writeStderr(`${lines.join('\n')}\n`);
59163
} catch {
60164
// Stay quiet rather than replacing oclif's report with an error about the
61165
// reporter itself.

packages/cli/test/run-dev-unbuilt-workspace.e2e.test.ts

Lines changed: 186 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,34 @@
4242
* (1) without (2) would pass in a tree where every run happens to be diagnosed;
4343
* (2) and (3) without (1) are two zero readings. Together they say the branch is
4444
* reachable, is not always taken, and is taken for the right reason.
45+
*
46+
* ## (4) — the same run, read by a parent that is not draining
47+
*
48+
* Cases 1-3 read the child through an `execFile` whose event loop is free, and
49+
* that is the one reader for which this diagnostic was never at risk. The merge
50+
* queue is not that reader: it runs the full suite sharded, and a worker whose
51+
* loop is starved stops draining its children for seconds at a time.
52+
*
53+
* What that costs is measurable and was measured. `settings.debug` puts ~138 KB
54+
* of oclif `ModuleLoadError` blocks on stderr AHEAD of the lead lines; a pipe
55+
* holds 64 KiB; and `handle()` ends in `process.exit()`, which Node documents as
56+
* dropping whatever has not drained. With the parent's loop blocked, an unfixed
57+
* `run-dev.js` delivers exactly one buffer — 64764 bytes measured — and loses
58+
* BOTH the lead lines and oclif's own `command … not found`, which is the pair
59+
* of assertions that reds in the queue. It is the #6531 defect
60+
* (`src/utils/format.ts`, `emitJson`) on stderr instead of stdout, and it is
61+
* invisible interactively, where stderr is a TTY and written synchronously.
62+
*
63+
* ⚠️ The stall has to block the LOOP, not merely pause the stream. A paused
64+
* `child.stderr` still lets node fill its own 64 KiB readable buffer, so the
65+
* kernel's buffer stops being the only absorber, ~128 KiB of headroom swallows
66+
* the whole run and the case passes against unfixed code — measured, and the
67+
* reason this is written the way it is. `Atomics.wait` blocks without spinning,
68+
* so the stall costs no CPU on a shared runner.
4569
*/
4670

4771
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
48-
import { execFile } from 'node:child_process';
72+
import { execFile, spawn } from 'node:child_process';
4973
import { mkdtempSync, rmSync } from 'node:fs';
5074
import { tmpdir } from 'node:os';
5175
import { join, resolve } from 'node:path';
@@ -94,6 +118,92 @@ function runCli(args: string[], cwd: string, nodeOptions: string | undefined): P
94118
});
95119
}
96120

121+
/**
122+
* One pipe buffer on Linux — what a truncated capture comes out at, and the
123+
* floor case 4 has to clear for its reading to mean anything.
124+
*/
125+
const PIPE_BUFFER_BYTES = 65_536;
126+
127+
/**
128+
* How long case 4 refuses to drain. Two constraints pin it, and the ORDER is
129+
* the whole design:
130+
*
131+
* worst measured child runtime (6.9 s) < STALL_MS < the shim's
132+
* no-progress bound (`STDERR_DRAIN_STALL_MS`, 15 s in `bin/run-dev.js`)
133+
*
134+
* Below the child's runtime the control cannot bite: the bulk of stderr is
135+
* emitted during `Config.load()` and the diagnostic ~3 s later, so a stall that
136+
* ends first lets the tail out and the case passes against unfixed code.
137+
* Above the shim's bound the fixed child correctly gives up, and the case would
138+
* red against a WORKING fix.
139+
*
140+
* ⚠️ Both failure directions have actually happened here. 4 s was tried and the
141+
* ablation against base came back GREEN — the case had silently stopped
142+
* discriminating once a contended box pushed child runtime past it. A slow
143+
* continuous reader was tried instead and failed the other way: it drains the
144+
* bulk long before the tail is written, so the tail meets an EMPTY pipe and
145+
* nothing is ever lost. Only a stall spanning the whole run reproduces this.
146+
*/
147+
const STALL_MS = 10_000;
148+
149+
/**
150+
* Ceiling for case 5: comfortably above the worst child runtime plus the shim's
151+
* 15 s bound (~22 s measured), so reaching it means a HANG rather than a wait.
152+
*/
153+
const UNREAD_HARD_CAP_MS = 40_000;
154+
155+
interface Lifetime {
156+
code: number | null;
157+
signal: NodeJS.Signals | null;
158+
elapsedMs: number;
159+
}
160+
161+
/**
162+
* Run the CLI against a pipe that is never drained, and report only how the
163+
* process ENDED. Nothing is read, so the kernel's 64 KiB is the whole absorber
164+
* and the child hits real backpressure it can never clear.
165+
*/
166+
function runCliAgainstDeadReader(
167+
args: string[],
168+
cwd: string,
169+
nodeOptions: string,
170+
mode: 'never-read' | 'destroy-read-end',
171+
): Promise<Lifetime> {
172+
return new Promise((resolvePromise) => {
173+
const child = spawn(TSX, [CLI, ...args], {
174+
cwd,
175+
env: childEnv({ NO_COLOR: '1', NODE_OPTIONS: nodeOptions }),
176+
stdio: ['ignore', 'ignore', 'pipe'],
177+
});
178+
const pipe = child.stderr;
179+
if (!pipe) throw new Error('stderr was not piped');
180+
if (mode === 'destroy-read-end') pipe.destroy();
181+
else pipe.pause();
182+
183+
const started = Date.now();
184+
// Ours, and it must be the ONLY thing that can end a hang — a child that
185+
// reaches it is the failure this case exists to catch.
186+
const cap = setTimeout(() => child.kill('SIGKILL'), UNREAD_HARD_CAP_MS);
187+
child.once('exit', (code, signal) => {
188+
clearTimeout(cap);
189+
resolvePromise({ code, signal, elapsedMs: Date.now() - started });
190+
});
191+
});
192+
}
193+
194+
/**
195+
* Run the CLI and DO NOT read it for `STALL_MS`, by blocking this thread.
196+
*
197+
* `Atomics.wait` rather than a spin loop: it parks the thread instead of
198+
* burning a core, which matters on a runner that is already the reason this
199+
* case exists. Nothing else in this file shares the worker while it is parked.
200+
*/
201+
async function runCliWhileParentStalls(args: string[], cwd: string, nodeOptions: string): Promise<Run> {
202+
const run = runCli(args, cwd, nodeOptions);
203+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, STALL_MS);
204+
return run;
205+
}
206+
97207
/** The sentence this change exists to contradict. */
98208
const LEAD = 'objectstack: NOT A MISSING COMMAND';
99209
const FIX = 'objectstack: Fix: pnpm exec turbo run build --filter=@objectstack/spec';
@@ -102,13 +212,19 @@ let dir: string;
102212
let unbuilt: Run;
103213
let built: Run;
104214
let genuinelyMissing: Run;
215+
let stalled: Run;
216+
let unread: Lifetime;
217+
let closedEnd: Lifetime;
105218

106219
beforeAll(async () => {
107220
dir = mkdtempSync(join(tmpdir(), 'os-run-dev-unbuilt-'));
108221
unbuilt = await runCli(REAL_COMMAND, dir, `--import ${UNBUILT_HOOK}`);
109222
built = await runCli(REAL_COMMAND, dir, undefined);
110223
genuinelyMissing = await runCli(['definitely-not-a-command'], dir, undefined);
111-
}, RUN_TIMEOUT_MS * 3);
224+
stalled = await runCliWhileParentStalls(REAL_COMMAND, dir, `--import ${UNBUILT_HOOK}`);
225+
unread = await runCliAgainstDeadReader(REAL_COMMAND, dir, `--import ${UNBUILT_HOOK}`, 'never-read');
226+
closedEnd = await runCliAgainstDeadReader(REAL_COMMAND, dir, `--import ${UNBUILT_HOOK}`, 'destroy-read-end');
227+
}, RUN_TIMEOUT_MS * 6);
112228

113229
afterAll(() => {
114230
rmSync(dir, { recursive: true, force: true });
@@ -158,3 +274,71 @@ describe('the same probe, un-simulated (positive control)', () => {
158274
expect(genuinelyMissing.code).toBe(2);
159275
});
160276
});
277+
278+
describe('the same probe, read by a parent that stalls (the merge-queue shape)', () => {
279+
it('delivers more than the one buffer a stalled pipe holds', () => {
280+
// THE control for the three cases below, and not a restatement of them: the
281+
// measured failure was a capture of exactly one buffer, so clearing that
282+
// line is what makes the string assertions evidence about DRAINING rather
283+
// than about a run that happened to be short. Unfixed, this reads 64764.
284+
expect(Buffer.byteLength(stalled.stderr)).toBeGreaterThan(PIPE_BUFFER_BYTES);
285+
});
286+
287+
it('still names the real cause and the one command that fixes it', () => {
288+
expect(stalled.stderr).toContain(LEAD);
289+
expect(stalled.stderr).toContain('@objectstack/spec');
290+
expect(stalled.stderr).toContain(FIX);
291+
});
292+
293+
it("still carries oclif's own report, which is written after ours and exits on top of it", () => {
294+
// Not ours to print, and the reason the fix is a DRAIN rather than a
295+
// reordering: `handle()` writes this and calls `process.exit` immediately,
296+
// so it survives only because awaiting our own write had already emptied
297+
// the buffer ahead of it. This assertion reds in the queue beside the lead
298+
// line — and a formatter that had merely failed to LOAD could not have
299+
// removed it, which is what rules that reading out.
300+
expect(stalled.stderr).toContain('Error: command i18n:extract:nope.ts not found');
301+
expect(stalled.code).toBe(2);
302+
});
303+
});
304+
305+
describe('the mirror direction: a reader that is never coming back', () => {
306+
/**
307+
* ⚠️ This case exists because the first fix for the stalled reader above
308+
* introduced a HANG here, and every instrument written for that fix pointed
309+
* the other way. Waiting for a drain is only safe if something bounds the
310+
* wait, and the bound has to be OBSERVED rather than assumed: the version
311+
* this replaces armed no bound at all (`write()` returned true, so an early
312+
* return skipped it) and read as correct in every stalled-reader test.
313+
*/
314+
it('gives up and exits instead of waiting forever', () => {
315+
// A child still alive at the cap was SIGKILLed: signal set, code null.
316+
// That is the hang, and it is the whole point of this case.
317+
expect(unread.signal).toBeNull();
318+
expect(unread.code).toBe(2);
319+
expect(unread.elapsedMs).toBeLessThan(UNREAD_HARD_CAP_MS);
320+
});
321+
322+
// ⛔ There is deliberately NO assertion here that the child WAITED for the
323+
// bound before exiting, though an earlier version of this file had one. It
324+
// is not sound: whether the tail finds bytes still pending — and so whether
325+
// the bound is needed at all — depends on how much of the ~138 KB backlog
326+
// the kernel and node's own readable buffer happened to absorb, which moves
327+
// with load. Measured on one contended run the child exited at 7653 ms
328+
// having never needed the bound; on another, with 7621 bytes still pending,
329+
// the unfixed shim hung instead. Asserting the wait would red on the first
330+
// run and pass on the second, which is a flake, not a pin. What this case
331+
// pins is the property that actually matters and holds either way: the
332+
// process ENDS. That the bound itself runs and trips is shown out of band,
333+
// by tracing a run whose reader blocks its loop for the whole run — see the
334+
// PR for the `BOUND TRIPPED` trace.
335+
336+
it('a CLOSED read end is released at once, not held for the bound (EPIPE reaches the callback)', () => {
337+
// Pins the fast path measured alongside the hang: when the reader is gone
338+
// rather than idle, the write callback fires with EPIPE and the wait ends
339+
// immediately. A future change to the bound must not quietly make the
340+
// closed-reader paths pay it.
341+
expect(closedEnd.signal).toBeNull();
342+
expect(closedEnd.elapsedMs).toBeLessThan(STALL_MS);
343+
});
344+
});

0 commit comments

Comments
 (0)