Skip to content

fix(lsp): mark column-unlocatable edges unresolvable to stop zombie re-walks#147

Merged
jrollin merged 1 commit into
mainfrom
fix/lsp-unlocatable-edges-mark-unresolvable
Jul 6, 2026
Merged

fix(lsp): mark column-unlocatable edges unresolvable to stop zombie re-walks#147
jrollin merged 1 commit into
mainfrom
fix/lsp-unlocatable-edges-mark-unresolvable

Conversation

@jrollin

@jrollin jrollin commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Problem

During LSP edge resolution, drain_language locates each edge's target column on its source line via find_column_in_line. Edges whose target_name can't be located as a word on its recorded line — a whole-expression or multi-line target (e.g. the Rust extractor emits Pool::new, method-chain receivers) — were silently dropped by the query-batch filter_map. A dropped edge is never queried, never marked, and stays resolution_state = 0 forever.

Because reopen_heuristic_exhausted un-seals state 4→0 before every LSP-enabled reindex, these edges are re-fetched and re-attempted on every pass with zero progress. On the family-check DB this was ~10,928 permanent state-0 zombies (only ~72 edges were locatable).

Fix (Part 1)

An edge cartog can't even form a query for is definitively unresolvable-by-LSP. Capture the dropped edge_ids in a new LangOutcomes.pending_unlocatable bucket and mark them unresolvable (state 2) via the existing mark_edge_unresolvable, so they stop re-walking.

  • Gating: !server_died only (no resolved > 0 gate, unlike pending_unresolvable) — unlocatable is a deterministic cartog-side fact independent of server health, same trust model as the external bucket.
  • Sticky-but-self-correcting: reset by --force (reset_all_unresolvable). The name-keyed reopen (reset_unresolvable_for_names) can't match a compound target name, so --force is the only reopen path (documented).
  • Resolution-side, all-language fix — no extractor change.

The Rust-extractor root cause (emit bare identifiers instead of Pool::new) is a separate, larger PR (breaks extractor tests + webapp_rs.json ground-truth, needs bench re-verify) — explicitly deferred.

Part 2 — dead-server close_file invariant (test-only)

Investigated and found already safe: on the server_died path the child is reaped, so its stdin read-end is closed and close_file's write returns BrokenPipe promptly via the Ok(Err(_)) ack arm — no write_timeout block. Added a #[cfg(unix)] regression test so a future edit can't reintroduce a 10s stall on this seam. No production change.

Also

Widened mark_edge_unresolvable's precondition docstring to sanction the cartog-side unlocatable determination alongside the server Ok(None) case.

