mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
co-authored by
miguel.sierra
parent
ef48485b91
commit
08d48ab753
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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}`]);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user