From 8891e6e8cd2b7d7cf180a5b85a4e4ea373417334 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=A4fele?= Date: Mon, 12 Jan 2026 15:53:02 +0100 Subject: [PATCH 1/2] Fix SignalR client disconnection issue by reverting to default timeouts The custom timeout configuration (ClientTimeoutInterval: 2 minutes, KeepAliveInterval: 30 seconds) was causing disconnections during active use due to a timing mismatch with the client's default ServerTimeout of 30 seconds. When the server sends keep-alive pings every 30 seconds but the client expects messages within 30 seconds, any network latency or processing delay causes the client to timeout before the server's ping arrives. Reverting to SignalR defaults provides the recommended 2:1 ratio: - Server KeepAliveInterval: 15 seconds (default) - Server ClientTimeoutInterval: 30 seconds (default) - Client KeepAliveInterval: 15 seconds (default) - Client ServerTimeout: 30 seconds (default) This ensures sufficient buffer time for network jitter and prevents spurious disconnections. --- src/RemoteViewer.Server/Program.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/RemoteViewer.Server/Program.cs b/src/RemoteViewer.Server/Program.cs index 7364f18..65bf36a 100644 --- a/src/RemoteViewer.Server/Program.cs +++ b/src/RemoteViewer.Server/Program.cs @@ -1,4 +1,4 @@ -using Nerdbank.MessagePack.SignalR; +using Nerdbank.MessagePack.SignalR; using RemoteViewer.Server.Hubs; using RemoteViewer.Server.Services; using RemoteViewer.Shared; @@ -26,9 +26,6 @@ .AddSignalR(options => { options.MaximumReceiveMessageSize = null; - - options.ClientTimeoutInterval = TimeSpan.FromMinutes(2); - options.KeepAliveInterval = TimeSpan.FromSeconds(30); }) .AddMessagePackProtocol(Witness.GeneratedTypeShapeProvider); builder.Services.AddSerilog(); From 43cd35df6489f86ba0405567546eb5266b913592 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=A4fele?= Date: Mon, 12 Jan 2026 16:31:47 +0100 Subject: [PATCH 2/2] Fix race condition in ConnectionGrain during connection teardown Add proper initialization guards and null checks to prevent InvalidOperationException when connections are being torn down: - Add EnsureInitialized() to UpdateProperties to ensure presenter exists before validation - Add EnsureInitialized() to Internal_AddViewer to prevent adding viewers to uninitialized grains - Add null guard in Internal_RemoveClient viewer path to handle race condition where presenter has already disconnected and nullified _presenter - Add EnsureInitialized() to Internal_DisplayNameChanged to prevent calls on uninitialized grains The race condition occurred when: 1. Presenter disconnects first, nullifying _presenter 2. Viewer disconnects immediately after 3. Viewer removal attempts to call NotifyConnectionChangedAsync() which requires presenter Now viewer removal gracefully skips notification if presenter is already gone, since the connection is effectively dead and all clients have already been notified via ConnectionStopped. Fixes: System.InvalidOperationException: ConnectionGrain not initialized: ConnectionId=..., presenter=null --- .../Orleans/Grains/ConnectionGrain.cs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/RemoteViewer.Server/Orleans/Grains/ConnectionGrain.cs b/src/RemoteViewer.Server/Orleans/Grains/ConnectionGrain.cs index 5a9b382..452f637 100644 --- a/src/RemoteViewer.Server/Orleans/Grains/ConnectionGrain.cs +++ b/src/RemoteViewer.Server/Orleans/Grains/ConnectionGrain.cs @@ -1,4 +1,4 @@ -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.SignalR; using RemoteViewer.Server.Hubs; using RemoteViewer.Shared; @@ -28,7 +28,9 @@ public sealed partial class ConnectionGrain(ILogger logger, IHu public async Task UpdateProperties(string signalrConnectionId, ConnectionProperties properties) { - if (this._presenter?.GetPrimaryKeyString() != signalrConnectionId) + this.EnsureInitialized(); + + if (this._presenter.GetPrimaryKeyString() != signalrConnectionId) { this.LogNonPresenterUpdateAttempt(signalrConnectionId, this.GetPrimaryKeyString()); return; @@ -149,6 +151,8 @@ await hubContext.Clients } async Task IConnectionGrain.Internal_AddViewer(IClientGrain viewer) { + this.EnsureInitialized(); + if (this._viewers.Contains(viewer)) { this.LogViewerAlreadyInConnection(this.GetPrimaryKeyString(), viewer.GetPrimaryKeyString()); @@ -190,11 +194,17 @@ async Task IConnectionGrain.Internal_RemoveClient(IClientGrain client) await hubContext.Clients.Client(client.GetPrimaryKeyString()).ConnectionStopped(this.GetPrimaryKeyString()); - await this.NotifyConnectionChangedAsync(); + // Only notify if presenter still exists - during teardown the presenter may have already + // disconnected and nullified _presenter, so we skip notification since the connection is dead + if (this._presenter is not null) + { + await this.NotifyConnectionChangedAsync(); + } } } async Task IConnectionGrain.Internal_DisplayNameChanged() { + this.EnsureInitialized(); await this.NotifyConnectionChangedAsync(); }