chore: initial project import

This commit is contained in:
justme committed 2026-10-04 21:46:39 +08:00
commit 3fc4e5ca53
347 files changed
+56172

No files matched your search

@@ -0,0 +1,105 @@
using System.Net;
using Monitor.Protocol;
namespace Monitor.Measurement;
/// <summary>积木的公共部分:环境、参数、把行切成列块交出去。</summary>
public abstract class BlockBase : IBlock
{
protected BlockBase(BlockEnv env, BlockSpec spec)
{
Env = env;
Spec = spec;
}
public virtual long Produced => 0;
protected BlockEnv Env { get; }
protected BlockSpec Spec { get; }
public abstract Task RunAsync(CancellationToken ct);
public virtual int Flush(FlushMode mode, long peerProduced = 0) => 0;
public virtual RowStore? Records(string streamName) => null;
protected IPEndPoint PeerEndpoint(int port) => new(Env.PeerAddress!, port);
/// <summary>交出一个流的行;起点和时长取自时间列 <paramref name="timeColumn"/> 的首尾(本侧的单调时钟,微秒)。</summary>
protected void Ship(RawStream stream, string timeColumn, IReadOnlyList<RawStream>? extra = null, IReadOnlyDictionary<string, System.Text.Json.JsonElement>? parameters = null)
{
RawColumn? time = stream.Find(timeColumn);
long first = stream.Count > 0 && time is not null ? time.Values[0] : Env.Clock.NowUs();
long last = stream.Count > 0 && time is not null ? time.Values[stream.Count - 1] : first;
List<RawStream> streams = [stream];
if (extra is not null)
{
streams.AddRange(extra);
}
Env.Fragments!.Ship(Spec.KindId, Spec.Scope, streams, parameters ?? Spec.Params.Values, Env.Clock.UtcMsAt(first), (int)((last - first) / 1000), Spec.Port);
}
/// <summary>什么也不做、一直等到取消(监听方里不需要自己运行逻辑的积木)。</summary>
protected async Task WaitUntilCancelledAsync(CancellationToken ct)
{
try
{
await Task.Delay(Timeout.InfiniteTimeSpan, Env.Time, ct);
}
catch (OperationCanceledException)
{
// 被停止
}
}
}
/// <summary>
/// 拨号方作为行的主导方时记录的行:行按键(序号)追加,回复到达时按键回填列,到点把已经稳定的行从游标处切下来交出去。
/// 环形缓冲被覆盖的行(交出太慢)直接跳过。
/// </summary>
internal sealed class DriverLog
{
private readonly object _gate = new();
private long _cursor;
public DriverLog(StreamSpec spec, Side driver, int capacity)
{
Store = new RowStore(spec, driver, capacity);
Spec = spec;
}
public RowStore Store { get; }
public StreamSpec Spec { get; }
public long Count => Store.NextKey;
/// <summary>取出游标之后已经稳定的行:<paramref name="settled"/> 为 null 或 <paramref name="all"/> 时取全部。</summary>
public RawStream? Take(Func<long, bool>? settled, bool all, int maxRows = int.MaxValue)
{
long from;
long to;
lock (_gate)
{
from = _cursor;
to = from;
long end = Store.NextKey;
while (to < end && to - from < maxRows && (all || settled is null || !Store.Contains(to) || settled(to)))
{
to++;
}
if (to == from)
{
return null;
}
_cursor = to;
}
RawStream stream = Store.Cut(from, to, includeKey: Spec.Key is not null);
return stream.Count == 0 ? null : stream;
}
}
@@ -0,0 +1,45 @@
using Monitor.Protocol;
namespace Monitor.Measurement;
/// <summary>
/// 一种采集方法(Kind)的两个角色:拨号方(Probe)和监听方(Agent),以及它能不能作为目标任务(Agent 自己探测一个地址,没有对端)运行。
/// 积木只按机制命名、只产生原始列,不知道上层的方案或协议。
/// </summary>
public sealed record BlockPair(
string Kind,
Func<BlockEnv, BlockSpec, IBlock> Dialer,
Func<BlockEnv, BlockSpec, IBlock>? Listener,
bool RunsAsTarget = false,
bool OnDemandOnly = false,
IReadOnlyList<string>? Directions = null)
{
/// <summary>这个 Kind 支持 <paramref name="direction"/> 吗(没有 <c>direction</c> 参数的 Kind 传 null)。</summary>
public bool Supports(string? direction) => Directions is null || direction is null || Directions.Contains(direction);
}
/// <summary>
/// 积木目录:新增一种采集方法 = 在 <see cref="Kinds"/> 里加一条声明,在这里加一行,实现它的两个角色。Probe 和 Agent 同时获得。
/// hello 里上报的 capabilities、本地预算检查的支持列表都从这里推出来,不再手写。
/// </summary>
public static class BlockCatalog
{
public static readonly IReadOnlyList<BlockPair> All =
[
new("udp_echo", (e, s) => new UdpEchoDialer(e, s), (e, s) => new UdpEchoListener(e, s)),
new("tcp_echo", (e, s) => new TcpEchoDialer(e, s), (e, s) => new TcpEchoListener(e, s)),
new("udp_train", (e, s) => new TrainDialer(e, s), (e, s) => new TrainListener(e, s), OnDemandOnly: true, Directions: ["rev"]),
new("tcp_bulk", (e, s) => new TcpBulkDialer(e, s), (e, s) => new TcpBulkListener(e, s), OnDemandOnly: true, Directions: ["fwd", "rev"]),
new("connect", (e, s) => new ConnectDialer(e, s), (e, s) => new IdleListener(e, s), RunsAsTarget: true),
new("hold", (e, s) => new HoldDialer(e, s), (e, s) => new TcpEchoListener(e, s, record: false)),
new("icmp_echo", (e, s) => new IcmpEchoSolo(e, s), null, RunsAsTarget: true),
];
public static BlockPair? Find(string kind) => All.FirstOrDefault(p => p.Kind == kind);
/// <summary>这个构建的 Agent 能运行的 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)];
}
@@ -0,0 +1,277 @@
using System.Net.Sockets;
using Monitor.Protocol;
namespace Monitor.Measurement;
/// <summary>批量传输的收发循环(两端共用)。</summary>
internal static class BulkIo
{
private const int ChunkBytes = 16 * 1024;
/// <summary>
/// 发送:尽可能快,但不超过 <paramref name="mbps"/>(本地硬上限,防止把小机器的 CPU 和链路打满)。
/// 发送的数据是随机字节(避免被链路上的压缩或重复数据消除"优化"掉)。
/// </summary>
public static async Task SendAsync(Stream stream, double mbps, Action<int> onBytes, TimeProvider time, CancellationToken ct)
{
byte[] buffer = new byte[ChunkBytes];
Random.Shared.NextBytes(buffer);
double bytesPerSecond = mbps * 1_000_000 / 8;
long start = time.GetTimestamp();
long sent = 0;
while (!ct.IsCancellationRequested)
{
await stream.WriteAsync(buffer, ct);
sent += buffer.Length;
onBytes(buffer.Length);
if (bytesPerSecond > 0)
{
double ahead = (sent / bytesPerSecond) - time.GetElapsedTime(start).TotalSeconds;
if (ahead > 0.002)
{
await Task.Delay(TimeSpan.FromSeconds(Math.Min(ahead, 0.05)), time, ct);
}
}
}
}
/// <summary>接收并丢弃,直到对端关闭或取消。</summary>
public static async Task ReceiveAsync(Stream stream, Action<int> onBytes, CancellationToken ct)
{
byte[] buffer = new byte[4 * ChunkBytes];
while (!ct.IsCancellationRequested)
{
int read = await stream.ReadAsync(buffer, ct);
if (read <= 0)
{
return;
}
onBytes(read);
}
}
public static bool IsExpected(Exception ex) =>
ex is OperationCanceledException or IOException or SocketException or ObjectDisposedException or System.Security.Authentication.AuthenticationException or InvalidOperationException;
}
/// <summary>
/// 批量传输,拨号方(Probe):开 <c>streams</c> 条 TCP 连接(可选 TLS),按 <c>direction</c> 发送(fwd)或接收(rev),持续 <c>duration_s</c>。
/// 记录每个采样间隔每条连接的<b>累计字节数</b>(<c>bytes</c> 流,Probe 的时钟),Backend 据此求每个间隔的吞吐;
/// 同时采样第一条连接自己这一侧的内核 <c>tcp_info</c>(多条连接的累计计数器不能混在同一个序列里求差)。
/// 发送速率不超过本地上限 <see cref="BlockEnv.MaxBulkMbps"/>。
/// </summary>
public sealed class TcpBulkDialer : BlockBase
{
private const int MaxRowsPerFragment = 40_000;
private readonly DriverLog _log;
private readonly TcpInfoRecorder _info;
private readonly int _ts;
private readonly int _conn;
private readonly int _bytes;
private long[] _counters = [];
public TcpBulkDialer(BlockEnv env, BlockSpec spec)
: 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);
_ts = _log.Store.ColumnIndex("ts");
_conn = _log.Store.ColumnIndex("conn");
_bytes = _log.Store.ColumnIndex("bytes");
}
public override long Produced => _log.Count;
public override async Task RunAsync(CancellationToken ct)
{
int streams = (int)Spec.Int("streams");
bool send = Spec.Params.GetString("direction") == "fwd";
TimeSpan duration = TimeSpan.FromSeconds(Spec.Int("duration_s"));
TimeSpan sampleEvery = TimeSpan.FromMilliseconds(Spec.Int("sample_ms"));
_counters = new long[streams];
var links = new TcpLink?[streams];
try
{
await Task.WhenAll(Enumerable.Range(0, streams).Select(async i =>
{
try
{
links[i] = await TcpLink.OpenAsync(Env, Spec.Port, Spec.Bool("tls"), Spec.StreamId, sendAuth: true, ct);
}
catch (Exception ex) when (BulkIo.IsExpected(ex))
{
// 这条连接没有建起来:它的字节数一直是 0
}
}));
using var timeout = new CancellationTokenSource(duration, Env.Time);
using var run = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
var work = new List<Task>();
double perStream = Env.MaxBulkMbps / streams;
for (int i = 0; i < streams; i++)
{
if (links[i] is not { } link)
{
continue;
}
int index = i;
work.Add(RunConnectionAsync(link.Stream, index, send, perStream, run.Token));
}
if (links.FirstOrDefault(l => l is not null) is { } first)
{
work.Add(_info.RunAsync(first.Socket, sampleEvery, run.Token));
}
Task sampler = SampleAsync(sampleEvery, run.Token);
await Task.WhenAll(work);
await run.CancelAsync();
await sampler;
Sample(); // 最后一个采样点
}
finally
{
foreach (TcpLink? link in links)
{
link?.Dispose();
}
}
}
public override int Flush(FlushMode mode, long peerProduced = 0)
{
int fragments = 0;
while (_log.Take(null, all: true, MaxRowsPerFragment) is { } stream)
{
RawStream kernel = _info.Rows.Drain(includeKey: false);
Ship(stream, "ts", kernel.Count > 0 ? [kernel] : null);
fragments++;
}
if (fragments == 0 && _info.Rows.Drain(includeKey: false) is { Count: > 0 } onlyKernel)
{
Ship(onlyKernel, "ts");
fragments++;
}
return fragments;
}
private async Task RunConnectionAsync(Stream stream, int index, bool send, double mbps, CancellationToken ct)
{
try
{
if (send)
{
await BulkIo.SendAsync(stream, mbps, n => Interlocked.Add(ref _counters[index], n), Env.Time, ct);
}
else
{
await BulkIo.ReceiveAsync(stream, n => Interlocked.Add(ref _counters[index], n), ct);
}
}
catch (Exception ex) when (BulkIo.IsExpected(ex))
{
// 时间到或者连接断开:计数停在这里
}
}
private async Task SampleAsync(TimeSpan every, CancellationToken ct)
{
using var timer = new PeriodicTimer(every, Env.Time);
try
{
while (await timer.WaitForNextTickAsync(ct))
{
Sample();
}
}
catch (OperationCanceledException)
{
// 阶段结束
}
}
private void Sample()
{
long now = Env.Clock.NowUs();
for (int i = 0; i < _counters.Length; i++)
{
long key = _log.Store.Append();
_log.Store.Set(key, _ts, now);
_log.Store.Set(key, _conn, i);
_log.Store.Set(key, _bytes, Interlocked.Read(ref _counters[i]));
}
}
}
/// <summary>
/// 批量传输,监听方(Agent):接受连接,<c>rev</c> 时向 Probe 发送(受本地码率上限约束),<c>fwd</c> 时接收并丢弃;
/// 采样第一条连接自己这一侧的内核 <c>tcp_info</c>(Agent 的重传反映下行方向,代理场景里大流量走这个方向)。
/// 连接在 <c>duration_s</c> 之后(加几秒余量)或阶段结束时关闭。
/// </summary>
public sealed class TcpBulkListener : BlockBase
{
private readonly TcpInfoRecorder _info;
private CancellationToken _stop;
private int _sampled;
public TcpBulkListener(BlockEnv env, BlockSpec spec)
: base(env, spec)
{
_info = new TcpInfoRecorder(spec.Kind.FindStream("tcp_info")!, Side.Agent, env.Clock);
}
public override RowStore? Records(string streamName) => streamName == "tcp_info" ? _info.Rows : null;
public override async Task RunAsync(CancellationToken ct)
{
_stop = ct;
Env.TcpListen!.Register(Spec.StreamId, HandleAsync);
try
{
await WaitUntilCancelledAsync(ct);
}
finally
{
Env.TcpListen.Unregister(Spec.StreamId);
}
}
private async Task HandleAsync(TcpConnection connection, CancellationToken acceptorToken)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(Spec.Int("duration_s") + 3), Env.Time);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(acceptorToken, _stop, timeout.Token);
Task sampler = Task.CompletedTask;
if (Interlocked.CompareExchange(ref _sampled, 1, 0) == 0)
{
sampler = _info.RunAsync(connection.Socket, TimeSpan.FromMilliseconds(Spec.Int("sample_ms")), linked.Token);
}
try
{
if (Spec.Params.GetString("direction") == "rev")
{
await BulkIo.SendAsync(connection.Stream, Env.MaxBulkMbps / Math.Max(1, Spec.Int("streams")), _ => Env.Counters.PacketOut(), Env.Time, linked.Token);
}
else
{
await BulkIo.ReceiveAsync(connection.Stream, _ => Env.Counters.PacketIn(), linked.Token);
}
}
catch (Exception ex) when (BulkIo.IsExpected(ex))
{
// 时间到、阶段结束或对端关闭
}
finally
{
await linked.CancelAsync();
await sampler;
}
}
}
@@ -0,0 +1,383 @@
using System.Net;
using System.Net.Sockets;
using Monitor.Protocol;
namespace Monitor.Measurement;
/// <summary>
/// 回显积木的行表(拨号方,行的主导方):发出时记 size、t1,回复到达时按序号回填 t4,到点把已经稳定的行切成列块。
/// UDP 回显和 TCP 回显共用,列由 Kind 的 <c>msg</c> 流声明决定。
/// </summary>
internal sealed class EchoRows
{
/// <summary>UDP:没有在这段时间内回来的包就是丢了。</summary>
public const long UdpGraceUs = 2_000_000;
/// <summary>
/// TCP 不会丢包,只会延迟(重传、队头阻塞):一次几秒的中断之后,积压的回显会陆续回来,RTT 就是那几秒。
/// 如果像 UDP 那样等 2 秒就把没有回复的行当作丢失交出去,最严重的长尾反而成了"丢包",RTT 里一点也看不到。
/// 所以 TCP 的行要等回复(或者连接真的断了——30 秒之后仍然没有回复才交出,t4 缺失)。
/// </summary>
public const long TcpGraceUs = 30_000_000;
private readonly DriverLog _log;
private readonly int _size;
private readonly int _t1;
private readonly int _t4;
private readonly long _graceUs;
private readonly bool _settleOnReply;
public EchoRows(KindSpec kind, int capacity, long graceUs, bool settleOnReply)
{
_graceUs = graceUs;
_settleOnReply = settleOnReply;
StreamSpec spec = kind.FindStream("msg")!;
_log = new DriverLog(spec, Side.Probe, capacity);
_size = _log.Store.ColumnIndex("size");
_t1 = _log.Store.ColumnIndex("t1");
_t4 = _log.Store.ColumnIndex("t4");
}
public long Count => _log.Count;
public uint Add(int size, long t1Us)
{
long key = _log.Store.Append();
_log.Store.Set(key, _size, size);
_log.Store.Set(key, _t1, t1Us);
return (uint)key;
}
public void Reply(uint seq, long t4Us)
{
if (_log.Store.Contains(seq) && !_log.Store.IsSet(seq, _t4))
{
_log.Store.Set(seq, _t4, t4Us);
}
}
public RawStream? Take(long nowUs, bool all) =>
_log.Take(
key => (_settleOnReply && _log.Store.IsSet(key, _t4)) || (_log.Store.TryGet(key, _t1, out long t1) && t1 <= nowUs - _graceUs),
all);
}
/// <summary>
/// UDP 回显,拨号方(Probe):按声明的速率发包,t1 在发出时记录,回复到达时回填 t4。回复丢失的行 t4 缺失(Agent 一侧再补 t2/t3)。
/// </summary>
public sealed class UdpEchoDialer : BlockBase
{
private readonly EchoRows _rows;
public UdpEchoDialer(BlockEnv env, BlockSpec spec)
: base(env, spec)
{
_rows = new EchoRows(spec.Kind, 16384, EchoRows.UdpGraceUs, settleOnReply: false);
}
public override long Produced => _rows.Count;
public override async Task RunAsync(CancellationToken ct)
{
UdpMux udp = Env.Udp!;
DataSession session = Env.Session() ?? throw new BlockFaultException("no data session");
IPEndPoint target = PeerEndpoint(Spec.Port);
int size = (int)Spec.Int("size");
byte[] packet = new byte[size];
udp.Register(Spec.StreamId, OnPacket);
try
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1.0 / Spec.Int("rate_hz")), Env.Time);
while (await timer.WaitForNextTickAsync(ct))
{
long t1 = Env.Clock.NowUs();
uint seq = _rows.Add(size, t1);
UdpPacket.Write(packet, new UdpHeader(UdpPacketType.EchoRequest, session.SessionId, Spec.StreamId, seq, t1), session.Key);
udp.Send(target, packet);
}
}
catch (OperationCanceledException)
{
// 积木被停止
}
finally
{
udp.Unregister(Spec.StreamId);
}
}
public override int Flush(FlushMode mode, long peerProduced = 0)
{
RawStream? stream = _rows.Take(Env.Clock.NowUs(), mode == FlushMode.All);
if (stream is null)
{
return 0;
}
Ship(stream, "t1");
return 1;
}
private void OnPacket(in UdpDelivery delivery)
{
if (delivery.Header.Type == UdpPacketType.EchoReply)
{
_rows.Reply(delivery.Header.Seq, delivery.ReceiveUs);
}
}
}
/// <summary>UDP 回显,监听方(Agent):校验后立即回复一个等长的包,记录 t2(到达)和 t3(回复发出),按序号留给合并。</summary>
public sealed class UdpEchoListener : BlockBase
{
private readonly RowStore _records;
public UdpEchoListener(BlockEnv env, BlockSpec spec)
: base(env, spec)
{
_records = new RowStore(spec.Kind.FindStream("msg")!, Side.Agent, 32768);
}
public override RowStore? Records(string streamName) => streamName == "msg" ? _records : null;
public override async Task RunAsync(CancellationToken ct)
{
Env.Udp!.Register(Spec.StreamId, OnPacket);
try
{
await WaitUntilCancelledAsync(ct);
}
finally
{
Env.Udp.Unregister(Spec.StreamId);
}
}
private void OnPacket(in UdpDelivery delivery)
{
if (delivery.Header.Type != UdpPacketType.EchoRequest)
{
return; // Data:探活包,只用来保持 NAT 映射和让准入策略学到源地址
}
long t2 = delivery.ReceiveUs;
delivery.ReplyAs(UdpPacketType.EchoReply);
Span<long> row = [t2, Env.Clock.NowUs()];
_records.SetRow(delivery.Header.Seq, row);
}
}
/// <summary>
/// TCP 回显,拨号方:一条常驻连接(可选 TLS)上按速率发小消息,回复按序到达。连接断开期间不发消息(没有连接不是丢包),
/// 之后按退避重连;断开和重连本身由 connect/hold 记录。
/// </summary>
public sealed class TcpEchoDialer : BlockBase
{
private readonly EchoRows _rows;
private readonly TcpInfoRecorder _info;
public TcpEchoDialer(BlockEnv env, BlockSpec spec)
: 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);
}
public override long Produced => _rows.Count;
public override async Task RunAsync(CancellationToken ct)
{
var backoff = new Fleet.Core.Backoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30));
while (!ct.IsCancellationRequested)
{
try
{
using TcpLink link = await TcpLink.OpenAsync(Env, Spec.Port, Spec.Bool("tls"), Spec.StreamId, sendAuth: true, ct);
backoff.Reset();
await ExchangeAsync(link, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return;
}
catch (Exception ex) when (ex is SocketException or IOException or System.Security.Authentication.AuthenticationException or OperationCanceledException or InvalidDataException or InvalidOperationException)
{
// 连接失败或断开:退避之后重连
}
try
{
await Task.Delay(backoff.Next(), Env.Time, ct);
}
catch (OperationCanceledException)
{
return;
}
}
}
public override int Flush(FlushMode mode, long peerProduced = 0)
{
RawStream? stream = _rows.Take(Env.Clock.NowUs(), mode == FlushMode.All);
RawStream kernel = _info.Rows.Drain(includeKey: false);
if (stream is null && kernel.Count == 0)
{
return 0;
}
// 内核采样(tcp_info)和回显行一起交出;只有采样没有回显行(例如连接刚断开)时单独交出
if (stream is null)
{
Ship(kernel, "ts");
}
else
{
Ship(stream, "t1", kernel.Count > 0 ? [kernel] : null);
}
return 1;
}
private async Task ExchangeAsync(TcpLink link, CancellationToken ct)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
Task reader = ReadRepliesAsync(link.Stream, cts.Token);
Task writer = WriteRequestsAsync(link.Stream, cts.Token);
Task sampler = _info.RunAsync(link.Socket, TimeSpan.FromMilliseconds(Spec.Int("tcp_info_ms")), cts.Token);
await Task.WhenAny(reader, writer);
await cts.CancelAsync();
foreach (Task task in new[] { reader, writer, sampler })
{
try
{
await task;
}
catch (Exception ex) when (ex is OperationCanceledException or IOException or SocketException or ObjectDisposedException)
{
// 连接已经断开
}
}
}
private async Task WriteRequestsAsync(Stream stream, CancellationToken ct)
{
int min = (int)Spec.Int("min");
int max = (int)Spec.Int("max");
byte[] body = new byte[Math.Max(max, min)];
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1.0 / Spec.Int("rate_hz")), Env.Time);
while (await timer.WaitForNextTickAsync(ct))
{
int size = Math.Max(TcpWire.MinEchoBody, max > min ? Random.Shared.Next(min, max + 1) : min);
long t1 = Env.Clock.NowUs();
uint seq = _rows.Add(size, t1);
body.AsSpan(0, size).Clear();
TcpWire.WriteEchoBody(body, seq, t1);
await TcpWire.WriteMessageAsync(stream, body.AsMemory(0, size), ct);
await stream.FlushAsync(ct);
}
}
private async Task ReadRepliesAsync(Stream stream, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
byte[]? reply = await TcpWire.ReadMessageAsync(stream, ct);
if (reply is null)
{
return; // 对端关闭
}
long now = Env.Clock.NowUs();
if (TcpWire.TryReadEchoBody(reply, out uint seq, out _))
{
_rows.Reply(seq, now);
}
}
}
}
/// <summary>TCP 回显,监听方:逐条回显并记录 t2(读到)和 t3(回复发出)。保持(hold)的监听方也用它,只是不记录。</summary>
public sealed class TcpEchoListener : BlockBase
{
private readonly RowStore? _records;
private readonly TcpInfoRecorder? _info;
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;
}
public override RowStore? Records(string streamName) => streamName switch
{
"msg" => _records,
"tcp_info" => _info?.Rows,
_ => null,
};
public override async Task RunAsync(CancellationToken ct)
{
Env.TcpListen!.Register(Spec.StreamId, EchoAsync);
try
{
await WaitUntilCancelledAsync(ct);
}
finally
{
Env.TcpListen.Unregister(Spec.StreamId);
}
}
private async Task EchoAsync(TcpConnection connection, CancellationToken ct)
{
// 这条连接上 Agent 一侧套接字的内核采样(下行方向的重传、cwnd、srtt)
using var samplerCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
Task sampler = _info is null ? Task.CompletedTask : _info.RunAsync(connection.Socket, TimeSpan.FromMilliseconds(Spec.Int("tcp_info_ms")), samplerCts.Token);
try
{
await EchoLoopAsync(connection, ct);
}
finally
{
await samplerCts.CancelAsync();
await sampler;
}
}
private async Task EchoLoopAsync(TcpConnection connection, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
byte[]? body = await TcpWire.ReadMessageAsync(connection.Stream, ct);
if (body is null)
{
return;
}
long t2 = Env.Clock.NowUs();
Env.Counters.PacketIn();
if (!TcpWire.TryReadEchoBody(body, out uint seq, out _))
{
Env.Counters.DropInvalid();
return; // 协议错乱:关闭连接
}
if (Env.Admission is { } admission && !admission.AdmitMessage(connection.Session, connection.StreamId, t2))
{
Env.Counters.DropRateLimited();
continue;
}
long t3 = Env.Clock.NowUs();
await TcpWire.WriteMessageAsync(connection.Stream, body, ct);
await connection.Stream.FlushAsync(ct);
Env.Counters.PacketOut();
if (_records is not null)
{
Span<long> row = [t2, t3];
_records.SetRow(seq, row);
}
}
}
}
@@ -0,0 +1,362 @@
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Security.Authentication;
using Monitor.Protocol;
namespace Monitor.Measurement;
/// <summary>什么也不做的监听方:connect 的另一端就是 TCP 监听器本身(接受连接、认证失败就关闭),不需要积木,但需要占一个流编号。</summary>
public sealed class IdleListener : BlockBase
{
public IdleListener(BlockEnv env, BlockSpec spec)
: base(env, spec)
{
}
public override Task RunAsync(CancellationToken ct) => WaitUntilCancelledAsync(ct);
}
/// <summary>
/// 反复建立连接(TCP,可选 TLS)并记录每个阶段的耗时和结果。结果编码与 Backend 的 <c>ConnectOutcome</c> 一致:
/// 0 成功、1 超时、2 被重置或拒绝、3 TLS 告警、4 其他。拨号方是 Probe 时对端是 Agent(按指纹固定证书,建好连接即关闭);
/// 作为目标任务在 Agent 上运行时对端是任意地址(不校验证书,只测握手耗时)。
/// </summary>
public sealed class ConnectDialer : BlockBase
{
private readonly DriverLog _log;
private readonly int _attempt;
private readonly int _ts;
private readonly int _tcp;
private readonly int _tls;
private readonly int _outcome;
public ConnectDialer(BlockEnv env, BlockSpec spec)
: base(env, spec)
{
_log = new DriverLog(spec.Kind.FindStream("conn")!, Side.Probe, 1024);
_attempt = _log.Store.ColumnIndex("attempt");
_ts = _log.Store.ColumnIndex("ts");
_tcp = _log.Store.ColumnIndex("tcp_us");
_tls = _log.Store.ColumnIndex("tls_us");
_outcome = _log.Store.ColumnIndex("outcome");
}
public override long Produced => _log.Count;
public override async Task RunAsync(CancellationToken ct)
{
TimeSpan interval = TimeSpan.FromSeconds(60.0 / Spec.Int("per_minute"));
TimeSpan timeout = TimeSpan.FromMilliseconds(Spec.Int("timeout_ms"));
bool tls = Spec.Bool("tls");
try
{
while (!ct.IsCancellationRequested)
{
long start = Env.Clock.NowUs();
await AttemptAsync(tls, timeout, start, ct);
TimeSpan wait = interval - TimeSpan.FromMicroseconds(Env.Clock.NowUs() - start);
if (wait > TimeSpan.Zero)
{
await Task.Delay(wait, Env.Time, ct);
}
}
}
catch (OperationCanceledException)
{
// 积木被停止
}
}
public override int Flush(FlushMode mode, long peerProduced = 0)
{
RawStream? stream = _log.Take(null, all: true);
if (stream is null)
{
return 0;
}
Ship(stream, "ts");
return 1;
}
private async Task AttemptAsync(bool tls, TimeSpan timeout, long startUs, CancellationToken ct)
{
using var timeoutCts = new CancellationTokenSource(timeout, Env.Time);
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
long tcpUs = 0;
long? tlsUs = null;
int outcome;
try
{
// 对端不需要认证首帧:只测量建立连接
using TcpLink link = await TcpLink.OpenAsync(Env, Spec.Port, tls, Spec.StreamId, sendAuth: false, linked.Token);
tcpUs = link.TcpUs;
tlsUs = link.TlsUs;
outcome = 0;
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
outcome = 1;
}
catch (SocketException ex) when (ex.SocketErrorCode is SocketError.ConnectionRefused or SocketError.ConnectionReset)
{
outcome = 2;
}
catch (Exception ex) when (ex is AuthenticationException || ex.InnerException is AuthenticationException)
{
outcome = 3;
}
catch (Exception ex) when (ex is SocketException or IOException)
{
outcome = ex is IOException { InnerException: SocketException { SocketErrorCode: SocketError.ConnectionReset } } ? 2 : 4;
}
// 失败时 tcp_us/tls_us 缺失;成功时没有 TLS 则 tls_us 缺失
long key = _log.Store.Append();
_log.Store.Set(key, _attempt, key);
_log.Store.Set(key, _ts, startUs);
_log.Store.Set(key, _outcome, outcome);
if (outcome == 0)
{
_log.Store.Set(key, _tcp, tcpUs);
if (tlsUs is { } t)
{
_log.Store.Set(key, _tls, t);
}
}
}
}
/// <summary>
/// 保持,拨号方:一条长时间保持的 TLS 连接,每隔 <c>heartbeat_s</c> 发一个心跳并等回复。记录打开、心跳、重置(连接意外中断)和关闭,
/// 每一行带着连接已经存活的时间。连接中断之后按退避重连。监听方是不记录的 <see cref="TcpEchoListener"/>。
/// </summary>
public sealed class HoldDialer : BlockBase
{
private const int Open = 0;
private const int Heartbeat = 1;
private const int Reset = 2;
private const int Close = 3;
private readonly DriverLog _log;
private readonly int _ts;
private readonly int _event;
private readonly int _lifetime;
public HoldDialer(BlockEnv env, BlockSpec spec)
: base(env, spec)
{
_log = new DriverLog(spec.Kind.FindStream("hold")!, Side.Probe, 1024);
_ts = _log.Store.ColumnIndex("ts");
_event = _log.Store.ColumnIndex("event");
_lifetime = _log.Store.ColumnIndex("lifetime_ms");
}
public override long Produced => _log.Count;
public override async Task RunAsync(CancellationToken ct)
{
var backoff = new Fleet.Core.Backoff(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30));
while (!ct.IsCancellationRequested)
{
long openedUs = 0;
bool opened = false;
try
{
using TcpLink link = await TcpLink.OpenAsync(Env, Spec.Port, tls: true, Spec.StreamId, sendAuth: true, ct);
openedUs = Env.Clock.NowUs();
opened = true;
backoff.Reset();
Record(Open, openedUs, openedUs);
await HoldAsync(link.Stream, openedUs, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
if (opened)
{
Record(Close, Env.Clock.NowUs(), openedUs);
}
return;
}
catch (Exception ex) when (ex is SocketException or IOException or AuthenticationException or OperationCanceledException or InvalidDataException or InvalidOperationException)
{
if (opened)
{
Record(Reset, Env.Clock.NowUs(), openedUs);
}
}
try
{
await Task.Delay(backoff.Next(), Env.Time, ct);
}
catch (OperationCanceledException)
{
return;
}
}
}
public override int Flush(FlushMode mode, long peerProduced = 0)
{
RawStream? stream = _log.Take(null, all: true);
if (stream is null)
{
return 0;
}
Ship(stream, "ts");
return 1;
}
private async Task HoldAsync(Stream stream, long openedUs, CancellationToken ct)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
Task reader = ReadAsync(stream, openedUs, cts.Token);
Task writer = WriteAsync(stream, cts.Token);
Task finished = await Task.WhenAny(reader, writer);
await cts.CancelAsync();
try
{
await finished; // 连接中断时这里抛出,由调用方记录 Reset
if (!ct.IsCancellationRequested)
{
throw new IOException("the peer closed the connection");
}
}
finally
{
foreach (Task task in new[] { reader, writer })
{
try
{
await task;
}
catch (Exception ex) when (ex is OperationCanceledException or IOException or SocketException or ObjectDisposedException)
{
// 已经处理过
}
}
}
}
private async Task WriteAsync(Stream stream, CancellationToken ct)
{
byte[] body = new byte[TcpWire.MinEchoBody];
uint seq = 0;
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(Spec.Int("heartbeat_s")), Env.Time);
while (await timer.WaitForNextTickAsync(ct))
{
TcpWire.WriteEchoBody(body, seq++, Env.Clock.NowUs());
await TcpWire.WriteMessageAsync(stream, body, ct);
await stream.FlushAsync(ct);
}
}
private async Task ReadAsync(Stream stream, long openedUs, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
byte[]? reply = await TcpWire.ReadMessageAsync(stream, ct);
if (reply is null)
{
return;
}
Record(Heartbeat, Env.Clock.NowUs(), openedUs);
}
}
private void Record(int eventCode, long nowUs, long openedUs)
{
long key = _log.Store.Append();
_log.Store.Set(key, _ts, nowUs);
_log.Store.Set(key, _event, eventCode);
_log.Store.Set(key, _lifetime, Math.Max(0, (nowUs - openedUs) / 1000));
}
}
/// <summary>
/// ICMP 回显(只有一侧:Agent 对目标地址):按速率发 ICMP 并记录发出和收到的时刻(本地时钟);超时没有回复的行 t4 缺失。
/// 没有对端,所以没有监听方;行交给 <see cref="BlockEnv.Fragments"/>(Agent 自己盖章上传)。
/// </summary>
public sealed class IcmpEchoSolo : BlockBase
{
private readonly DriverLog _log;
private readonly int _seq;
private readonly int _size;
private readonly int _t1;
private readonly int _t4;
public IcmpEchoSolo(BlockEnv env, BlockSpec spec)
: base(env, spec)
{
_log = new DriverLog(spec.Kind.FindStream("msg")!, Side.Probe, 1024);
_seq = _log.Store.ColumnIndex("seq");
_size = _log.Store.ColumnIndex("size");
_t1 = _log.Store.ColumnIndex("t1");
_t4 = _log.Store.ColumnIndex("t4");
}
public override long Produced => _log.Count;
public override async Task RunAsync(CancellationToken ct)
{
TimeSpan interval = TimeSpan.FromSeconds(1.0 / Spec.Int("rate_hz"));
int size = (int)Spec.Int("size");
byte[] payload = new byte[Math.Max(0, size - 28)];
using var ping = new Ping();
try
{
while (!ct.IsCancellationRequested)
{
long t1 = Env.Clock.NowUs();
long? t4 = null;
try
{
PingReply reply = await ping.SendPingAsync(Env.PeerAddress!, TimeSpan.FromSeconds(2), payload, null, ct);
if (reply.Status == IPStatus.Success)
{
t4 = Env.Clock.NowUs();
}
}
catch (PingException)
{
// 发送失败(例如本机没有路由):记为丢失
}
long key = _log.Store.Append();
_log.Store.Set(key, _seq, key);
_log.Store.Set(key, _size, size);
_log.Store.Set(key, _t1, t1);
if (t4 is { } received)
{
_log.Store.Set(key, _t4, received);
}
TimeSpan wait = interval - TimeSpan.FromMicroseconds(Env.Clock.NowUs() - t1);
if (wait > TimeSpan.Zero)
{
await Task.Delay(wait, Env.Time, ct);
}
}
}
catch (OperationCanceledException)
{
// 积木被停止
}
}
public override int Flush(FlushMode mode, long peerProduced = 0)
{
RawStream? stream = _log.Take(null, all: true);
if (stream is null)
{
return 0;
}
Ship(stream, "t1");
return 1;
}
}
@@ -0,0 +1,239 @@
using System.Diagnostics;
using System.Net;
using Monitor.Protocol;
namespace Monitor.Measurement;
/// <summary>
/// 单向数据流(UDP train),监听方(Agent,行的主导方):按声明的码率均匀发包,发出的每个包 (seq, size, t_send) 记在按序号的记录里,
/// Probe 的接收时刻之后按序号合并(没有到达的就是丢失)。发送在专用线程上完成,不占用线程池。
/// 等不到对端的地址(5 秒内没有任何认证过的包)就以 <see cref="BlockFaultException"/> 结束。
/// </summary>
public sealed class TrainListener : BlockBase
{
private static readonly TimeSpan s_endpointWait = TimeSpan.FromSeconds(5);
private readonly RowStore _records;
private long _sent;
public TrainListener(BlockEnv env, BlockSpec spec)
: 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));
}
public override long Produced => Interlocked.Read(ref _sent);
public override RowStore? Records(string streamName) => streamName == "pkt" ? _records : null;
public override async Task RunAsync(CancellationToken ct)
{
// Probe 开始前发来的探活包(Data)要被接受,准入策略才能据此学到它当前的源地址(NAT 映射);它们本身没有内容
Env.Udp!.Register(Spec.StreamId, static (in UdpDelivery _) => { });
try
{
DataSession session = await WaitForPeerAsync(ct);
IPEndPoint target = session.UdpEndpoint!;
TimeSpan duration = TimeSpan.FromSeconds(Spec.Int("duration_s"));
var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var thread = new Thread(() =>
{
try
{
Send(session, target, duration, ct);
done.SetResult();
}
catch (Exception ex)
{
done.SetException(ex);
}
})
{
IsBackground = true,
Name = "udp-train",
};
thread.Start();
await done.Task;
}
finally
{
Env.Udp.Unregister(Spec.StreamId);
}
}
private async Task<DataSession> WaitForPeerAsync(CancellationToken ct)
{
DateTimeOffset deadline = Env.Time.GetUtcNow() + s_endpointWait;
while (true)
{
DataSession? session = Env.Session();
if (session?.UdpEndpoint is not null)
{
return session;
}
if (Env.Time.GetUtcNow() >= deadline)
{
throw new BlockFaultException("the peer's address is unknown");
}
await Task.Delay(TimeSpan.FromMilliseconds(50), Env.Time, ct);
}
}
private void Send(DataSession session, IPEndPoint target, TimeSpan duration, CancellationToken ct)
{
double packetsPerSecond = Spec.Params.GetNumber("mbps") * 1_000_000 / 8 / Spec.Int("size");
int size = (int)Spec.Int("size");
byte[] packet = new byte[size];
using var pause = new ManualResetEventSlim(false);
uint seq = 0;
long startTicks = Stopwatch.GetTimestamp();
long endTicks = startTicks + (long)(duration.TotalSeconds * Stopwatch.Frequency);
while (!ct.IsCancellationRequested)
{
long now = Stopwatch.GetTimestamp();
if (now >= endTicks)
{
break;
}
// 到目前为止应该发出的包数(均匀):落后了就追,领先了就等;追赶的上限是 50 ms 的量,避免长时间停顿之后的突发
double elapsed = (now - startTicks) / (double)Stopwatch.Frequency;
long due = Math.Min((long)(elapsed * packetsPerSecond) + 1, seq + Math.Max(1, (long)(packetsPerSecond * 0.05)));
if (seq >= due)
{
// 离下一个包还早就让出 CPU(至少 1 ms 的粒度),临近了再自旋,保证发送间隔均匀
if ((seq / packetsPerSecond) - elapsed > 0.002)
{
pause.Wait(1, ct);
}
else
{
Thread.SpinWait(50);
}
continue;
}
for (; seq < due; seq++)
{
long sendUs = Env.Clock.NowUs();
UdpPacket.Write(packet, new UdpHeader(UdpPacketType.Data, session.SessionId, Spec.StreamId, seq, sendUs), session.Key);
Env.Udp!.Send(target, packet);
Span<long> row = [size, sendUs];
_records.SetRow(seq, row);
Interlocked.Exchange(ref _sent, seq + 1L);
}
}
}
}
/// <summary>
/// 单向数据流,拨号方(Probe,行的跟随方):接收,记录每个序号第一次到达的时刻(按序号索引,热路径不分配);
/// 序号超过本地包速率上限推算出的范围时忽略。阶段结束时按序号范围切成多个列块交给 Agent,每块不超过 <see cref="ProtocolLimits.MaxRecordsPerChunk"/> 的记录。
/// 开始时先发几个探活包,让 Agent 学到我们当前的源地址(NAT 映射)再开始发送。
/// </summary>
public sealed class TrainDialer : BlockBase
{
private const int PerFragment = 40_000;
private readonly RowStore _received;
private readonly RowStore _step;
private readonly int _tRecv;
private readonly long _maxSeq;
private long _startUs;
private long _endUs;
public TrainDialer(BlockEnv env, BlockSpec spec)
: base(env, spec)
{
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);
_tRecv = _received.ColumnIndex("t_recv");
_step = new RowStore(spec.Kind.FindStream("step")!, Side.Probe, 16);
}
public override async Task RunAsync(CancellationToken ct)
{
UdpMux udp = Env.Udp!;
DataSession session = Env.Session() ?? throw new BlockFaultException("no data session");
_startUs = Env.Clock.NowUs();
udp.Register(Spec.StreamId, OnPacket);
try
{
byte[] hello = new byte[UdpPacket.MinBytes + 20];
IPEndPoint target = PeerEndpoint(Spec.Port);
for (uint seq = 0; seq < 3; seq++)
{
UdpPacket.Write(hello, new UdpHeader(UdpPacketType.Data, session.SessionId, Spec.StreamId, seq, Env.Clock.NowUs()), session.Key);
udp.Send(target, hello);
}
await WaitUntilCancelledAsync(ct);
}
finally
{
_endUs = Env.Clock.NowUs();
udp.Unregister(Spec.StreamId);
}
}
public override int Flush(FlushMode mode, long peerProduced = 0)
{
if (mode != FlushMode.All)
{
return 0;
}
long end = _endUs == 0 ? Env.Clock.NowUs() : _endUs;
long step = _step.Append();
_step.Set(step, _step.ColumnIndex("index"), 0);
_step.Set(step, _step.ColumnIndex("mbps"), (long)Spec.Params.GetNumber("mbps"));
_step.Set(step, _step.ColumnIndex("start_ts"), _startUs);
_step.Set(step, _step.ColumnIndex("end_ts"), end);
long total = Math.Min(peerProduced, _maxSeq);
int fragments = 0;
for (long from = 0; from < total || fragments == 0; from += PerFragment)
{
long to = Math.Min(from + PerFragment, total);
RawStream pkt = _received.Cut(from, to, includeKey: true);
var parameters = new Dictionary<string, System.Text.Json.JsonElement>(Spec.Params.Values)
{
[FragmentKeys.SeqFrom] = Number(from),
[FragmentKeys.SeqTo] = Number(to),
};
Env.Fragments!.Ship(
"udp_train",
Spec.Scope,
fragments == 0 ? [pkt, _step.Cut(0, 1, includeKey: false)] : [pkt],
parameters,
Env.Clock.UtcMsAt(_startUs),
(int)((end - _startUs) / 1000),
Spec.Port);
fragments++;
if (total == 0)
{
break;
}
}
return fragments;
}
private static System.Text.Json.JsonElement Number(long value) =>
System.Text.Json.JsonDocument.Parse(value.ToString(System.Globalization.CultureInfo.InvariantCulture)).RootElement.Clone();
private void OnPacket(in UdpDelivery delivery)
{
long seq = delivery.Header.Seq;
if (delivery.Header.Type == UdpPacketType.Data && seq < _maxSeq && !_received.IsSet(seq, _tRecv))
{
_received.Set(seq, _tRecv, delivery.ReceiveUs); // 只记第一次到达(重复的包不覆盖)
}
}
}