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:
1 parent
c2fd342d33
commit
e80eb062a1
10 files changed
+20
-35
No files matched your search
@@ -18,5 +18,5 @@
|
||||
en: 9977196522f2d7e5
|
||||
zh: f971ba2c60beda68
|
||||
/lsp-navigation/failures:
|
||||
en: 192bd5699c1a23af
|
||||
zh: f221bff65286231a
|
||||
en: 50959870fd65acce
|
||||
zh: 9f9e0c2b04572235
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 提供方会在新进程上把只读查询重试一次。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 会让服务器继续运行直到自行退出——优雅关闭只发生在服务释放时。
|
||||
|
||||
### 安全边界
|
||||
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user