mirror of
https://github.com/stablyai/orca.git
synced 2026-09-24 16:02:41 +00:00
perf(mobile): open a session with parallel startup RPCs and a pre-warmed terminal engine (#19260)
Startup RPCs now fan out in parallel and the xterm engine pre-warms inside the real terminal frame while they are in flight, so the first pane inherits a warm WebView and an already-measured viewport instead of paying a round trip for it. The pre-warm opens its engine before measuring: web-ready only reports that the bundle loaded, and the WebView answers a measure with null until a terminal exists. It also pre-warms at the user's saved text size, because cell size is what the frame height gets divided by. Host writes such as worktree.activate wait for an evaluated status.get reply. Navigation still fails open when a host cannot answer one, but that fallback no longer reads as a passing compatibility verdict.
This commit is contained in:
@@ -44,6 +44,12 @@ function GateConsumer() {
|
||||
return createElement('GateStatus', null, hostCapabilities.join(','))
|
||||
}
|
||||
|
||||
// Separate from GateStatus so the capability assertions keep their exact rendered shape.
|
||||
function VerifiedConsumer() {
|
||||
const { compatVerified } = useHostProtocolGates()
|
||||
return createElement('GateVerified', null, compatVerified ? 'verified' : 'unverified')
|
||||
}
|
||||
|
||||
// Counts mounts so a test can prove the routes were never torn down, which presence alone can't.
|
||||
const probeMounts = { count: 0 }
|
||||
function MountProbe() {
|
||||
@@ -57,7 +63,13 @@ function gateElement() {
|
||||
return createElement(
|
||||
HostProtocolGate,
|
||||
{ hostId: 'host-1' },
|
||||
createElement('HostContent', null, createElement(GateConsumer), createElement(MountProbe))
|
||||
createElement(
|
||||
'HostContent',
|
||||
null,
|
||||
createElement(GateConsumer),
|
||||
createElement(VerifiedConsumer),
|
||||
createElement(MountProbe)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -154,13 +166,90 @@ describe('HostProtocolGate', () => {
|
||||
expect(client.sendRequest).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('serves every descendant capability read from the one status.get it issues', async () => {
|
||||
const client = clientWithStatus({
|
||||
protocolVersion: 5,
|
||||
minCompatibleMobileVersion: 0,
|
||||
capabilities: ['browser.screencast.v1', 'terminal.queryReplyInput.v1']
|
||||
})
|
||||
hostClient.current = { client, state: 'connected' }
|
||||
renderer = await act(async () => {
|
||||
const created = create(
|
||||
createElement(
|
||||
HostProtocolGate,
|
||||
{ hostId: 'host-1' },
|
||||
createElement(GateConsumer),
|
||||
createElement(GateConsumer)
|
||||
)
|
||||
)
|
||||
await Promise.resolve()
|
||||
return created
|
||||
})
|
||||
|
||||
// Why: the session route used to run its own retrying status.get on top of this one, so a
|
||||
// cold open cost two round trips for the same answer. Consumers now read the gate's copy.
|
||||
expect(client.sendRequest).toHaveBeenCalledOnce()
|
||||
expect(client.sendRequest).toHaveBeenCalledWith('status.get')
|
||||
const statuses = renderer.root.findAllByType('GateStatus')
|
||||
expect(statuses).toHaveLength(2)
|
||||
for (const status of statuses) {
|
||||
expect(status.props.children).toBe('browser.screencast.v1,terminal.queryReplyInput.v1')
|
||||
}
|
||||
})
|
||||
|
||||
it('releases the cover on a failed status.get and upgrades when a retry lands', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('status.get timed out'))
|
||||
.mockResolvedValue({
|
||||
ok: true,
|
||||
result: { protocolVersion: 5, minCompatibleMobileVersion: 0, capabilities: ['late.v1'] }
|
||||
})
|
||||
hostClient.current = { client: { sendRequest } as unknown as RpcClient, state: 'connected' }
|
||||
renderer = await renderGate()
|
||||
|
||||
// Why: a wedged status.get must never trap the routes behind the cover, so the first miss
|
||||
// settles conservative gates immediately — no capabilities, but a usable UI.
|
||||
let output = renderedText(renderer)
|
||||
expect(output).toContain('HostContent')
|
||||
expect(output).not.toContain('Checking host compatibility')
|
||||
expect(output).toContain('"type":"GateStatus","props":{},"children":null')
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1_100)
|
||||
})
|
||||
|
||||
// The probe kept retrying underneath, so the answer arrives without a remount.
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
expect(renderedText(renderer)).toContain('late.v1')
|
||||
expect(probeMounts.count).toBe(1)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('blocks a desktop that omits protocolVersion, so a pending verdict is not a formality', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
// Why this case and not just an explicit old version: evaluateCompat reads a missing
|
||||
// protocolVersion as 0, so the everyday shape of an old desktop is a blocking one.
|
||||
hostClient.current = {
|
||||
client: clientWithStatus({ capabilities: [] }),
|
||||
state: 'connected'
|
||||
}
|
||||
renderer = await renderGate()
|
||||
const output = renderedText(renderer)
|
||||
expect(output).toContain('Update Orca on your computer')
|
||||
expect(output).not.toContain('HostContent')
|
||||
})
|
||||
|
||||
it('renders the host UI while the host connection is still pending', async () => {
|
||||
hostClient.current = { client: null, state: 'connecting' }
|
||||
renderer = await renderGate()
|
||||
expect(renderedText(renderer)).toContain('HostContent')
|
||||
})
|
||||
|
||||
it('does not mount host routes before a connected host passes the compatibility probe', async () => {
|
||||
// Was: the routes were held back until status.get resolved, which serialised every route's
|
||||
// own startup RPC behind this one round trip. They now mount immediately and are covered.
|
||||
it('mounts host routes under the pending cover while status.get is still in flight', async () => {
|
||||
const client = {
|
||||
sendRequest: vi.fn().mockReturnValue(new Promise(() => {}))
|
||||
} as unknown as RpcClient
|
||||
@@ -168,9 +257,40 @@ describe('HostProtocolGate', () => {
|
||||
renderer = await renderGate()
|
||||
const output = renderedText(renderer)
|
||||
expect(output).toContain('Checking host compatibility')
|
||||
expect(output).not.toContain('HostContent')
|
||||
expect(probeMounts.count).toBe(0)
|
||||
expect(output).toContain('HostContent')
|
||||
expect(probeMounts.count).toBe(1)
|
||||
expect(client.sendRequest).toHaveBeenCalledOnce()
|
||||
// Why: mounting early must not leak an unproven host's capabilities to the routes below;
|
||||
// an empty join renders no children, so the consumer saw none.
|
||||
expect(output).toContain('"type":"GateStatus","props":{},"children":null')
|
||||
const overlay = renderer.root
|
||||
.findAllByType('View')
|
||||
.find((node) => node.props.accessibilityViewIsModal === true)
|
||||
expect(overlay?.props.pointerEvents).toBe('auto')
|
||||
})
|
||||
|
||||
it('unmounts the routes it mounted early when the verdict comes back blocked', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
let settle: ((response: unknown) => void) | null = null
|
||||
const client = {
|
||||
sendRequest: vi.fn().mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
settle = resolve
|
||||
})
|
||||
)
|
||||
} as unknown as RpcClient
|
||||
hostClient.current = { client, state: 'connected' }
|
||||
renderer = await renderGate()
|
||||
expect(renderedText(renderer)).toContain('HostContent')
|
||||
|
||||
await act(async () => {
|
||||
settle?.({ ok: true, result: { protocolVersion: 5, minCompatibleMobileVersion: 999 } })
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
const output = renderedText(renderer)
|
||||
expect(output).toContain('Update Orca Mobile')
|
||||
expect(output).not.toContain('HostContent')
|
||||
})
|
||||
|
||||
it('overlays the pending spinner instead of unmounting routes mounted while connecting', async () => {
|
||||
@@ -259,4 +379,52 @@ describe('HostProtocolGate', () => {
|
||||
renderer = await renderGate()
|
||||
expect(renderedText(renderer)).toContain('HostContent')
|
||||
})
|
||||
|
||||
it('reports a rejected status.get as unverified, so failing open is not a passing verdict', async () => {
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: false, error: { message: 'no such method' } })
|
||||
hostClient.current = { client: { sendRequest } as unknown as RpcClient, state: 'connected' }
|
||||
renderer = await renderGate()
|
||||
|
||||
// Navigation still works: the host said no, and that must not lock the user out of the route.
|
||||
const output = renderedText(renderer)
|
||||
expect(output).toContain('HostContent')
|
||||
expect(output).not.toContain('Checking host compatibility')
|
||||
// Why: `compatVerdict` is `ok` here purely as a fallback. Nothing about this host was proven,
|
||||
// so callers that write to it read this flag instead of the verdict.
|
||||
expect(output).toContain('["unverified"]')
|
||||
})
|
||||
|
||||
it('reports a passing status reply as verified', async () => {
|
||||
hostClient.current = {
|
||||
client: clientWithStatus({ protocolVersion: 5, minCompatibleMobileVersion: 0 }),
|
||||
state: 'connected'
|
||||
}
|
||||
renderer = await renderGate()
|
||||
expect(renderedText(renderer)).toContain('["verified"]')
|
||||
})
|
||||
|
||||
it('stays unverified through a failed status.get and flips once a retry answers', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('status.get timed out'))
|
||||
.mockResolvedValue({
|
||||
ok: true,
|
||||
result: { protocolVersion: 5, minCompatibleMobileVersion: 0 }
|
||||
})
|
||||
hostClient.current = { client: { sendRequest } as unknown as RpcClient, state: 'connected' }
|
||||
renderer = await renderGate()
|
||||
|
||||
expect(renderedText(renderer)).toContain('["unverified"]')
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1_100)
|
||||
})
|
||||
|
||||
// The retry landed, so the fallback is replaced by a real answer and writes are released.
|
||||
expect(renderedText(renderer)).toContain('["verified"]')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,45 +22,26 @@ export function useHostProtocolGates(): HostStatusGates {
|
||||
|
||||
// Why: single choke point above every /h/[hostId] route so a blocked verdict replaces the
|
||||
// whole host UI (sidebar + detail stack) while the host list and other hosts stay usable.
|
||||
// The routes mount as soon as the connection does, so their startup RPCs (session.tabs.list,
|
||||
// terminal.list) fly alongside this status.get instead of queueing behind it; a blocked verdict
|
||||
// then unmounts them and their answers are discarded.
|
||||
export function HostProtocolGate({ hostId, children }: Props) {
|
||||
const { client, state } = useHostClient(hostId)
|
||||
const gates = useHostStatusGates({ hostId, client, connState: state })
|
||||
const { compatVerdict, statusPending } = gates
|
||||
const resolvedHostIdRef = useRef<string | null>(null)
|
||||
const mountedHostIdRef = useRef<string | null>(null)
|
||||
const hostKey = hostId ?? null
|
||||
const resolvedNow = state === 'connected' && client !== null && !statusPending
|
||||
const blocked = compatVerdict.kind === 'blocked'
|
||||
const pending = statusPending && resolvedHostIdRef.current !== hostKey
|
||||
const holdBack = pending && mountedHostIdRef.current !== hostKey
|
||||
|
||||
// Why: React can replay or discard a render, so the latches record committed
|
||||
// outcomes only — a discarded children render must not count as mounted.
|
||||
// Why: React can replay or discard a render, so the latch records committed outcomes only.
|
||||
useEffect(() => {
|
||||
if (resolvedNow) {
|
||||
resolvedHostIdRef.current = hostKey
|
||||
}
|
||||
if (blocked) {
|
||||
// Why: the block screen unmounts the routes, so a later pending window
|
||||
// must not assume a live tree it can overlay.
|
||||
mountedHostIdRef.current = null
|
||||
} else if (!holdBack) {
|
||||
mountedHostIdRef.current = hostKey
|
||||
}
|
||||
})
|
||||
|
||||
if (holdBack) {
|
||||
// Why: nothing is mounted yet for this host, so hold the routes back entirely
|
||||
// rather than letting them mount (and fire their connect RPCs) pre-verdict.
|
||||
return (
|
||||
<View style={styles.pending}>
|
||||
<ActivityIndicator
|
||||
color={colors.textSecondary}
|
||||
accessibilityLabel="Checking host compatibility"
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (blocked) {
|
||||
return <ProtocolBlockScreen verdict={compatVerdict} />
|
||||
}
|
||||
@@ -77,10 +58,11 @@ export function HostProtocolGate({ hostId, children }: Props) {
|
||||
{children}
|
||||
</View>
|
||||
{pending ? (
|
||||
// Why: once the stack is mounted, unmounting it for a pending status.get destroys
|
||||
// in-flight nested navigation, so cover it instead. Mount effects underneath still
|
||||
// run — they wait for connState 'connected' and every capability-dependent call
|
||||
// re-probes status.get itself, so nothing newer than the baseline fires here.
|
||||
// Why: cover the stack rather than unmounting it — unmounting for a pending status.get
|
||||
// destroys in-flight nested navigation, and holding it back would serialise every route's
|
||||
// startup RPC behind this one. Mount effects underneath run pre-verdict by design; they
|
||||
// read capabilities from this gate, which reports none until the verdict lands, so every
|
||||
// capability-dependent surface stays closed rather than guessing.
|
||||
<View
|
||||
style={styles.pendingOverlay}
|
||||
// Why: the fill owns the hit test for in-tree views only — native-Modal-hosted
|
||||
@@ -100,12 +82,6 @@ export function HostProtocolGate({ hostId, children }: Props) {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pending: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
// Stays mounted across the overlay toggling so the routes below keep their identity.
|
||||
host: {
|
||||
flex: 1
|
||||
|
||||
@@ -7,7 +7,7 @@ const probe = vi.hoisted(() => ({
|
||||
start: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../transport/runtime-capability-probe', () => ({
|
||||
vi.mock('../transport/runtime-status-probe', () => ({
|
||||
startRuntimeCapabilityProbe: probe.start
|
||||
}))
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { CODEX_RESET_CREDIT_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { startRuntimeCapabilityProbe } from '../transport/runtime-capability-probe'
|
||||
import { startRuntimeCapabilityProbe } from '../transport/runtime-status-probe'
|
||||
|
||||
// Why: source the capability string from the shared contract so a host bump can never
|
||||
// silently drift from the mobile probe.
|
||||
|
||||
Reference in New Issue
Block a user