fix(lsp): align operations and harden lifecycle

This commit is contained in:
Tianyi Cui
2026-07-21 13:29:40 +08:00
parent 2a55f6b684
commit 2fd995bf3c
46 changed files with 680 additions and 366 deletions

View File

@@ -8,6 +8,6 @@ The language-server capability seam: an abstract LSP interface, a generic stdio
| `lsp-local/` | Generic multi-server local backend (spawn, JSON-RPC, transient-open queries) | (registers providers on `ctx.lsp`) |
| `tool-lsp/` | Model-facing `lsp` tool (four operations, one-based UTF-16 cursor coordinates) | (registers on `ctx.tools`) |
The interface lives at `lsp/lsp/`. The seam exposes exactly four semantic operations — `definition`, `references`, `implementation`, `hover` — and no generic JSON-RPC escape hatch, so a provider swap does not change how the model asks for navigation and no protocol payload or unreviewed mutation reaches the model contract. Providers register **capabilities**, not tools; `tool-lsp` is the only owner of the model-facing name, schema, prompt guidance, and presentation.
The interface lives at `lsp/lsp/`. The seam exposes exactly four semantic operations — `goToDefinition`, `findReferences`, `goToImplementation`, `hover` — and no generic JSON-RPC escape hatch, so a provider swap does not change how the model asks for navigation and no protocol payload or unreviewed mutation reaches the model contract. Providers register **capabilities**, not tools; `tool-lsp` is the only owner of the model-facing name, schema, prompt guidance, and presentation.
See the [LSP capability seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md) for the design rationale, including why documents open transiently per query, why the local host reads through Node APIs rather than `ctx.fs`, and why extension ownership is exclusive within one runtime.

View File

@@ -9,7 +9,7 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export).
- Resolves every server-local setting before registration; an invalid mapping or registration conflict rolls back earlier entries, so a failed load leaves no provider routes.
- Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A crash fails the active query without replay; a later query may replace the process.
- Uses a compatibility-first **transient-open** sequence per query: canonicalize and read the source with Node APIs, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU.
- Serializes queries through one abortable per-instance queue so a cancellation that fails to stop the server can terminate it without killing unrelated work; distinct instances run in parallel.
- Serializes each source-read/open/query/close lifecycle through one abortable per-workspace queue so queued calls read current source only when their turn starts; distinct workspaces run in parallel.
- Reads sources through Node filesystem APIs in the subprocess's host namespace — NOT `ctx.fs`, and emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy.
## Configuration
@@ -28,13 +28,13 @@ The `servers` record key is the stable provider id reserved on `ctx.lsp`; each v
| `maxStderrBytes` | `1000000` | Largest stderr tail retained for diagnostics. |
| `maxDocumentBytes` | `4000000` | Largest source file this host will open. |
| `shutdownTimeoutMs` | `5000` | Graceful `shutdown`/`exit` budget before escalation. |
| `killGraceMs` | `2000` | SIGTERM→SIGKILL grace after graceful shutdown fails. |
| `killGraceMs` | `2000` | Grace for request cancellation and for SIGTERM→SIGKILL escalation. |
`servers` must contain at least one entry, and every id must be non-empty. All executables resolve at load after credential scrubbing; a bad later entry prevents every provider from registering. Processes launch lazily on the first matching query.
`servers` must contain at least one entry, and every id must be non-empty. Timer budgets must be positive integers no greater than Node's `2_147_483_647` ms timer limit. All executables resolve at load after credential scrubbing; a bad later entry prevents every provider from registering. Processes launch lazily on the first matching query.
## Protocol behavior
Initialization advertises `general.positionEncodings: ['utf-16']`, `workspace: { workspaceFolders: true, configuration: true }`, `textDocument.hover.contentFormat: ['markdown', 'plaintext']`, and `linkSupport: true` for definition and implementation, with no dynamic registration. The server's returned capabilities are authoritative: an unsupported operation, or synchronization without transient open/close, fails the query. An omitted server `positionEncoding` defaults to `utf-16`; any other value is a protocol error. The client answers `workspace/configuration` from static config, accepts lifecycle bookkeeping requests, and rejects `workspace/applyEdit` — it never applies edits or runs commands. Navigation maps `Location` directly and `LocationLink` from `targetUri` + `targetSelectionRange`; hover normalization takes `MarkupContent.value`, preserves string `MarkedString`s, renders language-tagged values as fenced code, and joins arrays with one blank line.
Initialization advertises `general.positionEncodings: ['utf-16']`, `workspace: { workspaceFolders: true, configuration: true }`, `textDocument.hover.contentFormat: ['markdown', 'plaintext']`, and `linkSupport: true` for definition and implementation, with no dynamic registration. The server's returned capabilities are authoritative: an unsupported operation, or synchronization without transient open/close, fails the query. An omitted server `positionEncoding` defaults to `utf-16`; any other value is a protocol error. The client answers `workspace/configuration` from static config, accepts lifecycle bookkeeping requests, and rejects `workspace/applyEdit` — it never applies edits or runs commands. Navigation maps `Location` directly and `LocationLink` from `targetUri` + `targetSelectionRange`; hover normalization takes valid `MarkupContent.value`, preserves string `MarkedString`s, renders language-tagged values as fenced code, and joins arrays with one blank line. Missing results, malformed ranges or positions, and malformed hover encodings fail as structured `LSP_MALFORMED_RESPONSE` errors.
## Security boundary
@@ -50,6 +50,6 @@ No direct invalidation; `dsh-tool-lsp` owns request-prefix changes.
## Known Limitations and Deferred Work
- **Trusted host-local only** — no sandbox confinement, no private cache/temp write contract; supporting untrusted binaries or restricted/remote/virtual workspaces requires a later process/filesystem contract and a different provider ([seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md)). Containment resolves `realpath`, then opens the source through one handle with `O_NOFOLLOW` (final-component symlink guard) and a bounded read; a concurrent mutator that swaps an *ancestor* directory for a symlink between the resolve and the open is an accepted residual TOCTOU under this trusted-deployment model, not closed with non-portable `openat` segment walks.
- **Trusted host-local only** — no sandbox confinement, no private cache/temp write contract; supporting untrusted binaries or restricted/remote/virtual workspaces requires a later process/filesystem contract and a different provider ([seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md)). Containment resolves `realpath`, then opens the source through one handle with `O_NOFOLLOW | O_NONBLOCK` (final-component symlink guard plus nonblocking rejection of FIFOs) and a bounded read; a concurrent mutator that swaps an *ancestor* directory for a symlink between the resolve and the open is an accepted residual TOCTOU under this trusted-deployment model, not closed with non-portable `openat` segment walks.
- **Transient-open compatibility floor** — servers whose synchronization omits open/close (or advertise `None`) are unsupported even if closed-document queries would work; the pinned TypeScript e2e establishes one compatibility floor, not a cross-language claim.
- **Per-server/workspace serialization latency** — parallel agents sharing one server and workspace queue behind one process; long-lived workspace processes consume memory until disposal.

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-lsp-local",
"description": "Generic stdio language-server provider for the DeepSeek Harness LSP capability seam (ctx.lsp) — spawns configured servers, translates JSON-RPC, and serves transient-open definition/references/implementation/hover queries in the host filesystem namespace",
"description": "Generic stdio language-server provider for the DeepSeek Harness LSP capability seam (ctx.lsp) — spawns configured servers, translates JSON-RPC, and serves transient-open goToDefinition/findReferences/goToImplementation/hover queries in the host filesystem namespace",
"version": "0.0.1",
"private": true,
"type": "module",

View File

@@ -0,0 +1,48 @@
/**
* Shared cancellation helpers for the local LSP provider's host-I/O, queue, and protocol phases.
* @module @deepseek-ai/dsh-lsp-local/abort
*/
import { timeoutOf } from '@deepseek-ai/dsh-timeout'
/**
* Build an abort Error carrying the signal's reason and preserving timeout classification.
* @param signal - the aborted signal whose reason to surface.
* @returns the timeout reason if present, else the Error reason, else a generic aborted Error.
*/
export function abortError(signal: AbortSignal): Error {
const timeout = timeoutOf(signal)
if (timeout !== undefined) return timeout
const reason: unknown = signal.reason
if (reason instanceof Error) return reason
return new Error('LSP query aborted')
}
/**
* Throw the signal's classified abort error when it has already fired.
* @param signal - the optional query cancellation signal.
*/
export function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) throw abortError(signal)
}
/**
* Await work while allowing a query signal to abandon its wait; the underlying work keeps its own
* handlers and continues to its owner-defined quiescence boundary.
* @param work - the owned asynchronous work.
* @param signal - optional query cancellation.
* @returns the work result, or a rejection carrying the classified abort reason.
*/
export function abortable<T>(work: Promise<T>, signal?: AbortSignal): Promise<T> {
if (signal === undefined) return work
if (signal.aborted) return Promise.reject(abortError(signal))
const canceled = Promise.withResolvers<never>()
const onAbort = (): void => { canceled.reject(abortError(signal)) }
signal.addEventListener('abort', onAbort, { once: true })
const normalized = work.catch((error: unknown) => {
/* v8 ignore next -- owned LSP promises reject with Error; coercion defends the generic helper. */
throw error instanceof Error ? error : new Error(String(error))
})
return Promise.race([normalized, canceled.promise])
.finally(() => { signal.removeEventListener('abort', onAbort) })
}

View File

@@ -75,10 +75,10 @@ export class LspConnection {
})
})
this.child.on('error', (error) => { this.fail(error) })
// A write to the child's stdin after it exits emits an async 'error'; swallow it so an EPIPE
// during teardown does not crash the process. Pending requests fail via the 'close' handler.
/* v8 ignore next -- the handler only fires on an async stdin write error during teardown. */
this.child.stdin.on('error', () => { /* swallow */ })
// Child stdin can fail while the process itself remains alive (for example, a server closes fd
// 0). Treat that as a fatal connection error so pending requests reject immediately instead of
// waiting for a process-close event that may never arrive.
this.child.stdin.on('error', (error) => { this.fail(error) })
this.child.stdout.on('data', (chunk: Buffer) => { this.onStdout(chunk) })
this.child.stderr.on('data', (chunk: Buffer) => { this.onStderr(chunk) })
}
@@ -108,15 +108,9 @@ export class LspConnection {
return
}
this.pending.set(id, { resolve, reject })
try {
this.write({ jsonrpc: '2.0', id, method, params })
} catch (error) {
/* v8 ignore start -- a stdin write failure surfaces asynchronously via the swallowed
'error' listener, so this synchronous catch is a defensive guard. */
this.pending.delete(id)
reject(asError(error))
/* v8 ignore stop */
}
// `write()` records either synchronous or callback-delivered failures on the connection and
// rejects every pending request. This handler only consumes the write promise itself.
void this.write({ jsonrpc: '2.0', id, method, params }).catch(() => {})
})
// A caller that stops awaiting (e.g. an aborted query) can leave this promise to reject later
// when the process closes; a benign no-op handler keeps that from surfacing as an unhandled
@@ -129,9 +123,10 @@ export class LspConnection {
* Send a notification (no id, no response).
* @param method - the JSON-RPC method.
* @param params - the notification params.
* @returns a promise that settles when the framed notification has been written.
*/
notify(method: string, params: unknown): void {
this.write({ jsonrpc: '2.0', method, params })
notify(method: string, params: unknown): Promise<void> {
return this.write({ jsonrpc: '2.0', method, params })
}
/**
@@ -139,11 +134,9 @@ export class LspConnection {
* @param requestId - the numeric id of the request to cancel.
*/
cancel(requestId: number): void {
try {
this.write({ jsonrpc: '2.0', method: '$/cancelRequest', params: { id: requestId } })
} catch {
// The server is already gone or unwritable; the pending request will fail on close.
}
// The server is already gone or unwritable when this rejects; `write()` has recorded the fatal
// connection failure and rejected the pending request, so cancellation remains best-effort.
void this.write({ jsonrpc: '2.0', method: '$/cancelRequest', params: { id: requestId } }).catch(() => {})
}
/**
@@ -253,7 +246,10 @@ export class LspConnection {
const id = frame.id
const method = frame.method
if (typeof method === 'string' && (typeof id === 'number' || typeof id === 'string')) {
void this.handleServerRequest(id, method, frame.params)
// A response-write failure has already invalidated the connection in `write()`.
/* v8 ignore next -- protocol tests exercise response writes; only a simultaneous connection
failure makes this consumption handler run. */
void this.handleServerRequest(id, method, frame.params).catch(() => {})
return
}
if (typeof method === 'string') {
@@ -266,9 +262,9 @@ export class LspConnection {
private async handleServerRequest(id: number | string, method: string, params: unknown): Promise<void> {
try {
const result = await this.onServerRequest(method, params)
this.write({ jsonrpc: '2.0', id, result })
await this.write({ jsonrpc: '2.0', id, result })
} catch (error) {
this.write({ jsonrpc: '2.0', id, error: { code: -32601, message: asError(error).message } })
await this.write({ jsonrpc: '2.0', id, error: { code: -32601, message: asError(error).message } })
}
}
@@ -285,8 +281,28 @@ export class LspConnection {
pending.resolve(frame.result)
}
private write(message: unknown): void {
this.child.stdin.write(encodeMessage(message))
private write(message: unknown): Promise<void> {
if (this.closeReason !== undefined) return Promise.reject(this.closeReason)
return new Promise<void>((resolve, reject) => {
const done = (error?: Error | null): void => {
if (error === undefined || error === null) {
resolve()
return
}
this.fail(error)
reject(error)
}
try {
this.child.stdin.write(encodeMessage(message), done)
/* v8 ignore start -- Node stream write failures are callback-delivered; this guards a
nonconforming Writable implementation throwing synchronously. */
} catch (error) {
const failure = asError(error)
this.fail(failure)
reject(failure)
}
/* v8 ignore stop */
})
}
/** The exit-close error message, appending the retained stderr tail when the server wrote any. */

View File

@@ -13,6 +13,7 @@ import { constants } from 'node:fs'
import { open, realpath, stat } from 'node:fs/promises'
import type { FileHandle } from 'node:fs/promises'
import { isAbsolute, resolve as resolvePath, sep } from 'node:path'
import { throwIfAborted } from './abort.ts'
/** A validated source: its canonical absolute path and current UTF-8 text. */
export interface HostSource {
@@ -27,17 +28,21 @@ export interface HostSource {
* process cwd, `rootUri`, the sole `workspaceFolders` entry, and pool identity, so symlinked roots
* collapse to one instance.
* @param workspaceRoot - the caller's workspace root (absolute).
* @param signal - optional cancellation observed around each filesystem operation.
* @returns the canonical directory path.
* @throws Error when the path is missing or not a directory.
*/
export async function canonicalizeWorkspace(workspaceRoot: string): Promise<string> {
export async function canonicalizeWorkspace(workspaceRoot: string, signal?: AbortSignal): Promise<string> {
throwIfAborted(signal)
let canonical: string
try {
canonical = await realpath(workspaceRoot)
} catch (error) {
throw new Error(`workspace root "${workspaceRoot}" cannot be resolved: ${messageOf(error)}`)
}
throwIfAborted(signal)
const info = await stat(canonical)
throwIfAborted(signal)
if (!info.isDirectory()) {
throw new Error(`workspace root "${workspaceRoot}" is not a directory`)
}
@@ -52,6 +57,7 @@ export async function canonicalizeWorkspace(workspaceRoot: string): Promise<stri
* @param filePath - the model-supplied source path (relative or absolute).
* @param canonicalWorkspace - the already-canonicalized workspace root.
* @param maxDocumentBytes - the largest source this host will open.
* @param signal - optional cancellation observed throughout resolution, validation, and reading.
* @returns the canonical path and current UTF-8 text.
* @throws Error when the source is missing, non-regular, oversized, non-UTF-8, or out of workspace.
*/
@@ -59,7 +65,9 @@ export async function readHostSource(
filePath: string,
canonicalWorkspace: string,
maxDocumentBytes: number,
signal?: AbortSignal,
): Promise<HostSource> {
throwIfAborted(signal)
const requested = isAbsolute(filePath) ? filePath : resolvePath(canonicalWorkspace, filePath)
let canonicalPath: string
try {
@@ -67,6 +75,7 @@ export async function readHostSource(
} catch (error) {
throw new Error(`source "${filePath}" cannot be resolved: ${messageOf(error)}`)
}
throwIfAborted(signal)
if (!isInside(canonicalWorkspace, canonicalPath)) {
throw new Error(`source "${filePath}" resolves outside the workspace`)
}
@@ -74,9 +83,12 @@ export async function readHostSource(
// realpath and read cannot swap the target, so the regular-file and size checks bind the bytes we
// actually read (no path-based TOCTOU). O_NOFOLLOW rejects the final component being swapped for a
// symlink between realpath and open (which would otherwise escape the workspace).
const handle = await open(canonicalPath, constants.O_RDONLY | constants.O_NOFOLLOW)
// O_NONBLOCK prevents a FIFO with no writer from hanging before fstat can reject it as nonregular.
const handle = await open(canonicalPath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
try {
throwIfAborted(signal)
const info = await handle.stat()
throwIfAborted(signal)
if (!info.isFile()) {
throw new Error(`source "${filePath}" is not a regular file`)
}
@@ -85,8 +97,9 @@ export async function readHostSource(
}
// Bound the read to the cap even if the file grew after stat: read one extra byte and reject on
// overflow, so a concurrent grow cannot defeat the memory bound.
const buffer = await readCapped(handle, maxDocumentBytes, filePath)
const buffer = await readCapped(handle, maxDocumentBytes, filePath, signal)
const text = decodeUtf8Strict(buffer, filePath)
throwIfAborted(signal)
return { canonicalPath, text }
} finally {
await handle.close()
@@ -94,12 +107,19 @@ export async function readHostSource(
}
/** Read at most `maxBytes` from the handle, rejecting when the source overflows the cap. */
async function readCapped(handle: FileHandle, maxBytes: number, filePath: string): Promise<Buffer> {
async function readCapped(
handle: FileHandle,
maxBytes: number,
filePath: string,
signal?: AbortSignal,
): Promise<Buffer> {
const limit = maxBytes + 1
const chunk = Buffer.allocUnsafe(limit)
let total = 0
for (;;) {
throwIfAborted(signal)
const { bytesRead } = await handle.read(chunk, total, limit - total, total)
throwIfAborted(signal)
if (bytesRead === 0) break
total += bytesRead
/* v8 ignore next 3 -- overflow requires the file to grow past the cap between stat and read (a concurrent mutation); defensive. */

View File

@@ -15,14 +15,16 @@ import { accessSync, constants, statSync } from 'node:fs'
import { delimiter, isAbsolute, join } from 'node:path'
import type { Context } from 'cordis'
import z from 'schemastery'
import { LspProviderId } from '@deepseek-ai/dsh-lsp'
import { LspError, LspProviderId } from '@deepseek-ai/dsh-lsp'
import type {
LspProvider,
LspProviderQuery,
LspQueryResult,
} from '@deepseek-ai/dsh-lsp'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { abortable, abortError } from './abort.ts'
import { canonicalizeWorkspace, readHostSource } from './host.ts'
import { abortError, LspInstance } from './instance.ts'
import { LspInstance } from './instance.ts'
import type { InstanceSpec } from './instance.ts'
export { canonicalizeWorkspace, readHostSource } from './host.ts'
@@ -75,7 +77,7 @@ export interface LspLocalServerConfig {
maxDocumentBytes?: number
/** Graceful `shutdown`/`exit` budget before escalation (ms). Default 5000. */
shutdownTimeoutMs?: number
/** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). Default 2000. */
/** Request-cancel and SIGTERM→SIGKILL grace (ms). Default 2000. */
killGraceMs?: number
}
@@ -98,8 +100,8 @@ const LspLocalServerConfig: z<LspLocalServerConfig> = z.object({
maxMessageBytes: z.number().default(DEFAULT_MAX_MESSAGE_BYTES),
maxStderrBytes: z.number().default(DEFAULT_MAX_STDERR_BYTES),
maxDocumentBytes: z.number().default(DEFAULT_MAX_DOCUMENT_BYTES),
shutdownTimeoutMs: z.number().default(DEFAULT_SHUTDOWN_TIMEOUT_MS),
killGraceMs: z.number().default(DEFAULT_KILL_GRACE_MS),
shutdownTimeoutMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_SHUTDOWN_TIMEOUT_MS),
killGraceMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_KILL_GRACE_MS),
})
export const Config: z<Config> = z.object({
@@ -148,8 +150,8 @@ export function apply(ctx: Context, config: Config): void {
function validateServerConfig(providerId: string, resolved: ResolvedServerConfig): void {
// 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(providerId, 'shutdownTimeoutMs', resolved.shutdownTimeoutMs)
assertPositiveInteger(providerId, 'killGraceMs', resolved.killGraceMs)
assertTimer(providerId, 'shutdownTimeoutMs', resolved.shutdownTimeoutMs)
assertTimer(providerId, 'killGraceMs', resolved.killGraceMs)
// Byte caps must be positive: a nonpositive stderr cap defeats the retained-tail bound
// (`slice(-0)` keeps everything), `maxMessageBytes: 0` makes every response fatal, and a bad
// document cap fails later in the read path instead of at load.
@@ -158,6 +160,13 @@ function validateServerConfig(providerId: string, resolved: ResolvedServerConfig
assertPositiveInteger(providerId, 'maxDocumentBytes', resolved.maxDocumentBytes)
}
/** Reject a timer value Node would clamp instead of scheduling as configured. */
function assertTimer(providerId: string, name: string, value: number): void {
if (!Number.isInteger(value) || value < 1 || value > MAX_TIMER_DELAY_MS) {
throw new Error(`lsp-local: servers.${providerId}.${name} must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`)
}
}
/** Reject a nonpositive or non-integer config value at load, so misconfiguration fails loud. */
function assertPositiveInteger(providerId: string, name: string, value: number): void {
if (!Number.isInteger(value) || value < 1) {
@@ -171,6 +180,8 @@ class LocalLspProvider implements LspProvider {
readonly extensionToLanguage: Readonly<Record<string, string>>
/** One live instance per canonical workspace realpath. */
private readonly instances = new Map<string, LspInstance>()
/** One complete source-read→open→query→close serialization tail per canonical workspace. */
private readonly queues = new Map<string, Promise<void>>()
private disposed = false
constructor(
@@ -192,35 +203,49 @@ class LocalLspProvider implements LspProvider {
private assertActive(signal?: AbortSignal): void {
/* v8 ignore next -- the seam unregisters this provider before disposal; direct in-flight calls
exercise the post-await check instead. */
if (this.isDisposed()) throw new Error('lsp-local provider is disposed')
if (this.isDisposed()) throw new LspError('lsp-local provider is disposed', 'LSP_DISPOSED')
if (signal?.aborted) throw abortError(signal)
}
async query(request: LspProviderQuery, signal?: AbortSignal): Promise<LspQueryResult> {
// Honor an already-aborted signal before host I/O so a canceled request never starts a server.
this.assertActive(signal)
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.
const workspace = await canonicalizeWorkspace(request.workspaceRoot, signal)
this.assertActive(signal)
let instance = this.instanceFor(workspace)
// Eviction and replacement are synchronous so disposal cannot snapshot the pool between them and
// miss a newly spawned process.
if (instance.dead) {
this.evictIfCurrent(workspace, instance)
instance = 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) this.evictIfCurrent(workspace, instance)
}
return this.enqueue(workspace, signal, async () => {
this.assertActive(signal)
// Read inside the workspace queue but before spawning: a queued query sees current bytes when
// its turn starts, while an invalid source still cannot leave an idle process pooled.
const source = await readHostSource(request.filePath, workspace, this.config.maxDocumentBytes, signal)
// Disposal may have snapshotted the instance map while host I/O was pending. Re-check before a
// synchronous get-or-create so every spawned process remains owned by teardown.
this.assertActive(signal)
let instance = this.instanceFor(workspace)
if (instance.dead) {
this.evictIfCurrent(workspace, instance)
instance = this.instanceFor(workspace)
}
try {
return await instance.query(request, source, signal)
} finally {
// Drop a crashed slot only when it still owns this instance; a replacement must survive.
if (instance.dead) this.evictIfCurrent(workspace, instance)
}
})
}
/** Serialize one complete query lifecycle for a canonical workspace. */
private enqueue<T>(workspace: string, signal: AbortSignal | undefined, run: () => Promise<T>): Promise<T> {
const previous = this.queues.get(workspace) ?? Promise.resolve()
const result = abortable(previous, signal).then(run)
// The tail follows the actual prior work even when this caller aborts its wait. It never rejects,
// so later callers serialize without inheriting an earlier query's outcome.
const tail = previous.then(() => result).then(() => undefined, () => undefined)
this.queues.set(workspace, tail)
void tail.then(() => {
if (this.queues.get(workspace) === tail) this.queues.delete(workspace)
})
return result
}
/** Return or synchronously publish the one instance for a canonical workspace. */
@@ -259,8 +284,13 @@ class LocalLspProvider implements LspProvider {
async disposeAll(): Promise<void> {
this.disposed = true
const live = [...this.instances.values()]
const draining = [...this.queues.values()]
this.instances.clear()
await Promise.all(live.map(instance => instance.dispose()))
await Promise.all([
...live.map(instance => instance.dispose()),
...draining,
])
this.queues.clear()
}
}

View File

@@ -14,7 +14,8 @@ import type {
LspProviderQuery,
LspQueryResult,
} from '@deepseek-ai/dsh-lsp'
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { deadline } from '@deepseek-ai/dsh-timeout'
import { abortable, abortError } from './abort.ts'
import { LspConnection } from './connection.ts'
import type { ConnectionSpec } from './connection.ts'
import type { HostSource } from './host.ts'
@@ -83,7 +84,7 @@ export class LspInstance {
// Serialize behind prior work, but observe abort DURING the queue wait too: if an earlier query
// hangs (e.g. a signal-less seam caller), a later tool's timeout must still be able to give up
// rather than block on the shared tail forever.
const run = this.abortable(this.queue, signal).then(() => this.runQuery(request, source, signal))
const run = abortable(this.queue, signal).then(() => this.runQuery(request, source, signal))
// Keep the tail alive regardless of this query's outcome so the next caller still serializes. The
// tail follows the ACTUAL prior work (this.queue), not the abortable view, so a caller giving up
// on the wait does not deserialize the queue.
@@ -103,11 +104,11 @@ export class LspInstance {
// An omitted encoding defaults to utf-16; any other value is a protocol error we reject here.
negotiatePositionEncoding(capabilities.positionEncoding)
this.capabilities = capabilities
this.connection.notify('initialized', {})
await this.connection.notify('initialized', {})
}
private async runQuery(request: LspProviderQuery, source: HostSource, signal?: AbortSignal): Promise<LspQueryResult> {
if (this.disposed) throw new Error('LSP instance was disposed')
if (this.disposed) throw new LspError('LSP instance was disposed', 'LSP_DISPOSED')
/* v8 ignore next -- the abortable queue wait rejects a pre-aborted signal before runQuery; this is a belt-and-suspenders guard. */
if (signal?.aborted) throw abortError(signal)
// Observe abort during the handshake wait, and never pool a poisoned instance: if the wait ends
@@ -115,11 +116,10 @@ export class LspInstance {
// negotiation, malformed result) without the process exiting — tear the instance down so a
// permanently-rejecting/pending `ready` can't make every later query for this workspace fail.
try {
await this.abortable(this.ready, signal)
await abortable(this.ready, signal)
} catch (error) {
if (!this.dead) {
/* v8 ignore next -- ready rejects with an Error (abort reason or initialize failure); the String() fallback is defensive. */
await this.startTeardown(error instanceof Error ? error : new Error(String(error)))
await this.startTeardown()
}
throw error
}
@@ -138,7 +138,7 @@ export class LspInstance {
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', {
await this.connection.notify('textDocument/didOpen', {
textDocument: { uri, languageId: request.languageId, version: 1, text: source.text },
})
opened = true
@@ -150,34 +150,21 @@ export class LspInstance {
// 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) {
/* v8 ignore start -- stdin write errors surface asynchronously via the swallowed 'error'
listener, so a synchronous didClose write failure is a defensive path. */
await this.connection.notify('textDocument/didClose', { textDocument: { uri } })
} catch {
// A close-write failure does not replace the settled result/error, but the instance can no
// longer be trusted: invalidate it and await bounded process termination.
void this.startTeardown(error instanceof Error ? error : new Error(String(error)))
/* v8 ignore stop */
try {
await this.startTeardown()
} catch {
/* v8 ignore next -- teardown owns all expected process races; this only preserves the
already-settled query outcome if an unexpected cleanup primitive itself rejects. */
}
}
}
}
}
/**
* 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 -- 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)) }
signal.addEventListener('abort', onAbort, { once: true })
work.then(resolve, reject).finally(() => { signal.removeEventListener('abort', onAbort) })
})
}
private async sendRequest(
operation: LspOperation,
uri: string,
@@ -187,9 +174,9 @@ export class LspInstance {
const params = {
textDocument: { uri },
position: { line: position.line, character: position.character },
// references always includes declarations: the caller gets no flag and impact analysis never
// omits the defining site.
...(operation === 'references' ? { context: { includeDeclaration: true } } : {}),
// findReferences always includes declarations: the caller gets no flag and impact analysis
// never omits the defining site.
...(operation === 'findReferences' ? { context: { includeDeclaration: true } } : {}),
}
const requestId = this.connection.peekNextId()
const send = this.connection.request(requestMethod(operation), params)
@@ -204,7 +191,7 @@ export class LspInstance {
*/
private async raceAbort(send: Promise<unknown>, requestId: number, signal: AbortSignal): Promise<unknown> {
try {
return await this.abortable(send, signal)
return await abortable(send, signal)
} catch (error) {
if (!signal.aborted) throw error
this.connection.cancel(requestId)
@@ -221,7 +208,7 @@ export class LspInstance {
grace.signal.addEventListener('abort', () => { resolve(false) }, { once: true })
}),
])
if (!settled) await this.startTeardown(abortError(signal))
if (!settled) await this.startTeardown()
} finally {
grace[Symbol.dispose]()
}
@@ -263,17 +250,17 @@ export class LspInstance {
* process close so nothing outlives disposal.
*/
async dispose(): Promise<void> {
await this.startTeardown(new Error('LSP instance disposed'))
await this.startTeardown()
}
/** Publish disposal once and make every caller await the same quiescence boundary. */
private startTeardown(reason: Error): Promise<void> {
private startTeardown(): Promise<void> {
this.disposed = true
this.teardownPromise ??= this.tearDown(reason)
this.teardownPromise ??= this.tearDown()
return this.teardownPromise
}
private async tearDown(_reason: Error): Promise<void> {
private async tearDown(): Promise<void> {
const shutdownDeadline = deadline(undefined, this.spec.shutdownTimeoutMs, 'LSP_SHUTDOWN')
try {
await this.gracefulShutdown(shutdownDeadline.signal)
@@ -287,9 +274,9 @@ export class LspInstance {
/** Best-effort LSP `shutdown`/`exit`, including process close, bounded by `signal`. */
private async gracefulShutdown(signal: AbortSignal): Promise<void> {
await this.abortable(this.connection.request('shutdown', null), signal)
this.connection.notify('exit', null)
await this.abortable(this.connection.closed, signal)
await abortable(this.connection.request('shutdown', null), signal)
await this.connection.notify('exit', null)
await abortable(this.connection.closed, signal)
}
/** SIGTERM the group, escalate after `killGraceMs`, then await leader and helper exit. */
@@ -322,19 +309,6 @@ function markSettled(): boolean {
return true
}
/**
* Build an abort Error carrying the signal's reason (preserving a timeout classification).
* @param signal - the aborted signal whose reason to surface.
* @returns the timeout reason if the signal carries one, else the signal's Error reason, else a generic aborted Error.
*/
export function abortError(signal: AbortSignal): Error {
const timeout = timeoutOf(signal)
if (timeout !== undefined) return timeout
const reason: unknown = signal.reason
if (reason instanceof Error) return reason
return new Error('LSP query aborted')
}
/**
* The client capabilities advertised at `initialize`: UTF-16 positions, workspace folders and
* configuration, markdown/plaintext hover, and link support for definition/implementation. No

View File

@@ -11,6 +11,7 @@ import type {
LspOperation,
LspRange,
} from '@deepseek-ai/dsh-lsp'
import { LspError } from '@deepseek-ai/dsh-lsp'
import { assertNever } from '@deepseek-ai/dsh-llm'
import type {
WireHover,
@@ -30,9 +31,9 @@ import type {
*/
export function requestMethod(operation: LspOperation): string {
switch (operation) {
case 'definition': return 'textDocument/definition'
case 'references': return 'textDocument/references'
case 'implementation': return 'textDocument/implementation'
case 'goToDefinition': return 'textDocument/definition'
case 'findReferences': return 'textDocument/references'
case 'goToImplementation': return 'textDocument/implementation'
case 'hover': return 'textDocument/hover'
/* v8 ignore next -- exhaustive over the closed LspOperation union; unreachable. */
default: return assertNever(operation, 'requestMethod')
@@ -42,9 +43,9 @@ export function requestMethod(operation: LspOperation): string {
/** The `ServerCapabilities` provider field backing each operation. */
function capabilityValue(capabilities: WireServerCapabilities, operation: LspOperation): WireProviderCapability {
switch (operation) {
case 'definition': return capabilities.definitionProvider
case 'references': return capabilities.referencesProvider
case 'implementation': return capabilities.implementationProvider
case 'goToDefinition': return capabilities.definitionProvider
case 'findReferences': return capabilities.referencesProvider
case 'goToImplementation': return capabilities.implementationProvider
case 'hover': return capabilities.hoverProvider
/* v8 ignore next -- exhaustive over the closed LspOperation union; unreachable. */
default: return assertNever(operation, 'capabilityValue')
@@ -127,7 +128,12 @@ function isRange(value: unknown): value is WireRange {
function isPosition(value: unknown): boolean {
if (value === null || typeof value !== 'object') return false
const position = value as Record<string, unknown>
return typeof position.line === 'number' && typeof position.character === 'number'
return isProtocolCoordinate(position.line) && isProtocolCoordinate(position.character)
}
/** Whether a wire coordinate is a valid nonnegative integer. */
function isProtocolCoordinate(value: unknown): value is number {
return typeof value === 'number' && Number.isInteger(value) && value >= 0
}
/**
@@ -138,12 +144,13 @@ function isPosition(value: unknown): boolean {
* @throws Error when an element is neither a `Location` nor a `LocationLink`.
*/
export function normalizeLocations(payload: unknown): LspLocation[] {
if (payload === null || payload === undefined) return []
if (payload === null) return []
if (payload === undefined) throw malformedResponse('LSP navigation result was missing')
const elements = Array.isArray(payload) ? payload : [payload]
const locations: LspLocation[] = []
for (const element of elements) {
if (element === null || typeof element !== 'object') {
throw new Error('LSP navigation result contained a non-object entry')
throw malformedResponse('LSP navigation result contained a non-object entry')
}
const record = element as Record<string, unknown>
if (isLocationLink(record)) {
@@ -153,7 +160,7 @@ export function normalizeLocations(payload: unknown): LspLocation[] {
const location = record as unknown as WireLocation
locations.push({ uri: location.uri, range: toRange(location.range) })
} else {
throw new Error('LSP navigation result contained neither a Location nor a LocationLink')
throw malformedResponse('LSP navigation result contained neither a Location nor a LocationLink')
}
}
return locations
@@ -168,39 +175,61 @@ function renderMarkedString(value: WireMarkedString): string {
/**
* Normalize a `Hover` (or `null`) to the seam's hover. `MarkupContent` uses its `value`; a string
* `MarkedString` is verbatim; a language-tagged `MarkedString` becomes a fenced code block; an array
* joins its rendered parts with one blank line. `maxHoverChars` is NOT applied here — the tool caps.
* joins its rendered parts with one blank line. The model-facing tool owns the complete result cap.
* @param payload - the raw `textDocument/hover` result.
* @returns the normalized hover, or `null` when there is no content.
* @throws Error when the payload is a non-null, non-object, or structurally invalid hover.
*/
export function normalizeHover(payload: unknown): LspHover | null {
if (payload === null || payload === undefined) return null
if (typeof payload !== 'object') throw new Error('LSP hover result was not an object')
if (payload === null) return null
if (payload === undefined) throw malformedResponse('LSP hover result was missing')
if (typeof payload !== 'object') throw malformedResponse('LSP hover result was not an object')
const hover = payload as unknown as WireHover
const contents = renderHoverContents(hover.contents)
if (contents === '') return null
const range = hover.range
return range !== undefined && isRange(range) ? { contents, range: toRange(range) } : { contents }
if (range === undefined) return { contents }
if (!isRange(range)) throw malformedResponse('LSP hover result contained a malformed range')
return { contents, range: toRange(range) }
}
/** Render the three `Hover.contents` encodings into one string (input is untrusted wire data). */
function renderHoverContents(contents: unknown): string {
if (contents === null || contents === undefined) {
throw new Error('LSP hover result had no contents')
throw malformedResponse('LSP hover result had no contents')
}
if (typeof contents === 'string') return contents
if (Array.isArray(contents)) {
return contents.map(renderMarkedString).join('\n\n')
return contents.map((value) => {
if (isMarkedString(value)) return renderMarkedString(value)
throw malformedResponse('LSP hover contents contained a malformed MarkedString')
}).join('\n\n')
}
if (typeof contents !== 'object') {
throw new Error('LSP hover contents were not MarkupContent, MarkedString, or an array')
throw malformedResponse('LSP hover contents were not MarkupContent, MarkedString, or an array')
}
const record = contents as Record<string, unknown>
if (record.kind === 'markdown' || record.kind === 'plaintext') {
return typeof record.value === 'string' ? record.value : ''
if (typeof record.value !== 'string') {
throw malformedResponse('LSP hover MarkupContent value was not a string')
}
return record.value
}
if (typeof record.language === 'string' && typeof record.value === 'string') {
return renderMarkedString({ language: record.language, value: record.value })
}
throw new Error('LSP hover contents were not MarkupContent, MarkedString, or an array')
throw malformedResponse('LSP hover contents were not MarkupContent, MarkedString, or an array')
}
/** Whether an untrusted value is either form of `MarkedString`. */
function isMarkedString(value: unknown): value is WireMarkedString {
if (typeof value === 'string') return true
if (value === null || typeof value !== 'object') return false
const record = value as Record<string, unknown>
return typeof record.language === 'string' && typeof record.value === 'string'
}
/** Create the stable structured error used for malformed server result payloads. */
function malformedResponse(message: string): LspError {
return new LspError(message, 'LSP_MALFORMED_RESPONSE')
}

View File

@@ -18,9 +18,7 @@ const pkgDir = fileURLToPath(new URL('..', import.meta.url))
const seamLib = join(pkgDir, '../lsp/lib/index.js')
const built = existsSync(join(pkgDir, 'lib/index.js')) && existsSync(seamLib)
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
let root: string
let ws: string
@@ -49,13 +47,13 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
servers: {
fake: {
command: ${JSON.stringify(process.execPath)},
args: ['--import', ${JSON.stringify(tsxLoader)}, ${JSON.stringify(fixtureServer)}],
env: { TSX_TSCONFIG_PATH: ${JSON.stringify(repoTsconfig)}, LSP_FAKE_DEF: ${JSON.stringify(location)} },
args: [${JSON.stringify(fixtureServer)}],
env: { LSP_FAKE_DEF: ${JSON.stringify(location)} },
extensionToLanguage: { '.ts': 'typescript' },
},
},
})
const result = await ctx.lsp.query({ operation: 'definition', filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ${JSON.stringify(ws)} })
const result = await ctx.lsp.query({ operation: 'goToDefinition', filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ${JSON.stringify(ws)} })
console.log(JSON.stringify(result))
await ctx.fiber.dispose()
`

View File

@@ -2,9 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import { fileURLToPath } from 'node:url'
import { LspConnection } from '@deepseek-ai/dsh-lsp-local'
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
/** A recorded server→client request the test's handler saw. */
interface SeenRequest { method: string; params: unknown }
@@ -27,9 +25,9 @@ function connect(
): LspConnection {
const conn = new LspConnection({
command: process.execPath,
args: ['--import', tsxLoader, fixtureServer],
args: [fixtureServer],
cwd: process.cwd(),
env: { ...process.env as Record<string, string>, TSX_TSCONFIG_PATH: repoTsconfig, ...env },
env: { ...process.env as Record<string, string>, ...env },
maxMessageBytes: 16_000_000,
maxStderrBytes: 100_000,
configuration: { setting: 42 },
@@ -69,7 +67,7 @@ describe('LspConnection', () => {
seen,
)
await conn.request('initialize', { capabilities: {} })
conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } })
await conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } })
await waitFor(() => seen.some(s => s.method === 'workspace/configuration'))
expect(seen[0]?.method).toBe('workspace/configuration')
})
@@ -77,7 +75,7 @@ describe('LspConnection', () => {
it('drops a server→client notification without replying', async () => {
const conn = connect({ LSP_FAKE_ON_OPEN: 'notification' })
await conn.request('initialize', { capabilities: {} })
conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } })
await conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } })
// No throw and the connection stays usable.
await expect(conn.request('textDocument/hover', {})).resolves.toBeDefined()
})
@@ -90,7 +88,7 @@ describe('LspConnection', () => {
seen,
)
await conn.request('initialize', { capabilities: {} })
conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } })
await conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } })
await waitFor(() => seen.some(s => s.method === 'workspace/applyEdit'))
// The connection remains healthy after emitting the error response.
await expect(conn.request('textDocument/hover', {})).resolves.toBeDefined()
@@ -211,6 +209,15 @@ describe('LspConnection edge behavior', () => {
await expect(conn.request('initialize', {})).rejects.toThrow(/exited|closed/)
})
it('rejects a pending request when child stdin closes but the process stays alive', async () => {
const conn = connectScript('require("node:fs").closeSync(0); setInterval(()=>{}, 1000)')
await new Promise<void>(resolve => setTimeout(resolve, 100))
const timeout = new Promise<never>((_resolve, reject) => {
setTimeout(() => { reject(new Error('request timed out')) }, 1000)
})
await expect(Promise.race([conn.request('initialize', {}), timeout])).rejects.not.toThrow(/timed out/)
})
it('ignores a frame that is neither a valid request nor a numeric-id response', async () => {
// A frame with a string id and no method: not dispatchable; the client must ignore it and still
// answer our real request.

View File

@@ -12,6 +12,9 @@
* - 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_REPLY_DELAY_MS: delays each textDocument/* response by this many milliseconds.
* - LSP_FAKE_OPEN_MARKER: appends each didOpen document text as one JSON line to this path.
* - LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: "1" closes fd 0 before sending the first query response.
* - LSP_FAKE_EXIT_DELAY_MS / LSP_FAKE_EXIT_MARKER: delay protocol exit and record exit/termination.
* - LSP_FAKE_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
@@ -19,10 +22,10 @@
* - LSP_FAKE_ERROR: "1" answers textDocument/* requests with a JSON-RPC error response.
* - LSP_FAKE_GARBAGE: "1" emits an unframed garbage byte before the initialize reply.
*
* Run: node --import tsx fixture-server.ts
* Run: node fixture-server.ts (Node's erasable TypeScript syntax support).
*/
import { appendFileSync } from 'node:fs'
import { appendFileSync, closeSync } 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
@@ -30,6 +33,9 @@ const extraCaps: unknown = process.env.LSP_FAKE_CAPS !== undefined ? JSON.parse(
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 replyDelayMs = Number(process.env.LSP_FAKE_REPLY_DELAY_MS ?? 0)
const openMarker = process.env.LSP_FAKE_OPEN_MARKER
const closeStdinAfterReply = process.env.LSP_FAKE_CLOSE_STDIN_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'
@@ -124,17 +130,29 @@ function handle(message: { id?: number; method?: string; params?: unknown; resul
}
if (method === 'textDocument/didOpen') {
if (crashOnOpen) process.exit(1)
if (openMarker !== undefined) {
const params = message.params as { textDocument?: { text?: unknown } } | undefined
appendFileSync(openMarker, `${JSON.stringify(params?.textDocument?.text)}\n`)
}
if (onOpen !== undefined) emitServerRequest(onOpen)
return
}
if (method === 'textDocument/didClose' || method === 'initialized') return
if (method?.startsWith('textDocument/')) {
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)
const reply = (): void => {
if (closeStdinAfterReply) closeSync(0)
if (errorReply) {
send({ id, error: { code: -32000, message: 'server refused the request' } })
} else {
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)
}
if (replyDelayMs > 0) setTimeout(reply, replyDelayMs)
else reply()
return
}
// Unknown request with an id: answer null so the client never stalls.
@@ -172,3 +190,4 @@ function send(message: Record<string, unknown>): void {
// Keep the event loop alive.
process.stdin.resume()
if (closeStdinAfterReply) setInterval(() => {}, 1000)

View File

@@ -3,8 +3,13 @@ import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { realpath } from 'node:fs/promises'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { deadline } from '@deepseek-ai/dsh-timeout'
import { canonicalizeWorkspace, readHostSource } from '@deepseek-ai/dsh-lsp-local'
const execFileAsync = promisify(execFile)
let root: string
let ws: string
@@ -87,6 +92,19 @@ describe('readHostSource', () => {
await expect(readHostSource('dir', ws, BIG)).rejects.toThrow(/not a regular file/)
})
it('rejects a FIFO with no writer without blocking in open', async () => {
const fifo = join(ws, 'pipe.ts')
await execFileAsync('mkfifo', [fifo])
using d = deadline(undefined, 1000, 'FIFO_READ_TIMEOUT')
await expect(readHostSource('pipe.ts', ws, BIG, d.signal)).rejects.toThrow(/not a regular file/)
})
it('honors a pre-aborted source read before filesystem work', async () => {
const controller = new AbortController()
controller.abort(new Error('source read cancelled'))
await expect(readHostSource('missing.ts', ws, BIG, controller.signal)).rejects.toThrow(/source read cancelled/)
})
it('treats the workspace root itself as inside, then rejects it as non-regular', async () => {
// filePath '.' canonicalizes to the workspace dir: isInside's identity branch is taken, and the
// directory then fails the regular-file check.

View File

@@ -7,9 +7,7 @@ import { LspInstance, readHostSource } from '@deepseek-ai/dsh-lsp-local'
import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts'
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))
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
let root: string
let ws: string
@@ -31,9 +29,9 @@ afterEach(async () => {
function makeInstance(env: Record<string, string> = {}, overrides: Partial<InstanceSpec> = {}): LspInstance {
const instance = new LspInstance({
command: process.execPath,
args: ['--import', tsxLoader, fixtureServer],
args: [fixtureServer],
cwd: ws,
env: { ...process.env as Record<string, string>, TSX_TSCONFIG_PATH: repoTsconfig, ...env },
env: { ...process.env as Record<string, string>, ...env },
configuration: { setting: 42 },
initializationOptions: { init: true },
maxMessageBytes: 16_000_000,
@@ -46,12 +44,12 @@ function makeInstance(env: Record<string, string> = {}, overrides: Partial<Insta
return instance
}
function query(operation: LspProviderQuery['operation'] = 'definition'): LspProviderQuery {
function query(operation: LspProviderQuery['operation'] = 'goToDefinition'): LspProviderQuery {
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> {
async function run(instance: LspInstance, operation: LspProviderQuery['operation'] = 'goToDefinition', signal?: AbortSignal): Promise<LspQueryResult> {
const source = await readHostSource('a.ts', ws, 4_000_000)
return instance.query(query(operation), source, signal)
}
@@ -91,43 +89,43 @@ 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(run(instance, 'definition')).resolves.toMatchObject({ kind: 'locations' })
await expect(run(instance, 'goToDefinition')).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(run(instance, 'definition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
await expect(run(instance, 'goToDefinition')).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: [], resolvedWorkspaceRoot: ws })
await expect(run(instance, 'goToDefinition')).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: [], resolvedWorkspaceRoot: ws })
await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
})
})
describe('LspInstance query and abort', () => {
it('sends includeDeclaration for references', async () => {
const instance = makeInstance({ LSP_FAKE_REFS: JSON.stringify([JSON.parse(locJson())]) })
await expect(run(instance, 'references')).resolves.toMatchObject({ kind: 'locations' })
await expect(run(instance, 'findReferences')).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(run(instance, 'definition', controller.signal)).rejects.toThrow(/pre-abort/)
await expect(run(instance, 'goToDefinition', 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 = run(instance, 'definition', controller.signal)
const pending = run(instance, 'goToDefinition', controller.signal)
await new Promise<void>(resolve => setTimeout(resolve, 300))
controller.abort(new Error('mid-flight'))
await expect(pending).rejects.toThrow(/mid-flight/)
@@ -138,7 +136,7 @@ describe('LspInstance query and abort', () => {
// 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)
const pending = run(instance, 'goToDefinition', controller.signal)
await new Promise<void>(resolve => setTimeout(resolve, 300))
controller.abort(new Error('mid-flight'))
await expect(pending).rejects.toThrow(/mid-flight/)
@@ -159,7 +157,7 @@ describe('LspInstance query and abort', () => {
+ '}});'
const instance = scriptInstance(script, { killGraceMs: 2_000 })
const controller = new AbortController()
const pending = run(instance, 'definition', controller.signal)
const pending = run(instance, 'goToDefinition', controller.signal)
await new Promise<void>(resolve => setTimeout(resolve, 300))
controller.abort(new Error('mid-flight'))
await expect(pending).rejects.toThrow(/mid-flight/)
@@ -173,7 +171,7 @@ describe('LspInstance query and abort', () => {
// 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)
const pending = run(instance, 'goToDefinition', controller.signal)
await new Promise<void>(resolve => setTimeout(resolve, 150))
controller.abort(new Error('handshake-abort'))
await expect(pending).rejects.toThrow(/handshake-abort/)
@@ -182,7 +180,7 @@ describe('LspInstance query and abort', () => {
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(run(instance, 'definition')).rejects.toThrow(/does not support definition/)
await expect(run(instance, 'goToDefinition')).rejects.toThrow(/does not support goToDefinition/)
})
it('propagates a server error response even when a signal is supplied (not an abort)', async () => {
@@ -190,7 +188,20 @@ describe('LspInstance query and abort', () => {
// without treating it as an abort.
const instance = makeInstance({ LSP_FAKE_ERROR: '1' })
const controller = new AbortController()
await expect(run(instance, 'definition', controller.signal)).rejects.toThrow(/server refused/)
await expect(run(instance, 'goToDefinition', controller.signal)).rejects.toThrow(/server refused/)
})
it('keeps a settled result but awaits teardown when didClose cannot be written', async () => {
const instance = makeInstance({
LSP_FAKE_DEF: 'null',
LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: '1',
}, { shutdownTimeoutMs: 100, killGraceMs: 100 })
await expect(run(instance, 'goToDefinition')).resolves.toEqual({
kind: 'locations',
locations: [],
resolvedWorkspaceRoot: ws,
})
expect(instance.dead).toBe(true)
})
})
@@ -202,28 +213,28 @@ describe('LspInstance disposal', () => {
LSP_FAKE_EXIT_DELAY_MS: '75',
LSP_FAKE_EXIT_MARKER: marker,
}, { shutdownTimeoutMs: 500 })
await run(instance, 'definition')
await run(instance, 'goToDefinition')
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')
await run(instance, 'goToDefinition')
await instance.dispose()
await expect(instance.dispose()).resolves.toBeUndefined()
})
it('rejects a query after disposal', async () => {
const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
await run(instance, 'definition')
await run(instance, 'goToDefinition')
await instance.dispose()
await expect(run(instance, 'definition')).rejects.toThrow(/disposed/)
await expect(run(instance, 'goToDefinition')).rejects.toThrow(expect.objectContaining({ code: 'LSP_DISPOSED' }))
})
it('reports dead after the process closes', async () => {
const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
await run(instance, 'definition')
await run(instance, 'goToDefinition')
await instance.dispose()
expect(instance.dead).toBe(true)
})
@@ -232,7 +243,7 @@ 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 run(instance, 'definition')
await run(instance, 'goToDefinition')
await expect(instance.dispose()).resolves.toBeUndefined()
})
@@ -244,7 +255,7 @@ describe('LspInstance disposal', () => {
+ `writeFileSync(${JSON.stringify(marker)},String(helper.pid));`
+ RESPONDING_SERVER
const instance = scriptInstance(script, { shutdownTimeoutMs: 100, killGraceMs: 100 })
await run(instance, 'definition')
await run(instance, 'goToDefinition')
const helperPid = Number(await readFile(marker, 'utf8'))
try {
const first = instance.dispose()
@@ -259,7 +270,7 @@ describe('LspInstance disposal', () => {
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 = run(instance, 'definition', controller.signal)
const pending = run(instance, 'goToDefinition', controller.signal)
await new Promise<void>(resolve => setTimeout(resolve, 200))
controller.abort('a string reason, not an Error')
await expect(pending).rejects.toThrow(/aborted/)

View File

@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -10,9 +10,7 @@ import { deadline } from '@deepseek-ai/dsh-timeout'
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
import type { LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local'
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
let root: string
let ws: string
@@ -32,8 +30,8 @@ afterEach(async () => {
function fakeServer(fakeEnv: Record<string, string> = {}, overrides: Partial<LspLocalServerConfig> = {}): LspLocalServerConfig {
return {
command: process.execPath,
args: ['--import', tsxLoader, fixtureServer],
env: { TSX_TSCONFIG_PATH: repoTsconfig, ...fakeEnv },
args: [fixtureServer],
env: { ...fakeEnv },
extensionToLanguage: { '.ts': 'typescript' },
...overrides,
}
@@ -79,7 +77,7 @@ describe('lsp-local end to end over a fake server', () => {
it('resolves definition to normalized locations', async () => {
const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) })
const result = await ctx.lsp.query(query('definition'))
const result = await ctx.lsp.query(query('goToDefinition'))
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 } } }],
@@ -91,14 +89,14 @@ describe('lsp-local end to end over a fake server', () => {
it('maps a LocationLink for implementation', async () => {
const link = { targetUri: pathToFileURL(join(ws, 'a.ts')).href, targetSelectionRange: { start: { line: 1, character: 0 }, end: { line: 1, character: 2 } } }
const ctx = await mount({ LSP_FAKE_IMPL: JSON.stringify([link]) })
const result = await ctx.lsp.query(query('implementation'))
const result = await ctx.lsp.query(query('goToImplementation'))
expect(result).toMatchObject({ kind: 'locations', locations: [{ range: { start: { line: 1, character: 0 } } }] })
await ctx.fiber.dispose()
})
it('returns references (server includes the declaration)', async () => {
const ctx = await mount({ LSP_FAKE_REFS: JSON.stringify([locationJson(0), locationJson(1)]) })
const result = await ctx.lsp.query(query('references'))
const result = await ctx.lsp.query(query('findReferences'))
expect(result).toMatchObject({ kind: 'locations' })
if (result.kind !== 'locations') throw new Error('expected locations')
expect(result.locations).toHaveLength(2)
@@ -114,7 +112,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: [], resolvedWorkspaceRoot: ws })
expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
await ctx.fiber.dispose()
})
@@ -126,7 +124,7 @@ describe('lsp-local end to end over a fake server', () => {
it('rejects a non-utf-16 position encoding at initialize', async () => {
const ctx = await mount({ LSP_FAKE_ENCODING: 'utf-8', LSP_FAKE_DEF: 'null' })
await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/unsupported position encoding/)
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/)
await ctx.fiber.dispose()
})
@@ -134,21 +132,21 @@ describe('lsp-local end to end over a fake server', () => {
// A utf-8 server makes `initialize` reject; the instance must be torn down (not left with a
// permanently-rejecting `ready`) so a later query starts a fresh process rather than reusing it.
const ctx = await mount({ LSP_FAKE_ENCODING: 'utf-8', LSP_FAKE_DEF: 'null' })
await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/unsupported position encoding/)
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/)
// A second query must also fail the same way (fresh instance), and must NOT hang on a poisoned one.
await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/unsupported position encoding/)
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/)
await ctx.fiber.dispose()
})
it('rejects a server without transient-open sync (None)', async () => {
const ctx = await mount({ LSP_FAKE_SYNC: '0', LSP_FAKE_DEF: 'null' })
await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/transient textDocument\/didOpen/)
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/transient textDocument\/didOpen/)
await ctx.fiber.dispose()
})
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: [], resolvedWorkspaceRoot: ws })
expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
await ctx.fiber.dispose()
})
@@ -162,25 +160,44 @@ describe('lsp-local end to end over a fake server', () => {
const outside = join(root, 'out.ts')
await writeFile(outside, 'x')
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
await expect(ctx.lsp.query({ ...query('definition'), filePath: outside })).rejects.toThrow(/outside the workspace/)
await expect(ctx.lsp.query({ ...query('goToDefinition'), filePath: outside })).rejects.toThrow(/outside the workspace/)
await ctx.fiber.dispose()
})
it('serializes queries through one instance and runs them in order', async () => {
const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) })
const results = await Promise.all([
ctx.lsp.query(query('definition')),
ctx.lsp.query(query('definition')),
ctx.lsp.query(query('definition')),
ctx.lsp.query(query('goToDefinition')),
ctx.lsp.query(query('goToDefinition')),
ctx.lsp.query(query('goToDefinition')),
])
for (const result of results) expect(result).toMatchObject({ kind: 'locations' })
await ctx.fiber.dispose()
})
it('reads a queued query source only when its lifecycle starts', async () => {
const marker = join(root, 'opened.jsonl')
const ctx = await mount({
LSP_FAKE_DEF: 'null',
LSP_FAKE_REPLY_DELAY_MS: '300',
LSP_FAKE_OPEN_MARKER: marker,
})
const first = ctx.lsp.query(query('goToDefinition'))
await waitFor(async () => (await markerLines(marker)).length === 1)
const second = ctx.lsp.query(query('goToDefinition'))
await writeFile(join(ws, 'a.ts'), 'const changed = 2\n')
await Promise.all([first, second])
expect(await markerLines(marker)).toEqual([
'const x = 1\nconst y = x\n',
'const changed = 2\n',
])
await ctx.fiber.dispose()
})
it('aborts an in-flight query when the signal fires', async () => {
const ctx = await mount({ LSP_FAKE_HANG: '1' })
const controller = new AbortController()
const pending = ctx.lsp.query(query('definition'), controller.signal)
const pending = ctx.lsp.query(query('goToDefinition'), controller.signal)
controller.abort(new Error('caller cancelled'))
await expect(pending).rejects.toThrow(/cancelled/)
await ctx.fiber.dispose()
@@ -190,7 +207,7 @@ describe('lsp-local end to end over a fake server', () => {
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
const controller = new AbortController()
controller.abort(new Error('pre-aborted'))
await expect(ctx.lsp.query(query('definition'), controller.signal)).rejects.toThrow(/pre-aborted/)
await expect(ctx.lsp.query(query('goToDefinition'), controller.signal)).rejects.toThrow(/pre-aborted/)
await ctx.fiber.dispose()
})
@@ -201,22 +218,22 @@ describe('lsp-local end to end over a fake server', () => {
command: process.execPath,
args: ['-e', 'process.stderr.write("FATAL: boom\\n"); setTimeout(()=>process.exit(1), 50)'],
})
await expect(ctx.lsp.query(query('definition'))).rejects.toThrow(/FATAL: boom/)
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/FATAL: boom/)
await ctx.fiber.dispose()
})
it('classifies a timeout deadline as the abort reason', async () => {
const ctx = await mount({ LSP_FAKE_HANG: '1' })
using d = deadline(undefined, 50, 'TEST_TIMEOUT')
await expect(ctx.lsp.query(query('definition'), d.signal)).rejects.toThrow(/TEST_TIMEOUT/)
await expect(ctx.lsp.query(query('goToDefinition'), d.signal)).rejects.toThrow(/TEST_TIMEOUT/)
await ctx.fiber.dispose()
})
it('fails the active query when the server crashes on open, and replaces it next query', async () => {
const ctx = await mount({ LSP_FAKE_CRASH_ON_OPEN: '1', LSP_FAKE_DEF: 'null' }, { shutdownTimeoutMs: 100, killGraceMs: 100 })
await expect(ctx.lsp.query(query('definition'))).rejects.toThrow()
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow()
// A later query starts a fresh process; still crashes, but proves the slot was replaced (no hang).
await expect(ctx.lsp.query(query('definition'))).rejects.toThrow()
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow()
await ctx.fiber.dispose()
})
@@ -225,10 +242,10 @@ describe('lsp-local end to end over a fake server', () => {
// 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' })
expect(await ctx.lsp.query(query('goToDefinition'))).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' })
expect(await ctx.lsp.query(query('goToDefinition'))).toMatchObject({ kind: 'locations' })
await ctx.fiber.dispose()
})
@@ -237,11 +254,11 @@ describe('lsp-local end to end over a fake server', () => {
// 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)
const pending = ctx.lsp.query(query('goToDefinition'), 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 })
expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
await ctx.fiber.dispose()
})
@@ -251,8 +268,8 @@ describe('lsp-local end to end over a fake server', () => {
await writeFile(join(ws2, 'a.ts'), 'const z = 2\n')
const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) })
const [r1, r2] = await Promise.all([
ctx.lsp.query({ ...query('definition'), workspaceRoot: ws }),
ctx.lsp.query({ ...query('definition'), workspaceRoot: ws2 }),
ctx.lsp.query({ ...query('goToDefinition'), workspaceRoot: ws }),
ctx.lsp.query({ ...query('goToDefinition'), workspaceRoot: ws2 }),
])
expect(r1).toMatchObject({ kind: 'locations' })
expect(r2).toMatchObject({ kind: 'locations' })
@@ -261,7 +278,7 @@ describe('lsp-local end to end over a fake server', () => {
it('disposes cleanly, terminating a server that ignores shutdown', async () => {
const ctx = await mount({ LSP_FAKE_NO_SHUTDOWN: '1', LSP_FAKE_DEF: 'null' }, { killGraceMs: 100, shutdownTimeoutMs: 100 })
await ctx.lsp.query(query('definition'))
await ctx.lsp.query(query('goToDefinition'))
await expect(ctx.fiber.dispose()).resolves.toBeUndefined()
})
@@ -280,3 +297,23 @@ describe('lsp-local end to end over a fake server', () => {
await ctx.fiber.dispose()
})
})
/** Read the fixture's JSON-lines didOpen marker, returning no entries before it exists. */
async function markerLines(path: string): Promise<string[]> {
try {
const text = await readFile(path, 'utf8')
return text.trim().split('\n').filter(Boolean).map(line => JSON.parse(line) as string)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
throw error
}
}
/** Poll an asynchronous condition until it succeeds or the test-local deadline expires. */
async function waitFor(condition: () => Promise<boolean>, timeoutMs = 3000): Promise<void> {
const started = Date.now()
while (!await condition()) {
if (Date.now() - started > timeoutMs) throw new Error('waitFor timed out')
await new Promise<void>(resolve => setTimeout(resolve, 10))
}
}

View File

@@ -6,6 +6,7 @@ import { Context } from 'cordis'
import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp'
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
import type { Config, LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
let root: string
let ws: string
@@ -22,7 +23,7 @@ afterEach(async () => {
})
function query(): LspQueryRequest {
return { operation: 'definition', filePath: 'a.ts', position: { line: 0, character: 0 }, workspaceRoot: ws }
return { operation: 'goToDefinition', filePath: 'a.ts', position: { line: 0, character: 0 }, workspaceRoot: ws }
}
/** Wrap one server entry in the plugin's named server table. */
@@ -91,6 +92,30 @@ describe('lsp-local provider resolution', () => {
await ctx.fiber.dispose()
})
it('rejects a nonpositive byte cap at load', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await expect(ctx.plugin(LspLocal, config('bad-cap', {
command: process.execPath,
args: ['-e', ''],
extensionToLanguage: { '.ts': 'typescript' },
maxDocumentBytes: 0,
}))).rejects.toThrow(/servers\.bad-cap\.maxDocumentBytes must be a positive integer/)
await ctx.fiber.dispose()
})
it.each(['shutdownTimeoutMs', 'killGraceMs'] as const)('rejects %s above Node timer range at load', async (name) => {
const ctx = new Context()
await ctx.plugin(Lsp)
await expect(ctx.plugin(LspLocal, config('bad-timer', {
command: process.execPath,
args: ['-e', ''],
extensionToLanguage: { '.ts': 'typescript' },
[name]: MAX_TIMER_DELAY_MS + 1,
}))).rejects.toThrow(new RegExp(`servers\\.bad-timer\\.${name}`))
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')

View File

@@ -13,9 +13,9 @@ const RANGE = { start: { line: 1, character: 2 }, end: { line: 1, character: 5 }
describe('requestMethod', () => {
it('maps each operation to its textDocument request', () => {
expect(requestMethod('definition')).toBe('textDocument/definition')
expect(requestMethod('references')).toBe('textDocument/references')
expect(requestMethod('implementation')).toBe('textDocument/implementation')
expect(requestMethod('goToDefinition')).toBe('textDocument/definition')
expect(requestMethod('findReferences')).toBe('textDocument/references')
expect(requestMethod('goToImplementation')).toBe('textDocument/implementation')
expect(requestMethod('hover')).toBe('textDocument/hover')
})
})
@@ -27,9 +27,9 @@ describe('supportsOperation', () => {
referencesProvider: { workDoneProgress: true },
implementationProvider: false,
}
expect(supportsOperation(caps, 'definition')).toBe(true)
expect(supportsOperation(caps, 'references')).toBe(true)
expect(supportsOperation(caps, 'implementation')).toBe(false)
expect(supportsOperation(caps, 'goToDefinition')).toBe(true)
expect(supportsOperation(caps, 'findReferences')).toBe(true)
expect(supportsOperation(caps, 'goToImplementation')).toBe(false)
expect(supportsOperation(caps, 'hover')).toBe(false)
})
})
@@ -66,9 +66,9 @@ describe('negotiatePositionEncoding', () => {
})
describe('normalizeLocations', () => {
it('returns empty for null and undefined', () => {
it('returns empty only for the protocol no-result value null', () => {
expect(normalizeLocations(null)).toEqual([])
expect(normalizeLocations(undefined)).toEqual([])
expect(() => normalizeLocations(undefined)).toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
})
it('maps a single Location', () => {
@@ -100,6 +100,13 @@ describe('normalizeLocations', () => {
it('rejects a Location whose range positions are malformed', () => {
expect(() => normalizeLocations([{ uri: 'file:///a', range: { start: null, end: null } }])).toThrow(/neither a Location/)
})
it('rejects negative and fractional position coordinates', () => {
expect(() => normalizeLocations([{ uri: 'file:///a', range: { start: { line: -1, character: 0 }, end: RANGE.end } }]))
.toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
expect(() => normalizeLocations([{ uri: 'file:///a', range: { start: RANGE.start, end: { line: 1.5, character: 5 } } }]))
.toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
})
})
describe('normalizeHover', () => {
@@ -107,6 +114,10 @@ describe('normalizeHover', () => {
expect(normalizeHover(null)).toBeNull()
})
it('rejects a missing hover result', () => {
expect(() => normalizeHover(undefined)).toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
})
it('reads MarkupContent value and keeps a range', () => {
expect(normalizeHover({ contents: { kind: 'markdown', value: '# H' }, range: RANGE }))
.toEqual({ contents: '# H', range: RANGE })
@@ -130,8 +141,9 @@ describe('normalizeHover', () => {
expect(normalizeHover({ contents: { kind: 'plaintext', value: '' } })).toBeNull()
})
it('treats a MarkupContent with a non-string value as empty (null)', () => {
expect(normalizeHover({ contents: { kind: 'markdown', value: 42 } })).toBeNull()
it('rejects a MarkupContent with a non-string value', () => {
expect(() => normalizeHover({ contents: { kind: 'markdown', value: 42 } }))
.toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
})
it('rejects a non-object payload', () => {
@@ -143,11 +155,19 @@ describe('normalizeHover', () => {
expect(() => normalizeHover({ contents: 42 })).toThrow(/were not MarkupContent/)
})
it('rejects a malformed MarkedString array member', () => {
expect(() => normalizeHover({ contents: ['ok', { language: 'ts', value: 42 }] }))
.toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
expect(() => normalizeHover({ contents: [null] }))
.toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
})
it('rejects a hover with no contents field', () => {
expect(() => normalizeHover({ range: RANGE })).toThrow(/no contents/)
})
it('ignores a malformed range and keeps the contents', () => {
expect(normalizeHover({ contents: 'x', range: { start: { line: 1 } } })).toEqual({ contents: 'x' })
it('rejects a malformed range instead of silently dropping it', () => {
expect(() => normalizeHover({ contents: 'x', range: { start: { line: 1 } } }))
.toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
})
})

View File

@@ -81,7 +81,7 @@ function locations(result: LspQueryResult): readonly { uri: string }[] {
describe('real typescript-language-server', () => {
it('resolves the definition of a call site to its declaration', async () => {
// `export const text = describe(c)` (line 15): `describe` begins at column 21.
const result = await ctx.lsp.query(at('definition', 15, 22))
const result = await ctx.lsp.query(at('goToDefinition', 15, 22))
const locs = locations(result)
expect(locs.length).toBeGreaterThanOrEqual(1)
expect(locs.some(l => l.uri.endsWith('shapes.ts'))).toBe(true)
@@ -89,7 +89,7 @@ describe('real typescript-language-server', () => {
it('finds references to a symbol including its declaration', async () => {
// References to `describe` from its declaration (line 10, col 17).
const result = await ctx.lsp.query(at('references', 10, 17))
const result = await ctx.lsp.query(at('findReferences', 10, 17))
const locs = locations(result)
// At least the declaration plus the call site.
expect(locs.length).toBeGreaterThanOrEqual(2)
@@ -97,7 +97,7 @@ describe('real typescript-language-server', () => {
it('resolves implementations of an interface', async () => {
// Implementations of `Shape` (line 1, col 18) → Circle.
const result = await ctx.lsp.query(at('implementation', 1, 18))
const result = await ctx.lsp.query(at('goToImplementation', 1, 18))
const locs = locations(result)
expect(locs.length).toBeGreaterThanOrEqual(1)
}, 60_000)

View File

@@ -10,7 +10,7 @@ This package is the interface third of the LSP capability:
| `@deepseek-ai/dsh-lsp-local` | a generic local backend that registers configured stdio language-server providers |
| `@deepseek-ai/dsh-tool-lsp` | the model-facing `lsp` tool over `ctx.lsp` |
The seam exposes exactly four semantic operations — `definition`, `references`, `implementation`, `hover` — and no generic JSON-RPC escape hatch, so no protocol payload or unreviewed command/mutation reaches a provider through `ctx.lsp`.
The seam exposes exactly four semantic operations — `goToDefinition`, `findReferences`, `goToImplementation`, `hover` — and no generic JSON-RPC escape hatch, so no protocol payload or unreviewed command/mutation reaches a provider through `ctx.lsp`.
## Service API (`ctx.lsp`)
@@ -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; 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.
`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. `findReferences` 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, including `LSP_DISPOSED` and `LSP_MALFORMED_RESPONSE`.
## Model Experience

View File

@@ -1,6 +1,7 @@
/**
* The LSP capability seam (`ctx.lsp`): a language-server provider registry and per-query,
* order-independent selection over normalized definition/references/implementation/hover queries.
* order-independent selection over normalized goToDefinition/findReferences/goToImplementation/
* hover queries.
*
* A provider reserves a branded id and an exclusive set of file extensions atomically:
* {@link Lsp.registerProvider} validates and conflict-checks everything before mutating, so an
@@ -42,8 +43,9 @@ declare module 'cordis' {
/**
* Structured LSP failure. Extends {@link HarnessError} with a stable `code`
* (`LSP_INVALID_PROVIDER`, `LSP_CONFLICT`, `LSP_UNAVAILABLE`, `LSP_UNSUPPORTED_OPERATION`, …) that
* callers route on instead of parsing `message`.
* (`LSP_INVALID_PROVIDER`, `LSP_CONFLICT`, `LSP_UNAVAILABLE`, `LSP_DISPOSED`,
* `LSP_UNSUPPORTED_OPERATION`, `LSP_MALFORMED_RESPONSE`, …) that callers route on instead of
* parsing `message`.
*/
export class LspError extends HarnessError {}

View File

@@ -14,7 +14,7 @@ import type { LspProviderId } from './brand.ts'
* compile-enforced change across the seam, providers, and the tool. Symbols and call hierarchy are
* deliberately deferred (they need different schemas).
*/
export type LspOperation = 'definition' | 'references' | 'implementation' | 'hover'
export type LspOperation = 'goToDefinition' | 'findReferences' | 'goToImplementation' | 'hover'
/** A zero-based UTF-16 cursor coordinate, matching the LSP wire convention. */
export interface LspPosition {
@@ -73,9 +73,9 @@ 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 closed result union. Navigation operations (`goToDefinition`, `findReferences`,
* `goToImplementation`) 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
@@ -88,8 +88,9 @@ export type LspQueryResult =
/**
* A language-server backend registered on `ctx.lsp`. Each provider owns a stable {@link
* LspProviderId} and an extension-to-language-id map (lowercase, leading-dot keys). `references`
* always includes declarations — the provider enforces this internally; callers get no flag.
* LspProviderId} and an extension-to-language-id map (lowercase, leading-dot keys).
* `findReferences` always includes declarations — the provider enforces this internally; callers
* get no flag.
*/
export interface LspProvider {
/** Stable provider identity, reserved atomically with the extension mappings. */

View File

@@ -39,7 +39,7 @@ async function mountLsp(): Promise<{ ctx: Context; lsp: Lsp }> {
const hover: LspQueryResult = { kind: 'hover', hover: { contents: 'x' } }
function query(filePath: string, operation: LspProviderQuery['operation'] = 'definition'): Parameters<Lsp['query']>[0] {
function query(filePath: string, operation: LspProviderQuery['operation'] = 'goToDefinition'): Parameters<Lsp['query']>[0] {
return { operation, filePath, position: { line: 0, character: 0 }, workspaceRoot: '/ws' }
}

View File

@@ -6,7 +6,7 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). In
## The tool
`lsp` accepts `operation` (`definition` | `references` | `implementation` | `hover`), `file_path`, `line`, and `character`. `line` and `character` are positive, one-based UTF-16 cursor coordinates; the tool converts them to the seam's zero-based positions and converts rendered locations back. `references` includes declarations so impact analysis does not omit the defining site. Provider, language id, workspace root, limits, timeout, initialization, and executable stay outside model input.
`lsp` accepts `operation` (`goToDefinition` | `findReferences` | `goToImplementation` | `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. `findReferences` 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 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.
@@ -15,7 +15,7 @@ The tool requires the workspace root from the session `header.cwd`, with no fall
| Key | Default | Meaning |
|---|---|---|
| `maxLocations` | `100` | Largest number of rendered locations before an omission marker. |
| `maxHoverChars` | `16000` | Largest hover length in characters, applied after normalization. |
| `maxResultChars` | `16000` | Largest complete rendered result, including truncation metadata. |
| `timeoutMs` | `60000` | Tool-call timeout budget, enforced by `dsh-timeout-policy`; covers the complete queued open/query/close lifecycle and is not model-configurable. |
## Model Experience
@@ -29,7 +29,7 @@ One system-prompt section (order 112) positions LSP as a precision aid with the
##### Verbatim guidance
```markdown
Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references. Positions are one-based line and character (UTF-16) at the cursor; an off-symbol position may return no results. references always includes the declaration.
Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references. Positions are one-based line and character (UTF-16) at the cursor; an off-symbol position may return no results. findReferences always includes the declaration.
```
#### Token effect
@@ -58,11 +58,11 @@ Prefix-stable while the visible tool definition and order are unchanged; registr
#### What the model sees
File-grouped `path:line:character` location lines or normalized hover text, capped by `maxLocations` / `maxHoverChars` with an omission marker when truncated, and distinct `No results.` / `No hover information.` lines for empty results.
File-grouped `path:line:character` location lines or normalized hover text, capped first by `maxLocations` and then by `maxResultChars`; omission and truncation markers are included inside the complete character cap. Empty results use distinct `No results.` / `No hover information.` lines.
#### Token effect
Capped per tool result by the two limits above.
Capped per tool result by `maxResultChars`, with `maxLocations` additionally bounding navigation item count.
#### KV Cache effect

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-tool-lsp",
"description": "Model-facing lsp tool over the DeepSeek Harness LSP capability seam (ctx.lsp) — one read-only tool with definition/references/implementation/hover operations, one-based UTF-16 cursor coordinates, workspace-grouped location rendering, and hover normalization",
"description": "Model-facing lsp tool over the DeepSeek Harness LSP capability seam (ctx.lsp) — one read-only tool with goToDefinition/findReferences/goToImplementation/hover operations, one-based UTF-16 cursor coordinates, bounded location rendering, and hover normalization",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -25,6 +25,7 @@
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-lsp": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
@@ -38,6 +39,7 @@
"@deepseek-ai/dsh-lsp-local": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-timeout-policy": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"

View File

@@ -1,9 +1,10 @@
/**
* Model-facing `lsp` tool over `ctx.lsp`. One read-only tool with four operations
* (`definition`/`references`/`implementation`/`hover`); it converts one-based UTF-16 cursor
* coordinates to the seam's zero-based positions, requires the session workspace with no fallback,
* caps and renders results, and attaches a configurable timeout budget for `dsh-timeout-policy` to
* enforce. It runtime-injects only `tools`, `lsp`, and `systemPrompt` and imports no provider.
* (`goToDefinition`/`findReferences`/`goToImplementation`/`hover`); it converts one-based UTF-16
* cursor coordinates to the seam's zero-based positions, requires the session workspace with no
* fallback, caps and renders results, and attaches a configurable timeout budget for
* `dsh-timeout-policy` to enforce. It runtime-injects only `tools`, `lsp`, and `systemPrompt` and
* imports no provider.
*
* Namespace plugin (named exports, no default export).
* @module @deepseek-ai/dsh-tool-lsp
@@ -12,13 +13,14 @@
import type { Context } from 'cordis'
import z from 'schemastery'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { assertNever, type ContentBlock } from '@deepseek-ai/dsh-llm'
import { LspError } from '@deepseek-ai/dsh-lsp'
import type {} from '@deepseek-ai/dsh-lsp'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import {
DEFAULT_MAX_HOVER_CHARS,
DEFAULT_MAX_LOCATIONS,
DEFAULT_MAX_RESULT_CHARS,
formatHover,
formatLocations,
LSP_OPERATIONS,
@@ -28,8 +30,8 @@ import {
import { sessionCwd } from './session-cwd.ts'
export {
DEFAULT_MAX_HOVER_CHARS,
DEFAULT_MAX_LOCATIONS,
DEFAULT_MAX_RESULT_CHARS,
formatHover,
formatLocations,
LSP_OPERATIONS,
@@ -50,22 +52,22 @@ export const DEFAULT_LSP_TOOL_TIMEOUT_MS = 60_000
/** The stable system-prompt guidance positioning LSP as a precision aid. */
export const LSP_PROMPT_TEXT =
'Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references. Positions are one-based line and character (UTF-16) at the cursor; an off-symbol position may return no results. references always includes the declaration.'
'Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references. Positions are one-based line and character (UTF-16) at the cursor; an off-symbol position may return no results. findReferences always includes the declaration.'
/** Plugin configuration: result caps and the timeout budget. */
export interface Config {
/** Largest number of rendered locations before an omission marker (default 100). */
maxLocations?: number
/** Largest hover length in characters after normalization (default 16000). */
maxHoverChars?: number
/** Largest complete rendered result in characters, including truncation metadata (default 16000). */
maxResultChars?: number
/** Tool-call timeout budget in ms (default 60000). */
timeoutMs?: number
}
export const Config: z<Config> = z.object({
maxLocations: z.number().default(DEFAULT_MAX_LOCATIONS),
maxHoverChars: z.number().default(DEFAULT_MAX_HOVER_CHARS),
timeoutMs: z.number().default(DEFAULT_LSP_TOOL_TIMEOUT_MS),
maxResultChars: z.number().default(DEFAULT_MAX_RESULT_CHARS),
timeoutMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_LSP_TOOL_TIMEOUT_MS),
})
type ResolvedConfig = Required<Config>
@@ -78,21 +80,21 @@ type ResolvedConfig = Required<Config>
export function apply(ctx: Context, config: Config): void {
const resolved = config as ResolvedConfig
assertPositiveInteger('maxLocations', resolved.maxLocations)
assertPositiveInteger('maxHoverChars', resolved.maxHoverChars)
assertPositiveInteger('timeoutMs', resolved.timeoutMs)
assertPositiveInteger('maxResultChars', resolved.maxResultChars)
assertTimer('timeoutMs', resolved.timeoutMs)
ctx.systemPrompt.section({ name: 'tool:lsp', order: 112, text: LSP_PROMPT_TEXT })
ctx.tools.register(defineTool({
name: 'lsp',
description:
'Query a language server for precise code navigation. operation is one of definition, references, implementation, hover. line and character are one-based UTF-16 cursor coordinates. references includes the declaration.',
'Query a language server for precise code navigation. operation is one of goToDefinition, findReferences, goToImplementation, hover. line and character are one-based UTF-16 cursor coordinates. findReferences includes the declaration.',
parameters: {
operation: {
type: 'string',
required: true,
enum: [...LSP_OPERATIONS],
description: 'definition, references, implementation, or hover.',
description: 'goToDefinition, findReferences, goToImplementation, or hover.',
},
file_path: { type: 'string', required: true, description: 'The source file to query, relative to the workspace or absolute.' },
line: { type: 'number', required: true, description: 'One-based line of the cursor.' },
@@ -116,9 +118,12 @@ export function apply(ctx: Context, config: Config): void {
// 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) }]
return [{ type: 'text', text: formatLocations(result.locations, result.resolvedWorkspaceRoot, resolved.maxLocations, resolved.maxResultChars) }]
case 'hover':
return [{ type: 'text', text: formatHover(result.hover, resolved.maxHoverChars) }]
return [{ type: 'text', text: formatHover(result.hover, resolved.maxResultChars) }]
/* v8 ignore next -- exhaustive over the closed LspQueryResult union; unreachable. */
default:
return assertNever(result, 'tool-lsp result')
}
},
presentCall: presentLspCall,
@@ -131,3 +136,10 @@ function assertPositiveInteger(name: string, value: number): void {
throw new Error(`tool-lsp: ${name} must be a positive integer`)
}
}
/** Reject a timer value Node would clamp instead of scheduling as configured. */
function assertTimer(name: string, value: number): void {
if (!Number.isInteger(value) || value < 1 || value > MAX_TIMER_DELAY_MS) {
throw new Error(`tool-lsp: ${name} must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`)
}
}

View File

@@ -1,8 +1,8 @@
/**
* Pure formatting and coordinate conversion for the `lsp` tool: one-based↔zero-based UTF-16 cursor
* conversion, workspace-grouped location rendering with `file:`-URI resolution, hover capping, and
* ACP presentation. No I/O — a UI may call the presenter on live streaming and on replay, so it
* depends only on the tool arguments.
* conversion, workspace-grouped location rendering with `file:`-URI resolution, complete-result
* capping, and ACP presentation. No I/O — a UI may call the presenter on live streaming and on
* replay, so it depends only on the tool arguments.
* @module @deepseek-ai/dsh-tool-lsp/render
*/
@@ -12,13 +12,13 @@ import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import type { LspHover, LspLocation, LspOperation, LspPosition } from '@deepseek-ai/dsh-lsp'
/** The four operations the tool exposes, as a runtime tuple for schema enum + validation. */
export const LSP_OPERATIONS: readonly LspOperation[] = ['definition', 'references', 'implementation', 'hover']
export const LSP_OPERATIONS: readonly LspOperation[] = ['goToDefinition', 'findReferences', 'goToImplementation', 'hover']
/** Default cap on rendered locations before an omission marker is appended. */
export const DEFAULT_MAX_LOCATIONS = 100
/** Default cap on hover characters (applied after normalization) before truncation is marked. */
export const DEFAULT_MAX_HOVER_CHARS = 16_000
/** Default cap on the complete rendered tool result, including truncation metadata. */
export const DEFAULT_MAX_RESULT_CHARS = 16_000
/** Validated `lsp` arguments after coordinate checks. */
export interface LspToolInput {
@@ -75,18 +75,20 @@ function oneBased(value: number, name: string): number {
* Render a locations result grouped by file, converting each zero-based location back to a one-based
* `path:line:character` entry. A `file:` URI inside the workspace becomes a workspace-relative path;
* outside it, an absolute path; a non-`file:` URI is kept verbatim. Applies `maxLocations` and
* appends an omission marker when it truncates.
* appends an omission marker when it truncates by count, then applies the complete result cap.
* @param locations - the seam's locations (possibly empty).
* @param workspaceRoot - the canonical workspace root for relativizing `file:` paths.
* @param maxLocations - the cap before truncation.
* @param maxResultChars - the complete rendered-text cap, including truncation metadata.
* @returns the rendered text; a distinct no-result line when there are none.
*/
export function formatLocations(
locations: readonly LspLocation[],
workspaceRoot: string,
maxLocations: number,
maxResultChars: number,
): string {
if (locations.length === 0) return 'No results.'
if (locations.length === 0) return boundResult('No results.', maxResultChars, 'locations')
const shown = locations.slice(0, maxLocations)
const omitted = locations.length - shown.length
const grouped = new Map<string, string[]>()
@@ -103,20 +105,26 @@ export function formatLocations(
if (omitted > 0) {
lines.push(`${omitted} more location${omitted === 1 ? '' : 's'} omitted (limit ${maxLocations}).`)
}
return lines.join('\n')
return boundResult(lines.join('\n'), maxResultChars, 'locations')
}
/**
* Render a hover result, applying `maxHoverChars` last and marking truncation.
* Render a hover result, applying `maxResultChars` last and keeping its marker within the cap.
* @param hover - the normalized hover, or `null` for no hover.
* @param maxHoverChars - the cap applied after normalization.
* @param maxResultChars - the complete rendered-text cap, including truncation metadata.
* @returns the rendered hover text; a distinct no-result line for `null`.
*/
export function formatHover(hover: LspHover | null, maxHoverChars: number): string {
if (hover === null) return 'No hover information.'
const contents = hover.contents
if (contents.length <= maxHoverChars) return contents
return `${contents.slice(0, maxHoverChars)}\n… hover truncated (limit ${maxHoverChars} characters).`
export function formatHover(hover: LspHover | null, maxResultChars: number): string {
const text = hover === null ? 'No hover information.' : hover.contents
return boundResult(text, maxResultChars, 'hover')
}
/** Bound a complete rendered result, including the truncation notice itself. */
function boundResult(text: string, maxChars: number, label: string): string {
if (text.length <= maxChars) return text
const notice = `\n… ${label} truncated (limit ${maxChars} characters).`
if (notice.length >= maxChars) return notice.slice(0, maxChars)
return `${text.slice(0, maxChars - notice.length)}${notice}`
}
/**

View File

@@ -78,7 +78,7 @@ function call(ctx: Context, args: unknown) {
describe('tool-lsp integration', () => {
it('round-trips a definition query through the real provider and renders a location', async () => {
const ctx = await mount(false)
const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 7 })
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 7 })
expect(result.isError).toBe(false)
expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' })
await ctx.fiber.dispose()
@@ -86,7 +86,7 @@ describe('tool-lsp integration', () => {
it('enforces the TOOL_TIMEOUT budget when the server hangs', async () => {
const ctx = await mount(true, 300)
const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 7 })
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 7 })
expect(result.isError).toBe(true)
expect(result.error?.code).toBe('TOOL_TIMEOUT')
await ctx.fiber.dispose()

View File

@@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest'
import { pathToFileURL } from 'node:url'
import { join } from 'node:path'
import {
DEFAULT_MAX_HOVER_CHARS,
DEFAULT_MAX_LOCATIONS,
DEFAULT_MAX_RESULT_CHARS,
formatHover,
formatLocations,
LSP_OPERATIONS,
@@ -79,52 +79,63 @@ describe('renderUri', () => {
describe('formatLocations', () => {
it('renders a no-result line for an empty list', () => {
expect(formatLocations([], WS, DEFAULT_MAX_LOCATIONS)).toBe('No results.')
expect(formatLocations([], WS, DEFAULT_MAX_LOCATIONS, DEFAULT_MAX_RESULT_CHARS)).toBe('No results.')
})
it('renders one-based path:line:character grouped by file', () => {
const a = pathToFileURL(join(WS, 'a.ts')).href
const text = formatLocations([loc(a, 0, 0), loc(a, 4, 2)], WS, DEFAULT_MAX_LOCATIONS)
const text = formatLocations([loc(a, 0, 0), loc(a, 4, 2)], WS, DEFAULT_MAX_LOCATIONS, DEFAULT_MAX_RESULT_CHARS)
expect(text).toBe('a.ts:1:1\na.ts:5:3')
})
it('caps at maxLocations and marks the omission', () => {
const a = pathToFileURL(join(WS, 'a.ts')).href
const many = Array.from({ length: 5 }, (_, i) => loc(a, i))
const text = formatLocations(many, WS, 2)
const text = formatLocations(many, WS, 2, DEFAULT_MAX_RESULT_CHARS)
expect(text).toContain('a.ts:1:1')
expect(text).toContain('3 more locations omitted (limit 2).')
})
it('uses the singular omission marker for exactly one extra', () => {
const a = pathToFileURL(join(WS, 'a.ts')).href
const text = formatLocations([loc(a, 0), loc(a, 1)], WS, 1)
const text = formatLocations([loc(a, 0), loc(a, 1)], WS, 1, DEFAULT_MAX_RESULT_CHARS)
expect(text).toContain('1 more location omitted (limit 1).')
})
it('caps the complete location text even when one URI is enormous', () => {
const maxResultChars = 80
const text = formatLocations([loc(`custom:${'x'.repeat(1_000_000)}`, 0)], WS, 1, maxResultChars)
expect(text).toHaveLength(maxResultChars)
expect(text).toContain('locations truncated')
})
})
describe('formatHover', () => {
it('renders a no-result line for null', () => {
expect(formatHover(null, DEFAULT_MAX_HOVER_CHARS)).toBe('No hover information.')
expect(formatHover(null, DEFAULT_MAX_RESULT_CHARS)).toBe('No hover information.')
})
it('returns short hover verbatim', () => {
expect(formatHover({ contents: '```ts\nx: number\n```' }, DEFAULT_MAX_HOVER_CHARS)).toBe('```ts\nx: number\n```')
expect(formatHover({ contents: '```ts\nx: number\n```' }, DEFAULT_MAX_RESULT_CHARS)).toBe('```ts\nx: number\n```')
})
it('caps hover at maxHoverChars and marks truncation', () => {
const text = formatHover({ contents: 'a'.repeat(50) }, 10)
expect(text.startsWith('aaaaaaaaaa\n')).toBe(true)
expect(text).toContain('hover truncated (limit 10 characters).')
it('caps the complete hover text including its truncation marker', () => {
const text = formatHover({ contents: 'a'.repeat(100) }, 60)
expect(text).toHaveLength(60)
expect(text).toContain('hover truncated (limit 60 characters).')
})
it('still honors a cap smaller than the truncation marker', () => {
expect(formatHover({ contents: 'a'.repeat(100) }, 10)).toHaveLength(10)
})
})
describe('presentLspCall', () => {
it('is a generic search card with an operation/cursor title and a line location', () => {
expect(presentLspCall({ operation: 'references', file_path: 'a.ts', line: 3, character: 7 })).toEqual({
expect(presentLspCall({ operation: 'findReferences', file_path: 'a.ts', line: 3, character: 7 })).toEqual({
card: 'generic',
kind: 'search',
title: 'LSP references a.ts:3:7',
title: 'LSP findReferences a.ts:3:7',
locations: [{ path: 'a.ts', line: 3 }],
})
})

View File

@@ -5,6 +5,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
import Lsp, { LspProviderId, type LspProvider, type LspProviderQuery, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp'
import { DEFAULT_LSP_TOOL_TIMEOUT_MS, LSP_PROMPT_TEXT } from '@deepseek-ai/dsh-tool-lsp'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
/** A scripted provider recording queries; `respond` yields the result or throws. */
function stubProvider(
@@ -76,7 +77,7 @@ describe('tool-lsp registration', () => {
it('exposes exactly the four operations in the schema enum', async () => {
const { ctx } = await mount(stubProvider(() => okLocations))
const schema = ctx.tools.get('lsp')?.parameters as { properties: { operation: { enum: string[] } } }
expect(schema.properties.operation.enum).toEqual(['definition', 'references', 'implementation', 'hover'])
expect(schema.properties.operation.enum).toEqual(['goToDefinition', 'findReferences', 'goToImplementation', 'hover'])
})
it('has no default export (namespace plugin shape)', () => {
@@ -86,16 +87,28 @@ describe('tool-lsp registration', () => {
it('rejects a non-positive config value at load', async () => {
await expect(mount(stubProvider(() => okLocations), { maxLocations: 0 })).rejects.toThrow(/maxLocations/)
})
it('rejects a timeout above Node timer range at load', async () => {
await expect(mount(stubProvider(() => okLocations), { timeoutMs: MAX_TIMER_DELAY_MS + 1 }))
.rejects.toThrow(/timeoutMs/)
expect(() => {
ToolLsp.apply(new Context(), {
maxLocations: 100,
maxResultChars: 16_000,
timeoutMs: MAX_TIMER_DELAY_MS + 1,
})
}).toThrow(/timeoutMs/)
})
})
describe('tool-lsp execution', () => {
it('converts one-based coordinates and passes the session cwd as workspaceRoot', async () => {
const provider = stubProvider(() => okLocations)
const { ctx } = await mount(provider)
const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 3, character: 5 }, '/ws')
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 3, character: 5 }, '/ws')
expect(result.isError).toBe(false)
expect(provider.seen[0]).toMatchObject({
operation: 'definition',
operation: 'goToDefinition',
filePath: 'a.ts',
position: { line: 2, character: 4 },
workspaceRoot: '/ws',
@@ -104,7 +117,7 @@ describe('tool-lsp execution', () => {
it('renders locations relative to the workspace', async () => {
const { ctx } = await mount(stubProvider(() => okLocations))
const result = await call(ctx, { operation: 'references', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
const result = await call(ctx, { operation: 'findReferences', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' })
})
@@ -118,7 +131,7 @@ describe('tool-lsp execution', () => {
resolvedWorkspaceRoot: '/real/ws',
}))
const { ctx } = await mount(provider)
const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 1 }, '/alias')
const result = await call(ctx, { operation: 'goToDefinition', 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' })
})
@@ -131,14 +144,14 @@ describe('tool-lsp execution', () => {
it('fails LSP_WORKSPACE_REQUIRED without a session cwd', async () => {
const { ctx } = await mount(stubProvider(() => okLocations))
const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 1 }, null)
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, null)
expect(result.isError).toBe(true)
expect(result.error?.code).toBe('LSP_WORKSPACE_REQUIRED')
})
it('surfaces a structured LSP_UNAVAILABLE when no provider handles the file', async () => {
const { ctx } = await mount(stubProvider(() => okLocations, { '.py': 'python' }))
const result = await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
expect(result.isError).toBe(true)
expect(result.error?.code).toBe('LSP_UNAVAILABLE')
})
@@ -161,7 +174,7 @@ describe('tool-lsp execution', () => {
},
}
const { ctx } = await mount(provider)
await call(ctx, { operation: 'definition', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
// The timeout policy is not mounted here, so the signal is whatever the registry passes (may be
// undefined); the point is the tool threads it through without throwing.
expect(seen).toHaveLength(1)

View File

@@ -26,6 +26,9 @@
{
"path": "../../core/system-prompt"
},
{
"path": "../../util/timeout"
},
{
"path": "../lsp"
}