refactor!: rename the project from Monitor to MoShiro
Namespaces, assemblies, projects and directories, the solution, binaries, images, systemd units, config paths, environment variables, protocol method names, the media type and the raw chunk magic all move to MoShiro/moshiro. Existing data and deployments are not compatible and must be recreated. Also fix the architecture tests after the solution layout change and make the update tests independent of their order.
This commit is contained in:
1 parent
17b35223aa
commit
83e5120377
309 files changed
+1505
-1499
No files matched your search
@@ -0,0 +1,105 @@
|
||||
using System.Net;
|
||||
using MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.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 MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.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 MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.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 MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.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 MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.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 MoShiro.Protocol;
|
||||
|
||||
namespace MoShiro.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