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:
@@ -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 } })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user