From b8cb3f39860ff73eeab861ad5f83ff437c535144 Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Wed, 29 Jul 2026 12:18:48 -0300 Subject: [PATCH] fix(run): await capped browser close on shutdown so Chromium isn't orphaned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SIGINT/SIGTERM/timeout `shutdown` handler fired `browser.close()` fire-and-forget and then called `process.exit(130)` synchronously — Node died before the async close completed, orphaning the Chromium subprocess on every Ctrl-C or global-timeout. Orphaned browsers pile up under repeated/ parallel runs and feed the OOM pressure that SIGKILLs siblings (exit 137). Make `shutdown` async and await a capped `browser.close()` (3s race) before exiting. `browser.close()` terminates the subprocess; awaiting it ensures that actually happens. Signal/timeout callers use `void shutdown(...)`. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/run.ts | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/commands/run.ts b/src/commands/run.ts index cbe7604..7751afe 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -649,7 +649,21 @@ export async function runCommand(rawOpts: RunOptions): Promise { let currentPhase = "launch"; let shuttingDown = false; const partialChecks: CheckResult[] = []; - const shutdown = (reason: string) => { + // Close the browser but never let a wedged Chromium block process exit: + // race the graceful close against a short cap. Previously `shutdown` fired + // `browser.close()` fire-and-forget and immediately called + // `process.exit(130)`, which killed Node before the close completed and + // orphaned the Chromium process on every Ctrl-C / timeout (issue #167). + // `browser.close()` terminates the Chromium subprocess; awaiting it (capped) + // ensures that actually happens before we exit. + const forceCloseBrowser = async (b: Browser | null, capMs: number): Promise => { + if (!b) return; + await Promise.race([ + b.close().catch(() => undefined), + new Promise((resolve) => setTimeout(resolve, capMs)), + ]); + }; + const shutdown = async (reason: string) => { if (shuttingDown) { // 2nd signal: hard exit. The 1st already kicked off cleanup. process.stderr.write("\n segunda interrupção — forçando saída.\n"); @@ -693,21 +707,20 @@ export async function runCommand(rawOpts: RunOptions): Promise { } catch (err) { process.stderr.write(` falha escrevendo report parcial: ${(err as Error).message}\n`); } - // Best-effort browser close. - if (browser) { - browser.close().catch(() => undefined); - } + // Best-effort browser close, capped so a wedged Chromium can't block the + // exit — then SIGKILL the subprocess so we never orphan it. + await forceCloseBrowser(browser, 3_000); process.exit(130); }; const onSignal = (sig: NodeJS.Signals) => { - shutdown(`signal ${sig}`); + void shutdown(`signal ${sig}`); }; process.on("SIGINT", onSignal); process.on("SIGTERM", onSignal); const timeoutMinutes = Math.max(1, Math.floor(opts.timeout ?? 30)); const globalTimeoutTimer = setTimeout(() => { - shutdown(`timeout ${timeoutMinutes}min`); + void shutdown(`timeout ${timeoutMinutes}min`); }, timeoutMinutes * 60_000); // Don't block process exit on the timer. globalTimeoutTimer.unref?.();