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.
241 lines
7.6 KiB
C#
241 lines
7.6 KiB
C#
using System.Buffers.Binary;
|
|
using System.Net.WebSockets;
|
|
using Fleet.Rpc;
|
|
|
|
namespace Fleet.Link;
|
|
|
|
/// <summary>WebSocket 传输(节点 → 服务端):文本帧是 JSON-RPC,二进制帧是发件箱。</summary>
|
|
public sealed class WsTransport : IRpcTransport
|
|
{
|
|
private static readonly TimeSpan s_sendTimeout = TimeSpan.FromSeconds(20);
|
|
private readonly WebSocket _socket;
|
|
private readonly int _maxFrameBytes;
|
|
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
|
|
|
public WsTransport(WebSocket socket, int maxFrameBytes)
|
|
{
|
|
_socket = socket;
|
|
_maxFrameBytes = maxFrameBytes;
|
|
}
|
|
|
|
public async ValueTask SendAsync(RpcFrame frame, CancellationToken ct)
|
|
{
|
|
using var timeout = new CancellationTokenSource(s_sendTimeout);
|
|
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
|
bool lockTaken = false;
|
|
try
|
|
{
|
|
await _sendLock.WaitAsync(linked.Token);
|
|
lockTaken = true;
|
|
if (_socket.State != WebSocketState.Open)
|
|
{
|
|
throw new WebSocketException($"WebSocket is not open (state: {_socket.State})");
|
|
}
|
|
|
|
await _socket.SendAsync(frame.Data, frame.Kind == FrameKind.Text ? WebSocketMessageType.Text : WebSocketMessageType.Binary, endOfMessage: true, linked.Token);
|
|
}
|
|
catch (OperationCanceledException) when (timeout.IsCancellationRequested && !ct.IsCancellationRequested)
|
|
{
|
|
// A wedged send otherwise holds the lock and prevents heartbeat/reconnect from making progress.
|
|
_socket.Abort();
|
|
throw new TimeoutException($"WebSocket send timed out after {s_sendTimeout.TotalSeconds:0} seconds");
|
|
}
|
|
finally
|
|
{
|
|
if (lockTaken)
|
|
{
|
|
_sendLock.Release();
|
|
}
|
|
}
|
|
}
|
|
|
|
public async ValueTask<RpcFrame?> ReceiveAsync(CancellationToken ct)
|
|
{
|
|
var message = new MemoryStream();
|
|
byte[] buffer = new byte[16 * 1024];
|
|
while (true)
|
|
{
|
|
ValueWebSocketReceiveResult result = await _socket.ReceiveAsync(buffer.AsMemory(), ct);
|
|
if (result.MessageType == WebSocketMessageType.Close)
|
|
{
|
|
if (_socket.State == WebSocketState.CloseReceived)
|
|
{
|
|
// 按协议确认对端发起的关闭
|
|
try
|
|
{
|
|
await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "ack", CancellationToken.None);
|
|
}
|
|
catch (WebSocketException)
|
|
{
|
|
// 对端已经断开
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
message.Write(buffer, 0, result.Count);
|
|
if (message.Length > _maxFrameBytes)
|
|
{
|
|
await CloseAsync("frame too large", CancellationToken.None);
|
|
return null;
|
|
}
|
|
|
|
if (result.EndOfMessage)
|
|
{
|
|
return new RpcFrame(result.MessageType == WebSocketMessageType.Text ? FrameKind.Text : FrameKind.Binary, message.ToArray());
|
|
}
|
|
}
|
|
}
|
|
|
|
public async ValueTask CloseAsync(string reason, CancellationToken ct)
|
|
{
|
|
if (_socket.State is WebSocketState.Open or WebSocketState.CloseReceived)
|
|
{
|
|
try
|
|
{
|
|
await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, reason, ct);
|
|
}
|
|
catch (WebSocketException)
|
|
{
|
|
// 对端已经断开
|
|
}
|
|
}
|
|
}
|
|
|
|
public ValueTask DisposeAsync()
|
|
{
|
|
_socket.Dispose();
|
|
_sendLock.Dispose();
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// TLS 帧传输(拨号方 → 监听方):<c>u32 LE 正文长度 | u8 类型(0 文本, 1 二进制) | 正文</c>。
|
|
/// 比 WebSocket 轻得多,Agent 不需要内置 HTTP 服务端。
|
|
/// </summary>
|
|
public sealed class TlsFrameTransport : IRpcTransport
|
|
{
|
|
private const int HeaderBytes = 5;
|
|
private static readonly TimeSpan s_sendTimeout = TimeSpan.FromSeconds(20);
|
|
|
|
private readonly Stream _stream;
|
|
private readonly int _maxFrameBytes;
|
|
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
|
|
|
public TlsFrameTransport(Stream stream, int maxFrameBytes)
|
|
{
|
|
_stream = stream;
|
|
_maxFrameBytes = maxFrameBytes;
|
|
}
|
|
|
|
public async ValueTask SendAsync(RpcFrame frame, CancellationToken ct)
|
|
{
|
|
if (frame.Data.Length > _maxFrameBytes)
|
|
{
|
|
throw new InvalidOperationException("frame exceeds the maximum size");
|
|
}
|
|
|
|
byte[] header = new byte[HeaderBytes];
|
|
BinaryPrimitives.WriteUInt32LittleEndian(header, (uint)frame.Data.Length);
|
|
header[4] = (byte)frame.Kind;
|
|
// 对端黑洞时写会一直挂着并占着发送锁,心跳、时钟同步和重连都跟着卡住:超时就掐掉这条连接
|
|
using var timeout = new CancellationTokenSource(s_sendTimeout);
|
|
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
|
bool lockTaken = false;
|
|
try
|
|
{
|
|
await _sendLock.WaitAsync(linked.Token);
|
|
lockTaken = true;
|
|
await _stream.WriteAsync(header, linked.Token);
|
|
await _stream.WriteAsync(frame.Data, linked.Token);
|
|
await _stream.FlushAsync(linked.Token);
|
|
}
|
|
catch (OperationCanceledException) when (timeout.IsCancellationRequested && !ct.IsCancellationRequested)
|
|
{
|
|
await _stream.DisposeAsync();
|
|
throw new TimeoutException($"peer send timed out after {s_sendTimeout.TotalSeconds:0} seconds");
|
|
}
|
|
finally
|
|
{
|
|
if (lockTaken)
|
|
{
|
|
_sendLock.Release();
|
|
}
|
|
}
|
|
}
|
|
|
|
public async ValueTask<RpcFrame?> ReceiveAsync(CancellationToken ct)
|
|
{
|
|
byte[] header = new byte[HeaderBytes];
|
|
try
|
|
{
|
|
if (!await ReadExactlyOrEofAsync(header, ct))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
uint length = BinaryPrimitives.ReadUInt32LittleEndian(header);
|
|
if (length > _maxFrameBytes || header[4] > (byte)FrameKind.Binary)
|
|
{
|
|
await _stream.DisposeAsync(); // 非法帧:直接断开
|
|
return null;
|
|
}
|
|
|
|
byte[] body = new byte[length];
|
|
if (!await ReadExactlyOrEofAsync(body, ct))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new RpcFrame((FrameKind)header[4], body);
|
|
}
|
|
catch (IOException)
|
|
{
|
|
return null;
|
|
}
|
|
catch (ObjectDisposedException)
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public async ValueTask CloseAsync(string reason, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
await _stream.FlushAsync(ct);
|
|
}
|
|
catch (Exception ex) when (ex is IOException or ObjectDisposedException)
|
|
{
|
|
// 对端已经断开
|
|
}
|
|
|
|
await _stream.DisposeAsync();
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await _stream.DisposeAsync();
|
|
_sendLock.Dispose();
|
|
}
|
|
|
|
private async ValueTask<bool> ReadExactlyOrEofAsync(byte[] buffer, CancellationToken ct)
|
|
{
|
|
int read = 0;
|
|
while (read < buffer.Length)
|
|
{
|
|
int n = await _stream.ReadAsync(buffer.AsMemory(read), ct);
|
|
if (n == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
read += n;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|