Skip to main content

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(&params.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) = &params.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(&params.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}