refactor: apply repository naming contract

Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
Tianyi Cui
2026-08-13 00:36:22 +08:00
parent 101df7cf58
commit a2d0f7f411
3281 changed files with 21730 additions and 21592 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/lsp/lsp-stdio/README.md
README.md: beadc34ec738ae3f6511cae764240c0e9d8c61d7
README.zh.md: 2d6516a25147fb7593e8d45d843315098cdff7d3

View File

@@ -0,0 +1,60 @@
# @deepseek-ai/dsh-lsp-stdio
English | [中文](README.zh.md)
A **generic stdio language-server backend** for `ctx.lsp`. One plugin instance accepts a named server table and registers one isolated provider per entry. It reads through `ctx.fs` and launches through `ctx.subprocess`, so the server and source always inhabit the mounted execution world. This is a generic host, not a language-server catalog or installer — deployments configure commands and mappings explicitly; presets belong in `cordis.yml` overlays.
Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export).
## What it does
- 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 target)`. A live server error is not replayed; if the selected pooled transport fails before or during a read-only query, the provider awaits its disposal and retries that query once on a fresh process.
- Uses a compatibility-first **transient-open** sequence per query: resolve and byte-bound the source while streaming it through `ctx.fs`, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. A failed or canceled `didOpen` write terminates the instance before the pool can reuse it. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU.
- 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. Provider disposal aborts filesystem and protocol work, awaits workspace lookups that have not entered a queue, then drains every queue and server.
- After protocol shutdown fails, terminates the server's descendant tree through the subprocess seam (POSIX process-group signaling; Windows `taskkill /T /F`). Tree-kill delivery is contained like every group signal — it races server exit — and quiescence is confirmed by the handle's tree-liveness wait rather than by the kill's own outcome.
- Resolves the server executable, cwd, process, and protocol streams through `ctx.subprocess`; `initialize.processId` is `null` because another machine or PID namespace must not monitor the harness process.
- Uses `ctx.fs` canonical containment, file URIs, and streamed text validation, but emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy.
## Configuration
The `servers` record key is the stable provider id reserved on `ctx.lsp`; each value has this shape:
| Server key | Default | Meaning |
|---|---|---|
| `command` | (required) | Executable to spawn — absolute, or resolved on the child PATH at load. Launch uses no shell. |
| `args` | `[]` | Arguments passed to the executable. |
| `env` | `{}` | Extra env merged on top of the credential-scrubbed ambient env (vars matching `KEY`/`PASSWORD`/`SECRET`/`TOKEN` are not forwarded); an explicit `DSH_*` entry merges after the seam's scrub of ambient ones. |
| `extensionToLanguage` | (required) | Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). |
| `initializationOptions` | `null` | Static `initialize` options forwarded to the server. |
| `configuration` | `null` | Static answer to every `workspace/configuration` item. |
| `maxMessageBytes` | `16000000` | Largest single framed message accepted from the server. |
| `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` | Grace for request cancellation and for SIGTERM→SIGKILL escalation. |
`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 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
The provider trusts its configured server and claims no sandbox confinement. It delegates canonical identity, containment, regular-file streaming, UTF-8 validation, and file-URI encoding to `ctx.fs`; it rejects missing, non-regular, non-UTF-8, oversized, or canonically out-of-workspace query sources before server startup. Containment is evaluated before the stream opens and does not promise stable-handle identity across concurrent path replacement. Result locations may be external, but an external path cannot become a query source. A deployment must mount filesystem and subprocess providers for the same execution world; split-world composition is invalid.
## Model Experience
Indirectly, through `dsh-tool-lsp`, which surfaces this provider's normalized results; this host contributes no prompt or schema itself.
#### KV Cache effect
No direct invalidation; `dsh-tool-lsp` owns request-prefix changes.
## Known Limitations and Deferred Work
- **No confinement policy** — this package trusts the configured server and does not sandbox its process; a restricted deployment must supply appropriate process/filesystem providers or a same-world sandbox wrapper.
- **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.
- **A hard-killed harness orphans language servers** — `initialize.processId: null` removes server-side client-PID monitoring, so servers are cleaned only by graceful service disposal; a SIGKILL'd harness leaves them running until they exit on their own.

View File

@@ -0,0 +1,60 @@
# @deepseek-ai/dsh-lsp-stdio
[English](README.md) | 中文
`ctx.lsp` 的**通用 stdio 语言服务器后端**。一个插件实例接受一张命名服务器表,并逐配置项注册一个隔离的提供方。它通过 `ctx.fs` 读取,并通过 `ctx.subprocess` 启动,因此服务器与源文件始终位于已挂载的执行世界中。这是通用主机,而不是语言服务器目录或安装器:部署需要显式配置命令与映射,预设应放在 `cordis.yml` overlay 中。
Namespace 插件(`name`/`inject`/`Config`/`apply`,无默认导出)。
## 功能
- 在注册前解析每项服务器局部设置;无效映射或注册冲突会回滚较早配置项,因此加载失败不会留下提供方路由。
- 每个 `(server id, canonical workspace target)` 惰性 single-flight 一个服务器进程。服务器仍存活时返回的错误不会触发重试;如果选中的池化传输在只读查询之前或期间发生故障,提供方会等待其 dispose(资源释放)完成,并在新进程上重试该查询一次。
- 每次查询都使用兼容性优先的**临时打开**序列:通过 `ctx.fs` 流式读取源文件,同时解析并限制其字节数;随后执行 `textDocument/didOpen`(版本 1、完整文本)、所请求操作,再执行位于 `finally` 中的 `textDocument/didClose`。写入 `didOpen` 失败或取消时,会在池复用该实例前将其终止。文档在每次调用后关闭,因此第一版不需要 `didChange`、内容 cache 或文档 LRU。
- 通过一条逐 Workspace、可中止的队列,串行执行每个源读取/打开/查询/关闭生命周期,因此排队调用只会在轮到自身时读取当前源;不同 Workspace 并行运行。提供方 dispose 会中止文件系统与协议工作,等待尚未进入队列的 Workspace 查找完成,随后排空每条队列与每个服务器。
- 协议 shutdown 失败后,经由子进程 seam 终止服务器后代树(POSIX 进程组信号;Windows `taskkill /T /F`)。树终止的投递结果与所有进程组信号一样被就地吸收,不向外抛出(投递与服务器退出存在竞态);服务器是否完全停稳,由句柄的进程树存活等待确认,而非由这次终止自身的结果确认。
- 通过 `ctx.subprocess` 解析服务器可执行文件、cwd、进程和协议流;`initialize.processId` 为 `null`,因为另一台机器或 PID namespace 不得监视 harness 进程。
- 使用 `ctx.fs` 提供的规范化包含关系、文件 URI 与流式文本验证,但不发出 `fs/observed`:只有 LSP 结果对模型可见,因此查询不满足先读后写策略。
## 配置
`servers` 记录的 key 是在 `ctx.lsp` 上保留的稳定提供方 id;每个值具有以下形状:
| 服务器 key | 默认值 | 含义 |
|---|---|---|
| `command` | (必填) | 要 spawn 的可执行文件:绝对路径,或在加载时从子进程 PATH 解析。不使用 shell 启动。 |
| `args` | `[]` | 传给可执行文件的参数。 |
| `env` | `{}` | 合并到已清理 credential 的环境之上的额外 env(匹配 `KEY`/`PASSWORD`/`SECRET`/`TOKEN` 的变量不会转发);显式 `DSH_*` 条目在 seam 清除环境中同名值之后合并。 |
| `extensionToLanguage` | (必填) | 小写、以点开头的扩展名 → LSP language id(例如 `{ '.ts': 'typescript' }`)。 |
| `initializationOptions` | `null` | 转发给服务器的静态 `initialize` 选项。 |
| `configuration` | `null` | 每个 `workspace/configuration` 配置项的静态答案。 |
| `maxMessageBytes` | `16000000` | 从服务器接受的单条 framed 消息最大大小。 |
| `maxStderrBytes` | `1000000` | 为诊断保留的 stderr 尾部最大大小。 |
| `maxDocumentBytes` | `4000000` | 该主机可打开的源文件大小上限。 |
| `shutdownTimeoutMs` | `5000` | 升级前用于优雅 `shutdown`/`exit` 的预算。 |
| `killGraceMs` | `2000` | 请求取消及 SIGTERM→SIGKILL 升级的宽限期。 |
`servers` 必须至少包含一个配置项,每个 id 都必须非空。定时器预算必须是正整数,且不超过 Node 的 `2_147_483_647` ms 定时器上限。所有可执行文件都会在清理 credential 后于加载时解析;后面的坏配置项会阻止所有提供方注册。进程在第一次匹配查询时惰性启动。
## 协议行为
初始化会声明 `general.positionEncodings: ['utf-16']`、`workspace: { workspaceFolders: true, configuration: true }`、`textDocument.hover.contentFormat: ['markdown', 'plaintext']`,以及定义与实现使用的 `linkSupport: true`,且不进行动态注册。服务器返回的能力具有最终决定权:不受支持的操作,或缺少临时打开/关闭的同步方式,会使查询失败。服务器省略 `positionEncoding` 时默认为 `utf-16`;其他值都属于协议错误。客户端通过静态配置回答 `workspace/configuration`,接受生命周期记账请求,并拒绝 `workspace/applyEdit`:它绝不应用编辑或运行命令。导航直接映射 `Location`,并从 `LocationLink` 的 `targetUri` + `targetSelectionRange` 映射;hover 规范化会取得有效的 `MarkupContent.value`,保留 string `MarkedString`,把带 language tag 的值渲染为围栏代码,并用一个空行连接数组。缺失结果、格式错误的范围或位置,以及格式错误的 hover 编码,都会以结构化 `LSP_MALFORMED_RESPONSE` 错误的形式失败。
## 安全边界
提供方信任其配置的服务器,不提供任何沙箱隔离。它把规范化身份、包含关系、普通文件流式读取、UTF-8 验证和文件 URI 编码委托给 `ctx.fs`;并在服务器启动前拒绝缺失、非普通文件、非 UTF-8、过大,或规范化后位于 Workspace 外部的查询源。包含关系在打开流之前评估,不承诺在并发路径替换期间保持稳定句柄身份。结果位置可以在外部,但外部路径不能成为查询源。部署必须挂载描述同一执行世界的文件系统与进程管理提供方;分裂世界组合无效。
## 模型体验
通过 `dsh-tool-lsp` 间接影响;该工具呈现此提供方的规范化结果,该主机自身不贡献提示词或 schema。
#### KV Cache 影响
不会直接失效;请求前缀变更由 `dsh-tool-lsp` 负责。
## 已知限制与暂缓事项
- **不提供隔离策略**:本包(package)信任所配置的服务器,不对其进程实施沙箱;受限部署必须提供适当的进程/文件系统提供方,或使用同一执行世界的沙箱包装层。
- **临时打开兼容性下限**:同步能力省略打开/关闭(或声明 `None`)的服务器不受支持,即使关闭文档查询能够工作;固定的 TypeScript e2e 只建立一项兼容性下限,不代表跨语言承诺。
- **逐服务器/Workspace 串行化延迟**:共享同一个服务器与 Workspace 的并行 agent(智能体)会在一个进程后排队;长生命周期 Workspace 进程会占用内存直到 dispose。
- **被强制杀死的 harness 会遗留语言服务器**:`initialize.processId: null` 取消了服务器侧的客户端 PID 监视,因此服务器只能由服务的优雅 dispose 清理;被 SIGKILL 的 harness 会让它们继续运行,直到自行退出。

View File

@@ -0,0 +1,61 @@
{
"name": "@deepseek-ai/dsh-lsp-stdio",
"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-rc.2",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/lsp/lsp-stdio"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-lsp": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-lsp": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
"typescript": "^6.0.3",
"typescript-language-server": "^5.0.0"
}
}

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-stdio/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

