fix(engine): reuse HyperFrames browser cache (#2459)

* fix(engine): reuse HyperFrames browser cache

* test(engine): isolate browser cache home on Windows
This commit is contained in:
Miguel Ángel
2026-07-16 18:20:21 -04:00
committed by GitHub
parent 428e571914
commit f0aee28551
2 changed files with 117 additions and 18 deletions
@@ -1,4 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Browser, PuppeteerNode } from "puppeteer-core";
@@ -241,10 +245,13 @@ describe("resolveBrowserGpuMode", () => {
describe("resolveHeadlessShellPath", () => {
const originalHeadlessShellPath = process.env.PRODUCER_HEADLESS_SHELL_PATH;
const originalHyperframesBrowserPath = process.env.HYPERFRAMES_BROWSER_PATH;
afterEach(() => {
if (originalHeadlessShellPath === undefined) delete process.env.PRODUCER_HEADLESS_SHELL_PATH;
else process.env.PRODUCER_HEADLESS_SHELL_PATH = originalHeadlessShellPath;
if (originalHyperframesBrowserPath === undefined) delete process.env.HYPERFRAMES_BROWSER_PATH;
else process.env.HYPERFRAMES_BROWSER_PATH = originalHyperframesBrowserPath;
});
it("throws a clear error when PRODUCER_HEADLESS_SHELL_PATH points at a missing binary", () => {
@@ -254,6 +261,59 @@ describe("resolveHeadlessShellPath", () => {
/Chrome binary not found at PRODUCER_HEADLESS_SHELL_PATH/,
);
});
it("uses HYPERFRAMES_BROWSER_PATH when the CLI resolved a browser explicitly", () => {
const dir = mkdtempSync(join(tmpdir(), "hyperframes-engine-browser-env-"));
try {
const binary = join(dir, "chrome-headless-shell");
writeFileSync(binary, "");
delete process.env.PRODUCER_HEADLESS_SHELL_PATH;
process.env.HYPERFRAMES_BROWSER_PATH = binary;
expect(resolveHeadlessShellPath({})).toBe(binary);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("reuses chrome-headless-shell from the HyperFrames-managed cache", () => {
const home = mkdtempSync(join(tmpdir(), "hyperframes-engine-browser-cache-"));
try {
const binary = join(
home,
".cache",
"hyperframes",
"chrome",
"chrome-headless-shell",
"linux-152.0.7928.2",
"chrome-headless-shell-linux64",
"chrome-headless-shell",
);
mkdirSync(join(binary, ".."), { recursive: true });
writeFileSync(binary, "");
const olderBinary = binary.replace("linux-152.0.7928.2", "linux-99.0.1.1");
mkdirSync(join(olderBinary, ".."), { recursive: true });
writeFileSync(olderBinary, "");
// os.homedir() reads HOME on POSIX and USERPROFILE on Windows.
const env = { ...process.env, HOME: home, USERPROFILE: home };
delete env.PRODUCER_HEADLESS_SHELL_PATH;
delete env.HYPERFRAMES_BROWSER_PATH;
const moduleUrl = new URL("./browserManager.ts", import.meta.url).href;
const stdout = execFileSync(
"bun",
[
"--eval",
`import(${JSON.stringify(moduleUrl)}).then(({ resolveHeadlessShellPath }) => process.stdout.write(resolveHeadlessShellPath({}) ?? ""))`,
],
{ encoding: "utf8", env },
);
expect(stdout).toBe(binary);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
});
describe("forceReleaseBrowser", () => {
+57 -18
View File
@@ -112,10 +112,52 @@ async function probeHardwareWebGlInfo(
export type AcquiredBrowser = BrowserLease;
function compareBrowserVersionsDescending(left: string, right: string): number {
const parse = (value: string): number[] => {
const version = value.slice(value.indexOf("-") + 1);
const segments: number[] = [];
for (const segment of version.split(".")) {
const parsed = Number.parseInt(segment, 10);
if (!Number.isFinite(parsed)) break;
segments.push(parsed);
}
return segments;
};
const leftSegments = parse(left);
const rightSegments = parse(right);
const length = Math.max(leftSegments.length, rightSegments.length);
for (let index = 0; index < length; index += 1) {
const difference = (rightSegments[index] ?? 0) - (leftSegments[index] ?? 0);
if (difference !== 0) return difference;
}
return 0;
}
function findCachedHeadlessShell(baseDir: string): string | undefined {
if (!existsSync(baseDir)) return undefined;
try {
const versions = readdirSync(baseDir).sort(compareBrowserVersionsDescending);
for (const version of versions) {
const candidates = [
join(baseDir, version, "chrome-headless-shell-linux64", "chrome-headless-shell"),
join(baseDir, version, "chrome-headless-shell-mac-arm64", "chrome-headless-shell"),
join(baseDir, version, "chrome-headless-shell-mac-x64", "chrome-headless-shell"),
join(baseDir, version, "chrome-headless-shell-win64", "chrome-headless-shell.exe"),
];
for (const binary of candidates) {
if (existsSync(binary)) return binary;
}
}
} catch {
// Ignore unreadable cache directories and continue browser discovery.
}
return undefined;
}
/**
* Resolve chrome-headless-shell binary for deterministic BeginFrame rendering.
* Checks config.chromePath, then PRODUCER_HEADLESS_SHELL_PATH env var,
* then scans Puppeteer's managed cache at ~/.cache/puppeteer/chrome-headless-shell/.
* then the CLI browser override, HyperFrames' managed cache, and Puppeteer's cache.
*/
export function resolveHeadlessShellPath(
config?: Partial<Pick<EngineConfig, "chromePath">>,
@@ -133,25 +175,22 @@ export function resolveHeadlessShellPath(
}
return envPath;
}
const baseDir = join(homedir(), ".cache", "puppeteer", "chrome-headless-shell");
if (!existsSync(baseDir)) return undefined;
try {
const versions = readdirSync(baseDir).sort().reverse(); // newest first
for (const version of versions) {
const candidates = [
join(baseDir, version, "chrome-headless-shell-linux64", "chrome-headless-shell"),
join(baseDir, version, "chrome-headless-shell-mac-arm64", "chrome-headless-shell"),
join(baseDir, version, "chrome-headless-shell-mac-x64", "chrome-headless-shell"),
join(baseDir, version, "chrome-headless-shell-win64", "chrome-headless-shell.exe"),
];
for (const binary of candidates) {
if (existsSync(binary)) return binary;
}
if (process.env.HYPERFRAMES_BROWSER_PATH) {
const envPath = process.env.HYPERFRAMES_BROWSER_PATH;
if (!existsSync(envPath)) {
throw new Error(
`[BrowserManager] Chrome binary not found at HYPERFRAMES_BROWSER_PATH="${envPath}". ` +
"Run `hyperframes browser ensure` to re-download.",
);
}
} catch {
// ignore
return envPath;
}
return undefined;
const home = homedir();
return (
findCachedHeadlessShell(
join(home, ".cache", "hyperframes", "chrome", "chrome-headless-shell"),
) ?? findCachedHeadlessShell(join(home, ".cache", "puppeteer", "chrome-headless-shell"))
);
}
// Preserve the producer-era export so re-export shims keep the same public API.