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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user