From e80eb062a14c838aeaee98e34537f8e5e9bf6879 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Fri, 6 Nov 2026 16:19:12 +0800 Subject: [PATCH] fix(lsp): derive query capabilities from the handshake result The instance's ready promise now resolves to the negotiated server capabilities, so a query can no longer observe an instance without them; LSP_NOT_INITIALIZED and its private-state test are removed. The LSP failures page qualifies the teardown combination (uncoded query failures surface as AggregateError without error.info) and the stderr tail of LSP_SERVER_EXITED, and LspQueryTeardownError documents that it does not keep the query failure's class. --- docs/subsystems/lsp.i18n.yaml | 4 ++-- docs/subsystems/lsp.md | 5 ++--- docs/subsystems/lsp.zh.md | 5 ++--- packages/lsp/lsp-stdio/README.i18n.yaml | 4 ++-- packages/lsp/lsp-stdio/README.md | 2 +- packages/lsp/lsp-stdio/README.zh.md | 2 +- packages/lsp/lsp-stdio/package.json | 4 ++-- packages/lsp/lsp-stdio/src/index.ts | 3 ++- packages/lsp/lsp-stdio/src/instance.ts | 15 ++++++--------- packages/lsp/lsp-stdio/tests/instance.spec.ts | 11 ----------- 10 files changed, 20 insertions(+), 35 deletions(-) diff --git a/docs/subsystems/lsp.i18n.yaml b/docs/subsystems/lsp.i18n.yaml index 6d21e0fc70..a89df73725 100644 --- a/docs/subsystems/lsp.i18n.yaml +++ b/docs/subsystems/lsp.i18n.yaml @@ -18,5 +18,5 @@ en: 9977196522f2d7e5 zh: f971ba2c60beda68 /lsp-navigation/failures: - en: 192bd5699c1a23af - zh: f221bff65286231a + en: 50959870fd65acce + zh: 9f9e0c2b04572235 diff --git a/docs/subsystems/lsp.md b/docs/subsystems/lsp.md index 275ba3b236..647fd2920b 100644 --- a/docs/subsystems/lsp.md +++ b/docs/subsystems/lsp.md @@ -167,7 +167,7 @@ interface LspService { ## Failures -Every expected failure of an `lsp` tool call is a `HarnessError` whose `name` and `code` the tool result keeps in `error.info`. `LspError` sets `name` to `LspError`; its `code` stays open because providers and consumers add codes, so consumers tolerate codes they do not know. Invalid tool arguments fail as `ToolArgsError` `INVALID_ARGS`, and caller cancellation as `AbortError`. Workspace and source reads keep the filesystem provider's `FsError` code, such as `FS_NOT_FOUND`, `FS_NOT_REGULAR_FILE`, or `FS_NOT_TEXT`, and prefix the LSP operand to its message; the stdio provider adds `FS_NOT_DIRECTORY` for a non-directory workspace and `FS_TOO_LARGE` for a source over `maxDocumentBytes`. A query failure whose server teardown also fails keeps the query failure's `name` and `code` and appends the teardown failure to its message. +Every expected failure of an `lsp` tool call is a `HarnessError` whose `name` and `code` the tool result keeps in `error.info`. `LspError` sets `name` to `LspError`; its `code` stays open because providers and consumers add codes, so consumers tolerate codes they do not know. Invalid tool arguments fail as `ToolArgsError` `INVALID_ARGS`, and caller cancellation as `AbortError`. Workspace and source reads keep the filesystem provider's `FsError` code, such as `FS_NOT_FOUND`, `FS_NOT_REGULAR_FILE`, or `FS_NOT_TEXT`, and prefix the LSP operand to its message; the stdio provider adds `FS_NOT_DIRECTORY` for a non-directory workspace and `FS_TOO_LARGE` for a source over `maxDocumentBytes`. A coded query failure whose server teardown also fails keeps the query failure's `name` and `code` and appends the teardown failure to its message; a query failure without a code, such as a cancellation, surfaces with the teardown failure as an `AggregateError` without `error.info`. | Code | Raised by | Meaning | Consumer action | |---|---|---|---| @@ -181,11 +181,10 @@ Every expected failure of an `lsp` tool call is a `HarnessError` whose `name` an | `LSP_FILE_OUTSIDE_WORKSPACE` | Stdio provider | The source resolves outside the canonical workspace | Query a file inside the workspace | | `LSP_UNSUPPORTED_ENCODING` | Stdio provider | The server negotiated a position encoding other than UTF-16 | Use a server that supports UTF-16 | | `LSP_SPAWN_FAILED` | Stdio provider | The subprocess provider failed to launch or run the server; a provider `HarnessError` keeps its own `name` and `code` instead | Fix the server command or installation | -| `LSP_SERVER_EXITED` | Stdio provider | The server exited or closed its stdin; the message carries the retained stderr tail | Retry later; inspect the stderr tail when it repeats | +| `LSP_SERVER_EXITED` | Stdio provider | The server exited or closed its stdin; after an exit, the message carries the retained stderr tail | Retry later; inspect the stderr tail when it repeats | | `LSP_PROTOCOL_ERROR` | Stdio provider | The server's output broke base-protocol framing or JSON | Report the server defect | | `LSP_MESSAGE_TOO_LARGE` | Stdio provider | A server message exceeded `maxMessageBytes` | Narrow the query or raise `maxMessageBytes` | | `LSP_SERVER_ERROR` | Stdio provider | The server answered with a JSON-RPC error; the message carries its numeric code and text | Act on the server's message | -| `LSP_NOT_INITIALIZED` | Stdio provider | A query found the server instance without negotiated capabilities after its handshake succeeded; this is inconsistent internal state, not a server condition | Report the defect | After a transport failure (`LSP_SPAWN_FAILED`, `LSP_SERVER_EXITED`, `LSP_PROTOCOL_ERROR`, or `LSP_MESSAGE_TOO_LARGE`), the stdio provider retries the read-only query once on a fresh process when the failed process tears down cleanly. diff --git a/docs/subsystems/lsp.zh.md b/docs/subsystems/lsp.zh.md index 65612a8eac..7a6ef8d3c6 100644 --- a/docs/subsystems/lsp.zh.md +++ b/docs/subsystems/lsp.zh.md @@ -167,7 +167,7 @@ interface LspService { ## 失败 -`lsp` 工具调用的每个预期失败都是 `HarnessError`,工具结果会把它的 `name` 和 `code` 保留在 `error.info` 中。`LspError` 把 `name` 设为 `LspError`;由于提供方和消费方会添加错误码,它的 `code` 保持开放,消费方须容忍未知错误码。无效的工具参数以 `ToolArgsError` `INVALID_ARGS` 失败,调用方取消以 `AbortError` 失败。工作区与源文件读取保留文件系统提供方的 `FsError` 错误码,例如 `FS_NOT_FOUND`、`FS_NOT_REGULAR_FILE` 或 `FS_NOT_TEXT`,并在其消息前加上 LSP 操作对象;stdio 提供方对非目录工作区使用 `FS_NOT_DIRECTORY`,对超过 `maxDocumentBytes` 的源文件使用 `FS_TOO_LARGE`。如果查询失败后服务器拆除也失败,错误保留查询失败的 `name` 和 `code`,并把拆除失败追加到消息中。 +`lsp` 工具调用的每个预期失败都是 `HarnessError`,工具结果会把它的 `name` 和 `code` 保留在 `error.info` 中。`LspError` 把 `name` 设为 `LspError`;由于提供方和消费方会添加错误码,它的 `code` 保持开放,消费方须容忍未知错误码。无效的工具参数以 `ToolArgsError` `INVALID_ARGS` 失败,调用方取消以 `AbortError` 失败。工作区与源文件读取保留文件系统提供方的 `FsError` 错误码,例如 `FS_NOT_FOUND`、`FS_NOT_REGULAR_FILE` 或 `FS_NOT_TEXT`,并在其消息前加上 LSP 操作对象;stdio 提供方对非目录工作区使用 `FS_NOT_DIRECTORY`,对超过 `maxDocumentBytes` 的源文件使用 `FS_TOO_LARGE`。如果带错误码的查询失败后服务器拆除也失败,错误保留查询失败的 `name` 和 `code`,并把拆除失败追加到消息中;不带错误码的查询失败(例如取消)与拆除失败一起以 `AggregateError` 呈现,不带 `error.info`。 | 错误码 | 抛出方 | 含义 | 消费方操作 | |---|---|---|---| @@ -181,11 +181,10 @@ interface LspService { | `LSP_FILE_OUTSIDE_WORKSPACE` | stdio 提供方 | 源文件解析到规范工作区之外 | 查询工作区内的文件 | | `LSP_UNSUPPORTED_ENCODING` | stdio 提供方 | 服务器协商了 UTF-16 以外的位置编码 | 使用支持 UTF-16 的服务器 | | `LSP_SPAWN_FAILED` | stdio 提供方 | 子进程提供方未能启动或运行服务器;提供方的 `HarnessError` 则保留其自身的 `name` 和 `code` | 修正服务器命令或安装 | -| `LSP_SERVER_EXITED` | stdio 提供方 | 服务器已退出或关闭了 stdin;消息携带保留的 stderr 尾部 | 稍后重试;反复出现时检查 stderr 尾部 | +| `LSP_SERVER_EXITED` | stdio 提供方 | 服务器已退出或关闭了 stdin;进程退出时消息携带保留的 stderr 尾部 | 稍后重试;反复出现时检查 stderr 尾部 | | `LSP_PROTOCOL_ERROR` | stdio 提供方 | 服务器输出破坏了基础协议分帧或 JSON | 报告服务器缺陷 | | `LSP_MESSAGE_TOO_LARGE` | stdio 提供方 | 某条服务器消息超过 `maxMessageBytes` | 缩小查询范围或调大 `maxMessageBytes` | | `LSP_SERVER_ERROR` | stdio 提供方 | 服务器以 JSON-RPC 错误应答;消息携带其数字错误码与文本 | 按服务器消息处理 | -| `LSP_NOT_INITIALIZED` | stdio 提供方 | 握手已成功,但查询时服务器实例没有已协商的能力;这是内部状态不一致,不是服务器的情况 | 报告该缺陷 | 传输失败(`LSP_SPAWN_FAILED`、`LSP_SERVER_EXITED`、`LSP_PROTOCOL_ERROR` 或 `LSP_MESSAGE_TOO_LARGE`)后,如果失败的进程干净地完成拆除,stdio 提供方会在新进程上把只读查询重试一次。 diff --git a/packages/lsp/lsp-stdio/README.i18n.yaml b/packages/lsp/lsp-stdio/README.i18n.yaml index 369a475ea4..f4f7a2db0e 100644 --- a/packages/lsp/lsp-stdio/README.i18n.yaml +++ b/packages/lsp/lsp-stdio/README.i18n.yaml @@ -24,8 +24,8 @@ en: 41363baf8fdba020 zh: d1a6193b1aa813ba /deepseek-ai-dsh-lsp-stdio/use-this-package/observable-success-and-failures: - en: 27766a0ecd0f70fa - zh: 532bc93a8ce5e701 + en: 9b947af73dbe2305 + zh: e40b163f476deaf9 /deepseek-ai-dsh-lsp-stdio/use-this-package/security-boundary: en: f1f7fef771bcaa6d zh: f11dcf771a6fecfc diff --git a/packages/lsp/lsp-stdio/README.md b/packages/lsp/lsp-stdio/README.md index 8f30301f48..418e44520b 100644 --- a/packages/lsp/lsp-stdio/README.md +++ b/packages/lsp/lsp-stdio/README.md @@ -68,7 +68,7 @@ On the first query for a workspace, the provider launches one server process for ### Observable success and failures -A successful navigation returns normalized locations, and hover returns normalized text or a no-hover notice; empty results are successful no-result responses. Before the server starts, a missing, non-directory, non-regular, non-UTF-8, or oversized workspace or source fails with its `FsError` code, and a source outside the canonical workspace fails with `LSP_FILE_OUTSIDE_WORKSPACE`. The server then fails the query with `LSP_UNSUPPORTED_OPERATION` when it lacks the operation or the transient open/close synchronization, `LSP_UNSUPPORTED_ENCODING` for a non-UTF-16 position encoding, `LSP_MALFORMED_RESPONSE` for a malformed payload, and `LSP_SERVER_ERROR` for a JSON-RPC error response; a failed process or stream reports `LSP_SPAWN_FAILED`, `LSP_SERVER_EXITED`, `LSP_PROTOCOL_ERROR`, or `LSP_MESSAGE_TOO_LARGE`; a query that finds the instance without negotiated capabilities after a successful handshake reports `LSP_NOT_INITIALIZED`, which marks a provider defect. The [LSP navigation subsystem](../../../docs/subsystems/lsp.md#failures) gives each code's meaning and consumer action. A hard-killed harness leaves servers running until they exit on their own — graceful shutdown happens only through service disposal. +A successful navigation returns normalized locations, and hover returns normalized text or a no-hover notice; empty results are successful no-result responses. Before the server starts, a missing, non-directory, non-regular, non-UTF-8, or oversized workspace or source fails with its `FsError` code, and a source outside the canonical workspace fails with `LSP_FILE_OUTSIDE_WORKSPACE`. The server then fails the query with `LSP_UNSUPPORTED_OPERATION` when it lacks the operation or the transient open/close synchronization, `LSP_UNSUPPORTED_ENCODING` for a non-UTF-16 position encoding, `LSP_MALFORMED_RESPONSE` for a malformed payload, and `LSP_SERVER_ERROR` for a JSON-RPC error response; a failed process or stream reports `LSP_SPAWN_FAILED`, `LSP_SERVER_EXITED`, `LSP_PROTOCOL_ERROR`, or `LSP_MESSAGE_TOO_LARGE`. The [LSP navigation subsystem](../../../docs/subsystems/lsp.md#failures) gives each code's meaning and consumer action. A hard-killed harness leaves servers running until they exit on their own — graceful shutdown happens only through service disposal. ### Security boundary diff --git a/packages/lsp/lsp-stdio/README.zh.md b/packages/lsp/lsp-stdio/README.zh.md index f54eda31c1..e16e912cef 100644 --- a/packages/lsp/lsp-stdio/README.zh.md +++ b/packages/lsp/lsp-stdio/README.zh.md @@ -68,7 +68,7 @@ kind: "package-reference" ### 可观察的成功与失败 -成功的导航返回规范化位置,悬停返回规范化文本或无可悬停提示;空结果是成功的无结果响应。在服务器启动前,缺失、非目录、非普通文件、非 UTF-8 或过大的工作区或源文件以其 `FsError` 错误码失败,位于规范工作区之外的源文件以 `LSP_FILE_OUTSIDE_WORKSPACE` 失败。随后,服务器不支持该操作或临时打开/关闭同步时查询以 `LSP_UNSUPPORTED_OPERATION` 失败,非 UTF-16 位置编码以 `LSP_UNSUPPORTED_ENCODING` 失败,格式错误的载荷以 `LSP_MALFORMED_RESPONSE` 失败,JSON-RPC 错误响应以 `LSP_SERVER_ERROR` 失败;进程或流失败时报告 `LSP_SPAWN_FAILED`、`LSP_SERVER_EXITED`、`LSP_PROTOCOL_ERROR` 或 `LSP_MESSAGE_TOO_LARGE`;握手成功后查询却发现实例没有已协商的能力时报告 `LSP_NOT_INITIALIZED`,表示提供方存在缺陷。[LSP 导航子系统](../../../docs/subsystems/lsp.zh.md#failures)给出每个错误码的含义与消费方操作。被强制杀死的 harness 会让服务器继续运行直到自行退出——优雅关闭只发生在服务释放时。 +成功的导航返回规范化位置,悬停返回规范化文本或无可悬停提示;空结果是成功的无结果响应。在服务器启动前,缺失、非目录、非普通文件、非 UTF-8 或过大的工作区或源文件以其 `FsError` 错误码失败,位于规范工作区之外的源文件以 `LSP_FILE_OUTSIDE_WORKSPACE` 失败。随后,服务器不支持该操作或临时打开/关闭同步时查询以 `LSP_UNSUPPORTED_OPERATION` 失败,非 UTF-16 位置编码以 `LSP_UNSUPPORTED_ENCODING` 失败,格式错误的载荷以 `LSP_MALFORMED_RESPONSE` 失败,JSON-RPC 错误响应以 `LSP_SERVER_ERROR` 失败;进程或流失败时报告 `LSP_SPAWN_FAILED`、`LSP_SERVER_EXITED`、`LSP_PROTOCOL_ERROR` 或 `LSP_MESSAGE_TOO_LARGE`。[LSP 导航子系统](../../../docs/subsystems/lsp.zh.md#failures)给出每个错误码的含义与消费方操作。被强制杀死的 harness 会让服务器继续运行直到自行退出——优雅关闭只发生在服务释放时。 ### 安全边界 diff --git a/packages/lsp/lsp-stdio/package.json b/packages/lsp/lsp-stdio/package.json index 494db76732..4afa5b8d0d 100644 --- a/packages/lsp/lsp-stdio/package.json +++ b/packages/lsp/lsp-stdio/package.json @@ -32,9 +32,9 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/dsh-fs": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-lsp": "workspace:*", @@ -46,9 +46,9 @@ "@deepseek-ai/schemastery": "workspace:~" }, "devDependencies": { - "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/dsh-fs": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", diff --git a/packages/lsp/lsp-stdio/src/index.ts b/packages/lsp/lsp-stdio/src/index.ts index eee4962849..7c42effa43 100644 --- a/packages/lsp/lsp-stdio/src/index.ts +++ b/packages/lsp/lsp-stdio/src/index.ts @@ -119,7 +119,8 @@ function throwTeardownFailures(results: readonly PromiseSettledResult[], m /** * A coded query failure whose instance teardown also failed. It keeps the query failure's `name` - * and `code` so tool results route on the query outcome; `cause` aggregates both failures. + * and `code` so tool results route on the query outcome; `cause` aggregates both failures. It does + * not keep the query failure's class, so route on `name` and `code`, not `instanceof`. */ class LspQueryTeardownError extends HarnessError { constructor(query: HarnessError, teardown: unknown, cause: AggregateError) { diff --git a/packages/lsp/lsp-stdio/src/instance.ts b/packages/lsp/lsp-stdio/src/instance.ts index f72a892fd5..30c5225468 100644 --- a/packages/lsp/lsp-stdio/src/instance.ts +++ b/packages/lsp/lsp-stdio/src/instance.ts @@ -45,7 +45,6 @@ export interface InstanceSpec extends ConnectionSpec { */ export class LspInstance { private readonly connection: LspConnection - private capabilities: WireServerCapabilities | undefined /** The serialization tail: each query awaits the prior one, so lifecycles never interleave. */ private queue: Promise = Promise.resolve() private disposed = false @@ -53,8 +52,8 @@ export class LspInstance { private teardownPromise: Promise | undefined /** Set once the process closes, so the pool can synchronously skip a dead instance. */ private processClosed = false - /** Populated once `initialize` succeeds; a failed handshake rejects every query. */ - private readonly ready: Promise + /** The server's negotiated capabilities once `initialize` succeeds; a failed handshake rejects every query. */ + private readonly ready: Promise /** * @param spec - the launch, initialize, and teardown parameters. @@ -109,7 +108,7 @@ export class LspInstance { return run } - private async initialize(): Promise { + private async initialize(): Promise { const initializeResult = await this.connection.request('initialize', { // A subprocess provider may run in another PID namespace or machine; // the host PID would let the server monitor an unrelated process. @@ -122,8 +121,8 @@ export class LspInstance { const capabilities = initializeCapabilities(initializeResult) // An omitted encoding defaults to utf-16; any other value is a protocol error we reject here. negotiatePositionEncoding(capabilities.positionEncoding) - this.capabilities = capabilities await this.connection.notify('initialized', {}) + return capabilities } private async runQuery(request: LspProviderQuery, source: HostSource, signal?: AbortSignal): Promise { @@ -134,17 +133,15 @@ export class LspInstance { // in failure — an abort on a still-pending handshake, OR `initialize` rejecting (utf-8 // negotiation, malformed result) without the process exiting — tear the instance down so a // permanently-rejecting/pending `ready` can't make every later query for this workspace fail. + let capabilities: WireServerCapabilities try { - await abortable(this.ready, signal) + capabilities = await abortable(this.ready, signal) } catch (error) { if (!this.dead) { await this.awaitTeardownAttempt() } throw error } - const capabilities = this.capabilities - // `ready` resolves only after capabilities are set; reaching this means instance state is inconsistent. - if (capabilities === undefined) throw new LspError('LSP instance is not initialized', 'LSP_NOT_INITIALIZED') if (!supportsOperation(capabilities, request.operation)) { throw new LspError(`server does not support ${request.operation}`, 'LSP_UNSUPPORTED_OPERATION') } diff --git a/packages/lsp/lsp-stdio/tests/instance.spec.ts b/packages/lsp/lsp-stdio/tests/instance.spec.ts index 10e0a783b1..e34366042d 100644 --- a/packages/lsp/lsp-stdio/tests/instance.spec.ts +++ b/packages/lsp/lsp-stdio/tests/instance.spec.ts @@ -281,17 +281,6 @@ describe('LspInstance query and abort', () => { }) }) - it('reports inconsistent instance state as LSP_NOT_INITIALIZED', async () => { - const instance = makeInstance({ LSP_FAKE_DEF: 'null' }) - await expect(run(instance, 'goToDefinition')).resolves.toMatchObject({ kind: 'locations' }) - Reflect.set(instance, 'capabilities', undefined) - await expect(run(instance, 'goToDefinition')).rejects.toMatchObject({ - name: 'LspError', - code: 'LSP_NOT_INITIALIZED', - message: 'LSP instance is not initialized', - }) - }) - it('propagates a server error response even when a signal is supplied (not an abort)', async () => { // A live signal is passed, but the request fails for a server reason; the catch must rethrow // without treating it as an abort.