@@ -42,11 +42,17 @@ export interface BrowserPoolStats {
4242
4343/**
4444 * Minimal logger surface the pool uses for lifecycle events. Matches the
45- * harness-wide `Logger` interface but only the `info` method is required.
46- * Optional so existing callers (tests, legacy boot paths) don't break.
45+ * harness-wide `Logger` interface but only the `info` method is required;
46+ * `warn`/`error` are OPTIONAL so existing callers (tests, legacy boot paths)
47+ * that inject a bare-`info` fake still type-check. The concrete harness logger
48+ * implements all three (warn/error route to stderr → Sentry), so capacity-loss
49+ * events emitted via `error` reach the alert pipeline instead of being buried
50+ * at info. Call the optional methods safely: `this.logger?.warn?.(...)`.
4751 */
4852interface PoolLogger {
4953 info ( msg : string , meta ?: Record < string , unknown > ) : void ;
54+ warn ?( msg : string , meta ?: Record < string , unknown > ) : void ;
55+ error ?( msg : string , meta ?: Record < string , unknown > ) : void ;
5056}
5157
5258/**
@@ -73,6 +79,15 @@ export class BrowserPool {
7379 // the same slot — which would leak one process and/or publish the slot to
7480 // `available` twice.
7581 private relaunchingSlots = new Set < Slot > ( ) ;
82+ // Re-entry guard for the empty-pool reinit backstop, mirroring the
83+ // relaunchingSlots/recyclingSlots idiom. Two concurrent acquire() calls
84+ // against a truly-empty pool (this.slots.length === 0) would each drive
85+ // reinit() and each launch up to poolSize browsers — overshooting capacity.
86+ // Setting this synchronously before reinit's first await makes the second
87+ // acquire skip reinit and fall through to the waiter queue, where the first
88+ // reinit's handOff serves it. Cleared in a finally so a thrown reinit can't
89+ // wedge the guard set forever.
90+ private reiniting = false ;
7691 private isShutdown = false ;
7792 private readonly logger ?: PoolLogger ;
7893 private readonly injectedLaunchBrowser ?: LaunchBrowser ;
@@ -156,7 +171,7 @@ export class BrowserPool {
156171 try {
157172 await browser . close ( ) ;
158173 } catch ( err ) {
159- this . logger ?. info ( "browser-pool.close-failed" , {
174+ this . logger ?. warn ?. ( "browser-pool.close-failed" , {
160175 slotIndex,
161176 error : err instanceof Error ? err . message : String ( err ) ,
162177 } ) ;
@@ -178,7 +193,7 @@ export class BrowserPool {
178193 // contained — a recovery launch failing is non-fatal to the pool.
179194 return promise
180195 . catch ( ( err : unknown ) => {
181- this . logger ?. info ( "browser-pool.recovery-failed" , {
196+ this . logger ?. error ?. ( "browser-pool.recovery-failed" , {
182197 error : err instanceof Error ? err . message : String ( err ) ,
183198 } ) ;
184199 } )
@@ -200,10 +215,20 @@ export class BrowserPool {
200215 */
201216 private async reinit ( ) : Promise < void > {
202217 if ( this . isShutdown || this . launchBrowser === undefined ) return ;
203- // Track the whole reinit so a shutdown racing this launch loop drains it
204- // before closing slots, rather than leaking a browser that resolves after
205- // shutdown already awaited the in-flight set.
206- await this . track ( this . reinitInner ( ) ) ;
218+ // Set the concurrent-entry guard SYNCHRONOUSLY before the first `await`
219+ // (before `this.track(...)`) so a second acquire reaching its empty-pool
220+ // check while this reinit is in flight skips reinit and parks a waiter
221+ // instead of launching its own duplicate set of browsers. Cleared in the
222+ // `finally` so a thrown reinit can't leave the guard latched forever.
223+ this . reiniting = true ;
224+ try {
225+ // Track the whole reinit so a shutdown racing this launch loop drains it
226+ // before closing slots, rather than leaking a browser that resolves after
227+ // shutdown already awaited the in-flight set.
228+ await this . track ( this . reinitInner ( ) ) ;
229+ } finally {
230+ this . reiniting = false ;
231+ }
207232 }
208233
209234 private async reinitInner ( ) : Promise < void > {
@@ -229,12 +254,25 @@ export class BrowserPool {
229254 this . handOff ( slot , browser ) ;
230255 } catch ( err ) {
231256 // Best effort — keep trying the remaining slots. If none succeed
232- // the pool stays empty and the next acquire retries reinit.
233- this . logger ?. info ( "browser-pool.reinit-failed" , {
257+ // the pool stays empty and the next acquire retries reinit. Per-attempt
258+ // failure is a warn: a single slot failing to relaunch is recoverable
259+ // (other slots may succeed, or a later acquire retries).
260+ this . logger ?. warn ?.( "browser-pool.reinit-failed" , {
234261 error : err instanceof Error ? err . message : String ( err ) ,
235262 } ) ;
236263 }
237264 }
265+
266+ // The genuine empty-pool capacity-loss signal: reinit ran but every launch
267+ // failed, so the pool ends with zero slots. Unlike the per-attempt warn
268+ // above, this is the terminal "pool ended empty" outage and must reach the
269+ // error/Sentry pipeline. Per-attempt reinit-failed stays warn (recoverable);
270+ // this distinct end-of-loop emit fires once when the backstop drained empty.
271+ if ( this . slots . length === 0 ) {
272+ this . logger ?. error ?.( "browser-pool.reinit-empty" , {
273+ poolSize : this . poolSize ,
274+ } ) ;
275+ }
238276 }
239277
240278 /**
@@ -309,8 +347,10 @@ export class BrowserPool {
309347 slotIndex : this . slots . indexOf ( slot ) ,
310348 } ) ;
311349 } catch ( err ) {
312- // Still failing — leave it pending for the next acquire to retry.
313- this . logger ?. info ( "browser-pool.relaunch-failed" , {
350+ // Still failing — leave it pending for the next acquire to retry. This
351+ // is a per-attempt failure (the slot stays parked and recoverable), so
352+ // warn rather than error.
353+ this . logger ?. warn ?.( "browser-pool.relaunch-failed" , {
314354 slotIndex : this . slots . indexOf ( slot ) ,
315355 error : err instanceof Error ? err . message : String ( err ) ,
316356 } ) ;
@@ -333,16 +373,21 @@ export class BrowserPool {
333373 // `relaunchPendingSlots()`, not here. (`stats().size` may read 0 in that
334374 // state, but `size` does not gate this backstop — `this.slots.length`
335375 // does.)
336- if ( this . slots . length === 0 ) {
376+ if ( this . slots . length === 0 && ! this . reiniting ) {
337377 await this . reinit ( ) ;
338- } else {
378+ } else if ( this . slots . length > 0 ) {
339379 // Lazily relaunch any slot whose last relaunch failed. This is the
340380 // recovery path for a transient launch failure: the slot was kept
341381 // (capacity preserved) but parked as `relaunchPending`; the next
342382 // acquire re-attempts the launch before falling through to the
343383 // normal available-slot scan.
344384 await this . relaunchPendingSlots ( ) ;
345385 }
386+ // The remaining case — `slots.length === 0 && this.reiniting` — is the
387+ // concurrent-acquire skip: another acquire's reinit is already launching
388+ // up to poolSize browsers, so this call does NOT launch its own duplicate
389+ // set. It falls through to the waiter-queue path below; the in-flight
390+ // reinit's `handOff` serves this waiter as soon as a fresh browser lands.
346391
347392 // Skip zombie slots whose browser has disconnected but whose disconnect
348393 // handler hasn't yet completed the recycle. Without this loop, a single
@@ -601,7 +646,10 @@ export class BrowserPool {
601646 // launch (see relaunchPendingSlots), and the empty-pool backstop in
602647 // acquire() re-initializes if every slot ends up pending/lost.
603648 slot . relaunchPending = true ;
604- this . logger ?. info ( "browser-pool.recycle-relaunch-failed" , {
649+ // Capacity loss after exhausting ALL immediate retries — the slot is now
650+ // parked dead and only a later lazy acquire can recover it. This is a
651+ // genuine capacity-loss signal that must reach error/Sentry, not info.
652+ this . logger ?. error ?.( "browser-pool.recycle-relaunch-failed" , {
605653 slotIndex : this . slots . indexOf ( slot ) ,
606654 attempts : RELAUNCH_MAX_ATTEMPTS ,
607655 poolSize : this . slots . length ,
@@ -623,7 +671,7 @@ export class BrowserPool {
623671 // never surface as an unhandled rejection.
624672 recyclePromise
625673 . catch ( ( err : unknown ) => {
626- this . logger ?. info ( "browser-pool.recycle-failed" , {
674+ this . logger ?. error ?. ( "browser-pool.recycle-failed" , {
627675 slotIndex : this . slots . indexOf ( slot ) ,
628676 error : err instanceof Error ? err . message : String ( err ) ,
629677 } ) ;
0 commit comments