Browse Source

Send CloseMessage from client to server (#48577)

Brennan 2 years ago
parent
commit
69203c4fdb
23 changed files with 293 additions and 40 deletions
  1. 33 7
      src/SignalR/clients/csharp/Client.Core/src/HubConnection.cs
  2. 3 4
      src/SignalR/clients/csharp/Client/test/FunctionalTests/Startup.cs
  3. 3 1
      src/SignalR/clients/csharp/Client/test/UnitTests/HubConnectionTests.Protocol.cs
  4. 4 4
      src/SignalR/clients/csharp/Client/test/UnitTests/HubConnectionTests.cs
  5. 0 1
      src/SignalR/clients/csharp/Http.Connections.Client/src/Internal/WebSocketsTransport.cs
  6. 2 0
      src/SignalR/clients/java/signalr/core/src/main/java/com/microsoft/signalr/CloseMessage.java
  7. 2 3
      src/SignalR/clients/java/signalr/core/src/main/java/com/microsoft/signalr/GsonHubProtocol.java
  8. 3 0
      src/SignalR/clients/java/signalr/core/src/main/java/com/microsoft/signalr/HubConnection.java
  9. 9 0
      src/SignalR/clients/java/signalr/test/src/main/java/com/microsoft/signalr/GsonHubProtocolTest.java
  10. 17 0
      src/SignalR/clients/java/signalr/test/src/main/java/com/microsoft/signalr/HubConnectionTest.java
  11. 8 0
      src/SignalR/clients/ts/signalr-protocol-msgpack/src/MessagePackHubProtocol.ts
  12. 12 1
      src/SignalR/clients/ts/signalr-protocol-msgpack/tests/MessagePackHubProtocol.test.ts
  13. 19 1
      src/SignalR/clients/ts/signalr/src/HubConnection.ts
  14. 19 2
      src/SignalR/clients/ts/signalr/src/LongPollingTransport.ts
  15. 3 3
      src/SignalR/clients/ts/signalr/tests/HubConnection.Reconnect.test.ts
  16. 27 5
      src/SignalR/clients/ts/signalr/tests/HubConnection.test.ts
  17. 13 1
      src/SignalR/clients/ts/signalr/tests/JsonHubProtocol.test.ts
  18. 1 1
      src/SignalR/docs/specs/HubProtocol.md
  19. 4 1
      src/SignalR/server/Core/src/HubConnectionContext.cs
  20. 15 1
      src/SignalR/server/Core/src/HubConnectionHandler.cs
  21. 5 0
      src/SignalR/server/Core/src/Internal/DefaultHubDispatcher.cs
  22. 88 0
      src/SignalR/server/SignalR/test/HubConnectionHandlerTests.cs
  23. 3 4
      src/SignalR/server/SignalR/test/Startup.cs

+ 33 - 7
src/SignalR/clients/csharp/Client.Core/src/HubConnection.cs

@@ -489,6 +489,8 @@ public partial class HubConnection : IAsyncDisposable
         {
             Log.ErrorStartingConnection(_logger, ex);
 
+            startingConnectionState.Cleanup();
+
             // Can't have any invocations to cancel, we're in the lock.
             await CloseAsync(startingConnectionState.Connection).ConfigureAwait(false);
             throw;
@@ -543,6 +545,8 @@ public partial class HubConnection : IAsyncDisposable
 
         ConnectionState? connectionState;
 
+        var connectionStateStopTask = Task.CompletedTask;
+
         try
         {
             if (disposing && _disposed)
@@ -559,6 +563,19 @@ public partial class HubConnection : IAsyncDisposable
             if (connectionState != null)
             {
                 connectionState.Stopping = true;
+                // Try to send CloseMessage
+                var writeTask = SendHubMessage(connectionState, CloseMessage.Empty);
+                if (writeTask.IsFaulted || writeTask.IsCanceled || !writeTask.IsCompleted)
+                {
+                    // Ignore exception from write, this is a best effort attempt to let the server know the client closed gracefully.
+                    // We are already closing the connection via an explicit StopAsync call from the user so don't care about any potential
+                    // errors that might happen.
+                    _ = writeTask.ContinueWith(
+                        static t => _ = t.Exception,
+                        CancellationToken.None,
+                        TaskContinuationOptions.ExecuteSynchronously | TaskContinuationOptions.OnlyOnFaulted,
+                        TaskScheduler.Default);
+                }
             }
             else
             {
@@ -579,17 +596,20 @@ public partial class HubConnection : IAsyncDisposable
                     (_serviceProvider as IDisposable)?.Dispose();
                 }
             }
+
+            if (connectionState != null)
+            {
+                // Start Stop inside the lock so a closure from the transport side at the same time as this doesn't cause an ODE
+                // But don't await the call in the lock as that could deadlock with HandleConnectionClose in the ReceiveLoop
+                connectionStateStopTask = connectionState.StopAsync();
+            }
         }
         finally
         {
             _state.ReleaseConnectionLock();
         }
 
-        // Now stop the connection we captured
-        if (connectionState != null)
-        {
-            await connectionState.StopAsync().ConfigureAwait(false);
-        }
+        await connectionStateStopTask.ConfigureAwait(false);
     }
 
     /// <summary>
