Skip to content

Commit a4e0f32

Browse files
committed
fix(showcase/harness): stop pooled-launcher context leaks and abort-listener leaks
A3: the d4 and e2e-parity pooled context-wrappers now delete from the abort tracking set on normal close() (mirroring d5/d6/demos), and their test fakes' release() is idempotent (tracks a liveContexts Set, no-ops on unknown/double release) so a normal-close-then-abort sequence can't double-release or drive inUse negative. Across all five pooled launchers (d4, d5, d6, e2e-parity, e2e-readiness): capture the abort listener and detach it in the launcher-level close() so a post-completion abort can't fire after the run returned, and in the pre-aborted branch refuse + immediately release any context opened after the signal already aborted so it can't leak into a torn-down run.
1 parent 6735c04 commit a4e0f32

6 files changed

Lines changed: 248 additions & 132 deletions

File tree

showcase/harness/src/probes/drivers/d4-chat-roundtrip.test.ts

Lines changed: 54 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -64,18 +64,17 @@ function makePage(script: PageScript = {}): E2ePage {
6464
}
6565
},
6666
async textContent(sel) {
67-
if (sel === '[data-testid="copilot-assistant-message"]:last-of-type') {
68-
return script.assistantText ?? "";
69-
}
67+
// The driver reads the assistant message via `evaluate()` (see below),
68+
// NOT `textContent(:last-of-type)`, so only the `body` read remains.
7069
if (sel === "body") {
7170
return script.bodyText ?? "";
7271
}
7372
return "";
7473
},
7574
async evaluate<R>(fn: () => R): Promise<R> {
76-
// The evaluate() call in the driver reads the last assistant
77-
// message's textContent via querySelectorAll. In the fake we
78-
// return the same assistantText the old textContent path used.
75+
// The driver's evaluate() reads the last assistant message's
76+
// textContent via querySelectorAll and returns a string. The fake
77+
// returns the scripted assistantText as that string.
7978
return (script.assistantText ?? "") as unknown as R;
8079
},
8180
async close() {
@@ -624,29 +623,58 @@ describe("createPooledE2eSmokeLauncher context checkout + abort release", () =>
624623
await browser.close(); // no-op
625624
expect(pool._releaseLog).toHaveLength(1);
626625
});
626+
627+
// A3 — a normal close() must remove the context from the abort tracking set
628+
// so a SUBSEQUENT abort does not re-release it (double release). Before the
629+
// fix, close() released but never `openContexts.delete(ctxHandle)`, so abort
630+
// closed the already-released context a second time — driving the pool's
631+
// inUse negative with a non-idempotent pool. The fix (delete-on-close + an
632+
// idempotent pool) keeps the release count accurate at exactly 1 and inUse
633+
// at 0.
634+
it("does not double-release a normally-closed context on a later abort", async () => {
635+
const pool = makeFakeContextPool(4);
636+
const launcher = createPooledE2eSmokeLauncher(
637+
pool as unknown as BrowserPool,
638+
);
639+
const ac = new AbortController();
640+
const browser = await launcher(ac.signal);
641+
const ctx = await browser.newContext();
642+
await ctx.newPage();
643+
expect(pool.stats().inUse).toBe(1);
644+
645+
// Normal close first — releases the context exactly once.
646+
await ctx.close();
647+
expect(pool.stats().inUse).toBe(0);
648+
expect(pool._releaseLog).toHaveLength(1);
649+
650+
// Now abort. The already-closed context must NOT be released again.
651+
ac.abort();
652+
await new Promise((r) => setTimeout(r, 10));
653+
expect(pool._releaseLog).toHaveLength(1); // still exactly one release
654+
expect(pool.stats().inUse).toBe(0); // never driven negative
655+
});
627656
});
628657

