Skip to content

LspManager mutex is held across the post-edit diagnostics wait (up to 10s), serializing every other LSP operation #216

Description

@iceteaSA

lsp_notify_and_collect_diagnostics takes the LspManager mutex and keeps the guard alive across a blocking wait that can run for seconds.

// crates/aft/src/context.rs:5713
let Some(mut lsp) = self.lsp_manager.try_lock() else {
    return crate::lsp::manager::PostEditWaitOutcome::default();
};// crates/aft/src/context.rs:5742 — guard `lsp` still alive
lsp.wait_for_post_edit_diagnostics(file_path, &config, &expected_versions, &pre_snapshot, timeout)

wait_for_post_edit_diagnostics loops on event_rx.recv_timeout(timeout) until its deadline. The timeout comes from wait_ms — default 3000 ms, capped at 10000 ms (context.rs:5959-5963).

For that whole window every other lsp_manager accessor blocks on the same mutex: lsp_notify_file_changed, lsp_clear_diagnostics_for_file, lsp_mark_diagnostics_stale_for_file, lsp_resync_changed_file_for_diagnostics, lsp_server_count.

This is the normal request path, not an edge case. lsp_post_write is called from every write/edit handler — write.rs:162, apply_patch.rs:318, edit_match.rs:1318, edit_symbol.rs:293, add_import.rs:630, batch.rs:164. Any edit with diagnostics: true (or an explicit wait_ms) takes the lock for the duration of the wait.

Your own comment at context.rs:5925-5927 notes that a tsserver re-analysis on a monorepo file routinely takes 2-5 s. That's the same window the whole LSP manager is serialized behind — and on a daemon serving several roots, an edit in one root blocks LSP bookkeeping for all of them.

Severity: P2. Needs concurrency to bite, but a multi-root daemon is exactly where it does, and the blocking window is set by an external process's analysis time rather than anything aft controls.

Fix direction: the wait doesn't need the manager — it needs the event channel. Snapshot the pre-edit state and expected versions under the lock, drop the guard, then wait on the channel. The try_lock already returns a default outcome on contention (context.rs:5713-5715), so the calling convention tolerates not getting the lock; shortening the hold makes that path rarer rather than changing semantics.


Provenance and limits. Found in a parallel read-only sweep; I verified the guard's lifetime across the wait firsthand (context.rs:5713 acquire → 5742 wait, same scope) before filing. What I have not done is measure contention — no profile showing a thread actually blocked, no wall-clock numbers. The claim is structural: the guard is alive across a bounded-but-long wait, and the other accessors take the same mutex. Whether that costs anything real depends on concurrent LSP traffic I haven't instrumented.

Line numbers against 28930e09.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions