Skip to content

Commit a54953b

Browse files
committed
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
1 parent 618ac7f commit a54953b

10 files changed

Lines changed: 199 additions & 22 deletions

File tree

packages/cli/src/commands/preview.ts

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
killActiveServers,
2929
type FindPortResult,
3030
} from "../server/portUtils.js";
31+
import { killOrphanedProcesses, killProcessTree } from "../utils/orphanCleanup.js";
3132

3233
export default defineCommand({
3334
meta: { name: "preview", description: "Start the studio for previewing compositions" },
@@ -96,6 +97,14 @@ export default defineCommand({
9697
return;
9798
}
9899

100+
// Kill orphaned chrome-headless-shell processes from previous crashed sessions.
101+
const orphansKilled = killOrphanedProcesses();
102+
if (orphansKilled > 0) {
103+
console.log(
104+
` ${c.dim(`Cleaned up ${orphansKilled} orphaned process${orphansKilled === 1 ? "" : "es"} from a previous session.`)}`,
105+
);
106+
}
107+
99108
const rawArg = args.dir;
100109
const dir = resolve(rawArg ?? ".");
101110

@@ -249,8 +258,16 @@ async function runDevMode(
249258
});
250259
}
251260

252-
// Wait for child to exit. Ctrl+C sends SIGINT to the entire process group,
253-
// so the child (Vite) receives it directly — no need to intercept or forward.
261+
// Kill the child's entire process tree on SIGTERM/SIGINT. Ctrl+C sends
262+
// SIGINT to the foreground process group (covers the common case), but
263+
// `kill <pid>` only targets this process — the child tree (Vite + Chrome)
264+
// would survive without explicit cleanup.
265+
const shutdown = () => {
266+
if (child.pid) killProcessTree(child.pid);
267+
};
268+
process.once("SIGINT", shutdown);
269+
process.once("SIGTERM", shutdown);
270+
254271
return new Promise<void>((resolve) => {
255272
child.on("close", () => resolve());
256273
});
@@ -349,6 +366,12 @@ async function runLocalStudioMode(
349366
});
350367
}
351368

369+
const shutdown = () => {
370+
if (child.pid) killProcessTree(child.pid);
371+
};
372+
process.once("SIGINT", shutdown);
373+
process.once("SIGTERM", shutdown);
374+
352375
return new Promise<void>((resolve) => {
353376
child.on("close", () => resolve());
354377
});
@@ -477,19 +500,27 @@ async function runEmbeddedMode(
477500
shuttingDown = true;
478501
process.off("SIGINT", shutdown);
479502
process.off("SIGTERM", shutdown);
480-
// Close the readline interface so a second Ctrl+C during the grace
481-
// period below doesn't re-emit SIGINT and trigger Node's default
482-
// exit-130 behaviour, contradicting our intent to exit cleanly.
483503
rl?.close();
484-
// `server.close()` can take a second or two to drain keep-alive
485-
// connections; surface progress so the terminal doesn't look frozen.
486504
console.log();
487505
console.log(` ${c.dim("Shutting down studio...")}`);
488-
result.server.close(() => resolveRun());
489-
// If close() hangs on an open connection, force exit after a short
490-
// grace period. Exit 0 because user-initiated Ctrl+C isn't an error
491-
// — a non-zero code makes pnpm / npm print ELIFECYCLE.
492-
setTimeout(() => process.exit(0), 2000).unref();
506+
507+
// Kill all child processes (browsers, ffmpeg) before closing the server.
508+
// This is the centralized cleanup path — studioServer no longer registers
509+
// its own per-process signal handlers.
510+
const cleanup = async () => {
511+
const { closeThumbnailBrowser } = await import("../server/studioServer.js");
512+
const { drainBrowserPool, killTrackedProcesses } = await import("@hyperframes/engine");
513+
await closeThumbnailBrowser().catch(() => {});
514+
await drainBrowserPool().catch(() => {});
515+
killTrackedProcesses();
516+
};
517+
518+
cleanup()
519+
.catch(() => {})
520+
.finally(() => {
521+
result.server.close(() => resolveRun());
522+
setTimeout(() => process.exit(0), 3000).unref();
523+
});
493524
};
494525
process.once("SIGINT", shutdown);
495526
process.once("SIGTERM", shutdown);

