Skip to content

chore(lsp): migrate from tower-lsp to tower-lsp-server 0.23 - #877

Merged
psteinroe merged 1 commit into
mainfrom
chore/tower-lsp-server
Oct 19, 2026
Merged

psteinroe merged 1 commit into
mainfrom
chore/tower-lsp-server

Conversation

@psteinroe

Copy link
Copy Markdown
Collaborator

Addresses #446.

Why

tower-lsp has been unmaintained since August 2024. It also panics when the server sends a response after the client has disconnected (ebkalderon/tower-lsp#413, reported in #446). The panic happens in its transport:

// tower-lsp-0.20.0/src/transport.rs:116-121
let process_server_tasks = server_tasks_rx
    .buffer_unordered(self.max_concurrency)
    .filter_map(future::ready)
    .map(|res| Ok(Message::Response(res)))
    .forward(responses_tx.clone().sink_map_err(|_| unreachable!()))

Once writing to the client fails, print_output stops and drops the receiving end of responses_tx. The next request to finish then fails to send its response and lands in unreachable!().

The community fork tower-lsp-server is maintained and is what Biome's biome_lsp (where our LSP layer comes from) uses. In 0.23.0 the same line forwards into the channel without the unreachable!(), and a failed send just ends the stream:

// tower-lsp-server-0.23.0/src/transport.rs
let process_server_tasks = server_tasks_rx
    .buffer_unordered(self.max_concurrency)
    .filter_map(future::ready)
    .map(|res| Ok(Message::Response(res)))
    .forward(responses_tx.clone());

The fork also fixed a related panic in 0.21.1: "Do no panic when receiving a response whose request has been cancelled". I pinned 0.23.0 rather than the 0.24 RC, because the RC switches the types crate again.

What changed

The change is mechanical and limited to pgls_lsp. Only that crate depended on tower-lsp; pgls_wasm keeps its own lsp-types.

  • tower-lsp = "0.20.0" → tower-lsp-server = "0.23.0". The tower dev-dependency used by the integration tests goes from 0.4 to 0.5 to match, which drops tower 0.4 from the lockfile.
  • tower_lsp::lsp_types → tower_lsp_server::ls_types, and tower_lsp:: → tower_lsp_server::.
  • #[tower_lsp::async_trait] is gone. LanguageServer now uses async fn in traits.
  • Url → Uri. Uri wraps fluent_uri rather than the url crate:
    • to_file_path() now returns Option<Cow<Path>> instead of Result<PathBuf, ()>. The match arms in Session::file_path, Session::base_path, load_workspace_settings and did_change_watched_files are adapted, and the fallbacks are unchanged.
    • Uri doesn't implement Display, so tracing fields and log messages use uri.as_str(). That prints the same string as before.
    • Url::parse → Uri::from_str, for diagnostic code-description links and in the tests.
  • New struct fields get their defaults: InitializeResult::offset_encoding: None is skipped when serialized, so the wire output is the same. The tests also set InitializeParams::work_done_progress_params.
  • Custom methods (pgls/shutdown, pgls/set_configuration_overrides, and the workspace methods registered through workspace_method!) are registered through the same LspService::build(..).custom_method(..) API, which is unchanged.

Behaviour is meant to be identical. There are no feature changes.

Tests

  • New client_disconnecting_mid_request_does_not_panic test in crates/pgls_lsp/tests/server.rs. It runs the server over in-memory pipes, drops the client's read end, sends a batch of requests, and checks that serve returns without panicking. I ran the same scenario in a scratch crate against tower-lsp 0.20, and it reproduces the panic: panicked at tower-lsp-0.20.0/src/transport.rs:120:60: internal error: entered unreachable code.
  • All pgls_lsp tests pass against the docker DB, and so do all pgls_cli tests (including the daemon / lsp-proxy ones). cargo clippy --all-targets --all-features is clean.

Risks

  • URI normalisation. url::Url normalised URIs it parsed (scheme case, percent-encoding of unsafe characters, and so on). fluent_uri keeps the client's string as is and rejects invalid URIs. Documents are keyed by the URI the client sends, and responses now echo that exact string, so a client that sends consistent URIs (every mainstream client does) sees no difference. A client that sends URIs that aren't RFC 3986 valid would now get a deserialization error where url would have repaired them.
  • Windows paths. Uri::to_file_path builds Windows paths with / separators (z:/workspace/... instead of z:\workspace\...). std::path treats both the same, and Biome runs the same conversion on Windows.
  • Merge conflicts. This touches every handler's imports, so it will conflict with the open feat(linter): add autofixes for lint rules #859 (code actions) and feat(lsp): add inlay hints and resolve hover through the catalog #861 (inlay hints handler). Whichever merges later rebases. The conflicts are the same lsp_types/Url renames.

@psteinroe
psteinroe merged commit 10624e4 into main Oct 19, 2026
9 checks passed
@psteinroe
psteinroe deleted the chore/tower-lsp-server branch October 19, 2026 06:52
psteinroe added a commit that referenced this pull request Oct 26, 2026
Three ways a client request could break the server or the document state.

### `pgls.executeStatement` without arguments panicked

The handler read `params.arguments[0]` and `params.arguments[1]` directly, so `workspace/executeCommand` with no arguments panicked in the handler. Malformed arguments came back as an internal error. Both are now `InvalidParams` errors with a message that says which argument is wrong.

### Documents of other schemes shared the workspace document of a file

`Session::file_path` mapped every URI to its path, whatever the scheme, and the workspace keys documents by path. So:

- VS Code's `git:/workspace/a.sql?{"ref":"HEAD"}` (the left side of a diff) mapped to `/workspace/a.sql`. `didOpen` on it kept the existing document, and `didClose` removed the document of the real file. The next edit of the file failed with "not found", and its diagnostics were never published again.
- Every cell of a notebook (`vscode-notebook-cell:/nb.ipynb#W0...`, `#W1...`) mapped to the same path.
- `untitled:Untitled-1` became the relative path `Untitled-1`, and `ntitled-1` on Windows, where `to_file_path` strips the first character.

Only `file:` URIs map to file paths now. Other schemes are keyed by their whole URI, e.g. `untitled:Untitled-1`, which never equals a file's path and differs between notebook cells.

I gave them paths of their own rather than skipping them because untitled buffers and notebook cells are real editing surfaces, and people do write SQL in them. With their own key they keep diagnostics, completions and hover without touching any file's document. The trade-off is that path-based settings (`files.include`/`ignore`, the migrations directory) don't match them, which is right since they are not files. A git diff's old side is linted like any other buffer, which matches what VS Code does for other languages. The WASM worker's own `documentSelector` (`{ language: 'sql' }`) is unchanged.

### Out-of-range positions failed the whole request

`LineIndex::offset` returned `None` for a line past the end, and a column past the end of a line ran into the next line or past the end of the text. Some clients send the line after the last one as the end of a range:

```
textDocument/inlayHint, range (0,0)-(2,0) on a two-line document without a final newline
-> Internal error: position Position { line: 2, character: 0 } is out of range
```

Positions from the client are now clamped (`LineIndex::offset_clamped`): a column past the end of its line means the end of the line, as the LSP specifies, and a line past the end means the end of the text. This applies to every handler that converts client positions (`from_lsp::offset`). The internal `LineIndex::offset` used for diffs stays exact. A huge UTF-16 column no longer overflows in `wide_to_utf8_col`. Inlay hints that can't be converted are left out with a warning instead of failing the response.

### Tests

- `execute_statement_without_arguments_is_invalid`: no arguments, and arguments of the wrong type, both get `InvalidParams`.
- `closing_a_document_of_another_scheme_keeps_the_file`: open the file, open and close its `git:` version, edit the file. Diagnostics for the edit are published.
- `test_inlay_hints_utf16_and_out_of_range`: hints after an emoji land on the right UTF-16 columns, and a range that ends at the end of the line, past it, or on the line after the last one gets the same hints.
- Unit tests: `document_path` for `file:` (percent-decoding, drive colon), `git:`, notebook cells and `untitled:`, and clamping in UTF-16 including `u32::MAX`.

### Not covered

`fluent_uri` (since #877) rejects URIs with unencoded spaces, so such a request fails to deserialize before it reaches the server. VS Code always encodes them, and fixing it would need a change in `ls-types`.
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