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.
204 lines
6.2 KiB
C#
204 lines
6.2 KiB
C#
using System.Collections.Concurrent;
|
|
using Fleet.Core;
|
|
using MoShiro.Protocol;
|
|
|
|
namespace MoShiro.Agent;
|
|
|
|
/// <summary>
|
|
/// 拨号侧的组装点:每个拨号目标(监听节点 × IP 族)一个 <see cref="DialConnection"/>。目标列表变化时只调和连接:
|
|
/// 新增的建立,移除的断开,地址或证书指纹变化的重建,其余不受影响。
|
|
/// </summary>
|
|
public sealed class DialerHost : IDisposable
|
|
{
|
|
private readonly DialEnv _env;
|
|
private readonly Dictionary<string, Running> _connections = [];
|
|
private readonly ConcurrentDictionary<string, long> _pendingRetries = new(StringComparer.Ordinal);
|
|
private readonly SemaphoreSlim _changed = new(0);
|
|
private readonly TimeProvider _time;
|
|
private readonly INodeLog _log;
|
|
|
|
public DialerHost(DialEnv env, TimeProvider time, INodeLog log)
|
|
{
|
|
_env = env;
|
|
_time = time;
|
|
_log = log;
|
|
env.Directory.Changed += () => _changed.Release();
|
|
}
|
|
|
|
public DialDirectory Directory => _env.Directory;
|
|
|
|
/// <summary>某条路径的控制会话是否已经建立、QUIC 数据通道是否可发送(测试和状态查看用)。</summary>
|
|
public bool IsConnected(string listenerId, string ipVersion = IpVersions.V4)
|
|
{
|
|
lock (_connections)
|
|
{
|
|
return _connections.TryGetValue(listenerId + "~" + ipVersion, out Running? running) && running.Connection.IsConnected;
|
|
}
|
|
}
|
|
|
|
public DialConnection? Connection(string listenerId, string ipVersion = IpVersions.V4)
|
|
{
|
|
lock (_connections)
|
|
{
|
|
return _connections.TryGetValue(listenerId + "~" + ipVersion, out Running? running) ? running.Connection : null;
|
|
}
|
|
}
|
|
|
|
/// <summary>每条路径当前的线路健康(随心跳上报)。</summary>
|
|
public List<LinkHealth> SnapshotLinks()
|
|
{
|
|
DialConnection[] connections;
|
|
lock (_connections)
|
|
{
|
|
connections = [.. _connections.Values.Select(r => r.Connection)];
|
|
}
|
|
|
|
return [.. connections.Select(c => c.Health())];
|
|
}
|
|
|
|
/// <summary>管理端手动重试:跳过这个监听节点所有路径的退避。目标列表还没到时,30 秒内到达就补上。</summary>
|
|
public void RetryListener(string listenerId, bool queueIfMissing = true)
|
|
{
|
|
DialConnection[] matches;
|
|
lock (_connections)
|
|
{
|
|
matches = [.. _connections.Values.Select(r => r.Connection).Where(c => c.Endpoint.NodeId == listenerId)];
|
|
}
|
|
|
|
if (matches.Length == 0)
|
|
{
|
|
if (queueIfMissing)
|
|
{
|
|
_pendingRetries[listenerId] = _time.GetUtcNow().AddSeconds(30).ToUnixTimeMilliseconds();
|
|
}
|
|
|
|
_log.Warn("manual link retry requested but no matching dial target is configured", ("listener", listenerId));
|
|
return;
|
|
}
|
|
|
|
_pendingRetries.TryRemove(listenerId, out _);
|
|
foreach (DialConnection connection in matches)
|
|
{
|
|
connection.RetryNow();
|
|
}
|
|
}
|
|
|
|
public async Task RunAsync(CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
while (!ct.IsCancellationRequested)
|
|
{
|
|
await ReconcileAsync(_env.Directory.Targets, ct);
|
|
RetryPending();
|
|
await _changed.WaitAsync(ct);
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// 停止
|
|
}
|
|
finally
|
|
{
|
|
await ReconcileAsync([], CancellationToken.None);
|
|
}
|
|
}
|
|
|
|
public void Dispose() => _changed.Dispose();
|
|
|
|
private void RetryPending()
|
|
{
|
|
long now = _time.GetUtcNow().ToUnixTimeMilliseconds();
|
|
foreach ((string listenerId, long expiresAt) in _pendingRetries.ToArray())
|
|
{
|
|
if (expiresAt <= now)
|
|
{
|
|
_pendingRetries.TryRemove(listenerId, out _);
|
|
}
|
|
else
|
|
{
|
|
RetryListener(listenerId, queueIfMissing: false);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task ReconcileAsync(IReadOnlyList<DialEndpoint> targets, CancellationToken ct)
|
|
{
|
|
var wanted = targets.ToDictionary(t => t.PathKey);
|
|
var stopped = new List<Running>();
|
|
lock (_connections)
|
|
{
|
|
foreach ((string id, Running running) in _connections.ToList())
|
|
{
|
|
if (!wanted.TryGetValue(id, out DialEndpoint? endpoint) || !DialDirectory.Equal(endpoint, running.Connection.Endpoint))
|
|
{
|
|
stopped.Add(running);
|
|
_connections.Remove(id);
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach (Running running in stopped)
|
|
{
|
|
await running.StopAsync();
|
|
}
|
|
|
|
if (ct.IsCancellationRequested)
|
|
{
|
|
return;
|
|
}
|
|
|
|
lock (_connections)
|
|
{
|
|
foreach ((string id, DialEndpoint endpoint) in wanted)
|
|
{
|
|
if (!_connections.ContainsKey(id))
|
|
{
|
|
_connections[id] = Running.Start(new DialConnection(endpoint, _env));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private sealed class Running : IDisposable
|
|
{
|
|
private readonly CancellationTokenSource _cts = new();
|
|
private Task _run = Task.CompletedTask;
|
|
|
|
private Running(DialConnection connection)
|
|
{
|
|
Connection = connection;
|
|
}
|
|
|
|
public DialConnection Connection { get; }
|
|
|
|
public static Running Start(DialConnection connection)
|
|
{
|
|
var running = new Running(connection);
|
|
running._run = Task.Run(() => connection.RunAsync(running._cts.Token), CancellationToken.None);
|
|
return running;
|
|
}
|
|
|
|
public async Task StopAsync()
|
|
{
|
|
await _cts.CancelAsync();
|
|
try
|
|
{
|
|
await _run.WaitAsync(TimeSpan.FromSeconds(10));
|
|
}
|
|
catch (Exception ex) when (ex is OperationCanceledException or TimeoutException)
|
|
{
|
|
// 没有及时退出:不再等
|
|
}
|
|
|
|
Dispose();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Connection.Dispose();
|
|
_cts.Dispose();
|
|
}
|
|
}
|
|
}
|