629658
// Module-scoped fake context-pool for the createPooledE2eSmokeLauncher tests
630659
// above — mirrors d6-all-pills.test.ts's helper. Tracks per-CONTEXT
631-
// acquire/release and the contextOptions each acquire was called with. The
632-
// release() is idempotent on the inUse counter only in the sense that the
633-
// real BrowserPool.release is a no-op on a context that's already been
634-
// released; here each ctxHandle.close() routes to one release call, and the
635-
// launcher's abort path closes each open context exactly once.
660+
// acquire/release and the contextOptions each acquire was called with.
661+
// release() is IDEMPOTENT, mirroring the real BrowserPool.release: it tracks a
662+
// `liveContexts` Set and no-ops on an unknown / already-released context so a
663+
// double release can never drive the inUse counter negative and silently mask
664+
// a double-release bug.
636665
function makeFakeContextPool(maxContexts: number) {
637666
let nextCtxId = 0;
638-
let live = 0;
667+
const liveContexts = new Set<object>();
639668
const releaseLog: number[] = [];
640669
const acquireOptions: Array<
641670
{ extraHTTPHeaders?: Record<string, string> } | undefined
642671
> = [];
643672
return {
644673
async acquire(options?: { extraHTTPHeaders?: Record<string, string> }) {
645-
if (live >= maxContexts) throw new Error("FakePool: at cap");
674+
if (liveContexts.size >= maxContexts) throw new Error("FakePool: at cap");
646675
const id = nextCtxId++;
647-
live++;
648676
acquireOptions.push(options);
649-
return {
677+
const ctx = {
650678
__id: id,
651679
async newPage() {
652680
return {
@@ -661,18 +689,23 @@ function makeFakeContextPool(maxContexts: number) {
661689
} as unknown;
662690
},
663691
async close() {},
664-
} as unknown as Browser;
692+
};
693+
liveContexts.add(ctx);
694+
return ctx as unknown as Browser;
665695
},
666696
async release(ctx: unknown) {
667-
const c = ctx as { __id: number };
668-
releaseLog.push(c.__id);
669-
live--;
697+
// Unknown / double release — no-op, mirroring BrowserPool.release.
698+
if (typeof ctx !== "object" || ctx === null || !liveContexts.has(ctx)) {
699+
return;
700+
}
701+
liveContexts.delete(ctx);
702+
releaseLog.push((ctx as { __id: number }).__id);
670703
},
671704
stats() {
672705
return {
673706
size: maxContexts,
674-
available: maxContexts - live,
675-
inUse: live,
707+
available: maxContexts - liveContexts.size,
708+
inUse: liveContexts.size,
676709
totalRecycles: 0,
677710
};
678711
},

showcase/harness/src/probes/drivers/d4-chat-roundtrip.ts

Lines changed: 53 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,12 @@ import type { ProbeContext, ProbeResult } from "../../types/index.js";
2929
*
3030
* In-process vs spawn: the orchestrator already runs as a long-lived Node
3131
* process with `playwright` installed (see Dockerfile). Launching chromium
32-
* directly via `playwright.chromium.launch()` gives us first-class
33-
* AbortSignal propagation into `page.close()` / `browser.close()` rather
34-
* than having to kill a child process.
32+
* directly via `playwright.chromium.launch()` keeps the browser in-process
33+
* rather than having to kill a child process. Note: the `defaultLauncher`
34+
* does NOT wire the AbortSignal — it dedicates a chromium per call and lets
35+
* the driver's teardown close it. The POOLED launcher
36+
* (`createPooledE2eSmokeLauncher`) is the path that actually re-targets abort
37+
* onto open contexts for prompt pool release.
3538
*
3639
* Pluggable launcher: the production default imports `playwright` lazily
3740
* so the driver module loads cleanly in environments without chromium
@@ -291,33 +294,40 @@ export function createPooledE2eSmokeLauncher(
291294
// pooled context back to the pool, freeing a context slot.
292295
const openContexts = new Set<{ close(): Promise<void> }>();
293296

294-
if (abortSignal) {
295-
const onAbort = (): void => {
296-
if (aborted) return;
297-
aborted = true;
298-
const ctxCount = openContexts.size;
299-
const stats = pool.stats();
300-
logger?.warn("probe.e2e-smoke.pool-abort-release", {
301-
openContexts: ctxCount,
302-
poolAvailable: stats.available,
303-
poolInUse: stats.inUse,
304-
poolSize: stats.size,
305-
});
306-
const contextClosePromises = Array.from(openContexts).map((ctx) =>
307-
ctx.close().catch(() => {}),
308-
);
309-
void Promise.allSettled(contextClosePromises).then(() => {
310-
logger?.warn("probe.e2e-smoke.pool-abort-released", {
311-
closedContexts: ctxCount,
312-
poolAvailable: pool.stats().available,
313-
});
297+
const onAbort = (): void => {
298+
if (aborted) return;
299+
aborted = true;
300+
const ctxCount = openContexts.size;
301+
const stats = pool.stats();
302+
logger?.warn("probe.e2e-smoke.pool-abort-release", {
303+
openContexts: ctxCount,
304+
poolAvailable: stats.available,
305+
poolInUse: stats.inUse,
306+
poolSize: stats.size,
307+
});
308+
const contextClosePromises = Array.from(openContexts).map((ctx) =>
309+
ctx.close().catch(() => {}),
310+
);
311+
void Promise.allSettled(contextClosePromises).then(() => {
312+
logger?.warn("probe.e2e-smoke.pool-abort-released", {
313+
closedContexts: ctxCount,
314+
poolAvailable: pool.stats().available,
314315
});
315-
};
316+
});
317+
};
318+
// Capture the listener so launcher-level close() can detach it: without
319+
// removeEventListener a post-completion abort would fire onAbort after the
320+
// run returned (closing nothing, but leaking the listener for the signal's
321+
// lifetime).
322+
let detachAbort: (() => void) | undefined;
323+
if (abortSignal) {
316324
if (abortSignal.aborted) {
317325
aborted = true;
318326
logger?.warn("probe.e2e-smoke.pool-pre-aborted-release");
319327
} else {
320328
abortSignal.addEventListener("abort", onAbort, { once: true });
329+
detachAbort = () =>
330+
abortSignal.removeEventListener("abort", onAbort);
321331
}
322332
}
323333

@@ -328,6 +338,14 @@ export function createPooledE2eSmokeLauncher(
328338
const ctx = await pool.acquire({
329339
extraHTTPHeaders: ctxOpts?.extraHTTPHeaders,
330340
});
341+
// If the signal was already aborted at launcher construction (the
342+
// pre-aborted branch never attached the live abort listener), a context
343+
// opened now would never be closed by the abort path. Release it
344+
// immediately and refuse so it cannot leak into a torn-down run.
345+
if (aborted) {
346+
await pool.release(ctx).catch(() => {});
347+
throw new Error("e2e-smoke launcher aborted");
348+
}
331349
const ctxHandle = { close: () => pool.release(ctx) };
332350
openContexts.add(ctxHandle);
333351
return {
@@ -343,12 +361,19 @@ export function createPooledE2eSmokeLauncher(
343361
close: () => page.close(),
344362
};
345363
},
346-
close: () => ctxHandle.close(),
364+
close: async () => {
365+
openContexts.delete(ctxHandle);
366+
await ctxHandle.close();
367+
},
347368
};
348369
},
349-
// Launcher-level close is a no-op: contexts are released individually via
350-
// each context-wrapper's close(). There is no Browser held to release.
351-
close: async () => {},
370+
// Launcher-level close releases nothing itself — contexts are released
371+
// individually via each context-wrapper's close() — but it detaches the
372+
// abort listener so a post-completion abort can't fire onAbort after the
373+
// run returned. There is no Browser held to release.
374+
close: async () => {
375+
detachAbort?.();
376+
},
352377
};
353378
};
354379
}

showcase/harness/src/probes/drivers/d5-single-pill.ts

Lines changed: 40 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -478,33 +478,38 @@ export function createPooledE2eDeepLauncher(
478478
// pooled context back to the pool, freeing a context slot.
479479
const openContexts = new Set<{ close(): Promise<void> }>();
480480

481-
if (abortSignal) {
482-
const onAbort = (): void => {
483-
if (aborted) return;
484-
aborted = true;
485-
const ctxCount = openContexts.size;
486-
const stats = pool.stats();
487-
logger?.warn("probe.e2e-deep.pool-abort-release", {
488-
openContexts: ctxCount,
489-
poolAvailable: stats.available,
490-
poolInUse: stats.inUse,
491-
poolSize: stats.size,
492-
});
493-
const contextClosePromises = Array.from(openContexts).map((ctx) =>
494-
ctx.close().catch(() => {}),
495-
);
496-
void Promise.allSettled(contextClosePromises).then(() => {
497-
logger?.warn("probe.e2e-deep.pool-abort-released", {
498-
closedContexts: ctxCount,
499-
poolAvailable: pool.stats().available,
500-
});
481+
const onAbort = (): void => {
482+
if (aborted) return;
483+
aborted = true;
484+
const ctxCount = openContexts.size;
485+
const stats = pool.stats();
486+
logger?.warn("probe.e2e-deep.pool-abort-release", {
487+
openContexts: ctxCount,
488+
poolAvailable: stats.available,
489+
poolInUse: stats.inUse,
490+
poolSize: stats.size,
491+
});
492+
const contextClosePromises = Array.from(openContexts).map((ctx) =>
493+
ctx.close().catch(() => {}),
494+
);
495+
void Promise.allSettled(contextClosePromises).then(() => {
496+
logger?.warn("probe.e2e-deep.pool-abort-released", {
497+
closedContexts: ctxCount,
498+
poolAvailable: pool.stats().available,
501499
});
502-
};
500+
});
501+
};
502+
// Capture the listener so launcher-level close() can detach it: without
503+
// removeEventListener a post-completion abort would fire onAbort after the
504+
// run returned, leaking the listener for the signal's lifetime.
505+
let detachAbort: (() => void) | undefined;
506+
if (abortSignal) {
503507
if (abortSignal.aborted) {
504508
aborted = true;
505509
logger?.warn("probe.e2e-deep.pool-pre-aborted-release");
506510
} else {
507511
abortSignal.addEventListener("abort", onAbort, { once: true });
512+
detachAbort = () => abortSignal.removeEventListener("abort", onAbort);
508513
}
509514
}
510515

@@ -515,6 +520,14 @@ export function createPooledE2eDeepLauncher(
515520
const ctx = await pool.acquire({
516521
extraHTTPHeaders: contextOpts?.extraHTTPHeaders,
517522
});
523+
// If the signal was already aborted at launcher construction (the
524+
// pre-aborted branch never attached the live abort listener), a context
525+
// opened now would never be closed by the abort path. Release it
526+
// immediately and refuse so it cannot leak into a torn-down run.
527+
if (aborted) {
528+
await pool.release(ctx).catch(() => {});
529+
throw new Error("e2e-deep launcher aborted");
530+
}
518531
const ctxHandle = { close: () => pool.release(ctx) };
519532
openContexts.add(ctxHandle);
520533
return {
@@ -573,8 +586,12 @@ export function createPooledE2eDeepLauncher(
573586
},
574587
};
575588
},
576-
// Launcher-level close is a no-op: each context releases itself.
577-
close: async () => {},
589+
// Launcher-level close releases nothing itself (each context releases
590+
// itself) but detaches the abort listener so a post-completion abort
591+
// can't fire onAbort after the run returned.
592+
close: async () => {
593+
detachAbort?.();
594+
},
578595
};
579596
};
580597
}

0 commit comments

Comments
 (0)