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
{