diff --git a/CHANGELOG.md b/CHANGELOG.md index d1b80a152f..3a2deb768d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -176,6 +176,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In TypeScript and JavaScript, a call on a field or variable whose class the file imports now goes through that class and the classes and interfaces it extends, each found the way its own file names it, before any other class of the same name. Before, when the class didn't declare the method itself, the call went to whichever same-named class had it: in vscode, `this._storage.dispose()` reached a test fixture's `Disposable`, and calls on a library's services in bitwarden reached the browser app's copies. A call like `this.logService.trace()`, whose `ILogService` gets `trace` from the `ILogger` it extends, linked nowhere and now links to `ILogger`'s method. A field's call whose class and everything it extends are known but lack the method, as when a package's base class provides it, now links to nothing rather than to an unrelated class. Re-index TypeScript and JavaScript projects after upgrading. - In TypeScript, a call on a value of an interface that extends one also declared as a constant, like vscode's `ITerminalLogService extends ILogService` where `ILogService` is its dependency-injection decorator too, now links to the method the extended interface declares, such as `ILogger`'s `trace`. Before, the constant was taken for the extended type, so the call linked nowhere. Re-index TypeScript projects after upgrading. - In JavaScript and TypeScript, an import written from a jsconfig or tsconfig `baseUrl`, like `import MainLayout from 'layout/MainLayout'` under `"baseUrl": "src"`, or through a catch-all `"paths": { "*": ["./src/*"] }`, now links to the file it names, read with the config of the app the importing file belongs to, and so does an import of a single name like `'config'` or `'themes'`. Create React App and Vite admin templates such as Berry, Mantis and MatX write nearly every import this way. Before, each one was taken for an npm package, so it linked nothing, its names were matched by name alone, which could reach a same-named component in another app of the same repository, and a React Router route whose page loads through `lazy(() => import('views/dashboard/Default'))` pointed at that loader instead of the page and its layout. A package the app's `package.json` declares, or a Node built-in, never links to a project module of the same name, only to type declarations the project keeps for it, and only a file the index holds counts, never a folder or something in `node_modules`. `codegraph sync` and the file watcher link such an import too when the file it names is added later or gains what it imports. Re-index JavaScript and TypeScript projects after upgrading. +- Indexing and sync on large projects, especially C and C++ codebases with huge generated headers, now keep cached source text and preprocessor data within a fixed memory budget, so the final resolution pass no longer runs out of memory. (#1583) +- `codegraph_impact` on a heavily referenced symbol now stops at a safety limit and says the answer was truncated, instead of exhausting the MCP server's memory; narrow it with `file` or a smaller `depth`. (#1583) +- The MCP server now recycles its query workers after they sit idle, releasing the memory a burst of large queries left behind while keeping one worker warm for the next call. (#1583) +- Path search between two symbols no longer slows down sharply or grows its memory use on densely connected graphs. (#1583) ## [1.6.2] - 2026-10-03 diff --git a/__tests__/graph.test.ts b/__tests__/graph.test.ts index 875c9ed2a8..0a2276ee37 100644 --- a/__tests__/graph.test.ts +++ b/__tests__/graph.test.ts @@ -11,6 +11,7 @@ import * as os from 'os'; import CodeGraph from '../src/index'; import { Node, Edge } from '../src/types'; import { GraphTraverser } from '../src/graph/traversal'; +import { ToolHandler } from '../src/mcp/tools'; describe('Graph Queries', () => { let testDir: string; @@ -535,6 +536,37 @@ function tGraph(nodes: Node[], edges: Edge[]): GraphTraverser { } describe('Traversal edge-completeness & limits (#1086–#1090)', () => { + it('findPath keeps shortest-path order without duplicate frontier entries', () => { + const nodes = ['A', 'B', 'C', 'D', 'E'].map((id) => tNode(id)); + const edges: Edge[] = [ + { source: 'A', target: 'B', kind: 'calls', line: 1 }, + { source: 'A', target: 'B', kind: 'references', line: 2 }, + { source: 'A', target: 'C', kind: 'calls', line: 3 }, + { source: 'B', target: 'D', kind: 'calls', line: 4 }, + { source: 'C', target: 'D', kind: 'calls', line: 5 }, + { source: 'D', target: 'E', kind: 'calls', line: 6 }, + ]; + const byId = new Map(nodes.map((n) => [n.id, n])); + const batches: string[][] = []; + const q = { + getNodeById: (id: string) => byId.get(id) ?? null, + getNodesByIds: (ids: readonly string[]) => { + batches.push([...ids]); + expect(new Set(ids).size).toBe(ids.length); + return new Map(ids.flatMap((id) => { + const node = byId.get(id); + return node ? [[id, node] as const] : []; + })); + }, + getOutgoingEdges: (source: string) => edges.filter((e) => e.source === source), + }; + + const path = new GraphTraverser(q as never).findPath('A', 'E'); + expect(path?.map((step) => step.node.id)).toEqual(['A', 'B', 'D', 'E']); + expect(path?.map((step) => step.edge?.line ?? null)).toEqual([null, 1, 4, 6]); + expect(batches[0]).toEqual(['B', 'C']); + }); + it('traverseBFS keeps every parallel edge to the same target (#1090)', () => { // A reaches B via both `calls` and `references` — two distinct edges. const edges: Edge[] = [ @@ -650,6 +682,29 @@ describe('Traversal edge-completeness & limits (#1086–#1090)', () => { const callees = tGraph(depthNodes, edges).getCallees('t', 2); expect(callees.map((c) => c.node.id).sort()).toEqual(['a', 'b', 'c']); }); + + it('getImpactRadius stops at node/edge budgets and marks truncation', () => { + const dependents = ['B', 'C', 'D', 'E', 'F']; + const nodes = [tNode('A'), ...dependents.map((id) => tNode(id))]; + const edges: Edge[] = dependents.map((source) => ({ source, target: 'A', kind: 'calls' })); + const sub = tGraph(nodes, edges).getImpactRadius('A', 2, { maxNodes: 3, maxEdges: 2 }); + + expect(sub.nodes.size).toBe(3); + expect(sub.edges).toHaveLength(2); + expect(sub.truncated).toBe(true); + expect(sub.edges.every((edge) => sub.nodes.has(edge.source) && sub.nodes.has(edge.target))).toBe(true); + }); + + it('surfaces impact truncation explicitly in MCP output', () => { + const formatted = (new ToolHandler(null) as any).formatImpact('A', { + nodes: new Map([['A', tNode('A')]]), + edges: [], + roots: ['A'], + truncated: true, + }); + expect(formatted).toMatch(/truncated at safety limit/i); + expect(formatted).toMatch(/reduce `depth`/i); + }); }); describe('findPath enqueue-once (#1359)', () => { @@ -686,22 +741,21 @@ describe('findPath enqueue-once (#1359)', () => { for (const b of layerB) edges.push({ source: b, target: 'end', kind: 'calls' }); seed(ids, edges); - // Count actual queue insertions, without timing thresholds or replacing SQLite. + // Count target lookups: a node is looked up only when it is first + // discovered and enqueued, so a repeat would mean a duplicate queue entry. + // No timing thresholds, and SQLite stays real. const counts = new Map(); - const push = Array.prototype.push; + const queries = cg['queries']; + const getNodesByIds = queries.getNodesByIds; let result: ReturnType; try { - Array.prototype.push = function (...items) { - for (const item of items) { - if (item && typeof item.nodeId === 'string' && Array.isArray(item.path)) { - counts.set(item.nodeId, (counts.get(item.nodeId) ?? 0) + 1); - } - } - return Reflect.apply(push, this, items); + queries.getNodesByIds = function (ids) { + for (const id of ids) counts.set(id, (counts.get(id) ?? 0) + 1); + return getNodesByIds.call(this, ids); }; result = cg.findPath('start', 'end', ['calls']); } finally { - Array.prototype.push = push; + queries.getNodesByIds = getNodesByIds; } expect(result?.map((step) => step.node.id)).toEqual(['start', 'a0', 'b0', 'end']); expect(result?.slice(1).every((step) => step.edge?.kind === 'calls')).toBe(true); diff --git a/__tests__/integration/lru-cache.test.ts b/__tests__/integration/lru-cache.test.ts index 8156760ae2..56accf3263 100644 --- a/__tests__/integration/lru-cache.test.ts +++ b/__tests__/integration/lru-cache.test.ts @@ -82,6 +82,44 @@ describe('LRUCache', () => { expect(() => new LRUCache(NaN)).toThrow(); }); + it('evicts by retained weight as well as entry count', () => { + const cache = new LRUCache(100, { + maxWeight: 10, + weightOf: (value) => value.length, + }); + cache.set('a', '1234'); + cache.set('b', '5678'); + expect(cache.get('a')).toBe('1234'); // refresh a; b is now oldest + cache.set('c', '9012'); + expect(cache.get('b')).toBeUndefined(); + expect(cache.get('a')).toBe('1234'); + expect(cache.get('c')).toBe('9012'); + }); + + it('does not retain a single entry larger than the weight budget', () => { + const cache = new LRUCache(10, { + maxWeight: 4, + weightOf: (value) => value.length, + }); + cache.set('too-large', '12345'); + expect(cache.size).toBe(0); + }); + + it('updates weight accounting on replacement and clear', () => { + const cache = new LRUCache(10, { + maxWeight: 6, + weightOf: (value) => value.length, + }); + cache.set('a', '12345'); + cache.set('a', '1'); + cache.set('b', '23456'); + expect(cache.get('a')).toBe('1'); + expect(cache.get('b')).toBe('23456'); + cache.clear(); + cache.set('c', '123456'); + expect(cache.get('c')).toBe('123456'); + }); + it('stays bounded under heavy churn (regression for OOM scenario)', () => { const cache = new LRUCache(100); for (let i = 0; i < 10_000; i++) { diff --git a/__tests__/query-pool.test.ts b/__tests__/query-pool.test.ts index 7c28f48f04..d5d6807530 100644 --- a/__tests__/query-pool.test.ts +++ b/__tests__/query-pool.test.ts @@ -36,6 +36,10 @@ class FakeWorker implements PoolWorker { private errorCb?: (e: Error) => void; private exitCb?: (code: number) => void; alive = true; + terminated = false; + exitCode: number | undefined; + /** Ignore the pool's 'close', to exercise its terminate fallback. */ + ignoresClose = false; constructor(private behavior: (m: CallMsg) => Action, readyOk: boolean | null = true) { setTimeout(() => { if (this.alive && readyOk !== null) this.emitMessage({ type: 'ready', ok: readyOk }); }, 0); } @@ -51,7 +55,14 @@ class FakeWorker implements PoolWorker { if (this.alive) this.msgCb?.({ type: 'result', id, result }); } postMessage(msg: unknown): void { - const m = msg as CallMsg; + const m = msg as CallMsg | { type: 'close' }; + if (m?.type === 'close') { + // Like the real worker: close up and exit by itself, with code 0. + if (this.ignoresClose) return; + this.alive = false; + setTimeout(() => { this.exitCode = 0; this.exitCb?.(0); }, 0); + return; + } if (!m || m.type !== 'call') return; const action = this.behavior(m); if ('crash' in action) { @@ -63,7 +74,7 @@ class FakeWorker implements PoolWorker { if ('wait' in action) { void action.wait.then((r) => this.reply(m.id, r)); return; } setTimeout(() => this.reply(m.id, action.result), 0); } - terminate(): Promise { this.alive = false; return Promise.resolve(0); } + terminate(): Promise { this.alive = false; this.terminated = true; return Promise.resolve(0); } } const ok = (text: string): ToolResult => ({ content: [{ type: 'text', text }] }); @@ -126,6 +137,59 @@ describe('QueryPool', () => { await pool.destroy(); }); + it('recycles an idle burst back to one fresh worker', async () => { + let release!: () => void; + const gate = new Promise((r) => { release = r; }); + const workers: FakeWorker[] = []; + const pool = new QueryPool({ + root: '/x', size: 4, idleShrinkMs: 20, + createWorker: () => { + const worker = new FakeWorker((m) => ({ + wait: gate.then(() => ok(`r${m.id}`)), + })); + workers.push(worker); + return worker; + }, + }); + + const calls = Promise.all(Array.from({ length: 4 }, (_, i) => pool.run('codegraph_search', { i }))); + await sleep(40); + expect(pool.liveWorkers).toBe(4); + release(); + await calls; + await sleep(40); + + expect(pool.liveWorkers).toBe(1); + expect(workers).toHaveLength(5); // four used isolates replaced by one clean isolate + expect(workers.slice(0, 4).every((w) => !w.alive)).toBe(true); + // Each used worker exited by itself on 'close'; none was terminated. + expect(workers.slice(0, 4).every((w) => w.exitCode === 0 && !w.terminated)).toBe(true); + expect(pool.ready).toBe(true); + const again = await pool.run('codegraph_node', { symbol: 's' }); + expect(again.isError).toBeFalsy(); + await pool.destroy(); + }); + + it('terminates a retired idle worker that does not exit by itself', async () => { + const workers: FakeWorker[] = []; + const pool = new QueryPool({ + root: '/x', size: 1, idleShrinkMs: 20, retireTimeoutMs: 200, + createWorker: () => { + const worker = new FakeWorker(() => ({ result: ok('r') })); + worker.ignoresClose = workers.length === 0; + workers.push(worker); + return worker; + }, + }); + + await pool.run('codegraph_search', {}); + await vi.waitFor(() => expect(workers).toHaveLength(2)); // retired, replaced + expect(workers[0].terminated).toBe(false); // still has time to exit + await vi.waitFor(() => expect(workers[0].terminated).toBe(true)); + expect(workers[1].terminated).toBe(false); + await pool.destroy(); + }); + it('recovers from a worker crash: retries the in-flight call and respawns', async () => { let calls = 0; const pool = new QueryPool({ @@ -465,6 +529,31 @@ describe('MCP query pool with real projects (#1465)', () => { } }, 30000); + it('retires an idle real worker that exits by itself with code 0', async () => { + const alpha = await indexProject('alpha', 'alphaSymbol'); + const terminated: boolean[] = []; + const exits: Promise[] = []; + pool = new QueryPool({ + root: alpha, size: 1, idleShrinkMs: 50, + createWorker: () => { + const worker = new Worker(path.resolve(__dirname, '../dist/mcp/query-worker.js'), { workerData: { root: alpha } }); + const i = terminated.push(false) - 1; + const terminate = worker.terminate.bind(worker); + worker.terminate = () => { + terminated[i] = true; + return terminate(); + }; + exits.push(new Promise((resolve) => worker.once('exit', resolve))); + return worker; + }, + }); + const result = await pool.run('codegraph_search', { query: 'alphaSymbol' }); + expect(result.isError).toBeFalsy(); + expect(await exits[0]).toBe(0); + expect(terminated[0]).toBe(false); + expect(exits).toHaveLength(2); // a fresh worker replaced it + }, 30000); + it('never terminates a real worker before it has started', async () => { const alpha = await indexProject('alpha', 'alphaSymbol'); const startedWhenEnded: boolean[] = []; diff --git a/src/db/queries.ts b/src/db/queries.ts index fd8367f174..534d0bed51 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -2194,8 +2194,8 @@ export class QueryBuilder { * Get outgoing edges from a node. Preserve the source/kind index order * (calls before imports/references), then break ties deterministically. */ - getOutgoingEdges(sourceId: string, kinds?: EdgeKind[], provenance?: string): Edge[] { - if ((kinds && kinds.length > 0) || provenance) { + getOutgoingEdges(sourceId: string, kinds?: EdgeKind[], provenance?: string, limit?: number): Edge[] { + if ((kinds && kinds.length > 0) || provenance || limit !== undefined) { let sql = 'SELECT * FROM edges WHERE source = ?'; const params: (string | number)[] = [sourceId]; @@ -2210,6 +2210,10 @@ export class QueryBuilder { } sql += ' ORDER BY kind, target, line, col'; + if (limit !== undefined) { + sql += ' LIMIT ?'; + params.push(Math.max(0, Math.floor(limit))); + } const rows = this.edgeKindStmt(sql).all(...params) as EdgeRow[]; return rows.map(rowToEdge); } @@ -2227,10 +2231,20 @@ export class QueryBuilder { * displace actual calls in capped caller lists. Keep deterministic ties * without changing the target/kind index's established kind precedence. */ - getIncomingEdges(targetId: string, kinds?: EdgeKind[]): Edge[] { - if (kinds && kinds.length > 0) { - const sql = `SELECT * FROM edges WHERE target = ? AND kind IN (${kinds.map(() => '?').join(',')}) ORDER BY kind, source, line, col`; - const rows = this.edgeKindStmt(sql).all(targetId, ...kinds) as EdgeRow[]; + getIncomingEdges(targetId: string, kinds?: EdgeKind[], limit?: number): Edge[] { + if ((kinds && kinds.length > 0) || limit !== undefined) { + let sql = 'SELECT * FROM edges WHERE target = ?'; + const params: (string | number)[] = [targetId]; + if (kinds && kinds.length > 0) { + sql += ` AND kind IN (${kinds.map(() => '?').join(',')})`; + params.push(...kinds); + } + sql += ' ORDER BY kind, source, line, col'; + if (limit !== undefined) { + sql += ' LIMIT ?'; + params.push(Math.max(0, Math.floor(limit))); + } + const rows = this.edgeKindStmt(sql).all(...params) as EdgeRow[]; return rows.map(rowToEdge); } diff --git a/src/graph/traversal.ts b/src/graph/traversal.ts index 47368ac6af..a45bc4e583 100644 --- a/src/graph/traversal.ts +++ b/src/graph/traversal.ts @@ -4,7 +4,7 @@ * BFS and DFS traversal for the code knowledge graph. */ -import { Node, Edge, Subgraph, TraversalOptions, EdgeKind } from '../types'; +import { Node, Edge, Subgraph, TraversalOptions, EdgeKind, ImpactOptions, EDGE_KINDS } from '../types'; import { QueryBuilder } from '../db/queries'; /** @@ -19,6 +19,10 @@ const DEFAULT_OPTIONS: Required = { includeStart: true, }; +const DEFAULT_IMPACT_MAX_NODES = 10_000; +const DEFAULT_IMPACT_MAX_EDGES = 50_000; +const IMPACT_INCOMING_KINDS = EDGE_KINDS.filter((kind) => kind !== 'contains'); + /** * Result of a single traversal step */ @@ -535,7 +539,7 @@ export class GraphTraverser { * @param maxDepth - Maximum depth to traverse (default: 3) * @returns Subgraph containing potentially impacted nodes */ - getImpactRadius(nodeId: string, maxDepth: number = 3): Subgraph { + getImpactRadius(nodeId: string, maxDepth: number = 3, options: ImpactOptions = {}): Subgraph { const focalNode = this.queries.getNodeById(nodeId); if (!focalNode) { return { nodes: new Map(), edges: [], roots: [] }; @@ -544,17 +548,23 @@ export class GraphTraverser { const nodes = new Map(); const edges: Edge[] = []; const visited = new Map(); + const budget = { + maxNodes: Math.max(1, Math.floor(options.maxNodes ?? DEFAULT_IMPACT_MAX_NODES)), + maxEdges: Math.max(0, Math.floor(options.maxEdges ?? DEFAULT_IMPACT_MAX_EDGES)), + truncated: false, + }; // Add focal node nodes.set(focalNode.id, focalNode); // Traverse incoming edges to find all dependents - this.getImpactRecursive(nodeId, maxDepth, 0, nodes, edges, visited, new Set()); + this.getImpactRecursive(nodeId, maxDepth, 0, nodes, edges, visited, new Set(), budget); return { nodes, edges, roots: [nodeId], + truncated: budget.truncated || undefined, }; } @@ -565,7 +575,8 @@ export class GraphTraverser { nodes: Map, edges: Edge[], visited: Map, - expanded: Set + expanded: Set, + budget: { maxNodes: number; maxEdges: number; truncated: boolean } ): void { // Mark visited before the depth check so a node collected at the depth // boundary still lands in `visited`. Otherwise it could sit in `nodes` but @@ -586,18 +597,25 @@ export class GraphTraverser { if (focalNode) { const containerKinds = new Set(['class', 'interface', 'struct', 'union', 'trait', 'protocol', 'module', 'enum']); if (containerKinds.has(focalNode.kind)) { - const containsEdges = this.queries.getOutgoingEdges(nodeId, ['contains']); + const remaining = budget.maxEdges - edges.length; + const fetched = this.queries.getOutgoingEdges(nodeId, ['contains'], undefined, remaining + 1); + if (fetched.length > remaining) budget.truncated = true; + const containsEdges = fetched.slice(0, remaining); if (containsEdges.length > 0) { const children = this.queries.getNodesByIds(containsEdges.map((e) => e.target)); for (const edge of containsEdges) { const childNode = children.get(edge.target); if (childNode && this.nearerThanBefore(visited, childNode.id, currentDepth)) { if (!nodes.has(childNode.id)) { + if (nodes.size >= budget.maxNodes || edges.length >= budget.maxEdges) { + budget.truncated = true; + continue; + } nodes.set(childNode.id, childNode); edges.push(edge); } // Recurse into children at the same depth (they're part of the same symbol) - this.getImpactRecursive(childNode.id, maxDepth, currentDepth, nodes, edges, visited, expanded); + this.getImpactRecursive(childNode.id, maxDepth, currentDepth, nodes, edges, visited, expanded, budget); } } } @@ -608,7 +626,12 @@ export class GraphTraverser { // `contains`: a container "contains" its members but does not *depend* on // them, so following it upward would climb to the parent class and then // re-expand every sibling member — exploding impact for a leaf symbol. (#536) - const incomingEdges = this.queries.getIncomingEdges(nodeId).filter((e) => e.kind !== 'contains'); + const remaining = budget.maxEdges - edges.length; + // Fetch one extra row so hitting the SQL LIMIT is distinguishable from an + // exact-size result and can be surfaced as explicit truncation. + const fetched = this.queries.getIncomingEdges(nodeId, IMPACT_INCOMING_KINDS, remaining + 1); + if (fetched.length > remaining) budget.truncated = true; + const incomingEdges = fetched.slice(0, remaining); if (incomingEdges.length === 0) return; const sources = this.queries.getNodesByIds(incomingEdges.map((e) => e.source)); @@ -620,10 +643,18 @@ export class GraphTraverser { // node already collected via another path was silently dropped from // `edges` even though it's a real dependency (#1089). Each node's incoming // edges are recorded on its first expansion only, so no edge repeats. + if (!nodes.has(sourceNode.id) && nodes.size >= budget.maxNodes) { + budget.truncated = true; + continue; + } + if (edges.length >= budget.maxEdges) { + budget.truncated = true; + continue; + } if (firstExpansion) edges.push(edge); if (this.nearerThanBefore(visited, sourceNode.id, currentDepth + 1)) { nodes.set(sourceNode.id, sourceNode); - this.getImpactRecursive(sourceNode.id, maxDepth, currentDepth + 1, nodes, edges, visited, expanded); + this.getImpactRecursive(sourceNode.id, maxDepth, currentDepth + 1, nodes, edges, visited, expanded, budget); } } } @@ -648,25 +679,31 @@ export class GraphTraverser { return null; } - // BFS to find shortest path - const visited = new Set(); + // BFS to find the shortest path. Keep one predecessor per discovered node + // instead of copying the full path into every queue entry; use a head index + // so dequeues stay O(1), and mark on enqueue so converging edges cannot + // multiply queued work. const enqueued = new Set([fromId]); - const queue: Array<{ nodeId: string; path: Array<{ node: Node; edge: Edge | null }> }> = [ - { nodeId: fromId, path: [{ node: fromNode, edge: null }] }, - ]; + const parents = new Map(); + const queue: string[] = [fromId]; + let head = 0; - while (queue.length > 0) { - const { nodeId, path } = queue.shift()!; + while (head < queue.length) { + const nodeId = queue[head++]!; if (nodeId === toId) { + const path: Array<{ node: Node; edge: Edge | null }> = []; + let currentId = toId; + while (currentId !== fromId) { + const step = parents.get(currentId)!; + path.push({ node: step.node, edge: step.edge }); + currentId = step.parentId; + } + path.push({ node: fromNode, edge: null }); + path.reverse(); return path; } - if (visited.has(nodeId)) { - continue; - } - visited.add(nodeId); - // Get outgoing edges const outgoingEdges = this.queries.getOutgoingEdges( nodeId, @@ -674,23 +711,20 @@ export class GraphTraverser { ); if (outgoingEdges.length === 0) continue; - // Batch-fetch only targets not yet visited or queued. - const wantIds = outgoingEdges - .map((e) => e.target) - .filter((id) => !visited.has(id) && !enqueued.has(id)); + // Batch-fetch only undiscovered targets, once each even when parallel + // edges point at the same node. + const wantIds = [...new Set( + outgoingEdges.map((e) => e.target).filter((id) => !enqueued.has(id)) + )]; const nextNodes = wantIds.length > 0 ? this.queries.getNodesByIds(wantIds) : new Map(); for (const edge of outgoingEdges) { - if (!visited.has(edge.target) && !enqueued.has(edge.target)) { - const nextNode = nextNodes.get(edge.target); - if (nextNode) { - enqueued.add(edge.target); - queue.push({ - nodeId: edge.target, - path: [...path, { node: nextNode, edge }], - }); - } - } + if (enqueued.has(edge.target)) continue; + const nextNode = nextNodes.get(edge.target); + if (!nextNode) continue; + enqueued.add(edge.target); + parents.set(edge.target, { parentId: nodeId, node: nextNode, edge }); + queue.push(edge.target); } } diff --git a/src/index.ts b/src/index.ts index 7a5a2d4252..c827556b80 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,7 @@ import { FindRelevantContextOptions, UnresolvedReference, IndexHealth, + ImpactOptions, Language, } from './types'; import { DatabaseConnection, getDatabasePath, removeDatabaseFiles } from './db'; @@ -2395,8 +2396,8 @@ export class CodeGraph { * @param maxDepth - Maximum depth to traverse (default: 3) * @returns Subgraph containing potentially impacted nodes */ - getImpactRadius(nodeId: string, maxDepth: number = 3): Subgraph { - return this.traverser.getImpactRadius(nodeId, maxDepth); + getImpactRadius(nodeId: string, maxDepth: number = 3, options: ImpactOptions = {}): Subgraph { + return this.traverser.getImpactRadius(nodeId, maxDepth, options); } /** diff --git a/src/mcp/query-pool.ts b/src/mcp/query-pool.ts index 6b1f473991..9bd463857f 100644 --- a/src/mcp/query-pool.ts +++ b/src/mcp/query-pool.ts @@ -48,6 +48,13 @@ export interface PoolWorker { /** Default linger before a queued call is answered with busy-guidance. */ const DEFAULT_BUSY_TIMEOUT_MS = 45_000; // < the ~60s MCP client request timeout +/** Recycle a quiescent pool after a burst so worker-local graph/SQLite caches + * do not remain resident for the daemon's whole lifetime. */ +const DEFAULT_IDLE_SHRINK_MS = 60_000; + +/** How long a retired idle worker gets to exit on 'close' before it is terminated. */ +const RETIRE_TIMEOUT_MS = 5_000; + /** Hard ceiling on pool size regardless of core count / env. */ const MAX_POOL_SIZE = 16; @@ -108,6 +115,10 @@ export interface QueryPoolOptions { softTimeoutMs?: number; /** Retries for an in-flight call whose worker crashed. Default 1. */ maxRetries?: number; + /** Idle delay before recycling back to one fresh worker. Default 60s; 0 disables. */ + idleShrinkMs?: number; + /** How long a retired idle worker gets to exit by itself (tests shorten it). Default 5s. */ + retireTimeoutMs?: number; /** Worker factory (tests inject a fake). Defaults to a real `worker_threads` Worker. */ createWorker?: () => PoolWorker; /** How long destroy() waits on a worker still starting up (tests shorten it). Default 15s. */ @@ -174,14 +185,22 @@ export class QueryPool { private readonly maxSize: number; private readonly softTimeoutMs: number; private readonly maxRetries: number; + private readonly idleShrinkMs: number; + private readonly retireTimeoutMs: number; private readonly createWorker: () => PoolWorker; private readonly startSettleMs: number; + private idleShrinkTimer?: NodeJS.Timeout; + // Retired idle workers asked to exit, each with the timer that terminates it + // if it hasn't by RETIRE_TIMEOUT_MS. + private retiring = new Map(); constructor(opts: QueryPoolOptions) { this.root = opts.root; this.maxSize = Math.max(1, Math.min(opts.size ?? Math.max(1, os.cpus().length - 1), MAX_POOL_SIZE)); this.softTimeoutMs = opts.softTimeoutMs ?? resolveBusyTimeoutMs(); this.maxRetries = opts.maxRetries ?? 1; + this.idleShrinkMs = Math.max(0, opts.idleShrinkMs ?? DEFAULT_IDLE_SHRINK_MS); + this.retireTimeoutMs = opts.retireTimeoutMs ?? RETIRE_TIMEOUT_MS; this.createWorker = opts.createWorker ?? (() => new Worker(WORKER_FILE, { workerData: { root: this.root } })); this.startSettleMs = opts.startSettleMs ?? WORKER_START_SETTLE_MS; this.spawnOne(); // one eager warm worker, ready for the first call @@ -235,7 +254,7 @@ export class QueryPool { this.starts.set(w, { settled, settle }); w.on('message', (m) => this.onMessage(w, (m ?? {}) as WorkerMessage)); w.on('error', () => { this.startSettled(w); this.onWorkerGone(w); }); - w.on('exit', (code) => { this.startSettled(w); if (code !== 0) this.onWorkerGone(w); }); + w.on('exit', (code) => { this.startSettled(w); this.retired(w); if (code !== 0) this.onWorkerGone(w); }); } private onMessage(w: PoolWorker, m: WorkerMessage): void { @@ -260,9 +279,72 @@ export class QueryPool { this.idle.push(w); if (job) this.settle(job, m.result ?? busyGuidance(0)); this.drain(); + this.armIdleShrink(); + } + } + + private clearIdleShrink(): void { + if (this.idleShrinkTimer) clearTimeout(this.idleShrinkTimer); + this.idleShrinkTimer = undefined; + } + + private armIdleShrink(): void { + this.clearIdleShrink(); + if ( + this.idleShrinkMs === 0 || this.destroyed || this.queue.length > 0 || + this.inflight.size > 0 || this.pendingWorkers.size > 0 || + this.idle.length !== this.workers.size + ) return; + this.idleShrinkTimer = setTimeout(() => this.shrinkIdle(), this.idleShrinkMs); + this.idleShrinkTimer.unref?.(); + } + + private shrinkIdle(): void { + this.idleShrinkTimer = undefined; + if ( + this.destroyed || this.queue.length > 0 || this.inflight.size > 0 || + this.pendingWorkers.size > 0 || this.idle.length !== this.workers.size + ) return; + + // Drop every used isolate, including the last one: a single worker can hold + // hundreds of MB in graph and SQLite caches after a large query. Replace it + // with one clean warm worker so the next burst still avoids a cold queue. + const stale = [...this.workers]; + this.workers.clear(); + this.idle = []; + this.everReady = false; + for (const w of stale) this.retire(w); + this.spawnOne(); + } + + /** + * Ask an idle worker to exit by itself: it closes its connections and + * collects garbage first, so no marking is in flight when its thread ends + * (see worker-teardown.ts). One that hasn't exited by `retireTimeoutMs` is + * terminated. + */ + private retire(w: PoolWorker): void { + const cap = setTimeout(() => { + this.retiring.delete(w); + void this.terminateStarted(w); + }, this.retireTimeoutMs); + cap.unref?.(); + this.retiring.set(w, cap); + try { + w.postMessage({ type: 'close' }); + } catch { + this.retired(w); + void this.terminateStarted(w); } } + private retired(w: PoolWorker): void { + const cap = this.retiring.get(w); + if (cap === undefined) return; + clearTimeout(cap); + this.retiring.delete(w); + } + // A worker died (crash hook, OOM, segfault, exit≠0). Respawn a replacement and // retry its in-flight job once; a job that keeps crashing workers fails // gracefully so it can't loop the pool forever. @@ -319,6 +401,7 @@ export class QueryPool { /** Run a read tool on the pool. Always resolves (never rejects). */ run(toolName: string, args: Record): Promise { + this.clearIdleShrink(); return new Promise((resolve) => { const job: Job = { id: this.nextId++, toolName, args, resolve, @@ -369,6 +452,7 @@ export class QueryPool { async destroy(): Promise { if (this.destroyed) return; this.destroyed = true; + this.clearIdleShrink(); const ws = [...this.workers]; this.workers.clear(); this.pendingWorkers.clear(); diff --git a/src/mcp/query-worker.ts b/src/mcp/query-worker.ts index 525e9334a6..f03af5eee4 100644 --- a/src/mcp/query-worker.ts +++ b/src/mcp/query-worker.ts @@ -23,6 +23,7 @@ import { parentPort, workerData } from 'worker_threads'; import type { ToolResult } from './tools'; +import { collectBeforeExit } from '../worker-teardown'; interface WorkerInit { root: string | null; @@ -35,6 +36,11 @@ interface CallMessage { args: Record; } +/** The pool retiring this idle worker: close everything and exit. */ +interface CloseMessage { + type: 'close'; +} + // Mirror the engine's lazy-require of the heavy CodeGraph + tools chain. This // module is only ever loaded as a Worker, so the require runs once on spawn. const loadCodeGraph = (): typeof import('../index').default => @@ -50,10 +56,11 @@ if (parentPort) { // opened lazily on first cross-project (projectPath) call by the ToolHandler's // own per-handler cache. openSync does not start a watcher — workers are pure // readers; the single watcher/writer stays on the daemon's main thread. + let cg: import('../index').default | null = null; let handler: InstanceType | null = null; let initError: string | null = null; try { - const cg = root ? loadCodeGraph().openSync(root) : null; + cg = root ? loadCodeGraph().openSync(root) : null; handler = new (loadToolHandler())(cg); } catch (err) { initError = err instanceof Error ? err.message : String(err); @@ -63,7 +70,18 @@ if (parentPort) { // against its crash budget (→ fall back to in-process) without hanging. port.postMessage({ type: 'ready', ok: initError === null, error: initError }); - port.on('message', (msg: CallMessage) => { + port.on('message', (msg: CallMessage | CloseMessage) => { + if (msg?.type === 'close') { + try { + void handler?.closeAll(); // projects opened through projectPath + } catch { /* already closed */ } + try { + cg?.close(); + } catch { /* already closed */ } + // No GC marking in flight when the thread ends (worker-teardown.ts). + collectBeforeExit(); + process.exit(0); + } if (!msg || msg.type !== 'call') return; void serve(msg); }); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 86084e841e..4c92ed292c 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -3262,24 +3262,40 @@ export class ToolHandler { : ''; const impactOf = (defNodes: Node[]) => { + const maxNodes = 1_000; + const maxEdges = 5_000; const mergedNodes = new Map(); const mergedEdges: Edge[] = []; const seenEdges = new Set(); + let truncated = false; for (const node of defNodes) { - const impact = cg.getImpactRadius(node.id, depth); + const impact = cg.getImpactRadius(node.id, depth, { maxNodes, maxEdges }); + truncated ||= impact.truncated === true; for (const [id, n] of impact.nodes) { + if (!mergedNodes.has(id) && mergedNodes.size >= maxNodes) { + truncated = true; + continue; + } mergedNodes.set(id, n); answerPaths.add(n.filePath); } for (const e of impact.edges) { + if (!mergedNodes.has(e.source) || !mergedNodes.has(e.target)) { + truncated = true; + continue; + } const key = `${e.source}->${e.target}:${e.kind}`; if (!seenEdges.has(key)) { + if (mergedEdges.length >= maxEdges) { + truncated = true; + continue; + } seenEdges.add(key); mergedEdges.push(e); } } } - return { nodes: mergedNodes, edges: mergedEdges, roots: defNodes.map((n) => n.id) }; + return { nodes: mergedNodes, edges: mergedEdges, roots: defNodes.map((n) => n.id), truncated }; }; // Single definition (or same-file overloads): the familiar merged report. @@ -8444,9 +8460,12 @@ export class ToolHandler { // Compact format: just list affected symbols grouped by file const lines: string[] = [ - `**Impact: "${symbol}" affects ${nodeCount} symbols**`, + `**Impact: "${symbol}" affects ${nodeCount} symbols${impact.truncated ? ' (truncated at safety limit)' : ''}**`, '', ]; + if (impact.truncated) { + lines.push('> Result truncated to protect the MCP process on a high-fanout graph. Narrow with `file` or reduce `depth`.', ''); + } // Group by file const byFile = new Map(); diff --git a/src/resolution/c-fnptr-synthesizer.ts b/src/resolution/c-fnptr-synthesizer.ts index 568b782c9f..9d9a760d17 100644 --- a/src/resolution/c-fnptr-synthesizer.ts +++ b/src/resolution/c-fnptr-synthesizer.ts @@ -178,6 +178,12 @@ interface MacroDef { expansion: string; } +/** Regex captures are sliced strings in V8. A cached tiny macro capture would + * otherwise pin its whole multi-megabyte source header; force an owned copy. */ +function ownString(value: string): string { + return Buffer.from(value, 'utf8').toString('utf8'); +} + /** * Collect function-like macros from (comment-stripped) source, joining * `\`-continuations first. Only object/positional table macros matter here, so @@ -191,9 +197,9 @@ function parseFunctionMacros(stripped: string): Map { const RE = /^[ \t]*#[ \t]*define[ \t]+(\w+)\(([^)]*)\)\s+(.+)$/gm; let m: RegExpExecArray | null; while ((m = RE.exec(joined))) { - const params = m[2]!.split(',').map((p) => p.trim()).filter(Boolean); + const params = m[2]!.split(',').map((p) => p.trim()).filter(Boolean).map(ownString); if (params.some((p) => p === '...' || p.endsWith('...'))) continue; // variadic — skip - out.set(m[1]!, { params, expansion: m[3]!.trim() }); + out.set(ownString(m[1]!), { params, expansion: ownString(m[3]!.trim()) }); } return out; } @@ -207,22 +213,41 @@ function parseObjectMacros(stripped: string): Map { const out = new Map(); if (!stripped.includes('#define') && !stripped.includes('# define')) return out; const joined = stripped.replace(/\\\r?\n/g, ' '); - const RE = /^[ \t]*#[ \t]*define[ \t]+(\w+)[ \t]+(\S[^\n]*)$/gm; + const RE = /^[ \t]*#[ \t]*define[ \t]+(\w+)[ \t]+((?:(?:struct|union)[ \t]+)?[A-Za-z_]\w*)[ \t\r]*$/gm; let m: RegExpExecArray | null; - while ((m = RE.exec(joined))) out.set(m[1]!, m[2]!.trim()); + while ((m = RE.exec(joined))) out.set(ownString(m[1]!), ownString(m[2]!)); return out; } /** All macro names a file `#define`s (value-ful or not) — the "defined" set for #ifdef. */ -function parseDefinedNames(stripped: string): Set { +function parseDefinedNames(stripped: string, relevant: Set): Set { const out = new Set(); - if (!stripped.includes('#define') && !stripped.includes('# define')) return out; + if (relevant.size === 0 || (!stripped.includes('#define') && !stripped.includes('# define'))) return out; const RE = /^[ \t]*#[ \t]*define[ \t]+(\w+)/gm; let m: RegExpExecArray | null; - while ((m = RE.exec(stripped))) out.add(m[1]!); + while ((m = RE.exec(stripped))) { + if (relevant.has(m[1]!)) out.add(ownString(m[1]!)); + } return out; } +/** Names the conditional evaluator can observe. Keeping only these in each + * file's defined-set drops millions of numeric register macros that can never + * affect an `#ifdef`/`defined(NAME)` branch in any registration unit. */ +function collectConditionalNames(source: string, out: Set): void { + if (!source.includes('#if') && !source.includes('# if') && !source.includes('#elif')) return; + const ifdef = /^[ \t]*#[ \t]*(?:ifdef|ifndef)[ \t]+(\w+)/gm; + let m: RegExpExecArray | null; + while ((m = ifdef.exec(source))) out.add(ownString(m[1]!)); + const line = /^[ \t]*#[ \t]*(?:if|elif)\b([^\n]*)$/gm; + while ((m = line.exec(source))) { + const expr = m[1]!; + const defined = /\bdefined\s*(?:\(\s*)?(\w+)/g; + let d: RegExpExecArray | null; + while ((d = defined.exec(expr))) out.add(ownString(d[1]!)); + } +} + /** * Drop the inactive arms of `#ifdef`/`#ifndef`/`#if defined(X)`/`#else`/`#elif`/ * `#endif` given a set of defined macro names, keeping line offsets (inactive @@ -370,7 +395,7 @@ const INCLUDABLE_EXT = /\.(def|inc|h|hh|hpp|hxx|c|cc|cpp|cxx|ipp|tcc|tbl)$/i; * are excluded: `resolveTypeName` would rewrite to a dead-end token that can * never name a struct, so skipping them is exact, and it drops the register * flood. */ -const OBJ_ALIAS_RE = /^[ \t]*#[ \t]*define[ \t]+(\w+)[ \t]+(?:(?:struct|union)[ \t]+)*[A-Za-z_]\w*[ \t\r]*$/gm; +const OBJ_ALIAS_RE = /^[ \t]*#[ \t]*define[ \t]+(\w+)[ \t]+((?:(?:struct|union)[ \t]+)?[A-Za-z_]\w*)[ \t\r]*$/gm; /** `(?:struct )?TYPE name[opt] = {` initializers, where TYPE is a struct that * has ≥1 fn-pointer field. Handles both single (`= {…}`) and array @@ -469,19 +494,26 @@ export async function cFnPointerDispatchEdges( // The extraction sweep reads sequentially; the linking stages re-request // only surviving files (plus include units), so access is near-sequential // and a small LRU hits; a miss just re-reads + re-strips. - // Cache sizing is memory-budget-aware AND all-or-nothing (§7a.3 cFnPtr - // round): a partial LRU is WORSE than useless for cyclic sweeps (a first - // attempt sized ~61k against 63.8k files thrashed to a ~0% cross-sweep hit - // rate). Hold every stripped file (~24KB each measured on the Linux tree) - // only when 40% of the live memory budget covers it; otherwise keep the - // within-stage-locality 128. When the big cache declines (the kernel), the - // survival filters keep the linking stages' re-strips to a fraction of a - // sweep. Slack over files.length: non-indexed includes (.def/.inc, generated - // headers) join the working set mid-pass. Pass-scoped transient, freed on - // return. + // Bound by ACTUAL retained string bytes, not an average bytes-per-file guess: + // large generated files made the old entry-count estimate understate the + // cache by multiples and drove the main-thread resolution pass into OOM. + // UTF-16 length*2 is conservative for V8's one-byte strings and correctly + // bounds the worst case. The shared 128MB ceiling is also capped at 5% of + // currently-available memory, leaving room for the DB, fact tables and edges. + // The survival filters keep cache misses in later stages to a fraction of a + // full sweep, so bounded re-reads are preferable to an unbounded peak. const fullCacheCap = Math.ceil(files.length * 1.05) + 512; - const cacheCap = memoryBudgetBytes() * 0.5 >= fullCacheCap * 24_576 ? fullCacheCap : 128; - const rawCache = new LRUCache(Math.min(cacheCap, 4096)); + const totalTextBudget = Math.max( + 16 * 1024 * 1024, + Math.min(128 * 1024 * 1024, Math.floor(memoryBudgetBytes() * 0.05)) + ); + const rawBudget = Math.floor(totalTextBudget / 3); + const srcBudget = totalTextBudget - rawBudget; + const stringWeight = (value: string | null): number => value === null ? 1 : Math.max(64, value.length * 2); + const rawCache = new LRUCache(Math.min(fullCacheCap, 4096), { + maxWeight: rawBudget, + weightOf: stringWeight, + }); const raw = (file: string): string | null => { if (rawCache.has(file)) return rawCache.get(file)!; const t0 = prof ? Date.now() : 0; @@ -490,7 +522,10 @@ export async function cFnPointerDispatchEdges( rawCache.set(file, r); return r; }; - const srcCache = new LRUCache(cacheCap); + const srcCache = new LRUCache(fullCacheCap, { + maxWeight: srcBudget, + weightOf: stringWeight, + }); const src = (file: string): string | null => { // A cached '' (empty or unreadable file) returns '' where the miss path // returns null for unreadable — every caller falsy-checks, so the two are @@ -548,6 +583,8 @@ export async function cFnPointerDispatchEdges( const inlineTags = new Set(); /** Object-macro names with an alias-shaped value anywhere (see OBJ_ALIAS_RE). */ const aliasNames = new Set(); + /** Macro names that can affect a supported preprocessor conditional anywhere. */ + const conditionalNames = new Set(); // Parse a struct body (the text between its `{` and `}`) into ordered fields, // structure only — see RawFieldDecl for why classification is deferred. @@ -698,6 +735,7 @@ export async function cFnPointerDispatchEdges( await tick(); const rawText = raw(file); if (!rawText) continue; // unreadable or empty — the JS sweep skips these too + collectConditionalNames(rawText, conditionalNames); const tN = prof ? Date.now() : 0; const fileNodes = ctx.getNodesInFile(file); if (prof) { prof.nodesMs += Date.now() - tN; prof.nodesN++; } @@ -719,6 +757,7 @@ export async function cFnPointerDispatchEdges( await tick(); const s = src(file); if (!s) continue; + collectConditionalNames(s, conditionalNames); // Typedefs (cross-file). if (s.includes('typedef')) { @@ -991,22 +1030,50 @@ export async function cFnPointerDispatchEdges( // parsed tables is ruled out by the kernel's 6.1M `#define`s, and the // registration stage below only builds an env for files that survive its // filter or carry local includes, so most files never need one. - const fnMacroCache = new LRUCache>(256); + const macroCacheBudget = Math.min(64 * 1024 * 1024, Math.max(8 * 1024 * 1024, totalTextBudget / 2)); + const fnMacroWeight = (macros: Map): number => { + let chars = 0; + for (const [name, def] of macros) { + chars += name.length + def.expansion.length; + for (const param of def.params) chars += param.length; + } + return Math.max(64, chars * 2); + }; + const objMacroWeight = (macros: Map): number => { + let chars = 0; + for (const [name, value] of macros) chars += name.length + value.length; + return Math.max(64, chars * 2); + }; + const definedWeight = (names: Set): number => { + let chars = 0; + for (const name of names) chars += name.length; + return Math.max(64, chars * 2); + }; + const fnMacroCache = new LRUCache>(256, { + maxWeight: Math.floor(macroCacheBudget * 0.4), + weightOf: fnMacroWeight, + }); const fileFnMacros = (file: string): Map => { let m = fnMacroCache.get(file); if (!m) { m = parseFunctionMacros(src(file) ?? ''); fnMacroCache.set(file, m); } return m; }; - const objMacroCache = new LRUCache>(256); + const objMacroCache = new LRUCache>(256, { + maxWeight: Math.floor(macroCacheBudget * 0.2), + weightOf: objMacroWeight, + }); const fileObjMacros = (file: string): Map => { let m = objMacroCache.get(file); if (!m) { m = parseObjectMacros(src(file) ?? ''); objMacroCache.set(file, m); } return m; }; - const definedCache = new LRUCache>(256); + const definedCache = new LRUCache>(256, { + maxWeight: Math.floor(macroCacheBudget * 0.4), + weightOf: definedWeight, + }); const fileDefinedNames = (file: string): Set => { let d = definedCache.get(file); - if (!d) { d = parseDefinedNames(src(file) ?? ''); definedCache.set(file, d); } + if (!d) { d = parseDefinedNames(src(file) ?? '', conditionalNames); definedCache.set(file, d); } return d; }; @@ -1204,6 +1271,25 @@ export async function cFnPointerDispatchEdges( processUnit({ text, file: target, env: incEnv, objEnv: incObjEnv }); } } + // Registration-only facts and memo tables are substantial on C-heavy repos; + // release them before propagation/dispatch allocate their own working sets. + for (const facts of factsByFile.values()) { + facts.initTokens = null; + facts.arrayElems = null; + facts.inlineTypes = null; + facts.includes = NO_INCLUDES; + } + inlineTags.clear(); + aliasNames.clear(); + includeCache.clear(); + fnMacroCache.clear(); + objMacroCache.clear(); + definedCache.clear(); + seenInclude.clear(); + interned.clear(); + fnPtrTypedefs.clear(); + fnTypeTypedefs.clear(); + conditionalNames.clear(); if (prof) { prof.C = Date.now() - tPass; tPass = Date.now(); } // ---- receiver-type resolution within a function's source ---- @@ -1321,6 +1407,8 @@ export async function cFnPointerDispatchEdges( } if (!changed) break; } + propagations.length = 0; + for (const facts of factsByFile.values()) facts.dPairs = null; if (prof) { prof.D = Date.now() - tPass; tPass = Date.now(); } if (reg.size === 0 && arrayReg.size === 0) return []; diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index e50d35380e..e9039ee6e0 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -4734,7 +4734,17 @@ export async function synthesizeCallbackEdges( // fan out across its read-only workers and the per-pass wall-clock comes from // the worker; a pass that fails on a worker falls back to running on the main // thread, so a worker crash isolates to a retry instead of failing synthesis. - const passEdges: Edge[][] = new Array(SYNTH_PASSES.length).fill(NONE); + const passEdges: Array = new Array(SYNTH_PASSES.length); + const merged: Edge[] = []; + const seen = new Set(); + const mergeEdges = (edges: Edge[]): void => { + for (const e of edges) { + const key = `${e.source}>${e.target}`; + if (seen.has(key)) continue; + seen.add(key); + merged.push(e); + } + }; const markPass = (label: string, dt: number): void => { if (process.env.CODEGRAPH_SYNTH_TIMINGS && (dt > 250 || process.env.CODEGRAPH_SYNTH_TIMINGS === 'all')) { console.error(`[synth-timing] ${label}: ${dt}ms`); @@ -4742,10 +4752,12 @@ export async function synthesizeCallbackEdges( passesDone++; emit(passesDone); }; - const runPassOnMain = async (i: number): Promise => { + const runPassOnMain = async (i: number, retainForOrderedMerge: boolean): Promise => { const pass = SYNTH_PASSES[i]!; const t0 = Date.now(); - passEdges[i] = await pass.run(queries, ctx, yieldToLoop, subProgress); + const edges = await pass.run(queries, ctx, yieldToLoop, subProgress); + if (retainForOrderedMerge) passEdges[i] = edges; + else mergeEdges(edges); await yieldToLoop(); markPass(pass.name, Date.now() - t0); }; @@ -4809,7 +4821,7 @@ export async function synthesizeCallbackEdges( } // Worker-side failure (crash, OOM, unknown pass after a version // mismatch): retry this one pass on the main thread. - await runPassOnMain(i); + await runPassOnMain(i, true); } }; // One pass per worker at a time, heaviest first. Handing every pass out @@ -4833,17 +4845,19 @@ export async function synthesizeCallbackEdges( await fanOut; } else { for (const i of gatedIn) { - await runPassOnMain(i); + // Merge before starting the next pass so the just-produced array can be + // reclaimed instead of retaining every pass result until the end. + await runPassOnMain(i, false); } } - const merged: Edge[] = []; - const seen = new Set(); - for (const e of passEdges.flat()) { - const key = `${e.source}>${e.target}`; - if (seen.has(key)) continue; - seen.add(key); - merged.push(e); + markT.t = Date.now(); + if (pool && gatedIn.length > 1) { + // Worker results arrive out of order, so merge them in registry order to + // preserve which duplicate edge wins while releasing each array promptly. + for (const edges of passEdges) { + if (edges) mergeEdges(edges); + } } __mark('dedupe-merge'); // Chunked insert with yields: on the Linux kernel the merged synthesized diff --git a/src/resolution/index.ts b/src/resolution/index.ts index d6b4f23487..92f283a510 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -52,6 +52,7 @@ import { LRUCache } from './lru-cache'; import { JS_BUILT_INS } from './js-builtins'; import { builtinModules } from 'module'; import { parse as parseJsonc } from 'jsonc-parser'; +import { memoryBudgetBytes } from './memory-budget'; const NODE_BUILTINS = new Set(builtinModules); @@ -331,8 +332,15 @@ export class ReferenceResolver { // The content cache is heavier (full file text), so we give it a // smaller budget than the metadata caches. const contentLimit = Math.max(64, Math.floor(limit / 5)); + const contentBudget = Math.max( + 8 * 1024 * 1024, + Math.min(64 * 1024 * 1024, Math.floor(memoryBudgetBytes() * 0.02)) + ); this.nodeCache = new LRUCache(limit); - this.fileCache = new LRUCache(contentLimit); + this.fileCache = new LRUCache(contentLimit, { + maxWeight: Math.floor(contentBudget / 4), + weightOf: (value) => value === null ? 1 : Math.max(64, value.length * 2), + }); this.importMappingCache = new LRUCache(limit); this.reExportCache = new LRUCache(limit); this.nameCache = new LRUCache(limit); @@ -340,7 +348,12 @@ export class ReferenceResolver { this.qualifiedNameCache = new LRUCache(limit); // Split-lines arrays are heavier than content strings; refs arrive // file-ordered, so a small cache still hits nearly always. - this.fileLinesCache = new LRUCache(contentLimit); + this.fileLinesCache = new LRUCache(contentLimit, { + maxWeight: Math.floor(contentBudget * 3 / 4), + weightOf: (value) => value === null + ? 1 + : Math.max(64, value.length * 8 + value.reduce((sum, line) => sum + line.length * 2, 0)), + }); this.nodeByIdCache = new LRUCache(Math.max(limit * 4, 20_000)); this.methodMatchCache = new LRUCache(limit); diff --git a/src/resolution/lru-cache.ts b/src/resolution/lru-cache.ts index 2a597ddbe0..fdc3f7e9d9 100644 --- a/src/resolution/lru-cache.ts +++ b/src/resolution/lru-cache.ts @@ -13,13 +13,25 @@ */ export class LRUCache { private readonly max: number; + private readonly maxWeight: number | null; + private readonly weightOf: ((value: V, key: K) => number) | null; private readonly store = new Map(); + private readonly weights = new Map(); + private totalWeight = 0; - constructor(max: number) { + constructor( + max: number, + opts: { maxWeight: number; weightOf: (value: V, key: K) => number } | null = null + ) { if (!Number.isFinite(max) || max <= 0) { throw new Error(`LRUCache max must be a positive finite number, got ${max}`); } + if (opts && (!Number.isFinite(opts.maxWeight) || opts.maxWeight <= 0)) { + throw new Error(`LRUCache maxWeight must be a positive finite number, got ${opts.maxWeight}`); + } this.max = Math.floor(max); + this.maxWeight = opts ? Math.floor(opts.maxWeight) : null; + this.weightOf = opts?.weightOf ?? null; } get size(): number { @@ -45,18 +57,35 @@ export class LRUCache { set(key: K, value: V): void { if (this.store.has(key)) { + this.totalWeight -= this.weights.get(key) ?? 0; this.store.delete(key); - } else if (this.store.size >= this.max) { + this.weights.delete(key); + } + + const weight = this.weightOf ? Math.max(0, Math.ceil(this.weightOf(value, key))) : 0; + if (this.maxWeight !== null && weight > this.maxWeight) return; + + while ( + this.store.size >= this.max || + (this.maxWeight !== null && this.totalWeight + weight > this.maxWeight) + ) { // Evict the oldest entry — first key in iteration order. const oldest = this.store.keys().next().value; - if (oldest !== undefined) { - this.store.delete(oldest); - } + if (oldest === undefined) break; + this.totalWeight -= this.weights.get(oldest) ?? 0; + this.weights.delete(oldest); + this.store.delete(oldest); } this.store.set(key, value); + if (this.maxWeight !== null) { + this.weights.set(key, weight); + this.totalWeight += weight; + } } clear(): void { this.store.clear(); + this.weights.clear(); + this.totalWeight = 0; } } diff --git a/src/types.ts b/src/types.ts index f30080002f..4738dea010 100644 --- a/src/types.ts +++ b/src/types.ts @@ -418,6 +418,17 @@ export interface Subgraph { * for graph traversals that don't run the search-ranking path. */ confidence?: 'high' | 'low'; + + /** True when a traversal stopped at its node/edge safety budget. */ + truncated?: boolean; +} + +/** Safety budgets for impact traversal on high-fanout graphs. */ +export interface ImpactOptions { + /** Maximum nodes retained, including the focal node. Default: 10,000. */ + maxNodes?: number; + /** Maximum edges retained. Default: 50,000. */ + maxEdges?: number; } /**