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:
@@ -54,6 +54,30 @@ final class SessionState {
|
||||
var accounts: [Account] = []
|
||||
var devices: [Device] = []
|
||||
|
||||
/// Back-reference to the app state. Once `SessionState.init` overwrites `client.onEvent`
|
||||
/// (`SessionState.swift:87`), `AppState.handleConnectEvent` no longer receives per-session
|
||||
/// events — so the `.disconnected` event for a LIVE session arrives here in `handleEvent`,
|
||||
/// not in AppState. This weak ref lets us hand the disconnect back to AppState (which owns
|
||||
/// the reconnect state machine) so the auto-reconnect fires. Set by AppState on auth success.
|
||||
weak var appState: AppState?
|
||||
|
||||
// MARK: - Reconnect restore state
|
||||
//
|
||||
// When the iOS client auto-reconnects after a network drop, AppState captures the prior
|
||||
// session's channel + voice/mic state and asks the new SessionState (created on auth success)
|
||||
// to restore it. We rejoin the channel explicitly (the server auto-placed us in Lobby on
|
||||
// auth), and on the resulting `.joinResult` we re-arm voice subscription + mute/deafen. The
|
||||
// drive is here, not in AppState, because once SessionState is created it owns
|
||||
// `client.onEvent` and AppState no longer sees per-session events.
|
||||
private struct RestoreRequest {
|
||||
let channelId: UInt32
|
||||
let voiceSubscribed: Bool
|
||||
let micMuted: Bool
|
||||
let deafened: Bool
|
||||
}
|
||||
private var pendingRestore: RestoreRequest?
|
||||
private var didIssueRestoreJoin = false
|
||||
|
||||
/// Host side of iOS screen-audio sharing — drains the broadcast extension's App Group ring
|
||||
/// and feeds the SCREEN_AUDIO stream this session owns. See BroadcastAudioPump.
|
||||
private let broadcastPump = BroadcastAudioPump()
|
||||
@@ -186,8 +210,24 @@ final class SessionState {
|
||||
currentChannelId = ev.channelId
|
||||
addActivity("Joined channel")
|
||||
refreshUsers()
|
||||
// Reconnect restore: this was our restore-join. Now that the server has
|
||||
// processed the channel move, re-arm voice subscription (if the user was
|
||||
// transmitting before the drop) and re-apply the local mute/deafen state.
|
||||
// The server returns ok even when joining the channel we're already in, so
|
||||
// this fires reliably for the Lobby-too case.
|
||||
if didIssueRestoreJoin, let r = pendingRestore, r.channelId == ev.channelId {
|
||||
didIssueRestoreJoin = false
|
||||
completeRestore()
|
||||
}
|
||||
} else {
|
||||
addActivity("Join failed: \(ev.result.description)")
|
||||
// Restore-join failed (channel was deleted, became password-protected or
|
||||
// full while we were away). Give up on the voice/mute restore cleanly so we
|
||||
// don't leave dangling state or attempt voice without being in a channel.
|
||||
if didIssueRestoreJoin {
|
||||
didIssueRestoreJoin = false
|
||||
pendingRestore = nil
|
||||
}
|
||||
}
|
||||
case .error:
|
||||
addActivity("Error: \(ev.text ?? ev.result.description)")
|
||||
@@ -198,9 +238,15 @@ final class SessionState {
|
||||
case .accountList:
|
||||
accounts = client.listAccounts()
|
||||
case .disconnected:
|
||||
// Audible cue only — session teardown is driven elsewhere (AppState / UI).
|
||||
// Audible cue, then hand the disconnect back to AppState so its reconnect state
|
||||
// machine fires. This is the ONLY way AppState learns a live session dropped —
|
||||
// after auth success, `SessionState.init` overwrites `client.onEvent`, so
|
||||
// `AppState.handleConnectEvent` never sees this event. (Without this callback, a
|
||||
// network drop on a live session would just play the cue and leave the session as a
|
||||
// zombie — the user would have to tap Disconnect manually.)
|
||||
EventFeedback.shared.play(ev.result == .ok ? .logout : .connectionLost)
|
||||
EventFeedback.shared.speak(ev.result == .ok ? "Disconnected" : "Connection lost")
|
||||
appState?.onLiveSessionDisconnected()
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -300,6 +346,49 @@ final class SessionState {
|
||||
client.leaveVoice()
|
||||
}
|
||||
|
||||
// MARK: - Reconnect restore
|
||||
|
||||
/// Called by `AppState` after a reconnect's auth success to rejoin the prior channel and
|
||||
/// re-enable the prior voice/mic state. Drives the restore through the `.joinResult` event
|
||||
/// so we re-arm voice only AFTER the server processed the join — joining voice before the
|
||||
/// channel move would be rejected server-side. `micMuted`/`deafened` are the user's LOCAL
|
||||
/// mute/deafen state at the moment of the drop; the server resets those on a fresh auth, so
|
||||
/// we re-push them via `setMute` after the channel is restored.
|
||||
func requestRestore(channelId: UInt32, voiceSubscribed: Bool,
|
||||
micMuted: Bool, deafened: Bool) {
|
||||
pendingRestore = RestoreRequest(channelId: channelId,
|
||||
voiceSubscribed: voiceSubscribed,
|
||||
micMuted: micMuted,
|
||||
deafened: deafened)
|
||||
didIssueRestoreJoin = false
|
||||
if channelId != 0 {
|
||||
// The server auto-placed us in the Lobby on auth; join our prior channel explicitly.
|
||||
// `vc_join_channel` is idempotent server-side (joining the channel you're already in
|
||||
// returns ok), so this is safe even if the prior channel was the Lobby.
|
||||
client.joinChannel(channelId)
|
||||
didIssueRestoreJoin = true
|
||||
} else {
|
||||
// No prior channel — go straight to the voice/mute restore. (voiceSubscribed with
|
||||
// channelId == 0 is contradictory; `completeRestore` further guards on
|
||||
// currentChannelId != 0 before subscribing to voice.)
|
||||
completeRestore()
|
||||
}
|
||||
}
|
||||
|
||||
/// Finish the restore after the channel is in place (or there was no channel to restore):
|
||||
/// re-subscribe to voice if the user was transmitting, and re-apply the local mute/deafen
|
||||
/// state. Safe to call once per `pendingRestore`; clears it.
|
||||
private func completeRestore() {
|
||||
guard let r = pendingRestore else { return }
|
||||
if r.voiceSubscribed && currentChannelId != 0 {
|
||||
joinVoice()
|
||||
}
|
||||
setMute(r.micMuted, deafened: r.deafened)
|
||||
addActivity("Restored to channel \(currentChannelId)"
|
||||
+ (r.voiceSubscribed ? " with voice" : ""))
|
||||
pendingRestore = nil
|
||||
}
|
||||
|
||||
// MARK: - Screen audio share
|
||||
|
||||
/// Called when the broadcast extension becomes active. Announces the SCREEN_AUDIO stream;
|
||||
|
||||
Reference in New Issue
Block a user