From b0768b329e7ee526c3278a738a13fc222abd1918 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Thu, 23 Apr 2026 21:19:25 -0600 Subject: [PATCH 1/2] fix(bench): dispose WASM worker pool and keep progress off stdout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v3.9.5 moved WASM tree-sitter parsing into a node:worker_threads Worker (via a shared singleton pool in src/domain/wasm-worker-pool.ts). The benchmark worker scripts never dispose that pool, so the worker thread kept the event loop alive after JSON was written to stdout. The parent fork-engine then SIGKILLed the hung child at 600s and discarded the valid stdout as "Both engines failed." Also fix the embedding benchmark: src/domain/search/models.ts wrote "Embedded N/M\r" progress directly to process.stdout, bypassing the worker's console.log->stderr redirect and corrupting the JSON payload with "Unexpected token 'E'..." errors. - benchmark/query/incremental/resolution scripts: import disposeParsers from the installed package (try/catch for older releases) and call it before cleanup(), then process.exit(0) to guarantee termination. - fork-engine: on SIGKILL, try to salvage captured stdout as JSON before giving up — defence-in-depth against future event-loop-keepalive bugs. - search/models.ts: write progress to stderr so it can't pollute stdout in JSON-consuming benchmark workers. docs check acknowledged — internal benchmark infra fix, no user-facing docs affected. Impact: 7 functions changed, 16 affected --- scripts/benchmark.ts | 9 +++++++++ scripts/incremental-benchmark.ts | 9 +++++++++ scripts/lib/fork-engine.ts | 13 ++++++++++++- scripts/query-benchmark.ts | 9 +++++++++ scripts/resolution-benchmark.ts | 10 ++++++++++ src/domain/search/models.ts | 2 +- 6 files changed, 50 insertions(+), 2 deletions(-) diff --git a/scripts/benchmark.ts b/scripts/benchmark.ts index e989a48b..aaacb65d 100644 --- a/scripts/benchmark.ts +++ b/scripts/benchmark.ts @@ -82,6 +82,13 @@ const { buildGraph } = await import(srcImport(srcDir, 'domain/graph/builder.js') const { fnDepsData, fnImpactData, pathData, rolesData, statsData } = await import( srcImport(srcDir, 'domain/queries.js') ); +// v3.9.5+ parses WASM in a worker_thread that keeps the event loop alive until +// disposed. Older releases don't export disposeParsers — fall back to a no-op. +let disposeParsers = async () => {}; +try { + const parser = await import(srcImport(srcDir, 'domain/parser.js')); + if (typeof parser.disposeParsers === 'function') disposeParsers = parser.disposeParsers; +} catch { /* older release — no worker pool to dispose */ } const INCREMENTAL_RUNS = 3; const QUERY_RUNS = 5; @@ -226,4 +233,6 @@ const workerResult = { console.log(JSON.stringify(workerResult)); +await disposeParsers(); cleanup(); +process.exit(0); diff --git a/scripts/incremental-benchmark.ts b/scripts/incremental-benchmark.ts index 8758fb17..f2f5a38c 100644 --- a/scripts/incremental-benchmark.ts +++ b/scripts/incremental-benchmark.ts @@ -143,6 +143,13 @@ const { srcDir, cleanup } = await resolveBenchmarkSource(); const dbPath = path.join(root, '.codegraph', 'graph.db'); const { buildGraph } = await import(srcImport(srcDir, 'domain/graph/builder.js')); +// v3.9.5+ parses WASM in a worker_thread that keeps the event loop alive until +// disposed. Older releases don't export disposeParsers — fall back to a no-op. +let disposeParsers = async () => {}; +try { + const parser = await import(srcImport(srcDir, 'domain/parser.js')); + if (typeof parser.disposeParsers === 'function') disposeParsers = parser.disposeParsers; +} catch { /* older release — no worker pool to dispose */ } // Redirect console.log to stderr so only JSON goes to stdout const origLog = console.log; @@ -218,4 +225,6 @@ console.log = origLog; const workerResult = { fullBuildMs, noopRebuildMs, oneFileRebuildMs, oneFilePhases }; console.log(JSON.stringify(workerResult)); +await disposeParsers(); cleanup(); +process.exit(0); diff --git a/scripts/lib/fork-engine.ts b/scripts/lib/fork-engine.ts index 12a704a5..945a9a66 100644 --- a/scripts/lib/fork-engine.ts +++ b/scripts/lib/fork-engine.ts @@ -93,7 +93,18 @@ export function forkWorker(scriptPath, envKey, workerName, argv = [], timeoutMs if (signal) { console.error(`[fork] ${workerName} worker killed by signal ${signal}`); - settle(null); + // A worker can finish its measurements and write valid JSON to + // stdout before hanging on a non-daemonized handle (e.g. an + // un-disposed worker_thread pool). SIGKILL from our timeout + // then discards the result. Try to parse captured stdout first + // so we don't lose real data to a lingering event-loop keepalive. + try { + const parsed = JSON.parse(stdout); + console.error(`[fork] ${workerName} worker produced results before being killed — salvaging`); + settle(parsed); + } catch { + settle(null); + } return; } diff --git a/scripts/query-benchmark.ts b/scripts/query-benchmark.ts index f3d9d1ad..18cdf65a 100644 --- a/scripts/query-benchmark.ts +++ b/scripts/query-benchmark.ts @@ -94,6 +94,13 @@ const { buildGraph } = await import(srcImport(srcDir, 'domain/graph/builder.js') const { fnDepsData, fnImpactData, diffImpactData } = await import( srcImport(srcDir, 'domain/queries.js') ); +// v3.9.5+ parses WASM in a worker_thread that keeps the event loop alive until +// disposed. Older releases don't export disposeParsers — fall back to a no-op. +let disposeParsers = async () => {}; +try { + const parser = await import(srcImport(srcDir, 'domain/parser.js')); + if (typeof parser.disposeParsers === 'function') disposeParsers = parser.disposeParsers; +} catch { /* older release — no worker pool to dispose */ } // Redirect console.log to stderr so only JSON goes to stdout const origLog = console.log; @@ -259,4 +266,6 @@ console.log = origLog; const workerResult = { targets, fnDeps, fnImpact, diffImpact }; console.log(JSON.stringify(workerResult)); +await disposeParsers(); cleanup(); +process.exit(0); diff --git a/scripts/resolution-benchmark.ts b/scripts/resolution-benchmark.ts index de50426a..0acbe748 100644 --- a/scripts/resolution-benchmark.ts +++ b/scripts/resolution-benchmark.ts @@ -219,6 +219,14 @@ console.log = (...args) => console.error(...args); const { srcDir, cleanup } = await resolveBenchmarkSource(); +// v3.9.5+ parses WASM in a worker_thread that keeps the event loop alive until +// disposed. Older releases don't export disposeParsers — fall back to a no-op. +let disposeParsers = async () => {}; +try { + const parser = await import(srcImport(srcDir, 'domain/parser.js')); + if (typeof parser.disposeParsers === 'function') disposeParsers = parser.disposeParsers; +} catch { /* older release — no worker pool to dispose */ } + try { const { buildGraph } = await import(srcImport(srcDir, 'domain/graph/builder.js')); const { openReadonlyOrFail } = await import(srcImport(srcDir, 'db/index.js')); @@ -296,5 +304,7 @@ try { console.log(JSON.stringify(results, null, 2)); } finally { console.log = origLog; + await disposeParsers(); cleanup(); + process.exit(0); } diff --git a/src/domain/search/models.ts b/src/domain/search/models.ts index 67554de0..54ce5956 100644 --- a/src/domain/search/models.ts +++ b/src/domain/search/models.ts @@ -253,7 +253,7 @@ export async function embed( } if (texts.length > batchSize) { - process.stdout.write(` Embedded ${Math.min(i + batchSize, texts.length)}/${texts.length}\r`); + process.stderr.write(` Embedded ${Math.min(i + batchSize, texts.length)}/${texts.length}\r`); } } From 873a4355e9ae9e2b87ed144476150c4aea149cb8 Mon Sep 17 00:00:00 2001 From: carlos-alm <127798846+carlos-alm@users.noreply.github.com> Date: Fri, 24 Apr 2026 00:10:21 -0600 Subject: [PATCH 2/2] fix(bench): move process.exit(0) outside finally to preserve exception exit codes (#1009) Addresses Greptile P1: placing process.exit(0) inside the finally block would swallow exceptions from the try body (missing fixture, DB open failure, import error) and exit with code 0, masking the failure from CI and the parent forkWorker. Mirror the pattern in benchmark.ts/incremental-benchmark.ts/ query-benchmark.ts: exit only after the success path completes. --- scripts/resolution-benchmark.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/resolution-benchmark.ts b/scripts/resolution-benchmark.ts index 0acbe748..9252028e 100644 --- a/scripts/resolution-benchmark.ts +++ b/scripts/resolution-benchmark.ts @@ -306,5 +306,5 @@ try { console.log = origLog; await disposeParsers(); cleanup(); - process.exit(0); } +process.exit(0);