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:
Miguel Ángel
2026-05-22 23:31:00 -04:00
parent 618ac7f5b3
commit a54953b936
10 changed files with 199 additions and 22 deletions
+110
View File
@@ -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;
}
}