Skip to content

fix: free leaked WASM trees in native engine typeMap backfill#534

Merged
carlos-alm merged 8 commits intomainfrom
perf/query-latency-regression
Mar 19, 2026
Merged

fix: free leaked WASM trees in native engine typeMap backfill#534
carlos-alm merged 8 commits intomainfrom
perf/query-latency-regression

Conversation

@carlos-alm
Copy link
Contributor

Summary

  • Free WASM trees in backfillTypeMap() and the parseFilesAuto() bulk backfill loop — previously these trees were never freed, accumulating in WASM linear memory across repeated builds
  • Skip backfill for JS files — only TS/TSX files have type annotations that WASM can extract; the native engine already handles JS new Expr() patterns, so re-parsing all JS files with WASM was pure waste and the primary leak vector for JS-only codebases like codegraph itself

Closes #530

Root cause

When the native engine is used, parseFilesAuto calls native.parseFiles() then checks if any files need typeMap backfill via WASM. For every file where typeMap was empty (all JS files — they have no type annotations), it called wasmExtractSymbols() which returns a { symbols, tree } object. The tree (WASM tree-sitter Tree requiring explicit .delete()) was never freed. Over repeated builds in benchmarks, hundreds of leaked trees accumulated in WASM linear memory, eventually corrupting V8 internal state and crashing the native addon with ACCESS_VIOLATION (0xC0000005) or V8 has_exception() assertion failures.

Test plan

  • 478 integration tests pass (3 pre-existing WASM grammar failures unrelated)
  • Lint clean
  • Run node scripts/benchmark.js — native worker should complete all iterations without crashing
  • Run node scripts/query-benchmark.js — native worker should complete without V8 assertion failure

Three targeted fixes for the +28–56% query latency regression:

1. Pin benchmark hub target to stable function names (buildGraph,
   openDb, loadConfig) instead of auto-selecting the most-connected
   node. Barrel/type files becoming the hub made version-to-version
   comparison meaningless.

2. Gate implementors queries in bfsTransitiveCallers — check once
   whether the graph has any 'implements' edges before doing per-node
   findNodeById + findImplementors lookups. Skips all implementor
   overhead for codebases without interface/trait hierarchies.

3. Cache loadConfig() results per cwd. The config file is read from
   disk on every fnImpactData and diffImpactData call; caching
   eliminates redundant fs.existsSync + readFileSync + JSON.parse
   per query invocation.

Impact: 5 functions changed, 123 affected
…handle

Prevent callers from mutating the cached config object by returning a
deep clone on cache hits. Add try/finally to selectTargets() so the
database handle is closed even if a query throws.

Impact: 2 functions changed, 1 affected
The embedding benchmark's npm mode installs codegraph into a temp dir,
but @huggingface/transformers is a devDependency and not included.
All 6 model workers crash on import, producing symbols: 0, models: {}.

Install it explicitly from the local devDependencies version, matching
the existing pattern for native platform packages. Also add a guard in
update-embedding-report.js to reject empty results and fail loudly
instead of silently overwriting valid benchmark data.
…gression

Impact: 3 functions changed, 8 affected
The typeMap backfill path in parseFilesAuto and backfillTypeMap called
wasmExtractSymbols but never freed the returned WASM tree objects.
Over repeated builds (benchmarks, watch mode), hundreds of trees
accumulated in WASM linear memory, eventually corrupting V8 state and
crashing the native addon with ACCESS_VIOLATION / has_exception().

Two fixes:
1. Free WASM trees immediately after extracting typeMap data in both
   backfillTypeMap() and the parseFilesAuto() bulk backfill loop.
2. Skip backfill entirely for JS files — only TS/TSX have type
   annotations that WASM can extract. The native engine already
   handles JS `new Expr()` patterns, so re-parsing all JS files
   with WASM was pure waste.

Closes #530

Impact: 2 functions changed, 2 affected
@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 19, 2026

Greptile Summary

