Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/cli/src/browser/downloadStall.fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// fallow-ignore-file unused-file
import { ensureBrowser } from "./manager.js";

const [baseUrl] = process.argv.slice(2);
try {
await ensureBrowser({
preferManagedChrome: true,
downloadStallMs: 200,
downloadBaseUrl: baseUrl,
});
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
78 changes: 78 additions & 0 deletions packages/cli/src/browser/downloadStall.process.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { spawn } from "node:child_process";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { createServer, type Socket } from "node:net";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, expect, it } from "vitest";

const fixturePath = join(dirname(fileURLToPath(import.meta.url)), "downloadStall.fixture.ts");
const cleanups: (() => void)[] = [];

afterEach(() => {
for (const cleanup of cleanups.splice(0)) cleanup();
});

async function runFixture(onConnection: (socket: Socket) => void) {
const sockets: Socket[] = [];
const server = createServer((socket) => {
sockets.push(socket);
// Windows resets the held socket when the child is killed; that is expected.
socket.on("error", () => {});
onConnection(socket);
});
await new Promise<void>((ready) => server.listen(0, "127.0.0.1", ready));
const { port } = server.address() as { port: number };
const home = mkdtempSync(join(tmpdir(), "hf-download-stall-"));
const child = spawn(
process.execPath,
["--import", "tsx", fixturePath, `http://127.0.0.1:${port}`],
{
env: { ...process.env, HOME: home, USERPROFILE: home, HYPERFRAMES_BROWSER_PATH: "" },
stdio: ["ignore", "ignore", "pipe"],
},
);
cleanups.push(() => {
child.kill("SIGKILL");
for (const socket of sockets) socket.destroy();
server.close();
rmSync(home, { recursive: true, force: true });
});
let stderr = "";
child.stderr.on("data", (chunk) => (stderr += chunk));
const startedAt = Date.now();
const code = await new Promise<number | null>((resolve) => {
const giveUp = setTimeout(() => resolve(null), 20_000);
child.once("exit", (exitCode) => {
clearTimeout(giveUp);
resolve(exitCode);
});
});
return { code, stderr, port, home, elapsedMs: Date.now() - startedAt };
}

it("exits with code 1 when the download host accepts the connection and never answers", async () => {
const run = await runFixture(() => {});

expect(run.code).toBe(1);
expect(run.stderr).toContain(`The Chrome download from 127.0.0.1:${run.port} stalled`);
expect(run.stderr).toContain("Retrying (2/2)");
expect(existsSync(join(run.home, ".cache", "hyperframes", ".chrome.install.lock"))).toBe(false);
}, 30_000);

it("does not call a download that keeps sending bytes a stall", async () => {
const run = await runFixture((socket) => {
socket.once("data", () => {
socket.write("HTTP/1.1 200 OK\r\ncontent-length: 100000\r\n\r\n");
const trickle = setInterval(() => socket.write("x"), 80);
setTimeout(() => {
clearInterval(trickle);
socket.destroy();
}, 1_200);
});
});

expect(run.code).toBe(1);
expect(run.stderr).not.toContain("no data for");
expect(run.elapsedMs).toBeGreaterThan(1_000);
}, 30_000);
86 changes: 86 additions & 0 deletions packages/cli/src/browser/installInChild.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { runRenderSetupWorker } from "../utils/cancellableProcess.js";
import { parseInstallLine } from "./installProtocol.js";

type InstallOptions = import("@puppeteer/browsers").InstallOptions;

export type ChildInstallInput = Pick<
InstallOptions,
"cacheDir" | "browser" | "buildId" | "platform" | "baseUrl"
> & { unpack?: false };

interface InstalledPaths {
path: string;
executablePath: string;
}

interface InstallHooks {
stallMs: number;
host: string;
onProgress?: (downloadedBytes: number, totalBytes: number) => void;
onLog: (line: string) => void;
}

const CHILD_STARTUP_MS = 15_000;

export class BrowserDownloadStalledError extends Error {}

function lineReader(onLine: (line: string) => void): (chunk: string) => void {
let pending = "";
return (chunk) => {
const lines = (pending + chunk).split("\n");
pending = lines.pop() ?? "";
for (const line of lines) onLine(line);
};
}