@@ -0,0 +1,329 @@
/**
* A JSON-RPC endpoint over one language server spawned through the subprocess
* capability. Owns id correlation, outbound requests/notifications, and inbound
* server→client requests: it answers `workspace/configuration` from static
* config, and rejects `workspace/applyEdit` (this host never applies edits or
* runs commands). It caps stderr, surfaces framing/decoder failures as a
* fatal close, and exposes tree-scoped termination through the handle so the
* instance owns teardown; group/tree mechanics live in the subprocess
* Service provider.
* @module @deepseek-ai/dsh-lsp-stdio/connection
*/
import type { Writable } from 'node:stream'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { encodeMessage, MessageDecoder } from './framing.ts'
/** How to launch the server and answer its config requests. */
export interface ConnectionSpec {
/** The resolved absolute executable path (no shell). */
readonly command: string
/** Arguments passed to the executable. */
readonly args: readonly string[]
/** The child's working directory (the canonical workspace). */
readonly cwd: string
/** Explicit child environment overrides; the subprocess provider owns its ambient scrub. */
readonly env: Record<string, string>
/** Largest single framed message accepted from the server. */
readonly maxMessageBytes: number
/** Largest stderr tail retained for diagnostics. */
readonly maxStderrBytes: number
/**
* The subprocess spec's `graceMs`: the SIGTERM→SIGKILL window of
* {@link LspConnection.terminate}'s escalation, and the bound for draining
* pipes a surviving helper still holds after the server exits.
*/
readonly killGraceMs: number
/** Static answer to every `workspace/configuration` item. */
readonly configuration: unknown
}
interface Pending {
resolve: (value: unknown) => void
reject: (error: Error) => void
}
/**
* Write one JSON-RPC message to the child stdin.
* @param stdin - the spawned server stdin.
* @param message - the unencoded JSON-RPC message.
* @param done - callback that reports asynchronous stream settlement.
*/
export type ConnectionWriter = (
stdin: Writable,
message: unknown,
done: (error?: Error | null) => void,
) => void
/** Spawn one subprocess for this connection (the provider passes `ctx.subprocess.spawn`). */
export type ConnectionSpawner = (spec: SubprocessSpawnSpec) => SubprocessHandle
const writeConnectionMessage: ConnectionWriter = (stdin, message, done) => {
stdin.write(encodeMessage(message), done)
}
/** A live JSON-RPC endpoint bound to one child process. */
export class LspConnection {
private readonly handle: SubprocessHandle
private readonly stdin: Writable
private readonly decoder: MessageDecoder
private readonly pending = new Map<number, Pending>()
private nextId = 1
private closeReason: Error | undefined
/** Set once the process has fully exited; the instance awaits it during teardown. */
readonly closed: Promise<void>
/**
* @param spec - how to launch the server and answer its config requests.
* @param spawner - the subprocess seam's spawn (the provider passes `ctx.subprocess.spawn`).
* @param onServerRequest - answers a server→client request; rejects to send an error response.
* @param writer - message writer; tests inject callback failures without relying on OS pipe races.
*/
constructor(
spec: ConnectionSpec,
spawner: ConnectionSpawner,
private readonly onServerRequest: (method: string, params: unknown) => Promise<unknown>,
private readonly writer: ConnectionWriter = writeConnectionMessage,
) {
this.decoder = new MessageDecoder(spec.maxMessageBytes)
// stdin/stdout are piped protocol streams this endpoint frames itself;
// stderr is a collected diagnostic tail (no spill — the bounded tail IS
// the contract). The seam owns detachment and tree-scoped signalling.
this.handle = spawner({
argv: [spec.command, ...spec.args],
cwd: spec.cwd,
stdio: {
stdin: 'pipe',
stdout: 'pipe',
stderr: { maxBytes: spec.maxStderrBytes },
},
graceMs: spec.killGraceMs,
// The seam merges explicit config entries after its ambient scrub, so a
// configured credential or DSH_* fact reaches the child deliberately.
env: spec.env,
})
/* v8 ignore start -- 'pipe' dispositions expose both streams by the seam contract; defensive. */
if (this.handle.stdin === undefined || this.handle.stdout === undefined) {
throw new Error('lsp-stdio: subprocess implementation dropped a piped protocol stream')
}
/* v8 ignore stop */
this.stdin = this.handle.stdin
this.closed = new Promise<void>((resolve) => {
const close = (): void => {
const reason = this.closeReason ?? new Error(this.exitMessage())
// Record the reason so any request issued AFTER close rejects immediately instead of hanging
// (a closed process sends no further responses).
this.closeReason = reason
this.failAll(reason)
resolve()
}
this.handle.done.then(close, (error: unknown) => {
// A spawn-level failure never produces a close event; the rejection is
// the fatal cause and the close boundary at once.
this.fail(asError(error))
close()
})
})
// 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.stdin.on('error', (error) => { this.fail(error) })
this.handle.stdout.on('data', (chunk: Buffer) => { this.onStdout(chunk) })
}
/** The child's pid, or `-1` when the spawn produced no pid (so signalling is a no-op). */
get pid(): number {
return this.handle.pid
}
/** The retained stderr tail, for diagnostics on a failed server. */
get stderrTail(): string {
/* v8 ignore next -- the collect disposition always exposes a stderr reader; defensive. */
return this.handle.collected.stderr?.readFrom(0).text ?? ''
}
/** Whether the transport has failed even if the child close event has not arrived yet. */
get failed(): boolean {
return this.closeReason !== undefined
}
/**
* Test whether a caught error is this connection's retained fatal transport cause.
* @param error - error caught by the instance or provider.
* @returns `true` only when this connection produced that exact failure.
*/
failedWith(error: unknown): boolean {
return this.closeReason === error
}
/**
* Send a request and await its result.
* @param method - the JSON-RPC method.
* @param params - the request params.
* @returns the response result; rejects on an error response, write failure, or close.
*/
request(method: string, params: unknown): Promise<unknown> {
const id = this.nextId++
const promise = new Promise<unknown>((resolve, reject) => {
if (this.closeReason !== undefined) {
reject(this.closeReason)
return
}
this.pending.set(id, { resolve, reject })
// `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
// rejection. The returned promise still delivers the rejection to the caller's own await/catch.
promise.catch(() => {})
return promise
}
/**
* 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): Promise<void> {
return this.write({ jsonrpc: '2.0', method, params })
}
/**
* Send a `$/cancelRequest` for an in-flight request id (best-effort; ignores write failure).
* @param requestId - the numeric id of the request to cancel.
*/
cancel(requestId: number): void {
// 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(() => {})
}
/**
* The id the NEXT `request()` will use, so the instance can pre-arm a cancel.
* @returns the numeric id the next request will be assigned.
*/
peekNextId(): number {
return this.nextId
}
/** Terminate the server's process tree (the seam's SIGTERM→grace→SIGKILL escalation; idempotent). */
terminate(): void {
this.handle.terminate()
}
/**
* Wait until the owned process tree has exited.
* @param signal - optional bound for the wait.
* @returns `true` when the tree exited, or `false` when the signal aborted first.
*/
async waitForProcessTreeExit(signal?: AbortSignal): Promise<boolean> {
return await this.handle.waitForExit(signal)
}
private onStdout(chunk: Buffer): void {
let messages: unknown[]
try {
messages = this.decoder.push(chunk)
} catch (error) {
// A framing/JSON failure corrupts the stream position irrecoverably: fail the instance and
// terminate the whole group so helper processes don't outlive the leader (SIGTERM first, then
// the kill grace's SIGKILL — a misbehaving server still gets its bounded flush window).
this.fail(asError(error))
this.handle.terminate()
return
}
for (const message of messages) this.dispatch(message)
}
private dispatch(message: unknown): void {
if (message === null || typeof message !== 'object') return
const frame = message as Record<string, unknown>
const id = frame.id
const method = frame.method
if (typeof method === 'string' && (typeof id === 'number' || typeof id === 'string')) {
// 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') {
// A server→client notification (e.g. diagnostics, logs): ignored by this MVP host.
return
}
if (typeof id === 'number') this.handleResponse(id, frame)
}
private async handleServerRequest(id: number | string, method: string, params: unknown): Promise<void> {
try {
const result = await this.onServerRequest(method, params)
await this.write({ jsonrpc: '2.0', id, result })
} catch (error) {
await this.write({ jsonrpc: '2.0', id, error: { code: -32601, message: asError(error).message } })
}
}
private handleResponse(id: number, frame: Record<string, unknown>): void {
const pending = this.pending.get(id)
if (!pending) return
this.pending.delete(id)
const error = frame.error
if (error !== null && typeof error === 'object') {
const record = error as Record<string, unknown>
pending.reject(new Error(typeof record.message === 'string' ? record.message : 'LSP error response'))
return
}
pending.resolve(frame.result)
}
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.writer(this.stdin, 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. */
private exitMessage(): string {
const tail = this.stderrTail.trim()
return tail === '' ? 'language server exited' : `language server exited; stderr: ${tail}`
}
private fail(error: Error): void {
/* v8 ignore next -- the second arm (closeReason already set) needs two fail() calls before close; defensive. */
if (this.closeReason === undefined) this.closeReason = error
this.failAll(error)
}
private failAll(error: Error): void {
const waiting = [...this.pending.values()]
this.pending.clear()
for (const pending of waiting) pending.reject(error)
}
}
/** Coerce an unknown thrown value to an `Error`. */
function asError(value: unknown): Error {
/* v8 ignore next -- the non-Error branch guards against a non-Error throw, which our paths never produce. */
return value instanceof Error ? value : new Error(String(value))
}

View File

@@ -0,0 +1,102 @@
/**
* LSP base-protocol framing: `Content-Length`-delimited JSON-RPC over a byte stream. The encoder
* produces one framed buffer; the decoder buffers incoming bytes and yields complete message bodies,
* bounding the header and total message size so a hostile or broken server cannot exhaust memory.
* @module @deepseek-ai/dsh-lsp-stdio/framing
*/
/** The header/body separator in the LSP base protocol. */
const HEADER_SEPARATOR = '\r\n\r\n'
/** Cap on the header section so a server that never sends the separator cannot grow the buffer forever. */
const MAX_HEADER_BYTES = 1 << 16
/**
* Encode one JSON-RPC message as a framed LSP buffer (`Content-Length: N\r\n\r\n<utf-8 json>`).
* @param message - the JSON-RPC message object to serialize.
* @returns the framed bytes ready to write to the server's stdin.
*/
export function encodeMessage(message: unknown): Buffer {
const body = Buffer.from(JSON.stringify(message), 'utf8')
const header = Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii')
return Buffer.concat([header, body])
}
/**
* A streaming decoder for `Content-Length`-framed JSON-RPC. Feed it stdout chunks; it returns any
* whole message bodies that completed. It parses only the `Content-Length` header and ignores other
* headers (e.g. `Content-Type`), matching the base protocol.
*/
export class MessageDecoder {
private buffer: Buffer = Buffer.alloc(0)
private readonly maxMessageBytes: number
/**
* @param maxMessageBytes - reject any single framed body larger than this (guards memory).
*/
constructor(maxMessageBytes: number) {
this.maxMessageBytes = maxMessageBytes
}
/**
* Append a chunk and return every message body that is now complete.
* @param chunk - raw bytes from the server's stdout.
* @returns the parsed JSON bodies, in arrival order (possibly empty).
* @throws Error when a header is malformed or a body exceeds `maxMessageBytes`.
*/
push(chunk: Buffer): unknown[] {
this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk])
const messages: unknown[] = []
for (;;) {
const step = this.next()
if (!step.ready) break
messages.push(step.message)
}
return messages
}
/** Parse and consume the next complete message, or report that more bytes are needed. */
private next(): { ready: false } | { ready: true; message: unknown } {
const separator = this.buffer.indexOf(HEADER_SEPARATOR)
if (separator < 0) {
if (this.buffer.length > MAX_HEADER_BYTES) {
throw new Error(`LSP header exceeded ${MAX_HEADER_BYTES} bytes without a terminator`)
}
return { ready: false }
}
if (separator > MAX_HEADER_BYTES) {
throw new Error(`LSP header exceeded ${MAX_HEADER_BYTES} bytes`)
}
const headerText = this.buffer.toString('ascii', 0, separator)
const contentLength = parseContentLength(headerText)
if (contentLength > this.maxMessageBytes) {
throw new Error(`LSP message length ${contentLength} exceeds the ${this.maxMessageBytes}-byte limit`)
}
const bodyStart = separator + HEADER_SEPARATOR.length
const bodyEnd = bodyStart + contentLength
if (this.buffer.length < bodyEnd) return { ready: false }
const body = this.buffer.toString('utf8', bodyStart, bodyEnd)
this.buffer = this.buffer.subarray(bodyEnd)
try {
return { ready: true, message: JSON.parse(body) }
} catch (error) {
/* v8 ignore next -- JSON.parse throws a SyntaxError (an Error); the String() fallback is defensive. */
throw new Error(`LSP message body was not valid JSON: ${error instanceof Error ? error.message : String(error)}`)
}
}
}
/** Read the `Content-Length` header value (case-insensitive), rejecting a missing or non-numeric one. */
function parseContentLength(headerText: string): number {
for (const line of headerText.split('\r\n')) {
const colon = line.indexOf(':')
if (colon < 0) continue
if (line.slice(0, colon).trim().toLowerCase() !== 'content-length') continue
const value = Number(line.slice(colon + 1).trim())
if (!Number.isInteger(value) || value < 0) {
throw new Error(`invalid Content-Length header: ${JSON.stringify(line)}`)
}
return value
}
throw new Error(`LSP header block missing Content-Length: ${JSON.stringify(headerText)}`)
}

View File

@@ -0,0 +1,124 @@
/** Filesystem-seam source access for the generic stdio LSP provider. */
import { Buffer } from 'node:buffer'
import type { FileSystem, FsTarget } from '@deepseek-ai/dsh-fs'
import { throwIfAborted } from './abort.ts'
/** A canonical workspace in the filesystem/subprocess execution world. */
export interface HostWorkspace {
/** Stable filesystem identity used for provider pooling. */
readonly target: FsTarget
/** Canonical absolute path accepted as a subprocess cwd. */
readonly canonicalPath: string
/** Canonical file URI sent during LSP initialization. */
readonly fileUrl: string
}
/** A validated source and the exact URI sent to the language server. */
export interface HostSource {
/** Canonical file URI in the execution world's platform syntax. */
readonly fileUrl: string
/** Current complete UTF-8 text. */
readonly text: string
}
/**
* Resolve and validate one workspace through `ctx.fs`.
* @param fs - filesystem provider sharing the language server's execution world.
* @param workspaceRoot - caller-supplied workspace path.
* @param signal - optional cancellation around provider operations.
* @returns stable identity plus process path and file URI.
*/
export async function canonicalizeWorkspace(
fs: FileSystem,
workspaceRoot: string,
signal?: AbortSignal,
): Promise<HostWorkspace> {
throwIfAborted(signal)
let target: FsTarget
try {
target = await fs.resolve(workspaceRoot, signal === undefined ? {} : { signal })
} catch (error: unknown) {
throwIfAborted(signal)
throw new Error(`workspace root "${workspaceRoot}" cannot be resolved: ${messageOf(error)}`, { cause: error })
}
throwIfAborted(signal)
const info = await fs.stat(target, signal).catch((error: unknown) => {
throwIfAborted(signal)
throw error
})
throwIfAborted(signal)
if (info?.type !== 'directory') {
throw new Error(`workspace root "${workspaceRoot}" is not a directory`)
}
return {
target,
canonicalPath: fs.processPath(target),
fileUrl: fs.fileUrl(target),
}
}
/**
* Resolve, contain, and read one byte-bounded query source through `ctx.fs`.
* This layer owns the LSP-specific complete-document cap while the filesystem
* provider owns streaming, regular-file checks, and UTF-8 validation.
* @param fs - filesystem provider sharing the server's execution world.
* @param filePath - absolute source path or path relative to `workspace`.
* @param workspace - already-canonical workspace.
* @param maxDocumentBytes - largest complete source accepted by this host.
* @param signal - optional cancellation.
* @returns canonical file URI and current text.
*/
export async function readHostSource(
fs: FileSystem,
filePath: string,
workspace: HostWorkspace,
maxDocumentBytes: number,
signal?: AbortSignal,
): Promise<HostSource> {
throwIfAborted(signal)
let target: FsTarget
try {
target = await fs.resolve(filePath, {
cwd: workspace.canonicalPath,
...signal === undefined ? {} : { signal },
})
} catch (error: unknown) {
throwIfAborted(signal)
throw new Error(`source "${filePath}" cannot be resolved: ${messageOf(error)}`, { cause: error })
}
throwIfAborted(signal)
if (!fs.contains(workspace.target, target)) {
throw new Error(`source "${filePath}" resolves outside the workspace`)
}
const chunks: string[] = []
let bytes = 0
try {
// XXX(lsp-source-replacement): Revisit stable-handle identity only if a real query observes
// replacement between canonical containment and the provider opening this stream.
const stream = await fs.streamText(target, signal)
for await (const chunk of stream) {
throwIfAborted(signal)
bytes += Buffer.byteLength(chunk)
if (bytes > maxDocumentBytes) break
chunks.push(chunk)
}
} catch (error: unknown) {
throwIfAborted(signal)
throw new Error(`source "${filePath}" could not be read: ${messageOf(error)}`, { cause: error })
}
if (bytes > maxDocumentBytes) {
throw new Error(
`source "${filePath}" exceeds the ${maxDocumentBytes}-byte limit; reading stopped after ${bytes} bytes`,
)
}
throwIfAborted(signal)
return {
fileUrl: fs.fileUrl(target),
text: chunks.join(''),
}
}
function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}

View File

