Skip to content

fix(server): bounded navigation wait and buffer desync detection - #493

Closed
16bit-ykiko wants to merge 1 commit into
mainfrom
fix/bounded-wait-and-desync
Closed

16bit-ykiko wants to merge 1 commit into
mainfrom
fix/bounded-wait-and-desync

Conversation

@16bit-ykiko

@16bit-ykiko 16bit-ykiko commented Jul 7, 2026 •

Copy link
Copy Markdown
Member

Motivation

Two gaps in how feature requests relate to the editor buffer:

  1. Navigation silently served stale results after an edit. Index-backed queries (definition, references, declaration, type definition, implementation, call/type hierarchy) consulted the session's file index only when it was up to date and otherwise fell back to the merged index shards — which still describe the pre-edit file. Two FIXMEs marked this. Going through the compile unconditionally is not an option either: navigation must not hang for seconds behind a large TU.
  2. A provably diverged buffer kept answering. If an incremental didChange carried a range that does not map into the server's buffer (client and server have drifted apart), the edit was silently dropped and every subsequent feature request answered from text the user is not looking at.

Changes

  • Bounded compile wait for navigation. New Compiler::ensure_compiled_bounded races the compile against a timeout; FeatureRouter::await_index_freshness applies it with a 1000 ms budget (internal constant — deliberately not configuration) before every position-resolving index query. If the compile lands in time, the query sees the fresh file index; on timeout the query degrades to the shards exactly as before, now with a debug log. The wait is bounded-staleness by design: it waits for any compile round to settle, not necessarily the newest (an invalidation landing mid-flight keeps the dirty flag set), and a timeout abandons only the wait — the detached compile keeps running for the next request.
  • definition no longer double-waits. Its flow used to forward dirty sessions to the worker, which waits on the same compile without a bound. After the bounded wait expires it now returns the shard answer instead of queueing again.
  • Buffer desync detection. SessionStore::apply_change marks the session desynced when a change's range cannot be mapped; a whole-document change or didOpen restores authoritative content (a later valid incremental change does not — the buffer it edits is already wrong). Every feature entry refuses a desynced session with error -32801 ("Document out of sync", LSP's ContentModified code), and the index layer excludes desynced sessions from cross-file queries. definition re-checks after its bounded wait, since a desyncing didChange can land mid-wait.

Tests

  • Unit: bounded wait times out against a never-settling compile without disturbing it; clean-session fast path; definition degrades to the shard answer on timeout instead of hanging (uses the real 1 s budget); invalid range marks desync; recovery matrix (valid incremental does not recover, whole-document and didOpen do); index session filter skips dirty and desynced sessions.
  • Integration: definition immediately after an edit resolves to the post-edit location (a stale shard answer cannot produce it); out-of-range change makes hover and definition fail with "out of sync", and a whole-document change recovers.
  • Known gaps, judged not worth forcing: an end-to-end timeout scenario needs a reliably >1 s compile (machine-dependent, flaky); the shard fallback inside resolve_cursor for desynced sessions would need a merged-shard fixture — both paths are covered at unit level or by construction.

Local verification: format, RelWithDebInfo build, unit suite 855 passed, integration subset (navigation freshness, protocol edges, index, server basics, rapid edit) passed, smoke tests 3/3.

Summary by CodeRabbit

  • New Features

    • Added better handling for out-of-sync documents, including automatic recovery after a full document refresh.
    • Navigation and code insight requests now wait briefly for updated results before falling back gracefully.
  • Bug Fixes

    • Prevented stale results from being returned after edits that can’t be mapped cleanly to the current document.
    • Improved definition lookup so it favors fresh content after recent changes instead of outdated indexes.

@coderabbitai

coderabbitai Bot commented Jul 7, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: ee364d24-4001-4dcd-8768-437b74e10313

📥 Commits

Reviewing files that changed from the base of the PR and between 00f0e53 and 02fe375.

📒 Files selected for processing (14)
  • src/server/compiler/compiler.cpp
  • src/server/compiler/compiler.h
  • src/server/service/feature_router.cpp
  • src/server/service/feature_router.h
  • src/server/service/query.cpp
  • src/server/state/session.h
  • src/server/state/session_store.cpp
  • src/server/state/session_store.h
  • tests/integration/features/test_navigation_freshness.py
  • tests/integration/lifecycle/test_protocol_edges.py
  • tests/unit/server/compiler_tests.cpp
  • tests/unit/server/feature_router_tests.cpp
  • tests/unit/server/index_query_tests.cpp
  • tests/unit/server/session_store_tests.cpp

📝 Walkthrough

Walkthrough