This PR fixes a WASM linear memory leak in the native engine's typeMap backfill path: backfillTypeMap now wraps its result logic in try/finally to guarantee tree.delete() is called, and parseFilesAuto / parseFileAuto / parseFileIncremental now skip the WASM backfill entirely for JS files (which carry no type annotations). Together these eliminate the hundreds of leaked trees that accumulated across repeated builds and eventually corrupted V8 internal state.

  • The try/finally pattern in backfillTypeMap and the bulk parseFilesAuto loop correctly handles both early-return and error paths, ensuring trees are freed in all cases.
  • Restricting backfill to .ts/.tsx is both a correctness improvement (JS WASM extraction can't produce typeMap) and the primary leak vector for JS-only codebases.
  • TS_BACKFILL_EXTS / TS_EXTS constants are re-created as new Set(...) inside parseFileAuto and parseFileIncremental on every call; these should be a single module-level constant.
  • wasmExtractSymbols itself still leaks a WASM tree in the !entry and !symbols early-return paths — these are not introduced by this PR but remain unaddressed now that callers have been hardened.
  • The edge_builder.rs changes (adding struct, record, enum, trait to inheritance edge matching) are unrelated to the leak fix and should be shipped in a separate PR per the project's "one PR = one concern" convention.

Confidence Score: 4/5

  • Safe to merge for the JS-side fix; the bundled Rust change should ideally be split out but poses no regression risk.
  • The core leak-fix logic is sound and the try/finally pattern is correctly applied in all three backfill call sites. The JS-file skip is semantically correct. Minor residual issues (inline Set allocations, two internal wasmExtractSymbols leak paths) do not affect correctness. The unrelated edge_builder.rs change is a process concern rather than a bug.
  • src/domain/parser.js — wasmExtractSymbols still has two internal tree-leak paths; crates/codegraph-core/src/edge_builder.rs — unrelated change bundled into this PR

Important Files Changed

Filename Overview
src/domain/parser.js Core WASM tree-leak fix: backfillTypeMap now uses try/finally to guarantee tree.delete() is called, and parseFilesAuto/parseFileAuto/parseFileIncremental now skip WASM backfill for JS files. Logic is correct. Minor: TS_BACKFILL_EXTS constant is re-allocated per call, and wasmExtractSymbols still has two internal leak paths not addressed by this PR.
crates/codegraph-core/src/edge_builder.rs Extends class-inheritance and implements edge matching to accept struct, record, enum, and trait node kinds — broadening support for non-class OOP constructs. The logic itself is sound but this change is unrelated to the WASM tree-leak fix and should be in a separate PR per project convention.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[parseFilesAuto / parseFileAuto] --> B{Native engine?}
    B -- No --> C[WASM path: wasmExtractSymbols\nstores tree in symbols._tree]
    B -- Yes --> D[native.parseFiles / parseFile]
    D --> E{typeMap empty?}
    E -- No --> F[Return result]
    E -- Yes --> G{ext in .ts or .tsx?\nNEW: skip JS files}
    G -- No --> F
    G -- Yes --> H[backfillTypeMap]
    H --> I[createParsers]
    I --> J[wasmExtractSymbols]
    J --> K{symbols?.typeMap?}
    K -- No --> L[return typeMap empty]
    K -- Yes --> M[build Map from typeMap]
    M --> N[return typeMap, backfilled=true]
    L --> O[finally: extracted?.tree?.delete\nNEW: always free WASM tree]
    N --> O
    O --> F
Loading

Last reviewed commit: "fix: address review ..."

Comment on lines +158 to +160
const result = resolveSecrets(applyEnvOverrides(merged));
_configCache.set(cwd, result);
return result;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Inconsistent return value on first vs. subsequent cache hits

On the first call for a given cwd, loadConfig returns the actual cached object stored in _configCache. All subsequent calls correctly return a structuredClone. If any caller mutates the returned config (e.g., patching llm.apiKey or similar), the cached entry is silently corrupted and all future callers receive the mutated data.

Both return sites after populating the cache should return a clone for consistency:

Suggested change
const result = resolveSecrets(applyEnvOverrides(merged));
_configCache.set(cwd, result);
return result;
const result = resolveSecrets(applyEnvOverrides(merged));
_configCache.set(cwd, result);
return structuredClone(result);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed. The merge with main already brought in the structuredClone fix for both cache-population return sites (commit f8016c6 on main). The merged resolution preserves structuredClone(result) when storing to cache, so the first caller gets the raw object and the cache holds an isolated copy.

Comment on lines +166 to +168
const defaults = resolveSecrets(applyEnvOverrides({ ...DEFAULTS }));
_configCache.set(cwd, defaults);
return defaults;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Same raw-reference return for the defaults branch

The defaults code path has the same issue — the object stored in the cache is returned directly on the first call. This should also return a structuredClone to match the behaviour of the early-return cache hit path above:

Suggested change
const defaults = resolveSecrets(applyEnvOverrides({ ...DEFAULTS }));
_configCache.set(cwd, defaults);
return defaults;
const defaults = resolveSecrets(applyEnvOverrides({ ...DEFAULTS }));
_configCache.set(cwd, defaults);
return structuredClone(defaults);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed. Same as above -- the merge with main brought in structuredClone(defaults) for this path as well.

The Rust edge builder only matched `kind == "class"` when looking up
source nodes and targets for extends/implements edges. This caused all
`impl Trait for Struct` relationships (and any non-class hierarchy) to
be silently dropped — producing 0 implements edges for Rust sources
while WASM correctly found 9.

Align the three kind filter sets with the JS-side constants:
- Source: class, struct, record, enum (was: class only)
- Extends targets: class, struct, trait, record (was: class only)
- Implements targets: interface, class, trait (was: interface, class)

Fixes #530 (partial — implements parity gap)

Impact: 1 functions changed, 0 affected
Impact: 1 functions changed, 0 affected
- Consolidate duplicated tree.delete() in backfillTypeMap into a single
  finally block, preventing future early-return paths from leaking trees
- Skip WASM typeMap backfill for JS files in parseFileAuto and
  parseFileIncremental single-file paths, matching the bulk path behavior

Impact: 3 functions changed, 2 affected
@carlos-alm
Copy link
Contributor Author

@greptileai re-review: merged origin/main to resolve config.js conflicts (structuredClone fix already on main), refactored backfillTypeMap to use a single finally block for tree cleanup, and added TS/TSX extension guard to parseFileAuto and parseFileIncremental single-file paths to skip unnecessary WASM parsing for JS files.

@carlos-alm carlos-alm merged commit 8e78e62 into main Mar 19, 2026
29 checks passed
@carlos-alm carlos-alm deleted the perf/query-latency-regression branch March 19, 2026 10:45
@github-actions github-actions bot locked and limited conversation to collaborators Mar 19, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: native engine crashes with ACCESS_VIOLATION during benchmark builds

1 participant