diff --git a/PROGRESS.md b/PROGRESS.md index 792fe92..8e9bafc 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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. diff --git a/clients/apple/VoiceCat.iOS/AppModel.cs b/clients/apple/VoiceCat.iOS/AppModel.cs index 99c7581..a76160c 100644 --- a/clients/apple/VoiceCat.iOS/AppModel.cs +++ b/clients/apple/VoiceCat.iOS/AppModel.cs @@ -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 Channels => client?.Channels ?? []; internal IReadOnlyList 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 () => { diff --git a/clients/apple/VoiceCat.iOS/IosNetworkPathMonitor.cs b/clients/apple/VoiceCat.iOS/IosNetworkPathMonitor.cs new file mode 100644 index 0000000..30ead38 --- /dev/null +++ b/clients/apple/VoiceCat.iOS/IosNetworkPathMonitor.cs @@ -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? 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 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(); + } + } +} diff --git a/docs/apple-ios-release.md b/docs/apple-ios-release.md index 698d962..7231ada 100644 --- a/docs/apple-ios-release.md +++ b/docs/apple-ios-release.md @@ -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. diff --git a/src/VoiceCat.Core/ControlPathWatcher.cs b/src/VoiceCat.Core/ControlPathWatcher.cs new file mode 100644 index 0000000..6347ab8 --- /dev/null +++ b/src/VoiceCat.Core/ControlPathWatcher.cs @@ -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 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 = ""; } +} diff --git a/src/VoiceCat.Core/VoiceCatClient.cs b/src/VoiceCat.Core/VoiceCatClient.cs index 6858fe1..074332f 100644 --- a/src/VoiceCat.Core/VoiceCatClient.cs +++ b/src/VoiceCat.Core/VoiceCatClient.cs @@ -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? ConnectionStateChanged; public ClientConnectionState State { get { lock (stateGate) return state; } } public Exception? ConnectionFailure { get; private set; } diff --git a/tests/VoiceCat.Tests/NetworkImpairmentTests.cs b/tests/VoiceCat.Tests/NetworkImpairmentTests.cs index d2c7e88..694fd12 100644 --- a/tests/VoiceCat.Tests/NetworkImpairmentTests.cs +++ b/tests/VoiceCat.Tests/NetworkImpairmentTests.cs @@ -261,6 +261,77 @@ public class NetworkImpairmentTests(ITestOutputHelper output) Assert.IsType(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(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(() => client.ConfigureControlLiveness(TimeSpan.Zero, TimeSpan.FromSeconds(12))); + Assert.Throws(() => 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