This PR adds Compiler::ensure_compiled_bounded, a timeout-bounded compile wait, and a Session::desynced flag set by SessionStore when incremental edit ranges cannot be mapped. FeatureRouter gains await_index_freshness() and desync guards across navigation/hierarchy endpoints; IndexQuery excludes desynced sessions. Tests cover both features.

Changes

Bounded compile wait and desync handling

Layer / File(s) Summary
Bounded compile wait API
src/server/compiler/compiler.h, src/server/compiler/compiler.cpp, tests/unit/server/compiler_tests.cpp
Adds Compiler::ensure_compiled_bounded racing ensure_compiled against a timeout via when_any, with documented semantics and unit tests for timeout and fast-path behavior.
Session desync state and transitions
src/server/state/session.h, src/server/state/session_store.cpp, src/server/state/session_store.h, tests/unit/server/session_store_tests.cpp
Adds Session::desynced; SessionStore clears it on open/whole-document changes and sets it when an incremental change range cannot be mapped, with updated docs and tests.
IndexQuery filtering
src/server/service/query.cpp, tests/unit/server/index_query_tests.cpp
visit_sessions and resolve_cursor now skip desynced sessions in addition to dirty ones, with a test covering clean/dirty/desynced sessions.
FeatureRouter freshness wait and desync guards
src/server/service/feature_router.h, src/server/service/feature_router.cpp, tests/unit/server/feature_router_tests.cpp
Adds await_index_freshness() and navigation_compile_wait; definition() waits for freshness and degrades to stale shards on timeout; navigation, formatting, and hierarchy endpoints return document_out_of_sync() when desynced.
Integration tests
tests/integration/features/test_navigation_freshness.py, tests/integration/lifecycle/test_protocol_edges.py
New tests verify fresh definitions after edits and bounded compilation, and out-of-sync errors with recovery after a whole-document change.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant FeatureRouter
    participant Compiler
    participant SessionStore
    participant IndexQuery

    Client->>SessionStore: did_change (incremental edit)
    alt range unmappable
        SessionStore->>SessionStore: mark session.desynced = true
    else whole-document or valid range
        SessionStore->>SessionStore: clear session.desynced
    end

    Client->>FeatureRouter: definition(session)
    alt session.desynced
        FeatureRouter-->>Client: document_out_of_sync error
    else
        FeatureRouter->>Compiler: ensure_compiled_bounded(session, timeout)
        Compiler-->>FeatureRouter: fresh (bool)
        FeatureRouter->>IndexQuery: query relations
        IndexQuery-->>FeatureRouter: locations (fresh or stale)
        FeatureRouter-->>Client: definition response
    end
Loading

Possibly related PRs

  • clice-io/clice#462: Introduces the Compiler::ensure_compiled(std::shared_ptr<Session>) API that this PR's ensure_compiled_bounded directly wraps and depends on.
🚥 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 summarizes the two main changes: bounded navigation waits and buffer desync detection.
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/bounded-wait-and-desync

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.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 02fe375617

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

