diff options
| author | Tianren Dong <tianrendong.dtr@gmail.com> | 2026-09-09 23:14:54 -0400 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-09 23:14:54 -0400 |
| commit | 641ae30f7963ad962e162ff0dfbd80966b09a516 (patch) | |
| tree | 5a8ba52d06a1c96d93540cfe1b34f699cd447c68 | |
| parent | 017ff4b9a639f0b8b213e58a3f30613fc38edcc8 (diff) | |
| parent | 1ec29fc90b84517322bf5994245368f0e59eff61 (diff) | |
Merge pull request #12 from tianrendong/fix/session-cleanup-safety
Fix session cleanup and release 0.15.47
| -rw-r--r-- | CHANGELOG.md | 6 | ||||
| -rw-r--r-- | CONTRIBUTING.md | 4 | ||||
| -rw-r--r-- | docs/ARCHITECTURE.md | 5 | ||||
| -rw-r--r-- | extensions/chrome-profile-bridge/browser-extension/manifest.json | 2 | ||||
| -rw-r--r-- | extensions/chrome-profile-bridge/browser-extension/service_worker.js | 160 | ||||
| -rw-r--r-- | extensions/chrome-profile-bridge/index.ts | 39 | ||||
| -rw-r--r-- | package-lock.json | 4 | ||||
| -rw-r--r-- | package.json | 4 | ||||
| -rw-r--r-- | test-suite/unit/automation-target.test.mjs | 109 | ||||
| -rw-r--r-- | test-suite/unit/session-cleanup.test.mjs | 93 |
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"); |
