Reconnect on a real handover instead of waiting for a dead path
Build and test / test (macos-latest) (push) Waiting to run
Build and test / test (ubuntu-24.04) (push) Waiting to run
Build and test / test (windows-latest) (push) Waiting to run
Build and test / apple-client (push) Waiting to run

A Wi-Fi to cellular switch left the session visibly dropping: the media transport rebound
itself within a few seconds, but nothing noticed the blackholed control connection until an
unanswered keepalive proved it, and the teardown that followed announced a lost connection and
waited another second before dialling again.

Watch the system path on iOS and fail the control connection the moment the carrying interface
changes, which is the only path change TCP cannot survive. Roaming between access points and a
link that is merely unusable for a while keep the same interface and the same source address,
so ControlPathWatcher reports neither; an unsatisfied path holds the last signature rather than
reporting, so a reconnect is never started into a route that cannot carry it. Tighten the
keepalive window on the phone as the backstop for what the monitor cannot see, run the first
reconnect attempt immediately, and defer the lost-connection announcement until an attempt has
actually failed, so a sub-second handover is silent and only a real outage is announced.

A control reconnect still re-authenticates and rejoins: the media keys come from the TLS
exporter of the connection that was lost, so seamless handover needs control-plane session
resumption rather than a faster reconnect.
This commit is contained in:
2026-09-25 17:20:52 +02:00
parent 0b81b81c0c
commit 0dad40c9d7
7 changed files with 251 additions and 12 deletions
+48 -7
View File
@@ -34,6 +34,8 @@ internal sealed class AppModel
private bool restoreDeafened;
private bool backgrounded;
private int diagnosticPolls;
private bool announcedConnectionLoss;
private bool handingOver;
internal event Action? Changed;
// Raised by the 20 Hz level timer only. Subscribers must be cheap and must not reload a list.
@@ -57,8 +59,24 @@ internal sealed class AppModel
internal IReadOnlyList<Channel> Channels => client?.Channels ?? [];
internal IReadOnlyList<User> Users => client?.Users ?? [];
private AppModel() { feedback = new(settings); }
internal void Load() { profiles.Clear(); profiles.AddRange(storage.LoadProfiles()); settings.Load(); IosAudioRouter.Shared.Load(); Notify(); }
private AppModel() { feedback = new(settings); IosNetworkPathMonitor.Shared.InterfaceChanged += OnInterfaceChanged; }
internal void Load() { profiles.Clear(); profiles.AddRange(storage.LoadProfiles()); settings.Load(); IosAudioRouter.Shared.Load(); IosNetworkPathMonitor.Shared.Start(); Notify(); }
// A changed interface has already stranded the control socket on a source address that is
// gone; the media transport rebinds itself, but TCP cannot, and waiting for the keepalive to
// notice costs the user ten seconds of a session that is already dead. Fail it now so the
// reconnect runs while the audio session is still up. Raised on the monitor queue.
private void OnInterfaceChanged(string path)
{
UIApplication.SharedApplication.BeginInvokeOnMainThread(() =>
{
VoiceCatClient? owner = client;
if (owner is null || explicitDisconnect || IsConnecting || owner.State != ClientConnectionState.Connected) return;
AddActivity("Network changed; reconnecting.");
handingOver = true; reconnectAttempt = 0;
owner.DropForReconnect($"The network interface changed to {path}.");
});
}
internal void Save() { storage.SaveProfiles(profiles); settings.Save(); }
internal void DidEnterBackground()
@@ -105,6 +123,10 @@ internal sealed class AppModel
// handoff; a sleep-paced audio worker stalls when iOS coalesces backgrounded wakeups and
// the call glitches after several minutes in the background.
VoiceCatClient next = new("VoiceCat-iOS", "0.0.1", storage.TofuPath, deviceClockedAudio: true);
// A phone changes path often and the OS reports a blackholed TCP connection late or never.
// The path monitor catches a real handover immediately; this is the backstop for the cases
// it cannot see, such as a NAT rebinding or an upstream route that quietly stops carrying.
next.ConfigureControlLiveness(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(12));
next.ConnectionStateChanged += state =>
{
if (state == ClientConnectionState.Disconnected && next.ConnectionFailure is { } failure)
@@ -131,7 +153,10 @@ internal sealed class AppModel
_ = PumpEventsAsync(next, lifetime.Token);
levelTimer?.Dispose(); levelTimer = new(_ =>
UIApplication.SharedApplication.BeginInvokeOnMainThread(PollAudio), null, 50, 50);
feedback.Play(SoundEvent.Login); feedback.Speak(restoring ? "Reconnected" : "Connected");
// A handover that recovers in under a second should read as a hiccup, not a dropped
// call: announce the return only when the loss itself was announced.
if (!restoring || announcedConnectionLoss) { feedback.Play(SoundEvent.Login); feedback.Speak(restoring ? "Reconnected" : "Connected"); }
announcedConnectionLoss = false; handingOver = false;
if (restoring && restoreChannel != 0) await RestoreSessionAsync(next);
Notify();
}
@@ -142,7 +167,15 @@ internal sealed class AppModel
if (ReferenceEquals(client, next)) { client = null; await StopSessionResourcesAsync(releaseAudio: !restoring); }
await next.DisposeAsync();
Notify();
if (restoring && !explicitDisconnect) ScheduleReconnect();
if (restoring && !explicitDisconnect)
{
if (!announcedConnectionLoss)
{
announcedConnectionLoss = true; handingOver = false;
feedback.Play(SoundEvent.ConnectionLost); feedback.Speak("Connection lost, reconnecting");
}
ScheduleReconnect();
}
else throw;
}
}
@@ -205,7 +238,11 @@ internal sealed class AppModel
catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Audio cleanup failed: {exception}"); }
try { await owner.DisposeAsync(); }
catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Connection cleanup failed: {exception}"); }
feedback.Play(SoundEvent.ConnectionLost); feedback.Speak("Connection lost, reconnecting"); Notify();
// On a handover the reconnect below usually lands within a second, so stay quiet and let
// the first failed attempt be what tells the user. Any other loss is announced at once.
if (handingOver) Status = "Reconnecting…";
else { announcedConnectionLoss = true; feedback.Play(SoundEvent.ConnectionLost); feedback.Speak("Connection lost, reconnecting"); }
Notify();
ScheduleReconnect();
}
@@ -275,7 +312,8 @@ internal sealed class AppModel
internal async Task DisconnectAsync()
{
explicitDisconnect = true; lifetime?.Cancel(); await StopSessionResourcesAsync(releaseAudio: true);
explicitDisconnect = true; handingOver = false; announcedConnectionLoss = false;
lifetime?.Cancel(); await StopSessionResourcesAsync(releaseAudio: true);
VoiceCatClient? old = client; client = null; microphoneStream = 0; IsConnecting = false; Status = "Not connected"; Notify();
if (old is not null) await old.DisposeAsync(); feedback.Play(SoundEvent.Logout); feedback.Speak("Disconnected");
}
@@ -283,7 +321,10 @@ internal sealed class AppModel
private void ScheduleReconnect()
{
ServerProfile? profile = connectedProfile; if (profile is null || explicitDisconnect) return;
int delay = Math.Min(1 << Math.Min(reconnectAttempt++, 5), 30);
// The first attempt runs immediately: after a handover the new path is already up, and a
// second of deliberate silence is the difference between a hiccup and a dropped call.
int delay = reconnectAttempt == 0 ? 0 : Math.Min(1 << Math.Min(reconnectAttempt, 5), 30);
reconnectAttempt++;
CancellationToken token = lifetime?.Token ?? default;
_ = Task.Run(async () =>
{