From 3fc485ca92d897cb41465bfc58136e022ed1458c Mon Sep 17 00:00:00 2001 From: Rohit <76606932+rohitjavvadi@users.noreply.github.com> Date: Mon, 1 Jun 2026 07:25:38 +0530 Subject: [PATCH] fix(browser): isolate Chrome MCP pending attach aborts (#88305) * fix(browser): isolate Chrome MCP pending attach aborts * fix(browser): evict closing Chrome MCP sessions * fix(browser): clean chrome mcp pending session lifecycle * fix(browser): handle stale chrome mcp pending sessions * fix(browser): serialize stale chrome mcp replacement * fix(browser): skip cancelled chrome mcp attach * fix(browser): retire timed-out chrome mcp pending sessions * fix(browser): retire stale chrome mcp after readiness * fix(browser): keep shared chrome mcp timeouts isolated * fix(browser): bound stale chrome mcp ready retries * fix(browser): narrow pending session lease release * fix(browser): keep ephemeral probes out of pending attaches * fix(foundry): satisfy provider lint --------- Co-authored-by: Peter Steinberger --- .../browser/src/browser/chrome-mcp.test.ts | 529 ++++++++++++++++++ extensions/browser/src/browser/chrome-mcp.ts | 313 +++++++++-- extensions/microsoft-foundry/provider.ts | 16 +- 3 files changed, 789 insertions(+), 69 deletions(-) diff --git a/extensions/browser/src/browser/chrome-mcp.test.ts b/extensions/browser/src/browser/chrome-mcp.test.ts index 382bdcf3276..aa9a566c9f8 100644 --- a/extensions/browser/src/browser/chrome-mcp.test.ts +++ b/extensions/browser/src/browser/chrome-mcp.test.ts @@ -666,6 +666,535 @@ describe("chrome MCP page parsing", () => { expect(result).toBe(123); }); + it("keeps a shared pending session alive when one waiter aborts", async () => { + let factoryCalls = 0; + let releaseFactory: (() => void) | undefined; + const factoryGate = new Promise((resolve) => { + releaseFactory = resolve; + }); + if (!releaseFactory) { + throw new Error("Expected Chrome MCP factory release callback to be initialized"); + } + + const closeMock = vi.fn().mockResolvedValue(undefined); + const factory: ChromeMcpSessionFactory = async () => { + factoryCalls += 1; + await factoryGate; + const session = createFakeSession(); + session.client.close = closeMock as typeof session.client.close; + return session; + }; + setChromeMcpSessionFactoryForTest(factory); + + const ctrl = new AbortController(); + const keptCtrl = new AbortController(); + const abortedTabsPromise = listChromeMcpTabs("chrome-live", undefined, { + signal: ctrl.signal, + }); + const tabsPromise = listChromeMcpTabs("chrome-live", undefined, { + signal: keptCtrl.signal, + }); + + const abortedTabsExpectation = + expect(abortedTabsPromise).rejects.toThrow(/first caller cancelled/); + ctrl.abort(new Error("first caller cancelled")); + releaseFactory(); + + await abortedTabsExpectation; + await expect(tabsPromise).resolves.toHaveLength(2); + expect(factoryCalls).toBe(1); + expect(closeMock).not.toHaveBeenCalled(); + }); + + it("closes a shared pending session when every waiter aborts", async () => { + let factoryCalls = 0; + let releaseFactory: (() => void) | undefined; + const factoryGate = new Promise((resolve) => { + releaseFactory = resolve; + }); + if (!releaseFactory) { + throw new Error("Expected Chrome MCP factory release callback to be initialized"); + } + + const closeMock = vi.fn().mockResolvedValue(undefined); + const factory: ChromeMcpSessionFactory = async () => { + factoryCalls += 1; + await factoryGate; + const session = createFakeSession(); + session.client.close = closeMock as typeof session.client.close; + return session; + }; + setChromeMcpSessionFactoryForTest(factory); + + const ctrl = new AbortController(); + const tabsPromise = listChromeMcpTabs("chrome-live", undefined, { + signal: ctrl.signal, + }); + const tabsExpectation = expect(tabsPromise).rejects.toThrow(/caller cancelled/); + + await vi.waitFor(() => expect(factoryCalls).toBe(1)); + ctrl.abort(new Error("caller cancelled")); + releaseFactory(); + + await tabsExpectation; + await vi.waitFor(() => expect(closeMock).toHaveBeenCalledTimes(1)); + expect(factoryCalls).toBe(1); + }); + + it("starts a fresh shared session after every waiter aborts a pending attach", async () => { + let factoryCalls = 0; + const releaseFactories: Array<() => void> = []; + const closeMocks: Array> = []; + const factory: ChromeMcpSessionFactory = async () => { + factoryCalls += 1; + let releaseFactory: (() => void) | undefined; + const factoryGate = new Promise((resolve) => { + releaseFactory = resolve; + }); + if (!releaseFactory) { + throw new Error("Expected Chrome MCP factory release callback to be initialized"); + } + releaseFactories.push(releaseFactory); + await factoryGate; + const session = createFakeSession(); + const closeMock = vi.fn().mockResolvedValue(undefined); + closeMocks.push(closeMock); + session.client.close = closeMock as typeof session.client.close; + return session; + }; + setChromeMcpSessionFactoryForTest(factory); + + const ctrl = new AbortController(); + const abortedTabsPromise = listChromeMcpTabs("chrome-live", undefined, { + signal: ctrl.signal, + }); + const abortedTabsExpectation = expect(abortedTabsPromise).rejects.toThrow(/caller cancelled/); + + await vi.waitFor(() => expect(factoryCalls).toBe(1)); + ctrl.abort(new Error("caller cancelled")); + await abortedTabsExpectation; + + const tabsPromise = listChromeMcpTabs("chrome-live"); + await vi.waitFor(() => expect(factoryCalls).toBe(2)); + releaseFactories[0]?.(); + releaseFactories[1]?.(); + + await expect(tabsPromise).resolves.toHaveLength(2); + await vi.waitFor(() => expect(closeMocks[0]).toHaveBeenCalledTimes(1)); + expect(closeMocks[1]).not.toHaveBeenCalled(); + }); + + it("closes a shared pending session when every waiter aborts before ready", async () => { + let factoryCalls = 0; + let releaseReady: (() => void) | undefined; + const readyGate = new Promise((resolve) => { + releaseReady = resolve; + }); + if (!releaseReady) { + throw new Error("Expected Chrome MCP ready release callback to be initialized"); + } + + const closeMock = vi.fn().mockResolvedValue(undefined); + const factory: ChromeMcpSessionFactory = async () => { + factoryCalls += 1; + const session = createFakeSession(); + session.ready = readyGate; + session.client.close = closeMock as typeof session.client.close; + return session; + }; + setChromeMcpSessionFactoryForTest(factory); + + const ctrl = new AbortController(); + const tabsPromise = listChromeMcpTabs("chrome-live", undefined, { + signal: ctrl.signal, + }); + const tabsExpectation = expect(tabsPromise).rejects.toThrow(/caller cancelled/); + + await vi.waitFor(() => expect(factoryCalls).toBe(1)); + ctrl.abort(new Error("caller cancelled")); + releaseReady(); + + await tabsExpectation; + await vi.waitFor(() => expect(closeMock).toHaveBeenCalledTimes(1)); + }); + + it("starts a fresh session while last-waiter abort cleanup is closing", async () => { + let factoryCalls = 0; + let releaseFirstClose: (() => void) | undefined; + const firstCloseGate = new Promise((resolve) => { + releaseFirstClose = resolve; + }); + if (!releaseFirstClose) { + throw new Error("Expected Chrome MCP close release callback to be initialized"); + } + + const closeMocks: Array> = []; + const factory: ChromeMcpSessionFactory = async () => { + factoryCalls += 1; + const session = createFakeSession(); + const closeMock = + factoryCalls === 1 + ? vi.fn(async () => { + await firstCloseGate; + }) + : vi.fn().mockResolvedValue(undefined); + closeMocks.push(closeMock); + session.client.close = closeMock as typeof session.client.close; + if (factoryCalls === 1) { + session.ready = new Promise(() => {}); + } + return session; + }; + setChromeMcpSessionFactoryForTest(factory); + + const ctrl = new AbortController(); + const abortedTabsPromise = listChromeMcpTabs("chrome-live", undefined, { + signal: ctrl.signal, + }); + const abortedTabsExpectation = expect(abortedTabsPromise).rejects.toThrow(/caller cancelled/); + + await vi.waitFor(() => expect(factoryCalls).toBe(1)); + ctrl.abort(new Error("caller cancelled")); + await vi.waitFor(() => expect(closeMocks[0]).toHaveBeenCalledTimes(1)); + + const tabsPromise = listChromeMcpTabs("chrome-live"); + await vi.waitFor(() => expect(factoryCalls).toBe(2)); + await expect(tabsPromise).resolves.toHaveLength(2); + expect(closeMocks[1]).not.toHaveBeenCalled(); + + releaseFirstClose(); + await abortedTabsExpectation; + }); + + it("keeps a ready-pending shared session cached when another waiter remains", async () => { + let factoryCalls = 0; + let releaseReady: (() => void) | undefined; + const readyGate = new Promise((resolve) => { + releaseReady = resolve; + }); + const readyThen = vi.spyOn(readyGate, "then"); + if (!releaseReady) { + throw new Error("Expected Chrome MCP ready release callback to be initialized"); + } + + const closeMock = vi.fn().mockResolvedValue(undefined); + const factory: ChromeMcpSessionFactory = async () => { + factoryCalls += 1; + const session = createFakeSession(); + session.ready = readyGate; + session.client.close = closeMock as typeof session.client.close; + return session; + }; + setChromeMcpSessionFactoryForTest(factory); + + const ctrl = new AbortController(); + const abortedTabsPromise = listChromeMcpTabs("chrome-live", undefined, { + signal: ctrl.signal, + }); + const abortedTabsExpectation = + expect(abortedTabsPromise).rejects.toThrow(/first caller cancelled/); + + await vi.waitFor(() => expect(factoryCalls).toBe(1)); + await vi.waitFor(() => expect(readyThen).toHaveBeenCalledTimes(1)); + const keptCtrl = new AbortController(); + const tabsPromise = listChromeMcpTabs("chrome-live", undefined, { + signal: keptCtrl.signal, + }); + await vi.waitFor(() => expect(readyThen).toHaveBeenCalledTimes(2)); + ctrl.abort(new Error("first caller cancelled")); + releaseReady(); + + await abortedTabsExpectation; + await expect(tabsPromise).resolves.toHaveLength(2); + await expect(listChromeMcpTabs("chrome-live")).resolves.toHaveLength(2); + expect(factoryCalls).toBe(1); + expect(closeMock).not.toHaveBeenCalled(); + }); + + it("starts a fresh shared session when a ready-pending session loses its transport", async () => { + let factoryCalls = 0; + let firstSession: ChromeMcpSession | undefined; + let releaseFirstReady: (() => void) | undefined; + const firstReadyGate = new Promise((resolve) => { + releaseFirstReady = resolve; + }); + const firstReadyThen = vi.spyOn(firstReadyGate, "then"); + if (!releaseFirstReady) { + throw new Error("Expected Chrome MCP ready release callback to be initialized"); + } + + const closeMocks: Array> = []; + const factory: ChromeMcpSessionFactory = async () => { + factoryCalls += 1; + const session = createFakeSession(); + const closeMock = vi.fn().mockResolvedValue(undefined); + closeMocks.push(closeMock); + session.client.close = closeMock as typeof session.client.close; + if (factoryCalls === 1) { + firstSession = session; + session.ready = firstReadyGate; + } + return session; + }; + setChromeMcpSessionFactoryForTest(factory); + + const ctrl = new AbortController(); + const firstTabsPromise = listChromeMcpTabs("chrome-live", undefined, { + signal: ctrl.signal, + }); + const firstTabsExpectation = expect(firstTabsPromise).rejects.toThrow(/first waiter cancelled/); + + await vi.waitFor(() => expect(factoryCalls).toBe(1)); + await vi.waitFor(() => expect(firstReadyThen).toHaveBeenCalledTimes(1)); + if (!firstSession) { + throw new Error("Expected first Chrome MCP session to be created"); + } + (firstSession.transport as { pid: number | null }).pid = null; + + const tabsPromise = listChromeMcpTabs("chrome-live"); + const siblingTabsPromise = listChromeMcpTabs("chrome-live"); + ctrl.abort(new Error("first waiter cancelled")); + releaseFirstReady(); + await vi.waitFor(() => expect(factoryCalls).toBe(2)); + const [tabs, siblingTabs] = await Promise.all([tabsPromise, siblingTabsPromise]); + expect(tabs).toHaveLength(2); + expect(siblingTabs).toHaveLength(2); + + await firstTabsExpectation; + await vi.waitFor(() => expect(closeMocks[0]).toHaveBeenCalledTimes(1)); + expect(closeMocks[1]).not.toHaveBeenCalled(); + }); + + it("surfaces startup failures before treating null-pid pending sessions as stale", async () => { + let factoryCalls = 0; + const closeMock = vi.fn().mockResolvedValue(undefined); + const factory: ChromeMcpSessionFactory = async () => { + factoryCalls += 1; + if (factoryCalls > 1) { + throw new Error("unexpected retry"); + } + const session = createFakeSession(); + (session.transport as { pid: number | null }).pid = null; + const readyFailure = Promise.reject(new Error("startup failed")); + readyFailure.catch(() => {}); + session.ready = readyFailure; + session.client.close = closeMock as typeof session.client.close; + return session; + }; + setChromeMcpSessionFactoryForTest(factory); + + await expect(listChromeMcpTabs("chrome-live")).rejects.toThrow(/startup failed/); + + expect(factoryCalls).toBe(1); + await vi.waitFor(() => expect(closeMock).toHaveBeenCalledTimes(1)); + }); + + it("bounds retries when ready sessions keep losing their transport", async () => { + let factoryCalls = 0; + const closeMocks: Array> = []; + const factory: ChromeMcpSessionFactory = async () => { + factoryCalls += 1; + const session = createFakeSession(); + (session.transport as { pid: number | null }).pid = null; + const closeMock = vi.fn().mockResolvedValue(undefined); + closeMocks.push(closeMock); + session.client.close = closeMock as typeof session.client.close; + return session; + }; + setChromeMcpSessionFactoryForTest(factory); + + await expect(listChromeMcpTabs("chrome-live")).rejects.toThrow( + /subprocess exited before it became usable/, + ); + + expect(factoryCalls).toBe(2); + await vi.waitFor(() => expect(closeMocks[0]).toHaveBeenCalled()); + await vi.waitFor(() => expect(closeMocks[1]).toHaveBeenCalled()); + }); + + it("does not reuse a stale ready-pending session for ephemeral probes", async () => { + let factoryCalls = 0; + let firstSession: ChromeMcpSession | undefined; + let releaseFirstReady: (() => void) | undefined; + const firstReadyGate = new Promise((resolve) => { + releaseFirstReady = resolve; + }); + const firstReadyThen = vi.spyOn(firstReadyGate, "then"); + if (!releaseFirstReady) { + throw new Error("Expected Chrome MCP ready release callback to be initialized"); + } + + const closeMocks: Array> = []; + const factory: ChromeMcpSessionFactory = async () => { + factoryCalls += 1; + const session = createFakeSession(); + const closeMock = vi.fn().mockResolvedValue(undefined); + closeMocks.push(closeMock); + session.client.close = closeMock as typeof session.client.close; + if (factoryCalls === 1) { + firstSession = session; + session.ready = firstReadyGate; + } + return session; + }; + setChromeMcpSessionFactoryForTest(factory); + + const ctrl = new AbortController(); + const firstAvailablePromise = ensureChromeMcpAvailable("chrome-live", undefined, { + signal: ctrl.signal, + }); + const firstAvailableExpectation = + expect(firstAvailablePromise).rejects.toThrow(/first waiter cancelled/); + + await vi.waitFor(() => expect(factoryCalls).toBe(1)); + await vi.waitFor(() => expect(firstReadyThen).toHaveBeenCalledTimes(1)); + if (!firstSession) { + throw new Error("Expected first Chrome MCP session to be created"); + } + (firstSession.transport as { pid: number | null }).pid = null; + + const availablePromise = ensureChromeMcpAvailable("chrome-live", undefined, { + ephemeral: true, + }); + ctrl.abort(new Error("first waiter cancelled")); + releaseFirstReady(); + await expect(availablePromise).resolves.toBeUndefined(); + expect(factoryCalls).toBe(2); + await vi.waitFor(() => expect(closeMocks[1]).toHaveBeenCalledTimes(1)); + + await firstAvailableExpectation; + await vi.waitFor(() => expect(closeMocks[0]).toHaveBeenCalledTimes(1)); + }); + + it("does not let ephemeral probes persist canceled pending attaches", async () => { + let factoryCalls = 0; + let releaseFirstReady: (() => void) | undefined; + const firstReadyGate = new Promise((resolve) => { + releaseFirstReady = resolve; + }); + const firstReadyThen = vi.spyOn(firstReadyGate, "then"); + if (!releaseFirstReady) { + throw new Error("Expected Chrome MCP ready release callback to be initialized"); + } + + const closeMocks: Array> = []; + const factory: ChromeMcpSessionFactory = async () => { + factoryCalls += 1; + const session = createFakeSession(); + const closeMock = vi.fn().mockResolvedValue(undefined); + closeMocks.push(closeMock); + session.client.close = closeMock as typeof session.client.close; + if (factoryCalls === 1) { + session.ready = firstReadyGate; + } + return session; + }; + setChromeMcpSessionFactoryForTest(factory); + + const ctrl = new AbortController(); + const firstAvailablePromise = ensureChromeMcpAvailable("chrome-live", undefined, { + signal: ctrl.signal, + }); + const firstAvailableExpectation = + expect(firstAvailablePromise).rejects.toThrow(/first waiter cancelled/); + + await vi.waitFor(() => expect(factoryCalls).toBe(1)); + await vi.waitFor(() => expect(firstReadyThen).toHaveBeenCalledTimes(1)); + + await expect( + ensureChromeMcpAvailable("chrome-live", undefined, { + ephemeral: true, + }), + ).resolves.toBeUndefined(); + expect(factoryCalls).toBe(2); + expect(firstReadyThen).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(closeMocks[1]).toHaveBeenCalledTimes(1)); + + ctrl.abort(new Error("first waiter cancelled")); + releaseFirstReady(); + await firstAvailableExpectation; + await vi.waitFor(() => expect(closeMocks[0]).toHaveBeenCalledTimes(1)); + + await expect(listChromeMcpTabs("chrome-live")).resolves.toHaveLength(2); + expect(factoryCalls).toBe(3); + }); + + it("keeps a shared session after a readiness timeout while another waiter remains", async () => { + let factoryCalls = 0; + let releaseFirstReady: (() => void) | undefined; + const firstReadyGate = new Promise((resolve) => { + releaseFirstReady = resolve; + }); + const firstReadyThen = vi.spyOn(firstReadyGate, "then"); + if (!releaseFirstReady) { + throw new Error("Expected Chrome MCP ready release callback to be initialized"); + } + + const closeMocks: Array> = []; + const factory: ChromeMcpSessionFactory = async () => { + factoryCalls += 1; + const session = createFakeSession(); + const closeMock = vi.fn().mockResolvedValue(undefined); + closeMocks.push(closeMock); + session.client.close = closeMock as typeof session.client.close; + if (factoryCalls === 1) { + session.ready = firstReadyGate; + } + return session; + }; + setChromeMcpSessionFactoryForTest(factory); + + const keptCtrl = new AbortController(); + const timedOutTabsPromise = listChromeMcpTabs("chrome-live", undefined, { + timeoutMs: 1, + }); + const timedOutTabsExpectation = expect(timedOutTabsPromise).rejects.toThrow(/timed out/); + const keptTabsPromise = listChromeMcpTabs("chrome-live", undefined, { + signal: keptCtrl.signal, + }); + + await vi.waitFor(() => expect(factoryCalls).toBe(1)); + await vi.waitFor(() => expect(firstReadyThen).toHaveBeenCalledTimes(2)); + await timedOutTabsExpectation; + + const laterTabsPromise = listChromeMcpTabs("chrome-live"); + releaseFirstReady(); + + await expect(keptTabsPromise).resolves.toHaveLength(2); + await expect(laterTabsPromise).resolves.toHaveLength(2); + expect(factoryCalls).toBe(1); + expect(closeMocks[0]).not.toHaveBeenCalled(); + keptCtrl.abort(new Error("kept waiter cancelled")); + }); + + it("closes a shared pending session after a readiness timeout with no other waiters", async () => { + let factoryCalls = 0; + const closeMocks: Array> = []; + const factory: ChromeMcpSessionFactory = async () => { + factoryCalls += 1; + const session = createFakeSession(); + const closeMock = vi.fn().mockResolvedValue(undefined); + closeMocks.push(closeMock); + session.client.close = closeMock as typeof session.client.close; + if (factoryCalls === 1) { + session.ready = new Promise(() => {}); + } + return session; + }; + setChromeMcpSessionFactoryForTest(factory); + + await expect( + listChromeMcpTabs("chrome-live", undefined, { + timeoutMs: 1, + }), + ).rejects.toThrow(/timed out/); + await vi.waitFor(() => expect(closeMocks[0]).toHaveBeenCalledTimes(1)); + + await expect(listChromeMcpTabs("chrome-live")).resolves.toHaveLength(2); + expect(factoryCalls).toBe(2); + expect(closeMocks[1]).not.toHaveBeenCalled(); + }); + it("preserves session after tool-level errors (isError)", async () => { let factoryCalls = 0; const factory: ChromeMcpSessionFactory = async () => { diff --git a/extensions/browser/src/browser/chrome-mcp.ts b/extensions/browser/src/browser/chrome-mcp.ts index 156c540a6dc..5a01fed0613 100644 --- a/extensions/browser/src/browser/chrome-mcp.ts +++ b/extensions/browser/src/browser/chrome-mcp.ts @@ -80,6 +80,22 @@ type ChromeMcpSessionFactory = ( options?: NormalizedChromeMcpProfileOptions, ) => Promise; +type PendingChromeMcpSession = { + cacheKey: string; + id: symbol; + promise: Promise; + abortController: AbortController; + state: { + waiters: number; + settled: boolean; + }; +}; + +type PendingChromeMcpSessionLease = { + session: ChromeMcpSession; + release: (closeIfLastWaiter: boolean) => Promise; +}; + export type ChromeMcpProcessInfo = { pid: number; ppid: number; @@ -123,7 +139,7 @@ const STALE_SELECTED_PAGE_ERROR = const execFileAsync = promisify(execFile); const sessions = new Map(); -const pendingSessions = new Map>(); +const pendingSessions = new Map(); let sessionFactory: ChromeMcpSessionFactory | null = null; let chromeMcpProcessCleanupDepsForTest: ChromeMcpProcessCleanupDeps | null = null; @@ -362,9 +378,10 @@ async function closeChromeMcpSessionsForProfile( ): Promise { let closed = false; - for (const key of Array.from(pendingSessions.keys())) { + for (const [key, pending] of Array.from(pendingSessions.entries())) { if (key !== keepKey && cacheKeyMatchesProfileName(key, profileName)) { pendingSessions.delete(key); + abortPendingChromeMcpSession(pending, new Error("Chrome MCP profile session was replaced")); closed = true; } } @@ -650,10 +667,9 @@ async function withChromeMcpHandshakeTimeout(task: Promise): Promise { return await Promise.race([ task, new Promise((_, reject) => { - timer = setTimeout( - () => reject(new Error("Chrome MCP handshake timed out")), - CHROME_MCP_HANDSHAKE_TIMEOUT_MS, - ); + timer = setTimeout(() => { + reject(new Error("Chrome MCP handshake timed out")); + }, CHROME_MCP_HANDSHAKE_TIMEOUT_MS); timer.unref?.(); }), ]); @@ -812,21 +828,132 @@ async function createChromeMcpSession( signal?: AbortSignal, ): Promise { const created = (sessionFactory ?? createRealSession)(profileName, options); + let closedAfterAbort = false; try { const session = await waitForChromeMcpPendingSession(created, signal); if (signal?.aborted) { + closedAfterAbort = true; await closeChromeMcpSessionHandle(session); throw signal.reason ?? new Error("aborted"); } return session; } catch (err) { - if (signal?.aborted) { + if (signal?.aborted && !closedAfterAbort) { void created.then((session) => closeChromeMcpSessionHandle(session)).catch(() => {}); } throw err; } } +function abortPendingChromeMcpSession( + pending: PendingChromeMcpSession, + reason: unknown = new Error("Chrome MCP session attach no longer has active waiters"), +): void { + if (!pending.state.settled && !pending.abortController.signal.aborted) { + pending.abortController.abort(reason); + } +} + +function forgetCachedChromeMcpSessionIfCurrent( + cacheKey: string, + session: ChromeMcpSession, +): boolean { + const current = sessions.get(cacheKey); + if (current?.transport !== session.transport) { + return false; + } + sessions.delete(cacheKey); + return true; +} + +function forgetPendingChromeMcpSessionIfCurrent( + cacheKey: string, + pending: PendingChromeMcpSession, +): boolean { + if (pendingSessions.get(cacheKey) !== pending) { + return false; + } + pendingSessions.delete(cacheKey); + return true; +} + +function createSharedPendingChromeMcpSession( + cacheKey: string, + profileName: string, + options: NormalizedChromeMcpProfileOptions, +): PendingChromeMcpSession { + const id = Symbol(cacheKey); + const abortController = new AbortController(); + const state = { + waiters: 0, + settled: false, + }; + const promise = (async () => { + try { + const created = await createChromeMcpSession(profileName, options, abortController.signal); + if (pendingSessions.get(cacheKey)?.id === id) { + sessions.set(cacheKey, created); + } else { + await closeChromeMcpSessionHandle(created); + } + return created; + } finally { + state.settled = true; + if (state.waiters === 0 && pendingSessions.get(cacheKey)?.id === id) { + pendingSessions.delete(cacheKey); + } + } + })(); + const pending: PendingChromeMcpSession = { + cacheKey, + id, + promise, + abortController, + state, + }; + void promise.catch(() => {}); + return pending; +} + +async function waitForSharedPendingChromeMcpSession( + pending: PendingChromeMcpSession, + signal?: AbortSignal, +): Promise { + pending.state.waiters += 1; + let released = false; + let leasedSession: ChromeMcpSession | undefined; + const release = async (closeIfLastWaiter: boolean) => { + if (released) { + return false; + } + released = true; + pending.state.waiters = Math.max(0, pending.state.waiters - 1); + if (pending.state.waiters !== 0) { + return false; + } + if (pendingSessions.get(pending.cacheKey) === pending) { + pendingSessions.delete(pending.cacheKey); + } + if (!pending.state.settled) { + abortPendingChromeMcpSession(pending, signal?.reason); + } else if (closeIfLastWaiter && leasedSession) { + forgetCachedChromeMcpSessionIfCurrent(pending.cacheKey, leasedSession); + await closeChromeMcpSessionHandle(leasedSession); + } + return true; + }; + try { + leasedSession = await waitForChromeMcpPendingSession(pending.promise, signal); + return { + session: leasedSession, + release, + }; + } catch (err) { + await release(signal?.aborted === true); + throw err; + } +} + async function getSession( profileName: string, profileOptions?: ChromeMcpOptionsInput, @@ -836,43 +963,79 @@ async function getSession( const options = normalizeChromeMcpOptions(profileOptions); const cacheKey = buildChromeMcpSessionCacheKey(profileName, options); await closeChromeMcpSessionsForProfile(profileName, cacheKey); + if (signal?.aborted) { + throw signal.reason ?? new Error("aborted"); + } - let session = sessions.get(cacheKey); - if (session && session.transport.pid === null) { - sessions.delete(cacheKey); - session = undefined; - } - if (!session) { - let pending = pendingSessions.get(cacheKey); - if (!pending) { - pending = (async () => { - const created = await createChromeMcpSession(profileName, options, signal); - if (pendingSessions.get(cacheKey) === pending) { - sessions.set(cacheKey, created); - } else { - await closeChromeMcpSessionHandle(created); - } - return created; - })(); - pendingSessions.set(cacheKey, pending); - } - try { - session = await pending; - } finally { - if (pendingSessions.get(cacheKey) === pending) { - pendingSessions.delete(cacheKey); - } - } - } - try { - await waitForChromeMcpReady(session, profileName, timeoutMs, signal); - return session; - } catch (err) { - const current = sessions.get(cacheKey); - if (current?.transport === session.transport) { + let staleReadySessionRetries = 0; + for (;;) { + let session = sessions.get(cacheKey); + if (session && session.transport.pid === null) { sessions.delete(cacheKey); + session = undefined; + } + + let pendingLease: PendingChromeMcpSessionLease | undefined; + let leasedPending: PendingChromeMcpSession | undefined; + const pending = pendingSessions.get(cacheKey); + if (pending) { + leasedPending = pending; + pendingLease = await waitForSharedPendingChromeMcpSession(pending, signal); + session = pendingLease.session; + } + + if (!session) { + const createdPending = createSharedPendingChromeMcpSession(cacheKey, profileName, options); + pendingSessions.set(cacheKey, createdPending); + leasedPending = createdPending; + pendingLease = await waitForSharedPendingChromeMcpSession(createdPending, signal); + session = pendingLease.session; + } + + try { + await waitForChromeMcpReady(session, profileName, timeoutMs, signal); + if (session.transport.pid === null) { + forgetCachedChromeMcpSessionIfCurrent(cacheKey, session); + if (leasedPending) { + forgetPendingChromeMcpSessionIfCurrent(cacheKey, leasedPending); + } + if (pendingLease) { + await pendingLease.release(true); + pendingLease = undefined; + } + staleReadySessionRetries += 1; + if (staleReadySessionRetries > 1) { + throw new BrowserProfileUnavailableError( + `Chrome MCP existing-session attach failed for profile "${redactChromeMcpProfileLabelForDiagnostic(profileName)}". ` + + "The Chrome MCP subprocess exited before it became usable.", + ); + } + continue; + } + return session; + } catch (err) { + if (signal?.aborted && pendingLease) { + await pendingLease.release(true); + pendingLease = undefined; + } else if (pendingLease && leasedPending && leasedPending.state.waiters > 1) { + await pendingLease.release(false); + pendingLease = undefined; + } else { + forgetCachedChromeMcpSessionIfCurrent(cacheKey, session); + if (leasedPending) { + forgetPendingChromeMcpSessionIfCurrent(cacheKey, leasedPending); + } + if (pendingLease) { + await pendingLease.release(true); + pendingLease = undefined; + } else { + await closeChromeMcpSessionHandle(session); + } + } + throw err; + } finally { + await pendingLease?.release(false); } - throw err; } } @@ -881,41 +1044,65 @@ async function getExistingSession( profileName: string, timeoutMs?: number, signal?: AbortSignal, + includePending = true, ): Promise { + if (!includePending && pendingSessions.has(cacheKey)) { + return null; + } + let session = sessions.get(cacheKey); if (session && session.transport.pid === null) { sessions.delete(cacheKey); session = undefined; } + + const pending = pendingSessions.get(cacheKey); + if (includePending && pending) { + const pendingLease = await waitForSharedPendingChromeMcpSession(pending, signal); + let pendingLeaseReleased = false; + session = pendingLease.session; + try { + await waitForChromeMcpReady(session, profileName, timeoutMs, signal); + if (session.transport.pid === null) { + forgetCachedChromeMcpSessionIfCurrent(cacheKey, session); + forgetPendingChromeMcpSessionIfCurrent(cacheKey, pending); + await pendingLease.release(true); + pendingLeaseReleased = true; + return null; + } + return session; + } catch (err) { + if (signal?.aborted) { + await pendingLease.release(true); + pendingLeaseReleased = true; + } else if (pending.state.waiters > 1) { + await pendingLease.release(false); + pendingLeaseReleased = true; + } else { + forgetCachedChromeMcpSessionIfCurrent(cacheKey, session); + forgetPendingChromeMcpSessionIfCurrent(cacheKey, pending); + await pendingLease.release(true); + pendingLeaseReleased = true; + } + throw err; + } finally { + if (!pendingLeaseReleased) { + await pendingLease.release(false); + } + } + } + if (session) { try { await waitForChromeMcpReady(session, profileName, timeoutMs, signal); return session; } catch (err) { - const current = sessions.get(cacheKey); - if (current?.transport === session.transport) { - sessions.delete(cacheKey); - } + forgetCachedChromeMcpSessionIfCurrent(cacheKey, session); throw err; } } - const pending = pendingSessions.get(cacheKey); - if (!pending) { - return null; - } - - session = await waitForChromeMcpPendingSession(pending, signal); - try { - await waitForChromeMcpReady(session, profileName, timeoutMs, signal); - return session; - } catch (err) { - const current = sessions.get(cacheKey); - if (current?.transport === session.transport) { - sessions.delete(cacheKey); - } - throw err; - } + return null; } async function createEphemeralSession( @@ -962,6 +1149,7 @@ async function leaseSession( profileName, options.timeoutMs, options.signal, + false, ); if (existingSession) { return { @@ -1539,6 +1727,9 @@ export function setChromeMcpProcessCleanupDepsForTest( export async function resetChromeMcpSessionsForTest(): Promise { sessionFactory = null; + for (const pending of pendingSessions.values()) { + abortPendingChromeMcpSession(pending, new Error("Chrome MCP sessions reset for test")); + } pendingSessions.clear(); await stopAllChromeMcpSessions(); chromeMcpProcessCleanupDepsForTest = null; diff --git a/extensions/microsoft-foundry/provider.ts b/extensions/microsoft-foundry/provider.ts index 4b13f5048e3..ca497446e3f 100644 --- a/extensions/microsoft-foundry/provider.ts +++ b/extensions/microsoft-foundry/provider.ts @@ -58,7 +58,7 @@ export function buildMicrosoftFoundryProvider(): ProviderPlugin { const nextModel = Object.assign({}, model, { name: selectedModelCapabilities.modelName, api: selectedModelCapabilities.api, - reasoning: selectedModelCapabilities.reasoning || model.reasoning === true, + reasoning: selectedModelCapabilities.reasoning || model.reasoning, thinkingLevelMap: selectedModelCapabilities.thinkingLevelMap ?? model.thinkingLevelMap, input: selectedModelCapabilities.input, }); @@ -69,7 +69,7 @@ export function buildMicrosoftFoundryProvider(): ProviderPlugin { : undefined; const preserveExplicitReasoningEffort = !selectedModelCapabilities.reasoning && - model.reasoning === true && + model.reasoning && explicitSupportsReasoningEffort !== false; const explicitMaxTokensField = typeof model.compat?.maxTokensField === "string" @@ -78,13 +78,13 @@ export function buildMicrosoftFoundryProvider(): ProviderPlugin { ? "max_completion_tokens" : undefined; nextModel.compat = { - ...(model.compat ?? {}), + ...model.compat, ...selectedModelCapabilities.compat, ...(explicitSupportsReasoningEffort !== undefined ? { supportsReasoningEffort: explicitSupportsReasoningEffort } : preserveExplicitReasoningEffort ? { supportsReasoningEffort: true } - : {}), + : undefined), ...(explicitMaxTokensField ? { maxTokensField: explicitMaxTokensField } : {}), }; } @@ -138,7 +138,7 @@ export function buildMicrosoftFoundryProvider(): ProviderPlugin { typeof model.compat?.supportsReasoningEffort === "boolean" ? model.compat.supportsReasoningEffort : undefined; - const preserveExplicitReasoningEffort = !capabilities.reasoning && model.reasoning === true; + const preserveExplicitReasoningEffort = !capabilities.reasoning && model.reasoning; const explicitMaxTokensField = typeof model.compat?.maxTokensField === "string" ? model.compat.maxTokensField @@ -147,13 +147,13 @@ export function buildMicrosoftFoundryProvider(): ProviderPlugin { : undefined; const compat = capabilities.compat ? { - ...(model.compat ?? {}), + ...model.compat, ...capabilities.compat, ...(explicitSupportsReasoningEffort !== undefined ? { supportsReasoningEffort: explicitSupportsReasoningEffort } : preserveExplicitReasoningEffort ? { supportsReasoningEffort: true } - : {}), + : undefined), ...(explicitMaxTokensField ? { maxTokensField: explicitMaxTokensField } : {}), } : undefined; @@ -161,7 +161,7 @@ export function buildMicrosoftFoundryProvider(): ProviderPlugin { ...model, name: capabilities.modelName, api: capabilities.api, - reasoning: capabilities.reasoning || model.reasoning === true, + reasoning: capabilities.reasoning || model.reasoning, thinkingLevelMap: capabilities.thinkingLevelMap ?? model.thinkingLevelMap, input: capabilities.input, baseUrl: buildFoundryProviderBaseUrl(