mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-21 00:38:18 +08:00
838006c947
Fixes #1581.
## What was wrong
`codegraph init` / `codegraph index` died with `Segmentation fault` — the whole CLI
process, not a parse worker — on a C/C++ file with very deep brace nesting (llvm's
`clang/test/Parser/parser_overflow.c`, 16,384 nested `{`). The reporter's diagnosis is
exactly right: tree-sitter's parser is iterative, so the file parses fine, and then the
native kernel's **recursive walker** (`visit_node` → `visit_for_calls_and_structure` → …,
one frame per AST level) overflowed the thread's stack. A native overflow can't be caught
the way a wasm abort can, and a parse worker is a thread of the `codegraph` process, so
the SIGSEGV took the entire indexer down — no message, no per-file fallback, no partial
index.
Two things made "just give the worker a bigger stack" the wrong fix:
- it only moves the cliff — reproduced here: the reporter's 16,384-deep file kills a
default 4 MiB worker (rc=132 on macOS / 139 on Linux), and a 100k-deep file kills the
8 MiB **main** thread too;
- the walkers are shared by every kernel-routed language (20 of them), and each has
several recursion points with different frame sizes, so no single stack size is a
provable bound.
Meanwhile the wasm path already handles this shape gracefully: its JS walker catches its
own `RangeError` per file and stores a partial result with a `parse_error`. The kernel
just needed a way to get there instead of dying.
## What this does
**The kernel guards its own recursion against the calling thread's real stack bounds and
defers a too-deep file to wasm** — the same `defer:` routing signal it already uses for
files with parse errors, which `src/extraction/kernel/index.ts` treats as "take the wasm
path for this file", silently.
- `codegraph-kernel/src/stack.rs`: per-thread stack bounds from the OS, computed once per
thread and cached — glibc/musl `pthread_getattr_np` + `pthread_attr_getstack`, macOS
`pthread_get_stackaddr_np` + `pthread_get_stacksize_np`, Win32
`GetCurrentThreadStackLimits` (a hand-declared `kernel32` extern; no `windows-sys`).
`exhausted()` is one thread-local load and one compare: true once the stack pointer is
within a 256 KiB red zone of the limit, and it latches a flag. Where the OS can't report
bounds it falls back to a fixed 1 MiB descent budget measured from the entry stack
pointer — safe on anything from Node's 4 MiB worker default up. So the guard is exact on
the 4 MiB worker, the 8 MiB main thread, and any `resourceLimits.stackSizeMb` alike.
- `stack_guard!()` (defined in `lib.rs`) is the first statement of every recursive walker
function — all **150** self-recursive or on-cycle functions across the 15 walker modules,
found by script (every cycle in the call graph, not just direct self-calls). It returns
`Default::default()` (`()`, `false`, `None`, `""`) so an exhausted walk simply stops
descending; a hook returning `false` sends its caller down the generic child walk, whose
own guard returns at once.
- `extract_file` runs the whole walk under `stack::run_guarded`: if the flag is set
afterwards the (truncated) result is discarded and replaced by
`defer: nesting too deep for the native walker — wasm recovery handles it`.
- `parse-pool.ts`: a comment at `new Worker(scriptPath)` records why there is deliberately
no `resourceLimits.stackSizeMb` bump.
- No new crates beyond `libc` as a direct unix dependency (already in `Cargo.lock`
transitively). No wire/ABI change.
Net effect for the reporter's repo: `deep.c` goes to the wasm path, lands as
`function foo` plus a recorded parse warning, and the other 31,607 files index normally.
`CODEGRAPH_KERNEL=0` and the `exclude` workaround are no longer needed.
## Tests
**Rust unit tests** (`cargo test`, 21 passed — 7 new in `stack.rs`): the walkers for
C, C++, Rust, TypeScript and Python are driven on a **1 MiB** thread (a quarter of Node's
worker default) with 30k-deep nesting and must return `defer:` instead of crashing;
shallow files are untouched; the latch resets between runs; the OS bounds are sane on the
main thread and describe a small thread's own stack.
**`__tests__/kernel-deep-nesting.test.ts`** (new, 8 tests — skips without a staged `.node`,
fails under `CODEGRAPH_KERNEL_EXPECT=1` if the kernel is missing, like the other kernel
suites):
- every default-routed language (all 20) survives a 60k-deep expression on the main thread
— clean result or the wasm fallback's partial result, never a crash;
- the reporter's exact 16,384-brace C file is indexed (partial) on the main thread;
- 200-deep expressions in every language still take the kernel path clean (the guard never
trips on normal code);
- inside a **default-sized 4 MiB `worker_threads` Worker** through `dist/`: the reporter's
`deep.c` and a 60k-deep expression in every language come back `deferred` with exit 0,
and a normal file still extracts natively;
- end-to-end through the built CLI: `codegraph init` on a repo holding `deep.c` + `ok.c`
exits 0 and records both files, with both functions.
**Existing kernel suites**: all 15 (`kernel-*-parity`, `kernel-scaffold`,
`kernel-retry-materialize`, `kernel-grammar-parity`) pass unchanged, 147 tests — the guard
never fires on the parity fixtures.
**Reporter's probes** (`one.js` from the issue, default 4 MiB worker, this build):
`deep.c` → `deferred`, exitCode=0 (was rc=132/139); `deep100k.c` → `deferred`, exitCode=0.
Main thread: `deep.c` / `deep100k.c` → wasm partial with
`Parse error: Maximum call stack size exceeded`; a 6,000-term binary expression and a
3,000-branch `else if` chain stay on the kernel path with clean results.
**Perf** (same `dist/`, only the `.node` swapped via `CODEGRAPH_KERNEL_PATH`; interleaved
main/new ×3, `codegraph init`, macOS arm64):
| repo | main (median) | guarded (median) | nodes / edges |
|---|---|---|---|
| express (141 files) | 0.60 s (0.58–0.65) | 0.61 s (0.58–0.61) | 1,084 / identical |
| redis (786 C/H files) | 4.44 s (4.39–4.66) | 4.49 s (4.41–4.70) | 19,942 / 76,446 identical |
Within run-to-run noise, as expected for one TLS load + compare per recursion entry.
**Linux (Docker, `node:22-bookworm`, kernel built in-container, `docker run --rm --init`)** —
the reporter's platform and the glibc `pthread_getattr_np` bounds path:
```
=== platform ===
Linux efe3cc86947b 6.12.54-linuxkit #1 SMP Tue Nov 4 21:21:47 UTC 2025 aarch64 GNU/Linux
v22.22.3
-rwxr-xr-x 1 root root 35332288 Aug 22 18:02 codegraph-kernel/prebuilds/linux-arm64/codegraph-kernel.node
=== reporter repro (issue #1581): 16,384-brace deep.c, codegraph init ===
│
└ Done
init exit code: 0
file: deep.c
file: deep100k.c
file: ok.c
function: add
function: bar
function: foo
=== worker probe: kernel raw extract in a default 4 MiB worker ===
deep.c: deferred
deep.c: worker exitCode=0
deep100k.c: deferred
deep100k.c: worker exitCode=0
ok.c: kernel nodes=2
ok.c: worker exitCode=0
=== cargo test stack:: (glibc pthread_getattr_np bounds path) ===
test stack::tests::os_bounds_are_sane_on_this_platform ... ok
test stack::tests::small_stack_reports_its_own_bounds ... ok
test stack::tests::normal_files_are_untouched_by_the_guard ... ok
test stack::tests::deep_braces_c_defer_instead_of_crashing ... ok
test stack::tests::latch_resets_between_runs ... ok
test stack::tests::deep_parens_cpp_rust_ts_python_defer_instead_of_crashing ... ok
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 15 filtered out; finished in 0.23s
=== vitest: kernel-deep-nesting + kernel-scaffold ===
✓ __tests__/kernel-scaffold.test.ts (10 tests) 30ms
✓ __tests__/kernel-deep-nesting.test.ts (8 tests) 36989ms
Test Files 2 passed (2)
Tests 18 passed (18)
```
(The pre-fix crash was reproduced on macOS — rc=132 in a default worker, rc=139 on the main thread at 100k depth — not re-run inside this container; the reporter's Linux x86_64 trace is the SIGSEGV form of the same overflow.)
**Windows (Parallels ARM64 VM, MSVC 14.44, `cargo 1.97`, kernel built on the VM,
`GetCurrentThreadStackLimits` path)**:
```
head: cbf8485 fix(kernel): guard the native walkers against stack overflow and defer deep files to wasm (#1581)
=== cargo build --release (win32-arm64) ===
Finished `release` profile [optimized] target(s) in 2m 04s
staged: 35086848 bytes
=== cargo test (stack guard unit tests) ===
test stack::tests::normal_files_are_untouched_by_the_guard ... ok
test stack::tests::os_bounds_are_sane_on_this_platform ... ok
test stack::tests::small_stack_reports_its_own_bounds ... ok
test stack::tests::deep_braces_c_defer_instead_of_crashing ... ok
test stack::tests::latch_resets_between_runs ... ok
test stack::tests::deep_parens_cpp_rust_ts_python_defer_instead_of_crashing ... ok
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 15 filtered out; finished in 0.49s
=== reporter repro: codegraph init on a 16,384-brace deep.c ===
└ Done
init exit code: 0
=== vitest: deep-nesting + scaffold (CODEGRAPH_KERNEL_EXPECT=1) ===
✓ __tests__/kernel-scaffold.test.ts (10 tests) 55ms
✓ __tests__/kernel-deep-nesting.test.ts (8 tests) 67239ms
✓ every default-routed language survives a 60k-deep expression on the main thread 52801ms
✓ inside a default-sized (4 MiB) parse worker, through dist/ > defers a 60k-deep expression in every default-routed language 13050ms
✓ end-to-end: codegraph init on a repo holding the deep file > exits 0 and records deep.c alongside the normal files 936ms
Test Files 2 passed (2)
Tests 18 passed (18)
```
(The end-to-end test is what reads the Windows index back through `node:sqlite` — `files` = `deep.c`, `ok.c`; functions `add`, `foo`.)
Full `npm test` on this branch (macOS arm64, kernel staged): **190 files passed, 3,185 tests passed, 10 skipped, 0 failed.**
Clippy note: `cargo clippy` on the current toolchain (1.92) reports 18 pre-existing lints
(`manual_contains`, `unnecessary_to_owned`, …) in walker code this PR only touched by
inserting guard lines; none are in `stack.rs`/`lib.rs`. Left alone to keep the diff
reviewable.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01LxZj6W6Y1SHXwvpT3uwJpK
477 lines
21 KiB
TypeScript
477 lines
21 KiB
TypeScript
/**
|
||
* Parse worker pool — runs tree-sitter parsing across N worker threads so a full
|
||
* `codegraph index` uses every core instead of pinning one.
|
||
*
|
||
* Why this exists: `ExtractionOrchestrator.indexAll()` already reads files in
|
||
* parallel, but it parsed them through a SINGLE worker thread, so on an
|
||
* N-core machine indexing a large repo used one core and left the rest idle
|
||
* (issue #1015, the parse-time half of #320). Spreading the parse calls across a
|
||
* pool of workers — each its own tree-sitter WASM heap — restores multi-core
|
||
* throughput. SQLite storage stays on the main thread (it isn't thread-safe), so
|
||
* only the CPU-bound parse step is parallelised; results are stored as they
|
||
* arrive, in whatever order they finish.
|
||
*
|
||
* Design mirrors {@link ../mcp/query-pool} (idle-list dispatch, lazy growth,
|
||
* throttled cold-starts, crash recovery), with parse-specific behaviour:
|
||
* - per-worker recycle: WASM linear memory grows but never shrinks, so each
|
||
* worker is torn down and replaced after `recycleInterval` parses to reclaim
|
||
* its heap — the same reason the old single worker recycled.
|
||
* - reject, don't retry: a parse that crashes or times out its worker REJECTS
|
||
* (with a message the orchestrator's retry pass recognises) rather than being
|
||
* silently requeued — the orchestrator owns the smarter two-stage retry
|
||
* (fresh worker, then comment-stripped) on a clean WASM heap.
|
||
* - a size-1 pool reproduces the old single-worker path exactly, which is the
|
||
* conservative rollback: set `CODEGRAPH_PARSE_WORKERS=1`.
|
||
*
|
||
* Memory: peak scales with pool size (≈ size × a worker's pre-recycle heap), so
|
||
* the default is capped and the env var lets constrained machines dial it down.
|
||
*/
|
||
|
||
import { Worker } from 'worker_threads';
|
||
import type { Language, ExtractionResult } from '../types';
|
||
|
||
/**
|
||
* Minimal worker surface the pool drives — satisfied by a real `worker_threads`
|
||
* Worker. Abstracted so tests can inject a fake worker and exercise the pool's
|
||
* queue / growth / recycle / crash-recovery logic without spawning threads or a
|
||
* built `dist/`.
|
||
*/
|
||
export interface ParsePoolWorker {
|
||
postMessage(msg: unknown): void;
|
||
terminate(): Promise<number> | void;
|
||
on(event: 'message', cb: (m: unknown) => void): void;
|
||
on(event: 'error', cb: (e: Error) => void): void;
|
||
on(event: 'exit', cb: (code: number) => void): void;
|
||
}
|
||
|
||
/** A single file to parse. `language` is resolved on the main thread (it holds
|
||
* the project's codegraph.json extension overrides) and handed to the worker. */
|
||
export interface ParseTask {
|
||
filePath: string;
|
||
content: string;
|
||
language: Language;
|
||
frameworkNames?: string[];
|
||
}
|
||
|
||
/** Default upper bound on the pool size derived from the core count. */
|
||
const DEFAULT_PARSE_POOL_CAP = 8;
|
||
/** Hard ceiling on pool size regardless of an explicit env override. */
|
||
const MAX_PARSE_POOL_SIZE = 16;
|
||
/** Parses a worker performs before it's recycled to reclaim WASM heap. */
|
||
const DEFAULT_RECYCLE_INTERVAL = 250;
|
||
/** Base per-parse timeout; scaled up for large files by the caller's formula. */
|
||
const DEFAULT_PARSE_TIMEOUT_MS = 10_000;
|
||
/** Keep the default large-file budget bounded; the hard-kill window is 3× this. */
|
||
const MAX_SCALED_PARSE_TIMEOUT_MS = 20_000;
|
||
/**
|
||
* A worker is only killed once a parse has gone this many × its budget with no
|
||
* result. The base timer firing is NOT proof the parse is still running: after
|
||
* a long synchronous main-thread stretch (the SQLite store on slow disks,
|
||
* issue #1231) Node runs the timers phase before the poll phase, so the
|
||
* expired timer fires BEFORE an already-delivered `parse-result` is processed.
|
||
* Killing at the base timeout therefore produced false timeouts on parses that
|
||
* finished instantly (even 0-byte files). Instead the base timer only marks
|
||
* the job late; a result that arrives before this backstop is accepted, and
|
||
* only a worker that stays silent the whole window is treated as hung.
|
||
*/
|
||
const HARD_KILL_MULTIPLIER = 3;
|
||
/**
|
||
* 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.
|
||
* Warming a couple at a time keeps each start fast while the pool still reaches
|
||
* full size within a few parses of a large run.
|
||
*/
|
||
const MAX_CONCURRENT_SPAWN = 2;
|
||
/**
|
||
* Total worker deaths before the pool stops respawning and fails outstanding
|
||
* work, so a systematically-broken worker platform degrades instead of
|
||
* respawning forever. Set high: normal per-file WASM crashes are cleared by the
|
||
* orchestrator's retry pass and shouldn't trip this on a merely-crashy repo.
|
||
*/
|
||
const CRASH_BUDGET = 100;
|
||
|
||
/**
|
||
* Resolve the pool size from the `CODEGRAPH_PARSE_WORKERS` override and the
|
||
* machine's core count.
|
||
* - explicit `0` or `1` → 1 worker (the old single-worker path; the rollback).
|
||
* - explicit `N` → N, clamped to [1, 16].
|
||
* - unset / blank / non-numeric → `clamp(cores - 1, 1, 8)` (leave a core for
|
||
* the main thread + UI; never zero — parsing always needs a worker).
|
||
*/
|
||
/**
|
||
* Resolve the base per-parse timeout from the `CODEGRAPH_PARSE_TIMEOUT_MS`
|
||
* override. Slow storage (HDD, network folders) can need a larger budget; a
|
||
* non-numeric / non-positive value falls back to the default (10s).
|
||
*/
|
||
export function resolveParseTimeoutMs(envVal: string | undefined): number {
|
||
if (envVal !== undefined && envVal !== '') {
|
||
const n = Number(envVal);
|
||
if (Number.isFinite(n) && n > 0) return Math.floor(n);
|
||
}
|
||
return DEFAULT_PARSE_TIMEOUT_MS;
|
||
}
|
||
|
||
/**
|
||
* Per-file soft timeout. Size scaling helps legitimate large sources, but an
|
||
* uncapped linear budget gave data-only headers near the 1 MiB file limit a
|
||
* 4.5–5 minute hard-kill window (#1555). Explicit larger base overrides remain
|
||
* respected for slow storage.
|
||
*/
|
||
export function resolveParseBudgetMs(baseMs: number, contentLength: number): number {
|
||
const scaled = baseMs + Math.floor(contentLength / 100_000) * 10_000;
|
||
return Math.min(scaled, Math.max(baseMs, MAX_SCALED_PARSE_TIMEOUT_MS));
|
||
}
|
||
|
||
export function resolveParsePoolSize(envVal: string | undefined, cpuCount: number): number {
|
||
if (envVal !== undefined && envVal !== '') {
|
||
const n = Number(envVal);
|
||
if (Number.isFinite(n) && n >= 0) {
|
||
return Math.max(1, Math.min(Math.floor(n), MAX_PARSE_POOL_SIZE));
|
||
}
|
||
// non-numeric / negative → fall through to the default
|
||
}
|
||
return Math.max(1, Math.min(cpuCount - 1, DEFAULT_PARSE_POOL_CAP));
|
||
}
|
||
|
||
interface ParseJob {
|
||
id: number;
|
||
task: ParseTask;
|
||
resolve: (r: ExtractionResult) => void;
|
||
reject: (e: Error) => void;
|
||
settled: boolean;
|
||
timer?: ReturnType<typeof setTimeout>;
|
||
/** Full budget for this parse (base timeout + size scaling), for late-result logging. */
|
||
budgetMs?: number;
|
||
/** The base timer fired with no result yet — accept a late result, kill at the backstop. */
|
||
timerExpired?: boolean;
|
||
hardKillTimer?: ReturnType<typeof setTimeout>;
|
||
}
|
||
|
||
/** Shape of a message a worker posts back (grammar-load ack or a parse result). */
|
||
interface ParseWorkerMessage {
|
||
type?: string;
|
||
id?: number;
|
||
result?: ExtractionResult;
|
||
/** Worker-side parse duration — the worker's own clock, immune to main-thread stalls. */
|
||
parseMs?: number;
|
||
}
|
||
|
||
export interface ParseWorkerPoolOptions {
|
||
/** Languages to load grammars for in every worker at spawn. */
|
||
languages: Language[];
|
||
/** Number of worker threads (≥1). Clamp the resolved value before passing. */
|
||
size: number;
|
||
/** Compiled `parse-worker.js` path. Required unless `createWorker` is given. */
|
||
workerScriptPath?: string;
|
||
/** Parses per worker before recycle. Default 250. */
|
||
recycleInterval?: number;
|
||
/** Base per-parse timeout (ms); scaled by file size per parse. Default 10s. */
|
||
parseTimeoutMs?: number;
|
||
/** Worker factory (tests inject a fake). Defaults to a real `worker_threads` Worker. */
|
||
createWorker?: () => ParsePoolWorker;
|
||
/** Optional verbose logger (the orchestrator's `[worker] …` logger). */
|
||
log?: (msg: string) => void;
|
||
/**
|
||
* Pre-read grammar WASM bytes keyed by language, forwarded to every worker's
|
||
* `load-grammars` message so a spawn/respawn loads grammars from memory
|
||
* instead of re-reading them from disk — on slow storage each respawn's
|
||
* grammar re-read otherwise amplifies the very I/O contention that caused
|
||
* the respawn (issue #1231). Best-effort: a missing language falls back to
|
||
* the worker's own disk read.
|
||
*/
|
||
grammarBuffers?: Record<string, Uint8Array>;
|
||
}
|
||
|
||
export class ParseWorkerPool {
|
||
private idle: ParsePoolWorker[] = [];
|
||
private queue: ParseJob[] = [];
|
||
private inflight = new Map<ParsePoolWorker, ParseJob>();
|
||
private workers = new Set<ParsePoolWorker>();
|
||
// 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>();
|
||
private parseCounts = new Map<ParsePoolWorker, number>();
|
||
private nextId = 1;
|
||
private totalCrashes = 0;
|
||
private destroyed = false;
|
||
|
||
private readonly languages: Language[];
|
||
private readonly maxSize: number;
|
||
private readonly recycleInterval: number;
|
||
private readonly parseTimeoutMs: number;
|
||
private readonly createWorker: () => ParsePoolWorker;
|
||
private readonly log: (msg: string) => void;
|
||
private readonly grammarBuffers?: Record<string, Uint8Array>;
|
||
|
||
constructor(opts: ParseWorkerPoolOptions) {
|
||
this.languages = opts.languages;
|
||
this.grammarBuffers = opts.grammarBuffers;
|
||
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.log = opts.log ?? (() => {});
|
||
if (opts.createWorker) {
|
||
this.createWorker = opts.createWorker;
|
||
} else if (opts.workerScriptPath) {
|
||
const scriptPath = opts.workerScriptPath;
|
||
// Deliberately no `resourceLimits.stackSizeMb`: a bigger worker stack
|
||
// only moves the cliff a deeply nested file falls off (#1581 — the
|
||
// 8 MiB main thread still dies at 100k levels). The native kernel
|
||
// guards its own recursion against THIS thread's real stack bounds
|
||
// (codegraph-kernel/src/stack.rs) and defers such a file to the wasm
|
||
// path, which catches its JS RangeError per file.
|
||
this.createWorker = () => new Worker(scriptPath);
|
||
} else {
|
||
throw new Error('ParseWorkerPool requires workerScriptPath or createWorker');
|
||
}
|
||
this.spawnOne(); // one eager warm worker, ready for the first parse
|
||
}
|
||
|
||
/**
|
||
* Spawn the whole pool up front. The default demand-driven growth avoids
|
||
* paying worker boot for small jobs, but a bulk index KNOWS every core will
|
||
* be needed — on a fast repo the one-by-one ramp-up otherwise consumes most
|
||
* of the parse phase (each worker boot is a fresh Node isolate + grammar
|
||
* load, ~hundreds of ms, and growth only triggers as queue pressure builds).
|
||
*/
|
||
prewarm(): void {
|
||
while (this.workers.size < this.maxSize) {
|
||
const before = this.workers.size;
|
||
this.spawnOne();
|
||
if (this.workers.size === before) break; // spawn failed / breaker tripped
|
||
}
|
||
}
|
||
|
||
/** Pool size cap (for logging). */
|
||
get size(): number { return this.maxSize; }
|
||
|
||
/** Live worker count (for tests). */
|
||
get liveWorkers(): number { return this.workers.size; }
|
||
|
||
/** False once the crash budget is exhausted (or after destroy). */
|
||
get healthy(): boolean {
|
||
return !this.destroyed && this.totalCrashes < CRASH_BUDGET;
|
||
}
|
||
|
||
/**
|
||
* Parse one file on the pool. Resolves with the extraction result, or REJECTS
|
||
* if the parse times out or its worker crashes — the caller records the error
|
||
* and (for worker-exit/OOM/timeout rejections) re-attempts in its retry pass.
|
||
*/
|
||
requestParse(task: ParseTask): Promise<ExtractionResult> {
|
||
if (this.destroyed) return Promise.reject(new Error('Parse pool destroyed'));
|
||
return new Promise<ExtractionResult>((resolve, reject) => {
|
||
this.queue.push({ id: this.nextId++, task, resolve, reject, settled: false });
|
||
this.drain();
|
||
});
|
||
}
|
||
|
||
private spawnOne(): void {
|
||
if (this.destroyed || this.workers.size >= this.maxSize || !this.healthy) return;
|
||
let w: ParsePoolWorker;
|
||
try {
|
||
w = this.createWorker();
|
||
} catch {
|
||
this.totalCrashes++; // counts toward the circuit breaker
|
||
return;
|
||
}
|
||
this.workers.add(w);
|
||
this.pending.add(w);
|
||
this.parseCounts.set(w, 0);
|
||
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}`); });
|
||
// 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.
|
||
w.postMessage({ type: 'load-grammars', languages: this.languages, grammarBuffers: this.grammarBuffers });
|
||
}
|
||
|
||
private onMessage(w: ParsePoolWorker, m: ParseWorkerMessage): void {
|
||
if (m.type === 'grammars-loaded') {
|
||
if (!this.workers.has(w)) return; // recycled/destroyed before ready
|
||
this.pending.delete(w);
|
||
this.idle.push(w);
|
||
this.drain();
|
||
return;
|
||
}
|
||
if (m.type === 'parse-result') {
|
||
const job = this.inflight.get(w);
|
||
if (!job || (m.id !== undefined && m.id !== job.id)) return; // stale (post-recycle)
|
||
this.inflight.delete(w);
|
||
if (job.timerExpired) {
|
||
// The base timer fired before this result was processed. That almost
|
||
// always means the MAIN THREAD was stalled (sync SQLite store on slow
|
||
// disks) while the parse itself finished long ago — the worker's own
|
||
// clock (parseMs) tells the two apart. Either way the result is here
|
||
// and valid: accept it instead of the old behaviour (kill worker +
|
||
// reject), which turned every main-thread stall into false timeouts
|
||
// and dropped files (issue #1231).
|
||
const parseMs = typeof m.parseMs === 'number' ? Math.round(m.parseMs) : undefined;
|
||
const detail = parseMs === undefined
|
||
? ''
|
||
: parseMs < (job.budgetMs ?? this.parseTimeoutMs)
|
||
? ` (parse took ${parseMs}ms in-worker — the main thread was stalled, not the parse)`
|
||
: ` (parse genuinely took ${parseMs}ms)`;
|
||
this.log(`Late parse-result accepted: ${job.task.filePath}${detail}`);
|
||
}
|
||
// Recycle the worker once it's done enough parses to have grown its WASM
|
||
// heap; otherwise return it to the idle set for the next job.
|
||
if ((this.parseCounts.get(w) ?? 0) >= this.recycleInterval) {
|
||
this.recycle(w);
|
||
} else {
|
||
this.idle.push(w);
|
||
}
|
||
this.settle(job, m.result);
|
||
this.drain();
|
||
}
|
||
}
|
||
|
||
/** A worker died (crash hook / OOM exit / spawn error). Reject its in-flight
|
||
* parse so the caller's retry pass can re-attempt it, then respawn. */
|
||
private onWorkerGone(w: ParsePoolWorker, message: string): void {
|
||
if (!this.workers.has(w)) return; // already handled (error+exit both fire), or recycled
|
||
this.removeWorker(w);
|
||
this.totalCrashes++;
|
||
const job = this.inflight.get(w);
|
||
this.inflight.delete(w);
|
||
try { void w.terminate(); } catch { /* already gone */ }
|
||
if (job) this.settle(job, undefined, new Error(message));
|
||
if (this.healthy) this.spawnOne(); // keep capacity
|
||
this.drain();
|
||
}
|
||
|
||
/** Tear down a worker that has hit its recycle threshold and replace it. Not a
|
||
* crash, so it doesn't count against the budget. */
|
||
private recycle(w: ParsePoolWorker): void {
|
||
this.log(`Recycling worker after ${this.parseCounts.get(w)} parses (heap: ${Math.round(process.memoryUsage().rss / 1024 / 1024)}MB RSS)`);
|
||
this.removeWorker(w);
|
||
// Fire-and-forget: worker.terminate() can hang if WASM is wedged.
|
||
try { void w.terminate(); } catch { /* already gone */ }
|
||
if (this.healthy && !this.destroyed) this.spawnOne();
|
||
}
|
||
|
||
private removeWorker(w: ParsePoolWorker): void {
|
||
this.workers.delete(w);
|
||
this.pending.delete(w);
|
||
this.parseCounts.delete(w);
|
||
this.idle = this.idle.filter((x) => x !== w);
|
||
}
|
||
|
||
private dispatch(w: ParsePoolWorker, job: ParseJob): void {
|
||
this.inflight.set(w, job);
|
||
this.parseCounts.set(w, (this.parseCounts.get(w) ?? 0) + 1);
|
||
// Scale the timeout for large files: base + 10s per 100KB (matches the
|
||
// original single-worker formula so pathological-file behaviour is unchanged).
|
||
const timeoutMs = resolveParseBudgetMs(this.parseTimeoutMs, job.task.content.length);
|
||
job.budgetMs = timeoutMs;
|
||
job.timer = setTimeout(() => this.onTimeout(w, job, timeoutMs), timeoutMs);
|
||
job.timer.unref?.();
|
||
w.postMessage({
|
||
type: 'parse',
|
||
id: job.id,
|
||
filePath: job.task.filePath,
|
||
content: job.task.content,
|
||
frameworkNames: job.task.frameworkNames,
|
||
language: job.task.language,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* The base timer fired with no result processed yet. Do NOT kill or settle:
|
||
* the timer firing doesn't prove the parse is still running — after a long
|
||
* synchronous main-thread stretch Node services the timers phase before the
|
||
* poll phase, so an already-delivered `parse-result` is still queued behind
|
||
* this callback. Mark the job late (onMessage accepts a result that shows up)
|
||
* and arm the hard-kill backstop for workers that are genuinely hung.
|
||
*/
|
||
private onTimeout(w: ParsePoolWorker, job: ParseJob, ms: number): void {
|
||
if (job.settled || !this.workers.has(w)) return;
|
||
const graceMs = ms * (HARD_KILL_MULTIPLIER - 1);
|
||
this.log(`TIMEOUT: ${job.task.filePath} exceeded ${ms}ms with no result — waiting up to ${graceMs}ms more for a late result before killing the worker`);
|
||
job.timerExpired = true;
|
||
job.hardKillTimer = setTimeout(() => this.onHardTimeout(w, job, ms * HARD_KILL_MULTIPLIER), graceMs);
|
||
job.hardKillTimer.unref?.();
|
||
}
|
||
|
||
/** No result after the full hard-kill window — the worker really is hung. */
|
||
private onHardTimeout(w: ParsePoolWorker, job: ParseJob, totalMs: number): void {
|
||
if (job.settled || !this.workers.has(w)) return;
|
||
this.log(`TIMEOUT: ${job.task.filePath} got no result after ${totalMs}ms — killing worker`);
|
||
// Kill the (WASM-wedged) worker and reject this parse. A timeout isn't a
|
||
// crash — don't charge the budget — but the worker is gone, so spawn a
|
||
// replacement to keep capacity. The rejection message contains "timed out"
|
||
// so the orchestrator's retry pass re-attempts the file.
|
||
this.removeWorker(w);
|
||
this.inflight.delete(w);
|
||
try { void w.terminate(); } catch { /* already gone */ }
|
||
this.settle(job, undefined, new Error(`Parse timed out after ${totalMs}ms`));
|
||
if (this.healthy) this.spawnOne();
|
||
this.drain();
|
||
}
|
||
|
||
private drain(): void {
|
||
// Grow toward maxSize while queued work outstrips workers that are idle OR
|
||
// already on their way up — throttled so we never cold-start the whole pool
|
||
// at once.
|
||
while (
|
||
this.queue.length > this.idle.length + this.pending.size &&
|
||
this.workers.size < this.maxSize &&
|
||
this.pending.size < MAX_CONCURRENT_SPAWN &&
|
||
!this.destroyed &&
|
||
this.healthy
|
||
) {
|
||
this.spawnOne();
|
||
}
|
||
// Dispatch queued jobs to idle workers.
|
||
while (this.idle.length && this.queue.length) {
|
||
let job: ParseJob | undefined;
|
||
while (this.queue.length && (job = this.queue.shift()) && job.settled) job = undefined;
|
||
if (!job || job.settled) break;
|
||
const w = this.idle.pop()!;
|
||
this.dispatch(w, job);
|
||
}
|
||
// Hang-prevention: if there's queued work but nothing can ever run it (no
|
||
// idle workers, none spawning, none alive), fail it instead of hanging
|
||
// forever. Reached only when the crash budget is exhausted or after destroy.
|
||
if (this.queue.length && this.idle.length === 0 && this.pending.size === 0 && this.workers.size === 0) {
|
||
const reason = this.destroyed ? 'parse pool destroyed' : 'parse pool exhausted its worker crash budget';
|
||
for (const job of this.queue.splice(0)) this.settle(job, undefined, new Error(reason));
|
||
}
|
||
}
|
||
|
||
private settle(job: ParseJob, result?: ExtractionResult, err?: Error): void {
|
||
if (job.settled) return;
|
||
job.settled = true;
|
||
if (job.timer) clearTimeout(job.timer);
|
||
if (job.hardKillTimer) clearTimeout(job.hardKillTimer);
|
||
if (err) job.reject(err);
|
||
else job.resolve(result!);
|
||
}
|
||
|
||
/**
|
||
* Recycle every idle worker now (fresh WASM heaps). The orchestrator calls
|
||
* this before its retry pass so crash-on-memory files get the cleanest heap.
|
||
*/
|
||
recycleAll(): void {
|
||
for (const w of [...this.idle]) this.recycle(w);
|
||
}
|
||
|
||
/** Terminate all workers and reject any outstanding parses. */
|
||
async destroy(): Promise<void> {
|
||
if (this.destroyed) return;
|
||
this.destroyed = true;
|
||
const ws = [...this.workers];
|
||
this.workers.clear();
|
||
this.pending.clear();
|
||
this.parseCounts.clear();
|
||
this.idle = [];
|
||
for (const job of [...this.inflight.values(), ...this.queue]) {
|
||
this.settle(job, undefined, new Error('parse pool destroyed'));
|
||
}
|
||
this.inflight.clear();
|
||
this.queue = [];
|
||
await Promise.all(ws.map((w) => Promise.resolve(w.terminate()).catch(() => { /* already gone */ })));
|
||
}
|
||
}
|