331 lines
16 KiB
C#
331 lines
16 KiB
C#
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;
|
||
|
|
}
|
||
|
|
}
|