mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-03 16:54:05 +00:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user