mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix: clean up orphaned Chrome and ffmpeg processes on preview exit
The preview command's shutdown handler only closed the HTTP server, leaving Chrome (browser pool) and ffmpeg processes alive. This caused silent resource leaks — orphaned processes consuming CPU and RAM with no parent. Root cause: preview.ts never called drainBrowserPool() or killed tracked ffmpeg processes. The thumbnail browser in studioServer.ts registered its own competing signal handlers that raced with preview's shutdown. Fix: - Add a central process tracker (processTracker.ts) that registers every spawned ffmpeg across engine and producer packages - Centralize thumbnail browser cleanup via exported closeThumbnailBrowser() instead of scattered signal handlers - Wire preview shutdown to call closeThumbnailBrowser(), drainBrowserPool(), and killTrackedProcesses() before closing the HTTP server (embedded mode) - Add killProcessTree() for dev/local modes where Chrome runs in a child process tree - Add startup orphan detection that finds and kills orphaned chrome-headless-shell/Puppeteer Chrome processes (PPID=1) from previously crashed sessions Closes #1038
This commit is contained in:
@@ -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 <pid>` 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<void>((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<void>((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);
|
||||
|
||||
@@ -148,16 +148,6 @@ async function getThumbnailBrowser(): Promise<import("puppeteer-core").Browser |
|
||||
_thumbnailBrowser = null;
|
||||
_thumbnailBrowserInitializing = null;
|
||||
});
|
||||
// Release the pool ref on process exit so the browser closes cleanly.
|
||||
const onExit = async () => {
|
||||
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<import("puppeteer-core").Browser |
|
||||
return _thumbnailBrowserInitializing;
|
||||
}
|
||||
|
||||
export async function closeThumbnailBrowser(): Promise<void> {
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user