fix(cli): guard process snapshot capture (#3974)

Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com>
Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com>
This commit is contained in:
heygengenesis[bot]
2026-09-16 06:33:29 +00:00
committed by GitHub
co-authored by miguel.sierra
parent ef48485b91
commit 08d48ab753
29 changed files with 2098 additions and 230 deletions
+3
View File
@@ -71,6 +71,9 @@ npx hyperframes render -o output.mp4
npx hyperframes render -c ./my-composition.html -o output.mp4
```
Set `HYPERFRAMES_RENDER_DETACHED=1` when starting a render with `nohup` or
`disown` so it keeps running after the invoking shell exits.
### `publish`
Upload a project directory and get a hosted URL that keeps working after the CLI exits.
+13
View File
@@ -1,6 +1,7 @@
import { execFileSync } from "node:child_process";
import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
import { detectLinuxDistro, ffmpegInstallCommand } from "./linuxDeps.js";
import { runCancellableProcess } from "../utils/cancellableProcess.js";
export { FFMPEG_PATH_ENV, FFPROBE_PATH_ENV } from "@hyperframes/parsers/ff-binaries";
@@ -40,6 +41,18 @@ export function detectH264EncoderMode(ffmpegPath: string, gpuRequested: boolean)
return resolveH264EncoderMode(encoders, gpuRequested);
}
export async function detectH264EncoderModeForRender(
ffmpegPath: string,
gpuRequested: boolean,
signal: AbortSignal,
): Promise<H264EncoderMode> {
const result = await runCancellableProcess(ffmpegPath, ["-hide_banner", "-encoders"], {
signal,
timeoutMs: 5000,
});
return resolveH264EncoderMode(result.stdout, gpuRequested);
}
// `configuredMustExist`: the CLI surfaces an install hint when a binary is
// missing, so an env override pointing at a nonexistent file reports as
// not-found instead of being handed to spawn.
+15
View File
@@ -70,6 +70,7 @@ function installFsMocks({ existing, dirs, touchError, initialMtimeMs = 0 }: FsMo
// assert the lock dir doesn't leak after ensureBrowser resolves.
const paths = new Set(existing);
const mtimes = new Map([...existing].map((p) => [p, initialMtimeMs]));
const contents = new Map<string, string>();
vi.doMock("node:fs", () => ({
existsSync: (p: string) => paths.has(p),
readdirSync: (p: string) => {
@@ -94,6 +95,7 @@ function installFsMocks({ existing, dirs, touchError, initialMtimeMs = 0 }: FsMo
if (existingPath === p || existingPath.startsWith(p + sep)) {
paths.delete(existingPath);
mtimes.delete(existingPath);
contents.delete(existingPath);
}
}
},
@@ -114,6 +116,19 @@ function installFsMocks({ existing, dirs, touchError, initialMtimeMs = 0 }: FsMo
}
mtimes.set(p, mtime.getTime());
},
writeFileSync: (p: string, value: string) => {
paths.add(p);
contents.set(p, value);
},
readFileSync: (p: string) => {
const value = contents.get(p);
if (value === undefined) {
const err = new Error(`ENOENT: no such file or directory, read '${p}'`);
(err as NodeJS.ErrnoException).code = "ENOENT";
throw err;
}
return value;
},
}));
vi.doMock("node:os", () => ({
homedir: () => FAKE_HOME,
+71 -2
View File
@@ -1,10 +1,21 @@
// fallow-ignore-file code-duplication
import { execSync, spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readdirSync, rmSync, statSync, utimesSync } from "node:fs";
import { randomUUID } from "node:crypto";
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
statSync,
utimesSync,
writeFileSync,
} from "node:fs";
import { basename } from "node:path";
import { homedir } from "node:os";
import { join } from "node:path";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { runRenderSetupWorker } from "../utils/cancellableProcess.js";
type PuppeteerBrowsers = typeof import("@puppeteer/browsers");
@@ -57,6 +68,9 @@ const PUPPETEER_CACHE_DIR = join(homedir(), ".cache", "puppeteer", "chrome-headl
// doubles as a zero-dependency cross-process mutex — no lockfile library needed.
const INSTALL_LOCK_DIR = join(CACHE_ROOT_DIR, ".chrome.install.lock");
const INSTALL_RECLAIM_LOCK_DIR = join(CACHE_ROOT_DIR, ".chrome.install.reclaim.lock");
const INSTALL_LOCK_OWNER_FILE = join(INSTALL_LOCK_DIR, "owner");
const INSTALL_LOCK_OWNER_ENV = "HYPERFRAMES_BROWSER_LOCK_OWNER";
let ownedInstallLockToken: string | undefined;
const INSTALL_LOCK_TIMINGS = {
staleMs: 120_000,
pollMs: 200,
@@ -134,6 +148,7 @@ function touchInstallLock(): void {
}
}
// fallow-ignore-next-line complexity
export async function withInstallLock<T>(
fn: () => Promise<T>,
timings: InstallLockTimings = INSTALL_LOCK_TIMINGS,
@@ -156,6 +171,14 @@ export async function withInstallLock<T>(
continue;
}
if (tryAcquireDirLock(INSTALL_LOCK_DIR)) {
ownedInstallLockToken = process.env[INSTALL_LOCK_OWNER_ENV] ?? randomUUID();
try {
writeFileSync(INSTALL_LOCK_OWNER_FILE, ownedInstallLockToken);
} catch (error) {
ownedInstallLockToken = undefined;
rmSync(INSTALL_LOCK_DIR, { recursive: true, force: true });
throw error;
}
rmSync(INSTALL_RECLAIM_LOCK_DIR, { recursive: true, force: true });
break;
}
@@ -184,10 +207,25 @@ export async function withInstallLock<T>(
return await fn();
} finally {
clearInterval(heartbeat);
rmSync(INSTALL_LOCK_DIR, { recursive: true, force: true });
releaseOwnedBrowserInstallLock();
}
}
export function releaseOwnedBrowserInstallLock(): void {
if (!ownedInstallLockToken) return;
releaseBrowserInstallLock(ownedInstallLockToken);
}
function releaseBrowserInstallLock(ownerToken: string): void {
if (ownedInstallLockToken === ownerToken) ownedInstallLockToken = undefined;
try {
if (readFileSync(INSTALL_LOCK_OWNER_FILE, "utf8") !== ownerToken) return;
} catch {
return;
}
rmSync(INSTALL_LOCK_DIR, { recursive: true, force: true });
}
export type BrowserSource = "env" | "cache" | "system" | "download";
export interface BrowserResult {
@@ -211,6 +249,7 @@ export interface EnsureBrowserOptions {
// eligible renders outright; HF#2060). `HYPERFRAMES_BROWSER_PATH` still
// wins over this — an explicit override is still an explicit override.
preferManagedChrome?: boolean;
signal?: AbortSignal;
}
interface CacheLookupResult {
@@ -614,6 +653,14 @@ async function ensureLinuxArmBrowser(options?: EnsureBrowserOptions): Promise<Br
* (puppeteer-cache preference and system Chrome are both skipped).
*/
export async function ensureBrowser(options?: EnsureBrowserOptions): Promise<BrowserResult> {
if (options?.signal) return ensureBrowserInOwnedProcess(options);
return ensureBrowserInCurrentProcess(options);
}
// fallow-ignore-next-line complexity
async function ensureBrowserInCurrentProcess(
options?: EnsureBrowserOptions,
): Promise<BrowserResult> {
const fromEnv = findFromEnv();
if (fromEnv) return fromEnv;
@@ -665,6 +712,28 @@ export async function ensureBrowser(options?: EnsureBrowserOptions): Promise<Bro
});
}
async function ensureBrowserInOwnedProcess(options: EnsureBrowserOptions): Promise<BrowserResult> {
const signal = options.signal;
signal?.throwIfAborted();
const lockOwner = randomUUID();
try {
return await runRenderSetupWorker<BrowserResult>(
"browser",
{
force: options.force,
preferManagedChrome: options.preferManagedChrome,
},
{
signal,
maxBufferBytes: 4 * 1024 * 1024,
env: { [INSTALL_LOCK_OWNER_ENV]: lockOwner },
},
);
} finally {
releaseBrowserInstallLock(lockOwner);
}
}
/**
* True when `err` is a corrupt/truncated-archive extraction failure, as opposed
* to a network error or a genuine platform problem. A partially-downloaded or
+88 -36
View File
@@ -1,15 +1,28 @@
// fallow-ignore-file code-duplication
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { checkDisk, parseToolVersion, runEnvironmentChecks } from "./preflight.js";
import * as manager from "./manager.js";
import * as linuxDeps from "./linuxDeps.js";
const execFileSync = vi.hoisted(() => vi.fn());
const runProcess = vi.hoisted(() => vi.fn());
vi.mock("node:child_process", async (importOriginal) => ({
...(await importOriginal<typeof import("node:child_process")>()),
execFileSync,
}));
vi.mock("../utils/cancellableProcess.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../utils/cancellableProcess.js")>();
return {
...actual,
runCancellableProcess: (
command: string,
args: readonly string[],
options: { signal?: AbortSignal },
) =>
options.signal
? actual.runCancellableProcess(command, args, options)
: runProcess(command, args, options),
};
});
describe("runEnvironmentChecks", () => {
const originalFfmpegPath = process.env.HYPERFRAMES_FFMPEG_PATH;
@@ -18,7 +31,8 @@ describe("runEnvironmentChecks", () => {
beforeEach(() => {
process.env.HYPERFRAMES_FFMPEG_PATH = process.execPath;
process.env.HYPERFRAMES_FFPROBE_PATH = process.execPath;
execFileSync.mockReturnValue("ffmpeg version 7.1.1\n");
runProcess.mockReset();
runProcess.mockResolvedValue({ stdout: "ffmpeg version 7.1.1\n", stderr: "" });
});
afterEach(() => {
@@ -35,12 +49,52 @@ describe("runEnvironmentChecks", () => {
expect(result.outcomes.find((outcome) => outcome.name === "FFprobe")?.ok).toBe(true);
expect(result.ffmpegPath).toBe(process.execPath);
expect(result.ffprobePath).toBe(process.execPath);
expect(execFileSync).toHaveBeenCalledTimes(2);
for (const call of execFileSync.mock.calls) {
expect(call[2]).toEqual(expect.objectContaining({ windowsHide: true }));
}
expect(runProcess).toHaveBeenCalledTimes(2);
expect(runProcess).toHaveBeenCalledWith(
process.execPath,
["-version"],
expect.objectContaining({ timeoutMs: 5000 }),
);
});
it.skipIf(process.platform === "win32")(
"aborts a blocked render probe and waits for its process tree to exit",
async () => {
const testDir = mkdtempSync(join(tmpdir(), "hyperframes-preflight-cancel-"));
const probePath = join(testDir, "ffmpeg");
const pidPath = join(testDir, "probe.pid");
writeFileSync(probePath, `#!/bin/sh\necho $$ > "${pidPath}"\nsleep 30\n`);
chmodSync(probePath, 0o755);
process.env.HYPERFRAMES_FFMPEG_PATH = probePath;
const controller = new AbortController();
let probePid: number | undefined;
try {
const checks = runEnvironmentChecks({ signal: controller.signal });
const deadline = Date.now() + 2_000;
while (probePid === undefined && Date.now() < deadline) {
try {
probePid = Number(readFileSync(pidPath, "utf8").trim());
} catch {
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
expect(probePid).toBeGreaterThan(0);
controller.abort(new Error("render_cancelled_parent_exited"));
await expect(checks).rejects.toThrow("render_cancelled_parent_exited");
expect(() => process.kill(probePid!, 0)).toThrow();
} finally {
if (probePid) {
try {
process.kill(probePid, "SIGKILL");
} catch {}
}
rmSync(testDir, { recursive: true, force: true });
}
},
);
it("reports ffprobe as a render-blocking error when the explicit path is missing", async () => {
process.env.HYPERFRAMES_FFPROBE_PATH = "/missing/ffprobe.exe";
@@ -70,8 +124,9 @@ describe("runEnvironmentChecks", () => {
});
it("blocks rendering when the selected FFmpeg binary cannot launch", async () => {
execFileSync.mockImplementation((binaryPath: string) => {
if (binaryPath !== process.env.HYPERFRAMES_FFMPEG_PATH) return "ffprobe version 7.1.1\n";
runProcess.mockImplementation((binaryPath: string) => {
if (binaryPath !== process.env.HYPERFRAMES_FFMPEG_PATH)
return Promise.resolve({ stdout: "ffprobe version 7.1.1\n", stderr: "" });
throw Object.assign(new Error("Command failed with exit code 3221225781"), {
status: 3221225781,
});
@@ -130,9 +185,9 @@ describe("runEnvironmentChecks", () => {
{ failure: { code: "EACCES" }, detail: "EACCES" },
{ failure: { code: "ETIMEDOUT", signal: "SIGKILL" }, detail: "ETIMEDOUT" },
])("rejects an existing browser that fails --version: $detail", async ({ failure, detail }) => {
execFileSync.mockImplementation((_path, args) => {
runProcess.mockImplementation((_path, args) => {
if (args[0] === "--version") throw Object.assign(new Error("cannot execute"), failure);
return "ffmpeg version 7.1.1\n";
return Promise.resolve({ stdout: "ffmpeg version 7.1.1\n", stderr: "" });
});
const findBrowser = vi.spyOn(manager, "findBrowser").mockResolvedValue({
executablePath: process.execPath,
@@ -146,13 +201,12 @@ describe("runEnvironmentChecks", () => {
expect(chrome?.detail).toContain(detail);
expect(result.browser).toBeUndefined();
}
expect(execFileSync).toHaveBeenCalledWith(
expect(runProcess).toHaveBeenCalledWith(
process.execPath,
["--version"],
expect.objectContaining({
timeout: 5000,
killSignal: "SIGKILL",
windowsHide: true,
timeoutMs: 5000,
maxBufferBytes: 64 * 1024,
}),
);
} finally {
@@ -178,6 +232,8 @@ describe("runEnvironmentChecks — Chrome shared libraries (Linux/WSL)", () => {
beforeEach(() => {
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
runProcess.mockReset();
runProcess.mockResolvedValue({ stdout: "", stderr: "" });
});
afterEach(() => {
@@ -187,14 +243,16 @@ describe("runEnvironmentChecks — Chrome shared libraries (Linux/WSL)", () => {
it("downgrades a found Chrome to a render-blocking error when libs are missing", async () => {
vi.spyOn(manager, "findBrowser").mockResolvedValue({
executablePath: "/root/.cache/hyperframes/chrome-headless-shell",
executablePath: process.execPath,
source: "cache",
});
vi.spyOn(linuxDeps, "probeChromeSharedLibs").mockReturnValue({
ok: false,
missing: ["libnss3.so", "libatk-1.0.so.0"],
probeUnavailable: false,
});
runProcess.mockImplementation((_command, args) =>
Promise.resolve({
stdout:
args[0] === "--version" ? "" : "libnss3.so => not found\nlibatk-1.0.so.0 => not found\n",
stderr: "",
}),
);
vi.spyOn(linuxDeps, "detectLinuxDistro").mockReturnValue({
family: "debian",
id: "ubuntu",
@@ -220,29 +278,23 @@ describe("runEnvironmentChecks — Chrome shared libraries (Linux/WSL)", () => {
it("keeps Chrome ok when the shared-lib probe passes", async () => {
vi.spyOn(manager, "findBrowser").mockResolvedValue({
executablePath: "/usr/bin/chromium",
executablePath: process.execPath,
source: "system",
});
vi.spyOn(linuxDeps, "probeChromeSharedLibs").mockReturnValue({
ok: true,
missing: [],
probeUnavailable: false,
});
const result = await runEnvironmentChecks({ includeBrowser: true });
expect(result.outcomes.find((o) => o.name === "Chrome")).toMatchObject({ ok: true });
expect(result.browser?.executablePath).toBe("/usr/bin/chromium");
expect(result.browser?.executablePath).toBe(process.execPath);
});
it("keeps Chrome ok when the probe is inconclusive (no ldd)", async () => {
vi.spyOn(manager, "findBrowser").mockResolvedValue({
executablePath: "/usr/bin/chromium",
executablePath: process.execPath,
source: "system",
});
vi.spyOn(linuxDeps, "probeChromeSharedLibs").mockReturnValue({
ok: false,
missing: [],
probeUnavailable: true,
runProcess.mockImplementation((_command, args) => {
if (args[0] === "--version") return Promise.resolve({ stdout: "", stderr: "" });
throw new Error("ldd unavailable");
});
const result = await runEnvironmentChecks({ includeBrowser: true });
+95 -55
View File
@@ -1,21 +1,16 @@
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { platform } from "node:os";
import { findBrowser, type BrowserResult } from "./manager.js";
import {
FFMPEG_PATH_ENV,
FFPROBE_PATH_ENV,
findFFmpeg,
findFFprobe,
getFFmpegInstallHint,
} from "./ffmpeg.js";
import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
import { ensureBrowser, findBrowser, type BrowserResult } from "./manager.js";
import { FFMPEG_PATH_ENV, FFPROBE_PATH_ENV, getFFmpegInstallHint } from "./ffmpeg.js";
import {
chromeDepsInstallCommand,
detectLinuxDistro,
distroLabel,
probeChromeSharedLibs,
parseLddMissingLibs,
} from "./linuxDeps.js";
import { getFreeDiskMb } from "../telemetry/system.js";
import { runCancellableProcess } from "../utils/cancellableProcess.js";
export type EnvironmentCheckLevel = "ok" | "warn" | "error";
@@ -43,6 +38,7 @@ export interface EnvironmentCheckOptions {
includeBrowser?: boolean;
includeDisk?: boolean;
includeWindowsUnc?: boolean;
signal?: AbortSignal;
}
export function parseToolVersion(raw: string): string {
@@ -58,17 +54,23 @@ function configuredMissingDetail(envName: string): string | undefined {
type ToolVersionResult = { ok: true; detail: string } | { ok: false; detail: string };
function readToolVersion(binaryPath: string): ToolVersionResult {
// fallow-ignore-next-line complexity
async function readToolVersion(
binaryPath: string,
signal?: AbortSignal,
): Promise<ToolVersionResult> {
try {
const raw =
execFileSync(binaryPath, ["-version"], {
encoding: "utf-8",
timeout: 5000,
windowsHide: true,
}).split("\n")[0] ?? "";
const output = (
await runCancellableProcess(binaryPath, ["-version"], {
signal,
timeoutMs: 5000,
})
).stdout;
const raw = output.split("\n")[0] ?? "";
const version = parseToolVersion(raw);
return { ok: true, detail: version ? `${version} at ${binaryPath}` : binaryPath };
} catch (error) {
if (signal?.aborted) signal.throwIfAborted();
const status =
typeof error === "object" && error !== null && "status" in error ? error.status : undefined;
const exitDetail = typeof status === "number" ? ` (exit code ${status})` : "";
@@ -79,7 +81,7 @@ function readToolVersion(binaryPath: string): ToolVersionResult {
}
}
function checkFFmpeg(): EnvironmentCheckOutcome {
async function checkFFmpeg(signal?: AbortSignal): Promise<EnvironmentCheckOutcome> {
const missingConfigured = configuredMissingDetail(FFMPEG_PATH_ENV);
if (missingConfigured) {
return {
@@ -92,9 +94,9 @@ function checkFFmpeg(): EnvironmentCheckOutcome {
};
}
const path = findFFmpeg();
const path = findFfBinary("ffmpeg", { configuredMustExist: true });
if (path) {
const version = readToolVersion(path);
const version = await readToolVersion(path, signal);
if (!version.ok) {
return {
name: "FFmpeg",
@@ -121,7 +123,7 @@ function checkFFmpeg(): EnvironmentCheckOutcome {
};
}
function checkFFprobe(): EnvironmentCheckOutcome {
async function checkFFprobe(signal?: AbortSignal): Promise<EnvironmentCheckOutcome> {
const missingConfigured = configuredMissingDetail(FFPROBE_PATH_ENV);
if (missingConfigured) {
return {
@@ -134,9 +136,9 @@ function checkFFprobe(): EnvironmentCheckOutcome {
};
}
const path = findFFprobe();
const path = findFfBinary("ffprobe", { configuredMustExist: true });
if (path) {
const version = readToolVersion(path);
const version = await readToolVersion(path, signal);
return { name: "FFprobe", ok: true, level: "ok", detail: version.detail, path };
}
@@ -160,12 +162,37 @@ function checkFFprobe(): EnvironmentCheckOutcome {
* `Failed to launch the browser process` mid-render. No-op off Linux and when
* `ldd` can't run (probe inconclusive).
*/
function chromeSharedLibOutcome(
// fallow-ignore-next-line complexity
async function chromeSharedLibOutcome(
executablePath: string,
found: EnvironmentCheckOutcome,
): EnvironmentCheckOutcome {
signal?: AbortSignal,
): Promise<EnvironmentCheckOutcome> {
if (process.platform !== "linux") return found;
const probe = probeChromeSharedLibs(executablePath);
let probe;
if (!existsSync(executablePath)) {
probe = { ok: true, missing: [], probeUnavailable: true };
} else {
try {
const result = await runCancellableProcess("ldd", [executablePath], {
signal,
timeoutMs: 5000,
});
probe = parseLddMissingLibs(result.stdout);
} catch (error) {
if (signal?.aborted) signal.throwIfAborted();
const stdout =
typeof error === "object" && error !== null && "stdout" in error
? String(error.stdout ?? "")
: "";
const killed =
typeof error === "object" && error !== null && "killed" in error && error.killed === true;
probe =
stdout && !killed
? parseLddMissingLibs(stdout)
: { ok: true, missing: [], probeUnavailable: true };
}
}
if (probe.probeUnavailable || probe.ok) return found;
const distro = detectLinuxDistro();
@@ -194,23 +221,22 @@ function chromeLaunchFailureDetails(error: unknown): string {
.join(", ");
}
function chromeLaunchOutcome(
async function chromeLaunchOutcome(
executablePath: string,
found: EnvironmentCheckOutcome,
): EnvironmentCheckOutcome {
const libraries = chromeSharedLibOutcome(executablePath, found);
signal?: AbortSignal,
): Promise<EnvironmentCheckOutcome> {
const libraries = await chromeSharedLibOutcome(executablePath, found, signal);
if (!libraries.ok) return libraries;
try {
execFileSync(executablePath, ["--version"], {
encoding: "utf-8",
stdio: ["ignore", "pipe", "pipe"],
timeout: 5000,
killSignal: "SIGKILL",
maxBuffer: 64 * 1024,
windowsHide: true,
await runCancellableProcess(executablePath, ["--version"], {
signal,
timeoutMs: 5000,
maxBufferBytes: 64 * 1024,
});
return found;
} catch (error) {
if (signal?.aborted) signal.throwIfAborted();
const details = chromeLaunchFailureDetails(error);
return {
name: "Chrome",
@@ -226,16 +252,23 @@ function chromeLaunchOutcome(
}
}
async function checkChrome(browserPath?: string): Promise<EnvironmentCheckOutcome> {
async function checkChrome(
browserPath?: string,
signal?: AbortSignal,
): Promise<EnvironmentCheckOutcome> {
if (browserPath) {
if (existsSync(browserPath)) {
return chromeLaunchOutcome(browserPath, {
name: "Chrome",
ok: true,
level: "ok",
detail: `explicit: ${browserPath}`,
path: browserPath,
});
return chromeLaunchOutcome(
browserPath,
{
name: "Chrome",
ok: true,
level: "ok",
detail: `explicit: ${browserPath}`,
path: browserPath,
},
signal,
);
}
return {
name: "Chrome",
@@ -254,18 +287,25 @@ async function checkChrome(browserPath?: string): Promise<EnvironmentCheckOutcom
// (notably `doctor`, which is documented to exit 0 even when checks fail).
let info: Awaited<ReturnType<typeof findBrowser>>;
try {
info = await findBrowser();
info = signal
? await ensureBrowser({ preferManagedChrome: true, signal })
: await findBrowser();
} catch {
if (signal?.aborted) signal.throwIfAborted();
info = undefined;
}
if (info) {
return chromeLaunchOutcome(info.executablePath, {
name: "Chrome",
ok: true,
level: "ok",
detail: `${info.source}: ${info.executablePath}`,
path: info.executablePath,
});
return chromeLaunchOutcome(
info.executablePath,
{
name: "Chrome",
ok: true,
level: "ok",
detail: `${info.source}: ${info.executablePath}`,
path: info.executablePath,
},
signal,
);
}
return {
@@ -318,15 +358,15 @@ export async function runEnvironmentChecks(
): Promise<EnvironmentCheckResult> {
const outcomes: EnvironmentCheckOutcome[] = [];
const ffmpeg = checkFFmpeg();
const ffmpeg = await checkFFmpeg(options.signal);
outcomes.push(ffmpeg);
const ffprobe = checkFFprobe();
const ffprobe = await checkFFprobe(options.signal);
outcomes.push(ffprobe);
let browser: BrowserResult | undefined;
if (options.includeBrowser) {
const chrome = await checkChrome(options.browserPath);
const chrome = await checkChrome(options.browserPath, options.signal);
outcomes.push(chrome);
if (chrome.ok && chrome.path) {
browser = {
+30 -23
View File
@@ -15,7 +15,8 @@ import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { scanActiveServers, type ActiveServer } from "../server/portUtils.js";
import type { BrowserGpuMode } from "../browser/gpuPolicy.js";
import { isProcessDescendant, killProcessTree, processIdentity } from "../utils/orphanCleanup.js";
import { isProcessDescendant, processIdentity } from "../utils/orphanCleanup.js";
import { terminateProcessTree } from "../utils/processTree.js";
export interface PreviewSession {
pid: number;
@@ -42,7 +43,7 @@ interface LifecycleDependencies {
scan?: (startPort?: number) => Promise<ActiveServer[]>;
spawn?: SpawnPreview;
sleep?: (ms: number) => Promise<void>;
kill?: (pid: number) => void;
kill?: (pid: number) => void | Promise<void>;
isDescendant?: (childPid: number, ancestorPid: number) => boolean;
identity?: (pid: number) => string | null;
isSignalable?: (pid: number) => boolean;
@@ -188,15 +189,8 @@ function sameProjectPorts(servers: ActiveServer[], projectDir: string): Set<numb
);
}
function stopProcess(pid: number): void {
killProcessTree(pid);
if (process.platform === "win32") {
try {
process.kill(pid, "SIGTERM");
} catch {
// Process already exited.
}
}
async function stopProcess(pid: number): Promise<void> {
await terminateProcessTree(pid);
}
const delay = (ms: number): Promise<void> => new Promise((done) => setTimeout(done, ms));
@@ -426,6 +420,19 @@ function savedOwnedPreview(
return matchingServer(savedPortServers, projectDir);
}
async function readPreviewLifecycleState(
projectDir: string,
startPort: number,
dependencies: LifecycleDependencies,
) {
const scan = dependencies.scan ?? scanActiveServers;
const stateHome = dependencies.stateHome ?? defaultStateHome();
const saved = readPreviewSession(projectDir, stateHome);
const scanStart = saved?.port ?? startPort;
const scanned = await scan(scanStart);
return { scan, stateHome, saved, scanStart, scanned };
}
export async function startBackgroundPreview(
projectDir: string,
startPort: number,
@@ -434,14 +441,14 @@ export async function startBackgroundPreview(
| { type: "reused"; port: number; pid: number | null; logPath: string | null }
| { type: "started"; port: number; pid: number; logPath: string }
> {
const scan = dependencies.scan ?? scanActiveServers;
const stateHome = dependencies.stateHome ?? defaultStateHome();
const saved = readPreviewSession(projectDir, stateHome);
const { scan, stateHome, saved, scanned } = await readPreviewLifecycleState(
projectDir,
startPort,
dependencies,
);
// Always inspect a saved custom port first. `--force-new --port <new>` must
// replace that owned server before recording the replacement, otherwise the
// single per-project ownership record would orphan the old listener.
const scanStart = saved?.port ?? startPort;
const scanned = await scan(scanStart);
const requestedExisting = matchingServer(scanned, projectDir, dependencies.browserGpuMode);
const ownedExisting = savedOwnedPreview(scanned, saved, projectDir);
// A saved managed preview is the authoritative same-project instance. An
@@ -498,7 +505,7 @@ export async function startBackgroundPreview(
await sleep(200);
}
(dependencies.kill ?? stopProcess)(pid);
await (dependencies.kill ?? stopProcess)(pid);
throw new Error(`background preview did not become ready; see ${logPath}`);
}
@@ -507,11 +514,11 @@ export async function stopBackgroundPreview(
startPort: number,
dependencies: LifecycleDependencies = {},
): Promise<boolean> {
const scan = dependencies.scan ?? scanActiveServers;
const stateHome = dependencies.stateHome ?? defaultStateHome();
const saved = readPreviewSession(projectDir, stateHome);
const scanStart = saved?.port ?? startPort;
const scanned = await scan(scanStart);
const { scan, stateHome, saved, scanStart, scanned } = await readPreviewLifecycleState(
projectDir,
startPort,
dependencies,
);
const server = saved
? matchingServerAtPort(scanned, projectDir, saved.port)
: matchingServer(scanned, projectDir);
@@ -528,7 +535,7 @@ export async function stopBackgroundPreview(
}
const kill = dependencies.kill ?? stopProcess;
kill(ownedStopTargetPid(saved, pid, dependencies));
await kill(ownedStopTargetPid(saved, pid, dependencies));
const sleep = dependencies.sleep ?? delay;
for (let attempt = 0; attempt < 25; attempt++) {
+236 -5
View File
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawn } from "node:child_process";
const producerState = vi.hoisted(() => ({
createdJobs: [] as Array<Record<string, unknown>>,
@@ -10,7 +11,11 @@ const producerState = vi.hoisted(() => ({
// Overridable per-test hook so the DE-parallel-router-trial tests can
// mutate the job (perfSummary/errorDetails) or throw, without perturbing
// every other test in this file that expects a plain no-op resolve.
executeImpl: async (_job: Record<string, unknown>): Promise<void> => undefined,
executeImpl: async (
_job: Record<string, unknown>,
_abortSignal?: AbortSignal,
_assertRenderActive?: () => void,
): Promise<void> => undefined,
}));
// Defaults to "trial already fired" so the pre-existing renderLocal tests
@@ -51,6 +56,7 @@ const trackingState = vi.hoisted(() => ({
}));
const preflightState = vi.hoisted(() => ({
onRun: undefined as (() => void) | undefined,
result: {
outcomes: [
{ name: "FFmpeg", ok: true, level: "ok", detail: "/usr/bin/ffmpeg", path: "/usr/bin/ffmpeg" },
@@ -83,6 +89,11 @@ const ffmpegEncoderState = vi.hoisted(() => ({
const orphanCleanupState = vi.hoisted(() => ({
calls: 0,
killed: 0,
parentPid: (_pid: number): number | null => null,
identity: (_pid: number): string | null => null,
}));
const browserManagerState = vi.hoisted(() => ({
onEnsure: undefined as ((signal?: AbortSignal) => Promise<void> | void) | undefined,
}));
vi.mock("../utils/producer.js", () => ({
@@ -114,7 +125,16 @@ vi.mock("../utils/producer.js", () => ({
producerState.createdJobs.push(config);
return { config, progress: 100, outcome: "completed", warnings: [] };
}),
executeRenderJob: vi.fn(async (job: Record<string, unknown>) => producerState.executeImpl(job)),
executeRenderJob: vi.fn(
async (
job: Record<string, unknown>,
_projectDir: string,
_outputPath: string,
_onProgress: unknown,
abortSignal?: AbortSignal,
assertRenderActive?: () => void,
) => producerState.executeImpl(job, abortSignal, assertRenderActive),
),
})),
}));
@@ -185,7 +205,7 @@ vi.mock("../browser/ffmpeg.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../browser/ffmpeg.js")>();
return {
...actual,
detectH264EncoderMode: vi.fn(() => {
detectH264EncoderModeForRender: vi.fn(async () => {
if (ffmpegEncoderState.error) throw ffmpegEncoderState.error;
if (ffmpegEncoderState.encoders !== null)
return actual.resolveH264EncoderMode(ffmpegEncoderState.encoders, false);
@@ -197,7 +217,10 @@ vi.mock("../browser/ffmpeg.js", async (importOriginal) => {
});
vi.mock("../browser/preflight.js", () => ({
runEnvironmentChecks: vi.fn(async () => preflightState.result),
runEnvironmentChecks: vi.fn(async () => {
preflightState.onRun?.();
return preflightState.result;
}),
}));
// The "render command explicit composition" test below drives the real
@@ -208,7 +231,10 @@ vi.mock("../browser/preflight.js", () => ({
// the shared `~/.cache/hyperframes/chrome`, racing other packages' browser
// tests in CI.
vi.mock("../browser/manager.js", () => ({
ensureBrowser: vi.fn(async () => ({ executablePath: "/mock/chrome", source: "cache" })),
ensureBrowser: vi.fn(async (options?: { signal?: AbortSignal }) => {
await browserManagerState.onEnsure?.(options?.signal);
return { executablePath: "/mock/chrome", source: "cache" };
}),
}));
vi.mock("../utils/orphanCleanup.js", () => ({
@@ -216,6 +242,18 @@ vi.mock("../utils/orphanCleanup.js", () => ({
orphanCleanupState.calls += 1;
return orphanCleanupState.killed;
}),
killOrphanedProcessesForRender: vi.fn(async () => {
orphanCleanupState.calls += 1;
return orphanCleanupState.killed;
}),
processIdentity: vi.fn((pid: number) => orphanCleanupState.identity(pid)),
processAncestorSnapshot: vi.fn((pid: number) => {
const parent = orphanCleanupState.parentPid(pid);
if (parent === null) return [];
const identity = orphanCleanupState.identity(parent);
return identity === null ? [] : [{ pid: parent, identity }];
}),
processParentPid: vi.fn((pid: number) => orphanCleanupState.parentPid(pid)),
}));
// Collect the heavy render module once, after Vitest has hoisted the mocks
@@ -292,6 +330,7 @@ describe("renderLocal browser GPU config", () => {
producerState.createdJobs = [];
producerState.resolveConfigCalls = [];
producerState.executeImpl = async () => undefined;
preflightState.onRun = undefined;
configState.disk = { telemetryEnabled: true, deParallelRouterTrialFired: true };
configState.cache = null;
configState.failWrites = 0;
@@ -304,6 +343,9 @@ describe("renderLocal browser GPU config", () => {
ffmpegEncoderState.encoders = null;
orphanCleanupState.calls = 0;
orphanCleanupState.killed = 0;
orphanCleanupState.parentPid = () => null;
orphanCleanupState.identity = () => null;
browserManagerState.onEnsure = undefined;
resetTrialState();
savedEnv.clear();
savedEnv.set("HYPERFRAMES_FFMPEG_PATH", process.env.HYPERFRAMES_FFMPEG_PATH);
@@ -332,6 +374,123 @@ describe("renderLocal browser GPU config", () => {
expect(orphanCleanupState.calls).toBe(1);
});
it("owns cancellation before preflight so an early wrapper loss cannot start rendering", async () => {
const priorListeners = new Set(process.listeners("SIGTERM"));
let rendererStarted = false;
preflightState.onRun = () => {
const cancellationListener = process
.listeners("SIGTERM")
.find((listener) => !priorListeners.has(listener));
expect(cancellationListener).toBeDefined();
cancellationListener?.("SIGTERM");
};
producerState.executeImpl = async (_job, abortSignal) => {
if (!abortSignal?.aborted) rendererStarted = true;
throw abortSignal?.reason;
};
await expect(
renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: { num: 30, den: 1 },
quality: "standard",
format: "mp4",
gpu: false,
browserGpuMode: "software",
hdrMode: "auto",
quiet: true,
throwOnError: true,
}),
).rejects.toThrow("render_cancelled_by_sigterm");
expect(rendererStarted).toBe(false);
expect(
process.listeners("SIGTERM").filter((listener) => !priorListeners.has(listener)),
).toEqual([]);
});
it.each(["SIGINT", "SIGTERM", "SIGHUP"] as const)(
"wires %s to the producer AbortSignal and removes render-scoped listeners",
async (signal) => {
let markExecutionStarted!: () => void;
const executionStarted = new Promise<void>((resolve) => {
markExecutionStarted = resolve;
});
let producerSignal: AbortSignal | undefined;
producerState.executeImpl = async (_job, abortSignal) => {
producerSignal = abortSignal;
markExecutionStarted();
await new Promise<void>((_resolve, reject) => {
abortSignal?.addEventListener("abort", () => reject(abortSignal.reason), { once: true });
});
};
const priorListeners = new Set(process.listeners(signal));
const render = renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: { num: 30, den: 1 },
quality: "standard",
format: "mp4",
gpu: false,
browserGpuMode: "software",
hdrMode: "auto",
quiet: true,
throwOnError: true,
});
await executionStarted;
const cancellationListener = process
.listeners(signal)
.find((listener) => !priorListeners.has(listener));
expect(cancellationListener).toBeDefined();
cancellationListener?.(signal);
expect(process.listeners(signal)).toContain(cancellationListener);
cancellationListener?.(signal);
await expect(render).rejects.toThrow(`render_cancelled_by_${signal.toLowerCase()}`);
expect(producerSignal).toBeInstanceOf(AbortSignal);
expect(producerSignal?.aborted).toBe(true);
expect(process.listeners(signal).filter((listener) => !priorListeners.has(listener))).toEqual(
[],
);
},
);
it("checks ancestors again at producer artifact finalization", async () => {
const controller = new AbortController();
let parentExited = false;
const cancellation = {
signal: controller.signal,
checkAncestors: vi.fn(() => {
if (parentExited) controller.abort(new Error("render_cancelled_parent_exited"));
}),
dispose: vi.fn(),
};
producerState.executeImpl = async (_job, abortSignal, assertRenderActive) => {
parentExited = true;
assertRenderActive?.();
abortSignal?.throwIfAborted();
};
await expect(
renderLocal(
"/tmp/project",
"/tmp/out.mp4",
{
fps: { num: 30, den: 1 },
quality: "standard",
format: "mp4",
gpu: false,
browserGpuMode: "software",
hdrMode: "auto",
quiet: true,
throwOnError: true,
},
cancellation,
),
).rejects.toThrow("render_cancelled_parent_exited");
expect(cancellation.checkAncestors).toHaveBeenLastCalledWith();
});
afterEach(() => {
for (const [key, value] of savedEnv) {
if (value === undefined) {
@@ -1558,6 +1717,78 @@ describe("render fps arg definition", () => {
});
describe("render command explicit composition", () => {
it("cancels before rendering when the wrapper exits during browser preparation", async () => {
const projectDir = mkdtempSync(join(tmpdir(), "hf-render-prep-cancel-"));
writeFileSync(
join(projectDir, "index.html"),
'<!doctype html><html><body><div data-composition-id="main" data-width="1920" data-height="1080" data-no-timeline></div></body></html>',
);
const wrapper = spawn(process.execPath, ["--eval", "setInterval(() => undefined, 1000)"], {
stdio: "ignore",
});
const jobsBefore = producerState.createdJobs.length;
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
let releasePreparation!: () => void;
const preparationBlocked = new Promise<void>((resolve) => {
releasePreparation = resolve;
});
orphanCleanupState.parentPid = (pid) => (pid === process.pid ? wrapper.pid! : null);
orphanCleanupState.identity = (pid) => (pid === wrapper.pid ? "wrapper-birth-token" : null);
browserManagerState.onEnsure = async (signal) => {
const exited = new Promise<void>((resolve) => wrapper.once("exit", () => resolve()));
wrapper.kill("SIGTERM");
await exited;
await new Promise<void>((resolve, reject) => {
const onAbort = () => reject(signal?.reason);
signal?.addEventListener("abort", onAbort, { once: true });
if (signal?.aborted) onAbort();
void preparationBlocked.then(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
});
});
};
try {
const command = renderModule.default.run?.({
args: {
dir: projectDir,
output: join(projectDir, "out.mp4"),
quiet: true,
quality: "standard",
format: "mp4",
},
} as never);
await expect(
Promise.race([
command,
new Promise<never>((_resolve, reject) =>
setTimeout(
() => reject(new Error("Setup cancellation timed out")),
process.platform === "linux" ? 1_000 : 3_000,
),
),
]),
).rejects.toThrow("render_cancelled_parent_exited");
expect(producerState.createdJobs).toHaveLength(jobsBefore);
expect(
stderr.mock.calls.filter(([line]) =>
String(line).includes("Render cancelled: render_cancelled_parent_exited"),
),
).toEqual([["Render cancelled: render_cancelled_parent_exited\n"]]);
} finally {
releasePreparation();
const { consumeCommandResult } = await import("../utils/commandResult.js");
consumeCommandResult();
browserManagerState.onEnsure = undefined;
orphanCleanupState.parentPid = () => null;
orphanCleanupState.identity = () => null;
stderr.mockRestore();
wrapper.kill("SIGKILL");
rmSync(projectDir, { recursive: true, force: true });
}
});
it("renders an explicit composition from a project with no index.html", async () => {
const projectDir = mkdtempSync(join(tmpdir(), "hf-render-explicit-"));
const outputPath = join(projectDir, "out.mp4");
+77 -16
View File
@@ -85,14 +85,16 @@ import { buildDockerRunArgs, resolveDockerPlatform } from "../utils/dockerRunArg
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { runEnvironmentChecks } from "../browser/preflight.js";
import {
detectH264EncoderMode,
detectH264EncoderModeForRender,
getFFmpegInstallHint,
H264EncoderUnavailableError,
type H264EncoderMode,
} from "../browser/ffmpeg.js";
import { chromeLaunchRemediation } from "../browser/linuxDeps.js";
import { macosOldChromeCrashRemediation } from "../browser/macosOldChromeCrash.js";
import { windowsChromeCrashRemediation } from "../browser/windowsCrash.js";
import { killOrphanedProcesses } from "../utils/orphanCleanup.js";
import { killOrphanedProcessesForRender } from "../utils/orphanCleanup.js";
import { createRenderCancellationScope } from "../utils/renderCancellation.js";
import {
markRenderSucceeded,
runPostRenderStep,
@@ -368,15 +370,32 @@ export default defineCommand({
// Keep the transport adapter thin: each phase has one ownership boundary.
async run({ args }) {
const plan = createRenderPlan(args);
// Teach the project its owning skill from an explicit --skill so every
// later flag-less render (re-render, `npm run render`, batch) inherits it.
seedProjectAuthoringSkill(plan.project.dir, args.skill);
await presentRenderPlan(plan);
await executeRenderPlan(plan, {
renderDocker,
renderLocal,
checkResolution: checkRenderResolutionPreflight,
});
const cancellation = plan.useDocker ? undefined : createRenderCancellationScope();
try {
// Teach the project its owning skill from an explicit --skill so every
// later flag-less render (re-render, `npm run render`, batch) inherits it.
seedProjectAuthoringSkill(plan.project.dir, args.skill);
await presentRenderPlan(plan);
await executeRenderPlan(
plan,
{
renderDocker,
renderLocal,
checkResolution: checkRenderResolutionPreflight,
},
cancellation,
);
} catch (error) {
if (cancellation?.signal.aborted) {
const reason = cancellation.signal.reason;
const message = reason instanceof Error ? reason.message : String(reason);
process.stderr.write(`Render cancelled: ${message}\n`);
requestCliExit(1);
}
throw error;
} finally {
cancellation?.dispose();
}
},
});
@@ -802,13 +821,37 @@ async function renderDocker(
return { renderTimeMs: elapsed };
}
// fallow-ignore-next-line complexity
export async function renderLocal(
projectDir: string,
outputPath: string,
options: RenderOptions,
commandCancellation?: ReturnType<typeof createRenderCancellationScope>,
): Promise<SingleRenderResult> {
const recoveredOrphanTrees = killOrphanedProcesses();
const cancellation = commandCancellation ?? createRenderCancellationScope();
try {
return await executeLocalRender(projectDir, outputPath, options, cancellation);
} finally {
if (!commandCancellation) cancellation.dispose();
}
}
// fallow-ignore-next-line complexity
async function executeLocalRender(
projectDir: string,
outputPath: string,
options: RenderOptions,
cancellation: ReturnType<typeof createRenderCancellationScope>,
): Promise<SingleRenderResult> {
cancellation.checkAncestors();
cancellation.signal.throwIfAborted();
let recoveredOrphanTrees = 0;
try {
recoveredOrphanTrees = await killOrphanedProcessesForRender(cancellation.signal);
} catch {
if (cancellation.signal.aborted) cancellation.signal.throwIfAborted();
}
cancellation.checkAncestors();
cancellation.signal.throwIfAborted();
if (recoveredOrphanTrees > 0 && !options.quiet) {
console.warn(
c.warn(
@@ -824,7 +867,10 @@ export async function renderLocal(
includeBrowser: true,
includeDisk: true,
includeWindowsUnc: true,
signal: cancellation.signal,
});
cancellation.checkAncestors();
cancellation.signal.throwIfAborted();
const failedChecks = preflight.outcomes.filter((outcome) => !outcome.ok);
if (failedChecks.length > 0) {
for (const check of failedChecks) {
@@ -848,10 +894,15 @@ export async function renderLocal(
}
if (!options.gpu && options.format === "mp4" && preflight.ffmpegPath) {
let encoderMode: ReturnType<typeof detectH264EncoderMode> = "software";
let encoderMode: H264EncoderMode = "software";
try {
encoderMode = detectH264EncoderMode(preflight.ffmpegPath, false);
encoderMode = await detectH264EncoderModeForRender(
preflight.ffmpegPath,
false,
cancellation.signal,
);
} catch (error) {
if (cancellation.signal.aborted) cancellation.signal.throwIfAborted();
// HDR MP4 uses HEVC; auto mode cannot resolve the codec until sources
// have been inspected. Only forced SDR is definitely H.264 here.
if (error instanceof H264EncoderUnavailableError && options.hdrMode === "force-sdr") {
@@ -877,6 +928,8 @@ export async function renderLocal(
}
}
cancellation.checkAncestors();
cancellation.signal.throwIfAborted();
const producer = await loadProducer();
const deParallelRouterActive =
options.manageDeParallelRouterBreaker === true
@@ -938,7 +991,15 @@ export async function renderLocal(
};
try {
await producer.executeRenderJob(job, projectDir, outputPath, onProgress);
cancellation.checkAncestors();
await producer.executeRenderJob(
job,
projectDir,
outputPath,
onProgress,
cancellation.signal,
cancellation.checkAncestors,
);
} catch (error: unknown) {
maybeConsumeDeParallelRouterTrial(deParallelRouterActive, job, options.quiet);
// The render container sets `ENV CONTAINER=true`; suggesting `--docker`
+54 -8
View File
@@ -1,3 +1,4 @@
// fallow-ignore-file code-duplication
import { mkdirSync, readFileSync } from "node:fs";
import type { CanvasResolution, OutputResolutionIssueKind } from "@hyperframes/core";
import { c } from "../../ui/colors.js";
@@ -19,11 +20,14 @@ import {
import { trackRenderPreflightRejected } from "../../telemetry/events.js";
import { applyRenderEnvironment, renderOutputDirectory, type RenderPlan } from "./plan.js";
import type { RenderOptions, SingleRenderResult } from "../render.js";
import type { RenderCancellationScope } from "../../utils/renderCancellation.js";
import { runRenderSetupWorker } from "../../utils/cancellableProcess.js";
type RenderExecutor = (
projectDir: string,
outputPath: string,
options: RenderOptions,
cancellation?: RenderCancellationScope,
) => Promise<SingleRenderResult>;
type ResolutionPreflight = (
@@ -60,7 +64,9 @@ function renderLintShouldAbort(
export async function executeRenderPlan(
plan: RenderPlan,
dependencies: RenderExecutionDependencies,
cancellation?: RenderCancellationScope,
): Promise<void> {
assertRenderActive(cancellation);
applyRenderEnvironment(plan);
if (!plan.batchPath) mkdirSync(renderOutputDirectory(plan), { recursive: true });
@@ -82,12 +88,24 @@ export async function executeRenderPlan(
}
}
const browserPath = plan.useDocker ? undefined : await ensureRenderBrowser(plan);
await runRenderLint(plan);
const browserPath = plan.useDocker
? undefined
: await ensureRenderBrowser(plan, cancellation?.signal);
assertRenderActive(cancellation);
await runRenderLint(plan, lintProject, cancellation?.signal);
assertRenderActive(cancellation);
await runResolutionPreflight(plan, dependencies.checkResolution);
assertRenderActive(cancellation);
if (plan.batchPath && batchModule && preparedBatch) {
await executeBatchRender(plan, browserPath, batchModule, preparedBatch, dependencies);
await executeBatchRender(
plan,
browserPath,
batchModule,
preparedBatch,
dependencies,
cancellation,
);
return;
}
@@ -132,10 +150,15 @@ export async function executeRenderPlan(
options.experimentalFastCapture = plan.experimentalFastCapture;
}
const execute = plan.useDocker ? dependencies.renderDocker : dependencies.renderLocal;
await execute(plan.project.dir, plan.outputPath, options);
await execute(plan.project.dir, plan.outputPath, options, cancellation);
}
async function ensureRenderBrowser(plan: RenderPlan): Promise<string> {
function assertRenderActive(cancellation?: RenderCancellationScope): void {
cancellation?.checkAncestors();
cancellation?.signal.throwIfAborted();
}
async function ensureRenderBrowser(plan: RenderPlan, signal?: AbortSignal): Promise<string> {
const { ensureBrowser } = await import("../../browser/manager.js");
let browserSpinner:
| {
@@ -146,13 +169,14 @@ async function ensureRenderBrowser(plan: RenderPlan): Promise<string> {
| undefined;
try {
if (plan.effectiveQuiet) {
return (await ensureBrowser({ preferManagedChrome: true })).executablePath;
return (await ensureBrowser({ preferManagedChrome: true, signal })).executablePath;
}
const clack = await import("@clack/prompts");
browserSpinner = clack.spinner();
browserSpinner.start("Checking browser...");
const info = await ensureBrowser({
preferManagedChrome: true,
signal,
onProgress: (downloaded, total) => {
if (total <= 0) return;
const pct = Math.floor((downloaded / total) * 100);
@@ -165,6 +189,7 @@ async function ensureRenderBrowser(plan: RenderPlan): Promise<string> {
return info.executablePath;
} catch (error: unknown) {
browserSpinner?.stop(c.error("Browser not available"));
if (signal?.aborted) signal.throwIfAborted();
errorBox(
"Chrome not found",
normalizeErrorMessage(error),
@@ -178,11 +203,15 @@ async function ensureRenderBrowser(plan: RenderPlan): Promise<string> {
export async function runRenderLint(
plan: RenderPlan,
runLint: (projectDir: string, entryFile?: string) => Promise<ProjectLintResult> = lintProject,
signal?: AbortSignal,
): Promise<void> {
// lintProject's explicit-entry contract is an absolute source path;
// entryFile remains project-relative for the producer.
const explicitEntry = plan.entryFile ? plan.renderTarget : undefined;
const lintResult = await runLint(plan.project.dir, explicitEntry);
const lintResult =
signal && runLint === lintProject
? await runRenderLintInOwnedProcess(plan.project.dir, explicitEntry, signal)
: await runLint(plan.project.dir, explicitEntry);
if (lintResult.totalErrors === 0 && lintResult.totalWarnings === 0) return;
presentRenderLintFindings(lintResult, plan.effectiveQuiet);
const definitiveEntryMismatch = hasDefinitiveEntryMismatch(lintResult);
@@ -193,6 +222,21 @@ export async function runRenderLint(
presentRenderLintContinuation(plan);
}
async function runRenderLintInOwnedProcess(
projectDir: string,
entryFile: string | undefined,
signal: AbortSignal,
): Promise<ProjectLintResult> {
return runRenderSetupWorker<ProjectLintResult>(
"lint",
{ projectDir, entryFile },
{
signal,
maxBufferBytes: 8 * 1024 * 1024,
},
);
}
function presentRenderLintFindings(
lintResult: Awaited<ReturnType<typeof lintProject>>,
quiet: boolean,
@@ -249,6 +293,7 @@ async function executeBatchRender(
batchModule: typeof import("../batchRender.js"),
preparedBatch: import("../batchRender.js").PreparedBatchRender,
dependencies: RenderExecutionDependencies,
cancellation?: RenderCancellationScope,
): Promise<void> {
const batchQuiet = plan.quiet || plan.batchJson;
const renderOptionsBase: RenderOptions = {
@@ -289,10 +334,11 @@ async function executeBatchRender(
quiet: batchQuiet,
json: plan.batchJson,
renderOne: (row) => {
assertRenderActive(cancellation);
const options: RenderOptions = { ...renderOptionsBase, variables: row.variables };
if (plan.useDocker) options.pageSideCompositing = plan.pageSideCompositing;
const execute = plan.useDocker ? dependencies.renderDocker : dependencies.renderLocal;
return execute(plan.project.dir, row.outputPath, options);
return execute(plan.project.dir, row.outputPath, options, cancellation);
},
});
if (manifest.failed > 0) setCommandExitCode(1);
@@ -0,0 +1,55 @@
import { describe, expect, it, vi } from "vitest";
import { installRenderSetupSignalHandlers } from "./renderSetupWorkerLifecycle.js";
describe("render setup worker signal lifecycle", () => {
function collectHandlers(handleHangup = true) {
const handlers = new Map<string, () => void>();
const dispose = installRenderSetupSignalHandlers(
{
on: vi.fn((signal, handler) => handlers.set(signal, handler)),
off: vi.fn((signal) => handlers.delete(signal)),
},
vi.fn(),
vi.fn(),
handleHangup,
);
return { handlers, dispose };
}
it("installs the complete interrupt set, including SIGHUP", () => {
const { handlers, dispose } = collectHandlers();
expect([...handlers.keys()]).toEqual(["SIGINT", "SIGTERM", "SIGHUP"]);
dispose();
});
it("does not override inherited SIGHUP behavior for detached setup workers", () => {
const { handlers, dispose } = collectHandlers(false);
expect([...handlers.keys()]).toEqual(["SIGINT", "SIGTERM"]);
dispose();
});
it.each(["SIGINT", "SIGTERM", "SIGHUP"] as const)(
"releases the browser lock before forwarding %s",
(signal) => {
const calls: string[] = [];
const handlers = new Map<string, () => void>();
installRenderSetupSignalHandlers(
{
on: (_signal, handler) => handlers.set(_signal, handler),
off: (_signal) => {
calls.push(`off:${_signal}`);
handlers.delete(_signal);
},
},
() => calls.push("release-lock"),
(forwardedSignal) => calls.push(`forward:${forwardedSignal}`),
);
handlers.get(signal)?.();
expect(calls.slice(0, 3)).toEqual(["release-lock", `off:${signal}`, `forward:${signal}`]);
},
);
});
+32
View File
@@ -0,0 +1,32 @@
// fallow-ignore-file unused-file
import { ensureBrowser, releaseOwnedBrowserInstallLock } from "./browser/manager.js";
import { lintProject } from "./utils/lintProject.js";
import { killOrphanedProcesses } from "./utils/orphanCleanup.js";
import { installRenderSetupSignalHandlers } from "./renderSetupWorkerLifecycle.js";
const RESULT_PREFIX = "HYPERFRAMES_RENDER_SETUP_RESULT:";
const mode = process.argv[2];
const input = JSON.parse(process.env.HYPERFRAMES_RENDER_SETUP_INPUT ?? "{}");
const disposeSignalHandlers = installRenderSetupSignalHandlers(
process,
releaseOwnedBrowserInstallLock,
(signal) => process.kill(process.pid, signal),
process.env.HYPERFRAMES_RENDER_DETACHED !== "1",
);
let result: unknown;
if (mode === "browser") {
result = await ensureBrowser(input);
} else if (mode === "lint") {
result = await lintProject(input.projectDir, input.entryFile);
} else if (mode === "orphan-cleanup") {
const killed = killOrphanedProcesses();
result = killed;
if (killed > 0) await new Promise((resolve) => setTimeout(resolve, 600));
} else {
throw new Error(`Unknown render setup mode: ${mode}`);
}
disposeSignalHandlers();
process.stdout.write(RESULT_PREFIX + JSON.stringify(result) + "\n");
@@ -0,0 +1,32 @@
const RENDER_SETUP_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
type RenderSetupSignal = (typeof RENDER_SETUP_SIGNALS)[number];
interface SignalTarget {
on(signal: RenderSetupSignal, handler: () => void): unknown;
off(signal: RenderSetupSignal, handler: () => void): unknown;
}
export function installRenderSetupSignalHandlers(
signalTarget: SignalTarget,
releaseLock: () => void,
resendSignal: (signal: RenderSetupSignal) => void,
handleHangup = true,
): () => void {
const handlers = new Map<RenderSetupSignal, () => void>();
const handledSignals = handleHangup
? RENDER_SETUP_SIGNALS
: RENDER_SETUP_SIGNALS.filter((signal) => signal !== "SIGHUP");
for (const signal of handledSignals) {
const handler = (): void => {
releaseLock();
signalTarget.off(signal, handler);
resendSignal(signal);
};
handlers.set(signal, handler);
signalTarget.on(signal, handler);
}
return () => {
for (const [signal, handler] of handlers) signalTarget.off(signal, handler);
};
}
@@ -0,0 +1,74 @@
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { runCancellableProcess } from "./cancellableProcess.js";
import { testProcessIsAlive, waitForTestCondition } from "./processTestUtils.js";
const IS_POSIX = process.platform !== "win32";
describe.skipIf(!IS_POSIX)("cancellable process tree teardown", () => {
it("reaps a SIGTERM-resistant grandchild after the setup root exits", async () => {
const testDir = mkdtempSync(join(tmpdir(), "hyperframes-cancellable-process-"));
const readyPath = join(testDir, "ready");
const completionPath = join(testDir, "completed");
const completionDelayMs = 750;
const grandchildSource = [
'import { writeFileSync } from "node:fs";',
"const [readyPath, completionPath, completionDelay] = process.argv.slice(1);",
'process.on("SIGTERM", () => undefined);',
"writeFileSync(readyPath, String(process.pid));",
'setTimeout(() => writeFileSync(completionPath, "completed"), Number(completionDelay));',
"setInterval(() => undefined, 1000);",
].join("\n");
const rootSource = [
'import { spawn } from "node:child_process";',
"const [grandchildSource, readyPath, completionPath, completionDelay] = process.argv.slice(1);",
'spawn(process.execPath, ["--input-type=module", "--eval", grandchildSource, readyPath, completionPath, completionDelay], { stdio: "ignore" });',
'process.on("SIGTERM", () => process.exit(0));',
"setInterval(() => undefined, 1000);",
].join("\n");
const controller = new AbortController();
const abortReason = new Error("cancel setup");
let rootPid: number | undefined;
let grandchildPid: number | undefined;
try {
const setup = runCancellableProcess(
process.execPath,
[
"--input-type=module",
"--eval",
rootSource,
grandchildSource,
readyPath,
completionPath,
String(completionDelayMs),
],
{
signal: controller.signal,
onSpawn: (pid) => {
rootPid = pid;
},
},
);
await waitForTestCondition(() => existsSync(readyPath), 2_000);
grandchildPid = Number(readFileSync(readyPath, "utf8"));
controller.abort(abortReason);
await expect(setup).rejects.toBe(abortReason);
expect(rootPid).toBeDefined();
expect(grandchildPid).toBeGreaterThan(0);
expect(testProcessIsAlive(rootPid!)).toBe(false);
expect(testProcessIsAlive(grandchildPid)).toBe(false);
await new Promise((resolve) => setTimeout(resolve, completionDelayMs + 100));
expect(existsSync(completionPath)).toBe(false);
} finally {
if (rootPid && testProcessIsAlive(rootPid)) process.kill(rootPid, "SIGKILL");
if (grandchildPid && testProcessIsAlive(grandchildPid))
process.kill(grandchildPid, "SIGKILL");
rmSync(testDir, { recursive: true, force: true });
}
}, 5_000);
});
@@ -0,0 +1,231 @@
import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
import { fileURLToPath } from "node:url";
import { terminateProcessTree } from "./processTree.js";
export interface CancellableProcessResult {
stdout: string;
stderr: string;
}
export interface CancellableProcessOptions {
signal?: AbortSignal;
timeoutMs?: number;
maxBufferBytes?: number;
cwd?: string;
env?: NodeJS.ProcessEnv;
onSpawn?: (pid: number) => void;
}
class CancellableProcessError extends Error {
readonly code?: string;
readonly status?: number;
readonly signal?: NodeJS.Signals;
readonly stdout: string;
readonly stderr: string;
readonly killed: boolean;
constructor(
message: string,
options: {
code?: string;
status?: number;
signal?: NodeJS.Signals;
stdout?: string;
stderr?: string;
killed?: boolean;
cause?: unknown;
} = {},
) {
super(message, { cause: options.cause });
this.name = "CancellableProcessError";
this.code = options.code;
this.status = options.status;
this.signal = options.signal;
this.stdout = options.stdout ?? "";
this.stderr = options.stderr ?? "";
this.killed = options.killed ?? false;
}
}
export function runCancellableProcess(
command: string,
args: readonly string[],
options: CancellableProcessOptions = {},
): Promise<CancellableProcessResult> {
const signal = options.signal;
signal?.throwIfAborted();
return new Promise((resolve, reject) => {
let child: ChildProcess;
try {
const spawnOptions: SpawnOptions = {
cwd: options.cwd,
env: options.env,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
detached: process.platform !== "win32",
};
child = spawn(command, [...args], spawnOptions);
} catch (error) {
reject(error);
return;
}
if (child.pid) options.onSpawn?.(child.pid);
let stdout = "";
let stderr = "";
let settled = false;
let terminationReason: unknown;
let terminationSignal: NodeJS.Signals | undefined;
let terminationCode: string | undefined;
const maxBufferBytes = options.maxBufferBytes ?? 1024 * 1024;
const processTreePid = child.pid;
let operationTimeout: ReturnType<typeof setTimeout> | undefined;
let terminationTask: Promise<NodeJS.Signals> | undefined;
const cleanup = (): void => {
signal?.removeEventListener("abort", onAbort);
if (operationTimeout) clearTimeout(operationTimeout);
};
const finishReject = async (error: unknown): Promise<void> => {
if (settled) return;
if (terminationTask) {
try {
terminationSignal = await terminationTask;
} catch (terminationError) {
error = terminationError;
}
}
if (settled) return;
settled = true;
cleanup();
reject(error);
};
const terminate = (reason: unknown, code?: string): void => {
if (terminationReason !== undefined) return;
terminationReason = reason;
terminationCode = code;
terminationSignal = "SIGTERM";
if (processTreePid) {
terminationTask = terminateProcessTree(processTreePid, { child, processGroup: true });
void terminationTask.catch(() => undefined);
}
};
const onAbort = (): void => terminate(signal?.reason ?? new Error("Operation aborted"));
child.stdout?.on("data", (chunk: Buffer | string) => {
stdout += chunk.toString();
if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) > maxBufferBytes) {
terminate(new Error(`Process output exceeded ${maxBufferBytes} bytes`), "ENOBUFS");
}
});
child.stderr?.on("data", (chunk: Buffer | string) => {
stderr += chunk.toString();
if (Buffer.byteLength(stdout) + Buffer.byteLength(stderr) > maxBufferBytes) {
terminate(new Error(`Process output exceeded ${maxBufferBytes} bytes`), "ENOBUFS");
}
});
child.once("error", (error) => void finishReject(error));
// fallow-ignore-next-line complexity
child.once("close", async (status, closeSignal) => {
if (settled) return;
if (terminationTask) {
try {
terminationSignal = await terminationTask;
} catch (error) {
await finishReject(error);
return;
}
}
if (settled) return;
settled = true;
cleanup();
if (terminationReason !== undefined) {
if (signal?.aborted && terminationReason === signal.reason) {
reject(terminationReason);
return;
}
reject(
new CancellableProcessError(
terminationReason instanceof Error
? terminationReason.message
: String(terminationReason),
{
code: terminationCode,
signal: closeSignal ?? terminationSignal,
stdout,
stderr,
killed: true,
cause: terminationReason,
},
),
);
return;
}
if (status === 0) {
resolve({ stdout, stderr });
return;
}
reject(
new CancellableProcessError(`Command failed: ${command} ${args.join(" ")}`, {
status: status ?? undefined,
signal: closeSignal ?? undefined,
stdout,
stderr,
}),
);
});
signal?.addEventListener("abort", onAbort, { once: true });
if (signal?.aborted) onAbort();
if (options.timeoutMs !== undefined) {
operationTimeout = setTimeout(() => {
terminate(new Error(`Process timed out after ${options.timeoutMs} ms`), "ETIMEDOUT");
}, options.timeoutMs);
operationTimeout.unref();
}
});
}
const SETUP_RESULT_PREFIX = "HYPERFRAMES_RENDER_SETUP_RESULT:";
export async function runRenderSetupWorker<T>(
mode: "browser" | "lint" | "orphan-cleanup",
input: unknown,
options: CancellableProcessOptions,
): Promise<T> {
const sourceMode = import.meta.url.endsWith(".ts");
const workerUrl = new URL(
sourceMode ? "../renderSetupWorker.ts" : "./renderSetupWorker.js",
import.meta.url,
);
const execArgv = sourceMode ? ["--import", "tsx"] : [];
let result: CancellableProcessResult;
try {
result = await runCancellableProcess(
process.execPath,
[...execArgv, fileURLToPath(workerUrl), mode],
{
...options,
env: {
...process.env,
...options.env,
HYPERFRAMES_RENDER_SETUP_INPUT: JSON.stringify(input),
},
},
);
} catch (error) {
if (options.signal?.aborted) options.signal.throwIfAborted();
if (error instanceof CancellableProcessError && error.stderr.trim()) {
throw new Error(error.stderr.trim(), { cause: error });
}
throw error;
}
if (result.stderr) process.stderr.write(result.stderr);
const encoded = result.stdout
.split(/\r?\n/)
.find((line) => line.startsWith(SETUP_RESULT_PREFIX))
?.slice(SETUP_RESULT_PREFIX.length);
if (!encoded) throw new Error(`${mode} setup process exited without a result`);
return JSON.parse(encoded) as T;
}
+1 -1
View File
@@ -92,7 +92,7 @@ describe.skipIf(!IS_UNIX)("killProcessTree", () => {
it("handles non-existent PID gracefully", () => {
// Should not throw for a PID that doesn't exist
killProcessTree(999999999);
expect(killProcessTree(999999999)).toBeUndefined();
});
it("escalates to SIGKILL after grace period", async () => {
+95 -77
View File
@@ -1,5 +1,10 @@
// fallow-ignore-file code-duplication
import { execFileSync, execSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { runRenderSetupWorker } from "./cancellableProcess.js";
import { terminateProcessTree, windowsProcessTreeKillArgs } from "./processTree.js";
export { windowsProcessTreeKillArgs };
/**
* Find and kill orphaned Chrome processes from previous crashed sessions.
@@ -30,61 +35,12 @@ export function killOrphanedProcesses(): number {
return killed;
}
/**
* Kill an entire process tree rooted at `pid`. Walks descendants
* depth-first so children are killed before parents, preventing
* re-adoption races.
*
* Windows uses taskkill's tree mode because pgrep/ps are unavailable there.
*
* `signal` is honoured on POSIX only. The Windows path always passes `/F`, so a
* caller asking for SIGTERM gets a forced tree kill with no grace period, while
* the same call on POSIX gets 500 ms to flush and exit. That is deliberate —
* `taskkill` without `/F` posts WM_CLOSE, which a console process is free to
* ignore, and leaving a preview server alive is the worse failure here. Do not
* pass SIGTERM expecting a clean shutdown on Windows.
*/
export function killProcessTree(pid: number, signal: NodeJS.Signals = "SIGTERM"): void {
if (process.platform === "win32") {
try {
execFileSync("taskkill", windowsProcessTreeKillArgs(pid), {
stdio: "ignore",
timeout: 5000,
windowsHide: true,
});
} catch {
// Process already exited or taskkill could not inspect it.
}
return;
}
const descendants = getDescendants(pid);
const allPids = [...descendants.reverse(), pid];
for (const p of allPids) {
try {
process.kill(p, signal);
} catch {
// Already exited.
}
}
// Escalate to SIGKILL after a short grace period for any survivors.
if (signal !== "SIGKILL") {
setTimeout(() => {
for (const p of allPids) {
try {
process.kill(p, "SIGKILL");
} catch {
// Already exited.
}
}
}, 500).unref();
}
export async function killOrphanedProcessesForRender(signal: AbortSignal): Promise<number> {
return runRenderSetupWorker<number>("orphan-cleanup", {}, { signal, timeoutMs: 15_000 });
}
export function windowsProcessTreeKillArgs(pid: number): string[] {
return ["/PID", String(pid), "/T", "/F"];
export function killProcessTree(pid: number, signal: NodeJS.Signals = "SIGTERM"): void {
void terminateProcessTree(pid, { signal }).catch(() => undefined);
}
/**
@@ -136,7 +92,92 @@ export function processIdentity(pid: number): string | null {
type ParentPidLookup = (pid: number) => number | null;
function processParentPid(pid: number): number | null {
export interface ProcessAncestor {
pid: number;
identity: string;
}
interface ProcessRecord extends ProcessAncestor {
parentPid: number;
}
function recordsToAncestors(pid: number, records: readonly ProcessRecord[]): ProcessAncestor[] {
const byPid = new Map(records.map((record) => [record.pid, record]));
const ancestors: ProcessAncestor[] = [];
const visited = new Set<number>([pid]);
let current = pid;
for (let depth = 0; depth < 64; depth++) {
const parent = byPid.get(current)?.parentPid;
if (parent === undefined || parent <= 1 || visited.has(parent)) break;
visited.add(parent);
const ancestor = byPid.get(parent);
if (!ancestor) break;
ancestors.push({ pid: ancestor.pid, identity: ancestor.identity });
current = parent;
}
return ancestors;
}
/**
* Capture an off-Linux ancestor chain in one process-table lookup. This keeps
* the birth token attached to every tracked PID without a PowerShell/ps spawn
* for each ancestor.
*/
export function processAncestorSnapshot(pid: number): ProcessAncestor[] {
if (!Number.isInteger(pid) || pid <= 0 || process.platform === "linux") return [];
try {
if (process.platform === "win32") {
const output = execFileSync(
"powershell.exe",
[
"-NoProfile",
"-NonInteractive",
"-Command",
'Get-CimInstance Win32_Process | ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId) $($_.CreationDate.ToFileTimeUtc())" }',
],
{ encoding: "utf8", timeout: 2000, stdio: ["pipe", "pipe", "ignore"], windowsHide: true },
);
const records = output
.split(/\r?\n/)
.map((line) => line.trim().split(/\s+/))
.map(([processId, parentProcessId, creationDate]) => ({
pid: Number(processId),
parentPid: Number(parentProcessId),
identity: creationDate ? `windows:${creationDate}` : "",
}))
.filter(
(record): record is ProcessRecord =>
Number.isInteger(record.pid) &&
record.pid > 0 &&
Number.isInteger(record.parentPid) &&
record.parentPid > 0 &&
record.identity !== "",
);
return recordsToAncestors(pid, records);
}
const output = execFileSync("ps", ["-axo", "pid=,ppid=,lstart="], {
encoding: "utf8",
timeout: 2000,
});
const records = output
.split(/\r?\n/)
.map((line) => line.match(/^\s*(\d+)\s+(\d+)\s+(.+)$/))
.filter((match): match is RegExpMatchArray => match !== null)
.map(([, processId, parentProcessId, started]) => ({
pid: Number(processId),
parentPid: Number(parentProcessId),
identity: `posix:${started?.trim() ?? ""}`,
}));
return recordsToAncestors(pid, records);
} catch {
return [];
}
}
export function processParentPid(pid: number): number | null {
try {
const output =
process.platform === "win32"
@@ -191,29 +232,6 @@ export function isProcessDescendant(
return false;
}
function getDescendants(pid: number): number[] {
let children: number[];
try {
const raw = execSync(`pgrep -P ${pid}`, {
encoding: "utf-8",
timeout: 2000,
}).trim();
if (!raw) return [];
children = raw
.split("\n")
.map((s) => parseInt(s, 10))
.filter((n) => !isNaN(n) && n > 0);
} catch {
return [];
}
const all: number[] = [];
for (const child of children) {
all.push(child);
all.push(...getDescendants(child));
}
return all;
}
function killOrphansByName(processName: string): number {
const uid = getUid();
const userFlag = uid !== null ? `-u ${uid} ` : "";
@@ -7,7 +7,7 @@ vi.mock("node:child_process", () => ({
execSync: vi.fn(),
}));
import { isProcessDescendant, killProcessTree, processIdentity } from "./orphanCleanup.js";
import { isProcessDescendant, processAncestorSnapshot, processIdentity } from "./orphanCleanup.js";
describe("Windows orphan-cleanup child-process options", () => {
const originalPlatform = process.platform;
@@ -29,14 +29,32 @@ describe("Windows orphan-cleanup child-process options", () => {
vi.clearAllMocks();
});
it("hides taskkill and PowerShell console windows", () => {
killProcessTree(4321);
it("hides PowerShell process-identity windows", () => {
expect(processIdentity(4321)).toBe("windows:123456");
expect(isProcessDescendant(400, 200)).toBe(true);
expect(execFileSyncMock).toHaveBeenCalledTimes(4);
expect(execFileSyncMock).toHaveBeenCalledTimes(3);
for (const call of execFileSyncMock.mock.calls) {
expect(call[2]).toEqual(expect.objectContaining({ windowsHide: true }));
}
});
it("captures the complete Windows ancestor chain with one hidden PowerShell lookup", () => {
execFileSyncMock.mockImplementation((_command: string, args: string[]) => {
const script = args.at(-1) ?? "";
if (script.includes("Get-CimInstance Win32_Process |")) {
return "400 300 444\n300 200 333\n200 1 222\n";
}
return "";
});
expect(processAncestorSnapshot(400)).toEqual([
{ pid: 300, identity: "windows:333" },
{ pid: 200, identity: "windows:222" },
]);
expect(execFileSyncMock).toHaveBeenCalledOnce();
expect(execFileSyncMock.mock.calls[0]?.[2]).toEqual(
expect.objectContaining({ windowsHide: true }),
);
});
});
@@ -0,0 +1,27 @@
import { readFileSync } from "node:fs";
export function testProcessIsAlive(pid: number): boolean {
if (process.platform === "linux") {
try {
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
const state = stat.slice(stat.lastIndexOf(") ") + 2)[0];
return state !== "Z" && state !== "X";
} catch {
return false;
}
}
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
export async function waitForTestCondition(check: () => boolean, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!check()) {
if (Date.now() >= deadline) throw new Error(`Condition not met within ${timeoutMs} ms`);
await new Promise((resolve) => setTimeout(resolve, 20));
}
}
@@ -0,0 +1,33 @@
import { EventEmitter } from "node:events";
import { describe, expect, it, vi } from "vitest";
import {
terminateWindowsProcessTree,
windowsProcessTreeKillArgs,
type SpawnProcess,
} from "./processTree.js";
describe("Windows process-tree termination", () => {
it("runs taskkill recursively and forcefully through the platform seam", async () => {
const killer = new EventEmitter();
const spawnProcess = vi.fn<SpawnProcess>(() => killer);
const termination = terminateWindowsProcessTree(4321, spawnProcess);
killer.emit("close", 0);
await termination;
expect(spawnProcess).toHaveBeenCalledWith("taskkill", windowsProcessTreeKillArgs(4321), {
stdio: "ignore",
windowsHide: true,
});
});
it("surfaces taskkill failure so cancellable subprocesses can fall back", async () => {
const killer = new EventEmitter();
const spawnProcess = vi.fn<SpawnProcess>(() => killer);
const termination = terminateWindowsProcessTree(55, spawnProcess);
killer.emit("close", 1);
await expect(termination).rejects.toThrow("taskkill exited with status 1");
});
});
+150
View File
@@ -0,0 +1,150 @@
import { execFileSync, spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
import { readdirSync, readFileSync } from "node:fs";
interface SpawnedProcess {
once(event: "error", listener: (error: Error) => void): unknown;
once(event: "close", listener: (status: number | null) => void): unknown;
}
export type SpawnProcess = (
command: string,
args: readonly string[],
options: SpawnOptions,
) => SpawnedProcess;
export interface ProcessTreeTerminationOptions {
child?: Pick<ChildProcess, "kill">;
processGroup?: boolean;
signal?: NodeJS.Signals;
spawnProcess?: SpawnProcess;
}
export function processIsAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException | undefined)?.code !== "ESRCH";
}
}
function processGroupIsAlive(pid: number): boolean {
if (process.platform === "linux") {
try {
return readdirSync("/proc").some((entry) => {
if (!/^\d+$/.test(entry)) return false;
try {
const stat = readFileSync(`/proc/${entry}/stat`, "utf8");
const fields = stat.slice(stat.lastIndexOf(") ") + 2).split(/\s+/);
return fields[2] === String(pid) && fields[0] !== "Z" && fields[0] !== "X";
} catch {
return false;
}
});
} catch {}
}
return processIsAlive(-pid);
}
function waitForDelay(delayMs: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, delayMs));
}
async function waitForProcessGroupExit(pid: number): Promise<void> {
while (processGroupIsAlive(pid)) await waitForDelay(20);
}
export function windowsProcessTreeKillArgs(pid: number): string[] {
return ["/PID", String(pid), "/T", "/F"];
}
export function terminateWindowsProcessTree(
pid: number,
spawnProcess: SpawnProcess = (command, args, options) => spawn(command, [...args], options),
): Promise<void> {
return new Promise((resolve, reject) => {
const killer = spawnProcess("taskkill", windowsProcessTreeKillArgs(pid), {
stdio: "ignore",
windowsHide: true,
});
killer.once("error", reject);
killer.once("close", (status) => {
if (status === 0) resolve();
else reject(new Error(`taskkill exited with status ${status ?? "unknown"}`));
});
});
}
function signalProcessGroup(pid: number, signal: NodeJS.Signals): void {
try {
process.kill(-pid, signal);
} catch {}
}
function getDescendants(pid: number): number[] {
let children: number[];
try {
const raw = execFileSync("pgrep", ["-P", String(pid)], {
encoding: "utf8",
timeout: 2_000,
}).trim();
children = raw
.split(/\r?\n/)
.map(Number)
.filter((childPid) => Number.isInteger(childPid) && childPid > 0);
} catch {
return [];
}
return children.flatMap((childPid) => [childPid, ...getDescendants(childPid)]);
}
function signalProcesses(pids: readonly number[], signal: NodeJS.Signals): void {
for (const pid of pids) {
try {
process.kill(pid, signal);
} catch {}
}
}
async function terminatePidTree(pid: number, signal: NodeJS.Signals): Promise<NodeJS.Signals> {
const pids = [...getDescendants(pid).reverse(), pid];
signalProcesses(pids, signal);
if (signal === "SIGKILL") return "SIGKILL";
await waitForDelay(500);
const survivors = pids.filter(processIsAlive);
signalProcesses(survivors, "SIGKILL");
return survivors.length > 0 ? "SIGKILL" : "SIGTERM";
}
async function terminateProcessGroup(pid: number, signal: NodeJS.Signals): Promise<NodeJS.Signals> {
signalProcessGroup(pid, signal);
if (signal === "SIGKILL") {
await waitForProcessGroupExit(pid);
return "SIGKILL";
}
await Promise.race([waitForProcessGroupExit(pid), waitForDelay(250)]);
if (!processGroupIsAlive(pid)) return "SIGTERM";
signalProcessGroup(pid, "SIGKILL");
await waitForProcessGroupExit(pid);
return "SIGKILL";
}
export async function terminateProcessTree(
pid: number,
options: ProcessTreeTerminationOptions = {},
): Promise<NodeJS.Signals> {
if (process.platform === "win32") {
try {
await terminateWindowsProcessTree(pid, options.spawnProcess);
} catch (error) {
try {
options.child?.kill("SIGKILL");
} catch {}
if (options.child) throw error;
}
return "SIGKILL";
}
const signal = options.signal ?? "SIGTERM";
return options.processGroup ? terminateProcessGroup(pid, signal) : terminatePidTree(pid, signal);
}
@@ -0,0 +1,43 @@
// fallow-ignore-file unused-file
import { writeFileSync } from "node:fs";
import { registerRootExitRequester, requestCliExit } from "./commandResult.js";
import { runCancellableProcess } from "./cancellableProcess.js";
import { createRenderCancellationScope } from "./renderCancellation.js";
const [outputPath, readyPath, rawWriteDelay] = process.argv.slice(2);
if (!outputPath || !readyPath || !rawWriteDelay) throw new Error("Missing fixture arguments");
const writeDelay = Number(rawWriteDelay);
const cancellation = createRenderCancellationScope({ parentPollIntervalMs: 25 });
registerRootExitRequester(() => {
cancellation.dispose();
process.kill(process.pid, "SIGTERM");
});
const setupScript = `
const { writeFileSync } = await import("node:fs");
const [outputPath, rawDelay] = process.argv.slice(1);
setTimeout(() => writeFileSync(outputPath, "stale render output"), Number(rawDelay));
`;
const setup = runCancellableProcess(
process.execPath,
["--input-type=module", "--eval", setupScript, outputPath, String(writeDelay)],
{
signal: cancellation.signal,
onSpawn: (setupPid) => {
writeFileSync(
readyPath,
JSON.stringify({ pid: process.pid, parentPid: process.ppid, setupPid }),
);
},
},
);
try {
await setup;
} catch (error) {
if (!cancellation.signal.aborted) throw error;
requestCliExit(1);
await new Promise<never>(() => undefined);
} finally {
cancellation.dispose();
}
@@ -0,0 +1,152 @@
import { createHash } from "node:crypto";
import { spawn, type ChildProcess } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { testProcessIsAlive, waitForTestCondition } from "./processTestUtils.js";
const IS_POSIX = process.platform !== "win32";
const WRITE_DELAY_MS = 750;
const children = new Set<ChildProcess>();
const fixturePath = join(dirname(fileURLToPath(import.meta.url)), "renderCancellation.fixture.ts");
function checksum(path: string): string {
return createHash("sha256").update(readFileSync(path)).digest("hex");
}
function spawnWrapper(outputPath: string, readyPath: string): ChildProcess {
const wrapperSource = [
'import { spawn } from "node:child_process";',
"const [fixturePath, outputPath, readyPath, writeDelay] = process.argv.slice(1);",
'spawn("sh", ["-c", \'"$1" --import tsx "$2" "$3" "$4" "$5" & wait\', "render-shell", process.execPath, fixturePath, outputPath, readyPath, writeDelay], { stdio: "ignore" });',
"setInterval(() => undefined, 1000);",
].join("\n");
const wrapper = spawn(
process.execPath,
[
"--input-type=module",
"--eval",
wrapperSource,
fixturePath,
outputPath,
readyPath,
String(WRITE_DELAY_MS),
],
{ cwd: process.cwd(), stdio: "ignore" },
);
children.add(wrapper);
wrapper.once("exit", () => children.delete(wrapper));
return wrapper;
}
afterEach(() => {
for (const child of children) child.kill("SIGKILL");
children.clear();
});
describe.skipIf(!IS_POSIX)("render cancellation process lifecycle", () => {
it.each(["SIGINT", "SIGTERM", "SIGHUP"] as const)(
"cancels the Node worker after its background wrapper exits from %s",
async (signal) => {
const testDir = mkdtempSync(join(tmpdir(), "hyperframes-render-cancel-"));
const outputPath = join(testDir, "output.mp4");
const readyPath = join(testDir, "ready.json");
writeFileSync(outputPath, "existing render sentinel");
const fixedMtime = new Date("2020-01-02T03:04:05.000Z");
utimesSync(outputPath, fixedMtime, fixedMtime);
const initialChecksum = checksum(outputPath);
const initialMtime = statSync(outputPath).mtimeMs;
let workerPid: number | undefined;
let shellPid: number | undefined;
let setupPid: number | undefined;
try {
const wrapper = spawnWrapper(outputPath, readyPath);
const wrapperExit = new Promise<void>((resolve) => wrapper.once("exit", () => resolve()));
await waitForTestCondition(() => {
try {
const ready = JSON.parse(readFileSync(readyPath, "utf8")) as {
pid: number;
parentPid: number;
setupPid: number;
};
workerPid = ready.pid;
shellPid = ready.parentPid;
setupPid = ready.setupPid;
return testProcessIsAlive(ready.pid) && testProcessIsAlive(ready.setupPid);
} catch {
return false;
}
}, 5_000);
expect(wrapper.kill(signal)).toBe(true);
await Promise.race([
wrapperExit,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Wrapper did not exit")), 2_000),
),
]);
await waitForTestCondition(
() => workerPid !== undefined && !testProcessIsAlive(workerPid),
3_000,
);
await waitForTestCondition(
() => setupPid !== undefined && !testProcessIsAlive(setupPid),
3_000,
);
await new Promise((resolve) => setTimeout(resolve, WRITE_DELAY_MS + 100));
expect(checksum(outputPath)).toBe(initialChecksum);
expect(statSync(outputPath).mtimeMs).toBe(initialMtime);
} finally {
if (workerPid !== undefined && testProcessIsAlive(workerPid))
process.kill(workerPid, "SIGKILL");
if (setupPid !== undefined && testProcessIsAlive(setupPid))
process.kill(setupPid, "SIGKILL");
if (shellPid !== undefined && testProcessIsAlive(shellPid))
process.kill(shellPid, "SIGKILL");
rmSync(testDir, { recursive: true, force: true });
}
},
10_000,
);
});
describe("render cancellation process keepalive", () => {
it("keeps detached renders alive while their awaited work only uses unrefed handles", async () => {
const testDir = mkdtempSync(join(tmpdir(), "hyperframes-render-keepalive-"));
const outputPath = join(testDir, "completed");
const modulePath = join(dirname(fixturePath), "renderCancellation.ts");
const source = [
'import { writeFileSync } from "node:fs";',
'import { createRenderCancellationScope } from "' + pathToFileURL(modulePath).href + '";',
"const [outputPath] = process.argv.slice(1);",
"const cancellation = createRenderCancellationScope({ detached: true, parentPollIntervalMs: 25 });",
"await new Promise((resolve) => {",
' const timer = setTimeout(() => { writeFileSync(outputPath, "completed"); cancellation.dispose(); resolve(); }, 250);',
" timer.unref();",
"});",
].join("\n");
try {
const fixture = spawn(
process.execPath,
["--import", "tsx", "--input-type=module", "--eval", source, outputPath],
{ cwd: process.cwd(), stdio: "pipe" },
);
const exited = new Promise<{ code: number | null; stderr: string }>((resolve) => {
let stderr = "";
fixture.stderr?.on("data", (chunk) => (stderr += chunk.toString()));
fixture.once("exit", (code) => resolve({ code, stderr }));
});
const result = await exited;
expect(result, result.stderr).toMatchObject({ code: 0 });
expect(readFileSync(outputPath, "utf8")).toBe("completed");
} finally {
rmSync(testDir, { recursive: true, force: true });
}
}, 5_000);
});
@@ -0,0 +1,161 @@
// fallow-ignore-file code-duplication
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRenderCancellationScope } from "./renderCancellation.js";
afterEach(() => {
vi.useRealTimers();
});
describe("createRenderCancellationScope ancestor monitoring", () => {
it.each([
{ label: "dead", identity: "windows:birth", alive: false },
{ label: "identity-less", identity: null, alive: true },
])("stops capture before retaining a $label parent PID", ({ identity, alive }) => {
const lookupIdentity = vi.fn(() => identity);
const isAlive = vi.fn(() => alive);
const cancellation = createRenderCancellationScope({
signalTarget: { on: vi.fn(), off: vi.fn() },
pid: 100,
parentPid: (pid) => (pid === 100 ? 50 : 1),
identity: lookupIdentity,
isAlive,
});
cancellation.checkAncestors();
expect(cancellation.signal.aborted).toBe(false);
expect(isAlive).toHaveBeenCalledTimes(1);
expect(lookupIdentity).toHaveBeenCalledTimes(alive ? 1 : 0);
cancellation.dispose();
});
it("keeps termination handlers installed until cancellation cleanup finishes", () => {
const handlers = new Map<string, () => void>();
const signalTarget = {
on: vi.fn((signal: string, handler: () => void) => handlers.set(signal, handler)),
off: vi.fn((signal: string) => handlers.delete(signal)),
};
const cancellation = createRenderCancellationScope({
signalTarget,
pid: 100,
parentPid: () => 1,
});
handlers.get("SIGTERM")?.();
handlers.get("SIGTERM")?.();
expect(cancellation.signal.aborted).toBe(true);
expect(handlers.has("SIGTERM")).toBe(true);
cancellation.dispose();
expect(handlers.size).toBe(0);
});
it("does not treat repeated identity lookup failures as proof an ancestor exited", () => {
vi.useFakeTimers();
let identity: string | null = "ancestor-birth-token";
const cancellation = createRenderCancellationScope({
signalTarget: { on: vi.fn(), off: vi.fn() },
pid: 100,
parentPid: (pid) => (pid === 100 ? 50 : 1),
identity: () => identity,
isAlive: () => true,
parentPollIntervalMs: 10,
});
identity = null;
vi.advanceTimersByTime(100);
expect(cancellation.signal.aborted).toBe(false);
cancellation.dispose();
});
it("aborts through the win32-compatible seam when the kernel proves the ancestor is gone", () => {
vi.useFakeTimers();
let identity: string | null = "ancestor-birth-token";
let alive = true;
const cancellation = createRenderCancellationScope({
signalTarget: { on: vi.fn(), off: vi.fn() },
pid: 100,
parentPid: (pid) => (pid === 100 ? 50 : 1),
identity: () => (identity === null ? null : `windows:${identity}`),
isAlive: () => alive,
parentPollIntervalMs: 10,
});
identity = null;
alive = false;
vi.advanceTimersByTime(10);
expect(cancellation.signal.aborted).toBe(true);
expect(cancellation.signal.reason).toMatchObject({
message: "render_cancelled_parent_exited",
});
cancellation.dispose();
});
it("aborts when a live PID has a different birth identity", () => {
let identity = "ancestor-birth-token";
const cancellation = createRenderCancellationScope({
signalTarget: { on: vi.fn(), off: vi.fn() },
platform: "linux",
pid: 100,
parentPid: (pid) => (pid === 100 ? 50 : 1),
identity: () => identity,
isAlive: () => true,
});
identity = "reused-pid-birth-token";
cancellation.checkAncestors();
expect(cancellation.signal.aborted).toBe(true);
cancellation.dispose();
});
it("takes the off-Linux process snapshot once and never from a checkpoint", () => {
vi.useFakeTimers();
const ancestorSnapshot = vi.fn(() => [{ pid: 50, identity: "windows:ancestor-birth-token" }]);
const isAlive = vi.fn(() => true);
const cancellation = createRenderCancellationScope({
signalTarget: { on: vi.fn(), off: vi.fn() },
platform: "win32",
pid: 100,
ancestorSnapshot,
isAlive,
});
expect(ancestorSnapshot).toHaveBeenCalledExactlyOnceWith(100);
vi.advanceTimersByTime(1_999);
vi.advanceTimersByTime(1);
expect(isAlive).toHaveBeenCalledOnce();
cancellation.checkAncestors();
cancellation.checkAncestors();
expect(ancestorSnapshot).toHaveBeenCalledOnce();
expect(isAlive).toHaveBeenCalledTimes(3);
cancellation.dispose();
});
it("leaves SIGHUP ignored and parent ownership untracked for detached renders", () => {
const handlers = new Map<string, () => void>();
const parentPid = vi.fn(() => 50);
const previous = process.env.HYPERFRAMES_RENDER_DETACHED;
process.env.HYPERFRAMES_RENDER_DETACHED = "1";
try {
const cancellation = createRenderCancellationScope({
signalTarget: {
on: vi.fn((signal: string, handler: () => void) => handlers.set(signal, handler)),
off: vi.fn((signal: string) => handlers.delete(signal)),
},
parentPid,
});
expect([...handlers.keys()]).toEqual(["SIGINT", "SIGTERM"]);
expect(parentPid).not.toHaveBeenCalled();
cancellation.checkAncestors();
expect(cancellation.signal.aborted).toBe(false);
cancellation.dispose();
} finally {
if (previous === undefined) delete process.env.HYPERFRAMES_RENDER_DETACHED;
else process.env.HYPERFRAMES_RENDER_DETACHED = previous;
}
});
});
@@ -0,0 +1,150 @@
import {
processAncestorSnapshot,
processIdentity,
processParentPid,
type ProcessAncestor,
} from "./orphanCleanup.js";
import { processIsAlive } from "./processTree.js";
type RenderCancellationSignal = "SIGINT" | "SIGTERM" | "SIGHUP";
interface RenderSignalTarget {
on(event: RenderCancellationSignal, listener: () => void): unknown;
off(event: RenderCancellationSignal, listener: () => void): unknown;
}
type RenderAncestor = ProcessAncestor;
export interface RenderCancellationScopeOptions {
signalTarget?: RenderSignalTarget;
parentPid?: (pid: number) => number | null;
identity?: (pid: number) => string | null;
ancestorSnapshot?: (pid: number) => ProcessAncestor[];
isAlive?: (pid: number) => boolean;
pid?: number;
parentPollIntervalMs?: number;
platform?: NodeJS.Platform;
detached?: boolean;
}
export interface RenderCancellationScope {
signal: AbortSignal;
checkAncestors: () => void;
dispose: () => void;
}
const RENDER_CANCELLATION_SIGNALS: readonly RenderCancellationSignal[] = [
"SIGINT",
"SIGTERM",
"SIGHUP",
];
function captureAncestors(
pid: number,
lookupParentPid: (pid: number) => number | null,
lookupIdentity: (pid: number) => string | null,
isAlive: (pid: number) => boolean,
): RenderAncestor[] {
const ancestors: RenderAncestor[] = [];
const visited = new Set<number>([pid]);
let current = pid;
for (let depth = 0; depth < 64; depth++) {
const parent = lookupParentPid(current);
if (parent === null || parent <= 1 || visited.has(parent)) break;
visited.add(parent);
if (!isAlive(parent)) break;
const identity = lookupIdentity(parent);
if (identity === null) break;
ancestors.push({ pid: parent, identity });
current = parent;
}
return ancestors;
}
function captureRenderAncestors(
options: RenderCancellationScopeOptions,
platform: NodeJS.Platform,
pid: number,
lookupIdentity: (pid: number) => string | null,
isAlive: (pid: number) => boolean,
): RenderAncestor[] {
if (options.detached ?? process.env.HYPERFRAMES_RENDER_DETACHED === "1") return [];
if (platform !== "linux" && options.parentPid === undefined && options.identity === undefined) {
return (options.ancestorSnapshot ?? processAncestorSnapshot)(pid);
}
return captureAncestors(pid, options.parentPid ?? processParentPid, lookupIdentity, isAlive);
}
function installSignalHandlers(
detached: boolean,
signalTarget: RenderSignalTarget,
abort: (reason: string) => void,
): Map<RenderCancellationSignal, () => void> {
const signalHandlers = new Map<RenderCancellationSignal, () => void>();
const handledSignals = detached
? RENDER_CANCELLATION_SIGNALS.filter((signal) => signal !== "SIGHUP")
: RENDER_CANCELLATION_SIGNALS;
for (const signal of handledSignals) {
const handler = () => abort(`render_cancelled_by_${signal.toLowerCase()}`);
signalHandlers.set(signal, handler);
signalTarget.on(signal, handler);
}
return signalHandlers;
}
export function createRenderCancellationScope(
options: RenderCancellationScopeOptions = {},
): RenderCancellationScope {
const controller = new AbortController();
const signalTarget = options.signalTarget ?? process;
const abort = (reason: string): void => {
if (!controller.signal.aborted) controller.abort(new Error(reason));
};
const detached = options.detached ?? process.env.HYPERFRAMES_RENDER_DETACHED === "1";
const signalHandlers = installSignalHandlers(detached, signalTarget, abort);
const lookupIdentity = options.identity ?? processIdentity;
const isAlive = options.isAlive ?? processIsAlive;
const platform = options.platform ?? process.platform;
const pid = options.pid ?? process.pid;
const ancestors = captureRenderAncestors(options, platform, pid, lookupIdentity, isAlive);
const checkAncestorLiveness = (): boolean => {
for (const ancestor of ancestors) {
if (!isAlive(ancestor.pid)) {
abort("render_cancelled_parent_exited");
return false;
}
}
return true;
};
const checkAncestors = (): void => {
if (!checkAncestorLiveness()) return;
if (platform !== "linux") return;
for (const ancestor of ancestors) {
const currentIdentity = lookupIdentity(ancestor.pid);
if (currentIdentity !== null && currentIdentity !== ancestor.identity) {
abort("render_cancelled_parent_exited");
return;
}
}
};
const defaultPollIntervalMs = ancestors.length > 0 && platform === "linux" ? 250 : 2_000;
const watchdogTimer = setInterval(
platform === "linux" ? checkAncestors : () => void checkAncestorLiveness(),
options.parentPollIntervalMs ?? defaultPollIntervalMs,
);
let disposed = false;
return {
signal: controller.signal,
checkAncestors,
dispose: () => {
if (disposed) return;
disposed = true;
if (watchdogTimer !== undefined) clearInterval(watchdogTimer);
for (const [signal, handler] of signalHandlers) signalTarget.off(signal, handler);
},
};
}
+1
View File
@@ -15,6 +15,7 @@ export default defineConfig({
cli: "src/cli.ts",
fontLocalizeCli: "src/fontLocalizeCli.ts",
runtimeVersion: "src/runtimeVersion.ts",
renderSetupWorker: "src/renderSetupWorker.ts",
shaderTransitionWorker: "../producer/src/services/shaderTransitionWorker.ts",
},
format: ["esm"],
@@ -363,3 +363,11 @@ export class ArtifactTransaction {
}
}
}
export async function commitArtifactTransaction(
transaction: ArtifactTransaction,
assertNotAborted: () => void,
): Promise<void> {
assertNotAborted();
await transaction.commit();
}
@@ -0,0 +1,131 @@
import { afterEach, expect, it, vi } from "vitest";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
vi.mock("@hyperframes/engine", async (importOriginal) => ({
...(await importOriginal<typeof import("@hyperframes/engine")>()),
assertConfiguredFfmpegBinariesExist: () => {},
resolveBrowserGpuMode: async () => "software",
resolveHeadlessShellPath: () => process.execPath,
}));
vi.mock("./fileServer.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./fileServer.js")>()),
createFileServer: async () => ({
url: "http://127.0.0.1:1",
port: 1,
close: () => {},
addPreHeadScript: () => {},
}),
}));
vi.mock("./render/stages/compileStage.js", () => ({
runCompileStage: async () => ({
compiled: {
html: "<div></div>",
hasShaderTransitions: false,
renderModeHints: { reasons: [] },
},
composition: { width: 16, height: 16, duration: 1 / 30, videos: [], images: [], audios: [] },
deviceScaleFactor: 1,
outputWidth: 16,
outputHeight: 16,
compileOnlyMs: 0,
forceScreenshot: true,
}),
}));
vi.mock("./render/stages/probeStage.js", () => ({
runProbeStage: async ({ compiled }: { compiled: object }) => ({
compiled,
fileServer: null,
probeSession: null,
lastBrowserConsole: [],
duration: 1 / 30,
totalFrames: 1,
browserProbeMs: 0,
beginFrameStalled: false,
}),
}));
vi.mock("./render/stages/extractVideosStage.js", () => ({
runExtractVideosStage: async () => ({
extractionResult: null,
frameLookup: null,
videoReadinessSkipIds: [],
videoMetadataHints: [],
nativeHdrVideoIds: new Set(),
videoTransfers: new Map(),
nativeHdrImageIds: new Set(),
imageTransfers: new Map(),
hdrImageSrcPaths: new Map(),
imageColorSpaces: [],
videoExtractMs: 0,
failureToEnforce: null,
}),
}));
vi.mock("./render/stages/audioStage.js", () => ({
runAudioStage: async ({ workDir }: { workDir: string }) => ({
audioOutputPath: join(workDir, "audio.wav"),
hasAudio: false,
audioProcessMs: 0,
}),
}));
vi.mock("./render/stages/captureStage.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./render/stages/captureStage.js")>()),
runCaptureStage: async () => ({
workerCount: 1,
probeSession: null,
lastBrowserConsole: [],
}),
}));
vi.mock("./render/stages/encodeStage.js", () => ({
runEncodeStage: async ({ outputPath }: { outputPath: string }) => {
mkdirSync(outputPath, { recursive: true });
writeFileSync(join(outputPath, "frame_000001.png"), "new-render");
return { encodeMs: 0 };
},
}));
vi.mock("./render/perfSummary.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./render/perfSummary.js")>()),
buildRenderPerfSummary: (..._args: unknown[]) => ({}),
}));
import { createRenderJob, executeRenderJob } from "./renderOrchestrator.js";
const dirs: string[] = [];
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
it("executeRenderJob cannot publish when the final ancestor check cancels the render", async () => {
const root = mkdtempSync(join(tmpdir(), "hf-render-final-cancel-"));
dirs.push(root);
writeFileSync(join(root, "index.html"), "<div></div>");
const outputPath = join(root, "frames");
mkdirSync(outputPath);
writeFileSync(join(outputPath, "frame_000001.png"), "existing-render");
const controller = new AbortController();
const checkAncestors = vi.fn(() => controller.abort(new Error("parent-exited")));
const job = createRenderJob({
fps: 30,
quality: "draft",
format: "png-sequence",
hdrMode: "force-sdr",
workers: 1,
logger: { info() {}, warn() {}, error() {}, debug() {} },
});
await expect(
executeRenderJob(job, root, outputPath, undefined, controller.signal, checkAncestors),
).rejects.toThrow("render_cancelled");
expect(checkAncestors).toHaveBeenCalledOnce();
expect(readFileSync(join(outputPath, "frame_000001.png"), "utf8")).toBe("existing-render");
});
@@ -116,7 +116,7 @@ import { buildRenderErrorDetails } from "./render/cleanup.js";
import { publishRenderFailure } from "./render/renderEventPublisher.js";
import { EncoderInterruptedError } from "./render/encoderInterruption.js";
import { RenderExecutionContext } from "./render/renderExecutionContext.js";
import { ArtifactTransaction } from "./render/artifactTransaction.js";
import { ArtifactTransaction, commitArtifactTransaction } from "./render/artifactTransaction.js";
import {
createCapturePlan,
replanAfterFailure,
@@ -2144,6 +2144,7 @@ export async function executeRenderJob(
outputPath: string,
progressSink?: ProgressCallback,
abortSignal?: AbortSignal,
assertRenderActive?: () => void,
): Promise<void> {
const moduleDir = dirname(fileURLToPath(import.meta.url));
const producerRoot = process.env.PRODUCER_RENDERS_DIR
@@ -2183,6 +2184,7 @@ export async function executeRenderJob(
logPath,
pipelineStart,
execution,
assertRenderActive,
});
} finally {
await execution.dispose();
@@ -2197,8 +2199,18 @@ async function executeRenderPipeline(input: {
logPath: string | null;
pipelineStart: number;
execution: RenderExecutionContext;
assertRenderActive?: () => void;
}): Promise<void> {
const { job, projectDir, outputPath, workDir, logPath, pipelineStart, execution } = input;
const {
job,
projectDir,
outputPath,
workDir,
logPath,
pipelineStart,
execution,
assertRenderActive,
} = input;
const log = execution.logger;
const eventPublisher = execution.events;
const onProgress = execution.onProgress;
@@ -4214,7 +4226,10 @@ async function executeRenderPipeline(input: {
}
}
await artifactTransaction.commit();
await commitArtifactTransaction(artifactTransaction, () => {
assertRenderActive?.();
assertNotAborted();
});
job.outputPath = outputPath;
updateJobStatus(job, "complete", "Render complete", 100, onProgress);
await eventPublisher.flush();