fix(browser): cap managed profile tabs to prevent renderer buildup

This commit is contained in:
pandego
2026-02-28 12:49:48 +01:00
committed by Peter Steinberger
parent 050e928985
commit b47dc73b70
2 changed files with 118 additions and 0 deletions

View File

@@ -354,6 +354,99 @@ describe("browser server-context tab selection state", () => {
});
});
it("closes excess managed tabs after opening a new tab", async () => {
vi.spyOn(cdpModule, "createTargetViaCdp").mockResolvedValue({ targetId: "NEW" });
const existingTabs = [
{
id: "OLD1",
title: "1",
url: "http://127.0.0.1:3001",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD1",
type: "page",
},
{
id: "OLD2",
title: "2",
url: "http://127.0.0.1:3002",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD2",
type: "page",
},
{
id: "OLD3",
title: "3",
url: "http://127.0.0.1:3003",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD3",
type: "page",
},
{
id: "OLD4",
title: "4",
url: "http://127.0.0.1:3004",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD4",
type: "page",
},
{
id: "OLD5",
title: "5",
url: "http://127.0.0.1:3005",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD5",
type: "page",
},
{
id: "OLD6",
title: "6",
url: "http://127.0.0.1:3006",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD6",
type: "page",
},
{
id: "OLD7",
title: "7",
url: "http://127.0.0.1:3007",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD7",
type: "page",
},
{
id: "OLD8",
title: "8",
url: "http://127.0.0.1:3008",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/OLD8",
type: "page",
},
{
id: "NEW",
title: "9",
url: "http://127.0.0.1:3009",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/NEW",
type: "page",
},
];
const fetchMock = vi.fn(async (url: unknown) => {
const value = String(url);
if (value.includes("/json/list")) {
return { ok: true, json: async () => existingTabs } as unknown as Response;
}
if (value.includes("/json/close/OLD1")) {
return { ok: true, json: async () => ({}) } as unknown as Response;
}
throw new Error(`unexpected fetch: ${value}`);
});
global.fetch = withFetchPreconnect(fetchMock);
const state = makeState("openclaw");
const ctx = createBrowserRouteContext({ getState: () => state });
const openclaw = ctx.forProfile("openclaw");
const opened = await openclaw.openTab("http://127.0.0.1:3009");
expect(opened.targetId).toBe("NEW");
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining("/json/close/OLD1"),
expect.any(Object),
);
});
it("blocks unsupported non-network URLs before any HTTP tab-open fallback", async () => {
const fetchMock = vi.fn(async () => {
throw new Error("unexpected fetch");

View File

@@ -56,6 +56,8 @@ export function listKnownProfileNames(state: BrowserServerState): string[] {
return [...names];
}
const MAX_MANAGED_BROWSER_PAGE_TABS = 8;
/**
* Normalize a CDP WebSocket URL to use the correct base URL.
*/
@@ -136,6 +138,25 @@ function createProfileContext(
.filter((t) => Boolean(t.targetId));
};
const enforceManagedTabLimit = async (keepTargetId: string): Promise<void> => {
if (profile.driver !== "openclaw") {
return;
}
const pageTabs = (await listTabs()).filter((tab) => (tab.type ?? "page") === "page");
if (pageTabs.length <= MAX_MANAGED_BROWSER_PAGE_TABS) {
return;
}
const candidates = pageTabs.filter((tab) => tab.targetId !== keepTargetId);
const excessCount = pageTabs.length - MAX_MANAGED_BROWSER_PAGE_TABS;
for (const tab of candidates.slice(0, excessCount)) {
await fetchOk(appendCdpPath(profile.cdpUrl, `/json/close/${tab.targetId}`)).catch(() => {
// best-effort cleanup only
});
}
};
const openTab = async (url: string): Promise<BrowserTab> => {
const ssrfPolicyOpts = withBrowserNavigationPolicy(state().resolved.ssrfPolicy);
@@ -152,6 +173,7 @@ function createProfileContext(
});
const profileState = getProfileState();
profileState.lastTargetId = page.targetId;
await enforceManagedTabLimit(page.targetId);
return {
targetId: page.targetId,
title: page.title,
@@ -178,10 +200,12 @@ function createProfileContext(
const found = tabs.find((t) => t.targetId === createdViaCdp);
if (found) {
await assertBrowserNavigationResultAllowed({ url: found.url, ...ssrfPolicyOpts });
await enforceManagedTabLimit(found.targetId);
return found;
}
await new Promise((r) => setTimeout(r, 100));
}
await enforceManagedTabLimit(createdViaCdp);
return { targetId: createdViaCdp, title: "", url, type: "page" };
}
@@ -218,6 +242,7 @@ function createProfileContext(
profileState.lastTargetId = created.id;
const resolvedUrl = created.url ?? url;
await assertBrowserNavigationResultAllowed({ url: resolvedUrl, ...ssrfPolicyOpts });
await enforceManagedTabLimit(created.id);
return {
targetId: created.id,
title: created.title ?? "",