From 78fd72521edcf646d1af701ad536a8476a1bd85b Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Tue, 27 Oct 2026 08:51:39 +0800 Subject: [PATCH 1/9] =?UTF-8?q?chore:=20=E5=A2=9E=E5=8A=A0=20SignalR.Clien?= =?UTF-8?q?t=20=E5=B7=A5=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BootstrapBlazor.Extensions.slnx | 1 + .../BootstrapBlazor.SignalR.Client.csproj | 39 +++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 src/extensions/BootstrapBlazor.SignalR.Client/BootstrapBlazor.SignalR.Client.csproj diff --git a/BootstrapBlazor.Extensions.slnx b/BootstrapBlazor.Extensions.slnx index 088bbf7d..35ea650e 100644 --- a/BootstrapBlazor.Extensions.slnx +++ b/BootstrapBlazor.Extensions.slnx @@ -84,6 +84,7 @@ + diff --git a/src/extensions/BootstrapBlazor.SignalR.Client/BootstrapBlazor.SignalR.Client.csproj b/src/extensions/BootstrapBlazor.SignalR.Client/BootstrapBlazor.SignalR.Client.csproj new file mode 100644 index 00000000..e3c8e005 --- /dev/null +++ b/src/extensions/BootstrapBlazor.SignalR.Client/BootstrapBlazor.SignalR.Client.csproj @@ -0,0 +1,39 @@ + + + + 11.0.0-rc.2.1 + + + + Optional SignalR client transport for BootstrapBlazor dispatch and connection services. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From b5a47ba898f3af670eac636c97bee53f94342f73 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Tue, 27 Oct 2026 08:52:11 +0800 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E5=BF=83=E8=B7=B3=E4=B8=8E=E6=B6=88=E6=81=AF=E5=88=86?= =?UTF-8?q?=E5=8F=91=20wasm=20=E6=A8=A1=E5=BC=8F=E4=B8=8B=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DefaultConnectionService.cs | 148 +++++++ .../DefaultDispatchService.cs | 248 +++++++++++ .../ServiceCollectionExtensions.cs | 43 ++ .../SignalRConnection.cs | 397 ++++++++++++++++++ 4 files changed, 836 insertions(+) create mode 100644 src/extensions/BootstrapBlazor.SignalR.Client/DefaultConnectionService.cs create mode 100644 src/extensions/BootstrapBlazor.SignalR.Client/DefaultDispatchService.cs create mode 100644 src/extensions/BootstrapBlazor.SignalR.Client/ServiceCollectionExtensions.cs create mode 100644 src/extensions/BootstrapBlazor.SignalR.Client/SignalRConnection.cs diff --git a/src/extensions/BootstrapBlazor.SignalR.Client/DefaultConnectionService.cs b/src/extensions/BootstrapBlazor.SignalR.Client/DefaultConnectionService.cs new file mode 100644 index 00000000..473f5840 --- /dev/null +++ b/src/extensions/BootstrapBlazor.SignalR.Client/DefaultConnectionService.cs @@ -0,0 +1,148 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +using Microsoft.Extensions.Logging; + +namespace BootstrapBlazor.Components; + +/// +/// 基于 SignalR 的客户端连接服务,通过心跳上报连接信息并轮询服务端连接快照 +/// SignalR client connection service that reports connection information through heartbeats and polls server connection snapshots +/// +sealed class DefaultConnectionService : IConnectionService, IAsyncDisposable +{ + private readonly SignalRConnection _connection; + private readonly ILogger _logger; + private readonly CancellationTokenSource _lifetime = new(); + private readonly Task _pollingTask; + private ConnectionSnapshot _snapshot = new(); + private int _disposed; + + /// + /// 初始化客户端连接服务并启动连接快照轮询 + /// Initialize the client connection service and start connection snapshot polling + /// + /// 共享的 SignalR 连接The shared SignalR connection + /// 日志记录器The logger + public DefaultConnectionService(SignalRConnection connection, ILogger logger) + { + _connection = connection; + _logger = logger; + _connection.Retain(); + _pollingTask = PollAsync(_lifetime.Token); + } + + /// + /// 获得 最近一次服务端快照中的连接数量 + /// Gets the connection count from the latest server snapshot + /// + public long Count => _snapshot.Count; + + /// + /// 获得 最近一次服务端快照中的连接集合 + /// Gets the connection collection from the latest server snapshot + /// + public ICollection Connections => _snapshot.Connections; + + /// + /// 根据连接标识从本地快照中获取连接信息 + /// Try to get connection information from the local snapshot by connection identifier + /// + /// 连接标识The connection identifier + /// 匹配的连接信息,未找到时为 nullThe matching connection information, or null if not found + /// 找到连接时返回 true,否则返回 falseTrue if the connection is found; otherwise, false + public bool TryGetValue(string key, [NotNullWhen(true)] out ConnectionItem? value) + { + value = _snapshot.Connections.FirstOrDefault(item => item.Id == key); + return value != null; + } + + /// + /// 向服务端发送心跳以添加或更新客户端连接信息 + /// Send a heartbeat to the server to add or update client connection information + /// + /// 客户端信息The client information + /// 取消令牌The cancellation token + /// 表示异步操作的任务A task representing the asynchronous operation + public async Task AddOrUpdateAsync(ClientInfo client, CancellationToken cancellationToken = default) + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetime.Token); + if (!await _connection.InvokeAsync("Heartbeat", client, cancellation.Token)) + { + _logger.LogWarning("Connection heartbeats are disabled on the server."); + } + } + + /// + /// 定期刷新连接快照,查询被禁用、未获授权或请求被拒绝时停止轮询 + /// Periodically refresh the connection snapshot, stopping when queries are disabled, unauthorized, or rejected + /// + /// 取消令牌The cancellation token + /// 表示轮询操作的任务A task representing the polling operation + private async Task PollAsync(CancellationToken cancellationToken) + { + try + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + var snapshot = await _connection.InvokeAsync("GetConnectionSnapshot", null, cancellationToken); + if (snapshot == null) + { + _snapshot = new(); + _logger.LogInformation("Connection snapshot polling stopped because queries are disabled or not authorized."); + return; + } + _snapshot = snapshot; + } + catch (HttpRequestException ex) when (!cancellationToken.IsCancellationRequested && !SignalRConnection.IsTransientFailure(ex)) + { + _snapshot = new(); + _logger.LogWarning(ex, "Connection snapshot polling stopped because the Hub request was rejected."); + return; + } + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) + { + _logger.LogError(ex, "Unable to refresh the connection snapshot."); + } + await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + } + + /// + /// 停止连接快照轮询并释放共享连接的使用引用 + /// Stop connection snapshot polling and release the usage reference to the shared connection + /// + /// 表示异步释放操作的任务A task representing the asynchronous disposal operation + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + _lifetime.Cancel(); + try + { + await _pollingTask; + } + finally + { + try + { + await _connection.ReleaseAsync(); + } + finally + { + _lifetime.Dispose(); + } + } + } +} diff --git a/src/extensions/BootstrapBlazor.SignalR.Client/DefaultDispatchService.cs b/src/extensions/BootstrapBlazor.SignalR.Client/DefaultDispatchService.cs new file mode 100644 index 00000000..3ff5563f --- /dev/null +++ b/src/extensions/BootstrapBlazor.SignalR.Client/DefaultDispatchService.cs @@ -0,0 +1,248 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +using Microsoft.Extensions.Logging; +using System.Text.Json; + +namespace BootstrapBlazor.Components; + +/// +/// 基于 SignalR 的客户端消息分发服务 +/// SignalR client dispatch service +/// +/// 消息数据类型The entry data type +/// 共享的 SignalR 连接The shared SignalR connection +/// 日志记录器The logger +sealed class DefaultDispatchService( + SignalRConnection connection, + ILogger> logger) : IDispatchService, IAsyncDisposable +{ + private static readonly string _entryType = typeof(TEntry).FullName ?? typeof(TEntry).Name; + private static readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web); + private readonly List _callbacks = []; + private readonly object _locker = new(); + private readonly SemaphoreSlim _subscriptions = new(1, 1); + private readonly CancellationTokenSource _lifetime = new(); + private bool _requiresUnsubscribe; + + /// + /// 向服务端分发消息,成功后通知本地订阅者 + /// Dispatch an entry to the server and notify local subscribers after a successful send + /// + /// 消息分发项The dispatch entry + /// 取消令牌The cancellation token + /// 表示异步操作的任务A task representing the asynchronous operation + public async Task DispatchAsync(DispatchEntry entry, CancellationToken cancellationToken = default) + { + await connection.DispatchAsync(_entryType, entry, cancellationToken); + await DispatchCoreAsync(entry, cancellationToken); + } + + /// + /// 注册消息回调,并在添加首个本地订阅者时订阅服务端消息 + /// Register a message callback and subscribe to server messages when adding the first local subscriber + /// + /// 消息回调委托The message callback + /// 取消令牌The cancellation token + /// 表示异步操作的任务A task representing the asynchronous operation + public async Task SubscribeAsync(Func, Task> callback, CancellationToken cancellationToken = default) + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetime.Token); + var token = cancellation.Token; + // 串行处理订阅与退订,避免后续订阅者在首个远程订阅尚未就绪时返回成功。 + // Serialize subscription changes so later subscribers cannot succeed before the first remote subscription is ready. + await _subscriptions.WaitAsync(token); + try + { + var subscription = new Subscription(callback); + // first 表示本次添加前没有本地订阅者,而非服务生命周期内第一次订阅。 + // 同一消息类型仅在本地订阅数从 0 变为 1 时请求远程订阅,后续回调共用该订阅。 + // first means there were no local subscribers before this addition, not the first subscription in the service lifetime. + // Request a remote subscription only when the local count changes from 0 to 1; later callbacks share it. + bool first; + lock (_locker) + { + first = _callbacks.Count == 0; + _callbacks.Add(subscription); + } + if (first) + { + try + { + while (true) + { + // 这里不能写 while(!token.IsCancellationRequested) 因为取消时仍需清理远程订阅。 + // Do not write while(!token.IsCancellationRequested) here because remote subscription still needs to be cleaned up when canceled. + token.ThrowIfCancellationRequested(); + try + { + // 远程调用失败或取消时,服务端仍可能已完成订阅,因此提前标记需要清理。 + // The server may have subscribed even if the call fails or is canceled, so mark cleanup as required beforehand. + _requiresUnsubscribe = true; + await connection.SubscribeAsync(_entryType, ReceiveAsync, token); + break; + } + catch (Exception ex) when (!token.IsCancellationRequested && + (SignalRConnection.IsTransientFailure(ex) || + ex is InvalidOperationException && connection.IsReconnecting)) + { + logger.LogWarning(ex, "Unable to subscribe to dispatch entry type {EntryType}; retrying.", _entryType); + await Task.Delay(TimeSpan.FromSeconds(3), token); + } + } + } + catch + { + // 订阅未就绪时撤销本次本地注册,使后续订阅者仍能走 first 分支重新尝试。 + // Roll back this registration if subscribing fails so the next subscriber can retry through the first branch. + lock (_locker) + { + _callbacks.Remove(subscription); + } + throw; + } + } + } + finally + { + _subscriptions.Release(); + } + } + + /// + /// 移除消息回调,并在移除最后一个本地订阅者时取消服务端订阅 + /// Remove a message callback and unsubscribe from the server when removing the last local subscriber + /// + /// 消息回调委托The message callback + /// 取消令牌The cancellation token + /// 表示异步操作的任务A task representing the asynchronous operation + /// + /// 传输层退订取消或失败时保留待清理状态,再次退订或释放服务时继续清理 + /// If transport unsubscription is canceled or fails, retain the pending cleanup state for a subsequent unsubscription or service disposal + /// + public async Task UnSubscribeAsync(Func, Task> callback, CancellationToken cancellationToken = default) + { + await _subscriptions.WaitAsync(cancellationToken); + try + { + bool empty; + lock (_locker) + { + var index = _callbacks.FindIndex(subscription => subscription.Callback == callback); + if (index >= 0) + { + _callbacks.RemoveAt(index); + } + + // 集合中仍可能存在相同回调的其他订阅记录,因此仅在集合为空时才退订服务端。 + // There may still be other subscription records with the same callback in the collection, so unsubscribe from the server only when the collection is empty. + empty = _callbacks.Count == 0; + } + if (empty && _requiresUnsubscribe) + { + await connection.UnsubscribeAsync(_entryType, cancellationToken); + _requiresUnsubscribe = false; + } + } + finally + { + _subscriptions.Release(); + } + } + + /// + /// 反序列化服务端消息并通知本地订阅者 + /// Deserialize a server message and notify local subscribers + /// + /// 消息的 JSON 数据The message JSON data + /// 表示异步操作的任务A task representing the asynchronous operation + private Task ReceiveAsync(JsonElement json) + => DispatchCoreAsync(json.Deserialize>(_serializerOptions) + ?? throw new JsonException("The dispatch entry must not be null.")); + + /// + /// 依次调用本地消息回调,记录单个回调的异常并继续通知其他订阅者 + /// Invoke local message callbacks in order, logging individual callback failures and continuing to notify other subscribers + /// + /// 消息分发项The dispatch entry + /// 取消令牌The cancellation token + /// 表示异步操作的任务A task representing the asynchronous operation + private async Task DispatchCoreAsync(DispatchEntry entry, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + Subscription[] callbacks; + lock (_locker) + { + callbacks = _callbacks.ToArray(); + } + foreach (var subscription in callbacks) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + Task callbackTask; + lock (_locker) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!_callbacks.Contains(subscription)) + { + continue; + } + + callbackTask = subscription.Callback(entry); + } + + await callbackTask.WaitAsync(cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + } + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) + { + logger.LogError(ex, "A dispatch subscriber failed for entry type {EntryType}.", _entryType); + } + } + } + + /// + /// 取消正在进行的订阅操作,清空本地回调并清理传输层订阅,包括之前未完成的退订 + /// Cancel pending subscription operations, clear local callbacks, and clean up transport subscriptions, including previously incomplete unsubscriptions + /// + /// 表示异步释放操作的任务A task representing the asynchronous disposal operation + public async ValueTask DisposeAsync() + { + _lifetime.Cancel(); + await _subscriptions.WaitAsync(); + try + { + lock (_locker) + { + _callbacks.Clear(); + } + if (_requiresUnsubscribe) + { + await connection.UnsubscribeAsync(_entryType, CancellationToken.None); + _requiresUnsubscribe = false; + } + } + finally + { + _subscriptions.Release(); + } + } + + /// + /// 独立的消息订阅记录,用于区分同一回调的多次订阅 + /// An individual message subscription that distinguishes separate registrations of the same callback + /// + /// 消息回调委托The message callback + private sealed class Subscription(Func, Task> callback) + { + /// + /// 获得 消息回调委托 + /// Gets the message callback + /// + public Func, Task> Callback { get; } = callback; + } +} diff --git a/src/extensions/BootstrapBlazor.SignalR.Client/ServiceCollectionExtensions.cs b/src/extensions/BootstrapBlazor.SignalR.Client/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..f4ab39e5 --- /dev/null +++ b/src/extensions/BootstrapBlazor.SignalR.Client/ServiceCollectionExtensions.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +using BootstrapBlazor.Components; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// SignalR 客户端服务扩展类 +/// SignalR client service collection extensions +/// +public static class ServiceCollectionExtensions +{ + /// + /// 注册 SignalR 客户端消息分发、心跳上报与连接快照服务,共用一个 Hub 连接 + /// Register SignalR client dispatch, heartbeat reporting, and connection snapshot services with one shared Hub connection + /// + /// 服务集合The service collection + /// Hub 地址,默认为 bb.hubThe Hub path. Defaults to bb.hub + /// 服务集合The service collection + /// + /// 在 WebAssembly 宿主中调用 AddBootstrapBlazor 后调用此方法。离线发送会失败,消息不会排队或重放 + /// Call this method after AddBootstrapBlazor in the WebAssembly host. Offline sends fail; messages are not queued or replayed + /// 服务端需注册并映射对应 Hub,Subscribe 方法需返回 bool,以区分订阅拒绝与可重试的执行失败。连接快照查询需在服务端单独启用并授权客户端访问 + /// The server must register and map a corresponding Hub whose Subscribe method returns bool to distinguish subscription rejection from retryable execution failures. Connection snapshot queries require explicit server-side opt-in and client authorization + /// + public static IServiceCollection AddBootstrapBlazorHub(this IServiceCollection services, string hubPath = "bb.hub") + { +#if NET7_0_OR_GREATER + ArgumentException.ThrowIfNullOrEmpty(hubPath); +#endif + services.TryAddSingleton(provider => new SignalRConnection( + provider.GetRequiredService(), + provider.GetRequiredService>(), + hubPath)); + services.AddSingleton(typeof(IDispatchService<>), typeof(DefaultDispatchService<>)); + services.AddSingleton(); + return services; + } +} diff --git a/src/extensions/BootstrapBlazor.SignalR.Client/SignalRConnection.cs b/src/extensions/BootstrapBlazor.SignalR.Client/SignalRConnection.cs new file mode 100644 index 00000000..306692dc --- /dev/null +++ b/src/extensions/BootstrapBlazor.SignalR.Client/SignalRConnection.cs @@ -0,0 +1,397 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.SignalR; +using Microsoft.AspNetCore.SignalR.Client; +using Microsoft.Extensions.Logging; +using System.Collections.Concurrent; +using System.Net; +using System.Net.WebSockets; +using System.Text.Json; + +namespace BootstrapBlazor.Components; + +/// +/// 消息分发与连接服务共用的 SignalR Hub 连接 +/// SignalR Hub connection shared by dispatch and connection services +/// +sealed class SignalRConnection : IAsyncDisposable +{ + private readonly HubConnection _connection; + private readonly ILogger _logger; + private readonly ConcurrentDictionary> _subscriptions = new(StringComparer.Ordinal); + private readonly HashSet _pendingSubscriptions = new(StringComparer.Ordinal); + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly CancellationTokenSource _lifetime = new(); + private readonly IDisposable _receiver; + private int _connectionUsers; + + /// + /// 获得 当前连接是否正在重连 + /// Gets whether the connection is reconnecting + /// + public bool IsReconnecting => _connection.State == HubConnectionState.Reconnecting; + + /// + /// 判断异常是否属于可重试的暂时性连接故障 + /// Determine whether an exception represents a transient connection failure that can be retried + /// + /// 待判断的异常The exception to check + /// 暂时性故障返回 true,否则返回 falseTrue for a transient failure; otherwise, false + internal static bool IsTransientFailure(Exception exception) => exception switch + { + HttpRequestException ex => ex.StatusCode is null or HttpStatusCode.RequestTimeout or + HttpStatusCode.TooManyRequests or HttpStatusCode.InternalServerError or + HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout, + IOException or WebSocketException or TimeoutException or OperationCanceledException => true, + _ => false + }; + + /// + /// 初始化共享连接,配置自动重连与消息接收回调 + /// Initialize the shared connection with automatic reconnection and a message receiver + /// + /// 导航管理器,用于解析 Hub 的绝对地址The navigation manager used to resolve the absolute Hub URL + /// 日志记录器The logger + /// Hub 地址The Hub path + public SignalRConnection(NavigationManager navigation, ILogger logger, string hubPath) + { + _logger = logger; + _connection = new HubConnectionBuilder() + .WithUrl(navigation.ToAbsoluteUri(hubPath)) + .WithAutomaticReconnect(new ConnectionRetryPolicy()) + .Build(); + _receiver = _connection.On("Receive", ReceiveAsync); + _connection.Reconnected += RestoreSubscriptionsAsync; + } + + /// + /// 注册指定消息类型的接收回调并订阅服务端消息 + /// Register a receiver for the specified entry type and subscribe to server messages + /// + /// 消息类型标识The entry type identifier + /// 消息接收回调委托The message receiver callback + /// 取消令牌The cancellation token + /// 表示异步操作的任务A task representing the asynchronous operation + public async Task SubscribeAsync(string entryType, Func callback, CancellationToken cancellationToken) + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetime.Token); + var token = cancellation.Token; + await _gate.WaitAsync(token); + try + { + _subscriptions[entryType] = callback; + await EnsureConnectedAsync(token); + await SubscribeCoreAsync(entryType, token); + _pendingSubscriptions.Remove(entryType); + } + catch + { + _subscriptions.TryRemove(entryType, out _); + _pendingSubscriptions.Remove(entryType); + throw; + } + finally + { + _gate.Release(); + } + } + + /// + /// 取消指定消息类型的订阅,并在无订阅和使用引用时停止连接 + /// Unsubscribe from the specified entry type and stop the connection when no subscriptions or usage references remain + /// + /// 消息类型标识The entry type identifier + /// 取消令牌The cancellation token + /// 表示异步操作的任务A task representing the asynchronous operation + public async Task UnsubscribeAsync(string entryType, CancellationToken cancellationToken) + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetime.Token); + var token = cancellation.Token; + await _gate.WaitAsync(token); + try + { + _subscriptions.TryRemove(entryType, out _); + _pendingSubscriptions.Remove(entryType); + if (_connection.State == HubConnectionState.Connected) + { + await _connection.InvokeAsync("Unsubscribe", entryType, token); + } + if (_subscriptions.IsEmpty && Volatile.Read(ref _connectionUsers) == 0) + { + await _connection.StopAsync(token); + } + } + finally + { + _gate.Release(); + } + } + + /// + /// 增加共享连接的使用引用,防止取消消息订阅时停止仍在使用的连接 + /// Add a usage reference to prevent unsubscription from stopping a connection still in use + /// + public void Retain() => Interlocked.Increment(ref _connectionUsers); + + /// + /// 释放共享连接的使用引用,并在无使用引用和订阅时停止连接 + /// Release a usage reference and stop the connection when no usage references or subscriptions remain + /// + /// 表示异步操作的任务A task representing the asynchronous operation + public async Task ReleaseAsync() + { + await _gate.WaitAsync(_lifetime.Token); + try + { + if (Interlocked.Decrement(ref _connectionUsers) == 0 && _subscriptions.IsEmpty) + { + await _connection.StopAsync(_lifetime.Token); + } + } + finally + { + _gate.Release(); + } + } + + /// + /// 调用服务端 Hub 方法并返回结果,操作超时时间为 30 秒 + /// Invoke a server Hub method and return its result with a 30-second operation timeout + /// + /// 返回结果类型The result type + /// Hub 方法名称The Hub method name + /// 方法参数,为 null 时不传递参数The method argument; null invokes the method without arguments + /// 取消令牌The cancellation token + /// 服务端返回的结果The result returned by the server + public async Task InvokeAsync(string method, object? argument, CancellationToken cancellationToken) + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetime.Token); + cancellation.CancelAfter(TimeSpan.FromSeconds(30)); + var token = cancellation.Token; + await _gate.WaitAsync(token); + try + { + await EnsureConnectedAsync(token); + return await _connection.InvokeCoreAsync(method, argument == null ? [] : [argument], token); + } + finally + { + _gate.Release(); + } + } + + /// + /// 通过共享 Hub 连接向服务端分发消息 + /// Dispatch an entry to the server through the shared Hub connection + /// + /// 消息数据类型The entry data type + /// 消息类型标识The entry type identifier + /// 消息分发项The dispatch entry + /// 取消令牌The cancellation token + /// 表示异步操作的任务A task representing the asynchronous operation + public async Task DispatchAsync(string entryType, DispatchEntry entry, CancellationToken cancellationToken) + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetime.Token); + var token = cancellation.Token; + await _gate.WaitAsync(token); + try + { + await EnsureConnectedAsync(token); + await _connection.InvokeAsync("Dispatch", entryType, entry, token); + } + finally + { + _gate.Release(); + } + } + + /// + /// 在连接断开时尝试建立连接并恢复订阅,连接尚未就绪时抛出异常 + /// Attempt to start a disconnected connection and restore subscriptions, throwing if the connection is not ready + /// + /// 取消令牌The cancellation token + /// 表示异步操作的任务A task representing the asynchronous operation + private async Task EnsureConnectedAsync(CancellationToken cancellationToken) + { + if (_connection.State == HubConnectionState.Disconnected) + { + for (var attempt = 0; ; attempt++) + { + try + { + await _connection.StartAsync(cancellationToken); + break; + } + catch (Exception ex) when (attempt < 2 && !cancellationToken.IsCancellationRequested && IsTransientFailure(ex)) + { + _logger.LogWarning(ex, "Unable to connect to the BootstrapBlazor Hub; retrying."); + await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken); + } + } + + _pendingSubscriptions.UnionWith(_subscriptions.Keys); + } + + if (_connection.State != HubConnectionState.Connected) + { + throw new InvalidOperationException("The BootstrapBlazor connection is reconnecting. Retry when it is connected."); + } + + await RestoreSubscriptionsCoreAsync(cancellationToken); + } + + /// + /// 自动重连成功后逐项恢复消息订阅,并重试暂时性故障 + /// Restore message subscriptions individually after automatic reconnection and retry transient failures + /// + /// 重连后的连接标识The connection identifier after reconnection + /// 表示异步操作的任务A task representing the asynchronous operation + private async Task RestoreSubscriptionsAsync(string? connectionId) + { + var token = _lifetime.Token; + try + { + var initialize = true; + while (true) + { + await _gate.WaitAsync(token); + try + { + if (initialize) + { + _pendingSubscriptions.UnionWith(_subscriptions.Keys); + initialize = false; + } + if (_connection.State != HubConnectionState.Connected) + { + return; + } + + await RestoreSubscriptionsCoreAsync(token); + if (_pendingSubscriptions.Count == 0) + { + return; + } + } + finally + { + _gate.Release(); + } + + await Task.Delay(TimeSpan.FromSeconds(3), token); + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to restore dispatch subscriptions after reconnecting."); + } + } + + /// + /// 在持有连接锁时恢复待处理订阅,隔离单项失败;服务端明确拒绝时停止重试,执行失败时保留待重试项 + /// Restore pending subscriptions while holding the connection gate, isolating individual failures; stop retrying explicit server rejections and retain server execution failures for retry + /// + /// 取消令牌The cancellation token + /// 表示异步操作的任务A task representing the asynchronous operation + private async Task RestoreSubscriptionsCoreAsync(CancellationToken cancellationToken) + { + foreach (var entryType in _pendingSubscriptions.ToArray()) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + await SubscribeCoreAsync(entryType, cancellationToken); + _pendingSubscriptions.Remove(entryType); + } + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) + { + if (!IsTransientFailure(ex) && _connection.State == HubConnectionState.Connected) + { + _pendingSubscriptions.Remove(entryType); + } + _logger.LogError(ex, "Unable to restore the dispatch subscription for entry type {EntryType}.", entryType); + } + } + } + + /// + /// 请求服务端订阅,区分明确拒绝与可重试的服务端执行失败 + /// Request a server subscription, distinguishing explicit rejection from retryable server execution failures + /// + /// 消息类型标识The entry type identifier + /// 取消令牌The cancellation token + /// 表示异步操作的任务A task representing the asynchronous operation + private async Task SubscribeCoreAsync(string entryType, CancellationToken cancellationToken) + { + try + { + if (!await _connection.InvokeAsync("Subscribe", entryType, cancellationToken)) + { + throw new UnauthorizedAccessException("Dispatch subscriptions are not allowed for this type."); + } + } + catch (HubException ex) + { + throw new IOException("The server could not complete the dispatch subscription.", ex); + } + } + + /// + /// 将服务端消息传递给对应消息类型的接收回调 + /// Forward a server message to the receiver registered for its entry type + /// + /// 消息类型标识The entry type identifier + /// 消息的 JSON 数据The message JSON data + /// 表示异步操作的任务A task representing the asynchronous operation + private async Task ReceiveAsync(string entryType, JsonElement entry) + { + if (_subscriptions.TryGetValue(entryType, out var callback)) + { + try + { + await callback(entry); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unable to receive a dispatch for entry type {EntryType}.", entryType); + } + } + } + + /// + /// 取消连接操作,注销消息接收回调并释放 Hub 连接 + /// Cancel connection operations, unregister the message receiver, and dispose of the Hub connection + /// + /// 表示异步释放操作的任务A task representing the asynchronous disposal operation + public async ValueTask DisposeAsync() + { + _lifetime.Cancel(); + _connection.Reconnected -= RestoreSubscriptionsAsync; + _receiver.Dispose(); + await _connection.DisposeAsync(); + _lifetime.Dispose(); + } + + /// + /// 自动重连策略,首次立即重试,后续间隔递增至最多 30 秒 + /// Automatic reconnection policy that retries immediately at first, then increases the delay up to 30 seconds + /// + private sealed class ConnectionRetryPolicy : IRetryPolicy + { + /// + /// 根据已重试次数计算下次重连的等待时间 + /// Calculate the delay before the next reconnection attempt based on the previous retry count + /// + /// 重试上下文The retry context + /// 下次重连前的等待时间The delay before the next reconnection attempt + public TimeSpan? NextRetryDelay(RetryContext retryContext) + => TimeSpan.FromSeconds(Math.Min(30, retryContext.PreviousRetryCount * 3)); + } +} From 7e1edc2c8386b52ca3b65c231f9023f591a3bd11 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Tue, 27 Oct 2026 09:36:00 +0800 Subject: [PATCH 3/9] chore: bump version 11.0.0-rc.3.1 --- .../BootstrapBlazor.SignalR.Client.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/extensions/BootstrapBlazor.SignalR.Client/BootstrapBlazor.SignalR.Client.csproj b/src/extensions/BootstrapBlazor.SignalR.Client/BootstrapBlazor.SignalR.Client.csproj index e3c8e005..dfd455c2 100644 --- a/src/extensions/BootstrapBlazor.SignalR.Client/BootstrapBlazor.SignalR.Client.csproj +++ b/src/extensions/BootstrapBlazor.SignalR.Client/BootstrapBlazor.SignalR.Client.csproj @@ -1,7 +1,7 @@ - 11.0.0-rc.2.1 + 11.0.0-rc.3.1 @@ -33,7 +33,7 @@ - + From 8614e2826b0910a8c9e6f6b32f8b58d400577747 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Tue, 27 Oct 2026 11:46:42 +0800 Subject: [PATCH 4/9] =?UTF-8?q?doc:=20=E6=9B=B4=E6=96=B0=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BootstrapBlazor.Middleware/BootstrapBlazorHub.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazorHub.cs b/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazorHub.cs index cdfefda0..1016cf0f 100644 --- a/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazorHub.cs +++ b/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazorHub.cs @@ -48,9 +48,9 @@ public Task Heartbeat(ClientInfo client) => ConnectionHandler.ReportHeartbeatAsync(Context, client, services); /// - /// 查询在线连接快照,仅返回给调用方,不向消息订阅者广播;查询被禁用或调用方未获授权时返回 null - /// Queries a connection snapshot for the caller only, without broadcasting it to dispatch subscribers. Returns null when queries are disabled or the caller is not authorized. + /// 查询在线连接列表,仅返回给调用方,不向消息订阅者广播;查询被禁用或调用方未获授权时返回 null + /// Queries the online connections for the caller only, without broadcasting them to dispatch subscribers. Returns null when queries are disabled or the caller is not authorized. /// - public Task GetConnectionSnapshot() + public Task?> GetConnectionSnapshot() => ConnectionHandler.GetSnapshotAsync(Context, options, services); } From 9808c99990bbc91c24ee632b9fdb9d20daf0e1aa Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Tue, 27 Oct 2026 11:46:56 +0800 Subject: [PATCH 5/9] =?UTF-8?q?refactor:=20=E6=94=B9=E4=B8=BA=20Connection?= =?UTF-8?q?Item=20=E9=9B=86=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BootstrapBlazor.Middleware/ConnectionHandler.cs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/middleware/BootstrapBlazor.Middleware/ConnectionHandler.cs b/src/middleware/BootstrapBlazor.Middleware/ConnectionHandler.cs index 520949a8..b62d2f42 100644 --- a/src/middleware/BootstrapBlazor.Middleware/ConnectionHandler.cs +++ b/src/middleware/BootstrapBlazor.Middleware/ConnectionHandler.cs @@ -46,7 +46,7 @@ public static async Task ReportHeartbeatAsync(HubCallerContext context, Cl return true; } - public static async Task GetSnapshotAsync(HubCallerContext context, BootstrapBlazorHubOptions options, IServiceProvider services) + public static async Task?> GetSnapshotAsync(HubCallerContext context, BootstrapBlazorHubOptions options, IServiceProvider services) { if (!options.EnableConnectionSnapshots) { @@ -66,10 +66,6 @@ public static async Task ReportHeartbeatAsync(HubCallerContext context, Cl } var service = services.GetRequiredService(); - return new ConnectionSnapshot - { - Count = service.Count, - Connections = service.Connections - }; + return service.Connections; } } From 2c7fb17c3e08741862da989dd170f31250fe4a6b Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Tue, 27 Oct 2026 11:53:33 +0800 Subject: [PATCH 6/9] chore: bump version 11.1.0-rc.3.2 --- .../BootstrapBlazor.Middleware.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazor.Middleware.csproj b/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazor.Middleware.csproj index ca75beda..bc8d1124 100644 --- a/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazor.Middleware.csproj +++ b/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazor.Middleware.csproj @@ -1,7 +1,7 @@ - 11.0.0-rc.3.2 + 11.0.0-rc.3.3 @@ -9,7 +9,7 @@ - + From 9ed3a7b25a0e1e98899aa6d02132e4cb977cab8e Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Tue, 27 Oct 2026 12:04:41 +0800 Subject: [PATCH 7/9] =?UTF-8?q?refactor:=20=E5=A2=9E=E5=8A=A0=20Connection?= =?UTF-8?q?Snapshot=20=E8=BF=94=E5=9B=9E=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BootstrapBlazorHub.cs | 2 +- .../ConnectionHandler.cs | 17 ++++++++--------- .../ConnectionSnapshot.cs | 10 ++++++++++ 3 files changed, 19 insertions(+), 10 deletions(-) create mode 100644 src/middleware/BootstrapBlazor.Middleware/ConnectionSnapshot.cs diff --git a/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazorHub.cs b/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazorHub.cs index 1016cf0f..d5fd2dc4 100644 --- a/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazorHub.cs +++ b/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazorHub.cs @@ -51,6 +51,6 @@ public Task Heartbeat(ClientInfo client) /// 查询在线连接列表,仅返回给调用方,不向消息订阅者广播;查询被禁用或调用方未获授权时返回 null /// Queries the online connections for the caller only, without broadcasting them to dispatch subscribers. Returns null when queries are disabled or the caller is not authorized. /// - public Task?> GetConnectionSnapshot() + public Task GetConnectionSnapshot() => ConnectionHandler.GetSnapshotAsync(Context, options, services); } diff --git a/src/middleware/BootstrapBlazor.Middleware/ConnectionHandler.cs b/src/middleware/BootstrapBlazor.Middleware/ConnectionHandler.cs index b62d2f42..1a76b2ef 100644 --- a/src/middleware/BootstrapBlazor.Middleware/ConnectionHandler.cs +++ b/src/middleware/BootstrapBlazor.Middleware/ConnectionHandler.cs @@ -24,12 +24,6 @@ public static async Task ReportHeartbeatAsync(HubCallerContext context, Cl return false; } - if (client == null || string.IsNullOrWhiteSpace(client.Id) || client.Id.Length > 128 || - !Enum.IsDefined(typeof(WebClientDeviceType), client.Device)) - { - throw new HubException("Invalid connection heartbeat."); - } - var httpContext = context.GetHttpContext() ?? throw new HubException("The connection context is unavailable."); client.Ip = httpContext.Connection.RemoteIpAddress.ToIPv4String(); client.UserName = context.User?.Identity is { IsAuthenticated: true } identity ? identity.Name : null; @@ -46,7 +40,13 @@ public static async Task ReportHeartbeatAsync(HubCallerContext context, Cl return true; } - public static async Task?> GetSnapshotAsync(HubCallerContext context, BootstrapBlazorHubOptions options, IServiceProvider services) + public static async Task GetSnapshotAsync(HubCallerContext context, BootstrapBlazorHubOptions options, IServiceProvider services) + { + var service = await GetConnectionServiceAsync(context, options, services); + return service == null ? null : new ConnectionSnapshot(service.Count, service.Connections); + } + + private static async Task GetConnectionServiceAsync(HubCallerContext context, BootstrapBlazorHubOptions options, IServiceProvider services) { if (!options.EnableConnectionSnapshots) { @@ -65,7 +65,6 @@ public static async Task ReportHeartbeatAsync(HubCallerContext context, Cl } } - var service = services.GetRequiredService(); - return service.Connections; + return services.GetRequiredService(); } } diff --git a/src/middleware/BootstrapBlazor.Middleware/ConnectionSnapshot.cs b/src/middleware/BootstrapBlazor.Middleware/ConnectionSnapshot.cs new file mode 100644 index 00000000..bb587709 --- /dev/null +++ b/src/middleware/BootstrapBlazor.Middleware/ConnectionSnapshot.cs @@ -0,0 +1,10 @@ +// Copyright (c) BootstrapBlazor & Argo Zhang (argo@live.ca). All rights reserved. +// Licensed under the Apache License, Version 2.0. + +namespace BootstrapBlazor.Components; + +/// +/// SignalR 连接快照及服务端计算的在线连接数量 +/// SignalR connection snapshot and the online connection count computed by the server +/// +public sealed record ConnectionSnapshot(long Count, ICollection Connections); From ddea174bb644f515895737a181c05e572220267b Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Tue, 27 Oct 2026 12:36:52 +0800 Subject: [PATCH 8/9] =?UTF-8?q?revert:=20=E6=92=A4=E9=94=80=20client=20?= =?UTF-8?q?=E5=B7=A5=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- BootstrapBlazor.Extensions.slnx | 1 - .../BootstrapBlazor.SignalR.Client.csproj | 39 -- .../DefaultConnectionService.cs | 148 ------- .../DefaultDispatchService.cs | 248 ----------- .../ServiceCollectionExtensions.cs | 43 -- .../SignalRConnection.cs | 397 ------------------ 6 files changed, 876 deletions(-) delete mode 100644 src/extensions/BootstrapBlazor.SignalR.Client/BootstrapBlazor.SignalR.Client.csproj delete mode 100644 src/extensions/BootstrapBlazor.SignalR.Client/DefaultConnectionService.cs delete mode 100644 src/extensions/BootstrapBlazor.SignalR.Client/DefaultDispatchService.cs delete mode 100644 src/extensions/BootstrapBlazor.SignalR.Client/ServiceCollectionExtensions.cs delete mode 100644 src/extensions/BootstrapBlazor.SignalR.Client/SignalRConnection.cs diff --git a/BootstrapBlazor.Extensions.slnx b/BootstrapBlazor.Extensions.slnx index 35ea650e..088bbf7d 100644 --- a/BootstrapBlazor.Extensions.slnx +++ b/BootstrapBlazor.Extensions.slnx @@ -84,7 +84,6 @@ - diff --git a/src/extensions/BootstrapBlazor.SignalR.Client/BootstrapBlazor.SignalR.Client.csproj b/src/extensions/BootstrapBlazor.SignalR.Client/BootstrapBlazor.SignalR.Client.csproj deleted file mode 100644 index dfd455c2..00000000 --- a/src/extensions/BootstrapBlazor.SignalR.Client/BootstrapBlazor.SignalR.Client.csproj +++ /dev/null @@ -1,39 +0,0 @@ - - - - 11.0.0-rc.3.1 - - - - Optional SignalR client transport for BootstrapBlazor dispatch and connection services. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/extensions/BootstrapBlazor.SignalR.Client/DefaultConnectionService.cs b/src/extensions/BootstrapBlazor.SignalR.Client/DefaultConnectionService.cs deleted file mode 100644 index 473f5840..00000000 --- a/src/extensions/BootstrapBlazor.SignalR.Client/DefaultConnectionService.cs +++ /dev/null @@ -1,148 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the Apache 2.0 License -// See the LICENSE file in the project root for more information. -// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone - -using Microsoft.Extensions.Logging; - -namespace BootstrapBlazor.Components; - -/// -/// 基于 SignalR 的客户端连接服务,通过心跳上报连接信息并轮询服务端连接快照 -/// SignalR client connection service that reports connection information through heartbeats and polls server connection snapshots -/// -sealed class DefaultConnectionService : IConnectionService, IAsyncDisposable -{ - private readonly SignalRConnection _connection; - private readonly ILogger _logger; - private readonly CancellationTokenSource _lifetime = new(); - private readonly Task _pollingTask; - private ConnectionSnapshot _snapshot = new(); - private int _disposed; - - /// - /// 初始化客户端连接服务并启动连接快照轮询 - /// Initialize the client connection service and start connection snapshot polling - /// - /// 共享的 SignalR 连接The shared SignalR connection - /// 日志记录器The logger - public DefaultConnectionService(SignalRConnection connection, ILogger logger) - { - _connection = connection; - _logger = logger; - _connection.Retain(); - _pollingTask = PollAsync(_lifetime.Token); - } - - /// - /// 获得 最近一次服务端快照中的连接数量 - /// Gets the connection count from the latest server snapshot - /// - public long Count => _snapshot.Count; - - /// - /// 获得 最近一次服务端快照中的连接集合 - /// Gets the connection collection from the latest server snapshot - /// - public ICollection Connections => _snapshot.Connections; - - /// - /// 根据连接标识从本地快照中获取连接信息 - /// Try to get connection information from the local snapshot by connection identifier - /// - /// 连接标识The connection identifier - /// 匹配的连接信息,未找到时为 nullThe matching connection information, or null if not found - /// 找到连接时返回 true,否则返回 falseTrue if the connection is found; otherwise, false - public bool TryGetValue(string key, [NotNullWhen(true)] out ConnectionItem? value) - { - value = _snapshot.Connections.FirstOrDefault(item => item.Id == key); - return value != null; - } - - /// - /// 向服务端发送心跳以添加或更新客户端连接信息 - /// Send a heartbeat to the server to add or update client connection information - /// - /// 客户端信息The client information - /// 取消令牌The cancellation token - /// 表示异步操作的任务A task representing the asynchronous operation - public async Task AddOrUpdateAsync(ClientInfo client, CancellationToken cancellationToken = default) - { - using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetime.Token); - if (!await _connection.InvokeAsync("Heartbeat", client, cancellation.Token)) - { - _logger.LogWarning("Connection heartbeats are disabled on the server."); - } - } - - /// - /// 定期刷新连接快照,查询被禁用、未获授权或请求被拒绝时停止轮询 - /// Periodically refresh the connection snapshot, stopping when queries are disabled, unauthorized, or rejected - /// - /// 取消令牌The cancellation token - /// 表示轮询操作的任务A task representing the polling operation - private async Task PollAsync(CancellationToken cancellationToken) - { - try - { - while (!cancellationToken.IsCancellationRequested) - { - try - { - var snapshot = await _connection.InvokeAsync("GetConnectionSnapshot", null, cancellationToken); - if (snapshot == null) - { - _snapshot = new(); - _logger.LogInformation("Connection snapshot polling stopped because queries are disabled or not authorized."); - return; - } - _snapshot = snapshot; - } - catch (HttpRequestException ex) when (!cancellationToken.IsCancellationRequested && !SignalRConnection.IsTransientFailure(ex)) - { - _snapshot = new(); - _logger.LogWarning(ex, "Connection snapshot polling stopped because the Hub request was rejected."); - return; - } - catch (Exception ex) when (!cancellationToken.IsCancellationRequested) - { - _logger.LogError(ex, "Unable to refresh the connection snapshot."); - } - await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken); - } - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - } - } - - /// - /// 停止连接快照轮询并释放共享连接的使用引用 - /// Stop connection snapshot polling and release the usage reference to the shared connection - /// - /// 表示异步释放操作的任务A task representing the asynchronous disposal operation - public async ValueTask DisposeAsync() - { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - return; - } - - _lifetime.Cancel(); - try - { - await _pollingTask; - } - finally - { - try - { - await _connection.ReleaseAsync(); - } - finally - { - _lifetime.Dispose(); - } - } - } -} diff --git a/src/extensions/BootstrapBlazor.SignalR.Client/DefaultDispatchService.cs b/src/extensions/BootstrapBlazor.SignalR.Client/DefaultDispatchService.cs deleted file mode 100644 index 3ff5563f..00000000 --- a/src/extensions/BootstrapBlazor.SignalR.Client/DefaultDispatchService.cs +++ /dev/null @@ -1,248 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the Apache 2.0 License -// See the LICENSE file in the project root for more information. -// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone - -using Microsoft.Extensions.Logging; -using System.Text.Json; - -namespace BootstrapBlazor.Components; - -/// -/// 基于 SignalR 的客户端消息分发服务 -/// SignalR client dispatch service -/// -/// 消息数据类型The entry data type -/// 共享的 SignalR 连接The shared SignalR connection -/// 日志记录器The logger -sealed class DefaultDispatchService( - SignalRConnection connection, - ILogger> logger) : IDispatchService, IAsyncDisposable -{ - private static readonly string _entryType = typeof(TEntry).FullName ?? typeof(TEntry).Name; - private static readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web); - private readonly List _callbacks = []; - private readonly object _locker = new(); - private readonly SemaphoreSlim _subscriptions = new(1, 1); - private readonly CancellationTokenSource _lifetime = new(); - private bool _requiresUnsubscribe; - - /// - /// 向服务端分发消息,成功后通知本地订阅者 - /// Dispatch an entry to the server and notify local subscribers after a successful send - /// - /// 消息分发项The dispatch entry - /// 取消令牌The cancellation token - /// 表示异步操作的任务A task representing the asynchronous operation - public async Task DispatchAsync(DispatchEntry entry, CancellationToken cancellationToken = default) - { - await connection.DispatchAsync(_entryType, entry, cancellationToken); - await DispatchCoreAsync(entry, cancellationToken); - } - - /// - /// 注册消息回调,并在添加首个本地订阅者时订阅服务端消息 - /// Register a message callback and subscribe to server messages when adding the first local subscriber - /// - /// 消息回调委托The message callback - /// 取消令牌The cancellation token - /// 表示异步操作的任务A task representing the asynchronous operation - public async Task SubscribeAsync(Func, Task> callback, CancellationToken cancellationToken = default) - { - using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetime.Token); - var token = cancellation.Token; - // 串行处理订阅与退订,避免后续订阅者在首个远程订阅尚未就绪时返回成功。 - // Serialize subscription changes so later subscribers cannot succeed before the first remote subscription is ready. - await _subscriptions.WaitAsync(token); - try - { - var subscription = new Subscription(callback); - // first 表示本次添加前没有本地订阅者,而非服务生命周期内第一次订阅。 - // 同一消息类型仅在本地订阅数从 0 变为 1 时请求远程订阅,后续回调共用该订阅。 - // first means there were no local subscribers before this addition, not the first subscription in the service lifetime. - // Request a remote subscription only when the local count changes from 0 to 1; later callbacks share it. - bool first; - lock (_locker) - { - first = _callbacks.Count == 0; - _callbacks.Add(subscription); - } - if (first) - { - try - { - while (true) - { - // 这里不能写 while(!token.IsCancellationRequested) 因为取消时仍需清理远程订阅。 - // Do not write while(!token.IsCancellationRequested) here because remote subscription still needs to be cleaned up when canceled. - token.ThrowIfCancellationRequested(); - try - { - // 远程调用失败或取消时,服务端仍可能已完成订阅,因此提前标记需要清理。 - // The server may have subscribed even if the call fails or is canceled, so mark cleanup as required beforehand. - _requiresUnsubscribe = true; - await connection.SubscribeAsync(_entryType, ReceiveAsync, token); - break; - } - catch (Exception ex) when (!token.IsCancellationRequested && - (SignalRConnection.IsTransientFailure(ex) || - ex is InvalidOperationException && connection.IsReconnecting)) - { - logger.LogWarning(ex, "Unable to subscribe to dispatch entry type {EntryType}; retrying.", _entryType); - await Task.Delay(TimeSpan.FromSeconds(3), token); - } - } - } - catch - { - // 订阅未就绪时撤销本次本地注册,使后续订阅者仍能走 first 分支重新尝试。 - // Roll back this registration if subscribing fails so the next subscriber can retry through the first branch. - lock (_locker) - { - _callbacks.Remove(subscription); - } - throw; - } - } - } - finally - { - _subscriptions.Release(); - } - } - - /// - /// 移除消息回调,并在移除最后一个本地订阅者时取消服务端订阅 - /// Remove a message callback and unsubscribe from the server when removing the last local subscriber - /// - /// 消息回调委托The message callback - /// 取消令牌The cancellation token - /// 表示异步操作的任务A task representing the asynchronous operation - /// - /// 传输层退订取消或失败时保留待清理状态,再次退订或释放服务时继续清理 - /// If transport unsubscription is canceled or fails, retain the pending cleanup state for a subsequent unsubscription or service disposal - /// - public async Task UnSubscribeAsync(Func, Task> callback, CancellationToken cancellationToken = default) - { - await _subscriptions.WaitAsync(cancellationToken); - try - { - bool empty; - lock (_locker) - { - var index = _callbacks.FindIndex(subscription => subscription.Callback == callback); - if (index >= 0) - { - _callbacks.RemoveAt(index); - } - - // 集合中仍可能存在相同回调的其他订阅记录,因此仅在集合为空时才退订服务端。 - // There may still be other subscription records with the same callback in the collection, so unsubscribe from the server only when the collection is empty. - empty = _callbacks.Count == 0; - } - if (empty && _requiresUnsubscribe) - { - await connection.UnsubscribeAsync(_entryType, cancellationToken); - _requiresUnsubscribe = false; - } - } - finally - { - _subscriptions.Release(); - } - } - - /// - /// 反序列化服务端消息并通知本地订阅者 - /// Deserialize a server message and notify local subscribers - /// - /// 消息的 JSON 数据The message JSON data - /// 表示异步操作的任务A task representing the asynchronous operation - private Task ReceiveAsync(JsonElement json) - => DispatchCoreAsync(json.Deserialize>(_serializerOptions) - ?? throw new JsonException("The dispatch entry must not be null.")); - - /// - /// 依次调用本地消息回调,记录单个回调的异常并继续通知其他订阅者 - /// Invoke local message callbacks in order, logging individual callback failures and continuing to notify other subscribers - /// - /// 消息分发项The dispatch entry - /// 取消令牌The cancellation token - /// 表示异步操作的任务A task representing the asynchronous operation - private async Task DispatchCoreAsync(DispatchEntry entry, CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - - Subscription[] callbacks; - lock (_locker) - { - callbacks = _callbacks.ToArray(); - } - foreach (var subscription in callbacks) - { - cancellationToken.ThrowIfCancellationRequested(); - try - { - Task callbackTask; - lock (_locker) - { - cancellationToken.ThrowIfCancellationRequested(); - if (!_callbacks.Contains(subscription)) - { - continue; - } - - callbackTask = subscription.Callback(entry); - } - - await callbackTask.WaitAsync(cancellationToken); - cancellationToken.ThrowIfCancellationRequested(); - } - catch (Exception ex) when (!cancellationToken.IsCancellationRequested) - { - logger.LogError(ex, "A dispatch subscriber failed for entry type {EntryType}.", _entryType); - } - } - } - - /// - /// 取消正在进行的订阅操作,清空本地回调并清理传输层订阅,包括之前未完成的退订 - /// Cancel pending subscription operations, clear local callbacks, and clean up transport subscriptions, including previously incomplete unsubscriptions - /// - /// 表示异步释放操作的任务A task representing the asynchronous disposal operation - public async ValueTask DisposeAsync() - { - _lifetime.Cancel(); - await _subscriptions.WaitAsync(); - try - { - lock (_locker) - { - _callbacks.Clear(); - } - if (_requiresUnsubscribe) - { - await connection.UnsubscribeAsync(_entryType, CancellationToken.None); - _requiresUnsubscribe = false; - } - } - finally - { - _subscriptions.Release(); - } - } - - /// - /// 独立的消息订阅记录,用于区分同一回调的多次订阅 - /// An individual message subscription that distinguishes separate registrations of the same callback - /// - /// 消息回调委托The message callback - private sealed class Subscription(Func, Task> callback) - { - /// - /// 获得 消息回调委托 - /// Gets the message callback - /// - public Func, Task> Callback { get; } = callback; - } -} diff --git a/src/extensions/BootstrapBlazor.SignalR.Client/ServiceCollectionExtensions.cs b/src/extensions/BootstrapBlazor.SignalR.Client/ServiceCollectionExtensions.cs deleted file mode 100644 index f4ab39e5..00000000 --- a/src/extensions/BootstrapBlazor.SignalR.Client/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the Apache 2.0 License -// See the LICENSE file in the project root for more information. -// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone - -using BootstrapBlazor.Components; -using Microsoft.Extensions.DependencyInjection.Extensions; - -namespace Microsoft.Extensions.DependencyInjection; - -/// -/// SignalR 客户端服务扩展类 -/// SignalR client service collection extensions -/// -public static class ServiceCollectionExtensions -{ - /// - /// 注册 SignalR 客户端消息分发、心跳上报与连接快照服务,共用一个 Hub 连接 - /// Register SignalR client dispatch, heartbeat reporting, and connection snapshot services with one shared Hub connection - /// - /// 服务集合The service collection - /// Hub 地址,默认为 bb.hubThe Hub path. Defaults to bb.hub - /// 服务集合The service collection - /// - /// 在 WebAssembly 宿主中调用 AddBootstrapBlazor 后调用此方法。离线发送会失败,消息不会排队或重放 - /// Call this method after AddBootstrapBlazor in the WebAssembly host. Offline sends fail; messages are not queued or replayed - /// 服务端需注册并映射对应 Hub,Subscribe 方法需返回 bool,以区分订阅拒绝与可重试的执行失败。连接快照查询需在服务端单独启用并授权客户端访问 - /// The server must register and map a corresponding Hub whose Subscribe method returns bool to distinguish subscription rejection from retryable execution failures. Connection snapshot queries require explicit server-side opt-in and client authorization - /// - public static IServiceCollection AddBootstrapBlazorHub(this IServiceCollection services, string hubPath = "bb.hub") - { -#if NET7_0_OR_GREATER - ArgumentException.ThrowIfNullOrEmpty(hubPath); -#endif - services.TryAddSingleton(provider => new SignalRConnection( - provider.GetRequiredService(), - provider.GetRequiredService>(), - hubPath)); - services.AddSingleton(typeof(IDispatchService<>), typeof(DefaultDispatchService<>)); - services.AddSingleton(); - return services; - } -} diff --git a/src/extensions/BootstrapBlazor.SignalR.Client/SignalRConnection.cs b/src/extensions/BootstrapBlazor.SignalR.Client/SignalRConnection.cs deleted file mode 100644 index 306692dc..00000000 --- a/src/extensions/BootstrapBlazor.SignalR.Client/SignalRConnection.cs +++ /dev/null @@ -1,397 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the Apache 2.0 License -// See the LICENSE file in the project root for more information. -// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone - -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.SignalR; -using Microsoft.AspNetCore.SignalR.Client; -using Microsoft.Extensions.Logging; -using System.Collections.Concurrent; -using System.Net; -using System.Net.WebSockets; -using System.Text.Json; - -namespace BootstrapBlazor.Components; - -/// -/// 消息分发与连接服务共用的 SignalR Hub 连接 -/// SignalR Hub connection shared by dispatch and connection services -/// -sealed class SignalRConnection : IAsyncDisposable -{ - private readonly HubConnection _connection; - private readonly ILogger _logger; - private readonly ConcurrentDictionary> _subscriptions = new(StringComparer.Ordinal); - private readonly HashSet _pendingSubscriptions = new(StringComparer.Ordinal); - private readonly SemaphoreSlim _gate = new(1, 1); - private readonly CancellationTokenSource _lifetime = new(); - private readonly IDisposable _receiver; - private int _connectionUsers; - - /// - /// 获得 当前连接是否正在重连 - /// Gets whether the connection is reconnecting - /// - public bool IsReconnecting => _connection.State == HubConnectionState.Reconnecting; - - /// - /// 判断异常是否属于可重试的暂时性连接故障 - /// Determine whether an exception represents a transient connection failure that can be retried - /// - /// 待判断的异常The exception to check - /// 暂时性故障返回 true,否则返回 falseTrue for a transient failure; otherwise, false - internal static bool IsTransientFailure(Exception exception) => exception switch - { - HttpRequestException ex => ex.StatusCode is null or HttpStatusCode.RequestTimeout or - HttpStatusCode.TooManyRequests or HttpStatusCode.InternalServerError or - HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout, - IOException or WebSocketException or TimeoutException or OperationCanceledException => true, - _ => false - }; - - /// - /// 初始化共享连接,配置自动重连与消息接收回调 - /// Initialize the shared connection with automatic reconnection and a message receiver - /// - /// 导航管理器,用于解析 Hub 的绝对地址The navigation manager used to resolve the absolute Hub URL - /// 日志记录器The logger - /// Hub 地址The Hub path - public SignalRConnection(NavigationManager navigation, ILogger logger, string hubPath) - { - _logger = logger; - _connection = new HubConnectionBuilder() - .WithUrl(navigation.ToAbsoluteUri(hubPath)) - .WithAutomaticReconnect(new ConnectionRetryPolicy()) - .Build(); - _receiver = _connection.On("Receive", ReceiveAsync); - _connection.Reconnected += RestoreSubscriptionsAsync; - } - - /// - /// 注册指定消息类型的接收回调并订阅服务端消息 - /// Register a receiver for the specified entry type and subscribe to server messages - /// - /// 消息类型标识The entry type identifier - /// 消息接收回调委托The message receiver callback - /// 取消令牌The cancellation token - /// 表示异步操作的任务A task representing the asynchronous operation - public async Task SubscribeAsync(string entryType, Func callback, CancellationToken cancellationToken) - { - using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetime.Token); - var token = cancellation.Token; - await _gate.WaitAsync(token); - try - { - _subscriptions[entryType] = callback; - await EnsureConnectedAsync(token); - await SubscribeCoreAsync(entryType, token); - _pendingSubscriptions.Remove(entryType); - } - catch - { - _subscriptions.TryRemove(entryType, out _); - _pendingSubscriptions.Remove(entryType); - throw; - } - finally - { - _gate.Release(); - } - } - - /// - /// 取消指定消息类型的订阅,并在无订阅和使用引用时停止连接 - /// Unsubscribe from the specified entry type and stop the connection when no subscriptions or usage references remain - /// - /// 消息类型标识The entry type identifier - /// 取消令牌The cancellation token - /// 表示异步操作的任务A task representing the asynchronous operation - public async Task UnsubscribeAsync(string entryType, CancellationToken cancellationToken) - { - using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetime.Token); - var token = cancellation.Token; - await _gate.WaitAsync(token); - try - { - _subscriptions.TryRemove(entryType, out _); - _pendingSubscriptions.Remove(entryType); - if (_connection.State == HubConnectionState.Connected) - { - await _connection.InvokeAsync("Unsubscribe", entryType, token); - } - if (_subscriptions.IsEmpty && Volatile.Read(ref _connectionUsers) == 0) - { - await _connection.StopAsync(token); - } - } - finally - { - _gate.Release(); - } - } - - /// - /// 增加共享连接的使用引用,防止取消消息订阅时停止仍在使用的连接 - /// Add a usage reference to prevent unsubscription from stopping a connection still in use - /// - public void Retain() => Interlocked.Increment(ref _connectionUsers); - - /// - /// 释放共享连接的使用引用,并在无使用引用和订阅时停止连接 - /// Release a usage reference and stop the connection when no usage references or subscriptions remain - /// - /// 表示异步操作的任务A task representing the asynchronous operation - public async Task ReleaseAsync() - { - await _gate.WaitAsync(_lifetime.Token); - try - { - if (Interlocked.Decrement(ref _connectionUsers) == 0 && _subscriptions.IsEmpty) - { - await _connection.StopAsync(_lifetime.Token); - } - } - finally - { - _gate.Release(); - } - } - - /// - /// 调用服务端 Hub 方法并返回结果,操作超时时间为 30 秒 - /// Invoke a server Hub method and return its result with a 30-second operation timeout - /// - /// 返回结果类型The result type - /// Hub 方法名称The Hub method name - /// 方法参数,为 null 时不传递参数The method argument; null invokes the method without arguments - /// 取消令牌The cancellation token - /// 服务端返回的结果The result returned by the server - public async Task InvokeAsync(string method, object? argument, CancellationToken cancellationToken) - { - using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetime.Token); - cancellation.CancelAfter(TimeSpan.FromSeconds(30)); - var token = cancellation.Token; - await _gate.WaitAsync(token); - try - { - await EnsureConnectedAsync(token); - return await _connection.InvokeCoreAsync(method, argument == null ? [] : [argument], token); - } - finally - { - _gate.Release(); - } - } - - /// - /// 通过共享 Hub 连接向服务端分发消息 - /// Dispatch an entry to the server through the shared Hub connection - /// - /// 消息数据类型The entry data type - /// 消息类型标识The entry type identifier - /// 消息分发项The dispatch entry - /// 取消令牌The cancellation token - /// 表示异步操作的任务A task representing the asynchronous operation - public async Task DispatchAsync(string entryType, DispatchEntry entry, CancellationToken cancellationToken) - { - using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetime.Token); - var token = cancellation.Token; - await _gate.WaitAsync(token); - try - { - await EnsureConnectedAsync(token); - await _connection.InvokeAsync("Dispatch", entryType, entry, token); - } - finally - { - _gate.Release(); - } - } - - /// - /// 在连接断开时尝试建立连接并恢复订阅,连接尚未就绪时抛出异常 - /// Attempt to start a disconnected connection and restore subscriptions, throwing if the connection is not ready - /// - /// 取消令牌The cancellation token - /// 表示异步操作的任务A task representing the asynchronous operation - private async Task EnsureConnectedAsync(CancellationToken cancellationToken) - { - if (_connection.State == HubConnectionState.Disconnected) - { - for (var attempt = 0; ; attempt++) - { - try - { - await _connection.StartAsync(cancellationToken); - break; - } - catch (Exception ex) when (attempt < 2 && !cancellationToken.IsCancellationRequested && IsTransientFailure(ex)) - { - _logger.LogWarning(ex, "Unable to connect to the BootstrapBlazor Hub; retrying."); - await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken); - } - } - - _pendingSubscriptions.UnionWith(_subscriptions.Keys); - } - - if (_connection.State != HubConnectionState.Connected) - { - throw new InvalidOperationException("The BootstrapBlazor connection is reconnecting. Retry when it is connected."); - } - - await RestoreSubscriptionsCoreAsync(cancellationToken); - } - - /// - /// 自动重连成功后逐项恢复消息订阅,并重试暂时性故障 - /// Restore message subscriptions individually after automatic reconnection and retry transient failures - /// - /// 重连后的连接标识The connection identifier after reconnection - /// 表示异步操作的任务A task representing the asynchronous operation - private async Task RestoreSubscriptionsAsync(string? connectionId) - { - var token = _lifetime.Token; - try - { - var initialize = true; - while (true) - { - await _gate.WaitAsync(token); - try - { - if (initialize) - { - _pendingSubscriptions.UnionWith(_subscriptions.Keys); - initialize = false; - } - if (_connection.State != HubConnectionState.Connected) - { - return; - } - - await RestoreSubscriptionsCoreAsync(token); - if (_pendingSubscriptions.Count == 0) - { - return; - } - } - finally - { - _gate.Release(); - } - - await Task.Delay(TimeSpan.FromSeconds(3), token); - } - } - catch (OperationCanceledException) when (token.IsCancellationRequested) - { - } - catch (Exception ex) - { - _logger.LogError(ex, "Unable to restore dispatch subscriptions after reconnecting."); - } - } - - /// - /// 在持有连接锁时恢复待处理订阅,隔离单项失败;服务端明确拒绝时停止重试,执行失败时保留待重试项 - /// Restore pending subscriptions while holding the connection gate, isolating individual failures; stop retrying explicit server rejections and retain server execution failures for retry - /// - /// 取消令牌The cancellation token - /// 表示异步操作的任务A task representing the asynchronous operation - private async Task RestoreSubscriptionsCoreAsync(CancellationToken cancellationToken) - { - foreach (var entryType in _pendingSubscriptions.ToArray()) - { - cancellationToken.ThrowIfCancellationRequested(); - try - { - await SubscribeCoreAsync(entryType, cancellationToken); - _pendingSubscriptions.Remove(entryType); - } - catch (Exception ex) when (!cancellationToken.IsCancellationRequested) - { - if (!IsTransientFailure(ex) && _connection.State == HubConnectionState.Connected) - { - _pendingSubscriptions.Remove(entryType); - } - _logger.LogError(ex, "Unable to restore the dispatch subscription for entry type {EntryType}.", entryType); - } - } - } - - /// - /// 请求服务端订阅,区分明确拒绝与可重试的服务端执行失败 - /// Request a server subscription, distinguishing explicit rejection from retryable server execution failures - /// - /// 消息类型标识The entry type identifier - /// 取消令牌The cancellation token - /// 表示异步操作的任务A task representing the asynchronous operation - private async Task SubscribeCoreAsync(string entryType, CancellationToken cancellationToken) - { - try - { - if (!await _connection.InvokeAsync("Subscribe", entryType, cancellationToken)) - { - throw new UnauthorizedAccessException("Dispatch subscriptions are not allowed for this type."); - } - } - catch (HubException ex) - { - throw new IOException("The server could not complete the dispatch subscription.", ex); - } - } - - /// - /// 将服务端消息传递给对应消息类型的接收回调 - /// Forward a server message to the receiver registered for its entry type - /// - /// 消息类型标识The entry type identifier - /// 消息的 JSON 数据The message JSON data - /// 表示异步操作的任务A task representing the asynchronous operation - private async Task ReceiveAsync(string entryType, JsonElement entry) - { - if (_subscriptions.TryGetValue(entryType, out var callback)) - { - try - { - await callback(entry); - } - catch (Exception ex) - { - _logger.LogError(ex, "Unable to receive a dispatch for entry type {EntryType}.", entryType); - } - } - } - - /// - /// 取消连接操作,注销消息接收回调并释放 Hub 连接 - /// Cancel connection operations, unregister the message receiver, and dispose of the Hub connection - /// - /// 表示异步释放操作的任务A task representing the asynchronous disposal operation - public async ValueTask DisposeAsync() - { - _lifetime.Cancel(); - _connection.Reconnected -= RestoreSubscriptionsAsync; - _receiver.Dispose(); - await _connection.DisposeAsync(); - _lifetime.Dispose(); - } - - /// - /// 自动重连策略,首次立即重试,后续间隔递增至最多 30 秒 - /// Automatic reconnection policy that retries immediately at first, then increases the delay up to 30 seconds - /// - private sealed class ConnectionRetryPolicy : IRetryPolicy - { - /// - /// 根据已重试次数计算下次重连的等待时间 - /// Calculate the delay before the next reconnection attempt based on the previous retry count - /// - /// 重试上下文The retry context - /// 下次重连前的等待时间The delay before the next reconnection attempt - public TimeSpan? NextRetryDelay(RetryContext retryContext) - => TimeSpan.FromSeconds(Math.Min(30, retryContext.PreviousRetryCount * 3)); - } -} From 1c0c395d619b4e872f7835cd81f3731bd8cd4e09 Mon Sep 17 00:00:00 2001 From: Argo Zhang Date: Tue, 27 Oct 2026 12:47:31 +0800 Subject: [PATCH 9/9] chore: update BB to 11.1.0-rc.3.3 --- .../BootstrapBlazor.Middleware.csproj | 2 +- .../BootstrapBlazor.Middleware/ConnectionSnapshot.cs | 10 ---------- 2 files changed, 1 insertion(+), 11 deletions(-) delete mode 100644 src/middleware/BootstrapBlazor.Middleware/ConnectionSnapshot.cs diff --git a/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazor.Middleware.csproj b/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazor.Middleware.csproj index bc8d1124..70a3fc23 100644 --- a/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazor.Middleware.csproj +++ b/src/middleware/BootstrapBlazor.Middleware/BootstrapBlazor.Middleware.csproj @@ -9,7 +9,7 @@ - + diff --git a/src/middleware/BootstrapBlazor.Middleware/ConnectionSnapshot.cs b/src/middleware/BootstrapBlazor.Middleware/ConnectionSnapshot.cs deleted file mode 100644 index bb587709..00000000 --- a/src/middleware/BootstrapBlazor.Middleware/ConnectionSnapshot.cs +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) BootstrapBlazor & Argo Zhang (argo@live.ca). All rights reserved. -// Licensed under the Apache License, Version 2.0. - -namespace BootstrapBlazor.Components; - -/// -/// SignalR 连接快照及服务端计算的在线连接数量 -/// SignalR connection snapshot and the online connection count computed by the server -/// -public sealed record ConnectionSnapshot(long Count, ICollection Connections);