Skip to main content

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, &registry.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}