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-serversupplies the JSON-RPC connection (Connection::stdiofor the real server,Connection::memoryfor tests — see the verification section of the Milestone 4 plan) and theMessage/Request/Notification/Responsetypes the dispatch loop matches on.lsp-typessupplies 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.rsis a plainfor msg in &connection.receiverloop with amatchon method name. More boilerplate thantower-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-typespulled 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) andcrates/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 placeskorrin-lspexplicitly 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 grantskorrin-cliforclapandrustyline.docs/internals/dependencies.mdgets a new## crates/korrin-lspsection 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, butlsp-serveris 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-Lengthframing, cancellation) that are easy to get subtly wrong by hand. Not worth reimplementing for the dependency count it would save.