Files
deepseek-harness/packages/subprocess/subprocess-local/src/index.ts
Tianyi Cui e385c11e8e fix(pty,subprocess,lsp): address review round on cancellation, lookup, and contracts
- pty-local: a pre-write inspection rejection no longer releases a canceled
  send while its foreground SIGINT is in flight; the interrupt path retains
  the slot and its post-signal tail resumes polling. Regression pins the
  failure-shaped cancellation and a close-during-write release.
- pty-local: SEND_ACTIVE names which provider operation is draining; README
  states the never-settling-provider outcome (slot retained, close recovers).
- subprocess-local: resolveExecutable rejects relative paths containing
  separators instead of expanding them per PATH entry with a misleading
  not-found error; seam JSDoc pins the rule for every provider.
- subprocess-local: LocalTerminalHandle documents why the seam's
  in-flight-join promise holds without operation tracking.
- lsp-local: the oversized-source diagnostic reports the observed byte lower
  bound; README documents that processId: null trades away server-side
  orphan cleanup after a hard-killed harness.
- pty/subprocess seams: cross-reference the twin five-member signal unions.
- pty-local: TODO markers for send-state consolidation and initialize-race
  relocation.
2026-08-08 21:27:59 +08:00

163 lines
7.0 KiB
TypeScript

/**
* 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<SubprocessHandle>()
/** Live terminal sessions retained through whole-session quiescence. */
private terminals = new Set<SubprocessTerminalHandle>()
/** 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<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 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<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