From 32a651a8e66735111b8aaf5e25d36f46bafb62f4 Mon Sep 17 00:00:00 2001 From: ianmage <8745151+ianmage@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:31:46 +0800 Subject: [PATCH] fix(sync): avoid argument-stack overflow when gathering unresolved refs getUnresolvedReferencesByFiles chunked the file-path IN-list under SQLite's parameter limit, but then spread each chunk's matched rows into rows.push(...chunkRows). On a large sync (fresh index, big Perforce/git changelist, first sync after switching branches) one chunk can match hundreds of thousands of unresolved_refs rows, blowing V8's argument-stack limit with "Maximum call stack size exceeded" and aborting the whole sync before it finishes. Chunking bounds the SQL params, not the returned row count, so append rows in a loop instead of spreading. Adds a regression test that inserts 130k refs sharing one file_path and asserts no throw. --- CHANGELOG.md | 1 + __tests__/db-perf.test.ts | 47 +++++++++++++++++++++++++++++++++++++++ src/db/queries.ts | 7 +++++- 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2201da71e..d0544d2c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - A stuck git command can no longer hang CodeGraph indefinitely. The git checks behind worktree detection and git-hook setup, and the installer's optional `npm install -g` step, now time out and fail gracefully instead of blocking forever — this matters most for the background MCP server, where an unbounded git hang (network filesystems, a wedged fsmonitor) could previously freeze it long enough for the safety watchdog to kill it. Thanks @inth3shadows for the report. (#1139) - The context hook's new plain-words matching works immediately on projects indexed by an older CodeGraph version. The word lookup it relies on is built at index time, so a project indexed before the upgrade had an empty one, and the hook would silently find nothing until something else happened to refresh the index; the hook now fills it in on first use (a one-time step — normally the background MCP server's startup catch-up gets there first). Thanks @inth3shadows for the report. (#1142) - Several accuracy fixes to the plain-words matching: a renamed symbol (for example a NestJS route after its module prefix is applied) stays findable under its new name (#1141); a word that only appears in your code as an import statement's package name is no longer presented as a matched symbol (#1144); plural words no longer generate garbled lookup keys ("services" no longer also looks up "servic") (#1145); and a name matching both the singular and plural of one word can no longer squeeze out a genuine two-word match (#1146). Thanks @inth3shadows for the reports. +- `codegraph sync` no longer fails with "Maximum call stack size exceeded" on large projects. When a sync re-checked a big set of files at once — a fresh index, a large Perforce or git changelist, or the first sync after switching branches — CodeGraph gathered the pending references for those files in a way that broke down once the count reached into the hundreds of thousands, aborting the whole sync before it could finish. Syncs of any size now complete. Thanks for the report. ## [1.2.0] - 2026-07-02 diff --git a/__tests__/db-perf.test.ts b/__tests__/db-perf.test.ts index 181ccc77c..8c69aa895 100644 --- a/__tests__/db-perf.test.ts +++ b/__tests__/db-perf.test.ts @@ -142,6 +142,53 @@ describe('deleteResolvedReferences (chunking)', () => { }); }); +describe('getUnresolvedReferencesByFiles (large result set)', () => { + let dir: string; + let db: DatabaseConnection; + let q: QueryBuilder; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-perf-unresbyfiles-')); + db = DatabaseConnection.initialize(path.join(dir, 'test.db')); + q = new QueryBuilder(db.getDb()); + }); + + afterEach(() => { + db.close(); + if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('does not overflow the stack when one file has more refs than the spread limit', () => { + // Regression: the method chunks the file-PATH list under SQLite's parameter + // limit, but then did `rows.push(...chunkRows)` — spreading every matched + // row as a separate call argument. On a large `codegraph sync` a single + // chunk of files matches hundreds of thousands of unresolved_refs rows, + // which blew V8's argument-stack limit with "Maximum call stack size + // exceeded" (the arg-spread threshold is ~125k). All refs here share ONE + // file_path so a single chunk returns the whole set. from_node_id has a FK + // to nodes, so a backing node must exist first. + const COUNT = 130_000; // just past the arg-spread ceiling + q.insertNode(makeNode('n1')); + q.insertUnresolvedRefsBatch( + Array.from({ length: COUNT }, (_, i) => ({ + fromNodeId: 'n1', + referenceName: `ref${i}`, + referenceKind: 'calls' as const, + line: i + 1, + column: 0, + filePath: 'big.ts', + language: 'typescript' as const, + })) + ); + + let refs: ReturnType = []; + expect(() => { + refs = q.getUnresolvedReferencesByFiles(['big.ts']); + }).not.toThrow(); + expect(refs.length).toBe(COUNT); + }); +}); + describe('insertNode cache invalidation', () => { let dir: string; let db: DatabaseConnection; diff --git a/src/db/queries.ts b/src/db/queries.ts index 0cdc93a18..3913e4b18 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -1856,7 +1856,12 @@ export class QueryBuilder { const chunkRows = this.db .prepare(`SELECT * FROM unresolved_refs WHERE file_path IN (${placeholders})`) .all(...chunk) as UnresolvedRefRow[]; - rows.push(...chunkRows); + // Append without spreading: `rows.push(...chunkRows)` passes every row as a + // separate argument, and a 500-file chunk of a large repo matches tens of + // thousands of refs — enough to blow V8's argument-stack limit with + // "Maximum call stack size exceeded" on `codegraph sync`. Chunking the + // file-path IN-list bounds the SQL params, NOT the returned row count. + for (const row of chunkRows) rows.push(row); } return rows.map((row) => ({