fix(mobile): abandon pairing journals that can no longer reconcile (#12773)
This commit is contained in:
parent
7f4570c9a6
commit
be2f9eddd3
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue