fix(browser): don't let puppeteer kill Chrome during the shutdown drain; v1.14.0 - #46
Conversation
…in; v1.14.0
Puppeteer installs its own SIGTERM/SIGINT/SIGHUP listeners by default, and on
SIGTERM they close Chrome the instant the signal lands (SIGINT is worse:
process.exit(130) outright). That races the worker's graceful drain, so every
rollout produced two error signatures per in-flight render:
- "Attempted to use detached Frame" / "Target closed" out of the renderer's
page.evaluate, logged as `failed to render page`
- "Target.disposeBrowserContext … Failed to find context", logged as
`Failed to close context.` when the page-close handler then tried to
dispose a context whose browser was already gone
The drain in RenderWorker.shutdown could never actually succeed: in-flight
renders were lost and re-queued instead of posted back.
The worker owns Chrome's lifecycle, so it now launches with those handlers off
(workerLaunchOptions, applied last so a consumer-supplied browserLaunchOptions
can't reinstate the race). Nothing is orphaned: destroy() closes the browser,
ManagedBrowser.close() has its SIGKILL fallback, and the new
killBrowsersSync()/onForceExit covers the forced-exit paths that can't await.
The ErrorHandler backstop now lands strictly after the drain deadline so it
can't cut off the graceful close. When the embedding app owns signals
(installSignalHandlers: false), puppeteer's handlers are left in place.
Also, so the next teardown is legible rather than noisy:
- renders killed by our own shutdown count as failures.shutdownAborted and
log at warn, not as render failures
- a context close that fails with the browser already disconnected is debug,
not error
- shutdown logs whether it drained or hit the deadline
Separately, navigation.navigationTimeoutMs caps the initial goto on its own
(default 0 = off, prior behavior). Without it goto's timeout is the whole
remaining render budget, so a page that stalls before `waitUntil` holds a
concurrency slot for the full budget and leaves nothing for settle. Navigation
timeouts are now tagged with their phase and counted as failures.navTimeout,
which separates a stalled origin from slow in-browser settle in the stats.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a new navigationTimeoutMs configuration option to cap initial page navigation and fail fast, preventing stalled pages from consuming the entire render budget. It also refactors the shutdown process by disabling Puppeteer's default signal handlers when the worker owns signals, implementing a synchronous last-resort cleanup (killBrowsersSync) for forced exits, and adding detailed tracking for navigation timeouts and shutdown-aborted renders. Feedback suggests using a WeakMap instead of directly mutating caught exception objects to store error metadata, avoiding potential TypeErrors if the exception object is frozen.
| const PHASE_KEY = '__prerenderPhase'; | ||
|
|
||
| /** Tag `err` with the phase it came from and return it (so it can be re-thrown inline). */ | ||
| export function markRenderPhase<E>(err: E, phase: RenderPhase): E { | ||
| if (err !== null && typeof err === 'object') { | ||
| (err as Record<string, unknown>)[PHASE_KEY] = phase; | ||
| } | ||
| return err; | ||
| } | ||
|
|
||
| /** The phase tagged on `err`, or undefined if it carries none. */ | ||
| export function renderPhaseOf(err: unknown): RenderPhase | undefined { | ||
| if (err === null || typeof err !== 'object') return undefined; | ||
| const phase = (err as Record<string, unknown>)[PHASE_KEY]; | ||
| return phase === 'navigation' ? phase : undefined; | ||
| } |
There was a problem hiding this comment.
Mutating caught exception objects directly by adding arbitrary properties (err[PHASE_KEY] = phase) can throw a TypeError in strict mode if the exception object is frozen or non-extensible (e.g., via Object.freeze or Object.preventExtensions). Using a WeakMap to store metadata about errors is a much safer, non-intrusive pattern that completely avoids mutating the error object.
const phaseMap = new WeakMap<object, RenderPhase>();
/** Tag err with the phase it came from and return it (so it can be re-thrown inline). */
export function markRenderPhase<E>(err: E, phase: RenderPhase): E {
if (err !== null && typeof err === 'object') {
phaseMap.set(err, phase);
}
return err;
}
/** The phase tagged on err, or undefined if it carries none. */
export function renderPhaseOf(err: unknown): RenderPhase | undefined {
if (err === null || typeof err !== 'object') return undefined;
return phaseMap.get(err);
}The "Failed to close context" error also shows up away from worker shutdown, when a RETIRED browser is reaped — and there it was a real bug, not just noise. closeRetiredBrowsers() closed a browser when `activePages === 0 || jobRefs === 0`. Those counters reach zero at different times: render() dropped its job ref BEFORE awaiting the result POST and the page close, so the whole result-POST window sits at jobRefs === 0 with pages still open. A 10s cleanup tick landing in that window closed the browser under those pages, and their close handlers then failed to dispose contexts whose browser was already gone. - jobRefs is released in a `finally` after the POST and page close, so `jobRefs === 0` really means "nothing is touching this browser". - The reaper requires BOTH counters at zero, skips a browser whose close is already in flight, and force-closes after RETIRED_BROWSER_MAX_MS (120s) so stuck bookkeeping can't leak a Chrome process forever — with a warn line, since a stuck counter is a bug worth seeing. - retiredBrowsers is a Map<ManagedBrowser, retiredAt> to carry that deadline. - The context-close log now classifies by cause: a closing/disconnected browser, or an error saying the context/target/session/connection is already gone, is debug — nothing leaked. Only a live browser failing to dispose a context that still exists stays an error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second cause of
|
The bug
Two of the three error signatures seen in the fleet's logs are the same root cause. Both traces sit on the SIGTERM path (
handleTermination→Worker.shutdown→allSettled(inflight)):Puppeteer installs its own
SIGTERM/SIGINT/SIGHUPlisteners by default (handleSIGTERM = truein@puppeteer/browsers), and on SIGTERM the handler doesvoid this.close()— killing Chrome the moment the signal lands, concurrently with our graceful drain. NeitherdefaultLaunchOptions()nor any consumer'sbrowserLaunchOptionsturned them off.So on every rollout / scale-down: Chrome dies → in-flight renders throw detached Frame / Target closed and are logged as render failures → their pages emit
close→ the incognitocontext.close()hits a dead browser → the second error.RenderWorker.shutdown's drain — the whole reason in-flight renders are supposed to be posted back rather than dropped and re-queued — could never succeed. SIGINT never even reached the drain: puppeteer's handler callsprocess.exit(130).Reproduced, and the new test fails without the fix (
TargetCloseError: Protocol error (Runtime.callFunctionOn): Target closed).The fix
workerLaunchOptions(ownsSignals)(settings.ts) is now the single place the worker's launch options come from, and it turns puppeteer's three signal handlers off — applied last, so a consumer that suppliesbrowserLaunchOptionswholesale can't silently reinstate the race. Left untouched wheninstallSignalHandlers: false, since there puppeteer's handlers may be the only teardown.destroy()still closes the browser (withManagedBrowser.close()'s SIGKILL fallback), and the forced-exit paths that can't await — a second termination signal, or the drain blowing through the deadline — now runworker.killBrowsersSync()via a newErrorHandler.onForceExit.ErrorHandlerbackstop is nowdrain + 3sinstead of an equal 12s, so it can't fire in the same tick the drain gives up and cut off the graceful close.Legibility of the next teardown
failures.shutdownAborted, logged atwarn, not counted or logged as render failures. A rollout shouldn't read as a failure spike.debug. Only a live-browser failure is a real leak.worker shutdown completenow carriesdrained: true|false, with a warn line when the deadline expired.Separately:
navigation.navigationTimeoutMsThe third error —
Navigation timeout of 20000 ms exceededatpage.goto— is unrelated: 20s isrenderBudgetMs, i.e. the page never reacheddomcontentloadedwithin the entire render budget.goto's timeout being the whole remaining budget has two costs: a stalled page holds a concurrency slot for the full budget, and when it does eventually load there's nothing left for settle (every settle wait clamps to what remains).navigationTimeoutMscaps navigation on its own —0(default) keeps prior behavior. Navigation failures are also tagged with their phase (util/renderPhase.ts) and counted asfailures.navTimeout, so the stats separate a stalled origin from slow in-browser settle instead of pooling both infailures.timeout.A consumer that wants the fast-fail sets e.g.
navigation: { navigationTimeoutMs: 8000 }; nothing changes for consumers that don't.Testing
packages/browser/test/shutdownSignals.test.ts: the launch-option contract (both ownership modes + the consumer-can't-reinstate case), the phase tag, and a real-browser test that an in-flightpage.evaluatesurvives a SIGTERM. That last one fails withTargetCloseErrorif the flags are removed.config.test.ts:navigationTimeoutMsdefault,0allowed, negative/non-numeric rejected.npm run lintclean;format:checkreports only the pre-existing unformattedCLAUDE.md(untouched here on purpose — it holds a block kept in sync across repos).Notes for the reviewer
v1.13.0in its title, which is already released — whichever lands second needs a re-bump.terminationGracePeriodSecondsbefore rollout.processJobResultfiles that under "Unknown prerender error" and leaves a target-backed job to retry after its lease expires — no target deletion, no reschedule penalty — so this is noise, not damage, and fixing it belongs with a "don't post abandoned results" change.🤖 Generated with Claude Code