From d043cfbbee0f0c35ebe8a25103895f6d7cff94b8 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:45:03 -0400 Subject: [PATCH] feat(mobile): the page mounts on the shell's init, with the client injected and a fault boundary (OTA phase C, C1.1) (#21500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(mobile): the page mounts on the shell's init, with the client injected (OTA phase C, C1.1) The Route A entry built no client and mounted the route tree immediately, so the web provider minted its own: it read the page channel, built `BridgeRpcClient` and fell back to a placeholder that rejected every call. A tree that mounts before `init` reads synchronous getters against a client that knows no host, no state and no build, and the first render it records is the wrong one. The entry now owns the page's one client. It builds it from the channel at module scope, mounts nothing until `onReady` fires, and stamps the session and build ids `getShellSession()` returns on the document beside the mount state, so a screenshot, the render check and a device console read the same three facts. `client-context.web.tsx` takes that client by injection and serves it from `acquire()` for every hostId, because the bridge protocol names no host; the placeholder and its `BridgeTransportUnavailableError` are gone, along with the entry that pointed at them in the unvalidated-port inventory. A document with no channel is not inside the shell, so it says `unbridged` and stops rather than waiting out a backoff nobody answers. The render check gains a shell double that answers `ready` with `init`, reads the stamped session back off the document, and proves the gate is real by opening the same route with no double and finding an empty `#root`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): the page can tell the shell it faulted (OTA phase C, C1.1) A page that throws where it renders has nowhere to report it: the shell sees a document that loaded and a view that never painted, so it waits on a blank page forever. This adds the one frame that says so. `notify { name: 'fault' }` carries the capture an `error` frame already carries, so both directions share one bound and one reader. It rides a grant because `notify` is a closed list on both sides: a page served by a newer desktop into an older shell would have the whole frame refused, so the page asks `init.grants.native` first and stays quiet on a no. The shell answers it as `document-load-failed`, which is what happened. That reason drops the generation and downloads once, so a page broken by bytes this host has since replaced recovers, and one broken by its own code stops at the failure screen rather than a blank one. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): give the bridge's notifications and the host's errors their own modules The fault report took both files over the 300-line cap, so each gives up the group that was already separable. The page's one-way members move to `bridge-client-notifications.ts`, which is also where the two policies that split them can be stated: the two the native contract declares throw before a session, and the fault report never throws at all. The host's three error classes move to `bridge-host-errors.ts`, the mirror of the page's own `bridge-client-errors.ts`. No behaviour changes. The commit before this one is over the cap on its own, which a forward-only history is the reason to say rather than hide. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): one boundary under the page's root, reporting to the shell (OTA phase C, C1.1) Nothing in `app/h/**` exports an `ErrorBoundary` and `ExpoRoot` provides no global one, so a throw while a route renders — or a route module that rejects once the manifest is lazy — unmounts the tree and leaves a blank document. The shell sees a load that finished and waits on it forever. The entry now wraps what it mounts on `init` in one boundary that posts the throw over the bridge. Above `ExpoRoot`, not inside its wrapper: a route that cannot be resolved throws where the router renders it, and a boundary below the router never sees that. It renders nothing and offers nothing to press. The generation 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 drops the generation on the report. The render check now grants the fault and collects what the page posts into the errors every case already asserts empty, because a throw the boundary caught paints nothing and logs nothing a `pageerror` listener would hear. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): write the page-fault callback ref after the commit, not during render React may replay or discard a render, so the write belongs in the commit phase. Layout, not passive, and declared above the host's effect: a native frame can arrive between a commit and a passive effect, and the host must already hold this render's callback. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): take the boundary test to C0.5's fake-client pair `createBridgePortPair` is generic over the shell client now; the fake-client form this test wants is `createFakeBridgePortPair`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * feat(mobile): bound the wait for a page that never says a word (OTA phase C, C1.1) A route module that throws while the bundle is evaluated takes the entry with it. The document still commits and the WebView still reports it loaded, but no boundary mounts, no fault is posted and no frame is ever sent, so the session sat in `ready` behind a blank view forever. The native view's finished load starts a clock; the page's first `ready` stops it; expiry is `document-load-failed`, which deletes the generation and fetches once. Nothing cancels the timer — a `ready` that lands first makes the expiry a no-op — so the runner owns a clock and the reducer owns every decision. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): make a route chunk throw, so the render check proves the boundary reports The check folded page faults into its errors but nothing ever produced one, so a boundary that stopped reporting would have stayed green. The server now serves one real route chunk with a throw in front of it: the module still links, so the failure is an evaluation throw where the router renders, which is exactly what the boundary is for. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): make the host enforce the grants it issued, and hear nothing before ready `forwardNotify` acted on any frame that parsed, including a `fault` from a page that had never asked for a session and therefore held no grant. Both refusals now go through one rule the host shares with the frame it sends, so the list a page is told about and the list it will be served cannot drift. Inert while every page is offered `fault`; the ungranted arm is what C1.3 needs the moment a grant belongs to a route rather than to the protocol. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): start a new flow when the shell view remounts A remount cleared `pageReady` but left the flow alone, so the wait the retired document armed still matched. It expired onto the page that replaced it, took a ready workspace to `document-load-failed`, and deleted the generation on the way. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): say which page notification the bridge refused and why A refused `notify` fell through to the line about a view outliving its host, which is a different fault and names neither the notification nor the reason. The two refusals now get a line each, so a page that was told nothing cannot bury one reaching past what it was told. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): pin the ready deadline to the page's own retry ceiling The margin was stated in a comment and asserted against itself, so changing either number left the suite green. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): split the bridge frame suite along the modules the merge created `bridge-rpc-client-frames.test.ts` reached 835 counted lines once C0.8 and C1.1 both added cases to it, over the 800 the lint allows. The split follows the two modules those changes extracted, so each suite now names the module it covers. `bridge client page faults` moves to `bridge-client-notifications.test.ts` (the outbound notify surface) and `bridge client refusals and send failures` to `bridge-client-inbound-frames.test.ts` (the reader, including the refused-event release that cancels at the shell). The seven suites that exercise the client as a whole stay put. The fake port all three drive moves to `bridge-page-client-test-harness.ts` rather than being copied three times. No case changed and none was dropped: 48 `it` cases before, 37 + 4 + 7 after, and all nine `describe` bodies compare byte-identical to their originals. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(mobile): type the shared init fixture as the member a case reads The harness exported `INIT` as `BridgeHostMessage`. While it was a module-local const, control flow narrowed it to the `init` member at each use, so `INIT.grants` read fine. An imported binding keeps its declared type instead, so the same read lost `grants` to the union and the tests ratchet went red. Declared as the init member, which is what every case already treats it as. No cast: the object literal is checked against the narrower type directly. `INIT` was the only exported fixture with this shape. `CONNECTION` is `as const`, `GRANTS` is inferred, and nothing reads a member off an `eventFrame` result. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- config/scripts/mobile-web-app-render.test.mjs | 209 +++++++++++++- .../MobileWebShellScreen.test.tsx | 75 ++++- .../mobile-web-shell/MobileWebShellScreen.tsx | 31 ++- .../bridge-diagnostic-log.test.ts | 55 ++++ .../mobile-web-shell/bridge-diagnostic-log.ts | 54 ++++ .../mobile-web-shell/bridge-host-errors.ts | 28 ++ .../src/mobile-web-shell/bridge-host.test.ts | 138 ++++++++- mobile/src/mobile-web-shell/bridge-host.ts | 93 ++++--- .../bridge-client-inbound-frames.test.ts | 167 +++++++++++ .../bridge-client-notifications.test.ts | 60 ++++ .../bridge/bridge-client-notifications.ts | 81 ++++++ .../bridge/bridge-envelope.test.ts | 14 + .../bridge/bridge-envelope.ts | 18 ++ .../bridge/bridge-init-frame.ts | 40 +++ .../bridge/bridge-notify-grants.test.ts | 37 +++ .../bridge/bridge-notify-grants.ts | 32 +++ .../bridge/bridge-page-client-test-harness.ts | 103 +++++++ .../bridge/bridge-port-pair-test-harness.ts | 13 + .../bridge/bridge-rpc-client-frames.test.ts | 261 +----------------- .../bridge/bridge-rpc-client.ts | 46 ++- .../bridge/page-bootstrap.test.ts | 184 ++++++++++++ .../mobile-web-shell/bridge/page-bootstrap.ts | 99 +++++++ .../bridge/page-fault-boundary.test.tsx | 81 ++++++ .../bridge/page-fault-boundary.tsx | 40 +++ .../mobile-web-shell-runtime.ts | 47 ++++ .../mobile-web-shell-session-contract.ts | 13 + .../mobile-web-shell-session.test.ts | 99 +++++++ .../mobile-web-shell-session.ts | 25 +- .../use-mobile-web-shell-bridge.test.ts | 137 ++++++++- .../use-mobile-web-shell-bridge.ts | 54 ++-- .../use-mobile-web-shell-session.test.ts | 150 +++++++++- .../use-mobile-web-shell-session.ts | 64 +++-- .../src/transport/client-context.web.test.tsx | 147 +++++++--- mobile/src/transport/client-context.web.tsx | 135 ++------- .../unvalidated-rpc-request-port-inventory.ts | 3 - mobile/web-entry/index.tsx | 58 +++- mobile/web-entry/web-overrides.json | 2 +- 37 files changed, 2313 insertions(+), 580 deletions(-) create mode 100644 mobile/src/mobile-web-shell/bridge-diagnostic-log.test.ts create mode 100644 mobile/src/mobile-web-shell/bridge-diagnostic-log.ts create mode 100644 mobile/src/mobile-web-shell/bridge-host-errors.ts create mode 100644 mobile/src/mobile-web-shell/bridge/bridge-client-inbound-frames.test.ts create mode 100644 mobile/src/mobile-web-shell/bridge/bridge-client-notifications.test.ts create mode 100644 mobile/src/mobile-web-shell/bridge/bridge-client-notifications.ts create mode 100644 mobile/src/mobile-web-shell/bridge/bridge-init-frame.ts create mode 100644 mobile/src/mobile-web-shell/bridge/bridge-notify-grants.test.ts create mode 100644 mobile/src/mobile-web-shell/bridge/bridge-notify-grants.ts create mode 100644 mobile/src/mobile-web-shell/bridge/bridge-page-client-test-harness.ts create mode 100644 mobile/src/mobile-web-shell/bridge/page-bootstrap.test.ts create mode 100644 mobile/src/mobile-web-shell/bridge/page-bootstrap.ts create mode 100644 mobile/src/mobile-web-shell/bridge/page-fault-boundary.test.tsx create mode 100644 mobile/src/mobile-web-shell/bridge/page-fault-boundary.tsx create mode 100644 mobile/src/mobile-web-shell/mobile-web-shell-runtime.ts 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",