fix(#4448): stop io.test.cjs's in-process runMain calls from corrupting node:test's own fd-1 IPC (#4452)
tests/io.test.cjs's "review fix: pending-outcome cell lifetime" describe block deliberately drives runMain()/output() in-process (needed to observe a cross-invocation state leak) instead of via a subprocess. output() ends with a raw synchronous fs.writeSync(1, ...) to the real stdout fd, and Node's --test-isolation=process (default since Node 22) uses that same fd for the file's own parent-child reporter protocol. The two writes racing produced an intermittent "Unable to deserialize cloned data" that killed the whole file — observed twice on next's macOS lane, most recently on the commit that merged PR #4428 (unrelated to that PR's content; io.test.cjs isn't part of its diff). Empirically validated locally (gsd-test can't reach macOS): built a repro loop running N parallel copies of `node --test tests/io.test.cjs` to recreate CI-like contention. Baseline: ~13-17% of runs hit the corruption (12/90, 15/90 across two samples). A first fix attempt wrapped the writes in captureFdAsync (an await-aware twin of the existing captureFdSync, added because runMain() defers main() through a microtask chain, so a synchronous wrap restores before the real write fires) — but captureFdAsync always forwards to the real fs.writeSync by design (matching captureFdSync's "never swallow" contract, tests/helpers.cjs, #4306). Re-ran the same loop against that fix: 15/90, statistically unchanged. Forwarding the write doesn't stop it from reaching the fd node:test's own IPC also uses. Replaced it with suppressFdAsync: a narrow, deliberate exception to the never-swallow contract for a window the caller has verified is fully controlled (a single runMain() call plus its promise-chain settling, where nothing else can legitimately need that fd). It records the bytes for the test's own assertions but never lets them reach the real fd. Re-ran the loop: 0/300 across three samples (90+120+90), including one round at 8-way parallelism. Also caught and fixed a real bug surfaced by the same loop: the new regression assertion checked for compact-JSON `"error":"x"` but output() pretty-prints, so it failed 100% of runs deterministically until fixed to parse and check the structured value instead (io.test.cjs, matching this repo's "assert on structured output, not raw text" convention). Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,7 @@ const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const fs = require('node:fs');
|
||||
const { captureFdSync } = require('./helpers.cjs');
|
||||
const { captureFdSync, suppressFdAsync } = require('./helpers.cjs');
|
||||
|
||||
const io = require('../gsd-core/bin/lib/io.cjs');
|
||||
const {
|
||||
@@ -717,7 +717,7 @@ describe('#3912 A1/B1: error() declares from ERROR_REASON, exhaustive over the 2
|
||||
test(`v1: ERROR_REASON.${key} (${reasonValue}) exits 1`, () => {
|
||||
resolveContractVersion({ argv: ['node', 'x'], env: {} }); // v1
|
||||
assert.throws(
|
||||
() => io.error('msg', reasonValue),
|
||||
() => captureFdSync(2, () => { io.error('msg', reasonValue); }),
|
||||
(err) => err instanceof ExitError && err.code === 1,
|
||||
`ERROR_REASON.${key} must exit 1 under v1`,
|
||||
);
|
||||
@@ -727,7 +727,7 @@ describe('#3912 A1/B1: error() declares from ERROR_REASON, exhaustive over the 2
|
||||
resolveContractVersion({ argv: ['node', 'x', '--exit-contract=v2'], env: {} });
|
||||
const expected = expectedErrorCode3912(reasonValue, 'v2');
|
||||
assert.throws(
|
||||
() => io.error('msg', reasonValue),
|
||||
() => captureFdSync(2, () => { io.error('msg', reasonValue); }),
|
||||
(err) => err instanceof ExitError && err.code === expected,
|
||||
`ERROR_REASON.${key} under v2 must exit ${expected}`,
|
||||
);
|
||||
@@ -738,7 +738,7 @@ describe('#3912 A1/B1: error() declares from ERROR_REASON, exhaustive over the 2
|
||||
test('A2: error() with no reason argument exits 1 under v1 (defaults to UNKNOWN)', () => {
|
||||
resolveContractVersion({ argv: ['node', 'x'], env: {} });
|
||||
assert.throws(
|
||||
() => io.error('no reason given'),
|
||||
() => captureFdSync(2, () => { io.error('no reason given'); }),
|
||||
(err) => err instanceof ExitError && err.code === 1,
|
||||
);
|
||||
});
|
||||
@@ -746,7 +746,7 @@ describe('#3912 A1/B1: error() declares from ERROR_REASON, exhaustive over the 2
|
||||
test('A2: error() with no reason argument stays FAIL (exit 1) under v2 too — UNKNOWN is not a specific outcome', () => {
|
||||
resolveContractVersion({ argv: ['node', 'x', '--exit-contract=v2'], env: {} });
|
||||
assert.throws(
|
||||
() => io.error('no reason given'),
|
||||
() => captureFdSync(2, () => { io.error('no reason given'); }),
|
||||
(err) => err instanceof ExitError && err.code === 1,
|
||||
);
|
||||
});
|
||||
@@ -756,7 +756,7 @@ describe('#3912 A1/B1: error() declares from ERROR_REASON, exhaustive over the 2
|
||||
resolveContractVersion({ argv: ['node', 'x', '--exit-contract=v2'], env: {} });
|
||||
for (const key of ['SDK_MISSING_ARG', 'SDK_UNKNOWN_COMMAND', 'USAGE']) {
|
||||
assert.throws(
|
||||
() => io.error('msg', io.ERROR_REASON[key]),
|
||||
() => captureFdSync(2, () => { io.error('msg', io.ERROR_REASON[key]); }),
|
||||
(err) => err instanceof ExitError && err.code === 64,
|
||||
`${key} must project to 64 under v2`,
|
||||
);
|
||||
@@ -768,10 +768,14 @@ describe('#3912 A1/B1: error() declares from ERROR_REASON, exhaustive over the 2
|
||||
test('B5 (anti-vacuity): v1 and v2 differ for at least one reason', () => {
|
||||
resolveContractVersion({ argv: ['node', 'x'], env: {} });
|
||||
let v1Code;
|
||||
try { io.error('msg', io.ERROR_REASON.SDK_MISSING_ARG); } catch (e) { v1Code = e.code; }
|
||||
captureFdSync(2, () => {
|
||||
try { io.error('msg', io.ERROR_REASON.SDK_MISSING_ARG); } catch (e) { v1Code = e.code; }
|
||||
});
|
||||
resolveContractVersion({ argv: ['node', 'x', '--exit-contract=v2'], env: {} });
|
||||
let v2Code;
|
||||
try { io.error('msg', io.ERROR_REASON.SDK_MISSING_ARG); } catch (e) { v2Code = e.code; }
|
||||
captureFdSync(2, () => {
|
||||
try { io.error('msg', io.ERROR_REASON.SDK_MISSING_ARG); } catch (e) { v2Code = e.code; }
|
||||
});
|
||||
assert.equal(v1Code, 1);
|
||||
assert.equal(v2Code, 64);
|
||||
assert.notEqual(v1Code, v2Code, 'v1 and v2 must differ for at least one reason, or the declaration is decorative');
|
||||
@@ -990,11 +994,22 @@ describe('review fix: pending-outcome cell lifetime (last-write-wins, cleared on
|
||||
try {
|
||||
// First invocation declares DEGRADED via a payload-carried error and
|
||||
// returns nothing — runMain projects it to 80 under v2.
|
||||
runMain(() => {
|
||||
io.output({ found: false, error: 'not found' }, false);
|
||||
return undefined;
|
||||
//
|
||||
// runMain() defers main() via Promise.resolve().then(...), so the
|
||||
// actual fs.writeSync(1, ...) fires in a later microtask, not
|
||||
// synchronously inside this call. suppressFdAsync (not captureFdAsync)
|
||||
// keeps fs.writeSync patched across that await AND prevents the
|
||||
// deferred write from ever reaching the real fd 1 — this exact window
|
||||
// races node:test's own fd-1 IPC protocol under
|
||||
// --test-isolation=process and forwarding (as captureFdAsync does) was
|
||||
// empirically confirmed to still corrupt it (#4448).
|
||||
await suppressFdAsync(1, async () => {
|
||||
runMain(() => {
|
||||
io.output({ found: false, error: 'not found' }, false);
|
||||
return undefined;
|
||||
});
|
||||
await waitForRunMain();
|
||||
});
|
||||
await waitForRunMain();
|
||||
assert.strictEqual(process.exitCode, 80, 'first runMain should have projected the DEGRADED cell to 80');
|
||||
|
||||
// Second, unrelated invocation in the SAME process declares nothing
|
||||
@@ -1002,8 +1017,10 @@ describe('review fix: pending-outcome cell lifetime (last-write-wins, cleared on
|
||||
// runMain, so this would inherit the first call's stale DEGRADED and
|
||||
// also exit 80 — the exact bug the reviewers found.
|
||||
process.exitCode = undefined;
|
||||
runMain(() => undefined);
|
||||
await waitForRunMain();
|
||||
await suppressFdAsync(1, async () => {
|
||||
runMain(() => undefined);
|
||||
await waitForRunMain();
|
||||
});
|
||||
assert.strictEqual(
|
||||
process.exitCode,
|
||||
undefined,
|
||||
@@ -1018,12 +1035,14 @@ describe('review fix: pending-outcome cell lifetime (last-write-wins, cleared on
|
||||
resolveContractVersion({ argv: ['node', 'x', '--exit-contract=v2'], env: {} });
|
||||
const savedExitCode = process.exitCode;
|
||||
try {
|
||||
runMain(() => {
|
||||
io.output({ found: false, error: 'not found' }, false);
|
||||
io.output({ ok: true }, false);
|
||||
return undefined;
|
||||
await suppressFdAsync(1, async () => {
|
||||
runMain(() => {
|
||||
io.output({ found: false, error: 'not found' }, false);
|
||||
io.output({ ok: true }, false);
|
||||
return undefined;
|
||||
});
|
||||
await waitForRunMain();
|
||||
});
|
||||
await waitForRunMain();
|
||||
assert.strictEqual(
|
||||
process.exitCode,
|
||||
undefined,
|
||||
@@ -1038,13 +1057,29 @@ describe('review fix: pending-outcome cell lifetime (last-write-wins, cleared on
|
||||
resolveContractVersion({ argv: ['node', 'x', '--exit-contract=v2'], env: {} });
|
||||
const savedExitCode = process.exitCode;
|
||||
try {
|
||||
runMain(() => {
|
||||
io.output({ error: 'x' }, false);
|
||||
return undefined;
|
||||
const captured = await suppressFdAsync(1, async () => {
|
||||
runMain(() => {
|
||||
io.output({ error: 'x' }, false);
|
||||
return undefined;
|
||||
});
|
||||
await waitForRunMain();
|
||||
});
|
||||
await waitForRunMain();
|
||||
assert.strictEqual(process.exitCode, 80);
|
||||
assert.strictEqual(getPendingOutcome(), undefined, 'the cell must be cleared once runMain has consumed it');
|
||||
// Load-bearing check on the wrap itself, not just the outcome it
|
||||
// guards: proves suppressFdAsync actually intercepted the deferred
|
||||
// bytes runMain wrote (rather than the patch having been restored
|
||||
// before the deferred write ran, which would silently capture an
|
||||
// empty string — verified as the failure mode of a naive
|
||||
// captureFdSync wrap during development of this fix). These bytes
|
||||
// never reached the real fd 1 by design (#4448) — only the in-memory
|
||||
// recording is asserted on here.
|
||||
const parsedCaptured = JSON.parse(captured);
|
||||
assert.strictEqual(
|
||||
parsedCaptured.error,
|
||||
'x',
|
||||
`expected suppressFdAsync to intercept the output({error}) JSON bytes for fd 1; got: ${JSON.stringify(captured)}`,
|
||||
);
|
||||
} finally {
|
||||
process.exitCode = savedExitCode;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user