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.
lsp_notify_and_collect_diagnosticstakes theLspManagermutex and keeps the guard alive across a blocking wait that can run for seconds.wait_for_post_edit_diagnosticsloops onevent_rx.recv_timeout(timeout)until its deadline. The timeout comes fromwait_ms— default 3000 ms, capped at 10000 ms (context.rs:5959-5963).For that whole window every other
lsp_manageraccessor 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_writeis 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 withdiagnostics: true(or an explicitwait_ms) takes the lock for the duration of the wait.Your own comment at
context.rs:5925-5927notes 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_lockalready 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:5713acquire →5742wait, 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.