fix(mobile): close reconnect review gaps

This commit is contained in:
Brennan Benson
2026-08-01 12:43:14 -07:00
parent ac0c199eec
commit 8e00b065bd
12 changed files with 127 additions and 39 deletions
@@ -4457,7 +4457,7 @@ export default function SessionScreen() {
accessibilityRole={showConnectionRetry ? 'button' : undefined}
accessibilityLabel={showConnectionRetry ? 'Reconnect to desktop' : undefined}
>
<StatusDot state={connState} />
<StatusDot state={connState} verdict={connectionVerdict} />
<Text style={styles.sessionMetaText} numberOfLines={1}>
{terminalSummary}
</Text>
@@ -31,12 +31,12 @@ export function useAllHostClients(hostIds: string[]) {
}, [key])
return useMemo(() => {
const clients: Array<{
const clients: {
hostId: string
client: RpcClient
state: ConnectionState
path: MobileConnectionPath
}> = []
}[] = []
for (const id of hostIds) {
const found = context.getAllClients().find((entry) => entry.hostId === id)
if (found) {
+18 -10
View File
@@ -289,11 +289,15 @@ describe('useHostClient', () => {
})
expect(stale.closeMock).toHaveBeenCalledOnce()
expect(fresh.sendRequest).toHaveBeenCalledWith('status.get', undefined, {
timeoutMs: expect.any(Number),
budgetSpansConnect: true,
strictDeadline: true
})
expect(fresh.sendRequest).toHaveBeenCalledWith(
'worktree.list',
{ limit: 1 },
{
timeoutMs: expect.any(Number),
budgetSpansConnect: true,
strictDeadline: true
}
)
expect(completed).toBe(false)
resolveHealthCheck?.()
@@ -623,11 +627,15 @@ describe('useHostClient', () => {
expect(connectMock.mock.calls[2]?.[0]).toEqual(updatedHost)
expect(oldReplacement.closeMock).toHaveBeenCalledOnce()
expect(newReplacement.sendRequest).toHaveBeenCalledWith('status.get', undefined, {
timeoutMs: expect.any(Number),
budgetSpansConnect: true,
strictDeadline: true
})
expect(newReplacement.sendRequest).toHaveBeenCalledWith(
'worktree.list',
{ limit: 1 },
{
timeoutMs: expect.any(Number),
budgetSpansConnect: true,
strictDeadline: true
}
)
harness.unmount()
})
@@ -219,6 +219,37 @@ describe('existing host relay endpoint lifecycle publication', () => {
expect(events).toEqual(['publication-started', 'publication-finished', 'removed'])
})
it('fences a queued stale publication before it mutates credentials', async () => {
let releaseFirstPublication: (() => void) | null = null
let markFirstPublicationStarted: (() => void) | null = null
const firstPublicationStarted = new Promise<void>((resolve) => {
markFirstPublicationStarted = resolve
})
const firstPublication = publishHostProfileTransaction(
HOST,
async () => {
markFirstPublicationStarted?.()
await new Promise<void>((resolve) => {
releaseFirstPublication = resolve
})
},
async () => {}
)
await firstPublicationStarted
const staleCredentialWrite = vi.fn(async () => {})
const stalePublication = publishHostProfileTransaction(
{ ...HOST, deviceToken: 'token-2' },
staleCredentialWrite,
async () => {}
)
releaseFirstPublication?.()
await firstPublication
await expect(stalePublication).rejects.toThrow('host profile publication was retired')
expect(staleCredentialWrite).not.toHaveBeenCalled()
})
it('rejects an old credential write that starts during same-host re-pair publication', async () => {
let releaseReplacement: (() => void) | null = null
let markReplacementStarted: (() => void) | null = null
@@ -41,11 +41,15 @@ describe('Force Reconnect RPC health', () => {
expect(outcome).toBe('stalled')
expect(sendRequest).toHaveBeenCalledTimes(2)
expect(sendRequest).toHaveBeenLastCalledWith('status.get', undefined, {
timeoutMs: 1_000,
budgetSpansConnect: true,
strictDeadline: true
})
expect(sendRequest).toHaveBeenLastCalledWith(
'worktree.list',
{ limit: 1 },
{
timeoutMs: 1_000,
budgetSpansConnect: true,
strictDeadline: true
}
)
})
it('waits through a transient authorization retry', async () => {
@@ -67,22 +71,26 @@ describe('Force Reconnect RPC health', () => {
expect(sendRequest).toHaveBeenCalledTimes(2)
})
it('rejects a successful probe until a real application response clears the shared latch', async () => {
it('clears the shared latch with an application-level replacement probe', async () => {
const responsiveness = new RpcApplicationResponsiveness()
responsiveness.recordTimeout('browser.screenshot', 123)
const sendRequest = vi.fn(async () => ({ id: 'rpc-1', ok: true, result: {} }) as RpcResponse)
const sendRequest = vi.fn(async (method: string) => {
responsiveness.recordResponse(method)
return { id: 'rpc-1', ok: true, result: {} } as RpcResponse
})
const client = {
sendRequest,
getState: () => 'connected' as const,
getRpcUnresponsiveSince: () => responsiveness.getUnresponsiveSince()
} as unknown as RpcClient
await expect(verifyForceReconnectRpcHealth(client)).rejects.toThrow(
'Application RPC channel is still not responding'
)
responsiveness.recordResponse('worktree.list')
await expect(verifyForceReconnectRpcHealth(client)).resolves.toBeUndefined()
expect(sendRequest).toHaveBeenCalledTimes(2)
expect(sendRequest).toHaveBeenCalledWith(
'worktree.list',
{ limit: 1 },
expect.objectContaining({ strictDeadline: true })
)
expect(responsiveness.getUnresponsiveSince()).toBeNull()
})
it('fails when authorization retries reach auth-failed', async () => {
@@ -15,11 +15,15 @@ export async function verifyForceReconnectRpcHealth(
throw lastError ?? new Error('Force Reconnect health check timed out')
}
try {
await client.sendRequest('status.get', undefined, {
timeoutMs,
budgetSpansConnect: true,
strictDeadline: true
})
await client.sendRequest(
'worktree.list',
{ limit: 1 },
{
timeoutMs,
budgetSpansConnect: true,
strictDeadline: true
}
)
if (client.getRpcUnresponsiveSince?.() != null) {
throw new Error('Application RPC channel is still not responding')
}
@@ -81,6 +81,7 @@ export function publishHostProfileTransaction(
): Promise<void> {
const publicationRevision = getHostProfilePublicationRevision(host.id)
return serializeHostProfilePublication(host.id, async () => {
requirePublicationRevision(host.id, publicationRevision)
await beforeHostSave?.()
requirePublicationRevision(host.id, publicationRevision)
await saveHost(host)
@@ -14,6 +14,7 @@ import {
} from './existing-host-relay-routing'
import {
beginHostEndpointPublicationLifecycle,
getHostEndpointPublicationLifecycle,
type HostEndpointPublicationLifecycle
} from './host-profile-publication'
import { upgradeDirectMobileRelay } from './mobile-relay-direct-upgrade'
@@ -78,7 +79,14 @@ export function startMobileEndpointLifecycle(
if (initialHost.relay) {
// Why: relay publication supersedes any committed journal retained by the retired direct owner.
void retireMobileRelayDirectUpgradeJournalForRelayHost(initialHost.id).catch(() => {})
void retireMobileRelayDirectUpgradeJournalForRelayHost(initialHost.id, () => {
const current = getHostEndpointPublicationLifecycle(initialHost.id)
return (
!stopped &&
current.generation === endpointLifecycle.generation &&
current.profileRevision === endpointLifecycle.profileRevision
)
}).catch(() => {})
owner = createSupervisor(
logical,
initialHost,
@@ -37,4 +37,27 @@ describe('mobile relay direct upgrade journal retirement', () => {
JSON.stringify(journal)
)
})
it('does not clear a replacement journal after the owning lifecycle stops', async () => {
const journal = createMobileRelayDirectUpgradeJournal('host-1', (length) =>
new Uint8Array(length).fill(8)
)
let resolveRead: ((raw: string) => void) | null = null
keychain.readPairingKeychainItem.mockReturnValueOnce(
new Promise<string>((resolve) => {
resolveRead = resolve
})
)
let active = true
const retirement = retireMobileRelayDirectUpgradeJournalForRelayHost(
journal.hostId,
() => active
)
active = false
resolveRead?.(JSON.stringify(journal))
await retirement
expect(keychain.deletePairingKeychainItemIfMatches).not.toHaveBeenCalled()
})
})
@@ -82,10 +82,11 @@ export function deleteMobileRelayDirectUpgradeJournalIfCurrent(
}
export async function retireMobileRelayDirectUpgradeJournalForRelayHost(
hostId: string
hostId: string,
shouldRetire: () => boolean = () => true
): Promise<void> {
const journal = await readMobileRelayDirectUpgradeJournal(hostId)
if (journal) {
if (journal && shouldRetire()) {
await deleteMobileRelayDirectUpgradeJournalIfCurrent(journal)
}
}
@@ -442,7 +442,7 @@ describe('mobile rpc-client request deadline', () => {
await vi.advanceTimersByTimeAsync(1)
await settled
expect(outcome).toBe('Request timed out: status.get')
expect(outcome).toBe('Request timed out: worktree.list')
client.close()
})
@@ -116,11 +116,15 @@ describe('stable logical RPC client', () => {
await client.migrateTo(nextSession, 'relay')
await expect(verifying).resolves.toBeUndefined()
expect(nextSession.sendRequest).toHaveBeenCalledWith('status.get', undefined, {
timeoutMs: expect.any(Number),
budgetSpansConnect: true,
strictDeadline: true
})
expect(nextSession.sendRequest).toHaveBeenCalledWith(
'worktree.list',
{ limit: 1 },
{
timeoutMs: expect.any(Number),
budgetSpansConnect: true,
strictDeadline: true
}
)
pending.resolve(success('late'))
})