mcpls_core/bridge/resources.rs
1//! MCP resource URI codec and subscription tracking for LSP diagnostics.
2//!
3//! Resources in mcpls use the `lsp-diagnostics:///` scheme (RFC 3986 compliant,
4//! empty authority, percent-encoded path). Each resource corresponds to a single
5//! file whose diagnostics are cached from LSP `textDocument/publishDiagnostics`
6//! notifications.
7
8use std::collections::{HashMap, HashSet};
9use std::path::{Path, PathBuf};
10use std::sync::{Arc, Mutex as StdMutex, Weak};
11
12use thiserror::Error;
13use tokio::sync::RwLock;
14use url::Url;
15
16use super::lock_std;
17use super::state::encode_rfc3986_path_chars;
18
19/// URI scheme used for diagnostic resources.
20const SCHEME: &str = "lsp-diagnostics";
21
22/// Full scheme + authority prefix (`scheme://`).
23///
24/// Three-slash form (`lsp-diagnostics:///`) is produced by appending an empty
25/// authority and the absolute path: `{PREFIX}{path}`.
26const PREFIX: &str = "lsp-diagnostics://";
27
28/// Maximum number of resource URIs a single client session may subscribe to.
29///
30/// Guards against memory exhaustion from a misbehaving or adversarial client.
31pub const MAX_SUBSCRIPTIONS: usize = 1_000;
32
33/// Errors produced by the resource URI codec.
34#[derive(Debug, Error)]
35pub enum ResourceUriError {
36 /// The path is relative or contains non-UTF-8 components.
37 #[error("path must be absolute and valid UTF-8: {0}")]
38 InvalidPath(String),
39
40 /// The URI has the wrong scheme or malformed structure.
41 #[error("expected '{SCHEME}:///' prefix in URI: {0}")]
42 InvalidScheme(String),
43
44 /// The URI path could not be decoded to a filesystem path.
45 #[error("failed to decode URI to filesystem path: {0}")]
46 DecodeFailed(String),
47}
48
49/// Errors produced when adding a URI to a [`ResourceSubscriptions`] set.
50#[derive(Debug, Error, PartialEq, Eq)]
51pub enum SubscriptionError {
52 /// The session's subscription set has already reached [`MAX_SUBSCRIPTIONS`].
53 #[error("subscription limit of {MAX_SUBSCRIPTIONS} reached")]
54 LimitReached,
55}
56
57impl From<SubscriptionError> for crate::error::Error {
58 fn from(err: SubscriptionError) -> Self {
59 match err {
60 SubscriptionError::LimitReached => Self::SubscriptionLimitReached {
61 max: MAX_SUBSCRIPTIONS,
62 },
63 }
64 }
65}
66
67/// Encode an absolute filesystem path into a `lsp-diagnostics:///…` resource URI.
68///
69/// Percent-encoding is delegated to [`url::Url::from_file_path`], which
70/// handles spaces, unicode, `%`, `?`, `#`, and platform separators correctly,
71/// plus an additional pass for the RFC 3986 §2.2 "other reserved" characters
72/// (`[ ] ^ |`) that `url` otherwise leaves unescaped — the same encoding
73/// applied to `file://` URIs.
74///
75/// # Errors
76///
77/// Returns [`ResourceUriError::InvalidPath`] if the path is relative or
78/// cannot be expressed as a valid file URI.
79///
80/// # Examples
81///
82/// ```
83/// use std::path::Path;
84/// use mcpls_core::bridge::resources::make_uri;
85///
86/// let uri = make_uri(Path::new("/home/user/main.rs")).unwrap();
87/// assert!(uri.starts_with("lsp-diagnostics:///"));
88/// ```
89pub fn make_uri(path: &Path) -> Result<String, ResourceUriError> {
90 let file_url = Url::from_file_path(path)
91 .map_err(|()| ResourceUriError::InvalidPath(path.display().to_string()))?;
92
93 // Replace the "file" scheme with our custom scheme while keeping the
94 // percent-encoded path and authority (empty) components.
95 let encoded = encode_rfc3986_path_chars(&file_url);
96 let after_scheme = encoded.strip_prefix(file_url.scheme()).unwrap_or(&encoded);
97 let uri = format!("{SCHEME}{after_scheme}");
98 Ok(uri)
99}
100
101/// Decode a `lsp-diagnostics:///…` resource URI back to an absolute filesystem path.
102///
103/// # Errors
104///
105/// Returns an error if the URI does not start with the expected scheme,
106/// or if the percent-encoded path cannot be mapped to a filesystem path.
107///
108/// # Examples
109///
110/// ```
111/// use std::path::Path;
112/// use mcpls_core::bridge::resources::{make_uri, parse_uri};
113///
114/// let path = Path::new("/home/user/main.rs");
115/// let uri = make_uri(path).unwrap();
116/// let recovered = parse_uri(&uri).unwrap();
117/// assert_eq!(recovered, path);
118/// ```
119pub fn parse_uri(uri: &str) -> Result<PathBuf, ResourceUriError> {
120 if !uri.starts_with(PREFIX) {
121 return Err(ResourceUriError::InvalidScheme(uri.to_string()));
122 }
123
124 // Require empty authority: the character immediately after `://` must be `/`.
125 // This blocks `lsp-diagnostics://evil-host/path` → UNC path on Windows.
126 let after_prefix = &uri[PREFIX.len()..];
127 if !after_prefix.starts_with('/') {
128 return Err(ResourceUriError::InvalidScheme(format!(
129 "non-empty authority in URI: {uri}"
130 )));
131 }
132
133 let file_uri = format!("file://{after_prefix}");
134 let url = Url::parse(&file_uri).map_err(|e| ResourceUriError::DecodeFailed(e.to_string()))?;
135
136 url.to_file_path()
137 .map_err(|()| ResourceUriError::DecodeFailed(file_uri))
138}
139
140/// Internal state guarded by [`ResourceSubscriptions`]'s `RwLock`.
141#[derive(Debug, Default)]
142struct SubscriptionState {
143 /// Canonical resource URIs currently subscribed -- what the diagnostics
144 /// pump checks against.
145 canonical: HashSet<String>,
146 /// Client-supplied ("raw") URI -> canonical URI, recorded at subscribe
147 /// time for entries where the two differ (symlink, macOS `/var` vs
148 /// `/private/var`, ...). Lets a later `unsubscribe` for the same raw URI
149 /// still resolve to the right entry even if canonicalizing it at
150 /// unsubscribe time fails, e.g. because the file was deleted since
151 /// subscribing (#499).
152 aliases: HashMap<String, String>,
153}
154
155/// Tracks which MCP resource URIs the client has subscribed to.
156///
157/// The hot read path (pump tasks checking before sending notifications) uses
158/// a `RwLock` so concurrent readers do not block each other.
159#[derive(Debug)]
160pub struct ResourceSubscriptions(RwLock<SubscriptionState>);
161
162impl Default for ResourceSubscriptions {
163 fn default() -> Self {
164 Self::new()
165 }
166}
167
168impl ResourceSubscriptions {
169 /// Create an empty subscription set.
170 #[must_use]
171 pub fn new() -> Self {
172 Self(RwLock::new(SubscriptionState::default()))
173 }
174
175 /// Add a URI to the subscription set.
176 ///
177 /// Returns `Ok(true)` if newly inserted, `Ok(false)` if already present.
178 ///
179 /// # Errors
180 ///
181 /// Returns [`SubscriptionError::LimitReached`] if the set has already
182 /// reached [`MAX_SUBSCRIPTIONS`] and `uri` is not already a member.
183 pub async fn subscribe(&self, uri: String) -> Result<bool, SubscriptionError> {
184 let mut state = self.0.write().await;
185 if !state.canonical.contains(&uri) && state.canonical.len() >= MAX_SUBSCRIPTIONS {
186 return Err(SubscriptionError::LimitReached);
187 }
188 Ok(state.canonical.insert(uri))
189 }
190
191 /// Record that `raw_uri` (the client-supplied URI, before
192 /// canonicalization) currently corresponds to `canonical_uri`, so a
193 /// later [`Self::unsubscribe`] for the same raw URI still resolves even
194 /// if canonicalization fails by then (#499). A no-op when the two are
195 /// equal, or when `canonical_uri` is not (or is no longer) an actual
196 /// subscribed entry -- the latter also closes a race against a
197 /// concurrent [`Self::unsubscribe`] landing between a caller's own
198 /// `subscribe`/`record_alias` pair.
199 ///
200 /// Drops any existing alias that already points at `canonical_uri`
201 /// before inserting the new one, so at most one alias is kept per
202 /// canonical entry. This keeps the alias map's size structurally bounded
203 /// by the canonical set's size (itself capped at [`MAX_SUBSCRIPTIONS`] by
204 /// [`Self::subscribe`]), instead of an independent bound: without this,
205 /// re-subscribing under many distinct raw encodings of the same
206 /// already-subscribed file (each a no-op against the canonical set, so
207 /// never gated by the subscribe cap) could otherwise exhaust an
208 /// independent alias-count bound on a single file, starving aliases for
209 /// every other subscription.
210 ///
211 /// Residual (#499): one-alias-per-canonical narrows but does not fully
212 /// eliminate the leak this exists to close. A file subscribed under two
213 /// distinct raw URIs, then deleted, then unsubscribed via the
214 /// non-latest raw form still leaks one slot -- self-healing the moment
215 /// the client instead unsubscribes via the latest recorded form.
216 pub(crate) async fn record_alias(&self, raw_uri: String, canonical_uri: String) {
217 if raw_uri == canonical_uri {
218 return;
219 }
220 let mut state = self.0.write().await;
221 if !state.canonical.contains(&canonical_uri) {
222 return;
223 }
224 state.aliases.retain(|_, c| c != &canonical_uri);
225 state.aliases.insert(raw_uri, canonical_uri);
226 }
227
228 /// Check whether the subscription set is empty.
229 ///
230 /// Used as a fast path in the diagnostics pump to skip URI construction
231 /// when no client has subscribed yet.
232 pub async fn is_empty(&self) -> bool {
233 self.0.read().await.canonical.is_empty()
234 }
235
236 /// Remove a URI from the subscription set.
237 ///
238 /// Tries `uri` directly against the canonical set first, then falls back
239 /// to resolving it as a recorded raw alias (see `Self::record_alias`,
240 /// crate-private) -- covers a caller that could not canonicalize the path at
241 /// unsubscribe time (e.g. the file was deleted since subscribing) and so
242 /// passed the same raw URI it originally subscribed with.
243 ///
244 /// Returns `true` if a subscription was found and removed.
245 pub async fn unsubscribe(&self, uri: &str) -> bool {
246 let mut state = self.0.write().await;
247 if state.canonical.remove(uri) {
248 state.aliases.retain(|_, canonical| canonical != uri);
249 return true;
250 }
251 if let Some(canonical) = state.aliases.remove(uri) {
252 state.aliases.retain(|_, c| c != &canonical);
253 return state.canonical.remove(&canonical);
254 }
255 false
256 }
257
258 /// Check if a URI is currently subscribed.
259 pub async fn contains(&self, uri: &str) -> bool {
260 self.0.read().await.canonical.contains(uri)
261 }
262
263 /// Return a snapshot of all subscribed URIs (primarily for tests).
264 pub async fn snapshot(&self) -> Vec<String> {
265 self.0.read().await.canonical.iter().cloned().collect()
266 }
267}
268
269/// Tracks every live session's [`ResourceSubscriptions`] set for one mcpls process.
270///
271/// Lets a process-wide reader (the diagnostics pump) ask "does any live
272/// session want this URI?" without holding a strong reference to any one
273/// session's set.
274///
275/// Each HTTP session gets its own [`ResourceSubscriptions`] (registered via
276/// [`register`](Self::register)) so [`MAX_SUBSCRIPTIONS`] caps per session
277/// rather than process-wide, and unsubscribe/subscribe calls from one session
278/// can never affect another's entries. The registry holds only [`Weak`]
279/// references, so a session's set becomes reclaimable the moment nothing else
280/// holds it (i.e. when the session's `McplsServer` instance is dropped on
281/// close) — no explicit close-time bookkeeping is required.
282///
283/// # Reclaim is lazy, not synchronous with session close
284///
285/// Unlike `CappedSessionManager`'s concurrency permit, which `close_session`
286/// frees synchronously the moment a session ends, a dead entry here is only
287/// *actually* dropped from the backing `Vec` the next time [`Self::register`],
288/// [`Self::any_contains`], or [`Self::is_all_empty`] runs (each prunes dead
289/// entries as a side effect). If sessions stop churning and no LSP server is
290/// publishing diagnostics, already-dead entries can sit unpruned indefinitely
291/// -- bounded (never growing past what churn has actually produced, since
292/// [`Self::register`] itself prunes on every call) but not immediate. This is
293/// a deliberate GC-on-next-use design, not a leak: the cost is a few dead
294/// `Weak` slots, never unbounded growth or a wrong query answer.
295///
296/// # Known limitation: rmcp's stateless HTTP path (#482)
297///
298/// This is scoped per `McplsServer` *instance*, not per durable client
299/// identity, which only coincides with "per session" on rmcp's legacy
300/// (`initialize`-handshake) session path -- rmcp also serves some requests
301/// through a stateless, per-request path with a fresh, ephemeral instance.
302/// `mcp::server`'s `reject_if_stateless_http`/`is_stateless_http_request`
303/// detect that case on `subscribe`/`unsubscribe` and reject it explicitly
304/// instead of silently losing the subscription; see their docs for the exact
305/// mechanism. [`MAX_SUBSCRIPTIONS`] is unaffected by this gap: nothing is
306/// ever recorded into a set on the path those functions reject. See
307/// [issue #482](https://github.com/bug-ops/mcpls/issues/482); `crate::transport`'s
308/// test module has HTTP-level regression coverage.
309#[derive(Debug, Default, Clone)]
310pub struct SubscriptionRegistry(Arc<StdMutex<Vec<Weak<ResourceSubscriptions>>>>);
311
312impl SubscriptionRegistry {
313 /// Create an empty registry.
314 ///
315 /// # Examples
316 ///
317 /// ```
318 /// use mcpls_core::bridge::resources::SubscriptionRegistry;
319 ///
320 /// let registry = SubscriptionRegistry::new();
321 /// let subs = registry.register();
322 /// assert!(!std::sync::Arc::ptr_eq(&subs, ®istry.register()));
323 /// ```
324 #[must_use]
325 pub fn new() -> Self {
326 Self::default()
327 }
328
329 /// Create a fresh [`ResourceSubscriptions`] set scoped to one session and
330 /// register it for the aggregate queries below.
331 ///
332 /// The returned `Arc` is the session's only strong reference; once it (and
333 /// any clone of it) is dropped, the registry drops the entry on the next
334 /// call to this method or to [`Self::any_contains`]/[`Self::is_all_empty`]
335 /// rather than keeping it alive. Pruning dead entries here (not only in
336 /// those aggregate queries) keeps the registry bounded even when nothing
337 /// ever calls them -- e.g. a workspace with no LSP server publishing
338 /// diagnostics -- under sustained registration churn (a `register` call
339 /// per request, not per session, on rmcp's stateless HTTP path; see this
340 /// type's "Known limitation" section above).
341 #[must_use]
342 pub fn register(&self) -> Arc<ResourceSubscriptions> {
343 let subs = Arc::new(ResourceSubscriptions::new());
344 let mut guard = lock_std(&self.0);
345 guard.retain(|weak| weak.strong_count() > 0);
346 guard.push(Arc::downgrade(&subs));
347 subs
348 }
349
350 /// Upgrade every still-live entry, dropping dead ones from the registry
351 /// along the way so it doesn't grow unbounded across session churn.
352 ///
353 /// `pub(crate)` (not private) so a caller checking more than one thing
354 /// against the same point-in-time set of sessions -- the diagnostics pump
355 /// checks both "is everything empty" and "does anyone want this URI" per
356 /// notification -- can take one snapshot and query it twice, instead of
357 /// each of [`Self::any_contains`]/[`Self::is_all_empty`] separately
358 /// locking the registry and re-upgrading every `Weak`.
359 pub(crate) fn live_sessions(&self) -> Vec<Arc<ResourceSubscriptions>> {
360 let mut guard = lock_std(&self.0);
361 guard.retain(|weak| weak.strong_count() > 0);
362 guard.iter().filter_map(Weak::upgrade).collect()
363 }
364
365 /// Whether any live session has subscribed to `uri`.
366 ///
367 /// # Examples
368 ///
369 /// ```
370 /// use mcpls_core::bridge::resources::SubscriptionRegistry;
371 ///
372 /// tokio::runtime::Runtime::new().unwrap().block_on(async {
373 /// let registry = SubscriptionRegistry::new();
374 /// let subs = registry.register();
375 /// subs.subscribe("lsp-diagnostics:///a.rs".to_string())
376 /// .await
377 /// .unwrap();
378 /// assert!(registry.any_contains("lsp-diagnostics:///a.rs").await);
379 /// });
380 /// ```
381 pub async fn any_contains(&self, uri: &str) -> bool {
382 for subs in self.live_sessions() {
383 if subs.contains(uri).await {
384 return true;
385 }
386 }
387 false
388 }
389
390 /// Whether every live session's subscription set is empty.
391 ///
392 /// Used as a fast path in the diagnostics pump to skip URI construction
393 /// when no live session has subscribed to anything yet.
394 ///
395 /// # Examples
396 ///
397 /// ```
398 /// use mcpls_core::bridge::resources::SubscriptionRegistry;
399 ///
400 /// tokio::runtime::Runtime::new().unwrap().block_on(async {
401 /// let registry = SubscriptionRegistry::new();
402 /// assert!(registry.is_all_empty().await);
403 ///
404 /// let subs = registry.register();
405 /// subs.subscribe("lsp-diagnostics:///a.rs".to_string())
406 /// .await
407 /// .unwrap();
408 /// assert!(!registry.is_all_empty().await);
409 /// });
410 /// ```
411 pub async fn is_all_empty(&self) -> bool {
412 for subs in self.live_sessions() {
413 if !subs.is_empty().await {
414 return false;
415 }
416 }
417 true
418 }
419
420 /// Raw number of entries currently stored, dead or alive, *without*
421 /// pruning them first.
422 ///
423 /// Test-only, and deliberately not pruning: a version of this method that
424 /// pruned before reading would report the same small number whether or
425 /// not [`Self::register`] itself prunes on the way in, masking exactly
426 /// the growth regression this exists to catch. Reading the unpruned
427 /// length is what lets an integration test tell "many dead entries piled
428 /// up because nothing ever called a pruning method" apart from "the
429 /// registry stayed bounded".
430 #[cfg(test)]
431 pub(crate) fn raw_len(&self) -> usize {
432 lock_std(&self.0).len()
433 }
434}
435
436#[cfg(test)]
437#[allow(clippy::unwrap_used, clippy::expect_used)]
438mod tests {
439 use super::*;
440
441 // ------------------------------------------------------------------
442 // URI codec
443 // ------------------------------------------------------------------
444
445 #[test]
446 fn test_make_uri_rejects_relative_path() {
447 let result = make_uri(Path::new("relative/path.rs"));
448 assert!(result.is_err());
449 }
450
451 #[test]
452 fn test_parse_uri_rejects_wrong_scheme() {
453 let result = parse_uri("file:///home/user/main.rs");
454 assert!(result.is_err());
455 }
456
457 #[test]
458 fn test_parse_uri_rejects_http_scheme() {
459 let result = parse_uri("https://example.com/file.rs");
460 assert!(result.is_err());
461 }
462
463 #[cfg(unix)]
464 #[test]
465 fn test_make_uri_simple_path() {
466 let uri = make_uri(Path::new("/home/user/main.rs")).unwrap();
467 assert_eq!(uri, "lsp-diagnostics:///home/user/main.rs");
468 }
469
470 #[cfg(unix)]
471 #[test]
472 fn test_make_uri_scheme_prefix() {
473 let uri = make_uri(Path::new("/tmp/file.rs")).unwrap();
474 assert!(uri.starts_with("lsp-diagnostics:///"));
475 }
476
477 #[cfg(unix)]
478 #[test]
479 fn test_parse_uri_simple() {
480 let path = PathBuf::from("/home/user/main.rs");
481 let uri = make_uri(&path).unwrap();
482 let recovered = parse_uri(&uri).unwrap();
483 assert_eq!(recovered, path);
484 }
485
486 /// Round-trip: paths with spaces, unicode, `%`, `?`, `#`.
487 #[cfg(unix)]
488 #[test]
489 fn test_round_trip_special_chars() {
490 let paths = [
491 "/home/user/my file.rs",
492 "/tmp/café/main.rs",
493 "/data/100%/test.rs",
494 "/workspace/query?param/file.rs",
495 "/repo/branch#fragment/src.rs",
496 "/путь/к/файлу.rs",
497 ];
498
499 for raw in &paths {
500 let path = PathBuf::from(raw);
501 let uri = make_uri(&path).expect(raw);
502 assert!(
503 uri.starts_with("lsp-diagnostics:///"),
504 "URI should start with correct scheme: {uri}"
505 );
506 let recovered = parse_uri(&uri).expect(&uri);
507 assert_eq!(recovered, path, "Round-trip failed for: {raw}");
508 }
509 }
510
511 /// Snapshot test: verify the on-wire form uses three slashes and percent-encoding.
512 #[cfg(unix)]
513 #[test]
514 fn test_wire_format_percent_encoded() {
515 let path = Path::new("/home/user/my file.rs");
516 let uri = make_uri(path).unwrap();
517 // Space must be percent-encoded as %20
518 assert!(uri.contains("%20"), "Expected %20 in: {uri}");
519 assert!(uri.starts_with("lsp-diagnostics:///"));
520 }
521
522 /// #265 regression: all seven RFC 3986 §2.2 "other reserved" characters
523 /// must be percent-encoded in `lsp-diagnostics://` URIs, same as
524 /// `file://` URIs from `try_path_to_uri` (see
525 /// `test_path_to_uri_percent_encodes_all_rfc3986_other_reserved_chars`
526 /// in `state.rs`). `{`, `}`, and backtick are already encoded by the
527 /// `url` crate on serialization; `[`, `]`, `^`, `|` are handled
528 /// explicitly by `encode_rfc3986_path_chars`.
529 #[cfg(unix)]
530 #[test]
531 fn test_make_uri_percent_encodes_reserved_chars() {
532 let path = Path::new("/home/user/test[]^|{}`.ts");
533 let uri = make_uri(path).unwrap();
534
535 for (raw, encoded) in [
536 ('[', "%5B"),
537 (']', "%5D"),
538 ('^', "%5E"),
539 ('|', "%7C"),
540 ('{', "%7B"),
541 ('}', "%7D"),
542 ('`', "%60"),
543 ] {
544 assert!(
545 uri.contains(encoded),
546 "expected {raw:?} to be percent-encoded as {encoded} in {uri}"
547 );
548 }
549 assert!(
550 !uri.contains(['[', ']', '^', '|', '{', '}', '`']),
551 "no raw reserved characters should remain in {uri}"
552 );
553 assert_eq!(parse_uri(&uri).unwrap(), path);
554 }
555
556 // ------------------------------------------------------------------
557 // ResourceSubscriptions
558 // ------------------------------------------------------------------
559
560 #[tokio::test]
561 async fn test_subscribe_and_contains() {
562 let subs = ResourceSubscriptions::new();
563 let uri = "lsp-diagnostics:///home/user/main.rs".to_string();
564
565 assert!(!subs.contains(&uri).await);
566 assert!(subs.subscribe(uri.clone()).await.unwrap());
567 assert!(subs.contains(&uri).await);
568 }
569
570 #[tokio::test]
571 async fn test_subscribe_duplicate_returns_false() {
572 let subs = ResourceSubscriptions::new();
573 let uri = "lsp-diagnostics:///tmp/file.rs".to_string();
574 assert!(subs.subscribe(uri.clone()).await.unwrap());
575 assert!(!subs.subscribe(uri).await.unwrap());
576 }
577
578 #[tokio::test]
579 async fn test_unsubscribe() {
580 let subs = ResourceSubscriptions::new();
581 let uri = "lsp-diagnostics:///tmp/file.rs".to_string();
582 subs.subscribe(uri.clone()).await.unwrap();
583 assert!(subs.unsubscribe(&uri).await);
584 assert!(!subs.contains(&uri).await);
585 }
586
587 #[tokio::test]
588 async fn test_unsubscribe_nonexistent_returns_false() {
589 let subs = ResourceSubscriptions::new();
590 assert!(!subs.unsubscribe("lsp-diagnostics:///nonexistent.rs").await);
591 }
592
593 /// #499: a stale raw URI recorded via `record_alias` must still resolve
594 /// to the canonical entry `subscribe` created, even though the raw and
595 /// canonical strings differ (e.g. a symlink or macOS `/var` vs
596 /// `/private/var`).
597 #[tokio::test]
598 async fn test_unsubscribe_resolves_recorded_alias() {
599 let subs = ResourceSubscriptions::new();
600 let raw = "lsp-diagnostics:///var/tmp/file.rs".to_string();
601 let canonical = "lsp-diagnostics:///private/var/tmp/file.rs".to_string();
602
603 subs.subscribe(canonical.clone()).await.unwrap();
604 subs.record_alias(raw.clone(), canonical.clone()).await;
605 assert!(subs.contains(&canonical).await);
606
607 assert!(subs.unsubscribe(&raw).await);
608 assert!(!subs.contains(&canonical).await);
609 }
610
611 /// `record_alias` is a no-op when the raw and canonical URIs are equal
612 /// (the common case), so it never grows the alias map for entries that
613 /// don't need it.
614 #[tokio::test]
615 async fn test_record_alias_noop_when_raw_equals_canonical() {
616 let subs = ResourceSubscriptions::new();
617 let uri = "lsp-diagnostics:///tmp/file.rs".to_string();
618 subs.subscribe(uri.clone()).await.unwrap();
619 subs.record_alias(uri.clone(), uri.clone()).await;
620
621 // No alias was recorded, so unsubscribing under the canonical URI
622 // directly is still what resolves it.
623 assert!(subs.unsubscribe(&uri).await);
624 }
625
626 /// Unsubscribing under the canonical URI directly must also clear any
627 /// aliases that pointed at it, so the alias map does not accumulate
628 /// stale entries for already-removed subscriptions.
629 #[tokio::test]
630 async fn test_unsubscribe_by_canonical_clears_stale_aliases() {
631 let subs = ResourceSubscriptions::new();
632 let raw = "lsp-diagnostics:///var/tmp/file.rs".to_string();
633 let canonical = "lsp-diagnostics:///private/var/tmp/file.rs".to_string();
634
635 subs.subscribe(canonical.clone()).await.unwrap();
636 subs.record_alias(raw.clone(), canonical.clone()).await;
637
638 assert!(subs.unsubscribe(&canonical).await);
639 // The alias must no longer resolve to anything now that the
640 // canonical entry it pointed at is gone.
641 assert!(!subs.unsubscribe(&raw).await);
642 }
643
644 /// #499 site fix (impl-critic C1): re-subscribing to an already-subscribed
645 /// canonical URI under distinct genuine percent-encoding variants of the
646 /// same filename (`%66`/`%65`/`%2E` for `f`/`e`/`.` in `file.rs`) must not
647 /// grow the alias map without bound -- only the most recently recorded
648 /// alias for a given canonical URI is kept, so the alias map's size stays
649 /// structurally tied to the (already `MAX_SUBSCRIPTIONS`-capped) canonical
650 /// set's size, rather than an independent, exhaustible counter.
651 #[tokio::test]
652 async fn test_record_alias_keeps_only_latest_alias_per_canonical() {
653 let subs = ResourceSubscriptions::new();
654 let canonical = "lsp-diagnostics:///file.rs".to_string();
655 subs.subscribe(canonical.clone()).await.unwrap();
656
657 let raws = [
658 "lsp-diagnostics:///%66ile.rs".to_string(),
659 "lsp-diagnostics:///fil%65.rs".to_string(),
660 "lsp-diagnostics:///file%2Ers".to_string(),
661 ];
662 for raw in &raws {
663 subs.record_alias(raw.clone(), canonical.clone()).await;
664 }
665
666 // Every earlier alias for this canonical was displaced -- none of
667 // them resolve anymore.
668 for raw in &raws[..raws.len() - 1] {
669 assert!(!subs.unsubscribe(raw).await);
670 }
671 // Only the latest recorded alias still resolves, to the same
672 // canonical entry.
673 let latest = raws.last().unwrap();
674 assert!(subs.unsubscribe(latest).await);
675 assert!(!subs.contains(&canonical).await);
676 }
677
678 /// #499 site fix (impl-critic C3): `record_alias` is a no-op when its
679 /// `canonical_uri` argument is not (or is no longer) an actual
680 /// subscribed entry -- covers a concurrent `unsubscribe` landing between
681 /// a caller's own `subscribe` and `record_alias` calls, which would
682 /// otherwise record a dangling alias for an entry that no longer exists.
683 ///
684 /// Discriminating: `unsubscribe(&raw)` alone can't tell "the guard
685 /// skipped recording the alias" apart from "the alias was recorded but
686 /// its canonical target was never subscribed either" (both return
687 /// `false` from `unsubscribe`'s final `canonical.remove` either way).
688 /// Subscribing `canonical` *after* the no-op `record_alias` call
689 /// isolates the guard: if it had recorded the alias anyway, `raw` would
690 /// now resolve to the (now real) canonical entry; it must not.
691 #[tokio::test]
692 async fn test_record_alias_noop_for_unsubscribed_canonical() {
693 let subs = ResourceSubscriptions::new();
694 let raw = "lsp-diagnostics:///var/tmp/file.rs".to_string();
695 let canonical = "lsp-diagnostics:///private/var/tmp/file.rs".to_string();
696
697 // Never subscribed (or already unsubscribed by a racing task) --
698 // `record_alias` must not record anything for it.
699 subs.record_alias(raw.clone(), canonical.clone()).await;
700
701 // Now make `canonical` a real subscribed entry. If the guard above
702 // had not fired, `raw` would incorrectly resolve to it.
703 subs.subscribe(canonical.clone()).await.unwrap();
704 assert!(!subs.unsubscribe(&raw).await);
705 assert!(subs.unsubscribe(&canonical).await);
706 }
707
708 #[tokio::test]
709 async fn test_subscribe_cap_exceeded() {
710 let subs = ResourceSubscriptions::new();
711 for i in 0..MAX_SUBSCRIPTIONS {
712 subs.subscribe(format!("lsp-diagnostics:///file{i}.rs"))
713 .await
714 .unwrap();
715 }
716 let result = subs
717 .subscribe("lsp-diagnostics:///overflow.rs".to_string())
718 .await;
719 assert_eq!(result, Err(SubscriptionError::LimitReached));
720 }
721
722 #[tokio::test]
723 async fn test_snapshot() {
724 let subs = ResourceSubscriptions::new();
725 subs.subscribe("lsp-diagnostics:///a.rs".to_string())
726 .await
727 .unwrap();
728 subs.subscribe("lsp-diagnostics:///b.rs".to_string())
729 .await
730 .unwrap();
731 let mut snap = subs.snapshot().await;
732 snap.sort();
733 assert_eq!(snap, ["lsp-diagnostics:///a.rs", "lsp-diagnostics:///b.rs"]);
734 }
735
736 // ------------------------------------------------------------------
737 // SubscriptionRegistry
738 // ------------------------------------------------------------------
739
740 #[test]
741 fn test_registry_register_returns_distinct_sets() {
742 let registry = SubscriptionRegistry::new();
743 let a = registry.register();
744 let b = registry.register();
745 assert!(!Arc::ptr_eq(&a, &b));
746 }
747
748 #[tokio::test]
749 async fn test_registry_sessions_are_isolated() {
750 let registry = SubscriptionRegistry::new();
751 let session_a = registry.register();
752 let session_b = registry.register();
753
754 session_a
755 .subscribe("lsp-diagnostics:///a.rs".to_string())
756 .await
757 .unwrap();
758
759 assert!(session_a.contains("lsp-diagnostics:///a.rs").await);
760 assert!(!session_b.contains("lsp-diagnostics:///a.rs").await);
761
762 // Cross-session unsubscribe must not touch another session's entry.
763 assert!(!session_b.unsubscribe("lsp-diagnostics:///a.rs").await);
764 assert!(session_a.contains("lsp-diagnostics:///a.rs").await);
765 }
766
767 #[tokio::test]
768 async fn test_registry_cap_is_per_session() {
769 let registry = SubscriptionRegistry::new();
770 let session_a = registry.register();
771 let session_b = registry.register();
772
773 for i in 0..MAX_SUBSCRIPTIONS {
774 session_a
775 .subscribe(format!("lsp-diagnostics:///a{i}.rs"))
776 .await
777 .unwrap();
778 }
779
780 // Session A is at the cap, but session B's own set is untouched.
781 assert_eq!(
782 session_a
783 .subscribe("lsp-diagnostics:///overflow.rs".to_string())
784 .await,
785 Err(SubscriptionError::LimitReached)
786 );
787 assert!(
788 session_b
789 .subscribe("lsp-diagnostics:///b.rs".to_string())
790 .await
791 .unwrap()
792 );
793 }
794
795 #[tokio::test]
796 async fn test_registry_any_contains_and_is_all_empty() {
797 let registry = SubscriptionRegistry::new();
798 assert!(registry.is_all_empty().await);
799 assert!(!registry.any_contains("lsp-diagnostics:///a.rs").await);
800
801 let session_a = registry.register();
802 let _session_b = registry.register();
803 session_a
804 .subscribe("lsp-diagnostics:///a.rs".to_string())
805 .await
806 .unwrap();
807
808 assert!(!registry.is_all_empty().await);
809 assert!(registry.any_contains("lsp-diagnostics:///a.rs").await);
810 assert!(!registry.any_contains("lsp-diagnostics:///other.rs").await);
811 }
812
813 #[tokio::test]
814 async fn test_registry_reclaims_dropped_session() {
815 let registry = SubscriptionRegistry::new();
816 let session_a = registry.register();
817 session_a
818 .subscribe("lsp-diagnostics:///a.rs".to_string())
819 .await
820 .unwrap();
821 assert!(registry.any_contains("lsp-diagnostics:///a.rs").await);
822
823 drop(session_a);
824
825 assert!(!registry.any_contains("lsp-diagnostics:///a.rs").await);
826 assert!(registry.is_all_empty().await);
827 assert_eq!(registry.0.lock().unwrap().len(), 0);
828 }
829}