Skip to content

Commit f691fed

Browse files
committed
Fix CDP WebSocket dying after idle: add 30s heartbeat, clear stale timers
The Playwright-to-Chrome CDP WebSocket connection silently dies after ~2 hours of idle due to Linux tcp_keepalive_time defaulting to 7200s. Once dead, page.goto() hangs forever on the next request. Fix: send a lightweight Browser.getVersion CDP command every 30 seconds to keep the connection alive. Also clear the withPage hard timeout timer on success to stop ghost HARD TIMEOUT log entries.
1 parent de8ad37 commit f691fed

1 file changed

Lines changed: 39 additions & 1 deletion

File tree

src/browser/playwright-pool.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ export class ChromeInstance {
108108
private userDataDir: string | null = null;
109109
private wsEndpoint: string | null = null;
110110
private fingerprint: BrowserFingerprintWithHeaders["fingerprint"] | null = null;
111+
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
111112

112113
readonly ready: Promise<void>;
113114
private resolveReady!: () => void;
@@ -195,10 +196,11 @@ export class ChromeInstance {
195196
try {
196197
// Hard timeout: if fn hangs, reject and force-close the page.
197198
// This prevents pLimit slots from being held forever.
199+
let hardTimer: ReturnType<typeof setTimeout> | null = null;
198200
const result = await Promise.race([
199201
fn(page),
200202
new Promise<never>((_, reject) => {
201-
setTimeout(() => {
203+
hardTimer = setTimeout(() => {
202204
this.logger.error(
203205
{ proxy: redactProxy(this.proxyUrl), timeoutMs, elapsed: Date.now() - pageStart },
204206
`withPage: HARD TIMEOUT after ${timeoutMs}ms`
@@ -207,6 +209,7 @@ export class ChromeInstance {
207209
}, timeoutMs);
208210
}),
209211
]);
212+
if (hardTimer !== null) clearTimeout(hardTimer);
210213
this.logger.info(
211214
{ proxy: redactProxy(this.proxyUrl), duration: Date.now() - pageStart },
212215
`withPage: completed (${Date.now() - pageStart}ms)`
@@ -427,6 +430,7 @@ export class ChromeInstance {
427430
});
428431

429432
this.state = "active";
433+
this.startHeartbeat();
430434
this.resolveReady();
431435

432436
this.logger.debug(
@@ -441,7 +445,41 @@ export class ChromeInstance {
441445
}
442446
}
443447

448+
/**
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).
452+
*/
453+
private startHeartbeat(): void {
454+
this.stopHeartbeat();
455+
this.heartbeatInterval = setInterval(async () => {
456+
if (this.state !== "active" || !this.pwBrowser) {
457+
this.stopHeartbeat();
458+
return;
459+
}
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"
468+
);
469+
}
470+
}, 30_000);
471+
}
472+
473+
private stopHeartbeat(): void {
474+
if (this.heartbeatInterval) {
475+
clearInterval(this.heartbeatInterval);
476+
this.heartbeatInterval = null;
477+
}
478+
}
479+
444480
private async cleanup(): Promise<void> {
481+
this.stopHeartbeat();
482+
445483
// Disconnect Playwright
446484
if (this.pwBrowser) {
447485
try {

0 commit comments

Comments
 (0)