import { spawn } from "node:child_process"; import type { Readable } from "node:stream"; import { fail } from "./errors.ts"; export interface RenderProcess { readonly pid: number | undefined; readonly stdout: ReadableStream | undefined; readonly stderr: ReadableStream; /** Resolves after the process and its stdio handles have closed. */ readonly exited: Promise; kill(): void; } function outputStream(stream: Readable): ReadableStream { // Flowing reads also drain empty pipes reliably under Bun. An async iterator // can otherwise keep an already-exited media process waiting for EOF. let finished = false; return new ReadableStream({ start(controller) { stream.on("data", (chunk: unknown) => { if (finished) return; if (!(chunk instanceof Uint8Array)) { finished = true; controller.error(new Error("渲染程序返回了非二进制输出")); stream.destroy(); return; } controller.enqueue(chunk); if ((controller.desiredSize ?? 0) <= 0) stream.pause(); }); const close = () => { if (!finished) { finished = true; controller.close(); } }; stream.once("end", close); stream.once("close", close); stream.once("error", (error) => { if (!finished) { finished = true; controller.error(error); } }); }, pull() { stream.resume(); }, cancel() { finished = true; stream.destroy(); }, }); } /** Share Node's pipe ownership with Playwright, and drain handles before reusing a worker. */ export async function spawnRenderProcess(args: readonly string[], options: { stdout: "pipe" | "ignore"; stderr: "pipe"; signal?: AbortSignal; }): Promise { if (options.signal?.aborted) fail("RENDER_CANCELLED", "渲染已取消"); const executable = args[0]; if (executable === undefined) fail("INTERNAL_ERROR", "缺少渲染程序路径"); const child = spawn(executable, args.slice(1), { stdio: ["ignore", options.stdout, "pipe"], windowsHide: true, }); const kill = () => { if (child.exitCode === null && child.signalCode === null) { try { child.kill("SIGKILL"); } catch { /* already closed */ } } }; const started = new Promise((resolve, reject) => { child.once("spawn", resolve); child.once("error", reject); }); const exited = new Promise((resolve) => child.once("close", (code) => { options.signal?.removeEventListener("abort", kill); resolve(code ?? 128); })); // Attach flowing readers before awaiting spawn: a short-lived tool can close // an empty pipe before the next promise continuation runs. const stdout = child.stdout === null ? undefined : outputStream(child.stdout); const stderr = child.stderr === null ? undefined : outputStream(child.stderr); options.signal?.addEventListener("abort", kill, { once: true }); if (options.signal?.aborted) kill(); try { await started; } catch (error) { await Promise.allSettled([stdout?.cancel(), stderr?.cancel()]); await exited; throw error; } if (stderr === undefined) { kill(); await exited; fail("INTERNAL_ERROR", "渲染错误管道不可读"); } return { pid: child.pid, stdout, stderr, exited, kill, }; }