fix(mobile): fence reconnect recovery lifecycles

This commit is contained in:
Brennan Benson
2026-08-01 05:28:27 -07:00
parent 0954f98504
commit 357275ba56
12 changed files with 286 additions and 57 deletions
@@ -415,6 +415,74 @@ describe('useHostClient', () => {
harness.unmount()
})
it('publishes lifecycle updates after cold-start priming races the host lookup', async () => {
let resolveLookup: ((hosts: HostProfile[]) => void) | null = null
const lookup = new Promise<HostProfile[]>((resolve) => {
resolveLookup = resolve
})
const initial = makeFakeClient('connected')
const fresh = makeFakeClient('connected')
fresh.sendRequest = vi.fn(async () => ({ id: 'health', ok: true, result: {} }))
loadHostsMock.mockReturnValue(lookup)
connectMock.mockReturnValueOnce(initial).mockReturnValueOnce(fresh)
const harness = await renderHarness(HOST.id)
harness.primeHosts([HOST])
await act(async () => {
resolveLookup?.([HOST])
await lookup
})
const relayHostId = 'AbCdEf0123_-xyZ9'
const upgradedHost: HostProfile = {
...HOST,
endpoints: [
{ id: 'direct-primary', kind: 'lan', url: HOST.endpoint },
{ id: 'relay-primary', kind: 'relay', url: 'wss://relay.invalid/v1/connect/host' }
],
relayHostId,
relay: {
v: 1,
directorUrl: 'https://relay.invalid',
cellUrl: 'https://relay.invalid',
assignmentEpoch: 1,
relayHostId,
e2eeFraming: 2
}
}
const publishHostUpdate = connectMock.mock.calls[0]?.[2] as
| ((host: HostProfile) => void)
| undefined
publishHostUpdate?.(upgradedHost)
await act(async () => harness.forceReconnect(HOST.id))
expect(connectMock.mock.calls[1]?.[0]).toEqual(upgradedHost)
harness.unmount()
})
it('ignores a cancelled lookup failure after its replacement connects', async () => {
let rejectLookup: ((error: Error) => void) | null = null
const lookup = new Promise<HostProfile[]>((_resolve, reject) => {
rejectLookup = reject
})
const fresh = makeFakeClient('connected')
fresh.sendRequest = vi.fn(async () => ({ id: 'health', ok: true, result: {} }))
loadHostsMock.mockReturnValue(lookup)
connectMock.mockReturnValue(fresh)
const harness = await renderHarness(HOST.id)
harness.primeHosts([HOST])
await act(async () => harness.forceReconnect(HOST.id))
await act(async () => {
rejectLookup?.(new Error('keychain locked'))
await lookup.catch(() => undefined)
})
expect(harness.hook.client).toBe(fresh)
expect(harness.hook.state).toBe('connected')
harness.unmount()
})
it('coalesces overlapping Force Reconnect calls for one host', async () => {
const stale = makeFakeClient('connected')
const fresh = makeFakeClient('connecting')
+22 -25
View File
@@ -16,6 +16,7 @@ import { HostForceReconnectCoordinator, type HostReconnectEntry } from './host-f
import { HostReconnectProfileCache, type HostOpenProfile } from './host-reconnect-profile-cache'
import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers'
import { HostClientOpenRegistry } from './host-client-open-registry'
import { loadHostClientOpenProfile } from './host-client-open-profile'
import { loadHosts } from './host-store'
import { openHostLogicalClient } from './host-logical-client'
import { clientActivePath } from './rpc-client-active-path'
@@ -85,29 +86,26 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
const pendingOpen = pendingOpensRef.current.register(hostId, profileVersion, promise)
try {
// Why: prefer the primed cache so we don't serialize a second Keychain pass on cold start.
let host = requestedProfile?.host ?? primedHostsRef.current.get(hostId)
if (!host) {
try {
const hosts = await loadHosts()
host = hosts.find((h) => h.id === hostId)
} catch {
// Why: cold-start Keychain failure (iOS mid-unlock / Android Keystore race); surface 'disconnected' so the user can Reconnect.
notifyHostState(hostId, 'disconnected')
notifyAllHosts()
return null
}
if (!host) {
// Why: silent return leaves screens on a permanent spinner (STA-1511); surface 'disconnected' so they show a retry affordance.
notifyHostState(hostId, 'disconnected')
notifyAllHosts()
return null
}
}
if (pendingOpen.cancelled) {
const cachedHost = requestedProfile?.host ?? primedHostsRef.current.get(hostId)
const profile = cachedHost
? { host: cachedHost, version: requestedProfile?.version ?? profileVersion }
: await loadHostClientOpenProfile({
hostId,
cache: primedHostsRef.current,
ticket: pendingOpen,
loadHosts,
onUnavailable: () => {
notifyHostState(hostId, 'disconnected')
notifyAllHosts()
}
})
if (!profile || pendingOpen.cancelled) {
return null
}
const resolvedProfile = cachedHost
? profile
: primedHostsRef.current.resolveLoadedOpenProfile(profile.host)
pendingOpen.profileVersion = resolvedProfile.version
// Re-check after any await — another acquire() may have completed.
const after = storeRef.current.get(hostId)
@@ -119,9 +117,9 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
const onLog: ConnectionLogSink = (entry) => connectionLogStore.append(hostId, entry)
try {
client = openHostLogicalClient(
host,
resolvedProfile.host,
onLog,
primedHostsRef.current.publisher(hostId, profileVersion)
primedHostsRef.current.publisher(hostId, resolvedProfile.version)
)
} catch {
// Why: openHostLogicalClient can throw synchronously (bad public key / invalid URL); notify so the UI leaves 'connecting'.
@@ -459,6 +457,5 @@ export function useForceReconnect(): (hostId: string) => Promise<void> {
// Why: primes already-loaded HostProfiles so the provider can skip a second loadHosts()/Keychain pass on cold start.
export function usePrimeHosts(): (hosts: HostProfile[]) => void {
const ctx = useRpcClientContext()
return ctx.primeHosts
return useRpcClientContext().primeHosts
}
@@ -1,5 +1,5 @@
import { HostProfileSchema, type HostProfile } from './types'
import { loadHosts } from './host-store'
import { loadStoredHostCredentialIdentity } from './host-store'
import { serializeHostProfilePublication } from './host-profile-publication'
import * as hostListLoads from './host-list-load-sharing'
import { saveMobileRelayHostOverlay } from './mobile-relay-host-overlay-store'
@@ -54,7 +54,7 @@ export async function writeExistingHostRelayCredentialBundle(
async function requireCurrentHostIdentity(
host: HostProfile
): Promise<Pick<HostProfile, 'endpoint'>> {
const existing = (await loadHosts({ requireCredentials: true })).find(({ id }) => id === host.id)
const existing = await loadStoredHostCredentialIdentity(host.id)
if (!existing || existing.publicKeyB64 !== host.publicKeyB64) {
throw new MobileRelayUpgradeHostRemovedError('mobile relay upgrade host was removed')
}
@@ -0,0 +1,34 @@
import type { HostClientOpenTicket } from './host-client-open-registry'
import { HostReconnectProfileCache } from './host-reconnect-profile-cache'
import type { HostProfile } from './types'
type HostClientOpenProfileOptions = {
hostId: string
cache: HostReconnectProfileCache
ticket: HostClientOpenTicket
loadHosts: () => Promise<HostProfile[]>
onUnavailable: () => void
}
export async function loadHostClientOpenProfile(
options: HostClientOpenProfileOptions
): Promise<{ host: HostProfile; version: number } | null> {
const { hostId, cache, ticket } = options
let loadedHost: HostProfile | undefined
try {
loadedHost = (await options.loadHosts()).find(({ id }) => id === hostId)
} catch {
if (!ticket.cancelled) {
options.onUnavailable()
}
return null
}
if (ticket.cancelled) {
return null
}
if (!loadedHost) {
options.onUnavailable()
return null
}
return { host: cache.get(hostId) ?? loadedHost, version: cache.version(hostId) }
}
@@ -45,6 +45,10 @@ export class HostReconnectProfileCache {
}
}
resolveLoadedOpenProfile(host: HostProfile): { host: HostProfile; version: number } {
return { host: this.get(host.id) ?? host, version: this.version(host.id) }
}
get(hostId: string): HostProfile | undefined {
return this.profiles.get(hostId)?.host
}
+44
View File
@@ -282,6 +282,49 @@ describe('host-store list mutations', () => {
expect(storedOverlayRaw).toBeNull()
})
it('validates only the target credential during relay publication', async () => {
secureStoreMock.getItemAsync.mockImplementation(async (key: string) =>
key.endsWith(HOST_ONE.id) ? 'token-1' : null
)
const relayHostId = 'AbCdEf0123_-xyZ9'
await saveExistingHostRelayRouting({
...HOST_ONE,
deviceToken: 'token-1',
endpoints: [
{ id: 'direct-primary', kind: 'lan', url: HOST_ONE.endpoint },
{ id: 'relay-primary', kind: 'relay', url: 'wss://relay.onorca.dev/v1/connect/host' }
],
relayHostId,
relay: {
v: 1,
directorUrl: 'https://relay.onorca.dev',
cellUrl: 'https://relay.onorca.dev',
assignmentEpoch: 1,
relayHostId,
e2eeFraming: 2
}
})
expect(secureStoreMock.getItemAsync).toHaveBeenCalledOnce()
expect(secureStoreMock.getItemAsync.mock.calls[0]?.[0]).toContain(HOST_ONE.id)
expect(JSON.parse(storedOverlayRaw ?? '[]')).toHaveLength(1)
})
it('does not infer host removal from unreadable identity storage', async () => {
storedHostsRaw = '{'
const error = await saveExistingHostRelayRouting({
...HOST_ONE,
deviceToken: 'token-1'
}).catch((reason: unknown) => reason)
expect(error).toMatchObject({ message: 'host list storage unreadable' })
expect(error).not.toBeInstanceOf(MobileRelayUpgradeHostRemovedError)
expect(storedOverlayRaw).toBeNull()
expect(secureStoreMock.getItemAsync).not.toHaveBeenCalled()
})
it('publishes relay routing without overwriting a newer endpoint edit', async () => {
const relayHostId = 'AbCdEf0123_-xyZ9'
await updateHostNameAndEndpoint(HOST_ONE.id, { endpoint: 'ws://127.0.0.1:9' })
@@ -355,6 +398,7 @@ describe('host-store list mutations', () => {
it('rejects relay routing from a lifecycle superseded by same-host re-pairing', async () => {
await saveHost({ ...HOST_ONE, deviceToken: 'replacement-token' })
secureStoreMock.getItemAsync.mockResolvedValueOnce('replacement-token')
const relayHostId = 'AbCdEf0123_-xyZ9'
await expect(
+19 -15
View File
@@ -64,19 +64,14 @@ function parseStoredHosts(raw: string | null): StoredHostProfile[] | null {
}
}
export async function loadHosts(
options: { requireCredentials?: boolean } = {}
): Promise<HostProfile[]> {
export async function loadHosts(): Promise<HostProfile[]> {
// Why: writers hold the mutation chain across their full RMW; wait so a load doesn't race a half-written list.
await hostListMutation
if (options.requireCredentials) {
return doLoadHosts(true)
}
// Why: deduplicate concurrent loadHosts() calls so simultaneously mounting screens share one Keychain read pass.
return hostListLoads.shareHostListLoad(() => doLoadHosts(false))
return hostListLoads.shareHostListLoad(doLoadHosts)
}
async function doLoadHosts(requireCredentials: boolean): Promise<HostProfile[]> {
async function doLoadHosts(): Promise<HostProfile[]> {
const raw = await AsyncStorage.getItem(STORAGE_KEY)
const storedHosts = parseStoredHosts(raw)
if (!storedHosts) {
@@ -99,17 +94,11 @@ async function doLoadHosts(requireCredentials: boolean): Promise<HostProfile[]>
let fetched: string | null
try {
fetched = await readHostDeviceToken(stored.id)
} catch (error) {
if (requireCredentials) {
throw error
}
} catch {
// Why: a transient Keychain failure for one entry (e.g. errSecInteractionNotAllowed while locked) must not blank the whole host list; skip it.
continue
}
if (!fetched) {
if (requireCredentials) {
throw new Error('host credential unavailable')
}
// Why: orphaned metadata with no matching keychain entry; skip rather than surface a half-broken host.
continue
}
@@ -134,6 +123,21 @@ async function doLoadHosts(requireCredentials: boolean): Promise<HostProfile[]>
return out
}
export async function loadStoredHostCredentialIdentity(
hostId: string
): Promise<Pick<HostProfile, 'endpoint' | 'publicKeyB64' | 'deviceToken'> | null> {
await hostListMutation
const stored = (await readStoredHostsForMutation()).find(({ id }) => id === hostId)
if (!stored) {
return null
}
const deviceToken = await readHostDeviceToken(hostId)
if (!deviceToken) {
throw new Error('host credential unavailable')
}
return { endpoint: stored.endpoint, publicKeyB64: stored.publicKeyB64, deviceToken }
}
export async function resolvePairingHostIdentity(
publicKeyB64: string,
newHostId: string
@@ -3,6 +3,7 @@ import { z } from 'zod'
import { hashMobileRelayCredential } from './mobile-relay-credential-hash'
import {
deletePairingKeychainItem,
deletePairingKeychainItemIfMatches,
readPairingKeychainItem,
writePairingKeychainItem
} from './pairing-keychain'
@@ -70,6 +71,16 @@ export async function deleteMobileRelayDirectUpgradeJournal(hostId: string): Pro
await deletePairingKeychainItem(journalKey(hostId))
}
export function deleteMobileRelayDirectUpgradeJournalIfCurrent(
journal: MobileRelayDirectUpgradeJournal
): Promise<boolean> {
if (Platform.OS === 'web') {
return Promise.resolve(false)
}
const parsed = MobileRelayDirectUpgradeJournalSchema.parse(journal)
return deletePairingKeychainItemIfMatches(journalKey(parsed.hostId), JSON.stringify(parsed))
}
function encodeBase64Url(value: Uint8Array): string {
let binary = ''
for (const byte of value) {
@@ -62,8 +62,12 @@ function dependencies(journal: MobileRelayDirectUpgradeJournal | null = null) {
writeJournal: vi.fn(async (next: MobileRelayDirectUpgradeJournal) => {
stored = next
}),
clearJournal: vi.fn(async () => {
clearJournal: vi.fn(async (expected: MobileRelayDirectUpgradeJournal) => {
if (stored?.reqId !== expected.reqId) {
return false
}
stored = null
return true
}),
writeBundle: vi.fn(async () => {}),
deleteBundle: vi.fn(async () => {}),
@@ -121,7 +125,7 @@ describe('existing direct pairing relay upgrade', () => {
expect(deps.saveHost).toHaveBeenCalledWith(expect.any(Object), expect.any(Function))
expect(deps.writeBundle).toHaveBeenCalledOnce()
expect(result?.host.relay).toEqual(relay)
expect(deps.clearJournal).toHaveBeenCalledWith(host.id)
expect(deps.clearJournal).toHaveBeenCalledWith(journal)
})
it('does not delete a replacement pairing bundle when the old upgrade is superseded', async () => {
@@ -146,7 +150,35 @@ describe('existing direct pairing relay upgrade', () => {
).rejects.toBeInstanceOf(MobileRelayUpgradeHostSupersededError)
expect(deps.writeBundle).not.toHaveBeenCalled()
expect(deps.deleteBundle).not.toHaveBeenCalled()
expect(deps.clearJournal).toHaveBeenCalledWith(host.id)
expect(deps.clearJournal).toHaveBeenCalledWith(journal)
})
it('does not clear a replacement lifecycle journal from a superseded upgrade', async () => {
const journal = createMobileRelayDirectUpgradeJournal(host.id, (length) =>
new Uint8Array(length).fill(6)
)
const replacement = createMobileRelayDirectUpgradeJournal(host.id, (length) =>
new Uint8Array(length).fill(9)
)
const committed = installed(journal)
const deps = dependencies(journal)
deps.saveHost.mockImplementation(async () => {
await deps.writeJournal(replacement)
throw new MobileRelayUpgradeHostSupersededError('mobile relay upgrade host was re-paired')
})
const client = clientWith([
success({
v: 1,
relay,
installStatus: { v: 1, reqId: journal.reqId, state: 'committed', result: committed }
})
])
await expect(
upgradeDirectMobileRelay({ client, host, dependencies: deps })
).rejects.toBeInstanceOf(MobileRelayUpgradeHostSupersededError)
await expect(deps.readJournal()).resolves.toEqual(replacement)
})
it('retains recovery state when host identity credentials are temporarily unavailable', async () => {
@@ -205,7 +237,7 @@ describe('existing direct pairing relay upgrade', () => {
])
await expect(upgradeDirectMobileRelay({ client, host, dependencies: deps })).resolves.toBeNull()
expect(deps.clearJournal).toHaveBeenCalledWith(host.id)
expect(deps.clearJournal).toHaveBeenCalledWith(expect.objectContaining({ hostId: host.id }))
expect(deps.writeBundle).not.toHaveBeenCalled()
expect(deps.saveHost).not.toHaveBeenCalled()
})
@@ -242,6 +274,6 @@ describe('existing direct pairing relay upgrade', () => {
upgradeDirectMobileRelay({ client, host, dependencies: deps })
).rejects.toBeInstanceOf(MobileRelayUpgradeHostRemovedError)
expect(deps.deleteBundle).toHaveBeenCalledWith(host.id)
expect(deps.clearJournal).toHaveBeenCalledWith(host.id)
expect(deps.clearJournal).toHaveBeenCalledWith(journal)
})
})
@@ -19,7 +19,7 @@ import {
} from './mobile-relay-credential-bundle'
import {
createMobileRelayDirectUpgradeJournal,
deleteMobileRelayDirectUpgradeJournal,
deleteMobileRelayDirectUpgradeJournalIfCurrent,
readMobileRelayDirectUpgradeJournal,
writeMobileRelayDirectUpgradeJournal,
type MobileRelayDirectUpgradeJournal
@@ -35,7 +35,7 @@ export type MobileRelayDirectUpgradeResult = {
type Dependencies = {
readJournal: typeof readMobileRelayDirectUpgradeJournal
writeJournal: typeof writeMobileRelayDirectUpgradeJournal
clearJournal: typeof deleteMobileRelayDirectUpgradeJournal
clearJournal: typeof deleteMobileRelayDirectUpgradeJournalIfCurrent
writeBundle: typeof writeMobileRelayCredentialBundle
saveHost: typeof saveExistingHostRelayRouting
deleteBundle: typeof deleteMobileRelayCredentialBundle
@@ -53,7 +53,7 @@ export async function upgradeDirectMobileRelay(args: {
const dependencies: Dependencies = {
readJournal: readMobileRelayDirectUpgradeJournal,
writeJournal: writeMobileRelayDirectUpgradeJournal,
clearJournal: deleteMobileRelayDirectUpgradeJournal,
clearJournal: deleteMobileRelayDirectUpgradeJournalIfCurrent,
writeBundle: writeMobileRelayCredentialBundle,
saveHost: saveExistingHostRelayRouting,
deleteBundle: deleteMobileRelayCredentialBundle,
@@ -69,7 +69,7 @@ export async function upgradeDirectMobileRelay(args: {
const initial = await getEndpoints(args.client, journal.reqId)
if (initial === 'method-not-found') {
await dependencies.clearJournal(args.host.id)
await dependencies.clearJournal(journal)
return null
}
if (initial.installStatus?.state === 'committed') {
@@ -84,7 +84,7 @@ export async function upgradeDirectMobileRelay(args: {
newResumeTokenHash: journal.pendingResumeTokenHash
})
if (isMethodNotFound(provisionResponse)) {
await dependencies.clearJournal(args.host.id)
await dependencies.clearJournal(journal)
return null
}
const installed = DeviceCredentialInstalledSchema.parse(requireSuccess(provisionResponse))
@@ -127,13 +127,13 @@ async function publishCommitted(
} catch (error) {
if (error instanceof MobileRelayUpgradeHostRemovedError) {
await dependencies.deleteBundle(host.id)
await dependencies.clearJournal(host.id)
await dependencies.clearJournal(journal)
} else if (error instanceof MobileRelayUpgradeHostSupersededError) {
await dependencies.clearJournal(host.id)
await dependencies.clearJournal(journal)
}
throw error
}
await dependencies.clearJournal(host.id)
await dependencies.clearJournal(journal)
return { host: updatedHost, bundle }
}
@@ -23,6 +23,7 @@ vi.mock('react-native', () => ({ Platform: platformMock }))
import {
deletePairingKeychainItem,
deletePairingKeychainItemIfMatches,
readPairingKeychainItem,
resetPairingKeychainForTests,
writePairingKeychainItem
@@ -338,6 +339,22 @@ describe('pairing keychain', () => {
expect(asyncStorageMock.removeItem).toHaveBeenCalledWith(TOKEN_PRESENCE_KEY)
})
it('does not delete a keychain item replaced by a newer lifecycle', async () => {
secureStoreMock.getItemAsync.mockResolvedValue('replacement')
await expect(deletePairingKeychainItemIfMatches(TOKEN_KEY, 'stale')).resolves.toBe(false)
expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled()
})
it('deletes a keychain item only while its expected value is current', async () => {
secureStoreMock.getItemAsync.mockResolvedValue('current')
await expect(deletePairingKeychainItemIfMatches(TOKEN_KEY, 'current')).resolves.toBe(true)
expect(secureStoreMock.deleteItemAsync).toHaveBeenCalledOnce()
})
it('reports a partial delete failure after attempting every generation', async () => {
generationRecord = '2'
const deleteError = new Error('delete failed')
+20 -2
View File
@@ -156,8 +156,7 @@ async function setItemAtGeneration(key: string, value: string, generation: numbe
}
}
export async function readPairingKeychainItem(key: string): Promise<string | null> {
await keychainMutation
async function readPairingKeychainItemImpl(key: string): Promise<string | null> {
const presenceGeneration = await loadPresenceGeneration(key)
if (presenceGeneration !== null) {
const value = await SecureStore.getItemAsync(key, optionsForGeneration(presenceGeneration))
@@ -182,6 +181,11 @@ export async function readPairingKeychainItem(key: string): Promise<string | nul
return null
}
export async function readPairingKeychainItem(key: string): Promise<string | null> {
await keychainMutation
return readPairingKeychainItemImpl(key)
}
async function writePairingKeychainItemImpl(key: string, value: string): Promise<void> {
const loaded = await loadGeneration()
if (!loaded.reliable) {
@@ -255,6 +259,20 @@ export function deletePairingKeychainItem(key: string): Promise<void> {
return enqueueKeychainMutation(() => deletePairingKeychainItemImpl(key))
}
export function deletePairingKeychainItemIfMatches(
key: string,
expectedValue: string
): Promise<boolean> {
let deleted = false
return enqueueKeychainMutation(async () => {
if ((await readPairingKeychainItemImpl(key)) !== expectedValue) {
return
}
await deletePairingKeychainItemImpl(key)
deleted = true
}).then(() => deleted)
}
/** Test-only: drop cached generation and mutation state between cases. */
export function resetPairingKeychainForTests(): void {
cachedGeneration = null