Skip to content

fix: prevent LSP crash loop under sustained editing load - #473

Merged
Feel-ix-343 merged 1 commit into
Feel-ix-343:mainfrom
exa-labs:devin/1780375161-fix-async-executor-starvation
Jun 19, 2026
Merged

fix: prevent LSP crash loop under sustained editing load#473
Feel-ix-343 merged 1 commit into
Feel-ix-343:mainfrom
exa-labs:devin/1780375161-fix-async-executor-starvation

Conversation

@Feel-ix-343

@Feel-ix-343 Feel-ix-343 commented Jun 17, 2026

Copy link
Copy Markdown
Owner

The server would become unresponsive after a few minutes of editing a moderately large vault: code actions then go-to-definition time out, the server goes silent, and Zed's shutdown times out and force-resets the connection, restarting the cycle.

Root cause is async-runtime starvation:

  • Every keystroke (didChange/didOpen) recomputes diagnostics over all open buffers synchronously, which is O(open_files x references x referenceables). As open buffers accumulate, each pass eventually takes longer than the gap between keystrokes, so passes pile up faster than they finish.
  • This CPU-bound work runs inline on the Tokio worker threads while holding the vault lock, and the forked tower-lsp caps request concurrency at 4. The server's bounded message queue fills, the stdin reader can no longer be scheduled, and shutdown / $/cancelRequest are never read -> silence.

Fixes:

  • Debounce diagnostics: coalesce bursts of edits so only the latest change in a quiet window triggers a diagnostics pass, bounding the load.
  • Offload synchronous vault work with tokio::task::block_in_place so the runtime keeps servicing stdin and control messages under load.
  • Raise the request concurrency limit from the default 4 to 256.

Open in Devin Review

The server would become unresponsive after a few minutes of editing a
moderately large vault: code actions then go-to-definition time out, the
server goes silent, and Zed's shutdown times out and force-resets the
connection, restarting the cycle.

Root cause is async-runtime starvation:

- Every keystroke (didChange/didOpen) recomputes diagnostics over all open
  buffers synchronously, which is O(open_files x references x referenceables).
  As open buffers accumulate, each pass eventually takes longer than the gap
  between keystrokes, so passes pile up faster than they finish.
- This CPU-bound work runs inline on the Tokio worker threads while holding the
  vault lock, and the forked tower-lsp caps request concurrency at 4. The
  server's bounded message queue fills, the stdin reader can no longer be
  scheduled, and shutdown / $/cancelRequest are never read -> silence.

Fixes:

- Debounce diagnostics: coalesce bursts of edits so only the latest change in a
  quiet window triggers a diagnostics pass, bounding the load.
- Offload synchronous vault work with tokio::task::block_in_place so the runtime
  keeps servicing stdin and control messages under load.
- Raise the request concurrency limit from the default 4 to 256.

Co-Authored-By: Felix Zeller <felixazeller@gmail.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

Open in Devin Review

Comment thread src/main.rs
Comment on lines +89 to +104
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(DIAGNOSTICS_DEBOUNCE_MS)).await;
// A newer edit arrived during the debounce window; let its task run.
if backend.diag_gen.load(Ordering::SeqCst) != generation {
return;
}
if let Err(e) = backend.publish_diagnostics().await {
backend
.client
.log_message(
MessageType::ERROR,
format!("Failed calculating diagnostics {:?}", e),
)
.await;
}
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚩 Stale diagnostics possible when computation outlasts debounce window

If a diagnostics computation (generation N) takes longer than DIAGNOSTICS_DEBOUNCE_MS (300ms), a new edit can trigger generation N+1's task which starts and finishes first, publishing correct diagnostics. Then generation N finishes and overwrites them with stale results. There's no generation check inside publish_diagnostics (src/main.rs:193-243) after the computation completes. In practice, the staleness is short-lived (corrected on the next edit cycle) and the window is narrow, but for very large vaults where diagnostics routinely exceed 300ms, diagnostics could flicker. A mitigation would be to check diag_gen after bind_vault returns in the spawned task before publishing.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@Feel-ix-343
Feel-ix-343 merged commit 5d1af99 into Feel-ix-343:main Jun 19, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant