mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
163 lines
7.0 KiB
TypeScript
163 lines
7.0 KiB
TypeScript
/**
|
|
* 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 '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
|