fix(ci): reduce Windows test concurrency 4→2 to prevent synckit worker exhaustion on Node 24 (#173)
Under Node 24 on Windows, running node --test with --test-concurrency=4 causes 4 concurrent gsd-tools subprocesses to each spawn a synckit worker_threads worker for the SDK bridge. The 4 workers simultaneously contend on SharedArrayBuffer + Atomics.wait under Windows Defender scanning and NTFS latency, triggering OS-level resource exhaustion that kills worker processes with empty stderr before any output is flushed. The symptom: intermittent exit 1 with 0 test failures, varying affected test files per run, all sharing the pattern of invoking gsd-tools as a subprocess. Empty stderr distinguishes OS crash from gsd-tools app error (the error() path writes to stderr before exiting). Fix: platform-aware concurrency default — 2 on win32, 4 on Linux/macOS. The existing TEST_CONCURRENCY env-var override is preserved. Also adds a [stderr: (empty) exit:N] diagnostic note in helpers.cjs runGsdTools catch block so future empty-stderr crashes are visible in CI logs. Fixes gsd-build/get-shit-done#3869 Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -126,9 +126,23 @@ function main() {
|
||||
.join(' ')}`,
|
||||
);
|
||||
|
||||
// Default concurrency: 4 on Linux/macOS, 2 on Windows.
|
||||
//
|
||||
// Windows has significantly higher per-subprocess overhead than Linux/macOS:
|
||||
// - Windows Defender scans each spawned process
|
||||
// - NTFS has higher file-system latency under concurrent access
|
||||
// - synckit worker_threads (used by the SDK bridge in gsd-tools.cjs) spawn
|
||||
// native threads that contend on SharedArrayBuffer + Atomics.wait; under
|
||||
// Node 24 on Windows, 4-way concurrent gsd-tools invocations (each spawning
|
||||
// a synckit worker) caused intermittent process crashes with empty stderr —
|
||||
// a signature of OS-level resource exhaustion killing worker threads before
|
||||
// they could flush. Reducing to 2 halves the peak concurrent worker count.
|
||||
//
|
||||
// Operator override via TEST_CONCURRENCY env var for local debugging.
|
||||
const defaultConcurrency = process.platform === 'win32' ? 2 : 4;
|
||||
const concurrency = process.env.TEST_CONCURRENCY
|
||||
? `--test-concurrency=${process.env.TEST_CONCURRENCY}`
|
||||
: '--test-concurrency=4';
|
||||
: `--test-concurrency=${defaultConcurrency}`;
|
||||
|
||||
// Windows `CreateProcess` caps the full command line at 32,767 chars
|
||||
// (lpCommandLine). With 500+ test paths the spawn fails instantly with no
|
||||
|
||||
@@ -62,10 +62,18 @@ function runGsdTools(args, cwd = process.cwd(), env = {}) {
|
||||
}
|
||||
return { success: true, output: result.trim(), exitCode: 0 };
|
||||
} catch (err) {
|
||||
const stderrRaw = err.stderr?.toString().trim() || '';
|
||||
// Prefer actual stderr content; fall back to err.message (which contains
|
||||
// the command invocation). If stderr is empty, append a note so CI logs
|
||||
// show "stderr: (empty)" rather than silently losing the fact that the
|
||||
// child process produced no error output — empty stderr with a non-zero
|
||||
// exit code is a signal of OS-level crash (OOM kill, worker thread fatal
|
||||
// error) rather than a gsd-tools application error.
|
||||
const error = stderrRaw || `${err.message} [stderr: (empty) exit:${err.status ?? 1}]`;
|
||||
return {
|
||||
success: false,
|
||||
output: err.stdout?.toString().trim() || '',
|
||||
error: err.stderr?.toString().trim() || err.message,
|
||||
error,
|
||||
exitCode: err.status ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user