fix(browser): close tracked tabs on session cleanup (#36666)

This commit is contained in:
Vignesh Natarajan
2026-03-05 16:36:29 -08:00
parent 6dfd39c32f
commit 06a229f98f
8 changed files with 412 additions and 1 deletions

View File

@@ -0,0 +1,114 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__countTrackedSessionBrowserTabsForTests,
__resetTrackedSessionBrowserTabsForTests,
closeTrackedBrowserTabsForSessions,
trackSessionBrowserTab,
untrackSessionBrowserTab,
} from "./session-tab-registry.js";
describe("session tab registry", () => {
beforeEach(() => {
__resetTrackedSessionBrowserTabsForTests();
});
afterEach(() => {
__resetTrackedSessionBrowserTabsForTests();
});
it("tracks and closes tabs for normalized session keys", async () => {
trackSessionBrowserTab({
sessionKey: "Agent:Main:Main",
targetId: "tab-a",
baseUrl: "http://127.0.0.1:9222",
profile: "OpenClaw",
});
trackSessionBrowserTab({
sessionKey: "agent:main:main",
targetId: "tab-b",
baseUrl: "http://127.0.0.1:9222",
profile: "OpenClaw",
});
expect(__countTrackedSessionBrowserTabsForTests("agent:main:main")).toBe(2);
const closeTab = vi.fn(async () => {});
const closed = await closeTrackedBrowserTabsForSessions({
sessionKeys: ["agent:main:main"],
closeTab,
});
expect(closed).toBe(2);
expect(closeTab).toHaveBeenCalledTimes(2);
expect(closeTab).toHaveBeenNthCalledWith(1, {
targetId: "tab-a",
baseUrl: "http://127.0.0.1:9222",
profile: "openclaw",
});
expect(closeTab).toHaveBeenNthCalledWith(2, {
targetId: "tab-b",
baseUrl: "http://127.0.0.1:9222",
profile: "openclaw",
});
expect(__countTrackedSessionBrowserTabsForTests()).toBe(0);
});
it("untracks specific tabs", async () => {
trackSessionBrowserTab({
sessionKey: "agent:main:main",
targetId: "tab-a",
});
trackSessionBrowserTab({
sessionKey: "agent:main:main",
targetId: "tab-b",
});
untrackSessionBrowserTab({
sessionKey: "agent:main:main",
targetId: "tab-a",
});
const closeTab = vi.fn(async () => {});
const closed = await closeTrackedBrowserTabsForSessions({
sessionKeys: ["agent:main:main"],
closeTab,
});
expect(closed).toBe(1);
expect(closeTab).toHaveBeenCalledTimes(1);
expect(closeTab).toHaveBeenCalledWith({
targetId: "tab-b",
baseUrl: undefined,
profile: undefined,
});
});
it("deduplicates tabs and ignores expected close errors", async () => {
trackSessionBrowserTab({
sessionKey: "agent:main:main",
targetId: "tab-a",
});
trackSessionBrowserTab({
sessionKey: "main",
targetId: "tab-a",
});
trackSessionBrowserTab({
sessionKey: "main",
targetId: "tab-b",
});
const warnings: string[] = [];
const closeTab = vi
.fn()
.mockRejectedValueOnce(new Error("target not found"))
.mockRejectedValueOnce(new Error("network down"));
const closed = await closeTrackedBrowserTabsForSessions({
sessionKeys: ["agent:main:main", "main"],
closeTab,
onWarn: (message) => warnings.push(message),
});
expect(closed).toBe(0);
expect(closeTab).toHaveBeenCalledTimes(2);
expect(warnings).toEqual([expect.stringContaining("network down")]);
expect(__countTrackedSessionBrowserTabsForTests()).toBe(0);
});
});

View File

