From f2be6299c852b69929edb1778bf7963ca56fda84 Mon Sep 17 00:00:00 2001
From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com>
Date: Fri, 18 Sep 2026 10:46:47 -0400
Subject: [PATCH] feat(mobile): RN bridge host for the web shell page (OTA
phase C, C0.3) (#21459)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(mobile): RN bridge host for the web shell page (OTA phase C, C0.3)
One page document's end of the bridge: page frames in through the C0.1
reader, one RpcClient behind it, host frames out. Requests forward with the
arity the page used and answer with the verbatim RpcResponse, chunked when it
is over the frame cap; a rejection crosses as the five-field capture instead.
Subscriptions carry a seq and an unacked window, and end with `overflow`
rather than dropping frames a reader cannot see are missing.
The fence is structural: the protocol names no host, so the client is
whichever this host was built with, and the in-flight caps the page is told
about in `init` are enforced here rather than trusted from there.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* feat(mobile): wire the bridge host to B4's hybrid shell screen (OTA phase C, C0.3)
The channel opens on the session B4 put on screen and closes with it. The
session id is B4's: nothing new is minted, and a remount is a new one, which
is what makes a dead page's frames fail the native origin check.
Both halves are stamped with the session they belong to, because React swaps
refs during the commit and runs the retiring effect's cleanup after it — a
host disposing on a remount would otherwise post its teardown into the page
that replaced it. `bridgeEnabled` is derived from the session step alone,
since the native side treats a prop change as a reload.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): prove the bridge fence holds for traffic, not just for answers
A mutation that dropped the post-teardown guard in `receive` survived: the
teardown case only fed a frame whose answer the outbound guard already
swallowed, so nothing observed that a dead page could still reach a live
client. Both teardown paths now feed a request, a subscribe and a notify,
and assert the client saw none of them.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): read the hook's frames through the page's own reader
`JSON.parse` returns `any`, and taming it with an assertion is a cast the
gate refuses and a check nobody gets. Reading each posted frame through
`readBridgeHostMessage` types it and proves the same thing the host's own
suite does: a frame the page would refuse is a frame that never arrives.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* chore(mobile): list the bridge host as a raw request port owner
The boundary ratchet reads a `.sendRequest` access as a call site, and the
host has three: one per arity the page can use. It is 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 the client this carries, which
is what the C0 design put page-side so `runRpcOperation` stays unchanged
there. That makes it an owner, beside the socket and relay senders, not a
migration backlog entry.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): prove a stream that overflows inside subscribe is unsubscribed
A client that emits synchronously from `subscribe` can retire a stream before
its unsubscribe exists to be stored. The identity check that calls it instead
had no test; deleting it left the suite green while the client's stream leaked.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): hand the bridge host over in the commit, not after it
A client swap that keeps the session id leaves the handler's own fence inert:
until the passive effect ran, a native frame reached the retiring host and the
client it closed over. A layout effect swaps both inside the commit.
Teardown on unmount now runs while the view is still attached, so a pending
request is answered delivery-unknown instead of being dropped on the floor.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): hold a refused page frame to one warning per page
A page that sends one bad frame usually sends many, and a line each buries the
first — the one that says why. Same bound the host already keeps on a failing
post, applied per kind and reset when a new page gets a new host.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): bound the page's terminal viewport at the bridge contract
A viewport crossing the bridge is written into the cached subscribe params of
every stream naming that terminal, including the native terminal screen's, and
the desktop refuses cols over 1000 or rows over 500 when those streams
resubscribe. Unbounded, one page could kill streams it never opened; the frame
is refused instead, and the bound is pinned to the desktop's own.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): stop the page's close from latching the bridge host shut
One view carries every document the shell loads, so the page that says `close`
is not the last one. A latched host dropped the next document's `ready` in
silence, and a page that re-sends `ready` on a backoff would retry forever with
nothing posted and nothing logged. Close now cancels what the page owned and
leaves the host live; only dispose shuts it, and a frame arriving after that is
diagnosed rather than dropped.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): keep a throwing client or post inside the bridge host
The `state` frame is sent from inside the client's own state-change fan-out and
a notify runs on the native event handler that delivered the page's frame, so a
synchronous throw from either escapes into a loop the bridge does not own and
takes unrelated listeners with it. Both are fenced and reported once.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* test(mobile): prove an ack releases the stream's unacked bytes
The frame window reopens on ack through the splice, so deleting the byte
release left every existing test green while a long-lived stream of large
frames would end with overflow on its first frame after an ack.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): pass the commit-window harness its children as a prop
`createElement`'s variadic children do not satisfy a props type that declares
`children`, so the file dropped out of the tests typecheck ratchet.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): render the harness from the commit-window wrapper, not as children
A props type that declares `children` is what `createElement`'s variadic form
does not satisfy, and passing it as a prop instead trips the react rule. The
wrapper renders the harness itself, which is the parent position the layout
effect ordering needs anyway.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): pin the desktop viewport bound by reading it, not importing it
Mobile may not pull an rpc-contract *value* into its bundle, and the boundary
test that enforces that scans this test file too. The pin reads the schema's
own source instead, so drift in either bound still fails loudly.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): serve the bridge to one document at a time
A page's `close` now ends that document's turn: until the next `ready`
claims the view, every other frame is dropped and diagnosed instead of
reaching the client, and nothing is posted. Without the fence a straggler
from the closed document was still forwarded, and a `state` frame from the
still-running client landed in the replacement document before its `init`.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
* fix(mobile): hold the request cap against the calls, not the page's ledger
`sendRequest` has no cancel, so a request the page cancelled or closed out
keeps running on the desktop until it answers. The cap now counts those
calls until each settles; counting the pending map let a page interleaving
`close` with batches hold many more than the cap `init` advertises.
Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
---
.../MobileWebShellScreen.test.tsx | 14 +
.../mobile-web-shell/MobileWebShellScreen.tsx | 5 +
.../bridge-host-subscriptions.ts | 153 ++++
.../bridge-host-test-fakes.ts | 103 +++
.../src/mobile-web-shell/bridge-host.test.ts | 735 ++++++++++++++++++
mobile/src/mobile-web-shell/bridge-host.ts | 385 +++++++++
.../mobile-web-shell/bridge/bridge-caps.ts | 11 +
.../bridge/bridge-envelope.test.ts | 56 +-
.../bridge/bridge-envelope.ts | 6 +-
.../use-mobile-web-shell-bridge.test.ts | 311 ++++++++
.../use-mobile-web-shell-bridge.ts | 136 ++++
.../unvalidated-rpc-request-port-inventory.ts | 6 +
12 files changed, 1918 insertions(+), 3 deletions(-)
create mode 100644 mobile/src/mobile-web-shell/bridge-host-subscriptions.ts
create mode 100644 mobile/src/mobile-web-shell/bridge-host-test-fakes.ts
create mode 100644 mobile/src/mobile-web-shell/bridge-host.test.ts
create mode 100644 mobile/src/mobile-web-shell/bridge-host.ts
create mode 100644 mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.test.ts
create mode 100644 mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts
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.