From 71595dd82012314f353d1237f50da0fad7639817 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 14 Oct 2026 19:19:37 +0000 Subject: [PATCH] fix(cli): check and snapshot run where a sandbox refuses to listen on localhost (#5618) --- ...reCompositionFrame.browserFallback.test.ts | 75 ++++++++++++- .../src/capture/captureCompositionFrame.ts | 35 +++++- .../cli/src/utils/staticProjectServer.test.ts | 57 +++++++++- packages/cli/src/utils/staticProjectServer.ts | 106 +++++++++++++++++- 4 files changed, 266 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/capture/captureCompositionFrame.browserFallback.test.ts b/packages/cli/src/capture/captureCompositionFrame.browserFallback.test.ts index aa77cbef3f..fa929722f3 100644 --- a/packages/cli/src/capture/captureCompositionFrame.browserFallback.test.ts +++ b/packages/cli/src/capture/captureCompositionFrame.browserFallback.test.ts @@ -5,7 +5,7 @@ const mocks = vi.hoisted(() => ({ ensureBrowser: vi.fn(), findSystemBrowser: vi.fn(), launch: vi.fn(), - buildChromeArgs: vi.fn(() => []), + buildChromeArgs: vi.fn((): string[] => []), resolveBrowserGpuMode: vi.fn(async () => "software" as const), compositionRequiresWebGpu: vi.fn(() => false), assertWebGpuAdapterAvailable: vi.fn(async () => undefined), @@ -33,6 +33,11 @@ vi.mock("@hyperframes/engine", () => ({ })); import { openSettledCompositionPage } from "./captureCompositionFrame.js"; +import { Server } from "node:http"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { serveStaticProjectHtml } from "../utils/staticProjectServer.js"; const HTML = '
'; const OPTIONS = { @@ -54,6 +59,8 @@ function fakeBrowser() { goto: vi.fn(async () => undefined), waitForFunction: vi.fn(async () => undefined), evaluate: vi.fn(async () => undefined), + setRequestInterception: vi.fn(async (_on: boolean) => undefined), + on: vi.fn((_event: string, _handler: (request: unknown) => void) => undefined), }; return { browser: { @@ -178,3 +185,69 @@ describe("openSettledCompositionPage Windows bundled-browser recovery", () => { expect(mocks.launch).toHaveBeenCalledOnce(); }); }); + +describe("openSettledCompositionPage when nothing may listen", () => { + const launched = () => + mocks.launch.mock.calls.at(-1)?.[0] as unknown as { pipe?: boolean; args: string[] }; + + beforeEach(() => { + vi.resetAllMocks(); + mocks.ensureBrowser.mockResolvedValue({ executablePath: BUNDLED, source: "cache" }); + mocks.buildChromeArgs.mockReturnValue(["--headless"]); + mocks.resolveBrowserGpuMode.mockResolvedValue("software"); + mocks.resolveConfig.mockReturnValue({ allowSoftwareWebGpu: false }); + mocks.usesSoftwareWebGpu.mockReturnValue(false); + }); + + afterEach(() => vi.restoreAllMocks()); + + it("drives Chrome over a pipe in one process and answers the page from the project", async () => { + vi.spyOn(Server.prototype, "listen").mockImplementation(function (this: Server) { + process.nextTick(() => + this.emit("error", Object.assign(new Error("listen EPERM"), { code: "EPERM" })), + ); + return this; + }); + const dir = mkdtempSync(join(tmpdir(), "hf-nolisten-")); + const server = await serveStaticProjectHtml(dir, "composition"); + const opened = fakeBrowser(); + mocks.launch.mockResolvedValue(opened.browser); + try { + await openSettledCompositionPage(HTML, server.url, OPTIONS); + + expect(launched().pipe).toBe(true); + expect(launched().args).toEqual(["--headless", "--single-process"]); + expect(opened.page.setRequestInterception).toHaveBeenCalledWith(true); + const onRequest = opened.page.on.mock.calls.find(([event]) => event === "request")![1]; + const respond = vi.fn(async (_response: { status: number; body: Buffer }) => undefined); + const proceed = vi.fn(async () => undefined); + const request = (url: string) => ({ + url: () => url, + method: () => "GET", + headers: () => ({}), + isInterceptResolutionHandled: () => false, + respond, + continue: proceed, + abort: vi.fn(async () => undefined), + }); + onRequest(request(`${server.url}index.html`)); + await vi.waitFor(() => expect(respond).toHaveBeenCalledOnce()); + expect(respond.mock.calls[0]![0]).toMatchObject({ status: 200 }); + expect(String(respond.mock.calls[0]![0].body)).toContain("composition"); + onRequest(request("https://cdn.example/gsap.js")); + expect(proceed).toHaveBeenCalledOnce(); + } finally { + await server.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("keeps the usual launch for a project served on a port", async () => { + const opened = fakeBrowser(); + mocks.launch.mockResolvedValue(opened.browser); + await openSettledCompositionPage(HTML, "http://127.0.0.1:3000", OPTIONS); + expect(launched().pipe).toBeUndefined(); + expect(launched().args).toEqual(["--headless"]); + expect(opened.page.setRequestInterception).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/capture/captureCompositionFrame.ts b/packages/cli/src/capture/captureCompositionFrame.ts index 08c2abdafc..a15ab88eea 100644 --- a/packages/cli/src/capture/captureCompositionFrame.ts +++ b/packages/cli/src/capture/captureCompositionFrame.ts @@ -5,6 +5,7 @@ import { } from "../browser/launch.js"; import { spawn } from "node:child_process"; import type { Browser, Page } from "puppeteer-core"; +import type { InProcessRequest, InProcessResponse } from "../utils/staticProjectServer.js"; import { c } from "../ui/colors.js"; import { assertWebGpuAdapterAvailable, @@ -201,6 +202,8 @@ export async function openSettledCompositionPage( const puppeteer = await import("puppeteer-core"); const { buildChromeArgs } = await import("@hyperframes/engine"); const requestedGpuMode = options.browserGpuMode ?? resolveCliChromeGpuMode(); + // Nothing may listen (a sandbox refused it): DevTools over a pipe, one process, pages answered in-process. + const answer = (await import("../utils/staticProjectServer.js")).inProcessAnswer(url); const requiresWebGpu = compositionRequiresWebGpu(html); let softwareWebGpu = false; const launch = async (executablePath: string): Promise => { @@ -212,14 +215,15 @@ export async function openSettledCompositionPage( options.signal?.throwIfAborted(); const webGpu = resolveLocalWebGpu(resolvedGpuMode, requiresWebGpu); softwareWebGpu = webGpu.softwareWebGpu; + const args = buildChromeArgs( + { ...viewport, captureMode: "screenshot", requiresWebGpu }, + webGpu.gpuConfig, + ); return launchManagedBrowser(puppeteer.default, { headless: true, signal: options.signal, executablePath, - args: buildChromeArgs( - { ...viewport, captureMode: "screenshot", requiresWebGpu }, - webGpu.gpuConfig, - ), + ...(answer ? { pipe: true, args: [...args, "--single-process"] } : { args }), }); }; @@ -258,6 +262,7 @@ export async function openSettledCompositionPage( const page = await chromeBrowser.newPage(); await installPageFunctionGuard(page); await page.setViewport(viewport); + if (answer) await answerPageInProcess(page, url, answer); await options.beforeNavigate?.(page); await page.goto(url, { waitUntil: "domcontentloaded", @@ -598,3 +603,25 @@ export async function runFfmpegOnce( }); }); } + +/** Requests to the in-process origin are answered here; any other request goes on as usual. */ +async function answerPageInProcess( + page: Page, + url: string, + answer: (request: InProcessRequest) => Promise, +): Promise { + const origin = new URL(url).origin; + await page.setRequestInterception(true); + page.on("request", (request) => { + if (request.isInterceptResolutionHandled()) return; + const target = new URL(request.url()); + if (target.origin !== origin) return void request.continue().catch(() => {}); + answer({ + method: request.method(), + path: target.pathname + target.search, + headers: request.headers(), + }) + .then((response) => request.respond(response)) + .catch(() => request.abort("failed").catch(() => {})); + }); +} diff --git a/packages/cli/src/utils/staticProjectServer.test.ts b/packages/cli/src/utils/staticProjectServer.test.ts index 013e81d4f0..abd60a17aa 100644 --- a/packages/cli/src/utils/staticProjectServer.test.ts +++ b/packages/cli/src/utils/staticProjectServer.test.ts @@ -2,7 +2,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { serveStaticProjectHtml, type StaticProjectServer } from "./staticProjectServer.js"; +import { Server } from "node:http"; +import { + inProcessAnswer, + serveStaticProjectHtml, + type StaticProjectServer, +} from "./staticProjectServer.js"; // `serveStaticProjectHtml` reaches these two studio-server helpers via a // absolute module regardless of which file's relative specifier reaches it). @@ -413,3 +418,53 @@ describe("serveStaticProjectHtml transparent media proxies", () => { expect(mocks.resolveProxy).not.toHaveBeenCalled(); }); }); + +describe("serveStaticProjectHtml when a sandbox refuses listen()", () => { + const refuse = (code: string) => + vi.spyOn(Server.prototype, "listen").mockImplementation(function (this: Server) { + process.nextTick(() => + this.emit("error", Object.assign(new Error(`listen ${code}`), { code })), + ); + return this; + }); + + afterEach(() => vi.restoreAllMocks()); + + it("answers the project in this process, Range included, with nothing listening", async () => { + const listen = refuse("EPERM"); + dir = mkdtempSync(join(tmpdir(), "hf-static-")); + writeFileSync(join(dir, "tone.wav"), Buffer.from("0123456789", "utf-8")); + server = await serveStaticProjectHtml(dir, "composition"); + + expect(listen).toHaveBeenCalledOnce(); + expect(server.port).toBe(0); + expect(inProcessAnswer(server.url)).toBe(server.answer); + const page = await server.answer!({ method: "GET", path: "/", headers: {} }); + expect(page.status).toBe(200); + expect(page.body.toString()).toContain("composition"); + const slice = await server.answer!({ + method: "GET", + path: "/tone.wav", + headers: { range: "bytes=2-5" }, + }); + expect(slice.status).toBe(206); + expect(slice.headers["content-range"]).toBe("bytes 2-5/10"); + expect(slice.body.toString()).toBe("2345"); + expect( + (await server.answer!({ method: "GET", path: "/missing.png", headers: {} })).status, + ).toBe(404); + + const url = server.url; + await server.close(); + server = undefined; + expect(inProcessAnswer(url)).toBeUndefined(); + }); + + it("still fails on a bind error that is not a refusal", async () => { + refuse("EADDRINUSE"); + dir = mkdtempSync(join(tmpdir(), "hf-static-")); + await expect(serveStaticProjectHtml(dir, "")).rejects.toMatchObject({ + code: "EADDRINUSE", + }); + }); +}); diff --git a/packages/cli/src/utils/staticProjectServer.ts b/packages/cli/src/utils/staticProjectServer.ts index a737470cf8..0031a8a130 100644 --- a/packages/cli/src/utils/staticProjectServer.ts +++ b/packages/cli/src/utils/staticProjectServer.ts @@ -1,4 +1,5 @@ -import { createServer, type ServerResponse } from "node:http"; +import { createServer, request as httpRequest, type Server, type ServerResponse } from "node:http"; +import { Duplex } from "node:stream"; import { createReadStream, existsSync, statSync } from "node:fs"; import { isAbsolute, relative, resolve } from "node:path"; import { getMimeType } from "@hyperframes/core/studio-api"; @@ -22,8 +23,92 @@ import { export interface StaticProjectServer { url: string; + /** 0 when nothing listens: the server answers in this process only (see `answer`). */ port: number; close: () => Promise; + /** Set when a sandbox refused listen(): the browser's requests to `url` come here instead of a socket. */ + answer?: (request: InProcessRequest) => Promise; +} + +export interface InProcessRequest { + method: string; + /** Path and query, e.g. `/assets/a.mp4?hf-proxy=alpha`. */ + path: string; + headers: Record; +} + +export interface InProcessResponse { + status: number; + headers: Record; + body: Buffer; +} + +/** A sandbox refuses a loopback listen() with one of these; any other bind error stays fatal. */ +const REFUSED_LISTEN = new Set(["EPERM", "EACCES"]); + +/** Two connected in-memory sockets: what one side writes, the other reads. */ +function socketPair(): [Duplex, Duplex] { + const sides: Duplex[] = []; + const side = (other: () => Duplex) => + new Duplex({ + read() {}, + write(chunk, _encoding, done) { + other().push(chunk); + done(); + }, + final(done) { + other().push(null); + done(); + }, + }); + sides.push( + side(() => sides[1]!), + side(() => sides[0]!), + ); + return [sides[0]!, sides[1]!]; +} + +/** Hands one request to `server` over an in-memory socket, so its handler (Range, proxies) answers unchanged. */ +function answerInProcess(server: Server, request: InProcessRequest): Promise { + const [client, served] = socketPair(); + server.emit("connection", served); + return new Promise((resolveAnswer, rejectAnswer) => { + const req = httpRequest( + { + method: request.method, + path: request.path, + headers: { ...request.headers, connection: "close" }, + // No `agent`: Node uses createConnection only when no agent is given. + createConnection: () => client as never, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("error", rejectAnswer); + res.on("end", () => { + const headers: Record = {}; + for (const [name, value] of Object.entries(res.headers)) { + if (value !== undefined && name !== "connection" && name !== "transfer-encoding") + headers[name] = Array.isArray(value) ? value.join(", ") : value; + } + resolveAnswer({ status: res.statusCode ?? 500, headers, body: Buffer.concat(chunks) }); + }); + }, + ); + req.on("error", rejectAnswer); + req.end(); + }); +} + +let inProcessServers = 0; +const inProcessAnswers = new Map< + string, + (request: InProcessRequest) => Promise +>(); + +/** The in-process answer for a URL served by `serveStaticProjectHtml` when listen() was refused, else undefined. */ +export function inProcessAnswer(url: string) { + return inProcessAnswers.get(new URL(url).origin); } /** @@ -229,8 +314,27 @@ export async function serveStaticProjectHtml( if (!resolvedPort) rejectPort(new Error(bindErrorMessage)); else resolvePort(resolvedPort); }); + }).catch((error: unknown) => { + if (REFUSED_LISTEN.has((error as NodeJS.ErrnoException).code ?? "")) return 0; + throw error; }); + if (port === 0) { + server.removeAllListeners("error"); + // A sandbox (e.g. an agent's) may refuse listen(); the browser asks this origin and the process answers. + const url = `http://hyperframes-${++inProcessServers}.localhost/`; + const answer = (request: InProcessRequest) => answerInProcess(server, request); + inProcessAnswers.set(new URL(url).origin, answer); + return { + url, + port: 0, + close: async () => { + inProcessAnswers.delete(new URL(url).origin); + }, + answer, + }; + } + return { url: `http://127.0.0.1:${port}/`, port,