Skip to content

Commit dfc72f1

Browse files
authored
fix(showcase/harness): harden browser-pool non-release teardown paths (CopilotKit#5174)
## Summary Follow-up to CopilotKit#5173 (bucket-(d)) closing three **pre-existing** browser-pool concurrency defects on the non-release teardown paths. The browser-pool is a context-pool over a fixed set of long-lived Chromium processes; these defects biased the hygiene-recycle cadence and could strand waiters / leak deferred recycles. ## Fixes (each red-green proven) 1. **`serveNextWaiter` orphan-close leaked `servedContexts`.** A waiter timing out mid-`openContextOn` cleaned up the reservation/context but never decremented `servedContexts` (which `openContextOn` had already `++`'d) → every orphaned-by-timeout serve permanently inflated the count → premature hygiene recycles. Fix: decrement `servedContexts` in the orphan-close block. 2. **Deferred `recyclePending` honored only on `release()`.** A recycle deferred because `pendingOpens > 0` set `recyclePending`, but the non-release teardown paths (orphan-by-recycle rollback, orphan-close) returned the entry to idle without re-checking it → the deferred recycle was dropped and the browser exceeded `recycleAfter` indefinitely. Fix: shared `maybeFireDeferredRecycle(entry)` helper called on both non-release teardown paths. 3. **`openContextOn` rollback didn't drain waiters.** The orphan-by-recycle rollback freed a reservation but never `scheduleServeNextWaiter()` → queued waiters could stall with free capacity until an unrelated release. Fix: `scheduleServeNextWaiter()` after the rollback. ## Verification - Red-green for all three (reproduced each bug, then green). - Full harness vitest: **1702–1705 passed**; browser-pool suite **27/27** (mutation-tested — reverting fix CopilotKit#3 fails its guard). `tsc --noEmit` exit 0. - Public API + `BROWSER_POOL_MAX_CONTEXTS` default untouched. ## Reviewed 7-agent unbiased CR (cr-loop): the three fixes confirmed sound; one ordering concern on the new code investigated and **refuted** (sole-browser relaunch-failure rejects the waiter rather than stranding it). ## Known further hardening (separate effort — NOT in this PR) The CR surfaced additional **pre-existing** browser-pool reliability bugs that warrant a dedicated hardening pass, independently flagged by multiple reviewers: - `shutdown()` vs in-flight `openContextOn` → leaked context (no `isShutdown` re-check post-`newContext`); recycles added to `inFlightRecycles` after shutdown's snapshot not awaited. - crash-reason `recycleBrowser` abandons live contexts without `.close()` — leaks on the non-dead `acquire`-retry path. - `acquire` retry treats a transient `newContext` failure as a full crash → recycles the whole browser, tearing down unrelated live contexts (correlated flakiness). - `launchChain` launch gate has no timeout → a single hung `chromium.launch()` permanently deadlocks all relaunches. - `parseInt` env parsing silently accepts trailing garbage (`MAX_CONTEXTS=24x` → 24); no warning. - context-close failures swallowed via bare `.catch(() => {})` (inconsistent with `closeBrowser`'s logged path). - relaunch-failure eviction only rejects waiters when the pool is fully empty (doesn't redistribute onto surviving browsers); `pickLeastLoaded` tie-break concentrates load on browser 0.
2 parents 9089490 + e032c51 commit dfc72f1

2 files changed

Lines changed: 261 additions & 0 deletions

File tree

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

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1150,4 +1150,220 @@ describe("BrowserPool — context pooling over fixed browser set", () => {
11501150
await pool.release(c2!);
11511151
await pool.shutdown();
11521152
});
1153+
1154+
// ── BUCKET-D CONCURRENCY-HARDENING REGRESSIONS ────────────────────────────
1155+
// Three PRE-EXISTING defects in the non-release teardown paths, each driven
1156+
// by an interleave the sequential cases above never exercised.
1157+
1158+
// Internal-state probe: read a private BrowserEntry field for assertions the
1159+
// public stats() surface does not expose (servedContexts / recyclePending).
1160+
// The pool keeps these per-entry; the only way to assert the orphan-close
1161+
// bookkeeping is to read them directly.
1162+
const entry0 = (
1163+
pool: BrowserPool,
1164+
): {
1165+
servedContexts: number;
1166+
recyclePending: boolean;
1167+
pendingOpens: number;
1168+
} =>
1169+
(
1170+
pool as unknown as {
1171+
browsers: Array<{
1172+
servedContexts: number;
1173+
recyclePending: boolean;
1174+
pendingOpens: number;
1175+
}>;
1176+
}
1177+
).browsers[0]!;
1178+
1179+
// BUG 1 — a waiter that times out WHILE serveNextWaiter is opening its
1180+
// context leaks `servedContexts`. openContextOn did servedContexts++ when it
1181+
// added the never-delivered context; the orphan-close cleanup decrements the
1182+
// reservation + drops liveContexts but (pre-fix) does NOT decrement
1183+
// servedContexts. So every timed-out-mid-serve permanently inflates
1184+
// servedContexts, biasing the hygiene recycle to fire early. The fix mirrors
1185+
// the increment with a decrement in the orphan-close block.
1186+
it("BUG1: a waiter that times out mid-serveNextWaiter does NOT inflate servedContexts", async () => {
1187+
const { launchBrowser, launched } = makeFakeLauncher({
1188+
deferNewContextForCalls: [1],
1189+
});
1190+
const pool = new BrowserPool({
1191+
browsers: 1,
1192+
maxContexts: 1,
1193+
recycleAfter: 1000, // keep hygiene out of the picture; assert the count
1194+
launchBrowser,
1195+
launchStaggerMs: 0,
1196+
});
1197+
await pool.init();
1198+
1199+
// Fill the cap with one context (parked open → release it).
1200+
const c1p = pool.acquire();
1201+
await drainMicrotasks();
1202+
launched[0]!.__releaseNewContexts();
1203+
const c1 = await c1p;
1204+
expect(pool.stats().inUse).toBe(1);
1205+
// One context genuinely served.
1206+
expect(entry0(pool).servedContexts).toBe(1);
1207+
1208+
// Enqueue a waiter with a SHORT timeout — it pends past the cap.
1209+
let waiterRejected: Error | undefined;
1210+
const waiterP = pool
1211+
.acquire(undefined, 20)
1212+
.catch((e: Error) => (waiterRejected = e));
1213+
await drainMicrotasks();
1214+
1215+
// Release c1 → serveNextWaiter() picks the waiter, calls newContext()
1216+
// (parked). While that open is in flight, let the 20ms timeout fire.
1217+
await pool.release(c1);
1218+
await drainMicrotasks();
1219+
await new Promise((r) => setTimeout(r, 40)); // waiter times out now
1220+
expect(waiterRejected).toBeInstanceOf(Error);
1221+
1222+
// Let the parked newContext() for the dead waiter resolve → orphan-close.
1223+
launched[0]!.__releaseNewContexts();
1224+
await drainMicrotasks();
1225+
await waiterP;
1226+
1227+
// The orphaned serve must NOT count toward servedContexts: the context was
1228+
// opened (servedContexts++) but never delivered, then closed + rolled back.
1229+
// Pre-fix: servedContexts stuck at 2 (the orphan inflated it). Fixed: 1.
1230+
expect(entry0(pool).servedContexts).toBe(1);
1231+
expect(pool.stats().inUse).toBe(0);
1232+
1233+
await pool.shutdown();
1234+
});
1235+
1236+
// BUG 2 — a hygiene recycle deferred via the `shouldRecycle && hadWaiter`
1237+
// guard sets recyclePending=true, expecting "the next idle release honors it."
1238+
// But if the just-served waiter's lifecycle ends via a NON-release teardown
1239+
// (here: the waiter times out mid-serve → serveNextWaiter orphan-close), no
1240+
// release() ever fires, so the deferred recycle is dropped and the browser
1241+
// exceeds recycleAfter indefinitely. The fix re-checks the deferred recycle on
1242+
// the non-release teardown paths.
1243+
it("BUG2: a deferred hygiene recycle still fires when the just-served waiter ends via a non-release teardown", async () => {
1244+
const { launchBrowser, launched } = makeFakeLauncher({
1245+
deferNewContextForCalls: [1], // browser0 defers every newContext
1246+
});
1247+
const { logger } = makeLeveledLogger();
1248+
const pool = new BrowserPool({
1249+
browsers: 1,
1250+
maxContexts: 1,
1251+
recycleAfter: 1, // eligible after the first served context
1252+
logger,
1253+
launchBrowser,
1254+
launchStaggerMs: 0,
1255+
});
1256+
await pool.init();
1257+
expect(launched.length).toBe(1);
1258+
1259+
// Fill the cap with one context (parked open → release the park).
1260+
const c1p = pool.acquire();
1261+
await drainMicrotasks();
1262+
launched[0]!.__releaseNewContexts();
1263+
const c1 = await c1p;
1264+
expect(pool.stats().inUse).toBe(1);
1265+
expect(entry0(pool).servedContexts).toBe(1); // >= recycleAfter
1266+
1267+
// Queue a waiter W past the cap with a SHORT timeout.
1268+
let wRejected: Error | undefined;
1269+
const wP = pool.acquire(undefined, 20).catch((e: Error) => (wRejected = e));
1270+
await drainMicrotasks();
1271+
1272+
// Release c1: the boundary-crossing release (servedContexts>=recycleAfter,
1273+
// entry momentarily idle) computes shouldRecycle=true AND has a queued waiter
1274+
// → it serves W (parks in deferred newContext) and DEFERS the recycle, setting
1275+
// recyclePending=true. No recycle fires now.
1276+
await pool.release(c1);
1277+
await drainMicrotasks();
1278+
expect(pool.stats().totalRecycles).toBe(0);
1279+
expect(entry0(pool).recyclePending).toBe(true);
1280+
1281+
// W now times out WHILE its serve's newContext() is still parked in flight.
1282+
await new Promise((r) => setTimeout(r, 40));
1283+
expect(wRejected).toBeInstanceOf(Error);
1284+
1285+
// Resolve the parked open for the dead waiter → serveNextWaiter orphan-close,
1286+
// a NON-release teardown that returns the entry to idle. Pre-fix: nothing
1287+
// re-checks the deferred recycle here, so recyclePending stays true forever
1288+
// and the browser is never recycled (totalRecycles stuck at 0). The fix
1289+
// re-checks isEntryRecyclable && recyclePending on the orphan-close path and
1290+
// fires the deferred recycle.
1291+
launched[0]!.__releaseNewContexts();
1292+
await wP;
1293+
await waitFor(() => pool.stats().totalRecycles === 1);
1294+
expect(pool.stats().totalRecycles).toBe(1);
1295+
expect(entry0(pool).recyclePending).toBe(false);
1296+
1297+
await pool.shutdown();
1298+
});
1299+
1300+
// BUG 3 — when an in-flight open is orphaned by a recycle and rolls back via
1301+
// releaseReservation(), it does NOT drain the waiter queue. So a queued waiter
1302+
// can stall with free capacity until the next unrelated release/recycle
1303+
// handoff. The fix calls scheduleServeNextWaiter() after the rollback so freed
1304+
// capacity immediately serves the waiter.
1305+
it("BUG3: an open orphaned by recycle drains a queued waiter from the freed capacity", async () => {
1306+
// browser0 (init) defers its first newContext so we can park an in-flight
1307+
// open and orphan it; its relaunch (call 2) does NOT defer, so a waiter
1308+
// served onto the fresh browser resolves immediately.
1309+
const { launchBrowser, launched } = makeFakeLauncher({
1310+
deferNewContextForCalls: [1],
1311+
});
1312+
const { logger } = makeLeveledLogger();
1313+
const pool = new BrowserPool({
1314+
browsers: 1,
1315+
maxContexts: 1,
1316+
recycleAfter: 1000, // hygiene out of the picture
1317+
logger,
1318+
launchBrowser,
1319+
launchStaggerMs: 0,
1320+
});
1321+
await pool.init();
1322+
expect(launched.length).toBe(1);
1323+
1324+
// Acquire A parks in newContext() on browser0 (pendingOpens=1, reservation
1325+
// held → cap saturated at 1).
1326+
let aDone = false;
1327+
const aP = pool
1328+
.acquire(undefined, 5_000)
1329+
.then(() => (aDone = true))
1330+
.catch(() => {});
1331+
await drainMicrotasks();
1332+
expect(launched[0]!.__pendingNewContexts).toBe(1);
1333+
expect(pool.stats().inUse).toBe(1);
1334+
1335+
// Acquire B pends as a WAITER past the cap (cap=1, A holds the only slot).
1336+
let b: BrowserContext | undefined;
1337+
const bP = pool
1338+
.acquire(undefined, 5_000)
1339+
.then((c) => (b = c))
1340+
.catch(() => {});
1341+
await drainMicrotasks();
1342+
expect(b).toBeUndefined();
1343+
1344+
// CRASH browser0 → recovery recycle reassigns entry.browser to a fresh,
1345+
// NON-deferring process (call 2). Resolve A's parked open on the ORIGINAL
1346+
// (now-replaced) browser: the orphan guard closes it and rolls back the
1347+
// reservation — freeing the only cap slot.
1348+
launched[0]!.__crash();
1349+
await waitFor(() => launched.length === 2);
1350+
launched[0]!.__releaseNewContexts(); // A's open resolves → orphaned + rolled back
1351+
await drainMicrotasks();
1352+
1353+
// BUG3 INVARIANT: the freed slot must immediately serve waiter B from the
1354+
// rollback's scheduleServeNextWaiter(). Pre-fix the rollback did not drain
1355+
// the queue, so B stalled with free capacity. The crash-recovery relaunch's
1356+
// own drain loop would EVENTUALLY serve B, so to isolate the rollback-drain
1357+
// we assert B resolves promptly after the rollback without any further pool
1358+
// activity.
1359+
await waitFor(() => b !== undefined, 1_000);
1360+
expect(b).toBeDefined();
1361+
expect(pool.stats().inUse).toBe(1); // exactly B is live
1362+
1363+
void aDone;
1364+
await aP;
1365+
await bP;
1366+
await pool.release(b!);
1367+
await pool.shutdown();
1368+
});
11531369
});

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,28 @@ export class BrowserPool {
395395
return !entry.recycling && this.isEntryIdle(entry);
396396
}
397397