@@ -1459,6 +1479,7 @@ public partial class HubConnection : IAsyncDisposable
 
             // Cancel any outstanding invocations within the connection lock
             connectionState.CancelOutstandingInvocations(connectionState.CloseException);
+            connectionState.Cleanup();
 
             if (connectionState.Stopping || _reconnectPolicy == null)
             {
@@ -1965,9 +1986,9 @@ public partial class HubConnection : IAsyncDisposable
 
         private async Task StopAsyncCore()
         {
-            Log.Stopping(_logger);
+            _hubConnection._state.AssertInConnectionLock();
 
-            _messageBuffer?.Dispose();
+            Log.Stopping(_logger);
 
             // Complete our write pipe, which should cause everything to shut down
             Log.TerminatingReceiveLoop(_logger);
@@ -1983,6 +2004,11 @@ public partial class HubConnection : IAsyncDisposable
             _stopTcs!.TrySetResult(null);
         }
 
+        public void Cleanup()
+        {
+            _messageBuffer?.Dispose();
+        }
+
         public async Task TimerLoop(TimerAwaitable timer)
         {
             // initialize the timers

+ 3 - 4
src/SignalR/clients/csharp/Client/test/FunctionalTests/Startup.cs

@@ -59,10 +59,9 @@ public class Startup
                 });
         services.AddAuthentication(NegotiateDefaults.AuthenticationScheme).AddNegotiate();
 
-        // Since tests run in parallel, it's possible multiple servers will startup and read files being written by another test
-        // Use a unique directory per server to avoid this collision
-        services.AddDataProtection()
-            .PersistKeysToFileSystem(Directory.CreateDirectory(Path.GetRandomFileName()));
+        // Since tests run in parallel, it's possible multiple servers will startup,
+        // we use an ephemeral key provider to avoid filesystem contention issues
+        services.AddSingleton<IDataProtectionProvider, EphemeralDataProtectionProvider>();
     }
 
     public void Configure(IApplicationBuilder app)

+ 3 - 1
src/SignalR/clients/csharp/Client/test/UnitTests/HubConnectionTests.Protocol.cs

@@ -668,7 +668,9 @@ public partial class HubConnectionTests
                 await hubConnection.DisposeAsync().DefaultTimeout();
                 await connection.DisposeAsync().DefaultTimeout();
 
-                Assert.Equal(0, (await connection.ReadAllSentMessagesAsync(ignorePings: false).DefaultTimeout()).Count);
+                var messages = await connection.ReadAllSentMessagesAsync(ignorePings: false).DefaultTimeout();
+                var message = Assert.Single(messages);
+                Assert.Equal("{\"type\":7}", message);
             }
             finally
             {

+ 4 - 4
src/SignalR/clients/csharp/Client/test/UnitTests/HubConnectionTests.cs

@@ -193,7 +193,7 @@ public partial class HubConnectionTests : VerifiableLoggedTest
             await hubConnection.StopAsync().DefaultTimeout();
 
             // Assert that InvokeAsync didn't send a message
-            Assert.Null(await connection.ReadSentTextMessageAsync().DefaultTimeout());
+            Assert.Equal("{\"type\":7}", await connection.ReadSentTextMessageAsync().DefaultTimeout());
         }
     }
 
