Reconnect on a real handover instead of waiting for a dead path
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:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user