Survive changing networks and deepen the receive buffer
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

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:
2026-09-24 19:15:16 +02:00
parent fb740bcfb2
commit f3ac779bf4
12 changed files with 612 additions and 41 deletions
+35 -1
View File
@@ -1,6 +1,7 @@
using VoiceCat.Transport;
using System.Net;
using System.Net.Sockets;
using VoiceCat.Crypto;
using VoiceCat.Protocol;
using VoiceCat.Server.Transport;
using Voicecat.V1;
@@ -177,7 +178,7 @@ public sealed class MediaRelayTests
internal sealed class VoicePeer : IAsyncDisposable
{
public Client Client { get; }
private readonly Socket udp = new(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
private Socket udp = new(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
private readonly IPEndPoint endpoint;
private readonly MediaSessionCrypto crypto;
public ulong LastSequence { get; private set; }
@@ -228,6 +229,39 @@ public sealed class MediaRelayTests
return packet;
}
public async Task SendAsync(byte[] packet) => await udp.SendToAsync(packet, SocketFlags.None, endpoint, Client.Timeout.Token);
// Models a Wi-Fi/cellular handover: the peer keeps its TLS control session but its media
// source address changes, then it re-offers its UDP binding token from the new address.
internal async Task HandoverAsync()
{
udp.Dispose();
udp = new(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
udp.Bind(new IPEndPoint(IPAddress.Loopback, 0));
byte[] rebind = new byte[MediaEncryptor.RebindSize];
int size = crypto.Encryptor.EncryptRebind(Client.Authentication!.UdpToken.Span, rebind);
await SendAsync(rebind[..size]);
}
// A rebind captured off the wire must not let anyone else claim the peer's downlink.
internal async Task<byte[]> CaptureRebindAsync()
{
byte[] rebind = new byte[MediaEncryptor.RebindSize];
int size = crypto.Encryptor.EncryptRebind(Client.Authentication!.UdpToken.Span, rebind);
await SendAsync(rebind[..size]);
return rebind[..size];
}
// Returns true when the relay echoes a keepalive to the peer's current source address,
// which is the only signal that the server will route downlink media back to it.
internal async Task<bool> KeepaliveEchoesAsync(int timeoutMilliseconds = 1000)
{
byte[] keepalive = new byte[VoiceFrameHeader.Size];
new VoiceFrameHeader(MediaFrameType.Keepalive, 0, 0, 0, 0, 0).Write(keepalive);
await SendAsync(keepalive);
using var timeout = new CancellationTokenSource(timeoutMilliseconds);
try { byte[] buffer = new byte[65535]; await udp.ReceiveAsync(buffer, SocketFlags.None, timeout.Token); return true; }
catch (OperationCanceledException) { return false; }
}
public async Task<byte[]> ReceivePacketAsync()
{
byte[] buffer = new byte[65535];