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:
72
PROGRESS.md
72
PROGRESS.md
@@ -10,6 +10,78 @@ up instantly. Newest status at the top.
|
||||
|
||||
## ▶ Where we left off / next action
|
||||
|
||||
- **Done (2026-06-25):** **iOS robustness — auto-reconnect after a network change + audio
|
||||
recovery when audio devices plug/unplug.** Two layers of bugs the iOS client had:
|
||||
(a) a `VC_EVENT_DISCONNECTED` from the C core on a Wi-Fi→cellular flip / DNS outage /
|
||||
server restart used to leave the session dead with no retry; (b) unplugging wired
|
||||
headphones or AirPods left the engine stopped forever — mic stopped transmitting and
|
||||
remote audio stayed silent (the server connection itself survived, but the audio graph
|
||||
did not recover).
|
||||
|
||||
The first attempt wired reconnect into `AppState.handleConnectEvent`, but that handler
|
||||
never runs for a live-session disconnect: once `SessionState.init` overwrites
|
||||
`client.onEvent` (`SessionState.swift:87`), the `.disconnected` event is delivered to
|
||||
`SessionState.handleEvent`, which used to play a cue and do nothing else. So the live
|
||||
session would sit as a zombie for ~30-60 s (the C core's TCP keepalive/reaper timeout)
|
||||
and then play the "connection lost" sound with no reconnect armed — exactly what the
|
||||
user saw. The fix below has two parts addressing both the missing reconnect AND the
|
||||
long wait.
|
||||
|
||||
1. **Event-driven reconnect** (`AppState.swift`, `SessionState.swift`): added a
|
||||
`weak var appState: AppState?` to `SessionState`, set by AppState on auth success.
|
||||
`SessionState.handleEvent` `.disconnected` now plays the cue and calls
|
||||
`appState?.onLiveSessionDisconnected()` — the SINGLE path by which AppState learns a
|
||||
live session dropped (since its own `handleConnectEvent` is bypassed for live-session
|
||||
events). `onLiveSessionDisconnected` calls a shared `teardownLiveSessionAndReconnect`
|
||||
that snapshots the live session into `LastSession`, stops the audio engine,
|
||||
deactivates the AVAudioSession, nil's `session` (which releases `VoiceCatClient` →
|
||||
`vc_client_destroy` joins the io thread), resets the backoff counter, and arms
|
||||
`scheduleReconnect`.
|
||||
2. **Path-driven proactive reconnect** (`AppState.swift`): an `NWPathMonitor`
|
||||
(`Network.framework`) now runs the whole time we're CONNECTED (started on auth
|
||||
success, not only when armed for reconnect) and stays armed across reconnects. Its
|
||||
`pathUpdateHandler` (dispatched to @MainActor) does two things:
|
||||
- While connected: a primary-interface change (Wi-Fi↔cellular) OR the path becoming
|
||||
`.unsatisfied` triggers `proactiveReconnect()` — tearing the live session down
|
||||
BEFORE the C core notices the dead TCP read. This is what collapses the 30-60 s
|
||||
reaper wait into ~1 s + the first backoff tick. Same-interface refreshes (Wi-Fi
|
||||
BSSID roams, signal-strength changes) are intentionally ignored (signature
|
||||
comparison via `pathSignature`); those usually don't break the TCP connection.
|
||||
- While mid-reconnect (no session): a path becoming `.satisfied` resets the backoff
|
||||
counter and arms `scheduleReconnect` for a fast-fresh retry.
|
||||
`userInitiatedDisconnect` distinguishes manual `disconnect()`/`cancelConnect()` (which
|
||||
set it true → cancel all reconnect state) from a network drop (which leaves it false).
|
||||
On a successful reconnect, `reconnectAttempt` resets and `lastSession` clears; the
|
||||
path monitor keeps watching for the next change. On user-initiated disconnect, all
|
||||
reconnect state (task + path monitor + `lastSession` + `connectedServer`) is cancelled.
|
||||
3. **Backoff + restore**: exponential backoff 1s → 2s → 4s → 8s → 16s → 30s cap,
|
||||
indefinite. TOFU pins match on the second connect (`VC_TOFU_MATCHED`) so the identity
|
||||
gate auto-confirms; on auth success `SessionState.requestRestore` issues a
|
||||
`joinChannel` and re-arms voice + restores the local mute/deafen state on the
|
||||
resulting `.joinResult`.
|
||||
4. **Audio recovery** (`AudioSessionManager.swift`, `IOSVoiceProcessingEngine.swift`):
|
||||
replaced the route-change handler's narrow `.oldDeviceUnavailable`/
|
||||
`.newDeviceAvailable` guard with a single intent-gated `recoverAudio()` path that
|
||||
re-activates the AVAudioSession, re-applies the route config, and rebuilds the
|
||||
engine; it runs on every externally-initiated route change reason except
|
||||
`.categoryChange`/`.routeConfigurationChange` (those we cause ourselves and would
|
||||
loop). Interruption-end now always calls `recoverAudio()` instead of only when
|
||||
`.shouldResume` is set (which left the session permanently dead after Siri). Added
|
||||
an `AVAudioEngineConfigurationChange` observer on the engine in `IOSAudioEngine`
|
||||
that catches the case where iOS stops the engine itself AFTER our route-change
|
||||
handler already rebuilt it (the previous rebuilds raced the engine's own self-stop
|
||||
and lost). And `IOSAudioEngine.rebuild()` now does a one-shot reactivation-retry on
|
||||
`engine.start()` failure — iOS sometimes refuses to start until the AVAudioSession is
|
||||
re-activated, which is the silent-death case.
|
||||
**Build:** `scripts/build-ios-client.sh --no-configure` green (Xcode 26.5 / iOS 18.0 sim
|
||||
SDK, Swift 5 mode). No C ABI / `voicecat.h` / `voicecat.proto` / C core changes; the
|
||||
existing TOFU auto-confirm (`VC_TOFU_MATCHED`) and idempotent `vc_join_channel` make
|
||||
reconnect+restore possible without new C ABI. macOS and Windows clients unchanged.
|
||||
**Next (manual, on-device):** verify unplugging AirPods/wired headphones mid-call keeps
|
||||
audio going through the loudspeaker; verify Wi-Fi→cellular flip mid-call now triggers a
|
||||
FAST reconnect (within a couple seconds, not 30-60 s) and lands in the same channel with
|
||||
voice re-armed; verify tapping Disconnect mid-reconnect-abort cancels cleanly.
|
||||
|
||||
- **Done (2026-06-24):** **Three bug fixes — voice join/leave, channel edit defaults, channel-update stream restart.**
|
||||
1. **Join/Leave Voice now truly subscribes/unsubscribes from the voice plane.** Previously
|
||||
"Join Voice" only started the local mic — receiving was always on (gated by channel
|
||||
|
||||
Reference in New Issue
Block a user