diff --git a/scripts/fixtures/browser-verifier-site.mjs b/scripts/fixtures/browser-verifier-site.mjs index 208a314a2..059a63599 100644 --- a/scripts/fixtures/browser-verifier-site.mjs +++ b/scripts/fixtures/browser-verifier-site.mjs @@ -11,9 +11,14 @@ export async function startBrowserVerifierSite() { response.destroy(); return; } - if (request.url === "/scenario-environment") { + const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1"); + if (requestUrl.pathname === "/scenario-environment") { + const requestedRun = requestUrl.searchParams.get("run"); + const run = ["A", "B", "C"].includes(requestedRun) + ? requestedRun + : "default"; response.setHeader("content-type", "text/html"); - response.end(`

+      response.end(`

         `);
       return;
diff --git a/scripts/lib/browser-scenario-environment-e2e.mjs b/scripts/lib/browser-scenario-environment-e2e.mjs
index bc460192d..1804402f3 100644
--- a/scripts/lib/browser-scenario-environment-e2e.mjs
+++ b/scripts/lib/browser-scenario-environment-e2e.mjs
@@ -17,43 +17,68 @@ const expected = {
   width: 900,
   height: 600,
 };
+const alternate = {
+  timezone: "UTC",
+  locale: "en-US",
+  dpr: 1,
+  width: 700,
+  height: 500,
+};
 const expression = `JSON.stringify({
   timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
   locale: Intl.DateTimeFormat().resolvedOptions().locale,
   dpr: devicePixelRatio, width: innerWidth, height: innerHeight
 })`;
 
-const environmentScenario = (endpoint, targetId, origin) =>
+const environmentScenario = ({ endpoint, targetId, origin, values, run }) =>
   browserScenarioSchema.parse({
     browser: { mode: "connect", cdp_endpoint: endpoint, target_id: targetId },
-    start_url: { url: `${origin}/scenario-environment` },
+    start_url: {
+      url: `${origin}/scenario-environment${run === undefined ? "" : `?run=${run}`}`,
+    },
     environment: {
-      locale: expected.locale,
-      timezone: expected.timezone,
+      locale: values.locale,
+      timezone: values.timezone,
       viewport: {
-        width: expected.width,
-        height: expected.height,
-        device_scale_factor: expected.dpr,
+        width: values.width,
+        height: values.height,
+        device_scale_factor: values.dpr,
       },
     },
-    actions: [
-      {
-        step_id: "refresh",
-        action: "click",
-        locator: { kind: "css", selector: "#refresh-environment" },
-      },
-    ],
+    actions:
+      run === undefined
+        ? [
+            {
+              step_id: "refresh",
+              action: "click",
+              locator: { kind: "css", selector: "#refresh-environment" },
+            },
+          ]
+        : [
+            {
+              step_id: `gate_${run}`,
+              action: "wait_for",
+              locator: { kind: "css", selector: `#release-${run}` },
+              state: "attached",
+              timeout_ms: 30_000,
+            },
+            {
+              step_id: `refresh_${run}`,
+              action: "click",
+              locator: { kind: "css", selector: "#refresh-environment" },
+            },
+          ],
     capture: { after_each_step: ["dom"], at_end: ["dom"] },
   });
 
-const assertCapture = (capture) => {
+const assertCapture = (capture, values = expected) => {
   assert.equal(capture.browser.cleanup, "disconnected-external");
   for (const step of capture.steps) {
     assert.equal(step.artifacts.dom.state, "captured");
     const text = step.artifacts.dom.value.text;
     const observed = /
([^<]+)<\/pre>/u.exec(text);
     assert.ok(observed, "DOM omitted the page's actual environment values");
-    assert.deepEqual(JSON.parse(observed[1]), expected);
+    assert.deepEqual(JSON.parse(observed[1]), values);
   }
 };
 
@@ -75,7 +100,12 @@ export async function verifyScenarioEnvironment(endpoint, targetId, origin) {
     return JSON.parse(value.result.value);
   };
   const baseline = await readEnvironment();
-  const scenario = environmentScenario(endpoint, targetId, origin);
+  const scenario = environmentScenario({
+    endpoint,
+    targetId,
+    origin,
+    values: expected,
+  });
   const assertRestored = async () => {
     assert.deepEqual(await readEnvironment(), baseline);
     const remaining = await (await fetch(`${endpoint}/json/list`)).json();
@@ -105,6 +135,15 @@ export async function verifyScenarioEnvironment(endpoint, targetId, origin) {
           "capture_browser_scenario",
         ),
       );
+      await verifyConcurrentMcpCapture({
+        client,
+        connection,
+        endpoint,
+        targetId,
+        origin,
+        readEnvironment,
+        assertRestored,
+      });
     } finally {
       await client.close();
       await transport.close();
@@ -157,6 +196,125 @@ export async function verifyScenarioEnvironment(endpoint, targetId, origin) {
   }
 }
 
+const verifyConcurrentMcpCapture = async ({
+  client,
+  connection,
+  endpoint,
+  targetId,
+  origin,
+  readEnvironment,
+  assertRestored,
+}) => {
+  const firstScenario = environmentScenario({
+    endpoint,
+    targetId,
+    origin,
+    values: expected,
+    run: "A",
+  });
+  const secondScenario = environmentScenario({
+    endpoint,
+    targetId,
+    origin,
+    values: alternate,
+    run: "B",
+  });
+  const firstCancellation = new AbortController();
+  const secondCancellation = new AbortController();
+  const cancellation = new AbortController();
+  const pendingCalls = [];
+  const callTool = (arguments_, signal) => {
+    const pending = client.callTool(
+      { name: "capture_browser_scenario", arguments: arguments_ },
+      { signal, timeout: 60_000 },
+    );
+    void pending.catch(() => {});
+    pendingCalls.push(pending);
+    return pending;
+  };
+
+  try {
+    const first = callTool(firstScenario, firstCancellation.signal);
+    await waitForEnvironment(readEnvironment, connection, expected, "A");
+
+    const second = callTool(secondScenario, secondCancellation.signal);
+    await client.ping();
+    assert.deepEqual(await readEnvironment(), expected);
+    assert.equal(await readScenarioRun(connection), "A");
+
+    const cancelled = callTool(
+      environmentScenario({
+        endpoint,
+        targetId,
+        origin,
+        values: alternate,
+        run: "C",
+      }),
+      cancellation.signal,
+    );
+    await client.ping();
+    cancellation.abort(new Error("cancel queued browser scenario"));
+    await assert.rejects(cancelled, /cancel queued browser scenario/u);
+    assert.deepEqual(await readEnvironment(), expected);
+    assert.equal(await readScenarioRun(connection), "A");
+
+    await releaseScenario(connection);
+    assertCapture(
+      requireMcpEvidenceResult(await first, "capture_browser_scenario"),
+      expected,
+    );
+
+    await waitForEnvironment(readEnvironment, connection, alternate, "B");
+    await releaseScenario(connection);
+    assertCapture(
+      requireMcpEvidenceResult(await second, "capture_browser_scenario"),
+      alternate,
+    );
+    await client.ping();
+    await assertRestored();
+  } finally {
+    firstCancellation.abort();
+    secondCancellation.abort();
+    cancellation.abort();
+    await releaseScenario(connection).catch(() => undefined);
+    await Promise.allSettled(pendingCalls);
+  }
+};
+
+const waitForEnvironment = async (
+  readEnvironment,
+  connection,
+  expectedValues,
+  run,
+) => {
+  const deadline = Date.now() + 10_000;
+  while (Date.now() < deadline) {
+    if (
+      JSON.stringify(await readEnvironment()) ===
+        JSON.stringify(expectedValues) &&
+      (await readScenarioRun(connection)) === run
+    )
+      return;
+    await delay(25);
+  }
+  throw new Error(`Scenario ${run} never reached its page-controlled gate`);
+};
+
+const readScenarioRun = async (connection) => {
+  const value = await connection.send("Runtime.evaluate", {
+    expression: "document.body.dataset.run ?? null",
+    returnByValue: true,
+  });
+  return value.result.value;
+};
+
+const releaseScenario = async (connection) => {
+  await connection.send("Runtime.evaluate", {
+    expression: "window.releaseScenario()",
+    returnByValue: true,
+  });
+};
+
 async function verifyCancelledEnvironment(
   provider,
   connection,
diff --git a/src/browser/PlaywrightBrowserScenarioProvider.test.ts b/src/browser/PlaywrightBrowserScenarioProvider.test.ts
index b66cecb20..af94b7f90 100644
--- a/src/browser/PlaywrightBrowserScenarioProvider.test.ts
+++ b/src/browser/PlaywrightBrowserScenarioProvider.test.ts
@@ -529,9 +529,11 @@ it("shares concurrent close and retries browser cleanup after failure", async ()
   const finishEvents = vi
     .fn<() => Promise>()
     .mockResolvedValue(undefined);
+  const cleanupSettlements: boolean[] = [];
   const cleanup = new PlaywrightScenarioBrowserCleanupOwner({
     closeBrowser: browserClose,
     removeProfile,
+    onSettled: (browserClosed) => cleanupSettlements.push(browserClosed),
   });
 
   const first = cleanup.close(finishEvents);
@@ -541,12 +543,14 @@ it("shares concurrent close and retries browser cleanup after failure", async ()
   expect(browserClose).toHaveBeenCalledTimes(1);
   expect(removeProfile).not.toHaveBeenCalled();
   expect(finishEvents).toHaveBeenCalledTimes(1);
+  expect(cleanupSettlements).toEqual([false]);
 
   await expect(cleanup.close(finishEvents)).resolves.toBeUndefined();
   await expect(cleanup.close(finishEvents)).resolves.toBeUndefined();
   expect(browserClose).toHaveBeenCalledTimes(2);
   expect(removeProfile).toHaveBeenCalledTimes(1);
   expect(finishEvents).toHaveBeenCalledTimes(1);
+  expect(cleanupSettlements).toEqual([false, true]);
 });
 
 it("keeps failed event finalization failed across cleanup retries", async () => {
diff --git a/src/browser/PlaywrightScenarioBrowser.test.ts b/src/browser/PlaywrightScenarioBrowser.test.ts
index 970decfd9..4ee0a3f1f 100644
--- a/src/browser/PlaywrightScenarioBrowser.test.ts
+++ b/src/browser/PlaywrightScenarioBrowser.test.ts
@@ -1,4 +1,5 @@
 import { afterEach, expect, it, vi } from "vitest";
+import type { Browser } from "playwright-core";
 
 import { browserScenarioSchema } from "../domain/browserScenario.js";
 import {
@@ -8,6 +9,14 @@ import {
 
 afterEach(() => vi.unstubAllEnvs());
 
+const deferred = () => {
+  let resolve: (value: Value | PromiseLike) => void = () => undefined;
+  const promise = new Promise((resolvePromise) => {
+    resolve = resolvePromise;
+  });
+  return { promise, resolve };
+};
+
 it("passes the selected environment to the actual browser launch boundary", async () => {
   vi.stubEnv("LANG", "ambient-language");
   const selected = {
@@ -25,12 +34,14 @@ it("passes the selected environment to the actual browser launch boundary", asyn
   });
   await expect(
     openPlaywrightScenarioBrowser(scenario, selected, {
-      connectOverCDP: async () => {
-        throw new Error("Unexpected connection");
-      },
-      launchPersistentContext: async (_profile, options) => {
-        launchedEnvironment = options?.env;
-        throw new Error("launch boundary reached");
+      launcher: {
+        connectOverCDP: async () => {
+          throw new Error("Unexpected connection");
+        },
+        launchPersistentContext: async (_profile, options) => {
+          launchedEnvironment = options?.env;
+          throw new Error("launch boundary reached");
+        },
       },
     }),
   ).rejects.toThrow("launch boundary reached");
@@ -40,27 +51,244 @@ it("passes the selected environment to the actual browser launch boundary", asyn
   });
 });
 
-it("returns cancellation instead of success when an attached browser hangs on close", async () => {
+it("keeps attached-target ownership until cancelled cleanup actually settles", async () => {
   const controller = new AbortController();
-  let resolveCloseStarted: () => void = () => undefined;
-  const closeStarted = new Promise((resolve) => {
-    resolveCloseStarted = resolve;
-  });
+  const closeStarted = deferred();
+  const closeResult = deferred();
+  const settled: boolean[] = [];
   const cleanup = new PlaywrightScenarioBrowserCleanupOwner({
     closeBrowser: () => {
-      resolveCloseStarted();
-      return new Promise(() => undefined);
+      closeStarted.resolve(undefined);
+      return closeResult.promise;
     },
     removeProfile: undefined,
+    onSettled: (browserClosed) => settled.push(browserClosed),
   });
-  const closing = cleanup.close(undefined, controller.signal);
-
-  await closeStarted;
+  const cancelledClose = cleanup.close(undefined, controller.signal);
+  await closeStarted.promise;
   controller.abort();
-  await expect(closing).rejects.toMatchObject({
+  await expect(cancelledClose).rejects.toMatchObject({
     _tag: "BrowserObservationError",
     reason: "cleanup_failed",
     cleanupIncomplete: true,
     userCategory: "cancelled",
   });
+  expect(settled).toEqual([]);
+
+  closeResult.resolve(undefined);
+  await cleanup.close();
+  expect(settled).toEqual([true]);
+});
+
+it("keeps attached-target ownership after the cleanup response timeout", async () => {
+  vi.useFakeTimers();
+  try {
+    const closeStarted = deferred();
+    const closeResult = deferred();
+    const settled: boolean[] = [];
+    const cleanup = new PlaywrightScenarioBrowserCleanupOwner({
+      closeBrowser: () => {
+        closeStarted.resolve(undefined);
+        return closeResult.promise;
+      },
+      removeProfile: undefined,
+      onSettled: (browserClosed) => settled.push(browserClosed),
+    });
+    const timedOutClose = cleanup.close();
+    const timeoutExpectation = expect(timedOutClose).rejects.toMatchObject({
+      reason: "cleanup_failed",
+      cleanup: { reason: expect.stringContaining("timed out") },
+    });
+    await closeStarted.promise;
+    await vi.advanceTimersByTimeAsync(1_000);
+    await timeoutExpectation;
+    expect(settled).toEqual([]);
+
+    closeResult.resolve(undefined);
+    await cleanup.close();
+    expect(settled).toEqual([true]);
+  } finally {
+    vi.useRealTimers();
+  }
+});
+
+it("does not configure a late attached connection after cancellation", async () => {
+  const connection =
+    deferred>();
+  const connectionStarted = deferred();
+  const connectedBrowser = {
+    close: vi.fn(async () => undefined),
+    contexts: vi.fn(() => []),
+    version: () => "fixture",
+  };
+  const controller = new AbortController();
+  const launcher = {
+    connectOverCDP: () => {
+      connectionStarted.resolve(undefined);
+      return connection.promise;
+    },
+    launchPersistentContext: async () => {
+      throw new Error("Unexpected launch");
+    },
+  };
+  const scenario = browserScenarioSchema.parse({
+    browser: {
+      mode: "connect",
+      cdp_endpoint: "http://127.0.0.1:9222",
+      target_id: "page-1",
+    },
+    start_url: { url: "https://example.test" },
+    actions: [{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }],
+  });
+  const opening = openPlaywrightScenarioBrowser(
+    scenario,
+    {},
+    {
+      launcher,
+      signal: controller.signal,
+    },
+  );
+  await connectionStarted.promise;
+  controller.abort();
+  connection.resolve(connectedBrowser);
+
+  await expect(opening).rejects.toMatchObject({
+    _tag: "BrowserObservationError",
+    reason: "cancelled",
+  });
+  expect(connectedBrowser.contexts).not.toHaveBeenCalled();
+  expect(connectedBrowser.close).toHaveBeenCalledOnce();
+});
+
+it("closes an attached connection when cancellation starts target discovery", async () => {
+  const controller = new AbortController();
+  const connectedBrowser = {
+    close: vi.fn(async () => undefined),
+    contexts: vi.fn(() => {
+      controller.abort();
+      return [];
+    }),
+    version: () => "fixture",
+  };
+  const launcher = {
+    connectOverCDP: async () => connectedBrowser,
+    launchPersistentContext: async () => {
+      throw new Error("Unexpected launch");
+    },
+  };
+  const scenario = browserScenarioSchema.parse({
+    browser: {
+      mode: "connect",
+      cdp_endpoint: "http://127.0.0.1:9222",
+      target_id: "page-1",
+    },
+    start_url: { url: "https://example.test" },
+    actions: [{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }],
+  });
+
+  await expect(
+    openPlaywrightScenarioBrowser(
+      scenario,
+      {},
+      {
+        launcher,
+        signal: controller.signal,
+      },
+    ),
+  ).rejects.toMatchObject({
+    _tag: "AnalysisCancelledError",
+  });
+  expect(connectedBrowser.contexts).toHaveBeenCalledOnce();
+  expect(connectedBrowser.close).toHaveBeenCalledOnce();
+});
+
+it("cancels same-target queued connection before acquiring a browser", async () => {
+  const makeScenario = (targetId: string) =>
+    browserScenarioSchema.parse({
+      browser: {
+        mode: "connect",
+        cdp_endpoint: "http://127.0.0.1:9222",
+        target_id: targetId,
+      },
+      start_url: { url: "https://example.test" },
+      actions: [
+        {
+          step_id: "wait",
+          action: "wait_for_timeout",
+          duration_ms: 1,
+        },
+      ],
+    });
+  let rejectFirst: ((cause: Error) => void) | undefined;
+  const firstConnection = new Promise((_resolve, reject) => {
+    rejectFirst = reject;
+  });
+  let resolveFirstStarted: (() => void) | undefined;
+  const firstStarted = new Promise((resolve) => {
+    resolveFirstStarted = resolve;
+  });
+  const endpoints: string[] = [];
+  const connectOverCDP = vi.fn((endpoint: string) => {
+    endpoints.push(endpoint);
+    if (endpoints.length === 1) {
+      resolveFirstStarted?.();
+      return firstConnection;
+    }
+    return Promise.reject(new Error("connection failed"));
+  });
+  const launchPersistentContext = vi.fn(async () => {
+    throw new Error("launch boundary reached");
+  });
+  const launcher = { connectOverCDP, launchPersistentContext };
+  const environment = {};
+  const first = openPlaywrightScenarioBrowser(
+    makeScenario("page-1"),
+    environment,
+    { launcher },
+  );
+  await firstStarted;
+  expect(connectOverCDP).toHaveBeenCalledTimes(1);
+
+  const controller = new AbortController();
+  const removeAbort = vi.spyOn(controller.signal, "removeEventListener");
+  const queued = openPlaywrightScenarioBrowser(
+    makeScenario("page-1"),
+    environment,
+    { launcher, signal: controller.signal },
+  );
+  controller.abort();
+  await expect(queued).rejects.toMatchObject({
+    _tag: "BrowserObservationError",
+    reason: "cancelled",
+  });
+  expect(connectOverCDP).toHaveBeenCalledTimes(1);
+  expect(removeAbort).toHaveBeenCalledWith("abort", expect.any(Function));
+
+  await expect(
+    openPlaywrightScenarioBrowser(makeScenario("page-2"), environment, {
+      launcher,
+    }),
+  ).rejects.toThrow("connection failed");
+  expect(connectOverCDP).toHaveBeenCalledTimes(2);
+
+  const launch = browserScenarioSchema.parse({
+    browser: { mode: "launch", executable_path: process.execPath },
+    start_url: { url: "https://example.test" },
+    actions: [{ step_id: "wait", action: "wait_for_timeout", duration_ms: 1 }],
+  });
+  await expect(
+    openPlaywrightScenarioBrowser(launch, environment, { launcher }),
+  ).rejects.toThrow("launch boundary reached");
+  expect(launchPersistentContext).toHaveBeenCalledTimes(1);
+
+  if (rejectFirst === undefined)
+    throw new Error("first connection did not begin");
+  rejectFirst(new Error("first connection failed"));
+  await expect(first).rejects.toThrow("first connection failed");
+  await expect(
+    openPlaywrightScenarioBrowser(makeScenario("page-1"), environment, {
+      launcher,
+    }),
+  ).rejects.toThrow("connection failed");
+  expect(connectOverCDP).toHaveBeenCalledTimes(3);
 });
diff --git a/src/browser/PlaywrightScenarioBrowser.ts b/src/browser/PlaywrightScenarioBrowser.ts
index 392c96278..88fdda0f8 100644
--- a/src/browser/PlaywrightScenarioBrowser.ts
+++ b/src/browser/PlaywrightScenarioBrowser.ts
@@ -16,10 +16,15 @@ import { withPlaywrightExecutionBoundary } from "./PlaywrightExecutionBoundary.j
 
 const OPERATION = "capture_browser_scenario" as const;
 
+const throwIfScenarioCancelled = (signal: AbortSignal | undefined): void => {
+  if (signal?.aborted === true)
+    throw new BrowserObservationError(OPERATION, "cancelled");
+};
+
 export interface OpenedScenarioBrowser {
   readonly context: BrowserContext;
   readonly page: Page;
-  readonly browser: Browser;
+  readonly browser: Pick;
   readonly profilePath: string | undefined;
   readonly cleanup: PlaywrightScenarioBrowserCleanupOwner;
 }
@@ -28,6 +33,7 @@ export interface OpenedScenarioBrowser {
 interface PlaywrightScenarioCleanupResources {
   readonly closeBrowser: () => Promise;
   readonly removeProfile: (() => Promise) | undefined;
+  readonly onSettled?: (browserClosed: boolean) => void;
 }
 
 /** Owns one browser connection and optional private profile through cleanup retries. */
@@ -49,9 +55,13 @@ export class PlaywrightScenarioBrowserCleanupOwner {
     const cleanup = this.#closePromise ?? this.#closeResources(finishEvents);
     if (this.#closePromise === undefined) {
       this.#closePromise = cleanup;
-      void cleanup.catch(() => {
-        if (this.#closePromise === cleanup) this.#closePromise = undefined;
-      });
+      void cleanup.then(
+        () => this.resources.onSettled?.(this.#browserClosed),
+        () => {
+          if (this.#closePromise === cleanup) this.#closePromise = undefined;
+          this.resources.onSettled?.(this.#browserClosed);
+        },
+      );
     }
     return this.#awaitCleanup(cleanup, signal);
   }
@@ -115,6 +125,116 @@ export class PlaywrightScenarioBrowserCleanupOwner {
   }
 }
 
+interface AttachedScenarioWaiter {
+  readonly signal: AbortSignal | undefined;
+  readonly resolve: (release: () => void) => void;
+  readonly reject: (error: BrowserObservationError) => void;
+  onAbort: (() => void) | undefined;
+}
+
+interface AttachedScenarioAdmission {
+  active: boolean;
+  readonly waiters: AttachedScenarioWaiter[];
+  cleanupOwner: PlaywrightScenarioBrowserCleanupOwner | undefined;
+}
+
+interface AttachedScenarioLease {
+  readonly admission: AttachedScenarioAdmission;
+  readonly release: () => void;
+}
+
+const attachedScenarioAdmissions = new Map();
+
+const releaseAttachedScenario = (
+  key: string,
+  admission: AttachedScenarioAdmission,
+): void => {
+  const waiter = admission.waiters.shift();
+  if (waiter === undefined) {
+    admission.active = false;
+    if (
+      admission.cleanupOwner === undefined &&
+      attachedScenarioAdmissions.get(key) === admission
+    )
+      attachedScenarioAdmissions.delete(key);
+    return;
+  }
+
+  if (waiter.onAbort !== undefined)
+    waiter.signal?.removeEventListener("abort", waiter.onAbort);
+  if (waiter.signal?.aborted === true) {
+    waiter.reject(new BrowserObservationError(OPERATION, "cancelled"));
+    releaseAttachedScenario(key, admission);
+    return;
+  }
+  waiter.resolve(createAttachedScenarioRelease(key, admission));
+};
+
+const createAttachedScenarioRelease = (
+  key: string,
+  admission: AttachedScenarioAdmission,
+): (() => void) => {
+  let released = false;
+  return () => {
+    if (released) return;
+    released = true;
+    releaseAttachedScenario(key, admission);
+  };
+};
+
+const acquireAttachedScenario = (
+  scenario: Extract,
+  signal: AbortSignal | undefined,
+): Promise => {
+  if (signal?.aborted === true)
+    return Promise.reject(new BrowserObservationError(OPERATION, "cancelled"));
+
+  const key = JSON.stringify([scenario.cdp_endpoint, scenario.target_id]);
+  const admission =
+    attachedScenarioAdmissions.get(key) ??
+    ({
+      active: false,
+      waiters: [],
+      cleanupOwner: undefined,
+    } satisfies AttachedScenarioAdmission);
+  attachedScenarioAdmissions.set(key, admission);
+
+  return new Promise((resolve, reject) => {
+    const grant = (release: () => void): void =>
+      resolve({ admission, release });
+    if (!admission.active) {
+      admission.active = true;
+      grant(createAttachedScenarioRelease(key, admission));
+      return;
+    }
+
+    const waiter: AttachedScenarioWaiter = {
+      signal,
+      resolve: (release) => grant(release),
+      reject,
+      onAbort: undefined,
+    };
+    admission.waiters.push(waiter);
+    if (signal !== undefined) {
+      const onAbort = (): void => {
+        const index = admission.waiters.indexOf(waiter);
+        if (index < 0) return;
+        admission.waiters.splice(index, 1);
+        signal.removeEventListener("abort", onAbort);
+        reject(new BrowserObservationError(OPERATION, "cancelled"));
+        if (!admission.active && admission.waiters.length === 0)
+          releaseAttachedScenario(key, admission);
+      };
+      waiter.onAbort = onAbort;
+      signal.addEventListener("abort", onAbort, { once: true });
+      if (signal.aborted) {
+        onAbort();
+        return;
+      }
+    }
+  });
+};
+
 const allowedBrowserEnvironment = (
   environment: Readonly>,
 ): Record => {
@@ -142,14 +262,17 @@ const allowedBrowserEnvironment = (
   );
 };
 const findConnectedPage = async (
-  browser: Browser,
+  browser: Pick,
   targetId: string,
+  signal: AbortSignal | undefined,
 ): Promise<{ readonly context: BrowserContext; readonly page: Page }> => {
   for (const context of browser.contexts())
     for (const page of context.pages()) {
       const session = await context.newCDPSession(page);
       try {
+        throwIfScenarioCancelled(signal);
         const { targetInfo } = await session.send("Target.getTargetInfo");
+        throwIfScenarioCancelled(signal);
         if (targetInfo.targetId !== targetId) continue;
         return { context, page };
       } finally {
@@ -162,30 +285,36 @@ const findConnectedPage = async (
 const configureAttachedEnvironment = async (
   context: BrowserContext,
   page: Page,
-  scenario: BrowserScenario,
+  environment: BrowserScenario["environment"],
+  signal: AbortSignal | undefined,
 ): Promise => {
   await page.setViewportSize({
-    width: scenario.environment.viewport.width,
-    height: scenario.environment.viewport.height,
+    width: environment.viewport.width,
+    height: environment.viewport.height,
   });
+  throwIfScenarioCancelled(signal);
   await page.emulateMedia({
-    colorScheme: scenario.environment.color_scheme,
-    reducedMotion: scenario.environment.reduced_motion,
+    colorScheme: environment.color_scheme,
+    reducedMotion: environment.reduced_motion,
   });
+  throwIfScenarioCancelled(signal);
   const session = await context.newCDPSession(page);
+  throwIfScenarioCancelled(signal);
   // Chromium resets these overrides when the CDP session detaches. Keep it on
   // the scenario transport until cleanup disconnects the attached browser.
   await session.send("Emulation.setDeviceMetricsOverride", {
-    width: scenario.environment.viewport.width,
-    height: scenario.environment.viewport.height,
-    deviceScaleFactor: scenario.environment.viewport.device_scale_factor,
+    width: environment.viewport.width,
+    height: environment.viewport.height,
+    deviceScaleFactor: environment.viewport.device_scale_factor,
     mobile: false,
   });
+  throwIfScenarioCancelled(signal);
   await session.send("Emulation.setLocaleOverride", {
-    locale: scenario.environment.locale,
+    locale: environment.locale,
   });
+  throwIfScenarioCancelled(signal);
   await session.send("Emulation.setTimezoneOverride", {
-    timezoneId: scenario.environment.timezone,
+    timezoneId: environment.timezone,
   });
 };
 
@@ -224,40 +353,97 @@ export const failBrowserScenarioOperation = async (
   throw primaryFailure;
 };
 
+type ScenarioBrowserLauncher = Pick<
+  typeof chromium,
+  "launchPersistentContext"
+> & {
+  readonly connectOverCDP: (
+    endpoint: string,
+  ) => Promise>;
+};
+
+type AttachedBrowserRequest = Extract<
+  BrowserScenario["browser"],
+  { readonly mode: "connect" }
+>;
+
+const openAttachedScenarioBrowser = async (
+  browserRequest: AttachedBrowserRequest,
+  environment: BrowserScenario["environment"],
+  launcher: ScenarioBrowserLauncher,
+  signal: AbortSignal | undefined,
+): Promise => {
+  const lease = await acquireAttachedScenario(browserRequest, signal);
+  let cleanupOwner: PlaywrightScenarioBrowserCleanupOwner | undefined;
+  try {
+    const priorOwner = lease.admission.cleanupOwner;
+    if (priorOwner !== undefined) await priorOwner.close();
+    throwIfScenarioCancelled(signal);
+
+    const browser = await launcher.connectOverCDP(browserRequest.cdp_endpoint);
+    const cleanup: PlaywrightScenarioBrowserCleanupOwner =
+      new PlaywrightScenarioBrowserCleanupOwner({
+        closeBrowser: () => browser.close(),
+        onSettled: (browserClosed) => {
+          if (browserClosed && lease.admission.cleanupOwner === cleanup)
+            lease.admission.cleanupOwner = undefined;
+          lease.release();
+        },
+        removeProfile: undefined,
+      });
+    cleanupOwner = cleanup;
+    lease.admission.cleanupOwner = cleanup;
+    throwIfScenarioCancelled(signal);
+    const target = await withPlaywrightExecutionBoundary(
+      () => findConnectedPage(browser, browserRequest.target_id, signal),
+      undefined,
+      signal,
+    );
+    throwIfScenarioCancelled(signal);
+    await withPlaywrightExecutionBoundary(
+      () =>
+        configureAttachedEnvironment(
+          target.context,
+          target.page,
+          environment,
+          signal,
+        ),
+      undefined,
+      signal,
+    );
+    return {
+      ...target,
+      browser,
+      profilePath: undefined,
+      cleanup,
+    };
+  } catch (cause: unknown) {
+    if (cleanupOwner === undefined) {
+      lease.release();
+      throw cause;
+    }
+    const cleanup = cleanupOwner;
+    return failBrowserScenarioOperation(() => cleanup.close(), cause);
+  }
+};
+
 /** Launch or attach using the caller-selected environment and browser engine. */
 export const openPlaywrightScenarioBrowser = async (
   scenario: BrowserScenario,
   environment: Readonly>,
-  launcher: Pick<
-    typeof chromium,
-    "connectOverCDP" | "launchPersistentContext"
-  > = chromium,
+  options: {
+    readonly launcher?: ScenarioBrowserLauncher;
+    readonly signal?: AbortSignal | undefined;
+  } = {},
 ): Promise => {
-  if (scenario.browser.mode === "connect") {
-    const browser = await launcher.connectOverCDP(
-      scenario.browser.cdp_endpoint,
-      { timeout: 0 },
+  const launcher = options.launcher ?? chromium;
+  if (scenario.browser.mode === "connect")
+    return openAttachedScenarioBrowser(
+      scenario.browser,
+      scenario.environment,
+      launcher,
+      options.signal,
     );
-    const cleanup = new PlaywrightScenarioBrowserCleanupOwner({
-      closeBrowser: () => browser.close(),
-      removeProfile: undefined,
-    });
-    try {
-      const target = await findConnectedPage(
-        browser,
-        scenario.browser.target_id,
-      );
-      await configureAttachedEnvironment(target.context, target.page, scenario);
-      return {
-        ...target,
-        browser,
-        profilePath: undefined,
-        cleanup,
-      };
-    } catch (cause: unknown) {
-      return failBrowserScenarioOperation(() => cleanup.close(), cause);
-    }
-  }
 
   const profilePath = await mkdtemp(join(tmpdir(), "rea-browser-scenario-"));
   let context: BrowserContext;
diff --git a/src/browser/PlaywrightScenarioSession.ts b/src/browser/PlaywrightScenarioSession.ts
index 26aa65fcd..c4ac54459 100644
--- a/src/browser/PlaywrightScenarioSession.ts
+++ b/src/browser/PlaywrightScenarioSession.ts
@@ -135,7 +135,9 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
     if (secrets === undefined)
       throw new BrowserObservationError(OPERATION, "secret_unavailable");
     const startedAt = Date.now();
-    const opening = openPlaywrightScenarioBrowser(scenario, environment);
+    const opening = openPlaywrightScenarioBrowser(scenario, environment, {
+      signal: options.signal,
+    });
     let opened: OpenedScenarioBrowser;
     let events: PlaywrightScenarioEvents | undefined;
     try {