diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index 4ec46685d..5925d4bcb 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -28,6 +28,7 @@ import { killActiveServers, type FindPortResult, } from "../server/portUtils.js"; +import { killOrphanedProcesses, killProcessTree } from "../utils/orphanCleanup.js"; export default defineCommand({ meta: { name: "preview", description: "Start the studio for previewing compositions" }, @@ -96,6 +97,14 @@ export default defineCommand({ return; } + // Kill orphaned chrome-headless-shell processes from previous crashed sessions. + const orphansKilled = killOrphanedProcesses(); + if (orphansKilled > 0) { + console.log( + ` ${c.dim(`Cleaned up ${orphansKilled} orphaned process${orphansKilled === 1 ? "" : "es"} from a previous session.`)}`, + ); + } + const rawArg = args.dir; const dir = resolve(rawArg ?? "."); @@ -249,8 +258,16 @@ async function runDevMode( }); } - // Wait for child to exit. Ctrl+C sends SIGINT to the entire process group, - // so the child (Vite) receives it directly — no need to intercept or forward. + // Kill the child's entire process tree on SIGTERM/SIGINT. Ctrl+C sends + // SIGINT to the foreground process group (covers the common case), but + // `kill ` only targets this process — the child tree (Vite + Chrome) + // would survive without explicit cleanup. + const shutdown = () => { + if (child.pid) killProcessTree(child.pid); + }; + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); + return new Promise((resolve) => { child.on("close", () => resolve()); }); @@ -349,6 +366,12 @@ async function runLocalStudioMode( }); } + const shutdown = () => { + if (child.pid) killProcessTree(child.pid); + }; + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); + return new Promise((resolve) => { child.on("close", () => resolve()); }); @@ -477,19 +500,27 @@ async function runEmbeddedMode( shuttingDown = true; process.off("SIGINT", shutdown); process.off("SIGTERM", shutdown); - // Close the readline interface so a second Ctrl+C during the grace - // period below doesn't re-emit SIGINT and trigger Node's default - // exit-130 behaviour, contradicting our intent to exit cleanly. rl?.close(); - // `server.close()` can take a second or two to drain keep-alive - // connections; surface progress so the terminal doesn't look frozen. console.log(); console.log(` ${c.dim("Shutting down studio...")}`); - result.server.close(() => resolveRun()); - // If close() hangs on an open connection, force exit after a short - // grace period. Exit 0 because user-initiated Ctrl+C isn't an error - // — a non-zero code makes pnpm / npm print ELIFECYCLE. - setTimeout(() => process.exit(0), 2000).unref(); + + // Kill all child processes (browsers, ffmpeg) before closing the server. + // This is the centralized cleanup path — studioServer no longer registers + // its own per-process signal handlers. + const cleanup = async () => { + const { closeThumbnailBrowser } = await import("../server/studioServer.js"); + const { drainBrowserPool, killTrackedProcesses } = await import("@hyperframes/engine"); + await closeThumbnailBrowser().catch(() => {}); + await drainBrowserPool().catch(() => {}); + killTrackedProcesses(); + }; + + cleanup() + .catch(() => {}) + .finally(() => { + result.server.close(() => resolveRun()); + setTimeout(() => process.exit(0), 3000).unref(); + }); }; process.once("SIGINT", shutdown); process.once("SIGTERM", shutdown); diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 690db51ac..6837c5bfa 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -148,16 +148,6 @@ async function getThumbnailBrowser(): Promise { - const { releaseBrowser } = await import("@hyperframes/engine"); - if (_thumbnailBrowser) { - await releaseBrowser(_thumbnailBrowser).catch(() => {}); - _thumbnailBrowser = null; - } - }; - process.once("SIGTERM", () => void onExit()); - process.once("SIGINT", () => void onExit()); return _thumbnailBrowser; } catch (err) { console.warn( @@ -172,6 +162,15 @@ async function getThumbnailBrowser(): Promise { + if (!_thumbnailBrowser) return; + const browser = _thumbnailBrowser; + _thumbnailBrowser = null; + _thumbnailBrowserInitializing = null; + const { releaseBrowser } = await import("@hyperframes/engine"); + await releaseBrowser(browser).catch(() => {}); +} + // ── Server factory ────────────────────────────────────────────────────────── export interface StudioServerOptions { diff --git a/packages/cli/src/utils/orphanCleanup.ts b/packages/cli/src/utils/orphanCleanup.ts new file mode 100644 index 000000000..966ab8e7c --- /dev/null +++ b/packages/cli/src/utils/orphanCleanup.ts @@ -0,0 +1,110 @@ +import { execSync } from "node:child_process"; + +/** + * Find and kill orphaned Chrome processes from previous crashed sessions. + * Targets both chrome-headless-shell (production/CI) and Google Chrome + * launched by Puppeteer (dev mode). Puppeteer Chrome is identified by the + * `puppeteer_dev_chrome_profile` marker in its user-data-dir argument. + * + * An orphan is a process whose PPID=1 (reparented to init/launchd after + * its parent died). We kill the orphan's entire subtree so child helper + * processes (GPU, renderer, network, etc.) are also cleaned up. + * + * Returns the count of killed process trees. + */ +export function killOrphanedProcesses(): number { + if (process.platform === "win32") return 0; + + let killed = 0; + + // chrome-headless-shell: used in production/CI via the engine's browser manager. + for (const name of ["chrome-headless-shell", "chrome_headless_shell"]) { + killed += killOrphansByName(name); + } + + // Puppeteer-launched Chrome (dev mode): identified by the temp profile dir + // that Puppeteer creates. This avoids killing the user's real Chrome. + killed += killOrphansByName("puppeteer_dev_chrome_profile"); + + return killed; +} + +/** + * Kill an entire process tree rooted at `pid`. Walks descendants + * depth-first so children are killed before parents, preventing + * re-adoption races. + */ +export function killProcessTree(pid: number, signal: NodeJS.Signals = "SIGTERM"): void { + if (process.platform === "win32") return; + + const descendants = getDescendants(pid); + for (const child of descendants.reverse()) { + try { + process.kill(child, signal); + } catch { + // Already exited. + } + } + try { + process.kill(pid, signal); + } catch { + // Already exited. + } +} + +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 { + let pids: number[]; + try { + const raw = execSync(`pgrep -f ${processName}`, { + encoding: "utf-8", + timeout: 3000, + }).trim(); + if (!raw) return 0; + pids = raw + .split("\n") + .map((s) => parseInt(s, 10)) + .filter((n) => !isNaN(n) && n > 0); + } catch { + return 0; + } + + let killed = 0; + for (const pid of pids) { + if (!isOrphan(pid)) continue; + killProcessTree(pid); + killed++; + } + return killed; +} + +function isOrphan(pid: number): boolean { + try { + const ppid = execSync(`ps -p ${pid} -o ppid=`, { + encoding: "utf-8", + timeout: 2000, + }).trim(); + return ppid === "1"; + } catch { + return false; + } +} diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index e8f067c4f..8730b4c47 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -186,6 +186,8 @@ export { type RunFfmpegResult, } from "./utils/runFfmpeg.js"; +export { trackChildProcess, killTrackedProcesses } from "./utils/processTracker.js"; + export { decodePng, decodePngToRgb48le, diff --git a/packages/engine/src/services/chunkEncoder.ts b/packages/engine/src/services/chunkEncoder.ts index bed6cd6b3..592e8bc40 100644 --- a/packages/engine/src/services/chunkEncoder.ts +++ b/packages/engine/src/services/chunkEncoder.ts @@ -8,6 +8,7 @@ import { spawn } from "child_process"; import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "fs"; import { join, dirname } from "path"; +import { trackChildProcess } from "../utils/processTracker.js"; import { DEFAULT_CONFIG, type EngineConfig } from "../config.js"; import { type GpuEncoder, @@ -404,6 +405,7 @@ export async function encodeFramesFromDir( return new Promise((resolve) => { const ffmpeg = spawn("ffmpeg", args); + trackChildProcess(ffmpeg); let stderr = ""; const onAbort = () => { ffmpeg.kill("SIGTERM"); @@ -535,6 +537,7 @@ export async function encodeFramesChunkedConcat( const args = buildEncoderArgs(options, inputArgs, chunkPath, gpuEncoder); const chunkResult = await new Promise<{ success: boolean; error?: string }>((resolve) => { const ffmpeg = spawn("ffmpeg", args); + trackChildProcess(ffmpeg); let stderr = ""; ffmpeg.stderr.on("data", (d) => { stderr += d.toString(); @@ -578,6 +581,7 @@ export async function encodeFramesChunkedConcat( ]; const concatResult = await new Promise<{ success: boolean; error?: string }>((resolve) => { const ffmpeg = spawn("ffmpeg", concatArgs); + trackChildProcess(ffmpeg); let stderr = ""; ffmpeg.stderr.on("data", (d) => { stderr += d.toString(); diff --git a/packages/engine/src/services/streamingEncoder.ts b/packages/engine/src/services/streamingEncoder.ts index 9f2bcf81e..0c2c4dcf7 100644 --- a/packages/engine/src/services/streamingEncoder.ts +++ b/packages/engine/src/services/streamingEncoder.ts @@ -13,6 +13,7 @@ */ import { spawn, type ChildProcess } from "child_process"; +import { trackChildProcess } from "../utils/processTracker.js"; import { existsSync, mkdirSync, statSync } from "fs"; import { dirname } from "path"; @@ -375,6 +376,7 @@ export async function spawnStreamingEncoder( const ffmpeg: ChildProcess = spawn("ffmpeg", args, { stdio: ["pipe", "pipe", "pipe"], }); + trackChildProcess(ffmpeg); let exitStatus: "running" | "success" | "error" = "running"; let stderr = ""; diff --git a/packages/engine/src/services/videoFrameExtractor.ts b/packages/engine/src/services/videoFrameExtractor.ts index ef32c2ef2..d8af3b94b 100644 --- a/packages/engine/src/services/videoFrameExtractor.ts +++ b/packages/engine/src/services/videoFrameExtractor.ts @@ -9,6 +9,7 @@ import { spawn } from "child_process"; import { existsSync, mkdirSync, readdirSync, rmSync } from "fs"; import { isAbsolute, join, posix, resolve, sep } from "path"; import { parseHTML } from "linkedom"; +import { trackChildProcess } from "../utils/processTracker.js"; import { extractMediaMetadata, type VideoMetadata } from "../utils/ffprobe.js"; import { analyzeCompositionHdr, @@ -258,6 +259,7 @@ export async function extractVideoFramesRange( return new Promise((resolve, reject) => { const ffmpeg = spawn("ffmpeg", args); + trackChildProcess(ffmpeg); let stderr = ""; const onAbort = () => { ffmpeg.kill("SIGTERM"); diff --git a/packages/engine/src/utils/processTracker.ts b/packages/engine/src/utils/processTracker.ts new file mode 100644 index 000000000..660038ea3 --- /dev/null +++ b/packages/engine/src/utils/processTracker.ts @@ -0,0 +1,23 @@ +import type { ChildProcess } from "node:child_process"; + +const tracked = new Set(); + +export function trackChildProcess(proc: ChildProcess): void { + tracked.add(proc); + const remove = () => tracked.delete(proc); + proc.once("exit", remove); + proc.once("error", remove); +} + +export function killTrackedProcesses(signal: NodeJS.Signals = "SIGTERM"): void { + for (const proc of tracked) { + if (!proc.killed) { + try { + proc.kill(signal); + } catch { + // Best-effort — process may have already exited between the check and the kill. + } + } + } + tracked.clear(); +} diff --git a/packages/engine/src/utils/runFfmpeg.ts b/packages/engine/src/utils/runFfmpeg.ts index 949239dce..c5ccbdf66 100644 --- a/packages/engine/src/utils/runFfmpeg.ts +++ b/packages/engine/src/utils/runFfmpeg.ts @@ -6,6 +6,7 @@ */ import { spawn } from "child_process"; +import { trackChildProcess } from "./processTracker.js"; export interface RunFfmpegOptions { signal?: AbortSignal; @@ -60,6 +61,7 @@ export async function runFfmpeg(args: string[], opts?: RunFfmpegOptions): Promis return new Promise((resolve) => { const ffmpeg = spawn("ffmpeg", args); + trackChildProcess(ffmpeg); let stderr = ""; const onAbort = () => { diff --git a/packages/producer/src/services/audioExtractor.ts b/packages/producer/src/services/audioExtractor.ts index 0fa4fbf90..cffff0ddb 100644 --- a/packages/producer/src/services/audioExtractor.ts +++ b/packages/producer/src/services/audioExtractor.ts @@ -8,6 +8,7 @@ import { spawn } from "node:child_process"; import { existsSync, mkdirSync, rmSync, readFileSync } from "node:fs"; import { join, dirname } from "node:path"; +import { trackChildProcess } from "@hyperframes/engine"; export interface AudioElement { id: string; @@ -82,6 +83,7 @@ export function parseAudioElements(html: string): AudioElement[] { function runFFmpeg(args: string[]): Promise { return new Promise((resolve, reject) => { const ffmpeg = spawn("ffmpeg", args); + trackChildProcess(ffmpeg); let stderr = ""; ffmpeg.stderr.on("data", (data) => {