Skip to content

[miniflare] Recover from a partially downloaded Chrome install - #15206

Merged
petebacondarwin merged 4 commits into
mainfrom
fix/miniflare-chrome-install-race
Aug 16, 2026
Merged

petebacondarwin merged 4 commits into
mainfrom
fix/miniflare-chrome-install-race

Conversation

@petebacondarwin

@petebacondarwin petebacondarwin commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

Fixes the Windows CI failure in packages/miniflare/test/plugins/browser/index.spec.ts (Failed to launch the browser process! / Failed to load ...\resources.pak).

This reproduces on main and is unrelated to any single PR — see runs 94802577128, 94729853512, 94741594483.

Root cause

@puppeteer/browsers considers an install present as soon as the directory and executable exist. Chrome-for-Testing archives extract alphabetically, so an interrupted install leaves chrome.exe in place while resources.pak is still missing — satisfying install() while producing a Chrome that dies on startup. Every later launch reused that directory, so a single interrupted download poisoned the cache permanently.

Two things made this near-certain on Windows CI:

  1. The spec allows 20s per test, nowhere near enough for a cold ~150 MB download. The first test lost that race and was abandoned mid-extraction, leaving the rest of the file launching a half-written Chrome.
  2. The Chrome cache was never populated. actions/cache saves from a post step declaring post-if: success(), so any job failure skipped the save — and since test-and-check.yml only runs on pull_request/merge_group, nothing is ever written to refs/heads/main, the one ref every PR can restore from. So every PR got a cold cache, every time, and each cold run could re-poison it.

Existing mitigations missed it: CORRUPTED_CACHE_ERROR_PATTERN only matched "executable is missing", and the retry condition only matched the timeout — so it stopped retrying once the failure became an assertion.

Changes

  • Extract browser-rendering/install.ts; overlapping installs now share one download instead of racing to populate the same directory.
  • Mark an install only once Chrome has actually launched from it; on a launch failure, clear and re-download an unmarked install once. Known-good installs are left alone so real launch bugs still surface rather than being papered over with a 150 MB re-download.
  • Wait for the browser process to exit before clearing. A crashed Chrome keeps handles open on files inside its install directory on Windows, so removal failed and the corrupt install survived. An unclearable install is now reported on the error itself, because Miniflare logs to a no-op by default.
  • Download Chrome in a beforeAll with a 10 minute budget, so no test races the download.
  • Broaden the retry condition to cover the launch failure, not just the timeout.
  • Pair cache/restore with an explicit cache/save, gated on the completion marker so a partial install is never cached.
  • Make test-and-check-other-node.yml restore-only. It shares this cache key, and keys are write-once, so leaving it on the combined actions/cache would let it publish an unvalidated Chrome that the marker-gated save could then never replace.

Evidence

Measured with a temporary soak workflow (14 Windows jobs per run, since removed), comparing this branch against the same commit with the runtime fix reverted.

Corrupt install — delete resources.pak, keep chrome.exe, exactly reproducing the poisoned cache:

variant result final install size
baseline 0/4 passed 322M (still corrupt)
fixed 2/2 passed 331M (re-downloaded)

The fixed runs log ...which it has never successfully started from; clearing it, then re-download. Baseline fails with the exact CI error. An earlier iteration of the fix scored 1/2 here, which is what surfaced the Windows file-handle bug fixed above.

Cold cache — first test in the file, against its 20s timeout:

baseline fixed
first test (ms) 11607, 15822, 13124, 11713, 11999 1727, 4008, 1754, 3083, 1806
share of 20s budget 58–79% 9–20%

Both variants passed 10/10 in the soak: on an idle runner the download beats the timeout. The baseline still leaves as little as 4.2s of headroom on the good case, while real CI runs this suite alongside two other packages.

Cold cache, on real CI. The soak could never make the cold case actually fail. Real CI just did, and it produced a clean controlled comparison. The Windows cache entry has since expired, so both of the following jobs logged Cache not found for input keys: chrome-Windows-f12b2a29… and downloaded Chrome cold, on the same OS, at the same time, from the same base:

branch has this fix test/plugins/browser/index.spec.ts
this PR (job) yes 21 tests pass, 116560ms
#15209 (job) no 9 failed, Test timed out in 20000ms

#15209 is an unrelated test-output-noise PR that touches none of this code, so it shows what main does today on a cold cache: the first test blows its 20s budget mid-download, and the remaining tests then fail expect(text.includes("sessionId")).toBe(true) against a Chrome that never started — the exact cascade this PR fixes. It is the soak's 58–79%-of-budget figure tipping past 100% on a runner that is not idle.

