Skip to content

Commit e8c2031

Browse files
[miniflare] Recover from corrupted @puppeteer/browsers cache in launchBrowser (#13980)
1 parent 9c4569f commit e8c2031

2 files changed

Lines changed: 87 additions & 5 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"miniflare": patch
3+
---
4+
5+
Recover from corrupted `@puppeteer/browsers` cache when launching a Browser Run session
6+
7+
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.
8+
9+
`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.
10+
11+
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.

packages/miniflare/src/plugins/browser-rendering/index.ts

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import fs from "node:fs";
22
import path from "node:path";
3-
import { brandColor } from "@cloudflare/cli-shared-helpers/colors";
3+
import { brandColor, dim, red } from "@cloudflare/cli-shared-helpers/colors";
44
import { spinner } from "@cloudflare/cli-shared-helpers/interactive";
55
import { removeDir } from "@cloudflare/workers-utils";
66
import {
@@ -11,7 +11,6 @@ import {
1111
launch,
1212
resolveBuildId,
1313
} from "@puppeteer/browsers";
14-
import { dim } from "kleur/colors";
1514
import BROWSER_RENDERING_WORKER from "worker:browser-rendering/binding";
1615
import { z } from "zod";
1716
import { kVoid } from "../../runtime";
@@ -24,6 +23,7 @@ import {
2423
} from "../shared";
2524
import type { Log } from "../../shared";
2625
import type { Plugin, RemoteProxyConnectionString } from "../shared";
26+
import type { InstalledBrowser, InstallOptions } from "@puppeteer/browsers";
2727

2828
const BrowserRenderingSchema = z.object({
2929
binding: z.string(),
@@ -138,20 +138,33 @@ export async function launchBrowser({
138138
const s = spinner();
139139
let startedDownloading = false;
140140

141-
const { executablePath } = await install({
141+
const installOptions = {
142142
browser,
143143
platform,
144144
cacheDir: getGlobalWranglerCachePath(),
145145
buildId: await resolveBuildId(browser, platform, browserVersion),
146-
downloadProgressCallback: (downloadedBytes, totalBytes) => {
146+
downloadProgressCallback: (downloadedBytes: number, totalBytes: number) => {
147147
if (!startedDownloading) {
148148
s.start(`Downloading browser...`);
149149
startedDownloading = true;
150150
}
151151
const progress = Math.round((downloadedBytes / totalBytes) * 100);
152152
s.update(`Downloading browser... ${progress}%`);
153153
},
154-
});
154+
};
155+
156+
let executablePath: string;
157+
try {
158+
({ executablePath } = await installWithCorruptedCacheRecovery(
159+
installOptions,
160+
log
161+
));
162+
} catch (e) {
163+
if (startedDownloading) {
164+
s.stop(`${red("failed")} ${dim(`browser download`)}`);
165+
}
166+
throw e;
167+
}
155168

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

254+
/**
255+
* Regex matching the `@puppeteer/browsers` error thrown when its cache
256+
* directory exists but the executable inside it is missing — typically
257+
* because a previous `install()` was interrupted mid-extraction (test
258+
* timeout, process kill) or because an external agent (Windows Defender,
259+
* antivirus, disk cleanup) removed the executable from a previously-good
260+
* install.
261+
*
262+
* @puppeteer/browsers source:
263+
* https://github.com/puppeteer/puppeteer/blob/main/packages/browsers/src/install.ts
264+
*/
265+
const CORRUPTED_CACHE_ERROR_PATTERN =
266+
/The browser folder \((.+?)\) exists but the executable .+? is missing/;
267+
268+
/**
269+
* Run `@puppeteer/browsers` `install()`, but if it fails with the
270+
* "folder exists but executable is missing" error, clear the corrupted
271+
* cache directory and retry once.
272+
*
273+
* Recovers from a known intermittent failure on CI runners (especially
274+
* Windows) where the cache state can become partially populated and stay
275+
* that way for the rest of the run, breaking every subsequent test until
276+
* the runner is recycled.
277+
*/
278+
async function installWithCorruptedCacheRecovery(
279+
installOptions: InstallOptions & { unpack?: true },
280+
log: Log
281+
): Promise<InstalledBrowser> {
282+
try {
283+
return await install(installOptions);
284+
} catch (e) {
285+
const match = (e as Error)?.message?.match(CORRUPTED_CACHE_ERROR_PATTERN);
286+
if (!match) {
287+
throw e;
288+
}
289+
const corruptedPath = match[1];
290+
log.warn(
291+
`Detected corrupted Chrome cache at ${corruptedPath}; clearing and retrying install.`
292+
);
293+
try {
294+
await removeDir(corruptedPath);
295+
} catch (cleanupError) {
296+
throw new Error(
297+
`Failed to clear corrupted Chrome cache at ${corruptedPath} after detecting "${(e as Error).message}". Manual cleanup may be required.`,
298+
{ cause: cleanupError }
299+
);
300+
}
301+
try {
302+
return await install(installOptions);
303+
} catch (retryError) {
304+
throw new Error(
305+
`Chrome install failed after clearing corrupted cache at ${corruptedPath}: ${(retryError as Error).message}`,
306+
{ cause: retryError }
307+
);
308+
}
309+
}
310+
}
311+
241312
/**
242313
* Probe Chrome's HTTP DevTools endpoint until it accepts connections.
243314
*

0 commit comments

Comments
 (0)