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 schemars::JsonSchema;
22use serde::{Deserialize, Serialize};
23use tokio::time::Instant;
24
25use crate::config::ServerId;
26use crate::lsp::types::ProgressKind;
27
28/// How a server's tracked [`IndexingState`] is reset.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum IndexingReset {
31    /// Forget everything tracked: the state reverts to
32    /// [`IndexingState::Unknown`] until the next signal.
33    Forget,
34    /// The server was replaced by a new process: a server that has reported a
35    /// recognized signal before reads [`IndexingState::Loading`] for `within`
36    /// or until its replacement reports one, whichever comes first. A server
37    /// that never reported one behaves as after [`Self::Forget`], so it is not
38    /// delayed.
39    AwaitReplacement {
40        /// Upper bound of the pre-signal [`IndexingState::Loading`] reading.
41        within: Duration,
42    },
43}
44
45/// Workspace-indexing readiness of a routed LSP server.
46///
47/// Tracked separately from the `initialize`/`initialized` handshake
48/// completion (`ServerState::is_ready`). A server can finish the handshake
49/// and still be mid-index for tens of seconds afterward, during which
50/// whole-workspace queries (hover, definition, references, completions,
51/// code actions) can silently return an empty/`null` result
52/// indistinguishable from a genuine "nothing found".
53///
54/// `Unknown` and `Ready` are treated identically by
55/// `Translator::wait_for_indexing_ready` (proceed without waiting): a server
56/// that never emits a recognized readiness signal must never be penalized
57/// with an artificial delay, and the only way to tell "no signal ever comes"
58/// apart from "just hasn't reported yet" would require guessing at a
59/// server's protocol support, so both stay unblocked. Only `Loading` -- a
60/// positive signal that indexing is actively in progress -- triggers a
61/// bounded wait.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, JsonSchema)]
63#[serde(rename_all = "snake_case")]
64pub enum IndexingState {
65    /// No recognized workspace-readiness signal has been observed for this
66    /// server yet.
67    #[default]
68    Unknown,
69    /// A recognized signal reported that indexing is still in progress.
70    Loading,
71    /// A recognized signal reported that the initial workspace load is
72    /// complete.
73    Ready,
74}
75
76/// Escape hatch for `IndexingTracker`'s readiness gate, configured per
77/// server via `LspServerConfig::indexing`.
78///
79/// `Disabled` pins `IndexingTracker::state` at [`IndexingState::Unknown`]
80/// unconditionally -- the same fail-open path a server that has simply never
81/// sent a recognized signal already takes -- rather than adding a new branch
82/// to the gate itself. Exists for a server whose `$/progress`/`serverStatus`
83/// shape doesn't fit this tracker's assumptions (Accepted cost 1/3 in the
84/// design): per-server granularity is enough, since `Error::WorkspaceIndexing`
85/// already names the offending `server_id`.
86///
87/// # Examples
88///
89/// ```
90/// use mcpls_core::bridge::IndexingPolicy;
91///
92/// assert_eq!(IndexingPolicy::default(), IndexingPolicy::Auto);
93/// let json = serde_json::to_string(&IndexingPolicy::Disabled).unwrap();
94/// assert_eq!(json, "\"disabled\"");
95/// ```
96#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
97#[serde(rename_all = "snake_case")]
98pub enum IndexingPolicy {
99    /// Track readiness normally from whatever signals the server sends.
100    #[default]
101    Auto,
102    /// Never gate on this server; `indexing_state` always reads `Unknown`.
103    Disabled,
104}
105
106impl IndexingPolicy {
107    /// Whether this is the default [`Self::Auto`] policy.
108    ///
109    /// Used as `LspServerConfig::indexing`'s `skip_serializing_if` (M4):
110    /// without it, the generated default `mcpls.toml` would write `indexing
111    /// = "auto"` into every one of its ~30 builtin server entries, unlike
112    /// every other optional field in that struct, which is omitted at its
113    /// default. Takes `&self`, not `self`, since `skip_serializing_if`
114    /// requires `fn(&T) -> bool` (matching `Option::is_none`'s convention).
115    #[expect(
116        clippy::trivially_copy_pass_by_ref,
117        reason = "serde's `skip_serializing_if` requires `fn(&T) -> bool`"
118    )]
119    #[must_use]
120    pub(crate) const fn is_auto(&self) -> bool {
121        matches!(self, Self::Auto)
122    }
123}
124
125/// Which signal source a server has reported.
126///
127/// Both sources feed the same per-server state, so once one has spoken it must
128/// not be silently overwritten by stale reasoning from the other (S2/#421): a
129/// ready rust-analyzer `serverStatus` reading must not be knocked back to
130/// `Loading` by a later `$/progress` sequence it never asked for, and
131/// conversely a `ServerStatus` signal -- authoritative, since it is
132/// rust-analyzer's own purpose-built readiness notification -- always
133/// overrides whatever a generic `$/progress` sequence guessed, in any state.
134/// See [`IndexingTracker::observe_server_status`] and
135/// [`IndexingTracker::observe_progress`] for the exact precedence rules.
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137enum IndexingSignalSource {
138    /// rust-analyzer's `experimental/serverStatus`.
139    ServerStatus,
140    /// A generic `$/progress` `begin`/`end` sequence.
141    Progress,
142}
143
144/// What is tracked for one server: the wait for a replacement's first signal,
145/// or what a real signal produced.
146#[derive(Debug, Clone)]
147enum TrackedIndexing {
148    /// A replacement process has not reported a signal yet; reads
149    /// [`IndexingState::Loading`] until `since + within` has elapsed.
150    AwaitingFirstSignal { since: Instant, within: Duration },
151    /// A real signal has been observed.
152    Signalled(Signalled),
153}
154
155/// What a `serverStatus` notification reported.
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157enum StatusReading {
158    /// `quiescent: false`: indexing is in progress.
159    Loading,
160    /// `quiescent: true`: the initial workspace load is complete.
161    Ready,
162}
163
164impl From<StatusReading> for IndexingState {
165    fn from(reading: StatusReading) -> Self {
166        match reading {
167            StatusReading::Loading => Self::Loading,
168            StatusReading::Ready => Self::Ready,
169        }
170    }
171}
172
173/// A tracked [`IndexingState`] plus the bookkeeping its source needs.
174///
175/// `last_updated` is the *only* timestamp of either variant (N5): both signal
176/// sources refresh it on every accepted update, so [`IndexingTracker::state`]'s
177/// staleness check (`INDEXING_STALENESS_BOUND`) works identically for either
178/// source without a second, source-specific clock to keep in sync. For a
179/// `Progress` source this also means a multi-phase load whose individual
180/// phases each stay under the staleness bound remains gated for its whole
181/// duration, even if the load as a whole runs past `INDEXING_STALENESS_BOUND`
182/// -- only a single phase that itself never reports a boundary for that long
183/// fails open.
184#[derive(Debug, Clone)]
185enum Signalled {
186    /// Directly authoritative: the reading is the state.
187    ServerStatus {
188        reading: StatusReading,
189        last_updated: Instant,
190    },
191    /// Recomputed on every read from the progress bookkeeping.
192    Progress(ProgressEntry),
193}
194
195/// The bookkeeping of a `$/progress`-sourced readiness.
196#[derive(Debug, Clone)]
197struct ProgressEntry {
198    last_updated: Instant,
199    /// Progress tokens with an outstanding `begin` and no matching `end`
200    /// yet, each paired with when its `begin` was accepted.
201    ///
202    /// A `HashMap`, not a `HashSet` (S1 / security HIGH): a single lost
203    /// `end` -- realistic, since `try_send` drops frames once the
204    /// lifecycle lane is full -- used to leave a token in the set forever,
205    /// pinning [`IndexingTracker::state`] at `Loading` for the process
206    /// lifetime even while unrelated later frames on the same server kept
207    /// refreshing `last_updated` and masking the entry-wide staleness
208    /// self-heal below. `observe_progress` evicts tokens older than
209    /// `INDEXING_STALENESS_BOUND` on every write, and `state` additionally
210    /// treats any individually stale token as absent even between writes,
211    /// so the self-heal no longer depends on `last_updated` alone. Also
212    /// capped at [`PROGRESS_OPEN_CAP`] so worst-case memory is bounded
213    /// independent of eviction timing.
214    open: HashMap<ProgressToken, Instant>,
215    /// When `open` most recently became empty, if it ever has. `None` means
216    /// "never been empty" -- critically different from "became empty a long
217    /// time ago" (N2): both `state` and `latch` reads must treat them
218    /// differently, or a fresh entry's very first `begin` reads/latches as
219    /// though progress had already settled.
220    empty_since: Option<Instant>,
221    /// Once set, `observe_progress` ignores this server's `$/progress`
222    /// stream forever and `state` reads `Ready` unconditionally. Set when a
223    /// `begin` arrives after `open` has sat empty for at least
224    /// `PROGRESS_LATCH_IDLE` -- the generic signal for "the workspace-load
225    /// phase is over and everything after this is a per-request progress
226    /// sequence (formatting, a single completion, ...), not more indexing".
227    latched: bool,
228}
229
230impl ProgressEntry {
231    /// A freshly observed entry, with no outstanding `begin` yet --
232    /// `observe_progress` fills in `open`/`empty_since` right after this
233    /// returns.
234    fn new() -> Self {
235        Self {
236            last_updated: Instant::now(),
237            open: HashMap::new(),
238            empty_since: None,
239            latched: false,
240        }
241    }
242}
243
244/// Custom notification method rust-analyzer uses to report workspace-load
245/// progress; requires opting in at `initialize` (see `LspServer::initialize`).
246const SERVER_STATUS_METHOD: &str = "experimental/serverStatus";
247
248/// Boolean field on a [`SERVER_STATUS_METHOD`] payload: `true` once
249/// rust-analyzer's initial workspace load is complete, `false` while it is
250/// still in progress.
251const QUIESCENT_FIELD: &str = "quiescent";
252
253/// Once a `Loading` entry has gone this long without a fresh signal, reads
254/// stop trusting it -- see [`IndexingTracker::state`].
255///
256/// Deliberately larger than `navigation::INDEXING_READY_TIMEOUT` (30s) and
257/// anchored to the signal's own age, not to any individual caller's wait:
258/// a caller that times out must never affect another concurrent or later
259/// caller's deadline, only the age of the last real signal does. See
260/// `navigation.rs`'s const-asserts for the cross-checked ordering between
261/// this, `INDEXING_READY_TIMEOUT`, and the two constants below.
262pub const INDEXING_STALENESS_BOUND: Duration = Duration::from_mins(1);
263
264/// Default maximum time, in seconds, `Translator::wait_for_indexing_ready`
265/// waits for a routed LSP server to report indexing readiness.
266///
267/// Only takes effect once a readiness signal has shown indexing is actually
268/// in progress. A raw `u64` (not a `Duration`), so
269/// `config::default_indexing_ready_timeout_seconds` can share this single
270/// source of truth without a config -> translator module dependency;
271/// `navigation::INDEXING_READY_TIMEOUT` derives its `Duration` from this
272/// same value. Overridable via `workspace.indexing_ready_timeout_seconds` in
273/// `mcpls.toml` (#424).
274pub const DEFAULT_INDEXING_READY_TIMEOUT_SECS: u64 = 30;
275
276/// How long a `Progress`-sourced entry's `open` set must stay empty before a
277/// *read* trusts it as settled and reports `Ready` -- the read-path
278/// counterpart to [`PROGRESS_LATCH_IDLE`] below.
279///
280/// Covers the ordinary inter-phase gap in a multi-phase load (e.g. gopls
281/// ending "Setting up workspace" and taking a few seconds to run `go list`
282/// before beginning "Loading packages"): without this, a read landing in
283/// that gap would report `Ready` mid-load. 3s, not 1s: shortening it only
284/// narrows this real gap-covering window while buying nothing, since the
285/// cost is a few seconds of latency paid once per server lifetime and only
286/// by servers that emit progress at all.
287pub const PROGRESS_SETTLE: Duration = Duration::from_secs(3);
288
289/// How long a `Progress`-sourced entry's `open` set must stay empty before a
290/// `begin` write latches it `Ready` forever, instead of re-gating.
291///
292/// Deliberately a *separate*, larger threshold than [`PROGRESS_SETTLE`]
293/// (N1): collapsing them into one 3s threshold reintroduced the exact
294/// regression this two-threshold split fixes. gopls's own multi-phase load
295/// routinely leaves a several-second gap between ending one phase and
296/// beginning the next -- with a single 3s threshold, that ordinary gap
297/// would permanently latch the tracker `Ready` after the *first* phase,
298/// leaving the actual package-loading phase (the part that matters) to run
299/// completely ungated. 30s is long enough that only a genuine end of the
300/// workspace-load phase -- followed by a real per-request sequence
301/// (formatting, one completion) -- crosses it.
302pub const PROGRESS_LATCH_IDLE: Duration = Duration::from_secs(30);
303
304/// Hard cap on the number of distinct tokens tracked in one server's
305/// `open` set (security HIGH).
306///
307/// 64 is far above any real server's concurrent-operation count. Bounds
308/// worst-case memory independent of eviction timing -- a server flooding
309/// many distinct never-ending tokens within a single
310/// `INDEXING_STALENESS_BOUND` window would otherwise grow `open` without
311/// limit before the time-based eviction in `observe_progress` ever gets a
312/// chance to shrink it. Once reached, `observe_progress` stops inserting
313/// and `state` fails open (`Unknown`) rather than trusting an accumulator
314/// this large.
315const PROGRESS_OPEN_CAP: usize = 64;
316
317/// Maximum accepted length, in bytes, of a `ProgressToken::String` before
318/// [`IndexingTracker::observe_progress`] rejects the frame outright
319/// (security HIGH).
320///
321/// Without this, [`PROGRESS_OPEN_CAP`] bounds token *count* but not size --
322/// a server-chosen token string is otherwise bounded only by the
323/// transport's 10 MiB `MAX_CONTENT_LENGTH`, so the count cap alone could
324/// still admit up to `PROGRESS_OPEN_CAP * 10 MiB` of retained token bytes.
325/// A few hundred bytes is far more than any real progress token needs.
326const PROGRESS_TOKEN_MAX_LEN: usize = 256;
327
328/// Everything known about one server's indexing readiness, so no per-server
329/// fact can exist without the others.
330#[derive(Debug, Default)]
331struct ServerIndexing {
332    policy: IndexingPolicy,
333    /// The signal source the server last reported; survives
334    /// [`IndexingTracker::reset`] like `policy` and decides whether a
335    /// replacement is awaited ([`IndexingReset::AwaitReplacement`]).
336    seen: Option<IndexingSignalSource>,
337    tracked: Option<TrackedIndexing>,
338}
339
340impl ServerIndexing {
341    fn is_disabled(&self) -> bool {
342        self.policy == IndexingPolicy::Disabled
343    }
344
345    fn log_wait_ended(&self, server_id: &ServerId) {
346        if matches!(
347            self.tracked,
348            Some(TrackedIndexing::AwaitingFirstSignal { .. })
349        ) {
350            tracing::debug!(%server_id, "indexing signal ends the pre-signal wait");
351        }
352    }
353}
354
355/// Per-server workspace-indexing readiness state plus the escape-hatch
356/// policy, owned by [`NotificationCache`](super::NotificationCache) and
357/// delegated to for every indexing-related call.
358#[derive(Debug, Default)]
359pub struct IndexingTracker {
360    servers: HashMap<ServerId, ServerIndexing>,
361}
362
363impl IndexingTracker {
364    /// Create an empty tracker: every server starts at [`IndexingState::Unknown`]
365    /// under [`IndexingPolicy::Auto`].
366    pub(crate) fn new() -> Self {
367        Self::default()
368    }
369
370    /// Configure `server_id`'s [`IndexingPolicy`], set once at server
371    /// registration from `LspServerConfig::indexing` before any signal for
372    /// it can arrive.
373    pub(crate) fn set_policy(&mut self, server_id: ServerId, policy: IndexingPolicy) {
374        self.servers.entry(server_id).or_default().policy = policy;
375    }
376
377    fn is_disabled(&self, server_id: &ServerId) -> bool {
378        self.servers
379            .get(server_id)
380            .is_some_and(ServerIndexing::is_disabled)
381    }
382
383    /// Record a workspace-readiness signal from an unrecognized/custom LSP
384    /// notification (`LspNotification::Other`).
385    ///
386    /// Currently recognizes rust-analyzer's `experimental/serverStatus`
387    /// notification: a `quiescent` boolean of `false` marks the server
388    /// [`IndexingState::Loading`], `true` marks it [`IndexingState::Ready`].
389    /// Any other method, or a `serverStatus` payload missing/malformed the
390    /// field, leaves the current entry entirely untouched -- including its
391    /// source -- rather than erroring: a malformed frame must not mark
392    /// this server `ServerStatus`-sourced and thereby permanently disable
393    /// its `$/progress` path via [`Self::observe_progress`]'s stickiness
394    /// check (N6).
395    ///
396    /// Once a `ServerStatus`-sourced entry reaches [`IndexingState::Ready`]
397    /// it never regresses on its own (sticky); a `ServerStatus` signal
398    /// always overwrites a `Progress`-sourced entry regardless of that
399    /// entry's current state, since it is the more authoritative source.
400    pub(crate) fn observe_server_status(
401        &mut self,
402        server_id: &ServerId,
403        method: &str,
404        params: Option<&serde_json::Value>,
405    ) {
406        if self.is_disabled(server_id) {
407            return;
408        }
409        if method != SERVER_STATUS_METHOD {
410            return;
411        }
412        let Some(quiescent) = params
413            .and_then(|p| p.get(QUIESCENT_FIELD))
414            .and_then(serde_json::Value::as_bool)
415        else {
416            return;
417        };
418
419        let server = self.servers.entry(server_id.clone()).or_default();
420        let sticky_ready = matches!(
421            server.tracked,
422            Some(TrackedIndexing::Signalled(Signalled::ServerStatus {
423                reading: StatusReading::Ready,
424                ..
425            }))
426        );
427        if sticky_ready {
428            return;
429        }
430
431        server.seen = Some(IndexingSignalSource::ServerStatus);
432        server.log_wait_ended(server_id);
433        server.tracked = Some(TrackedIndexing::Signalled(Signalled::ServerStatus {
434            reading: if quiescent {
435                StatusReading::Ready
436            } else {
437                StatusReading::Loading
438            },
439            last_updated: Instant::now(),
440        }));
441    }
442
443    /// Record a `$/progress` notification toward `server_id`'s readiness.
444    ///
445    /// A no-op once a well-formed `experimental/serverStatus` signal has
446    /// been seen for this server (P2b/S2): that source is authoritative and
447    /// sticky, so a `$/progress` sequence arriving after it (e.g.
448    /// rust-analyzer's own pre-`serverStatus` startup progress, or a later
449    /// per-request sequence) must never resurrect or override it.
450    ///
451    /// Otherwise implements the settle/latch transition (see
452    /// [`PROGRESS_SETTLE`]/[`PROGRESS_LATCH_IDLE`] docs for the two
453    /// thresholds this balances):
454    /// - an unparseable frame (`report`, or missing/unrecognized `kind`) is
455    ///   ignored outright, as is a `ProgressToken::String` longer than
456    ///   [`PROGRESS_TOKEN_MAX_LEN`] (security HIGH);
457    /// - once `latched`, every later frame is ignored;
458    /// - every accepted frame first evicts tokens whose `begin` is older
459    ///   than `INDEXING_STALENESS_BOUND` from `open` (S1) -- otherwise a
460    ///   single lost `end` leaves a token in `open` forever, and unrelated
461    ///   later frames on the same server keep refreshing `last_updated`
462    ///   and masking the entry-wide staleness self-heal that would
463    ///   otherwise catch it;
464    /// - `begin`: if `open` is empty and has been for at least
465    ///   [`PROGRESS_LATCH_IDLE`], latches instead of reopening -- **the
466    ///   `PROGRESS_LATCH_IDLE` check only fires when `empty_since` is
467    ///   `Some`**; a fresh entry's very first `begin` has `empty_since ==
468    ///   None` ("never been empty", not "empty forever ago") and must
469    ///   always open normally, never latch (N2) -- otherwise the entire
470    ///   feature silently no-ops on the very first workspace load. Anything
471    ///   short of the latch threshold inserts the token (unless
472    ///   [`PROGRESS_OPEN_CAP`] is already reached, security HIGH: the frame
473    ///   is then dropped rather than grown further) and clears
474    ///   `empty_since` normally, including the ordinary inter-phase gap a
475    ///   multi-phase load leaves between `PROGRESS_SETTLE` and
476    ///   `PROGRESS_LATCH_IDLE` (N1) -- that gap must re-gate, not latch;
477    /// - `end`: sets `empty_since` **only if this token was actually open**
478    ///   (`open.remove` returned `Some`) and `open` is now empty (N3) -- an
479    ///   unmatched `end` (a dropped `begin`, or one arriving after latching)
480    ///   must not fabricate a settle window on an already-empty entry.
481    pub(crate) fn observe_progress(&mut self, server_id: &ServerId, params: &ProgressParams) {
482        if self.is_disabled(server_id) {
483            return;
484        }
485        let Some(kind) = ProgressKind::from_value(&params.value) else {
486            return;
487        };
488        if let Some(server) = self.servers.get(server_id) {
489            match &server.tracked {
490                Some(TrackedIndexing::Signalled(Signalled::ServerStatus { .. })) => return,
491                // Progress frames of a server that reports `serverStatus` are not the signal awaited.
492                Some(TrackedIndexing::AwaitingFirstSignal { .. })
493                    if server.seen != Some(IndexingSignalSource::Progress) =>
494                {
495                    return;
496                }
497                _ => {}
498            }
499        }
500        if let ProgressToken::String(token) = &params.token
501            && token.len() > PROGRESS_TOKEN_MAX_LEN
502        {
503            // Reject outright, before PROGRESS_OPEN_CAP could be multiplied by an oversized token (security HIGH).
504            return;
505        }
506
507        let server = self.servers.entry(server_id.clone()).or_default();
508        server.seen.get_or_insert(IndexingSignalSource::Progress);
509        server.log_wait_ended(server_id);
510        let tracked = server.tracked.get_or_insert_with(|| {
511            TrackedIndexing::Signalled(Signalled::Progress(ProgressEntry::new()))
512        });
513        if matches!(tracked, TrackedIndexing::AwaitingFirstSignal { .. }) {
514            *tracked = TrackedIndexing::Signalled(Signalled::Progress(ProgressEntry::new()));
515        }
516        let TrackedIndexing::Signalled(Signalled::Progress(entry)) = tracked else {
517            return;
518        };
519        if entry.latched {
520            return;
521        }
522
523        let now = Instant::now();
524        // Age out tokens whose `begin` never got a matching `end` (S1) -- see `ProgressEntry::open`'s doc.
525        entry
526            .open
527            .retain(|_, began| began.elapsed() < INDEXING_STALENESS_BOUND);
528
529        match kind {
530            ProgressKind::Begin => {
531                if entry.open.is_empty()
532                    && entry
533                        .empty_since
534                        .is_some_and(|since| since.elapsed() >= PROGRESS_LATCH_IDLE)
535                {
536                    entry.latched = true;
537                    entry.open.clear();
538                    return;
539                }
540                if entry.open.len() < PROGRESS_OPEN_CAP {
541                    entry.open.insert(params.token.clone(), now);
542                }
543                // Else at PROGRESS_OPEN_CAP already -- deliberately not inserted; `state` fails open instead.
544                entry.empty_since = None;
545            }
546            ProgressKind::End => {
547                if entry.open.remove(&params.token).is_some() && entry.open.is_empty() {
548                    entry.empty_since = Some(now);
549                }
550            }
551        }
552        entry.last_updated = now;
553    }
554
555    /// Current tracked workspace-indexing readiness for `server_id`.
556    ///
557    /// Returns [`IndexingState::Unknown`] if no signal has ever been
558    /// observed for `server_id`, or if it is configured
559    /// [`IndexingPolicy::Disabled`].
560    ///
561    /// A `ServerStatus`-sourced `Loading` entry older than
562    /// `INDEXING_STALENESS_BOUND` reads back as `Unknown` rather than
563    /// `Loading`: the self-heal for a `quiescent: true` notification dropped
564    /// by a full channel, or a server that stalled mid-index. A
565    /// `Progress`-sourced entry applies the same staleness bound to
566    /// `last_updated` regardless of `open`/`latched` as a coarse entry-wide
567    /// gate, then: `latched` reads `Ready` unconditionally; `open.len() >=
568    /// PROGRESS_OPEN_CAP` reads `Unknown` (security HIGH -- too many
569    /// concurrently open tokens to track reliably, matching
570    /// `observe_progress`'s refusal to grow `open` further); otherwise any
571    /// token in `open` individually younger than `INDEXING_STALENESS_BOUND`
572    /// reads `Loading` (S1: computed here, not just via write-time
573    /// eviction, so a token that went stale purely with the passage of time
574    /// between writes still stops pinning `Loading` on the very next read).
575    /// If `open` is truly empty, reads `Loading` for [`PROGRESS_SETTLE`]
576    /// after `empty_since`, `Ready` once past that -- but only when
577    /// `empty_since` is `Some`, i.e. a real `begin`-then-`end` transition
578    /// was actually observed to complete. `empty_since == None` reads
579    /// `Unknown` instead of `Ready`: the only way to reach `open` empty
580    /// with `empty_since` still `None` is an entry whose first-ever
581    /// `$/progress` frame was an unmatched `end` (e.g. its `begin` was
582    /// dropped) -- there is no evidence indexing ever finished, so this
583    /// must fail open rather than falsely report readiness. If `open`
584    /// is non-empty but every token in it is individually stale (a lost
585    /// `end` that unrelated later frames kept `last_updated` fresh enough to
586    /// survive the entry-wide gate above), also reads `Unknown` rather than
587    /// running the settle/latch logic meant for a genuinely-observed `end`.
588    pub(crate) fn state(&self, server_id: &ServerId) -> IndexingState {
589        let Some(server) = self.servers.get(server_id) else {
590            return IndexingState::Unknown;
591        };
592        if server.is_disabled() {
593            return IndexingState::Unknown;
594        }
595        match &server.tracked {
596            None => IndexingState::Unknown,
597            Some(TrackedIndexing::AwaitingFirstSignal { since, within }) => {
598                if since.elapsed() < *within {
599                    IndexingState::Loading
600                } else {
601                    IndexingState::Unknown
602                }
603            }
604            Some(TrackedIndexing::Signalled(signalled)) => Self::signalled_state(signalled),
605        }
606    }
607
608    fn signalled_state(signalled: &Signalled) -> IndexingState {
609        match signalled {
610            Signalled::ServerStatus {
611                reading,
612                last_updated,
613            } => {
614                if *reading == StatusReading::Loading
615                    && last_updated.elapsed() >= INDEXING_STALENESS_BOUND
616                {
617                    IndexingState::Unknown
618                } else {
619                    (*reading).into()
620                }
621            }
622            Signalled::Progress(entry) => Self::progress_state(entry),
623        }
624    }
625
626    fn progress_state(entry: &ProgressEntry) -> IndexingState {
627        if entry.latched {
628            return IndexingState::Ready;
629        }
630        if entry.last_updated.elapsed() >= INDEXING_STALENESS_BOUND {
631            return IndexingState::Unknown;
632        }
633        if entry.open.len() >= PROGRESS_OPEN_CAP {
634            return IndexingState::Unknown;
635        }
636        let has_live_open = entry
637            .open
638            .values()
639            .any(|began| began.elapsed() < INDEXING_STALENESS_BOUND);
640        if has_live_open {
641            return IndexingState::Loading;
642        }
643        if entry.open.is_empty() {
644            match entry.empty_since {
645                Some(since) if since.elapsed() < PROGRESS_SETTLE => IndexingState::Loading,
646                Some(_) => IndexingState::Ready,
647                // No transition has ever been observed to settle.
648                None => IndexingState::Unknown,
649            }
650        } else {
651            IndexingState::Unknown
652        }
653    }
654
655    /// Reset `server_id`'s tracked entry as `reset` says. Touches neither its
656    /// [`IndexingPolicy`] (see [`Self::set_policy`]) -- a respawned process
657    /// still honors whatever the static config said -- nor which signal
658    /// source it has reported before.
659    pub(crate) fn reset(&mut self, server_id: &ServerId, reset: IndexingReset) {
660        let Some(server) = self.servers.get_mut(server_id) else {
661            return;
662        };
663        server.tracked = None;
664        let IndexingReset::AwaitReplacement { within } = reset else {
665            return;
666        };
667        if server.is_disabled() || server.seen.is_none() {
668            return;
669        }
670        tracing::debug!(%server_id, ?within, "replacement awaits its first indexing signal");
671        server.tracked = Some(TrackedIndexing::AwaitingFirstSignal {
672            since: Instant::now(),
673            within,
674        });
675    }
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681
682    fn test_server() -> ServerId {
683        ServerId::from_static("test-server")
684    }
685
686    fn progress(kind: &str, token: i32) -> ProgressParams {
687        ProgressParams {
688            token: ProgressToken::Int(token),
689            value: serde_json::json!({ "kind": kind }),
690        }
691    }
692
693    #[test]
694    fn test_state_defaults_unknown() {
695        let tracker = IndexingTracker::new();
696        assert_eq!(tracker.state(&test_server()), IndexingState::Unknown);
697    }
698
699    #[test]
700    fn test_observe_server_status_quiescent_false_marks_loading() {
701        let mut tracker = IndexingTracker::new();
702        let server = test_server();
703        tracker.observe_server_status(
704            &server,
705            "experimental/serverStatus",
706            Some(&serde_json::json!({"quiescent": false})),
707        );
708        assert_eq!(tracker.state(&server), IndexingState::Loading);
709    }
710
711    #[test]
712    fn test_observe_server_status_quiescent_true_marks_ready() {
713        let mut tracker = IndexingTracker::new();
714        let server = test_server();
715        tracker.observe_server_status(
716            &server,
717            "experimental/serverStatus",
718            Some(&serde_json::json!({"quiescent": true})),
719        );
720        assert_eq!(tracker.state(&server), IndexingState::Ready);
721    }
722
723    #[test]
724    fn test_observe_server_status_ignores_unrecognized_method() {
725        let mut tracker = IndexingTracker::new();
726        let server = test_server();
727        tracker.observe_server_status(
728            &server,
729            "window/logMessage",
730            Some(&serde_json::json!({"quiescent": false})),
731        );
732        assert_eq!(tracker.state(&server), IndexingState::Unknown);
733    }
734
735    /// N6: a malformed `serverStatus` payload must not mark the entry's
736    /// source, or it would permanently disable that server's `$/progress`
737    /// path via `observe_progress`'s stickiness check even though no
738    /// well-formed `ServerStatus` signal was ever actually seen.
739    #[test]
740    fn test_malformed_server_status_does_not_mark_source_or_disable_progress() {
741        let mut tracker = IndexingTracker::new();
742        let server = test_server();
743        tracker.observe_server_status(&server, "experimental/serverStatus", None);
744        assert_eq!(tracker.state(&server), IndexingState::Unknown);
745
746        tracker.observe_progress(&server, &progress("begin", 1));
747        assert_eq!(
748            tracker.state(&server),
749            IndexingState::Loading,
750            "the progress path must still be live after a malformed serverStatus payload"
751        );
752    }
753
754    #[test]
755    fn test_observe_server_status_ready_is_sticky() {
756        let mut tracker = IndexingTracker::new();
757        let server = test_server();
758        tracker.observe_server_status(
759            &server,
760            "experimental/serverStatus",
761            Some(&serde_json::json!({"quiescent": true})),
762        );
763        tracker.observe_server_status(
764            &server,
765            "experimental/serverStatus",
766            Some(&serde_json::json!({"quiescent": false})),
767        );
768        assert_eq!(tracker.state(&server), IndexingState::Ready);
769    }
770
771    #[test]
772    fn test_observe_server_status_tracks_servers_independently() {
773        let mut tracker = IndexingTracker::new();
774        let rust = ServerId::from_static("rust");
775        let python = ServerId::from_static("python");
776        tracker.observe_server_status(
777            &rust,
778            "experimental/serverStatus",
779            Some(&serde_json::json!({"quiescent": false})),
780        );
781        assert_eq!(tracker.state(&rust), IndexingState::Loading);
782        assert_eq!(tracker.state(&python), IndexingState::Unknown);
783    }
784
785    #[tokio::test(start_paused = true)]
786    async fn test_state_treats_stale_server_status_loading_as_unknown() {
787        let mut tracker = IndexingTracker::new();
788        let server = test_server();
789        tracker.observe_server_status(
790            &server,
791            "experimental/serverStatus",
792            Some(&serde_json::json!({"quiescent": false})),
793        );
794        assert_eq!(tracker.state(&server), IndexingState::Loading);
795
796        tokio::time::advance(INDEXING_STALENESS_BOUND.saturating_sub(Duration::from_secs(1))).await;
797        assert_eq!(tracker.state(&server), IndexingState::Loading);
798
799        tokio::time::advance(Duration::from_secs(2)).await;
800        assert_eq!(tracker.state(&server), IndexingState::Unknown);
801    }
802
803    #[tokio::test(start_paused = true)]
804    async fn test_observe_server_status_refreshes_staleness_clock() {
805        let mut tracker = IndexingTracker::new();
806        let server = test_server();
807        tracker.observe_server_status(
808            &server,
809            "experimental/serverStatus",
810            Some(&serde_json::json!({"quiescent": false})),
811        );
812
813        tokio::time::advance(INDEXING_STALENESS_BOUND.saturating_sub(Duration::from_secs(1))).await;
814        tracker.observe_server_status(
815            &server,
816            "experimental/serverStatus",
817            Some(&serde_json::json!({"quiescent": false})),
818        );
819
820        tokio::time::advance(INDEXING_STALENESS_BOUND.saturating_sub(Duration::from_secs(1))).await;
821        assert_eq!(tracker.state(&server), IndexingState::Loading);
822    }
823
824    /// N2: a fresh entry's very first `begin` must open (`Loading`), never
825    /// latch straight to `Ready` -- the `empty_since == None` ("never been
826    /// empty") case must not satisfy the latch-idle check.
827    #[test]
828    fn test_first_begin_on_fresh_entry_yields_loading_never_ready() {
829        let mut tracker = IndexingTracker::new();
830        let server = test_server();
831        tracker.observe_progress(&server, &progress("begin", 1));
832        assert_eq!(tracker.state(&server), IndexingState::Loading);
833    }
834
835    /// N3: an `end` for a token that was never open must not create (or
836    /// refresh) `empty_since` on an already-empty entry.
837    #[tokio::test(start_paused = true)]
838    async fn test_unmatched_end_does_not_create_empty_since() {
839        let mut tracker = IndexingTracker::new();
840        let server = test_server();
841        // Open and close one legitimate operation first, establishing a real (old) empty_since.
842        tracker.observe_progress(&server, &progress("begin", 1));
843        tracker.observe_progress(&server, &progress("end", 1));
844
845        tokio::time::advance(PROGRESS_SETTLE + Duration::from_secs(1)).await;
846        assert_eq!(
847            tracker.state(&server),
848            IndexingState::Ready,
849            "settled after the real end"
850        );
851
852        // An end for a token that was never open must not refresh empty_since back to "just now".
853        tracker.observe_progress(&server, &progress("end", 99));
854        assert_eq!(
855            tracker.state(&server),
856            IndexingState::Ready,
857            "an unmatched end must not manufacture a fresh settle window"
858        );
859    }
860
861    /// Fix 8 (code-review round 2): a fresh entry whose first-ever
862    /// `$/progress` frame is an `end` (e.g. its matching `begin` was
863    /// dropped by a full lifecycle lane) must read `Unknown`, not `Ready`
864    /// -- `empty_since == None` means "no load-to-quiescent transition has
865    /// ever been observed", not "already settled".
866    #[tokio::test(start_paused = true)]
867    async fn test_end_as_first_ever_frame_reads_unknown_not_ready() {
868        let mut tracker = IndexingTracker::new();
869        let server = test_server();
870        tracker.observe_progress(&server, &progress("end", 1)); // no prior begin
871
872        assert_eq!(
873            tracker.state(&server),
874            IndexingState::Unknown,
875            "an end with no observed prior begin must not read Ready immediately"
876        );
877
878        tokio::time::advance(PROGRESS_SETTLE + Duration::from_secs(1)).await;
879        assert_eq!(
880            tracker.state(&server),
881            IndexingState::Unknown,
882            "must still read Unknown once the settle window would have expired -- there was \
883             never a real empty_since to settle from"
884        );
885    }
886
887    /// `begin` after the `open` set has sat empty for at least
888    /// `PROGRESS_LATCH_IDLE` latches the entry `Ready` forever.
889    #[tokio::test(start_paused = true)]
890    async fn test_begin_after_latch_idle_gap_latches_permanently() {
891        let mut tracker = IndexingTracker::new();
892        let server = test_server();
893        tracker.observe_progress(&server, &progress("begin", 1));
894        tracker.observe_progress(&server, &progress("end", 1));
895
896        tokio::time::advance(PROGRESS_LATCH_IDLE + Duration::from_secs(1)).await;
897        tracker.observe_progress(&server, &progress("begin", 2));
898        assert_eq!(
899            tracker.state(&server),
900            IndexingState::Ready,
901            "a begin after PROGRESS_LATCH_IDLE of quiet must latch, not re-gate"
902        );
903
904        // Latched forever: even ending the "reopened" op, or a later begin, changes nothing.
905        tracker.observe_progress(&server, &progress("end", 2));
906        tracker.observe_progress(&server, &progress("begin", 3));
907        assert_eq!(tracker.state(&server), IndexingState::Ready);
908    }
909
910    /// N1 (gopls regression guard): a `begin` after a gap strictly between
911    /// `PROGRESS_SETTLE` and `PROGRESS_LATCH_IDLE` must re-gate as `Loading`,
912    /// not latch -- this is the ordinary inter-phase gap shape gopls uses.
913    #[tokio::test(start_paused = true)]
914    async fn test_begin_after_mid_gap_regates_and_does_not_latch() {
915        let mut tracker = IndexingTracker::new();
916        let server = test_server();
917        tracker.observe_progress(&server, &progress("begin", 1));
918        tracker.observe_progress(&server, &progress("end", 1));
919
920        tokio::time::advance(Duration::from_secs(10)).await; // between 3s and 30s
921        tracker.observe_progress(&server, &progress("begin", 2));
922        assert_eq!(
923            tracker.state(&server),
924            IndexingState::Loading,
925            "a mid-gap begin must re-gate the next phase, not latch it away"
926        );
927    }
928
929    /// S1 (core): a phase gap shorter than `PROGRESS_SETTLE` must not read
930    /// `Ready` mid-gap.
931    #[tokio::test(start_paused = true)]
932    async fn test_phase_gap_shorter_than_settle_does_not_read_ready() {
933        let mut tracker = IndexingTracker::new();
934        let server = test_server();
935        tracker.observe_progress(&server, &progress("begin", 1));
936        tracker.observe_progress(&server, &progress("end", 1));
937
938        tokio::time::advance(PROGRESS_SETTLE.checked_sub(Duration::from_secs(1)).unwrap()).await;
939        assert_eq!(tracker.state(&server), IndexingState::Loading);
940    }
941
942    #[test]
943    fn test_open_non_empty_reads_loading() {
944        let mut tracker = IndexingTracker::new();
945        let server = test_server();
946        tracker.observe_progress(&server, &progress("begin", 1));
947        tracker.observe_progress(&server, &progress("begin", 2));
948        tracker.observe_progress(&server, &progress("end", 1));
949        assert_eq!(
950            tracker.state(&server),
951            IndexingState::Loading,
952            "token 2 is still open"
953        );
954    }
955
956    /// Multi-server isolation for the `Progress` source, mirroring
957    /// `test_observe_server_status_tracks_servers_independently` for
958    /// `ServerStatus` -- the same `HashMap<ServerId, _>` isolation, proven
959    /// for the other source too.
960    #[test]
961    fn test_observe_progress_tracks_servers_independently() {
962        let mut tracker = IndexingTracker::new();
963        let rust = ServerId::from_static("rust");
964        let go = ServerId::from_static("go");
965
966        tracker.observe_progress(&rust, &progress("begin", 1));
967
968        assert_eq!(
969            tracker.state(&rust),
970            IndexingState::Loading,
971            "rust's token is still open"
972        );
973        assert_eq!(
974            tracker.state(&go),
975            IndexingState::Unknown,
976            "go has received no progress signal of its own"
977        );
978    }
979
980    #[tokio::test(start_paused = true)]
981    async fn test_progress_last_updated_past_staleness_bound_reads_unknown() {
982        let mut tracker = IndexingTracker::new();
983        let server = test_server();
984        tracker.observe_progress(&server, &progress("begin", 1));
985
986        tokio::time::advance(INDEXING_STALENESS_BOUND + Duration::from_secs(1)).await;
987        assert_eq!(
988            tracker.state(&server),
989            IndexingState::Unknown,
990            "a single op with no phase boundary for over a minute must fail open"
991        );
992    }
993
994    /// N5: a multi-phase load whose *individual* phases each refresh
995    /// `last_updated` stays gated past the raw 60s bound, as long as no
996    /// single phase itself runs that long uninterrupted.
997    #[tokio::test(start_paused = true)]
998    async fn test_multiphase_load_past_staleness_bound_with_boundaries_stays_loading() {
999        let mut tracker = IndexingTracker::new();
1000        let server = test_server();
1001        tracker.observe_progress(&server, &progress("begin", 1));
1002
1003        tokio::time::advance(Duration::from_secs(40)).await;
1004        tracker.observe_progress(&server, &progress("end", 1));
1005        tracker.observe_progress(&server, &progress("begin", 2));
1006
1007        tokio::time::advance(Duration::from_secs(40)).await;
1008        // 80s total elapsed, but every gap between refreshes stayed under INDEXING_STALENESS_BOUND (60s).
1009        assert_eq!(tracker.state(&server), IndexingState::Loading);
1010    }
1011
1012    /// S1 (security HIGH regression guard): a lost `end` must not pin
1013    /// `state` at `Loading` for the process lifetime, even while unrelated
1014    /// later frames on the *same* server keep refreshing `last_updated` --
1015    /// the exact mechanism that let this bug survive the pre-existing
1016    /// entry-wide-only staleness self-heal.
1017    #[tokio::test(start_paused = true)]
1018    async fn test_lost_end_self_heals_after_staleness_bound() {
1019        let mut tracker = IndexingTracker::new();
1020        let server = test_server();
1021        tracker.observe_progress(&server, &progress("begin", 1)); // never gets an `end`
1022
1023        // Unrelated periodic activity on a different token keeps last_updated fresh.
1024        tokio::time::advance(Duration::from_secs(30)).await;
1025        tracker.observe_progress(&server, &progress("begin", 2));
1026        tracker.observe_progress(&server, &progress("end", 2));
1027
1028        tokio::time::advance(Duration::from_secs(25)).await; // t=55s
1029        tracker.observe_progress(&server, &progress("begin", 3));
1030        tracker.observe_progress(&server, &progress("end", 3));
1031
1032        assert_eq!(
1033            tracker.state(&server),
1034            IndexingState::Loading,
1035            "token 1 is still within its own staleness window at t=55s"
1036        );
1037
1038        // Token 1's own begin (t=0) is now stale (t=61s), though last_updated (t=55s) is not.
1039        tokio::time::advance(Duration::from_secs(6)).await; // t=61s
1040        assert_eq!(
1041            tracker.state(&server),
1042            IndexingState::Unknown,
1043            "a token whose own begin exceeded INDEXING_STALENESS_BOUND must stop pinning \
1044             Loading even while unrelated later frames keep the entry-wide last_updated clock \
1045             fresh"
1046        );
1047    }
1048
1049    /// Security HIGH: reaching `PROGRESS_OPEN_CAP` distinct open tokens
1050    /// must fail open (`Unknown`) instead of growing `open` further or
1051    /// staying `Loading` forever.
1052    #[test]
1053    fn test_open_token_cap_forces_unknown() {
1054        let mut tracker = IndexingTracker::new();
1055        let server = test_server();
1056        for i in 0..(PROGRESS_OPEN_CAP - 1) {
1057            tracker.observe_progress(&server, &progress("begin", i32::try_from(i).unwrap()));
1058        }
1059        assert_eq!(
1060            tracker.state(&server),
1061            IndexingState::Loading,
1062            "PROGRESS_OPEN_CAP - 1 distinct open tokens must still read Loading"
1063        );
1064
1065        tracker.observe_progress(
1066            &server,
1067            &progress("begin", i32::try_from(PROGRESS_OPEN_CAP - 1).unwrap()),
1068        );
1069        assert_eq!(
1070            tracker.state(&server),
1071            IndexingState::Unknown,
1072            "reaching PROGRESS_OPEN_CAP must fail open instead of staying Loading forever"
1073        );
1074    }
1075
1076    /// Security HIGH: an oversized `ProgressToken::String` must be rejected
1077    /// outright, before it can multiply `PROGRESS_OPEN_CAP` by up to
1078    /// `MAX_CONTENT_LENGTH` (10 MiB) per token.
1079    #[test]
1080    fn test_oversized_string_token_is_rejected() {
1081        let mut tracker = IndexingTracker::new();
1082        let server = test_server();
1083        let oversized = ProgressParams {
1084            token: ProgressToken::String("x".repeat(PROGRESS_TOKEN_MAX_LEN + 1)),
1085            value: serde_json::json!({ "kind": "begin" }),
1086        };
1087        tracker.observe_progress(&server, &oversized);
1088        assert_eq!(
1089            tracker.state(&server),
1090            IndexingState::Unknown,
1091            "an oversized token must be dropped outright, not tracked"
1092        );
1093    }
1094
1095    #[test]
1096    fn test_malformed_progress_kind_ignored() {
1097        let mut tracker = IndexingTracker::new();
1098        let server = test_server();
1099        tracker.observe_progress(&server, &progress("report", 1));
1100        assert_eq!(tracker.state(&server), IndexingState::Unknown);
1101
1102        let missing_kind = ProgressParams {
1103            token: ProgressToken::Int(1),
1104            value: serde_json::json!({}),
1105        };
1106        tracker.observe_progress(&server, &missing_kind);
1107        assert_eq!(tracker.state(&server), IndexingState::Unknown);
1108    }
1109
1110    #[test]
1111    fn test_disabled_policy_pins_unknown() {
1112        let mut tracker = IndexingTracker::new();
1113        let server = test_server();
1114        tracker.set_policy(server.clone(), IndexingPolicy::Disabled);
1115
1116        tracker.observe_server_status(
1117            &server,
1118            "experimental/serverStatus",
1119            Some(&serde_json::json!({"quiescent": false})),
1120        );
1121        tracker.observe_progress(&server, &progress("begin", 1));
1122        assert_eq!(tracker.state(&server), IndexingState::Unknown);
1123    }
1124
1125    /// S2 (#421 regression guard): a settled `$/progress` sequence followed
1126    /// by `quiescent: false` must still report `Loading` -- the
1127    /// `ServerStatus` source must be able to override a `Progress` entry in
1128    /// any state, not just while that entry itself reads `Loading`.
1129    #[tokio::test(start_paused = true)]
1130    async fn test_progress_settle_then_quiescent_false_yields_loading() {
1131        let mut tracker = IndexingTracker::new();
1132        let server = test_server();
1133        tracker.observe_progress(&server, &progress("begin", 1));
1134        tracker.observe_progress(&server, &progress("end", 1));
1135        tokio::time::advance(PROGRESS_SETTLE + Duration::from_secs(1)).await;
1136        assert_eq!(tracker.state(&server), IndexingState::Ready);
1137
1138        tracker.observe_server_status(
1139            &server,
1140            "experimental/serverStatus",
1141            Some(&serde_json::json!({"quiescent": false})),
1142        );
1143        assert_eq!(tracker.state(&server), IndexingState::Loading);
1144    }
1145
1146    /// P2b: once a `ServerStatus` signal has been seen, later `$/progress`
1147    /// frames are ignored entirely -- rust-analyzer's own pre-`serverStatus`
1148    /// `$/progress` chatter (or a later per-request sequence) must never
1149    /// resurrect or override the authoritative source.
1150    #[test]
1151    fn test_server_status_entry_ignores_later_progress_frames() {
1152        let mut tracker = IndexingTracker::new();
1153        let server = test_server();
1154        tracker.observe_server_status(
1155            &server,
1156            "experimental/serverStatus",
1157            Some(&serde_json::json!({"quiescent": true})),
1158        );
1159        tracker.observe_progress(&server, &progress("begin", 1));
1160        assert_eq!(tracker.state(&server), IndexingState::Ready);
1161    }
1162
1163    #[test]
1164    fn test_reset_clears_entry_but_not_policy() {
1165        let mut tracker = IndexingTracker::new();
1166        let server = test_server();
1167        tracker.set_policy(server.clone(), IndexingPolicy::Disabled);
1168        tracker.observe_progress(&server, &progress("begin", 1));
1169        tracker.reset(&server, IndexingReset::Forget);
1170        assert_eq!(
1171            tracker.state(&server),
1172            IndexingState::Unknown,
1173            "policy stays Disabled, so this reads Unknown regardless of the reset entry"
1174        );
1175
1176        let mut auto_tracker = IndexingTracker::new();
1177        auto_tracker.observe_progress(&server, &progress("begin", 1));
1178        auto_tracker.reset(&server, IndexingReset::Forget);
1179        assert_eq!(auto_tracker.state(&server), IndexingState::Unknown);
1180    }
1181
1182    const WITHIN: Duration = Duration::from_secs(30);
1183
1184    fn server_status(tracker: &mut IndexingTracker, server: &ServerId, quiescent: bool) {
1185        tracker.observe_server_status(
1186            server,
1187            "experimental/serverStatus",
1188            Some(&serde_json::json!({ "quiescent": quiescent })),
1189        );
1190    }
1191
1192    fn await_replacement(tracker: &mut IndexingTracker, server: &ServerId) {
1193        tracker.reset(server, IndexingReset::AwaitReplacement { within: WITHIN });
1194    }
1195
1196    #[test]
1197    fn test_await_replacement_without_history_stays_unknown() {
1198        let mut tracker = IndexingTracker::new();
1199        let server = test_server();
1200        await_replacement(&mut tracker, &server);
1201        assert_eq!(tracker.state(&server), IndexingState::Unknown);
1202    }
1203
1204    #[test]
1205    fn test_await_replacement_after_ready_reads_loading() {
1206        let mut tracker = IndexingTracker::new();
1207        let server = test_server();
1208        server_status(&mut tracker, &server, true);
1209        await_replacement(&mut tracker, &server);
1210        assert_eq!(tracker.state(&server), IndexingState::Loading);
1211    }
1212
1213    #[test]
1214    fn test_await_replacement_after_loading_reads_loading() {
1215        let mut tracker = IndexingTracker::new();
1216        let server = test_server();
1217        server_status(&mut tracker, &server, false);
1218        await_replacement(&mut tracker, &server);
1219        assert_eq!(tracker.state(&server), IndexingState::Loading);
1220    }
1221
1222    #[tokio::test(start_paused = true)]
1223    async fn test_await_replacement_ends_after_within() {
1224        let mut tracker = IndexingTracker::new();
1225        let server = test_server();
1226        server_status(&mut tracker, &server, true);
1227        await_replacement(&mut tracker, &server);
1228
1229        tokio::time::advance(WITHIN.saturating_sub(Duration::from_secs(1))).await;
1230        assert_eq!(tracker.state(&server), IndexingState::Loading);
1231        tokio::time::advance(Duration::from_secs(2)).await;
1232        assert_eq!(tracker.state(&server), IndexingState::Unknown);
1233    }
1234
1235    #[test]
1236    fn test_await_replacement_ready_signal_ends_wait_at_once() {
1237        let mut tracker = IndexingTracker::new();
1238        let server = test_server();
1239        server_status(&mut tracker, &server, true);
1240        await_replacement(&mut tracker, &server);
1241        server_status(&mut tracker, &server, true);
1242        assert_eq!(tracker.state(&server), IndexingState::Ready);
1243    }
1244
1245    #[test]
1246    fn test_await_replacement_loading_signal_is_a_real_loading_entry() {
1247        let mut tracker = IndexingTracker::new();
1248        let server = test_server();
1249        server_status(&mut tracker, &server, true);
1250        await_replacement(&mut tracker, &server);
1251        server_status(&mut tracker, &server, false);
1252        assert_eq!(tracker.state(&server), IndexingState::Loading);
1253        server_status(&mut tracker, &server, true);
1254        assert_eq!(tracker.state(&server), IndexingState::Ready);
1255    }
1256
1257    #[test]
1258    fn test_await_replacement_ignores_progress_of_a_server_status_server() {
1259        let mut tracker = IndexingTracker::new();
1260        let server = test_server();
1261        server_status(&mut tracker, &server, true);
1262        await_replacement(&mut tracker, &server);
1263        tracker.observe_progress(&server, &progress("begin", 1));
1264        tracker.observe_progress(&server, &progress("end", 1));
1265        assert_eq!(
1266            tracker.state(&server),
1267            IndexingState::Loading,
1268            "startup progress must not end the wait for the serverStatus signal"
1269        );
1270    }
1271
1272    #[test]
1273    fn test_await_replacement_progress_server_is_replaced_by_progress_entry() {
1274        let mut tracker = IndexingTracker::new();
1275        let server = test_server();
1276        tracker.observe_progress(&server, &progress("begin", 1));
1277        tracker.observe_progress(&server, &progress("end", 1));
1278        await_replacement(&mut tracker, &server);
1279        assert_eq!(tracker.state(&server), IndexingState::Loading);
1280
1281        tracker.observe_progress(&server, &progress("begin", 2));
1282        assert_eq!(tracker.state(&server), IndexingState::Loading);
1283        tracker.observe_progress(&server, &progress("end", 2));
1284        assert_eq!(
1285            tracker.state(&server),
1286            IndexingState::Loading,
1287            "settle window of the fresh progress entry"
1288        );
1289    }
1290
1291    #[test]
1292    fn test_await_replacement_twice_restarts_the_wait() {
1293        let mut tracker = IndexingTracker::new();
1294        let server = test_server();
1295        server_status(&mut tracker, &server, true);
1296        await_replacement(&mut tracker, &server);
1297        await_replacement(&mut tracker, &server);
1298        assert_eq!(tracker.state(&server), IndexingState::Loading);
1299        tracker.reset(&server, IndexingReset::Forget);
1300        assert_eq!(tracker.state(&server), IndexingState::Unknown);
1301    }
1302
1303    #[test]
1304    fn test_await_replacement_respects_disabled_policy() {
1305        let mut tracker = IndexingTracker::new();
1306        let server = test_server();
1307        tracker.set_policy(server.clone(), IndexingPolicy::Disabled);
1308        await_replacement(&mut tracker, &server);
1309        assert_eq!(tracker.state(&server), IndexingState::Unknown);
1310    }
1311
1312    #[test]
1313    fn test_await_replacement_tracks_servers_independently() {
1314        let mut tracker = IndexingTracker::new();
1315        let rust = ServerId::from_static("rust");
1316        let python = ServerId::from_static("python");
1317        server_status(&mut tracker, &rust, true);
1318        server_status(&mut tracker, &python, true);
1319        await_replacement(&mut tracker, &rust);
1320        assert_eq!(tracker.state(&rust), IndexingState::Loading);
1321        assert_eq!(tracker.state(&python), IndexingState::Ready);
1322    }
1323
1324    /// End-to-end `$/progress` handling over a real [`LspClient`] on a mock
1325    /// transport: the server-initiated `create` request, the lane routing of
1326    /// `begin`/`report`/`end`, and the tracked [`IndexingState`] through the
1327    /// real notification pump.
1328    mod mock_server {
1329        use std::time::Duration;
1330
1331        use serde_json::json;
1332        use tokio::io::BufReader;
1333
1334        use super::*;
1335        use crate::lsp::LspNotification;
1336        use crate::test_lsp::{
1337            FakeServer, fake_lsp_client_with_lanes, read_framed_message,
1338            spawn_test_pump_over_lanes, write_notification, write_request,
1339        };
1340
1341        async fn assert_create_request_is_acknowledged(server: &mut FakeServer) {
1342            write_request(
1343                &mut server.read_half_stdin,
1344                &json!(7),
1345                "window/workDoneProgress/create",
1346                json!({ "token": "idx" }),
1347            )
1348            .await;
1349
1350            let reply = read_framed_message(&mut BufReader::new(&mut server.write_stdout)).await;
1351            assert_eq!(reply["id"], 7);
1352            assert_eq!(reply.get("result"), Some(&serde_json::Value::Null));
1353            assert!(reply.get("error").is_none(), "unexpected error: {reply}");
1354        }
1355
1356        async fn send_progress(server: &mut FakeServer, kind: &str) {
1357            write_notification(
1358                &mut server.read_half_stdin,
1359                "$/progress",
1360                json!({ "token": "idx", "value": { "kind": kind, "title": "Indexing" } }),
1361            )
1362            .await;
1363        }
1364
1365        fn kind_of(notification: &LspNotification) -> &str {
1366            let LspNotification::Progress(params) = notification else {
1367                panic!("expected a progress notification, got {notification:?}");
1368            };
1369            params.value["kind"].as_str().unwrap()
1370        }
1371
1372        async fn wait_for(
1373            cache: &tokio::sync::Mutex<crate::bridge::NotificationCache>,
1374            id: &ServerId,
1375            expected: IndexingState,
1376        ) {
1377            for _ in 0..200 {
1378                if cache.lock().await.indexing_state(id) == expected {
1379                    return;
1380                }
1381                tokio::time::sleep(Duration::from_millis(1)).await;
1382            }
1383            panic!("indexing state never became {expected:?}");
1384        }
1385
1386        #[tokio::test]
1387        async fn test_report_frame_never_reaches_the_lifecycle_lane() {
1388            let (_client, mut server, mut lanes) = fake_lsp_client_with_lanes();
1389            assert_create_request_is_acknowledged(&mut server).await;
1390
1391            for kind in ["begin", "report", "end"] {
1392                send_progress(&mut server, kind).await;
1393            }
1394
1395            let first = tokio::time::timeout(Duration::from_secs(5), lanes.lifecycle_rx.recv())
1396                .await
1397                .unwrap()
1398                .unwrap();
1399            let second = tokio::time::timeout(Duration::from_secs(5), lanes.lifecycle_rx.recv())
1400                .await
1401                .unwrap()
1402                .unwrap();
1403            assert_eq!((kind_of(&first), kind_of(&second)), ("begin", "end"));
1404            assert!(lanes.lifecycle_rx.try_recv().is_err());
1405        }
1406
1407        #[tokio::test]
1408        async fn test_progress_sequence_moves_tracker_from_loading_to_ready() {
1409            let (_client, mut server, lanes) = fake_lsp_client_with_lanes();
1410            assert_create_request_is_acknowledged(&mut server).await;
1411            let (cache, _cancel) = spawn_test_pump_over_lanes(lanes);
1412            let id = ServerId::from_static("rust");
1413            tokio::time::pause();
1414
1415            send_progress(&mut server, "begin").await;
1416            wait_for(&cache, &id, IndexingState::Loading).await;
1417
1418            send_progress(&mut server, "report").await;
1419            send_progress(&mut server, "end").await;
1420            tokio::time::sleep(Duration::from_millis(10)).await;
1421            assert_eq!(
1422                cache.lock().await.indexing_state(&id),
1423                IndexingState::Loading,
1424                "still inside the settle window after `end`"
1425            );
1426
1427            tokio::time::advance(PROGRESS_SETTLE + Duration::from_secs(1)).await;
1428            assert_eq!(cache.lock().await.indexing_state(&id), IndexingState::Ready);
1429        }
1430    }
1431}