diff --git a/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx b/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx
index b148374e9dd..34562932177 100644
--- a/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx
+++ b/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx
@@ -55,6 +55,9 @@ vi.mock('../../modules/orca-mobile-web-shell/src', async () => {
parseMobileWebShellLoadState: loadState.parseMobileWebShellLoadState
}
})
+// The real bridge hook runs, so the props it owns are the ones the view is handed here; only the
+// client lookup is stubbed, because reaching it imports the Expo runtime this test does not have.
+vi.mock('../transport/client-context', () => ({ useHostClient: () => ({ client: null }) }))
vi.mock('./use-mobile-web-shell-session', () => ({
useMobileWebShellSession: () => ({
state: dependencies.state,
@@ -200,6 +203,17 @@ describe('the hybrid shell screen', () => {
expect(view.props.sessionId).toBe('session-one')
})
+ it('opens the bridge channel on a ready session and hands it a receiver', async () => {
+ const tree = await render(readyState('session-one'))
+ const view = byName(tree, 'ShellViewProbe')[0]
+ expect(view.props.bridgeEnabled).toBe(true)
+ expect(typeof view.props.onBridgeMessage).toBe('function')
+ // Delivered with no client behind it: there is no host to answer, and nothing throws.
+ await act(async () => {
+ view.props.onBridgeMessage({ nativeEvent: { json: '{"v":1,"type":"ready"}' } })
+ })
+ })
+
it('rebuilds the view rather than updating it when the session id changes', async () => {
const tree = await render(readyState('session-one'))
await update(tree, readyState('session-two'))
diff --git a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx
index 0d73b5a8adf..8acb8042eba 100644
--- a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx
+++ b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx
@@ -11,6 +11,7 @@ import type {
MobileWebShellFailureCause,
MobileWebShellSessionState
} from './mobile-web-shell-session-contract'
+import { useMobileWebShellBridge } from './use-mobile-web-shell-bridge'
import {
useMobileWebShellSession,
type MobileWebShellRuntime
@@ -123,6 +124,7 @@ export type MobileWebShellScreenProps = {
export function MobileWebShellScreen({ hostId, runtime }: MobileWebShellScreenProps) {
const insets = useSafeAreaInsets()
const { state, retry, reportShellFailure } = useMobileWebShellSession({ hostId, runtime })
+ const bridge = useMobileWebShellBridge({ hostId, session: state })
if (state.kind === 'wall') {
return
@@ -152,9 +154,12 @@ export function MobileWebShellScreen({ hostId, runtime }: MobileWebShellScreenPr
>
{
const parsed = parseMobileWebShellLoadState(event.nativeEvent)
if (parsed?.state === 'failed') {
diff --git a/mobile/src/mobile-web-shell/bridge-host-subscriptions.ts b/mobile/src/mobile-web-shell/bridge-host-subscriptions.ts
new file mode 100644
index 00000000000..b2019a2168f
--- /dev/null
+++ b/mobile/src/mobile-web-shell/bridge-host-subscriptions.ts
@@ -0,0 +1,153 @@
+import { BRIDGE_MAX_MESSAGE_BYTES, utf8ByteLength } from './bridge/bridge-caps'
+import { BRIDGE_PROTOCOL_VERSION, type BridgeHostMessage } from './bridge/bridge-envelope'
+import type { RpcClient } from '../transport/rpc-client'
+
+/** Derived, so an arm added to the envelope's closed list is a compile error here rather than a
+ * reason this module never sends. */
+export type BridgeEndReason = Extract['reason']
+
+/**
+ * Frames the page has not acked, per subscription. `postBridgeMessage` resolves on enqueue and
+ * proves nothing about delivery, so a page that has stopped reading is invisible until it stops
+ * acking: this window is the only evidence the shell gets, and without it a stalled page grows the
+ * native queue until the process dies.
+ */
+export const BRIDGE_MAX_UNACKED_FRAMES = 256
+export const BRIDGE_MAX_UNACKED_BYTES = 4 * 1024 * 1024
+
+type UnackedFrame = { seq: number; bytes: number }
+
+type OpenSubscription = {
+ unsubscribe: () => void
+ /** Last seq sent. Starts at 0 so `ack{seq:0}` is the honest "nothing yet". */
+ seq: number
+ unacked: UnackedFrame[]
+ unackedBytes: number
+}
+
+/**
+ * Every host subscription the page opened, and the backpressure window each one carries.
+ *
+ * Ending a stream is never silent. Dropping terminal bytes to keep a stream alive corrupts a
+ * transcript, which the reader cannot see; a stream that ends says so, and the page can resubscribe.
+ */
+export class BridgeHostSubscriptions {
+ private readonly open = new Map()
+
+ constructor(
+ private readonly options: {
+ client: RpcClient
+ /** Fire and forget: the host owns rejection logging, and no post proves delivery. */
+ post: (json: string) => void
+ }
+ ) {}
+
+ get size(): number {
+ return this.open.size
+ }
+
+ has(id: string): boolean {
+ return this.open.has(id)
+ }
+
+ /** Throws whatever `client.subscribe` throws; the caller answers the page with `error`. */
+ start(id: string, method: string, params: unknown): void {
+ const record: OpenSubscription = {
+ unsubscribe: () => undefined,
+ seq: 0,
+ unacked: [],
+ unackedBytes: 0
+ }
+ this.open.set(id, record)
+ let unsubscribe: () => void
+ try {
+ unsubscribe = this.options.client.subscribe(method, params, (payload) =>
+ this.deliver(id, payload)
+ )
+ } catch (error) {
+ this.open.delete(id)
+ throw error
+ }
+ // A stream that emitted and overflowed inside `subscribe` is already retired, and its
+ // unsubscribe arrived too late to be stored: calling it here is what keeps it from leaking.
+ if (this.open.get(id) === record) {
+ record.unsubscribe = unsubscribe
+ } else {
+ unsubscribe()
+ }
+ }
+
+ ack(id: string, seq: number): void {
+ const record = this.open.get(id)
+ if (record === undefined) {
+ return
+ }
+ let acked = 0
+ for (const frame of record.unacked) {
+ if (frame.seq > seq) {
+ break
+ }
+ record.unackedBytes -= frame.bytes
+ acked += 1
+ }
+ record.unacked.splice(0, acked)
+ }
+
+ /** `null` tears the stream down without telling the page, for a page that already said goodbye. */
+ cancel(id: string, reason: BridgeEndReason | null): void {
+ const record = this.open.get(id)
+ if (record === undefined) {
+ return
+ }
+ this.open.delete(id)
+ try {
+ record.unsubscribe()
+ } catch {
+ // A client whose unsubscribe throws must not keep the rest of the ledger open.
+ }
+ if (reason !== null) {
+ this.options.post(JSON.stringify({ v: BRIDGE_PROTOCOL_VERSION, type: 'end', id, reason }))
+ }
+ }
+
+ // Deleting the visited entry is what a `Map` iterator is specified to survive, so the ledger is
+ // walked in place rather than copied.
+ closeAll(reason: BridgeEndReason | null): void {
+ for (const id of this.open.keys()) {
+ this.cancel(id, reason)
+ }
+ }
+
+ private deliver(id: string, payload: unknown): void {
+ const record = this.open.get(id)
+ if (record === undefined) {
+ return
+ }
+ const seq = record.seq + 1
+ let json: string
+ try {
+ json = JSON.stringify({ v: BRIDGE_PROTOCOL_VERSION, type: 'event', id, seq, payload })
+ } catch {
+ // Nothing off the wire is cyclic, but a stream that cannot be serialized ends rather than
+ // silently skipping the frame the reader is missing.
+ this.cancel(id, 'closed')
+ return
+ }
+ const bytes = utf8ByteLength(json)
+ // An event is never chunked, so one over the frame cap would be refused by the page's reader
+ // and leave a hole nothing reports. Over the window, or too big to carry: same verdict, because
+ // both mean this stream cannot be delivered whole.
+ if (
+ bytes > BRIDGE_MAX_MESSAGE_BYTES ||
+ record.unacked.length >= BRIDGE_MAX_UNACKED_FRAMES ||
+ record.unackedBytes + bytes > BRIDGE_MAX_UNACKED_BYTES
+ ) {
+ this.cancel(id, 'overflow')
+ return
+ }
+ record.seq = seq
+ record.unacked.push({ seq, bytes })
+ record.unackedBytes += bytes
+ this.options.post(json)
+ }
+}
diff --git a/mobile/src/mobile-web-shell/bridge-host-test-fakes.ts b/mobile/src/mobile-web-shell/bridge-host-test-fakes.ts
new file mode 100644
index 00000000000..d3c558cd675
--- /dev/null
+++ b/mobile/src/mobile-web-shell/bridge-host-test-fakes.ts
@@ -0,0 +1,103 @@
+import type { RpcClient, SendRequestOptions } from '../transport/rpc-client'
+import type { ConnectionState, RpcResponse } from '../transport/types'
+import { BRIDGE_PROTOCOL_VERSION } from './bridge/bridge-envelope'
+
+export type SentRequest = {
+ method: string
+ /** The arity the host used, which the golden recorder reads as part of the call. */
+ args: readonly unknown[]
+ resolve: (response: RpcResponse) => void
+ reject: (error: unknown) => void
+}
+
+export type OpenStream = {
+ method: string
+ params: unknown
+ emit: (payload: unknown) => void
+ unsubscribes: number
+}
+
+export type FakeRpcClient = RpcClient & {
+ readonly requests: SentRequest[]
+ readonly streams: OpenStream[]
+ readonly foregroundCalls: (readonly unknown[])[]
+ readonly viewports: { terminal: string; cols: number; rows: number }[]
+ pushState: (state: ConnectionState) => void
+ stateListeners: () => number
+}
+
+type ClientGetters = Partial<
+ Pick<
+ RpcClient,
+ 'getState' | 'getReconnectAttempt' | 'getLastConnectedAt' | 'getLastInboundAt' | 'getGeneration'
+ >
+>
+
+/** Every call the host can make, recorded; nothing settles until the test says so. */
+export function createFakeRpcClient(getters: ClientGetters = {}): FakeRpcClient {
+ const requests: SentRequest[] = []
+ const streams: OpenStream[] = []
+ const foregroundCalls: (readonly unknown[])[] = []
+ const viewports: { terminal: string; cols: number; rows: number }[] = []
+ const listeners = new Set<(state: ConnectionState) => void>()
+ return {
+ sendRequest: (...args: [string, unknown?, SendRequestOptions?]) =>
+ new Promise((resolve, reject) => {
+ requests.push({ method: args[0], args, resolve, reject })
+ }),
+ subscribe: (method, params, onData) => {
+ const stream: OpenStream = { method, params, emit: onData, unsubscribes: 0 }
+ streams.push(stream)
+ return () => {
+ stream.unsubscribes += 1
+ }
+ },
+ updateTerminalSubscriptionViewport: (terminal, viewport) => {
+ viewports.push({ terminal, cols: viewport.cols, rows: viewport.rows })
+ },
+ getState: () => 'connected',
+ getReconnectAttempt: () => 0,
+ getLastConnectedAt: () => null,
+ onStateChange: (listener) => {
+ listeners.add(listener)
+ return () => {
+ listeners.delete(listener)
+ }
+ },
+ notifyForeground: (...args: Parameters) => {
+ foregroundCalls.push(args)
+ },
+ close: () => undefined,
+ requests,
+ streams,
+ foregroundCalls,
+ viewports,
+ pushState: (state) => {
+ for (const listener of listeners) {
+ listener(state)
+ }
+ },
+ stateListeners: () => listeners.size,
+ ...getters
+ }
+}
+
+/** 22 chars of base64url, which is what the envelope's id pattern accepts. */
+export function bridgeId(index: number): string {
+ return index.toString(36).padStart(22, 'a')
+}
+
+export function clientFrame(fields: Record): string {
+ return JSON.stringify({ v: BRIDGE_PROTOCOL_VERSION, ...fields })
+}
+
+export function rpcSuccess(id: string, result: unknown): RpcResponse {
+ return { id, ok: true, result, _meta: { runtimeId: 'runtime-a' } }
+}
+
+/** Two microtask turns: a settled `sendRequest` posts from a `then`, and a post rejection is
+ * reported from a `catch` chained onto it. */
+export async function flushBridge(): Promise {
+ await Promise.resolve()
+ await Promise.resolve()
+}
diff --git a/mobile/src/mobile-web-shell/bridge-host.test.ts b/mobile/src/mobile-web-shell/bridge-host.test.ts
new file mode 100644
index 00000000000..e3badea4f87
--- /dev/null
+++ b/mobile/src/mobile-web-shell/bridge-host.test.ts
@@ -0,0 +1,735 @@
+import { describe, expect, it } from 'vitest'
+import type { RpcResponse } from '../transport/types'
+import { BRIDGE_MAX_UNACKED_BYTES, BRIDGE_MAX_UNACKED_FRAMES } from './bridge-host-subscriptions'
+import {
+ bridgeId,
+ clientFrame,
+ createFakeRpcClient,
+ flushBridge,
+ rpcSuccess,
+ type FakeRpcClient
+} from './bridge-host-test-fakes'
+import { createBridgeHost, type BridgeHost, type BridgeHostDiagnostic } from './bridge-host'
+import {
+ BRIDGE_MAX_MESSAGE_BYTES,
+ BRIDGE_MAX_PENDING_REQUESTS,
+ BRIDGE_MAX_REPLY_BYTES,
+ BRIDGE_MAX_SUBSCRIPTIONS
+} from './bridge/bridge-caps'
+import { readBridgeHostMessage, type BridgeHostMessage } from './bridge/bridge-envelope'
+import { BridgeReplyAssembler } from './bridge/bridge-reply-chunking'
+
+const ID = bridgeId(1)
+const OTHER = bridgeId(2)
+
+type Harness = {
+ host: BridgeHost
+ client: FakeRpcClient
+ posted: string[]
+ diagnostics: BridgeHostDiagnostic[]
+ frames: () => BridgeHostMessage[]
+ last: () => BridgeHostMessage
+}
+
+function harness(
+ options: { client?: FakeRpcClient; post?: (json: string) => Promise } = {}
+): Harness {
+ const client = options.client ?? createFakeRpcClient()
+ const posted: string[] = []
+ const diagnostics: BridgeHostDiagnostic[] = []
+ const host = createBridgeHost({
+ client,
+ post: (json) => {
+ posted.push(json)
+ return options.post?.(json) ?? Promise.resolve()
+ },
+ buildId: 'build-a',
+ sessionId: 'session-a',
+ onDiagnostic: (diagnostic) => diagnostics.push(diagnostic)
+ })
+ // Read back through the page's own reader: a frame the host sends that the page would refuse is
+ // a frame that never arrives, and this is the only place both halves meet in one test.
+ const frames = (): BridgeHostMessage[] =>
+ posted.map((json) => {
+ const read = readBridgeHostMessage(json)
+ if (!read.ok) {
+ throw new Error(`the page would refuse this frame: ${read.refusal}`)
+ }
+ return read.message
+ })
+ return {
+ host,
+ client,
+ posted,
+ diagnostics,
+ frames,
+ last: () => {
+ const all = frames()
+ const tail = all.at(-1)
+ if (tail === undefined) {
+ throw new Error('nothing was posted')
+ }
+ return tail
+ }
+ }
+}
+
+function subscribeFrame(id: string, method = 'terminal.subscribe'): string {
+ return clientFrame({ type: 'subscribe', id, method, params: { terminal: 't' } })
+}
+
+describe('init and state', () => {
+ it('answers ready with the getters, the caps it enforces, and no native grant', () => {
+ const client = createFakeRpcClient({
+ getState: () => 'reconnecting',
+ getReconnectAttempt: () => 3,
+ getLastConnectedAt: () => 1_700_000_000_000,
+ getLastInboundAt: () => 1_700_000_000_500,
+ getGeneration: () => 7
+ })
+ const bridge = harness({ client })
+ bridge.host.receive(clientFrame({ type: 'ready' }))
+ expect(bridge.last()).toEqual({
+ v: 1,
+ type: 'init',
+ sessionId: 'session-a',
+ buildId: 'build-a',
+ connection: {
+ state: 'reconnecting',
+ reconnectAttempt: 3,
+ lastConnectedAt: 1_700_000_000_000,
+ lastInboundAt: 1_700_000_000_500,
+ generation: 7
+ },
+ grants: {
+ rpc: {
+ maxPendingRequests: BRIDGE_MAX_PENDING_REQUESTS,
+ maxSubscriptions: BRIDGE_MAX_SUBSCRIPTIONS
+ },
+ native: []
+ }
+ })
+ })
+
+ it('reports a client without the optional getters as null rather than omitting the field', () => {
+ const bridge = harness()
+ bridge.host.receive(clientFrame({ type: 'ready' }))
+ const init = bridge.last()
+ expect(init.type === 'init' && init.connection).toEqual({
+ state: 'connected',
+ reconnectAttempt: 0,
+ lastConnectedAt: null,
+ lastInboundAt: null,
+ generation: null
+ })
+ })
+
+ it('re-answers ready, which is how a page that missed a state frame recovers', () => {
+ const bridge = harness()
+ bridge.host.receive(clientFrame({ type: 'ready' }))
+ bridge.host.receive(clientFrame({ type: 'ready' }))
+ expect(bridge.frames().filter((frame) => frame.type === 'init')).toHaveLength(2)
+ })
+
+ it('pushes the event state, not the getter a listener can outrun', () => {
+ const bridge = harness()
+ bridge.client.pushState('disconnected')
+ const pushed = bridge.last()
+ expect(pushed.type === 'state' && pushed.connection.state).toBe('disconnected')
+ })
+
+ it('drops the state listener on dispose', () => {
+ const bridge = harness()
+ expect(bridge.client.stateListeners()).toBe(1)
+ bridge.host.dispose()
+ expect(bridge.client.stateListeners()).toBe(0)
+ })
+})
+
+describe('requests', () => {
+ it('replays the arity the page used', () => {
+ const bridge = harness()
+ bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'status.get' }))
+ bridge.host.receive(
+ clientFrame({ type: 'request', id: OTHER, method: 'status.get', params: undefined })
+ )
+ bridge.host.receive(
+ clientFrame({ type: 'request', id: bridgeId(3), method: 'status.get', params: { a: 1 } })
+ )
+ bridge.host.receive(
+ clientFrame({
+ type: 'request',
+ id: bridgeId(4),
+ method: 'status.get',
+ options: { timeoutMs: 50 }
+ })
+ )
+ expect(bridge.client.requests.map((request) => request.args)).toEqual([
+ ['status.get'],
+ ['status.get'],
+ ['status.get', { a: 1 }],
+ ['status.get', undefined, { timeoutMs: 50 }]
+ ])
+ })
+
+ it('carries a host failure through as data, _meta and error.data included', async () => {
+ const bridge = harness()
+ bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'status.get' }))
+ const failure: RpcResponse = {
+ id: 'wire-1',
+ ok: false,
+ error: { code: 'not_found', message: 'gone', data: { path: '/x' } },
+ _meta: { runtimeId: 'runtime-a' }
+ }
+ bridge.client.requests[0]?.resolve(failure)
+ await flushBridge()
+ expect(bridge.last()).toEqual({ v: 1, type: 'reply', id: ID, payload: failure })
+ })
+
+ it('turns a rejection into the five-field capture, delivery mark and cause included', async () => {
+ const bridge = harness()
+ bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'status.get' }))
+ const cause = new Error('socket closed')
+ const error = new TypeError('send failed')
+ error.cause = cause
+ bridge.client.requests[0]?.reject(error)
+ await flushBridge()
+ expect(bridge.last()).toEqual({
+ v: 1,
+ type: 'error',
+ id: ID,
+ error: {
+ category: 'TypeError',
+ message: 'send failed',
+ isRpcDeliveryUnknown: false,
+ cause: { category: 'Error', message: 'socket closed', isRpcDeliveryUnknown: false }
+ }
+ })
+ })
+
+ it('answers a synchronous throw from the client and frees the slot', () => {
+ const client = createFakeRpcClient()
+ const bridge = harness({
+ client: {
+ ...client,
+ sendRequest: () => {
+ throw new Error('no socket')
+ }
+ }
+ })
+ bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'status.get' }))
+ bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'status.get' }))
+ const errors = bridge.frames().filter((frame) => frame.type === 'error')
+ expect(errors).toHaveLength(2)
+ expect(
+ errors.every((frame) => frame.type === 'error' && frame.error.category === 'Error')
+ ).toBe(true)
+ })
+
+ it('refuses an id already in flight without settling the exchange it collided with', async () => {
+ const bridge = harness()
+ bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'status.get' }))
+ bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'other.get' }))
+ expect(bridge.client.requests).toHaveLength(1)
+ expect(bridge.last().type).toBe('error')
+ bridge.client.requests[0]?.resolve(rpcSuccess('wire-1', 'ok'))
+ await flushBridge()
+ expect(bridge.last()).toEqual({
+ v: 1,
+ type: 'reply',
+ id: ID,
+ payload: rpcSuccess('wire-1', 'ok')
+ })
+ })
+
+ it('refuses a subscription id as a request id, because one ledger answers for both', () => {
+ const bridge = harness()
+ bridge.host.receive(subscribeFrame(ID))
+ bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'status.get' }))
+ expect(bridge.client.requests).toHaveLength(0)
+ expect(bridge.last().type).toBe('error')
+ })
+
+ it('admits exactly the in-flight cap and refuses the next', () => {
+ const bridge = harness()
+ for (let index = 0; index < BRIDGE_MAX_PENDING_REQUESTS; index += 1) {
+ bridge.host.receive(
+ clientFrame({ type: 'request', id: bridgeId(index), method: 'status.get' })
+ )
+ }
+ expect(bridge.client.requests).toHaveLength(BRIDGE_MAX_PENDING_REQUESTS)
+ expect(bridge.posted).toHaveLength(0)
+ bridge.host.receive(
+ clientFrame({ type: 'request', id: bridgeId(BRIDGE_MAX_PENDING_REQUESTS), method: 'x.get' })
+ )
+ expect(bridge.client.requests).toHaveLength(BRIDGE_MAX_PENDING_REQUESTS)
+ expect(bridge.last().type).toBe('error')
+ })
+
+ it('reopens a slot when a request settles', async () => {
+ const bridge = harness()
+ for (let index = 0; index < BRIDGE_MAX_PENDING_REQUESTS; index += 1) {
+ bridge.host.receive(
+ clientFrame({ type: 'request', id: bridgeId(index), method: 'status.get' })
+ )
+ }
+ bridge.client.requests[0]?.resolve(rpcSuccess('wire-1', 'ok'))
+ await flushBridge()
+ bridge.host.receive(
+ clientFrame({ type: 'request', id: bridgeId(BRIDGE_MAX_PENDING_REQUESTS), method: 'x.get' })
+ )
+ expect(bridge.client.requests).toHaveLength(BRIDGE_MAX_PENDING_REQUESTS + 1)
+ })
+
+ it('holds the cap against a page that closes between batches', async () => {
+ const bridge = harness()
+ const fill = (offset: number): void => {
+ for (let index = 0; index < BRIDGE_MAX_PENDING_REQUESTS; index += 1) {
+ bridge.host.receive(
+ clientFrame({ type: 'request', id: bridgeId(offset + index), method: 'status.get' })
+ )
+ }
+ }
+ fill(0)
+ // `close` empties the page's ledger, but the desktop is still running all 64 and `sendRequest`
+ // has no cancel: counting the ledger would hand the cap over again to the next document.
+ bridge.host.receive(clientFrame({ type: 'close' }))
+ bridge.host.receive(clientFrame({ type: 'ready' }))
+ fill(100)
+ expect(bridge.client.requests).toHaveLength(BRIDGE_MAX_PENDING_REQUESTS)
+ expect(bridge.frames().filter((frame) => frame.type === 'error')).toHaveLength(
+ BRIDGE_MAX_PENDING_REQUESTS
+ )
+ for (const request of bridge.client.requests) {
+ request.resolve(rpcSuccess('wire-1', 'ok'))
+ }
+ await flushBridge()
+ fill(200)
+ expect(bridge.client.requests).toHaveLength(BRIDGE_MAX_PENDING_REQUESTS * 2)
+ })
+
+ it('stops answering a cancelled request without pretending the desktop stopped running it', async () => {
+ const bridge = harness()
+ bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'status.get' }))
+ bridge.host.receive(clientFrame({ type: 'cancel', id: ID, target: 'request' }))
+ bridge.client.requests[0]?.resolve(rpcSuccess('wire-1', 'ok'))
+ await flushBridge()
+ expect(bridge.posted).toHaveLength(0)
+ })
+})
+
+describe('replies too big for one frame', () => {
+ it('chunks and reassembles to the same payload', async () => {
+ const bridge = harness()
+ bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'worktree.list' }))
+ const payload = rpcSuccess('wire-1', 'y'.repeat(BRIDGE_MAX_MESSAGE_BYTES * 2))
+ bridge.client.requests[0]?.resolve(payload)
+ await flushBridge()
+ const replies = bridge.frames()
+ expect(replies.length).toBeGreaterThan(1)
+ const assembler = new BridgeReplyAssembler()
+ const assembled = replies.map((frame) =>
+ frame.type === 'reply' ? assembler.accept(frame) : { status: 'pending' as const }
+ )
+ expect(assembled.at(-1)).toEqual({ status: 'complete', payload })
+ })
+
+ it('aborts the request over the reply ceiling rather than truncating an answer', async () => {
+ const bridge = harness()
+ bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'worktree.list' }))
+ bridge.client.requests[0]?.resolve(rpcSuccess('wire-1', 'y'.repeat(BRIDGE_MAX_REPLY_BYTES + 1)))
+ await flushBridge()
+ const frame = bridge.last()
+ expect(frame.type === 'error' && frame.error).toMatchObject({
+ category: 'BridgeReplyUndeliverableError',
+ isRpcDeliveryUnknown: false
+ })
+ })
+})
+
+describe('subscriptions', () => {
+ it('forwards with the arity the recorder reads and streams events from seq 1', () => {
+ const bridge = harness()
+ bridge.host.receive(subscribeFrame(ID))
+ expect(bridge.client.streams[0]?.method).toBe('terminal.subscribe')
+ bridge.client.streams[0]?.emit({ chunk: 'a' })
+ bridge.client.streams[0]?.emit({ chunk: 'b' })
+ expect(bridge.frames()).toEqual([
+ { v: 1, type: 'event', id: ID, seq: 1, payload: { chunk: 'a' } },
+ { v: 1, type: 'event', id: ID, seq: 2, payload: { chunk: 'b' } }
+ ])
+ })
+
+ it('admits exactly the subscription cap and refuses the next', () => {
+ const bridge = harness()
+ for (let index = 0; index < BRIDGE_MAX_SUBSCRIPTIONS; index += 1) {
+ bridge.host.receive(subscribeFrame(bridgeId(index)))
+ }
+ expect(bridge.client.streams).toHaveLength(BRIDGE_MAX_SUBSCRIPTIONS)
+ expect(bridge.posted).toHaveLength(0)
+ bridge.host.receive(subscribeFrame(bridgeId(BRIDGE_MAX_SUBSCRIPTIONS)))
+ expect(bridge.client.streams).toHaveLength(BRIDGE_MAX_SUBSCRIPTIONS)
+ expect(bridge.last().type).toBe('error')
+ })
+
+ it('reopens a slot when a stream is cancelled', () => {
+ const bridge = harness()
+ for (let index = 0; index < BRIDGE_MAX_SUBSCRIPTIONS; index += 1) {
+ bridge.host.receive(subscribeFrame(bridgeId(index)))
+ }
+ bridge.host.receive(clientFrame({ type: 'cancel', id: bridgeId(0), target: 'subscription' }))
+ bridge.host.receive(subscribeFrame(bridgeId(BRIDGE_MAX_SUBSCRIPTIONS)))
+ expect(bridge.client.streams).toHaveLength(BRIDGE_MAX_SUBSCRIPTIONS + 1)
+ })
+
+ it('unsubscribes on cancel, says so, and delivers nothing after', () => {
+ const bridge = harness()
+ bridge.host.receive(subscribeFrame(ID))
+ bridge.client.streams[0]?.emit({ chunk: 'a' })
+ bridge.host.receive(clientFrame({ type: 'cancel', id: ID, target: 'subscription' }))
+ expect(bridge.client.streams[0]?.unsubscribes).toBe(1)
+ expect(bridge.last()).toEqual({ v: 1, type: 'end', id: ID, reason: 'unsubscribed' })
+ bridge.client.streams[0]?.emit({ chunk: 'b' })
+ expect(bridge.frames().filter((frame) => frame.type === 'event')).toHaveLength(1)
+ })
+
+ it('answers a client whose subscribe throws and holds no slot', () => {
+ const client = createFakeRpcClient()
+ const bridge = harness({
+ client: {
+ ...client,
+ subscribe: () => {
+ throw new Error('no socket')
+ }
+ }
+ })
+ bridge.host.receive(subscribeFrame(ID))
+ expect(bridge.last().type).toBe('error')
+ bridge.host.receive(subscribeFrame(ID))
+ expect(bridge.frames()).toHaveLength(2)
+ })
+
+ it('unsubscribes a stream that overflowed inside subscribe, exactly once', () => {
+ const client = createFakeRpcClient()
+ let unsubscribes = 0
+ const bridge = harness({
+ client: {
+ ...client,
+ subscribe: (_method, _params, onData) => {
+ onData('z'.repeat(BRIDGE_MAX_MESSAGE_BYTES))
+ return () => {
+ unsubscribes += 1
+ }
+ }
+ }
+ })
+ bridge.host.receive(subscribeFrame(ID))
+ expect(bridge.frames()).toEqual([{ v: 1, type: 'end', id: ID, reason: 'overflow' }])
+ // The stream was already retired when its unsubscribe arrived, so storing it on the record
+ // would leak the client's stream with nothing left to read it.
+ expect(unsubscribes).toBe(1)
+ })
+})
+
+describe('backpressure', () => {
+ function fill(bridge: Harness, frames: number): void {
+ for (let index = 0; index < frames; index += 1) {
+ bridge.client.streams[0]?.emit({ n: index })
+ }
+ }
+
+ it('sends exactly the unacked frame window and then ends with overflow', () => {
+ const bridge = harness()
+ bridge.host.receive(subscribeFrame(ID))
+ fill(bridge, BRIDGE_MAX_UNACKED_FRAMES)
+ expect(bridge.frames().filter((frame) => frame.type === 'event')).toHaveLength(
+ BRIDGE_MAX_UNACKED_FRAMES
+ )
+ fill(bridge, 1)
+ expect(bridge.last()).toEqual({ v: 1, type: 'end', id: ID, reason: 'overflow' })
+ expect(bridge.client.streams[0]?.unsubscribes).toBe(1)
+ })
+
+ it('reopens the window on ack', () => {
+ const bridge = harness()
+ bridge.host.receive(subscribeFrame(ID))
+ fill(bridge, BRIDGE_MAX_UNACKED_FRAMES)
+ bridge.host.receive(clientFrame({ type: 'ack', id: ID, seq: BRIDGE_MAX_UNACKED_FRAMES }))
+ fill(bridge, 1)
+ const events = bridge.frames().filter((frame) => frame.type === 'event')
+ expect(events).toHaveLength(BRIDGE_MAX_UNACKED_FRAMES + 1)
+ expect(events.at(-1)).toMatchObject({ seq: BRIDGE_MAX_UNACKED_FRAMES + 1 })
+ })
+
+ it('acks only up to the seq it was given', () => {
+ const bridge = harness()
+ bridge.host.receive(subscribeFrame(ID))
+ fill(bridge, BRIDGE_MAX_UNACKED_FRAMES)
+ bridge.host.receive(clientFrame({ type: 'ack', id: ID, seq: 1 }))
+ fill(bridge, 1)
+ expect(bridge.frames().filter((frame) => frame.type === 'event')).toHaveLength(
+ BRIDGE_MAX_UNACKED_FRAMES + 1
+ )
+ fill(bridge, 1)
+ expect(bridge.last()).toEqual({ v: 1, type: 'end', id: ID, reason: 'overflow' })
+ })
+
+ it('ends on the unacked byte window well before the frame window is reached', () => {
+ const bridge = harness()
+ bridge.host.receive(subscribeFrame(ID))
+ const chunk = 'z'.repeat(BRIDGE_MAX_MESSAGE_BYTES - 1024)
+ const ended = (): boolean => (bridge.posted.at(-1) ?? '').includes('"type":"end"')
+ for (let index = 0; index < BRIDGE_MAX_UNACKED_FRAMES && !ended(); index += 1) {
+ bridge.client.streams[0]?.emit(chunk)
+ }
+ const events = bridge.posted.length - 1
+ expect(events).toBeLessThan(BRIDGE_MAX_UNACKED_FRAMES)
+ const eventBytes = bridge.posted
+ .slice(0, events)
+ .reduce((total, json) => total + json.length, 0)
+ // Brackets the window: everything sent fits under it, and one more frame would not have.
+ expect(eventBytes).toBeLessThanOrEqual(BRIDGE_MAX_UNACKED_BYTES)
+ expect(eventBytes + chunk.length).toBeGreaterThan(BRIDGE_MAX_UNACKED_BYTES)
+ expect(bridge.last()).toEqual({ v: 1, type: 'end', id: ID, reason: 'overflow' })
+ })
+
+ it('reopens the byte window on ack, not just the frame window', () => {
+ const bridge = harness()
+ bridge.host.receive(subscribeFrame(ID))
+ const chunk = 'z'.repeat(BRIDGE_MAX_MESSAGE_BYTES - 1024)
+ // What fits under the byte window, which leaves the next frame of this size to overflow it.
+ const fits = Math.floor(BRIDGE_MAX_UNACKED_BYTES / (chunk.length + 128))
+ const events = (): BridgeHostMessage[] => bridge.frames().filter((f) => f.type === 'event')
+ const emit = (times: number): void => {
+ for (let index = 0; index < times; index += 1) {
+ bridge.client.streams[0]?.emit(chunk)
+ }
+ }
+ emit(fits)
+ expect(events()).toHaveLength(fits)
+ bridge.host.receive(clientFrame({ type: 'ack', id: ID, seq: fits }))
+ emit(fits)
+ // The frame window is nowhere near full, so releasing the acked bytes is the only thing that
+ // can let the second batch through.
+ expect(fits * 2).toBeLessThan(BRIDGE_MAX_UNACKED_FRAMES)
+ expect(events()).toHaveLength(fits * 2)
+ expect(bridge.frames().some((frame) => frame.type === 'end')).toBe(false)
+ })
+
+ it('ends rather than posting an event the page would refuse as oversized', () => {
+ const bridge = harness()
+ bridge.host.receive(subscribeFrame(ID))
+ bridge.client.streams[0]?.emit('z'.repeat(BRIDGE_MAX_MESSAGE_BYTES))
+ expect(bridge.last()).toEqual({ v: 1, type: 'end', id: ID, reason: 'overflow' })
+ })
+
+ it('keeps each stream on its own window', () => {
+ const bridge = harness()
+ bridge.host.receive(subscribeFrame(ID))
+ bridge.host.receive(subscribeFrame(OTHER))
+ for (let index = 0; index <= BRIDGE_MAX_UNACKED_FRAMES; index += 1) {
+ bridge.client.streams[0]?.emit({ n: index })
+ }
+ bridge.client.streams[1]?.emit({ n: 0 })
+ expect(bridge.last()).toEqual({ v: 1, type: 'event', id: OTHER, seq: 1, payload: { n: 0 } })
+ })
+})
+
+describe('teardown', () => {
+ it('rejects every pending as delivery-unknown, ends every stream, and refuses later frames', async () => {
+ const bridge = harness()
+ bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'status.get' }))
+ bridge.host.receive(subscribeFrame(OTHER))
+ bridge.host.dispose()
+ expect(bridge.frames()).toEqual([
+ {
+ v: 1,
+ type: 'error',
+ id: ID,
+ error: {
+ category: 'BridgeHostDisposedError',
+ message: 'the page bridge was torn down before this request answered',
+ isRpcDeliveryUnknown: true
+ }
+ },
+ { v: 1, type: 'end', id: OTHER, reason: 'closed' }
+ ])
+ expect(bridge.client.streams[0]?.unsubscribes).toBe(1)
+ bridge.client.requests[0]?.resolve(rpcSuccess('wire-1', 'ok'))
+ bridge.client.streams[0]?.emit({ chunk: 'a' })
+ // Nothing reaches the client either: a page that outlived its host is a page the fence is for.
+ bridge.host.receive(clientFrame({ type: 'request', id: bridgeId(9), method: 'status.get' }))
+ bridge.host.receive(subscribeFrame(bridgeId(10)))
+ bridge.host.receive(clientFrame({ type: 'notify', name: 'foreground' }))
+ bridge.host.receive(clientFrame({ type: 'ready' }))
+ await flushBridge()
+ expect(bridge.frames()).toHaveLength(2)
+ expect(bridge.client.requests).toHaveLength(1)
+ expect(bridge.client.streams).toHaveLength(1)
+ expect(bridge.client.foregroundCalls).toEqual([])
+ // A view still posting into a disposed host is a leak, and the diagnostic is how it is found.
+ expect(bridge.diagnostics).toEqual(
+ Array.from({ length: 4 }, () => ({ kind: 'frame-after-dispose' }))
+ )
+ })
+
+ it('is idempotent', () => {
+ const bridge = harness()
+ bridge.host.receive(subscribeFrame(ID))
+ bridge.host.dispose()
+ bridge.host.dispose()
+ expect(bridge.frames()).toHaveLength(1)
+ expect(bridge.client.streams[0]?.unsubscribes).toBe(1)
+ })
+
+ it('settles what the page owned on close without answering a page that said goodbye', async () => {
+ const bridge = harness()
+ bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'status.get' }))
+ bridge.host.receive(subscribeFrame(OTHER))
+ bridge.host.receive(clientFrame({ type: 'close' }))
+ expect(bridge.posted).toHaveLength(0)
+ expect(bridge.client.streams[0]?.unsubscribes).toBe(1)
+ bridge.client.requests[0]?.resolve(rpcSuccess('wire-1', 'ok'))
+ bridge.client.streams[0]?.emit({ chunk: 'a' })
+ await flushBridge()
+ expect(bridge.posted).toHaveLength(0)
+ })
+
+ it('answers the document that loads in after a close, rather than latching shut', () => {
+ const bridge = harness()
+ bridge.host.receive(clientFrame({ type: 'close' }))
+ // The next page shares this host, and a host that had shut itself would leave its `ready`
+ // retrying forever with nothing posted and nothing logged.
+ bridge.host.receive(clientFrame({ type: 'ready' }))
+ expect(bridge.last().type).toBe('init')
+ expect(bridge.client.stateListeners()).toBe(1)
+ bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'status.get' }))
+ expect(bridge.client.requests).toHaveLength(1)
+ // Full service, not just an answered `ready`: the state fan-out reaches this document too.
+ bridge.client.pushState('reconnecting')
+ expect(bridge.last()).toMatchObject({ type: 'state', connection: { state: 'reconnecting' } })
+ expect(bridge.diagnostics).toEqual([])
+ })
+
+ it('forwards no straggler from the document that said goodbye', () => {
+ const bridge = harness()
+ bridge.host.receive(clientFrame({ type: 'close' }))
+ // Frames the closed document posted before it went away. Forwarding one now would answer it
+ // into whichever document loads in next.
+ bridge.host.receive(clientFrame({ type: 'request', id: ID, method: 'status.get' }))
+ bridge.host.receive(subscribeFrame(OTHER))
+ bridge.host.receive(clientFrame({ type: 'notify', name: 'foreground' }))
+ expect(bridge.client.requests).toHaveLength(0)
+ expect(bridge.client.streams).toHaveLength(0)
+ expect(bridge.client.foregroundCalls).toEqual([])
+ expect(bridge.posted).toHaveLength(0)
+ expect(bridge.diagnostics).toEqual(
+ Array.from({ length: 3 }, () => ({ kind: 'frame-after-close' }))
+ )
+ })
+
+ it('posts nothing into a view that belongs to no document yet', () => {
+ const bridge = harness()
+ bridge.host.receive(clientFrame({ type: 'close' }))
+ // The client keeps running between documents, and this listener is still attached: a `state`
+ // posted now arrives in the replacement document before its own `init`.
+ bridge.client.pushState('reconnecting')
+ bridge.client.pushState('connected')
+ expect(bridge.posted).toHaveLength(0)
+ expect(bridge.diagnostics).toEqual([])
+ })
+})
+
+describe('notifications, refusals and the fence', () => {
+ it('forwards foreground with the arity the page used, and the viewport whole', () => {
+ const bridge = harness()
+ bridge.host.receive(clientFrame({ type: 'notify', name: 'foreground' }))
+ bridge.host.receive(clientFrame({ type: 'notify', name: 'foreground', reason: 'app-resume' }))
+ bridge.host.receive(
+ clientFrame({ type: 'notify', name: 'terminalViewport', terminal: 't1', cols: 80, rows: 24 })
+ )
+ expect(bridge.client.foregroundCalls).toEqual([[], ['app-resume']])
+ expect(bridge.client.viewports).toEqual([{ terminal: 't1', cols: 80, rows: 24 }])
+ })
+
+ it('reports a refused frame and forwards nothing from it', () => {
+ const bridge = harness()
+ bridge.host.receive('{"v":1,"type":')
+ bridge.host.receive(clientFrame({ type: 'request', id: 'short', method: 'x' }))
+ expect(bridge.diagnostics).toEqual([
+ { kind: 'refused', refusal: 'malformed-json' },
+ { kind: 'refused', refusal: 'unrecognised-message' }
+ ])
+ expect(bridge.client.requests).toHaveLength(0)
+ })
+
+ it('reports a client that throws on a notify once per session, and keeps reading', () => {
+ const client = createFakeRpcClient()
+ const failure = new Error('no client')
+ const bridge = harness({
+ client: {
+ ...client,
+ notifyForeground: () => {
+ throw failure
+ },
+ updateTerminalSubscriptionViewport: () => {
+ throw failure
+ }
+ }
+ })
+ // The page's frame arrives on a native event handler, and a throw that escapes this arm takes
+ // that handler down with it.
+ bridge.host.receive(clientFrame({ type: 'notify', name: 'foreground' }))
+ bridge.host.receive(
+ clientFrame({ type: 'notify', name: 'terminalViewport', terminal: 't1', cols: 80, rows: 24 })
+ )
+ expect(bridge.diagnostics).toEqual([{ kind: 'notify-failed', error: failure }])
+ bridge.host.receive(clientFrame({ type: 'ready' }))
+ expect(bridge.last().type).toBe('init')
+ })
+
+ it('reports a post that throws instead of rejecting, and does not take the sender down', () => {
+ const failure = new Error('the bridge module is gone')
+ const client = createFakeRpcClient()
+ const bridge = harness({
+ client,
+ post: () => {
+ throw failure
+ }
+ })
+ // The `state` frame is sent from inside the client's own fan-out, so a throw here would reach
+ // every other listener that client has.
+ expect(() => client.pushState('reconnecting')).not.toThrow()
+ expect(bridge.diagnostics).toEqual([{ kind: 'post-failed', error: failure }])
+ })
+
+ it('reports a failing post once per session', async () => {
+ const failure = new Error('nowhere to post')
+ const bridge = harness({ post: () => Promise.reject(failure) })
+ bridge.host.receive(clientFrame({ type: 'ready' }))
+ bridge.host.receive(clientFrame({ type: 'ready' }))
+ await flushBridge()
+ expect(bridge.diagnostics).toEqual([{ kind: 'post-failed', error: failure }])
+ expect(bridge.posted).toHaveLength(2)
+ })
+
+ it('forwards to the client it was built with, whatever the frame names', () => {
+ const mine = createFakeRpcClient()
+ const theirs = createFakeRpcClient()
+ const bridge = harness({ client: mine })
+ harness({ client: theirs })
+ bridge.host.receive(
+ clientFrame({ type: 'request', id: ID, method: 'status.get', hostId: 'other-host' })
+ )
+ expect(mine.requests.map((request) => request.method)).toEqual(['status.get'])
+ expect(theirs.requests).toHaveLength(0)
+ })
+
+ it('carries no host name into the client message it parsed', () => {
+ const bridge = harness()
+ bridge.host.receive(
+ clientFrame({ type: 'request', id: ID, method: 'status.get', hostId: 'other-host' })
+ )
+ expect(bridge.client.requests[0]?.args).toEqual(['status.get'])
+ })
+})
diff --git a/mobile/src/mobile-web-shell/bridge-host.ts b/mobile/src/mobile-web-shell/bridge-host.ts
new file mode 100644
index 00000000000..14410dcdfb3
--- /dev/null
+++ b/mobile/src/mobile-web-shell/bridge-host.ts
@@ -0,0 +1,385 @@
+import type { RpcClient } from '../transport/rpc-client'
+import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
+import type { ConnectionState, RpcResponse } from '../transport/types'
+import { BridgeHostSubscriptions } from './bridge-host-subscriptions'
+import {
+ BRIDGE_MAX_PENDING_REQUESTS,
+ BRIDGE_MAX_SUBSCRIPTIONS,
+ type BridgeRefusal
+} from './bridge/bridge-caps'
+import {
+ BRIDGE_PROTOCOL_VERSION,
+ readBridgeClientMessage,
+ type BridgeClientMessage,
+ type BridgeConnectionSnapshot,
+ type BridgeHostMessage
+} from './bridge/bridge-envelope'
+import { captureBridgeError } from './bridge/bridge-error-capture'
+import { splitBridgeReply } from './bridge/bridge-reply-chunking'
+
+type RequestMessage = Extract
+type SubscribeMessage = Extract
+type NotifyMessage = Extract
+
+/** Live until something settles it; the flag is what keeps a cancelled request's late answer from
+ * being posted under an id the page has moved on from. */
+type PendingRequest = { live: boolean }
+
+/** Nothing here is recoverable in place; each is worth a line in a log and none of them is retried. */
+export type BridgeHostDiagnostic =
+ | { kind: 'refused'; refusal: BridgeRefusal }
+ | { kind: 'post-failed'; error: unknown }
+ /** A page posting into a host that has already been disposed, which its own view is the only
+ * thing that can do. Dropping it silently is what hides a leaked view. */
+ | { kind: 'frame-after-dispose' }
+ /** A client that threw where the bridge only forwards. Nothing is owed to the page for a notify,
+ * so the throw is reported rather than answered. */
+ | { kind: 'notify-failed'; error: unknown }
+ /** A frame that arrived between a page's `close` and the next document's `ready`. It belongs to
+ * the closed document, and serving it would answer into whatever loads in next. */
+ | { kind: 'frame-after-close' }
+
+export type BridgeHostOptions = {
+ client: RpcClient
+ /**
+ * Rejects when there is nowhere to post. Resolving proves the message was handed over, never that
+ * the page received it, so nothing here treats a resolve as an acknowledgement.
+ */
+ post: (json: string) => Promise
+ buildId: string
+ sessionId: string
+ onDiagnostic?: (diagnostic: BridgeHostDiagnostic) => void
+}
+
+export type BridgeHost = {
+ receive: (json: string) => void
+ dispose: () => void
+}
+
+class BridgeHostDisposedError extends Error {
+ constructor() {
+ super('the page bridge was torn down before this request answered')
+ this.name = 'BridgeHostDisposedError'
+ }
+}
+
+class BridgeCapExceededError extends Error {
+ constructor(message: string) {
+ super(message)
+ this.name = 'BridgeCapExceededError'
+ }
+}
+
+class BridgeReplyUndeliverableError extends Error {
+ constructor(refusal: BridgeRefusal) {
+ super(`the reply could not be delivered to the page (${refusal})`)
+ this.name = 'BridgeReplyUndeliverableError'
+ }
+}
+
+/**
+ * One page document's end of the bridge: page frames in, host frames out, one RPC client behind it.
+ *
+ * The fence is structural rather than checked. The protocol names no host, so a page cannot ask for
+ * one: the client is whichever this host was built with, and a page that outlives its session has
+ * its frames refused at the native origin check before this module ever sees them. The caps the
+ * page is told about in `init` are enforced here and not trusted from there.
+ */
+export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
+ const { client, buildId, sessionId } = options
+ const pending = new Map()
+ let closed = false
+ // Requests the client is still running. `pending` is the page's view and empties on a cancel or a
+ // `close`, but `sendRequest` has no cancel: the call keeps its slot on the wire until it settles,
+ // and a page that closed between batches would otherwise be handed the cap over again.
+ let inFlight = 0
+ // One document's turn at the bridge. `close` ends it and the next `ready` begins the next one;
+ // between the two the view belongs to no document, so nothing is served and nothing is posted.
+ let serving = true
+ let postFailureReported = false
+ let notifyFailureReported = false
+
+ // Once per session: a page that cannot be posted to fails every frame after the first, and a
+ // line per frame buries the one that says why.
+ function reportPostFailure(error: unknown): void {
+ if (postFailureReported) {
+ return
+ }
+ postFailureReported = true
+ options.onDiagnostic?.({ kind: 'post-failed', error })
+ }
+
+ function sendJson(json: string): void {
+ // Defensive: teardown already settles everything that could post; this fences callers added later.
+ if (closed) {
+ return
+ }
+ // Between documents the view still exists and still accepts posts, which is exactly why this is
+ // checked: a `state` frame sent now lands in the next document before it has said `ready`.
+ if (!serving) {
+ return
+ }
+ // A `post` that throws where it should reject would escape into the client's own state-change
+ // fan-out, which is what sends the `state` frame, and take the other listeners down with it.
+ try {
+ void options.post(json).catch(reportPostFailure)
+ } catch (error) {
+ reportPostFailure(error)
+ }
+ }
+
+ // Every value in a host frame has already been serialized by whoever produced it — a reply by
+ // `splitBridgeReply`, an error `code` by the capture's round trip — so this cannot throw.
+ function send(frame: BridgeHostMessage): void {
+ sendJson(JSON.stringify(frame))
+ }
+
+ function sendError(id: string, error: unknown): void {
+ send({ v: BRIDGE_PROTOCOL_VERSION, type: 'error', id, error: captureBridgeError(error) })
+ }
+
+ const subscriptions = new BridgeHostSubscriptions({ client, post: sendJson })
+
+ /** `state` is the event's own value: a listener can run before the getter it mirrors is updated. */
+ function snapshot(state?: ConnectionState): BridgeConnectionSnapshot {
+ return {
+ state: state ?? client.getState(),
+ reconnectAttempt: client.getReconnectAttempt(),
+ lastConnectedAt: client.getLastConnectedAt(),
+ lastInboundAt: client.getLastInboundAt?.() ?? null,
+ generation: client.getGeneration?.() ?? null
+ }
+ }
+
+ // Answered every time it is asked: a page that saw a `state` older than the one it holds recovers
+ // by asking again rather than by living with a cache it knows is wrong.
+ function sendInit(): void {
+ send({
+ v: BRIDGE_PROTOCOL_VERSION,
+ type: 'init',
+ sessionId,
+ buildId,
+ connection: snapshot(),
+ grants: {
+ rpc: {
+ maxPendingRequests: BRIDGE_MAX_PENDING_REQUESTS,
+ maxSubscriptions: BRIDGE_MAX_SUBSCRIPTIONS
+ },
+ // Every native capability is out of C0. A name added here is never a version bump.
+ native: []
+ }
+ })
+ }
+
+ function settle(id: string, record: PendingRequest): boolean {
+ if (!record.live) {
+ return false
+ }
+ record.live = false
+ pending.delete(id)
+ return true
+ }
+
+ /** The arity the page used, replayed exactly: `sendRequest(m)` and `sendRequest(m, undefined)`
+ * are different calls to the golden recorder. */
+ function forwardRequest(message: RequestMessage): Promise {
+ if (message.options !== undefined) {
+ return client.sendRequest(message.method, message.params, message.options)
+ }
+ return 'params' in message
+ ? client.sendRequest(message.method, message.params)
+ : client.sendRequest(message.method)
+ }
+
+ function sendReply(id: string, payload: RpcResponse): void {
+ const split = splitBridgeReply(id, payload)
+ if (!split.ok) {
+ sendError(id, new BridgeReplyUndeliverableError(split.refusal))
+ return
+ }
+ for (const frame of split.frames) {
+ send(frame)
+ }
+ }
+
+ /** An id already in flight is a page bug; refusing the newcomer leaves the exchange it collided
+ * with intact, which settling it would not. */
+ function idInFlight(id: string): boolean {
+ return pending.has(id) || subscriptions.has(id)
+ }
+
+ function handleRequest(message: RequestMessage): void {
+ const { id } = message
+ if (idInFlight(id)) {
+ sendError(id, new BridgeCapExceededError('that id is already in flight'))
+ return
+ }
+ if (inFlight >= BRIDGE_MAX_PENDING_REQUESTS) {
+ sendError(id, new BridgeCapExceededError(`over ${BRIDGE_MAX_PENDING_REQUESTS} requests`))
+ return
+ }
+ const record: PendingRequest = { live: true }
+ pending.set(id, record)
+ let answer: Promise
+ try {
+ answer = forwardRequest(message)
+ } catch (error) {
+ settle(id, record)
+ sendError(id, error)
+ return
+ }
+ inFlight += 1
+ void answer.then(
+ (payload) => {
+ inFlight -= 1
+ if (settle(id, record)) {
+ sendReply(id, payload)
+ }
+ },
+ (error: unknown) => {
+ inFlight -= 1
+ if (settle(id, record)) {
+ sendError(id, error)
+ }
+ }
+ )
+ }
+
+ // `wantsBinary` is read by the contract and acted on in C6, which owns the screencast encoder and
+ // the measurement that earns it. Until then every stream crosses as JSON.
+ function handleSubscribe(message: SubscribeMessage): void {
+ const { id } = message
+ if (idInFlight(id)) {
+ sendError(id, new BridgeCapExceededError('that id is already in flight'))
+ return
+ }
+ if (subscriptions.size >= BRIDGE_MAX_SUBSCRIPTIONS) {
+ sendError(id, new BridgeCapExceededError(`over ${BRIDGE_MAX_SUBSCRIPTIONS} subscriptions`))
+ return
+ }
+ try {
+ subscriptions.start(id, message.method, message.params)
+ } catch (error) {
+ sendError(id, error)
+ }
+ }
+
+ /** The client's own work runs inside these calls, and a throw from one would otherwise escape into
+ * the native event handler that delivered the page's frame. Nothing is owed to the page here. */
+ function forwardNotify(message: NotifyMessage): void {
+ try {
+ if (message.name === 'foreground') {
+ if (message.reason === undefined) {
+ client.notifyForeground()
+ } else {
+ client.notifyForeground(message.reason)
+ }
+ return
+ }
+ client.updateTerminalSubscriptionViewport(message.terminal, {
+ cols: message.cols,
+ rows: message.rows
+ })
+ } catch (error) {
+ // Once per session, for the reason a failing post is: a page nudging a broken client nudges it
+ // again on every foreground.
+ if (notifyFailureReported) {
+ return
+ }
+ notifyFailureReported = true
+ options.onDiagnostic?.({ kind: 'notify-failed', error })
+ }
+ }
+
+ /** Cancels everything the page had open. `notify` is false for the page's own `close`, which has
+ * already settled what it owned. */
+ function settleAll(notify: boolean): void {
+ for (const [id, record] of pending) {
+ record.live = false
+ // In flight when the door shut: the desktop may already have run it, and a page told this was
+ // a definite send failure would offer to retry something that already happened.
+ if (notify) {
+ sendError(id, markRpcDeliveryUnknown(new BridgeHostDisposedError()))
+ }
+ }
+ pending.clear()
+ subscriptions.closeAll(notify ? 'closed' : null)
+ }
+
+ function dispose(): void {
+ if (closed) {
+ return
+ }
+ settleAll(true)
+ closed = true
+ unsubscribeState()
+ }
+
+ function dispatch(message: BridgeClientMessage): void {
+ // `ready` is what claims the view, whether it is the first document's or a replacement's; a
+ // re-asked `ready` from the document already being served is answered the same way.
+ if (message.type === 'ready') {
+ serving = true
+ sendInit()
+ return
+ }
+ if (!serving) {
+ options.onDiagnostic?.({ kind: 'frame-after-close' })
+ return
+ }
+ switch (message.type) {
+ case 'request':
+ handleRequest(message)
+ return
+ case 'subscribe':
+ handleSubscribe(message)
+ return
+ case 'cancel': {
+ if (message.target === 'subscription') {
+ subscriptions.cancel(message.id, 'unsubscribed')
+ return
+ }
+ // `sendRequest` has no cancel: the desktop still runs it, and this only stops the host from
+ // posting an answer under an id the page has stopped waiting on.
+ const record = pending.get(message.id)
+ if (record !== undefined) {
+ settle(message.id, record)
+ }
+ return
+ }
+ case 'ack':
+ subscriptions.ack(message.id, message.seq)
+ return
+ case 'notify':
+ forwardNotify(message)
+ return
+ case 'close':
+ // Not a latch. The document that loads next into this same view says `ready` over this same
+ // host, and a host that had shut itself would leave that `ready` retrying forever.
+ settleAll(false)
+ serving = false
+ return
+ }
+ }
+
+ const unsubscribeState = client.onStateChange((state) => {
+ send({ v: BRIDGE_PROTOCOL_VERSION, type: 'state', connection: snapshot(state) })
+ })
+
+ return {
+ receive(json: string): void {
+ if (closed) {
+ // Only a disposed host reaches this, and it can neither answer the frame nor refuse it.
+ options.onDiagnostic?.({ kind: 'frame-after-dispose' })
+ return
+ }
+ const read = readBridgeClientMessage(json)
+ if (!read.ok) {
+ options.onDiagnostic?.({ kind: 'refused', refusal: read.refusal })
+ return
+ }
+ dispatch(read.message)
+ },
+ dispose
+ }
+}
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-caps.ts b/mobile/src/mobile-web-shell/bridge/bridge-caps.ts
index 652a5154d42..d8ddedc4539 100644
--- a/mobile/src/mobile-web-shell/bridge/bridge-caps.ts
+++ b/mobile/src/mobile-web-shell/bridge/bridge-caps.ts
@@ -35,6 +35,17 @@ export const BRIDGE_MAX_METHOD_CHARS = 64
export const BRIDGE_MAX_PENDING_REQUESTS = 64
export const BRIDGE_MAX_SUBSCRIPTIONS = 32
+/**
+ * Viewport bounds, held to the desktop's `TerminalViewport` by the envelope's test.
+ *
+ * A viewport the page sends is written into the cached subscribe params of every stream naming that
+ * terminal, the native terminal screens' included, and the desktop refuses an out-of-range one when
+ * those streams resubscribe. Refusing it at the frame is what keeps a bad page's reach inside its
+ * own document.
+ */
+export const BRIDGE_MAX_VIEWPORT_COLS = 1000
+export const BRIDGE_MAX_VIEWPORT_ROWS = 500
+
/**
* A reply above this aborts its request rather than being chunked further. The frame cap is a
* transport bound; this is the policy. The native screens have no reply byte cap at all, so a
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-envelope.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-envelope.test.ts
index 3c6b1ee13bf..ca062513931 100644
--- a/mobile/src/mobile-web-shell/bridge/bridge-envelope.test.ts
+++ b/mobile/src/mobile-web-shell/bridge/bridge-envelope.test.ts
@@ -6,10 +6,14 @@ import {
} from '../../transport/browser-screencast-protocol'
import type { ConnectionState, ForegroundNudgeReason, RpcResponse } from '../../transport/types'
import type { SendRequestOptions } from '../../transport/unvalidated-rpc-request-port'
+import { readFileSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
import {
BRIDGE_MAX_MESSAGE_BYTES,
BRIDGE_MAX_METHOD_CHARS,
- BRIDGE_MAX_REPLY_PARTS
+ BRIDGE_MAX_REPLY_PARTS,
+ BRIDGE_MAX_VIEWPORT_COLS,
+ BRIDGE_MAX_VIEWPORT_ROWS
} from './bridge-caps'
import {
BRIDGE_BINARY_FORMATS,
@@ -99,6 +103,16 @@ describe('client messages', () => {
'terminal viewport notify',
{ type: 'notify', name: 'terminalViewport', terminal: 't1', cols: 80, rows: 24 }
],
+ [
+ 'a terminal viewport notify of exactly the bounds',
+ {
+ type: 'notify',
+ name: 'terminalViewport',
+ terminal: 't1',
+ cols: BRIDGE_MAX_VIEWPORT_COLS,
+ rows: BRIDGE_MAX_VIEWPORT_ROWS
+ }
+ ],
['close', { type: 'close' }]
] as const
@@ -127,6 +141,26 @@ describe('client messages', () => {
'a viewport of zero columns',
client({ type: 'notify', name: 'terminalViewport', terminal: 't1', cols: 0, rows: 24 })
],
+ [
+ 'a viewport one column over the bound',
+ client({
+ type: 'notify',
+ name: 'terminalViewport',
+ terminal: 't1',
+ cols: BRIDGE_MAX_VIEWPORT_COLS + 1,
+ rows: 24
+ })
+ ],
+ [
+ 'a viewport one row over the bound',
+ client({
+ type: 'notify',
+ name: 'terminalViewport',
+ terminal: 't1',
+ cols: 80,
+ rows: BRIDGE_MAX_VIEWPORT_ROWS + 1
+ })
+ ],
['a bare array', []],
['a bare string', 'ready']
] as const
@@ -355,6 +389,26 @@ describe('type pins', () => {
expect(readClient(client({ type: 'request', id: ID, method: 'm', options })).ok).toBe(true)
})
+ it('bounds the viewport exactly where the desktop terminal contract does', () => {
+ // A viewport the page sends is replayed on resubscribe by every stream naming that terminal,
+ // the native screens' included. One the desktop refuses there would kill a stream the page
+ // never opened, so the two bounds have to be the same number.
+ //
+ // Read rather than imported: mobile may not pull a contract *value* into its bundle, and the
+ // boundary test that enforces that scans this file too.
+ const contract = readFileSync(
+ fileURLToPath(
+ new URL('../../../../src/shared/rpc-contract/terminal-unary-params.ts', import.meta.url)
+ ),
+ 'utf8'
+ )
+ const start = contract.indexOf('export const TerminalViewport')
+ expect(start).toBeGreaterThan(-1)
+ const declaration = contract.slice(start, contract.indexOf('})', start))
+ expect(declaration).toContain(`cols: z.number().int().min(1).max(${BRIDGE_MAX_VIEWPORT_COLS})`)
+ expect(declaration).toContain(`rows: z.number().int().min(1).max(${BRIDGE_MAX_VIEWPORT_ROWS})`)
+ })
+
it('closes the binary formats over the screencast protocol', () => {
const asProtocol = (value: (typeof BRIDGE_BINARY_FORMATS)[number]): BrowserScreencastFormat =>
value
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-envelope.ts b/mobile/src/mobile-web-shell/bridge/bridge-envelope.ts
index d3d034f4d9c..453c5e6bfd9 100644
--- a/mobile/src/mobile-web-shell/bridge/bridge-envelope.ts
+++ b/mobile/src/mobile-web-shell/bridge/bridge-envelope.ts
@@ -3,6 +3,8 @@ import { BridgeErrorCaptureSchema } from './bridge-error-capture'
import {
BRIDGE_MAX_METHOD_CHARS,
BRIDGE_MAX_REPLY_PARTS,
+ BRIDGE_MAX_VIEWPORT_COLS,
+ BRIDGE_MAX_VIEWPORT_ROWS,
parseBridgeMessage,
type BridgeDirection,
type BridgeRead
@@ -183,8 +185,8 @@ const BridgeClientMessageSchema = z.discriminatedUnion('type', [
type: z.literal('notify'),
name: z.literal('terminalViewport'),
terminal: z.string().min(1),
- cols: z.number().int().positive(),
- rows: z.number().int().positive()
+ cols: z.number().int().min(1).max(BRIDGE_MAX_VIEWPORT_COLS),
+ rows: z.number().int().min(1).max(BRIDGE_MAX_VIEWPORT_ROWS)
})
]),
z.object({ v: versionSchema, type: z.literal('close') })
diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts
new file mode 100644
index 00000000000..7a579d90cf5
--- /dev/null
+++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts
@@ -0,0 +1,311 @@
+import { createElement, useImperativeHandle, useLayoutEffect, type ReactElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
+import type { OrcaMobileWebShellViewHandle } from '../../modules/orca-mobile-web-shell/src'
+import { readBridgeHostMessage, type BridgeHostMessage } from './bridge/bridge-envelope'
+import type { MobileWebShellSessionState } from './mobile-web-shell-session-contract'
+import type { FakeRpcClient } from './bridge-host-test-fakes'
+
+const doubles = vi.hoisted((): { client: FakeRpcClient | null } => ({ client: null }))
+
+// Reaching the real one imports the Expo runtime this test does not have; the hook reads one field.
+vi.mock('../transport/client-context', () => ({
+ useHostClient: () => ({ client: doubles.client })
+}))
+
+import {
+ bridgeId,
+ clientFrame,
+ createFakeRpcClient,
+ flushBridge,
+ rpcSuccess
+} from './bridge-host-test-fakes'
+import {
+ useMobileWebShellBridge,
+ type MobileWebShellBridgeView
+} from './use-mobile-web-shell-bridge'
+
+const ID = bridgeId(1)
+const DIRECTORY = '/caches/mobile-web/deadbeef/generations/a1b2'
+
+/** Each post is stamped with the mount that carried it, which is the only way to see a retiring
+ * host's teardown land in the page that replaced it. */
+type PostedFrame = { sessionId: string; json: string }
+
+type Probe = { view: MobileWebShellBridgeView | null }
+
+function fakeClient(): FakeRpcClient {
+ const client = doubles.client
+ if (client === null) {
+ throw new Error('this test has no client')
+ }
+ return client
+}
+
+function FakeShellView(props: {
+ sessionId: string
+ viewRef: (handle: OrcaMobileWebShellViewHandle | null) => void
+ posted: PostedFrame[]
+}): null {
+ useImperativeHandle(
+ props.viewRef,
+ () => ({
+ postBridgeMessage: (json: string) => {
+ props.posted.push({ sessionId: props.sessionId, json })
+ return Promise.resolve()
+ }
+ }),
+ [props.posted, props.sessionId]
+ )
+ return null
+}
+
+/**
+ * Delivers a frame from a layout effect of the hook's *parent*, which React runs after the hook's
+ * own commit work and before any passive effect. That is where a native message lands while React
+ * still has passive work queued, and it is the only window this suite can address.
+ */
+function DeliverDuringCommit(props: {
+ deliver: string | null
+ posted: PostedFrame[]
+ probe: Probe
+}): ReactElement {
+ const { deliver, probe } = props
+ useLayoutEffect(() => {
+ if (deliver !== null) {
+ probe.view?.onBridgeMessage({ nativeEvent: { json: deliver } })
+ }
+ }, [deliver, probe])
+ return createElement(Harness, {
+ session: readyState('session-one'),
+ posted: props.posted,
+ probe
+ })
+}
+
+function Harness(props: {
+ session: MobileWebShellSessionState
+ posted: PostedFrame[]
+ probe: Probe
+}): ReactElement | null {
+ const view = useMobileWebShellBridge({ hostId: 'host-1', session: props.session })
+ props.probe.view = view
+ return props.session.kind === 'ready'
+ ? createElement(FakeShellView, {
+ key: props.session.sessionId,
+ sessionId: props.session.sessionId,
+ viewRef: view.viewRef,
+ posted: props.posted
+ })
+ : null
+}
+
+function readyState(sessionId: string): MobileWebShellSessionState {
+ return {
+ kind: 'ready',
+ generationDirectory: DIRECTORY,
+ sessionId,
+ buildId: 'build-a',
+ totalBytes: 4096,
+ elapsedMs: 11
+ }
+}
+
+type Mounted = {
+ tree: ReactTestRenderer
+ posted: PostedFrame[]
+ probe: Probe
+ update: (session: MobileWebShellSessionState) => Promise
+ deliver: (json: string) => Promise
+ frames: (sessionId: string) => BridgeHostMessage[]
+}
+
+let warned: MockInstance
+
+async function mount(session: MobileWebShellSessionState): Promise {
+ const posted: PostedFrame[] = []
+ const probe: Probe = { view: null }
+ const rendered: { tree: ReactTestRenderer | null } = { tree: null }
+ const render = (next: MobileWebShellSessionState): ReactElement =>
+ createElement(Harness, { session: next, posted, probe })
+ await act(async () => {
+ rendered.tree = create(render(session))
+ })
+ const tree = rendered.tree
+ if (tree === null) {
+ throw new Error('the harness did not render')
+ }
+ return {
+ tree,
+ posted,
+ probe,
+ update: async (next) => {
+ await act(async () => {
+ tree.update(render(next))
+ })
+ },
+ deliver: async (json) => {
+ await act(async () => {
+ probe.view?.onBridgeMessage({ nativeEvent: { json } })
+ })
+ },
+ // Read back through the page's own reader: a frame the page would refuse never arrives.
+ frames: (sessionId) =>
+ posted
+ .filter((frame) => frame.sessionId === sessionId)
+ .map((frame) => {
+ const read = readBridgeHostMessage(frame.json)
+ if (!read.ok) {
+ throw new Error(`the page would refuse this frame: ${read.refusal}`)
+ }
+ return read.message
+ })
+ }
+}
+
+beforeEach(() => {
+ doubles.client = createFakeRpcClient()
+ warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ // `spyOn` on an already-spied method hands back the same mock, calls and all.
+ warned.mockClear()
+})
+
+describe('the bridge channel', () => {
+ it('is closed until the session is ready and opens with it', async () => {
+ const mounted = await mount({ kind: 'checking' })
+ expect(mounted.probe.view?.bridgeEnabled).toBe(false)
+ await mounted.update(readyState('session-one'))
+ expect(mounted.probe.view?.bridgeEnabled).toBe(true)
+ })
+
+ it('answers the page through the handle of the session it belongs to', async () => {
+ const mounted = await mount(readyState('session-one'))
+ await mounted.deliver(clientFrame({ type: 'ready' }))
+ expect(mounted.frames('session-one')).toEqual([
+ expect.objectContaining({ type: 'init', sessionId: 'session-one', buildId: 'build-a' })
+ ])
+ })
+
+ it('forwards to the client the hook was given', async () => {
+ const mounted = await mount(readyState('session-one'))
+ await mounted.deliver(clientFrame({ type: 'request', id: ID, method: 'status.get' }))
+ expect(fakeClient().requests.map((request) => request.method)).toEqual(['status.get'])
+ })
+
+ it('builds no host while the ready session has no client, and answers nothing', async () => {
+ doubles.client = null
+ const mounted = await mount(readyState('session-one'))
+ expect(mounted.probe.view?.bridgeEnabled).toBe(true)
+ await mounted.deliver(clientFrame({ type: 'ready' }))
+ expect(mounted.posted).toEqual([])
+ })
+})
+
+describe('teardown', () => {
+ it('posts a retiring session nothing into the page that replaced it', async () => {
+ const mounted = await mount(readyState('session-one'))
+ await mounted.deliver(clientFrame({ type: 'request', id: ID, method: 'status.get' }))
+ await mounted.update(readyState('session-two'))
+ expect(mounted.frames('session-two')).toEqual([])
+ // The retiring host still tried, and the rejection is what said the view was gone.
+ expect(warned).toHaveBeenCalledTimes(1)
+ })
+
+ it(`routes the next session's frames to the next host`, async () => {
+ const mounted = await mount(readyState('session-one'))
+ await mounted.update(readyState('session-two'))
+ await mounted.deliver(clientFrame({ type: 'ready' }))
+ expect(mounted.frames('session-two')).toEqual([
+ expect.objectContaining({ type: 'init', sessionId: 'session-two' })
+ ])
+ })
+
+ it('disposes when the session leaves ready, and answers nothing after', async () => {
+ const mounted = await mount(readyState('session-one'))
+ await mounted.deliver(clientFrame({ type: 'subscribe', id: ID, method: 'x.sub', params: {} }))
+ await mounted.update({ kind: 'failed', reason: 'render-process-gone', retriedOnce: false })
+ expect(fakeClient().streams[0]?.unsubscribes).toBe(1)
+ await mounted.deliver(clientFrame({ type: 'request', id: ID, method: 'status.get' }))
+ await mounted.deliver(clientFrame({ type: 'ready' }))
+ expect(mounted.frames('session-one')).toEqual([])
+ expect(fakeClient().requests).toEqual([])
+ })
+
+ it('disposes on unmount and settles what was in flight as delivery-unknown', async () => {
+ const mounted = await mount(readyState('session-one'))
+ await mounted.deliver(clientFrame({ type: 'request', id: ID, method: 'status.get' }))
+ await act(async () => {
+ mounted.tree.unmount()
+ })
+ // The commit tears the host down while its own view is still attached, so the page hears why
+ // its request will never answer instead of being left holding it.
+ expect(mounted.frames('session-one')).toEqual([
+ expect.objectContaining({ type: 'error', id: ID })
+ ])
+ expect(warned).not.toHaveBeenCalled()
+ fakeClient().requests[0]?.resolve(rpcSuccess('wire-1', 'ok'))
+ await flushBridge()
+ expect(mounted.posted).toHaveLength(1)
+ })
+
+ it('ignores a frame that arrives for a session the hook has moved past', async () => {
+ const mounted = await mount(readyState('session-one'))
+ const stale = mounted.probe.view
+ await mounted.update(readyState('session-two'))
+ await act(async () => {
+ stale?.onBridgeMessage({ nativeEvent: { json: clientFrame({ type: 'ready' }) } })
+ })
+ expect(mounted.posted).toEqual([])
+ })
+})
+
+describe('diagnostics', () => {
+ it('warns once for the frames one page has refused, not once each', async () => {
+ const mounted = await mount(readyState('session-one'))
+ await mounted.deliver('{"v":1,"type":')
+ await mounted.deliver(clientFrame({ type: 'request', id: 'short', method: 'x' }))
+ expect(warned).toHaveBeenCalledTimes(1)
+ })
+
+ it('starts the count over for the next page', async () => {
+ const mounted = await mount(readyState('session-one'))
+ await mounted.deliver('{"v":1,"type":')
+ await mounted.update(readyState('session-two'))
+ await mounted.deliver('{"v":1,"type":')
+ expect(warned).toHaveBeenCalledTimes(2)
+ })
+})
+
+describe('client changes', () => {
+ it('rebuilds the host on a new client, so nothing crosses to the one that was replaced', async () => {
+ const first = fakeClient()
+ const mounted = await mount(readyState('session-one'))
+ const next = createFakeRpcClient()
+ doubles.client = next
+ await mounted.update(readyState('session-one'))
+ await mounted.deliver(clientFrame({ type: 'request', id: ID, method: 'status.get' }))
+ expect(next.requests).toHaveLength(1)
+ expect(first.requests).toHaveLength(0)
+ })
+
+ it('hands the host over in the commit, so no frame reaches the replaced client', async () => {
+ const first = fakeClient()
+ const posted: PostedFrame[] = []
+ const probe: Probe = { view: null }
+ const render = (deliver: string | null): ReactElement =>
+ createElement(DeliverDuringCommit, { deliver, posted, probe })
+ const rendered: { tree: ReactTestRenderer | null } = { tree: null }
+ await act(async () => {
+ rendered.tree = create(render(null))
+ })
+ const next = createFakeRpcClient()
+ doubles.client = next
+ // The session id does not change, so the handler's own fence does not apply: only handing the
+ // host over in the commit keeps this frame off the client that was replaced.
+ await act(async () => {
+ rendered.tree?.update(render(clientFrame({ type: 'request', id: ID, method: 'status.get' })))
+ })
+ expect(first.requests).toHaveLength(0)
+ expect(next.requests).toHaveLength(1)
+ })
+})
diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts
new file mode 100644
index 00000000000..3386e744132
--- /dev/null
+++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts
@@ -0,0 +1,136 @@
+import { useCallback, useLayoutEffect, useRef } from 'react'
+import type {
+ MobileWebShellBridgeMessagePayload,
+ OrcaMobileWebShellViewHandle
+} from '../../modules/orca-mobile-web-shell/src'
+import { useHostClient } from '../transport/client-context'
+import { createBridgeHost, type BridgeHost, type BridgeHostDiagnostic } from './bridge-host'
+import type { MobileWebShellSessionState } from './mobile-web-shell-session-contract'
+
+class BridgeViewGoneError extends Error {
+ constructor() {
+ super('the shell view for this session is not mounted')
+ this.name = 'BridgeViewGoneError'
+ }
+}
+
+/**
+ * One line per kind, for the life of one host.
+ *
+ * A page that is failing frames fails all of them, and a line each buries the first — the one that
+ * says why. The host already holds `post-failed` to one; this is the same bound for the kinds it
+ * does not, and a new host starts the count over because a new page is new evidence.
+ */
+function createBridgeDiagnosticReporter(): (diagnostic: BridgeHostDiagnostic) => void {
+ const reported = new Set()
+ return (diagnostic) => {
+ if (reported.has(diagnostic.kind)) {
+ return
+ }
+ reported.add(diagnostic.kind)
+ if (diagnostic.kind === 'refused') {
+ console.warn('[web-shell-bridge] refused a page frame', diagnostic.refusal)
+ return
+ }
+ if (diagnostic.kind === 'post-failed') {
+ console.warn('[web-shell-bridge] the page could not be posted to', diagnostic.error)
+ return
+ }
+ if (diagnostic.kind === 'notify-failed') {
+ console.warn('[web-shell-bridge] the client threw on a page notification', diagnostic.error)
+ return
+ }
+ console.warn('[web-shell-bridge] a view outlived its host and is still posting')
+ }
+}
+
+/**
+ * Both halves are stamped with the session they belong to.
+ *
+ * React swaps refs in the commit phase and runs the retiring effect's cleanup after it, so a host
+ * disposing on a remount would otherwise post its teardown frames into the page that replaced it.
+ */
+type MountedView = { sessionId: string; handle: OrcaMobileWebShellViewHandle }
+type MountedHost = { sessionId: string; host: BridgeHost }
+
+/** Exactly the field the handler reads. The view's own `NativeSyntheticEvent` prop type is
+ * assignable to this, and a handler declared this narrowly is one a test can call honestly. */
+export type MobileWebShellBridgeMessageEvent = {
+ readonly nativeEvent: MobileWebShellBridgeMessagePayload
+}
+
+export type MobileWebShellBridgeView = {
+ /**
+ * Changing this prop re-enters the native load, so it is derived from the session step alone and
+ * is constant for the life of a mount. A ready session whose client has not arrived yet gets the
+ * channel and no host: there is no honest `init` to answer with, and `ready` is answered every
+ * time it is asked so the page can ask again.
+ */
+ readonly bridgeEnabled: boolean
+ readonly viewRef: (handle: OrcaMobileWebShellViewHandle | null) => void
+ readonly onBridgeMessage: (event: MobileWebShellBridgeMessageEvent) => void
+}
+
+/**
+ * Wires B4's session to one bridge host: the session the reducer put on screen owns the channel,
+ * and nothing here mints, retries or decides anything.
+ *
+ * The session id is B4's — a remount is a new one, which is what makes a dead page's frames fail
+ * the native origin check rather than reach a live client.
+ */
+export function useMobileWebShellBridge(args: {
+ hostId: string
+ session: MobileWebShellSessionState
+}): MobileWebShellBridgeView {
+ const { client } = useHostClient(args.hostId)
+ const ready = args.session.kind === 'ready' ? args.session : null
+ const sessionId = ready?.sessionId ?? null
+ const buildId = ready?.buildId ?? null
+ const viewRef = useRef(null)
+ const hostRef = useRef(null)
+
+ // Commit-phase, not passive: a native frame that arrives between the two carries the session id
+ // the handler is fenced on, so only handing the host over here keeps it off the retired client.
+ useLayoutEffect(() => {
+ if (client === null || sessionId === null || buildId === null) {
+ return
+ }
+ const host = createBridgeHost({
+ client,
+ buildId,
+ sessionId,
+ post: (json) => {
+ const mounted = viewRef.current
+ return mounted === null || mounted.sessionId !== sessionId
+ ? Promise.reject(new BridgeViewGoneError())
+ : mounted.handle.postBridgeMessage(json)
+ },
+ onDiagnostic: createBridgeDiagnosticReporter()
+ })
+ hostRef.current = { sessionId, host }
+ return () => {
+ hostRef.current = null
+ host.dispose()
+ }
+ }, [buildId, client, sessionId])
+
+ return {
+ bridgeEnabled: ready !== null,
+ viewRef: useCallback(
+ (handle: OrcaMobileWebShellViewHandle | null) => {
+ viewRef.current = handle === null || sessionId === null ? null : { sessionId, handle }
+ },
+ [sessionId]
+ ),
+ onBridgeMessage: useCallback(
+ (event: MobileWebShellBridgeMessageEvent) => {
+ const mounted = hostRef.current
+ if (mounted === null || mounted.sessionId !== sessionId) {
+ return
+ }
+ mounted.host.receive(event.nativeEvent.json)
+ },
+ [sessionId]
+ )
+ }
+}
diff --git a/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts b/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts
index 6698a9be723..d38baf9a76a 100644
--- a/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts
+++ b/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts
@@ -25,6 +25,12 @@ export type UnvalidatedRpcRequestPortEntry = {
/** Modules whose job is the port. These do not shrink to zero. */
export const UNVALIDATED_RPC_REQUEST_PORT_OWNERS: readonly UnvalidatedRpcRequestPortEntry[] = [
+ // Carries the port across the page boundary for the hybrid shell. Not a call site: it picks no
+ // method, reads no reply and decides no acceptance — the page names the method and runs the
+ // typed operation over it, exactly as a native screen does over a socket client.
+ { file: 'src/mobile-web-shell/bridge-host.ts', references: 3 },
+ // Fakes the port for the bridge host suites; a non-test file only because tsconfig excludes tests.
+ { file: 'src/mobile-web-shell/bridge-host-test-fakes.ts', references: 1 },
// Placeholder page transport until C0.4's BridgeRpcClient replaces it; rejects every call, reads no reply.
{ file: 'src/transport/client-context.web.tsx', references: 1 },
// Implements the port over the device-to-host websocket.