Skip to content

Commit 824ed18

Browse files
committed
docs(plan): scope LSP workspace singleton per client
The newest-wins workspace singleton keys its owner record on the workspace root alone, so every mdsmith lsp on a workspace contends for one slot and the newest supersedes the rest. That tears down the VS Code server the moment a Claude Code plugin server (or a second Claude terminal) initializes on the same repo. Plan: make the singleton claim opt-in via a client-supplied `singletonScope` token and key the owner record on (root, scope). VS Code sends `vscode.env.sessionId` (stable across an extension-host reload), so the upgrade hand-off still reaps the orphan; other clients send no token and coexist freely. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mb6qCg7oxErCU2p5JFH3eP
1 parent 0314c10 commit 824ed18

2 files changed

Lines changed: 204 additions & 0 deletions

File tree

PLAN.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,4 +233,5 @@ footer: |
233233
| 2606260211 || sonnet | [Add dedicated unit tests for layer0_html.go helpers](plan/2606260211_arch-fix-layer0-html-helper-tests.md) |
234234
| 2606260614 || sonnet | [arch-fix: add dedicated unit tests for lineclass_scan.go HTML-scanning helpers](plan/2606260614_arch-fix-lineclass-scan-helper-tests.md) |
235235
| 2606260615 || sonnet | [Add dedicated unit tests for unexported helpers in cue/cuelite/engine.go](plan/2606260615_arch-fix-cuelite-engine-helper-tests.md) |
236+
| 2606292015 | 🔳 | opus | [Scope the LSP workspace singleton per client so instances coexist](plan/2606292015_lsp-multi-instance-coexistence.md) |
236237
<?/catalog?>
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
---
2+
id: 2606292015
3+
title: Scope the LSP workspace singleton per client so instances coexist
4+
status: "🔳"
5+
model: opus
6+
summary: >-
7+
Key the newest-wins LSP workspace singleton on a
8+
client-supplied scope token, not the workspace root
9+
alone, and make the claim opt-in. VS Code sends a
10+
token stable across an extension-host reload
11+
(`vscode.env.sessionId`); other clients send none and
12+
run without the singleton. That lets the VS Code
13+
server, a Claude Code plugin server, and a second
14+
Claude in another terminal all run on one workspace at
15+
once, while the VS Code extension-upgrade hand-off
16+
(old server stops, new starts) keeps working.
17+
---
18+
# Scope the LSP workspace singleton per client so instances coexist
19+
20+
## Goal
21+
22+
Run one `mdsmith lsp` per client on the same workspace, all at
23+
once. One behavior must survive the change. A VS Code extension
24+
upgrade or reload still stops the old server and starts the new
25+
one.
26+
27+
## Background
28+
29+
`mdsmith lsp` is a stdio server; each client spawns and owns its
30+
own process. That part already supports many instances. The
31+
[VS Code extension](../editors/vscode/src/wiring.ts), the
32+
[Claude Code plugin](../editors/claude-code/.claude-plugin/plugin.json)
33+
(`npx -y -p @mdsmith/cli mdsmith lsp`), and a second Claude in
34+
another terminal each launch their own server.
35+
36+
One mechanism breaks that: the newest-wins singleton in
37+
[singleton.go](../internal/lsp/singleton.go). It is turned on in
38+
production by [`cmd/mdsmith/lsp.go`](../cmd/mdsmith/lsp.go). It
39+
records one owner per workspace. The key is the root path alone:
40+
41+
```go
42+
func workspaceKey(root string) string {
43+
sum := sha256.Sum256([]byte(filepath.Clean(root)))
44+
return hex.EncodeToString(sum[:])
45+
}
46+
```
47+
48+
Every server on one workspace contends for that single owner
49+
record. The newest claim wins; older servers poll, see a
50+
different owner, send `mdsmith/superseded`, and exit. The
51+
extension's [`decideClose`](../editors/vscode/src/wiring.ts)
52+
suppresses restart on that signal. So when a Claude plugin server
53+
(or a second Claude terminal) initializes on the same repo, the
54+
VS Code server steps aside and does not come back, and the editor
55+
loses diagnostics.
56+
57+
The singleton exists for one real case, documented in the
58+
[VS Code guide](../docs/guides/editors/vscode.md): a VS Code
59+
extension update or reload can leave a leaked extension host alive
60+
next to the new one. The orphaned host holds the old server's
61+
stdin open, so no EOF arrives, and it stays alive by PID, so the
62+
[`processId` watchdog](../internal/lsp/parentwatch.go) can't reap
63+
it. The newest-wins claim is what stops that orphan from racing
64+
the freshly spawned server. That hand-off must keep working.
65+
66+
No other client has this failure mode. A Claude instance that
67+
dies closes its child server's stdin pipe, so EOF arrives and the
68+
server exits normally. The singleton is, in practice, a
69+
VS-Code-only safeguard that currently reaches across to every
70+
client on the workspace.
71+
72+
## Non-Goals
73+
74+
- Removing the singleton or the `processId` watchdog. Both stay.
75+
- Sharing parse or cross-file caches between processes. Each
76+
`mdsmith lsp` keeps its own in-process
77+
[`Session`](../pkg/mdsmith) caches; no shared cache is added.
78+
- Coordinating fix-on-save writes across processes. Each editor
79+
writes its own buffer; concurrent on-disk writes are out of
80+
scope.
81+
- Changing the LSP wire surface beyond reading
82+
`initializationOptions` on `initialize`.
83+
84+
## Design
85+
86+
### Opt-in scope token
87+
88+
Add a client opt-in. The client may send a `singletonScope`
89+
string in its `initializationOptions`. When it does, the owner
90+
key hashes the root *and* that token:
91+
92+
```text
93+
key = sha256(filepath.Clean(root) + "\x00" + scope)
94+
```
95+
96+
When the token is empty or absent, the server does not claim the
97+
registry and does not start the watcher. The singleton is then a
98+
no-op for that client. So a client opts in by sending a token and
99+
opts out by sending nothing.
100+
101+
### What each client sends
102+
103+
| Client | `singletonScope` value | Effect |
104+
| --------------------------- | ---------------------- | ---------------------------------------------- |
105+
| VS Code extension | `vscode.env.sessionId` | Orphan and respawn share one slot; newest wins |
106+
| Claude Code plugin | none | No claim; never supersedes or is superseded |
107+
| Second Claude in a terminal | none | No claim; coexists with the first |
108+
| Neovim / Helix / JetBrains | none | No claim; coexists |
109+
110+
`vscode.env.sessionId` is stable across an extension-host reload
111+
and across an extension update within one VS Code application
112+
session, and it is unique per application session. On a full app
113+
upgrade VS Code restarts, the old process exits, its pipes close,
114+
and there is no orphan to reap. So the token reaps exactly the
115+
orphan the singleton targets.
116+
117+
### Why a client token, not an inferred identity
118+
119+
| Identity | Reaps the upgrade orphan? | Lets two Claude terminals coexist? |
120+
| --------------------- | ------------------------------ | ---------------------------------- |
121+
| Workspace root only | yes | no — they supersede each other |
122+
| `processId` | no — orphan and respawn differ | yes |
123+
| `clientInfo.name` | yes | no — both report `claude-code` |
124+
| Client-supplied token | yes | yes |
125+
126+
Only a client-supplied, reload-stable token reaps the orphan and
127+
lets independent clients coexist. The mechanism is generic: any
128+
future client with the leaked-host problem opts in with its own
129+
stable token, with no name-specific branch in the server.
130+
131+
### Rollout
132+
133+
The VS Code extension bundles its own `mdsmith` binary. So the
134+
server change and the extension change ship together for the
135+
common path. Until the extension sends the token, its singleton
136+
stays off. That only disables orphan reaping, a rare edge case.
137+
EOF and the `processId` watchdog still handle normal exits. A
138+
user who points `mdsmith.path` at a newer external binary gets
139+
the same interim behavior until they update the extension.
140+
141+
### Documentation
142+
143+
Two docs change. [`docs/reference/cli/lsp.md`](../docs/reference/cli/lsp.md)
144+
gains a "Multiple instances" section. It states that many servers
145+
per workspace are supported, and that the singleton is opt-in via
146+
`singletonScope`. The [VS Code guide](../docs/guides/editors/vscode.md)
147+
note "Two mdsmith servers running" is updated. It says the scope
148+
is per VS Code session. So a Claude plugin or another editor on
149+
the same workspace is unaffected.
150+
151+
## Tasks
152+
153+
1. [ ] Capture `initializationOptions` in `initializeParams`
154+
([protocol.go](../internal/lsp/protocol.go)); add a typed
155+
`singletonScope` string field. Unit-test the unmarshal.
156+
2. [ ] Add `singletonKey(root, scope)` and route the claim and
157+
watcher through it. Make
158+
[`startSingletonWatch`](../internal/lsp/singleton.go) a no-op
159+
on an empty scope. Red/green: same `(root, scope)` supersedes;
160+
different scopes coexist; empty scope never claims.
161+
3. [ ] Thread the scope from `handleInitialize`
162+
([server_lifecycle.go](../internal/lsp/server_lifecycle.go))
163+
into the claim.
164+
4. [ ] VS Code: send
165+
`initializationOptions: { singletonScope: env.sessionId }`
166+
([extension.ts](../editors/vscode/src/extension.ts) /
167+
[wiring.ts](../editors/vscode/src/wiring.ts)). Test that the
168+
built client options carry it.
169+
5. [ ] Add a test that the upgrade hand-off still works: two
170+
processes with the same scope, newest wins, older emits
171+
`mdsmith/superseded`. Leave `decideClose` unchanged.
172+
6. [ ] Update [lsp.md](../docs/reference/cli/lsp.md) and
173+
[vscode.md](../docs/guides/editors/vscode.md).
174+
7. [ ] Run `mdsmith fix PLAN.md` after the front-matter status
175+
flips to done.
176+
177+
## Acceptance Criteria
178+
179+
- [ ] Two `mdsmith lsp` servers on one workspace with different
180+
`singletonScope` tokens both stay alive; neither is
181+
superseded.
182+
- [ ] Two servers with the same `singletonScope`: the newest
183+
wins, the older sends `mdsmith/superseded` and exits.
184+
- [ ] A server that receives no `singletonScope` never writes the
185+
owner registry and is never superseded.
186+
- [ ] The VS Code extension sends
187+
`initializationOptions.singletonScope = vscode.env.sessionId`;
188+
an extension test asserts it.
189+
- [ ] The VS Code upgrade hand-off still works — old server stops,
190+
new server starts — verified by a test using one scope
191+
across two processes.
192+
- [ ] [`docs/reference/cli/lsp.md`](../docs/reference/cli/lsp.md)
193+
documents multi-instance coexistence, and the
194+
[VS Code guide](../docs/guides/editors/vscode.md) note
195+
reflects the per-session scope.
196+
- [ ] All tests pass: `go test ./...`.
197+
- [ ] `go tool -modfile=tools/go.mod golangci-lint run` reports no
198+
issues.
199+
- [ ] `mdsmith check .` passes.
200+
201+
## ...
202+
203+
<?allow-empty-section?>

0 commit comments

Comments
 (0)