agent: unify listening and dialing with directed mutual measurements
Replace Probe with Agent across node runtime, Fleet contracts, backend state, API and storage. Report incoming sessions and outgoing link health through each Agent uplink, and retain directed link controls and retry. Update protocol fixtures and end-to-end coverage for the unified model; accept requested cancellation during the Fleet timeout test cleanup.
This commit is contained in:
1 parent
d5ebf29e57
commit
ad5efc62ff
206 files changed
+4202
-4964
No files matched your search
@@ -3,7 +3,7 @@ using MoShiro.Protocol;
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>
|
||||
/// 一种采集方法(Kind)的两个角色:拨号方(Probe)和监听方(Agent),以及它能不能作为目标任务(Agent 自己探测一个地址,没有对端)运行。
|
||||
/// 一种采集方法(Kind)的两个角色:拨号方和监听方,以及它能不能作为目标任务(节点自己探测一个地址,没有对端)运行。
|
||||
/// 积木只按机制命名、只产生原始列,不知道上层的方案或协议。
|
||||
/// </summary>
|
||||
public sealed record BlockPair(
|
||||
@@ -19,7 +19,7 @@ public sealed record BlockPair(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 积木目录:新增一种采集方法 = 在 <see cref="Kinds"/> 里加一条声明,在这里加一行,实现它的两个角色。Probe 和 Agent 同时获得。
|
||||
/// 积木目录:新增一种采集方法 = 在 <see cref="Kinds"/> 里加一条声明,在这里加一行,实现它的两个角色。所有节点同时获得。
|
||||
/// hello 里上报的 capabilities、本地预算检查的支持列表都从这里推出来,不再手写。
|
||||
/// </summary>
|
||||
public static class BlockCatalog
|
||||
@@ -37,9 +37,12 @@ public static class BlockCatalog
|
||||
|
||||
public static BlockPair? Find(string kind) => All.FirstOrDefault(p => p.Kind == kind);
|
||||
|
||||
/// <summary>这个构建的 Agent 能运行的 Kind(监听方,或目标任务)。</summary>
|
||||
/// <summary>不监听的节点能运行的 Kind:只有目标任务(节点自己探测一个地址)。</summary>
|
||||
public static IReadOnlyList<string> TargetKinds { get; } = [.. All.Where(p => p.RunsAsTarget).Select(p => p.Kind)];
|
||||
|
||||
/// <summary>监听的节点能运行的 Kind(监听方,或目标任务)。</summary>
|
||||
public static IReadOnlyList<string> AgentKinds { get; } = [.. All.Where(p => p.Listener is not null || p.RunsAsTarget).Select(p => p.Kind)];
|
||||
|
||||
/// <summary>这个构建的 Probe 能运行的 Kind(有监听方可以配对的拨号方)。</summary>
|
||||
public static IReadOnlyList<string> ProbeKinds { get; } = [.. All.Where(p => p.Listener is not null).Select(p => p.Kind)];
|
||||
/// <summary>这个构建能作为拨号方运行的 Kind(有监听方可以配对的拨号方)。</summary>
|
||||
public static IReadOnlyList<string> DialerKinds { get; } = [.. All.Where(p => p.Listener is not null).Select(p => p.Kind)];
|
||||
}
|
||||
@@ -56,8 +56,8 @@ internal static class BulkIo
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量传输,拨号方(Probe):开 <c>streams</c> 条 TCP 连接(可选 TLS),按 <c>direction</c> 发送(fwd)或接收(rev),持续 <c>duration_s</c>。
|
||||
/// 记录每个采样间隔每条连接的<b>累计字节数</b>(<c>bytes</c> 流,Probe 的时钟),Backend 据此求每个间隔的吞吐;
|
||||
/// 批量传输,拨号方:开 <c>streams</c> 条 TCP 连接(可选 TLS),按 <c>direction</c> 发送(fwd)或接收(rev),持续 <c>duration_s</c>。
|
||||
/// 记录每个采样间隔每条连接的<b>累计字节数</b>(<c>bytes</c> 流,拨号方 的时钟),Backend 据此求每个间隔的吞吐;
|
||||
/// 同时采样第一条连接自己这一侧的内核 <c>tcp_info</c>(多条连接的累计计数器不能混在同一个序列里求差)。
|
||||
/// 发送速率不超过本地上限 <see cref="BlockEnv.MaxBulkMbps"/>。
|
||||
/// </summary>
|
||||
@@ -76,8 +76,8 @@ public sealed class TcpBulkDialer : BlockBase
|
||||
: base(env, spec)
|
||||
{
|
||||
long expectedRows = spec.Int("streams") * spec.Int("duration_s") * Math.Max(1, 1000 / Math.Max(1, spec.Int("sample_ms")));
|
||||
_log = new DriverLog(spec.Kind.FindStream("bytes")!, Side.Probe, (int)Math.Clamp(expectedRows + 256, 1024, 262_144));
|
||||
_info = new TcpInfoRecorder(spec.Kind.FindStream("tcp_info")!, Side.Probe, env.Clock);
|
||||
_log = new DriverLog(spec.Kind.FindStream("bytes")!, Side.Dialer, (int)Math.Clamp(expectedRows + 256, 1024, 262_144));
|
||||
_info = new TcpInfoRecorder(spec.Kind.FindStream("tcp_info")!, Side.Dialer, env.Clock);
|
||||
_ts = _log.Store.ColumnIndex("ts");
|
||||
_conn = _log.Store.ColumnIndex("conn");
|
||||
_bytes = _log.Store.ColumnIndex("bytes");
|
||||
@@ -211,7 +211,7 @@ public sealed class TcpBulkDialer : BlockBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量传输,监听方(Agent):接受连接,<c>rev</c> 时向 Probe 发送(受本地码率上限约束),<c>fwd</c> 时接收并丢弃;
|
||||
/// 批量传输,监听方:接受连接,<c>rev</c> 时向 拨号方 发送(受本地码率上限约束),<c>fwd</c> 时接收并丢弃;
|
||||
/// 采样第一条连接自己这一侧的内核 <c>tcp_info</c>(Agent 的重传反映下行方向,代理场景里大流量走这个方向)。
|
||||
/// 连接在 <c>duration_s</c> 之后(加几秒余量)或阶段结束时关闭。
|
||||
/// </summary>
|
||||
@@ -224,7 +224,7 @@ public sealed class TcpBulkListener : BlockBase
|
||||
public TcpBulkListener(BlockEnv env, BlockSpec spec)
|
||||
: base(env, spec)
|
||||
{
|
||||
_info = new TcpInfoRecorder(spec.Kind.FindStream("tcp_info")!, Side.Agent, env.Clock);
|
||||
_info = new TcpInfoRecorder(spec.Kind.FindStream("tcp_info")!, Side.Listener, env.Clock);
|
||||
}
|
||||
|
||||
public override RowStore? Records(string streamName) => streamName == "tcp_info" ? _info.Rows : null;
|
||||
|
||||
@@ -34,7 +34,7 @@ internal sealed class EchoRows
|
||||
_graceUs = graceUs;
|
||||
_settleOnReply = settleOnReply;
|
||||
StreamSpec spec = kind.FindStream("msg")!;
|
||||
_log = new DriverLog(spec, Side.Probe, capacity);
|
||||
_log = new DriverLog(spec, Side.Dialer, capacity);
|
||||
_size = _log.Store.ColumnIndex("size");
|
||||
_t1 = _log.Store.ColumnIndex("t1");
|
||||
_t4 = _log.Store.ColumnIndex("t4");
|
||||
@@ -76,7 +76,7 @@ internal sealed class EchoRows
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// UDP 回显,拨号方(Probe):按声明的速率发包,t1 在发出时记录,回复到达时回填 t4。回复丢失的行 t4 缺失(Agent 一侧再补 t2/t3)。
|
||||
/// UDP 回显,拨号方:按声明的速率发包,t1 在发出时记录,回复到达时回填 t4。回复丢失的行 t4 缺失(Agent 一侧再补 t2/t3)。
|
||||
/// </summary>
|
||||
public sealed class DatagramEchoDialer : BlockBase
|
||||
{
|
||||
@@ -163,7 +163,7 @@ public sealed class DatagramEchoDialer : BlockBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>UDP 回显,监听方(Agent):校验后立即回复一个等长的包,记录 t2(到达)和 t3(回复发出),按序号留给合并。</summary>
|
||||
/// <summary>UDP 回显,监听方:校验后立即回复一个等长的包,记录 t2(到达)和 t3(回复发出),按序号留给合并。</summary>
|
||||
public sealed class DatagramEchoListener : BlockBase
|
||||
{
|
||||
private readonly RowStore _records;
|
||||
@@ -171,7 +171,7 @@ public sealed class DatagramEchoListener : BlockBase
|
||||
public DatagramEchoListener(BlockEnv env, BlockSpec spec)
|
||||
: base(env, spec)
|
||||
{
|
||||
_records = new RowStore(spec.Kind.FindStream("msg")!, Side.Agent, 32768);
|
||||
_records = new RowStore(spec.Kind.FindStream("msg")!, Side.Listener, 32768);
|
||||
}
|
||||
|
||||
public override RowStore? Records(string streamName) => streamName == "msg" ? _records : null;
|
||||
@@ -216,7 +216,7 @@ public sealed class TcpEchoDialer : BlockBase
|
||||
: base(env, spec)
|
||||
{
|
||||
_rows = new EchoRows(spec.Kind, 16384, EchoRows.TcpGraceUs, settleOnReply: true);
|
||||
_info = new TcpInfoRecorder(spec.Kind.FindStream("tcp_info")!, Side.Probe, env.Clock);
|
||||
_info = new TcpInfoRecorder(spec.Kind.FindStream("tcp_info")!, Side.Dialer, env.Clock);
|
||||
}
|
||||
|
||||
public override long Produced => _rows.Count;
|
||||
@@ -341,8 +341,8 @@ public sealed class TcpEchoListener : BlockBase
|
||||
public TcpEchoListener(BlockEnv env, BlockSpec spec, bool record = true)
|
||||
: base(env, spec)
|
||||
{
|
||||
_records = record ? new RowStore(spec.Kind.FindStream("msg")!, Side.Agent, 32768) : null;
|
||||
_info = record ? new TcpInfoRecorder(spec.Kind.FindStream("tcp_info")!, Side.Agent, env.Clock) : null;
|
||||
_records = record ? new RowStore(spec.Kind.FindStream("msg")!, Side.Listener, 32768) : null;
|
||||
_info = record ? new TcpInfoRecorder(spec.Kind.FindStream("tcp_info")!, Side.Listener, env.Clock) : null;
|
||||
}
|
||||
|
||||
public override RowStore? Records(string streamName) => streamName switch
|
||||
|
||||
@@ -19,7 +19,7 @@ public sealed class IdleListener : BlockBase
|
||||
|
||||
/// <summary>
|
||||
/// 反复建立连接(TCP,可选 TLS)并记录每个阶段的耗时和结果。结果编码与 Backend 的 <c>ConnectOutcome</c> 一致:
|
||||
/// 0 成功、1 超时、2 被重置或拒绝、3 TLS 告警、4 其他。拨号方是 Probe 时对端是 Agent(按指纹固定证书,建好连接即关闭);
|
||||
/// 0 成功、1 超时、2 被重置或拒绝、3 TLS 告警、4 其他。拨号方连接监听方时(按指纹固定证书,建好连接即关闭);
|
||||
/// 作为目标任务在 Agent 上运行时对端是任意地址(不校验证书,只测握手耗时)。
|
||||
/// </summary>
|
||||
public sealed class ConnectDialer : BlockBase
|
||||
@@ -34,7 +34,7 @@ public sealed class ConnectDialer : BlockBase
|
||||
public ConnectDialer(BlockEnv env, BlockSpec spec)
|
||||
: base(env, spec)
|
||||
{
|
||||
_log = new DriverLog(spec.Kind.FindStream("conn")!, Side.Probe, 1024);
|
||||
_log = new DriverLog(spec.Kind.FindStream("conn")!, Side.Dialer, 1024);
|
||||
_attempt = _log.Store.ColumnIndex("attempt");
|
||||
_ts = _log.Store.ColumnIndex("ts");
|
||||
_tcp = _log.Store.ColumnIndex("tcp_us");
|
||||
@@ -147,7 +147,7 @@ public sealed class HoldDialer : BlockBase
|
||||
public HoldDialer(BlockEnv env, BlockSpec spec)
|
||||
: base(env, spec)
|
||||
{
|
||||
_log = new DriverLog(spec.Kind.FindStream("hold")!, Side.Probe, 1024);
|
||||
_log = new DriverLog(spec.Kind.FindStream("hold")!, Side.Dialer, 1024);
|
||||
_ts = _log.Store.ColumnIndex("ts");
|
||||
_event = _log.Store.ColumnIndex("event");
|
||||
_lifetime = _log.Store.ColumnIndex("lifetime_ms");
|
||||
@@ -293,7 +293,7 @@ public sealed class IcmpEchoSolo : BlockBase
|
||||
public IcmpEchoSolo(BlockEnv env, BlockSpec spec)
|
||||
: base(env, spec)
|
||||
{
|
||||
_log = new DriverLog(spec.Kind.FindStream("msg")!, Side.Probe, 1024);
|
||||
_log = new DriverLog(spec.Kind.FindStream("msg")!, Side.Dialer, 1024);
|
||||
_seq = _log.Store.ColumnIndex("seq");
|
||||
_size = _log.Store.ColumnIndex("size");
|
||||
_t1 = _log.Store.ColumnIndex("t1");
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>
|
||||
/// 单向数据流(UDP train),监听方(Agent,行的主导方):按声明的码率均匀发包,发出的每个包 (seq, size, t_send) 记在按序号的记录里,
|
||||
/// Probe 的接收时刻之后按序号合并(没有到达的就是丢失)。发送在专用线程上完成,不占用线程池。
|
||||
/// 拨号方 的接收时刻之后按序号合并(没有到达的就是丢失)。发送在专用线程上完成,不占用线程池。
|
||||
/// 等不到对端的地址(5 秒内没有任何认证过的包)就以 <see cref="BlockFaultException"/> 结束。
|
||||
/// </summary>
|
||||
public sealed class TrainListener : BlockBase
|
||||
@@ -21,7 +21,7 @@ public sealed class TrainListener : BlockBase
|
||||
: base(env, spec)
|
||||
{
|
||||
double pps = spec.Params.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, spec.Int("size"));
|
||||
_records = new RowStore(spec.Kind.FindStream("pkt")!, Side.Agent, (int)Math.Clamp(pps * spec.Int("duration_s") * 1.25, 1024, 4_000_000));
|
||||
_records = new RowStore(spec.Kind.FindStream("pkt")!, Side.Listener, (int)Math.Clamp(pps * spec.Int("duration_s") * 1.25, 1024, 4_000_000));
|
||||
}
|
||||
|
||||
public override long Produced => Interlocked.Read(ref _sent);
|
||||
@@ -30,7 +30,7 @@ public sealed class TrainListener : BlockBase
|
||||
|
||||
public override async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
// Probe 开始前发来的探活包(Data)要被接受,准入策略才能据此学到它当前的源地址(NAT 映射);它们本身没有内容
|
||||
// 拨号方 开始前发来的探活包(Data)要被接受,准入策略才能据此学到它当前的源地址(NAT 映射);它们本身没有内容
|
||||
Env.Datagrams!.Register(Spec.StreamId, static (in QuicDatagramDelivery _) => { });
|
||||
try
|
||||
{
|
||||
@@ -138,7 +138,7 @@ public sealed class TrainListener : BlockBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 单向数据流,拨号方(Probe,行的跟随方):接收,记录每个序号第一次到达的时刻(按序号索引,热路径不分配);
|
||||
/// 单向数据流,拨号方(行的跟随方):接收,记录每个序号第一次到达的时刻(按序号索引,热路径不分配);
|
||||
/// 序号超过本地包速率上限推算出的范围时忽略。阶段结束时按序号范围切成多个列块交给 Agent,每块不超过 <see cref="ProtocolLimits.MaxRecordsPerChunk"/> 的记录。
|
||||
/// 开始时先发几个探活包,让 Agent 学到我们当前的源地址(NAT 映射)再开始发送。
|
||||
/// </summary>
|
||||
@@ -159,9 +159,9 @@ public sealed class TrainDialer : BlockBase
|
||||
double pps = spec.Params.GetNumber("mbps") * 1_000_000 / 8 / Math.Max(1, spec.Int("size"));
|
||||
long seconds = Math.Max(1, spec.Int("duration_s"));
|
||||
_maxSeq = Math.Max(1024, Math.Min((long)(pps * seconds * 1.25), env.MaxPacketsPerSecond * seconds * 3 / 2));
|
||||
_received = new RowStore(spec.Kind.FindStream("pkt")!, Side.Probe, (int)_maxSeq);
|
||||
_received = new RowStore(spec.Kind.FindStream("pkt")!, Side.Dialer, (int)_maxSeq);
|
||||
_tRecv = _received.ColumnIndex("t_recv");
|
||||
_step = new RowStore(spec.Kind.FindStream("step")!, Side.Probe, 16);
|
||||
_step = new RowStore(spec.Kind.FindStream("step")!, Side.Dialer, 16);
|
||||
}
|
||||
|
||||
public override async Task RunAsync(CancellationToken ct)
|
||||
|
||||
@@ -51,7 +51,7 @@ public interface IDataSessions
|
||||
DataSession? Find(long sessionId);
|
||||
}
|
||||
|
||||
/// <summary>只有一个会话(拨号方:Probe 与某个 Agent 的会话)。</summary>
|
||||
/// <summary>只有一个会话(拨号方:与某个监听方的会话)。</summary>
|
||||
public sealed class SingleSession : IDataSessions
|
||||
{
|
||||
private readonly DataSession _session;
|
||||
@@ -113,7 +113,7 @@ public sealed class DataPlaneCounters
|
||||
public sealed record FragmentScope(TaskId? Task, TestId? Test, int? Phase);
|
||||
|
||||
/// <summary>
|
||||
/// 积木交出一侧的行的地方。Probe:放进发给 Agent 的发件箱(列块);Agent 自己运行的积木(目标任务):直接盖章上传。
|
||||
/// 积木交出一侧的行的地方。拨号方:放进发给 Agent 的发件箱(列块);Agent 自己运行的积木(目标任务):直接盖章上传。
|
||||
/// </summary>
|
||||
public interface IFragmentSink
|
||||
{
|
||||
@@ -139,7 +139,7 @@ public sealed class BlockFaultException : Exception
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 一个运行中的积木角色。同一种采集方法的两端是一对角色(拨号方 = Probe,监听方 = Agent),写在同一个文件里,
|
||||
/// 一个运行中的积木角色。同一种采集方法的两端是一对角色(拨号方和监听方),写在同一个文件里,
|
||||
/// 共用包的构造、校验、行的记录;它们只产生<b>原始列</b>,不做任何统计,也不知道上层的方案或协议。
|
||||
/// </summary>
|
||||
public interface IBlock
|
||||
@@ -192,7 +192,7 @@ public sealed class BlockSpec
|
||||
public string ConfigSignature => KindId + "|" + Port + "|" +
|
||||
string.Join(',', Params.Values.OrderBy(p => p.Key, StringComparer.Ordinal).Select(p => p.Key + "=" + p.Value.GetRawText()));
|
||||
|
||||
/// <summary>参数、端口或流编号变化时重启:Probe 据此判断 Agent 下发的任务有没有变(Agent 重启后流编号会变)。</summary>
|
||||
/// <summary>参数、端口或流编号变化时重启:拨号方 据此判断 Agent 下发的任务有没有变(Agent 重启后流编号会变)。</summary>
|
||||
public string Signature => StreamId + "|" + ConfigSignature;
|
||||
|
||||
/// <summary>校验参数(补全默认值)后构造;Kind 不存在或参数非法返回 null。</summary>
|
||||
@@ -216,7 +216,7 @@ public sealed class BlockEnv
|
||||
|
||||
public required INodeLog Log { get; init; }
|
||||
|
||||
/// <summary>当前的数据面会话(拨号方固定;监听方是 Agent 当前的 Probe 会话,可能没有)。</summary>
|
||||
/// <summary>当前的数据面会话(拨号方固定;监听方是 Agent 当前的 拨号方 会话,可能没有)。</summary>
|
||||
public required Func<DataSession?> Session { get; init; }
|
||||
|
||||
public QuicDatagramTransport? Datagrams { get; init; }
|
||||
@@ -249,7 +249,7 @@ public sealed class BlockEnv
|
||||
|
||||
public TimeProvider Time => Clock.Time;
|
||||
|
||||
/// <summary>同一套环境,但绑定到一个固定的数据面会话(Agent 为每个 Probe 会话各起一组监听方积木)。</summary>
|
||||
/// <summary>同一套环境,但绑定到一个固定的数据面会话(Agent 为每个 拨号方 会话各起一组监听方积木)。</summary>
|
||||
public BlockEnv WithSession(DataSession session) =>
|
||||
new()
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>节点的单调时钟(微秒):t1…t4、t_send、t_recv 都用它;Probe 和 Agent 各自一个,块头里的时钟锚点把它对应到 UTC。</summary>
|
||||
/// <summary>节点的单调时钟(微秒):t1…t4、t_send、t_recv 都用它;每个节点各一个,块头里的时钟锚点把它对应到 UTC。</summary>
|
||||
public sealed class MonoClock
|
||||
{
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
@@ -4,8 +4,8 @@ namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>
|
||||
/// 一侧记录的行:只含这一侧拥有的列(见 <see cref="StreamSpec.ColumnsOf"/>),按连接键(通常是 <c>seq</c>)索引的环形缓冲,
|
||||
/// 每列一个 long 数组,热路径不分配。Agent 用它记录 t2/t3 或发送行,等 Probe 的列块到达后按键合并;
|
||||
/// Probe 用它记录 t1/t4 或 t_recv,到点切成列块交出去。行被后来的键覆盖(环绕)就丢弃,这只会发生在一侧长时间没有交出行的时候。
|
||||
/// 每列一个 long 数组,热路径不分配。Agent 用它记录 t2/t3 或发送行,等 拨号方 的列块到达后按键合并;
|
||||
/// 拨号方 用它记录 t1/t4 或 t_recv,到点切成列块交出去。行被后来的键覆盖(环绕)就丢弃,这只会发生在一侧长时间没有交出行的时候。
|
||||
/// </summary>
|
||||
public sealed class RowStore
|
||||
{
|
||||
@@ -65,7 +65,7 @@ public sealed class RowStore
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 清空所有行。一个新的数据面会话开始时,监听方必须清掉上一个会话留下的记录:Probe 重启之后序号从 0 重新开始,
|
||||
/// 清空所有行。一个新的数据面会话开始时,监听方必须清掉上一个会话留下的记录:拨号方 重启之后序号从 0 重新开始,
|
||||
/// 旧会话里同一个序号的 t2/t3 还留着的话,会被当成新会话里这个包"上行已经到达",真正的上行丢失就被算成了下行丢失。
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>一个运行中的积木:自己的取消令牌和后台任务。Probe 和 Agent 共用。</summary>
|
||||
/// <summary>一个运行中的积木:自己的取消令牌和后台任务。拨号方和监听方共用。</summary>
|
||||
public sealed class RunningBlock : IDisposable
|
||||
{
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
|
||||
@@ -5,11 +5,11 @@ using MoShiro.Protocol;
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>Agent 单调时钟(微秒)与 UTC(毫秒)的对应关系:块头里的时钟锚点。</summary>
|
||||
public readonly record struct ClockAnchor(long AgentMonoUs, long UtcMs);
|
||||
public readonly record struct ClockAnchor(long ListenerMonoUs, long UtcMs);
|
||||
|
||||
/// <summary>
|
||||
/// 给合并好的流盖章:生成完整的块头(任务/测试、参数快照、端口、时钟锚点、起止时间)。
|
||||
/// 起止时间由数据本身的时间戳换算得出,而不是取收到列块的时刻,所以不受 Probe 交付延迟的影响。
|
||||
/// 起止时间由数据本身的时间戳换算得出,而不是取收到列块的时刻,所以不受拨号方交付延迟的影响。
|
||||
/// </summary>
|
||||
public static class ChunkFinalizer
|
||||
{
|
||||
@@ -26,7 +26,7 @@ public static class ChunkFinalizer
|
||||
ChunkId id)
|
||||
{
|
||||
RawChunkHeader source = fragment.Header;
|
||||
long offsetUs = source.Clock.ProbeOffsetUs;
|
||||
long offsetUs = source.Clock.DialerOffsetUs;
|
||||
long min = long.MaxValue;
|
||||
long max = long.MinValue;
|
||||
foreach (RawStream stream in streams)
|
||||
@@ -68,7 +68,7 @@ public static class ChunkFinalizer
|
||||
TestId = source.TestId,
|
||||
Phase = source.Phase,
|
||||
AgentId = agent,
|
||||
ProbeId = source.ProbeId,
|
||||
DialerId = source.DialerId,
|
||||
IpVersion = source.IpVersion,
|
||||
Target = null,
|
||||
Params = new Dictionary<string, JsonElement>(resolvedParams),
|
||||
@@ -77,15 +77,15 @@ public static class ChunkFinalizer
|
||||
StartUtcMs = start,
|
||||
DurationMs = duration,
|
||||
Partial = false,
|
||||
Clock = new ChunkClock { AgentMonoUs = anchor.AgentMonoUs, UtcMs = anchor.UtcMs, ProbeOffsetUs = offsetUs },
|
||||
Clock = new ChunkClock { ListenerMonoUs = anchor.ListenerMonoUs, UtcMs = anchor.UtcMs, DialerOffsetUs = offsetUs },
|
||||
};
|
||||
return new RawChunk(header, streams);
|
||||
}
|
||||
|
||||
public static long ToUtcMs(long value, ColumnRole role, ClockAnchor anchor, long probeOffsetUs)
|
||||
public static long ToUtcMs(long value, ColumnRole role, ClockAnchor anchor, long dialerOffsetUs)
|
||||
{
|
||||
long agentUs = role == ColumnRole.TsProbe ? value + probeOffsetUs : value;
|
||||
long delta = agentUs - anchor.AgentMonoUs;
|
||||
long listenerUs = role == ColumnRole.TsDialer ? value + dialerOffsetUs : value;
|
||||
long delta = listenerUs - anchor.ListenerMonoUs;
|
||||
return anchor.UtcMs + (delta >= 0 ? delta / 1000 : -((-delta + 999) / 1000));
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,10 @@ public interface IRecordSource
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 把 Probe 一侧的列块(一个只带 Probe 列的 RawChunk)与 Agent 一侧的记录合并成完整的行。合并完全由 Kind 的 <see cref="StreamSpec"/> 声明驱动:
|
||||
/// 把 拨号方 一侧的列块(一个只带 拨号方 列的 RawChunk)与 Agent 一侧的记录合并成完整的行。合并完全由 Kind 的 <see cref="StreamSpec"/> 声明驱动:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>声明了连接键、两侧都有列:按键连接。主导方是 Probe(回显)时行取自列块,Agent 的列按键补上,缺失就是缺失(上行丢失);
|
||||
/// 主导方是 Agent(单向数据流)时行取自 Agent 的记录(按列块头里的序号范围),Probe 的列按键补上,缺失就是缺失(丢失)。</description></item>
|
||||
/// <item><description>声明了连接键、两侧都有列:按键连接。主导方是 拨号方(回显)时行取自列块,Agent 的列按键补上,缺失就是缺失(上行丢失);
|
||||
/// 主导方是 Agent(单向数据流)时行取自 Agent 的记录(按列块头里的序号范围),拨号方 的列按键补上,缺失就是缺失(丢失)。</description></item>
|
||||
/// <item><description>没有连接键,或者只有主导方一侧有列:原样透传,只按声明的列顺序重排。</description></item>
|
||||
/// </list>
|
||||
/// 合并之后的流按声明的列顺序排列,所以 Backend 的分析层不需要知道这些列是谁记录的;新增一种记录流不需要写合并代码。
|
||||
@@ -41,7 +41,7 @@ public static class FragmentMerger
|
||||
streams.Add(value);
|
||||
}
|
||||
|
||||
// 两侧各自记录完整行的流:Probe 的列块里没有它(例如 Probe 在不支持内核采样的平台上)时,Agent 自己的行也要交出去
|
||||
// 两侧各自记录完整行的流:拨号方 的列块里没有它(例如 拨号方 在不支持内核采样的平台上)时,Agent 自己的行也要交出去
|
||||
foreach (StreamSpec union in spec.Streams.Where(s => s.Union && fragment.Find(s.Name) is null))
|
||||
{
|
||||
RawStream? own = records.Records(streamId, union.Name)?.Drain(includeKey: false);
|
||||
@@ -54,32 +54,32 @@ public static class FragmentMerger
|
||||
return Result<IReadOnlyList<RawStream>>.Ok(streams);
|
||||
}
|
||||
|
||||
private static Result<RawStream> Join(StreamSpec spec, RawStream probe, RawChunkHeader header, RowStore? agent)
|
||||
private static Result<RawStream> Join(StreamSpec spec, RawStream fragment, RawChunkHeader header, RowStore? local)
|
||||
{
|
||||
if (spec.Union)
|
||||
{
|
||||
return Result<RawStream>.Ok(Concatenate(spec, probe, agent));
|
||||
return Result<RawStream>.Ok(Concatenate(spec, fragment, local));
|
||||
}
|
||||
|
||||
IReadOnlyList<ColumnSpec> probeColumns = spec.ColumnsOf(Side.Probe);
|
||||
IReadOnlyList<ColumnSpec> agentColumns = spec.ColumnsOf(Side.Agent);
|
||||
if (spec.Key is null || (spec.Driver == Side.Probe && agentColumns.Count == 0))
|
||||
IReadOnlyList<ColumnSpec> dialerColumns = spec.ColumnsOf(Side.Dialer);
|
||||
IReadOnlyList<ColumnSpec> listenerColumns = spec.ColumnsOf(Side.Listener);
|
||||
if (spec.Key is null || (spec.Driver == Side.Dialer && listenerColumns.Count == 0))
|
||||
{
|
||||
return Result<RawStream>.Ok(Conform(spec, probe));
|
||||
return Result<RawStream>.Ok(Conform(spec, fragment));
|
||||
}
|
||||
|
||||
RawColumn? key = probe.Find(spec.Key);
|
||||
if (key is null || probeColumns.Any(c => probe.Find(c.Name) is null))
|
||||
RawColumn? key = fragment.Find(spec.Key);
|
||||
if (key is null || dialerColumns.Any(c => fragment.Find(c.Name) is null))
|
||||
{
|
||||
return Result<RawStream>.Fail(MoShiroErrors.ValidationFailed, $"a fragment of stream {spec.Name} needs the columns {string.Join(", ", new[] { spec.Key }.Concat(probeColumns.Select(c => c.Name)))}");
|
||||
return Result<RawStream>.Fail(MoShiroErrors.ValidationFailed, $"a fragment of stream {spec.Name} needs the columns {string.Join(", ", new[] { spec.Key }.Concat(dialerColumns.Select(c => c.Name)))}");
|
||||
}
|
||||
|
||||
return spec.Driver == Side.Probe ? JoinOnProbeRows(spec, probe, key, agent) : JoinOnAgentRows(spec, probe, key, header, agent);
|
||||
return spec.Driver == Side.Dialer ? JoinOnDialerRows(spec, fragment, key, local) : JoinOnListenerRows(spec, fragment, key, header, local);
|
||||
}
|
||||
|
||||
private static Result<RawStream> JoinOnProbeRows(StreamSpec spec, RawStream probe, RawColumn key, RowStore? agent)
|
||||
private static Result<RawStream> JoinOnDialerRows(StreamSpec spec, RawStream fragment, RawColumn key, RowStore? local)
|
||||
{
|
||||
int n = probe.Count;
|
||||
int n = fragment.Count;
|
||||
var columns = new List<RawColumn>(spec.Columns.Count);
|
||||
foreach (ColumnSpec column in spec.Columns)
|
||||
{
|
||||
@@ -90,21 +90,21 @@ public static class FragmentMerger
|
||||
key.Values.AsSpan(0, n).CopyTo(values);
|
||||
Array.Fill(valid, true);
|
||||
}
|
||||
else if (spec.OwnerOf(column) == Side.Probe)
|
||||
else if (spec.OwnerOf(column) == Side.Dialer)
|
||||
{
|
||||
RawColumn source = probe.Find(column.Name)!;
|
||||
RawColumn source = fragment.Find(column.Name)!;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
values[i] = source.Values[i];
|
||||
valid[i] = source.IsValid(i);
|
||||
}
|
||||
}
|
||||
else if (agent is not null)
|
||||
else if (local is not null)
|
||||
{
|
||||
int index = agent.ColumnIndex(column.Name);
|
||||
int index = local.ColumnIndex(column.Name);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
valid[i] = agent.TryGet(key.Values[i], index, out values[i]);
|
||||
valid[i] = local.TryGet(key.Values[i], index, out values[i]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,17 +114,17 @@ public static class FragmentMerger
|
||||
return Result<RawStream>.Ok(new RawStream(spec.Name, n, columns));
|
||||
}
|
||||
|
||||
private static Result<RawStream> JoinOnAgentRows(StreamSpec spec, RawStream probe, RawColumn key, RawChunkHeader header, RowStore? agent)
|
||||
private static Result<RawStream> JoinOnListenerRows(StreamSpec spec, RawStream fragment, RawColumn key, RawChunkHeader header, RowStore? local)
|
||||
{
|
||||
if (!TryGetRange(header, out uint from, out uint to))
|
||||
{
|
||||
return Result<RawStream>.Fail(MoShiroErrors.ValidationFailed, $"a fragment of stream {spec.Name} must declare its sequence range");
|
||||
}
|
||||
|
||||
// 行取自 Agent 的记录:范围内发出过的每一个包都有一行,Probe 没有报告到达的就是丢失
|
||||
RawStream rows = agent?.Cut(from, to, includeKey: true) ?? new RawStream(spec.Name, 0, []);
|
||||
// 行取自 Agent 的记录:范围内发出过的每一个包都有一行,拨号方 没有报告到达的就是丢失
|
||||
RawStream rows = local?.Cut(from, to, includeKey: true) ?? new RawStream(spec.Name, 0, []);
|
||||
var arrived = new Dictionary<long, int>();
|
||||
for (int i = 0; i < probe.Count; i++)
|
||||
for (int i = 0; i < fragment.Count; i++)
|
||||
{
|
||||
arrived.TryAdd(key.Values[i], i); // 重复的包只算第一次
|
||||
}
|
||||
@@ -144,7 +144,7 @@ public static class FragmentMerger
|
||||
valid[i] = own.IsValid(i);
|
||||
}
|
||||
}
|
||||
else if (probe.Find(column.Name) is { } source)
|
||||
else if (fragment.Find(column.Name) is { } source)
|
||||
{
|
||||
RawColumn keys = rows.Find(spec.Key!)!;
|
||||
for (int i = 0; i < n; i++)
|
||||
@@ -164,13 +164,13 @@ public static class FragmentMerger
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 两侧各自记录完整行的流(例如各自套接字的内核采样):Probe 的行(列块里)加上 Agent 自己新记录的行,按声明的列顺序拼接。
|
||||
/// 两侧各自记录完整行的流(例如各自套接字的内核采样):拨号方 的行(列块里)加上 Agent 自己新记录的行,按声明的列顺序拼接。
|
||||
/// 每一行里有一列标明它属于哪一侧,所以不需要连接。
|
||||
/// </summary>
|
||||
private static RawStream Concatenate(StreamSpec spec, RawStream probe, RowStore? agent)
|
||||
private static RawStream Concatenate(StreamSpec spec, RawStream fragment, RowStore? local)
|
||||
{
|
||||
RawStream own = Conform(spec, probe);
|
||||
RawStream? extra = agent?.Drain(includeKey: false);
|
||||
RawStream own = Conform(spec, fragment);
|
||||
RawStream? extra = local?.Drain(includeKey: false);
|
||||
if (extra is null || extra.Count == 0)
|
||||
{
|
||||
return own;
|
||||
@@ -202,7 +202,7 @@ public static class FragmentMerger
|
||||
return new RawStream(spec.Name, n, columns);
|
||||
}
|
||||
|
||||
/// <summary>按声明的列顺序重排;Probe 一侧已经是完整的行时不改变内容。</summary>
|
||||
/// <summary>按声明的列顺序重排;拨号方 一侧已经是完整的行时不改变内容。</summary>
|
||||
private static RawStream Conform(StreamSpec spec, RawStream stream)
|
||||
{
|
||||
var columns = new List<RawColumn>(spec.Columns.Count);
|
||||
|
||||
@@ -75,7 +75,7 @@ internal sealed class MsQuicConnection : IAsyncDisposable, IDatagramConnection
|
||||
_configuration = configuration;
|
||||
_expectedPeerSpki = expectedPeerSpki;
|
||||
DatagramHandler = datagramHandler;
|
||||
_log = new ContextNodeLog(log, ("quic_id", DiagnosticId), ("side", expectedPeerSpki is null ? "agent" : "probe"));
|
||||
_log = new ContextNodeLog(log, ("quic_id", DiagnosticId), ("side", expectedPeerSpki is null ? "listener" : "dialer"));
|
||||
_closed = closed;
|
||||
_peerAddressChanged = peerAddressChanged;
|
||||
_self = GCHandle.Alloc(this);
|
||||
@@ -164,7 +164,7 @@ internal sealed class MsQuicConnection : IAsyncDisposable, IDatagramConnection
|
||||
long handshakeStarted = 0;
|
||||
try
|
||||
{
|
||||
registration = MsQuicApi.RegistrationOpen("moshiro-probe");
|
||||
registration = MsQuicApi.RegistrationOpen("moshiro-dialer");
|
||||
configuration = MsQuicApi.ConfigurationOpen(registration, QuicAlpn.Value);
|
||||
LoadClientCredentials(configuration);
|
||||
var endpoint = new IPEndPoint(address.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, localPort);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>The application protocol negotiated by the Agent and Probe QUIC endpoints.</summary>
|
||||
/// <summary>The application protocol negotiated by the Agent and 拨号方 QUIC endpoints.</summary>
|
||||
internal static class QuicAlpn
|
||||
{
|
||||
internal static readonly byte[] Value = "h3"u8.ToArray();
|
||||
|
||||
@@ -6,7 +6,7 @@ using Fleet.Core;
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>
|
||||
/// Measurement data transport. Agent instances listen on configured QUIC UDP ports; Probe instances
|
||||
/// Measurement data transport. Agent instances listen on configured QUIC UDP ports; 拨号方 instances
|
||||
/// open one QUIC connection from a newly selected ephemeral source port. Measurement frames are sent
|
||||
/// only with MsQuic DATAGRAM calls; the existing TLS peer connection continues to carry session control.
|
||||
/// </summary>
|
||||
@@ -21,7 +21,7 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable
|
||||
private readonly DataSession? _clientSession;
|
||||
private readonly IPEndPoint? _remote;
|
||||
private readonly byte[]? _peerSpki;
|
||||
private readonly int _previousSourcePort;
|
||||
private int _previousSourcePort;
|
||||
private readonly TaskCompletionSource _clientReady = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private MsQuicListener? _listener;
|
||||
private MsQuicConnection? _client;
|
||||
@@ -38,8 +38,8 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable
|
||||
_serverCertificate = serverCertificate;
|
||||
}
|
||||
|
||||
/// <summary>Constructs a Probe-side QUIC dialer for one Agent session.</summary>
|
||||
public QuicDatagramTransport(DataSession session, MonoClock clock, DataPlaneCounters counters, INodeLog log, IPEndPoint remote, byte[] peerSpki, int previousSourcePort)
|
||||
/// <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)
|
||||
{
|
||||
_router = new MeasurementDatagramRouter(new SingleSession(session), null, clock, counters, log);
|
||||
_log = log;
|
||||
@@ -49,7 +49,6 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable
|
||||
_clientSession = session;
|
||||
_remote = remote;
|
||||
_peerSpki = peerSpki;
|
||||
_previousSourcePort = previousSourcePort;
|
||||
}
|
||||
|
||||
public IReadOnlyList<int> BoundPorts { get; private set; } = [];
|
||||
@@ -79,33 +78,39 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable
|
||||
BoundPorts = _listener.BoundPorts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a QUIC connection from a new ephemeral source port. May be called again after the previous connection closed;
|
||||
/// the session, handler registrations and counters carry over. A failed attempt leaves the transport reusable.
|
||||
/// </summary>
|
||||
public async Task ConnectAsync(CancellationToken ct)
|
||||
{
|
||||
if (_remote is null || _clientSession is null || _peerSpki is null)
|
||||
{
|
||||
throw new InvalidOperationException("Only Probe-side QUIC transports can connect.");
|
||||
throw new InvalidOperationException("Only 拨号方-side QUIC transports can connect.");
|
||||
}
|
||||
|
||||
try
|
||||
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);
|
||||
_client = client;
|
||||
_clientSession.BindDatagramConnection(client);
|
||||
BoundPorts = client.LocalEndPoint is { } local ? [local.Port] : [sourcePort];
|
||||
_previousSourcePort = BoundPorts[0];
|
||||
_clientReady.TrySetResult();
|
||||
}
|
||||
|
||||
/// <summary>Completes when the current 拨号方-side connection has shut down (peer close, idle timeout, local close).</summary>
|
||||
public Task WaitForDisconnectAsync(CancellationToken ct) =>
|
||||
_client is { } client ? client.WaitForShutdownAsync().WaitAsync(ct) : Task.CompletedTask;
|
||||
|
||||
/// <summary>Closes the current 拨号方-side connection; the next <see cref="ConnectAsync"/> opens a new one.</summary>
|
||||
public async Task CloseClientAsync()
|
||||
{
|
||||
MsQuicConnection? client = Interlocked.Exchange(ref _client, null);
|
||||
if (client is not null)
|
||||
{
|
||||
int sourcePort = SelectSourcePort(_remote.AddressFamily, _previousSourcePort);
|
||||
_client = await MsQuicConnection.ConnectAsync(
|
||||
_remote.Address,
|
||||
_remote.Port,
|
||||
sourcePort,
|
||||
_peerSpki,
|
||||
_onDatagram,
|
||||
_log,
|
||||
ct);
|
||||
_clientSession.BindDatagramConnection(_client);
|
||||
BoundPorts = _client.LocalEndPoint is { } local ? [local.Port] : [sourcePort];
|
||||
_clientReady.TrySetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Error("QUIC client connection failed", ("err", ex.Message));
|
||||
_clientReady.TrySetException(ex);
|
||||
throw;
|
||||
await client.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,15 +133,9 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Agent side: holds the listener open until cancelled. 拨号方-side connections are driven by <see cref="ConnectAsync"/> and <see cref="WaitForDisconnectAsync"/>.</summary>
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
if (_remote is not null)
|
||||
{
|
||||
await _clientReady.Task.WaitAsync(ct);
|
||||
await _client!.WaitForShutdownAsync().WaitAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, ct);
|
||||
@@ -171,10 +170,7 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_client is not null)
|
||||
{
|
||||
await _client.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
await CloseClientAsync().ConfigureAwait(false);
|
||||
|
||||
if (_listener is not null)
|
||||
{
|
||||
|
||||
@@ -223,7 +223,7 @@ public sealed class TcpAcceptor
|
||||
using var authCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
authCts.CancelAfter(s_authTimeout);
|
||||
|
||||
// 预握手敲门:名册里的 Probe 才算得出敲门包;不通过就不写任何字节、直接断开
|
||||
// 预握手敲门:名册里的 拨号方 才算得出敲门包;不通过就不写任何字节、直接断开
|
||||
if (!await _knock.AdmitAsync(stream, (client.Client.RemoteEndPoint as IPEndPoint)?.Address, authCts.Token))
|
||||
{
|
||||
_counters.DropInvalid();
|
||||
|
||||
@@ -71,7 +71,7 @@ public static class TcpInfo
|
||||
|
||||
/// <summary>
|
||||
/// 一条连接上周期性的内核采样(<c>tcp_info</c> 流):每个采样一行 (ts, side, srtt, rttvar, cwnd, retrans_total, segs_out, bytes_acked, conn)。
|
||||
/// 两端各采样自己的套接字:Probe 的重传反映上行方向,Agent 的重传反映下行方向(代理场景里大流量走这个方向)。
|
||||
/// 两端各采样自己的套接字:拨号方 的重传反映上行方向,Agent 的重传反映下行方向(代理场景里大流量走这个方向)。
|
||||
/// 累计计数器在 Backend 按 (side, conn) 分别求差;conn 是当前记录器内分配的连接编号,不包含网络地址。
|
||||
/// </summary>
|
||||
public sealed class TcpInfoRecorder
|
||||
@@ -84,7 +84,7 @@ public sealed class TcpInfoRecorder
|
||||
{
|
||||
Rows = new RowStore(spec, side, 4096);
|
||||
_clock = clock;
|
||||
_side = side == Side.Probe ? 0 : 1;
|
||||
_side = side == Side.Dialer ? 0 : 1;
|
||||
}
|
||||
|
||||
public RowStore Rows { get; }
|
||||
|
||||
@@ -4,7 +4,7 @@ using MoShiro.Protocol;
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>
|
||||
/// Agent 和 Probe 共用。主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。
|
||||
/// 拨号方和监听方共用。主机资源占用的只读快照:CPU 占用、内存、根分区、网卡累计流量与瞬时网速、系统运行时长。
|
||||
/// 只有汇总的数字:<b>不采集 CPU 型号、主机名、网卡名和任何 IP 地址</b>,所以面板和展示页想泄漏也泄漏不出去。
|
||||
/// Linux 读 <c>/proc</c> 和 <c>/sys</c>;容器通过 HOST_PROC、HOST_SYS、HOST_ROOT 指定宿主路径。
|
||||
/// Windows 用 GetSystemTimes、GlobalMemoryStatusEx 和 NetworkInterface(见 WindowsHost);macOS 等其他系统只有磁盘和运行时长。读不到的项是 null。不执行任何命令,不写文件。
|
||||
|
||||
@@ -2,7 +2,7 @@ using System.Text.Json.Serialization;
|
||||
|
||||
namespace MoShiro.Measurement;
|
||||
|
||||
/// <summary>节点本地配置里的 <c>reporting</c> 段(面板无法修改):Agent 和 Probe 共用。</summary>
|
||||
/// <summary>节点本地配置里的 <c>reporting</c> 段(面板无法修改):拨号方和监听方共用。</summary>
|
||||
public sealed class ReportingSection
|
||||
{
|
||||
/// <summary>上报主机资源占用(CPU、内存、磁盘、流量、运行时长,只有汇总数字,没有型号和 IP)。设为 false 就一个字也不上报。</summary>
|
||||
|
||||
@@ -23,37 +23,37 @@ public enum ColumnEnc
|
||||
Delta,
|
||||
}
|
||||
|
||||
/// <summary>时间列的时钟归属:Probe、Agent 各自的单调时钟(微秒),或所在一侧的本地时钟。</summary>
|
||||
/// <summary>时间列的时钟归属:拨号方、监听方各自的单调时钟(微秒),或所在一侧的本地时钟。</summary>
|
||||
public enum ColumnRole
|
||||
{
|
||||
None,
|
||||
TsProbe,
|
||||
TsAgent,
|
||||
TsDialer,
|
||||
TsListener,
|
||||
TsLocal,
|
||||
}
|
||||
|
||||
public sealed record ColumnSpec(string Name, ColumnType Type, ColumnEnc Enc = ColumnEnc.Plain, bool Nullable = false, ColumnRole Role = ColumnRole.None);
|
||||
|
||||
/// <summary>数据面上的一侧:Probe 永远是拨号方,Agent 永远是监听方。</summary>
|
||||
/// <summary>数据面上的一侧:拨号方发起连接,监听方接受连接。</summary>
|
||||
public enum Side
|
||||
{
|
||||
Probe,
|
||||
Agent,
|
||||
Dialer,
|
||||
Listener,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 一个记录流(一张表)。<see cref="Driver"/> 是行的主导方:行由它发出的包或事件决定,另一侧的列按 <see cref="Key"/>(例如 <c>seq</c>)补充,
|
||||
/// 缺失就是缺失(回显缺 t2/t3 表示上行丢失,单向数据流缺 t_recv 表示丢失)。没有 <see cref="Key"/> 的流只有主导方一侧的列,原样透传。
|
||||
/// 每一列属于哪一侧由它的时钟决定(<c>TsProbe</c> 属于 Probe,<c>TsAgent</c> 属于 Agent),其他列属于主导方。
|
||||
/// 每一列属于哪一侧由它的时钟决定(<c>TsDialer</c> 属于拨号方,<c>TsListener</c> 属于监听方),其他列属于主导方。
|
||||
/// <see cref="Union"/> 流是例外:两侧各自记录完整的行(行里有一列标明是哪一侧),合并时直接拼接,不连接。
|
||||
/// 这一条声明同时决定了两端各记录什么、Agent 怎么合并,新增一种记录流不需要写合并代码。
|
||||
/// 这一条声明同时决定了两端各记录什么、监听方怎么合并,新增一种记录流不需要写合并代码。
|
||||
/// </summary>
|
||||
public sealed record StreamSpec(string Name, IReadOnlyList<ColumnSpec> Columns, Side Driver = Side.Probe, string? Key = null, bool Union = false)
|
||||
public sealed record StreamSpec(string Name, IReadOnlyList<ColumnSpec> Columns, Side Driver = Side.Dialer, string? Key = null, bool Union = false)
|
||||
{
|
||||
public Side OwnerOf(ColumnSpec column) => column.Role switch
|
||||
{
|
||||
ColumnRole.TsProbe => Side.Probe,
|
||||
ColumnRole.TsAgent => Side.Agent,
|
||||
ColumnRole.TsDialer => Side.Dialer,
|
||||
ColumnRole.TsListener => Side.Listener,
|
||||
_ => Driver,
|
||||
};
|
||||
|
||||
@@ -70,7 +70,7 @@ public sealed record BlockSet(string Transport, string Pacer, string Payload, st
|
||||
|
||||
/// <summary>
|
||||
/// 采集方法声明:积木组合 + 参数 + 记录的列 + 带宽预算。
|
||||
/// 这一条声明同时被 Probe/Agent(组装执行)、Backend(校验与目录)、Analysis(按列名读数据)使用。
|
||||
/// 这一条声明同时被拨号方和监听方(组装执行)、Backend(校验与目录)、Analysis(按列名读数据)使用。
|
||||
/// </summary>
|
||||
public sealed record KindSpec(
|
||||
string Id,
|
||||
@@ -109,9 +109,9 @@ public static class Col
|
||||
/// <summary>单调递增的序号:差分编码。</summary>
|
||||
public static ColumnSpec Seq(string name) => new(name, ColumnType.U32, ColumnEnc.Delta);
|
||||
|
||||
public static ColumnSpec TsProbe(string name, bool nullable = false) => new(name, ColumnType.I64, ColumnEnc.Delta, nullable, ColumnRole.TsProbe);
|
||||
public static ColumnSpec TsDialer(string name, bool nullable = false) => new(name, ColumnType.I64, ColumnEnc.Delta, nullable, ColumnRole.TsDialer);
|
||||
|
||||
public static ColumnSpec TsAgent(string name, bool nullable = false) => new(name, ColumnType.I64, ColumnEnc.Delta, nullable, ColumnRole.TsAgent);
|
||||
public static ColumnSpec TsListener(string name, bool nullable = false) => new(name, ColumnType.I64, ColumnEnc.Delta, nullable, ColumnRole.TsListener);
|
||||
|
||||
public static ColumnSpec TsLocal(string name) => new(name, ColumnType.I64, ColumnEnc.Delta, false, ColumnRole.TsLocal);
|
||||
}
|
||||
|
||||
@@ -12,9 +12,9 @@ public static class Kinds
|
||||
"msg",
|
||||
[
|
||||
Col.Seq("seq"), Col.U16("size"),
|
||||
Col.TsProbe("t1"), Col.TsAgent("t2", nullable: true), Col.TsAgent("t3", nullable: true), Col.TsProbe("t4", nullable: true),
|
||||
Col.TsDialer("t1"), Col.TsListener("t2", nullable: true), Col.TsListener("t3", nullable: true), Col.TsDialer("t4", nullable: true),
|
||||
],
|
||||
Side.Probe,
|
||||
Side.Dialer,
|
||||
"seq");
|
||||
|
||||
private static readonly StreamSpec s_tcpInfo = new(
|
||||
@@ -43,7 +43,7 @@ public static class Kinds
|
||||
public static readonly KindSpec UdpTrain = new(
|
||||
"udp_train",
|
||||
"QUIC Datagram train",
|
||||
"Fixed-rate or laddered one-way QUIC Datagram stream (Agent to Probe) for loss and delay variation under load.",
|
||||
"Fixed-rate or laddered one-way QUIC Datagram stream (Agent to 拨号方) for loss and delay variation under load.",
|
||||
Scope.Line,
|
||||
new BlockSet("quic_datagram", "ladder", "fixed", "sink", []),
|
||||
[
|
||||
@@ -54,8 +54,8 @@ public static class Kinds
|
||||
P.Int("port", 7443, 1, 65535),
|
||||
],
|
||||
[
|
||||
new StreamSpec("pkt", [Col.Seq("seq"), Col.U16("size"), Col.TsAgent("t_send"), Col.TsProbe("t_recv", nullable: true)], Side.Agent, "seq"),
|
||||
new StreamSpec("step", [Col.U16("index"), Col.U32("mbps"), Col.TsProbe("start_ts"), Col.TsProbe("end_ts")]),
|
||||
new StreamSpec("pkt", [Col.Seq("seq"), Col.U16("size"), Col.TsListener("t_send"), Col.TsDialer("t_recv", nullable: true)], Side.Listener, "seq"),
|
||||
new StreamSpec("step", [Col.U16("index"), Col.U32("mbps"), Col.TsDialer("start_ts"), Col.TsDialer("end_ts")]),
|
||||
],
|
||||
p => (long)(p.GetNumber("mbps") * 1_000_000));
|
||||
|
||||
@@ -91,7 +91,7 @@ public static class Kinds
|
||||
P.Bool("tls", true),
|
||||
],
|
||||
[
|
||||
new StreamSpec("bytes", [Col.TsProbe("ts"), Col.U8("conn"), Col.U64("bytes")]),
|
||||
new StreamSpec("bytes", [Col.TsDialer("ts"), Col.U8("conn"), Col.U64("bytes")]),
|
||||
s_tcpInfo,
|
||||
],
|
||||
_ => 0);
|
||||
@@ -111,7 +111,7 @@ public static class Kinds
|
||||
[
|
||||
new StreamSpec(
|
||||
"conn",
|
||||
[Col.Seq("attempt"), Col.TsProbe("ts"), Col.U32("tcp_us", nullable: true), Col.U32("tls_us", nullable: true), Col.U8("outcome")]),
|
||||
[Col.Seq("attempt"), Col.TsDialer("ts"), Col.U32("tcp_us", nullable: true), Col.U32("tls_us", nullable: true), Col.U8("outcome")]),
|
||||
],
|
||||
p => p.GetInt("per_minute") * 4000 / 60);
|
||||
|
||||
@@ -126,14 +126,14 @@ public static class Kinds
|
||||
P.Int("port", 7444, 1, 65535),
|
||||
],
|
||||
[
|
||||
new StreamSpec("hold", [Col.TsProbe("ts"), Col.U8("event"), Col.U32("lifetime_ms")]),
|
||||
new StreamSpec("hold", [Col.TsDialer("ts"), Col.U8("event"), Col.U32("lifetime_ms")]),
|
||||
],
|
||||
p => 2000 / Math.Max(1, p.GetInt("heartbeat_s")) * 8);
|
||||
|
||||
public static readonly KindSpec IcmpEcho = new(
|
||||
"icmp_echo",
|
||||
"ICMP echo",
|
||||
"Probe or Agent side ICMP echo to a target address.",
|
||||
"拨号方 or Agent side ICMP echo to a target address.",
|
||||
Scope.Target,
|
||||
new BlockSet("icmp", "fixed", "fixed", "echo", []),
|
||||
[
|
||||
|
||||
@@ -3,14 +3,6 @@ using Fleet.Core;
|
||||
|
||||
namespace MoShiro.Protocol;
|
||||
|
||||
[JsonConverter(typeof(ProbeIdJsonConverter))]
|
||||
public readonly record struct ProbeId(string Value)
|
||||
{
|
||||
public const string Prefix = "prb";
|
||||
|
||||
public override string ToString() => Value;
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(TaskIdJsonConverter))]
|
||||
public readonly record struct TaskId(string Value)
|
||||
{
|
||||
@@ -42,13 +34,6 @@ public static class AgentIds
|
||||
public const string Prefix = "agt";
|
||||
}
|
||||
|
||||
public sealed class ProbeIdJsonConverter : StringIdJsonConverter<ProbeId>
|
||||
{
|
||||
protected override ProbeId Create(string value) => new(value);
|
||||
|
||||
protected override string GetValue(ProbeId id) => id.Value;
|
||||
}
|
||||
|
||||
public sealed class TaskIdJsonConverter : StringIdJsonConverter<TaskId>
|
||||
{
|
||||
protected override TaskId Create(string value) => new(value);
|
||||
|
||||
@@ -8,6 +8,6 @@ public static class IpVersions
|
||||
|
||||
public static bool IsValid(string? value) => value is V4 or V6;
|
||||
|
||||
public static string LineSeries(string probeId, string ipVersion) =>
|
||||
RawChunkHeader.LinePrefix + probeId + (ipVersion == V6 ? ":ipv6" : "");
|
||||
public static string LineSeries(string dialerId, string ipVersion) =>
|
||||
RawChunkHeader.LinePrefix + dialerId + (ipVersion == V6 ? ":ipv6" : "");
|
||||
}
|
||||
@@ -13,9 +13,10 @@ public static class MoShiroErrors
|
||||
public static readonly ErrorCode SitePrivate = new("site_private");
|
||||
public static readonly ErrorCode NotFound = new("not_found");
|
||||
public static readonly ErrorCode AgentNotFound = new("agent_not_found");
|
||||
public static readonly ErrorCode ProbeNotFound = new("probe_not_found");
|
||||
public static readonly ErrorCode ProbeRevoked = new("probe_revoked");
|
||||
public static readonly ErrorCode ProbeRequired = new("probe_required");
|
||||
public static readonly ErrorCode NodeRevoked = new("node_revoked");
|
||||
public static readonly ErrorCode DialerRequired = new("dialer_required");
|
||||
public static readonly ErrorCode NotListening = new("not_listening");
|
||||
public static readonly ErrorCode SelfLink = new("self_link");
|
||||
public static readonly ErrorCode TaskNotFound = new("task_not_found");
|
||||
public static readonly ErrorCode TestNotFound = new("test_not_found");
|
||||
public static readonly ErrorCode ChunkNotFound = new("chunk_not_found");
|
||||
@@ -23,7 +24,7 @@ public static class MoShiroErrors
|
||||
public static readonly ErrorCode UnknownKind = new("unknown_kind");
|
||||
public static readonly ErrorCode UnknownPlan = new("unknown_plan");
|
||||
public static readonly ErrorCode NameConflict = new("name_conflict");
|
||||
public static readonly ErrorCode ProbeOffline = new("probe_offline");
|
||||
public static readonly ErrorCode DialerOffline = new("dialer_offline");
|
||||
public static readonly ErrorCode TestBusy = new("test_busy");
|
||||
public static readonly ErrorCode AgentNotActive = new("agent_not_active");
|
||||
public static readonly ErrorCode AgentOffline = new("agent_offline");
|
||||
|
||||
@@ -30,7 +30,7 @@ public static class MoShiroChannels
|
||||
/// <summary>Agent → Backend:RawChunk(发件箱)。</summary>
|
||||
public const byte RawChunk = 16;
|
||||
|
||||
/// <summary>Probe → Agent:列块(点对点会话的发件箱)。</summary>
|
||||
/// <summary>拨号方 → Agent:列块(点对点会话的发件箱)。</summary>
|
||||
public const byte ColumnBlock = 17;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,22 +4,22 @@ using Fleet.Core;
|
||||
|
||||
namespace MoShiro.Protocol;
|
||||
|
||||
/// <summary>Agent 单调时钟与 UTC 的锚点,以及 Probe 时钟相对 Agent 时钟的偏移估计。</summary>
|
||||
/// <summary>监听方单调时钟与 UTC 的锚点,以及拨号方时钟相对监听方时钟的偏移估计。</summary>
|
||||
public sealed class ChunkClock
|
||||
{
|
||||
[JsonPropertyName("agent_mono_us")]
|
||||
public long AgentMonoUs { get; set; }
|
||||
[JsonPropertyName("listener_mono_us")]
|
||||
public long ListenerMonoUs { get; set; }
|
||||
|
||||
[JsonPropertyName("utc_ms")]
|
||||
public long UtcMs { get; set; }
|
||||
|
||||
/// <summary>Agent 时钟 − Probe 时钟(微秒)。</summary>
|
||||
[JsonPropertyName("probe_offset_us")]
|
||||
public long ProbeOffsetUs { get; set; }
|
||||
/// <summary>监听方时钟 − 拨号方时钟(微秒)。</summary>
|
||||
[JsonPropertyName("dialer_offset_us")]
|
||||
public long DialerOffsetUs { get; set; }
|
||||
|
||||
/// <summary>Probe 相对 Agent 的时钟漂移(ppb,十亿分之一)。</summary>
|
||||
[JsonPropertyName("probe_drift_ppb")]
|
||||
public long ProbeDriftPpb { get; set; }
|
||||
/// <summary>拨号方相对监听方的时钟漂移(ppb,十亿分之一)。</summary>
|
||||
[JsonPropertyName("dialer_drift_ppb")]
|
||||
public long DialerDriftPpb { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ColumnHeader
|
||||
@@ -38,7 +38,7 @@ public sealed class ColumnHeader
|
||||
[JsonPropertyName("nullable")]
|
||||
public bool Nullable { get; set; }
|
||||
|
||||
/// <summary>ts_probe | ts_agent | ts_local,其他为空。</summary>
|
||||
/// <summary>ts_dialer | ts_listener | ts_local,其他为空。</summary>
|
||||
[JsonPropertyName("role")]
|
||||
public string? Role { get; set; }
|
||||
}
|
||||
@@ -83,8 +83,8 @@ public sealed class RawChunkHeader
|
||||
[JsonPropertyName("agent_id")]
|
||||
public NodeId AgentId { get; set; }
|
||||
|
||||
[JsonPropertyName("probe_id")]
|
||||
public ProbeId? ProbeId { get; set; }
|
||||
[JsonPropertyName("dialer_id")]
|
||||
public NodeId? DialerId { get; set; }
|
||||
|
||||
[JsonPropertyName("target")]
|
||||
public string? Target { get; set; }
|
||||
@@ -105,7 +105,7 @@ public sealed class RawChunkHeader
|
||||
[JsonPropertyName("duration_ms")]
|
||||
public int DurationMs { get; set; }
|
||||
|
||||
/// <summary>Probe 一侧的列没有在宽限期内送达:只有 Agent 一侧的数据。</summary>
|
||||
/// <summary>拨号方一侧的列没有在宽限期内送达:只有监听方一侧的数据。</summary>
|
||||
[JsonPropertyName("partial")]
|
||||
public bool Partial { get; set; }
|
||||
|
||||
@@ -116,11 +116,11 @@ public sealed class RawChunkHeader
|
||||
public List<StreamHeader> Streams { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// "line:<probe_id>" 或 "target:<task_id>":存储和分析用的序列标识。
|
||||
/// 同一个 Agent 上每个 Probe 的每种地址族各是一个序列,IPv6 追加 :ipv6,互不混合。
|
||||
/// "line:<dialer_id>" 或 "target:<task_id>":存储和分析用的序列标识。
|
||||
/// 同一个监听节点上每个拨号方的每种地址族各是一个序列,IPv6 追加 :ipv6,互不混合。
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string Series => TaskId is { } id && Target is not null ? "target:" + id.Value : IpVersions.LineSeries(ProbeId?.Value ?? "", IpVersion);
|
||||
public string Series => TaskId is { } id && Target is not null ? "target:" + id.Value : IpVersions.LineSeries(DialerId?.Value ?? "", IpVersion);
|
||||
|
||||
public const string LinePrefix = "line:";
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ using Fleet.Core;
|
||||
namespace MoShiro.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// RawChunk 的编解码(Probe、Agent、Backend、离线工具共用同一份代码)。
|
||||
/// RawChunk 的编解码(拨号方、Agent、Backend、离线工具共用同一份代码)。
|
||||
/// 布局:<c>"MSRC" | u8 container_version | u32 crc32c(压缩数据) | Brotli( u32 header_len | header JSON | 各列数据 )</c>。
|
||||
/// 列数据:可空列先写有效位图,再写有效值;plain 为无符号 varint(有符号类型用 zigzag),delta 为与上一个有效值之差的 zigzag varint。
|
||||
/// </summary>
|
||||
@@ -334,16 +334,16 @@ public static class RawChunkCodec
|
||||
|
||||
private static string? RoleName(ColumnRole role) => role switch
|
||||
{
|
||||
ColumnRole.TsProbe => "ts_probe",
|
||||
ColumnRole.TsAgent => "ts_agent",
|
||||
ColumnRole.TsDialer => "ts_dialer",
|
||||
ColumnRole.TsListener => "ts_listener",
|
||||
ColumnRole.TsLocal => "ts_local",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static ColumnRole ParseRole(string? text) => text switch
|
||||
{
|
||||
"ts_probe" => ColumnRole.TsProbe,
|
||||
"ts_agent" => ColumnRole.TsAgent,
|
||||
"ts_dialer" => ColumnRole.TsDialer,
|
||||
"ts_listener" => ColumnRole.TsListener,
|
||||
"ts_local" => ColumnRole.TsLocal,
|
||||
_ => ColumnRole.None,
|
||||
};
|
||||
|
||||
@@ -2,23 +2,12 @@ using Fleet.Rpc;
|
||||
|
||||
namespace MoShiro.Protocol;
|
||||
|
||||
/// <summary>节点的角色。服务端按节点 ID 的前缀判断;声明表用它限定哪些角色可以参与某个方法或通道。</summary>
|
||||
public static class NodeRoles
|
||||
{
|
||||
public const string Agent = "agent";
|
||||
public const string Probe = "probe";
|
||||
|
||||
public static string? Of(string nodeId) =>
|
||||
nodeId.StartsWith(AgentIds.Prefix, StringComparison.Ordinal) ? Agent
|
||||
: nodeId.StartsWith(ProbeId.Prefix, StringComparison.Ordinal) ? Probe
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 所有方法和二进制通道的方向声明,单一真源(框架的 <c>fleet.*</c> 见 <see cref="FleetMethodTable"/>)。
|
||||
/// 新增一个方法:在 <see cref="MoShiroMethods"/> 里加名字和参数,在这里加一行声明,再在对应一侧注册处理函数;
|
||||
/// 声明了但没人处理、或者处理了但没声明,都会被测试和启动时的检查发现。
|
||||
/// 同一个方法名只有一个发起方;节点面是服务端和节点(B 和 A、P),对等面是 Probe(拨号方)和 Agent(监听方)。
|
||||
/// 同一个方法名只有一个发起方;节点面是服务端和节点,对等面是拨号方和监听方。所有节点是同一种角色:
|
||||
/// 能做什么由节点的配置(是否监听)和 Backend 下发的期望状态决定,声明表不区分节点类型。
|
||||
/// </summary>
|
||||
public static class MoShiroMethodTable
|
||||
{
|
||||
@@ -27,30 +16,30 @@ public static class MoShiroMethodTable
|
||||
new MethodTable(
|
||||
[
|
||||
// 节点面:Backend → 节点
|
||||
MethodSpec.Request(MoShiroMethods.TestRun, RpcSide.Server, NodeRoles.Agent),
|
||||
MethodSpec.Request(MoShiroMethods.TestCancel, RpcSide.Server, NodeRoles.Agent),
|
||||
MethodSpec.Request(MoShiroMethods.ProbeDisconnect, RpcSide.Server, NodeRoles.Agent),
|
||||
MethodSpec.Notification(MoShiroMethods.ProbeLinkRetry, RpcSide.Server, NodeRoles.Probe),
|
||||
MethodSpec.Request(MoShiroMethods.TestRun, RpcSide.Server),
|
||||
MethodSpec.Request(MoShiroMethods.TestCancel, RpcSide.Server),
|
||||
MethodSpec.Request(MoShiroMethods.DialerDisconnect, RpcSide.Server),
|
||||
MethodSpec.Notification(MoShiroMethods.LinkRetry, RpcSide.Server),
|
||||
MethodSpec.Notification(MoShiroMethods.UpdateOffer, RpcSide.Server),
|
||||
MethodSpec.Notification(MoShiroMethods.UpdateApply, RpcSide.Server),
|
||||
MethodSpec.Notification(MoShiroMethods.Schedule, RpcSide.Server),
|
||||
|
||||
// 节点面:节点 → Backend
|
||||
MethodSpec.Notification(MoShiroMethods.TestStatus, RpcSide.Node, NodeRoles.Agent),
|
||||
MethodSpec.Notification(MoShiroMethods.Event, RpcSide.Node, NodeRoles.Agent),
|
||||
MethodSpec.Notification(MoShiroMethods.Host, RpcSide.Node), // Agent 和 Probe 都上报主机资源占用的汇总
|
||||
MethodSpec.Notification(MoShiroMethods.TestStatus, RpcSide.Node),
|
||||
MethodSpec.Notification(MoShiroMethods.Event, RpcSide.Node),
|
||||
MethodSpec.Notification(MoShiroMethods.Host, RpcSide.Node),
|
||||
|
||||
// 对等面:Probe(拨号方)→ Agent(监听方)
|
||||
// 对等面:拨号方 → 监听方
|
||||
MethodSpec.Request(MoShiroMethods.Clock, RpcSide.Dialer),
|
||||
|
||||
// 对等面:Agent → Probe
|
||||
// 对等面:监听方 → 拨号方
|
||||
MethodSpec.Request(MoShiroMethods.TestPhase, RpcSide.Listener),
|
||||
MethodSpec.Request(MoShiroMethods.TestPhaseEnd, RpcSide.Listener),
|
||||
MethodSpec.Notification(MoShiroMethods.TestAbort, RpcSide.Listener),
|
||||
],
|
||||
[
|
||||
// 原始块:只有 Agent 往 Backend 发;列块:Probe 经对等会话发给 Agent
|
||||
new ChannelSpec(MoShiroChannels.RawChunk, RpcSide.Node, [NodeRoles.Agent]),
|
||||
// 原始块:节点往 Backend 发;列块:拨号方经对等会话发给监听方
|
||||
new ChannelSpec(MoShiroChannels.RawChunk, RpcSide.Node),
|
||||
new ChannelSpec(MoShiroChannels.ColumnBlock, RpcSide.Dialer),
|
||||
]));
|
||||
}
|
||||
@@ -9,8 +9,8 @@ public static class MoShiroMethods
|
||||
{
|
||||
public const string TestRun = "moshiro.test.run";
|
||||
public const string TestCancel = "moshiro.test.cancel";
|
||||
public const string ProbeDisconnect = "moshiro.probe.disconnect";
|
||||
public const string ProbeLinkRetry = "moshiro.probe.link_retry";
|
||||
public const string DialerDisconnect = "moshiro.dialer.disconnect";
|
||||
public const string LinkRetry = "moshiro.link.retry";
|
||||
public const string TestStatus = "moshiro.test.status";
|
||||
public const string Event = "moshiro.event";
|
||||
public const string Host = "moshiro.host";
|
||||
@@ -18,7 +18,7 @@ public static class MoShiroMethods
|
||||
public const string UpdateApply = "moshiro.update.apply";
|
||||
public const string Schedule = "moshiro.schedule";
|
||||
|
||||
// Probe ↔ Agent(点对点会话)
|
||||
// 拨号方 ↔ 监听方(点对点会话)
|
||||
public const string Clock = "moshiro.clock";
|
||||
public const string TestPhase = "moshiro.test.phase";
|
||||
public const string TestPhaseEnd = "moshiro.test.phase_end";
|
||||
@@ -27,8 +27,8 @@ public static class MoShiroMethods
|
||||
|
||||
public static class AgentEventKinds
|
||||
{
|
||||
public const string ProbeConnected = "probe_connected";
|
||||
public const string ProbeDisconnected = "probe_disconnected";
|
||||
public const string DialerConnected = "dialer_connected";
|
||||
public const string DialerDisconnected = "dialer_disconnected";
|
||||
public const string EndpointChanged = "endpoint_changed";
|
||||
public const string SessionRejected = "session_rejected";
|
||||
public const string ClockStep = "clock_step";
|
||||
@@ -36,7 +36,7 @@ public static class AgentEventKinds
|
||||
public const string CpuSaturated = "cpu_saturated";
|
||||
|
||||
public static readonly IReadOnlyList<string> All =
|
||||
[ProbeConnected, ProbeDisconnected, EndpointChanged, SessionRejected, ClockStep, Gap, CpuSaturated];
|
||||
[DialerConnected, DialerDisconnected, EndpointChanged, SessionRejected, ClockStep, Gap, CpuSaturated];
|
||||
}
|
||||
|
||||
public static class TestStatuses
|
||||
@@ -69,9 +69,9 @@ public sealed class TestRunParams
|
||||
[JsonPropertyName("test_id")]
|
||||
public TestId TestId { get; set; }
|
||||
|
||||
/// <summary>在哪个 Probe 上运行:必须是当前连着这个 Agent 的 Probe。</summary>
|
||||
[JsonPropertyName("probe_id")]
|
||||
public ProbeId ProbeId { get; set; }
|
||||
/// <summary>在哪个拨号方上运行:必须是当前连着这个监听方的节点。</summary>
|
||||
[JsonPropertyName("dialer_id")]
|
||||
public NodeId DialerId { get; set; }
|
||||
|
||||
[JsonPropertyName("plan")]
|
||||
public string Plan { get; set; } = "";
|
||||
@@ -92,18 +92,18 @@ public sealed class TestCancelParams
|
||||
public TestId TestId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Backend → Agent:断开某个 Probe 的会话(Probe 会按退避重连,除非它已经被吊销)。</summary>
|
||||
public sealed class ProbeDisconnectParams
|
||||
/// <summary>Backend → 监听方:断开某个拨号方的会话(拨号方按退避重连,除非它已经被吊销)。</summary>
|
||||
public sealed class DialerDisconnectParams
|
||||
{
|
||||
[JsonPropertyName("probe_id")]
|
||||
public ProbeId ProbeId { get; set; }
|
||||
[JsonPropertyName("dialer_id")]
|
||||
public NodeId DialerId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Backend → Probe: immediately retry the selected Agent's current IP paths.</summary>
|
||||
public sealed class ProbeLinkRetryParams
|
||||
/// <summary>Backend → 拨号方:立即重试到指定监听方的线路。</summary>
|
||||
public sealed class LinkRetryParams
|
||||
{
|
||||
[JsonPropertyName("agent_id")]
|
||||
public NodeId AgentId { get; set; }
|
||||
[JsonPropertyName("listener_id")]
|
||||
public NodeId ListenerId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class TestStatusParams
|
||||
@@ -123,9 +123,9 @@ public sealed class TestStatusParams
|
||||
|
||||
public sealed class AgentEventParams
|
||||
{
|
||||
/// <summary><c>probe_connected</c> 时:这个 Probe 的 id、版本、地址和连接时间(Backend 不用等下一次心跳就能显示)。</summary>
|
||||
[JsonPropertyName("probe")]
|
||||
public ConnectedProbe? Probe { get; set; }
|
||||
/// <summary><c>dialer_connected</c> 时:这个拨号方的 id、版本、地址和连接时间(Backend 不用等下一次心跳就能显示)。</summary>
|
||||
[JsonPropertyName("dialer")]
|
||||
public ConnectedDialer? Dialer { get; set; }
|
||||
|
||||
[JsonPropertyName("kind")]
|
||||
public string Kind { get; set; } = "";
|
||||
@@ -137,13 +137,11 @@ public sealed class AgentEventParams
|
||||
public string? Detail { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AgentEndpoint
|
||||
/// <summary>拨号目标:一个监听节点的地址、端口和固定证书用的公钥。</summary>
|
||||
public sealed class DialTarget
|
||||
{
|
||||
[JsonPropertyName("ipv6_host")]
|
||||
public string? Ipv6Host { get; set; }
|
||||
|
||||
[JsonPropertyName("agent_id")]
|
||||
public NodeId AgentId { get; set; }
|
||||
[JsonPropertyName("node_id")]
|
||||
public NodeId NodeId { get; set; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; } = "";
|
||||
@@ -151,46 +149,43 @@ public sealed class AgentEndpoint
|
||||
[JsonPropertyName("host")]
|
||||
public string Host { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("ipv6_host")]
|
||||
public string? Ipv6Host { get; set; }
|
||||
|
||||
[JsonPropertyName("tcp_port")]
|
||||
public int TcpPort { get; set; }
|
||||
|
||||
[JsonPropertyName("quic_ports")]
|
||||
public List<int> QuicPorts { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("udp_ports")]
|
||||
public List<int> LegacyUdpPorts { get; set; } = [];
|
||||
|
||||
/// <summary>Agent 面向 Probe 的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它和 Probe 的身份密钥派生。</summary>
|
||||
/// <summary>监听方面向拨号方的 TLS 证书公钥(SPKI,base64):证书按它的 SHA-256 指纹固定,敲门密钥由它和拨号方的身份密钥派生。</summary>
|
||||
[JsonPropertyName("public_key")]
|
||||
public string PublicKey { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>期望状态文档(Fleet state type = <c>probe_agents</c>):Backend 直接下发给每个 Probe 的 Agent 列表。</summary>
|
||||
public sealed class ProbeAgentList
|
||||
/// <summary>期望状态文档(Fleet state type = <c>dial_targets</c>):Backend 下发给每个节点的、它要拨出的监听节点列表。</summary>
|
||||
public sealed class DialTargets
|
||||
{
|
||||
public const string StateType = "probe_agents";
|
||||
public const string StateType = "dial_targets";
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public long Version { get; set; }
|
||||
|
||||
[JsonPropertyName("agents")]
|
||||
public List<AgentEndpoint> Agents { get; set; } = [];
|
||||
[JsonPropertyName("targets")]
|
||||
public List<DialTarget> Targets { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("trust_chain")]
|
||||
public List<SignedStatementDto>? TrustChain { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>注册元数据(MoShiro 定义,经 Fleet 的 <c>meta</c> 字段带上来)。</summary>
|
||||
public sealed class AgentEnrollMeta
|
||||
/// <summary>注册元数据(MoShiro 定义,经 Fleet 的 <c>meta</c> 字段带上来)。不监听的节点不带端口。</summary>
|
||||
public sealed class NodeEnrollMeta
|
||||
{
|
||||
[JsonPropertyName("probe_tcp_port")]
|
||||
public int ProbeTcpPort { get; set; } = 7443;
|
||||
[JsonPropertyName("tcp_port")]
|
||||
public int TcpPort { get; set; }
|
||||
|
||||
[JsonPropertyName("probe_quic_ports")]
|
||||
public List<int> ProbeQuicPorts { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("probe_udp_ports")]
|
||||
public List<int>? LegacyProbeUdpPorts { get; set; }
|
||||
[JsonPropertyName("quic_ports")]
|
||||
public List<int> QuicPorts { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("public_host")]
|
||||
public string? PublicHost { get; set; }
|
||||
@@ -307,19 +302,39 @@ public sealed class UpdateStatus
|
||||
public long At { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Probe 的 <c>fleet.heartbeat</c> 附加字段:更新器的状态。</summary>
|
||||
public sealed class ProbeHeartbeatExtra
|
||||
/// <summary>节点的 <c>fleet.heartbeat</c> 附加字段:进程内的计数、拨号侧的线路健康和监听侧的会话快照,没有主机信息。</summary>
|
||||
public sealed class NodeHeartbeatExtra
|
||||
{
|
||||
/// <summary>Node operating state; registration approval and transport presence are reported separately.</summary>
|
||||
[JsonPropertyName("runtime_status")]
|
||||
public NodeRuntimeStatus RuntimeStatus { get; set; } = NodeRuntimeStatus.Running;
|
||||
|
||||
[JsonPropertyName("quic_links")]
|
||||
public List<ProbeQuicLinkHealth>? QuicLinks { get; set; }
|
||||
/// <summary>拨号侧:到每个监听节点(每个 IP 族)的线路健康。</summary>
|
||||
[JsonPropertyName("links")]
|
||||
public List<LinkHealth>? Links { get; set; }
|
||||
|
||||
[JsonPropertyName("ipv6")]
|
||||
public Ipv6Diagnostic? Ipv6 { get; set; }
|
||||
|
||||
/// <summary>监听侧:当前连着的拨号方,每次心跳都是完整的快照,Backend 靠它纠正错过的 dialer_connected / dialer_disconnected 事件。</summary>
|
||||
[JsonPropertyName("dialers")]
|
||||
public List<ConnectedDialer> Dialers { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("pps_in")]
|
||||
public long PpsIn { get; set; }
|
||||
|
||||
[JsonPropertyName("pps_out")]
|
||||
public long PpsOut { get; set; }
|
||||
|
||||
[JsonPropertyName("dropped_invalid")]
|
||||
public long DroppedInvalid { get; set; }
|
||||
|
||||
[JsonPropertyName("dropped_rate_limited")]
|
||||
public long DroppedRateLimited { get; set; }
|
||||
|
||||
[JsonPropertyName("queue_bytes")]
|
||||
public long QueueBytes { get; set; }
|
||||
|
||||
[JsonPropertyName("update")]
|
||||
public UpdateStatus? Update { get; set; }
|
||||
|
||||
@@ -330,23 +345,94 @@ public sealed class ProbeHeartbeatExtra
|
||||
public long? DroppedRuntimeLogs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Probe's rolling QUIC connection health snapshot for one Agent/IP path.</summary>
|
||||
public sealed class ProbeQuicLinkHealth
|
||||
/// <summary>
|
||||
/// 拨号方到一个监听方(一个 IP 族)的线路健康。控制层(TCP+TLS 长连接,承载控制 RPC 和回传数据)与任务层(测量用的 TCP / QUIC)
|
||||
/// 各有一个状态机:控制层断开才算两个节点失联,任务层只影响测量。
|
||||
/// </summary>
|
||||
public sealed class LinkHealth
|
||||
{
|
||||
[JsonPropertyName("agent_id")]
|
||||
public string AgentId { get; set; } = "";
|
||||
[JsonPropertyName("listener_id")]
|
||||
public string ListenerId { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("ip_version")]
|
||||
public string IpVersion { get; set; } = IpVersions.V4;
|
||||
|
||||
[JsonPropertyName("connected")]
|
||||
public bool Connected { get; set; }
|
||||
[JsonPropertyName("control")]
|
||||
public ControlHealth Control { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("task")]
|
||||
public TaskHealth Task { get; set; } = new();
|
||||
|
||||
/// <summary>控制层在线且 QUIC 数据通道就绪:这条线路可以完整测量。</summary>
|
||||
[JsonIgnore]
|
||||
public bool Ready => Control.State == ControlState.Up && Task.Quic.State == QuicState.Ready;
|
||||
}
|
||||
|
||||
/// <summary>某一层最近一次失败的原因:失败发生的阶段、分类和说明。</summary>
|
||||
public sealed class LinkFault
|
||||
{
|
||||
[JsonPropertyName("phase")]
|
||||
public string Phase { get; set; } = "";
|
||||
|
||||
/// <summary><c>timeout</c>、<c>refused</c>、<c>reset</c>、<c>dns</c>、<c>tls</c>、<c>auth</c>、<c>closed</c>、<c>silent</c>、<c>other</c>。</summary>
|
||||
[JsonPropertyName("kind")]
|
||||
public string Kind { get; set; } = "other";
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string? Message { get; set; }
|
||||
|
||||
public override string ToString() => string.IsNullOrEmpty(Message) ? $"{Phase}: {Kind}" : $"{Phase}: {Kind}: {Message}";
|
||||
}
|
||||
|
||||
/// <summary>控制层:到 Agent 的 TCP+TLS 长连接,包括认证、时钟同步,以及回传数据的发件箱。</summary>
|
||||
public sealed class ControlHealth
|
||||
{
|
||||
[JsonPropertyName("state")]
|
||||
public ProbeLinkAttemptState State { get; set; } = ProbeLinkAttemptState.Connecting;
|
||||
public ControlState State { get; set; } = ControlState.Dialing;
|
||||
|
||||
/// <summary>进入当前状态的时间(Unix 毫秒)。</summary>
|
||||
[JsonPropertyName("since_ms")]
|
||||
public long SinceMs { get; set; }
|
||||
|
||||
[JsonPropertyName("last_error")]
|
||||
public string? LastError { get; set; }
|
||||
public LinkFault? LastError { get; set; }
|
||||
|
||||
[JsonPropertyName("retry_attempt")]
|
||||
public int RetryAttempt { get; set; }
|
||||
|
||||
[JsonPropertyName("retry_in_ms")]
|
||||
public long? RetryInMs { get; set; }
|
||||
|
||||
/// <summary>发件箱里还没有被确认的回传数据。</summary>
|
||||
[JsonPropertyName("outbox_bytes")]
|
||||
public long OutboxBytes { get; set; }
|
||||
|
||||
[JsonPropertyName("outbox_pending")]
|
||||
public int OutboxPending { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>任务层:测量自己使用的链路。TCP 类积木自带套接字和重拨,QUIC 数据通道由拨号方单独维护。</summary>
|
||||
public sealed class TaskHealth
|
||||
{
|
||||
/// <summary>正在运行的 TCP 类线路积木数(tcp_echo、connect、hold、tcp_bulk)。</summary>
|
||||
[JsonPropertyName("tcp_blocks")]
|
||||
public int TcpBlocks { get; set; }
|
||||
|
||||
[JsonPropertyName("quic")]
|
||||
public QuicHealth Quic { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>QUIC 数据通道(udp_echo、udp_train 使用)的状态、握手统计和传输层统计。</summary>
|
||||
public sealed class QuicHealth
|
||||
{
|
||||
[JsonPropertyName("state")]
|
||||
public QuicState State { get; set; } = QuicState.Idle;
|
||||
|
||||
[JsonPropertyName("since_ms")]
|
||||
public long SinceMs { get; set; }
|
||||
|
||||
[JsonPropertyName("last_error")]
|
||||
public LinkFault? LastError { get; set; }
|
||||
|
||||
[JsonPropertyName("retry_attempt")]
|
||||
public int RetryAttempt { get; set; }
|
||||
@@ -389,21 +475,40 @@ public sealed class ProbeQuicLinkHealth
|
||||
public ushort? PathMtu { get; set; }
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<ProbeLinkAttemptState>))]
|
||||
public enum ProbeLinkAttemptState
|
||||
/// <summary>控制层状态:<c>dialing</c>(DNS、TCP、敲门、TLS)→ <c>authenticating</c>(身份签名)→ <c>syncing</c>(时钟)→ <c>up</c>;失败进入 <c>backoff</c>。</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<ControlState>))]
|
||||
public enum ControlState
|
||||
{
|
||||
[JsonStringEnumMemberName("dialing")]
|
||||
Dialing,
|
||||
[JsonStringEnumMemberName("authenticating")]
|
||||
Authenticating,
|
||||
[JsonStringEnumMemberName("syncing")]
|
||||
Syncing,
|
||||
[JsonStringEnumMemberName("up")]
|
||||
Up,
|
||||
[JsonStringEnumMemberName("backoff")]
|
||||
Backoff,
|
||||
}
|
||||
|
||||
/// <summary>QUIC 数据通道状态:控制层不在线时是 <c>idle</c>;<c>connecting</c> 握手中;<c>ready</c> 可发送;<c>retrying</c> 断开后等待重拨。</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<QuicState>))]
|
||||
public enum QuicState
|
||||
{
|
||||
[JsonStringEnumMemberName("idle")]
|
||||
Idle,
|
||||
[JsonStringEnumMemberName("connecting")]
|
||||
Connecting,
|
||||
[JsonStringEnumMemberName("retry_wait")]
|
||||
RetryWait,
|
||||
[JsonStringEnumMemberName("connected")]
|
||||
Connected,
|
||||
[JsonStringEnumMemberName("ready")]
|
||||
Ready,
|
||||
[JsonStringEnumMemberName("retrying")]
|
||||
Retrying,
|
||||
}
|
||||
|
||||
/// <summary>Probe 的 <c>fleet.hello</c> 附加字段:系统、架构和构建信息(只用于展示和更新)。</summary>
|
||||
public sealed class ProbeHelloMeta
|
||||
/// <summary>节点的 <c>fleet.hello</c> 附加字段:属主、系统与构建信息,以及(监听时)面向拨号方的接入信息。</summary>
|
||||
public sealed class NodeHelloMeta
|
||||
{
|
||||
/// <summary>这个 Probe 的属主:安装时固定在节点本地的 passkey 凭证 id。面板据此知道它绑定在哪个账户的哪把 passkey 上。</summary>
|
||||
/// <summary>这个节点的属主:安装时固定在节点本地的 passkey 凭证 id。面板据此知道它绑定在哪个账户的哪把 passkey 上。</summary>
|
||||
[JsonPropertyName("owners")]
|
||||
public List<string>? Owners { get; set; }
|
||||
|
||||
@@ -412,44 +517,31 @@ public sealed class ProbeHelloMeta
|
||||
|
||||
[JsonPropertyName("platform")]
|
||||
public PlatformInfo? Platform { get; set; }
|
||||
|
||||
[JsonPropertyName("local_limits")]
|
||||
public LocalLimits? LocalLimits { get; set; }
|
||||
|
||||
/// <summary>监听接入信息;节点不监听时为 null,面板不会把它当作拨号目标。</summary>
|
||||
[JsonPropertyName("listen")]
|
||||
public ListenInfo? Listen { get; set; }
|
||||
}
|
||||
|
||||
/// <summary><c>fleet.hello</c> 的附加字段。</summary>
|
||||
public sealed class AgentHelloMeta
|
||||
/// <summary>节点作为监听方时,面向拨号方的接入信息。</summary>
|
||||
public sealed class ListenInfo
|
||||
{
|
||||
/// <summary>这个 Agent 的属主:安装时固定在节点本地的 passkey 凭证 id。面板据此知道它绑定在哪个账户的哪把 passkey 上。</summary>
|
||||
[JsonPropertyName("owners")]
|
||||
public List<string>? Owners { get; set; }
|
||||
[JsonPropertyName("tcp_port")]
|
||||
public int TcpPort { get; set; }
|
||||
|
||||
[JsonPropertyName("ipv6")]
|
||||
public Ipv6Diagnostic? Ipv6 { get; set; }
|
||||
[JsonPropertyName("quic_ports")]
|
||||
public List<int> QuicPorts { get; set; } = [];
|
||||
|
||||
/// <summary>本机是否启用了 IPv6 监听。旧 Agent 缺省为 false,面板不会给它安排 IPv6 会话。</summary>
|
||||
/// <summary>本机是否启用了 IPv6 监听;面板只给启用了的节点安排 IPv6 会话。</summary>
|
||||
[JsonPropertyName("ipv6_supported")]
|
||||
public bool Ipv6Supported { get; set; }
|
||||
|
||||
[JsonPropertyName("platform")]
|
||||
public PlatformInfo? Platform { get; set; }
|
||||
|
||||
/// <summary>Agent 面向 Probe 的 TLS 证书公钥(SPKI,base64);面板据此得到证书指纹,并下发给 Probe 用来固定证书和派生敲门密钥。</summary>
|
||||
[JsonPropertyName("tls_pubkey")]
|
||||
public string? TlsPublicKey { get; set; }
|
||||
|
||||
/// <summary>本机的公网地址(IPv4 公网地址、IPv6 全球单播地址):面板据此自动填 <c>public_host</c> 和 <c>ipv6_host</c>,管理员手填的优先。</summary>
|
||||
[JsonPropertyName("addresses")]
|
||||
public List<string>? Addresses { get; set; }
|
||||
|
||||
[JsonPropertyName("probe_tcp_port")]
|
||||
public int? ProbeTcpPort { get; set; }
|
||||
|
||||
[JsonPropertyName("probe_quic_ports")]
|
||||
public List<int>? ProbeQuicPorts { get; set; }
|
||||
|
||||
[JsonPropertyName("probe_udp_ports")]
|
||||
public List<int>? LegacyProbeUdpPorts { get; set; }
|
||||
|
||||
[JsonPropertyName("local_limits")]
|
||||
public LocalLimits? LocalLimits { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -497,19 +589,19 @@ public sealed class HostSnapshot
|
||||
public long AtUtcMs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Agent 眼中的一个已连接 Probe。</summary>
|
||||
public sealed class ConnectedProbe
|
||||
/// <summary>监听方眼中的一个已连接拨号方。</summary>
|
||||
public sealed class ConnectedDialer
|
||||
{
|
||||
[JsonPropertyName("ip_version")]
|
||||
public string IpVersion { get; set; } = IpVersions.V4;
|
||||
|
||||
[JsonPropertyName("id")]
|
||||
public ProbeId Id { get; set; }
|
||||
public NodeId Id { get; set; }
|
||||
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; set; }
|
||||
|
||||
/// <summary>Probe 的远端地址(经过 NAT 之后的公网 IP:端口)。</summary>
|
||||
/// <summary>拨号方的远端地址(经过 NAT 之后的公网 IP:端口)。</summary>
|
||||
[JsonPropertyName("remote")]
|
||||
public string? Remote { get; set; }
|
||||
|
||||
@@ -517,50 +609,7 @@ public sealed class ConnectedProbe
|
||||
public long SinceUtcMs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary><c>fleet.heartbeat</c> 的附加字段:只有进程内的计数,没有主机信息。</summary>
|
||||
public sealed class AgentHeartbeatExtra
|
||||
{
|
||||
/// <summary>Node operating state; registration approval and transport presence are reported separately.</summary>
|
||||
[JsonPropertyName("runtime_status")]
|
||||
public NodeRuntimeStatus RuntimeStatus { get; set; } = NodeRuntimeStatus.Running;
|
||||
|
||||
[JsonPropertyName("ipv6")]
|
||||
public Ipv6Diagnostic? Ipv6 { get; set; }
|
||||
|
||||
/// <summary>当前连着的 Probe 会话数(等于 <see cref="Probes"/> 的个数)。</summary>
|
||||
[JsonPropertyName("sessions")]
|
||||
public int Sessions { get; set; }
|
||||
|
||||
/// <summary>当前连着的 Probe:每次心跳都是完整的快照,Backend 靠它纠正错过的 probe_connected / probe_disconnected 事件。</summary>
|
||||
[JsonPropertyName("probes")]
|
||||
public List<ConnectedProbe> Probes { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("pps_in")]
|
||||
public long PpsIn { get; set; }
|
||||
|
||||
[JsonPropertyName("pps_out")]
|
||||
public long PpsOut { get; set; }
|
||||
|
||||
[JsonPropertyName("dropped_invalid")]
|
||||
public long DroppedInvalid { get; set; }
|
||||
|
||||
[JsonPropertyName("dropped_rate_limited")]
|
||||
public long DroppedRateLimited { get; set; }
|
||||
|
||||
[JsonPropertyName("queue_bytes")]
|
||||
public long QueueBytes { get; set; }
|
||||
|
||||
[JsonPropertyName("update")]
|
||||
public UpdateStatus? Update { get; set; }
|
||||
|
||||
[JsonPropertyName("runtime_logs")]
|
||||
public List<NodeLogRecord>? RuntimeLogs { get; set; }
|
||||
|
||||
[JsonPropertyName("dropped_runtime_logs")]
|
||||
public long? DroppedRuntimeLogs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Agent 下发给 Probe 的一条线路任务(期望状态类型 <c>line_task_set</c> 里的一项)。</summary>
|
||||
/// <summary>监听方下发给拨号方的一条线路任务(期望状态类型 <c>line_task_set</c> 里的一项)。</summary>
|
||||
public sealed class LineTask
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
@@ -580,7 +629,7 @@ public sealed class LineTask
|
||||
public int Port { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>这条线路的期望状态(Agent → Probe):Probe 按它调和自己一侧的角色。</summary>
|
||||
/// <summary>这条线路的期望状态(监听方 → 拨号方):拨号方按它调和自己一侧的角色。</summary>
|
||||
public sealed class LineTaskSet
|
||||
{
|
||||
public const string StateType = "line_task_set";
|
||||
@@ -591,31 +640,28 @@ public sealed class LineTaskSet
|
||||
[JsonPropertyName("tasks")]
|
||||
public List<LineTask> Tasks { get; set; } = [];
|
||||
|
||||
/// <summary>Agent 的数据面端口(Probe 把包发到这里)。</summary>
|
||||
/// <summary>监听方的数据面端口(拨号方把包发到这里)。</summary>
|
||||
[JsonPropertyName("quic_ports")]
|
||||
public List<int> QuicPorts { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("udp_ports")]
|
||||
public List<int> LegacyUdpPorts { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("tcp_ports")]
|
||||
public List<int> TcpPorts { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary><c>moshiro.clock</c>:Probe 用它估计与 Agent 单调时钟的偏移(对称往返假设)。</summary>
|
||||
/// <summary><c>moshiro.clock</c>:拨号方用它估计与监听方单调时钟的偏移(对称往返假设)。</summary>
|
||||
public sealed class ClockParams
|
||||
{
|
||||
[JsonPropertyName("probe_mono_us")]
|
||||
public long ProbeMonoUs { get; set; }
|
||||
[JsonPropertyName("dialer_mono_us")]
|
||||
public long DialerMonoUs { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ClockResult
|
||||
{
|
||||
[JsonPropertyName("agent_mono_us")]
|
||||
public long AgentMonoUs { get; set; }
|
||||
[JsonPropertyName("listener_mono_us")]
|
||||
public long ListenerMonoUs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>按需测试的一个阶段(Agent → Probe):Agent 编排时间,Probe 准备好自己一侧的角色之后应答。</summary>
|
||||
/// <summary>按需测试的一个阶段(监听方 → 拨号方):监听方编排时间,拨号方准备好自己一侧的角色之后应答。</summary>
|
||||
public sealed class TestPhaseStartParams
|
||||
{
|
||||
[JsonPropertyName("test_id")]
|
||||
@@ -646,7 +692,7 @@ public sealed class TestPhaseStartResult
|
||||
public bool Accepted { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>阶段结束(Agent → Probe):Probe 收尾并把自己一侧的列块交给 Agent。<c>sent_count</c> 是 Agent 一侧发出的包数(单向数据流用)。</summary>
|
||||
/// <summary>阶段结束(监听方 → 拨号方):拨号方收尾并把自己一侧的列块交给监听方。<c>sent_count</c> 是监听方一侧发出的包数(单向数据流用)。</summary>
|
||||
public sealed class TestPhaseEndParams
|
||||
{
|
||||
[JsonPropertyName("test_id")]
|
||||
@@ -662,7 +708,7 @@ public sealed class TestPhaseEndParams
|
||||
public long SentCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Probe 收尾之后回复:它交给 Agent 的列块个数(Agent 据此等待全部列块到齐再进入下一阶段)。</summary>
|
||||
/// <summary>拨号方收尾之后回复:它交给监听方的列块个数(监听方据此等待全部列块到齐再进入下一阶段)。</summary>
|
||||
public sealed class TestPhaseEndResult
|
||||
{
|
||||
[JsonPropertyName("fragments")]
|
||||
@@ -675,7 +721,7 @@ public sealed class TestAbortParams
|
||||
public TestId TestId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Probe 一侧的列块放在 RawChunk 头部 params 里的保留键:本块覆盖的序号范围 [from, to)(单向数据流由 Agent 一侧按它取行)。</summary>
|
||||
/// <summary>拨号方一侧的列块放在 RawChunk 头部 params 里的保留键:本块覆盖的序号范围 [from, to)(单向数据流由监听方一侧按它取行)。</summary>
|
||||
public static class FragmentKeys
|
||||
{
|
||||
public const string SeqFrom = "_seq_from";
|
||||
@@ -695,21 +741,19 @@ public static class FragmentKeys
|
||||
[JsonSerializable(typeof(TestRunParams))]
|
||||
[JsonSerializable(typeof(TestRunResult))]
|
||||
[JsonSerializable(typeof(TestCancelParams))]
|
||||
[JsonSerializable(typeof(ProbeDisconnectParams))]
|
||||
[JsonSerializable(typeof(ProbeLinkRetryParams))]
|
||||
[JsonSerializable(typeof(DialerDisconnectParams))]
|
||||
[JsonSerializable(typeof(LinkRetryParams))]
|
||||
[JsonSerializable(typeof(HostSnapshot))]
|
||||
[JsonSerializable(typeof(TestStatusParams))]
|
||||
[JsonSerializable(typeof(AgentEventParams))]
|
||||
[JsonSerializable(typeof(ProbeAgentList))]
|
||||
[JsonSerializable(typeof(DialTargets))]
|
||||
[JsonSerializable(typeof(UpdateOfferParams))]
|
||||
[JsonSerializable(typeof(ScheduleParams))]
|
||||
[JsonSerializable(typeof(UpdateStatus))]
|
||||
[JsonSerializable(typeof(ProbeHeartbeatExtra))]
|
||||
[JsonSerializable(typeof(ProbeHelloMeta))]
|
||||
[JsonSerializable(typeof(AgentEnrollMeta))]
|
||||
[JsonSerializable(typeof(NodeHeartbeatExtra))]
|
||||
[JsonSerializable(typeof(NodeHelloMeta))]
|
||||
[JsonSerializable(typeof(NodeEnrollMeta))]
|
||||
[JsonSerializable(typeof(PlatformInfo))]
|
||||
[JsonSerializable(typeof(AgentHelloMeta))]
|
||||
[JsonSerializable(typeof(Ipv6Diagnostic))]
|
||||
[JsonSerializable(typeof(AgentHeartbeatExtra))]
|
||||
[JsonSerializable(typeof(RawChunkHeader))]
|
||||
public sealed partial class MoShiroJsonContext : JsonSerializerContext;
|
||||
@@ -10,8 +10,8 @@ public enum NodeRuntimeStatus
|
||||
Initializing,
|
||||
[JsonStringEnumMemberName("running")]
|
||||
Running,
|
||||
[JsonStringEnumMemberName("probe_paused")]
|
||||
ProbePaused,
|
||||
[JsonStringEnumMemberName("idle")]
|
||||
Idle,
|
||||
[JsonStringEnumMemberName("maintenance")]
|
||||
Maintenance,
|
||||
[JsonStringEnumMemberName("stopping")]
|
||||
|
||||
@@ -34,10 +34,10 @@ public sealed class TaskSpec
|
||||
|
||||
public sealed class PeerSpec
|
||||
{
|
||||
[JsonPropertyName("probe_id")]
|
||||
public ProbeId ProbeId { get; set; }
|
||||
[JsonPropertyName("node_id")]
|
||||
public NodeId NodeId { get; set; }
|
||||
|
||||
/// <summary>Probe 的身份公钥(SPKI,base64)。</summary>
|
||||
/// <summary>拨号方的身份公钥(SPKI,base64)。</summary>
|
||||
[JsonPropertyName("pubkey")]
|
||||
public string PublicKey { get; set; } = "";
|
||||
}
|
||||
@@ -53,7 +53,7 @@ public sealed class TaskSet
|
||||
[JsonPropertyName("tasks")]
|
||||
public List<TaskSpec> Tasks { get; set; } = [];
|
||||
|
||||
/// <summary>允许连接到这个 Agent 的 Probe 公钥。</summary>
|
||||
/// <summary>允许拨入这个节点的拨号方公钥。</summary>
|
||||
[JsonPropertyName("peers")]
|
||||
public List<PeerSpec> Peers { get; set; } = [];
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ public enum MeasurementDatagramType : byte
|
||||
EchoRequest = 1,
|
||||
EchoReply = 2,
|
||||
|
||||
/// <summary>单向数据(udp_train),以及 Probe 为了保持 NAT 映射和让 Agent 学到源地址而发的探活包。</summary>
|
||||
/// <summary>单向数据(udp_train),以及 拨号方 为了保持 NAT 映射和让 Agent 学到源地址而发的探活包。</summary>
|
||||
Data = 3,
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ using Fleet.Core;
|
||||
namespace MoShiro.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// 数据面的 TCP 连接(Fleet 之外的线格式)。连接都由 Probe 发起;TLS 是可选的(ClientHello 以 0x16 开头,
|
||||
/// 数据面的 TCP 连接(Fleet 之外的线格式)。连接都由 拨号方 发起;TLS 是可选的(ClientHello 以 0x16 开头,
|
||||
/// 明文首帧以 "MN" 开头,所以同一个端口可以区分)。首帧:
|
||||
/// <c>"MN"(2) version(1) session_id(8) stream_id(4) nonce(16) mac(16)</c>,认证失败直接关闭连接;
|
||||
/// 之后是 <c>VarInt 长度 | 正文</c> 的消息,回显类消息的正文是 <c>seq(u32) send_ts_us(i64) | padding</c>。
|
||||
|
||||
@@ -42,7 +42,7 @@ public sealed class Ipv6DiagnosticsTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Probe_DoesNotRequireAnInboundListener_AndUnavailableDataStaysUnknown()
|
||||
public void Dialer_DoesNotRequireAnInboundListener_AndUnavailableDataStaysUnknown()
|
||||
{
|
||||
Assert.Equal("ready", Ipv6Diagnostics.Evaluate(true, null, true, true).State);
|
||||
Assert.Equal("unknown", Ipv6Diagnostics.Evaluate(true, null, null, null).State);
|
||||
|
||||
@@ -64,31 +64,31 @@ public sealed class MeasurementTests
|
||||
public void RowStore_RecordsOnlyItsOwnSidesColumns_AndCutsExistingRowsInKeyOrder()
|
||||
{
|
||||
StreamSpec msg = Kinds.UdpEcho.FindStream("msg")!;
|
||||
var probe = new RowStore(msg, Side.Probe, 64);
|
||||
Assert.Equal(["size", "t1", "t4"], msg.ColumnsOf(Side.Probe).Select(c => c.Name));
|
||||
Assert.Equal(["t2", "t3"], msg.ColumnsOf(Side.Agent).Select(c => c.Name));
|
||||
var dialer = new RowStore(msg, Side.Dialer, 64);
|
||||
Assert.Equal(["size", "t1", "t4"], msg.ColumnsOf(Side.Dialer).Select(c => c.Name));
|
||||
Assert.Equal(["t2", "t3"], msg.ColumnsOf(Side.Listener).Select(c => c.Name));
|
||||
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
long key = probe.Append();
|
||||
probe.Set(key, probe.ColumnIndex("size"), 64);
|
||||
probe.Set(key, probe.ColumnIndex("t1"), 1000 + i);
|
||||
long key = dialer.Append();
|
||||
dialer.Set(key, dialer.ColumnIndex("size"), 64);
|
||||
dialer.Set(key, dialer.ColumnIndex("t1"), 1000 + i);
|
||||
}
|
||||
|
||||
probe.Set(2, probe.ColumnIndex("t4"), 5000);
|
||||
RawStream cut = probe.Cut(1, 4, includeKey: true);
|
||||
dialer.Set(2, dialer.ColumnIndex("t4"), 5000);
|
||||
RawStream cut = dialer.Cut(1, 4, includeKey: true);
|
||||
Assert.Equal(3, cut.Count);
|
||||
Assert.Equal(["seq", "size", "t1", "t4"], cut.Columns.Select(c => c.Name));
|
||||
Assert.Equal([1L, 2L, 3L], cut.Find("seq")!.Values);
|
||||
Assert.Equal([false, true, false], cut.Find("t4")!.Valid!);
|
||||
|
||||
Assert.Throws<ArgumentException>(() => probe.ColumnIndex("t2"));
|
||||
Assert.Throws<ArgumentException>(() => dialer.ColumnIndex("t2"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RowStore_RingOverwritesOldRows_AndNeverReturnsAStaleOne()
|
||||
{
|
||||
var store = new RowStore(Kinds.UdpEcho.FindStream("msg")!, Side.Agent, 16);
|
||||
var store = new RowStore(Kinds.UdpEcho.FindStream("msg")!, Side.Listener, 16);
|
||||
store.SetRow(3, [30, 31]);
|
||||
Assert.True(store.TryGet(3, 0, out long t2));
|
||||
Assert.Equal(30, t2);
|
||||
@@ -111,19 +111,19 @@ public sealed class MeasurementTests
|
||||
|
||||
private static RawChunk EchoFragment(params (uint Seq, long T1, long? T4)[] rows)
|
||||
{
|
||||
var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.TsProbe("t1"), Col.TsProbe("t4", nullable: true)]));
|
||||
var msg = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq"), Col.U16("size"), Col.TsDialer("t1"), Col.TsDialer("t4", nullable: true)]));
|
||||
foreach ((uint seq, long t1, long? t4) in rows)
|
||||
{
|
||||
msg.Add(seq, 64, t1, t4);
|
||||
}
|
||||
|
||||
return new RawChunk(new RawChunkHeader { Kind = "udp_echo", Clock = new ChunkClock { ProbeOffsetUs = 1000 } }, [msg.Build()]);
|
||||
return new RawChunk(new RawChunkHeader { Kind = "udp_echo", Clock = new ChunkClock { DialerOffsetUs = 1000 } }, [msg.Build()]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FragmentMerger_ProbeDriven_AddsTheAgentColumnsByKey_AndLeavesMissingRowsEmpty()
|
||||
public void FragmentMerger_DialerDriven_AddsTheAgentColumnsByKey_AndLeavesMissingRowsEmpty()
|
||||
{
|
||||
var agent = new RowStore(Kinds.UdpEcho.FindStream("msg")!, Side.Agent, 64);
|
||||
var agent = new RowStore(Kinds.UdpEcho.FindStream("msg")!, Side.Listener, 64);
|
||||
agent.SetRow(0, [2000, 2050]);
|
||||
agent.SetRow(2, [4000, 4040]);
|
||||
var source = new Source { Stores = { ["msg"] = agent } };
|
||||
@@ -138,7 +138,7 @@ public sealed class MeasurementTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FragmentMerger_ProbeDriven_WithoutAnyAgentRecords_KeepsTheProbeRows()
|
||||
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);
|
||||
@@ -148,7 +148,7 @@ public sealed class MeasurementTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FragmentMerger_RefusesAFragmentWithoutTheProbeColumns()
|
||||
public void FragmentMerger_RefusesAFragmentWithoutTheDialerColumns()
|
||||
{
|
||||
var bad = new StreamBuilder(new StreamSpec("msg", [Col.Seq("seq")]));
|
||||
bad.Add(0);
|
||||
@@ -159,13 +159,13 @@ public sealed class MeasurementTests
|
||||
[Fact]
|
||||
public void FragmentMerger_AgentDriven_TakesRowsFromTheAgent_AndMarksUnreceivedPacketsAsLost()
|
||||
{
|
||||
var agent = new RowStore(Kinds.UdpTrain.FindStream("pkt")!, Side.Agent, 64);
|
||||
var agent = new RowStore(Kinds.UdpTrain.FindStream("pkt")!, Side.Listener, 64);
|
||||
for (uint seq = 0; seq < 5; seq++)
|
||||
{
|
||||
agent.SetRow(seq, [1000, 100 + seq]);
|
||||
}
|
||||
|
||||
var pkt = new StreamBuilder(new StreamSpec("pkt", [Col.Seq("seq"), Col.TsProbe("t_recv")]));
|
||||
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" };
|
||||
header.Params[FragmentKeys.SeqFrom] = Json("0");
|
||||
@@ -187,7 +187,7 @@ public sealed class MeasurementTests
|
||||
[Fact]
|
||||
public void FragmentMerger_StreamsWithoutAgentColumns_PassThroughInDeclaredOrder()
|
||||
{
|
||||
var conn = new StreamBuilder(new StreamSpec("conn", [Col.U8("outcome"), Col.Seq("attempt"), Col.TsProbe("ts")]));
|
||||
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()]);
|
||||
|
||||
@@ -213,8 +213,8 @@ public sealed class MeasurementTests
|
||||
public void FragmentMerger_UnionStreams_ConcatenateBothSidesRows_AndEachAgentRowIsDeliveredOnce()
|
||||
{
|
||||
StreamSpec info = Kinds.TcpEcho.FindStream("tcp_info")!;
|
||||
Assert.Equal(info.Columns.Count, info.ColumnsOf(Side.Agent).Count); // each side owns every column
|
||||
var agent = new RowStore(info, Side.Agent, 64);
|
||||
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 } };
|
||||
agent.SetRow(agent.Append(), [500, 1, 40_000, 5_000, 10, 7, 100, 1_000, 1]);
|
||||
agent.SetRow(agent.Append(), [600, 1, 40_000, 5_000, 10, 8, 120, 1_100, 1]);
|
||||
@@ -230,19 +230,19 @@ public sealed class MeasurementTests
|
||||
Assert.False(merged.Find("conn")!.IsValid(0));
|
||||
Assert.Equal(1, merged.Find("conn")!.Values[1]);
|
||||
|
||||
// The agent's rows were consumed: the next chunk carries only the probe's own rows
|
||||
// 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;
|
||||
Assert.Equal(1, second.Single(x => x.Name == "tcp_info").Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FragmentMerger_UnionStreams_AgentRowsAreDeliveredEvenWhenTheProbeHasNone()
|
||||
public void FragmentMerger_UnionStreams_AgentRowsAreDeliveredEvenWhenTheDialerHasNone()
|
||||
{
|
||||
StreamSpec info = Kinds.TcpEcho.FindStream("tcp_info")!;
|
||||
var agent = new RowStore(info, Side.Agent, 64);
|
||||
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 probe on a platform without kernel sampling sends the echo rows only
|
||||
// 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;
|
||||
RawStream rows = merged.Single(x => x.Name == "tcp_info");
|
||||
@@ -353,12 +353,12 @@ public sealed class MeasurementTests
|
||||
continue;
|
||||
}
|
||||
|
||||
Assert.Contains(pair.Kind, BlockCatalog.ProbeKinds);
|
||||
Assert.Contains(pair.Kind, BlockCatalog.DialerKinds);
|
||||
BlockSpec spec = new(kind, ParamResolver.Resolve(kind.Params, null).Values!, 1, 7443, new FragmentScope(null, null, null));
|
||||
IBlock listener = pair.Listener(env, spec);
|
||||
|
||||
// 声明了连接键、Agent 一侧有列的流:监听方必须为它保留按键记录,否则合并不出完整的行
|
||||
foreach (StreamSpec stream in kind.Streams.Where(s => s.Key is not null && s.ColumnsOf(Side.Agent).Count > 0))
|
||||
foreach (StreamSpec stream in kind.Streams.Where(s => s.Key is not null && s.ColumnsOf(Side.Listener).Count > 0))
|
||||
{
|
||||
Assert.NotNull(listener.Records(stream.Name));
|
||||
}
|
||||
@@ -389,18 +389,18 @@ public sealed class MeasurementTests
|
||||
[Fact]
|
||||
public void ChunkFinalizer_TakesStartAndDurationFromTheDataTimestamps_NotFromTheDeliveryTime()
|
||||
{
|
||||
var agent = new RowStore(Kinds.UdpEcho.FindStream("msg")!, Side.Agent, 64);
|
||||
var agent = new RowStore(Kinds.UdpEcho.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;
|
||||
|
||||
var anchor = new ClockAnchor(AgentMonoUs: 1_000_000, UtcMs: 1_700_000_000_000);
|
||||
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()));
|
||||
|
||||
// t1 = 1000 us (Probe clock) + offset 1000 = 2000 us on the Agent clock, which is 998 ms before the anchor
|
||||
// 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);
|
||||
Assert.Equal(7443, chunk.Header.Port);
|
||||
Assert.False(chunk.Header.Partial);
|
||||
Assert.Equal(1_000_000, chunk.Header.Clock.AgentMonoUs);
|
||||
Assert.Equal(1_000_000, chunk.Header.Clock.ListenerMonoUs);
|
||||
}
|
||||
}
|
||||
@@ -29,8 +29,11 @@ public sealed class MsQuicConnectionTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DatagramConnection_CanSendCloseAndReconnect_WithoutBlockingNativeWorkers()
|
||||
[Theory]
|
||||
[InlineData("127.0.0.1", "127.0.0.1")]
|
||||
[InlineData("::", "127.0.0.1")]
|
||||
[InlineData("::", "::1")]
|
||||
public async Task DatagramConnection_CanSendCloseAndReconnect_WithoutBlockingNativeWorkers(string bind, string peer)
|
||||
{
|
||||
var log = new CaptureLog();
|
||||
var counters = new DataPlaneCounters();
|
||||
@@ -39,7 +42,7 @@ public sealed class MsQuicConnectionTests
|
||||
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,
|
||||
await using var listener = new MsQuicListener(IPAddress.Parse(bind), [0], certificate,
|
||||
(connection, data) =>
|
||||
{
|
||||
// Bad measurement payloads must be counted without flooding logs.
|
||||
@@ -55,7 +58,7 @@ public sealed class MsQuicConnectionTests
|
||||
{
|
||||
var reply = new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
MsQuicConnection client = await MsQuicConnection.ConnectAsync(IPAddress.Loopback, listener.BoundPorts[0], 0,
|
||||
MsQuicConnection client = await MsQuicConnection.ConnectAsync(IPAddress.Parse(peer), listener.BoundPorts[0], 0,
|
||||
key.ExportSubjectPublicKeyInfo(), (_, data) => reply.TrySetResult(data.ToArray()), log, timeout.Token);
|
||||
try
|
||||
{
|
||||
|
||||
@@ -48,7 +48,7 @@ public sealed class RestartUpdaterTests
|
||||
RestartUpdater updater = Create(builtAt: 5_000);
|
||||
updater.Offer(Offer("dev-bbb", 2_000));
|
||||
Assert.Equal("up_to_date", updater.Status().State);
|
||||
updater.Offer(Offer("dev-ccc", 9_000, component: "probe"));
|
||||
updater.Offer(Offer("dev-ccc", 9_000, component: "backend"));
|
||||
Assert.Equal("up_to_date", updater.Status().State);
|
||||
Assert.False(updater.RequestApply());
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ public sealed class CatalogTests
|
||||
{
|
||||
Version = 7,
|
||||
Tasks = [new TaskSpec { Id = new TaskId("tsk_1"), Kind = "udp_echo", Params = new Dictionary<string, JsonElement> { ["rate_hz"] = Json("10") } }],
|
||||
Peers = [new PeerSpec { ProbeId = new ProbeId("prb_1"), PublicKey = "AAAA" }],
|
||||
Peers = [new PeerSpec { NodeId = new Fleet.Core.NodeId("agt_1"), PublicKey = "AAAA" }],
|
||||
};
|
||||
string text = JsonSerializer.Serialize(set, MoShiroJsonContext.Default.TaskSet);
|
||||
TaskSet back = JsonSerializer.Deserialize(text, MoShiroJsonContext.Default.TaskSet)!;
|
||||
@@ -108,14 +108,14 @@ public sealed class CatalogTests
|
||||
Assert.Equal(7, back.Version);
|
||||
Assert.Equal("tsk_1", back.Tasks[0].Id.Value);
|
||||
Assert.Equal(10, back.Tasks[0].Params["rate_hz"].GetInt64());
|
||||
Assert.Equal("prb_1", back.Peers[0].ProbeId.Value);
|
||||
Assert.Equal("agt_1", back.Peers[0].NodeId.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgentEnrollMeta_ToleratesUnknownFields_AndKeepsDefaults()
|
||||
public void NodeEnrollMeta_ToleratesUnknownFields_AndKeepsDefaults()
|
||||
{
|
||||
AgentEnrollMeta meta = JsonSerializer.Deserialize("{\"kinds\":[\"udp_echo\"],\"future\":1}", MoShiroJsonContext.Default.AgentEnrollMeta)!;
|
||||
Assert.Equal(7443, meta.ProbeTcpPort);
|
||||
NodeEnrollMeta meta = JsonSerializer.Deserialize("{\"kinds\":[\"udp_echo\"],\"future\":1}", MoShiroJsonContext.Default.NodeEnrollMeta)!;
|
||||
Assert.Equal(0, meta.TcpPort); // 没有端口表示这个节点不监听
|
||||
Assert.Equal(["udp_echo"], meta.Kinds);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@ public sealed class NodeRuntimeStatusTests
|
||||
[Fact]
|
||||
public void HeartbeatStatus_UsesStableSnakeCaseEnumValues()
|
||||
{
|
||||
var heartbeat = new AgentHeartbeatExtra { RuntimeStatus = NodeRuntimeStatus.ProbePaused };
|
||||
string json = JsonSerializer.Serialize(heartbeat, MoShiroJsonContext.Default.AgentHeartbeatExtra);
|
||||
var heartbeat = new NodeHeartbeatExtra { RuntimeStatus = NodeRuntimeStatus.Idle };
|
||||
string json = JsonSerializer.Serialize(heartbeat, MoShiroJsonContext.Default.NodeHeartbeatExtra);
|
||||
|
||||
Assert.Contains("\"runtime_status\":\"probe_paused\"", json, StringComparison.Ordinal);
|
||||
Assert.Equal(NodeRuntimeStatus.ProbePaused,
|
||||
JsonSerializer.Deserialize(json, MoShiroJsonContext.Default.AgentHeartbeatExtra)!.RuntimeStatus);
|
||||
Assert.Contains("\"runtime_status\":\"idle\"", json, StringComparison.Ordinal);
|
||||
Assert.Equal(NodeRuntimeStatus.Idle,
|
||||
JsonSerializer.Deserialize(json, MoShiroJsonContext.Default.NodeHeartbeatExtra)!.RuntimeStatus);
|
||||
}
|
||||
}
|
||||
@@ -9,17 +9,17 @@ namespace MoShiro.Protocol.Tests;
|
||||
public sealed class RawChunkCodecTests
|
||||
{
|
||||
[Fact]
|
||||
public void Header_LegacyDefaultsToIpv4_AndIpv6RoundTripsToAnIndependentSeries()
|
||||
public void Header_DefaultsToIpv4_AndIpv6RoundTripsToAnIndependentSeries()
|
||||
{
|
||||
RawChunkHeader legacy = System.Text.Json.JsonSerializer.Deserialize("{\"probe_id\":\"prb_legacy\"}", MoShiroJsonContext.Default.RawChunkHeader)!;
|
||||
RawChunkHeader legacy = System.Text.Json.JsonSerializer.Deserialize("{\"dialer_id\":\"agt_legacy\"}", MoShiroJsonContext.Default.RawChunkHeader)!;
|
||||
Assert.Equal(IpVersions.V4, legacy.IpVersion);
|
||||
Assert.Equal("line:prb_legacy", legacy.Series);
|
||||
Assert.Equal("line:agt_legacy", legacy.Series);
|
||||
RawChunk chunk = SampleChunk();
|
||||
chunk.Header.ProbeId = new ProbeId("prb_dual");
|
||||
chunk.Header.DialerId = new Fleet.Core.NodeId("agt_dual");
|
||||
chunk.Header.IpVersion = IpVersions.V6;
|
||||
RawChunk decoded = RawChunkCodec.Decode(RawChunkCodec.Encode(chunk)).Value;
|
||||
Assert.Equal("ipv6", decoded.Header.IpVersion);
|
||||
Assert.Equal("line:prb_dual:ipv6", decoded.Header.Series);
|
||||
Assert.Equal("line:agt_dual:ipv6", decoded.Header.Series);
|
||||
Assert.Equal(chunk.Find("msg")!.Count, decoded.Find("msg")!.Count);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ public sealed class RawChunkCodecTests
|
||||
|
||||
Assert.Equal(original.Header.ChunkId, decoded.Header.ChunkId);
|
||||
Assert.Equal("udp_echo", decoded.Header.Kind);
|
||||
Assert.Equal(original.Header.Clock.ProbeOffsetUs, decoded.Header.Clock.ProbeOffsetUs);
|
||||
Assert.Equal(original.Header.Clock.DialerOffsetUs, decoded.Header.Clock.DialerOffsetUs);
|
||||
RawStream before = original.Find("msg")!;
|
||||
RawStream after = decoded.Find("msg")!;
|
||||
Assert.Equal(before.Count, after.Count);
|
||||
@@ -93,7 +93,7 @@ public sealed class RawChunkCodecTests
|
||||
{
|
||||
// 布局变化(列编码、头部字段、字节序)会让这个测试失败:必须同时升级 container_version 并更新黄金字节。
|
||||
RawChunk chunk = SampleChunk();
|
||||
chunk.Header.Clock = new ChunkClock { AgentMonoUs = 1000, UtcMs = 1_700_000_000_000, ProbeOffsetUs = 500 };
|
||||
chunk.Header.Clock = new ChunkClock { ListenerMonoUs = 1000, UtcMs = 1_700_000_000_000, DialerOffsetUs = 500 };
|
||||
byte[] body = RawChunkCodec.EncodeBody(chunk);
|
||||
uint headerLength = BinaryPrimitives.ReadUInt32LittleEndian(body);
|
||||
string columns = Convert.ToHexStringLower(body.AsSpan(4 + (int)headerLength));
|
||||
|
||||
Reference in new issue
Block a user