|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * The hazard `bin/stderr-nonblocking.mjs` exists for, MANUFACTURED rather than |
| 5 | + * waited for — driven by `run-dev-stderr-nonblocking.e2e.test.ts`. |
| 6 | + * |
| 7 | + * ⚠️ The reason this fixture exists at all is that the real occurrence is |
| 8 | + * INTERMITTENT: over the real CLI it reproduced 27 of 30 runs on a cold `tsx` |
| 9 | + * transform cache and 1 of 90 on a warm one. A pin that drove the real child |
| 10 | + * would therefore be green most of the time on a developer box while the defect |
| 11 | + * was fully present — a test that only fails intermittently is not a pin. So |
| 12 | + * this fixture reproduces the CONDITION deterministically and in ~200 ms: |
| 13 | + * |
| 14 | + * 1. materialise `process.stderr`, which is when node opens the pipe and sets |
| 15 | + * `O_NONBLOCK` on it — the state every healthy run starts in; |
| 16 | + * 2. spawn a trivial child with INHERITED stdio. libuv clears `O_NONBLOCK` on |
| 17 | + * fds 0-2 in the child's pre-exec, and inheriting is `dup2`, so the flag — |
| 18 | + * which lives on the shared open file description — is cleared for THIS |
| 19 | + * process too. In the real defect this spawn is the esbuild service that |
| 20 | + * `tsx` starts when it has to transform a module; nothing about the |
| 21 | + * mechanism needs it to be esbuild; |
| 22 | + * 3. write far past every buffer on the path (2 MiB, against ~128 KiB of |
| 23 | + * kernel pipe plus the parent's own readable buffer) to a reader that is |
| 24 | + * never coming back. |
| 25 | + * |
| 26 | + * With the flag cleared, step 3 parks the MAIN THREAD inside `write(2)` and the |
| 27 | + * event loop stops: no timer, no callback, no bound of any kind can run, and the |
| 28 | + * process ends only when someone reads the pipe or kills it. With the guard |
| 29 | + * installed, the same writes queue in userland and the process exits on its own. |
| 30 | + * |
| 31 | + * Every step announces itself into a MARKER FILE rather than onto stderr — |
| 32 | + * stderr is the thing under test and, in the failing arm, the thing that is |
| 33 | + * blocked. The markers are what let the harness tell "froze at the write" from |
| 34 | + * "was still booting", so its verdict never rests on wall clock alone. |
| 35 | + * |
| 36 | + * argv: `<marker file> guarded|unguarded` |
| 37 | + */ |
| 38 | + |
| 39 | +import { spawnSync } from 'node:child_process'; |
| 40 | +import { appendFileSync, readFileSync } from 'node:fs'; |
| 41 | + |
| 42 | +import { keepStderrNonBlocking } from '../../bin/stderr-nonblocking.mjs'; |
| 43 | + |
| 44 | +const [, , MARKS, ARM] = process.argv; |
| 45 | +const mark = (line) => appendFileSync(MARKS, `${line}\n`); |
| 46 | + |
| 47 | +/** |
| 48 | + * The flag itself, read from the kernel rather than inferred. |
| 49 | + * |
| 50 | + * Linux-only. `unreadable` elsewhere, and the harness treats that as "cannot |
| 51 | + * confirm" instead of quietly assuming the hazard was armed — the one reading |
| 52 | + * that would make the control vacuous is `true`, and only that one is refused. |
| 53 | + */ |
| 54 | +function nonBlocking() { |
| 55 | + try { |
| 56 | + const flags = /flags:\s*(\d+)/.exec(readFileSync('/proc/self/fdinfo/2', 'utf8'))?.[1]; |
| 57 | + return flags === undefined ? 'unreadable' : String((parseInt(flags, 8) & 0o4000) !== 0); |
| 58 | + } catch { |
| 59 | + return 'unreadable'; |
| 60 | + } |
| 61 | +} |
| 62 | + |
| 63 | +// Touching the stream is what materialises it; `writableLength` is the cheapest |
| 64 | +// touch that cannot itself write anything. |
| 65 | +void process.stderr.writableLength; |
| 66 | +mark(`START O_NONBLOCK=${nonBlocking()}`); |
| 67 | + |
| 68 | +spawnSync(process.execPath, ['-e', '0'], { stdio: 'inherit' }); |
| 69 | +mark(`HAZARD O_NONBLOCK=${nonBlocking()}`); |
| 70 | + |
| 71 | +if (ARM === 'guarded') mark(`GUARD ${keepStderrNonBlocking()}`); |
| 72 | + |
| 73 | +mark('WRITING'); |
| 74 | +const chunk = 'x'.repeat(8 * 1024); |
| 75 | +// ⚠️ 2 MiB, and the size is a MEASUREMENT rather than a round number. A reader |
| 76 | +// that is merely paused is not the only absorber: the kernel pipe holds 64 KiB |
| 77 | +// and node's own readable buffer in the parent holds about another 64 KiB, so |
| 78 | +// ~128 KiB can disappear before the writer ever meets backpressure. 192 KiB was |
| 79 | +// tried first and the unguarded arm reached the end of its loop unblocked on |
| 80 | +// one run in two — a control that green-lights the very hazard it exists to |
| 81 | +// prove. 2 MiB is 16x that headroom, so no absorber on this path can swallow it. |
| 82 | +let backpressured = 0; |
| 83 | +for (let i = 0; i < 256; i++) { |
| 84 | + if (process.stderr.write(chunk) === false) backpressured += 1; |
| 85 | + // Progress, so a frozen arm shows WHERE it stopped rather than only that it |
| 86 | + // never finished — the difference between evidence and an empty timeout. |
| 87 | + // |
| 88 | + // ⚠️ Every 4 chunks (32 KiB), not every 32. The block lands around chunk 16 — |
| 89 | + // one kernel pipe plus one reader-side buffer in — so a coarser interval puts |
| 90 | + // the FIRST marker after the freeze, and the control then reads "froze before |
| 91 | + // any write landed" on a perfectly good reproduction. Measured that way. |
| 92 | + if ((i + 1) % 4 === 0) mark(`WROTE ${(i + 1) * 8} KiB`); |
| 93 | +} |
| 94 | +// ⚠️ `pending` is reported but deliberately NOT the evidence that the bytes |
| 95 | +// were kept. Measured: it reads 0 here even on a perfectly healthy guarded run, |
| 96 | +// because libuv has taken every chunk into its own write queue and |
| 97 | +// `writableLength` only counts what the STREAM still holds above the handle. |
| 98 | +// `bytesWritten` and `destroyed` are the readings that separate "buffered" from |
| 99 | +// "thrown away", so those are what the harness asserts on. |
| 100 | +mark( |
| 101 | + `WRITES RETURNED pending=${process.stderr.writableLength} bytesWritten=${process.stderr.bytesWritten} ` + |
| 102 | + `destroyed=${process.stderr.destroyed} backpressured=${backpressured}`, |
| 103 | +); |
| 104 | + |
| 105 | +// A distinctive status, so "exited on its own" is evidence about THIS file |
| 106 | +// rather than about any process that happens to end in 0 or 1. |
| 107 | +process.exit(7); |
0 commit comments