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(); 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([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(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([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(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; } }