@@ -0,0 +1,189 @@
import { browserCloseTab } from "./client.js";
export type TrackedSessionBrowserTab = {
sessionKey: string;
targetId: string;
baseUrl?: string;
profile?: string;
trackedAt: number;
};
const trackedTabsBySession = new Map<string, Map<string, TrackedSessionBrowserTab>>();
function normalizeSessionKey(raw: string): string {
return raw.trim().toLowerCase();
}
function normalizeTargetId(raw: string): string {
return raw.trim();
}
function normalizeProfile(raw?: string): string | undefined {
if (!raw) {
return undefined;
}
const trimmed = raw.trim();
return trimmed ? trimmed.toLowerCase() : undefined;
}
function normalizeBaseUrl(raw?: string): string | undefined {
if (!raw) {
return undefined;
}
const trimmed = raw.trim();
return trimmed ? trimmed : undefined;
}
function toTrackedTabId(params: { targetId: string; baseUrl?: string; profile?: string }): string {
return `${params.targetId}\u0000${params.baseUrl ?? ""}\u0000${params.profile ?? ""}`;
}
function isIgnorableCloseError(err: unknown): boolean {
const message = String(err).toLowerCase();
return (
message.includes("tab not found") ||
message.includes("target closed") ||
message.includes("target not found") ||
message.includes("no such target")
);
}
export function trackSessionBrowserTab(params: {
sessionKey?: string;
targetId?: string;
baseUrl?: string;
profile?: string;
}): void {
const sessionKeyRaw = params.sessionKey?.trim();
const targetIdRaw = params.targetId?.trim();
if (!sessionKeyRaw || !targetIdRaw) {
return;
}
const sessionKey = normalizeSessionKey(sessionKeyRaw);
const targetId = normalizeTargetId(targetIdRaw);
const baseUrl = normalizeBaseUrl(params.baseUrl);
const profile = normalizeProfile(params.profile);
const tracked: TrackedSessionBrowserTab = {
sessionKey,
targetId,
baseUrl,
profile,
trackedAt: Date.now(),
};
const trackedId = toTrackedTabId(tracked);
let trackedForSession = trackedTabsBySession.get(sessionKey);
if (!trackedForSession) {
trackedForSession = new Map();
trackedTabsBySession.set(sessionKey, trackedForSession);
}
trackedForSession.set(trackedId, tracked);
}
export function untrackSessionBrowserTab(params: {
sessionKey?: string;
targetId?: string;
baseUrl?: string;
profile?: string;
}): void {
const sessionKeyRaw = params.sessionKey?.trim();
const targetIdRaw = params.targetId?.trim();
if (!sessionKeyRaw || !targetIdRaw) {
return;
}
const sessionKey = normalizeSessionKey(sessionKeyRaw);
const trackedForSession = trackedTabsBySession.get(sessionKey);
if (!trackedForSession) {
return;
}
const trackedId = toTrackedTabId({
targetId: normalizeTargetId(targetIdRaw),
baseUrl: normalizeBaseUrl(params.baseUrl),
profile: normalizeProfile(params.profile),
});
trackedForSession.delete(trackedId);
if (trackedForSession.size === 0) {
trackedTabsBySession.delete(sessionKey);
}
}
function takeTrackedTabsForSessionKeys(
sessionKeys: Array<string | undefined>,
): TrackedSessionBrowserTab[] {
const uniqueSessionKeys = new Set<string>();
for (const key of sessionKeys) {
if (!key?.trim()) {
continue;
}
uniqueSessionKeys.add(normalizeSessionKey(key));
}
if (uniqueSessionKeys.size === 0) {
return [];
}
const seenTrackedIds = new Set<string>();
const tabs: TrackedSessionBrowserTab[] = [];
for (const sessionKey of uniqueSessionKeys) {
const trackedForSession = trackedTabsBySession.get(sessionKey);
if (!trackedForSession || trackedForSession.size === 0) {
continue;
}
trackedTabsBySession.delete(sessionKey);
for (const tracked of trackedForSession.values()) {
const trackedId = toTrackedTabId(tracked);
if (seenTrackedIds.has(trackedId)) {
continue;
}
seenTrackedIds.add(trackedId);
tabs.push(tracked);
}
}
return tabs;
}
export async function closeTrackedBrowserTabsForSessions(params: {
sessionKeys: Array<string | undefined>;
closeTab?: (tab: { targetId: string; baseUrl?: string; profile?: string }) => Promise<void>;
onWarn?: (message: string) => void;
}): Promise<number> {
const tabs = takeTrackedTabsForSessionKeys(params.sessionKeys);
if (tabs.length === 0) {
return 0;
}
const closeTab =
params.closeTab ??
(async (tab: { targetId: string; baseUrl?: string; profile?: string }) => {
await browserCloseTab(tab.baseUrl, tab.targetId, {
profile: tab.profile,
});
});
let closed = 0;
for (const tab of tabs) {
try {
await closeTab({
targetId: tab.targetId,
baseUrl: tab.baseUrl,
profile: tab.profile,
});
closed += 1;
} catch (err) {
if (!isIgnorableCloseError(err)) {
params.onWarn?.(`failed to close tracked browser tab ${tab.targetId}: ${String(err)}`);
}
}
}
return closed;
}
export function __resetTrackedSessionBrowserTabsForTests(): void {
trackedTabsBySession.clear();
}
export function __countTrackedSessionBrowserTabsForTests(sessionKey?: string): number {
if (typeof sessionKey === "string" && sessionKey.trim()) {
return trackedTabsBySession.get(normalizeSessionKey(sessionKey))?.size ?? 0;
}
let count = 0;
for (const tracked of trackedTabsBySession.values()) {
count += tracked.size;
}
return count;
}