1818 * adds credentials and tunnels to the upstream proxy.
1919 */
2020
21+ import http from "http" ;
2122import { spawn , type ChildProcess } from "child_process" ;
2223import { createInterface } from "readline" ;
2324import { 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