/** * Local implementation of the subprocess seam. Each spawn is a detached * process tree with the spec's per-stream stdio dispositions; disposal * terminates and joins live trees. It has no config: every disposition and * limit arrives on the spec, so the deployment-varying choices stay with the * calling seam's config (the bash executor's, the LSP host's, …). * @module @deepseek-ai/dsh-subprocess-local */ import { constants } from 'node:fs' import { access, stat } from 'node:fs/promises' import { delimiter, extname, isAbsolute, resolve } from 'node:path' import { Context } from 'cordis' import * as nodePty from 'node-pty' import type { IPtyForkOptions } from 'node-pty' import { SubprocessService } from '@deepseek-ai/dsh-subprocess' import type { SubprocessHandle, SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalSpawnSpec, } from '@deepseek-ai/dsh-subprocess' import { childEnv, spawnSubprocess } from './spawn.ts' import type { SpawnInternals } from './spawn.ts' import { createProcessInspector } from './process-inspector.ts' import type { ProcessInspector } from './process-inspector.ts' import { LocalTerminalHandle } from './terminal.ts' /** * Local subprocess service: detached process trees, Node-shaped stdio * dispositions (raw pipes, inherit, bounded tail-keep collection with spill * files), credential-scrubbed environment, and tree-scoped signalling with * SIGTERM→grace→SIGKILL escalation. */ export class LocalSubprocessService extends SubprocessService { /** Live handles retained only so disposal can terminate and join them. */ private live = new Set() /** Live terminal sessions retained through whole-session quiescence. */ private terminals = new Set() /** Test seam: spill and platform knobs forwarded to spawnSubprocess. */ internals: SpawnInternals = {} /** Test seam for platform process inspection; production resolves lazily on terminal spawn. */ terminalInspector: ProcessInspector | undefined constructor(ctx: Context) { super(ctx) ctx.effect(() => async () => { // Terminate (escalating), then await WHOLE-TREE exit — not just the // direct child's settlement — so even a TERM-trapping descendant cannot // outlive the fiber. const pending: Promise[] = [] for (const handle of this.live) { handle.terminate() // Spawn-failure rejections already settled and left the live set. pending.push(handle.done.catch(() => {}).then(() => handle.waitForExit())) } for (const terminal of this.terminals) { pending.push(terminal.terminate()) } this.live.clear() this.terminals.clear() const outcomes = await Promise.allSettled(pending) const failures = outcomes.flatMap(outcome => outcome.status === 'rejected' ? [outcome.reason as unknown] : []) if (failures.length === 1) throw failures[0] if (failures.length > 1) throw new AggregateError(failures, 'local subprocess teardown failed') }, 'local subprocess teardown') } async resolveExecutable( command: string, env?: Readonly>, signal?: AbortSignal, ): Promise { if (command.length === 0) throw new Error('subprocess-local: executable must be non-empty') signal?.throwIfAborted() const environment = childEnv(env) const absolute = isAbsolute(command) if (!absolute && (command.includes('/') || (process.platform === 'win32' && command.includes('\\')))) { throw new Error( `subprocess-local: command ${JSON.stringify(command)} is a relative path; use an absolute path or a bare PATH name`, ) } const candidates = absolute ? [command] : this.executableCandidates(command, environment) for (const candidate of candidates) { signal?.throwIfAborted() try { const info = await stat(candidate) if (!info.isFile()) continue await access(candidate, constants.X_OK) signal?.throwIfAborted() return candidate } catch { // Try the next PATH candidate; the final miss receives one stable error. } } signal?.throwIfAborted() throw new Error(absolute ? `subprocess-local: command ${JSON.stringify(command)} is not an executable file` : `subprocess-local: command ${JSON.stringify(command)} was not found on PATH`) } private executableCandidates(command: string, env: NodeJS.ProcessEnv): string[] { const path = environmentValue(env, 'PATH') ?? '' const extensions = process.platform === 'win32' && extname(command) === '' ? (environmentValue(env, 'PATHEXT') ?? '.COM;.EXE;.BAT;.CMD').split(';') : [''] return path.split(delimiter).flatMap(directory => extensions.map(extension => resolve(process.cwd(), directory, command + extension))) } spawn(spec: SubprocessSpawnSpec): SubprocessHandle { const handle = spawnSubprocess(spec, this.internals) this.live.add(handle) // Release ownership only once the whole TREE is gone, not at direct-child // settlement — a TERM-trapping helper that outlives the leader must stay // owned so teardown can still escalate it. For the common no-survivor // case waitForExit resolves immediately after settlement. const release = (): Promise => handle.waitForExit().then(() => { this.live.delete(handle) }) handle.done.then(release, release) return handle } // Local PTY allocation is synchronous, but the provider seam permits remote asynchronous allocation. // oxlint-disable-next-line typescript/require-await -- Preserve promise rejection semantics at the async provider seam. async spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise { const file = spec.argv[0] if (file === undefined || file.length === 0) { throw new Error('subprocess-local: terminal argv must contain a program') } spec.signal?.throwIfAborted() const options: IPtyForkOptions = { name: 'dumb', rows: spec.rows, cols: spec.cols, cwd: spec.cwd, env: childEnv(spec.env), } const inspector = this.terminalInspector ?? createProcessInspector() const terminal = nodePty.spawn(file, [...spec.argv.slice(1)], options) const handle = new LocalTerminalHandle(terminal, inspector, spec.graceMs) this.terminals.add(handle) const release = async (): Promise => { await handle.terminate() this.terminals.delete(handle) } void handle.done.then(release, release).catch(() => {}) return handle } } /** Read a Windows environment key using the platform's case-insensitive semantics. */ function environmentValue(env: NodeJS.ProcessEnv, name: 'PATH' | 'PATHEXT'): string | undefined { const exact = env[name] if (exact !== undefined || process.platform !== 'win32') return exact const normalized = name.toUpperCase() return Object.entries(env).find(([key]) => key.toUpperCase() === normalized)?.[1] } export default LocalSubprocessService