Skip to content

fix(browser): don't let puppeteer kill Chrome during the shutdown drain; v1.14.0 - #46

Merged
harper-joseph merged 2 commits into
mainfrom
fix/sigterm-drain
Jul 31, 2026
Merged

fix(browser): don't let puppeteer kill Chrome during the shutdown drain; v1.14.0#46
harper-joseph merged 2 commits into
mainfrom
fix/sigterm-drain

Conversation

@harper-joseph

Copy link
Copy Markdown
Contributor

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 (handleTerminationWorker.shutdownallSettled(inflight)):

"Attempted to use detached Frame 'C8D5…'"      at renderer.js:341 (page.evaluate)  → "failed to render page"
"Target.disposeBrowserContext: Failed to find context with id 9E29…"
                                               at ManagedBrowser.js:59 (context.close)  → "Failed to close context."

Puppeteer installs its own SIGTERM/SIGINT/SIGHUP listeners by default (handleSIGTERM = true in @puppeteer/browsers), and on SIGTERM the handler does void this.close() — killing Chrome the moment the signal lands, concurrently with our graceful drain. Neither defaultLaunchOptions() nor any consumer's browserLaunchOptions turned 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 incognito context.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 calls process.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 supplies browserLaunchOptions wholesale can't silently reinstate the race. Left untouched when installSignalHandlers: false, since there puppeteer's handlers may be the only teardown.
  • Nothing is orphaned: destroy() still closes the browser (with ManagedBrowser.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 run worker.killBrowsersSync() via a new ErrorHandler.onForceExit.
  • The ErrorHandler backstop is now drain + 3s instead 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

  • Renders killed by our own shutdown → failures.shutdownAborted, logged at warn, not counted or logged as render failures. A rollout shouldn't read as a failure spike.
  • A context close that fails while the browser is already disconnected → debug. Only a live-browser failure is a real leak.
  • worker shutdown complete now carries drained: true|false, with a warn line when the deadline expired.

Separately: navigation.navigationTimeoutMs

The third error — Navigation timeout of 20000 ms exceeded at page.goto — is unrelated: 20s is renderBudgetMs, i.e. the page never reached domcontentloaded within 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).

navigationTimeoutMs caps navigation on its own — 0 (default) keeps prior behavior. Navigation failures are also tagged with their phase (util/renderPhase.ts) and counted as failures.navTimeout, so the stats separate a stalled origin from slow in-browser settle instead of pooling both in failures.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-flight page.evaluate survives a SIGTERM. That last one fails with TargetCloseError if the flags are removed.
  • config.test.ts: navigationTimeoutMs default, 0 allowed, negative/non-numeric rejected.
  • Full suite green: 87 tests, 0 failures. npm run lint clean; format:check reports only the pre-existing unformatted CLAUDE.md (untouched here on purpose — it holds a block kept in sync across repos).

Notes for the reviewer

  • Version: v1.14.0. Note PR feat(browser): opt-in OpenTelemetry OTLP metrics export; v1.13.0 #30 also claims v1.13.0 in its title, which is already released — whichever lands second needs a re-bump.
  • Drain is 12s and the backstop 15s, both comfortably under a 30s termination grace period. Worth a glance at the deployment's terminationGracePeriodSeconds before rollout.
  • Deliberately unchanged: a shutdown-aborted render still POSTs its (empty) result. The plugin's processJobResult files 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

…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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +10 to +25
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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>
@harper-joseph

Copy link
Copy Markdown
Contributor Author

Second cause of Failed to close context. — the retirement reaper (581b346)

@joseph pointed out this error also fires away from worker shutdown, when a retired browser is closed. That one wasn't just noisy logging — it was a real bug, and the reason it's frequent:

closeRetiredBrowsers() closed a retired browser when activePages === 0 || browser.jobRefs === 0. Those two counters don't reach zero together. render() dropped its job ref at the end of the render, then awaited the result POST and the page close:

job.attemptEnded(error, content);
browser.jobRefs--;                                  // ← ref gone here

const closePromise = page ? browser.closePage(page) : Promise.resolve();
const [posted] = await Promise.all([sendPromise, closePromise]);   // ← page still open here

So the entire result-POST window sits at jobRefs === 0 with pages still open, and the either-or condition made that enough to close the browser. Any 10s cleanup tick landing in that window closed the browser out from under those pages; their close handlers then tried to dispose contexts whose browser was already gone. With N renders finishing together on a retired browser, that's N of these errors per retirement.

Changes:

  • jobRefs is released in a finally after the POST and page close, so jobRefs === 0 actually means "nothing is touching this browser".
  • The reaper requires both counters at zero, and skips a browser whose close() is already in flight (previously a tick could re-enter close() every 10s until it resolved).
  • Force-close backstop: retired browsers are closed regardless after RETIRED_BROWSER_MAX_MS (120s, ≫ a render + its POST), so if a counter ever gets stuck the process is reclaimed rather than leaked. It logs a warn — a stuck counter is a bug worth seeing, not something to swallow. retiredBrowsers became a Map<ManagedBrowser, retiredAt> to carry that deadline.
  • The log line now classifies by cause instead of by browser.connected (which is still true early in a browser.close(), so it wouldn't have caught this path): a closing/disconnected browser, or an error saying the context/target/session/connection is already gone, is debug — nothing leaked. A live browser failing to dispose a context that still exists stays an error, since that context survives until the browser exits.

Tests (test/browserRetirement.test.ts, 6 cases): not closed with an open page at zero refs (this one fails against the old ||), not closed with refs and no pages, reaped when both are zero, force-closed past the deadline, no re-entry while closing, and the error classifier against the exact production message shapes.

Full suite: 93 tests, 0 failures. Lint and format clean.

Worth noting the two causes are independent: the SIGTERM fix stops the rollout burst, this stops the steady-state trickle at every browser retirement (browserExpirationThreshold, default 200 pages).

@harper-joseph
harper-joseph merged commit f542c63 into main Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant