fix(lsp): harden local provider lifecycle

This commit is contained in:
Dudu-0223
2026-07-16 16:42:32 +08:00
parent 3368f7924f
commit 0d8e7e98f7
21 changed files with 192 additions and 52 deletions

View File

@@ -54,7 +54,7 @@ interface LspProviderQuery extends LspQueryRequest {
## Result ## Result
A CLOSED discriminated union: navigation operations normalize to `locations`, `hover` to content or `null`. Consumers `switch` on `kind` to exhaustiveness so a new arm breaks compilation until handled. `references` always includes declarations — the provider enforces this internally, so callers get no flag. A CLOSED discriminated union: navigation operations normalize to `locations`, `hover` to content or `null`. Consumers `switch` on `kind` to exhaustiveness so a new arm breaks compilation until handled. `references` always includes declarations — the provider enforces this internally, so callers get no flag. The `locations` variant carries `resolvedWorkspaceRoot`: the provider's canonical form of the request's `workspaceRoot` and the root its `file:` URIs are relative to, so a caller relativizing display paths uses it rather than the possibly-symlinked request root.
```ts type-equiv ```ts type-equiv
interface LspLocation { interface LspLocation {
@@ -76,7 +76,7 @@ interface LspHover {
```ts type-equiv ```ts type-equiv
type LspQueryResult = type LspQueryResult =
| { readonly kind: 'locations'; readonly locations: readonly LspLocation[] } | { readonly kind: 'locations'; readonly locations: readonly LspLocation[]; readonly resolvedWorkspaceRoot: string }
| { readonly kind: 'hover'; readonly hover: LspHover | null } | { readonly kind: 'hover'; readonly hover: LspHover | null }
``` ```

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: 77b544efbcef1f352806d0431f1ff705d2b7bd44 2026-07-15-lsp-capability-seam.md: c21d2f3926b5b465f4ccf48f1bf954c2ae604e12
2026-07-15-lsp-capability-seam.zh.md: dad8160f0bb3a08b2b6546429c3826689a6307f8 2026-07-15-lsp-capability-seam.zh.md: 9556ead6c2e0d6d49fffe54928d53f9418cf10b7

View File

@@ -62,7 +62,7 @@ interface LspProviderQuery extends LspQueryRequest {
} }
type LspQueryResult = type LspQueryResult =
| { readonly kind: 'locations'; readonly locations: readonly { readonly uri: string; readonly range: LspRange }[] } | { readonly kind: 'locations'; readonly locations: readonly { readonly uri: string; readonly range: LspRange }[]; readonly resolvedWorkspaceRoot: string }
| { readonly kind: 'hover'; readonly hover: { readonly contents: string; readonly range?: LspRange } | null } | { readonly kind: 'hover'; readonly hover: { readonly contents: string; readonly range?: LspRange } | null }
interface LspProvider { interface LspProvider {
@@ -77,7 +77,7 @@ interface LspService {
} }
``` ```
Mapping keys normalize to lowercase, leading-dot extensions selected from `filePath`'s final extension; language ids only synchronize documents. Seam positions and ranges are zero-based UTF-16. `references` always includes declarations: providers enforce this internally, the local mapping sets `context.includeDeclaration: true`, and callers get no flag. Closed result unions normalize navigation to locations and hover to content or `null`. The seam exposes no protocol types, process or document controls, or generic request escape hatch. Mapping keys normalize to lowercase, leading-dot extensions selected from `filePath`'s final extension; language ids only synchronize documents. Seam positions and ranges are zero-based UTF-16. `references` always includes declarations: providers enforce this internally, the local mapping sets `context.includeDeclaration: true`, and callers get no flag. Closed result unions normalize navigation to locations and hover to content or `null`; navigation results carry the provider's resolved workspace root so consumers relativize file URIs in the same canonical namespace. The seam exposes no protocol types, process or document controls, or generic request escape hatch.
`dsh-lsp-local` owns host files, server configuration, JSON-RPC, process and transient-document state, and protocol translation; it depends on `dsh-lsp` and Node APIs, not `dsh-fs`. `dsh-tool-lsp` runtime-injects only `tools`, `lsp`, and `systemPrompt`, obtains the workspace from `exec.agent?.session.header.cwd` through a package-local `sessionCwd(exec)` helper matching the filesystem tools' lookup, and imports no provider. `dsh-lsp-local` owns host files, server configuration, JSON-RPC, process and transient-document state, and protocol translation; it depends on `dsh-lsp` and Node APIs, not `dsh-fs`. `dsh-tool-lsp` runtime-injects only `tools`, `lsp`, and `systemPrompt`, obtains the workspace from `exec.agent?.session.header.cwd` through a package-local `sessionCwd(exec)` helper matching the filesystem tools' lookup, and imports no provider.

View File

@@ -62,7 +62,7 @@ interface LspProviderQuery extends LspQueryRequest {
} }
type LspQueryResult = type LspQueryResult =
| { readonly kind: 'locations'; readonly locations: readonly { readonly uri: string; readonly range: LspRange }[] } | { readonly kind: 'locations'; readonly locations: readonly { readonly uri: string; readonly range: LspRange }[]; readonly resolvedWorkspaceRoot: string }
| { readonly kind: 'hover'; readonly hover: { readonly contents: string; readonly range?: LspRange } | null } | { readonly kind: 'hover'; readonly hover: { readonly contents: string; readonly range?: LspRange } | null }
interface LspProvider { interface LspProvider {
@@ -77,7 +77,7 @@ interface LspService {
} }
``` ```
映射键规范化为带前导点的小写扩展名,并按 `filePath` 的最后一个扩展名选择;语言 id 仅用于文档同步。服务边界中的位置和范围从零开始按 UTF-16 计数。`references` 始终包含声明:提供方在内部执行该约束,本地映射设置 `context.includeDeclaration: true`,调用方不能配置。封闭结果联合将导航统一为位置,将 `hover` 统一为内容或 `null`。服务边界不公开协议类型、进程或文档控制,也不提供通用请求逃生口。 映射键规范化为带前导点的小写扩展名,并按 `filePath` 的最后一个扩展名选择;语言 id 仅用于文档同步。服务边界中的位置和范围从零开始按 UTF-16 计数。`references` 始终包含声明:提供方在内部执行该约束,本地映射设置 `context.includeDeclaration: true`,调用方不能配置。封闭结果联合将导航统一为位置,将 `hover` 统一为内容或 `null`;导航结果携带提供方解析后的工作区根目录,使消费方依据同一规范化根目录相对化文件 URI。服务边界不公开协议类型、进程或文档控制,也不提供通用请求逃生口。
`dsh-lsp-local` 负责主机文件、服务器配置、JSON-RPC、进程与临时文档状态和协议转换;它依赖 `dsh-lsp` 与 Node API,不依赖 `dsh-fs`。`dsh-tool-lsp` 在运行时只注入 `tools`、`lsp` 和 `systemPrompt`,通过包内的 `sessionCwd(exec)` 辅助函数从 `exec.agent?.session.header.cwd` 取得工作区,其取值方式与文件系统工具一致,也不导入提供方。 `dsh-lsp-local` 负责主机文件、服务器配置、JSON-RPC、进程与临时文档状态和协议转换;它依赖 `dsh-lsp` 与 Node API,不依赖 `dsh-fs`。`dsh-tool-lsp` 在运行时只注入 `tools`、`lsp` 和 `systemPrompt`,通过包内的 `sessionCwd(exec)` 辅助函数从 `exec.agent?.session.header.cwd` 取得工作区,其取值方式与文件系统工具一致,也不导入提供方。

View File

@@ -41,7 +41,7 @@ export class LspConnection {
private readonly decoder: MessageDecoder private readonly decoder: MessageDecoder
private readonly pending = new Map<number, Pending>() private readonly pending = new Map<number, Pending>()
private nextId = 1 private nextId = 1
private stderr = '' private stderr = Buffer.alloc(0)
private closeReason: Error | undefined private closeReason: Error | undefined
/** Set once the process has fully exited; the instance awaits it during teardown. */ /** Set once the process has fully exited; the instance awaits it during teardown. */
readonly closed: Promise<void> readonly closed: Promise<void>
@@ -90,7 +90,7 @@ export class LspConnection {
/** The retained stderr tail, for diagnostics on a failed server. */ /** The retained stderr tail, for diagnostics on a failed server. */
get stderrTail(): string { get stderrTail(): string {
return this.stderr return this.stderr.toString('utf8')
} }
/** /**
@@ -199,7 +199,17 @@ export class LspConnection {
private onStderr(chunk: Buffer): void { private onStderr(chunk: Buffer): void {
// Retain the TAIL, not the prefix: a language server's fatal diagnostic usually appears just // Retain the TAIL, not the prefix: a language server's fatal diagnostic usually appears just
// before it exits, so the final bounded segment is the useful one. // before it exits, so the final bounded segment is the useful one.
this.stderr = (this.stderr + chunk.toString('utf8')).slice(-this.spec.maxStderrBytes) const cap = this.spec.maxStderrBytes
if (chunk.length >= cap) {
// Copy the bounded suffix so retaining it does not pin an arbitrarily large incoming buffer.
this.stderr = Buffer.from(chunk.subarray(chunk.length - cap))
return
}
const retainedBytes = Math.min(this.stderr.length, cap - chunk.length)
this.stderr = Buffer.concat([
this.stderr.subarray(this.stderr.length - retainedBytes),
chunk,
], retainedBytes + chunk.length)
} }
private dispatch(message: unknown): void { private dispatch(message: unknown): void {
@@ -246,7 +256,7 @@ export class LspConnection {
/** The exit-close error message, appending the retained stderr tail when the server wrote any. */ /** The exit-close error message, appending the retained stderr tail when the server wrote any. */
private exitMessage(): string { private exitMessage(): string {
const tail = this.stderr.trim() const tail = this.stderrTail.trim()
return tail === '' ? 'language server exited' : `language server exited; stderr: ${tail}` return tail === '' ? 'language server exited' : `language server exited; stderr: ${tail}`
} }

View File

@@ -64,6 +64,9 @@ export class MessageDecoder {
} }
return { ready: false } return { ready: false }
} }
if (separator > MAX_HEADER_BYTES) {
throw new Error(`LSP header exceeded ${MAX_HEADER_BYTES} bytes`)
}
const headerText = this.buffer.toString('ascii', 0, separator) const headerText = this.buffer.toString('ascii', 0, separator)
const contentLength = parseContentLength(headerText) const contentLength = parseContentLength(headerText)
if (contentLength > this.maxMessageBytes) { if (contentLength > this.maxMessageBytes) {

View File

@@ -11,7 +11,7 @@
* @module @deepseek-ai/dsh-lsp-local * @module @deepseek-ai/dsh-lsp-local
*/ */
import { accessSync, constants } from 'node:fs' import { accessSync, constants, statSync } from 'node:fs'
import { delimiter, isAbsolute, join } from 'node:path' import { delimiter, isAbsolute, join } from 'node:path'
import type { Context } from 'cordis' import type { Context } from 'cordis'
import z from 'schemastery' import z from 'schemastery'
@@ -178,19 +178,23 @@ class LocalLspProvider implements LspProvider {
// were canonicalizing/reading, so creating a server now would leave it unowned by teardown. // were canonicalizing/reading, so creating a server now would leave it unowned by teardown.
/* v8 ignore next -- guards a dispose landing during the canonicalize/read await; not a reproducible unit race. */ /* v8 ignore next -- guards a dispose landing during the canonicalize/read await; not a reproducible unit race. */
if (this.isDisposed()) throw new Error('lsp-local provider is disposed') if (this.isDisposed()) throw new Error('lsp-local provider is disposed')
const instance = await this.instanceFor(workspace) // Re-check cancellation too: an abort during the canonicalize/read awaits must not go on to spawn
// (or pool) a server solely for an operation the caller already gave up on.
if (signal?.aborted) throw abortError(signal)
let instance = await this.instanceFor(workspace)
// A pooled server that exited while idle resolves to a dead instance: evict it and create a fresh
// one before dispatch, so this query does not have to fail on a closed connection first. One retry
// suffices — the replacement was just constructed and has not been used.
if (instance.dead) {
await this.evictIfCurrent(workspace, instance)
instance = await this.instanceFor(workspace)
}
try { try {
return await instance.query(request, source, signal) return await instance.query(request, source, signal)
} finally { } finally {
// A crashed/closed process must not be reused: drop its slot so the next query starts fresh, // A crashed/closed process must not be reused: drop its slot so the next query starts fresh,
// but only if the slot still holds THIS instance (a concurrent replacement must survive). // but only if the slot still holds THIS instance (a concurrent replacement must survive).
if (instance.dead) { if (instance.dead) await this.evictIfCurrent(workspace, instance)
const slot = this.instances.get(workspace)
/* v8 ignore next -- the slot-undefined arm needs a concurrent eviction of the same slot; defensive. */
if (slot !== undefined && (await settledInstance(slot)) === instance) {
this.instances.delete(workspace)
}
}
} }
} }
@@ -208,6 +212,15 @@ class LocalLspProvider implements LspProvider {
return created return created
} }
/** Drop the slot for `workspace` iff it still resolves to `instance` (a concurrent replacement survives). */
private async evictIfCurrent(workspace: string, instance: LspInstance): Promise<void> {
const slot = this.instances.get(workspace)
/* v8 ignore next -- the slot-undefined/mismatch arm needs a concurrent eviction of the same slot; defensive. */
if (slot !== undefined && (await settledInstance(slot)) === instance) {
this.instances.delete(workspace)
}
}
private createInstance(workspace: string): LspInstance { private createInstance(workspace: string): LspInstance {
const spec: InstanceSpec = { const spec: InstanceSpec = {
command: this.executable, command: this.executable,
@@ -265,7 +278,7 @@ function buildChildEnv(extra: Record<string, string>): Record<string, string> {
function resolveExecutable(command: string, childEnv: Record<string, string>): string { function resolveExecutable(command: string, childEnv: Record<string, string>): string {
if (isAbsolute(command)) { if (isAbsolute(command)) {
// Verify an absolute command too, so an unavailable one fails at load, not on the first query. // Verify an absolute command too, so an unavailable one fails at load, not on the first query.
if (!isExecutableSync(command)) { if (!isExecutableFileSync(command)) {
throw new Error(`lsp-local: command "${command}" is not an executable file`) throw new Error(`lsp-local: command "${command}" is not an executable file`)
} }
return command return command
@@ -275,14 +288,15 @@ function resolveExecutable(command: string, childEnv: Record<string, string>): s
for (const dir of pathValue.split(delimiter)) { for (const dir of pathValue.split(delimiter)) {
if (dir === '') continue if (dir === '') continue
const candidate = join(dir, command) const candidate = join(dir, command)
if (isExecutableSync(candidate)) return candidate if (isExecutableFileSync(candidate)) return candidate
} }
throw new Error(`lsp-local: command "${command}" was not found on PATH`) throw new Error(`lsp-local: command "${command}" was not found on PATH`)
} }
/** Synchronous executable check used only at load-time resolution. */ /** Synchronous regular-file and executable check used only at load-time resolution. */
function isExecutableSync(path: string): boolean { function isExecutableFileSync(path: string): boolean {
try { try {
if (!statSync(path).isFile()) return false
accessSync(path, constants.X_OK) accessSync(path, constants.X_OK)
return true return true
} catch { } catch {

View File

@@ -169,7 +169,7 @@ export class LspInstance {
*/ */
private abortable<T>(work: Promise<T>, signal: AbortSignal | undefined): Promise<T> { private abortable<T>(work: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
if (signal === undefined) return work if (signal === undefined) return work
/* v8 ignore next -- runQuery checks signal.aborted before each abortable() call, so it is not already aborted here; defensive. */ /* v8 ignore next -- callers either pre-check the signal or pass a freshly armed teardown deadline. */
if (signal.aborted) return Promise.reject(abortError(signal)) if (signal.aborted) return Promise.reject(abortError(signal))
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
const onAbort = (): void => { reject(abortError(signal)) } const onAbort = (): void => { reject(abortError(signal)) }
@@ -232,7 +232,10 @@ export class LspInstance {
if (operation === 'hover') { if (operation === 'hover') {
return { kind: 'hover', hover: normalizeHover(payload) } return { kind: 'hover', hover: normalizeHover(payload) }
} }
return { kind: 'locations', locations: normalizeLocations(payload) } // `spec.cwd` is the canonical workspace realpath (the provider canonicalizes before spawning),
// and every `file:` location URI is relative to it — so it is the root a caller must relativize
// display paths against, not the request's possibly-symlinked workspaceRoot.
return { kind: 'locations', locations: normalizeLocations(payload), resolvedWorkspaceRoot: this.spec.cwd }
} }
private answerServerRequest(method: string, params: unknown): Promise<unknown> { private answerServerRequest(method: string, params: unknown): Promise<unknown> {
@@ -271,24 +274,18 @@ export class LspInstance {
try { try {
using shutdownDeadline = deadline(undefined, this.spec.shutdownTimeoutMs, 'LSP_SHUTDOWN') using shutdownDeadline = deadline(undefined, this.spec.shutdownTimeoutMs, 'LSP_SHUTDOWN')
await this.gracefulShutdown(shutdownDeadline.signal) await this.gracefulShutdown(shutdownDeadline.signal)
return
} catch { } catch {
// Graceful shutdown failed or timed out: fall through to signal escalation. // Graceful shutdown failed or timed out: fall through to signal escalation.
} }
await this.forceTerminate() await this.forceTerminate()
} }
/** Best-effort LSP `shutdown` request then `exit` notification, bounded by `signal`. */ /** Best-effort LSP `shutdown`/`exit`, including process close, bounded by `signal`. */
private async gracefulShutdown(signal: AbortSignal): Promise<void> { private async gracefulShutdown(signal: AbortSignal): Promise<void> {
const shutdown = this.connection.request('shutdown', null) await this.abortable(this.connection.request('shutdown', null), signal)
await Promise.race([
shutdown,
new Promise<never>((_, reject) => {
/* v8 ignore next -- the shutdown deadline signal is freshly armed and not yet aborted here; defensive. */
if (signal.aborted) { reject(abortError(signal)); return }
signal.addEventListener('abort', () => { reject(abortError(signal)) }, { once: true })
}),
])
this.connection.notify('exit', null) this.connection.notify('exit', null)
await this.abortable(this.connection.closed, signal)
} }
/** SIGTERM, wait `killGraceMs` for close, then SIGKILL; await full process close either way. */ /** SIGTERM, wait `killGraceMs` for close, then SIGKILL; await full process close either way. */

View File

@@ -55,7 +55,6 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
const result = await ctx.lsp.query({ operation: 'definition', filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ${JSON.stringify(ws)} }) const result = await ctx.lsp.query({ operation: 'definition', filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ${JSON.stringify(ws)} })
console.log(JSON.stringify(result)) console.log(JSON.stringify(result))
await ctx.fiber.dispose() await ctx.fiber.dispose()
process.exit(0)
` `
const child = spawn(process.execPath, ['--input-type=module', '-e', script], { cwd: pkgDir, stdio: ['ignore', 'pipe', 'pipe'] }) const child = spawn(process.execPath, ['--input-type=module', '-e', script], { cwd: pkgDir, stdio: ['ignore', 'pipe', 'pipe'] })
let stdout = '' let stdout = ''

View File

@@ -190,6 +190,13 @@ describe('LspConnection edge behavior', () => {
expect(conn.stderrTail.length).toBe(100) expect(conn.stderrTail.length).toBe(100)
}) })
it('caps the retained stderr tail by bytes for multibyte UTF-8', async () => {
const conn = connectScript('process.stderr.write("😀😀")', 4)
await conn.closed
expect(conn.stderrTail).toBe('😀')
expect(Buffer.byteLength(conn.stderrTail)).toBe(4)
})
it('rejects with a fallback message when the error response has no message string', async () => { it('rejects with a fallback message when the error response has no message string', async () => {
const script = 'let b=Buffer.alloc(0);' const script = 'let b=Buffer.alloc(0);'
+ 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};' + 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'

View File

@@ -10,6 +10,9 @@
* - LSP_FAKE_DEF / LSP_FAKE_REFS / LSP_FAKE_IMPL / LSP_FAKE_HOVER: JSON result per request. * - LSP_FAKE_DEF / LSP_FAKE_REFS / LSP_FAKE_IMPL / LSP_FAKE_HOVER: JSON result per request.
* - LSP_FAKE_HANG: "1" makes textDocument/* requests never respond (for abort/timeout tests). * - LSP_FAKE_HANG: "1" makes textDocument/* requests never respond (for abort/timeout tests).
* - LSP_FAKE_CRASH_ON_OPEN: "1" exits the process when a didOpen arrives (crash test). * - LSP_FAKE_CRASH_ON_OPEN: "1" exits the process when a didOpen arrives (crash test).
* - LSP_FAKE_EXIT_AFTER_REPLY: "1" exits the process right after answering a textDocument/* request,
* simulating a server that dies while idle so the pool holds a dead instance (eviction test).
* - 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).
* - LSP_FAKE_ON_OPEN: server→client request to emit when a didOpen arrives, one of * - LSP_FAKE_ON_OPEN: server→client request to emit when a didOpen arrives, one of
* "configuration" | "applyEdit" | "notification" | "unknown"; the reply is logged to stderr. * "configuration" | "applyEdit" | "notification" | "unknown"; the reply is logged to stderr.
@@ -19,11 +22,16 @@
* Run: node --import tsx fixture-server.ts * Run: node --import tsx fixture-server.ts
*/ */
import { appendFileSync } from 'node:fs'
const enc = process.env.LSP_FAKE_ENCODING ?? 'utf-16' const enc = process.env.LSP_FAKE_ENCODING ?? 'utf-16'
const sync: unknown = process.env.LSP_FAKE_SYNC !== undefined ? JSON.parse(process.env.LSP_FAKE_SYNC) : 1 const sync: unknown = process.env.LSP_FAKE_SYNC !== undefined ? JSON.parse(process.env.LSP_FAKE_SYNC) : 1
const extraCaps: unknown = process.env.LSP_FAKE_CAPS !== undefined ? JSON.parse(process.env.LSP_FAKE_CAPS) : {} const extraCaps: unknown = process.env.LSP_FAKE_CAPS !== undefined ? JSON.parse(process.env.LSP_FAKE_CAPS) : {}
const hang = process.env.LSP_FAKE_HANG === '1' const hang = process.env.LSP_FAKE_HANG === '1'
const crashOnOpen = process.env.LSP_FAKE_CRASH_ON_OPEN === '1' const crashOnOpen = process.env.LSP_FAKE_CRASH_ON_OPEN === '1'
const exitAfterReply = process.env.LSP_FAKE_EXIT_AFTER_REPLY === '1'
const exitDelayMs = Number(process.env.LSP_FAKE_EXIT_DELAY_MS ?? 0)
const exitMarker = process.env.LSP_FAKE_EXIT_MARKER
const noShutdown = process.env.LSP_FAKE_NO_SHUTDOWN === '1' const noShutdown = process.env.LSP_FAKE_NO_SHUTDOWN === '1'
const onOpen = process.env.LSP_FAKE_ON_OPEN const onOpen = process.env.LSP_FAKE_ON_OPEN
const errorReply = process.env.LSP_FAKE_ERROR === '1' const errorReply = process.env.LSP_FAKE_ERROR === '1'
@@ -32,6 +40,11 @@ const garbage = process.env.LSP_FAKE_GARBAGE === '1'
let serverRequestId = 10_000 let serverRequestId = 10_000
const pendingServerRequests = new Map<number, string>() const pendingServerRequests = new Map<number, string>()
process.on('SIGTERM', () => {
markExit('TERM')
process.exit(0)
})
function resultFor(method: string): unknown { function resultFor(method: string): unknown {
switch (method) { switch (method) {
case 'textDocument/definition': return envJson('LSP_FAKE_DEF', null) case 'textDocument/definition': return envJson('LSP_FAKE_DEF', null)
@@ -98,6 +111,15 @@ function handle(message: { id?: number; method?: string; params?: unknown; resul
return return
} }
if (method === 'exit') { if (method === 'exit') {
markExit('EXIT')
if (exitDelayMs > 0) {
setTimeout(() => {
markExit('CLEAN')
process.exit(0)
}, exitDelayMs)
return
}
markExit('CLEAN')
process.exit(0) process.exit(0)
} }
if (method === 'textDocument/didOpen') { if (method === 'textDocument/didOpen') {
@@ -110,12 +132,20 @@ function handle(message: { id?: number; method?: string; params?: unknown; resul
if (hang) return if (hang) return
if (errorReply) { send({ id, error: { code: -32000, message: 'server refused the request' } }); return } if (errorReply) { send({ id, error: { code: -32000, message: 'server refused the request' } }); return }
send({ id, result: resultFor(method) }) send({ id, result: resultFor(method) })
// Simulate an idle death: answer this request, then exit before the next one arrives so the pool
// is left holding a dead instance.
if (exitAfterReply) setTimeout(() => process.exit(0), 20)
return return
} }
// Unknown request with an id: answer null so the client never stalls. // Unknown request with an id: answer null so the client never stalls.
if (id !== undefined) send({ id, result: null }) if (id !== undefined) send({ id, result: null })
} }
/** Append one teardown event when the fixture is configured to expose process ordering. */
function markExit(event: string): void {
if (exitMarker !== undefined) appendFileSync(exitMarker, `${event}\n`)
}
/** Emit a server→client request and log the client's reply to stderr for the test to assert. */ /** Emit a server→client request and log the client's reply to stderr for the test to assert. */
function emitServerRequest(kind: string): void { function emitServerRequest(kind: string): void {
if (kind === 'notification') { if (kind === 'notification') {

View File

@@ -69,6 +69,12 @@ describe('MessageDecoder', () => {
expect(() => decoder.push(huge)).toThrow(/exceeded .* bytes without a terminator/) expect(() => decoder.push(huge)).toThrow(/exceeded .* bytes without a terminator/)
}) })
it('rejects an oversized header block that includes its terminator', () => {
const decoder = new MessageDecoder(1_000)
const huge = Buffer.from(`Content-Length: 2\r\nX-Fill: ${'a'.repeat(70_000)}\r\n\r\n{}`, 'ascii')
expect(() => decoder.push(huge)).toThrow(/header exceeded .* bytes/)
})
it('rejects a non-JSON body', () => { it('rejects a non-JSON body', () => {
const decoder = new MessageDecoder(1_000) const decoder = new MessageDecoder(1_000)
expect(() => decoder.push(frame('not json'))).toThrow(/not valid JSON/) expect(() => decoder.push(frame('not json'))).toThrow(/not valid JSON/)

View File

@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises' import { mkdtemp, mkdir, readFile, rm, writeFile, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import { pathToFileURL, fileURLToPath } from 'node:url' import { pathToFileURL, fileURLToPath } from 'node:url'
@@ -96,17 +96,17 @@ describe('LspInstance server-request handling', () => {
it('accepts a lifecycle client/registerCapability request', async () => { it('accepts a lifecycle client/registerCapability request', async () => {
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'lifecycle', LSP_FAKE_DEF: 'null' }) const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'lifecycle', LSP_FAKE_DEF: 'null' })
await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [] }) await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
}) })
it('rejects a workspace/applyEdit request but keeps serving', async () => { it('rejects a workspace/applyEdit request but keeps serving', async () => {
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'applyEdit', LSP_FAKE_DEF: 'null' }) const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'applyEdit', LSP_FAKE_DEF: 'null' })
await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [] }) await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
}) })
it('rejects an unknown server request but keeps serving', async () => { it('rejects an unknown server request but keeps serving', async () => {
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'unknown', LSP_FAKE_DEF: 'null' }) const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'unknown', LSP_FAKE_DEF: 'null' })
await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [] }) await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
}) })
}) })
@@ -195,6 +195,18 @@ describe('LspInstance query and abort', () => {
}) })
describe('LspInstance disposal', () => { describe('LspInstance disposal', () => {
it('lets a server finish protocol exit before signal escalation', async () => {
const marker = join(root, 'graceful-exit.log')
const instance = makeInstance({
LSP_FAKE_DEF: 'null',
LSP_FAKE_EXIT_DELAY_MS: '75',
LSP_FAKE_EXIT_MARKER: marker,
}, { shutdownTimeoutMs: 500 })
await run(instance, 'definition')
await instance.dispose()
expect(await readFile(marker, 'utf8')).toBe('EXIT\nCLEAN\n')
})
it('is idempotent — a second dispose awaits close without error', async () => { it('is idempotent — a second dispose awaits close without error', async () => {
const instance = makeInstance({ LSP_FAKE_DEF: 'null' }) const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
await run(instance, 'definition') await run(instance, 'definition')

View File

@@ -59,6 +59,7 @@ describe('lsp-local end to end over a fake server', () => {
expect(result).toEqual<LspQueryResult>({ expect(result).toEqual<LspQueryResult>({
kind: 'locations', kind: 'locations',
locations: [{ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } }], locations: [{ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } }],
resolvedWorkspaceRoot: ws,
}) })
await ctx.fiber.dispose() await ctx.fiber.dispose()
}) })
@@ -89,7 +90,7 @@ describe('lsp-local end to end over a fake server', () => {
it('returns an empty locations result for a null definition', async () => { it('returns an empty locations result for a null definition', async () => {
const ctx = await mount({ LSP_FAKE_DEF: 'null' }) const ctx = await mount({ LSP_FAKE_DEF: 'null' })
expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [] }) expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
await ctx.fiber.dispose() await ctx.fiber.dispose()
}) })
@@ -123,7 +124,7 @@ describe('lsp-local end to end over a fake server', () => {
it('accepts openClose options sync', async () => { it('accepts openClose options sync', async () => {
const ctx = await mount({ LSP_FAKE_SYNC: JSON.stringify({ openClose: true, change: 2 }), LSP_FAKE_DEF: 'null' }) const ctx = await mount({ LSP_FAKE_SYNC: JSON.stringify({ openClose: true, change: 2 }), LSP_FAKE_DEF: 'null' })
expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [] }) expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
await ctx.fiber.dispose() await ctx.fiber.dispose()
}) })
@@ -195,6 +196,31 @@ describe('lsp-local end to end over a fake server', () => {
await ctx.fiber.dispose() await ctx.fiber.dispose()
}) })
it('evicts a pooled server that died while idle and serves the next query from a fresh one', async () => {
// The first query succeeds, then the server exits before the second arrives, leaving a dead
// instance in the pool. The next query must evict-and-replace it and still succeed, rather than
// failing once on the closed connection first.
const ctx = await mount({ LSP_FAKE_EXIT_AFTER_REPLY: '1', LSP_FAKE_DEF: JSON.stringify(locationJson(0)) })
expect(await ctx.lsp.query(query('definition'))).toMatchObject({ kind: 'locations' })
// Wait past the fixture's post-reply exit so the pooled instance is observably dead.
await new Promise(resolve => setTimeout(resolve, 60))
expect(await ctx.lsp.query(query('definition'))).toMatchObject({ kind: 'locations' })
await ctx.fiber.dispose()
})
it('does not spawn a server when the signal aborts during source read', async () => {
// Abort right after issuing the query: the abort lands while canonicalizeWorkspace/readHostSource
// are awaited, so the pre-spawn recheck must reject without ever creating a pooled instance.
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
const controller = new AbortController()
const pending = ctx.lsp.query(query('definition'), controller.signal)
controller.abort(new Error('mid-read cancel'))
await expect(pending).rejects.toThrow(/mid-read cancel/)
// A subsequent live query still works, proving no half-created instance poisoned the pool.
expect(await ctx.lsp.query(query('definition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
await ctx.fiber.dispose()
})
it('runs distinct workspaces in parallel instances', async () => { it('runs distinct workspaces in parallel instances', async () => {
const ws2 = join(root, 'ws2') const ws2 = join(root, 'ws2')
await mkdir(ws2) await mkdir(ws2)

View File

@@ -102,4 +102,16 @@ describe('lsp-local provider resolution', () => {
})).rejects.toThrow(/is not an executable file/) })).rejects.toThrow(/is not an executable file/)
await ctx.fiber.dispose() await ctx.fiber.dispose()
}) })
it('rejects an executable directory as a command at load', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await expect(ctx.plugin(LspLocal, {
providerId: 'abs-directory',
command: ws,
args: [],
extensionToLanguage: { '.ts': 'typescript' },
})).rejects.toThrow(/is not an executable file/)
await ctx.fiber.dispose()
})
}) })

View File

@@ -25,7 +25,7 @@ Providers register **capabilities**, not tools. `dsh-tool-lsp` is the only owner
## Vocabulary ## Vocabulary
`LspQueryRequest` (`operation`, `filePath`, `position`, `workspaceRoot`) — every field required, so no field needs implementation defaulting and there is no `resolve()` step. Positions and ranges are zero-based UTF-16, matching the protocol; the tool owns the one-based cursor convention. `references` always includes declarations — providers enforce this internally, so callers get no flag. `LspQueryResult` is a CLOSED discriminated union: `{ kind: 'locations'; locations }` for navigation, `{ kind: 'hover'; hover }` for hover (content or `null`) — consumers `switch` to exhaustiveness so a new arm breaks compilation until handled. See `src/types.ts` for the full contracts and `src/index.ts` for the `LspError` codes. `LspQueryRequest` (`operation`, `filePath`, `position`, `workspaceRoot`) — every field required, so no field needs implementation defaulting and there is no `resolve()` step. Positions and ranges are zero-based UTF-16, matching the protocol; the tool owns the one-based cursor convention. `references` always includes declarations — providers enforce this internally, so callers get no flag. `LspQueryResult` is a CLOSED discriminated union: `{ kind: 'locations'; locations; resolvedWorkspaceRoot }` for navigation, `{ kind: 'hover'; hover }` for hover (content or `null`) — consumers `switch` to exhaustiveness so a new arm breaks compilation until handled. `resolvedWorkspaceRoot` is the provider's canonical form of the request's `workspaceRoot` and the root its `file:` URIs are relative to; a caller relativizing display paths uses it, not the (possibly symlinked) request root. See `src/types.ts` for the full contracts and `src/index.ts` for the `LspError` codes.
## Model Experience ## Model Experience

View File

@@ -76,9 +76,14 @@ export interface LspHover {
* The closed result union. Navigation operations (`definition`, `references`, `implementation`) * The closed result union. Navigation operations (`definition`, `references`, `implementation`)
* normalize to `locations`; `hover` normalizes to content or `null`. Consumers `switch` on `kind` * normalize to `locations`; `hover` normalizes to content or `null`. Consumers `switch` on `kind`
* to exhaustiveness so a new arm breaks compilation until handled. * to exhaustiveness so a new arm breaks compilation until handled.
*
* The `locations` variant carries `resolvedWorkspaceRoot`: the provider's canonical form of the
* request's `workspaceRoot`, and the root its `file:` location URIs are relative to. A caller that
* relativizes display paths MUST use this, not the request's (possibly symlinked) `workspaceRoot`;
* otherwise a symlinked workspace misclassifies in-workspace results as external.
*/ */
export type LspQueryResult = export type LspQueryResult =
| { readonly kind: 'locations'; readonly locations: readonly LspLocation[] } | { readonly kind: 'locations'; readonly locations: readonly LspLocation[]; readonly resolvedWorkspaceRoot: string }
| { readonly kind: 'hover'; readonly hover: LspHover | null } | { readonly kind: 'hover'; readonly hover: LspHover | null }
/** /**

View File

@@ -13,7 +13,7 @@ import Lsp, {
function makeProvider( function makeProvider(
id: string, id: string,
extensionToLanguage: Record<string, string>, extensionToLanguage: Record<string, string>,
result: LspQueryResult = { kind: 'locations', locations: [] }, result: LspQueryResult = { kind: 'locations', locations: [], resolvedWorkspaceRoot: '/ws' },
): LspProvider & { seen: LspProviderQuery[]; seenSignals: (AbortSignal | undefined)[] } { ): LspProvider & { seen: LspProviderQuery[]; seenSignals: (AbortSignal | undefined)[] } {
const seen: LspProviderQuery[] = [] const seen: LspProviderQuery[] = []
const seenSignals: (AbortSignal | undefined)[] = [] const seenSignals: (AbortSignal | undefined)[] = []
@@ -63,7 +63,7 @@ describe('Lsp registration', () => {
const provider = makeProvider('ts', { '.ts': 'typescript' }) const provider = makeProvider('ts', { '.ts': 'typescript' })
const dispose = lsp.registerProvider(provider) const dispose = lsp.registerProvider(provider)
await expect(lsp.query(query('a.ts'))).resolves.toEqual({ kind: 'locations', locations: [] }) await expect(lsp.query(query('a.ts'))).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: '/ws' })
expect(provider.seen[0]).toMatchObject({ filePath: 'a.ts', languageId: 'typescript' }) expect(provider.seen[0]).toMatchObject({ filePath: 'a.ts', languageId: 'typescript' })
dispose() dispose()
@@ -148,7 +148,7 @@ describe('Lsp registration', () => {
const py = makeProvider('py', { '.py': 'python' }) const py = makeProvider('py', { '.py': 'python' })
lsp.registerProvider(ts) lsp.registerProvider(ts)
lsp.registerProvider(py) lsp.registerProvider(py)
await expect(lsp.query(query('a.py'))).resolves.toEqual({ kind: 'locations', locations: [] }) await expect(lsp.query(query('a.py'))).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: '/ws' })
await expect(lsp.query(query('a.ts', 'hover'))).resolves.toEqual(hover) await expect(lsp.query(query('a.ts', 'hover'))).resolves.toEqual(hover)
}) })
@@ -172,7 +172,7 @@ describe('Lsp registration', () => {
const fiber = await ctx.plugin(Object.assign((inner: Context) => { const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' })) inner.lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' }))
}, { inject: ['lsp'] })) }, { inject: ['lsp'] }))
await expect(lsp.query(query('a.ts'))).resolves.toEqual({ kind: 'locations', locations: [] }) await expect(lsp.query(query('a.ts'))).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: '/ws' })
await fiber.dispose() await fiber.dispose()
await expect(lsp.query(query('a.ts'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) await expect(lsp.query(query('a.ts'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
}) })

View File

@@ -8,7 +8,7 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). In
`lsp` accepts `operation` (`definition` | `references` | `implementation` | `hover`), `file_path`, `line`, and `character`. `line` and `character` are positive, one-based UTF-16 cursor coordinates; the tool converts them to the seam's zero-based positions and converts rendered locations back. `references` includes declarations so impact analysis does not omit the defining site. Provider, language id, workspace root, limits, timeout, initialization, and executable stay outside model input. `lsp` accepts `operation` (`definition` | `references` | `implementation` | `hover`), `file_path`, `line`, and `character`. `line` and `character` are positive, one-based UTF-16 cursor coordinates; the tool converts them to the seam's zero-based positions and converts rendered locations back. `references` includes declarations so impact analysis does not omit the defining site. Provider, language id, workspace root, limits, timeout, initialization, and executable stay outside model input.
The tool requires the workspace root from the session `header.cwd`, with no fallback: absence fails as `LSP_WORKSPACE_REQUIRED` before querying. Locations render as stable, file-grouped `path:line:character` entries; a `file:` URI becomes a workspace-relative path (inside) or absolute path (outside), and any other URI stays verbatim. Empty locations and `null` hover are successful no-result responses; malformed provider payloads remain structured errors. The tool requires the workspace root from the session `header.cwd`, with no fallback: absence fails as `LSP_WORKSPACE_REQUIRED` before querying. Locations render as stable, file-grouped `path:line:character` entries relativized against the result's `resolvedWorkspaceRoot` (the provider's canonical root), not the session cwd — so a symlinked cwd still renders in-workspace results as workspace-relative paths; a `file:` URI becomes a workspace-relative path (inside) or absolute path (outside), and any other URI stays verbatim. Empty locations and `null` hover are successful no-result responses; malformed provider payloads remain structured errors.
## Configuration ## Configuration

View File

@@ -113,7 +113,10 @@ export function apply(ctx: Context, config: Config): void {
}, exec.signal) }, exec.signal)
switch (result.kind) { switch (result.kind) {
case 'locations': case 'locations':
return [{ type: 'text', text: formatLocations(result.locations, workspaceRoot, resolved.maxLocations) }] // Relativize against the provider's canonical workspace root (which its file: URIs are
// relative to), not the session cwd: a symlinked cwd would otherwise misclassify every
// in-workspace location as external and render it as an absolute path.
return [{ type: 'text', text: formatLocations(result.locations, result.resolvedWorkspaceRoot, resolved.maxLocations) }]
case 'hover': case 'hover':
return [{ type: 'text', text: formatHover(result.hover, resolved.maxHoverChars) }] return [{ type: 'text', text: formatHover(result.hover, resolved.maxHoverChars) }]
} }

View File

@@ -51,6 +51,7 @@ function call(ctx: Context, args: unknown, cwd: string | null = '/ws') {
const okLocations: LspQueryResult = { const okLocations: LspQueryResult = {
kind: 'locations', kind: 'locations',
locations: [{ uri: 'file:///ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }], locations: [{ uri: 'file:///ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
resolvedWorkspaceRoot: '/ws',
} }
describe('tool-lsp registration', () => { describe('tool-lsp registration', () => {
@@ -107,6 +108,21 @@ describe('tool-lsp execution', () => {
expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' }) expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' })
}) })
it('relativizes against the provider resolvedWorkspaceRoot, not the session cwd', async () => {
// A symlinked session cwd (`/alias`) resolves to a real path (`/real/ws`) that the provider's
// location URIs are under. Relativizing against the alias would misclassify the location as
// external and print an absolute path; the tool must use resolvedWorkspaceRoot.
const provider = stubProvider(() => ({
kind: 'locations',
locations: [{ uri: 'file:///real/ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
resolvedWorkspaceRoot: '/real/ws',
}))
const { ctx } = await mount(provider)
const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 1 }, '/alias')
expect(provider.seen[0]).toMatchObject({ workspaceRoot: '/alias' })
expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' })
})
it('renders hover content', async () => { it('renders hover content', async () => {
const { ctx } = await mount(stubProvider(() => ({ kind: 'hover', hover: { contents: 'number' } }))) const { ctx } = await mount(stubProvider(() => ({ kind: 'hover', hover: { contents: 'number' } })))
const result = await call(ctx, { operation: 'hover', file_path: 'a.ts', line: 1, character: 1 }, '/ws') const result = await call(ctx, { operation: 'hover', file_path: 'a.ts', line: 1, character: 1 }, '/ws')