refactor(runtime): compose consumers over fs and subprocess

This commit is contained in:
Tianyi Cui
2026-07-28 23:00:00 +08:00
parent 987d477cfc
commit 8917ff8ef4
116 changed files with 2828 additions and 1122 deletions

View File

@@ -22,7 +22,7 @@ export interface ConnectionSpec {
readonly args: readonly string[]
/** The child's working directory (the canonical workspace). */
readonly cwd: string
/** The child's environment (credential-scrubbed, with overrides applied). */
/** 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
@@ -98,9 +98,8 @@ export class LspConnection {
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.
// 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. */

View File

@@ -1,154 +1,106 @@
/**
* Host-filesystem source access for the local provider, using Node APIs directly in the
* subprocess's namespace (never `ctx.fs`): only the LSP result is model-visible, so a query does not
* satisfy read-before-write policy and emits no `fs/observed`. Canonicalization derives target
* identity from `realpath`, so symlink aliases share a workspace; a source is rejected before server
* startup when it is missing, non-regular, non-UTF-8, oversized, or canonically outside the
* workspace. External result locations are allowed, but an external path can never become a query
* source.
* @module @deepseek-ai/dsh-lsp-local/host
*/
/** Filesystem-seam source access for the generic stdio LSP provider. */
import { constants } from 'node:fs'
import { open, realpath, stat } from 'node:fs/promises'
import type { FileHandle } from 'node:fs/promises'
import { isAbsolute, resolve as resolvePath, sep } from 'node:path'
import type { FileSystem, FsTarget } from '@deepseek-ai/dsh-fs'
import { throwIfAborted } from './abort.ts'
/** A validated source: its canonical absolute path and current UTF-8 text. */
export interface HostSource {
/** The canonical (realpath-resolved) absolute path, inside the canonical workspace. */
/** 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
/** The file's current text, read as UTF-8. */
/** 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
}
/**
* Canonicalize a workspace root: it must exist and be a directory. The returned realpath supplies
* process cwd, `rootUri`, the sole `workspaceFolders` entry, and pool identity, so symlinked roots
* collapse to one instance.
* @param workspaceRoot - the caller's workspace root (absolute).
* @param signal - optional cancellation observed around each filesystem operation.
* @returns the canonical directory path.
* @throws Error when the path is missing or not a directory.
* 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(workspaceRoot: string, signal?: AbortSignal): Promise<string> {
export async function canonicalizeWorkspace(
fs: FileSystem,
workspaceRoot: string,
signal?: AbortSignal,
): Promise<HostWorkspace> {
throwIfAborted(signal)
let canonical: string
let target: FsTarget
try {
canonical = await realpath(workspaceRoot)
} catch (error) {
throw new Error(`workspace root "${workspaceRoot}" cannot be resolved: ${messageOf(error)}`)
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 stat(canonical)
const info = await fs.stat(target, signal)
throwIfAborted(signal)
if (!info.isDirectory()) {
if (info?.type !== 'directory') {
throw new Error(`workspace root "${workspaceRoot}" is not a directory`)
}
return canonical
return {
target,
canonicalPath: fs.processPath(target),
fileUrl: fs.fileUrl(target),
}
}
/**
* Resolve, canonicalize, validate, and read a query source in one pass. A relative `filePath`
* resolves against `canonicalWorkspace`; an absolute one is taken directly. The canonical target
* must be a regular UTF-8 file no larger than `maxDocumentBytes`, and must lie inside the canonical
* workspace.
* @param filePath - the model-supplied source path (relative or absolute).
* @param canonicalWorkspace - the already-canonicalized workspace root.
* @param maxDocumentBytes - the largest source this host will open.
* @param signal - optional cancellation observed throughout resolution, validation, and reading.
* @returns the canonical path and current UTF-8 text.
* @throws Error when the source is missing, non-regular, oversized, non-UTF-8, or out of workspace.
* Resolve, contain, and atomically read one bounded query source through
* `ctx.fs`. The provider's bounded read owns stable-handle and no-follow
* mechanics; this layer owns only LSP-facing validation and messages.
* @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,
canonicalWorkspace: string,
workspace: HostWorkspace,
maxDocumentBytes: number,
signal?: AbortSignal,
): Promise<HostSource> {
throwIfAborted(signal)
const requested = isAbsolute(filePath) ? filePath : resolvePath(canonicalWorkspace, filePath)
let canonicalPath: string
let target: FsTarget
try {
canonicalPath = await realpath(requested)
} catch (error) {
throw new Error(`source "${filePath}" cannot be resolved: ${messageOf(error)}`)
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 (!isInside(canonicalWorkspace, canonicalPath)) {
if (!fs.contains(workspace.target, target)) {
throw new Error(`source "${filePath}" resolves outside the workspace`)
}
// Open ONE handle after containment, then stat and read through it: a concurrent replace between
// realpath and read cannot swap the target, so the regular-file and size checks bind the bytes we
// actually read (no path-based TOCTOU). O_NOFOLLOW rejects the final component being swapped for a
// symlink between realpath and open (which would otherwise escape the workspace).
// O_NONBLOCK prevents a FIFO with no writer from hanging before fstat can reject it as nonregular.
const handle = await open(canonicalPath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
let text: string
try {
text = await fs.readTextBounded(target, maxDocumentBytes, signal)
} catch (error: unknown) {
throwIfAborted(signal)
const info = await handle.stat()
throwIfAborted(signal)
if (!info.isFile()) {
throw new Error(`source "${filePath}" is not a regular file`)
}
if (info.size > maxDocumentBytes) {
throw new Error(`source "${filePath}" is ${info.size} bytes, over the ${maxDocumentBytes}-byte limit`)
}
// Bound the read to the cap even if the file grew after stat: read one extra byte and reject on
// overflow, so a concurrent grow cannot defeat the memory bound.
const buffer = await readCapped(handle, maxDocumentBytes, filePath, signal)
const text = decodeUtf8Strict(buffer, filePath)
throwIfAborted(signal)
return { canonicalPath, text }
} finally {
await handle.close()
throw new Error(`source "${filePath}" could not be opened safely: ${messageOf(error)}`, { cause: error })
}
throwIfAborted(signal)
return {
fileUrl: fs.fileUrl(target),
text,
}
}
/** Read at most `maxBytes` from the handle, rejecting when the source overflows the cap. */
async function readCapped(
handle: FileHandle,
maxBytes: number,
filePath: string,
signal?: AbortSignal,
): Promise<Buffer> {
const limit = maxBytes + 1
const chunk = Buffer.allocUnsafe(limit)
let total = 0
for (;;) {
throwIfAborted(signal)
const { bytesRead } = await handle.read(chunk, total, limit - total, total)
throwIfAborted(signal)
if (bytesRead === 0) break
total += bytesRead
/* v8 ignore next 3 -- overflow requires the file to grow past the cap between stat and read (a concurrent mutation); defensive. */
if (total > maxBytes) {
throw new Error(`source "${filePath}" grew past the ${maxBytes}-byte limit while reading`)
}
}
return chunk.subarray(0, total)
}
/** Whether `child` is the workspace itself or a descendant of it (both already canonical). */
function isInside(workspace: string, child: string): boolean {
if (child === workspace) return true
/* v8 ignore next -- a canonical non-root workspace never ends with a separator; the guard covers the filesystem root. */
const base = workspace.endsWith(sep) ? workspace : workspace + sep
return child.startsWith(base)
}
/** Decode strictly as UTF-8: a fatal decoder rejects only malformed bytes, keeping a legitimate U+FFFD. */
function decodeUtf8Strict(buffer: Buffer, filePath: string): string {
try {
return new TextDecoder('utf-8', { fatal: true }).decode(buffer)
} catch {
throw new Error(`source "${filePath}" is not valid UTF-8 text`)
}
}
/** Extract a message from an unknown thrown value without leaking `any`. */
function messageOf(error: unknown): string {
/* v8 ignore next -- Node fs rejections are always Error instances; the String() fallback is defensive. */
return error instanceof Error ? error.message : String(error)
}

View File

@@ -1,18 +1,16 @@
/**
* 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 realpath, serves transient-open queries
* 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 Node APIs in the host namespace (not `ctx.fs`)
* and trust their configured servers — no sandbox confinement.
* 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-local
*/
import { accessSync, constants, statSync } from 'node:fs'
import { delimiter, isAbsolute, join } from 'node:path'
import type { Context } from 'cordis'
import z from 'schemastery'
import { LspError, LspProviderId } from '@deepseek-ai/dsh-lsp'
@@ -24,9 +22,9 @@ import type {
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 { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import type { InstanceSpec } from './instance.ts'
export { canonicalizeWorkspace, readHostSource } from './host.ts'
@@ -46,10 +44,7 @@ export { LspConnection } from './connection.ts'
export const name = 'lsp-local'
/** Services required by this plugin. */
export const inject = ['lsp', 'subprocess']
/** Credential-shaped ambient env vars are NOT forwarded to the child by default. */
export const inject = ['fs', 'lsp', 'subprocess']
const DEFAULT_MAX_MESSAGE_BYTES = 16_000_000
const DEFAULT_MAX_STDERR_BYTES = 1_000_000
@@ -91,6 +86,7 @@ export interface Config {
/** 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(),
@@ -114,23 +110,28 @@ export const Config: z<Config> = z.object({
* 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 (must inject `lsp`).
* @param ctx - the plugin context carrying `fs`, `lsp`, and `subprocess`.
* @param config - the resolved plugin configuration (schemastery has filled every default).
*/
export function apply(ctx: Context, config: Config): void {
export async function apply(ctx: Context, config: Config): Promise<void> {
const entries = Object.entries(config.servers)
if (entries.length === 0) throw new Error('lsp-local: servers must contain at least one server')
// 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 = entries.map(([providerId, rawConfig]) => {
const providers = await Promise.all(entries.map(async ([providerId, rawConfig]) => {
if (providerId.trim() === '') throw new Error('lsp-local: server ids must be non-empty strings')
const resolved = rawConfig as ResolvedServerConfig
validateServerConfig(providerId, resolved)
const childEnv = buildChildEnv(resolved.env)
const executable = resolveExecutable(resolved.command, childEnv)
return new LocalLspProvider(providerId, resolved, childEnv, executable, spec => ctx.subprocess.spawn(spec))
})
const executable = await ctx.subprocess.resolveExecutable(resolved.command, resolved.env)
return new LocalLspProvider(
providerId,
ctx.fs,
resolved,
executable,
spec => ctx.subprocess.spawn(spec),
)
}))
ctx.effect(() => {
const disposers: Array<() => void> = []
@@ -180,16 +181,16 @@ function assertPositiveInteger(providerId: string, name: string, value: number):
class LocalLspProvider implements LspProvider {
readonly id: LspProviderId
readonly extensionToLanguage: Readonly<Record<string, string>>
/** One live instance per canonical workspace realpath. */
private readonly instances = new Map<string, LspInstance>()
/** 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<string, Promise<void>>()
private readonly queues = new Map<WorkspaceKey, Promise<void>>()
private disposed = false
constructor(
providerId: string,
private readonly fs: Context['fs'],
private readonly config: ResolvedServerConfig,
private readonly childEnv: Record<string, string>,
private readonly executable: string,
private readonly spawner: ConnectionSpawner,
) {
@@ -211,19 +212,20 @@ class LocalLspProvider implements LspProvider {
}
async query(request: LspProviderQuery, signal?: AbortSignal): Promise<LspQueryResult> {
// Honor an already-aborted signal before host I/O so a canceled request never starts a server.
// Honor an already-aborted signal before provider I/O so a canceled request never starts a server.
this.assertActive(signal)
const workspace = await canonicalizeWorkspace(request.workspaceRoot, signal)
const workspace = await canonicalizeWorkspace(this.fs, request.workspaceRoot, signal)
this.assertActive(signal)
return this.enqueue(workspace, signal, async () => {
const workspaceKey = workspace.target.targetKey
return this.enqueue(workspaceKey, signal, async () => {
this.assertActive(signal)
// Read inside the workspace queue but before spawning: a queued query sees current bytes when
// its turn starts, while an invalid source still cannot leave an idle process pooled.
const source = await readHostSource(request.filePath, workspace, this.config.maxDocumentBytes, signal)
const source = await readHostSource(this.fs, request.filePath, workspace, this.config.maxDocumentBytes, signal)
// Disposal may have snapshotted the instance map while host I/O was pending. Re-check before a
// synchronous get-or-create so every spawned process remains owned by teardown.
this.assertActive(signal)
let instance = this.instanceFor(workspace)
let instance = this.instanceFor(workspaceKey, workspace)
try {
return await instance.query(request, source, signal)
} catch (error) {
@@ -231,22 +233,22 @@ class LocalLspProvider implements LspProvider {
// read-only, so replace that transport once and retry transparently.
if (!instance.isTransportFailure(error)) throw error
await instance.dispose()
this.evictIfCurrent(workspace, instance)
this.evictIfCurrent(workspaceKey, instance)
this.assertActive(signal)
instance = this.instanceFor(workspace)
instance = this.instanceFor(workspaceKey, workspace)
return await instance.query(request, source, signal)
} finally {
// Reach quiescence before dropping a dead slot; a replacement must survive this ownership check.
if (instance.dead) {
await instance.dispose()
this.evictIfCurrent(workspace, instance)
this.evictIfCurrent(workspaceKey, instance)
}
}
})
}
/** Serialize one complete query lifecycle for a canonical workspace. */
private enqueue<T>(workspace: string, signal: AbortSignal | undefined, run: () => Promise<T>): Promise<T> {
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,
@@ -260,27 +262,28 @@ class LocalLspProvider implements LspProvider {
}
/** Return or synchronously publish the one instance for a canonical workspace. */
private instanceFor(workspace: string): LspInstance {
private instanceFor(workspaceKey: WorkspaceKey, workspace: HostWorkspace): LspInstance {
this.assertActive()
const existing = this.instances.get(workspace)
const existing = this.instances.get(workspaceKey)
if (existing !== undefined) return existing
const created = this.createInstance(workspace)
this.instances.set(workspace, created)
this.instances.set(workspaceKey, created)
return created
}
/** Drop the slot iff it still contains this instance. */
private evictIfCurrent(workspace: string, instance: LspInstance): void {
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: string): LspInstance {
private createInstance(workspace: HostWorkspace): LspInstance {
const spec: InstanceSpec = {
command: this.executable,
args: this.config.args,
cwd: workspace,
env: this.childEnv,
cwd: workspace.canonicalPath,
workspaceUri: workspace.fileUrl,
env: this.config.env,
configuration: this.config.configuration,
initializationOptions: this.config.initializationOptions,
maxMessageBytes: this.config.maxMessageBytes,
@@ -304,41 +307,3 @@ class LocalLspProvider implements LspProvider {
this.queues.clear()
}
}
/** The seam's scrubbed parent env (credential-shaped and DSH_* names dropped), plus the config's explicit env. */
function buildChildEnv(extra: Record<string, string>): Record<string, string> {
return { ...scrubbedParentEnv(), ...extra }
}
/**
* Resolve the server executable to an absolute path: an absolute command is verified directly; a
* bare command is looked up on the child's PATH. Fails loudly when nothing is executable.
*/
function resolveExecutable(command: string, childEnv: Record<string, string>): string {
if (isAbsolute(command)) {
// Verify an absolute command too, so an unavailable one fails at load, not on the first query.
if (!isExecutableFileSync(command)) {
throw new Error(`lsp-local: command "${command}" is not an executable file`)
}
return command
}
/* v8 ignore next -- buildChildEnv always sets PATH from the ambient env; the further fallbacks are defensive. */
const pathValue = childEnv.PATH ?? process.env.PATH ?? ''
for (const dir of pathValue.split(delimiter)) {
if (dir === '') continue
const candidate = join(dir, command)
if (isExecutableFileSync(candidate)) return candidate
}
throw new Error(`lsp-local: command "${command}" was not found on PATH`)
}
/** Synchronous regular-file and executable check used only at load-time resolution. */
function isExecutableFileSync(path: string): boolean {
try {
if (!statSync(path).isFile()) return false
accessSync(path, constants.X_OK)
return true
} catch {
return false
}
}

View File

@@ -7,7 +7,6 @@
* @module @deepseek-ai/dsh-lsp-local/instance
*/
import { pathToFileURL } from 'node:url'
import { LspError } from '@deepseek-ai/dsh-lsp'
import type {
LspOperation,
@@ -31,6 +30,8 @@ import {
/** 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). */
@@ -108,9 +109,11 @@ export class LspInstance {
private async initialize(): Promise<void> {
const initializeResult = await this.connection.request('initialize', {
processId: process.pid,
rootUri: pathToFileURL(this.spec.cwd).href,
workspaceFolders: [{ uri: pathToFileURL(this.spec.cwd).href, name: 'workspace' }],
// 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
@@ -147,7 +150,7 @@ export class LspInstance {
throw new LspError('server does not support the transient textDocument/didOpen this host requires', 'LSP_UNSUPPORTED_OPERATION')
}
const uri = pathToFileURL(source.canonicalPath).href
const uri = source.fileUrl
let opened = false
try {
/* v8 ignore next -- guards an abort landing between the ready wait and didOpen; not deterministically reproducible. */