fix(lsp): cancel blocked document opens

This commit is contained in:
Tianyi Cui
2026-07-21 13:52:15 +08:00
parent 2fd995bf3c
commit bc60b9ffe8
7 changed files with 82 additions and 12 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-07-15-lsp-capability-seam.md: 91b15e8f9ff044d2c438c87040f9fc19a8dabc6a 2026-07-15-lsp-capability-seam.md: 7265b04ac9b2f83764bdd13f07b2d3404c4c1708
2026-07-15-lsp-capability-seam.zh.md: 39e63370241e8bbeb93ea7bb81fbd951fe807b19 2026-07-15-lsp-capability-seam.zh.md: 10e8956005045d0934dd9dada5718b85a34cda3f

View File

@@ -119,7 +119,7 @@ The `read` tool is unsuitable source because its output is windowed, numbered, t
The local provider uses a compatibility-first transient-open sequence for every query. It accepts legacy `textDocumentSync` `Full` or `Incremental`, or options with `openClose: true`; omitted, `None`, or explicitly incompatible synchronization fails as unsupported before `didOpen`. The local provider uses a compatibility-first transient-open sequence for every query. It accepts legacy `textDocumentSync` `Full` or `Incremental`, or options with `openClose: true`; omitted, `None`, or explicitly incompatible synchronization fails as unsupported before `didOpen`.
1. Canonicalize and validate the host path, then read the current source with Node filesystem APIs. 1. Canonicalize and validate the host path, then read the current source with Node filesystem APIs.
2. Send `textDocument/didOpen` with version `1`, full text, and the configured language id. 2. Send `textDocument/didOpen` with version `1`, full text, and the configured language id. Its write remains abortable; failure or cancellation invalidates the instance and awaits bounded process termination before the pool can reuse it.
3. Send the requested `textDocument/definition`, `textDocument/references`, `textDocument/implementation`, or `textDocument/hover` request. 3. Send the requested `textDocument/definition`, `textDocument/references`, `textDocument/implementation`, or `textDocument/hover` request.
4. If `didOpen` succeeded, attempt `textDocument/didClose` in `finally` after the request settles or aborts. A close-write failure does not replace the settled result or error, but invalidates the instance and awaits bounded process termination. 4. If `didOpen` succeeded, attempt `textDocument/didClose` in `finally` after the request settles or aborts. A close-write failure does not replace the settled result or error, but invalidates the instance and awaits bounded process termination.
@@ -177,7 +177,7 @@ The local provider trusts its configured server and claims no sandbox confinemen
- Tool tests pin the four operations, coordinate validation, configured bounds and omission markers, prompt, and ACP presentation. - Tool tests pin the four operations, coordinate validation, configured bounds and omission markers, prompt, and ACP presentation.
- Registry tests pin atomic reservation/release, order-independent selection, and structured unavailable, disposed, conflict, and unsupported-operation errors. - Registry tests pin atomic reservation/release, order-independent selection, and structured unavailable, disposed, conflict, and unsupported-operation errors.
- Fake-stdio tests pin exact initialization capabilities, four protocol mappings, `Location`/`LocationLink` and hover normalization, and `findReferences` mapping to `references.includeDeclaration`. - Fake-stdio tests pin exact initialization capabilities, four protocol mappings, `Location`/`LocationLink` and hover normalization, and `findReferences` mapping to `references.includeDeclaration`.
- Synchronization tests pin UTF-16 negotiation and conversion, supported and rejected `textDocumentSync` forms, balanced transient open/close, close-write failure, and malformed-response rejection. - Synchronization tests pin UTF-16 negotiation and conversion, supported and rejected `textDocumentSync` forms, blocked and failed open writes, balanced transient open/close, close-write failure, and malformed-response rejection.
- Timeout tests pin one `TOOL_TIMEOUT` budget, unclassified upstream cancellation, no hidden seam deadline, and bounded awaited teardown. - Timeout tests pin one `TOOL_TIMEOUT` budget, unclassified upstream cancellation, no hidden seam deadline, and bounded awaited teardown.
- Lifecycle tests pin startup single-flight, complete-lifecycle serialization with fresh queued source reads, cross-workspace parallelism, abortable queues, crash replacement without replay, failed-stdin teardown, and quiescent disposal. - Lifecycle tests pin startup single-flight, complete-lifecycle serialization with fresh queued source reads, cross-workspace parallelism, abortable queues, crash replacement without replay, failed-stdin teardown, and quiescent disposal.
- Host-filesystem tests pin session-cwd requirements, relative and absolute source containment through symlinks, document validation, file/non-file URI rendering, unformatted source, and no `fs/observed` event. - Host-filesystem tests pin session-cwd requirements, relative and absolute source containment through symlinks, document validation, file/non-file URI rendering, unformatted source, and no `fs/observed` event.

