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
4771import { describe , it , expect , beforeAll , afterAll } from 'vitest' ;
48- import { execFile } from 'node:child_process' ;
72+ import { execFile , spawn } from 'node:child_process' ;
4973import { mkdtempSync , rmSync } from 'node:fs' ;
5074import { tmpdir } from 'node:os' ;
5175import { 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. */
98208const LEAD = 'objectstack: NOT A MISSING COMMAND' ;
99209const FIX = 'objectstack: Fix: pnpm exec turbo run build --filter=@objectstack/spec' ;
@@ -102,13 +212,19 @@ let dir: string;
102212let unbuilt : Run ;
103213let built : Run ;
104214let genuinelyMissing : Run ;
215+ let stalled : Run ;
216+ let unread : Lifetime ;
217+ let closedEnd : Lifetime ;
105218
106219beforeAll ( 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
113229afterAll ( ( ) => {
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