Skip to content

fix(bridge): split NotificationCache out of Translator lock#224

Merged
bug-ops merged 3 commits into
mainfrom
fix/104-notification-pump-lock
Jul 24, 2026
Merged

fix(bridge): split NotificationCache out of Translator lock#224
bug-ops merged 3 commits into
mainfrom
fix/104-notification-pump-lock

Conversation

@bug-ops

@bug-ops bug-ops commented Jul 24, 2026

Copy link
Copy Markdown
Owner

Summary

diagnostics_pump cached incoming LSP notifications through Arc<Mutex<Translator>>, the same lock held by every MCP tool call. While a slow textDocument/diagnostic round-trip (get_diagnostics) held that lock, the pump blocked trying to acquire it to cache an incoming publishDiagnostics/log/message notification. The LSP transport forwards notifications via a non-blocking try_send, so once the bounded channel filled, those notifications were silently dropped rather than queued. Separately, get_cached_diagnostics, read_resource, and subscribe locked the translator purely to validate a path against workspace roots (immutable after startup), so those cache-only reads also queued behind the same in-flight round-trip.

Test plan

  • cargo +nightly fmt --all -- --check
  • cargo clippy --all-targets --all-features --workspace -- -D warnings
  • cargo nextest run --workspace --all-features (432/432 passing, including a new concurrency regression test proving the pump makes progress while the translator lock is held elsewhere)
  • RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features

Closes #104

diagnostics_pump wrote incoming notifications into the cache through
Arc<Mutex<Translator>>, the same lock held by every MCP tool call. While
a slow textDocument/diagnostic round-trip held that lock, the pump
blocked trying to cache incoming publishDiagnostics/log/message
notifications, and the LSP transport's non-blocking try_send silently
dropped them once the notification channel filled. Cache-only reads
(get_cached_diagnostics, read_resource, subscribe) also locked the
translator purely to validate a path against immutable workspace roots,
so they queued behind the same in-flight round-trip.

Extract NotificationCache into its own Arc<Mutex<NotificationCache>>,
independent of Translator, and validate workspace-relative paths against
a lock-free Arc<[PathBuf]> snapshot instead of the translator lock.
get_diagnostics keeps holding the translator lock across its LSP
round-trip; nothing else needs to anymore.

Closes #104
@github-actions github-actions Bot added documentation Improvements or additions to documentation rust Rust code changes mcpls-core mcpls-core crate changes labels Jul 24, 2026
@bug-ops
bug-ops requested a review from Copilot July 24, 2026 22:27

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves mcpls’s bridge concurrency by decoupling notification caching and workspace-root validation from the global Arc<Mutex<Translator>>, preventing LSP push notifications and cache-only MCP operations from being stalled behind slow in-flight LSP round-trips.

Changes:

  • Split NotificationCache out of Translator into an independent Arc<Mutex<NotificationCache>>, and updated diagnostics_pump + MCP handlers to use it directly.
  • Introduced lock-free workspace-root validation via an Arc<[PathBuf]> snapshot and validate_path_against_roots, used by cache-only MCP resource flows.
  • Updated constructors/tests and documented the behavior and API changes in CHANGELOG.md.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
crates/mcpls-core/src/transport.rs Updates HTTP transport tests to construct McplsServer with the new cache + workspace-roots snapshot args.
crates/mcpls-core/src/mcp/server.rs Switches cache-only tools/resources to use notification_cache + lock-free root validation instead of locking the translator.
crates/mcpls-core/src/mcp/handlers.rs Extends HandlerContext to carry notification_cache and immutable workspace_roots snapshot.
crates/mcpls-core/src/lib.rs Makes diagnostics_pump lock only NotificationCache; wires cache + roots snapshot through startup and adds a concurrency regression test.
crates/mcpls-core/src/bridge/translator.rs Removes embedded NotificationCache; adds validate_path_against_roots; refactors cached/log/message handlers to operate on cache inputs.
crates/mcpls-core/src/bridge/mod.rs Re-exports validate_path_against_roots for crate-internal use.
CHANGELOG.md Documents the contention fix and breaking signature changes under Unreleased.

Comment thread crates/mcpls-core/src/mcp/server.rs Outdated
Comment thread crates/mcpls-core/src/mcp/server.rs Outdated
…ck split

get_cached_diagnostics still held the notification_cache mutex across
canonicalize() and the cached-diagnostics-to-MCP-shape mapping,
reintroducing the same lock-held-during-syscall class this branch
exists to eliminate. Split Translator::handle_cached_diagnostics into
cached_diagnostics_uri (path validation, no lock) and
diagnostics_from_cache_entry (pure mapping over an already-cloned
entry, no lock); the cache lock is now held only for the map lookup
and clone.

