UNPKG

pi-lens

Version:

Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo

445 lines (381 loc) • 24.1 kB
# LSP bridges — analysis & recommendation > Research artifact. **Not committed.** Working doc that captures the investigation > behind [#157](https://github.com/apmantza/pi-lens/issues/157) ("feat: consider > opt-in lspmux support"): does it make sense for pi-lens to build a separate LSP > bridge client that spawns servers and makes them available across concurrent > `pi` sessions, and what are the trade-offs of the existing reference bridges? ## TL;DR **No, do not build a general-purpose LSP bridge on top of pi-lens.** - The premise ("x2 sessions spawn x2 LSPs") is only true for **plain `pi` in two separate processes** on the same workspace. All other cases either already share (per-process singleton, MCP warm server) or aren't a real workload. - The two reference bridges solve different problems and neither is a clean fit: - [lsp-bridge](https://github.com/manateelazycat/lsp-bridge) is an *async-offload* bridge shaped for Emacs's single-threaded runtime — wrong shape (Elisp + EPC + Python event loop + acm completion UI; no TUI to integrate into). - [lspmux](https://codeberg.org/p2502/lspmux) is the right shape (N clients → 1 server per workspace) but **drops server→client messages** during ID remapping, which breaks `client/registerCapability` (used by pi-lens for `typescript/willRenameFile`, file watcher registration, inlay hint providers), `window/workDoneProgress/create`, and `$/progress`. - The LSP spec itself marks multi-client-per-server out-of-scope ([microsoft/language-server-protocol#1160](https://github.com/microsoft/language-server-protocol/issues/1160)) and Claude Code hit the same problem and closed their tracker ([anthropics/claude-code#28673](https://github.com/anthropics/claude-code/issues/28673)) without shipping a shared-instance solution. - The cheaper, in-tree alternative worth scoping: a `pi-lens-mcp-lspd` daemon — long-lived LSP-only process the existing `LSPService` socket-attaches to. Reuses existing client code, warm-channel pattern, and per-server install. No protocol rewriting, no per-server re-testing. **Recommendation:** gate both on **measurement**. If two `pi` processes on a real workspace are actually 2× memory / 2× startup cost on a workload the user cares about, scope Phase 1 (`pi-lens-mcp-lspd`). Otherwise, leave [#157](https://github.com/apmantza/pi-lens/issues/157) open as-is and the MCP path continues to cover the highest-cost user (Claude Code + `pi-lens-analyze`). ## Background: what was being investigated The question that motivated this analysis: > "we want to assess whether building a separate client that could spawn lsps > and make them available for different pi sessions that go on concurrently > would make sense. i assume that currently if we are running x2 different > sessions in a typescript server we spawn x2 ts lsps." Two reference bridges were named: [manateelazycat/lsp-bridge](https://github.com/manateelazycat/lsp-bridge) and [p2502/lspmux](https://codeberg.org/p2502/lspmux). The hypothesis was that multiple `pi` sessions on the same workspace each spawn an independent `tsserver` (etc.) and waste 300-500MB+ of RAM and several seconds of indexing time per extra session. The investigation confirms the **cost is real** for some scenarios (cross-process plain `pi`, or any agent that doesn't go through the MCP warm path) but **already solved** for others (MCP warm server + PostToolUse hook). The bridges themselves turn out to be poor fits for pi-lens — for architectural reasons specific to how pi-lens uses LSP, not just "we'd have to maintain another dep." ## Current state in pi-lens (what already shares what) `LSPService` is a per-Node-process singleton: ```ts // clients/lsp/index.ts:2018-2033 let globalLSPService: LSPService | null = null; export function getLSPService(): LSPService { if (!globalLSPService) globalLSPService = new LSPService(); return globalLSPService; } export function resetLSPService(options: LSPShutdownOptions = {}): void { if (globalLSPService) { globalLSPService.shutdown(options).catch(() => {}); } globalLSPService = null; } ``` The `state.clients` map is keyed by `"serverId:root"` (`clients/lsp/index.ts:53`), so the service deduplicates per-server per-workspace **within one process**. The matrix: | Scenario | Shared? | Why | |---|---|---| | Sub-agents / forked contexts in one `pi` invocation | ✅ | Same process → same singleton → same `tsserver` instance per workspace root | | One `pi` process, multiple `session_start` events with overlapping roots | ✅ | `resetLSPService({ fast: true })` tears down but the new session re-attaches to still-living warm clients in the warm channel (`mcp/server.ts` `auto session_start` path) | | **Two separate `pi` processes** on the same workspace (two terminals) | ❌ | Each Node process has its own `globalLSPService`; each spawns its own `tsserver`/`rust-analyzer`/etc. — same gap as [anthropics/claude-code#28673](https://github.com/anthropics/claude-code/issues/28673) | | Two Claude Code sessions + `pi-lens-analyze` PostToolUse hook | ✅ | The MCP server is a long-lived separate process that holds LSP clients warm and is shared across sessions. The hook routes through the warm IPC side-channel (`clients/mcp/ipc.ts` — per-workspace stable socket/pipe = sha256 of resolved cwd), so both sessions hit the same `tsserver` instance | The "x2 ts lsps for x2 sessions" claim is therefore only true for the cross-process plain-`pi` case, and even there the cost is bounded: - A single `tsserver` is ~300-500MB on a typical TS project - A single `rust-analyzer` is 500MB-2GB+ on large Rust monorepos - A single `clangd` is 200-500MB on large C++ projects - A single `jdtls` is 1-2GB on large Java projects So the cost matters most for **plain `pi` on large monorepos** with a heavy server. For small/medium projects with light servers (pylsp, gopls on small modules), the cost is negligible. ## Bridges evaluated ### lsp-bridge (manateelazycat) - **Repo:** <https://github.com/manateelazycat/lsp-bridge> - **Architecture (from the repo framework + EmacsConf 2022 talk):** ``` Elisp client ──EPC──> Python backend ──stdio──> many LSP servers ▲ ▲ │ │ acm/ completion UI core/handler/ per-LSP-method handlers ``` - **Primary motivation:** *async offload* — move heavy LSP work out of Emacs's single-threaded runtime. The Python event loop handles async requests without blocking the editor. - **Multi-session sharing:** not a primary goal. Single Emacs → single Python → many servers. Each server still 1:1 with the bridge. - **Why it's the wrong fit for pi-lens:** - The Elisp half is irrelevant (we don't have a TUI to integrate into — pi has its own). - The acm/ completion UI is the whole reason the bridge exists for Emacs; we have no equivalent in pi-lens (we surface diagnostics as agent context injections, not as a completion menu). - The Python event loop doesn't help: Node is async-by-default and the LSP pipeline in pi-lens is already fully async (see `clients/safe-spawn.ts`, `clients/lsp/index.ts`). - Porting the architecture would mean rebuilding the Elisp/Python parts without gaining the UI integration that motivated the design. - **Verdict:** ❌ wrong shape. ### lspmux (p2502) - **Repo:** <https://codeberg.org/p2502/lspmux> (formerly `ra-multiplex` at <https://crates.io/crates/ra-multiplex>). - **Architecture (from the README):** ``` N editor clients ──socket──> lspmux server ──stdio──> 1 LSP server per workspace ▲ ▲ │ │ lspmux client shim per-workspace + per-env (impersonates the server lifecycle, with LSP binary on stdout) ID remapping on the wire ``` Editors that support socket transport (e.g. nvim with custom init) can connect directly to the lspmux server and skip the shim. - **Primary motivation:** N clients → 1 server per workspace, for RAM savings on heavy servers (originally rust-analyzer; now generalized). - **The trade-off, from the README:** > "Because neither LSP nor language servers (usually) support multiple clients > per server lspmux intercepts the handshake process and modifies IDs of > requests and responses to track which response belongs to which client. > Because not all messages can be tracked this way it drops some, notably it > drops any requests from the server, this may cause some issues for you, if > you run into any issues which are definitely not present in the language > server alone please open an issue!" - **What specifically breaks for pi-lens (concrete):** - `client/registerCapability` — pi-lens uses this for `typescript/willRenameFile` (rename preview), file watcher registration (`workspace/didChangeWatchedFiles`), and inlay hint providers. The `executeCommand` allowlist in `LSPService.getAdvertisedCommands()` (per-server advertised commands captured at initialize) is downstream of this — a server can't register new commands after init if lspmux drops its `client/registerCapability` request from the client side, and can't send `workspace/executeCommand` requests back to the client. - `window/workDoneProgress/create` + `$/progress` — TypeScript's "Indexing..." progress UI in the status bar, gopls/rust-analyzer progress reporting, and any other server-originated progress channel. - Per-client workspace folder scoping — lspmux hands the server a *single* root (per `(workspace, environment)` key). If two clients on the same lspmux instance have different workspace roots (e.g. different sub-projects in a monorepo, different branches), they collide. Pi-lens's `resolveLanguageRootForFile` resolves per-file workspace roots; that resolution would be lost. - `workspace/diagnostic` pull diagnostics from different clients would collide on the same server. - **What works fine in lspmux (rust-analyzer is the proven target):** - `textDocument/publishDiagnostics` (push diagnostics, the common case) - `textDocument/definition`, `references`, `hover`, `completion`, `documentSymbol` — basic read-only operations - `textDocument/didOpen`, `didChange`, `didClose` — most state stays consistent because the remapping is request-scoped - **Verdict:** ❌ not safe for pi-lens as a general solution. Could be safe per-server with an allowlist (rust-analyzer is the proven safe target; typescript-language-server is not, due to `client/registerCapability`). ### Zed / sourcegraph / "lsp-proxy" / others - **Zed:** No built-in "shared rust-analyzer" daemon. The conventional answer in the ecosystem is lspmux, same as above. The [Rust on Zed docs](https://zed.dev/docs/languages/rust) mention no cross-instance sharing. (Zed's extensions are per-editor-instance.) - **sourcegraph:** No public "lsp-proxy" daemon. The closest is the [codescout::lsp::mux::protocol](https://docs.rs/codescout/latest/codescout/lsp/mux/protocol/index.html) Rust crate which is the lspmux-protocol library extracted for reuse — same project. - **Other AI-agent codegen tools (Cursor, Cody, Continue, etc.):** All hit the same problem (each editor session spawns its own LSP). None ship a shared-instance solution. Claude Code closed the equivalent tracker ([#28673](https://github.com/anthropics/claude-code/issues/28673)) without shipping a solution — proposed "socket transport with PID file probe" but it was not implemented. ### Claude Code's own problem (#28673, closed) The most directly relevant prior art. Quoting from the issue body: > The rust-analyzer-lsp plugin (and any LSP plugin using the declarative > `lspServers` config) spawns a new LSP server process per Claude Code > session. For `rust-analyzer`, each instance independently loads and indexes > the entire project, consuming 500MB–2GB+ of memory. Running multiple Claude > Code sessions against the same project means multiple redundant instances of > the same server, all holding duplicate indexes in memory. > > Current Architecture: The LSP Manager reads `lspServers` config from the > plugin manifest, spawns a child process per session via stdio, manages a > 1:1 client-server lifecycle tied to the session. Stdio transport is > inherently single-client — there's no mechanism to discover or reuse an > existing server process. > > Proposed Solution: Add an option for LSP server definitions to share a > single server process across sessions working on the same project. Two > possible approaches: A) Socket-based transport with process reuse, B) A > dedicated LSP proxy that multiplexes clients. Status: **closed** without a shipped solution. The proposed approaches (socket-based + PID probe, or external proxy like lspmux) are exactly the two options in this analysis; neither was implemented in Claude Code. This is informative for pi-lens: even a much larger team with explicit motivation (Anthropic) found the cost of the right solution too high to ship. The lighter alternative (in-tree daemon) is the lower-friction path. ## The LSP spec itself [microsoft/language-server-protocol#1160](https://github.com/microsoft/language-server-protocol/issues/1160) remains **open** with this statement in the spec: > The protocol currently assumes that one server serves one tool. There is > currently no support in the protocol to share one server between different > tools. Such a sharing would require additional protocol e.g. to lock a > document to support concurrent editing. Multi-client-per-server is not a designed-in scenario. Any bridge that attempts it is working against the protocol, not with it. Lspmux's "drop server→client messages" trade-off is the symptom. ## Alternative: in-tree `pi-lens-mcp-lspd` daemon A much smaller change that closes the plain-`pi` cross-process gap without any of the lspmux breakage. ### Design - **A new long-lived process** (`dist/mcp/lspd.js`) that: - Owns one `LSPService` singleton (same code as the MCP server) - Listens on a per-workspace stable endpoint, derived the same way as `clients/mcp/ipc.ts:ipcPathForCwd` (sha256 of resolved cwd → named pipe on Windows, Unix socket on POSIX) - Speaks the **existing** LSP JSON-RPC protocol over the socket — no rewriting, no ID remapping, no message drops - When a client connects, hands it its own `LSPClientInfo` (per-client `LSPClient` instance backed by a socket child of the actual LSP server process) — the LSP server itself runs once per `(serverId, root)`, the same way the current `LSPService` deduplicates within a process - **The existing `LSPService`** is taught a new `transport: "stdio" | "socket"` field on its spawn. When `socket` and the daemon is reachable for the workspace root, it spawns the daemon and connects to it instead of `safeSpawnAsync`'ing the LSP binary directly. When the daemon is unreachable, falls back to the current stdio-spawn path (preserves all existing behavior; this is opt-in by config). ### What this reuses (no new infrastructure) - `LSPService` client code (`clients/lsp/index.ts`) — unchanged surface, new transport option - `LSPClient` (`clients/lsp/client.ts`) — the actual JSON-RPC client; swap the stdio transport for `net.Socket`/`node:net` named-pipe transport - The warm-channel pattern in `clients/mcp/ipc.ts:ipcPathForCwd` — already cross-platform (Windows named pipe `\\.\pipe\pi-lens-mcp-<sha256prefix>` / Unix socket `/tmp/pi-lens-mcp-<sha256prefix>.sock`), already per-workspace stable - The per-server install in `clients/installer/index.ts` — the daemon can reuse the same registry of installable servers; only the launch shape changes - The `lsp.json` config surface (per `.pi-lens/lsp.json` or `~/.pi-lens/lsp.json`) — opt-in per-server, same as the disabled-servers config ### What this does NOT need - No protocol rewriting. The LSP server is told about exactly one client connection (the daemon's), and that client manages the cross-process sharing itself with normal LSP semantics. - No ID remapping. Each connected `pi` process talks JSON-RPC to its own `LSPClient` instance in the daemon; the daemon fans messages to the one server process. (The server is the only thing shared, not the protocol state.) - No dropping of server→client messages. The daemon forwards everything. - No per-server re-testing beyond confirming the new transport works for each server. The transport is the same JSON-RPC the existing client uses; the only change is `stdio` → `socket`. ### What this is NOT (deliberate scope cuts) - **Not a general LSP multiplexer** — only the LSP server is shared. There is one daemon per workspace, not per-server, but each serverId still has its own server process. (Sharing one server across multiple workspaces is a bigger problem — different root folders, different `workspace/didChangeWatchedFiles` subscriptions — that the spec explicitly doesn't support.) - **Not a multi-host daemon** — the socket/pipe is `127.0.0.1` / local named-pipe only. No auth, no remote clients. (Cross-host LSP sharing is a fundamentally different problem; the filesystem state would have to be identical, as the lspmux README itself notes.) - **Not auto-started** — the user runs `pi-lens-mcp-lspd start` (or it auto-starts on first `pi` session if `lsp.json` enables the socket transport). Stays off by default; the per-edit cost of one daemon process is small but real (Node startup + module load + JSON-RPC server listener). ### Cost estimate - **New code:** ~500-800 LOC. The daemon is mostly a stripped-down `mcp/server.ts` with no MCP transport, no `pilens_analyze`/etc. tools, no dispatcher — just a `LSPService` + a JSON-RPC-over-socket bridge. - **New tests:** ~10-15 unit tests for the daemon (start, connect, request/response forwarding, error/fallback, shutdown); one spawn smoke. The existing LSP client tests (38 server fixtures) cover the LSP side unchanged. - **New packaging:** one new bin (`pi-lens-mcp-lspd`) + one new dist entry. No new runtime deps. - **No lockfile change** (no new deps). Per the install constraints in AGENTS.md: runtime imports must live in `dependencies`, the host SDK must be type-only, `package-lock.json` is committed and must stay in sync. ### Risk - The new transport is the only piece of new logic; it's a thin `net.Socket` ↔ `LSPClient` glue. Most failure modes (server crash, pipe hangup, slow client) are already handled in the existing client. - The main risk is **operational**: users will need to know to start the daemon, and a daemon that crashes silently will degrade to the existing stdio path. A small health check + `lens-health` integration handles this. ## Recommendation **Phase 0 (do now, small):** the `LSP sharing scope` matrix is not currently documented in `AGENTS.md`; add it as a load-bearing note so the next agent doesn't relitigate. (Currently held in [#157](https://github.com/apmantza/pi-lens/issues/157#issuecomment-4761963228).) **Phase 1 (gated, in-tree, only if measured cost warrants it):** `pi-lens-mcp-lspd` daemon as sketched above. Reuses the existing `LSPService` + `clients/mcp/ipc.ts` warm channel + installer registry. No new deps. Scope as a separate `feature` sub-issue with `refs #157` once there's a measured workload demonstrating the cost. **Phase 2 (only if Phase 1 isn't enough):** lspmux integration, behind a per-server opt-in, with an explicit `lspmux-safe: [serverIds]` allowlist (rust-analyzer first; typescript-language-server is NOT safe per the `client/registerCapability` analysis above). This is the high-cost phase — it needs per-server testing for capability registration and progress reporting on every server in `LSP_SERVERS`, plus per-server fallback when the lspmux shim is missing. **Do not ship a general-purpose bridge.** lsp-bridge is the wrong shape; lspmux is the right shape but breaks messages pi-lens uses. The `pi-lens-mcp-lspd` approach is the only one that doesn't trade correctness for sharing. ## Gating: when to actually do Phase 1 The MCP path already covers the highest-cost user (Claude Code via `pi-lens-analyze` PostToolUse hook). The remaining gap is plain `pi` in two terminals on a large monorepo. Before scoping Phase 1, measure: 1. `ps` / `psm` on a clean workspace while running two `pi` sessions side-by-side. Capture RSS per `tsserver` (or the heavy server in use). 2. First-edit latency in each session — second session should pay the cold spawn, the first should not. 3. Compare to running both sessions through the MCP warm path (one `pi-lens-mcp` server, both clients). If the warm path saves meaningfully and the user is happy with it, the plain-`pi` cross-process gap is a non-issue for them. If the cost is real and the MCP path is a non-fit (e.g. the user is on plain `pi` and doesn't want the MCP overhead), Phase 1 is justified. Otherwise, leave [#157](https://github.com/apmantza/pi-lens/issues/157) open and the matrix above is the durable answer. ## Open questions - **Does pi itself support concurrent sub-agents within one process?** This analysis assumes the matrix row "sub-agents / forked contexts in one `pi` invocation" is a real workload. If pi only ever has one active session per process, that row collapses to "session_start events with overlapping roots" and the matrix shrinks. Need to confirm against the pi host SDK (`@earendil-works/pi-coding-agent`). - **How often does the warm MCP path actually save cost in practice?** The `pi-lens-analyze` hook is opt-in (user must register it with `claude mcp add`). If most users don't register it, the MCP path covers a much smaller fraction of the user base than this analysis assumes. - **Is there an existing Rust/Node LSP multiplexing crate we could embed?** `codescout::lsp::mux::protocol` is the lspmux-protocol library. Embedding it as a Node N-API module is a viable middle ground (no fork to maintain) but still has the same message-dropping trade-off. The Phase 1 design is strictly better. ## References - Bridges: - [lsp-bridge](https://github.com/manateelazycat/lsp-bridge) — async-offload bridge for Emacs - [lspmux](https://codeberg.org/p2502/lspmux) — N clients → 1 server per workspace (formerly [ra-multiplex](https://docs.rs/crate/ra-multiplex/latest)) - [codescout::lsp::mux::protocol](https://docs.rs/codescout/latest/codescout/lsp/mux/protocol/index.html) — the lspmux-protocol crate extracted for reuse - LSP spec / related issues: - [microsoft/language-server-protocol#1160](https://github.com/microsoft/language-server-protocol/issues/1160) — "Plans for supporting multiple clients from a single LSP server?" (open) - [anthropics/claude-code#28673](https://github.com/anthropics/claude-code/issues/28673) — "Support shared LSP server instances across sessions" (closed without a solution) - pi-lens code: - `clients/lsp/index.ts:2018-2033` — `LSPService` singleton + reset - `clients/lsp/index.ts:53` — `state.clients` keyed by `serverId:root` - `clients/mcp/ipc.ts:ipcPathForCwd` — per-workspace stable socket/pipe naming (sha256 of resolved cwd) - `mcp/server.ts` — the long-lived MCP server (the existing cross-process-sharing substrate for Claude Code) - `docs/mcp.md` — design doc for the MCP server (the pattern a `pi-lens-mcp-lspd` would reuse) - pi-lens issues: - [#157](https://github.com/apmantza/pi-lens/issues/157) — "feat: consider opt-in lspmux support" (open; the issue this analysis supports) - [#157 comment](https://github.com/apmantza/pi-lens/issues/157#issuecomment-4761963228) — earlier version of the trade-off analysis (superseded by this doc)