fix(cli): support arm64 hosts for --docker render (#1196)

* fix(cli): support arm64 hosts for `--docker` render

The Docker render path pinned `--platform linux/amd64` for both build
and run, which on Apple Silicon / Graviton forced qemu emulation of
chrome-headless-shell. The emulated chrome process either SEGV'd or
hung on page navigation, producing the failures reported in #1193 /
#1194 / #1195.

Derive the platform from `process.arch` instead. On arm64 hosts:

- The image builds natively (no qemu).
- The Dockerfile skips the chrome-headless-shell install because
  Chrome for Testing only publishes a `linux64` build (verified
  against the known-good-versions manifest).
- The wrapper script leaves `PRODUCER_HEADLESS_SHELL_PATH` unset
  when no headless-shell binary is present, so the engine falls
  back to the system chromium that the Dockerfile already
  installs from apt and points at via `PUPPETEER_EXECUTABLE_PATH`.

`TARGETARCH` is forwarded as an explicit `--build-arg` instead of
relying on BuildKit's automatic platform args — the legacy
builder (and some BuildKit configs, including colima on macOS)
leaves it unset, which would silently bypass the arch conditional
in the Dockerfile.

Image tags are now suffixed with `-arm64` on arm64 hosts so amd64
and arm64 images of the same hyperframes version can coexist in
the local cache.

The arm64 path renders correctly but loses byte-for-byte parity
with amd64 (system chromium uses screenshot capture, not
HeadlessExperimental.beginFrame). The CLI prints a one-line
warning so users comparing against amd64 baselines know.

Verified on macOS 26.5 / M4 Max:

- Before: `qemu: unknown option 'type=gpu-process'` followed by a
  chrome-headless-shell SIGSEGV after ~4 minutes.
- After: 300/300 frames captured in ~18s of render time (1m18s
  wallclock including a one-time image build), MP4 produced.

Closes #1193
Closes #1194
Closes #1195

* fix(cli): address review feedback on docker arm64 fix

Follow-up to 61880cdc. Addresses one substantive review comment from
@vanceingalls and three self-review gaps.

1. Restore loud build failure on amd64 when chrome-headless-shell is
   missing (per @vanceingalls). The original Dockerfile used an `&&`
   chain that crashed the build if `find` returned empty; the new
   `if/else` wrapper silently fell through to system chromium even on
   amd64, which would mask golden-baseline regressions from a future
   @puppeteer/browsers cache layout change. The else branch now checks
   `TARGETARCH = amd64` and exits 1 with an actionable error, while
   arm64 still falls through to the system-chromium wrapper cleanly.

2. Add `HYPERFRAMES_DOCKER_PLATFORM` env override. The fix derives
   platform from `process.arch`, which silently picks the wrong arch
   in three real-world cases: x64 Node under Rosetta on Apple Silicon
   (re-triggers issue #1193), parity-regen for amd64 golden baselines
   on an arm64 host, and DOCKER_HOST pointing at a remote daemon with
   a different arch. Empty/whitespace override is a no-op (falls back
   to arch detection) so `export FOO=""` doesn't pin platform to "".

3. Fail fast when `--gpu` is requested on arm64. Docker Desktop on
   Apple Silicon doesn't implement `--gpus` passthrough; the previous
   code would crash at `docker run` with an opaque device-driver
   error. We now short-circuit with errorBox pointing at the env
   override as the workaround.

4. Close the test gap on the default-arch resolution. Every previous
   test passed `arch` explicitly; a refactor that dropped the
   `= process.arch` default would pass all tests but break every arm64
   host at runtime. Added one assertion that calls
   `resolveDockerPlatform()` with no args, plus coverage for the env
   override.

The new arm64 platform-checking logic is extracted into
`resolveDockerHostPlatform()` so `renderDocker` itself stays focused
on the build/run wiring (and below the fallow complexity gate).

Test plan:
- `bunx vitest run packages/cli/src/utils/dockerRunArgs.test.ts` — 31 passed (was 27).
- `bunx vitest run packages/cli` — 647 passed (was 643).
- E2E on macOS 26.5 / M4 Max: deleted the cached arm64 image, ran
  `--docker --quality draft --workers 1` against the blank scaffold —
  300/300 frames in 1m1s wallclock, MP4 produced.
This commit is contained in:
James Russo
2026-06-04 03:13:44 -04:00
committed by GitHub
parent c6a91ab0e4
commit 2be41937a9
4 changed files with 225 additions and 16 deletions
+33 -7
View File
@@ -1,6 +1,10 @@
FROM node:22-bookworm-slim
ARG HYPERFRAMES_VERSION=latest
# Set automatically by `docker build --platform` (BuildKit); we use it to
# decide whether to install chrome-headless-shell, which only ships for
# linux64 (see https://googlechromelabs.github.io/chrome-for-testing/).
ARG TARGETARCH=amd64
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl unzip ffmpeg chromium \
@@ -16,16 +20,38 @@ ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
ENV CONTAINER=true
RUN npx --yes @puppeteer/browsers install chrome-headless-shell@stable \
--path /root/.cache/puppeteer
# chrome-headless-shell unlocks BeginFrame-based deterministic capture but the
# project only publishes a linux64 binary. On arm64 we skip the install and
# let the engine fall back to system chromium (set via
# PUPPETEER_EXECUTABLE_PATH above). The wrapper script below only sets
# PRODUCER_HEADLESS_SHELL_PATH when the binary is actually present.
RUN if [ "$TARGETARCH" = "amd64" ]; then \
npx --yes @puppeteer/browsers install chrome-headless-shell@stable \
--path /root/.cache/puppeteer; \
else \
echo "Skipping chrome-headless-shell install on ${TARGETARCH} (linux64-only); using system chromium."; \
fi
RUN npm install -g hyperframes@${HYPERFRAMES_VERSION}
# Wrapper script: resolves chrome-headless-shell path at build time,
# sets PRODUCER_HEADLESS_SHELL_PATH at runtime so the engine uses
# BeginFrame rendering instead of falling back to system Chromium.
RUN SHELL_PATH=$(find /root/.cache/puppeteer/chrome-headless-shell -name "chrome-headless-shell" -type f | head -1) \
&& printf '#!/bin/sh\nexport PRODUCER_HEADLESS_SHELL_PATH=%s\nexec hyperframes render "$@"\n' "$SHELL_PATH" > /usr/local/bin/hf-render \
# Wrapper script: resolves chrome-headless-shell path at build time when
# available so the engine uses BeginFrame rendering. On arm64 (no
# chrome-headless-shell) it leaves PRODUCER_HEADLESS_SHELL_PATH unset, so the
# engine falls back to PUPPETEER_EXECUTABLE_PATH (system chromium).
#
# If TARGETARCH=amd64 and the binary is missing, fail the build loudly — the
# previous (pre-PR) wrapper used an `&&` chain that crashed `docker build` in
# this case, and silently downgrading to system chromium on amd64 would mask
# golden-baseline regressions.
RUN SHELL_PATH=$(find /root/.cache/puppeteer/chrome-headless-shell -name "chrome-headless-shell" -type f 2>/dev/null | head -1); \
if [ -n "$SHELL_PATH" ]; then \
printf '#!/bin/sh\nexport PRODUCER_HEADLESS_SHELL_PATH=%s\nexec hyperframes render "$@"\n' "$SHELL_PATH" > /usr/local/bin/hf-render; \
elif [ "$TARGETARCH" = "amd64" ]; then \
echo "ERROR: chrome-headless-shell binary not found on amd64 — @puppeteer/browsers install must have failed or moved its cache layout." >&2; \
exit 1; \
else \
printf '#!/bin/sh\nexec hyperframes render "$@"\n' > /usr/local/bin/hf-render; \
fi \
&& chmod +x /usr/local/bin/hf-render
WORKDIR /project