bool include_declaration) {
if(session && session->desynced)
co_return kota::outcome_error(document_out_of_sync());
co_await await_index_freshness(session);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Add desync re-check after bounded navigation waits

When a desyncing didChange arrives while references/declaration/type-definition/etc. are suspended in await_index_freshness, the initial guard has already run. These handlers then call IndexQuery with the now-desynced session; resolve_cursor skips the session index but still maps the cursor through session->line_map() before falling back to shards, so the request returns [] or stale locations instead of the ContentModified error. definition has the needed post-wait guard; the other index-backed handlers need the same re-check after this await.

Useful? React with 👍 / 👎.

Comment on lines +200 to 202
if(session->desynced)
co_return kota::outcome_error(document_out_of_sync());
co_return co_await compiler.forward_query(worker::QueryKind::Hover, session, position);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Re-check desync after worker-backed awaits

When an invalid didChange arrives after this preflight check but while the forwarded worker request is awaiting ensure_compiled or pool.send, the guard no longer applies. forward_query can return null on the generation mismatch before the router sees session->desynced, or return the old worker result if the edit lands after the pre-send generation check, so hover/semantic tokens/etc. don't consistently report the intended ContentModified error. Re-check session->desynced after the await (or have the compiler forward helpers surface it) for these worker-backed paths.

Useful? React with 👍 / 👎.

Comment on lines +171 to +173
if(!fresh) {
co_return to_raw(
index_query.query_relations(path, pos, RelationKind::Definition, session.get()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid querying old shards with edited offsets

When this timeout branch runs after an edit inserted or deleted text before the cursor, query_relations receives the dirty session and resolve_cursor converts the current LSP position through session->line_map() before looking in the pre-edit merged shard. That mixes offsets from the edited buffer with occurrences from the old file contents, so a timed-out definition request can jump to an unrelated symbol instead of merely returning a bounded-stale/empty shard result; use the shard's own map (or avoid the shard fallback for shifted dirty buffers) on this path.

Useful? React with 👍 / 👎.

@16bit-ykiko

Copy link
Copy Markdown
Member Author

Superseded: the approach here (bounded timeout wait + desync refusal) was rejected by the maintainer. A replacement PR will implement a layered freshness policy instead: cursor resolution waits for the current file's compile unconditionally (same as every other request), and cross-TU index queries serve existing results for files whose own content is unchanged while skipping files whose content changed but whose index has not caught up.

@16bit-ykiko 16bit-ykiko closed this Jul 7, 2026
16bit-ykiko added a commit that referenced this pull request Jul 7, 2026
Supersedes #493 (bounded-wait approach, rejected). Instead of paying a
fixed
delay for a still-incomplete freshness guarantee, index queries now
follow a
layered freshness policy.

## Cursor resolution waits for the file's compile

Requests that resolve a cursor position into a symbol (definition,
references, declaration, type definition, implementation, call/type
hierarchy) now await the current file's compile before querying the
index —
the same await, with no timeout, that hover and every other AST-backed
request already uses. Previously most of these queried the index
immediately, so a query racing a `didChange` could resolve the cursor
against pre-edit positions and name the wrong symbol.

## Cross-file results honor the reindex queue's pending reason

When a query fans out to other files' index contributions, files sitting
in
the background reindex queue split two ways, decided by why they were
enqueued:

- **Dependency-only staleness** (a header they include changed — the
common
  cascade case): their own text did not move, so the existing rows keep
  serving until the reindex lands.
- **Own content changed** (disk edit, close after saved edits, compile
command change): their rows describe text that no longer exists, so
their
  contribution is skipped until the reindex lands.

The invalidation engine knows the cause at enqueue time, so the
`Indexer`
records a two-level pending reason (`DepsOnly | ContentChanged`,
upgrades
are absorbing) and the query side checks it in O(1) with no I/O. A file
re-enqueued while its index task is in flight keeps its newer pending
state
(ticket-guarded clear). `didClose` classifies by comparing the disk
content
against the shard's stored snapshot — a browse-and-close keeps serving
its
rows, a close after saved edits does not. The startup sweep enqueues as
deps-only so a warm index cache keeps serving through the initial scan.
With indexing disabled the gate is off: serving last-known rows beats a
permanent hole.

Results may therefore be incomplete while the queue drains. That is now
a
documented contract on `IndexQuery` (replacing two FIXMEs), together
with
two recorded-not-implemented TODOs: a blocking "complete results" query
mode, and a dedicated "is the index ready?" request for agent consumers.

Fixed along the way: the background round used to spawn its per-file
task
as an immediately-invoked capturing lambda. A lambda coroutine's
captures
live in the lambda object, which dies at the end of the spawning
statement,
so anything the task read after its first suspension was dangling. The
task
is now a member coroutine taking its inputs as parameters, which are
copied
into the coroutine frame.

## Buffer desync is tolerated and logged

An incremental `didChange` whose range does not fit the buffer (client
and
server views drifted) was silently discarded; it is now discarded with
an
ERROR log. No desync flag, no refusal of service — a full-document
change
or reopen resynchronizes.

## Tests

- Unit: pending-reason upgrade semantics; deps-only pending files keep
  serving rows while content-changed ones are skipped (real shards built
  through the test compiler), including line-based symbol resolution;
`didClose` classification (no shard / current shard / divergent shard);
  the invalidator suite migrated to the split effect lists with
  complementary-list assertions.
- Integration: definition/references immediately after `didChange`
resolve
  correctly against the edited buffer; an out-of-range edit produces an
error log and later requests keep working. The pending-flag clear after
  a reindex lands is exercised end-to-end by the existing file-tracker
  tests (a stuck flag would time them out).
- Not covered deterministically: the guard that keeps a file's pending
state when it is re-enqueued while its index task is in flight — forcing
  that interleaving needs an injectable suspension point in the index
  task, which does not exist today.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Navigation and symbol lookups stay consistent immediately after edits,
even while background indexing is still running.
* Queries apply a freshness contract, skipping index contributions that
are known stale until reindex completes.
* **Bug Fixes**
* Definition, references, and hierarchy views now await compilation
before using index-backed results, avoiding stale/superseded session
state.
* Invalid incremental edits are logged with details and dropped without
breaking subsequent requests.
* Reindex behavior is refined for dependency-only vs content-changed
cases to improve correctness.
* **Tests**
* Added integration coverage for post-edit navigation accuracy and
desync range tolerance, plus new unit tests for query freshness gating.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
@16bit-ykiko
16bit-ykiko deleted the fix/bounded-wait-and-desync branch July 17, 2026 12:55
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