fix(resolution): gate closure-collection synthesis to Swift/Kotlin and de-quadratic its line accounting (#1235) (#1237)

closureCollectionEdges scanned every method/function node in every
language, but its dispatcher patterns ({ $0( / { it( ) are Swift/Kotlin
trailing-closure syntax — on PHP/JS repos the pass can never emit an
edge, yet .push(/.add( fired its append gate on nearly every function.
Per match it computed the line via src.slice(0, idx).split('\n'), which
is O(source) per match and goes quadratic on match-dense generated
functions (two-byte content roughly doubles it). On a 12,860-file
PHP/JS app that was 20+ minutes of the "Resolving refs" tail — frozen
at 97% — and a #850 watchdog kill; profiled on CRMEB it was 127s of a
166s index for zero edges.

- Skip nodes whose language isn't swift/kotlin before any file I/O.
- makeLineAt(): lazy newline index + binary search, shared with the
  emitter passes' per-file lineOf.
- Yield every 256 regex matches inside the scan's match loops so a
  single pathological function can't starve the watchdog.

CRMEB (ThinkPHP, 2,913 files): 166.8s -> 72.7s, graph byte-identical.
Alamofire: closure-collection edges byte-identical (9 edges, 4 fields).
Drupal core control: graph byte-identical.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-09 20:54:45 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent e552dc2d2f
commit e76a355df5
3 changed files with 92 additions and 10 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes ### Fixes
- Indexing very large codebases no longer dies at the end of the "Resolving refs" step. Two failure modes are fixed: on multi-million-symbol projects (e.g. the Linux kernel, ~95,000 files) the final analysis phase ran out of memory and crashed the process outright, and on large projects on slower machines (reported on a 24,000-file Java project on Windows) the same phase could stall long enough that the safety watchdog killed a healthy, still-progressing index at ~98% (#1212). The whole phase now streams its work instead of holding whole-graph snapshots in memory, keeps the process responsive throughout, and skips analysis passes for languages a project doesn't contain — which also makes the tail of indexing noticeably faster on single-language repos. The resulting graph is identical, and a genuinely wedged process is still detected and killed. - Fixed `codegraph init` grinding for 20+ minutes (or getting killed by the safety watchdog) near the end of the "Resolving refs" step on large PHP and JavaScript codebases — a regression since 0.9.x reported on a 12,000-file project that used to index in about a minute. One of the dynamic-dispatch analysis passes only ever applies to Swift/Kotlin closure collections, but it was scanning every function in every language, and on `.push(`-heavy JavaScript (or any codebase with big generated functions, especially with non-ASCII text) its per-match bookkeeping went quadratic. The pass now skips languages it can't apply to, does its line accounting in constant time, and stays responsive even inside a single pathological function — the graph produced is identical. Thanks @sniperrenren for the report. (#1235) Two failure modes are fixed: on multi-million-symbol projects (e.g. the Linux kernel, ~95,000 files) the final analysis phase ran out of memory and crashed the process outright, and on large projects on slower machines (reported on a 24,000-file Java project on Windows) the same phase could stall long enough that the safety watchdog killed a healthy, still-progressing index at ~98% (#1212). The whole phase now streams its work instead of holding whole-graph snapshots in memory, keeps the process responsive throughout, and skips analysis passes for languages a project doesn't contain — which also makes the tail of indexing noticeably faster on single-language repos. The resulting graph is identical, and a genuinely wedged process is still detected and killed.
- Indexing and `codegraph sync` stay responsive through their heaviest internal steps on huge projects: the post-index database maintenance (which on a multi-gigabyte index could stall the process for minutes and get a fully successful index killed by the safety watchdog at the finish line) now runs on a background thread, storing a giant generated file no longer freezes the process mid-extraction, and the reference-resolution bookkeeping between progress updates is broken into small responsive steps. The resulting graph is byte-for-byte identical. - Indexing and `codegraph sync` stay responsive through their heaviest internal steps on huge projects: the post-index database maintenance (which on a multi-gigabyte index could stall the process for minutes and get a fully successful index killed by the safety watchdog at the finish line) now runs on a background thread, storing a giant generated file no longer freezes the process mid-extraction, and the reference-resolution bookkeeping between progress updates is broken into small responsive steps. The resulting graph is byte-for-byte identical.
- Fixed a race that could leave a freshly-attached MCP session permanently silent: when a client's first messages arrived glued together during the daemon's connection handshake (roughly one attach in five on a busy machine), the daemon could drop them and stop reading that connection entirely — every tool call from that session then hung with no reply. The handshake now hands the connection over losslessly, and the fix is validated by hammering the previously-flaky attach test 25× under load. - Fixed a race that could leave a freshly-attached MCP session permanently silent: when a client's first messages arrived glued together during the daemon's connection handshake (roughly one attach in five on a busy machine), the daemon could drop them and stop reading that connection entirely — every tool call from that session then hung with no reply. The handshake now hands the connection over losslessly, and the fix is validated by hammering the previously-flaky attach test 25× under load.
- The first tool call after the shared daemon starts no longer waits behind the query workers' cold start (which can take many seconds on a busy machine) — it's served directly until the first worker is warm, so a fresh session answers immediately. - The first tool call after the shared daemon starts no longer waits behind the query workers' cold start (which can take many seconds on a busy machine) — it's served directly until the first worker is warm, so a fresh session answers immediately.
@@ -106,7 +106,9 @@ describe('closure-collection synthesizer', () => {
); );
expect(validatorsEdge).toBeTruthy(); expect(validatorsEdge).toBeTruthy();
expect(validatorsEdge.source_name).toBe('didCompleteTask'); expect(validatorsEdge.source_name).toBe('didCompleteTask');
expect(validatorsEdge.registeredAt).toMatch(/DataRequest\.swift:\d+/); // Exact wiring-site line, not just shape: pins the binary-search line
// resolver (#1235) to the same answer as the old per-match slice+split.
expect(validatorsEdge.registeredAt).toBe('DataRequest.swift:4');
// The handlers flow: runHandlers → onEvent, via the direct `prop.append` // The handlers flow: runHandlers → onEvent, via the direct `prop.append`
// form — proves both registrar shapes are covered. // form — proves both registrar shapes are covered.
@@ -121,4 +123,37 @@ describe('closure-collection synthesizer', () => {
expect(rows.some((r: any) => r.field === 'names')).toBe(false); expect(rows.some((r: any) => r.field === 'names')).toBe(false);
expect(rows.some((r: any) => r.target_name === 'addName')).toBe(false); expect(rows.some((r: any) => r.target_name === 'addName')).toBe(false);
}); });
it('is a no-op on non-Swift/Kotlin code even when the text patterns occur (#1235)', async () => {
// JS that contains every textual trigger the scanner looks for —
// `.forEach`, `.push(`, even a `{ $0(` lookalike inside a string — but
// `{ $0( ` / `{ it( ` element-invocation is Swift/Kotlin trailing-closure
// syntax, so the pass must skip the language entirely (this scan running
// ungated over `.push(`-heavy JS/PHP was the #1235 25-minute index).
fs.writeFileSync(
path.join(dir, 'queue.js'),
`class Queue {
constructor() { this.tasks = []; }
register(fn) { this.tasks.push(fn); }
drain() { this.tasks.forEach(function (t) { t(); }); }
weird() { const s = "tasks.forEach { $0( } tasks.push("; return s; }
}
module.exports = Queue;
`
);
const cg = await CodeGraph.init(dir, { silent: true });
await cg.indexAll();
const db = (cg as any).db.db;
const count = db
.prepare(
`SELECT count(*) c FROM edges
WHERE json_extract(metadata,'$.synthesizedBy') = 'closure-collection'`
)
.get();
cg.close?.();
expect(count.c).toBe(0);
});
}); });
+55 -8
View File
@@ -66,6 +66,15 @@ const CC_DISPATCH_RE = /(\w+)\.forEach\s*\{\s*(?:\$0|it)\s*\(/g;
const CC_APPEND_WRITE_RE = /(\w+)\.write\s*\{\s*\$0(?:\.(\w+))?\.(?:append|add|push|insert)\s*\(/g; const CC_APPEND_WRITE_RE = /(\w+)\.write\s*\{\s*\$0(?:\.(\w+))?\.(?:append|add|push|insert)\s*\(/g;
const CC_APPEND_DIRECT_RE = /(\w+)\.(?:append|add|push|insert)\s*\(/g; const CC_APPEND_DIRECT_RE = /(\w+)\.(?:append|add|push|insert)\s*\(/g;
const CC_FANOUT_CAP = 8; // skip a field name with more dispatchers/registrars than this (too generic to pair confidently) const CC_FANOUT_CAP = 8; // skip a field name with more dispatchers/registrars than this (too generic to pair confidently)
// The dispatcher gate — `{ $0( ` / `{ it( ` element-invocation — is Swift/Kotlin
// trailing-closure syntax, so ONLY those languages can ever contribute a
// dispatcher, and a cross-language registrar pairing (a JS `.push(` against a
// Swift dispatcher's field name) would be a wrong edge, not a missed one.
// Gating both sides here isn't just precision: `.push(`/`.add(` is everywhere
// in JS/PHP, so an ungated scan slices + regexes nearly every function on repos
// where the pass cannot emit a single edge — on a 12k-file PHP/JS app that was
// 20+ minutes of the "Resolving refs" tail and a #850 watchdog kill (#1235).
const CC_LANGUAGES = new Set(['swift', 'kotlin']);
function kebabToPascal(s: string): string { function kebabToPascal(s: string): string {
return s.split('-').map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(''); return s.split('-').map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join('');
@@ -100,6 +109,33 @@ function sliceLines(content: string, startLine?: number, endLine?: number): stri
return content.split('\n').slice(startLine - 1, endLine).join('\n'); return content.split('\n').slice(startLine - 1, endLine).join('\n');
} }
/**
* Per-match line resolver over `src`, 1-based at `baseLine`. The inline
* `src.slice(0, idx).split('\n').length` idiom is O(source-length) PER MATCH,
* which goes quadratic on a match-dense source (a generated function full of
* `.push(` calls re-scanned tens of thousands of times was most of the #1235
* indexing wedge). Builds the newline index once lazily, since most sources
* never produce a match then answers each call with a binary search.
*/
function makeLineAt(src: string, baseLine: number): (idx: number) => number {
let nl: number[] | null = null;
return (idx: number) => {
if (!nl) {
nl = [];
for (let i = src.indexOf('\n'); i !== -1; i = src.indexOf('\n', i + 1)) nl.push(i);
}
// Count newlines strictly before idx.
let lo = 0;
let hi = nl.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (nl[mid]! < idx) lo = mid + 1;
else hi = mid;
}
return baseLine + lo;
};
}
function registrarField(src: string): string | null { function registrarField(src: string): string | null {
const m = src.match(/this\.(\w+)\.(?:add|push|set)\(/); const m = src.match(/this\.(\w+)\.(?:add|push|set)\(/);
return m ? m[1]! : null; return m ? m[1]! : null;
@@ -224,24 +260,29 @@ async function closureCollectionEdges(queries: QueryBuilder, ctx: ResolutionCont
registrars.set(field, arr); registrars.set(field, arr);
}; };
// Slices EVERY method/function's source (no cheap name-gate), so on a repo // Slices EVERY Swift/Kotlin method/function's source (no cheap name-gate), so
// with a huge file this is the heaviest synthesis pass — yield mid-scan so it // on a repo with a huge file this is the heaviest synthesis pass — yield
// can't wedge the #850 watchdog on its own (#1091). // mid-scan (and mid-match-loop below: a single generated function dense with
// matches must not starve the watchdog either) so it can't wedge the #850
// watchdog on its own (#1091, #1235).
let scanned = 0; let scanned = 0;
let matchTick = 0;
for (const m of methodAndFunctionNodes(queries)) { for (const m of methodAndFunctionNodes(queries)) {
if ((++scanned & 127) === 0) await onYield(); if ((++scanned & 127) === 0) await onYield();
if (!CC_LANGUAGES.has(m.language)) continue;
const content = ctx.readFile(m.filePath); const content = ctx.readFile(m.filePath);
const src = content && sliceLines(content, m.startLine, m.endLine); const src = content && sliceLines(content, m.startLine, m.endLine);
if (!src) continue; if (!src) continue;
const hasForEach = src.includes('.forEach'); const hasForEach = src.includes('.forEach');
const hasAppend = src.includes('.append(') || src.includes('.add(') || src.includes('.push(') || src.includes('.insert('); const hasAppend = src.includes('.append(') || src.includes('.add(') || src.includes('.push(') || src.includes('.insert(');
if (!hasForEach && !hasAppend) continue; if (!hasForEach && !hasAppend) continue;
const lineAt = (idx: number) => (m.startLine ?? 1) + src.slice(0, idx).split('\n').length - 1; const lineAt = makeLineAt(src, m.startLine ?? 1);
if (hasForEach) { if (hasForEach) {
CC_DISPATCH_RE.lastIndex = 0; CC_DISPATCH_RE.lastIndex = 0;
let d: RegExpExecArray | null; let d: RegExpExecArray | null;
while ((d = CC_DISPATCH_RE.exec(src))) { while ((d = CC_DISPATCH_RE.exec(src))) {
if ((++matchTick & 255) === 0) await onYield();
const arr = dispatchers.get(d[1]!) ?? []; const arr = dispatchers.get(d[1]!) ?? [];
if (!arr.some((n) => n.node.id === m.id)) arr.push({ node: m, line: lineAt(d.index) }); if (!arr.some((n) => n.node.id === m.id)) arr.push({ node: m, line: lineAt(d.index) });
dispatchers.set(d[1]!, arr); dispatchers.set(d[1]!, arr);
@@ -250,10 +291,16 @@ async function closureCollectionEdges(queries: QueryBuilder, ctx: ResolutionCont
if (hasAppend) { if (hasAppend) {
CC_APPEND_WRITE_RE.lastIndex = 0; CC_APPEND_WRITE_RE.lastIndex = 0;
let w: RegExpExecArray | null; let w: RegExpExecArray | null;
while ((w = CC_APPEND_WRITE_RE.exec(src))) addReg(w[2] || w[1], m, lineAt(w.index)); // nested `$0.streams` else the `.write` receiver while ((w = CC_APPEND_WRITE_RE.exec(src))) {
if ((++matchTick & 255) === 0) await onYield();
addReg(w[2] || w[1], m, lineAt(w.index)); // nested `$0.streams` else the `.write` receiver
}
CC_APPEND_DIRECT_RE.lastIndex = 0; CC_APPEND_DIRECT_RE.lastIndex = 0;
let a: RegExpExecArray | null; let a: RegExpExecArray | null;
while ((a = CC_APPEND_DIRECT_RE.exec(src))) addReg(a[1], m, lineAt(a.index)); while ((a = CC_APPEND_DIRECT_RE.exec(src))) {
if ((++matchTick & 255) === 0) await onYield();
addReg(a[1], m, lineAt(a.index));
}
} }
} }
@@ -294,7 +341,7 @@ async function eventEmitterEdges(ctx: ResolutionContext, onYield: MaybeYield): P
const hasOn = content.includes('.on(') || content.includes('.once(') || content.includes('.addListener('); const hasOn = content.includes('.on(') || content.includes('.once(') || content.includes('.addListener(');
if (!hasEmit && !hasOn) continue; if (!hasEmit && !hasOn) continue;
const nodesInFile = ctx.getNodesInFile(file); const nodesInFile = ctx.getNodesInFile(file);
const lineOf = (idx: number) => content.slice(0, idx).split('\n').length; const lineOf = makeLineAt(content, 1);
if (hasEmit) { if (hasEmit) {
EMIT_RE.lastIndex = 0; EMIT_RE.lastIndex = 0;
@@ -1368,7 +1415,7 @@ async function rnEventEdges(ctx: ResolutionContext, onYield: MaybeYield): Promis
if (!content) continue; if (!content) continue;
const nodesInFile = ctx.getNodesInFile(file); const nodesInFile = ctx.getNodesInFile(file);
const lineOf = (idx: number) => content.slice(0, idx).split('\n').length; const lineOf = makeLineAt(content, 1);
const addDispatcher = (event: string, line: number) => { const addDispatcher = (event: string, line: number) => {
const disp = enclosingFn(nodesInFile, line); const disp = enclosingFn(nodesInFile, line);
if (!disp) return; if (!disp) return;