398+
/**
399+
* Fire a hygiene recycle that was previously DEFERRED (carried on
400+
* `entry.recyclePending`) if the entry is now genuinely recyclable. This is
401+
* the shared "honor the deferred recycle" check that EVERY path which returns
402+
* an entry to idle must run — not just `release()`. The release path defers a
403+
* hygiene recycle (e.g. because a waiter was just served onto the freed slot)
404+
* and sets `recyclePending`, expecting the next idle release to honor it; but
405+
* an entry can also return to idle via NON-release teardowns (the
406+
* `openContextOn` orphan-by-recycle rollback and the `serveNextWaiter`
407+
* orphan-close), where no `release()` ever fires. Without re-checking here the
408+
* deferred recycle is dropped and the browser exceeds `recycleAfter`
409+
* indefinitely. Race-safe: the predicate + flag clear + recycle dispatch are
410+
* all synchronous (no await straddles them), and `recycleBrowser` itself
411+
* re-guards on `recycling`/`pendingOpens`.
412+
*/
413+
private maybeFireDeferredRecycle(entry: BrowserEntry): void {
414+
if (entry.recyclePending && this.isEntryRecyclable(entry)) {
415+
entry.recyclePending = false;
416+
this.recycleBrowser(entry, "hygiene");
417+
}
418+
}
419+
398420
/**
399421
* Open a context on the given live browser against a reservation the caller
400422
* ALREADY took via `reserveSlot()`. Centralizes the `X-AIMock-Strict` default
@@ -455,6 +477,15 @@ export class BrowserPool {
455477
this.logger?.warn?.("browser-pool.open-orphaned-by-recycle", {
456478
browserIndex: this.browsers.indexOf(entry),
457479
});
480+
// NON-release teardown: this in-flight open ended without a release(), so
481+
// the release-path's deferred-recycle re-check (recyclePending) and waiter
482+
// drain never run for the capacity this rollback just freed. Mirror them
483+
// here. (Bug 2) honor a recycle that was deferred behind this very open,
484+
// now that pendingOpens dropped and the entry may be idle; (Bug 3) drain a
485+
// queued waiter onto the freed slot instead of stalling it until the next
486+
// unrelated release/recycle handoff. Both run on synchronous state.
487+
this.maybeFireDeferredRecycle(entry);
488+
this.scheduleServeNextWaiter();
458489
throw new Error("browser-pool: context open orphaned by recycle");
459490
}
460491

@@ -657,11 +688,25 @@ export class BrowserPool {
657688
if (waiter.settled) {
658689
this.contextToBrowser.delete(context);
659690
entry.liveContexts.delete(context);
691+
// openContextOn already did `entry.servedContexts++` for this context.
692+
// Since it is being orphan-closed and never delivered, mirror that
693+
// increment with a decrement — otherwise every timed-out-mid-serve
694+
// permanently inflates servedContexts and biases the hygiene recycle
695+
// (servedContexts >= recycleAfter) to fire early, wasting PID budget on
696+
// relaunches. (Bug 1)
697+
entry.servedContexts--;
660698
this.releaseReservation();
661699
void context.close().catch(() => {});
662700
this.logger?.warn?.("browser-pool.serve-waiter-orphan-closed", {
663701
browserIndex: this.browsers.indexOf(entry),
664702
});
703+
// NON-release teardown: returning this entry to idle here without a
704+
// release() means the release-path's deferred-recycle re-check never runs.
705+
// Honor a hygiene recycle that was deferred behind this serve (set via the
706+
// release-path `shouldRecycle && hadWaiter` guard) now that the entry is
707+
// idle again — otherwise the deferred recycle is dropped and the browser
708+
// exceeds recycleAfter indefinitely. (Bug 2)
709+
this.maybeFireDeferredRecycle(entry);
665710
if (this.waiters.length > 0) this.scheduleServeNextWaiter();
666711
return;
667712
}

0 commit comments

Comments
 (0)