feat(quic): replace probe UDP data plane with QUIC datagrams

This commit is contained in:
justme committed 2026-10-10 02:59:56 +08:00
1 parent 4609048865
commit b964dc2bbc
53 files changed
+2413 -411

No files matched your search

@@ -40,8 +40,8 @@ public sealed class ModelAndTestEvaluatorTests
[Fact]
public void BaseModel_PerfectLine_ScoresHigh_AndBadLine_ScoresLow()
{
double good = new BaseModel().Score(View(("rtt.p95", 25), ("loss.total", 0), ("jitter.down.p95", 1), ("jitter.up.p95", 1), ("loss.run_max", 1))).Score!.Value;
double bad = new BaseModel().Score(View(("rtt.p95", 400), ("loss.total", 12), ("jitter.down.p95", 90), ("jitter.up.p95", 90), ("loss.run_max", 40))).Score!.Value;
double good = new BaseModel().Score(View(("rtt.p95", 25), ("loss.total", 0), ("jitter.down.p95", 1), ("jitter.up.p95", 1), ("loss.run.up_max", 1), ("loss.run.down_max", 1))).Score!.Value;
double bad = new BaseModel().Score(View(("rtt.p95", 400), ("loss.total", 12), ("jitter.down.p95", 90), ("jitter.up.p95", 90), ("loss.run.up_max", 40), ("loss.run.down_max", 40))).Score!.Value;
Assert.True(good > 95);
Assert.True(bad < 10);
}
@@ -83,7 +83,7 @@ public sealed class ModelAndTestEvaluatorTests
var ranges = new Dictionary<string, double[]>
{
["rtt.p95"] = [10, 30, 80, 150, 300, 600], ["loss.total"] = [0, 0.5, 1, 3, 10, 30],
["jitter.down.p95"] = [0, 2, 8, 20, 80, 200], ["jitter.up.p95"] = [0, 2, 8, 20, 80, 200], ["loss.run_max"] = [1, 3, 10, 30, 60],
["jitter.down.p95"] = [0, 2, 8, 20, 80, 200], ["jitter.up.p95"] = [0, 2, 8, 20, 80, 200], ["loss.run.up_max"] = [1, 3, 10, 30, 60], ["loss.run.down_max"] = [1, 3, 10, 30, 60],
["tcp.rtt.p95"] = [10, 40, 100, 200, 400, 800], ["tcp.stall.per_min"] = [0, 1, 5, 20, 60, 200], ["tcp.stall_max"] = [50, 150, 500, 2000, 5000],
["tcp.retrans"] = [0, 0.1, 1, 3, 10, 30], ["connect.tls_ms.p95"] = [50, 150, 400, 1000, 3000], ["hold.resets.per_h"] = [0, 1, 4, 12, 30],
["connect.success"] = [100, 99, 95, 80, 50], ["tcp.bulk.peak.max"] = [800, 400, 100, 10, 1], ["hy2.sustainable_mbps"] = [100, 50, 25, 10, 0],
@@ -90,6 +90,28 @@ public sealed class OpTests
Assert.Equal(2, ((RunLengthAcc)EvaluateOneBucket(chunk, "loss.run").Items["loss.run"]).Max);
}
[Fact]
public void LossRuns_AreSeparatedByDirection_AndDownstreamExcludesRequestsLostUpstream()
{
bool[] upLost = [false, true, true, false, false, false, true, true];
bool[] downLost = [false, false, true, true, false, true, false, true];
RawChunk chunk = ChunkFactory.Echo(s_agent, "udp_echo", T0, upLost.Length, 100,
i => new EchoPacket(upLost[i], downLost[i], 20_000, 20_000));
MetricSet set = EvaluateOneBucket(chunk, "loss.run.up", "loss.run.down");
Assert.Equal(2, ((MaxAcc)set.Items["loss.run.up"]).Result);
Assert.Equal(1, ((MaxAcc)set.Items["loss.run.down"]).Result);
}
[Fact]
public void LossRuns_NoLoss_ExportsZeroForBothDirections()
{
RawChunk chunk = ChunkFactory.UdpEchoClean(s_agent, T0, count: 8);
MetricSet set = EvaluateOneBucket(chunk, "loss.run.up", "loss.run.down");
Assert.Equal(0, ((MaxAcc)set.Items["loss.run.up"]).Result);
Assert.Equal(0, ((MaxAcc)set.Items["loss.run.down"]).Result);
}
[Fact]
public void Reorder_CountsLateArrivals_AndIgnoresDuplicates()
{
@@ -21,7 +21,7 @@ public sealed class AgentEndToEndTests
AgentHarness agent = await AgentHarness.StartAsync(_backend);
await agent.OverrideLineTasksAsync(rateHz: 30);
ProbeSimulator probe = await agent.ConnectProbeAsync();
LineTaskSet set = await probe.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.UdpPorts[0]));
LineTaskSet set = await probe.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.QuicPorts[0]));
return (agent, probe, set.Tasks.Single(t => t.Kind == "udp_echo"));
}
@@ -159,8 +159,8 @@ public sealed class AgentEndToEndTests
// The probe process restarts: a new session with its own streams; sequence numbers start from 0 again
await using ProbeSimulator second = first.Restarted();
await second.ConnectAsync("127.0.0.1", agent.App.ControlPort, agent.TlsPublicKey, agent.App.UdpPorts[0], agent.NodeId);
LineTaskSet set = await second.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.UdpPorts[0]));
await second.ConnectAsync("127.0.0.1", agent.App.ControlPort, agent.TlsPublicKey, agent.App.QuicPorts[0], agent.NodeId);
LineTaskSet set = await second.WaitForTasksAsync(s => s.Tasks.Any(t => t.Kind == "udp_echo" && t.Port == agent.App.QuicPorts[0]));
LineTask udp2 = set.Tasks.Single(t => t.Kind == "udp_echo");
Assert.NotEqual(udp.StreamId, udp2.StreamId);
@@ -221,11 +221,11 @@ public sealed class AgentEndToEndTests
await using AgentHarness agent = await AgentHarness.StartAsync(_backend);
await agent.CreateProbeAsync();
await using var stranger = new ProbeSimulator("prb_stranger", Fleet.Identity.NodeIdentity.Generate());
await Assert.ThrowsAnyAsync<Exception>(() => stranger.ConnectAsync("127.0.0.1", agent.App.ControlPort, agent.TlsPublicKey, agent.App.UdpPorts[0], agent.NodeId));
await Assert.ThrowsAnyAsync<Exception>(() => stranger.ConnectAsync("127.0.0.1", agent.App.ControlPort, agent.TlsPublicKey, agent.App.QuicPorts[0], agent.NodeId));
// 证书公钥不对(中间人)也不会连上
await using ProbeSimulator probe = await agent.CreateProbeAsync();
await Assert.ThrowsAnyAsync<Exception>(() => probe.ConnectAsync("127.0.0.1", agent.App.ControlPort, Fleet.Identity.NodeIdentity.Generate().PublicKeySpki, agent.App.UdpPorts[0], agent.NodeId));
await Assert.ThrowsAnyAsync<Exception>(() => probe.ConnectAsync("127.0.0.1", agent.App.ControlPort, Fleet.Identity.NodeIdentity.Generate().PublicKeySpki, agent.App.QuicPorts[0], agent.NodeId));
}
[Fact]
@@ -335,7 +335,7 @@ public sealed class AgentEndToEndTests
await using AgentHarness agent = await AgentHarness.StartAsync(_backend, config => config.Limits.MaxTotalMbps = 20);
await agent.OverrideLineTasksAsync();
var phase = new TestPhase { Kind = "udp_train", DurationSeconds = 1, Params = { ["port"] = System.Text.Json.JsonSerializer.SerializeToElement(agent.App.UdpPorts[0]) } };
var phase = new TestPhase { Kind = "udp_train", DurationSeconds = 1, Params = { ["port"] = System.Text.Json.JsonSerializer.SerializeToElement(agent.App.QuicPorts[0]) } };
var run = new TestRunParams { TestId = new TestId("tst_x"), ProbeId = new ProbeId("prb_nobody"), Plan = "x", Phases = [phase] };
Assert.Equal("probe_offline", agent.App.Tests.Start(run).Error.Code.Value);
@@ -58,7 +58,7 @@ public sealed class AgentHarness : IAsyncDisposable
var config = new AgentConfig
{
Fleet = { Server = backend.BaseAddress.ToString(), IdentityDir = directory },
Listen = { Bind = "127.0.0.1", ControlPort = 0, UdpPorts = [0], TcpPorts = [0] },
Listen = { Bind = "127.0.0.1", ControlPort = 0, QuicPorts = [0], TcpPorts = [0] },
};
configure?.Invoke(config);
LoadedNodeIdentity identity = LoadedNodeIdentity.Load(directory);
@@ -87,7 +87,7 @@ public sealed class AgentHarness : IAsyncDisposable
public async Task<ProbeSimulator> ConnectProbeAsync()
{
ProbeSimulator probe = await CreateProbeAsync();
await probe.ConnectAsync("127.0.0.1", App.ControlPort, TlsPublicKey, App.UdpPorts[0], NodeId);
await probe.ConnectAsync("127.0.0.1", App.ControlPort, TlsPublicKey, App.QuicPorts[0], NodeId);
return probe;
}
@@ -104,7 +104,7 @@ public sealed class AgentHarness : IAsyncDisposable
}
string kind = task["kind"]!.GetValue<string>();
var parameters = new JsonObject { ["port"] = kind == "udp_echo" ? App.UdpPorts[0] : App.TcpPorts[0] };
var parameters = new JsonObject { ["port"] = kind == "udp_echo" ? App.QuicPorts[0] : App.TcpPorts[0] };
// 默认的 connect 模板在这些用例里不需要(也占预算):对这个 Agent 关掉它
bool enabled = kind != "connect";
if (rateHz is { } hz && kind != "connect")
@@ -97,6 +97,7 @@ public sealed class AgentSimulator : IAsyncDisposable
["meta"] = new JsonObject
{
["probe_tcp_port"] = 7443,
["probe_quic_ports"] = new JsonArray(7443),
["probe_udp_ports"] = new JsonArray(7443),
["public_host"] = publicHost,
["kinds"] = new JsonArray((kinds ?? ["udp_echo", "tcp_echo", "udp_train", "connect", "hold", "tcp_bulk", "icmp_echo"]).Select(k => (JsonNode?)JsonValue.Create(k)).ToArray()),
@@ -154,7 +155,8 @@ public sealed class AgentSimulator : IAsyncDisposable
TlsPublicKey = Convert.ToBase64String(_tlsKey.PublicKeySpki),
Addresses = Addresses is null ? null : [.. Addresses],
ProbeTcpPort = 7443,
ProbeUdpPorts = [7443],
ProbeQuicPorts = [7443],
LegacyProbeUdpPorts = [7443],
LocalLimits = new LocalLimits { MaxTotalMbps = 200, MaxTotalPps = 20_000, MaxConcurrentTests = 1 },
},
MoShiroJsonContext.Default.AgentHelloMeta);
@@ -4,6 +4,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\shared\MoShiro.Protocol\MoShiro.Protocol.csproj" />
<ProjectReference Include="..\..\shared\MoShiro.Measurement\MoShiro.Measurement.csproj" />
<ProjectReference Include="..\..\fleet\src\Fleet.Identity\Fleet.Identity.csproj" />
<ProjectReference Include="..\..\fleet\src\Fleet.Link\Fleet.Link.csproj" />
</ItemGroup>
@@ -8,13 +8,14 @@ using Fleet.Core;
using Fleet.Identity;
using Fleet.Link;
using Fleet.Rpc;
using MoShiro.Measurement;
using MoShiro.Protocol;
namespace MoShiro.TestSupport;
/// <summary>
/// 假 Probe:通过真实的点对点握手(TLS 固定证书 + 签名 + ECDH)连接真实的 Agent,接收 <c>line_task_set</c>,
/// 在自己的 UDP 套接字上发回显包、接收单向数据流、估计时钟偏移,并把 Probe 一侧的列块经发件箱通道 17 交给 Agent。
/// 通过 QUIC Datagram 发回显包、接收单向数据流、估计时钟偏移,并把 Probe 一侧的列块经发件箱通道 17 交给 Agent。
/// 它实现 Probe 的协议职责,是将来真实 Probe 的参照。
/// </summary>
public sealed class ProbeSimulator : IAsyncDisposable
@@ -30,8 +31,9 @@ public sealed class ProbeSimulator : IAsyncDisposable
private readonly CancellationTokenSource _stop = new();
private NodeId _agentId;
private RpcSession<ProbeSimulator>? _rpc;
private UdpClient? _udp;
private IPEndPoint? _agentUdp;
private QuicDatagramTransport? _quic;
private DataSession? _dataSession;
private IPEndPoint? _agentQuic;
private PeerSessionInfo? _info;
private Task? _receiver;
private Task? _run;
@@ -72,13 +74,22 @@ public sealed class ProbeSimulator : IAsyncDisposable
(PeerSessionInfo info, TlsFrameTransport transport) = await PeerConnector.ConnectAsync(host, controlPort, agentPublicKey, _identity, ProbeId, "0.0.0-sim", 8 << 20, time ?? TimeProvider.System, ct);
_info = info;
_agentKey = agentPublicKey;
_udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0));
_agentUdp = new IPEndPoint(IPAddress.Parse(host), udpPort);
_dataSession = new DataSession(info.Credentials.SessionIdValue, info.Credentials.Key);
_agentQuic = new IPEndPoint(IPAddress.Parse(host), udpPort);
_quic = new QuicDatagramTransport(
_dataSession,
new MonoClock(time ?? TimeProvider.System),
new DataPlaneCounters(),
new TestNodeLog(),
_agentQuic,
agentPublicKey,
0);
_rpc = new RpcSession<ProbeSimulator>(transport, _router, this, time ?? TimeProvider.System);
_rpc.OnBinary = (_, _) => ValueTask.CompletedTask;
_run = _rpc.RunAsync(_stop.Token);
_receiver = Task.Run(ReceiveLoopAsync, _stop.Token);
_receiver = _quic.RunAsync(_stop.Token);
await SyncClockAsync();
await _quic.ConnectAsync(ct);
}
public async Task<LineTaskSet> WaitForTasksAsync(Func<LineTaskSet, bool> predicate, TimeSpan? timeout = null)
@@ -123,19 +134,21 @@ public sealed class ProbeSimulator : IAsyncDisposable
int size = (int)task.Params["size"].GetInt64();
var collector = new EchoCollector();
_echoes[task.StreamId] = collector;
_quic!.Register(task.StreamId, OnDatagram);
long startUtc = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds();
byte[] packet = new byte[size];
for (uint seq = 0; seq < count; seq++)
{
long t1 = NowUs();
collector.Sent[seq] = t1;
UdpPacket.Write(packet, new UdpHeader(UdpPacketType.EchoRequest, _info!.Credentials.SessionIdValue, task.StreamId, seq, t1), _info.Credentials.Key);
await _udp!.SendAsync(packet, packet.Length, _agentUdp!);
MeasurementDatagram.Write(packet, new MeasurementDatagramHeader(MeasurementDatagramType.EchoRequest, _info!.Credentials.SessionIdValue, task.StreamId, seq, t1), _info.Credentials.Key);
_quic.Send(_dataSession!, _agentQuic!, packet);
await Task.Delay(interval, ct);
}
await Task.Delay(300, ct); // 等最后的回复
_echoes.TryRemove(task.StreamId, out _);
_quic.Unregister(task.StreamId);
var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.TsProbe("t1"), Col.TsProbe("t4", nullable: true)]));
foreach ((uint seq, long t1) in collector.Sent.OrderBy(p => p.Key))
@@ -191,22 +204,28 @@ public sealed class ProbeSimulator : IAsyncDisposable
}
/// <summary>发送任意字节到 Agent 的 UDP 端口(用来测试 Agent 对垃圾包的处理)。</summary>
public Task SendRawUdpAsync(byte[] datagram) => _udp!.SendAsync(datagram, datagram.Length, _agentUdp!);
public Task SendRawUdpAsync(byte[] datagram)
{
_quic!.Send(_dataSession!, _agentQuic!, datagram);
return Task.CompletedTask;
}
/// <summary>用伪造的密钥发一个回显包。</summary>
public Task SendForgedEchoAsync(LineTask task, uint seq, byte[]? key = null)
{
byte[] packet = new byte[64];
UdpPacket.Write(packet, new UdpHeader(UdpPacketType.EchoRequest, _info!.Credentials.SessionIdValue, task.StreamId, seq, NowUs()), key ?? new byte[32]);
return _udp!.SendAsync(packet, packet.Length, _agentUdp!);
MeasurementDatagram.Write(packet, new MeasurementDatagramHeader(MeasurementDatagramType.EchoRequest, _info!.Credentials.SessionIdValue, task.StreamId, seq, NowUs()), key ?? new byte[32]);
_quic!.Send(_dataSession!, _agentQuic!, packet);
return Task.CompletedTask;
}
/// <summary>重放:用同一个序号、同样的密钥再发一次。</summary>
public Task SendEchoAsync(LineTask task, uint seq)
{
byte[] packet = new byte[64];
UdpPacket.Write(packet, new UdpHeader(UdpPacketType.EchoRequest, _info!.Credentials.SessionIdValue, task.StreamId, seq, NowUs()), _info.Credentials.Key);
return _udp!.SendAsync(packet, packet.Length, _agentUdp!);
MeasurementDatagram.Write(packet, new MeasurementDatagramHeader(MeasurementDatagramType.EchoRequest, _info!.Credentials.SessionIdValue, task.StreamId, seq, NowUs()), _info.Credentials.Key);
_quic!.Send(_dataSession!, _agentQuic!, packet);
return Task.CompletedTask;
}
public int EchoRepliesSeen(uint streamId) => _echoes.TryGetValue(streamId, out EchoCollector? c) ? c.Received.Count : 0;
@@ -227,7 +246,7 @@ public sealed class ProbeSimulator : IAsyncDisposable
public async ValueTask DisposeAsync()
{
await _stop.CancelAsync();
_udp?.Dispose();
_quic?.Dispose();
if (_rpc is not null)
{
await _rpc.DisposeAsync();
@@ -277,41 +296,18 @@ public sealed class ProbeSimulator : IAsyncDisposable
return header;
}
private async Task ReceiveLoopAsync()
private void OnDatagram(in QuicDatagramDelivery delivery)
{
while (!_stop.IsCancellationRequested)
long now = delivery.ReceiveUs;
MeasurementDatagramHeader header = delivery.Header;
if (header.Type == MeasurementDatagramType.EchoReply && _echoes.TryGetValue(header.StreamId, out EchoCollector? echo))
{
UdpReceiveResult result;
try
{
result = await _udp!.ReceiveAsync(_stop.Token);
}
catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException or SocketException)
{
if (_stop.IsCancellationRequested || ex is ObjectDisposedException)
{
return;
}
continue;
}
long now = NowUs();
byte[] data = result.Buffer;
if (!UdpPacket.TryParseHeader(data, out UdpHeader header) || !UdpPacket.VerifyMac(data, _info!.Credentials.Key))
{
continue;
}
if (header.Type == UdpPacketType.EchoReply && _echoes.TryGetValue(header.StreamId, out EchoCollector? echo))
{
echo.Received.TryAdd(header.Seq, now);
}
else if (header.Type == UdpPacketType.Data && _trains.TryGetValue(header.StreamId, out TrainCollector? train))
{
train.Received.TryAdd(header.Seq, now);
train.Size = data.Length;
}
echo.Received.TryAdd(header.Seq, now);
}
else if (header.Type == MeasurementDatagramType.Data && _trains.TryGetValue(header.StreamId, out TrainCollector? train))
{
train.Received.TryAdd(header.Seq, now);
train.Size = delivery.Payload.Length;
}
}
@@ -366,33 +362,39 @@ public sealed class ProbeSimulator : IAsyncDisposable
private readonly ConcurrentDictionary<uint, TestPhaseStartParams> _activePhases = new();
private async Task<bool> StartPhaseAsync(TestPhaseStartParams start, CancellationToken ct)
private Task<bool> StartPhaseAsync(TestPhaseStartParams start, CancellationToken ct)
{
Phases.Enqueue(start);
if (RefusePhases)
{
return false;
return Task.FromResult(false);
}
_activePhases[start.StreamId] = start;
if (start.Kind == "udp_train")
{
_trains[start.StreamId] = new TrainCollector { StartUtcMs = TimeProvider.System.GetUtcNow().ToUnixTimeMilliseconds() };
_quic!.Register(start.StreamId, OnDatagram);
// 先发几个探活包,让 Agent 学到(最新的)源地址,再回复 accepted
byte[] hello = new byte[64];
for (uint seq = 0; seq < 3; seq++)
{
UdpPacket.Write(hello, new UdpHeader(UdpPacketType.Data, _info!.Credentials.SessionIdValue, start.StreamId, seq, NowUs()), _info.Credentials.Key);
await _udp!.SendAsync(hello.AsMemory(), _agentUdp!, ct);
MeasurementDatagram.Write(hello, new MeasurementDatagramHeader(MeasurementDatagramType.Data, _info!.Credentials.SessionIdValue, start.StreamId, seq, NowUs()), _info.Credentials.Key);
_quic.Send(_dataSession!, _agentQuic!, hello);
}
}
return true;
_ = ct;
return Task.FromResult(true);
}
private async Task<int> EndPhaseAsync(TestPhaseEndParams end, CancellationToken ct)
{
_activePhases.TryRemove(end.StreamId, out TestPhaseStartParams? start);
if (start is not null)
{
_quic!.Unregister(start.StreamId);
}
if (start is null || DropFragments)
{
_trains.TryRemove(end.StreamId, out _);
@@ -456,4 +458,9 @@ public sealed class ProbeSimulator : IAsyncDisposable
public long StartUtcMs { get; init; }
}
private sealed class TestNodeLog : INodeLog
{
public void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields) { }
}
}