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.
This commit is contained in:
sjw1231 committed 2026-11-06 16:19:12 +08:00
1 parent c2fd342d33
commit e80eb062a1
10 files changed
+20 -35

No files matched your search

+2 -2
View File
@@ -18,5 +18,5 @@
en: 9977196522f2d7e5
zh: f971ba2c60beda68
/lsp-navigation/failures:
en: 192bd5699c1a23af
zh: f221bff65286231a
en: 50959870fd65acce
zh: 9f9e0c2b04572235
+2 -3
View File
@@ -167,7 +167,7 @@ interface LspService {
<a id="failures"></a>
## 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.
+2 -3
View File
@@ -167,7 +167,7 @@ interface LspService {
<a id="failures"></a>
## 失败
`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 提供方会在新进程上把只读查询重试一次。
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 会让服务器继续运行直到自行退出——优雅关闭只发生在服务释放时。
### 安全边界
+2 -2
View File
@@ -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:*",
+2 -1
View File
@@ -119,7 +119,8 @@ function throwTeardownFailures(results: readonly PromiseSettledResult<void>[], 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) {
+6 -9
View File
@@ -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<unknown> = Promise.resolve()
private disposed = false
@@ -53,8 +52,8 @@ export class LspInstance {
private teardownPromise: Promise<void> | 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<void>
/** The server's negotiated capabilities once `initialize` succeeds; a failed handshake rejects every query. */
private readonly ready: Promise<WireServerCapabilities>
/**
* @param spec - the launch, initialize, and teardown parameters.
@@ -109,7 +108,7 @@ export class LspInstance {
return run
}
private async initialize(): Promise<void> {
private async initialize(): Promise<WireServerCapabilities> {
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<LspQueryResult> {
@@ -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')
}
@@ -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.