mcpls_core/bridge/indexing.rs
1//! Workspace-indexing readiness tracking.
2//!
3//! Extracted from `bridge::notifications` (already ~2900 lines) so the
4//! settle/latch transition logic below -- the part most likely to grow a
5//! subtle off-by-one -- has its own directly unit-testable surface. See
6//! [`IndexingTracker`] for the entry point; [`NotificationCache`](super::NotificationCache)
7//! owns one and delegates every indexing-related call to it.
8//!
9//! Two independent signal sources feed the same per-server state:
10//! rust-analyzer's custom `experimental/serverStatus` notification (#421,
11//! [`IndexingTracker::observe_server_status`]), and the generic LSP
12//! `$/progress` `begin`/`end` sequence every spec-compliant server may send
13//! once mcpls advertises `window.workDoneProgress` (this issue,
14//! [`IndexingTracker::observe_progress`]). [`IndexingSignalSource`] keeps
15//! them from fighting over the same entry -- see that type's docs.
16
17use std::collections::HashMap;
18use std::time::Duration;
19
20use lsp_types::{ProgressParams, ProgressToken};
21use serde::{Deserialize, Serialize};
22use tokio::time::Instant;
23
24use crate::config::ServerId;
25use crate::lsp::types::ProgressKind;
26
27/// Workspace-indexing readiness of a routed LSP server.
28///
29/// Tracked separately from the `initialize`/`initialized` handshake
30/// completion (`ServerState::is_ready`). A server can finish the handshake
31/// and still be mid-index for tens of seconds afterward, during which
32/// whole-workspace queries (hover, definition, references, completions,
33/// code actions) can silently return an empty/`null` result
34/// indistinguishable from a genuine "nothing found".
35///
36/// `Unknown` and `Ready` are treated identically by
37/// `Translator::wait_for_indexing_ready` (proceed without waiting): a server
38/// that never emits a recognized readiness signal must never be penalized
39/// with an artificial delay, and the only way to tell "no signal ever comes"
40/// apart from "just hasn't reported yet" would require guessing at a
41/// server's protocol support, so both stay unblocked. Only `Loading` -- a
42/// positive signal that indexing is actively in progress -- triggers a
43/// bounded wait.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
45pub enum IndexingState {
46 /// No recognized workspace-readiness signal has been observed for this
47 /// server yet.
48 #[default]
49 Unknown,
50 /// A recognized signal reported that indexing is still in progress.
51 Loading,
52 /// A recognized signal reported that the initial workspace load is
53 /// complete.
54 Ready,
55}
56
57/// Escape hatch for `IndexingTracker`'s readiness gate, configured per
58/// server via `LspServerConfig::indexing`.
59///
60/// `Disabled` pins `IndexingTracker::state` at [`IndexingState::Unknown`]
61/// unconditionally -- the same fail-open path a server that has simply never
62/// sent a recognized signal already takes -- rather than adding a new branch
63/// to the gate itself. Exists for a server whose `$/progress`/`serverStatus`
64/// shape doesn't fit this tracker's assumptions (Accepted cost 1/3 in the
65/// design): per-server granularity is enough, since `Error::WorkspaceIndexing`
66/// already names the offending `server_id`.
67///
68/// # Examples
69///
70/// ```
71/// use mcpls_core::bridge::IndexingPolicy;
72///
73/// assert_eq!(IndexingPolicy::default(), IndexingPolicy::Auto);
74/// let json = serde_json::to_string(&IndexingPolicy::Disabled).unwrap();
75/// assert_eq!(json, "\"disabled\"");
76/// ```
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
78#[serde(rename_all = "snake_case")]
79pub enum IndexingPolicy {
80 /// Track readiness normally from whatever signals the server sends.
81 #[default]
82 Auto,
83 /// Never gate on this server; `indexing_state` always reads `Unknown`.
84 Disabled,
85}
86
87impl IndexingPolicy {
88 /// Whether this is the default [`Self::Auto`] policy.
89 ///
90 /// Used as `LspServerConfig::indexing`'s `skip_serializing_if` (M4):
91 /// without it, the generated default `mcpls.toml` would write `indexing
92 /// = "auto"` into every one of its ~30 builtin server entries, unlike
93 /// every other optional field in that struct, which is omitted at its
94 /// default. Takes `&self`, not `self`, since `skip_serializing_if`
95 /// requires `fn(&T) -> bool` (matching `Option::is_none`'s convention).
96 // serde needs `&self` here despite Self being a trivially-Copy 1-byte enum.
97 #[allow(clippy::trivially_copy_pass_by_ref)]
98 #[must_use]
99 pub(crate) const fn is_auto(&self) -> bool {
100 matches!(self, Self::Auto)
101 }
102}
103
104/// Which signal kind last drove a server's [`IndexingEntry`].
105///
106/// Both sources write the same entry, so once one has spoken it must not be
107/// silently overwritten by stale reasoning from the other (S2/#421): a
108/// `Ready(ServerStatus)` rust-analyzer entry must not be knocked back to
109/// `Loading` by a later `$/progress` sequence it never asked for, and
110/// conversely a `ServerStatus` signal -- authoritative, since it is
111/// rust-analyzer's own purpose-built readiness notification -- always
112/// overrides whatever a generic `$/progress` sequence guessed, in any state.
113/// See [`IndexingTracker::observe_server_status`] and
114/// [`IndexingTracker::observe_progress`] for the exact precedence rules.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116enum IndexingSignalSource {
117 /// rust-analyzer's `experimental/serverStatus`.
118 ServerStatus,
119 /// A generic `$/progress` `begin`/`end` sequence.
120 Progress,
121}
122
123/// A tracked [`IndexingState`] plus the bookkeeping needed to derive it.
124///
125/// `last_updated` is the *only* timestamp field (N5): both signal sources
126/// refresh it on every accepted update, so [`IndexingTracker::state`]'s
127/// staleness check (`INDEXING_STALENESS_BOUND`) works identically for
128/// either source without a second, source-specific clock to keep in sync.
129/// For a `Progress`-sourced entry this also means a multi-phase load whose
130/// individual phases each stay under the staleness bound remains gated for
131/// its whole duration, even if the load as a whole runs past
132/// `INDEXING_STALENESS_BOUND` -- only a single phase that itself never
133/// reports a boundary for that long fails open.
134///
135/// `open`/`empty_since`/`latched` are meaningful only for a `Progress`
136/// source; a `ServerStatus`-sourced entry leaves them at their initial
137/// values and `state` is read directly instead -- see
138/// [`IndexingTracker::state`].
139#[derive(Debug, Clone)]
140struct IndexingEntry {
141 /// Directly authoritative for a `ServerStatus` source; ignored on read
142 /// for a `Progress` source, which recomputes state from
143 /// `open`/`empty_since`/`latched` instead.
144 state: IndexingState,
145 source: IndexingSignalSource,
146 last_updated: Instant,
147 /// Progress tokens with an outstanding `begin` and no matching `end`
148 /// yet, each paired with when its `begin` was accepted.
149 ///
150 /// A `HashMap`, not a `HashSet` (S1 / security HIGH): a single lost
151 /// `end` -- realistic, since `try_send` drops frames once the
152 /// lifecycle lane is full -- used to leave a token in the set forever,
153 /// pinning [`IndexingTracker::state`] at `Loading` for the process
154 /// lifetime even while unrelated later frames on the same server kept
155 /// refreshing `last_updated` and masking the entry-wide staleness
156 /// self-heal below. `observe_progress` evicts tokens older than
157 /// `INDEXING_STALENESS_BOUND` on every write, and `state` additionally
158 /// treats any individually stale token as absent even between writes,
159 /// so the self-heal no longer depends on `last_updated` alone. Also
160 /// capped at [`PROGRESS_OPEN_CAP`] so worst-case memory is bounded
161 /// independent of eviction timing.
162 open: HashMap<ProgressToken, Instant>,
163 /// When `open` most recently became empty, if it ever has. `None` means
164 /// "never been empty" -- critically different from "became empty a long
165 /// time ago" (N2): both `state` and `latch` reads must treat them
166 /// differently, or a fresh entry's very first `begin` reads/latches as
167 /// though progress had already settled.
168 empty_since: Option<Instant>,
169 /// Once set, `observe_progress` ignores this server's `$/progress`
170 /// stream forever and `state` reads `Ready` unconditionally. Set when a
171 /// `begin` arrives after `open` has sat empty for at least
172 /// `PROGRESS_LATCH_IDLE` -- the generic signal for "the workspace-load
173 /// phase is over and everything after this is a per-request progress
174 /// sequence (formatting, a single completion, ...), not more indexing".
175 latched: bool,
176}
177
178impl IndexingEntry {
179 /// A freshly observed `Progress`-sourced entry, with no outstanding
180 /// `begin` yet -- `observe_progress` fills in `open`/`empty_since` right
181 /// after this returns.
182 fn fresh_progress() -> Self {
183 Self {
184 state: IndexingState::Unknown,
185 source: IndexingSignalSource::Progress,
186 last_updated: Instant::now(),
187 open: HashMap::new(),
188 empty_since: None,
189 latched: false,
190 }
191 }
192}
193
194/// Custom notification method rust-analyzer uses to report workspace-load
195/// progress; requires opting in at `initialize` (see `LspServer::initialize`).
196const SERVER_STATUS_METHOD: &str = "experimental/serverStatus";
197
198/// Boolean field on a [`SERVER_STATUS_METHOD`] payload: `true` once
199/// rust-analyzer's initial workspace load is complete, `false` while it is
200/// still in progress.
201const QUIESCENT_FIELD: &str = "quiescent";
202
203/// Once a `Loading` entry has gone this long without a fresh signal, reads
204/// stop trusting it -- see [`IndexingTracker::state`].
205///
206/// Deliberately larger than `navigation::INDEXING_READY_TIMEOUT` (30s) and
207/// anchored to the signal's own age, not to any individual caller's wait:
208/// a caller that times out must never affect another concurrent or later
209/// caller's deadline, only the age of the last real signal does. See
210/// `navigation.rs`'s const-asserts for the cross-checked ordering between
211/// this, `INDEXING_READY_TIMEOUT`, and the two constants below.
212pub const INDEXING_STALENESS_BOUND: Duration = Duration::from_secs(60);
213
214/// Default maximum time, in seconds, `Translator::wait_for_indexing_ready`
215/// waits for a routed LSP server to report indexing readiness.
216///
217/// Only takes effect once a readiness signal has shown indexing is actually
218/// in progress. A raw `u64` (not a `Duration`), so
219/// `config::default_indexing_ready_timeout_seconds` can share this single
220/// source of truth without a config -> translator module dependency;
221/// `navigation::INDEXING_READY_TIMEOUT` derives its `Duration` from this
222/// same value. Overridable via `workspace.indexing_ready_timeout_seconds` in
223/// `mcpls.toml` (#424).
224pub const DEFAULT_INDEXING_READY_TIMEOUT_SECS: u64 = 30;
225
226/// How long a `Progress`-sourced entry's `open` set must stay empty before a
227/// *read* trusts it as settled and reports `Ready` -- the read-path
228/// counterpart to [`PROGRESS_LATCH_IDLE`] below.
229///
230/// Covers the ordinary inter-phase gap in a multi-phase load (e.g. gopls
231/// ending "Setting up workspace" and taking a few seconds to run `go list`
232/// before beginning "Loading packages"): without this, a read landing in
233/// that gap would report `Ready` mid-load. 3s, not 1s: shortening it only
234/// narrows this real gap-covering window while buying nothing, since the
235/// cost is a few seconds of latency paid once per server lifetime and only
236/// by servers that emit progress at all.
237pub const PROGRESS_SETTLE: Duration = Duration::from_secs(3);
238
239/// How long a `Progress`-sourced entry's `open` set must stay empty before a
240/// `begin` write latches it `Ready` forever, instead of re-gating.
241///
242/// Deliberately a *separate*, larger threshold than [`PROGRESS_SETTLE`]
243/// (N1): collapsing them into one 3s threshold reintroduced the exact
244/// regression this two-threshold split fixes. gopls's own multi-phase load
245/// routinely leaves a several-second gap between ending one phase and
246/// beginning the next -- with a single 3s threshold, that ordinary gap
247/// would permanently latch the tracker `Ready` after the *first* phase,
248/// leaving the actual package-loading phase (the part that matters) to run
249/// completely ungated. 30s is long enough that only a genuine end of the
250/// workspace-load phase -- followed by a real per-request sequence
251/// (formatting, one completion) -- crosses it.
252pub const PROGRESS_LATCH_IDLE: Duration = Duration::from_secs(30);
253
254/// Hard cap on the number of distinct tokens tracked in one server's
255/// `open` set (security HIGH).
256///
257/// 64 is far above any real server's concurrent-operation count. Bounds
258/// worst-case memory independent of eviction timing -- a server flooding
259/// many distinct never-ending tokens within a single
260/// `INDEXING_STALENESS_BOUND` window would otherwise grow `open` without
261/// limit before the time-based eviction in `observe_progress` ever gets a
262/// chance to shrink it. Once reached, `observe_progress` stops inserting
263/// and `state` fails open (`Unknown`) rather than trusting an accumulator
264/// this large.
265const PROGRESS_OPEN_CAP: usize = 64;
266
267/// Maximum accepted length, in bytes, of a `ProgressToken::String` before
268/// [`IndexingTracker::observe_progress`] rejects the frame outright
269/// (security HIGH).
270///
271/// Without this, [`PROGRESS_OPEN_CAP`] bounds token *count* but not size --
272/// a server-chosen token string is otherwise bounded only by the
273/// transport's 10 MiB `MAX_CONTENT_LENGTH`, so the count cap alone could
274/// still admit up to `PROGRESS_OPEN_CAP * 10 MiB` of retained token bytes.
275/// A few hundred bytes is far more than any real progress token needs.
276const PROGRESS_TOKEN_MAX_LEN: usize = 256;
277
278/// Per-server workspace-indexing readiness state plus the escape-hatch
279/// policy map, owned by [`NotificationCache`](super::NotificationCache) and
280/// delegated to for every indexing-related call.
281#[derive(Debug, Default)]
282pub struct IndexingTracker {
283 entries: HashMap<ServerId, IndexingEntry>,
284 policies: HashMap<ServerId, IndexingPolicy>,
285}
286
287impl IndexingTracker {
288 /// Create an empty tracker: every server starts at [`IndexingState::Unknown`]
289 /// under [`IndexingPolicy::Auto`].
290 pub(crate) fn new() -> Self {
291 Self::default()
292 }
293
294 /// Configure `server_id`'s [`IndexingPolicy`], set once at server
295 /// registration from `LspServerConfig::indexing` before any signal for
296 /// it can arrive.
297 pub(crate) fn set_policy(&mut self, server_id: ServerId, policy: IndexingPolicy) {
298 self.policies.insert(server_id, policy);
299 }
300
301 fn is_disabled(&self, server_id: &ServerId) -> bool {
302 self.policies.get(server_id) == Some(&IndexingPolicy::Disabled)
303 }
304
305 /// Record a workspace-readiness signal from an unrecognized/custom LSP
306 /// notification (`LspNotification::Other`).
307 ///
308 /// Currently recognizes rust-analyzer's `experimental/serverStatus`
309 /// notification: a `quiescent` boolean of `false` marks the server
310 /// [`IndexingState::Loading`], `true` marks it [`IndexingState::Ready`].
311 /// Any other method, or a `serverStatus` payload missing/malformed the
312 /// field, leaves the current entry entirely untouched -- including its
313 /// `source` -- rather than erroring: a malformed frame must not mark
314 /// this server `ServerStatus`-sourced and thereby permanently disable
315 /// its `$/progress` path via [`Self::observe_progress`]'s stickiness
316 /// check (N6).
317 ///
318 /// Once a `ServerStatus`-sourced entry reaches [`IndexingState::Ready`]
319 /// it never regresses on its own (sticky); a `ServerStatus` signal
320 /// always overwrites a `Progress`-sourced entry regardless of that
321 /// entry's current state, since it is the more authoritative source.
322 pub(crate) fn observe_server_status(
323 &mut self,
324 server_id: &ServerId,
325 method: &str,
326 params: Option<&serde_json::Value>,
327 ) {
328 if self.is_disabled(server_id) {
329 return;
330 }
331 if method != SERVER_STATUS_METHOD {
332 return;
333 }
334 let Some(quiescent) = params
335 .and_then(|p| p.get(QUIESCENT_FIELD))
336 .and_then(serde_json::Value::as_bool)
337 else {
338 return;
339 };
340
341 let sticky_ready = self.entries.get(server_id).is_some_and(|entry| {
342 entry.source == IndexingSignalSource::ServerStatus
343 && entry.state == IndexingState::Ready
344 });
345 if sticky_ready {
346 return;
347 }
348
349 self.entries.insert(
350 server_id.clone(),
351 IndexingEntry {
352 state: if quiescent {
353 IndexingState::Ready
354 } else {
355 IndexingState::Loading
356 },
357 source: IndexingSignalSource::ServerStatus,
358 last_updated: Instant::now(),
359 open: HashMap::new(),
360 empty_since: None,
361 latched: false,
362 },
363 );
364 }
365
366 /// Record a `$/progress` notification toward `server_id`'s readiness.
367 ///
368 /// A no-op once a well-formed `experimental/serverStatus` signal has
369 /// been seen for this server (P2b/S2): that source is authoritative and
370 /// sticky, so a `$/progress` sequence arriving after it (e.g.
371 /// rust-analyzer's own pre-`serverStatus` startup progress, or a later
372 /// per-request sequence) must never resurrect or override it.
373 ///
374 /// Otherwise implements the settle/latch transition (see
375 /// [`PROGRESS_SETTLE`]/[`PROGRESS_LATCH_IDLE`] docs for the two
376 /// thresholds this balances):
377 /// - an unparseable frame (`report`, or missing/unrecognized `kind`) is
378 /// ignored outright, as is a `ProgressToken::String` longer than
379 /// [`PROGRESS_TOKEN_MAX_LEN`] (security HIGH);
380 /// - once `latched`, every later frame is ignored;
381 /// - every accepted frame first evicts tokens whose `begin` is older
382 /// than `INDEXING_STALENESS_BOUND` from `open` (S1) -- otherwise a
383 /// single lost `end` leaves a token in `open` forever, and unrelated
384 /// later frames on the same server keep refreshing `last_updated`
385 /// and masking the entry-wide staleness self-heal that would
386 /// otherwise catch it;
387 /// - `begin`: if `open` is empty and has been for at least
388 /// [`PROGRESS_LATCH_IDLE`], latches instead of reopening -- **the
389 /// `PROGRESS_LATCH_IDLE` check only fires when `empty_since` is
390 /// `Some`**; a fresh entry's very first `begin` has `empty_since ==
391 /// None` ("never been empty", not "empty forever ago") and must
392 /// always open normally, never latch (N2) -- otherwise the entire
393 /// feature silently no-ops on the very first workspace load. Anything
394 /// short of the latch threshold inserts the token (unless
395 /// [`PROGRESS_OPEN_CAP`] is already reached, security HIGH: the frame
396 /// is then dropped rather than grown further) and clears
397 /// `empty_since` normally, including the ordinary inter-phase gap a
398 /// multi-phase load leaves between `PROGRESS_SETTLE` and
399 /// `PROGRESS_LATCH_IDLE` (N1) -- that gap must re-gate, not latch;
400 /// - `end`: sets `empty_since` **only if this token was actually open**
401 /// (`open.remove` returned `Some`) and `open` is now empty (N3) -- an
402 /// unmatched `end` (a dropped `begin`, or one arriving after latching)
403 /// must not fabricate a settle window on an already-empty entry.
404 pub(crate) fn observe_progress(&mut self, server_id: &ServerId, params: &ProgressParams) {
405 if self.is_disabled(server_id) {
406 return;
407 }
408 let Some(kind) = ProgressKind::from_value(¶ms.value) else {
409 return;
410 };
411 if self
412 .entries
413 .get(server_id)
414 .is_some_and(|entry| entry.source == IndexingSignalSource::ServerStatus)
415 {
416 return;
417 }
418 if let ProgressToken::String(token) = ¶ms.token
419 && token.len() > PROGRESS_TOKEN_MAX_LEN
420 {
421 // Reject outright, before PROGRESS_OPEN_CAP could be multiplied by an oversized token (security HIGH).
422 return;
423 }
424
425 let entry = self
426 .entries
427 .entry(server_id.clone())
428 .or_insert_with(IndexingEntry::fresh_progress);
429 if entry.latched {
430 return;
431 }
432
433 let now = Instant::now();
434 // Age out tokens whose `begin` never got a matching `end` (S1) -- see `IndexingEntry::open`'s doc.
435 entry
436 .open
437 .retain(|_, began| began.elapsed() < INDEXING_STALENESS_BOUND);
438
439 match kind {
440 ProgressKind::Begin => {
441 if entry.open.is_empty()
442 && entry
443 .empty_since
444 .is_some_and(|since| since.elapsed() >= PROGRESS_LATCH_IDLE)
445 {
446 entry.latched = true;
447 entry.open.clear();
448 return;
449 }
450 if entry.open.len() < PROGRESS_OPEN_CAP {
451 entry.open.insert(params.token.clone(), now);
452 }
453 // Else at PROGRESS_OPEN_CAP already -- deliberately not inserted; `state` fails open instead.
454 entry.empty_since = None;
455 }
456 ProgressKind::End => {
457 if entry.open.remove(¶ms.token).is_some() && entry.open.is_empty() {
458 entry.empty_since = Some(now);
459 }
460 }
461 }
462 entry.last_updated = now;
463 }
464
465 /// Current tracked workspace-indexing readiness for `server_id`.
466 ///
467 /// Returns [`IndexingState::Unknown`] if no signal has ever been
468 /// observed for `server_id`, or if it is configured
469 /// [`IndexingPolicy::Disabled`].
470 ///
471 /// A `ServerStatus`-sourced `Loading` entry older than
472 /// `INDEXING_STALENESS_BOUND` reads back as `Unknown` rather than
473 /// `Loading`: the self-heal for a `quiescent: true` notification dropped
474 /// by a full channel, or a server that stalled mid-index. A
475 /// `Progress`-sourced entry applies the same staleness bound to
476 /// `last_updated` regardless of `open`/`latched` as a coarse entry-wide
477 /// gate, then: `latched` reads `Ready` unconditionally; `open.len() >=
478 /// PROGRESS_OPEN_CAP` reads `Unknown` (security HIGH -- too many
479 /// concurrently open tokens to track reliably, matching
480 /// `observe_progress`'s refusal to grow `open` further); otherwise any
481 /// token in `open` individually younger than `INDEXING_STALENESS_BOUND`
482 /// reads `Loading` (S1: computed here, not just via write-time
483 /// eviction, so a token that went stale purely with the passage of time
484 /// between writes still stops pinning `Loading` on the very next read).
485 /// If `open` is truly empty, reads `Loading` for [`PROGRESS_SETTLE`]
486 /// after `empty_since`, `Ready` once past that -- but only when
487 /// `empty_since` is `Some`, i.e. a real `begin`-then-`end` transition
488 /// was actually observed to complete. `empty_since == None` reads
489 /// `Unknown` instead of `Ready`: the only way to reach `open` empty
490 /// with `empty_since` still `None` is an entry whose first-ever
491 /// `$/progress` frame was an unmatched `end` (e.g. its `begin` was
492 /// dropped) -- there is no evidence indexing ever finished, so this
493 /// must fail open rather than falsely report readiness. If `open`
494 /// is non-empty but every token in it is individually stale (a lost
495 /// `end` that unrelated later frames kept `last_updated` fresh enough to
496 /// survive the entry-wide gate above), also reads `Unknown` rather than
497 /// running the settle/latch logic meant for a genuinely-observed `end`.
498 pub(crate) fn state(&self, server_id: &ServerId) -> IndexingState {
499 if self.is_disabled(server_id) {
500 return IndexingState::Unknown;
501 }
502 let Some(entry) = self.entries.get(server_id) else {
503 return IndexingState::Unknown;
504 };
505 match entry.source {
506 IndexingSignalSource::ServerStatus => {
507 if entry.state == IndexingState::Loading
508 && entry.last_updated.elapsed() >= INDEXING_STALENESS_BOUND
509 {
510 IndexingState::Unknown
511 } else {
512 entry.state
513 }
514 }
515 IndexingSignalSource::Progress => {
516 if entry.latched {
517 return IndexingState::Ready;
518 }
519 if entry.last_updated.elapsed() >= INDEXING_STALENESS_BOUND {
520 return IndexingState::Unknown;
521 }
522 if entry.open.len() >= PROGRESS_OPEN_CAP {
523 return IndexingState::Unknown;
524 }
525 let has_live_open = entry
526 .open
527 .values()
528 .any(|began| began.elapsed() < INDEXING_STALENESS_BOUND);
529 if has_live_open {
530 return IndexingState::Loading;
531 }
532 if entry.open.is_empty() {
533 match entry.empty_since {
534 Some(since) if since.elapsed() < PROGRESS_SETTLE => IndexingState::Loading,
535 Some(_) => IndexingState::Ready,
536 // No transition has ever been observed to settle.
537 None => IndexingState::Unknown,
538 }
539 } else {
540 IndexingState::Unknown
541 }
542 }
543 }
544 }
545
546 /// Forget `server_id`'s tracked entry, reverting it to
547 /// [`IndexingState::Unknown`]. Does not touch its [`IndexingPolicy`]
548 /// (see [`Self::set_policy`]) -- a respawned process still honors
549 /// whatever the static config said.
550 pub(crate) fn reset(&mut self, server_id: &ServerId) {
551 self.entries.remove(server_id);
552 }
553}
554
555// TODO(critic): mock LSP harness for $/progress sequences -- see follow-up issue
556
557#[cfg(test)]
558#[allow(clippy::unwrap_used)]
559mod tests {
560 use super::*;
561
562 fn test_server() -> ServerId {
563 ServerId::from("test-server")
564 }
565
566 fn progress(kind: &str, token: i32) -> ProgressParams {
567 ProgressParams {
568 token: ProgressToken::Int(token),
569 value: serde_json::json!({ "kind": kind }),
570 }
571 }
572
573 #[test]
574 fn test_state_defaults_unknown() {
575 let tracker = IndexingTracker::new();
576 assert_eq!(tracker.state(&test_server()), IndexingState::Unknown);
577 }
578
579 #[test]
580 fn test_observe_server_status_quiescent_false_marks_loading() {
581 let mut tracker = IndexingTracker::new();
582 let server = test_server();
583 tracker.observe_server_status(
584 &server,
585 "experimental/serverStatus",
586 Some(&serde_json::json!({"quiescent": false})),
587 );
588 assert_eq!(tracker.state(&server), IndexingState::Loading);
589 }
590
591 #[test]
592 fn test_observe_server_status_quiescent_true_marks_ready() {
593 let mut tracker = IndexingTracker::new();
594 let server = test_server();
595 tracker.observe_server_status(
596 &server,
597 "experimental/serverStatus",
598 Some(&serde_json::json!({"quiescent": true})),
599 );
600 assert_eq!(tracker.state(&server), IndexingState::Ready);
601 }
602
603 #[test]
604 fn test_observe_server_status_ignores_unrecognized_method() {
605 let mut tracker = IndexingTracker::new();
606 let server = test_server();
607 tracker.observe_server_status(
608 &server,
609 "window/logMessage",
610 Some(&serde_json::json!({"quiescent": false})),
611 );
612 assert_eq!(tracker.state(&server), IndexingState::Unknown);
613 }
614
615 /// N6: a malformed `serverStatus` payload must not mark the entry's
616 /// source, or it would permanently disable that server's `$/progress`
617 /// path via `observe_progress`'s stickiness check even though no
618 /// well-formed `ServerStatus` signal was ever actually seen.
619 #[test]
620 fn test_malformed_server_status_does_not_mark_source_or_disable_progress() {
621 let mut tracker = IndexingTracker::new();
622 let server = test_server();
623 tracker.observe_server_status(&server, "experimental/serverStatus", None);
624 assert_eq!(tracker.state(&server), IndexingState::Unknown);
625
626 tracker.observe_progress(&server, &progress("begin", 1));
627 assert_eq!(
628 tracker.state(&server),
629 IndexingState::Loading,
630 "the progress path must still be live after a malformed serverStatus payload"
631 );
632 }
633
634 #[test]
635 fn test_observe_server_status_ready_is_sticky() {
636 let mut tracker = IndexingTracker::new();
637 let server = test_server();
638 tracker.observe_server_status(
639 &server,
640 "experimental/serverStatus",
641 Some(&serde_json::json!({"quiescent": true})),
642 );
643 tracker.observe_server_status(
644 &server,
645 "experimental/serverStatus",
646 Some(&serde_json::json!({"quiescent": false})),
647 );
648 assert_eq!(tracker.state(&server), IndexingState::Ready);
649 }
650
651 #[test]
652 fn test_observe_server_status_tracks_servers_independently() {
653 let mut tracker = IndexingTracker::new();
654 let rust = ServerId::from("rust");
655 let python = ServerId::from("python");
656 tracker.observe_server_status(
657 &rust,
658 "experimental/serverStatus",
659 Some(&serde_json::json!({"quiescent": false})),
660 );
661 assert_eq!(tracker.state(&rust), IndexingState::Loading);
662 assert_eq!(tracker.state(&python), IndexingState::Unknown);
663 }
664
665 #[tokio::test(start_paused = true)]
666 async fn test_state_treats_stale_server_status_loading_as_unknown() {
667 let mut tracker = IndexingTracker::new();
668 let server = test_server();
669 tracker.observe_server_status(
670 &server,
671 "experimental/serverStatus",
672 Some(&serde_json::json!({"quiescent": false})),
673 );
674 assert_eq!(tracker.state(&server), IndexingState::Loading);
675
676 tokio::time::advance(INDEXING_STALENESS_BOUND.saturating_sub(Duration::from_secs(1))).await;
677 assert_eq!(tracker.state(&server), IndexingState::Loading);
678
679 tokio::time::advance(Duration::from_secs(2)).await;
680 assert_eq!(tracker.state(&server), IndexingState::Unknown);
681 }
682
683 #[tokio::test(start_paused = true)]
684 async fn test_observe_server_status_refreshes_staleness_clock() {
685 let mut tracker = IndexingTracker::new();
686 let server = test_server();
687 tracker.observe_server_status(
688 &server,
689 "experimental/serverStatus",
690 Some(&serde_json::json!({"quiescent": false})),
691 );
692
693 tokio::time::advance(INDEXING_STALENESS_BOUND.saturating_sub(Duration::from_secs(1))).await;
694 tracker.observe_server_status(
695 &server,
696 "experimental/serverStatus",
697 Some(&serde_json::json!({"quiescent": false})),
698 );
699
700 tokio::time::advance(INDEXING_STALENESS_BOUND.saturating_sub(Duration::from_secs(1))).await;
701 assert_eq!(tracker.state(&server), IndexingState::Loading);
702 }
703
704 /// N2: a fresh entry's very first `begin` must open (`Loading`), never
705 /// latch straight to `Ready` -- the `empty_since == None` ("never been
706 /// empty") case must not satisfy the latch-idle check.
707 #[test]
708 fn test_first_begin_on_fresh_entry_yields_loading_never_ready() {
709 let mut tracker = IndexingTracker::new();
710 let server = test_server();
711 tracker.observe_progress(&server, &progress("begin", 1));
712 assert_eq!(tracker.state(&server), IndexingState::Loading);
713 }
714
715 /// N3: an `end` for a token that was never open must not create (or
716 /// refresh) `empty_since` on an already-empty entry.
717 #[tokio::test(start_paused = true)]
718 async fn test_unmatched_end_does_not_create_empty_since() {
719 let mut tracker = IndexingTracker::new();
720 let server = test_server();
721 // Open and close one legitimate operation first, establishing a real (old) empty_since.
722 tracker.observe_progress(&server, &progress("begin", 1));
723 tracker.observe_progress(&server, &progress("end", 1));
724
725 tokio::time::advance(PROGRESS_SETTLE + Duration::from_secs(1)).await;
726 assert_eq!(
727 tracker.state(&server),
728 IndexingState::Ready,
729 "settled after the real end"
730 );
731
732 // An end for a token that was never open must not refresh empty_since back to "just now".
733 tracker.observe_progress(&server, &progress("end", 99));
734 assert_eq!(
735 tracker.state(&server),
736 IndexingState::Ready,
737 "an unmatched end must not manufacture a fresh settle window"
738 );
739 }
740
741 /// Fix 8 (code-review round 2): a fresh entry whose first-ever
742 /// `$/progress` frame is an `end` (e.g. its matching `begin` was
743 /// dropped by a full lifecycle lane) must read `Unknown`, not `Ready`
744 /// -- `empty_since == None` means "no load-to-quiescent transition has
745 /// ever been observed", not "already settled".
746 #[tokio::test(start_paused = true)]
747 async fn test_end_as_first_ever_frame_reads_unknown_not_ready() {
748 let mut tracker = IndexingTracker::new();
749 let server = test_server();
750 tracker.observe_progress(&server, &progress("end", 1)); // no prior begin
751
752 assert_eq!(
753 tracker.state(&server),
754 IndexingState::Unknown,
755 "an end with no observed prior begin must not read Ready immediately"
756 );
757
758 tokio::time::advance(PROGRESS_SETTLE + Duration::from_secs(1)).await;
759 assert_eq!(
760 tracker.state(&server),
761 IndexingState::Unknown,
762 "must still read Unknown once the settle window would have expired -- there was \
763 never a real empty_since to settle from"
764 );
765 }
766
767 /// `begin` after the `open` set has sat empty for at least
768 /// `PROGRESS_LATCH_IDLE` latches the entry `Ready` forever.
769 #[tokio::test(start_paused = true)]
770 async fn test_begin_after_latch_idle_gap_latches_permanently() {
771 let mut tracker = IndexingTracker::new();
772 let server = test_server();
773 tracker.observe_progress(&server, &progress("begin", 1));
774 tracker.observe_progress(&server, &progress("end", 1));
775
776 tokio::time::advance(PROGRESS_LATCH_IDLE + Duration::from_secs(1)).await;
777 tracker.observe_progress(&server, &progress("begin", 2));
778 assert_eq!(
779 tracker.state(&server),
780 IndexingState::Ready,
781 "a begin after PROGRESS_LATCH_IDLE of quiet must latch, not re-gate"
782 );
783
784 // Latched forever: even ending the "reopened" op, or a later begin, changes nothing.
785 tracker.observe_progress(&server, &progress("end", 2));
786 tracker.observe_progress(&server, &progress("begin", 3));
787 assert_eq!(tracker.state(&server), IndexingState::Ready);
788 }
789
790 /// N1 (gopls regression guard): a `begin` after a gap strictly between
791 /// `PROGRESS_SETTLE` and `PROGRESS_LATCH_IDLE` must re-gate as `Loading`,
792 /// not latch -- this is the ordinary inter-phase gap shape gopls uses.
793 #[tokio::test(start_paused = true)]
794 async fn test_begin_after_mid_gap_regates_and_does_not_latch() {
795 let mut tracker = IndexingTracker::new();
796 let server = test_server();
797 tracker.observe_progress(&server, &progress("begin", 1));
798 tracker.observe_progress(&server, &progress("end", 1));
799
800 tokio::time::advance(Duration::from_secs(10)).await; // between 3s and 30s
801 tracker.observe_progress(&server, &progress("begin", 2));
802 assert_eq!(
803 tracker.state(&server),
804 IndexingState::Loading,
805 "a mid-gap begin must re-gate the next phase, not latch it away"
806 );
807 }
808
809 /// S1 (core): a phase gap shorter than `PROGRESS_SETTLE` must not read
810 /// `Ready` mid-gap.
811 #[tokio::test(start_paused = true)]
812 async fn test_phase_gap_shorter_than_settle_does_not_read_ready() {
813 let mut tracker = IndexingTracker::new();
814 let server = test_server();
815 tracker.observe_progress(&server, &progress("begin", 1));
816 tracker.observe_progress(&server, &progress("end", 1));
817
818 tokio::time::advance(PROGRESS_SETTLE.checked_sub(Duration::from_secs(1)).unwrap()).await;
819 assert_eq!(tracker.state(&server), IndexingState::Loading);
820 }
821
822 #[test]
823 fn test_open_non_empty_reads_loading() {
824 let mut tracker = IndexingTracker::new();
825 let server = test_server();
826 tracker.observe_progress(&server, &progress("begin", 1));
827 tracker.observe_progress(&server, &progress("begin", 2));
828 tracker.observe_progress(&server, &progress("end", 1));
829 assert_eq!(
830 tracker.state(&server),
831 IndexingState::Loading,
832 "token 2 is still open"
833 );
834 }
835
836 /// Multi-server isolation for the `Progress` source, mirroring
837 /// `test_observe_server_status_tracks_servers_independently` for
838 /// `ServerStatus` -- the same `HashMap<ServerId, _>` isolation, proven
839 /// for the other source too.
840 #[test]
841 fn test_observe_progress_tracks_servers_independently() {
842 let mut tracker = IndexingTracker::new();
843 let rust = ServerId::from("rust");
844 let go = ServerId::from("go");
845
846 tracker.observe_progress(&rust, &progress("begin", 1));
847
848 assert_eq!(
849 tracker.state(&rust),
850 IndexingState::Loading,
851 "rust's token is still open"
852 );
853 assert_eq!(
854 tracker.state(&go),
855 IndexingState::Unknown,
856 "go has received no progress signal of its own"
857 );
858 }
859
860 #[tokio::test(start_paused = true)]
861 async fn test_progress_last_updated_past_staleness_bound_reads_unknown() {
862 let mut tracker = IndexingTracker::new();
863 let server = test_server();
864 tracker.observe_progress(&server, &progress("begin", 1));
865
866 tokio::time::advance(INDEXING_STALENESS_BOUND + Duration::from_secs(1)).await;
867 assert_eq!(
868 tracker.state(&server),
869 IndexingState::Unknown,
870 "a single op with no phase boundary for over a minute must fail open"
871 );
872 }
873
874 /// N5: a multi-phase load whose *individual* phases each refresh
875 /// `last_updated` stays gated past the raw 60s bound, as long as no
876 /// single phase itself runs that long uninterrupted.
877 #[tokio::test(start_paused = true)]
878 async fn test_multiphase_load_past_staleness_bound_with_boundaries_stays_loading() {
879 let mut tracker = IndexingTracker::new();
880 let server = test_server();
881 tracker.observe_progress(&server, &progress("begin", 1));
882
883 tokio::time::advance(Duration::from_secs(40)).await;
884 tracker.observe_progress(&server, &progress("end", 1));
885 tracker.observe_progress(&server, &progress("begin", 2));
886
887 tokio::time::advance(Duration::from_secs(40)).await;
888 // 80s total elapsed, but every gap between refreshes stayed under INDEXING_STALENESS_BOUND (60s).
889 assert_eq!(tracker.state(&server), IndexingState::Loading);
890 }
891
892 /// S1 (security HIGH regression guard): a lost `end` must not pin
893 /// `state` at `Loading` for the process lifetime, even while unrelated
894 /// later frames on the *same* server keep refreshing `last_updated` --
895 /// the exact mechanism that let this bug survive the pre-existing
896 /// entry-wide-only staleness self-heal.
897 #[tokio::test(start_paused = true)]
898 async fn test_lost_end_self_heals_after_staleness_bound() {
899 let mut tracker = IndexingTracker::new();
900 let server = test_server();
901 tracker.observe_progress(&server, &progress("begin", 1)); // never gets an `end`
902
903 // Unrelated periodic activity on a different token keeps last_updated fresh.
904 tokio::time::advance(Duration::from_secs(30)).await;
905 tracker.observe_progress(&server, &progress("begin", 2));
906 tracker.observe_progress(&server, &progress("end", 2));
907
908 tokio::time::advance(Duration::from_secs(25)).await; // t=55s
909 tracker.observe_progress(&server, &progress("begin", 3));
910 tracker.observe_progress(&server, &progress("end", 3));
911
912 assert_eq!(
913 tracker.state(&server),
914 IndexingState::Loading,
915 "token 1 is still within its own staleness window at t=55s"
916 );
917
918 // Token 1's own begin (t=0) is now stale (t=61s), though last_updated (t=55s) is not.
919 tokio::time::advance(Duration::from_secs(6)).await; // t=61s
920 assert_eq!(
921 tracker.state(&server),
922 IndexingState::Unknown,
923 "a token whose own begin exceeded INDEXING_STALENESS_BOUND must stop pinning \
924 Loading even while unrelated later frames keep the entry-wide last_updated clock \
925 fresh"
926 );
927 }
928
929 /// Security HIGH: reaching `PROGRESS_OPEN_CAP` distinct open tokens
930 /// must fail open (`Unknown`) instead of growing `open` further or
931 /// staying `Loading` forever.
932 #[test]
933 fn test_open_token_cap_forces_unknown() {
934 let mut tracker = IndexingTracker::new();
935 let server = test_server();
936 for i in 0..(PROGRESS_OPEN_CAP - 1) {
937 tracker.observe_progress(&server, &progress("begin", i32::try_from(i).unwrap()));
938 }
939 assert_eq!(
940 tracker.state(&server),
941 IndexingState::Loading,
942 "PROGRESS_OPEN_CAP - 1 distinct open tokens must still read Loading"
943 );
944
945 tracker.observe_progress(
946 &server,
947 &progress("begin", i32::try_from(PROGRESS_OPEN_CAP - 1).unwrap()),
948 );
949 assert_eq!(
950 tracker.state(&server),
951 IndexingState::Unknown,
952 "reaching PROGRESS_OPEN_CAP must fail open instead of staying Loading forever"
953 );
954 }
955
956 /// Security HIGH: an oversized `ProgressToken::String` must be rejected
957 /// outright, before it can multiply `PROGRESS_OPEN_CAP` by up to
958 /// `MAX_CONTENT_LENGTH` (10 MiB) per token.
959 #[test]
960 fn test_oversized_string_token_is_rejected() {
961 let mut tracker = IndexingTracker::new();
962 let server = test_server();
963 let oversized = ProgressParams {
964 token: ProgressToken::String("x".repeat(PROGRESS_TOKEN_MAX_LEN + 1)),
965 value: serde_json::json!({ "kind": "begin" }),
966 };
967 tracker.observe_progress(&server, &oversized);
968 assert_eq!(
969 tracker.state(&server),
970 IndexingState::Unknown,
971 "an oversized token must be dropped outright, not tracked"
972 );
973 }
974
975 #[test]
976 fn test_malformed_progress_kind_ignored() {
977 let mut tracker = IndexingTracker::new();
978 let server = test_server();
979 tracker.observe_progress(&server, &progress("report", 1));
980 assert_eq!(tracker.state(&server), IndexingState::Unknown);
981
982 let missing_kind = ProgressParams {
983 token: ProgressToken::Int(1),
984 value: serde_json::json!({}),
985 };
986 tracker.observe_progress(&server, &missing_kind);
987 assert_eq!(tracker.state(&server), IndexingState::Unknown);
988 }
989
990 #[test]
991 fn test_disabled_policy_pins_unknown() {
992 let mut tracker = IndexingTracker::new();
993 let server = test_server();
994 tracker.set_policy(server.clone(), IndexingPolicy::Disabled);
995
996 tracker.observe_server_status(
997 &server,
998 "experimental/serverStatus",
999 Some(&serde_json::json!({"quiescent": false})),
1000 );
1001 tracker.observe_progress(&server, &progress("begin", 1));
1002 assert_eq!(tracker.state(&server), IndexingState::Unknown);
1003 }
1004
1005 /// S2 (#421 regression guard): a settled `$/progress` sequence followed
1006 /// by `quiescent: false` must still report `Loading` -- the
1007 /// `ServerStatus` source must be able to override a `Progress` entry in
1008 /// any state, not just while that entry itself reads `Loading`.
1009 #[tokio::test(start_paused = true)]
1010 async fn test_progress_settle_then_quiescent_false_yields_loading() {
1011 let mut tracker = IndexingTracker::new();
1012 let server = test_server();
1013 tracker.observe_progress(&server, &progress("begin", 1));
1014 tracker.observe_progress(&server, &progress("end", 1));
1015 tokio::time::advance(PROGRESS_SETTLE + Duration::from_secs(1)).await;
1016 assert_eq!(tracker.state(&server), IndexingState::Ready);
1017
1018 tracker.observe_server_status(
1019 &server,
1020 "experimental/serverStatus",
1021 Some(&serde_json::json!({"quiescent": false})),
1022 );
1023 assert_eq!(tracker.state(&server), IndexingState::Loading);
1024 }
1025
1026 /// P2b: once a `ServerStatus` signal has been seen, later `$/progress`
1027 /// frames are ignored entirely -- rust-analyzer's own pre-`serverStatus`
1028 /// `$/progress` chatter (or a later per-request sequence) must never
1029 /// resurrect or override the authoritative source.
1030 #[test]
1031 fn test_server_status_entry_ignores_later_progress_frames() {
1032 let mut tracker = IndexingTracker::new();
1033 let server = test_server();
1034 tracker.observe_server_status(
1035 &server,
1036 "experimental/serverStatus",
1037 Some(&serde_json::json!({"quiescent": true})),
1038 );
1039 tracker.observe_progress(&server, &progress("begin", 1));
1040 assert_eq!(tracker.state(&server), IndexingState::Ready);
1041 }
1042
1043 #[test]
1044 fn test_reset_clears_entry_but_not_policy() {
1045 let mut tracker = IndexingTracker::new();
1046 let server = test_server();
1047 tracker.set_policy(server.clone(), IndexingPolicy::Disabled);
1048 tracker.observe_progress(&server, &progress("begin", 1));
1049 tracker.reset(&server);
1050 assert_eq!(
1051 tracker.state(&server),
1052 IndexingState::Unknown,
1053 "policy stays Disabled, so this reads Unknown regardless of the reset entry"
1054 );
1055
1056 let mut auto_tracker = IndexingTracker::new();
1057 auto_tracker.observe_progress(&server, &progress("begin", 1));
1058 auto_tracker.reset(&server);
1059 assert_eq!(auto_tracker.state(&server), IndexingState::Unknown);
1060 }
1061}