Skip to content

fix(generic): free web-tree-sitter parsers and trees so large builds stop aborting - #340

Open
csm-kb wants to merge 4 commits into
trailhq:mainfrom
csm-kb:fix/wasm-parser-lifetime
Open

csm-kb wants to merge 4 commits into
trailhq:mainfrom
csm-kb:fix/wasm-parser-lifetime

Conversation

@csm-kb

@csm-kb csm-kb commented Sep 10, 2026 •

Copy link
Copy Markdown

Was working with graft on a large codebase (~65k files) to stress-test, noticed some failure doing so -> attempted a fix pass to move it towards functional. Feel free to leave inputs or modify/reject!

I have a subsequent PR ( #548 ) that improves the graft build memory tolerance + performance using a streaming worker pool (measured 2.2x speedup on that codebase) that I can open afterwards.


Problem

extractGeneric and parseWasm created a Parser per file and never deleted it or the Tree. Both live in the WASM heap (2 GB cap, no finalizer), so a 65k-file C repo aborted after ~12k files and every subsequent parse failed with Aborted() after a few minutes.

Solution

Reuse one parser per grammar and delete every tree. After a real abort, fail every later parse with one message instead of one per file. parseWasm now returns the tree for the container tier to free, and rethrows instead of caching a symbol-less file as a success.

@trailhq-graft

trailhq-graft Bot commented Sep 10, 2026 •

Copy link
Copy Markdown

🌱 graft blast radius

2 areas changed → 7 areas can be affected. 27 dependent symbols, depth 2.
Tests: 1 area updated its tests.
Tag: @anirudhkumar-nanonets — 7 of 9 areas · @shhdwi — 6 of 9 areas · @Frankie-Xu — Build Context

flowchart TB
  A0(("Build And Review Pipeline<br/>13 symbols"))
  A1(("Workspace Graph Operations<br/>7 symbols"))
  A2(("Engine Core<br/>2 symbols"))
  A3(("Build Context<br/>2 symbols"))
  A4(("Viewer Build Script<br/>1 symbol"))
  AX(("2 smaller areas<br/>2 symbols"))
  classDef reached fill:#D9EDF3,stroke:#3AA7C9,stroke-width:1.5px,color:#0E313C;
  class A0,A1,A2,A3,A4 reached;
  classDef tail fill:#EEF2F3,stroke:#9AA4A9,stroke-width:1px,color:#3A4247;
  class AX tail;
Loading
Can be affected Symbols Nearest hop Reached from
Build And Review Pipeline 13 src/app/brain-build-process.ts:L105-L110 settle — calls, depth 1 Graph Construction, test/generic-extract.test.ts
Workspace Graph Operations 7 src/graph/check.ts:L58-L164 checkGraph — calls, depth 1 Graph Construction, test/generic-extract.test.ts
Engine Core 2 src/engine.ts:L91-L101 graph — calls, depth 1 Graph Construction
Build Context 2 src/context/build.ts:L452-L467 mapWithConcurrency — calls, depth 1 test/generic-extract.test.ts
Viewer Build Script 1 scripts/build-viewer.mjs:L1-L45 build-viewer.mjs — calls, depth 2 Graph Construction
MCP Tools 1 src/mcp/tools.ts:L216-L244 callTool — calls, depth 2 Graph Construction
1 smaller area 1 Claude Sync Execution see below
Who knows this code — 4 people across 9 areas
Area Who knows it
Graph Construction · changed @anirudhkumar-nanonets — 17 commits, last 2mo ago · @shhdwi — 11 commits, last 2mo ago
CLI · changed @anirudhkumar-nanonets — 52 commits, last 5d ago · @shhdwi — 23 commits, last 2mo ago
Build And Review Pipeline · affected @anirudhkumar-nanonets — 15 commits, last 6d ago
Workspace Graph Operations · affected @anirudhkumar-nanonets — 16 commits, last 1mo ago · @shhdwi — 8 commits, last 2mo ago
Engine Core · affected @anirudhkumar-nanonets — 16 commits, last 2mo ago
Build Context · affected @anirudhkumar-nanonets — 4 commits, last 2mo ago · @Frankie-Xu — 2 commits, last 1mo ago
Viewer Build Script · affected @shhdwi — 2 commits, last 2mo ago
MCP Tools · affected @shhdwi — 14 commits, last 2mo ago · @anirudhkumar-nanonets — 7 commits, last 1mo ago
…1 further area

Ownership is git history over each area's own files, weighted towards recent work (120-day half-life). Merge commits and bots are dropped, and you are dropped from your own PR. A name with no @ has no GitHub handle in its commit email — tag them by hand, or add a .mailmap entry. A suggestion from history, not a CODEOWNERS rule.

All 27 dependent symbols, grouped by area

Build And Review Pipeline — 13 symbols in 8 files

  • src/app/brain-build-process.ts:L105-L110 — settle (calls, depth 1)
    105: const settle = (fn: () => void): void => {
  • src/app/brain-build.ts:L251-L358 — readRepository (calls, depth 1)
    282: await buildGraph(checkout.dir, { graphOnly: true });
  • src/app/history.ts:L164-L170 — worker (calls, depth 1)
    168: out[i] = await fn(items[i]!, i);
  • src/app/review-process.ts:L206-L211 — settle (calls, depth 1)
    206: const settle = (fn: () => void): void => {
  • src/app/review.ts:L45-L99 — reviewPullRequest (calls, depth 1)
    55: await buildGraph(checkout.dir);
  • src/app/brain-build-process.ts:L88-L148 — runInChild (calls, depth 2)
    105: const settle = (fn: () => void): void => {
  • src/app/brain-build-worker.ts:L1-L83 — brain-build-worker.ts (calls, depth 2)
    9: * Everything expensive happens here — the clone, the tree-sitter parse, the
  • src/app/brain-build-worker.ts:L29-L32 — DoneMessage (references, depth 2)
  • src/app/brain-build.ts:L237-L239 — buildRepoIntoBrain (calls, depth 2)
  • src/app/review-process.ts:L179-L183 — childReviewer (references, depth 2)
  • src/app/review-process.ts:L185-L265 — runInChild (calls, depth 2)
    206: const settle = (fn: () => void): void => {
  • src/app/review-worker.ts:L67-L87 — run (calls, depth 2)
  • src/app/server.ts:L34-L46 — AppSeams (references, depth 2)

Workspace Graph Operations — 7 symbols in 5 files

  • src/graph/check.ts:L58-L164 — checkGraph (calls, depth 1)
    90: await warmGenericGrammars(
  • src/graph/enrich.ts:L226-L241 — mapWithConcurrency (calls, depth 1)
    229: fn: (item: T, index: number) => Promise<R>,
  • src/graph/refresh.ts:L150-L227 — ensureFreshGraph (calls, depth 1)
    216: await buildGraph(dir, { contextDir: opts.contextDir, graphOnly: true, onlyDirs });
  • src/graph/enrich.ts:L74-L223 — enrichGraph (calls, depth 2)
    94: const was = prior.get(node.id);
  • src/graph/refresh.ts:L235-L261 — ensureFreshChildren (calls, depth 2)
  • src/graph/workspace-cli.ts:L49-L70 — buildChild (calls, depth 2)
  • src/graph/workspace.ts:L630-L655 — federateCheck (calls, depth 2)

Engine Core — 2 symbols in 1 file

  • src/engine.ts:L91-L101 — graph (calls, depth 1)
    91: graph(dir: string, opts: GraphRunOptions = {}): Promise<GraphBuildResult> {
  • src/engine.ts:L82-L84 — checkGraph (calls, depth 2)

Build Context — 2 symbols in 1 file

  • src/context/build.ts:L452-L467 — mapWithConcurrency (calls, depth 1)
    455: fn: (item: T, index: number) => Promise<R>,
  • src/context/build.ts:L146-L374 — buildContext (calls, depth 2)
    258: else delete cache.synth[key];

Viewer Build Script — 1 symbol in 1 file

  • scripts/build-viewer.mjs:L1-L45 — build-viewer.mjs (calls, depth 2)
    3: * assets). Runs as part of `npm run build`; the bundle ships in the package

MCP Tools — 1 symbol in 1 file

  • src/mcp/tools.ts:L216-L244 — callTool (calls, depth 2)

Claude Sync Execution — 1 symbol in 1 file

  • src/claude/sync-run.ts:L19-L33 — runSync (calls, depth 2)
Test signal per changed area — 1 ✓ · 1 –

Reached = a node under a test path has a resolved edge into the changed symbol. It undercounts anything called indirectly — through a CLI, a spawned process or a dynamic import — so read a low ratio as “look here”, never as a coverage gate.

  • ✓ Graph Construction — 9 of 17 reached · 2 test files changed here: test/container-extract.test.ts, test/generic-extract.test.ts
    • not reached: notePoison, poisonedError, mkDef, forEachNamedNode, extractPhpUses, extractUses, extractIncludes, walkExtract
  • – CLI — no function, method or class changed here
35 test suites also reference this code

47 symbols, kept out of the diagram and the table so they cannot crowd out the areas a reviewer has to look at.

  • test/ask-index.test.ts
  • test/ask.test.ts
  • test/claude-hooks.test.ts
  • test/context-only-dir.test.ts
  • test/context.test.ts
  • test/covers.test.ts
  • test/generic-node24-probe.ts
  • test/graph-go.test.ts
  • test/graph-incremental.test.ts
  • test/graph-invariants.test.ts
  • test/graph-java.test.ts
  • test/graph-languages.test.ts
  • test/graph-php.test.ts
  • test/graph-posix-paths.test.ts
  • test/graph-python.test.ts
  • test/graph-r-classes.test.ts
  • test/graph-r-phase3.test.ts
  • test/graph-r-phase4.test.ts
  • test/graph-r-phase5.test.ts
  • test/graph-r.test.ts
  • …15 more

⚠️ 1 changed file not in the graph (CHANGELOG.md) — no parser claims the extension, or the index predates the file.

graft blast · origin/main...HEAD · depth 2 · 7 changed files

Open the interactive graph → — click an area to see its dependent symbols at file:line.

github-actions Bot added a commit that referenced this pull request Sep 10, 2026
@pavelsem

Copy link
Copy Markdown

Confirming this on a second language, and offering a measurement.

Env: graft 0.18.0, Node v24.9.0, Windows 11. Private C# monorepo, 5361 parseable files. Aborted() first appears at file ~4843 of 5361, and all 519 remaining .cs files then fail — the same cascade you describe. Each failing file parses fine in isolation, and the 662-file subtree they sit in parses 662/662 on its own, so it is aggregate growth rather than content.

Your diagnosis reproduces exactly. I arrived at generic.js independently: there is not one .delete() call in any of the 26 dist/graph/*.js modules. C# has no native binding in 0.18.0 — it loads tree-sitter-c_sharp.wasm — so it runs entirely on the leaking WASM path.

The extractGeneric half looks separable from the rest of this PR. parseWasm genuinely needs the signature change, since it returns tree.rootNode and callers cannot free the tree. But in extractGeneric the tree is function-local: last used at line 274, returned at 275. Pooling one parser per grammar and deleting the tree before the return is +8/−3:

+const parserPool = new Map();
+function pooledParser(langName, language) {
+    let p = parserPool.get(langName);
+    if (!p) { p = new tsMod.Parser(); p.setLanguage(language); parserPool.set(langName, p); }
+    return p;
+}
 export function extractGeneric(rel, source, langName) {
@@
     let tree;
     try {
-        const parser = new tsMod.Parser();
-        parser.setLanguage(entry.language);
-        tree = parser.parse((i) => source.slice(i, i + PARSE_CHUNK));
+        tree = pooledParser(langName, entry.language).parse((i) => source.slice(i, i + PARSE_CHUNK));
     }
@@
     else if (langName === "php")
         extractPhpUses(tree.rootNode, rel, rawEdges);
+    tree.delete();
     return { nodes, rawEdges };
 }

Results on the repo above, same commit, --no-reuse both times:

stock 0.18.0 patched
parse failures 519 0
files indexed 4842 / 5361 5361 / 5361
nodes 46 406 54 733
edges 11 765 18 318

Edges recover +56% against +18% for nodes — the dropped files took every edge crossing into them with them. This path is shared by every WASM-parsed language, so it is not C#-specific.

Two things it deliberately does not do, both of which your PR does: it leaves parseWasm alone, and it has no post-abort short-circuit, so if an abort still happened the pooled parser would be reused in a dead state. Offered only in case a smaller conflict-free slice is useful while this PR has conflicts against main; the parseWasm rework here is the more complete fix.

Caveat on pooling: it assumes single-threaded parsing, so it would need revisiting alongside the streaming worker pool you mention.

@csm-kb

csm-kb commented Oct 4, 2026 •

Copy link
Copy Markdown
Author

Confirming this on a second language, and offering a measurement.

cheers for this! apologies for the delay, let me get this updated and tuned as I found a C++ repo case where I run into a parse failed — Maximum call stack size exceeded

going to use the Linux kernel as the test case, as I found current graft fails on that codebase... it's a powerful test scenario 😎

csm-kb added 4 commits October 4, 2026 11:15
…ed runtime

extractGeneric and parseWasm created a Parser per file and never deleted it
or the Tree. Both live in the WASM heap (2 GB cap, no finalizer), so a
65k-file C repo aborted after ~12k files and every later parse failed with
Aborted(). Reuse one parser per grammar and delete every tree. After a real
abort, fail every later parse with one message instead of one per file.
parseWasm now returns the tree for the container tier to free, and rethrows
instead of caching a symbol-less file as a success.
A cached error was replayed until the file's bytes changed, so one abort
pinned 52k files as failed. Skip the cache hit for an errored entry. The
fingerprint keeps its hash, so a refresh does not rebuild just to retry it.
A C++ header misparsed by the C grammar can nest one level per initializer element; the recursive walks overflowed the call stack.
@csm-kb
csm-kb force-pushed the fix/wasm-parser-lifetime branch from 63ab0c7 to 487513a Compare October 4, 2026 17:26
github-actions Bot added a commit that referenced this pull request Oct 4, 2026
@csm-kb

csm-kb commented Oct 4, 2026 •

Copy link
Copy Markdown
Author

rebased on main (0.21.1); the CHANGELOG conflict is gone + suite passes

I also added the Maximum call stack size exceeded fix, since it's in the same extractor -- cross-checked AI summary:

  • The C grammar parses .h files, and a C++ header with a large brace initializer (std::array<uint8, 9318> data = { 0x.., ... };) misparses into right-nested comma_expression nodes, one level per element. On the two headers I hit, the trees were 9,322 and 16,514 levels deep. The recursive visit closures in generic.ts recursed once per level and overflowed V8's stack. The four walkers (extractIncludes, extractUses, extractPhpUses, walkExtract) now share one explicit-stack pre-order walk that visits nodes in the same order, so output is unchanged. A test builds a 20,000-element initializer and fails with RangeError on the old code.

@pavelsem on your caveat about pooling: the pool in the follow-up PR ( #548 ) forks child processes, so each child gets its own module state and its own parser per grammar + a pooled parser is never shared across workers. agreed that parseWasm was the part that needed the signature change, as leaving it alone would have kept the tree leak in the .vue tier

-> overlap with open PRs, to help with merge order:

@csm-kb csm-kb changed the title Fix/wasm parser lifetime fix(generic): free web-tree-sitter parsers and trees so large builds stop aborting Oct 4, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants