import { execa, type ResultPromise } from "execa"; export type RunChildOpts = { command: string; args: string[]; cwd: string; env?: Record; // Optional prefix prepended to every streamed line (e.g. "[testsite] "), so a // single combined log can interleave several children legibly. label?: string; }; // Release 18: a publish STAGE CHILD (and `archilyzer publish …`) is a process of // its own whose children run whole trees — `pnpm exec next build` and its // workers, wrangler, docker. Killing only the direct child leaves the rest // running after a Cancel. With tree-kill on, each child is spawned as the // leader of its own process group, and a cancel signals the whole GROUP. Off // by default, so the editor's own in-process jobs are unchanged; a stage child // turns it on once at start. Per process, on globalThis like the registry. const TREE_KILL_KEY = Symbol.for("archilyzer.runChild.treeKill"); type TreeKillGlobal = { [TREE_KILL_KEY]?: boolean }; export function setKillChildTrees(on: boolean): void { (globalThis as TreeKillGlobal)[TREE_KILL_KEY] = on; } export function killsChildTrees(): boolean { return (globalThis as TreeKillGlobal)[TREE_KILL_KEY] === true; } // The process groups this process leads right now (tree-kill mode), so a // second Ctrl-C / SIGTERM — which exits at once, without waiting for the // cancel to unwind — can still take them down first (`killChildTreesNow`). const LIVE_GROUPS_KEY = Symbol.for("archilyzer.runChild.liveGroups"); function liveGroups(): Set { const g = globalThis as { [LIVE_GROUPS_KEY]?: Set }; return (g[LIVE_GROUPS_KEY] ??= new Set()); } /** Signal every live child process group this process started (tree-kill mode). */ export function killChildTreesNow(sig: NodeJS.Signals = "SIGKILL"): void { for (const pgid of liveGroups()) { try { process.kill(-pgid, sig); } catch { // Already gone. } } } // Spawn a child process and stream its combined stdout/stderr into `onLog`, // resolving with the exit code. Used inside a managed function's `fn` to run a // sub-command as part of a larger job (build-then-deploy, multi-phase builds) // while keeping ONE job/stream/cancel. Mirrors streamCommand's execa usage // (all:true, buffer:false, reject:false) so behavior matches the top-level // managed command. On `signal` abort the child is SIGTERM'd, then SIGKILL'd // after a grace period — the same escalation registry.cancel() applies to the // top-level child. Resolves the abort case with the child's (likely non-zero) // exit code; the caller gates on `signal.aborted` to decide what to do next. export async function runChildIntoLog( onLog: (line: string) => void, signal: AbortSignal, opts: RunChildOpts, ): Promise { const prefix = opts.label ?? ""; const tree = killsChildTrees(); const child: ResultPromise = execa(opts.command, opts.args, { cwd: opts.cwd, env: opts.env, all: true, buffer: false, reject: false, ...(tree ? { detached: true } : {}), }); if (tree && child.pid) liveGroups().add(child.pid); // Signal the child — or, with tree-kill, its whole process group. const signalChild = (sig: NodeJS.Signals) => { if (tree && child.pid) { try { process.kill(-child.pid, sig); return; } catch { // The group is gone (or was never made): the child alone. } } child.kill(sig); }; // Line-buffer: execa chunks aren't line-aligned, and the managed-function // onLog appends a newline per call, so emitting raw chunks would inject // spurious newlines mid-line. Accumulate and emit only COMPLETE lines (without // the trailing "\n", which onLog re-adds), holding any partial until more data // arrives or the child exits. The optional label prefixes each line. let buf = ""; const flushLines = () => { let nl = buf.indexOf("\n"); while (nl !== -1) { onLog(prefix + buf.slice(0, nl)); buf = buf.slice(nl + 1); nl = buf.indexOf("\n"); } }; child.all?.on("data", (chunk: Buffer) => { buf += chunk.toString("utf8"); flushLines(); }); child.all?.on("error", () => {}); let killTimer: ReturnType | null = null; const onAbort = () => { signalChild("SIGTERM"); killTimer = setTimeout(() => { if (tree) signalChild("SIGKILL"); else if (child.killed === false) child.kill("SIGKILL"); }, 5_000); killTimer.unref?.(); }; if (signal.aborted) onAbort(); else signal.addEventListener("abort", onAbort, { once: true }); try { const result = await child; return result.exitCode ?? 1; } finally { if (buf.length > 0) { onLog(prefix + buf); buf = ""; } if (killTimer) clearTimeout(killTimer); // The leader is gone; whatever of its group a cancel left is not wanted. if (tree && signal.aborted) signalChild("SIGKILL"); if (tree && child.pid) liveGroups().delete(child.pid); signal.removeEventListener("abort", onAbort); } }