From 61880cdc0a95eab39075486b4d2a2fc35cbc1d5a Mon Sep 17 00:00:00 2001 From: James Date: Wed, 3 Jun 2026 23:11:22 -0700 Subject: [PATCH] fix(cli): support arm64 hosts for `--docker` render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/cli/src/commands/render.ts | 48 ++++++++++++++++--- packages/cli/src/docker/Dockerfile.render | 32 ++++++++++--- packages/cli/src/utils/dockerRunArgs.test.ts | 49 +++++++++++++++++++- packages/cli/src/utils/dockerRunArgs.ts | 22 ++++++++- 4 files changed, 135 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 4be88de16b..f3c21f91c8 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -49,7 +49,7 @@ import { maybePromptRenderFeedback } from "../telemetry/feedback.js"; import { bytesToMb } from "../telemetry/system.js"; import { VERSION } from "../version.js"; import { isDevMode } from "../utils/env.js"; -import { buildDockerRunArgs } from "../utils/dockerRunArgs.js"; +import { buildDockerRunArgs, resolveDockerPlatform } from "../utils/dockerRunArgs.js"; import { normalizeErrorMessage } from "../utils/errorMessage.js"; import { findFFmpeg, getFFmpegInstallHint } from "../browser/ffmpeg.js"; import type { RenderJob } from "@hyperframes/producer"; @@ -632,15 +632,23 @@ function dockerImageExists(tag: string): boolean { } } -function ensureDockerImage(version: string, quiet: boolean): string { - const tag = dockerImageTag(version); +function dockerImageTagForPlatform(version: string, platform: string): string { + // Suffix the tag with the arch so amd64 and arm64 images of the same + // hyperframes version coexist in the local cache (a developer who flips + // between hosts shouldn't have to rebuild). + const archSuffix = platform === "linux/arm64" ? "-arm64" : ""; + return `${dockerImageTag(version)}${archSuffix}`; +} + +function ensureDockerImage(version: string, platform: string, quiet: boolean): string { + const tag = dockerImageTagForPlatform(version, platform); if (dockerImageExists(tag)) { if (!quiet) console.log(c.dim(` Docker image: ${tag} (cached)`)); return tag; } - if (!quiet) console.log(c.dim(` Building Docker image: ${tag}...`)); + if (!quiet) console.log(c.dim(` Building Docker image: ${tag} (${platform})...`)); const dockerfilePath = resolveDockerfilePath(); @@ -649,16 +657,27 @@ function ensureDockerImage(version: string, quiet: boolean): string { mkdirSync(tmpDir, { recursive: true }); writeFileSync(join(tmpDir, "Dockerfile"), readFileSync(dockerfilePath)); - // linux/amd64 forced — chrome-headless-shell doesn't ship ARM Linux binaries + // Platform is now derived from the host arch (see resolveDockerPlatform). + // Apple Silicon and other arm64 hosts get a native linux/arm64 build; the + // Dockerfile skips chrome-headless-shell on arm64 and falls back to system + // chromium because chrome-headless-shell ships linux64 only. + // + // TARGETARCH is passed explicitly rather than relying on BuildKit's + // automatic platform args because the legacy builder (and some BuildKit + // configurations like colima 0.6.x) leaves it unset, which would defeat + // the arch conditional in the Dockerfile. + const targetArch = platform === "linux/arm64" ? "arm64" : "amd64"; try { execFileSync( "docker", [ "build", "--platform", - "linux/amd64", + platform, "--build-arg", `HYPERFRAMES_VERSION=${version}`, + "--build-arg", + `TARGETARCH=${targetArch}`, "-t", tag, tmpDir, @@ -689,9 +708,23 @@ async function renderDocker( console.log(c.dim(" Dev mode: using hyperframes@latest in Docker image")); } + const platform = resolveDockerPlatform(); + if (!options.quiet && platform === "linux/arm64") { + // chrome-headless-shell doesn't publish a linux-arm64 build, so the arm64 + // image falls back to system chromium. That loses byte-for-byte parity + // with amd64 renders — fine for end-user output, not fine if you're + // comparing against an amd64 golden baseline. + console.log( + c.dim( + " Host is arm64 — using linux/arm64 image with system chromium " + + "(output won't be byte-identical to amd64 renders).", + ), + ); + } + let imageTag: string; try { - imageTag = ensureDockerImage(dockerVersion, options.quiet); + imageTag = ensureDockerImage(dockerVersion, platform, options.quiet); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); const isDockerMissing = /connect|not found|ENOENT/i.test(message); @@ -712,6 +745,7 @@ async function renderDocker( projectDir: resolve(projectDir), outputDir: resolve(outputDir), outputFilename, + platform, options: { fps: options.fps, quality: options.quality, diff --git a/packages/cli/src/docker/Dockerfile.render b/packages/cli/src/docker/Dockerfile.render index ba3cb12c53..f00caf4d9a 100644 --- a/packages/cli/src/docker/Dockerfile.render +++ b/packages/cli/src/docker/Dockerfile.render @@ -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,30 @@ 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). +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; \ + else \ + printf '#!/bin/sh\nexec hyperframes render "$@"\n' > /usr/local/bin/hf-render; \ + fi \ && chmod +x /usr/local/bin/hf-render WORKDIR /project diff --git a/packages/cli/src/utils/dockerRunArgs.test.ts b/packages/cli/src/utils/dockerRunArgs.test.ts index 7409bdb851..6cea5e0f12 100644 --- a/packages/cli/src/utils/dockerRunArgs.test.ts +++ b/packages/cli/src/utils/dockerRunArgs.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { buildDockerRunArgs, type DockerRenderOptions } from "./dockerRunArgs.js"; +import { + buildDockerRunArgs, + resolveDockerPlatform, + type DockerRenderOptions, +} from "./dockerRunArgs.js"; const BASE: DockerRenderOptions = { fps: { num: 30, den: 1 }, @@ -18,6 +22,10 @@ const FIXED_INPUT = { projectDir: "/abs/proj", outputDir: "/abs/out", outputFilename: "out.mp4", + // Pin platform in tests so snapshots are arch-independent (otherwise they + // flip between linux/amd64 and linux/arm64 depending on the host running + // the test). + platform: "linux/amd64", }; describe("buildDockerRunArgs", () => { @@ -290,4 +298,43 @@ describe("buildDockerRunArgs", () => { const args = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE }); expect(args).not.toContain("--no-page-side-compositing"); }); + + // Regression for #1193: an arm64 host (Apple Silicon) was being pinned to + // linux/amd64, which forced qemu emulation of chrome-headless-shell and + // produced either navigation timeouts or chrome SEGVs. Each host arch must + // land in its native --platform value. + it("emits linux/arm64 when host platform is arm64", () => { + const args = buildDockerRunArgs({ + imageTag: "hyperframes-renderer:0.0.0-test", + projectDir: "/abs/proj", + outputDir: "/abs/out", + outputFilename: "out.mp4", + platform: "linux/arm64", + options: BASE, + }); + const idx = args.indexOf("--platform"); + expect(idx).toBeGreaterThanOrEqual(0); + expect(args[idx + 1]).toBe("linux/arm64"); + }); + + it("emits linux/amd64 when platform is explicitly amd64", () => { + const args = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE }); + const idx = args.indexOf("--platform"); + expect(idx).toBeGreaterThanOrEqual(0); + expect(args[idx + 1]).toBe("linux/amd64"); + }); +}); + +describe("resolveDockerPlatform", () => { + it("maps arm64 hosts to linux/arm64", () => { + expect(resolveDockerPlatform("arm64")).toBe("linux/arm64"); + }); + + it("maps x64 hosts to linux/amd64", () => { + expect(resolveDockerPlatform("x64")).toBe("linux/amd64"); + }); + + it("treats unknown architectures as linux/amd64 (safe default)", () => { + expect(resolveDockerPlatform("riscv64")).toBe("linux/amd64"); + }); }); diff --git a/packages/cli/src/utils/dockerRunArgs.ts b/packages/cli/src/utils/dockerRunArgs.ts index 5237036c88..12349cb149 100644 --- a/packages/cli/src/utils/dockerRunArgs.ts +++ b/packages/cli/src/utils/dockerRunArgs.ts @@ -17,6 +17,16 @@ export interface DockerRunArgsInput { outputDir: string; /** Filename within `outputDir` (joined to /output inside the container). */ outputFilename: string; + /** + * Docker `--platform` value (`linux/amd64` or `linux/arm64`). When omitted, + * resolves to the host architecture via `resolveDockerPlatform()`. Pinning + * to `linux/amd64` on an arm64 host (the legacy default) forces qemu + * emulation of chrome-headless-shell, which segfaults or stalls on Apple + * Silicon — see issue #1193. Native `linux/arm64` falls back to the + * system chromium baked into the image at the cost of byte-for-byte + * parity with amd64 renders. + */ + platform?: string; options: DockerRenderOptions; } @@ -44,13 +54,23 @@ export interface DockerRenderOptions { pageSideCompositing?: boolean; } +/** + * Maps Node's `process.arch` to a Docker `--platform` string. We only emit + * the two architectures the renderer actively supports — arm64 hosts (Apple + * Silicon, Graviton, Ampere) and everything else (treated as amd64). + */ +export function resolveDockerPlatform(arch: string = process.arch): string { + return arch === "arm64" ? "linux/arm64" : "linux/amd64"; +} + export function buildDockerRunArgs(input: DockerRunArgsInput): string[] { const { imageTag, projectDir, outputDir, outputFilename, options } = input; + const platform = input.platform ?? resolveDockerPlatform(); return [ "run", "--rm", "--platform", - "linux/amd64", + platform, "--shm-size=2g", // GPU encoding requires host GPU passthrough. ...(options.gpu ? ["--gpus", "all"] : []),