fix(extension): stateless SW, pull tab state instead of ports (#596)

* feat(extension)!: make sw stateless; tabs update with pulling instead of pushing

* fix(extension): harden tab loading wait

* fix: safer `waitUntil`

* chore: comments

* chore: reduce polling logs
This commit is contained in:
Simon
2026-07-07 21:08:27 +08:00
committed by GitHub
parent 9609592be3
commit 35ff6d44fe
4 changed files with 101 additions and 124 deletions
@@ -64,9 +64,11 @@ export class MultiPageAgent extends PageAgentCore {
}, },
onBeforeStep: async (agent) => { onBeforeStep: async (agent) => {
// pull latest tab state so that tabs changes can be observed
await tabsController.syncTabs()
if (!tabsController.currentTabId) return if (!tabsController.currentTabId) return
// make sure the current tab is loaded before the step starts // make sure the current tab is loaded before the step starts
await tabsController.waitUntilTabLoaded(tabsController.currentTabId!) await tabsController.waitUntilTabLoaded(tabsController.currentTabId)
}, },
onDispose: () => { onDispose: () => {
@@ -1,5 +1,9 @@
/** /**
* background logics for TabsController * background logics for TabsController
*
* Keep this stateless: pure request/response handlers only, no in-memory
* state, no ports, no event pushing. MV3 SW should be killed and restarted at
* any time (idle timeout, extension update) without special handling.
*/ */
import type { TabAction } from './TabsController' import type { TabAction } from './TabsController'
@@ -144,7 +148,6 @@ export function handleTabControlMessage(
} }
case 'get_window_tabs': { case 'get_window_tabs': {
debug('get_window_tabs', payload)
chrome.tabs chrome.tabs
.query({ windowId: payload.windowId }) .query({ windowId: payload.windowId })
.then((tabs) => { .then((tabs) => {
@@ -161,41 +164,3 @@ export function handleTabControlMessage(
return return
} }
} }
const tabEventPorts = new Set<chrome.runtime.Port>()
function broadcastTabEvent(message: object) {
for (const port of tabEventPorts) {
port.postMessage(message)
}
}
/**
* Port-based tab events: agents connect via `chrome.runtime.connect({ name: 'tab-events' })`
* and receive tab change events through the port. Works for both extension pages and content scripts.
*/
export function setupTabEventsPort() {
chrome.runtime.onConnect.addListener((port) => {
if (port.name !== 'tab-events') return
debug('port connected', port.sender?.tab?.id ?? port.sender?.url)
tabEventPorts.add(port)
port.onDisconnect.addListener(() => {
debug('port disconnected')
tabEventPorts.delete(port)
})
})
chrome.tabs.onCreated.addListener((tab) => {
broadcastTabEvent({ action: 'created', payload: { tab } })
})
chrome.tabs.onRemoved.addListener((tabId, removeInfo) => {
broadcastTabEvent({ action: 'removed', payload: { tabId, removeInfo } })
})
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
broadcastTabEvent({ action: 'updated', payload: { tabId, changeInfo, tab } })
})
}
+84 -70
View File
@@ -33,15 +33,16 @@ async function getOwnWindowId(): Promise<number | undefined> {
* Controller for managing browser tabs. * Controller for managing browser tabs.
* - live in the agent env (extension page or content script) * - live in the agent env (extension page or content script)
* - no chrome apis. call sw for tab operations * - no chrome apis. call sw for tab operations
* - store tabs states, pull tabs info and detect changes
*/ */
export class TabsController { export class TabsController {
currentTabId: number | null = null currentTabId: number | null = null
private disposed = false private disposed = false
private port?: chrome.runtime.Port
private portRetries = 0
/* tracked window */
private windowId: number | null = null private windowId: number | null = null
/* tracked tabs */
private tabs: TabMeta[] = [] private tabs: TabMeta[] = []
private initialTabId: number | null = null private initialTabId: number | null = null
private tabGroupId: number | null = null private tabGroupId: number | null = null
@@ -57,9 +58,6 @@ export class TabsController {
} }
await this.updateCurrentTabId(null) await this.updateCurrentTabId(null)
this.disposed = false
this.port = undefined
this.portRetries = 0
this.windowId = null this.windowId = null
this.tabs = [] this.tabs = []
@@ -85,8 +83,6 @@ export class TabsController {
} }
} }
this.connectTabEvents()
if (experimentalIncludeAllTabs) { if (experimentalIncludeAllTabs) {
const allTabs = await sendMessage({ const allTabs = await sendMessage({
type: 'TAB_CONTROL', type: 'TAB_CONTROL',
@@ -297,82 +293,96 @@ export class TabsController {
async waitUntilTabLoaded(tabId: number): Promise<void> { async waitUntilTabLoaded(tabId: number): Promise<void> {
const tab = this.tabs.find((t) => t.id === tabId) const tab = this.tabs.find((t) => t.id === tabId)
if (!tab) throw new Error(`Tab ID ${tabId} not found in tab list.`) if (!tab) throw new Error(`Tab ID ${tabId} not found in tab list.`)
if (tab.status === 'unloaded') throw new Error(`Tab ID ${tabId} is unloaded.`)
if (tab.status === 'complete') return if (tab.status === 'complete') return
// When a tracked tab is closed or untracked.
// The tab object will be removed from the tab list.
// Finding the latest tab object is the only way to know if it's closed.
debug('waitUntilTabLoaded', tabId) debug('waitUntilTabLoaded', tabId)
await waitUntil(() => tab.status === 'complete', 4_000) await waitUntil(async () => {
await this.syncTabs()
const latest = this.tabs.find((t) => t.id === tabId)
return !latest || latest.status !== 'loading'
}, 4_000)
const latest = this.tabs.find((t) => t.id === tabId)
if (latest?.status === 'unloaded') throw new Error(`Tab ID ${tabId} is unloaded.`)
} }
/** /**
* Connect to background SW via port to receive tab change events. * Pull the window's tabs from the background.
* * Pulling is better than pushing. Long-lived ports are stateful troublemakers.
* @note Port is 1:1 (runtime.connect → background SW has no frames),
* so onDisconnect fires exactly once and we can safely reconnect.
* Reconnection may miss events during the gap.
* TODO: refresh this.tabs from background after reconnect to stay consistent.
*/ */
private connectTabEvents() { async syncTabs(): Promise<void> {
this.port = chrome.runtime.connect({ name: 'tab-events' }) if (this.disposed || this.windowId == null) return
this.port.onMessage.addListener((message: any) => { const result = await sendMessage({
if (this.disposed) return type: 'TAB_CONTROL',
this.portRetries = 0 action: 'get_window_tabs',
payload: { windowId: this.windowId },
})
// sendMessage already logged the failure; keep the stale mirror
if (!result?.success) return
if (message.action === 'created') { const liveTabs = (result.tabs as chrome.tabs.Tab[]).filter((t) => t.id != null)
const tab = message.payload.tab as chrome.tabs.Tab const liveIds = new Set(liveTabs.map((t) => t.id!))
const shouldTrack =
tab.groupId === this.tabGroupId || const closedIds = this.tabs.filter((t) => !liveIds.has(t.id)).map((t) => t.id)
// @note Never track tabs from other windows. if (closedIds.length) {
(this.experimentalIncludeAllTabs && tab.windowId === this.windowId) debug('syncTabs: tabs closed', closedIds)
if (shouldTrack && tab.id != null) { this.tabs = this.tabs.filter((t) => liveIds.has(t.id))
this.addTab({ id: tab.id, isInitial: false })
this.switchToTab(tab.id)
} }
} else if (message.action === 'removed') {
const { tabId } = message.payload as { tabId: number } const newTabs: TabMeta[] = []
const targetTab = this.tabs.find((t) => t.id === tabId) for (const live of liveTabs) {
if (targetTab) { const tracked = this.tabs.find((t) => t.id === live.id)
this.tabs = this.tabs.filter((t) => t.id !== tabId) if (tracked) {
if (this.currentTabId === tabId) { tracked.url = live.url
const newCurrentTab = this.tabs[this.tabs.length - 1] || null tracked.title = live.title
if (newCurrentTab) { tracked.status = live.status as TabMeta['status']
this.switchToTab(newCurrentTab.id) } else if (this.shouldTrack(live)) {
debug('syncTabs: new tab', live.id, live.url)
const meta: TabMeta = {
id: live.id!,
isInitial: false,
url: live.url,
title: live.title,
status: live.status,
}
this.addTab(meta)
newTabs.push(meta)
}
}
// Follow the page like a user would: focus the newest tab it opened.
// If the current tab is gone, fall back to the last tracked one.
if (newTabs.length) {
await this.switchToTab(newTabs[newTabs.length - 1].id)
} else if (this.currentTabId != null && !this.tabs.find((t) => t.id === this.currentTabId)) {
const fallback = this.tabs[this.tabs.length - 1]
if (fallback) {
await this.switchToTab(fallback.id)
} else { } else {
this.updateCurrentTabId(null) debug('syncTabs: no fallback tab found, updating current tab to null')
await this.updateCurrentTabId(null)
} }
} }
} }
} else if (message.action === 'updated') {
const { tabId, tab } = message.payload as { tabId: number; tab: chrome.tabs.Tab }
const targetTab = this.tabs.find((t) => t.id === tabId)
if (targetTab) {
targetTab.url = tab.url
targetTab.title = tab.title
targetTab.status = tab.status
}
}
})
this.port.onDisconnect.addListener(() => { private shouldTrack(tab: chrome.tabs.Tab): boolean {
this.port = undefined if (this.tabGroupId != null && tab.groupId === this.tabGroupId) return true
if (this.disposed) return return (
if (this.portRetries >= 7) { this.experimentalIncludeAllTabs &&
console.error(PREFIX, 'tab events port failed after 7 retries, giving up') tab.windowId === this.windowId &&
return !tab.pinned &&
} isContentScriptAllowed(tab.url)
debug('port disconnected, reconnecting...') )
this.portRetries++
this.connectTabEvents()
})
} }
dispose() { dispose() {
debug('dispose') debug('dispose')
this.disposed = true this.disposed = true
this.port?.disconnect()
this.port = undefined
} }
} }
@@ -410,29 +420,33 @@ function randomColor(): TabGroupColor {
/** /**
* Wait until condition becomes true * Wait until condition becomes true
* @returns Returns when condition becomes true, throws otherwise * @returns Returns when condition becomes true, false if timeout
* @param timeoutMS Timeout in milliseconds, default 1 minutes, throws error on timeout * @param timeoutMS Timeout in milliseconds, default 1 minutes
* @param error Error object to reject on timeout. If not provided, will resolve with false * @param throwIfTimeout Reject on timeout instead of resolving with `false`
*/ */
export async function waitUntil( async function waitUntil(
check: () => boolean | Promise<boolean>, check: () => boolean | Promise<boolean>,
timeoutMS = 60_000, timeoutMS = 60_000,
error?: string throwIfTimeout = false
): Promise<boolean> { ): Promise<boolean> {
if (await check()) return true if (await check()) return true
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const start = Date.now() const start = Date.now()
const poll = async () => { const poll = async () => {
try {
if (await check()) return resolve(true) if (await check()) return resolve(true)
if (Date.now() - start > timeoutMS) { if (Date.now() - start > timeoutMS) {
if (error) { if (throwIfTimeout) {
return reject(new Error(error)) return reject(new Error(`waitUntil timed out after ${timeoutMS}ms`))
} else { } else {
return resolve(false) return resolve(false)
} }
} }
setTimeout(poll, 100) setTimeout(poll, 100)
} catch (err) {
reject(err instanceof Error ? err : new Error(String(err)))
}
} }
setTimeout(poll, 100) setTimeout(poll, 100)
}) })
@@ -1,13 +1,9 @@
import { handlePageControlMessage } from '@/agent/RemotePageController.background' import { handlePageControlMessage } from '@/agent/RemotePageController.background'
import { handleTabControlMessage, setupTabEventsPort } from '@/agent/TabsController.background' import { handleTabControlMessage } from '@/agent/TabsController.background'
export default defineBackground(() => { export default defineBackground(() => {
console.log('[Background] Service Worker started') console.log('[Background] Service Worker started')
// tab change events
setupTabEventsPort()
// generate user auth token // generate user auth token
chrome.storage.local.get('PageAgentExtUserAuthToken').then((result) => { chrome.storage.local.get('PageAgentExtUserAuthToken').then((result) => {