@@ -0,0 +1,369 @@
/**
* Generic stdio language-server backend for `ctx.lsp`. One plugin instance configures a named table
* of server commands and registers one isolated provider for each entry. Every provider lazily
* single-flights one server process per canonical workspace target, serves transient-open queries
* through it, and replaces a selected transport that fails before or during the next read-only
* query. Providers read sources through `ctx.fs` and launch servers through
* `ctx.subprocess`, so both local and remote implementations share one host.
*
* Namespace plugin (named exports, no default export). Lifecycle is effect-scoped: disposal
* unregisters from `ctx.lsp` and tears down every live server.
* @module @deepseek-ai/dsh-lsp-stdio
*/
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
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 type { HostWorkspace } from './host.ts'
import { LspInstance } from './instance.ts'
import type { ConnectionSpawner } from './connection.ts'
import type { InstanceSpec } from './instance.ts'
export { canonicalizeWorkspace, readHostSource } from './host.ts'
export { encodeMessage, MessageDecoder } from './framing.ts'
export {
negotiatePositionEncoding,
normalizeHover,
normalizeLocations,
requestMethod,
supportsOperation,
supportsTransientOpen,
} from './translate.ts'
export { LspInstance } from './instance.ts'
export { LspConnection } from './connection.ts'
/** Cordis plugin name for loader diagnostics. */
export const name = 'lsp-stdio'
/** Services required by this plugin. */
export const inject = ['fs', 'lsp', 'subprocess']
const DEFAULT_MAX_MESSAGE_BYTES = 16_000_000
const DEFAULT_MAX_STDERR_BYTES = 1_000_000
const DEFAULT_MAX_DOCUMENT_BYTES = 4_000_000
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000
const DEFAULT_KILL_GRACE_MS = 2_000
/** One configured local language server and its host bounds. */
export interface LspLocalServerConfig {
/** Executable to spawn (absolute, or resolved on PATH at load). */
command: string
/** Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). */
extensionToLanguage: Record<string, string>
/** Arguments passed to the executable (no shell). Default `[]`. */
args?: string[]
/** Extra env vars merged on top of the scrubbed ambient env. Default `{}`. */
env?: Record<string, string>
/** Static `initialize` options forwarded to the server. Default `null`. */
initializationOptions?: unknown
/** Static answer to every `workspace/configuration` item. Default `null`. */
configuration?: unknown
/** Largest single framed message accepted from the server (bytes). Default 16000000. */
maxMessageBytes?: number
/** Largest stderr tail retained for diagnostics (bytes). Default 1000000. */
maxStderrBytes?: number
/** Largest source file this host will open (bytes). Default 4000000. */
maxDocumentBytes?: number
/** Graceful `shutdown`/`exit` budget before escalation (ms). Default 5000. */
shutdownTimeoutMs?: number
/** Request-cancel and SIGTERM→SIGKILL grace (ms). Default 2000. */
killGraceMs?: number
}
/** Plugin configuration: provider id → local language-server configuration. */
export interface Config {
/** Non-empty table of stable provider ids to independent local server configurations. */
servers: Record<string, LspLocalServerConfig>
}
/** One server config after schemastery fills every default. */
type ResolvedServerConfig = Required<LspLocalServerConfig>
type WorkspaceKey = HostWorkspace['target']['targetKey']
const LspLocalServerConfig: z<LspLocalServerConfig> = z.object({
command: z.string().required(),
args: z.array(String).default([]),
env: z.dict(String).default({}),
extensionToLanguage: z.dict(String).required(),
initializationOptions: z.any().default(null),
configuration: z.any().default(null),
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().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({
servers: z.dict(LspLocalServerConfig).required(),
})
/** Propagate teardown failures only after every sibling has settled. */
function throwTeardownFailures(results: readonly PromiseSettledResult<void>[], message: string): void {
const failures: unknown[] = []
for (const result of results) {
if (result.status === 'rejected') failures.push(result.reason)
}
if (failures.length === 1) throw failures[0]
if (failures.length > 1) throw new AggregateError(failures, message)
}
/**
* Register the configured stdio LSP providers. Resolves every executable at load (after credential
* scrubbing) before publishing any provider; each process launches lazily on its first matching
* query.
* @param ctx - the plugin context carrying `fs`, `lsp`, and `subprocess`.
* @param config - the resolved plugin configuration (schemastery has filled every default).
*/
export async function apply(ctx: Context, config: Config): Promise<void> {
const entries = Object.entries(config.servers)
if (entries.length === 0) throw new Error('lsp-stdio: servers must contain at least one server')
const setupAbort = new AbortController()
const stopSetupCancellation = ctx.on('internal/plugin', (fiber) => {
// An async plugin callback must observe its own disposal before Cordis can
// run effect cleanup, because unload otherwise waits for this callback.
if (fiber === ctx.fiber && fiber.uid === null) {
setupAbort.abort(new Error('lsp-stdio setup disposed'))
}
})
// Resolve every server-local setting before registration so a bad later command or bound cannot
// publish an earlier provider. Registry-level mapping conflicts are rolled back below.
const providers = await (async () => {
const lookups = entries.map(async ([providerId, rawConfig]) => {
if (providerId.trim() === '') throw new Error('lsp-stdio: server ids must be non-empty strings')
const resolved = rawConfig as ResolvedServerConfig
validateServerConfig(providerId, resolved)
const executable = await ctx.subprocess.resolveExecutable(
resolved.command,
resolved.env,
setupAbort.signal,
)
setupAbort.signal.throwIfAborted()
return new LocalLspProvider(
providerId,
ctx.fs,
resolved,
executable,
spec => ctx.subprocess.spawn(spec),
)
})
try {
return await Promise.all(lookups)
} catch (error: unknown) {
setupAbort.abort(error)
await Promise.allSettled(lookups)
throw error
} finally {
stopSetupCancellation()
}
})()
ctx.effect(() => {
const disposers: Array<() => void> = []
try {
for (const provider of providers) disposers.push(ctx.lsp.registerProvider(provider))
} catch (error) {
for (const dispose of disposers.reverse()) dispose()
throw error
}
return async () => {
// Remove every route before process teardown so no new query can enter a draining provider.
for (const dispose of disposers.reverse()) dispose()
const results = await Promise.allSettled(providers.map(provider => provider.disposeAll()))
throwTeardownFailures(results, 'lsp-stdio provider teardown failed')
}
}, 'lsp-stdio.registerProviders')
}
/** Validate one resolved server entry before any provider in the table is registered. */
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.
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.
assertPositiveInteger(providerId, 'maxStderrBytes', resolved.maxStderrBytes)
assertPositiveInteger(providerId, 'maxMessageBytes', resolved.maxMessageBytes)
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-stdio: 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) {
throw new Error(`lsp-stdio: servers.${providerId}.${name} must be a positive integer`)
}
}
/** A pooled generic provider: one server process per canonical workspace, created on demand. */
class LocalLspProvider implements LspProvider {
readonly id: LspProviderId
readonly extensionToLanguage: Readonly<Record<string, string>>
/** One live instance per stable canonical workspace identity. */
private readonly instances = new Map<WorkspaceKey, LspInstance>()
/** One complete source-read→open→query→close serialization tail per canonical workspace. */
private readonly queues = new Map<WorkspaceKey, Promise<void>>()
/** Workspace canonicalizations that have not entered a provider-owned queue yet. */
private readonly workspaceLookups = new Set<Promise<void>>()
private readonly lifetime = new AbortController()
private disposed = false
constructor(
providerId: string,
private readonly fs: Context['fs'],
private readonly config: ResolvedServerConfig,
private readonly executable: string,
private readonly spawner: ConnectionSpawner,
) {
this.id = LspProviderId(providerId)
this.extensionToLanguage = config.extensionToLanguage
}
/** Read the disposed flag through a method so a `query()` await cannot narrow it to a literal. */
private isDisposed(): boolean {
return this.disposed
}
/** Reject work that cannot publish or use a provider-owned instance. */
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 LspError('lsp-stdio provider is disposed', 'LSP_DISPOSED')
if (signal?.aborted) throw abortError(signal)
}
/** Fuse caller cancellation with provider disposal for every filesystem and protocol await. */
private querySignal(signal?: AbortSignal): AbortSignal {
return signal === undefined
? this.lifetime.signal
: AbortSignal.any([signal, this.lifetime.signal])
}
async query(request: LspProviderQuery, signal?: AbortSignal): Promise<LspQueryResult> {
// Honor an already-aborted signal before provider I/O so a canceled request never starts a server.
this.assertActive(signal)
const querySignal = this.querySignal(signal)
const workspaceResult = canonicalizeWorkspace(this.fs, request.workspaceRoot, querySignal)
const workspaceLookup = workspaceResult.then(() => undefined, () => undefined)
this.workspaceLookups.add(workspaceLookup)
let workspace: HostWorkspace
try {
workspace = await workspaceResult
} finally {
this.workspaceLookups.delete(workspaceLookup)
}
this.assertActive(querySignal)
const workspaceKey = workspace.target.targetKey
return this.enqueue(workspaceKey, querySignal, async () => {
this.assertActive(querySignal)
// 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(this.fs, request.filePath, workspace, this.config.maxDocumentBytes, querySignal)
// 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(querySignal)
let instance = this.instanceFor(workspaceKey, workspace)
try {
return await instance.query(request, source, querySignal)
} catch (error) {
// A selected child can have died while idle or fail during the next write. Queries are
// read-only, so replace that transport once and retry transparently.
if (!instance.isTransportFailure(error)) throw error
await instance.dispose()
this.evictIfCurrent(workspaceKey, instance)
this.assertActive(querySignal)
instance = this.instanceFor(workspaceKey, workspace)
return await instance.query(request, source, querySignal)
} finally {
// Reach quiescence before dropping a dead slot; a replacement must survive this ownership check.
if (instance.dead) {
await instance.dispose()
this.evictIfCurrent(workspaceKey, instance)
}
}
})
}
/** Serialize one complete query lifecycle for a canonical workspace. */
private enqueue<T>(workspace: WorkspaceKey, 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. */
private instanceFor(workspaceKey: WorkspaceKey, workspace: HostWorkspace): LspInstance {
this.assertActive()
const existing = this.instances.get(workspaceKey)
if (existing !== undefined) return existing
const created = this.createInstance(workspace)
this.instances.set(workspaceKey, created)
return created
}
/** Drop the slot iff it still contains this instance. */
private evictIfCurrent(workspace: WorkspaceKey, instance: LspInstance): void {
/* v8 ignore next -- mismatch requires another query to replace the slot before this finally runs. */
if (this.instances.get(workspace) === instance) this.instances.delete(workspace)
}
private createInstance(workspace: HostWorkspace): LspInstance {
const spec: InstanceSpec = {
command: this.executable,
args: this.config.args,
cwd: workspace.canonicalPath,
workspaceUri: workspace.fileUrl,
env: this.config.env,
configuration: this.config.configuration,
initializationOptions: this.config.initializationOptions,
maxMessageBytes: this.config.maxMessageBytes,
maxStderrBytes: this.config.maxStderrBytes,
shutdownTimeoutMs: this.config.shutdownTimeoutMs,
killGraceMs: this.config.killGraceMs,
}
return new LspInstance(spec, this.spawner)
}
/** Dispose every live instance and block further queries. */
async disposeAll(): Promise<void> {
this.disposed = true
this.lifetime.abort(new LspError('lsp-stdio provider is disposed', 'LSP_DISPOSED'))
const live = [...this.instances.values()]
const draining = [...this.queues.values()]
const resolving = [...this.workspaceLookups]
this.instances.clear()
const results = await Promise.allSettled([
...live.map(instance => instance.dispose()),
...draining,
...resolving,
])
this.queues.clear()
this.workspaceLookups.clear()
throwTeardownFailures(results, 'lsp-stdio instance teardown failed')
}
}

View File

