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.