Fix retrying interrupted Relay pairing (#8809)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-07-14 21:08:26 -07:00 committed by GitHub
parent ef87461bfe
commit 58f222b43f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 169 additions and 24 deletions

View File

@ -130,7 +130,7 @@ describe('mobile relay pairing journal store', () => {
expect(metadataRaw).toBeNull()
})
it('cleans mismatched secret records and refuses to replace a recoverable journal', async () => {
it('cleans mismatched secret records', async () => {
const journal = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-1',
@ -142,6 +142,137 @@ describe('mobile relay pairing journal store', () => {
await expect(loadMobileRelayPairingJournal()).resolves.toBeNull()
expect(metadataRaw).toBeNull()
expect(secretRaw).toBeNull()
})
it('replaces a journal that never selected an authorization path', async () => {
const journal = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-1',
hostName: 'Blue Whale',
randomBytes: (length) => new Uint8Array(length).fill(10)
})
await saveMobileRelayPairingJournal(journal)
const replacement = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-2',
hostName: 'Red Panda',
randomBytes: (length) => new Uint8Array(length).fill(12)
})
const replacementSave = saveMobileRelayPairingJournal(replacement)
const staleUpdate = updateMobileRelayPairingJournal(
journal.metadata.journalId,
(metadata) => metadata
)
await expect(replacementSave).resolves.toBeUndefined()
await expect(staleUpdate).rejects.toThrow(/stale/)
await expect(loadMobileRelayPairingJournal()).resolves.toEqual(replacement)
})
it('serializes a replacement behind an in-flight journal snapshot', async () => {
const journal = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-1',
hostName: 'Blue Whale',
randomBytes: (length) => new Uint8Array(length).fill(10)
})
await saveMobileRelayPairingJournal(journal)
let releaseSecretRead!: () => void
const secretReadGate = new Promise<void>((resolve) => {
releaseSecretRead = resolve
})
secureStore.getItemAsync.mockImplementationOnce(async () => {
await secretReadGate
return secretRaw
})
const loading = loadMobileRelayPairingJournal()
await vi.waitFor(() => expect(secureStore.getItemAsync).toHaveBeenCalled())
const replacement = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-2',
hostName: 'Red Panda',
randomBytes: (length) => new Uint8Array(length).fill(12)
})
let replacementSaved = false
const saving = saveMobileRelayPairingJournal(replacement).then(() => {
replacementSaved = true
})
await Promise.resolve()
expect(replacementSaved).toBe(false)
releaseSecretRead()
await expect(loading).resolves.toEqual(journal)
await expect(saving).resolves.toBeUndefined()
await expect(loadMobileRelayPairingJournal()).resolves.toEqual(replacement)
expect(asyncStorage.removeItem).not.toHaveBeenCalled()
})
it('cleans a replacement whose secret write fails', async () => {
const journal = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-1',
hostName: 'Blue Whale',
randomBytes: (length) => new Uint8Array(length).fill(10)
})
await saveMobileRelayPairingJournal(journal)
const replacement = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-2',
hostName: 'Red Panda',
randomBytes: (length) => new Uint8Array(length).fill(12)
})
secureStore.setItemAsync.mockRejectedValueOnce(new Error('keychain unavailable'))
await expect(saveMobileRelayPairingJournal(replacement)).rejects.toThrow(/keychain/)
await expect(loadMobileRelayPairingJournal()).resolves.toBeNull()
expect(metadataRaw).toBeNull()
expect(secretRaw).toBeNull()
})
it('blocks replacement when the old authorization update wins the mutation race', async () => {
const journal = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-1',
hostName: 'Blue Whale',
randomBytes: (length) => new Uint8Array(length).fill(10)
})
await saveMobileRelayPairingJournal(journal)
const replacement = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-2',
hostName: 'Red Panda',
randomBytes: (length) => new Uint8Array(length).fill(12)
})
const authorizationUpdate = updateMobileRelayPairingJournal(
journal.metadata.journalId,
(metadata) => ({
...metadata,
winner: 'direct',
authorizationMode: 'authenticated-direct'
})
)
const replacementSave = saveMobileRelayPairingJournal(replacement)
await expect(authorizationUpdate).resolves.toBeUndefined()
await expect(replacementSave).rejects.toThrow(/recovery pending/)
})
it.each([
['winner', { winner: 'direct' as const }],
['authorization mode', { authorizationMode: 'authenticated-direct' as const }]
])('refuses replacement once the durable %s exists', async (_name, authorization) => {
const created = createMobileRelayPairingJournal({
offer: offer as PairingOffer & { relay: NonNullable<PairingOffer['relay']> },
hostId: 'host-1',
hostName: 'Blue Whale',
randomBytes: (length) => new Uint8Array(length).fill(10)
})
const journal = {
...created,
metadata: { ...created.metadata, ...authorization }
}
await saveMobileRelayPairingJournal(journal)
const replacement = createMobileRelayPairingJournal({

View File

@ -27,9 +27,15 @@ export async function saveMobileRelayPairingJournal(
const mutation = journalMutation.then(async () => {
const existingRaw = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY)
const existing = existingRaw ? parseMetadata(existingRaw) : null
if (existing && existing.journalId !== metadata.journalId) {
if (
existing &&
existing.journalId !== metadata.journalId &&
(existing.winner !== undefined || existing.authorizationMode !== undefined)
) {
throw new Error('mobile relay pairing recovery pending')
}
// Why: no install RPC can run before winner+authorization are durable, so
// a new user-initiated scan may safely supersede a pre-authorization attempt.
// Why: metadata-first makes a crash before the keychain write recover as
// an incomplete journal, never as an untracked bearer secret.
await AsyncStorage.setItem(JOURNAL_STORAGE_KEY, JSON.stringify(metadata))
@ -41,28 +47,36 @@ export async function saveMobileRelayPairingJournal(
export async function loadMobileRelayPairingJournal(): Promise<MobileRelayPairingJournal | null> {
requireNativeSecretStore()
await journalMutation
const rawMetadata = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY)
if (rawMetadata === null) {
await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS).catch(() => {})
return null
}
const metadata = parseMetadata(rawMetadata)
if (!metadata) {
await removeIncompleteJournal()
return null
}
const rawSecrets = await SecureStore.getItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS)
if (rawSecrets === null) {
await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY)
return null
}
const secrets = parseSecrets(rawSecrets)
if (!secrets || secrets.journalId !== metadata.journalId) {
await removeIncompleteJournal()
return null
}
return { metadata, secrets }
const load = journalMutation.then(async () => {
const rawMetadata = await AsyncStorage.getItem(JOURNAL_STORAGE_KEY)
if (rawMetadata === null) {
await SecureStore.deleteItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS).catch(() => {})
return null
}
const metadata = parseMetadata(rawMetadata)
if (!metadata) {
await removeIncompleteJournal()
return null
}
const rawSecrets = await SecureStore.getItemAsync(JOURNAL_SECRET_KEY, KEYCHAIN_OPTIONS)
if (rawSecrets === null) {
await AsyncStorage.removeItem(JOURNAL_STORAGE_KEY)
return null
}
const secrets = parseSecrets(rawSecrets)
if (!secrets || secrets.journalId !== metadata.journalId) {
await removeIncompleteJournal()
return null
}
return { metadata, secrets }
})
// Why: recovery may load while a new scan saves; serialize the complete
// metadata/secret snapshot and any cleanup so it cannot delete the new journal.
journalMutation = load.then(
() => undefined,
() => undefined
)
return load
}
async function removeIncompleteJournal(): Promise<void> {