From 8cc3ee02363f4bc86a9470f7cdd71baf8100f901 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 19 May 2025 18:17:37 +0000
Subject: [PATCH 1/2] Fix HTTP/2 pings with zero connection lifetime

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
---
 .../HttpConnectionPoolManager.cs              |   5 +-
 ...cketsHttpHandlerTest.Http2KeepAlivePing.cs | 112 ++++++++++++++++++
 2 files changed, 116 insertions(+), 1 deletion(-)

diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPoolManager.cs b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPoolManager.cs
index 27181c7b5b8a95..8424d1b26cebe2 100644
--- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPoolManager.cs
+++ b/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/HttpConnectionPoolManager.cs
@@ -69,10 +69,13 @@ public HttpConnectionPoolManager(HttpConnectionSettings settings)
             // However, we can only do such optimizations if we're not also tracking
             // connections per server, as we use data in the associated data structures
             // to do that tracking.
+            // Additionally, we should not avoid storing connections if keep-alive ping is configured,
+            // as the heartbeat timer is needed for ping functionality.
             bool avoidStoringConnections =
                 settings._maxConnectionsPerServer == int.MaxValue &&
                 (settings._pooledConnectionIdleTimeout == TimeSpan.Zero ||
-                 settings._pooledConnectionLifetime == TimeSpan.Zero);
+                 settings._pooledConnectionLifetime == TimeSpan.Zero) &&
+                settings._keepAlivePingDelay == Timeout.InfiniteTimeSpan;
 
             // Start out with the timer not running, since we have no pools.
             // When it does run, run it with a frequency based on the idle timeout.
diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.Http2KeepAlivePing.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.Http2KeepAlivePing.cs
index 0f1c1b8d58c326..b60211bc21e56f 100644
--- a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.Http2KeepAlivePing.cs
+++ b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.Http2KeepAlivePing.cs
@@ -255,6 +255,118 @@ await Http2LoopbackServer.CreateClientAndServerAsync(async uri =>
             }, NoAutoPingResponseHttp2Options);
         }
 
