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",