@@ -178,18 +178,32 @@ export class BrowserPool {
178178 */
179179 private track ( promise : Promise < void > ) : Promise < void > {
180180 this . inFlightRecycles . add ( promise ) ;
181- promise . finally ( ( ) => {
182- this . inFlightRecycles . delete ( promise ) ;
183- } ) ;
184- return promise ;
181+ // Chain the cleanup onto what we return (and await), and absorb any throw
182+ // with a logged `.catch` so it can never surface as an unhandled
183+ // rejection. The inner methods swallow their own launch errors today, but
184+ // a future throw inside reinitInner/relaunchPendingSlotsInner must stay
185+ // contained — a recovery launch failing is non-fatal to the pool.
186+ return promise
187+ . catch ( ( err : unknown ) => {
188+ this . logger ?. info ( "browser-pool.recovery-failed" , {
189+ error : err instanceof Error ? err . message : String ( err ) ,
190+ } ) ;
191+ } )
192+ . finally ( ( ) => {
193+ this . inFlightRecycles . delete ( promise ) ;
194+ } ) ;
185195 }
186196
187197 /**
188- * Backstop re-initialization. Called from `acquire()` when the pool has
189- * drained to zero slots. Re-launches up to `poolSize` browsers, tolerating
190- * partial failure — even one fresh slot lets a waiting probe proceed. A
191- * total failure here surfaces to the caller via the normal acquire
192- * timeout / waiter-reject path rather than wedging the pool forever.
198+ * Backstop re-initialization for the truly-empty pool. Called from
199+ * `acquire()` only when `this.slots` is empty — i.e. nothing was ever
200+ * launched (init never ran / launched nothing). This is NOT the all-slots-
201+ * crashed recovery path: failed recycles keep their slots in `this.slots`
202+ * (parked `relaunchPending`) and are recovered by `relaunchPendingSlots()`,
203+ * so they never reach this backstop. Re-launches up to `poolSize` browsers,
204+ * tolerating partial failure — even one fresh slot lets a waiting probe
205+ * proceed. A total failure here surfaces to the caller via the normal
206+ * acquire timeout / waiter-reject path rather than wedging the pool forever.
193207 */
194208 private async reinit ( ) : Promise < void > {
195209 if ( this . isShutdown || this . launchBrowser === undefined ) return ;
@@ -209,7 +223,10 @@ export class BrowserPool {
209223 try {
210224 const browser = await this . launchBrowser ( ) ;
211225 if ( this . isShutdown ) {
212- await this . closeBrowser ( browser , this . slots . length ) ;
226+ // This browser was never added to `this.slots`, so there is no real
227+ // originating slot index. Pass -1 rather than a fabricated index
228+ // (`this.slots.length`) that would masquerade as a live slot.
229+ await this . closeBrowser ( browser , - 1 ) ;
213230 return ;
214231 }
215232 const slot : Slot = { browser, contextCount : 0 } ;
@@ -321,10 +338,14 @@ export class BrowserPool {
321338 throw new Error ( "BrowserPool is shut down" ) ;
322339 }
323340
324- // Backstop: if every slot has been lost (e.g. a burst of crashes whose
325- // relaunches all failed), the pool would otherwise be permanently empty
326- // and every acquire would time out. Re-initialize from scratch so the
327- // pool self-heals instead of draining to nothing.
341+ // Backstop: only when `this.slots` is truly empty — nothing was ever
342+ // launched (init() never ran or launched nothing) — does the pool have no
343+ // slot to recover, so reinit from scratch. NOTE: a burst of crashes whose
344+ // relaunches all fail does NOT empty `this.slots`; those slots are kept
345+ // and parked as `relaunchPending`, and are recovered below via
346+ // `relaunchPendingSlots()`, not here. (`stats().size` may read 0 in that
347+ // state, but `size` does not gate this backstop — `this.slots.length`
348+ // does.)
328349 if ( this . slots . length === 0 ) {
329350 await this . reinit ( ) ;
330351 } else {
@@ -452,9 +473,12 @@ export class BrowserPool {
452473 stats ( ) : BrowserPoolStats {
453474 // `size` counts only live (non-pending) capacity. A slot parked as
454475 // `relaunchPending` holds a dead browser and is being recovered lazily,
455- // so it is not usable capacity and must not inflate `size`/`inUse`. This
456- // also makes the empty-pool backstop observable: when every slot is
457- // pending, `size` reads 0 (the condition under which acquire() recovers).
476+ // so it is not usable capacity and must not inflate `size`/`inUse`. When
477+ // every slot is pending, `size` reads 0 — that surfaces the outage to
478+ // callers, but it is NOT what gates recovery. The parked slots remain in
479+ // `this.slots`, so `acquire()` recovers them via `relaunchPendingSlots()`
480+ // (the `this.slots.length === 0` reinit backstop is a separate
481+ // never-launched case), not via this size reading.
458482 const liveSlots = this . slots . filter ( ( s ) => ! s . relaunchPending ) ;
459483 const availableCount = this . available . length ;
460484 return {
0 commit comments