Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/bump-ws-8-20-1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"miniflare": patch
"wrangler": patch
"@cloudflare/vite-plugin": patch
---

Bump `ws` from 8.18.0 to 8.20.1 to address GHSA-58qx-3vcg-4xpx

[GHSA-58qx-3vcg-4xpx](https://github.com/advisories/GHSA-58qx-3vcg-4xpx) / [CVE-2026-45736](https://www.cve.org/CVERecord?id=CVE-2026-45736) reports an uninitialized-memory disclosure in `ws@<8.20.1` when a `TypedArray` is passed as the reason argument to `WebSocket.close()`. The fix shipped in [ws@8.20.1](https://github.com/websockets/ws/commit/c0327ec15a54d701eb6ccefaa8bef328cfc03086) on 2026-05-12. This change bumps the workspace catalog entry so that `miniflare`, `wrangler`, and `@cloudflare/vite-plugin` all pick up the patched release.
12 changes: 12 additions & 0 deletions .changeset/dependabot-update-13977.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"miniflare": patch
"wrangler": patch
---

Update dependencies of "miniflare", "wrangler"

The following dependency versions have been updated:

| Dependency | From | To |
| ---------- | ------------ | ------------ |
| workerd | 1.20260518.1 | 1.20260519.1 |
11 changes: 11 additions & 0 deletions .changeset/miniflare-recover-from-corrupted-chrome-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"miniflare": patch
---

Recover from corrupted `@puppeteer/browsers` cache when launching a Browser Run session

When Miniflare's local Browser Run binding launches Chrome, it calls `@puppeteer/browsers`' `install()` to ensure the binary is present. If a previous `install()` was interrupted mid-extraction (test timeout, process kill, antivirus quarantine), the cache directory can be left partially populated — the folder exists but the executable inside it is missing. `install()` then throws `The browser folder (...) exists but the executable (...) is missing` on every subsequent call within the same process and the entire test session, breaking every later Browser Run operation until the cache is manually cleared.

`launchBrowser` now catches that specific error, removes the corrupted cache directory, and retries `install()` once. If the corruption persists after cleanup, the original error is rethrown with a clearer message.

This complements [#13971](https://github.com/cloudflare/workers-sdk/pull/13971), which surfaced the original error from inside the binding worker. With that diagnostic in place and this self-healing layer, the previously-intermittent "browser folder exists but executable missing" failure mode should no longer fail an entire CI run.
27 changes: 27 additions & 0 deletions .github/workflows/test-and-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,33 @@ jobs:
env:
GITHUB_TOKEN: ${{ github.token }}

# Browser Run tests in `packages/miniflare/test/plugins/browser/index.spec.ts`
# and the `fixtures/browser-run` fixture use `@puppeteer/browsers` to
# download Chrome into the global Wrangler cache. Caching that binary
# across CI runs avoids ~150 MB of repeat downloads per run and reduces
# the surface area for the intermittent partial-extraction race that
# surfaces as `The browser folder (...) exists but the executable (...)
# is missing` (see #13971, #13980).
#
# The Browser Run fixture is skipped on Ubuntu (AppArmor), but the
# miniflare browser spec runs everywhere, so the cache is needed on all
# three OSes for `packages-and-tools` and on Windows + macOS for
# `fixtures`.
- name: Restore Chrome browser cache (Browser Run)
if: steps.changes.outputs.everything_but_markdown == 'true' && (matrix.suite == 'packages-and-tools' || (matrix.suite == 'fixtures' && matrix.os != 'ubuntu-latest'))
uses: actions/cache@v4
with:
# The Chrome version lives in
# `packages/miniflare/src/plugins/browser-rendering/browser-version.ts`.
# Bumping that constant invalidates this cache automatically via
# `hashFiles()` — a cache miss only triggers a fresh download, no
# functional impact.
key: chrome-${{ runner.os }}-${{ hashFiles('packages/miniflare/src/plugins/browser-rendering/browser-version.ts') }}
path: |
~/.cache/.wrangler/chrome
~/Library/Caches/.wrangler/chrome
~/AppData/Local/xdg.cache/.wrangler/chrome

- name: Run tests (tools only)
# tools _only_ needs to be tested on Linux because they're only intended to run in CI
if: steps.changes.outputs.everything_but_markdown == 'true' && matrix.suite == 'packages-and-tools' && matrix.os == 'ubuntu-latest'
Expand Down
2 changes: 1 addition & 1 deletion packages/miniflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
"@cspotcode/source-map-support": "0.8.1",
"sharp": "^0.34.5",
"undici": "catalog:default",
"workerd": "1.20260518.1",
"workerd": "1.20260519.1",
"ws": "catalog:default",
"youch": "4.1.0-beta.10"
},
Expand Down
9 changes: 2 additions & 7 deletions packages/miniflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {
WORKFLOWS_PLUGIN_NAME,
} from "./plugins";
import { RPC_PROXY_SERVICE_NAME } from "./plugins/assets/constants";
import { BROWSER_VERSION } from "./plugins/browser-rendering/browser-version";
import {
CUSTOM_SERVICE_KNOWN_OUTBOUND,
CustomServiceKind,
Expand Down Expand Up @@ -1550,13 +1551,7 @@ export class Miniflare {
);
const { sessionId, browserProcess, startTime, wsEndpoint } =
await launchBrowser({
// Puppeteer v22.13.1 supported chrome version:
// https://pptr.dev/supported-browsers#supported-browser-version-list
//
// It should match the supported chrome version for the upstream puppeteer
// version from which @cloudflare/puppeteer branched off, which is specified in:
// https://github.com/cloudflare/puppeteer/?tab=readme-ov-file#workers-version-of-puppeteer-core
browserVersion: "126.0.6478.182",
browserVersion: BROWSER_VERSION,
log: this.#log,
tmpPath: this.#tmpPath,
headful,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* The Chrome browser version downloaded by Miniflare's Browser Run binding.
*
* Puppeteer v22.13.1 supported chrome version:
* https://pptr.dev/supported-browsers#supported-browser-version-list
*
* It should match the supported chrome version for the upstream puppeteer
* version from which @cloudflare/puppeteer branched off, which is specified in:
* https://github.com/cloudflare/puppeteer/?tab=readme-ov-file#workers-version-of-puppeteer-core
*
* Bumping this value also invalidates the Chrome binary cache in
* `.github/workflows/test-and-check.yml` (which uses `hashFiles()` on this file).
*/
export const BROWSER_VERSION = "126.0.6478.182";
81 changes: 76 additions & 5 deletions packages/miniflare/src/plugins/browser-rendering/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import { brandColor } from "@cloudflare/cli-shared-helpers/colors";
import { brandColor, dim, red } from "@cloudflare/cli-shared-helpers/colors";
import { spinner } from "@cloudflare/cli-shared-helpers/interactive";
import { removeDir } from "@cloudflare/workers-utils";
import {
Expand All @@ -11,7 +11,6 @@ import {
launch,
resolveBuildId,
} from "@puppeteer/browsers";
import { dim } from "kleur/colors";
import BROWSER_RENDERING_WORKER from "worker:browser-rendering/binding";
import { z } from "zod";
import { kVoid } from "../../runtime";
Expand All @@ -24,6 +23,7 @@ import {
} from "../shared";
import type { Log } from "../../shared";
import type { Plugin, RemoteProxyConnectionString } from "../shared";
import type { InstalledBrowser, InstallOptions } from "@puppeteer/browsers";

const BrowserRenderingSchema = z.object({
binding: z.string(),
Expand Down Expand Up @@ -138,20 +138,33 @@ export async function launchBrowser({
const s = spinner();
let startedDownloading = false;

const { executablePath } = await install({
const installOptions = {
browser,
platform,
cacheDir: getGlobalWranglerCachePath(),
buildId: await resolveBuildId(browser, platform, browserVersion),
downloadProgressCallback: (downloadedBytes, totalBytes) => {
downloadProgressCallback: (downloadedBytes: number, totalBytes: number) => {
if (!startedDownloading) {
s.start(`Downloading browser...`);
startedDownloading = true;
}
const progress = Math.round((downloadedBytes / totalBytes) * 100);
s.update(`Downloading browser... ${progress}%`);
},
});
};

let executablePath: string;
try {
({ executablePath } = await installWithCorruptedCacheRecovery(
installOptions,
log
));
} catch (e) {
if (startedDownloading) {
s.stop(`${red("failed")} ${dim(`browser download`)}`);
}
throw e;
}

if (startedDownloading) {
s.stop(`${brandColor("downloaded")} ${dim(`browser`)}`);
Expand Down Expand Up @@ -238,6 +251,64 @@ export async function launchBrowser({
return { sessionId, browserProcess, startTime, wsEndpoint };
}

/**
* Regex matching the `@puppeteer/browsers` error thrown when its cache
* directory exists but the executable inside it is missing — typically
* because a previous `install()` was interrupted mid-extraction (test
* timeout, process kill) or because an external agent (Windows Defender,
* antivirus, disk cleanup) removed the executable from a previously-good
* install.
*
* @puppeteer/browsers source:
* https://github.com/puppeteer/puppeteer/blob/main/packages/browsers/src/install.ts
*/
const CORRUPTED_CACHE_ERROR_PATTERN =
/The browser folder \((.+?)\) exists but the executable .+? is missing/;

/**
* Run `@puppeteer/browsers` `install()`, but if it fails with the
* "folder exists but executable is missing" error, clear the corrupted
* cache directory and retry once.
*
* Recovers from a known intermittent failure on CI runners (especially
* Windows) where the cache state can become partially populated and stay
* that way for the rest of the run, breaking every subsequent test until
* the runner is recycled.
*/
async function installWithCorruptedCacheRecovery(
installOptions: InstallOptions & { unpack?: true },
log: Log
): Promise<InstalledBrowser> {
try {
return await install(installOptions);
} catch (e) {
const match = (e as Error)?.message?.match(CORRUPTED_CACHE_ERROR_PATTERN);
if (!match) {
throw e;
}
const corruptedPath = match[1];
log.warn(
`Detected corrupted Chrome cache at ${corruptedPath}; clearing and retrying install.`
);
try {
await removeDir(corruptedPath);
} catch (cleanupError) {
throw new Error(
`Failed to clear corrupted Chrome cache at ${corruptedPath} after detecting "${(e as Error).message}". Manual cleanup may be required.`,
{ cause: cleanupError }
);
}
try {
return await install(installOptions);
} catch (retryError) {
throw new Error(
`Chrome install failed after clearing corrupted cache at ${corruptedPath}: ${(retryError as Error).message}`,
{ cause: retryError }
);
}
}
}

/**
* Probe Chrome's HTTP DevTools endpoint until it accepts connections.
*
Expand Down
4 changes: 2 additions & 2 deletions packages/wrangler/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
"miniflare": "workspace:*",
"path-to-regexp": "6.3.0",
"unenv": "2.0.0-rc.24",
"workerd": "1.20260518.1"
"workerd": "1.20260519.1"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.721.0",
Expand Down Expand Up @@ -153,7 +153,7 @@
"smol-toml": "catalog:default",
"source-map": "^0.6.1",
"supports-color": "^9.2.2",
"timeago.js": "^4.0.2",
"timeago.js": "4.0.2",
"tree-kill": "catalog:default",
"ts-dedent": "^2.2.0",
"ts-json-schema-generator": "^1.5.0",
Expand Down
Loading
Loading