fix(lsp): harden local provider lifecycle
This commit is contained in:
@@ -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