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