fix(mobile): give the page a client identity so its terminal reaches init

The page's RPC provider answered `getClientId` with `null`, and the session
route refuses `terminal.subscribe` without a client identity. No subscribe
meant no scrollback, so `init` never reached the terminal document: the
surface stayed 0x0 and the document answered every measure with `notReady`.
`canSend` reads the same value, so live input was dead for the same reason.

The identity is the page's shell session, not the pairing credential `init`
deliberately withholds: the host only ever uses `client.id` as an opaque
in-memory key for the mobile input floor and the viewport claim.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-22 05:30:37 -04:00
parent 0dae510d04
commit f29a7d1958
4 changed files with 346 additions and 1 deletions
@@ -375,6 +375,27 @@ export function installShellDouble({
channel.onmessage?.({ data: json })
return 'posted'
}
/**
* One JSON stream event from the shell, on the same ledger the binary emitter uses.
*
* The host serves `session.tabs.subscribe` and `terminal.subscribe` as JSON events — the native
* client decodes the terminal's binary frames into `scrollback`/`data` payloads before the bridge
* ever sees them — so a check that drives a screen off a live stream needs this and not the
* binary arm. No window rule: these payloads are a check's own fixtures and are nowhere near the
* cap, and a drop here would read as the page ignoring an event it was never sent.
*/
globalThis.__orcaRenderCheckEmitEvent = (id, payload) => {
const stream = openStreams.get(id)
if (!stream) {
return 'no-stream'
}
const seq = stream.seq + 1
const json = JSON.stringify({ v: version, type: 'event', id, seq, payload })
stream.seq = seq
stream.unacked.push({ seq, bytes: new TextEncoder().encode(json).length })
channel.onmessage?.({ data: json })
return 'posted'
}
globalThis.orcaBridge = channel
}
@@ -0,0 +1,280 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { chromium } from 'playwright-core'
import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
import { MOBILE_WEB_PAGE_ROUTES } from './mobile-web-page-routes.mjs'
import {
createBundleServer,
installShellDouble,
readBridgeFaultGrant,
readBridgeProtocolVersion,
readShellCsp
} from './mobile-web-app-render-harness.mjs'
/**
* The session route's terminal, driven from a shell double that answers the streams a host does.
*
* `mobile-web-app-session-render.test.mjs` mounts this route against a double that answers no RPC,
* so its terminal never attaches and it says so. This is the other half: a tab snapshot with one
* terminal in it, and a `terminal.subscribe` stream the double serves. Both are the shapes the page
* reads, not the host's — the double decides no behaviour, it only carries what this file wrote.
*
* The oracle is the document, not the route's chrome: `#terminal-surface` exists the moment the
* component mounts, and it stays 0x0 with no children until the document receives `init`. So the
* check is that the surface has a real grid, which is exactly what the hybrid RC emulator run did
* not get — `[fit]measure-fail` with `notReady: true`, a document that answered a measure with no
* `term` because no `init` had ever reached it.
*
* What made it red: the page's client provider answered `getClientId` with `null`, and
* `use-mobile-session-terminal-subscription.ts` refuses to subscribe without a client identity. No
* `terminal.subscribe`, no `scrollback`, no `init`. The first case below is the narrow one and
* catches a regression at its source; the second reads the painted grid.
*/
const HOST_ID = 'terminal-init-host'
const WORKTREE = 'wt-1'
const SESSION_ROUTE = `/h/${HOST_ID}/session/${WORKTREE}`
const SESSION_PATTERN = '/h/[hostId]/session/[worktreeId]'
const PAGE_ROUTE_PATTERNS = [SESSION_PATTERN]
const SHELL_SESSION_ID = 'terminal-init-session'
const SHELL_BUILD_ID = 'terminal-init-build'
const SHELL_HOST = {
id: HOST_ID,
name: 'Terminal Init Host',
endpoint: 'ws://terminal-init',
lastConnected: 1
}
const HANDLE = 'pty-handle-terminal-init'
const TAB_ID = 'tab-terminal-init'
/** The bytes the snapshot carries, so the buffer the document opens holds this run's fixture. */
const SCROLLBACK_MARKER = 'orca-terminal-init-marker'
/** The tab snapshot the route applies, in the shape `applySessionTabs` reads off the stream. */
const TABS_SNAPSHOT = {
type: 'snapshot',
worktree: `id:${WORKTREE}`,
publicationEpoch: 'terminal-init-epoch',
snapshotVersion: 1,
activeTabId: TAB_ID,
activeTabType: 'terminal',
tabs: [
{
type: 'terminal',
id: TAB_ID,
title: 'bash',
terminal: HANDLE,
status: 'ready',
isActive: true
}
]
}
/** The first stream event a live terminal sends, which is what carries `init` into the document. */
const SCROLLBACK_EVENT = {
type: 'scrollback',
seq: 1,
cols: 80,
rows: 24,
serialized: `${SCROLLBACK_MARKER}\r\n`,
displayMode: 'phone'
}
/** Read off the manifest rather than restated, for the reason the session render check reads it. */
function sessionGrants() {
const declared = MOBILE_WEB_PAGE_ROUTES.find((route) => route.pathname === SESSION_PATTERN)
if (!declared) {
throw new Error(`${SESSION_PATTERN} is not registered`)
}
return declared.grants
}
const bundles = mobileWebAppDependenciesPresent()
const describeRender = bundles ? describe : describe.skip
let scratch
let server
let browser
let origin
let cspHeader = null
let bridgeVersion = null
let faultGrant = null
beforeAll(async () => {
if (!bundles) {
return
}
cspHeader = await readShellCsp()
bridgeVersion = await readBridgeProtocolVersion()
faultGrant = await readBridgeFaultGrant()
scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-terminal-init-'))
const built = await buildMobileWebAppBundle({ outDir: join(scratch, 'bundle') })
const served = await createBundleServer({ outDir: built.outDir, cspHeader })
server = served.server
origin = served.origin
const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
browser = await chromium.launch({ headless: true, ...(executablePath ? { executablePath } : {}) })
}, 600_000)
afterAll(async () => {
await browser?.close()
server?.close()
if (scratch) {
await rm(scratch, { recursive: true, force: true })
}
})
/**
* The session route, mounted, with its tab snapshot already applied.
*
* The two streams are named so the double accepts them instead of refusing; the replies are the
* reads the screen makes on the way up, each answered with the least the screen needs so nothing
* below is looking at a retry or an error state.
*/
async function openSessionWithTerminalTab() {
const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
await page.addInitScript(installShellDouble, {
version: bridgeVersion,
sessionId: SHELL_SESSION_ID,
buildId: SHELL_BUILD_ID,
route: { pathname: SESSION_ROUTE },
host: SHELL_HOST,
storage: {},
faultGrant,
grants: [faultGrant, ...sessionGrants()],
pageRoutes: PAGE_ROUTE_PATTERNS,
streams: ['session.tabs.subscribe', 'terminal.subscribe'],
replies: {
'worktree.show': { worktree: { id: WORKTREE, name: WORKTREE, path: `/tmp/${WORKTREE}` } },
'terminal.list': {
terminals: [{ handle: HANDLE, title: 'bash', worktree: `id:${WORKTREE}` }]
}
}
})
const errors = []
page.on('pageerror', (error) => errors.push(`${error.name}: ${error.message}`))
await page.goto(`${origin}/`, { waitUntil: 'load' })
await page.waitForFunction(
() => document.documentElement.dataset.orcaWebEntry === 'mounted',
undefined,
{ timeout: 60_000, polling: 250 }
)
const tabs = await waitForSubscribe(page, 'session.tabs.subscribe')
await page.evaluate(
([id, event]) => globalThis.__orcaRenderCheckEmitEvent(id, event),
[tabs.id, TABS_SNAPSHOT]
)
return { errors, page }
}
/**
* The surface's box, and the grid xterm opened inside it, in cells.
*
* Serialized into the page, so it closes over nothing. The screen is sized in pixels and the helper
* textarea is exactly one cell, which is how a cell count is read back from a renderer that paints
* to a canvas and leaves no row elements to count.
*/
function readTerminalGrid() {
const surface = document.getElementById('terminal-surface')
const box = surface.getBoundingClientRect()
const read = { surface: { width: Math.round(box.width), height: Math.round(box.height) } }
const screen = surface.querySelector('.xterm-screen')
const cell = surface.querySelector('.xterm-helper-textarea')
if (!screen || !cell) {
return { ...read, grid: null }
}
const cellBox = cell.getBoundingClientRect()
if (cellBox.width <= 0 || cellBox.height <= 0) {
return { ...read, grid: null }
}
const screenBox = screen.getBoundingClientRect()
return {
...read,
grid: {
cols: Math.round(screenBox.width / cellBox.width),
rows: Math.round(screenBox.height / cellBox.height)
}
}
}
/** The stream the page opened for `method`, or a failure naming every stream it did open. */
async function waitForSubscribe(page, method) {
try {
await page.waitForFunction(
(name) => (globalThis.__orcaRenderCheckSubscribes ?? []).some((one) => one.method === name),
method,
// Both streams open within a second of the mount they follow, so this is headroom rather
// than a wait: what it bounds is how long a broken attach path takes to say so.
{ timeout: 30_000, polling: 100 }
)
} catch {
const opened = await page.evaluate(() => globalThis.__orcaRenderCheckSubscribes ?? [])
throw new Error(
`the page never subscribed to ${method}; it opened: ${
opened.map((one) => one.method).join(', ') || 'nothing'
}`
)
}
const found = await page.evaluate(
(name) => (globalThis.__orcaRenderCheckSubscribes ?? []).find((one) => one.method === name),
method
)
return found
}
describeRender(
'the session route attaches its terminal on the page',
() => {
it('subscribes to the terminal the tab snapshot named, carrying a client identity', async () => {
const { page } = await openSessionWithTerminalTab()
const subscribe = await waitForSubscribe(page, 'terminal.subscribe')
expect(subscribe.params.terminal).toBe(HANDLE)
// The gate that was closed. `client.id` is what the host keys the mobile input floor and the
// viewport claim on, and the page refuses to subscribe at all without one — silently, because
// the diagnostic that would have named it is compiled out of a release build.
expect(subscribe.params.client).toEqual({
id: expect.any(String),
type: 'mobile'
})
expect(subscribe.params.client.id.length).toBeGreaterThan(0)
await page.close()
}, 300_000)
it('receives init in the document and measures a non-zero grid', async () => {
const { errors, page } = await openSessionWithTerminalTab()
const subscribe = await waitForSubscribe(page, 'terminal.subscribe')
await page.evaluate(
([id, event]) => globalThis.__orcaRenderCheckEmitEvent(id, event),
[subscribe.id, SCROLLBACK_EVENT]
)
// The surface exists from the moment the component mounts, so its presence proves nothing.
// `.xterm-screen` does: it is created by `term.open()`, which only ever runs from the
// document's `init`. The emulator run had the surface with zero children inside a correctly
// sized container, which is this element missing.
await page.waitForFunction(
() => document.querySelector('.xterm-screen') !== null,
undefined,
{
timeout: 60_000,
polling: 250
}
)
const painted = await page.evaluate(readTerminalGrid)
console.log('[terminal-init]', JSON.stringify(painted))
// The box the emulator read as 0x0.
expect(painted.surface.width).toBeGreaterThan(0)
expect(painted.surface.height).toBeGreaterThan(0)
// And the grid it opened, which is the one this run's scrollback named: the screen is sized
// in cells, so a document that opened on its own defaults rather than on this event would
// report a different pair. This is what the terminal renders through — under WebGL there are
// no row elements and no text on the document, so the bytes in the buffer are
// `mobile-web-app-terminal-render.test.mjs`'s oracle and the attach path is this one's.
expect(painted.grid).toEqual({ cols: SCROLLBACK_EVENT.cols, rows: SCROLLBACK_EVENT.rows })
expect(errors).toEqual([])
await page.close()
}, 300_000)
},
900_000
)
@@ -133,6 +133,35 @@ describe('the page provider', () => {
expect(context.getLastConnectedAt('host-a')).toBe(1700)
})
it('identifies the page to the host by its shell session', () => {
const channel = installChannel()
const client = createReadyClient(channel.deliver)
act(() => {
create(render(client))
})
// What `terminal.subscribe` carries as `client.id` and what the send gate reads. The native
// provider answers with the pairing credential, which `init` deliberately does not carry; the
// host only ever uses this as an opaque key, so the session id is the whole identity. Null was
// not a smaller answer — the session route refuses to subscribe without one, so no scrollback
// arrives, the terminal document never receives `init`, and live input never opens.
expect(readContext().getClientId('host-a')).toBe(INIT.sessionId)
})
it('has no identity to give before the shell has named a session', () => {
installChannel()
const client = createShellPageClient()
if (client === null) {
throw new Error('no channel installed')
}
act(() => {
create(render(client))
})
// The precondition for the case above: it reads the session rather than answering a constant.
expect(readContext().getClientId('host-a')).toBe(null)
})
it('carries a state change from the shell to the screens watching it', () => {
const channel = installChannel()
const client = createReadyClient(channel.deliver)
+16 -1
View File
@@ -55,7 +55,22 @@ export function RpcClientProvider({
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,
/**
* The page's identity to the host, which is its shell session and not the pairing credential.
*
* The native provider answers with `host.deviceToken`, and the page holds no keychain: `init`
* carries the host "minus the credential the bridge already carries for it", and the token is
* keychain-only by the same rule that keeps it out of AsyncStorage. But the host only ever
* uses `client.id` as an opaque key — the mobile input floor and the viewport claim, both
* in-memory and both scoped to a live subscription — so the session id is a whole identity
* for it, and one no document has to be trusted with a credential to have.
*
* Null was not a smaller answer, it was no terminal at all: the session route refuses
* `terminal.subscribe` without a client identity, so no scrollback arrived, `init` never
* reached the document and the terminal surface stayed 0x0. `canSend` reads the same value,
* so live input was dead for the same reason.
*/
getClientId: () => client.getShellSession()?.sessionId ?? null,
getReconnectAttempt: () => client.getReconnectAttempt(),
getLastConnectedAt: () => client.getLastConnectedAt(),
// The page reaches its host through the shell bridge, which rides whatever path the RN