From faa05863ca1dbf4a5b928e59bf64b37835d2f699 Mon Sep 17 00:00:00 2001 From: Diogo Martins Date: Sat, 11 Jul 2026 16:56:51 +0100 Subject: [PATCH] TestRunner: give servers that advertise Connection: close longer to actually close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The close check polled the socket once ~50ms after the response. Envoy advertises Connection: close but (as a proxy, delayed_close_timeout defaults to ~1s) tears the socket down ~1s later, so it read as still-Open and failed close-expected tests. Now, when the response carries Connection: close, poll up to ~2s (RawTcpClient.WaitForCloseAsync, returns early once closed); keep-alive responses keep the brief 50ms check. Validated locally against Envoy: COMP-CONNECTION-CLOSE flips to pass (close now detected at ~1s), while genuine failures — where Envoy returns 200 keep-alive to malformed input and never advertises close — correctly stay failed. --- src/Http11Probe/Client/RawTcpClient.cs | 16 ++++++++++++++++ src/Http11Probe/Runner/TestRunner.cs | 22 +++++++++++++++++----- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/Http11Probe/Client/RawTcpClient.cs b/src/Http11Probe/Client/RawTcpClient.cs index 96fa84e..e5a16b1 100644 --- a/src/Http11Probe/Client/RawTcpClient.cs +++ b/src/Http11Probe/Client/RawTcpClient.cs @@ -155,6 +155,22 @@ public ConnectionState CheckConnectionState() } } + /// + /// Polls up to times ( apart), returning as + /// soon as the peer closes. Gives slow-to-close peers — e.g. proxies that tear the socket down shortly + /// after responding — time to actually close, instead of a single early snapshot. + /// + public async Task WaitForCloseAsync(int checks, int intervalMs) + { + for (var i = 0; ; i++) + { + var state = CheckConnectionState(); + if (state != ConnectionState.Open || i >= checks) + return state; + await Task.Delay(intervalMs); + } + } + private static int FindHeaderTerminator(ReadOnlySpan data) { ReadOnlySpan terminator = "\r\n\r\n"u8; diff --git a/src/Http11Probe/Runner/TestRunner.cs b/src/Http11Probe/Runner/TestRunner.cs index 3035957..ff4f80f 100644 --- a/src/Http11Probe/Runner/TestRunner.cs +++ b/src/Http11Probe/Runner/TestRunner.cs @@ -98,9 +98,7 @@ private async Task RunSingleAsync(TestCase testCase, TestContext con if (connectionState == ConnectionState.Open) { - // Brief pause then check if server closed the connection - await Task.Delay(50); - connectionState = client.CheckConnectionState(); + connectionState = await ResolveCloseAsync(client, response); } var verdict = testCase.Expected.Evaluate(response, connectionState); @@ -265,8 +263,7 @@ private async Task RunSequenceAsync(SequenceTestCase seq, TestContex if (connectionState == ConnectionState.Open) { - await Task.Delay(50); - connectionState = client.CheckConnectionState(); + connectionState = await ResolveCloseAsync(client, response); } stepResults.Add(new StepResult @@ -333,4 +330,19 @@ private async Task RunSequenceAsync(SequenceTestCase seq, TestContex }; } } + + // A server that responded but kept the socket open may still be about to close it — some proxies + // (e.g. Envoy) tear the connection down shortly after responding. Give it a longer window to close + // ONLY when it advertised Connection: close; keep-alive responses stay open by design, so they get a + // brief check (keeps the common path fast, and servers that ignore Connection: close still read Open). + private static async Task ResolveCloseAsync(RawTcpClient client, HttpResponse? response) + { + var advertisedClose = response is not null + && response.Headers.TryGetValue("Connection", out var conn) + && conn.Contains("close", StringComparison.OrdinalIgnoreCase); + if (advertisedClose) + return await client.WaitForCloseAsync(20, 100); // up to ~2s (Envoy delays close ~1s); returns early once closed + await Task.Delay(50); + return client.CheckConnectionState(); + } }