Skip to content

explore: call graph is stale and dangling after file edits #656

Description

@justrach

Problem

Explorer.call_graph is built once (ensureCallGraph, guarded by call_graph != null) and never invalidated: the file-edit commit path refreshes deps, the symbol index, and line offsets, but not call_graph/call_centrality. Every codedb_callpath (and the graph-distance rerank boost feeding ranked search) answers from the process-start snapshot of the graph.

It's worse than staleness: CallGraph.node_path/node_name are borrowed slices into outline symbol memory (CallGraph.deinit frees only the arrays, not the strings). Re-indexing a file frees those outlines, so the graph's name/path tables dangle — reads through them after an edit are use-after-free. Under the testing allocator this shows up as silently-wrong results (freed memory no longer matches any query name); under other allocators it can return corrupt paths.

Found while perf-auditing the graph queries (the same audit that produced #651): the invalidation gap was visible statically, and the failing test then exposed the dangling-slice aspect (an earlier test variant "passed" only because the poisoned freed memory matched nothing).

Failing Test

src/test_explore.zig (branch issue-656-failing-test), red on current release/0.2.5828:

test "issue-656: call graph is stale and dangling after a file edit" {
    var explorer_inst = Explorer.init(testing.allocator, Explorer.DEFAULT_CONTENT_CACHE_CAPACITY);
    defer explorer_inst.deinit();
    try explorer_inst.indexFile("calls.zig",
        \\pub fn alpha() void {}
        \\pub fn beta() void {}
    );

    // First query builds the call graph: no alpha -> beta edge yet.
    const before = try explorer_inst.findCallPath("alpha", "beta", testing.allocator, 4);
    try testing.expect(before == null);

    // Edit the file so alpha NOW calls beta.
    try explorer_inst.indexFile("calls.zig",
        \\pub fn alpha() void {
        \\    beta();
        \\}
        \\pub fn beta() void {}
    );

    const after = try explorer_inst.findCallPath("alpha", "beta", testing.allocator, 4);
    defer if (after) |steps| testing.allocator.free(steps);
    try testing.expect(after != null); // fails: stale/dangling graph can't see the new edge
}

Expected

After a file is re-indexed, codedb_callpath reflects the edited call sites (and never reads freed memory).

Fix

Two-part, smallest-safe shape:

  1. Invalidate on commit: in the file-commit path, deinit + null call_graph (and call_centrality if its snapshot-restored copy should track edits) and bumpSearchGen() — next graph query lazily rebuilds, matching ensureSymbolIndex's pattern. Rebuild-per-edit is acceptable: build cost is the existing first-query cost, and edits are much rarer than queries.
  2. Own the strings (or dupe node_path/node_name at build time) so a not-yet-invalidated graph can never dangle even if a future code path misses the invalidation hook.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority:p2Medium priority

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions