refactor(mobile): collapse the broker replay guard into one window

Two classes and two Sets tracked the same thing: a page-minted id the broker
has already honoured. Request and subscription ids share one window now. It
stays a module rather than a broker field because the broker sits at its
max-lines ceiling.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-07 15:03:07 -04:00
parent 8351f34b18
commit effd8ec5de
6 changed files with 69 additions and 106 deletions
@@ -1,35 +0,0 @@
import {
MOBILE_WEB_BRIDGE_MAX_PENDING_REQUESTS,
MOBILE_WEB_BRIDGE_MAX_SUBSCRIPTIONS
} from '../../../src/shared/mobile-web/bridge-contract'
import { MobileWebMessageReplayWindow } from './mobile-web-message-replay-window'
export class MobileWebBrokerReplayGuard {
private readonly requests = new MobileWebMessageReplayWindow(
MOBILE_WEB_BRIDGE_MAX_PENDING_REQUESTS * 4
)
private readonly subscriptions = new MobileWebMessageReplayWindow(
MOBILE_WEB_BRIDGE_MAX_SUBSCRIPTIONS * 4
)
acceptRequest(id: string, active: boolean): boolean {
if (active || this.requests.has(id)) {
return false
}
this.requests.remember(id)
return true
}
acceptSubscription(id: string): boolean {
if (this.subscriptions.has(id)) {
return false
}
this.subscriptions.remember(id)
return true
}
clear(): void {
this.requests.clear()
this.subscriptions.clear()
}
}
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { MobileWebBrokerReplayWindow } from './mobile-web-broker-replay-window'
describe('mobile web broker replay window', () => {
it('honours an id once and shares one window across request and subscription ids', () => {
const replay = new MobileWebBrokerReplayWindow()
expect(replay.accept('request-1')).toBe(true)
expect(replay.accept('request-1')).toBe(false)
expect(replay.accept('subscription-1')).toBe(true)
expect(replay.accept('subscription-1')).toBe(false)
})
it('evicts the oldest id rather than exhausting a long-lived session', () => {
const replay = new MobileWebBrokerReplayWindow()
const ids = Array.from({ length: 5_000 }, (_, index) => `id-${index}`)
for (const id of ids) {
expect(replay.accept(id)).toBe(true)
}
expect(replay.accept(ids[0]!)).toBe(true)
expect(replay.accept(ids.at(-1)!)).toBe(false)
})
it('clears all authority on disposal', () => {
const replay = new MobileWebBrokerReplayWindow()
replay.accept('request-1')
replay.clear()
expect(replay.accept('request-1')).toBe(true)
})
})
@@ -0,0 +1,31 @@
import {
MOBILE_WEB_BRIDGE_MAX_PENDING_REQUESTS,
MOBILE_WEB_BRIDGE_MAX_SUBSCRIPTIONS
} from '../../../src/shared/mobile-web/bridge-contract'
// One window covers request and subscription ids alike: both are page-minted and honoured once.
const REPLAY_WINDOW =
(MOBILE_WEB_BRIDGE_MAX_PENDING_REQUESTS + MOBILE_WEB_BRIDGE_MAX_SUBSCRIPTIONS) * 4
export class MobileWebBrokerReplayWindow {
private readonly ids = new Set<string>()
/** An id evicted from the window while still in flight is caught by the broker's pending map. */
accept(id: string): boolean {
if (this.ids.has(id)) {
return false
}
this.ids.add(id)
if (this.ids.size > REPLAY_WINDOW) {
const oldest = this.ids.values().next().value
if (oldest !== undefined) {
this.ids.delete(oldest)
}
}
return true
}
clear(): void {
this.ids.clear()
}
}
@@ -20,7 +20,7 @@ import { executeMobileWebCapabilityRequest } from './mobile-web-capability-execu
import { MobileWebCapabilityAuthorities } from './mobile-web-capability-authorities'
import type { MobileWebCapabilityBrokerOptions } from './mobile-web-capability-broker-options'
import { MobileWebBrokerMessageSender } from './mobile-web-broker-message-sender'
import { MobileWebBrokerReplayGuard } from './mobile-web-broker-replay-guard'
import { MobileWebBrokerReplayWindow } from './mobile-web-broker-replay-window'
import { rememberMobileWebBrokerRoute } from './mobile-web-broker-route-memory'
import { resolveMobileWebHostNavigationRoute } from './mobile-web-host-navigation-route'
import {
@@ -40,7 +40,7 @@ type PendingRequest = { operationKey: string; subscriptionId?: string; cancelled
export class MobileWebCapabilityBroker {
private readonly pending = new Map<string, PendingRequest>()
private readonly replay = new MobileWebBrokerReplayGuard()
private readonly replay = new MobileWebBrokerReplayWindow()
private readonly subscriptions: MobileWebCapabilitySubscriptions
private readonly terminalStreams: MobileWebTerminalStreams
private readonly speechAuthority: MobileWebSpeechAuthority
@@ -146,7 +146,7 @@ export class MobileWebCapabilityBroker {
)
}
private async handleRequest(request: PageRequest): Promise<void> {
if (!this.replay.acceptRequest(request.requestId, this.pending.has(request.requestId))) {
if (this.pending.has(request.requestId) || !this.replay.accept(request.requestId)) {
await this.messages.error(request.requestId, 'invalid_request', false)
return
}
@@ -158,10 +158,7 @@ export class MobileWebCapabilityBroker {
await this.messages.error(request.requestId, 'unsupported_capability', false)
return
}
if (
request.mode === 'subscription' &&
!this.replay.acceptSubscription(request.subscriptionId)
) {
if (request.mode === 'subscription' && !this.replay.accept(request.subscriptionId)) {
await this.messages.error(request.requestId, 'invalid_request', false)
return
}
@@ -1,33 +0,0 @@
import { describe, expect, it } from 'vitest'
import { MobileWebMessageReplayWindow } from './mobile-web-message-replay-window'
describe('mobile web message replay window', () => {
it('rejects recent IDs without permanently exhausting a long-lived session', () => {
const replay = new MobileWebMessageReplayWindow(3)
replay.remember('a')
replay.remember('b')
replay.remember('c')
expect(replay.has('a')).toBe(true)
replay.remember('d')
expect(replay.has('a')).toBe(false)
expect(replay.has('b')).toBe(true)
expect(replay.has('d')).toBe(true)
})
it('refreshes an existing ID and clears all authority on disposal', () => {
const replay = new MobileWebMessageReplayWindow(2)
replay.remember('a')
replay.remember('b')
replay.remember('a')
replay.remember('c')
expect(replay.has('a')).toBe(true)
expect(replay.has('b')).toBe(false)
replay.clear()
expect(replay.has('a')).toBe(false)
expect(replay.has('c')).toBe(false)
})
})
@@ -1,31 +0,0 @@
export class MobileWebMessageReplayWindow {
private readonly ids = new Set<string>()
constructor(private readonly limit: number) {
if (!Number.isInteger(limit) || limit < 1) {
throw new Error('mobile_web_replay_window_invalid')
}
}
has(id: string): boolean {
return this.ids.has(id)
}
remember(id: string): void {
if (this.ids.delete(id)) {
this.ids.add(id)
return
}
this.ids.add(id)
if (this.ids.size > this.limit) {
const oldest = this.ids.values().next().value
if (oldest !== undefined) {
this.ids.delete(oldest)
}
}
}
clear(): void {
this.ids.clear()
}
}