Merge remote-tracking branch 'origin/master' into feat/adr0016-type-build-check
This commit is contained in:
365 files changed
+13601
-7241
No files matched your search
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* `LocalBashExecutor`: the local-subprocess implementation of the
|
||||
* `@deepseek-ai/dsh-bash` executor seam. Spawns `bash -c` per call in its
|
||||
* own process group (see `./run.ts` for the plumbing and the agent-tool
|
||||
* survey notes), tracks background tasks, and kills everything on dispose.
|
||||
*
|
||||
* TODO(permissions/sandbox): execution policy does NOT belong here — wrap
|
||||
* the `tools/execute` waterfall (see docs/architecture.md § plugin
|
||||
* checklist) or implement a sandboxing `BashExecutor`. Reference points:
|
||||
* Claude Code wraps commands in sandbox-exec/bubblewrap; Codex applies
|
||||
* seatbelt/landlock plus an execpolicy prefix-rule engine.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-bash-local
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { BashExecutor, BashTaskId } from '@deepseek-ai/dsh-bash'
|
||||
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash'
|
||||
import { runBash } from './run'
|
||||
import type { RunInternals, RunningBash } from './run'
|
||||
|
||||
export { DEFAULT_GRACE_MS, ENV_OVERRIDES, killGroup, OutputCollector, runBash } from './run'
|
||||
export type { RunInternals, RunningBash, SpawnOutcome, SpawnSpec } from './run'
|
||||
|
||||
/** Plugin config (all optional — `static Config` supplies the defaults). */
|
||||
export interface Config {
|
||||
/** Default working directory for commands (default: process.cwd()). */
|
||||
cwd?: string
|
||||
/** Default foreground timeout in milliseconds. */
|
||||
timeoutMs?: number
|
||||
/** Upper bound for per-call timeout overrides. */
|
||||
maxTimeoutMs?: number
|
||||
/** Per-stream in-memory output cap; overflow spills to a temp file. */
|
||||
maxOutputBytes?: number
|
||||
}
|
||||
|
||||
/** The shape after schemastery applied the defaults (cwd has none). */
|
||||
type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
|
||||
|
||||
function assertPositiveFinite(name: string, value: number): void {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`bash-local: ${name} must be a positive finite number`)
|
||||
}
|
||||
}
|
||||
|
||||
interface TrackedTask extends BashTask {
|
||||
running: RunningBash
|
||||
/** Whole-stream byte offsets already delivered via {@link LocalBashExecutor.readOutput}. */
|
||||
stdoutOffset: number
|
||||
stderrOffset: number
|
||||
/** Opaque owner token from the {@link BashExecSpec} (the consumer's isolation key). */
|
||||
owner: OwnerToken | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Local-subprocess bash executor. Defaults follow the agent-tool survey
|
||||
* consensus: 120s default / 600s max timeout (Claude Code, OpenCode), 64KB
|
||||
* in-memory output with full-stream spill files (pi, OpenCode),
|
||||
* process-group SIGTERM→SIGKILL kills (OpenCode).
|
||||
*/
|
||||
export class LocalBashExecutor extends BashExecutor {
|
||||
static Config: z<Config> = z.object({
|
||||
cwd: z.string(),
|
||||
timeoutMs: z.number().default(120_000),
|
||||
maxTimeoutMs: z.number().default(600_000),
|
||||
maxOutputBytes: z.number().default(64_000),
|
||||
})
|
||||
|
||||
private tasks = new Map<BashTaskId, TrackedTask>()
|
||||
private nextTaskId = 1
|
||||
/** Test seam: timer/spill knobs forwarded to runBash. */
|
||||
internals: RunInternals = {}
|
||||
|
||||
/** Validated config (schemastery applied the defaults before construction). */
|
||||
readonly config: ResolvedConfig
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx)
|
||||
// schemastery (static Config) has already filled the defaulted fields;
|
||||
// the cast records that runtime fact for exactOptionalPropertyTypes.
|
||||
this.config = config as ResolvedConfig
|
||||
assertPositiveFinite('timeoutMs', this.config.timeoutMs)
|
||||
assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs)
|
||||
assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
|
||||
ctx.effect(() => async () => {
|
||||
// Kill every live process group and WAIT for the processes to close so
|
||||
// nothing outlives the fiber (HMR safety) — a TERM-trapping child is
|
||||
// held until the SIGKILL escalation lands. The base class already
|
||||
// silenced listeners, so these kills complete without notices.
|
||||
const pending: Promise<void>[] = []
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.status === 'running') {
|
||||
task.status = 'killed'
|
||||
task.running.kill()
|
||||
pending.push(task.done)
|
||||
}
|
||||
}
|
||||
this.tasks.clear()
|
||||
await Promise.all(pending)
|
||||
}, 'local bash teardown')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a request into a fully-specified spec: fill `workdir` from
|
||||
* `config.cwd` (else `process.cwd()`), and `timeoutMs` from
|
||||
* `config.timeoutMs`, capped at `config.maxTimeoutMs`. The tool layer calls
|
||||
* this before {@link run}/{@link start}, so those methods receive explicit
|
||||
* values and never re-default.
|
||||
*/
|
||||
resolve(request: BashExecRequest): BashExecSpec {
|
||||
if (request.timeoutMs !== undefined) assertPositiveFinite('request.timeoutMs', request.timeoutMs)
|
||||
const timeoutMs = Math.min(request.timeoutMs ?? this.config.timeoutMs, this.config.maxTimeoutMs)
|
||||
return {
|
||||
command: request.command,
|
||||
workdir: request.workdir ?? this.config.cwd ?? process.cwd(),
|
||||
timeoutMs,
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
// Carry the owner through verbatim (required-but-nullable on the spec):
|
||||
// the executor never interprets it — the consumer's access policy does.
|
||||
owner: request.owner,
|
||||
}
|
||||
}
|
||||
|
||||
async run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
const outcome = await runBash({
|
||||
command: spec.command,
|
||||
cwd: spec.workdir,
|
||||
timeoutMs: spec.timeoutMs,
|
||||
maxOutputBytes: this.config.maxOutputBytes,
|
||||
signal: spec.signal,
|
||||
}, this.internals).done
|
||||
return { ...outcome, timeoutMs: spec.timeoutMs }
|
||||
}
|
||||
|
||||
start(spec: BashExecSpec): BashTask {
|
||||
// No timeout for background tasks (matches Claude Code, which detaches
|
||||
// the timeout when backgrounding); callers stop tasks via kill() — or
|
||||
// via spec.signal, which the seam contract honors for background runs
|
||||
// too (runBash wires it to the group kill). spec.timeoutMs is ignored
|
||||
// here by design.
|
||||
const running = runBash({
|
||||
command: spec.command,
|
||||
cwd: spec.workdir,
|
||||
timeoutMs: 0,
|
||||
maxOutputBytes: this.config.maxOutputBytes,
|
||||
signal: spec.signal,
|
||||
}, this.internals)
|
||||
|
||||
const id = BashTaskId(`bash-${this.nextTaskId++}`)
|
||||
const task: TrackedTask = {
|
||||
id,
|
||||
command: spec.command,
|
||||
status: 'running',
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
owner: spec.owner,
|
||||
running,
|
||||
stdoutOffset: 0,
|
||||
stderrOffset: 0,
|
||||
done: running.done.then((outcome) => {
|
||||
// Abort-killed tasks report as killed, not completed.
|
||||
if (task.status === 'running') task.status = outcome.aborted ? 'killed' : 'completed'
|
||||
task.exitCode = outcome.exitCode
|
||||
task.signal = outcome.signal
|
||||
this.notifyTaskDone(task)
|
||||
}, (error: unknown) => {
|
||||
// Spawn-level failure (bad workdir, …): the task never ran. String()
|
||||
// suffices — runBash only rejects with Error instances.
|
||||
task.status = 'killed'
|
||||
task.running.stderr.push(Buffer.from(`spawn failed: ${String(error)}`))
|
||||
this.notifyTaskDone(task)
|
||||
}),
|
||||
}
|
||||
this.tasks.set(id, task)
|
||||
return task
|
||||
}
|
||||
|
||||
get(id: BashTaskId): BashTask | undefined {
|
||||
return this.tasks.get(id)
|
||||
}
|
||||
|
||||
ownerOf(id: BashTaskId): OwnerToken | undefined {
|
||||
// Unknown id and known-but-ownerless both read as undefined — the consumer
|
||||
// treats undefined as "open" and a truly unknown id fails at readOutput/kill.
|
||||
return this.tasks.get(id)?.owner
|
||||
}
|
||||
|
||||
list(): BashTask[] {
|
||||
return [...this.tasks.values()]
|
||||
}
|
||||
|
||||
readOutput(id: BashTaskId): BashTaskRead {
|
||||
const task = this.tasks.get(id)
|
||||
if (!task) throw new Error(`unknown bash task "${id}"`)
|
||||
|
||||
const out = task.running.stdout.readFrom(task.stdoutOffset)
|
||||
const err = task.running.stderr.readFrom(task.stderrOffset)
|
||||
task.stdoutOffset = out.nextOffset
|
||||
task.stderrOffset = err.nextOffset
|
||||
|
||||
// Single newline between sections: stdout chunks usually end with one
|
||||
// already; add it only when missing.
|
||||
const separator = out.text.length > 0 && !out.text.endsWith('\n') ? '\n' : ''
|
||||
const delta = out.text
|
||||
+ (err.text.length > 0 ? `${separator}[stderr]\n${err.text}` : '')
|
||||
return {
|
||||
task,
|
||||
delta,
|
||||
lossy: out.lossy || err.lossy,
|
||||
...out.spillPath !== undefined ? { stdoutSpillPath: out.spillPath } : {},
|
||||
...err.spillPath !== undefined ? { stderrSpillPath: err.spillPath } : {},
|
||||
}
|
||||
}
|
||||
|
||||
kill(id: BashTaskId): boolean {
|
||||
const task = this.tasks.get(id)
|
||||
if (!task) throw new Error(`unknown bash task "${id}"`)
|
||||
if (task.status !== 'running') return false
|
||||
task.status = 'killed'
|
||||
task.running.kill()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export default LocalBashExecutor
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* Process plumbing for the local bash executor: spawn, output collection
|
||||
* with tail-keep + spill-to-disk truncation, and process-group kill with
|
||||
* SIGTERM→SIGKILL escalation.
|
||||
*
|
||||
* Everything here is deliberately free of Cordis concepts so it can be unit
|
||||
* tested in isolation; `LocalBashExecutor` owns lifecycle and configuration.
|
||||
*
|
||||
* Design notes (surveyed against Claude Code, OpenCode, Codex, and pi — see
|
||||
* the package README): spawn-per-call with `detached: true` so the child
|
||||
* leads its own process group; kills target the group (`kill(-pid)`) so
|
||||
* pipelines and subshells die with the parent. SIGTERM first, SIGKILL after a
|
||||
* grace period (OpenCode's escalation; Codex/pi jump straight to SIGKILL).
|
||||
*
|
||||
* @module dsh-bash-local/run
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { closeSync, mkdtempSync, openSync, writeSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
/**
|
||||
* Model-friendly environment overrides: disable colors, pagers, and
|
||||
* interactive terminal features that would garble tool output (the same set
|
||||
* Codex hardcodes; Claude Code achieves it via TERM=dumb).
|
||||
*/
|
||||
export const ENV_OVERRIDES = {
|
||||
NO_COLOR: '1',
|
||||
TERM: 'dumb',
|
||||
PAGER: 'cat',
|
||||
GIT_PAGER: 'cat',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Credential-shaped env vars are NOT forwarded to commands (the harness's
|
||||
* own DEEPSEEK_API_KEY must not leak into `env` output, tool results, or
|
||||
* spill files). Same default pattern as Codex's env policy; a future config
|
||||
* can whitelist specific vars when a workflow genuinely needs one.
|
||||
*/
|
||||
export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
|
||||
|
||||
/** process.env minus credential-shaped vars, plus the model-friendly overrides. */
|
||||
export function childEnv(): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (!SENSITIVE_ENV_PATTERN.test(key)) env[key] = value
|
||||
}
|
||||
return { ...env, ...ENV_OVERRIDES }
|
||||
}
|
||||
|
||||
/** What to run and under which limits (resolved — no defaults in here). */
|
||||
export interface SpawnSpec {
|
||||
command: string
|
||||
cwd: string
|
||||
/** Kill the process group after this many milliseconds. 0 = no timeout. */
|
||||
timeoutMs: number
|
||||
/** Per-stream in-memory cap; overflow spills to disk (tail kept in memory). */
|
||||
maxOutputBytes: number
|
||||
/** Abort signal — kills the process group when fired. */
|
||||
signal?: AbortSignal | undefined
|
||||
}
|
||||
|
||||
/** Raw outcome of one closed process (before result shaping). */
|
||||
export interface SpawnOutcome {
|
||||
exitCode: number | null
|
||||
signal: NodeJS.Signals | null
|
||||
timedOut: boolean
|
||||
aborted: boolean
|
||||
stdout: CollectedOutput
|
||||
stderr: CollectedOutput
|
||||
}
|
||||
|
||||
/** Injectable knobs so tests can exercise escalation/spill without long waits. */
|
||||
export interface RunInternals {
|
||||
/** Grace period between SIGTERM and SIGKILL on the process group. */
|
||||
graceMs?: number
|
||||
/** Directory for spill files (defaults to the OS temp dir). */
|
||||
spillDir?: string
|
||||
}
|
||||
|
||||
/** Default SIGTERM→SIGKILL grace period (matches OpenCode's 3s). */
|
||||
export const DEFAULT_GRACE_MS = 3_000
|
||||
|
||||
let spillCounter = 0
|
||||
let defaultSpillDir: string | undefined
|
||||
|
||||
/**
|
||||
* The default spill location: a private (0700) per-process directory under
|
||||
* the OS tmpdir, created lazily. Predictable world-readable paths would let
|
||||
* other local users read command output or pre-create symlinks.
|
||||
*/
|
||||
function privateSpillDir(): string {
|
||||
defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-bash-'))
|
||||
return defaultSpillDir
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects one stream with a bounded in-memory tail. The FULL stream is
|
||||
* always recoverable: on first overflow a spill file is created and every
|
||||
* chunk (including those already collected) is appended there.
|
||||
*
|
||||
* Tail-keep rationale (pi/OpenCode): errors and final results cluster at the
|
||||
* end of command output; the spill file covers the head.
|
||||
*/
|
||||
export class OutputCollector {
|
||||
private chunks: Buffer[] = []
|
||||
private bytes = 0
|
||||
private dropped = false
|
||||
private spillFd: number | undefined
|
||||
private spillFile: string | undefined
|
||||
/** Total bytes ever pushed (not just retained). */
|
||||
private total = 0
|
||||
|
||||
constructor(
|
||||
private readonly maxBytes: number,
|
||||
private readonly label: string,
|
||||
private readonly spillDir: string,
|
||||
) {}
|
||||
|
||||
push(chunk: Buffer): void {
|
||||
this.total += chunk.length
|
||||
const overflows = this.bytes + chunk.length > this.maxBytes
|
||||
if (overflows || this.spillFd !== undefined) this.spillAll(chunk)
|
||||
this.chunks.push(chunk)
|
||||
this.bytes += chunk.length
|
||||
while (this.bytes > this.maxBytes && this.chunks.length > 1) {
|
||||
// Drop whole chunks from the head; pipe chunks are small (≤64KiB), so
|
||||
// the retained tail tracks the cap closely enough for a model-facing
|
||||
// truncation boundary. (length > 1 was just checked — shift() returns.)
|
||||
const head = this.chunks.shift() as Buffer
|
||||
this.bytes -= head.length
|
||||
this.dropped = true
|
||||
}
|
||||
if (this.bytes > this.maxBytes && this.chunks.length === 1) {
|
||||
// A single chunk larger than the cap: keep its tail.
|
||||
const only = this.chunks[0] as Buffer
|
||||
this.chunks[0] = only.subarray(only.length - this.maxBytes)
|
||||
this.bytes = this.maxBytes
|
||||
this.dropped = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Open the spill file lazily and append `chunk` (and any prior chunks once). */
|
||||
private spillAll(chunk: Buffer): void {
|
||||
if (this.spillFd === undefined) {
|
||||
// Random suffix + O_EXCL + no-follow-equivalent ('wx' fails on any
|
||||
// existing path, symlink or not) + owner-only mode: defeats spill-path
|
||||
// prediction and symlink planting in shared tmp dirs.
|
||||
this.spillFile = join(
|
||||
this.spillDir,
|
||||
`dsh-bash-${process.pid}-${++spillCounter}-${randomBytes(6).toString('hex')}-${this.label}.log`,
|
||||
)
|
||||
this.spillFd = openSync(this.spillFile, 'wx', 0o600)
|
||||
for (const prior of this.chunks) writeSync(this.spillFd, prior)
|
||||
}
|
||||
writeSync(this.spillFd, chunk)
|
||||
}
|
||||
|
||||
// TODO(snapshot-scope): `snapshot()` has one internal caller (`finalize()` at
|
||||
// the bottom of this file) and `totalBytes` is read only by a test. The live
|
||||
// background-poll path goes through `readFrom()`, so inline snapshot() into
|
||||
// finalize() and drop or privatize the totalBytes getter.
|
||||
/** Read the collected tail without finalizing (the final-result snapshot). */
|
||||
snapshot(): CollectedOutput {
|
||||
return {
|
||||
text: Buffer.concat(this.chunks).toString('utf8'),
|
||||
truncated: this.dropped,
|
||||
...this.spillFile !== undefined ? { spillPath: this.spillFile } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/** Total bytes ever pushed (including bytes dropped from memory). */
|
||||
get totalBytes(): number {
|
||||
return this.total
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental read in whole-stream byte coordinates: returns everything
|
||||
* pushed since `fromByte`. When `fromByte` has already slid out of the
|
||||
* in-memory tail window, the read is `lossy` — it returns the whole
|
||||
* retained tail and the gap is only recoverable from the spill file.
|
||||
*/
|
||||
readFrom(fromByte: number): { text: string; nextOffset: number; lossy: boolean; spillPath?: string } {
|
||||
const windowStart = this.total - this.bytes
|
||||
const buffer = Buffer.concat(this.chunks)
|
||||
const lossy = fromByte < windowStart
|
||||
const slice = lossy ? buffer : buffer.subarray(fromByte - windowStart)
|
||||
return {
|
||||
text: slice.toString('utf8'),
|
||||
nextOffset: this.total,
|
||||
lossy,
|
||||
...this.spillFile !== undefined ? { spillPath: this.spillFile } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the spill file (if any) and return the final output. */
|
||||
finalize(): CollectedOutput {
|
||||
if (this.spillFd !== undefined) {
|
||||
try {
|
||||
closeSync(this.spillFd)
|
||||
} catch {
|
||||
// close can surface delayed writeback failures (for example EIO/ENOSPC)
|
||||
// after writeSync appeared to succeed. Keep finalize total so runBash's
|
||||
// close handler still resolves, but stop advertising a spill file that
|
||||
// may be missing its tail.
|
||||
this.spillFile = undefined
|
||||
}
|
||||
this.spillFd = undefined
|
||||
}
|
||||
return this.snapshot()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send `sig` to the process GROUP led by `pid` (requires the child to have
|
||||
* been spawned with `detached: true`). NEVER throws: kills race process exit
|
||||
* by design (ESRCH), and the other failure modes (EPERM from setuid
|
||||
* children, …) fire inside timer callbacks where a throw would crash the
|
||||
* host process — a kill that cannot be delivered is reported by the process
|
||||
* NOT dying, which callers already handle via escalation/timeouts. No-op for
|
||||
* non-positive pids (spawn never started a process).
|
||||
*/
|
||||
export function killGroup(pid: number, sig: NodeJS.Signals): void {
|
||||
if (pid <= 0) return
|
||||
try {
|
||||
process.kill(-pid, sig)
|
||||
} catch {
|
||||
// Swallow: see contract above.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A live bash child process: the promise resolves when the process closes;
|
||||
* `kill()` starts the SIGTERM→grace→SIGKILL escalation on its group.
|
||||
*/
|
||||
export interface RunningBash {
|
||||
/** Process id (group leader); -1 when the spawn itself failed. */
|
||||
readonly pid: number
|
||||
/** stdout/stderr collectors (live — background polling reads incrementally). */
|
||||
readonly stdout: OutputCollector
|
||||
readonly stderr: OutputCollector
|
||||
/** Resolves when the process closes; rejects only for spawn-level failures. */
|
||||
readonly done: Promise<SpawnOutcome>
|
||||
/** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */
|
||||
kill(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn `bash -c <command>` in its own process group and collect output.
|
||||
*
|
||||
* Outcome semantics: the returned promise REJECTS only for spawn-level
|
||||
* failures (bad cwd → ENOENT, missing binary, pre-aborted signal); every
|
||||
* runtime outcome — nonzero exit, timeout kill, abort kill, signal death —
|
||||
* RESOLVES with a {@link SpawnOutcome} describing what happened, so callers
|
||||
* shape one consistent report for the model.
|
||||
*
|
||||
* XXX(stateful-shell): per the agent-tool survey there are two proven
|
||||
* stateful designs worth revisiting — Claude Code persists ONLY cwd between
|
||||
* calls (captures `pwd -P` after each command), and Codex keeps whole PTY
|
||||
* exec sessions addressable via session ids + stdin writes. We deliberately
|
||||
* spawn a fresh non-login `bash -c` per call for determinism (no rc files,
|
||||
* no inherited shell state); revisit when real workflows demand it.
|
||||
*/
|
||||
export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningBash {
|
||||
const graceMs = internals.graceMs ?? DEFAULT_GRACE_MS
|
||||
const spillDir = internals.spillDir ?? privateSpillDir()
|
||||
|
||||
if (spec.signal?.aborted) {
|
||||
throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`)
|
||||
}
|
||||
|
||||
const child = spawn('bash', ['-c', spec.command], {
|
||||
cwd: spec.cwd,
|
||||
env: childEnv(),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true,
|
||||
})
|
||||
|
||||
const stdout = new OutputCollector(spec.maxOutputBytes, 'stdout', spillDir)
|
||||
const stderr = new OutputCollector(spec.maxOutputBytes, 'stderr', spillDir)
|
||||
child.stdout.on('data', (chunk: Buffer) => { stdout.push(chunk) })
|
||||
child.stderr.on('data', (chunk: Buffer) => { stderr.push(chunk) })
|
||||
|
||||
let timedOut = false
|
||||
let aborted = false
|
||||
let killTimer: NodeJS.Timeout | undefined
|
||||
let graceTimer: NodeJS.Timeout | undefined
|
||||
|
||||
// pid is undefined when the spawn itself fails (bad cwd, missing binary);
|
||||
// the 'error' handler rejects `done` and kills become no-ops via pid -1.
|
||||
const pid = child.pid ?? -1
|
||||
|
||||
const kill = (): void => {
|
||||
if (graceTimer !== undefined) return // escalation already in flight
|
||||
killGroup(pid, 'SIGTERM')
|
||||
graceTimer = setTimeout(() => { killGroup(pid, 'SIGKILL') }, graceMs)
|
||||
}
|
||||
|
||||
if (spec.timeoutMs > 0) {
|
||||
killTimer = setTimeout(() => {
|
||||
timedOut = true
|
||||
kill()
|
||||
}, spec.timeoutMs)
|
||||
}
|
||||
|
||||
const onAbort = (): void => {
|
||||
aborted = true
|
||||
kill()
|
||||
}
|
||||
spec.signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
const done = new Promise<SpawnOutcome>((resolve, reject) => {
|
||||
child.on('error', (error) => {
|
||||
// Spawn-level failure (ENOENT cwd, EACCES, …): no close event with
|
||||
// meaningful output follows; clean up and reject.
|
||||
cleanup()
|
||||
reject(error)
|
||||
})
|
||||
child.on('close', (exitCode, signal) => {
|
||||
cleanup()
|
||||
resolve({
|
||||
exitCode,
|
||||
signal,
|
||||
timedOut,
|
||||
aborted,
|
||||
stdout: stdout.finalize(),
|
||||
stderr: stderr.finalize(),
|
||||
})
|
||||
})
|
||||
function cleanup(): void {
|
||||
if (killTimer !== undefined) clearTimeout(killTimer)
|
||||
if (graceTimer !== undefined) clearTimeout(graceTimer)
|
||||
spec.signal?.removeEventListener('abort', onAbort)
|
||||
}
|
||||
})
|
||||
|
||||
return { pid, stdout, stderr, done, kill }
|
||||
}
|
||||
Reference in New Issue
Block a user