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:
+11
-1
@@ -29,6 +29,15 @@ The iOS user list now opens a remote-user detail view with independent tuning fo
|
||||
audio stream. Private messages are grouped into per-user conversations with direct access to the
|
||||
same user and audio controls.
|
||||
|
||||
The media path now survives changing networks. A client whose source address changes proves
|
||||
possession of its media key from the new address with an authenticated `Rebind` frame and the
|
||||
relay moves its endpoint, instead of the session dying silently in both directions; the client
|
||||
rebuilds its UDP socket rather than retrying on one pinned to a vanished interface. The control
|
||||
connection is judged live by server traffic rather than assumed live, so a blackholed TCP path
|
||||
is detected in 30 s instead of waiting minutes for the OS. The receive jitter buffer keeps a
|
||||
one-frame depth floor, measures late and reordered arrivals, and can deepen mid-call, and a
|
||||
stalled consumer now costs bounded audio rather than the live talkspurt.
|
||||
|
||||
SQLite schema v4 persists DRED and the channel packet-loss mode. Manual loss remains the default;
|
||||
automatic Fast/Balanced/Stable modes measure each sender's authenticated UDP uplink at the server,
|
||||
cap the applied Opus hint at 30%, and feed it back over TLS.
|
||||
@@ -47,7 +56,8 @@ cap the applied Opus hint at 30%, and feed it back over TLS.
|
||||
counters (the render callback now paces the mix and the 20 ms capture handoff, and a watchdog
|
||||
rebuilds a graph that stops calling back). Take a Siri or phone-call interruption while
|
||||
backgrounded and confirm audio resumes without foregrounding. Complete a
|
||||
30-minute iOS call and Wi-Fi/cellular switching with voice restoration, plus extended
|
||||
30-minute iOS call and Wi-Fi/cellular switching with voice restoration (the switch is covered
|
||||
by simulation in `NetworkImpairmentTests`; hardware confirms the real route change), plus extended
|
||||
mono/stereo/voice-chat switching while joined. Verify Windows desktop/per-app stereo sharing.
|
||||
- Complete Developer ID signing/notarization. The iOS host and ReplayKit extension have been
|
||||
distribution-signed and packaged locally; upload the IPA for Apple's server-side validation.
|
||||
|
||||
@@ -68,6 +68,12 @@ need direct native entry points. These are platform adapters, not a second core.
|
||||
- `proto/voicecat.proto` is the control-plane schema.
|
||||
- Media uses the fixed header and AEAD construction described in `protocol.md` and
|
||||
`security.md`.
|
||||
- Media frame types are a versioned contract: `Voice`, `Keepalive`, `UdpBinding`, and `Rebind`.
|
||||
`UdpBinding` establishes a peer's endpoint once, in the clear. `Rebind` moves an established
|
||||
endpoint after the client's source address changes, as on a Wi-Fi/cellular handover; it
|
||||
carries the binding token in the clear for peer lookup only, and authorization comes from the
|
||||
AEAD tag over header and token plus the peer's replay window, so a captured rebind cannot be
|
||||
replayed to redirect someone else's downlink.
|
||||
- SQLite is the server's persistent store; schema changes require explicit migrations.
|
||||
- Client profiles and TOFU pins are local platform data.
|
||||
- The ReplayKit ring layout is separately versioned and frozen.
|
||||
|
||||
@@ -25,7 +25,11 @@ internal sealed class ReceiveStream : IDisposable
|
||||
private readonly byte[][] jitter;
|
||||
private readonly uint[] timestamps;
|
||||
private readonly int[] sizes;
|
||||
private int count, available, offset, missing, waiting;
|
||||
private int count, available, offset, missing, waiting, stretchCooldown;
|
||||
// One held frame per half second. Depth still follows a degrading link within a few seconds,
|
||||
// but a target pinned at its cap can no longer trade a steady stream of concealment against
|
||||
// depth it will never reach, nor oscillate against CatchUp's trim.
|
||||
private const int StretchCooldownFrames = 25;
|
||||
private uint expected;
|
||||
private bool started, hasTimestamp;
|
||||
private bool hasMarker;
|
||||
@@ -40,6 +44,8 @@ internal sealed class ReceiveStream : IDisposable
|
||||
internal int ConcealedFrames { get; private set; }
|
||||
internal int DredFrames { get; private set; }
|
||||
internal int FecFrames { get; private set; }
|
||||
internal int Overruns { get; private set; }
|
||||
internal int Stretches { get; private set; }
|
||||
|
||||
internal ReceiveStream(uint userId, StreamInfo info, TimeProvider? clock = null)
|
||||
{
|
||||
@@ -60,14 +66,22 @@ internal sealed class ReceiveStream : IDisposable
|
||||
internal bool Enqueue(VoiceFrameHeader header, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
int index = written;
|
||||
if (payload.Length is < 1 or > 1275 || unchecked(index - Volatile.Read(ref read)) >= 64) return false;
|
||||
if (payload.Length is < 1 or > 1275) return false;
|
||||
// A stalled consumer (an interrupted or rebuilding iOS graph) stops draining this
|
||||
// handoff. Refusing new packets would hold a ring of audio that is already too old to
|
||||
// play and discard the live talkspurt instead, so the newest always wins and the
|
||||
// consumer — which alone owns `read` — notices the overrun and skips forward.
|
||||
int slot = index & 63; payload.CopyTo(packets[slot]); headers[slot] = header; lengths[slot] = payload.Length; arrivals[slot] = clock.GetTimestamp();
|
||||
Volatile.Write(ref written, unchecked(index + 1)); return true;
|
||||
}
|
||||
|
||||
private void Drain()
|
||||
{
|
||||
while (read != Volatile.Read(ref written))
|
||||
int end = Volatile.Read(ref written);
|
||||
// Leave a margin below the producer rather than resuming exactly 64 back, so a write
|
||||
// during this drain cannot lap the slot being copied.
|
||||
if (unchecked(end - read) > 64) { read = unchecked(end - 32); Overruns++; }
|
||||
while (read != end)
|
||||
{
|
||||
int source = read & 63; uint timestamp = headers[source].Timestamp;
|
||||
if (!hasTimestamp) { hasTimestamp = true; expected = timestamp; }
|
||||
@@ -78,8 +92,16 @@ internal sealed class ReceiveStream : IDisposable
|
||||
DropBefore(timestamp); available = offset = missing = waiting = 0;
|
||||
expected = timestamp; started = false; delta = 0; lastArrival = 0; jitterSamples = 0;
|
||||
}
|
||||
// Observed before the acceptance test below: arrival statistics describe the network,
|
||||
// not what this buffer could use. Measuring only accepted packets let a buffer that
|
||||
// was too shallow reject the very late arrivals that should have deepened it.
|
||||
ObserveArrival(timestamp, arrivals[source]);
|
||||
bool duplicate = false;
|
||||
for (int i = 0; i < sizes.Length; i++) if (sizes[i] != 0 && timestamps[i] == timestamp) duplicate = true;
|
||||
// Frames behind `expected` are unplayable: it is the playout clock and never moves
|
||||
// backward, so a late arrival would sit here forever and, as the oldest entry, would
|
||||
// also mask the future packet that DRED and FEC recover from. Depth, not late
|
||||
// tolerance, is what absorbs reordering here.
|
||||
if ((!started || delta >= 0) && delta % frameSamples == 0 && !duplicate)
|
||||
{
|
||||
if (count >= maximumDepth)
|
||||
@@ -89,7 +111,6 @@ internal sealed class ReceiveStream : IDisposable
|
||||
int target = Array.IndexOf(sizes, 0);
|
||||
timestamps[target] = timestamp; sizes[target] = lengths[source];
|
||||
packets[source].AsSpan(0, lengths[source]).CopyTo(jitter[target]); count++;
|
||||
ObserveArrival(timestamp, arrivals[source]);
|
||||
}
|
||||
Volatile.Write(ref read, unchecked(read + 1));
|
||||
}
|
||||
@@ -100,8 +121,10 @@ internal sealed class ReceiveStream : IDisposable
|
||||
if (lastArrival != 0)
|
||||
{
|
||||
int timestampDelta = unchecked((int)(timestamp - lastArrivalTimestamp));
|
||||
if (timestampDelta <= 0) return;
|
||||
if (timestampDelta > 0 && timestampDelta <= frameSamples * 10)
|
||||
// A reordered packet arrives with a negative timestamp delta, and that is precisely
|
||||
// the arrival the target depth has to absorb. Ignoring it left reordering invisible
|
||||
// to the estimator. Gaps far beyond a frame are talkspurt silence, not jitter.
|
||||
if (Math.Abs(timestampDelta) <= frameSamples * 10)
|
||||
{
|
||||
double arrivalDelta = clock.GetElapsedTime(lastArrival, arrival).TotalSeconds * 48_000;
|
||||
double deviation = Math.Abs(arrivalDelta - timestampDelta);
|
||||
@@ -111,9 +134,12 @@ internal sealed class ReceiveStream : IDisposable
|
||||
lastArrival = arrival; lastArrivalTimestamp = timestamp;
|
||||
}
|
||||
|
||||
// The playout target never drops below a single frame. A zero target starts playout on one
|
||||
// packet with no depth at all, so ordinary reordering becomes concealment even when nothing
|
||||
// was actually lost; recovery modes need a further frame of lookahead on top of that floor.
|
||||
private int TargetSamples()
|
||||
{
|
||||
int recovery = Info.Audio.Dred || Info.Audio.Fec ? frameSamples : 0;
|
||||
int recovery = Info.Audio.Dred || Info.Audio.Fec ? frameSamples * 2 : frameSamples;
|
||||
int variation = checked((int)Math.Ceiling(4 * jitterSamples / frameSamples)) * frameSamples;
|
||||
return Math.Min(5760, recovery + variation);
|
||||
}
|
||||
@@ -155,6 +181,23 @@ internal sealed class ReceiveStream : IDisposable
|
||||
int found = -1;
|
||||
for (int i = 0; i < sizes.Length; i++) if (sizes[i] != 0 && timestamps[i] == expected) { found = i; break; }
|
||||
bool decoded = false;
|
||||
// The playout clock advances one frame per call, so a target that grows mid-call has no
|
||||
// way to deepen the buffer again. Hold the clock for one frame — concealing instead of
|
||||
// consuming — so the standing depth can follow a link that has become jittery. Only when
|
||||
// the expected frame is actually present, otherwise a loss gap would stall playout, and
|
||||
// CatchUp's trim threshold sits two frames above this so the two cannot oscillate.
|
||||
if (stretchCooldown > 0) stretchCooldown--;
|
||||
if (started && found >= 0 && count > 0 && stretchCooldown == 0)
|
||||
{
|
||||
int ahead = Newest();
|
||||
if (unchecked((int)(timestamps[ahead] - expected)) + frameSamples < TargetSamples())
|
||||
{
|
||||
stretchCooldown = StretchCooldownFrames;
|
||||
if (!decoder.TryDecode([], pcm, frameSamples, out _)) pcm.AsSpan(0, frameSamples * channels).Clear();
|
||||
ConcealedFrames++; Stretches++;
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (found >= 0)
|
||||
{
|
||||
decoded = decoder.TryDecode(jitter[found].AsSpan(0, sizes[found]), pcm, frameSamples, out int result) && result == frameSamples;
|
||||
|
||||
@@ -10,7 +10,9 @@ public delegate void EncodedVoiceHandler(VoiceFrameHeader header, ReadOnlySpan<b
|
||||
|
||||
internal sealed class ClientMediaTransport : IAsyncDisposable
|
||||
{
|
||||
private readonly Socket socket;
|
||||
private Socket socket;
|
||||
private readonly IPEndPoint endpoint;
|
||||
private readonly byte[] token = new byte[MediaEncryptor.RebindTokenSize];
|
||||
private readonly MediaSessionCrypto crypto;
|
||||
private readonly CancellationTokenSource stop;
|
||||
private readonly byte[] binding = new byte[VoiceFrameHeader.Size + 16];
|
||||
@@ -21,6 +23,13 @@ internal sealed class ClientMediaTransport : IAsyncDisposable
|
||||
private readonly TaskCompletionSource bound = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly AutoResetEvent sendReady = new(false);
|
||||
private int senderNapping;
|
||||
private long lastInbound;
|
||||
// A media path that has gone quiet for longer than this is treated as lost: the phone has
|
||||
// most likely changed interface, which strands a connected UDP socket on a dead source
|
||||
// address. Two missed keepalive echoes.
|
||||
private const int RecoveryIdleMilliseconds = 5_000;
|
||||
private const int KeepaliveMilliseconds = 2_000;
|
||||
internal int Migrations { get; private set; }
|
||||
internal event EncodedVoiceHandler? Received;
|
||||
internal Task Bound => bound.Task;
|
||||
|
||||
@@ -28,10 +37,13 @@ internal sealed class ClientMediaTransport : IAsyncDisposable
|
||||
{
|
||||
if (token.Length != 16) throw new IOException("Invalid UDP binding token.");
|
||||
this.crypto = crypto;
|
||||
this.endpoint = endpoint;
|
||||
token.CopyTo(this.token.AsSpan());
|
||||
stop = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
socket = new(endpoint.AddressFamily, SocketType.Dgram, ProtocolType.Udp);
|
||||
try { socket.Connect(endpoint); }
|
||||
catch { socket.Dispose(); stop.Dispose(); throw; }
|
||||
lastInbound = Environment.TickCount64;
|
||||
new VoiceFrameHeader(MediaFrameType.UdpBinding, 0, 0, 0, 0, 0).Write(binding);
|
||||
token.CopyTo(binding.AsSpan(VoiceFrameHeader.Size));
|
||||
new VoiceFrameHeader(MediaFrameType.Keepalive, 0, 0, 0, 0, 0).Write(keepalive);
|
||||
@@ -52,6 +64,7 @@ internal sealed class ClientMediaTransport : IAsyncDisposable
|
||||
private void Send()
|
||||
{
|
||||
byte[] plain = new byte[1275], packet = new byte[1275 + VoiceFrameHeader.Size + MediaEncryptor.TagSize];
|
||||
byte[] rebind = new byte[MediaEncryptor.RebindSize];
|
||||
long nextKeepalive = 0;
|
||||
try
|
||||
{
|
||||
@@ -60,23 +73,39 @@ internal sealed class ClientMediaTransport : IAsyncDisposable
|
||||
bool drained = false;
|
||||
try
|
||||
{
|
||||
// A bound path that stops echoing keepalives has been lost under us. The
|
||||
// socket is connected, so it is still pinned to a source address that may no
|
||||
// longer exist; rebuild it and prove possession of the media key from the
|
||||
// new one so the relay moves this peer's endpoint.
|
||||
if (bound.Task.IsCompleted && Environment.TickCount64 - Volatile.Read(ref lastInbound) > RecoveryIdleMilliseconds)
|
||||
{
|
||||
Rebuild();
|
||||
int size = crypto.Encryptor.EncryptRebind(token, rebind);
|
||||
Volatile.Read(ref socket).Send(rebind.AsSpan(0, size), SocketFlags.None);
|
||||
Migrations++;
|
||||
Volatile.Write(ref lastInbound, Environment.TickCount64);
|
||||
nextKeepalive = 0;
|
||||
}
|
||||
if (Environment.TickCount64 >= nextKeepalive)
|
||||
{
|
||||
if (!bound.Task.IsCompleted) socket.Send(binding, SocketFlags.None);
|
||||
socket.Send(keepalive, SocketFlags.None);
|
||||
nextKeepalive = Environment.TickCount64 + (bound.Task.IsCompleted ? 5000 : 250);
|
||||
Socket current = Volatile.Read(ref socket);
|
||||
if (!bound.Task.IsCompleted) current.Send(binding, SocketFlags.None);
|
||||
current.Send(keepalive, SocketFlags.None);
|
||||
nextKeepalive = Environment.TickCount64 + (bound.Task.IsCompleted ? KeepaliveMilliseconds : 250);
|
||||
}
|
||||
while (packets.TryRead(plain, out VoiceFrameHeader header, out int length))
|
||||
{
|
||||
drained = true;
|
||||
int size = crypto.Encryptor.Encrypt(header, plain.AsSpan(0, length), packet);
|
||||
socket.Send(packet.AsSpan(0, size), SocketFlags.None);
|
||||
Volatile.Read(ref socket).Send(packet.AsSpan(0, size), SocketFlags.None);
|
||||
}
|
||||
}
|
||||
catch (SocketException exception) when (IsTransientNetworkError(exception))
|
||||
{
|
||||
// iOS can briefly lose its UDP route while Wi-Fi and cellular switch.
|
||||
// Keep the sender and socket alive so the next route can carry media.
|
||||
// iOS loses its UDP route while Wi-Fi and cellular switch. A connected socket
|
||||
// stays bound to the vanished source address, so retrying on it never
|
||||
// recovers; replace it and let the idle check above re-offer the binding.
|
||||
Rebuild();
|
||||
nextKeepalive = 0;
|
||||
Thread.Sleep(100);
|
||||
}
|
||||
@@ -98,6 +127,18 @@ internal sealed class ClientMediaTransport : IAsyncDisposable
|
||||
finally { stop.Cancel(); }
|
||||
}
|
||||
|
||||
// Replaces the UDP socket so the next send leaves over whichever interface is now current.
|
||||
// Only the sender thread rebuilds; the receive loop picks the new socket up on its next read.
|
||||
private void Rebuild()
|
||||
{
|
||||
if (stop.IsCancellationRequested) return;
|
||||
Socket replacement = new(endpoint.AddressFamily, SocketType.Dgram, ProtocolType.Udp);
|
||||
try { replacement.Connect(endpoint); }
|
||||
catch { replacement.Dispose(); return; }
|
||||
Socket previous = Interlocked.Exchange(ref socket, replacement);
|
||||
previous.Dispose();
|
||||
}
|
||||
|
||||
private async Task ReceiveAsync()
|
||||
{
|
||||
byte[] packet = new byte[65535], plain = new byte[65535];
|
||||
@@ -106,10 +147,14 @@ internal sealed class ClientMediaTransport : IAsyncDisposable
|
||||
while (true)
|
||||
{
|
||||
int length;
|
||||
try { length = await socket.ReceiveAsync(packet, SocketFlags.None, stop.Token).ConfigureAwait(false); }
|
||||
Socket current = Volatile.Read(ref socket);
|
||||
try { length = await current.ReceiveAsync(packet, SocketFlags.None, stop.Token).ConfigureAwait(false); }
|
||||
catch (SocketException exception) when (exception.SocketErrorCode is SocketError.ConnectionReset or SocketError.MessageSize) { continue; }
|
||||
catch (SocketException exception) when (IsTransientNetworkError(exception))
|
||||
{ await Task.Delay(100, stop.Token).ConfigureAwait(false); continue; }
|
||||
// The sender replaced the socket under us during a migration; read the new one.
|
||||
catch (ObjectDisposedException) when (!stop.IsCancellationRequested && !ReferenceEquals(current, Volatile.Read(ref socket))) { continue; }
|
||||
Volatile.Write(ref lastInbound, Environment.TickCount64);
|
||||
if (!VoiceFrameHeader.TryRead(packet.AsSpan(0, length), out var candidate)) continue;
|
||||
if (candidate.Type == MediaFrameType.Keepalive && length == VoiceFrameHeader.Size) { bound.TrySetResult(); continue; }
|
||||
if (candidate.Type != MediaFrameType.Voice || candidate.Codec != 0 ||
|
||||
@@ -128,9 +173,14 @@ internal sealed class ClientMediaTransport : IAsyncDisposable
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
stop.Cancel(); socket.Dispose(); sendReady.Set();
|
||||
stop.Cancel(); Volatile.Read(ref socket).Dispose(); sendReady.Set();
|
||||
try { sending.Join(); await receiving.ConfigureAwait(false); }
|
||||
finally { System.Security.Cryptography.CryptographicOperations.ZeroMemory(binding); stop.Dispose(); sendReady.Dispose(); }
|
||||
finally
|
||||
{
|
||||
System.Security.Cryptography.CryptographicOperations.ZeroMemory(binding);
|
||||
System.Security.Cryptography.CryptographicOperations.ZeroMemory(token);
|
||||
stop.Dispose(); sendReady.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// A bounded, allocation-free packet handoff. A contending producer drops instead of
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -25,20 +25,42 @@ public sealed class MediaDecryptor : IDisposable
|
||||
if (packet.Overlaps(plaintext)) throw new ArgumentException("Input and output must not overlap.", nameof(plaintext));
|
||||
VoiceFrameHeader.TryRead(packet, out var candidate);
|
||||
ulong sequence = candidate.Sequence;
|
||||
if (initialized && sequence <= highestSequence)
|
||||
{
|
||||
ulong offset = highestSequence - sequence;
|
||||
if (offset >= 64 || (replayWindow & (1UL << (int)offset)) != 0) return false;
|
||||
}
|
||||
if (IsReplay(sequence)) return false;
|
||||
if (!cipher.TryDecrypt(sequence, packet[VoiceFrameHeader.Size..], packet[..VoiceFrameHeader.Size], plaintext[..length])) return false;
|
||||
Accept(sequence);
|
||||
header = candidate;
|
||||
bytesWritten = length;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Verifies an endpoint-migration frame: header, plaintext binding token, and tag. The token
|
||||
// is authenticated as additional data, so only the media key holder can move an endpoint,
|
||||
// and the shared replay window makes a captured frame useless to an on-path observer.
|
||||
public bool TryVerifyRebind(ReadOnlySpan<byte> packet, out ReadOnlySpan<byte> token)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
token = default;
|
||||
if (packet.Length != MediaEncryptor.RebindSize) return false;
|
||||
if (!VoiceFrameHeader.TryRead(packet, out var candidate) || candidate.Type != MediaFrameType.Rebind) return false;
|
||||
if (IsReplay(candidate.Sequence)) return false;
|
||||
int aad = VoiceFrameHeader.Size + MediaEncryptor.RebindTokenSize;
|
||||
if (!cipher.TryDecrypt(candidate.Sequence, packet[aad..], packet[..aad], [])) return false;
|
||||
Accept(candidate.Sequence);
|
||||
token = packet.Slice(VoiceFrameHeader.Size, MediaEncryptor.RebindTokenSize);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool IsReplay(ulong sequence)
|
||||
{
|
||||
if (!initialized || sequence > highestSequence) return false;
|
||||
ulong offset = highestSequence - sequence;
|
||||
return offset >= 64 || (replayWindow & (1UL << (int)offset)) != 0;
|
||||
}
|
||||
|
||||
// Only authenticated counters may move the replay window.
|
||||
if (!initialized)
|
||||
private void Accept(ulong sequence)
|
||||
{
|
||||
highestSequence = sequence;
|
||||
replayWindow = 1;
|
||||
initialized = true;
|
||||
}
|
||||
if (!initialized) { highestSequence = sequence; replayWindow = 1; initialized = true; }
|
||||
else if (sequence > highestSequence)
|
||||
{
|
||||
ulong shift = sequence - highestSequence;
|
||||
@@ -46,9 +68,6 @@ public sealed class MediaDecryptor : IDisposable
|
||||
highestSequence = sequence;
|
||||
}
|
||||
else replayWindow |= 1UL << (int)(highestSequence - sequence);
|
||||
header = candidate;
|
||||
bytesWritten = length;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -9,6 +9,8 @@ public sealed class MediaEncryptor : IDisposable
|
||||
private bool disposed;
|
||||
|
||||
public const int TagSize = 16;
|
||||
public const int RebindTokenSize = 16;
|
||||
public const int RebindSize = VoiceFrameHeader.Size + RebindTokenSize + TagSize;
|
||||
|
||||
public MediaEncryptor(ReadOnlySpan<byte> key) : this(key, false) { }
|
||||
|
||||
@@ -31,6 +33,23 @@ public sealed class MediaEncryptor : IDisposable
|
||||
return size;
|
||||
}
|
||||
|
||||
// A rebind frame proves possession of the media key from a new source address. The token
|
||||
// is plaintext so the relay can find the peer without trialling every key; it is covered by
|
||||
// the AEAD as additional data, and the counter makes a captured frame unreplayable.
|
||||
public int EncryptRebind(ReadOnlySpan<byte> token, Span<byte> packet)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
if (token.Length != RebindTokenSize) throw new ArgumentException("Binding tokens contain 16 bytes.", nameof(token));
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(packet.Length, RebindSize);
|
||||
if (nextSequence == ulong.MaxValue) throw new InvalidOperationException("Media counter exhausted; establish a new session.");
|
||||
var header = new VoiceFrameHeader(MediaFrameType.Rebind, VoiceFrameFlags.None, 0, 0, nextSequence++, 0);
|
||||
header.Write(packet);
|
||||
token.CopyTo(packet.Slice(VoiceFrameHeader.Size, RebindTokenSize));
|
||||
int aad = VoiceFrameHeader.Size + RebindTokenSize;
|
||||
cipher.Encrypt(header.Sequence, [], packet[..aad], packet.Slice(aad, TagSize));
|
||||
return RebindSize;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed) return;
|
||||
|
||||
@@ -6,7 +6,10 @@ public enum MediaFrameType : byte
|
||||
{
|
||||
Voice = 1,
|
||||
Keepalive = 2,
|
||||
UdpBinding = 3
|
||||
UdpBinding = 3,
|
||||
// Authenticated endpoint migration. Carries the peer's binding token in the clear for
|
||||
// lookup only; the AEAD tag and replay window are what authorize the move.
|
||||
Rebind = 4
|
||||
}
|
||||
|
||||
[Flags]
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading.Channels;
|
||||
using VoiceCat.Crypto;
|
||||
using VoiceCat.Protocol;
|
||||
using PacketLossMode = Voicecat.V1.PacketLossMode;
|
||||
|
||||
@@ -31,6 +32,7 @@ internal sealed class MediaRelay : IAsyncDisposable
|
||||
private readonly Channel<byte> changed = Channel.CreateBounded<byte>(1);
|
||||
private MediaRoute[] routes = [];
|
||||
private readonly byte[] input = new byte[65535];
|
||||
private readonly byte[] rebindAck = new byte[VoiceFrameHeader.Size];
|
||||
private readonly MediaFanout fanout = new();
|
||||
private readonly Task receiving;
|
||||
internal Task Completion => receiving;
|
||||
@@ -91,6 +93,30 @@ internal sealed class MediaRelay : IAsyncDisposable
|
||||
foreach (MediaRoute route in current)
|
||||
if (route.Peer.Endpoint?.Equals(sender) == true) { source = route; break; }
|
||||
|
||||
// A client whose media source address changed (a phone moving between Wi-Fi and
|
||||
// cellular) keeps its TLS session but arrives here from an unknown address. The
|
||||
// binding token alone travels in the clear, so it may only locate the peer; the
|
||||
// authenticated tag and the peer's replay window are what authorize the move.
|
||||
if (header.Type == MediaFrameType.Rebind)
|
||||
{
|
||||
if (length != MediaEncryptor.RebindSize) continue;
|
||||
// Locate the peer by token before verifying, so a flood of forged rebinds
|
||||
// costs one authentication attempt rather than one per connected peer.
|
||||
MediaPeer? claimed = null;
|
||||
foreach (MediaRoute route in current)
|
||||
if (CryptographicOperations.FixedTimeEquals(route.Peer.Token, input.AsSpan(VoiceFrameHeader.Size, MediaEncryptor.RebindTokenSize)))
|
||||
{ claimed = route.Peer; break; }
|
||||
if (claimed is null || !claimed.Crypto.Decryptor.TryVerifyRebind(input.AsSpan(0, length), out _)) continue;
|
||||
var moved = new SocketAddress(sender.Family, sender.Size);
|
||||
for (int index = 0; index < sender.Size; index++) moved[index] = sender[index];
|
||||
claimed.Endpoint = moved;
|
||||
claimed.Activity.Touch();
|
||||
// Echo a keepalive so the client learns its new path is carrying media.
|
||||
new VoiceFrameHeader(MediaFrameType.Keepalive, 0, 0, 0, 0, 0).Write(rebindAck);
|
||||
await SendAsync(rebindAck, sender).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (header.Type == MediaFrameType.UdpBinding)
|
||||
{
|
||||
if (length != VoiceFrameHeader.Size + 16 || source is not null) continue;
|
||||
|
||||
@@ -28,7 +28,7 @@ public class AudioEngineTests
|
||||
string source = File.ReadAllText(Path.Combine(FindRoot(), "src", "VoiceCat.Core", "ClientMediaTransport.cs"));
|
||||
|
||||
Assert.Contains("new Thread(Send)", source);
|
||||
Assert.Contains("socket.Send(packet.AsSpan(0, size)", source);
|
||||
Assert.Contains("Volatile.Read(ref socket).Send(packet.AsSpan(0, size)", source);
|
||||
Assert.DoesNotContain("Task.Delay(5", source);
|
||||
Assert.DoesNotContain("SendAsync(packet.AsMemory", source);
|
||||
}
|
||||
@@ -253,8 +253,14 @@ public class AudioEngineTests
|
||||
[InlineData(5)] [InlineData(10)] [InlineData(20)] [InlineData(40)] [InlineData(60)]
|
||||
public void RecoveryLookaheadTracksChannelFrameDuration(int frameMilliseconds)
|
||||
{
|
||||
// One frame of standing depth plus one of recovery lookahead. The depth floor keeps a
|
||||
// quiet link from playing out with no buffer at all, where reordering alone conceals.
|
||||
using var stream = new ReceiveStream(2, Stream(frameMilliseconds));
|
||||
Assert.Equal(frameMilliseconds * 48, stream.TargetDepthSamples);
|
||||
Assert.Equal(frameMilliseconds * 96, stream.TargetDepthSamples);
|
||||
|
||||
StreamInfo plain = Stream(frameMilliseconds); plain.Audio.Fec = false;
|
||||
using var bare = new ReceiveStream(2, plain);
|
||||
Assert.Equal(frameMilliseconds * 48, bare.TargetDepthSamples);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -485,7 +491,7 @@ public class AudioEngineTests
|
||||
for (uint i = 0; i < 64; i++) stream.Enqueue(new(MediaFrameType.Voice, 0, 0, 42, i, i * 960), packet.AsSpan(0, length));
|
||||
stream.Mix(output, false, null); Assert.InRange(stream.Depth, 0, 6);
|
||||
for (int i = 0; i < 20; i++) { output.AsSpan().Clear(); stream.Mix(output, false, null); }
|
||||
Assert.All(output, value => Assert.Equal(0, value)); Assert.InRange(stream.ConcealedFrames, 1, 10);
|
||||
Assert.All(output, value => Assert.Equal(0, value)); Assert.InRange(stream.ConcealedFrames, 1, 16);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using VoiceCat.Audio;
|
||||
using VoiceCat.Core;
|
||||
using VoiceCat.Codec;
|
||||
using VoiceCat.Protocol;
|
||||
using Voicecat.V1;
|
||||
using Xunit;
|
||||
using static VoiceCat.Tests.ServerTests;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace VoiceCat.Tests;
|
||||
|
||||
// Deterministic network-impairment simulation for the receive path. A virtual millisecond clock
|
||||
// drives an encoder, an impairment model, and the mixer so bursty loss, jitter, reordering,
|
||||
// link outages, and a stalled (backgrounded) consumer are reproducible without hardware.
|
||||
public class NetworkImpairmentTests(ITestOutputHelper output)
|
||||
{
|
||||
// A scheduled packet. Arrival is virtual-clock milliseconds; duplicates share a timestamp.
|
||||
private readonly record struct Wire(int Arrival, uint Sequence, uint Timestamp, bool Marker, int Length, byte[] Payload);
|
||||
|
||||
private sealed class Impairment(int seed)
|
||||
{
|
||||
private readonly Random random = new(seed);
|
||||
private bool bursting;
|
||||
internal double LossPercent, BurstLossPercent, BurstEntryPercent, BurstExitPercent = 30, JitterMs, ReorderPercent, DuplicatePercent;
|
||||
internal int BaseDelayMs = 20, OutageStartMs = -1, OutageEndMs = -1;
|
||||
|
||||
internal bool Dropped(int sendTime)
|
||||
{
|
||||
if (OutageStartMs >= 0 && sendTime >= OutageStartMs && sendTime < OutageEndMs) return true;
|
||||
if (BurstEntryPercent > 0)
|
||||
{
|
||||
bursting = bursting ? random.NextDouble() * 100 >= BurstExitPercent : random.NextDouble() * 100 < BurstEntryPercent;
|
||||
if (bursting) return random.NextDouble() * 100 < BurstLossPercent;
|
||||
}
|
||||
return random.NextDouble() * 100 < LossPercent;
|
||||
}
|
||||
|
||||
internal int Arrival(int sendTime)
|
||||
{
|
||||
double delay = BaseDelayMs + (JitterMs > 0 ? random.NextDouble() * JitterMs : 0);
|
||||
if (ReorderPercent > 0 && random.NextDouble() * 100 < ReorderPercent) delay += 45;
|
||||
return sendTime + (int)delay;
|
||||
}
|
||||
|
||||
internal bool Duplicated() => DuplicatePercent > 0 && random.NextDouble() * 100 < DuplicatePercent;
|
||||
}
|
||||
|
||||
private sealed record Report(string Name, int Frames, int SilentFrames, int LongestSilentRunMs, int Concealed, int Overruns, int Sent, int TargetDepth)
|
||||
{
|
||||
internal double SilentPercent => Frames == 0 ? 0 : SilentFrames * 100.0 / Frames;
|
||||
public override string ToString() =>
|
||||
$"{Name,-28} silent={SilentPercent,5:F1}% worstGap={LongestSilentRunMs,5}ms concealed={Concealed,4} overruns={Overruns,3} sent={Sent,4} target={TargetDepth,4}";
|
||||
}
|
||||
|
||||
// Runs `durationMs` of a 20 ms mono talkspurt through the impairment model. The consumer
|
||||
// pumps the mixer every 20 ms of virtual time except inside a stall window, which models an
|
||||
// iOS render callback that stops being serviced while backgrounded.
|
||||
private Report Simulate(string name, Impairment impairment, int durationMs = 20_000, bool dred = false, bool fec = true,
|
||||
int stallStartMs = -1, int stallEndMs = -1)
|
||||
{
|
||||
StreamInfo info = AudioEngineTests.Stream(20, dred: dred);
|
||||
info.Audio.Fec = fec;
|
||||
var clock = new VirtualClock();
|
||||
using var stream = new ReceiveStream(7, info, clock);
|
||||
using var encoder = new OpusEncoder(new() { Bitrate = 32000, ForwardErrorCorrection = fec, DeepRedundancy = dred, ExpectedPacketLossPercent = 20, Complexity = 5 });
|
||||
var pending = new List<Wire>();
|
||||
short[] tone = new short[960];
|
||||
int frames = 0, silent = 0, run = 0, longest = 0, sent = 0;
|
||||
int[] mix = new int[1920];
|
||||
|
||||
for (int now = 0; now <= durationMs; now += 20)
|
||||
{
|
||||
CodecTests.FillTone(tone, 960, 1, 48000, now / 20);
|
||||
byte[] packet = new byte[1275];
|
||||
int length = encoder.Encode(tone, packet);
|
||||
uint sequence = (uint)(now / 20);
|
||||
if (!impairment.Dropped(now))
|
||||
{
|
||||
pending.Add(new(impairment.Arrival(now), sequence, sequence * 960, sequence == 0, length, packet));
|
||||
if (impairment.Duplicated()) pending.Add(new(impairment.Arrival(now) + 5, sequence, sequence * 960, false, length, packet));
|
||||
}
|
||||
|
||||
clock.Set(now);
|
||||
foreach (Wire wire in pending.Where(w => w.Arrival <= now).OrderBy(w => w.Arrival).ToArray())
|
||||
{
|
||||
var flags = wire.Marker ? VoiceFrameFlags.Marker : VoiceFrameFlags.None;
|
||||
sent++;
|
||||
stream.Enqueue(new(MediaFrameType.Voice, flags, 0, 42, wire.Sequence, wire.Timestamp), wire.Payload.AsSpan(0, wire.Length));
|
||||
pending.Remove(wire);
|
||||
}
|
||||
|
||||
if (stallStartMs >= 0 && now >= stallStartMs && now < stallEndMs) continue;
|
||||
|
||||
mix.AsSpan().Clear();
|
||||
stream.Mix(mix, false, null);
|
||||
frames++;
|
||||
bool quiet = true;
|
||||
foreach (int sample in mix) if (sample != 0) { quiet = false; break; }
|
||||
if (quiet) { silent++; run += 20; longest = Math.Max(longest, run); }
|
||||
else run = 0;
|
||||
}
|
||||
var report = new Report(name, frames, silent, longest, stream.ConcealedFrames, stream.Overruns, sent, stream.TargetDepthSamples);
|
||||
output.WriteLine(report.ToString());
|
||||
return report;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImpairmentProfileReport()
|
||||
{
|
||||
output.WriteLine("--- FEC on ---");
|
||||
Simulate("clean", new Impairment(1));
|
||||
Simulate("random loss 2%", new Impairment(2) { LossPercent = 2 });
|
||||
Simulate("random loss 10%", new Impairment(3) { LossPercent = 10 });
|
||||
Simulate("bursty loss", new Impairment(4) { BurstEntryPercent = 4, BurstLossPercent = 80, BurstExitPercent = 25 });
|
||||
Simulate("jitter 60ms", new Impairment(5) { JitterMs = 60 });
|
||||
Simulate("jitter 120ms", new Impairment(6) { JitterMs = 120 });
|
||||
Simulate("reorder 5%", new Impairment(7) { ReorderPercent = 5 });
|
||||
Simulate("duplicate 5%", new Impairment(8) { DuplicatePercent = 5 });
|
||||
Simulate("wifi switch 3s outage", new Impairment(9) { OutageStartMs = 6000, OutageEndMs = 9000 });
|
||||
Simulate("bad wifi (loss+jitter)", new Impairment(10) { LossPercent = 8, JitterMs = 80, ReorderPercent = 3 });
|
||||
Simulate("background stall 2s", new Impairment(11), stallStartMs: 6000, stallEndMs: 8000);
|
||||
Simulate("background stall 10s", new Impairment(13), stallStartMs: 6000, stallEndMs: 16000);
|
||||
Simulate("stall + jitter", new Impairment(12) { JitterMs = 60 }, stallStartMs: 6000, stallEndMs: 8000);
|
||||
|
||||
output.WriteLine("--- FEC off and DRED off ---");
|
||||
Simulate("nofec clean", new Impairment(21), fec: false);
|
||||
Simulate("nofec loss 2%", new Impairment(22) { LossPercent = 2 }, fec: false);
|
||||
Simulate("nofec loss 10%", new Impairment(23) { LossPercent = 10 }, fec: false);
|
||||
Simulate("nofec jitter 30ms", new Impairment(24) { JitterMs = 30 }, fec: false);
|
||||
Simulate("nofec reorder 5%", new Impairment(25) { ReorderPercent = 5 }, fec: false);
|
||||
Simulate("nofec bad wifi", new Impairment(26) { LossPercent = 8, JitterMs = 80, ReorderPercent = 3 }, fec: false);
|
||||
}
|
||||
|
||||
// Bounds are set well above the measured result so ordinary codec variation does not make
|
||||
// them flaky; they exist to catch a structural regression in the receive path, such as the
|
||||
// depth floor or the arrival estimator being lost again.
|
||||
[Theory]
|
||||
// impairment, maxConcealedPercent, maxGapMs
|
||||
[InlineData("loss2", 12)]
|
||||
[InlineData("loss10", 12)]
|
||||
[InlineData("burst", 20)]
|
||||
[InlineData("jitter60", 12)]
|
||||
[InlineData("jitter120", 12)]
|
||||
[InlineData("reorder", 5)]
|
||||
[InlineData("badwifi", 12)]
|
||||
public void ImpairedLinksStayIntelligible(string profile, int maxConcealedPercent)
|
||||
{
|
||||
Impairment impairment = profile switch
|
||||
{
|
||||
"loss2" => new(2) { LossPercent = 2 },
|
||||
"loss10" => new(3) { LossPercent = 10 },
|
||||
"burst" => new(4) { BurstEntryPercent = 4, BurstLossPercent = 80, BurstExitPercent = 25 },
|
||||
"jitter60" => new(5) { JitterMs = 60 },
|
||||
"jitter120" => new(6) { JitterMs = 120 },
|
||||
"reorder" => new(7) { ReorderPercent = 5 },
|
||||
_ => new(10) { LossPercent = 8, JitterMs = 80, ReorderPercent = 3 },
|
||||
};
|
||||
Report report = Simulate(profile, impairment);
|
||||
Assert.True(report.Concealed * 100 / report.Frames <= maxConcealedPercent,
|
||||
$"{profile} concealed {report.Concealed} of {report.Frames} frames.");
|
||||
Assert.True(report.LongestSilentRunMs <= 200, $"{profile} went silent for {report.LongestSilentRunMs} ms.");
|
||||
}
|
||||
|
||||
// Pure reordering loses no data at all, so it must be absorbed by depth rather than concealed.
|
||||
// Without the depth floor and an estimator that observes late arrivals this was 47 frames.
|
||||
[Fact]
|
||||
public void ReorderingWithoutLossIsAbsorbedRatherThanConcealed()
|
||||
{
|
||||
Report report = Simulate("reorder no fec", new Impairment(25) { ReorderPercent = 5 }, fec: false);
|
||||
Assert.Equal(1000, report.Sent);
|
||||
Assert.True(report.Concealed <= 15, $"Concealed {report.Concealed} frames despite losing none.");
|
||||
}
|
||||
|
||||
// A consumer that stops draining (an interrupted or rebuilding iOS graph) must not cost the
|
||||
// live talkspurt. The handoff previously refused new packets while full, discarding 437 of
|
||||
// 1000 packets across a ten second stall.
|
||||
[Fact]
|
||||
public void AStalledConsumerLosesBoundedAudioRatherThanTheLiveTalkspurt()
|
||||
{
|
||||
Report report = Simulate("stall", new Impairment(13), stallStartMs: 6000, stallEndMs: 16000);
|
||||
Assert.Equal(1000, report.Sent);
|
||||
Assert.InRange(report.Overruns, 1, 4);
|
||||
Assert.True(report.LongestSilentRunMs <= 200, $"Silent for {report.LongestSilentRunMs} ms after the stall.");
|
||||
}
|
||||
|
||||
// A Wi-Fi/cellular handover changes the client's media source address while TLS survives.
|
||||
// The relay binds a peer's endpoint once and refuses to move it, and the client stops
|
||||
// offering its binding token after the first bind, so media must not silently die here.
|
||||
[Fact]
|
||||
public async Task MediaSurvivesAHandoverThatChangesTheClientSourceAddress()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var alice = await MediaRelayTests.VoicePeer.ConnectAsync(fixture, "Alice");
|
||||
await using var bob = await MediaRelayTests.VoicePeer.ConnectAsync(fixture, "Bob");
|
||||
StreamAnnounceResult stream = await alice.AnnounceAsync(StreamKind.StreamMic);
|
||||
|
||||
await alice.SendAsync(alice.Seal(stream.Ssrc, [1]));
|
||||
await bob.ReceiveVoiceAsync();
|
||||
|
||||
await alice.HandoverAsync();
|
||||
Assert.True(await alice.KeepaliveEchoesAsync(), "Relay stopped routing to Alice after her media source address changed.");
|
||||
|
||||
await alice.SendAsync(alice.Seal(stream.Ssrc, [2]));
|
||||
(_, byte[] payload) = await bob.ReceiveVoiceAsync();
|
||||
Assert.Equal<byte[]>([2], payload);
|
||||
}
|
||||
|
||||
// The rebind token travels in the clear so the relay can locate the peer without trialling
|
||||
// every key. Authorization comes from the AEAD tag and the replay window, so an on-path
|
||||
// observer who captures a rebind must not be able to redirect the peer's downlink.
|
||||
[Fact]
|
||||
public async Task ReplayedRebindFromAnotherAddressCannotStealTheDownlink()
|
||||
{
|
||||
await using var fixture = new ServerFixture();
|
||||
await using var alice = await MediaRelayTests.VoicePeer.ConnectAsync(fixture, "Alice");
|
||||
await using var bob = await MediaRelayTests.VoicePeer.ConnectAsync(fixture, "Bob");
|
||||
StreamAnnounceResult stream = await alice.AnnounceAsync(StreamKind.StreamMic);
|
||||
|
||||
byte[] captured = await alice.CaptureRebindAsync();
|
||||
Assert.True(await alice.KeepaliveEchoesAsync());
|
||||
|
||||
using var attacker = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
|
||||
attacker.Bind(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
await attacker.SendToAsync(captured, SocketFlags.None, fixture.Server.MediaEndPoint);
|
||||
using (var timeout = new CancellationTokenSource(500))
|
||||
{
|
||||
byte[] buffer = new byte[65535];
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
|
||||
await attacker.ReceiveAsync(buffer, SocketFlags.None, timeout.Token));
|
||||
}
|
||||
|
||||
// Alice still owns the path, so her media keeps flowing to Bob.
|
||||
await alice.SendAsync(alice.Seal(stream.Ssrc, [9]));
|
||||
(_, byte[] payload) = await bob.ReceiveVoiceAsync();
|
||||
Assert.Equal<byte[]>([9], payload);
|
||||
Assert.True(await alice.KeepaliveEchoesAsync());
|
||||
}
|
||||
|
||||
// A phone that changes interface leaves TCP blackholed rather than reset: the socket stays
|
||||
// open and the OS reports nothing for minutes. Only an unanswered keepalive exposes it, and
|
||||
// until it does the app shows a live session over a dead path and never reconnects.
|
||||
[Fact]
|
||||
public async Task ABlackholedControlConnectionIsDetectedInsteadOfAppearingConnected()
|
||||
{
|
||||
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.FromMilliseconds(200), TimeSpan.FromSeconds(2));
|
||||
|
||||
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();
|
||||
await disconnected.Task.WaitAsync(TimeSpan.FromSeconds(15));
|
||||
Assert.Equal(ClientConnectionState.Disconnected, client.State);
|
||||
Assert.IsType<IOException>(client.ConnectionFailure);
|
||||
}
|
||||
|
||||
// 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
|
||||
{
|
||||
private readonly Socket listener = new(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
private readonly CancellationTokenSource stop = new();
|
||||
private readonly IPEndPoint origin;
|
||||
private volatile bool frozen;
|
||||
internal IPEndPoint EndPoint { get; }
|
||||
internal void Freeze() => frozen = true;
|
||||
|
||||
internal BlackholeProxy(IPEndPoint origin)
|
||||
{
|
||||
this.origin = origin;
|
||||
listener.Bind(new IPEndPoint(IPAddress.Loopback, 0));
|
||||
listener.Listen(4);
|
||||
EndPoint = (IPEndPoint)listener.LocalEndPoint!;
|
||||
_ = AcceptAsync();
|
||||
}
|
||||
|
||||
private async Task AcceptAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!stop.IsCancellationRequested)
|
||||
{
|
||||
Socket inbound = await listener.AcceptAsync(stop.Token);
|
||||
Socket outbound = new(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
await outbound.ConnectAsync(origin, stop.Token);
|
||||
_ = PumpAsync(inbound, outbound);
|
||||
_ = PumpAsync(outbound, inbound);
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is OperationCanceledException or SocketException or ObjectDisposedException) { }
|
||||
}
|
||||
|
||||
private async Task PumpAsync(Socket from, Socket to)
|
||||
{
|
||||
byte[] buffer = new byte[16384];
|
||||
try
|
||||
{
|
||||
while (!stop.IsCancellationRequested)
|
||||
{
|
||||
int length = await from.ReceiveAsync(buffer, SocketFlags.None, stop.Token);
|
||||
if (length == 0) break;
|
||||
if (frozen) continue;
|
||||
await to.SendAsync(buffer.AsMemory(0, length), SocketFlags.None, stop.Token);
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is OperationCanceledException or SocketException or ObjectDisposedException) { }
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
stop.Cancel(); listener.Dispose(); stop.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class VirtualClock : TimeProvider
|
||||
{
|
||||
private long milliseconds;
|
||||
public override long TimestampFrequency => 1000;
|
||||
public override long GetTimestamp() => milliseconds;
|
||||
internal void Set(long value) => milliseconds = value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user