@@ -0,0 +1,347 @@
/**
* One language-server instance: a connection plus the initialize handshake, the serialized abortable
* query queue, the transient `didOpen`→request→`didClose` lifecycle, and bounded teardown. One
* instance owns one `(provider id, canonical workspace)` process. Queries serialize through a single
* queue so a cancellation that fails to stop the server can terminate it without killing unrelated
* work; distinct instances run in parallel.
* @module @deepseek-ai/dsh-lsp-stdio/instance
*/
import { LspError } from '@deepseek-ai/dsh-lsp'
import type {
LspOperation,
LspProviderQuery,
LspQueryResult,
} from '@deepseek-ai/dsh-lsp'
import { deadline } from '@deepseek-ai/dsh-timeout'
import { abortable, abortError } from './abort.ts'
import { LspConnection } from './connection.ts'
import type { ConnectionSpawner, ConnectionSpec, ConnectionWriter } from './connection.ts'
import type { HostSource } from './host.ts'
import type { WireInitializeResult, WireServerCapabilities } from './protocol.ts'
import {
negotiatePositionEncoding,
normalizeHover,
normalizeLocations,
requestMethod,
supportsOperation,
supportsTransientOpen,
} from './translate.ts'
/** Everything an instance needs beyond the connection spec. */
export interface InstanceSpec extends ConnectionSpec {
/** Canonical workspace file URI supplied by the filesystem provider. */
readonly workspaceUri: string
/** Static `initialize` options forwarded to the server. */
readonly initializationOptions: unknown
/** Graceful `shutdown`/`exit` budget before escalation (ms). */
readonly shutdownTimeoutMs: number
}
/**
* A single initialized server process. Not exported as a provider — the provider single-flights and
* pools these. `query()` serializes; `dispose()` rejects queued work and tears the process down.
*/
export class LspInstance {
private readonly connection: LspConnection
private capabilities: WireServerCapabilities | undefined
/** The serialization tail: each query awaits the prior one, so lifecycles never interleave. */
private queue: Promise<unknown> = Promise.resolve()
private disposed = false
/** The one teardown transaction shared by abort, failure, and explicit disposal. */
private teardownPromise: Promise<void> | undefined
/** Set once the process closes, so the pool can synchronously skip a dead instance. */
private processClosed = false
/** Populated once `initialize` succeeds; a failed handshake rejects every query. */
private readonly ready: Promise<void>
/**
* @param spec - the launch, initialize, and teardown parameters.
* @param spawner - the subprocess seam's spawn function.
* @param writer - optional connection writer used by transport conformance tests.
*/
constructor(private readonly spec: InstanceSpec, spawner: ConnectionSpawner, writer?: ConnectionWriter) {
this.connection = new LspConnection(spec, spawner, (method, params) => this.answerServerRequest(method, params), writer)
this.ready = this.initialize()
// A handshake rejection must not surface as an unhandled rejection before the first query awaits
// it; queries attach the real handler.
this.ready.catch(() => {})
void this.connection.closed.then(() => { this.processClosed = true })
}
/** Synchronous liveness check: true once the process has closed or the instance was disposed. */
get dead(): boolean {
return this.processClosed || this.disposed || this.connection.failed
}
/**
* Test whether a caught query error came from this instance's transport.
* @param error - error caught by the provider.
* @returns `true` only for the connection's retained fatal transport cause.
*/
isTransportFailure(error: unknown): boolean {
return this.connection.failedWith(error)
}
/**
* Run one query through the serialized queue.
* @param request - the resolved provider query.
* @param source - the pre-validated, already-read host source (the provider reads before spawning).
* @param signal - optional cancellation for this query's full lifecycle.
* @returns the normalized result.
*/
query(request: LspProviderQuery, source: HostSource, signal?: AbortSignal): Promise<LspQueryResult> {
// Serialize behind prior work, but observe abort DURING the queue wait too: if an earlier query
// hangs (e.g. a signal-less service caller), a later tool's timeout must still be able to give up
// rather than block on the shared tail forever.
const run = abortable(this.queue, signal)
.then(() => this.runQuery(request, source, signal))
.catch(async (error: unknown) => {
if (this.isTransportFailure(error)) await this.startTeardown()
throw error
})
// 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.
this.queue = this.queue.then(() => run).then(() => undefined, () => undefined)
return run
}
private async initialize(): Promise<void> {
const initializeResult = await this.connection.request('initialize', {
// A subprocess provider may run in another PID namespace or machine;
// the host PID would let the server monitor an unrelated process.
processId: null,
rootUri: this.spec.workspaceUri,
workspaceFolders: [{ uri: this.spec.workspaceUri, name: 'workspace' }],
capabilities: CLIENT_CAPABILITIES,
initializationOptions: this.spec.initializationOptions,
}) as WireInitializeResult
const capabilities = initializeResult.capabilities
// An omitted encoding defaults to utf-16; any other value is a protocol error we reject here.
negotiatePositionEncoding(capabilities.positionEncoding)
this.capabilities = capabilities
await this.connection.notify('initialized', {})
}
private async runQuery(request: LspProviderQuery, source: HostSource, signal?: AbortSignal): Promise<LspQueryResult> {
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
// in failure — an abort on a still-pending handshake, OR `initialize` rejecting (utf-8
// 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 abortable(this.ready, signal)
} catch (error) {
if (!this.dead) {
await this.startTeardown()
}
throw error
}
const capabilities = this.capabilities
/* v8 ignore next -- `ready` resolves only after capabilities are set, else it rejects above; defensive. */
if (capabilities === undefined) throw new Error('LSP instance is not initialized')
if (!supportsOperation(capabilities, request.operation)) {
throw new LspError(`server does not support ${request.operation}`, 'LSP_UNSUPPORTED_OPERATION')
}
if (!supportsTransientOpen(capabilities.textDocumentSync)) {
throw new LspError('server does not support the transient textDocument/didOpen this host requires', 'LSP_UNSUPPORTED_OPERATION')
}
const uri = source.fileUrl
let opened = false
try {
/* v8 ignore next -- guards an abort landing between the ready wait and didOpen; not deterministically reproducible. */
if (signal?.aborted) throw abortError(signal)
try {
await abortable(this.connection.notify('textDocument/didOpen', {
textDocument: { uri, languageId: request.languageId, version: 1, text: source.text },
}), signal)
} catch (error) {
// A canceled backpressured write or failed stdin leaves the protocol stream unusable before
// `opened` can arm the didClose cleanup. Teardown here makes the pool evict the instance.
await this.startTeardown()
throw error
}
opened = true
const payload = await this.sendRequest(request.operation, uri, request.position, signal)
return this.normalize(request.operation, payload)
} finally {
// A disposed or closed instance (e.g. an aborted request whose server ignored
// `$/cancelRequest`) is already tearing down; sending didClose would race that teardown and let
// the next queued query's document lifecycle overlap the still-active request.
if (opened && !this.dead) {
try {
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.
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. */
}
}
}
}
}
private async sendRequest(
operation: LspOperation,
uri: string,
position: LspProviderQuery['position'],
signal?: AbortSignal,
): Promise<unknown> {
const params = {
textDocument: { uri },
position: { line: position.line, character: position.character },
// 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)
if (signal === undefined) return send
return this.raceAbort(send, requestId, signal)
}
/**
* Race a pending request against abort. On abort, send `$/cancelRequest` and give the server a
* bounded grace to acknowledge; if it does not settle in time, invalidate and tear down the
* instance so the still-active request cannot overlap the next queued query's document lifecycle.
*/
private async raceAbort(send: Promise<unknown>, requestId: number, signal: AbortSignal): Promise<unknown> {
try {
return await abortable(send, signal)
} catch (error) {
if (!signal.aborted) throw error
this.connection.cancel(requestId)
// Wait, bounded, for the server to honor the cancellation. If it does not, the request is still
// running: terminate the instance (disposal awaits process close) so nothing outlives the query.
const grace = deadline(undefined, this.spec.killGraceMs, 'LSP_CANCEL_GRACE')
try {
// `settled` is true if the request finished (either outcome) before the grace elapsed.
const settled = await Promise.race([
send.then(markSettled, markSettled),
new Promise<boolean>((resolve) => {
/* v8 ignore next -- the cancel-grace deadline signal is freshly armed and not yet aborted here; defensive. */
if (grace.signal.aborted) { resolve(false); return }
grace.signal.addEventListener('abort', () => { resolve(false) }, { once: true })
}),
])
if (!settled) await this.startTeardown()
} finally {
grace[Symbol.dispose]()
}
throw error
}
}
private normalize(operation: LspOperation, payload: unknown): LspQueryResult {
if (operation === 'hover') {
return { kind: 'hover', hover: normalizeHover(payload) }
}
// The filesystem provider owns URI syntax for the execution platform, which may differ from the
// harness host. Preserve that coordinate through rendering instead of reparsing `spec.cwd` there.
return { kind: 'locations', locations: normalizeLocations(payload), resolvedWorkspaceUri: this.spec.workspaceUri }
}
private answerServerRequest(method: string, params: unknown): Promise<unknown> {
if (method === 'workspace/configuration') {
// Answer every requested item with the one static configuration value.
const record = params as { items?: unknown[] } | null
/* v8 ignore next -- a configuration request always carries an items array; the empty fallback is defensive. */
const items = Array.isArray(record?.items) ? record.items : []
return Promise.resolve(items.map(() => this.spec.configuration))
}
if (LIFECYCLE_NOOP_METHODS.has(method)) {
// Accept lifecycle bookkeeping requests with an empty result; we register nothing dynamic.
return Promise.resolve(null)
}
if (method === 'workspace/applyEdit') {
// This host never applies edits or runs commands.
return Promise.reject(new Error('workspace/applyEdit is not permitted by this host'))
}
return Promise.reject(new Error(`unsupported server request: ${method}`))
}
/**
* Reject queued work, attempt graceful `shutdown`/`exit`, then escalate SIGTERM→SIGKILL, awaiting
* process close so nothing outlives disposal.
*/
async dispose(): Promise<void> {
await this.startTeardown()
}
/** Publish disposal once and make every caller await the same quiescence boundary. */
private startTeardown(): Promise<void> {
this.disposed = true
this.teardownPromise ??= this.tearDown()
return this.teardownPromise
}
private async tearDown(): Promise<void> {
const shutdownDeadline = deadline(undefined, this.spec.shutdownTimeoutMs, 'LSP_SHUTDOWN')
try {
await this.gracefulShutdown(shutdownDeadline.signal)
} catch {
// Graceful shutdown failed or timed out; process-tree cleanup below remains authoritative.
} finally {
shutdownDeadline[Symbol.dispose]()
}
await this.forceTerminate()
}
/** Best-effort LSP `shutdown`/`exit`, including process close, bounded by `signal`. */
private async gracefulShutdown(signal: AbortSignal): Promise<void> {
await abortable(this.connection.request('shutdown', null), signal)
await this.connection.notify('exit', null)
await abortable(this.connection.closed, signal)
}
/**
* Terminate the tree (the seam escalates SIGTERM→`killGraceMs`→SIGKILL),
* then await leader and helper exit. The awaits are unbounded on purpose:
* the seam's escalation already committed to SIGKILL, so quiescence — not
* another timer — is the postcondition disposal owes its callers.
*/
private async forceTerminate(): Promise<void> {
this.connection.terminate()
await Promise.all([
this.connection.closed,
this.connection.waitForProcessTreeExit(),
])
}
}
/** Server→client request methods this host acknowledges with an empty result (no dynamic registration). */
const LIFECYCLE_NOOP_METHODS = new Set([
'window/workDoneProgress/create',
'client/registerCapability',
'client/unregisterCapability',
])
/** Mark a settled request in the cancel-grace race (either outcome means the request finished). */
function markSettled(): boolean {
return true
}
/**
* The client capabilities advertised at `initialize`: UTF-16 positions, workspace folders and
* configuration, markdown/plaintext hover, and link support for definition/implementation. No
* dynamic registration; the server's returned capabilities are authoritative.
*/
const CLIENT_CAPABILITIES = {
general: { positionEncodings: ['utf-16'] },
workspace: { workspaceFolders: true, configuration: true },
textDocument: {
synchronization: { dynamicRegistration: false },
hover: { contentFormat: ['markdown', 'plaintext'] },
definition: { linkSupport: true },
implementation: { linkSupport: true },
references: {},
},
} as const

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-lsp-stdio`.
* @module @deepseek-ai/dsh-lsp-stdio/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-lsp-stdio'
/** Cordis companion plugin name. */
export const name = 'lsp-stdio-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: process pools and per-workspace queues are private implementation state,
* and this provider publishes no independent lifecycle event stream or enumerable snapshot.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,80 @@
/**
* The subset of LSP wire types this generic host reads and writes: initialize capabilities, the four
* request results (`Location`, `LocationLink`, `Hover`), and the `textDocumentSync` shapes used to
* decide transient-open support. Types only. Fields absent from a real server payload stay optional;
* the translation layer normalizes them into the seam's closed unions.
* @module @deepseek-ai/dsh-lsp-stdio/protocol
*/
/** A zero-based UTF-16 position on the wire (the protocol's `Position`). */
export interface WirePosition {
readonly line: number
readonly character: number
}
/** A wire range (`Range`). */
export interface WireRange {
readonly start: WirePosition
readonly end: WirePosition
}
/** A `Location`: a document URI plus a range. */
export interface WireLocation {
readonly uri: string
readonly range: WireRange
}
/** A `LocationLink`: the target uri plus the selection range to focus. */
export interface WireLocationLink {
readonly targetUri: string
readonly targetSelectionRange: WireRange
readonly targetRange?: WireRange
}
/** A `MarkupContent` hover body (`markdown` or `plaintext`). */
export interface WireMarkupContent {
readonly kind: 'markdown' | 'plaintext'
readonly value: string
}
/** A `MarkedString` object form (`{ language, value }`); the string form is a bare `string`. */
export interface WireMarkedStringObject {
readonly language: string
readonly value: string
}
/** One `MarkedString`: a raw string or a language-tagged code block. */
export type WireMarkedString = string | WireMarkedStringObject
/** A `Hover`: contents in any of the protocol's three encodings, plus an optional range. */
export interface WireHover {
readonly contents: WireMarkupContent | WireMarkedString | readonly WireMarkedString[]
readonly range?: WireRange
}
/** The legacy enum form of `textDocumentSync` (`0` None, `1` Full, `2` Incremental). */
export type WireTextDocumentSyncKind = 0 | 1 | 2
/** The options form of `textDocumentSync` (`{ openClose, change }`). */
export interface WireTextDocumentSyncOptions {
readonly openClose?: boolean
readonly change?: WireTextDocumentSyncKind
}
/** A `ServerCapabilities.provider` slot: a boolean or an options object (both mean "supported"). */
export type WireProviderCapability = boolean | Record<string, unknown> | undefined
/** The `ServerCapabilities` fields this host inspects. */
export interface WireServerCapabilities {
readonly positionEncoding?: string
readonly textDocumentSync?: WireTextDocumentSyncKind | WireTextDocumentSyncOptions
readonly definitionProvider?: WireProviderCapability
readonly referencesProvider?: WireProviderCapability
readonly implementationProvider?: WireProviderCapability
readonly hoverProvider?: WireProviderCapability
}
/** The `initialize` result envelope. */
export interface WireInitializeResult {
readonly capabilities: WireServerCapabilities
}

View File

@@ -0,0 +1,235 @@
/**
* Pure protocol translation for the local host: what the server's capabilities allow, and how its
* `Location`/`LocationLink`/`Hover` payloads normalize into the seam's closed result unions. No I/O
* or process state — every function here is a pure transform, which the fake-stdio tests pin exactly.
* @module @deepseek-ai/dsh-lsp-stdio/translate
*/
import type {
LspHover,
LspLocation,
LspOperation,
LspRange,
} from '@deepseek-ai/dsh-lsp'
import { LspError } from '@deepseek-ai/dsh-lsp'
import { assertNever } from '@deepseek-ai/dsh-llm'
import type {
WireHover,
WireLocation,
WireLocationLink,
WireMarkedString,
WireProviderCapability,
WireRange,
WireServerCapabilities,
WireTextDocumentSyncKind,
} from './protocol.ts'
/**
* The `textDocument/*` request method for each LSP operation.
* @param operation - the LSP operation to map.
* @returns the LSP request method name.
*/
export function requestMethod(operation: LspOperation): string {
switch (operation) {
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')
}
}
/** The `ServerCapabilities` provider field backing each operation. */
function capabilityValue(capabilities: WireServerCapabilities, operation: LspOperation): WireProviderCapability {
switch (operation) {
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')
}
}
/** A provider capability is present when the server sent `true` or an options object (not `false`/absent). */
function supportsCapability(value: WireProviderCapability): boolean {
if (value === undefined) return false
if (typeof value === 'boolean') return value
return true
}
/**
* Whether the server advertises the requested operation.
* @param capabilities - the server's `initialize` capabilities.
* @param operation - the LSP operation to check.
* @returns true when the corresponding provider capability is present.
*/
export function supportsOperation(capabilities: WireServerCapabilities, operation: LspOperation): boolean {
return supportsCapability(capabilityValue(capabilities, operation))
}
/**
* Whether a `textDocumentSync` value permits the transient `didOpen`/`didClose` this host relies on.
* The legacy enum form implies open/close for `Full`/`Incremental`; the options form requires an
* explicit `openClose: true`, because the protocol defaults an omitted `openClose` to false.
* @param sync - the server's advertised `textDocumentSync` capability.
* @returns true when transient open/close is supported.
*/
export function supportsTransientOpen(sync: WireServerCapabilities['textDocumentSync']): boolean {
if (sync === undefined) return false
if (typeof sync === 'number') return isOpenCloseKind(sync)
return sync.openClose === true
}
/** Legacy enum: `Full` (1) or `Incremental` (2) imply open/close support; `None` (0) does not. */
function isOpenCloseKind(kind: WireTextDocumentSyncKind): boolean {
return kind === 1 || kind === 2
}
/**
* Normalize the negotiated position encoding. An omitted encoding defaults to `utf-16`; any value
* other than `utf-16` is a protocol error this host does not support.
* @param encoding - the server's advertised `positionEncoding`, if any.
* @returns the string `'utf-16'`.
* @throws Error for any non-`utf-16` encoding.
*/
export function negotiatePositionEncoding(encoding: string | undefined): 'utf-16' {
if (encoding === undefined || encoding === 'utf-16') return 'utf-16'
throw new Error(`server negotiated unsupported position encoding "${encoding}"; this host requires utf-16`)
}
/** Convert a wire range to the seam's range (structurally identical, but re-shaped as `readonly`). */
function toRange(range: WireRange): LspRange {
return {
start: { line: range.start.line, character: range.start.character },
end: { line: range.end.line, character: range.end.character },
}
}
/** Whether a record is a `LocationLink` (has `targetUri` + `targetSelectionRange`). */
function isLocationLink(value: Record<string, unknown>): boolean {
return typeof value.targetUri === 'string' && isRange(value.targetSelectionRange)
}
/** Whether a record is a `Location` (has string `uri` + a range). */
function isLocation(value: Record<string, unknown>): boolean {
return typeof value.uri === 'string' && isRange(value.range)
}
/** Structural range guard used by both location shapes. */
function isRange(value: unknown): value is WireRange {
if (value === null || typeof value !== 'object') return false
const range = value as Record<string, unknown>
return isPosition(range.start) && isPosition(range.end)
}
/** Structural position guard. */
function isPosition(value: unknown): boolean {
if (value === null || typeof value !== 'object') return false
const position = value as Record<string, unknown>
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
}
/**
* Normalize a navigation result (`Location`, `Location[]`, `LocationLink[]`, or `null`) to the seam's
* locations. `Location` maps directly; `LocationLink` maps `targetUri` + `targetSelectionRange`.
* @param payload - the raw `textDocument/definition|references|implementation` result.
* @returns the normalized locations (empty for `null`/`[]`).
* @throws Error when an element is neither a `Location` nor a `LocationLink`.
*/
export function normalizeLocations(payload: unknown): LspLocation[] {
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 malformedResponse('LSP navigation result contained a non-object entry')
}
const record = element as Record<string, unknown>
if (isLocationLink(record)) {
const link = record as unknown as WireLocationLink
locations.push({ uri: link.targetUri, range: toRange(link.targetSelectionRange) })
} else if (isLocation(record)) {
const location = record as unknown as WireLocation
locations.push({ uri: location.uri, range: toRange(location.range) })
} else {
throw malformedResponse('LSP navigation result contained neither a Location nor a LocationLink')
}
}
return locations
}
/** Render one `MarkedString` (string form verbatim; object form as a language-tagged fenced block). */
function renderMarkedString(value: WireMarkedString): string {
if (typeof value === 'string') return value
return `\`\`\`${value.language}\n${value.value}\n\`\`\``
}
/**
* 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. 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) 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
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 malformedResponse('LSP hover result had no contents')
}
if (typeof contents === 'string') return contents
if (Array.isArray(contents)) {
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 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') {
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 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

@@ -0,0 +1,80 @@
import { existsSync } from 'node:fs'
import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { execa } from 'execa'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
/**
* Keyless built-artifact smoke: plain Node imports `@deepseek-ai/dsh-lsp` and
* `@deepseek-ai/dsh-lsp-stdio` by name through their exports maps, spawns the fixture server, runs
* one query (exercising real `Content-Length` framing over `lib/index.js`), and disposes (exercising
* subprocess cleanup). Unit tests use `src/`; this pins the downstream `lib/` path. Skips when `lib/`
* is absent; CI runs it after the build.
*/
const pkgDir = fileURLToPath(new URL('..', import.meta.url))
const seamLib = join(pkgDir, '../lsp/lib/index.js')
const fsLib = join(pkgDir, '../../fs/fs-local/lib/index.js')
const subprocessLib = join(pkgDir, '../../subprocess/subprocess-local/lib/index.js')
const built = existsSync(join(pkgDir, 'lib/index.js')) && existsSync(seamLib) && existsSync(fsLib) && existsSync(subprocessLib)
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
let root: string
let ws: string
beforeAll(async () => {
root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-built-')))
ws = join(root, 'ws')
await mkdir(ws)
await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
})
afterAll(async () => {
if (root) await rm(root, { recursive: true, force: true })
})
describe.skipIf(!built)('built lib real load path (plain node)', () => {
it('runs a query through lib/index.js and disposes cleanly, framing over the base protocol', async () => {
const location = JSON.stringify({ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } })
const script = `
const { Context } = await import('@deepseek-ai/cordis')
const { default: Lsp } = await import('@deepseek-ai/dsh-lsp')
const LspLocal = await import('@deepseek-ai/dsh-lsp-stdio')
const { default: LocalFileSystem } = await import('@deepseek-ai/dsh-fs-local')
const { default: LocalSubprocessRuntime } = await import('@deepseek-ai/dsh-subprocess-local')
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await ctx.plugin(LspLocal, {
servers: {
fake: {
command: ${JSON.stringify(process.execPath)},
args: [${JSON.stringify(fixtureServer)}],
env: { LSP_FAKE_DEF: ${JSON.stringify(location)} },
extensionToLanguage: { '.ts': 'typescript' },
},
},
})
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()
`
const { exitCode, stdout, stderr } = await execa(process.execPath, ['--input-type=module', '-e', script], {
cwd: pkgDir,
stdin: 'ignore',
timeout: 55_000,
killSignal: 'SIGKILL',
reject: false,
})
expect(exitCode, `stderr:\n${stderr}`).toBe(0)
const lastLine = stdout.trim().split('\n').at(-1) ?? ''
const result = JSON.parse(lastLine) as { kind: string; locations: unknown[] }
expect(result.kind).toBe('locations')
expect(result.locations).toHaveLength(1)
}, 60_000)
})

View File

