Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save staylor/900a3f825f110e9904e3cfa2191a6637 to your computer and use it in GitHub Desktop.

Select an option

Save staylor/900a3f825f110e9904e3cfa2191a6637 to your computer and use it in GitHub Desktop.
Lightpanda page/context close timeout reproducer
import { spawn } from "node:child_process";
import http from "node:http";
import net from "node:net";
import { setTimeout as sleep } from "node:timers/promises";
import playwright from "playwright-core";
const { chromium } = playwright;
const LIGHTPANDA_BIN = process.env.LIGHTPANDA_BIN ?? "lightpanda";
const CDP_PORT = Number(process.env.CDP_PORT ?? "9222");
const CDP = `http://127.0.0.1:${CDP_PORT}`;
const WORKERS = Number(process.env.WORKERS ?? "6");
const ITERATIONS = Number(process.env.ITERATIONS ?? "50");
const NAVIGATION_TIMEOUT_MS = Number(process.env.NAVIGATION_TIMEOUT_MS ?? "250");
const CLOSE_TIMEOUT_MS = Number(process.env.CLOSE_TIMEOUT_MS ?? "5100");
const hangingResponses = new Set();
const fixtureSockets = new Set();
function fixtureHtml() {
const heldAsync = Array.from(
{ length: 5 },
(_, index) => `<script async src="/hold?async=${index}"></script>`,
).join("");
const heldDefer = Array.from(
{ length: 5 },
(_, index) => `<script defer src="/hold?defer=${index}"></script>`,
).join("");
const readyAsync = Array.from(
{ length: 5 },
(_, index) => `<script async src="/ok?async=${index}"></script>`,
).join("");
const readyDefer = Array.from(
{ length: 18 },
(_, index) => `<script defer src="/ok?defer=${index}"></script>`,
).join("");
const readyBlocking = Array.from(
{ length: 9 },
(_, index) => `<script src="/ok?blocking=${index}"></script>`,
).join("");
const styles = Array.from(
{ length: 12 },
(_, index) => `<link rel="stylesheet" href="/ok?style=${index}">`,
).join("");
return `<!doctype html><html><head>${heldAsync}${heldDefer}${readyAsync}${readyDefer}${readyBlocking}${styles}</head><body>fixture</body></html>`;
}
const fixture = http.createServer((request, response) => {
if (request.url?.startsWith("/hold")) {
hangingResponses.add(response);
response.on("close", () => hangingResponses.delete(response));
return;
}
if (request.url?.startsWith("/ok")) {
response.writeHead(200, { "content-type": "application/javascript" });
response.end("");
return;
}
response.writeHead(200, { "content-type": "text/html" });
response.end(fixtureHtml());
});
fixture.on("connection", (socket) => {
fixtureSockets.add(socket);
socket.on("close", () => fixtureSockets.delete(socket));
});
const proxy = http.createServer((request, response) => {
const url = new URL(request.url);
const upstream = http.request(
{
host: url.hostname,
port: Number(url.port || 80),
method: request.method,
path: `${url.pathname}${url.search}`,
headers: request.headers,
},
(upstreamResponse) => {
response.writeHead(upstreamResponse.statusCode ?? 502, upstreamResponse.headers);
upstreamResponse.pipe(response);
},
);
upstream.on("error", () => response.writeHead(502).end());
request.pipe(upstream);
});
proxy.on("connect", (request, clientSocket, head) => {
const [host, portString] = request.url.split(":");
const upstream = net.connect(Number(portString || 443), host, () => {
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
if (head.length > 0) upstream.write(head);
upstream.pipe(clientSocket);
clientSocket.pipe(upstream);
});
upstream.on("error", () => clientSocket.destroy());
clientSocket.on("error", () => upstream.destroy());
});
function waitForListen(server) {
return new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
}
async function raceClose(close) {
let timer;
try {
return await Promise.race([
close().then(() => "closed", () => "error"),
new Promise((resolve) => {
timer = setTimeout(() => resolve("timeout"), CLOSE_TIMEOUT_MS);
}),
]);
} finally {
clearTimeout(timer);
}
}
await waitForListen(fixture);
await waitForListen(proxy);
const fixturePort = fixture.address().port;
const proxyPort = proxy.address().port;
const target = `http://127.0.0.1:${fixturePort}/`;
const lightpanda = spawn(
LIGHTPANDA_BIN,
[
"serve",
"--host",
"127.0.0.1",
"--port",
String(CDP_PORT),
"--cdp-max-connections",
String(WORKERS),
"--disable-subframes",
"--disable-workers",
"--http_proxy",
`http://127.0.0.1:${proxyPort}`,
"--log-level",
"warn",
],
{ stdio: "ignore" },
);
async function waitForLightpanda() {
for (let attempt = 0; attempt < 50; attempt++) {
try {
const browser = await chromium.connectOverCDP(CDP, { timeout: 100 });
await browser.close();
return;
} catch {
await sleep(100);
}
}
throw new Error(`Lightpanda did not become ready at ${CDP}`);
}
await waitForLightpanda();
const stats = {
contexts: 0,
navigationTimeouts: 0,
pageCloseTimeouts: 0,
contextCloseTimeouts: 0,
};
async function worker() {
const browser = await chromium.connectOverCDP(CDP, { timeout: 15_000 });
try {
for (let index = 0; index < ITERATIONS; index++) {
const context = await browser.newContext();
const page = await context.newPage();
await context.route("**/*", (route) => route.continue());
await page.addInitScript("new MutationObserver(() => {}).observe(document, { childList: true, subtree: true })");
const navigated = await page
.goto(target, {
timeout: NAVIGATION_TIMEOUT_MS,
waitUntil: "domcontentloaded",
})
.then(() => true, () => false);
if (!navigated) stats.navigationTimeouts++;
const [pageClose, contextClose] = await Promise.all([
raceClose(() => page.close()),
raceClose(() => context.close()),
]);
if (pageClose === "timeout") stats.pageCloseTimeouts++;
if (contextClose === "timeout") stats.contextCloseTimeouts++;
stats.contexts++;
}
} finally {
await browser.close().catch(() => {});
}
}
try {
console.log({ CDP, target, proxyPort, WORKERS, ITERATIONS });
await Promise.all(Array.from({ length: WORKERS }, worker));
console.log(stats);
} finally {
lightpanda.kill("SIGTERM");
for (const response of hangingResponses) response.destroy();
for (const socket of fixtureSockets) socket.destroy();
await Promise.all([
new Promise((resolve) => fixture.close(resolve)),
new Promise((resolve) => proxy.close(resolve)),
]);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment