stablyai/orca · warning · MobileRelayUpgradeHostRemovedError

mobile relay upgrade host was removed

Error message

mobile relay upgrade host was removed

What it means

persistHost was called with requireExisting=true (via saveExistingHostRelayUpgrade) but the host ID was not found in the stored list. The code throws MobileRelayUpgradeHostRemovedError specifically to prevent an in-flight relay upgrade from silently resurrecting a host the user already removed between when the upgrade started and when it committed.

Solutions

  1. Catch MobileRelayUpgradeHostRemovedError specifically and abort the upgrade silently (the user's remove intent is authoritative).
  2. Do NOT retry the upgrade — it would resurrect a deliberately-removed host.
  3. Surface 'Host removed — upgrade cancelled' as an informational toast, not an error.
  4. Guard the upgrade entry point by re-checking the host exists immediately before saveExistingHostRelayUpgrade.

Example fix

// before
try {
  await saveExistingHostRelayUpgrade(host)
} catch (e) {
  showError(e.message)
}

// after — the removal is intentional, abort quietly
try {
  await saveExistingHostRelayUpgrade(host)
} catch (e) {
  if (e instanceof MobileRelayUpgradeHostRemovedError) {
    navigateAway() // user already removed it
    return
  }
  throw e
}
Defensive patterns

Strategy: try-catch

Validate before calling

// Re-check the host exists immediately before a relay upgrade
const hosts = await loadHosts()
if (!hosts.some((h) => h.id === host.id)) {
  return // host already removed
}

Type guard

import { MobileRelayUpgradeHostRemovedError } from './host-store'

function isRelayHostRemoved(e: unknown): e is MobileRelayUpgradeHostRemovedError {
  return e instanceof MobileRelayUpgradeHostRemovedError
}

Try / catch

try {
  await saveExistingHostRelayUpgrade(host)
} catch (e) {
  if (e instanceof MobileRelayUpgradeHostRemovedError) {
    // User removed the host — abort the upgrade silently
    return
  }
  throw e
}

Prevention

When it happens

Trigger: User started a relay-direct upgrade, then removed the host from Settings before the upgrade's metadata write landed; a duplicate-key collapse removed the row; a concurrent removeHost won the mutation-chain race.

Common situations: User taps 'Upgrade to direct connection' then quickly swipes to delete the host; relay upgrade retries after the host was already removed; rapid pair/unpair actions racing the upgrade flow.

Related errors


AI-assisted analysis of stablyai/orca@1136503c6a (2026-08-12). Data as JSON: /api/errors/7e8546d2db631d8e. Report an issue: GitHub.

Appendix: source

Thrown at mobile/src/transport/host-store.ts:193

  let tokenCommittedBeforeMetadata = false
  try {
    await mutateStoredHosts(async (hosts) => {
      const index = hosts.findIndex((h) => h.id === stored.id)
      for (const candidate of hosts) {
        if (candidate.id !== stored.id && candidate.publicKeyB64 === stored.publicKeyB64) {
          duplicateHostIds.add(candidate.id)
        }
      }
      let next: StoredHostProfile[]
      if (index !== -1) {
        updatedExistingHost = true
        // Why: an authoritative save is the safe point to collapse pre-existing duplicate rows to the preserved host id.
        next = hosts
          .filter(({ id }) => !duplicateHostIds.has(id))
          .map((candidate) => (candidate.id === stored.id ? stored : candidate))
      } else if (requireExisting) {
        // Why: an in-flight relay upgrade must not resurrect a host the user removed.
        throw new MobileRelayUpgradeHostRemovedError('mobile relay upgrade host was removed')
      } else {
        next = [...hosts.filter(({ id }) => !duplicateHostIds.has(id)), stored]
      }
      if (duplicateHostIds.size > 0) {
        if (index === -1) {
          // Why: process death between the early token write and metadata publication must leave cleanup discoverable.
          await recordHostCredentialCleanupIntent(stored.id)
          cleanupIntentRecordedBeforeMetadata = true
        }
        for (const duplicateHostId of duplicateHostIds) {
          await recordHostCredentialCleanupIntent(duplicateHostId)
        }
        // Why: never remove the only usable same-key row until its replacement credential is durable.
        await commitDeviceToken(stored.id, validated.deviceToken)
        tokenCommittedBeforeMetadata = true
      }
      return next
    })

View on GitHub (pinned to 1136503c6a)