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 <steipete@gmail.com>
This commit is contained in:
Rohit
2026-06-01 07:25:38 +05:30
committed by GitHub
parent 2b184ac3a0
commit 3fc485ca92
3 changed files with 789 additions and 69 deletions

View File

@@ -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<void>((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<void>((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<ReturnType<typeof vi.fn>> = [];
const factory: ChromeMcpSessionFactory = async () => {
factoryCalls += 1;
let releaseFactory: (() => void) | undefined;
const factoryGate = new Promise<void>((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<void>((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<void>((resolve) => {
releaseFirstClose = resolve;
});
if (!releaseFirstClose) {
throw new Error("Expected Chrome MCP close release callback to be initialized");
}
const closeMocks: Array<ReturnType<typeof vi.fn>> = [];
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<void>(() => {});
}
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<void>((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<void>((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<ReturnType<typeof vi.fn>> = [];
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<ReturnType<typeof vi.fn>> = [];
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<void>((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<ReturnType<typeof vi.fn>> = [];
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<void>((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<ReturnType<typeof vi.fn>> = [];
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<void>((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<ReturnType<typeof vi.fn>> = [];
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<ReturnType<typeof vi.fn>> = [];
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<void>(() => {});
}
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 () => {

View File

@@ -80,6 +80,22 @@ type ChromeMcpSessionFactory = (
options?: NormalizedChromeMcpProfileOptions,
) => Promise<ChromeMcpSession>;
type PendingChromeMcpSession = {
cacheKey: string;
id: symbol;
promise: Promise<ChromeMcpSession>;
abortController: AbortController;
state: {
waiters: number;
settled: boolean;
};
};
type PendingChromeMcpSessionLease = {
session: ChromeMcpSession;
release: (closeIfLastWaiter: boolean) => Promise<boolean>;
};
export type ChromeMcpProcessInfo = {
pid: number;
ppid: number;
@@ -123,7 +139,7 @@ const STALE_SELECTED_PAGE_ERROR =
const execFileAsync = promisify(execFile);
const sessions = new Map<string, ChromeMcpSession>();
const pendingSessions = new Map<string, Promise<ChromeMcpSession>>();
const pendingSessions = new Map<string, PendingChromeMcpSession>();
let sessionFactory: ChromeMcpSessionFactory | null = null;
let chromeMcpProcessCleanupDepsForTest: ChromeMcpProcessCleanupDeps | null = null;
@@ -362,9 +378,10 @@ async function closeChromeMcpSessionsForProfile(
): Promise<boolean> {
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<T>(task: Promise<T>): Promise<T> {
return await Promise.race([
task,
new Promise<never>((_, 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<ChromeMcpSession> {
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<PendingChromeMcpSessionLease> {
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<ChromeMcpSession | null> {
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<void> {
sessionFactory = null;
for (const pending of pendingSessions.values()) {
abortPendingChromeMcpSession(pending, new Error("Chrome MCP sessions reset for test"));
}
pendingSessions.clear();
await stopAllChromeMcpSessions();
chromeMcpProcessCleanupDepsForTest = null;

View File

@@ -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(