From 736b7f5042b080958304986088cc21a8c43177ae Mon Sep 17 00:00:00 2001 From: Dylan Dmitri Gray Date: Wed, 23 May 2018 13:53:23 -0700 Subject: [PATCH] Client pings server on interval (#2309) --- .../src/MessagePackHubProtocol.ts | 6 + clients/ts/signalr/src/HubConnection.ts | 49 +++++- .../ts/signalr/tests/HttpConnection.test.ts | 2 +- .../ts/signalr/tests/HubConnection.test.ts | 21 ++- .../tests/HubConnectionBuilder.test.ts | 3 +- .../Internal => Common}/TimerAwaitable.cs | 2 +- .../Internal/HttpConnectionManager.cs | 1 + ...crosoft.AspNetCore.Http.Connections.csproj | 1 + .../HubConnection.Log.cs | 44 ++++- .../HubConnection.cs | 162 ++++++++++++------ ...soft.AspNetCore.SignalR.Client.Core.csproj | 1 + .../HubConnectionTests.Protocol.cs | 29 +++- .../TestConnection.cs | 26 ++- 13 files changed, 276 insertions(+), 71 deletions(-) rename src/{Microsoft.AspNetCore.Http.Connections/Internal => Common}/TimerAwaitable.cs (98%) diff --git a/clients/ts/signalr-protocol-msgpack/src/MessagePackHubProtocol.ts b/clients/ts/signalr-protocol-msgpack/src/MessagePackHubProtocol.ts index a4b2aeb42d..198855f9cc 100644 --- a/clients/ts/signalr-protocol-msgpack/src/MessagePackHubProtocol.ts +++ b/clients/ts/signalr-protocol-msgpack/src/MessagePackHubProtocol.ts @@ -10,6 +10,10 @@ import { BinaryMessageFormat } from "./BinaryMessageFormat"; // TypeDoc's @inheritDoc and @link don't work across modules :( +// constant encoding of the ping message +// see: https://github.com/aspnet/SignalR/blob/dev/specs/HubProtocol.md#ping-message-encoding-1 +const SERIALIZED_PING_MESSAGE: ArrayBuffer = Uint8Array.from([0x91, MessageType.Ping]).buffer; + /** Implements the MessagePack Hub Protocol */ export class MessagePackHubProtocol implements IHubProtocol { /** The name of the protocol. This is used by SignalR to resolve the protocol between the client and server. */ @@ -50,6 +54,8 @@ export class MessagePackHubProtocol implements IHubProtocol { case MessageType.StreamItem: case MessageType.Completion: throw new Error(`Writing messages of type '${message.type}' is not supported.`); + case MessageType.Ping: + return SERIALIZED_PING_MESSAGE; default: throw new Error("Invalid message type."); } diff --git a/clients/ts/signalr/src/HubConnection.ts b/clients/ts/signalr/src/HubConnection.ts index 6bc618c8db..c1c2419850 100644 --- a/clients/ts/signalr/src/HubConnection.ts +++ b/clients/ts/signalr/src/HubConnection.ts @@ -6,9 +6,11 @@ import { IConnection } from "./IConnection"; import { CancelInvocationMessage, CompletionMessage, IHubProtocol, InvocationMessage, MessageType, StreamInvocationMessage, StreamItemMessage } from "./IHubProtocol"; import { ILogger, LogLevel } from "./ILogger"; import { IStreamResult } from "./Stream"; +import { TextMessageFormat } from "./TextMessageFormat"; import { Arg, Subject } from "./Utils"; const DEFAULT_TIMEOUT_IN_MS: number = 30 * 1000; +const DEFAULT_PING_INTERVAL_IN_MS: number = 15 * 1000; /** Describes the current state of the {@link HubConnection} to the server. */ export enum HubConnectionState { @@ -20,6 +22,7 @@ export enum HubConnectionState { /** Represents a connection to a SignalR Hub. */ export class HubConnection { + private readonly cachedPingMessage: string | ArrayBuffer; private readonly connection: IConnection; private readonly logger: ILogger; private protocol: IHubProtocol; @@ -29,6 +32,7 @@ export class HubConnection { private id: number; private closedCallbacks: Array<(error?: Error) => void>; private timeoutHandle: NodeJS.Timer; + private pingServerHandle: NodeJS.Timer; private receivedHandshakeResponse: boolean; private connectionState: HubConnectionState; @@ -39,6 +43,13 @@ export class HubConnection { */ public serverTimeoutInMilliseconds: number; + /** Default interval at which to ping the server. + * + * The default value is 15,000 milliseconds (15 seconds). + * Allows the server to detect hard disconnects (like when a client unplugs their computer). + */ + public pingIntervalInMilliseconds: number; + /** @internal */ // Using a public static factory method means we can have a private constructor and an _internal_ // create method that can be used by HubConnectionBuilder. An "internal" constructor would just @@ -54,6 +65,7 @@ export class HubConnection { Arg.isRequired(protocol, "protocol"); this.serverTimeoutInMilliseconds = DEFAULT_TIMEOUT_IN_MS; + this.pingIntervalInMilliseconds = DEFAULT_PING_INTERVAL_IN_MS; this.logger = logger; this.protocol = protocol; @@ -68,6 +80,8 @@ export class HubConnection { this.closedCallbacks = []; this.id = 0; this.connectionState = HubConnectionState.Disconnected; + + this.cachedPingMessage = this.protocol.writeMessage({ type: MessageType.Ping }); } /** Indicates the state of the {@link HubConnection} to the server. */ @@ -93,13 +107,14 @@ export class HubConnection { this.logger.log(LogLevel.Debug, "Sending handshake request."); - await this.connection.send(this.handshakeProtocol.writeHandshakeRequest(handshakeRequest)); + await this.sendMessage(this.handshakeProtocol.writeHandshakeRequest(handshakeRequest)); this.logger.log(LogLevel.Information, `Using HubProtocol '${this.protocol.name}'.`); // defensively cleanup timeout in case we receive a message from the server before we finish start this.cleanupTimeout(); - this.configureTimeout(); + this.resetTimeoutPeriod(); + this.resetPingInterval(); this.connectionState = HubConnectionState.Connected; } @@ -112,6 +127,7 @@ export class HubConnection { this.logger.log(LogLevel.Debug, "Stopping HubConnection."); this.cleanupTimeout(); + this.cleanupPingTimer(); return this.connection.stop(); } @@ -131,7 +147,7 @@ export class HubConnection { delete this.callbacks[invocationDescriptor.invocationId]; - return this.connection.send(cancelMessage); + return this.sendMessage(cancelMessage); }); this.callbacks[invocationDescriptor.invocationId] = (invocationEvent: CompletionMessage | StreamItemMessage, error?: Error) => { @@ -153,7 +169,7 @@ export class HubConnection { const message = this.protocol.writeMessage(invocationDescriptor); - this.connection.send(message) + this.sendMessage(message) .catch((e) => { subject.error(e); delete this.callbacks[invocationDescriptor.invocationId]; @@ -162,6 +178,11 @@ export class HubConnection { return subject; } + private sendMessage(message: any) { + this.resetPingInterval(); + return this.connection.send(message); + } + /** Invokes a hub method on the server using the specified name and arguments. Does not wait for a response from the receiver. * * The Promise returned by this method resolves when the client has sent the invocation to the server. The server may still @@ -176,7 +197,7 @@ export class HubConnection { const message = this.protocol.writeMessage(invocationDescriptor); - return this.connection.send(message); + return this.sendMessage(message); } /** Invokes a hub method on the server using the specified name and arguments. @@ -213,7 +234,7 @@ export class HubConnection { const message = this.protocol.writeMessage(invocationDescriptor); - this.connection.send(message) + this.sendMessage(message) .catch((e) => { reject(e); delete this.callbacks[invocationDescriptor.invocationId]; @@ -337,7 +358,7 @@ export class HubConnection { } } - this.configureTimeout(); + this.resetTimeoutPeriod(); } private processHandshakeResponse(data: any): any { @@ -365,7 +386,12 @@ export class HubConnection { return remainingData; } - private configureTimeout() { + private resetPingInterval() { + this.cleanupPingTimer(); + this.pingServerHandle = setTimeout(() => this.sendMessage(this.cachedPingMessage), this.pingIntervalInMilliseconds); + } + + private resetTimeoutPeriod() { if (!this.connection.features || !this.connection.features.inherentKeepAlive) { // Set the timeout timer this.timeoutHandle = setTimeout(() => this.serverTimeout(), this.serverTimeoutInMilliseconds); @@ -406,10 +432,17 @@ export class HubConnection { }); this.cleanupTimeout(); + this.cleanupPingTimer(); this.closedCallbacks.forEach((c) => c.apply(this, [error])); } + private cleanupPingTimer(): void { + if (this.pingServerHandle) { + clearTimeout(this.pingServerHandle); + } + } + private cleanupTimeout(): void { if (this.timeoutHandle) { clearTimeout(this.timeoutHandle); diff --git a/clients/ts/signalr/tests/HttpConnection.test.ts b/clients/ts/signalr/tests/HttpConnection.test.ts index f7e00093a9..fe190aab53 100644 --- a/clients/ts/signalr/tests/HttpConnection.test.ts +++ b/clients/ts/signalr/tests/HttpConnection.test.ts @@ -267,7 +267,7 @@ describe("HttpConnection", () => { }); } - it(`cannot be started if server's only transport (${HttpTransportType[requestedTransport]}) is masked out by the transport option`, async() => { + it(`cannot be started if server's only transport (${HttpTransportType[requestedTransport]}) is masked out by the transport option`, async () => { const negotiateResponse = { availableTransports: [ { transport: "WebSockets", transferFormats: [ "Text", "Binary" ] }, diff --git a/clients/ts/signalr/tests/HubConnection.test.ts b/clients/ts/signalr/tests/HubConnection.test.ts index f7b5e0b6a1..36bb18ceb6 100644 --- a/clients/ts/signalr/tests/HubConnection.test.ts +++ b/clients/ts/signalr/tests/HubConnection.test.ts @@ -48,6 +48,25 @@ describe("HubConnection", () => { }); }); + describe("ping", () => { + it("automatically sends multiple pings", async () => { + const connection = new TestConnection(); + const hubConnection = createHubConnection(connection); + + hubConnection.pingIntervalInMilliseconds = 5; + + try { + await hubConnection.start(); + await delay(32); + + const numPings = connection.sentData.filter((s) => JSON.parse(s).type === MessageType.Ping).length; + expect(numPings).toBeGreaterThanOrEqual(2); + } finally { + await hubConnection.stop(); + } + }); + }); + describe("stop", () => { it("state disconnected", async () => { const connection = new TestConnection(); @@ -870,7 +889,7 @@ describe("HubConnection", () => { hubConnection.onclose((e) => state = hubConnection.state); // Typically this would be called by the transport connection.onclose(); - + expect(state).toBe(HubConnectionState.Disconnected); } finally { hubConnection.stop(); diff --git a/clients/ts/signalr/tests/HubConnectionBuilder.test.ts b/clients/ts/signalr/tests/HubConnectionBuilder.test.ts index e5f0b8be9c..5ecb43618e 100644 --- a/clients/ts/signalr/tests/HubConnectionBuilder.test.ts +++ b/clients/ts/signalr/tests/HubConnectionBuilder.test.ts @@ -201,7 +201,8 @@ class TestProtocol implements IHubProtocol { throw new Error("Method not implemented."); } public writeMessage(message: HubMessage): string | ArrayBuffer { - throw new Error("Method not implemented."); + // builds ping message in the `hubConnection` constructor + return ""; } } diff --git a/src/Microsoft.AspNetCore.Http.Connections/Internal/TimerAwaitable.cs b/src/Common/TimerAwaitable.cs similarity index 98% rename from src/Microsoft.AspNetCore.Http.Connections/Internal/TimerAwaitable.cs rename to src/Common/TimerAwaitable.cs index bb3e826b07..d2e0005bcf 100644 --- a/src/Microsoft.AspNetCore.Http.Connections/Internal/TimerAwaitable.cs +++ b/src/Common/TimerAwaitable.cs @@ -6,7 +6,7 @@ using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; -namespace Microsoft.AspNetCore.Http.Connections.Internal +namespace Microsoft.AspNetCore.Internal { internal class TimerAwaitable : IDisposable, ICriticalNotifyCompletion { diff --git a/src/Microsoft.AspNetCore.Http.Connections/Internal/HttpConnectionManager.cs b/src/Microsoft.AspNetCore.Http.Connections/Internal/HttpConnectionManager.cs index 43e5982748..e1851a1a90 100644 --- a/src/Microsoft.AspNetCore.Http.Connections/Internal/HttpConnectionManager.cs +++ b/src/Microsoft.AspNetCore.Http.Connections/Internal/HttpConnectionManager.cs @@ -13,6 +13,7 @@ using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Internal; using Microsoft.Extensions.Internal; using Microsoft.Extensions.Logging; diff --git a/src/Microsoft.AspNetCore.Http.Connections/Microsoft.AspNetCore.Http.Connections.csproj b/src/Microsoft.AspNetCore.Http.Connections/Microsoft.AspNetCore.Http.Connections.csproj index 5b3cb0660d..44cd96c75a 100644 --- a/src/Microsoft.AspNetCore.Http.Connections/Microsoft.AspNetCore.Http.Connections.csproj +++ b/src/Microsoft.AspNetCore.Http.Connections/Microsoft.AspNetCore.Http.Connections.csproj @@ -9,6 +9,7 @@ + diff --git a/src/Microsoft.AspNetCore.SignalR.Client.Core/HubConnection.Log.cs b/src/Microsoft.AspNetCore.SignalR.Client.Core/HubConnection.Log.cs index d2791f4d3d..ed713cf001 100644 --- a/src/Microsoft.AspNetCore.SignalR.Client.Core/HubConnection.Log.cs +++ b/src/Microsoft.AspNetCore.SignalR.Client.Core/HubConnection.Log.cs @@ -174,6 +174,18 @@ namespace Microsoft.AspNetCore.SignalR.Client private static readonly Action _removingHandlers = LoggerMessage.Define(LogLevel.Debug, new EventId(58, "RemovingHandlers"), "Removing handlers for client method '{MethodName}'."); + private static readonly Action _sendingMessageGeneric = + LoggerMessage.Define(LogLevel.Debug, new EventId(59, "SendingMessageGeneric"), "Sending {MessageType} message."); + + private static readonly Action _messageSentGeneric = + LoggerMessage.Define(LogLevel.Debug, new EventId(60, "MessageSentGeneric"), "Sending {MessageType} message completed."); + + private static readonly Action _acquiredConnectionLockForPing = + LoggerMessage.Define(LogLevel.Trace, new EventId(61, "AcquiredConnectionLockForPing"), "Acquired the Connection Lock in order to ping the server."); + + private static readonly Action _unableToAcquireConnectionLockForPing = + LoggerMessage.Define(LogLevel.Trace, new EventId(62, "UnableToAcquireConnectionLockForPing"), "Skipping ping because a send is already in progress."); + public static void PreparingNonBlockingInvocation(ILogger logger, string target, int count) { _preparingNonBlockingInvocation(logger, target, count, null); @@ -203,19 +215,33 @@ namespace Microsoft.AspNetCore.SignalR.Client } } - public static void SendingMessage(ILogger logger, HubInvocationMessage message) + public static void SendingMessage(ILogger logger, HubMessage message) { if (logger.IsEnabled(LogLevel.Debug)) { - _sendingMessage(logger, message.GetType().Name, message.InvocationId, null); + if (message is HubInvocationMessage invocationMessage) + { + _sendingMessage(logger, message.GetType().Name, invocationMessage.InvocationId, null); + } + else + { + _sendingMessageGeneric(logger, message.GetType().Name, null); + } } } - public static void MessageSent(ILogger logger, HubInvocationMessage message) + public static void MessageSent(ILogger logger, HubMessage message) { if (logger.IsEnabled(LogLevel.Debug)) { - _messageSent(logger, message.GetType().Name, message.InvocationId, null); + if (message is HubInvocationMessage invocationMessage) + { + _messageSent(logger, message.GetType().Name, invocationMessage.InvocationId, null); + } + else + { + _messageSentGeneric(logger, message.GetType().Name, null); + } } } @@ -460,6 +486,16 @@ namespace Microsoft.AspNetCore.SignalR.Client { _argumentBindingFailure(logger, invocationId, target, exception); } + + public static void AcquiredConnectionLockForPing(ILogger logger) + { + _acquiredConnectionLockForPing(logger, null); + } + + public static void UnableToAcquireConnectionLockForPing(ILogger logger) + { + _unableToAcquireConnectionLockForPing(logger, null); + } } } } diff --git a/src/Microsoft.AspNetCore.SignalR.Client.Core/HubConnection.cs b/src/Microsoft.AspNetCore.SignalR.Client.Core/HubConnection.cs index 568bbc35c4..fb371fcb25 100644 --- a/src/Microsoft.AspNetCore.SignalR.Client.Core/HubConnection.cs +++ b/src/Microsoft.AspNetCore.SignalR.Client.Core/HubConnection.cs @@ -33,6 +33,8 @@ namespace Microsoft.AspNetCore.SignalR.Client { public static readonly TimeSpan DefaultServerTimeout = TimeSpan.FromSeconds(30); // Server ping rate is 15 sec, this is 2 times that. public static readonly TimeSpan DefaultHandshakeTimeout = TimeSpan.FromSeconds(15); + public static readonly TimeSpan DefaultPingInterval = TimeSpan.FromSeconds(15); + public static readonly TimeSpan DefaultTickRate = TimeSpan.FromSeconds(1); // This lock protects the connection state. private readonly SemaphoreSlim _connectionLock = new SemaphoreSlim(1, 1); @@ -44,6 +46,8 @@ namespace Microsoft.AspNetCore.SignalR.Client private readonly IServiceProvider _serviceProvider; private readonly IConnectionFactory _connectionFactory; private readonly ConcurrentDictionary _handlers = new ConcurrentDictionary(StringComparer.Ordinal); + private long _nextActivationServerTimeout; + private long _nextActivationSendPing; private bool _disposed; // Transient state to a connection @@ -51,11 +55,28 @@ namespace Microsoft.AspNetCore.SignalR.Client public event Func Closed; + // internal for testing purposes + internal TimeSpan TickRate { get; set; } = DefaultTickRate; + /// - /// Gets or sets the server timeout interval for the connection. Changes to this value - /// will not be applied until the Keep Alive timer is next reset. + /// Gets or sets the server timeout interval for the connection. /// + /// + /// The client times out if it hasn't heard from the server for `this` long. + /// public TimeSpan ServerTimeout { get; set; } = DefaultServerTimeout; + + /// + /// Gets or sets the interval at which the client sends ping messages. + /// + /// + /// Sending any message resets the timer to the start of the interval. + /// + public TimeSpan PingInterval { get; set; } = DefaultPingInterval; + + /// + /// Gets or sets the timeout for the initial handshake. + /// public TimeSpan HandshakeTimeout { get; set; } = DefaultHandshakeTimeout; /// @@ -163,7 +184,7 @@ namespace Microsoft.AspNetCore.SignalR.Client // It's OK to be disposed while registering a callback, we'll just never call the callback anyway (as with all the callbacks registered before disposal). var invocationHandler = new InvocationHandler(parameterTypes, handler, state); - var invocationList = _handlers.AddOrUpdate(methodName, _ => new InvocationHandlerList(invocationHandler) , + var invocationList = _handlers.AddOrUpdate(methodName, _ => new InvocationHandlerList(invocationHandler), (_, invocations) => { lock (invocations) @@ -175,6 +196,7 @@ namespace Microsoft.AspNetCore.SignalR.Client return new Subscription(invocationHandler, invocationList); } + /// /// Removes all handlers associated with the method with the specified method name. /// @@ -274,6 +296,7 @@ namespace Microsoft.AspNetCore.SignalR.Client // Set this at the end to avoid setting internal state until the connection is real _connectionState = startingConnectionState; _connectionState.ReceiveTask = ReceiveLoop(_connectionState); + Log.Started(_logger); } finally @@ -465,7 +488,7 @@ namespace Microsoft.AspNetCore.SignalR.Client } } - private async Task SendHubMessage(HubInvocationMessage hubMessage, CancellationToken cancellationToken = default) + private async Task SendHubMessage(HubMessage hubMessage, CancellationToken cancellationToken = default) { AssertConnectionValid(); @@ -476,6 +499,9 @@ namespace Microsoft.AspNetCore.SignalR.Client // REVIEW: If a token is passed in and is canceled during FlushAsync it seems to break .Complete()... await _connectionState.Connection.Transport.Output.FlushAsync(); + // We've sent a message, so don't ping for a while + ResetSendPing(); + Log.MessageSent(_logger, hubMessage); } @@ -703,7 +729,10 @@ namespace Microsoft.AspNetCore.SignalR.Client Log.ReceiveLoopStarting(_logger); - var timeoutTimer = StartTimeoutTimer(connectionState); + // Performs periodic tasks -- here sending pings and checking timeout + // Disposed with `timer.Stop()` in the finally block below + var timer = new TimerAwaitable(TickRate, TickRate); + _ = TimerLoop(timer); try { @@ -721,7 +750,8 @@ namespace Microsoft.AspNetCore.SignalR.Client } else if (!buffer.IsEmpty) { - ResetTimeoutTimer(timeoutTimer); + Log.ResettingKeepAliveTimer(_logger); + ResetTimeout(); Log.ProcessingMessage(_logger, buffer.Length); @@ -771,6 +801,10 @@ namespace Microsoft.AspNetCore.SignalR.Client Log.ServerDisconnectedWithError(_logger, ex); connectionState.CloseException = ex; } + finally + { + timer.Stop(); + } // Clear the connectionState field await WaitConnectionLockAsync(); @@ -785,9 +819,6 @@ namespace Microsoft.AspNetCore.SignalR.Client ReleaseConnectionLock(); } - // Stop the timeout timer. - timeoutTimer?.Dispose(); - // Dispose the connection await CloseAsync(connectionState.Connection); @@ -814,6 +845,77 @@ namespace Microsoft.AspNetCore.SignalR.Client } } + public void ResetSendPing() + { + Volatile.Write(ref _nextActivationSendPing, (DateTime.UtcNow + PingInterval).Ticks); + } + + public void ResetTimeout() + { + Volatile.Write(ref _nextActivationServerTimeout, (DateTime.UtcNow + ServerTimeout).Ticks); + } + + private async Task TimerLoop(TimerAwaitable timer) + { + // initialize the timers + timer.Start(); + ResetSendPing(); + ResetTimeout(); + + using (timer) + { + // await returns True until `timer.Stop()` is called in the `finally` block of `ReceiveLoop` + while (await timer) + { + if (DateTime.UtcNow.Ticks > Volatile.Read(ref _nextActivationServerTimeout)) + { + OnServerTimeout(); + } + + if (DateTime.UtcNow.Ticks > Volatile.Read(ref _nextActivationSendPing)) + { + await PingServer(); + } + } + } + } + + private void OnServerTimeout() + { + if (Debugger.IsAttached) + { + return; + } + + _connectionState.CloseException = new TimeoutException( + $"Server timeout ({ServerTimeout.TotalMilliseconds:0.00}ms) elapsed without receiving a message from the server."); + _connectionState.Connection.Transport.Input.CancelPendingRead(); + } + + private async Task PingServer() + { + if (_disposed || !_connectionLock.Wait(0)) + { + Log.UnableToAcquireConnectionLockForPing(_logger); + return; + } + + Log.AcquiredConnectionLockForPing(_logger); + + try + { + if (_disposed || _connectionState == null || _connectionState.Stopping) + { + return; + } + await SendHubMessage(PingMessage.Instance); + } + finally + { + ReleaseConnectionLock(); + } + } + private async Task RunClosedEvent(Func closed, Exception closeException) { // Dispatch to the thread pool before we invoke the user callback @@ -830,48 +932,6 @@ namespace Microsoft.AspNetCore.SignalR.Client } } - private void ResetTimeoutTimer(Timer timeoutTimer) - { - if (timeoutTimer != null) - { - Log.ResettingKeepAliveTimer(_logger); - timeoutTimer.Change(ServerTimeout, Timeout.InfiniteTimeSpan); - } - } - - private Timer StartTimeoutTimer(ConnectionState connectionState) - { - // Check if we need keep-alive - Timer timeoutTimer = null; - - // We use '!== true' because it could be null, which we treat as false. - if (connectionState.Connection.Features.Get()?.HasInherentKeepAlive != true) - { - Log.StartingServerTimeoutTimer(_logger, ServerTimeout); - timeoutTimer = new Timer( - state => OnTimeout((ConnectionState)state), - connectionState, - dueTime: ServerTimeout, - period: Timeout.InfiniteTimeSpan); - } - else - { - Log.NotUsingServerTimeout(_logger); - } - - return timeoutTimer; - } - - private void OnTimeout(ConnectionState connectionState) - { - if (!Debugger.IsAttached) - { - connectionState.CloseException = new TimeoutException( - $"Server timeout ({ServerTimeout.TotalMilliseconds:0.00}ms) elapsed without receiving a message from the server."); - connectionState.Connection.Transport.Input.CancelPendingRead(); - } - } - private void CheckConnectionActive(string methodName) { if (_connectionState == null || _connectionState.Stopping) diff --git a/src/Microsoft.AspNetCore.SignalR.Client.Core/Microsoft.AspNetCore.SignalR.Client.Core.csproj b/src/Microsoft.AspNetCore.SignalR.Client.Core/Microsoft.AspNetCore.SignalR.Client.Core.csproj index 1d91bf64cc..8cbe6845a4 100644 --- a/src/Microsoft.AspNetCore.SignalR.Client.Core/Microsoft.AspNetCore.SignalR.Client.Core.csproj +++ b/src/Microsoft.AspNetCore.SignalR.Client.Core/Microsoft.AspNetCore.SignalR.Client.Core.csproj @@ -10,6 +10,7 @@ + diff --git a/test/Microsoft.AspNetCore.SignalR.Client.Tests/HubConnectionTests.Protocol.cs b/test/Microsoft.AspNetCore.SignalR.Client.Tests/HubConnectionTests.Protocol.cs index 99ec2eba60..3a4d78ac45 100644 --- a/test/Microsoft.AspNetCore.SignalR.Client.Tests/HubConnectionTests.Protocol.cs +++ b/test/Microsoft.AspNetCore.SignalR.Client.Tests/HubConnectionTests.Protocol.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections.Generic; using System.Threading.Channels; using System.Threading.Tasks; using Xunit; @@ -532,7 +533,7 @@ namespace Microsoft.AspNetCore.SignalR.Client.Tests [Fact] public async Task PartialInvocationWorks() - { + { var connection = new TestConnection(); var hubConnection = CreateHubConnection(connection); try @@ -565,6 +566,32 @@ namespace Microsoft.AspNetCore.SignalR.Client.Tests await connection.DisposeAsync().OrTimeout(); } } + + [Fact] + public async Task ClientPingsMultipleTimes() + { + var connection = new TestConnection(); + var hubConnection = CreateHubConnection(connection); + + hubConnection.TickRate = TimeSpan.FromMilliseconds(30); + hubConnection.PingInterval = TimeSpan.FromMilliseconds(80); + + try + { + await hubConnection.StartAsync().OrTimeout(); + + var firstPing = await connection.ReadSentTextMessageAsync().OrTimeout(TimeSpan.FromMilliseconds(200)); + Assert.Equal("{\"type\":6}", firstPing); + + var secondPing = await connection.ReadSentTextMessageAsync().OrTimeout(TimeSpan.FromMilliseconds(200)); + Assert.Equal("{\"type\":6}", secondPing); + } + finally + { + await hubConnection.DisposeAsync().OrTimeout(); + await connection.DisposeAsync().OrTimeout(); + } + } } } } diff --git a/test/Microsoft.AspNetCore.SignalR.Client.Tests/TestConnection.cs b/test/Microsoft.AspNetCore.SignalR.Client.Tests/TestConnection.cs index 2cec05e1db..c927a3d42a 100644 --- a/test/Microsoft.AspNetCore.SignalR.Client.Tests/TestConnection.cs +++ b/test/Microsoft.AspNetCore.SignalR.Client.Tests/TestConnection.cs @@ -24,7 +24,6 @@ namespace Microsoft.AspNetCore.SignalR.Client.Tests private readonly TaskCompletionSource _disposed = new TaskCompletionSource(); private int _disposeCount = 0; - public Task Started => _started.Task; public Task Disposed => _disposed.Task; @@ -117,7 +116,6 @@ namespace Microsoft.AspNetCore.SignalR.Client.Tests { return Application.Output.WriteAsync(bytes).AsTask(); } - public async Task ReadSentTextMessageAsync() { // Read a single text message from the Application Input pipe @@ -136,7 +134,7 @@ namespace Microsoft.AspNetCore.SignalR.Client.Tests } else if (result.IsCompleted) { - throw new InvalidOperationException("Out of data!"); + return null; } } finally @@ -146,6 +144,28 @@ namespace Microsoft.AspNetCore.SignalR.Client.Tests } } + public async Task> ReadAllSentMessagesAsync() + { + if (!Disposed.IsCompleted) + { + throw new InvalidOperationException("The connection must be stopped before this method can be used."); + } + + var results = new List(); + + while (true) + { + var message = await ReadSentTextMessageAsync(); + if (message == null) + { + break; + } + results.Add(message); + } + + return results; + } + public void CompleteFromTransport(Exception ex = null) { Application.Output.Complete(ex);