diff --git a/config/scripts/mobile-web-app-render.test.mjs b/config/scripts/mobile-web-app-render.test.mjs
index 6ad3d1c3e61..79cc08f5c9b 100644
--- a/config/scripts/mobile-web-app-render.test.mjs
+++ b/config/scripts/mobile-web-app-render.test.mjs
@@ -14,6 +14,11 @@ const projectDir = fileURLToPath(new URL('../..', import.meta.url))
// RequireContext. Nothing short of mounting it proves that object is the shape ExpoRoot reads.
const HOST_ROUTE = '/h/render-check-host'
+// What the double answers `ready` with. Asserted on the document, so a page that mounted against
+// some other session, or against none, fails here rather than on a phone.
+const SHELL_SESSION_ID = 'render-check-session'
+const SHELL_BUILD_ID = 'render-check-build'
+
// The sharded `test` job does not install mobile dependencies, so the page cannot be built there.
// The CSP suite below needs none of them and still runs. pr.yml's mobile_web_app job runs both.
const bundles = mobileWebAppDependenciesPresent()
@@ -25,6 +30,18 @@ let browser
let origin
let routeChunks = {}
let cspHeader = null
+let bridgeVersion = null
+let faultGrant = null
+
+/**
+ * Chunk paths the server answers with a module that throws on evaluation.
+ *
+ * The one way to reproduce the failure the boundary exists for: a route chunk that never arrives
+ * intact. Building a second bundle around a throwing route would test a synthetic tree; poisoning
+ * one file of the real bundle keeps everything else exactly what ships.
+ */
+const poisonedChunks = new Set()
+const POISON_MESSAGE = 'render check poisoned this route chunk'
/**
* Both CSP constants are a list of quoted directives with `//` comments between them, and those
@@ -49,6 +66,99 @@ export function parseCspDirectives(source, startMarker, endMarker) {
return directives.join('; ')
}
+/**
+ * The envelope version the page speaks, read from the contract rather than written down twice. A
+ * bumped `v` would otherwise reach this file as a 30s timeout naming nothing.
+ */
+async function readBridgeProtocolVersion() {
+ const source = await readFile(
+ join(projectDir, 'mobile/src/mobile-web-shell/bridge/bridge-envelope.ts'),
+ 'utf8'
+ )
+ const match = /BRIDGE_PROTOCOL_VERSION = (\d+)/.exec(source)
+ if (!match) {
+ throw new Error('could not read BRIDGE_PROTOCOL_VERSION')
+ }
+ return Number(match[1])
+}
+
+/** The grant the shell offers every page, read from the same source for the same reason. */
+async function readBridgeFaultGrant() {
+ const source = await readFile(
+ join(projectDir, 'mobile/src/mobile-web-shell/bridge/bridge-envelope.ts'),
+ 'utf8'
+ )
+ const match = /BRIDGE_FAULT_GRANT = '([a-zA-Z]+)'/.exec(source)
+ if (!match) {
+ throw new Error('could not read BRIDGE_FAULT_GRANT')
+ }
+ return match[1]
+}
+
+/**
+ * The shell's half of the bridge, as the page's channel sees it.
+ *
+ * The entry mounts nothing until `init` lands, so a render check with no shell renders no route at
+ * all. This answers `ready` and refuses everything else: a real reply would make this file the
+ * place domain behaviour is decided, and every screen below already has a state for an RPC that
+ * failed. The one message that matters here is the one that lets the tree mount.
+ */
+function installShellDouble({ version, sessionId, buildId, faultGrant }) {
+ // Where the page's own fault reports land. Read back after the render, so a route that threw
+ // under the boundary names itself instead of timing out as a page that never mounted.
+ globalThis.__orcaRenderCheckFaults = []
+ const channel = {
+ postMessage: (json) => {
+ const frame = JSON.parse(json)
+ const answer = (message) => {
+ // A microtask, not a task: the page posts `ready` while its script is still running, and
+ // this keeps the answer behind it without moving a timer the page's backoff reads.
+ queueMicrotask(() => {
+ channel.onmessage?.({ data: JSON.stringify(message) })
+ })
+ }
+ if (frame.type === 'ready') {
+ answer({
+ v: version,
+ type: 'init',
+ sessionId,
+ buildId,
+ connection: {
+ state: 'connected',
+ reconnectAttempt: 0,
+ lastConnectedAt: 1,
+ lastInboundAt: 1,
+ generation: 0
+ },
+ grants: {
+ rpc: { maxPendingRequests: 64, maxSubscriptions: 32 },
+ native: [faultGrant]
+ }
+ })
+ return
+ }
+ if (frame.type === 'notify' && frame.name === faultGrant) {
+ globalThis.__orcaRenderCheckFaults.push(frame.error.message)
+ return
+ }
+ if (frame.type === 'request' || frame.type === 'subscribe') {
+ answer({
+ v: version,
+ type: 'error',
+ id: frame.id,
+ error: {
+ category: 'RenderCheckShellDouble',
+ message: 'the render check answers no RPC',
+ isRpcDeliveryUnknown: false
+ }
+ })
+ }
+ },
+ onmessage: null
+ }
+ globalThis.orcaBridge = channel
+}
+
/**
* The shipped policy, read from the Kotlin source so this test cannot drift from what the shell
* actually sends. Parsed rather than imported: the constant lives in a JVM module.
@@ -66,6 +176,8 @@ async function readShellCsp() {
beforeAll(async () => {
cspHeader = await readShellCsp()
+ bridgeVersion = await readBridgeProtocolVersion()
+ faultGrant = await readBridgeFaultGrant()
if (!bundles) {
return
}
@@ -89,7 +201,13 @@ beforeAll(async () => {
const namesAFile = path.slice(path.lastIndexOf('/')).includes('.')
const file = namesAFile ? path.slice(1) : 'index.html'
readFile(join(outDir, file)).then(
- (bytes) => {
+ (real) => {
+ // The real bytes with a throw in front: the module still links, so the importer resolves
+ // every export it asked for and then evaluation throws. A body replaced outright fails at
+ // link instead, which is a different failure from the one the boundary is here for.
+ const bytes = poisonedChunks.has(path)
+ ? `throw new Error(${JSON.stringify(POISON_MESSAGE)});\n${real.toString('utf8')}`
+ : real
const headers = {
'content-type': file.endsWith('.js') ? 'text/javascript' : 'text/html'
}
@@ -132,8 +250,18 @@ const UNMATCHED = 'Unmatched Route'
* paths the browser actually fetched. The last one is how a client-side navigation proves it
* pulled the next route's chunk rather than painting out of what the entry already had.
*/
-async function openPage() {
+async function openPage({ shell = true } = {}) {
const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
+ if (shell) {
+ // At document start, where the native shell installs the real channel: the entry reads it
+ // while its own script runs, so a channel added after `load` would already be too late.
+ await page.addInitScript(installShellDouble, {
+ version: bridgeVersion,
+ sessionId: SHELL_SESSION_ID,
+ buildId: SHELL_BUILD_ID,
+ faultGrant
+ })
+ }
const errors = []
const scripts = []
let reportUncaught = () => {}
@@ -204,6 +332,11 @@ async function waitForRoute({ page, errors, uncaught }, route, awaitText) {
if (paintCause) {
throw named(paintCause, `mounted but never painted ${JSON.stringify(awaitText)}`)
}
+ // Folded into the errors the caller already asserts empty: a throw the boundary caught paints
+ // nothing and logs nothing a `pageerror` listener hears, so this is the only place it shows up.
+ for (const fault of await page.evaluate(() => globalThis.__orcaRenderCheckFaults ?? [])) {
+ errors.push(`page fault: ${fault}`)
+ }
}
async function render(route, awaitText) {
@@ -211,16 +344,35 @@ async function render(route, awaitText) {
await opened.page.goto(`${origin}${route}`, { waitUntil: 'load' })
await waitForRoute(opened, route, awaitText)
const text = await opened.page.evaluate(() => document.body.innerText)
+ // What the page believes it is: read off the document rather than off the double, so a tree that
+ // mounted without a session, or against a session it invented, is not a passing render.
+ const session = await opened.page.evaluate(() => ({
+ sessionId: document.documentElement.dataset.orcaWebSessionId ?? null,
+ buildId: document.documentElement.dataset.orcaWebBuildId ?? null
+ }))
await opened.page.close()
// A CSP refusal reaches the page as a console error, so the caller's empty-errors assertion is
// also the policy assertion; name it here so a failure says which one broke.
return {
errors: opened.errors,
cspErrors: opened.errors.filter((entry) => entry.includes('Content Security Policy')),
- text
+ text,
+ session
}
}
+/** The entry's state and what it painted, for a page that is never going to mount. */
+async function renderUnbridged(route) {
+ const { page, errors } = await openPage({ shell: false })
+ // Read straight after `load` and not polled: the entry decides this synchronously, inside the
+ // script `load` waits for, so a state that is not settled by now is never going to settle.
+ await page.goto(`${origin}${route}`, { waitUntil: 'load' })
+ const entry = await page.evaluate(() => document.documentElement.dataset.orcaWebEntry ?? 'absent')
+ const rootChildren = await page.evaluate(() => document.getElementById('root').childElementCount)
+ await page.close()
+ return { entry, errors, rootChildren }
+}
+
describe('the shell policy this page is tested under', () => {
it('is the same on both platforms, so one render check covers both', async () => {
const swift = await readFile(
@@ -284,19 +436,23 @@ describeRender('the page server this check runs against', () => {
describeRender('the Route A page in a real browser', () => {
it('mounts the worktree list route, not the unmatched screen', async () => {
- const { errors, cspErrors, text } = await render(HOST_ROUTE, 'Host not found')
+ const { errors, cspErrors, text, session } = await render(HOST_ROUTE, 'Host not found')
expect(cspErrors).toEqual([])
expect(errors).toEqual([])
- // app/h/[hostId]/index.tsx: the placeholder client knows no host, so the list paints its
- // not-found state. Only that route's own component produces this string.
+ // The tree that mounted is the one the shell handed a session to, and it says which.
+ expect(session).toEqual({ sessionId: SHELL_SESSION_ID, buildId: SHELL_BUILD_ID })
+ // app/h/[hostId]/index.tsx: expo-secure-store is {} on web, so loadHosts() finds no profile
+ // and the list paints its not-found state. Only that route's own component produces this
+ // string, and C1.4's host-store.web.ts is what replaces it with a real row.
expect(text).toContain('Host not found')
expect(text).not.toContain(UNMATCHED)
}, 60_000)
it('routes a nested dynamic segment through the same context', async () => {
- const { errors, cspErrors, text } = await render(`${HOST_ROUTE}/tasks`, 'Tasks')
+ const { errors, cspErrors, text, session } = await render(`${HOST_ROUTE}/tasks`, 'Tasks')
expect(cspErrors).toEqual([])
expect(errors).toEqual([])
+ expect(session.sessionId).toBe(SHELL_SESSION_ID)
// app/h/[hostId]/tasks.tsx paints its header and its GitHub filter row.
expect(text).toContain('Tasks')
expect(text).toContain('Issues')
@@ -311,6 +467,45 @@ describeRender('the Route A page in a real browser', () => {
expect(text).toContain(UNMATCHED)
}, 60_000)
+ it('mounts nothing at all when no shell answered, which is what makes the three above real', async () => {
+ const { entry, errors, rootChildren } = await renderUnbridged(HOST_ROUTE)
+ // Without this the checks above would pass against a page that ignores `init` entirely.
+ expect(entry).toBe('unbridged')
+ expect(rootChildren).toBe(0)
+ expect(errors).toEqual([])
+ }, 60_000)
+
+ it('tells the shell when a route chunk throws, rather than sitting on a blank page', async () => {
+ const chunk = routeChunks['./h/[hostId]/index.tsx']
+ expect(chunk, Object.keys(routeChunks).join(' ')).toBeTruthy()
+ poisonedChunks.add(`/assets/${chunk}`)
+ try {
+ const opened = await openPage()
+ await opened.page.goto(`${origin}${HOST_ROUTE}`, { waitUntil: 'load' })
+ const reported = await opened.page
+ .waitForFunction(
+ () => {
+ const faults = globalThis.__orcaRenderCheckFaults ?? []
+ return faults.length > 0 ? faults : null
+ },
+ { timeout: 30_000, polling: 250 }
+ )
+ .then((handle) => handle.jsonValue())
+ // The message the poisoned module threw, carried across the bridge as the shell sees it. A
+ // boundary that caught the throw and reported something else would pass an "any fault" check.
+ expect(reported.join(' | ')).toContain(POISON_MESSAGE)
+ // And the screen never painted. The router's own shell commits before the deferred chunk
+ // rejects, so the entry does reach `mounted`; what the boundary takes away is everything
+ // below it, which is the difference between a reported failure and a blank page nobody hears.
+ const text = await opened.page.evaluate(() => document.body.innerText)
+ expect(text).not.toContain('Host not found')
+ expect(text).not.toContain(UNMATCHED)
+ await opened.page.close()
+ } finally {
+ poisonedChunks.delete(`/assets/${chunk}`)
+ }
+ }, 60_000)
+
it("fetches the next route's chunks on a client-side navigation", async () => {
const opened = await openPage()
const { page, errors, scripts } = opened
diff --git a/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx b/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx
index 34562932177..ca73006671e 100644
--- a/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx
+++ b/mobile/src/mobile-web-shell/MobileWebShellScreen.test.tsx
@@ -1,14 +1,19 @@
import { createElement } from 'react'
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
+import type { FakeRpcClient } from './bridge-host-test-fakes'
import type { MobileWebShellSessionState } from './mobile-web-shell-session-contract'
type ScreenDependencies = {
retry: Mock
reportShellFailure: Mock
+ reportDocumentLoaded: Mock
+ reportPageReady: Mock
openUrl: Mock
lifecycle: string[]
state: MobileWebShellSessionState
+ /** Null for every case but the bridge's: with no client the hook builds no host at all. */
+ client: FakeRpcClient | null
}
const dependencies = vi.hoisted((): ScreenDependencies => {
@@ -18,9 +23,12 @@ const dependencies = vi.hoisted((): ScreenDependencies => {
return {
retry: vi.fn(),
reportShellFailure: vi.fn(),
+ reportDocumentLoaded: vi.fn(),
+ reportPageReady: vi.fn(),
openUrl: vi.fn(),
lifecycle: [],
- state: { kind: 'checking' }
+ state: { kind: 'checking' },
+ client: null
}
})
@@ -57,15 +65,21 @@ vi.mock('../../modules/orca-mobile-web-shell/src', async () => {
})
// 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('../transport/client-context', () => ({
+ useHostClient: () => ({ client: dependencies.client })
+}))
vi.mock('./use-mobile-web-shell-session', () => ({
useMobileWebShellSession: () => ({
state: dependencies.state,
retry: dependencies.retry,
- reportShellFailure: dependencies.reportShellFailure
+ reportShellFailure: dependencies.reportShellFailure,
+ reportDocumentLoaded: dependencies.reportDocumentLoaded,
+ reportPageReady: dependencies.reportPageReady
})
}))
+import { clientFrame, createFakeRpcClient } from './bridge-host-test-fakes'
+import { BRIDGE_FAULT_GRANT } from './bridge/bridge-envelope'
import { MobileWebShellScreen } from './MobileWebShellScreen'
const BUILD_ID = 'a1b2c3d4e5f6'.repeat(5) + 'abcd'
@@ -117,7 +131,10 @@ describe('the hybrid shell screen', () => {
beforeEach(() => {
dependencies.retry.mockReset()
dependencies.reportShellFailure.mockReset()
+ dependencies.reportDocumentLoaded.mockReset()
+ dependencies.reportPageReady.mockReset()
dependencies.lifecycle.length = 0
+ dependencies.client = null
})
it('renders the update wall for a bundle verdict, with no shell view', async () => {
@@ -235,6 +252,58 @@ describe('the hybrid shell screen', () => {
expect(dependencies.reportShellFailure.mock.calls).toEqual([['render-process-gone']])
})
+ it('starts the wait for the page when the native view says the document finished', async () => {
+ const tree = await render(readyState('session-one'))
+ const view = byName(tree, 'ShellViewProbe')[0]
+ await act(async () => {
+ view.props.onLoadState({ nativeEvent: { state: 'loading' } })
+ view.props.onLoadState({ nativeEvent: { state: 'ready' } })
+ view.props.onLoadState({ nativeEvent: { state: 'failed', reason: 'document-load-failed' } })
+ })
+ // Once, for the one finished document, and never for the failure: a view that reported a
+ // failure has nothing left to wait for.
+ expect(dependencies.reportDocumentLoaded).toHaveBeenCalledTimes(1)
+ })
+
+ it('ends that wait on the page asking for a session', async () => {
+ dependencies.client = createFakeRpcClient()
+ const tree = await render(readyState('session-one'))
+ await act(async () => {
+ byName(tree, 'ShellViewProbe')[0].props.onBridgeMessage({
+ nativeEvent: { json: clientFrame({ type: 'ready' }) }
+ })
+ })
+ expect(dependencies.reportPageReady).toHaveBeenCalled()
+ })
+
+ it('fails the session on a page fault, so a blank page becomes the failure screen', async () => {
+ dependencies.client = createFakeRpcClient()
+ const warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ const tree = await render(readyState('session-one'))
+ await act(async () => {
+ // The page asks for its session first, which is what earns it the `fault` grant: a host that
+ // has told a page nothing refuses the name.
+ byName(tree, 'ShellViewProbe')[0].props.onBridgeMessage({
+ nativeEvent: { json: clientFrame({ type: 'ready' }) }
+ })
+ byName(tree, 'ShellViewProbe')[0].props.onBridgeMessage({
+ nativeEvent: {
+ json: clientFrame({
+ type: 'notify',
+ name: BRIDGE_FAULT_GRANT,
+ error: { category: 'Error', message: 'the route threw', isRpcDeliveryUnknown: false }
+ })
+ }
+ })
+ })
+ expect(dependencies.reportShellFailure.mock.calls).toEqual([['document-load-failed']])
+ warned.mockRestore()
+ // The reducer's answer to that reason, rendered: this is what the page's blank turns into.
+ expect(
+ textOf(await render({ kind: 'failed', reason: 'document-load-failed', retriedOnce: true }))
+ ).toContain('The downloaded workspace could not be opened.')
+ })
+
it('shows a build id prefix and never the whole one, the cache path, or the host id', async () => {
const tree = await render(readyState('session-one'))
const text = textOf(tree)
diff --git a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx
index 8acb8042eba..5e4f756dfd6 100644
--- a/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx
+++ b/mobile/src/mobile-web-shell/MobileWebShellScreen.tsx
@@ -12,10 +12,8 @@ import type {
MobileWebShellSessionState
} from './mobile-web-shell-session-contract'
import { useMobileWebShellBridge } from './use-mobile-web-shell-bridge'
-import {
- useMobileWebShellSession,
- type MobileWebShellRuntime
-} from './use-mobile-web-shell-session'
+import type { MobileWebShellRuntime } from './mobile-web-shell-runtime'
+import { useMobileWebShellSession } from './use-mobile-web-shell-session'
// Same guard as the Troubleshoot developer row: `__DEV__` is undefined outside the React Native
// runtime, and the facts below are for whoever is bringing the shell up, not for a user.
@@ -123,8 +121,23 @@ 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 })
+ const { state, retry, reportShellFailure, reportDocumentLoaded, reportPageReady } =
+ useMobileWebShellSession({ hostId, runtime })
+ const bridge = useMobileWebShellBridge({
+ hostId,
+ session: state,
+ // Reported as the document failing to load, which is what it is: the document loaded and never
+ // produced a tree. That reason drops this generation and downloads once, so a page broken by
+ // bytes this host has since replaced recovers, and a page broken by its own code stops at the
+ // failure screen instead of a blank one.
+ // Must not throw: it runs inside the page's own error boundary on one side and the native frame
+ // handler on the other, and neither has anywhere to put a throw.
+ onPageFault: (error) => {
+ console.warn('[web-shell] the page faulted', error)
+ reportShellFailure('document-load-failed')
+ },
+ onPageReady: reportPageReady
+ })
if (state.kind === 'wall') {
return
@@ -164,6 +177,12 @@ export function MobileWebShellScreen({ hostId, runtime }: MobileWebShellScreenPr
const parsed = parseMobileWebShellLoadState(event.nativeEvent)
if (parsed?.state === 'failed') {
reportShellFailure(parsed.reason)
+ return
+ }
+ // A finished document is not a working one. The WebView says the response committed; only
+ // the page's own first frame says its code ran, so this is where the wait for it starts.
+ if (parsed?.state === 'ready') {
+ reportDocumentLoaded()
}
}}
/>
diff --git a/mobile/src/mobile-web-shell/bridge-diagnostic-log.test.ts b/mobile/src/mobile-web-shell/bridge-diagnostic-log.test.ts
new file mode 100644
index 00000000000..cf0ec0abf96
--- /dev/null
+++ b/mobile/src/mobile-web-shell/bridge-diagnostic-log.test.ts
@@ -0,0 +1,55 @@
+import { beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
+import { createBridgeDiagnosticReporter } from './bridge-diagnostic-log'
+
+let warned: MockInstance
+
+beforeEach(() => {
+ warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ warned.mockClear()
+})
+
+function readPart(part: unknown): string {
+ return part instanceof Error ? part.message : JSON.stringify(part)
+}
+
+/** What a reader has to be able to tell apart from the line alone. */
+function lines(): string[] {
+ return warned.mock.calls.map((call) => call.map(readPart).join(' '))
+}
+
+describe('the bridge diagnostic log', () => {
+ it('names the notification it refused and why, for each refusal', () => {
+ const report = createBridgeDiagnosticReporter()
+ report({ kind: 'notify-refused', name: 'fault', why: 'before-ready' })
+ report({ kind: 'notify-refused', name: 'fault', why: 'ungranted' })
+ expect(lines()).toHaveLength(2)
+ expect(lines()[0]).toContain('fault')
+ expect(lines()[0]).toContain('before-ready')
+ expect(lines()[1]).toContain('ungranted')
+ })
+
+ it('still holds one page to a line per refusal, however many frames it posts', () => {
+ const report = createBridgeDiagnosticReporter()
+ report({ kind: 'notify-refused', name: 'fault', why: 'before-ready' })
+ report({ kind: 'notify-refused', name: 'fault', why: 'before-ready' })
+ expect(warned).toHaveBeenCalledTimes(1)
+ })
+
+ it('says a view outlived its host only for a frame that did', () => {
+ const report = createBridgeDiagnosticReporter()
+ report({ kind: 'frame-after-dispose' })
+ report({ kind: 'notify-refused', name: 'fault', why: 'before-ready' })
+ expect(lines()[0]).toContain('outlived')
+ expect(lines()[1]).not.toContain('outlived')
+ })
+
+ it('carries the cause of the kinds that have one', () => {
+ const report = createBridgeDiagnosticReporter()
+ report({ kind: 'refused', refusal: 'malformed-json' })
+ report({ kind: 'post-failed', error: new Error('no view') })
+ report({ kind: 'notify-failed', error: new Error('the listener threw') })
+ expect(lines()[0]).toContain('malformed-json')
+ expect(lines()[1]).toContain('no view')
+ expect(lines()[2]).toContain('the listener threw')
+ })
+})
diff --git a/mobile/src/mobile-web-shell/bridge-diagnostic-log.ts b/mobile/src/mobile-web-shell/bridge-diagnostic-log.ts
new file mode 100644
index 00000000000..c6e90ede345
--- /dev/null
+++ b/mobile/src/mobile-web-shell/bridge-diagnostic-log.ts
@@ -0,0 +1,54 @@
+import type { BridgeHostDiagnostic } from './bridge-host'
+
+/**
+ * What a repeat is, for the line bound below.
+ *
+ * The kind on its own for everything the host reports once per cause. Not for a refused `notify`:
+ * a page that was told nothing and a page reaching past what it was told are different faults, and
+ * the first would otherwise bury the second for the life of the host.
+ */
+function diagnosticKey(diagnostic: BridgeHostDiagnostic): string {
+ return diagnostic.kind === 'notify-refused'
+ ? `${diagnostic.kind}:${diagnostic.why}`
+ : diagnostic.kind
+}
+
+/**
+ * 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.
+ */
+export function createBridgeDiagnosticReporter(): (diagnostic: BridgeHostDiagnostic) => void {
+ const reported = new Set()
+ return (diagnostic) => {
+ const key = diagnosticKey(diagnostic)
+ if (reported.has(key)) {
+ return
+ }
+ reported.add(key)
+ if (diagnostic.kind === 'refused') {
+ console.warn('[web-shell-bridge] refused a page frame', diagnostic.refusal)
+ return
+ }
+ if (diagnostic.kind === 'notify-refused') {
+ // Both halves, because neither is derivable from the other: which name the page posted, and
+ // whether the host had issued it anything at all.
+ console.warn('[web-shell-bridge] refused a page notification', {
+ name: diagnostic.name,
+ why: diagnostic.why
+ })
+ 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')
+ }
+}
diff --git a/mobile/src/mobile-web-shell/bridge-host-errors.ts b/mobile/src/mobile-web-shell/bridge-host-errors.ts
new file mode 100644
index 00000000000..a79307e693b
--- /dev/null
+++ b/mobile/src/mobile-web-shell/bridge-host-errors.ts
@@ -0,0 +1,28 @@
+import type { BridgeRefusal } from './bridge/bridge-caps'
+
+/** Everything the RN host raises on its own, as opposed to what it forwards from the client. */
+
+/** The bridge went away with a request still on it. Carried to the page as delivery-unknown: the
+ * desktop may already have run it. */
+export class BridgeHostDisposedError extends Error {
+ constructor() {
+ super('the page bridge was torn down before this request answered')
+ this.name = 'BridgeHostDisposedError'
+ }
+}
+
+/** A page over a cap `init` already told it. Refusing the newcomer leaves what it collided with. */
+export class BridgeCapExceededError extends Error {
+ constructor(message: string) {
+ super(message)
+ this.name = 'BridgeCapExceededError'
+ }
+}
+
+/** A reply the page's own reader would refuse, failed on the sending side so the page hears why. */
+export class BridgeReplyUndeliverableError extends Error {
+ constructor(refusal: BridgeRefusal) {
+ super(`the reply could not be delivered to the page (${refusal})`)
+ this.name = 'BridgeReplyUndeliverableError'
+ }
+}
diff --git a/mobile/src/mobile-web-shell/bridge-host.test.ts b/mobile/src/mobile-web-shell/bridge-host.test.ts
index e3badea4f87..f02d6cecdf1 100644
--- a/mobile/src/mobile-web-shell/bridge-host.test.ts
+++ b/mobile/src/mobile-web-shell/bridge-host.test.ts
@@ -16,7 +16,12 @@ import {
BRIDGE_MAX_REPLY_BYTES,
BRIDGE_MAX_SUBSCRIPTIONS
} from './bridge/bridge-caps'
-import { readBridgeHostMessage, type BridgeHostMessage } from './bridge/bridge-envelope'
+import {
+ BRIDGE_FAULT_GRANT,
+ readBridgeHostMessage,
+ type BridgeHostMessage
+} from './bridge/bridge-envelope'
+import type { BridgeErrorCapture } from './bridge/bridge-error-capture'
import { BridgeReplyAssembler } from './bridge/bridge-reply-chunking'
const ID = bridgeId(1)
@@ -27,16 +32,24 @@ type Harness = {
client: FakeRpcClient
posted: string[]
diagnostics: BridgeHostDiagnostic[]
+ pageFaults: BridgeErrorCapture[]
+ pageReadyCount: () => number
frames: () => BridgeHostMessage[]
last: () => BridgeHostMessage
}
function harness(
- options: { client?: FakeRpcClient; post?: (json: string) => Promise } = {}
+ options: {
+ client?: FakeRpcClient
+ post?: (json: string) => Promise
+ onPageFault?: (error: BridgeErrorCapture) => void
+ } = {}
): Harness {
const client = options.client ?? createFakeRpcClient()
const posted: string[] = []
const diagnostics: BridgeHostDiagnostic[] = []
+ const pageFaults: BridgeErrorCapture[] = []
+ let pageReadies = 0
const host = createBridgeHost({
client,
post: (json) => {
@@ -45,6 +58,13 @@ function harness(
},
buildId: 'build-a',
sessionId: 'session-a',
+ onPageFault: (error) => {
+ pageFaults.push(error)
+ options.onPageFault?.(error)
+ },
+ onPageReady: () => {
+ pageReadies += 1
+ },
onDiagnostic: (diagnostic) => diagnostics.push(diagnostic)
})
// Read back through the page's own reader: a frame the host sends that the page would refuse is
@@ -62,6 +82,8 @@ function harness(
client,
posted,
diagnostics,
+ pageFaults,
+ pageReadyCount: () => pageReadies,
frames,
last: () => {
const all = frames()
@@ -79,7 +101,7 @@ function subscribeFrame(id: string, method = 'terminal.subscribe'): string {
}
describe('init and state', () => {
- it('answers ready with the getters, the caps it enforces, and no native grant', () => {
+ it('answers ready with the getters, the caps it enforces, and the one native grant', () => {
const client = createFakeRpcClient({
getState: () => 'reconnecting',
getReconnectAttempt: () => 3,
@@ -106,7 +128,7 @@ describe('init and state', () => {
maxPendingRequests: BRIDGE_MAX_PENDING_REQUESTS,
maxSubscriptions: BRIDGE_MAX_SUBSCRIPTIONS
},
- native: []
+ native: [BRIDGE_FAULT_GRANT]
}
})
})
@@ -131,6 +153,22 @@ describe('init and state', () => {
expect(bridge.frames().filter((frame) => frame.type === 'init')).toHaveLength(2)
})
+ it('tells the shell the page spoke, on the first ask and on every re-ask', () => {
+ const bridge = harness()
+ expect(bridge.pageReadyCount()).toBe(0)
+ bridge.host.receive(clientFrame({ type: 'ready' }))
+ bridge.host.receive(clientFrame({ type: 'ready' }))
+ // The shell bounds the wait for the first of these; a page on its backoff must not have to
+ // land a particular one to end it.
+ expect(bridge.pageReadyCount()).toBe(2)
+ })
+
+ it('says nothing about a page that never asked, however much else it posts', () => {
+ const bridge = harness()
+ bridge.host.receive(clientFrame({ type: 'notify', name: 'foreground' }))
+ expect(bridge.pageReadyCount()).toBe(0)
+ })
+
it('pushes the event state, not the getter a listener can outrun', () => {
const bridge = harness()
bridge.client.pushState('disconnected')
@@ -643,6 +681,7 @@ describe('teardown', () => {
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: 'ready' }))
bridge.host.receive(clientFrame({ type: 'notify', name: 'foreground' }))
bridge.host.receive(clientFrame({ type: 'notify', name: 'foreground', reason: 'app-resume' }))
bridge.host.receive(
@@ -652,6 +691,96 @@ describe('notifications, refusals and the fence', () => {
expect(bridge.client.viewports).toEqual([{ terminal: 't1', cols: 80, rows: 24 }])
})
+ it('hands a page fault to the session and asks the client for nothing', () => {
+ const bridge = harness()
+ bridge.host.receive(clientFrame({ type: 'ready' }))
+ bridge.host.receive(
+ clientFrame({
+ type: 'notify',
+ name: BRIDGE_FAULT_GRANT,
+ error: { category: 'Error', message: 'route threw', isRpcDeliveryUnknown: false }
+ })
+ )
+ expect(bridge.pageFaults).toEqual([
+ { category: 'Error', message: 'route threw', isRpcDeliveryUnknown: false }
+ ])
+ expect(bridge.client.requests).toHaveLength(0)
+ expect(bridge.client.foregroundCalls).toEqual([])
+ expect(bridge.diagnostics).toEqual([])
+ })
+
+ it('refuses a notify from a page it has told nothing, grant or no grant', () => {
+ const bridge = harness()
+ bridge.host.receive(
+ clientFrame({
+ type: 'notify',
+ name: BRIDGE_FAULT_GRANT,
+ error: { category: 'Error', message: 'route threw', isRpcDeliveryUnknown: false }
+ })
+ )
+ bridge.host.receive(clientFrame({ type: 'notify', name: 'foreground' }))
+ expect(bridge.pageFaults).toEqual([])
+ expect(bridge.client.foregroundCalls).toEqual([])
+ expect(bridge.diagnostics).toEqual([
+ { kind: 'notify-refused', name: BRIDGE_FAULT_GRANT, why: 'before-ready' },
+ { kind: 'notify-refused', name: 'foreground', why: 'before-ready' }
+ ])
+ })
+
+ it('serves the grant it issued once the page has asked for a session', () => {
+ const bridge = harness()
+ bridge.host.receive(clientFrame({ type: 'ready' }))
+ const init = bridge.last()
+ // The list on the wire is the list the check above reads; a host that offered one and enforced
+ // another would pass every other test in this file.
+ expect(init.type === 'init' && init.grants.native).toEqual([BRIDGE_FAULT_GRANT])
+ bridge.host.receive(
+ clientFrame({
+ type: 'notify',
+ name: BRIDGE_FAULT_GRANT,
+ error: { category: 'Error', message: 'route threw', isRpcDeliveryUnknown: false }
+ })
+ )
+ expect(bridge.pageFaults).toHaveLength(1)
+ expect(bridge.diagnostics).toEqual([])
+ })
+
+ it('drops a page fault that arrives after the document said goodbye', () => {
+ const bridge = harness()
+ bridge.host.receive(clientFrame({ type: 'close' }))
+ bridge.host.receive(
+ clientFrame({
+ type: 'notify',
+ name: BRIDGE_FAULT_GRANT,
+ error: { category: 'Error', message: 'late', isRpcDeliveryUnknown: false }
+ })
+ )
+ expect(bridge.pageFaults).toEqual([])
+ expect(bridge.diagnostics).toEqual([{ kind: 'frame-after-close' }])
+ })
+
+ it('reports a listener that throws on a page fault once, and keeps reading', () => {
+ const failure = new Error('the session is gone')
+ const bridge = harness({
+ onPageFault: () => {
+ throw failure
+ }
+ })
+ const fault = clientFrame({
+ type: 'notify',
+ name: BRIDGE_FAULT_GRANT,
+ error: { category: 'Error', message: 'route threw', isRpcDeliveryUnknown: false }
+ })
+ bridge.host.receive(clientFrame({ type: 'ready' }))
+ // 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(fault)
+ bridge.host.receive(fault)
+ expect(bridge.diagnostics).toEqual([{ kind: 'notify-failed', error: failure }])
+ bridge.host.receive(clientFrame({ type: 'ready' }))
+ expect(bridge.last().type).toBe('init')
+ })
+
it('reports a refused frame and forwards nothing from it', () => {
const bridge = harness()
bridge.host.receive('{"v":1,"type":')
@@ -677,6 +806,7 @@ describe('notifications, refusals and the fence', () => {
}
}
})
+ bridge.host.receive(clientFrame({ type: 'ready' }))
// 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' }))
diff --git a/mobile/src/mobile-web-shell/bridge-host.ts b/mobile/src/mobile-web-shell/bridge-host.ts
index 4241e34a295..99246f86a29 100644
--- a/mobile/src/mobile-web-shell/bridge-host.ts
+++ b/mobile/src/mobile-web-shell/bridge-host.ts
@@ -1,6 +1,11 @@
import type { RpcClient } from '../transport/rpc-client'
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import type { ConnectionState, RpcResponse } from '../transport/types'
+import {
+ BridgeCapExceededError,
+ BridgeHostDisposedError,
+ BridgeReplyUndeliverableError
+} from './bridge-host-errors'
import { BridgeHostSubscriptions } from './bridge-host-subscriptions'
import {
BRIDGE_MAX_PENDING_REQUESTS,
@@ -8,13 +13,16 @@ import {
type BridgeRefusal
} from './bridge/bridge-caps'
import {
+ BRIDGE_FAULT_GRANT,
BRIDGE_PROTOCOL_VERSION,
readBridgeClientMessage,
type BridgeClientMessage,
type BridgeConnectionSnapshot,
type BridgeHostMessage
} from './bridge/bridge-envelope'
-import { captureBridgeError } from './bridge/bridge-error-capture'
+import { captureBridgeError, type BridgeErrorCapture } from './bridge/bridge-error-capture'
+import { BRIDGE_NATIVE_GRANTS, createBridgeInitFrame } from './bridge/bridge-init-frame'
+import { bridgeNotifyRefusal, type BridgeNotifyRefusal } from './bridge/bridge-notify-grants'
import { splitBridgeReply } from './bridge/bridge-reply-chunking'
type RequestMessage = Extract
@@ -32,12 +40,15 @@ export type BridgeHostDiagnostic =
/** 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. */
+ /** A listener 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' }
+ /** A `notify` the host will not act on: a grant-gated name it never issued, or any name from a
+ * page that has not asked for a session yet. Nothing is owed back, so it is logged and dropped. */
+ | { kind: 'notify-refused'; name: string; why: BridgeNotifyRefusal }
export type BridgeHostOptions = {
client: RpcClient
@@ -48,6 +59,18 @@ export type BridgeHostOptions = {
post: (json: string) => Promise
buildId: string
sessionId: string
+ /**
+ * The page could not render the generation it was handed. Required, because the page has no
+ * recovery of its own: the generation is on disk and was hash-checked before the view loaded it,
+ * so the same bytes throw again, and the only thing left is for the shell to stop showing them.
+ */
+ onPageFault: (error: BridgeErrorCapture) => void
+ /**
+ * The page asked for a session, which is the only proof its bundle evaluated at all. Required for
+ * the same reason as the fault: the shell bounds the wait for it, and a host built without this
+ * would leave a document that never spoke looking exactly like one still starting up.
+ */
+ onPageReady: () => void
onDiagnostic?: (diagnostic: BridgeHostDiagnostic) => void
}
@@ -56,27 +79,6 @@ export type BridgeHost = {
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.
*
@@ -98,6 +100,10 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
// No epoch rides along: one native listener delivers page frames in order, so a straggler from
// the closed document is always behind it and ahead of the next document's `ready`.
let serving = true
+ // Whether this host has ever answered a `ready`. Not the same as `serving`, which starts true so
+ // the first document's frames are not refused for arriving in the same batch as its `ready`: this
+ // one starts false, because a page that has been told no grants holds none.
+ let initSent = false
let postFailureReported = false
let notifyFailureReported = false
@@ -156,21 +162,8 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
// 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: []
- }
- })
+ initSent = true
+ send(createBridgeInitFrame({ sessionId, buildId, connection: snapshot() }))
}
function settle(id: string, record: PendingRequest): boolean {
@@ -269,7 +262,22 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
/** 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 {
+ const refusal = bridgeNotifyRefusal({
+ name: message.name,
+ initSent,
+ granted: BRIDGE_NATIVE_GRANTS
+ })
+ if (refusal !== null) {
+ options.onDiagnostic?.({ kind: 'notify-refused', name: message.name, why: refusal })
+ return
+ }
try {
+ if (message.name === BRIDGE_FAULT_GRANT) {
+ // Not the client's: a page that threw is this session's problem, and the desktop on the
+ // other end of the client has nothing to do with it.
+ options.onPageFault(message.error)
+ return
+ }
if (message.name === 'foreground') {
if (message.reason === undefined) {
client.notifyForeground()
@@ -283,8 +291,8 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
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.
+ // Once per session, for the reason a failing post is: a page nudging a broken listener nudges
+ // it again on every foreground.
if (notifyFailureReported) {
return
}
@@ -323,6 +331,9 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
if (message.type === 'ready') {
serving = true
sendInit()
+ // Every time it is asked, not once: the page re-asks on a backoff, and the shell's wait ends
+ // on the first of those that lands rather than on a particular one.
+ options.onPageReady()
return
}
if (!serving) {
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-client-inbound-frames.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-client-inbound-frames.test.ts
new file mode 100644
index 00000000000..e7b942abeb4
--- /dev/null
+++ b/mobile/src/mobile-web-shell/bridge/bridge-client-inbound-frames.test.ts
@@ -0,0 +1,167 @@
+/** What the page does with a frame its own reader will not take: which exchange it settles,
+ * which stream it releases at the shell, and what it reports for a frame naming no exchange. */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { isRpcDeliveryUnknown } from '../../transport/rpc-delivery-ambiguity'
+import { BRIDGE_MAX_MESSAGE_BYTES } from './bridge-caps'
+import { BRIDGE_PROTOCOL_VERSION } from './bridge-envelope'
+import { createPageClient, idOf, readError } from './bridge-page-client-test-harness'
+
+beforeEach(() => {
+ vi.useFakeTimers()
+})
+
+afterEach(() => {
+ vi.useRealTimers()
+})
+
+describe('bridge client refusals and send failures', () => {
+ it('reports a frame its own reader will not take, and changes nothing', () => {
+ const page = createPageClient()
+ page.start()
+ page.deliverRaw('{ not json')
+ page.deliverRaw(JSON.stringify({ v: 99, type: 'state' }))
+ page.deliverRaw(`"${'z'.repeat(BRIDGE_MAX_MESSAGE_BYTES)}"`)
+ expect(page.diagnostics).toEqual([
+ { kind: 'refused', refusal: 'malformed-json' },
+ { kind: 'refused', refusal: 'unrecognised-message' },
+ { kind: 'refused', refusal: 'oversized' }
+ ])
+ expect(page.client.getState()).toBe('connected')
+ })
+
+ it('settles the request a reply it could not read was answering, on the same turn', async () => {
+ const page = createPageClient()
+ page.start()
+ const answer = page.client.sendRequest('worktree.ps')
+ // `{ ok: true }` with no `result`: refused by this reader and by `isRpcResponse` alike.
+ page.deliver({
+ v: BRIDGE_PROTOCOL_VERSION,
+ type: 'reply',
+ id: idOf(page, 0),
+ payload: { id: 'frame-1', ok: true }
+ })
+ // No timer is advanced: a caller that has to wait for one has already rendered without it.
+ const error = await answer.catch((thrown: unknown) => thrown)
+ expect(readError(error).name).toBe('BridgeReplyRefusedError')
+ expect(readError(error).message).toContain('unrecognised-message')
+ // The shell answered, so the desktop ran the request; a definite failure would invite a retry.
+ expect(isRpcDeliveryUnknown(error)).toBe(true)
+ expect(page.diagnostics).toContainEqual({
+ kind: 'refused',
+ refusal: 'unrecognised-message'
+ })
+ })
+
+ it('ends the stream an event it could not read belonged to, and cancels it at the shell', () => {
+ const page = createPageClient()
+ page.start()
+ const ended: unknown[] = []
+ page.client.subscribe('terminal.stream', { terminal: 't1' }, (result) => {
+ ended.push(result)
+ })
+ page.deliver({ v: BRIDGE_PROTOCOL_VERSION, type: 'event', id: idOf(page, 0), seq: -1 })
+ expect(page.diagnostics.map((diagnostic) => diagnostic.kind)).toEqual([
+ 'refused',
+ 'stream-failed'
+ ])
+ expect(ended).toHaveLength(1)
+ // The shell did not retire this stream — it is still sending on it — so nothing but the page's
+ // own `cancel` releases the slot it holds there. Its overflow backstop counts unacked frames,
+ // which a stream that has gone quiet never reaches.
+ expect(page.frames().at(-1)).toEqual({
+ v: BRIDGE_PROTOCOL_VERSION,
+ type: 'cancel',
+ id: idOf(page, 0),
+ target: 'subscription'
+ })
+ })
+
+ it('cancels nothing for a stream the shell has already retired', () => {
+ const page = createPageClient()
+ page.start()
+ page.client.subscribe('terminal.stream', { terminal: 't1' }, () => undefined)
+ const id = idOf(page, 0)
+ const posted = page.frames().length
+ page.deliver({ v: BRIDGE_PROTOCOL_VERSION, type: 'end', id, reason: 'closed' })
+ page.deliver({ v: BRIDGE_PROTOCOL_VERSION, type: 'error', id, error: { message: 'gone' } })
+ expect(page.frames()).toHaveLength(posted)
+ })
+
+ it('cannot settle a reply whose frame it never parsed, and nothing on the page can', async () => {
+ const page = createPageClient()
+ page.start()
+ const answer = page.client.sendRequest('worktree.ps')
+ let settled = false
+ void answer.then(
+ () => {
+ settled = true
+ },
+ () => {
+ settled = true
+ }
+ )
+ // The two refusals that come before the id does. `oversized` is decided on the raw string and
+ // `malformed-json` on a parse that failed, so neither frame ever yields an id to settle: what
+ // the page holds for it is released by `close` or by a shell replacement and by nothing else.
+ // Neither arises from a host that is behaving: it chunks at the frame cap, refuses a body over
+ // `BRIDGE_MAX_REPLY_BYTES` on its own side, and answers that with an `error` frame instead.
+ page.deliverRaw(`{"v":1,"type":"reply","id":"${idOf(page, 0)}",`)
+ page.deliverRaw(`"${'z'.repeat(BRIDGE_MAX_MESSAGE_BYTES)}"`)
+ await Promise.resolve()
+ expect(page.diagnostics).toEqual([
+ { kind: 'refused', refusal: 'malformed-json' },
+ { kind: 'refused', refusal: 'oversized' }
+ ])
+ expect(settled).toBe(false)
+ page.client.close()
+ const error = await answer.catch((thrown: unknown) => thrown)
+ expect(readError(error).name).toBe('BridgeClientClosedError')
+ expect(isRpcDeliveryUnknown(error)).toBe(true)
+ })
+
+ it('leaves a refused frame that names no open exchange to the diagnostic alone', async () => {
+ const page = createPageClient()
+ page.start()
+ const answer = page.client.sendRequest('worktree.ps')
+ page.deliver({
+ v: BRIDGE_PROTOCOL_VERSION,
+ type: 'reply',
+ id: 'ZZZZZZZZZZZZZZZZZZZZZZ',
+ payload: { id: 'frame-1', ok: true }
+ })
+ expect(page.diagnostics).toEqual([{ kind: 'refused', refusal: 'unrecognised-message' }])
+ page.deliver({
+ v: BRIDGE_PROTOCOL_VERSION,
+ type: 'reply',
+ id: idOf(page, 0),
+ payload: { id: 'frame-1', ok: true, result: 7, _meta: { runtimeId: 'runtime-a' } }
+ })
+ await expect(answer).resolves.toEqual({
+ id: 'frame-1',
+ ok: true,
+ result: 7,
+ _meta: { runtimeId: 'runtime-a' }
+ })
+ })
+
+ it('fails a request whose frame never left the page, without the delivery mark', async () => {
+ let live = true
+ const page = createPageClient({
+ send: () => {
+ if (!live) {
+ throw new Error('the port is gone')
+ }
+ }
+ })
+ page.start()
+ live = false
+ const answer = page.client.sendRequest('worktree.ps')
+ const error = await answer.catch((thrown: unknown) => thrown)
+ expect(readError(error).name).toBe('BridgeSendFailedError')
+ expect(isRpcDeliveryUnknown(error)).toBe(false)
+ expect(page.diagnostics.at(-1)).toEqual({
+ kind: 'send-failed',
+ error: expect.any(Error)
+ })
+ })
+})
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.test.ts
new file mode 100644
index 00000000000..516346ec7c9
--- /dev/null
+++ b/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.test.ts
@@ -0,0 +1,60 @@
+/** The page's outbound notify surface: what it posts, what it stays quiet about, and what it
+ * answers when the shell granted nothing or the port refused the frame. */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { BRIDGE_FAULT_GRANT, BRIDGE_PROTOCOL_VERSION } from './bridge-envelope'
+import { GRANTS, INIT, createPageClient } from './bridge-page-client-test-harness'
+
+beforeEach(() => {
+ vi.useFakeTimers()
+})
+
+afterEach(() => {
+ vi.useRealTimers()
+})
+
+describe('bridge client page faults', () => {
+ /** A shell that says it will act on a fault, which is the only kind the page posts one to. */
+ function startGranted(page: ReturnType): void {
+ page.deliver({ ...INIT, grants: { ...GRANTS, native: [BRIDGE_FAULT_GRANT] } })
+ }
+
+ it('posts the captured error once the shell has granted fault reporting', () => {
+ const page = createPageClient()
+ startGranted(page)
+ expect(page.client.notifyPageFault(new Error('the route threw'))).toBe(true)
+ expect(page.frames().at(-1)).toEqual({
+ v: BRIDGE_PROTOCOL_VERSION,
+ type: 'notify',
+ name: BRIDGE_FAULT_GRANT,
+ error: { category: 'Error', message: 'the route threw', isRpcDeliveryUnknown: false }
+ })
+ })
+
+ it('stays quiet against a shell that granted nothing, because the frame would be refused whole', () => {
+ const page = createPageClient()
+ page.start()
+ expect(page.client.notifyPageFault(new Error('the route threw'))).toBe(false)
+ expect(page.sent).toHaveLength(1)
+ })
+
+ it('answers false before a session and after close rather than throwing at a boundary', () => {
+ const early = createPageClient()
+ expect(early.client.notifyPageFault(new Error('too soon'))).toBe(false)
+ const page = createPageClient()
+ startGranted(page)
+ page.client.close()
+ expect(page.client.notifyPageFault(new Error('too late'))).toBe(false)
+ expect(page.frames().at(-1)).toEqual({ v: BRIDGE_PROTOCOL_VERSION, type: 'close' })
+ })
+
+ it('answers false for a port that refused the frame, and reports it once', () => {
+ const page = createPageClient({
+ send: () => {
+ throw new Error('the channel is gone')
+ }
+ })
+ startGranted(page)
+ expect(page.client.notifyPageFault(new Error('the route threw'))).toBe(false)
+ expect(page.diagnostics.map((diagnostic) => diagnostic.kind)).toContain('send-failed')
+ })
+})
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.ts b/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.ts
new file mode 100644
index 00000000000..79b19b33dd0
--- /dev/null
+++ b/mobile/src/mobile-web-shell/bridge/bridge-client-notifications.ts
@@ -0,0 +1,81 @@
+import type { ForegroundNudgeReason } from '../../transport/types'
+import {
+ BRIDGE_FAULT_GRANT,
+ BRIDGE_PROTOCOL_VERSION,
+ type BridgeClientMessage
+} from './bridge-envelope'
+import { captureBridgeError } from './bridge-error-capture'
+
+/** Everything the page tells the shell without waiting for an answer. */
+export type BridgeClientNotifications = {
+ updateTerminalSubscriptionViewport: (
+ terminal: string,
+ viewport: { cols: number; rows: number }
+ ) => void
+ notifyForeground: (reason?: ForegroundNudgeReason) => void
+ notifyPageFault: (error: unknown) => boolean
+}
+
+export type BridgeClientNotificationDeps = {
+ /** False when the frame never left the page. */
+ send: (frame: BridgeClientMessage) => boolean
+ /** Throws for a call that arrived before `init`, which is always a mount-order bug. */
+ requireSession: () => void
+ isClosed: () => boolean
+ /** What `init.grants.native` listed, which is the only thing that makes a name safe to post. */
+ hasGrant: (grant: string) => boolean
+}
+
+/**
+ * The one-way half of the page's client.
+ *
+ * Two policies split them. The two the native contract declares answer nothing and throw before a
+ * session, because a screen calling them early is a bug in this bundle. The fault report answers a
+ * boolean and never throws, because its one caller is an error boundary and a report that threw
+ * would replace the page's last word with an error nobody catches.
+ */
+export function createBridgeClientNotifications(
+ deps: BridgeClientNotificationDeps
+): BridgeClientNotifications {
+ return {
+ updateTerminalSubscriptionViewport: (terminal, viewport) => {
+ deps.requireSession()
+ if (deps.isClosed()) {
+ return
+ }
+ deps.send({
+ v: BRIDGE_PROTOCOL_VERSION,
+ type: 'notify',
+ name: 'terminalViewport',
+ terminal,
+ cols: viewport.cols,
+ rows: viewport.rows
+ })
+ },
+ notifyForeground: (reason?: ForegroundNudgeReason) => {
+ deps.requireSession()
+ if (deps.isClosed()) {
+ return
+ }
+ deps.send({
+ v: BRIDGE_PROTOCOL_VERSION,
+ type: 'notify',
+ name: 'foreground',
+ ...(reason === undefined ? {} : { reason })
+ })
+ },
+ notifyPageFault: (error: unknown) => {
+ // Read rather than required: `requireSession` throws, and this is called from a
+ // `componentDidCatch` where a throw is the second failure and the first one's grave.
+ if (deps.isClosed() || !deps.hasGrant(BRIDGE_FAULT_GRANT)) {
+ return false
+ }
+ return deps.send({
+ v: BRIDGE_PROTOCOL_VERSION,
+ type: 'notify',
+ name: BRIDGE_FAULT_GRANT,
+ error: captureBridgeError(error)
+ })
+ }
+ }
+}
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 ab86a677995..171916625f6 100644
--- a/mobile/src/mobile-web-shell/bridge/bridge-envelope.test.ts
+++ b/mobile/src/mobile-web-shell/bridge/bridge-envelope.test.ts
@@ -19,6 +19,7 @@ import {
import {
BRIDGE_BINARY_FORMATS,
BRIDGE_CONNECTION_STATES,
+ BRIDGE_FAULT_GRANT,
BRIDGE_FOREGROUND_NUDGE_REASONS,
BRIDGE_PROTOCOL_VERSION,
readBridgeClientMessage,
@@ -114,6 +115,14 @@ describe('client messages', () => {
rows: BRIDGE_MAX_VIEWPORT_ROWS
}
],
+ [
+ 'a page fault notify',
+ {
+ type: 'notify',
+ name: BRIDGE_FAULT_GRANT,
+ error: { category: 'Error', message: 'the route threw', isRpcDeliveryUnknown: false }
+ }
+ ],
['close', { type: 'close' }]
] as const
@@ -162,6 +171,11 @@ describe('client messages', () => {
rows: BRIDGE_MAX_VIEWPORT_ROWS + 1
})
],
+ ['a page fault carrying no error', client({ type: 'notify', name: BRIDGE_FAULT_GRANT })],
+ [
+ 'a page fault whose error is not a capture',
+ client({ type: 'notify', name: BRIDGE_FAULT_GRANT, error: 'the route threw' })
+ ],
['a bare array', []],
['a bare string', 'ready']
] as const
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-envelope.ts b/mobile/src/mobile-web-shell/bridge/bridge-envelope.ts
index 13747082f8e..de9b1a0402a 100644
--- a/mobile/src/mobile-web-shell/bridge/bridge-envelope.ts
+++ b/mobile/src/mobile-web-shell/bridge/bridge-envelope.ts
@@ -84,6 +84,16 @@ export const BridgeGrantsSchema = z.object({
export type BridgeGrants = z.infer
+/**
+ * The one grant negotiated for the protocol itself rather than for a screen: the shell saying it
+ * will act on a `fault` report.
+ *
+ * It exists because `notify` is a closed list on both sides. A page served by a newer desktop into
+ * an older shell that posted an unknown name would have the whole frame refused as
+ * `unrecognised-message`, so the page asks first and stays quiet when the answer is no.
+ */
+export const BRIDGE_FAULT_GRANT = 'fault'
+
/** Pinned against `SendRequestOptions` in this module's test. */
export const BridgeSendRequestOptionsSchema = z.object({
timeoutMs: z.number().int().positive().optional(),
@@ -175,6 +185,14 @@ const BridgeClientMessageSchema = z.discriminatedUnion('type', [
terminal: z.string().min(1),
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('notify'),
+ name: z.literal(BRIDGE_FAULT_GRANT),
+ /** The capture an `error` frame already carries, so both directions share one bound and one
+ * reader. Nothing is owed back: the page is telling the shell, not asking it. */
+ error: BridgeErrorCaptureSchema
})
]),
z.object({ v: versionSchema, type: z.literal('close') })
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-init-frame.ts b/mobile/src/mobile-web-shell/bridge/bridge-init-frame.ts
new file mode 100644
index 00000000000..55a9b96dfbb
--- /dev/null
+++ b/mobile/src/mobile-web-shell/bridge/bridge-init-frame.ts
@@ -0,0 +1,40 @@
+import { BRIDGE_MAX_PENDING_REQUESTS, BRIDGE_MAX_SUBSCRIPTIONS } from './bridge-caps'
+import {
+ BRIDGE_FAULT_GRANT,
+ BRIDGE_PROTOCOL_VERSION,
+ type BridgeConnectionSnapshot,
+ type BridgeHostMessage
+} from './bridge-envelope'
+
+/**
+ * What `init` offers every page.
+ *
+ * A name added here is never a version bump. `fault` is what lets the page report that it could not
+ * render, and it is offered to every page because it is the protocol's grant, not a screen's. The
+ * host enforces this same list, so what the page is told and what it will be served cannot drift.
+ */
+export const BRIDGE_NATIVE_GRANTS: readonly string[] = [BRIDGE_FAULT_GRANT]
+
+/** The one frame that starts a session, built in one place so its caps and its grants agree. */
+export function createBridgeInitFrame(args: {
+ sessionId: string
+ buildId: string
+ connection: BridgeConnectionSnapshot
+}): Extract {
+ return {
+ v: BRIDGE_PROTOCOL_VERSION,
+ type: 'init',
+ sessionId: args.sessionId,
+ buildId: args.buildId,
+ connection: args.connection,
+ grants: {
+ rpc: {
+ maxPendingRequests: BRIDGE_MAX_PENDING_REQUESTS,
+ maxSubscriptions: BRIDGE_MAX_SUBSCRIPTIONS
+ },
+ // Copied, not shared: the list the host enforces must not be reachable through a frame it
+ // hands out.
+ native: [...BRIDGE_NATIVE_GRANTS]
+ }
+ }
+}
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-notify-grants.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-notify-grants.test.ts
new file mode 100644
index 00000000000..4158d5f5969
--- /dev/null
+++ b/mobile/src/mobile-web-shell/bridge/bridge-notify-grants.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from 'vitest'
+import { BRIDGE_FAULT_GRANT } from './bridge-envelope'
+import { BRIDGE_GRANT_GATED_NOTIFY_NAMES, bridgeNotifyRefusal } from './bridge-notify-grants'
+
+const GRANTED = [BRIDGE_FAULT_GRANT]
+
+describe('what the host will act on', () => {
+ it('refuses every name from a page that has not been told anything', () => {
+ for (const name of [BRIDGE_FAULT_GRANT, 'foreground', 'terminalViewport']) {
+ expect(bridgeNotifyRefusal({ name, initSent: false, granted: GRANTED }), name).toBe(
+ 'before-ready'
+ )
+ }
+ })
+
+ it('refuses a gated name this host did not issue', () => {
+ // Unreachable while every page is offered `fault`, and the whole point of the check once a
+ // grant is per-route: a page on a screen that was granted nothing must not be served one.
+ expect(bridgeNotifyRefusal({ name: BRIDGE_FAULT_GRANT, initSent: true, granted: [] })).toBe(
+ 'ungranted'
+ )
+ })
+
+ it('serves a gated name this host did issue', () => {
+ expect(
+ bridgeNotifyRefusal({ name: BRIDGE_FAULT_GRANT, initSent: true, granted: GRANTED })
+ ).toBeNull()
+ })
+
+ it("serves the protocol's own names against a page that holds no grant at all", () => {
+ // `foreground` and the viewport are not grants and must not become ones by being in this file.
+ for (const name of ['foreground', 'terminalViewport']) {
+ expect(bridgeNotifyRefusal({ name, initSent: true, granted: [] }), name).toBeNull()
+ expect(BRIDGE_GRANT_GATED_NOTIFY_NAMES, name).not.toContain(name)
+ }
+ })
+})
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-notify-grants.ts b/mobile/src/mobile-web-shell/bridge/bridge-notify-grants.ts
new file mode 100644
index 00000000000..f41cba1547a
--- /dev/null
+++ b/mobile/src/mobile-web-shell/bridge/bridge-notify-grants.ts
@@ -0,0 +1,32 @@
+import { BRIDGE_FAULT_GRANT } from './bridge-envelope'
+
+/**
+ * Which `notify` names a grant gates, and whether the host will act on one.
+ *
+ * `foreground` and `terminalViewport` are the protocol's own and ride no grant, so they are not
+ * listed. A name that is listed is served only when `init.grants.native` carried it — inert while
+ * every page is offered `fault`, and load-bearing the moment a grant is per-route.
+ */
+export const BRIDGE_GRANT_GATED_NOTIFY_NAMES: readonly string[] = [BRIDGE_FAULT_GRANT]
+
+export type BridgeNotifyRefusal = 'before-ready' | 'ungranted'
+
+/**
+ * Two refusals, not one.
+ *
+ * A page that has not asked for a session has been told nothing, so it holds no grant and cannot
+ * have been given one. A page that has been told a list can still post a name outside it, and a
+ * host issuing a grant is worth nothing if it serves the name anyway.
+ */
+export function bridgeNotifyRefusal(args: {
+ name: string
+ /** Whether this host has answered a `ready` yet, which is the only thing that issues grants. */
+ initSent: boolean
+ granted: readonly string[]
+}): BridgeNotifyRefusal | null {
+ if (!args.initSent) {
+ return 'before-ready'
+ }
+ const gated = BRIDGE_GRANT_GATED_NOTIFY_NAMES.includes(args.name)
+ return gated && !args.granted.includes(args.name) ? 'ungranted' : null
+}
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-page-client-test-harness.ts b/mobile/src/mobile-web-shell/bridge/bridge-page-client-test-harness.ts
new file mode 100644
index 00000000000..e66f6094c1e
--- /dev/null
+++ b/mobile/src/mobile-web-shell/bridge/bridge-page-client-test-harness.ts
@@ -0,0 +1,103 @@
+/** The page-side client harness the bridge frame suites share: one fake port, its sent frames,
+ * its diagnostics, and the readers a case needs to talk about either. */
+import {
+ BRIDGE_PROTOCOL_VERSION,
+ readBridgeClientMessage,
+ type BridgeClientMessage,
+ type BridgeHostMessage
+} from './bridge-envelope'
+import { createBridgeRpcClient, type BridgeRpcClientDiagnostic } from './bridge-rpc-client'
+
+export const CONNECTION = {
+ state: 'connected',
+ reconnectAttempt: 2,
+ lastConnectedAt: 1700,
+ lastInboundAt: 1800,
+ generation: 5
+} as const
+
+export const GRANTS = { rpc: { maxPendingRequests: 64, maxSubscriptions: 32 }, native: [] }
+
+/** The init member, not the whole union: an imported binding keeps its declared type, and a
+ * case reads `INIT.grants` off it. */
+export const INIT: Extract = {
+ v: BRIDGE_PROTOCOL_VERSION,
+ type: 'init',
+ sessionId: 'session-a',
+ buildId: 'build-a',
+ connection: CONNECTION,
+ grants: GRANTS
+}
+
+type PageClientOptions = {
+ send?: (json: string) => void
+ /** A port that ignores its own unsubscribe, which is the only way to observe the read guard. */
+ keepDeliveringAfterUnsubscribe?: boolean
+}
+
+export function createPageClient(options: PageClientOptions = {}) {
+ const sent: string[] = []
+ const diagnostics: BridgeRpcClientDiagnostic[] = []
+ let handler: ((json: string) => void) | null = null
+ const client = createBridgeRpcClient({
+ send: (json) => {
+ sent.push(json)
+ options.send?.(json)
+ },
+ onMessage: (received) => {
+ handler = received
+ return () => {
+ if (options.keepDeliveringAfterUnsubscribe !== true) {
+ handler = null
+ }
+ }
+ },
+ onDiagnostic: (diagnostic) => {
+ diagnostics.push(diagnostic)
+ }
+ })
+ return {
+ client,
+ sent,
+ diagnostics,
+ deliver(frame: unknown): void {
+ handler?.(JSON.stringify(frame))
+ },
+ deliverRaw(json: string): void {
+ handler?.(json)
+ },
+ frames(): BridgeClientMessage[] {
+ return sent.map((json) => {
+ const read = readBridgeClientMessage(json)
+ if (!read.ok) {
+ throw new Error(`the shell would have refused this frame: ${read.refusal}`)
+ }
+ return read.message
+ })
+ },
+ start(): void {
+ this.deliver(INIT)
+ }
+ }
+}
+
+/** Narrows what a rejection handed back, so a test reads an error rather than asserting one. */
+export function readError(thrown: unknown): Error {
+ if (!(thrown instanceof Error)) {
+ throw new Error(`expected an Error, got ${typeof thrown}`)
+ }
+ return thrown
+}
+
+export function eventFrame(id: string, seq: number, payload: unknown): BridgeHostMessage {
+ return { v: BRIDGE_PROTOCOL_VERSION, type: 'event', id, seq, payload }
+}
+
+/** The id the client minted for the nth exchange it opened, read back off its own frame. */
+export function idOf(page: ReturnType, index: number): string {
+ const frame = page.frames().filter((message) => 'id' in message)[index]
+ if (frame === undefined || !('id' in frame)) {
+ throw new Error('the page opened no such exchange')
+ }
+ return frame.id
+}
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.ts b/mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.ts
index 67857309444..adb26bc795b 100644
--- a/mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.ts
+++ b/mobile/src/mobile-web-shell/bridge/bridge-port-pair-test-harness.ts
@@ -7,6 +7,7 @@ import {
type BridgeClientMessage,
type BridgeHostMessage
} from './bridge-envelope'
+import type { BridgeErrorCapture } from './bridge-error-capture'
import {
createBridgeRpcClient,
type BridgeRpcClient,
@@ -35,6 +36,10 @@ export type BridgePortPair = {
toPage: string[]
diagnostics: BridgeRpcClientDiagnostic[]
hostDiagnostics: BridgeHostDiagnostic[]
+ /** Every fault the page reported, in order, as the shell received it. */
+ pageFaults: BridgeErrorCapture[]
+ /** How many times the page asked for a session; it re-asks on a backoff until one lands. */
+ readonly pageReadyCount: () => number
/** Runs both lanes until a full round moves nothing. */
flush: () => Promise
/**
@@ -130,6 +135,8 @@ export function createBridgePortPair(
const rpc = options.rpc
const diagnostics: BridgeRpcClientDiagnostic[] = []
const hostDiagnostics: BridgeHostDiagnostic[] = []
+ const pageFaults: BridgeErrorCapture[] = []
+ let pageReadies = 0
let receiveOnPage: ((json: string) => void) | null = null
const rewrite = options.rewriteToPage ?? ((json: string) => json)
@@ -144,6 +151,10 @@ export function createBridgePortPair(
},
buildId: options.buildId ?? 'build-a',
sessionId: options.sessionId ?? 'session-a',
+ onPageFault: (error) => pageFaults.push(error),
+ onPageReady: () => {
+ pageReadies += 1
+ },
onDiagnostic: (diagnostic) => hostDiagnostics.push(diagnostic)
})
const toShell = createLane((json) => {
@@ -170,6 +181,8 @@ export function createBridgePortPair(
toPage: toPage.sent,
diagnostics,
hostDiagnostics,
+ pageFaults,
+ pageReadyCount: () => pageReadies,
async flush(): Promise {
for (let round = 0; round < 64; round += 1) {
const moved = toShell.sent.length + toPage.sent.length
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-rpc-client-frames.test.ts b/mobile/src/mobile-web-shell/bridge/bridge-rpc-client-frames.test.ts
index 8a85b4f0520..7ffb86f8eb1 100644
--- a/mobile/src/mobile-web-shell/bridge/bridge-rpc-client-frames.test.ts
+++ b/mobile/src/mobile-web-shell/bridge/bridge-rpc-client-frames.test.ts
@@ -11,12 +11,7 @@ import {
BRIDGE_ACK_INTERVAL_BYTES,
BRIDGE_ACK_INTERVAL_FRAMES
} from './bridge-client-subscriptions'
-import {
- BRIDGE_PROTOCOL_VERSION,
- readBridgeClientMessage,
- type BridgeClientMessage,
- type BridgeHostMessage
-} from './bridge-envelope'
+import { BRIDGE_PROTOCOL_VERSION, type BridgeHostMessage } from './bridge-envelope'
import {
BRIDGE_READY_RETRY_MAX_MS,
BRIDGE_READY_RETRY_MIN_MS
@@ -24,100 +19,16 @@ import {
import {
BridgeClientCapExceededError,
BridgeClientClosedError,
- BridgeClientNotReadyError,
- createBridgeRpcClient,
- type BridgeRpcClientDiagnostic
+ BridgeClientNotReadyError
} from './bridge-rpc-client'
-
-const CONNECTION = {
- state: 'connected',
- reconnectAttempt: 2,
- lastConnectedAt: 1700,
- lastInboundAt: 1800,
- generation: 5
-} as const
-
-const INIT: BridgeHostMessage = {
- v: BRIDGE_PROTOCOL_VERSION,
- type: 'init',
- sessionId: 'session-a',
- buildId: 'build-a',
- connection: CONNECTION,
- grants: { rpc: { maxPendingRequests: 64, maxSubscriptions: 32 }, native: [] }
-}
-
-type PageClientOptions = {
- send?: (json: string) => void
- /** A port that ignores its own unsubscribe, which is the only way to observe the read guard. */
- keepDeliveringAfterUnsubscribe?: boolean
-}
-
-function createPageClient(options: PageClientOptions = {}) {
- const sent: string[] = []
- const diagnostics: BridgeRpcClientDiagnostic[] = []
- let handler: ((json: string) => void) | null = null
- const client = createBridgeRpcClient({
- send: (json) => {
- sent.push(json)
- options.send?.(json)
- },
- onMessage: (received) => {
- handler = received
- return () => {
- if (options.keepDeliveringAfterUnsubscribe !== true) {
- handler = null
- }
- }
- },
- onDiagnostic: (diagnostic) => {
- diagnostics.push(diagnostic)
- }
- })
- return {
- client,
- sent,
- diagnostics,
- deliver(frame: unknown): void {
- handler?.(JSON.stringify(frame))
- },
- deliverRaw(json: string): void {
- handler?.(json)
- },
- frames(): BridgeClientMessage[] {
- return sent.map((json) => {
- const read = readBridgeClientMessage(json)
- if (!read.ok) {
- throw new Error(`the shell would have refused this frame: ${read.refusal}`)
- }
- return read.message
- })
- },
- start(): void {
- this.deliver(INIT)
- }
- }
-}
-
-/** Narrows what a rejection handed back, so a test reads an error rather than asserting one. */
-function readError(thrown: unknown): Error {
- if (!(thrown instanceof Error)) {
- throw new Error(`expected an Error, got ${typeof thrown}`)
- }
- return thrown
-}
-
-function eventFrame(id: string, seq: number, payload: unknown): BridgeHostMessage {
- return { v: BRIDGE_PROTOCOL_VERSION, type: 'event', id, seq, payload }
-}
-
-/** The id the client minted for the nth exchange it opened, read back off its own frame. */
-function idOf(page: ReturnType, index: number): string {
- const frame = page.frames().filter((message) => 'id' in message)[index]
- if (frame === undefined || !('id' in frame)) {
- throw new Error('the page opened no such exchange')
- }
- return frame.id
-}
+import {
+ CONNECTION,
+ INIT,
+ createPageClient,
+ eventFrame,
+ idOf,
+ readError
+} from './bridge-page-client-test-harness'
beforeEach(() => {
vi.useFakeTimers()
@@ -484,158 +395,6 @@ describe('bridge client replies', () => {
})
})
-describe('bridge client refusals and send failures', () => {
- it('reports a frame its own reader will not take, and changes nothing', () => {
- const page = createPageClient()
- page.start()
- page.deliverRaw('{ not json')
- page.deliverRaw(JSON.stringify({ v: 99, type: 'state' }))
- page.deliverRaw(`"${'z'.repeat(BRIDGE_MAX_MESSAGE_BYTES)}"`)
- expect(page.diagnostics).toEqual([
- { kind: 'refused', refusal: 'malformed-json' },
- { kind: 'refused', refusal: 'unrecognised-message' },
- { kind: 'refused', refusal: 'oversized' }
- ])
- expect(page.client.getState()).toBe('connected')
- })
-
- it('settles the request a reply it could not read was answering, on the same turn', async () => {
- const page = createPageClient()
- page.start()
- const answer = page.client.sendRequest('worktree.ps')
- // `{ ok: true }` with no `result`: refused by this reader and by `isRpcResponse` alike.
- page.deliver({
- v: BRIDGE_PROTOCOL_VERSION,
- type: 'reply',
- id: idOf(page, 0),
- payload: { id: 'frame-1', ok: true }
- })
- // No timer is advanced: a caller that has to wait for one has already rendered without it.
- const error = await answer.catch((thrown: unknown) => thrown)
- expect(readError(error).name).toBe('BridgeReplyRefusedError')
- expect(readError(error).message).toContain('unrecognised-message')
- // The shell answered, so the desktop ran the request; a definite failure would invite a retry.
- expect(isRpcDeliveryUnknown(error)).toBe(true)
- expect(page.diagnostics).toContainEqual({
- kind: 'refused',
- refusal: 'unrecognised-message'
- })
- })
-
- it('ends the stream an event it could not read belonged to, and cancels it at the shell', () => {
- const page = createPageClient()
- page.start()
- const ended: unknown[] = []
- page.client.subscribe('terminal.stream', { terminal: 't1' }, (result) => {
- ended.push(result)
- })
- page.deliver({ v: BRIDGE_PROTOCOL_VERSION, type: 'event', id: idOf(page, 0), seq: -1 })
- expect(page.diagnostics.map((diagnostic) => diagnostic.kind)).toEqual([
- 'refused',
- 'stream-failed'
- ])
- expect(ended).toHaveLength(1)
- // The shell did not retire this stream — it is still sending on it — so nothing but the page's
- // own `cancel` releases the slot it holds there. Its overflow backstop counts unacked frames,
- // which a stream that has gone quiet never reaches.
- expect(page.frames().at(-1)).toEqual({
- v: BRIDGE_PROTOCOL_VERSION,
- type: 'cancel',
- id: idOf(page, 0),
- target: 'subscription'
- })
- })
-
- it('cancels nothing for a stream the shell has already retired', () => {
- const page = createPageClient()
- page.start()
- page.client.subscribe('terminal.stream', { terminal: 't1' }, () => undefined)
- const id = idOf(page, 0)
- const posted = page.frames().length
- page.deliver({ v: BRIDGE_PROTOCOL_VERSION, type: 'end', id, reason: 'closed' })
- page.deliver({ v: BRIDGE_PROTOCOL_VERSION, type: 'error', id, error: { message: 'gone' } })
- expect(page.frames()).toHaveLength(posted)
- })
-
- it('cannot settle a reply whose frame it never parsed, and nothing on the page can', async () => {
- const page = createPageClient()
- page.start()
- const answer = page.client.sendRequest('worktree.ps')
- let settled = false
- void answer.then(
- () => {
- settled = true
- },
- () => {
- settled = true
- }
- )
- // The two refusals that come before the id does. `oversized` is decided on the raw string and
- // `malformed-json` on a parse that failed, so neither frame ever yields an id to settle: what
- // the page holds for it is released by `close` or by a shell replacement and by nothing else.
- // Neither arises from a host that is behaving: it chunks at the frame cap, refuses a body over
- // `BRIDGE_MAX_REPLY_BYTES` on its own side, and answers that with an `error` frame instead.
- page.deliverRaw(`{"v":1,"type":"reply","id":"${idOf(page, 0)}",`)
- page.deliverRaw(`"${'z'.repeat(BRIDGE_MAX_MESSAGE_BYTES)}"`)
- await Promise.resolve()
- expect(page.diagnostics).toEqual([
- { kind: 'refused', refusal: 'malformed-json' },
- { kind: 'refused', refusal: 'oversized' }
- ])
- expect(settled).toBe(false)
- page.client.close()
- const error = await answer.catch((thrown: unknown) => thrown)
- expect(readError(error).name).toBe('BridgeClientClosedError')
- expect(isRpcDeliveryUnknown(error)).toBe(true)
- })
-
- it('leaves a refused frame that names no open exchange to the diagnostic alone', async () => {
- const page = createPageClient()
- page.start()
- const answer = page.client.sendRequest('worktree.ps')
- page.deliver({
- v: BRIDGE_PROTOCOL_VERSION,
- type: 'reply',
- id: 'ZZZZZZZZZZZZZZZZZZZZZZ',
- payload: { id: 'frame-1', ok: true }
- })
- expect(page.diagnostics).toEqual([{ kind: 'refused', refusal: 'unrecognised-message' }])
- page.deliver({
- v: BRIDGE_PROTOCOL_VERSION,
- type: 'reply',
- id: idOf(page, 0),
- payload: { id: 'frame-1', ok: true, result: 7, _meta: { runtimeId: 'runtime-a' } }
- })
- await expect(answer).resolves.toEqual({
- id: 'frame-1',
- ok: true,
- result: 7,
- _meta: { runtimeId: 'runtime-a' }
- })
- })
-
- it('fails a request whose frame never left the page, without the delivery mark', async () => {
- let live = true
- const page = createPageClient({
- send: () => {
- if (!live) {
- throw new Error('the port is gone')
- }
- }
- })
- page.start()
- live = false
- const answer = page.client.sendRequest('worktree.ps')
- const error = await answer.catch((thrown: unknown) => thrown)
- expect(readError(error).name).toBe('BridgeSendFailedError')
- expect(isRpcDeliveryUnknown(error)).toBe(false)
- expect(page.diagnostics.at(-1)).toEqual({
- kind: 'send-failed',
- error: expect.any(Error)
- })
- })
-})
-
describe('bridge client caps', () => {
it('refuses the request past the shell grant without a round trip', async () => {
const page = createPageClient()
diff --git a/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts b/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts
index 7eeffcfe6d2..132ffe3fa78 100644
--- a/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts
+++ b/mobile/src/mobile-web-shell/bridge/bridge-rpc-client.ts
@@ -1,6 +1,6 @@
import type { BrowserScreencastFrame } from '../../transport/browser-screencast-protocol'
import type { RpcClient, SendRequestOptions } from '../../transport/rpc-client'
-import type { ConnectionState, ForegroundNudgeReason, RpcResponse } from '../../transport/types'
+import type { ConnectionState, RpcResponse } from '../../transport/types'
import { BRIDGE_MAX_PENDING_REQUESTS, BRIDGE_MAX_SUBSCRIPTIONS } from './bridge-caps'
import { BridgeConnectionCache } from './bridge-client-connection-cache'
import type { BridgeRpcClientDiagnostic } from './bridge-client-diagnostics'
@@ -13,6 +13,7 @@ import {
BridgeShellReplacedError
} from './bridge-client-errors'
import { createBridgeInboundFrameReader } from './bridge-client-inbound-frames'
+import { createBridgeClientNotifications } from './bridge-client-notifications'
import { BridgeClientRequests } from './bridge-client-requests'
import { BridgeClientSubscriptions } from './bridge-client-subscriptions'
import {
@@ -55,6 +56,15 @@ export type BridgeRpcClient = RpcClient & {
/** Fires once `init` has landed, immediately if it already has. Mount no screen before it. */
onReady: (listener: () => void) => () => void
getShellSession: () => BridgeShellSession | null
+ /**
+ * Tells the shell this page cannot render what it was opened for. Never throws and never rejects:
+ * the one caller is an error boundary, and a report that threw would be the second failure.
+ *
+ * False means nothing left — no session, a closed client, a shell that granted no fault
+ * reporting, or a port that refused the frame. There is no second attempt: what could not be said
+ * once will not say itself on a retry, and the shell's own load state is the other way it finds out.
+ */
+ notifyPageFault: (error: unknown) => boolean
}
/**
@@ -261,26 +271,20 @@ export function createBridgeRpcClient(options: BridgeRpcClientOptions): BridgeRp
unsubscribeFromMessages()
}
+ const notifications = createBridgeClientNotifications({
+ send: sendFrame,
+ requireSession,
+ isClosed: () => closed,
+ hasGrant: (grant) => session?.grants.native.includes(grant) ?? false
+ })
+
const unsubscribeFromMessages = options.onMessage(receive)
handshake.start()
return {
sendRequest,
subscribe,
- updateTerminalSubscriptionViewport: (terminal, viewport) => {
- requireSession()
- if (closed) {
- return
- }
- sendFrame({
- v: BRIDGE_PROTOCOL_VERSION,
- type: 'notify',
- name: 'terminalViewport',
- terminal,
- cols: viewport.cols,
- rows: viewport.rows
- })
- },
+ ...notifications,
getState: (): ConnectionState => snapshot().state,
getReconnectAttempt: () => snapshot().reconnectAttempt,
getLastConnectedAt: () => snapshot().lastConnectedAt,
@@ -292,18 +296,6 @@ export function createBridgeRpcClient(options: BridgeRpcClientOptions): BridgeRp
// Not gated on the session: it registers a listener and reads nothing, so it cannot answer
// wrongly, and a provider that subscribes before `init` is how a screen hears the first change.
onStateChange: (listener) => cache.onStateChange(listener),
- notifyForeground: (reason?: ForegroundNudgeReason) => {
- requireSession()
- if (closed) {
- return
- }
- sendFrame({
- v: BRIDGE_PROTOCOL_VERSION,
- type: 'notify',
- name: 'foreground',
- ...(reason === undefined ? {} : { reason })
- })
- },
close,
onReady: (listener) => {
if (session !== null) {
diff --git a/mobile/src/mobile-web-shell/bridge/page-bootstrap.test.ts b/mobile/src/mobile-web-shell/bridge/page-bootstrap.test.ts
new file mode 100644
index 00000000000..105e93b0a81
--- /dev/null
+++ b/mobile/src/mobile-web-shell/bridge/page-bootstrap.test.ts
@@ -0,0 +1,184 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { BRIDGE_PROTOCOL_VERSION } from './bridge-envelope'
+import type { OrcaBridgePageChannel } from './orca-bridge-page-channel'
+import {
+ bootstrapShellPage,
+ createShellPageClient,
+ PAGE_BUILD_ID_KEY,
+ PAGE_MOUNT_STATE_KEY,
+ PAGE_SESSION_ID_KEY,
+ stampPageMountState,
+ type PageMountTarget
+} from './page-bootstrap'
+import type { BridgeRpcClient, BridgeShellSession } from './bridge-rpc-client'
+
+const INIT = {
+ v: BRIDGE_PROTOCOL_VERSION,
+ type: 'init',
+ sessionId: 'session-a',
+ buildId: 'build-a',
+ connection: {
+ state: 'connected',
+ reconnectAttempt: 0,
+ lastConnectedAt: 1700,
+ lastInboundAt: 1800,
+ generation: 3
+ },
+ grants: { rpc: { maxPendingRequests: 64, maxSubscriptions: 32 }, native: [] }
+}
+
+function createTarget(): PageMountTarget {
+ return { dataset: {} }
+}
+
+/** The channel the shell's document-start script installs, as a double. */
+function installChannel(): { posted: string[]; deliver: (frame: unknown) => void } {
+ const posted: string[] = []
+ const channel: OrcaBridgePageChannel = {
+ postMessage: (json) => {
+ posted.push(json)
+ },
+ onmessage: null
+ }
+ Object.defineProperty(globalThis, 'orcaBridge', { value: channel, configurable: true })
+ return {
+ posted,
+ deliver: (frame) => {
+ channel.onmessage?.({ data: JSON.stringify(frame) })
+ }
+ }
+}
+
+type Mounted = { client: BridgeRpcClient; session: BridgeShellSession }
+
+function bootstrap(target: PageMountTarget): {
+ mounts: Mounted[]
+ client: BridgeRpcClient | null
+} {
+ const mounts: Mounted[] = []
+ const client = createShellPageClient()
+ bootstrapShellPage({
+ target,
+ client,
+ mount: (mountedClient, session) => {
+ mounts.push({ client: mountedClient, session })
+ }
+ })
+ return { mounts, client }
+}
+
+beforeEach(() => {
+ vi.useFakeTimers()
+})
+
+afterEach(() => {
+ vi.useRealTimers()
+ Reflect.deleteProperty(globalThis, 'orcaBridge')
+})
+
+describe('the page bootstrap inside the shell', () => {
+ it('asks for a session and mounts nothing until the shell answers', () => {
+ const channel = installChannel()
+ const target = createTarget()
+ const { mounts } = bootstrap(target)
+
+ expect(channel.posted.map((json) => JSON.parse(json).type)).toEqual(['ready'])
+ expect(mounts).toHaveLength(0)
+ expect(target.dataset[PAGE_MOUNT_STATE_KEY]).toBeUndefined()
+ // The handshake keeps asking rather than waiting out an `init` that has been and gone, and
+ // still nothing is mounted while it does.
+ vi.advanceTimersByTime(5_000)
+ expect(channel.posted.length).toBeGreaterThan(1)
+ expect(mounts).toHaveLength(0)
+ })
+
+ it('mounts the client it was given once init lands, and stamps the session on the document', () => {
+ const channel = installChannel()
+ const target = createTarget()
+ const { mounts, client } = bootstrap(target)
+
+ channel.deliver(INIT)
+
+ expect(mounts).toHaveLength(1)
+ expect(mounts[0]?.client).toBe(client)
+ expect(mounts[0]?.session.sessionId).toBe('session-a')
+ expect(target.dataset[PAGE_MOUNT_STATE_KEY]).toBe('shell-ready')
+ expect(target.dataset[PAGE_SESSION_ID_KEY]).toBe('session-a')
+ expect(target.dataset[PAGE_BUILD_ID_KEY]).toBe('build-a')
+ })
+
+ it('stamps the session before it mounts, so a tree that throws still names its build', () => {
+ const channel = installChannel()
+ const target = createTarget()
+ const client = createShellPageClient()
+ bootstrapShellPage({
+ target,
+ client,
+ mount: () => {
+ expect(target.dataset[PAGE_BUILD_ID_KEY]).toBe('build-a')
+ throw new Error('the route tree threw')
+ }
+ })
+
+ expect(() => {
+ channel.deliver(INIT)
+ }).toThrow('the route tree threw')
+ expect(target.dataset[PAGE_MOUNT_STATE_KEY]).toBe('shell-ready')
+ })
+
+ it('mounts one tree for one document, whatever the shell sends next', () => {
+ const channel = installChannel()
+ const target = createTarget()
+ const { mounts } = bootstrap(target)
+
+ channel.deliver(INIT)
+ channel.deliver({ ...INIT, sessionId: 'session-b', buildId: 'build-b' })
+
+ expect(mounts).toHaveLength(1)
+ expect(target.dataset[PAGE_SESSION_ID_KEY]).toBe('session-a')
+ })
+
+ it('mounts at once when the client already holds a session', () => {
+ const channel = installChannel()
+ const client = createShellPageClient()
+ channel.deliver(INIT)
+ const target = createTarget()
+ const mounts: Mounted[] = []
+
+ bootstrapShellPage({
+ target,
+ client,
+ mount: (mountedClient, session) => {
+ mounts.push({ client: mountedClient, session })
+ }
+ })
+
+ expect(mounts).toHaveLength(1)
+ expect(target.dataset[PAGE_MOUNT_STATE_KEY]).toBe('shell-ready')
+ })
+})
+
+describe('the page bootstrap outside the shell', () => {
+ it('builds no client when nothing installed a channel', () => {
+ expect(createShellPageClient()).toBeNull()
+ })
+
+ it('says so and mounts nothing, because no init is ever coming', () => {
+ const target = createTarget()
+ const { mounts } = bootstrap(target)
+
+ expect(mounts).toHaveLength(0)
+ expect(target.dataset[PAGE_MOUNT_STATE_KEY]).toBe('unbridged')
+ expect(target.dataset[PAGE_SESSION_ID_KEY]).toBeUndefined()
+ })
+})
+
+describe('the mount state attribute', () => {
+ it('records the last state reached, so the entry can say its script ran', () => {
+ const target = createTarget()
+ stampPageMountState(target, 'started')
+ expect(target.dataset[PAGE_MOUNT_STATE_KEY]).toBe('started')
+ stampPageMountState(target, 'mounted')
+ expect(target.dataset[PAGE_MOUNT_STATE_KEY]).toBe('mounted')
+ })
+})
diff --git a/mobile/src/mobile-web-shell/bridge/page-bootstrap.ts b/mobile/src/mobile-web-shell/bridge/page-bootstrap.ts
new file mode 100644
index 00000000000..796a54028b2
--- /dev/null
+++ b/mobile/src/mobile-web-shell/bridge/page-bootstrap.ts
@@ -0,0 +1,99 @@
+import {
+ createBridgeRpcClient,
+ type BridgeRpcClient,
+ type BridgeRpcClientDiagnostic,
+ type BridgeShellSession
+} from './bridge-rpc-client'
+import {
+ createOrcaBridgePageTransport,
+ readOrcaBridgePageChannel
+} from './orca-bridge-page-channel'
+
+/**
+ * How far the page's bootstrap got, in one attribute.
+ *
+ * The state is the only thing that tells a document which never ran its script from one that ran
+ * it and threw, and from one still waiting on a shell that has not answered. `unbridged` is
+ * terminal: nothing is coming, because nothing installed a channel on this document.
+ */
+export type PageMountState = 'started' | 'unbridged' | 'shell-ready' | 'mounted'
+
+/** `dataset` keys, so a screenshot, the render check and a device console read the same three facts. */
+export const PAGE_MOUNT_STATE_KEY = 'orcaWebEntry'
+export const PAGE_SESSION_ID_KEY = 'orcaWebSessionId'
+export const PAGE_BUILD_ID_KEY = 'orcaWebBuildId'
+
+/** The document element, narrowed to the one thing the page writes on it. */
+export type PageMountTarget = { dataset: DOMStringMap }
+
+export function stampPageMountState(target: PageMountTarget, state: PageMountState): void {
+ target.dataset[PAGE_MOUNT_STATE_KEY] = state
+}
+
+/** One line per kind for the life of one page: a page that is failing frames fails all of them. */
+export function createPageDiagnosticReporter(): (diagnostic: BridgeRpcClientDiagnostic) => void {
+ const reported = new Set()
+ return (diagnostic) => {
+ if (reported.has(diagnostic.kind)) {
+ return
+ }
+ reported.add(diagnostic.kind)
+ console.warn('[page-bridge]', diagnostic.kind, diagnostic)
+ }
+}
+
+/**
+ * The page's one client, or `null` for a document opened outside the shell.
+ *
+ * One `onmessage` slot exists on the channel, so a second client would silently take the first
+ * one's frames; the page builds this once, at the entry, and hands the same client to the provider.
+ */
+export function createShellPageClient(): BridgeRpcClient | null {
+ const channel = readOrcaBridgePageChannel()
+ if (channel === null) {
+ return null
+ }
+ return createBridgeRpcClient({
+ ...createOrcaBridgePageTransport(channel),
+ onDiagnostic: createPageDiagnosticReporter()
+ })
+}
+
+/**
+ * Mounts the route tree once `init` has landed, and never before it.
+ *
+ * Nothing mounts against a session-less client: the page's getters are synchronous reads of a cache
+ * `init` primes, so a screen that rendered first would record its first frame against a client that
+ * knows no host, no state and no build. A document with no channel is not inside the shell and no
+ * `init` is ever coming, so it says so and stops rather than waiting out a backoff nobody answers.
+ */
+export function bootstrapShellPage(options: {
+ target: PageMountTarget
+ client: BridgeRpcClient | null
+ mount: (client: BridgeRpcClient, session: BridgeShellSession) => void
+}): void {
+ const { target, client, mount } = options
+ if (client === null) {
+ stampPageMountState(target, 'unbridged')
+ return
+ }
+ const mountWithSession = (): boolean => {
+ const session = client.getShellSession()
+ if (session === null) {
+ return false
+ }
+ target.dataset[PAGE_SESSION_ID_KEY] = session.sessionId
+ target.dataset[PAGE_BUILD_ID_KEY] = session.buildId
+ stampPageMountState(target, 'shell-ready')
+ mount(client, session)
+ return true
+ }
+ if (mountWithSession()) {
+ return
+ }
+ // `onReady` fires once and clears its listeners, so one document mounts one tree: a later `init`
+ // under a new session id fails what the page held rather than mounting a second route tree over it.
+ client.onReady(() => {
+ mountWithSession()
+ })
+}
diff --git a/mobile/src/mobile-web-shell/bridge/page-fault-boundary.test.tsx b/mobile/src/mobile-web-shell/bridge/page-fault-boundary.test.tsx
new file mode 100644
index 00000000000..433aacfe401
--- /dev/null
+++ b/mobile/src/mobile-web-shell/bridge/page-fault-boundary.test.tsx
@@ -0,0 +1,81 @@
+import { createElement, type ReactElement } from 'react'
+import { act, create, type ReactTestRenderer } from 'react-test-renderer'
+import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
+import { createFakeBridgePortPair } from './bridge-port-pair-test-harness'
+import { PageFaultBoundary } from './page-fault-boundary'
+
+const FAILURE = new Error('the route threw')
+
+function Throws(): ReactElement {
+ throw FAILURE
+}
+
+function Renders(): ReactElement {
+ return createElement('div', null, 'a worktree list')
+}
+
+function boundary(child: () => ReactElement, onFault: (error: unknown) => void): ReactElement {
+ return createElement(PageFaultBoundary, { onFault }, createElement(child))
+}
+
+let logged: MockInstance
+
+beforeEach(() => {
+ // React prints the throw it handed to the boundary; the test is about what the page did with it.
+ logged = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+})
+
+afterEach(() => {
+ logged.mockRestore()
+})
+
+describe('the page fault boundary', () => {
+ it('reports a throw from the tree it wraps and leaves nothing on the page', async () => {
+ const faults: unknown[] = []
+ const rendered: { tree: ReactTestRenderer | null } = { tree: null }
+ await act(async () => {
+ rendered.tree = create(boundary(Throws, (error) => faults.push(error)))
+ })
+ expect(faults).toEqual([FAILURE])
+ expect(rendered.tree?.toJSON()).toBeNull()
+ })
+
+ it('stays out of the way of a tree that renders', async () => {
+ const faults: unknown[] = []
+ const rendered: { tree: ReactTestRenderer | null } = { tree: null }
+ await act(async () => {
+ rendered.tree = create(boundary(Renders, (error) => faults.push(error)))
+ })
+ expect(faults).toEqual([])
+ expect(rendered.tree?.toJSON()).not.toBeNull()
+ })
+
+ it('reports once, because a faulted page never renders the tree that threw again', async () => {
+ const faults: unknown[] = []
+ const render = (): ReactElement => boundary(Throws, (error) => faults.push(error))
+ const rendered: { tree: ReactTestRenderer | null } = { tree: null }
+ await act(async () => {
+ rendered.tree = create(render())
+ })
+ await act(async () => {
+ rendered.tree?.update(render())
+ })
+ expect(faults).toEqual([FAILURE])
+ })
+
+ it('carries the throw across a real bridge to the shell that mounted the page', async () => {
+ const pair = createFakeBridgePortPair()
+ await pair.flush()
+ await act(async () => {
+ create(
+ boundary(Throws, (error) => {
+ pair.client.notifyPageFault(error)
+ })
+ )
+ })
+ await pair.flush()
+ expect(pair.pageFaults).toEqual([
+ { category: 'Error', message: 'the route threw', isRpcDeliveryUnknown: false }
+ ])
+ })
+})
diff --git a/mobile/src/mobile-web-shell/bridge/page-fault-boundary.tsx b/mobile/src/mobile-web-shell/bridge/page-fault-boundary.tsx
new file mode 100644
index 00000000000..356330e549d
--- /dev/null
+++ b/mobile/src/mobile-web-shell/bridge/page-fault-boundary.tsx
@@ -0,0 +1,40 @@
+import { Component, type PropsWithChildren, type ReactNode } from 'react'
+
+export type PageFaultBoundaryProps = PropsWithChildren<{
+ /** Called once, on the first throw. Must not throw: nothing above this catches. */
+ onFault: (error: unknown) => void
+}>
+
+type PageFaultBoundaryState = { faulted: boolean }
+
+/**
+ * The one boundary the page mounts, directly under its root and above the router.
+ *
+ * It renders nothing on a fault and offers nothing to press. That is the whole design: the
+ * generation this page came from is on disk and was hash-checked before the view loaded it, so the
+ * same bytes throw again and a retry here would only throw twice. Recovery belongs to the shell,
+ * which hears the report and drops the generation, and until it acts the page showing nothing is
+ * honest about what it can do.
+ *
+ * Above the router rather than inside it, because a route that cannot be resolved or imported
+ * throws where the router renders it and a boundary below that would never see it. What it cannot
+ * see either way is a throw from an event handler or a rejected promise with no render behind it;
+ * React reports neither to a boundary, and the shell's own load state is what covers those.
+ */
+export class PageFaultBoundary extends Component {
+ override state: PageFaultBoundaryState = { faulted: false }
+
+ static getDerivedStateFromError(): PageFaultBoundaryState {
+ return { faulted: true }
+ }
+
+ /** React calls this after the tree is already unmounted, so the report is the last thing the page
+ * does rather than something the render it interrupted has to survive. */
+ override componentDidCatch(error: unknown): void {
+ this.props.onFault(error)
+ }
+
+ override render(): ReactNode {
+ return this.state.faulted ? null : this.props.children
+ }
+}
diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-runtime.ts b/mobile/src/mobile-web-shell/mobile-web-shell-runtime.ts
new file mode 100644
index 00000000000..0aa03435256
--- /dev/null
+++ b/mobile/src/mobile-web-shell/mobile-web-shell-runtime.ts
@@ -0,0 +1,47 @@
+import * as ExpoCrypto from 'expo-crypto'
+import { encodeBase64Url } from '../transport/mobile-endpoint-supervisor-support'
+import { BRIDGE_READY_RETRY_MAX_MS } from './bridge/bridge-client-init-handshake'
+import { createGenerationStore, type GenerationStore } from './generation-store'
+import { createExpoGenerationFileSystem } from './generation-store-file-system'
+
+/**
+ * Everything the shell session touches that a test cannot: entropy, the clock, the filesystem.
+ *
+ * It lives beside the hook rather than inside it so the suite can drive all three without a
+ * simulator, and so the one number the shell waits on has somewhere honest to be stated.
+ */
+
+/** 32 bytes, base64url: the session id scopes the view's private origin, so two mounts must never
+ * share one and a remount must never reuse the one that was just on screen. */
+const SESSION_ID_BYTES = 32
+
+/**
+ * How long a finished document has to say `ready` before the shell gives up on it.
+ *
+ * Five times the page's own 2 s retry ceiling (`BRIDGE_READY_RETRY_MAX_MS`), so no device is slow
+ * enough for the backoff to outlast the wait: whatever the page is doing, several of its asks fit
+ * inside this. What it bounds is a page that will never ask at all, because a route module threw
+ * while the bundle was being evaluated and nothing downstream of that import ever ran.
+ */
+export const PAGE_READY_DEADLINE_MS = BRIDGE_READY_RETRY_MAX_MS * 5
+
+export type MobileWebShellRuntime = {
+ createStore(): GenerationStore
+ mintSessionId(): string
+ now(): number
+ /** Runs `run` once, `delayMs` from now, and returns the cancel. The seam the deadline test drives
+ * instead of waiting ten seconds for a real one. */
+ setTimer(run: () => void, delayMs: number): () => void
+}
+
+export function createMobileWebShellRuntime(): MobileWebShellRuntime {
+ return {
+ createStore: () => createGenerationStore({ fileSystem: createExpoGenerationFileSystem() }),
+ mintSessionId: () => encodeBase64Url(ExpoCrypto.getRandomBytes(SESSION_ID_BYTES)),
+ now: Date.now,
+ setTimer: (run, delayMs) => {
+ const handle = setTimeout(run, delayMs)
+ return () => clearTimeout(handle)
+ }
+ }
+}
diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session-contract.ts b/mobile/src/mobile-web-shell/mobile-web-shell-session-contract.ts
index d9fa56a3b88..16d65f3a0b1 100644
--- a/mobile/src/mobile-web-shell/mobile-web-shell-session-contract.ts
+++ b/mobile/src/mobile-web-shell/mobile-web-shell-session-contract.ts
@@ -104,6 +104,10 @@ export type MobileWebShellSessionEffect =
| { readonly kind: 'delete-cache' }
/** Mint a new session id for the generation already on screen, which is what remounts the view. */
| { readonly kind: 'remount' }
+ /** Start the clock on the page's first word. Expiry arrives as `page-ready-deadline` for the flow
+ * it was armed in, and nothing cancels it: a `ready` that lands first makes the expiry a no-op,
+ * so the runner owns a timer and none of the decision. */
+ | { readonly kind: 'await-page-ready' }
/**
* Events, in two kinds.
@@ -153,6 +157,12 @@ export type MobileWebShellSessionEvent =
}
| { readonly type: 'shell-failed'; readonly reason: MobileWebShellFailureReason }
| { readonly type: 'retry-pressed' }
+ /** The native view finished a document. Unstamped, like the view's failure and for the same
+ * reason: the view exists only under the generation on screen. */
+ | { readonly type: 'document-loaded' }
+ /** The page said `ready` over the bridge, which is the only proof its code ran at all. */
+ | { readonly type: 'page-ready' }
+ | { readonly type: 'page-ready-deadline'; readonly flow: number }
/** Latches live beside the state because both outlive the state they were set in: `retriedOnce`
* spans the delete-and-refetch that puts the state back to `checking`, and `remountedOnce` spans a
@@ -161,6 +171,9 @@ export type MobileWebShellSession = {
readonly state: MobileWebShellSessionState
readonly retriedOnce: boolean
readonly remountedOnce: boolean
+ /** Whether the document on screen has spoken over the bridge. Cleared by every new document,
+ * because each one has to prove itself: the last one's word says nothing about this one. */
+ readonly pageReady: boolean
/** The gates the current step was taken on; null until the first one arrives. */
readonly gates: MobileWebShellGates | null
readonly cached: CachedGeneration | null
diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session.test.ts b/mobile/src/mobile-web-shell/mobile-web-shell-session.test.ts
index 1af288f069d..ec09e0bed2d 100644
--- a/mobile/src/mobile-web-shell/mobile-web-shell-session.test.ts
+++ b/mobile/src/mobile-web-shell/mobile-web-shell-session.test.ts
@@ -50,6 +50,8 @@ function stamp(flow: number, event: PendingEvent): MobileWebShellSessionEvent {
case 'gates-changed':
case 'shell-failed':
case 'retry-pressed':
+ case 'document-loaded':
+ case 'page-ready':
return event
case 'cache-read':
case 'manifest-read':
@@ -58,6 +60,7 @@ function stamp(flow: number, event: PendingEvent): MobileWebShellSessionEvent {
case 'activated':
case 'remounted':
case 'download-failed':
+ case 'page-ready-deadline':
return { ...event, flow: event.flow ?? flow }
}
}
@@ -715,3 +718,99 @@ describe('a gates change that says nothing new starts nothing', () => {
})
})
})
+
+/**
+ * A document that commits and then says nothing.
+ *
+ * The WebView reports a finished load for a response it painted, which a bundle whose entry threw
+ * during evaluation still produces. Only the page's own first frame proves its code ran, so the
+ * wait between the two is where a blank screen would otherwise live forever.
+ */
+describe('the page has to speak for the document that loaded', () => {
+ it('arms one wait when the document finishes and the page has not spoken', () => {
+ const step = run(readySession().session, { type: 'document-loaded' })
+ expect(step.effects).toEqual([{ kind: 'await-page-ready' }])
+ expect(step.session.state.kind).toBe('ready')
+ })
+
+ it('arms nothing when the page spoke first, because there is nothing left to wait for', () => {
+ const step = run(readySession().session, { type: 'page-ready' }, { type: 'document-loaded' })
+ expect(step.effects).toEqual([])
+ expect(step.session.pageReady).toBe(true)
+ })
+
+ it('arms nothing outside ready, where no view exists to have loaded anything', () => {
+ const step = run(afterCacheRead(null).session, { type: 'document-loaded' })
+ expect(step.effects).toEqual([])
+ })
+
+ it('deletes the cache and runs the flow again when the wait expires', () => {
+ const ready = run(readySession().session, { type: 'document-loaded' })
+ const step = run(ready.session, { type: 'page-ready-deadline' })
+ // The same recovery `document-load-failed` gets from the view: the bytes on disk are suspect,
+ // so they go and the host is asked once more.
+ expect(step.effects).toEqual([{ kind: 'delete-cache' }, { kind: 'open-cache' }])
+ expect(step.session.retriedOnce).toBe(true)
+ expect(step.session.state.kind).toBe('checking')
+ })
+
+ it('does nothing when the wait expires after the page has spoken', () => {
+ const step = run(
+ readySession().session,
+ { type: 'document-loaded' },
+ { type: 'page-ready' },
+ { type: 'page-ready-deadline' }
+ )
+ expect(step.effects).toEqual([])
+ expect(step.session.state.kind).toBe('ready')
+ })
+
+ it('drops the expiry of a wait a restarted flow left behind', () => {
+ const ready = run(readySession().session, { type: 'document-loaded' })
+ const step = run(
+ ready.session,
+ { type: 'retry-pressed' },
+ { type: 'page-ready-deadline', flow: ready.session.flow }
+ )
+ expect(step.session.state.kind).toBe('checking')
+ expect(step.session.retriedOnce).toBe(false)
+ })
+
+ it('makes the second document prove itself, rather than riding the first one word', () => {
+ const spoken = run(readySession().session, { type: 'page-ready' })
+ const remounted = run(spoken.session, { type: 'remounted', sessionId: 'session-two' })
+ expect(remounted.session.pageReady).toBe(false)
+ expect(run(remounted.session, { type: 'document-loaded' }).effects).toEqual([
+ { kind: 'await-page-ready' }
+ ])
+ })
+
+ it('leaves a remounted document its own wait when the first one expires late', () => {
+ const first = run(readySession().session, { type: 'document-loaded' }, { type: 'page-ready' })
+ const armed = first.session.flow
+ const second = run(
+ first.session,
+ { type: 'shell-failed', reason: 'render-process-gone' },
+ { type: 'remounted', sessionId: 'session-two' },
+ { type: 'document-loaded' }
+ )
+ // The second document is inside its own wait and has not spoken yet, so the only thing that can
+ // keep the first document's expiry off it is the flow the remount started.
+ const step = run(second.session, { type: 'page-ready-deadline', flow: armed })
+ expect(step.effects).toEqual([])
+ expect(step.session.state).toMatchObject({ kind: 'ready', sessionId: 'session-two' })
+ })
+
+ it('makes a freshly activated generation prove itself too', () => {
+ const spoken = run(readySession().session, { type: 'page-ready' })
+ const reactivated = run(spoken.session, {
+ type: 'activated',
+ generationDirectory: CACHED.directory,
+ sessionId: 'session-three',
+ buildId: MANIFEST.buildId,
+ totalBytes: MANIFEST.totalBytes,
+ elapsedMs: 9
+ })
+ expect(reactivated.session.pageReady).toBe(false)
+ })
+})
diff --git a/mobile/src/mobile-web-shell/mobile-web-shell-session.ts b/mobile/src/mobile-web-shell/mobile-web-shell-session.ts
index 5bc9f893ab6..63b511d27ec 100644
--- a/mobile/src/mobile-web-shell/mobile-web-shell-session.ts
+++ b/mobile/src/mobile-web-shell/mobile-web-shell-session.ts
@@ -43,6 +43,7 @@ export function createMobileWebShellSession(): MobileWebShellSession {
state: CHECKING,
retriedOnce: false,
remountedOnce: false,
+ pageReady: false,
gates: null,
cached: null,
flow: 0
@@ -348,6 +349,7 @@ export function reduceMobileWebShellSession(
: step(session, {})
case 'activated':
return step(session, {
+ pageReady: false,
state: {
kind: 'ready',
generationDirectory: event.generationDirectory,
@@ -358,14 +360,33 @@ export function reduceMobileWebShellSession(
}
})
case 'remounted':
- // Only the session id changes, so the view remounts against the same verified bytes.
+ // Only the session id changes, so the view remounts against the same verified bytes. A new
+ // key is a new document, so whatever the last one said is no longer evidence about this one.
+ // The flow goes with it: the wait the retired document armed would otherwise expire onto a
+ // healthy page that is still inside its own, and take a working workspace off screen.
return session.state.kind === 'ready'
- ? step(session, { state: { ...session.state, sessionId: event.sessionId } })
+ ? step(session, {
+ pageReady: false,
+ flow: session.flow + 1,
+ state: { ...session.state, sessionId: event.sessionId }
+ })
: step(session, {})
case 'download-failed':
return onDownloadFailed(session, event.failure)
case 'shell-failed':
return onShellFailed(session, event.reason)
+ case 'document-loaded':
+ // Nothing to wait on outside `ready`, and nothing to wait for once the page has spoken: the
+ // two orders this can arrive in are a race, and the latch is what makes either one fine.
+ return session.state.kind === 'ready' && !session.pageReady
+ ? step(session, {}, [{ kind: 'await-page-ready' }])
+ : step(session, {})
+ case 'page-ready':
+ return session.state.kind === 'ready' ? step(session, { pageReady: true }) : step(session, {})
+ case 'page-ready-deadline':
+ // A document that finished and never said a word is a document that did not load, whatever
+ // the WebView reported: `document-load-failed` is what drops the generation and fetches once.
+ return session.pageReady ? step(session, {}) : onShellFailed(session, 'document-load-failed')
case 'retry-pressed':
// Clears both latches, so the delete-and-refetch and the remount are each available again.
// Only here: a reconnect is not a reason to grant a second remount of the same session.
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
index 7a579d90cf5..57f8e78e0f2 100644
--- 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
@@ -2,7 +2,12 @@ import { createElement, useImperativeHandle, useLayoutEffect, type ReactElement
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 {
+ BRIDGE_FAULT_GRANT,
+ readBridgeHostMessage,
+ type BridgeHostMessage
+} from './bridge/bridge-envelope'
+import type { BridgeErrorCapture } from './bridge/bridge-error-capture'
import type { MobileWebShellSessionState } from './mobile-web-shell-session-contract'
import type { FakeRpcClient } from './bridge-host-test-fakes'
@@ -69,6 +74,8 @@ function DeliverDuringCommit(props: {
deliver: string | null
posted: PostedFrame[]
probe: Probe
+ faults: BridgeErrorCapture[]
+ readies: string[]
}): ReactElement {
const { deliver, probe } = props
useLayoutEffect(() => {
@@ -79,7 +86,9 @@ function DeliverDuringCommit(props: {
return createElement(Harness, {
session: readyState('session-one'),
posted: props.posted,
- probe
+ probe,
+ faults: props.faults,
+ readies: props.readies
})
}
@@ -87,8 +96,21 @@ function Harness(props: {
session: MobileWebShellSessionState
posted: PostedFrame[]
probe: Probe
+ faults: BridgeErrorCapture[]
+ readies: string[]
}): ReactElement | null {
- const view = useMobileWebShellBridge({ hostId: 'host-1', session: props.session })
+ const view = useMobileWebShellBridge({
+ hostId: 'host-1',
+ session: props.session,
+ // A fresh closure every render, which is the shape a screen passes and the one a ref must
+ // absorb: rebuilding the host here would settle every pending request on each render.
+ onPageFault: (error) => props.faults.push(error),
+ onPageReady: () => {
+ props.readies.push(
+ props.session.kind === 'ready' ? props.session.sessionId : props.session.kind
+ )
+ }
+ })
props.probe.view = view
return props.session.kind === 'ready'
? createElement(FakeShellView, {
@@ -115,6 +137,9 @@ type Mounted = {
tree: ReactTestRenderer
posted: PostedFrame[]
probe: Probe
+ faults: BridgeErrorCapture[]
+ /** The session id of every `ready` the page asked for, in order. */
+ readies: string[]
update: (session: MobileWebShellSessionState) => Promise
deliver: (json: string) => Promise
frames: (sessionId: string) => BridgeHostMessage[]
@@ -125,9 +150,11 @@ let warned: MockInstance
async function mount(session: MobileWebShellSessionState): Promise {
const posted: PostedFrame[] = []
const probe: Probe = { view: null }
+ const faults: BridgeErrorCapture[] = []
+ const readies: string[] = []
const rendered: { tree: ReactTestRenderer | null } = { tree: null }
const render = (next: MobileWebShellSessionState): ReactElement =>
- createElement(Harness, { session: next, posted, probe })
+ createElement(Harness, { session: next, posted, probe, faults, readies })
await act(async () => {
rendered.tree = create(render(session))
})
@@ -139,6 +166,8 @@ async function mount(session: MobileWebShellSessionState): Promise {
tree,
posted,
probe,
+ faults,
+ readies,
update: async (next) => {
await act(async () => {
tree.update(render(next))
@@ -186,6 +215,44 @@ describe('the bridge channel', () => {
])
})
+ it('hands a page fault to the screen and asks the client for nothing', async () => {
+ const mounted = await mount(readyState('session-one'))
+ // The grant comes with the session, so the page asks for one before it reports anything.
+ await mounted.deliver(clientFrame({ type: 'ready' }))
+ await mounted.deliver(
+ clientFrame({
+ type: 'notify',
+ name: BRIDGE_FAULT_GRANT,
+ error: { category: 'Error', message: 'the route threw', isRpcDeliveryUnknown: false }
+ })
+ )
+ expect(mounted.faults).toEqual([
+ { category: 'Error', message: 'the route threw', isRpcDeliveryUnknown: false }
+ ])
+ expect(fakeClient().requests).toEqual([])
+ })
+
+ it('reports a fault from the page on screen, never from the one it replaced', async () => {
+ const mounted = await mount(readyState('session-one'))
+ const stale = mounted.probe.view
+ await mounted.update(readyState('session-two'))
+ // The live page asks first, so the host that hears the stale frame has issued its grants: what
+ // refuses the frame below is the session fence and not a page that had been told nothing.
+ await mounted.deliver(clientFrame({ type: 'ready' }))
+ await act(async () => {
+ stale?.onBridgeMessage({
+ nativeEvent: {
+ json: clientFrame({
+ type: 'notify',
+ name: BRIDGE_FAULT_GRANT,
+ error: { category: 'Error', message: 'a dead page', isRpcDeliveryUnknown: false }
+ })
+ }
+ })
+ })
+ expect(mounted.faults).toEqual([])
+ })
+
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' }))
@@ -267,6 +334,23 @@ describe('diagnostics', () => {
expect(warned).toHaveBeenCalledTimes(1)
})
+ it('names the notification it refused and why, rather than blaming the view', async () => {
+ const mounted = await mount(readyState('session-one'))
+ // No `ready` first, so the page holds nothing the host issued and the frame is refused for it.
+ await mounted.deliver(
+ clientFrame({
+ type: 'notify',
+ name: BRIDGE_FAULT_GRANT,
+ error: { category: 'Error', message: 'too early', isRpcDeliveryUnknown: false }
+ })
+ )
+ expect(mounted.faults).toEqual([])
+ expect(warned).toHaveBeenCalledWith(expect.stringContaining('refused a page notification'), {
+ name: BRIDGE_FAULT_GRANT,
+ why: 'before-ready'
+ })
+ })
+
it('starts the count over for the next page', async () => {
const mounted = await mount(readyState('session-one'))
await mounted.deliver('{"v":1,"type":')
@@ -276,6 +360,49 @@ describe('diagnostics', () => {
})
})
+describe('the callbacks a render passes', () => {
+ it('faults into the latest render, not into the closure the host was built with', async () => {
+ const first: BridgeErrorCapture[] = []
+ const second: BridgeErrorCapture[] = []
+ const posted: PostedFrame[] = []
+ const probe: Probe = { view: null }
+ // One session throughout, so the host is never rebuilt: only the ref refresh can carry the
+ // second render's callback to a frame that arrives after it.
+ const render = (faults: BridgeErrorCapture[]): ReactElement =>
+ createElement(Harness, {
+ session: readyState('session-one'),
+ posted,
+ probe,
+ faults,
+ readies: []
+ })
+ const rendered: { tree: ReactTestRenderer | null } = { tree: null }
+ await act(async () => {
+ rendered.tree = create(render(first))
+ })
+ await act(async () => {
+ rendered.tree?.update(render(second))
+ })
+ const deliver = async (json: string): Promise => {
+ await act(async () => {
+ probe.view?.onBridgeMessage({ nativeEvent: { json } })
+ })
+ }
+ await deliver(clientFrame({ type: 'ready' }))
+ await deliver(
+ clientFrame({
+ type: 'notify',
+ name: BRIDGE_FAULT_GRANT,
+ error: { category: 'Error', message: 'the route threw', isRpcDeliveryUnknown: false }
+ })
+ )
+ expect(first).toEqual([])
+ expect(second).toEqual([
+ { category: 'Error', message: 'the route threw', isRpcDeliveryUnknown: false }
+ ])
+ })
+})
+
describe('client changes', () => {
it('rebuilds the host on a new client, so nothing crosses to the one that was replaced', async () => {
const first = fakeClient()
@@ -293,7 +420,7 @@ describe('client changes', () => {
const posted: PostedFrame[] = []
const probe: Probe = { view: null }
const render = (deliver: string | null): ReactElement =>
- createElement(DeliverDuringCommit, { deliver, posted, probe })
+ createElement(DeliverDuringCommit, { deliver, posted, probe, faults: [], readies: [] })
const rendered: { tree: ReactTestRenderer | null } = { tree: null }
await act(async () => {
rendered.tree = create(render(null))
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
index 3386e744132..8ec8dcc1632 100644
--- a/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts
+++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-bridge.ts
@@ -4,7 +4,9 @@ import type {
OrcaMobileWebShellViewHandle
} from '../../modules/orca-mobile-web-shell/src'
import { useHostClient } from '../transport/client-context'
-import { createBridgeHost, type BridgeHost, type BridgeHostDiagnostic } from './bridge-host'
+import { createBridgeDiagnosticReporter } from './bridge-diagnostic-log'
+import { createBridgeHost, type BridgeHost } from './bridge-host'
+import type { BridgeErrorCapture } from './bridge/bridge-error-capture'
import type { MobileWebShellSessionState } from './mobile-web-shell-session-contract'
class BridgeViewGoneError extends Error {
@@ -14,36 +16,6 @@ class BridgeViewGoneError extends Error {
}
}
-/**
- * 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.
*
@@ -81,6 +53,10 @@ export type MobileWebShellBridgeView = {
export function useMobileWebShellBridge(args: {
hostId: string
session: MobileWebShellSessionState
+ /** The page could not render the generation on screen. Reported, never recovered from here. */
+ onPageFault: (error: BridgeErrorCapture) => void
+ /** The page asked for a session. Reported so the screen can stop waiting for it. */
+ onPageReady: () => void
}): MobileWebShellBridgeView {
const { client } = useHostClient(args.hostId)
const ready = args.session.kind === 'ready' ? args.session : null
@@ -88,6 +64,16 @@ export function useMobileWebShellBridge(args: {
const buildId = ready?.buildId ?? null
const viewRef = useRef(null)
const hostRef = useRef(null)
+ // Read through a ref: the host is built once per session, and a caller's fresh closure every
+ // render must not tear one down and settle its pendings.
+ const pageFaultRef = useRef(args.onPageFault)
+ const pageReadyRef = useRef(args.onPageReady)
+ // Commit-phase and declared above the host's effect, so the host is built against the callbacks
+ // this render passed: a native frame can land between a commit and a passive effect.
+ useLayoutEffect(() => {
+ pageFaultRef.current = args.onPageFault
+ pageReadyRef.current = args.onPageReady
+ }, [args.onPageFault, args.onPageReady])
// 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.
@@ -99,6 +85,12 @@ export function useMobileWebShellBridge(args: {
client,
buildId,
sessionId,
+ onPageFault: (error) => {
+ pageFaultRef.current(error)
+ },
+ onPageReady: () => {
+ pageReadyRef.current()
+ },
post: (json) => {
const mounted = viewRef.current
return mounted === null || mounted.sessionId !== sessionId
diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-session.test.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-session.test.ts
index 5aa808c952a..c880e81590d 100644
--- a/mobile/src/mobile-web-shell/use-mobile-web-shell-session.test.ts
+++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-session.test.ts
@@ -89,6 +89,8 @@ vi.mock('../transport/mobile-web-bundle-fetch', () => ({
})
}))
+import { BRIDGE_READY_RETRY_MAX_MS } from './bridge/bridge-client-init-handshake'
+import { PAGE_READY_DEADLINE_MS } from './mobile-web-shell-runtime'
import { useMobileWebShellSession } from './use-mobile-web-shell-session'
const HOST_ID = 'host-1'
@@ -161,24 +163,70 @@ function createFakeStore(): {
}
}
+/** Armed timers, never real ones: the deadline is ten seconds and a suite that waited for it would
+ * be a suite nobody runs. Each entry keeps its delay so the test can pin what was asked for. */
+type ArmedTimer = { delayMs: number; run: () => void; cancelled: boolean }
+
+function createTimerSeam() {
+ const armed: ArmedTimer[] = []
+ return {
+ armed,
+ setTimer: (run: () => void, delayMs: number): (() => void) => {
+ const timer: ArmedTimer = { delayMs, run, cancelled: false }
+ armed.push(timer)
+ return () => {
+ timer.cancelled = true
+ }
+ },
+ /** Fires every timer still armed, in the order it was armed. */
+ fire: (): void => {
+ // A snapshot: firing one may arm another, and the new one is not part of this round.
+ const round = armed.slice()
+ for (const timer of round) {
+ if (!timer.cancelled) {
+ timer.run()
+ }
+ }
+ }
+ }
+}
+
type Mounted = {
tree: ReactTestRenderer
retry: () => void
rerender: () => void
states: () => readonly MobileWebShellSessionState[]
+ documentLoaded: () => void
+ pageReady: () => void
+ timers: ReturnType
}
async function mount(store: GenerationStore): Promise {
- const handle: { retry: () => void; states: MobileWebShellSessionState[] } = {
+ const timers = createTimerSeam()
+ const handle: {
+ retry: () => void
+ documentLoaded: () => void
+ pageReady: () => void
+ states: MobileWebShellSessionState[]
+ } = {
retry: () => {},
+ documentLoaded: () => {},
+ pageReady: () => {},
states: []
}
function Probe() {
const session = useMobileWebShellSession({
hostId: HOST_ID,
- runtime: { createStore: () => store, mintSessionId: () => 'session-id', now: () => 0 }
+ runtime: {
+ createStore: () => store,
+ mintSessionId: () => 'session-id',
+ now: () => 0,
+ setTimer: timers.setTimer
+ }
})
handle.retry = session.retry
+ handle.documentLoaded = session.reportDocumentLoaded
+ handle.pageReady = session.reportPageReady
handle.states.push(session.state)
return null
}
@@ -194,7 +242,10 @@ async function mount(store: GenerationStore): Promise {
tree,
retry: () => handle.retry(),
rerender: () => tree.update(createElement(Probe)),
- states: () => handle.states
+ states: () => handle.states,
+ documentLoaded: () => handle.documentLoaded(),
+ pageReady: () => handle.pageReady(),
+ timers
}
}
@@ -344,3 +395,96 @@ describe('the hybrid shell runner', () => {
})
})
})
+
+/**
+ * The gap a blank page lives in.
+ *
+ * A route module that throws while the bundle is being evaluated takes the entry down with it: the
+ * document still commits, the WebView still reports it loaded, and nothing downstream of that
+ * import runs — so no boundary mounts, no fault is posted, and no frame is ever sent. Without a
+ * clock the session sits in `ready` behind a WebView showing nothing, forever.
+ */
+describe('the wait for the page to speak', () => {
+ beforeEach(() => {
+ doubles.manifestReads = 0
+ doubles.manifestClients.length = 0
+ doubles.manifestRejection = null
+ doubles.fetches.length = 0
+ doubles.connection = { client: {}, state: 'connected' }
+ doubles.gates.hostCapabilities = [MOBILE_WEB_BUNDLE_CAPABILITY]
+ })
+
+ async function ready(): Promise {
+ const fake = createFakeStore()
+ const mounted = await mount(fake.store)
+ fake.settleCacheRead(activeGeneration())
+ await flush()
+ expect(mounted.states().at(-1)?.kind).toBe('ready')
+ return mounted
+ }
+
+ it(`waits out several of the page's own asks before it gives up on one`, () => {
+ // The number the deadline is for: a page whose backoff has widened to the ceiling still gets
+ // several asks inside the wait, so a slow device is never mistaken for a page that never ran.
+ expect(PAGE_READY_DEADLINE_MS / BRIDGE_READY_RETRY_MAX_MS).toBe(5)
+ })
+
+ it('fails the generation a finished document never spoke for, and asks to fetch it again', async () => {
+ const mounted = await ready()
+ await act(async () => {
+ mounted.documentLoaded()
+ })
+ expect(mounted.timers.armed.map((timer) => timer.delayMs)).toEqual([PAGE_READY_DEADLINE_MS])
+ await act(async () => {
+ mounted.timers.fire()
+ })
+ // Not the failure screen: `document-load-failed` on a session that has not retried deletes the
+ // host's cache and runs the flow once more, which is the recovery a republished bundle needs.
+ expect(mounted.states().at(-1)?.kind).toBe('checking')
+ await act(async () => {
+ mounted.tree.unmount()
+ })
+ })
+
+ it('stops the clock when the page speaks inside it, whichever of the two lands first', async () => {
+ const mounted = await ready()
+ await act(async () => {
+ mounted.documentLoaded()
+ mounted.pageReady()
+ })
+ await act(async () => {
+ mounted.timers.fire()
+ })
+ expect(mounted.states().at(-1)?.kind).toBe('ready')
+ await act(async () => {
+ mounted.tree.unmount()
+ })
+ })
+
+ it('arms nothing when the page spoke before the document was reported finished', async () => {
+ const mounted = await ready()
+ await act(async () => {
+ mounted.pageReady()
+ mounted.documentLoaded()
+ })
+ // The race is real on a device: the page's first frame crosses the bridge while the WebView's
+ // own load callback is still in the native queue.
+ expect(mounted.timers.armed).toHaveLength(0)
+ await act(async () => {
+ mounted.tree.unmount()
+ })
+ })
+
+ it('cancels the armed deadline when the session it belongs to is torn down', async () => {
+ const mounted = await ready()
+ await act(async () => {
+ mounted.documentLoaded()
+ })
+ await act(async () => {
+ mounted.tree.unmount()
+ })
+ // A real `setTimeout` outlives the screen; the epoch check makes it inert, and this makes it
+ // not fire at all.
+ expect(mounted.timers.armed.every((timer) => timer.cancelled)).toBe(true)
+ })
+})
diff --git a/mobile/src/mobile-web-shell/use-mobile-web-shell-session.ts b/mobile/src/mobile-web-shell/use-mobile-web-shell-session.ts
index 87f7e5defd8..a0a780e5e38 100644
--- a/mobile/src/mobile-web-shell/use-mobile-web-shell-session.ts
+++ b/mobile/src/mobile-web-shell/use-mobile-web-shell-session.ts
@@ -1,9 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import * as ExpoCrypto from 'expo-crypto'
import type { MobileWebShellFailureReason } from '../../modules/orca-mobile-web-shell/src/load-state'
import { useHostProtocolGates } from '../components/HostProtocolGate'
import { useHostClient } from '../transport/client-context'
-import { encodeBase64Url } from '../transport/mobile-endpoint-supervisor-support'
import { fetchMobileWebBundle } from '../transport/mobile-web-bundle-fetch'
import {
isMobileWebBundleTransportFailure,
@@ -11,12 +9,14 @@ import {
} from '../transport/mobile-web-bundle-operations'
import { runRpcOperation } from '../transport/rpc-operation'
import type { RpcClient } from '../transport/rpc-client'
-import { createGenerationStore, type GenerationStore } from './generation-store'
-import {
- createExpoGenerationFileSystem,
- generationDirectoryPath
-} from './generation-store-file-system'
+import type { GenerationStore } from './generation-store'
+import { generationDirectoryPath } from './generation-store-file-system'
import { deriveHostCacheKey } from './host-cache-key'
+import {
+ createMobileWebShellRuntime,
+ PAGE_READY_DEADLINE_MS,
+ type MobileWebShellRuntime
+} from './mobile-web-shell-runtime'
import {
createMobileWebShellSession,
readMobileWebShellReachability,
@@ -29,30 +29,15 @@ import type {
MobileWebShellSessionState
} from './mobile-web-shell-session-contract'
-/** 32 bytes, base64url: the session id scopes the view's private origin, so two mounts must never
- * share one and a remount must never reuse the one that was just on screen. */
-const SESSION_ID_BYTES = 32
-
-/** The impure edges, injectable so the wiring is testable without a simulator. */
-export type MobileWebShellRuntime = {
- createStore(): GenerationStore
- mintSessionId(): string
- now(): number
-}
-
-function defaultRuntime(): MobileWebShellRuntime {
- return {
- createStore: () => createGenerationStore({ fileSystem: createExpoGenerationFileSystem() }),
- mintSessionId: () => encodeBase64Url(ExpoCrypto.getRandomBytes(SESSION_ID_BYTES)),
- now: Date.now
- }
-}
-
export type MobileWebShellSessionView = {
readonly state: MobileWebShellSessionState
readonly retry: () => void
/** B3's failure reasons, forwarded verbatim; the reducer owns what each one means. */
readonly reportShellFailure: (reason: MobileWebShellFailureReason) => void
+ /** The native view finished a document; starts the wait for the page's first word. */
+ readonly reportDocumentLoaded: () => void
+ /** The page spoke over the bridge; ends that wait, whichever of the two arrived first. */
+ readonly reportPageReady: () => void
}
/**
@@ -71,7 +56,7 @@ export function useMobileWebShellSession(args: {
const { client, state: connState } = useHostClient(hostId)
const runtimeRef = useRef(null)
- runtimeRef.current ??= args.runtime ?? defaultRuntime()
+ runtimeRef.current ??= args.runtime ?? createMobileWebShellRuntime()
const runtime = runtimeRef.current
const storeRef = useRef(null)
storeRef.current ??= runtime.createStore()
@@ -84,6 +69,9 @@ export function useMobileWebShellSession(args: {
const epochRef = useRef(0)
// Aborted on the same bump: a download nobody will use still holds four of the host's read slots.
const downloadsRef = useRef>(new Set())
+ // Cancelled on the same bump, for the same reason: an armed deadline belongs to the generation it
+ // was armed under, and the epoch check alone would leave a real timer alive until it fired.
+ const timersRef = useRef void>>(new Set())
const runEffectRef = useRef<
((epoch: number, flow: number, effect: MobileWebShellSessionEffect) => void) | null
>(null)
@@ -108,6 +96,10 @@ export function useMobileWebShellSession(args: {
controller.abort()
}
downloadsRef.current.clear()
+ for (const cancel of timersRef.current) {
+ cancel()
+ }
+ timersRef.current.clear()
}, [])
const runEffect = useCallback(
@@ -155,6 +147,14 @@ export function useMobileWebShellSession(args: {
case 'remount':
send({ type: 'remounted', flow, sessionId: runtime.mintSessionId() })
return
+ case 'await-page-ready': {
+ const cancel = runtime.setTimer(() => {
+ timersRef.current.delete(cancel)
+ send({ type: 'page-ready-deadline', flow })
+ }, PAGE_READY_DEADLINE_MS)
+ timersRef.current.add(cancel)
+ return
+ }
}
},
[client, dispatch, hostKey, runtime]
@@ -217,7 +217,15 @@ export function useMobileWebShellSession(args: {
[dispatch]
)
- return { state, retry, reportShellFailure }
+ const reportDocumentLoaded = useCallback(() => {
+ dispatch(epochRef.current, { type: 'document-loaded' })
+ }, [dispatch])
+
+ const reportPageReady = useCallback(() => {
+ dispatch(epochRef.current, { type: 'page-ready' })
+ }, [dispatch])
+
+ return { state, retry, reportShellFailure, reportDocumentLoaded, reportPageReady }
}
async function openCache(
diff --git a/mobile/src/transport/client-context.web.test.tsx b/mobile/src/transport/client-context.web.test.tsx
index b1164968e6a..61bfe95941a 100644
--- a/mobile/src/transport/client-context.web.test.tsx
+++ b/mobile/src/transport/client-context.web.test.tsx
@@ -1,7 +1,9 @@
-import { createElement, type ReactElement } from 'react'
+import type { ReactElement } from 'react'
import { act, create } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { BRIDGE_PROTOCOL_VERSION } from '../mobile-web-shell/bridge/bridge-envelope'
+import { createShellPageClient } from '../mobile-web-shell/bridge/page-bootstrap'
+import type { BridgeRpcClient } from '../mobile-web-shell/bridge/bridge-rpc-client'
import type { RpcClientContextValue } from './rpc-client-context-contract'
// The web file re-exports the screen hooks, and reaching the real ones imports the Expo runtime
@@ -44,31 +46,41 @@ function Screen(): null {
return null
}
-function render(): ReactElement {
- return createElement(RpcClientProvider, null, createElement(Screen))
+function render(client: BridgeRpcClient): ReactElement {
+ return (
+
+
+
+ )
}
/** The channel the shell's document-start script installs, as a double. */
-function installChannel(): { posted: string[]; deliver: (frame: unknown) => void } {
- const posted: string[] = []
+function installChannel(): { deliver: (frame: unknown) => void } {
const channel: {
postMessage: (json: string) => void
onmessage: ((e: { data: string }) => void) | null
} = {
- postMessage: (json) => {
- posted.push(json)
- },
+ postMessage: () => {},
onmessage: null
}
Object.defineProperty(globalThis, 'orcaBridge', { value: channel, configurable: true })
return {
- posted,
deliver: (frame) => {
channel.onmessage?.({ data: JSON.stringify(frame) })
}
}
}
+/** What the entry hands the provider: one client, already holding a session. */
+function createReadyClient(deliver: (frame: unknown) => void): BridgeRpcClient {
+ const client = createShellPageClient()
+ if (client === null) {
+ throw new Error('no channel installed')
+ }
+ deliver(INIT)
+ return client
+}
+
function readContext(): RpcClientContextValue {
const context = screen.context
if (context === null) {
@@ -88,47 +100,48 @@ afterEach(() => {
Reflect.deleteProperty(globalThis, 'orcaBridge')
})
-describe('the page provider inside the shell', () => {
- it('mounts nothing until the shell answers with a session', () => {
- const channel = installChannel()
- act(() => {
- create(render())
- })
- expect(screen.mounts).toBe(0)
- expect(channel.posted.map((json: string) => JSON.parse(json).type)).toEqual(['ready'])
- act(() => {
- channel.deliver(INIT)
- })
- expect(screen.mounts).toBe(1)
- })
-
+describe('the page provider', () => {
it('answers every screen with the one client the page has', () => {
const channel = installChannel()
+ const client = createReadyClient(channel.deliver)
act(() => {
- create(render())
- })
- act(() => {
- channel.deliver(INIT)
+ create(render(client))
})
+
+ expect(screen.mounts).toBe(1)
+ const context = readContext()
+ expect(context.acquire('host-a', {})).toBe(client)
+ // No host is named anywhere in the protocol, so a second route's host gets the same client.
+ expect(context.acquire('host-b', {})).toBe(client)
+ expect(context.getAllClients()).toEqual([
+ { hostId: 'host-a', client },
+ { hostId: 'host-b', client }
+ ])
+ })
+
+ it('reads the connection the shell primed rather than a state of its own', () => {
+ const channel = installChannel()
+ const client = createReadyClient(channel.deliver)
+ act(() => {
+ create(render(client))
+ })
+
const context = readContext()
- const client = context.acquire('host-a', {})
- expect(client).not.toBeNull()
expect(context.getState('host-a')).toBe('connected')
+ expect(context.getKnownState('host-a')).toBe('connected')
expect(context.getReconnectAttempt('host-a')).toBe(2)
expect(context.getLastConnectedAt('host-a')).toBe(1700)
- expect(context.getAllClients()).toEqual([{ hostId: 'host-a', client }])
})
it('carries a state change from the shell to the screens watching it', () => {
const channel = installChannel()
+ const client = createReadyClient(channel.deliver)
act(() => {
- create(render())
- })
- act(() => {
- channel.deliver(INIT)
+ create(render(client))
})
const listener = vi.fn()
readContext().subscribeHostState('host-a', listener)
+
act(() => {
channel.deliver({
v: BRIDGE_PROTOCOL_VERSION,
@@ -136,26 +149,70 @@ describe('the page provider inside the shell', () => {
connection: { ...INIT.connection, state: 'reconnecting' }
})
})
+
expect(listener).toHaveBeenCalledWith('reconnecting')
expect(readContext().getState('host-a')).toBe('reconnecting')
})
-})
-describe('the page provider outside the shell', () => {
- it('mounts the route tree at once, because no session is ever coming', () => {
+ it('wakes a screen watching every host on the same change', () => {
+ const channel = installChannel()
+ const client = createReadyClient(channel.deliver)
act(() => {
- create(render())
+ create(render(client))
})
- expect(screen.mounts).toBe(1)
- expect(readContext().getState('host-a')).toBe('disconnected')
+ const listener = vi.fn()
+ readContext().subscribeAllHosts(listener)
+
+ act(() => {
+ channel.deliver({
+ v: BRIDGE_PROTOCOL_VERSION,
+ type: 'state',
+ connection: { ...INIT.connection, state: 'reconnecting' }
+ })
+ })
+
+ expect(listener).toHaveBeenCalledTimes(1)
})
- it('hands out a client that reaches nothing rather than none at all', async () => {
+ it('stops listening when the screen that asked goes away', () => {
+ const channel = installChannel()
+ const client = createReadyClient(channel.deliver)
act(() => {
- create(render())
+ create(render(client))
})
- const client = readContext().acquire('host-a', {})
- expect(client).not.toBeNull()
- await expect(client?.sendRequest('worktree.ps')).rejects.toThrow('bridge transport unavailable')
+ const listener = vi.fn()
+ const unsubscribe = readContext().subscribeHostState('host-a', listener)
+ unsubscribe()
+
+ act(() => {
+ channel.deliver({
+ v: BRIDGE_PROTOCOL_VERSION,
+ type: 'state',
+ connection: { ...INIT.connection, state: 'reconnecting' }
+ })
+ })
+
+ expect(listener).not.toHaveBeenCalled()
+ })
+
+ it('never closes, drops or re-dials the connection the shell owns', async () => {
+ const channel = installChannel()
+ const client = createReadyClient(channel.deliver)
+ const close = vi.spyOn(client, 'close')
+ act(() => {
+ create(render(client))
+ })
+
+ const context = readContext()
+ context.release('host-a', {})
+ context.releaseAndCloseIfUnused('host-a', {})
+ context.closeIfUnused('host-a')
+ context.disconnectHostClient('host-a')
+ context.forgetHostClient('host-a')
+ context.refreshHostClient('host-a')
+ await context.forceReconnect('host-a')
+
+ expect(close).not.toHaveBeenCalled()
+ expect(context.acquire('host-a', {})).toBe(client)
})
})
diff --git a/mobile/src/transport/client-context.web.tsx b/mobile/src/transport/client-context.web.tsx
index b070a7e0edd..964bc080cc2 100644
--- a/mobile/src/transport/client-context.web.tsx
+++ b/mobile/src/transport/client-context.web.tsx
@@ -1,25 +1,8 @@
// Web sibling: RN Web has no pairing keychain and no websocket transport of its own, so the page's
// client is the shell bridge. Nothing here dials, retries or pairs — the native client on the other
// side of the bridge already did, and this provider only carries what it holds across the boundary.
-import {
- createContext,
- useContext,
- useEffect,
- useMemo,
- useRef,
- useState,
- type ReactNode
-} from 'react'
-import {
- createBridgeRpcClient,
- type BridgeRpcClient,
- type BridgeRpcClientDiagnostic
-} from '../mobile-web-shell/bridge/bridge-rpc-client'
-import {
- createOrcaBridgePageTransport,
- readOrcaBridgePageChannel
-} from '../mobile-web-shell/bridge/orca-bridge-page-channel'
-import type { RpcClient } from './rpc-client'
+import { createContext, useContext, useMemo, useRef, type ReactNode } from 'react'
+import type { BridgeRpcClient } from '../mobile-web-shell/bridge/bridge-rpc-client'
import type { ConnectionState, HostProfile } from './types'
import type { RpcClientContextValue } from './rpc-client-context-contract'
@@ -32,93 +15,29 @@ export {
useRefreshHostClient
} from './host-client-hooks'
-/** Named so a page-side failure is never mistaken for a host RpcFailure. */
-export class BridgeTransportUnavailableError extends Error {
- constructor(what: string) {
- super(`bridge transport unavailable: ${what}`)
- this.name = 'BridgeTransportUnavailableError'
- }
-}
-
-/**
- * For a page opened outside the shell: a browser, or a WebView mounted with the bridge off.
- *
- * It answers every member and reaches nothing, which is what lets the route tree mount and paint
- * its empty states instead of crashing on a client that is not there.
- */
-function createPlaceholderClient(): RpcClient {
- return {
- sendRequest: (method) => Promise.reject(new BridgeTransportUnavailableError(method)),
- // No synthetic frame: stream readers are checked, and inventing a shape they must parse
- // would fail differently from the real bridge. Screens stay in their loading state.
- subscribe: () => () => {},
- updateTerminalSubscriptionViewport: () => {},
- getState: () => 'disconnected',
- getReconnectAttempt: () => 0,
- getLastConnectedAt: () => null,
- getLastInboundAt: () => null,
- getGeneration: () => 0,
- onStateChange: () => () => {},
- notifyForeground: () => {},
- close: () => {}
- }
-}
-
-/** One line per kind for the life of one page: a page that is failing frames fails all of them. */
-function createPageDiagnosticReporter(): (diagnostic: BridgeRpcClientDiagnostic) => void {
- const reported = new Set()
- return (diagnostic) => {
- if (reported.has(diagnostic.kind)) {
- return
- }
- reported.add(diagnostic.kind)
- console.warn('[page-bridge]', diagnostic.kind, diagnostic)
- }
-}
-
const Ctx = createContext(null)
-export function RpcClientProvider({ children }: { children: ReactNode }) {
- // Held in a ref as well as in state: the context value is built once, because `useHostClient`
- // re-acquires whenever the value's identity changes.
- const clientRef = useRef(null)
+/**
+ * The client is injected rather than built here: the entry owns it, because it has to wait for
+ * `init` before it renders anything at all, and a provider that built its own would be a second
+ * client reading the one `onmessage` slot the page's channel has.
+ */
+export function RpcClientProvider({
+ client,
+ children
+}: {
+ client: BridgeRpcClient
+ children: ReactNode
+}) {
const acquiredRef = useRef>(new Set())
- const [ready, setReady] = useState(false)
-
- useEffect(() => {
- const channel = readOrcaBridgePageChannel()
- if (channel === null) {
- // Nothing to wait for, so the tree mounts against the placeholder rather than never.
- clientRef.current = createPlaceholderClient()
- setReady(true)
- return
- }
- const client: BridgeRpcClient = createBridgeRpcClient({
- ...createOrcaBridgePageTransport(channel),
- onDiagnostic: createPageDiagnosticReporter()
- })
- // Nothing mounts before `init`: every member of this client throws until the shell answers,
- // and a screen that rendered first would record its first frame against a session-less client.
- const release = client.onReady(() => {
- clientRef.current = client
- setReady(true)
- })
- return () => {
- release()
- clientRef.current = null
- setReady(false)
- client.close()
- }
- }, [])
const value = useMemo(() => {
- const state = (): ConnectionState => clientRef.current?.getState() ?? 'connecting'
return {
// One client for one page: the shell opened this document for one host, so whichever host
// the route names is the host on the other side of the bridge.
acquire: (hostId: string) => {
acquiredRef.current.add(hostId)
- return clientRef.current
+ return client
},
// The shell owns the connection, and a page client cannot be reopened once it says goodbye.
// Every member that would close, drop or re-dial one is inert here for that reason.
@@ -129,11 +48,12 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
refreshHostClient: () => {},
forgetHostClient: () => {},
disconnectHostClient: () => {},
- getState: state,
- getKnownState: () => (clientRef.current === null ? null : state()),
+ getState: () => client.getState(),
+ // Nothing mounts before `init`, so the page's state is never the unknown this answers null for.
+ getKnownState: () => client.getState(),
getClientId: () => null,
- getReconnectAttempt: () => clientRef.current?.getReconnectAttempt() ?? 0,
- getLastConnectedAt: () => clientRef.current?.getLastConnectedAt() ?? null,
+ getReconnectAttempt: () => client.getReconnectAttempt(),
+ getLastConnectedAt: () => client.getLastConnectedAt(),
// The page reaches its host through the shell bridge, which rides whatever path the RN
// client already negotiated. 'relay' is the honest default until init carries the real one.
getActivePath: () => 'relay',
@@ -142,20 +62,17 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
isPairingRejected: () => false,
isHostSignedOut: () => false,
subscribeHostState: (_hostId: string, listener: (next: ConnectionState) => void) =>
- clientRef.current?.onStateChange(listener) ?? (() => {}),
- getAllClients: () => {
- const client = clientRef.current
- return client === null ? [] : [...acquiredRef.current].map((hostId) => ({ hostId, client }))
- },
+ client.onStateChange(listener),
+ getAllClients: () => [...acquiredRef.current].map((hostId) => ({ hostId, client })),
subscribeAllHosts: (listener: () => void) =>
- clientRef.current?.onStateChange(() => {
+ client.onStateChange(() => {
listener()
- }) ?? (() => {}),
+ }),
primeHosts: (_hosts: HostProfile[]) => {}
}
- }, [])
+ }, [client])
- return {ready ? children : null}
+ return {children}
}
export function useRpcClientContext(): RpcClientContextValue {
diff --git a/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts b/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts
index 68042ef9ce9..f48ffebb931 100644
--- a/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts
+++ b/mobile/src/transport/unvalidated-rpc-request-port-inventory.ts
@@ -34,9 +34,6 @@ export const UNVALIDATED_RPC_REQUEST_PORT_OWNERS: readonly UnvalidatedRpcRequest
{ file: 'src/mobile-web-shell/bridge/bridge-rpc-client.ts', references: 1 },
// 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 },
- // The page's client is BridgeRpcClient over the shell bridge; this one reference is the
- // placeholder it falls back to outside the shell, which rejects every call and reads no reply.
- { file: 'src/transport/client-context.web.tsx', references: 1 },
// Implements the port over the device-to-host websocket.
{ file: 'src/transport/direct-rpc-client.ts', references: 3 },
// Fakes the port for the supervisor suites; a non-test file only because tsconfig excludes tests.
diff --git a/mobile/web-entry/index.tsx b/mobile/web-entry/index.tsx
index 95e6afc73b0..b730bf9320f 100644
--- a/mobile/web-entry/index.tsx
+++ b/mobile/web-entry/index.tsx
@@ -3,29 +3,59 @@
import { useEffect, type PropsWithChildren } from 'react'
import { createRoot } from 'react-dom/client'
import { ExpoRoot } from 'expo-router'
-import { RpcClientProvider } from '../src/transport/client-context'
+import type { BridgeRpcClient } from '../src/mobile-web-shell/bridge/bridge-rpc-client'
+import {
+ bootstrapShellPage,
+ createShellPageClient,
+ stampPageMountState,
+ type PageMountTarget
+} from '../src/mobile-web-shell/bridge/page-bootstrap'
+import { PageFaultBoundary } from '../src/mobile-web-shell/bridge/page-fault-boundary'
+// Named with its extension: this entry is the web build's and the provider it needs is the web
+// sibling's, which takes the page's client. The screens below still import `./client-context`
+// and reach the same module, because the builder resolves both specifiers to the same file.
+import { RpcClientProvider } from '../src/transport/client-context.web'
// Body replaced at build time: esbuild has no require.context, so the builder synthesizes one.
import routeContext from './route-manifest'
-// Progress of the mount, in one attribute, so the render check can tell a page that never ran
-// its script from one that ran it and threw. Effects run child-first, so 'mounted' lands only
-// after the router tree below this wrapper has committed.
-const MOUNT_STATE_ATTRIBUTE = 'orcaWebEntry'
-
// The route tree starts at app/h, below the native root layout that owns the provider, so the
// page supplies it here through ExpoRoot's own wrapper rather than mounting the native shell.
// No suspense boundary: expo-router wraps every screen in its own, which is what catches the
-// route chunks the manifest defers.
-function RootProviders({ children }: PropsWithChildren) {
- useEffect(() => {
- document.documentElement.dataset[MOUNT_STATE_ATTRIBUTE] = 'mounted'
- }, [])
- return {children}
+// route chunks the manifest defers. A chunk that never arrives is a rejection rather than a wait,
+// and that is the boundary below's, not suspense's.
+// A factory because the client is not in scope until `init` lands, and ExpoRoot takes a component.
+function createRootProviders(client: BridgeRpcClient, target: PageMountTarget) {
+ return function RootProviders({ children }: PropsWithChildren) {
+ // Effects run child-first, so 'mounted' lands only after the router tree below this wrapper
+ // has committed. The tree is rendered once, with a ready client, so there is one such commit.
+ useEffect(() => {
+ stampPageMountState(target, 'mounted')
+ }, [])
+ return {children}
+ }
}
const container = document.getElementById('root')
if (!container) {
throw new Error('[orca-mobile-web-app] #root missing')
}
-document.documentElement.dataset[MOUNT_STATE_ATTRIBUTE] = 'started'
-createRoot(container).render()
+const target = document.documentElement
+stampPageMountState(target, 'started')
+
+bootstrapShellPage({
+ target,
+ client: createShellPageClient(),
+ mount: (client) => {
+ createRoot(container).render(
+ // Above `ExpoRoot`, not inside its wrapper: a route this bundle cannot resolve or import
+ // throws where the router renders it, and a boundary below the router never sees that.
+ {
+ client.notifyPageFault(error)
+ }}
+ >
+
+
+ )
+ }
+})
diff --git a/mobile/web-entry/web-overrides.json b/mobile/web-entry/web-overrides.json
index 0484ee95c52..aedc8449884 100644
--- a/mobile/web-entry/web-overrides.json
+++ b/mobile/web-entry/web-overrides.json
@@ -3,7 +3,7 @@
"overrides": [
{
"file": "src/transport/client-context.web.tsx",
- "reason": "The page has no websocket transport and no pairing keychain. This is the single transport substitution point: BridgeRpcClient over the shell bridge, and a placeholder RpcClient for a page opened outside it, which is what lets the route tree mount in a plain browser."
+ "reason": "The page has no websocket transport and no pairing keychain. This is the single transport substitution point: it serves the BridgeRpcClient the entry built, for every hostId, because the bridge protocol names no host. The entry mounts nothing until init lands, so this provider never holds a client that cannot answer."
},
{
"file": "packages/expo-two-way-audio/src/ExpoTwoWayAudioModule.web.ts",