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)
{
// 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
timer.Start();
ResetSendPing();
ResetTimeout();
ResetSendPing();
using (timer)
{
// 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 CancellationTokenSource _connectionAbortedTokenSource = new CancellationTokenSource();
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 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 bool _clientTimeoutActive;
/// <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.</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)
/// <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;
_logger = loggerFactory.CreateLogger<HubConnectionContext>();
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>
/// Gets a <see cref="CancellationToken"/> that notifies when the connection is aborted.
/// </summary>
@ -428,13 +443,15 @@ namespace Microsoft.AspNetCore.SignalR
private void KeepAliveTick()
{
var timestamp = Stopwatch.GetTimestamp();
var currentTime = DateTime.UtcNow.Ticks;
// 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).
// 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),
// 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.
// 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.
_ = 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)
{
var connection = (HubConnectionContext)state;
@ -464,6 +508,11 @@ namespace Microsoft.AspNetCore.SignalR
}
}
internal void ResetClientTimeout()
{
_receivedMessageThisInterval = true;
}
private static class Log
{
// 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.
// 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 clientTimeout = _hubOptions.ClientTimeoutInterval ?? _globalHubOptions.ClientTimeoutInterval ?? HubOptionsSetup.DefaultClientTimeoutInterval;
var handshakeTimeout = _hubOptions.HandshakeTimeout ?? _globalHubOptions.HandshakeTimeout ?? HubOptionsSetup.DefaultHandshakeTimeout;
var supportedProtocols = _hubOptions.SupportedProtocols ?? _globalHubOptions.SupportedProtocols;
@ -79,7 +80,7 @@ namespace Microsoft.AspNetCore.SignalR
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();
if (!await connectionContext.HandshakeAsync(handshakeTimeout, resolvedSupportedProtocols, _protocolResolver, _userIdProvider, _enableDetailedErrors))
@ -87,6 +88,8 @@ namespace Microsoft.AspNetCore.SignalR
return;
}
// -- the connectionContext has been set up --
try
{
await _lifetimeManager.OnConnectedAsync(connectionContext);
@ -197,6 +200,8 @@ namespace Microsoft.AspNetCore.SignalR
if (!buffer.IsEmpty)
{
connection.ResetClientTimeout();
while (protocol.TryParseMessage(ref buffer, _dispatcher, out var message))
{
await _dispatcher.DispatchMessageAsync(connection, message);

View File

@ -26,6 +26,11 @@ namespace Microsoft.AspNetCore.SignalR
/// </summary>
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>
/// Gets or sets a collection of supported hub protocol names.
/// </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.
/// </summary>
/// <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;
case PingMessage _:
// We don't care about pings
connection.StartClientTimeout();
break;
// 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);
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)
{
foreach (var hubProtocol in protocols)
{
_protocols.Add(hubProtocol.Name);
_defaultProtocols.Add(hubProtocol.Name);
}
}
public void Configure(HubOptions options)
{
if (options.SupportedProtocols == null)
{
options.SupportedProtocols = new List<string>();
}
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.
options.KeepAliveInterval = DefaultKeepAliveInterval;
}
@ -43,7 +40,12 @@ namespace Microsoft.AspNetCore.SignalR.Internal
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);
}

View File

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

View File

@ -14,6 +14,7 @@ using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Internal;
using Microsoft.AspNetCore.SignalR.Protocol;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Microsoft.AspNetCore.SignalR.Client.Tests
{
@ -55,7 +56,7 @@ namespace Microsoft.AspNetCore.SignalR.Client.Tests
Application.Input.OnWriterCompleted((ex, _) =>
{
Application.Output.Complete();
},
},
null);
}
@ -116,9 +117,27 @@ namespace Microsoft.AspNetCore.SignalR.Client.Tests
{
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
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)
{
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)
{
@ -155,7 +174,7 @@ namespace Microsoft.AspNetCore.SignalR.Client.Tests
while (true)
{
var message = await ReadSentTextMessageAsync();
var message = await ReadSentTextMessageAsync(ignorePings);
if (message == null)
{
break;

View File

@ -22,7 +22,7 @@ namespace Microsoft.AspNetCore.SignalR.Tests
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();
mock.SetupGet(m => m.Protocol).Returns(protocol);
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]
public async Task EndingConnectionSendsCloseMessageWithNoError()
{