Reconnect on a real handover instead of waiting for a dead path
Build and test / test (macos-latest) (push) Canceled after 0s
Build and test / test (ubuntu-24.04) (push) Canceled after 0s
Build and test / test (windows-latest) (push) Canceled after 0s
Build and test / apple-client (push) Canceled after 0s

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
+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; }