Skip to content

Commit f012907

Browse files
author
merge-queue-bot
committed
Merge PR #714: Plan: scope the LSP workspace singleton per client so instances coexist
2 parents 830a7ca + a68ec24 commit f012907

2 files changed

Lines changed: 283 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: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
---
2+
id: 2606292015
3+
title: Scope the LSP workspace singleton per client so instances coexist
4+
status: "🔲"
5+
model: opus
6+
summary: >-
7+
Make the newest-wins LSP workspace singleton opt-in.
8+
Key its owner record on the workspace root plus a
9+
client-supplied scope token, not the root alone. The
10+
VS Code extension sends a per-workspace UUID it
11+
persists in `workspaceState`; the id is read from
12+
disk, so it is stable across an extension reload or
13+
update. Other clients send no token and run without
14+
the singleton. That lets the VS Code server, a Claude
15+
Code plugin server, and a second Claude in another
16+
terminal all run on one workspace at once, while the
17+
VS Code upgrade hand-off (old server stops, new one
18+
starts) keeps working.
19+
---
20+
# Scope the LSP workspace singleton per client so instances coexist
21+
22+
## Goal
23+
24+
Run one `mdsmith lsp` per client on the same workspace, all at
25+
once. One behavior must survive the change. A VS Code extension
26+
upgrade or reload still stops the old server and starts the new
27+
one.
28+
29+
## Background
30+
31+
`mdsmith lsp` is a stdio server; each client spawns and owns its
32+
own process. That part already supports many instances. The
33+
[VS Code extension](../editors/vscode/src/wiring.ts), the
34+
[Claude Code plugin](../editors/claude-code/.claude-plugin/plugin.json)
35+
(`npx -y -p @mdsmith/cli mdsmith lsp`), and a second Claude in
36+
another terminal each launch their own server.
37+
38+
One mechanism breaks that: the newest-wins singleton in
39+
[singleton.go](../internal/lsp/singleton.go). It is turned on in
40+
production by [`cmd/mdsmith/lsp.go`](../cmd/mdsmith/lsp.go). It
41+
records one owner per workspace. The key is the root path alone:
42+
43+
```go
44+
func workspaceKey(root string) string {
45+
sum := sha256.Sum256([]byte(filepath.Clean(root)))
46+
return hex.EncodeToString(sum[:])
47+
}
48+
```
49+
50+
Every server on one workspace contends for that single owner
51+
record. The newest claim wins; older servers poll, see a
52+
different owner, send `mdsmith/superseded`, and exit. The
53+
extension's [`decideClose`](../editors/vscode/src/wiring.ts)
54+
suppresses restart on that signal. So when a Claude plugin server
55+
(or a second Claude terminal) initializes on the same repo, the
56+
VS Code server steps aside and does not come back, and the editor
57+
loses diagnostics.
58+
59+
The singleton exists for one real case, documented in the
60+
[VS Code guide](../docs/guides/editors/vscode.md): a VS Code
61+
extension update or reload can leave a leaked extension host alive
62+
next to the new one. The orphaned host holds the old server's
63+
stdin open, so no EOF arrives, and it stays alive by PID, so the
64+
[`processId` watchdog](../internal/lsp/parentwatch.go) can't reap
65+
it. The newest-wins claim is what stops that orphan from racing
66+
the freshly spawned server. That hand-off must keep working.
67+
68+
No other client has this failure mode. A Claude instance that
69+
dies closes its child server's stdin pipe, so EOF arrives and the
70+
server exits normally. The singleton is, in practice, a
71+
VS-Code-only safeguard that currently reaches across to every
72+
client on the workspace.
73+
74+
## Non-Goals
75+
76+
- Removing the singleton or the `processId` watchdog. Both stay.
77+
- Sharing parse or cross-file caches between processes. Each
78+
`mdsmith lsp` keeps its own in-process
79+
[`Session`](../pkg/mdsmith) caches; no shared cache is added.
80+
- Coordinating fix-on-save writes across processes. Each editor
81+
writes its own buffer; concurrent on-disk writes are out of
82+
scope.
83+
- Re-keying mid-session. The root is read once at `initialize`
84+
(from `workspaceFolders[0]`), exactly as today. A multi-root
85+
folder add/remove does not re-claim. This matches current
86+
behavior and is unchanged here.
87+
- Changing the LSP wire surface beyond reading
88+
`initializationOptions` on `initialize`.
89+
90+
## Design
91+
92+
### Opt-in scope token
93+
94+
Add a client opt-in. The client may send a `singletonScope`
95+
string under `initializationOptions.mdsmith`. The namespace keeps
96+
the key off the generic top level and leaves room to add sibling
97+
fields later. When the scope is non-empty, the owner key hashes
98+
the root *and* that token:
99+
100+
```text
101+
key = sha256(filepath.Clean(root) + "\x00" + scope)
102+
```
103+
104+
When the scope is empty or absent, the server does not claim the
105+
registry and does not start the watcher. The singleton is then a
106+
no-op for that client. So a client opts in by sending a scope and
107+
opts out by sending nothing. An empty scope also hashes to the
108+
legacy root-only key, so a no-token client and an old root-only
109+
binary still agree on the same key (see Backward compatibility).
110+
111+
### What each client sends
112+
113+
| Client | `singletonScope` value | Effect |
114+
| --------------------------- | ---------------------------- | ---------------------------------------------- |
115+
| VS Code extension | persisted per-workspace UUID | Orphan and respawn share one slot; newest wins |
116+
| Claude Code plugin | none | No claim; never supersedes or is superseded |
117+
| Second Claude in a terminal | none | No claim; coexists with the first |
118+
| Neovim / Helix / JetBrains | none | No claim; coexists |
119+
120+
### The VS Code token must be disk-backed
121+
122+
The orphan and its respawn are two different extension-host
123+
processes. The token must be identical for both, or the new
124+
server never reaps the orphan. So the token must survive an
125+
extension-host restart.
126+
127+
The extension generates a UUID once with `crypto.randomUUID()`
128+
and stores it in `context.workspaceState`. That store is written
129+
to disk and read back unchanged after any reload or update. So
130+
both hosts read the same id. The id is per workspace on that
131+
machine, which is the grain the key needs.
132+
133+
`vscode.env.sessionId` is the obvious shortcut, but it is not
134+
safe here. The API documents it as changing "each time the editor
135+
is started," and it is injected per extension-host process. So a
136+
leaked host and a fresh host may hold different session ids. That
137+
would silently break reaping — the exact case the singleton
138+
exists for. A disk-backed id removes that doubt.
139+
140+
One known limit follows from the per-workspace grain. Two VS Code
141+
windows on the *same* folder read the same stored id, so the
142+
newest still wins between them. VS Code already focuses an open
143+
folder instead of opening a duplicate window, and today's
144+
root-only key behaves the same way, so this is not a regression.
145+
146+
### Why a client token, not an inferred identity
147+
148+
| Identity | Reaps the upgrade orphan? | Two Claude terminals coexist? |
149+
| ---------------------------- | ----------------------------------- | ------------------------------ |
150+
| Workspace root only | yes | no — they supersede each other |
151+
| `processId` | no — orphan and respawn differ | yes |
152+
| `clientInfo.name` | yes | no — both report `claude-code` |
153+
| `vscode.env.sessionId` | unclear — may change on host reload | yes (VS Code only) |
154+
| Persisted per-workspace UUID | yes — read from disk | yes |
155+
156+
Only a disk-backed, client-supplied token reaps the orphan and
157+
lets independent clients coexist. The mechanism is generic: any
158+
future client with the leaked-host problem opts in with its own
159+
stable token, with no name-specific branch in the server.
160+
161+
### One key function, one gate
162+
163+
Keep a single key function. Extend `workspaceKey` to take the
164+
scope rather than adding a sibling. An empty scope hashes the
165+
root alone, so every call site agrees on one derivation and the
166+
legacy behavior is preserved.
167+
168+
Keep one switch for "is the singleton active." `EnableWorkspaceSingleton`
169+
stays the process capability: it wires the registry seams and
170+
keeps unit tests hermetic. The scope is only the key input and
171+
the claim gate. The claim fires only when the scope is non-empty.
172+
The server never treats the scope as content; any client that
173+
sends one has opted in by definition.
174+
175+
### Backward compatibility
176+
177+
The key format changes from `sha256(root)` to
178+
`sha256(root + "\x00" + scope)`. An empty scope reproduces the
179+
old key byte for byte. So a no-token client, and an older
180+
root-only binary, still key the same way and still see each
181+
other. Only the VS Code (now UUID-keyed) path moves to a new key.
182+
183+
Old `.owner` records written under the legacy root-only key are
184+
not migrated. They are tiny files in the user cache dir, and
185+
nothing reads them once VS Code keys by UUID. They are harmless;
186+
the plan does not add a prune.
187+
188+
### Rollout
189+
190+
The VS Code extension bundles its own `mdsmith` binary. So the
191+
server change and the extension change ship together for the
192+
common path. There is no skew there.
193+
194+
Skew is possible only when a user points `mdsmith.path` at a
195+
newer external binary while running an older extension that sends
196+
no token. In that window the singleton is off, so the leaked-host
197+
orphan is not reaped — and that orphan is the one case the
198+
subsystem exists for. EOF and the `processId` watchdog still
199+
handle normal exits. The honest cost is: for the override case
200+
only, the "Two mdsmith servers running" note can recur until the
201+
extension updates.
202+
203+
### Documentation
204+
205+
Two docs change. [`docs/reference/cli/lsp.md`](../docs/reference/cli/lsp.md)
206+
gains a "Multiple instances" section. It states that many servers
207+
per workspace are supported, and that the singleton is opt-in via
208+
`singletonScope`. The [VS Code guide](../docs/guides/editors/vscode.md)
209+
note "Two mdsmith servers running" is updated. It says the scope
210+
is per VS Code workspace. So a Claude plugin or another editor on
211+
the same workspace is unaffected.
212+
213+
## Tasks
214+
215+
1. [ ] Capture `initializationOptions` in `initializeParams`
216+
([protocol.go](../internal/lsp/protocol.go)); read
217+
`mdsmith.singletonScope` as an optional string. Unit-test the
218+
unmarshal, including the absent / `null` / non-object case,
219+
which must decode to an empty scope (the opt-out path).
220+
2. [ ] Extend `workspaceKey` to take the scope and fold the
221+
empty-scope case in (empty scope hashes the root alone).
222+
Add `TestWorkspaceKey…` cases: same root + different scope →
223+
different key; same root + same scope → same key; empty scope
224+
→ the legacy key. Do not add a second key function.
225+
3. [ ] Gate the claim and watcher on a non-empty scope in
226+
[`startSingletonWatch`](../internal/lsp/singleton.go), and
227+
thread the scope from `handleInitialize`
228+
([server_lifecycle.go](../internal/lsp/server_lifecycle.go)).
229+
Drive the new empty-scope no-op red/green, distinct from the
230+
existing empty-root / empty-instanceID guard.
231+
4. [ ] VS Code: generate a UUID once and persist it in
232+
`context.workspaceState`; send it as
233+
`initializationOptions.mdsmith.singletonScope`
234+
([extension.ts](../editors/vscode/src/extension.ts) /
235+
[wiring.ts](../editors/vscode/src/wiring.ts)). Widen the
236+
injected context type to expose `workspaceState`. A `bun:test`
237+
asserts the built client options carry the token and that the
238+
id is stable across two activations.
239+
5. [ ] Unit-test the supersede logic on the existing seams
240+
(`watchSingleton` / the key): same scope → newest wins, older
241+
emits `mdsmith/superseded`; different scopes → both stay; no
242+
scope → never claims. Leave `decideClose` unchanged.
243+
6. [ ] Update [lsp.md](../docs/reference/cli/lsp.md) and
244+
[vscode.md](../docs/guides/editors/vscode.md).
245+
7. [ ] On completion, flip the front-matter status and run
246+
`mdsmith fix PLAN.md`.
247+
248+
## Acceptance Criteria
249+
250+
- [ ] Two `mdsmith lsp` servers on one workspace with different
251+
`singletonScope` tokens both stay alive; neither is
252+
superseded.
253+
- [ ] Two servers with the same `singletonScope`: the newest
254+
wins, the older sends `mdsmith/superseded` and exits.
255+
- [ ] A server that receives an empty or absent `singletonScope`
256+
never writes the owner registry and is never superseded;
257+
the absent / `null` `initializationOptions` decode is
258+
covered by a test.
259+
- [ ] The empty-scope no-op is driven red/green and is distinct
260+
from the pre-existing empty-root guard.
261+
- [ ] There is exactly one key function; an empty scope yields
262+
the legacy root-only key (a unit test pins this).
263+
- [ ] The VS Code extension sends
264+
`initializationOptions.mdsmith.singletonScope` = a UUID it
265+
persists in `workspaceState`; a `bun:test` asserts the
266+
token is sent and is stable across activations.
267+
- [ ] The VS Code upgrade hand-off still works — old server
268+
stops, new server starts — verified by a unit test that
269+
feeds one scope to two server instances.
270+
- [ ] [`docs/reference/cli/lsp.md`](../docs/reference/cli/lsp.md)
271+
documents multi-instance coexistence, and the
272+
[VS Code guide](../docs/guides/editors/vscode.md) note
273+
reflects the per-workspace scope.
274+
- [ ] All tests pass: `go test ./...` and the extension
275+
`bun:test` suite.
276+
- [ ] `go tool -modfile=tools/go.mod golangci-lint run` reports no
277+
issues.
278+
- [ ] `mdsmith check .` passes.
279+
280+
## ...
281+
282+
<?allow-empty-section?>

0 commit comments

Comments
 (0)