fix(extraction): never terminate a parse worker while it is loading grammars (#2301)

On a loaded Windows machine `codegraph init` / `index` sometimes died right
after the parse phase with 0xC0000005 (STATUS_ACCESS_VIOLATION), no WER report
and no output past the last parsed file. The parse pool's destroy() terminated
every worker at once, and a short index often ends while a late-spawned worker
is still compiling its grammar WASM; terminating a worker with that compile in
flight crashes the process.

Reproduced on a Windows 11 ARM64 VM without CodeGraph's pipeline: starting
three parse workers with preloaded grammar bytes and terminating them mid-load
crashed 5 of 720 child processes, terminating them only after
grammars-loaded crashed 0 of 360. Under the real load (24 parallel
`codegraph init` runs on 4 cores) main crashed after parsing in 8 of 1,200
runs; with this change 0 of 1,080 did.

destroy() now waits for a worker that is still loading to report
grammars-loaded or exit before terminating it, capped at 15 s so a wedged load
can't hold teardown forever. Recycling only ever touches idle workers, and a
hung parse is still killed by the hard timeout.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-02 08:48:01 +00:00
committed by GitHub
parent 8abebda02c
commit 91b9bba133
3 changed files with 131 additions and 3 deletions
+1
View File
@@ -174,6 +174,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- On Windows, a project opened with a different drive-letter or folder-name case, like `d:\work\app` and `D:\Work\App`, now joins the CodeGraph background server that is already running for it. Before, each spelling started a server of its own that exited at once, so the session quietly served the graph by itself with no shared file watching or auto-sync, and `codegraph list` showed the project twice. Thanks @greatjackzhou. (#2278)
- An agent session that lost its connection to CodeGraph's shared background server, or couldn't reach it at startup, no longer stops that server from running for the rest of the session. It reconnects on its own once the server can start, so other sessions on the same project get auto-sync back instead of running read-only. Thanks @ijbranch for the report. (#2277)
- On Windows, `codegraph upgrade` no longer breaks the install while an agent session is using CodeGraph. It used to stop partway and leave CodeGraph unable to start; it now swaps the new files in with CodeGraph running and puts the previous version back if anything fails, and re-running the PowerShell installer works the same way. If an earlier upgrade already broke your install, re-run `irm https://raw.githubusercontent.com/colbymchenry/codegraph/main/install.ps1 | iex` to repair it. Thanks @tippmar-nr for the report. (#2185)
- On Windows, `codegraph init`, `codegraph index` and the background server's syncs no longer sometimes crash right after parsing on a busy machine (exit code 3221225477, an access violation). Shutting down the parsing threads could stop one while it was still loading its language grammars, which took the whole process down; they now finish loading first.
## [1.6.1] - 2026-09-29
+79
View File
@@ -248,3 +248,82 @@ describe('ParseWorkerPool', () => {
await expect(pool.requestParse(task('y.ts'))).rejects.toThrow(/destroyed/);
});
});
/**
* A fake whose grammar load takes `loadMs` (or never finishes, with `null`), and
* which records when it finished loading and when it was terminated.
*/
class SlowLoadWorker implements ParsePoolWorker {
private msgCb?: (m: unknown) => void;
private exitCb?: (code: number) => void;
loadedAt: number | null = null;
terminatedAt: number | null = null;
constructor(private loadMs: number | null) {}
on(event: string, cb: (...args: any[]) => void): void {
if (event === 'message') this.msgCb = cb;
else if (event === 'exit') this.exitCb = cb;
}
postMessage(msg: unknown): void {
if ((msg as { type: string }).type !== 'load-grammars' || this.loadMs === null) return;
setTimeout(() => {
if (this.terminatedAt !== null) return;
this.loadedAt = performance.now();
this.msgCb?.({ type: 'grammars-loaded' });
}, this.loadMs);
}
exit(code: number): void { this.exitCb?.(code); }
terminate(): Promise<number> { this.terminatedAt = performance.now(); return Promise.resolve(0); }
}
describe('teardown never terminates a worker mid-load', () => {
// Terminating a worker whose grammar WASM is still compiling can crash the
// process with an access violation (0xC0000005) on Windows; a pool torn down
// right after a short index often has a late-spawned worker in that state.
const poolOf = (worker: SlowLoadWorker, loadSettleMs?: number) =>
new ParseWorkerPool({ languages: ['typescript'] as Language[], size: 1, createWorker: () => worker, loadSettleMs });
it('waits for a loading worker to finish before terminating it', async () => {
const w = new SlowLoadWorker(120);
const pool = poolOf(w);
await pool.destroy();
expect(w.loadedAt).not.toBeNull();
expect(w.terminatedAt).not.toBeNull();
expect(w.terminatedAt!).toBeGreaterThanOrEqual(w.loadedAt!);
});
it('terminates a worker that is already loaded right away', async () => {
const w = new SlowLoadWorker(0);
const pool = poolOf(w);
await sleep(20);
expect(w.loadedAt).not.toBeNull();
const t0 = performance.now();
await pool.destroy();
expect(w.terminatedAt! - t0).toBeLessThan(50);
});
it('does not wait past the cap on a load that never finishes', async () => {
const w = new SlowLoadWorker(null);
const pool = poolOf(w, 80);
const t0 = performance.now();
await pool.destroy();
expect(w.terminatedAt).not.toBeNull();
expect(w.terminatedAt! - t0).toBeGreaterThanOrEqual(70);
expect(w.terminatedAt! - t0).toBeLessThan(1000);
});
it('does not wait on a worker that died while loading', async () => {
const w = new SlowLoadWorker(null);
// The pool respawns a crashed worker; the replacement loads normally. The
// default 15s cap stays, so waiting on the dead one would time the test out.
const workers = [w];
const pool = new ParseWorkerPool({
languages: ['typescript'] as Language[], size: 1,
createWorker: () => workers.shift() ?? new SlowLoadWorker(0),
});
w.exit(1);
await sleep(20);
const t0 = performance.now();
await pool.destroy();
expect(performance.now() - t0).toBeLessThan(200);
});
});
+51 -3
View File
@@ -75,6 +75,16 @@ const MAX_SCALED_PARSE_TIMEOUT_MS = 20_000;
* only a worker that stays silent the whole window is treated as hung.
*/
const HARD_KILL_MULTIPLIER = 3;
/**
* How long `destroy()` waits for a worker that is still loading its grammars
* before terminating it anyway. Terminating a worker whose WebAssembly
* compiles are still in flight can take the whole process down with an access
* violation (0xC0000005, seen on Windows): a pool torn down right after a short
* index often has a late-spawned worker in exactly that state. A load finishes
* in well under a second normally and a few seconds under heavy load; the cap
* only bounds a load that is wedged.
*/
const GRAMMAR_LOAD_SETTLE_MS = 15_000;
/**
* Max workers cold-starting at once. A worker's cold start is heavy (module load
* + grammar WASM compile); starting the whole pool simultaneously thrashes CPU.
@@ -169,6 +179,8 @@ export interface ParseWorkerPoolOptions {
parseTimeoutMs?: number;
/** Worker factory (tests inject a fake). Defaults to a real `worker_threads` Worker. */
createWorker?: () => ParsePoolWorker;
/** How long destroy() waits on a worker still loading grammars (tests shorten it). Default 15s. */
loadSettleMs?: number;
/** Optional verbose logger (the orchestrator's `[worker] …` logger). */
log?: (msg: string) => void;
/**
@@ -190,6 +202,10 @@ export class ParseWorkerPool {
// Spawned but not yet 'grammars-loaded'. Growth counts these so a single first
// parse doesn't spawn the whole pool before the eager worker reports ready.
private pending = new Set<ParsePoolWorker>();
// Each worker's grammar load, settled when it reports 'grammars-loaded' or
// goes away — what destroy() waits on before terminating it (see
// GRAMMAR_LOAD_SETTLE_MS).
private loads = new Map<ParsePoolWorker, { settled: Promise<void>; settle: () => void }>();
private parseCounts = new Map<ParsePoolWorker, number>();
private nextId = 1;
private totalCrashes = 0;
@@ -200,6 +216,7 @@ export class ParseWorkerPool {
private readonly recycleInterval: number;
private readonly parseTimeoutMs: number;
private readonly createWorker: () => ParsePoolWorker;
private readonly loadSettleMs: number;
private readonly log: (msg: string) => void;
private readonly grammarBuffers?: Record<string, Uint8Array>;
@@ -209,6 +226,7 @@ export class ParseWorkerPool {
this.maxSize = Math.max(1, Math.min(opts.size, MAX_PARSE_POOL_SIZE));
this.recycleInterval = opts.recycleInterval ?? DEFAULT_RECYCLE_INTERVAL;
this.parseTimeoutMs = opts.parseTimeoutMs ?? DEFAULT_PARSE_TIMEOUT_MS;
this.loadSettleMs = opts.loadSettleMs ?? GRAMMAR_LOAD_SETTLE_MS;
this.log = opts.log ?? (() => {});
if (opts.createWorker) {
this.createWorker = opts.createWorker;
@@ -278,9 +296,12 @@ export class ParseWorkerPool {
this.workers.add(w);
this.pending.add(w);
this.parseCounts.set(w, 0);
let settle!: () => void;
const settled = new Promise<void>((resolve) => { settle = resolve; });
this.loads.set(w, { settled, settle });
w.on('message', (m) => this.onMessage(w, (m ?? {}) as ParseWorkerMessage));
w.on('error', (e) => this.onWorkerGone(w, `Worker error: ${e?.message ?? 'unknown'}`));
w.on('exit', (code) => { if (code !== 0) this.onWorkerGone(w, `Worker exited with code ${code}`); });
w.on('error', (e) => { this.loadSettled(w); this.onWorkerGone(w, `Worker error: ${e?.message ?? 'unknown'}`); });
w.on('exit', (code) => { this.loadSettled(w); if (code !== 0) this.onWorkerGone(w, `Worker exited with code ${code}`); });
// Load grammars; the worker replies 'grammars-loaded' and only then is idle.
// Pre-read WASM bytes (when the orchestrator provided them) make this a
// memory load instead of a per-spawn disk read.
@@ -289,6 +310,7 @@ export class ParseWorkerPool {
private onMessage(w: ParsePoolWorker, m: ParseWorkerMessage): void {
if (m.type === 'grammars-loaded') {
this.loadSettled(w);
if (!this.workers.has(w)) return; // recycled/destroyed before ready
this.pending.delete(w);
this.idle.push(w);
@@ -351,6 +373,32 @@ export class ParseWorkerPool {
if (this.healthy && !this.destroyed) this.spawnOne();
}
/** The worker's grammar load is over: it reported ready, errored or exited. */
private loadSettled(w: ParsePoolWorker): void {
const load = this.loads.get(w);
if (!load) return;
this.loads.delete(w);
load.settle();
}
/**
* Terminate a worker, but not while it is still loading grammars: wait for
* the load to finish (or the worker to go away), up to `loadSettleMs`
* (GRAMMAR_LOAD_SETTLE_MS — see that constant for the crash this avoids).
*/
private async terminateSettled(w: ParsePoolWorker): Promise<void> {
const load = this.loads.get(w);
if (load) {
let cap: NodeJS.Timeout | undefined;
await Promise.race([
load.settled,
new Promise<void>((resolve) => { cap = setTimeout(resolve, this.loadSettleMs); cap.unref?.(); }),
]);
clearTimeout(cap);
}
try { await w.terminate(); } catch { /* already gone */ }
}
private removeWorker(w: ParsePoolWorker): void {
this.workers.delete(w);
this.pending.delete(w);
@@ -471,6 +519,6 @@ export class ParseWorkerPool {
}
this.inflight.clear();
this.queue = [];
await Promise.all(ws.map((w) => Promise.resolve(w.terminate()).catch(() => { /* already gone */ })));
await Promise.all(ws.map((w) => this.terminateSettled(w)));
}
}