fix: retire extension iframe event listeners with native frame (#35011)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-28 08:42:02 -07:00
committed by GitHub
co-authored by OrcaWin
parent 2657567db0
commit bc80bc59fb
9 changed files with 384 additions and 54 deletions
@@ -5,7 +5,10 @@ import { join } from 'node:path'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import { z } from 'zod'
type PortStub = EventEmitter & { close: ReturnType<typeof vi.fn>; start: ReturnType<typeof vi.fn> }
const mocks = vi.hoisted(() => ({
channels: new Array<{ port1: PortStub; port2: PortStub }>(),
userData: '',
prompt: vi.fn<() => Promise<boolean>>(),
handlers: new Map<
@@ -18,22 +21,37 @@ const mocks = vi.hoisted(() => ({
>()
}))
vi.mock('electron', () => ({
app: { on: vi.fn(), getPath: () => mocks.userData },
BrowserWindow: vi.fn(),
session: { fromPartition: vi.fn() },
webContents: { fromId: vi.fn() },
ipcMain: {
removeHandler: (channel: string) => mocks.handlers.delete(channel),
handle: (
channel: string,
handler: (event: unknown, request: unknown, userGesture?: boolean) => Promise<unknown>
) => mocks.handlers.set(channel, handler)
},
webFrameMain: {
fromId: (processId: number, routingId: number) => mocks.frames.get(`${processId}:${routingId}`)
vi.mock('electron', async () => {
const { EventEmitter } = await import('node:events')
class Port extends EventEmitter {
close = vi.fn(() => this.emit('close'))
start = vi.fn()
}
}))
return {
MessageChannelMain: class {
port1 = new Port()
port2 = new Port()
constructor() {
mocks.channels.push(this)
}
},
app: { on: vi.fn(), getPath: () => mocks.userData },
BrowserWindow: vi.fn(),
session: { fromPartition: vi.fn() },
webContents: { fromId: vi.fn() },
ipcMain: {
removeHandler: (channel: string) => mocks.handlers.delete(channel),
handle: (
channel: string,
handler: (event: unknown, request: unknown, userGesture?: boolean) => Promise<unknown>
) => mocks.handlers.set(channel, handler)
},
webFrameMain: {
fromId: (processId: number, routingId: number) =>
mocks.frames.get(`${processId}:${routingId}`)
}
}
})
vi.mock('../browser-manager', () => ({ browserManager: {} }))
vi.mock('../../ipc/browser-tab-registration-wait', () => ({ waitForTabRegistration: vi.fn() }))
vi.mock('../../runtime/browser/browser-tab-renderer-create', () => ({
@@ -48,8 +66,11 @@ vi.mock('./browser-extension-permission-prompt', () => ({
}))
mocks.userData = mkdtempSync(join(tmpdir(), 'orca-extension-api-host-'))
const { BROWSER_EXTENSION_EVENT_CHANNEL, BROWSER_EXTENSION_INVOKE_CHANNEL } =
await import('../../../shared/browser-extension-channels')
const {
BROWSER_EXTENSION_EVENT_CHANNEL,
BROWSER_EXTENSION_INVOKE_CHANNEL,
BROWSER_EXTENSION_FRAME_LIFETIME_CHANNEL
} = await import('../../../shared/browser-extension-channels')
const surface = await import('../../../preload/browser-extension-api-surface')
const registry = await import('./browser-extension-namespace-registry')
const host = await import('./browser-extension-api-host')
@@ -87,7 +108,15 @@ function frameEvent(
// Node reports "null" for chrome-extension: URLs; Chromium reports the real extension origin.
origin = url.split('/').slice(0, 3).join('/')
) {
const frame = { url, origin, processId, routingId, isDestroyed: () => false, send: vi.fn() }
const frame = {
url,
origin,
processId,
routingId,
isDestroyed: () => false,
send: vi.fn(),
postMessage: vi.fn()
}
mocks.frames.set(`${processId}:${routingId}`, frame)
return {
frame,
@@ -133,6 +162,9 @@ describe('preload API surface', () => {
it('pins the preload channel copies to the ones main listens on', () => {
expect(surface.PRELOAD_BROWSER_EXTENSION_INVOKE_CHANNEL).toBe(BROWSER_EXTENSION_INVOKE_CHANNEL)
expect(surface.PRELOAD_BROWSER_EXTENSION_EVENT_CHANNEL).toBe(BROWSER_EXTENSION_EVENT_CHANNEL)
expect(surface.PRELOAD_BROWSER_EXTENSION_FRAME_LIFETIME_CHANNEL).toBe(
BROWSER_EXTENSION_FRAME_LIFETIME_CHANNEL
)
})
it('installs exactly what main registers, under the same permission', () => {
@@ -394,3 +426,159 @@ it('retires the current host after extension support restarts in the same docume
expect(contexts.browserExtensionEventHasListener(isolated, 'probe', 'onPing')).toBe(false)
host.forgetBrowserExtensionContexts(isolated)
})
describe('extension subframe listener retirement', () => {
const listen = { type: 'listen', namespace: 'probe', event: 'onPing', active: true }
function isolatedSession() {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: host calls use the copied session members already exercised by the host fixtures.
const sess = { ...extensionSession } as unknown as Electron.Session
host.attachBrowserExtensionApiHostToSession(sess, 'subframes')
const state = contexts.getHostSessionState(sess)
if (!state) {
throw new Error('Missing session host')
}
return { sess, state }
}
function channel() {
const current = mocks.channels.at(-1)
if (!current) {
throw new Error('Listening frame did not acquire a native port')
}
return current
}
it('retires a detached filtered frame without dispatch or document navigation', async () => {
const { sess, state } = isolatedSession()
const page = frameEvent(`chrome-extension://${EXTENSION_ID}/frame.html`, sess, 20, 1)
await invoke(page.event, { ...listen, filters: [{ urls: ['https://never.invalid/*'] }] })
expect(contexts.browserExtensionEventHasListener(sess, 'probe', 'onPing')).toBe(true)
channel().port1.emit('close')
expect(state.frames.size).toBe(0)
expect(contexts.browserExtensionEventHasListener(sess, 'probe', 'onPing')).toBe(false)
host.emitBrowserExtensionEvent({ session: sess }, 'probe', 'onPing', ['removed'])
expect(page.frame.send).not.toHaveBeenCalled()
host.forgetBrowserExtensionContexts(sess)
})
it('shares one port across listeners and replaces it after final removal', async () => {
const { sess, state } = isolatedSession()
const page = frameEvent(`chrome-extension://${EXTENSION_ID}/frame.html`, sess, 21, 1)
await invoke(page.event, listen)
const first = channel()
const lateClose = first.port1.listeners('close')[0]
await invoke(page.event, listen)
await invoke(page.event, { ...listen, namespace: 'tabs', event: 'onCreated' })
expect(page.frame.postMessage).toHaveBeenCalledTimes(1)
await invoke(page.event, { ...listen, active: false })
expect(state.frames.size).toBe(1)
expect(first.port1.close).not.toHaveBeenCalled()
await invoke(page.event, { ...listen, namespace: 'tabs', event: 'onCreated', active: false })
expect(state.frames.size).toBe(0)
expect(first.port1.close).toHaveBeenCalledTimes(1)
expect(first.port1.listenerCount('close')).toBe(0)
await invoke(page.event, listen)
expect(channel()).not.toBe(first)
lateClose?.()
host.emitBrowserExtensionEvent({ session: sess }, 'probe', 'onPing', ['successor'])
expect(page.frame.send).toHaveBeenCalledTimes(1)
expect(state.frames.size).toBe(1)
host.forgetBrowserExtensionContexts(sess)
})
it('preserves sibling frames, windows, sessions and worker listeners', async () => {
const { sess, state } = isolatedSession()
const elsewhere = isolatedSession()
const removed = frameEvent(`chrome-extension://${EXTENSION_ID}/frame.html`, sess, 22, 1)
const sibling = frameEvent(`chrome-extension://${EXTENSION_ID}/frame.html`, sess, 22, 2)
sibling.event.sender = removed.event.sender
const window = frameEvent(`chrome-extension://${EXTENSION_ID}/page.html`, sess, 22, 3)
window.event.sender.id = 78
const other = frameEvent(`chrome-extension://${EXTENSION_ID}/page.html`, elsewhere.sess, 23, 1)
await invoke(removed.event, listen)
const owner = channel()
await invoke(sibling.event, listen)
await invoke(window.event, listen)
await invoke(other.event, listen)
const worker = contexts.ensureWorkerContext(
state,
EXTENSION_ID,
`chrome-extension://${EXTENSION_ID}/`
)
worker.events.add('probe.onPing')
worker.stream = { send: vi.fn(), close: vi.fn() }
owner.port1.emit('close')
host.emitBrowserExtensionEvent({}, 'probe', 'onPing', ['survivors'])
expect(removed.frame.send).not.toHaveBeenCalled()
for (const frame of [sibling.frame, window.frame, other.frame]) {
expect(frame.send).toHaveBeenCalledTimes(1)
}
expect(worker.stream.send).toHaveBeenCalledTimes(1)
expect(worker.stream.close).not.toHaveBeenCalled()
expect(state.frames.size).toBe(2)
expect(elsewhere.state.frames.size).toBe(1)
host.forgetBrowserExtensionContexts(sess)
host.forgetBrowserExtensionContexts(elsewhere.sess)
})
it.each([
'did-navigate',
'render-process-gone',
'destroyed',
'extension-unloaded',
'host-forgotten',
'invalid-frame',
'failed-send'
])('closes a frame port exactly once on %s', async (retirement) => {
const { sess, state } = isolatedSession()
const page = frameEvent(`chrome-extension://${EXTENSION_ID}/frame.html`, sess, 24, 1)
await invoke(page.event, listen)
const owner = channel()
if (retirement === 'extension-unloaded') {
contexts.forgetExtensionContexts(sess, EXTENSION_ID)
} else if (retirement === 'host-forgotten') {
host.forgetBrowserExtensionContexts(sess)
} else if (retirement === 'invalid-frame') {
mocks.frames.delete('24:1')
host.emitBrowserExtensionEvent({ session: sess }, 'probe', 'onPing', [])
} else if (retirement === 'failed-send') {
page.frame.send.mockImplementation(() => {
throw new Error('Frame retired')
})
host.emitBrowserExtensionEvent({ session: sess }, 'probe', 'onPing', [])
} else {
page.event.sender.emit(retirement)
}
expect(state.frames.size).toBe(0)
expect(owner.port1.close).toHaveBeenCalledTimes(1)
expect(owner.port1.listenerCount('close')).toBe(0)
page.event.sender.emit('destroyed')
host.forgetBrowserExtensionContexts(sess)
expect(owner.port1.close).toHaveBeenCalledTimes(1)
})
it('releases both endpoints and the registry when frame transfer fails', async () => {
const { sess, state } = isolatedSession()
const page = frameEvent(`chrome-extension://${EXTENSION_ID}/frame.html`, sess, 25, 1)
page.frame.postMessage.mockImplementation(() => {
throw new Error('Frame retired before transfer')
})
const warning = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
await expect(invoke(page.event, listen)).resolves.toEqual({
ok: false,
error: 'Frame retired before transfer'
})
const owner = channel()
expect(state.frames.size).toBe(0)
expect(contexts.browserExtensionEventHasListener(sess, 'probe', 'onPing')).toBe(false)
expect(owner.port1.close).toHaveBeenCalledTimes(1)
expect(owner.port2.close).toHaveBeenCalledTimes(1)
expect(owner.port1.listenerCount('close')).toBe(0)
} finally {
warning.mockRestore()
host.forgetBrowserExtensionContexts(sess)
}
})
})
@@ -19,6 +19,10 @@ const mocks = vi.hoisted(() => ({
}))
vi.mock('electron', () => ({
MessageChannelMain: class {
port1 = Object.assign(new EventEmitter(), { start: vi.fn(), close: vi.fn() })
port2 = { close: vi.fn() }
},
app: { on: vi.fn(), getPath: () => mocks.userData },
BrowserWindow: vi.fn(),
Notification: { isSupported: () => false },
@@ -819,7 +823,8 @@ async function listenFrame(extensionId: string, namespace: string, event: string
processId: 3,
routingId,
isDestroyed: () => false,
send: vi.fn()
send: vi.fn(),
postMessage: vi.fn()
}
mocks.frames.set(`3:${routingId}`, frame)
await mocks.handlers.get(BROWSER_EXTENSION_INVOKE_CHANNEL)?.(
@@ -1,5 +1,10 @@
import { abortWhenRendererGone } from '../../ipc/renderer-lifetime-abort'
import { forgetBrowserExtensionContextLifetime } from './browser-extension-context-lifetime'
import {
createBrowserExtensionFrameContext,
forgetBrowserExtensionFrame,
trackBrowserExtensionDocument,
type FrameContext
} from './browser-extension-frame-lifetime'
import { webFrameMain } from 'electron'
import {
BROWSER_EXTENSION_EVENT_CHANNEL,
@@ -18,15 +23,6 @@ import {
type WorkerEventStream
} from './browser-extension-worker-event-stream'
type FrameContext = {
extensionId: string
webContentsId: number
processId: number
routingId: number
events: Set<string>
filters: ListenerFilters
}
/** Event key → every listener's filter; absent when some listener takes everything. */
export type ListenerFilters = Map<string, unknown[]>
@@ -50,7 +46,6 @@ export type HostSessionState = {
}
const stateBySession = new Map<Electron.Session, HostSessionState>()
const trackedWebContents = new WeakSet<Electron.WebContents>()
export function eventKey(namespace: string, event: string): string {
return `${namespace}.${event}`
@@ -73,8 +68,14 @@ export function ensureHostSessionState(
}
export function forgetHostSession(sess: Electron.Session): void {
forgetBrowserExtensionContextLifetime(stateBySession.get(sess))
for (const context of stateBySession.get(sess)?.workers.values() ?? []) {
const state = stateBySession.get(sess)
forgetBrowserExtensionContextLifetime(state)
if (state) {
for (const [key, context] of state.frames) {
forgetBrowserExtensionFrame(state.frames, key, context)
}
}
for (const context of state?.workers.values() ?? []) {
detachWorkerEventStream(context)
}
stateBySession.delete(sess)
@@ -93,7 +94,7 @@ export function forgetExtensionContexts(sess: Electron.Session, extensionId: str
state.workers.delete(extensionId)
for (const [key, frame] of state.frames) {
if (frame.extensionId === extensionId) {
state.frames.delete(key)
forgetBrowserExtensionFrame(state.frames, key, frame)
}
}
}
@@ -135,15 +136,13 @@ export function setFrameListening(
if (!active) {
return
}
context = {
context = createBrowserExtensionFrameContext(
state.frames,
frameKey,
extensionId,
webContentsId: webContents.id,
processId: frame.processId,
routingId: frame.routingId,
events: new Set(),
filters: new Map()
}
state.frames.set(frameKey, context)
webContents.id,
frame
)
}
if (active) {
context.events.add(key)
@@ -151,18 +150,11 @@ export function setFrameListening(
context.events.delete(key)
}
setListenerFilters(context.filters, key, active ? filters : undefined)
if (!trackedWebContents.has(webContents)) {
trackedWebContents.add(webContents)
const { session: webSession, id: webContentsId } = webContents
abortWhenRendererGone(webContents).signal.addEventListener('abort', () => {
trackedWebContents.delete(webContents)
stateBySession.get(webSession)?.frames.forEach((candidate, candidateKey, frames) => {
if (candidate.webContentsId === webContentsId) {
frames.delete(candidateKey)
}
})
})
if (context.events.size === 0) {
forgetBrowserExtensionFrame(state.frames, frameKey, context)
}
const webSession = webContents.session
trackBrowserExtensionDocument(webContents, () => stateBySession.get(webSession)?.frames)
}
export function setListenerFilters(
@@ -240,13 +232,13 @@ function deliverToFrames(
const frame = webFrameMain.fromId(context.processId, context.routingId)
// Why: a frame that navigated away from the extension must stop receiving its events.
if (!frame || frame.isDestroyed() || !frame.url.startsWith(origin)) {
state.frames.delete(frameKey)
forgetBrowserExtensionFrame(state.frames, frameKey, context)
continue
}
try {
frame.send(BROWSER_EXTENSION_EVENT_CHANNEL, payload)
} catch {
state.frames.delete(frameKey)
forgetBrowserExtensionFrame(state.frames, frameKey, context)
}
}
}
@@ -0,0 +1,90 @@
import { MessageChannelMain } from 'electron'
import { BROWSER_EXTENSION_FRAME_LIFETIME_CHANNEL } from '../../../shared/browser-extension-channels'
import { abortWhenRendererGone } from '../../ipc/renderer-lifetime-abort'
export type FrameContext = {
extensionId: string
webContentsId: number
processId: number
routingId: number
events: Set<string>
filters: Map<string, unknown[]>
dispose: () => void
}
const trackedWebContents = new WeakSet<Electron.WebContents>()
export function forgetBrowserExtensionFrame(
frames: Map<string, FrameContext>,
key: string,
context: FrameContext
): void {
if (frames.get(key) !== context) {
return
}
frames.delete(key)
context.dispose()
}
export function createBrowserExtensionFrameContext(
frames: Map<string, FrameContext>,
key: string,
extensionId: string,
webContentsId: number,
frame: Electron.WebFrameMain
): FrameContext {
const { port1, port2 } = new MessageChannelMain()
let disposed = false
const retire = (): void => forgetBrowserExtensionFrame(frames, key, context)
const context: FrameContext = {
extensionId,
webContentsId,
processId: frame.processId,
routingId: frame.routingId,
events: new Set(),
filters: new Map(),
dispose: () => {
if (disposed) {
return
}
disposed = true
port1.removeListener('close', retire)
port1.close()
}
}
frames.set(key, context)
port1.once('close', retire)
try {
port1.start()
// Native frame teardown closes the peer without needing an unload callback.
frame.postMessage(BROWSER_EXTENSION_FRAME_LIFETIME_CHANNEL, null, [port2])
} catch (error) {
forgetBrowserExtensionFrame(frames, key, context)
port2.close()
throw error
}
return context
}
export function trackBrowserExtensionDocument(
webContents: Electron.WebContents,
currentFrames: () => Map<string, FrameContext> | undefined
): void {
if (trackedWebContents.has(webContents)) {
return
}
trackedWebContents.add(webContents)
const webContentsId = webContents.id
abortWhenRendererGone(webContents).signal.addEventListener('abort', () => {
trackedWebContents.delete(webContents)
const frames = currentFrames()
if (!frames) {
return
}
for (const [key, context] of frames) {
if (context.webContentsId === webContentsId) {
forgetBrowserExtensionFrame(frames, key, context)
}
}
})
}
@@ -9,6 +9,7 @@
export const PRELOAD_BROWSER_EXTENSION_INVOKE_CHANNEL = 'browserExtension:invoke'
export const PRELOAD_BROWSER_EXTENSION_EVENT_CHANNEL = 'browserExtension:event'
export const PRELOAD_BROWSER_EXTENSION_FRAME_LIFETIME_CHANNEL = 'browserExtension:frameLifetime'
export type BrowserExtensionSurfaceMethod = {
/**
@@ -0,0 +1,31 @@
import { EventEmitter } from 'node:events'
import { describe, expect, it, vi } from 'vitest'
import { installBrowserExtensionFrameLifetime } from './browser-extension-frame-lifetime'
import { PRELOAD_BROWSER_EXTENSION_FRAME_LIFETIME_CHANNEL } from './browser-extension-api-surface'
describe('extension frame lifetime receiver', () => {
it('retains the current endpoint and closes replaced and surplus endpoints', () => {
const ipcRenderer = Object.assign(new EventEmitter(), {
invoke: vi.fn(),
postMessage: vi.fn(),
send: vi.fn(),
sendSync: vi.fn(),
sendToHost: vi.fn()
})
installBrowserExtensionFrameLifetime(ipcRenderer)
const first = { close: vi.fn() }
const second = { close: vi.fn() }
const surplus = { close: vi.fn() }
ipcRenderer.emit(PRELOAD_BROWSER_EXTENSION_FRAME_LIFETIME_CHANNEL, { ports: [first] })
ipcRenderer.emit(PRELOAD_BROWSER_EXTENSION_FRAME_LIFETIME_CHANNEL, { ports: [] })
expect(first.close).not.toHaveBeenCalled()
ipcRenderer.emit(PRELOAD_BROWSER_EXTENSION_FRAME_LIFETIME_CHANNEL, {
ports: [second, surplus]
})
expect(first.close).toHaveBeenCalledTimes(1)
expect(second.close).not.toHaveBeenCalled()
expect(surplus.close).toHaveBeenCalledTimes(1)
ipcRenderer.emit(PRELOAD_BROWSER_EXTENSION_FRAME_LIFETIME_CHANNEL, { ports: [] })
expect(second.close).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,18 @@
import type { IpcRenderer } from 'electron'
import { PRELOAD_BROWSER_EXTENSION_FRAME_LIFETIME_CHANNEL } from './browser-extension-api-surface'
export function installBrowserExtensionFrameLifetime(ipcRenderer: IpcRenderer): void {
let retainedPort: MessagePort | undefined
ipcRenderer.on(PRELOAD_BROWSER_EXTENSION_FRAME_LIFETIME_CHANNEL, (event) => {
const [port, ...surplus] = event.ports
for (const extra of surplus) {
extra.close()
}
if (!port) {
return
}
retainedPort?.close()
// Retain the isolated-world endpoint until its document is destroyed or it is replaced.
retainedPort = port
})
}
+2
View File
@@ -17,6 +17,7 @@ import { passesBrowserExtensionEventFilter } from './browser-extension-event-fil
import { installBrowserExtensionInertApis } from './browser-extension-inert-apis'
import { installContentScriptRelayFilter } from './browser-extension-content-script-relay'
import { installBrowserExtensionNativeMessaging } from './browser-extension-native-messaging'
import { installBrowserExtensionFrameLifetime } from './browser-extension-frame-lifetime'
import { installBrowserExtensionStorageRelay } from './browser-extension-storage-relay'
import {
installBrowserExtensionStorageSync,
@@ -68,6 +69,7 @@ async function invoke(request: unknown): Promise<BrowserExtensionInvokeReply> {
}
function installExtensionContextApis(): void {
installBrowserExtensionFrameLifetime(ipcRenderer)
const dispatchers: ((namespace: string, event: string, args: unknown[]) => void)[] = []
ipcRenderer.on(PRELOAD_BROWSER_EXTENSION_EVENT_CHANNEL, (_event, payload: unknown) => {
if (
+3
View File
@@ -12,6 +12,9 @@ export const BROWSER_EXTENSION_INVOKE_CHANNEL = 'browserExtension:invoke'
/** Main → extension context, only for events that context registered. Payload: BrowserExtensionEventPayload. */
export const BROWSER_EXTENSION_EVENT_CHANNEL = 'browserExtension:event'
/** Main → listening frame. The transferred port owns the frame's listener registrations. */
export const BROWSER_EXTENSION_FRAME_LIFETIME_CHANNEL = 'browserExtension:frameLifetime'
export type BrowserExtensionInvokeRequest =
| { type: 'call'; namespace: string; method: string; args: unknown[] }
// Why: lets main skip contexts with no listener and restart a stopped worker only when it listens.