Created
July 21, 2026 19:58
-
-
Save staylor/900a3f825f110e9904e3cfa2191a6637 to your computer and use it in GitHub Desktop.
Lightpanda page/context close timeout reproducer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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