packages/cli/src/server/studioServer.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -148,16 +148,6 @@ async function getThumbnailBrowser(): Promise<import("puppeteer-core").Browser |
148148
_thumbnailBrowser = null;
149149
_thumbnailBrowserInitializing = null;
150150
});
151-
// Release the pool ref on process exit so the browser closes cleanly.
152-
const onExit = async () => {
153-
const { releaseBrowser } = await import("@hyperframes/engine");
154-
if (_thumbnailBrowser) {
155-
await releaseBrowser(_thumbnailBrowser).catch(() => {});
156-
_thumbnailBrowser = null;
157-
}
158-
};
159-
process.once("SIGTERM", () => void onExit());
160-
process.once("SIGINT", () => void onExit());
161151
return _thumbnailBrowser;
162152
} catch (err) {
163153
console.warn(
@@ -172,6 +162,15 @@ async function getThumbnailBrowser(): Promise<import("puppeteer-core").Browser |
172162
return _thumbnailBrowserInitializing;
173163
}
174164

165+
export async function closeThumbnailBrowser(): Promise<void> {
166+
if (!_thumbnailBrowser) return;
167+
const browser = _thumbnailBrowser;
168+
_thumbnailBrowser = null;
169+
_thumbnailBrowserInitializing = null;
170+
const { releaseBrowser } = await import("@hyperframes/engine");
171+
await releaseBrowser(browser).catch(() => {});
172+
}
173+
175174
// ── Server factory ──────────────────────────────────────────────────────────
176175

177176
export interface StudioServerOptions {
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { execSync } from "node:child_process";
2+
3+
/**
4+
* Find and kill orphaned Chrome processes from previous crashed sessions.
5+
* Targets both chrome-headless-shell (production/CI) and Google Chrome
6+
* launched by Puppeteer (dev mode). Puppeteer Chrome is identified by the
7+
* `puppeteer_dev_chrome_profile` marker in its user-data-dir argument.
8+
*
9+
* An orphan is a process whose PPID=1 (reparented to init/launchd after
10+
* its parent died). We kill the orphan's entire subtree so child helper
11+
* processes (GPU, renderer, network, etc.) are also cleaned up.
12+
*
13+
* Returns the count of killed process trees.
14+
*/
15+
export function killOrphanedProcesses(): number {
16+
if (process.platform === "win32") return 0;
17+
18+
let killed = 0;
19+
20+
// chrome-headless-shell: used in production/CI via the engine's browser manager.
21+
for (const name of ["chrome-headless-shell", "chrome_headless_shell"]) {
22+
killed += killOrphansByName(name);
23+
}
24+
25+
// Puppeteer-launched Chrome (dev mode): identified by the temp profile dir
26+
// that Puppeteer creates. This avoids killing the user's real Chrome.
27+
killed += killOrphansByName("puppeteer_dev_chrome_profile");
28+
29+
return killed;
30+
}
31+
32+
/**
33+
* Kill an entire process tree rooted at `pid`. Walks descendants
34+
* depth-first so children are killed before parents, preventing
35+
* re-adoption races.
36+
*/
37+
export function killProcessTree(pid: number, signal: NodeJS.Signals = "SIGTERM"): void {
38+
if (process.platform === "win32") return;
39+
40+
const descendants = getDescendants(pid);
41+
for (const child of descendants.reverse()) {
42+
try {
43+
process.kill(child, signal);
44+
} catch {
45+
// Already exited.
46+
}
47+
}
48+
try {
49+
process.kill(pid, signal);
50+
} catch {
51+
// Already exited.
52+
}
53+
}
54+
55+
function getDescendants(pid: number): number[] {
56+
let children: number[];
57+
try {
58+
const raw = execSync(`pgrep -P ${pid}`, { encoding: "utf-8", timeout: 2000 }).trim();
59+
if (!raw) return [];
60+
children = raw
61+
.split("\n")
62+
.map((s) => parseInt(s, 10))
63+
.filter((n) => !isNaN(n) && n > 0);
64+
} catch {
65+
return [];
66+
}
67+
const all: number[] = [];
68+
for (const child of children) {
69+
all.push(child);
70+
all.push(...getDescendants(child));
71+
}
72+
return all;
73+
}
74+
75+
function killOrphansByName(processName: string): number {
76+
let pids: number[];
77+
try {
78+
const raw = execSync(`pgrep -f ${processName}`, {
79+
encoding: "utf-8",
80+
timeout: 3000,
81+
}).trim();
82+
if (!raw) return 0;
83+
pids = raw
84+
.split("\n")
85+
.map((s) => parseInt(s, 10))
86+
.filter((n) => !isNaN(n) && n > 0);
87+
} catch {
88+
return 0;
89+
}
90+
91+
let killed = 0;
92+
for (const pid of pids) {
93+
if (!isOrphan(pid)) continue;
94+
killProcessTree(pid);
95+
killed++;
96+
}
97+
return killed;
98+
}
99+
100+
function isOrphan(pid: number): boolean {
101+
try {
102+
const ppid = execSync(`ps -p ${pid} -o ppid=`, {
103+
encoding: "utf-8",
104+
timeout: 2000,
105+
}).trim();
106+
return ppid === "1";
107+
} catch {
108+
return false;
109+
}
110+
}

packages/engine/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,8 @@ export {
186186
type RunFfmpegResult,
187187
} from "./utils/runFfmpeg.js";
188188

189+
export { trackChildProcess, killTrackedProcesses } from "./utils/processTracker.js";
190+
189191
export {
190192
decodePng,
191193
decodePngToRgb48le,

packages/engine/src/services/chunkEncoder.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import { spawn } from "child_process";
99
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "fs";
1010
import { join, dirname } from "path";
11+
import { trackChildProcess } from "../utils/processTracker.js";
1112
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
1213
import {
1314
type GpuEncoder,
@@ -404,6 +405,7 @@ export async function encodeFramesFromDir(
404405

405406
return new Promise((resolve) => {
406407
const ffmpeg = spawn("ffmpeg", args);
408+
trackChildProcess(ffmpeg);
407409
let stderr = "";
408410
const onAbort = () => {
409411
ffmpeg.kill("SIGTERM");
@@ -535,6 +537,7 @@ export async function encodeFramesChunkedConcat(
535537
const args = buildEncoderArgs(options, inputArgs, chunkPath, gpuEncoder);
536538
const chunkResult = await new Promise<{ success: boolean; error?: string }>((resolve) => {
537539
const ffmpeg = spawn("ffmpeg", args);
540+
trackChildProcess(ffmpeg);
538541
let stderr = "";
539542
ffmpeg.stderr.on("data", (d) => {
540543
stderr += d.toString();
@@ -578,6 +581,7 @@ export async function encodeFramesChunkedConcat(
578581
];
579582
const concatResult = await new Promise<{ success: boolean; error?: string }>((resolve) => {
580583
const ffmpeg = spawn("ffmpeg", concatArgs);
584+
trackChildProcess(ffmpeg);
581585
let stderr = "";
582586
ffmpeg.stderr.on("data", (d) => {
583587
stderr += d.toString();

packages/engine/src/services/streamingEncoder.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
*/
1414

1515
import { spawn, type ChildProcess } from "child_process";
16+
import { trackChildProcess } from "../utils/processTracker.js";
1617
import { existsSync, mkdirSync, statSync } from "fs";
1718
import { dirname } from "path";
1819

@@ -375,6 +376,7 @@ export async function spawnStreamingEncoder(
375376
const ffmpeg: ChildProcess = spawn("ffmpeg", args, {
376377
stdio: ["pipe", "pipe", "pipe"],
377378
});
379+
trackChildProcess(ffmpeg);
378380

379381
let exitStatus: "running" | "success" | "error" = "running";
380382
let stderr = "";

packages/engine/src/services/videoFrameExtractor.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { spawn } from "child_process";
99
import { existsSync, mkdirSync, readdirSync, rmSync } from "fs";
1010
import { isAbsolute, join, posix, resolve, sep } from "path";
1111
import { parseHTML } from "linkedom";
12+
import { trackChildProcess } from "../utils/processTracker.js";
1213
import { extractMediaMetadata, type VideoMetadata } from "../utils/ffprobe.js";
1314
import {
1415
analyzeCompositionHdr,
@@ -258,6 +259,7 @@ export async function extractVideoFramesRange(
258259

259260
return new Promise((resolve, reject) => {
260261
const ffmpeg = spawn("ffmpeg", args);
262+
trackChildProcess(ffmpeg);
261263
let stderr = "";
262264
const onAbort = () => {
263265
ffmpeg.kill("SIGTERM");
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { ChildProcess } from "node:child_process";
2+
3+
const tracked = new Set<ChildProcess>();
4+
5+
export function trackChildProcess(proc: ChildProcess): void {
6+
tracked.add(proc);
7+
const remove = () => tracked.delete(proc);
8+
proc.once("exit", remove);
9+
proc.once("error", remove);
10+
}
11+
12+
export function killTrackedProcesses(signal: NodeJS.Signals = "SIGTERM"): void {
13+
for (const proc of tracked) {
14+
if (!proc.killed) {
15+
try {
16+
proc.kill(signal);
17+
} catch {
18+
// Best-effort — process may have already exited between the check and the kill.
19+
}
20+
}
21+
}
22+
tracked.clear();
23+
}

packages/engine/src/utils/runFfmpeg.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
import { spawn } from "child_process";
9+
import { trackChildProcess } from "./processTracker.js";
910

1011
export interface RunFfmpegOptions {
1112
signal?: AbortSignal;
@@ -60,6 +61,7 @@ export async function runFfmpeg(args: string[], opts?: RunFfmpegOptions): Promis
6061

6162
return new Promise<RunFfmpegResult>((resolve) => {
6263
const ffmpeg = spawn("ffmpeg", args);
64+
trackChildProcess(ffmpeg);
6365
let stderr = "";
6466

6567
const onAbort = () => {

packages/producer/src/services/audioExtractor.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import { spawn } from "node:child_process";
99
import { existsSync, mkdirSync, rmSync, readFileSync } from "node:fs";
1010
import { join, dirname } from "node:path";
11+
import { trackChildProcess } from "@hyperframes/engine";
1112

1213
export interface AudioElement {
1314
id: string;
@@ -82,6 +83,7 @@ export function parseAudioElements(html: string): AudioElement[] {
8283
function runFFmpeg(args: string[]): Promise<void> {
8384
return new Promise((resolve, reject) => {
8485
const ffmpeg = spawn("ffmpeg", args);
86+
trackChildProcess(ffmpeg);
8587
let stderr = "";
8688

8789
ffmpeg.stderr.on("data", (data) => {

0 commit comments

Comments
 (0)