fix(3597): chunk node --test spawn to survive Windows CreateProcess limit
Windows CreateProcess caps lpCommandLine at 32,767 chars. The original `execFileSync(node, ['--test', ...546 paths])` exceeded that on every Windows runner and exited within ~70ms with no test output. Linux/macOS allow ~2 MB ARG_MAX so the same call worked there. `scripts/run-tests.cjs` now splits selected files into chunks that keep each spawn's argv under 28,000 chars (operator-overridable via RUN_TESTS_MAX_CMDLINE_CHARS), runs them sequentially, and reports the first non-zero exit. Cross-platform regression test forces chunking with a low ceiling and asserts the `run-tests: chunk N/M …` stderr marker. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.changeset/3597-windows-argv-overflow.md
Normal file
5
.changeset/3597-windows-argv-overflow.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
type: Fixed
|
||||||
|
pr: 3649
|
||||||
|
---
|
||||||
|
**`scripts/run-tests.cjs` no longer fails on Windows when invoking many test files at once** — Windows `CreateProcess` caps `lpCommandLine` at 32,767 chars, so an unchunked spawn of `node --test <546 paths>` aborted instantly with exit 1 and no test output (Linux/macOS allow ~2 MB so the same path worked there). The harness now batches selected files into chunks whose total argv stays under 28,000 chars and runs each chunk sequentially, reporting `run-tests: chunk N/M — K files` to stderr. The ceiling is overridable via `RUN_TESTS_MAX_CMDLINE_CHARS` for tuning and tests. Adds a cross-platform regression test that forces chunking with a low ceiling.
|
||||||
@@ -130,14 +130,49 @@ function main() {
|
|||||||
? `--test-concurrency=${process.env.TEST_CONCURRENCY}`
|
? `--test-concurrency=${process.env.TEST_CONCURRENCY}`
|
||||||
: '--test-concurrency=4';
|
: '--test-concurrency=4';
|
||||||
|
|
||||||
try {
|
// Windows `CreateProcess` caps the full command line at 32,767 chars
|
||||||
execFileSync(process.execPath, ['--test', concurrency, ...selected], {
|
// (lpCommandLine). With 500+ test paths the spawn fails instantly with no
|
||||||
stdio: 'inherit',
|
// test output. Linux/macOS allow ~2 MB (ARG_MAX) so unchunked spawns are
|
||||||
env: { ...process.env },
|
// fine there. Split into chunks sized for the tightest target so behavior
|
||||||
});
|
// is identical across platforms. (#3597)
|
||||||
} catch (err) {
|
// Operator override (also used by tests to force chunking with short paths).
|
||||||
process.exit(err.status || 1);
|
const MAX_CMDLINE_CHARS = process.env.RUN_TESTS_MAX_CMDLINE_CHARS
|
||||||
|
? Number(process.env.RUN_TESTS_MAX_CMDLINE_CHARS)
|
||||||
|
: 28000; // headroom below the 32,767 Windows ceiling
|
||||||
|
const FIXED_OVERHEAD = process.execPath.length + '--test'.length + concurrency.length + 8;
|
||||||
|
const chunks = [];
|
||||||
|
let current = [];
|
||||||
|
let currentLen = FIXED_OVERHEAD;
|
||||||
|
for (const file of selected) {
|
||||||
|
const add = file.length + 1; // +1 for the inter-arg separator
|
||||||
|
if (current.length > 0 && currentLen + add > MAX_CMDLINE_CHARS) {
|
||||||
|
chunks.push(current);
|
||||||
|
current = [];
|
||||||
|
currentLen = FIXED_OVERHEAD;
|
||||||
|
}
|
||||||
|
current.push(file);
|
||||||
|
currentLen += add;
|
||||||
}
|
}
|
||||||
|
if (current.length > 0) chunks.push(current);
|
||||||
|
|
||||||
|
let firstFailureExit = 0;
|
||||||
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
|
if (chunks.length > 1) {
|
||||||
|
console.error(`run-tests: chunk ${i + 1}/${chunks.length} — ${chunks[i].length} files`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
execFileSync(process.execPath, ['--test', concurrency, ...chunks[i]], {
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: { ...process.env },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const code = err.status || 1;
|
||||||
|
// Run every chunk so the operator sees all failures in one pass; report
|
||||||
|
// the first non-zero exit at the end.
|
||||||
|
if (firstFailureExit === 0) firstFailureExit = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (firstFailureExit !== 0) process.exit(firstFailureExit);
|
||||||
}
|
}
|
||||||
|
|
||||||
main();
|
main();
|
||||||
|
|||||||
@@ -34,10 +34,10 @@ function seed(dir, names) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function runHarness(testDir, args = []) {
|
function runHarness(testDir, args = [], extraEnv = {}) {
|
||||||
// Clear node:test parent-context env so the harness's child `node --test`
|
// Clear node:test parent-context env so the harness's child `node --test`
|
||||||
// doesn't refuse to run with "recursive run() skipping running files".
|
// doesn't refuse to run with "recursive run() skipping running files".
|
||||||
const env = { ...process.env, GSD_TEST_DIR: testDir };
|
const env = { ...process.env, GSD_TEST_DIR: testDir, ...extraEnv };
|
||||||
delete env.NODE_TEST_CONTEXT;
|
delete env.NODE_TEST_CONTEXT;
|
||||||
return spawnSync(process.execPath, [HARNESS, ...args], {
|
return spawnSync(process.execPath, [HARNESS, ...args], {
|
||||||
cwd: path.join(__dirname, '..'),
|
cwd: path.join(__dirname, '..'),
|
||||||
@@ -207,4 +207,34 @@ test('boom', () => { throw new Error('intentional'); });
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Windows argv-overflow chunking (issue #3597)', () => {
|
||||||
|
// Windows CreateProcess caps lpCommandLine at 32,767 chars. With ~550
|
||||||
|
// tests the unchunked spawn fails instantly on Windows with no test
|
||||||
|
// output. Linux/macOS allow ~2 MB so the same path works there. The
|
||||||
|
// harness chunks selected files so each spawn stays under the ceiling,
|
||||||
|
// and chunking is observable via the `run-tests: chunk N/M …` stderr
|
||||||
|
// line. Long filenames force chunking even with a modest file count so
|
||||||
|
// the test stays fast on every platform.
|
||||||
|
test('chunks when total argv would exceed configured ceiling', () => {
|
||||||
|
// Use a deliberately low MAX_CMDLINE_CHARS so the test is independent
|
||||||
|
// of tmp-path length (varies by OS). With a 2000-char ceiling and 30
|
||||||
|
// tests at ≥100 char paths, chunking must engage and at least one
|
||||||
|
// `chunk N/M …` marker must appear in stderr.
|
||||||
|
const longPrefix = 'a-deliberately-long-test-filename-to-force-chunking-behavior-cross-platform-';
|
||||||
|
const names = Array.from({ length: 30 }, (_, i) => `${longPrefix}${String(i).padStart(4, '0')}.test.cjs`);
|
||||||
|
seed(tmpDir, names);
|
||||||
|
const r = runHarness(tmpDir, [], { RUN_TESTS_MAX_CMDLINE_CHARS: '2000' });
|
||||||
|
assert.strictEqual(
|
||||||
|
r.status,
|
||||||
|
0,
|
||||||
|
`expected zero exit; got status=${r.status} signal=${r.signal}\nSTDERR (tail):\n${r.stderr.split('\n').slice(-20).join('\n')}`,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
r.stderr,
|
||||||
|
/run-tests: chunk \d+\/\d+ — \d+ files/,
|
||||||
|
`expected chunking marker in stderr; STDERR (tail):\n${r.stderr.split('\n').slice(-20).join('\n')}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user