@@ -212,7 +212,7 @@ public partial class HubConnectionTests : VerifiableLoggedTest
             await hubConnection.StopAsync().DefaultTimeout();
 
             // Assert that SendAsync didn't send a message
-            Assert.Null(await connection.ReadSentTextMessageAsync().DefaultTimeout());
+            Assert.Equal("{\"type\":7}", await connection.ReadSentTextMessageAsync().DefaultTimeout());
         }
     }
 
@@ -254,7 +254,7 @@ public partial class HubConnectionTests : VerifiableLoggedTest
             await hubConnection.StopAsync().DefaultTimeout();
 
             // Assert that StreamAsChannelAsync didn't send a message
-            Assert.Null(await connection.ReadSentTextMessageAsync().DefaultTimeout());
+            Assert.Equal("{\"type\":7}", await connection.ReadSentTextMessageAsync().DefaultTimeout());
         }
     }
 
@@ -273,7 +273,7 @@ public partial class HubConnectionTests : VerifiableLoggedTest
             await hubConnection.StopAsync().DefaultTimeout();
 
             // Assert that StreamAsync didn't send a message
-            Assert.Null(await connection.ReadSentTextMessageAsync().DefaultTimeout());
+            Assert.Equal("{\"type\":7}", await connection.ReadSentTextMessageAsync().DefaultTimeout());
         }
     }
 

+ 0 - 1
src/SignalR/clients/csharp/Http.Connections.Client/src/Internal/WebSocketsTransport.cs