This also means the failure is no longer merely latent: it is reproducing on main now that the cache has lapsed.

  • Tests
    • Tests included/updated
    • Automated tests not possible - manual testing has been completed as follows:
    • Additional testing not necessary because:
  • Public documentation
    • Cloudflare docs PR(s):
    • Documentation not necessary because: internal reliability fix with no user-facing interface change.

@changeset-bot

changeset-bot Bot commented Aug 15, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 66896bb

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 8 packages
Name Type
miniflare Patch
@cloudflare/deploy-helpers Patch
@cloudflare/pages-shared Patch
@cloudflare/remote-bindings Patch
@cloudflare/runtime-types Patch
@cloudflare/vite-plugin Patch
@cloudflare/vitest-pool-workers Patch
wrangler Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

✅ All changesets look good

ask-bonk[bot]

This comment was marked as resolved.

@ask-bonk

ask-bonk Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #15206. Summary of what I flagged:

  1. (High) Recovery-path concurrency race — discardIncompleteInstall in install.ts:164 calls removeDir(installDir) unconditionally, with no awareness of the inFlightInstalls map. Since launchBrowser runs per concurrent /browser/launch//v1/acquire request, two concurrent launches that both fail on a shared bad install can have the second call delete the first call's in-progress re-download (marker not yet written), corrupting it — reintroducing the very "racing to populate the same directory" bug this PR aims to fix. Posted an inline suggestion plus a note that a fully robust fix serializes discard+reinstall per installDir.

  2. (Low, already acknowledged) The temporary chrome-install-soak.yml workflow must be deleted before merge — noted only so it isn't forgotten when the PR leaves draft.

github run

@pkg-pr-new

pkg-pr-new Bot commented Aug 15, 2026 •

Copy link
Copy Markdown
@cloudflare/autoconfig

npm i https://pkg.pr.new/@cloudflare/autoconfig@15206

@cloudflare/build-output-utils

npm i https://pkg.pr.new/@cloudflare/build-output-utils@15206

@cloudflare/config

npm i https://pkg.pr.new/@cloudflare/config@15206

create-cloudflare

npm i https://pkg.pr.new/create-cloudflare@15206

@cloudflare/deploy-helpers

npm i https://pkg.pr.new/@cloudflare/deploy-helpers@15206

@cloudflare/kv-asset-handler

npm i https://pkg.pr.new/@cloudflare/kv-asset-handler@15206

miniflare

npm i https://pkg.pr.new/miniflare@15206

@cloudflare/pages-functions

npm i https://pkg.pr.new/@cloudflare/pages-functions@15206

@cloudflare/pages-shared

npm i https://pkg.pr.new/@cloudflare/pages-shared@15206

@cloudflare/unenv-preset

npm i https://pkg.pr.new/@cloudflare/unenv-preset@15206

@cloudflare/vite-plugin

npm i https://pkg.pr.new/@cloudflare/vite-plugin@15206

@cloudflare/vitest-pool-workers

npm i https://pkg.pr.new/@cloudflare/vitest-pool-workers@15206

@cloudflare/workers-auth

npm i https://pkg.pr.new/@cloudflare/workers-auth@15206

@cloudflare/workers-editor-shared

npm i https://pkg.pr.new/@cloudflare/workers-editor-shared@15206

@cloudflare/workers-utils

npm i https://pkg.pr.new/@cloudflare/workers-utils@15206

wrangler

npm i https://pkg.pr.new/wrangler@15206

commit: 66896bb

@petebacondarwin
petebacondarwin marked this pull request as ready for review August 15, 2026 14:52
@workers-devprod
workers-devprod requested review from a team and dario-piotrowicz and removed request for a team August 15, 2026 14:52
@workers-devprod

workers-devprod commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

Codeowners approval required for this PR:

  • ✅ @cloudflare/wrangler
Show detailed file reviewers

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@ask-bonk

ask-bonk Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

@petebacondarwin Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

devin-ai-integration[bot]

This comment was marked as resolved.

@petebacondarwin
petebacondarwin force-pushed the fix/miniflare-chrome-install-race branch from 1f00721 to 3a04bc1 Compare August 15, 2026 16:06
`@puppeteer/browsers` considers an install present as soon as the directory
and executable exist. Chrome-for-Testing archives extract alphabetically, so
an interrupted install leaves `chrome.exe` in place while `resources.pak` is
still missing, satisfying `install()` while producing a Chrome that dies on
startup with `Failed to load ...\resources.pak`. Every later launch reused
that directory, so one interrupted download poisoned the cache until it was
deleted by hand.

Two things made this near-certain on Windows CI. The browser spec allowed 20s
per test, nowhere near enough for a cold ~150 MB download, so the first test
lost that race and abandoned the install mid-extraction. And the Chrome cache
was never populated: `actions/cache` saves from a post step declaring
`post-if: success()`, so a failing job skipped the save, and since
`test-and-check.yml` runs only on `pull_request`/`merge_group` nothing is ever
written to `refs/heads/main` for a PR to restore from.