@@ -0,0 +1,261 @@
import { afterEach, describe, expect, it } from 'vitest'
import { fileURLToPath } from 'node:url'
import { LspConnection } from '@deepseek-ai/dsh-lsp-stdio'
import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-stdio/src/connection.ts'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts'
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
/** A recorded server→client request the test's handler saw. */
interface SeenRequest { method: string; params: unknown }
let open: LspConnection[] = []
afterEach(async () => {
for (const conn of open) {
conn.terminate()
await conn.closed
}
open = []
})
/** Spawn the fixture as a raw connection, with a scripted server-request handler. */
function connect(
env: Record<string, string>,
onServerRequest: (method: string, params: unknown) => Promise<unknown> = () => Promise.resolve(null),
seen?: SeenRequest[],
): LspConnection {
const conn = new LspConnection({
command: process.execPath,
args: [fixtureServer],
cwd: process.cwd(),
env: { ...scrubbedParentEnv(), ...env },
maxMessageBytes: 16_000_000,
maxStderrBytes: 100_000,
killGraceMs: 3_000,
configuration: { setting: 42 },
}, spawnSubprocess, (method, params) => {
seen?.push({ method, params })
return onServerRequest(method, params)
})
open.push(conn)
return conn
}
describe('LspConnection', () => {
it('completes an initialize request/response round-trip and exposes a pid', async () => {
const conn = connect({})
const result = await conn.request('initialize', { capabilities: {} })
expect(result).toMatchObject({ capabilities: { hoverProvider: true } })
expect(conn.pid).toBeGreaterThan(0)
})
it('forwards explicit DSH_* env entries to the child', async () => {
// A configured DSH_* fact must reach the child: the seam scrubs only the
// ambient namespace, and the explicit entry merges after that scrub. The
// fixture echoes the named variable back as hover text.
const conn = connect({ LSP_FAKE_ECHO_ENV: 'DSH_LSP_TEST_FACT', DSH_LSP_TEST_FACT: 'managed' })
await conn.request('initialize', { capabilities: {} })
expect(await conn.request('textDocument/hover', {})).toEqual({ contents: 'managed' })
})
it('rejects a request when the server replies with an error', async () => {
const conn = connect({ LSP_FAKE_ERROR: '1' })
await conn.request('initialize', { capabilities: {} })
await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/server refused the request/)
})
it('treats terminating an already-closed child as a teardown race', async () => {
const conn = connectScript('')
await conn.closed
expect(() => { conn.terminate() }).not.toThrow()
})
it('answers a server workspace/configuration request from static config', async () => {
const seen: SeenRequest[] = []
const conn = connect(
{ LSP_FAKE_ON_OPEN: 'configuration' },
(method, params) => {
if (method === 'workspace/configuration') {
const items = (params as { items: unknown[] }).items
return Promise.resolve(items.map(() => ({ setting: 42 })))
}
return Promise.resolve(null)
},
seen,
)
await conn.request('initialize', { capabilities: {} })
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')
})
it('drops a server→client notification without replying', async () => {
const conn = connect({ LSP_FAKE_ON_OPEN: 'notification' })
await conn.request('initialize', { capabilities: {} })
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()
})
it('sends an error response when the server-request handler rejects', async () => {
const seen: SeenRequest[] = []
const conn = connect(
{ LSP_FAKE_ON_OPEN: 'applyEdit' },
method => method === 'workspace/applyEdit' ? Promise.reject(new Error('not permitted')) : Promise.resolve(null),
seen,
)
await conn.request('initialize', { capabilities: {} })
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()
})
it('fails all pending requests and kills the process on a framing error', async () => {
const conn = connect({ LSP_FAKE_GARBAGE: '1' })
// The garbage byte precedes a valid initialize reply; unframed bytes are tolerated until a
// Content-Length header, so initialize still resolves. This exercises the decoder's resilience.
await expect(conn.request('initialize', { capabilities: {} })).resolves.toBeDefined()
})
it('rejects a new request issued after the process closes', async () => {
const conn = connect({})
await conn.request('initialize', { capabilities: {} })
conn.terminate()
await conn.closed
await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/exited|closed/)
})
it('cancel is a no-op-safe write after close', async () => {
const conn = connect({})
await conn.request('initialize', { capabilities: {} })
conn.terminate()
await conn.closed
expect(() => { conn.cancel(1) }).not.toThrow()
})
it('caps the retained stderr tail', async () => {
const conn = connect({})
await conn.request('initialize', { capabilities: {} })
expect(conn.stderrTail.length).toBeLessThanOrEqual(100_000)
})
})
/** Spawn a raw connection running an inline node script as the "server". */
function connectScript(script: string, maxStderrBytes = 100_000, writer?: ConnectionWriter): LspConnection {
const conn = new LspConnection({
command: process.execPath,
args: ['-e', script],
cwd: process.cwd(),
env: scrubbedParentEnv(),
maxMessageBytes: 16_000_000,
maxStderrBytes,
killGraceMs: 3_000,
configuration: null,
}, spawnSubprocess, () => Promise.resolve(null), writer)
open.push(conn)
return conn
}
describe('LspConnection edge behavior', () => {
it('fails a request when the command cannot be spawned', async () => {
const conn = new LspConnection({
command: '/definitely/not/a/real/binary/xyz',
args: [],
cwd: process.cwd(),
env: {},
maxMessageBytes: 1000,
maxStderrBytes: 1000,
killGraceMs: 3_000,
configuration: null,
}, spawnSubprocess, () => Promise.resolve(null))
open.push(conn)
await expect(conn.request('initialize', {})).rejects.toThrow()
})
it('kills the process and fails pending requests on a framing error', async () => {
// Emit an invalid Content-Length header, corrupting the stream irrecoverably.
const conn = connectScript('process.stdout.write("Content-Length: abc\\r\\n\\r\\n{}"); setInterval(()=>{}, 1000)')
await expect(conn.request('initialize', {})).rejects.toThrow()
})
it('ignores a framed non-object message', async () => {
// Send a framed JSON number and a framed null (both non-objects) then a proper response to id 1.
const script = 'let b=Buffer.alloc(0);'
+ 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
+ 'process.stdout.write(fr("42"));process.stdout.write(fr("null"));'
+ 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,result:{ok:true}})));});'
const conn = connectScript(script)
await expect(conn.request('initialize', {})).resolves.toEqual({ ok: true })
})
it('drops a response for an unknown id', async () => {
// Emit a response for id 999 (never sent), then answer our real request.
const script = 'let b=Buffer.alloc(0);'
+ 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
+ 'process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:999,result:{stray:true}})));'
+ 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,result:{ok:true}})));});'
const conn = connectScript(script)
await expect(conn.request('initialize', {})).resolves.toEqual({ ok: true })
})
it('caps the retained stderr tail at maxStderrBytes across chunks', async () => {
// Write stderr repeatedly so a later chunk arrives after the cap is already reached.
const conn = connectScript('setInterval(()=>process.stderr.write("E".repeat(200)), 5); setInterval(()=>{}, 1000)', 100)
await waitFor(() => conn.stderrTail.length >= 100)
await new Promise<void>(resolve => setTimeout(resolve, 50))
expect(conn.stderrTail.length).toBe(100)
})
it('caps the retained stderr tail by bytes for multibyte UTF-8', async () => {
const conn = connectScript('process.stderr.write("😀😀")', 4)
await conn.closed
expect(conn.stderrTail).toBe('😀')
expect(Buffer.byteLength(conn.stderrTail)).toBe(4)
})
it('rejects with a fallback message when the error response has no message string', async () => {
const script = 'let b=Buffer.alloc(0);'
+ 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
+ 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,error:{code:-1}})));});'
const conn = connectScript(script)
await expect(conn.request('initialize', {})).rejects.toThrow(/LSP error response/)
})
it('rejects a pending request when the process exits mid-flight', async () => {
// Never responds, then exits shortly: the pending request must reject on close.
const conn = connectScript('setTimeout(()=>process.exit(0), 100)')
await expect(conn.request('initialize', {})).rejects.toThrow(/exited|closed/)
})
it('rejects a pending request when child stdin fails but the process stays alive', async () => {
const failure = new Error('fixture stdin failure')
const writer: ConnectionWriter = (_stdin, _message, done) => {
queueMicrotask(() => { done(failure) })
}
const conn = connectScript('setInterval(()=>{}, 1000)', 100_000, writer)
await expect(conn.request('initialize', {})).rejects.toThrow(/fixture stdin failure/)
})
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.
const script = 'let b=Buffer.alloc(0);'
+ 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
+ 'process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:"str-id"})));'
+ 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,result:{ok:true}})));});'
const conn = connectScript(script)
await expect(conn.request('initialize', {})).resolves.toEqual({ ok: true })
})
})
/** Poll a predicate until it holds or a deadline elapses. */
async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise<void> {
const start = Date.now()
while (!predicate()) {
if (Date.now() - start > timeoutMs) throw new Error('waitFor timed out')
await new Promise<void>(resolve => setTimeout(resolve, 10))
}
}

View File

