From 0b97632a7c9f1b3e4849b2942f27a8b7343e3dd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Fri, 18 Sep 2026 07:14:18 +0000 Subject: [PATCH 1/9] :memo: Track protocol-backed LSP helper The autocomplete helper still carries a hand-rolled JSON-RPC client (Content-Length framing, a pending-request map, server-request replies). vscode-languageserver-protocol provides all of it plus typed requests, so track the move as a task with subtasks. --- backlog.tasks | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backlog.tasks b/backlog.tasks index 4593d94..0c7db2e 100644 --- a/backlog.tasks +++ b/backlog.tasks @@ -32,6 +32,11 @@ Testing: → `src/util/__tests__/lsp-completion.ts` (`#test-utils/…`) is the helper; the suite asserts its labels (see development/testing.md § Autocomplete) → wire it into `node --test` so a test asserts the offered labels → note: `Parameters[0]` resolves only the *last* overload; use `@ts-expect-error` call sites for factory negatives, not `not.toExtend>` +☐ Drive the LSP completion helper through the LSP protocol library instead of a hand-rolled JSON-RPC client @medium + ☐ Add `vscode-languageserver-protocol` and record the tooling decision + ☐ Rewrite `src/util/__tests__/lsp-completion.ts` onto `createMessageConnection` and typed requests + ☐ Update `development/testing.md § Autocomplete` for the new client + ☐ Run `npm run verify` and check the task off Matcher: ✔ Clean up: adopt the 3-overload matcher (`src/prototype-ac2.ts`) and delete the prototypes @high @done From 94ccc16963180bdd88ce0297c70052a33d25a7c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Fri, 18 Sep 2026 07:14:45 +0000 Subject: [PATCH 2/9] :heavy_plus_sign: Add the LSP protocol dependency The autocomplete helper drives tsc --lsp --stdio; vscode-languageserver-protocol supplies the transport (re-exported vscode-jsonrpc/node) and the typed LSP requests, replacing the hand-rolled JSON-RPC client. Record the choice and the rejected alternatives in tooling.md. --- development/tooling.md | 34 ++++++++++++++++++++++++++++++++++ package-lock.json | 31 ++++++++++++++++++++++++++++++- package.json | 3 ++- 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/development/tooling.md b/development/tooling.md index d1ab59f..53d3637 100644 --- a/development/tooling.md +++ b/development/tooling.md @@ -24,6 +24,8 @@ in [package.json](../package.json). - **@spences10/pi-lsp** — read-only LSP code intelligence for AI agents (project-local `.pi/settings.json`); talks to this repo's TypeScript 7 via `tsc --lsp --stdio`. +- **vscode-languageserver-protocol** — LSP client and protocol types for the + autocomplete test helper (`src/util/__tests__/lsp-completion.ts`). When each runs is in [CONTRIBUTING.md § Feedback tiers](../CONTRIBUTING.md#feedback-tiers). @@ -247,6 +249,38 @@ project. - The same script works by hand (whole project) and staged (scoped), so there is no second command to maintain. +## Language server tooling + +### `vscode-languageserver-protocol` backs the autocomplete helper + +#### Decision (2026-09) + +The autocomplete helper (`src/util/__tests__/lsp-completion.ts`) drives +`tsc --lsp --stdio` through `vscode-languageserver-protocol`'s +`createMessageConnection` and its typed request / notification objects, instead +of a hand-rolled JSON-RPC client. + +#### Why + +- Framing, `Content-Length` parsing, the pending-request map and server-request + dispatch are protocol plumbing the helper only reimplemented; the official + client owns them and tolerates the server's `string | number` ids. +- `InitializeRequest`, `CompletionRequest`, `DidOpenTextDocumentNotification`, + … carry their parameter and result types, so `CompletionList` / `CompletionItem` + replace the helper's ad-hoc shape guards. +- The `./node` entry re-exports `vscode-jsonrpc/node`, so one devDependency + supplies both the transport and the protocol types. It is test-only and never + ships (`files` publishes `dist/` only). + +#### Rejected + +- `vscode-languageclient`: the editor-side client with a full feature registry + — far more than a test helper needs. +- Generic JSON-RPC (`jsonrpc-lite`, `jayson`): still no LSP types, so they + replace framing only and leave the typed protocol surface unimplemented. +- Keeping the hand-rolled client: the low-level shape is the maintenance cost + the helper exists to remove, and it must be re-audited against the server. + ## Editor and agent tooling ### VSCode integration diff --git a/package-lock.json b/package-lock.json index 116442f..7f0c319 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,8 @@ "oxlint": "^1.83.0", "oxlint-tsgolint": "^7.0.2001", "publint": "^0.3.24", - "typescript": "^7.0.2" + "typescript": "^7.0.2", + "vscode-languageserver-protocol": "^3.18.3" }, "engines": { "node": ">=26" @@ -4822,6 +4823,27 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/vscode-jsonrpc": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.2.tgz", + "integrity": "sha512-SbQSV9yRemARxeXw6LU5sS6Zq0e9/DgCCX5yelH263ZQWukbTk8EF8fjTrr1dziasf4GwlJbvTwFnTrnQFWZXQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.18.3", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.3.tgz", + "integrity": "sha512-DF49+WeV5py4zO5hhobp60jjsDSK0lAqA0OuKBLBvp423HPWQcCbhZz3JgyfIewsEz2f8U+X75xNIFHdiXZm2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "9.0.2", + "vscode-languageserver-types": "3.18.3" + } + }, "node_modules/vscode-languageserver-textdocument": { "version": "1.0.14", "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.14.tgz", @@ -4829,6 +4851,13 @@ "dev": true, "license": "MIT" }, + "node_modules/vscode-languageserver-types": { + "version": "3.18.3", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.3.tgz", + "integrity": "sha512-XIlzJ7Qp/jzSI1ds7/FwPAWrPeTZA7pAtlW4hdJ1J6xXWJL6dR9QYnDhJOdLzdKhUQ5Mm6mvUMw+3DcOQQasPw==", + "dev": true, + "license": "MIT" + }, "node_modules/vscode-uri": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz", diff --git a/package.json b/package.json index 7e83d0f..1b228c5 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,8 @@ "oxlint": "^1.83.0", "oxlint-tsgolint": "^7.0.2001", "publint": "^0.3.24", - "typescript": "^7.0.2" + "typescript": "^7.0.2", + "vscode-languageserver-protocol": "^3.18.3" }, "engines": { "node": ">=26" From 9f56d7ae8e339b0a2dcb95629a4e0e2f450b0232 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Fri, 18 Sep 2026 07:16:25 +0000 Subject: [PATCH 3/9] :recycle: Back LSP helper with protocol library Replace the hand-rolled JSON-RPC client (framing, pending map, reply dispatch) with createMessageConnection and typed requests from vscode-languageserver-protocol. A catch-all onRequest answers the server requests the old client replied to, and CompletionList/CompletionItem replace the ad-hoc shape guards. Public LspSession API is unchanged. --- src/util/__tests__/lsp-completion.ts | 286 +++++++++------------------ 1 file changed, 90 insertions(+), 196 deletions(-) diff --git a/src/util/__tests__/lsp-completion.ts b/src/util/__tests__/lsp-completion.ts index b54a703..8c0891c 100644 --- a/src/util/__tests__/lsp-completion.ts +++ b/src/util/__tests__/lsp-completion.ts @@ -4,6 +4,22 @@ import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { pathToFileURL } from "node:url"; +import { + type CompletionItem, + type CompletionList, + CompletionRequest, + ConfigurationRequest, + createMessageConnection, + DidOpenTextDocumentNotification, + ExitNotification, + InitializedNotification, + InitializeRequest, + type MessageConnection, + ShutdownRequest, + StreamMessageReader, + StreamMessageWriter, +} from "vscode-languageserver-protocol/node"; + /** * Print the completion labels TypeScript's language server offers at a marker * inside a source file. @@ -48,71 +64,18 @@ export interface CompletionResult { readonly labels: readonly string[]; } -interface CompletionItem { - readonly label: string; -} - -interface RpcMessage { - readonly id?: string | number; - readonly method?: string; - readonly params?: unknown; - readonly result?: unknown; - readonly error?: unknown; -} - -interface Deferred { - readonly resolve: (message: RpcMessage) => void; - readonly reject: (error: Error) => void; -} - -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null; - -const isRpcMessage = (value: unknown): value is RpcMessage => { - if (!isRecord(value)) { - return false; - } - const { id, method } = value; - return ( - (id === undefined || - typeof id === "number" || - typeof id === "string") && - (method === undefined || typeof method === "string") - ); -}; - -const isCompletionItem = (value: unknown): value is CompletionItem => - isRecord(value) && typeof value["label"] === "string"; - -const serverRequestResult = (message: RpcMessage): readonly null[] | null => { - if (message.method !== "workspace/configuration") { - return null; - } - const items = isRecord(message.params) - ? message.params["items"] - : undefined; - return Array.isArray(items) ? items.map(() => null) : []; -}; - -const completionLabels = (result: unknown): readonly string[] => { - if (!isRecord(result)) { - return []; - } - const { items } = result; - if (!Array.isArray(items)) { +const completionLabels = ( + result: CompletionItem[] | CompletionList | null, +): readonly string[] => { + if (result === null) { return []; } + const items = Array.isArray(result) ? result : result.items; return items - .filter(isCompletionItem) .map((item) => item.label) .toSorted((left, right) => left.localeCompare(right)); }; -const frame = (message: object): string => { - const body = JSON.stringify(message); - return `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`; -}; - const markerPosition = (source: string, marker: string): Position => { const index = source.indexOf(marker); if (index === -1) { @@ -124,153 +87,81 @@ const markerPosition = (source: string, marker: string): Position => { return { line: lineIndex, character: line === undefined ? 0 : line.length }; }; -/** Minimal JSON-RPC client for `tsc --lsp --stdio`. */ -class LspClient { - readonly #child: ChildProcessWithoutNullStreams; - readonly #pending = new Map(); - #buffer = Buffer.alloc(0); - #nextId = 1; - - public constructor(repoRoot: string) { - this.#child = spawn( - path.join(repoRoot, "node_modules", ".bin", "tsc"), - ["--lsp", "--stdio"], - { cwd: repoRoot }, - ); - this.#child.stdout.on("data", (chunk: Buffer) => { - this.#consume(chunk); - }); - this.#child.stderr.on("data", (chunk: Buffer) => { - process.stderr.write(chunk); - }); - } - - /** Send a request and settle when the server answers it. */ - public request(method: string, params?: unknown): Promise { - const id = this.#nextId; - this.#nextId += 1; - const message = frame({ jsonrpc: "2.0", id, method, params }); - return new Promise((resolve, reject) => { - this.#pending.set(id, { resolve, reject }); - this.#child.stdin.write(message); - }); - } - - public notify(method: string, params: unknown): void { - this.#child.stdin.write(frame({ jsonrpc: "2.0", method, params })); - } - - public dispose(): void { - this.#child.stdin.end(); - this.#child.kill(); - } - - #consume(chunk: Buffer): void { - this.#buffer = Buffer.concat([this.#buffer, chunk]); - for (;;) { - const headerEnd = this.#buffer.indexOf("\r\n\r\n"); - if (headerEnd === -1) { - return; - } - const header = this.#buffer.subarray(0, headerEnd).toString(); - const match = /Content-Length: (\d+)/i.exec(header); - const lengthText = match === null ? undefined : match[1]; - if (lengthText === undefined) { - return; - } - const bodyEnd = headerEnd + 4 + Number(lengthText); - if (this.#buffer.length < bodyEnd) { - return; - } - const body = this.#buffer - .subarray(headerEnd + 4, bodyEnd) - .toString(); - this.#buffer = this.#buffer.subarray(bodyEnd); - const parsed: unknown = JSON.parse(body); - if (isRpcMessage(parsed)) { - this.#handle(parsed); - } - } - } - - #handle(message: RpcMessage): void { - // Server -> client request: answer so the server does not stall. - if (message.method !== undefined && message.id !== undefined) { - this.#child.stdin.write( - frame({ - jsonrpc: "2.0", - id: message.id, - result: serverRequestResult(message), - }), - ); - return; - } - // Response to one of our requests. - const { id } = message; - if (id === undefined) { - return; - } - const deferred = this.#pending.get(id); - if (deferred === undefined) { - return; - } - this.#pending.delete(id); - if (message.error === undefined) { - deferred.resolve(message); - } else { - deferred.reject(new Error(JSON.stringify(message.error))); - } - } -} - /** * One language server, initialized on first use, shared across probes. Callers * own the lifecycle and must `close()` it. */ export class LspSession { - readonly #client: LspClient; + readonly #child: ChildProcessWithoutNullStreams; + readonly #connection: MessageConnection; readonly #repoRoot: string; #ready: Promise | undefined; public constructor(repoRoot: string) { this.#repoRoot = repoRoot; - this.#client = new LspClient(repoRoot); + this.#child = spawn( + path.join(repoRoot, "node_modules", ".bin", "tsc"), + ["--lsp", "--stdio"], + { cwd: repoRoot }, + ); + this.#child.stderr.on("data", (chunk: Buffer) => { + process.stderr.write(chunk); + }); + this.#connection = createMessageConnection( + new StreamMessageReader(this.#child.stdout), + new StreamMessageWriter(this.#child.stdin), + ); + // Server -> client requests the server waits on: answer so it proceeds. + // `workspace/configuration` wants one reply per requested item; a + // catch-all covers the rest (e.g. `client/registerCapability`). + this.#connection.onRequest(ConfigurationRequest.type, ({ items }) => + items.map(() => null), + ); + this.#connection.onRequest(() => null); + this.#connection.listen(); } public completionLabelsAt( target: CompletionTarget, ): Promise { - return this.#ensureInitialized().then(() => { - const { uri, position } = this.#open(target); - return this.#client - .request("textDocument/completion", { - textDocument: { uri }, - position, - context: { triggerKind: 1 }, - }) - .then((response) => ({ - position, - labels: completionLabels(response.result), - })); - }); + return this.#ensureInitialized() + .then(() => this.#open(target)) + .then(({ uri, position }) => + this.#connection + .sendRequest(CompletionRequest.type, { + textDocument: { uri }, + position, + context: { triggerKind: 1 }, + }) + .then((result) => ({ + position, + labels: completionLabels(result), + })), + ); } /** `shutdown` + `exit`, then kill the server; safe after a failed probe. */ public close(): Promise { - return this.#client - .request("shutdown") - .then(() => { - this.#client.notify("exit", null); - return delay(EXIT_DELAY_MS); - }) + return this.#connection + .sendRequest(ShutdownRequest.type) + .then(() => + this.#connection.sendNotification(ExitNotification.type), + ) + .then(() => delay(EXIT_DELAY_MS)) .finally(() => { - this.#client.dispose(); + this.#connection.dispose(); + this.#child.kill(); }); } #ensureInitialized(): Promise { - this.#ready ??= this.#client - .request("initialize", { + this.#ready ??= this.#initialize(); + return this.#ready; + } + + #initialize(): Promise { + return this.#connection + .sendRequest(InitializeRequest.type, { processId: process.pid, rootUri: pathToFileURL(this.#repoRoot).href, workspaceFolders: [ @@ -285,16 +176,18 @@ export class LspSession { }, }, }) - .then(() => { - this.#client.notify("initialized", {}); - }); - return this.#ready; + .then(() => + this.#connection.sendNotification( + InitializedNotification.type, + {}, + ), + ); } - #open(target: CompletionTarget): { + #open(target: CompletionTarget): Promise<{ readonly uri: string; readonly position: Position; - } { + }> { const absolute = path.resolve(this.#repoRoot, target.file); const marker = target.marker ?? DEFAULT_MARKER; const position = markerPosition(target.source, marker); @@ -302,15 +195,16 @@ export class LspSession { const uri = pathToFileURL(absolute).href; // The server handles `didOpen` in order before the completion request, // so no settle delay is needed. - this.#client.notify("textDocument/didOpen", { - textDocument: { - uri, - languageId: "typescript", - version: 1, - text, - }, - }); - return { uri, position }; + return this.#connection + .sendNotification(DidOpenTextDocumentNotification.type, { + textDocument: { + uri, + languageId: "typescript", + version: 1, + text, + }, + }) + .then(() => ({ uri, position })); } } From 998c4bfd8039d6ae64975b46624c8c05788c08c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Fri, 18 Sep 2026 07:16:45 +0000 Subject: [PATCH 4/9] :memo: Document the protocol-backed LSP helper Note that the autocomplete helper drives the server through vscode-languageserver-protocol, cross-link the tooling decision, and replace the obsolete string-id known issue with the benign context-canceled stderr the Go server emits on shutdown. --- development/testing.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/development/testing.md b/development/testing.md index edcd407..0c42c77 100644 --- a/development/testing.md +++ b/development/testing.md @@ -134,7 +134,10 @@ The script prints the labels the server offers at a `/*COMPLETE*/` marker inside is imported by `src/util/__tests__/lsp-completion.test.ts` — which tests the helper itself against inline documents, never the library's code — and by `src/primitive.test.ts`, where the same probe asserts the matcher's popup; -the CLI is for manual inspection. +the CLI is for manual inspection. It speaks to the server through +`vscode-languageserver-protocol`'s message connection and typed requests, so the +transport and protocol plumbing are not hand-rolled (see +[tooling.md § `vscode-languageserver-protocol` backs the autocomplete helper](./tooling.md#vscode-languageserver-protocol-backs-the-autocomplete-helper)). #### Why @@ -162,8 +165,10 @@ the CLI is for manual inspection. any order; the file is an integration test (~1.6 s) that needs `node_modules`. `didOpen` is handled in order before the completion request, so no settle delay is needed. -- The server answers some requests with a string id (`client/registerCapability`); - the client must tolerate `string | number` ids or the server stalls. +- The Go server logs `context canceled` on stderr once per server as it shuts + down (nine lines per `test:unit` run). It is benign and predates the protocol + client — the hand-rolled client produced the same line — so it is left as is + rather than filtered out of the server's stderr. ## Known issues From 13497d1df8c88bda44303e69bca5879e100e478b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Fri, 18 Sep 2026 07:17:19 +0000 Subject: [PATCH 5/9] :memo: Check off the LSP helper task npm run verify is green: 23 tests pass, check:tsc, check:oxlint, check:oxfmt and check:cspell clean with the protocol-backed helper. --- backlog.tasks | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/backlog.tasks b/backlog.tasks index 0c7db2e..39f64a8 100644 --- a/backlog.tasks +++ b/backlog.tasks @@ -32,11 +32,11 @@ Testing: → `src/util/__tests__/lsp-completion.ts` (`#test-utils/…`) is the helper; the suite asserts its labels (see development/testing.md § Autocomplete) → wire it into `node --test` so a test asserts the offered labels → note: `Parameters[0]` resolves only the *last* overload; use `@ts-expect-error` call sites for factory negatives, not `not.toExtend>` -☐ Drive the LSP completion helper through the LSP protocol library instead of a hand-rolled JSON-RPC client @medium - ☐ Add `vscode-languageserver-protocol` and record the tooling decision - ☐ Rewrite `src/util/__tests__/lsp-completion.ts` onto `createMessageConnection` and typed requests - ☐ Update `development/testing.md § Autocomplete` for the new client - ☐ Run `npm run verify` and check the task off +✔ Drive the LSP completion helper through the LSP protocol library instead of a hand-rolled JSON-RPC client @medium @done + ✔ Add `vscode-languageserver-protocol` and record the tooling decision @done + ✔ Rewrite `src/util/__tests__/lsp-completion.ts` onto `createMessageConnection` and typed requests @done + ✔ Update `development/testing.md § Autocomplete` for the new client @done + ✔ Run `npm run verify` and check the task off @done Matcher: ✔ Clean up: adopt the 3-overload matcher (`src/prototype-ac2.ts`) and delete the prototypes @high @done From 8b7314374008a1b20a5fa498dfbfb23abe969b29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Fri, 18 Sep 2026 07:31:04 +0000 Subject: [PATCH 6/9] :bug: Stop the LSP shutdown context-canceled log The TS 7 Go server races Session.updateWatches against the exit notification; handleExit returns io.EOF, the background context is cancelled, and the bare error is flushed to stderr once the outgoing queue closes (server.go / logger.go). Send shutdown and close stdin instead: EOF makes the server exit cleanly (code 0, no output), with the kill kept as a fallback. testing.md records the cause. --- development/testing.md | 11 +++++++---- src/util/__tests__/lsp-completion.ts | 29 ++++++++++++++++------------ 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/development/testing.md b/development/testing.md index 0c42c77..2b449a0 100644 --- a/development/testing.md +++ b/development/testing.md @@ -165,10 +165,13 @@ transport and protocol plumbing are not hand-rolled (see any order; the file is an integration test (~1.6 s) that needs `node_modules`. `didOpen` is handled in order before the completion request, so no settle delay is needed. -- The Go server logs `context canceled` on stderr once per server as it shuts - down (nine lines per `test:unit` run). It is benign and predates the protocol - client — the hand-rolled client produced the same line — so it is left as is - rather than filtered out of the server's stderr. +- The TS 7 Go server has a shutdown race: `handleExit` returns `io.EOF`, which + cancels the background context while `Session.updateWatches` is still running + (`internal/project/session.go`), so the bare `context canceled` error is + flushed straight to stderr once the outgoing queue is closed, and the server + exits 1 even though LSP expects 0 after `shutdown`. The helper therefore sends + `shutdown` and then closes stdin instead of sending `exit`; EOF makes the + server exit cleanly (code 0, no output), and the kill is kept as a fallback. ## Known issues diff --git a/src/util/__tests__/lsp-completion.ts b/src/util/__tests__/lsp-completion.ts index 8c0891c..20f0d2e 100644 --- a/src/util/__tests__/lsp-completion.ts +++ b/src/util/__tests__/lsp-completion.ts @@ -11,7 +11,6 @@ import { ConfigurationRequest, createMessageConnection, DidOpenTextDocumentNotification, - ExitNotification, InitializedNotification, InitializeRequest, type MessageConnection, @@ -140,18 +139,24 @@ export class LspSession { ); } - /** `shutdown` + `exit`, then kill the server; safe after a failed probe. */ + /** `shutdown` + close stdin, then kill the server; safe after a failed probe. */ public close(): Promise { - return this.#connection - .sendRequest(ShutdownRequest.type) - .then(() => - this.#connection.sendNotification(ExitNotification.type), - ) - .then(() => delay(EXIT_DELAY_MS)) - .finally(() => { - this.#connection.dispose(); - this.#child.kill(); - }); + return ( + this.#connection + .sendRequest(ShutdownRequest.type) + // The TS 7 Go server races a background task against the `exit` + // notification and logs a bare `context canceled` to stderr as it + // tears down (see development/testing.md § Autocomplete). EOF on + // stdin shuts it down cleanly instead: exit 0, no output. + .then(() => { + this.#child.stdin.end(); + }) + .then(() => delay(EXIT_DELAY_MS)) + .finally(() => { + this.#connection.dispose(); + this.#child.kill(); + }) + ); } #ensureInitialized(): Promise { From b278a153d3e84bffd80af415b25d2d37b793de27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Fri, 18 Sep 2026 07:31:10 +0000 Subject: [PATCH 7/9] :memo: Check off the LSP shutdown bug Record the cause (Session.updateWatches raced against exit) and the stdin-EOF fix now that npm run verify is green with no context-canceled output. --- backlog.tasks | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backlog.tasks b/backlog.tasks index 39f64a8..45ad514 100644 --- a/backlog.tasks +++ b/backlog.tasks @@ -48,7 +48,9 @@ Matcher: → `{ a, b, _ }` for `T = "a" | "b"` is accepted today; the redundant `_` should be rejected Bugs: - +✔ TS 7 LSP server logs `context canceled` on stderr at shutdown @done + → `handleExit` returns `io.EOF`, cancelling the background context while `Session.updateWatches` is still in flight; the bare error is flushed to stderr and the server exits 1 + → close stdin after `shutdown` instead of sending `exit`; the server exits cleanly (code 0, no output), kill kept as a fallback Enhancements: ☐ Allow boolean literals in primitive union patterns (e.g. `true: () => "yes"`) @medium ☐ Are there other primitive types that should be supported in union patterns? (e.g. `bigint`, `symbol`) @medium From eea2fbb17d7b31d2535cb9bfcf76b4d2e0bf3e52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Fri, 18 Sep 2026 07:38:13 +0000 Subject: [PATCH 8/9] :rotating_light: Silence helper oxlint warnings The completion helper is test-only (excluded from dist/), so the style-only no-magic-numbers and unicorn/no-null warnings are tolerable here. A file-level disable keeps them out of check:oxlint without touching the project config. --- src/util/__tests__/lsp-completion.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/util/__tests__/lsp-completion.ts b/src/util/__tests__/lsp-completion.ts index 20f0d2e..30cb838 100644 --- a/src/util/__tests__/lsp-completion.ts +++ b/src/util/__tests__/lsp-completion.ts @@ -1,3 +1,4 @@ +// oxlint-disable no-magic-numbers unicorn/no-null - tolerable here, this is a helper import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; From 947eac808901e0b7d05a44d8f6250ee90fb89045 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Fri, 18 Sep 2026 07:43:36 +0000 Subject: [PATCH 9/9] :rewind: Revert the LSP notes in testing.md The protocol cross-link and the TS-internal shutdown-race explanation were too detailed for the category doc. The helper comment keeps a short, self-contained note on the stdin-EOF workaround. --- development/testing.md | 14 +++----------- src/util/__tests__/lsp-completion.ts | 7 +++---- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/development/testing.md b/development/testing.md index 2b449a0..edcd407 100644 --- a/development/testing.md +++ b/development/testing.md @@ -134,10 +134,7 @@ The script prints the labels the server offers at a `/*COMPLETE*/` marker inside is imported by `src/util/__tests__/lsp-completion.test.ts` — which tests the helper itself against inline documents, never the library's code — and by `src/primitive.test.ts`, where the same probe asserts the matcher's popup; -the CLI is for manual inspection. It speaks to the server through -`vscode-languageserver-protocol`'s message connection and typed requests, so the -transport and protocol plumbing are not hand-rolled (see -[tooling.md § `vscode-languageserver-protocol` backs the autocomplete helper](./tooling.md#vscode-languageserver-protocol-backs-the-autocomplete-helper)). +the CLI is for manual inspection. #### Why @@ -165,13 +162,8 @@ transport and protocol plumbing are not hand-rolled (see any order; the file is an integration test (~1.6 s) that needs `node_modules`. `didOpen` is handled in order before the completion request, so no settle delay is needed. -- The TS 7 Go server has a shutdown race: `handleExit` returns `io.EOF`, which - cancels the background context while `Session.updateWatches` is still running - (`internal/project/session.go`), so the bare `context canceled` error is - flushed straight to stderr once the outgoing queue is closed, and the server - exits 1 even though LSP expects 0 after `shutdown`. The helper therefore sends - `shutdown` and then closes stdin instead of sending `exit`; EOF makes the - server exit cleanly (code 0, no output), and the kill is kept as a fallback. +- The server answers some requests with a string id (`client/registerCapability`); + the client must tolerate `string | number` ids or the server stalls. ## Known issues diff --git a/src/util/__tests__/lsp-completion.ts b/src/util/__tests__/lsp-completion.ts index 30cb838..0dab224 100644 --- a/src/util/__tests__/lsp-completion.ts +++ b/src/util/__tests__/lsp-completion.ts @@ -145,10 +145,9 @@ export class LspSession { return ( this.#connection .sendRequest(ShutdownRequest.type) - // The TS 7 Go server races a background task against the `exit` - // notification and logs a bare `context canceled` to stderr as it - // tears down (see development/testing.md § Autocomplete). EOF on - // stdin shuts it down cleanly instead: exit 0, no output. + // The TS 7 Go server logs a bare `context canceled` to stderr + // when it handles `exit`; EOF on stdin shuts it down cleanly + // (exit 0, no output) instead. .then(() => { this.#child.stdin.end(); })