- Mark an install only once Chrome has actually launched from it, and on a
  startup failure clear and re-download an unmarked install once. Installs
  Chrome has started from before are left alone so real launch bugs surface.
- Only treat pre-banner failures as evidence about the install. Once Chrome
  has printed its DevTools endpoint it has loaded the resources a partial
  download would lack, so a readiness-probe timeout or a profile directory
  that cannot be created must not cost a 150 MB re-download.
- Share one download between overlapping installs, record a successful launch
  before the awaited marker write, and version each install directory, so a
  failing launch never deletes an install a concurrent session has just
  proven or replaced.
- Wait for Chrome to exit before clearing. A crashed Chrome holds handles on
  files inside its install directory on Windows, which made removal fail. An
  unclearable install is reported on the error itself, since Miniflare logs
  to a no-op by default.
- Give each launch attempt its own profile directory, so the retry cannot have
  it deleted by the previous attempt's unawaited cleanup.
- Download Chrome in a `beforeAll` with a 10 minute budget so no test races
  the download, and broaden the retry condition to cover the launch failure.
- Pair `cache/restore` with an explicit `cache/save`, gated on the completion
  marker so a partial install is never cached.
@petebacondarwin
petebacondarwin force-pushed the fix/miniflare-chrome-install-race branch from df7532e to 03b53bb Compare August 15, 2026 18:02
…cache

It shares its Chrome cache key with test-and-check.yml, which now saves only
once a marker proves Chrome launched from the install. Cache keys are
write-once, so an unvalidated save here could claim the key and the validated
save would never replace it. Restore only.

@dario-piotrowicz dario-piotrowicz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The changes here look good to me (although I didn't spend as much time reviewing as I would have liked), I left a few minor comments.

One thing concerns me a bit is the amount of complexity that we're adding here for such a simple feature... 😖 but I trust your judgement on this Pete of course 🙂

Comment thread .github/workflows/test-and-check-other-node.yml
Comment thread packages/miniflare/test/plugins/browser/index.spec.ts
Comment thread packages/miniflare/src/plugins/browser-rendering/install.ts Outdated
Comment thread packages/miniflare/src/plugins/browser-rendering/install.ts Outdated

@workers-devprod workers-devprod left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Codeowners reviews satisfied

`install.ts` was written as though it might be loaded from outside Miniflare,
so it hand-rolled a structural `InstallLog` rather than naming `Log`, and
exported types and functions nothing else refers to. Everything it
coordinates now lives in this plugin, and the module is not re-exported from
`plugins/index.ts`, so its only callers are the sibling `index.ts` and its
own spec.

- `InstallLog` is `Pick<Log, "warn" | "debug">`, matching `BrowserProcess` in
  `process.ts`. The comment justifying the structural type went with it: the
  standalone script it described does not exist.
- `ensureBrowserInstalled` hands back the installation as a small handle
  carrying `markVerified()` and `discard()`. Both need the generation of the
  directory the caller was given, captured before it launches, so making them
  methods removes both the `getInstallGeneration` export and the chance of
  taking that token too late.
- `DiscardResult` discriminates on `outcome` alone rather than on `cleared`
  plus `reason`, which lets the caller test the two failing outcomes directly
  instead of `!cleared && reason !== "superseded"`.
- Inline the single-use option and result types, and unexport the four
  functions the handle now covers. Ten exported names become two.
- Rename `installs.verified` to `installs.verifiedDirs` and reword its
  comment, since it holds directories rather than installations.

The discard tests now hold handles instead of generation numbers, which is
what they were always modelling: two sessions racing over one install.
@github-project-automation github-project-automation Bot moved this from Untriaged to Approved in workers-sdk Aug 16, 2026
devin-ai-integration[bot]

This comment was marked as resolved.

…rror

When a bad install cannot be removed, the error told the developer to delete
the directory by hand but dropped the reason Chrome would not start, which is
the part that says whether the install is really at fault.

`cause` cannot carry it: the loopback replies with `e?.stack ?? String(e)`, so
only the message and stack reach the caller, and Miniflare logs to a no-op by
default. Both failures therefore go in the message — the removal error inline
after "could not be removed", and Chrome's own message at the end, behind the
instruction that matters. `cause` keeps the removal error so `formatError`
still chains it wherever the log is real.

Reported by Devin. Its other point, that the message no longer matches the
spec's retry condition, does not hold: every acquire failure is wrapped as
"Failed to launch local browser via miniflare loopback (/browser/launch)",
which matches that condition whatever the inner message says.
@petebacondarwin
petebacondarwin merged commit 1277a72 into main Aug 16, 2026
57 of 58 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants