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:
Tom Boucher
2026-05-23 23:10:06 -04:00
committed by GitHub
parent 6177e3a5f5
commit 3169d5cda6
2 changed files with 24 additions and 2 deletions

View File

@@ -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

View File

@@ -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,
};
}