@@ -0,0 +1,207 @@
/**
* A scriptable fake LSP server over stdio for lsp-stdio tests. It speaks the real
* `Content-Length`-framed base protocol so it exercises the client's framing, initialize handshake,
* transient open/close, request mapping, and teardown — without a real language server.
*
* Behavior is driven by env vars so one file backs many scenarios:
* - LSP_FAKE_ENCODING: advertised positionEncoding (default utf-16; "utf-8" forces a mismatch).
* - LSP_FAKE_SYNC: textDocumentSync value as JSON (default 1/Full).
* - LSP_FAKE_CAPS: JSON of extra capability flags merged into the defaults.
* - LSP_FAKE_DEF / LSP_FAKE_REFS / LSP_FAKE_IMPL / LSP_FAKE_HOVER: JSON result per request.
* - LSP_FAKE_HANG: "1" makes textDocument/* requests never respond (for abort/timeout tests).
* - LSP_FAKE_CRASH_ON_OPEN: "1" exits the process when a didOpen arrives (crash test).
* - LSP_FAKE_EXIT_AFTER_REPLY: "1" exits the process right after answering a textDocument/* request,
* simulating a server that dies while idle so the pool holds a dead instance (eviction test).
* - LSP_FAKE_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_INITIALIZED_MARKER: records when the initialized notification is received.
* - LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED: "1" stops consuming stdin after initialized.
* - LSP_FAKE_EXIT_DELAY_MS / LSP_FAKE_EXIT_MARKER: delay protocol exit and record exit/termination.
* - LSP_FAKE_NO_SHUTDOWN: "1" ignores the shutdown request (forces kill escalation).
* - LSP_FAKE_ON_OPEN: server→client request to emit when a didOpen arrives, one of
* "configuration" | "applyEdit" | "notification" | "unknown"; the reply is logged to stderr.
* - 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 fixture-server.ts (Node's erasable TypeScript syntax support).
*/
import { appendFileSync } from 'node:fs'
const enc = process.env.LSP_FAKE_ENCODING ?? 'utf-16'
const sync: unknown = process.env.LSP_FAKE_SYNC !== undefined ? JSON.parse(process.env.LSP_FAKE_SYNC) : 1
const extraCaps: unknown = process.env.LSP_FAKE_CAPS !== undefined ? JSON.parse(process.env.LSP_FAKE_CAPS) : {}
const hang = process.env.LSP_FAKE_HANG === '1'
const crashOnOpen = process.env.LSP_FAKE_CRASH_ON_OPEN === '1'
const exitAfterReply = process.env.LSP_FAKE_EXIT_AFTER_REPLY === '1'
const replyDelayMs = Number(process.env.LSP_FAKE_REPLY_DELAY_MS ?? 0)
const openMarker = process.env.LSP_FAKE_OPEN_MARKER
const initializedMarker = process.env.LSP_FAKE_INITIALIZED_MARKER
const pauseStdinAfterInitialized = process.env.LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED === '1'
const exitDelayMs = Number(process.env.LSP_FAKE_EXIT_DELAY_MS ?? 0)
const exitMarker = process.env.LSP_FAKE_EXIT_MARKER
const noShutdown = process.env.LSP_FAKE_NO_SHUTDOWN === '1'
const onOpen = process.env.LSP_FAKE_ON_OPEN
const errorReply = process.env.LSP_FAKE_ERROR === '1'
const garbage = process.env.LSP_FAKE_GARBAGE === '1'
let serverRequestId = 10_000
const pendingServerRequests = new Map<number, string>()
process.on('SIGTERM', () => {
markExit('TERM')
process.exit(0)
})
function resultFor(method: string): unknown {
switch (method) {
case 'textDocument/definition': return envJson('LSP_FAKE_DEF', null)
case 'textDocument/references': return envJson('LSP_FAKE_REFS', null)
case 'textDocument/implementation': return envJson('LSP_FAKE_IMPL', null)
case 'textDocument/hover': {
// LSP_FAKE_ECHO_ENV names a variable whose VALUE becomes the hover
// contents — a test can assert exactly what env reached this process.
const echoName = process.env.LSP_FAKE_ECHO_ENV
if (echoName !== undefined) return { contents: process.env[echoName] ?? `<${echoName} unset>` }
return envJson('LSP_FAKE_HOVER', null)
}
default: return null
}
}
function envJson(name: string, fallback: unknown): unknown {
const raw = process.env[name]
return raw === undefined ? fallback : JSON.parse(raw)
}
let buffer = Buffer.alloc(0)
process.stdin.on('data', (chunk: Buffer) => {
buffer = Buffer.concat([buffer, chunk])
for (;;) {
const sep = buffer.indexOf('\r\n\r\n')
if (sep < 0) break
const header = buffer.toString('ascii', 0, sep)
const match = /content-length:\s*(\d+)/i.exec(header)
if (!match) { buffer = buffer.subarray(sep + 4); continue }
const length = Number(match[1])
const start = sep + 4
if (buffer.length < start + length) break
const body = buffer.toString('utf8', start, start + length)
buffer = buffer.subarray(start + length)
handle(JSON.parse(body) as { id?: number; method?: string; params?: unknown; result?: unknown; error?: unknown })
}
})
function handle(message: { id?: number; method?: string; params?: unknown; result?: unknown; error?: unknown }): void {
const { id, method } = message
// A frame with an id but no method is the client's REPLY to a server→client request; log it.
if (method === undefined && id !== undefined && pendingServerRequests.has(id)) {
const kind = pendingServerRequests.get(id)
pendingServerRequests.delete(id)
process.stderr.write(`REPLY ${kind} ${JSON.stringify({ result: message.result, error: message.error })}\n`)
return
}
if (method === 'initialize') {
if (garbage) process.stdout.write('this is not a framed message\r\n')
send({
id,
result: {
capabilities: {
positionEncoding: enc,
textDocumentSync: sync,
definitionProvider: true,
referencesProvider: true,
implementationProvider: true,
hoverProvider: true,
...(extraCaps as Record<string, unknown>),
},
},
})
return
}
if (method === 'shutdown') {
if (noShutdown) return
send({ id, result: null })
return
}
if (method === 'exit') {
markExit('EXIT')
if (exitDelayMs > 0) {
setTimeout(() => {
markExit('CLEAN')
process.exit(0)
}, exitDelayMs)
return
}
markExit('CLEAN')
process.exit(0)
}
if (method === 'textDocument/didOpen') {
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 === 'initialized') {
if (initializedMarker !== undefined) appendFileSync(initializedMarker, 'INITIALIZED\n')
if (pauseStdinAfterInitialized) process.stdin.pause()
return
}
if (method === 'textDocument/didClose') return
if (method?.startsWith('textDocument/')) {
if (hang) return
const reply = (): void => {
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.
if (id !== undefined) send({ id, result: null })
}
/** Append one teardown event when the fixture is configured to expose process ordering. */
function markExit(event: string): void {
if (exitMarker !== undefined) appendFileSync(exitMarker, `${event}\n`)
}
/** Emit a server→client request and log the client's reply to stderr for the test to assert. */
function emitServerRequest(kind: string): void {
if (kind === 'notification') {
send({ method: 'window/logMessage', params: { type: 3, message: 'hello' } })
return
}
const id = serverRequestId++
const method = kind === 'configuration'
? 'workspace/configuration'
: kind === 'applyEdit'
? 'workspace/applyEdit'
: kind === 'lifecycle'
? 'client/registerCapability'
: 'window/showMessageRequest'
const params = kind === 'configuration' ? { items: [{ section: 'a' }, { section: 'b' }] } : {}
pendingServerRequests.set(id, method)
send({ id, method, params })
}
function send(message: Record<string, unknown>): void {
const body = Buffer.from(JSON.stringify({ jsonrpc: '2.0', ...message }), 'utf8')
process.stdout.write(Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii'), body]))
}
// Keep the event loop alive.
process.stdin.resume()
if (pauseStdinAfterInitialized) {
setInterval(() => {}, 1000)
}

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest'
import { encodeMessage, MessageDecoder } from '@deepseek-ai/dsh-lsp-stdio'
/** Frame a message the way a server would, for decoder round-trips. */
function frame(body: string): Buffer {
return Buffer.concat([Buffer.from(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n`, 'ascii'), Buffer.from(body, 'utf8')])
}
describe('encodeMessage', () => {
it('prefixes a Content-Length header with the utf-8 byte length', () => {
const buffer = encodeMessage({ jsonrpc: '2.0', method: 'x', params: { s: 'é' } })
const text = buffer.toString('utf8')
const body = '{"jsonrpc":"2.0","method":"x","params":{"s":"é"}}'
expect(text).toBe(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`)
})
})
describe('MessageDecoder', () => {
it('decodes a single framed message', () => {
const decoder = new MessageDecoder(1_000)
expect(decoder.push(frame('{"id":1,"result":42}'))).toEqual([{ id: 1, result: 42 }])
})
it('decodes multiple messages arriving in one chunk', () => {
const decoder = new MessageDecoder(1_000)
const chunk = Buffer.concat([frame('{"a":1}'), frame('{"b":2}')])
expect(decoder.push(chunk)).toEqual([{ a: 1 }, { b: 2 }])
})
it('reassembles a message split across chunks', () => {
const decoder = new MessageDecoder(1_000)
const full = frame('{"hello":"world"}')
expect(decoder.push(full.subarray(0, 10))).toEqual([])
expect(decoder.push(full.subarray(10))).toEqual([{ hello: 'world' }])
})
it('handles a header split from its body', () => {
const decoder = new MessageDecoder(1_000)
const body = '{"x":1}'
expect(decoder.push(Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii'))).toEqual([])
expect(decoder.push(Buffer.from(body, 'utf8'))).toEqual([{ x: 1 }])
})
it('reads a case-insensitive header and ignores other headers', () => {
const decoder = new MessageDecoder(1_000)
const body = '{"ok":true}'
const chunk = Buffer.from(`content-length: ${body.length}\r\nContent-Type: x\r\n\r\n${body}`, 'utf8')
expect(decoder.push(chunk)).toEqual([{ ok: true }])
})
it('rejects a body over the size limit', () => {
const decoder = new MessageDecoder(4)
expect(() => decoder.push(frame('{"big":true}'))).toThrow(/exceeds the 4-byte limit/)
})
it('rejects a missing Content-Length header', () => {
const decoder = new MessageDecoder(1_000)
expect(() => decoder.push(Buffer.from('X: 1\r\n\r\n{}', 'utf8'))).toThrow(/missing Content-Length/)
})
it('rejects a non-numeric Content-Length', () => {
const decoder = new MessageDecoder(1_000)
expect(() => decoder.push(Buffer.from('Content-Length: abc\r\n\r\n{}', 'utf8'))).toThrow(/invalid Content-Length/)
})
it('rejects a header block that never terminates', () => {
const decoder = new MessageDecoder(1_000)
const huge = Buffer.alloc((1 << 16) + 1, 0x41)
expect(() => decoder.push(huge)).toThrow(/exceeded .* bytes without a terminator/)
})
it('rejects an oversized header block that includes its terminator', () => {
const decoder = new MessageDecoder(1_000)
const huge = Buffer.from(`Content-Length: 2\r\nX-Fill: ${'a'.repeat(70_000)}\r\n\r\n{}`, 'ascii')
expect(() => decoder.push(huge)).toThrow(/header exceeded .* bytes/)
})
it('rejects a non-JSON body', () => {
const decoder = new MessageDecoder(1_000)
expect(() => decoder.push(frame('not json'))).toThrow(/not valid JSON/)
})
})

View File

@@ -0,0 +1,184 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
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 { pathToFileURL } from 'node:url'
import { promisify } from 'node:util'
import { Context } from '@deepseek-ai/cordis'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import { deadline } from '@deepseek-ai/dsh-timeout'
import { canonicalizeWorkspace, readHostSource } from '@deepseek-ai/dsh-lsp-stdio'
const execFileAsync = promisify(execFile)
let root: string
let ws: string
let ctx: Context
let fs: LocalFileSystem
beforeEach(async () => {
root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-host-')))
ws = join(root, 'ws')
await mkdir(ws)
ctx = new Context()
await ctx.plugin(LocalFileSystem, { cwd: root })
fs = ctx.fs as LocalFileSystem
})
afterEach(async () => {
await ctx.fiber.dispose()
await rm(root, { recursive: true, force: true })
})
const BIG = 1_000_000
async function workspace() {
return await canonicalizeWorkspace(fs, ws)
}
async function readSource(filePath: string, maxBytes = BIG, signal?: AbortSignal) {
return await readHostSource(fs, filePath, await workspace(), maxBytes, signal)
}
describe('canonicalizeWorkspace', () => {
it('returns the realpath of a directory', async () => {
expect((await workspace()).canonicalPath).toBe(ws)
})
it('resolves a symlinked workspace to its target so aliases share identity', async () => {
const link = join(root, 'ws-link')
await symlink(ws, link)
expect((await canonicalizeWorkspace(fs, link)).canonicalPath).toBe(ws)
})
it('rejects a missing workspace', async () => {
await expect(canonicalizeWorkspace(fs, join(root, 'nope'))).rejects.toThrow(/not a directory/)
})
it('wraps a provider failure while resolving the workspace', async () => {
fs.resolve = async () => { throw 'raw workspace resolve failure' }
await expect(canonicalizeWorkspace(fs, ws))
.rejects.toThrow(`workspace root "${ws}" cannot be resolved: raw workspace resolve failure`)
})
it('rejects a non-directory workspace', async () => {
const file = join(root, 'file.txt')
await writeFile(file, 'x')
await expect(canonicalizeWorkspace(fs, file)).rejects.toThrow(/not a directory/)
})
it('normalizes workspace metadata cancellation and preserves other provider failures', async () => {
const providerFailure = new Error('workspace metadata failed')
fs.stat = async () => { throw providerFailure }
await expect(canonicalizeWorkspace(fs, ws)).rejects.toBe(providerFailure)
const controller = new AbortController()
fs.stat = async () => {
controller.abort(new Error('workspace metadata cancelled'))
throw providerFailure
}
await expect(canonicalizeWorkspace(fs, ws, controller.signal))
.rejects.toThrow('workspace metadata cancelled')
})
})
describe('readHostSource', () => {
it('reads a relative path against the workspace', async () => {
await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
const source = await readSource('a.ts')
expect(source.fileUrl).toBe(pathToFileURL(join(ws, 'a.ts')).href)
expect(source.text).toBe('const x = 1\n')
})
it('reads an absolute path inside the workspace', async () => {
const abs = join(ws, 'b.ts')
await writeFile(abs, 'b')
const source = await readSource(abs)
expect(source.fileUrl).toBe(pathToFileURL(abs).href)
})
it('accepts a source reached through a symlink that stays inside the workspace', async () => {
await mkdir(join(ws, 'real'))
await writeFile(join(ws, 'real', 'c.ts'), 'c')
await symlink(join(ws, 'real'), join(ws, 'linked'))
const source = await readSource('linked/c.ts')
expect(source.fileUrl).toBe(pathToFileURL(join(ws, 'real', 'c.ts')).href)
})
it('rejects a source whose canonical path escapes the workspace via symlink', async () => {
const outside = join(root, 'outside.ts')
await writeFile(outside, 'secret')
await symlink(outside, join(ws, 'escape.ts'))
await expect(readSource('escape.ts')).rejects.toThrow(/outside the workspace/)
})
it('rejects an absolute source outside the workspace', async () => {
const outside = join(root, 'out.ts')
await writeFile(outside, 'x')
await expect(readSource(outside)).rejects.toThrow(/outside the workspace/)
})
it('rejects a missing source', async () => {
await expect(readSource('nope.ts')).rejects.toThrow(/not found/)
})
it('wraps a provider failure while resolving the source', async () => {
const canonical = await workspace()
fs.resolve = async () => { throw 'raw resolve failure' }
await expect(readHostSource(fs, 'broken.ts', canonical, BIG))
.rejects.toThrow('source "broken.ts" cannot be resolved: raw resolve failure')
})
it('rejects a non-regular source (directory)', async () => {
await mkdir(join(ws, 'dir'))
await expect(readSource('dir')).rejects.toThrow(/not a regular file/)
})
// Windows has no filesystem FIFO; the directory case above pins non-regular rejection there.
it.skipIf(process.platform === 'win32')('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(readSource('pipe.ts', 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(readSource('missing.ts', BIG, controller.signal)).rejects.toThrow(/source read cancelled/)
})
it('treats the workspace root itself as inside, then rejects it as non-regular', async () => {
// The filesystem containment primitive accepts the workspace itself; the
// bounded read then rejects the directory as non-regular.
await expect(readSource('.')).rejects.toThrow(/not a regular file/)
})
it('rejects an oversized source and reports the observed lower bound', async () => {
await writeFile(join(ws, 'big.ts'), 'x'.repeat(100))
await expect(readSource('big.ts', 10)).rejects.toMatchObject({
message: 'source "big.ts" exceeds the 10-byte limit; reading stopped after 100 bytes',
})
})
it('counts the complete UTF-8 byte length at the configured boundary', async () => {
await writeFile(join(ws, 'multibyte.ts'), '€abc')
await expect(readSource('multibyte.ts', 6)).resolves.toMatchObject({ text: '€abc' })
await expect(readSource('multibyte.ts', 5)).rejects.toThrow(/5-byte limit/)
})
it('rejects a non-UTF-8 source', async () => {
await writeFile(join(ws, 'bin.ts'), Buffer.from([0xff, 0xfe, 0x00]))
await expect(readSource('bin.ts')).rejects.toThrow(/invalid UTF-8|binary file/)
})
it('keeps a valid U+FFFD replacement character in otherwise-valid UTF-8', async () => {
// The literal replacement char is valid UTF-8; a fatal decoder must accept it (only malformed
// byte sequences are rejected).
await writeFile(join(ws, 'repl.ts'), 'const s = "<22>"\n')
const source = await readSource('repl.ts')
expect(source.text).toBe('const s = "<22>"\n')
})
})

View File

@@ -0,0 +1,398 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { mkdtemp, mkdir, readFile, rm, writeFile, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL, fileURLToPath } from 'node:url'
import { Context } from '@deepseek-ai/cordis'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import { LspInstance, readHostSource } from '@deepseek-ai/dsh-lsp-stdio'
import { encodeMessage } from '@deepseek-ai/dsh-lsp-stdio'
import type { ConnectionWriter } from '@deepseek-ai/dsh-lsp-stdio/src/connection.ts'
import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-stdio/src/instance.ts'
import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import { spawnSubprocess } from '@deepseek-ai/dsh-subprocess-local/src/spawn.ts'
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
let root: string
let ws: string
let ctx: Context
let fs: LocalFileSystem
let live: LspInstance[] = []
beforeEach(async () => {
root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-inst-')))
ws = join(root, 'ws')
await mkdir(ws)
await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
ctx = new Context()
await ctx.plugin(LocalFileSystem, { cwd: root })
fs = ctx.fs as LocalFileSystem
})
afterEach(async () => {
for (const instance of live) await instance.dispose()
live = []
await ctx.fiber.dispose()
await rm(root, { recursive: true, force: true })
})
function makeInstance(
env: Record<string, string> = {},
overrides: Partial<InstanceSpec> = {},
writer?: ConnectionWriter,
): LspInstance {
const instance = new LspInstance({
command: process.execPath,
args: [fixtureServer],
cwd: ws,
workspaceUri: pathToFileURL(ws).href,
env: { ...scrubbedParentEnv(), ...env },
configuration: { setting: 42 },
initializationOptions: { init: true },
maxMessageBytes: 16_000_000,
maxStderrBytes: 100_000,
shutdownTimeoutMs: 200,
killGraceMs: 200,
...overrides,
}, spawnSubprocess, writer)
live.push(instance)
return instance
}
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'] = 'goToDefinition', signal?: AbortSignal): Promise<LspQueryResult> {
const workspace = {
target: await fs.resolve(ws),
canonicalPath: ws,
fileUrl: pathToFileURL(ws).href,
}
const source = await readHostSource(fs, 'a.ts', workspace, 4_000_000)
return instance.query(query(operation), source, signal)
}
/** Build an instance whose "server" is an inline node script (for teardown-escalation control). */
function scriptInstance(script: string, overrides: Partial<InstanceSpec> = {}): LspInstance {
const instance = new LspInstance({
command: process.execPath,
args: ['-e', script],
cwd: ws,
workspaceUri: pathToFileURL(ws).href,
env: scrubbedParentEnv(),
configuration: null,
initializationOptions: null,
maxMessageBytes: 16_000_000,
maxStderrBytes: 100_000,
shutdownTimeoutMs: 150,
killGraceMs: 150,
...overrides,
}, spawnSubprocess)
live.push(instance)
return instance
}
/** An inline server that answers initialize + definition and echoes a location. */
const RESPONDING_SERVER =
'let b=Buffer.alloc(0);'
+ 'const fr=(o)=>{const x=Buffer.from(JSON.stringify({jsonrpc:"2.0",...o}));return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
+ 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);for(;;){const s=b.indexOf("\\r\\n\\r\\n");if(s<0)break;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);if(b.length<s+4+len)break;const m=JSON.parse(b.toString("utf8",s+4,s+4+len));b=b.subarray(s+4+len);'
+ 'if(m.method==="initialize")process.stdout.write(fr({id:m.id,result:{capabilities:{positionEncoding:"utf-16",textDocumentSync:1,definitionProvider:true}}}));'
+ 'else if(m.method==="textDocument/definition")process.stdout.write(fr({id:m.id,result:null}));'
+ '}});'
const locJson = () => JSON.stringify({ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } })
describe('LspInstance server-request handling', () => {
it('answers workspace/configuration with the static config per item', async () => {
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, '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, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceUri: pathToFileURL(ws).href })
})
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, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceUri: pathToFileURL(ws).href })
})
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, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceUri: pathToFileURL(ws).href })
})
})
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, '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, '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, 'goToDefinition', controller.signal)
await new Promise<void>(resolve => setTimeout(resolve, 300))
controller.abort(new Error('mid-flight'))
await expect(pending).rejects.toThrow(/mid-flight/)
})
it('terminates the instance when the server ignores $/cancelRequest past the grace', async () => {
// The hang server never honors cancellation, so after the bounded grace the instance must be torn
// down (its process closed) rather than left with an active request.
const instance = makeInstance({ LSP_FAKE_HANG: '1' }, { killGraceMs: 100 })
const controller = new AbortController()
const pending = run(instance, 'goToDefinition', controller.signal)
await new Promise<void>(resolve => setTimeout(resolve, 300))
controller.abort(new Error('mid-flight'))
await expect(pending).rejects.toThrow(/mid-flight/)
expect(instance.dead).toBe(true)
})
it('resolves the cancel grace when the server honors $/cancelRequest', async () => {
// A server that answers $/cancelRequest by settling the pending request lets the grace race
// resolve via the request rather than the timeout, so the instance is NOT force-terminated.
const script = 'let b=Buffer.alloc(0),reqId=null;'
+ 'const fr=(o)=>{const x=Buffer.from(JSON.stringify({jsonrpc:"2.0",...o}));return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
+ 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);for(;;){const s=b.indexOf("\\r\\n\\r\\n");if(s<0)break;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);if(b.length<s+4+len)break;const m=JSON.parse(b.toString("utf8",s+4,s+4+len));b=b.subarray(s+4+len);'
+ 'if(m.method==="initialize")process.stdout.write(fr({id:m.id,result:{capabilities:{positionEncoding:"utf-16",textDocumentSync:1,definitionProvider:true}}}));'
+ 'else if(m.method==="textDocument/definition")reqId=m.id;'
+ 'else if(m.method==="$/cancelRequest"&&reqId!==null)process.stdout.write(fr({id:reqId,error:{code:-32800,message:"request cancelled"}}));'
+ 'else if(m.method==="shutdown")process.stdout.write(fr({id:m.id,result:null}));'
+ 'else if(m.method==="exit")process.exit(0);'
+ '}});'
const instance = scriptInstance(script, { killGraceMs: 2_000 })
const controller = new AbortController()
const pending = run(instance, 'goToDefinition', controller.signal)
await new Promise<void>(resolve => setTimeout(resolve, 300))
controller.abort(new Error('mid-flight'))
await expect(pending).rejects.toThrow(/mid-flight/)
// The server acknowledged cancellation within grace, so the instance was not force-killed.
expect(instance.dead).toBe(false)
await instance.dispose()
})
it('observes abort while awaiting a slow initialize handshake', async () => {
// A server that answers nothing (not even initialize) leaves `ready` pending; an abort must be
// observed during that wait instead of hanging the tool-timeout signal.
const instance = scriptInstance('setInterval(()=>{},1000)', { killGraceMs: 100 })
const controller = new AbortController()
const pending = run(instance, 'goToDefinition', controller.signal)
await new Promise<void>(resolve => setTimeout(resolve, 150))
controller.abort(new Error('handshake-abort'))
await expect(pending).rejects.toThrow(/handshake-abort/)
await instance.dispose()
})
it('terminates when abort interrupts a backpressured didOpen write', async () => {
// The fixture consumes initialized, then stops reading. A document larger than the stdio pipe
// keeps didOpen's write callback pending until cancellation forces bounded process teardown.
await writeFile(join(ws, 'a.ts'), 'x'.repeat(2_000_000))
const marker = join(root, 'initialized.log')
const instance = makeInstance({
LSP_FAKE_INITIALIZED_MARKER: marker,
LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED: '1',
}, {
shutdownTimeoutMs: 100,
killGraceMs: 100,
})
const controller = new AbortController()
const pending = run(instance, 'goToDefinition', controller.signal)
await waitForFile(marker)
// Let the client enter the large didOpen write after the fixture has paused stdin.
await new Promise<void>(resolve => setTimeout(resolve, 100))
controller.abort(new Error('didOpen-abort'))
await expect(pending).rejects.toThrow(/didOpen-abort/)
expect(instance.dead).toBe(true)
})
it('terminates when stdin fails during the didOpen write', async () => {
const instance = makeInstance({}, {
shutdownTimeoutMs: 100,
killGraceMs: 100,
}, failingWriter('textDocument/didOpen'))
await expect(run(instance, 'goToDefinition')).rejects.toThrow()
expect(instance.dead).toBe(true)
})
it('awaits process exit before rejecting a request write failure', async () => {
const instance = makeInstance({}, {
shutdownTimeoutMs: 100,
killGraceMs: 100,
}, failingWriter('textDocument/definition'))
// The pid is observed only to prove the owned subprocess reached quiescence before rejection.
const pid = (instance as unknown as { connection: { pid: number } }).connection.pid
await expect(run(instance, 'goToDefinition')).rejects.toThrow(/fixture textDocument\/definition failure/)
expect(processAlive(pid)).toBe(false)
})
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, 'goToDefinition')).rejects.toThrow(/does not support goToDefinition/)
})
it('propagates a server error response even when a signal is supplied (not an abort)', async () => {
// A live signal is passed, but the request fails for a server reason; the catch must rethrow
// without treating it as an abort.
const instance = makeInstance({ LSP_FAKE_ERROR: '1' })
const controller = new AbortController()
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',
}, { shutdownTimeoutMs: 100, killGraceMs: 100 }, failingWriter('textDocument/didClose'))
await expect(run(instance, 'goToDefinition')).resolves.toEqual({
kind: 'locations',
locations: [],
resolvedWorkspaceUri: pathToFileURL(ws).href,
})
expect(instance.dead).toBe(true)
})
})
describe('LspInstance disposal', () => {
it('lets a server finish protocol exit before signal escalation', async () => {
const marker = join(root, 'graceful-exit.log')
const instance = makeInstance({
LSP_FAKE_DEF: 'null',
LSP_FAKE_EXIT_DELAY_MS: '75',
LSP_FAKE_EXIT_MARKER: marker,
}, { shutdownTimeoutMs: 500 })
await run(instance, '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, '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, 'goToDefinition')
await instance.dispose()
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, 'goToDefinition')
await instance.dispose()
expect(instance.dead).toBe(true)
})
it('escalates to SIGKILL when the server ignores shutdown and SIGTERM', async () => {
// 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, 'goToDefinition')
await expect(instance.dispose()).resolves.toBeUndefined()
})
it('awaits a surviving process-tree helper on every concurrent dispose', async () => {
const marker = join(root, 'helper.pid')
const helper = 'process.on("SIGTERM",()=>{});setInterval(()=>{},1000);'
const script = 'const{spawn}=require("node:child_process");const{writeFileSync}=require("node:fs");'
+ `const helper=spawn(process.execPath,["-e",${JSON.stringify(helper)}],{stdio:"ignore"});`
+ `writeFileSync(${JSON.stringify(marker)},String(helper.pid));`
+ RESPONDING_SERVER
const instance = scriptInstance(script, { shutdownTimeoutMs: 100, killGraceMs: 100 })
await run(instance, 'goToDefinition')
const helperPid = Number(await readFile(marker, 'utf8'))
try {
const first = instance.dispose()
await instance.dispose()
expect(processAlive(helperPid)).toBe(false)
await first
} finally {
if (processAlive(helperPid)) process.kill(helperPid, 'SIGKILL')
await waitForProcessExit(helperPid)
}
})
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, '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/)
})
})
/** Probe a pid without changing its state. */
function processAlive(pid: number): boolean {
try {
process.kill(pid, 0)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false
throw error
}
if (process.platform !== 'linux') return true
try {
const stat = readFileSync(`/proc/${pid}/stat`, 'utf8')
const state = stat.slice(stat.lastIndexOf(')') + 2).split(/\s+/, 1)[0]
return !/^[ZXx]$/.test(state ?? '')
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false
throw error
}
}
/** Wait until a process can no longer execute so temporary-workspace cleanup cannot race handle release. */
async function waitForProcessExit(pid: number, timeoutMs = 3_000): Promise<void> {
const started = Date.now()
while (processAlive(pid)) {
if (Date.now() - started > timeoutMs) throw new Error(`process ${pid} did not exit`)
await new Promise<void>(resolve => setTimeout(resolve, 10))
}
}
/** Write normally except for one method whose callback receives a deterministic transport error. */
function failingWriter(method: string): ConnectionWriter {
return (stdin, message, done) => {
if ((message as { method?: unknown }).method === method) {
queueMicrotask(() => { done(new Error(`fixture ${method} failure`)) })
return
}
stdin.write(encodeMessage(message), done)
}
}
/** Wait until a fixture marker exists, bounded so a broken handshake cannot hang the test. */
async function waitForFile(path: string, timeoutMs = 3000): Promise<void> {
const started = Date.now()
for (;;) {
try {
await readFile(path)
return
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
if (Date.now() - started > timeoutMs) throw new Error('waitForFile timed out')
await new Promise<void>(resolve => setTimeout(resolve, 10))
}
}

View File

@@ -0,0 +1,495 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
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'
import { pathToFileURL, fileURLToPath } from 'node:url'
import { Context } from '@deepseek-ai/cordis'
import Lsp, { type LspProvider, type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
import { deadline } from '@deepseek-ai/dsh-timeout'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import * as LspLocal from '@deepseek-ai/dsh-lsp-stdio'
import type { LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-stdio'
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
let root: string
let ws: string
beforeEach(async () => {
root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-local-')))
ws = join(root, 'ws')
await mkdir(ws)
await writeFile(join(ws, 'a.ts'), 'const x = 1\nconst y = x\n')
})
afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
/** One fake stdio server entry with optional behavior and host-bound overrides. */
function fakeServer(fakeEnv: Record<string, string> = {}, overrides: Partial<LspLocalServerConfig> = {}): LspLocalServerConfig {
return {
command: process.execPath,
args: [fixtureServer],
env: { ...fakeEnv },
extensionToLanguage: { '.ts': 'typescript' },
...overrides,
}
}
/** Mount the real seam + lsp-stdio plugin driving one fake server. */
async function mount(
fakeEnv: Record<string, string> = {},
overrides: Partial<LspLocalServerConfig> = {},
captureProvider?: (provider: LspProvider) => void,
): Promise<Context> {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
const register = ctx.lsp.registerProvider.bind(ctx.lsp)
const registrationSpy = captureProvider === undefined
? undefined
: vi.spyOn(ctx.lsp, 'registerProvider').mockImplementation((provider) => {
captureProvider(provider)
return register(provider)
})
try {
await ctx.plugin(LspLocal, {
servers: { fake: fakeServer(fakeEnv, overrides) },
})
} finally {
registrationSpy?.mockRestore()
}
return ctx
}
function query(operation: LspQueryRequest['operation'], filePath = 'a.ts'): LspQueryRequest {
return { operation, filePath, position: { line: 0, character: 6 }, workspaceRoot: ws }
}
/** A single Location JSON pointing into the workspace. */
function locationJson(line: number): unknown {
return { uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line, character: 0 }, end: { line, character: 3 } } }
}
describe('lsp-stdio end to end over a fake server', () => {
it('routes different extensions to independent configured servers', async () => {
await writeFile(join(ws, 'a.py'), 'x = 1\n')
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await ctx.plugin(LspLocal, {
servers: {
typescript: fakeServer({ LSP_FAKE_HOVER: JSON.stringify({ contents: 'ts' }) }),
python: fakeServer(
{ LSP_FAKE_HOVER: JSON.stringify({ contents: 'py' }) },
{ extensionToLanguage: { '.py': 'python' } },
),
},
})
expect(await ctx.lsp.query(query('hover', 'a.ts'))).toEqual({ kind: 'hover', hover: { contents: 'ts' } })
expect(await ctx.lsp.query(query('hover', 'a.py'))).toEqual({ kind: 'hover', hover: { contents: 'py' } })
await ctx.fiber.dispose()
})
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('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 } } }],
resolvedWorkspaceUri: pathToFileURL(ws).href,
})
await ctx.fiber.dispose()
})
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('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('findReferences'))
expect(result).toMatchObject({ kind: 'locations' })
if (result.kind !== 'locations') throw new Error('expected locations')
expect(result.locations).toHaveLength(2)
await ctx.fiber.dispose()
})
it('normalizes a hover MarkupContent', async () => {
const ctx = await mount({ LSP_FAKE_HOVER: JSON.stringify({ contents: { kind: 'markdown', value: 'docs' } }) })
const result = await ctx.lsp.query(query('hover'))
expect(result).toEqual({ kind: 'hover', hover: { contents: 'docs' } })
await ctx.fiber.dispose()
})
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('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceUri: pathToFileURL(ws).href })
await ctx.fiber.dispose()
})
it('returns a null hover for a null result', async () => {
const ctx = await mount({ LSP_FAKE_HOVER: 'null' })
expect(await ctx.lsp.query(query('hover'))).toEqual({ kind: 'hover', hover: null })
await ctx.fiber.dispose()
})
it('rejects a non-utf-16 position encoding at initialize without retrying', async () => {
const marker = join(root, 'initialize-rejection-exit.log')
const ctx = await mount({
LSP_FAKE_ENCODING: 'utf-8',
LSP_FAKE_DEF: 'null',
LSP_FAKE_EXIT_MARKER: marker,
})
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/)
expect(await readFile(marker, 'utf8')).toBe('EXIT\nCLEAN\n')
await ctx.fiber.dispose()
})
it('does not pool a poisoned instance when initialize rejects', async () => {
// 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('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('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('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('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceUri: pathToFileURL(ws).href })
await ctx.fiber.dispose()
})
it('fails a query for an unsupported operation', async () => {
const ctx = await mount({ LSP_FAKE_CAPS: JSON.stringify({ hoverProvider: false }), LSP_FAKE_DEF: 'null' })
await expect(ctx.lsp.query(query('hover'))).rejects.toThrow(/does not support hover/)
await ctx.fiber.dispose()
})
it('rejects a source outside the workspace before startup', async () => {
const outside = join(root, 'out.ts')
await writeFile(outside, 'x')
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
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('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('goToDefinition'), controller.signal)
controller.abort(new Error('caller cancelled'))
await expect(pending).rejects.toThrow(/cancelled/)
await ctx.fiber.dispose()
})
it('honors an already-aborted signal before any host I/O or startup', async () => {
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
const controller = new AbortController()
controller.abort(new Error('pre-aborted'))
await expect(ctx.lsp.query(query('goToDefinition'), controller.signal)).rejects.toThrow(/pre-aborted/)
await ctx.fiber.dispose()
})
it('surfaces the server stderr tail in the exit error', async () => {
// A server that writes to stderr then exits without answering: the query rejection carries the
// retained stderr tail so the failure is diagnosable.
const ctx = await mount({}, {
command: process.execPath,
args: ['-e', 'process.stderr.write("FATAL: boom\\n"); setTimeout(()=>process.exit(1), 50)'],
})
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('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('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('goToDefinition'))).rejects.toThrow()
await ctx.fiber.dispose()
})
it('evicts a pooled server that died while idle and serves the next query from a fresh one', async () => {
// The first query succeeds, then the server exits before the second arrives, leaving a dead
// instance in the pool. The next query must evict-and-replace it and still succeed, rather than
// failing once on the closed connection first.
let provider: LspProvider | undefined
const ctx = await mount(
{ LSP_FAKE_EXIT_AFTER_REPLY: '1', LSP_FAKE_DEF: JSON.stringify(locationJson(0)) },
{},
(registered) => { provider = registered },
)
expect(await ctx.lsp.query(query('goToDefinition'))).toMatchObject({ kind: 'locations' })
if (provider === undefined) throw new Error('expected lsp-stdio to register a provider')
// This implementation-local test reaches the private pool only to synchronize with its actual
// close state. A fixed wall-clock sleep can expire before a CPU-starved child runs its exit timer.
const instances = (provider as unknown as {
readonly instances: ReadonlyMap<string, { readonly dead: boolean }>
}).instances
const instance = [...instances.values()][0]
// The query's finally may already have observed the exit and evicted the dead slot. When the
// slot remains, synchronize with its close before proving the next query replaces it.
if (instance !== undefined) await waitFor(async () => instance.dead)
expect(await ctx.lsp.query(query('goToDefinition'))).toMatchObject({ kind: 'locations' })
await ctx.fiber.dispose()
})
it('does not spawn a server when the signal aborts during source read', async () => {
// Abort right after issuing the query: the abort lands while canonicalizeWorkspace/readHostSource
// are awaited, so the pre-spawn recheck must reject without ever creating a pooled instance.
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
const controller = new AbortController()
const pending = ctx.lsp.query(query('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('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceUri: pathToFileURL(ws).href })
await ctx.fiber.dispose()
})
it('aborts and awaits a workspace lookup when the provider is disposed', async () => {
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
const fs = ctx.fs
const resolve = fs.resolve.bind(fs)
const started = Promise.withResolvers<AbortSignal>()
const release = Promise.withResolvers<undefined>()
vi.spyOn(fs, 'resolve').mockImplementation(async (path, options) => {
if (path !== ws) return await resolve(path, options)
const signal = options?.signal
if (signal === undefined) throw new Error('workspace lookup missing provider lifetime signal')
started.resolve(signal)
return await rejectWhenAborted(signal, release.promise)
})
const pending = ctx.lsp.query(query('goToDefinition'))
const signal = await started.promise
let disposed = false
const disposing = ctx.fiber.dispose().then(() => { disposed = true })
await new Promise<void>(resolve => setImmediate(resolve))
expect(signal.aborted).toBe(true)
expect(disposed).toBe(false)
release.resolve(undefined)
await expect(pending).rejects.toThrow('provider is disposed')
await expect(disposing).resolves.toBeUndefined()
})
it('aborts a queued source stream when the provider is disposed', async () => {
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
const fs = ctx.fs
const started = Promise.withResolvers<AbortSignal>()
vi.spyOn(fs, 'streamText').mockImplementation(async (_target, signal) => {
if (signal === undefined) throw new Error('source read missing provider lifetime signal')
started.resolve(signal)
return (async function* () {
await rejectWhenAborted(signal)
yield ''
})()
})
const pending = ctx.lsp.query(query('goToDefinition'))
const signal = await started.promise
const disposing = ctx.fiber.dispose()
await expect(pending).rejects.toThrow('provider is disposed')
await expect(disposing).resolves.toBeUndefined()
expect(signal.aborted).toBe(true)
})
it('waits for every owned teardown before aggregating instance failures', async () => {
let provider: LspProvider | undefined
const ctx = await mount({ LSP_FAKE_DEF: 'null' }, {}, (registered) => { provider = registered })
if (provider === undefined) throw new Error('expected lsp-stdio to register a provider')
const internals = provider as unknown as {
readonly instances: Map<string, { dispose(): Promise<void> }>
readonly queues: Map<string, Promise<void>>
readonly workspaceLookups: Set<Promise<void>>
disposeAll(): Promise<void>
}
const firstFailure = new Error('first instance cleanup failed')
const secondFailure = new Error('second instance cleanup failed')
const release = Promise.withResolvers<undefined>()
internals.instances.set('first', { dispose: async () => { throw firstFailure } })
internals.instances.set('second', { dispose: async () => { throw secondFailure } })
internals.queues.set('pending', release.promise)
internals.workspaceLookups.add(Promise.resolve())
let settled = false
const disposing = internals.disposeAll().finally(() => { settled = true })
await new Promise<void>(resolve => setImmediate(resolve))
expect(settled).toBe(false)
release.resolve(undefined)
await expect(disposing).rejects.toMatchObject({
errors: [firstFailure, secondFailure],
message: 'lsp-stdio instance teardown failed',
})
expect(internals.instances.size).toBe(0)
expect(internals.queues.size).toBe(0)
expect(internals.workspaceLookups.size).toBe(0)
await ctx.fiber.dispose()
})
it('waits for every provider before reporting plugin teardown failure', async () => {
const ctx = new Context()
const disposalErrors: unknown[] = []
ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
const providers: LspProvider[] = []
const register = ctx.lsp.registerProvider.bind(ctx.lsp)
const registrationSpy = vi.spyOn(ctx.lsp, 'registerProvider').mockImplementation((provider) => {
providers.push(provider)
return register(provider)
})
const fiber = await ctx.plugin(LspLocal, {
servers: {
first: fakeServer(),
second: fakeServer({}, { extensionToLanguage: { '.js': 'javascript' } }),
},
})
registrationSpy.mockRestore()
expect(providers).toHaveLength(2)
const failure = new Error('provider cleanup failed')
const release = Promise.withResolvers<undefined>()
const first = providers[0] as LspProvider & { disposeAll(): Promise<void> }
const second = providers[1] as LspProvider & { disposeAll(): Promise<void> }
first.disposeAll = async () => { throw failure }
second.disposeAll = async () => { await release.promise }
let disposed = false
const disposing = fiber.dispose().then(() => { disposed = true })
await new Promise<void>(resolve => setImmediate(resolve))
expect(disposed).toBe(false)
expect(disposalErrors).toEqual([])
release.resolve(undefined)
await disposing
expect(disposalErrors).toEqual([failure])
await ctx.fiber.dispose()
})
it('runs distinct workspaces in parallel instances', async () => {
const ws2 = join(root, 'ws2')
await mkdir(ws2)
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('goToDefinition'), workspaceRoot: ws }),
ctx.lsp.query({ ...query('goToDefinition'), workspaceRoot: ws2 }),
])
expect(r1).toMatchObject({ kind: 'locations' })
expect(r2).toMatchObject({ kind: 'locations' })
await ctx.fiber.dispose()
})
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('goToDefinition'))
await expect(ctx.fiber.dispose()).resolves.toBeUndefined()
})
it('rejects at load when the command is not found', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, {
servers: {
missing: {
command: 'definitely-not-a-real-lsp-binary-xyz',
args: [],
extensionToLanguage: { '.ts': 'typescript' },
},
},
})).rejects.toThrow(/was not found on PATH/)
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))
}
}
/** Hold one fake provider operation until cancellation, optionally behind a cleanup gate. */
function rejectWhenAborted<T>(signal: AbortSignal, release: Promise<unknown> = Promise.resolve()): Promise<T> {
return new Promise((_resolve, reject) => {
const onAbort = (): void => {
void release.then(() => {
reject(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason)))
})
}
signal.addEventListener('abort', onAbort, { once: true })
if (signal.aborted) onAbort()
})
}

