utils/subprocess.ts
Rama main · réplica de lectura del repositorio original.
import { type ChildProcess, spawn as nodeSpawn } from "node:child_process";
import { performance } from "node:perf_hooks";
import { DEFAULT_ACTIVITY_CHECK_INTERVAL_MS, DEFAULT_ACTIVITY_TIMEOUT_MS } from "./activity.ts";
import { log } from "./cli.ts";
import { onExitSignal } from "./exitHandler.ts";
export type TrackChildOptions = {
child: ChildProcess;
// if true, kill the entire process group (requires detached spawn)
killGroup?: boolean;
};
// sentinel codes for timeout rejections — callers (e.g. lifecycle.ts) use
// these to distinguish timeouts from other errors without string-matching
// on the error message, which is fragile to rewording.
export const SPAWN_TIMEOUT_CODE = "E_SPAWN_TIMEOUT";
export const SPAWN_ACTIVITY_TIMEOUT_CODE = "E_SPAWN_ACTIVITY_TIMEOUT";
export class SpawnTimeoutError extends Error {
readonly code: typeof SPAWN_TIMEOUT_CODE | typeof SPAWN_ACTIVITY_TIMEOUT_CODE;
constructor(
message: string,
code: typeof SPAWN_TIMEOUT_CODE | typeof SPAWN_ACTIVITY_TIMEOUT_CODE
) {
super(message);
this.name = "SpawnTimeoutError";
this.code = code;
}
}
// track all spawned child processes for cleanup on Ctrl+C
const activeChildren = new Map<ChildProcess, boolean>();
// signal handler override (used by test runner for graceful shutdown)
export type SignalHandler = (signal: NodeJS.Signals) => void;
let externalSignalHandler: SignalHandler | null = null;
// track a child process for cleanup on Ctrl+C
export function trackChild(options: TrackChildOptions): void {
// the signal handler cleans up all tracked children
// so we only have to install it once some child gets tracked
installSignalHandler();
activeChildren.set(options.child, options.killGroup ?? false);
}
// untrack a child process
export function untrackChild(child: ChildProcess): void {
activeChildren.delete(child);
}
// whether a child is still ours to reap: false once untracked or killed by `killTrackedChildren`
export function isTrackedChild(child: ChildProcess): boolean {
return activeChildren.has(child);
}
// allow callers to override default signal handling
export function setSignalHandler(handler: SignalHandler | null): void {
externalSignalHandler = handler;
}
// kill all tracked children without exiting
export function killTrackedChildren() {
for (const entry of activeChildren) {
const child = entry[0];
const killGroup = entry[1];
if (killGroup && child.pid) {
try {
process.kill(-child.pid, "SIGKILL");
continue;
} catch {
// fall through to direct kill
}
}
child.kill("SIGKILL");
}
activeChildren.clear();
}
// install signal handlers once (call early in process lifecycle)
let handlersInstalled = false;
function installSignalHandler(): void {
if (handlersInstalled) return;
handlersInstalled = true;
onExitSignal((signal) => {
if (externalSignalHandler) {
externalSignalHandler(signal);
return;
}
const count = activeChildren.size;
if (count > 0) {
log.info(`» received ${signal}, killing ${count} subprocess(es)...`);
}
killTrackedChildren();
});
}
/**
* Controls what the wrapper retains in memory across the child's lifetime
* for the post-hoc `SpawnResult.stdout` / `SpawnResult.stderr` snapshots.
*
* Streaming callbacks (`onStdout` / `onStderr`) fire regardless — `retain`
* only governs the buffered snapshot returned in `SpawnResult`.
*
* - `"tail"` (default): keep the last `maxRetainedBytes` UTF-16 code units
* of each stream. Once the cap is exceeded, oldest bytes are sliced off
* and the result is prefixed with a `... [N MiB truncated] ...` sentinel.
* Right default for short-lived commands whose failure mode is in their
* final output (git errors, install failures, hook scripts).
* - `"none"`: skip the buffer entirely. `SpawnResult.stdout` / `.stderr`
* are empty strings. Use this for long-lived streaming agents that already
* drain via `onStdout` / `onStderr` and never read the buffered snapshot.
*
* Default cap is 8 MiB — well below V8's ~1 GiB `kMaxLength` so `+= chunk`
* can never throw `RangeError: Invalid string length`.
*/
export type RetainMode = "tail" | "none";
export const DEFAULT_MAX_RETAINED_BYTES = 8 * 1024 * 1024;
export interface SpawnOptions {
cmd: string;
args: string[];
env?: NodeJS.ProcessEnv;
input?: string;
timeout?: number;
// activity timeout: kill process if no stdout for this many ms (default:
// DEFAULT_ACTIVITY_TIMEOUT_MS, 0 to disable). only stdout resets the timer —
// stderr (e.g. provider error retries) does not count as progress.
activityTimeout?: number;
// fired synchronously when the activity timeout kills the process. used by
// callers (main.ts) to tear down shared resources like the MCP HTTP server
// so that lingering SSE reconnects don't keep the outer activity timer
// alive after the subprocess is already dead.
onActivityTimeout?: (() => void) | undefined;
cwd?: string;
stdio?: ("pipe" | "ignore" | "inherit")[];
onStdout?: (chunk: string) => void;
onStderr?: (chunk: string) => void;
// when true, spawn the child detached (its own process group) and route all
// kill paths (timeout, activity timeout, ctrl-c) through `process.kill(-pid, ...)`
// so signals reach grandchildren too. critical for binaries that fork through
// a shim (e.g. node_modules/opencode-ai/bin/opencode is a Node shim that
// spawnSync's the native binary; without killGroup, SIGKILL only hits the
// shim and the native binary is reparented to PID 1, holds our stdout pipe
// open, keeps emitting NDJSON, and `child.on("close")` never fires —
// producing zombie runs that hang until the GitHub Actions job timeout).
killGroup?: boolean;
retain?: RetainMode;
maxRetainedBytes?: number;
// abort terminates the child like `timeout` does, then rejects with `signal.reason` once it exits
signal?: AbortSignal | undefined;
}
/**
* Bounded string accumulator that keeps the tail of appended chunks.
* Once the cap is exceeded, oldest bytes are sliced off and `toString()`
* prefixes the survivors with a sentinel describing the elided byte count.
*
* Exported because long-lived agent runtimes (opencode, claude) also
* accumulate per-run narration strings independently of the spawn wrapper
* and need the same protection against V8's `kMaxLength`.
*/
export class TailBuffer {
// explicit field declarations rather than constructor parameter properties:
// node's strip-only TS loader (used by action/test/run.ts in CI) rejects
// `constructor(private readonly cap: number)` with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX.
private readonly cap: number;
private buffer = "";
private truncatedBytes = 0;
constructor(cap: number) {
this.cap = cap;
}
append(chunk: string): void {
if (this.cap <= 0) return;
this.buffer += chunk;
if (this.buffer.length > this.cap) {
const drop = this.buffer.length - this.cap;
this.truncatedBytes += drop;
this.buffer = this.buffer.slice(drop);
}
}
toString(): string {
if (this.truncatedBytes === 0) return this.buffer;
const mib = (this.truncatedBytes / 1024 / 1024).toFixed(1);
return `... [${mib} MiB truncated by retain:tail cap] ...\n${this.buffer}`;
}
}
export interface SpawnResult {
stdout: string;
stderr: string;
exitCode: number;
durationMs: number;
}
/**
* Spawn a subprocess with streaming callbacks and buffered results
*/
export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
const activityTimeoutMs = options.activityTimeout ?? DEFAULT_ACTIVITY_TIMEOUT_MS;
options.signal?.throwIfAborted();
installSignalHandler();
const startTime = performance.now();
// capped accumulators — unbounded `+= chunk` previously crashed the wrapper
// with `RangeError: Invalid string length` once V8's ~1 GiB kMaxLength was
// breached on long-lived agent subprocesses (e.g. multi-lens opencode
// Reviews on large monorepos). retain:"none" skips the buffer entirely
// for callers that already drain via onStdout/onStderr.
const retain: RetainMode = options.retain ?? "tail";
const cap = options.maxRetainedBytes ?? DEFAULT_MAX_RETAINED_BYTES;
const stdoutBuffer = retain === "none" ? null : new TailBuffer(cap);
const stderrBuffer = retain === "none" ? null : new TailBuffer(cap);
const killGroup = options.killGroup ?? false;
return new Promise((resolve, reject) => {
// security: caller must provide complete env object, not merged with process.env
const child = nodeSpawn(options.cmd, options.args, {
env: options.env || {
PATH: process.env.PATH || "",
HOME: process.env.HOME || "",
},
stdio: options.stdio || ["pipe", "pipe", "pipe"],
cwd: options.cwd || process.cwd(),
detached: killGroup,
});
// sends `signal` to the entire process group when killGroup is set, so
// grandchildren (e.g. the native opencode binary spawned by the
// opencode-ai Node shim) die with the parent. falls back to a direct
// child kill if the process-group send fails (common when the child
// already exited or was never made a process group leader).
const killSelf = (signal: NodeJS.Signals): void => {
if (killGroup && child.pid) {
try {
process.kill(-child.pid, signal);
return;
} catch {
// fall through to direct kill
}
}
child.kill(signal);
};
// track child for cleanup on Ctrl+C
trackChild({ child, killGroup });
let timeoutId: NodeJS.Timeout | undefined;
let sigkillEscalatorId: NodeJS.Timeout | undefined;
let activityCheckIntervalId: NodeJS.Timeout | undefined;
let isTimedOut = false;
let isActivityTimedOut = false;
let lastActivityTime = performance.now();
// idle-ms snapshot taken at the moment the activity timer decides to kill.
// we reuse it when composing the SpawnTimeoutError so a final stdout chunk
// that races with `close` (and resets lastActivityTime via updateActivity)
// can't make the error message contradict the "no output for Ns" log line.
let killedAtIdleMs: number | undefined;
// SIGTERM first: git deletes its own lockfiles on SIGTERM but leaves them on SIGKILL.
// the escalator has no `child.killed` guard: that flag turns true once SIGTERM is sent.
const terminate = (): void => {
if (sigkillEscalatorId) return; // timeout and abort can both fire; arm one escalator
killSelf("SIGTERM");
// track the escalator so a graceful SIGTERM response (close fires
// before the 5s elapses) can clear it. without capture, this timer
// was orphaned in the event loop and kept node alive for up to 5s
// past a timed-out subprocess's clean exit.
sigkillEscalatorId = setTimeout(() => killSelf("SIGKILL"), 5000);
};
// overall timeout
if (options.timeout) {
timeoutId = setTimeout(() => {
isTimedOut = true;
terminate();
}, options.timeout);
}
options.signal?.addEventListener("abort", terminate, { once: true });
// activity timeout: kill if no output for too long
if (activityTimeoutMs > 0) {
log.debug(
`spawn activity timer: pid=${child.pid} cmd=${options.cmd} timeout=${activityTimeoutMs}ms`
);
activityCheckIntervalId = setInterval(() => {
const idleMs = performance.now() - lastActivityTime;
log.debug(
`spawn activity check: pid=${child.pid} idle=${Math.round(idleMs)}ms / ${activityTimeoutMs}ms`
);
if (idleMs > activityTimeoutMs) {
isActivityTimedOut = true;
killedAtIdleMs = idleMs;
const idleSec = Math.round(idleMs / 1000);
log.info(
`no output for ${idleSec}s from pid=${child.pid} (${options.cmd}), killing process${killGroup ? " group" : ""}`
);
killSelf("SIGKILL");
clearInterval(activityCheckIntervalId);
try {
options.onActivityTimeout?.();
} catch (err) {
log.debug(
`spawn onActivityTimeout handler threw: ${err instanceof Error ? err.message : String(err)}`
);
}
}
}, DEFAULT_ACTIVITY_CHECK_INTERVAL_MS);
}
function updateActivity(): void {
lastActivityTime = performance.now();
}
// wrap handlers in try/catch as defense in depth for synchronous throws
// inside the listener body. the historical `+= chunk` RangeError was such
// a throw — synchronous and fatal under node's default uncaught-exception
// policy. with the TailBuffer cap in place the wrapper-side `append` can
// no longer throw, but the catch keeps protecting against any future
// synchronous regression in this path.
//
// note: this does NOT catch rejections from async user callbacks —
// `options.onStdout?.(chunk)` returns a Promise in the agent callers
// (claude.ts, opencode.ts) and a throw inside an async callback surfaces
// as an unhandled Promise rejection, not a synchronous exception. agent
// callers handle their own NDJSON-parse failures internally; the
// synchronous protection here is what matters for the RangeError class
// of bugs (issue #680).
if (child.stdout) {
child.stdout.on("data", (data: Buffer) => {
try {
updateActivity();
const chunk = data.toString();
stdoutBuffer?.append(chunk);
options.onStdout?.(chunk);
} catch (err) {
log.debug(
`spawn stdout handler threw: ${err instanceof Error ? err.message : String(err)}`
);
}
});
}
if (child.stderr) {
child.stderr.on("data", (data: Buffer) => {
try {
const chunk = data.toString();
stderrBuffer?.append(chunk);
options.onStderr?.(chunk);
} catch (err) {
log.debug(
`spawn stderr handler threw: ${err instanceof Error ? err.message : String(err)}`
);
}
});
}
child.on("close", (exitCode, signal) => {
const durationMs = performance.now() - startTime;
untrackChild(child);
if (timeoutId) clearTimeout(timeoutId);
if (sigkillEscalatorId) clearTimeout(sigkillEscalatorId);
if (activityCheckIntervalId) clearInterval(activityCheckIntervalId);
options.signal?.removeEventListener("abort", terminate);
if (options.signal?.aborted) {
reject(options.signal.reason);
return;
}
if (isTimedOut) {
reject(
new SpawnTimeoutError(`process timed out after ${options.timeout}ms`, SPAWN_TIMEOUT_CODE)
);
return;
}
if (isActivityTimedOut) {
// prefer the idle-ms captured when the kill fired (killedAtIdleMs).
// recomputing from lastActivityTime here would be wrong if the child
// emitted one final stdout chunk between SIGKILL and close — the
// chunk's updateActivity() would reset lastActivityTime and the error
// would report near-zero idle, contradicting the kill-site log line.
const idleMs = killedAtIdleMs ?? performance.now() - lastActivityTime;
const idleSec = Math.round(idleMs / 1000);
reject(
new SpawnTimeoutError(
`activity timeout: no output for ${idleSec}s`,
SPAWN_ACTIVITY_TIMEOUT_CODE
)
);
return;
}
// when a child is killed by signal (OOM, segfault, external SIGTERM),
// node delivers (code=null, signal=<name>). without this branch,
// `exitCode || 0` coerced null to 0 and lifecycle hooks silently
// appeared to succeed when they'd actually been killed — caller
// checked `result.exitCode !== 0` and moved on.
let resolvedExitCode = exitCode ?? 0;
let resolvedStderr = stderrBuffer?.toString() ?? "";
if (exitCode === null && signal) {
const killMsg = `[spawn] ${options.cmd}: killed by signal ${signal}`;
resolvedStderr = resolvedStderr ? `${resolvedStderr}\n${killMsg}` : killMsg;
resolvedExitCode = 1;
}
resolve({
stdout: stdoutBuffer?.toString() ?? "",
stderr: resolvedStderr,
exitCode: resolvedExitCode,
durationMs,
});
});
child.on("error", (error) => {
const durationMs = performance.now() - startTime;
untrackChild(child);
if (timeoutId) clearTimeout(timeoutId);
if (sigkillEscalatorId) clearTimeout(sigkillEscalatorId);
if (activityCheckIntervalId) clearInterval(activityCheckIntervalId);
options.signal?.removeEventListener("abort", terminate);
// surface the spawn error in stderr so callers (e.g. lifecycle hook
// warnings) don't just see "exit code 1, output: (empty)" when the
// command was misspelled, missing, or unexecutable. without this a
// user with a bad postCheckout script got an opaque failure, retried
// per the guidance, and hit the same wall every run.
const errMsg = `[spawn] ${options.cmd}: ${error.message}`;
console.error(errMsg);
const existingStderr = stderrBuffer?.toString() ?? "";
const finalStderr = existingStderr ? `${existingStderr}\n${errMsg}` : errMsg;
resolve({
stdout: stdoutBuffer?.toString() ?? "",
stderr: finalStderr,
exitCode: 1,
durationMs,
});
});
if (options.input && child.stdin && options.stdio?.[0] !== "ignore") {
child.stdin.write(options.input);
child.stdin.end();
}
});
}