mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 16:42:27 +00:00
fix(gcp): enforce effective BeginFrame capture
This commit is contained in:
@@ -35,6 +35,7 @@ jobs:
|
|||||||
cli: ${{ steps.filter.outputs.cli }}
|
cli: ${{ steps.filter.outputs.cli }}
|
||||||
skills: ${{ steps.filter.outputs.skills }}
|
skills: ${{ steps.filter.outputs.skills }}
|
||||||
codex_plugin: ${{ steps.filter.outputs.codex_plugin }}
|
codex_plugin: ${{ steps.filter.outputs.codex_plugin }}
|
||||||
|
gcp_beginframe: ${{ steps.filter.outputs.gcp_beginframe }}
|
||||||
steps:
|
steps:
|
||||||
# Force git-based change detection instead of the pull_request REST API.
|
# Force git-based change detection instead of the pull_request REST API.
|
||||||
# The API path can fail the whole workflow on transient listFiles
|
# The API path can fail the whole workflow on transient listFiles
|
||||||
@@ -76,6 +77,33 @@ jobs:
|
|||||||
- "scripts/package-codex-plugin.mjs"
|
- "scripts/package-codex-plugin.mjs"
|
||||||
- "package.json"
|
- "package.json"
|
||||||
- ".github/workflows/ci.yml"
|
- ".github/workflows/ci.yml"
|
||||||
|
gcp_beginframe:
|
||||||
|
- "packages/gcp-cloud-run/Dockerfile"
|
||||||
|
- "packages/aws-lambda/scripts/probe-beginframe.ts"
|
||||||
|
- "packages/engine/src/services/browserManager.ts"
|
||||||
|
- "package.json"
|
||||||
|
- "bun.lock"
|
||||||
|
- ".github/workflows/ci.yml"
|
||||||
|
|
||||||
|
gcp-beginframe-contract:
|
||||||
|
name: GCP BeginFrame image contract
|
||||||
|
needs: changes
|
||||||
|
if: needs.changes.outputs.gcp_beginframe == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
|
with:
|
||||||
|
lfs: true
|
||||||
|
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
||||||
|
- uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: packages/gcp-cloud-run/Dockerfile
|
||||||
|
target: beginframe-contract
|
||||||
|
push: false
|
||||||
|
cache-from: type=gha,scope=gcp-beginframe-contract
|
||||||
|
cache-to: type=gha,mode=max,scope=gcp-beginframe-contract
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Build
|
name: Build
|
||||||
|
|||||||
@@ -408,6 +408,8 @@ else
|
|||||||
!packages/shader-transitions/package.json
|
!packages/shader-transitions/package.json
|
||||||
!packages/aws-lambda/
|
!packages/aws-lambda/
|
||||||
!packages/aws-lambda/package.json
|
!packages/aws-lambda/package.json
|
||||||
|
!packages/aws-lambda/scripts/
|
||||||
|
!packages/aws-lambda/scripts/probe-beginframe.ts
|
||||||
packages/**/node_modules/**
|
packages/**/node_modules/**
|
||||||
packages/**/dist/**
|
packages/**/dist/**
|
||||||
packages/**/coverage/**
|
packages/**/coverage/**
|
||||||
@@ -548,6 +550,19 @@ for protocol in "${PROTOCOL_LIST[@]}"; do
|
|||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
CAPTURE_MODES="$(jq -r \
|
||||||
|
'.result | fromjson | [.Chunks[]?.CaptureMode // "<missing>"] | unique | join(",")' \
|
||||||
|
"$EXECUTION_JSON")"
|
||||||
|
if jq -e \
|
||||||
|
'.result | fromjson | .Chunks as $chunks |
|
||||||
|
(($chunks | length) > 0 and all($chunks[]; .CaptureMode == "beginframe"))' \
|
||||||
|
"$EXECUTION_JSON" >/dev/null; then
|
||||||
|
echo " ✓ effective capture mode=beginframe"
|
||||||
|
else
|
||||||
|
echo " ✗ expected every chunk to use beginframe; observed=${CAPTURE_MODES:-<none>}"
|
||||||
|
[ "$OVERALL_RC" -ne 0 ] || OVERALL_RC=6
|
||||||
|
fi
|
||||||
|
|
||||||
OUTPUT_LOCAL="$RENDER_DIR/$protocol-c$chunk_size-output.mp4"
|
OUTPUT_LOCAL="$RENDER_DIR/$protocol-c$chunk_size-output.mp4"
|
||||||
gcloud storage cp "$OUTPUT_GCS" "$OUTPUT_LOCAL" --project "$PROJECT" >/dev/null
|
gcloud storage cp "$OUTPUT_GCS" "$OUTPUT_LOCAL" --project "$PROJECT" >/dev/null
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, expect, it } from "bun:test";
|
||||||
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join, resolve } from "node:path";
|
||||||
|
import {
|
||||||
|
_awaitBeforeDeadlineForTests,
|
||||||
|
_closeBrowserForProbeTests,
|
||||||
|
parseProbeArgs,
|
||||||
|
} from "./probe-beginframe.js";
|
||||||
|
|
||||||
|
describe("parseProbeArgs", () => {
|
||||||
|
it("defaults to the @sparticuz/chromium source", () => {
|
||||||
|
expect(parseProbeArgs([])).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a standalone executable path", () => {
|
||||||
|
expect(parseProbeArgs(["--executable-path", "/opt/chrome/chrome-headless-shell"])).toEqual({
|
||||||
|
executablePath: "/opt/chrome/chrome-headless-shell",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts the equals form and resolves relative paths", () => {
|
||||||
|
expect(parseProbeArgs(["--executable-path=./chrome"])).toEqual({
|
||||||
|
executablePath: resolve("./chrome"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads exact production launch arguments from JSON", () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "hf-probe-args-"));
|
||||||
|
const argsPath = join(dir, "args.json");
|
||||||
|
writeFileSync(argsPath, JSON.stringify(["--enable-begin-frame-control", "--no-sandbox"]));
|
||||||
|
try {
|
||||||
|
expect(parseProbeArgs(["--launch-args-json", argsPath])).toEqual({
|
||||||
|
launchArgs: ["--enable-begin-frame-control", "--no-sandbox"],
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing values and unknown arguments", () => {
|
||||||
|
expect(() => parseProbeArgs(["--executable-path"])).toThrow(
|
||||||
|
"--executable-path requires a path",
|
||||||
|
);
|
||||||
|
expect(() => parseProbeArgs(["--source", "chrome"])).toThrow("Unknown argument: --source");
|
||||||
|
expect(() => parseProbeArgs(["--launch-args-json"])).toThrow(
|
||||||
|
"--launch-args-json requires a path",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds a CDP operation that never resolves", async () => {
|
||||||
|
const never = new Promise<never>(() => {});
|
||||||
|
await expect(
|
||||||
|
_awaitBeforeDeadlineForTests(never, Date.now() + 25, "screenshot beginFrame"),
|
||||||
|
).rejects.toThrow("timeout during screenshot beginFrame");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("force-kills and disconnects when graceful cleanup never resolves", async () => {
|
||||||
|
let killedWith: NodeJS.Signals | number | undefined;
|
||||||
|
let disconnected = false;
|
||||||
|
await _closeBrowserForProbeTests(
|
||||||
|
{
|
||||||
|
close: () => new Promise<never>(() => {}),
|
||||||
|
process: () => ({
|
||||||
|
kill: (signal) => {
|
||||||
|
killedWith = signal;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
disconnect: async () => {
|
||||||
|
disconnected = true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
25,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(killedWith).toBe("SIGKILL");
|
||||||
|
expect(disconnected).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
#!/usr/bin/env tsx
|
#!/usr/bin/env tsx
|
||||||
|
// fallow-ignore-file code-duplication
|
||||||
/**
|
/**
|
||||||
* BeginFrame regression guard for `@sparticuz/chromium`.
|
* BeginFrame regression guard for a Chromium executable.
|
||||||
*
|
*
|
||||||
* The load-bearing assumption of `@hyperframes/aws-lambda` is that the
|
* With no arguments, this boots the build shipped by `@sparticuz/chromium`
|
||||||
* Chromium build shipped by `@sparticuz/chromium` honours CDP
|
* (decompressing into `/tmp` per the library's runtime contract). Passing
|
||||||
* `HeadlessExperimental.beginFrame` with `screenshot: true`. This script
|
* `--executable-path /path/to/chrome-headless-shell` probes an arbitrary
|
||||||
* boots that Chromium build (decompressing into `/tmp` per the library's
|
* executable instead; the GCP image build uses that form against the exact
|
||||||
* runtime contract), navigates to a tiny static page, issues one
|
* binary copied into the image.
|
||||||
* `beginFrame` with a screenshot request, and asserts the response
|
|
||||||
* carries a PNG buffer.
|
|
||||||
*
|
*
|
||||||
* The script is the contract test, not a one-shot verification — every
|
* The script is the contract test, not a one-shot verification — every
|
||||||
* release should run it inside the Docker container at
|
* release should run it inside the Docker container at
|
||||||
@@ -17,15 +16,18 @@
|
|||||||
*
|
*
|
||||||
* Exits 0 on pass, 1 on fail. Run via:
|
* Exits 0 on pass, 1 on fail. Run via:
|
||||||
*
|
*
|
||||||
* bun run --cwd packages/aws-lambda probe:beginframe # host
|
* bun run --cwd packages/aws-lambda probe:beginframe
|
||||||
* bun run --cwd packages/aws-lambda probe:beginframe:docker # Lambda-like
|
* bun run --cwd packages/aws-lambda probe:beginframe -- \
|
||||||
|
* --executable-path /opt/chrome/chrome-headless-shell
|
||||||
|
* bun run --cwd packages/aws-lambda probe:beginframe:docker
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { mkdtempSync, promises as fs } from "node:fs";
|
import { mkdtempSync, promises as fs, readFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
interface ProbeResult {
|
export interface ProbeResult {
|
||||||
passed: boolean;
|
passed: boolean;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
chromiumPath: string;
|
chromiumPath: string;
|
||||||
@@ -38,10 +40,140 @@ const PROBE_HTML = `<!doctype html>
|
|||||||
<html><head><meta charset="utf-8"><title>hf-beginframe-probe</title>
|
<html><head><meta charset="utf-8"><title>hf-beginframe-probe</title>
|
||||||
<style>html,body{margin:0;background:#173;color:#fff;font:48px/1 sans-serif;display:flex;align-items:center;justify-content:center;height:100vh}</style>
|
<style>html,body{margin:0;background:#173;color:#fff;font:48px/1 sans-serif;display:flex;align-items:center;justify-content:center;height:100vh}</style>
|
||||||
</head><body><div id="x">hf-beginframe-probe</div></body></html>`;
|
</head><body><div id="x">hf-beginframe-probe</div></body></html>`;
|
||||||
|
const SCREENSHOT_ATTEMPTS = 10;
|
||||||
|
const PROBE_OPERATION_TIMEOUT_MS = 5000;
|
||||||
|
const PROBE_CLEANUP_TIMEOUT_MS = 250;
|
||||||
|
|
||||||
|
export interface ProbeOptions {
|
||||||
|
executablePath?: string;
|
||||||
|
/** Exact launch arguments to probe instead of the standalone default profile. */
|
||||||
|
launchArgs?: string[];
|
||||||
|
/** Test override for the renderer/CDP operation deadline. */
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The CLI accepts paired and equals forms for two independent path options.
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
|
export function parseProbeArgs(args: string[]): ProbeOptions {
|
||||||
|
let executablePath: string | undefined;
|
||||||
|
let launchArgs: string[] | undefined;
|
||||||
|
for (let i = 0; i < args.length; i += 1) {
|
||||||
|
const arg = args[i];
|
||||||
|
if (arg === "--executable-path") {
|
||||||
|
const value = args[i + 1];
|
||||||
|
if (!value || value.startsWith("--")) {
|
||||||
|
throw new Error("--executable-path requires a path");
|
||||||
|
}
|
||||||
|
executablePath = resolve(value);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg.startsWith("--executable-path=")) {
|
||||||
|
const value = arg.slice("--executable-path=".length);
|
||||||
|
if (!value) throw new Error("--executable-path requires a path");
|
||||||
|
executablePath = resolve(value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--launch-args-json") {
|
||||||
|
const value = args[i + 1];
|
||||||
|
if (!value || value.startsWith("--")) {
|
||||||
|
throw new Error("--launch-args-json requires a path");
|
||||||
|
}
|
||||||
|
launchArgs = readLaunchArgs(value);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg.startsWith("--launch-args-json=")) {
|
||||||
|
const value = arg.slice("--launch-args-json=".length);
|
||||||
|
if (!value) throw new Error("--launch-args-json requires a path");
|
||||||
|
launchArgs = readLaunchArgs(value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(`Unknown argument: ${arg}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...(executablePath ? { executablePath } : {}),
|
||||||
|
...(launchArgs ? { launchArgs } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLaunchArgs(path: string): string[] {
|
||||||
|
const resolved = resolve(path);
|
||||||
|
const value: unknown = JSON.parse(readFileSync(resolved, "utf-8"));
|
||||||
|
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
|
||||||
|
throw new Error(`--launch-args-json must contain a JSON string array: ${resolved}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function awaitBeforeDeadline<T>(
|
||||||
|
operation: Promise<T>,
|
||||||
|
deadline: number,
|
||||||
|
label: string,
|
||||||
|
): Promise<T> {
|
||||||
|
const remainingMs = deadline - Date.now();
|
||||||
|
if (remainingMs <= 0) throw new Error(`BeginFrame probe timeout before ${label}`);
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
operation,
|
||||||
|
new Promise<never>((_, reject) => {
|
||||||
|
timeout = setTimeout(
|
||||||
|
() => reject(new Error(`BeginFrame probe timeout during ${label}`)),
|
||||||
|
remainingMs,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-only export for the standalone probe's bounded-operation contract. */
|
||||||
|
export const _awaitBeforeDeadlineForTests = awaitBeforeDeadline;
|
||||||
|
|
||||||
|
interface ProbeBrowserCleanup {
|
||||||
|
close(): Promise<void>;
|
||||||
|
disconnect(): Promise<void>;
|
||||||
|
process(): { kill(signal?: NodeJS.Signals | number): boolean } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settleWithin(operation: Promise<unknown>, timeoutMs: number): Promise<boolean> {
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
operation.then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
),
|
||||||
|
new Promise<false>((resolveTimeout) => {
|
||||||
|
timeout = setTimeout(() => resolveTimeout(false), timeoutMs);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeBrowserForProbe(
|
||||||
|
browser: ProbeBrowserCleanup,
|
||||||
|
timeoutMs = PROBE_CLEANUP_TIMEOUT_MS,
|
||||||
|
): Promise<void> {
|
||||||
|
if (await settleWithin(browser.close(), timeoutMs)) return;
|
||||||
|
try {
|
||||||
|
browser.process()?.kill("SIGKILL");
|
||||||
|
} catch {
|
||||||
|
// Best effort; disconnect below still releases Puppeteer's transport.
|
||||||
|
}
|
||||||
|
await settleWithin(browser.disconnect(), timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test-only export for bounded standalone-probe cleanup. */
|
||||||
|
export const _closeBrowserForProbeTests = closeBrowserForProbe;
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const result = await probe();
|
const result = await probe(parseProbeArgs(process.argv.slice(2)));
|
||||||
result.durationMs = Date.now() - start;
|
result.durationMs = Date.now() - start;
|
||||||
console.log(JSON.stringify(result, null, 2));
|
console.log(JSON.stringify(result, null, 2));
|
||||||
if (!result.passed) {
|
if (!result.passed) {
|
||||||
@@ -49,12 +181,21 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function probe(): Promise<ProbeResult> {
|
// This intentionally linear contract owns launch, renderer setup, CDP
|
||||||
|
// validation, diagnostics, and cleanup in one fail-closed lifecycle.
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
|
export async function probe(options: ProbeOptions = {}): Promise<ProbeResult> {
|
||||||
let chromiumPath = "";
|
let chromiumPath = "";
|
||||||
|
let tmpHtmlDir = "";
|
||||||
try {
|
try {
|
||||||
|
let sourceArgs: string[] = [];
|
||||||
|
if (options.executablePath) {
|
||||||
|
chromiumPath = options.executablePath;
|
||||||
|
} else {
|
||||||
const { default: chromium } = await import("@sparticuz/chromium");
|
const { default: chromium } = await import("@sparticuz/chromium");
|
||||||
chromiumPath = await chromium.executablePath();
|
chromiumPath = await chromium.executablePath();
|
||||||
const args = chromium.args;
|
sourceArgs = chromium.args;
|
||||||
|
}
|
||||||
|
|
||||||
const puppeteer = await import("puppeteer-core");
|
const puppeteer = await import("puppeteer-core");
|
||||||
|
|
||||||
@@ -63,7 +204,7 @@ async function probe(): Promise<ProbeResult> {
|
|||||||
// Chrome-side issue. `mkdtempSync` (vs `tmpdir() + Date.now()`) gives
|
// Chrome-side issue. `mkdtempSync` (vs `tmpdir() + Date.now()`) gives
|
||||||
// an unguessable directory name so two concurrent probes on the same
|
// an unguessable directory name so two concurrent probes on the same
|
||||||
// host don't collide and CodeQL's insecure-tempfile rule clears.
|
// host don't collide and CodeQL's insecure-tempfile rule clears.
|
||||||
const tmpHtmlDir = mkdtempSync(join(tmpdir(), "hf-beginframe-"));
|
tmpHtmlDir = mkdtempSync(join(tmpdir(), "hf-beginframe-"));
|
||||||
const htmlPath = join(tmpHtmlDir, "probe.html");
|
const htmlPath = join(tmpHtmlDir, "probe.html");
|
||||||
await fs.writeFile(htmlPath, PROBE_HTML, "utf-8");
|
await fs.writeFile(htmlPath, PROBE_HTML, "utf-8");
|
||||||
|
|
||||||
@@ -75,6 +216,11 @@ async function probe(): Promise<ProbeResult> {
|
|||||||
// ("Chrome's beginFrame with `screenshot` param always reports
|
// ("Chrome's beginFrame with `screenshot` param always reports
|
||||||
// hasDamage=true").
|
// hasDamage=true").
|
||||||
const beginFrameFlags = [
|
const beginFrameFlags = [
|
||||||
|
"--no-sandbox",
|
||||||
|
"--disable-setuid-sandbox",
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
"--enable-webgl",
|
||||||
|
"--ignore-gpu-blocklist",
|
||||||
"--deterministic-mode",
|
"--deterministic-mode",
|
||||||
"--enable-begin-frame-control",
|
"--enable-begin-frame-control",
|
||||||
"--disable-new-content-rendering-timeout",
|
"--disable-new-content-rendering-timeout",
|
||||||
@@ -89,55 +235,97 @@ async function probe(): Promise<ProbeResult> {
|
|||||||
"--use-gl=angle",
|
"--use-gl=angle",
|
||||||
"--use-angle=swiftshader",
|
"--use-angle=swiftshader",
|
||||||
"--enable-unsafe-swiftshader",
|
"--enable-unsafe-swiftshader",
|
||||||
|
// Distributed Linux rendering explicitly uses software compositing to
|
||||||
|
// avoid stale transformed layers in SwiftShader (see browserManager).
|
||||||
|
"--disable-gpu-compositing",
|
||||||
];
|
];
|
||||||
|
|
||||||
const browser = await puppeteer.launch({
|
const browser = await puppeteer.launch({
|
||||||
executablePath: chromiumPath,
|
executablePath: chromiumPath,
|
||||||
headless: "shell",
|
headless: "shell",
|
||||||
args: [...args, ...beginFrameFlags],
|
args: options.launchArgs ?? [...sourceArgs, ...beginFrameFlags],
|
||||||
defaultViewport: { width: 800, height: 600 },
|
defaultViewport: { width: 800, height: 600 },
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const page = await browser.newPage();
|
const timeoutMs = options.timeoutMs ?? PROBE_OPERATION_TIMEOUT_MS;
|
||||||
await page.goto(`file://${htmlPath}`, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
const deadline = Date.now() + timeoutMs;
|
||||||
const session = await page.createCDPSession();
|
const page = await awaitBeforeDeadline(browser.newPage(), deadline, "newPage");
|
||||||
await session.send("HeadlessExperimental.enable");
|
await awaitBeforeDeadline(
|
||||||
|
page.goto(`file://${htmlPath}`, { waitUntil: "domcontentloaded", timeout: timeoutMs }),
|
||||||
|
deadline,
|
||||||
|
"navigation",
|
||||||
|
);
|
||||||
|
const session = await awaitBeforeDeadline(
|
||||||
|
page.createCDPSession(),
|
||||||
|
deadline,
|
||||||
|
"CDP session creation",
|
||||||
|
);
|
||||||
|
await awaitBeforeDeadline(
|
||||||
|
session.send("HeadlessExperimental.enable"),
|
||||||
|
deadline,
|
||||||
|
"HeadlessExperimental.enable",
|
||||||
|
);
|
||||||
// Warm-up beginFrame with noDisplayUpdates: true — drives the
|
// Warm-up beginFrame with noDisplayUpdates: true — drives the
|
||||||
// compositor without producing a screenshot, matching how the engine
|
// compositor without producing a screenshot, matching how the engine
|
||||||
// primes a capture loop.
|
// primes a capture loop.
|
||||||
await session.send("HeadlessExperimental.beginFrame", {
|
await awaitBeforeDeadline(
|
||||||
|
session.send("HeadlessExperimental.beginFrame", {
|
||||||
frameTimeTicks: 0,
|
frameTimeTicks: 0,
|
||||||
interval: 33,
|
interval: 33,
|
||||||
noDisplayUpdates: true,
|
noDisplayUpdates: true,
|
||||||
});
|
}),
|
||||||
const response = await session.send("HeadlessExperimental.beginFrame", {
|
deadline,
|
||||||
frameTimeTicks: 1000,
|
"warm-up beginFrame",
|
||||||
|
);
|
||||||
|
let hasDamage = false;
|
||||||
|
let bytes = Buffer.alloc(0);
|
||||||
|
let isPng = false;
|
||||||
|
let attempts = 0;
|
||||||
|
// A renderer-ready document can still need more than one controlled
|
||||||
|
// frame before it submits a screenshot surface. Chromium explicitly
|
||||||
|
// permits screenshotData to be absent during renderer initialization,
|
||||||
|
// so retry a small bounded sequence with monotonically increasing ticks.
|
||||||
|
for (attempts = 1; attempts <= SCREENSHOT_ATTEMPTS; attempts += 1) {
|
||||||
|
const response = await awaitBeforeDeadline(
|
||||||
|
session.send("HeadlessExperimental.beginFrame", {
|
||||||
|
frameTimeTicks: 1000 + (attempts - 1) * 33,
|
||||||
interval: 33,
|
interval: 33,
|
||||||
screenshot: { format: "png" },
|
screenshot: { format: "png" },
|
||||||
});
|
}),
|
||||||
await fs.rm(tmpHtmlDir, { recursive: true, force: true }).catch(() => {});
|
deadline,
|
||||||
|
`screenshot beginFrame attempt ${attempts}`,
|
||||||
|
);
|
||||||
|
hasDamage = response.hasDamage;
|
||||||
const screenshot = response.screenshotData ?? "";
|
const screenshot = response.screenshotData ?? "";
|
||||||
const bytes = screenshot ? Buffer.from(screenshot, "base64") : Buffer.alloc(0);
|
bytes = screenshot ? Buffer.from(screenshot, "base64") : Buffer.alloc(0);
|
||||||
const isPng =
|
isPng =
|
||||||
bytes.length >= 8 &&
|
bytes.length >= 8 &&
|
||||||
bytes[0] === 0x89 &&
|
bytes[0] === 0x89 &&
|
||||||
bytes[1] === 0x50 &&
|
bytes[1] === 0x50 &&
|
||||||
bytes[2] === 0x4e &&
|
bytes[2] === 0x4e &&
|
||||||
bytes[3] === 0x47;
|
bytes[3] === 0x47;
|
||||||
|
if (isPng) break;
|
||||||
|
await awaitBeforeDeadline(
|
||||||
|
new Promise((resolveDelay) => setTimeout(resolveDelay, 10)),
|
||||||
|
deadline,
|
||||||
|
`screenshot retry delay ${attempts}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
passed: isPng && bytes.length > 0,
|
passed: isPng && bytes.length > 0,
|
||||||
durationMs: 0,
|
durationMs: 0,
|
||||||
chromiumPath,
|
chromiumPath,
|
||||||
screenshotBytes: bytes.length,
|
screenshotBytes: bytes.length,
|
||||||
hasDamage: response.hasDamage,
|
hasDamage,
|
||||||
detail: isPng
|
detail: isPng
|
||||||
? "OK — BeginFrame returned a PNG buffer."
|
? `OK — BeginFrame returned a PNG buffer after ${attempts} attempt(s).`
|
||||||
: `FAIL — BeginFrame returned ${bytes.length} bytes, PNG signature ${
|
: `FAIL — BeginFrame returned ${bytes.length} bytes after ${SCREENSHOT_ATTEMPTS} ` +
|
||||||
|
`attempts, PNG signature ${
|
||||||
bytes.length >= 4 ? bytes.subarray(0, 4).toString("hex") : "<empty>"
|
bytes.length >= 4 ? bytes.subarray(0, 4).toString("hex") : "<empty>"
|
||||||
}`,
|
}`,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
await browser.close().catch(() => {});
|
await closeBrowserForProbe(browser);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
@@ -148,10 +336,17 @@ async function probe(): Promise<ProbeResult> {
|
|||||||
hasDamage: false,
|
hasDamage: false,
|
||||||
detail: `FAIL — ${err instanceof Error ? err.message : String(err)}`,
|
detail: `FAIL — ${err instanceof Error ? err.message : String(err)}`,
|
||||||
};
|
};
|
||||||
|
} finally {
|
||||||
|
if (tmpHtmlDir) {
|
||||||
|
await fs.rm(tmpHtmlDir, { recursive: true, force: true }).catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void main().catch((err) => {
|
const invokedPath = process.argv[1] ? resolve(process.argv[1]) : "";
|
||||||
|
if (invokedPath === fileURLToPath(import.meta.url)) {
|
||||||
|
void main().catch((err) => {
|
||||||
console.error("[probe-beginframe] unexpected:", err);
|
console.error("[probe-beginframe] unexpected:", err);
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -184,6 +184,8 @@ export interface RenderChunkLambdaResult {
|
|||||||
ChunkIndex: number;
|
ChunkIndex: number;
|
||||||
Sha256: string;
|
Sha256: string;
|
||||||
FramesEncoded: number;
|
FramesEncoded: number;
|
||||||
|
/** Effective engine mode after browser probing. Emitted by current handlers. */
|
||||||
|
CaptureMode?: "beginframe" | "screenshot" | "drawelement";
|
||||||
DurationMs: number;
|
DurationMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -377,6 +377,12 @@ describe("handler dispatch", () => {
|
|||||||
framesEncoded: 240,
|
framesEncoded: 240,
|
||||||
sha256: "0".repeat(64),
|
sha256: "0".repeat(64),
|
||||||
durationMs: 12345,
|
durationMs: 12345,
|
||||||
|
planHashMs: 1,
|
||||||
|
sessionBootMs: 2,
|
||||||
|
captureStageMs: 3,
|
||||||
|
encodeStageMs: 4,
|
||||||
|
workers: 1,
|
||||||
|
captureMode: "beginframe",
|
||||||
perfPath: outputChunkPath + ".perf.json",
|
perfPath: outputChunkPath + ".perf.json",
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -416,6 +422,7 @@ describe("handler dispatch", () => {
|
|||||||
expect(result.ChunkIndex).toBe(2);
|
expect(result.ChunkIndex).toBe(2);
|
||||||
expect(result.Sha256).toBe("0".repeat(64));
|
expect(result.Sha256).toBe("0".repeat(64));
|
||||||
expect(result.FramesEncoded).toBe(240);
|
expect(result.FramesEncoded).toBe(240);
|
||||||
|
expect(result.CaptureMode).toBe("beginframe");
|
||||||
expect(renderChunkMock).toHaveBeenCalledTimes(1);
|
expect(renderChunkMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -555,6 +562,12 @@ describe("handler dispatch", () => {
|
|||||||
framesEncoded: 30,
|
framesEncoded: 30,
|
||||||
sha256: "b".repeat(64),
|
sha256: "b".repeat(64),
|
||||||
durationMs: 1,
|
durationMs: 1,
|
||||||
|
planHashMs: 0,
|
||||||
|
sessionBootMs: 0,
|
||||||
|
captureStageMs: 1,
|
||||||
|
encodeStageMs: 0,
|
||||||
|
workers: 1,
|
||||||
|
captureMode: "beginframe",
|
||||||
perfPath: `${outputPath}.perf.json`,
|
perfPath: `${outputPath}.perf.json`,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -631,6 +644,7 @@ describe("handler dispatch", () => {
|
|||||||
deps,
|
deps,
|
||||||
);
|
);
|
||||||
if (chunk.Action !== "renderChunk") throw new Error("expected chunk result");
|
if (chunk.Action !== "renderChunk") throw new Error("expected chunk result");
|
||||||
|
expect(chunk.CaptureMode).toBe("beginframe");
|
||||||
const audioDigest = createHash("sha256").update("AAC").digest("hex");
|
const audioDigest = createHash("sha256").update("AAC").digest("hex");
|
||||||
const audioUri = `${planned.PlanV2ArtifactS3Prefix}/${audioDigest.slice(0, 2)}/${audioDigest}`;
|
const audioUri = `${planned.PlanV2ArtifactS3Prefix}/${audioDigest.slice(0, 2)}/${audioDigest}`;
|
||||||
expect(s3.ops.slice(beforeChunk).some((operation) => operation.uri === audioUri)).toBe(false);
|
expect(s3.ops.slice(beforeChunk).some((operation) => operation.uri === audioUri)).toBe(false);
|
||||||
|
|||||||
@@ -457,6 +457,7 @@ async function handleRenderChunk(
|
|||||||
ChunkIndex: event.ChunkIndex,
|
ChunkIndex: event.ChunkIndex,
|
||||||
Sha256: result.sha256,
|
Sha256: result.sha256,
|
||||||
FramesEncoded: result.framesEncoded,
|
FramesEncoded: result.framesEncoded,
|
||||||
|
CaptureMode: result.captureMode,
|
||||||
DurationMs: Date.now() - started,
|
DurationMs: Date.now() - started,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
@@ -504,6 +505,7 @@ async function handleRenderChunkV2(
|
|||||||
ChunkIndex: event.ChunkIndex,
|
ChunkIndex: event.ChunkIndex,
|
||||||
Sha256: result.sha256,
|
Sha256: result.sha256,
|
||||||
FramesEncoded: result.framesEncoded,
|
FramesEncoded: result.framesEncoded,
|
||||||
|
CaptureMode: result.captureMode,
|
||||||
DurationMs: Date.now() - started,
|
DurationMs: Date.now() - started,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// fallow-ignore-file code-duplication
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
@@ -9,6 +10,9 @@ import type { Browser, PuppeteerNode } from "puppeteer-core";
|
|||||||
import {
|
import {
|
||||||
_resetAutoBrowserGpuModeCacheForTests,
|
_resetAutoBrowserGpuModeCacheForTests,
|
||||||
_resetBrowserPoolForTests,
|
_resetBrowserPoolForTests,
|
||||||
|
_closeBrowserAfterFailedProbeForTests,
|
||||||
|
_createBrowserLaunchFingerprintForTests,
|
||||||
|
_probeBeginFrameSupportForTests,
|
||||||
_setPuppeteerForTests,
|
_setPuppeteerForTests,
|
||||||
acquireBrowser,
|
acquireBrowser,
|
||||||
buildChromeArgs,
|
buildChromeArgs,
|
||||||
@@ -19,6 +23,102 @@ import {
|
|||||||
resolveBrowserGpuMode,
|
resolveBrowserGpuMode,
|
||||||
} from "./browserManager.js";
|
} from "./browserManager.js";
|
||||||
|
|
||||||
|
describe("BeginFrame capability probe", () => {
|
||||||
|
it("waits for a document and validates a PNG-returning frame", async () => {
|
||||||
|
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
const send = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({})
|
||||||
|
.mockResolvedValueOnce({})
|
||||||
|
.mockResolvedValueOnce({ hasDamage: true, screenshotData: png.toString("base64") });
|
||||||
|
const detach = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const goto = vi.fn().mockResolvedValue(null);
|
||||||
|
const close = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const browser = {
|
||||||
|
newPage: vi.fn().mockResolvedValue({
|
||||||
|
goto,
|
||||||
|
createCDPSession: vi.fn().mockResolvedValue({ send, detach }),
|
||||||
|
close,
|
||||||
|
}),
|
||||||
|
} as unknown as Browser;
|
||||||
|
|
||||||
|
const result = await _probeBeginFrameSupportForTests(browser);
|
||||||
|
|
||||||
|
expect(result.supported).toBe(true);
|
||||||
|
expect(goto).toHaveBeenCalledWith(expect.stringContaining("hf-beginframe-probe"), {
|
||||||
|
waitUntil: "domcontentloaded",
|
||||||
|
timeout: 2000,
|
||||||
|
});
|
||||||
|
expect(send.mock.calls.map(([method]) => method)).toEqual([
|
||||||
|
"HeadlessExperimental.enable",
|
||||||
|
"HeadlessExperimental.beginFrame",
|
||||||
|
"HeadlessExperimental.beginFrame",
|
||||||
|
]);
|
||||||
|
expect(detach).toHaveBeenCalledOnce();
|
||||||
|
expect(close).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an empty screenshot as unsupported", async () => {
|
||||||
|
const send = vi.fn().mockResolvedValue({ hasDamage: false });
|
||||||
|
const browser = {
|
||||||
|
newPage: vi.fn().mockResolvedValue({
|
||||||
|
goto: vi.fn().mockResolvedValue(null),
|
||||||
|
createCDPSession: vi.fn().mockResolvedValue({
|
||||||
|
send,
|
||||||
|
detach: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
close: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
} as unknown as Browser;
|
||||||
|
|
||||||
|
const result = await _probeBeginFrameSupportForTests(browser);
|
||||||
|
|
||||||
|
expect(result.supported).toBe(false);
|
||||||
|
expect(result.detail).toContain("returned 0 bytes after 10 attempts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds a screenshot-bearing CDP call that never resolves", async () => {
|
||||||
|
const never = new Promise<never>(() => {});
|
||||||
|
const send = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({})
|
||||||
|
.mockResolvedValueOnce({})
|
||||||
|
.mockReturnValueOnce(never);
|
||||||
|
const close = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const browser = {
|
||||||
|
newPage: vi.fn().mockResolvedValue({
|
||||||
|
goto: vi.fn().mockResolvedValue(null),
|
||||||
|
createCDPSession: vi.fn().mockResolvedValue({
|
||||||
|
send,
|
||||||
|
detach: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}),
|
||||||
|
close,
|
||||||
|
}),
|
||||||
|
} as unknown as Browser;
|
||||||
|
|
||||||
|
const result = await _probeBeginFrameSupportForTests(browser, 25);
|
||||||
|
|
||||||
|
expect(result.supported).toBe(false);
|
||||||
|
expect(result.detail).toContain("timeout during screenshot beginFrame attempt 1");
|
||||||
|
expect(close).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("force-kills and disconnects when graceful browser cleanup never resolves", async () => {
|
||||||
|
const kill = vi.fn();
|
||||||
|
const disconnect = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const browser = {
|
||||||
|
close: vi.fn().mockReturnValue(new Promise<never>(() => {})),
|
||||||
|
process: vi.fn().mockReturnValue({ kill }),
|
||||||
|
disconnect,
|
||||||
|
} as unknown as Browser;
|
||||||
|
|
||||||
|
await _closeBrowserAfterFailedProbeForTests(browser, 25);
|
||||||
|
|
||||||
|
expect(kill).toHaveBeenCalledWith("SIGKILL");
|
||||||
|
expect(disconnect).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("buildChromeArgs browser GPU mode", () => {
|
describe("buildChromeArgs browser GPU mode", () => {
|
||||||
const base = { width: 1920, height: 1080 };
|
const base = { width: 1920, height: 1080 };
|
||||||
|
|
||||||
@@ -89,6 +189,31 @@ describe("buildChromeArgs browser GPU mode", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("browser launch capture-mode contract", () => {
|
||||||
|
it("derives BeginFrame from the actual launch flags, not forceScreenshot alone", () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "hf-browser-fingerprint-"));
|
||||||
|
const chromePath = join(dir, "chrome-headless-shell");
|
||||||
|
writeFileSync(chromePath, "");
|
||||||
|
try {
|
||||||
|
const withoutControl = _createBrowserLaunchFingerprintForTests([], {
|
||||||
|
chromePath,
|
||||||
|
forceScreenshot: false,
|
||||||
|
});
|
||||||
|
const withControl = _createBrowserLaunchFingerprintForTests(
|
||||||
|
["--enable-begin-frame-control"],
|
||||||
|
{ chromePath, forceScreenshot: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(withoutControl.requestedCaptureMode).toBe("screenshot");
|
||||||
|
expect(withControl.requestedCaptureMode).toBe(
|
||||||
|
process.platform === "linux" ? "beginframe" : "screenshot",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("resolveBrowserGpuMode", () => {
|
describe("resolveBrowserGpuMode", () => {
|
||||||
const setMockWebGlProbe = (info: { hasWebGL: boolean; vendor: string; renderer: string }) => {
|
const setMockWebGlProbe = (info: { hasWebGL: boolean; vendor: string; renderer: string }) => {
|
||||||
const close = vi.fn().mockResolvedValue(undefined);
|
const close = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|||||||
@@ -217,44 +217,192 @@ function stripBeginFrameFlags(args: string[]): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Probe whether the browser still speaks HeadlessExperimental.beginFrame.
|
* Probe the complete HeadlessExperimental.beginFrame runtime contract.
|
||||||
*
|
*
|
||||||
* Recent chrome-headless-shell builds (observed on 147) expose the domain
|
* Domain registration alone is insufficient: BeginFrame control must be
|
||||||
* well enough that HeadlessExperimental.enable succeeds but drop the
|
* enabled at launch and a renderer-ready target must return a real PNG.
|
||||||
* beginFrame method itself — the capture loop then dies on first frame with
|
* Every operation shares one short deadline so a wedged CDP call cannot hold
|
||||||
* `'HeadlessExperimental.beginFrame' wasn't found`. So we probe BOTH: enable
|
* a serverless cold start until Puppeteer's much longer protocol timeout.
|
||||||
* + one cheap beginFrame raced against a 2s timeout. In beginframe-control
|
|
||||||
* mode the command completes as soon as the compositor acks, so a real
|
|
||||||
* supported browser returns well under the timeout.
|
|
||||||
*
|
|
||||||
* Any failure (method missing, timeout, protocol error) is treated as
|
|
||||||
* unsupported. Real errors after launch would surface in the warmup loop and
|
|
||||||
* fall out through the caller's try/catch.
|
|
||||||
*/
|
*/
|
||||||
async function probeBeginFrameSupport(browser: Browser): Promise<boolean> {
|
interface BeginFrameProbeResult {
|
||||||
let page;
|
supported: boolean;
|
||||||
|
detail: string;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BEGINFRAME_SCREENSHOT_PROBE_ATTEMPTS = 10;
|
||||||
|
const BEGINFRAME_PROBE_TIMEOUT_MS = 2000;
|
||||||
|
const BEGINFRAME_PROBE_CLEANUP_TIMEOUT_MS = 250;
|
||||||
|
|
||||||
|
async function awaitBeforeDeadline<T>(
|
||||||
|
operation: Promise<T>,
|
||||||
|
deadline: number,
|
||||||
|
label: string,
|
||||||
|
): Promise<T> {
|
||||||
|
const remainingMs = deadline - Date.now();
|
||||||
|
if (remainingMs <= 0) {
|
||||||
|
throw new Error(`beginFrame probe timeout before ${label}`);
|
||||||
|
}
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
try {
|
try {
|
||||||
page = await browser.newPage();
|
return await Promise.race([
|
||||||
const client = await page.createCDPSession();
|
operation,
|
||||||
await client.send("HeadlessExperimental.enable");
|
new Promise<never>((_, reject) => {
|
||||||
const beginFrame = client.send("HeadlessExperimental.beginFrame", {
|
timeout = setTimeout(
|
||||||
|
() => reject(new Error(`beginFrame probe timeout during ${label}`)),
|
||||||
|
remainingMs,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settleWithin(operation: Promise<unknown>, timeoutMs: number): Promise<boolean> {
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
operation.then(
|
||||||
|
() => true,
|
||||||
|
() => false,
|
||||||
|
),
|
||||||
|
new Promise<false>((resolveTimeout) => {
|
||||||
|
timeout = setTimeout(() => resolveTimeout(false), timeoutMs);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeBrowserAfterFailedProbe(
|
||||||
|
browser: Browser,
|
||||||
|
timeoutMs = BEGINFRAME_PROBE_CLEANUP_TIMEOUT_MS,
|
||||||
|
): Promise<void> {
|
||||||
|
if (await settleWithin(browser.close(), timeoutMs)) return;
|
||||||
|
// A wedged CDP transport can make graceful close inherit Puppeteer's
|
||||||
|
// multi-minute protocol timeout. Kill the owned process and disconnect so
|
||||||
|
// screenshot fallback can launch promptly.
|
||||||
|
try {
|
||||||
|
browser.process()?.kill("SIGKILL");
|
||||||
|
} catch {
|
||||||
|
// Best effort; disconnect below still releases Puppeteer's transport.
|
||||||
|
}
|
||||||
|
await settleWithin(browser.disconnect(), timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The probe keeps its renderer setup, bounded CDP sequence, PNG validation,
|
||||||
|
// diagnostics, and cleanup together so every failure uses one contract.
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
|
async function probeBeginFrameSupport(
|
||||||
|
browser: Browser,
|
||||||
|
timeoutMs = BEGINFRAME_PROBE_TIMEOUT_MS,
|
||||||
|
): Promise<BeginFrameProbeResult> {
|
||||||
|
const started = Date.now();
|
||||||
|
const deadline = started + timeoutMs;
|
||||||
|
let page;
|
||||||
|
let result: BeginFrameProbeResult;
|
||||||
|
try {
|
||||||
|
page = await awaitBeforeDeadline(browser.newPage(), deadline, "newPage");
|
||||||
|
// `browser.newPage()` resolves before a cold renderer has necessarily
|
||||||
|
// submitted its first surface. Cloud Run exposed this as a false
|
||||||
|
// "unsupported Chromium" result: probing the untouched about:blank
|
||||||
|
// target raced renderer initialization, while the same binary passed
|
||||||
|
// once a real document was ready. Navigate first so this tests protocol
|
||||||
|
// capability rather than target-startup timing.
|
||||||
|
await awaitBeforeDeadline(
|
||||||
|
page.goto(
|
||||||
|
"data:text/html,<style>html,body{margin:0;background:%23173}</style><div>hf-beginframe-probe</div>",
|
||||||
|
{ waitUntil: "domcontentloaded", timeout: timeoutMs },
|
||||||
|
),
|
||||||
|
deadline,
|
||||||
|
"navigation",
|
||||||
|
);
|
||||||
|
const client = await awaitBeforeDeadline(
|
||||||
|
page.createCDPSession(),
|
||||||
|
deadline,
|
||||||
|
"CDP session creation",
|
||||||
|
);
|
||||||
|
await awaitBeforeDeadline(
|
||||||
|
client.send("HeadlessExperimental.enable"),
|
||||||
|
deadline,
|
||||||
|
"HeadlessExperimental.enable",
|
||||||
|
);
|
||||||
|
await awaitBeforeDeadline(
|
||||||
|
client.send("HeadlessExperimental.beginFrame", {
|
||||||
frameTimeTicks: 0,
|
frameTimeTicks: 0,
|
||||||
interval: 33,
|
interval: 33,
|
||||||
noDisplayUpdates: true,
|
noDisplayUpdates: true,
|
||||||
});
|
}),
|
||||||
const timeout = new Promise<never>((_, reject) =>
|
deadline,
|
||||||
setTimeout(() => reject(new Error("beginFrame probe timeout")), 2000),
|
"warm-up beginFrame",
|
||||||
|
);
|
||||||
|
let bytes = Buffer.alloc(0);
|
||||||
|
let isPng = false;
|
||||||
|
let attempts = 0;
|
||||||
|
for (attempts = 1; attempts <= BEGINFRAME_SCREENSHOT_PROBE_ATTEMPTS; attempts += 1) {
|
||||||
|
const response = await awaitBeforeDeadline(
|
||||||
|
client.send("HeadlessExperimental.beginFrame", {
|
||||||
|
frameTimeTicks: 1000 + (attempts - 1) * 33,
|
||||||
|
interval: 33,
|
||||||
|
screenshot: { format: "png" },
|
||||||
|
}),
|
||||||
|
deadline,
|
||||||
|
`screenshot beginFrame attempt ${attempts}`,
|
||||||
|
);
|
||||||
|
const screenshot = response.screenshotData ?? "";
|
||||||
|
bytes = screenshot ? Buffer.from(screenshot, "base64") : Buffer.alloc(0);
|
||||||
|
isPng =
|
||||||
|
bytes.length >= 8 &&
|
||||||
|
bytes[0] === 0x89 &&
|
||||||
|
bytes[1] === 0x50 &&
|
||||||
|
bytes[2] === 0x4e &&
|
||||||
|
bytes[3] === 0x47;
|
||||||
|
if (isPng) break;
|
||||||
|
await awaitBeforeDeadline(
|
||||||
|
new Promise((resolveDelay) => setTimeout(resolveDelay, 10)),
|
||||||
|
deadline,
|
||||||
|
`screenshot retry delay ${attempts}`,
|
||||||
);
|
);
|
||||||
await Promise.race([beginFrame, timeout]);
|
|
||||||
await client.detach().catch(() => {});
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
await page?.close().catch(() => {});
|
|
||||||
}
|
}
|
||||||
|
if (!isPng) {
|
||||||
|
throw new Error(
|
||||||
|
`beginFrame screenshot returned ${bytes.length} bytes after ` +
|
||||||
|
`${BEGINFRAME_SCREENSHOT_PROBE_ATTEMPTS} attempts with signature ` +
|
||||||
|
`${bytes.length >= 4 ? bytes.subarray(0, 4).toString("hex") : "<empty>"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await awaitBeforeDeadline(client.detach(), deadline, "CDP detach").catch(() => {});
|
||||||
|
result = {
|
||||||
|
supported: true,
|
||||||
|
detail:
|
||||||
|
`enable + warm-up + ${bytes.length}-byte PNG beginFrame succeeded ` +
|
||||||
|
`after ${attempts} screenshot attempt(s)`,
|
||||||
|
durationMs: Date.now() - started,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
result = {
|
||||||
|
supported: false,
|
||||||
|
detail: error instanceof Error ? error.message : String(error),
|
||||||
|
durationMs: Date.now() - started,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (page && !(await settleWithin(page.close(), BEGINFRAME_PROBE_CLEANUP_TIMEOUT_MS))) {
|
||||||
|
return {
|
||||||
|
supported: false,
|
||||||
|
detail: `${result.detail}; probe page cleanup timed out`,
|
||||||
|
durationMs: Date.now() - started,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Test-only export for the renderer-readiness + PNG capability contract. */
|
||||||
|
export const _probeBeginFrameSupportForTests = probeBeginFrameSupport;
|
||||||
|
/** Test-only export for the forced browser-cleanup fallback. */
|
||||||
|
export const _closeBrowserAfterFailedProbeForTests = closeBrowserAfterFailedProbe;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cached *in-flight or resolved* probe Promise for `resolveBrowserGpuMode("auto", ...)`.
|
* Cached *in-flight or resolved* probe Promise for `resolveBrowserGpuMode("auto", ...)`.
|
||||||
*
|
*
|
||||||
@@ -391,8 +539,17 @@ function createBrowserLaunchFingerprint(
|
|||||||
...config,
|
...config,
|
||||||
};
|
};
|
||||||
const headlessShell = resolveHeadlessShellPath(launchConfig);
|
const headlessShell = resolveHeadlessShellPath(launchConfig);
|
||||||
|
// The launch arguments are the authoritative capture-mode contract.
|
||||||
|
// A caller can pass `forceScreenshot:false` while a later safety clamp
|
||||||
|
// deliberately omits BeginFrame control flags. Inferring solely from the
|
||||||
|
// raw config in that case makes BrowserManager probe a capability it never
|
||||||
|
// enabled, then mislabel the result as an unsupported Chromium build.
|
||||||
|
const beginFrameControlEnabled = chromeArgs.includes("--enable-begin-frame-control");
|
||||||
const requestedCaptureMode: CaptureMode =
|
const requestedCaptureMode: CaptureMode =
|
||||||
headlessShell && process.platform === "linux" && !launchConfig.forceScreenshot
|
headlessShell &&
|
||||||
|
process.platform === "linux" &&
|
||||||
|
!launchConfig.forceScreenshot &&
|
||||||
|
beginFrameControlEnabled
|
||||||
? "beginframe"
|
? "beginframe"
|
||||||
: "screenshot";
|
: "screenshot";
|
||||||
return {
|
return {
|
||||||
@@ -404,6 +561,9 @@ function createBrowserLaunchFingerprint(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Test-only export for launch-argument/capture-mode agreement. */
|
||||||
|
export const _createBrowserLaunchFingerprintForTests = createBrowserLaunchFingerprint;
|
||||||
|
|
||||||
export async function acquireBrowser(
|
export async function acquireBrowser(
|
||||||
chromeArgs: string[],
|
chromeArgs: string[],
|
||||||
config?: Partial<
|
config?: Partial<
|
||||||
@@ -443,12 +603,17 @@ async function launchBrowser(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (captureMode === "beginframe") {
|
if (captureMode === "beginframe") {
|
||||||
const supported = await probeBeginFrameSupport(browser).catch(() => true);
|
const probe = await probeBeginFrameSupport(browser).catch((error) => ({
|
||||||
if (!supported) {
|
supported: true,
|
||||||
await browser.close().catch(() => {});
|
detail: `probe harness error ignored: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
durationMs: 0,
|
||||||
|
}));
|
||||||
|
if (!probe.supported) {
|
||||||
|
await closeBrowserAfterFailedProbe(browser);
|
||||||
browser = undefined;
|
browser = undefined;
|
||||||
console.warn(
|
console.warn(
|
||||||
"[BrowserManager] HeadlessExperimental.beginFrame unavailable in this Chromium build; falling back to screenshot mode.",
|
`[BrowserManager] HeadlessExperimental.beginFrame probe failed after ${probe.durationMs}ms: ` +
|
||||||
|
`${probe.detail}; falling back to screenshot mode.`,
|
||||||
);
|
);
|
||||||
captureMode = "screenshot";
|
captureMode = "screenshot";
|
||||||
browser = await ppt.launch({
|
browser = await ppt.launch({
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
# Unlike the AWS Lambda adapter there is no ZIP-size ceiling and no runtime
|
# Unlike the AWS Lambda adapter there is no ZIP-size ceiling and no runtime
|
||||||
# Chrome decompression step — the binary lives in the image at a fixed path.
|
# Chrome decompression step — the binary lives in the image at a fixed path.
|
||||||
|
|
||||||
FROM node:22-bookworm-slim
|
FROM node:22-bookworm-slim AS beginframe-contract
|
||||||
|
|
||||||
# ── System dependencies (identical set to the producer's render image) ───────
|
# ── System dependencies (identical set to the producer's render image) ───────
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
@@ -55,9 +55,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& fc-cache -fv
|
&& fc-cache -fv
|
||||||
|
|
||||||
# ── chrome-headless-shell (deterministic BeginFrame capture) ─────────────────
|
# ── chrome-headless-shell (deterministic BeginFrame capture) ─────────────────
|
||||||
# Pinned to the SAME build the producer regression baselines were generated
|
# Pinned deliberately. The build-stage contract probe below launches this
|
||||||
# against so distributed renders are pixel-comparable. Bump together with the
|
# exact executable and requires a renderer-ready warm-up plus a PNG-returning
|
||||||
# producer's Dockerfile.test pin and a baseline regen.
|
# HeadlessExperimental.beginFrame before the image can build.
|
||||||
RUN npx --yes @puppeteer/browsers install chrome-headless-shell@148.0.7778.167 \
|
RUN npx --yes @puppeteer/browsers install chrome-headless-shell@148.0.7778.167 \
|
||||||
--path /opt/puppeteer \
|
--path /opt/puppeteer \
|
||||||
&& CHS="$(find /opt/puppeteer/chrome-headless-shell -name chrome-headless-shell -type f | head -n1)" \
|
&& CHS="$(find /opt/puppeteer/chrome-headless-shell -name chrome-headless-shell -type f | head -n1)" \
|
||||||
@@ -100,6 +100,23 @@ COPY packages/studio-server/package.json packages/studio-server/package.json
|
|||||||
COPY scripts/package-subpaths.mjs scripts/package-subpaths.mjs
|
COPY scripts/package-subpaths.mjs scripts/package-subpaths.mjs
|
||||||
RUN bun install --frozen-lockfile
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
|
# Fail the image build closed if the packaged executable cannot perform the
|
||||||
|
# same BeginFrame operation the distributed renderer relies on. Generate the
|
||||||
|
# argument list with the production engine helper so a launcher-flag regression
|
||||||
|
# invalidates this layer and fails the contract instead of testing a duplicate
|
||||||
|
# hard-coded profile.
|
||||||
|
COPY packages/engine/ packages/engine/
|
||||||
|
COPY packages/aws-lambda/scripts/probe-beginframe.ts packages/aws-lambda/scripts/probe-beginframe.ts
|
||||||
|
RUN bun -e 'import { buildChromeArgs } from "./packages/engine/src/services/browserManager.ts"; await Bun.write("/tmp/hf-gcp-chrome-args.json", JSON.stringify(buildChromeArgs({ width: 800, height: 600, captureMode: "beginframe", platform: "linux" }, { browserGpuMode: "software" })))' \
|
||||||
|
&& bun packages/aws-lambda/scripts/probe-beginframe.ts \
|
||||||
|
--executable-path /opt/chrome/chrome-headless-shell \
|
||||||
|
--launch-args-json /tmp/hf-gcp-chrome-args.json
|
||||||
|
|
||||||
|
# CI targets `beginframe-contract` directly, so pin/probe changes do not need
|
||||||
|
# the full monorepo build merely to verify browser capability. The deployable
|
||||||
|
# image continues from the already-proven binary and installed workspaces.
|
||||||
|
FROM beginframe-contract AS runtime
|
||||||
|
|
||||||
# Copy source for the packages the render path needs.
|
# Copy source for the packages the render path needs.
|
||||||
COPY packages/core/ packages/core/
|
COPY packages/core/ packages/core/
|
||||||
COPY packages/engine/ packages/engine/
|
COPY packages/engine/ packages/engine/
|
||||||
|
|||||||
@@ -50,7 +50,11 @@ decompresses `@sparticuz/chromium` into `/tmp` at runtime — Cloud Run runs a
|
|||||||
container image. The `Dockerfile` installs the same pinned
|
container image. The `Dockerfile` installs the same pinned
|
||||||
`chrome-headless-shell` build and font set the production renderer uses, at a
|
`chrome-headless-shell` build and font set the production renderer uses, at a
|
||||||
fixed path, and exports `HYPERFRAMES_CHROME_PATH`. CDP-level `BeginFrame`
|
fixed path, and exports `HYPERFRAMES_CHROME_PATH`. CDP-level `BeginFrame`
|
||||||
works because the command lives in the protocol, not the binary. There is no
|
support is a binary/runtime capability, so the image build launches that
|
||||||
|
exact executable and requires an enable + warm-up + PNG-returning
|
||||||
|
`HeadlessExperimental.beginFrame` probe to pass. The end-to-end smoke also
|
||||||
|
requires every chunk to report effective `CaptureMode: "beginframe"`, which
|
||||||
|
catches runtime fallback separately from build-time packaging. There is no
|
||||||
runtime decompression step and no packaging ceiling.
|
runtime decompression step and no packaging ceiling.
|
||||||
|
|
||||||
## Deploying
|
## Deploying
|
||||||
|
|||||||
@@ -5,9 +5,10 @@
|
|||||||
* via the engine's `BrowserManager`. Because Cloud Run runs a container
|
* via the engine's `BrowserManager`. Because Cloud Run runs a container
|
||||||
* image rather than a size-capped ZIP, the Chrome story is far simpler than
|
* image rather than a size-capped ZIP, the Chrome story is far simpler than
|
||||||
* the Lambda adapter's: the `Dockerfile` installs `chrome-headless-shell`
|
* the Lambda adapter's: the `Dockerfile` installs `chrome-headless-shell`
|
||||||
* (the same BeginFrame-capable build the K8s deploy uses) into the image at
|
* into the image at a known path and exports `HYPERFRAMES_CHROME_PATH`.
|
||||||
* a known path and exports `HYPERFRAMES_CHROME_PATH`. There is no runtime
|
* The Dockerfile proves that exact executable's full BeginFrame screenshot
|
||||||
* decompression-into-/tmp step and no 250 MB packaging ceiling to fight.
|
* contract during the image build. There is no runtime decompression-into-
|
||||||
|
* /tmp step and no 250 MB packaging ceiling to fight.
|
||||||
*
|
*
|
||||||
* Resolution order:
|
* Resolution order:
|
||||||
* 1. `PRODUCER_HEADLESS_SHELL_PATH` — the engine's own override. If a
|
* 1. `PRODUCER_HEADLESS_SHELL_PATH` — the engine's own override. If a
|
||||||
|
|||||||
@@ -203,6 +203,8 @@ export interface RenderChunkResultBody {
|
|||||||
ChunkIndex: number;
|
ChunkIndex: number;
|
||||||
Sha256: string;
|
Sha256: string;
|
||||||
FramesEncoded: number;
|
FramesEncoded: number;
|
||||||
|
/** Effective engine mode after browser probing. Emitted by current handlers. */
|
||||||
|
CaptureMode?: "beginframe" | "screenshot" | "drawelement";
|
||||||
DurationMs: number;
|
DurationMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,14 @@ function depsWith(
|
|||||||
outputKind: "file",
|
outputKind: "file",
|
||||||
framesEncoded: 30,
|
framesEncoded: 30,
|
||||||
sha256: `sha-${chunkIndex}`,
|
sha256: `sha-${chunkIndex}`,
|
||||||
|
captureMode: "beginframe",
|
||||||
|
durationMs: 0,
|
||||||
|
planHashMs: 0,
|
||||||
|
sessionBootMs: 0,
|
||||||
|
captureStageMs: 0,
|
||||||
|
encodeStageMs: 0,
|
||||||
|
workers: 1,
|
||||||
|
perfPath: `${outputBase}.perf.json`,
|
||||||
} satisfies ChunkResult;
|
} satisfies ChunkResult;
|
||||||
};
|
};
|
||||||
const assemble = async (
|
const assemble = async (
|
||||||
@@ -201,6 +209,7 @@ describe("dispatch", () => {
|
|||||||
const res = await dispatch(event, depsWith(gcs));
|
const res = await dispatch(event, depsWith(gcs));
|
||||||
if (res.Action !== "renderChunk") throw new Error("unreachable");
|
if (res.Action !== "renderChunk") throw new Error("unreachable");
|
||||||
expect(res.ChunkIndex).toBe(2);
|
expect(res.ChunkIndex).toBe(2);
|
||||||
|
expect(res.CaptureMode).toBe("beginframe");
|
||||||
expect(res.ChunkGcsUri).toBe("gs://b/renders/r1/chunks/0002.mp4");
|
expect(res.ChunkGcsUri).toBe("gs://b/renders/r1/chunks/0002.mp4");
|
||||||
expect(gcs.objects.has("gs://b/renders/r1/chunks/0002.mp4")).toBe(true);
|
expect(gcs.objects.has("gs://b/renders/r1/chunks/0002.mp4")).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -270,6 +279,14 @@ describe("dispatch", () => {
|
|||||||
outputKind: "file",
|
outputKind: "file",
|
||||||
framesEncoded: 30,
|
framesEncoded: 30,
|
||||||
sha256: "b".repeat(64),
|
sha256: "b".repeat(64),
|
||||||
|
captureMode: "beginframe",
|
||||||
|
durationMs: 0,
|
||||||
|
planHashMs: 0,
|
||||||
|
sessionBootMs: 0,
|
||||||
|
captureStageMs: 0,
|
||||||
|
encodeStageMs: 0,
|
||||||
|
workers: 1,
|
||||||
|
perfPath: `${outputBase}.perf.json`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const assemble = async (
|
const assemble = async (
|
||||||
|
|||||||
@@ -455,6 +455,7 @@ async function handleRenderChunk(
|
|||||||
ChunkIndex: event.ChunkIndex,
|
ChunkIndex: event.ChunkIndex,
|
||||||
Sha256: result.sha256,
|
Sha256: result.sha256,
|
||||||
FramesEncoded: result.framesEncoded,
|
FramesEncoded: result.framesEncoded,
|
||||||
|
CaptureMode: result.captureMode,
|
||||||
DurationMs: Date.now() - started,
|
DurationMs: Date.now() - started,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
@@ -500,6 +501,7 @@ async function handleRenderChunkV2(
|
|||||||
ChunkIndex: event.ChunkIndex,
|
ChunkIndex: event.ChunkIndex,
|
||||||
Sha256: result.sha256,
|
Sha256: result.sha256,
|
||||||
FramesEncoded: result.framesEncoded,
|
FramesEncoded: result.framesEncoded,
|
||||||
|
CaptureMode: result.captureMode,
|
||||||
DurationMs: Date.now() - started,
|
DurationMs: Date.now() - started,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ describe("GCP smoke ownership and protocol safety", () => {
|
|||||||
expect(smoke).toContain("decodedFramesEqual");
|
expect(smoke).toContain("decodedFramesEqual");
|
||||||
expect(smoke).toContain("decodedAudioEqual");
|
expect(smoke).toContain("decodedAudioEqual");
|
||||||
expect(smoke).toContain("normalizedMetadataEqual");
|
expect(smoke).toContain("normalizedMetadataEqual");
|
||||||
|
expect(smoke).toContain('.CaptureMode == "beginframe"');
|
||||||
|
expect(smoke).toContain("expected every chunk to use beginframe");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("derives a length-safe owner prefix and isolates Terraform state", () => {
|
it("derives a length-safe owner prefix and isolates Terraform state", () => {
|
||||||
@@ -51,6 +53,7 @@ describe("GCP smoke ownership and protocol safety", () => {
|
|||||||
expect(smoke).toContain("--ignore-file");
|
expect(smoke).toContain("--ignore-file");
|
||||||
expect(smoke).toContain("--gcs-source-staging-dir");
|
expect(smoke).toContain("--gcs-source-staging-dir");
|
||||||
expect(smoke).toContain("!scripts/package-subpaths.mjs");
|
expect(smoke).toContain("!scripts/package-subpaths.mjs");
|
||||||
|
expect(smoke).toContain("!packages/aws-lambda/scripts/probe-beginframe.ts");
|
||||||
expect(dockerfile).toContain("COPY scripts/package-subpaths.mjs scripts/package-subpaths.mjs");
|
expect(dockerfile).toContain("COPY scripts/package-subpaths.mjs scripts/package-subpaths.mjs");
|
||||||
expect(smoke).not.toContain("gcloud services enable");
|
expect(smoke).not.toContain("gcloud services enable");
|
||||||
expect(smoke).toContain("gcloud services list");
|
expect(smoke).toContain("gcloud services list");
|
||||||
|
|||||||
@@ -238,6 +238,8 @@ describe("cross-worker idempotency", () => {
|
|||||||
expect(b.outputKind).toBe("file");
|
expect(b.outputKind).toBe("file");
|
||||||
expect(a.framesEncoded).toBeGreaterThan(0);
|
expect(a.framesEncoded).toBeGreaterThan(0);
|
||||||
expect(b.framesEncoded).toBe(a.framesEncoded);
|
expect(b.framesEncoded).toBe(a.framesEncoded);
|
||||||
|
expect(a.captureMode).toBe("beginframe");
|
||||||
|
expect(b.captureMode).toBe("beginframe");
|
||||||
|
|
||||||
expect(a.sha256).toBe(b.sha256);
|
expect(a.sha256).toBe(b.sha256);
|
||||||
assertBytesEqual(outA, outB, "file", `mp4 chunk ${chunkIndex}`);
|
assertBytesEqual(outA, outB, "file", `mp4 chunk ${chunkIndex}`);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
|||||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { applyConcreteGpuScreenshotClamp, buildChromeArgs } from "@hyperframes/engine";
|
||||||
import { recomputePlanHashFromPlanDir } from "../render/stages/freezePlan.js";
|
import { recomputePlanHashFromPlanDir } from "../render/stages/freezePlan.js";
|
||||||
import { RenderQualityError } from "../renderOrchestrator.js";
|
import { RenderQualityError } from "../renderOrchestrator.js";
|
||||||
import { CURRENT_PLAN_PROTOCOL } from "./planProtocol.js";
|
import { CURRENT_PLAN_PROTOCOL } from "./planProtocol.js";
|
||||||
@@ -29,6 +30,7 @@ import {
|
|||||||
DEFAULT_MAX_PARALLEL_CHUNKS,
|
DEFAULT_MAX_PARALLEL_CHUNKS,
|
||||||
MIN_CHUNK_SIZE,
|
MIN_CHUNK_SIZE,
|
||||||
plan,
|
plan,
|
||||||
|
resolveDistributedEngineConfig,
|
||||||
resolveChunkPlan,
|
resolveChunkPlan,
|
||||||
} from "./plan.js";
|
} from "./plan.js";
|
||||||
import { buildSyntheticRenderJob } from "./shared.js";
|
import { buildSyntheticRenderJob } from "./shared.js";
|
||||||
@@ -155,6 +157,26 @@ describe("distributed synthetic render job", () => {
|
|||||||
|
|
||||||
expect(job.config.variables).toEqual(variables);
|
expect(job.config.variables).toEqual(variables);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the production software-GPU launch on BeginFrame control", () => {
|
||||||
|
const cfg = resolveDistributedEngineConfig({
|
||||||
|
fps: 30,
|
||||||
|
width: 320,
|
||||||
|
height: 240,
|
||||||
|
format: "mp4",
|
||||||
|
});
|
||||||
|
const forceScreenshot = applyConcreteGpuScreenshotClamp(
|
||||||
|
cfg.forceScreenshot,
|
||||||
|
"software",
|
||||||
|
cfg,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const captureMode = forceScreenshot ? "screenshot" : "beginframe";
|
||||||
|
const args = buildChromeArgs({ width: 320, height: 240, captureMode, platform: "linux" }, cfg);
|
||||||
|
|
||||||
|
expect(forceScreenshot).toBe(false);
|
||||||
|
expect(args).toContain("--enable-begin-frame-control");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveChunkPlan", () => {
|
describe("resolveChunkPlan", () => {
|
||||||
|
|||||||
@@ -778,6 +778,20 @@ function resolveNonMp4EncoderTriple(
|
|||||||
return { encoder: "png-sequence", pixelFormat: "rgba", preset: "lossless" };
|
return { encoder: "png-sequence", pixelFormat: "rgba", preset: "lossless" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Test-visible construction of the engine config used by distributed planning. */
|
||||||
|
export function resolveDistributedEngineConfig(config: DistributedRenderConfig): EngineConfig {
|
||||||
|
return {
|
||||||
|
...(config.producerConfig ?? config.engineConfig ?? resolveConfig()),
|
||||||
|
browserGpuMode: "software",
|
||||||
|
forceScreenshot: false,
|
||||||
|
// Distributed rendering deliberately opts into deterministic BeginFrame
|
||||||
|
// on Linux SwiftShader. Preserve the provenance bit consumed by the
|
||||||
|
// engine's software-GPU screenshot clamp; assigning the boolean alone
|
||||||
|
// loses the distinction between a default false and this explicit opt-out.
|
||||||
|
forceScreenshotExplicitlyOptedOut: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Activity A of the distributed render pipeline. Produces a self-contained
|
* Activity A of the distributed render pipeline. Produces a self-contained
|
||||||
* `<planDir>/` from a project + config. See module docstring for the
|
* `<planDir>/` from a project + config. See module docstring for the
|
||||||
@@ -808,11 +822,7 @@ export async function plan(
|
|||||||
throw new Error("[plan] render_cancelled");
|
throw new Error("[plan] render_cancelled");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const cfg: EngineConfig = {
|
const cfg = resolveDistributedEngineConfig(config);
|
||||||
...(config.producerConfig ?? config.engineConfig ?? resolveConfig()),
|
|
||||||
browserGpuMode: "software",
|
|
||||||
forceScreenshot: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const job = buildSyntheticRenderJob({
|
const job = buildSyntheticRenderJob({
|
||||||
fps: { num: config.fps, den: 1 },
|
fps: { num: config.fps, den: 1 },
|
||||||
|
|||||||
@@ -203,8 +203,11 @@ describe("renderChunk()", () => {
|
|||||||
expect(a.captureStageMs).toBeGreaterThan(0);
|
expect(a.captureStageMs).toBeGreaterThan(0);
|
||||||
expect(a.encodeStageMs).toBeGreaterThanOrEqual(0);
|
expect(a.encodeStageMs).toBeGreaterThanOrEqual(0);
|
||||||
expect(a.workers).toBeGreaterThanOrEqual(1);
|
expect(a.workers).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(["beginframe", "screenshot", "drawelement"]).toContain(a.captureMode);
|
||||||
|
expect(b.captureMode).toBe(a.captureMode);
|
||||||
expect(a.captureStageMs + a.encodeStageMs).toBeLessThanOrEqual(a.durationMs);
|
expect(a.captureStageMs + a.encodeStageMs).toBeLessThanOrEqual(a.durationMs);
|
||||||
const perf = JSON.parse(readFileSync(a.perfPath, "utf-8"));
|
const perf = JSON.parse(readFileSync(a.perfPath, "utf-8"));
|
||||||
|
expect(perf.captureMode).toBe(a.captureMode);
|
||||||
for (const key of [
|
for (const key of [
|
||||||
"planHashMs",
|
"planHashMs",
|
||||||
"sessionBootMs",
|
"sessionBootMs",
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ import {
|
|||||||
BROWSER_GPU_NOT_SOFTWARE,
|
BROWSER_GPU_NOT_SOFTWARE,
|
||||||
calculateOptimalWorkers,
|
calculateOptimalWorkers,
|
||||||
type CaptureOptions,
|
type CaptureOptions,
|
||||||
|
type CaptureMode,
|
||||||
|
type CapturePerfSummary,
|
||||||
type CaptureSession,
|
type CaptureSession,
|
||||||
closeCaptureSession,
|
closeCaptureSession,
|
||||||
createCaptureSession,
|
createCaptureSession,
|
||||||
@@ -158,6 +160,8 @@ export interface ChunkResult {
|
|||||||
encodeStageMs: number;
|
encodeStageMs: number;
|
||||||
/** Capture workers used for this chunk (`calculateOptimalWorkers` result). */
|
/** Capture workers used for this chunk (`calculateOptimalWorkers` result). */
|
||||||
workers: number;
|
workers: number;
|
||||||
|
/** Effective engine mode used by every worker, after any browser fallback. */
|
||||||
|
captureMode: CaptureMode;
|
||||||
/**
|
/**
|
||||||
* Path to a sidecar JSON containing per-chunk perf counters. Adapters
|
* Path to a sidecar JSON containing per-chunk perf counters. Adapters
|
||||||
* upload this alongside the chunk so per-chunk regressions are
|
* upload this alongside the chunk so per-chunk regressions are
|
||||||
@@ -330,6 +334,7 @@ export function resolveLockedVp9CpuUsed(
|
|||||||
* outputs — the caller picks the right shape based on `meta/encoder.json`.
|
* outputs — the caller picks the right shape based on `meta/encoder.json`.
|
||||||
* `renderChunk` enforces the same choice via `outputKind` on the result.
|
* `renderChunk` enforces the same choice via `outputKind` on the result.
|
||||||
*/
|
*/
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
export async function renderChunk(
|
export async function renderChunk(
|
||||||
planDir: string,
|
planDir: string,
|
||||||
chunkIndex: number,
|
chunkIndex: number,
|
||||||
@@ -485,6 +490,10 @@ export async function renderChunk(
|
|||||||
...resolveConfig(),
|
...resolveConfig(),
|
||||||
browserGpuMode: "software",
|
browserGpuMode: "software",
|
||||||
forceScreenshot: encoder.forceScreenshot,
|
forceScreenshot: encoder.forceScreenshot,
|
||||||
|
// `encoder.forceScreenshot=false` is a locked distributed-render
|
||||||
|
// decision, not the engine default. Carry that explicit opt-out through
|
||||||
|
// the software-GPU clamp so buildChromeArgs includes BeginFrameControl.
|
||||||
|
forceScreenshotExplicitlyOptedOut: !encoder.forceScreenshot,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build the BeforeCaptureHook that injects pre-extracted video frames
|
// Build the BeforeCaptureHook that injects pre-extracted video frames
|
||||||
@@ -585,6 +594,8 @@ export async function renderChunk(
|
|||||||
let sessionBootMs = 0;
|
let sessionBootMs = 0;
|
||||||
let captureStageMs = 0;
|
let captureStageMs = 0;
|
||||||
let encodeStageMs = 0;
|
let encodeStageMs = 0;
|
||||||
|
let captureMode: CaptureMode | undefined;
|
||||||
|
const capturePerfs: CapturePerfSummary[] = [];
|
||||||
try {
|
try {
|
||||||
if (chunkWorkerCount === 1) {
|
if (chunkWorkerCount === 1) {
|
||||||
// Sequential branch reuses the probe session for the actual capture.
|
// Sequential branch reuses the probe session for the actual capture.
|
||||||
@@ -638,9 +649,10 @@ export async function renderChunk(
|
|||||||
log,
|
log,
|
||||||
probeSession: session,
|
probeSession: session,
|
||||||
captureAttempts: [],
|
captureAttempts: [],
|
||||||
// Distributed chunks run on Linux (beginframe) where dedup never arms;
|
// This sink also records each worker's effective capture mode. That
|
||||||
// a throwaway sink satisfies the type without per-chunk dedup reporting.
|
// makes a BeginFrame → screenshot fallback observable to adapters and
|
||||||
dedupPerfs: [],
|
// end-to-end smoke tests instead of existing only in stderr.
|
||||||
|
dedupPerfs: capturePerfs,
|
||||||
buildCaptureOptions: () => captureOptions,
|
buildCaptureOptions: () => captureOptions,
|
||||||
createRenderVideoFrameInjector: () => videoInjector,
|
createRenderVideoFrameInjector: () => videoInjector,
|
||||||
abortSignal: undefined,
|
abortSignal: undefined,
|
||||||
@@ -650,6 +662,20 @@ export async function renderChunk(
|
|||||||
// captureStage closes the session it consumed.
|
// captureStage closes the session it consumed.
|
||||||
captureStageMs = Date.now() - captureStarted;
|
captureStageMs = Date.now() - captureStarted;
|
||||||
session = null;
|
session = null;
|
||||||
|
const observedModes = new Set(capturePerfs.map((perf) => perf.captureMode));
|
||||||
|
const validModes = new Set<CaptureMode>(["beginframe", "screenshot", "drawelement"]);
|
||||||
|
if (
|
||||||
|
observedModes.size !== 1 ||
|
||||||
|
![...observedModes].every((mode): mode is CaptureMode =>
|
||||||
|
validModes.has(mode as CaptureMode),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`[renderChunk] capture workers reported invalid or inconsistent modes: ` +
|
||||||
|
`${[...observedModes].join(",") || "<none>"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
captureMode = [...observedModes][0] as CaptureMode;
|
||||||
framesEncoded = framesInChunk;
|
framesEncoded = framesInChunk;
|
||||||
|
|
||||||
// ── Encode the chunk ──
|
// ── Encode the chunk ──
|
||||||
@@ -737,6 +763,9 @@ export async function renderChunk(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Hash the output + write the perf sidecar ──
|
// ── Hash the output + write the perf sidecar ──
|
||||||
|
if (!captureMode) {
|
||||||
|
throw new Error("[renderChunk] capture stage completed without reporting a capture mode");
|
||||||
|
}
|
||||||
const sha256 = hashChunkOutput(outputChunkPath, outputKind);
|
const sha256 = hashChunkOutput(outputChunkPath, outputKind);
|
||||||
const durationMs = Date.now() - start;
|
const durationMs = Date.now() - start;
|
||||||
const perfPath = `${outputChunkPath}.perf.json`;
|
const perfPath = `${outputChunkPath}.perf.json`;
|
||||||
@@ -752,6 +781,7 @@ export async function renderChunk(
|
|||||||
captureStageMs,
|
captureStageMs,
|
||||||
encodeStageMs,
|
encodeStageMs,
|
||||||
workers: chunkWorkerCount,
|
workers: chunkWorkerCount,
|
||||||
|
captureMode,
|
||||||
sha256,
|
sha256,
|
||||||
outputKind,
|
outputKind,
|
||||||
producerVersion: plan.producerVersion,
|
producerVersion: plan.producerVersion,
|
||||||
@@ -781,6 +811,7 @@ export async function renderChunk(
|
|||||||
captureStageMs,
|
captureStageMs,
|
||||||
encodeStageMs,
|
encodeStageMs,
|
||||||
workers: chunkWorkerCount,
|
workers: chunkWorkerCount,
|
||||||
|
captureMode,
|
||||||
perfPath,
|
perfPath,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user