View File

@@ -0,0 +1,291 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { chmod, mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { delimiter, join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp'
import * as LspLocal from '@deepseek-ai/dsh-lsp-stdio'
import type { Config, LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-stdio'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
let root: string
let ws: string
beforeEach(async () => {
root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-prov-')))
ws = join(root, 'ws')
await mkdir(ws)
await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
})
afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
function query(): LspQueryRequest {
return { operation: 'goToDefinition', filePath: 'a.ts', position: { line: 0, character: 0 }, workspaceRoot: ws }
}
/** Wrap one server entry in the plugin's named server table. */
function config(providerId: string, server: LspLocalServerConfig): Config {
return { servers: { [providerId]: server } }
}
describe('lsp-stdio provider resolution', () => {
it('resolves a bare command on the child PATH and registers the provider', async () => {
// A tiny executable script placed on a custom PATH dir: the load-time resolver must find it.
const bin = join(root, 'bin')
await mkdir(bin)
const exe = join(bin, process.platform === 'win32' ? 'fake-lsp.cmd' : 'fake-lsp')
await writeFile(exe, process.platform === 'win32' ? '@exit /b 0\r\n' : '#!/bin/sh\nexit 0\n')
if (process.platform !== 'win32') await chmod(exe, 0o755)
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, config('onpath', {
command: 'fake-lsp',
args: [],
env: { PATH: bin, ...process.platform === 'win32' ? { PATHEXT: '.CMD' } : {} },
extensionToLanguage: { '.ts': 'typescript' },
}))).resolves.toBeDefined()
await ctx.fiber.dispose()
})
it('skips empty PATH segments and fails when the command is absent', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, config('nope', {
command: 'fake-lsp',
args: [],
env: { PATH: `${delimiter}${delimiter}${join(root, 'empty')}` },
extensionToLanguage: { '.ts': 'typescript' },
}))).rejects.toThrow(/was not found on PATH/)
await ctx.fiber.dispose()
})
it('rejects a query after the provider is disposed', async () => {
// Use a server that never emits results and dispose the plugin, then confirm queries are refused.
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
// Grab the provider instance by registering, then dispose the whole plugin fiber.
const lsp = ctx.lsp
const fiber = await ctx.plugin(LspLocal, config('disp', {
command: process.execPath,
args: ['-e', 'setInterval(()=>{},1000)'],
extensionToLanguage: { '.ts': 'typescript' },
}))
await fiber.dispose()
// After disposal the provider unregistered from the seam, so selection fails as unavailable.
await expect(lsp.query(query())).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
await ctx.fiber.dispose()
})
it('rejects a nonpositive teardown budget at load', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, config('bad-budget', {
command: process.execPath,
args: ['-e', ''],
extensionToLanguage: { '.ts': 'typescript' },
killGraceMs: 0,
}))).rejects.toThrow(/servers\.bad-budget\.killGraceMs must be a positive integer/)
await ctx.fiber.dispose()
})
it('rejects a nonpositive byte cap at load', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
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 ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
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()
})
// Node's X_OK probe is an existence check on Windows, which has no executable mode bit.
it.skipIf(process.platform === 'win32')('rejects an absolute command that is not executable at load', async () => {
const notExe = join(root, 'not-exe.txt')
await writeFile(notExe, 'plain text, not executable')
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, config('abs-bad', {
command: notExe,
args: [],
extensionToLanguage: { '.ts': 'typescript' },
}))).rejects.toThrow(/is not an executable file/)
await ctx.fiber.dispose()
})
it('rejects an executable directory as a command at load', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, config('abs-directory', {
command: ws,
args: [],
extensionToLanguage: { '.ts': 'typescript' },
}))).rejects.toThrow(/is not an executable file/)
await ctx.fiber.dispose()
})
it('rejects an empty server table at load', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, { servers: {} })).rejects.toThrow(/servers must contain at least one server/)
await ctx.fiber.dispose()
})
it('rejects an empty server id at load', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, config('', {
command: process.execPath,
extensionToLanguage: { '.ts': 'typescript' },
}))).rejects.toThrow(/server ids must be non-empty strings/)
await ctx.fiber.dispose()
})
it('resolves every executable before publishing any provider', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, {
servers: {
valid: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } },
missing: { command: 'definitely-not-a-real-lsp-binary-xyz', extensionToLanguage: { '.py': 'python' } },
},
})).rejects.toThrow(/was not found on PATH/)
await expect(ctx.lsp.query(query())).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
await ctx.fiber.dispose()
})
it('waits for aborted sibling executable lookups before setup rejects', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
const slowStarted = Promise.withResolvers<undefined>()
const slowAborted = Promise.withResolvers<undefined>()
const releaseCleanup = Promise.withResolvers<undefined>()
vi.spyOn(ctx.subprocess, 'resolveExecutable').mockImplementation(async (command, _env, signal) => {
if (signal === undefined) throw new Error('missing setup signal')
if (command === 'slow-lsp') {
return await new Promise<string>((_resolve, reject) => {
const onAbort = (): void => {
slowAborted.resolve(undefined)
void releaseCleanup.promise.then(() => {
reject(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason)))
})
}
signal.addEventListener('abort', onAbort, { once: true })
slowStarted.resolve(undefined)
if (signal.aborted) onAbort()
})
}
await slowStarted.promise
throw new Error('lookup failed')
})
const loading = ctx.plugin(LspLocal, {
servers: {
slow: { command: 'slow-lsp', extensionToLanguage: { '.ts': 'typescript' } },
failing: { command: 'failing-lsp', extensionToLanguage: { '.js': 'javascript' } },
},
})
await slowAborted.promise
let settled = false
void loading.then(() => { settled = true }, () => { settled = true })
await new Promise<void>((resolve) => { setImmediate(resolve) })
expect(settled).toBe(false)
releaseCleanup.resolve(undefined)
await expect(loading).rejects.toThrow('lookup failed')
await ctx.fiber.dispose()
})
it('aborts executable resolution when disposed during setup', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
const subprocess = ctx.subprocess
const lookupStarted = Promise.withResolvers<AbortSignal>()
vi.spyOn(subprocess, 'resolveExecutable').mockImplementation(async (_command, _env, signal) => {
if (signal === undefined) throw new Error('missing setup signal')
lookupStarted.resolve(signal)
return await new Promise<string>((_resolve, reject) => {
const onAbort = (): void => {
reject(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason)))
}
signal.addEventListener('abort', onAbort, { once: true })
if (signal.aborted) onAbort()
})
})
const loading = ctx.plugin(LspLocal, config('pending', {
command: 'pending-lsp',
extensionToLanguage: { '.ts': 'typescript' },
}))
const signal = await lookupStarted.promise
const unrelated = await ctx.plugin(() => {})
await unrelated.dispose()
expect(signal.aborted).toBe(false)
const disposing = loading.dispose()
await expect(loading).rejects.toThrow('lsp-stdio setup disposed')
await expect(disposing).resolves.toBeUndefined()
expect(signal.aborted).toBe(true)
await ctx.fiber.dispose()
})
it('rolls back earlier registrations when a later server conflicts', async () => {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await expect(ctx.plugin(LspLocal, {
servers: {
first: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } },
second: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } },
},
})).rejects.toThrow(expect.objectContaining({ code: 'LSP_CONFLICT' }))
await expect(ctx.lsp.query(query())).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
await ctx.fiber.dispose()
})
})

View File

@@ -0,0 +1,173 @@
import { describe, expect, it } from 'vitest'
import {
negotiatePositionEncoding,
normalizeHover,
normalizeLocations,
requestMethod,
supportsOperation,
supportsTransientOpen,
} from '@deepseek-ai/dsh-lsp-stdio'
import type { WireServerCapabilities } from '@deepseek-ai/dsh-lsp-stdio/src/protocol.ts'
const RANGE = { start: { line: 1, character: 2 }, end: { line: 1, character: 5 } }
describe('requestMethod', () => {
it('maps each operation to its textDocument request', () => {
expect(requestMethod('goToDefinition')).toBe('textDocument/definition')
expect(requestMethod('findReferences')).toBe('textDocument/references')
expect(requestMethod('goToImplementation')).toBe('textDocument/implementation')
expect(requestMethod('hover')).toBe('textDocument/hover')
})
})
describe('supportsOperation', () => {
it('reads the provider slot for each operation (boolean and options forms)', () => {
const caps: WireServerCapabilities = {
definitionProvider: true,
referencesProvider: { workDoneProgress: true },
implementationProvider: 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)
})
})
describe('supportsTransientOpen', () => {
it('accepts legacy Full and Incremental enums, rejects None and absent', () => {
expect(supportsTransientOpen(1)).toBe(true)
expect(supportsTransientOpen(2)).toBe(true)
expect(supportsTransientOpen(0)).toBe(false)
expect(supportsTransientOpen(undefined)).toBe(false)
})
it('accepts options with openClose:true and rejects openClose:false', () => {
expect(supportsTransientOpen({ openClose: true })).toBe(true)
expect(supportsTransientOpen({ openClose: false, change: 2 })).toBe(false)
})
it('requires an explicit openClose for the options form (no change-enum fallback)', () => {
expect(supportsTransientOpen({ change: 1 })).toBe(false)
expect(supportsTransientOpen({ change: 2 })).toBe(false)
expect(supportsTransientOpen({})).toBe(false)
})
})
describe('negotiatePositionEncoding', () => {
it('defaults an omitted encoding to utf-16', () => {
expect(negotiatePositionEncoding(undefined)).toBe('utf-16')
expect(negotiatePositionEncoding('utf-16')).toBe('utf-16')
})
it('rejects any other encoding', () => {
expect(() => negotiatePositionEncoding('utf-8')).toThrow(/unsupported position encoding/)
})
})
describe('normalizeLocations', () => {
it('returns empty only for the protocol no-result value null', () => {
expect(normalizeLocations(null)).toEqual([])
expect(() => normalizeLocations(undefined)).toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
})
it('maps a single Location', () => {
expect(normalizeLocations({ uri: 'file:///a', range: RANGE })).toEqual([{ uri: 'file:///a', range: RANGE }])
})
it('maps an array of Locations', () => {
const result = normalizeLocations([{ uri: 'file:///a', range: RANGE }, { uri: 'file:///b', range: RANGE }])
expect(result.map(l => l.uri)).toEqual(['file:///a', 'file:///b'])
})
it('maps a LocationLink from targetUri + targetSelectionRange', () => {
const link = { targetUri: 'file:///c', targetSelectionRange: RANGE, targetRange: RANGE }
expect(normalizeLocations([link])).toEqual([{ uri: 'file:///c', range: RANGE }])
})
it('rejects a non-object entry', () => {
expect(() => normalizeLocations([42])).toThrow(/non-object/)
})
it('rejects an entry that is neither a Location nor a LocationLink', () => {
expect(() => normalizeLocations([{ nope: true }])).toThrow(/neither a Location nor a LocationLink/)
})
it('rejects a Location whose range is not an object', () => {
expect(() => normalizeLocations([{ uri: 'file:///a', range: 'nope' }])).toThrow(/neither a Location/)
})
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', () => {
it('returns null for null', () => {
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 })
})
it('keeps a bare string MarkedString verbatim', () => {
expect(normalizeHover({ contents: 'plain text' })).toEqual({ contents: 'plain text' })
})
it('renders a language-tagged MarkedString object as a fenced code block', () => {
expect(normalizeHover({ contents: { language: 'ts', value: 'const x = 1' } }))
.toEqual({ contents: '```ts\nconst x = 1\n```' })
})
it('joins a MarkedString array with one blank line', () => {
expect(normalizeHover({ contents: ['a', { language: 'ts', value: 'b' }] }))
.toEqual({ contents: 'a\n\n```ts\nb\n```' })
})
it('drops an empty-contents hover to null', () => {
expect(normalizeHover({ contents: { kind: 'plaintext', value: '' } })).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', () => {
expect(() => normalizeHover(42)).toThrow(/was not an object/)
})
it('rejects malformed contents', () => {
expect(() => normalizeHover({ contents: { weird: true } })).toThrow(/were not MarkupContent/)
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('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

@@ -0,0 +1,118 @@
/**
* Keyless real-server e2e: drives the real `typescript-language-server` through the full
* `ctx.lsp` → `dsh-lsp-stdio` stack over the base protocol, exercising all four operations. No API
* key needed — the server is a local dev dependency. This establishes one compatibility floor
* (TypeScript), not a cross-language claim.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import Lsp, { type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
import * as LspLocal from '@deepseek-ai/dsh-lsp-stdio'
// The server binary is a dev dependency of this package; resolve its pnpm-hoisted .bin path.
const serverBin = join(
new URL('..', import.meta.url).pathname,
'node_modules',
'.bin',
'typescript-language-server',
)
let root: string
let ws: string
let ctx: Context
beforeAll(async () => {
root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-ts-e2e-')))
ws = join(root, 'proj')
await mkdir(ws)
await writeFile(join(ws, 'tsconfig.json'), JSON.stringify({ compilerOptions: { strict: true, module: 'nodenext' } }))
// A small program with a definition, a reference, an interface + implementation, and a typed value.
await writeFile(join(ws, 'shapes.ts'), [
'export interface Shape {',
' area(): number',
'}',
'',
'export class Circle implements Shape {',
' constructor(private r: number) {}',
' area(): number { return Math.PI * this.r * this.r }',
'}',
'',
'export function describe(s: Shape): string {',
' return `area=${s.area()}`',
'}',
'',
'const c = new Circle(2)',
'export const text = describe(c)',
'',
].join('\n'))
ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
await ctx.plugin(LspLocal, {
servers: {
typescript: {
command: serverBin,
args: ['--stdio'],
extensionToLanguage: { '.ts': 'typescript', '.tsx': 'typescriptreact' },
},
},
})
}, 60_000)
afterAll(async () => {
if (ctx) await ctx.fiber.dispose()
if (root) await rm(root, { recursive: true, force: true })
})
/** One-based helper mirroring the model contract, converted to the seam's zero-based position. */
function at(operation: LspQueryRequest['operation'], line1: number, char1: number, filePath = 'shapes.ts'): LspQueryRequest {
return { operation, filePath, position: { line: line1 - 1, character: char1 - 1 }, workspaceRoot: ws }
}
function locations(result: LspQueryResult): readonly { uri: string }[] {
if (result.kind !== 'locations') throw new Error(`expected locations, got ${result.kind}`)
return result.locations
}
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('goToDefinition', 15, 22))
const locs = locations(result)
expect(locs.length).toBeGreaterThanOrEqual(1)
expect(locs.some(l => l.uri.endsWith('shapes.ts'))).toBe(true)
}, 60_000)
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('findReferences', 10, 17))
const locs = locations(result)
// At least the declaration plus the call site.
expect(locs.length).toBeGreaterThanOrEqual(2)
}, 60_000)
it('resolves implementations of an interface', async () => {
// Implementations of `Shape` (line 1, col 18) → Circle.
const result = await ctx.lsp.query(at('goToImplementation', 1, 18))
const locs = locations(result)
expect(locs.length).toBeGreaterThanOrEqual(1)
}, 60_000)
it('returns hover information for a typed symbol', async () => {
// Hover on `Circle` in `new Circle(2)` (line 14, col 15).
const result = await ctx.lsp.query(at('hover', 14, 15))
expect(result.kind).toBe('hover')
if (result.kind === 'hover') {
expect(result.hover).not.toBeNull()
expect(result.hover?.contents).toContain('Circle')
}
}, 60_000)
})

View File

@@ -0,0 +1,42 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../util/brand"
},
{
"path": "../../util/timeout"
},
{
"path": "../../llm/llm"
},
{
"path": "../../fs/fs"
},
{
"path": "../lsp"
},
{
"path": "../../subprocess/subprocess"
},
{
"path": "../../runtime-diagnostics/invariants"
}
]
}