Skip to content

fix(cli): scan puppeteer cache for chrome-headless-shell; warn on system-chrome fallback#821

Merged
vanceingalls merged 2 commits into
mainfrom
vai/cli-chrome-binary-fix
May 15, 2026
Merged

fix(cli): scan puppeteer cache for chrome-headless-shell; warn on system-chrome fallback#821
vanceingalls merged 2 commits into
mainfrom
vai/cli-chrome-binary-fix

Conversation

@vanceingalls
Copy link
Copy Markdown
Collaborator

What

Two correctness fixes to packages/cli/src/browser/manager.ts so the CLI picks the right Chrome binary for any perf path that depends on chrome-headless-shell:

  1. Also scan the puppeteer-managed cache. findFromCache now reads from both ~/.cache/hyperframes/chrome (the CLI's own managed cache) and ~/.cache/puppeteer/chrome-headless-shell/<version>/<platform-dir>/chrome-headless-shell (the path layout that the engine's resolveHeadlessShellPath already reads from). When chrome-headless-shell is present in either cache, it now wins over system Chrome.
  2. Warn when falling through to a non-chrome-headless-shell system binary on Linux. A single one-time console.warn explains the perf consequence and points the user at npx @puppeteer/browsers install chrome-headless-shell. Linux-scoped because the BeginFrame perf path is Linux-only.

Why

Discovered in a recent spike on the BeginFrame perf path. On a clean install, the CLI's hyperframes-managed cache (~/.cache/hyperframes/chrome) is empty, so findFromCache returns undefined. The CLI then falls through to findFromSystem() and picks /usr/bin/google-chrome, exporting it to the engine via PRODUCER_HEADLESS_SHELL_PATH in render.ts. The engine receives that path, sees it's already set, and skips its own correct ~/.cache/puppeteer/chrome-headless-shell scan.

Regular Chrome (147+) has dropped HeadlessExperimental.enable. The engine's BeginFrame probe correctly catches this and silently falls back to screenshot mode — but the operator sees no signal, so any user who installed chrome-headless-shell via npx @puppeteer/browsers install (the standard puppeteer flow) silently loses the perf path.

This is a "two codepaths know about 'the chrome we ship' but look in different places" bug. The fix collapses them.

How

  • findFromCache now consults both caches. Hyperframes-managed cache wins when both contain a binary (preserves existing behavior).
  • New findFromPuppeteerCache mirrors resolveHeadlessShellPath from packages/engine/src/services/browserManager.ts — same path layout, same newest-first version sort. A comment in both files notes they need to move together if puppeteer ever changes the on-disk layout.
  • warnSystemFallbackOnce is gated on process.platform === "linux" and on the binary name (basename of the path being chrome-headless-shell/.exe). One-shot latch so a long-running hyperframes studio process isn't spammed. Exported test-reset helper _resetSystemFallbackWarnForTests for the unit tests.

No public-API changes. findBrowser, ensureBrowser, clearBrowser, setBrowserPath all keep the same signatures.

Test plan

  • Unit tests added (packages/cli/src/browser/manager.test.ts, 7 tests):
    • cache hit on hyperframes dir
    • cache hit on puppeteer dir (the new path)
    • newest-version preference when multiple versions are cached
    • system fallback + Linux warning emitted
    • no warning when the resolved path is itself chrome-headless-shell (e.g. HYPERFRAMES_BROWSER_PATH override)
    • no warning on macOS (Linux-only perf path)
    • one-time warning idempotency across repeated findBrowser() calls
  • bun run --filter @hyperframes/cli typecheck — passes
  • bun run --filter @hyperframes/cli test — 305/305 passing
  • bun run --filter @hyperframes/cli build — passes
  • Manual smoke on a Linux host with chrome-headless-shell in the puppeteer cache (skipped — sandbox already has both binaries and the unit tests cover the resolution logic deterministically; reviewers welcome to verify)

— Vai

…tem-chrome fallback

