Skip to content

Commit eada22a

Browse files
committed
Harden browser infra: HTTP heartbeat, idle retirement, SIGKILL, listener cleanup
Chrome's internal debug server freezes after hours of idle (known Chromium bug). The previous CDP-based heartbeat hung silently because Playwright buffers messages on a dead WebSocket without erroring. Fixes: - Heartbeat now pings Chrome's HTTP debug port (/json/version) with a 5s hard timeout. Detects frozen Chrome and auto-relaunches. - Idle retirement: recycle Chrome after 30min with 0 active pages, before it can freeze. - newPage() wrapped with 15s operation timeout (Crawlee pattern). - SIGTERM then SIGKILL after 5s in cleanup if Chrome doesn't die. - Launch event listeners (on-error, on-exit) removed after promise settles to prevent accumulation across relaunches. - pwBrowser "disconnected" listener triggers auto-relaunch on CDP drop. - Stale withPage hard-timeout timer cleared on success (no more ghost HARD TIMEOUT log entries).
1 parent f691fed commit eada22a

1 file changed

Lines changed: 158 additions & 32 deletions

File tree

src/browser/playwright-pool.ts

Lines changed: 158 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
* adds credentials and tunnels to the upstream proxy.
1919
*/
2020

21+
import http from "http";
2122
import { spawn, type ChildProcess } from "child_process";
2223
import { createInterface } from "readline";
2324
import { mkdtempSync, rmSync } from "fs";
@@ -95,6 +96,7 @@ export class ChromeInstance {
9596

9697
private state: "launching" | "active" | "retired" | "closed" = "launching";
9798
private totalPages = 0;
99+
private lastPageAt = Date.now();
98100
private recycling = false;
99101
private readonly limit: ReturnType<typeof pLimit>;
100102
private readonly logger: Logger;
@@ -182,6 +184,7 @@ export class ChromeInstance {
182184
}
183185

184186
const pageStart = Date.now();
187+
this.lastPageAt = pageStart;
185188
this.logger.info(
186189
{
187190
proxy: redactProxy(this.proxyUrl),
@@ -191,7 +194,12 @@ export class ChromeInstance {
191194
"withPage: tab acquired"
192195
);
193196

194-
const page = await this.pwContext.newPage();
197+
const page = await Promise.race([
198+
this.pwContext.newPage(),
199+
new Promise<never>((_, reject) =>
200+
setTimeout(() => reject(new Error("newPage() timed out after 15s")), 15_000)
201+
),
202+
]);
195203

196204
try {
197205
// Hard timeout: if fn hangs, reject and force-close the page.
@@ -332,9 +340,24 @@ export class ChromeInstance {
332340
stdio: ["ignore", "pipe", "pipe"],
333341
});
334342

335-
// Extract WebSocket URL from Chrome stderr
343+
// Extract WebSocket URL from Chrome stderr.
344+
// Listeners are removed after the promise settles to prevent accumulation
345+
// across relaunches (each launch adds listeners to the same ChildProcess
346+
// event emitter; without cleanup they pile up and leak memory).
347+
const errorListener = (err: Error) => {
348+
clearTimeout(launchTimeout);
349+
rejectLaunch(new Error(`Failed to launch Chrome: ${err.message}`));
350+
};
351+
const exitListener = (code: number | null) => {
352+
clearTimeout(launchTimeout);
353+
rejectLaunch(new Error(`Chrome exited with code ${code} before ready`));
354+
};
355+
let rejectLaunch: (err: Error) => void;
356+
let launchTimeout: ReturnType<typeof setTimeout>;
357+
336358
this.wsEndpoint = await new Promise<string>((resolve, reject) => {
337-
const launchTimeout = setTimeout(() => {
359+
rejectLaunch = reject;
360+
launchTimeout = setTimeout(() => {
338361
reject(new Error("Timed out waiting for Chrome to start"));
339362
}, CHROME_LAUNCH_TIMEOUT_MS);
340363

@@ -350,15 +373,13 @@ export class ChromeInstance {
350373
});
351374
}
352375

353-
this.chromeProcess!.on("error", (err) => {
354-
clearTimeout(launchTimeout);
355-
reject(new Error(`Failed to launch Chrome: ${err.message}`));
356-
});
357-
358-
this.chromeProcess!.on("exit", (code) => {
359-
clearTimeout(launchTimeout);
360-
reject(new Error(`Chrome exited with code ${code} before ready`));
361-
});
376+
this.chromeProcess!.on("error", errorListener);
377+
this.chromeProcess!.on("exit", exitListener);
378+
}).finally(() => {
379+
if (this.chromeProcess) {
380+
this.chromeProcess.removeListener("error", errorListener);
381+
this.chromeProcess.removeListener("exit", exitListener);
382+
}
362383
});
363384

364385
// Connect Playwright via CDP (standard, no stealth wrapper).
@@ -419,16 +440,42 @@ export class ChromeInstance {
419440
/* non-fatal */
420441
}
421442

422-
// Handle Chrome crashes
443+
// Handle Chrome crashes (process exit)
423444
this.chromeProcess!.on("exit", () => {
424445
if (this.state === "active") {
425-
this.logger.warn({ proxy: redactProxy(this.proxyUrl) }, "Chrome process crashed");
446+
this.logger.warn(
447+
{ proxy: redactProxy(this.proxyUrl) },
448+
"Chrome process exited unexpectedly"
449+
);
426450
this.state = "closed";
451+
this.stopHeartbeat();
427452
this.pwBrowser = null;
428453
this.pwContext = null;
429454
}
430455
});
431456

457+
// Handle Playwright disconnection (CDP WebSocket drop)
458+
this.pwBrowser.on("disconnected", () => {
459+
if (this.state === "active" && !this.recycling) {
460+
this.logger.warn(
461+
{ proxy: redactProxy(this.proxyUrl) },
462+
"Playwright disconnected, relaunching"
463+
);
464+
this.stopHeartbeat();
465+
this.recycling = true;
466+
void this.relaunch()
467+
.catch((err) => {
468+
this.logger.error(
469+
{ err, proxy: redactProxy(this.proxyUrl) },
470+
"disconnect relaunch failed"
471+
);
472+
})
473+
.finally(() => {
474+
this.recycling = false;
475+
});
476+
}
477+
});
478+
432479
this.state = "active";
433480
this.startHeartbeat();
434481
this.resolveReady();
@@ -446,30 +493,84 @@ export class ChromeInstance {
446493
}
447494

448495
/**
449-
* Send a lightweight CDP command every 30s to keep the WebSocket alive.
450-
* Without this, the TCP connection between Playwright and Chrome dies
451-
* silently after ~2 hours of idle (Linux tcp_keepalive_time default).
496+
* Every 30s: ping Chrome's HTTP debug endpoint and check idle time.
497+
*
498+
* Health check: GET /json/version with 5s timeout. If Chrome's internal
499+
* server is frozen (known Chromium bug after hours of idle), the PID
500+
* stays alive but the debug port hangs. Detect and relaunch.
501+
*
502+
* Idle retirement: if no pages have been opened for 30 minutes and no
503+
* tabs are active, proactively recycle Chrome before it can freeze.
452504
*/
453505
private startHeartbeat(): void {
454506
this.stopHeartbeat();
455-
this.heartbeatInterval = setInterval(async () => {
456-
if (this.state !== "active" || !this.pwBrowser) {
507+
const IDLE_RETIRE_MS = 30 * 60 * 1000; // 30 minutes
508+
509+
this.heartbeatInterval = setInterval(() => {
510+
if (this.state !== "active" || !this.wsEndpoint || this.recycling) {
457511
this.stopHeartbeat();
458512
return;
459513
}
460-
try {
461-
const cdp = await this.pwBrowser.newBrowserCDPSession();
462-
await cdp.send("Browser.getVersion");
463-
await cdp.detach();
464-
} catch {
465-
this.logger.warn(
466-
{ proxy: redactProxy(this.proxyUrl) },
467-
"CDP heartbeat failed, connection may be dead"
514+
515+
// Idle retirement: recycle Chrome before it freezes
516+
const idleMs = Date.now() - this.lastPageAt;
517+
if (idleMs >= IDLE_RETIRE_MS && this.limit.activeCount === 0) {
518+
this.logger.info(
519+
{ proxy: redactProxy(this.proxyUrl), idleMinutes: Math.round(idleMs / 60_000) },
520+
"Chrome idle too long, recycling proactively"
468521
);
522+
this.triggerRelaunch("idle retirement");
523+
return;
469524
}
525+
526+
// Health check: ping Chrome's debug port via HTTP
527+
const port = new URL(this.wsEndpoint).port;
528+
const req = http.get(`http://127.0.0.1:${port}/json/version`, { timeout: 5_000 }, (res) => {
529+
let body = "";
530+
res.on("data", (c: Buffer) => {
531+
body += c;
532+
});
533+
res.on("end", () => {
534+
if (!body) {
535+
this.logger.error(
536+
{ proxy: redactProxy(this.proxyUrl) },
537+
"Chrome debug port returned empty response"
538+
);
539+
this.triggerRelaunch("empty health response");
540+
}
541+
});
542+
});
543+
req.on("timeout", () => {
544+
req.destroy();
545+
this.logger.error(
546+
{ proxy: redactProxy(this.proxyUrl) },
547+
"Chrome debug port unresponsive (5s timeout)"
548+
);
549+
this.triggerRelaunch("health check timeout");
550+
});
551+
req.on("error", () => {
552+
this.logger.error(
553+
{ proxy: redactProxy(this.proxyUrl) },
554+
"Chrome debug port connection error"
555+
);
556+
this.triggerRelaunch("health check error");
557+
});
470558
}, 30_000);
471559
}
472560

561+
private triggerRelaunch(reason: string): void {
562+
this.stopHeartbeat();
563+
this.recycling = true;
564+
this.logger.warn({ proxy: redactProxy(this.proxyUrl), reason }, "triggering Chrome relaunch");
565+
void this.relaunch()
566+
.catch((err) => {
567+
this.logger.error({ err, proxy: redactProxy(this.proxyUrl) }, "relaunch failed");
568+
})
569+
.finally(() => {
570+
this.recycling = false;
571+
});
572+
}
573+
473574
private stopHeartbeat(): void {
474575
if (this.heartbeatInterval) {
475576
clearInterval(this.heartbeatInterval);
@@ -491,18 +592,43 @@ export class ChromeInstance {
491592
this.pwContext = null;
492593
}
493594

494-
// Kill Chrome
595+
// Kill Chrome: SIGTERM first, SIGKILL after 5s if it doesn't die
495596
if (this.chromeProcess?.pid && !this.chromeProcess.killed) {
597+
const pid = this.chromeProcess.pid;
598+
const proc = this.chromeProcess;
599+
this.chromeProcess = null;
600+
496601
try {
497602
if (process.platform !== "win32") {
498-
process.kill(-this.chromeProcess.pid, "SIGTERM");
603+
process.kill(-pid, "SIGTERM");
499604
} else {
500-
this.chromeProcess.kill("SIGTERM");
605+
proc.kill("SIGTERM");
501606
}
502607
} catch {
503-
/* swallow */
608+
/* already dead */
504609
}
505-
this.chromeProcess = null;
610+
611+
// Force-kill after 5s if still alive (Crawlee pattern)
612+
const killTimer = setTimeout(() => {
613+
try {
614+
if (!proc.killed) {
615+
this.logger.warn(
616+
{ proxy: redactProxy(this.proxyUrl) },
617+
"Chrome did not exit after SIGTERM, sending SIGKILL"
618+
);
619+
if (process.platform !== "win32") {
620+
process.kill(-pid, "SIGKILL");
621+
} else {
622+
proc.kill("SIGKILL");
623+
}
624+
}
625+
} catch {
626+
/* already dead */
627+
}
628+
}, 5_000);
629+
630+
// Clear timer if process exits on its own
631+
proc.once("exit", () => clearTimeout(killTimer));
506632
}
507633

508634
// Stop proxy-chain server

0 commit comments

Comments
 (0)