# Conflicts: # docs/cordis-catalog/services.md # docs/core-data-structures/subprocess.i18n.yaml # packages/lsp/lsp-local/src/connection.ts # packages/subagent/subagent-acp/README.i18n.yaml # packages/subprocess/subprocess-local/README.i18n.yaml # packages/subprocess/subprocess-local/src/spawn.ts # packages/subprocess/subprocess/README.i18n.yaml # packages/subprocess/subprocess/README.md # packages/subprocess/subprocess/README.zh.md # packages/subprocess/subprocess/tests/service.spec.ts
331 lines
13 KiB
TypeScript
331 lines
13 KiB
TypeScript
/**
|
|
* A JSON-RPC endpoint over one language server spawned through the subprocess
|
|
* seam. 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 seam's
|
|
* implementation.
|
|
* @module @deepseek-ai/dsh-lsp-local/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
|
|
/** The child's environment (credential-scrubbed, with overrides applied). */
|
|
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,
|
|
// spec.env mixes the scrubbed base with explicit config entries; the
|
|
// seam merges the whole map after its own ambient scrub, so a
|
|
// configured DSH_* fact reaches the child.
|
|
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-local: 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))
|
|
}
|