+        [OuterLoop("Runs long")]
+        [Fact]
+        public async Task KeepAlivePing_ZeroConnectionLifetime_PingsStillWork()
+        {
+            await Http2LoopbackServer.CreateClientAndServerAsync(async uri =>
+            {
+                SocketsHttpHandler handler = CreateSocketsHttpHandler(allowAllCertificates: true);
+                handler.KeepAlivePingTimeout = TimeSpan.FromSeconds(10);
+                handler.KeepAlivePingPolicy = HttpKeepAlivePingPolicy.WithActiveRequests;
+                handler.KeepAlivePingDelay = TimeSpan.FromSeconds(1);
+                handler.PooledConnectionLifetime = TimeSpan.Zero; // Zero lifetime should still allow pings
+
+                using HttpClient client = new HttpClient(handler);
+                client.DefaultRequestVersion = HttpVersion.Version20;
+
+                // Warmup request to create connection:
+                HttpResponseMessage response0 = await client.GetAsync(uri);
+                Assert.Equal(HttpStatusCode.OK, response0.StatusCode);
+
+                // Actual request:
+                HttpResponseMessage response1 = await client.GetAsync(uri);
+                Assert.Equal(HttpStatusCode.OK, response1.StatusCode);
+
+                // Let connection live until server finishes:
+                await _serverFinished.Task.WaitAsync(TestTimeout);
+            },
+            async server =>
+            {
+                await EstablishConnectionAsync(server);
+
+                // Warmup the connection.
+                int streamId1 = await ReadRequestHeaderAsync();
+                await GuardConnectionWriteAsync(() => _connection.SendDefaultResponseAsync(streamId1));
+
+                // Request under the test scope.
+                int streamId2 = await ReadRequestHeaderAsync();
+                Interlocked.Exchange(ref _pingCounter, 0); // reset the PING counter
+
+                // Simulate inactive period:
+                await Task.Delay(5_000);
+
+                // Even with zero lifetime, we should still receive pings for active streams:
+                // We may receive one RTT PING in response to HEADERS.
+                // Upon that, we expect to receive at least 1 keep alive PING:
+                Assert.True(_pingCounter > 1);
+
+                // Finish the response:
+                await GuardConnectionWriteAsync(() => _connection.SendDefaultResponseAsync(streamId2));
+                
+                await TerminateLoopbackConnectionAsync();
+
+                List<Frame> unexpectedFrames = new List<Frame>();
+                while (_framesChannel.Reader.Count > 0)
+                {
+                    Frame unexpectedFrame = await _framesChannel.Reader.ReadAsync();
+                    unexpectedFrames.Add(unexpectedFrame);
+                }
+
+                Assert.False(unexpectedFrames.Any(), "Received unexpected frames: \n" + string.Join('\n', unexpectedFrames.Select(f => f.ToString()).ToArray()));
+            }, NoAutoPingResponseHttp2Options);
+        }
+
+        [OuterLoop("Runs long")]
+        [Fact]
+        public async Task KeepAlivePing_ZeroConnectionLifetime_NoPingConfig_NoPingsSent()
+        {
+            await Http2LoopbackServer.CreateClientAndServerAsync(async uri =>
+            {
+                SocketsHttpHandler handler = CreateSocketsHttpHandler(allowAllCertificates: true);
+                // Don't configure KeepAlivePing settings
+                handler.PooledConnectionLifetime = TimeSpan.Zero; // Zero lifetime
+
+                using HttpClient client = new HttpClient(handler);
+                client.DefaultRequestVersion = HttpVersion.Version20;
+
+                // Warmup request to create connection:
+                HttpResponseMessage response0 = await client.GetAsync(uri);
+                Assert.Equal(HttpStatusCode.OK, response0.StatusCode);
+
+                // Actual request:
+                HttpResponseMessage response1 = await client.GetAsync(uri);
+                Assert.Equal(HttpStatusCode.OK, response1.StatusCode);
+
+                // Let connection live until server finishes:
+                await _serverFinished.Task.WaitAsync(TestTimeout);
+            },
+            async server =>
+            {
+                await EstablishConnectionAsync(server);
+
+                // Warmup the connection.
+                int streamId1 = await ReadRequestHeaderAsync();
+                await GuardConnectionWriteAsync(() => _connection.SendDefaultResponseAsync(streamId1));
+
+                // Request under the test scope.
+                int streamId2 = await ReadRequestHeaderAsync();
+                Interlocked.Exchange(ref _pingCounter, 0); // reset the PING counter
+
+                // Simulate inactive period:
+                await Task.Delay(5_000);
+
+                // With zero lifetime and no ping config, we should not receive keep alive pings
+                // We may have received one RTT PING in response to HEADERS, but should receive no KeepAlive PING
+                Assert.True(_pingCounter <= 1);
+
+                // Finish the response:
+                await GuardConnectionWriteAsync(() => _connection.SendDefaultResponseAsync(streamId2));
+                
+                await TerminateLoopbackConnectionAsync();
+            }, NoAutoPingResponseHttp2Options);
+        }
+
         private async Task ProcessIncomingFramesAsync(CancellationToken cancellationToken)
         {
             try

From dbfa998a239c61c6496f61bad843683891354924 Mon Sep 17 00:00:00 2001
From: Stephen Toub <stoub@microsoft.com>
Date: Tue, 17 Jun 2025 16:13:54 -0400
Subject: [PATCH 2/2] Extend existing test instead of adding new ones

---
 ...cketsHttpHandlerTest.Http2KeepAlivePing.cs | 120 +-----------------
 1 file changed, 5 insertions(+), 115 deletions(-)

diff --git a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.Http2KeepAlivePing.cs b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.Http2KeepAlivePing.cs
index b60211bc21e56f..e2af99c6de45fa 100644
--- a/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.Http2KeepAlivePing.cs
+++ b/src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.Http2KeepAlivePing.cs
@@ -95,9 +95,10 @@ await Http2LoopbackServer.CreateClientAndServerAsync(async uri =>
 
         [OuterLoop("Runs long")]
         [Theory]
-        [InlineData(HttpKeepAlivePingPolicy.Always)]
-        [InlineData(HttpKeepAlivePingPolicy.WithActiveRequests)]
-        public async Task KeepAliveConfigured_KeepAlivePingsAreSentAccordingToPolicy(HttpKeepAlivePingPolicy policy)
+        [InlineData(HttpKeepAlivePingPolicy.Always, -1)]
+        [InlineData(HttpKeepAlivePingPolicy.WithActiveRequests, -1)]
+        [InlineData(HttpKeepAlivePingPolicy.WithActiveRequests, 0)]
+        public async Task KeepAliveConfigured_KeepAlivePingsAreSentAccordingToPolicy(HttpKeepAlivePingPolicy policy, int connectionLifetimeMilliseconds)
         {
             await Http2LoopbackServer.CreateClientAndServerAsync(async uri =>
             {
@@ -105,6 +106,7 @@ await Http2LoopbackServer.CreateClientAndServerAsync(async uri =>
                 handler.KeepAlivePingTimeout = TimeSpan.FromSeconds(10);
                 handler.KeepAlivePingPolicy = policy;
                 handler.KeepAlivePingDelay = TimeSpan.FromSeconds(1);
+                handler.PooledConnectionLifetime = TimeSpan.FromMilliseconds(connectionLifetimeMilliseconds);
 
                 using HttpClient client = new HttpClient(handler);
                 client.DefaultRequestVersion = HttpVersion.Version20;
@@ -255,118 +257,6 @@ await Http2LoopbackServer.CreateClientAndServerAsync(async uri =>
             }, NoAutoPingResponseHttp2Options);
         }
 
-        [OuterLoop("Runs long")]
-        [Fact]
-        public async Task KeepAlivePing_ZeroConnectionLifetime_PingsStillWork()
-        {
-            await Http2LoopbackServer.CreateClientAndServerAsync(async uri =>
-            {
-                SocketsHttpHandler handler = CreateSocketsHttpHandler(allowAllCertificates: true);
-                handler.KeepAlivePingTimeout = TimeSpan.FromSeconds(10);
-                handler.KeepAlivePingPolicy = HttpKeepAlivePingPolicy.WithActiveRequests;
-                handler.KeepAlivePingDelay = TimeSpan.FromSeconds(1);
-                handler.PooledConnectionLifetime = TimeSpan.Zero; // Zero lifetime should still allow pings
-
-                using HttpClient client = new HttpClient(handler);
-                client.DefaultRequestVersion = HttpVersion.Version20;
-
-                // Warmup request to create connection:
-                HttpResponseMessage response0 = await client.GetAsync(uri);
-                Assert.Equal(HttpStatusCode.OK, response0.StatusCode);
-
-                // Actual request:
-                HttpResponseMessage response1 = await client.GetAsync(uri);
-                Assert.Equal(HttpStatusCode.OK, response1.StatusCode);
-
-                // Let connection live until server finishes:
-                await _serverFinished.Task.WaitAsync(TestTimeout);
-            },
-            async server =>
-            {
-                await EstablishConnectionAsync(server);
-
-                // Warmup the connection.
-                int streamId1 = await ReadRequestHeaderAsync();
-                await GuardConnectionWriteAsync(() => _connection.SendDefaultResponseAsync(streamId1));
-
-                // Request under the test scope.
-                int streamId2 = await ReadRequestHeaderAsync();
-                Interlocked.Exchange(ref _pingCounter, 0); // reset the PING counter
-
-                // Simulate inactive period:
-                await Task.Delay(5_000);
-
-                // Even with zero lifetime, we should still receive pings for active streams:
-                // We may receive one RTT PING in response to HEADERS.
-                // Upon that, we expect to receive at least 1 keep alive PING:
-                Assert.True(_pingCounter > 1);
-
-                // Finish the response:
-                await GuardConnectionWriteAsync(() => _connection.SendDefaultResponseAsync(streamId2));
-                
-                await TerminateLoopbackConnectionAsync();
-
-                List<Frame> unexpectedFrames = new List<Frame>();
-                while (_framesChannel.Reader.Count > 0)
-                {
-                    Frame unexpectedFrame = await _framesChannel.Reader.ReadAsync();
-                    unexpectedFrames.Add(unexpectedFrame);
-                }
-
-                Assert.False(unexpectedFrames.Any(), "Received unexpected frames: \n" + string.Join('\n', unexpectedFrames.Select(f => f.ToString()).ToArray()));
-            }, NoAutoPingResponseHttp2Options);
-        }
-
-        [OuterLoop("Runs long")]
-        [Fact]
-        public async Task KeepAlivePing_ZeroConnectionLifetime_NoPingConfig_NoPingsSent()
-        {
-            await Http2LoopbackServer.CreateClientAndServerAsync(async uri =>
-            {
-                SocketsHttpHandler handler = CreateSocketsHttpHandler(allowAllCertificates: true);
-                // Don't configure KeepAlivePing settings
-                handler.PooledConnectionLifetime = TimeSpan.Zero; // Zero lifetime
-
-                using HttpClient client = new HttpClient(handler);
-                client.DefaultRequestVersion = HttpVersion.Version20;
-
-                // Warmup request to create connection:
-                HttpResponseMessage response0 = await client.GetAsync(uri);
-                Assert.Equal(HttpStatusCode.OK, response0.StatusCode);
-
-                // Actual request:
-                HttpResponseMessage response1 = await client.GetAsync(uri);
-                Assert.Equal(HttpStatusCode.OK, response1.StatusCode);
-
-                // Let connection live until server finishes:
-                await _serverFinished.Task.WaitAsync(TestTimeout);
-            },
-            async server =>
-            {
-                await EstablishConnectionAsync(server);
-
-                // Warmup the connection.
-                int streamId1 = await ReadRequestHeaderAsync();
-                await GuardConnectionWriteAsync(() => _connection.SendDefaultResponseAsync(streamId1));
-
-                // Request under the test scope.
-                int streamId2 = await ReadRequestHeaderAsync();
-                Interlocked.Exchange(ref _pingCounter, 0); // reset the PING counter
-
-                // Simulate inactive period:
-                await Task.Delay(5_000);
-
-                // With zero lifetime and no ping config, we should not receive keep alive pings
-                // We may have received one RTT PING in response to HEADERS, but should receive no KeepAlive PING
-                Assert.True(_pingCounter <= 1);
-
-                // Finish the response:
-                await GuardConnectionWriteAsync(() => _connection.SendDefaultResponseAsync(streamId2));
-                
-                await TerminateLoopbackConnectionAsync();
-            }, NoAutoPingResponseHttp2Options);
-        }
-
         private async Task ProcessIncomingFramesAsync(CancellationToken cancellationToken)
         {
             try
