Summary
The Rust host serves LSP requests one at a time on a single dispatch loop, and every
sidecar-backed handler blocks that loop with runtime.block_on. One slow request therefore
stalls every later request — including requests for the other language, and syntax-only
requests that are supposed to answer in <5ms.
src/sharplsp/src/main.rs:615
for msg in &connection.receiver {
Message::Request(req) => { handle_request(...)?; } // inline, synchronous
src/sharplsp/src/code_lens.rs:28
let response_bytes = match runtime.block_on(sidecar.request("textDocument/codeLens", payload))
The budget on that call is REQUEST_TIMEOUT = 2 minutes
(src/sharplsp/src/sidecar/manager.rs:29), so the worst-case stall is two minutes of a
completely unresponsive server. SidecarManager::request's own doc-comment already names the
hazard ("a wedged sidecar handler blocks the LSP main loop forever") but treats the per-method
budget as the mitigation — the budget bounds the stall, it does not remove it.
Observed impact
CI run 33612748733, attempt 1, job VS Code (Windows) / testexplorer
(job id 100196280123). Three consecutive tests failed, each burning its entire 5000ms
ceiling to the millisecond:
09:36:51.300 PASS a C# test file exposes Run + Debug test lenses (179ms)
09:36:56.530 FAIL an F# test file exposes Run + Debug lenses Timeout of 5000ms
09:37:01.568 FAIL disabling sharplsp.testLens.enabled ... Timeout of 5000ms
09:37:06.618 FAIL a non-test C# file produces no test lenses ... Timeout of 5000ms
09:37:06.647 PASS formatDuration renders the lens status suffix (pure, instant)
Tests 2 and 3 open C# files. They had nothing to do with F#. They timed out because the
first F# textDocument/codeLens — a cold FCS project crack — was still holding the dispatch
loop, so their own requests were never served. The last test passes instantly because it never
touches the LSP.
An exactly-5000ms cascade across three tests is the signature of a wedge, not of slowness: a
merely-slow server produces varying durations.
Why it matters outside CI
textDocument/codeLens is issued by the editor unprompted, for every visible document, on
open and on every change. It is the request most likely to be sitting in front of user-initiated
work. While one is in flight against a cold or slow sidecar, the user gets no completions, no
hover, no go-to-definition — in either language — and queued didChange notifications are not
applied, so the VFS goes stale behind the block.
Why the obvious fixes do not work
- Shorten the budget for cheap methods.
fail_timed_out_request kills the sidecar on
timeout (manager.rs:258). A 3s codeLens budget would kill FCS every time a cold crack took
3.1s.
- Abandon the future. Not possible: the transport is a single mutex-guarded
write-then-read pair with no correlation id, so dropping a read mid-stream leaves a stale
frame for the next caller — documented at manager.rs:377 and
TODO [SIDECAR-IPC-CORRELATION] at manager.rs:47.
The fix has to be not blocking the loop: dispatch sidecar-backed handlers onto the tokio
runtime and send their responses from there (connection.sender is a cloneable crossbeam
Sender, and LSP permits out-of-order responses). Note the handlers that take &mut NavCache
need that state made shareable first, and converge_provisional_publication
(main.rs:927) must move to its async form inside the spawned task rather than
runtime.block_on.
Not covered here
The test-side trigger — a cold F# semantic load charged to a 5000ms test body — is fixed
separately by warming the code-lens path in suiteSetup. That removes the trigger for this
particular flake; this issue is the amplifier that turned one slow request into three failures,
and it remains.
Summary
The Rust host serves LSP requests one at a time on a single dispatch loop, and every
sidecar-backed handler blocks that loop with
runtime.block_on. One slow request thereforestalls every later request — including requests for the other language, and syntax-only
requests that are supposed to answer in <5ms.
src/sharplsp/src/main.rs:615src/sharplsp/src/code_lens.rs:28The budget on that call is
REQUEST_TIMEOUT = 2 minutes(
src/sharplsp/src/sidecar/manager.rs:29), so the worst-case stall is two minutes of acompletely unresponsive server.
SidecarManager::request's own doc-comment already names thehazard ("a wedged sidecar handler blocks the LSP main loop forever") but treats the per-method
budget as the mitigation — the budget bounds the stall, it does not remove it.
Observed impact
CI run
33612748733, attempt 1, jobVS Code (Windows) / testexplorer(job id
100196280123). Three consecutive tests failed, each burning its entire 5000msceiling to the millisecond:
Tests 2 and 3 open C# files. They had nothing to do with F#. They timed out because the
first F#
textDocument/codeLens— a cold FCS project crack — was still holding the dispatchloop, so their own requests were never served. The last test passes instantly because it never
touches the LSP.
An exactly-5000ms cascade across three tests is the signature of a wedge, not of slowness: a
merely-slow server produces varying durations.
Why it matters outside CI
textDocument/codeLensis issued by the editor unprompted, for every visible document, onopen and on every change. It is the request most likely to be sitting in front of user-initiated
work. While one is in flight against a cold or slow sidecar, the user gets no completions, no
hover, no go-to-definition — in either language — and queued
didChangenotifications are notapplied, so the VFS goes stale behind the block.
Why the obvious fixes do not work
fail_timed_out_requestkills the sidecar ontimeout (
manager.rs:258). A 3s codeLens budget would kill FCS every time a cold crack took3.1s.
write-then-read pair with no correlation id, so dropping a read mid-stream leaves a stale
frame for the next caller — documented at
manager.rs:377andTODO [SIDECAR-IPC-CORRELATION]atmanager.rs:47.The fix has to be not blocking the loop: dispatch sidecar-backed handlers onto the tokio
runtime and send their responses from there (
connection.senderis a cloneable crossbeamSender, and LSP permits out-of-order responses). Note the handlers that take&mut NavCacheneed that state made shareable first, and
converge_provisional_publication(
main.rs:927) must move to its async form inside the spawned task rather thanruntime.block_on.Not covered here
The test-side trigger — a cold F# semantic load charged to a 5000ms test body — is fixed
separately by warming the code-lens path in
suiteSetup. That removes the trigger for thisparticular flake; this issue is the amplifier that turned one slow request into three failures,
and it remains.