fix(kernel): guard the native walkers against stack overflow and defer deep files to wasm (#1581) - #1600
Open
colbymchenry wants to merge 1 commit into
Open
fix(kernel): guard the native walkers against stack overflow and defer deep files to wasm (#1581)#1600colbymchenry wants to merge 1 commit into
colbymchenry wants to merge 1 commit into
Conversation
…r deep files to wasm (#1581) A C/C++ (or any other kernel-routed) file with extremely deep nesting — clang's 16,384-brace `parser_overflow.c`, fuzzer corpora — parsed fine (tree-sitter is iterative) and then overflowed the native stack of the kernel's recursive walker. A native overflow is uncatchable: the parse worker is a thread of the `codegraph` process, so the SIGSEGV took the whole indexer down with no message, no partial index and no per-file fallback. Worker threads get Node's 4 MiB default stack; the 8 MiB main thread only moved the cliff (100k levels still died), so a bigger `resourceLimits.stackSizeMb` was never a fix. The walkers now guard their own recursion against the CALLING THREAD's real stack bounds (`codegraph-kernel/src/stack.rs`: glibc/musl `pthread_getattr_np`, macOS `pthread_get_stackaddr_np`, Win32 `GetCurrentThreadStackLimits`; one thread-local load + one compare per recursive entry, inserted by the `stack_guard!` macro at all 150 self-recursive / on-cycle walker functions). Within 256 KiB of the limit the walk stops descending and latches a flag; `stack::run_guarded` turns a tripped walk into the kernel's existing `defer:` routing signal, so the file takes the wasm path — whose walker catches its own JS `RangeError` per file — and lands as a partial result with a recorded parse error while the rest of the repository indexes normally. Platforms without a bounds query fall back to a fixed descent budget that is safe on any stack ≥ 2 MiB. No Worker stack bump; no new crates beyond `libc` (already in the lock file transitively). Validated: the reporter's `deep.c` inside a default 4 MiB worker goes from rc=132/139 to a clean `deferred` exit; `codegraph init` on a repo holding it exits 0 with the file recorded; 60k-deep expressions in every default-routed language survive on the main thread and in a worker; Rust unit tests drive the walkers on a 1 MiB thread; all 15 existing kernel parity suites unchanged; index wall-clock on express and redis within run-to-run noise with identical node/edge counts; Linux verified in Docker (node:22-bookworm, glibc bounds path). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LxZj6W6Y1SHXwvpT3uwJpK
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1581.
What was wrong
codegraph init/codegraph indexdied withSegmentation fault— the whole CLIprocess, not a parse worker — on a C/C++ file with very deep brace nesting (llvm's
clang/test/Parser/parser_overflow.c, 16,384 nested{). The reporter's diagnosis isexactly right: tree-sitter's parser is iterative, so the file parses fine, and then the
native kernel's recursive walker (
visit_node→visit_for_calls_and_structure→ …,one frame per AST level) overflowed the thread's stack. A native overflow can't be caught
the way a wasm abort can, and a parse worker is a thread of the
codegraphprocess, sothe SIGSEGV took the entire indexer down — no message, no per-file fallback, no partial
index.
Two things made "just give the worker a bigger stack" the wrong fix:
default 4 MiB worker (rc=132 on macOS / 139 on Linux), and a 100k-deep file kills the
8 MiB main thread too;
several recursion points with different frame sizes, so no single stack size is a
provable bound.
Meanwhile the wasm path already handles this shape gracefully: its JS walker catches its
own
RangeErrorper file and stores a partial result with aparse_error. The kerneljust needed a way to get there instead of dying.
What this does
The kernel guards its own recursion against the calling thread's real stack bounds and
defers a too-deep file to wasm — the same
defer:routing signal it already uses forfiles with parse errors, which
src/extraction/kernel/index.tstreats as "take the wasmpath for this file", silently.
codegraph-kernel/src/stack.rs: per-thread stack bounds from the OS, computed once perthread and cached — glibc/musl
pthread_getattr_np+pthread_attr_getstack, macOSpthread_get_stackaddr_np+pthread_get_stacksize_np, Win32GetCurrentThreadStackLimits(a hand-declaredkernel32extern; nowindows-sys).exhausted()is one thread-local load and one compare: true once the stack pointer iswithin a 256 KiB red zone of the limit, and it latches a flag. Where the OS can't report
bounds it falls back to a fixed 1 MiB descent budget measured from the entry stack
pointer — safe on anything from Node's 4 MiB worker default up. So the guard is exact on
the 4 MiB worker, the 8 MiB main thread, and any
resourceLimits.stackSizeMbalike.stack_guard!()(defined inlib.rs) is the first statement of every recursive walkerfunction — all 150 self-recursive or on-cycle functions across the 15 walker modules,
found by script (every cycle in the call graph, not just direct self-calls). It returns
Default::default()((),false,None,"") so an exhausted walk simply stopsdescending; a hook returning
falsesends its caller down the generic child walk, whoseown guard returns at once.
extract_fileruns the whole walk understack::run_guarded: if the flag is setafterwards the (truncated) result is discarded and replaced by
defer: nesting too deep for the native walker — wasm recovery handles it.parse-pool.ts: a comment atnew Worker(scriptPath)records why there is deliberatelyno
resourceLimits.stackSizeMbbump.libcas a direct unix dependency (already inCargo.locktransitively). No wire/ABI change.
Net effect for the reporter's repo:
deep.cgoes to the wasm path, lands asfunction fooplus a recorded parse warning, and the other 31,607 files index normally.CODEGRAPH_KERNEL=0and theexcludeworkaround are no longer needed.Tests
Rust unit tests (
cargo test, 21 passed — 7 new instack.rs): the walkers forC, C++, Rust, TypeScript and Python are driven on a 1 MiB thread (a quarter of Node's
worker default) with 30k-deep nesting and must return
defer:instead of crashing;shallow files are untouched; the latch resets between runs; the OS bounds are sane on the
main thread and describe a small thread's own stack.
__tests__/kernel-deep-nesting.test.ts(new, 8 tests — skips without a staged.node,fails under
CODEGRAPH_KERNEL_EXPECT=1if the kernel is missing, like the other kernelsuites):
— clean result or the wasm fallback's partial result, never a crash;
trips on normal code);
worker_threadsWorker throughdist/: the reporter'sdeep.cand a 60k-deep expression in every language come backdeferredwith exit 0,and a normal file still extracts natively;
codegraph initon a repo holdingdeep.c+ok.cexits 0 and records both files, with both functions.
Existing kernel suites: all 15 (
kernel-*-parity,kernel-scaffold,kernel-retry-materialize,kernel-grammar-parity) pass unchanged, 147 tests — the guardnever fires on the parity fixtures.
Reporter's probes (
one.jsfrom the issue, default 4 MiB worker, this build):deep.c→deferred, exitCode=0 (was rc=132/139);deep100k.c→deferred, exitCode=0.Main thread:
deep.c/deep100k.c→ wasm partial withParse error: Maximum call stack size exceeded; a 6,000-term binary expression and a3,000-branch
else ifchain stay on the kernel path with clean results.Perf (same
dist/, only the.nodeswapped viaCODEGRAPH_KERNEL_PATH; interleavedmain/new ×3,
codegraph init, macOS arm64):Within run-to-run noise, as expected for one TLS load + compare per recursion entry.
Linux (Docker,
node:22-bookworm, kernel built in-container,docker run --rm --init) —the reporter's platform and the glibc
pthread_getattr_npbounds path:(The pre-fix crash was reproduced on macOS — rc=132 in a default worker, rc=139 on the main thread at 100k depth — not re-run inside this container; the reporter's Linux x86_64 trace is the SIGSEGV form of the same overflow.)
Windows (Parallels ARM64 VM, MSVC 14.44,
cargo 1.97, kernel built on the VM,GetCurrentThreadStackLimitspath):(The end-to-end test is what reads the Windows index back through
node:sqlite—files=deep.c,ok.c; functionsadd,foo.)Full
npm teston this branch (macOS arm64, kernel staged): 190 files passed, 3,185 tests passed, 10 skipped, 0 failed.Clippy note:
cargo clippyon the current toolchain (1.92) reports 18 pre-existing lints(
manual_contains,unnecessary_to_owned, …) in walker code this PR only touched byinserting guard lines; none are in
stack.rs/lib.rs. Left alone to keep the diffreviewable.
🤖 Generated with Claude Code
https://claude.ai/code/session_01LxZj6W6Y1SHXwvpT3uwJpK