The CLI's `ensureBrowser` only scanned `~/.cache/hyperframes/chrome` for a
managed Chrome binary, which is empty on most clean installs. It then fell
through to `findFromSystem()` and picked `/usr/bin/google-chrome`, which the
engine receives via `PRODUCER_HEADLESS_SHELL_PATH`. Regular Chrome has
dropped `HeadlessExperimental.enable`, so the engine's probe correctly
detects this and falls back to screenshot mode — but the user sees no
signal, so any perf path depending on chrome-headless-shell silently
disappears.

Two fixes:

1. Also scan `~/.cache/puppeteer/chrome-headless-shell/<version>/...` (the
   path layout shared with the engine's `resolveHeadlessShellPath`). When
   chrome-headless-shell is present in either cache it gets picked over
   system Chrome.

2. When falling back to a non-`chrome-headless-shell` system binary on
   Linux, emit a single one-time `console.warn` pointing users at
   `npx @puppeteer/browsers install chrome-headless-shell`. Linux-scoped
   because the BeginFrame perf path is Linux-only.

Tests cover: hyperframes cache hit, puppeteer cache hit (the new path),
newest-version preference, system fallback + Linux warn, no warn for direct
headless-shell paths, no warn on macOS, one-time warning idempotency.

— Vai
The previous version hardcoded `/fake/home/...` literals. On Windows CI
`path.join` produces backslash-separated paths, so `existsSync(p)` lookups
against the forward-slash literal Set never matched and three cache-hit
tests failed.

Build the fake paths via `node:path.join` so the test uses the same
separator as the production code under test, regardless of host platform.

— Vai
vanceingalls added a commit that referenced this pull request May 14, 2026
Two correctness fixes from PR #821 self-review:

1. Cache priority order. Previous order was hyperframes-managed cache →
   puppeteer cache. HF cache is pinned to CHROME_VERSION (131-era) which
   lags 17+ releases behind upstream; if a user separately installed a
   newer chrome-headless-shell via @puppeteer/browsers install, the CLI
   would silently hand engine the older HF-cache binary while engine's
   own resolveHeadlessShellPath would have picked the newer one. Flip
   the priority so puppeteer cache wins, matching engine semantics.

2. Numeric (not lexicographic) version sort. `readdirSync.sort().reverse()`
   over names like `linux-148.0.7778.97` and `linux-99.0.6533.123` would
   return `linux-99...` first because character '9' outranks '1'. Parse
   each name into integer segments and compare them numerically.

Tests: add both-caches-populated and linux-148-beats-linux-99 cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vanceingalls vanceingalls force-pushed the vai/cli-chrome-binary-fix branch from 5bc730a to c6a9818 Compare May 14, 2026 23:42
Copy link
Copy Markdown
Collaborator Author

Copy link
Copy Markdown
Collaborator

@miguel-heygen miguel-heygen left a comment

Choose a reason for hiding this comment

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

Review: Chrome headless-shell cache scan

Dual-cache lookup is sound: hyperframes-managed → puppeteer cache → system fallback. Warning is properly Linux-scoped, binary-name-gated, and one-shot. 7 tests cover all branches.

Minor: version sort is lexicographic — works for 3-digit Chrome versions but would mis-sort 99.x vs 100.x. Low risk, worth a code comment.

LGTM.

@vanceingalls vanceingalls merged commit c1ba528 into main May 15, 2026
53 checks passed
@vanceingalls vanceingalls deleted the vai/cli-chrome-binary-fix branch May 15, 2026 00:09
vanceingalls added a commit that referenced this pull request May 15, 2026
Two correctness fixes from PR #821 self-review:

1. Cache priority order. Previous order was hyperframes-managed cache →
   puppeteer cache. HF cache is pinned to CHROME_VERSION (131-era) which
   lags 17+ releases behind upstream; if a user separately installed a
   newer chrome-headless-shell via @puppeteer/browsers install, the CLI
   would silently hand engine the older HF-cache binary while engine's
   own resolveHeadlessShellPath would have picked the newer one. Flip
   the priority so puppeteer cache wins, matching engine semantics.

2. Numeric (not lexicographic) version sort. `readdirSync.sort().reverse()`
   over names like `linux-148.0.7778.97` and `linux-99.0.6533.123` would
   return `linux-99...` first because character '9' outranks '1'. Parse
   each name into integer segments and compare them numerically.

Tests: add both-caches-populated and linux-148-beats-linux-99 cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

2 participants