Skip to main content

Module lsp

Module lsp 

Source
Expand description

§2 module 28 lsp (COMPOSABLE-HARNESS-DESIGN.md line 478): “D1 LSP diagnostics in edit path + query tool” — this module ships the WEAKEST FORM that satisfies D1: server LIFECYCLE for a HANDFUL of user-configured language servers, and diagnostics surfaced in the edit/write TOOL RESULT via the shared D-5 write-path seam (crate::tools::WriteObserver, P5-9/P5-11).

§Honest, deliberate gaps (design §4.4 “LSP-in-edit at fleet scale”)

  • No auto-spawn/auto-download fleet. opencode auto-provisions ~38 language servers. This module only ever spawns a server the user EXPLICITLY configured under [capabilities.lsp.servers.<name>] — no network fetch, no bundled binaries, nothing runs that wasn’t named in config.
  • No /find/symbol symbol-indexing query tool. A real symbol index (workspace/symbol, textDocument/definition, …) is D-4-sized work this module does not attempt — shipping a lsp.query tool that silently no-ops would be a worse outcome than not shipping it (build brief: “if you expose any query surface, it must work or not exist”), so none is exposed. [capabilities.lsp] carries exactly two real, wired knobs: enabled and servers (plus the bounds max_diagnostics/timeout_secs) — no query/symbols key is ever parsed, so there is no declared-but-dead knob for either gap.

§Wire protocol — hand-rolled, no new dependency

LSP frames a JSON-RPC message behind a tiny HTTP-style header (Content-Length: N\r\n\r\n<N bytes of JSON>). That framing is a dozen lines over tokio::io::AsyncBufReadExt/AsyncReadExt — pulling in a dedicated lsp-types/lsp-server crate for it would be the heavy, over-built option for a module scoped to lifecycle + diagnostics over a handful of servers (no symbol index, no code actions, no incremental sync deltas — just initialize/initialized/didOpen/didChange/ publishDiagnostics/shutdown/exit), so write_message/ read_message below hand-roll it instead. cargo deny check has nothing new to license-audit as a result.

§Process lifecycle (no orphaned language servers, incl. grandchildren)

A server is a long-lived child process, spawned lazily (LspManager::diagnostics_after_write, on the first write to a file extension it’s configured for) and kept alive in LspManager for reuse across writes. A real configured server (rust-analyzer, typescript-language-server, gopls, …) commonly spawns its OWN persistent worker subprocesses (a proc-macro/build server, tsserver, go, …) — so .kill_on_drop(true)/Child::start_kill alone (which only ever signal the ONE directly-tracked pid) are not enough; this is the SAME grandchild-orphan class crate::agent::kill_job_process_group was built to close for background shell jobs (P5-6), and the fix here reuses that exact mechanism: LspClient::spawn puts the server in its OWN process group (Command::process_group(0), unix), and LspClient::kill SIGKILLs the WHOLE group (kill_process_group), not just the leader — .kill_on_drop(true) remains as a second, independent backstop for the leader pid specifically. On non-unix targets, no portable process-group primitive is wired up (same posture as kill_job_process_group’s own #[cfg(not(unix))] arm) — this falls back to the pre-fix direct-child-only kill, a documented residual, not silently claimed fixed there.

LspManager::kill_all_sync (this module’s group-kill, above) is called from impl Drop for crate::Agent — the ONLY production teardown path today, provable/traceable rather than relying solely on kill_on_drop(true)’s implicit runtime behavior. LspManager::shutdown_all (a graceful LSP shutdown/exit handshake, letting a well-behaved server reap its own children before this module force-kills the group) is NOT wired into any automatic path — Agent::run_loop runs once PER TURN, not once per session, so calling it there would tear down and respawn a reused server every turn, defeating the “kept alive for reuse across writes” design above; there is no separate session-level clean-exit hook distinct from Drop in this codebase today. It remains available as public API (exercised directly by this module’s own tests) for a caller that manages its own Agent lifecycle and wants to drain gracefully before dropping it — but nothing calls it automatically, and that is the honest, current state (not an aspirational claim about a code path that doesn’t exist).

Structs§

LspClient
A live connection to one configured language server — one spawned child process, kept alive for reuse across writes to files it handles.
LspDiagnosticsObserver
The crate::tools::WriteObserver [capabilities.lsp] installs — before_write is a true no-op (LSP has nothing to capture before a mutation); after_write delegates straight to LspManager::diagnostics_after_write.
LspManager
Session-scoped registry of configured language servers and the live connections spawned so far — the crate::tools::WriteObserver this module installs (LspDiagnosticsObserver) is a thin wrapper around a shared Arc<LspManager>; crate::agent::build_tool_context keeps its own Arc clone too, so crate::Agent’s Drop impl can reach Self::kill_all_sync regardless of how many observer clones exist.
LspServerSpec
One [capabilities.lsp.servers.<name>] entry — a user-configured language server this module is allowed to spawn. command/args are config-borne code execution (D-10) — see crate::configfile::sanitize_for_project / crate::userconfig’s project-strip, which refuses this table from an untrusted project layer exactly like hooks/mcp.servers.

Constants§

DEFAULT_LSP_MAX_DIAGNOSTICS
Default cap on the number of diagnostics rendered into a single tool result (bounded-context requirement — build brief: “a flood mustn’t blow context”). Overridable via [capabilities.lsp] max_diagnostics.
DEFAULT_LSP_TIMEOUT_SECS
Default wait for a configured server to publish diagnostics after a didOpen/didChange before giving up gracefully (never blocking the tool call indefinitely). Overridable via [capabilities.lsp] timeout_secs.
LSP_MAX_MESSAGE_BYTES
Hardening cap (same rationale as crate::mcp::MCP_MAX_RESPONSE_BYTES): the largest single Content-Length-framed message this client will buffer before treating the server as hostile/broken and erroring out — bounds how much memory a misbehaving configured language server can force this process to allocate for one message.

Functions§

manager_for_config
Build the LspManager a fresh crate::Agent should install, given a resolved crate::Config — called once, from crate::agent::build_tool_context. Config::lsp_enabled is the ONE gate: false (the default) returns None WITHOUT spawning anything — the default-off byte-identity guarantee. true with an EMPTY Config::lsp_servers still returns a (harmless, does-nothing) manager, but warns once — an enabled module with no configured servers is very likely a config mistake, not silent-by-design.