fix(lsp): address codex review round 1
Lifecycle and safety fixes from the external review: - Observe abort while awaiting the initialize handshake, so a server that never replies can't defeat the tool-timeout signal. - On an aborted request the server won't cancel, tear the instance down after a bounded grace instead of releasing the serialized queue with work still live (prevents overlapping document lifecycles). - Re-check provider disposal after the canonicalize/read awaits so a query can't spawn an unowned server after disposeAll(). - Read the source through one open handle (stat + read on the same fd) to close the realpath-vs-read TOCTOU; decode with a fatal UTF-8 decoder so a legitimate U+FFFD is not misclassified as invalid. - Validate and read the source BEFORE spawning a server (pre-start rejection). - Require an explicit openClose for option-form textDocumentSync. - Reject nonpositive teardown budgets and non-executable absolute commands at load; surface unsupported operations as structured LSP_UNSUPPORTED_OPERATION. - Retain the stderr tail (fatal diagnostics land at exit), not the prefix. - Catalog the seam vocabulary in docs/core-data-structures/lsp.md.
This commit is contained in:
@@ -174,8 +174,9 @@ export class LspConnection {
|
||||
}
|
||||
|
||||
private onStderr(chunk: Buffer): void {
|
||||
if (this.stderr.length >= this.spec.maxStderrBytes) return
|
||||
this.stderr = (this.stderr + chunk.toString('utf8')).slice(0, this.spec.maxStderrBytes)
|
||||
// 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)
|
||||
}
|
||||
|
||||
private dispatch(message: unknown): void {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* @module @deepseek-ai/dsh-lsp-local/host
|
||||
*/
|
||||
|
||||
import { readFile, realpath, stat } from 'node:fs/promises'
|
||||
import { open, realpath, stat } from 'node:fs/promises'
|
||||
import { isAbsolute, resolve as resolvePath, sep } from 'node:path'
|
||||
|
||||
/** A validated source: its canonical absolute path and current UTF-8 text. */
|
||||
@@ -68,16 +68,24 @@ export async function readHostSource(
|
||||
if (!isInside(canonicalWorkspace, canonicalPath)) {
|
||||
throw new Error(`source "${filePath}" resolves outside the workspace`)
|
||||
}
|
||||
const info = await stat(canonicalPath)
|
||||
if (!info.isFile()) {
|
||||
throw new Error(`source "${filePath}" is not a regular file`)
|
||||
// Open ONE handle after containment, then stat and read through it: a concurrent replace between
|
||||
// realpath and read cannot swap the target, so the regular-file and size checks bind the bytes we
|
||||
// actually read (no path-based TOCTOU).
|
||||
const handle = await open(canonicalPath, 'r')
|
||||
try {
|
||||
const info = await handle.stat()
|
||||
if (!info.isFile()) {
|
||||
throw new Error(`source "${filePath}" is not a regular file`)
|
||||
}
|
||||
if (info.size > maxDocumentBytes) {
|
||||
throw new Error(`source "${filePath}" is ${info.size} bytes, over the ${maxDocumentBytes}-byte limit`)
|
||||
}
|
||||
const buffer = await handle.readFile()
|
||||
const text = decodeUtf8Strict(buffer, filePath)
|
||||
return { canonicalPath, text }
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
if (info.size > maxDocumentBytes) {
|
||||
throw new Error(`source "${filePath}" is ${info.size} bytes, over the ${maxDocumentBytes}-byte limit`)
|
||||
}
|
||||
const buffer = await readFile(canonicalPath)
|
||||
const text = decodeUtf8Strict(buffer, filePath)
|
||||
return { canonicalPath, text }
|
||||
}
|
||||
|
||||
/** Whether `child` is the workspace itself or a descendant of it (both already canonical). */
|
||||
@@ -88,13 +96,13 @@ function isInside(workspace: string, child: string): boolean {
|
||||
return child.startsWith(base)
|
||||
}
|
||||
|
||||
/** Decode UTF-8 strictly (a replacement char means the source was not valid UTF-8 text). */
|
||||
/** Decode strictly as UTF-8: a fatal decoder rejects only malformed bytes, keeping a legitimate U+FFFD. */
|
||||
function decodeUtf8Strict(buffer: Buffer, filePath: string): string {
|
||||
const text = buffer.toString('utf8')
|
||||
if (text.includes('<EFBFBD>')) {
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(buffer)
|
||||
} catch {
|
||||
throw new Error(`source "${filePath}" is not valid UTF-8 text`)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
/** Extract a message from an unknown thrown value without leaking `any`. */
|
||||
|
||||
@@ -23,7 +23,7 @@ import type {
|
||||
} from '@deepseek-ai/dsh-lsp'
|
||||
// Side-effect type import: declaration-merges `ctx.lsp` onto Context.
|
||||
import type {} from '@deepseek-ai/dsh-lsp'
|
||||
import { canonicalizeWorkspace } from './host.ts'
|
||||
import { canonicalizeWorkspace, readHostSource } from './host.ts'
|
||||
import { LspInstance } from './instance.ts'
|
||||
import type { InstanceSpec } from './instance.ts'
|
||||
|
||||
@@ -110,6 +110,10 @@ export const Config: z<Config> = z.object({
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const resolved = config as ResolvedConfig
|
||||
// Teardown budgets feed `deadline()`, whose `<= 0` is the internal no-timeout sentinel; a
|
||||
// nonpositive value would let a server that ignores shutdown hang disposal forever. Fail at load.
|
||||
assertPositiveInteger('shutdownTimeoutMs', resolved.shutdownTimeoutMs)
|
||||
assertPositiveInteger('killGraceMs', resolved.killGraceMs)
|
||||
const childEnv = buildChildEnv(resolved.env)
|
||||
// Resolve the executable eagerly so a misconfigured command fails at load, not on first query.
|
||||
const executable = resolveExecutable(resolved.command, childEnv)
|
||||
@@ -124,6 +128,13 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}, 'lsp-local.registerProvider')
|
||||
}
|
||||
|
||||
/** Reject a nonpositive or non-integer config value at load, so misconfiguration fails loud. */
|
||||
function assertPositiveInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
throw new Error(`lsp-local: ${name} must be a positive integer`)
|
||||
}
|
||||
}
|
||||
|
||||
/** A pooled generic provider: one server process per canonical workspace, created on demand. */
|
||||
class LocalLspProvider implements LspProvider {
|
||||
readonly id: LspProviderId
|
||||
@@ -141,13 +152,26 @@ class LocalLspProvider implements LspProvider {
|
||||
this.extensionToLanguage = config.extensionToLanguage
|
||||
}
|
||||
|
||||
/** Read the disposed flag through a method so a `query()` await cannot narrow it to a literal. */
|
||||
private isDisposed(): boolean {
|
||||
return this.disposed
|
||||
}
|
||||
|
||||
async query(request: LspProviderQuery, signal?: AbortSignal): Promise<LspQueryResult> {
|
||||
/* v8 ignore next -- the seam unregisters this provider on dispose, so a query never reaches a disposed provider; defensive. */
|
||||
if (this.disposed) throw new Error('lsp-local provider is disposed')
|
||||
/* v8 ignore next -- the seam unregisters this provider on dispose, so a query never reaches it disposed; defensive. */
|
||||
if (this.isDisposed()) throw new Error('lsp-local provider is disposed')
|
||||
const workspace = await canonicalizeWorkspace(request.workspaceRoot)
|
||||
// Validate and read the source BEFORE spawning a server: a missing/external/non-regular/oversized
|
||||
// source must fail without leaving an idle process pooled (the pre-start rejection contract), and
|
||||
// the single-handle read preserves the containment/size checks against a mid-read swap.
|
||||
const source = await readHostSource(request.filePath, workspace, this.config.maxDocumentBytes)
|
||||
// Re-check disposal after the awaits: disposeAll() may have snapshotted the instance map while we
|
||||
// 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)
|
||||
try {
|
||||
return await instance.query(request, signal)
|
||||
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).
|
||||
@@ -185,7 +209,6 @@ class LocalLspProvider implements LspProvider {
|
||||
initializationOptions: this.config.initializationOptions,
|
||||
maxMessageBytes: this.config.maxMessageBytes,
|
||||
maxStderrBytes: this.config.maxStderrBytes,
|
||||
maxDocumentBytes: this.config.maxDocumentBytes,
|
||||
shutdownTimeoutMs: this.config.shutdownTimeoutMs,
|
||||
killGraceMs: this.config.killGraceMs,
|
||||
}
|
||||
@@ -232,6 +255,10 @@ 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)) {
|
||||
throw new Error(`lsp-local: command "${command}" is not an executable file`)
|
||||
}
|
||||
return command
|
||||
}
|
||||
/* v8 ignore next -- buildChildEnv always sets PATH from the ambient env; the further fallbacks are defensive. */
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { LspError } from '@deepseek-ai/dsh-lsp'
|
||||
import type {
|
||||
LspOperation,
|
||||
LspProviderQuery,
|
||||
@@ -16,7 +17,7 @@ import type {
|
||||
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
import { LspConnection } from './connection.ts'
|
||||
import type { ConnectionSpec } from './connection.ts'
|
||||
import { readHostSource } from './host.ts'
|
||||
import type { HostSource } from './host.ts'
|
||||
import type { WireInitializeResult, WireServerCapabilities } from './protocol.ts'
|
||||
import {
|
||||
negotiatePositionEncoding,
|
||||
@@ -31,8 +32,6 @@ import {
|
||||
export interface InstanceSpec extends ConnectionSpec {
|
||||
/** Static `initialize` options forwarded to the server. */
|
||||
readonly initializationOptions: unknown
|
||||
/** Largest source file this host will open (bytes). */
|
||||
readonly maxDocumentBytes: number
|
||||
/** Graceful `shutdown`/`exit` budget before escalation (ms). */
|
||||
readonly shutdownTimeoutMs: number
|
||||
/** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). */
|
||||
@@ -74,11 +73,12 @@ export class LspInstance {
|
||||
/**
|
||||
* Run one query through the serialized queue.
|
||||
* @param request - the resolved provider query.
|
||||
* @param source - the pre-validated, already-read host source (the provider reads before spawning).
|
||||
* @param signal - optional cancellation for this query's full lifecycle.
|
||||
* @returns the normalized result.
|
||||
*/
|
||||
query(request: LspProviderQuery, signal?: AbortSignal): Promise<LspQueryResult> {
|
||||
const run = this.queue.then(() => this.runQuery(request, signal))
|
||||
query(request: LspProviderQuery, source: HostSource, signal?: AbortSignal): Promise<LspQueryResult> {
|
||||
const run = this.queue.then(() => this.runQuery(request, source, signal))
|
||||
// Keep the tail alive regardless of this query's outcome so the next caller still serializes.
|
||||
this.queue = run.then(() => undefined, () => undefined)
|
||||
return run
|
||||
@@ -99,24 +99,26 @@ export class LspInstance {
|
||||
this.connection.notify('initialized', {})
|
||||
}
|
||||
|
||||
private async runQuery(request: LspProviderQuery, signal?: AbortSignal): Promise<LspQueryResult> {
|
||||
private async runQuery(request: LspProviderQuery, source: HostSource, signal?: AbortSignal): Promise<LspQueryResult> {
|
||||
if (this.disposed) throw new Error('LSP instance was disposed')
|
||||
if (signal?.aborted) throw abortError(signal)
|
||||
await this.ready
|
||||
// Observe abort during the handshake wait: a server that never answers `initialize` must not
|
||||
// block the tool-timeout signal here (the timeout policy awaits our quiescence, not the promise).
|
||||
await this.abortable(this.ready, signal)
|
||||
const capabilities = this.capabilities
|
||||
/* v8 ignore next -- `ready` resolves only after capabilities are set, else it rejects above; defensive. */
|
||||
if (capabilities === undefined) throw new Error('LSP instance is not initialized')
|
||||
if (!supportsOperation(capabilities, request.operation)) {
|
||||
throw new Error(`server does not support ${request.operation}`)
|
||||
throw new LspError(`server does not support ${request.operation}`, 'LSP_UNSUPPORTED_OPERATION')
|
||||
}
|
||||
if (!supportsTransientOpen(capabilities.textDocumentSync)) {
|
||||
throw new Error('server does not support the transient textDocument/didOpen this host requires')
|
||||
throw new LspError('server does not support the transient textDocument/didOpen this host requires', 'LSP_UNSUPPORTED_OPERATION')
|
||||
}
|
||||
|
||||
const source = await readHostSource(request.filePath, this.spec.cwd, this.spec.maxDocumentBytes)
|
||||
const uri = pathToFileURL(source.canonicalPath).href
|
||||
let opened = false
|
||||
try {
|
||||
/* v8 ignore next -- guards an abort landing between the ready wait and didOpen; not deterministically reproducible. */
|
||||
if (signal?.aborted) throw abortError(signal)
|
||||
this.connection.notify('textDocument/didOpen', {
|
||||
textDocument: { uri, languageId: request.languageId, version: 1, text: source.text },
|
||||
@@ -125,7 +127,10 @@ export class LspInstance {
|
||||
const payload = await this.sendRequest(request.operation, uri, request.position, signal)
|
||||
return this.normalize(request.operation, payload)
|
||||
} finally {
|
||||
if (opened) {
|
||||
// A disposed or closed instance (e.g. an aborted request whose server ignored
|
||||
// `$/cancelRequest`) is already tearing down; sending didClose would race that teardown and let
|
||||
// the next queued query's document lifecycle overlap the still-active request.
|
||||
if (opened && !this.dead) {
|
||||
try {
|
||||
this.connection.notify('textDocument/didClose', { textDocument: { uri } })
|
||||
} catch (error) {
|
||||
@@ -141,6 +146,21 @@ export class LspInstance {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Await `work`, but reject as soon as `signal` aborts. The underlying `work` promise keeps its own
|
||||
* handlers, so an orphaned rejection after abort is not unhandled.
|
||||
*/
|
||||
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. */
|
||||
if (signal.aborted) return Promise.reject(abortError(signal))
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const onAbort = (): void => { reject(abortError(signal)) }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
work.then(resolve, reject).finally(() => { signal.removeEventListener('abort', onAbort) })
|
||||
})
|
||||
}
|
||||
|
||||
private async sendRequest(
|
||||
operation: LspOperation,
|
||||
uri: string,
|
||||
@@ -160,21 +180,33 @@ export class LspInstance {
|
||||
return this.raceAbort(send, requestId, signal)
|
||||
}
|
||||
|
||||
/** Race a pending request against abort; on abort, send `$/cancelRequest` and reject. */
|
||||
/**
|
||||
* Race a pending request against abort. On abort, send `$/cancelRequest` and give the server a
|
||||
* bounded grace to acknowledge; if it does not settle in time, invalidate and tear down the
|
||||
* instance so the still-active request cannot overlap the next queued query's document lifecycle.
|
||||
*/
|
||||
private async raceAbort(send: Promise<unknown>, requestId: number, signal: AbortSignal): Promise<unknown> {
|
||||
const abort = new Promise<never>((_, reject) => {
|
||||
const onAbort = (): void => { reject(abortError(signal)) }
|
||||
/* v8 ignore next -- runQuery checks signal.aborted before sending, so it is not yet aborted here; defensive. */
|
||||
if (signal.aborted) { onAbort(); return }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
// Remove the abort listener once the request settles either way; the finally-promise inherits
|
||||
// send's rejection, so catch it to avoid an unhandled rejection when abort already won.
|
||||
send.finally(() => { signal.removeEventListener('abort', onAbort) }).catch(() => {})
|
||||
})
|
||||
try {
|
||||
return await Promise.race([send, abort])
|
||||
return await this.abortable(send, signal)
|
||||
} catch (error) {
|
||||
if (signal.aborted) this.connection.cancel(requestId)
|
||||
if (!signal.aborted) throw error
|
||||
this.connection.cancel(requestId)
|
||||
// Wait, bounded, for the server to honor the cancellation. If it does not, the request is still
|
||||
// running: terminate the instance (disposal awaits process close) so nothing outlives the query.
|
||||
using grace = deadline(undefined, this.spec.killGraceMs, 'LSP_CANCEL_GRACE')
|
||||
// `settled` is true if the request finished (either outcome) before the grace elapsed.
|
||||
const settled = await Promise.race([
|
||||
send.then(markSettled, markSettled),
|
||||
new Promise<boolean>((resolve) => {
|
||||
/* v8 ignore next -- the cancel-grace deadline signal is freshly armed and not yet aborted here; defensive. */
|
||||
if (grace.signal.aborted) { resolve(false); return }
|
||||
grace.signal.addEventListener('abort', () => { resolve(false) }, { once: true })
|
||||
}),
|
||||
])
|
||||
if (!settled && !this.disposed) {
|
||||
this.disposed = true
|
||||
await this.tearDown(abortError(signal))
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -266,6 +298,11 @@ const LIFECYCLE_NOOP_METHODS = new Set([
|
||||
'client/unregisterCapability',
|
||||
])
|
||||
|
||||
/** Mark a settled request in the cancel-grace race (either outcome means the request finished). */
|
||||
function markSettled(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
/** Build an abort Error carrying the signal's reason (preserving a timeout classification). */
|
||||
function abortError(signal: AbortSignal): Error {
|
||||
const timeout = timeoutOf(signal)
|
||||
|
||||
@@ -21,7 +21,6 @@ import type {
|
||||
WireRange,
|
||||
WireServerCapabilities,
|
||||
WireTextDocumentSyncKind,
|
||||
WireTextDocumentSyncOptions,
|
||||
} from './protocol.ts'
|
||||
|
||||
/**
|
||||
@@ -71,13 +70,15 @@ export function supportsOperation(capabilities: WireServerCapabilities, operatio
|
||||
|
||||
/**
|
||||
* Whether a `textDocumentSync` value permits the transient `didOpen`/`didClose` this host relies on.
|
||||
* The legacy enum form implies open/close for `Full`/`Incremental`; the options form requires an
|
||||
* explicit `openClose: true`, because the protocol defaults an omitted `openClose` to false.
|
||||
* @param sync - the server's advertised `textDocumentSync` capability.
|
||||
* @returns true when transient open/close is supported.
|
||||
*/
|
||||
export function supportsTransientOpen(sync: WireServerCapabilities['textDocumentSync']): boolean {
|
||||
if (sync === undefined) return false
|
||||
if (typeof sync === 'number') return isOpenCloseKind(sync)
|
||||
return sync.openClose === true || (sync.openClose === undefined && changeAllowsOpenClose(sync))
|
||||
return sync.openClose === true
|
||||
}
|
||||
|
||||
/** Legacy enum: `Full` (1) or `Incremental` (2) imply open/close support; `None` (0) does not. */
|
||||
@@ -85,11 +86,6 @@ function isOpenCloseKind(kind: WireTextDocumentSyncKind): boolean {
|
||||
return kind === 1 || kind === 2
|
||||
}
|
||||
|
||||
/** Options without an explicit `openClose` fall back to the legacy `change` enum's implication. */
|
||||
function changeAllowsOpenClose(sync: WireTextDocumentSyncOptions): boolean {
|
||||
return sync.change !== undefined && isOpenCloseKind(sync.change)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the negotiated position encoding. An omitted encoding defaults to `utf-16`; any value
|
||||
* other than `utf-16` is a protocol error this host does not support.
|
||||
|
||||
@@ -102,4 +102,12 @@ describe('readHostSource', () => {
|
||||
await writeFile(join(ws, 'bin.ts'), Buffer.from([0xff, 0xfe, 0x00]))
|
||||
await expect(readHostSource('bin.ts', ws, BIG)).rejects.toThrow(/not valid UTF-8/)
|
||||
})
|
||||
|
||||
it('keeps a valid U+FFFD replacement character in otherwise-valid UTF-8', async () => {
|
||||
// The literal replacement char is valid UTF-8; a fatal decoder must accept it (only malformed
|
||||
// byte sequences are rejected).
|
||||
await writeFile(join(ws, 'repl.ts'), 'const s = "<22>"\n')
|
||||
const source = await readHostSource('repl.ts', ws, BIG)
|
||||
expect(source.text).toBe('const s = "<22>"\n')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,9 +3,9 @@ import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url'
|
||||
import { LspInstance } from '@deepseek-ai/dsh-lsp-local'
|
||||
import { LspInstance, readHostSource } from '@deepseek-ai/dsh-lsp-local'
|
||||
import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts'
|
||||
import type { LspProviderQuery } from '@deepseek-ai/dsh-lsp'
|
||||
import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp'
|
||||
|
||||
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
||||
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
|
||||
@@ -38,7 +38,6 @@ function makeInstance(env: Record<string, string> = {}, overrides: Partial<Insta
|
||||
initializationOptions: { init: true },
|
||||
maxMessageBytes: 16_000_000,
|
||||
maxStderrBytes: 100_000,
|
||||
maxDocumentBytes: 4_000_000,
|
||||
shutdownTimeoutMs: 200,
|
||||
killGraceMs: 200,
|
||||
...overrides,
|
||||
@@ -51,6 +50,12 @@ function query(operation: LspProviderQuery['operation'] = 'definition'): LspProv
|
||||
return { operation, filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ws, languageId: 'typescript' }
|
||||
}
|
||||
|
||||
/** Run a query against an instance, reading the source first the way the provider does. */
|
||||
async function run(instance: LspInstance, operation: LspProviderQuery['operation'] = 'definition', signal?: AbortSignal): Promise<LspQueryResult> {
|
||||
const source = await readHostSource('a.ts', ws, 4_000_000)
|
||||
return instance.query(query(operation), source, signal)
|
||||
}
|
||||
|
||||
/** Build an instance whose "server" is an inline node script (for teardown-escalation control). */
|
||||
function scriptInstance(script: string, overrides: Partial<InstanceSpec> = {}): LspInstance {
|
||||
const instance = new LspInstance({
|
||||
@@ -62,7 +67,6 @@ function scriptInstance(script: string, overrides: Partial<InstanceSpec> = {}):
|
||||
initializationOptions: null,
|
||||
maxMessageBytes: 16_000_000,
|
||||
maxStderrBytes: 100_000,
|
||||
maxDocumentBytes: 4_000_000,
|
||||
shutdownTimeoutMs: 150,
|
||||
killGraceMs: 150,
|
||||
...overrides,
|
||||
@@ -87,51 +91,98 @@ describe('LspInstance server-request handling', () => {
|
||||
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'configuration', LSP_FAKE_DEF: locJson() })
|
||||
// The query drives didOpen, which makes the fake emit workspace/configuration; a healthy answer
|
||||
// keeps the query working.
|
||||
await expect(instance.query(query('definition'))).resolves.toMatchObject({ kind: 'locations' })
|
||||
await expect(run(instance, 'definition')).resolves.toMatchObject({ kind: 'locations' })
|
||||
})
|
||||
|
||||
it('accepts a lifecycle client/registerCapability request', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'lifecycle', LSP_FAKE_DEF: 'null' })
|
||||
await expect(instance.query(query('definition'))).resolves.toEqual({ kind: 'locations', locations: [] })
|
||||
await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [] })
|
||||
})
|
||||
|
||||
it('rejects a workspace/applyEdit request but keeps serving', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'applyEdit', LSP_FAKE_DEF: 'null' })
|
||||
await expect(instance.query(query('definition'))).resolves.toEqual({ kind: 'locations', locations: [] })
|
||||
await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [] })
|
||||
})
|
||||
|
||||
it('rejects an unknown server request but keeps serving', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'unknown', LSP_FAKE_DEF: 'null' })
|
||||
await expect(instance.query(query('definition'))).resolves.toEqual({ kind: 'locations', locations: [] })
|
||||
await expect(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('LspInstance query and abort', () => {
|
||||
it('sends includeDeclaration for references', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_REFS: JSON.stringify([JSON.parse(locJson())]) })
|
||||
await expect(instance.query(query('references'))).resolves.toMatchObject({ kind: 'locations' })
|
||||
await expect(run(instance, 'references')).resolves.toMatchObject({ kind: 'locations' })
|
||||
})
|
||||
|
||||
it('rejects a query aborted before it starts', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
|
||||
const controller = new AbortController()
|
||||
controller.abort(new Error('pre-abort'))
|
||||
await expect(instance.query(query('definition'), controller.signal)).rejects.toThrow(/pre-abort/)
|
||||
await expect(run(instance, 'definition', controller.signal)).rejects.toThrow(/pre-abort/)
|
||||
})
|
||||
|
||||
it('cancels an in-flight request on abort and rejects', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_HANG: '1' })
|
||||
const controller = new AbortController()
|
||||
// Warm the instance first so the abort lands during the hanging request, not during startup.
|
||||
const pending = instance.query(query('definition'), controller.signal)
|
||||
const pending = run(instance, 'definition', controller.signal)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 300))
|
||||
controller.abort(new Error('mid-flight'))
|
||||
await expect(pending).rejects.toThrow(/mid-flight/)
|
||||
})
|
||||
|
||||
it('terminates the instance when the server ignores $/cancelRequest past the grace', async () => {
|
||||
// The hang server never honors cancellation, so after the bounded grace the instance must be torn
|
||||
// down (its process closed) rather than left with an active request.
|
||||
const instance = makeInstance({ LSP_FAKE_HANG: '1' }, { killGraceMs: 100 })
|
||||
const controller = new AbortController()
|
||||
const pending = run(instance, 'definition', controller.signal)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 300))
|
||||
controller.abort(new Error('mid-flight'))
|
||||
await expect(pending).rejects.toThrow(/mid-flight/)
|
||||
expect(instance.dead).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves the cancel grace when the server honors $/cancelRequest', async () => {
|
||||
// A server that answers $/cancelRequest by settling the pending request lets the grace race
|
||||
// resolve via the request rather than the timeout, so the instance is NOT force-terminated.
|
||||
const script = 'let b=Buffer.alloc(0),reqId=null;'
|
||||
+ 'const fr=(o)=>{const x=Buffer.from(JSON.stringify({jsonrpc:"2.0",...o}));return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
|
||||
+ 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);for(;;){const s=b.indexOf("\\r\\n\\r\\n");if(s<0)break;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);if(b.length<s+4+len)break;const m=JSON.parse(b.toString("utf8",s+4,s+4+len));b=b.subarray(s+4+len);'
|
||||
+ 'if(m.method==="initialize")process.stdout.write(fr({id:m.id,result:{capabilities:{positionEncoding:"utf-16",textDocumentSync:1,definitionProvider:true}}}));'
|
||||
+ 'else if(m.method==="textDocument/definition")reqId=m.id;'
|
||||
+ 'else if(m.method==="$/cancelRequest"&&reqId!==null)process.stdout.write(fr({id:reqId,error:{code:-32800,message:"request cancelled"}}));'
|
||||
+ 'else if(m.method==="shutdown")process.stdout.write(fr({id:m.id,result:null}));'
|
||||
+ 'else if(m.method==="exit")process.exit(0);'
|
||||
+ '}});'
|
||||
const instance = scriptInstance(script, { killGraceMs: 2_000 })
|
||||
const controller = new AbortController()
|
||||
const pending = run(instance, 'definition', controller.signal)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 300))
|
||||
controller.abort(new Error('mid-flight'))
|
||||
await expect(pending).rejects.toThrow(/mid-flight/)
|
||||
// The server acknowledged cancellation within grace, so the instance was not force-killed.
|
||||
expect(instance.dead).toBe(false)
|
||||
await instance.dispose()
|
||||
})
|
||||
|
||||
it('observes abort while awaiting a slow initialize handshake', async () => {
|
||||
// A server that answers nothing (not even initialize) leaves `ready` pending; an abort must be
|
||||
// observed during that wait instead of hanging the tool-timeout signal.
|
||||
const instance = scriptInstance('setInterval(()=>{},1000)', { killGraceMs: 100 })
|
||||
const controller = new AbortController()
|
||||
const pending = run(instance, 'definition', controller.signal)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 150))
|
||||
controller.abort(new Error('handshake-abort'))
|
||||
await expect(pending).rejects.toThrow(/handshake-abort/)
|
||||
await instance.dispose()
|
||||
})
|
||||
|
||||
it('rejects when the server lacks the operation capability', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_CAPS: JSON.stringify({ definitionProvider: false }), LSP_FAKE_DEF: 'null' })
|
||||
await expect(instance.query(query('definition'))).rejects.toThrow(/does not support definition/)
|
||||
await expect(run(instance, 'definition')).rejects.toThrow(/does not support definition/)
|
||||
})
|
||||
|
||||
it('propagates a server error response even when a signal is supplied (not an abort)', async () => {
|
||||
@@ -139,28 +190,28 @@ describe('LspInstance query and abort', () => {
|
||||
// without treating it as an abort.
|
||||
const instance = makeInstance({ LSP_FAKE_ERROR: '1' })
|
||||
const controller = new AbortController()
|
||||
await expect(instance.query(query('definition'), controller.signal)).rejects.toThrow(/server refused/)
|
||||
await expect(run(instance, 'definition', controller.signal)).rejects.toThrow(/server refused/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LspInstance disposal', () => {
|
||||
it('is idempotent — a second dispose awaits close without error', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
|
||||
await instance.query(query('definition'))
|
||||
await run(instance, 'definition')
|
||||
await instance.dispose()
|
||||
await expect(instance.dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a query after disposal', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
|
||||
await instance.query(query('definition'))
|
||||
await run(instance, 'definition')
|
||||
await instance.dispose()
|
||||
await expect(instance.query(query('definition'))).rejects.toThrow(/disposed/)
|
||||
await expect(run(instance, 'definition')).rejects.toThrow(/disposed/)
|
||||
})
|
||||
|
||||
it('reports dead after the process closes', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
|
||||
await instance.query(query('definition'))
|
||||
await run(instance, 'definition')
|
||||
await instance.dispose()
|
||||
expect(instance.dead).toBe(true)
|
||||
})
|
||||
@@ -169,14 +220,14 @@ describe('LspInstance disposal', () => {
|
||||
// Server answers initialize, ignores shutdown, and traps SIGTERM so only SIGKILL stops it.
|
||||
const script = RESPONDING_SERVER + 'process.on("SIGTERM",()=>{});'
|
||||
const instance = scriptInstance(script, { shutdownTimeoutMs: 100, killGraceMs: 100 })
|
||||
await instance.query(query('definition'))
|
||||
await run(instance, 'definition')
|
||||
await expect(instance.dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('carries a non-Error abort reason as a generic aborted error', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_HANG: '1' })
|
||||
const controller = new AbortController()
|
||||
const pending = instance.query(query('definition'), controller.signal)
|
||||
const pending = run(instance, 'definition', controller.signal)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 200))
|
||||
controller.abort('a string reason, not an Error')
|
||||
await expect(pending).rejects.toThrow(/aborted/)
|
||||
|
||||
@@ -75,4 +75,31 @@ describe('lsp-local provider resolution', () => {
|
||||
await expect(lsp.query(query())).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a nonpositive teardown budget at load', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await expect(ctx.plugin(LspLocal, {
|
||||
providerId: 'bad-budget',
|
||||
command: process.execPath,
|
||||
args: ['-e', ''],
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
killGraceMs: 0,
|
||||
})).rejects.toThrow(/killGraceMs must be a positive integer/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects an absolute command that is not executable at load', async () => {
|
||||
const notExe = join(root, 'not-exe.txt')
|
||||
await writeFile(notExe, 'plain text, not executable')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await expect(ctx.plugin(LspLocal, {
|
||||
providerId: 'abs-bad',
|
||||
command: notExe,
|
||||
args: [],
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
})).rejects.toThrow(/is not an executable file/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -47,9 +47,9 @@ describe('supportsTransientOpen', () => {
|
||||
expect(supportsTransientOpen({ openClose: false, change: 2 })).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to the change enum when openClose is omitted', () => {
|
||||
expect(supportsTransientOpen({ change: 1 })).toBe(true)
|
||||
expect(supportsTransientOpen({ change: 0 })).toBe(false)
|
||||
it('requires an explicit openClose for the options form (no change-enum fallback)', () => {
|
||||
expect(supportsTransientOpen({ change: 1 })).toBe(false)
|
||||
expect(supportsTransientOpen({ change: 2 })).toBe(false)
|
||||
expect(supportsTransientOpen({})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user