Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

26. korrin-lsp's transport: lsp-server, no async runtime

  • Status: Accepted
  • Date: 2026-09-04

Context

Milestone 4 adds a language server so an editor can show live diagnostics, hover text, and go-to-definition for .kor files. Every real LSP server needs a transport layer: something that speaks JSON-RPC over stdio, handles the initialize handshake, and dispatches incoming requests and notifications to handler code.

Rust has two well-known choices. tower-lsp wraps the protocol in tower's service abstraction and dispatches through an async fn trait (impl LanguageServer for Backend), which requires an async runtime — in practice tokio — to drive it. lsp-server (maintained by the rust-analyzer project, and what rust-analyzer itself runs on) is synchronous: a crossbeam-channel-backed connection that hands you lsp_server::Message values to match on and reply to in a plain loop, no runtime required.

Nothing in three completed milestones — a tree-walking interpreter, a bytecode compiler and VM, a CLI with a line-editing REPL — has ever needed an async runtime. The question is whether a language server changes that.

It doesn't, for a concrete reason: korrin-lsp serves one file at a time, and recompiling that file on every keystroke is not a latency problem. korrin::lexer::tokenize's own doc comment (crates/korrin/src/lexer/mod.rs:54) already states it is "cheap enough to redo on every keystroke for typical file sizes." The full tokenize -> parse -> resolve chain on a .kor file runs in microseconds to low milliseconds. There is no request that benefits from being handled concurrently with another, because there is only ever one editor, one open document being actively typed into, and no I/O slow enough to want to await around.

Decision

korrin-lsp uses lsp-server + lsp-types, synchronously, with no async runtime.

  • lsp-server supplies the JSON-RPC connection (Connection::stdio for the real server, Connection::memory for tests — see the verification section of the Milestone 4 plan) and the Message/Request/Notification/ Response types the dispatch loop matches on.
  • lsp-types supplies the protocol's own vocabulary as serde types (Position, Range, Diagnostic, Hover, Location, the capability structs) — this crate is useful with either transport and would be needed regardless of the choice above.
  • The dispatch loop in crates/korrin-lsp/src/server.rs is a plain for msg in &connection.receiver loop with a match on method name. More boilerplate than tower-lsp's trait dispatch, but bounded, one-time, and contained to one file.

Consequences

  • No tokio (or any async runtime) enters the dependency tree. The workspace stays true to ADR 0013's "avoid crates that pull large transitive trees" — confirmed in practice: lsp-server + lsp-types pulled 11 packages total (crossbeam-channel, crossbeam-utils, bitflags, fluent-uri, itoa, serde, serde_derive, serde_json, serde_repr, plus the two crates themselves), none of them an executor.
  • This is also the decision that fills a real gap in ADR 0013. That ADR's dependency bar names exactly two tiers by name — crates/korrin (published, stays lean) and crates/korrin-cli (freer, for CLI concerns) — and says nothing about a third crate, because none existed when it was written. ADRs are immutable once accepted (docs/decisions/0000-adr-process.md), so 0013 is not edited. This ADR places korrin-lsp explicitly in the looser, korrin-cli-like tier: not published, a tool crate, free to depend on mature crates for its genuine concern (speaking a wire protocol), the same latitude 0013 already grants korrin-cli for clap and rustyline. docs/internals/dependencies.md gets a new ## crates/korrin-lsp section in that same style.
  • The dispatch loop is hand-written rather than generated by a trait, which means adding a new LSP method later (semantic tokens, code actions) means writing its match arm by hand rather than implementing one more trait method. A deliberate, small, recurring cost in exchange for the dependency savings above.
  • If a future feature genuinely needs concurrency (for example, indexing a whole workspace of files in the background while still answering requests on the one currently open), that is the point to revisit this decision, not before.

Alternatives considered

  • tower-lsp + tokio. The more common choice in the Rust LSP ecosystem and more ergonomic to write against. Rejected for pulling in an async runtime this project has never needed, for a workload with no actual concurrency requirement — exactly the "large transitive tree" ADR 0013 asks to be avoided without a load-bearing reason, and none exists here.
  • Hand-rolled JSON-RPC over stdio, no framework at all. Removes even lsp-server's dependency, but lsp-server is small, maintained by the team that runs the most demanding LSP server in the Rust ecosystem on it daily, and correctly handles the handshake/shutdown edge cases (partial reads, Content-Length framing, cancellation) that are easy to get subtly wrong by hand. Not worth reimplementing for the dependency count it would save.