measurement: name the kinds by protocol and action, add the http_request kind and QUIC settings

This commit is contained in:
justme committed 2026-10-22 07:02:23 +08:00
1 parent bf2a598891
commit e77880d372
31 files changed
+938 -141

No files matched your search

@@ -1,3 +1,4 @@
using System.Net;
using System.Text.Json;
using Fleet.Core;
using MoShiro.Protocol;
@@ -24,11 +25,11 @@ public sealed class MeasurementTests
BudgetGuard guard = Guard();
double mbps = 0;
long pps = 0;
Assert.True(guard.Check(Line("udp_echo"), ref mbps, ref pps).Accepted);
Assert.True(guard.Check(Line("quic_ping"), ref mbps, ref pps).Accepted);
Assert.Equal(RejectReasons.Unsupported, guard.Check(Line("no_such_kind"), ref mbps, ref pps).Reason);
Assert.Equal(RejectReasons.InvalidParams, guard.Check(Line("udp_echo", ("rate_hz", "100000")), ref mbps, ref pps).Reason);
Assert.Equal(RejectReasons.InvalidParams, guard.Check(Line("udp_echo", ("nope", "1")), ref mbps, ref pps).Reason);
Assert.Equal(RejectReasons.PolicyDenied, guard.Check(Line("udp_echo", ("port", "9999")), ref mbps, ref pps).Reason);
Assert.Equal(RejectReasons.InvalidParams, guard.Check(Line("quic_ping", ("rate_hz", "100000")), ref mbps, ref pps).Reason);
Assert.Equal(RejectReasons.InvalidParams, guard.Check(Line("quic_ping", ("nope", "1")), ref mbps, ref pps).Reason);
Assert.Equal(RejectReasons.PolicyDenied, guard.Check(Line("quic_ping", ("port", "9999")), ref mbps, ref pps).Reason);
}
[Fact]
@@ -37,11 +38,11 @@ public sealed class MeasurementTests
BudgetGuard guard = Guard(new NodeLimits { MaxTotalMbps = 15, AllowedQuicPorts = [7443] });
double mbps = 0;
long pps = 0;
Assert.True(guard.CheckPhase("udp_train", new Dictionary<string, JsonElement> { ["mbps"] = Json("10") }).Accepted);
Assert.Equal(RejectReasons.BudgetExceeded, guard.CheckPhase("udp_train", new Dictionary<string, JsonElement> { ["mbps"] = Json("20") }).Reason);
Assert.True(guard.CheckPhase("quic_stream", new Dictionary<string, JsonElement> { ["mbps"] = Json("10") }).Accepted);
Assert.Equal(RejectReasons.BudgetExceeded, guard.CheckPhase("quic_stream", new Dictionary<string, JsonElement> { ["mbps"] = Json("20") }).Reason);
Assert.True(guard.Check(Line("udp_train", ("mbps", "10")), ref mbps, ref pps).Accepted);
Assert.Equal(RejectReasons.BudgetExceeded, guard.Check(Line("udp_train", ("mbps", "10")), ref mbps, ref pps).Reason);
Assert.True(guard.Check(Line("quic_stream", ("mbps", "10")), ref mbps, ref pps).Accepted);
Assert.Equal(RejectReasons.BudgetExceeded, guard.Check(Line("quic_stream", ("mbps", "10")), ref mbps, ref pps).Reason);
}
[Fact]
@@ -54,8 +55,15 @@ public sealed class MeasurementTests
Assert.True(some.TargetAllowed("192.0.2.17:443"));
Assert.True(some.TargetAllowed("2001:db8::1"));
Assert.False(some.TargetAllowed("192.0.3.1"));
Assert.False(some.TargetAllowed("example.com"));
Assert.False(some.TargetAllowed(null));
// 域名要解析之后才知道地址:配置过白名单就先接受,每个解析出的地址再由 AddressAllowed 检查
Assert.True(some.TargetAllowed("example.com:443"));
Assert.True(some.TargetAllowed("https://example.com/health"));
Assert.False(Guard().TargetAllowed("example.com"));
Assert.True(some.AddressAllowed(IPAddress.Parse("192.0.2.9")));
Assert.False(some.AddressAllowed(IPAddress.Parse("10.0.0.1")));
Assert.False(some.AddressAllowed(IPAddress.Parse("::ffff:10.0.0.1")));
}
// ---- RowStore ----
@@ -63,7 +71,7 @@ public sealed class MeasurementTests
[Fact]
public void RowStore_RecordsOnlyItsOwnSidesColumns_AndCutsExistingRowsInKeyOrder()
{
StreamSpec msg = Kinds.UdpEcho.FindStream("msg")!;
StreamSpec msg = Kinds.QuicPing.FindStream("msg")!;
var dialer = new RowStore(msg, Side.Dialer, 64);
Assert.Equal(["size", "w", "t1", "t4"], msg.ColumnsOf(Side.Dialer).Select(c => c.Name));
Assert.Equal(["t2", "t3"], msg.ColumnsOf(Side.Listener).Select(c => c.Name));
@@ -88,7 +96,7 @@ public sealed class MeasurementTests
[Fact]
public void RowStore_RingOverwritesOldRows_AndNeverReturnsAStaleOne()
{
var store = new RowStore(Kinds.UdpEcho.FindStream("msg")!, Side.Listener, 16);
var store = new RowStore(Kinds.QuicPing.FindStream("msg")!, Side.Listener, 16);
store.SetRow(3, [30, 31]);
Assert.True(store.TryGet(3, 0, out long t2));
Assert.Equal(30, t2);
@@ -117,19 +125,19 @@ public sealed class MeasurementTests
msg.Add(seq, 64, 1, t1, t4);
}
return new RawChunk(new RawChunkHeader { Kind = "udp_echo", Clock = new ChunkClock { DialerOffsetUs = 1000 } }, [msg.Build()]);
return new RawChunk(new RawChunkHeader { Kind = "quic_ping", Clock = new ChunkClock { DialerOffsetUs = 1000 } }, [msg.Build()]);
}
[Fact]
public void FragmentMerger_DialerDriven_AddsTheAgentColumnsByKey_AndLeavesMissingRowsEmpty()
{
var agent = new RowStore(Kinds.UdpEcho.FindStream("msg")!, Side.Listener, 64);
var agent = new RowStore(Kinds.QuicPing.FindStream("msg")!, Side.Listener, 64);
agent.SetRow(0, [2000, 2050]);
agent.SetRow(2, [4000, 4040]);
var source = new Source { Stores = { ["msg"] = agent } };
RawChunk fragment = EchoFragment((0, 1000, 3000), (1, 2000, null), (2, 3000, 5000));
Result<IReadOnlyList<RawStream>> merged = FragmentMerger.Merge(Kinds.UdpEcho, fragment, 1, source);
Result<IReadOnlyList<RawStream>> merged = FragmentMerger.Merge(Kinds.QuicPing, fragment, 1, source);
RawStream msg = Assert.Single(merged.Value);
Assert.Equal(["seq", "size", "w", "t1", "t2", "t3", "t4"], msg.Columns.Select(c => c.Name));
Assert.Equal([true, false, true], Enumerable.Range(0, 3).Select(i => msg.Find("t2")!.IsValid(i)));
@@ -141,7 +149,7 @@ public sealed class MeasurementTests
public void FragmentMerger_DialerDriven_WithoutAnyAgentRecords_KeepsTheDialerRows()
{
RawChunk fragment = EchoFragment((0, 1000, 3000), (1, 2000, null));
RawStream msg = Assert.Single(FragmentMerger.Merge(Kinds.UdpEcho, fragment, 1, new Source()).Value);
RawStream msg = Assert.Single(FragmentMerger.Merge(Kinds.QuicPing, fragment, 1, new Source()).Value);
Assert.Equal(2, msg.Count);
Assert.False(msg.Find("t2")!.IsValid(0));
Assert.Equal(3000, msg.Find("t4")!.Values[0]);
@@ -152,14 +160,14 @@ public sealed class MeasurementTests
{
var bad = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq")]));
bad.Add(0);
var fragment = new RawChunk(new RawChunkHeader { Kind = "udp_echo" }, [bad.Build()]);
Assert.False(FragmentMerger.Merge(Kinds.UdpEcho, fragment, 1, new Source()).IsSuccess);
var fragment = new RawChunk(new RawChunkHeader { Kind = "quic_ping" }, [bad.Build()]);
Assert.False(FragmentMerger.Merge(Kinds.QuicPing, fragment, 1, new Source()).IsSuccess);
}
[Fact]
public void FragmentMerger_AgentDriven_TakesRowsFromTheAgent_AndMarksUnreceivedPacketsAsLost()
{
var agent = new RowStore(Kinds.UdpTrain.FindStream("pkt")!, Side.Listener, 64);
var agent = new RowStore(Kinds.QuicStream.FindStream("pkt")!, Side.Listener, 64);
for (uint seq = 0; seq < 5; seq++)
{
agent.SetRow(seq, [1000, 100 + seq]);
@@ -167,12 +175,12 @@ public sealed class MeasurementTests
var pkt = new StreamBuilder(new StreamSpec("pkt", [Col.Seq("seq"), Col.TsDialer("t_recv")]));
pkt.Add(1, 5000).Add(1, 9999).Add(3, 6000); // sequence 1 arrives twice (the first one counts); 0, 2 and 4 are lost
var header = new RawChunkHeader { Kind = "udp_train" };
var header = new RawChunkHeader { Kind = "quic_stream" };
header.Params[FragmentKeys.SeqFrom] = Json("0");
header.Params[FragmentKeys.SeqTo] = Json("5");
var source = new Source { Stores = { ["pkt"] = agent } };
RawStream merged = Assert.Single(FragmentMerger.Merge(Kinds.UdpTrain, new RawChunk(header, [pkt.Build()]), 1, source).Value);
RawStream merged = Assert.Single(FragmentMerger.Merge(Kinds.QuicStream, new RawChunk(header, [pkt.Build()]), 1, source).Value);
Assert.Equal(5, merged.Count);
Assert.Equal(["seq", "size", "t_send", "t_recv"], merged.Columns.Select(c => c.Name));
RawColumn recv = merged.Find("t_recv")!;
@@ -181,7 +189,7 @@ public sealed class MeasurementTests
Assert.Equal(103, merged.Find("t_send")!.Values[3]);
// A one-way fragment that does not declare its sequence range is invalid
Assert.False(FragmentMerger.Merge(Kinds.UdpTrain, new RawChunk(new RawChunkHeader { Kind = "udp_train" }, [pkt.Build()]), 1, source).IsSuccess);
Assert.False(FragmentMerger.Merge(Kinds.QuicStream, new RawChunk(new RawChunkHeader { Kind = "quic_stream" }, [pkt.Build()]), 1, source).IsSuccess);
}
[Fact]
@@ -189,9 +197,9 @@ public sealed class MeasurementTests
{
var conn = new StreamBuilder(new StreamSpec("conn", [Col.U8("outcome"), Col.Seq("attempt"), Col.TsDialer("ts")]));
conn.Add(0, 0, 100);
var fragment = new RawChunk(new RawChunkHeader { Kind = "connect" }, [conn.Build()]);
var fragment = new RawChunk(new RawChunkHeader { Kind = "tcp_connect" }, [conn.Build()]);
RawStream merged = Assert.Single(FragmentMerger.Merge(Kinds.Connect, fragment, 1, new Source()).Value);
RawStream merged = Assert.Single(FragmentMerger.Merge(Kinds.TcpConnect, fragment, 1, new Source()).Value);
Assert.Equal(["attempt", "ts", "tcp_us", "tls_us", "outcome"], merged.Columns.Select(c => c.Name));
Assert.False(merged.Find("tcp_us")!.IsValid(0));
}
@@ -212,7 +220,7 @@ public sealed class MeasurementTests
[Fact]
public void FragmentMerger_UnionStreams_ConcatenateBothSidesRows_AndEachAgentRowIsDeliveredOnce()
{
StreamSpec info = Kinds.TcpEcho.FindStream("tcp_info")!;
StreamSpec info = Kinds.TcpPing.FindStream("tcp_info")!;
Assert.Equal(info.Columns.Count, info.ColumnsOf(Side.Listener).Count); // each side owns every column
var agent = new RowStore(info, Side.Listener, 64);
var recorder = new Source { Stores = { ["tcp_info"] = agent } };
@@ -222,7 +230,7 @@ public sealed class MeasurementTests
var msg = EchoFragment((0, 1000, 3000));
var fragment = new RawChunk(msg.Header, [msg.Streams[0], InfoRows(info, (100, 0, 1))]);
IReadOnlyList<RawStream> first = FragmentMerger.Merge(Kinds.TcpEcho, fragment, 1, recorder).Value;
IReadOnlyList<RawStream> first = FragmentMerger.Merge(Kinds.TcpPing, fragment, 1, recorder).Value;
RawStream merged = first.Single(x => x.Name == "tcp_info");
Assert.Equal(3, merged.Count);
Assert.Equal([0L, 1L, 1L], merged.Find("side")!.Values);
@@ -231,20 +239,20 @@ public sealed class MeasurementTests
Assert.Equal(1, merged.Find("conn")!.Values[1]);
// The agent's rows were consumed: the next chunk carries only the dialer's own rows
IReadOnlyList<RawStream> second = FragmentMerger.Merge(Kinds.TcpEcho, fragment, 1, recorder).Value;
IReadOnlyList<RawStream> second = FragmentMerger.Merge(Kinds.TcpPing, fragment, 1, recorder).Value;
Assert.Equal(1, second.Single(x => x.Name == "tcp_info").Count);
}
[Fact]
public void FragmentMerger_UnionStreams_AgentRowsAreDeliveredEvenWhenTheDialerHasNone()
{
StreamSpec info = Kinds.TcpEcho.FindStream("tcp_info")!;
StreamSpec info = Kinds.TcpPing.FindStream("tcp_info")!;
var agent = new RowStore(info, Side.Listener, 64);
agent.SetRow(agent.Append(), [500, 1, 40_000, 5_000, 10, 7, 100, 1_000, 1]);
// A dialer on a platform without kernel sampling sends the echo rows only
RawChunk fragment = EchoFragment((0, 1000, 3000));
IReadOnlyList<RawStream> merged = FragmentMerger.Merge(Kinds.TcpEcho, fragment, 1, new Source { Stores = { ["tcp_info"] = agent } }).Value;
IReadOnlyList<RawStream> merged = FragmentMerger.Merge(Kinds.TcpPing, fragment, 1, new Source { Stores = { ["tcp_info"] = agent } }).Value;
RawStream rows = merged.Single(x => x.Name == "tcp_info");
Assert.Equal(1, rows.Count);
Assert.Equal(7, rows.Find("retrans_total")!.Values[0]);
@@ -253,11 +261,11 @@ public sealed class MeasurementTests
[Fact]
public void FragmentMerger_LegacyTcpSamples_KeepConnectionIdentityUnknown()
{
StreamSpec info = Kinds.TcpEcho.FindStream("tcp_info")!;
StreamSpec info = Kinds.TcpPing.FindStream("tcp_info")!;
RawStream sample = InfoRows(info, (100, 0, 1));
var legacy = new RawStream(sample.Name, sample.Count, sample.Columns.Where(c => c.Name != "conn").ToArray());
var fragment = new RawChunk(new RawChunkHeader { Kind = "tcp_echo" }, [legacy]);
RawStream merged = Assert.Single(FragmentMerger.Merge(Kinds.TcpEcho, fragment, 1, new Source()).Value);
var fragment = new RawChunk(new RawChunkHeader { Kind = "tcp_ping" }, [legacy]);
RawStream merged = Assert.Single(FragmentMerger.Merge(Kinds.TcpPing, fragment, 1, new Source()).Value);
Assert.False(merged.Find("conn")!.IsValid(0));
Assert.Equal(1, merged.Find("retrans_total")!.Values[0]);
}
@@ -282,7 +290,7 @@ public sealed class MeasurementTests
[Fact]
public void EchoRows_Tcp_WaitsForTheReplyInsteadOfCallingASeveralSecondStallALoss()
{
var rows = new EchoRows(Kinds.TcpEcho, 64, EchoRows.TcpGraceUs, settleOnReply: true);
var rows = new EchoRows(Kinds.TcpPing, 64, EchoRows.TcpGraceUs, settleOnReply: true);
uint a = rows.Add(64, 0);
uint b = rows.Add(64, 100_000);
uint c = rows.Add(64, 200_000);
@@ -312,7 +320,7 @@ public sealed class MeasurementTests
[Fact]
public void EchoRows_Udp_GivesUpOnAMissingReplyAfterTwoSeconds()
{
var rows = new EchoRows(Kinds.UdpEcho, 64, EchoRows.DatagramGraceUs, settleOnReply: false);
var rows = new EchoRows(Kinds.QuicPing, 64, EchoRows.DatagramGraceUs, settleOnReply: false);
rows.Add(64, 0);
rows.Add(64, 1_000_000);
RawStream cut = rows.Take(2_500_000, all: false)!;
@@ -324,7 +332,7 @@ public sealed class MeasurementTests
[Fact]
public void EchoRows_DiscardedLocalSendIsNotReportedAsNetworkLoss()
{
var rows = new EchoRows(Kinds.UdpEcho, 64, EchoRows.DatagramGraceUs, settleOnReply: false);
var rows = new EchoRows(Kinds.QuicPing, 64, EchoRows.DatagramGraceUs, settleOnReply: false);
uint unsent = rows.Add(64, 0);
Assert.True(rows.Discard(unsent));
@@ -369,7 +377,7 @@ public sealed class MeasurementTests
public void BlockSpec_ConfigSignatureIgnoresTheStreamId_ButSignatureDoesNot()
{
BlockSpec Create(string json, uint stream = 1, int port = 7443) => BlockSpec.Create(
"udp_echo",
"quic_ping",
JsonDocument.Parse(json).RootElement.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.Clone()),
stream,
port,
@@ -381,7 +389,7 @@ public sealed class MeasurementTests
Assert.NotEqual(baseline.Signature, Create("""{"rate_hz":10,"size":64}""", stream: 2).Signature);
Assert.Equal(baseline.ConfigSignature, Create("""{"rate_hz":10,"size":64}""", stream: 2).ConfigSignature);
Assert.NotEqual(baseline.ConfigSignature, Create("""{"rate_hz":10,"size":64}""", port: 9000).ConfigSignature);
Assert.Null(BlockSpec.Create("udp_echo", new Dictionary<string, JsonElement> { ["rate_hz"] = Json("100000") }, 1, 7443, new FragmentScope(null, null, null)));
Assert.Null(BlockSpec.Create("quic_ping", new Dictionary<string, JsonElement> { ["rate_hz"] = Json("100000") }, 1, 7443, new FragmentScope(null, null, null)));
}
// ---- 起止时间 ----
@@ -389,13 +397,13 @@ public sealed class MeasurementTests
[Fact]
public void ChunkFinalizer_TakesStartAndDurationFromTheDataTimestamps_NotFromTheDeliveryTime()
{
var agent = new RowStore(Kinds.UdpEcho.FindStream("msg")!, Side.Listener, 64);
var agent = new RowStore(Kinds.QuicPing.FindStream("msg")!, Side.Listener, 64);
agent.SetRow(0, [2000, 2050]);
RawChunk fragment = EchoFragment((0, 1000, 3000));
IReadOnlyList<RawStream> streams = FragmentMerger.Merge(Kinds.UdpEcho, fragment, 1, new Source { Stores = { ["msg"] = agent } }).Value;
IReadOnlyList<RawStream> streams = FragmentMerger.Merge(Kinds.QuicPing, fragment, 1, new Source { Stores = { ["msg"] = agent } }).Value;
var anchor = new ClockAnchor(ListenerMonoUs: 1_000_000, UtcMs: 1_700_000_000_000);
RawChunk chunk = ChunkFinalizer.Finalize(Kinds.UdpEcho, fragment, streams, new NodeId("agt_x"), anchor, new Dictionary<string, JsonElement> { ["port"] = Json("7443") }, 7443, new ChunkId(Guid.NewGuid()));
RawChunk chunk = ChunkFinalizer.Finalize(Kinds.QuicPing, fragment, streams, new NodeId("agt_x"), anchor, new Dictionary<string, JsonElement> { ["port"] = Json("7443") }, 7443, new ChunkId(Guid.NewGuid()));
// t1 = 1000 us (拨号方 clock) + offset 1000 = 2000 us on the Agent clock, which is 998 ms before the anchor
Assert.Equal(1_700_000_000_000 - 998, chunk.Header.StartUtcMs);
@@ -0,0 +1,57 @@
using System.Net;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Fleet.Core;
namespace MoShiro.Measurement.Tests;
public sealed class QuicSettingsTests
{
[Theory]
[InlineData(1100, null, false)]
[InlineData(null, 70_000, false)]
[InlineData(1400, 1300, false)]
[InlineData(1280, 1500, true)]
[InlineData(null, null, true)]
public void Validate_RejectsMtuOutsideTheSupportedRangeOrOutOfOrder(int? min, int? max, bool valid) =>
Assert.Equal(valid, new QuicSettings { MinMtu = min, MaxMtu = max }.Validate() is null);
/// <summary>设置真的写进了 MsQuic:最大 MTU 压低后数据报上限随之变小,最小 MTU 抬高后一连上就是大包(不用等路径探测)。</summary>
[Theory]
[InlineData(1200, 1280, 1200, 1280)]
[InlineData(1500, 1500, 1400, 1500)]
public async Task Settings_ChangeTheDatagramLimitOfTheConnection(int min, int max, int atLeast, int atMost)
{
var settings = new QuicSettings { MinMtu = min, MaxMtu = max };
var log = new NullLog();
using ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var request = new CertificateRequest("CN=localhost", key, HashAlgorithmName.SHA256);
using X509Certificate2 certificate = request.CreateSelfSigned(DateTimeOffset.UnixEpoch, DateTimeOffset.UnixEpoch.AddYears(100));
await using var listener = new MsQuicListener(IPAddress.Loopback, [0], certificate, (_, _) => { }, log, null, settings);
listener.Start();
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15));
MsQuicConnection client = await MsQuicConnection.ConnectAsync(IPAddress.Loopback, listener.BoundPorts[0], 0, key.ExportSubjectPublicKeyInfo(), (_, _) => { }, log, timeout.Token, settings);
try
{
while (!client.IsReady)
{
await Task.Delay(10, timeout.Token);
}
// 路径 MTU 探测还可能在往上走,给它一点时间;上限始终不能超过设置的最大 MTU
await Task.Delay(1500, timeout.Token);
Assert.InRange(client.MaxDatagramBytes, atLeast - 100, atMost);
}
finally
{
await client.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(3));
}
}
private sealed class NullLog : INodeLog
{
public void Write(NodeLogLevel level, string message, params (string Key, object? Value)[] fields)
{
}
}
}
@@ -35,7 +35,7 @@ public sealed class CatalogTests
public void DefaultLineTemplates_StayWithinTheLineBudget()
{
long total = 0;
foreach (KindSpec kind in new[] { Kinds.UdpEcho, Kinds.TcpEcho })
foreach (KindSpec kind in new[] { Kinds.QuicPing, Kinds.TcpPing })
{
total += kind.BudgetBitsPerSecond(ParamResolver.Resolve(kind.Params, null).Values!);
}
@@ -52,7 +52,7 @@ public sealed class CatalogTests
["size"] = Json("\"big\""),
["bogus"] = Json("1"),
};
ParamResolution result = ParamResolver.Resolve(Kinds.UdpEcho.Params, input);
ParamResolution result = ParamResolver.Resolve(Kinds.QuicPing.Params, input);
Assert.False(result.IsValid);
Assert.Contains("between 1 and 200", result.Errors["rate_hz"]);
@@ -64,13 +64,13 @@ public sealed class CatalogTests
[Fact]
public void Resolve_AcceptsBoundariesAndRejectsJustOutside()
{
Assert.True(ParamResolver.Resolve(Kinds.UdpEcho.Params, new Dictionary<string, JsonElement> { ["rate_hz"] = Json("1") }).IsValid);
Assert.True(ParamResolver.Resolve(Kinds.UdpEcho.Params, new Dictionary<string, JsonElement> { ["rate_hz"] = Json("200") }).IsValid);
Assert.False(ParamResolver.Resolve(Kinds.UdpEcho.Params, new Dictionary<string, JsonElement> { ["rate_hz"] = Json("0") }).IsValid);
Assert.False(ParamResolver.Resolve(Kinds.UdpEcho.Params, new Dictionary<string, JsonElement> { ["rate_hz"] = Json("201") }).IsValid);
Assert.False(ParamResolver.Resolve(Kinds.UdpEcho.Params, new Dictionary<string, JsonElement> { ["rate_hz"] = Json("1.5") }).IsValid);
Assert.False(ParamResolver.Resolve(Kinds.UdpTrain.Params, new Dictionary<string, JsonElement> { ["direction"] = Json("\"sideways\"") }).IsValid);
Assert.False(ParamResolver.Resolve(Kinds.TcpEcho.Params, new Dictionary<string, JsonElement> { ["tls"] = Json("1") }).IsValid);
Assert.True(ParamResolver.Resolve(Kinds.QuicPing.Params, new Dictionary<string, JsonElement> { ["rate_hz"] = Json("1") }).IsValid);
Assert.True(ParamResolver.Resolve(Kinds.QuicPing.Params, new Dictionary<string, JsonElement> { ["rate_hz"] = Json("200") }).IsValid);
Assert.False(ParamResolver.Resolve(Kinds.QuicPing.Params, new Dictionary<string, JsonElement> { ["rate_hz"] = Json("0") }).IsValid);
Assert.False(ParamResolver.Resolve(Kinds.QuicPing.Params, new Dictionary<string, JsonElement> { ["rate_hz"] = Json("201") }).IsValid);
Assert.False(ParamResolver.Resolve(Kinds.QuicPing.Params, new Dictionary<string, JsonElement> { ["rate_hz"] = Json("1.5") }).IsValid);
Assert.False(ParamResolver.Resolve(Kinds.QuicStream.Params, new Dictionary<string, JsonElement> { ["direction"] = Json("\"sideways\"") }).IsValid);
Assert.False(ParamResolver.Resolve(Kinds.TcpPing.Params, new Dictionary<string, JsonElement> { ["tls"] = Json("1") }).IsValid);
}
[Fact]
@@ -98,7 +98,7 @@ public sealed class CatalogTests
var set = new TaskSet
{
Version = 7,
Tasks = [new TaskSpec { Id = new TaskId("tsk_1"), Kind = "udp_echo", Params = new Dictionary<string, JsonElement> { ["rate_hz"] = Json("10") } }],
Tasks = [new TaskSpec { Id = new TaskId("tsk_1"), Kind = "quic_ping", Params = new Dictionary<string, JsonElement> { ["rate_hz"] = Json("10") } }],
Peers = [new PeerSpec { NodeId = new Fleet.Core.NodeId("agt_1"), PublicKey = "AAAA" }],
};
string text = JsonSerializer.Serialize(set, MoShiroJsonContext.Default.TaskSet);
@@ -114,9 +114,9 @@ public sealed class CatalogTests
[Fact]
public void NodeEnrollMeta_ToleratesUnknownFields_AndKeepsDefaults()
{
NodeEnrollMeta meta = JsonSerializer.Deserialize("{\"kinds\":[\"udp_echo\"],\"future\":1}", MoShiroJsonContext.Default.NodeEnrollMeta)!;
NodeEnrollMeta meta = JsonSerializer.Deserialize("{\"kinds\":[\"quic_ping\"],\"future\":1}", MoShiroJsonContext.Default.NodeEnrollMeta)!;
Assert.Equal(0, meta.TcpPort); // 没有端口表示这个节点不监听
Assert.Equal(["udp_echo"], meta.Kinds);
Assert.Equal(["quic_ping"], meta.Kinds);
}
[Fact]
@@ -28,7 +28,7 @@ public sealed class RawChunkCodecTests
private static RawChunk SampleChunk()
{
RawChunk chunk = ChunkFactory.Echo(
s_agent, "udp_echo", 1_700_000_000_000, 6, 100,
s_agent, "quic_ping", 1_700_000_000_000, 6, 100,
i => new EchoPacket(LostUp: i == 2, LostDown: i == 4, 20_000 + (i * 100), 21_000));
chunk.Header.ChunkId = new ChunkId(Guid.Parse("01890000-0000-7000-8000-000000000001"));
return chunk;
@@ -41,7 +41,7 @@ public sealed class RawChunkCodecTests
RawChunk decoded = RawChunkCodec.Decode(RawChunkCodec.Encode(original)).Value;
Assert.Equal(original.Header.ChunkId, decoded.Header.ChunkId);
Assert.Equal("udp_echo", decoded.Header.Kind);
Assert.Equal("quic_ping", decoded.Header.Kind);
Assert.Equal(original.Header.Clock.DialerOffsetUs, decoded.Header.Clock.DialerOffsetUs);
RawStream before = original.Find("msg")!;
RawStream after = decoded.Find("msg")!;
@@ -100,7 +100,7 @@ public sealed class RawChunkCodecTests
string columns = Convert.ToHexStringLower(body.AsSpan(4 + (int)headerLength));
string header = System.Text.Encoding.UTF8.GetString(body.AsSpan(4, (int)headerLength));
Assert.StartsWith("{\"chunk_id\":\"01890000-0000-7000-8000-000000000001\",\"container_version\":1,\"kind\":\"udp_echo\"", header);
Assert.StartsWith("{\"chunk_id\":\"01890000-0000-7000-8000-000000000001\",\"container_version\":1,\"kind\":\"quic_ping\"", header);
Assert.Contains("\"streams\":[{\"name\":\"msg\",\"count\":6,\"columns\":[{\"name\":\"seq\",\"type\":\"u32\",\"enc\":\"delta\",\"nullable\":false},", header);
Assert.Equal(GoldenColumns, columns);
}
@@ -181,7 +181,7 @@ public sealed class RawChunkCodecTests
[Fact]
public void Encode_IsSmallerThanTheNaiveRepresentation()
{
// 10 Hz 的 udp_echo 一个 10 秒块应当远小于每行 6×8 字节
// 10 Hz 的 quic_ping 一个 10 秒块应当远小于每行 6×8 字节
RawChunk chunk = ChunkFactory.UdpEchoClean(s_agent, 1_700_000_000_000);
Assert.True(RawChunkCodec.Encode(chunk).Length < 100 * 6 * 8 / 4);
}
@@ -0,0 +1,28 @@
using MoShiro.Protocol;
namespace MoShiro.Protocol.Tests;
public sealed class TargetHostTests
{
[Theory]
[InlineData("http_request", "https://Example.com/health?x=1", "https://example.com/health?x=1")]
[InlineData("http_request", "http://192.0.2.1:8080", "http://192.0.2.1:8080/")]
[InlineData("tcp_connect", "Example.com:443", "example.com:443")]
[InlineData("tcp_connect", "192.0.2.1", "192.0.2.1")]
[InlineData("tcp_connect", "[2001:db8::1]:443", "[2001:db8::1]:443")]
[InlineData("icmp_ping", "2001:db8::1", "2001:db8::1")]
public void Normalize_AcceptsTheTargetFormOfTheKind(string kind, string target, string expected) =>
Assert.Equal(expected, TargetHost.Normalize(kind, target));
[Theory]
[InlineData("http_request", "example.com")] // http 必须是 URL
[InlineData("http_request", "ftp://example.com/")]
[InlineData("http_request", "https://user:pw@example.com/")]
[InlineData("tcp_connect", "https://example.com/")] // 其余 Kind 不能是 URL
[InlineData("tcp_connect", "example.com:0")]
[InlineData("tcp_connect", "exa mple.com")]
[InlineData("tcp_connect", "-bad.example.com")]
[InlineData("tcp_connect", "")]
public void Normalize_RejectsTheWrongFormForTheKind(string kind, string target) =>
Assert.Null(TargetHost.Normalize(kind, target));
}