1
0

kernel-deep-nesting.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. /**
  2. * Deep-nesting safety for the native kernel (#1581).
  3. *
  4. * The kernel's per-language walkers recurse once per AST level. tree-sitter's
  5. * parser is iterative, so a pathologically nested file — clang's
  6. * `clang/test/Parser/parser_overflow.c` nests 16,384 `{`; fuzzer corpora go
  7. * deeper — parses fine and then overflowed the WALKER's native stack. A native
  8. * overflow is uncatchable: the parse worker is a thread of the `codegraph`
  9. * process, so the SIGSEGV killed the whole indexer with no message, no partial
  10. * index, no per-file fallback. Worker threads get Node's 4 MiB default stack;
  11. * the 8 MiB main thread only moved the cliff (100k levels still died).
  12. *
  13. * The kernel now guards its recursion against the calling thread's real stack
  14. * bounds (codegraph-kernel/src/stack.rs) and turns an imminent overflow into
  15. * its `defer:` routing signal, so the file takes the wasm path — whose walker
  16. * catches its own JS `RangeError` per file and stores a partial result with a
  17. * `parse_error`. These tests pin that contract on every default-routed
  18. * language, on the main thread AND inside a default-sized worker, and
  19. * end-to-end through the built CLI.
  20. *
  21. * Like the other kernel suites: skipped without a staged .node; CI that
  22. * builds the kernel sets CODEGRAPH_KERNEL_EXPECT=1 so a missing binary FAILS.
  23. */
  24. import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest';
  25. import * as fs from 'fs';
  26. import * as os from 'os';
  27. import * as path from 'path';
  28. import { execFileSync } from 'child_process';
  29. import { Worker } from 'worker_threads';
  30. import { extractFromSource } from '../src/extraction';
  31. import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars';
  32. import { kernelRoutes, resetKernelForTests } from '../src/extraction/kernel';
  33. import type { Language } from '../src/types';
  34. const REPO = path.resolve(__dirname, '..');
  35. const KERNEL_PATH = path.join(
  36. REPO,
  37. 'codegraph-kernel',
  38. 'prebuilds',
  39. `${process.platform}-${process.arch}`,
  40. 'codegraph-kernel.node'
  41. );
  42. const kernelBuilt = fs.existsSync(KERNEL_PATH);
  43. const expectKernel = process.env.CODEGRAPH_KERNEL_EXPECT === '1';
  44. const BIN = path.join(REPO, 'dist', 'bin', 'codegraph.js');
  45. const DIST_KERNEL = path.join(REPO, 'dist', 'extraction', 'kernel');
  46. const distBuilt = fs.existsSync(BIN) && fs.existsSync(path.join(DIST_KERNEL, 'index.js'));
  47. /** Deep enough to overflow an 8 MiB main-thread stack on every walker. */
  48. const PARENS_DEPTH = 60_000;
  49. /** The reporter's exact shape: clang's parser_overflow.c nests 16,384 `{`. */
  50. const BRACES_DEPTH = 16_384;
  51. const CANDIDATES: Language[] = [
  52. 'typescript', 'tsx', 'javascript', 'jsx', 'java', 'python', 'go', 'c', 'cpp',
  53. 'rust', 'csharp', 'ruby', 'php', 'swift', 'kotlin', 'r', 'lua', 'luau', 'scala', 'dart',
  54. ];
  55. const EXT: Record<string, string> = {
  56. typescript: 'ts', tsx: 'tsx', javascript: 'js', jsx: 'jsx', java: 'java', python: 'py',
  57. go: 'go', c: 'c', cpp: 'cpp', rust: 'rs', csharp: 'cs', ruby: 'rb', php: 'php',
  58. swift: 'swift', kotlin: 'kt', r: 'R', lua: 'lua', luau: 'luau', scala: 'scala', dart: 'dart',
  59. };
  60. /** A function `f` whose body is a `depth`-deep parenthesized expression. */
  61. function deepParens(language: Language, depth: number): string {
  62. const open = '('.repeat(depth);
  63. const close = ')'.repeat(depth);
  64. switch (language) {
  65. case 'typescript': case 'tsx': case 'javascript': case 'jsx':
  66. return `function f() { return ${open}1${close}; }\n`;
  67. case 'java':
  68. return `class A {\n int f() { return ${open}1${close}; }\n}\n`;
  69. case 'python':
  70. return `def f():\n return ${open}1${close}\n`;
  71. case 'go':
  72. return `package p\n\nfunc f() int { return ${open}1${close} }\n`;
  73. case 'c':
  74. return `int f(void) { return ${open}1${close}; }\n`;
  75. case 'cpp':
  76. return `int f() { return ${open}1${close}; }\n`;
  77. case 'rust':
  78. return `fn f() -> i32 { ${open}1${close} }\n`;
  79. case 'csharp':
  80. return `class A {\n int f() { return ${open}1${close}; }\n}\n`;
  81. case 'ruby':
  82. return `def f\n ${open}1${close}\nend\n`;
  83. case 'php':
  84. return `<?php\nfunction f() { return ${open}1${close}; }\n`;
  85. case 'swift':
  86. return `func f() -> Int { return ${open}1${close} }\n`;
  87. case 'kotlin':
  88. return `fun f(): Int { return ${open}1${close} }\n`;
  89. case 'r':
  90. return `f <- function() {\n ${open}1${close}\n}\n`;
  91. case 'lua': case 'luau':
  92. return `local function f()\n return ${open}1${close}\nend\n`;
  93. case 'scala':
  94. return `object A {\n def f(): Int = ${open}1${close}\n}\n`;
  95. case 'dart':
  96. return `int f() { return ${open}1${close}; }\n`;
  97. default:
  98. throw new Error(`no deep fixture for ${language}`);
  99. }
  100. }
  101. /** The reporter's repro: a C function body of `depth` nested blocks. */
  102. function deepBraces(depth: number): string {
  103. return `void foo(void) {\n${'{'.repeat(depth)}${'}'.repeat(depth)}\n}\n`;
  104. }
  105. const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS', 'CODEGRAPH_KERNEL_PATH'] as const;
  106. let savedEnv: Record<string, string | undefined>;
  107. describe.skipIf(!kernelBuilt)('kernel deep-nesting guard (#1581)', () => {
  108. let routed: Language[] = [];
  109. beforeAll(async () => {
  110. resetKernelForTests();
  111. routed = CANDIDATES.filter((l) => kernelRoutes(l));
  112. expect(routed.length).toBeGreaterThan(0);
  113. await initGrammars();
  114. await loadGrammarsForLanguages(routed);
  115. });
  116. beforeEach(() => {
  117. savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
  118. resetKernelForTests();
  119. });
  120. afterEach(() => {
  121. for (const k of ENV_KEYS) {
  122. if (savedEnv[k] === undefined) delete process.env[k];
  123. else process.env[k] = savedEnv[k];
  124. }
  125. resetKernelForTests();
  126. });
  127. it('every default-routed language survives a 60k-deep expression on the main thread', () => {
  128. const failures: string[] = [];
  129. for (const language of routed) {
  130. const file = `deep.${EXT[language]}`;
  131. const source = deepParens(language, PARENS_DEPTH);
  132. // The ONLY acceptable outcomes: a clean result (the thread's stack was
  133. // big enough for the walk), or the wasm fallback's partial result with
  134. // its parse_error. A native overflow would have killed this process.
  135. const result = extractFromSource(file, source, language);
  136. const fn = result.nodes.find((n) => n.name === 'f' && (n.kind === 'function' || n.kind === 'method'));
  137. // R's wasm walker mints `f <- function()` only after walking the
  138. // assignment's value, so its partial result for a file this deep holds
  139. // just the file node — the same shape main's wasm-only path produces
  140. // (verified with CODEGRAPH_KERNEL=0). Pre-existing and out of scope
  141. // here; what this test pins for R is that the process survives.
  142. if (!fn && language !== 'r') failures.push(`${language}: no function node 'f' (nodes=${result.nodes.map((n) => `${n.kind}:${n.name}`).join(',')})`);
  143. for (const e of result.errors) {
  144. if (!/Maximum call stack|parse_error|Parse error/.test(`${e.code} ${e.message}`)) {
  145. failures.push(`${language}: unexpected error ${e.message}`);
  146. }
  147. }
  148. }
  149. expect(failures).toEqual([]);
  150. }, 120_000);
  151. it("the reporter's 16,384-brace C file is indexed (partial) instead of killing the process", () => {
  152. const result = extractFromSource('deep.c', deepBraces(BRACES_DEPTH), 'c');
  153. expect(result.nodes.some((n) => n.kind === 'function' && n.name === 'foo')).toBe(true);
  154. }, 60_000);
  155. it('shallow files still take the kernel path (the guard never trips on normal code)', () => {
  156. // Sanity for the perf-neutral claim: a 200-deep expression is far inside
  157. // any thread's stack, so it must come back clean with no parse_error.
  158. for (const language of routed) {
  159. const result = extractFromSource(`ok.${EXT[language]}`, deepParens(language, 200), language);
  160. expect(result.errors, language).toEqual([]);
  161. expect(result.nodes.some((n) => n.name === 'f'), language).toBe(true);
  162. }
  163. }, 60_000);
  164. describe.skipIf(!distBuilt)('inside a default-sized (4 MiB) parse worker, through dist/', () => {
  165. let tmp: string;
  166. beforeEach(() => {
  167. tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-deep-'));
  168. });
  169. afterEach(() => {
  170. fs.rmSync(tmp, { recursive: true, force: true });
  171. });
  172. /**
  173. * Run the kernel's raw extraction for `file` inside a Worker with Node's
  174. * DEFAULT resourceLimits — exactly how ParseWorkerPool runs it. Resolves
  175. * with the worker's exit code and what it reported; a native overflow
  176. * would SIGSEGV/SIGILL this whole vitest process instead.
  177. */
  178. function runInWorker(file: string, source: string, language: Language): Promise<{ exitCode: number; outcome: string }> {
  179. const script = path.join(tmp, 'worker.cjs');
  180. fs.writeFileSync(
  181. script,
  182. [
  183. `const { parentPort, workerData } = require('worker_threads');`,
  184. `const { tryKernelExtractRaw } = require(${JSON.stringify(DIST_KERNEL)});`,
  185. `const raw = tryKernelExtractRaw(workerData.file, workerData.source, workerData.language);`,
  186. `parentPort.postMessage(raw ? 'kernel:' + raw.counts.nodes : 'deferred');`,
  187. ].join('\n')
  188. );
  189. return new Promise((resolve, reject) => {
  190. let outcome = 'no message';
  191. const w = new Worker(script, { workerData: { file, source, language } });
  192. w.on('message', (m: string) => { outcome = m; });
  193. w.on('error', reject);
  194. w.on('exit', (exitCode) => resolve({ exitCode, outcome }));
  195. });
  196. }
  197. it("defers the reporter's deep.c instead of crashing the worker", async () => {
  198. const r = await runInWorker('deep.c', deepBraces(BRACES_DEPTH), 'c');
  199. expect(r.exitCode).toBe(0);
  200. expect(r.outcome).toBe('deferred');
  201. }, 60_000);
  202. it('defers a 60k-deep expression in every default-routed language', async () => {
  203. for (const language of routed) {
  204. const r = await runInWorker(`deep.${EXT[language]}`, deepParens(language, PARENS_DEPTH), language);
  205. expect(r.exitCode, language).toBe(0);
  206. // Either the guard tripped (deferred) or the walk fit — never a crash.
  207. expect(['deferred', 'kernel'].some((p) => r.outcome.startsWith(p)), `${language}: ${r.outcome}`).toBe(true);
  208. }
  209. }, 180_000);
  210. it('still extracts a normal file natively in the worker', async () => {
  211. const r = await runInWorker('ok.c', 'int add(int a, int b) { return a + b; }\n', 'c');
  212. expect(r.exitCode).toBe(0);
  213. expect(r.outcome).toMatch(/^kernel:/);
  214. }, 30_000);
  215. });
  216. describe.skipIf(!distBuilt)('end-to-end: codegraph init on a repo holding the deep file', () => {
  217. let tmp: string;
  218. beforeEach(() => {
  219. tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-deep-cli-'));
  220. });
  221. afterEach(() => {
  222. fs.rmSync(tmp, { recursive: true, force: true });
  223. });
  224. it('exits 0 and records deep.c alongside the normal files', () => {
  225. fs.writeFileSync(path.join(tmp, 'deep.c'), deepBraces(BRACES_DEPTH));
  226. fs.writeFileSync(path.join(tmp, 'ok.c'), 'int add(int a, int b) { return a + b; }\n');
  227. execFileSync(process.execPath, [BIN, 'init', '.'], {
  228. cwd: tmp,
  229. encoding: 'utf-8',
  230. stdio: ['ignore', 'pipe', 'pipe'],
  231. timeout: 120_000,
  232. env: {
  233. ...process.env,
  234. CODEGRAPH_NO_DAEMON: '1',
  235. CODEGRAPH_WASM_RELAUNCHED: '1',
  236. CODEGRAPH_TELEMETRY: '0',
  237. DO_NOT_TRACK: '1',
  238. CODEGRAPH_NO_PROMPT_HOOK: '1',
  239. },
  240. });
  241. const { DatabaseSync } = require('node:sqlite') as typeof import('node:sqlite');
  242. const db = new DatabaseSync(path.join(tmp, '.codegraph', 'codegraph.db'), { readOnly: true });
  243. try {
  244. const files = (db.prepare('SELECT path FROM files ORDER BY path').all() as Array<{ path: string }>).map((r) => r.path);
  245. expect(files).toEqual(['deep.c', 'ok.c']);
  246. const fns = (db.prepare("SELECT name FROM nodes WHERE kind = 'function' ORDER BY name").all() as Array<{ name: string }>).map((r) => r.name);
  247. expect(fns).toEqual(['add', 'foo']);
  248. } finally {
  249. db.close();
  250. }
  251. }, 180_000);
  252. });
  253. });
  254. describe.skipIf(!expectKernel)('kernel presence (CODEGRAPH_KERNEL_EXPECT=1)', () => {
  255. it('the staged .node exists so the deep-nesting suite actually ran', () => {
  256. expect(kernelBuilt).toBe(true);
  257. });
  258. });