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
+8 -1
View File
@@ -46,7 +46,14 @@ possession of its media key from the new address with an authenticated `Rebind`
relay moves its endpoint, instead of the session dying silently in both directions; the client
rebuilds its UDP socket rather than retrying on one pinned to a vanished interface. The control
connection is judged live by server traffic rather than assumed live, so a blackholed TCP path
is detected in 30 s instead of waiting minutes for the OS. The receive jitter buffer keeps a
is detected in 30 s instead of waiting minutes for the OS, and 12 s on iOS. iOS also watches the
system path and fails the control connection the moment the carrying interface changes, so a
handover reconnects in about a second instead of waiting out the silence timeout; access-point
roaming and an unusable-but-unchanged link are deliberately not handovers and are ridden out.
The first reconnect attempt is immediate, and a loss is only announced once an attempt has
actually failed, so a handover reads as a hiccup rather than a dropped call. A control reconnect
still re-authenticates and rejoins: seamless handover needs control-plane session resumption,
because the media keys come from the TLS exporter of the connection that was lost. The receive jitter buffer keeps a
one-frame depth floor, measures late and reordered arrivals, and can deepen mid-call, and a
stalled consumer now costs bounded audio rather than the live talkspurt.
+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 () =>
{
@@ -0,0 +1,56 @@
using CoreFoundation;
using Network;
using VoiceCat.Core;
namespace VoiceCat.iOS;
// Watches which interface actually carries traffic so a handover can be acted on the instant it
// happens instead of after the control keepalive proves the old path dead.
//
// The signal must be narrow. Roaming between access points, a lift ride, a minute of bad
// cellular: those keep the same interface and the same source address, so TCP survives them and
// a reconnect would be pure damage. Only the set of satisfied interfaces changing — Wi-Fi to
// cellular, one physical link to another — strands the existing sockets on a source address that
// no longer exists, and that is the only thing reported here.
internal sealed class IosNetworkPathMonitor : IDisposable
{
internal static IosNetworkPathMonitor Shared { get; } = new();
private readonly DispatchQueue queue = new("voicecat.path");
private readonly object gate = new();
private readonly ControlPathWatcher watcher = new();
private NWPathMonitor? monitor;
private bool started;
// Raised on the monitor queue when the carrying interface changed and the new path is usable.
internal event Action<string>? InterfaceChanged;
internal void Start()
{
lock (gate)
{
if (started) return;
started = true;
monitor = new NWPathMonitor();
monitor.SetQueue(queue);
monitor.SnapshotHandler = OnPath;
monitor.Start();
}
}
private void OnPath(NWPath path)
{
List<string> interfaces = [];
if (path.Status == NWPathStatus.Satisfied)
path.EnumerateInterfaces(item => { interfaces.Add($"{item.InterfaceType}:{item.Name}"); return true; });
if (watcher.Observe(path.Status == NWPathStatus.Satisfied, interfaces))
InterfaceChanged?.Invoke(string.Join(",", interfaces));
}
public void Dispose()
{
lock (gate)
{
monitor?.Cancel(); monitor?.Dispose(); monitor = null; started = false; watcher.Reset();
}
}
}
+4 -3
View File
@@ -164,8 +164,9 @@ Before packaging, confirm:
- the bundle identifiers are the stable identifiers above;
- both version pairs match;
- both executables contain `arm64`; and
- the host `Info.plist` contains `CFBundleIconName` and the bundle contains both
`AppIcon60x60@2x.png` (120x120) and `AppIcon76x76@2x~ipad.png` (152x152).
- the host `Info.plist` contains `CFBundleIconName` nested inside both `CFBundleIcons` and
`CFBundleIcons~ipad`, which is where `actool` writes it and not at the top level, and the
bundle contains both `AppIcon60x60@2x.png` (120x120) and `AppIcon76x76@2x~ipad.png` (152x152).
Also decode each embedded profile and verify its name, UUID, application identifier, and
`get-task-allow` value:
@@ -270,7 +271,7 @@ clean build regenerated the app manifests.
## Last verified release build
On 2026-09-22, version `0.0.1`, build `2026092203` was built with .NET 10.0.401 and Xcode 27.0.
On 2026-09-24, version `0.0.1`, build `2026092401` was built with .NET 10.0.401 and Xcode 27.0.
The host and ReplayKit extension passed strict nested-signature validation with App Store Connect
profiles, matching distribution identities, matching versions, the shared App Group, and
`get-task-allow=false`. The host carries `CFBundleIconName` with the 120x120 and 152x152 icons.
+42
View File
@@ -0,0 +1,42 @@
namespace VoiceCat.Core;
/// Decides whether an observed network path change is a genuine handover — one that strands the
/// existing sockets on a source address that no longer exists — or something the connection
/// should be left alone to ride out.
///
/// The distinction matters because the response is a reconnect. Roaming between access points,
/// a tunnel, a minute of unusable cellular: those keep the same interface, so TCP survives them
/// and a reconnect would turn a recoverable glitch into a visible drop. Only the set of
/// interfaces carrying the path changing is reported as a handover.
///
/// Platform-agnostic on purpose: the caller supplies whatever stable interface identity its OS
/// reports (on iOS, NWPath's interface type and name).
public sealed class ControlPathWatcher
{
private readonly object gate = new();
private string signature = "";
/// Returns true when the path moved to a different set of interfaces and is usable again.
/// The first usable path only establishes a baseline; there is nothing to hand over from.
public bool Observe(bool usable, IReadOnlyList<string> interfaces)
{
// An unusable path is an outage, not a handover. Hold the last signature so a link that
// returns on the same interface stays silent, and so a reconnect is never started into a
// path that cannot carry it.
if (!usable) return false;
string[] sorted = [.. interfaces.Where(item => !string.IsNullOrEmpty(item))];
if (sorted.Length == 0) return false;
Array.Sort(sorted, StringComparer.Ordinal);
string current = string.Join(",", sorted);
lock (gate)
{
string previous = signature;
if (previous == current) return false;
signature = current;
return previous.Length != 0;
}
}
/// Forgets the baseline, so the next usable path establishes a new one without reporting.
public void Reset() { lock (gate) signature = ""; }
}
+22 -1
View File
@@ -49,10 +49,31 @@ public sealed partial class VoiceCatClient : IAsyncDisposable
private TimeSpan controlKeepaliveInterval = TimeSpan.FromSeconds(10);
private TimeSpan controlSilenceTimeout = TimeSpan.FromSeconds(30);
// Instance scoped so tests can shorten the window without disturbing parallel tests.
// Instance scoped so tests can shorten the window without disturbing parallel tests, and so
// a mobile client can tighten it: on a phone the interval is the delay between a handover and
// the reconnect that follows it, which a desktop on one fixed interface never pays.
// Takes effect on the next ConnectAsync; the running keepalive worker keeps its own values.
public void ConfigureControlLiveness(TimeSpan keepalive, TimeSpan silenceTimeout)
{
if (keepalive <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(keepalive));
if (silenceTimeout <= keepalive) throw new ArgumentOutOfRangeException(nameof(silenceTimeout), "Silence timeout must exceed the keepalive interval.");
controlKeepaliveInterval = keepalive; controlSilenceTimeout = silenceTimeout;
}
internal void SetControlLiveness(TimeSpan keepalive, TimeSpan silenceTimeout)
{ controlKeepaliveInterval = keepalive; controlSilenceTimeout = silenceTimeout; }
// The platform can know the path is gone long before an unanswered keepalive proves it: iOS
// reports an interface change the instant it happens. Failing the connection here hands the
// existing disconnect path a real cause and starts the reconnect immediately instead of
// waiting out the silence timeout on a route that is already dead.
public void DropForReconnect(string reason)
{
if (State == ClientConnectionState.Disconnected) return;
ConnectionFailure ??= new IOException(reason);
connectionLifetime?.Cancel();
}
public event Action<ClientConnectionState>? ConnectionStateChanged;
public ClientConnectionState State { get { lock (stateGate) return state; } }
public Exception? ConnectionFailure { get; private set; }
@@ -261,6 +261,77 @@ public class NetworkImpairmentTests(ITestOutputHelper output)
Assert.IsType<IOException>(client.ConnectionFailure);
}
// The reconnect is only worth paying for a path change that TCP genuinely cannot survive.
// Walking a house between access points, or a link that is merely unusable for a while, keeps
// the same interface and the same source address, and a reconnect there would turn a
// recoverable glitch into the visible drop this whole change exists to remove.
[Fact]
public void OnlyAChangedInterfaceCountsAsAHandover()
{
var watcher = new ControlPathWatcher();
// The first usable path is a baseline, not a handover.
Assert.False(watcher.Observe(true, ["wifi:en0"]));
// Roaming between access points, and the same path reported again.
Assert.False(watcher.Observe(true, ["wifi:en0"]));
// A dead spot: unusable, then back on the interface it left from.
Assert.False(watcher.Observe(false, []));
Assert.False(watcher.Observe(false, []));
Assert.False(watcher.Observe(true, ["wifi:en0"]));
// Wi-Fi gives out and cellular takes over: the source address is gone.
Assert.True(watcher.Observe(true, ["cellular:pdp_ip0"]));
Assert.False(watcher.Observe(true, ["cellular:pdp_ip0"]));
// And back onto Wi-Fi on arriving home.
Assert.True(watcher.Observe(true, ["wifi:en0"]));
// Interface order is a reporting detail, not a change.
Assert.True(watcher.Observe(true, ["wifi:en0", "cellular:pdp_ip0"]));
Assert.False(watcher.Observe(true, ["cellular:pdp_ip0", "wifi:en0"]));
// After a reset the next usable path is a baseline again.
watcher.Reset();
Assert.False(watcher.Observe(true, ["wired:en5"]));
Assert.True(watcher.Observe(true, ["wifi:en0"]));
}
// The platform knows the path changed long before an unanswered keepalive can prove it. A
// client told directly must fail the connection at once, with the real cause, so the reconnect
// runs while the audio session is still up instead of after the silence timeout expires.
[Fact]
public async Task ADroppedControlConnectionReconnectsWithoutWaitingOutTheSilenceTimeout()
{
await using var fixture = new ServerFixture();
await using var proxy = new BlackholeProxy(fixture.Server.EndPoint);
await using var client = new VoiceCatClient("Test", "0.0.1", Path.Combine(fixture.Directory, "tofu.txt"));
client.SetControlLiveness(TimeSpan.FromSeconds(30), TimeSpan.FromMinutes(5));
var disconnected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
client.ConnectionStateChanged += state => { if (state == ClientConnectionState.Disconnected) disconnected.TrySetResult(); };
await client.ConnectAsync("127.0.0.1", (ushort)proxy.EndPoint.Port, (_, _) => ValueTask.FromResult(true));
await client.AuthenticateGuestAsync("Alice");
Assert.Equal(ClientConnectionState.Connected, client.State);
proxy.Freeze();
client.DropForReconnect("The network interface changed to cellular:pdp_ip0.");
await disconnected.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal(ClientConnectionState.Disconnected, client.State);
Assert.Contains("cellular:pdp_ip0", Assert.IsType<IOException>(client.ConnectionFailure).Message);
// Dropping an already dead connection is a no-op, not a second failure.
client.DropForReconnect("Ignored.");
Assert.Contains("cellular:pdp_ip0", client.ConnectionFailure!.Message);
}
// A silence timeout at or below the keepalive interval would fail every healthy connection on
// its first tick, so a mobile client tightening these cannot be allowed to invert them.
[Fact]
public async Task ControlLivenessRejectsAWindowThatCannotBeMet()
{
await using var client = new VoiceCatClient("Test", "0.0.1", Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()));
Assert.Throws<ArgumentOutOfRangeException>(() => client.ConfigureControlLiveness(TimeSpan.Zero, TimeSpan.FromSeconds(12)));
Assert.Throws<ArgumentOutOfRangeException>(() => client.ConfigureControlLiveness(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5)));
client.ConfigureControlLiveness(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(12));
}
// Forwards TCP both ways until frozen, after which bytes are swallowed and the sockets are
// left open — what a vanished route looks like to the client, unlike a close or a reset.
private sealed class BlackholeProxy : IAsyncDisposable