@@ -491,7 +491,6 @@ internal sealed partial class WebSocketsTransport : ITransport, IReconnectFeatur
                 {
                     if (result.IsCanceled && !ignoreFirstCanceled)
                     {
-                        _logger.LogInformation("send canceled");
                         break;
                     }
 

+ 2 - 0
src/SignalR/clients/java/signalr/core/src/main/java/com/microsoft/signalr/CloseMessage.java

@@ -4,6 +4,8 @@
 package com.microsoft.signalr;
 
 public final class CloseMessage extends HubMessage {
+    private final int type = HubMessageType.CLOSE.value;
+
     private final String error;
     private final boolean allowReconnect;
 

+ 2 - 3
src/SignalR/clients/java/signalr/core/src/main/java/com/microsoft/signalr/GsonHubProtocol.java

@@ -19,7 +19,6 @@ import com.google.gson.stream.JsonReader;
 import com.google.gson.stream.JsonToken;
 
 public final class GsonHubProtocol implements HubProtocol {
-    private final JsonParser jsonParser = new JsonParser();
     private final Gson gson;
     private static final String RECORD_SEPARATOR = "\u001e";
 
@@ -95,7 +94,7 @@ public final class GsonHubProtocol implements HubProtocol {
                         case "result":
                         case "item":
                             if (invocationId == null || binder.getReturnType(invocationId) == null) {
-                                resultToken = jsonParser.parse(reader);
+                                resultToken = JsonParser.parseReader(reader);
                             } else {
                                 result = gson.fromJson(reader, binder.getReturnType(invocationId));
                             }
@@ -123,7 +122,7 @@ public final class GsonHubProtocol implements HubProtocol {
                                     }
                                 }
                             } else {
-                                argumentsToken = (JsonArray)jsonParser.parse(reader);
+                                argumentsToken = (JsonArray)JsonParser.parseReader(reader);
                             }
                             break;
                         case "headers":

+ 3 - 0
src/SignalR/clients/java/signalr/core/src/main/java/com/microsoft/signalr/HubConnection.java

@@ -431,6 +431,9 @@ public class HubConnection implements AutoCloseable {
                 connectionState.stopError = errorMessage;
                 logger.error("HubConnection disconnected with an error: {}.", errorMessage);
             } else {
+                if (this.state.getHubConnectionState() == HubConnectionState.CONNECTED) {
+                    sendHubMessageWithLock(new CloseMessage());
+                }
                 logger.debug("Stopping HubConnection.");
             }
 

+ 9 - 0
src/SignalR/clients/java/signalr/test/src/main/java/com/microsoft/signalr/GsonHubProtocolTest.java

@@ -56,6 +56,15 @@ class GsonHubProtocolTest {
         assertEquals(HubMessageType.PING, messages.get(0).getMessageType());
     }
 
+    @Test
+    public void writeCloseMessage() {
+        CloseMessage closeMessage = new CloseMessage();
+        String result = TestUtils.byteBufferToString(hubProtocol.writeMessage(closeMessage));
+        String expectedResult = "{\"type\":7,\"allowReconnect\":false}\u001E";
+
+        assertEquals(expectedResult, result);
+    }
+
     @Test
     public void parseCloseMessage() {
         String stringifiedMessage = "{\"type\":7}\u001E";

+ 17 - 0
src/SignalR/clients/java/signalr/test/src/main/java/com/microsoft/signalr/HubConnectionTest.java

@@ -4022,4 +4022,21 @@ class HubConnectionTest {
             assertEquals(1, count);
         }
     }
+
+    @Test
+    public void sendsCloseMessageOnStop() throws InterruptedException {
+        MockTransport mockTransport = new MockTransport(true, false);
+        HubConnection hubConnection = TestUtils.createHubConnection("http://example.com", mockTransport);
+
+        hubConnection.start().timeout(30, TimeUnit.SECONDS).blockingAwait();
+
+        hubConnection.stop().timeout(30, TimeUnit.SECONDS).blockingAwait();
+
+        ByteBuffer[] messages = mockTransport.getSentMessages();
+
+        // handshake, close
+        assertEquals(2, messages.length);
+        String message = TestUtils.byteBufferToString(messages[1]);
+        assertEquals("{\"type\":7,\"allowReconnect\":false}" + RECORD_SEPARATOR, message);
+    }
 }

+ 8 - 0
src/SignalR/clients/ts/signalr-protocol-msgpack/src/MessagePackHubProtocol.ts

@@ -112,6 +112,8 @@ export class MessagePackHubProtocol implements IHubProtocol {
                 return BinaryMessageFormat.write(SERIALIZED_PING_MESSAGE);
             case MessageType.CancelInvocation:
                 return this._writeCancelInvocation(message as CancelInvocationMessage);
+            case MessageType.Close:
+                return this._writeClose();
             default:
                 throw new Error("Invalid message type.");
         }
@@ -309,6 +311,12 @@ export class MessagePackHubProtocol implements IHubProtocol {
         return BinaryMessageFormat.write(payload.slice());
     }
 
+    private _writeClose(): ArrayBuffer {
+        const payload = this._encoder.encode([MessageType.Close, null]);
+
+        return BinaryMessageFormat.write(payload.slice());
+    }
+
     private _readHeaders(properties: any): MessageHeaders {
         const headers: MessageHeaders = properties[1] as MessageHeaders;
         if (typeof headers !== "object") {

+ 12 - 1
src/SignalR/clients/ts/signalr-protocol-msgpack/tests/MessagePackHubProtocol.test.ts

@@ -1,7 +1,7 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
-import { CompletionMessage, InvocationMessage, MessageType, NullLogger, StreamItemMessage } from "@microsoft/signalr";
+import { CloseMessage, CompletionMessage, InvocationMessage, MessageType, NullLogger, StreamItemMessage } from "@microsoft/signalr";
 import { MessagePackHubProtocol } from "../src/MessagePackHubProtocol";
 
 describe("MessagePackHubProtocol", () => {
@@ -273,4 +273,15 @@ describe("MessagePackHubProtocol", () => {
             type: 1,
         });
     });
+
+    it("can write/read Close message", () => {
+        const closeMessage = {
+            type: MessageType.Close,
+        } as CloseMessage;
+
+        const protocol = new MessagePackHubProtocol();
+        const parsedMessages = protocol.parseMessages(protocol.writeMessage(closeMessage), NullLogger.instance);
+        expect(parsedMessages.length).toEqual(1);
+        expect(parsedMessages[0].type).toEqual(MessageType.Close);
+    });
 });

+ 19 - 1
src/SignalR/clients/ts/signalr/src/HubConnection.ts

@@ -4,7 +4,7 @@
 import { HandshakeProtocol, HandshakeRequestMessage, HandshakeResponseMessage } from "./HandshakeProtocol";
 import { IConnection } from "./IConnection";
 import { AbortError } from "./Errors";
-import { CancelInvocationMessage, CompletionMessage, IHubProtocol, InvocationMessage, MessageType, StreamInvocationMessage, StreamItemMessage } from "./IHubProtocol";
+import { CancelInvocationMessage, CloseMessage, CompletionMessage, IHubProtocol, InvocationMessage, MessageType, StreamInvocationMessage, StreamItemMessage } from "./IHubProtocol";
 import { ILogger, LogLevel } from "./ILogger";
 import { IRetryPolicy } from "./IRetryPolicy";
 import { IStreamResult } from "./Stream";
@@ -294,6 +294,7 @@ export class HubConnection {
             return this._stopPromise!;
         }
 
+        const state = this._connectionState;
         this._connectionState = HubConnectionState.Disconnecting;
 
         this._logger.log(LogLevel.Debug, "Stopping HubConnection.");
@@ -311,6 +312,11 @@ export class HubConnection {
             return Promise.resolve();
         }
 
+        if (state === HubConnectionState.Connected) {
+            // eslint-disable-next-line @typescript-eslint/no-floating-promises
+            this._sendCloseMessage();
+        }
+
         this._cleanupTimeout();
         this._cleanupPingTimer();
         this._stopDuringStartError = error || new AbortError("The connection was stopped before the hub handshake could complete.");
@@ -321,6 +327,14 @@ export class HubConnection {
         return this.connection.stop(error);
     }
 
+    private async _sendCloseMessage() {
+        try {
+            await this._sendWithProtocol(this._createCloseMessage());
+        } catch {
+            // Ignore, this is a best effort attempt to let the server know the client closed gracefully.
+        }
+    }
+
     /** Invokes a streaming hub method on the server using the specified name and arguments.
      *
      * @typeparam T The type of the items returned by the server.
@@ -1077,4 +1091,8 @@ export class HubConnection {
             type: MessageType.Completion,
         };
     }
+
+    private _createCloseMessage(): CloseMessage {
+        return { type: MessageType.Close };
+    }
 }

+ 19 - 2
src/SignalR/clients/ts/signalr/src/LongPollingTransport.ts

@@ -175,9 +175,26 @@ export class LongPollingTransport implements ITransport {
                 timeout: this._options.timeout,
                 withCredentials: this._options.withCredentials,
             };
-            await this._httpClient.delete(this._url!, deleteOptions);
 
-            this._logger.log(LogLevel.Trace, "(LongPolling transport) DELETE request sent.");
+            let error;
+            try {
+                await this._httpClient.delete(this._url!, deleteOptions);
+            } catch (err) {
+                error = err;
+            }
+
+            if (error) {
+                if (error instanceof HttpError) {
+                    if (error.statusCode === 404) {
+                        this._logger.log(LogLevel.Trace, "(LongPolling transport) A 404 response was returned from sending a DELETE request.");
+                    } else {
+                        this._logger.log(LogLevel.Trace, `(LongPolling transport) Error sending a DELETE request: ${error}`);
+                    }
+                }
+            } else {
+                this._logger.log(LogLevel.Trace, "(LongPolling transport) DELETE request accepted.");
+            }
+
         } finally {
             this._logger.log(LogLevel.Trace, "(LongPolling transport) Stop finished.");
 

+ 3 - 3
src/SignalR/clients/ts/signalr/tests/HubConnection.Reconnect.test.ts

@@ -768,10 +768,10 @@ describe("auto reconnect", () => {
             const connection = new TestConnection();
             const hubConnection = HubConnection.create(connection, logger, new JsonHubProtocol(), new DefaultReconnectPolicy());
             try {
-                let isClosed = false;
+                const p = new PromiseSource<void>();
                 let closeError: Error | undefined;
                 hubConnection.onclose((e) => {
-                    isClosed = true;
+                    p.resolve();
                     closeError = e;
                 });
 
@@ -782,7 +782,7 @@ describe("auto reconnect", () => {
                     type: MessageType.Close,
                 });
 
-                expect(isClosed).toEqual(true);
+                await p;
                 expect(closeError!.message).toEqual("Server returned an error on close: Error!");
             } finally {
                 await hubConnection.stop();

+ 27 - 5
src/SignalR/clients/ts/signalr/tests/HubConnection.test.ts

@@ -171,6 +171,26 @@ describe("HubConnection", () => {
                 }
             });
         });
+
+        it("sends close message", async () => {
+            await VerifyLogger.run(async (logger) => {
+                const connection = new TestConnection();
+                const hubConnection = createHubConnection(connection, logger);
+                try {
+                    await hubConnection.start();
+
+                    await hubConnection.stop();
+
+                    // Handshake, Ping, Close
+                    expect(connection.sentData.length).toBe(3);
+                    expect(JSON.parse(connection.sentData[2])).toEqual({
+                        type: MessageType.Close,
+                    });
+                } finally {
+                    await hubConnection.stop();
+                }
+            });
+        });
     });
 
     describe("send", () => {
@@ -850,10 +870,10 @@ describe("HubConnection", () => {
                 const connection = new TestConnection();
                 const hubConnection = createHubConnection(connection, logger);
                 try {
-                    let isClosed = false;
+                    const p = new PromiseSource<void>();
                     let closeError: Error | undefined;
                     hubConnection.onclose((e) => {
-                        isClosed = true;
+                        p.resolve();
                         closeError = e;
                     });
 
@@ -863,7 +883,7 @@ describe("HubConnection", () => {
                         type: MessageType.Close,
                     });
 
-                    expect(isClosed).toEqual(true);
+                    await p;
                     expect(closeError).toBeUndefined();
                 } finally {
                     await hubConnection.stop();
@@ -1827,8 +1847,10 @@ class TestProtocol implements IHubProtocol {
     }
 
     public writeMessage(message: HubMessage): any {
-        if (message.type === 6) {
-            return "{\"type\": 6}" + TextMessageFormat.RecordSeparator;
+        if (message.type === 6 || message.type === 7) {
+            return `{"type": ${message.type}}` + TextMessageFormat.RecordSeparator;
+        } else {
+            throw new Error(`update TestProtocol to write message type ${message.type}`);
         }
     }
 }

+ 13 - 1
src/SignalR/clients/ts/signalr/tests/JsonHubProtocol.test.ts

@@ -1,7 +1,7 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
-import { CompletionMessage, InvocationMessage, MessageType, StreamItemMessage } from "../src/IHubProtocol";
+import { CloseMessage, CompletionMessage, InvocationMessage, MessageType, StreamItemMessage } from "../src/IHubProtocol";
 import { JsonHubProtocol } from "../src/JsonHubProtocol";
 import { TextMessageFormat } from "../src/TextMessageFormat";
 import { VerifyLogger } from "./Common";
@@ -212,4 +212,16 @@ describe("JsonHubProtocol", () => {
             ]);
         });
     });
+
+    it("can write/read Close message", async () => {
+        await VerifyLogger.run(async (logger) => {
+            const closeMessage = {
+                type: MessageType.Close,
+            } as CloseMessage;
+
+            const protocol = new JsonHubProtocol();
+            const parsedMessages = protocol.parseMessages(protocol.writeMessage(closeMessage), logger);
+            expect(parsedMessages).toEqual([closeMessage]);
+        });
+    });
 });

+ 1 - 1
src/SignalR/docs/specs/HubProtocol.md

@@ -24,7 +24,7 @@ In the SignalR protocol, the following types of messages can be sent:
 | ------------------    | -------------- | ------------------------------------------------------------------------------------------------------------------------------ |
 | `HandshakeRequest`    | Client         | Sent by the client to agree on the message format.                                                                            |
 | `HandshakeResponse`   | Server         | Sent by the server as an acknowledgment of the previous `HandshakeRequest` message. Contains an error if the handshake failed. |
-| `Close`               | Callee, Caller | Sent by the server when a connection is closed. Contains an error if the connection was closed because of an error.            |
+| `Close`               | Callee, Caller | Sent by the server when a connection is closed. Contains an error if the connection was closed because of an error. Sent by the client when it's closing the connection, unlikely to contain an error. |
 | `Invocation`          | Caller         | Indicates a request to invoke a particular method (the Target) with provided Arguments on the remote endpoint.                 |
 | `StreamInvocation`    | Caller         | Indicates a request to invoke a streaming method (the Target) with provided Arguments on the remote endpoint.                  |
 | `StreamItem`          | Callee, Caller | Indicates individual items of streamed response data from a previous `StreamInvocation` message or streamed uploads from an invocation with streamIds.                               |

+ 4 - 1
src/SignalR/server/Core/src/HubConnectionContext.cs

@@ -106,6 +106,8 @@ public partial class HubConnectionContext
 
     internal Exception? CloseException { get; private set; }
 
+    internal CloseMessage? CloseMessage { get; set; }
+
     internal ChannelBasedSemaphore ActiveInvocationLimit { get; }
 
     /// <summary>
@@ -676,7 +678,7 @@ public partial class HubConnectionContext
 
     private void CheckClientTimeout()
     {
-        if (Debugger.IsAttached)
+        if (Debugger.IsAttached || _connectionAborted)
         {
             return;
         }
@@ -689,6 +691,7 @@ public partial class HubConnectionContext
 
                 if (_receivedMessageElapsed >= _clientTimeoutInterval)
                 {
+                    CloseException ??= new OperationCanceledException($"Client hasn't sent a message/ping within the configured {nameof(HubConnectionContextOptions.ClientTimeoutInterval)}.");
                     Log.ClientTimeout(_logger, _clientTimeoutInterval);
                     AbortAllowReconnect();
                 }

+ 15 - 1
src/SignalR/server/Core/src/HubConnectionHandler.cs

@@ -192,6 +192,20 @@ public class HubConnectionHandler<THub> : ConnectionHandler where THub : Hub
 
     private async Task HubOnDisconnectedAsync(HubConnectionContext connection, Exception? exception)
     {
+        var disconnectException = exception;
+        if (connection.CloseMessage is not null)
+        {
+            // If client sent a CloseMessage we don't care about any internal exceptions that may have occurred.
+            // The CloseMessage indicates a graceful closure on the part of the client.
+            disconnectException = null;
+            exception = null;
+            if (connection.CloseMessage.Error is not null)
+            {
+                // A bit odd for the client to send an error along with a graceful close, but just in case we should surface it in OnDisconnectedAsync
+                disconnectException = new HubException(connection.CloseMessage.Error);
+            }
+        }
+
         // send close message before aborting the connection
         await SendCloseAsync(connection, exception, connection.AllowReconnect);
 
@@ -206,7 +220,7 @@ public class HubConnectionHandler<THub> : ConnectionHandler where THub : Hub
 
         try
         {
-            await _dispatcher.OnDisconnectedAsync(connection, exception);
+            await _dispatcher.OnDisconnectedAsync(connection, disconnectException);
         }
         catch (Exception ex)
         {

+ 5 - 0
src/SignalR/server/Core/src/Internal/DefaultHubDispatcher.cs

@@ -204,6 +204,11 @@ internal sealed partial class DefaultHubDispatcher<THub> : HubDispatcher<THub> w
                 connection.ResetSequence(sequenceMessage);
                 break;
 
+            case CloseMessage closeMessage:
+                connection.CloseMessage = closeMessage;
+                connection.Abort();
+                break;
+
             // Other kind of message we weren't expecting
             default:
                 Log.UnsupportedMessageReceived(_logger, hubMessage.GetType().FullName!);

+ 88 - 0
src/SignalR/server/SignalR/test/HubConnectionHandlerTests.cs

@@ -2854,6 +2854,42 @@ public partial class HubConnectionHandlerTests : VerifiableLoggedTest
         }
     }
 
+    [Fact]
+    public async Task OnDisconnectedAsyncReceivesExceptionOnPingTimeout()
+    {
+        using (StartVerifiableLog())
+        {
+            var timeout = TimeSpan.FromMilliseconds(100);
+            var timeProvider = new MockTimeProvider();
+            var state = new ConnectionLifetimeState();
+            var serviceProvider = HubConnectionHandlerTestUtils.CreateServiceProvider(services =>
+            {
+                services.Configure<HubOptions>(options =>
+                    options.ClientTimeoutInterval = timeout);
+
+                services.AddSingleton(state);
+            }, LoggerFactory);
+
+            var connectionHandler = serviceProvider.GetService<HubConnectionHandler<ConnectionLifetimeHub>>();
+            connectionHandler.TimeProvider = timeProvider;
+
+            using (var client = new TestClient(new NewtonsoftJsonHubProtocol()))
+            {
+                var connectionHandlerTask = await client.ConnectAsync(connectionHandler);
+
+                await client.SendHubMessageAsync(PingMessage.Instance);
+
+                timeProvider.Advance(timeout + TimeSpan.FromMilliseconds(1));
+                client.TickHeartbeat();
+
+                await connectionHandlerTask.DefaultTimeout();
+
+                var ex = Assert.IsType<OperationCanceledException>(state.DisconnectedException);
+                Assert.Equal("Client hasn't sent a message/ping within the configured ClientTimeoutInterval.", ex.Message);
+            }
+        }
+    }
+
     [Fact]
     public async Task ReceivingMessagesPreventsConnectionTimeoutFromOccuring()
     {
@@ -4923,6 +4959,58 @@ public partial class HubConnectionHandlerTests : VerifiableLoggedTest
         }
     }
 
+    [Fact]
+    public async Task ConnectionClosesWhenClientSendsCloseMessage()
+    {
+        using (StartVerifiableLog())
+        {
+            var state = new ConnectionLifetimeState();
+            var serviceProvider = HubConnectionHandlerTestUtils.CreateServiceProvider(s => s.AddSingleton(state), LoggerFactory);
+            var connectionHandler = serviceProvider.GetService<HubConnectionHandler<ConnectionLifetimeHub>>();
+
+            using var client = new TestClient();
+
+            var connectionHandlerTask = await client.ConnectAsync(connectionHandler);
+
+            await client.SendHubMessageAsync(new CloseMessage(error: null));
+
+            var message = Assert.IsType<CloseMessage>(await client.ReadAsync().DefaultTimeout());
+            Assert.Null(message.Error);
+
+            await connectionHandlerTask.DefaultTimeout();
+
+            Assert.Null(state.DisconnectedException);
+       }
+    }
+
+    [Fact]
+    public async Task ConnectionClosesWhenClientSendsCloseMessageWithError()
+    {
+        using (StartVerifiableLog())
+        {
+            var state = new ConnectionLifetimeState();
+            var serviceProvider = HubConnectionHandlerTestUtils.CreateServiceProvider(s => s.AddSingleton(state), LoggerFactory);
+            var connectionHandler = serviceProvider.GetService<HubConnectionHandler<ConnectionLifetimeHub>>();
+
+            using var client = new TestClient();
+
+            var connectionHandlerTask = await client.ConnectAsync(connectionHandler);
+
+            var errorMessage = "custom client error";
+            await client.SendHubMessageAsync(new CloseMessage(error: errorMessage));
+
+            var message = Assert.IsType<CloseMessage>(await client.ReadAsync().DefaultTimeout());
+            // Verify no error sent to client
+            Assert.Null(message.Error);
+
+            await connectionHandlerTask.DefaultTimeout();
+
+            // Verify OnDisconnectedAsync was called with the error sent by the client
+            var ex = Assert.IsType<HubException>(state.DisconnectedException);
+            Assert.Equal(errorMessage, ex.Message);
+        }
+    }
+
     [Fact]
     public async Task UnsolicitedSequenceAndAckMessagesDoNothing()
     {

+ 3 - 4
src/SignalR/server/SignalR/test/Startup.cs

@@ -66,10 +66,9 @@ public class Startup
 
         services.AddSingleton<IAuthorizationHandler, TestAuthHandler>();
 
-        // Since tests run in parallel, it's possible multiple servers will startup and read files being written by another test
-        // Use a unique directory per server to avoid this collision
-        services.AddDataProtection()
-            .PersistKeysToFileSystem(Directory.CreateDirectory(Path.GetRandomFileName()));
+        // Since tests run in parallel, it's possible multiple servers will startup,
+        // we use an ephemeral key provider to avoid filesystem contention issues
+        services.AddSingleton<IDataProtectionProvider, EphemeralDataProtectionProvider>();
     }
 
     public void Configure(IApplicationBuilder app)