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