Skip to content

perf(lsp): run editor requests off the runtime and stop cancelled or outdated ones - #1008

Open
psteinroe wants to merge 3 commits into
perf/split-around-changesfrom
perf/editor-requests-cancellable
Open

psteinroe wants to merge 3 commits into
perf/split-around-changesfrom
perf/editor-requests-cancellable

Conversation

@psteinroe

@psteinroe psteinroe commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #1007. Part of the rebuild described in #997 (point 6). Replaces #931, #954 and #961.

The handlers for hover, inlay hints, semantic tokens, completions, code actions and formatting ran synchronously inside their async fns on the tokio workers, and the workspace held the read lock of all documents for the whole request. change_file needs the write lock, so a request that took seconds on a long file delayed the next keystroke by as much, and $/cancelRequest couldn't stop anything.

Documents as Arcs, with one cache

  • The workspace stores Arc<Document>. Every request clones the Arc and releases the lock right away (WorkspaceServer::document). change_file uses Arc::make_mut, so it copies the document if a request still works on the previous version. The copy shares the document's cache (Arc<DocumentCache>): the parse results and trees are keyed by statement text, so sharing them is correct.
  • The entries that depend on the document's content as a whole (the analysis, the walk's checkpoints, resolved statements and snapshot, see perf(workspace): keep what the walk resolves for the next requests #1005) are tagged with the content's generation, which update_content increments. A request on a previous generation neither reads nor writes them: it gets an empty cache for its walk, and its analysis isn't stored. Without that, a request that finished on an outdated version would leave its checkpoints in the cache of the new one.

Stopping outdated and cancelled requests

  • pgls_workspace::cancellation (from perf(lsp): stop cancelled requests, and run diagnostics, completions and formatting off the runtime #954): with_cancellation(flag, f) sets a flag for the thread that runs f.
  • pgls_analyser::walk_statements takes should_stop and checks it every STOP_CHECK_INTERVAL = 64 statements, and so does Analyser::analyse_until (analyse never stops). The workspace passes "the request was cancelled, or the document changed" (WorkspaceServer::should_stop), and checks it as often while collecting the statements before a walk or an analysis.
  • A stopped walk records nothing: inlay hints and semantic tokens return nothing, hover the database's view. A stopped analysis isn't cached, pull_file_diagnostics returns nothing and skips the database checks, and the lint code actions are empty. The CLI never stops: its documents don't change and nothing cancels its requests.
  • This reaches the editors: the VS Code extension, nvim-lspconfig, lsp-mode and the Helix/Zed setups start the server with lsp-proxy, which only copies bytes to the daemon, and the daemon runs the language server with the workspace in its own process (perf(workspace): stop stale diagnostics, lint code actions and completions early #961). --use-server CLI commands go through a transport and are not cancelled.

The language server

  • LSPServer::run_document_request runs a handler with spawn_blocking under a cancellation flag that is set when tower-lsp drops the request's future, whether it finished or was cancelled. If the document's version changed while it ran, it answers ContentModified (-32801), which tells the client to ask again instead of showing outdated results. Hover, inlay hints, semantic tokens (full and range), completions, code actions and formatting (full and range) go through it.
  • Diagnostics run on the blocking pool too. They stop when the document changes, which the LSP already discards.
  • The LSP test server's per-request timeout goes from 1 s to 30 s: with the work off the runtime it measures wall time, including diagnostics against the database while other tests run. A hang still fails the test.

Timings

Release build, the benchmark of #1005: an edit while a cold full semantic tokens request runs on the same document, on another thread.

statements change_file during the request, main this stack the request, main this stack
5,000 549 ms 0.6 ms 587 ms stops after 149 ms
20,000 1,933 ms 1.2 ms 1,948 ms stops after 130 ms

On main, change_file waited for the read lock until the request finished.

Tests

  • cancellation: the flag applies while the request runs, nests, and is restored.
  • analysis_stops_when_asked (analyser): 200 statements, asked to stop at the second check, it returns the 127 statements before it and stopped.
  • clones_only_see_the_cache_of_their_content (document): a clone shares the cache, but a request on the previous generation neither reads nor writes the entries of the new one.
  • test_editor_requests_use_document_snapshots: a document taken before an edit keeps its content, its walk stops and leaves nothing in the cache, and a walk of the current document resolves everything.
  • test_cancelled_requests_stop_walking and test_cancelled_analysis_stops: under a cancelled flag, semantic tokens, diagnostics and the lint code actions of a 200-statement file are empty (the code actions use the cached analysis while it is valid); without it they're all there.
  • pgls_workspace (including the memory and edit-churn tests), pgls_analyser and pgls_lsp pass, and clippy on the whole workspace.

Not covered

  • No LSP test that sends $/cancelRequest: the test client can't send a notification while it awaits a request. The flag is tested at the workspace level.
  • pgls_completions::complete itself works on one statement and isn't interrupted.

…outdated ones

The workspace keeps documents as Arc<Document>: requests clone the Arc
and release the lock, and an edit copies the document if a request still
uses it. The copy shares the document's cache, whose key-dependent
entries are tagged with the generation of the content, so a request on
an outdated version neither reads nor writes them. The walk and the
analysis check every 64 statements whether the document changed or the
request was cancelled (pgls_workspace::cancellation) and stop without
caching anything.

The language server runs hover, inlay hints, semantic tokens,
completions, code actions and formatting on the blocking pool, cancels
them when tower-lsp drops their future, and answers ContentModified when
the document changed meanwhile. Diagnostics run on the blocking pool.
…ncellable

# Conflicts:
#	crates/pgls_workspace/src/workspace/server.rs
#	crates/pgls_workspace/src/workspace/server/document.rs
#	crates/pgls_workspace/src/workspace/server/document_cache.rs

This branch has not been deployed

No deployments
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