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