From a861d18d244a632281ef4a1402f29ad1051e5bf9 Mon Sep 17 00:00:00 2001 From: James Newton-King Date: Tue, 16 Jun 2020 10:13:09 +1200 Subject: [PATCH] Add HTTP/2 keep alive pings (#22565) --- ...pNetCore.Server.Kestrel.Core.netcoreapp.cs | 2 + src/Servers/Kestrel/Core/src/CoreStrings.resx | 6 + src/Servers/Kestrel/Core/src/Http2Limits.cs | 54 +++ .../src/Internal/Http2/Http2Connection.cs | 28 ++ .../Core/src/Internal/Http2/Http2KeepAlive.cs | 91 ++++ .../Kestrel/shared/test/TestConstants.cs | 2 +- .../Http2/Http2KeepAliveTests.cs | 389 ++++++++++++++++++ .../Http2/Http2TestBase.cs | 19 +- 8 files changed, 585 insertions(+), 6 deletions(-) create mode 100644 src/Servers/Kestrel/Core/src/Internal/Http2/Http2KeepAlive.cs create mode 100644 src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2KeepAliveTests.cs diff --git a/src/Servers/Kestrel/Core/ref/Microsoft.AspNetCore.Server.Kestrel.Core.netcoreapp.cs b/src/Servers/Kestrel/Core/ref/Microsoft.AspNetCore.Server.Kestrel.Core.netcoreapp.cs index aee4918239..3156545567 100644 --- a/src/Servers/Kestrel/Core/ref/Microsoft.AspNetCore.Server.Kestrel.Core.netcoreapp.cs +++ b/src/Servers/Kestrel/Core/ref/Microsoft.AspNetCore.Server.Kestrel.Core.netcoreapp.cs @@ -74,6 +74,8 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core public int HeaderTableSize { get { throw null; } set { } } public int InitialConnectionWindowSize { get { throw null; } set { } } public int InitialStreamWindowSize { get { throw null; } set { } } + public System.TimeSpan KeepAlivePingInterval { get { throw null; } set { } } + public System.TimeSpan KeepAlivePingTimeout { get { throw null; } set { } } public int MaxFrameSize { get { throw null; } set { } } public int MaxRequestHeaderFieldSize { get { throw null; } set { } } public int MaxStreamsPerConnection { get { throw null; } set { } } diff --git a/src/Servers/Kestrel/Core/src/CoreStrings.resx b/src/Servers/Kestrel/Core/src/CoreStrings.resx index 7f9e97c266..1327210b5a 100644 --- a/src/Servers/Kestrel/Core/src/CoreStrings.resx +++ b/src/Servers/Kestrel/Core/src/CoreStrings.resx @@ -605,4 +605,10 @@ For more information on configuring HTTPS see https://go.microsoft.com/fwlink/?l Connection "{connectionId}" established using the following protocol: {protocol} + + Timeout while waiting for incoming HTTP/2 frames after a keep alive ping. + + + A TimeSpan value greater than or equal to {value} is required. + \ No newline at end of file diff --git a/src/Servers/Kestrel/Core/src/Http2Limits.cs b/src/Servers/Kestrel/Core/src/Http2Limits.cs index 713159f66a..eccf7e0598 100644 --- a/src/Servers/Kestrel/Core/src/Http2Limits.cs +++ b/src/Servers/Kestrel/Core/src/Http2Limits.cs @@ -2,7 +2,9 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Threading; using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; namespace Microsoft.AspNetCore.Server.Kestrel.Core { @@ -17,6 +19,8 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core private int _maxRequestHeaderFieldSize = (int)Http2PeerSettings.DefaultMaxFrameSize; private int _initialConnectionWindowSize = 1024 * 128; // Larger than the default 64kb, and larger than any one single stream. private int _initialStreamWindowSize = 1024 * 96; // Larger than the default 64kb + private TimeSpan _keepAlivePingInterval = TimeSpan.MaxValue; + private TimeSpan _keepAlivePingTimeout = TimeSpan.FromSeconds(20); /// /// Limits the number of concurrent request streams per HTTP/2 connection. Excess streams will be refused. @@ -141,5 +145,55 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core _initialStreamWindowSize = value; } } + + /// + /// Gets or sets the keep alive ping interval. The server will send a keep alive ping to the client if it + /// doesn't receive any frames for this period of time. This property is used together with + /// to close broken connections. + /// + /// Interval must be greater than or equal to 1 second. Set to to + /// disable the keep alive ping interval. + /// Defaults to . + /// + /// + public TimeSpan KeepAlivePingInterval + { + get => _keepAlivePingInterval; + set + { + // Keep alive uses Kestrel's system clock which has a 1 second resolution. Time is greater or equal to clock resolution. + if (value < Heartbeat.Interval && value != Timeout.InfiniteTimeSpan) + { + throw new ArgumentOutOfRangeException(nameof(value), CoreStrings.FormatArgumentTimeSpanGreaterOrEqual(Heartbeat.Interval)); + } + + _keepAlivePingInterval = value != Timeout.InfiniteTimeSpan ? value : TimeSpan.MaxValue; + } + } + + /// + /// Gets or sets the keep alive ping timeout. Keep alive pings are sent when a period of inactivity exceeds + /// the configured value. The server will close the connection if it + /// doesn't receive any frames within the timeout. + /// + /// Timeout must be greater than or equal to 1 second. Set to to + /// disable the keep alive ping timeout. + /// Defaults to 20 seconds. + /// + /// + public TimeSpan KeepAlivePingTimeout + { + get => _keepAlivePingTimeout; + set + { + // Keep alive uses Kestrel's system clock which has a 1 second resolution. Time is greater or equal to clock resolution. + if (value < Heartbeat.Interval && value != Timeout.InfiniteTimeSpan) + { + throw new ArgumentOutOfRangeException(nameof(value), CoreStrings.FormatArgumentTimeSpanGreaterOrEqual(Heartbeat.Interval)); + } + + _keepAlivePingTimeout = value != Timeout.InfiniteTimeSpan ? value : TimeSpan.MaxValue; + } + } } } diff --git a/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Connection.cs b/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Connection.cs index 6c31c51827..28002d804a 100644 --- a/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Connection.cs +++ b/src/Servers/Kestrel/Core/src/Internal/Http2/Http2Connection.cs @@ -66,6 +66,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2 private int _isClosed; // Internal for testing + internal readonly Http2KeepAlive _keepAlive; internal readonly Dictionary _streams = new Dictionary(); internal Http2StreamStack StreamPool; @@ -106,6 +107,14 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2 var connectionWindow = (uint)http2Limits.InitialConnectionWindowSize; _inputFlowControl = new InputFlowControl(connectionWindow, connectionWindow / 2); + if (http2Limits.KeepAlivePingInterval != TimeSpan.MaxValue) + { + _keepAlive = new Http2KeepAlive( + http2Limits.KeepAlivePingInterval, + http2Limits.KeepAlivePingTimeout, + context.ServiceContext.SystemClock); + } + _serverSettings.MaxConcurrentStreams = (uint)http2Limits.MaxStreamsPerConnection; _serverSettings.MaxFrameSize = (uint)http2Limits.MaxFrameSize; _serverSettings.HeaderTableSize = (uint)http2Limits.HeaderTableSize; @@ -211,8 +220,10 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2 try { + bool frameReceived = false; while (Http2FrameReader.TryReadFrame(ref buffer, _incomingFrame, _serverSettings.MaxFrameSize, out var framePayload)) { + frameReceived = true; Log.Http2FrameReceived(ConnectionId, _incomingFrame); await ProcessFrameAsync(application, framePayload); } @@ -221,6 +232,23 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2 { return; } + + if (_keepAlive != null) + { + // Note that the keep alive uses a complete frame being received to reset state. + // Some other keep alive implementations use any bytes being received to reset state. + var state = _keepAlive.ProcessKeepAlive(frameReceived); + if (state == KeepAliveState.SendPing) + { + await _frameWriter.WritePingAsync(Http2PingFrameFlags.NONE, Http2KeepAlive.PingPayload); + } + else if (state == KeepAliveState.Timeout) + { + // There isn't a good error code to return with the GOAWAY. + // NO_ERROR isn't a good choice because it indicates the connection is gracefully shutting down. + throw new Http2ConnectionErrorException(CoreStrings.Http2ErrorKeepAliveTimeout, Http2ErrorCode.INTERNAL_ERROR); + } + } } catch (Http2StreamErrorException ex) { diff --git a/src/Servers/Kestrel/Core/src/Internal/Http2/Http2KeepAlive.cs b/src/Servers/Kestrel/Core/src/Internal/Http2/Http2KeepAlive.cs new file mode 100644 index 0000000000..4703eb76d7 --- /dev/null +++ b/src/Servers/Kestrel/Core/src/Internal/Http2/Http2KeepAlive.cs @@ -0,0 +1,91 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Buffers; +using System.Threading; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure; + +namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2 +{ + internal enum KeepAliveState + { + None, + SendPing, + PingSent, + Timeout + } + + internal class Http2KeepAlive + { + // An empty ping payload + internal static readonly ReadOnlySequence PingPayload = new ReadOnlySequence(new byte[8]); + + private readonly TimeSpan _keepAliveInterval; + private readonly TimeSpan _keepAliveTimeout; + private readonly ISystemClock _systemClock; + private long _lastFrameReceivedTimestamp; + private long _pingSentTimestamp; + + // Internal for testing + internal KeepAliveState _state; + + public Http2KeepAlive(TimeSpan keepAliveInterval, TimeSpan keepAliveTimeout, ISystemClock systemClock) + { + _keepAliveInterval = keepAliveInterval; + _keepAliveTimeout = keepAliveTimeout; + _systemClock = systemClock; + } + + public KeepAliveState ProcessKeepAlive(bool frameReceived) + { + var timestamp = _systemClock.UtcNowTicks; + + if (frameReceived) + { + // System clock only has 1 second of precision, so the clock could be up to 1 second in the past. + // To err on the side of caution, add a second to the clock when calculating the ping sent time. + _lastFrameReceivedTimestamp = timestamp + TimeSpan.TicksPerSecond; + + // Any frame received after the keep alive interval is exceeded resets the state back to none. + if (_state == KeepAliveState.PingSent) + { + _pingSentTimestamp = 0; + _state = KeepAliveState.None; + } + } + else + { + switch (_state) + { + case KeepAliveState.None: + // Check whether keep alive interval has passed since last frame received + if (timestamp > (_lastFrameReceivedTimestamp + _keepAliveInterval.Ticks)) + { + // Ping will be sent immeditely after this method finishes. + // Set the status directly to ping sent and set the timestamp + _state = KeepAliveState.PingSent; + // System clock only has 1 second of precision, so the clock could be up to 1 second in the past. + // To err on the side of caution, add a second to the clock when calculating the ping sent time. + _pingSentTimestamp = _systemClock.UtcNowTicks + TimeSpan.TicksPerSecond; + + // Indicate that the ping needs to be sent. This is only returned once + return KeepAliveState.SendPing; + } + break; + case KeepAliveState.PingSent: + if (_keepAliveTimeout != TimeSpan.MaxValue) + { + if (timestamp > (_pingSentTimestamp + _keepAliveTimeout.Ticks)) + { + _state = KeepAliveState.Timeout; + } + } + break; + } + } + + return _state; + } + } +} diff --git a/src/Servers/Kestrel/shared/test/TestConstants.cs b/src/Servers/Kestrel/shared/test/TestConstants.cs index 9615b4c5ef..a69feddc6f 100644 --- a/src/Servers/Kestrel/shared/test/TestConstants.cs +++ b/src/Servers/Kestrel/shared/test/TestConstants.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2KeepAliveTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2KeepAliveTests.cs new file mode 100644 index 0000000000..47176d32bc --- /dev/null +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2KeepAliveTests.cs @@ -0,0 +1,389 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http2; +using Xunit; + +namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests +{ + public class Http2KeepAliveTests : Http2TestBase + { + [Fact] + public async Task KeepAlivePingInterval_InfiniteTimeSpan_KeepAliveNotEnabled() + { + _serviceContext.ServerOptions.Limits.Http2.KeepAlivePingInterval = Timeout.InfiniteTimeSpan; + + await InitializeConnectionAsync(_noopApplication).DefaultTimeout(); + + Assert.Null(_connection._keepAlive); + + await StopConnectionAsync(expectedLastStreamId: 0, ignoreNonGoAwayFrames: false).DefaultTimeout(); + } + + [Fact] + public async Task KeepAlivePingTimeout_InfiniteTimeSpan_NoGoAway() + { + _serviceContext.ServerOptions.Limits.Http2.KeepAlivePingInterval = TimeSpan.FromSeconds(1); + _serviceContext.ServerOptions.Limits.Http2.KeepAlivePingTimeout = Timeout.InfiniteTimeSpan; + + await InitializeConnectionAsync(_noopApplication).DefaultTimeout(); + + DateTimeOffset now = _serviceContext.MockSystemClock.UtcNow; + + // Heartbeat + TriggerTick(now); + + // Heartbeat that exceeds interval + TriggerTick(now + TimeSpan.FromSeconds(1.1 * 2)); + + await ExpectAsync(Http2FrameType.PING, + withLength: 8, + withFlags: (byte)Http2PingFrameFlags.NONE, + withStreamId: 0).DefaultTimeout(); + + // Heartbeat that exceeds timeout + TriggerTick(now + TimeSpan.FromSeconds(1.1 * 3)); + TriggerTick(now + TimeSpan.FromSeconds(1.1 * 4)); + TriggerTick(now + TimeSpan.FromSeconds(1.1 * 20)); + + Assert.Equal(KeepAliveState.PingSent, _connection._keepAlive._state); + + await StopConnectionAsync(expectedLastStreamId: 0, ignoreNonGoAwayFrames: false).DefaultTimeout(); + } + + [Fact] + public async Task IntervalExceeded_WithoutActivity_PingSent() + { + _serviceContext.ServerOptions.Limits.Http2.KeepAlivePingInterval = TimeSpan.FromSeconds(1); + + await InitializeConnectionAsync(_noopApplication).DefaultTimeout(); + + DateTimeOffset now = _serviceContext.MockSystemClock.UtcNow; + + // Heartbeat + TriggerTick(now); + + // Heartbeat that exceeds interval + TriggerTick(now + TimeSpan.FromSeconds(1.1 * 2)); + + await ExpectAsync(Http2FrameType.PING, + withLength: 8, + withFlags: (byte)Http2PingFrameFlags.NONE, + withStreamId: 0).DefaultTimeout(); + + await StopConnectionAsync(expectedLastStreamId: 0, ignoreNonGoAwayFrames: false).DefaultTimeout(); + } + + [Fact] + public async Task IntervalExceeded_WithActivity_NoPingSent() + { + _serviceContext.ServerOptions.Limits.Http2.KeepAlivePingInterval = TimeSpan.FromSeconds(1); + + await InitializeConnectionAsync(_noopApplication).DefaultTimeout(); + + DateTimeOffset now = _serviceContext.MockSystemClock.UtcNow; + + // Heartbeat + TriggerTick(now); + + await SendPingAsync(Http2PingFrameFlags.NONE).DefaultTimeout(); + await ExpectAsync(Http2FrameType.PING, + withLength: 8, + withFlags: (byte)Http2PingFrameFlags.ACK, + withStreamId: 0).DefaultTimeout(); + + // Heartbeat that exceeds interval + TriggerTick(now + TimeSpan.FromSeconds(1.1)); + + await StopConnectionAsync(expectedLastStreamId: 0, ignoreNonGoAwayFrames: false).DefaultTimeout(); + } + + [Fact] + public async Task IntervalNotExceeded_NoPingSent() + { + _serviceContext.ServerOptions.Limits.Http2.KeepAlivePingInterval = TimeSpan.FromSeconds(5); + + await InitializeConnectionAsync(_noopApplication).DefaultTimeout(); + + DateTimeOffset now = new DateTimeOffset(1, TimeSpan.Zero); + + // Heartbeats + TriggerTick(now); + TriggerTick(now + TimeSpan.FromSeconds(1.1)); + TriggerTick(now + TimeSpan.FromSeconds(1.1 * 2)); + TriggerTick(now + TimeSpan.FromSeconds(1.1 * 3)); + + await StopConnectionAsync(expectedLastStreamId: 0, ignoreNonGoAwayFrames: false).DefaultTimeout(); + } + + [Fact] + public async Task IntervalExceeded_MultipleTimes_PingsNotSentWhileAwaitingOnAck() + { + _serviceContext.ServerOptions.Limits.Http2.KeepAlivePingInterval = TimeSpan.FromSeconds(1); + + await InitializeConnectionAsync(_noopApplication).DefaultTimeout(); + + DateTimeOffset now = _serviceContext.MockSystemClock.UtcNow; + + TriggerTick(now); + TriggerTick(now + TimeSpan.FromSeconds(1.1 * 2)); + + await ExpectAsync(Http2FrameType.PING, + withLength: 8, + withFlags: (byte)Http2PingFrameFlags.NONE, + withStreamId: 0).DefaultTimeout(); + + TriggerTick(now + TimeSpan.FromSeconds(1.1 * 3)); + TriggerTick(now + TimeSpan.FromSeconds(1.1 * 4)); + TriggerTick(now + TimeSpan.FromSeconds(1.1 * 5)); + + await StopConnectionAsync(expectedLastStreamId: 0, ignoreNonGoAwayFrames: false).DefaultTimeout(); + } + + [Fact] + public async Task IntervalExceeded_MultipleTimes_PingSentAfterAck() + { + _serviceContext.ServerOptions.Limits.Http2.KeepAlivePingInterval = TimeSpan.FromSeconds(1); + + await InitializeConnectionAsync(_noopApplication).DefaultTimeout(); + + DateTimeOffset now = _serviceContext.MockSystemClock.UtcNow; + + // Heartbeats + TriggerTick(now); + TriggerTick(now + TimeSpan.FromSeconds(1.1 * 2)); + + await ExpectAsync(Http2FrameType.PING, + withLength: 8, + withFlags: (byte)Http2PingFrameFlags.NONE, + withStreamId: 0).DefaultTimeout(); + await SendPingAsync(Http2PingFrameFlags.ACK).DefaultTimeout(); + + TriggerTick(now + TimeSpan.FromSeconds(1.1 * 3)); + TriggerTick(now + TimeSpan.FromSeconds(1.1 * 4)); + + await ExpectAsync(Http2FrameType.PING, + withLength: 8, + withFlags: (byte)Http2PingFrameFlags.NONE, + withStreamId: 0).DefaultTimeout(); + await SendPingAsync(Http2PingFrameFlags.ACK).DefaultTimeout(); + + TriggerTick(now + TimeSpan.FromSeconds(1.1 * 5)); + TriggerTick(now + TimeSpan.FromSeconds(1.1 * 6)); + + await ExpectAsync(Http2FrameType.PING, + withLength: 8, + withFlags: (byte)Http2PingFrameFlags.NONE, + withStreamId: 0).DefaultTimeout(); + + await StopConnectionAsync(expectedLastStreamId: 0, ignoreNonGoAwayFrames: false).DefaultTimeout(); + } + + [Fact] + public async Task TimeoutExceeded_NoAck_GoAway() + { + _serviceContext.ServerOptions.Limits.Http2.KeepAlivePingInterval = TimeSpan.FromSeconds(1); + _serviceContext.ServerOptions.Limits.Http2.KeepAlivePingTimeout = TimeSpan.FromSeconds(3); + + await InitializeConnectionAsync(_noopApplication).DefaultTimeout(); + + DateTimeOffset now = _serviceContext.MockSystemClock.UtcNow; + + // Heartbeat + TriggerTick(now); + + // Heartbeat that exceeds interval + TriggerTick(now + TimeSpan.FromSeconds(1.1 * 2)); + + await ExpectAsync(Http2FrameType.PING, + withLength: 8, + withFlags: (byte)Http2PingFrameFlags.NONE, + withStreamId: 0).DefaultTimeout(); + + // Heartbeat that exceeds timeout + TriggerTick(now + TimeSpan.FromSeconds(1.1 * 3)); + TriggerTick(now + TimeSpan.FromSeconds(1.1 * 4)); + TriggerTick(now + TimeSpan.FromSeconds(1.1 * 5)); + TriggerTick(now + TimeSpan.FromSeconds(1.1 * 6)); + + Assert.Equal(KeepAliveState.Timeout, _connection._keepAlive._state); + + VerifyGoAway(await ReceiveFrameAsync().DefaultTimeout(), 0, Http2ErrorCode.INTERNAL_ERROR); + } + + [Fact] + public async Task TimeoutExceeded_NonPingActivity_NoGoAway() + { + _serviceContext.ServerOptions.Limits.Http2.KeepAlivePingInterval = TimeSpan.FromSeconds(1); + _serviceContext.ServerOptions.Limits.Http2.KeepAlivePingTimeout = TimeSpan.FromSeconds(3); + + await InitializeConnectionAsync(_noopApplication).DefaultTimeout(); + + DateTimeOffset now = _serviceContext.MockSystemClock.UtcNow; + + // Heartbeat + TriggerTick(now); + + // Heartbeat that exceeds interval + TriggerTick(now + TimeSpan.FromSeconds(1.1 * 2)); + + Assert.Equal(KeepAliveState.PingSent, _connection._keepAlive._state); + await ExpectAsync(Http2FrameType.PING, + withLength: 8, + withFlags: (byte)Http2PingFrameFlags.NONE, + withStreamId: 0).DefaultTimeout(); + + await StartStreamAsync(1, _browserRequestHeaders, endStream: true).DefaultTimeout(); + Assert.Equal(KeepAliveState.None, _connection._keepAlive._state); + + await ExpectAsync(Http2FrameType.HEADERS, + withLength: 36, + withFlags: (byte)(Http2HeadersFrameFlags.END_HEADERS | Http2HeadersFrameFlags.END_STREAM), + withStreamId: 1).DefaultTimeout(); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false).DefaultTimeout(); + } + + [Fact] + public async Task IntervalExceeded_StreamStarted_NoPingSent() + { + _serviceContext.ServerOptions.Limits.Http2.KeepAlivePingInterval = TimeSpan.FromSeconds(1); + + await InitializeConnectionAsync(_noopApplication).DefaultTimeout(); + + DateTimeOffset now = _serviceContext.MockSystemClock.UtcNow; + + // Heartbeat + TriggerTick(now); + + await StartStreamAsync(1, _browserRequestHeaders, endStream: true).DefaultTimeout(); + + await ExpectAsync(Http2FrameType.HEADERS, + withLength: 36, + withFlags: (byte)(Http2HeadersFrameFlags.END_HEADERS | Http2HeadersFrameFlags.END_STREAM), + withStreamId: 1).DefaultTimeout(); + + // Heartbeat that exceeds interval + TriggerTick(now + TimeSpan.FromSeconds(1.1)); + + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: false).DefaultTimeout(); + } + + [Fact] + public async Task IntervalExceeded_ConnectionFlowControlUsedUpThenPings_NoPingSent() + { + _serviceContext.ServerOptions.Limits.Http2.KeepAlivePingInterval = TimeSpan.FromSeconds(1); + + // Reduce connection window size so that one stream can fill it + _serviceContext.ServerOptions.Limits.Http2.InitialConnectionWindowSize = 65535; + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await InitializeConnectionAsync(async c => + { + // Don't consume any request data + await tcs.Task; + // Send headers + await c.Request.Body.FlushAsync(); + }, expectedWindowUpdate: false).DefaultTimeout(); + + DateTimeOffset now = _serviceContext.MockSystemClock.UtcNow; + + // Heartbeat + TriggerTick(now); + + await StartStreamAsync(1, _browserRequestHeaders, endStream: false).DefaultTimeout(); + + // Use up connection flow control + await SendDataAsync(1, new byte[16384], false).DefaultTimeout(); + await SendDataAsync(1, new byte[16384], false).DefaultTimeout(); + await SendDataAsync(1, new byte[16384], false).DefaultTimeout(); + await SendDataAsync(1, new byte[16383], false).DefaultTimeout(); + + // Heartbeat that exceeds interval + TriggerTick(now + TimeSpan.FromSeconds(1.1)); + + // Send ping that will update the keep alive on the server + await SendPingAsync(Http2PingFrameFlags.NONE).DefaultTimeout(); + await ExpectAsync(Http2FrameType.PING, + withLength: 8, + withFlags: (byte)Http2PingFrameFlags.ACK, + withStreamId: 0).DefaultTimeout(); + + // Heartbeat that exceeds interval + TriggerTick(now + TimeSpan.FromSeconds(1.1 * 2)); + + // Continue request delegate on server + tcs.SetResult(null); + + await ExpectAsync(Http2FrameType.HEADERS, + withLength: 36, + withFlags: (byte)(Http2HeadersFrameFlags.END_HEADERS | Http2HeadersFrameFlags.END_STREAM), + withStreamId: 1).DefaultTimeout(); + + // Server could send RST_STREAM + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: true).DefaultTimeout(); + } + + [Fact] + public async Task TimeoutExceeded_ConnectionFlowControlUsedUpThenPings_NoGoAway() + { + _serviceContext.ServerOptions.Limits.Http2.KeepAlivePingInterval = TimeSpan.FromSeconds(1); + + // Reduce connection window size so that one stream can fill it + _serviceContext.ServerOptions.Limits.Http2.InitialConnectionWindowSize = 65535; + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await InitializeConnectionAsync(async c => + { + // Don't consume any request data + await tcs.Task; + // Send headers + await c.Request.Body.FlushAsync(); + }, expectedWindowUpdate: false).DefaultTimeout(); + + DateTimeOffset now = _serviceContext.MockSystemClock.UtcNow; + + // Heartbeat + TriggerTick(now); + + await StartStreamAsync(1, _browserRequestHeaders, endStream: false).DefaultTimeout(); + + // Use up connection flow control + await SendDataAsync(1, new byte[16384], false).DefaultTimeout(); + await SendDataAsync(1, new byte[16384], false).DefaultTimeout(); + await SendDataAsync(1, new byte[16384], false).DefaultTimeout(); + await SendDataAsync(1, new byte[16383], false).DefaultTimeout(); + + // Heartbeat that exceeds interval + TriggerTick(now + TimeSpan.FromSeconds(1.1)); + + // Heartbeat that triggers keep alive ping + TriggerTick(now + TimeSpan.FromSeconds(1.1 * 2)); + + await ExpectAsync(Http2FrameType.PING, + withLength: 8, + withFlags: (byte)Http2PingFrameFlags.NONE, + withStreamId: 0).DefaultTimeout(); + + // Send ping ack that will reset the keep alive on the server + await SendPingAsync(Http2PingFrameFlags.ACK).DefaultTimeout(); + + // Heartbeat that exceeds interval + TriggerTick(now + TimeSpan.FromSeconds(1.1 * 3)); + + // Continue request delegate on server + tcs.SetResult(null); + + await ExpectAsync(Http2FrameType.HEADERS, + withLength: 36, + withFlags: (byte)(Http2HeadersFrameFlags.END_HEADERS | Http2HeadersFrameFlags.END_STREAM), + withStreamId: 1).DefaultTimeout(); + + // Server could send RST_STREAM + await StopConnectionAsync(expectedLastStreamId: 1, ignoreNonGoAwayFrames: true).DefaultTimeout(); + } + } +} diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2TestBase.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2TestBase.cs index 55ee7c635c..1a9cbba998 100644 --- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2TestBase.cs +++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/Http2/Http2TestBase.cs @@ -441,7 +441,7 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests .Callback(r => httpConnection.OnTimeout(r)); } - protected async Task InitializeConnectionAsync(RequestDelegate application, int expectedSettingsCount = 3) + protected async Task InitializeConnectionAsync(RequestDelegate application, int expectedSettingsCount = 3, bool expectedWindowUpdate = true) { if (_connection == null) { @@ -473,10 +473,13 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests withFlags: 0, withStreamId: 0); - await ExpectAsync(Http2FrameType.WINDOW_UPDATE, - withLength: 4, - withFlags: 0, - withStreamId: 0); + if (expectedWindowUpdate) + { + await ExpectAsync(Http2FrameType.WINDOW_UPDATE, + withLength: 4, + withFlags: 0, + withStreamId: 0); + } await ExpectAsync(Http2FrameType.SETTINGS, withLength: 0, @@ -497,6 +500,12 @@ namespace Microsoft.AspNetCore.Server.Kestrel.Core.Tests ((IRequestProcessor)((Http2TestBase)state)._connection)?.Tick(default); } + protected void TriggerTick(DateTimeOffset now) + { + _serviceContext.MockSystemClock.UtcNow = now; + ((IRequestProcessor)_connection)?.Tick(now); + } + protected Task StartStreamAsync(int streamId, IEnumerable> headers, bool endStream) { var writableBuffer = _pair.Application.Output;