Tests

  • unlocatable_edges_are_marked_unresolvable — folds into marked_unresolvable alongside a real success.
  • unlocatable_marked_even_with_zero_resolved — proves the health-gate difference vs pending_unresolvable; server_died=true suppresses.
  • unlocatable_edges_are_bucketed_and_not_queried (#[cfg(unix)], scripted fake server) — the unfindable edge lands in pending_unlocatable and is never queried; only the findable edge reaches the server.
  • write_to_a_dead_server_fails_fast_via_broken_pipe_not_write_timeout (#[cfg(unix)]) — dead-server write returns Err well under a 30s write_timeout, not classified as a wedge.

Gates

cargo fmt --check, cargo clippy --all-targets -- -D warnings (+ --no-default-features), cargo test -p cartog-lsp -p cartog-db (83 + 199), cargo test --workspace (+ --no-default-features), cargo doc --no-deps (lsp + db, warning-clean) — all green.

Docs

Internal resilience change (no new command/flag/config/MCP-tool/language/count), so site + reference docs untouched per doc-sync rule. Updated the cartog-lsp README.md outcome list (adds the unlocatable branch) and one docs/troubleshooting.md entry.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Edges that can’t be precisely located on a source line are now handled more consistently and won’t be queried unnecessarily.
    • LSP requests now fail fast when the server process is already dead, instead of being mistaken for a timeout.
    • Unresolvable resolution states are now recorded more reliably, while transient failures and dead-server cases are left unmarked.
  • Documentation

    • Improved troubleshooting guidance for resolution issues and reopen behavior.

…e-walks

Edges whose target_name can't be located as a word on its recorded line (a
whole-expression or multi-line target) can't be turned into an LSP definition
query, so drain_language silently dropped them. They stayed resolution_state=0
and, because reopen_heuristic_exhausted un-seals state 4->0 before every
LSP-enabled reindex, were re-fetched and re-attempted on every pass with zero
progress (~10.9k permanent state-0 zombies on the family-check DB).

Capture the dropped edge_ids in a new LangOutcomes.pending_unlocatable bucket and
mark them unresolvable (state 2) via the existing mark_edge_unresolvable. Gated
on !server_died only (no resolved>0 gate): it's a deterministic cartog-side fact,
independent of server health, like the external bucket. Sticky-but-self-correcting
via --force (reset_all_unresolvable); the name-keyed reopen can't match a compound
target name, so --force is the only reopen path.

Resolution-side, all-language fix; no extractor change. The Rust-extractor root
cause (emit bare identifiers) is deferred to a separate PR.

Also add a #[cfg(unix)] regression test that a write to a dead server fails fast
via BrokenPipe (Ok(Err(_)) ack arm), not after the full write_timeout, locking the
server_died close_file seam against a future edit reintroducing a stall.

Widen mark_edge_unresolvable's precondition docstring to sanction the cartog-side
unlocatable determination alongside the server Ok(None) case.
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Introduces a pending_unlocatable bucket in the LSP resolver for edges whose target column cannot be found on their source line, marking them unresolvable deterministically when the server stays alive, independent of resolution success gating. Updates documentation and adds tests, including a client regression test for dead-server write failures.

Changes

Unlocatable Edge Detection and Marking

Layer / File(s) Summary
Batch and mark unlocatable edges
crates/cartog-lsp/src/resolve.rs
LangOutcomes adds a pending_unlocatable: Vec<i64> field; drain_language buckets edges with no computable column into it instead of dropping them; apply_lang_outcomes marks these edges unresolvable whenever server_died is false, without requiring in-root successes.
Tests and documentation for unresolvable marking
crates/cartog-lsp/src/resolve.rs, crates/cartog-db/src/store/lsp.rs, crates/cartog-lsp/README.md, docs/troubleshooting.md
Adds unit tests covering marking with zero successes, suppression on server death, and non-querying of unlocatable edges; updates mark_edge_unresolvable doc comments, the README resolution flow, and a new troubleshooting entry describing sticky unresolvable state and the --force reopen path.

CI Cache Update

Not applicable — this cohort is instead:

Dead LSP Server Write Regression Test

Layer / File(s) Summary
Dead server write-failure test
crates/cartog-lsp/src/client.rs
Adds a Unix-only dead_fake_server helper and a regression test confirming a write to an already-exited server fails fast via broken pipe and is not misclassified as a write-ack timeout.

Estimated code review effort: 2 (Simple) | ~15 minutes

Possibly related PRs

  • jrollin/cartog#52: The pending_unlocatable and resolution_state marking changes build on this PR's persistent unresolvable state machine and mark_edge_unresolvable introduction.
  • jrollin/cartog#87: Deterministic unresolvable marking aligns with this PR's per-edge resolution provenance and lsp_unresolvable state handling.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: unlocatable LSP edges are marked unresolvable to prevent repeated re-walks.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/lsp-unlocatable-edges-mark-unresolvable

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.13084% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.38%. Comparing base (7119159) to head (c3f9e09).

Files with missing lines Patch % Lines
crates/cartog-lsp/src/resolve.rs 97.46% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #147      +/-   ##
==========================================
+ Coverage   88.35%   88.38%   +0.03%     
==========================================
  Files          96       96              
  Lines       32474    32574     +100     
==========================================
+ Hits        28692    28792     +100     
  Misses       3782     3782              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
crates/cartog-lsp/src/client.rs (1)

865-903: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Strengthen the assertion to positively confirm BrokenPipe, not just "not a write-timeout".

The test only asserts !is_write_timeout(&err) and that it returned quickly. That proves the failure isn't a write-timeout wedge, but it doesn't prove it is the intended broken-pipe failure — a future regression that returns some other fast, non-write-timeout error would still pass, silently defeating the regression guard the doc comment (lines 868-872) claims to provide.

♻️ Suggested stronger assertion
         assert!(
             !is_write_timeout(&err),
             "dead-server write is a broken pipe, not a write-ack timeout: {err}"
         );
+        assert!(
+            err.chain()
+                .filter_map(|c| c.downcast_ref::<std::io::Error>())
+                .any(|io_err| io_err.kind() == std::io::ErrorKind::BrokenPipe),
+            "expected a BrokenPipe io error in the chain, got: {err:#}"
+        );
         assert!(
             elapsed < Duration::from_secs(5),
             "a broken pipe must return promptly, not wait out write_timeout; took {elapsed:?}"
         );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/cartog-lsp/src/client.rs` around lines 865 - 903, The test in
write_to_a_dead_server_fails_fast_via_broken_pipe_not_write_timeout only checks
that the error is not a write timeout and that it returns quickly, but it should
also positively assert the failure is BrokenPipe. Update the assertion logic in
this test to inspect the error returned by client.send_notification for the
expected broken-pipe kind/variant, using the existing helper is_write_timeout
and the error value from expect_err so the regression guard in this
dead_fake_server path actually proves the intended failure mode.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@crates/cartog-lsp/src/client.rs`:
- Around line 865-903: The test in
write_to_a_dead_server_fails_fast_via_broken_pipe_not_write_timeout only checks
that the error is not a write timeout and that it returns quickly, but it should
also positively assert the failure is BrokenPipe. Update the assertion logic in
this test to inspect the error returned by client.send_notification for the
expected broken-pipe kind/variant, using the existing helper is_write_timeout
and the error value from expect_err so the regression guard in this
dead_fake_server path actually proves the intended failure mode.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3b2d825f-aca1-4ef3-8848-ba910dd72cff

📥 Commits

Reviewing files that changed from the base of the PR and between 7119159 and c3f9e09.

📒 Files selected for processing (5)
  • crates/cartog-db/src/store/lsp.rs
  • crates/cartog-lsp/README.md
  • crates/cartog-lsp/src/client.rs
  • crates/cartog-lsp/src/resolve.rs
  • docs/troubleshooting.md

@jrollin jrollin merged commit caf64ab into main Jul 6, 2026
14 checks passed
@jrollin jrollin deleted the fix/lsp-unlocatable-edges-mark-unresolvable branch July 6, 2026 12:28
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.

1 participant