fix(mobile): abandon pairing journals that can no longer reconcile (#12773)

This commit is contained in:
Jinwoo Hong 2026-08-05 13:45:39 -07:00 committed by GitHub
parent 7f4570c9a6
commit be2f9eddd3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 73 additions and 1 deletions

View File

@ -220,4 +220,53 @@ describe('mobile relay pairing recovery', () => {
expect(saved.metadata.authorizationMode).toBe('authenticated-direct')
expect(deps.updateJournal).toHaveBeenCalledTimes(2)
})
// Why: a journal stranded by a relay outage used to block every later pairing
// with "recovery pending" forever, because recovery only ever deferred.
it('abandons a journal once its invite expired and no credential can reconcile', async () => {
const saved = journal()
const unreachable = client(async () => {
throw new Error('relay unreachable')
})
const deps = {
...dependencies({ journal: saved, connectRelay: vi.fn(() => unreachable) }),
now: () => saved.metadata.relay.inviteExpiresAt + 10 * 60 * 1000 + 1
}
await expect(recoverMobileRelayPairing(deps)).resolves.toBe('abandoned')
expect(deps.clearJournal).toHaveBeenCalledWith(saved.metadata.journalId)
})
it('keeps a just-expired journal so a brief outage cannot discard it', async () => {
const saved = journal()
const unreachable = client(async () => {
throw new Error('relay unreachable')
})
const deps = {
...dependencies({ journal: saved, connectRelay: vi.fn(() => unreachable) }),
now: () => saved.metadata.relay.inviteExpiresAt + 1
}
await expect(recoverMobileRelayPairing(deps)).resolves.toBe('deferred')
expect(deps.clearJournal).not.toHaveBeenCalled()
})
// Why: a committed install whose local persistence failed is the one case the
// journal must survive — it is the only record left to retry the write from.
it('keeps a journal when the server committed but the local write failed', async () => {
const saved = journal()
const directInstalled = installed(saved, 'authenticated-direct')
const committed = client(async () =>
response(endpoints(saved, { state: 'committed', result: directInstalled }))
)
const deps = {
...dependencies({ journal: saved, connectRelay: vi.fn(() => committed) }),
now: () => saved.metadata.relay.inviteExpiresAt + 10 * 60 * 1000 + 1,
writeCredentialBundle: vi.fn(async () => {
throw new Error('keychain unavailable')
})
}
await expect(recoverMobileRelayPairing(deps)).resolves.toBe('deferred')
expect(deps.clearJournal).not.toHaveBeenCalled()
})
})

View File

@ -27,7 +27,7 @@ import {
import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate'
import type { HostProfile, RpcResponse } from './types'
export type MobileRelayPairingRecoveryResult = 'none' | 'recovered' | 'deferred'
export type MobileRelayPairingRecoveryResult = 'none' | 'recovered' | 'deferred' | 'abandoned'
type RecoveryDependencies = {
loadJournal: typeof loadMobileRelayPairingJournal
@ -57,6 +57,10 @@ const defaultDependencies: RecoveryDependencies = {
platform: Platform.OS
}
// One full invite lifetime past expiry, so a momentary outage never discards a
// journal that a later launch could still reconcile.
const ABANDON_GRACE_MS = 10 * 60 * 1000
let recoveryPromise: Promise<MobileRelayPairingRecoveryResult> | null = null
export function recoverMobileRelayPairing(
@ -96,6 +100,10 @@ async function runRecovery(
}
const credentials = recoveryCredentials(journal, bundle, dependencies.now())
// Why: publishCommitted runs inside the catch below, so a failed local write
// of an authoritatively committed install must not look like "nothing to
// reconcile" — that journal is the only record left to retry the write from.
let observedCommitted = false
for (const credential of credentials) {
let client: PairingCandidateClient | null = null
try {
@ -113,6 +121,7 @@ async function runRecovery(
})
const endpoints = await getRecoveryStatus(client, journal, credential.kind)
if (endpoints.installStatus?.state === 'committed') {
observedCommitted = true
await publishCommitted(journal, endpoints, dependencies)
return 'recovered'
}
@ -128,6 +137,7 @@ async function runRecovery(
)
const reconciled = await getRecoveryStatus(client, journal, 'invite')
assertCommitted(reconciled, installed)
observedCommitted = true
await publishCommitted(journal, reconciled, dependencies)
return 'recovered'
}
@ -138,6 +148,19 @@ async function runRecovery(
client?.close()
}
}
// Why: past invite expiry no credential can still establish what happened, so
// retaining the journal cannot reconcile anything — it only fails every later
// pairing with "recovery pending" forever. Re-pairing mints a fresh device and
// any uncommitted server-side install expires on its own. The extra invite
// lifetime of slack keeps a brief relay outage from discarding a journal whose
// resume credential would have reconciled it on the next launch.
if (
!observedCommitted &&
journal.metadata.relay.inviteExpiresAt + ABANDON_GRACE_MS <= dependencies.now()
) {
await dependencies.clearJournal(journal.metadata.journalId).catch(() => {})
return 'abandoned'
}
return 'deferred'
}