| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162 |
- /**
- * Local Service provider for the subprocess capability 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
- * caller'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 '@deepseek-ai/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<SubprocessHandle>()
- /** Live terminal sessions retained through whole-session quiescence. */
- private terminals = new Set<SubprocessTerminalHandle>()
- /** Test hook: spill and platform knobs forwarded to spawnSubprocess. */
- internals: SpawnInternals = {}
- /** Test hook 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<unknown>[] = []
- 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<unknown>(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<Record<string, string>>,
- signal?: AbortSignal,
- ): Promise<string> {
- 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<void> =>
- handle.waitForExit().then(() => { this.live.delete(handle) })
- handle.done.then(release, release)
- return handle
- }
- // Local PTY allocation is synchronous, but the provider contract permits remote asynchronous allocation.
- // oxlint-disable-next-line typescript/require-await -- Preserve promise rejection semantics at the async provider contract.
- async spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle> {
- 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<void> => {
- 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
|