aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTianren Dong <tianrendong.dtr@gmail.com>2026-09-09 22:54:22 -0400
committerTianren Dong <tianrendong.dtr@gmail.com>2026-09-09 23:14:15 -0400
commit1ec29fc90b84517322bf5994245368f0e59eff61 (patch)
tree5a8ba52d06a1c96d93540cfe1b34f699cd447c68
parent017ff4b9a639f0b8b213e58a3f30613fc38edcc8 (diff)
Fix session cleanup and release 0.15.47
-rw-r--r--CHANGELOG.md6
-rw-r--r--CONTRIBUTING.md4
-rw-r--r--docs/ARCHITECTURE.md5
-rw-r--r--extensions/chrome-profile-bridge/browser-extension/manifest.json2
-rw-r--r--extensions/chrome-profile-bridge/browser-extension/service_worker.js160
-rw-r--r--extensions/chrome-profile-bridge/index.ts39
-rw-r--r--package-lock.json4
-rw-r--r--package.json4
-rw-r--r--test-suite/unit/automation-target.test.mjs109
-rw-r--r--test-suite/unit/session-cleanup.test.mjs93
10 files changed, 365 insertions, 61 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7228c67..cca1965 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,12 @@
All notable user-facing changes to `pi-chrome`.
+## 0.15.47 — 2026-09-09
+
+- **Bounded session cleanup.** On exit, Pi waits up to two seconds for cleanup before stopping the bridge. Reload preserves browser resources; revoke remains non-blocking.
+- **Track every created tab.** Cleanup closes session-created tabs and ungroups adopted user tabs only if they remain in the group Pi assigned. Ownership survives service-worker restarts; failed removals remain tracked for retry.
+- **Mixed-window safety.** Cleanup removes individual owned tabs, never whole windows. User tabs moved into a Pi window, and other sessions' tabs sharing that window, remain open.
+
## 0.15.40 — 2026-06-22
- **Automation targets reuse the session tab group.** When `chrome_navigate` / implicit page actions create a new pi-chrome automation tab, it is now created in this session's existing tab-group window when possible and joins that same group, avoiding duplicate same-title `Pi Session: ...` groups.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 3b8ff0f..3a747ab 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -15,6 +15,10 @@ Thanks for considering a contribution. pi-chrome aims to be the **de-facto brows
# Link from a checkout
pi install ./pi-chrome
+# Run unit regressions (Node.js 22.13+; no live Chrome required)
+# Lifecycle tests use Node's built-in TypeScript stripping.
+npm test
+
# Run the benchmark dashboard
cd test-suite
python3 -m http.server 8765
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 4b2a561..2e21499 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -30,7 +30,10 @@ Each Pi session owns its own automation target:
- If separate window cannot be created, pi-chrome falls back to dedicated tab.
- Target survives `/reload` and Chrome service-worker restarts.
- Ownership is tracked by id and mirrored to `chrome.storage.session`.
-- Cleanup closes only calling session's own target, never user tabs/windows or other sessions' targets.
+- Cleanup closes calling session's automation target and every tab it created through `tab.new`.
+- Existing user tabs adopted into a session group are preserved, and ungrouped only if still in that group.
+- Cleanup removes individual owned tabs, never whole windows. User/other-session tabs moved into a Pi window remain open; Chrome closes a window automatically when its final tab is removed.
+- Shutdown waits at most two seconds for cleanup before stopping the bridge. `/reload` preserves resources; revoke starts cleanup without blocking. Hard process termination or unavailable Chrome can still leave owned tabs open.
To point pi-chrome at an existing tab, pass `targetId`, `urlIncludes`, or `titleIncludes`.
diff --git a/extensions/chrome-profile-bridge/browser-extension/manifest.json b/extensions/chrome-profile-bridge/browser-extension/manifest.json
index e5fbe61..4fe5b17 100644
--- a/extensions/chrome-profile-bridge/browser-extension/manifest.json
+++ b/extensions/chrome-profile-bridge/browser-extension/manifest.json
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Pi Chrome Connector",
- "version": "0.15.46",
+ "version": "0.15.47",
"description": "Lets Pi control tabs in Chrome via a local connector at 127.0.0.1.",
"permissions": [
"tabs",
diff --git a/extensions/chrome-profile-bridge/browser-extension/service_worker.js b/extensions/chrome-profile-bridge/browser-extension/service_worker.js
index e04eafd..dd0567a 100644
--- a/extensions/chrome-profile-bridge/browser-extension/service_worker.js
+++ b/extensions/chrome-profile-bridge/browser-extension/service_worker.js
@@ -29,7 +29,11 @@ let polling = false;
const automationTargets = new Map(); // sessionKey -> { windowId?: number, tabId: number }
const DEFAULT_SESSION_KEY = "__default__";
const AUTOMATION_STORAGE_KEY = "piChromeAutomationTargets";
-let automationHydrated = false;
+let automationHydrated;
+const sessionTabs = new Map(); // sessionKey -> Map<tabId, { created: boolean, groupId?: number }>
+const SESSION_TABS_STORAGE_KEY = "piChromeSessionTabs";
+let sessionTabsReady;
+let sessionTabsWrite = Promise.resolve();
function sessionKeyOf(params) {
return params && typeof params.sessionKey === "string" && params.sessionKey
@@ -41,24 +45,99 @@ function sessionKeyOf(params) {
// effort: storage may be unavailable on old Chrome, and a failure just means we may create a
// fresh window (a harmless orphan) rather than reusing one.
async function hydrateAutomationTargets() {
- if (automationHydrated) return;
- automationHydrated = true;
- try {
- const stored = await chrome.storage?.session?.get?.(AUTOMATION_STORAGE_KEY);
- const saved = stored && stored[AUTOMATION_STORAGE_KEY];
- if (saved && typeof saved === "object") {
- for (const [key, value] of Object.entries(saved)) {
- if (value && typeof value.tabId === "number") {
- automationTargets.set(key, {
- windowId: typeof value.windowId === "number" ? value.windowId : undefined,
- tabId: value.tabId,
- });
+ if (automationHydrated) return automationHydrated;
+ automationHydrated = (async () => {
+ try {
+ const stored = await chrome.storage?.session?.get?.(AUTOMATION_STORAGE_KEY);
+ const saved = stored && stored[AUTOMATION_STORAGE_KEY];
+ if (saved && typeof saved === "object") {
+ for (const [key, value] of Object.entries(saved)) {
+ if (value && typeof value.tabId === "number") {
+ automationTargets.set(key, {
+ windowId: typeof value.windowId === "number" ? value.windowId : undefined,
+ tabId: value.tabId,
+ });
+ }
}
}
+ } catch {
+ // Ignore: treat as "no persisted state".
+ }
+ })();
+ return automationHydrated;
+}
+
+async function hydrateSessionTabs() {
+ if (!sessionTabsReady) sessionTabsReady = (async () => {
+ try {
+ const stored = await chrome.storage?.session?.get?.(SESSION_TABS_STORAGE_KEY);
+ for (const [key, entries] of Object.entries(stored?.[SESSION_TABS_STORAGE_KEY] || {})) {
+ if (!Array.isArray(entries)) continue;
+ const tabs = new Map();
+ for (const entry of entries) {
+ if (!entry || !Number.isInteger(entry.tabId) || entry.tabId < 0) continue;
+ if (entry.created === true) tabs.set(entry.tabId, { created: true });
+ else if (entry.created === false && Number.isInteger(entry.groupId) && entry.groupId >= 0) {
+ tabs.set(entry.tabId, { created: false, groupId: entry.groupId });
+ }
+ }
+ if (tabs.size) sessionTabs.set(key, tabs);
+ }
+ } catch {
+ // Missing ownership must leave tabs alone, not guess ownership from group names.
+ }
+ })();
+ return sessionTabsReady;
+}
+
+function persistSessionTabs() {
+ // Serialize writes and construct each snapshot when its turn starts.
+ sessionTabsWrite = sessionTabsWrite.then(async () => {
+ const saved = Object.fromEntries([...sessionTabs].map(([key, tabs]) => [
+ key, [...tabs].map(([tabId, record]) => ({ tabId, ...record })),
+ ]));
+ await chrome.storage?.session?.set?.({ [SESSION_TABS_STORAGE_KEY]: saved });
+ }).catch(() => {});
+ return sessionTabsWrite;
+}
+
+async function trackSessionTab(sessionKey, tabId, created, groupId) {
+ await Promise.all([hydrateSessionTabs(), hydrateAutomationTargets()]);
+ if (!Number.isInteger(tabId)) return;
+ if (!created) {
+ if (!Number.isInteger(groupId) || groupId < 0 || isPiChromeOwnedTarget(tabId)) return;
+ if ([...sessionTabs.values()].some((tabs) => tabs.get(tabId)?.created)) return;
+ }
+ let tabs = sessionTabs.get(sessionKey);
+ if (!tabs) sessionTabs.set(sessionKey, tabs = new Map());
+ tabs.set(tabId, created ? { created: true } : { created: false, groupId });
+ await persistSessionTabs();
+}
+
+async function cleanupSessionTabs(sessionKey) {
+ await hydrateSessionTabs();
+ const automation = await cleanupAutomationTarget(sessionKey);
+ const tabs = sessionTabs.get(sessionKey);
+ let closedCreatedTabs = 0;
+ let ungroupedAdoptedTabs = 0;
+ for (const [tabId, record] of [...(tabs || [])]) {
+ try {
+ const tab = await chrome.tabs.get(tabId).catch(() => null);
+ if (tab && record.created) {
+ await chrome.tabs.remove(tabId);
+ closedCreatedTabs++;
+ } else if (tab && tab.groupId === record.groupId) {
+ await chrome.tabs.ungroup(tabId);
+ ungroupedAdoptedTabs++;
+ }
+ tabs.delete(tabId);
+ } catch {
+ // Retain failed operations for a later cleanup; never report them as completed.
}
- } catch {
- // Ignore: treat as "no persisted state".
}
+ if (tabs && !tabs.size) sessionTabs.delete(sessionKey);
+ await persistSessionTabs();
+ return { ...automation, closedCreatedTabs, ungroupedAdoptedTabs };
}
async function persistAutomationTargets() {
@@ -144,25 +223,26 @@ async function getOrCreateAutomationTarget(sessionKey, groupTitle) {
async function cleanupAutomationTarget(sessionKey) {
await hydrateAutomationTargets();
const t = automationTargets.get(sessionKey);
- automationTargets.delete(sessionKey);
- await persistAutomationTargets();
- if (!t) return { closedWindowId: null, closedTabId: null };
- const { windowId, tabId } = t;
- if (typeof windowId === "number" && chrome.windows && typeof chrome.windows.remove === "function") {
- const win = await chrome.windows.get(windowId).catch(() => null);
- if (win) {
- await chrome.windows.remove(windowId).catch(() => {});
- return { closedWindowId: windowId, closedTabId: typeof tabId === "number" ? tabId : null };
+ const result = { closedWindowId: null, closedTabId: null };
+ if (!t) return result;
+ const tab = await chrome.tabs.get(t.tabId).catch(() => null);
+ if (tab) {
+ try {
+ // Never remove a whole window: users/other sessions can add tabs even between a
+ // contents check and removal. Chrome closes empty windows when their last tab closes.
+ await chrome.tabs.remove(t.tabId);
+ result.closedTabId = t.tabId;
+ } catch {
+ return result; // Keep ownership so cleanup can retry.
}
- }
- if (typeof tabId === "number") {
- const tab = await chrome.tabs.get(tabId).catch(() => null);
- if (tab) {
- await chrome.tabs.remove(tabId).catch(() => {});
- return { closedWindowId: null, closedTabId: tabId };
+ if (tab.windowId === t.windowId && typeof chrome.windows?.get === "function") {
+ const remaining = await chrome.windows.get(t.windowId).catch(() => null);
+ if (!remaining) result.closedWindowId = t.windowId;
}
}
- return { closedWindowId: null, closedTabId: null };
+ automationTargets.delete(sessionKey);
+ await persistAutomationTargets();
+ return result;
}
function withTimeout(promise, ms, label, onTimeout) {
@@ -1142,6 +1222,7 @@ async function dispatch(action, params) {
const createParams = { url: params.url || "about:blank", active: true };
if (existingGroup && typeof existingGroup.windowId === "number") createParams.windowId = existingGroup.windowId;
const tab = await chrome.tabs.create(createParams);
+ await trackSessionTab(sessionKeyOf(params), tab.id, true);
try {
return await groupTab(tab, groupTitle, params.groupColor);
} catch (error) {
@@ -1159,7 +1240,9 @@ async function dispatch(action, params) {
}
case "tab.group": {
const tab = await getTabByParams(params, { createOwnedTarget: false });
- return groupTab(tab, params.groupTitle || "Pi", params.groupColor);
+ const grouped = await groupTab(tab, params.groupTitle || "Pi", params.groupColor);
+ if (!(tab.groupId >= 0)) await trackSessionTab(sessionKeyOf(params), tab.id, false, grouped.group?.id);
+ return grouped;
}
case "tab.ungroup": {
const tab = await getTabByParams(params, { createOwnedTarget: false });
@@ -1256,9 +1339,9 @@ async function dispatch(action, params) {
return { windowId: t?.windowId ?? null, tabId: t?.tabId ?? null };
}
case "automation.cleanup":
- // Close only THIS session's pi-chrome-owned window/tab. Never touches user tabs/windows or
- // another Pi session's target.
- return cleanupAutomationTarget(sessionKeyOf(params));
+ // Close recorded creations, and only ungroup user tabs still in their adopted group.
+ // Group titles are not ownership evidence.
+ return cleanupSessionTabs(sessionKeyOf(params));
default:
throw new Error(`Unknown action: ${action}`);
}
@@ -1345,18 +1428,19 @@ async function getTabByParams(params, { createOwnedTarget = true } = {}) {
// which tabs Pi is driving. We only adopt *ungrouped* tabs — never hijack a tab the user (or
// another Pi session) already grouped, since groupTab would otherwise rename that group.
if (params.joinSessionGroup && params.sessionGroupTitle) {
- await joinSessionGroup(tab, params.sessionGroupTitle);
+ await joinSessionGroup(tab, params.sessionGroupTitle, sessionKeyOf(params));
}
return tab;
}
// Add an ungrouped tab to the session's tab group (reusing it by title, else creating it).
// No-op when the tab is already grouped or tabGroups is unavailable.
-async function joinSessionGroup(tab, title) {
+async function joinSessionGroup(tab, title, sessionKey) {
if (!chrome.tabGroups || typeof tab.id !== "number") return;
if (typeof tab.groupId === "number" && tab.groupId >= 0) return;
try {
- await groupTab(tab, title);
+ const grouped = await groupTab(tab, title);
+ await trackSessionTab(sessionKey, tab.id, false, grouped.group?.id);
} catch {
// Grouping is best-effort; never block the actual page action on a grouping failure.
}
diff --git a/extensions/chrome-profile-bridge/index.ts b/extensions/chrome-profile-bridge/index.ts
index e3e5a04..fdfa39c 100644
--- a/extensions/chrome-profile-bridge/index.ts
+++ b/extensions/chrome-profile-bridge/index.ts
@@ -759,13 +759,25 @@ export default function (pi: ExtensionAPI): void {
}, { triggerTurn: false });
};
- // Close THIS session's dedicated automation window/tab. Fire-and-forget and best-effort: it
- // must never block /quit, /reload, revoke, or session end, and the service-worker side only
- // ever closes targets this session created itself (never user tabs/windows, never another
- // session's target). Errors (bridge down, target already closed) are intentionally swallowed.
- const cleanupAutomationTargetBestEffort = (): void => {
+ // Bound the entire request, including shared-owner forwarding/takeover. Revoke can launch
+ // this in the background; shutdown must wait before tearing down the bridge.
+ const cleanupAutomationTargetBestEffort = async (timeoutMs = 2_000): Promise<void> => {
const sessionKey = sessionKeyFor(sessionCtx);
- void bridge.send("automation.cleanup", sessionKey !== undefined ? { sessionKey } : {}, 3_000).catch(() => undefined);
+ if (sessionKey === undefined) return; // Never clean up an unscoped/default session.
+ const controller = new AbortController();
+ let timer: ReturnType<typeof setTimeout> | undefined;
+ try {
+ await Promise.race([
+ bridge.send("automation.cleanup", { sessionKey }, timeoutMs, controller.signal).catch(() => undefined),
+ new Promise<void>((resolveCleanup) => {
+ timer = setTimeout(() => { controller.abort(); resolveCleanup(); }, timeoutMs);
+ }),
+ ]);
+ } catch {
+ // Shutdown/revoke remains best-effort when Chrome or its bridge is unavailable.
+ } finally {
+ clearTimeout(timer);
+ }
};
const lockChromeControl = (logAction?: "revoked" | "expired"): void => {
@@ -778,7 +790,7 @@ export default function (pi: ExtensionAPI): void {
chromeAuthorizedUntil = undefined;
persistAuth();
// Revoking control ends pi-chrome's automation for this session; tidy up the target we own.
- cleanupAutomationTargetBestEffort();
+ void cleanupAutomationTargetBestEffort();
};
const authSummary = (): string => {
@@ -925,17 +937,12 @@ export default function (pi: ExtensionAPI): void {
updateChromeStatus(ctx);
});
- pi.on("session_shutdown", (event) => {
+ pi.on("session_shutdown", async (event) => {
clearAuthExpiryTimer();
clearCountdownInterval();
- // Tidy up this session's dedicated automation window on real session end, but NOT on
- // "reload": /reload tears down and re-evaluates this module while the *same* session
- // (same sessionKey) continues, so we keep the window so it is reused, not churned. The
- // call is fire-and-forget and runs before bridge.stop() so it never blocks shutdown.
- // (Owner-session quit may not deliver in time since stop() closes the bridge server;
- // that only ever leaves a clearly pi-chrome window for the user to close — never a user
- // tab — and /chrome revoke remains the reliable, bridge-alive cleanup path.)
- if (event?.reason !== "reload") cleanupAutomationTargetBestEffort();
+ // /reload continues the same session. On exit, give Chrome a bounded opportunity to
+ // close this session's created tabs and ungroup adopted tabs before stopping the broker.
+ if (event?.reason !== "reload") await cleanupAutomationTargetBestEffort();
bridge.stop();
if (globalState[PI_CHROME_GLOBAL_KEY]?.token === instanceToken) {
delete globalState[PI_CHROME_GLOBAL_KEY];
diff --git a/package-lock.json b/package-lock.json
index 9823197..3b570c2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "pi-chrome",
- "version": "0.15.46",
+ "version": "0.15.47",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pi-chrome",
- "version": "0.15.46",
+ "version": "0.15.47",
"license": "MIT",
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*",
diff --git a/package.json b/package.json
index 9da6d94..bd08334 100644
--- a/package.json
+++ b/package.json
@@ -1,8 +1,8 @@
{
"name": "pi-chrome",
- "version": "0.15.46",
+ "version": "0.15.47",
"scripts": {
- "test": "node test-suite/unit/csp-eval.test.mjs && node test-suite/unit/automation-target.test.mjs",
+ "test": "node test-suite/unit/csp-eval.test.mjs && node test-suite/unit/automation-target.test.mjs && node test-suite/unit/session-cleanup.test.mjs",
"version": "node scripts/sync-manifest-version.js",
"prepublishOnly": "node scripts/sync-manifest-version.js"
},
diff --git a/test-suite/unit/automation-target.test.mjs b/test-suite/unit/automation-target.test.mjs
index 8a6ea2b..f838fa6 100644
--- a/test-suite/unit/automation-target.test.mjs
+++ b/test-suite/unit/automation-target.test.mjs
@@ -81,7 +81,12 @@ function makeChrome(state, { withWindows = true, withStorage = true, withTabGrou
return { ...tab };
},
update: async (id, props = {}) => { const t = tabs.get(id); if (!t) throw new Error(`No tab with id ${id}`); Object.assign(t, props); return { ...t }; },
- remove: async (id) => { tabs.delete(id); },
+ remove: async (id) => {
+ const tab = tabs.get(id);
+ tabs.delete(id);
+ // Chrome closes a window automatically when its final tab is removed.
+ if (tab && ![...tabs.values()].some((other) => other.windowId === tab.windowId)) windows.delete(tab.windowId);
+ },
group: async ({ groupId, tabIds = [] } = {}) => {
let gid = groupId;
if (typeof gid !== "number") {
@@ -388,6 +393,108 @@ async function run() {
ok(stale.closedWindowId === null && stale.closedTabId === null, "cleanup: robust when owned window was already closed");
}
+ // Cleanup must never remove a window wholesale, even if tabs move during removal.
+ for (const moveOwnedTab of [false, true]) {
+ const state = makeChromeState();
+ const chrome = makeChrome(state);
+ chrome.windows.remove = async () => { throw new Error("whole-window removal is forbidden"); };
+ const w = loadWorker(chrome);
+ const owned = await w.getOrCreateAutomationTarget(SK);
+ const remove = chrome.tabs.remove;
+ chrome.tabs.remove = async (id) => {
+ // User adds a tab just as cleanup starts; a pre-removal window check is not enough.
+ state.userArticle.windowId = owned.windowId;
+ if (moveOwnedTab) state.tabs.get(owned.id).windowId = state.userWindowId;
+ await remove(id);
+ };
+ const result = await w.dispatch("automation.cleanup", { sessionKey: SK });
+ ok(result.closedTabId === owned.id && result.closedWindowId === null, "mixed window: only owned tab reported closed");
+ ok(state.tabs.has(state.userArticle.id) && state.windows.has(owned.windowId), "mixed window: user tab and its window survive");
+ ok(!state.tabs.has(owned.id), "mixed window: owned tab removed even after moving to another window");
+ }
+
+ // A populated automation window is not disposable merely because Pi created it.
+ {
+ const state = makeChromeState();
+ const w = loadWorker(makeChrome(state));
+ const owned = await w.getOrCreateAutomationTarget(SK);
+ state.userArticle.windowId = owned.windowId;
+ await w.dispatch("automation.cleanup", { sessionKey: SK });
+ ok(state.tabs.has(state.userArticle.id) && state.windows.has(owned.windowId), "populated window: user's moved-in tab survives cleanup");
+ }
+
+ // Created vs adopted ownership survives restart; matching titles do not grant ownership.
+ for (const restart of [false, true]) {
+ const state = makeChromeState();
+ const chrome = makeChrome(state, { withTabGroups: true });
+ let w = loadWorker(chrome);
+ const groupTitle = "Pi Session: shared-name";
+ await w.dispatch("page.navigate", {
+ sessionKey: SK, targetId: String(state.userGmail.id), url: "https://mail.google.com/",
+ waitUntilLoad: false, joinSessionGroup: true, sessionGroupTitle: groupTitle,
+ });
+ await w.dispatch("tab.group", { sessionKey: SK, targetId: String(state.userArticle.id), groupTitle });
+ const created = await w.dispatch("tab.new", { sessionKey: SK, groupTitle });
+ const other = await w.dispatch("tab.new", { sessionKey: "session:other", groupTitle });
+ // User changes the article's group after Pi adopted it. Cleanup must respect that change.
+ const replacementGroup = state.alloc.group();
+ state.userArticle.groupId = replacementGroup;
+ if (restart) w = loadWorker(makeChrome(state, { withTabGroups: true }));
+ const result = await w.dispatch("automation.cleanup", { sessionKey: SK });
+ ok(!state.tabs.has(created.tab.id) && state.tabs.has(other.tab.id), "resources: close only this session's created tabs");
+ ok(state.tabs.has(state.userGmail.id) && state.userGmail.groupId === -1, "resources: adopted user tab ungrouped, not closed");
+ ok(state.userArticle.groupId === replacementGroup, "resources: user's replacement group untouched");
+ ok(result.closedCreatedTabs === 1 && result.ungroupedAdoptedTabs === 1, "resources: counts reflect successful operations");
+ ok(!(SK in state.storage.piChromeSessionTabs), "resources: completed ownership removed from persistence");
+ const again = await w.dispatch("automation.cleanup", { sessionKey: SK });
+ ok(again.closedCreatedTabs === 0 && again.ungroupedAdoptedTabs === 0, "resources: repeated cleanup is idempotent");
+ }
+
+ // A failed close keeps ownership for retry, without claiming success.
+ {
+ const state = makeChromeState();
+ const chrome = makeChrome(state, { withTabGroups: true });
+ const w = loadWorker(chrome);
+ const owned = await w.getOrCreateAutomationTarget(SK);
+ const opened = await w.dispatch("tab.new", { sessionKey: SK });
+ const remove = chrome.tabs.remove;
+ chrome.tabs.remove = async () => { throw new Error("temporary close failure"); };
+ const failed = await w.dispatch("automation.cleanup", { sessionKey: SK });
+ ok(failed.closedCreatedTabs === 0 && failed.closedTabId === null, "retry: failed closes not reported as success");
+ chrome.tabs.remove = remove;
+ const restarted = loadWorker(chrome);
+ await restarted.dispatch("automation.cleanup", { sessionKey: SK });
+ ok(!state.tabs.has(owned.id) && !state.tabs.has(opened.tab.id), "retry: persisted ownership allows retry after restart");
+ }
+
+ // Failed ungrouping also remains retryable; a full browser restart abandons ownership.
+ {
+ const state = makeChromeState();
+ const chrome = makeChrome(state, { withTabGroups: true });
+ const w = loadWorker(chrome);
+ await w.dispatch("tab.group", { sessionKey: SK, targetId: String(state.userGmail.id) });
+ const ungroup = chrome.tabs.ungroup;
+ chrome.tabs.ungroup = async () => { throw new Error("temporary group failure"); };
+ const failed = await w.dispatch("automation.cleanup", { sessionKey: SK });
+ ok(failed.ungroupedAdoptedTabs === 0 && state.userGmail.groupId >= 0, "ungroup retry: failure leaves user tab and ownership intact");
+ chrome.tabs.ungroup = ungroup;
+ await loadWorker(chrome).dispatch("automation.cleanup", { sessionKey: SK });
+ ok(state.userGmail.groupId === -1, "ungroup retry: successful after worker restart");
+ const created = await w.dispatch("tab.new", { sessionKey: SK });
+ for (const key of Object.keys(state.storage)) delete state.storage[key];
+ await loadWorker(chrome).dispatch("automation.cleanup", { sessionKey: SK });
+ ok(state.tabs.has(created.tab.id), "browser restart: cleared storage never reclaims restored tabs by group name");
+ }
+
+ // Runtime tracking still works when storage.session is unavailable.
+ {
+ const state = makeChromeState();
+ const w = loadWorker(makeChrome(state, { withTabGroups: true, withStorage: false }));
+ const opened = await w.dispatch("tab.new", { sessionKey: SK });
+ await w.dispatch("automation.cleanup", { sessionKey: SK });
+ ok(!state.tabs.has(opened.tab.id) && state.tabs.has(state.userGmail.id), "no storage: created tab cleaned up safely");
+ }
+
console.log(`\n${passes} passed, ${failures} failed`);
if (failures) process.exit(1);
}
diff --git a/test-suite/unit/session-cleanup.test.mjs b/test-suite/unit/session-cleanup.test.mjs
new file mode 100644
index 0000000..7e3285f
--- /dev/null
+++ b/test-suite/unit/session-cleanup.test.mjs
@@ -0,0 +1,93 @@
+// Exercise the shipped cleanup helper and shutdown listener, without opening a bridge or Chrome.
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import vm from "node:vm";
+import { stripTypeScriptTypes } from "node:module";
+
+const source = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/index.ts", import.meta.url), "utf8");
+function section(start, end) {
+ const from = source.indexOf(start);
+ const to = source.indexOf(end, from);
+ assert.ok(from >= 0 && to > from, `Missing source section: ${start}`);
+ return source.slice(from, to);
+}
+const helper = stripTypeScriptTypes(section("const cleanupAutomationTargetBestEffort =", "const lockChromeControl ="));
+const listener = section('pi.on("session_shutdown",', 'pi.on("before_agent_start",');
+
+function harness(send, sessionKey = "session:test") {
+ const timers = new Map();
+ const events = [];
+ let shutdown;
+ const token = Symbol();
+ const globalState = { loaded: { token } };
+ const context = {
+ AbortController, Promise,
+ setTimeout(fn, ms) { const key = Symbol(); timers.set(key, { fn, ms }); return key; },
+ clearTimeout(key) { timers.delete(key); },
+ bridge: { send(...args) { events.push("send"); return send(...args); }, stop() { events.push("stop"); } },
+ sessionCtx: {}, sessionKeyFor: () => sessionKey ?? undefined,
+ clearAuthExpiryTimer() {}, clearCountdownInterval() {},
+ globalState, PI_CHROME_GLOBAL_KEY: "loaded", instanceToken: token,
+ pi: { on(name, handler) { assert.equal(name, "session_shutdown"); shutdown = handler; } },
+ };
+ vm.runInNewContext(`${helper}\n${listener}`, context);
+ return { shutdown, timers, events, globalState };
+}
+
+// Delivery must settle before bridge.stop; reload does not issue cleanup at all.
+{
+ let complete;
+ let request;
+ const h = harness((...args) => {
+ request = args;
+ return new Promise((resolve) => { complete = resolve; });
+ });
+ const done = h.shutdown({ reason: "quit" });
+ assert.deepEqual(h.events, ["send"]);
+ assert.equal(request[0], "automation.cleanup");
+ assert.equal(request[1].sessionKey, "session:test");
+ assert.equal(request[2], 2000);
+ assert.equal(request[3].aborted, false);
+ complete();
+ await done;
+ assert.deepEqual(h.events, ["send", "stop"]);
+ assert.equal(h.timers.size, 0);
+ assert.equal(h.globalState.loaded, undefined);
+}
+{
+ const h = harness(() => { throw new Error("reload must not send"); });
+ await h.shutdown({ reason: "reload" });
+ assert.deepEqual(h.events, ["stop"]);
+ assert.equal(h.timers.size, 0);
+}
+
+// Even an owner request that never settles cannot hold up shutdown past its budget.
+{
+ let signal;
+ const h = harness((_action, _params, _timeout, s) => { signal = s; return new Promise(() => {}); });
+ const done = h.shutdown({});
+ const [timer] = h.timers.values();
+ assert.equal(timer.ms, 2000);
+ timer.fn();
+ await done;
+ assert.equal(signal.aborted, true);
+ assert.deepEqual(h.events, ["send", "stop"]);
+ assert.equal(h.timers.size, 0);
+}
+for (const reason of ["quit", "new", "resume", "fork"]) {
+ const h = harness(() => Promise.resolve({}));
+ await h.shutdown({ reason });
+ assert.deepEqual(h.events, ["send", "stop"]);
+}
+for (const send of [() => Promise.reject(new Error("offline")), () => { throw new Error("offline"); }]) {
+ const h = harness(send);
+ await h.shutdown({ reason: "exit" });
+ assert.deepEqual(h.events, ["send", "stop"]);
+ assert.equal(h.timers.size, 0);
+}
+{
+ const h = harness(() => { throw new Error("unscoped cleanup must not send"); }, null);
+ await h.shutdown({ reason: "exit" });
+ assert.deepEqual(h.events, ["stop"]);
+}
+console.log("session-cleanup: shutdown ordering, reload, deadline, failures, and missing identity passed");