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
@@ -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