diff --git a/CHANGELOG.md b/CHANGELOG.md index 4921d20e1..30df1d500 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - C++ explicit operator calls — `a.operator+(b)`, `p->operator+(b)`, `a.operator[](3)`, and the other symbolic forms — now produce a `calls` edge to the operator method, so an operator invoked only through the explicit syntax no longer looks uncalled in callers and impact analysis. tree-sitter parses these call sites with the operator name stranded in an error node (never as a normal member access), so the call's target was silently read as just the receiver variable; the operator name is now recovered from the error node and resolved through receiver-type inference like any other member call — a same-named operator on an unrelated class can never capture the edge. Infix uses (`a + b`, `a[i]`) need real type inference and are tracked separately. (#1247) - `codegraph init` and `codegraph index` no longer look hung after "Resolving refs" reaches 100%. The dynamic-dispatch linking that runs after resolution (callbacks, React re-renders, C function pointers, and the rest) had no progress display, so on repos where it takes a while — large C codebases especially — the bar just sat frozen at 100% until it finished. That work now shows as its own "Linking dynamic dispatch" progress phase, and the heaviest pass — C function-pointer linking — additionally reports progress within the pass, so a large C codebase advances the bar smoothly instead of parking it on one number for the bulk of the phase. - Indexing no longer prints repeated "SQLite is an experimental feature" warnings that garbled the progress display. The warning comes from Node's built-in SQLite and fired once per parsing worker; it's now suppressed on every launch path. +- `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 changelist, or the first sync after switching branches — CodeGraph gathered the pending (and retry) references for those files in a way that broke down once a single batch reached into the hundreds of thousands of references, aborting the whole sync before it could finish. Syncs of any size now complete. ## [1.4.1] - 2026-07-10 diff --git a/__tests__/db-perf.test.ts b/__tests__/db-perf.test.ts index 9be0803b2..1d116f6ff 100644 --- a/__tests__/db-perf.test.ts +++ b/__tests__/db-perf.test.ts @@ -359,3 +359,85 @@ describe('migration v6: dedup edges + add identity index on upgrade (#1034)', () fs.rmSync(dir, { recursive: true, force: true }); }); }); + +describe('unresolved-ref readers do not overflow the argument stack (large result sets)', () => { + let dir: string; + let db: DatabaseConnection; + let q: QueryBuilder; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-perf-refspread-')); + 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('getUnresolvedReferencesByFiles: one file with more pending refs than the spread limit', () => { + // Regression: the method chunks the file-PATH list under SQLite's parameter + // limit, then collected the matched rows with `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 + // pending refs, which blew V8's argument-stack limit with "Maximum call + // stack size exceeded" (the arg-spread ceiling 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); + }); + + it('getRetryableFailedReferences: one name-chunk matching more failed refs than the spread limit', () => { + // Same regression, second reader (#1240 retry path). Pass 2 chunks the NAME + // list under the parameter limit, then did `rows.push(...chunkRows)`. Each + // name stays under perNameCeiling, but a single chunk of names collectively + // matches far more failed rows than the arg-spread ceiling, so the spread + // overflowed the stack. Here 400 distinct name tails × 400 failed refs each + // (each under the 500 ceiling) all fall in one name-chunk → 160k rows. + const TAILS = 400; + const PER_TAIL = 400; // < perNameCeiling(500) so every tail survives pass 1 + q.insertNode(makeNode('n1')); + + const refs: Array<{ fromNodeId: string; referenceName: string; referenceKind: 'calls'; line: number; column: number }> = []; + for (let t = 0; t < TAILS; t++) { + for (let j = 0; j < PER_TAIL; j++) { + // A plain name (no '.'/'::') is its own name_tail, so referenceName === tail. + refs.push({ fromNodeId: 'n1', referenceName: `tail${t}`, referenceKind: 'calls', line: j + 1, column: 0 }); + } + } + q.insertUnresolvedRefsBatch(refs); + + // Park them all as status='failed' with their name_tail, so the retry reader + // (which only sees failed rows) returns them. One mark per distinct tuple + // flips all same-tuple rows. + q.markReferencesFailed( + Array.from({ length: TAILS }, (_, t) => ({ fromNodeId: 'n1', referenceName: `tail${t}`, referenceKind: 'calls' })) + ); + + const names = Array.from({ length: TAILS }, (_, t) => `tail${t}`); + let retry: ReturnType = []; + expect(() => { + retry = q.getRetryableFailedReferences(names); + }).not.toThrow(); + expect(retry.length).toBe(TAILS * PER_TAIL); + }); +}); diff --git a/src/db/queries.ts b/src/db/queries.ts index 3f6b14de9..e83281f08 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -2167,7 +2167,12 @@ export class QueryBuilder { const chunkRows = this.db .prepare(`SELECT * FROM unresolved_refs WHERE status = 'pending' AND 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 pending 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) => ({ @@ -2349,7 +2354,12 @@ export class QueryBuilder { const chunkRows = this.db .prepare(`SELECT * FROM unresolved_refs WHERE status = 'failed' AND name_tail IN (${placeholders})`) .all(...chunk) as UnresolvedRefRow[]; - rows.push(...chunkRows); + // Append without spreading — see getUnresolvedReferencesByFiles: chunking + // the name IN-list bounds the SQL params, not the row count. One chunk of + // names, each just under `perNameCeiling`, can still match >100k failed + // rows, and `rows.push(...chunkRows)` would overflow V8's argument stack + // with "Maximum call stack size exceeded". + for (const row of chunkRows) rows.push(row); } return rows.map((row) => ({