fix(cli): check and snapshot run where a sandbox refuses to listen on localhost (#5618)

This commit is contained in:
Miguel Ángel
2026-10-14 19:19:37 +00:00
committed by GitHub
parent b14a456471
commit 71595dd820
4 changed files with 266 additions and 7 deletions
@@ -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 = '<div data-composition-id="main" data-width="1920" data-height="1080"></div>';
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, "<html><body>composition</body></html>");
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();
});
});
@@ -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<Browser> => {
@@ -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<InProcessResponse>,
): Promise<void> {
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(() => {}));
});
}
@@ -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, "<html><body>composition</body></html>");
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, "<html></html>")).rejects.toMatchObject({
code: "EADDRINUSE",
});
});
});
+105 -1
View File
@@ -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<void>;
/** Set when a sandbox refused listen(): the browser's requests to `url` come here instead of a socket. */
answer?: (request: InProcessRequest) => Promise<InProcessResponse>;
}
export interface InProcessRequest {
method: string;
/** Path and query, e.g. `/assets/a.mp4?hf-proxy=alpha`. */
path: string;
headers: Record<string, string>;
}
export interface InProcessResponse {
status: number;
headers: Record<string, string>;
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<InProcessResponse> {
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<string, string> = {};
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<InProcessResponse>
>();
/** 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,