server closes connections that have gone silent (#2364)

This commit is contained in:
Dylan Dmitri Gray 2018-06-05 15:21:55 -07:00 committed by GitHub
parent 948ebf34ec
commit 3d0f68b22c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 188 additions and 29 deletions

View File

@ -884,11 +884,16 @@ namespace Microsoft.AspNetCore.SignalR.Client
private async Task TimerLoop(TimerAwaitable timer) private async Task TimerLoop(TimerAwaitable timer)
{ {
// Tell the server we intend to ping
// Old clients never ping, and shouldn't be timed out
// So ping to tell the server that we should be timed out if we stop
await SendHubMessage(PingMessage.Instance);
// initialize the timers // initialize the timers
timer.Start(); timer.Start();
ResetSendPing();
ResetTimeout(); ResetTimeout();
ResetSendPing();
using (timer) using (timer)
{ {
// await returns True until `timer.Stop()` is called in the `finally` block of `ReceiveLoop` // await returns True until `timer.Stop()` is called in the `finally` block of `ReceiveLoop`

View File

@ -28,26 +28,41 @@ namespace Microsoft.AspNetCore.SignalR
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly CancellationTokenSource _connectionAbortedTokenSource = new CancellationTokenSource(); private readonly CancellationTokenSource _connectionAbortedTokenSource = new CancellationTokenSource();
private readonly TaskCompletionSource<object> _abortCompletedTcs = new TaskCompletionSource<object>(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource<object> _abortCompletedTcs = new TaskCompletionSource<object>(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly long _keepAliveDuration; private readonly long _keepAliveInterval;
private readonly long _clientTimeoutInterval;
private readonly SemaphoreSlim _writeLock = new SemaphoreSlim(1); private readonly SemaphoreSlim _writeLock = new SemaphoreSlim(1);
private long _lastSendTimestamp = Stopwatch.GetTimestamp(); private long _lastSendTimeStamp = DateTime.UtcNow.Ticks;
private long _lastReceivedTimeStamp = DateTime.UtcNow.Ticks;
private bool _receivedMessageThisInterval = false;
private ReadOnlyMemory<byte> _cachedPingMessage; private ReadOnlyMemory<byte> _cachedPingMessage;
private bool _clientTimeoutActive;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="HubConnectionContext"/> class. /// Initializes a new instance of the <see cref="HubConnectionContext"/> class.
/// </summary> /// </summary>
/// <param name="connectionContext">The underlying <see cref="ConnectionContext"/>.</param> /// <param name="connectionContext">The underlying <see cref="ConnectionContext"/>.</param>
/// <param name="keepAliveInterval">The keep alive interval.</param> /// <param name="keepAliveInterval">The keep alive interval. If no messages are sent by the server in this interval, a Ping message will be sent.</param>
/// <param name="loggerFactory">The logger factory.</param> /// <param name="loggerFactory">The logger factory.</param>
public HubConnectionContext(ConnectionContext connectionContext, TimeSpan keepAliveInterval, ILoggerFactory loggerFactory) /// <param name="clientTimeoutInterval">Clients we haven't heard from in this interval are assumed to have disconnected.</param>
public HubConnectionContext(ConnectionContext connectionContext, TimeSpan keepAliveInterval, ILoggerFactory loggerFactory, TimeSpan clientTimeoutInterval)
{ {
_connectionContext = connectionContext; _connectionContext = connectionContext;
_logger = loggerFactory.CreateLogger<HubConnectionContext>(); _logger = loggerFactory.CreateLogger<HubConnectionContext>();
ConnectionAborted = _connectionAbortedTokenSource.Token; ConnectionAborted = _connectionAbortedTokenSource.Token;
_keepAliveDuration = (int)keepAliveInterval.TotalMilliseconds * (Stopwatch.Frequency / 1000); _keepAliveInterval = keepAliveInterval.Ticks;
_clientTimeoutInterval = clientTimeoutInterval.Ticks;
} }
/// <summary>
/// Initializes a new instance of the <see cref="HubConnectionContext"/> class.
/// </summary>
/// <param name="connectionContext">The underlying <see cref="ConnectionContext"/>.</param>
/// <param name="keepAliveInterval">The keep alive interval. If no messages are sent by the server in this interval, a Ping message will be sent.</param>
/// <param name="loggerFactory">The logger factory.</param>
public HubConnectionContext(ConnectionContext connectionContext, TimeSpan keepAliveInterval, ILoggerFactory loggerFactory)
: this(connectionContext, keepAliveInterval, loggerFactory, HubOptionsSetup.DefaultClientTimeoutInterval) { }
/// <summary> /// <summary>
/// Gets a <see cref="CancellationToken"/> that notifies when the connection is aborted. /// Gets a <see cref="CancellationToken"/> that notifies when the connection is aborted.
/// </summary> /// </summary>
@ -428,13 +443,15 @@ namespace Microsoft.AspNetCore.SignalR
private void KeepAliveTick() private void KeepAliveTick()
{ {
var timestamp = Stopwatch.GetTimestamp(); var currentTime = DateTime.UtcNow.Ticks;
// Implements the keep-alive tick behavior // Implements the keep-alive tick behavior
// Each tick, we check if the time since the last send is larger than the keep alive duration (in ticks). // Each tick, we check if the time since the last send is larger than the keep alive duration (in ticks).
// If it is, we send a ping frame, if not, we no-op on this tick. This means that in the worst case, the // If it is, we send a ping frame, if not, we no-op on this tick. This means that in the worst case, the
// true "ping rate" of the server could be (_hubOptions.KeepAliveInterval + HubEndPoint.KeepAliveTimerInterval), // true "ping rate" of the server could be (_hubOptions.KeepAliveInterval + HubEndPoint.KeepAliveTimerInterval),
// because if the interval elapses right after the last tick of this timer, it won't be detected until the next tick. // because if the interval elapses right after the last tick of this timer, it won't be detected until the next tick.
if (timestamp - Interlocked.Read(ref _lastSendTimestamp) > _keepAliveDuration)
if (currentTime - Volatile.Read(ref _lastSendTimeStamp) > _keepAliveInterval)
{ {
// Haven't sent a message for the entire keep-alive duration, so send a ping. // Haven't sent a message for the entire keep-alive duration, so send a ping.
// If the transport channel is full, this will fail, but that's OK because // If the transport channel is full, this will fail, but that's OK because
@ -442,10 +459,37 @@ namespace Microsoft.AspNetCore.SignalR
// transport is still in the process of sending frames. // transport is still in the process of sending frames.
_ = TryWritePingAsync(); _ = TryWritePingAsync();
Interlocked.Exchange(ref _lastSendTimestamp, timestamp); // We only update the timestamp here, because updating on each sent message is bad for performance
// There can be a lot of sent messages per 15 seconds
Volatile.Write(ref _lastSendTimeStamp, currentTime);
} }
} }
internal void StartClientTimeout()
{
if (_clientTimeoutActive)
{
return;
}
_clientTimeoutActive = true;
Features.Get<IConnectionHeartbeatFeature>()?.OnHeartbeat(state => ((HubConnectionContext)state).CheckClientTimeout(), this);
}
private void CheckClientTimeout()
{
// If it's been too long since we've heard from the client, then close this
if (DateTime.UtcNow.Ticks - Volatile.Read(ref _lastReceivedTimeStamp) > _clientTimeoutInterval)
{
if (!_receivedMessageThisInterval)
{
Abort();
}
_receivedMessageThisInterval = false;
Volatile.Write(ref _lastReceivedTimeStamp, DateTime.UtcNow.Ticks);
}
}
private static void AbortConnection(object state) private static void AbortConnection(object state)
{ {
var connection = (HubConnectionContext)state; var connection = (HubConnectionContext)state;
@ -464,6 +508,11 @@ namespace Microsoft.AspNetCore.SignalR
} }
} }
internal void ResetClientTimeout()
{
_receivedMessageThisInterval = true;
}
private static class Log private static class Log
{ {
// Category: HubConnectionContext // Category: HubConnectionContext

View File

@ -69,6 +69,7 @@ namespace Microsoft.AspNetCore.SignalR
// We check to see if HubOptions<THub> are set because those take precedence over global hub options. // We check to see if HubOptions<THub> are set because those take precedence over global hub options.
// Then set the keepAlive and handshakeTimeout values to the defaults in HubOptionsSetup incase they were explicitly set to null. // Then set the keepAlive and handshakeTimeout values to the defaults in HubOptionsSetup incase they were explicitly set to null.
var keepAlive = _hubOptions.KeepAliveInterval ?? _globalHubOptions.KeepAliveInterval ?? HubOptionsSetup.DefaultKeepAliveInterval; var keepAlive = _hubOptions.KeepAliveInterval ?? _globalHubOptions.KeepAliveInterval ?? HubOptionsSetup.DefaultKeepAliveInterval;
var clientTimeout = _hubOptions.ClientTimeoutInterval ?? _globalHubOptions.ClientTimeoutInterval ?? HubOptionsSetup.DefaultClientTimeoutInterval;
var handshakeTimeout = _hubOptions.HandshakeTimeout ?? _globalHubOptions.HandshakeTimeout ?? HubOptionsSetup.DefaultHandshakeTimeout; var handshakeTimeout = _hubOptions.HandshakeTimeout ?? _globalHubOptions.HandshakeTimeout ?? HubOptionsSetup.DefaultHandshakeTimeout;
var supportedProtocols = _hubOptions.SupportedProtocols ?? _globalHubOptions.SupportedProtocols; var supportedProtocols = _hubOptions.SupportedProtocols ?? _globalHubOptions.SupportedProtocols;
@ -79,7 +80,7 @@ namespace Microsoft.AspNetCore.SignalR
Log.ConnectedStarting(_logger); Log.ConnectedStarting(_logger);
var connectionContext = new HubConnectionContext(connection, keepAlive, _loggerFactory); var connectionContext = new HubConnectionContext(connection, keepAlive, _loggerFactory, clientTimeout);
var resolvedSupportedProtocols = (supportedProtocols as IReadOnlyList<string>) ?? supportedProtocols.ToList(); var resolvedSupportedProtocols = (supportedProtocols as IReadOnlyList<string>) ?? supportedProtocols.ToList();
if (!await connectionContext.HandshakeAsync(handshakeTimeout, resolvedSupportedProtocols, _protocolResolver, _userIdProvider, _enableDetailedErrors)) if (!await connectionContext.HandshakeAsync(handshakeTimeout, resolvedSupportedProtocols, _protocolResolver, _userIdProvider, _enableDetailedErrors))
@ -87,6 +88,8 @@ namespace Microsoft.AspNetCore.SignalR
return; return;
} }
// -- the connectionContext has been set up --
try try
{ {
await _lifetimeManager.OnConnectedAsync(connectionContext); await _lifetimeManager.OnConnectedAsync(connectionContext);
@ -197,6 +200,8 @@ namespace Microsoft.AspNetCore.SignalR
if (!buffer.IsEmpty) if (!buffer.IsEmpty)
{ {
connection.ResetClientTimeout();
while (protocol.TryParseMessage(ref buffer, _dispatcher, out var message)) while (protocol.TryParseMessage(ref buffer, _dispatcher, out var message))
{ {
await _dispatcher.DispatchMessageAsync(connection, message); await _dispatcher.DispatchMessageAsync(connection, message);

View File

@ -26,6 +26,11 @@ namespace Microsoft.AspNetCore.SignalR
/// </summary> /// </summary>
public TimeSpan? KeepAliveInterval { get; set; } = null; public TimeSpan? KeepAliveInterval { get; set; } = null;
/// <summary>
/// Gets or sets the time window clients have to send a message before the server closes the connection.
/// </summary>
public TimeSpan? ClientTimeoutInterval { get; set; } = null;
/// <summary> /// <summary>
/// Gets or sets a collection of supported hub protocol names. /// Gets or sets a collection of supported hub protocol names.
/// </summary> /// </summary>

View File

@ -7,5 +7,7 @@ namespace Microsoft.AspNetCore.SignalR
/// Options used to configure the specified hub type instances. These options override globally set options. /// Options used to configure the specified hub type instances. These options override globally set options.
/// </summary> /// </summary>
/// <typeparam name="THub">The hub type to configure.</typeparam> /// <typeparam name="THub">The hub type to configure.</typeparam>
public class HubOptions<THub> : HubOptions where THub : Hub { } public class HubOptions<THub> : HubOptions where THub : Hub
{
}
} }

View File

@ -107,7 +107,7 @@ namespace Microsoft.AspNetCore.SignalR.Internal
break; break;
case PingMessage _: case PingMessage _:
// We don't care about pings connection.StartClientTimeout();
break; break;
// Other kind of message we weren't expecting // Other kind of message we weren't expecting

View File

@ -14,26 +14,23 @@ namespace Microsoft.AspNetCore.SignalR.Internal
internal static TimeSpan DefaultKeepAliveInterval => TimeSpan.FromSeconds(15); internal static TimeSpan DefaultKeepAliveInterval => TimeSpan.FromSeconds(15);
private readonly List<string> _protocols = new List<string>(); internal static TimeSpan DefaultClientTimeoutInterval => TimeSpan.FromSeconds(30);
private readonly List<string> _defaultProtocols = new List<string>();
public HubOptionsSetup(IEnumerable<IHubProtocol> protocols) public HubOptionsSetup(IEnumerable<IHubProtocol> protocols)
{ {
foreach (var hubProtocol in protocols) foreach (var hubProtocol in protocols)
{ {
_protocols.Add(hubProtocol.Name); _defaultProtocols.Add(hubProtocol.Name);
} }
} }
public void Configure(HubOptions options) public void Configure(HubOptions options)
{ {
if (options.SupportedProtocols == null)
{
options.SupportedProtocols = new List<string>();
}
if (options.KeepAliveInterval == null) if (options.KeepAliveInterval == null)
{ {
// The default keep - alive interval.This is set to exactly half of the default client timeout window, // The default keep - alive interval. This is set to exactly half of the default client timeout window,
// to ensure a ping can arrive in time to satisfy the client timeout. // to ensure a ping can arrive in time to satisfy the client timeout.
options.KeepAliveInterval = DefaultKeepAliveInterval; options.KeepAliveInterval = DefaultKeepAliveInterval;
} }
@ -43,7 +40,12 @@ namespace Microsoft.AspNetCore.SignalR.Internal
options.HandshakeTimeout = DefaultHandshakeTimeout; options.HandshakeTimeout = DefaultHandshakeTimeout;
} }
foreach (var protocol in _protocols) if (options.SupportedProtocols == null)
{
options.SupportedProtocols = new List<string>();
}
foreach (var protocol in _defaultProtocols)
{ {
options.SupportedProtocols.Add(protocol); options.SupportedProtocols.Add(protocol);
} }

View File

@ -580,10 +580,10 @@ namespace Microsoft.AspNetCore.SignalR.Client.Tests
{ {
await hubConnection.StartAsync().OrTimeout(); await hubConnection.StartAsync().OrTimeout();
var firstPing = await connection.ReadSentTextMessageAsync().OrTimeout(); var firstPing = await connection.ReadSentTextMessageAsync(ignorePings: false).OrTimeout();
Assert.Equal("{\"type\":6}", firstPing); Assert.Equal("{\"type\":6}", firstPing);
var secondPing = await connection.ReadSentTextMessageAsync().OrTimeout(); var secondPing = await connection.ReadSentTextMessageAsync(ignorePings: false).OrTimeout();
Assert.Equal("{\"type\":6}", secondPing); Assert.Equal("{\"type\":6}", secondPing);
} }
finally finally

View File

@ -14,6 +14,7 @@ using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Internal; using Microsoft.AspNetCore.Internal;
using Microsoft.AspNetCore.SignalR.Protocol; using Microsoft.AspNetCore.SignalR.Protocol;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Microsoft.AspNetCore.SignalR.Client.Tests namespace Microsoft.AspNetCore.SignalR.Client.Tests
{ {
@ -55,7 +56,7 @@ namespace Microsoft.AspNetCore.SignalR.Client.Tests
Application.Input.OnWriterCompleted((ex, _) => Application.Input.OnWriterCompleted((ex, _) =>
{ {
Application.Output.Complete(); Application.Output.Complete();
}, },
null); null);
} }
@ -116,9 +117,27 @@ namespace Microsoft.AspNetCore.SignalR.Client.Tests
{ {
return Application.Output.WriteAsync(bytes).AsTask(); return Application.Output.WriteAsync(bytes).AsTask();
} }
public async Task<string> ReadSentTextMessageAsync()
public async Task<string> ReadSentTextMessageAsync(bool ignorePings = true)
{ {
// Read a single text message from the Application Input pipe // Read a single text message from the Application Input pipe
while (true)
{
var result = await ReadSentTextMessageAsyncInner();
var receivedMessageType = (int?)JObject.Parse(result)["type"];
if (ignorePings && receivedMessageType == HubProtocolConstants.PingMessageType)
{
continue;
}
return result;
}
}
private async Task<string> ReadSentTextMessageAsyncInner()
{
while (true) while (true)
{ {
var result = await Application.Input.ReadAsync(); var result = await Application.Input.ReadAsync();
@ -144,7 +163,7 @@ namespace Microsoft.AspNetCore.SignalR.Client.Tests
} }
} }
public async Task<IList<string>> ReadAllSentMessagesAsync() public async Task<IList<string>> ReadAllSentMessagesAsync(bool ignorePings = true)
{ {
if (!Disposed.IsCompleted) if (!Disposed.IsCompleted)
{ {
@ -155,7 +174,7 @@ namespace Microsoft.AspNetCore.SignalR.Client.Tests
while (true) while (true)
{ {
var message = await ReadSentTextMessageAsync(); var message = await ReadSentTextMessageAsync(ignorePings);
if (message == null) if (message == null)
{ {
break; break;

View File

@ -22,7 +22,7 @@ namespace Microsoft.AspNetCore.SignalR.Tests
public static Mock<HubConnectionContext> CreateMock(ConnectionContext connection) public static Mock<HubConnectionContext> CreateMock(ConnectionContext connection)
{ {
var mock = new Mock<HubConnectionContext>(connection, TimeSpan.FromSeconds(15), NullLoggerFactory.Instance) { CallBase = true }; var mock = new Mock<HubConnectionContext>(connection, TimeSpan.FromSeconds(15), NullLoggerFactory.Instance, TimeSpan.FromSeconds(15)) { CallBase = true };
var protocol = new JsonHubProtocol(); var protocol = new JsonHubProtocol();
mock.SetupGet(m => m.Protocol).Returns(protocol); mock.SetupGet(m => m.Protocol).Returns(protocol);
return mock; return mock;

View File

@ -2019,6 +2019,78 @@ namespace Microsoft.AspNetCore.SignalR.Tests
} }
} }
[Fact]
public async Task ConnectionNotTimedOutIfClientNeverPings()
{
var serviceProvider = HubConnectionHandlerTestUtils.CreateServiceProvider(services =>
services.Configure<HubOptions>(options =>
options.ClientTimeoutInterval = TimeSpan.FromMilliseconds(100)));
var connectionHandler = serviceProvider.GetService<HubConnectionHandler<MethodHub>>();
using (var client = new TestClient(new JsonHubProtocol()))
{
var connectionHandlerTask = await client.ConnectAsync(connectionHandler);
await client.Connected.OrTimeout();
// This is a fake client -- it doesn't auto-ping to signal
// We go over the 100 ms timeout interval...
await Task.Delay(120);
client.TickHeartbeat();
// but client should still be open, since it never pinged to activate the timeout checking
Assert.False(connectionHandlerTask.IsCompleted);
}
}
[Fact]
public async Task ConnectionTimesOutIfInitialPingAndThenNoMessages()
{
var serviceProvider = HubConnectionHandlerTestUtils.CreateServiceProvider(services =>
services.Configure<HubOptions>(options =>
options.ClientTimeoutInterval = TimeSpan.FromMilliseconds(100)));
var connectionHandler = serviceProvider.GetService<HubConnectionHandler<MethodHub>>();
using (var client = new TestClient(new JsonHubProtocol()))
{
var connectionHandlerTask = await client.ConnectAsync(connectionHandler);
await client.Connected.OrTimeout();
await client.SendHubMessageAsync(PingMessage.Instance);
await Task.Delay(300);
client.TickHeartbeat();
await Task.Delay(300);
client.TickHeartbeat();
await connectionHandlerTask.OrTimeout();
}
}
[Fact]
public async Task ReceivingMessagesPreventsConnectionTimeoutFromOccuring()
{
var serviceProvider = HubConnectionHandlerTestUtils.CreateServiceProvider(services =>
services.Configure<HubOptions>(options =>
options.ClientTimeoutInterval = TimeSpan.FromMilliseconds(300)));
var connectionHandler = serviceProvider.GetService<HubConnectionHandler<MethodHub>>();
using (var client = new TestClient(new JsonHubProtocol()))
{
var connectionHandlerTask = await client.ConnectAsync(connectionHandler);
await client.Connected.OrTimeout();
await client.SendHubMessageAsync(PingMessage.Instance);
for (int i = 0; i < 10; i++)
{
await Task.Delay(100);
client.TickHeartbeat();
await client.SendHubMessageAsync(PingMessage.Instance);
}
Assert.False(connectionHandlerTask.IsCompleted);
}
}
[Fact] [Fact]
public async Task EndingConnectionSendsCloseMessageWithNoError() public async Task EndingConnectionSendsCloseMessageWithNoError()
{ {