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:
co-authored by
Claude Fable 5
parent
e76a355df5
commit
a11a439002
+89
-44
@@ -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
@@ -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
|
||||
|
||||
@@ -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!);
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
Reference in New Issue
Block a user