From f180ffca64b4b9604385b2572dc16f2e8d9db913 Mon Sep 17 00:00:00 2001 From: James Russo Date: Sat, 24 Oct 2026 23:09:57 +0000 Subject: [PATCH] fix(cli): reap only orphaned browsers HyperFrames launched (#6251) * fix(cli): reap only orphaned browsers HyperFrames launched Orphan cleanup on preview and render killed every PPID-1 process of the current user whose args named chrome-headless-shell or a Puppeteer temp profile, which included detached browsers started by other tools. Every HyperFrames Chrome launch now passes an inert --hyperframes-owned-browser switch, and the reaper only kills PPID-1 processes that carry it as a whole argument. Fixes #6203 Co-Authored-By: Claude Opus 5.5 (1M context) * fix(core): serve the browser marker from its own subpath The GCP image's BeginFrame contract step imports the engine's browserManager straight from source before core's generated runtime exists, so pulling the marker from the core root entry broke it. A dependency-free @hyperframes/core/browser-marker subpath keeps that import chain as light as it was. Co-Authored-By: Claude Opus 5.5 (1M context) * test(cli): expect the browser marker in the capture fallback launches Co-Authored-By: Claude Opus 5.5 (1M context) * docs(cli): say orphan cleanup runs before preview and render The cleanup only stops browsers HyperFrames launched, so the opt-out is no longer needed to protect other tools' browsers. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- docs/developers/cli.mdx | 7 +- .../cli/src/browser/launch.noListen.test.ts | 10 ++- packages/cli/src/browser/launch.ts | 10 ++- .../cli/src/browser/sandboxDenied.test.ts | 20 ++++- ...reCompositionFrame.browserFallback.test.ts | 4 +- .../cli/src/utils/orphanCleanup.scan.test.ts | 21 +++++- packages/cli/src/utils/orphanCleanup.ts | 16 ++-- packages/core/package-subpaths.json | 6 ++ packages/core/package.json | 10 +++ packages/core/src/browserMarker.test.ts | 19 +++++ packages/core/src/browserMarker.ts | 8 ++ .../browserManager-ownerMarker.test.ts | 74 +++++++++++++++++++ .../engine/src/services/browserManager.ts | 7 +- .../services/hdrCapture-ownerMarker.test.ts | 23 ++++++ packages/engine/src/services/hdrCapture.ts | 3 +- packages/inspector/src/service.ts | 5 +- .../tests/serviceFingerprint.test.ts | 59 +++++++++++---- packages/producer/src/parity-harness.ts | 5 +- packages/producer/src/verifyStaticPage.ts | 3 +- 19 files changed, 260 insertions(+), 50 deletions(-) create mode 100644 packages/core/src/browserMarker.test.ts create mode 100644 packages/core/src/browserMarker.ts create mode 100644 packages/engine/src/services/browserManager-ownerMarker.test.ts create mode 100644 packages/engine/src/services/hdrCapture-ownerMarker.test.ts diff --git a/docs/developers/cli.mdx b/docs/developers/cli.mdx index 82e8af5d5d..ed1bc43144 100644 --- a/docs/developers/cli.mdx +++ b/docs/developers/cli.mdx @@ -50,9 +50,10 @@ Google Cloud Run. Most commands take `--json` when you need to read the result f Start with the simple command, then open the [full reference](/packages/cli) for the exact options needed by automation or infrastructure. -When embedding preview in another tool or sharing a machine with other browser -automation, set `HYPERFRAMES_NO_ORPHAN_CLEANUP=1` in the calling environment -to skip startup cleanup of detached headless Chrome processes: +Before preview and render, HyperFrames stops headless Chrome processes left +behind by crashed HyperFrames sessions. It only stops browsers that HyperFrames +launched, so detached browsers from other tools keep running. To skip this +cleanup, set `HYPERFRAMES_NO_ORPHAN_CLEANUP=1` in the calling environment: ```bash HYPERFRAMES_NO_ORPHAN_CLEANUP=1 npx hyperframes preview ./my-video diff --git a/packages/cli/src/browser/launch.noListen.test.ts b/packages/cli/src/browser/launch.noListen.test.ts index 2a9e4f2218..81e5637881 100644 --- a/packages/cli/src/browser/launch.noListen.test.ts +++ b/packages/cli/src/browser/launch.noListen.test.ts @@ -1,4 +1,5 @@ import { EventEmitter } from "node:events"; +import { HYPERFRAMES_BROWSER_MARKER_ARG } from "@hyperframes/core/browser-marker"; import type { LaunchOptions, PuppeteerNode } from "puppeteer-core"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -30,10 +31,15 @@ describe("the GPU probe where nothing may listen", () => { await resolveManagedGpuMode("auto", "/chrome"); expect(probe.options[0]).toMatchObject({ pipe: true, - args: ["--headless", "--single-process", "--disable-gpu-compositing"], + args: [ + "--headless", + "--single-process", + "--disable-gpu-compositing", + HYPERFRAMES_BROWSER_MARKER_ARG, + ], }); expect(probe.options[1]!.pipe).toBe(true); - expect(probe.options[1]!.args).toEqual(["--headless"]); + expect(probe.options[1]!.args).toEqual(["--headless", HYPERFRAMES_BROWSER_MARKER_ARG]); }); }); diff --git a/packages/cli/src/browser/launch.ts b/packages/cli/src/browser/launch.ts index 2cda8a9388..d54c0d857a 100644 --- a/packages/cli/src/browser/launch.ts +++ b/packages/cli/src/browser/launch.ts @@ -1,4 +1,5 @@ import type { Browser, LaunchOptions, PuppeteerNode } from "puppeteer-core"; +import { withHyperframesBrowserMarker } from "@hyperframes/core/browser-marker"; import { resolveCaptureBrowserGpuMode, type BrowserGpuMode, @@ -117,7 +118,14 @@ async function launchOwnedBrowser( const signal = options.signal ? AbortSignal.any([options.signal, abort.signal]) : abort.signal; const launchedAt = Date.now(); const launch = untilLaunchOrAbort( - puppeteer.launch({ ...options, pipe: true, signal, handleSIGINT: false, handleSIGTERM: false }), + puppeteer.launch({ + ...options, + args: withHyperframesBrowserMarker(options.args), + pipe: true, + signal, + handleSIGINT: false, + handleSIGTERM: false, + }), signal, ); const limit = options.timeout ?? LAUNCH_TIMEOUT_MS; diff --git a/packages/cli/src/browser/sandboxDenied.test.ts b/packages/cli/src/browser/sandboxDenied.test.ts index 4ec29fd0e3..64f5c2a05b 100644 --- a/packages/cli/src/browser/sandboxDenied.test.ts +++ b/packages/cli/src/browser/sandboxDenied.test.ts @@ -6,6 +6,7 @@ import { type PuppeteerNode, } from "puppeteer-core"; import { TargetCloseError } from "puppeteer-core/internal/common/Errors.js"; +import { HYPERFRAMES_BROWSER_MARKER_ARG } from "@hyperframes/core/browser-marker"; import { afterEach, describe, expect, it, vi } from "vitest"; import { launchManagedBrowser } from "./launch.js"; @@ -66,7 +67,12 @@ describe("a browser inside an agent's macOS seatbelt", () => { expect(launches).toHaveLength(1); expect(launches[0]).toMatchObject({ pipe: true, - args: ["--headless", "--single-process", "--disable-gpu-compositing"], + args: [ + "--headless", + "--single-process", + "--disable-gpu-compositing", + HYPERFRAMES_BROWSER_MARKER_ARG, + ], }); }); @@ -75,11 +81,12 @@ describe("a browser inside an agent's macOS seatbelt", () => { const { puppeteer, launches } = recordingPuppeteer([SEATBELT_LAUNCH_FAILURE]); await expect(launchManagedBrowser(puppeteer, { args: ["--headless"] })).resolves.toBeDefined(); expect(launches.map((options) => options.pipe ?? false)).toEqual([true, true]); - expect(launches[0]!.args).toEqual(["--headless"]); + expect(launches[0]!.args).toEqual(["--headless", HYPERFRAMES_BROWSER_MARKER_ARG]); expect(launches[1]!.args).toEqual([ "--headless", "--single-process", "--disable-gpu-compositing", + HYPERFRAMES_BROWSER_MARKER_ARG, ]); }); it("relaunches as one process when an unnamed sandbox closes a pipe launch before its first targets", async () => { @@ -90,8 +97,13 @@ describe("a browser inside an agent's macOS seatbelt", () => { ]); await expect(launchManagedBrowser(puppeteer, { args: ["--headless"] })).resolves.toBeDefined(); expect(launches.map((options) => options.args)).toEqual([ - ["--headless"], - ["--headless", "--single-process", "--disable-gpu-compositing"], + ["--headless", HYPERFRAMES_BROWSER_MARKER_ARG], + [ + "--headless", + "--single-process", + "--disable-gpu-compositing", + HYPERFRAMES_BROWSER_MARKER_ARG, + ], ]); }); diff --git a/packages/cli/src/capture/captureCompositionFrame.browserFallback.test.ts b/packages/cli/src/capture/captureCompositionFrame.browserFallback.test.ts index 0942a77430..d22a9be7b0 100644 --- a/packages/cli/src/capture/captureCompositionFrame.browserFallback.test.ts +++ b/packages/cli/src/capture/captureCompositionFrame.browserFallback.test.ts @@ -1,4 +1,5 @@ import { EventEmitter } from "node:events"; +import { HYPERFRAMES_BROWSER_MARKER_ARG } from "@hyperframes/core/browser-marker"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ @@ -232,6 +233,7 @@ describe("openSettledCompositionPage when nothing may listen", () => { "--headless", "--single-process", "--disable-gpu-compositing", + HYPERFRAMES_BROWSER_MARKER_ARG, ]); expect(opened.page.setRequestInterception).toHaveBeenCalledWith(true); const onRequest = opened.page.on.mock.calls.find(([event]) => event === "request")![1]; @@ -263,7 +265,7 @@ describe("openSettledCompositionPage when nothing may listen", () => { mocks.launch.mockResolvedValue(opened.browser); await openSettledCompositionPage(HTML, "http://127.0.0.1:3000", OPTIONS); expect(launched().pipe).toBe(true); - expect(launched().args).toEqual(["--headless"]); + expect(launched().args).toEqual(["--headless", HYPERFRAMES_BROWSER_MARKER_ARG]); expect(opened.page.setRequestInterception).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/utils/orphanCleanup.scan.test.ts b/packages/cli/src/utils/orphanCleanup.scan.test.ts index 4124148a67..92f41296aa 100644 --- a/packages/cli/src/utils/orphanCleanup.scan.test.ts +++ b/packages/cli/src/utils/orphanCleanup.scan.test.ts @@ -1,3 +1,4 @@ +import { HYPERFRAMES_BROWSER_MARKER_ARG } from "@hyperframes/core/browser-marker"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const child = vi.hoisted(() => ({ execFileSync: vi.fn(), execSync: vi.fn() })); @@ -28,14 +29,17 @@ describe.skipIf(process.platform === "win32")("killOrphanedProcesses scan", () = expect(tree.terminateProcessTree).not.toHaveBeenCalled(); }); - it("finds orphaned browsers from one process-table read", () => { + it("kills only orphaned browsers HyperFrames marked, from one process-table read", () => { child.execFileSync.mockReturnValue( [ - " 101 1 /opt/chrome-headless-shell --headless", + ` 101 1 /opt/chrome-headless-shell --headless ${HYPERFRAMES_BROWSER_MARKER_ARG}`, " 102 101 /opt/chrome-headless-shell --type=renderer", - " 103 1 chrome --user-data-dir=/tmp/puppeteer_dev_chrome_profile-x", - " 104 900 chrome --user-data-dir=/tmp/puppeteer_dev_chrome_profile-y", + ` 103 1 chrome --user-data-dir=/tmp/puppeteer_dev_chrome_profile-x ${HYPERFRAMES_BROWSER_MARKER_ARG} about:blank`, + ` 104 900 chrome --user-data-dir=/tmp/puppeteer_dev_chrome_profile-y ${HYPERFRAMES_BROWSER_MARKER_ARG}`, " 105 1 /usr/bin/node server.js", + " 106 1 /opt/chrome-headless-shell --remote-debugging-port=0 about:blank", + " 107 1 chrome --user-data-dir=/tmp/puppeteer_dev_chrome_profile-z --headless", + ` 108 1 chrome ${HYPERFRAMES_BROWSER_MARKER_ARG}-lookalike`, ].join("\n"), ); @@ -44,4 +48,13 @@ describe.skipIf(process.platform === "win32")("killOrphanedProcesses scan", () = expect(child.execSync).not.toHaveBeenCalled(); expect(tree.terminateProcessTree.mock.calls.map(([pid]) => pid)).toEqual([101, 103]); }); + + it("leaves another tool's detached headless shell running", () => { + child.execFileSync.mockReturnValue( + " 201 1 /opt/chrome-headless-shell --remote-debugging-port=0 about:blank", + ); + + expect(killOrphanedProcesses()).toBe(0); + expect(tree.terminateProcessTree).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/utils/orphanCleanup.ts b/packages/cli/src/utils/orphanCleanup.ts index b07c831748..8cd2cd6b03 100644 --- a/packages/cli/src/utils/orphanCleanup.ts +++ b/packages/cli/src/utils/orphanCleanup.ts @@ -1,24 +1,20 @@ // fallow-ignore-file code-duplication import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; +import { HYPERFRAMES_BROWSER_MARKER_ARG } from "@hyperframes/core/browser-marker"; import { runRenderSetupWorker } from "./cancellableProcess.js"; import { terminateProcessTree, windowsProcessTreeKillArgs } from "./processTree.js"; export { windowsProcessTreeKillArgs }; /** - * Kill orphaned Chrome processes (PPID 1: their parent died) left by crashed sessions, headless shell or - * Puppeteer's Chrome (`puppeteer_dev_chrome_profile`), with their helper subtrees. Only the current user's - * processes are read. Returns the count of killed process trees. + * Kill the current user's orphaned (PPID 1) Chrome trees that carry the HyperFrames launch marker, so detached + * browsers other tools started survive. Returns the count of killed process trees. */ export function killOrphanedProcesses(): number { if (process.env.HYPERFRAMES_NO_ORPHAN_CLEANUP === "1" || process.platform === "win32") return 0; - return killOrphansMatching([ - "chrome-headless-shell", - "chrome_headless_shell", - "puppeteer_dev_chrome_profile", - ]); + return killOrphansMatching(HYPERFRAMES_BROWSER_MARKER_ARG); } export async function killOrphanedProcessesForRender(signal: AbortSignal): Promise { @@ -230,7 +226,7 @@ export function isProcessDescendant( } /** One process-table read: a spawn per candidate PID cost half a second before `preview` listened. */ -function killOrphansMatching(markers: readonly string[]): number { +function killOrphansMatching(marker: string): number { const uid = process.getuid?.(); const owner = uid === undefined ? ["-A"] : ["-U", String(uid)]; let table: string; @@ -247,7 +243,7 @@ function killOrphansMatching(markers: readonly string[]): number { for (const line of table.split("\n")) { const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/); if (!match || match[2] !== "1") continue; - if (!markers.some((marker) => match[3]!.includes(marker))) continue; + if (!match[3]!.split(/\s+/).includes(marker)) continue; killProcessTree(Number(match[1])); killed++; } diff --git a/packages/core/package-subpaths.json b/packages/core/package-subpaths.json index 95a6627d08..31699aeb0c 100644 --- a/packages/core/package-subpaths.json +++ b/packages/core/package-subpaths.json @@ -56,6 +56,12 @@ "types": "./dist/safePath.d.ts", "environments": ["bun", "node"] }, + "./browser-marker": { + "source": "./src/browserMarker.ts", + "runtime": "./dist/browserMarker.js", + "types": "./dist/browserMarker.d.ts", + "environments": ["bun", "node"] + }, "./atomic-file": { "source": "./src/atomicFile.ts", "runtime": "./dist/atomicFile.js", diff --git a/packages/core/package.json b/packages/core/package.json index 0192874494..d12a25d8a0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -68,6 +68,12 @@ "import": "./src/safePath.ts", "types": "./src/safePath.ts" }, + "./browser-marker": { + "bun": "./src/browserMarker.ts", + "node": "./dist/browserMarker.js", + "import": "./src/browserMarker.ts", + "types": "./src/browserMarker.ts" + }, "./atomic-file": { "bun": "./src/atomicFile.ts", "node": "./dist/atomicFile.js", @@ -594,6 +600,10 @@ "import": "./dist/safePath.js", "types": "./dist/safePath.d.ts" }, + "./browser-marker": { + "import": "./dist/browserMarker.js", + "types": "./dist/browserMarker.d.ts" + }, "./atomic-file": { "import": "./dist/atomicFile.js", "types": "./dist/atomicFile.d.ts" diff --git a/packages/core/src/browserMarker.test.ts b/packages/core/src/browserMarker.test.ts new file mode 100644 index 0000000000..ea58a3e082 --- /dev/null +++ b/packages/core/src/browserMarker.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { HYPERFRAMES_BROWSER_MARKER_ARG, withHyperframesBrowserMarker } from "./browserMarker.js"; + +describe("withHyperframesBrowserMarker", () => { + it("appends the marker to a copy of the args", () => { + const args = ["--no-sandbox"]; + expect(withHyperframesBrowserMarker(args)).toEqual([ + "--no-sandbox", + HYPERFRAMES_BROWSER_MARKER_ARG, + ]); + expect(args).toEqual(["--no-sandbox"]); + }); + + it("adds the marker once when a wrapper marks already-marked args", () => { + expect(withHyperframesBrowserMarker(withHyperframesBrowserMarker())).toEqual([ + HYPERFRAMES_BROWSER_MARKER_ARG, + ]); + }); +}); diff --git a/packages/core/src/browserMarker.ts b/packages/core/src/browserMarker.ts new file mode 100644 index 0000000000..7ab713dc7b --- /dev/null +++ b/packages/core/src/browserMarker.ts @@ -0,0 +1,8 @@ +/** An inert Chrome switch on every browser HyperFrames launches, so orphan cleanup can tell its own from other tools'. */ +export const HYPERFRAMES_BROWSER_MARKER_ARG = "--hyperframes-owned-browser"; + +export function withHyperframesBrowserMarker(args: readonly string[] = []): string[] { + return args.includes(HYPERFRAMES_BROWSER_MARKER_ARG) + ? [...args] + : [...args, HYPERFRAMES_BROWSER_MARKER_ARG]; +} diff --git a/packages/engine/src/services/browserManager-ownerMarker.test.ts b/packages/engine/src/services/browserManager-ownerMarker.test.ts new file mode 100644 index 0000000000..f9a539fb51 --- /dev/null +++ b/packages/engine/src/services/browserManager-ownerMarker.test.ts @@ -0,0 +1,74 @@ +import { HYPERFRAMES_BROWSER_MARKER_ARG } from "@hyperframes/core/browser-marker"; +import type { LaunchOptions, PuppeteerNode } from "puppeteer-core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + _resetAutoBrowserGpuModeCacheForTests, + _resetBrowserPoolForTests, + _setPuppeteerForTests, + acquireBrowser, + drainBrowserPool, + resolveBrowserGpuMode, +} from "./browserManager.js"; + +function fakeChrome(newPage = vi.fn().mockResolvedValue({ evaluate: vi.fn() })) { + const launch = vi.fn(async (_options: LaunchOptions) => ({ + connected: true, + newPage, + version: vi.fn().mockResolvedValue("HeadlessChrome/152.0.7977.30"), + close: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn(), + process: () => ({ kill: vi.fn(), killed: false }), + })); + _setPuppeteerForTests({ launch } as unknown as PuppeteerNode); + return launch; +} + +const launchedArgs = (launch: ReturnType) => + launch.mock.calls.map(([options]) => options.args ?? []); + +describe("every engine Chrome carries the HyperFrames marker", () => { + beforeEach(() => { + vi.stubEnv("PRODUCER_HEADLESS_SHELL_PATH", process.execPath); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(async () => { + await drainBrowserPool(); + _resetBrowserPoolForTests(); + _resetAutoBrowserGpuModeCacheForTests(); + _setPuppeteerForTests(undefined); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it("marks the GPU probe browser", async () => { + const launch = fakeChrome(); + await resolveBrowserGpuMode("auto", { platform: "linux" }); + expect(launchedArgs(launch)[0]).toContain(HYPERFRAMES_BROWSER_MARKER_ARG); + }); + + it("marks the capture browser without changing the caller's args", async () => { + const launch = fakeChrome(); + const args = ["--no-sandbox"]; + const lease = await acquireBrowser(args, { forceScreenshot: true }); + await lease.release(); + expect(launchedArgs(launch)).toEqual([["--no-sandbox", HYPERFRAMES_BROWSER_MARKER_ARG]]); + expect(args).toEqual(["--no-sandbox"]); + }); + + it.runIf(process.platform === "linux")( + "marks the screenshot relaunch after a failed beginFrame probe", + async () => { + const launch = fakeChrome(vi.fn().mockRejectedValue(new Error("no beginFrame"))); + const lease = await acquireBrowser(["--enable-begin-frame-control"], { + forceScreenshot: false, + }); + await lease.release(); + const args = launchedArgs(launch); + expect(args).toHaveLength(2); + for (const launched of args) expect(launched).toContain(HYPERFRAMES_BROWSER_MARKER_ARG); + }, + ); +}); diff --git a/packages/engine/src/services/browserManager.ts b/packages/engine/src/services/browserManager.ts index 71530f76da..6380a149a7 100644 --- a/packages/engine/src/services/browserManager.ts +++ b/packages/engine/src/services/browserManager.ts @@ -6,6 +6,7 @@ * launch args, pooled browser acquisition/release. */ +import { withHyperframesBrowserMarker } from "@hyperframes/core/browser-marker"; import { scanHtmlOpeningTags } from "@hyperframes/parsers/html-attribute-spans"; import type { Browser, LaunchOptions, Page, PuppeteerNode } from "puppeteer-core"; import { execSync } from "child_process"; @@ -111,7 +112,7 @@ async function probeHardwareWebGlInfo( const launchOptions: LaunchOptions = { ...sigintLaunchOptions(), headless: true, - args: options.args, + args: withHyperframesBrowserMarker(options.args), defaultViewport: { width: 64, height: 64 }, executablePath: options.executablePath, env: options.env, @@ -854,7 +855,7 @@ async function launchBrowser( browser = await ppt.launch({ ...sigintLaunchOptions(), headless: true, - args: [...fingerprint.args], + args: withHyperframesBrowserMarker(fingerprint.args), defaultViewport: null, executablePath: fingerprint.executablePath, env, @@ -887,7 +888,7 @@ async function launchBrowser( browser = await ppt.launch({ ...sigintLaunchOptions(), headless: true, - args: stripBeginFrameFlags([...fingerprint.args]), + args: withHyperframesBrowserMarker(stripBeginFrameFlags([...fingerprint.args])), defaultViewport: null, executablePath: fingerprint.executablePath, env, diff --git a/packages/engine/src/services/hdrCapture-ownerMarker.test.ts b/packages/engine/src/services/hdrCapture-ownerMarker.test.ts new file mode 100644 index 0000000000..1ecd70c311 --- /dev/null +++ b/packages/engine/src/services/hdrCapture-ownerMarker.test.ts @@ -0,0 +1,23 @@ +import { HYPERFRAMES_BROWSER_MARKER_ARG } from "@hyperframes/core/browser-marker"; +import type { LaunchOptions } from "puppeteer-core"; +import { expect, it, vi } from "vitest"; + +const launch = vi.hoisted(() => + vi.fn(async (_options: LaunchOptions) => ({ + newPage: async () => ({ setViewport: async () => undefined }), + })), +); +vi.mock("puppeteer", () => ({ default: { launch } })); +vi.mock("puppeteer-core", () => ({ default: { launch } })); +vi.mock("./browserManager.js", async (importOriginal) => ({ + ...(await importOriginal()), + resolveFullChromePath: () => "/opt/chrome/chrome", +})); + +const { launchHdrBrowser } = await import("./hdrCapture.js"); + +it("marks the HDR browser", async () => { + await launchHdrBrowser(64, 64); + expect(launch).toHaveBeenCalledTimes(1); + expect(launch.mock.calls[0]![0].args).toContain(HYPERFRAMES_BROWSER_MARKER_ARG); +}); diff --git a/packages/engine/src/services/hdrCapture.ts b/packages/engine/src/services/hdrCapture.ts index d71a7a9a78..bdb43f229f 100644 --- a/packages/engine/src/services/hdrCapture.ts +++ b/packages/engine/src/services/hdrCapture.ts @@ -19,6 +19,7 @@ * Performance: ~6 fps at 1080x1920 via base64 transfer. */ +import { withHyperframesBrowserMarker } from "@hyperframes/core/browser-marker"; import type { Page, Browser, PuppeteerNode } from "puppeteer-core"; import { resolveFullChromePath, sigintLaunchOptions } from "./browserManager.js"; @@ -249,7 +250,7 @@ export async function launchHdrBrowser( ...sigintLaunchOptions(), headless: false, executablePath: chromePath, - args: buildHdrChromeArgs(width, height), + args: withHyperframesBrowserMarker(buildHdrChromeArgs(width, height)), }); const page = await browser.newPage(); diff --git a/packages/inspector/src/service.ts b/packages/inspector/src/service.ts index 1d270b45f1..ab09b1a39f 100644 --- a/packages/inspector/src/service.ts +++ b/packages/inspector/src/service.ts @@ -1,4 +1,5 @@ import type { TimelineRequest } from "../../core/src/runtime/timelineInspection"; +import { withHyperframesBrowserMarker } from "../../core/src/browserMarker"; import { inspectComponents, inspectElementMap, @@ -176,12 +177,12 @@ export class Inspector { this.browser = await puppeteer.launch({ executablePath: this.options.executablePath ?? chromePath(), headless: true, - args: [ + args: withHyperframesBrowserMarker([ "--disable-dev-shm-usage", "--enable-webgl", "--use-gl=angle", "--use-angle=swiftshader", - ], + ]), }); this.stats.browserStarts++; } diff --git a/packages/inspector/tests/serviceFingerprint.test.ts b/packages/inspector/tests/serviceFingerprint.test.ts index 1ff77294ba..790fdf8124 100644 --- a/packages/inspector/tests/serviceFingerprint.test.ts +++ b/packages/inspector/tests/serviceFingerprint.test.ts @@ -2,26 +2,32 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, it, vi } from "vitest"; +import { HYPERFRAMES_BROWSER_MARKER_ARG } from "../../core/src/browserMarker"; import { Inspector } from "../src/service"; +const launches = vi.hoisted(() => [] as { args?: string[] }[]); + vi.mock("puppeteer-core", () => ({ default: { - launch: async () => ({ - close: async () => {}, - newPage: async () => { - let html = ""; - return { - setViewport: async () => {}, - goto: async (url: string) => { - html = await (await fetch(url)).text(); - }, - waitForFunction: async () => {}, - evaluate: async () => {}, - content: async () => html, - close: async () => {}, - }; - }, - }), + launch: async (options: { args?: string[] }) => { + launches.push(options); + return { + close: async () => {}, + newPage: async () => { + let html = ""; + return { + setViewport: async () => {}, + goto: async (url: string) => { + html = await (await fetch(url)).text(); + }, + waitForFunction: async () => {}, + evaluate: async () => {}, + content: async () => html, + close: async () => {}, + }; + }, + }; + }, }, })); @@ -50,3 +56,24 @@ it("reloads the standalone page when an HF ID is stamped onto disk", async () => rmSync(project, { recursive: true, force: true }); } }); + +it("launches its browser with the HyperFrames marker", async () => { + const project = mkdtempSync(join(tmpdir(), "inspect-marker-")); + writeFileSync( + join(project, "index.html"), + '
', + ); + const inspector = new Inspector(project, { + executablePath: "browser-stand-in", + instrument: false, + }); + try { + launches.length = 0; + await inspector.withPage((page) => page.content()); + expect(launches).toHaveLength(1); + expect(launches[0]!.args).toContain(HYPERFRAMES_BROWSER_MARKER_ARG); + } finally { + await inspector.dispose(); + rmSync(project, { recursive: true, force: true }); + } +}); diff --git a/packages/producer/src/parity-harness.ts b/packages/producer/src/parity-harness.ts index b0b7eb696f..996883feb3 100644 --- a/packages/producer/src/parity-harness.ts +++ b/packages/producer/src/parity-harness.ts @@ -4,6 +4,7 @@ import { dirname, join, resolve } from "node:path"; import process from "node:process"; import { spawnSync } from "node:child_process"; import puppeteer, { type Browser, type Page } from "puppeteer-core"; +import { withHyperframesBrowserMarker } from "@hyperframes/core/browser-marker"; import { MEDIA_VISUAL_STYLE_PROPERTIES, quantizeTimeToFrame } from "./utils/parityContract.js"; type ParityHarnessOptions = { @@ -319,7 +320,7 @@ async function run(): Promise { height: options.height, deviceScaleFactor: 1, }, - args: [ + args: withHyperframesBrowserMarker([ "--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", @@ -331,7 +332,7 @@ async function run(): Promise { "--font-render-hinting=none", "--force-color-profile=srgb", `--window-size=${options.width},${options.height}`, - ], + ]), }); try { diff --git a/packages/producer/src/verifyStaticPage.ts b/packages/producer/src/verifyStaticPage.ts index 677f27f61d..3fa65e473b 100644 --- a/packages/producer/src/verifyStaticPage.ts +++ b/packages/producer/src/verifyStaticPage.ts @@ -2,6 +2,7 @@ // error, catching a script-fetched asset a static markup scan can't see. import puppeteer, { type Browser } from "puppeteer"; +import { withHyperframesBrowserMarker } from "@hyperframes/core/browser-marker"; import { injectScriptsAtHeadStart } from "@hyperframes/core/compiler"; import { getVerifiedHyperframeRuntimeSource } from "./services/hyperframeRuntimeLoader.js"; @@ -13,7 +14,7 @@ export async function launchVerifyBrowser(): Promise { return puppeteer.launch({ headless: true, executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined, - args: ["--no-sandbox", "--disable-setuid-sandbox"], + args: withHyperframesBrowserMarker(["--no-sandbox", "--disable-setuid-sandbox"]), }); }