/** * The real runtime adapter for RunnerDeps: node:child_process spawn (Bun * implements the same module, so this is the Node AND Bun adapter), wall * clock, and process signalling. This is the ONE module where the injected * primitives touch the operating system - everything above it stays pure * and fake-driven. Kills go to the exact child handle this adapter * spawned, never to a pid pattern. */ import { type ChildProcess, spawn as nodeSpawn } from "node:child_process"; import { homedir } from "node:os"; import type { Readable } from "node:stream"; import type { Clock, ProcessStart, RunnerDeps, SpawnedProcess, SpawnOptions } from "./deps.js"; import { mergeEnvironment } from "./environment.js"; import { inspectNativeSettings, type NativeSettingsInspector } from "./native-settings.js"; import { readNativeProcessOwner, waitForChildExec } from "./process-identity.js"; const children = new WeakMap(); const emptyStream = async function* (): AsyncIterable {}; type SpawnOutcome = "spawned" | "failed"; /** Normalize only errors created by this adapter's own terminal operations. * Identity comparison keeps an unrelated read failure visible even when it * settles after output disposal has started. */ const normalizeOutputStream = async function* ( source: AsyncIterable, disposalCause: Error, spawnOutcome: Promise, ): AsyncIterable { try { yield* source; } catch (cause) { if (cause === disposalCause) return; if ( (cause as NodeJS.ErrnoException)?.code === "ERR_STREAM_PREMATURE_CLOSE" && (await spawnOutcome) === "failed" ) { return; } throw cause; } }; /** Request terminal disposal without discarding chunks already buffered by * the runtime. Once the consumer reaches the first pending read, destruction * settles that read with this adapter's unique disposal cause. */ export const disposableOutputStream = ( source: Readable, disposalCause: Error, spawnOutcome: Promise, ): { readonly stream: AsyncIterable; dispose(): void } => { let disposalRequested = false; let disposalDrainBudget = 0; let readPending = false; const destroyPendingRead = (): void => { if (disposalRequested && readPending && disposalDrainBudget === 0 && !source.destroyed) { source.destroy(disposalCause); } }; const drainBeforeDisposal = async function* (): AsyncIterable { const iterator = source[Symbol.asyncIterator](); while (true) { readPending = true; if (disposalRequested && disposalDrainBudget === 0 && !source.destroyed) { source.destroy(disposalCause); } let next: IteratorResult; try { next = await iterator.next(); } finally { readPending = false; } if (next.done) return; if (disposalRequested) { const bytes = next.value as { readonly byteLength?: number }; disposalDrainBudget = Math.max(0, disposalDrainBudget - (bytes.byteLength ?? 0)); } yield next.value as Uint8Array; } }; return { stream: normalizeOutputStream(drainBeforeDisposal(), disposalCause, spawnOutcome), dispose: (): void => { if (disposalRequested) return; disposalRequested = true; disposalDrainBudget = source.readableLength; destroyPendingRead(); }, }; }; const realSpawn = (argv: readonly string[], opts: SpawnOptions): SpawnedProcess => { const [bin, ...args] = argv; if (bin === undefined) throw new Error("empty argv"); const env = mergeEnvironment(process.env, opts.env); const child = nodeSpawn(bin, args, { ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}), ...(env !== undefined ? { env } : {}), stdio: [ opts.stdin === "pipe" ? "pipe" : opts.stdin === "close" ? "ignore" : "inherit", opts.output === "inherit" ? "inherit" : "pipe", opts.output === "inherit" ? "inherit" : "pipe", ], }); const started = new Promise((resolve) => { let settled = false; const settle = (value: ProcessStart): void => { if (settled) return; settled = true; resolve(value); }; let spawned = false; child.once("spawn", () => { spawned = true; if (opts.output !== "inherit" || child.pid === undefined) { settle({ kind: "started", owner: undefined }); return; } const pid = child.pid; // Under Bun on Linux, spawn can fire before the child has exec'd. // Wait for the post-exec image before reading the owner; "unknown" // stays ownerless and interactive launch fails closed. The error // handler below still wins when the spawn fails: whichever settles // first decides, and this async arm never settles twice. void waitForChildExec(pid).then( (state) => settle({ kind: "started", owner: state === "exec'd" ? readNativeProcessOwner(pid) : undefined, }), () => settle({ kind: "started", owner: undefined }), ); }); child.once("error", (cause: NodeJS.ErrnoException) => { const knownNoChild = ["ENOENT", "EACCES", "ENOTDIR", "ENOEXEC", "EAGAIN", "ENOMEM"]; settle( !spawned && child.pid === undefined && cause.code && knownNoChild.includes(cause.code) ? { code: cause.code, kind: "not-started" } : { kind: "uncertain" }, ); }); }); const inputError = new Promise((resolve) => { child.stdin?.once("error", () => resolve()); }); let outputDisposed = false; let outputDisposedResolve!: () => void; const outputDisposal = new Promise((resolve) => { outputDisposedResolve = resolve; }); const stdoutDisposalCause = new Error("stdout disposed by runner"); const stderrDisposalCause = new Error("stderr disposed by runner"); let spawnError: Error | null = null; const spawnOutcome = new Promise((resolve) => { child.once("spawn", () => resolve("spawned")); child.once("error", () => resolve("failed")); }); const stdoutOutput = child.stdout === null ? null : disposableOutputStream(child.stdout, stdoutDisposalCause, spawnOutcome); const stderrOutput = child.stderr === null ? null : disposableOutputStream(child.stderr, stderrDisposalCause, spawnOutcome); // exited resolves via 'exit' (process died - fires even while a grandchild // holds the pipes open, unlike 'close') or 'error' (ENOENT etc., which // node reports async, not as a sync throw -> map to 127 crash). The error // handler sets spawnError before it resolves exited. const exited = new Promise((resolve) => { child.on("error", (err) => { spawnError = err; resolve(127); }); child.on("exit", (code) => resolve(code)); }); // The spawn error rides the stderr stream so the runner's tail (and the // crash exit log) carry the explanation, matching the sync-throw path. // normalizeOutputStream waits only for the spawn/error outcome when it sees a // premature close, so a genuine read failure on a live child propagates // immediately while an async ENOENT remains the single spawn-error signal. const stderrWithError = async function* (): AsyncIterable { if (stderrOutput !== null) yield* stderrOutput.stream; // Terminal output disposal must also settle this tail when a child has // closed stderr but has not exited. The caller owns signalling it. await Promise.race([exited, outputDisposal]); if (spawnError !== null) yield `spawn failed: ${(spawnError as Error).message}\n`; }; const proc: SpawnedProcess = { inputError, stdout: stdoutOutput === null ? emptyStream() : stdoutOutput.stream, stderr: opts.output === "inherit" ? emptyStream() : stderrWithError(), started, exited, startupError: () => spawnError?.message ?? null, disposeOutput: (): void => { if (outputDisposed) return; outputDisposed = true; outputDisposedResolve(); stdoutOutput?.dispose(); stderrOutput?.dispose(); }, ...(opts.stdin === "pipe" && child.stdin !== null ? { stdin: { write: (data: string): void => { child.stdin?.write(data); }, end: (): void => { child.stdin?.end(); }, }, } : {}), }; children.set(proc, child); return proc; }; const realClock: Clock = { now: () => Date.now(), // getTimezoneOffset is minutes WEST of UTC; the reset-hint resolver // wants east-of-UTC, so negate it (+420 for +07:00). utcOffsetMinutes: (ms) => -new Date(ms).getTimezoneOffset(), setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number, clearTimeout: (handle) => clearTimeout(handle as unknown as ReturnType), }; export const nodeNativeSettingsInspector: NativeSettingsInspector = (request) => { const environment = mergeEnvironment(process.env, request.env); return inspectNativeSettings(request, { codexHome: environment.CODEX_HOME, env: environment, home: environment.HOME ?? homedir(), }); }; export const nodeRunnerDeps = (extra?: Partial): RunnerDeps => ({ inspectNativeSettings: nodeNativeSettingsInspector, spawn: realSpawn, clock: realClock, signal: (proc, sig) => { children.get(proc)?.kill(sig); }, ...extra, });