fix(indexing): HDD-class storage — false parse timeouts, dropped files, and WAL checkpoint write-back (#1231) (#1242)

Parse timeouts are now judged by the worker's own clock: the base timer
only marks a job late (after a long synchronous store stall, Node runs the
timers phase before the poll phase, so the timer fired before an
already-delivered result was processed — killing workers over parses that
took milliseconds, even on 0-byte files); a result arriving before a 3×
hard-kill backstop is accepted, timed-out files are retried, and
CODEGRAPH_PARSE_TIMEOUT_MS overrides the budget. Grammar WASM bytes are
pre-read once on the main thread and handed to every worker, so
spawns/respawns load grammars from memory instead of re-reading a
saturated disk.

Bulk indexing defers WAL auto-checkpointing for the whole run: the default
1000-page interval re-writes hot B-tree/FTS pages into the main DB file
over and over — ~95% of all disk I/O under throttled measurement. A
WalCheckpointValve bounds WAL growth with off-thread PASSIVE backfill
passes (never blocking the writer or the #850 watchdog heartbeat), pauses
the writer for a full backfill if the disk truly can't keep up, and folds
the WAL at the parse→resolution boundary so post-parse reads never page a
bulk-write-sized WAL. Opt out with CODEGRAPH_NO_WAL_DEFER=1; tune with
CODEGRAPH_WAL_VALVE_MB.

Measured at 150 IOPS (HDD class): commons-lang 1526s → 59s with 0 dropped
files (was 8); guava-scale completes in 7.6 min with a full graph where
v1.3.1 needed 25 min for a repo 5× smaller. Unthrottled: no change.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-10 03:42:30 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent e76a355df5
commit a11a439002
12 changed files with 928 additions and 73 deletions
+89 -44
View File
@@ -7,6 +7,7 @@
*/
import * as path from 'path';
import * as fsp from 'fs/promises';
import { Parser, Language as WasmLanguage } from 'web-tree-sitter';
import { Language } from '../types';
@@ -248,31 +249,101 @@ export async function initGrammars(): Promise<void> {
parserInitialized = true;
}
/**
* Grammars that ship their own vendored WASMs under `dist/extraction/wasm/`
* (not in tree-sitter-wasms, or the tree-sitter-wasms build is too old).
* Lua: tree-sitter-wasms ships an ABI-13 build that corrupts the shared WASM
* heap under web-tree-sitter 0.25 (drops nested calls/imports on every file
* after the first); we vendor the upstream ABI-15 wasm instead. C#: the
* tree-sitter-wasms build (ABI 13) has no primary-constructor support and
* parses `class Foo(...)` as an ERROR that swallows the whole class (#237); we
* vendor the upstream ABI-15 tree-sitter-c-sharp 0.23.5 wasm, which parses
* primary constructors natively. Terraform: tree-sitter-wasms does not ship
* HCL/Terraform at all, so we vendor the prebuilt tree-sitter-terraform.wasm
* from @tree-sitter-grammars/tree-sitter-hcl 1.2.0 (Apache-2.0) —
* byte-identical to the npm package's artifact. ArkTS: tree-sitter-wasms
* doesn't ship it either; we vendor the prebuilt tree-sitter-arkts.wasm from
* the tree-sitter-arkts 0.2.0 npm package (harmony-contrib/tree-sitter-arkts,
* MIT) — byte-identical to the npm tarball's artifact. It extends the
* tree-sitter-javascript grammar the same way tree-sitter-typescript does,
* adding `struct_declaration` and the `arkui_component_expression` build()
* DSL. Nix: tree-sitter-wasms doesn't ship it; we vendor a wasm built from
* nix-community/tree-sitter-nix @ 3d0173d (MIT) with tree-sitter-cli 0.25.10
* (`generate` + `build --wasm`, ABI 15 — upstream's checked-in parser.c is
* still ABI 13; all 54 upstream corpus tests pass on the regenerated parser).
*/
const VENDORED_WASM_LANGS: ReadonlySet<GrammarLanguage> = new Set([
'pascal', 'scala', 'lua', 'luau', 'csharp', 'r', 'cfml', 'cfscript', 'cfquery',
'cobol', 'vbnet', 'erlang', 'terraform', 'arkts', 'nix',
]);
/** Absolute path of a language's grammar WASM (vendored or tree-sitter-wasms). */
function resolveWasmPath(lang: GrammarLanguage): string {
const wasmFile = WASM_GRAMMAR_FILES[lang];
return VENDORED_WASM_LANGS.has(lang)
? path.join(__dirname, 'wasm', wasmFile)
: require.resolve(`tree-sitter-wasms/out/${wasmFile}`);
}
/**
* Expand an index set's languages to the grammars actually needed to parse it.
* SFC languages (svelte/vue/astro) have no grammar of their own — their
* extractors delegate <script>/frontmatter content to the TS/JS extractor, so
* those grammars must be loaded even when no plain .ts/.js file is in the index
* set (e.g. a pure-.astro content site). CFML (.cfc/.cfm) likewise delegates
* bare-script content, <cfscript> tag bodies, and <cfquery> SQL bodies to the
* cfscript/cfquery grammars (see injections.scm in tree-sitter-cfml).
*/
function expandGrammarLanguages(languages: Language[]): Language[] {
if (languages.some((l) => l === 'svelte' || l === 'vue' || l === 'astro')) {
languages = [...languages, 'typescript', 'javascript'];
}
if (languages.some((l) => l === 'cfml')) {
languages = [...languages, 'cfscript', 'cfquery'];
}
return languages;
}
/**
* Pre-read the grammar WASM bytes for an index set, keyed by language. The
* orchestrator reads each grammar ONCE and hands the bytes to every parse
* worker via its `load-grammars` message, so worker spawns/respawns load
* grammars from memory instead of re-reading them from disk — on slow storage
* (HDD, issue #1231) each respawn's grammar re-read otherwise amplifies the
* I/O contention that caused the respawn. Best-effort: a language whose WASM
* can't be read here is simply omitted, and the worker falls back to its own
* disk load (which surfaces the real error/warning path).
*/
export async function readGrammarWasmBytes(languages: Language[]): Promise<Record<string, Uint8Array>> {
const out: Record<string, Uint8Array> = {};
const toRead = [...new Set(expandGrammarLanguages(languages))].filter(
(lang): lang is GrammarLanguage => lang in WASM_GRAMMAR_FILES
);
for (const lang of toRead) {
try {
out[lang] = await fsp.readFile(resolveWasmPath(lang));
} catch {
// fall through — the worker's own load reports the failure
}
}
return out;
}
/**
* Load grammar WASM files for specific languages only.
* Skips languages that are already loaded or have no WASM grammar.
* Must be called after initGrammars().
*
* `wasmBytes` (optional) holds pre-read grammar bytes keyed by language (from
* {@link readGrammarWasmBytes}, forwarded through the parse pool); when a
* language's bytes are present they're loaded from memory instead of disk.
*/
export async function loadGrammarsForLanguages(languages: Language[]): Promise<void> {
export async function loadGrammarsForLanguages(languages: Language[], wasmBytes?: Record<string, Uint8Array>): Promise<void> {
if (!parserInitialized) {
await initGrammars();
}
// SFC languages (svelte/vue/astro) have no grammar of their own — their
// extractors delegate <script>/frontmatter content to the TS/JS extractor,
// so those grammars must be loaded even when no plain .ts/.js file is in
// the index set (e.g. a pure-.astro content site).
if (languages.some((l) => l === 'svelte' || l === 'vue' || l === 'astro')) {
languages = [...languages, 'typescript', 'javascript'];
}
// CFML (.cfc/.cfm) delegates bare-script content, <cfscript> tag bodies, and
// <cfquery> SQL bodies to the cfscript/cfquery grammars (see injections.scm in
// tree-sitter-cfml) — load both even when no standalone .cfs file is in the
// index set.
if (languages.some((l) => l === 'cfml')) {
languages = [...languages, 'cfscript', 'cfquery'];
}
languages = expandGrammarLanguages(languages);
// Deduplicate and filter to languages that have WASM grammars and aren't already loaded
const toLoad = [...new Set(languages)].filter(
@@ -285,35 +356,9 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise<v
// Load grammars sequentially to avoid web-tree-sitter WASM race condition on Node 20+
// See: https://github.com/tree-sitter/tree-sitter/issues/2338
for (const lang of toLoad) {
const wasmFile = WASM_GRAMMAR_FILES[lang];
try {
// Some grammars ship their own WASMs (not in tree-sitter-wasms, or the
// tree-sitter-wasms build is too old). Lua: tree-sitter-wasms ships an
// ABI-13 build that corrupts the shared WASM heap under web-tree-sitter
// 0.25 (drops nested calls/imports on every file after the first); we
// vendor the upstream ABI-15 wasm instead. C#: the tree-sitter-wasms
// build (ABI 13) has no primary-constructor support and parses
// `class Foo(...)` as an ERROR that swallows the whole class (#237); we
// vendor the upstream ABI-15 tree-sitter-c-sharp 0.23.5 wasm, which parses
// primary constructors natively. Terraform: tree-sitter-wasms does not
// ship HCL/Terraform at all, so we vendor the prebuilt
// tree-sitter-terraform.wasm from @tree-sitter-grammars/tree-sitter-hcl
// 1.2.0 (Apache-2.0) — byte-identical to the npm package's artifact.
// ArkTS: tree-sitter-wasms doesn't ship it either; we vendor the prebuilt
// tree-sitter-arkts.wasm from the tree-sitter-arkts 0.2.0 npm package
// (harmony-contrib/tree-sitter-arkts, MIT) — byte-identical to the npm
// tarball's artifact. It extends the tree-sitter-javascript grammar the
// same way tree-sitter-typescript does, adding `struct_declaration` and
// the `arkui_component_expression` build() DSL.
// Nix: tree-sitter-wasms doesn't ship it; we vendor a wasm built from
// nix-community/tree-sitter-nix @ 3d0173d (MIT) with tree-sitter-cli
// 0.25.10 (`generate` + `build --wasm`, ABI 15 — upstream's checked-in
// parser.c is still ABI 13; all 54 upstream corpus tests pass on the
// regenerated parser).
const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'csharp' || lang === 'r' || lang === 'cfml' || lang === 'cfscript' || lang === 'cfquery' || lang === 'cobol' || lang === 'vbnet' || lang === 'erlang' || lang === 'terraform' || lang === 'arkts' || lang === 'nix')
? path.join(__dirname, 'wasm', wasmFile)
: require.resolve(`tree-sitter-wasms/out/${wasmFile}`);
const language = await WasmLanguage.load(wasmPath);
const bytes = wasmBytes?.[lang];
const language = await WasmLanguage.load(bytes ?? resolveWasmPath(lang));
languageCache.set(lang, language);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
+31 -8
View File
@@ -19,8 +19,8 @@ import {
} from '../types';
import { QueryBuilder } from '../db/queries';
import { extractFromSource } from './tree-sitter';
import { ParseWorkerPool, resolveParsePoolSize } from './parse-pool';
import { detectLanguage, isSourceFile, isLanguageSupported, isFileLevelOnlyLanguage, initGrammars, loadGrammarsForLanguages } from './grammars';
import { ParseWorkerPool, resolveParsePoolSize, resolveParseTimeoutMs } from './parse-pool';
import { detectLanguage, isSourceFile, isLanguageSupported, isFileLevelOnlyLanguage, initGrammars, loadGrammarsForLanguages, readGrammarWasmBytes } from './grammars';
import { loadExtensionOverrides, loadIncludeIgnoredPatterns, loadExcludePatterns, loadIncludePatterns } from '../project-config';
import { isCodeGraphDataDir } from '../directory';
import { logDebug, logWarn } from '../errors';
@@ -53,9 +53,10 @@ const SYNC_RECONCILE_YIELD_INTERVAL = 1000;
/**
* Maximum time (ms) to wait for a single file to parse in the worker thread.
* If tree-sitter hangs or WASM runs out of memory, this prevents the entire
* indexing run from freezing. The worker is restarted after a timeout.
* indexing run from freezing. The worker is restarted after a (hard) timeout.
* Env-overridable via CODEGRAPH_PARSE_TIMEOUT_MS for slow storage (#1231).
*/
const PARSE_TIMEOUT_MS = 10_000;
const PARSE_TIMEOUT_MS = resolveParseTimeoutMs(process.env.CODEGRAPH_PARSE_TIMEOUT_MS);
/**
* Number of files to parse before recycling the worker thread.
@@ -1453,7 +1454,12 @@ export class ExtractionOrchestrator {
async indexAll(
onProgress?: (progress: IndexProgress) => void,
signal?: AbortSignal,
verbose?: boolean
verbose?: boolean,
// Writer-side backstop for deferred WAL checkpointing (#1231): returns
// null in the normal case, or a promise to await (at this safe,
// between-transactions boundary) when the WAL has outrun the off-thread
// checkpointer past its hard cap. See db/wal-valve.ts.
walBackpressure?: () => Promise<void> | null
): Promise<IndexResult> {
await initGrammars();
const startTime = Date.now();
@@ -1549,6 +1555,11 @@ export class ExtractionOrchestrator {
// CODEGRAPH_PARSE_WORKERS: explicit worker count; 1 = the old single-worker
// behaviour (the conservative rollback). Unset → clamp(cores-1, 1, 8).
const poolSize = resolveParsePoolSize(process.env.CODEGRAPH_PARSE_WORKERS, os.cpus().length);
// Read each needed grammar's WASM ONCE here and hand the bytes to every
// worker, so spawns/respawns load grammars from memory instead of
// re-reading them from disk (#1231: on an HDD, respawn re-reads amplify
// the very I/O contention that caused the respawn).
const grammarBuffers = await readGrammarWasmBytes(neededLanguages);
pool = new ParseWorkerPool({
languages: neededLanguages,
size: poolSize,
@@ -1556,6 +1567,7 @@ export class ExtractionOrchestrator {
recycleInterval: WORKER_RECYCLE_INTERVAL,
parseTimeoutMs: PARSE_TIMEOUT_MS,
log,
grammarBuffers,
});
log(`Parse worker pool: ${poolSize} worker(s)`);
} else {
@@ -1603,6 +1615,12 @@ export class ExtractionOrchestrator {
const storeResult = async (filePath: string, content: string, stats: fs.Stats, result: ExtractionResult): Promise<void> => {
processed++;
// WAL hard-cap backstop: between files (never mid-transaction), pause
// the store until the off-thread checkpoint catches up. Resolves to
// null in the normal case — a single size check, no cost.
const bp = walBackpressure?.();
if (bp) await bp;
// Store in database on main thread (SQLite is not thread-safe)
if (result.nodes.length > 0 || result.errors.length === 0) {
const language = detectLanguage(filePath, content, overrides);
@@ -1815,14 +1833,19 @@ export class ExtractionOrchestrator {
// Retry pass: files that failed due to WASM memory corruption may succeed
// on a fresh worker with a clean heap. Recycle before each attempt so
// every file gets the absolute cleanest WASM state possible.
// every file gets the absolute cleanest WASM state possible. Timeouts are
// retried too (#1231): most are main-thread-stall artifacts, not slow
// parses, and this pass parses one file at a time with the store strictly
// after each parse resolves, so the stall window can't recur here.
const retryableErrors = errors.filter(
(e) => e.code === 'parse_error' && e.filePath &&
(e.message.includes('Worker exited') || e.message.includes('memory access out of bounds'))
(e.message.includes('Worker exited') ||
e.message.includes('memory access out of bounds') ||
e.message.includes('timed out'))
);
if (retryableErrors.length > 0 && pool) {
log(`Retrying ${retryableErrors.length} files that failed due to WASM memory errors...`);
log(`Retrying ${retryableErrors.length} files that failed due to WASM memory errors or timeouts...`);
// Fresh WASM heaps for the retry phase. A retry that still crashes its
// worker makes the pool respawn it, so later retries keep landing on clean
+89 -7
View File
@@ -61,6 +61,18 @@ const MAX_PARSE_POOL_SIZE = 16;
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;
/**
* 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.
@@ -84,6 +96,19 @@ const CRASH_BUDGET = 100;
* - 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;
}
export function resolveParsePoolSize(envVal: string | undefined, cpuCount: number): number {
if (envVal !== undefined && envVal !== '') {
const n = Number(envVal);
@@ -102,6 +127,11 @@ interface ParseJob {
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). */
@@ -109,6 +139,8 @@ 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 {
@@ -126,6 +158,15 @@ export interface ParseWorkerPoolOptions {
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 {
@@ -147,9 +188,11 @@ export class ParseWorkerPool {
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;
@@ -179,7 +222,7 @@ export class ParseWorkerPool {
/**
* 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 rejections) re-attempts in its retry pass.
* 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'));
@@ -205,7 +248,9 @@ export class ParseWorkerPool {
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.
w.postMessage({ type: 'load-grammars', languages: this.languages });
// 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 {
@@ -220,6 +265,22 @@ export class ParseWorkerPool {
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) {
@@ -269,6 +330,7 @@ export class ParseWorkerPool {
// Scale the timeout for large files: base + 10s per 100KB (matches the
// original single-worker formula so pathological-file behaviour is unchanged).
const timeoutMs = this.parseTimeoutMs + Math.floor(job.task.content.length / 100_000) * 10_000;
job.budgetMs = timeoutMs;
job.timer = setTimeout(() => this.onTimeout(w, job, timeoutMs), timeoutMs);
job.timer.unref?.();
w.postMessage({
@@ -281,16 +343,35 @@ export class ParseWorkerPool {
});
}
/**
* 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;
this.log(`TIMEOUT: ${job.task.filePath} exceeded ${ms}ms — killing worker`);
// Kill the (possibly 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.
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 ${ms}ms`));
this.settle(job, undefined, new Error(`Parse timed out after ${totalMs}ms`));
if (this.healthy) this.spawnOne();
this.drain();
}
@@ -329,6 +410,7 @@ export class ParseWorkerPool {
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!);
}
+10 -3
View File
@@ -55,12 +55,18 @@ import type { Language, ExtractionResult } from '../types';
const PARSER_RESET_INTERVAL = 5000;
const parseCounts = new Map<Language, number>();
parentPort!.on('message', async (msg: { type: string; id?: number; filePath?: string; content?: string; languages?: Language[]; frameworkNames?: string[]; language?: Language }) => {
parentPort!.on('message', async (msg: { type: string; id?: number; filePath?: string; content?: string; languages?: Language[]; frameworkNames?: string[]; language?: Language; grammarBuffers?: Record<string, Uint8Array> }) => {
if (msg.type === 'load-grammars') {
await loadGrammarsForLanguages(msg.languages!);
// Grammar WASM bytes pre-read by the main thread (when provided) make this
// a memory load instead of a per-spawn disk read — see issue #1231.
await loadGrammarsForLanguages(msg.languages!, msg.grammarBuffers);
parentPort!.postMessage({ type: 'grammars-loaded' });
} else if (msg.type === 'parse') {
const { id, filePath, content, frameworkNames } = msg;
// Worker-side parse clock: reported back with the result so the pool can
// tell a genuinely slow parse from a result whose delivery was delayed by
// a stalled main thread (issue #1231 false timeouts).
const t0 = performance.now();
try {
// The main thread resolves the language (it holds the project's
// codegraph.json extension overrides) and sends it; fall back to detection
@@ -75,7 +81,7 @@ parentPort!.on('message', async (msg: { type: string; id?: number; filePath?: st
resetParser(language);
}
parentPort!.postMessage({ type: 'parse-result', id, result });
parentPort!.postMessage({ type: 'parse-result', id, result, parseMs: performance.now() - t0 });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@@ -89,6 +95,7 @@ parentPort!.on('message', async (msg: { type: string; id?: number; filePath?: st
parentPort!.postMessage({
type: 'parse-result',
id,
parseMs: performance.now() - t0,
result: {
nodes: [],
edges: [],