chore: initial project import
This commit is contained in:
commit
26620707a0
347 files changed
+56172
No files matched your search
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<OutputType>Library</OutputType>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
<EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Fleet.Server.Abstractions\Fleet.Server.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\Fleet.Identity\Fleet.Identity.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,170 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Fleet.Core;
|
||||
using Fleet.Rpc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Fleet.Server;
|
||||
|
||||
/// <summary>
|
||||
/// 服务端连接宿主:在线表 + 服务端发起的调用 + 状态推送通知。每个 WebSocket 对应一个 <see cref="NodeConnection"/>。
|
||||
/// </summary>
|
||||
public sealed class FleetLinkHost : IFleetConnections, IStateNotifier
|
||||
{
|
||||
private readonly ConcurrentDictionary<NodeId, NodeConnection> _connections = new();
|
||||
|
||||
internal FleetLinkHost(
|
||||
IFleetStore store,
|
||||
FleetServerOptions options,
|
||||
TimeProvider time,
|
||||
ILogger<FleetLinkHost> logger,
|
||||
IEnumerable<IStateProvider> stateProviders,
|
||||
IEnumerable<IInbox> inboxes,
|
||||
IEnumerable<INodeEventSink> sinks,
|
||||
FleetRouter router)
|
||||
{
|
||||
Store = store;
|
||||
Options = options;
|
||||
Time = time;
|
||||
Logger = logger;
|
||||
StateProviders = [.. stateProviders];
|
||||
Inboxes = inboxes.ToDictionary(i => i.Channel);
|
||||
Sinks = [.. sinks];
|
||||
Router = router.Inner;
|
||||
}
|
||||
|
||||
internal IFleetStore Store { get; }
|
||||
|
||||
internal FleetServerOptions Options { get; }
|
||||
|
||||
internal TimeProvider Time { get; }
|
||||
|
||||
internal ILogger<FleetLinkHost> Logger { get; }
|
||||
|
||||
internal IReadOnlyList<IStateProvider> StateProviders { get; }
|
||||
|
||||
internal IReadOnlyDictionary<byte, IInbox> Inboxes { get; }
|
||||
|
||||
internal IReadOnlyList<INodeEventSink> Sinks { get; }
|
||||
|
||||
internal RpcRouter<NodeCallContext> Router { get; }
|
||||
|
||||
public IReadOnlyCollection<NodeId> OnlineNodes => [.. _connections.Keys];
|
||||
|
||||
public bool IsOnline(NodeId node) => _connections.ContainsKey(node);
|
||||
|
||||
public NodeSessionInfo? GetSession(NodeId node) =>
|
||||
_connections.TryGetValue(node, out NodeConnection? c) ? c.Session : null;
|
||||
|
||||
/// <summary>处理一条 WebSocket 连接,直到连接结束。</summary>
|
||||
public async Task RunConnectionAsync(WebSocket socket, CancellationToken ct)
|
||||
{
|
||||
using var connection = new NodeConnection(this, socket);
|
||||
await connection.RunAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>返回被替换掉的旧连接(如果有)。</summary>
|
||||
internal NodeConnection? Register(NodeConnection connection)
|
||||
{
|
||||
NodeConnection? replaced = null;
|
||||
_connections.AddOrUpdate(
|
||||
connection.NodeId,
|
||||
connection,
|
||||
(_, existing) =>
|
||||
{
|
||||
replaced = existing;
|
||||
return connection;
|
||||
});
|
||||
return replaced;
|
||||
}
|
||||
|
||||
internal void Unregister(NodeConnection connection) =>
|
||||
_connections.TryRemove(new KeyValuePair<NodeId, NodeConnection>(connection.NodeId, connection));
|
||||
|
||||
public async ValueTask<Result<TResult>> RequestAsync<TParams, TResult>(
|
||||
NodeId node,
|
||||
string method,
|
||||
TParams parameters,
|
||||
JsonTypeInfo<TParams> paramsInfo,
|
||||
JsonTypeInfo<TResult> resultInfo,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (!_connections.TryGetValue(node, out NodeConnection? connection))
|
||||
{
|
||||
return Result<TResult>.Fail(FleetErrors.NodeOffline, "node is not connected");
|
||||
}
|
||||
|
||||
Result<JsonElement> raw = await connection.RequestAsync(method, JsonSerializer.SerializeToElement(parameters, paramsInfo), ct);
|
||||
if (!raw.TryGet(out JsonElement value, out FleetError? error))
|
||||
{
|
||||
return Result<TResult>.Fail(error);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
TResult? result = value.Deserialize(resultInfo);
|
||||
return result is null
|
||||
? Result<TResult>.Fail(FleetErrors.Internal, "empty result")
|
||||
: Result<TResult>.Ok(result);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Result<TResult>.Fail(FleetErrors.Internal, "result could not be parsed");
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<bool> NotifyAsync<TParams>(NodeId node, string method, TParams parameters, JsonTypeInfo<TParams> paramsInfo, CancellationToken ct)
|
||||
{
|
||||
if (!_connections.TryGetValue(node, out NodeConnection? connection))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return await connection.SendAsync(RpcMessage.Notification(method, parameters, paramsInfo), ct);
|
||||
}
|
||||
|
||||
public async ValueTask CloseAllAsync(string reason)
|
||||
{
|
||||
foreach (NodeConnection connection in _connections.Values.ToArray())
|
||||
{
|
||||
await connection.CloseAsync(reason);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisconnectAsync(NodeId node, string reason, CancellationToken ct)
|
||||
{
|
||||
if (_connections.TryGetValue(node, out NodeConnection? connection))
|
||||
{
|
||||
await connection.RevokeAsync(reason, ct);
|
||||
}
|
||||
}
|
||||
|
||||
public void NotifyChanged(NodeId node)
|
||||
{
|
||||
if (_connections.TryGetValue(node, out NodeConnection? connection))
|
||||
{
|
||||
connection.SignalStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public void NotifyAllChanged()
|
||||
{
|
||||
foreach (NodeConnection connection in _connections.Values)
|
||||
{
|
||||
connection.SignalStateChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>产品注册的 RPC 方法路由。</summary>
|
||||
public sealed class FleetRouter
|
||||
{
|
||||
public FleetRouter(RpcRouter<NodeCallContext> inner)
|
||||
{
|
||||
Inner = inner;
|
||||
}
|
||||
|
||||
internal RpcRouter<NodeCallContext> Inner { get; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Fleet.Server;
|
||||
|
||||
internal static partial class FleetLog
|
||||
{
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = "node connected node={Node} status={Status}")]
|
||||
public static partial void Connected(ILogger logger, string node, string status);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Information, Message = "node disconnected node={Node} reason={Reason}")]
|
||||
public static partial void Disconnected(ILogger logger, string node, string reason);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "node auth rejected err={Err}")]
|
||||
public static partial void AuthRejected(ILogger logger, string err);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "inbox failed node={Node} channel={Channel}")]
|
||||
public static partial void InboxFailed(ILogger logger, Exception exception, string node, byte channel);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Warning, Message = "state push failed node={Node} type={Type} err={Err}")]
|
||||
public static partial void StatePushFailed(ILogger logger, string node, string type, string err);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Error, Message = "unhandled error in handler method={Method}")]
|
||||
public static partial void HandlerFailed(ILogger logger, Exception exception, string method);
|
||||
|
||||
[LoggerMessage(Level = LogLevel.Error, Message = "event sink failed kind={Kind} node={Node}")]
|
||||
public static partial void SinkFailed(ILogger logger, Exception exception, string kind, string node);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Fleet.Rpc;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Fleet.Server;
|
||||
|
||||
/// <summary>Fleet 服务端的接入点:产品在这里挂上自己的状态、收件箱、事件和 RPC 方法。</summary>
|
||||
public sealed class FleetServerBuilder
|
||||
{
|
||||
private readonly List<Action<RpcRouter<NodeCallContext>, IServiceProvider>> _routeConfigurators = [];
|
||||
|
||||
internal FleetServerBuilder(IServiceCollection services)
|
||||
{
|
||||
Services = services;
|
||||
}
|
||||
|
||||
public IServiceCollection Services { get; }
|
||||
|
||||
internal IReadOnlyList<Action<RpcRouter<NodeCallContext>, IServiceProvider>> RouteConfigurators => _routeConfigurators;
|
||||
|
||||
public FleetServerBuilder AddState<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TProvider>()
|
||||
where TProvider : class, IStateProvider
|
||||
{
|
||||
Services.AddSingleton<IStateProvider, TProvider>();
|
||||
return this;
|
||||
}
|
||||
|
||||
public FleetServerBuilder AddInbox<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TInbox>()
|
||||
where TInbox : class, IInbox
|
||||
{
|
||||
Services.AddSingleton<IInbox, TInbox>();
|
||||
return this;
|
||||
}
|
||||
|
||||
public FleetServerBuilder OnNodeEvent<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TSink>()
|
||||
where TSink : class, INodeEventSink
|
||||
{
|
||||
Services.AddSingleton<INodeEventSink, TSink>();
|
||||
return this;
|
||||
}
|
||||
|
||||
public FleetServerBuilder OnEnrolled<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] THook>()
|
||||
where THook : class, IEnrollmentHook
|
||||
{
|
||||
Services.AddSingleton<IEnrollmentHook, THook>();
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>注册产品的 RPC 方法(<c>monitor.*</c> 等)。处理函数通过 <see cref="IServiceProvider"/> 在调用时解析服务,避免构造期的循环依赖。</summary>
|
||||
public FleetServerBuilder MapMethods(Action<RpcRouter<NodeCallContext>, IServiceProvider> configure)
|
||||
{
|
||||
_routeConfigurators.Add(configure);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
public static class FleetServerExtensions
|
||||
{
|
||||
/// <summary>需要事先在 DI 中注册 <see cref="IFleetStore"/>。</summary>
|
||||
public static FleetServerBuilder AddFleetServer(this IServiceCollection services, Action<FleetServerOptions>? configure = null)
|
||||
{
|
||||
var options = new FleetServerOptions();
|
||||
configure?.Invoke(options);
|
||||
services.AddSingleton(options);
|
||||
|
||||
var builder = new FleetServerBuilder(services);
|
||||
services.AddSingleton(provider =>
|
||||
{
|
||||
ILogger logger = provider.GetRequiredService<ILoggerFactory>().CreateLogger("Fleet.Rpc");
|
||||
var router = new RpcRouter<NodeCallContext>((method, ex) => FleetLog.HandlerFailed(logger, ex, method));
|
||||
foreach (Action<RpcRouter<NodeCallContext>, IServiceProvider> configurator in builder.RouteConfigurators)
|
||||
{
|
||||
configurator(router, provider);
|
||||
}
|
||||
|
||||
return new FleetRouter(router);
|
||||
});
|
||||
services.AddSingleton(provider => new FleetLinkHost(
|
||||
provider.GetRequiredService<IFleetStore>(),
|
||||
provider.GetRequiredService<FleetServerOptions>(),
|
||||
provider.GetRequiredService<TimeProvider>(),
|
||||
provider.GetRequiredService<ILogger<FleetLinkHost>>(),
|
||||
provider.GetServices<IStateProvider>(),
|
||||
provider.GetServices<IInbox>(),
|
||||
provider.GetServices<INodeEventSink>(),
|
||||
provider.GetRequiredService<FleetRouter>()));
|
||||
services.AddSingleton<IFleetConnections>(provider => provider.GetRequiredService<FleetLinkHost>());
|
||||
services.AddSingleton<IStateNotifier>(provider => provider.GetRequiredService<FleetLinkHost>());
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>映射 WebSocket 端点。调用方需要先 <c>app.UseWebSockets()</c>。</summary>
|
||||
public static IEndpointRouteBuilder MapFleetLink(this IEndpointRouteBuilder app, string path)
|
||||
{
|
||||
app.Map(path, async (HttpContext http) =>
|
||||
{
|
||||
if (!http.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
http.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
return;
|
||||
}
|
||||
|
||||
using System.Net.WebSockets.WebSocket socket = await http.WebSockets.AcceptWebSocketAsync();
|
||||
await http.RequestServices.GetRequiredService<FleetLinkHost>().RunConnectionAsync(socket, http.RequestAborted);
|
||||
});
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Fleet.Core;
|
||||
|
||||
namespace Fleet.Server;
|
||||
|
||||
/// <summary>节点的会话信息(来自 fleet.hello)。</summary>
|
||||
public sealed record NodeSessionInfo(
|
||||
NodeId NodeId,
|
||||
long ConnectedAtMs,
|
||||
string? NodeVersion,
|
||||
IReadOnlyList<string> Capabilities,
|
||||
string? HelloMetaJson);
|
||||
|
||||
/// <summary>在线表,以及服务端向节点发起的调用。</summary>
|
||||
public interface IFleetConnections
|
||||
{
|
||||
IReadOnlyCollection<NodeId> OnlineNodes { get; }
|
||||
|
||||
bool IsOnline(NodeId node);
|
||||
|
||||
NodeSessionInfo? GetSession(NodeId node);
|
||||
|
||||
/// <summary>向节点发起请求。节点不在线时返回 <c>fleet_node_offline</c>;超时返回 <c>fleet_timeout</c>。</summary>
|
||||
ValueTask<Result<TResult>> RequestAsync<TParams, TResult>(
|
||||
NodeId node,
|
||||
string method,
|
||||
TParams parameters,
|
||||
JsonTypeInfo<TParams> paramsInfo,
|
||||
JsonTypeInfo<TResult> resultInfo,
|
||||
CancellationToken ct);
|
||||
|
||||
ValueTask<bool> NotifyAsync<TParams>(NodeId node, string method, TParams parameters, JsonTypeInfo<TParams> paramsInfo, CancellationToken ct);
|
||||
|
||||
/// <summary>断开节点的连接(先发送 <c>fleet.revoked</c>)。</summary>
|
||||
ValueTask DisconnectAsync(NodeId node, string reason, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// 服务端要停止了:对所有在线节点做 Close 握手,<b>不</b>发送 <c>fleet.revoked</c>(节点会按退避重连,等服务端回来)。
|
||||
/// 长连接不主动关闭的话,宿主的有序停止要等满超时(30 秒)才会退出。
|
||||
/// </summary>
|
||||
ValueTask CloseAllAsync(string reason);
|
||||
}
|
||||
|
||||
/// <summary>调用方信息,传给产品注册的 RPC 处理函数。</summary>
|
||||
public sealed record NodeCallContext(NodeRecord Node, NodeSessionInfo Session, IFleetConnections Connections);
|
||||
@@ -0,0 +1,729 @@
|
||||
using System.Buffers;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading.Channels;
|
||||
using System.Text.Json;
|
||||
using Fleet.Core;
|
||||
using Fleet.Identity;
|
||||
using Fleet.Rpc;
|
||||
|
||||
namespace Fleet.Server;
|
||||
|
||||
/// <summary>
|
||||
/// 一个节点的 WebSocket 会话:挑战 → 签名认证 → hello → 状态推送 / 收件箱 / 产品 RPC。
|
||||
/// 收到的消息按顺序处理,所以产品的处理函数不能等待“向同一个节点发起的请求”,否则会死锁。
|
||||
/// </summary>
|
||||
internal sealed class NodeConnection : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan s_authTimeout = TimeSpan.FromSeconds(15);
|
||||
private static readonly TimeSpan s_presenceRefresh = TimeSpan.FromSeconds(60);
|
||||
private static readonly TimeSpan s_statePushRetry = TimeSpan.FromSeconds(5);
|
||||
private static readonly TimeSpan s_closeTimeout = TimeSpan.FromSeconds(2);
|
||||
|
||||
private readonly FleetLinkHost _host;
|
||||
private readonly WebSocket _socket;
|
||||
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
||||
private readonly ConcurrentDictionary<long, TaskCompletionSource<RpcMessage>> _pending = new();
|
||||
private readonly Channel<bool> _stateSignal = Channel.CreateBounded<bool>(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite });
|
||||
private readonly Dictionary<string, long> _lastPushedVersion = new(StringComparer.Ordinal);
|
||||
private readonly CancellationTokenSource _lifetime = new();
|
||||
private long _nextRequestId;
|
||||
private long _lastPresenceTicks;
|
||||
private NodeRecord? _node;
|
||||
private NodeSessionInfo? _session;
|
||||
private string? _closeReason;
|
||||
private bool _helloDone;
|
||||
|
||||
public NodeConnection(FleetLinkHost host, WebSocket socket)
|
||||
{
|
||||
_host = host;
|
||||
_socket = socket;
|
||||
}
|
||||
|
||||
public NodeId NodeId => _node!.Id;
|
||||
|
||||
public NodeSessionInfo Session => _session!;
|
||||
|
||||
public async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, _lifetime.Token);
|
||||
CancellationToken token = linked.Token;
|
||||
bool registered = false;
|
||||
Task? pusher = null;
|
||||
try
|
||||
{
|
||||
if (!await AuthenticateAsync(token))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
NodeConnection? old = _host.Register(this);
|
||||
registered = true;
|
||||
if (old is not null)
|
||||
{
|
||||
await old.CloseAsync("replaced");
|
||||
}
|
||||
|
||||
string status = StatusName(_node!.Status);
|
||||
FleetLog.Connected(_host.Logger, _node.Id.Value, status);
|
||||
await UpdatePresenceAsync(online: true, token);
|
||||
await FireAsync(NodeEventKind.Online, token);
|
||||
|
||||
pusher = Task.Run(() => StatePushLoopAsync(token), CancellationToken.None);
|
||||
await ReceiveLoopAsync(token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 关闭原因由触发取消的一方设置
|
||||
}
|
||||
catch (WebSocketException)
|
||||
{
|
||||
_closeReason = "connection lost";
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _lifetime.CancelAsync();
|
||||
if (pusher is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await pusher;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 正常结束
|
||||
}
|
||||
}
|
||||
|
||||
foreach (TaskCompletionSource<RpcMessage> waiter in _pending.Values)
|
||||
{
|
||||
waiter.TrySetCanceled(CancellationToken.None);
|
||||
}
|
||||
|
||||
if (registered)
|
||||
{
|
||||
_host.Unregister(this);
|
||||
FleetLog.Disconnected(_host.Logger, _node!.Id.Value, _closeReason ?? "closed");
|
||||
|
||||
// 被新连接替换时,在线状态属于新连接。
|
||||
if (!_host.IsOnline(_node.Id))
|
||||
{
|
||||
await UpdatePresenceAsync(online: false, CancellationToken.None);
|
||||
await FireAsync(NodeEventKind.Offline, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
if (_socket.State is WebSocketState.Open or WebSocketState.CloseReceived)
|
||||
{
|
||||
// 完整的关闭握手:发送 Close 并等待对端确认(有上限),这样对端能收到关闭原因
|
||||
try
|
||||
{
|
||||
using var closeTimeout = new CancellationTokenSource(s_closeTimeout, _host.Time);
|
||||
await _socket.CloseAsync(WebSocketCloseStatus.NormalClosure, _closeReason ?? "closed", closeTimeout.Token);
|
||||
}
|
||||
catch (WebSocketException)
|
||||
{
|
||||
// 对端已经断开
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// 对端没有在时限内确认:不再等待
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_lifetime.Dispose();
|
||||
_sendLock.Dispose();
|
||||
}
|
||||
|
||||
public void SignalStateChanged() => _stateSignal.Writer.TryWrite(true);
|
||||
|
||||
/// <summary>
|
||||
/// 服务端发起的关闭:发送 Close 帧,等对端确认之后接收循环自然结束。
|
||||
/// 注意不能直接取消接收的令牌——取消 <c>ReceiveAsync</c> 会中止整个 WebSocket,对端拿不到关闭原因;取消只作为超时兜底。
|
||||
/// </summary>
|
||||
public async ValueTask CloseAsync(string reason)
|
||||
{
|
||||
_closeReason ??= reason;
|
||||
try
|
||||
{
|
||||
await _sendLock.WaitAsync(CancellationToken.None);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_socket.State == WebSocketState.Open)
|
||||
{
|
||||
await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, reason, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
catch (WebSocketException)
|
||||
{
|
||||
// 对端已经断开
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sendLock.Release();
|
||||
}
|
||||
|
||||
_lifetime.CancelAfter(s_closeTimeout);
|
||||
}
|
||||
|
||||
public async ValueTask RevokeAsync(string reason, CancellationToken ct)
|
||||
{
|
||||
await SendAsync(RpcMessage.Notification(FleetMethods.Revoked, new RevokedParams { Reason = reason }, FleetRpcContext.Default.RevokedParams), ct);
|
||||
await CloseAsync(reason);
|
||||
}
|
||||
|
||||
public async ValueTask<bool> SendAsync(RpcMessage message, CancellationToken ct)
|
||||
{
|
||||
byte[] bytes = JsonSerializer.SerializeToUtf8Bytes(message, FleetRpcContext.Default.RpcMessage);
|
||||
try
|
||||
{
|
||||
await _sendLock.WaitAsync(ct);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_socket.State != WebSocketState.Open)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await _socket.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, ct);
|
||||
return true;
|
||||
}
|
||||
catch (WebSocketException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sendLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<Result<JsonElement>> RequestAsync(string method, JsonElement parameters, CancellationToken ct)
|
||||
{
|
||||
long id = Interlocked.Increment(ref _nextRequestId);
|
||||
var waiter = new TaskCompletionSource<RpcMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_pending[id] = waiter;
|
||||
try
|
||||
{
|
||||
var request = new RpcMessage { Id = id, Method = method, Params = parameters };
|
||||
if (!await SendAsync(request, ct))
|
||||
{
|
||||
return Result<JsonElement>.Fail(FleetErrors.NodeOffline, "node is not connected");
|
||||
}
|
||||
|
||||
using var timeout = new CancellationTokenSource(_host.Options.RequestTimeout, _host.Time);
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
||||
RpcMessage response = await waiter.Task.WaitAsync(linked.Token);
|
||||
return response.Error is not null
|
||||
? Result<JsonElement>.Fail(response.Error.ToFleetError())
|
||||
: Result<JsonElement>.Ok(response.Result ?? default);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
return Result<JsonElement>.Fail(FleetErrors.Timeout, $"{method} timed out");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pending.TryRemove(id, out _);
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────── 认证 ─────────────────────────
|
||||
|
||||
private async Task<bool> AuthenticateAsync(CancellationToken ct)
|
||||
{
|
||||
string nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||
long expiresAtMs = _host.Time.GetUtcNow().Add(_host.Options.ChallengeTtl).ToUnixTimeMilliseconds();
|
||||
await SendAsync(
|
||||
RpcMessage.Notification(FleetMethods.Challenge, new ChallengeParams { Nonce = nonce, ExpiresAtMs = expiresAtMs }, FleetRpcContext.Default.ChallengeParams),
|
||||
ct);
|
||||
|
||||
using var timeout = new CancellationTokenSource(s_authTimeout, _host.Time);
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token);
|
||||
Frame? frame;
|
||||
try
|
||||
{
|
||||
frame = await ReceiveFrameAsync(linked.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
await RejectAsync(null, "auth timeout", ct);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (frame is not { IsText: true } || !TryParse(frame.Data, out RpcMessage? message) || message.Method != FleetMethods.Auth || message.Id is null)
|
||||
{
|
||||
await RejectAsync(null, "expected fleet.auth", ct);
|
||||
return false;
|
||||
}
|
||||
|
||||
AuthParams? auth = null;
|
||||
try
|
||||
{
|
||||
auth = message.Params?.Deserialize(FleetRpcContext.Default.AuthParams);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 按缺失处理
|
||||
}
|
||||
|
||||
NodeRecord? node = auth is null ? null : await _host.Store.GetNodeAsync(new NodeId(auth.NodeId), ct);
|
||||
bool signatureOk = node is not null
|
||||
&& _host.Time.GetUtcNow().ToUnixTimeMilliseconds() <= expiresAtMs
|
||||
&& TryDecode(auth!.Signature, out byte[] signature)
|
||||
&& NodeIdentity.Verify(node.PublicKey, signature, SignaturePurpose.Auth, auth.NodeId, nonce);
|
||||
if (!signatureOk)
|
||||
{
|
||||
// 节点不存在与签名错误返回同样的错误,不泄露节点是否存在。
|
||||
await RejectAsync(message.Id, "authentication failed", ct);
|
||||
return false;
|
||||
}
|
||||
|
||||
_node = node;
|
||||
if (node!.Status == NodeStatus.Disabled)
|
||||
{
|
||||
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.NodeDisabled, "node is disabled")), ct);
|
||||
await RevokeAsync("disabled", ct);
|
||||
return false;
|
||||
}
|
||||
|
||||
_session = new NodeSessionInfo(node.Id, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), node.NodeVersion, [], null);
|
||||
await SendAsync(
|
||||
RpcMessage.Success(message.Id.Value, new AuthResult { Status = StatusName(node.Status) }, FleetRpcContext.Default.AuthResult),
|
||||
ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task RejectAsync(long? id, string reason, CancellationToken ct)
|
||||
{
|
||||
FleetLog.AuthRejected(_host.Logger, reason);
|
||||
_closeReason ??= reason;
|
||||
if (id is not null)
|
||||
{
|
||||
await SendAsync(RpcMessage.Failure(id, new FleetError(FleetErrors.Unauthorized, "authentication failed")), ct);
|
||||
}
|
||||
|
||||
if (_socket.State == WebSocketState.Open)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _socket.CloseOutputAsync(WebSocketCloseStatus.PolicyViolation, "unauthorized", ct);
|
||||
}
|
||||
catch (WebSocketException)
|
||||
{
|
||||
// 对端已经断开
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────── 接收循环 ─────────────────────────
|
||||
|
||||
private async Task ReceiveLoopAsync(CancellationToken ct)
|
||||
{
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
using var idle = new CancellationTokenSource(_host.Options.HeartbeatTimeout, _host.Time);
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(ct, idle.Token);
|
||||
Frame? frame;
|
||||
try
|
||||
{
|
||||
frame = await ReceiveFrameAsync(linked.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (idle.IsCancellationRequested && !ct.IsCancellationRequested)
|
||||
{
|
||||
_closeReason ??= "heartbeat timeout";
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame is null)
|
||||
{
|
||||
_closeReason ??= "closed by peer";
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.IsText)
|
||||
{
|
||||
if (TryParse(frame.Data, out RpcMessage? message))
|
||||
{
|
||||
await HandleMessageAsync(message, ct);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await HandleBinaryAsync(frame.Data, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleMessageAsync(RpcMessage message, CancellationToken ct)
|
||||
{
|
||||
if (message.IsResponse)
|
||||
{
|
||||
if (_pending.TryGetValue(message.Id!.Value, out TaskCompletionSource<RpcMessage>? waiter))
|
||||
{
|
||||
waiter.TrySetResult(message);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
switch (message.Method)
|
||||
{
|
||||
case FleetMethods.Hello:
|
||||
await HandleHelloAsync(message, ct);
|
||||
return;
|
||||
case FleetMethods.Heartbeat:
|
||||
await HandleHeartbeatAsync(message, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_node!.Status != NodeStatus.Active)
|
||||
{
|
||||
if (message.Id is not null)
|
||||
{
|
||||
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.NodePending, "node is not approved yet")), ct);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var context = new NodeCallContext(_node, _session!, _host);
|
||||
RpcMessage? response;
|
||||
try
|
||||
{
|
||||
response = await _host.Router.DispatchAsync(context, message, ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
|
||||
if (response is not null)
|
||||
{
|
||||
await SendAsync(response, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleHelloAsync(RpcMessage message, CancellationToken ct)
|
||||
{
|
||||
HelloParams? hello = null;
|
||||
try
|
||||
{
|
||||
hello = message.Params?.Deserialize(FleetRpcContext.Default.HelloParams);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 按缺失处理
|
||||
}
|
||||
|
||||
if (hello is null)
|
||||
{
|
||||
if (message.Id is not null)
|
||||
{
|
||||
await SendAsync(RpcMessage.Failure(message.Id, new FleetError(FleetErrors.InvalidParams, "invalid hello")), ct);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
long nowMs = _host.Time.GetUtcNow().ToUnixTimeMilliseconds();
|
||||
_session = _session! with
|
||||
{
|
||||
NodeVersion = hello.NodeVersion,
|
||||
Capabilities = hello.Capabilities,
|
||||
HelloMetaJson = hello.Meta?.GetRawText(),
|
||||
};
|
||||
_node = _node! with { NodeVersion = hello.NodeVersion };
|
||||
_helloDone = true;
|
||||
|
||||
await UpdatePresenceAsync(online: true, ct);
|
||||
await FireAsync(NodeEventKind.Hello, ct, hello);
|
||||
|
||||
if (message.Id is not null)
|
||||
{
|
||||
await SendAsync(
|
||||
RpcMessage.Success(
|
||||
message.Id.Value,
|
||||
new HelloResult { ServerTimeMs = nowMs, ClockOffsetMs = hello.NowUtcMs - nowMs },
|
||||
FleetRpcContext.Default.HelloResult),
|
||||
ct);
|
||||
}
|
||||
|
||||
SignalStateChanged();
|
||||
}
|
||||
|
||||
private async Task HandleHeartbeatAsync(RpcMessage message, CancellationToken ct)
|
||||
{
|
||||
JsonElement? extra = null;
|
||||
try
|
||||
{
|
||||
extra = message.Params?.Deserialize(FleetRpcContext.Default.HeartbeatParams)?.Extra;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 忽略格式不正确的附加字段
|
||||
}
|
||||
|
||||
long now = _host.Time.GetTimestamp();
|
||||
if (_host.Time.GetElapsedTime(Interlocked.Read(ref _lastPresenceTicks), now) >= s_presenceRefresh)
|
||||
{
|
||||
await UpdatePresenceAsync(online: true, ct);
|
||||
}
|
||||
|
||||
await FireAsync(NodeEventKind.Heartbeat, ct, hello: null, extra);
|
||||
if (message.Id is not null)
|
||||
{
|
||||
await SendAsync(new RpcMessage { Id = message.Id, Result = JsonSerializer.SerializeToElement(new EmptyObject(), FleetEmptyObject.Info) }, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleBinaryAsync(byte[] data, CancellationToken ct)
|
||||
{
|
||||
if (_node!.Status != NodeStatus.Active
|
||||
|| !OutboxFrame.TryParse(data, out byte channel, out Guid msgId, out ReadOnlyMemory<byte> payload)
|
||||
|| !_host.Inboxes.TryGetValue(channel, out IInbox? inbox))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
InboxOutcome outcome;
|
||||
try
|
||||
{
|
||||
outcome = await inbox.HandleAsync(_node, msgId, payload, ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 没有持久化就不确认,节点会重发。
|
||||
FleetLog.InboxFailed(_host.Logger, ex, _node.Id.Value, channel);
|
||||
return;
|
||||
}
|
||||
|
||||
if (outcome == InboxOutcome.Rejected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var ack = new OutboxAckParams();
|
||||
string id = msgId.ToString();
|
||||
if (outcome == InboxOutcome.Duplicate)
|
||||
{
|
||||
ack.Duplicates.Add(id);
|
||||
}
|
||||
else
|
||||
{
|
||||
ack.Ids.Add(id);
|
||||
}
|
||||
|
||||
await SendAsync(RpcMessage.Notification(FleetMethods.OutboxAck, ack, FleetRpcContext.Default.OutboxAckParams), ct);
|
||||
}
|
||||
|
||||
// ───────────────────────── 状态推送 ─────────────────────────
|
||||
|
||||
private async Task StatePushLoopAsync(CancellationToken ct)
|
||||
{
|
||||
while (await _stateSignal.Reader.WaitToReadAsync(ct))
|
||||
{
|
||||
_stateSignal.Reader.TryRead(out _);
|
||||
bool complete = await PushStateAsync(ct);
|
||||
if (!complete)
|
||||
{
|
||||
await Task.Delay(s_statePushRetry, _host.Time, ct);
|
||||
SignalStateChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> PushStateAsync(CancellationToken ct)
|
||||
{
|
||||
NodeRecord? fresh = await _host.Store.GetNodeAsync(_node!.Id, ct);
|
||||
if (fresh is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
_node = fresh with { NodeVersion = _node.NodeVersion ?? fresh.NodeVersion };
|
||||
if (fresh.Status == NodeStatus.Disabled)
|
||||
{
|
||||
await RevokeAsync("disabled", ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (fresh.Status != NodeStatus.Active || !_helloDone)
|
||||
{
|
||||
// pending 不下发状态;还没有 hello 的连接等 hello 之后再推送。
|
||||
return true;
|
||||
}
|
||||
|
||||
bool complete = true;
|
||||
foreach (IStateProvider provider in _host.StateProviders)
|
||||
{
|
||||
StateDocument? document = await provider.GetAsync(_node, ct);
|
||||
if (document is null || (_lastPushedVersion.TryGetValue(provider.Type, out long last) && last == document.Version))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Result<JsonElement> raw = await RequestAsync(
|
||||
FleetMethods.StateSync,
|
||||
JsonSerializer.SerializeToElement(
|
||||
new StateSyncParams { Type = provider.Type, Version = document.Version, Doc = document.Doc },
|
||||
FleetRpcContext.Default.StateSyncParams),
|
||||
ct);
|
||||
if (!raw.TryGet(out JsonElement value, out FleetError? error))
|
||||
{
|
||||
FleetLog.StatePushFailed(_host.Logger, _node.Id.Value, provider.Type, error.Code.Value);
|
||||
complete = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
StateSyncResult? result = null;
|
||||
try
|
||||
{
|
||||
result = value.Deserialize(FleetRpcContext.Default.StateSyncResult);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 按缺失处理
|
||||
}
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
complete = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
_lastPushedVersion[provider.Type] = document.Version;
|
||||
await provider.OnAppliedAsync(_node, document.Version, result, ct);
|
||||
}
|
||||
|
||||
return complete;
|
||||
}
|
||||
|
||||
// ───────────────────────── 帮助方法 ─────────────────────────
|
||||
|
||||
private async Task UpdatePresenceAsync(bool online, CancellationToken ct)
|
||||
{
|
||||
Interlocked.Exchange(ref _lastPresenceTicks, _host.Time.GetTimestamp());
|
||||
await _host.Store.UpdatePresenceAsync(
|
||||
_node!.Id,
|
||||
new Presence(online, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), _session?.NodeVersion, _session?.Capabilities ?? [], _session?.HelloMetaJson),
|
||||
ct);
|
||||
}
|
||||
|
||||
private async Task FireAsync(NodeEventKind kind, CancellationToken ct, HelloParams? hello = null, JsonElement? extra = null)
|
||||
{
|
||||
var nodeEvent = new NodeEvent(kind, _node!, _host.Time.GetUtcNow().ToUnixTimeMilliseconds(), hello, extra);
|
||||
foreach (INodeEventSink sink in _host.Sinks)
|
||||
{
|
||||
try
|
||||
{
|
||||
await sink.OnEventAsync(nodeEvent, ct);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 产品的事件处理失败不能影响连接。
|
||||
FleetLog.SinkFailed(_host.Logger, ex, kind.ToString(), _node!.Id.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask<Frame?> ReceiveFrameAsync(CancellationToken ct)
|
||||
{
|
||||
var writer = new ArrayBufferWriter<byte>(1024);
|
||||
while (true)
|
||||
{
|
||||
Memory<byte> buffer = writer.GetMemory(4096);
|
||||
ValueWebSocketReceiveResult result = await _socket.ReceiveAsync(buffer, ct);
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
writer.Advance(result.Count);
|
||||
if (writer.WrittenCount > _host.Options.MaxFrameBytes)
|
||||
{
|
||||
_closeReason ??= "frame too large";
|
||||
await _socket.CloseOutputAsync(WebSocketCloseStatus.MessageTooBig, "frame too large", CancellationToken.None);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (result.EndOfMessage)
|
||||
{
|
||||
return new Frame(result.MessageType == WebSocketMessageType.Text, writer.WrittenSpan.ToArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParse(byte[] data, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out RpcMessage? message)
|
||||
{
|
||||
try
|
||||
{
|
||||
message = JsonSerializer.Deserialize(data, FleetRpcContext.Default.RpcMessage);
|
||||
return message is not null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
message = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryDecode(string text, out byte[] data)
|
||||
{
|
||||
try
|
||||
{
|
||||
data = Convert.FromBase64String(text);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
data = [];
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string StatusName(NodeStatus status) => status switch
|
||||
{
|
||||
NodeStatus.Active => "active",
|
||||
NodeStatus.Pending => "pending",
|
||||
_ => "disabled",
|
||||
};
|
||||
|
||||
private sealed record Frame(bool IsText, byte[] Data);
|
||||
}
|
||||
|
||||
[System.Text.Json.Serialization.JsonSerializable(typeof(EmptyObject))]
|
||||
internal sealed partial class FleetEmptyObjectContext : System.Text.Json.Serialization.JsonSerializerContext;
|
||||
|
||||
internal sealed class EmptyObject;
|
||||
|
||||
internal static class FleetEmptyObject
|
||||
{
|
||||
public static System.Text.Json.Serialization.Metadata.JsonTypeInfo<EmptyObject> Info => FleetEmptyObjectContext.Default.EmptyObject;
|
||||
}
|
||||
Reference in new issue
Block a user