From 3169d5cda6ca73f5eb575c3830971a5537e697e1 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 23 May 2026 23:10:06 -0400 Subject: [PATCH] =?UTF-8?q?fix(ci):=20reduce=20Windows=20test=20concurrenc?= =?UTF-8?q?y=204=E2=86=922=20to=20prevent=20synckit=20worker=20exhaustion?= =?UTF-8?q?=20on=20Node=2024=20(#173)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- scripts/run-tests.cjs | 16 +++++++++++++++- tests/helpers.cjs | 10 +++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/scripts/run-tests.cjs b/scripts/run-tests.cjs index 1e065d986..53f4a8964 100644 --- a/scripts/run-tests.cjs +++ b/scripts/run-tests.cjs @@ -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 diff --git a/tests/helpers.cjs b/tests/helpers.cjs index 109c9838a..9c9869dfc 100644 --- a/tests/helpers.cjs +++ b/tests/helpers.cjs @@ -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, }; }