Skip to content

Commit b7b95b4

Browse files
committed
fix(showcase): correct browser-pool recovery docs and contain recovery throws
Reconcile the stale stats()/reinit()/acquire() comments: all-pending recovery goes through relaunchPendingSlots, not the size-0 reinit backstop (which is the truly-empty never-launched case). Rename the misleading reinit test to assert the actual relaunchPendingSlots mechanism. Chain track()'s cleanup so a future recovery throw cannot float as an unhandled rejection, and log a -1 sentinel slotIndex for the never-added reinit close. Fix the self-contradicting drainMicrotasks JSDoc.
1 parent e19159e commit b7b95b4

2 files changed

Lines changed: 63 additions & 37 deletions

File tree

showcase/harness/src/probes/helpers/browser-pool.test.ts

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -117,12 +117,10 @@ function makeFakeLogger(): {
117117
}
118118

119119
/**
120-
* `recycleSlot` kicks off an async relaunch via a fire-and-forget IIFE.
121-
* Tests that observe post-recycle state need to await the in-flight
122-
* promise to settle. `shutdown()` already awaits `inFlightRecycles`, so
123-
* this helper is just `await pool.shutdown()` from the test side; here
124-
* we use a small drain helper for tests that want to observe state
125-
* without tearing the pool down.
120+
* Flush pending microtasks `times` times so post-recycle state settles
121+
* enough to observe without tearing the pool down. It does NOT advance real
122+
* `setTimeout` backoff timers — tests that depend on backoff elapsing use
123+
* `waitFor` instead.
126124
*/
127125
async function drainMicrotasks(times = 5): Promise<void> {
128126
for (let i = 0; i < times; i++) {
@@ -316,13 +314,17 @@ describe("BrowserPool dead-instance detection", () => {
316314
await pool.shutdown();
317315
});
318316

319-
it("re-initializes the pool when every slot has been lost (size reaches 0)", async () => {
320-
// 2-slot pool. Both browsers crash and EVERY relaunch attempt fails for
321-
// both slots, driving the pool to size 0 so the backstop reinit() path
322-
// is actually exercised. Each slot makes RELAUNCH_MAX_ATTEMPTS (=3)
323-
// launch calls during recycle, so for 2 slots that is launch calls 3..8
324-
// (init used calls 1 and 2). Failing 3..8 exhausts all retries and
325-
// evicts both slots to leave size 0. Call 9 (the reinit) succeeds.
317+
it("recovers a fully-parked pool (all slots relaunchPending, size 0) via the lazy relaunchPendingSlots path on the next acquire", async () => {
318+
// 2-slot pool. Both browsers crash and EVERY immediate relaunch attempt
319+
// fails for both slots. Each slot makes RELAUNCH_MAX_ATTEMPTS (=3) launch
320+
// calls during recycle, so for 2 slots that is launch calls 3..8 (init
321+
// used calls 1 and 2). Failing 3..8 exhausts every immediate retry and
322+
// parks BOTH slots as `relaunchPending` — they are NOT evicted from
323+
// `this.slots` (capacity is preserved), so `stats().size` reads 0 during
324+
// the outage while the slots stay parked. Recovery is therefore via the
325+
// lazy `relaunchPendingSlots()` path on the next acquire (launch call 9
326+
// succeeds), NOT the `reinit()` backstop (which only fires when
327+
// `this.slots` is truly empty). This test asserts that actual mechanism.
326328
const { launchBrowser, launched } = makeFakeLauncher({
327329
failAtCalls: [3, 4, 5, 6, 7, 8],
328330
});
@@ -334,17 +336,17 @@ describe("BrowserPool dead-instance detection", () => {
334336
launched[0]!.__crash();
335337
launched[1]!.__crash();
336338
// The recycle uses real setTimeout backoff between attempts, so give it
337-
// a wall-clock window to exhaust all retries and park/evict both slots.
339+
// a wall-clock window to exhaust all retries and park both slots.
338340
await waitFor(() => pool.stats().size === 0, 5_000);
339341

340-
// Precondition: prove the pool actually drained to zero so reinit() is
341-
// the path under test (the old [3,4] fixture self-healed on attempt 2
342-
// and never reached this state — the test was vacuous).
342+
// Precondition: every slot is parked, so live capacity (`size`) reads 0
343+
// during the outage. The slots themselves are retained — recovery comes
344+
// through the relaunchPending path, not a from-scratch reinit.
343345
expect(pool.stats().size).toBe(0);
344346

345-
// With size 0, the next acquire() must trigger the backstop reinit
346-
// (launch call 9 succeeds) and hand back a live browser instead of
347-
// hanging until timeout.
347+
// The next acquire() recovers the parked slots via relaunchPendingSlots
348+
// (launch call 9 succeeds) and hands back a live browser instead of
349+
// hanging until timeout. `size` returns to non-zero once recovered.
348350
const acquired = await pool.acquire(5_000);
349351
expect(acquired).toBeDefined();
350352
expect((acquired as unknown as FakeBrowser).isConnected()).toBe(true);

showcase/harness/src/probes/helpers/browser-pool.ts

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)