fix(lsp): harden local provider lifecycle
This commit is contained in:
@@ -54,7 +54,7 @@ interface LspProviderQuery extends LspQueryRequest {
|
||||
|
||||
## 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
|
||||
interface LspLocation {
|
||||
@@ -76,7 +76,7 @@ interface LspHover {
|
||||
|
||||
```ts type-equiv
|
||||
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 }
|
||||
```
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# 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:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-15-lsp-capability-seam.md: 77b544efbcef1f352806d0431f1ff705d2b7bd44
|
||||
2026-07-15-lsp-capability-seam.zh.md: dad8160f0bb3a08b2b6546429c3826689a6307f8
|
||||
2026-07-15-lsp-capability-seam.md: c21d2f3926b5b465f4ccf48f1bf954c2ae604e12
|
||||
2026-07-15-lsp-capability-seam.zh.md: 9556ead6c2e0d6d49fffe54928d53f9418cf10b7
|
||||
@@ -62,7 +62,7 @@ interface LspProviderQuery extends LspQueryRequest {
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ interface LspProviderQuery extends LspQueryRequest {
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
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` 取得工作区,其取值方式与文件系统工具一致,也不导入提供方。
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export class LspConnection {
|
||||
private readonly decoder: MessageDecoder
|
||||
private readonly pending = new Map<number, Pending>()
|
||||
private nextId = 1
|
||||
private stderr = ''
|
||||
private stderr = Buffer.alloc(0)
|
||||
private closeReason: Error | undefined
|
||||
/** Set once the process has fully exited; the instance awaits it during teardown. */
|
||||
readonly closed: Promise<void>
|
||||
@@ -90,7 +90,7 @@ export class LspConnection {
|
||||
|
||||
/** The retained stderr tail, for diagnostics on a failed server. */
|
||||
get stderrTail(): string {
|
||||
return this.stderr
|
||||
return this.stderr.toString('utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,7 +199,17 @@ export class LspConnection {
|
||||
private onStderr(chunk: Buffer): void {
|
||||
// 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.
|
||||
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 {
|
||||
@@ -246,7 +256,7 @@ export class LspConnection {
|
||||
|
||||
/** The exit-close error message, appending the retained stderr tail when the server wrote any. */
|
||||
private exitMessage(): string {
|
||||
const tail = this.stderr.trim()
|
||||
const tail = this.stderrTail.trim()
|
||||
return tail === '' ? 'language server exited' : `language server exited; stderr: ${tail}`
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,9 @@ export class MessageDecoder {
|
||||
}
|
||||
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 contentLength = parseContentLength(headerText)
|
||||
if (contentLength > this.maxMessageBytes) {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* @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 type { Context } from 'cordis'
|
||||
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.
|
||||
/* 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')
|
||||
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 {
|
||||
return await instance.query(request, source, signal)
|
||||
} finally {
|
||||
// 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).
|
||||
if (instance.dead) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
if (instance.dead) await this.evictIfCurrent(workspace, instance)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +212,15 @@ class LocalLspProvider implements LspProvider {
|
||||
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 {
|
||||
const spec: InstanceSpec = {
|
||||
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 {
|
||||
if (isAbsolute(command)) {
|
||||
// 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`)
|
||||
}
|
||||
return command
|
||||
@@ -275,14 +288,15 @@ function resolveExecutable(command: string, childEnv: Record<string, string>): s
|
||||
for (const dir of pathValue.split(delimiter)) {
|
||||
if (dir === '') continue
|
||||
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`)
|
||||
}
|
||||
|
||||
/** Synchronous executable check used only at load-time resolution. */
|
||||
function isExecutableSync(path: string): boolean {
|
||||
/** Synchronous regular-file and executable check used only at load-time resolution. */
|
||||
function isExecutableFileSync(path: string): boolean {
|
||||
try {
|
||||
if (!statSync(path).isFile()) return false
|
||||
accessSync(path, constants.X_OK)
|
||||
return true
|
||||
} catch {
|
||||
|
||||
@@ -169,7 +169,7 @@ export class LspInstance {
|
||||
*/
|
||||
private abortable<T>(work: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
|
||||
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))
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const onAbort = (): void => { reject(abortError(signal)) }
|
||||
@@ -232,7 +232,10 @@ export class LspInstance {
|
||||
if (operation === 'hover') {
|
||||
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> {
|
||||
@@ -271,24 +274,18 @@ export class LspInstance {
|
||||
try {
|
||||
using shutdownDeadline = deadline(undefined, this.spec.shutdownTimeoutMs, 'LSP_SHUTDOWN')
|
||||
await this.gracefulShutdown(shutdownDeadline.signal)
|
||||
return
|
||||
} catch {
|
||||
// Graceful shutdown failed or timed out: fall through to signal escalation.
|
||||
}
|
||||
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> {
|
||||
const shutdown = this.connection.request('shutdown', null)
|
||||
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 })
|
||||
}),
|
||||
])
|
||||
await this.abortable(this.connection.request('shutdown', null), signal)
|
||||
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. */
|
||||
|
||||
@@ -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)} })
|
||||
console.log(JSON.stringify(result))
|
||||
await ctx.fiber.dispose()
|
||||
process.exit(0)
|
||||
`
|
||||
const child = spawn(process.execPath, ['--input-type=module', '-e', script], { cwd: pkgDir, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let stdout = ''
|
||||
|
||||
@@ -190,6 +190,13 @@ describe('LspConnection edge behavior', () => {
|
||||
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 () => {
|
||||
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]);};'
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
* - 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_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_ON_OPEN: server→client request to emit when a didOpen arrives, one of
|
||||
* "configuration" | "applyEdit" | "notification" | "unknown"; the reply is logged to stderr.
|
||||
@@ -19,11 +22,16 @@
|
||||
* Run: node --import tsx fixture-server.ts
|
||||
*/
|
||||
|
||||
import { appendFileSync } from 'node:fs'
|
||||
|
||||
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 extraCaps: unknown = process.env.LSP_FAKE_CAPS !== undefined ? JSON.parse(process.env.LSP_FAKE_CAPS) : {}
|
||||
const hang = process.env.LSP_FAKE_HANG === '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 onOpen = process.env.LSP_FAKE_ON_OPEN
|
||||
const errorReply = process.env.LSP_FAKE_ERROR === '1'
|
||||
@@ -32,6 +40,11 @@ const garbage = process.env.LSP_FAKE_GARBAGE === '1'
|
||||
let serverRequestId = 10_000
|
||||
const pendingServerRequests = new Map<number, string>()
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
markExit('TERM')
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
function resultFor(method: string): unknown {
|
||||
switch (method) {
|
||||
case 'textDocument/definition': return envJson('LSP_FAKE_DEF', null)
|
||||
@@ -98,6 +111,15 @@ function handle(message: { id?: number; method?: string; params?: unknown; resul
|
||||
return
|
||||
}
|
||||
if (method === 'exit') {
|
||||
markExit('EXIT')
|
||||
if (exitDelayMs > 0) {
|
||||
setTimeout(() => {
|
||||
markExit('CLEAN')
|
||||
process.exit(0)
|
||||
}, exitDelayMs)
|
||||
return
|
||||
}
|
||||
markExit('CLEAN')
|
||||
process.exit(0)
|
||||
}
|
||||
if (method === 'textDocument/didOpen') {
|
||||
@@ -110,12 +132,20 @@ function handle(message: { id?: number; method?: string; params?: unknown; resul
|
||||
if (hang) return
|
||||
if (errorReply) { send({ id, error: { code: -32000, message: 'server refused the request' } }); return }
|
||||
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
|
||||
}
|
||||
// Unknown request with an id: answer null so the client never stalls.
|
||||
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. */
|
||||
function emitServerRequest(kind: string): void {
|
||||
if (kind === 'notification') {
|
||||
|
||||
@@ -69,6 +69,12 @@ describe('MessageDecoder', () => {
|
||||
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', () => {
|
||||
const decoder = new MessageDecoder(1_000)
|
||||
expect(() => decoder.push(frame('not json'))).toThrow(/not valid JSON/)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { join } from 'node:path'
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url'
|
||||
@@ -96,17 +96,17 @@ describe('LspInstance server-request handling', () => {
|
||||
|
||||
it('accepts a lifecycle client/registerCapability request', async () => {
|
||||
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 () => {
|
||||
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 () => {
|
||||
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', () => {
|
||||
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 () => {
|
||||
const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
|
||||
await run(instance, 'definition')
|
||||
|
||||
@@ -59,6 +59,7 @@ describe('lsp-local end to end over a fake server', () => {
|
||||
expect(result).toEqual<LspQueryResult>({
|
||||
kind: 'locations',
|
||||
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()
|
||||
})
|
||||
@@ -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 () => {
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -123,7 +124,7 @@ describe('lsp-local end to end over a fake server', () => {
|
||||
|
||||
it('accepts openClose options sync', async () => {
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -195,6 +196,31 @@ describe('lsp-local end to end over a fake server', () => {
|
||||
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 () => {
|
||||
const ws2 = join(root, 'ws2')
|
||||
await mkdir(ws2)
|
||||
|
||||
@@ -102,4 +102,16 @@ describe('lsp-local provider resolution', () => {
|
||||
})).rejects.toThrow(/is not an executable file/)
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -25,7 +25,7 @@ Providers register **capabilities**, not tools. `dsh-tool-lsp` is the only owner
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -76,9 +76,14 @@ export interface LspHover {
|
||||
* The closed result union. Navigation operations (`definition`, `references`, `implementation`)
|
||||
* normalize to `locations`; `hover` normalizes to content or `null`. Consumers `switch` on `kind`
|
||||
* 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 =
|
||||
| { readonly kind: 'locations'; readonly locations: readonly LspLocation[] }
|
||||
| { readonly kind: 'locations'; readonly locations: readonly LspLocation[]; readonly resolvedWorkspaceRoot: string }
|
||||
| { readonly kind: 'hover'; readonly hover: LspHover | null }
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,7 +13,7 @@ import Lsp, {
|
||||
function makeProvider(
|
||||
id: string,
|
||||
extensionToLanguage: Record<string, string>,
|
||||
result: LspQueryResult = { kind: 'locations', locations: [] },
|
||||
result: LspQueryResult = { kind: 'locations', locations: [], resolvedWorkspaceRoot: '/ws' },
|
||||
): LspProvider & { seen: LspProviderQuery[]; seenSignals: (AbortSignal | undefined)[] } {
|
||||
const seen: LspProviderQuery[] = []
|
||||
const seenSignals: (AbortSignal | undefined)[] = []
|
||||
@@ -63,7 +63,7 @@ describe('Lsp registration', () => {
|
||||
const provider = makeProvider('ts', { '.ts': 'typescript' })
|
||||
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' })
|
||||
|
||||
dispose()
|
||||
@@ -148,7 +148,7 @@ describe('Lsp registration', () => {
|
||||
const py = makeProvider('py', { '.py': 'python' })
|
||||
lsp.registerProvider(ts)
|
||||
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)
|
||||
})
|
||||
|
||||
@@ -172,7 +172,7 @@ describe('Lsp registration', () => {
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' }))
|
||||
}, { 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 expect(lsp.query(query('a.ts'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -113,7 +113,10 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}, exec.signal)
|
||||
switch (result.kind) {
|
||||
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':
|
||||
return [{ type: 'text', text: formatHover(result.hover, resolved.maxHoverChars) }]
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ function call(ctx: Context, args: unknown, cwd: string | null = '/ws') {
|
||||
const okLocations: LspQueryResult = {
|
||||
kind: 'locations',
|
||||
locations: [{ uri: 'file:///ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
|
||||
resolvedWorkspaceRoot: '/ws',
|
||||
}
|
||||
|
||||
describe('tool-lsp registration', () => {
|
||||
@@ -107,6 +108,21 @@ describe('tool-lsp execution', () => {
|
||||
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 () => {
|
||||
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')
|
||||
|
||||
Reference in New Issue
Block a user