Skip to content

Commit a68ec24

Browse files
committed
docs(plan): revise LSP singleton plan after design review
Two independent xhigh design-review passes converged on a load-bearing flaw: vscode.env.sessionId is documented as changing "each time the editor is started" and is injected per extension-host process, so a leaked orphan host and its respawn may hold different ids — which would make (root, sessionId) keying never reap the orphan, strictly worse than today's root-only key for the one case the singleton exists for. Revisions: - VS Code now sends a per-workspace UUID persisted in context.workspaceState (disk-backed, provably stable across a reload/update) instead of env.sessionId. - Fold scope into the single workspaceKey seam (empty scope => legacy root-only key) instead of a parallel singletonKey. - Namespace the wire field as initializationOptions.mdsmith.singletonScope. - One gate: EnableWorkspaceSingleton stays the process capability; scope is only the key input and claim gate. - Add Backward compatibility (empty scope reproduces the legacy key; stale owner records are harmless), sharpen Rollout skew, and note multi-root re-keying is out of scope (unchanged today). - Strengthen TDD tasks/criteria: key-derivation unit test, absent/null initializationOptions decode, empty-scope no-op driven red/green, no-token-never-claims, bun:test for the token. - Two-window-same-folder limitation documented (not a regression). - Fix status to not-started (the implementation has not begun). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mb6qCg7oxErCU2p5JFH3eP
1 parent 824ed18 commit a68ec24

2 files changed

Lines changed: 150 additions & 71 deletions

File tree

PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,5 +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) |
236+
| 2606292015 | 🔲 | opus | [Scope the LSP workspace singleton per client so instances coexist](plan/2606292015_lsp-multi-instance-coexistence.md) |
237237
<?/catalog?>

plan/2606292015_lsp-multi-instance-coexistence.md

Lines changed: 149 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
---
22
id: 2606292015
33
title: Scope the LSP workspace singleton per client so instances coexist
4-
status: "🔳"
4+
status: "🔲"
55
model: opus
66
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.
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.
1719
---
1820
# Scope the LSP workspace singleton per client so instances coexist
1921

@@ -78,6 +80,10 @@ client on the workspace.
7880
- Coordinating fix-on-save writes across processes. Each editor
7981
writes its own buffer; concurrent on-disk writes are out of
8082
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.
8187
- Changing the LSP wire surface beyond reading
8288
`initializationOptions` on `initialize`.
8389

@@ -86,57 +92,113 @@ client on the workspace.
8692
### Opt-in scope token
8793

8894
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:
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:
9199

92100
```text
93101
key = sha256(filepath.Clean(root) + "\x00" + scope)
94102
```
95103

96-
When the token is empty or absent, the server does not claim the
104+
When the scope is empty or absent, the server does not claim the
97105
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.
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).
100110

101111
### What each client sends
102112

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.
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.
116145

117146
### Why a client token, not an inferred identity
118147

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 |
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 |
125155

126-
Only a client-supplied, reload-stable token reaps the orphan and
156+
Only a disk-backed, client-supplied token reaps the orphan and
127157
lets independent clients coexist. The mechanism is generic: any
128158
future client with the leaked-host problem opts in with its own
129159
stable token, with no name-specific branch in the server.
130160

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+
131188
### Rollout
132189

133190
The VS Code extension bundles its own `mdsmith` binary. So the
134191
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.
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.
140202

141203
### Documentation
142204

@@ -145,34 +207,43 @@ gains a "Multiple instances" section. It states that many servers
145207
per workspace are supported, and that the singleton is opt-in via
146208
`singletonScope`. The [VS Code guide](../docs/guides/editors/vscode.md)
147209
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
210+
is per VS Code workspace. So a Claude plugin or another editor on
149211
the same workspace is unaffected.
150212

151213
## Tasks
152214

153215
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 }`
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`
166234
([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.
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.
172243
6. [ ] Update [lsp.md](../docs/reference/cli/lsp.md) and
173244
[vscode.md](../docs/guides/editors/vscode.md).
174-
7. [ ] Run `mdsmith fix PLAN.md` after the front-matter status
175-
flips to done.
245+
7. [ ] On completion, flip the front-matter status and run
246+
`mdsmith fix PLAN.md`.
176247

177248
## Acceptance Criteria
178249

@@ -181,19 +252,27 @@ the same workspace is unaffected.
181252
superseded.
182253
- [ ] Two servers with the same `singletonScope`: the newest
183254
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.
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).
186263
- [ ] 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.
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.
192270
- [ ] [`docs/reference/cli/lsp.md`](../docs/reference/cli/lsp.md)
193271
documents multi-instance coexistence, and the
194272
[VS Code guide](../docs/guides/editors/vscode.md) note
195-
reflects the per-session scope.
196-
- [ ] All tests pass: `go test ./...`.
273+
reflects the per-workspace scope.
274+
- [ ] All tests pass: `go test ./...` and the extension
275+
`bun:test` suite.
197276
- [ ] `go tool -modfile=tools/go.mod golangci-lint run` reports no
198277
issues.
199278
- [ ] `mdsmith check .` passes.

0 commit comments

Comments
 (0)