diff --git a/src/SignalR/clients/csharp/Client.Core/src/HubConnection.cs b/src/SignalR/clients/csharp/Client.Core/src/HubConnection.cs index a57e4a80aa31..6d4698d95f4a 100644 --- a/src/SignalR/clients/csharp/Client.Core/src/HubConnection.cs +++ b/src/SignalR/clients/csharp/Client.Core/src/HubConnection.cs @@ -2082,7 +2082,7 @@ public bool ShouldProcessMessage(HubMessage message) { if (!_messageBuffer.ShouldProcessMessage(message)) { - Log.DroppingMessage(_logger, ((HubInvocationMessage)message).GetType().Name, ((HubInvocationMessage)message).InvocationId); + Log.DroppingMessage(_logger, message.GetType().Name, (message as HubInvocationMessage)?.InvocationId ?? "(null}"); return false; } } @@ -2168,7 +2168,6 @@ Type IInvocationBinder.GetReturnType(string invocationId) { if (!TryGetInvocation(invocationId, out var irq)) { - Log.ReceivedUnexpectedResponse(_logger, invocationId); throw new KeyNotFoundException($"No invocation with id '{invocationId}' could be found."); } return irq.ResultType; @@ -2180,7 +2179,6 @@ Type IInvocationBinder.GetStreamItemType(string invocationId) // literally the same code as the above method if (!TryGetInvocation(invocationId, out var irq)) { - Log.ReceivedUnexpectedResponse(_logger, invocationId); throw new KeyNotFoundException($"No invocation with id '{invocationId}' could be found."); } return irq.ResultType; diff --git a/src/SignalR/clients/csharp/Client/test/FunctionalTests/HubConnectionTests.cs b/src/SignalR/clients/csharp/Client/test/FunctionalTests/HubConnectionTests.cs index ce2e574b2835..38c331def64c 100644 --- a/src/SignalR/clients/csharp/Client/test/FunctionalTests/HubConnectionTests.cs +++ b/src/SignalR/clients/csharp/Client/test/FunctionalTests/HubConnectionTests.cs @@ -2543,7 +2543,7 @@ public async Task ServerSentEventsWorksWithHttp2OnlyEndpoint() public async Task CanReconnectAndSendMessageWhileDisconnected() { var protocol = HubProtocols["json"]; - await using (var server = await StartServer(w => w.EventId.Name == "ReceivedUnexpectedResponse")) + await using (var server = await StartServer()) { var websocket = new ClientWebSocket(); var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -2602,7 +2602,7 @@ public async Task CanReconnectAndSendMessageWhileDisconnected() public async Task CanReconnectAndSendMessageOnceConnected() { var protocol = HubProtocols["json"]; - await using (var server = await StartServer(w => w.EventId.Name == "ReceivedUnexpectedResponse")) + await using (var server = await StartServer()) { var websocket = new ClientWebSocket(); var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -2910,6 +2910,143 @@ bool ExpectedErrors(WriteContext writeContext) } } + [Fact] + public async Task MultipleReconnectAttemptsWhenUsingStatefulReconnectFailure() + { + bool ExpectedErrors(WriteContext writeContext) + { + return writeContext.LoggerName == typeof(HubConnection).FullName && + (writeContext.EventId.Name == "ShutdownWithError" || + writeContext.EventId.Name == "ServerDisconnectedWithError"); + } + + var protocol = HubProtocols["json"]; + await using (var server = await StartServer(ExpectedErrors)) + { + var websocketConnectAttempts = 0; + var websocket = new ClientWebSocket(); + + const string originalMessage = "SignalR"; + var connectionBuilder = new HubConnectionBuilder() + .WithLoggerFactory(LoggerFactory) + .WithUrl(server.Url + "/default", HttpTransportType.WebSockets, o => + { + o.WebSocketFactory = async (context, token) => + { + websocketConnectAttempts++; + if (websocketConnectAttempts > 1) + { + throw new Exception("from websocket connect"); + } + await websocket.ConnectAsync(context.Uri, token); + return websocket; + }; + o.UseStatefulReconnect = true; + }); + connectionBuilder.Services.AddSingleton(protocol); + var connection = connectionBuilder.Build(); + + try + { + await connection.StartAsync().DefaultTimeout(); + Assert.Equal(1, websocketConnectAttempts); + + var originalConnectionId = connection.ConnectionId; + + var result = await connection.InvokeAsync(nameof(TestHub.Echo), originalMessage).DefaultTimeout(); + + Assert.Equal(originalMessage, result); + + var originalWebsocket = websocket; + websocket = new ClientWebSocket(); + originalWebsocket.Dispose(); + + var resultTask = connection.InvokeAsync(nameof(TestHub.Echo), originalMessage).DefaultTimeout(); + + await Assert.ThrowsAsync(() => resultTask); + + // Initial connection + 3 failed reconnect attempts + Assert.Equal(4, websocketConnectAttempts); + } + catch (Exception ex) + { + LoggerFactory.CreateLogger().LogError(ex, "{ExceptionType} from test", ex.GetType().FullName); + throw; + } + finally + { + await connection.DisposeAsync().DefaultTimeout(); + } + } + } + + [Fact] + public async Task MultipleReconnectAttemptsWhenUsingStatefulReconnectSuccessful() + { + var protocol = HubProtocols["json"]; + await using (var server = await StartServer()) + { + var websocketConnectAttempts = 0; + var websocket = new ClientWebSocket(); + + const string originalMessage = "SignalR"; + var connectionBuilder = new HubConnectionBuilder() + .WithLoggerFactory(LoggerFactory) + .WithUrl(server.Url + "/default", HttpTransportType.WebSockets, o => + { + o.WebSocketFactory = async (context, token) => + { + websocketConnectAttempts++; + if (websocketConnectAttempts is > 1 and < 4) + { + throw new Exception("from websocket connect"); + } + await websocket.ConnectAsync(context.Uri, token); + return websocket; + }; + o.UseStatefulReconnect = true; + }); + connectionBuilder.Services.AddSingleton(protocol); + var connection = connectionBuilder.Build(); + + try + { + await connection.StartAsync().DefaultTimeout(); + Assert.Equal(1, websocketConnectAttempts); + + var originalConnectionId = connection.ConnectionId; + + var result = await connection.InvokeAsync(nameof(TestHub.Echo), originalMessage).DefaultTimeout(); + + Assert.Equal(originalMessage, result); + + var originalWebsocket = websocket; + websocket = new ClientWebSocket(); + originalWebsocket.Dispose(); + + var resultTask = connection.InvokeAsync(nameof(TestHub.Echo), originalMessage).DefaultTimeout(); + + result = await resultTask; + Assert.Equal(originalMessage, result); + + // Initial connection + 2 failed reconnect attempts + 1 successful attempt + Assert.Equal(4, websocketConnectAttempts); + + result = await connection.InvokeAsync(nameof(TestHub.Echo), originalMessage).DefaultTimeout(); + Assert.Equal(originalMessage, result); + } + catch (Exception ex) + { + LoggerFactory.CreateLogger().LogError(ex, "{ExceptionType} from test", ex.GetType().FullName); + throw; + } + finally + { + await connection.DisposeAsync().DefaultTimeout(); + } + } + } + private class OneAtATimeSynchronizationContext : SynchronizationContext, IAsyncDisposable { private readonly Channel<(SendOrPostCallback, object)> _taskQueue = Channel.CreateUnbounded<(SendOrPostCallback, object)>(); diff --git a/src/SignalR/clients/csharp/Http.Connections.Client/src/Internal/WebSocketsTransport.cs b/src/SignalR/clients/csharp/Http.Connections.Client/src/Internal/WebSocketsTransport.cs index c20a74ac8ab7..a6b9ce609fad 100644 --- a/src/SignalR/clients/csharp/Http.Connections.Client/src/Internal/WebSocketsTransport.cs +++ b/src/SignalR/clients/csharp/Http.Connections.Client/src/Internal/WebSocketsTransport.cs @@ -404,15 +404,27 @@ private async Task ProcessSocketAsync(WebSocket socket, Uri url, bool isReconnec { if (!_gracefulClose && UpdateConnectionPair()) { - try + Exception? exception = null; + // Arbitrary number of retries, will likely be user configurable in the future + for (var i = 0; i < 3; i++) { - await StartAsync(url, _webSocketMessageType == WebSocketMessageType.Binary ? TransferFormat.Binary : TransferFormat.Text, - cancellationToken: default).ConfigureAwait(false); - cleanup = false; + try + { + await StartAsync(url, _webSocketMessageType == WebSocketMessageType.Binary ? TransferFormat.Binary : TransferFormat.Text, + cancellationToken: default).ConfigureAwait(false); + exception = null; + cleanup = false; + break; + } + catch (Exception ex) + { + exception = ex; + } } - catch (Exception ex) + + if (exception is not null) { - throw new InvalidOperationException("Reconnect attempt failed.", innerException: ex); + throw new InvalidOperationException("Reconnect attempt failed.", innerException: exception); } } } diff --git a/src/SignalR/clients/ts/signalr/src/HttpConnection.ts b/src/SignalR/clients/ts/signalr/src/HttpConnection.ts index 3e48a83d61f4..58c1726e6c3b 100644 --- a/src/SignalR/clients/ts/signalr/src/HttpConnection.ts +++ b/src/SignalR/clients/ts/signalr/src/HttpConnection.ts @@ -442,8 +442,22 @@ export class HttpConnection implements IConnection { if (this.features.reconnect) { try { this.features.disconnected(); - await this.transport!.connect(url, transferFormat); - await this.features.resend(); + let error; + for (let i = 0; i < 3; i++) { + try { + await this.transport!.connect(url, transferFormat); + error = undefined; + break; + } catch (ex) { + error = ex; + } + } + + if (error) { + callStop = true; + } else { + await this.features.resend(); + } } catch { callStop = true; } diff --git a/src/SignalR/clients/ts/signalr/tests/HttpConnection.test.ts b/src/SignalR/clients/ts/signalr/tests/HttpConnection.test.ts index 76ff38e3357f..3fdaaf1510fe 100644 --- a/src/SignalR/clients/ts/signalr/tests/HttpConnection.test.ts +++ b/src/SignalR/clients/ts/signalr/tests/HttpConnection.test.ts @@ -1981,13 +1981,18 @@ describe("TransportSendQueue", () => { TestWebSocket.webSocketSet = new PromiseSource(); TestWebSocket.webSocket.close(); - // transport should be trying to connect again - await TestWebSocket.webSocketSet; - await TestWebSocket.webSocket.openSet; - expect(disconnectedCalled).toBe(true); - expect(resendCalled).toBe(false); - // fail to connect - TestWebSocket.webSocket.onclose(new TestEvent()); + for (let i = 0; i < 3; i++) { + // transport should be trying to connect again + await TestWebSocket.webSocketSet; + await TestWebSocket.webSocket.openSet; + expect(resendCalled).toBe(false); + expect(disconnectedCalled).toBe(true); + + TestWebSocket.webSocketSet = new PromiseSource(); + + // fail to connect + TestWebSocket.webSocket.onclose(new TestEvent()); + } await onclosePromise; expect(resendCalled).toBe(false);