mcpls_core/bridge/notifications.rs
1//! LSP notification storage and management.
2//!
3//! Stores diagnostics, log messages, and server messages received from LSP servers.
4
5use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
6
7use chrono::{DateTime, Utc};
8use lsp_types::{Diagnostic as LspDiagnostic, Uri};
9use serde::{Deserialize, Serialize};
10use tracing::warn;
11
12use crate::bridge::indexing::{IndexingPolicy, IndexingState, IndexingTracker};
13use crate::config::ServerId;
14use crate::util::{truncate_str, truncate_string};
15
16/// Maximum number of log entries to store.
17const MAX_LOG_ENTRIES: usize = 100;
18
19/// Maximum size, in bytes, of a single cached log message, server message,
20/// or a single diagnostic's free-form `message` text.
21///
22/// `MAX_LOG_ENTRIES`/`MAX_SERVER_MESSAGES`/`MAX_DIAGNOSTIC_ENTRIES` bound
23/// the *number* of cached entries, but not the size of any one entry -- a
24/// spawned LSP server could publish a single pathologically large message
25/// and still fit under those caps while consuming unbounded memory (#311).
26/// This is independent of the transport-level `MAX_CONTENT_LENGTH` cap in
27/// `lsp::transport`, which bounds a whole JSON-RPC frame, not one field
28/// within it. 256 KiB comfortably fits any realistic diagnostic or log
29/// message while still capping the worst case.
30///
31/// This alone does not bound a whole diagnostics *entry* (a
32/// `Vec<LspDiagnostic>`), only one diagnostic's `message` field -- see
33/// `MAX_DIAGNOSTICS_ENTRY_BYTES` for the entry-level cap.
34const MAX_ENTRY_TEXT_BYTES: usize = 256 * 1024;
35
36/// Maximum serialized size, in bytes, of a single document's *whole*
37/// diagnostics list (`Vec<LspDiagnostic>`), enforced by
38/// [`cap_diagnostics_entry_size`].
39///
40/// `MAX_ENTRY_TEXT_BYTES` alone does not bound this: it only truncates one
41/// diagnostic's `message` field, but the list's *length* is uncapped, and
42/// `LspDiagnostic` carries several more free-form or arbitrary-JSON fields
43/// besides `message` (`source`, `code`, `code_description`,
44/// `related_information`, `data`). A hostile server can stay under
45/// `MAX_ENTRY_TEXT_BYTES` on every individual message while still
46/// publishing e.g. 100k diagnostics for one URI, or a single diagnostic
47/// with a multi-MiB `data` blob -- both still fit under the transport-level
48/// `lsp::transport::MAX_CONTENT_LENGTH` (10 MiB) per notification, and
49/// `MAX_DIAGNOSTIC_ENTRIES` bounds only the *number* of distinct cached
50/// URIs, not their individual size, so up to 1000 such entries could
51/// otherwise accumulate to gigabytes. 1 MiB is far larger than any
52/// realistic diagnostics list for one file, and combined with
53/// `MAX_DIAGNOSTIC_ENTRIES` bounds the cache's total diagnostics footprint
54/// to roughly 1 GiB in the worst case.
55const MAX_DIAGNOSTICS_ENTRY_BYTES: usize = 1024 * 1024;
56
57/// Global budget for distinct-URI diagnostic entries, shared work-conservingly
58/// across every registered diagnostics-route server rather than claimed by
59/// one server alone.
60///
61/// Guards against unbounded growth when a spawned LSP server publishes
62/// diagnostics for an unbounded number of distinct URIs over a long-running
63/// session, matching the bounding already applied to `logs`/`messages`.
64/// Eviction only triggers once this global total is reached; it then targets
65/// whichever server most exceeds its fair share of
66/// `MAX_DIAGNOSTIC_ENTRIES / diagnostics_route_count` (see
67/// [`NotificationCache::set_diagnostics_route_count`]). If no server exceeds
68/// its share, eviction falls back to the writer's own oldest entry instead
69/// -- even if the writer is itself within its share -- since it is the one
70/// whose new entry needs room; a narrower fallback further evicts from the
71/// largest other in-share server only if the writer itself has no entries
72/// yet (its very first write) and every existing server is already within
73/// its own share, since otherwise there would be nothing to evict and the
74/// aggregate cap could be exceeded (see the private `server_to_evict_from`
75/// for both fallbacks). A quieter, non-writer server that is within its fair
76/// share is otherwise never touched (#266). A single active server can
77/// still use the full budget when other registered servers are idle (#276)
78/// instead of being capped at a static equal split regardless of how much
79/// of it they actually use. Which single entry within the chosen server (or
80/// another over-share one) is actually removed is further refined by
81/// emptiness -- see the private `entry_to_evict` (#284).
82const MAX_DIAGNOSTIC_ENTRIES: usize = 1000;
83
84/// Normalize a URI string to a stable cache key.
85///
86/// On Windows, URI comparisons must be case-insensitive: the filesystem is
87/// case-insensitive and different tools (e.g. rust-analyzer vs std) may
88/// produce drive letters in different cases (`C:` vs `c:`).
89/// Lowercasing the entire URI is safe for `file://` URIs because they have
90/// no case-sensitive query or fragment components.
91fn uri_cache_key(uri: &str) -> std::borrow::Cow<'_, str> {
92 if cfg!(windows) {
93 std::borrow::Cow::Owned(uri.to_ascii_lowercase())
94 } else {
95 std::borrow::Cow::Borrowed(uri)
96 }
97}
98
99/// Maximum number of server messages to store.
100const MAX_SERVER_MESSAGES: usize = 50;
101
102/// Conservative fixed-field/JSON-structure overhead assumed per diagnostic
103/// (`range`, `severity`, and object/field-name punctuation) by
104/// [`cap_diagnostics_entry_size`]'s cheap size estimate. Deliberately
105/// generous relative to the true overhead (`range` alone serializes to
106/// roughly 70 bytes) so the estimate can only ever *overcount*, never
107/// undercount, actual serialized size.
108const DIAGNOSTIC_ESTIMATE_OVERHEAD_BYTES: usize = 256;
109
110/// Worst-case JSON string-escaping expansion factor, applied to each raw
111/// string field's byte length in [`cap_diagnostics_entry_size`]'s cheap
112/// size estimate.
113///
114/// A raw byte's serialized JSON form is at most 6 bytes: `"` and `\` and
115/// the five control characters with a short escape (`\b \f \n \r \t`) cost
116/// 2 bytes, but every other control character (`U+0000`..=`U+001F`, e.g.
117/// NUL) has no short escape and is emitted as `\u00XX` -- 6 bytes for 1 raw
118/// byte. The original estimate summed raw string lengths directly and
119/// could *undercount* an escape-heavy string (e.g. all-NUL) by up to this
120/// factor, letting an oversized entry skip the real `fits` check
121/// entirely -- multiplying by it keeps the estimate a true upper bound on
122/// serialized size rather than merely a typical-case guess.
123const JSON_ESCAPE_WORST_CASE_FACTOR: usize = 6;
124
125/// Last-resort message length used by [`cap_diagnostics_entry_size`]'s
126/// terminal-enforcement fallback -- small enough that a single diagnostic
127/// (fixed-size `range`/`severity` plus this one short string, every other
128/// field cleared) can never approach [`MAX_DIAGNOSTICS_ENTRY_BYTES`]
129/// regardless of JSON encoding overhead.
130const DIAGNOSTIC_TERMINAL_FALLBACK_MESSAGE_BYTES: usize = 1024;
131
132/// Ordinal rank used to sort diagnostics by severity before
133/// [`cap_diagnostics_entry_size`] truncates an oversized list -- lower rank
134/// sorts first, so it is kept preferentially (#311 S6).
135///
136/// `DiagnosticSeverity`'s inner value is private, so its natural numeric
137/// ordering (`ERROR` < `WARNING` < `INFORMATION` < `HINT`) can't be read
138/// directly; `Option<DiagnosticSeverity>`'s *derived* `Ord` would also rank
139/// `None` before every `Some` value, the opposite of what's wanted here
140/// (no reported severity is treated as least important, same as `HINT`).
141/// This maps explicitly instead of relying on either.
142const fn diagnostic_severity_rank(diagnostic: &LspDiagnostic) -> u8 {
143 match diagnostic.severity {
144 Some(lsp_types::DiagnosticSeverity::Error) => 0,
145 Some(lsp_types::DiagnosticSeverity::Warning) => 1,
146 Some(lsp_types::DiagnosticSeverity::Information) => 2,
147 // An unrecognized (future) severity value is treated the same as
148 // no severity at all: least important, not most.
149 Some(_) | None => 3,
150 }
151}
152
153/// Largest `k` such that `fits(&diagnostics[..k])`, found via binary search
154/// rather than a linear scan or a flat halve (#311 S6).
155///
156/// Correct because a JSON array's serialized length is monotonically
157/// non-decreasing in its element count -- appending a diagnostic can only
158/// add bytes, never remove them -- so `fits(&diagnostics[..k])` is `true`
159/// for a contiguous run of small `k` and `false` for every larger `k`,
160/// exactly the shape a boundary binary search requires. `fits(&[])` is
161/// always `true`, so the search is well-defined even if no diagnostic at
162/// all fits individually.
163fn largest_fitting_prefix(
164 diagnostics: &[LspDiagnostic],
165 fits: impl Fn(&[LspDiagnostic]) -> bool,
166) -> usize {
167 let (mut lo, mut hi) = (0usize, diagnostics.len());
168 while lo < hi {
169 let mid = lo + (hi - lo).div_ceil(2);
170 if fits(&diagnostics[..mid]) {
171 lo = mid;
172 } else {
173 hi = mid - 1;
174 }
175 }
176 lo
177}
178
179/// Borrows a diagnostic's free-form `message` as plain text, regardless of
180/// whether the server sent it as a plain string or (per LSP 3.18)
181/// `MarkupContent`.
182pub fn message_as_str(message: &lsp_types::Message) -> &str {
183 match message {
184 lsp_types::Message::String(s) => s,
185 lsp_types::Message::MarkupContent(m) => &m.value,
186 }
187}
188
189/// Truncates a diagnostic's free-form `message` to at most `max_bytes`,
190/// regardless of whether it is a plain string or `MarkupContent`.
191fn truncate_message(message: lsp_types::Message, max_bytes: usize) -> lsp_types::Message {
192 match message {
193 lsp_types::Message::String(s) => lsp_types::Message::String(truncate_string(s, max_bytes)),
194 lsp_types::Message::MarkupContent(mut m) => {
195 m.value = truncate_string(m.value, max_bytes);
196 lsp_types::Message::MarkupContent(m)
197 }
198 }
199}
200
201/// Bounds `diagnostics`' serialized size to at most
202/// `MAX_DIAGNOSTICS_ENTRY_BYTES` (#311 C1 fix).
203///
204/// Measures the list's *actual* serialized size via `serde_json::to_vec`
205/// rather than bounding each field individually -- that covers every
206/// field on `LspDiagnostic` (`source`, `code`, `code_description`,
207/// `related_information`, `data`, `tags`) at once, not just `message`.
208///
209/// # Guarantee
210///
211/// The postcondition -- the returned list's serialized size is at most
212/// `MAX_DIAGNOSTICS_ENTRY_BYTES` -- is enforced directly by a final,
213/// unconditional check at the end of this function, not merely assumed to
214/// follow from the field-specific mitigations below it. Those mitigations
215/// are best-effort (preserve as much real content as fits) and only cover
216/// the fields known today; the terminal step is what actually guarantees
217/// the bound holds even if a mitigation is incomplete or `LspDiagnostic`
218/// gains a new unbounded field in a future `lsp-types` upgrade.
219///
220/// # Cost (#311 S5)
221///
222/// `publishDiagnostics` is a hot path (rust-analyzer republishes
223/// whole-workspace diagnostics on every save), so this avoids a full
224/// `serde_json` serialization pass whenever every diagnostic's size is
225/// cheaply accountable from `message`/`source`/`code` alone (i.e. none
226/// carry `data`, `code_description`, `related_information`, or `tags`,
227/// each of which needs real serialization to size safely) and a
228/// conservative *upper bound* on their sum already fits. The estimate is
229/// not their raw byte length: JSON string escaping can expand a byte up to
230/// [`JSON_ESCAPE_WORST_CASE_FACTOR`]-fold (a NUL-heavy string previously
231/// let this fast path undercount actual serialized size by that much and
232/// skip the real `fits` check below entirely), so raw lengths are
233/// multiplied by that factor before comparing against the cap.
234///
235/// # Visibility (#311 S7)
236///
237/// Every mitigation that drops or truncates real content -- discarding
238/// diagnostics entirely, or clearing a survivor's `data` (which the LSP
239/// spec says is preserved through to a later `textDocument/codeAction`
240/// request, so losing it can silently break that diagnostic's quick fix)
241/// -- logs a `tracing::warn!` so the degradation is visible rather than a
242/// silent, hard-to-diagnose gap in what a caller sees.
243fn cap_diagnostics_entry_size(uri: &Uri, diagnostics: &mut Vec<LspDiagnostic>) {
244 let fits = |ds: &[LspDiagnostic]| {
245 // A serialization error is conservatively treated as "does not
246 // fit" (triggers the mitigations below) rather than as success.
247 // `LspDiagnostic`'s fields can't actually produce one in practice
248 // (no floats, no non-string map keys anywhere in `Diagnostic` or
249 // `serde_json::Value`'s own object representation), but failing
250 // safe costs nothing here.
251 serde_json::to_vec(ds).is_ok_and(|bytes| bytes.len() <= MAX_DIAGNOSTICS_ENTRY_BYTES)
252 };
253
254 let cheaply_estimable = diagnostics.iter().all(|d| {
255 d.data.is_none()
256 && d.code_description.is_none()
257 && d.related_information.is_none()
258 && d.tags.is_none()
259 });
260 if cheaply_estimable {
261 let estimated: usize = diagnostics
262 .iter()
263 .map(|d| {
264 let raw_string_bytes = message_as_str(&d.message).len()
265 + d.source.as_deref().map_or(0, str::len)
266 + match &d.code {
267 Some(lsp_types::Code::String(s)) => s.len(),
268 _ => 0,
269 };
270 raw_string_bytes * JSON_ESCAPE_WORST_CASE_FACTOR
271 + DIAGNOSTIC_ESTIMATE_OVERHEAD_BYTES
272 })
273 .sum();
274 if estimated <= MAX_DIAGNOSTICS_ENTRY_BYTES {
275 return;
276 }
277 }
278
279 if fits(diagnostics) {
280 return;
281 }
282
283 let original_count = diagnostics.len();
284
285 // Prefer dropping lower-severity diagnostics first (a stable sort, so
286 // same-severity diagnostics keep their original -- typically
287 // file-position -- relative order), then keep the largest prefix that
288 // actually fits rather than a flat halve, which both overshoots (a
289 // list one byte over the cap would otherwise lose half its
290 // diagnostics) and was severity-blind (would keep hundreds of leading
291 // HINT-level noise over a later ERROR). At least one diagnostic is
292 // always kept here so the mitigations below have a survivor to act on.
293 diagnostics.sort_by_key(diagnostic_severity_rank);
294 let keep = largest_fitting_prefix(diagnostics, fits).max(1);
295 diagnostics.truncate(keep);
296 if diagnostics.len() < original_count {
297 warn!(
298 "diagnostics for {} exceeded the {MAX_DIAGNOSTICS_ENTRY_BYTES}-byte cache cap; kept \
299 the {} highest-severity of {original_count} diagnostics",
300 uri.as_ref(),
301 diagnostics.len(),
302 );
303 }
304
305 // Drop opaque/structured fields first -- cheap, and often enough on
306 // its own (e.g. the single-huge-`data`-blob shape).
307 if diagnostics.len() == 1 && !fits(diagnostics) {
308 let diagnostic = &mut diagnostics[0];
309 let had_data = diagnostic.data.is_some();
310 diagnostic.data = None;
311 diagnostic.code_description = None;
312 diagnostic.related_information = None;
313 diagnostic.tags = None;
314 warn!(
315 "diagnostic for {} exceeded the cache cap; dropped its data/code_description/\
316 related_information/tags fields{}",
317 uri.as_ref(),
318 if had_data {
319 " (a later code-action request for this diagnostic may not resolve its quick fix)"
320 } else {
321 ""
322 },
323 );
324 }
325
326 // Still oversized: `source`/`code` (plain strings, unlike the opaque
327 // fields above) are truncated rather than dropped, to preserve some
328 // content.
329 if diagnostics.len() == 1 && !fits(diagnostics) {
330 let diagnostic = &mut diagnostics[0];
331 if let Some(source) = &diagnostic.source {
332 diagnostic.source = Some(truncate_str(source, MAX_ENTRY_TEXT_BYTES));
333 }
334 if let Some(lsp_types::Code::String(code)) = &diagnostic.code {
335 diagnostic.code = Some(lsp_types::Code::String(truncate_str(
336 code,
337 MAX_ENTRY_TEXT_BYTES,
338 )));
339 }
340 }
341
342 // Terminal enforcement: guarantee the postcondition directly rather
343 // than trusting the mitigations above to have covered every case --
344 // see this function's doc.
345 if !fits(diagnostics) {
346 diagnostics.truncate(1);
347 if let Some(diagnostic) = diagnostics.first_mut() {
348 let placeholder = lsp_types::Message::String(String::new());
349 diagnostic.message = truncate_message(
350 std::mem::replace(&mut diagnostic.message, placeholder),
351 DIAGNOSTIC_TERMINAL_FALLBACK_MESSAGE_BYTES,
352 );
353 diagnostic.source = None;
354 diagnostic.code = None;
355 diagnostic.code_description = None;
356 diagnostic.related_information = None;
357 diagnostic.tags = None;
358 diagnostic.data = None;
359 }
360 warn!(
361 "diagnostic for {} still exceeded the cache cap after every other mitigation; \
362 truncated its message to {DIAGNOSTIC_TERMINAL_FALLBACK_MESSAGE_BYTES} bytes and \
363 cleared all other fields",
364 uri.as_ref(),
365 );
366 }
367}
368
369/// Information about diagnostics for a document.
370#[derive(Debug, Clone, Serialize, Deserialize)]
371pub struct DiagnosticInfo {
372 /// URI of the document.
373 pub uri: Uri,
374 /// Document version when diagnostics were received.
375 pub version: Option<i32>,
376 /// List of diagnostics.
377 pub diagnostics: Vec<LspDiagnostic>,
378}
379
380/// A log entry from the LSP server.
381#[derive(Debug, Clone, Serialize, Deserialize)]
382pub struct LogEntry {
383 /// Log level.
384 pub level: LogLevel,
385 /// Log message.
386 pub message: String,
387 /// Timestamp when the log was received.
388 pub timestamp: DateTime<Utc>,
389}
390
391/// Log severity level.
392#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
393#[serde(rename_all = "lowercase")]
394pub enum LogLevel {
395 /// Error log level.
396 Error,
397 /// Warning log level.
398 Warning,
399 /// Info log level.
400 Info,
401 /// Debug log level.
402 Debug,
403}
404
405impl From<lsp_types::MessageType> for LogLevel {
406 fn from(msg_type: lsp_types::MessageType) -> Self {
407 match msg_type {
408 lsp_types::MessageType::Error => Self::Error,
409 lsp_types::MessageType::Warning => Self::Warning,
410 lsp_types::MessageType::Info => Self::Info,
411 // LOG and unknown message types default to Debug
412 _ => Self::Debug,
413 }
414 }
415}
416
417/// A message from the LSP server.
418#[derive(Debug, Clone, Serialize, Deserialize)]
419pub struct ServerMessage {
420 /// Message type.
421 pub message_type: MessageType,
422 /// Message content.
423 pub message: String,
424 /// Timestamp when the message was received.
425 pub timestamp: DateTime<Utc>,
426}
427
428/// Server message type.
429#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
430#[serde(rename_all = "lowercase")]
431pub enum MessageType {
432 /// Error message.
433 Error,
434 /// Warning message.
435 Warning,
436 /// Info message.
437 Info,
438 /// Log message.
439 Log,
440}
441
442impl From<lsp_types::MessageType> for MessageType {
443 fn from(msg_type: lsp_types::MessageType) -> Self {
444 match msg_type {
445 lsp_types::MessageType::Error => Self::Error,
446 lsp_types::MessageType::Warning => Self::Warning,
447 lsp_types::MessageType::Info => Self::Info,
448 // LOG and unknown message types default to Log
449 _ => Self::Log,
450 }
451 }
452}
453
454/// Cache for LSP server notifications.
455#[derive(Debug)]
456pub struct NotificationCache {
457 /// Diagnostics indexed by document URI.
458 diagnostics: HashMap<String, DiagnosticInfo>,
459 /// Server that currently owns each cached URI, so an entry's order map
460 /// can be found without scanning every server's.
461 diagnostics_owners: HashMap<String, ServerId>,
462 /// Per-server `diagnostics` keys ordered oldest-write-first, keyed by a
463 /// monotonic sequence number rather than position: a re-publish removes
464 /// its old entry by key in `O(log n)` (via `diagnostic_seq`) instead of
465 /// scanning for it, which a plain `VecDeque` would require. Not
466 /// independently capped per server -- only the aggregate across all
467 /// servers is bounded, by `MAX_DIAGNOSTIC_ENTRIES` -- but each server's
468 /// own map length is what eviction compares against its fair share (see
469 /// [`NotificationCache::server_to_evict_from`]) to decide which server
470 /// loses an entry once the aggregate is full, so one server's write
471 /// volume can never evict another's entries while it still has room
472 /// left in the global budget (#266, #276). Kept in sync with
473 /// `diagnostics` by every method that adds or removes an entry.
474 diagnostic_order: HashMap<ServerId, BTreeMap<u64, String>>,
475 /// Maps each cached URI to its current sequence number in its owner's
476 /// `diagnostic_order` map, so a re-publish or clear can find and remove
477 /// its old order entry without scanning.
478 diagnostic_seq: HashMap<String, u64>,
479 /// Next sequence number to assign in `diagnostic_order`. Shared across
480 /// every server's order map and monotonically increasing for the
481 /// cache's lifetime; never reused, so it never collides with an older
482 /// entry still pending eviction.
483 next_diagnostic_seq: u64,
484 /// Number of registered diagnostics-route servers currently sharing the
485 /// `MAX_DIAGNOSTIC_ENTRIES` budget, explicitly configured via
486 /// [`NotificationCache::set_diagnostics_route_count`].
487 ///
488 /// `None` until that setter is called -- `per_server_budget` then falls
489 /// back to the number of servers whose `diagnostic_order` entry is
490 /// non-empty (i.e. currently holds at least one entry) rather than
491 /// treating an unset count as `1`, which used to hand a single early
492 /// publisher the entire budget with no fair-share partitioning at all
493 /// (#283). The explicit setter remains the preferred path when the
494 /// caller knows it up front: it pre-accounts for servers that are
495 /// registered but have not published anything yet, avoiding a window
496 /// where an early publisher is temporarily over-allocated before a
497 /// slower server's first write grows `diagnostic_order`.
498 diagnostics_route_count: Option<usize>,
499
500 /// Count of entries in `diagnostics` whose diagnostics list is currently
501 /// empty (`[]`), i.e. an LSP server reporting a previously-tracked file
502 /// as now clean. A plain counter, not a duplicated key set, so `0` is an
503 /// `O(1)` signal that lets `entry_to_evict` skip its empty-entry search
504 /// entirely in the common steady state of a codebase full of real
505 /// diagnostics (#284) -- which entry is empty is still answered by
506 /// looking the key up in `diagnostics` itself (see the private
507 /// `is_empty_entry`), not by mirroring membership here. Kept in sync by
508 /// every method that adds or removes a `diagnostics` entry.
509 empty_diagnostics_count: usize,
510 /// Recent log entries (FIFO queue with max size).
511 logs: VecDeque<LogEntry>,
512 /// Recent server messages (FIFO queue with max size).
513 messages: VecDeque<ServerMessage>,
514 /// Server ids whose `textDocument/publishDiagnostics` push notifications
515 /// are known to be dark: `Translator::respawn_if_dead` replaced a crashed
516 /// process for this id, and the replacement's notification receiver is
517 /// drained and discarded rather than wired into a running
518 /// `diagnostics_pump` (#249's documented trade-off -- the pump's
519 /// remaining dependencies live in `serve_with`'s scope, not
520 /// `Translator`'s). Once marked, an id is never unmarked here: only a
521 /// full mcpls process restart actually restores push diagnostics for
522 /// that server, so clearing this on a later respawn attempt would
523 /// misreport the cache as fresh again.
524 push_degraded: HashSet<ServerId>,
525 /// Workspace-indexing readiness per server, driven by recognized
526 /// out-of-band signals (rust-analyzer's `experimental/serverStatus`, and
527 /// a generic `$/progress` `begin`/`end` sequence) rather than the
528 /// `initialize`/`initialized` handshake. See
529 /// [`Self::observe_indexing_signal`] and [`Self::observe_progress`].
530 indexing: IndexingTracker,
531}
532
533impl Default for NotificationCache {
534 fn default() -> Self {
535 Self::new()
536 }
537}
538
539impl NotificationCache {
540 /// Create a new notification cache.
541 #[must_use]
542 pub fn new() -> Self {
543 Self {
544 diagnostics: HashMap::with_capacity(32),
545 diagnostics_owners: HashMap::with_capacity(32),
546 diagnostic_order: HashMap::new(),
547 diagnostic_seq: HashMap::with_capacity(32),
548 next_diagnostic_seq: 0,
549 diagnostics_route_count: None,
550 empty_diagnostics_count: 0,
551 logs: VecDeque::with_capacity(MAX_LOG_ENTRIES),
552 messages: VecDeque::with_capacity(MAX_SERVER_MESSAGES),
553 push_degraded: HashSet::new(),
554 indexing: IndexingTracker::new(),
555 }
556 }
557
558 /// Configure how many diagnostics-route servers share the global
559 /// `MAX_DIAGNOSTIC_ENTRIES` budget.
560 ///
561 /// Each server's fair share becomes `MAX_DIAGNOSTIC_ENTRIES / count`
562 /// (minimum 1). This does not cap any server's entries by itself -- the
563 /// aggregate cache is only ever trimmed once it reaches
564 /// `MAX_DIAGNOSTIC_ENTRIES` total -- it only decides, at that point,
565 /// which server's oldest entry is the one that gets evicted. Call once
566 /// after server registration completes and before diagnostics start
567 /// flowing, to pre-account for servers that are registered but have not
568 /// published anything yet. If never called, `per_server_budget` derives
569 /// the count from the number of servers currently holding at least one
570 /// entry instead (#283) -- a consumer that forgets to call this still
571 /// gets fair-share partitioning once more than one server has written an
572 /// entry, rather than silently handing the whole budget to a single
573 /// early publisher.
574 pub fn set_diagnostics_route_count(&mut self, count: usize) {
575 self.diagnostics_route_count = Some(count.max(1));
576 }
577
578 /// Current per-server fair share of `MAX_DIAGNOSTIC_ENTRIES`, divided
579 /// evenly across the configured server count and floored at 1 so a
580 /// large server count can never reduce a server's share to zero.
581 ///
582 /// Uses the explicit count from [`Self::set_diagnostics_route_count`]
583 /// when set; otherwise falls back to the number of servers whose
584 /// `diagnostic_order` entry is non-empty (#283) -- matching
585 /// `server_to_evict_from`'s own `!order.is_empty()` filter, so a server
586 /// that has been fully evicted or reassigned away from (an empty but
587 /// still-present order map) is not double-counted in the denominator.
588 /// Both are floored at 1 so a fresh cache with no entries and no
589 /// explicit count yet still yields a usable budget instead of dividing
590 /// by zero.
591 ///
592 /// This is a tie-breaker for eviction, not a hard per-server cap: a
593 /// server may hold more than its fair share of entries at any time, as
594 /// long as the aggregate across all servers stays within
595 /// `MAX_DIAGNOSTIC_ENTRIES` (#276).
596 fn per_server_budget(&self) -> usize {
597 let count = self
598 .diagnostics_route_count
599 .unwrap_or_else(|| {
600 self.diagnostic_order
601 .values()
602 .filter(|order| !order.is_empty())
603 .count()
604 })
605 .max(1);
606 (MAX_DIAGNOSTIC_ENTRIES / count).max(1)
607 }
608
609 /// Picks which server's oldest entry to evict once the aggregate cache
610 /// is full: whichever registered server holds the most entries, if that
611 /// exceeds its fair share ([`Self::per_server_budget`]) -- so a noisy
612 /// server can only ever evict its own entries, never a quiet server's
613 /// that is still within its share (#266). If every server (including
614 /// `writer`) is within its share, falls back to `writer`'s own oldest
615 /// entry, since it is the one currently growing. Falls back further, to
616 /// whichever server holds the most entries regardless of share, only in
617 /// the edge case where `writer` has no entries of its own yet (its very
618 /// first write) while the aggregate is already full purely from other
619 /// servers each individually within their share -- otherwise there
620 /// would be nothing to evict from and the aggregate cap could be
621 /// exceeded despite every server behaving fairly.
622 ///
623 /// Ties in entry count are broken by `ServerId`, not left to
624 /// `HashMap`'s iteration order: `Iterator::max_by_key` returns the
625 /// *last* equally-maximal element it sees, and a `HashMap`'s iteration
626 /// order is randomized per process, so an `order.len()`-only key would
627 /// make the eviction target for a genuine tie vary from run to run.
628 /// Every candidate here is a distinct `diagnostic_order` key, so pairing
629 /// the count with `id.as_str()` makes the sort key unique per server --
630 /// no two entries can ever tie on the full key, which eliminates the
631 /// non-determinism outright rather than just picking a fixed side of it.
632 fn server_to_evict_from(&self, writer: &ServerId) -> Option<ServerId> {
633 let largest = self
634 .diagnostic_order
635 .iter()
636 .filter(|(_, order)| !order.is_empty())
637 .max_by_key(|(id, order)| (order.len(), id.as_str()));
638
639 let budget = self.per_server_budget();
640 if let Some((id, order)) = largest
641 && order.len() > budget
642 {
643 return Some(id.clone());
644 }
645
646 if self
647 .diagnostic_order
648 .get(writer)
649 .is_some_and(|order| !order.is_empty())
650 {
651 return Some(writer.clone());
652 }
653
654 largest.map(|(id, _)| id.clone())
655 }
656
657 /// Whether the cached entry for `key` currently has an empty (`[]`)
658 /// diagnostics list, i.e. an LSP server reporting a previously-tracked
659 /// file as now clean. Derived directly from `diagnostics` rather than
660 /// from a separately maintained key set, so there is nothing else to
661 /// keep in sync (#284).
662 fn is_empty_entry(&self, key: &str) -> bool {
663 self.diagnostics
664 .get(key)
665 .is_some_and(|info| info.diagnostics.is_empty())
666 }
667
668 /// Oldest entry in `server`'s own order map whose diagnostics list is
669 /// empty, if it has one.
670 fn oldest_empty_entry_in(&self, server: &ServerId) -> Option<(u64, String)> {
671 let order = self.diagnostic_order.get(server)?;
672 order
673 .iter()
674 .find(|(_, key)| self.is_empty_entry(key))
675 .map(|(&seq, key)| (seq, key.clone()))
676 }
677
678 /// Which single entry to remove next when the aggregate cache is full
679 /// and a genuinely new URI needs room, returned as `(owner, seq, key)`
680 /// so the caller can remove it from every index it appears in.
681 ///
682 /// [`Self::server_to_evict_from`] decides which server is fairness's
683 /// primary target; this picks *which of that server's entries* to
684 /// actually remove, preferring an empty (`[]`) one over its
685 /// strictly-oldest entry wherever one can be found without disturbing a
686 /// server that is within its own fair share (#284):
687 ///
688 /// 1. If the chosen victim itself holds an empty entry, evict its oldest
689 /// one -- a `[]` publish carries no diagnostic content to lose, so
690 /// this lets an older, still-meaningful entry from the same server
691 /// survive in its place.
692 /// 2. Otherwise, if some *other* server that also exceeds
693 /// [`Self::per_server_budget`] holds an empty entry, evict that one
694 /// instead of destroying the chosen victim's real diagnostics (S1):
695 /// fairness only protects a server that is within its share, so an
696 /// over-share server's own clean entry is fair game regardless of
697 /// which over-share server `server_to_evict_from` happened to name.
698 /// Ties use the same `(count, id)` key as `server_to_evict_from`, for
699 /// the same determinism reason.
700 /// 3. Otherwise -- no empty entry exists anywhere over-budget -- falls
701 /// back to the chosen victim's strictly-oldest entry, exactly as
702 /// before #284.
703 ///
704 /// Step 1/2's search is skipped entirely when `empty_diagnostics_count`
705 /// is `0`, so the common steady state (a codebase full of real
706 /// diagnostics, no clean-file churn) pays no extra cost over a plain
707 /// oldest-first lookup (#284).
708 fn entry_to_evict(&self, writer: &ServerId) -> Option<(ServerId, u64, String)> {
709 let evict_from = self.server_to_evict_from(writer)?;
710
711 if self.empty_diagnostics_count > 0 {
712 if let Some((seq, key)) = self.oldest_empty_entry_in(&evict_from) {
713 return Some((evict_from, seq, key));
714 }
715
716 let budget = self.per_server_budget();
717 let cross_server_pick = self
718 .diagnostic_order
719 .iter()
720 .filter(|(id, order)| order.len() > budget && *id != &evict_from)
721 .filter_map(|(id, order)| {
722 self.oldest_empty_entry_in(id)
723 .map(|(seq, key)| (id, order.len(), seq, key))
724 })
725 .max_by_key(|(id, len, ..)| (*len, id.as_str()));
726
727 if let Some((id, _, seq, key)) = cross_server_pick {
728 return Some((id.clone(), seq, key));
729 }
730 }
731
732 let order = self.diagnostic_order.get(&evict_from)?;
733 let (&seq, key) = order.iter().next()?;
734 Some((evict_from, seq, key.clone()))
735 }
736
737 /// Store diagnostics for a document published by `server_id`.
738 ///
739 /// Each diagnostic's `message` is truncated to `MAX_ENTRY_TEXT_BYTES`,
740 /// and the whole list is bounded to `MAX_DIAGNOSTICS_ENTRY_BYTES`
741 /// serialized bytes, before storing (#311). When that bound requires
742 /// dropping diagnostics, the *survivors* come back sorted by severity
743 /// (`diagnostic_severity_rank`: `ERROR` first), not in the original
744 /// publish/file-position order -- see [`Self::diagnostics`].
745 ///
746 /// If diagnostics already exist for the URI, they are replaced and the
747 /// entry is repositioned to the back of its owner's eviction order, so
748 /// a URI republished on every edit is tracked as most-recently-written
749 /// and evicted last, not first -- and, since it is not a new distinct
750 /// URI, never triggers eviction on its own.
751 ///
752 /// Eviction is work-conserving (#276): storing diagnostics for a
753 /// genuinely new URI only evicts an existing entry once the *aggregate*
754 /// across every server reaches `MAX_DIAGNOSTIC_ENTRIES`, and then only
755 /// the least-recently-written entry of whichever server most exceeds its
756 /// fair share, or -- per the fallbacks documented on
757 /// `server_to_evict_from` -- the writer's own oldest entry when no
758 /// server exceeds its share. A quieter, non-writer server that is within
759 /// its fair share is never touched, outside the narrow edge case also
760 /// documented there. This lets a single active server use the full
761 /// aggregate budget while other registered servers are idle, instead of
762 /// being capped at a static equal split regardless of how much of it
763 /// they actually use. Which exact entry is removed is further refined by
764 /// emptiness -- see the private `entry_to_evict` (#284).
765 ///
766 /// # Examples
767 ///
768 /// ```
769 /// use mcpls_core::bridge::NotificationCache;
770 /// use mcpls_core::config::ServerId;
771 /// use lsp_types::Uri;
772 ///
773 /// let mut cache = NotificationCache::new();
774 /// let server: ServerId = "rust-analyzer".into();
775 /// let uri: Uri = Uri::from("file:///main.rs");
776 /// cache.store_diagnostics(&server, &uri, Some(1), vec![]);
777 /// assert!(cache.diagnostics(uri.as_ref()).is_some());
778 /// ```
779 pub fn store_diagnostics(
780 &mut self,
781 server_id: &ServerId,
782 uri: &Uri,
783 version: Option<i32>,
784 mut diagnostics: Vec<LspDiagnostic>,
785 ) {
786 // Bound each diagnostic's free-form message text (#311); see
787 // `MAX_ENTRY_TEXT_BYTES`. `mem::take` + `truncate_string` avoids an
788 // extra clone on the common (already-under-limit) path, since
789 // `message` is already an owned `String` here.
790 for diagnostic in &mut diagnostics {
791 let placeholder = lsp_types::Message::String(String::new());
792 diagnostic.message = truncate_message(
793 std::mem::replace(&mut diagnostic.message, placeholder),
794 MAX_ENTRY_TEXT_BYTES,
795 );
796 }
797 // Bound the whole list's serialized size (#311 C1); see
798 // `MAX_DIAGNOSTICS_ENTRY_BYTES`.
799 cap_diagnostics_entry_size(uri, &mut diagnostics);
800
801 let key = uri_cache_key(uri.as_ref()).into_owned();
802 let info = DiagnosticInfo {
803 uri: uri.clone(),
804 version,
805 diagnostics,
806 };
807
808 // Remove the URI's existing order entry, if any -- from its
809 // previous owner's order map, whether that's this same server (a
810 // republish, repositioned to the back below) or a different one
811 // (the diagnostics route changed, e.g. on respawn). Also tells us
812 // whether this store adds a new entry to the aggregate (and so may
813 // need to evict to stay within budget) or merely replaces one.
814 let mut is_new_entry = true;
815 if let Some(old_seq) = self.diagnostic_seq.remove(&key) {
816 is_new_entry = false;
817 if let Some(previous_owner) = self.diagnostics_owners.get(&key)
818 && let Some(order) = self.diagnostic_order.get_mut(previous_owner)
819 {
820 order.remove(&old_seq);
821 }
822 }
823
824 if is_new_entry {
825 while self.diagnostics.len() >= MAX_DIAGNOSTIC_ENTRIES
826 && let Some((owner, seq, evict_key)) = self.entry_to_evict(server_id)
827 {
828 if let Some(order) = self.diagnostic_order.get_mut(&owner) {
829 order.remove(&seq);
830 }
831 self.diagnostic_seq.remove(&evict_key);
832 self.diagnostics_owners.remove(&evict_key);
833 if let Some(removed) = self.diagnostics.remove(&evict_key)
834 && removed.diagnostics.is_empty()
835 {
836 self.empty_diagnostics_count -= 1;
837 }
838 }
839 }
840
841 self.diagnostics_owners
842 .insert(key.clone(), server_id.clone());
843 let seq = self.next_diagnostic_seq;
844 self.next_diagnostic_seq += 1;
845 self.diagnostic_order
846 .entry(server_id.clone())
847 .or_default()
848 .insert(seq, key.clone());
849 self.diagnostic_seq.insert(key.clone(), seq);
850
851 // Track the emptiness transition, if any, of the entry this store
852 // replaces (or creates) -- `self.diagnostics` still holds the old
853 // value at this point, since the `insert` below hasn't run yet
854 // (#284: derived from `diagnostics` itself, not a duplicated key
855 // set).
856 let was_empty = self.is_empty_entry(&key);
857 let is_empty_now = info.diagnostics.is_empty();
858 match (was_empty, is_empty_now) {
859 (false, true) => self.empty_diagnostics_count += 1,
860 (true, false) => self.empty_diagnostics_count -= 1,
861 _ => {}
862 }
863 self.diagnostics.insert(key, info);
864 }
865
866 /// Store a log entry.
867 ///
868 /// Maintains a maximum of `MAX_LOG_ENTRIES` entries, removing oldest when full.
869 /// `message` is truncated to `MAX_ENTRY_TEXT_BYTES` before storing.
870 pub fn store_log(&mut self, level: LogLevel, message: String) {
871 let entry = LogEntry {
872 level,
873 message: truncate_string(message, MAX_ENTRY_TEXT_BYTES),
874 timestamp: Utc::now(),
875 };
876
877 if self.logs.len() >= MAX_LOG_ENTRIES {
878 self.logs.pop_front();
879 }
880 self.logs.push_back(entry);
881 }
882
883 /// Store a server message.
884 ///
885 /// Maintains a maximum of `MAX_SERVER_MESSAGES` entries, removing oldest when full.
886 /// `message` is truncated to `MAX_ENTRY_TEXT_BYTES` before storing.
887 pub fn store_message(&mut self, message_type: MessageType, message: String) {
888 let msg = ServerMessage {
889 message_type,
890 message: truncate_string(message, MAX_ENTRY_TEXT_BYTES),
891 timestamp: Utc::now(),
892 };
893
894 if self.messages.len() >= MAX_SERVER_MESSAGES {
895 self.messages.pop_front();
896 }
897 self.messages.push_back(msg);
898 }
899
900 /// Record a workspace-readiness signal from an unrecognized/custom LSP
901 /// notification (`LspNotification::Other`), updating `server_id`'s
902 /// tracked [`IndexingState`] if the notification is one this cache
903 /// understands.
904 ///
905 /// Currently recognizes rust-analyzer's `experimental/serverStatus`
906 /// notification: a `quiescent` boolean of `false` marks the
907 /// server [`IndexingState::Loading`], `true` marks it
908 /// [`IndexingState::Ready`]. Any other method, or a `serverStatus`
909 /// payload missing/malformed the field, leaves the current state
910 /// untouched rather than erroring -- an unrecognized signal is
911 /// equivalent to no signal.
912 ///
913 /// Once a server reaches [`IndexingState::Ready`] it never regresses on
914 /// its own: this only tracks the *initial* workspace load, not later
915 /// re-indexing triggered by large-scale file changes. See
916 /// [`Self::reset_indexing_state`] for the one case that does move a
917 /// server back out of `Ready`/`Loading`.
918 ///
919 /// # Examples
920 ///
921 /// ```
922 /// use mcpls_core::bridge::{IndexingState, NotificationCache};
923 /// use mcpls_core::config::ServerId;
924 /// use serde_json::json;
925 ///
926 /// let mut cache = NotificationCache::new();
927 /// let id: ServerId = "rust-analyzer".into();
928 /// assert_eq!(cache.indexing_state(&id), IndexingState::Unknown);
929 ///
930 /// let loading = json!({"quiescent": false});
931 /// cache.observe_indexing_signal(&id, "experimental/serverStatus", Some(&loading));
932 /// assert_eq!(cache.indexing_state(&id), IndexingState::Loading);
933 ///
934 /// let ready = json!({"quiescent": true});
935 /// cache.observe_indexing_signal(&id, "experimental/serverStatus", Some(&ready));
936 /// assert_eq!(cache.indexing_state(&id), IndexingState::Ready);
937 /// ```
938 pub fn observe_indexing_signal(
939 &mut self,
940 server_id: &ServerId,
941 method: &str,
942 params: Option<&serde_json::Value>,
943 ) {
944 self.indexing
945 .observe_server_status(server_id, method, params);
946 }
947
948 /// Record a `$/progress` notification toward `server_id`'s tracked
949 /// [`IndexingState`] -- the generic LSP counterpart to
950 /// [`Self::observe_indexing_signal`]'s rust-analyzer-specific
951 /// `experimental/serverStatus`. See
952 /// [`crate::bridge::indexing::IndexingTracker::observe_progress`] for
953 /// the full begin/end/settle/latch transition rules.
954 pub(crate) fn observe_progress(
955 &mut self,
956 server_id: &ServerId,
957 params: &lsp_types::ProgressParams,
958 ) {
959 self.indexing.observe_progress(server_id, params);
960 }
961
962 /// Configure `server_id`'s [`IndexingPolicy`] -- see
963 /// [`crate::bridge::indexing::IndexingTracker::set_policy`].
964 pub(crate) fn set_indexing_policy(&mut self, server_id: ServerId, policy: IndexingPolicy) {
965 self.indexing.set_policy(server_id, policy);
966 }
967
968 /// Current tracked workspace-indexing readiness for `server_id`.
969 ///
970 /// Returns [`IndexingState::Unknown`] for a server no readiness signal
971 /// has ever been observed for -- see [`Self::observe_indexing_signal`].
972 ///
973 /// A `Loading` entry older than `INDEXING_STALENESS_BOUND` is read
974 /// back as `Unknown` rather than `Loading`: this is the self-heal for a
975 /// `quiescent: true` notification dropped by a full channel, or a
976 /// server that stalled mid-index, applied at *read* time based on the
977 /// signal's own age so it can never be triggered by (and can never
978 /// affect) any individual caller's own wait -- see
979 /// `Translator::wait_for_indexing_ready`.
980 #[must_use]
981 pub fn indexing_state(&self, server_id: &ServerId) -> IndexingState {
982 self.indexing.state(server_id)
983 }
984
985 /// Forget `server_id`'s tracked [`IndexingState`], reverting it to
986 /// [`IndexingState::Unknown`].
987 ///
988 /// `Translator::respawn_if_dead` calls this after replacing a crashed
989 /// server's process, since the new process starts indexing from
990 /// scratch and has sent no signal of its own yet -- a stale
991 /// `Ready`/`Loading` carried over from the crashed connection must not
992 /// leak into requests routed to its replacement. This is the only
993 /// production caller: a timed-out [`Self::indexing_state`] read does
994 /// *not* call this, so one caller's wait can never affect another's.
995 ///
996 /// # Examples
997 ///
998 /// ```
999 /// use mcpls_core::bridge::{IndexingState, NotificationCache};
1000 /// use mcpls_core::config::ServerId;
1001 /// use serde_json::json;
1002 ///
1003 /// let mut cache = NotificationCache::new();
1004 /// let id: ServerId = "rust-analyzer".into();
1005 /// cache.observe_indexing_signal(&id, "experimental/serverStatus", Some(&json!({"quiescent": false})));
1006 /// assert_eq!(cache.indexing_state(&id), IndexingState::Loading);
1007 ///
1008 /// cache.reset_indexing_state(&id);
1009 /// assert_eq!(cache.indexing_state(&id), IndexingState::Unknown);
1010 /// ```
1011 pub fn reset_indexing_state(&mut self, server_id: &ServerId) {
1012 self.indexing.reset(server_id);
1013 }
1014
1015 /// Get diagnostics for a document URI.
1016 ///
1017 /// If the stored list was ever truncated by `store_diagnostics`'s
1018 /// `MAX_DIAGNOSTICS_ENTRY_BYTES` cap (#311), the diagnostics here are in
1019 /// severity order (`ERROR` first), not the original publish/file-position
1020 /// order -- callers that assume file-position order should not rely on
1021 /// it after a cap-triggered truncation.
1022 #[inline]
1023 #[must_use]
1024 pub fn diagnostics(&self, uri: &str) -> Option<&DiagnosticInfo> {
1025 self.diagnostics.get(uri_cache_key(uri).as_ref())
1026 }
1027
1028 /// Server that published the currently cached diagnostics for `uri`, if
1029 /// any. Used to look up that server's negotiated position encoding for a
1030 /// cache-only read that has no live LSP round trip of its own to resolve
1031 /// one from.
1032 #[inline]
1033 #[must_use]
1034 pub fn diagnostics_owner(&self, uri: &str) -> Option<&ServerId> {
1035 self.diagnostics_owners.get(uri_cache_key(uri).as_ref())
1036 }
1037
1038 /// All stored log entries.
1039 #[inline]
1040 #[must_use]
1041 pub const fn logs(&self) -> &VecDeque<LogEntry> {
1042 &self.logs
1043 }
1044
1045 /// All stored server messages.
1046 #[inline]
1047 #[must_use]
1048 pub const fn messages(&self) -> &VecDeque<ServerMessage> {
1049 &self.messages
1050 }
1051
1052 /// Clear diagnostics for a specific document URI.
1053 ///
1054 /// Returns the cleared diagnostics if they existed.
1055 pub fn clear_diagnostics(&mut self, uri: &str) -> Option<DiagnosticInfo> {
1056 let key = uri_cache_key(uri).into_owned();
1057 if let Some(owner) = self.diagnostics_owners.remove(&key)
1058 && let Some(seq) = self.diagnostic_seq.remove(&key)
1059 && let Some(order) = self.diagnostic_order.get_mut(&owner)
1060 {
1061 order.remove(&seq);
1062 }
1063 let removed = self.diagnostics.remove(&key);
1064 if removed
1065 .as_ref()
1066 .is_some_and(|info| info.diagnostics.is_empty())
1067 {
1068 self.empty_diagnostics_count -= 1;
1069 }
1070 removed
1071 }
1072
1073 /// Clear all diagnostics owned by a single server.
1074 ///
1075 /// Used when a server crashes and respawns: its own stale entries must
1076 /// be invalidated without disturbing any other server's cache entries
1077 /// (#266), unlike [`Self::clear_all_diagnostics`].
1078 ///
1079 /// # Examples
1080 ///
1081 /// ```
1082 /// use mcpls_core::bridge::NotificationCache;
1083 /// use mcpls_core::config::ServerId;
1084 /// use lsp_types::Uri;
1085 ///
1086 /// let mut cache = NotificationCache::new();
1087 /// let crashed: ServerId = "pyright".into();
1088 /// let healthy: ServerId = "rust-analyzer".into();
1089 /// let crashed_uri: Uri = Uri::from("file:///main.py");
1090 /// let healthy_uri: Uri = Uri::from("file:///main.rs");
1091 /// cache.store_diagnostics(&crashed, &crashed_uri, Some(1), vec![]);
1092 /// cache.store_diagnostics(&healthy, &healthy_uri, Some(1), vec![]);
1093 ///
1094 /// cache.clear_server_diagnostics(&crashed);
1095 ///
1096 /// assert!(cache.diagnostics(crashed_uri.as_ref()).is_none());
1097 /// assert!(cache.diagnostics(healthy_uri.as_ref()).is_some());
1098 /// ```
1099 pub fn clear_server_diagnostics(&mut self, server_id: &ServerId) {
1100 let Some(order) = self.diagnostic_order.remove(server_id) else {
1101 return;
1102 };
1103 for (_, key) in order {
1104 if self
1105 .diagnostics
1106 .remove(&key)
1107 .is_some_and(|info| info.diagnostics.is_empty())
1108 {
1109 self.empty_diagnostics_count -= 1;
1110 }
1111 self.diagnostics_owners.remove(&key);
1112 self.diagnostic_seq.remove(&key);
1113 }
1114 }
1115
1116 /// Marks `server_id`'s push-based diagnostics as no longer live -- see
1117 /// the `push_degraded` field doc for why this is permanent for the life
1118 /// of the cache.
1119 ///
1120 /// # Examples
1121 ///
1122 /// ```
1123 /// use mcpls_core::bridge::NotificationCache;
1124 /// use mcpls_core::config::ServerId;
1125 ///
1126 /// let mut cache = NotificationCache::new();
1127 /// let id: ServerId = "rust-analyzer".into();
1128 /// assert!(!cache.is_push_degraded(&id));
1129 /// cache.mark_push_degraded(&id);
1130 /// assert!(cache.is_push_degraded(&id));
1131 /// ```
1132 pub fn mark_push_degraded(&mut self, server_id: &ServerId) {
1133 self.push_degraded.insert(server_id.clone());
1134 }
1135
1136 /// Whether `server_id`'s push-based diagnostics are known to be
1137 /// degraded (see [`Self::mark_push_degraded`]) -- callers such as
1138 /// `get_cached_diagnostics` and `read_resource` use this to flag a
1139 /// cache-only result as potentially stale rather than presenting it as
1140 /// current.
1141 #[inline]
1142 #[must_use]
1143 pub fn is_push_degraded(&self, server_id: &ServerId) -> bool {
1144 self.push_degraded.contains(server_id)
1145 }
1146
1147 /// Clear all diagnostics, for every server.
1148 pub fn clear_all_diagnostics(&mut self) {
1149 self.diagnostics.clear();
1150 self.diagnostics_owners.clear();
1151 self.diagnostic_order.clear();
1152 self.diagnostic_seq.clear();
1153 self.empty_diagnostics_count = 0;
1154 }
1155
1156 /// Clear all logs.
1157 pub fn clear_logs(&mut self) {
1158 self.logs.clear();
1159 }
1160
1161 /// Clear all messages.
1162 pub fn clear_messages(&mut self) {
1163 self.messages.clear();
1164 }
1165
1166 /// Get the number of documents with stored diagnostics.
1167 #[inline]
1168 #[must_use]
1169 pub fn diagnostics_count(&self) -> usize {
1170 self.diagnostics.len()
1171 }
1172
1173 /// Get the number of stored log entries.
1174 #[inline]
1175 #[must_use]
1176 pub fn logs_count(&self) -> usize {
1177 self.logs.len()
1178 }
1179
1180 /// Get the number of stored server messages.
1181 #[inline]
1182 #[must_use]
1183 pub fn messages_count(&self) -> usize {
1184 self.messages.len()
1185 }
1186}
1187
1188/// Applies one lifecycle-lane notification (`$/progress` or the generic
1189/// `Other` variant, which carries e.g. rust-analyzer's
1190/// `experimental/serverStatus`) to `cache` -- the only two variants that
1191/// lane ever carries, since the notification lane handles
1192/// diagnostics/log/showMessage instead (see
1193/// `LspClient::message_loop_inner`'s routing).
1194///
1195/// Shared by `diagnostics_pump`'s lifecycle-lane arm (wiring a freshly
1196/// spawned server's own pump) and `Translator::respawn_if_dead`'s
1197/// lifecycle-lane forwarding task (wiring a respawned server's replacement
1198/// process), so the two can't silently drift apart on which notification
1199/// variants feed which `NotificationCache` method.
1200pub fn apply_lifecycle_notification(
1201 cache: &mut NotificationCache,
1202 server_id: &ServerId,
1203 notif: crate::lsp::LspNotification,
1204) {
1205 match notif {
1206 crate::lsp::LspNotification::Progress(params) => {
1207 cache.observe_progress(server_id, ¶ms);
1208 }
1209 crate::lsp::LspNotification::Other { method, params } => {
1210 cache.observe_indexing_signal(server_id, &method, params.as_ref());
1211 }
1212 crate::lsp::LspNotification::PublishDiagnostics(_)
1213 | crate::lsp::LspNotification::LogMessage(_)
1214 | crate::lsp::LspNotification::ShowMessage(_) => {}
1215 }
1216}
1217
1218#[cfg(test)]
1219#[allow(clippy::unwrap_used)]
1220mod tests {
1221 use lsp_types::{Position, Range};
1222
1223 use super::*;
1224 use crate::test_lsp::CapturedLogs;
1225
1226 /// Every test in this module that doesn't exercise multi-server
1227 /// fairness routes through one implicit server, so `set_diagnostics_route_count`
1228 /// is left at its default of `1` (full `MAX_DIAGNOSTIC_ENTRIES` budget).
1229 fn test_server() -> ServerId {
1230 ServerId::from("test-server")
1231 }
1232
1233 #[test]
1234 fn test_notification_cache_new() {
1235 let cache = NotificationCache::new();
1236 assert_eq!(cache.diagnostics_count(), 0);
1237 assert_eq!(cache.logs_count(), 0);
1238 assert_eq!(cache.messages_count(), 0);
1239 }
1240
1241 #[test]
1242 fn test_store_and_diagnostics() {
1243 let mut cache = NotificationCache::new();
1244 let uri: Uri = Uri::from("file:///test.rs");
1245
1246 let diagnostic = LspDiagnostic {
1247 range: Range {
1248 start: Position {
1249 line: 0,
1250 character: 0,
1251 },
1252 end: Position {
1253 line: 0,
1254 character: 5,
1255 },
1256 },
1257 severity: Some(lsp_types::DiagnosticSeverity::Error),
1258 message: "test error".to_string().into(),
1259 code: None,
1260 source: None,
1261 code_description: None,
1262 related_information: None,
1263 tags: None,
1264 data: None,
1265 };
1266
1267 cache.store_diagnostics(&test_server(), &uri, Some(1), vec![diagnostic]);
1268
1269 let stored = cache.diagnostics(uri.as_ref()).unwrap();
1270 assert_eq!(stored.uri, uri);
1271 assert_eq!(stored.version, Some(1));
1272 assert_eq!(stored.diagnostics.len(), 1);
1273 assert_eq!(
1274 stored.diagnostics[0].message,
1275 lsp_types::Message::String("test error".to_string())
1276 );
1277 }
1278
1279 /// #311: a single diagnostic's `message` must be bounded independently
1280 /// of `MAX_DIAGNOSTIC_ENTRIES`, which only caps the number of entries.
1281 #[test]
1282 fn test_store_diagnostics_truncates_oversized_message() {
1283 let mut cache = NotificationCache::new();
1284 let uri: Uri = Uri::from("file:///test.rs");
1285 let oversized = "a".repeat(MAX_ENTRY_TEXT_BYTES + 100);
1286
1287 let diagnostic = LspDiagnostic {
1288 range: Range {
1289 start: Position {
1290 line: 0,
1291 character: 0,
1292 },
1293 end: Position {
1294 line: 0,
1295 character: 5,
1296 },
1297 },
1298 severity: Some(lsp_types::DiagnosticSeverity::Error),
1299 message: oversized.clone().into(),
1300 code: None,
1301 source: None,
1302 code_description: None,
1303 related_information: None,
1304 tags: None,
1305 data: None,
1306 };
1307
1308 cache.store_diagnostics(&test_server(), &uri, Some(1), vec![diagnostic]);
1309
1310 let stored = cache.diagnostics(uri.as_ref()).unwrap();
1311 let stored = message_as_str(&stored.diagnostics[0].message);
1312 assert!(stored.len() < oversized.len());
1313 assert!(stored.ends_with("... (truncated)"));
1314 }
1315
1316 /// Minimal diagnostic with an arbitrary `message`, for tests that only
1317 /// care about size/count bounds rather than range/severity details.
1318 fn minimal_diagnostic(message: String) -> LspDiagnostic {
1319 LspDiagnostic {
1320 range: Range {
1321 start: Position {
1322 line: 0,
1323 character: 0,
1324 },
1325 end: Position {
1326 line: 0,
1327 character: 5,
1328 },
1329 },
1330 severity: Some(lsp_types::DiagnosticSeverity::Error),
1331 message: message.into(),
1332 code: None,
1333 source: None,
1334 code_description: None,
1335 related_information: None,
1336 tags: None,
1337 data: None,
1338 }
1339 }
1340
1341 /// #311 C1: `MAX_ENTRY_TEXT_BYTES` alone bounds one `message` field, not
1342 /// the whole entry -- many diagnostics, each individually small, must
1343 /// still be capped in aggregate.
1344 #[test]
1345 fn test_store_diagnostics_caps_aggregate_size_for_many_small_diagnostics() {
1346 let mut cache = NotificationCache::new();
1347 let uri: Uri = Uri::from("file:///test.rs");
1348
1349 // Each diagnostic is far under MAX_ENTRY_TEXT_BYTES individually,
1350 // but 5000 of them comfortably exceeds MAX_DIAGNOSTICS_ENTRY_BYTES
1351 // in aggregate.
1352 let diagnostics: Vec<LspDiagnostic> = (0..5000)
1353 .map(|i| {
1354 minimal_diagnostic(format!(
1355 "diagnostic number {i}, padded: {}",
1356 "x".repeat(200)
1357 ))
1358 })
1359 .collect();
1360 let original_count = diagnostics.len();
1361
1362 cache.store_diagnostics(&test_server(), &uri, Some(1), diagnostics);
1363
1364 let stored = &cache.diagnostics(uri.as_ref()).unwrap().diagnostics;
1365 assert!(
1366 stored.len() < original_count,
1367 "aggregate cap must trim the list, kept {} of {original_count}",
1368 stored.len()
1369 );
1370 assert!(!stored.is_empty(), "must keep at least one diagnostic");
1371 let serialized_len = serde_json::to_vec(stored).unwrap().len();
1372 assert!(
1373 serialized_len <= MAX_DIAGNOSTICS_ENTRY_BYTES,
1374 "stored entry must fit the aggregate cap, got {serialized_len} bytes"
1375 );
1376 }
1377
1378 /// #311 S6: a naive flat halve would keep only the first N/2
1379 /// diagnostics even when far more than that would actually fit --
1380 /// truncation must find the largest prefix that fits instead.
1381 #[test]
1382 fn test_store_diagnostics_truncation_keeps_largest_fitting_prefix() {
1383 let mut cache = NotificationCache::new();
1384 let uri: Uri = Uri::from("file:///test.rs");
1385
1386 // Each diagnostic serializes to roughly 300 bytes; ~3800 of them
1387 // fit under the 1 MiB cap, well over half of the 5000 published --
1388 // a flat halve would incorrectly stop at 2500.
1389 let diagnostics: Vec<LspDiagnostic> = (0..5000)
1390 .map(|i| minimal_diagnostic(format!("diagnostic {i}: {}", "x".repeat(250))))
1391 .collect();
1392
1393 cache.store_diagnostics(&test_server(), &uri, Some(1), diagnostics);
1394
1395 let stored = &cache.diagnostics(uri.as_ref()).unwrap().diagnostics;
1396 assert!(
1397 stored.len() > 2600,
1398 "largest-fitting-prefix search must keep far more than half, kept {}",
1399 stored.len()
1400 );
1401 let serialized_len = serde_json::to_vec(stored).unwrap().len();
1402 assert!(serialized_len <= MAX_DIAGNOSTICS_ENTRY_BYTES);
1403 // The search must find the *largest* fitting prefix, not just *a*
1404 // fitting one: one more diagnostic than what was kept must no
1405 // longer fit (otherwise it should have been kept too).
1406 let mut with_one_more = stored.clone();
1407 with_one_more.push(minimal_diagnostic(format!(
1408 "diagnostic overflow: {}",
1409 "x".repeat(250)
1410 )));
1411 assert!(
1412 serde_json::to_vec(&with_one_more).unwrap().len() > MAX_DIAGNOSTICS_ENTRY_BYTES,
1413 "kept count must be the largest that fits, not merely a fitting count"
1414 );
1415 }
1416
1417 /// #311 S6: truncation must prefer keeping higher-severity diagnostics,
1418 /// not just whichever the server happened to publish first -- a late
1419 /// `ERROR` must survive over leading `HINT`-level noise.
1420 #[test]
1421 fn test_store_diagnostics_truncation_prefers_higher_severity() {
1422 let mut cache = NotificationCache::new();
1423 let uri: Uri = Uri::from("file:///test.rs");
1424
1425 let mut diagnostics: Vec<LspDiagnostic> = (0..5000)
1426 .map(|i| {
1427 let mut d = minimal_diagnostic(format!("hint {i}: {}", "x".repeat(200)));
1428 d.severity = Some(lsp_types::DiagnosticSeverity::Hint);
1429 d
1430 })
1431 .collect();
1432 let mut trailing_error = minimal_diagnostic("the one real error".to_string());
1433 trailing_error.severity = Some(lsp_types::DiagnosticSeverity::Error);
1434 diagnostics.push(trailing_error);
1435
1436 cache.store_diagnostics(&test_server(), &uri, Some(1), diagnostics);
1437
1438 let stored = &cache.diagnostics(uri.as_ref()).unwrap().diagnostics;
1439 assert!(
1440 stored
1441 .iter()
1442 .any(|d| message_as_str(&d.message) == "the one real error"),
1443 "the trailing ERROR diagnostic must survive truncation over leading HINT noise"
1444 );
1445 }
1446
1447 /// #311 S7 / M7: truncating the diagnostics list must not be silent --
1448 /// a caller with no visibility into this cache would otherwise have no
1449 /// way to know a `get_cached_diagnostics` result is incomplete.
1450 #[test]
1451 fn test_store_diagnostics_warns_when_truncating_list() {
1452 use tracing_subscriber::layer::SubscriberExt as _;
1453
1454 let mut cache = NotificationCache::new();
1455 let uri: Uri = Uri::from("file:///test.rs");
1456 let diagnostics: Vec<LspDiagnostic> = (0..5000)
1457 .map(|i| minimal_diagnostic(format!("diagnostic {i}: {}", "x".repeat(250))))
1458 .collect();
1459
1460 let captured = CapturedLogs::default();
1461 let subscriber = tracing_subscriber::registry().with(captured.clone());
1462 let guard = tracing::subscriber::set_default(subscriber);
1463 cache.store_diagnostics(&test_server(), &uri, Some(1), diagnostics);
1464 drop(guard);
1465
1466 let messages = captured.messages();
1467 assert!(
1468 messages
1469 .iter()
1470 .any(|m| m.contains("highest-severity") && m.contains("file:///test.rs")),
1471 "expected a truncation warning naming the URI, got: {messages:?}"
1472 );
1473 }
1474
1475 /// #311 S7: dropping a diagnostic's `data` breaks the LSP contract that
1476 /// it round-trips to a later `textDocument/codeAction` request -- this
1477 /// must be logged, not silent.
1478 #[test]
1479 fn test_store_diagnostics_warns_when_dropping_data_blob() {
1480 use tracing_subscriber::layer::SubscriberExt as _;
1481
1482 let mut cache = NotificationCache::new();
1483 let uri: Uri = Uri::from("file:///test.rs");
1484 let mut diagnostic = minimal_diagnostic("small message".to_string());
1485 diagnostic.data = Some(serde_json::json!({
1486 "blob": "x".repeat(MAX_DIAGNOSTICS_ENTRY_BYTES + 1000),
1487 }));
1488
1489 let captured = CapturedLogs::default();
1490 let subscriber = tracing_subscriber::registry().with(captured.clone());
1491 let guard = tracing::subscriber::set_default(subscriber);
1492 cache.store_diagnostics(&test_server(), &uri, Some(1), vec![diagnostic]);
1493 drop(guard);
1494
1495 let messages = captured.messages();
1496 assert!(
1497 messages.iter().any(|m| m.contains("code-action")),
1498 "expected a warning noting the code-action quick-fix impact, got: {messages:?}"
1499 );
1500 }
1501
1502 /// #311 C1: a single diagnostic dominated by an oversized `data` blob
1503 /// must be capped even though `message` alone is small -- the aggregate
1504 /// list-halving path can't shrink a one-element list, so the opaque
1505 /// fields on that single diagnostic must be dropped instead.
1506 #[test]
1507 fn test_store_diagnostics_drops_oversized_data_blob_on_single_diagnostic() {
1508 let mut cache = NotificationCache::new();
1509 let uri: Uri = Uri::from("file:///test.rs");
1510
1511 let mut diagnostic = minimal_diagnostic("small message".to_string());
1512 diagnostic.data = Some(serde_json::json!({
1513 "blob": "x".repeat(MAX_DIAGNOSTICS_ENTRY_BYTES + 1000),
1514 }));
1515
1516 cache.store_diagnostics(&test_server(), &uri, Some(1), vec![diagnostic]);
1517
1518 let stored = &cache.diagnostics(uri.as_ref()).unwrap().diagnostics;
1519 assert_eq!(stored.len(), 1);
1520 assert_eq!(
1521 stored[0].message,
1522 lsp_types::Message::String("small message".to_string())
1523 );
1524 assert!(
1525 stored[0].data.is_none(),
1526 "oversized data blob must be dropped"
1527 );
1528 let serialized_len = serde_json::to_vec(stored).unwrap().len();
1529 assert!(
1530 serialized_len <= MAX_DIAGNOSTICS_ENTRY_BYTES,
1531 "stored entry must fit the aggregate cap after dropping data, got {serialized_len} bytes"
1532 );
1533 }
1534
1535 /// #311 C1 follow-up: an oversized `source` (not `data`) on a single
1536 /// diagnostic must also be brought back under the cap -- the
1537 /// opaque-field-drop mitigation alone does not touch `source`, which is
1538 /// a plain string and must be truncated instead.
1539 #[test]
1540 fn test_store_diagnostics_truncates_oversized_source_on_single_diagnostic() {
1541 let mut cache = NotificationCache::new();
1542 let uri: Uri = Uri::from("file:///test.rs");
1543
1544 let mut diagnostic = minimal_diagnostic("small message".to_string());
1545 diagnostic.source = Some("x".repeat(MAX_DIAGNOSTICS_ENTRY_BYTES + 1000));
1546
1547 cache.store_diagnostics(&test_server(), &uri, Some(1), vec![diagnostic]);
1548
1549 let stored = &cache.diagnostics(uri.as_ref()).unwrap().diagnostics;
1550 assert_eq!(stored.len(), 1);
1551 assert_eq!(
1552 stored[0].message,
1553 lsp_types::Message::String("small message".to_string())
1554 );
1555 let serialized_len = serde_json::to_vec(stored).unwrap().len();
1556 assert!(
1557 serialized_len <= MAX_DIAGNOSTICS_ENTRY_BYTES,
1558 "stored entry must fit the aggregate cap after truncating source, got {serialized_len} bytes"
1559 );
1560 }
1561
1562 /// #311 C1 follow-up: `cap_diagnostics_entry_size`'s postcondition --
1563 /// the result always fits `MAX_DIAGNOSTICS_ENTRY_BYTES` -- must hold
1564 /// even when every uncapped field is maxed out simultaneously, not just
1565 /// one at a time. This is the terminal-enforcement guarantee itself,
1566 /// exercised end to end through `store_diagnostics` rather than by
1567 /// calling the private function directly.
1568 #[test]
1569 fn test_store_diagnostics_caps_single_diagnostic_with_every_field_maxed_out() {
1570 let mut cache = NotificationCache::new();
1571 let uri: Uri = Uri::from("file:///test.rs");
1572
1573 // Each field individually exceeds MAX_ENTRY_TEXT_BYTES (so
1574 // source/code truncation is exercised) and the combination exceeds
1575 // MAX_DIAGNOSTICS_ENTRY_BYTES, without needing to allocate multiple
1576 // megabytes per field just to prove the same point.
1577 let mut diagnostic = minimal_diagnostic("x".repeat(MAX_ENTRY_TEXT_BYTES + 1000));
1578 diagnostic.source = Some("x".repeat(MAX_ENTRY_TEXT_BYTES + 1000));
1579 diagnostic.code = Some(lsp_types::Code::String(
1580 "x".repeat(MAX_ENTRY_TEXT_BYTES + 1000),
1581 ));
1582 diagnostic.data = Some(serde_json::json!({ "blob": "x".repeat(MAX_ENTRY_TEXT_BYTES) }));
1583 diagnostic.tags = Some(vec![lsp_types::DiagnosticTag::Unnecessary; 50]);
1584 diagnostic.related_information = Some(vec![
1585 lsp_types::DiagnosticRelatedInformation {
1586 location: lsp_types::Location {
1587 uri: uri.clone(),
1588 range: Range::default(),
1589 },
1590 message: "x".repeat(1000),
1591 };
1592 5
1593 ]);
1594
1595 cache.store_diagnostics(&test_server(), &uri, Some(1), vec![diagnostic]);
1596
1597 let stored = &cache.diagnostics(uri.as_ref()).unwrap().diagnostics;
1598 assert_eq!(stored.len(), 1);
1599 let serialized_len = serde_json::to_vec(stored).unwrap().len();
1600 assert!(
1601 serialized_len <= MAX_DIAGNOSTICS_ENTRY_BYTES,
1602 "postcondition must hold even with every field maxed out, got {serialized_len} bytes"
1603 );
1604 }
1605
1606 /// #311 C1 follow-up: exercises `cap_diagnostics_entry_size`'s terminal
1607 /// fallback directly. `message` is the one field the field-specific
1608 /// mitigations never touch (they only cover
1609 /// `source`/`code`/`data`/`code_description`/`related_information`/
1610 /// `tags`), so an oversized, *untruncated* message -- as it would be if
1611 /// this private function were ever called without `store_diagnostics`'s
1612 /// own prior message truncation -- must still be brought under budget
1613 /// by the terminal step, not left to slip through.
1614 #[test]
1615 fn test_cap_diagnostics_entry_size_terminal_fallback_bounds_untruncated_message() {
1616 let uri: Uri = Uri::from("file:///test.rs");
1617 let mut diagnostics = vec![minimal_diagnostic(
1618 "x".repeat(MAX_DIAGNOSTICS_ENTRY_BYTES + 1000),
1619 )];
1620
1621 cap_diagnostics_entry_size(&uri, &mut diagnostics);
1622
1623 assert_eq!(diagnostics.len(), 1);
1624 assert!(
1625 message_as_str(&diagnostics[0].message).len()
1626 <= DIAGNOSTIC_TERMINAL_FALLBACK_MESSAGE_BYTES + 20,
1627 "terminal fallback must truncate the message itself, got {} bytes",
1628 message_as_str(&diagnostics[0].message).len()
1629 );
1630 let serialized_len = serde_json::to_vec(&diagnostics).unwrap().len();
1631 assert!(
1632 serialized_len <= MAX_DIAGNOSTICS_ENTRY_BYTES,
1633 "postcondition must hold via the terminal fallback, got {serialized_len} bytes"
1634 );
1635 }
1636
1637 /// #311 S5: when no diagnostic carries `data`/`code_description`/
1638 /// `related_information`/`tags` and the cheap size estimate is already
1639 /// under budget, nothing should be modified -- the fast path must not
1640 /// alter content it didn't need to touch.
1641 #[test]
1642 fn test_store_diagnostics_cheap_path_leaves_small_diagnostics_untouched() {
1643 let mut cache = NotificationCache::new();
1644 let uri: Uri = Uri::from("file:///test.rs");
1645
1646 let mut diagnostic = minimal_diagnostic("a small, ordinary diagnostic message".to_string());
1647 diagnostic.source = Some("rustc".to_string());
1648
1649 cache.store_diagnostics(&test_server(), &uri, Some(1), vec![diagnostic]);
1650
1651 let stored = &cache.diagnostics(uri.as_ref()).unwrap().diagnostics;
1652 assert_eq!(stored.len(), 1);
1653 assert_eq!(
1654 stored[0].message,
1655 lsp_types::Message::String("a small, ordinary diagnostic message".to_string())
1656 );
1657 assert_eq!(stored[0].source.as_deref(), Some("rustc"));
1658 }
1659
1660 /// #311 S5 follow-up: the critic's exact counterexample. A NUL-heavy
1661 /// message's *raw* byte length looks small enough for the cheap
1662 /// estimate to skip the real check, but its *serialized* (JSON-escaped)
1663 /// size is up to `JSON_ESCAPE_WORST_CASE_FACTOR`x larger -- each NUL
1664 /// byte costs 6 bytes as `\u0000` once JSON-encoded. Three diagnostics
1665 /// at exactly `MAX_ENTRY_TEXT_BYTES` of NULs each previously passed the
1666 /// old raw-length estimate (787,200 bytes, under the 1 MiB cap) while
1667 /// actually serializing to roughly 4.5 MiB -- letting an entry ~4.5x
1668 /// over budget skip `fits`/truncation/terminal-fallback entirely.
1669 #[test]
1670 fn test_store_diagnostics_cheap_path_escape_safe_for_control_character_heavy_message() {
1671 let mut cache = NotificationCache::new();
1672 let uri: Uri = Uri::from("file:///test.rs");
1673
1674 let nul_heavy_message = "\0".repeat(MAX_ENTRY_TEXT_BYTES);
1675 let diagnostics: Vec<LspDiagnostic> = (0..3)
1676 .map(|_| minimal_diagnostic(nul_heavy_message.clone()))
1677 .collect();
1678
1679 cache.store_diagnostics(&test_server(), &uri, Some(1), diagnostics);
1680
1681 let stored = &cache.diagnostics(uri.as_ref()).unwrap().diagnostics;
1682 let serialized_len = serde_json::to_vec(stored).unwrap().len();
1683 assert!(
1684 serialized_len <= MAX_DIAGNOSTICS_ENTRY_BYTES,
1685 "escape-heavy content must not let the cheap-estimate fast path skip the real cap, \
1686 got {serialized_len} bytes"
1687 );
1688 }
1689
1690 #[test]
1691 fn test_store_diagnostics_replaces_existing() {
1692 let mut cache = NotificationCache::new();
1693 let uri: Uri = Uri::from("file:///test.rs");
1694
1695 cache.store_diagnostics(&test_server(), &uri, Some(1), vec![]);
1696 assert_eq!(cache.diagnostics_count(), 1);
1697
1698 cache.store_diagnostics(&test_server(), &uri, Some(2), vec![]);
1699 assert_eq!(cache.diagnostics_count(), 1);
1700
1701 let stored = cache.diagnostics(uri.as_ref()).unwrap();
1702 assert_eq!(stored.version, Some(2));
1703 }
1704
1705 #[test]
1706 fn test_clear_diagnostics() {
1707 let mut cache = NotificationCache::new();
1708 let uri: Uri = Uri::from("file:///test.rs");
1709
1710 cache.store_diagnostics(&test_server(), &uri, Some(1), vec![]);
1711 assert_eq!(cache.diagnostics_count(), 1);
1712
1713 let cleared = cache.clear_diagnostics(uri.as_ref());
1714 assert!(cleared.is_some());
1715 assert_eq!(cache.diagnostics_count(), 0);
1716 }
1717
1718 #[test]
1719 fn test_clear_all_diagnostics() {
1720 let mut cache = NotificationCache::new();
1721 let uri1: Uri = Uri::from("file:///test1.rs");
1722 let uri2: Uri = Uri::from("file:///test2.rs");
1723
1724 cache.store_diagnostics(&test_server(), &uri1, Some(1), vec![]);
1725 cache.store_diagnostics(&test_server(), &uri2, Some(1), vec![]);
1726 assert_eq!(cache.diagnostics_count(), 2);
1727
1728 cache.clear_all_diagnostics();
1729 assert_eq!(cache.diagnostics_count(), 0);
1730 }
1731
1732 #[test]
1733 fn test_store_and_get_logs() {
1734 let mut cache = NotificationCache::new();
1735
1736 cache.store_log(LogLevel::Error, "error message".to_string());
1737 cache.store_log(LogLevel::Info, "info message".to_string());
1738
1739 let logs = cache.logs();
1740 assert_eq!(logs.len(), 2);
1741 assert_eq!(logs[0].level, LogLevel::Error);
1742 assert_eq!(logs[0].message, "error message");
1743 assert_eq!(logs[1].level, LogLevel::Info);
1744 assert_eq!(logs[1].message, "info message");
1745 }
1746
1747 #[test]
1748 fn test_logs_max_capacity() {
1749 let mut cache = NotificationCache::new();
1750
1751 // Add more than MAX_LOG_ENTRIES
1752 for i in 0..MAX_LOG_ENTRIES + 10 {
1753 cache.store_log(LogLevel::Info, format!("message {i}"));
1754 }
1755
1756 assert_eq!(cache.logs_count(), MAX_LOG_ENTRIES);
1757
1758 // Oldest entries should be removed (FIFO)
1759 let logs = cache.logs();
1760 assert_eq!(logs.front().unwrap().message, "message 10");
1761 assert_eq!(
1762 logs.back().unwrap().message,
1763 format!("message {}", MAX_LOG_ENTRIES + 9)
1764 );
1765 }
1766
1767 /// #311: `MAX_LOG_ENTRIES` bounds the number of log entries, but not the
1768 /// size of any one entry -- an oversized message must be truncated
1769 /// rather than stored verbatim.
1770 #[test]
1771 fn test_store_log_truncates_oversized_message() {
1772 let mut cache = NotificationCache::new();
1773 let oversized = "a".repeat(MAX_ENTRY_TEXT_BYTES + 100);
1774
1775 cache.store_log(LogLevel::Info, oversized.clone());
1776
1777 let stored = &cache.logs()[0].message;
1778 assert!(stored.len() < oversized.len());
1779 assert!(stored.ends_with("... (truncated)"));
1780 }
1781
1782 #[test]
1783 fn test_store_log_does_not_truncate_message_at_or_below_limit() {
1784 let mut cache = NotificationCache::new();
1785 let message = "a".repeat(MAX_ENTRY_TEXT_BYTES);
1786
1787 cache.store_log(LogLevel::Info, message.clone());
1788
1789 assert_eq!(cache.logs()[0].message, message);
1790 }
1791
1792 #[test]
1793 fn test_clear_logs() {
1794 let mut cache = NotificationCache::new();
1795 cache.store_log(LogLevel::Info, "test".to_string());
1796 assert_eq!(cache.logs_count(), 1);
1797
1798 cache.clear_logs();
1799 assert_eq!(cache.logs_count(), 0);
1800 }
1801
1802 #[test]
1803 fn test_store_and_get_messages() {
1804 let mut cache = NotificationCache::new();
1805
1806 cache.store_message(MessageType::Error, "error msg".to_string());
1807 cache.store_message(MessageType::Warning, "warning msg".to_string());
1808
1809 let messages = cache.messages();
1810 assert_eq!(messages.len(), 2);
1811 assert_eq!(messages[0].message_type, MessageType::Error);
1812 assert_eq!(messages[0].message, "error msg");
1813 assert_eq!(messages[1].message_type, MessageType::Warning);
1814 assert_eq!(messages[1].message, "warning msg");
1815 }
1816
1817 #[test]
1818 fn test_messages_max_capacity() {
1819 let mut cache = NotificationCache::new();
1820
1821 // Add more than MAX_SERVER_MESSAGES
1822 for i in 0..MAX_SERVER_MESSAGES + 10 {
1823 cache.store_message(MessageType::Info, format!("message {i}"));
1824 }
1825
1826 assert_eq!(cache.messages_count(), MAX_SERVER_MESSAGES);
1827
1828 // Oldest entries should be removed (FIFO)
1829 let messages = cache.messages();
1830 assert_eq!(messages.front().unwrap().message, "message 10");
1831 assert_eq!(
1832 messages.back().unwrap().message,
1833 format!("message {}", MAX_SERVER_MESSAGES + 9)
1834 );
1835 }
1836
1837 #[test]
1838 fn test_clear_messages() {
1839 let mut cache = NotificationCache::new();
1840 cache.store_message(MessageType::Info, "test".to_string());
1841 assert_eq!(cache.messages_count(), 1);
1842
1843 cache.clear_messages();
1844 assert_eq!(cache.messages_count(), 0);
1845 }
1846
1847 /// #311: same per-entry byte cap as `store_log`, applied to server messages.
1848 #[test]
1849 fn test_store_message_truncates_oversized_message() {
1850 let mut cache = NotificationCache::new();
1851 let oversized = "a".repeat(MAX_ENTRY_TEXT_BYTES + 100);
1852
1853 cache.store_message(MessageType::Info, oversized.clone());
1854
1855 let stored = &cache.messages()[0].message;
1856 assert!(stored.len() < oversized.len());
1857 assert!(stored.ends_with("... (truncated)"));
1858 }
1859
1860 #[test]
1861 fn test_log_levels() {
1862 let mut cache = NotificationCache::new();
1863
1864 cache.store_log(LogLevel::Error, "error".to_string());
1865 cache.store_log(LogLevel::Warning, "warning".to_string());
1866 cache.store_log(LogLevel::Info, "info".to_string());
1867 cache.store_log(LogLevel::Debug, "debug".to_string());
1868
1869 let logs = cache.logs();
1870 assert_eq!(logs[0].level, LogLevel::Error);
1871 assert_eq!(logs[1].level, LogLevel::Warning);
1872 assert_eq!(logs[2].level, LogLevel::Info);
1873 assert_eq!(logs[3].level, LogLevel::Debug);
1874 }
1875
1876 #[test]
1877 fn test_message_types() {
1878 let mut cache = NotificationCache::new();
1879
1880 cache.store_message(MessageType::Error, "error".to_string());
1881 cache.store_message(MessageType::Warning, "warning".to_string());
1882 cache.store_message(MessageType::Info, "info".to_string());
1883 cache.store_message(MessageType::Log, "log".to_string());
1884
1885 let messages = cache.messages();
1886 assert_eq!(messages[0].message_type, MessageType::Error);
1887 assert_eq!(messages[1].message_type, MessageType::Warning);
1888 assert_eq!(messages[2].message_type, MessageType::Info);
1889 assert_eq!(messages[3].message_type, MessageType::Log);
1890 }
1891
1892 #[test]
1893 fn test_timestamp_ordering() {
1894 let mut cache = NotificationCache::new();
1895
1896 cache.store_log(LogLevel::Info, "first".to_string());
1897 std::thread::sleep(std::time::Duration::from_millis(10));
1898 cache.store_log(LogLevel::Info, "second".to_string());
1899
1900 let logs = cache.logs();
1901 assert!(logs[0].timestamp < logs[1].timestamp);
1902 }
1903
1904 #[test]
1905 fn test_store_diagnostics_empty_list() {
1906 let mut cache = NotificationCache::new();
1907 let uri: Uri = Uri::from("file:///test.rs");
1908
1909 let diagnostic = LspDiagnostic {
1910 range: Range {
1911 start: Position {
1912 line: 0,
1913 character: 0,
1914 },
1915 end: Position {
1916 line: 0,
1917 character: 5,
1918 },
1919 },
1920 severity: Some(lsp_types::DiagnosticSeverity::Error),
1921 message: "test error".to_string().into(),
1922 code: None,
1923 source: None,
1924 code_description: None,
1925 related_information: None,
1926 tags: None,
1927 data: None,
1928 };
1929
1930 cache.store_diagnostics(&test_server(), &uri, Some(1), vec![diagnostic]);
1931 assert_eq!(
1932 cache.diagnostics(uri.as_ref()).unwrap().diagnostics.len(),
1933 1
1934 );
1935
1936 cache.store_diagnostics(&test_server(), &uri, Some(2), vec![]);
1937 let stored = cache.diagnostics(uri.as_ref()).unwrap();
1938 assert_eq!(stored.diagnostics.len(), 0);
1939 assert_eq!(stored.version, Some(2));
1940 }
1941
1942 #[test]
1943 fn test_store_many_diagnostics_single_file() {
1944 let mut cache = NotificationCache::new();
1945 let uri: Uri = Uri::from("file:///test.rs");
1946
1947 let diagnostics: Vec<LspDiagnostic> = (0..100)
1948 .map(|i| LspDiagnostic {
1949 range: Range {
1950 start: Position {
1951 line: i,
1952 character: 0,
1953 },
1954 end: Position {
1955 line: i,
1956 character: 10,
1957 },
1958 },
1959 message: format!("Error {i}").into(),
1960 severity: Some(lsp_types::DiagnosticSeverity::Error),
1961 code: None,
1962 source: None,
1963 code_description: None,
1964 related_information: None,
1965 tags: None,
1966 data: None,
1967 })
1968 .collect();
1969
1970 cache.store_diagnostics(&test_server(), &uri, Some(1), diagnostics);
1971
1972 let stored = cache.diagnostics(uri.as_ref()).unwrap();
1973 assert_eq!(stored.diagnostics.len(), 100);
1974 }
1975
1976 #[test]
1977 fn test_logs_exact_capacity_boundary() {
1978 let mut cache = NotificationCache::new();
1979
1980 for i in 0..MAX_LOG_ENTRIES {
1981 cache.store_log(LogLevel::Info, format!("message {i}"));
1982 }
1983 assert_eq!(cache.logs_count(), MAX_LOG_ENTRIES);
1984
1985 cache.store_log(LogLevel::Info, "overflow".to_string());
1986 assert_eq!(cache.logs_count(), MAX_LOG_ENTRIES);
1987 assert_eq!(cache.logs().front().unwrap().message, "message 1");
1988 }
1989
1990 #[test]
1991 fn test_messages_exact_capacity_boundary() {
1992 let mut cache = NotificationCache::new();
1993
1994 for i in 0..MAX_SERVER_MESSAGES {
1995 cache.store_message(MessageType::Info, format!("message {i}"));
1996 }
1997 assert_eq!(cache.messages_count(), MAX_SERVER_MESSAGES);
1998
1999 cache.store_message(MessageType::Info, "overflow".to_string());
2000 assert_eq!(cache.messages_count(), MAX_SERVER_MESSAGES);
2001 assert_eq!(cache.messages().front().unwrap().message, "message 1");
2002 }
2003
2004 #[test]
2005 fn test_diagnostics_max_capacity() {
2006 let mut cache = NotificationCache::new();
2007
2008 for i in 0..MAX_DIAGNOSTIC_ENTRIES + 10 {
2009 let uri: Uri = Uri::from(format!("file:///test{i}.rs"));
2010 cache.store_diagnostics(&test_server(), &uri, Some(1), vec![]);
2011 }
2012
2013 assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
2014
2015 // Oldest entries should be evicted (FIFO).
2016 let evicted: Uri = Uri::from("file:///test0.rs");
2017 assert!(cache.diagnostics(evicted.as_ref()).is_none());
2018 let newest: Uri = Uri::from(format!("file:///test{}.rs", MAX_DIAGNOSTIC_ENTRIES + 9));
2019 assert!(cache.diagnostics(newest.as_ref()).is_some());
2020 }
2021
2022 #[test]
2023 fn test_diagnostics_replacing_existing_uri_does_not_trigger_eviction() {
2024 let mut cache = NotificationCache::new();
2025 let uri: Uri = Uri::from("file:///stable.rs");
2026
2027 for i in 0..MAX_DIAGNOSTIC_ENTRIES {
2028 cache.store_diagnostics(
2029 &test_server(),
2030 &uri,
2031 Some(i32::try_from(i).unwrap()),
2032 vec![],
2033 );
2034 }
2035 assert_eq!(cache.diagnostics_count(), 1);
2036 assert!(cache.diagnostics(uri.as_ref()).is_some());
2037 }
2038
2039 #[test]
2040 fn test_diagnostics_republish_refreshes_eviction_order() {
2041 // #234 S2 / #266 S3 regression: an actively-edited file, republished
2042 // on every keystroke, must not be evicted ahead of a file that was
2043 // merely opened once and never touched again.
2044 let mut cache = NotificationCache::new();
2045 let actively_edited: Uri = Uri::from("file:///keep.rs");
2046 cache.store_diagnostics(&test_server(), &actively_edited, Some(1), vec![]);
2047
2048 // Fill the rest of the cache with untouched entries.
2049 for i in 0..MAX_DIAGNOSTIC_ENTRIES - 1 {
2050 let uri: Uri = Uri::from(format!("file:///untouched{i}.rs"));
2051 cache.store_diagnostics(&test_server(), &uri, Some(1), vec![]);
2052 }
2053 assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
2054
2055 // Republish the actively-edited file -- this must move it to the
2056 // back of the eviction order, not leave it at its original (oldest)
2057 // position.
2058 cache.store_diagnostics(&test_server(), &actively_edited, Some(2), vec![]);
2059
2060 // One more new URI arrives, exceeding the cap by one: the oldest
2061 // *untouched* entry must be evicted, not the republished one.
2062 let overflow: Uri = Uri::from("file:///overflow.rs");
2063 cache.store_diagnostics(&test_server(), &overflow, Some(1), vec![]);
2064
2065 assert!(
2066 cache.diagnostics(actively_edited.as_ref()).is_some(),
2067 "republished entry must survive eviction after being refreshed"
2068 );
2069 let oldest_untouched: Uri = Uri::from("file:///untouched0.rs");
2070 assert!(
2071 cache.diagnostics(oldest_untouched.as_ref()).is_none(),
2072 "the oldest never-republished entry must be evicted instead"
2073 );
2074 assert!(cache.diagnostics(overflow.as_ref()).is_some());
2075 }
2076
2077 #[test]
2078 fn test_clear_diagnostics_then_refill_does_not_evict_early() {
2079 let mut cache = NotificationCache::new();
2080 let first: Uri = Uri::from("file:///first.rs");
2081 cache.store_diagnostics(&test_server(), &first, Some(1), vec![]);
2082 cache.clear_diagnostics(first.as_ref());
2083 assert_eq!(cache.diagnostics_count(), 0);
2084
2085 for i in 0..MAX_DIAGNOSTIC_ENTRIES {
2086 let uri: Uri = Uri::from(format!("file:///test{i}.rs"));
2087 cache.store_diagnostics(&test_server(), &uri, Some(1), vec![]);
2088 }
2089 assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
2090 // Every entry from this batch must still be present -- the earlier
2091 // clear must not have left a stale `diagnostic_order` entry that
2092 // causes a premature eviction here.
2093 let first_of_batch: Uri = Uri::from("file:///test0.rs");
2094 assert!(cache.diagnostics(first_of_batch.as_ref()).is_some());
2095 }
2096
2097 #[test]
2098 fn test_clear_diagnostics_nonexistent() {
2099 let mut cache = NotificationCache::new();
2100 let result = cache.clear_diagnostics("file:///nonexistent.rs");
2101 assert!(result.is_none());
2102 }
2103
2104 #[test]
2105 fn test_store_diagnostics_no_version() {
2106 let mut cache = NotificationCache::new();
2107 let uri: Uri = Uri::from("file:///test.rs");
2108
2109 cache.store_diagnostics(&test_server(), &uri, None, vec![]);
2110 let stored = cache.diagnostics(uri.as_ref()).unwrap();
2111 assert_eq!(stored.version, None);
2112 }
2113
2114 /// #266/#276: once the *aggregate* cache is full, a noisy server that has
2115 /// grown far past its fair share must have its own oldest entries
2116 /// evicted, never a quiet server's, even though both share one
2117 /// `NotificationCache` and the noisy server was allowed to keep growing
2118 /// past its static equal share while the aggregate still had room.
2119 #[test]
2120 fn test_noisy_server_does_not_evict_quiet_server_entries() {
2121 let mut cache = NotificationCache::new();
2122 cache.set_diagnostics_route_count(2);
2123 let noisy = ServerId::from("noisy");
2124 let quiet = ServerId::from("quiet");
2125
2126 let quiet_uri: Uri = Uri::from("file:///quiet/only_file.rs");
2127 cache.store_diagnostics(&quiet, &quiet_uri, Some(1), vec![]);
2128
2129 // Drive the noisy server well past the aggregate cap -- it must be
2130 // allowed to consume nearly all of it since the quiet server leaves
2131 // the rest unused (#276), and once the aggregate is full it must
2132 // only evict its own oldest entries.
2133 for i in 0..MAX_DIAGNOSTIC_ENTRIES + 50 {
2134 let uri: Uri = Uri::from(format!("file:///noisy/file{i}.rs"));
2135 cache.store_diagnostics(&noisy, &uri, Some(1), vec![]);
2136 }
2137
2138 assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
2139 assert!(
2140 cache.diagnostics(quiet_uri.as_ref()).is_some(),
2141 "quiet server's only entry must survive the noisy server's overflow"
2142 );
2143
2144 let noisy_first: Uri = Uri::from("file:///noisy/file0.rs");
2145 assert!(
2146 cache.diagnostics(noisy_first.as_ref()).is_none(),
2147 "noisy server's own oldest entries must be evicted once the aggregate cache is full"
2148 );
2149 }
2150
2151 /// #276: a dominant server must be able to exceed its static equal share
2152 /// of the budget while other registered diagnostics-route servers are
2153 /// idle -- eviction is work-conserving and only triggers once the
2154 /// *aggregate* cache reaches `MAX_DIAGNOSTIC_ENTRIES`, not once a single
2155 /// server passes `MAX_DIAGNOSTIC_ENTRIES / diagnostics_route_count`.
2156 #[test]
2157 fn test_dominant_server_exceeds_equal_share_while_others_idle() {
2158 let mut cache = NotificationCache::new();
2159 cache.set_diagnostics_route_count(4);
2160 let dominant = ServerId::from("dominant");
2161
2162 let equal_share = MAX_DIAGNOSTIC_ENTRIES / 4;
2163 let more_than_share = equal_share + 100;
2164 for i in 0..more_than_share {
2165 let uri: Uri = Uri::from(format!("file:///file{i}.rs"));
2166 cache.store_diagnostics(&dominant, &uri, Some(1), vec![]);
2167 }
2168 assert_eq!(
2169 cache.diagnostics_count(),
2170 more_than_share,
2171 "a dominant server must be able to exceed its static equal share while the aggregate has room"
2172 );
2173
2174 // The other three registered servers never write anything, so the
2175 // dominant server can keep growing all the way to the full budget.
2176 for i in more_than_share..MAX_DIAGNOSTIC_ENTRIES {
2177 let uri: Uri = Uri::from(format!("file:///file{i}.rs"));
2178 cache.store_diagnostics(&dominant, &uri, Some(1), vec![]);
2179 }
2180 assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
2181 }
2182
2183 /// M1: eviction-target ties (multiple servers holding the same entry
2184 /// count) must resolve deterministically, not depend on `HashMap`'s
2185 /// per-process randomized iteration order. This pins the exact winner
2186 /// rather than only checking repeat-call stability -- stability across
2187 /// calls would hold trivially even without the fix, since a single
2188 /// `HashMap` instance's iteration order does not change between calls
2189 /// within one process; the real risk is a *different* winner on a
2190 /// *different* process run, which this test can't observe directly, but
2191 /// the pinned assertion below only passes because the tie-break key
2192 /// (`(order.len(), id.as_str())`) is unique per server -- no two
2193 /// distinct `ServerId`s can ever share it, so `max_by_key` never
2194 /// actually has a tie left to resolve by iteration order.
2195 #[test]
2196 fn test_eviction_target_tie_break_is_deterministic() {
2197 let mut cache = NotificationCache::new();
2198 cache.set_diagnostics_route_count(1000); // fair share floors at 1
2199
2200 let a = ServerId::from("a");
2201 let b = ServerId::from("b");
2202 for i in 0..2 {
2203 let uri: Uri = Uri::from(format!("file:///a/file{i}.rs"));
2204 cache.store_diagnostics(&a, &uri, Some(1), vec![]);
2205 }
2206 for i in 0..2 {
2207 let uri: Uri = Uri::from(format!("file:///b/file{i}.rs"));
2208 cache.store_diagnostics(&b, &uri, Some(1), vec![]);
2209 }
2210
2211 // `a` and `b` are tied at 2 entries each, both over the floor-1
2212 // share -- `"b"` sorts after `"a"` lexicographically, so it is the
2213 // one always picked.
2214 let writer = ServerId::from("writer");
2215 assert_eq!(cache.server_to_evict_from(&writer), Some(b));
2216 }
2217
2218 /// M2: `server_to_evict_from`'s "largest in-share server" fallback is
2219 /// reachable and correct through the public `store_diagnostics` API,
2220 /// not just in isolation -- a brand-new server's first write must still
2221 /// evict something when the aggregate cache is already full purely from
2222 /// other servers that are each individually within their fair share.
2223 /// Without this fallback there would be nothing to evict from (the
2224 /// writer has no entries yet, and no one else exceeds their share) and
2225 /// the aggregate could grow past `MAX_DIAGNOSTIC_ENTRIES`.
2226 #[test]
2227 fn test_new_writer_still_evicts_when_every_existing_server_is_in_share() {
2228 let mut cache = NotificationCache::new();
2229 cache.set_diagnostics_route_count(2); // fair share = 500 each
2230
2231 let a = ServerId::from("a");
2232 let b = ServerId::from("b");
2233 for i in 0..500 {
2234 let uri: Uri = Uri::from(format!("file:///a/file{i}.rs"));
2235 cache.store_diagnostics(&a, &uri, Some(1), vec![]);
2236 }
2237 for i in 0..500 {
2238 let uri: Uri = Uri::from(format!("file:///b/file{i}.rs"));
2239 cache.store_diagnostics(&b, &uri, Some(1), vec![]);
2240 }
2241 assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
2242
2243 // `c` has never written before -- its very first write hits a full,
2244 // entirely-in-share aggregate.
2245 let c = ServerId::from("c");
2246 let new_uri: Uri = Uri::from("file:///c/first.rs");
2247 cache.store_diagnostics(&c, &new_uri, Some(1), vec![]);
2248
2249 assert_eq!(
2250 cache.diagnostics_count(),
2251 MAX_DIAGNOSTIC_ENTRIES,
2252 "the aggregate cap must still be enforced even when every existing server is within share"
2253 );
2254 assert!(cache.diagnostics(new_uri.as_ref()).is_some());
2255
2256 // `a` and `b` are tied at 500 entries each; the deterministic
2257 // tie-break in `server_to_evict_from` picks `b`, so `b`'s oldest
2258 // entry is the one evicted, not `a`'s.
2259 let b_oldest: Uri = Uri::from("file:///b/file0.rs");
2260 assert!(
2261 cache.diagnostics(b_oldest.as_ref()).is_none(),
2262 "the largest in-share server (tie-broken to b) must lose its oldest entry"
2263 );
2264 assert!(
2265 cache.diagnostics("file:///a/file0.rs").is_some(),
2266 "the other in-share server must be untouched"
2267 );
2268 }
2269
2270 /// Re-publishing diagnostics for a URI under its existing owner must not
2271 /// count as a new entry against that server's budget.
2272 #[test]
2273 fn test_repeated_writes_same_owner_do_not_grow_order_map() {
2274 let mut cache = NotificationCache::new();
2275 let server = ServerId::from("server");
2276 let uri: Uri = Uri::from("file:///test.rs");
2277
2278 let max_version = i32::try_from(MAX_DIAGNOSTIC_ENTRIES).unwrap() + 10;
2279 for version in 0..max_version {
2280 cache.store_diagnostics(&server, &uri, Some(version), vec![]);
2281 }
2282
2283 assert_eq!(cache.diagnostics_count(), 1);
2284 let stored = cache.diagnostics(uri.as_ref()).unwrap();
2285 assert_eq!(stored.version, Some(max_version - 1));
2286 }
2287
2288 /// If a URI's diagnostics route changes to a different server (e.g.
2289 /// after a respawn rebind), the entry must move to the new owner's
2290 /// order map rather than staying attributed to the old one.
2291 #[test]
2292 fn test_store_diagnostics_reassigns_ownership() {
2293 let mut cache = NotificationCache::new();
2294 let old_owner = ServerId::from("old");
2295 let new_owner = ServerId::from("new");
2296 let uri: Uri = Uri::from("file:///test.rs");
2297
2298 cache.store_diagnostics(&old_owner, &uri, Some(1), vec![]);
2299 cache.store_diagnostics(&new_owner, &uri, Some(2), vec![]);
2300
2301 assert_eq!(cache.diagnostics_count(), 1);
2302 let stored = cache.diagnostics(uri.as_ref()).unwrap();
2303 assert_eq!(stored.version, Some(2));
2304
2305 // The old owner's order map must no longer reference this URI:
2306 // filling the old owner's budget with fresh entries must not evict
2307 // this URI a second time (it's not there to evict) nor corrupt state.
2308 for i in 0..MAX_DIAGNOSTIC_ENTRIES + 5 {
2309 let other: Uri = Uri::from(format!("file:///old/file{i}.rs"));
2310 cache.store_diagnostics(&old_owner, &other, Some(1), vec![]);
2311 }
2312 assert!(cache.diagnostics(uri.as_ref()).is_some());
2313 }
2314
2315 /// #290: `diagnostics_owner` is what a cache-only read (e.g.
2316 /// `get_cached_diagnostics`) uses to resolve the publishing server's
2317 /// negotiated position encoding, so both branches -- an owner on record
2318 /// and none -- must behave correctly.
2319 #[test]
2320 fn test_diagnostics_owner_returns_publisher_after_store() {
2321 let mut cache = NotificationCache::new();
2322 let server = ServerId::from("rust");
2323 let uri: Uri = Uri::from("file:///main.rs");
2324
2325 cache.store_diagnostics(&server, &uri, Some(1), vec![]);
2326
2327 assert_eq!(cache.diagnostics_owner(uri.as_ref()), Some(&server));
2328 }
2329
2330 #[test]
2331 fn test_diagnostics_owner_none_for_untracked_uri() {
2332 let cache = NotificationCache::new();
2333 let uri: Uri = Uri::from("file:///never-seen.rs");
2334
2335 assert_eq!(cache.diagnostics_owner(uri.as_ref()), None);
2336 }
2337
2338 /// Reassigning ownership (see `test_store_diagnostics_reassigns_ownership`
2339 /// above) must also update `diagnostics_owner`, not just the cached
2340 /// content -- otherwise a stale owner's encoding would be used to
2341 /// convert a different server's diagnostics.
2342 #[test]
2343 fn test_diagnostics_owner_reflects_reassigned_ownership() {
2344 let mut cache = NotificationCache::new();
2345 let old_owner = ServerId::from("old");
2346 let new_owner = ServerId::from("new");
2347 let uri: Uri = Uri::from("file:///test.rs");
2348
2349 cache.store_diagnostics(&old_owner, &uri, Some(1), vec![]);
2350 assert_eq!(cache.diagnostics_owner(uri.as_ref()), Some(&old_owner));
2351
2352 cache.store_diagnostics(&new_owner, &uri, Some(2), vec![]);
2353 assert_eq!(cache.diagnostics_owner(uri.as_ref()), Some(&new_owner));
2354 }
2355
2356 /// #266 S2: clearing one server's diagnostics must not disturb another
2357 /// server's cached entries, unlike `clear_all_diagnostics`.
2358 #[test]
2359 fn test_clear_server_diagnostics_scopes_to_one_server() {
2360 let mut cache = NotificationCache::new();
2361 let crashed = ServerId::from("crashed");
2362 let healthy = ServerId::from("healthy");
2363
2364 let crashed_uri: Uri = Uri::from("file:///crashed/main.py");
2365 let healthy_uri: Uri = Uri::from("file:///healthy/main.rs");
2366 cache.store_diagnostics(&crashed, &crashed_uri, Some(1), vec![]);
2367 cache.store_diagnostics(&healthy, &healthy_uri, Some(1), vec![]);
2368
2369 cache.clear_server_diagnostics(&crashed);
2370
2371 assert!(cache.diagnostics(crashed_uri.as_ref()).is_none());
2372 assert!(cache.diagnostics(healthy_uri.as_ref()).is_some());
2373 assert_eq!(cache.diagnostics_count(), 1);
2374
2375 // Idempotent / no-op for a server with no (or no longer any) entries.
2376 cache.clear_server_diagnostics(&crashed);
2377 assert_eq!(cache.diagnostics_count(), 1);
2378 }
2379
2380 /// #359: `mark_push_degraded` must be scoped per server and, once set,
2381 /// stay set -- there is no "unmark" operation, since only a full mcpls
2382 /// process restart actually restores push diagnostics for a respawned
2383 /// server.
2384 #[test]
2385 fn test_push_degraded_is_scoped_per_server_and_permanent() {
2386 let mut cache = NotificationCache::new();
2387 let degraded = ServerId::from("degraded");
2388 let healthy = ServerId::from("healthy");
2389
2390 assert!(!cache.is_push_degraded(°raded));
2391 assert!(!cache.is_push_degraded(&healthy));
2392
2393 cache.mark_push_degraded(°raded);
2394
2395 assert!(cache.is_push_degraded(°raded));
2396 assert!(!cache.is_push_degraded(&healthy));
2397
2398 // Marking again is idempotent.
2399 cache.mark_push_degraded(°raded);
2400 assert!(cache.is_push_degraded(°raded));
2401 }
2402
2403 /// #276: `set_diagnostics_route_count` shrinking a server's fair share
2404 /// must not retroactively evict any of its already-cached entries --
2405 /// eviction is work-conserving and only fires once the *aggregate* cache
2406 /// is full. Once full, though, the shrunk share is what makes that
2407 /// server the eviction target for a *different* server's write, rather
2408 /// than the write that actually needed room being rejected or evicting
2409 /// its own (nonexistent) entries.
2410 #[test]
2411 fn test_shrinking_budget_affects_eviction_target_not_existing_entries() {
2412 let mut cache = NotificationCache::new();
2413 let server = ServerId::from("server");
2414
2415 for i in 0..MAX_DIAGNOSTIC_ENTRIES {
2416 let uri: Uri = Uri::from(format!("file:///file{i}.rs"));
2417 cache.store_diagnostics(&server, &uri, Some(1), vec![]);
2418 }
2419 assert_eq!(
2420 cache.diagnostics_count(),
2421 MAX_DIAGNOSTIC_ENTRIES,
2422 "filling to the aggregate cap must not evict anything early"
2423 );
2424
2425 // A drastic shrink relative to the entries `server` already holds --
2426 // must not evict anything by itself.
2427 cache.set_diagnostics_route_count(4);
2428 assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
2429
2430 // A different server's first write, once the aggregate is full,
2431 // evicts from `server` (now far over its shrunk share) instead.
2432 let other = ServerId::from("other");
2433 let new_uri: Uri = Uri::from("file:///other/new.rs");
2434 cache.store_diagnostics(&other, &new_uri, Some(1), vec![]);
2435
2436 assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
2437 assert!(cache.diagnostics(new_uri.as_ref()).is_some());
2438 let server_oldest: Uri = Uri::from("file:///file0.rs");
2439 assert!(
2440 cache.diagnostics(server_oldest.as_ref()).is_none(),
2441 "the pre-existing server's oldest entry, now far over its shrunk share, must be evicted"
2442 );
2443 }
2444
2445 /// #283: an external `NotificationCache` consumer that never calls
2446 /// `set_diagnostics_route_count` must still get fair-share partitioning
2447 /// once more than one server has written an entry -- the pre-#266
2448 /// regression this guards against is an unset count silently giving one
2449 /// server the entire aggregate budget, letting it starve a quiet server.
2450 #[test]
2451 fn test_fair_share_applies_by_default_without_explicit_route_count() {
2452 let mut cache = NotificationCache::new();
2453 let noisy = ServerId::from("noisy");
2454 let quiet = ServerId::from("quiet");
2455
2456 let quiet_uri: Uri = Uri::from("file:///quiet/only_file.rs");
2457 cache.store_diagnostics(&quiet, &quiet_uri, Some(1), vec![]);
2458
2459 // `set_diagnostics_route_count` is deliberately never called here.
2460 for i in 0..MAX_DIAGNOSTIC_ENTRIES + 50 {
2461 let uri: Uri = Uri::from(format!("file:///noisy/file{i}.rs"));
2462 cache.store_diagnostics(&noisy, &uri, Some(1), vec![]);
2463 }
2464
2465 assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
2466 assert!(
2467 cache.diagnostics(quiet_uri.as_ref()).is_some(),
2468 "quiet server's only entry must survive even without ever calling \
2469 set_diagnostics_route_count"
2470 );
2471 let noisy_first: Uri = Uri::from("file:///noisy/file0.rs");
2472 assert!(
2473 cache.diagnostics(noisy_first.as_ref()).is_none(),
2474 "the noisy server, now auto-derived as one of two servers sharing the budget, \
2475 must still lose its own oldest entries once over its fair share"
2476 );
2477 }
2478
2479 /// #283: with only one server ever writing, the auto-derived fair-share
2480 /// count (from `diagnostic_order.len()`) must stay `1`, letting that
2481 /// server use the whole aggregate budget -- the same as the old default
2482 /// of `1` when the setter went uncalled, not a regression for the
2483 /// common single-server case.
2484 #[test]
2485 fn test_single_server_gets_full_budget_without_explicit_route_count() {
2486 let mut cache = NotificationCache::new();
2487
2488 for i in 0..MAX_DIAGNOSTIC_ENTRIES {
2489 let uri: Uri = Uri::from(format!("file:///file{i}.rs"));
2490 cache.store_diagnostics(&test_server(), &uri, Some(1), vec![]);
2491 }
2492
2493 assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
2494 }
2495
2496 /// #284: when the cache is full and a server's own entry must be
2497 /// evicted, an entry with an empty (`[]`) diagnostics list -- a file
2498 /// reported as now clean -- must be evicted ahead of an older entry that
2499 /// still carries real diagnostics, even though the empty entry is not
2500 /// that server's strictly-oldest entry.
2501 #[test]
2502 fn test_empty_diagnostics_entries_evicted_before_non_empty_ones() {
2503 let mut cache = NotificationCache::new();
2504 let server = test_server();
2505
2506 let important: Uri = Uri::from("file:///important.rs");
2507 cache.store_diagnostics(
2508 &server,
2509 &important,
2510 Some(1),
2511 vec![minimal_diagnostic("real error".to_string())],
2512 );
2513
2514 // Fill the rest of the budget with empty ("file is clean") entries,
2515 // all published after `important` and so all newer in eviction
2516 // order.
2517 for i in 0..MAX_DIAGNOSTIC_ENTRIES - 1 {
2518 let uri: Uri = Uri::from(format!("file:///clean{i}.rs"));
2519 cache.store_diagnostics(&server, &uri, Some(1), vec![]);
2520 }
2521 assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
2522
2523 // One more new URI exceeds the cap: `important` is the strictly
2524 // oldest entry, but it must survive in favor of the oldest *empty*
2525 // entry instead.
2526 let overflow: Uri = Uri::from("file:///overflow.rs");
2527 cache.store_diagnostics(&server, &overflow, Some(1), vec![]);
2528
2529 assert!(
2530 cache.diagnostics(important.as_ref()).is_some(),
2531 "a non-empty entry must survive eviction over empty entries, even though it is older"
2532 );
2533 let oldest_clean: Uri = Uri::from("file:///clean0.rs");
2534 assert!(
2535 cache.diagnostics(oldest_clean.as_ref()).is_none(),
2536 "the oldest empty entry must be evicted instead of the older non-empty one"
2537 );
2538 assert!(cache.diagnostics(overflow.as_ref()).is_some());
2539 }
2540
2541 /// #284: storing an empty diagnostics list must still create a fully
2542 /// tracked, cacheable entry -- eviction priority changes which entry is
2543 /// removed once the cache is full, but does not change what gets stored
2544 /// in the first place. See the `tracked` field semantics in
2545 /// `mcp::server::ResourceDiagnosticsResponse` for why callers rely on
2546 /// this: a `[]` publish for a previously-tracked URI must read back as
2547 /// "tracked, zero diagnostics", not as untracked.
2548 #[test]
2549 fn test_empty_diagnostics_entry_is_still_tracked_until_evicted() {
2550 let mut cache = NotificationCache::new();
2551 let uri: Uri = Uri::from("file:///clean.rs");
2552
2553 cache.store_diagnostics(&test_server(), &uri, Some(1), vec![]);
2554
2555 let stored = cache.diagnostics(uri.as_ref());
2556 assert!(
2557 stored.is_some(),
2558 "an empty-diagnostics entry must still be tracked"
2559 );
2560 assert_eq!(stored.unwrap().diagnostics.len(), 0);
2561 }
2562
2563 /// #284 S1: an over-share server's own empty ("clean") entry must be
2564 /// evicted before a *different* over-share server's real diagnostics are
2565 /// destroyed, even when the fairness-selected victim (the largest
2566 /// over-share server) itself holds no empty entry of its own.
2567 #[test]
2568 fn test_over_share_servers_empty_entry_evicted_before_a_different_servers_real_diagnostic() {
2569 let mut cache = NotificationCache::new();
2570 cache.set_diagnostics_route_count(3); // fair share = 333
2571
2572 let a = ServerId::from("a"); // over share, all real diagnostics
2573 let b = ServerId::from("b"); // over share, all empty/clean
2574 let c = ServerId::from("c"); // within share
2575
2576 for i in 0..500 {
2577 let uri: Uri = Uri::from(format!("file:///a/file{i}.rs"));
2578 cache.store_diagnostics(
2579 &a,
2580 &uri,
2581 Some(1),
2582 vec![minimal_diagnostic(format!("error {i}"))],
2583 );
2584 }
2585 for i in 0..400 {
2586 let uri: Uri = Uri::from(format!("file:///b/file{i}.rs"));
2587 cache.store_diagnostics(&b, &uri, Some(1), vec![]);
2588 }
2589 for i in 0..100 {
2590 let uri: Uri = Uri::from(format!("file:///c/file{i}.rs"));
2591 cache.store_diagnostics(&c, &uri, Some(1), vec![]);
2592 }
2593 assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
2594
2595 // One more write from `a` (the fairness-selected victim, being the
2596 // largest over-share server) must not destroy any of `a`'s real
2597 // diagnostics: `b`, also over its share, has an empty entry to give
2598 // up instead.
2599 let overflow: Uri = Uri::from("file:///a/overflow.rs");
2600 cache.store_diagnostics(
2601 &a,
2602 &overflow,
2603 Some(1),
2604 vec![minimal_diagnostic("overflow error".to_string())],
2605 );
2606
2607 for i in 0..500 {
2608 let uri: Uri = Uri::from(format!("file:///a/file{i}.rs"));
2609 assert!(
2610 cache.diagnostics(uri.as_ref()).is_some(),
2611 "server a's real diagnostics must all survive; b has an empty entry to lose \
2612 instead"
2613 );
2614 }
2615 let b_oldest: Uri = Uri::from("file:///b/file0.rs");
2616 assert!(
2617 cache.diagnostics(b_oldest.as_ref()).is_none(),
2618 "b's oldest empty entry must be evicted instead of a's real diagnostics"
2619 );
2620 assert!(cache.diagnostics(overflow.as_ref()).is_some());
2621 }
2622
2623 /// #284: a URI that transitions non-empty -> empty -> non-empty must not
2624 /// be treated as still-empty for eviction priority after the second
2625 /// transition -- emptiness tracking must reflect the *current* state,
2626 /// not the URI's history.
2627 #[test]
2628 fn test_dirty_then_clean_then_dirty_again_updates_emptiness_tracking() {
2629 let mut cache = NotificationCache::new();
2630 let server = test_server();
2631 let uri: Uri = Uri::from("file:///flapping.rs");
2632
2633 cache.store_diagnostics(
2634 &server,
2635 &uri,
2636 Some(1),
2637 vec![minimal_diagnostic("first error".to_string())],
2638 );
2639 cache.store_diagnostics(&server, &uri, Some(2), vec![]); // now clean
2640 cache.store_diagnostics(
2641 &server,
2642 &uri,
2643 Some(3),
2644 vec![minimal_diagnostic("second error".to_string())],
2645 ); // dirty again
2646
2647 // Fill the rest of the budget with genuinely empty entries -- if
2648 // `uri` were still (wrongly) tracked as empty, one of these would be
2649 // evicted in its place instead of `uri` being left alone.
2650 for i in 0..MAX_DIAGNOSTIC_ENTRIES - 1 {
2651 let other: Uri = Uri::from(format!("file:///clean{i}.rs"));
2652 cache.store_diagnostics(&server, &other, Some(1), vec![]);
2653 }
2654 assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
2655
2656 let overflow: Uri = Uri::from("file:///overflow.rs");
2657 cache.store_diagnostics(&server, &overflow, Some(1), vec![]);
2658
2659 let stored = cache.diagnostics(uri.as_ref());
2660 assert!(
2661 stored.is_some_and(|info| info.diagnostics.len() == 1),
2662 "the re-dirtied entry must survive and keep its real diagnostic, not be mistaken \
2663 for an empty entry"
2664 );
2665 }
2666
2667 /// `set_diagnostics_route_count(0)` must clamp to `1`, not panic via
2668 /// division by zero in `per_server_budget`.
2669 #[test]
2670 fn test_set_diagnostics_route_count_zero_clamps_to_one() {
2671 let mut cache = NotificationCache::new();
2672 cache.set_diagnostics_route_count(0);
2673
2674 for i in 0..MAX_DIAGNOSTIC_ENTRIES + 5 {
2675 let uri: Uri = Uri::from(format!("file:///file{i}.rs"));
2676 cache.store_diagnostics(&test_server(), &uri, Some(1), vec![]);
2677 }
2678
2679 assert_eq!(cache.diagnostics_count(), MAX_DIAGNOSTIC_ENTRIES);
2680 }
2681
2682 // Transition logic is unit-tested in `bridge::indexing`; these tests only cover delegation wiring.
2683
2684 #[test]
2685 fn test_indexing_state_defaults_unknown() {
2686 let cache = NotificationCache::new();
2687 assert_eq!(cache.indexing_state(&test_server()), IndexingState::Unknown);
2688 }
2689
2690 #[test]
2691 fn test_observe_indexing_signal_delegates_to_tracker() {
2692 let mut cache = NotificationCache::new();
2693 let server = test_server();
2694 cache.observe_indexing_signal(
2695 &server,
2696 "experimental/serverStatus",
2697 Some(&serde_json::json!({"quiescent": false})),
2698 );
2699 assert_eq!(cache.indexing_state(&server), IndexingState::Loading);
2700 }
2701
2702 #[test]
2703 fn test_reset_indexing_state_delegates_to_tracker() {
2704 let mut cache = NotificationCache::new();
2705 let server = test_server();
2706 cache.observe_indexing_signal(
2707 &server,
2708 "experimental/serverStatus",
2709 Some(&serde_json::json!({"quiescent": false})),
2710 );
2711 cache.reset_indexing_state(&server);
2712 assert_eq!(cache.indexing_state(&server), IndexingState::Unknown);
2713 }
2714}