agent: unify listening and dialing with directed mutual measurements

Replace Probe with Agent across node runtime, Fleet contracts, backend state, API and storage. Report incoming sessions and outgoing link health through each Agent uplink, and retain directed link controls and retry.

Update protocol fixtures and end-to-end coverage for the unified model; accept requested cancellation during the Fleet timeout test cleanup.
This commit is contained in:
justme committed 2026-10-21 11:55:36 +08:00
1 parent d5ebf29e57
commit ad5efc62ff
206 files changed
+4202 -4964

No files matched your search

@@ -75,7 +75,7 @@ internal sealed class MsQuicConnection : IAsyncDisposable, IDatagramConnection
_configuration = configuration;
_expectedPeerSpki = expectedPeerSpki;
DatagramHandler = datagramHandler;
_log = new ContextNodeLog(log, ("quic_id", DiagnosticId), ("side", expectedPeerSpki is null ? "agent" : "probe"));
_log = new ContextNodeLog(log, ("quic_id", DiagnosticId), ("side", expectedPeerSpki is null ? "listener" : "dialer"));
_closed = closed;
_peerAddressChanged = peerAddressChanged;
_self = GCHandle.Alloc(this);
@@ -164,7 +164,7 @@ internal sealed class MsQuicConnection : IAsyncDisposable, IDatagramConnection
long handshakeStarted = 0;
try
{
registration = MsQuicApi.RegistrationOpen("moshiro-probe");
registration = MsQuicApi.RegistrationOpen("moshiro-dialer");
configuration = MsQuicApi.ConfigurationOpen(registration, QuicAlpn.Value);
LoadClientCredentials(configuration);
var endpoint = new IPEndPoint(address.AddressFamily == AddressFamily.InterNetworkV6 ? IPAddress.IPv6Any : IPAddress.Any, localPort);
@@ -1,6 +1,6 @@
namespace MoShiro.Measurement;
/// <summary>The application protocol negotiated by the Agent and Probe QUIC endpoints.</summary>
/// <summary>The application protocol negotiated by the Agent and 拨号方 QUIC endpoints.</summary>
internal static class QuicAlpn
{
internal static readonly byte[] Value = "h3"u8.ToArray();
@@ -6,7 +6,7 @@ using Fleet.Core;
namespace MoShiro.Measurement;
/// <summary>
/// Measurement data transport. Agent instances listen on configured QUIC UDP ports; Probe instances
/// Measurement data transport. Agent instances listen on configured QUIC UDP ports; 拨号方 instances
/// open one QUIC connection from a newly selected ephemeral source port. Measurement frames are sent
/// only with MsQuic DATAGRAM calls; the existing TLS peer connection continues to carry session control.
/// </summary>
@@ -21,7 +21,7 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable
private readonly DataSession? _clientSession;
private readonly IPEndPoint? _remote;
private readonly byte[]? _peerSpki;
private readonly int _previousSourcePort;
private int _previousSourcePort;
private readonly TaskCompletionSource _clientReady = new(TaskCreationOptions.RunContinuationsAsynchronously);
private MsQuicListener? _listener;
private MsQuicConnection? _client;
@@ -38,8 +38,8 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable
_serverCertificate = serverCertificate;
}
/// <summary>Constructs a Probe-side QUIC dialer for one Agent session.</summary>
public QuicDatagramTransport(DataSession session, MonoClock clock, DataPlaneCounters counters, INodeLog log, IPEndPoint remote, byte[] peerSpki, int previousSourcePort)
/// <summary>Constructs a 拨号方-side QUIC dialer for one Agent session.</summary>
public QuicDatagramTransport(DataSession session, MonoClock clock, DataPlaneCounters counters, INodeLog log, IPEndPoint remote, byte[] peerSpki)
{
_router = new MeasurementDatagramRouter(new SingleSession(session), null, clock, counters, log);
_log = log;
@@ -49,7 +49,6 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable
_clientSession = session;
_remote = remote;
_peerSpki = peerSpki;
_previousSourcePort = previousSourcePort;
}
public IReadOnlyList<int> BoundPorts { get; private set; } = [];
@@ -79,33 +78,39 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable
BoundPorts = _listener.BoundPorts;
}
/// <summary>
/// Opens a QUIC connection from a new ephemeral source port. May be called again after the previous connection closed;
/// the session, handler registrations and counters carry over. A failed attempt leaves the transport reusable.
/// </summary>
public async Task ConnectAsync(CancellationToken ct)
{
if (_remote is null || _clientSession is null || _peerSpki is null)
{
throw new InvalidOperationException("Only Probe-side QUIC transports can connect.");
throw new InvalidOperationException("Only 拨号方-side QUIC transports can connect.");
}
try
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
await CloseClientAsync().ConfigureAwait(false);
int sourcePort = SelectSourcePort(_remote.AddressFamily, _previousSourcePort);
MsQuicConnection client = await MsQuicConnection.ConnectAsync(_remote.Address, _remote.Port, sourcePort, _peerSpki, _onDatagram, _log, ct).ConfigureAwait(false);
_client = client;
_clientSession.BindDatagramConnection(client);
BoundPorts = client.LocalEndPoint is { } local ? [local.Port] : [sourcePort];
_previousSourcePort = BoundPorts[0];
_clientReady.TrySetResult();
}
/// <summary>Completes when the current 拨号方-side connection has shut down (peer close, idle timeout, local close).</summary>
public Task WaitForDisconnectAsync(CancellationToken ct) =>
_client is { } client ? client.WaitForShutdownAsync().WaitAsync(ct) : Task.CompletedTask;
/// <summary>Closes the current 拨号方-side connection; the next <see cref="ConnectAsync"/> opens a new one.</summary>
public async Task CloseClientAsync()
{
MsQuicConnection? client = Interlocked.Exchange(ref _client, null);
if (client is not null)
{
int sourcePort = SelectSourcePort(_remote.AddressFamily, _previousSourcePort);
_client = await MsQuicConnection.ConnectAsync(
_remote.Address,
_remote.Port,
sourcePort,
_peerSpki,
_onDatagram,
_log,
ct);
_clientSession.BindDatagramConnection(_client);
BoundPorts = _client.LocalEndPoint is { } local ? [local.Port] : [sourcePort];
_clientReady.TrySetResult();
}
catch (Exception ex)
{
_log.Error("QUIC client connection failed", ("err", ex.Message));
_clientReady.TrySetException(ex);
throw;
await client.DisposeAsync().ConfigureAwait(false);
}
}
@@ -128,15 +133,9 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable
return false;
}
/// <summary>Agent side: holds the listener open until cancelled. 拨号方-side connections are driven by <see cref="ConnectAsync"/> and <see cref="WaitForDisconnectAsync"/>.</summary>
public async Task RunAsync(CancellationToken ct)
{
if (_remote is not null)
{
await _clientReady.Task.WaitAsync(ct);
await _client!.WaitForShutdownAsync().WaitAsync(ct);
return;
}
try
{
await Task.Delay(Timeout.InfiniteTimeSpan, ct);
@@ -171,10 +170,7 @@ public sealed class QuicDatagramTransport : IDisposable, IAsyncDisposable
{
try
{
if (_client is not null)
{
await _client.DisposeAsync().ConfigureAwait(false);
}
await CloseClientAsync().ConfigureAwait(false);
if (_listener is not null)
{
@@ -223,7 +223,7 @@ public sealed class TcpAcceptor
using var authCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
authCts.CancelAfter(s_authTimeout);
// 预握手敲门:名册里的 Probe 才算得出敲门包;不通过就不写任何字节、直接断开
// 预握手敲门:名册里的 拨号方 才算得出敲门包;不通过就不写任何字节、直接断开
if (!await _knock.AdmitAsync(stream, (client.Client.RemoteEndPoint as IPEndPoint)?.Address, authCts.Token))
{
_counters.DropInvalid();
@@ -71,7 +71,7 @@ public static class TcpInfo
/// <summary>
/// 一条连接上周期性的内核采样(<c>tcp_info</c> 流):每个采样一行 (ts, side, srtt, rttvar, cwnd, retrans_total, segs_out, bytes_acked, conn)。
/// 两端各采样自己的套接字:Probe 的重传反映上行方向,Agent 的重传反映下行方向(代理场景里大流量走这个方向)。
/// 两端各采样自己的套接字:拨号方 的重传反映上行方向,Agent 的重传反映下行方向(代理场景里大流量走这个方向)。
/// 累计计数器在 Backend 按 (side, conn) 分别求差;conn 是当前记录器内分配的连接编号,不包含网络地址。
/// </summary>
public sealed class TcpInfoRecorder
@@ -84,7 +84,7 @@ public sealed class TcpInfoRecorder
{
Rows = new RowStore(spec, side, 4096);
_clock = clock;
_side = side == Side.Probe ? 0 : 1;
_side = side == Side.Dialer ? 0 : 1;
}
public RowStore Rows { get; }