read_resource discarded the canonicalized path returned by
validate_path_against_roots and built its cache-lookup URI from the
raw input path, so paths with symlinks or `..` segments missed the
cache even when tracked under their canonical form. Use the validated
path for the URI, matching get_cached_diagnostics.
@bug-ops
bug-ops enabled auto-merge (squash) July 24, 2026 22:50
…`-based

`path_to_uri` re-parses the file URI through `url::Url::parse` for RFC 3986
character encoding, which normalizes away `..` segments on every platform.
A path differing from its canonical form only by literal `..` segments
therefore produces the same URI with or without the read_resource fix,
regardless of OS. The test only passed on macOS because `/tmp` there is
itself a symlink to `/private/tmp`, which `canonicalize()` resolves --
an effect unrelated to what the test claimed to prove. On Linux CI,
`/tmp` is a real directory, so the assertion failed.

Rewrite the test using a real symlinked directory so canonicalize()
resolves something URI string normalization cannot, producing a
deterministic raw-vs-canonical difference on any platform where
symlinks work. Unix-only, since symlink creation on Windows CI runners
typically requires elevated privileges.
@bug-ops
bug-ops merged commit dc84f86 into main Jul 24, 2026
27 checks passed
@bug-ops
bug-ops deleted the fix/104-notification-pump-lock branch July 24, 2026 23:04
bug-ops added a commit that referenced this pull request Jul 24, 2026


PR #224 (issue #104) extracted NotificationCache out of Translator and
introduced a lock-free workspace_roots snapshot after this branch was
created. Update subscribe() to read the cache via
self.context.notification_cache directly instead of the now-removed
Translator::notification_cache(), and build the diagnostics-cache URI
from the canonicalized path returned by validate_path_against_roots,
matching the pattern already established in read_resource.
bug-ops added a commit that referenced this pull request Jul 24, 2026
…ibe (#225)

* test(rust-analyzer): fix off-by-one column positions in hover, definition, and completion tests

test_hover_on_u64_type passed a column past the end of its target line,
so rust-analyzer always returned a null hover. test_definition_user_struct
and test_completions_basic had the same class of error, surviving only
because rust-analyzer's token resolution happened to tolerate the
off-by-one. test_completions_basic also asserted nothing on success,
so it could never fail regardless of the result.

Fixes #138

* fix(bridge): replay cached diagnostics on subscribe

A client subscribing to an lsp-diagnostics:// URI after the LSP server
already pushed its first publishDiagnostics notification for that file
saw nothing until the next push. subscribe() now checks the notification
cache and immediately replays a resources/updated notification when
diagnostics are already cached for the URI.

The subscription is recorded before the cache is checked, closing a race
with diagnostics_pump: whichever of the two observes the update first
delivers it, so at most a harmless duplicate notification can occur, never
a dropped one.

The e2e test client read exactly one line per request, so it misread the
new out-of-order notification as the RPC response. It now matches
responses by id and queues notifications separately, which is required
JSON-RPC/MCP behavior regardless of this fix.

Fixes #131

* fix(bridge): log failed diagnostics-replay notifications instead of discarding

subscribe() silently dropped errors from the replay notification, unlike
diagnostics_pump which treats the same failure as peer disconnect. Log
at warn level for debuggability; do not fail the subscribe call itself,
since the subscription already succeeded and only the initial replay
was affected.

Addresses review feedback on #131

* fix(bridge): adapt subscribe() to the split notification cache from #224

PR #224 (issue #104) extracted NotificationCache out of Translator and
introduced a lock-free workspace_roots snapshot after this branch was
created. Update subscribe() to read the cache via
self.context.notification_cache directly instead of the now-removed
Translator::notification_cache(), and build the diagnostics-cache URI
from the canonicalized path returned by validate_path_against_roots,
matching the pattern already established in read_resource.

* fix(bridge): canonicalize subscribe/unsubscribe resource URIs

subscribe() tracked the client's raw request.uri, but diagnostics_pump
matches subscriptions against a URI rebuilt from the canonical LSP path.
A client subscribing with a non-canonical but valid URI (symlink, macOS
/var vs /private/var) would be recorded under a key that never matches
subs.contains(), so it would receive the initial replay but silently
stop getting further diagnostics pushes.

Both subscribe and the replay notification now use the canonical URI
derived from validated_path. unsubscribe mirrors the same canonicalization
to remove the matching entry, falling back to the raw URI if the file no
longer exists (e.g. deleted since subscribing) so unsubscribing a stale
entry never errors.

Addresses further review feedback on #131
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation mcpls-core mcpls-core crate changes rust Rust code changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(bridge): split NotificationCache out of Translator lock to eliminate notification_pump starvation

2 participants