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 -1
View File
@@ -8,7 +8,7 @@
<EventSourceSupport>false</EventSourceSupport>
<DebuggerSupport>false</DebuggerSupport>
<HttpActivityPropagationSupport>false</HttpActivityPropagationSupport>
<Http3Support>false</Http3Support>
<Http3Support>true</Http3Support>
<MetricsSupport>false</MetricsSupport>
<UseSizeOptimizedLinq>true</UseSizeOptimizedLinq>
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
+8 -2
View File
@@ -74,10 +74,16 @@ public sealed class AgentApp : IDisposable, IAsyncDisposable
_ipv6Supported = _listening ? bind.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 : null;
_admission = new AgentAdmission(_streams);
_datagrams = new QuicDatagramTransport(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.QuicPorts, identity.Certificate);
QuicSettings quic = config.Quic.ToSettings();
if (quic.Validate() is { } invalid)
{
throw new InvalidOperationException("agent.json quic: " + invalid);
}
_datagrams = new QuicDatagramTransport(_sessions, _admission, _clock, _counters.Data, log, bind, config.Listen.QuicPorts, identity.Certificate, quic);
// 预握手敲门:控制端口和数据端口共用一个关卡(限速和去重一起算);名册就是期望状态里的 peers
_trust = TrustAnchor.Load(config.Fleet.IdentityDir, log);
_dialer = new DialerHost(new DialEnv(identity.PeerIdentity, identity.NodeId.Value, config.Limits, new DialDirectory(new ConfigStore(dataDirectory), dataDirectory, log, _trust), _clock, time, log), time, log);
_dialer = new DialerHost(new DialEnv(identity.PeerIdentity, identity.NodeId.Value, config.Limits, quic, new DialDirectory(new ConfigStore(dataDirectory), dataDirectory, log, _trust), _clock, time, log), time, log);
_knock = new KnockGate(identity.PeerIdentity, RosterKeys, time);
_tcp = new TcpAcceptor(bind, config.Listen.TcpPorts, identity.Certificate, _sessions, _counters.Data, _knock);
@@ -15,6 +15,9 @@ public sealed class AgentConfig
[JsonPropertyName("listen")]
public ListenSection Listen { get; set; } = new();
[JsonPropertyName("quic")]
public QuicSection Quic { get; set; } = new();
[JsonPropertyName("limits")]
public LimitsSection Limits { get; set; } = new();
@@ -66,6 +69,27 @@ public sealed class ListenSection
public List<int> TcpPorts { get; set; } = [7444];
}
/// <summary>数据通道 QUIC 连接的传输参数;不写的项用 MsQuic 默认值。监听和拨出的连接都用这里的值。</summary>
public sealed class QuicSection
{
[JsonPropertyName("min_mtu")]
public int? MinMtu { get; set; }
[JsonPropertyName("max_mtu")]
public int? MaxMtu { get; set; }
[JsonPropertyName("pacing")]
public bool? Pacing { get; set; }
[JsonPropertyName("idle_timeout_ms")]
public int? IdleTimeoutMs { get; set; }
[JsonPropertyName("keep_alive_ms")]
public int? KeepAliveMs { get; set; }
public QuicSettings ToSettings() => new() { MinMtu = MinMtu, MaxMtu = MaxMtu, Pacing = Pacing, IdleTimeoutMs = IdleTimeoutMs, KeepAliveMs = KeepAliveMs };
}
public sealed class LimitsSection
{
[JsonPropertyName("max_total_mbps")]
@@ -217,8 +217,8 @@ public sealed class StreamInfo
KindSpec spec = Kinds.Find(kind)!;
DeclaredPps = kind switch
{
"udp_echo" or "tcp_echo" => Kinds.PeakRateHz(kind, parameters),
"udp_train" => parameters.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, parameters.GetInt("size")),
"quic_ping" or "tcp_ping" => Kinds.PeakRateHz(kind, parameters),
"quic_stream" => parameters.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, parameters.GetInt("size")),
_ => 20,
};
Port = spec.Params.Any(p => p.Name == "port") ? (int)parameters.GetInt("port") : 0;
@@ -11,7 +11,7 @@ using MoShiro.Protocol;
namespace MoShiro.Agent;
/// <summary>所有连接共用的东西。</summary>
public sealed record DialEnv(NodeIdentity Identity, string NodeId, LimitsSection Limits, DialDirectory Directory, MonoClock Clock, TimeProvider Time, INodeLog Log);
public sealed record DialEnv(NodeIdentity Identity, string NodeId, LimitsSection Limits, QuicSettings Quic, DialDirectory Directory, MonoClock Clock, TimeProvider Time, INodeLog Log);
/// <summary>
/// 与一个 Agent 的点对点连接,分两层:
@@ -170,7 +170,8 @@ public sealed class DialConnection : IDisposable
new DataPlaneCounters(),
_attemptLog,
new IPEndPoint(remote.Address, quicPort),
agentKey);
agentKey,
_env.Quic);
Volatile.Write(ref _activeDatagrams, datagrams);
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
@@ -55,7 +55,7 @@ public sealed class DialSession
}
}
private static bool IsTcpKind(string kind) => kind is "tcp_echo" or "connect" or "hold" or "tcp_bulk";
private static bool IsTcpKind(string kind) => kind is "tcp_ping" or "tcp_connect" or "tls_hold" or "tcp_transfer";
/// <summary>调和线路任务:参数没有变化的积木继续运行,其余启动或停止。返回被拒绝的任务(不认识的 Kind、参数非法、超过上限)。</summary>
public async Task<List<RejectedItem>> ApplyAsync(LineTaskSet set)
@@ -137,7 +137,7 @@ public sealed class DialSession
return false;
}
if (start.Kind == "udp_train")
if (start.Kind == "quic_stream")
{
double mbps = start.Params["mbps"].GetDouble();
long size = start.Params["size"].GetInt64();
@@ -167,7 +167,7 @@ public sealed class DialSession
}
// 最后一批包(单向数据流)、最后几个回显的回复还在途中
await Task.Delay(TimeSpan.FromMilliseconds(phase.Kind == "udp_train" ? 200 : 300), BlockEnv.Time, ct);
await Task.Delay(TimeSpan.FromMilliseconds(phase.Kind == "quic_stream" ? 200 : 300), BlockEnv.Time, ct);
await phase.Running.StopAsync();
return phase.Running.Block.Flush(FlushMode.All, end.SentCount);
}
@@ -7,14 +7,16 @@ using MoShiro.Protocol;
namespace MoShiro.Agent;
/// <summary>
/// 目标任务(Agent 自己探测一个地址,没有对端):用和线路任务同一套积木(<see cref="BlockCatalog"/> 里的拨号方,例如 icmp_echo、connect),
/// 只是列块不交给 Agent 合并,而是自己盖章、10 秒一块直接上传。目标地址必须在本地 <c>allowed_target_cidrs</c> 内
/// (由 <see cref="BudgetGuard"/> 在接受任务之前检查)。
/// 目标任务(Agent 自己探测一个地址,没有对端):用和线路任务同一套积木(<see cref="BlockCatalog"/> 里的拨号方,例如 icmp_ping、connect),
/// 只是列块不交给 Agent 合并,而是自己盖章、10 秒一块直接上传。目标可以是 IP、域名或(http)URL;域名在 Agent 上解析,
/// 每个解析出的地址都必须在本地 <c>allowed_target_cidrs</c> 内(IP 目标由 <see cref="BudgetGuard"/> 在接受任务之前检查,域名在这里检查)。
/// </summary>
public sealed class TargetRunners
{
private static readonly TimeSpan s_chunkSpan = TimeSpan.FromSeconds(ProtocolLimits.ChunkSeconds);
private static readonly TimeSpan s_resolveRetry = TimeSpan.FromMinutes(1);
private readonly Dictionary<string, Running> _running = [];
private readonly object _gate = new();
private readonly ChunkAssembler _chunks;
@@ -33,7 +35,7 @@ public sealed class TargetRunners
}
/// <summary>让运行中的任务与期望集合一致:参数没有变化的保持,其余启动或停止。</summary>
public void Reconcile(IReadOnlyList<(TaskSpec Spec, TaskDecision Decision)> desired)
public void Reconcile(IReadOnlyList<(TaskSpec Spec, TaskDecision Decision)> desired, Func<IPAddress, bool>? allow = null)
{
lock (_gate)
{
@@ -56,7 +58,7 @@ public sealed class TargetRunners
var cts = new CancellationTokenSource();
_running[id] = new Running(Signature(spec), cts);
_ = Task.Run(() => RunAsync(spec, decision, cts.Token), CancellationToken.None);
_ = Task.Run(() => RunAsync(spec, decision, allow, cts.Token), CancellationToken.None);
}
}
}
@@ -64,23 +66,33 @@ public sealed class TargetRunners
private static string Signature(TaskSpec spec) =>
spec.Kind + "|" + spec.Target + "|" + string.Join(',', spec.Params.OrderBy(p => p.Key, StringComparer.Ordinal).Select(p => p.Key + "=" + p.Value.GetRawText()));
private async Task RunAsync(TaskSpec spec, TaskDecision decision, CancellationToken ct)
private async Task RunAsync(TaskSpec spec, TaskDecision decision, Func<IPAddress, bool>? allow, CancellationToken ct)
{
try
{
if (!TryResolve(spec.Target!, decision.Resolved!, spec.Kind, out IPAddress? address, out int port))
if (!TargetHost.TryParse(spec.Target, out string host, out int targetPort, out _))
{
_log.Warn("target is not an address", ("task", spec.Id.Value));
_log.Warn("target is not valid", ("task", spec.Id.Value));
return;
}
// http 自己解析并检查每个地址;其余 Kind 在这里解析出一个允许的地址作为对端
IPAddress? address = null;
if (spec.Kind != TargetHost.HttpKind)
{
address = await ResolveAllowedAsync(spec, host, allow, ct);
}
int port = targetPort != 0 ? targetPort : spec.Kind == "tcp_connect" ? (int)decision.Resolved!.GetInt("port") : 0;
var env = new BlockEnv
{
Clock = _clock,
Log = _log,
Session = () => null,
PeerAddress = address,
Fragments = new LocalSink(this, spec, address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 ? IpVersions.V6 : IpVersions.V4),
Target = spec.Target,
AllowAddress = allow,
Fragments = new LocalSink(this, spec, address?.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 ? IpVersions.V6 : IpVersions.V4),
};
IBlock block = BlockCatalog.Find(spec.Kind)!.Dialer(env, new BlockSpec(decision.Kind!, decision.Resolved!, 0, port, new FragmentScope(spec.Id, null, null)));
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
@@ -110,21 +122,29 @@ public sealed class TargetRunners
}
}
private static bool TryResolve(string target, ParamValues values, string kind, out IPAddress address, out int port)
/// <summary>解析目标,取第一个允许的地址;解析失败或没有允许的地址就每分钟重试(域名的解析结果会变)。</summary>
private async Task<IPAddress> ResolveAllowedAsync(TaskSpec spec, string host, Func<IPAddress, bool>? allow, CancellationToken ct)
{
port = kind == "connect" ? (int)values.GetInt("port") : 0;
if (IPEndPoint.TryParse(target, out IPEndPoint? endpoint))
while (true)
{
address = endpoint.Address;
if (endpoint.Port != 0)
try
{
port = endpoint.Port;
IPAddress[] addresses = IPAddress.TryParse(host, out IPAddress? literal) ? [literal] : await Dns.GetHostAddressesAsync(host, ct);
IPAddress? chosen = addresses.FirstOrDefault(a => allow?.Invoke(a) ?? true);
if (chosen is not null)
{
return chosen;
}
_log.Warn("no address of the target is allowed", ("task", spec.Id.Value), ("host", host));
}
catch (System.Net.Sockets.SocketException ex)
{
_log.Warn("target did not resolve", ("task", spec.Id.Value), ("host", host), ("err", ex.Message));
}
return true;
await Task.Delay(s_resolveRetry, _time, ct);
}
return IPAddress.TryParse(target, out address!);
}
/// <summary>目标任务的行没有拨号方一侧可以合并:积木交出的就是完整的行,Agent 自己盖章(目标、时钟锚点)并放进发件箱。</summary>
@@ -15,7 +15,7 @@ namespace MoShiro.Agent;
/// </summary>
public sealed class TaskManager
{
// 线路任务 = 有监听方积木、并且不是只用于按需测试的 Kind(udp_train、tcp_bulk 会占满链路);目标任务 = 可以由 Agent 自己运行的 Kind
// 线路任务 = 有监听方积木、并且不是只用于按需测试的 Kind(quic_stream、tcp_transfer 会占满链路);目标任务 = 可以由 Agent 自己运行的 Kind
private static readonly HashSet<string> s_lineKinds = [.. BlockCatalog.All.Where(p => p.Listener is not null && !p.OnDemandOnly).Select(p => p.Kind)];
private static readonly HashSet<string> s_targetKinds = [.. BlockCatalog.All.Where(p => p.RunsAsTarget).Select(p => p.Kind)];
@@ -151,7 +151,7 @@ public sealed class TaskManager
}
}
_targets.Reconcile(targets);
_targets.Reconcile(targets, guard.AddressAllowed);
// 参数没有变化的任务沿用原来的流和积木,数据面上的会话(重放窗口、记录)不被打断
var retired = new List<LineEntry>();
@@ -206,7 +206,7 @@ public sealed class TestRunner
}
// 单向数据流的发送端自己运行到结束(或失败);其他积木是反应式的,等够时长即可
if (phase.Kind == "udp_train")
if (phase.Kind == "quic_stream")
{
try
{
+1 -1
View File
@@ -55,7 +55,7 @@ public sealed class FleetLinkOptions
/// <summary>服务端至少要是哪个版本(每次握手重新取);空表示不要求。不够就关掉这条连接,等 <see cref="VersionRetry"/> 再握手,服务端可能正在更新。</summary>
public Func<string?>? MinServerVersion { get; init; }
/// <summary>框架之外的能力,例如 <c>moshiro.v1</c> 与 <c>moshiro.kinds:udp_echo,tcp_echo</c>。</summary>
/// <summary>框架之外的能力,例如 <c>moshiro.v1</c> 与 <c>moshiro.kinds:quic_ping,tcp_ping</c>。</summary>
public IReadOnlyList<string> Capabilities { get; init; } = [];
/// <summary>附加到 <c>fleet.hello</c> 的产品元数据。</summary>
@@ -76,7 +76,7 @@ public abstract class BlockBase : IBlock
}
}
/// <summary>读这个任务的自适应频率参数(<c>udp_echo</c> 和 <c>tcp_echo</c> 共用)。</summary>
/// <summary>读这个任务的自适应频率参数(<c>quic_ping</c> 和 <c>tcp_ping</c> 共用)。</summary>
protected AdaptiveRate? CreateAdaptiveRate()
{
if (!Spec.Bool("adaptive"))
@@ -26,13 +26,14 @@ public static class BlockCatalog
{
public static readonly IReadOnlyList<BlockPair> All =
[
new("udp_echo", (e, s) => new DatagramEchoDialer(e, s), (e, s) => new DatagramEchoListener(e, s)),
new("tcp_echo", (e, s) => new TcpEchoDialer(e, s), (e, s) => new TcpEchoListener(e, s)),
new("udp_train", (e, s) => new TrainDialer(e, s), (e, s) => new TrainListener(e, s), OnDemandOnly: true, Directions: ["rev"]),
new("tcp_bulk", (e, s) => new TcpBulkDialer(e, s), (e, s) => new TcpBulkListener(e, s), OnDemandOnly: true, Directions: ["fwd", "rev"]),
new("connect", (e, s) => new ConnectDialer(e, s), (e, s) => new IdleListener(e, s), RunsAsTarget: true),
new("hold", (e, s) => new HoldDialer(e, s), (e, s) => new TcpEchoListener(e, s, record: false)),
new("icmp_echo", (e, s) => new IcmpEchoSolo(e, s), null, RunsAsTarget: true),
new("quic_ping", (e, s) => new DatagramEchoDialer(e, s), (e, s) => new DatagramEchoListener(e, s)),
new("tcp_ping", (e, s) => new TcpEchoDialer(e, s), (e, s) => new TcpEchoListener(e, s)),
new("quic_stream", (e, s) => new TrainDialer(e, s), (e, s) => new TrainListener(e, s), OnDemandOnly: true, Directions: ["rev"]),
new("tcp_transfer", (e, s) => new TcpBulkDialer(e, s), (e, s) => new TcpBulkListener(e, s), OnDemandOnly: true, Directions: ["fwd", "rev"]),
new("tcp_connect", (e, s) => new ConnectDialer(e, s), (e, s) => new IdleListener(e, s), RunsAsTarget: true),
new("tls_hold", (e, s) => new HoldDialer(e, s), (e, s) => new TcpEchoListener(e, s, record: false)),
new("http_request", (e, s) => new HttpDialer(e, s), null, RunsAsTarget: true),
new("icmp_ping", (e, s) => new IcmpEchoSolo(e, s), null, RunsAsTarget: true),
];
public static BlockPair? Find(string kind) => All.FirstOrDefault(p => p.Kind == kind);
@@ -0,0 +1,382 @@
using System.Net;
using System.Net.Http;
using System.Net.Quic;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Authentication;
using Fleet.Core;
using MoShiro.Protocol;
namespace MoShiro.Measurement;
/// <summary>http 请求的结果,编码与 Backend 的 <c>HttpOutcome</c> 一致。</summary>
internal static class HttpOutcomes
{
public const int Ok = 0;
public const int Timeout = 1;
public const int Reset = 2;
public const int Tls = 3;
public const int Other = 4;
public const int Dns = 5;
public const int BadStatus = 6;
public const int Unsupported = 7;
public const int NotAllowed = 8;
}
/// <summary>解析出的地址都不在本地允许的网段内。</summary>
internal sealed class TargetNotAllowedException : Exception
{
public TargetNotAllowedException(string host)
: base("no address of " + host + " is allowed by allowed_target_cidrs")
{
}
}
/// <summary>
/// 单个 Agent 去请求一个 URL(HTTP/1.1、HTTP/2 或 HTTP/3,不回退),记录 DNS、TCP 建连、首字节、总耗时和状态码。
/// 每次请求新建连接,所以每次都包含完整的建连过程;频率按失败率自适应:目标一直应答就降到证明得了成功率的最低频率,
/// 出现失败升到最高频率,全部失败时指数退避。
/// </summary>
public sealed class HttpDialer : BlockBase
{
private readonly DriverLog _log;
private readonly int _attempt;
private readonly int _ts;
private readonly int _w;
private readonly int _dns;
private readonly int _tcp;
private readonly int _ttfb;
private readonly int _total;
private readonly int _status;
private readonly int _proto;
private readonly int _outcome;
private readonly HashSet<int> _loggedOutcomes = [];
private long _next;
public HttpDialer(BlockEnv env, BlockSpec spec)
: base(env, spec)
{
_log = new DriverLog(spec.Kind.FindStream("req")!, Side.Dialer, 1024);
_attempt = _log.Store.ColumnIndex("attempt");
_ts = _log.Store.ColumnIndex("ts");
_w = _log.Store.ColumnIndex("w");
_dns = _log.Store.ColumnIndex("dns_us");
_tcp = _log.Store.ColumnIndex("tcp_us");
_ttfb = _log.Store.ColumnIndex("ttfb_us");
_total = _log.Store.ColumnIndex("total_us");
_status = _log.Store.ColumnIndex("status");
_proto = _log.Store.ColumnIndex("proto");
_outcome = _log.Store.ColumnIndex("outcome");
}
public override long Produced => _log.Count;
public override async Task RunAsync(CancellationToken ct)
{
if (!TargetHost.TryParse(Env.Target, out _, out _, out Uri? url) || url is null)
{
Env.Log.Warn("http target is not a URL", ("task", Spec.Scope.Task?.Value));
await WaitUntilCancelledAsync(ct);
return;
}
AdaptiveRate? rate = CreateRequestRate();
try
{
if (rate is not null)
{
await PaceAsync(rate, async () =>
{
int weight = rate.Weight;
long sent = Env.Clock.NowUs();
rate.OnSend(sent);
bool ok = await RequestAsync(url, weight, ct);
if (ok)
{
long now = Env.Clock.NowUs();
rate.OnReply(sent, now, (now - sent) / 1000.0);
}
}, ct);
}
else
{
TimeSpan interval = TimeSpan.FromSeconds(60.0 / Spec.Int("per_minute"));
while (!ct.IsCancellationRequested)
{
long start = Env.Clock.NowUs();
await RequestAsync(url, 1, ct);
TimeSpan wait = interval - TimeSpan.FromMicroseconds(Env.Clock.NowUs() - start);
if (wait > TimeSpan.Zero)
{
await Task.Delay(wait, Env.Time, ct);
}
}
}
}
catch (OperationCanceledException)
{
// 积木被停止
}
}
public override int Flush(FlushMode mode, long peerProduced = 0)
{
RawStream? stream = _log.Take(null, all: true);
if (stream is null)
{
return 0;
}
Ship(stream, "ts");
return 1;
}
/// <summary>
/// 频率以「次/分钟」声明,换算成 Hz 交给 <see cref="AdaptiveRate"/>。请求间隔以秒到分钟计,所以窗口按分钟级放宽,
/// 并且不用 RTT 分位数的精度保护(样本太少,估计不出来):平稳时只由成功率的上界决定频率。
/// </summary>
private AdaptiveRate? CreateRequestRate()
{
if (!Spec.Bool("adaptive"))
{
return null;
}
double max = Math.Max(Spec.Int("rate_max_per_min"), Spec.Int("rate_min_per_min")) / 60.0;
return new AdaptiveRate(new AdaptiveRateOptions
{
MinHz = Spec.Int("rate_min_per_min") / 60.0,
MaxHz = max,
TauPct = Spec.Number("tau_pct"),
RttGuard = false,
CertifySeconds = 900,
QuietCertifySeconds = 1200,
GraceSeconds = Math.Ceiling(Spec.Int("timeout_ms") / 1000.0) + 1,
DeadWindowSeconds = 120,
DeadMinSent = 3,
DeadMaxIntervalSeconds = 300,
ActiveLossCount = 2,
Prior = Env.LossPrior,
});
}
/// <summary>发一次请求并记一行;返回这次算不算成功(有应答且状态码合格)。</summary>
private async Task<bool> RequestAsync(Uri url, int weight, CancellationToken ct)
{
string version = Spec.Text("version");
bool verify = Spec.Bool("tls_verify");
TimeSpan timeout = TimeSpan.FromMilliseconds(Spec.Int("timeout_ms"));
var phases = new Phases();
long start = Env.Clock.NowUs();
int outcome = HttpOutcomes.Other;
int status = 0;
int proto = 0;
long? ttfb = null;
long? total = null;
using var timeoutCts = new CancellationTokenSource(timeout, Env.Time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
try
{
if (version == "h3" && !QuicConnection.IsSupported)
{
outcome = HttpOutcomes.Unsupported;
LogFirstFailure(outcome, url, version, null);
}
else
{
if (version == "h3")
{
await ResolveForQuicAsync(url.IdnHost, phases, linked.Token);
}
using HttpMessageInvoker invoker = CreateInvoker(version, verify, phases);
using var request = new HttpRequestMessage(new HttpMethod(Spec.Text("method")), url)
{
Version = version switch { "h1" => HttpVersion.Version11, "h2" => HttpVersion.Version20, _ => HttpVersion.Version30 },
VersionPolicy = HttpVersionPolicy.RequestVersionExact,
};
using HttpResponseMessage response = await invoker.SendAsync(request, linked.Token);
ttfb = Env.Clock.NowUs() - start;
status = (int)response.StatusCode;
proto = response.Version.Major switch { 3 => 3, 2 => 2, _ => 1 };
await ReadBodyAsync(response, (int)Spec.Int("read_kb") * 1024, linked.Token);
total = Env.Clock.NowUs() - start;
long expected = Spec.Int("ok_status");
outcome = (expected == 0 ? status < 400 : status == expected) ? HttpOutcomes.Ok : HttpOutcomes.BadStatus;
}
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
outcome = HttpOutcomes.Timeout;
}
catch (OperationCanceledException)
{
return false;
}
catch (Exception ex) when (ex is HttpRequestException or IOException or SocketException or AuthenticationException or TargetNotAllowedException or QuicException)
{
outcome = Classify(ex);
LogFirstFailure(outcome, url, version, ex);
}
Record(start, weight, phases, ttfb, total, status, proto, outcome);
return outcome == HttpOutcomes.Ok;
}
private HttpMessageInvoker CreateInvoker(string version, bool verify, Phases phases)
{
var handler = new SocketsHttpHandler
{
UseProxy = false,
AllowAutoRedirect = false,
UseCookies = false,
AutomaticDecompression = DecompressionMethods.None,
};
// 设置了 ConnectCallback 的处理器不能用 HTTP/3(运行时用自己的 QUIC 连接,不经过回调):h3 只能靠请求前的解析和网段检查
if (version != "h3")
{
handler.ConnectCallback = (context, token) => ConnectAsync(context.DnsEndPoint, phases, token);
}
if (!verify)
{
#pragma warning disable CA5359 // 操作员明确关闭证书校验(tls_verify=false):只测可达性和耗时
handler.SslOptions = new SslClientAuthenticationOptions { RemoteCertificateValidationCallback = (_, _, _, _) => true };
#pragma warning restore CA5359
}
return new HttpMessageInvoker(handler, disposeHandler: true);
}
/// <summary>自己解析和连接:每个地址都要在允许的网段里,DNS 和 TCP 建连的耗时分别记下来。</summary>
private async ValueTask<Stream> ConnectAsync(DnsEndPoint endpoint, Phases phases, CancellationToken ct)
{
IPAddress[] addresses = await ResolveAsync(endpoint.Host, phases, ct);
long start = Env.Clock.NowUs();
Exception? last = null;
foreach (IPAddress address in addresses)
{
var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
try
{
await socket.ConnectAsync(new IPEndPoint(address, endpoint.Port), ct);
phases.TcpUs = Env.Clock.NowUs() - start;
return new NetworkStream(socket, ownsSocket: true);
}
catch (SocketException ex)
{
socket.Dispose();
last = ex;
}
}
throw last ?? new SocketException((int)SocketError.HostNotFound);
}
/// <summary>HTTP/3 由运行时自己建 UDP 连接,没法接管;先解析并检查一遍,再让它去连。</summary>
private async Task ResolveForQuicAsync(string host, Phases phases, CancellationToken ct) => _ = await ResolveAsync(host, phases, ct);
private async Task<IPAddress[]> ResolveAsync(string host, Phases phases, CancellationToken ct)
{
long start = Env.Clock.NowUs();
IPAddress[] all = IPAddress.TryParse(host, out IPAddress? literal) ? [literal] : await Dns.GetHostAddressesAsync(host, ct);
phases.DnsUs = Env.Clock.NowUs() - start;
IPAddress[] allowed = [.. all.Where(a => Env.AllowAddress?.Invoke(a) ?? true)];
return allowed.Length > 0 ? allowed : throw new TargetNotAllowedException(host);
}
private static async Task ReadBodyAsync(HttpResponseMessage response, int limit, CancellationToken ct)
{
if (limit <= 0)
{
return;
}
await using Stream body = await response.Content.ReadAsStreamAsync(ct);
byte[] buffer = new byte[Math.Min(limit, 16 * 1024)];
int read = 0;
while (read < limit)
{
int n = await body.ReadAsync(buffer.AsMemory(0, Math.Min(buffer.Length, limit - read)), ct);
if (n == 0)
{
break;
}
read += n;
}
}
/// <summary>每种失败结果只记第一次(带原因),保留给事后审计,不会随请求频率刷屏。</summary>
private void LogFirstFailure(int outcome, Uri url, string version, Exception? ex)
{
if (!_loggedOutcomes.Add(outcome))
{
return;
}
Env.Log.Warn("http request failed", ("task", Spec.Scope.Task?.Value), ("outcome", outcome), ("version", version), ("host", url.Host),
("quic_supported", version == "h3" ? QuicConnection.IsSupported : null), ("err", ex is null ? "System.Net.Quic is not available" : ex.GetType().Name + ": " + (ex.InnerException?.Message ?? ex.Message)));
}
private static int Classify(Exception ex)
{
for (Exception? e = ex; e is not null; e = e.InnerException)
{
switch (e)
{
case TargetNotAllowedException:
return HttpOutcomes.NotAllowed;
case AuthenticationException:
return HttpOutcomes.Tls;
case SocketException { SocketErrorCode: SocketError.HostNotFound or SocketError.TryAgain or SocketError.NoData }:
return HttpOutcomes.Dns;
case SocketException { SocketErrorCode: SocketError.ConnectionRefused or SocketError.ConnectionReset }:
return HttpOutcomes.Reset;
case HttpRequestException { HttpRequestError: HttpRequestError.NameResolutionError }:
return HttpOutcomes.Dns;
case HttpRequestException { HttpRequestError: HttpRequestError.SecureConnectionError }:
return HttpOutcomes.Tls;
case HttpRequestException { HttpRequestError: HttpRequestError.VersionNegotiationError }:
return HttpOutcomes.Unsupported;
}
}
return HttpOutcomes.Other;
}
private void Record(long startUs, int weight, Phases phases, long? ttfb, long? total, int status, int proto, int outcome)
{
long key = _log.Store.Append();
_log.Store.Set(key, _attempt, _next++);
_log.Store.Set(key, _ts, startUs);
_log.Store.Set(key, _w, Math.Clamp(weight, 1, 65535));
_log.Store.Set(key, _outcome, outcome);
SetIf(key, _dns, phases.DnsUs);
SetIf(key, _tcp, phases.TcpUs);
SetIf(key, _ttfb, ttfb);
SetIf(key, _total, total);
if (status != 0)
{
_log.Store.Set(key, _status, status);
_log.Store.Set(key, _proto, proto);
}
}
private void SetIf(long key, int column, long? value)
{
if (value is { } v)
{
_log.Store.Set(key, column, Math.Clamp(v, 0, uint.MaxValue));
}
}
/// <summary>一次请求里 DNS 和 TCP 建连用了多久(连接回调里填)。</summary>
private sealed class Phases
{
public long? DnsUs { get; set; }
public long? TcpUs { get; set; }
}
}
@@ -219,7 +219,7 @@ public sealed class TrainDialer : BlockBase
[FragmentKeys.SeqTo] = Number(to),
};
Env.Fragments!.Ship(
"udp_train",
"quic_stream",
Spec.Scope,
fragments == 0 ? [pkt, _step.Cut(0, 1, includeKey: false)] : [pkt],
parameters,
@@ -65,6 +65,9 @@ public sealed record AdaptiveRateOptions
public double RttFloorMs { get; init; } = 5;
/// <summary>用 RTT p99 的置信区间限制降频。样本稀少的任务(每分钟几次)估不出区间,关掉后平稳频率只由丢包上界决定。</summary>
public bool RttGuard { get; init; } = true;
/// <summary>先验:时间 → 这个时刻丢包的历史概率(0 到 1);null 表示没有历史。</summary>
public Func<long, double?>? Prior { get; init; }
@@ -285,7 +288,7 @@ public sealed class AdaptiveRate
UpdateGuard(nowUs);
double hz = next switch
{
ProbeState.Quiet => Quantize(Math.Max(QuietHz(QuietWindowSeconds(nowUs)), double.IsNaN(_guardHz) ? _options.MaxHz : _guardHz)),
ProbeState.Quiet => Quantize(Math.Max(QuietHz(QuietWindowSeconds(nowUs)), !_options.RttGuard ? _options.MinHz : double.IsNaN(_guardHz) ? _options.MaxHz : _guardHz)),
ProbeState.Watch or ProbeState.Active => _options.MaxHz,
_ => Math.Max(_options.MinHz / 4, 1.0 / _options.DeadMaxIntervalSeconds),
};
@@ -190,6 +190,8 @@ public sealed class BlockSpec
public bool Bool(string name) => Params.GetBool(name);
public string Text(string name) => Params.GetString(name);
/// <summary>参数或端口变化时调和器重启这个积木(不含流编号:Agent 的流编号每次重建都会重新分配)。</summary>
public string ConfigSignature => KindId + "|" + Port + "|" +
string.Join(',', Params.Values.OrderBy(p => p.Key, StringComparer.Ordinal).Select(p => p.Key + "=" + p.Value.GetRawText()));
@@ -237,6 +239,12 @@ public sealed class BlockEnv
/// <summary>拨号方:对端(Agent,或目标任务的目标地址)。</summary>
public IPAddress? PeerAddress { get; init; }
/// <summary>单个 Agent 任务的目标原文(http 是 URL,其余是主机[:端口]);线路任务为 null。</summary>
public string? Target { get; init; }
/// <summary>单个 Agent 任务:解析出来的地址能不能连(本地 <c>allowed_target_cidrs</c>);null 表示不限制。</summary>
public Func<IPAddress, bool>? AllowAddress { get; init; }
/// <summary>拨号方:对端 TLS 证书的 SPKI 指纹;null 表示不校验证书(目标任务,目标是任意地址)。</summary>
public byte[]? PeerSpkiSha256 { get; init; }
@@ -246,7 +254,7 @@ public sealed class BlockEnv
/// <summary>拨号方:列块出口。</summary>
public IFragmentSink? Fragments { get; init; }
/// <summary>本地硬上限:批量传输(tcp_bulk)发送端的码率(Mbit/s)。批量传输会尽可能占满链路,这是防止把小机器打满的保险。</summary>
/// <summary>本地硬上限:批量传输(tcp_transfer)发送端的码率(Mbit/s)。批量传输会尽可能占满链路,这是防止把小机器打满的保险。</summary>
public double MaxBulkMbps { get; init; } = 100;
/// <summary>本地硬上限:单向数据流接收缓冲的包速率上界。</summary>
@@ -267,6 +275,8 @@ public sealed class BlockEnv
Counters = Counters,
TcpListen = TcpListen,
PeerAddress = PeerAddress,
Target = Target,
AllowAddress = AllowAddress,
PeerSpkiSha256 = PeerSpkiSha256,
KnockKey = KnockKey,
Fragments = Fragments,
@@ -106,22 +106,25 @@ public sealed class BudgetGuard
return Check(new TaskSpec { Id = new TaskId("tsk_phase"), Scope = "line", Kind = kindId, Params = new Dictionary<string, JsonElement>(parameters) }, ref mbps, ref pps);
}
/// <summary>
/// 目标在静态上可不可以接受:IP 字面量按 <c>allowed_target_cidrs</c> 检查;域名(包括 URL 里的)要解析之后才知道,
/// 这里只要求配置过白名单,运行时每次解析出的地址再用 <see cref="AddressAllowed"/> 检查。
/// </summary>
public bool TargetAllowed(string? target)
{
if (target is null)
if (!TargetHost.TryParse(target, out string? host, out _, out _))
{
return false;
}
string host = target;
if (IPEndPoint.TryParse(target, out IPEndPoint? endpoint))
{
host = endpoint.Address.ToString();
}
return IPAddress.TryParse(host, out IPAddress? address) ? AddressAllowed(address) : _cidrs.Count > 0;
}
if (!IPAddress.TryParse(host, out IPAddress? address))
public bool AddressAllowed(IPAddress address)
{
if (address.IsIPv4MappedToIPv6)
{
return false;
address = address.MapToIPv4();
}
foreach ((IPAddress network, int prefix) in _cidrs)
@@ -148,10 +151,11 @@ public sealed class BudgetGuard
private static long EstimatePps(string kindId, ParamValues values) => kindId switch
{
"udp_echo" or "tcp_echo" => Kinds.PeakRateHz(kindId, values) * 2,
"icmp_echo" => values.GetInt("rate_hz") * 2,
"udp_train" => (long)(values.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, values.GetInt("size"))),
"connect" => values.GetInt("per_minute") * 6 / 60,
"quic_ping" or "tcp_ping" => Kinds.PeakRateHz(kindId, values) * 2,
"icmp_ping" => values.GetInt("rate_hz") * 2,
"quic_stream" => (long)(values.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, values.GetInt("size"))),
"tcp_connect" => values.GetInt("per_minute") * 6 / 60,
"http_request" => Math.Max(1, values.GetInt("rate_max_per_min") * 20 / 60),
_ => 1,
};
@@ -109,14 +109,16 @@ internal static unsafe class MsQuicApi
public static void RegistrationClose(nint handle) => s_registrationClose(handle);
public static nint ConfigurationOpen(nint registration, byte[] alpn)
public static nint ConfigurationOpen(nint registration, byte[] alpn, QuicSettings? settings = null)
{
byte[] native = settings is { IsDefault: false } ? settings.ToNative() : [];
unsafe
{
fixed (byte* alpnBytes = alpn)
fixed (byte* settingsBytes = native)
{
var buffer = new QuicBuffer { Length = (uint)alpn.Length, Buffer = (nint)alpnBytes };
uint status = s_configurationOpen(registration, ref buffer, 1, 0, 0, 0, out nint configuration);
uint status = s_configurationOpen(registration, ref buffer, 1, native.Length == 0 ? 0 : (nint)settingsBytes, (uint)native.Length, 0, out nint configuration);
Check(status, "ConfigurationOpen");
return configuration;
}
@@ -156,7 +156,8 @@ internal sealed class MsQuicConnection : IAsyncDisposable, IDatagramConnection
byte[] expectedPeerSpki,
MsQuicDatagramCallback datagramHandler,
INodeLog log,
CancellationToken ct)
CancellationToken ct,
QuicSettings? settings = null)
{
nint registration = 0;
nint configuration = 0;
@@ -165,7 +166,7 @@ internal sealed class MsQuicConnection : IAsyncDisposable, IDatagramConnection
try
{
registration = MsQuicApi.RegistrationOpen("moshiro-dialer");
configuration = MsQuicApi.ConfigurationOpen(registration, QuicAlpn.Value);
configuration = MsQuicApi.ConfigurationOpen(registration, QuicAlpn.Value, settings);
LoadClientCredentials(configuration);
var endpoint = new IPEndPoint(address.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, localPort);
SocketAddress serialized = endpoint.Serialize();
@@ -21,6 +21,7 @@ internal sealed class MsQuicListener : IAsyncDisposable
private readonly INodeLog _log;
private readonly Action<IPEndPoint>? _peerAddressChanged;
private readonly X509Certificate2 _certificate;
private readonly QuicSettings? _settings;
private readonly GCHandle _self;
private readonly ConcurrentDictionary<nint, MsQuicConnection> _connections = new();
private readonly List<nint> _listeners = [];
@@ -34,8 +35,10 @@ internal sealed class MsQuicListener : IAsyncDisposable
X509Certificate2 certificate,
MsQuicDatagramCallback datagramHandler,
INodeLog log,
Action<IPEndPoint>? peerAddressChanged)
Action<IPEndPoint>? peerAddressChanged,
QuicSettings? settings = null)
{
_settings = settings;
// MsQuic opens dual-mode wildcard sockets, but filters incoming connections using
// the address family supplied to ListenerStart. An IPv6Any listener alone rejects
// IPv4-mapped connections with TLS alert 120 (no_application_protocol).
@@ -62,7 +65,7 @@ internal sealed class MsQuicListener : IAsyncDisposable
_registration = MsQuicApi.RegistrationOpen("moshiro-agent");
try
{
_configuration = MsQuicApi.ConfigurationOpen(_registration, QuicAlpn.Value);
_configuration = MsQuicApi.ConfigurationOpen(_registration, QuicAlpn.Value, _settings);
MsQuicConnection.LoadServerCredentials(_configuration, _certificate);
var bound = new List<int>(_ports.Count * _bindAddresses.Count);
foreach (int port in _ports)
@@ -21,6 +21,7 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable
private readonly DataSession? _clientSession;
private readonly IPEndPoint? _remote;
private readonly byte[]? _peerSpki;
private readonly QuicSettings? _settings;
private int _previousSourcePort;
private readonly TaskCompletionSource _clientReady = new(TaskCreationOptions.RunContinuationsAsynchronously);
private MsQuicListener? _listener;
@@ -28,8 +29,9 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable
private int _disposed;
/// <summary>Constructs an Agent-side QUIC listener.</summary>
public QuicDatagramTransport(IDataSessions sessions, IAdmission? admission, MonoClock clock, DataPlaneCounters counters, INodeLog log, IPAddress bind, IReadOnlyList<int> ports, X509Certificate2 serverCertificate)
public QuicDatagramTransport(IDataSessions sessions, IAdmission? admission, MonoClock clock, DataPlaneCounters counters, INodeLog log, IPAddress bind, IReadOnlyList<int> ports, X509Certificate2 serverCertificate, QuicSettings? settings = null)
{
_settings = settings;
_router = new MeasurementDatagramRouter(sessions, admission, clock, counters, log);
_log = log;
_onDatagram = _router.Dispatch;
@@ -39,8 +41,9 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable
}
/// <summary>Constructs a 拨号方-side QUIC dialer for one Agent session.</summary>
public QuicDatagramTransport(DataSession session, MonoClock clock, DataPlaneCounters counters, INodeLog log, IPEndPoint remote, byte[] peerSpki)
public QuicDatagramTransport(DataSession session, MonoClock clock, DataPlaneCounters counters, INodeLog log, IPEndPoint remote, byte[] peerSpki, QuicSettings? settings = null)
{
_settings = settings;
_router = new MeasurementDatagramRouter(new SingleSession(session), null, clock, counters, log);
_log = log;
_onDatagram = _router.Dispatch;
@@ -73,7 +76,7 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable
throw new InvalidOperationException("QUIC listener is already bound.");
}
_listener = new MsQuicListener(_bind, _ports, _serverCertificate!, _onDatagram, _log, null);
_listener = new MsQuicListener(_bind, _ports, _serverCertificate!, _onDatagram, _log, null, _settings);
_listener.Start();
BoundPorts = _listener.BoundPorts;
}
@@ -92,7 +95,7 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
await CloseClientAsync().ConfigureAwait(false);
int sourcePort = SelectSourcePort(_remote.AddressFamily, _previousSourcePort);
MsQuicConnection client = await MsQuicConnection.ConnectAsync(_remote.Address, _remote.Port, sourcePort, _peerSpki, _onDatagram, _log, ct).ConfigureAwait(false);
MsQuicConnection client = await MsQuicConnection.ConnectAsync(_remote.Address, _remote.Port, sourcePort, _peerSpki, _onDatagram, _log, ct, _settings).ConfigureAwait(false);
_client = client;
_clientSession.BindDatagramConnection(client);
BoundPorts = client.LocalEndPoint is { } local ? [local.Port] : [sourcePort];
@@ -0,0 +1,110 @@
using System.Buffers.Binary;
namespace MoShiro.Measurement;
/// <summary>
/// 数据通道 QUIC 连接的传输参数(<c>agent.json</c> 的 <c>quic</c> 段);null 的项用 MsQuic 默认值。
/// 一条 QUIC 连接由这个 Agent 与对端之间的所有任务共用,所以这些参数属于节点,不属于某个任务。
/// 监听方用它接受连接,拨号方用它发起连接,两边各自生效。
/// </summary>
public sealed record QuicSettings
{
/// <summary>MsQuic 默认的最小、最大 MTU 是 1248 和 1500(IP 包大小,含 IP 和 UDP 头)。</summary>
public const int MinAllowedMtu = 1200;
public const int MaxAllowedMtu = 65535;
/// <summary>路径 MTU 探测的起点;链路的真实 MTU 更小时探测会往下收缩,更大时往上探测到 <see cref="MaxMtu"/>。</summary>
public int? MinMtu { get; init; }
/// <summary>路径 MTU 探测的上限。设成链路的真实 MTU 可以省掉探测,设小可以避开会丢大包的路径。</summary>
public int? MaxMtu { get; init; }
public bool? Pacing { get; init; }
public int? IdleTimeoutMs { get; init; }
public int? KeepAliveMs { get; init; }
public bool IsDefault => MinMtu is null && MaxMtu is null && Pacing is null && IdleTimeoutMs is null && KeepAliveMs is null;
/// <summary>参数是否合法;不合法返回原因。</summary>
public string? Validate()
{
if (MinMtu is { } min && min is < MinAllowedMtu or > MaxAllowedMtu)
{
return $"min_mtu must be {MinAllowedMtu}..{MaxAllowedMtu}";
}
if (MaxMtu is { } max && max is < MinAllowedMtu or > MaxAllowedMtu)
{
return $"max_mtu must be {MinAllowedMtu}..{MaxAllowedMtu}";
}
if (MinMtu is { } lo && MaxMtu is { } hi && lo > hi)
{
return "min_mtu must not exceed max_mtu";
}
if (IdleTimeoutMs is < 0)
{
return "idle_timeout_ms must not be negative";
}
return KeepAliveMs is < 0 ? "keep_alive_ms must not be negative" : null;
}
// QUIC_SETTINGS(msquic.h)的布局:IsSetFlags(u64) 后面依次是 u64、u32、u16 各一组字段,再是位域字节。
// 这里只写用到的几项,其余字段保持 0 且不置位。长度只算到 MtuDiscoveryMissingProbeCount,更靠后的字段 MsQuic 不会读。
private const int Size = 112;
private const int IdleTimeoutOffset = 24;
private const int KeepAliveOffset = 88;
private const int MinMtuOffset = 102;
private const int MaxMtuOffset = 104;
private const int BitFieldOffset = 106;
private const int PacingBit = 1 << 1;
private const int IdleTimeoutFlag = 2;
private const int KeepAliveFlag = 16;
private const int MinMtuFlag = 22;
private const int MaxMtuFlag = 23;
private const int PacingFlag = 25;
internal byte[] ToNative()
{
byte[] native = new byte[Size];
ulong isSet = 0;
if (IdleTimeoutMs is { } idle)
{
BinaryPrimitives.WriteUInt64LittleEndian(native.AsSpan(IdleTimeoutOffset), (ulong)idle);
isSet |= 1UL << IdleTimeoutFlag;
}
if (KeepAliveMs is { } keepAlive)
{
BinaryPrimitives.WriteUInt32LittleEndian(native.AsSpan(KeepAliveOffset), (uint)keepAlive);
isSet |= 1UL << KeepAliveFlag;
}
if (MinMtu is { } min)
{
BinaryPrimitives.WriteUInt16LittleEndian(native.AsSpan(MinMtuOffset), (ushort)min);
isSet |= 1UL << MinMtuFlag;
}
if (MaxMtu is { } max)
{
BinaryPrimitives.WriteUInt16LittleEndian(native.AsSpan(MaxMtuOffset), (ushort)max);
isSet |= 1UL << MaxMtuFlag;
}
if (Pacing is { } pacing)
{
native[BitFieldOffset] = (byte)(pacing ? PacingBit : 0);
isSet |= 1UL << PacingFlag;
}
BinaryPrimitives.WriteUInt64LittleEndian(native, isSet);
return native;
}
}
@@ -10,7 +10,7 @@ public static class Kinds
/// <summary>这个任务可能达到的最高探测频率(Hz):自适应时是 <c>rate_max_hz</c>,否则是固定的 <c>rate_hz</c>。预算、限速和准入都按它算。</summary>
public static long PeakRateHz(string kindId, ParamValues values) =>
(kindId is "udp_echo" or "tcp_echo") && values.GetBool("adaptive") ? Math.Max(values.GetInt("rate_max_hz"), values.GetInt("rate_min_hz")) : values.GetInt("rate_hz");
(kindId is "quic_ping" or "tcp_ping") && values.GetBool("adaptive") ? Math.Max(values.GetInt("rate_max_hz"), values.GetInt("rate_min_hz")) : values.GetInt("rate_hz");
private static readonly StreamSpec s_echoMsg = new(
"msg",
@@ -30,8 +30,8 @@ public static class Kinds
],
Union: true);
public static readonly KindSpec UdpEcho = new(
"udp_echo",
public static readonly KindSpec QuicPing = new(
"quic_ping",
"QUIC Datagram echo",
"Fixed-rate QUIC Datagram echo with four timestamps per packet.",
Scope.Line,
@@ -47,10 +47,10 @@ public static class Kinds
P.Int("port", 7443, 1, 65535),
],
[s_echoMsg],
p => Bandwidth.UdpEcho(PeakRateHz("udp_echo", p), p.GetInt("size")));
p => Bandwidth.UdpEcho(PeakRateHz("quic_ping", p), p.GetInt("size")));
public static readonly KindSpec UdpTrain = new(
"udp_train",
public static readonly KindSpec QuicStream = new(
"quic_stream",
"QUIC Datagram train",
"Fixed-rate or laddered one-way QUIC Datagram stream (Agent to 拨号方) for loss and delay variation under load.",
Scope.Line,
@@ -68,8 +68,8 @@ public static class Kinds
],
p => (long)(p.GetNumber("mbps") * 1_000_000));
public static readonly KindSpec TcpEcho = new(
"tcp_echo",
public static readonly KindSpec TcpPing = new(
"tcp_ping",
"TCP echo",
"Small TCP messages echoed with four timestamps, plus periodic kernel tcp_info samples.",
Scope.Line,
@@ -88,10 +88,10 @@ public static class Kinds
P.Bool("tls", false),
],
[s_echoMsg, s_tcpInfo],
p => Bandwidth.TcpEcho(PeakRateHz("tcp_echo", p), p.GetInt("max")));
p => Bandwidth.TcpEcho(PeakRateHz("tcp_ping", p), p.GetInt("max")));
public static readonly KindSpec TcpBulk = new(
"tcp_bulk",
public static readonly KindSpec TcpTransfer = new(
"tcp_transfer",
"TCP bulk",
"Bulk TCP transfer on several streams with periodic byte counters and tcp_info.",
Scope.Line,
@@ -110,8 +110,8 @@ public static class Kinds
],
_ => 0);
public static readonly KindSpec Connect = new(
"connect",
public static readonly KindSpec TcpConnect = new(
"tcp_connect",
"Connect",
"Repeated TCP and TLS connection establishment with per-phase timings and outcome.",
Scope.Line | Scope.Target,
@@ -129,8 +129,8 @@ public static class Kinds
],
p => p.GetInt("per_minute") * 4000 / 60);
public static readonly KindSpec Hold = new(
"hold",
public static readonly KindSpec TlsHold = new(
"tls_hold",
"Hold",
"A long-lived TLS connection with periodic heartbeats; records resets and lifetime.",
Scope.Line,
@@ -144,8 +144,38 @@ public static class Kinds
],
p => 2000 / Math.Max(1, p.GetInt("heartbeat_s")) * 8);
public static readonly KindSpec IcmpEcho = new(
"icmp_echo",
public static readonly KindSpec HttpRequest = new(
"http_request",
"HTTP request",
"One Agent requests a URL over HTTP/1.1, HTTP/2 or HTTP/3 and records DNS, TCP, time to first byte, total time and status. Target is the URL.",
Scope.Target,
new BlockSet("http", "fixed", "fixed", "request", []),
[
P.Enum("version", "h2", ["h1", "h2", "h3"], "HTTP version; the request fails instead of falling back."),
P.Enum("method", "GET", ["GET", "HEAD"]),
P.Bool("adaptive", true, "Adapt the request rate to the failure rate: low while the target answers, up to rate_max_per_min when it fails, exponential back-off while it is unreachable."),
P.Int("per_minute", 6, 1, 120, "/min", "Fixed rate, used when adaptive is false."),
P.Int("rate_min_per_min", 1, 1, 120, "/min"),
P.Int("rate_max_per_min", 12, 1, 120, "/min", "Highest rate, and the reference grid for sample weights."),
P.Number("tau_pct", 5, 0.1, 50, "%", "Failure rate a quiet target has to be able to prove below, at 95% confidence."),
P.Int("timeout_ms", 10000, 500, 60000, "ms"),
P.Int("read_kb", 16, 0, 1024, "KiB", "Body bytes to read before the request counts as finished (0: headers only)."),
P.Int("ok_status", 0, 0, 599, null, "Status that counts as success; 0 means any status below 400."),
P.Bool("tls_verify", true),
],
[
new StreamSpec(
"req",
[
Col.Seq("attempt"), Col.TsDialer("ts"), Col.U16("w"),
Col.U32("dns_us", nullable: true), Col.U32("tcp_us", nullable: true), Col.U32("ttfb_us", nullable: true), Col.U32("total_us", nullable: true),
Col.U16("status", nullable: true), Col.U8("proto", nullable: true), Col.U8("outcome"),
]),
],
p => p.GetInt("rate_max_per_min") * (2000 + p.GetInt("read_kb") * 1024) * 8 / 60);
public static readonly KindSpec IcmpPing = new(
"icmp_ping",
"ICMP echo",
"拨号方 or Agent side ICMP echo to a target address.",
Scope.Target,
@@ -159,7 +189,7 @@ public static class Kinds
],
p => Bandwidth.UdpEcho(p.GetInt("rate_hz"), p.GetInt("size")));
public static readonly IReadOnlyList<KindSpec> All = [UdpEcho, UdpTrain, TcpEcho, TcpBulk, Connect, Hold, IcmpEcho];
public static readonly IReadOnlyList<KindSpec> All = [QuicPing, QuicStream, TcpPing, TcpTransfer, TcpConnect, TlsHold, HttpRequest, IcmpPing];
public static KindSpec? Find(string id)
{
@@ -488,7 +488,7 @@ public sealed class ControlHealth
/// <summary>任务层:测量自己使用的链路。TCP 类积木自带套接字和重拨,QUIC 数据通道由拨号方单独维护。</summary>
public sealed class TaskHealth
{
/// <summary>正在运行的 TCP 类线路积木数(tcp_echo、connect、hold、tcp_bulk)。</summary>
/// <summary>正在运行的 TCP 类线路积木数(tcp_ping、connect、hold、tcp_transfer)。</summary>
[JsonPropertyName("tcp_blocks")]
public int TcpBlocks { get; set; }
@@ -496,7 +496,7 @@ public sealed class TaskHealth
public QuicHealth Quic { get; set; } = new();
}
/// <summary>QUIC 数据通道(udp_echo、udp_train 使用)的状态、握手统计和传输层统计。</summary>
/// <summary>QUIC 数据通道(quic_ping、quic_stream 使用)的状态、握手统计和传输层统计。</summary>
public sealed class QuicHealth
{
[JsonPropertyName("state")]
@@ -0,0 +1,104 @@
using System.Globalization;
using System.Net;
namespace MoShiro.Protocol;
/// <summary>
/// 单个 Agent 任务的目标:<c>http</c> 是 <c>http(s)://主机[:端口][/路径]</c>,其余 Kind 是 <c>主机[:端口]</c>(IP 或域名,IPv6 带端口时用方括号)。
/// Backend 只检查格式,不做 DNS 解析;解析在 Agent 上进行,每个解析出的地址都要在 <c>allowed_target_cidrs</c> 里。
/// </summary>
public static class TargetHost
{
public const string HttpKind = "http_request";
/// <summary>解析目标;<paramref name="port"/> 为 0 表示没有写端口,<paramref name="url"/> 只在 http(s) URL 时有值。</summary>
public static bool TryParse(string? target, out string host, out int port, out Uri? url)
{
host = "";
port = 0;
url = null;
if (string.IsNullOrWhiteSpace(target) || target.Length > 2048 || target.Any(char.IsWhiteSpace))
{
return false;
}
if (target.Contains("://", StringComparison.Ordinal))
{
if (!Uri.TryCreate(target, UriKind.Absolute, out Uri? uri) || uri.Scheme is not ("http" or "https") || uri.UserInfo.Length > 0 || !ValidHost(uri.IdnHost))
{
return false;
}
url = uri;
host = uri.IdnHost.Trim('[', ']');
port = uri.IsDefaultPort ? 0 : uri.Port;
return true;
}
// IPAddress.TryParse 会把「[v6]:端口」的端口丢掉,带方括号的先按端点解析
if (!target.StartsWith('[') && IPAddress.TryParse(target, out IPAddress? ip))
{
host = ip.ToString();
return true;
}
if (IPEndPoint.TryParse(target, out IPEndPoint? endpoint))
{
if (endpoint.Port is 0)
{
return false;
}
host = endpoint.Address.ToString();
port = endpoint.Port;
return true;
}
int colon = target.LastIndexOf(':');
string name = colon < 0 ? target : target[..colon];
if (colon >= 0 && (!int.TryParse(target.AsSpan(colon + 1), NumberStyles.None, CultureInfo.InvariantCulture, out port) || port is < 1 or > 65535))
{
return false;
}
if (!ValidHost(name))
{
return false;
}
host = name.ToLowerInvariant();
return true;
}
/// <summary>按 Kind 检查并规范化:<c>http</c> 必须是 http(s) URL,其余 Kind 必须是主机[:端口];不合格返回 null。</summary>
public static string? Normalize(string kindId, string? target)
{
if (!TryParse(target, out string host, out int port, out Uri? url) || (url is not null) != (kindId == HttpKind))
{
return null;
}
if (url is not null)
{
return url.AbsoluteUri;
}
string shown = IPAddress.TryParse(host, out IPAddress? ip) && ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 && port != 0 ? "[" + host + "]" : host;
return port == 0 ? host : shown + ":" + port.ToString(CultureInfo.InvariantCulture);
}
private static bool ValidHost(string name)
{
if (IPAddress.TryParse(name.Trim('[', ']'), out _))
{
return true;
}
if (name.Length is 0 or > 253 || name[^1] == '-' || name[0] is '-' or '.')
{
return false;
}
return name.TrimEnd('.').Split('.').All(label => label.Length is > 0 and <= 63 && label.All(c => char.IsAsciiLetterOrDigit(c) || c == '-'));
}
}
@@ -8,7 +8,7 @@ public enum MeasurementDatagramType : byte
EchoRequest = 1,
EchoReply = 2,
/// <summary>单向数据(udp_train),以及 拨号方 为了保持 NAT 映射和让 Agent 学到源地址而发的探活包。</summary>
/// <summary>单向数据(quic_stream),以及 拨号方 为了保持 NAT 映射和让 Agent 学到源地址而发的探活包。</summary>
Data = 3,
}
@@ -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));
}