fix: address code review feedback on process cleanup

- Blocker: arm 3s force-exit timer BEFORE awaiting cleanup, not
  inside .finally(). Prevents hang if drainBrowserPool() blocks on
  dead Chrome.
- Reorder cleanup: killTrackedProcesses() (sync, fast) runs first,
  then async browser drain. Ffmpeg dies immediately instead of
  surviving if the hard timer fires early.
- SIGKILL escalation: processTracker now SIGTERMs all tracked
  processes, then SIGKILLs survivors after 500ms grace period.
- Scope pgrep to current user (pgrep -u $(id -u)) so orphan
  detection doesn't touch other users' Chrome on shared machines.
- Add process.on('exit') handler for crash paths (unhandled
  exceptions/rejections that bypass signal handlers).
- Document Windows no-op behavior on killProcessTree handlers.
This commit is contained in:
Miguel Ángel
2026-05-22 23:53:46 -04:00
parent e87f5bb769
commit 84edce908a
3 changed files with 63 additions and 12 deletions
+21 -5
View File
@@ -262,6 +262,8 @@ async function runDevMode(
// 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.
// On Windows, killProcessTree is a no-op (pgrep/ps unavailable); Ctrl+C
// propagates via the console process group instead.
const shutdown = () => {
if (child.pid) killProcessTree(child.pid);
};
@@ -366,6 +368,7 @@ async function runLocalStudioMode(
});
}
// Same tree-kill handler as dev mode. No-op on Windows (see comment above).
const shutdown = () => {
if (child.pid) killProcessTree(child.pid);
};
@@ -504,25 +507,38 @@ async function runEmbeddedMode(
console.log();
console.log(` ${c.dim("Shutting down studio...")}`);
// 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.
// Hard deadline: if cleanup hangs (e.g. dead Chrome never responds to
// browser.close()), force exit. Armed before awaiting cleanup so it
// can't be blocked by a stuck drainBrowserPool().
setTimeout(() => process.exit(0), 3000).unref();
// Kill ffmpeg first (sync, fast), then drain browsers (async, slower).
const cleanup = async () => {
const { closeThumbnailBrowser } = await import("../server/studioServer.js");
const { drainBrowserPool, killTrackedProcesses } = await import("@hyperframes/engine");
killTrackedProcesses();
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);
// Last-resort cleanup for crash paths (unhandled exceptions/rejections)
// that bypass the signal handlers. Eagerly resolve the sync killer so
// the 'exit' handler (which is synchronous) can call it directly.
import("@hyperframes/engine")
.then(({ killTrackedProcesses }) => {
process.once("exit", () => {
if (!shuttingDown) killTrackedProcesses();
});
})
.catch(() => {});
});
}
+21 -4
View File
@@ -10,6 +10,9 @@ import { execSync } from "node:child_process";
* its parent died). We kill the orphan's entire subtree so child helper
* processes (GPU, renderer, network, etc.) are also cleaned up.
*
* Scoped to the current user via `pgrep -u` to avoid touching other
* users' processes on shared machines.
*
* Returns the count of killed process trees.
*/
export function killOrphanedProcesses(): number {
@@ -17,13 +20,10 @@ export function killOrphanedProcesses(): number {
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;
@@ -33,6 +33,9 @@ export function killOrphanedProcesses(): number {
* Kill an entire process tree rooted at `pid`. Walks descendants
* depth-first so children are killed before parents, preventing
* re-adoption races.
*
* No-op on Windows — process groups are managed differently and
* the pgrep/ps utilities are not available.
*/
export function killProcessTree(pid: number, signal: NodeJS.Signals = "SIGTERM"): void {
if (process.platform === "win32") return;
@@ -73,9 +76,11 @@ function getDescendants(pid: number): number[] {
}
function killOrphansByName(processName: string): number {
const uid = getUid();
const userFlag = uid !== null ? `-u ${uid} ` : "";
let pids: number[];
try {
const raw = execSync(`pgrep -f ${processName}`, {
const raw = execSync(`pgrep ${userFlag}-f ${processName}`, {
encoding: "utf-8",
timeout: 3000,
}).trim();
@@ -97,6 +102,18 @@ function killOrphansByName(processName: string): number {
return killed;
}
let _cachedUid: string | null | undefined;
function getUid(): string | null {
if (_cachedUid !== undefined) return _cachedUid;
try {
_cachedUid = execSync("id -u", { encoding: "utf-8", timeout: 1000 }).trim();
} catch {
_cachedUid = null;
}
return _cachedUid;
}
function isOrphan(pid: number): boolean {
try {
const ppid = execSync(`ps -p ${pid} -o ppid=`, {