/**
* install() takes no abort signal and keeps its sockets open, so it runs in its own process: a stall
* kills that process and the OS closes the sockets, with nothing in this one left to clean up.
*/
export async function installInChild(
input: ChildInstallInput & { unpack: false },
hooks: InstallHooks,
): Promise<string>;
export async function installInChild(
input: ChildInstallInput,
hooks: InstallHooks,
): Promise<InstalledPaths>;
export async function installInChild(
input: ChildInstallInput,
hooks: InstallHooks,
): Promise<string | InstalledPaths> {
const abort = new AbortController();
let timer: NodeJS.Timeout | undefined;
const arm = (waitMs: number) => {
clearTimeout(timer);
timer = setTimeout(
() =>
abort.abort(
new BrowserDownloadStalledError(
`The Chrome download from ${hooks.host} stalled: no data for ${hooks.stallMs / 1000}s.`,
),
),
waitMs,
);
timer.unref();
};
const onStdout = lineReader((line) => {
const event = parseInstallLine(line);
if (event?.kind === "log") hooks.onLog(event.text);
if (event?.kind !== "progress") return;
if (event.total > 0) hooks.onProgress?.(event.downloaded, event.total);
if (event.total > 0 && event.downloaded >= event.total) clearTimeout(timer);
else arm(hooks.stallMs);
});
arm(CHILD_STARTUP_MS + hooks.stallMs);
try {
return await runRenderSetupWorker<string | InstalledPaths>("browser-install", input, {
signal: abort.signal,
detached: false,
maxBufferBytes: 4 * 1024 * 1024,
onStdout,
});
} finally {
clearTimeout(timer);
}
}
32 changes: 32 additions & 0 deletions packages/cli/src/browser/installProtocol.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
const PROGRESS_PREFIX = "HYPERFRAMES_BROWSER_INSTALL_PROGRESS:";
const LOG_PREFIX = "HYPERFRAMES_BROWSER_INSTALL_LOG:";

export type InstallEvent =
| { kind: "progress"; downloaded: number; total: number }
| { kind: "log"; text: string };

export function installProgressLine(downloaded: number, total: number): string {
return `${PROGRESS_PREFIX}${JSON.stringify([downloaded, total])}\n`;
}

export function installLogLine(text: string): string {
return `${LOG_PREFIX}${JSON.stringify(text)}\n`;
}

export function parseInstallLine(line: string): InstallEvent | null {
try {
if (line.startsWith(PROGRESS_PREFIX)) {
const [downloaded, total] = JSON.parse(line.slice(PROGRESS_PREFIX.length)) as [
number,
number,
];
return { kind: "progress", downloaded, total };
}
if (line.startsWith(LOG_PREFIX)) {
return { kind: "log", text: JSON.parse(line.slice(LOG_PREFIX.length)) as string };
}
} catch {
// a torn line is dropped: the next progress line carries the same information
}
return null;
}
35 changes: 35 additions & 0 deletions packages/cli/src/browser/installRun.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { debuglog } from "node:util";
import { installLogLine, installProgressLine } from "./installProtocol.js";
import type { ChildInstallInput } from "./installInChild.js";

const PROGRESS_EVERY_MS = 100;

/** Runs in the install child: reports progress and unzip log lines on stdout, returns what install() produced. */
export async function runBrowserInstall(input: ChildInstallInput): Promise<unknown> {
const { install } = await import("@puppeteer/browsers");
let lastReport = 0;
process.stdout.write(installProgressLine(0, 0));
const { unpack, ...rest } = input;
const options = {
...rest,
downloadProgressCallback: (downloaded: number, total: number) => {
const now = Date.now();
if (now - lastReport < PROGRESS_EVERY_MS && downloaded < total) return;
lastReport = now;
process.stdout.write(installProgressLine(downloaded, total));
},
logger: (prefix: string) => {
const debug = debuglog(prefix);
const isUnzip = prefix === "puppeteer:browsers:fileUtil";
if (!isUnzip && !debug.enabled) return undefined;
return (...args: unknown[]) => {
const line = args.join(" ");
if (isUnzip) process.stdout.write(installLogLine(line));
if (debug.enabled) debug(line);
};
},
};
if (unpack === false) return install({ ...options, unpack: false });
const { path, executablePath } = await install(options);
return { path, executablePath };
}
21 changes: 21 additions & 0 deletions packages/cli/src/browser/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,27 @@ function installPuppeteerBrowsersMock(
mockedPaths?.add(path).add(executablePath);
return { executablePath, path };
};
vi.doMock("./installInChild.js", async () => {
const { install } = await import("@puppeteer/browsers");
return {
BrowserDownloadStalledError: class extends Error {},
installInChild: (
input: object,
hooks: {
onProgress?: (downloaded: number, total: number) => void;
onLog: (line: string) => void;
},
) =>
install({
...input,
downloadProgressCallback: hooks.onProgress,
logger: (prefix: string) =>
prefix === "puppeteer:browsers:fileUtil"
? (...args: unknown[]) => hooks.onLog(args.join(" "))
: undefined,
} as never),
};
});
vi.doMock("./unzipArchive.js", () => ({ unzipArchive: opts.unzip ?? vi.fn(async () => true) }));
vi.doMock("@puppeteer/browsers", () => ({
Browser: { CHROMEHEADLESSSHELL: "chrome-headless-shell" },
Expand Down
Loading
Loading