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:
@@ -640,6 +640,74 @@ function captureFdSync(captureFd, fn) {
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* NARROW, deliberate exception to `captureFdSync`'s
|
||||
* "never swallow" philosophy (#4306) — do NOT reach for this casually.
|
||||
*
|
||||
* Async twin of `captureFdSync` that awaits `fn()` before restoring the
|
||||
* patch, so a deferred write that happens after a microtask/macrotask
|
||||
* boundary is still safely observed. Its patched `fs.writeSync`
|
||||
* does NOT forward `captureFd`'s writes to the real `fs.writeSync` at all.
|
||||
* It only records the bytes into `chunks` and returns the byte length of
|
||||
* `data` as if the real syscall had succeeded, so a caller that inspects the
|
||||
* return value sees a normal success and not an error. Every OTHER fd's
|
||||
* writes still forward to the real `fs.writeSync` exactly as
|
||||
* `captureFdSync` does — only the `captureFd`-matching branch
|
||||
* differs.
|
||||
*
|
||||
* This exists for #4448: `runMain()`/`io.output()`'s deferred `fs.writeSync(1,
|
||||
* ...)` races Node's own `node:test` child-to-parent IPC, which also uses fd
|
||||
* 1 under the default `--test-isolation=process` — corrupting the parent's
|
||||
* message parsing ("Unable to deserialize cloned data"). An always-forward
|
||||
* capture (the first fix attempted for this issue) does not
|
||||
* fix that: the corrupting write still physically reaches fd 1. Use this
|
||||
* ONLY for a window the caller has verified is narrow and fully controlled —
|
||||
* i.e. nothing else legitimately needs to write to `captureFd` during `fn()`
|
||||
* — such as a single `runMain(...)` call plus its promise-chain settling.
|
||||
* Reaching for this in a window where something else might legitimately
|
||||
* write to `captureFd` will silently swallow that other write.
|
||||
*
|
||||
* @param {number} captureFd - the fd whose writes are suppressed and recorded
|
||||
* (never delivered to the real fd) while `fn()` runs.
|
||||
* @param {() => (Promise<void> | void)} fn - function to run (and await) while suppressing.
|
||||
* @returns {Promise<string>} every byte that WOULD have been written to
|
||||
* `captureFd` during `fn()`, joined as UTF-8 — none of it actually reached
|
||||
* the real fd.
|
||||
*/
|
||||
async function suppressFdAsync(captureFd, fn) {
|
||||
const chunks = [];
|
||||
const orig = fs.writeSync;
|
||||
fs.writeSync = (fd, data, ...rest) => {
|
||||
if (fd === captureFd) {
|
||||
const offset = Buffer.isBuffer(data) && typeof rest[0] === 'number' ? rest[0] : 0;
|
||||
// No real syscall happens here (unlike captureFdSync, which slices to
|
||||
// the real return value `n`), so the caller-requested `length` IS the
|
||||
// count that must be recorded and returned — suppression always
|
||||
// "succeeds" in full, so anything else silently drops or over-reports
|
||||
// bytes.
|
||||
const length = Buffer.isBuffer(data) && typeof rest[1] === 'number' ? rest[1] : undefined;
|
||||
const buf = Buffer.isBuffer(data)
|
||||
? data.subarray(offset, length === undefined ? undefined : offset + length)
|
||||
: Buffer.from(String(data), 'utf8');
|
||||
// Buffered, not decoded per-call: see captureFdSync's identical note on
|
||||
// why joined-then-decoded avoids splitting a multi-byte UTF-8 codepoint.
|
||||
chunks.push(buf);
|
||||
// No real fs.writeSync call for this fd — that is the entire point of
|
||||
// this helper. Return the byte length as if the write succeeded, so a
|
||||
// caller inspecting the return value (Node's own writeSync contract)
|
||||
// sees ordinary success rather than an error.
|
||||
return buf.length;
|
||||
}
|
||||
return orig.call(fs, fd, data, ...rest);
|
||||
};
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
fs.writeSync = orig;
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a workflow .md file plus every .md file under its sibling
|
||||
* `<workflow-basename>/steps/` directory, concatenated in document order
|
||||
@@ -1182,7 +1250,7 @@ function writePackageSourceMarkerFixture(configDir) {
|
||||
return configDir;
|
||||
}
|
||||
|
||||
module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, tmpRootCandidates, readFileNormalized, readWorkflowCombined, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, absPlanningPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, delay, waitFor, resetRuntimeWarningCaches, SESSION_ENV_KEYS, saveSessionEnv, restoreSessionEnv, clearSessionEnv, isolateWorkstreamEnv, restoreWorkstreamEnv, TOOLS_PATH, SESSION_IDENTITY_ENV_KEYS, scrubConfigLocationEnv, installSpawnEnv, installSpawnHome, sandboxHome, writePackageSourceMarkerFixture, TEST_HOME_SANDBOX_MARKER, mockPartialWriteThenThrow, captureFdSync };
|
||||
module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, tmpRootCandidates, readFileNormalized, readWorkflowCombined, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, absPlanningPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, delay, waitFor, resetRuntimeWarningCaches, SESSION_ENV_KEYS, saveSessionEnv, restoreSessionEnv, clearSessionEnv, isolateWorkstreamEnv, restoreWorkstreamEnv, TOOLS_PATH, SESSION_IDENTITY_ENV_KEYS, scrubConfigLocationEnv, installSpawnEnv, installSpawnHome, sandboxHome, writePackageSourceMarkerFixture, TEST_HOME_SANDBOX_MARKER, mockPartialWriteThenThrow, captureFdSync, suppressFdAsync };
|
||||
|
||||
// Lazy, for the reason builtLib() is lazy: reading either of these is what
|
||||
// forces the built-lib require, so a test file that needs neither can still
|
||||
|
||||
Reference in New Issue
Block a user