diff --git a/__tests__/resolution-oversized-file.test.ts b/__tests__/resolution-oversized-file.test.ts new file mode 100644 index 000000000..6c0a607c5 --- /dev/null +++ b/__tests__/resolution-oversized-file.test.ts @@ -0,0 +1,100 @@ +/** + * Resolution-side oversized-file guard. + * + * Pins the contract that reference resolution treats files larger than + * extraction's MAX_FILE_SIZE exactly like extraction does: invisible. + * Asset imports (`import video from "./intro.mp4"`, aliased or relative) + * hand the resolver a verbatim path to a file extraction never parsed — + * it can't contain resolvable symbols, but the import/re-export chase + * used to `readFileSync` it whole, decode it as UTF-8, and regex-scan + * it. On a real project a single ~240 MB video import OOM'd an 8 GB heap + * at "Resolving refs" ~65%, killing every `codegraph init`. + */ +import { describe, it, expect, beforeAll, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { CodeGraph } from '../src'; +import { initGrammars, loadAllGrammars } from '../src/extraction/grammars'; + +beforeAll(async () => { + await initGrammars(); + await loadAllGrammars(); +}); + +describe('resolution skips oversized import targets', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + tmpDir = undefined; + }); + + it('does not chase re-exports through a barrel extraction skipped for size', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-bigbarrel-')); + fs.mkdirSync(path.join(tmpDir, 'src')); + + fs.writeFileSync( + path.join(tmpDir, 'src/real.ts'), + 'export function play(): number {\n return 1;\n}\n' + ); + + // A syntactically valid barrel pushed over MAX_FILE_SIZE by padding. + // Extraction skips it (too big), so resolution must not read it either + // — chasing its re-export would resolve through a file the graph + // doesn't contain, and reading arbitrarily large targets is the OOM. + const padding = `// ${'x'.repeat(120)}\n`.repeat(9000); // ~1.1 MB + fs.writeFileSync( + path.join(tmpDir, 'src/barrel.ts'), + `export { play as video } from "./real";\n${padding}` + ); + + fs.writeFileSync( + path.join(tmpDir, 'src/app.ts'), + 'import { video } from "./barrel";\n' + + 'export function run(): number {\n' + + ' return video();\n' + + '}\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + // Sanity: the small files are in the graph, the oversized barrel is not. + const fns = cg.getNodesByKind('function'); + const play = fns.find((n) => n.name === 'play'); + const run = fns.find((n) => n.name === 'run'); + expect(play).toBeDefined(); + expect(run).toBeDefined(); + expect(cg.getNodesInFile('src/barrel.ts')).toHaveLength(0); + + // The chase must stop at the oversized barrel: no call edge may reach + // play() — resolving run()'s `video()` there requires reading + // barrel.ts's content. (The structural `contains` edge from real.ts + // itself is expected.) + const playCalls = cg.getIncomingEdges(play!.id).filter((e) => e.kind === 'calls'); + expect(playCalls).toHaveLength(0); + }); + + it('indexes a project importing a >1MB binary asset without dying', async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-bigasset-')); + fs.mkdirSync(path.join(tmpDir, 'src')); + + // Pseudo-binary garbage over MAX_FILE_SIZE. (The real-world trigger + // was a 240 MB .mp4; the guard threshold is what matters, not scale.) + fs.writeFileSync(path.join(tmpDir, 'src/intro.mp4'), Buffer.alloc(2 * 1024 * 1024, 0xfe)); + + fs.writeFileSync( + path.join(tmpDir, 'src/app.ts'), + 'import video from "./intro.mp4";\n' + + 'export function play(): string {\n' + + ' return video;\n' + + '}\n' + ); + + const cg = CodeGraph.initSync(tmpDir); + await cg.indexAll(); + + const fns = cg.getNodesByKind('function'); + expect(fns.find((n) => n.name === 'play')).toBeDefined(); + }); +}); diff --git a/src/resolution/index.ts b/src/resolution/index.ts index e956794e7..d3c086be1 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -56,6 +56,16 @@ const PHP_PROP_SHAPE = /^this->\w+\.\w+$/; * caches) when tuning for very large or very small projects. */ const DEFAULT_CACHE_LIMIT = 5_000; + +/** + * Skip resolution-side file reads above this size (bytes). Matches + * extraction's MAX_FILE_SIZE: anything larger was never parsed into the + * graph, so its content can't answer an import/re-export chase — but a + * `import intro from "./intro.mp4"` ref would otherwise pull the whole + * asset into the V8 heap and OOM the index (a single ~240 MB binary is + * enough to kill an 8 GB heap once decoded and regex-scanned). + */ +const MAX_RESOLUTION_FILE_SIZE = 1024 * 1024; function resolveCacheLimit(): number { const raw = process.env.CODEGRAPH_RESOLVER_CACHE_SIZE; if (!raw) return DEFAULT_CACHE_LIMIT; @@ -381,6 +391,18 @@ export class ReferenceResolver { } const fullPath = path.join(this.projectRoot, filePath); try { + // Mirror extraction's MAX_FILE_SIZE guard. Resolution follows import + // targets verbatim, so `import video from "./intro.mp4"` (or any large + // asset/bundle an alias resolves to) lands here — extraction never + // indexed a file this large, so it can't contain resolvable symbols or + // re-exports, but decoding hundreds of MB as UTF-8 and regex-scanning + // it for re-exports OOMs the whole index at "Resolving refs". + const stats = fs.statSync(fullPath); + if (stats.size > MAX_RESOLUTION_FILE_SIZE) { + logDebug('Skipping oversized file for resolution', { filePath, size: stats.size }); + this.fileCache.set(filePath, null); + return null; + } const content = fs.readFileSync(fullPath, 'utf-8'); this.fileCache.set(filePath, content); return content;