Survive changing networks and deepen the receive buffer
Media died silently whenever a client's source address changed. The relay bound a peer's endpoint once and refused to move it, and the client stopped offering its binding token after the first bind, so a Wi-Fi/cellular handover stranded the session in both directions. Add an authenticated Rebind media frame: the binding token travels in the clear for peer lookup only, and the AEAD tag over header and token plus the peer's existing replay window are what authorize the move, so a captured rebind cannot be replayed to redirect someone else's downlink. The client rebuilds its UDP socket instead of retrying on one still pinned to a vanished interface. Nothing judged the control connection live: pings were sent and pongs ignored, so a blackholed TCP path went unnoticed for minutes while the UI showed a live session. Treat any server traffic as liveness and fail the connection when it stops, which drives the existing reconnect. The receive jitter buffer had lost its depth floor, so a channel without FEC or DRED played out with no buffer at all and ordinary reordering became concealment. Restore a one-frame floor, observe every arrival rather than only accepted ones — a shallow buffer was rejecting the late arrivals that should have deepened it — and allow playout to hold a frame so depth can follow a degrading link. A stalled consumer now sheds the oldest queued packet instead of refusing the live talkspurt. Add a deterministic network-impairment simulation covering bursty loss, jitter, reordering, duplication, outages and a stalled consumer, a handover test against a real relay, a replay test for the rebind path, and a blackholed control connection driven through a freezable TCP proxy.
This commit is contained in:
@@ -42,6 +42,16 @@ public sealed partial class VoiceCatClient : IAsyncDisposable
|
||||
private uint adaptiveLossChannel;
|
||||
private int adaptiveLossPercent = -1;
|
||||
private ClientConnectionState state;
|
||||
private long lastControlInbound;
|
||||
// A control connection that stops answering is dead even though the socket still looks open.
|
||||
// A phone that changes interface leaves TCP blackholed rather than reset, and the OS will not
|
||||
// report it for minutes, so liveness is judged here instead.
|
||||
private TimeSpan controlKeepaliveInterval = TimeSpan.FromSeconds(10);
|
||||
private TimeSpan controlSilenceTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
// Instance scoped so tests can shorten the window without disturbing parallel tests.
|
||||
internal void SetControlLiveness(TimeSpan keepalive, TimeSpan silenceTimeout)
|
||||
{ controlKeepaliveInterval = keepalive; controlSilenceTimeout = silenceTimeout; }
|
||||
|
||||
public event Action<ClientConnectionState>? ConnectionStateChanged;
|
||||
public ClientConnectionState State { get { lock (stateGate) return state; } }
|
||||
@@ -178,8 +188,20 @@ public sealed partial class VoiceCatClient : IAsyncDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));
|
||||
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) Send(new() { Ping = new() { Nonce = checked((ulong)Environment.TickCount64) } });
|
||||
Volatile.Write(ref lastControlInbound, Environment.TickCount64);
|
||||
using var timer = new PeriodicTimer(controlKeepaliveInterval);
|
||||
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
// Every server reply counts as liveness, so a busy session never trips this; an
|
||||
// unanswered ping is what exposes a path that has stopped carrying anything.
|
||||
if (Environment.TickCount64 - Volatile.Read(ref lastControlInbound) > controlSilenceTimeout.TotalMilliseconds)
|
||||
{
|
||||
ConnectionFailure ??= new IOException("Server stopped responding on the control connection.");
|
||||
connectionLifetime?.Cancel();
|
||||
break;
|
||||
}
|
||||
Send(new() { Ping = new() { Nonce = checked((ulong)Environment.TickCount64) } });
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is OperationCanceledException or IOException or InvalidOperationException) { }
|
||||
}
|
||||
@@ -212,13 +234,16 @@ public sealed partial class VoiceCatClient : IAsyncDisposable
|
||||
{
|
||||
await foreach (Envelope message in connection.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
Volatile.Write(ref lastControlInbound, Environment.TickCount64);
|
||||
Apply(message);
|
||||
if (message.RequestId != 0 && pending.TryRemove(message.RequestId, out var completion)) completion.TrySetResult(message.Clone());
|
||||
if (!events.Writer.TryWrite(message.Clone())) throw new IOException("Client event queue exhausted; consume events regularly.");
|
||||
if (message.Disconnect is not null) { connection.CompleteWrites(); break; }
|
||||
}
|
||||
}
|
||||
catch (Exception exception) { failure = exception; ConnectionFailure = exception; }
|
||||
// A liveness failure has already recorded the real cause and cancelled this read, so do
|
||||
// not replace it with the cancellation it produced.
|
||||
catch (Exception exception) { failure = exception; ConnectionFailure ??= exception; }
|
||||
finally
|
||||
{
|
||||
connectionLifetime?.Cancel();
|
||||
|
||||
Reference in New Issue
Block a user