View File

@@ -119,7 +119,7 @@ ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_p
本地提供方对每次查询都采用兼容优先的临时打开流程。它接受旧式 `textDocumentSync` 的 `Full` 或 `Incremental`,也接受设置了 `openClose: true` 的选项;同步能力缺失、为 `None` 或明确不兼容时,在 `didOpen` 前以不支持错误失败。 本地提供方对每次查询都采用兼容优先的临时打开流程。它接受旧式 `textDocumentSync` 的 `Full` 或 `Incremental`,也接受设置了 `openClose: true` 的选项;同步能力缺失、为 `None` 或明确不兼容时,在 `didOpen` 前以不支持错误失败。
1. 规范化并校验主机路径,再使用 Node 文件系统 API 读取当前源文件。 1. 规范化并校验主机路径,再使用 Node 文件系统 API 读取当前源文件。
2. 发送 `textDocument/didOpen`,其中包含版本 `1`、完整文本和配置的语言 id。 2. 发送 `textDocument/didOpen`,其中包含版本 `1`、完整文本和配置的语言 id。该写入仍可取消;写入失败或遭取消会使实例失效,并等待有界进程终止完成,池才能复用它。
3. 发送所请求的 `textDocument/definition`、`textDocument/references`、`textDocument/implementation` 或 `textDocument/hover` 请求。 3. 发送所请求的 `textDocument/definition`、`textDocument/references`、`textDocument/implementation` 或 `textDocument/hover` 请求。
4. 如果 `didOpen` 成功,则在请求完成或取消后于 `finally` 中尝试发送 `textDocument/didClose`。关闭写入失败不会覆盖已经确定的结果或错误,但会使实例失效,并等待有界进程终止完成。 4. 如果 `didOpen` 成功,则在请求完成或取消后于 `finally` 中尝试发送 `textDocument/didClose`。关闭写入失败不会覆盖已经确定的结果或错误,但会使实例失效,并等待有界进程终止完成。
@@ -177,7 +177,7 @@ ACP 使用 `{ card: 'generic', kind: 'search', title, locations: [{ path: file_p
- 工具测试固定四种操作、坐标校验、配置限制与省略标记、提示词和 ACP 展示。 - 工具测试固定四种操作、坐标校验、配置限制与省略标记、提示词和 ACP 展示。
- 注册表测试固定原子占用/释放、不受顺序影响的选择,以及结构化的不可用、已释放、冲突和不支持操作错误。 - 注册表测试固定原子占用/释放、不受顺序影响的选择,以及结构化的不可用、已释放、冲突和不支持操作错误。
- 测试用 stdio server 固定精确的初始化能力、四种协议映射、`Location`/`LocationLink` 与 `hover` 归一化,以及 `findReferences` 到 `references.includeDeclaration` 的映射。 - 测试用 stdio server 固定精确的初始化能力、四种协议映射、`Location`/`LocationLink` 与 `hover` 归一化,以及 `findReferences` 到 `references.includeDeclaration` 的映射。
- 同步测试固定 UTF-16 协商与转换、受支持和被拒绝的 `textDocumentSync` 形式、配对的临时打开/关闭、关闭写入失败和错误响应拒绝。 - 同步测试固定 UTF-16 协商与转换、受支持和被拒绝的 `textDocumentSync` 形式、打开写入阻塞与失败、配对的临时打开/关闭、关闭写入失败和错误响应拒绝。
- 超时测试固定一个 `TOOL_TIMEOUT` 预算、不对上游取消错误分类、服务边界无隐藏截止时间,以及受限且等待完成的清理。 - 超时测试固定一个 `TOOL_TIMEOUT` 预算、不对上游取消错误分类、服务边界无隐藏截止时间,以及受限且等待完成的清理。
- 生命周期测试固定启动 single-flight、完整生命周期串行化及排队查询读取最新源文件、跨工作区并行、可取消队列、崩溃后不重放的替换、stdin 失败后的进程拆除,以及释放后完全停稳。 - 生命周期测试固定启动 single-flight、完整生命周期串行化及排队查询读取最新源文件、跨工作区并行、可取消队列、崩溃后不重放的替换、stdin 失败后的进程拆除,以及释放后完全停稳。
- 主机文件系统测试固定 session cwd 要求、符号链接下相对与绝对源路径的规范 containment、文档校验、file/non-file URI 渲染、无格式源文本和不发送 `fs/observed`。 - 主机文件系统测试固定 session cwd 要求、符号链接下相对与绝对源路径的规范 containment、文档校验、file/non-file URI 渲染、无格式源文本和不发送 `fs/observed`。

View File

@@ -8,7 +8,7 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export).
- Resolves every server-local setting before registration; an invalid mapping or registration conflict rolls back earlier entries, so a failed load leaves no provider routes. - Resolves every server-local setting before registration; an invalid mapping or registration conflict rolls back earlier entries, so a failed load leaves no provider routes.
- Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A crash fails the active query without replay; a later query may replace the process. - Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A crash fails the active query without replay; a later query may replace the process.
- Uses a compatibility-first **transient-open** sequence per query: canonicalize and read the source with Node APIs, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU. - Uses a compatibility-first **transient-open** sequence per query: canonicalize and read the source with Node APIs, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. A failed or canceled `didOpen` write terminates the instance before the pool can reuse it. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU.
- Serializes each source-read/open/query/close lifecycle through one abortable per-workspace queue so queued calls read current source only when their turn starts; distinct workspaces run in parallel. - Serializes each source-read/open/query/close lifecycle through one abortable per-workspace queue so queued calls read current source only when their turn starts; distinct workspaces run in parallel.
- Reads sources through Node filesystem APIs in the subprocess's host namespace — NOT `ctx.fs`, and emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy. - Reads sources through Node filesystem APIs in the subprocess's host namespace — NOT `ctx.fs`, and emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy.

View File

@@ -138,9 +138,16 @@ export class LspInstance {
try { try {
/* v8 ignore next -- guards an abort landing between the ready wait and didOpen; not deterministically reproducible. */ /* v8 ignore next -- guards an abort landing between the ready wait and didOpen; not deterministically reproducible. */
if (signal?.aborted) throw abortError(signal) if (signal?.aborted) throw abortError(signal)
await this.connection.notify('textDocument/didOpen', { try {
textDocument: { uri, languageId: request.languageId, version: 1, text: source.text }, await abortable(this.connection.notify('textDocument/didOpen', {
}) textDocument: { uri, languageId: request.languageId, version: 1, text: source.text },
}), signal)
} catch (error) {
// A canceled backpressured write or failed stdin leaves the protocol stream unusable before
// `opened` can arm the didClose cleanup. Teardown here makes the pool evict the instance.
await this.startTeardown()
throw error
}
opened = true opened = true
const payload = await this.sendRequest(request.operation, uri, request.position, signal) const payload = await this.sendRequest(request.operation, uri, request.position, signal)
return this.normalize(request.operation, payload) return this.normalize(request.operation, payload)

View File

@@ -14,6 +14,9 @@
* simulating a server that dies while idle so the pool holds a dead instance (eviction test). * simulating a server that dies while idle so the pool holds a dead instance (eviction test).
* - LSP_FAKE_REPLY_DELAY_MS: delays each textDocument/* response by this many milliseconds. * - LSP_FAKE_REPLY_DELAY_MS: delays each textDocument/* response by this many milliseconds.
* - LSP_FAKE_OPEN_MARKER: appends each didOpen document text as one JSON line to this path. * - LSP_FAKE_OPEN_MARKER: appends each didOpen document text as one JSON line to this path.
* - LSP_FAKE_INITIALIZED_MARKER: records when the initialized notification is received.
* - LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED: "1" stops consuming stdin after initialized.
* - LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED: "1" closes fd 0 after the initialized notification.
* - LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: "1" closes fd 0 before sending the first query response. * - LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: "1" closes fd 0 before sending the first query response.
* - LSP_FAKE_EXIT_DELAY_MS / LSP_FAKE_EXIT_MARKER: delay protocol exit and record exit/termination. * - LSP_FAKE_EXIT_DELAY_MS / LSP_FAKE_EXIT_MARKER: delay protocol exit and record exit/termination.
* - LSP_FAKE_NO_SHUTDOWN: "1" ignores the shutdown request (forces kill escalation). * - LSP_FAKE_NO_SHUTDOWN: "1" ignores the shutdown request (forces kill escalation).
@@ -35,6 +38,9 @@ const crashOnOpen = process.env.LSP_FAKE_CRASH_ON_OPEN === '1'
const exitAfterReply = process.env.LSP_FAKE_EXIT_AFTER_REPLY === '1' const exitAfterReply = process.env.LSP_FAKE_EXIT_AFTER_REPLY === '1'
const replyDelayMs = Number(process.env.LSP_FAKE_REPLY_DELAY_MS ?? 0) const replyDelayMs = Number(process.env.LSP_FAKE_REPLY_DELAY_MS ?? 0)
const openMarker = process.env.LSP_FAKE_OPEN_MARKER const openMarker = process.env.LSP_FAKE_OPEN_MARKER
const initializedMarker = process.env.LSP_FAKE_INITIALIZED_MARKER
const pauseStdinAfterInitialized = process.env.LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED === '1'
const closeStdinAfterInitialized = process.env.LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED === '1'
const closeStdinAfterReply = process.env.LSP_FAKE_CLOSE_STDIN_AFTER_REPLY === '1' const closeStdinAfterReply = process.env.LSP_FAKE_CLOSE_STDIN_AFTER_REPLY === '1'
const exitDelayMs = Number(process.env.LSP_FAKE_EXIT_DELAY_MS ?? 0) const exitDelayMs = Number(process.env.LSP_FAKE_EXIT_DELAY_MS ?? 0)
const exitMarker = process.env.LSP_FAKE_EXIT_MARKER const exitMarker = process.env.LSP_FAKE_EXIT_MARKER
@@ -137,7 +143,13 @@ function handle(message: { id?: number; method?: string; params?: unknown; resul
if (onOpen !== undefined) emitServerRequest(onOpen) if (onOpen !== undefined) emitServerRequest(onOpen)
return return
} }
if (method === 'textDocument/didClose' || method === 'initialized') return if (method === 'initialized') {
if (initializedMarker !== undefined) appendFileSync(initializedMarker, 'INITIALIZED\n')
if (pauseStdinAfterInitialized) process.stdin.pause()
if (closeStdinAfterInitialized) closeSync(0)
return
}
if (method === 'textDocument/didClose') return
if (method?.startsWith('textDocument/')) { if (method?.startsWith('textDocument/')) {
if (hang) return if (hang) return
const reply = (): void => { const reply = (): void => {
@@ -190,4 +202,6 @@ function send(message: Record<string, unknown>): void {
// Keep the event loop alive. // Keep the event loop alive.
process.stdin.resume() process.stdin.resume()
if (closeStdinAfterReply) setInterval(() => {}, 1000) if (pauseStdinAfterInitialized || closeStdinAfterInitialized || closeStdinAfterReply) {
setInterval(() => {}, 1000)
}

View File

@@ -178,6 +178,40 @@ describe('LspInstance query and abort', () => {
await instance.dispose() await instance.dispose()
}) })
it('terminates when abort interrupts a backpressured didOpen write', async () => {
// The fixture consumes initialized, then stops reading. A document larger than the stdio pipe
// keeps didOpen's write callback pending until cancellation forces bounded process teardown.
await writeFile(join(ws, 'a.ts'), 'x'.repeat(2_000_000))
const marker = join(root, 'initialized.log')
const instance = makeInstance({
LSP_FAKE_INITIALIZED_MARKER: marker,
LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED: '1',
}, {
shutdownTimeoutMs: 100,
killGraceMs: 100,
})
const controller = new AbortController()
const pending = run(instance, 'goToDefinition', controller.signal)
await waitForFile(marker)
// Let the client enter the large didOpen write after the fixture has paused stdin.
await new Promise<void>(resolve => setTimeout(resolve, 100))
controller.abort(new Error('didOpen-abort'))
await expect(pending).rejects.toThrow(/didOpen-abort/)
expect(instance.dead).toBe(true)
})
it('terminates when stdin fails during the didOpen write', async () => {
// Closing stdin after initialized makes a large didOpen fail before `opened` can arm didClose;
// the instance must still become dead so its provider can replace it.
await writeFile(join(ws, 'a.ts'), 'x'.repeat(2_000_000))
const instance = makeInstance({ LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED: '1' }, {
shutdownTimeoutMs: 100,
killGraceMs: 100,
})
await expect(run(instance, 'goToDefinition')).rejects.toThrow()
expect(instance.dead).toBe(true)
})
it('rejects when the server lacks the operation capability', async () => { it('rejects when the server lacks the operation capability', async () => {
const instance = makeInstance({ LSP_FAKE_CAPS: JSON.stringify({ definitionProvider: false }), LSP_FAKE_DEF: 'null' }) const instance = makeInstance({ LSP_FAKE_CAPS: JSON.stringify({ definitionProvider: false }), LSP_FAKE_DEF: 'null' })
await expect(run(instance, 'goToDefinition')).rejects.toThrow(/does not support goToDefinition/) await expect(run(instance, 'goToDefinition')).rejects.toThrow(/does not support goToDefinition/)
@@ -287,3 +321,18 @@ function processAlive(pid: number): boolean {
throw error throw error
} }
} }
/** Wait until a fixture marker exists, bounded so a broken handshake cannot hang the test. */
async function waitForFile(path: string, timeoutMs = 3000): Promise<void> {
const started = Date.now()
for (;;) {
try {
await readFile(path)
return
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
if (Date.now() - started > timeoutMs) throw new Error('waitForFile timed out')
await new Promise<void>(resolve => setTimeout(resolve, 10))
}
}