pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
445 lines (381 loc) • 24.1 kB
Markdown
# 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)