revert(mobile): pull the relay connect-speed mobile pass pending a smaller, verified re-land (#19348)

* Revert "feat(mobile): time relay dial stages so diagnostics say where a slow connect went (#19245)"

This reverts commit 83b1558ecc.

* Revert "perf(mobile): race the direct and relay dials from t=0 on every reconnect (#19308)"

This reverts commit ceafdcad2f.

* Revert "feat(mobile): draw the last known tab strip while a session reconnects (mobile pass) (#19281)"

This reverts commit 643571def6.

* Revert "perf(mobile): open a session with parallel startup RPCs and a pre-warmed terminal engine (#19260)"

This reverts commit c37413271e.

* Revert "perf(mobile): cut the relay reconnect critical path and admit dead sockets faster (mobile pass) (#19280)"

This reverts commit e628090ad4.

* chore: keep the react-doctor suppression for the startup timers

The pattern it covers (a variable number of timers cleared through one cleanup)
predates #19260 and is unchanged by the revert; dropping the entry only re-exposed
a pre-existing finding to the changed-code gate.
This commit is contained in:
Jinwoo Hong
2026-09-07 16:44:26 -04:00
committed by GitHub
parent 6a47d2831f
commit d936d8da82
75 changed files with 657 additions and 5366 deletions
-406
View File
@@ -1,406 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const asyncStorage = vi.hoisted(() => ({
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn()
}))
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
import {
deleteCachedSessionTabStripForHost,
getSessionTabStripCacheKey,
loadCachedSessionTabStrip,
readCachedSessionTabStrip,
resetSessionTabStripCacheForTests,
saveCachedSessionTabStrip
} from './session-tab-strip-cache'
import type { MobileSessionTabStripPreview } from '../session/mobile-session-tab-strip-entries'
const STORAGE_KEY = 'orca:session-tab-strip:v1'
function preview(...ids: string[]): MobileSessionTabStripPreview {
return {
tabs: ids.map((id) => ({ id, type: 'terminal' as const, title: id, agentId: null })),
activeTabId: ids[0] ?? null
}
}
function lastWrittenFile(): { workspaces: { key: string }[] } {
const call = asyncStorage.setItem.mock.calls.at(-1)
return JSON.parse(String(call?.[1]))
}
beforeEach(() => {
vi.useFakeTimers()
asyncStorage.getItem.mockReset().mockResolvedValue(null)
asyncStorage.setItem.mockReset().mockResolvedValue(undefined)
resetSessionTabStripCacheForTests()
})
afterEach(() => {
vi.useRealTimers()
})
describe('getSessionTabStripCacheKey', () => {
it('digests the workspace id so no filesystem path reaches the key', () => {
const path = '/Users/someone/private-client/worktrees/acquisition'
const key = getSessionTabStripCacheKey('host-1', `repo::${path}`)
expect(key).not.toContain(path)
expect(key).not.toContain('someone')
expect(key).toMatch(/^\["host-1","[0-9a-f]{32}"\]$/)
})
it('joins the two ids unambiguously, whatever a worktree path contains', () => {
expect(getSessionTabStripCacheKey('host', 'a\nb')).not.toBe(
getSessionTabStripCacheKey('host\na', 'b')
)
expect(getSessionTabStripCacheKey('host-1', 'wt-1')).not.toBe(
getSessionTabStripCacheKey('host-1', 'wt-2')
)
})
it('needs both a host and a workspace', () => {
expect(getSessionTabStripCacheKey(undefined, 'wt-1')).toBeNull()
expect(getSessionTabStripCacheKey('host-1', undefined)).toBeNull()
})
})
describe('session tab strip cache', () => {
it('serves a save back synchronously and persists it once the write settles', async () => {
const key = getSessionTabStripCacheKey('host-1', 'wt-1')
saveCachedSessionTabStrip(key, preview('tab-1', 'tab-2'))
expect(readCachedSessionTabStrip(key)?.tabs.map((tab) => tab.id)).toEqual(['tab-1', 'tab-2'])
expect(asyncStorage.setItem).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(300)
expect(asyncStorage.setItem.mock.calls[0]?.[0]).toBe(STORAGE_KEY)
expect(lastWrittenFile().workspaces.map((w) => w.key)).toEqual([key])
})
it('reads nothing synchronously before the stored file is loaded', async () => {
const key = getSessionTabStripCacheKey('host-1', 'wt-1')
asyncStorage.getItem.mockResolvedValue(
JSON.stringify({ workspaces: [{ key, preview: preview('tab-1') }] })
)
expect(readCachedSessionTabStrip(key)).toBeNull()
expect((await loadCachedSessionTabStrip(key))?.tabs.map((tab) => tab.id)).toEqual(['tab-1'])
expect(readCachedSessionTabStrip(key)?.tabs).toHaveLength(1)
})
it('returns null for a workspace with no stored strip', async () => {
expect(await loadCachedSessionTabStrip(getSessionTabStripCacheKey('host-1', 'wt-9'))).toBeNull()
expect(await loadCachedSessionTabStrip(null)).toBeNull()
})
it('survives unreadable storage', async () => {
asyncStorage.getItem.mockResolvedValue('{not json')
expect(await loadCachedSessionTabStrip(getSessionTabStripCacheKey('host-1', 'wt-1'))).toBeNull()
})
it('evicts the least recently written workspace past the cap', async () => {
for (let i = 0; i < 14; i++) {
saveCachedSessionTabStrip(getSessionTabStripCacheKey('host-1', `wt-${i}`), preview('tab-1'))
}
await vi.advanceTimersByTimeAsync(300)
const keys = lastWrittenFile().workspaces.map((w) => w.key)
expect(keys).toHaveLength(12)
expect(keys).not.toContain(getSessionTabStripCacheKey('host-1', 'wt-0'))
expect(keys.at(-1)).toBe(getSessionTabStripCacheKey('host-1', 'wt-13'))
})
it('re-writing a workspace makes it the newest, not the oldest', async () => {
for (let i = 0; i < 12; i++) {
saveCachedSessionTabStrip(getSessionTabStripCacheKey('host-1', `wt-${i}`), preview('tab-1'))
}
saveCachedSessionTabStrip(getSessionTabStripCacheKey('host-1', 'wt-0'), preview('tab-2'))
saveCachedSessionTabStrip(getSessionTabStripCacheKey('host-1', 'wt-99'), preview('tab-1'))
await vi.advanceTimersByTimeAsync(300)
const keys = lastWrittenFile().workspaces.map((w) => w.key)
expect(keys).toContain(getSessionTabStripCacheKey('host-1', 'wt-0'))
expect(keys).not.toContain(getSessionTabStripCacheKey('host-1', 'wt-1'))
})
it('records a workspace the host has emptied, so a stale strip cannot outlive it', async () => {
const key = getSessionTabStripCacheKey('host-1', 'wt-1')
saveCachedSessionTabStrip(key, preview('tab-1'))
saveCachedSessionTabStrip(key, { tabs: [], activeTabId: null })
expect(readCachedSessionTabStrip(key)).toEqual({ tabs: [], activeTabId: null })
})
it('caps tabs per workspace and title length, and drops an unmatched active id', async () => {
const key = getSessionTabStripCacheKey('host-1', 'wt-1')
saveCachedSessionTabStrip(key, {
// A file tab, because the titles that survive redaction at all are the ones the cap has
// to bound.
tabs: Array.from({ length: 30 }, (_, i) => ({
id: `tab-${i}`,
type: 'file' as const,
title: 'x'.repeat(200),
agentId: null
})),
activeTabId: 'tab-29'
})
const stored = readCachedSessionTabStrip(key)
expect(stored?.tabs).toHaveLength(24)
expect(stored?.tabs[0]?.title).toHaveLength(64)
expect(stored?.activeTabId).toBeNull()
})
it('drops fields a future tab type might smuggle into storage', async () => {
const key = getSessionTabStripCacheKey('host-1', 'wt-1')
saveCachedSessionTabStrip(key, {
tabs: [
{
id: 'tab-1',
type: 'file',
title: 'notes.md',
agentId: null,
filePath: '/Users/someone/secret/notes.md'
} as never
],
activeTabId: 'tab-1'
})
await vi.advanceTimersByTimeAsync(300)
expect(String(asyncStorage.setItem.mock.calls.at(-1)?.[1])).not.toContain('/Users/someone')
})
it('drops a stored entry naming a tab type this build cannot draw', async () => {
const key = getSessionTabStripCacheKey('host-1', 'wt-1')
saveCachedSessionTabStrip(key, {
tabs: [
{ id: 'tab-1', type: 'from-a-newer-build', title: 'raw title', agentId: null } as never,
{ id: 'tab-2', type: 'file', title: 'notes.md', agentId: null }
],
activeTabId: 'tab-2'
})
expect(readCachedSessionTabStrip(key)?.tabs.map((tab) => tab.id)).toEqual(['tab-2'])
})
it('never writes a shell-controlled terminal title, however it arrives', async () => {
const secret = 'psql postgres://admin:hunter2@db.internal/prod'
const key = getSessionTabStripCacheKey('host-1', 'wt-1')
saveCachedSessionTabStrip(key, {
tabs: [
{ id: 'tab-1', type: 'terminal', title: secret, agentId: null },
{ id: 'tab-2', type: 'terminal', title: secret, agentId: 'claude' },
{ id: 'tab-3', type: 'terminal', title: secret, agentId: 'not-a-known-agent' },
{ id: 'tab-4', type: 'browser', title: 'Acme Corp — Q3 layoffs memo', agentId: null }
],
activeTabId: 'tab-1'
})
await vi.advanceTimersByTimeAsync(300)
expect(readCachedSessionTabStrip(key)?.tabs.map((tab) => tab.title)).toEqual([
'Terminal',
'Claude',
'Terminal',
'Browser'
])
const written = String(asyncStorage.setItem.mock.calls.at(-1)?.[1])
expect(written).not.toContain('hunter2')
expect(written).not.toContain('postgres://')
expect(written).not.toContain('layoffs')
})
it('scrubs a stored title written by an older build on the way back out', async () => {
const key = getSessionTabStripCacheKey('host-1', 'wt-1')
asyncStorage.getItem.mockResolvedValue(
JSON.stringify({
workspaces: [
{
key,
preview: {
tabs: [{ id: 'tab-1', type: 'terminal', title: 'curl -H token', agentId: null }],
activeTabId: 'tab-1'
}
}
]
})
)
expect((await loadCachedSessionTabStrip(key))?.tabs[0]?.title).toBe('Terminal')
})
it('forgets an unpaired host and cannot resurrect it from a later save', async () => {
const hostA = getSessionTabStripCacheKey('host-a', 'wt-1')
const hostB = getSessionTabStripCacheKey('host-b', 'wt-1')
saveCachedSessionTabStrip(hostA, preview('tab-a'))
saveCachedSessionTabStrip(hostB, preview('tab-b'))
await vi.advanceTimersByTimeAsync(300)
await deleteCachedSessionTabStripForHost('host-a')
expect(readCachedSessionTabStrip(hostA)).toBeNull()
expect(readCachedSessionTabStrip(hostB)?.tabs).toHaveLength(1)
expect(lastWrittenFile().workspaces.map((w) => w.key)).toEqual([hostB])
saveCachedSessionTabStrip(hostB, preview('tab-b2'))
await vi.advanceTimersByTimeAsync(300)
expect(lastWrittenFile().workspaces.map((w) => w.key)).toEqual([hostB])
})
it('forgets a host whose rows are only on disk, never read this session', async () => {
const hostA = getSessionTabStripCacheKey('host-a', 'wt-1')
const hostB = getSessionTabStripCacheKey('host-b', 'wt-1')
asyncStorage.getItem.mockResolvedValue(
JSON.stringify({
workspaces: [
{ key: hostA, preview: preview('tab-a') },
{ key: hostB, preview: preview('tab-b') }
]
})
)
await deleteCachedSessionTabStripForHost('host-a')
expect(lastWrittenFile().workspaces.map((w) => w.key)).toEqual([hostB])
})
it('drops a pending debounced write so it cannot restore the forgotten host', async () => {
const hostA = getSessionTabStripCacheKey('host-a', 'wt-1')
saveCachedSessionTabStrip(hostA, preview('tab-a'))
await deleteCachedSessionTabStripForHost('host-a')
await vi.advanceTimersByTimeAsync(300)
expect(lastWrittenFile().workspaces).toEqual([])
})
it('rejects a deletion whose write never landed, rather than reporting it as done', async () => {
// A resolved delete over a failed write leaves the forgotten host's tab titles in
// plaintext on disk while every caller believes they are gone.
const hostA = getSessionTabStripCacheKey('host-a', 'wt-1')
saveCachedSessionTabStrip(hostA, preview('tab-a'))
await vi.advanceTimersByTimeAsync(300)
asyncStorage.setItem.mockRejectedValue(new Error('storage full'))
await expect(deleteCachedSessionTabStripForHost('host-a')).rejects.toThrow('storage full')
})
it('keeps a debounced save best effort, so one failed write cannot reject unowned', async () => {
asyncStorage.setItem.mockRejectedValue(new Error('storage full'))
saveCachedSessionTabStrip(getSessionTabStripCacheKey('host-a', 'wt-1'), preview('tab-a'))
// No throw and no unhandled rejection: the write is fire-and-forget by design.
await vi.advanceTimersByTimeAsync(300)
expect(asyncStorage.setItem).toHaveBeenCalledOnce()
})
it('refuses a save for the host it is in the middle of forgetting', async () => {
const hostA = getSessionTabStripCacheKey('host-a', 'wt-1')
saveCachedSessionTabStrip(hostA, preview('tab-a'))
await vi.advanceTimersByTimeAsync(300)
let releaseWrite!: () => void
asyncStorage.setItem.mockImplementationOnce(
async () =>
new Promise<void>((resolve) => {
releaseWrite = () => resolve()
})
)
const deletion = deleteCachedSessionTabStripForHost('host-a')
// The purge has run and its write is on the wire; a snapshot queued for the
// workspace the user just unpaired now lands in that window.
await vi.advanceTimersByTimeAsync(0)
saveCachedSessionTabStrip(hostA, preview('tab-a2'))
releaseWrite()
await deletion
await vi.advanceTimersByTimeAsync(300)
expect(readCachedSessionTabStrip(hostA)).toBeNull()
expect(lastWrittenFile().workspaces).toEqual([])
})
it('cannot be talked back into a host whose deletion write failed', async () => {
const hostA = getSessionTabStripCacheKey('host-a', 'wt-1')
saveCachedSessionTabStrip(hostA, preview('tab-a'))
await vi.advanceTimersByTimeAsync(300)
asyncStorage.setItem.mockRejectedValueOnce(new Error('storage full'))
await expect(deleteCachedSessionTabStripForHost('host-a')).rejects.toThrow('storage full')
const writesSoFar = asyncStorage.setItem.mock.calls.length
saveCachedSessionTabStrip(hostA, preview('tab-a3'))
await vi.advanceTimersByTimeAsync(300)
expect(readCachedSessionTabStrip(hostA)).toBeNull()
expect(asyncStorage.setItem).toHaveBeenCalledTimes(writesSoFar)
})
it('lets a debounced write that already snapshotted the removed host land first', async () => {
// The tombstone stops new saves, but a debounced write that fired a moment earlier
// built its blob from the map as it was and is still on the wire. Writing over it
// concurrently leaves which blob lands last up to storage.
const hostA = getSessionTabStripCacheKey('host-a', 'wt-1')
const hostB = getSessionTabStripCacheKey('host-b', 'wt-1')
saveCachedSessionTabStrip(hostA, preview('tab-a'))
saveCachedSessionTabStrip(hostB, preview('tab-b'))
let releaseDebounced!: () => void
asyncStorage.setItem.mockImplementationOnce(
async () =>
new Promise<void>((resolve) => {
releaseDebounced = () => resolve()
})
)
await vi.advanceTimersByTimeAsync(300)
const deletion = deleteCachedSessionTabStripForHost('host-a')
await vi.advanceTimersByTimeAsync(0)
expect(asyncStorage.setItem).toHaveBeenCalledOnce()
releaseDebounced()
await deletion
expect(asyncStorage.setItem).toHaveBeenCalledTimes(2)
expect(lastWrittenFile().workspaces.map((w) => w.key)).toEqual([hostB])
})
it('cannot let an older overlapping write commit after the purge', async () => {
// Why: two debounced writes can sit on the bridge at once, and the second used to replace
// the in-flight handle. The purge then awaited only the newer one, so the older blob --
// snapshotted while the forgotten host was still in the map -- could commit last.
const hostA = getSessionTabStripCacheKey('host-a', 'wt-1')
const hostB = getSessionTabStripCacheKey('host-b', 'wt-1')
let stored = ''
const gates: Array<() => void> = []
asyncStorage.setItem.mockImplementation(
(_key: string, value: string) =>
new Promise<void>((resolve) => {
gates.push(() => {
stored = value
resolve()
})
})
)
saveCachedSessionTabStrip(hostA, preview('tab-a'))
await vi.advanceTimersByTimeAsync(300)
saveCachedSessionTabStrip(hostB, preview('tab-b'))
await vi.advanceTimersByTimeAsync(300)
const deletion = deleteCachedSessionTabStripForHost('host-a')
// Newest released first: only writes that queue behind one another survive this.
for (let step = 0; step < 6 && gates.length > 0; step += 1) {
gates.pop()?.()
await vi.advanceTimersByTimeAsync(0)
}
await deletion
const keys = (JSON.parse(stored) as { workspaces: { key: string }[] }).workspaces.map(
(workspace) => workspace.key
)
expect(keys).toEqual([hostB])
})
})
-260
View File
@@ -1,260 +0,0 @@
// Why: reconnecting to a workspace the phone opened a minute ago tears the session screen back
// to an empty strip and a spinner, even though the tab list it is about to be handed is the one
// it just displayed. Persist the shape of the strip per workspace so a reconnect paints the
// known tabs immediately and swaps in live rows under the same keys.
//
// This file is the authority on what reaches plaintext storage, not its callers: every entry is
// rebuilt field by field on the way in, and shell-controlled titles are replaced with fixed
// labels here rather than trusted to have been scrubbed upstream.
import AsyncStorage from '@react-native-async-storage/async-storage'
import { sha256 } from '@noble/hashes/sha256'
import {
getPersistableTabStripTitle,
isDrawableTabStripType,
type MobileSessionTabStripEntry,
type MobileSessionTabStripPreview
} from '../session/mobile-session-tab-strip-entries'
const STORAGE_KEY = 'orca:session-tab-strip:v1'
// A phone realistically revisits a handful of workspaces; the caps bound both the stored blob
// and the cost of a single write.
const MAX_WORKSPACES = 12
const MAX_TABS_PER_WORKSPACE = 24
const MAX_TITLE_LENGTH = 64
const WRITE_DEBOUNCE_MS = 250
// 128 bits of a digest: far past collision range for a dozen workspaces, and short enough that
// the stored blob stays small.
const WORKSPACE_DIGEST_LENGTH = 32
type StoredWorkspace = { key: string; preview: MobileSessionTabStripPreview }
type StoredFile = { workspaces: StoredWorkspace[] }
// Insertion-ordered, so the first key is the least recently written one to evict.
let memoryCache: Map<string, MobileSessionTabStripPreview> | null = null
let loadPromise: Promise<Map<string, MobileSessionTabStripPreview>> | null = null
let writeTimer: ReturnType<typeof setTimeout> | null = null
// Tail of the write chain. Every write queues behind it, so an older setItem can never
// settle after a newer one and make its stale blob the last word on disk.
let writeInFlight: Promise<void> | null = null
// Hosts forgotten this session. A save racing the deletion would re-insert the host and
// the next debounced write would put its tab titles back on disk, so refuse those saves
// outright. Re-pairing the same host caches again from the next app launch — the cheap
// direction for a deletion the user asked for.
const forgottenHosts = new Set<string>()
/**
* A workspace id ends in a filesystem path, so it is digested rather than stored. The host id
* stays readable because forgetting a host has to be able to find that host's rows, and because
* host ids already key several other entries in this store.
*/
export function getSessionTabStripCacheKey(
hostId: string | undefined,
worktreeId: string | undefined
): string | null {
if (!hostId || !worktreeId) {
return null
}
return JSON.stringify([hostId, digestWorkspaceId(worktreeId)])
}
/** Whatever this process already knows, with no await — so a revisit paints on the first frame. */
export function readCachedSessionTabStrip(key: string | null): MobileSessionTabStripPreview | null {
if (!key || !memoryCache) {
return null
}
return memoryCache.get(key) ?? null
}
export async function loadCachedSessionTabStrip(
key: string | null
): Promise<MobileSessionTabStripPreview | null> {
if (!key) {
return null
}
const cache = await loadFile()
return cache.get(key) ?? null
}
export function saveCachedSessionTabStrip(
key: string | null,
preview: MobileSessionTabStripPreview
): void {
if (!key) {
return
}
const hostId = readHostIdFromKey(key)
if (hostId !== null && forgottenHosts.has(hostId)) {
return
}
const redacted = redactPreview(preview)
const cache = memoryCache ?? new Map()
memoryCache = cache
// Map.set on an existing key keeps its original iteration position, so delete first to make
// the re-inserted key the newest and give the cap true LRU eviction.
cache.delete(key)
cache.set(key, redacted)
while (cache.size > MAX_WORKSPACES) {
const oldest = cache.keys().next().value
if (oldest === undefined) {
break
}
cache.delete(oldest)
}
scheduleWrite(cache)
}
/**
* Drop every workspace belonging to a host the user has unpaired. Both the in-memory rows and
* the stored blob have to go: leaving either behind means the next save for any other host
* serializes the forgotten host's tabs straight back to disk.
*/
export async function deleteCachedSessionTabStripForHost(hostId: string): Promise<void> {
// Before the first await: a save landing during the load or the write must not
// re-insert the host the caller is in the middle of forgetting.
forgottenHosts.add(hostId)
// Load first so the rewrite below preserves other hosts. If storage is unreadable we still
// rewrite, which can cost another host its rows — the wrong direction for a cache, the right
// one for a deletion the user asked for.
const cache = await loadFile()
// Deleting the entry the iterator is standing on is well-defined for a Map.
for (const key of cache.keys()) {
if (readHostIdFromKey(key) === hostId) {
cache.delete(key)
}
}
if (writeTimer) {
clearTimeout(writeTimer)
writeTimer = null
}
// Queued, not raced: the purge is the last write, and its failure is the caller's.
await enqueueWrite(cache)
}
export function resetSessionTabStripCacheForTests(): void {
if (writeTimer) {
clearTimeout(writeTimer)
writeTimer = null
}
memoryCache = null
loadPromise = null
writeInFlight = null
forgottenHosts.clear()
}
function digestWorkspaceId(worktreeId: string): string {
const digest = sha256(new TextEncoder().encode(worktreeId))
let hex = ''
for (const byte of digest) {
hex += byte.toString(16).padStart(2, '0')
}
return hex.slice(0, WORKSPACE_DIGEST_LENGTH)
}
function readHostIdFromKey(key: string): string | null {
try {
const parsed = JSON.parse(key) as unknown
return Array.isArray(parsed) && typeof parsed[0] === 'string' ? parsed[0] : null
} catch {
return null
}
}
async function loadFile(): Promise<Map<string, MobileSessionTabStripPreview>> {
if (memoryCache) {
return memoryCache
}
loadPromise ??= (async () => {
const parsed = await readStoredFile()
// A save that landed while the read was in flight owns the newer truth.
const cache = memoryCache ?? new Map<string, MobileSessionTabStripPreview>()
for (const workspace of parsed) {
if (!cache.has(workspace.key)) {
cache.set(workspace.key, workspace.preview)
}
}
memoryCache = cache
return cache
})()
return loadPromise
}
async function readStoredFile(): Promise<StoredWorkspace[]> {
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY)
if (!raw) {
return []
}
const parsed = JSON.parse(raw) as StoredFile
if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed.workspaces)) {
return []
}
return parsed.workspaces.flatMap((workspace) => {
if (typeof workspace?.key !== 'string' || !Array.isArray(workspace.preview?.tabs)) {
return []
}
return [{ key: workspace.key, preview: redactPreview(workspace.preview) }]
})
} catch {
return []
}
}
// Why: a flurry of snapshots (one per desktop republication) must not hammer AsyncStorage.
function scheduleWrite(cache: Map<string, MobileSessionTabStripPreview>): void {
if (writeTimer) {
clearTimeout(writeTimer)
}
writeTimer = setTimeout(() => {
writeTimer = null
// Best effort by design: a dropped cache refresh costs one repaint, and the next
// save rewrites the whole map. Only the deletion path needs the failure.
void enqueueWrite(cache).catch(() => {})
}, WRITE_DEBOUNCE_MS)
}
// Why the chain rather than one handle: two debounced writes can overlap on the bridge, and
// the second overwrote the handle. A deletion then awaited only the newer one, so the older
// write -- serialized before the purge, host rows and all -- could land last and restore them.
function enqueueWrite(cache: Map<string, MobileSessionTabStripPreview>): Promise<void> {
const queued = (writeInFlight ?? Promise.resolve()).then(() => writeFile(cache))
// A rejected link must not break the chain for the writes queued behind it.
writeInFlight = queued.catch(() => {})
return queued
}
async function writeFile(cache: Map<string, MobileSessionTabStripPreview>): Promise<void> {
const workspaces: StoredWorkspace[] = [...cache].map(([key, preview]) => ({ key, preview }))
// Throws on purpose: a deletion that only removed the in-memory rows must not be
// reported as a deletion, or the forgotten host's titles stay in plaintext on disk.
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ workspaces }))
}
// Rebuilt field by field so a field later added to the live tab type cannot ride into storage
// without someone deciding it belongs there.
function redactPreview(preview: MobileSessionTabStripPreview): MobileSessionTabStripPreview {
const tabs: MobileSessionTabStripEntry[] = []
for (const tab of preview.tabs ?? []) {
if (typeof tab?.id !== 'string' || !isDrawableTabStripType(tab.type)) {
continue
}
const agentId = typeof tab.agentId === 'string' ? tab.agentId : null
const title = typeof tab.title === 'string' ? tab.title : ''
tabs.push({
id: tab.id,
type: tab.type,
title: getPersistableTabStripTitle({ type: tab.type, title, agentId }).slice(
0,
MAX_TITLE_LENGTH
),
agentId
})
if (tabs.length === MAX_TABS_PER_WORKSPACE) {
break
}
}
const activeTabId =
typeof preview.activeTabId === 'string' && tabs.some((tab) => tab.id === preview.activeTabId)
? preview.activeTabId
: null
return { tabs, activeTabId }
}
+4 -172
View File
@@ -44,12 +44,6 @@ 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() {
@@ -63,13 +57,7 @@ function gateElement() {
return createElement(
HostProtocolGate,
{ hostId: 'host-1' },
createElement(
'HostContent',
null,
createElement(GateConsumer),
createElement(VerifiedConsumer),
createElement(MountProbe)
)
createElement('HostContent', null, createElement(GateConsumer), createElement(MountProbe))
)
}
@@ -166,90 +154,13 @@ 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')
})
// 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 () => {
it('does not mount host routes before a connected host passes the compatibility probe', async () => {
const client = {
sendRequest: vi.fn().mockReturnValue(new Promise(() => {}))
} as unknown as RpcClient
@@ -257,40 +168,9 @@ describe('HostProtocolGate', () => {
renderer = await renderGate()
const output = renderedText(renderer)
expect(output).toContain('Checking host compatibility')
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')
expect(probeMounts.count).toBe(0)
expect(client.sendRequest).toHaveBeenCalledOnce()
})
it('overlays the pending spinner instead of unmounting routes mounted while connecting', async () => {
@@ -379,52 +259,4 @@ 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()
})
})
+33 -9
View File
@@ -22,26 +22,45 @@ 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 latch records committed outcomes only.
// Why: React can replay or discard a render, so the latches record committed
// outcomes only — a discarded children render must not count as mounted.
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} />
}
@@ -58,11 +77,10 @@ export function HostProtocolGate({ hostId, children }: Props) {
{children}
</View>
{pending ? (
// 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.
// 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.
<View
style={styles.pendingOverlay}
// Why: the fill owns the hit test for in-tree views only — native-Modal-hosted
@@ -82,6 +100,12 @@ 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-status-probe', () => ({
vi.mock('../transport/runtime-capability-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-status-probe'
import { startRuntimeCapabilityProbe } from '../transport/runtime-capability-probe'
// Why: source the capability string from the shared contract so a host bump can never
// silently drift from the mobile probe.
@@ -1,36 +1,8 @@
import { describe, expect, it } from 'vitest'
import { buildConnectionDiagnosticsReport } from './connection-diagnostics-report'
import type { ConnectionLogEntry } from '../transport/types'
const NOW = Date.UTC(2026, 6, 9, 22, 0, 0)
function stageEntry(
id: string,
ts: number,
name: string,
ms: number,
complete: boolean
): ConnectionLogEntry {
return {
id,
ts,
level: complete ? 'info' : 'warn',
path: 'relay',
message: `Relay dial stage ${name} ${complete ? 'finished' : 'did not finish'}`,
timing: { kind: 'relay-dial-stage', name, ms, complete }
}
}
function stateEntry(id: string, ts: number, name: string, ms: number): ConnectionLogEntry {
return {
id,
ts,
level: 'info',
message: `Connection state ${name} → connected`,
timing: { kind: 'connection-state', name, ms, complete: true }
}
}
describe('buildConnectionDiagnosticsReport', () => {
it('summarizes a failing Tailscale host with its log', () => {
const report = buildConnectionDiagnosticsReport({
@@ -96,28 +68,13 @@ describe('buildConnectionDiagnosticsReport', () => {
activePath: 'tailscale',
pendingPath: 'relay',
entries: [
{
id: 'relay-stage-opening',
ts: NOW - 6_000,
level: 'info',
path: 'relay',
message: 'Relay dial stage opening finished',
detail: '118ms — resumeToken=secret-resume-token',
timing: { kind: 'relay-dial-stage', name: 'opening', ms: 118, complete: true }
},
{
id: 'relay-failure',
ts: NOW - 5_000,
level: 'error',
message: 'Relay: relay dial failed',
detail:
'RelayDirectorHttpError: relay director resolve failed (503); retry after 30000ms; resumeToken=secret-resume-token',
timing: {
kind: 'relay-dial-stage',
name: 'awaiting-hello',
ms: 9_100,
complete: false
}
'RelayDirectorHttpError: relay director resolve failed (503); retry after 30000ms; resumeToken=secret-resume-token'
}
],
nowMs: NOW
@@ -130,9 +87,6 @@ describe('buildConnectionDiagnosticsReport', () => {
expect(report).toContain('Next step: Keep Orca open; recovery should retry automatically.')
expect(report).toContain('resumeToken=[redacted]')
expect(report).not.toContain('secret-resume-token')
expect(report).toContain(
'Relay dial stages: opening 118ms · awaiting-hello 9.1s (did not finish) — total 9.2s'
)
})
it('redacts quoted JSON credentials and never echoes an invalid endpoint', () => {
@@ -162,52 +116,6 @@ describe('buildConnectionDiagnosticsReport', () => {
expect(report).not.toContain('bearer-secret')
})
it('breaks a slow connect down by dial stage and connection state', () => {
const report = buildConnectionDiagnosticsReport({
hostName: 'Host 6',
endpoint: 'ws://192.168.1.50:6768',
state: 'connected',
reconnectAttempts: 2,
lastConnectedAt: NOW,
platform: 'ios 26.5.1',
appVersion: '0.0.47',
entries: [
stageEntry('a1', NOW - 30_000, 'opening', 90, false),
stateEntry('s1', NOW - 29_000, 'connecting', 12_000),
stageEntry('b1', NOW - 20_000, 'opening', 120, true),
stageEntry('b2', NOW - 19_000, 'awaiting-hello', 6_400, true),
stageEntry('b3', NOW - 13_000, 'handshaking', 240, true),
stageEntry('b4', NOW - 12_000, 'confirming', 1_180, true),
stateEntry('s2', NOW - 11_000, 'connecting', 8_000)
],
nowMs: NOW
})
// Only the latest dial is broken out, so a reconnect loop cannot average away
// the attempt the reporter is complaining about.
expect(report).toContain(
'Relay dial stages (latest of 2): opening 120ms · awaiting-hello 6.4s · handshaking 240ms · confirming 1.2s — total 7.9s'
)
expect(report).toContain('Connection state dwell: connecting 20.0s ×2')
})
it('omits the timing lines when nothing recorded a phase duration', () => {
const report = buildConnectionDiagnosticsReport({
hostName: 'Host 7',
endpoint: 'ws://192.168.1.50:6768',
state: 'connected',
reconnectAttempts: 0,
lastConnectedAt: NOW,
platform: 'ios 26.5.1',
appVersion: '0.0.47',
entries: [{ id: 'plain', ts: NOW, level: 'info', message: 'Authenticated' }],
nowMs: NOW
})
expect(report).not.toContain('Relay dial stages')
expect(report).not.toContain('Connection state dwell')
})
it('bounds a single event line before submission while preserving its identity', () => {
const report = buildConnectionDiagnosticsReport({
hostName: 'Host 5',
@@ -8,7 +8,6 @@ import { normalizeHostAppVersion } from '../transport/host-app-version-store'
import { formatEndpoint } from './host-reachability'
import { diagnoseConnection } from './connection-diagnostics-analysis'
import { redactConnectionLogEntry, redactConnectionLogText } from './connection-log-redaction'
import { summarizeConnectionLogTimings } from './connection-log-timing-summary'
const MAX_EVENT_LINE_BYTES = 2 * 1024
const EVENT_TRUNCATION_MARKER = ' … [truncated]'
@@ -60,7 +59,6 @@ export function buildConnectionDiagnosticsReport(args: {
? 'Last connected: never this session'
: `Last connected: ${new Date(args.lastConnectedAt).toISOString()} (${formatAgo(now - args.lastConnectedAt)} ago)`
)
lines.push(...summarizeConnectionLogTimings(entries))
lines.push('')
lines.push(`Likely cause: ${diagnosis.likelyCause}`)
lines.push(`Next step: ${diagnosis.nextStep}`)
@@ -1,66 +0,0 @@
import type { ConnectionLogEntry, ConnectionLogTiming } from '../transport/types'
// Why: a report that only says "connecting for 10s" cannot be triaged. These lines
// turn the per-phase timings the transport now records into the two questions
// support actually asks: which relay dial stage ate the time, and how long the
// client sat in each connection state.
export function summarizeConnectionLogTimings(entries: readonly ConnectionLogEntry[]): string[] {
const timings = entries.flatMap((entry) => (entry.timing ? [entry.timing] : []))
const lines: string[] = []
const dials = groupRelayDials(timings.filter((timing) => timing.kind === 'relay-dial-stage'))
const latestDial = dials.at(-1)
if (latestDial) {
const label =
dials.length > 1 ? `Relay dial stages (latest of ${dials.length})` : 'Relay dial stages'
const total = latestDial.reduce((sum, timing) => sum + timing.ms, 0)
lines.push(
`${label}: ${latestDial.map(formatStageTiming).join(' · ')} — total ${formatDurationMs(total)}`
)
}
const states = totalPerName(timings.filter((timing) => timing.kind === 'connection-state'))
if (states.length > 0) {
lines.push(
`Connection state dwell: ${states
.map(
({ name, ms, count }) => `${name} ${formatDurationMs(ms)}${count > 1 ? ` ×${count}` : ''}`
)
.join(' · ')}`
)
}
return lines
}
// Relay dial stages are strictly ordered and every dial starts in 'opening', so an
// 'opening' timing opens a new group. Reporting only the latest keeps a reconnect
// loop from averaging away the attempt the reporter is complaining about.
function groupRelayDials(timings: readonly ConnectionLogTiming[]): ConnectionLogTiming[][] {
const dials: ConnectionLogTiming[][] = []
for (const timing of timings) {
if (timing.name === 'opening' || dials.length === 0) {
dials.push([])
}
dials.at(-1)!.push(timing)
}
return dials
}
function totalPerName(
timings: readonly ConnectionLogTiming[]
): { name: string; ms: number; count: number }[] {
const totals = new Map<string, { name: string; ms: number; count: number }>()
for (const timing of timings) {
const total = totals.get(timing.name) ?? { name: timing.name, ms: 0, count: 0 }
total.ms += timing.ms
total.count += 1
totals.set(timing.name, total)
}
return [...totals.values()]
}
function formatStageTiming(timing: ConnectionLogTiming): string {
return `${timing.name} ${formatDurationMs(timing.ms)}${timing.complete ? '' : ' (did not finish)'}`
}
function formatDurationMs(ms: number): string {
return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`
}
@@ -2,8 +2,6 @@ import { Animated, View, Text, Pressable, ActivityIndicator } from 'react-native
import { saveTerminalTextScale } from '../storage/preferences'
import { MobileBrowserPane } from '../browser/MobileBrowserPane'
import { TerminalPaneView } from './TerminalPaneView'
import { TerminalEnginePrewarm } from './TerminalEnginePrewarm'
import { MOBILE_SESSION_TAB_BAR_HEIGHT } from './mobile-session-frame-styles'
import { MobileNativeChatOverlay } from './MobileNativeChatOverlay'
import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-session-styles'
@@ -76,38 +74,16 @@ export function MobileSessionActiveContent({
activePendingTerminalTab,
isPendingTerminalRecoveryParked,
retryPendingTerminalRecovery,
reconnectViewState,
tabStripRows,
showLoadingState,
measurePrewarmViewport,
showEmptyState,
keyboardLift,
activeTerminalKeyboardLift,
toastAnimatedStyle,
createTabBusy
} = controller
// Why the same list the header gates on: an unmounted tab bar gives the content row its band
// back, so the pre-warm would measure a taller box than the pane ever gets. Reading the header's
// own rows (live or cached preview) keeps the two from drifting.
const prewarmReservedTabBarHeight = tabStripRows.length > 0 ? 0 : MOBILE_SESSION_TAB_BAR_HEIGHT
// Why: the cached strip in the header is the content during a reconnect; the terminal body
// cannot be, because replaying stored scrollback into the WebView would double-render once the
// live stream replays the same rows. See mobile-session-reconnect-view-state. The engine still
// boots inside the real terminal frame while the startup RPCs are in flight, so the first pane
// inherits a warm WebView and a measured viewport (see prewarm).
return reconnectViewState.kind === 'reconnecting-with-cache' || showLoadingState ? (
<View style={styles.terminalFrame}>
<View style={styles.emptyState}>
<ActivityIndicator size="small" color={colors.textSecondary} />
{reconnectViewState.kind === 'reconnecting-with-cache' ? (
<Text style={styles.emptyText}>{reconnectViewState.label}</Text>
) : null}
</View>
<TerminalEnginePrewarm
reservedTabBarHeight={prewarmReservedTabBarHeight}
textScale={terminalTextScale}
onEngineMeasured={measurePrewarmViewport}
/>
return showLoadingState ? (
<View style={styles.emptyState}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
) : showEmptyState ? (
<View style={styles.emptyState}>
@@ -195,35 +171,25 @@ export function MobileSessionActiveContent({
)}
</View>
) : activePendingTerminalTab ? (
<View style={styles.terminalFrame}>
<View style={styles.emptyState}>
{!isPendingTerminalRecoveryParked && (
<ActivityIndicator size="small" color={colors.textSecondary} />
)}
<Text style={styles.emptyText}>
{isPendingTerminalRecoveryParked
? 'Terminal is taking longer than expected'
: activePendingTerminalTab.title || 'Loading terminal'}
</Text>
{isPendingTerminalRecoveryParked && (
<Pressable
accessibilityRole="button"
accessibilityLabel="Retry loading terminal"
style={({ pressed }) => [
styles.createButton,
pressed && styles.newTerminalButtonPressed
]}
onPress={() => void retryPendingTerminalRecovery()}
>
<Text style={styles.createButtonText}>Retry</Text>
</Pressable>
)}
</View>
<TerminalEnginePrewarm
reservedTabBarHeight={prewarmReservedTabBarHeight}
textScale={terminalTextScale}
onEngineMeasured={measurePrewarmViewport}
/>
<View style={styles.emptyState}>
{!isPendingTerminalRecoveryParked && (
<ActivityIndicator size="small" color={colors.textSecondary} />
)}
<Text style={styles.emptyText}>
{isPendingTerminalRecoveryParked
? 'Terminal is taking longer than expected'
: activePendingTerminalTab.title || 'Loading terminal'}
</Text>
{isPendingTerminalRecoveryParked && (
<Pressable
accessibilityRole="button"
accessibilityLabel="Retry loading terminal"
style={({ pressed }) => [styles.createButton, pressed && styles.newTerminalButtonPressed]}
onPress={() => void retryPendingTerminalRecovery()}
>
<Text style={styles.createButtonText}>Retry</Text>
</Pressable>
)}
</View>
) : (
<View
+29 -30
View File
@@ -14,6 +14,10 @@ import { MobileSessionHeaderIconButton } from './MobileSessionHeaderIconButton'
import { triggerMediumImpact } from '../platform/haptics'
import { StatusDot } from '../components/StatusDot'
import { MobileAgentIcon } from '../components/MobileAgentIcon'
import {
getMobileSessionTabTitle,
resolveMobileTerminalTabAgentId
} from './mobile-terminal-tab-agent'
import { colors } from '../theme/mobile-theme'
import { QuickCommandsTabButton } from './QuickCommandsTabButton'
import { styles } from './mobile-session-styles'
@@ -28,6 +32,7 @@ export function MobileSessionHeader({ controller }: { controller: MobileSessionC
forceReconnectHost,
worktreeName,
activePanel,
activeSessionTabId,
activeSessionTabIdRef,
tabStripRef,
tabStripOffsetRef,
@@ -47,7 +52,7 @@ export function MobileSessionHeader({ controller }: { controller: MobileSessionC
scrollActiveTabIntoView,
switchSessionTab,
openSessionTabActionSheetAfterKeyboardDismiss,
tabStripRows,
visibleTabs,
showConnectionRetry,
terminalSummary,
handlePanelTap,
@@ -112,7 +117,7 @@ export function MobileSessionHeader({ controller }: { controller: MobileSessionC
) : null}
</View>
{tabStripRows.length > 0 && (
{visibleTabs.length > 0 && (
<View style={styles.tabBar}>
{/* Why: tab taps must register on first press with the keyboard open instead of being eaten by dismissal (#5106). */}
<ScrollView
@@ -135,51 +140,45 @@ export function MobileSessionHeader({ controller }: { controller: MobileSessionC
scrollActiveTabIntoView(activeSessionTabIdRef.current, false)
}}
>
{tabStripRows.map(({ entry, isActive, tab }) => (
{visibleTabs.map((t) => (
<Pressable
key={entry.id}
style={[
styles.tab,
isActive && styles.tabActive,
tab === null && styles.tabPreview
]}
key={t.id}
style={[styles.tab, t.id === activeSessionTabId && styles.tabActive]}
onLayout={(e) => {
const { x, width } = e.nativeEvent.layout
tabLayoutsRef.current.set(entry.id, { x, width })
if (entry.id === activeSessionTabIdRef.current) {
scrollActiveTabIntoView(entry.id, false)
tabLayoutsRef.current.set(t.id, { x, width })
if (t.id === activeSessionTabIdRef.current) {
scrollActiveTabIntoView(t.id, false)
}
}}
// A cached preview row has no live tab behind it, so both gestures need the
// reconnect to land first.
disabled={tab === null}
onPress={tab === null ? undefined : () => switchSessionTab(tab)}
onLongPress={
tab === null
? undefined
: () => {
triggerMediumImpact()
openSessionTabActionSheetAfterKeyboardDismiss(tab)
}
}
onPress={() => switchSessionTab(t)}
onLongPress={() => {
triggerMediumImpact()
openSessionTabActionSheetAfterKeyboardDismiss(t)
}}
delayLongPress={400}
>
<View style={styles.tabLabelRow}>
{entry.type === 'browser' && (
{t.type === 'browser' && (
<Globe size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
{entry.type === 'markdown' && (
{t.type === 'markdown' && (
<FileText size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
{entry.type === 'file' && (
{t.type === 'file' && (
<File size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
{entry.agentId !== null && <MobileAgentIcon agentId={entry.agentId} size={13} />}
{t.type === 'agent-session' && <MobileAgentIcon agentId={t.agent} size={13} />}
{t.type === 'terminal' &&
(() => {
const agentId = resolveMobileTerminalTabAgentId(t)
return agentId ? <MobileAgentIcon agentId={agentId} size={13} /> : null
})()}
<Text
style={[styles.tabText, isActive && styles.tabTextActive]}
style={[styles.tabText, t.id === activeSessionTabId && styles.tabTextActive]}
numberOfLines={1}
>
{entry.title}
{getMobileSessionTabTitle(t)}
</Text>
</View>
</Pressable>
@@ -1,238 +0,0 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { TerminalWebViewHandle } from '../terminal/terminal-webview-contract'
const engine = vi.hoisted(() => ({
init: vi.fn((_cols: number, _rows: number) => {}),
awaitReady: vi.fn(async () => {}),
measureFitDimensions: vi.fn(async (_containerHeight?: number) => ({ cols: 120, rows: 40 })),
onWebReady: null as (() => void) | null,
textScale: undefined as number | undefined
}))
vi.mock('react-native', () => ({
StyleSheet: {
create: <T>(styles: T) => styles,
absoluteFillObject: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }
},
View: 'View'
}))
// Stands in for the real engine: records the ref the pre-warm pane holds and the ready callback
// it arms, so a test can drive web-ready and layout in either order.
vi.mock('../terminal/TerminalWebView', async () => {
const { forwardRef, useImperativeHandle } = await import('react')
return {
TerminalWebView: forwardRef<
TerminalWebViewHandle,
{ onWebReady?: () => void; textScale?: number }
>(function MockTerminalWebView(props, ref) {
engine.onWebReady = props.onWebReady ?? null
engine.textScale = props.textScale
useImperativeHandle(ref, () => engine as unknown as TerminalWebViewHandle, [])
return createElement('MockTerminalWebView')
})
}
})
import { TerminalEnginePrewarm } from './TerminalEnginePrewarm'
const FRAME = { x: 0, y: 0, width: 390, height: 700 }
const TEXT_SCALE = 1.25
function renderPrewarm(onEngineMeasured: (ref: TerminalWebViewHandle, height: number) => void): {
renderer: ReactTestRenderer
layout: (frame: { x: number; y: number; width: number; height: number }) => void
webReady: () => void
} {
let renderer: ReactTestRenderer | null = null
act(() => {
renderer = create(
createElement(TerminalEnginePrewarm, {
reservedTabBarHeight: 0,
textScale: TEXT_SCALE,
onEngineMeasured
})
)
})
const created = renderer as unknown as ReactTestRenderer
return {
renderer: created,
layout: (frame) =>
act(() => {
created.root.findAllByType('View')[0]?.props.onLayout({ nativeEvent: { layout: frame } })
}),
webReady: () =>
act(() => {
engine.onWebReady?.()
})
}
}
// The handoff now waits on the engine's ready promise, so tests have to let microtasks run.
async function flushReady(): Promise<void> {
await act(async () => {})
}
afterEach(() => {
engine.measureFitDimensions.mockClear()
engine.init.mockClear()
engine.awaitReady.mockReset()
engine.awaitReady.mockResolvedValue(undefined)
engine.onWebReady = null
engine.textScale = undefined
})
describe('TerminalEnginePrewarm', () => {
it('boots the engine without waiting for a terminal to attach', () => {
const measured = vi.fn()
const { renderer } = renderPrewarm(measured)
// The engine mounts on the first render, so its bundle loads while the startup RPCs fly.
expect(renderer.root.findAllByType('MockTerminalWebView')).toHaveLength(1)
expect(measured).not.toHaveBeenCalled()
})
it('withholds the measurement until the pane has a real layout', async () => {
const measured = vi.fn()
const { webReady, layout } = renderPrewarm(measured)
webReady()
// Why: this is the 80x24 trap — an unsized engine answers with xterm's default, and that
// number would ride the first subscribe to the host as the PTY size.
expect(measured).not.toHaveBeenCalled()
layout({ ...FRAME, width: 0, height: 0 })
expect(measured).not.toHaveBeenCalled()
layout(FRAME)
await flushReady()
expect(measured).toHaveBeenCalledOnce()
expect(measured.mock.calls[0]?.[1]).toBe(FRAME.height)
})
it('withholds the measurement until the engine reports ready', async () => {
const measured = vi.fn()
const { layout, webReady } = renderPrewarm(measured)
layout(FRAME)
expect(measured).not.toHaveBeenCalled()
webReady()
await flushReady()
expect(measured).toHaveBeenCalledOnce()
})
it('measures once however many times layout and web-ready repeat', async () => {
const measured = vi.fn()
const { layout, webReady } = renderPrewarm(measured)
layout(FRAME)
webReady()
webReady()
layout({ ...FRAME, height: 640 })
layout(FRAME)
await flushReady()
expect(measured).toHaveBeenCalledOnce()
})
it('opens the engine before handing it over, because web-ready alone builds no terminal', async () => {
const measured = vi.fn()
let releaseReady: (() => void) | null = null
engine.awaitReady.mockImplementation(
() =>
new Promise<void>((resolve) => {
releaseReady = resolve
})
)
const { layout, webReady } = renderPrewarm(measured)
layout(FRAME)
webReady()
// Why: the WebView answers `measure` with null while it has no terminal, and the pane latches
// once, so handing the engine over before init would spend the one measurement on nothing.
expect(engine.init).toHaveBeenCalledOnce()
expect(measured).not.toHaveBeenCalled()
releaseReady?.()
await flushReady()
expect(measured).toHaveBeenCalledOnce()
expect(measured.mock.calls[0]?.[0]).toBe(engine)
})
it('pre-warms at the text size the first pane will open with', () => {
renderPrewarm(vi.fn())
// Cell size is what the frame gets divided by, so a default-sized engine would measure a
// different phone than the one the user is looking at.
expect(engine.textScale).toBe(TEXT_SCALE)
})
it('reports the frame the pane ended up with when a resize lands during engine start-up', async () => {
const measured = vi.fn()
let releaseReady: (() => void) | null = null
engine.awaitReady.mockImplementation(
() =>
new Promise<void>((resolve) => {
releaseReady = resolve
})
)
const { layout, webReady } = renderPrewarm(measured)
layout(FRAME)
webReady()
expect(measured).not.toHaveBeenCalled()
// A rotation or split-screen resize while the engine is still coming up. The latch has already
// fired, so this is the last chance to correct the height the one measurement is taken against.
const resized = { ...FRAME, width: 700, height: 360 }
layout(resized)
releaseReady?.()
await flushReady()
expect(measured).toHaveBeenCalledOnce()
expect(measured.mock.calls[0]?.[1]).toBe(resized.height)
})
it('drops the handoff when the pane unmounts before the engine is ready', async () => {
const measured = vi.fn()
let releaseReady: (() => void) | null = null
engine.awaitReady.mockImplementation(
() =>
new Promise<void>((resolve) => {
releaseReady = resolve
})
)
const { renderer, layout, webReady } = renderPrewarm(measured)
layout(FRAME)
webReady()
act(() => {
renderer.unmount()
})
releaseReady?.()
await flushReady()
// The frame this measurement was taken against is gone, so it describes nothing.
expect(measured).not.toHaveBeenCalled()
})
it('is inert: no touches, no accessibility, and nothing sent to a terminal', async () => {
const measured = vi.fn()
const { renderer, layout, webReady } = renderPrewarm(measured)
layout(FRAME)
webReady()
await flushReady()
const pane = renderer.root.findAllByType('View')[0]
expect(pane?.props.pointerEvents).toBe('none')
expect(pane?.props.accessibilityElementsHidden).toBe(true)
expect(pane?.props.importantForAccessibility).toBe('no-hide-descendants')
// The pane owns no handle, so it has no way to subscribe, send input, or resize a PTY.
// Opening the engine is WebView-local; the measurement itself is the caller's to take.
expect(engine.measureFitDimensions).not.toHaveBeenCalled()
expect(measured.mock.calls[0]?.[0]).toBe(engine)
})
})
@@ -1,111 +0,0 @@
import { useCallback, useRef } from 'react'
import { StyleSheet, View, type LayoutChangeEvent } from 'react-native'
import { TerminalWebView } from '../terminal/TerminalWebView'
import type { TerminalWebViewHandle } from '../terminal/terminal-webview-contract'
// Diagnostics label for the measurement this pane contributes; it is not a PTY handle.
export const TERMINAL_ENGINE_PREWARM_HANDLE = '(engine-prewarm)'
// Why: the WebView builds no xterm until it is told to, and `measure` answers null while `term`
// is null, so the engine has to be opened before it can be asked anything. These are placeholder
// dimensions for an empty buffer nobody reads; the measurement derives its own cols and rows from
// the frame and the font's cell size, so nothing downstream inherits them.
const PREWARM_INIT_COLS = 80
const PREWARM_INIT_ROWS = 24
type Props = {
// Height the tab bar will claim from the top of this frame once the session has a tab. The
// loading state has no visible tab, so the bar is not mounted yet and the box the pane will
// finally occupy is this much shorter. Reserving it keeps the measurement honest; measuring
// the taller box would latch too many rows and send them to the host as the PTY size.
reservedTabBarHeight: number
// Why: the first pane opens at the user's saved text size, and cell size is what the
// measurement divides the frame by. Pre-warming at a different size measures a different phone.
textScale: number
onEngineMeasured: (ref: TerminalWebViewHandle, frameHeight: number) => void
}
// Why: a session still resolving its tabs already knows it is heading for a terminal, so load
// the xterm engine alongside the startup RPCs instead of after terminal.list returns. This pane
// owns no handle: it never subscribes, never sends input, and can never resize a PTY. Its only
// output is the viewport measurement the first real pane would otherwise pay a round trip for.
export function TerminalEnginePrewarm({
reservedTabBarHeight,
textScale,
onEngineMeasured
}: Props) {
const engineRef = useRef<TerminalWebViewHandle | null>(null)
const frameHeightRef = useRef(0)
const webReadyRef = useRef(false)
const measuredRef = useRef(false)
// Idempotent by construction: both triggers funnel here and the latch fires once per mount.
const measureWhenSized = useCallback(() => {
const engine = engineRef.current
// Why: an unsized or unmounted WebView measures xterm's 80x24 default, and that number
// rides the first subscribe to the host. Only a laid-out engine is allowed to answer.
if (measuredRef.current || !webReadyRef.current || !engine || frameHeightRef.current <= 0) {
return
}
measuredRef.current = true
// `web-ready` only says the xterm bundle loaded. Opening the engine is what creates `term`,
// and `awaitReady` is what lets its cell dimensions exist before anything reads them.
engine.init(PREWARM_INIT_COLS, PREWARM_INIT_ROWS)
void engine.awaitReady().then(() => {
// React nulls the ref on unmount, so this proves the pane the frame belongs to is still up.
if (engineRef.current !== engine) {
return
}
// Why read the height here and not before the wait: a rotation or split-screen resize during
// engine start-up re-lays out this pane, and the latch above already refused the second
// handoff, so a height captured earlier would be the only one this pane ever reports.
onEngineMeasured(engine, frameHeightRef.current)
})
}, [onEngineMeasured])
const handleLayout = useCallback(
(event: LayoutChangeEvent) => {
const { height, width } = event.nativeEvent.layout
if (width <= 0 || height <= 0) {
return
}
frameHeightRef.current = height
measureWhenSized()
},
[measureWhenSized]
)
const handleWebReady = useCallback(() => {
webReadyRef.current = true
measureWhenSized()
}, [measureWhenSized])
return (
<View
// Why: sized like the real pane so the measurement matches, but invisible and inert so it
// cannot paint over the loading state or steal a touch from the retry affordance above it.
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
pointerEvents="none"
style={[styles.prewarmPane, { top: reservedTabBarHeight }]}
onLayout={handleLayout}
>
<TerminalWebView
ref={engineRef}
style={styles.prewarmWebView}
textScale={textScale}
onWebReady={handleWebReady}
/>
</View>
)
}
const styles = StyleSheet.create({
prewarmPane: {
...StyleSheet.absoluteFillObject,
opacity: 0
},
prewarmWebView: {
flex: 1
}
})
@@ -2,20 +2,6 @@ import { StyleSheet } from 'react-native'
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
// Why one constant for the whole strip: the terminal frame is whatever the tab bar leaves behind,
// and the engine pre-warm has to reserve exactly that much before the bar exists. Every row child
// is pinned to this height so nothing can grow the bar without moving the reservation with it.
//
// The row deliberately has NO explicit height. React Native lays out border-box, so `height: 36`
// with a 1 px top border would render a 36 px row over a 35 px content area and squeeze children
// that are themselves 36 -- and it would leave this constant one pixel long, which is a whole row
// of drift once a frame sits near a row boundary. Left to size itself the row takes its tallest
// child and adds the border outside it, which is exactly the sum below.
export const MOBILE_SESSION_TAB_BAR_CONTENT_HEIGHT = 36
export const MOBILE_SESSION_TAB_BAR_BORDER_WIDTH = 1
export const MOBILE_SESSION_TAB_BAR_HEIGHT =
MOBILE_SESSION_TAB_BAR_CONTENT_HEIGHT + MOBILE_SESSION_TAB_BAR_BORDER_WIDTH
export const mobileSessionFrameStyles = StyleSheet.create({
container: {
flex: 1,
@@ -94,12 +80,12 @@ export const mobileSessionFrameStyles = StyleSheet.create({
tabBar: {
flexDirection: 'row',
alignItems: 'center',
borderTopWidth: MOBILE_SESSION_TAB_BAR_BORDER_WIDTH,
borderTopWidth: 1,
borderTopColor: colors.borderSubtle
},
tabScroll: {
flex: 1,
maxHeight: MOBILE_SESSION_TAB_BAR_CONTENT_HEIGHT
maxHeight: 36
},
tabContent: {
paddingLeft: spacing.sm,
@@ -108,7 +94,7 @@ export const mobileSessionFrameStyles = StyleSheet.create({
tab: {
width: 128,
maxWidth: 128,
minHeight: MOBILE_SESSION_TAB_BAR_CONTENT_HEIGHT,
minHeight: 36,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: spacing.sm,
@@ -116,11 +102,6 @@ export const mobileSessionFrameStyles = StyleSheet.create({
borderBottomWidth: 2,
borderBottomColor: 'transparent'
},
// Why: a cached row is inert until the reconnect lands, so it carries the same de-emphasis as
// the disabled tab-bar buttons beside it rather than passing for a live tab.
tabPreview: {
opacity: 0.45
},
tabActive: {
// Neutral grey underline, matching the desktop terminal tab's active
// indicator (a muted foreground/card mix), not a blue accent.
@@ -142,7 +123,7 @@ export const mobileSessionFrameStyles = StyleSheet.create({
},
newTerminalButton: {
width: 40,
height: MOBILE_SESSION_TAB_BAR_CONTENT_HEIGHT,
height: 36,
alignItems: 'center',
justifyContent: 'center',
borderBottomWidth: 2,
@@ -1,155 +0,0 @@
import { describe, expect, it } from 'vitest'
import { selectMobileSessionReconnectViewState } from './mobile-session-reconnect-view-state'
import {
getMobileSessionTabStripRows,
toMobileSessionTabStripPreview,
type MobileSessionTabStripPreview
} from './mobile-session-tab-strip-entries'
import type { MobileSessionTab } from './mobile-session-route-types'
function terminalTab(id: string, title: string, isActive = false): MobileSessionTab {
return { type: 'terminal', id, title, terminal: `h-${id}`, isActive }
}
const cachedPreview: MobileSessionTabStripPreview = {
tabs: [
{ id: 'tab-1', type: 'terminal', title: 'claude', agentId: 'claude' },
{ id: 'tab-2', type: 'terminal', title: 'shell', agentId: null }
],
activeTabId: 'tab-1'
}
const base = {
connState: 'reconnecting',
verdictKind: 'normal',
terminalsLoaded: false,
liveTabCount: 0,
activeHandle: null,
cachedPreview: null
} as const
describe('selectMobileSessionReconnectViewState', () => {
it('renders the cached strip with a progress label while reconnecting', () => {
const state = selectMobileSessionReconnectViewState({ ...base, cachedPreview })
expect(state).toEqual({
kind: 'reconnecting-with-cache',
preview: cachedPreview,
label: 'Reconnecting…'
})
})
it('labels the post-connect hydration gap as loading, not reconnecting', () => {
const state = selectMobileSessionReconnectViewState({
...base,
connState: 'connected',
cachedPreview
})
expect(state.kind === 'reconnecting-with-cache' && state.label).toBe('Loading tabs…')
})
it('blocks when nothing is cached for this workspace', () => {
expect(selectMobileSessionReconnectViewState(base)).toEqual({ kind: 'blocking' })
expect(
selectMobileSessionReconnectViewState({
...base,
cachedPreview: { tabs: [], activeTabId: null }
})
).toEqual({ kind: 'blocking' })
})
it('keeps mounted live content instead of swapping in its own cached snapshot', () => {
expect(
selectMobileSessionReconnectViewState({ ...base, liveTabCount: 2, cachedPreview })
).toEqual({ kind: 'live' })
expect(
selectMobileSessionReconnectViewState({ ...base, activeHandle: 'h-1', cachedPreview })
).toEqual({ kind: 'live' })
})
it('treats a host-confirmed empty workspace as live', () => {
expect(
selectMobileSessionReconnectViewState({
...base,
connState: 'connected',
terminalsLoaded: true,
cachedPreview
})
).toEqual({ kind: 'live' })
})
it('falls back to the offline state once the retry loop or the pairing has failed', () => {
expect(
selectMobileSessionReconnectViewState({ ...base, verdictKind: 'unreachable', cachedPreview })
).toEqual({ kind: 'offline' })
expect(
selectMobileSessionReconnectViewState({ ...base, verdictKind: 'auth-failed', cachedPreview })
).toEqual({ kind: 'offline' })
})
it('keeps showing the cache through a transient warning verdict', () => {
expect(
selectMobileSessionReconnectViewState({ ...base, verdictKind: 'warning', cachedPreview }).kind
).toBe('reconnecting-with-cache')
})
})
describe('getMobileSessionTabStripRows', () => {
it('draws disabled preview rows while reconnecting, then the live tabs under the same keys', () => {
const preview = selectMobileSessionReconnectViewState({ ...base, cachedPreview })
const previewRows = getMobileSessionTabStripRows({
liveTabs: [],
activeSessionTabId: null,
preview: preview.kind === 'reconnecting-with-cache' ? preview.preview : null
})
expect(previewRows.map((row) => row.entry.id)).toEqual(['tab-1', 'tab-2'])
expect(previewRows.map((row) => row.tab)).toEqual([null, null])
expect(previewRows.map((row) => row.isActive)).toEqual([true, false])
const liveTabs = [terminalTab('tab-1', 'claude', true), terminalTab('tab-2', 'shell')]
const liveRows = getMobileSessionTabStripRows({
liveTabs,
activeSessionTabId: 'tab-1',
preview: null
})
expect(liveRows.map((row) => row.entry.id)).toEqual(previewRows.map((row) => row.entry.id))
expect(liveRows.map((row) => row.isActive)).toEqual(previewRows.map((row) => row.isActive))
expect(liveRows.every((row) => row.tab !== null)).toBe(true)
})
it('prefers live tabs over a preview that is still present', () => {
const rows = getMobileSessionTabStripRows({
liveTabs: [terminalTab('tab-9', 'fresh', true)],
activeSessionTabId: 'tab-9',
preview: cachedPreview
})
expect(rows.map((row) => row.entry.id)).toEqual(['tab-9'])
})
it('keeps only the drawn fields when projecting a preview to persist', () => {
const preview = toMobileSessionTabStripPreview(
[
{
type: 'terminal',
id: 'tab-1',
title: 'claude',
terminal: 'h-1',
launchAgent: 'claude',
launchDraft: 'unsent secret prompt',
isActive: true
}
],
'tab-1'
)
expect(preview).toEqual({
tabs: [{ id: 'tab-1', type: 'terminal', title: 'claude', agentId: 'claude' }],
activeTabId: 'tab-1'
})
expect(JSON.stringify(preview)).not.toContain('unsent secret prompt')
})
})
@@ -1,61 +0,0 @@
import type { ConnectionVerdict } from '../transport/connection-health'
import type { ConnectionState } from '../transport/types'
import type { MobileSessionTabStripPreview } from './mobile-session-tab-strip-entries'
/**
* What the session screen should draw while the phone is not yet serving live tabs.
*
* - `live`: real tabs are mounted (or the host has confirmed there are none). The existing
* loading/empty/content branches own the screen.
* - `reconnecting-with-cache`: nothing live yet, but this workspace's last strip is on the
* device. Draw it, disabled, with a compact progress line instead of a bare spinner.
* - `offline`: the retry loop has given up or the pairing is rejected. A stale strip would
* imply a session we cannot reach, so fall back to the existing offline affordance.
* - `blocking`: nothing live and nothing cached. Unchanged from before this state existed.
*/
export type MobileSessionReconnectViewState =
| { kind: 'live' }
| { kind: 'reconnecting-with-cache'; preview: MobileSessionTabStripPreview; label: string }
| { kind: 'offline' }
| { kind: 'blocking' }
export function selectMobileSessionReconnectViewState(args: {
connState: ConnectionState
verdictKind: ConnectionVerdict['kind']
terminalsLoaded: boolean
liveTabCount: number
activeHandle: string | null
cachedPreview: MobileSessionTabStripPreview | null
}): MobileSessionReconnectViewState {
const { connState, verdictKind, terminalsLoaded, liveTabCount, activeHandle, cachedPreview } =
args
// A mounted terminal or tab is the real thing; a mid-session drop must never trade it for a
// snapshot of itself, however the connection is faring.
if (liveTabCount > 0 || activeHandle !== null) {
return { kind: 'live' }
}
// The host has answered and said this workspace is empty — that is live truth, not a gap.
if (connState === 'connected' && terminalsLoaded) {
return { kind: 'live' }
}
if (verdictKind === 'unreachable' || verdictKind === 'auth-failed') {
return { kind: 'offline' }
}
if (cachedPreview && cachedPreview.tabs.length > 0) {
return {
kind: 'reconnecting-with-cache',
preview: cachedPreview,
label: reconnectProgressLabel(connState)
}
}
return { kind: 'blocking' }
}
function reconnectProgressLabel(connState: ConnectionState): string {
if (connState === 'connected') {
return 'Loading tabs…'
}
return connState === 'reconnecting' || connState === 'disconnected'
? 'Reconnecting…'
: 'Connecting…'
}
@@ -37,7 +37,6 @@ const LOGIC_EXPANSION_NAMES = new Set([
'useMobileSessionContentCreateActions',
'useMobileSessionCloseActions',
'useMobileSessionBulkClose',
'useMobileSessionTabStripCache',
'useMobileSessionPresentation',
'useMobileSessionPanelRouteActions'
])
@@ -63,32 +62,32 @@ const HOST_COMPONENT_NAMES = new Set([
'View'
])
const HEAD_MAIN_HOOK_SHA256 = 'e22e7d3a1147ef19c747f0e216b778e794a73e027e96cf84fac2dde03b37b640'
const HEAD_HOOK_BINDING_SHA256 = '531fe06cf2c261b1346bbc949c9ceba5aea8b8ace2dcb8a1898e9759745e013c'
const HEAD_MAIN_HOOK_SHA256 = '10071240ef9edafc2b9c8bed73be83dceaf7828e3b29f17dab55da020a7697a6'
const HEAD_HOOK_BINDING_SHA256 = '1dadb8c3dc0573ea20659ce7251629669e618dd0effaeac3a4536b29c2e865a1'
const HEAD_CALLBACK_IDENTITY_SHA256 =
'e5df1043256bcb0b3813bf89161d91f5e65c00749fbb6d98176bca82e878d061'
const HEAD_CALLBACK_BODY_SHA256 = '6d9ed614ed139aef5cc911c33ea4220cc1fc5f888a1a564ef85e6910cc118bc3'
const HEAD_EFFECT_SHA256 = 'a6d4d5cb573926f40faa7701cef7885a0f2c7e7c5cfaa91f4e480c29aba44d79'
'2a9e4825df007f6ef53b81aa5004991d6318eee7507b44d625c07e630be432eb'
const HEAD_CALLBACK_BODY_SHA256 = '22103ba85a86e3a3fcb80a7509c7a455d79863010cde3af02db6565b55e3ebe9'
const HEAD_EFFECT_SHA256 = 'd9ebfaabc1e79773cdada7ab370b20459ed972f1f8edce1652199f4d0391cd13'
const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fef5cc45fde6f8189581'
const HEAD_NESTED_FUNCTION_SHA256 =
'0e553eb5ec7aeda8f8336b8da85ff87eb3657a21fa32d3c75c9cc32e36860244'
'536c72b233c813bb0cea164b090bdce5406ceb965bbc5b83c1f89b89b46f3821'
const HEAD_NATIVE_REGISTRATION_SHA256 =
'cab85e4e4a3f43289ba93ddea9ccce57aea83e0bf14fd1620a965aad0c1cb49e'
const HEAD_NATIVE_REMOVAL_SHA256 =
'4c994574675a2a0f9c607b3ea89ab7a2ed5a83f7c72fa42342ddcb5f00fc3f4f'
const HEAD_TIMER_CREATION_SHA256 =
'36c3ccef371698e25cd2eb239df7a8dea6dcc674d9da43cc38cabfa3a8f64929'
const HEAD_TIMER_CLEANUP_SHA256 = '2f41ddc30d0e9c1b6d1d6b5e09d96d1b3facd3133acae1ff7436bb40e4ef39dc'
'1a31b625e2174c3db77272249843196d2b6b06ab1e654a96d8f7858e3082e66b'
const HEAD_TIMER_CLEANUP_SHA256 = 'c73f1d1c2cc89642f3d727d6f3b6b81860a9d6f34234541a2065ec3d1a8cd116'
const HEAD_RUNTIME_STRING_SHA256 =
'694a22ed924ebc2a7d380089ff2cfd3e27f5d72d3c4d4b7b06aa3006db93c053'
const HEAD_HOST_JSX_SHA256 = '0aca9fe4b6738228020fe20334fe2716471a2fdf57e4feea6a2a92cac1c04c58'
const HEAD_LEAF_JSX_SHA256 = '2c38e19ffbcaae14f9df2fdb44751546d2b936f9a4b2c5e90727a5f74f3c2665'
'31951b0b83be01ebfa659c4b94df9ad7eaff6404df5338fbade89eb7473a3cb4'
const HEAD_HOST_JSX_SHA256 = '390405926b1695fa3a33686f0bc192b432f5468d8576499d7cafbb4922defbb5'
const HEAD_LEAF_JSX_SHA256 = '21dba981875e173f692590bf910d60964660c5f4cbb79f3a377c7e54f6a1f016'
const HEAD_STYLE_REFERENCE_SHA256 =
'da81d6065c5c1ebafbbd721321023cddd0bfc1afa0325749f736bb97898f9556'
'295a3501c2c6d7bea7c8bbf38b3f3534f01344cd7e1b91bb8e07c040821d596a'
const HEAD_IDENTITY_FIELD_SHA256 =
'a7444b7d0953edb34abc77180ba11d458b02081547b8499249571efd30ac0609'
'91146853930a34dd1f3d80e5c97fbacd7cf19fb93dd26fe8fc6f29169622f9d6'
const HEAD_NAVIGATION_SHA256 = '9d96f5dad7de555d6553eac39c0fab00efad507470fd562cb9beaa32db16f512'
const HEAD_CAPABILITY_SHA256 = '54c74cdb468d015c31517004e005187f6cff2ddb07e25fdb4a7060a2fac6b786'
const HEAD_CAPABILITY_SHA256 = 'ca219f7909a091717110b823d5b94a20770ad3ae51894e0fa765e8628309392d'
type Definition = { declaration: ts.FunctionDeclaration; sourceFile: ts.SourceFile }
type HookFacts = {
@@ -457,10 +456,8 @@ function readCompatibilityFacts(definitions: ReadonlyMap<string, Definition>): {
: ''
const callText = canonical(node, sourceFile)
if (
// hostCapabilities.* is included: the session route now reads the gate's shared status.get
// answer instead of running its own probe, and those reads still have to stay ratcheted.
['useHostProtocolGates', 'supportsMobileQuickCommands'].includes(callName) ||
(callName === 'includes' && /[cC]apabilities\.includes/.test(callText))
['startRuntimeCapabilityProbe', 'supportsMobileQuickCommands'].includes(callName) ||
(callName === 'includes' && callText.includes('capabilities.includes'))
) {
capabilities.push(callText)
}
@@ -475,18 +472,18 @@ describe('mobile session route extraction parity', () => {
const contentBindings = CONTENT_COMPONENT_NAMES.flatMap(
(name) => readHookFacts(name, definitions).bindings
)
expect(main.hooks).toHaveLength(272)
expect(main.hooks).toHaveLength(266)
expect(hash(main.hooks)).toBe(HEAD_MAIN_HOOK_SHA256)
expect(hash(main.bindings)).toBe(HEAD_HOOK_BINDING_SHA256)
expect(main.callbacks).toHaveLength(78)
expect(main.callbacks).toHaveLength(77)
expect(hash(main.callbacks)).toBe(HEAD_CALLBACK_IDENTITY_SHA256)
expect(hash(main.callbackBodies)).toBe(HEAD_CALLBACK_BODY_SHA256)
expect(main.effects).toHaveLength(27)
expect(main.effects).toHaveLength(24)
expect(hash(main.effects)).toBe(HEAD_EFFECT_SHA256)
expect(contentBindings).toHaveLength(14)
expect(hash(contentBindings)).toBe(HEAD_CONTENT_HOOK_SHA256)
const nestedFunctions = readNestedFunctions(definitions)
expect(nestedFunctions).toHaveLength(13)
expect(nestedFunctions).toHaveLength(12)
expect(hash(nestedFunctions)).toBe(HEAD_NESTED_FUNCTION_SHA256)
})
@@ -497,23 +494,20 @@ describe('mobile session route extraction parity', () => {
expect(hash(native.registrations)).toBe(HEAD_NATIVE_REGISTRATION_SHA256)
expect(native.removals).toHaveLength(9)
expect(hash(native.removals)).toBe(HEAD_NATIVE_REMOVAL_SHA256)
expect(native.creations.filter((fact) => fact.startsWith('setTimeout'))).toHaveLength(8)
expect(native.creations.filter((fact) => fact.startsWith('setTimeout'))).toHaveLength(7)
expect(native.creations.filter((fact) => fact.startsWith('setInterval'))).toHaveLength(1)
expect(
native.creations.filter((fact) => fact.startsWith('requestAnimationFrame'))
).toHaveLength(1)
expect(hash(native.creations)).toBe(HEAD_TIMER_CREATION_SHA256)
expect(native.cleanups.filter((fact) => fact.startsWith('clearTimeout'))).toHaveLength(12)
expect(native.cleanups.filter((fact) => fact.startsWith('clearTimeout'))).toHaveLength(11)
expect(native.cleanups.filter((fact) => fact.startsWith('clearInterval'))).toHaveLength(1)
expect(native.cleanups.filter((fact) => fact.startsWith('cancelAnimationFrame'))).toHaveLength(
1
)
expect(hash(native.cleanups)).toBe(HEAD_TIMER_CLEANUP_SHA256)
const compatibility = readCompatibilityFacts(definitions)
// 13, not 14: both worktree.activate call sites now share one payload builder, so the
// literal `notifyClients: false` they used to repeat appears once. The guarantee itself is
// pinned in mobile-session-startup-source.test.ts, which requires exactly one call site.
expect(compatibility.identityFields).toHaveLength(13)
expect(compatibility.identityFields).toHaveLength(14)
expect(hash(compatibility.identityFields)).toBe(HEAD_IDENTITY_FIELD_SHA256)
expect(compatibility.navigation).toHaveLength(6)
expect(hash(compatibility.navigation)).toBe(HEAD_NAVIGATION_SHA256)
@@ -523,14 +517,14 @@ describe('mobile session route extraction parity', () => {
it('preserves runtime strings, styles, and the expanded JSX tree', () => {
const strings = readRuntimeStrings()
expect(strings).toHaveLength(545)
expect(strings).toHaveLength(546)
expect(hash(strings)).toBe(HEAD_RUNTIME_STRING_SHA256)
const jsx = readJsxFacts(readDefinitions())
expect(jsx.host).toHaveLength(127)
expect(jsx.host).toHaveLength(124)
expect(hash(jsx.host)).toBe(HEAD_HOST_JSX_SHA256)
expect(jsx.leaf).toHaveLength(62)
expect(jsx.leaf).toHaveLength(61)
expect(hash(jsx.leaf)).toBe(HEAD_LEAF_JSX_SHA256)
expect(jsx.styleReferences).toHaveLength(176)
expect(jsx.styleReferences).toHaveLength(172)
expect(hash(jsx.styleReferences)).toBe(HEAD_STYLE_REFERENCE_SHA256)
})
})
@@ -33,7 +33,6 @@ export const MOBILE_SESSION_ROUTE_SOURCE_FILES = [
'./use-mobile-session-content-create-actions.ts',
'./use-mobile-session-close-actions.ts',
'./use-mobile-session-bulk-close.ts',
'./use-mobile-session-tab-strip-cache.ts',
'./use-mobile-session-presentation.ts',
'./use-mobile-session-panel-route-actions.tsx',
'./MobileSessionMarkdownReader.tsx',
@@ -1,279 +0,0 @@
import { createElement, type ReactElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useMobileSessionStartup } from './use-mobile-session-startup'
import type { MobileSessionKeyboardStateModel } from './use-mobile-session-keyboard-state'
type Deferred<T> = { promise: Promise<T>; resolve: (value: T) => void; reject: (e: Error) => void }
function defer<T>(): Deferred<T> {
let resolve!: (value: T) => void
let reject!: (error: Error) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
type StartupCall = { rpc: 'session.tabs.list' | 'terminal.list'; worktreeId: string }
// One session's worth of scope: only the fields useMobileSessionStartup actually reads, plus
// the two reads under test wired to deferreds so a test controls exactly when they settle.
function makeScope(worktreeId: string, calls: StartupCall[], protocolVerified = true) {
const tabs = defer<void>()
const terminals = defer<boolean>()
const sendRequest = vi.fn().mockResolvedValue({ ok: true, result: {} })
const scope = {
hostId: 'host-1',
worktreeId,
created: '0',
isFloatingWorkspaceRoute: false,
connState: 'connected',
client: { sendRequest },
protocolVerified,
setTerminals: vi.fn(),
terminalsRef: { current: [] },
setSessionTabs: vi.fn(),
appliedSnapshotMarkerRef: { current: { epoch: null, version: -1 } },
closedTabTombstonesRef: { current: new Map() },
setTerminalsLoaded: vi.fn(),
setActiveHandle: vi.fn(),
setActiveSessionTabId: vi.fn(),
setMarkdownDocs: vi.fn(),
setFileDocs: vi.fn(),
terminalGestureInputQueuesRef: { current: new Map() },
terminalGestureInputInFlightRef: { current: new Set() },
sessionTabActionSheetKeyboardHideSubRef: { current: null },
sessionTabActionSheetRequestSeqRef: { current: 0 },
initializedHandlesRef: { current: new Set<string>() },
terminalDiagnosticsRef: { current: { resetRoute: vi.fn() } },
activeHandleRef: { current: null },
activeSessionTabTypeRef: { current: null },
pendingActiveSessionTabIdRef: { current: null },
selectedSessionTabIdRef: { current: null },
pendingActiveTerminalHandleRef: { current: null },
pendingBrowserFocusPageIdRef: { current: null },
pendingTerminalActivationAttemptRef: { current: null },
initialSessionAutoCreateRef: { current: null },
bufferedTerminalDraftState: { resetDrafts: vi.fn(), clearPendingRestorations: vi.fn() },
clearPendingLiveInputCommit: vi.fn(),
clearDelayedActionTimers: vi.fn(),
showToast: vi.fn(),
clearTerminalCache: vi.fn(),
fetchTerminals: vi.fn(() => {
calls.push({ rpc: 'terminal.list', worktreeId })
return terminals.promise
}),
ensureSessionTabs: vi.fn(() => {
calls.push({ rpc: 'session.tabs.list', worktreeId })
return tabs.promise
})
}
return {
scope: scope as unknown as MobileSessionKeyboardStateModel,
tabs,
terminals,
sendRequest,
activateCalls: () =>
sendRequest.mock.calls.filter(([method]) => method === 'worktree.activate').length
}
}
function StartupHarness({
scope
}: {
scope: MobileSessionKeyboardStateModel
}): ReactElement | null {
useMobileSessionStartup(scope)
return null
}
async function flush(): Promise<void> {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
})
}
describe('mobile session startup parallelism', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.useRealTimers()
})
it('puts session.tabs.list and terminal.list on the wire together', async () => {
const calls: StartupCall[] = []
const { scope } = makeScope('wt-1', calls)
await act(async () => {
renderer = create(createElement(StartupHarness, { scope }))
await Promise.resolve()
})
await flush()
// Neither deferred has settled, so both requests are in flight at the same moment. Under the
// old chain the second call could not have been made until the first resolved.
expect(calls).toEqual([
{ rpc: 'session.tabs.list', worktreeId: 'wt-1' },
{ rpc: 'terminal.list', worktreeId: 'wt-1' }
])
})
it('isolates each read so one rejection cannot strand the follow-up refreshes', async () => {
const calls: StartupCall[] = []
const { scope, tabs, terminals } = makeScope('wt-1', calls)
await act(async () => {
renderer = create(createElement(StartupHarness, { scope }))
await Promise.resolve()
})
await flush()
await act(async () => {
tabs.reject(new Error('tabs rejected'))
terminals.reject(new Error('terminals rejected'))
await Promise.resolve()
})
await flush()
await act(async () => {
vi.advanceTimersByTime(1600)
await Promise.resolve()
})
// The 750 ms and 1500 ms follow-up refreshes still armed despite both rejections; an
// unguarded await would have thrown out of the startup block and armed neither.
expect(calls.filter((call) => call.rpc === 'terminal.list')).toHaveLength(3)
})
it('drops results that land after the route moved to another session', async () => {
const calls: StartupCall[] = []
const first = makeScope('wt-1', calls)
const second = makeScope('wt-2', calls)
await act(async () => {
renderer = create(createElement(StartupHarness, { scope: first.scope }))
await Promise.resolve()
})
await flush()
await act(async () => {
renderer?.update(createElement(StartupHarness, { scope: second.scope }))
await Promise.resolve()
})
await flush()
// The first session's reads land only now, after its effect was torn down.
await act(async () => {
first.tabs.resolve(undefined)
first.terminals.resolve(true)
await Promise.resolve()
})
await flush()
await act(async () => {
vi.advanceTimersByTime(1600)
await Promise.resolve()
})
// Why: a stale settlement must not schedule refreshes for a worktree the route has left.
expect(calls.filter((call) => call.worktreeId === 'wt-1')).toHaveLength(2)
})
it('withholds worktree.activate until the compatibility verdict lands', async () => {
const calls: StartupCall[] = []
const pending = makeScope('wt-1', calls, false)
await act(async () => {
renderer = create(createElement(StartupHarness, { scope: pending.scope }))
await Promise.resolve()
})
await flush()
// Why: a desktop that omits protocolVersion evaluates as version 0 and IS blocked, so the
// routes that now mount pre-verdict must not mutate a host the gate is about to refuse.
expect(pending.activateCalls()).toBe(0)
// The reads are not held back with it; that is the whole point of mounting early.
expect(calls).toHaveLength(2)
})
it('activates once the verdict lands without re-issuing the reads', async () => {
const calls: StartupCall[] = []
const pending = makeScope('wt-1', calls, false)
await act(async () => {
renderer = create(createElement(StartupHarness, { scope: pending.scope }))
await Promise.resolve()
})
await flush()
expect(pending.activateCalls()).toBe(0)
// Same session, verdict now proven: only the activation effect may re-run.
const verified = {
...(pending.scope as unknown as Record<string, unknown>),
protocolVerified: true
} as unknown as MobileSessionKeyboardStateModel
await act(async () => {
renderer?.update(createElement(StartupHarness, { scope: verified }))
await Promise.resolve()
})
await flush()
expect(pending.activateCalls()).toBe(1)
expect(pending.sendRequest).toHaveBeenCalledWith('worktree.activate', {
worktree: 'id:wt-1',
notifyClients: false,
navigation: 'caller'
})
expect(calls).toHaveLength(2)
})
it('discards both parallel results when the session changes mid-flight', async () => {
const calls: StartupCall[] = []
const first = makeScope('wt-1', calls)
const second = makeScope('wt-2', calls)
await act(async () => {
renderer = create(createElement(StartupHarness, { scope: first.scope }))
await Promise.resolve()
})
await flush()
expect(calls.filter((call) => call.worktreeId === 'wt-1')).toHaveLength(2)
await act(async () => {
renderer?.update(createElement(StartupHarness, { scope: second.scope }))
await Promise.resolve()
})
await flush()
// Tabs land late first, then terminals, so each is separately proven inert.
await act(async () => {
first.tabs.resolve(undefined)
await Promise.resolve()
})
await flush()
await act(async () => {
vi.advanceTimersByTime(1600)
await Promise.resolve()
})
expect(calls.filter((call) => call.worktreeId === 'wt-1')).toHaveLength(2)
await act(async () => {
first.terminals.resolve(true)
await Promise.resolve()
})
await flush()
await act(async () => {
vi.advanceTimersByTime(1600)
await Promise.resolve()
})
expect(calls.filter((call) => call.worktreeId === 'wt-1')).toHaveLength(2)
})
})
@@ -30,10 +30,6 @@ const autoCreateHookSource = readMobileSessionRouteSource(
'./use-initial-session-terminal-autocreate.ts'
)
const foundationSource = readMobileSessionRouteSource('./use-mobile-session-foundation.ts')
const activeContentSource = readMobileSessionRouteSource('./MobileSessionActiveContent.tsx')
const subscriptionFoundationSource = readMobileSessionRouteSource(
'./use-mobile-session-terminal-subscription-foundation.ts'
)
const terminalRuntimeSource = readMobileSessionRouteSource(
'./use-mobile-session-terminal-runtime.ts'
)
@@ -151,72 +147,35 @@ describe('mobile session startup', () => {
)
})
// Was: one effect that awaited tabs, then terminals, and fired worktree.activate alongside them.
// The reads are now concurrent and unblocked, while the activation moved to its own effect that
// waits for the compatibility verdict, because it writes host state.
it('loads session tabs and terminals concurrently, ahead of any desktop activation', () => {
const readEffect = sliceBetween(
it('loads session tabs without waiting for desktop activation', () => {
const startupEffect = sliceBetween(
'void (async () => {',
'return () => {\n disposed = true',
startupSource
)
expect(readEffect).toContain(
'await Promise.all([\n ensureSessionTabs().catch(() => null),\n fetchTerminals({ allowEmptyLoaded: false }).catch(() => false)\n ])'
expect(startupEffect).toContain("void client\n .sendRequest('worktree.activate'")
expect(startupEffect).toContain("if (client && created !== '1' && !isFloatingWorkspaceRoute)")
expect(startupEffect).toContain("if (client && created === '1' && !isFloatingWorkspaceRoute)")
expect(startupEffect).toContain('notifyClients: false')
expect(startupEffect).toContain("navigation: 'caller'")
expect(startupEffect).not.toContain("await client\n .sendRequest('worktree.activate'")
expect(startupEffect.indexOf("sendRequest('worktree.activate'")).toBeLessThan(
startupEffect.indexOf('await ensureSessionTabs()')
)
// The reads must not wait on the verdict; that is the point of mounting under the gate.
expect(readEffect).not.toContain('protocolVerified')
expect(readEffect).not.toContain('worktree.activate')
expect(startupSource).toContain('}, [connState, fetchTerminals, ensureSessionTabs])')
expect(startupEffect).toContain('headlessActivationNeedsHostRenderer(response.result)')
expect(startupEffect).toContain("showToast('Open Orca on the host to wake sleeping agents.'")
})
it('holds worktree.activate until the compatibility verdict lands', () => {
const activationEffect = sliceBetween(
"if (connState !== 'connected' || !client || !protocolVerified || isFloatingWorkspaceRoute) {",
'return () => {\n disposed = true',
startupSource.slice(startupSource.indexOf('worktree.activate') - 2000)
)
// Why: a desktop that omits protocolVersion reads as version 0 and IS blocked, so mounting
// this route pre-verdict must not let it mutate a host the gate is about to refuse.
expect(activationEffect).toContain("sendRequest('worktree.activate'")
expect(activationEffect).toContain('notifyClients: false')
expect(activationEffect).toContain("navigation: 'caller'")
expect(activationEffect).toContain("if (created !== '1') {")
expect(activationEffect).toContain('headlessActivationNeedsHostRenderer(response.result)')
expect(activationEffect).toContain("showToast('Open Orca on the host to wake sleeping agents.'")
// The only worktree.activate calls in the route are the two this gated effect owns.
expect(startupSource.split("sendRequest('worktree.activate'")).toHaveLength(2)
expect(startupSource).toContain(' protocolVerified,\n showToast,\n worktreeId\n ])')
})
// Was: this route ran its own retrying status.get. The gate above every /h/ route already
// holds that answer, so the second request is gone and the gates read it instead.
it('fails runtime capability gates closed until the shared status.get is proven', () => {
it('fails runtime capability gates closed before probing a replacement client', () => {
const capabilityEffect = sliceBetween(
'const hostQueryReplyInputSupportedRef = useRef(false)',
'return {\n consumeAcceptedSessionTabs',
tabReconciliationSource
)
const probeStart = capabilityEffect.indexOf('startRuntimeCapabilityProbe(client,')
expect(tabReconciliationSource).not.toContain('startRuntimeCapabilityProbe')
expect(tabReconciliationSource).not.toContain('useHostProtocolGates')
// One read of the gate for the whole route, taken in the foundation and passed down.
expect(foundationSource).toContain(
'const { compatVerdict, compatVerified, hostCapabilities, statusPending } = useHostProtocolGates()'
)
// Settled is not passing, and passing-by-fallback is not answered. The write gate reads all
// three, so a host that never answered status.get cannot be mistaken for a verified one.
expect(foundationSource).toContain(
"const protocolVerified = !statusPending && compatVerified && compatVerdict.kind === 'ok'"
)
expect(capabilityEffect).toContain(
"if (!client || connState !== 'connected' || !protocolVerified) {"
)
const readStart = capabilityEffect.indexOf(
"setBrowserScreencastSupported(hostCapabilities.includes('browser.screencast.v1'))"
)
expect(readStart).toBeGreaterThanOrEqual(0)
expect(probeStart).toBeGreaterThanOrEqual(0)
for (const reset of [
'setBrowserScreencastSupported(null)',
'setAgentSessionHistorySupported(null)',
@@ -226,7 +185,7 @@ describe('mobile session startup', () => {
]) {
const resetIndex = capabilityEffect.lastIndexOf(reset)
expect(resetIndex).toBeGreaterThanOrEqual(0)
expect(resetIndex).toBeLessThan(readStart)
expect(resetIndex).toBeLessThan(probeStart)
}
})
@@ -331,43 +290,4 @@ describe('mobile session startup', () => {
expect(source).toContain('onPendingTerminalRecoveryParked: setParkedPendingTerminalContext')
expect(source).toContain('retryPendingTerminalRecovery()')
})
it('boots the terminal engine while the startup reads are still in flight', () => {
// Why: the loading and pending-terminal states are exactly the window in which the startup
// RPCs are outstanding, so the engine loads there rather than after terminal.list answers.
const loadingBranch = sliceBetween(
"return reconnectViewState.kind === 'reconnecting-with-cache' || showLoadingState ? (",
') : showEmptyState ? (',
activeContentSource
)
const prewarmElement =
'<TerminalEnginePrewarm\n reservedTabBarHeight={prewarmReservedTabBarHeight}\n textScale={terminalTextScale}\n onEngineMeasured={measurePrewarmViewport}\n />'
expect(loadingBranch).toContain(prewarmElement)
expect(loadingBranch).toContain('<View style={styles.terminalFrame}>')
const pendingBranch = sliceBetween(
') : activePendingTerminalTab ? (',
') : (\n <View\n style={styles.terminalFrame}',
activeContentSource
)
expect(pendingBranch).toContain(prewarmElement)
// The pre-warm never reaches a terminal: the pane list is still the only attachment point.
expect(activeContentSource).toContain('{terminals.map((terminal) => (')
expect(activeContentSource.indexOf('<TerminalEnginePrewarm')).toBeLessThan(
activeContentSource.indexOf('{terminals.map((terminal) => (')
)
})
it('refuses a pre-warm viewport measured before the frame had a height', () => {
const measure = sliceBetween(
'const measurePrewarmViewport = useCallback(',
' return {\n getTerminalRef',
subscriptionFoundationSource
)
expect(measure).toContain('if (viewportMeasuredRef.current || frameHeight <= 0) {')
expect(measure).toContain('await engine.measureFitDimensions(frameHeight)')
// Why: the latch is re-checked after the await so a real pane that measured first wins.
expect(measure).toContain('if (dims && !viewportMeasuredRef.current) {')
})
})
@@ -1,116 +0,0 @@
import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/tui-agent-display-names'
import type { MobileSessionTab, MobileSessionTabType } from './mobile-session-route-types'
import {
getMobileSessionTabTitle,
resolveMobileTerminalTabAgentId
} from './mobile-terminal-tab-agent'
/**
* The only session-tab fields the tab strip draws. Everything else the live tab carries (unsent
* launch drafts, absolute file paths, browser URLs, agent session ids) stays on the wire.
*/
export type MobileSessionTabStripEntry = {
id: string
type: MobileSessionTabType
title: string
agentId: string | null
}
export type MobileSessionTabStripPreview = {
tabs: readonly MobileSessionTabStripEntry[]
activeTabId: string | null
}
export type MobileSessionTabStripRow = {
entry: MobileSessionTabStripEntry
isActive: boolean
/** null on a preview row: switching to that tab needs a live connection. */
tab: MobileSessionTab | null
}
export function toMobileSessionTabStripEntry(tab: MobileSessionTab): MobileSessionTabStripEntry {
return {
id: tab.id,
type: tab.type,
title: getMobileSessionTabTitle(tab),
agentId:
tab.type === 'agent-session'
? tab.agent
: tab.type === 'terminal'
? resolveMobileTerminalTabAgentId(tab)
: null
}
}
/**
* Every tab type the strip knows how to draw. A stored entry naming anything else is dropped
* rather than trusted, so a type added later fails closed: its rows go missing from the preview
* instead of carrying an unreviewed title into storage.
*/
const drawableTabTypes = new Set<string>([
'terminal',
'markdown',
'file',
'browser',
'agent-session'
] satisfies readonly MobileSessionTabType[])
export function isDrawableTabStripType(type: string): type is MobileSessionTabType {
return drawableTabTypes.has(type)
}
const agentDisplayNames: Readonly<Record<string, string>> = TUI_AGENT_DISPLAY_NAMES
/**
* The title a strip entry may be written to disk under.
*
* A terminal's title is whatever the shell last set, which is routinely the command line —
* `psql postgres://user:password@host/db`, `curl -H "Authorization: Bearer ..."`. None of that
* belongs in plaintext storage, and a browser tab's page title is no better. Both collapse to a
* fixed label, so what survives is the shape of the strip, not its contents. A resolved agent
* still names itself, because that lookup is a closed enum: an unrecognised id yields the
* generic label rather than passing text through.
*/
export function getPersistableTabStripTitle(
entry: Pick<MobileSessionTabStripEntry, 'type' | 'title' | 'agentId'>
): string {
if (entry.type === 'terminal') {
const agentLabel = entry.agentId === null ? undefined : agentDisplayNames[entry.agentId]
return agentLabel ?? 'Terminal'
}
if (entry.type === 'browser') {
return 'Browser'
}
return entry.title
}
export function toMobileSessionTabStripPreview(
tabs: readonly MobileSessionTab[],
activeTabId: string | null
): MobileSessionTabStripPreview {
return { tabs: tabs.map(toMobileSessionTabStripEntry), activeTabId }
}
/**
* Rows for the header strip. Live tabs always win; the preview only fills a strip that has no
* live rows yet, and its ids are the live ids, so the swap reuses the same React keys.
*/
export function getMobileSessionTabStripRows(args: {
liveTabs: readonly MobileSessionTab[]
activeSessionTabId: string | null
preview: MobileSessionTabStripPreview | null
}): MobileSessionTabStripRow[] {
const { liveTabs, activeSessionTabId, preview } = args
if (liveTabs.length > 0 || !preview) {
return liveTabs.map((tab) => ({
entry: toMobileSessionTabStripEntry(tab),
isActive: tab.id === activeSessionTabId,
tab
}))
}
return preview.tabs.map((entry) => ({
entry,
isActive: entry.id === preview.activeTabId,
tab: null
}))
}
@@ -1,181 +0,0 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { TerminalWebViewHandle } from '../terminal/terminal-webview-contract'
import { readMobileSessionRouteSource } from './mobile-session-route-source-family.test-support'
type StyleLayer = { top?: number }
// The applied top offset, read off the rendered pane rather than assumed.
function appliedTopOffset(style: unknown): number {
const layers = (Array.isArray(style) ? style : [style]) as (StyleLayer | null | undefined)[]
return layers.reduce<number>(
(top, layer) => (typeof layer?.top === 'number' ? layer.top : top),
0
)
}
const engine = vi.hoisted(() => ({
init: vi.fn((_cols: number, _rows: number) => {}),
awaitReady: vi.fn(async () => {}),
measureFitDimensions: vi.fn(async (_containerHeight?: number) => ({ cols: 100, rows: 40 })),
onWebReady: null as (() => void) | null
}))
vi.mock('react-native', () => ({
StyleSheet: {
create: <T>(styles: T) => styles,
absoluteFillObject: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }
},
View: 'View'
}))
vi.mock('../terminal/TerminalWebView', async () => {
const { forwardRef, useImperativeHandle } = await import('react')
return {
TerminalWebView: forwardRef<TerminalWebViewHandle, { onWebReady?: () => void }>(
function MockTerminalWebView(props, ref) {
engine.onWebReady = props.onWebReady ?? null
useImperativeHandle(ref, () => engine as unknown as TerminalWebViewHandle, [])
return createElement('MockTerminalWebView')
}
)
}
})
import { TerminalEnginePrewarm } from './TerminalEnginePrewarm'
import {
MOBILE_SESSION_TAB_BAR_BORDER_WIDTH,
MOBILE_SESSION_TAB_BAR_CONTENT_HEIGHT,
MOBILE_SESSION_TAB_BAR_HEIGHT,
mobileSessionFrameStyles
} from './mobile-session-frame-styles'
// The box the session content row occupies. Both states below live in it, so it is the one
// number the two frame heights are derived from.
const CONTENT_ROW_HEIGHT = 700
// The bar's rendered height, derived from the styles the header actually mounts rather than from
// the constant the pre-warm consumes — otherwise the comparison below would just restate itself.
// React Native sizes a row with no explicit height to its tallest child and puts the border
// outside that, so this is max(children) + border.
function renderedTabBarHeight(): number {
const row = mobileSessionFrameStyles.tabBar as { height?: number; borderTopWidth: number }
// An explicit height here would be border-box and would shrink the row below its children.
expect(row.height).toBeUndefined()
const tallestChild = Math.max(
mobileSessionFrameStyles.tabScroll.maxHeight,
mobileSessionFrameStyles.tab.minHeight,
mobileSessionFrameStyles.newTerminalButton.height,
mobileSessionFrameStyles.tabActionDivider.height
)
return tallestChild + row.borderTopWidth
}
// What the first real pane gets once its tab exists and the bar mounts above it.
function firstPaneFrameHeight(): number {
return CONTENT_ROW_HEIGHT - renderedTabBarHeight()
}
const headerSource = readMobileSessionRouteSource('./MobileSessionHeader.tsx')
const activeContentSource = readMobileSessionRouteSource('./MobileSessionActiveContent.tsx')
// Reproduces React Native's absolute-fill layout: a box pinned to every edge of its parent with
// a top offset gets exactly that much less height. The offset is read off the component, never
// assumed, so a pre-warm that stopped reserving the bar would report the taller box here.
function measuredPrewarmHeight(reservedTabBarHeight: number): number {
let renderer: ReactTestRenderer | null = null
act(() => {
renderer = create(
createElement(TerminalEnginePrewarm, { reservedTabBarHeight, onEngineMeasured: () => {} })
)
})
const created = renderer as unknown as ReactTestRenderer
const applied = appliedTopOffset(created.root.findAllByType('View')[0]?.props.style)
act(() => created.unmount())
return CONTENT_ROW_HEIGHT - applied
}
afterEach(() => {
engine.measureFitDimensions.mockClear()
engine.onWebReady = null
})
describe('terminal pre-warm frame geometry', () => {
it('states the height the bar actually renders at', () => {
// The constant is what the pre-warm reserves, so it has to equal what the header mounts.
// Deriving the latter from the styles catches the border-box trap: pinning an explicit
// height on the row would render it a pixel short of this sum and drift a whole row.
expect(renderedTabBarHeight()).toBe(MOBILE_SESSION_TAB_BAR_HEIGHT)
expect(MOBILE_SESSION_TAB_BAR_HEIGHT).toBe(
MOBILE_SESSION_TAB_BAR_CONTENT_HEIGHT + MOBILE_SESSION_TAB_BAR_BORDER_WIDTH
)
expect(mobileSessionFrameStyles.tabBar.borderTopWidth).toBe(MOBILE_SESSION_TAB_BAR_BORDER_WIDTH)
// Every child is pinned to the content height, so nothing can grow the row unnoticed.
expect(mobileSessionFrameStyles.tabScroll.maxHeight).toBe(MOBILE_SESSION_TAB_BAR_CONTENT_HEIGHT)
expect(mobileSessionFrameStyles.tab.minHeight).toBe(MOBILE_SESSION_TAB_BAR_CONTENT_HEIGHT)
expect(mobileSessionFrameStyles.newTerminalButton.height).toBe(
MOBILE_SESSION_TAB_BAR_CONTENT_HEIGHT
)
})
it('mounts the tab bar only once a tab is visible, which is what shortens the pane', () => {
expect(headerSource).toContain(
'{tabStripRows.length > 0 && (\n <View style={styles.tabBar}>'
)
// So the reservation has to be the exact complement of that condition, read off the same rows
// the header gates on (live tabs or the cached reconnect preview) rather than a proxy for it.
expect(activeContentSource).toContain(
'const prewarmReservedTabBarHeight = tabStripRows.length > 0 ? 0 : MOBILE_SESSION_TAB_BAR_HEIGHT'
)
})
it('measures the same frame height the first real pane will get', () => {
// Loading: no visible tab, so no tab bar, so the content row is all the pre-warm's to fill,
// minus whatever it reserves. Loaded: the first terminal produces a tab, the bar mounts, and
// the pane gets what is left. The right side is derived from the header's own styles.
expect(measuredPrewarmHeight(MOBILE_SESSION_TAB_BAR_HEIGHT)).toBe(firstPaneFrameHeight())
})
it('would latch a taller frame than the pane if the bar were not reserved', () => {
// Guards the fix rather than the code: without the reservation the pre-warm measures the
// pre-tab-bar box, and every row of that difference is a row the host never had.
const unreserved = measuredPrewarmHeight(0)
expect(unreserved).toBe(CONTENT_ROW_HEIGHT)
expect(unreserved - firstPaneFrameHeight()).toBe(renderedTabBarHeight())
})
it('hands the engine the reserved height, so no refit is owed after the first subscribe', async () => {
let measuredWith: number | null = null
let renderer: ReactTestRenderer | null = null
act(() => {
renderer = create(
createElement(TerminalEnginePrewarm, {
reservedTabBarHeight: MOBILE_SESSION_TAB_BAR_HEIGHT,
textScale: 1,
onEngineMeasured: (_ref: unknown, frameHeight: number) => {
measuredWith = frameHeight
}
})
)
})
const created = renderer as unknown as ReactTestRenderer
const pane = created.root.findAllByType('View')[0]
const applied = appliedTopOffset(pane?.props.style)
act(() => {
pane?.props.onLayout({
nativeEvent: { layout: { x: 0, y: 0, width: 390, height: CONTENT_ROW_HEIGHT - applied } }
})
})
act(() => {
engine.onWebReady?.()
})
// The handoff waits on the engine's ready promise, so let those microtasks land.
await act(async () => {})
// The height the latched viewport is computed from equals the real pane's frame height, so
// the frame-height refit re-measures the same cols/rows and returns before it would send
// terminal.updateViewport (see the prev-dims guard in terminal-viewport-refit.ts).
expect(measuredWith).toBe(firstPaneFrameHeight())
act(() => created.unmount())
})
})
@@ -1,147 +0,0 @@
import { createElement, useRef, type ReactElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import type { TerminalWebViewHandle } from '../terminal/terminal-webview-contract'
vi.mock('react-native', () => ({
AppState: { currentState: 'active', addEventListener: () => ({ remove: () => {} }) },
Platform: { OS: 'android' },
StyleSheet: {
create: <T>(styles: T) => styles,
absoluteFillObject: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 },
hairlineWidth: 1
},
useWindowDimensions: () => ({ width: 390, height: 844 }),
View: 'View'
}))
import { useTerminalViewportRefit } from '../terminal/terminal-viewport-refit'
import { MOBILE_SESSION_TAB_BAR_HEIGHT } from './mobile-session-frame-styles'
const CONTENT_ROW_HEIGHT = 700
const CELL_HEIGHT = 17
const HANDLE = 'term-1'
const REFIT_DEBOUNCE_MS = 150
// Stands in for the WebView's fit: the taller the box it is handed, the more rows it reports.
// This is what turns a frame that is one tab bar too tall into a row count the host never had.
function fitDimensions(containerHeight: number): { cols: number; rows: number } {
return { cols: 100, rows: Math.floor(containerHeight / CELL_HEIGHT) }
}
type ColdOpenResult = {
updateViewportCalls: number
resubscribes: number
latchedRows: number
}
// Replays a single-terminal cold open: the pre-warm measured `prewarmFrameHeight` and latched it,
// the first pane subscribed with those dims, and only then does the real frame report its layout.
async function runSingleTerminalColdOpen(prewarmFrameHeight: number): Promise<ColdOpenResult> {
const firstPaneFrameHeight = CONTENT_ROW_HEIGHT - MOBILE_SESSION_TAB_BAR_HEIGHT
const sendRequest = vi.fn(async () => ({ ok: true, result: { updated: true, applied: true } }))
const client = {
sendRequest,
updateTerminalSubscriptionViewport: vi.fn()
} as unknown as RpcClient
const engine = {
measureFitDimensions: vi.fn(async (containerHeight?: number) =>
fitDimensions(containerHeight ?? 0)
),
reflow: vi.fn()
} as unknown as TerminalWebViewHandle
const subscribeToTerminal = vi.fn()
const unsubscribeTerminal = vi.fn()
const viewport = { current: fitDimensions(prewarmFrameHeight) as { cols: number; rows: number } }
const viewportMeasured = { current: true }
// The real pane's frame, reported by its onLayout once the tab bar has mounted.
const frameHeight = { current: firstPaneFrameHeight }
let notify: ((height: number) => void) | null = null
function RefitHarness(): ReactElement | null {
const terminalRefs = useRef(new Map([[HANDLE, engine]]))
const { notifyTerminalFrameHeight } = useTerminalViewportRefit({
activeHandleRef: useRef<string | null>(HANDLE),
terminalRefs,
terminalFrameHeightRef: frameHeight,
viewportRef: viewport,
viewportMeasuredRef: viewportMeasured,
nativeChatCoveredRef: useRef(false),
clientRef: useRef<RpcClient | null>(client),
deviceTokenRef: useRef<string | null>('device-1'),
initializedHandlesRef: useRef(new Set([HANDLE])),
connState: 'connected',
// One terminal, so the tab-strip corrector is not armed — this is the case that used to
// fall through to the frame-height reducer and pay for the mis-measurement.
tabStripVisible: false,
textScale: 1,
terminalFrameWidth: 390,
unsubscribeTerminal,
subscribeToTerminal
})
notify = notifyTerminalFrameHeight
return null
}
let renderer: ReactTestRenderer | null = null
await act(async () => {
renderer = create(createElement(RefitHarness))
})
await act(async () => {
notify?.(firstPaneFrameHeight)
})
// Why drain microtasks between ticks and before unmount: the refit measures and sends inside an
// async block that bails once disposedRef flips, so tearing down early would fake a clean run.
await act(async () => {
vi.advanceTimersByTime(REFIT_DEBOUNCE_MS + 1)
for (let i = 0; i < 10; i += 1) {
await Promise.resolve()
}
})
await act(async () => {
vi.advanceTimersByTime(REFIT_DEBOUNCE_MS + 1)
for (let i = 0; i < 10; i += 1) {
await Promise.resolve()
}
})
act(() => (renderer as unknown as ReactTestRenderer).unmount())
return {
updateViewportCalls: sendRequest.mock.calls.filter(
([method]) => method === 'terminal.updateViewport'
).length,
resubscribes: subscribeToTerminal.mock.calls.length,
latchedRows: viewport.current.rows
}
}
describe('terminal pre-warm refit debt', () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
})
it('owes the host nothing after the first subscribe when the pre-warm reserved the tab bar', async () => {
const reserved = CONTENT_ROW_HEIGHT - MOBILE_SESSION_TAB_BAR_HEIGHT
const result = await runSingleTerminalColdOpen(reserved)
expect(result.updateViewportCalls).toBe(0)
expect(result.resubscribes).toBe(0)
expect(result.latchedRows).toBe(fitDimensions(reserved).rows)
})
it('pays a terminal.updateViewport round trip if the pre-warm measured the pre-tab-bar box', async () => {
// Guards the fix, not the code: this is the frame the pre-warm saw before it reserved the bar.
const result = await runSingleTerminalColdOpen(CONTENT_ROW_HEIGHT)
expect(result.updateViewportCalls).toBe(1)
// And the rows it had to correct are rows the host was told about and never had.
expect(fitDimensions(CONTENT_ROW_HEIGHT).rows).toBeGreaterThan(
fitDimensions(CONTENT_ROW_HEIGHT - MOBILE_SESSION_TAB_BAR_HEIGHT).rows
)
})
})
@@ -27,7 +27,6 @@ import { useMobileSessionTerminalCreateActions } from './use-mobile-session-term
import { useMobileSessionContentCreateActions } from './use-mobile-session-content-create-actions'
import { useMobileSessionCloseActions } from './use-mobile-session-close-actions'
import { useMobileSessionBulkClose } from './use-mobile-session-bulk-close'
import { useMobileSessionTabStripCache } from './use-mobile-session-tab-strip-cache'
import { useMobileSessionPresentation } from './use-mobile-session-presentation'
import { useMobileSessionPanelRouteActions } from './use-mobile-session-panel-route-actions'
@@ -114,8 +113,7 @@ export function useMobileSessionController() {
useMobileSessionCloseActions(contentCreateActions)
)
const bulkClose = Object.assign(closeActions, useMobileSessionBulkClose(closeActions))
const tabStripCache = Object.assign(bulkClose, useMobileSessionTabStripCache(bulkClose))
const presentation = Object.assign(tabStripCache, useMobileSessionPresentation(tabStripCache))
const presentation = Object.assign(bulkClose, useMobileSessionPresentation(bulkClose))
const panelRouteActions = Object.assign(
presentation,
useMobileSessionPanelRouteActions(presentation)
@@ -14,7 +14,6 @@ import { isFloatingWorkspaceWorktreeId } from './floating-workspace'
import { useLiveWorktreeName } from './use-live-worktree-name'
import { useMissingWorktreeBounce } from './use-missing-worktree-bounce'
import { hostRouteWithNotice } from '../host-route-notice'
import { useHostProtocolGates } from '../components/HostProtocolGate'
export function useMobileSessionFoundation() {
const {
@@ -37,14 +36,6 @@ export function useMobileSessionFoundation() {
const insets = useSafeAreaInsets()
// Why: shared client per host owned by RpcClientProvider (docs/mobile-shared-client-per-host.md).
const { client, clientId, state: connState } = useHostClient(hostId)
// Why: HostProtocolGate holds this connection's single status.get. Reading it here gives the
// whole route one source for host capabilities and for whether the compatibility verdict has
// landed — the routes now mount while it is still in flight, so "not yet known" is a real state.
const { compatVerdict, compatVerified, hostCapabilities, statusPending } = useHostProtocolGates()
// Why all three: a settled verdict is not necessarily a passing one, and a settled *passing*
// verdict is not necessarily an answered one — a host that cannot answer status.get fails open
// to `ok` so navigation still works. Writes read this flag, so they wait for a real reply.
const protocolVerified = !statusPending && compatVerified && compatVerdict.kind === 'ok'
const reconnectAttempts = useReconnectAttempt(hostId)
const lastConnectedAt = useLastConnectedAt(hostId)
const forceReconnectHost = useForceReconnect()
@@ -107,8 +98,6 @@ export function useMobileSessionFoundation() {
client,
clientId,
connState,
hostCapabilities,
protocolVerified,
reconnectAttempts,
lastConnectedAt,
forceReconnectHost,
@@ -3,11 +3,9 @@ import { classifyConnection, verdictDisplayLabel } from '../transport/connection
import { computeActiveTerminalKeyboardLift } from '../terminal/terminal-keyboard-avoidance-lift'
import { useInitialSessionTerminalAutoCreate } from './use-initial-session-terminal-autocreate'
import { MOBILE_SESSION_STATUS_LABELS } from './mobile-session-route-helpers'
import { selectMobileSessionReconnectViewState } from './mobile-session-reconnect-view-state'
import { getMobileSessionTabStripRows } from './mobile-session-tab-strip-entries'
import type { MobileSessionTabStripCacheModel } from './use-mobile-session-tab-strip-cache'
import type { MobileSessionBulkCloseModel } from './use-mobile-session-bulk-close'
export function useMobileSessionPresentation(scope: MobileSessionTabStripCacheModel) {
export function useMobileSessionPresentation(scope: MobileSessionBulkCloseModel) {
const {
created,
worktreeId,
@@ -26,8 +24,6 @@ export function useMobileSessionPresentation(scope: MobileSessionTabStripCacheMo
terminalKeyboardMetrics,
toastOpacityRef,
hostEndpoint,
activeSessionTabId,
cachedTabStrip,
initialSessionAutoCreateRef,
terminalFrameHeightRef,
handleCreateTerminal,
@@ -62,23 +58,6 @@ export function useMobileSessionPresentation(scope: MobileSessionTabStripCacheMo
const showConnectionRetry =
connectionVerdict.kind === 'warning' || connectionVerdict.kind === 'unreachable'
// Why: a reconnect to a workspace this phone has already drawn should re-draw it, not blank
// the screen while the RPCs land. See mobile-session-reconnect-view-state.
const reconnectViewState = selectMobileSessionReconnectViewState({
connState,
verdictKind: connectionVerdict.kind,
terminalsLoaded,
liveTabCount: visibleTabs.length,
activeHandle,
cachedPreview: cachedTabStrip
})
const tabStripRows = getMobileSessionTabStripRows({
liveTabs: visibleTabs,
activeSessionTabId,
preview:
reconnectViewState.kind === 'reconnecting-with-cache' ? reconnectViewState.preview : null
})
const terminalSummary =
connState === 'connected'
? showLoadingState
@@ -109,8 +88,6 @@ export function useMobileSessionPresentation(scope: MobileSessionTabStripCacheMo
return {
showLoadingState,
showEmptyState,
reconnectViewState,
tabStripRows,
connectionVerdict,
showConnectionRetry,
terminalSummary,
@@ -120,5 +97,5 @@ export function useMobileSessionPresentation(scope: MobileSessionTabStripCacheMo
}
}
export type MobileSessionPresentationModel = MobileSessionTabStripCacheModel &
export type MobileSessionPresentationModel = MobileSessionBulkCloseModel &
ReturnType<typeof useMobileSessionPresentation>
@@ -12,7 +12,6 @@ export function useMobileSessionStartup(scope: MobileSessionKeyboardStateModel)
isFloatingWorkspaceRoute,
connState,
client,
protocolVerified,
setTerminals,
terminalsRef,
setSessionTabs,
@@ -96,8 +95,6 @@ export function useMobileSessionStartup(scope: MobileSessionKeyboardStateModel)
worktreeId
])
// Reads only. They carry no side effect on the host, so they do not wait on the compatibility
// verdict — that is the whole point of mounting this route while status.get is still in flight.
// Every setTimeout goes through addTimer into `timers`, which the returned cleanup clears.
// react-doctor-disable-next-line react-doctor/effect-needs-cleanup
useEffect(() => {
@@ -119,81 +116,58 @@ export function useMobileSessionStartup(scope: MobileSessionKeyboardStateModel)
timers.push(setTimeout(fn, ms))
}
void (async () => {
// Why: session.tabs.list and terminal.list are independent reads, so issue both now and
// wait for the pair. Serialising them cost a full extra round trip before the first
// terminal could paint, which on a far relay cell is seconds, not milliseconds. Each
// call keeps its own catch so one rejection cannot strand the other's follow-up refreshes.
await Promise.all([
ensureSessionTabs().catch(() => null),
fetchTerminals({ allowEmptyLoaded: false }).catch(() => false)
])
const reportActivationOutcome = (response: RpcSuccess | null): void => {
if (!disposed && response && headlessActivationNeedsHostRenderer(response.result)) {
showToast('Open Orca on the host to wake sleeping agents.', 3000)
}
}
if (client && created !== '1' && !isFloatingWorkspaceRoute) {
// Why: hydrate host-owned tabs without pulling other paired clients (esp. desktop) into this worktree.
void client
.sendRequest('worktree.activate', {
worktree: `id:${worktreeId}`,
notifyClients: false,
navigation: 'caller'
})
.then((response) => reportActivationOutcome(response.ok ? response : null))
.catch(() => null)
}
if (disposed) {
return
}
await ensureSessionTabs().catch(() => null)
if (disposed) {
return
}
await fetchTerminals({ allowEmptyLoaded: false })
if (disposed) {
return
}
addTimer(() => void fetchTerminals({ allowEmptyLoaded: false }), 750)
addTimer(() => void fetchTerminals({ allowEmptyLoaded: true }), 1500)
})()
return () => {
disposed = true
for (const t of timers) {
clearTimeout(t)
}
}
// Why no client/worktreeId here: both reads are useCallbacks that already list them, so a
// host or worktree change replaces their identity and re-runs this effect with them.
}, [connState, fetchTerminals, ensureSessionTabs])
// worktree.activate writes host state, so unlike the reads above it waits for the compatibility
// verdict. A missing protocolVersion reads as 0 and is blocked, so "pending" is not a formality:
// mounting early must not let this route mutate a host the gate is about to refuse.
// Every setTimeout goes through addTimer into `timers`, which the returned cleanup clears.
// react-doctor-disable-next-line react-doctor/effect-needs-cleanup
useEffect(() => {
if (connState !== 'connected' || !client || !protocolVerified || isFloatingWorkspaceRoute) {
return
}
let disposed = false
const timers: ReturnType<typeof setTimeout>[] = []
function addTimer(fn: () => void, ms: number) {
if (disposed) {
return
}
timers.push(setTimeout(fn, ms))
}
const activateWorktree = () =>
client
.sendRequest('worktree.activate', {
worktree: `id:${worktreeId}`,
notifyClients: false,
navigation: 'caller'
})
.catch(() => null)
const reportActivationOutcome = (response: RpcSuccess | null): void => {
if (!disposed && response && headlessActivationNeedsHostRenderer(response.result)) {
showToast('Open Orca on the host to wake sleeping agents.', 3000)
}
}
if (created !== '1') {
// Why: hydrate host-owned tabs without pulling other paired clients (esp. desktop) into this worktree.
void activateWorktree().then((response) =>
reportActivationOutcome(response?.ok ? response : null)
)
} else {
addTimer(() => {
if (activeHandleRef.current) {
return
}
void (async () => {
const activationResponse = await activateWorktree()
reportActivationOutcome(activationResponse?.ok ? activationResponse : null)
if (disposed) {
if (client && created === '1' && !isFloatingWorkspaceRoute) {
addTimer(() => {
if (activeHandleRef.current) {
return
}
await fetchTerminals({ allowEmptyLoaded: true })
addTimer(() => void fetchTerminals({ allowEmptyLoaded: true }), 750)
})()
}, 1800)
}
void (async () => {
const activationResponse = await client
.sendRequest('worktree.activate', {
worktree: `id:${worktreeId}`,
notifyClients: false,
navigation: 'caller'
})
.catch(() => null)
reportActivationOutcome(activationResponse?.ok ? activationResponse : null)
if (disposed) {
return
}
await fetchTerminals({ allowEmptyLoaded: true })
addTimer(() => void fetchTerminals({ allowEmptyLoaded: true }), 750)
})()
}, 1800)
}
})()
return () => {
disposed = true
for (const t of timers) {
@@ -205,8 +179,8 @@ export function useMobileSessionStartup(scope: MobileSessionKeyboardStateModel)
connState,
created,
fetchTerminals,
ensureSessionTabs,
isFloatingWorkspaceRoute,
protocolVerified,
showToast,
worktreeId
])
@@ -1,4 +1,5 @@
import { useEffect, useRef, useCallback, useMemo, useState } from 'react'
import { startRuntimeCapabilityProbe } from '../transport/runtime-capability-probe'
import { supportsMobileQuickCommands } from '../terminal/quick-commands'
import { MOBILE_AI_VAULT_CAPABILITY } from '../agent-history/agent-history-capability'
import { TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version'
@@ -16,8 +17,6 @@ export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownAc
worktreeId,
client,
connState,
hostCapabilities,
protocolVerified,
sessionTabsRef,
activeSessionTabIdRef,
terminalsRef,
@@ -145,14 +144,8 @@ export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownAc
const hostQueryReplyInputSupportedRef = useRef(false)
// Why: the gate above every /h/ route already holds this connection's status.get answer (and
// retries it until one lands), so the route reads it through the foundation instead of issuing
// a second one. It reports no capabilities until the verdict is proven, which keeps the
// fail-closed reset below identical to the old pre-probe clear.
useEffect(() => {
// Why: a client swap can keep the route connected while moving to an older
// host; clear the prior capability before exposing host-specific actions.
if (!client || connState !== 'connected' || !protocolVerified) {
if (!client || connState !== 'connected') {
setBrowserScreencastSupported(null)
setAgentSessionHistorySupported(null)
setQuickCommandsSupported(null)
@@ -160,15 +153,26 @@ export function useMobileSessionTabReconciliation(scope: MobileSessionMarkdownAc
hostQueryReplyInputSupportedRef.current = false
return
}
setBrowserScreencastSupported(hostCapabilities.includes('browser.screencast.v1'))
setAgentSessionHistorySupported(hostCapabilities.includes(MOBILE_AI_VAULT_CAPABILITY))
setQuickCommandsSupported(supportsMobileQuickCommands(hostCapabilities))
// Why: hosts without this capability strip inputKind from terminal.send,
// so a forwarded xterm reply would become floor-stealing shell input.
hostQueryReplyInputSupportedRef.current = hostCapabilities.includes(
TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY
)
}, [client, connState, hostCapabilities, protocolVerified])
// Why: a client swap can keep the route connected while moving to an older
// host; clear the prior capability before exposing host-specific actions.
setBrowserScreencastSupported(null)
setAgentSessionHistorySupported(null)
setQuickCommandsSupported(null)
setShowQuickCommands(false)
hostQueryReplyInputSupportedRef.current = false
// Why: the probe retries — a relay→direct cutover or request timeout rejects
// status.get without changing connState, which used to latch these hidden.
return startRuntimeCapabilityProbe(client, (capabilities) => {
setBrowserScreencastSupported(capabilities.includes('browser.screencast.v1'))
setAgentSessionHistorySupported(capabilities.includes(MOBILE_AI_VAULT_CAPABILITY))
setQuickCommandsSupported(supportsMobileQuickCommands(capabilities))
// Why: hosts without this capability strip inputKind from terminal.send,
// so a forwarded xterm reply would become floor-stealing shell input.
hostQueryReplyInputSupportedRef.current = capabilities.includes(
TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY
)
})
}, [client, connState])
return {
consumeAcceptedSessionTabs,
hasSessionTabsRecoveryNeed,
@@ -1,66 +0,0 @@
import { useEffect, useState } from 'react'
import {
getSessionTabStripCacheKey,
loadCachedSessionTabStrip,
readCachedSessionTabStrip,
saveCachedSessionTabStrip
} from '../cache/session-tab-strip-cache'
import {
toMobileSessionTabStripPreview,
type MobileSessionTabStripPreview
} from './mobile-session-tab-strip-entries'
import type { MobileSessionBulkCloseModel } from './use-mobile-session-bulk-close'
/**
* Keeps the last drawn tab strip for this workspace on the device, so a reconnect has something
* to render before the first snapshot lands. See mobile-session-reconnect-view-state.
*/
export function useMobileSessionTabStripCache(scope: MobileSessionBulkCloseModel) {
const { hostId, worktreeId, connState, terminalsLoaded } = scope
const { visibleTabs, activeSessionTabId, activeHandle } = scope
const cacheKey = getSessionTabStripCacheKey(hostId, worktreeId)
// Why: state settles a commit behind the key it was read for, so carry the key with it —
// otherwise the first render after a workspace switch draws the previous workspace's strip.
const [loaded, setLoaded] = useState<{
key: string | null
preview: MobileSessionTabStripPreview | null
}>(() => ({ key: cacheKey, preview: readCachedSessionTabStrip(cacheKey) }))
useEffect(() => {
// Synchronous first, so an in-session revisit never blinks through the uncached branch.
setLoaded({ key: cacheKey, preview: readCachedSessionTabStrip(cacheKey) })
let disposed = false
void loadCachedSessionTabStrip(cacheKey).then((preview) => {
if (!disposed) {
setLoaded({ key: cacheKey, preview })
}
})
return () => {
disposed = true
}
}, [cacheKey])
const cachedTabStrip = loaded.key === cacheKey ? loaded.preview : null
// Only a host-confirmed strip is worth persisting, and an emptied workspace has to be written
// too — skipping it would leave yesterday's tabs to be drawn over a session that no longer has
// them. The one reading we do not trust is a live terminal with no tab record behind it, which
// is the same case the empty state refuses to claim (use-mobile-session-presentation).
// react-doctor-disable-next-line react-doctor/effect-needs-cleanup
useEffect(() => {
if (connState !== 'connected' || !terminalsLoaded) {
return
}
if (visibleTabs.length === 0 && activeHandle !== null) {
return
}
saveCachedSessionTabStrip(
cacheKey,
toMobileSessionTabStripPreview(visibleTabs, activeSessionTabId)
)
}, [activeHandle, activeSessionTabId, cacheKey, connState, terminalsLoaded, visibleTabs])
return { cachedTabStrip }
}
export type MobileSessionTabStripCacheModel = MobileSessionBulkCloseModel &
ReturnType<typeof useMobileSessionTabStripCache>
@@ -1,6 +1,4 @@
import { useRef, useCallback } from 'react'
import type { TerminalWebViewHandle } from '../terminal/terminal-webview-contract'
import { TERMINAL_ENGINE_PREWARM_HANDLE } from './TerminalEnginePrewarm'
import type { MobileSessionNativeChatDictationModel } from './use-mobile-session-native-chat-dictation'
export function useMobileSessionTerminalSubscriptionFoundation(
@@ -103,34 +101,12 @@ export function useMobileSessionTerminalSubscriptionFoundation(
},
[getTerminalRef]
)
// Why: the pre-warm engine occupies the frame the first pane will occupy, so let it satisfy
// the one-shot measurement. It has no handle, so it is passed its own ref instead of looking
// one up, and it must never latch a measurement taken before the frame has a real height.
const measurePrewarmViewport = useCallback(
async (engine: TerminalWebViewHandle, frameHeight: number) => {
if (viewportMeasuredRef.current || frameHeight <= 0) {
return
}
const dims = await engine.measureFitDimensions(frameHeight)
terminalDiagnosticsRef.current.viewportMeasured(
TERMINAL_ENGINE_PREWARM_HANDLE,
dims,
frameHeight
)
if (dims && !viewportMeasuredRef.current) {
viewportRef.current = dims
viewportMeasuredRef.current = true
}
},
[]
)
return {
getTerminalRef,
unsubscribeTerminal,
unsubscribeTerminalRef,
clearTerminalCache,
measureViewportOnce,
measurePrewarmViewport
measureViewportOnce
}
}
@@ -16,29 +16,6 @@ describe('connection log buffer', () => {
expect(store.get('host-b').map((e) => e.id)).toEqual(['log-2'])
})
it('retains phase timings through redaction and evicts them with the cap', () => {
const store = createConnectionLogStore(2)
store.append('host-a', {
...entry(1),
timing: { kind: 'connection-state', name: 'reconnecting', ms: 800, complete: true }
})
store.append('host-a', {
...entry(2),
detail: '4280ms in connecting; resumeToken=secret-resume-token',
timing: { kind: 'connection-state', name: 'connecting', ms: 4_280, complete: true }
})
store.append('host-a', {
...entry(3),
timing: { kind: 'relay-dial-stage', name: 'awaiting-hello', ms: 9_100, complete: false }
})
expect(store.get('host-a').map((e) => e.timing)).toEqual([
{ kind: 'connection-state', name: 'connecting', ms: 4_280, complete: true },
{ kind: 'relay-dial-stage', name: 'awaiting-hello', ms: 9_100, complete: false }
])
expect(store.get('host-a')[0]!.detail).toBe('4280ms in connecting; resumeToken=[redacted]')
})
it('drops the oldest entries past the cap', () => {
const store = createConnectionLogStore(3)
for (let i = 1; i <= 5; i++) {
@@ -1,77 +0,0 @@
import { describe, expect, it } from 'vitest'
import { DirectConnectionLog } from './direct-connection-log'
import { RpcClientConnectionState } from './rpc-client-connection-state'
import type { ConnectionLogEntry, ConnectionState } from './types'
function openStateWithLog(sink?: (entry: ConnectionLogEntry) => void) {
const entries: ConnectionLogEntry[] = []
const log = new DirectConnectionLog(
'ws://192.168.1.50:6768',
sink ?? ((entry) => entries.push(entry))
)
let now = 0
const state = new RpcClientConnectionState({
endpoint: 'ws://192.168.1.50:6768',
getReconnectAttempt: () => 0,
isClosed: () => false,
onStateDwell: log.stateDwell,
now: () => now
})
const publishAfter = (elapsedMs: number, next: ConnectionState): void => {
now += elapsedMs
state.publish(next)
}
return { entries, state, publishAfter }
}
describe('connection state dwell logging', () => {
it('records the time spent in each state as a structured log entry', () => {
const { entries, publishAfter } = openStateWithLog()
publishAfter(300, 'connecting')
publishAfter(4_200, 'handshaking')
publishAfter(250, 'connected')
expect(entries.map((entry) => entry.timing)).toEqual([
{ kind: 'connection-state', name: 'disconnected', ms: 300, complete: true },
{ kind: 'connection-state', name: 'connecting', ms: 4_200, complete: true },
{ kind: 'connection-state', name: 'handshaking', ms: 250, complete: true }
])
expect(entries[1]!.message).toBe('Connection state connecting → handshaking')
expect(entries[1]!.detail).toBe('4200ms in connecting')
})
it('skips transitions too short to explain a slow connect', () => {
const { entries, publishAfter } = openStateWithLog()
publishAfter(99, 'connecting')
publishAfter(100, 'handshaking')
expect(entries.map((entry) => entry.timing?.name)).toEqual(['connecting'])
})
it('does not log a dwell when the state does not change', () => {
const { entries, publishAfter } = openStateWithLog()
publishAfter(500, 'connecting')
publishAfter(500, 'connecting')
expect(entries).toHaveLength(1)
})
it('still publishes the state when the log sink throws', () => {
const seen: ConnectionState[] = []
const { state, publishAfter } = openStateWithLog(() => {
throw new Error('sink exploded')
})
state.addListener((next) => seen.push(next))
const connected = state.waitForConnected()
publishAfter(500, 'connecting')
publishAfter(500, 'connected')
expect(seen).toEqual(['connecting', 'connected'])
expect(state.get()).toBe('connected')
return expect(connected).resolves.toBeUndefined()
})
})
+1 -27
View File
@@ -4,15 +4,9 @@ import type {
ConnectionLogEntry,
ConnectionLogLevel,
ConnectionLogSink,
ConnectionState,
MobileConnectionDiagnosticPath
} from './types'
// Why: every reconnect cycle walks four states, and the per-host buffer is capped.
// Logging sub-100ms transitions would halve the history a report can show while
// telling support nothing — those states are never where a slow connect spent time.
const MIN_LOGGED_DWELL_MS = 100
export class DirectConnectionLog {
private sequence = 0
private readonly path: MobileConnectionDiagnosticPath
@@ -28,7 +22,7 @@ export class DirectConnectionLog {
level: ConnectionLogLevel,
message: string,
detail?: string,
evidence?: Pick<ConnectionLogEntry, 'code' | 'path' | 'timing'>
evidence?: Pick<ConnectionLogEntry, 'code' | 'path'>
): void => {
this.sink?.({
id: `log-${++this.sequence}-${Date.now()}`,
@@ -50,26 +44,6 @@ export class DirectConnectionLog {
)
}
// Why: how long the client sat in each ConnectionState used to go only to
// console, so a shared diagnostics report could not show where a slow connect
// spent its seconds.
stateDwell = (previous: ConnectionState, next: ConnectionState, dweltMs: number): void => {
if (dweltMs < MIN_LOGGED_DWELL_MS) {
return
}
this.emit('info', `Connection state ${previous}${next}`, `${dweltMs}ms in ${previous}`, {
timing: { kind: 'connection-state', name: previous, ms: dweltMs, complete: true }
})
}
retryScheduled = (message: string, detail?: string): void => {
this.emit('info', message, detail, { code: 'retry-scheduled' })
}
authenticationRejected = (message: string, detail?: string): void => {
this.emit('warn', message, detail, { code: 'authentication-rejected' })
}
connected = (): void => {
this.emit('success', 'Authenticated', 'Channel ready for RPC', { code: 'direct-connected' })
}
+5 -4
View File
@@ -48,14 +48,14 @@ export class DirectRpcClient implements RpcClient {
this.reconnect = new RpcClientReconnectSchedule({
openConnection: () => this.openConnection(),
rejectConnectWaiters: (reason) => this.connectionState.rejectWaiters(reason),
emitLog: this.connectionLog.retryScheduled
emitLog: (message, detail) =>
this.connectionLog.emit('info', message, detail, { code: 'retry-scheduled' })
})
this.connectionState = new RpcClientConnectionState({
endpoint,
initialListener: options.onStateChange,
getReconnectAttempt: () => this.reconnect.getAttempt(),
isClosed: () => this.intentionallyClosed,
onStateDwell: this.connectionLog.stateDwell
isClosed: () => this.intentionallyClosed
})
this.streams = new RpcClientStreamRegistry({
nextId: () => this.nextId(),
@@ -102,7 +102,8 @@ export class DirectRpcClient implements RpcClient {
this.authenticationRetry = new RpcClientAuthenticationRetry({
endpoint,
stopLiveness: () => this.stopLiveness(),
emitWarning: this.connectionLog.authenticationRejected,
emitWarning: (message, detail) =>
this.connectionLog.emit('warn', message, detail, { code: 'authentication-rejected' }),
retry: (reason) => this.retryAuthentication(reason),
latchFailure: (reason) => this.latchAuthenticationFailure(reason)
})
@@ -17,12 +17,6 @@ vi.mock('./host-store', () => ({
}))
import { removeHostAndCloseClient } from './host-removal-lifecycle'
import {
getSessionTabStripCacheKey,
readCachedSessionTabStrip,
resetSessionTabStripCacheForTests,
saveCachedSessionTabStrip
} from '../cache/session-tab-strip-cache'
import {
getHostNotificationSession,
resetHostNotificationSessionsForTests
@@ -32,9 +26,7 @@ describe('host removal lifecycle', () => {
beforeEach(() => {
removeHostMock.mockReset()
asyncStorage.removeItem.mockClear()
asyncStorage.setItem.mockReset().mockResolvedValue(undefined)
resetHostNotificationSessionsForTests()
resetSessionTabStripCacheForTests()
})
it('closes the client only after metadata removal commits', async () => {
@@ -96,40 +88,4 @@ describe('host removal lifecycle', () => {
expect(asyncStorage.removeItem).toHaveBeenCalledWith('orca:mobileNotificationsWatermark:host-1')
})
it('drops the removed host cached tab strip and keeps every other host', async () => {
// Why: the strip is plaintext and nothing else in the app ever expires an entry, so a
// forgotten host would keep its tab titles on disk and get them rewritten by the next
// save for any surviving host.
removeHostMock.mockResolvedValue(undefined)
const removed = getSessionTabStripCacheKey('host-1', 'wt-1')
const kept = getSessionTabStripCacheKey('host-2', 'wt-1')
const strip = {
tabs: [{ id: 'tab-1', type: 'terminal' as const, title: 'Terminal', agentId: null }],
activeTabId: 'tab-1'
}
saveCachedSessionTabStrip(removed, strip)
saveCachedSessionTabStrip(kept, strip)
await removeHostAndCloseClient('host-1', vi.fn())
// Fire-and-forget, like clearWatermark above; let its microtasks land.
await vi.waitFor(() => expect(readCachedSessionTabStrip(removed)).toBeNull())
expect(readCachedSessionTabStrip(kept)?.tabs).toHaveLength(1)
})
it('finishes the removal even when the cached tab strip write fails', async () => {
// The metadata removal has already committed and the client is closed by this
// point, so a cache write that fails must be reported, not thrown: surfacing it
// as a failed removal would leave the user staring at a host that is really gone.
removeHostMock.mockResolvedValue(undefined)
asyncStorage.setItem.mockRejectedValue(new Error('storage full'))
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const closeHostClient = vi.fn()
await expect(removeHostAndCloseClient('host-1', closeHostClient)).resolves.toBeUndefined()
expect(closeHostClient).toHaveBeenCalledWith('host-1')
expect(warn).toHaveBeenCalled()
warn.mockRestore()
})
})
@@ -1,4 +1,3 @@
import { deleteCachedSessionTabStripForHost } from '../cache/session-tab-strip-cache'
import {
clearWatermark,
forgetHostNotificationSession
@@ -18,12 +17,4 @@ export async function removeHostAndCloseClient(
// re-pair of the same host would inherit a watermark for a counter it never saw.
forgetHostNotificationSession(hostId)
void clearWatermark(hostId)
// Why: the cached tab strip is plaintext and host-scoped, so forgetting the host has to drop
// it here too — nothing else in the app ever expires an entry. Awaited so a storage failure
// is observed rather than swallowed, but never fatal: the metadata removal has already
// committed and the client is closed, so failing here would report a finished removal as
// failed. The cache refuses further saves for this host either way.
await deleteCachedSessionTabStripForHost(hostId).catch((error: unknown) => {
console.warn('[host-removal] cached tab strip delete failed', error)
})
}
+38 -39
View File
@@ -1,7 +1,6 @@
import { useEffect, useState } from 'react'
import type { RpcClient } from './rpc-client'
import type { ConnectionState } from './types'
import { readRuntimeCapabilities, startRuntimeStatusProbe } from './runtime-status-probe'
import type { ConnectionState, RpcSuccess } from './types'
import { evaluateCompat, type CompatVerdict } from './protocol-compat'
import type { DesktopStatus } from '../worktree/host-worktree-rpc-types'
import { normalizeHostAppVersion, recordHostAppVersion } from './host-app-version-store'
@@ -11,10 +10,6 @@ export type HostStatusGates = {
floatingWorkspaceEnabled: boolean
desktopAppVersion: string | null
compatVerdict: CompatVerdict
// Why: `compatVerdict.kind === 'ok'` is not proof. A host that never answers status.get settles
// the same `ok` so navigation is not trapped, and that fallback must not read as a passing
// verdict. Only an evaluated status reply sets this, so writes to the host can gate on it.
compatVerified: boolean
statusPending: boolean
}
@@ -26,11 +21,8 @@ type LoadedHostStatusGates = Omit<HostStatusGates, 'statusPending'> & {
const EMPTY_HOST_CAPABILITIES: string[] = []
// The route tree's single status.get: it reads capabilities, the protocol-compat verdict, and
// the floating-workspace flag once per connection and publishes them through HostProtocolGate,
// so no descendant issues its own. The verdict really can block — evaluateCompat reads a missing
// protocolVersion as 0, below MIN_COMPATIBLE_DESKTOP_VERSION — so a pending verdict is a real
// state, not a formality, and anything that writes to the host must wait for it.
// Reads status.get on connect for capabilities, protocol-compat verdict, and the
// floating-workspace flag. Compat constants are wide-open today so this never blocks yet.
export function useHostStatusGates(args: {
hostId: string | undefined
client: RpcClient | null
@@ -47,33 +39,30 @@ export function useHostStatusGates(args: {
setUnverified(true)
return
}
let cancelled = false
const requestClient = client
const settle = (gates: Omit<HostStatusGates, 'statusPending'>) => {
setLoaded({ hostId, client: requestClient, ...gates })
setUnverified(false)
}
// Why: a transient status failure must not trap navigation, so the first miss settles
// conservative gates and releases the pending overlay; the probe keeps retrying underneath
// so a cutover or timeout no longer latches capability-gated UI hidden until a remount.
// compatVerified stays false: this releases the UI, it proves nothing about the host.
let failedOpen = false
const failOpen = () => {
if (failedOpen) {
return
}
failedOpen = true
settle({
hostCapabilities: [],
floatingWorkspaceEnabled: false,
desktopAppVersion: null,
compatVerdict: { kind: 'ok' },
compatVerified: false
})
}
return startRuntimeStatusProbe(requestClient, {
onUnavailable: failOpen,
onStatus: (result) => {
const status = result as DesktopStatus & { capabilities?: string[] }
void (async () => {
try {
const response = await requestClient.sendRequest('status.get')
if (cancelled) {
return
}
if (!response.ok) {
settle({
hostCapabilities: [],
floatingWorkspaceEnabled: false,
desktopAppVersion: null,
compatVerdict: { kind: 'ok' }
})
return
}
const status = (response as RpcSuccess).result as DesktopStatus & {
capabilities?: string[]
}
const verdict = evaluateCompat({
desktopProtocolVersion: status.protocolVersion,
desktopMinCompatibleMobileVersion: status.minCompatibleMobileVersion
@@ -83,11 +72,10 @@ export function useHostStatusGates(args: {
void recordHostAppVersion(hostId, desktopAppVersion)
}
settle({
hostCapabilities: [...readRuntimeCapabilities(result)],
hostCapabilities: status.capabilities ?? [],
floatingWorkspaceEnabled: status.floatingWorkspaceEnabled === true,
desktopAppVersion,
compatVerdict: verdict,
compatVerified: true
compatVerdict: verdict
})
if (verdict.kind === 'blocked') {
// Why: support breadcrumb to confirm a block fired vs a render bug; no PII, just version ints.
@@ -98,8 +86,21 @@ export function useHostStatusGates(args: {
requiredDesktopVersion: verdict.requiredDesktopVersion
})
}
} catch {
// Why: a transient status failure must not trap navigation; conservative feature gates remain disabled.
if (!cancelled) {
settle({
hostCapabilities: [],
floatingWorkspaceEnabled: false,
desktopAppVersion: null,
compatVerdict: { kind: 'ok' }
})
}
}
})
})()
return () => {
cancelled = true
}
}, [client, connState, hostId])
// Why: effects run after render, so key loaded gates by host and client to fail closed during route reuse.
@@ -110,7 +111,6 @@ export function useHostStatusGates(args: {
floatingWorkspaceEnabled: false,
desktopAppVersion: null,
compatVerdict: { kind: 'ok' },
compatVerified: false,
statusPending: connState === 'connected' && client !== null
}
}
@@ -119,7 +119,6 @@ export function useHostStatusGates(args: {
floatingWorkspaceEnabled: proven.floatingWorkspaceEnabled,
desktopAppVersion: proven.desktopAppVersion,
compatVerdict: proven.compatVerdict,
compatVerified: proven.compatVerified,
// Why (F10): unchanged pending timing — the reconnect refetch is still "unknown", it just no
// longer blanks the capabilities this same host already proved.
statusPending: connState === 'connected' && unverified
@@ -1,96 +0,0 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { DirectReturnProbe } from './mobile-direct-return-probe'
import { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis'
import { FakeSession, host } from './mobile-endpoint-supervisor-test-fakes'
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
// A LAN that never answers: every dial sits open until the probe's own 12s budget.
function fixture() {
const opened: FakeSession[] = []
const probe = new DirectReturnProbe(
{
now: Date.now,
setTimer: setTimeout,
clearTimer: clearTimeout,
openDirect: () => {
const candidate = new FakeSession('connecting')
opened.push(candidate)
return candidate
}
},
{
hysteresis: new MobileEndpointHysteresis(Date.now(), {
directSuccessesRequired: 1,
directObservationMs: 60_000,
failureCooldownMs: 0,
minimumDwellMs: 0
}),
host: () => host,
canSchedule: () => true,
canDial: () => true,
canAttempt: () => true,
// These cases model a live relay session, so hysteresis still arbitrates.
adoptsOutright: () => false,
beginOperation: () => {},
migrate: async () => {},
onDirectMigrated: async () => {},
afterProbe: () => {}
}
)
return { opened, probe }
}
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('never opens a second dial while one is still in flight', async () => {
const { opened, probe } = fixture()
probe.schedule(0)
await vi.advanceTimersByTimeAsync(0)
expect(opened).toHaveLength(1)
// A relay drop and a foreground return both ask for an immediate probe while the
// first dial is still awaiting authentication.
probe.schedule(0)
probe.schedule(0)
await vi.advanceTimersByTimeAsync(0)
expect(opened).toHaveLength(1)
// Why this is the assertion that matters: a second probe would have overwritten
// activeProbe, so stop() would abort only the newest dial and leave this socket
// open for the rest of its 12s budget.
probe.stop()
await vi.advanceTimersByTimeAsync(0)
expect(opened[0]!.close).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
})
it('honors an urgent reprobe asked for mid-dial instead of dropping it on the 15s floor', async () => {
const { opened, probe } = fixture()
probe.schedule(0)
await vi.advanceTimersByTimeAsync(0)
probe.schedule(0)
await vi.advanceTimersByTimeAsync(0)
expect(opened).toHaveLength(1)
// The deferred ask survives the dial and runs at once when it settles, so holding
// the slot does not cost the caller the 15s it was trying to skip.
await vi.advanceTimersByTimeAsync(12_000)
await vi.advanceTimersByTimeAsync(1)
expect(opened).toHaveLength(2)
probe.stop()
})
it('falls back to the ordinary interval when nothing asked for a sooner probe', async () => {
const { opened, probe } = fixture()
probe.schedule(0)
await vi.advanceTimersByTimeAsync(12_000)
expect(opened).toHaveLength(1)
await vi.advanceTimersByTimeAsync(14_999)
expect(opened).toHaveLength(1)
await vi.advanceTimersByTimeAsync(1)
expect(opened).toHaveLength(2)
probe.stop()
})
@@ -6,18 +6,13 @@ import type { MobileConnectionPath } from './stable-logical-rpc-client'
const DIRECT_PROBE_INTERVAL_MS = 15_000
// Re-acquires the direct endpoint while the runtime channel rides the relay.
// Two adoption policies, because what is at stake differs:
// - against a live relay, hysteresis must prove direct stable before the swap;
// - during a reconnect nothing is live, so this dial races the relay dial from
// t=0 and the first authenticated socket is adopted outright.
// While the runtime channel rides the relay, periodically probe the direct
// endpoint and migrate back once hysteresis proves it stable.
export class DirectReturnProbe {
private timer: ReturnType<typeof setTimeout> | null = null
private stopped = false
private activeProbe: AbortController | null = null
// Soonest delay a caller asked for while a dial was in flight.
private deferredDelayMs: number | null = null
constructor(
private readonly deps: {
@@ -30,14 +25,7 @@ export class DirectReturnProbe {
hysteresis: MobileEndpointHysteresis
host: () => HostProfile
canSchedule: () => boolean
// A dial is a pure observation on its own socket, so it only needs a live
// supervisor; the cutover is the part that needs the operation mutex.
canDial: () => boolean
canAttempt: () => boolean
// True while no session is live: the reconnect is a race, so an
// authenticated direct socket wins without consulting hysteresis.
adoptsOutright: () => boolean
// Takes the supervisor's operation mutex, now held for the cutover only.
beginOperation: () => void
migrate: (
client: RpcClient,
@@ -50,18 +38,7 @@ export class DirectReturnProbe {
) {}
schedule(delayMs = DIRECT_PROBE_INTERVAL_MS): void {
if (this.stopped || !this.hooks.canSchedule()) {
return
}
// Why: the dial no longer holds the supervisor's mutex, so nothing else stops a
// second probe from overwriting activeProbe — stop() would then reach only the
// newest socket and leave the earlier one dialing for its full 12s budget. The
// in-flight probe owns the next slot and re-arms it on the soonest ask.
if (this.activeProbe) {
this.deferredDelayMs = Math.min(this.deferredDelayMs ?? delayMs, delayMs)
return
}
if (this.timer) {
if (this.stopped || !this.hooks.canSchedule() || this.timer) {
return
}
this.timer = this.deps.setTimer(() => {
@@ -70,18 +47,7 @@ export class DirectReturnProbe {
}, delayMs)
}
// Why: a reconnect races both paths from t=0, and schedule(0) yields to a
// pending 15s tick — that would hand the relay dial a head start by another name.
probeNow(): void {
if (this.stopped || this.activeProbe) {
return
}
this.clear()
this.schedule(0)
}
clear(): void {
this.deferredDelayMs = null
if (this.timer) {
this.deps.clearTimer(this.timer)
this.timer = null
@@ -98,23 +64,15 @@ export class DirectReturnProbe {
if (this.stopped) {
return
}
// Why: the failure cooldown exists to stop a healthy relay flapping onto a
// marginal LAN. With nothing connected there is no session to protect, and
// honouring it would leave the phone waiting on relay alone.
const racing = this.hooks.adoptsOutright()
if (!this.hooks.canDial() || (!racing && !this.hooks.hysteresis.canProbe(this.deps.now()))) {
if (!this.hooks.canAttempt() || !this.hooks.hysteresis.canProbe(this.deps.now())) {
this.schedule()
return
}
const controller = new AbortController()
this.activeProbe = controller
let owned = false
this.hooks.beginOperation()
let successful: Awaited<ReturnType<typeof openAuthenticatedDirectEndpoint>> = null
try {
// Why: the dial is a pure observation on its own socket — holding the
// supervisor's mutex across its 12s budget stalled every relay recovery
// that landed during a foreground return, and makes the reconnect race
// unwinnable while a relay dial holds it.
successful = await openAuthenticatedDirectEndpoint(
this.hooks.host(),
this.deps.openDirect,
@@ -128,42 +86,17 @@ export class DirectReturnProbe {
this.hooks.hysteresis.recordDirectFailure(this.deps.now())
return
}
// Both early returns leave the candidate to the finally, which owns it until
// migration takes over — closing here too would double-close it.
const outright = this.hooks.adoptsOutright()
// Why: a socket that entered the race and lost books nothing and leaves the
// promotion streak untouched — the winner is this reconnect's whole verdict.
if (!outright && (racing || !this.hooks.hysteresis.recordDirectSuccess(this.deps.now()))) {
if (!this.hooks.hysteresis.recordDirectSuccess(this.deps.now())) {
successful.client.close()
return
}
const mutexFree = this.hooks.canAttempt()
if (!mutexFree && !outright) {
// A relay dial owns the mutex; the streak survives, so the next probe
// promotes direct instead of this one.
return
}
if (mutexFree) {
this.hooks.beginOperation()
owned = true
}
// Why: when a relay dial holds the mutex the race still cuts over — that
// dial withdraws itself in migrateTo and books no failure against relay.
const candidate = successful
// Migration owns the candidate, including closing it if cutover is canceled.
successful = null
// Why: the relay dial can authenticate between this socket's authentication
// and the swap. migrateTo re-checks after auth, so the loser withdraws.
const abortCutover = outright
? (): boolean => this.stopped || !this.hooks.adoptsOutright()
: (): boolean => this.stopped
try {
await this.hooks.migrate(candidate.client, candidate.path, abortCutover)
await this.hooks.migrate(candidate.client, candidate.path, () => this.stopped)
} catch (error) {
// Why: a withdrawn cutover is the ordinary end of a lost race, and
// migrateTo has already closed the candidate. Only the timer calls this
// method, and it discards the promise, so rethrowing here would surface
// a routine loss as an unhandled rejection.
if (this.stopped || abortCutover()) {
if (this.stopped) {
return
}
throw error
@@ -176,14 +109,10 @@ export class DirectReturnProbe {
} finally {
this.activeProbe = null
successful?.client.close()
// Why: a relay drop or backoff timer can arrive while the cutover owns the
// Why: a relay drop or backoff timer can arrive while the probe owns the
// operation mutex; afterProbe releases it and replays deferred recovery.
if (owned) {
this.hooks.afterProbe()
}
const deferred = this.deferredDelayMs
this.deferredDelayMs = null
this.schedule(deferred ?? undefined)
this.hooks.afterProbe()
this.schedule()
}
}
}
@@ -86,7 +86,7 @@ function createSupervisor(
): MobileEndpointSupervisor {
return new MobileEndpointSupervisor(logical, host, {
openDirect: (endpoint) => connect(endpoint, host.deviceToken, host.publicKeyB64, { onLog }),
openRelay: (relay, credential, confirmReqId, onHostCloseReason, isForeground) =>
openRelay: (relay, credential, confirmReqId, onHostCloseReason) =>
connectMobileRelayRpcSession({
relay,
resumeToken: credential.token,
@@ -94,7 +94,6 @@ function createSupervisor(
resumeConfirmReqId: confirmReqId,
deviceToken: host.deviceToken,
desktopPublicKeyB64: host.publicKeyB64,
isForeground,
onHostCloseReason,
onLog
}),
@@ -1,362 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis'
import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor'
import { RelayOuterError } from './mobile-relay-e2ee-link'
import {
dependencies,
FakeLogicalClient,
FakeRelaySession,
FakeSession,
host,
unreachableDirect
} from './mobile-endpoint-supervisor-test-fakes'
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' }))
vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) }))
// Holds the relay cutover open so the direct path can authenticate mid-dial. The
// fake's migrateTo otherwise settles inside the dial, which no real cell does.
function holdRelayCutover(logical: FakeLogicalClient): () => void {
const settle = logical.migrateTo.getMockImplementation()!
let release!: () => void
const held = new Promise<void>((resolve) => {
release = resolve
})
logical.migrateTo.mockImplementationOnce(async (session, path, timeoutMs, shouldAbort) => {
await held
// Why: replays the real post-authentication checks, so a superseded dial
// still withdraws instead of stealing the client from the winner.
return await settle(session, path, timeoutMs, shouldAbort)
})
return release
}
// One full lost race: the relay dial starts, direct returns mid-cutover and wins.
async function loseOneRace(
logical: FakeLogicalClient,
openRelay: ReturnType<typeof vi.fn>
): Promise<void> {
const before = openRelay.mock.calls.length
const release = holdRelayCutover(logical)
logical.publishState('reconnecting')
await vi.advanceTimersByTimeAsync(0)
expect(openRelay.mock.calls.length).toBe(before + 1)
logical.publishState('connected')
release()
await vi.advanceTimersByTimeAsync(0)
}
function relaySessionsFrom(openRelay: ReturnType<typeof vi.fn>): FakeRelaySession[] {
return openRelay.mock.results.map((result) => result.value as FakeRelaySession)
}
describe('mobile endpoint reconnect race', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-13T12:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
})
it('dials relay at t=0 while the direct dial is still connecting', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const deps = dependencies({ openDirect: unreachableDirect() })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
// No timer advance at all: an unfinished direct dial buys no head start.
await supervisor.start()
expect(deps.openRelay).toHaveBeenCalledOnce()
expect(logical.getActivePath()).toBe('relay')
expect(logical.migrateTo).toHaveBeenCalledWith(
expect.any(FakeRelaySession),
'relay',
undefined,
expect.any(Function)
)
supervisor.stop()
})
it('adopts the direct dial and withdraws the slower relay dial without booking it', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const openRelay = vi.fn(() => new FakeRelaySession('connected'))
const deps = dependencies({ openRelay, openDirect: unreachableDirect() })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
const release = holdRelayCutover(logical)
const starting = supervisor.start()
await vi.advanceTimersByTimeAsync(0)
expect(openRelay).toHaveBeenCalledOnce()
// The direct dial authenticates while the cell is still cutting over.
logical.publishState('connected')
release()
await starting
expect(logical.getActivePath()).toBe('lan')
expect(relaySessionsFrom(openRelay)[0]!.close).toHaveBeenCalled()
// A withdrawn dial is not a failure: no cooldown is armed, so no redial lands.
await vi.advanceTimersByTimeAsync(60_000)
expect(openRelay).toHaveBeenCalledOnce()
expect(logical.setRecoveryPath).toHaveBeenLastCalledWith(null)
supervisor.stop()
})
it('adopts a direct socket that wins a reconnect the relay path started', async () => {
const recordMigration = vi.spyOn(MobileEndpointHysteresis.prototype, 'recordMigration')
const logical = new FakeLogicalClient('connected', 'relay')
const openRelay = vi.fn(() => new FakeRelaySession('connected'))
const deps = dependencies({ openRelay })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
const release = holdRelayCutover(logical)
logical.publishState('disconnected')
// The direct dial runs while the relay dial is still in flight and wins it.
await vi.advanceTimersByTimeAsync(0)
expect(deps.openDirect).toHaveBeenCalledOnce()
expect(logical.getActivePath()).toBe('lan')
release()
await vi.advanceTimersByTimeAsync(0)
expect(relaySessionsFrom(openRelay)[0]!.close).toHaveBeenCalled()
// Hysteresis stamps the dwell, and the losing relay dial books no backoff.
expect(recordMigration).toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(60_000)
expect(openRelay).toHaveBeenCalledOnce()
supervisor.stop()
})
it('books one backoff, not two, when both paths lose the reconnect', async () => {
const recordDirectFailure = vi.spyOn(MobileEndpointHysteresis.prototype, 'recordDirectFailure')
const logical = new FakeLogicalClient('connected', 'relay')
const openRelay = vi.fn(() => new FakeRelaySession('disconnected', new RelayOuterError(4408)))
const deps = dependencies({
openRelay,
openDirect: unreachableDirect(),
randomBytes: () => new Uint8Array([128, 0])
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
logical.publishState('disconnected')
await vi.advanceTimersByTimeAsync(0)
expect(openRelay).toHaveBeenCalledOnce()
expect(deps.openDirect).toHaveBeenCalledOnce()
expect(recordDirectFailure).toHaveBeenCalledOnce()
// One failure, so one 250ms step. A double-booked loss would redial at 500ms.
await vi.advanceTimersByTimeAsync(249)
expect(openRelay).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(1)
expect(openRelay).toHaveBeenCalledTimes(2)
supervisor.stop()
})
it('leaves the promotion streak alone when the direct socket loses the race', async () => {
const recordDirectSuccess = vi.spyOn(MobileEndpointHysteresis.prototype, 'recordDirectSuccess')
const logical = new FakeLogicalClient('connected', 'relay')
const direct = new FakeSession('connecting')
const openRelay = vi.fn(() => new FakeRelaySession('connected'))
const deps = dependencies({ openRelay, openDirect: vi.fn(() => direct) })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
const release = holdRelayCutover(logical)
logical.publishState('disconnected')
await vi.advanceTimersByTimeAsync(0)
expect(deps.openDirect).toHaveBeenCalledOnce()
// Relay authenticates first, then the direct socket finally answers.
release()
await vi.advanceTimersByTimeAsync(0)
expect(logical.getActivePath()).toBe('relay')
direct.publishState('connected')
await vi.advanceTimersByTimeAsync(0)
expect(direct.close).toHaveBeenCalled()
expect(recordDirectSuccess).not.toHaveBeenCalled()
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
it('ignores a loser that closes after the winner has been adopted', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const openRelay = vi.fn(() => new FakeRelaySession('connected'))
const deps = dependencies({ openRelay, openDirect: unreachableDirect() })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
const release = holdRelayCutover(logical)
const starting = supervisor.start()
await vi.advanceTimersByTimeAsync(0)
logical.publishState('connected')
release()
await starting
expect(logical.getActivePath()).toBe('lan')
// The withdrawn cell socket reports its close afterwards.
relaySessionsFrom(openRelay)[0]!.publishState('disconnected')
await vi.advanceTimersByTimeAsync(60_000)
expect(logical.getState()).toBe('connected')
expect(logical.getActivePath()).toBe('lan')
expect(openRelay).toHaveBeenCalledOnce()
supervisor.stop()
})
it('withdraws the relay socket before it authenticates once direct wins', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const relaySession = new FakeRelaySession('connecting')
const openRelay = vi.fn(() => relaySession)
const deps = dependencies({ openRelay, openDirect: unreachableDirect() })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
const release = holdRelayCutover(logical)
const starting = supervisor.start()
await vi.advanceTimersByTimeAsync(0)
expect(openRelay).toHaveBeenCalledOnce()
expect(relaySession.close).not.toHaveBeenCalled()
// The direct dial authenticates while the cell socket is still pre-handshake.
// migrateTo would not withdraw until after E2EE auth, so the cell would have
// reserved a splice and the desktop would have finished a handshake for it.
logical.publishState('connected')
expect(relaySession.close).toHaveBeenCalled()
expect(relaySession.getState()).not.toBe('connected')
release()
await starting
await vi.advanceTimersByTimeAsync(60_000)
expect(logical.getActivePath()).toBe('lan')
expect(openRelay).toHaveBeenCalledOnce()
supervisor.stop()
})
it('damps the race after a loss so a flapping LAN opens one cell socket', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const openRelay = vi.fn(() => new FakeRelaySession('connecting'))
const deps = dependencies({ openRelay, openDirect: unreachableDirect() })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
// The first blip races, and the returning direct dial wins it.
const release = holdRelayCutover(logical)
const starting = supervisor.start()
await vi.advanceTimersByTimeAsync(0)
logical.publishState('connected')
release()
await starting
expect(openRelay).toHaveBeenCalledOnce()
// Two more blips inside the damper window open no further cell socket.
for (const _blip of [1, 2]) {
logical.publishState('reconnecting')
await vi.advanceTimersByTimeAsync(100)
logical.publishState('connected')
await vi.advanceTimersByTimeAsync(400)
}
expect(openRelay).toHaveBeenCalledOnce()
// The window lapses against a live direct path, so it still opens nothing.
await vi.advanceTimersByTimeAsync(10_000)
expect(openRelay).toHaveBeenCalledOnce()
supervisor.stop()
})
it('races at once when the LAN dies inside a damper window grown to the cap', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const openRelay = vi.fn(() => new FakeRelaySession('connecting'))
const deps = dependencies({ openRelay, openDirect: unreachableDirect() })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
const release = holdRelayCutover(logical)
const starting = supervisor.start()
await vi.advanceTimersByTimeAsync(0)
logical.publishState('connected')
release()
await starting
// Four more losses, each once its window has run: 2s, 4s, 8s, 16s, then the
// fifth earns the 30s cap.
for (const window of [2_000, 4_000, 8_000, 16_000]) {
await vi.advanceTimersByTimeAsync(window)
await loseOneRace(logical, openRelay)
}
expect(openRelay).toHaveBeenCalledTimes(5)
// This time direct does not come back. Waiting out the window a blip earned
// would strand the phone offline for 30s with nothing else scheduled.
logical.publishState('reconnecting')
await vi.advanceTimersByTimeAsync(249)
expect(openRelay).toHaveBeenCalledTimes(5)
await vi.advanceTimersByTimeAsync(1)
expect(openRelay.mock.calls.length).toBeGreaterThan(5)
supervisor.stop()
})
it('lets a foreground resume race immediately inside a damper window', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const openRelay = vi.fn(() => new FakeRelaySession('connecting'))
const deps = dependencies({ openRelay, openDirect: unreachableDirect() })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
const release = holdRelayCutover(logical)
const starting = supervisor.start()
await vi.advanceTimersByTimeAsync(0)
logical.publishState('connected')
release()
await starting
logical.publishState('reconnecting')
await vi.advanceTimersByTimeAsync(100)
expect(openRelay).toHaveBeenCalledOnce()
// A resume is the user waiting on the screen; it never serves out the window.
supervisor.setForeground(false)
supervisor.setForeground(true)
await vi.advanceTimersByTimeAsync(0)
expect(openRelay.mock.calls.length).toBeGreaterThan(1)
supervisor.stop()
})
it('starts no dial in the background and races both paths on resume', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const deps = dependencies({ openDirect: unreachableDirect() })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
supervisor.setForeground(false)
await supervisor.start()
await vi.advanceTimersByTimeAsync(60_000)
expect(deps.openRelay).not.toHaveBeenCalled()
supervisor.setForeground(true)
await vi.advanceTimersByTimeAsync(0)
expect(deps.openRelay).toHaveBeenCalledOnce()
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
it('runs the resume probe against a relay that survived the background grace', async () => {
const logical = new FakeLogicalClient('connected', 'relay')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
supervisor.setForeground(false)
await vi.advanceTimersByTimeAsync(1_000)
expect(deps.openDirect).not.toHaveBeenCalled()
// A live relay is not a reconnect: the resume probe dials direct, but the
// promotion still has to earn its hysteresis streak.
supervisor.setForeground(true)
await vi.advanceTimersByTimeAsync(0)
expect(deps.openDirect).toHaveBeenCalledOnce()
expect(logical.getActivePath()).toBe('relay')
expect(deps.openRelay).not.toHaveBeenCalled()
supervisor.stop()
})
})
@@ -12,9 +12,7 @@ export type MobileEndpointSupervisorDependencies = {
relay: MobileRelayEndpoint,
credential: { token: string; version: number },
confirmReqId: string,
onHostCloseReason?: (reason: RelayHostCloseReason) => void,
// Gates the session's idle liveness sweep; a backgrounded app spends no probes.
isForeground?: () => boolean
onHostCloseReason?: (reason: RelayHostCloseReason) => void
) => MobileRelayRpcSession
resolveRelay: typeof resolveMobileRelayEndpoint
readBundle: (hostId: string) => Promise<MobileRelayCredentialBundle | null>
@@ -1,26 +1,13 @@
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor'
import { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis'
import {
dependencies,
FakeLogicalClient,
FakeRelaySession,
FakeSession,
host,
unreachableDirect
host
} from './mobile-endpoint-supervisor-test-fakes'
// A cell that authenticates and then answers the confirm for a different relay host
// — what a rehomed desktop produces. The session fails after the logical cutover.
function confirmRejectingRelaySession(logical: FakeLogicalClient): FakeRelaySession {
const session = new FakeRelaySession('connected', new Error('relay resume confirmation missing'))
session.whenResumeConfirmed = async () => {
session.publishState('disconnected')
logical.publishState('disconnected')
}
return session
}
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' }))
vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) }))
@@ -61,103 +48,4 @@ describe('mobile endpoint supervisor direct probe', () => {
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
it('recovers the relay at once while the probe is still dialing direct', async () => {
const logical = new FakeLogicalClient('connected', 'relay')
// A black-holed LAN endpoint: the dial sits unanswered for its whole 12s budget.
const direct = new FakeSession('connecting')
const openRelay = vi.fn(() => new FakeRelaySession('connected'))
const deps = dependencies({ openDirect: vi.fn(() => direct), openRelay })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
await vi.advanceTimersByTimeAsync(15_000)
expect(deps.openDirect).toHaveBeenCalledOnce()
logical.publishState('disconnected')
await vi.advanceTimersByTimeAsync(0)
// Why: the dial is a pure observation, so it no longer owns the operation
// mutex — recovery does not wait out the probe's budget.
expect(openRelay).toHaveBeenCalledOnce()
expect(logical.getState()).toBe('connected')
expect(logical.getActivePath()).toBe('relay')
supervisor.stop()
})
it('backs off a dial whose resume confirm fails after the cutover', async () => {
const recordMigration = vi.spyOn(MobileEndpointHysteresis.prototype, 'recordMigration')
const logical = new FakeLogicalClient('disconnected', 'lan')
const openRelay = vi.fn(() => confirmRejectingRelaySession(logical))
// No LAN to race: this is about the relay cadence after a confirm failure.
const deps = dependencies({
openRelay,
openDirect: unreachableDirect(),
randomBytes: () => new Uint8Array([128, 0])
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
// Two sockets per pass: a confirm mismatch reads as a stale cell assignment, so
// the existing director fallback re-resolves and dials the authoritative target.
expect(openRelay).toHaveBeenCalledTimes(2)
expect(logical.migrateTo).toHaveBeenCalledTimes(2)
// Why: `connected` is published at authentication, so the cutover happens before
// the confirm answers. A confirm that then fails must still book the shared
// cooldown — reporting it as an established dial redials in a tight loop.
await vi.advanceTimersByTimeAsync(0)
expect(openRelay).toHaveBeenCalledTimes(2)
// 250ms, then 500ms, then 1000ms: the streak grows instead of resetting, which
// it could not do if setActiveSession had run for this dying session.
await vi.advanceTimersByTimeAsync(249)
expect(openRelay).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1)
expect(openRelay).toHaveBeenCalledTimes(4)
await vi.advanceTimersByTimeAsync(250)
expect(openRelay).toHaveBeenCalledTimes(4)
await vi.advanceTimersByTimeAsync(250)
expect(openRelay).toHaveBeenCalledTimes(6)
await vi.advanceTimersByTimeAsync(999)
expect(openRelay).toHaveBeenCalledTimes(6)
await vi.advanceTimersByTimeAsync(1)
expect(openRelay).toHaveBeenCalledTimes(8)
// No session whose confirm failed is ever booked as a migration.
expect(recordMigration).not.toHaveBeenCalled()
supervisor.stop()
})
it('replays a relay recovery that landed while the direct cutover owned the mutex', async () => {
const logical = new FakeLogicalClient('connected', 'relay')
const openRelay = vi.fn(() => new FakeRelaySession('connected'))
const deps = dependencies({ openDirect: vi.fn(() => new FakeSession('connected')), openRelay })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
let release!: () => void
const cutover = new Promise<void>((resolve) => {
release = resolve
})
// The candidate loses the cutover, so the logical client stays on the relay path.
logical.migrateTo.mockImplementationOnce(async (candidate) => {
await cutover
candidate.close()
})
// Three authenticated probes plus the observation and dwell windows.
await vi.advanceTimersByTimeAsync(60_000)
expect(logical.migrateTo).toHaveBeenCalledOnce()
logical.publishState('disconnected')
await vi.advanceTimersByTimeAsync(0)
expect(openRelay).not.toHaveBeenCalled()
release()
await vi.advanceTimersByTimeAsync(0)
// The queued request is replayed by afterProbe, never dropped.
expect(openRelay).toHaveBeenCalledOnce()
expect(logical.getState()).toBe('connected')
supervisor.stop()
})
})
@@ -65,7 +65,6 @@ export class FakeRelaySession extends FakeSession implements MobileRelayRpcSessi
renewed: this.renewed,
resumeExpiresAt: this.resumeExpiry
})
whenResumeConfirmed = () => Promise.resolve()
getFailure = () => this.failure
}
@@ -204,15 +203,6 @@ export const bundle: MobileRelayCredentialBundle = {
}
}
// Why: LAN unreachable. A throwing open beats a never-answering socket — the
// direct dial resolves synchronously, so a relay-only test leaves no probe timer
// behind and the reconnect race has exactly one runner.
export function unreachableDirect(): MobileEndpointSupervisorDependencies['openDirect'] {
return vi.fn(() => {
throw new Error('direct endpoint unreachable')
})
}
export function dependencies(
overrides: Partial<MobileEndpointSupervisorDependencies> = {}
): MobileEndpointSupervisorDependencies {
@@ -10,8 +10,7 @@ import {
FakeSession,
host,
mockCredentialRotation,
relay,
unreachableDirect
relay
} from './mobile-endpoint-supervisor-test-fakes'
import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor'
@@ -49,17 +48,19 @@ describe('mobile endpoint supervisor', () => {
supervisor.stop()
})
it('fails over while the direct retry loop is still dialing', async () => {
it('fails over when the direct retry loop publishes reconnecting', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const deps = dependencies({ openDirect: unreachableDirect() })
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
// An unfinished direct dial no longer holds relay back, so the failover has
// already happened by the time the direct client gives up.
logical.publishState('handshaking')
await vi.advanceTimersByTimeAsync(0)
expect(deps.openRelay).toHaveBeenCalledOnce()
expect(deps.openRelay).not.toHaveBeenCalled()
supervisor.setForeground(true)
await vi.advanceTimersByTimeAsync(0)
expect(deps.openRelay).not.toHaveBeenCalled()
logical.publishState('reconnecting')
await vi.waitFor(() => expect(logical.getActivePath()).toBe('relay'))
@@ -147,12 +148,11 @@ describe('mobile endpoint supervisor', () => {
expect(logical.getPendingPath()).toBeNull()
})
it('keeps retrying relay on its own cadence while a direct handshake drags on', async () => {
it('does not spend a queued relay retry while direct authentication is progressing', async () => {
const logical = new FakeLogicalClient('disconnected', 'lan')
const openRelay = vi.fn(() => new FakeRelaySession('disconnected', new RelayOuterError(4408)))
const deps = dependencies({
openRelay,
openDirect: unreachableDirect(),
randomBytes: () => new Uint8Array([128, 0])
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
@@ -160,13 +160,12 @@ describe('mobile endpoint supervisor', () => {
await supervisor.start()
expect(openRelay).toHaveBeenCalledOnce()
// A direct dial that reaches 'handshaking' and stays there used to park relay
// recovery until it gave up; the retry now runs on the failure cadence alone.
logical.publishState('handshaking')
await vi.advanceTimersByTimeAsync(249)
await vi.advanceTimersByTimeAsync(250)
expect(openRelay).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(1)
expect(openRelay).toHaveBeenCalledTimes(2)
logical.publishState('disconnected')
await vi.waitFor(() => expect(openRelay).toHaveBeenCalledTimes(2))
supervisor.stop()
})
@@ -190,7 +189,6 @@ describe('mobile endpoint supervisor', () => {
resolved,
expect.any(Object),
expect.any(String),
expect.any(Function),
expect.any(Function)
)
expect(deps.saveHost).toHaveBeenCalledWith(
@@ -245,7 +243,6 @@ describe('mobile endpoint supervisor', () => {
const deps = dependencies({
openRelay,
onLog,
openDirect: unreachableDirect(),
randomBytes: () => new Uint8Array([128, 0])
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
@@ -290,7 +287,6 @@ describe('mobile endpoint supervisor', () => {
const openRelay = vi.fn(() => new FakeRelaySession('connected', new RelayOuterError(4408)))
const deps = dependencies({
openRelay,
openDirect: unreachableDirect(),
randomBytes: () => new Uint8Array([128, 0])
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
@@ -473,7 +469,6 @@ describe('mobile endpoint supervisor', () => {
.mockImplementation(() => new FakeRelaySession('connected'))
const deps = dependencies({
openRelay,
openDirect: unreachableDirect(),
writeBundle: vi.fn(() => writePending),
randomBytes: () => new Uint8Array([128, 0])
})
@@ -567,7 +562,6 @@ describe('mobile endpoint supervisor', () => {
relay,
expect.objectContaining({ version: 3 }),
expect.any(String),
expect.any(Function),
expect.any(Function)
)
supervisor.stop()
@@ -616,7 +610,6 @@ describe('mobile endpoint supervisor', () => {
relay,
expect.objectContaining({ version: 3 }),
expect.any(String),
expect.any(Function),
expect.any(Function)
)
supervisor.stop()
@@ -812,7 +805,6 @@ describe('mobile endpoint supervisor', () => {
.mockImplementation(() => new FakeRelaySession('connected'))
const deps = dependencies({
openRelay,
openDirect: unreachableDirect(),
randomBytes: () => new Uint8Array([128, 0])
})
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
@@ -849,6 +841,43 @@ describe('mobile endpoint supervisor', () => {
supervisor.stop()
})
it('races a relay dial when the direct dial stalls unauthenticated', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
await vi.advanceTimersByTimeAsync(2_499)
expect(deps.openRelay).not.toHaveBeenCalled()
expect(logical.getState()).toBe('connecting')
// The direct dial never authenticates; the relay wins the race through migrateTo.
await vi.advanceTimersByTimeAsync(1)
await vi.waitFor(() => expect(logical.getActivePath()).toBe('relay'))
expect(logical.migrateTo).toHaveBeenCalledWith(
expect.any(FakeRelaySession),
'relay',
undefined,
expect.any(Function)
)
supervisor.stop()
})
it('cancels the grace race when the direct dial authenticates first', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
logical.publishState('connected')
expect(vi.getTimerCount()).toBe(0)
await vi.advanceTimersByTimeAsync(5_000)
expect(deps.openRelay).not.toHaveBeenCalled()
expect(logical.getActivePath()).toBe('lan')
supervisor.stop()
})
it('never races a relay dial against a desktop with no relay endpoint', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const deps = dependencies()
@@ -861,4 +890,39 @@ describe('mobile endpoint supervisor', () => {
expect(vi.getTimerCount()).toBe(0)
supervisor.stop()
})
it('drops the pending grace race when the phone backgrounds', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const deps = dependencies()
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
supervisor.setForeground(false)
await vi.advanceTimersByTimeAsync(5_000)
expect(deps.openRelay).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
supervisor.stop()
})
it('books the shared cooldown when the grace race loses its dial', async () => {
const logical = new FakeLogicalClient('connecting', 'lan')
const openRelay = vi.fn(() => new FakeRelaySession('disconnected', new RelayOuterError(4408)))
const deps = dependencies({ openRelay, randomBytes: () => new Uint8Array([128, 0]) })
const supervisor = new MobileEndpointSupervisor(logical, host, deps)
await supervisor.start()
await vi.advanceTimersByTimeAsync(2_500)
expect(openRelay).toHaveBeenCalledOnce()
// The armed retry runs unforced, so it yields to the still-progressing direct
// dial: the race gets one attempt, never a socket-per-cooldown loop.
await vi.advanceTimersByTimeAsync(60_000)
expect(openRelay).toHaveBeenCalledOnce()
// Direct finally gives up: ordinary recovery still owns the failure.
logical.publishState('reconnecting')
await vi.waitFor(() => expect(openRelay).toHaveBeenCalledTimes(2))
supervisor.stop()
})
})
@@ -10,11 +10,13 @@ import {
} from './mobile-endpoint-supervisor-support'
import { selectDialableRelayCredentials } from './mobile-relay-credential-selection'
import { createRelayRecoveryLog, type RelayRecoveryLog } from './mobile-relay-recovery-log'
import { MobileRelayCredentialRefresh } from './mobile-relay-credential-refresh'
import {
mobileRelayCredentialNeedsRotation,
rotateMobileRelayCredential
} from './mobile-relay-credential-rotation'
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import { MobileEndpointNudgeRouter } from './mobile-endpoint-nudge-router'
import { RelayRecoveryIntentQueue } from './relay-recovery-intent-queue'
import { RelayLostRaceDamper } from './mobile-relay-lost-race-damper'
import { MobileRelayDirectGraceTimer } from './mobile-relay-direct-grace-timer'
import { MobileRelaySessionEstablisher } from './mobile-relay-session-establisher'
import * as recoveryPresentation from './mobile-relay-recovery-presentation'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
@@ -36,9 +38,9 @@ export class MobileEndpointSupervisor {
private bundle: MobileRelayCredentialBundle | null = null
private stopped = false
private operationInFlight = false
private readonly pending = new RelayRecoveryIntentQueue()
private pendingReplace = false
private readonly nudgeRouter: MobileEndpointNudgeRouter
private readonly credentialRefresh: MobileRelayCredentialRefresh
private credentialRotationInFlight = false
private relayRotationPending = false
private unsubscribeState: (() => void) | null = null
private readonly hysteresis: MobileEndpointHysteresis
@@ -46,7 +48,7 @@ export class MobileEndpointSupervisor {
private readonly leaseRotation: RelayLeaseRotationTimer
private readonly logRelay: RelayRecoveryLog
private readonly directProbe: DirectReturnProbe
private readonly lostRace: RelayLostRaceDamper
private readonly directGrace: MobileRelayDirectGraceTimer
private readonly backgroundGrace: MobileRelayBackgroundGrace
private readonly sessionEstablisher: MobileRelaySessionEstablisher
@@ -62,27 +64,6 @@ export class MobileEndpointSupervisor {
minimumDwellMs: MINIMUM_DWELL_MS
})
this.logRelay = createRelayRecoveryLog(dependencies.now, dependencies.onLog)
this.credentialRefresh = new MobileRelayCredentialRefresh({
logical,
now: dependencies.now,
randomBytes: dependencies.randomBytes,
writeBundle: dependencies.writeBundle,
bundle: () => this.bundle,
adoptBundle: (bundle) => (this.bundle = bundle),
persistResolvedRelay: async (resolved) => {
this.host = await persistRelayHost(this.host, resolved, dependencies.saveHost)
},
isStopped: () => this.stopped,
completeRefresh: () => this.relayReconnect.completeCredentialRefresh(),
// Why relayDialAllowed and not the reconnect controller's needsRecovery: a
// refresh that lands while direct is still dialing must start the relay race,
// not wait on the direct retry loop as the pre-race rotation path did.
onRefreshed: () => {
if (this.isActive() && this.relayDialAllowed(false)) {
void this.recoverRelay()
}
}
})
this.relayReconnect = new RelayReconnectController(dependencies, this.recoverRelay.bind(this))
this.relayReconnect.reportRecoveryTo(logical)
this.nudgeRouter = new MobileEndpointNudgeRouter({
@@ -92,17 +73,18 @@ export class MobileEndpointSupervisor {
isForeground: () => this.backgroundGrace.isForeground(),
setForeground: (foreground) => this.setForeground(foreground),
replaceRelay: () => void this.recoverRelay(true, true),
scheduleDirectProbe: () => this.directProbe.probeNow()
})
this.lostRace = new RelayLostRaceDamper(dependencies, () => {
// Why: the window closing is the moment to re-ask. If direct came back the
// guards below no-op; if it never did, relay recovery resumes on its own.
void this.recoverRelay()
scheduleDirectProbe: () => this.directProbe.schedule(0)
})
this.leaseRotation = new RelayLeaseRotationTimer(dependencies, () => {
this.relayRotationPending = true
void this.recoverRelay(true)
})
// Why: the race owns recovery exactly like a network-change replacement — its
// failure must book the shared cooldown. recoverRelay's own guards already
// cover stopped/background/no-relay, so the timer needs no scope check.
this.directGrace = new MobileRelayDirectGraceTimer(dependencies, logical, () => {
void this.recoverRelay(true, true)
})
this.sessionEstablisher = new MobileRelaySessionEstablisher({
logical,
controller: this.relayReconnect,
@@ -120,7 +102,6 @@ export class MobileEndpointSupervisor {
adoptBundle: (bundle) => (this.bundle = bundle),
recordMigration: () => {
this.relayRotationPending = false
this.lostRace.reset()
this.hysteresis.recordMigration(dependencies.now())
logRelayConnected(this.logRelay)
},
@@ -137,22 +118,21 @@ export class MobileEndpointSupervisor {
hysteresis: this.hysteresis,
host: () => this.host,
canSchedule: () => this.isActive() && this.logical.getActivePath() === 'relay',
canDial: () => this.isActive(),
canAttempt: () => this.isActive() && !this.operationInFlight,
// Why: a reconnect has no session to protect, so the first authenticated
// socket wins it outright — hysteresis only arbitrates against a live relay.
adoptsOutright: () => this.isActive() && this.logical.getState() !== 'connected',
beginOperation: () => (this.operationInFlight = true),
migrate: (client, path, abort) => this.logical.migrateTo(client, path, undefined, abort),
onDirectMigrated: async () => {
this.leaseRotation.clear()
this.relayRotationPending = false
await this.credentialRefresh.run(this.relayReconnect.resetForDirectConnection())
await this.rotateCredentialIfNeeded(this.relayReconnect.resetForDirectConnection())
},
afterProbe: () => {
this.operationInFlight = false
const queued = this.pending.takeRecovery() || this.pending.hasReplacement()
if (queued || this.relayRotationPending || this.logical.getState() !== 'connected') {
if (
this.pendingReplace ||
this.relayRotationPending ||
this.logical.getState() !== 'connected'
) {
void this.recoverRelay(this.relayRotationPending)
}
}
@@ -162,7 +142,8 @@ export class MobileEndpointSupervisor {
logical,
this.relayReconnect,
this.leaseRotation,
this.directProbe
this.directProbe,
this.directGrace
)
}
@@ -178,18 +159,12 @@ export class MobileEndpointSupervisor {
}
this.unsubscribeState = this.logical.onStateChange((state) => {
if (state === 'connected') {
this.lostRace.noteDirectRestored()
this.directGrace.clear()
if (this.logical.getActivePath() !== 'relay') {
void this.credentialRefresh.run(this.relayReconnect.resetForDirectConnection())
void this.rotateCredentialIfNeeded(this.relayReconnect.resetForDirectConnection())
}
this.directProbe.schedule()
return
}
// Why: the path that won the last race is gone, so the window it earned
// must not be served out — a blip that became an outage would otherwise
// strand the user for the whole window with nothing else scheduled.
this.lostRace.clampForLostDirect()
if (!this.backgroundGrace.isForeground()) {
} else if (!this.backgroundGrace.isForeground()) {
this.backgroundGrace.handleStateFailure()
} else {
// Why: the direct client enters reconnecting after its first failed
@@ -199,21 +174,17 @@ export class MobileEndpointSupervisor {
logRelayDialFailure(this.logRelay, relayFailure, 'active-session')
}
})
if (this.logical.getState() === 'connected') {
if (this.relayReconnect.needsRecovery(this.logical.getState())) {
// Why: the first direct dial can fail while encrypted relay credentials
// are still loading, before the supervisor subscribes to state changes.
await this.recoverRelay()
} else {
this.directProbe.schedule()
return
this.directGrace.arm()
}
// Why: nothing is live, so both paths dial from t=0. This also covers the
// first direct dial failing while encrypted relay credentials are still
// loading, before the supervisor subscribes to state changes.
await this.recoverRelay()
}
setForeground(foreground: boolean): void {
if (foreground) {
// Why: a resume is the user waiting on the screen, never a blip.
this.lostRace.reset()
}
this.backgroundGrace.setForeground(foreground)
if (foreground && this.relayRotationPending) {
void this.recoverRelay(true)
@@ -224,8 +195,6 @@ export class MobileEndpointSupervisor {
stop(): void {
this.stopped = true
this.pending.clear()
this.lostRace.reset()
this.directProbe.stop()
this.unsubscribeState?.()
this.unsubscribeState = null
@@ -236,15 +205,8 @@ export class MobileEndpointSupervisor {
return !this.stopped && this.backgroundGrace.isForeground()
}
// Why: the relay dial yields to a live session and to nothing else. An
// unfinished direct dial ('connecting'/'handshaking') used to block it behind a
// fixed head start, which bought an off-LAN phone nothing on every reconnect.
private relayDialAllowed(forceReplacement: boolean): boolean {
return forceReplacement || this.logical.getState() !== 'connected'
}
// forceReplacement: dial past the "a live session already holds the client"
// guard — a lease rotation or a network-change replacement.
// forceReplacement: dial past the "direct still looks live" guard — a lease
// rotation, a network-change replacement, or the happy-eyeballs grace race.
// ownsRecovery: this dial is the connection's only hope, so a failure books the
// shared cooldown and any session left stale-'connected' by a half-open socket
// comes down; lease rotation clears it because armRetry owns its own retry.
@@ -252,30 +214,20 @@ export class MobileEndpointSupervisor {
if (!this.isActive() || !this.host.relay) {
return
}
if (this.logical.getState() !== 'connected') {
// Why: both paths race from t=0. This no-ops unless relay owns the logical
// client — when direct owns it, its own session is already redialing.
this.directProbe.probeNow()
}
if (this.operationInFlight) {
// Why: a direct cutover or a slow post-migration write can own the mutex when
// a handoff lands. Every request is queued — an owning replacement keeps its
// force/owns intent, anything else replays as a plain recovery — so the
// holder's release replays it instead of dropping it.
this.pending.queue(forceReplacement, ownsRecovery)
// Why: a 12s direct probe can own the mutex when a network handoff lands;
// afterProbe replays the queued replacement so the signal is never lost.
this.pendingReplace ||= forceReplacement && ownsRecovery
return
}
if (this.pending.takeReplacement()) {
if (this.pendingReplace) {
this.pendingReplace = false
forceReplacement = true
ownsRecovery = true
}
if (!this.relayDialAllowed(forceReplacement)) {
return
}
if (!forceReplacement && this.lostRace.suppresses()) {
// Why: the previous race was lost to direct and booked nothing, so only
// this damper stands between a flapping LAN and a cell socket per blip.
this.logRelay('relay race damped after losing to direct')
// Why: connecting/handshaking is live direct progress; an unforced relay dial
// would race it before the grace timer has given direct its head start.
if (!forceReplacement && !this.relayReconnect.needsRecovery(this.logical.getState())) {
return
}
// Why: revival and lease timers can overlap resume failures; one shared cooldown
@@ -284,7 +236,7 @@ export class MobileEndpointSupervisor {
if (ownsRecovery) {
// Why: never tear down a session no dial has disproven — the intent stays
// queued so the armed retry runs forced once the cooldown lapses.
this.pending.holdReplacement()
this.pendingReplace = true
}
this.logRelay('recovery deferred by cooldown or gate')
return
@@ -308,18 +260,20 @@ export class MobileEndpointSupervisor {
if (ownsRecovery) {
// Why: no dial happened — keep the session and the intent; the reprobe
// runs forced and replaces make-before-break once a credential exists.
this.pending.holdReplacement()
this.pendingReplace = true
}
return
}
if (!this.isActive() || !this.relayDialAllowed(forceReplacement)) {
const recoveryNeeded =
forceReplacement || this.relayReconnect.needsRecovery(this.logical.getState())
if (!this.isActive() || !recoveryNeeded) {
return
}
this.logical.setRecoveryPath('relay', this.relayReconnect.getFailureCount())
const dialed = await this.sessionEstablisher.dialEligible(selection.credentials)
if (dialed.outcome === 'established') {
// Why: a fresh socket satisfies any replacement intent queued mid-dial.
this.pending.clearReplacement()
this.pendingReplace = false
retryAfterOperation = this.logical.getState() !== 'connected'
return
}
@@ -327,12 +281,6 @@ export class MobileEndpointSupervisor {
this.logical.setRecoveryPath(null)
// Why: direct won the race or the supervisor went inactive — not a
// failure; booking backoff would delay the next genuine recovery.
// Why: only an unforced race can be blip-driven. A forced replacement
// that stands down is a lease rotation or a network change reconsidered,
// not a LAN that flapped, so it must not grow the streak.
if (!forceReplacement && this.isActive() && this.logical.getState() === 'connected') {
this.lostRace.record()
}
return
}
// Why: cleanup may happen while a relay dial is awaiting the network;
@@ -345,12 +293,52 @@ export class MobileEndpointSupervisor {
}
} finally {
this.operationInFlight = false
const queued = this.pending.takeRecovery()
if (forceReplacement && this.relayRotationPending && this.isActive()) {
this.leaseRotation.armRetry(this.relayReconnect.retryDelayMs(5000))
}
// Why: the active relay can drop while migration follow-up still owns the mutex.
if ((retryAfterOperation || queued) && this.isActive()) {
if (retryAfterOperation && this.isActive()) {
void this.recoverRelay()
}
}
}
private async rotateCredentialIfNeeded(force = false): Promise<void> {
if (
this.stopped ||
this.credentialRotationInFlight ||
!this.bundle ||
this.logical.getActivePath() === 'relay' ||
(!force && !mobileRelayCredentialNeedsRotation(this.bundle, this.dependencies.now()))
) {
return
}
this.credentialRotationInFlight = true
let credentialRefreshed = false
try {
const result = await rotateMobileRelayCredential({
client: this.logical,
bundle: this.bundle,
writeBundle: this.dependencies.writeBundle,
randomBytes: this.dependencies.randomBytes
})
this.bundle = result.bundle
// Why: a scheduled rotation can finish after the old credential enters the rejection gate.
credentialRefreshed = true
this.host = await persistRelayHost(this.host, result.relay, this.dependencies.saveHost)
} catch {
// Why: pending material remains durable; the next authenticated direct
// opportunity must reconcile it before creating another install key.
} finally {
if (credentialRefreshed) {
this.relayReconnect.completeCredentialRefresh()
}
this.credentialRotationInFlight = false
if (
credentialRefreshed &&
this.isActive() &&
this.relayReconnect.needsRecovery(this.logical.getState())
) {
void this.recoverRelay()
}
}
@@ -51,7 +51,8 @@ export class MobileRelayBackgroundGraceTimer {
}
type Clearable = { clear(): void }
type DirectProbe = Clearable & { schedule(delayMs?: number): void; probeNow(): void }
type DirectProbe = Clearable & { schedule(delayMs?: number): void }
type DirectGrace = Clearable & { arm(): void }
export class MobileRelayBackgroundGrace {
private foregroundState = true
@@ -63,7 +64,8 @@ export class MobileRelayBackgroundGrace {
private readonly logical: StableLogicalRpcClient,
private readonly relayReconnect: RelayReconnectController,
private readonly leaseRotation: Clearable,
private readonly directProbe: DirectProbe
private readonly directProbe: DirectProbe,
private readonly directGrace: DirectGrace
) {
this.timer = new MobileRelayBackgroundGraceTimer(dependencies, () => this.suspendRelay())
}
@@ -78,9 +80,8 @@ export class MobileRelayBackgroundGrace {
if (foreground) {
this.foreground()
this.relayReconnect.handleForeground(this.logical, wasForeground)
// Why: a resume dials direct alongside the relay recovery handleForeground
// just triggered; a pending probe tick must not delay this one.
this.directProbe.probeNow()
this.directProbe.schedule(0)
this.directGrace.arm()
} else if (wasForeground) {
this.background()
}
@@ -91,6 +92,7 @@ export class MobileRelayBackgroundGrace {
this.directProbe.clear()
this.relayReconnect.clear()
this.leaseRotation.clear()
this.directGrace.clear()
this.logical.setRecoveryPath(null)
}
@@ -106,6 +108,7 @@ export class MobileRelayBackgroundGrace {
const retainsRelay =
this.logical.getActivePath() === 'relay' && this.logical.getState() === 'connected'
this.directProbe.clear()
this.directGrace.clear()
this.logical.setRecoveryPath(null)
if (retainsRelay) {
this.timer.arm()
@@ -1,71 +0,0 @@
import {
mobileRelayCredentialNeedsRotation,
rotateMobileRelayCredential
} from './mobile-relay-credential-rotation'
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract'
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
// Mints a replacement relay credential over a live direct connection. That is the
// only moment it can happen: the replacement comes from an authenticated RPC, and
// a phone whose credential the relay has rejected cannot carry one over relay.
export class MobileRelayCredentialRefresh {
private inFlight = false
constructor(
private readonly args: {
logical: StableLogicalRpcClient
now: () => number
randomBytes: (length: number) => Uint8Array
writeBundle: (bundle: MobileRelayCredentialBundle) => Promise<void>
bundle: () => MobileRelayCredentialBundle | null
adoptBundle: (bundle: MobileRelayCredentialBundle) => void
persistResolvedRelay: (resolved: MobileRelayEndpoint) => Promise<void>
isStopped: () => boolean
// Lifts the controller's fresh-credential gate once the replacement is durable.
completeRefresh: () => void
onRefreshed: () => void
}
) {}
// force: the caller already knows the current credential is rejected, so the
// age check would only delay a rotation the relay path is blocked on.
async run(force: boolean): Promise<void> {
const { args } = this
const bundle = args.bundle()
if (
args.isStopped() ||
this.inFlight ||
!bundle ||
args.logical.getActivePath() === 'relay' ||
(!force && !mobileRelayCredentialNeedsRotation(bundle, args.now()))
) {
return
}
this.inFlight = true
let refreshed = false
try {
const result = await rotateMobileRelayCredential({
client: args.logical,
bundle,
writeBundle: args.writeBundle,
randomBytes: args.randomBytes
})
args.adoptBundle(result.bundle)
// Why: a scheduled rotation can finish after the old credential enters the rejection gate.
refreshed = true
await args.persistResolvedRelay(result.relay)
} catch {
// Why: pending material remains durable; the next authenticated direct
// opportunity must reconcile it before creating another install key.
} finally {
if (refreshed) {
args.completeRefresh()
}
this.inFlight = false
if (refreshed) {
args.onRefreshed()
}
}
}
}
@@ -142,15 +142,11 @@ export async function persistResumeConfirmation(args: {
session: {
getResumeConfirmation(): DeviceResumeConfirmed | null
getResumeExpiresAt(): number | null
whenResumeConfirmed(): Promise<void>
}
bundle: MobileRelayCredentialBundle
usedCredentialVersion: number
writeBundle: (bundle: MobileRelayCredentialBundle) => Promise<void>
}): Promise<{ bundle: MobileRelayCredentialBundle; leaseExpiry: number | null }> {
// Why: 'connected' is published at E2EE authentication now, so the confirm round
// trip can still be in flight here — its answer is what makes the bundle durable.
await args.session.whenResumeConfirmed()
const confirmation = args.session.getResumeConfirmation()
let bundle = args.bundle
if (confirmation) {
@@ -0,0 +1,48 @@
import type { StableLogicalRpcClient } from './stable-logical-rpc-client'
// Why: on a black-holed LAN endpoint the direct dial sits in 'connecting' for the
// whole 12s connect timeout (rpc-client CONNECT_TIMEOUT_MS), and relay recovery
// cannot even start meanwhile because connecting/handshaking count as live direct
// progress. Happy eyeballs: give direct this much of a head start, then race the
// relay dial — migrateTo hands the logical client to whichever authenticates first.
const DIRECT_DIAL_GRACE_MS = 2500
type DirectGraceTimerDependencies = {
setTimer: typeof setTimeout
clearTimer: typeof clearTimeout
}
// One-shot timer that releases the relay dial when the direct dial has not
// authenticated within the grace. The supervisor arms it at start and on
// foreground restore, and clears it on connect, background, and stop.
export class MobileRelayDirectGraceTimer {
private timer: ReturnType<typeof setTimeout> | null = null
constructor(
private readonly dependencies: DirectGraceTimerDependencies,
private readonly logical: StableLogicalRpcClient,
private readonly dialRelay: () => void
) {}
// No-op unless the direct dial is still unauthenticated, so a healthy LAN and
// an already-failed direct path (recovery owns that) never open a relay socket.
arm(): void {
const state = this.logical.getState()
if (this.timer || (state !== 'connecting' && state !== 'handshaking')) {
return
}
this.timer = this.dependencies.setTimer(() => {
this.timer = null
if (this.logical.getState() !== 'connected') {
this.dialRelay()
}
}, DIRECT_DIAL_GRACE_MS)
}
clear(): void {
if (this.timer) {
this.dependencies.clearTimer(this.timer)
this.timer = null
}
}
}
@@ -195,38 +195,6 @@ describe('MobileRelayE2eeLink', () => {
}
})
it('writes no e2ee frame when withdrawn between relay-auth and the hello', () => {
const socket = new ThrowingSocket()
const sent: string[] = []
socket.send.mockImplementation((frame: string) => {
sent.push(frame)
})
const link = new MobileRelayE2eeLink({
endpoint: {
cellUrl: 'https://relay-c1.onorca.dev',
relayHostId: 'AbCdEf0123_-xyZ9'
},
credential: 'credential',
expectedCredentialKind: 'resume',
deviceToken: 'device-token',
desktopPublicKeyB64: 'desktop-key',
onAuthenticated: vi.fn(),
onText: vi.fn(),
onBinary: vi.fn(),
onError: vi.fn(),
createSocket: () => socket as unknown as WebSocket
})
socket.onopen?.()
// The window a lost reconnect race is withdrawn in: the cell has the outer
// credential but has not answered, so no key exchange has started.
link.close()
expect(sent).toHaveLength(1)
expect(JSON.parse(sent[0]!)).toMatchObject({ type: 'relay-auth' })
expect(socket.close).toHaveBeenCalledOnce()
})
it('cancels the missing-close timer when explicitly closed', async () => {
vi.useFakeTimers()
try {
@@ -1,99 +0,0 @@
// Paces the direct-vs-relay reconnect race after the relay dial loses it. A lost
// race books no failure — that is deliberate, since losing is the good outcome —
// so nothing else stops a flapping LAN from opening one cell socket per blip, and
// the relay's per-host rate limiter would eventually turn a benign race into a
// booked relay failure. This is not backoff: it never delays the failure path,
// and its window lapse re-enters recovery so a LAN that dies mid-window still
// reaches relay on its own.
const INITIAL_DAMP_MS = 2_000
const MAX_DAMP_MS = 30_000
// How long a lost direct path is given to prove it was only a blip. Long enough
// to absorb one that drops and comes straight back, short enough that a real
// outage never reads as the connection being stuck.
const LOST_DIRECT_FLOOR_MS = 250
type LostRaceDamperDependencies = {
now: () => number
setTimer: typeof setTimeout
clearTimer: typeof clearTimeout
}
export class RelayLostRaceDamper {
private windowMs = 0
private suppressUntil = 0
// The window held aside while a lost direct path proves whether it was a blip.
private pendingUntil = 0
private timer: ReturnType<typeof setTimeout> | null = null
constructor(
private readonly dependencies: LostRaceDamperDependencies,
private readonly onWindowLapse: () => void
) {}
suppresses(): boolean {
return this.dependencies.now() < this.suppressUntil
}
// Each successive loss inside the window doubles it, so a LAN that flaps all
// afternoon settles at one race per 30s instead of one per blip.
record(): void {
this.windowMs = this.windowMs === 0 ? INITIAL_DAMP_MS : Math.min(this.windowMs * 2, MAX_DAMP_MS)
this.suppressUntil = this.dependencies.now() + this.windowMs
this.arm(this.windowMs)
}
// The direct path that won the last race is gone. Collapse the wait to the
// floor, so an outage is never held off for the window a blip earned, and keep
// the rest of that window aside rather than spending it: one blip must not buy
// a flapping LAN a free pass on every race that follows.
clampForLostDirect(): void {
const floorAt = this.dependencies.now() + LOST_DIRECT_FLOOR_MS
if (this.suppressUntil === 0 || this.pendingUntil !== 0 || this.suppressUntil <= floorAt) {
return
}
this.pendingUntil = this.suppressUntil
this.suppressUntil = floorAt
this.arm(LOST_DIRECT_FLOOR_MS)
}
// Direct came back inside the floor, so that was the blip this exists for and
// the rest of the window still has to run.
noteDirectRestored(): void {
if (this.pendingUntil === 0) {
return
}
this.suppressUntil = this.pendingUntil
this.pendingUntil = 0
this.arm(Math.max(0, this.suppressUntil - this.dependencies.now()))
}
// A relay dial that wins, or the user bringing the app back, ends the streak:
// neither is a blip, and a resume must never wait out a damper window. A relay
// failure deliberately does not — it is not evidence the LAN stopped flapping,
// and its own cooldown runs after this window rather than on top of it, since
// a damped attempt never reaches the dial that would book one.
reset(): void {
this.windowMs = 0
this.suppressUntil = 0
this.pendingUntil = 0
this.clearTimer()
}
private arm(delayMs: number): void {
this.clearTimer()
this.timer = this.dependencies.setTimer(() => {
this.timer = null
// Why: the floor lapsed with direct still gone, so it was an outage and the
// window held aside is void — a later return must not resurrect it.
this.pendingUntil = 0
this.onWindowLapse()
}, delayMs)
}
private clearTimer(): void {
if (this.timer) {
this.dependencies.clearTimer(this.timer)
this.timer = null
}
}
}
@@ -32,10 +32,7 @@ const relay = {
e2eeFraming: 2 as const
}
async function authenticateSession(
onLog?: ConnectionLogSink,
isForeground: () => boolean = () => true
) {
async function authenticateSession(onLog?: ConnectionLogSink) {
const session = connectMobileRelayRpcSession({
relay,
resumeToken: 'resume-secret',
@@ -44,7 +41,6 @@ async function authenticateSession(
deviceToken: 'device-token',
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
requestTimeoutMs: 30_000,
isForeground,
onLog
})
fakes.linkOptions!.onHello({
@@ -56,12 +52,12 @@ async function authenticateSession(
acceptedAs: 'current',
resumeExpiresAt: Date.now() + 300_000
})
// Authentication publishes 'connected' and puts both advisories on the wire.
fakes.linkOptions!.onAuthenticated()
const [confirmation, capabilities] = sentRequests()
await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledOnce())
const confirmation = sentRequests()[0]!
fakes.linkOptions!.onText(
JSON.stringify({
id: confirmation!.id,
id: confirmation.id,
ok: true,
result: {
v: 1,
@@ -78,16 +74,17 @@ async function authenticateSession(
_meta: { runtimeId: 'runtime-1' }
})
)
await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledTimes(2))
const capabilities = sentRequests()[1]!
fakes.linkOptions!.onText(
JSON.stringify({
id: capabilities!.id,
id: capabilities.id,
ok: true,
result: {},
_meta: { runtimeId: 'runtime-1' }
})
)
await session.whenResumeConfirmed()
expect(session.getState()).toBe('connected')
await vi.waitFor(() => expect(session.getState()).toBe('connected'))
fakes.sendText.mockClear()
return session
}
@@ -98,13 +95,6 @@ function sentRequests(): Array<{ id: string; method: string }> {
)
}
function answerProbe(): void {
const probe = sentRequests().at(-1)!
fakes.linkOptions!.onText(
JSON.stringify({ id: probe.id, ok: true, result: {}, _meta: { runtimeId: 'r1' } })
)
}
describe('mobile relay RPC session liveness', () => {
beforeEach(() => {
vi.useFakeTimers()
@@ -114,82 +104,16 @@ describe('mobile relay RPC session liveness', () => {
})
afterEach(() => vi.useRealTimers())
it('sweeps an idle foregrounded relay once per idle interval', async () => {
it('sends no periodic traffic while an authenticated relay is idle', async () => {
const session = await authenticateSession()
await vi.advanceTimersByTimeAsync(24_999)
expect(fakes.sendText).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
expect(sentRequests().map(({ method }) => method)).toEqual(['status.get'])
answerProbe()
// Inbound traffic re-arms the sweep rather than stacking probes on it.
await vi.advanceTimersByTimeAsync(24_999)
expect(fakes.sendText).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(1)
expect(fakes.sendText).toHaveBeenCalledTimes(2)
expect(session.getState()).toBe('connected')
session.close()
})
it('spends no idle probe while the app is backgrounded', async () => {
let foreground = true
const session = await authenticateSession(undefined, () => foreground)
foreground = false
await vi.advanceTimersByTimeAsync(120_000)
await vi.advanceTimersByTimeAsync(60_000)
expect(fakes.sendText).not.toHaveBeenCalled()
expect(session.getState()).toBe('connected')
// The resume that follows probes at once instead of waiting out the sweep.
foreground = true
session.notifyForeground('app-resume')
expect(sentRequests().map(({ method }) => method)).toEqual(['status.get'])
session.close()
})
it('terminates a relay whose socket died in the background on two 2s resume misses', async () => {
const onLog = vi.fn<ConnectionLogSink>()
const session = await authenticateSession(onLog)
session.notifyForeground('app-resume')
expect(fakes.sendText).toHaveBeenCalledOnce()
// Why: the first frame after a resume rides a cold radio, so one slow answer is
// tolerated — but the verdict still lands at 4s instead of the old 8s.
await vi.advanceTimersByTimeAsync(2_000)
expect(session.getState()).toBe('connected')
expect(fakes.sendText).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1_999)
expect(session.getState()).toBe('connected')
await vi.advanceTimersByTimeAsync(1)
expect(session.getState()).toBe('disconnected')
expect(fakes.close).toHaveBeenCalledOnce()
expect(onLog).toHaveBeenCalledWith(
expect.objectContaining({
code: 'liveness-timeout',
detail: expect.stringMatching(/^probe-timeout; 2\/2 probes missed;/)
})
)
})
it('still terminates a dead relay when the log sink throws on the timeout line', async () => {
const onLog = vi.fn<ConnectionLogSink>(() => {
throw new Error('sink exploded')
})
const session = await authenticateSession(onLog)
session.notifyForeground('focus')
await vi.advanceTimersByTimeAsync(4_000)
await vi.advanceTimersByTimeAsync(4_000)
// The line was attempted and threw; the session still came down.
expect(onLog).toHaveBeenCalledWith(expect.objectContaining({ code: 'liveness-timeout' }))
expect(session.getState()).toBe('disconnected')
expect(fakes.close).toHaveBeenCalledOnce()
})
it('disconnects after two fair foreground misses', async () => {
const onLog = vi.fn<ConnectionLogSink>()
const session = await authenticateSession(onLog)
@@ -237,25 +161,22 @@ describe('mobile relay RPC session liveness', () => {
expect(secondId).not.toBe(firstId)
})
it('rate-limits focus nudges but never an app resume', async () => {
it('rate-limits foreground sequences without suppressing a retry', async () => {
const session = await authenticateSession()
session.notifyForeground('focus')
answerProbe()
const firstProbe = sentRequests()[0]!
fakes.linkOptions!.onText(
JSON.stringify({ id: firstProbe.id, ok: true, result: {}, _meta: { runtimeId: 'r1' } })
)
session.notifyForeground('focus')
await vi.advanceTimersByTimeAsync(9_999)
expect(fakes.sendText).toHaveBeenCalledOnce()
// The resume owns the only evidence that the suspended socket is still alive.
session.notifyForeground('app-resume')
expect(fakes.sendText).toHaveBeenCalledTimes(2)
answerProbe()
session.notifyForeground('focus')
expect(fakes.sendText).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(10_000)
expect(fakes.sendText).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(1)
session.notifyForeground('focus')
expect(fakes.sendText).toHaveBeenCalledTimes(3)
expect(fakes.sendText).toHaveBeenCalledTimes(2)
session.close()
})
@@ -268,9 +189,9 @@ describe('mobile relay RPC session liveness', () => {
session.close()
})
it('does not probe when work follows inbound silence', async () => {
it('does not probe when work follows prolonged inbound silence', async () => {
const session = await authenticateSession()
await vi.advanceTimersByTimeAsync(20_000)
await vi.advanceTimersByTimeAsync(60_000)
const pending = session.sendRequest('terminal.send', { terminal: 'term', text: 'hi' })
const outcome = pending.catch(() => undefined)
@@ -22,10 +22,6 @@ const fakes = vi.hoisted(() => ({
close: vi.fn()
}))
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' }))
vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) }))
vi.mock('./mobile-relay-e2ee-link', () => ({
MobileRelayE2eeLink: class {
constructor(options: NonNullable<typeof fakes.linkOptions>) {
@@ -37,8 +33,6 @@ vi.mock('./mobile-relay-e2ee-link', () => ({
}))
import { connectMobileRelayRpcSession } from './mobile-relay-rpc-session'
import { persistResumeConfirmation } from './mobile-relay-credential-rotation'
import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
const relay = {
v: 1 as const,
@@ -49,13 +43,6 @@ const relay = {
e2eeFraming: 2 as const
}
type SentRequest = {
id: string
method: string
deviceToken: string
params: Record<string, unknown> | undefined
}
function openSession() {
return connectMobileRelayRpcSession({
relay,
@@ -68,11 +55,8 @@ function openSession() {
})
}
function sentRequests(): SentRequest[] {
return fakes.sendText.mock.calls.map(([value]) => JSON.parse(value as string) as SentRequest)
}
function receiveHello(): void {
async function confirmResume() {
const session = openSession()
fakes.linkOptions!.onHello({
type: 'relay-hello',
ok: true,
@@ -82,31 +66,21 @@ function receiveHello(): void {
acceptedAs: 'current',
resumeExpiresAt: Date.now() + 300_000
})
}
// E2EE authentication alone publishes 'connected'; the confirm and the capability
// advisory are already on the wire by the time it returns.
function authenticateSession() {
const session = openSession()
receiveHello()
expect(session.getState()).toBe('handshaking')
fakes.linkOptions!.onAuthenticated()
const [confirmationRequest, capabilityRequest] = sentRequests()
return {
session,
confirmationRequest: confirmationRequest!,
capabilityRequest: capabilityRequest!
await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledOnce())
const request = JSON.parse(fakes.sendText.mock.calls[0]![0] as string) as {
id: string
method: string
params: unknown
}
}
function answerConfirm(request: SentRequest, relayHostId = relay.relayHostId): void {
fakes.linkOptions!.onText(
JSON.stringify({
id: request.id,
ok: true,
result: {
v: 1,
relay: { ...relay, relayHostId },
relay,
resumeConfirmation: {
v: 1,
reqId: 'confirm-1',
@@ -119,32 +93,39 @@ function answerConfirm(request: SentRequest, relayHostId = relay.relayHostId): v
_meta: { runtimeId: 'runtime-1' }
})
)
await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledTimes(2))
const capabilityRequest = JSON.parse(fakes.sendText.mock.calls[1]![0] as string) as {
id: string
method: string
deviceToken: string
params: { clientCapabilities?: string[] }
}
return { session, confirmationRequest: request, capabilityRequest }
}
function answerCapability(request: SentRequest, supported = true): void {
async function authenticateSession(capabilitySupported = true) {
const { session, confirmationRequest, capabilityRequest } = await confirmResume()
expect(session.getState()).toBe('handshaking')
fakes.linkOptions!.onText(
JSON.stringify(
supported
? { id: request.id, ok: true, result: request.params, _meta: { runtimeId: 'runtime-1' } }
capabilitySupported
? {
id: capabilityRequest.id,
ok: true,
result: capabilityRequest.params,
_meta: { runtimeId: 'runtime-1' }
}
: {
id: request.id,
id: capabilityRequest.id,
ok: false,
error: { code: 'method_not_found', message: 'Unknown method' },
_meta: { runtimeId: 'runtime-1' }
}
)
)
}
// Both advisories answered and the send log cleared, so a test can read its own frames.
async function settledSession(capabilitySupported = true) {
const authenticated = authenticateSession()
answerConfirm(authenticated.confirmationRequest)
answerCapability(authenticated.capabilityRequest, capabilitySupported)
await authenticated.session.whenResumeConfirmed()
expect(authenticated.session.getState()).toBe('connected')
await vi.waitFor(() => expect(session.getState()).toBe('connected'))
fakes.sendText.mockClear()
return authenticated
return { session, confirmationRequest, capabilityRequest }
}
describe('mobile relay RPC session', () => {
@@ -156,7 +137,7 @@ describe('mobile relay RPC session', () => {
afterEach(() => vi.useRealTimers())
it('releases stream listeners on failure even when close follows it', async () => {
const { session } = await settledSession()
const { session } = await authenticateSession()
const listener = vi.fn()
session.subscribe('runtime.clientEvents.subscribe', {}, listener)
await Promise.resolve()
@@ -185,8 +166,8 @@ describe('mobile relay RPC session', () => {
expect(listener).toHaveBeenCalledTimes(1)
})
it('sends the resume confirm by request ID and the capability advisory concurrently', async () => {
const { session, confirmationRequest, capabilityRequest } = await settledSession()
it('requires exact resume observations and confirms by request ID before becoming connected', async () => {
const { session, confirmationRequest, capabilityRequest } = await authenticateSession()
expect(fakes.linkOptions).toMatchObject({
endpoint: relay,
@@ -211,103 +192,21 @@ describe('mobile relay RPC session', () => {
})
it('connects when an older runtime rejects capability negotiation', async () => {
const { session } = await settledSession(false)
const { session } = await authenticateSession(false)
expect(session.getState()).toBe('connected')
expect(session.getFailure()).toBeNull()
})
it('connects when the relay never answers capability negotiation', async () => {
const { session, confirmationRequest } = authenticateSession()
answerConfirm(confirmationRequest)
const { session } = await confirmResume()
// Why: the advisory's own deadline used to fail the confirm, so a link too slow to
// Why: the advisory's own deadline used to fail confirmResume, so a link too slow to
// answer within the request timeout never published 'connected' — it just redialled.
await session.whenResumeConfirmed()
expect(session.getState()).toBe('connected')
await vi.waitFor(() => expect(session.getState()).toBe('connected'), { timeout: 5_000 })
expect(session.getFailure()).toBeNull()
})
it('publishes connected at authentication, ahead of the confirm answer', async () => {
const states: string[] = []
const session = openSession()
session.onStateChange((state) => states.push(state))
receiveHello()
fakes.linkOptions!.onAuthenticated()
// Why: the transport carries traffic from here; two serialized advisory round
// trips used to add ~200ms to every phone reconnect before anything rendered.
expect(session.getState()).toBe('connected')
expect(states).toEqual(['handshaking', 'connected'])
expect(session.getResumeConfirmation()).toBeNull()
expect(sentRequests().map(({ method }) => method)).toEqual([
'pairing.getEndpoints',
'runtime.clientCapabilities.update'
])
const [confirmationRequest] = sentRequests()
answerConfirm(confirmationRequest!)
await session.whenResumeConfirmed()
expect(session.getResumeConfirmation()).toMatchObject({ reqId: 'confirm-1' })
session.close()
})
it('fails a session whose confirm answers for another relay host after connected', async () => {
const { session, confirmationRequest } = authenticateSession()
expect(session.getState()).toBe('connected')
answerConfirm(confirmationRequest, 'ZZZZZZZZZZZZZZZZ')
await session.whenResumeConfirmed()
// A late failure is fine; a lost one is not.
expect(session.getState()).toBe('disconnected')
expect(session.getFailure()?.message).toBe('relay resume confirmation missing')
expect(fakes.close).toHaveBeenCalledOnce()
})
it('fails a session whose confirm never answers', async () => {
vi.useFakeTimers()
try {
const { session } = authenticateSession()
expect(session.getState()).toBe('connected')
await vi.advanceTimersByTimeAsync(1_000)
expect(session.getState()).toBe('disconnected')
expect(session.getFailure()?.message).toBe('relay RPC timed out: pairing.getEndpoints')
} finally {
vi.useRealTimers()
}
})
it('hands the landed confirmation to resume persistence', async () => {
const { session, confirmationRequest } = authenticateSession()
const bundle: MobileRelayCredentialBundle = {
v: 1,
hostId: 'host-1',
deviceToken: 'device-token',
current: { token: 'A'.repeat(43), hash: 'B'.repeat(43), version: 3, expiresAt: 1 }
}
const writeBundle = vi.fn(async () => {})
// Why: persistence runs right after the migration, while the confirm is still
// in flight — it must wait for the answer instead of reading a null.
const persisting = persistResumeConfirmation({
session,
bundle,
usedCredentialVersion: 3,
writeBundle
})
expect(writeBundle).not.toHaveBeenCalled()
answerConfirm(confirmationRequest)
const applied = await persisting
expect(writeBundle).toHaveBeenCalledOnce()
expect(applied.bundle.current.expiresAt).toBe(session.getResumeExpiresAt())
expect(applied.leaseExpiry).toBe(session.getResumeExpiresAt())
session.close()
})
// Why: ConnectionState stays 'connecting' until relay-hello, so the migration bound
// needs a separate signal to tell "cell never answered the upgrade" from "cell took
// relay-auth and is still resolving the assignment".
@@ -332,7 +231,7 @@ describe('mobile relay RPC session', () => {
expect(session.getDialStage()).toBe('handshaking')
fakes.linkOptions!.onAuthenticated()
expect(session.getDialStage()).toBe('confirming')
expect(fakes.sendText).toHaveBeenCalledTimes(2)
await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledOnce())
expect(stages).toEqual(['awaiting-hello', 'handshaking', 'confirming'])
session.close()
})
@@ -355,7 +254,7 @@ describe('mobile relay RPC session', () => {
})
it('routes terminal and browser binary streams after confirmation', async () => {
const { session } = await settledSession()
const { session } = await authenticateSession()
const terminalListener = vi.fn()
session.subscribe('terminal.subscribe', { terminal: 'term-1' }, terminalListener)
await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledOnce())
@@ -412,7 +311,7 @@ describe('mobile relay RPC session', () => {
})
it('rejects pending RPC work when the physical link fails', async () => {
const { session } = await settledSession()
const { session } = await authenticateSession()
const pending = session.sendRequest('status.get')
await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledOnce())
fakes.linkOptions!.onError(new Error('relay transport error'))
@@ -424,7 +323,7 @@ describe('mobile relay RPC session', () => {
})
it('marks in-flight requests delivery-unknown when the session closes', async () => {
const { session } = await settledSession()
const { session } = await authenticateSession()
const pending = session.sendRequest('terminal.send', { terminal: 'term', text: 'hi' })
await vi.waitFor(() => expect(fakes.sendText).toHaveBeenCalledOnce())
session.close()
@@ -434,7 +333,7 @@ describe('mobile relay RPC session', () => {
})
it('marks a relay RPC timeout delivery-unknown', async () => {
const { session } = await settledSession()
const { session } = await authenticateSession()
vi.useFakeTimers()
try {
const pending = session.sendRequest('terminal.send', { terminal: 'term', text: 'hi' })
@@ -453,57 +352,4 @@ describe('mobile relay RPC session', () => {
vi.useRealTimers()
}
})
it('keeps whenResumeConfirmed() pending until the session has an answer', async () => {
// The contract callers rely on is "settles when the confirm has answered or the
// session is over". A promise already resolved during the dial would let a caller
// read getResumeConfirmation() as null and persist that as the answer.
const session = openSession()
const settled = vi.fn()
void session.whenResumeConfirmed().then(settled)
receiveHello()
await Promise.resolve()
expect(settled).not.toHaveBeenCalled()
fakes.linkOptions!.onAuthenticated()
await Promise.resolve()
expect(settled).not.toHaveBeenCalled()
answerConfirm(sentRequests()[0]!)
await session.whenResumeConfirmed()
expect(settled).toHaveBeenCalled()
expect(session.getResumeConfirmation()).toMatchObject({ reqId: 'confirm-1' })
})
it('settles whenResumeConfirmed() when the session dies before authenticating', async () => {
const session = openSession()
const settled = vi.fn()
void session.whenResumeConfirmed().then(settled)
// A credential-version mismatch fails the session inside onHello, so no confirm
// is ever sent. Awaiting the answer must not hang a caller forever.
fakes.linkOptions!.onHello({
type: 'relay-hello',
ok: true,
credentialKind: 'resume',
leaseExpiresAt: Date.now() + 60_000,
acceptedCredentialVersion: 2,
acceptedAs: 'current',
resumeExpiresAt: Date.now() + 300_000
})
await session.whenResumeConfirmed()
expect(settled).toHaveBeenCalled()
expect(session.getState()).toBe('disconnected')
})
it('settles whenResumeConfirmed() when a caller closes an unconfirmed session', async () => {
const session = openSession()
const settled = vi.fn()
void session.whenResumeConfirmed().then(settled)
session.close()
await session.whenResumeConfirmed()
expect(settled).toHaveBeenCalled()
})
})
@@ -9,18 +9,17 @@ import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel
import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
import { openRpcRequestBudget, resolvePostConnectRequestTimeout } from './rpc-request-budget'
import { isRpcResponse } from './rpc-response-shape'
import { RelayDialStageLog } from './relay-dial-stage-log'
import { RelayDialStageTracker, type RelayDialStageSource } from './relay-dial-stage'
import { RelayPendingRequests } from './relay-pending-requests'
import { createRelaySessionLivenessWatchdog } from './relay-session-liveness-profile'
import { RpcSessionLivenessWatchdog } from './rpc-session-liveness-watchdog'
import { settleMobileRuntimeCapabilities } from './mobile-runtime-capability-negotiation'
import type { RelayHostCloseReason } from '../../../src/shared/relay-host-close-reason'
import type { RpcClient } from './rpc-client'
import type { ConnectionLogSink, ConnectionState, RpcResponse } from './types'
// Bounds the confirm exactly as migrateTo's own wait used to, so the supervisor's
// mutex is never held for the full request timeout waiting on a silent cell.
const RELAY_CONFIRM_TIMEOUT_MS = 12_000
const RELAY_PROBE_TIMEOUT_MS = 4_000
const RELAY_MISSED_PROBE_LIMIT = 2
const RELAY_FOREGROUND_PROBE_MIN_INTERVAL_MS = 10_000
let relayRpcSessionSequence = 0
export type MobileRelayRpcSession = RpcClient &
@@ -30,10 +29,6 @@ export type MobileRelayRpcSession = RpcClient &
getAttachDeadlineAt(): number | null
getResumeExpiresAt(): number | null
getResumeConfirmation(): DeviceResumeConfirmed | null
// Settles once the resume confirm has answered or failed the session. Never
// rejects. Anyone reading getResumeConfirmation()/getResumeExpiresAt() must
// await it: 'connected' is published at authentication, ahead of the confirm.
whenResumeConfirmed(): Promise<void>
getFailure(): Error | null
}
@@ -45,8 +40,6 @@ export function connectMobileRelayRpcSession(args: {
deviceToken: string
desktopPublicKeyB64: string
requestTimeoutMs?: number
// Gates the idle liveness sweep; a backgrounded app must not spend probes.
isForeground?: () => boolean
createSocket?: (url: string) => WebSocket
onHostCloseReason?: (reason: RelayHostCloseReason) => void
onLog?: ConnectionLogSink
@@ -64,16 +57,7 @@ export function connectMobileRelayRpcSession(args: {
let logSequence = 0
const logSessionId = `${Date.now().toString(36)}-${(++relayRpcSessionSequence).toString(36)}`
const livenessIdentity = {}
// Why created here and not at authentication: handing a pre-auth caller an
// already-resolved promise would let it read getResumeConfirmation() as null and
// treat that as the answer. Every terminal path settles it — the confirm, fail(),
// and close() — so awaiting it can never outlive the session.
let settleResumeConfirmed!: () => void
const resumeConfirmed = new Promise<void>((resolve) => {
settleResumeConfirmed = resolve
})
const dialStage = new RelayDialStageTracker()
const dialStageLog = new RelayDialStageLog(dialStage, logSessionId, args.onLog)
const streams = new MobileRelayRpcStreams({
nextId: () => pending.nextId(),
sendFrame,
@@ -88,7 +72,7 @@ export function connectMobileRelayRpcSession(args: {
desktopPublicKeyB64: args.desktopPublicKeyB64,
createSocket: args.createSocket,
onHostCloseReason: args.onHostCloseReason,
onOpen: () => dialStageLog.enter('awaiting-hello'),
onOpen: () => dialStage.advance('awaiting-hello'),
onHello: (hello) => {
if (
hello.credentialKind !== 'resume' ||
@@ -99,10 +83,10 @@ export function connectMobileRelayRpcSession(args: {
}
attachDeadlineAt = hello.leaseExpiresAt
resumeExpiresAt = hello.resumeExpiresAt
dialStageLog.enter('handshaking')
dialStage.advance('handshaking')
publishState('handshaking')
},
onAuthenticated: () => publishAuthenticated(),
onAuthenticated: () => void confirmResume(),
onText: (plaintext) => {
livenessWatchdog.noteAuthenticatedInbound(livenessIdentity)
handleText(plaintext)
@@ -141,56 +125,58 @@ export function connectMobileRelayRpcSession(args: {
},
notifyForeground: (reason) => {
if (state === 'connected' && reason !== 'network-change') {
livenessWatchdog.probeNow(livenessIdentity, reason === 'app-resume' ? 'resume' : 'nudge')
livenessWatchdog.probeNow(livenessIdentity)
}
},
close: () => terminate(new Error('Client closed')),
close() {
if (closed) {
return
}
closed = true
livenessWatchdog.stop(livenessIdentity)
link.close()
pending.rejectAll(new Error('Client closed'))
streams.clear()
publishState('disconnected')
},
getDialStage: () => dialStage.getDialStage(),
onDialStageChange: (listener) => dialStage.onDialStageChange(listener),
getAttachDeadlineAt: () => attachDeadlineAt,
getResumeExpiresAt: () => resumeExpiresAt,
getResumeConfirmation: () => resumeConfirmation,
whenResumeConfirmed: () => resumeConfirmed,
getFailure: () => failure
}
const livenessWatchdog = createRelaySessionLivenessWatchdog({
isForeground: args.isForeground,
const livenessWatchdog = new RpcSessionLivenessWatchdog({
transport: 'relay',
idleProbeMs: null,
probeTimeoutMs: RELAY_PROBE_TIMEOUT_MS,
missedProbeLimit: RELAY_MISSED_PROBE_LIMIT,
voluntaryProbeMinIntervalMs: RELAY_FOREGROUND_PROBE_MIN_INTERVAL_MS,
sendProbe: () =>
state === 'connected' &&
sendFrame({ id: pending.nextId(), method: 'status.get', params: undefined }),
terminate: () => fail(new Error('relay session liveness timeout')),
onLog: args.onLog,
nextLogId: () => `relay-liveness-${logSessionId}-${++logSequence}`
onTimeout: (evidence) => {
args.onLog?.({
id: `relay-liveness-${logSessionId}-${++logSequence}`,
ts: Date.now(),
level: 'error',
code: 'liveness-timeout',
path: 'relay',
message: 'Relay health check failed',
detail: `${evidence.reason}; ${evidence.missedProbes}/${evidence.missedProbeLimit} probes missed; last authenticated activity ${evidence.lastInboundAgeMs}ms ago`
})
},
terminate: () => fail(new Error('relay session liveness timeout'))
})
return client
// Why: the transport carries traffic the moment E2EE authenticates. The resume
// confirm and the capability advisory ride it concurrently instead of putting
// two serialized round trips in front of 'connected'.
function publishAuthenticated(): void {
if (closed) {
return
}
dialStageLog.enter('confirming')
void confirmResume().then(settleResumeConfirmed, settleResumeConfirmed)
// Why: an unanswered advisory says nothing, but a frame that never reached the
// wire proves the socket cannot carry traffic — that alone still fails.
void settleMobileRuntimeCapabilities((method, params) =>
sendRpc(method, params, requestTimeoutMs, true)
).catch((error: unknown) => fail(asError(error)))
lastConnectedAt = Date.now()
livenessWatchdog.start(livenessIdentity)
publishState('connected')
}
// Off the critical path but never optional: a failed confirm or a relayHostId
// that is not ours still fails the session, only later than it used to.
async function confirmResume(): Promise<void> {
dialStage.advance('confirming')
try {
const response = await sendRpc(
'pairing.getEndpoints',
{ resumeConfirmReqId: args.resumeConfirmReqId },
Math.min(requestTimeoutMs, RELAY_CONFIRM_TIMEOUT_MS),
requestTimeoutMs,
true
)
if (!response.ok) {
@@ -202,9 +188,13 @@ export function connectMobileRelayRpcSession(args: {
}
resumeConfirmation = result.resumeConfirmation
resumeExpiresAt = result.resumeConfirmation.resumeExpiresAt
// The dial's last stage ends when the desktop has confirmed the resume, not when
// 'connected' was published at authentication ahead of it.
dialStageLog.settle(true)
lastConnectedAt = Date.now()
// Why: an unanswered advisory must not keep a slow relay from ever reaching connected.
await settleMobileRuntimeCapabilities((method, params) =>
sendRpc(method, params, requestTimeoutMs, true)
)
livenessWatchdog.start(livenessIdentity)
publishState('connected')
} catch (error) {
fail(asError(error))
}
@@ -297,29 +287,18 @@ export function connectMobileRelayRpcSession(args: {
}
}
// One teardown for both endings; only whether the session is to blame differs, and
// recording a failure for a caller's close would make the establisher report a
// deliberate teardown as a dial error.
function terminate(error: Error): void {
function fail(error: Error): void {
if (closed) {
return
}
closed = true
settleResumeConfirmed()
dialStageLog.settle(false, error.message)
failure = error
livenessWatchdog.stop(livenessIdentity)
streams.clear()
link.close()
pending.rejectAll(error)
publishState(error instanceof MobileE2EEAuthenticationError ? 'auth-failed' : 'disconnected')
}
function fail(error: Error): void {
if (!closed) {
failure = error
}
terminate(error)
}
}
function asError(error: unknown): Error {
@@ -88,7 +88,6 @@ class FakeRelaySession extends FakeSession implements MobileRelayRpcSession {
this.dialStage.onDialStageChange(listener)
getResumeExpiresAt = () => Date.now() + 30 * 24 * 3_600_000
getResumeConfirmation = () => null
whenResumeConfirmed = () => Promise.resolve()
getFailure = () => this.failure
}
@@ -278,7 +277,6 @@ describe('relay runtime recovery without direct connectivity', () => {
relay,
expect.objectContaining({ version: 3 }),
expect.any(String),
expect.any(Function),
expect.any(Function)
)
expect(logical.getActivePath()).toBe('relay')
@@ -369,7 +367,6 @@ describe('relay runtime recovery without direct connectivity', () => {
relay,
expect.objectContaining({ version: 2 }),
expect.any(String),
expect.any(Function),
expect.any(Function)
)
expect(logical.getActivePath()).toBe('relay')
@@ -400,7 +397,6 @@ describe('relay runtime recovery without direct connectivity', () => {
relay,
expect.objectContaining({ version: 1 }),
expect.any(String),
expect.any(Function),
expect.any(Function)
)
expect(logical.getActivePath()).toBe('relay')
@@ -19,25 +19,6 @@ function directWon(logical: StableLogicalRpcClient): boolean {
return logical.getActivePath() !== 'relay' && logical.getState() === 'connected'
}
// Why: migrateTo consults its abort predicate only after E2EE authentication, so
// a dial that has already lost would still make the cell reserve a splice and the
// desktop finish a handshake. Closing the socket withdraws it at whatever stage it
// reached — before any e2ee frame when the hello has not landed yet. The caller
// still reports the dial as aborted, so nothing is booked against relay.
function withdrawWhenDirectWins(
logical: StableLogicalRpcClient,
session: { close(): void }
): () => void {
const withdraw = (): void => {
if (directWon(logical)) {
session.close()
}
}
const unsubscribe = logical.onStateChange(withdraw)
withdraw()
return unsubscribe
}
// Turns one relay credential into the active runtime session: resolve the cell
// assignment if the director rejects the cached one, open the cell socket,
// migrate the logical client onto it, then persist the resume confirmation and
@@ -129,10 +110,8 @@ export class MobileRelaySessionEstablisher {
if (reason === RELAY_HOST_CLOSE_REASON.SIGNED_OUT) {
args.logical.setHostSignedOut(true)
}
},
args.isForeground
}
)
const stopWithdrawWatch = withdrawWhenDirectWins(args.logical, session)
try {
// Why: backgrounding or a direct winner withdraws this dial before cutover.
await args.logical.migrateTo(
@@ -146,21 +125,6 @@ export class MobileRelaySessionEstablisher {
return { ok: false, error: new RelayDialAbortedError() }
}
return { ok: false, error: session.getFailure() ?? toError(error) }
} finally {
// Why: past the cutover this session is the active path, and a later direct
// promotion must not read as a reason to close the client's own socket.
stopWithdrawWatch()
}
// Why: migrateTo now resolves at E2EE authentication, so the resume confirm can
// still fail this session after the cutover. Booking a dying session as an
// established dial skips backoff and redials in a tight loop — the supervisor's
// bookkeeping waits for the verdict even though the UI is already connected.
await session.whenResumeConfirmed()
if (session.getState() !== 'connected') {
if (!args.isActive() || directWon(args.logical)) {
return { ok: false, error: new RelayDialAbortedError() }
}
return { ok: false, error: session.getFailure() ?? new Error('relay lost at confirm') }
}
args.controller.setActiveSession(session)
if (!args.isForeground()) {
-15
View File
@@ -1,15 +0,0 @@
// Why: connection phase durations must never go negative. Date.now() can jump
// backwards (NTP or a user clock change) mid-dial, which would turn a slow stage
// into a negative one in the diagnostics report. performance.now() is monotonic
// and Hermes exposes it; hosts without it fall back to wall clock.
const hasPerformanceNow =
typeof performance === 'object' && performance !== null && typeof performance.now === 'function'
export const monotonicNowMs: () => number = hasPerformanceNow
? () => performance.now()
: () => Date.now()
/** Whole milliseconds between two monotonic reads, clamped so a fallback wall-clock jump can't go negative. */
export function elapsedMs(startedAt: number, endedAt: number = monotonicNowMs()): number {
return Math.max(0, Math.round(endedAt - startedAt))
}
@@ -23,89 +23,6 @@ describe('persisted connection log store', () => {
vi.resetModules()
})
// 'negotiating' is not a dial stage and 'confirming' is a dial stage rather than a
// connection state; the report echoes the name, so neither may survive. A negative
// duration is corruption too: producers clamp at 0, and the report sums these, so a
// negative would subtract from a dial total.
it('rehydrates well-formed phase timings and drops corrupt names and durations', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(
JSON.stringify([
{
id: 'stage-ok',
ts: 900,
level: 'info',
message: 'Relay dial stage awaiting-hello finished',
timing: { kind: 'relay-dial-stage', name: 'awaiting-hello', ms: 6_400, complete: true }
},
{
id: 'stage-corrupt',
ts: 950,
level: 'info',
message: 'Relay dial stage handshaking finished',
timing: { kind: 'relay-dial-stage', name: 'handshaking', ms: 'soon' }
},
{
id: 'stage-unknown-name',
ts: 960,
level: 'info',
message: 'Relay dial stage negotiating finished',
timing: { kind: 'relay-dial-stage', name: 'negotiating', ms: 12, complete: true }
},
{
id: 'state-borrowed-stage-name',
ts: 970,
level: 'info',
message: 'Connection state confirming → connected',
timing: { kind: 'connection-state', name: 'confirming', ms: 12, complete: true }
},
{
id: 'state-unknown-kind',
ts: 980,
level: 'info',
message: 'Something else',
timing: { kind: 'wall-clock', name: 'connecting', ms: 12, complete: true }
},
{
id: 'stage-negative-ms',
ts: 985,
level: 'info',
message: 'Relay dial stage opening finished',
timing: { kind: 'relay-dial-stage', name: 'opening', ms: -1, complete: true }
},
{
id: 'state-negative-ms',
ts: 990,
level: 'info',
message: 'Connection state connecting → connected',
timing: { kind: 'connection-state', name: 'connecting', ms: -0.5, complete: true }
},
{
id: 'stage-zero-ms',
ts: 995,
level: 'info',
message: 'Relay dial stage confirming finished',
timing: { kind: 'relay-dial-stage', name: 'confirming', ms: 0, complete: true }
}
])
)
vi.resetModules()
const { connectionLogStore } = await import('./persisted-connection-log-store')
await connectionLogStore.hydrate('host-timings')
// 0 survives: a stage the dial passed through instantly is real, not corruption.
expect(connectionLogStore.get('host-timings').map((entry) => entry.id)).toEqual([
'stage-ok',
'stage-zero-ms'
])
expect(connectionLogStore.get('host-timings')[0]!.timing).toEqual({
kind: 'relay-dial-stage',
name: 'awaiting-hello',
ms: 6_400,
complete: true
})
})
it('keeps a new client-session boundary when a restart shares the prior timestamp', async () => {
const stored: ConnectionLogEntry[] = [
{
@@ -1,7 +1,6 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { createConnectionLogStore } from './connection-log-buffer'
import { RELAY_DIAL_STAGE_NAMES } from './relay-dial-stage'
import { CONNECTION_STATE_NAMES, type ConnectionLogEntry, type ConnectionLogTiming } from './types'
import type { ConnectionLogEntry } from './types'
const STORAGE_PREFIX = 'orca.mobile.connection-log.v1.'
const clientSessionId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
@@ -73,30 +72,6 @@ function isConnectionLogEntry(value: unknown): value is ConnectionLogEntry {
entry.level === 'warn' ||
entry.level === 'error') &&
typeof entry.message === 'string' &&
(entry.detail === undefined || typeof entry.detail === 'string') &&
(entry.timing === undefined || isConnectionLogTiming(entry.timing))
)
}
// Why: the report echoes the phase name and formats the duration directly, so a
// corrupted stored timing must not reach it. The name is checked against the closed
// enum for its kind, not just "is a string", and the duration must be one a producer
// could have written — `elapsedMs` clamps at 0, so a negative is corruption.
function isConnectionLogTiming(value: unknown): value is ConnectionLogTiming {
if (!value || typeof value !== 'object') {
return false
}
const timing = value as Partial<ConnectionLogTiming>
if (timing.kind !== 'relay-dial-stage' && timing.kind !== 'connection-state') {
return false
}
const names = timing.kind === 'relay-dial-stage' ? RELAY_DIAL_STAGE_NAMES : CONNECTION_STATE_NAMES
return (
typeof timing.name === 'string' &&
Object.hasOwn(names, timing.name) &&
typeof timing.ms === 'number' &&
Number.isFinite(timing.ms) &&
timing.ms >= 0 &&
typeof timing.complete === 'boolean'
(entry.detail === undefined || typeof entry.detail === 'string')
)
}
@@ -1,55 +0,0 @@
import type {
RelayDialStage,
RelayDialStageTracker,
RelayDialStageTiming
} from './relay-dial-stage'
import type { ConnectionLogSink } from './types'
// Why: support needs per-stage durations for a slow dial, and the name of the stage
// a failed dial died in, without a debug build. Timing only — advancing the tracker
// stays the session's call.
export class RelayDialStageLog {
private sequence = 0
constructor(
private readonly tracker: RelayDialStageTracker,
private readonly sessionId: string,
private readonly sink?: ConnectionLogSink
) {}
enter(stage: RelayDialStage): void {
this.record(this.tracker.advance(stage))
}
settle(complete: boolean, failureDetail?: string): void {
this.record(this.tracker.settle(complete), failureDetail)
}
private record(timing: RelayDialStageTiming | null, failureDetail?: string): void {
if (!timing) {
return
}
// Why: this runs inside the dial's success and failure paths. A sink that
// throws must not turn a good connect into a failed one.
try {
this.sink?.({
id: `relay-dial-stage-${this.sessionId}-${++this.sequence}`,
ts: Date.now(),
level: timing.complete ? 'info' : 'warn',
path: 'relay',
message: `Relay dial stage ${timing.stage} ${
timing.complete ? 'finished' : 'did not finish'
}`,
detail: `${timing.ms}ms${failureDetail ? `${failureDetail}` : ''}`,
timing: {
kind: 'relay-dial-stage',
name: timing.stage,
ms: timing.ms,
complete: timing.complete
}
})
} catch {
// Diagnostics only; a broken sink is not worth failing a dial over.
}
}
}
@@ -1,219 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { RelayDialStageTracker } from './relay-dial-stage'
import type { ConnectionLogEntry } from './types'
const fakes = vi.hoisted(() => ({
linkOptions: null as null | {
onOpen(): void
onHello(value: unknown): void
onAuthenticated(): void
onText(value: string): void
onBinary(value: Uint8Array): void
onError(error: Error): void
},
sendText: vi.fn(() => true),
close: vi.fn()
}))
vi.mock('./mobile-relay-e2ee-link', () => ({
MobileRelayE2eeLink: class {
constructor(options: NonNullable<typeof fakes.linkOptions>) {
fakes.linkOptions = options
}
sendText = fakes.sendText
close = fakes.close
}
}))
import { connectMobileRelayRpcSession } from './mobile-relay-rpc-session'
const relay = {
v: 1 as const,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay-c1.onorca.dev',
assignmentEpoch: 7,
relayHostId: 'AbCdEf0123_-xyZ9',
e2eeFraming: 2 as const
}
function openSession(entries: ConnectionLogEntry[]) {
return connectMobileRelayRpcSession({
relay,
resumeToken: 'resume-secret',
resumeCredentialVersion: 3,
resumeConfirmReqId: 'confirm-1',
deviceToken: 'device-token',
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
requestTimeoutMs: 1000,
onLog: (entry) => entries.push(entry)
})
}
function stageTimings(entries: readonly ConnectionLogEntry[]) {
return entries.flatMap((entry) =>
entry.timing?.kind === 'relay-dial-stage' ? [entry.timing] : []
)
}
describe('RelayDialStageTracker timings', () => {
it('times every stage it passes through without going negative', () => {
// A clock that steps backwards proves the report can never show a negative stage.
const reads = [0, 120, 4_400, 4_300, 5_500]
let index = 0
const tracker = new RelayDialStageTracker(() => reads[index++]!)
expect(tracker.advance('awaiting-hello')).toEqual({
stage: 'opening',
ms: 120,
complete: true
})
expect(tracker.advance('handshaking')).toEqual({
stage: 'awaiting-hello',
ms: 4_280,
complete: true
})
expect(tracker.advance('confirming')).toEqual({
stage: 'handshaking',
ms: 0,
complete: true
})
expect(tracker.settle(true)).toEqual({ stage: 'confirming', ms: 1_200, complete: true })
expect(tracker.getDialStage()).toBe('confirming')
})
it('re-advancing to the current stage is not a transition', () => {
const tracker = new RelayDialStageTracker(() => 0)
expect(tracker.advance('opening')).toBeNull()
})
it('settles once, so a failure after connecting cannot re-time the last stage', () => {
let now = 0
const tracker = new RelayDialStageTracker(() => now)
tracker.advance('awaiting-hello')
now = 900
expect(tracker.settle(true)).toEqual({ stage: 'awaiting-hello', ms: 900, complete: true })
now = 90_000
expect(tracker.settle(false)).toBeNull()
})
})
function requestIdAt(call: number): string {
return (JSON.parse(fakes.sendText.mock.calls[call]![0] as string) as { id: string }).id
}
async function driveToConnected(session: {
getState(): string
whenResumeConfirmed(): Promise<void>
}): Promise<void> {
fakes.linkOptions!.onOpen()
fakes.linkOptions!.onHello({
type: 'relay-hello',
ok: true,
credentialKind: 'resume',
leaseExpiresAt: Date.now() + 60_000,
acceptedCredentialVersion: 3,
acceptedAs: 'current',
resumeExpiresAt: Date.now() + 300_000
})
fakes.linkOptions!.onAuthenticated()
// 'connected' is published at authentication; the resume confirm and the capability
// advisory are both already on the wire, so answer them in the order they were sent.
await vi.waitFor(() => expect(session.getState()).toBe('connected'))
expect(fakes.sendText).toHaveBeenCalledTimes(2)
fakes.linkOptions!.onText(
JSON.stringify({
id: requestIdAt(0),
ok: true,
result: {
v: 1,
relay,
resumeConfirmation: {
v: 1,
reqId: 'confirm-1',
currentVersion: 3,
acceptedAs: 'current',
renewed: true,
resumeExpiresAt: Date.now() + 300_000
}
},
_meta: { runtimeId: 'runtime-1' }
})
)
fakes.linkOptions!.onText(
JSON.stringify({ id: requestIdAt(1), ok: true, result: {}, _meta: { runtimeId: 'runtime-1' } })
)
await session.whenResumeConfirmed()
}
describe('relay dial stage timings in the connection log', () => {
beforeEach(() => {
fakes.sendText.mockClear()
fakes.close.mockClear()
})
it('records the stages a failed dial reached plus the stage it died in', () => {
const entries: ConnectionLogEntry[] = []
openSession(entries)
fakes.linkOptions!.onOpen()
fakes.linkOptions!.onError(new Error('relay dial failed'))
const timings = stageTimings(entries)
expect(timings.map((timing) => timing.name)).toEqual(['opening', 'awaiting-hello'])
expect(timings.map((timing) => timing.complete)).toEqual([true, false])
for (const timing of timings) {
expect(timing.ms).toBeGreaterThanOrEqual(0)
}
expect(entries.at(-1)!.message).toContain('awaiting-hello did not finish')
expect(entries.at(-1)!.detail).toContain('relay dial failed')
expect(entries.at(-1)!.path).toBe('relay')
})
it('records every stage of a dial that reaches connected, all complete', async () => {
const entries: ConnectionLogEntry[] = []
const session = openSession(entries)
await driveToConnected(session)
const timings = stageTimings(entries)
expect(timings.map((timing) => timing.name)).toEqual([
'opening',
'awaiting-hello',
'handshaking',
'confirming'
])
expect(timings.every((timing) => timing.complete)).toBe(true)
expect(timings.every((timing) => timing.ms >= 0)).toBe(true)
// A later teardown must not append a second timing for 'confirming'.
session.close()
expect(stageTimings(entries)).toHaveLength(4)
})
it('reaches connected even when the log sink throws on every stage', async () => {
const session = connectMobileRelayRpcSession({
relay,
resumeToken: 'resume-secret',
resumeCredentialVersion: 3,
resumeConfirmReqId: 'confirm-1',
deviceToken: 'device-token',
desktopPublicKeyB64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
requestTimeoutMs: 1000,
onLog: () => {
throw new Error('sink exploded')
}
})
await driveToConnected(session)
expect(session.getState()).toBe('connected')
expect(session.getFailure()).toBeNull()
})
it('marks a dial that never opened its socket as stuck in opening', () => {
const entries: ConnectionLogEntry[] = []
openSession(entries)
fakes.linkOptions!.onError(new Error('websocket refused'))
expect(stageTimings(entries)).toEqual([
{ kind: 'relay-dial-stage', name: 'opening', ms: expect.any(Number), complete: false }
])
})
})
+2 -47
View File
@@ -1,5 +1,3 @@
import { elapsedMs, monotonicNowMs } from './monotonic-clock'
// Where a relay dial is waiting, so a bound can tell "the cell never answered the
// upgrade" from "the cell took the dial and is slow" — the two look identical from
// ConnectionState, which stays 'connecting' until relay-hello arrives.
@@ -14,23 +12,6 @@ export type RelayDialStage =
// E2EE authenticated; waiting on the desktop's resume confirmation.
| 'confirming'
// Exhaustive by construction: adding a stage to the union breaks this table, so a
// persisted-log validator can never silently start accepting an unknown stage.
export const RELAY_DIAL_STAGE_NAMES: Record<RelayDialStage, true> = {
opening: true,
'awaiting-hello': true,
handshaking: true,
confirming: true
}
// How long a dial spent in one stage. `complete` is false when the dial left the
// stage by dying in it, so a report can name the stage that never finished.
export type RelayDialStageTiming = {
stage: RelayDialStage
ms: number
complete: boolean
}
export type RelayDialStageSource = {
getDialStage(): RelayDialStage
onDialStageChange(listener: (stage: RelayDialStage) => void): () => void
@@ -46,14 +27,8 @@ export function relayDialStageSource(session: object): RelayDialStageSource | nu
export class RelayDialStageTracker implements RelayDialStageSource {
private stage: RelayDialStage = 'opening'
private stageEnteredAt: number
private settled = false
private readonly listeners = new Set<(stage: RelayDialStage) => void>()
constructor(private readonly now: () => number = monotonicNowMs) {
this.stageEnteredAt = now()
}
getDialStage(): RelayDialStage {
return this.stage
}
@@ -63,34 +38,14 @@ export class RelayDialStageTracker implements RelayDialStageSource {
return () => this.listeners.delete(listener)
}
/** Returns the timing of the stage just left, or null when nothing was timed. */
advance(stage: RelayDialStage): RelayDialStageTiming | null {
advance(stage: RelayDialStage): void {
if (this.stage === stage) {
return null
return
}
const now = this.now()
const timing = this.settled ? null : this.closeStage(true, now)
this.stage = stage
this.stageEnteredAt = now
for (const listener of this.listeners) {
listener(stage)
}
return timing
}
// Close the stage the dial is sitting in: `true` once it reached the runtime,
// `false` when it died there. Idempotent, so a failure on an already-connected
// session cannot re-time the last dial stage.
settle(complete: boolean): RelayDialStageTiming | null {
if (this.settled) {
return null
}
this.settled = true
return this.closeStage(complete, this.now())
}
private closeStage(complete: boolean, now: number): RelayDialStageTiming {
return { stage: this.stage, ms: elapsedMs(this.stageEnteredAt, now), complete }
}
}
@@ -1,45 +0,0 @@
// Recovery requests that arrive while the supervisor's operation mutex is held.
// Two latches, because the intents are not interchangeable: an owning forced
// replacement books the shared cooldown and may bring a stale session down, while
// every other request must replay as a plain recovery. Nothing is ever dropped.
export class RelayRecoveryIntentQueue {
private replacement = false
private recovery = false
queue(forceReplacement: boolean, ownsRecovery: boolean): void {
if (forceReplacement && ownsRecovery) {
this.replacement = true
return
}
this.recovery = true
}
holdReplacement(): void {
this.replacement = true
}
hasReplacement(): boolean {
return this.replacement
}
clearReplacement(): void {
this.replacement = false
}
takeReplacement(): boolean {
const queued = this.replacement
this.replacement = false
return queued
}
takeRecovery(): boolean {
const queued = this.recovery
this.recovery = false
return queued
}
clear(): void {
this.replacement = false
this.recovery = false
}
}
@@ -1,54 +0,0 @@
import {
RpcSessionLivenessWatchdog,
type LivenessTimeoutEvidence
} from './rpc-session-liveness-watchdog'
import type { ConnectionLogSink } from './types'
// Ordinary foreground checks: two 4s misses, at most one voluntary probe per 10s.
const RELAY_PROBE = { timeoutMs: 4_000, missedProbeLimit: 2, minIntervalMs: 10_000 }
// A socket that died while the process was suspended must be admitted before the
// user reads the screen as broken. Two 2s misses, not one: the first frame after a
// resume rides a cold radio, and a single slow answer is not proof of a dead link.
const RELAY_RESUME_PROBE = { timeoutMs: 2_000, missedProbeLimit: 2 }
// Foreground-only sweep so a silently-dead relay surfaces without a user action.
const RELAY_IDLE_PROBE_MS = 25_000
// The relay session's probe budget and its timeout log line, kept apart from the
// session so the dial/RPC code and the liveness policy can each be read on its own.
export function createRelaySessionLivenessWatchdog(args: {
isForeground?: () => boolean
sendProbe: () => boolean
terminate: () => void
onLog?: ConnectionLogSink
nextLogId: () => string
}): RpcSessionLivenessWatchdog {
return new RpcSessionLivenessWatchdog({
transport: 'relay',
idleProbeMs: RELAY_IDLE_PROBE_MS,
probeTimeoutMs: RELAY_PROBE.timeoutMs,
missedProbeLimit: RELAY_PROBE.missedProbeLimit,
voluntaryProbeMinIntervalMs: RELAY_PROBE.minIntervalMs,
urgentProbeTimeoutMs: RELAY_RESUME_PROBE.timeoutMs,
urgentMissedProbeLimit: RELAY_RESUME_PROBE.missedProbeLimit,
shouldIdleProbe: () => args.isForeground?.() ?? true,
sendProbe: args.sendProbe,
onTimeout: (evidence: LivenessTimeoutEvidence) => {
// Why: the watchdog terminates the session right after this returns. A sink
// that throws must not keep a dead relay 'connected'.
try {
args.onLog?.({
id: args.nextLogId(),
ts: Date.now(),
level: 'error',
code: 'liveness-timeout',
path: 'relay',
message: 'Relay health check failed',
detail: `${evidence.reason}; ${evidence.missedProbes}/${evidence.missedProbeLimit} probes missed; last authenticated activity ${evidence.lastInboundAgeMs}ms ago`
})
} catch {
// Diagnostics only.
}
},
terminate: args.terminate
})
}
@@ -1,4 +1,3 @@
import { elapsedMs, monotonicNowMs } from './monotonic-clock'
import { redactSocketEndpoint } from './socket-event-debug'
import type { ConnectionState } from './types'
@@ -13,19 +12,16 @@ type ConnectionStateOptions = {
initialListener?: (state: ConnectionState) => void
getReconnectAttempt: () => number
isClosed: () => boolean
onStateDwell?: (previous: ConnectionState, next: ConnectionState, dweltMs: number) => void
now?: () => number
}
export class RpcClientConnectionState {
private state: ConnectionState = 'disconnected'
private lastConnectedAt: number | null = null
private stateEnteredAt: number
private stateEnteredAt = Date.now()
private readonly listeners = new Set<(state: ConnectionState) => void>()
private readonly waiters: ConnectWaiter[] = []
constructor(private readonly options: ConnectionStateOptions) {
this.stateEnteredAt = this.now()
if (options.initialListener) {
this.listeners.add(options.initialListener)
}
@@ -44,14 +40,9 @@ export class RpcClientConnectionState {
return
}
const previous = this.state
const dweltMs = elapsedMs(this.stateEnteredAt, this.now())
const dweltMs = Date.now() - this.stateEnteredAt
this.state = next
this.stateEnteredAt = this.now()
try {
this.options.onStateDwell?.(previous, next, dweltMs)
} catch {
// Diagnostics only; a broken log sink must not abort the state publish.
}
this.stateEnteredAt = Date.now()
console.log('[net] state', {
from: previous,
to: next,
@@ -112,10 +103,6 @@ export class RpcClientConnectionState {
return () => this.listeners.delete(listener)
}
private now(): number {
return (this.options.now ?? monotonicNowMs)()
}
private resolveWaiters(): void {
for (const waiter of this.waiters.splice(0)) {
if (waiter.timeout) {
@@ -67,9 +67,7 @@ describe('mobile rpc-client connection logs', () => {
onLog: (entry) => logs.push(entry)
})
expect(logs).toContainEqual(
expect.objectContaining({ message: 'Opening WebSocket', detail: 'desktop.example:7443' })
)
expect(logs[0]?.detail).toBe('desktop.example:7443')
expect(JSON.stringify(logs)).not.toContain('password')
client.close()
})
@@ -173,97 +173,4 @@ describe('RpcSessionLivenessWatchdog', () => {
watchdog.probeNow(identity)
expect(terminate).toHaveBeenCalledWith(identity)
})
function backgroundableFixture() {
const sendProbe = vi.fn(() => true)
const terminate = vi.fn()
const identity = {}
const state = { foreground: true }
const watchdog = new RpcSessionLivenessWatchdog({
transport: 'relay',
sendProbe,
terminate,
shouldIdleProbe: () => state.foreground,
now: Date.now
})
watchdog.start(identity)
return { identity, sendProbe, state, terminate, watchdog }
}
it('stops retrying an idle probe once the app backgrounds under it', async () => {
const { sendProbe, state, terminate } = backgroundableFixture()
await vi.advanceTimersByTimeAsync(LIVENESS_IDLE_MS)
expect(sendProbe).toHaveBeenCalledOnce()
// iOS suspends the socket in the background, so every further miss is evidence
// about the app and not about the peer. Retrying would spend the whole budget on
// the suspension and terminate a relay that is fine.
state.foreground = false
await vi.advanceTimersByTimeAsync(LIVENESS_PROBE_TIMEOUT_MS * 4)
expect(sendProbe).toHaveBeenCalledOnce()
expect(terminate).not.toHaveBeenCalled()
})
it('re-arms the idle sweep with a clean slate after a backgrounded probe', async () => {
const { sendProbe, state, terminate } = backgroundableFixture()
await vi.advanceTimersByTimeAsync(LIVENESS_IDLE_MS)
state.foreground = false
await vi.advanceTimersByTimeAsync(LIVENESS_PROBE_TIMEOUT_MS)
state.foreground = true
// The abandoned probe must not be carried forward as a miss: the sweep needs its
// full three fair misses again before it may call the session dead.
await vi.advanceTimersByTimeAsync(LIVENESS_IDLE_MS)
expect(sendProbe).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(LIVENESS_PROBE_TIMEOUT_MS * 2)
expect(terminate).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(LIVENESS_PROBE_TIMEOUT_MS)
expect(terminate).toHaveBeenCalledOnce()
})
it('gives a resume probe its own miss budget, not the one the ordinary probe spent', async () => {
// Why: the urgent profile exists to tolerate one slow answer from a cold radio. Inheriting
// an ordinary miss spends that tolerance before the resume probe is even sent, so the first
// slow answer on a healthy socket kills the session -- the case the profile was added for.
const terminate = vi.fn()
const sendProbe = vi.fn(() => true)
const identity = {}
const watchdog = new RpcSessionLivenessWatchdog({
transport: 'relay',
idleProbeMs: 20_000,
probeTimeoutMs: 4_000,
missedProbeLimit: 2,
urgentProbeTimeoutMs: 2_000,
urgentMissedProbeLimit: 2,
shouldIdleProbe: () => true,
sendProbe,
terminate,
now: Date.now
})
watchdog.start(identity)
// One ordinary miss on the idle sweep, tolerated, and a second ordinary probe in flight.
await vi.advanceTimersByTimeAsync(20_000)
await vi.advanceTimersByTimeAsync(4_000)
expect(terminate).not.toHaveBeenCalled()
// Foreground: the resume probe supersedes the ordinary one still in flight.
watchdog.probeNow(identity, 'resume')
await vi.advanceTimersByTimeAsync(2_000)
expect(terminate).not.toHaveBeenCalled()
// The second urgent miss is the one that may terminate.
await vi.advanceTimersByTimeAsync(2_000)
expect(terminate).toHaveBeenCalledOnce()
})
it('still reaches a verdict on a caller probe when the app backgrounds', async () => {
// The gate covers the idle sweep only. A nudge or resume probe was asked for on
// purpose, and abandoning it would leave a genuinely dead socket unreported.
const { identity, state, terminate, watchdog } = backgroundableFixture()
watchdog.probeNow(identity)
state.foreground = false
await vi.advanceTimersByTimeAsync(LIVENESS_PROBE_TIMEOUT_MS * 3)
expect(terminate).toHaveBeenCalledOnce()
})
})
@@ -13,19 +13,11 @@ type WatchdogOptions = {
probeTimeoutMs?: number
missedProbeLimit?: number
voluntaryProbeMinIntervalMs?: number
// Bounds for probeImmediately(); default to the ordinary probe bounds.
urgentProbeTimeoutMs?: number
urgentMissedProbeLimit?: number
// Gates the idle sweep only. False re-arms without probing — a backgrounded app
// must not spend a probe, and its resume probes immediately anyway.
shouldIdleProbe?: () => boolean
now?: () => number
setTimer?: typeof setTimeout
clearTimer?: typeof clearTimeout
}
type ProbeProfile = { timeoutMs: number; missedProbeLimit: number }
export type LivenessTimeoutEvidence = {
transport: 'direct' | 'relay'
reason: 'probe-send-failed' | 'probe-timeout'
@@ -38,15 +30,12 @@ export class RpcSessionLivenessWatchdog {
private identity: RpcSessionIdentity | null = null
private timer: ReturnType<typeof setTimeout> | null = null
private probing = false
// Whether the probe in flight came from the idle sweep rather than a caller.
private idleSweepProbe = false
private missedProbes = 0
private lastInboundAt = 0
private lastVoluntaryProbeAt: number | null = null
private profile: ProbeProfile
private readonly idleProbeMs: number | null
private readonly ordinaryProfile: ProbeProfile
private readonly urgentProfile: ProbeProfile
private readonly probeTimeoutMs: number
private readonly missedProbeLimit: number
private readonly voluntaryProbeMinIntervalMs: number
private readonly now: () => number
private readonly setTimer: typeof setTimeout
@@ -54,15 +43,8 @@ export class RpcSessionLivenessWatchdog {
constructor(private readonly options: WatchdogOptions) {
this.idleProbeMs = options.idleProbeMs === undefined ? LIVENESS_IDLE_MS : options.idleProbeMs
this.ordinaryProfile = {
timeoutMs: options.probeTimeoutMs ?? LIVENESS_PROBE_TIMEOUT_MS,
missedProbeLimit: options.missedProbeLimit ?? MISSED_PROBE_LIMIT
}
this.urgentProfile = {
timeoutMs: options.urgentProbeTimeoutMs ?? this.ordinaryProfile.timeoutMs,
missedProbeLimit: options.urgentMissedProbeLimit ?? this.ordinaryProfile.missedProbeLimit
}
this.profile = this.ordinaryProfile
this.probeTimeoutMs = options.probeTimeoutMs ?? LIVENESS_PROBE_TIMEOUT_MS
this.missedProbeLimit = options.missedProbeLimit ?? MISSED_PROBE_LIMIT
this.voluntaryProbeMinIntervalMs = options.voluntaryProbeMinIntervalMs ?? 0
this.now = options.now ?? Date.now
this.setTimer = options.setTimer ?? setTimeout
@@ -73,11 +55,9 @@ export class RpcSessionLivenessWatchdog {
this.clearActiveTimer()
this.identity = identity
this.probing = false
this.idleSweepProbe = false
this.missedProbes = 0
this.lastInboundAt = this.now()
this.lastVoluntaryProbeAt = null
this.profile = this.ordinaryProfile
this.armIdle(identity)
}
@@ -104,28 +84,22 @@ export class RpcSessionLivenessWatchdog {
}
this.missedProbes = 0
this.probing = false
this.idleSweepProbe = false
this.armIdle(identity)
}
// 'resume' is evidence the socket may have died while the process was suspended:
// it ignores the voluntary minimum, runs on the urgent bounds, and replaces any
// probe already in flight so the verdict lands on the short clock.
probeNow(identity: RpcSessionIdentity, urgency: 'nudge' | 'resume' = 'nudge'): void {
const urgent = urgency === 'resume'
if (this.identity !== identity || (this.probing && !urgent)) {
probeNow(identity: RpcSessionIdentity): void {
if (this.identity !== identity || this.probing) {
return
}
const now = this.now()
if (
!urgent &&
this.lastVoluntaryProbeAt !== null &&
now - this.lastVoluntaryProbeAt < this.voluntaryProbeMinIntervalMs
) {
return
}
this.lastVoluntaryProbeAt = now
this.startProbe(identity, urgent ? this.urgentProfile : this.ordinaryProfile)
this.startProbe(identity)
}
stop(identity: RpcSessionIdentity): void {
@@ -135,11 +109,9 @@ export class RpcSessionLivenessWatchdog {
this.clearActiveTimer()
this.identity = null
this.probing = false
this.idleSweepProbe = false
this.missedProbes = 0
this.lastInboundAt = 0
this.lastVoluntaryProbeAt = null
this.profile = this.ordinaryProfile
}
private armIdle(identity: RpcSessionIdentity, delayMs = this.idleProbeMs): void {
@@ -152,37 +124,21 @@ export class RpcSessionLivenessWatchdog {
if (this.identity !== identity) {
return
}
if (this.options.shouldIdleProbe && !this.options.shouldIdleProbe()) {
this.armIdle(identity)
return
}
const idleMs = this.now() - this.lastInboundAt
if (this.idleProbeMs !== null && idleMs < this.idleProbeMs) {
this.armIdle(identity, Math.max(1, this.idleProbeMs - Math.max(0, idleMs)))
} else {
this.startProbe(identity, this.ordinaryProfile, true)
this.startProbe(identity)
}
}, delayMs)
}
private startProbe(
identity: RpcSessionIdentity,
profile = this.ordinaryProfile,
fromIdleSweep = false
): void {
private startProbe(identity: RpcSessionIdentity): void {
if (this.identity !== identity) {
return
}
this.clearActiveTimer()
// Why: switching profile starts a new observation window on a different clock. Carrying the
// ordinary probe's misses into the urgent one spends the tolerated slow answer that profile
// exists to give a cold radio, so the first 2s miss would kill a healthy socket.
if (profile !== this.profile) {
this.missedProbes = 0
}
this.profile = profile
this.probing = true
this.idleSweepProbe = fromIdleSweep
const sentAt = this.now()
let sent = false
try {
@@ -194,7 +150,7 @@ export class RpcSessionLivenessWatchdog {
this.terminateCurrent(identity, 'probe-send-failed')
return
}
this.timer = this.setTimer(() => this.handleProbeTimeout(identity, sentAt), profile.timeoutMs)
this.timer = this.setTimer(() => this.handleProbeTimeout(identity, sentAt), this.probeTimeoutMs)
}
private handleProbeTimeout(identity: RpcSessionIdentity, sentAt: number): void {
@@ -202,38 +158,27 @@ export class RpcSessionLivenessWatchdog {
if (this.identity !== identity) {
return
}
// Why: the idle sweep is foreground-only because iOS suspends sockets in the
// background, where a miss is not evidence of a dead peer. Retrying here would
// spend the whole miss budget on that suspension and kill a healthy session.
if (this.idleSweepProbe && this.options.shouldIdleProbe && !this.options.shouldIdleProbe()) {
this.probing = false
this.idleSweepProbe = false
this.missedProbes = 0
this.armIdle(identity)
return
}
const profile = this.profile
const elapsedMs = this.now() - sentAt
if (elapsedMs < 0 || elapsedMs > profile.timeoutMs * 1.5) {
if (elapsedMs < 0 || elapsedMs > this.probeTimeoutMs * 1.5) {
console.log('[net] activity-probe unfair window skipped', {
transport: this.options.transport,
elapsedMs,
timeoutMs: profile.timeoutMs
timeoutMs: this.probeTimeoutMs
})
this.startProbe(identity, profile, this.idleSweepProbe)
this.startProbe(identity)
return
}
this.missedProbes += 1
if (this.missedProbes >= profile.missedProbeLimit) {
if (this.missedProbes >= this.missedProbeLimit) {
this.terminateCurrent(identity, 'probe-timeout')
return
}
console.log('[net] activity-probe timeout tolerated', {
transport: this.options.transport,
missedProbes: this.missedProbes,
missedProbeLimit: profile.missedProbeLimit
missedProbeLimit: this.missedProbeLimit
})
this.startProbe(identity, profile, this.idleSweepProbe)
this.startProbe(identity)
}
private terminateCurrent(
@@ -246,17 +191,16 @@ export class RpcSessionLivenessWatchdog {
this.clearActiveTimer()
this.identity = null
this.probing = false
this.idleSweepProbe = false
console.log('[net] activity-probe TIMEOUT — forcing reconnect', {
transport: this.options.transport,
missedProbes: this.missedProbes,
missedProbeLimit: this.profile.missedProbeLimit
missedProbeLimit: this.missedProbeLimit
})
this.options.onTimeout?.({
transport: this.options.transport,
reason,
missedProbes: this.missedProbes,
missedProbeLimit: this.profile.missedProbeLimit,
missedProbeLimit: this.missedProbeLimit,
lastInboundAgeMs: Math.max(0, this.now() - this.lastInboundAt)
})
this.options.terminate(identity)
@@ -1,9 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
readRuntimeCapabilities,
startRuntimeCapabilityProbe,
startRuntimeStatusProbe
} from './runtime-status-probe'
import { startRuntimeCapabilityProbe } from './runtime-capability-probe'
import { LogicalClientCutoverError } from './stable-logical-rpc-client'
import type { RpcClient } from './rpc-client'
import type { RpcResponse } from './types'
@@ -129,46 +125,19 @@ describe('startRuntimeCapabilityProbe', () => {
cancel()
})
// Was: an ok:false response was retried like a timeout. The probe now backs the gate that sits
// above every /h/ route, so polling a host that already answered would run for the life of the
// connection. A reply is an answer; only an unanswered request is retried.
it('settles once on an ok:false response rather than polling the host', async () => {
it('retries an ok:false response instead of settling', async () => {
const failure: RpcResponse = {
ok: false,
id: '1',
error: { code: 'internal', message: 'nope' },
_meta: { runtimeId: 'r1' }
}
const { client, calls } = makeClient([failure, ok(['a.v1'])])
const { client } = makeClient([failure, ok(['a.v1'])])
const seen: (readonly string[])[] = []
const retrying: boolean[] = []
const cancel = startRuntimeStatusProbe(client, {
onStatus: (status) => seen.push(readRuntimeCapabilities(status)),
onUnavailable: (isRetrying) => retrying.push(isRetrying)
})
const cancel = startRuntimeCapabilityProbe(client, (capabilities) => seen.push(capabilities))
await flushMicrotasks()
expect(retrying).toEqual([false])
expect(seen).toEqual([])
await vi.advanceTimersByTimeAsync(60_000)
expect(calls()).toBe(1)
expect(seen).toEqual([])
cancel()
})
it('still retries a request the host never answered', async () => {
const { client, calls } = makeClient([new Error('timeout'), ok(['a.v1'])])
const seen: (readonly string[])[] = []
const retrying: boolean[] = []
const cancel = startRuntimeStatusProbe(client, {
onStatus: (status) => seen.push(readRuntimeCapabilities(status)),
onUnavailable: (isRetrying) => retrying.push(isRetrying)
})
await flushMicrotasks()
expect(retrying).toEqual([true])
await vi.advanceTimersByTimeAsync(1_000)
expect(calls()).toBe(2)
expect(seen).toEqual([['a.v1']])
cancel()
})
@@ -213,40 +182,4 @@ describe('startRuntimeCapabilityProbe', () => {
await flushMicrotasks()
expect(seen).toEqual([])
})
it('reports the full status, not just capabilities', async () => {
const response: RpcResponse = {
ok: true,
id: '1',
result: { appVersion: '1.4.0', protocolVersion: 7, capabilities: ['a.v1'] },
_meta: { runtimeId: 'r1' }
}
const { client } = makeClient([response])
const seen: Record<string, unknown>[] = []
const cancel = startRuntimeStatusProbe(client, { onStatus: (status) => seen.push(status) })
await flushMicrotasks()
expect(seen).toEqual([{ appVersion: '1.4.0', protocolVersion: 7, capabilities: ['a.v1'] }])
cancel()
})
// Why: the gate needs to release its pending cover on the first miss rather than wait out the
// retries, so a wedged status.get cannot hold the whole host UI behind a spinner.
it('announces each failed attempt while the retry is still pending', async () => {
const { client, calls } = makeClient([new Error('boom'), ok(['a.v1'])])
const misses: number[] = []
const seen: Record<string, unknown>[] = []
const cancel = startRuntimeStatusProbe(client, {
onStatus: (status) => seen.push(status),
onUnavailable: () => misses.push(calls())
})
await flushMicrotasks()
expect(misses).toEqual([1])
expect(seen).toEqual([])
await vi.advanceTimersByTimeAsync(1_000)
await flushMicrotasks()
expect(seen).toEqual([{ capabilities: ['a.v1'] }])
expect(misses).toEqual([1])
cancel()
})
})
@@ -9,20 +9,9 @@ const CUTOVER_RETRY_DELAY_MS = 250
const FAILURE_RETRY_BASE_DELAY_MS = 1_000
const FAILURE_RETRY_MAX_DELAY_MS = 15_000
export type RuntimeStatusProbeHandlers = {
onStatus: (status: Record<string, unknown>) => void
// Fires once per attempt that produced no status. `retrying` is false when the host itself
// answered with an error: that is a definitive reply, so the probe stops rather than polling a
// host that has already said no. It is true when nothing reached us and a retry is armed, which
// lets a caller that must not stay blocked fail open on the first miss and be upgraded later.
onUnavailable?: (retrying: boolean) => void
}
// Single status.get producer for a connected client: one request, retried until it
// lands. Callers share the answer instead of each issuing their own status.get.
export function startRuntimeStatusProbe(
client: Pick<RpcClient, 'sendRequest'>,
handlers: RuntimeStatusProbeHandlers
export function startRuntimeCapabilityProbe(
client: RpcClient,
onCapabilities: (capabilities: readonly string[]) => void
): () => void {
let cancelled = false
let retryTimer: ReturnType<typeof setTimeout> | null = null
@@ -35,15 +24,20 @@ export function startRuntimeStatusProbe(
return
}
if (!response.ok) {
// Why not retry: the desktop replied. Re-asking every 15 s for the life of a connection
// from a probe mounted above every /h/ route buys nothing a reconnect would not.
handlers.onUnavailable?.(false)
scheduleRetry(false)
return
}
const result = (response as RpcSuccess).result
handlers.onStatus(
result && typeof result === 'object' ? (result as Record<string, unknown>) : {}
)
const rawCapabilities =
result && typeof result === 'object'
? (result as { capabilities?: unknown }).capabilities
: null
const capabilities =
Array.isArray(rawCapabilities) &&
rawCapabilities.every((value) => typeof value === 'string')
? rawCapabilities
: []
onCapabilities(capabilities)
},
(error: unknown) => {
if (cancelled) {
@@ -61,7 +55,6 @@ export function startRuntimeStatusProbe(
? CUTOVER_RETRY_DELAY_MS
: Math.min(FAILURE_RETRY_BASE_DELAY_MS * 2 ** failureRetries++, FAILURE_RETRY_MAX_DELAY_MS)
retryTimer = setTimeout(attempt, delay)
handlers.onUnavailable?.(true)
}
attempt()
@@ -72,17 +65,3 @@ export function startRuntimeStatusProbe(
}
}
}
export function readRuntimeCapabilities(status: Record<string, unknown>): readonly string[] {
const raw = status.capabilities
return Array.isArray(raw) && raw.every((value) => typeof value === 'string') ? raw : []
}
export function startRuntimeCapabilityProbe(
client: Pick<RpcClient, 'sendRequest'>,
onCapabilities: (capabilities: readonly string[]) => void
): () => void {
return startRuntimeStatusProbe(client, {
onStatus: (status) => onCapabilities(readRuntimeCapabilities(status))
})
}
+1 -23
View File
@@ -58,17 +58,6 @@ export type ConnectionDiagnosticCode =
| 'relay-credential-unavailable'
| 'host-open-failed'
// Why: a 10s connect used to read as one opaque "connecting" span. Attaching the
// duration of the phase an entry closes out lets the report say where the time
// went. Diagnostics only — nothing schedules from these.
export type ConnectionLogTiming = {
kind: 'relay-dial-stage' | 'connection-state'
name: string
ms: number
// False when the phase never finished (the dial died inside it).
complete: boolean
}
export type ConnectionLogEntry = {
id: string
ts: number
@@ -79,7 +68,6 @@ export type ConnectionLogEntry = {
detail?: string
code?: ConnectionDiagnosticCode
path?: MobileConnectionDiagnosticPath
timing?: ConnectionLogTiming
}
export type ConnectionLogSink = (entry: ConnectionLogEntry) => void
@@ -88,7 +76,7 @@ export type ConnectionLogEmitter = (
level: ConnectionLogLevel,
message: string,
detail?: string,
evidence?: Pick<ConnectionLogEntry, 'code' | 'path' | 'timing'>
evidence?: Pick<ConnectionLogEntry, 'code' | 'path'>
) => void
export type ConnectionState =
@@ -99,16 +87,6 @@ export type ConnectionState =
| 'reconnecting'
| 'auth-failed'
// Exhaustive by construction; see RELAY_DIAL_STAGE_NAMES for why.
export const CONNECTION_STATE_NAMES: Record<ConnectionState, true> = {
connecting: true,
handshaking: true,
connected: true,
disconnected: true,
reconnecting: true,
'auth-failed': true
}
// Why: a user-attention nudge must not tear down a healthy relay (probe it); only a
// network-change nudge marks the socket suspect enough to replace it.
export type ForegroundNudgeReason = 'focus' | 'app-resume' | 'network-change'
@@ -1,104 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const asyncStorage = vi.hoisted(() => ({
getItem: vi.fn(async () => null),
setItem: vi.fn(async () => undefined),
removeItem: vi.fn(async () => undefined)
}))
const deletions = vi.hoisted(() => ({
deviceToken: vi.fn(async () => undefined),
credentialBundle: vi.fn(async () => undefined),
directUpgradeJournal: vi.fn(async () => undefined),
clearWriteRevision: vi.fn()
}))
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
vi.mock('./host-device-token-store', () => ({ deleteHostDeviceToken: deletions.deviceToken }))
vi.mock('./mobile-relay-credential-bundle', () => ({
deleteMobileRelayCredentialBundle: deletions.credentialBundle
}))
vi.mock('./mobile-relay-direct-upgrade-journal', () => ({
deleteMobileRelayDirectUpgradeJournal: deletions.directUpgradeJournal
}))
vi.mock('./host-credential-write-revision', () => ({
clearHostCredentialWriteRevision: deletions.clearWriteRevision,
getHostCredentialWriteRevision: () => 0
}))
import { createUnpairedHostCredentialDeletion } from './unpaired-host-credential-deletion'
import {
getSessionTabStripCacheKey,
readCachedSessionTabStrip,
resetSessionTabStripCacheForTests,
saveCachedSessionTabStrip
} from '../cache/session-tab-strip-cache'
const strip = {
tabs: [{ id: 'tab-1', type: 'terminal' as const, title: 'Terminal', agentId: null }],
activeTabId: 'tab-1'
}
function createDeletion(storedHostIds: string[] = []) {
return createUnpairedHostCredentialDeletion({
waitForHostMutations: async () => undefined,
hasStoredHost: async (hostId) => storedHostIds.includes(hostId),
onDeleted: vi.fn()
})
}
beforeEach(() => {
asyncStorage.getItem.mockClear()
asyncStorage.setItem.mockClear()
for (const mock of Object.values(deletions)) {
mock.mockClear()
}
resetSessionTabStripCacheForTests()
})
describe('unpaired host credential deletion', () => {
it('takes the cached tab strip with the credentials, leaving other hosts alone', async () => {
// Why: the strip is not a credential, but it is host-scoped plaintext written from the
// session screen. Without this sweep it outlives the pairing that produced it.
const unpaired = getSessionTabStripCacheKey('host-1', 'wt-1')
const other = getSessionTabStripCacheKey('host-2', 'wt-1')
saveCachedSessionTabStrip(unpaired, strip)
saveCachedSessionTabStrip(other, strip)
await createDeletion()('host-1', 0)
expect(readCachedSessionTabStrip(unpaired)).toBeNull()
expect(readCachedSessionTabStrip(other)?.tabs).toHaveLength(1)
})
it('finishes the cleanup when the cache purge fails', async () => {
// Why: every credential above is already deleted by this point. Aborting on the cache
// would strand the write revision and leave onDeleted's token cache holding a host whose
// credentials are gone, retried only by an explicit Settings action.
const onDeleted = vi.fn()
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
asyncStorage.setItem.mockRejectedValueOnce(new Error('disk full'))
await expect(
createUnpairedHostCredentialDeletion({
waitForHostMutations: async () => undefined,
hasStoredHost: async () => false,
onDeleted
})('host-1', 0)
).resolves.toBeUndefined()
expect(deletions.clearWriteRevision).toHaveBeenCalledWith('host-1')
expect(onDeleted).toHaveBeenCalledWith('host-1')
expect(warn).toHaveBeenCalled()
warn.mockRestore()
})
it('leaves the strip alone when the host turned out to still be paired', async () => {
const stillPaired = getSessionTabStripCacheKey('host-1', 'wt-1')
saveCachedSessionTabStrip(stillPaired, strip)
await createDeletion(['host-1'])('host-1', 0)
expect(readCachedSessionTabStrip(stillPaired)?.tabs).toHaveLength(1)
expect(deletions.deviceToken).not.toHaveBeenCalled()
})
})
@@ -1,4 +1,3 @@
import { deleteCachedSessionTabStripForHost } from '../cache/session-tab-strip-cache'
import { deleteHostDeviceToken } from './host-device-token-store'
import {
clearHostCredentialWriteRevision,
@@ -53,18 +52,6 @@ export function createUnpairedHostCredentialDeletion(dependencies: DeletionDepen
return
}
assertWriteRevisionUnchanged(hostId, writeRevision)
// The cached tab strip is not a credential, but it is host-scoped plaintext that outlives
// the pairing unless this sweep takes it too. Warned rather than thrown, as
// removeHostAndCloseClient does: every credential above is already gone, so aborting here
// would strand the write revision and leave onDeleted's token cache holding a host whose
// credentials no longer exist. The cache refuses further saves for this host either way.
await deleteCachedSessionTabStripForHost(hostId).catch((error: unknown) => {
console.warn('[unpaired-host-cleanup] cached tab strip delete failed', error)
})
if (await shouldSkip(hostId, writeRevision)) {
return
}
assertWriteRevisionUnchanged(hostId, writeRevision)
clearHostCredentialWriteRevision(hostId)
dependencies.onDeleted(hostId)
}
@@ -13,7 +13,7 @@ import { WORKTREE_PS_FULL_LIMIT } from './worktree-catalog-snapshot-client'
const ACTIVE_STATUSES = new Set(['working', 'active', 'permission'])
// Why: a relay↔direct cutover rejects in-flight reads without ever leaving 'connected', so the
// connect gate never re-arms. Re-issue on the replacement session; cap it so a migration loop
// can't spin. See runtime-status-probe.ts for the same hazard on status.get.
// can't spin. See runtime-capability-probe.ts for the same hazard on status.get.
const CUTOVER_RETRY_LIMIT = 2
export type HostWorktreeInfoSetter = (