chore: initial project import
This commit is contained in:
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); // 只记第一次到达(重复的包不覆盖)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user