feat(ios): auto-reconnect + audio-device-change recovery

Network drops (e.g. Wi-Fi -> cellular) and audio-device plug/unplug (wired
headphones, AirPods) used to leave the iOS client in a dead/zombie state:
the engine went silent, no reconnect was attempted, and a live-session
disconnect waited 30-60 s for the C core's TCP keepalive/reaper timeout.

Reconnect (AppState.swift, SessionState.swift):
- Two-layer reconcile. Once SessionState overwrites client.onEvent at auth
  success, AppState.handleConnectEvent no longer sees live-session events.
  Added a weak SessionState.appState; SessionState.handleEvent .disconnected
  calls appState.onLiveSessionDisconnected after the cue -- the single path
  AppState learns a live session dropped. Shared teardownLiveSessionAndReconnect
  snapshots lastSession, stops audio, releases session/VoiceCatClient (io-
  thread join via vc_client_destroy), resets the backoff, and arms
  scheduleReconnect (exponential 1s -> 30s cap, indefinite, restored on auth
  success via existing TOFU_MATCHED auto-confirm + idempotent join_channel).
- NWPathMonitor now runs WHILE CONNECTED (not only mid-reconnect). On a Wi-Fi
  <-> cellular interface change or path .unsatisfied it calls
  proactiveReconnect: tearing the session down BEFORE the C core notices the
  dead socket collapses the 30-60 s reaper wait into ~1 s + first backoff
  tick. Same-interface refreshes (BSSID roams) are ignored via pathSignature.
  While mid-reconnect a .satisfied path resets the backoff for a fast retry.
- User-initiated disconnect()/cancelConnect() set userInitiatedDisconnect
  and cancel all reconnect state (task + monitor + lastSession + connectedServer).

Audio recovery (AudioSessionManager.swift, IOSVoiceProcessingEngine.swift):
- Intent-gated recoverAudio() replaces the narrow .oldDeviceUnavailable/
  .newDeviceAvailable route-change guard; fires on every externally-initiated
  route change reason except the ones we cause ourselves (.categoryChange/
  .routeConfigurationChange) to avoid a notification loop. Interruption-end
  now always recovers instead of only when .shouldResume is set.
- Added AVAudioEngineConfigurationChange observer on the engine so a system
  self-stop after our route-change handler wins the race is caught.
- IOSAudioEngine.rebuild() does a one-shot reactivation-retry on
  engine.start() failure (iOS sometimes refuses until the session is
  re-reactivated -- the silent-death case).

No C ABI / voicecat.h / proto / core changes. Swift-only. iOS sim build green
via scripts/build-ios-client.sh --no-configure (Xcode 26.5 / iOS 18.0 sim).
This commit is contained in:
2026-06-25 14:57:13 +02:00
parent 44a336cc89
commit 99937c9446
5 changed files with 600 additions and 53 deletions

View File

@@ -187,6 +187,29 @@ final class IOSAudioEngine {
renderScratch.initialize(repeating: 0, count: renderScratchFrames * 2)
micDrainScratch = UnsafeMutablePointer<Int16>.allocate(capacity: 960 * 2)
micDrainScratch.initialize(repeating: 0, count: 960 * 2)
// AVAudioEngine stops itself on a mid-session route/configuration change (it stops
// if its I/O graph no longer matches the active route). Our route-change handler in
// AudioSessionManager normally rebuilds us before the user notices, but if the engine
// stops itself AFTER our recovery (because the route-change notification raced ahead
// of the engine's own self-stop), nothing restarts it. Catch that case here.
NotificationCenter.default.addObserver(
self, selector: #selector(handleEngineConfigurationChange),
name: .AVAudioEngineConfigurationChange, object: engine)
}
/// The engine stopped itself because its configuration no longer matches the active AVAudio
/// route (this fires after a route change that the route-change handler can't always outrun).
/// Dispatch to main and call the unified `recoverAudio()` it's intent-gated on
/// `isConnected`, idempotent, and no-ops if the engine is already running (the common case
/// where our route-change handler got there first).
@objc private func handleEngineConfigurationChange(_ notification: Notification) {
Task { @MainActor [weak self] in
guard let self else { return }
guard self.isConnected, !self.engine.isRunning else { return }
logger.info("engine configuration-change — engine stopped itself, recovering")
AudioSessionManager.shared.recoverAudio()
}
}
// MARK: - Lifecycle
@@ -308,7 +331,29 @@ final class IOSAudioEngine {
inFormat=\(inFmt) outputNode=\(outFmt) outputRoute=[\(route)]
""")
} catch {
logger.error("engine start failed: \(error.localizedDescription)")
// iOS occasionally refuses to start the engine immediately after a route change
// the AVAudioSession needs a re-activation nudge before the engine will start. Do
// ONE recovery attempt: re-activate the session, re-apply the route config, then
// try `engine.start()` again. Recovering here is what fixes the silent-death bug
// where unplugging headphones left the engine stopped forever.
logger.error("engine start failed: \(error.localizedDescription) — attempting one-shot recovery")
do {
try AudioSessionManager.shared.ensureSessionActive()
} catch {
logger.error("recovery — session re-activate failed: \(error.localizedDescription)")
}
IOSAudioRouter.shared.applyConfiguration()
if AudioSessionManager.shared.isActive {
IOSAudioRouter.shared.applyA2dpSpeakerFallback()
}
do {
try engine.start()
logger.info("engine start succeeded after one-shot recovery")
} catch {
logger.error("engine start failed after recovery: \(error.localizedDescription)")
// Not fatal a subsequent route-change or AVAudioEngine configuration-change
// notification will trigger recoverAudio() and re-attempt the rebuild.
}
}
// Start the feed pump last, with the current channel count, so it never carries a stale