Skip to content

Commit c8cb2e9

Browse files
authored
TestRunner: give servers that advertise Connection: close longer to actually close (#137)
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.
1 parent 04f149e commit c8cb2e9

2 files changed

Lines changed: 33 additions & 5 deletions

File tree

‎src/Http11Probe/Client/RawTcpClient.cs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,22 @@ public ConnectionState CheckConnectionState()
155155
}
156156
}
157157

158+
/// <summary>
159+
/// Polls up to <paramref name="checks"/> times (<paramref name="intervalMs"/> apart), returning as
160+
/// soon as the peer closes. Gives slow-to-close peers — e.g. proxies that tear the socket down shortly
161+
/// after responding — time to actually close, instead of a single early snapshot.
162+
/// </summary>
163+
public async Task<ConnectionState> WaitForCloseAsync(int checks, int intervalMs)
164+
{
165+
for (var i = 0; ; i++)
166+
{
167+
var state = CheckConnectionState();
168+
if (state != ConnectionState.Open || i >= checks)
169+
return state;
170+
await Task.Delay(intervalMs);
171+
}
172+
}
173+
158174
private static int FindHeaderTerminator(ReadOnlySpan<byte> data)
159175
{
160176
ReadOnlySpan<byte> terminator = "\r\n\r\n"u8;

‎src/Http11Probe/Runner/TestRunner.cs‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,7 @@ private async Task<TestResult> RunSingleAsync(TestCase testCase, TestContext con
9898

9999
if (connectionState == ConnectionState.Open)
100100
{
101-
// Brief pause then check if server closed the connection
102-
await Task.Delay(50);
103-
connectionState = client.CheckConnectionState();
101+
connectionState = await ResolveCloseAsync(client, response);
104102
}
105103

106104
var verdict = testCase.Expected.Evaluate(response, connectionState);
@@ -265,8 +263,7 @@ private async Task<TestResult> RunSequenceAsync(SequenceTestCase seq, TestContex
265263

266264
if (connectionState == ConnectionState.Open)
267265
{
268-
await Task.Delay(50);
269-
connectionState = client.CheckConnectionState();
266+
connectionState = await ResolveCloseAsync(client, response);
270267
}
271268

272269
stepResults.Add(new StepResult
@@ -333,4 +330,19 @@ private async Task<TestResult> RunSequenceAsync(SequenceTestCase seq, TestContex
333330
};
334331
}
335332
}
333+
334+
// A server that responded but kept the socket open may still be about to close it — some proxies
335+
// (e.g. Envoy) tear the connection down shortly after responding. Give it a longer window to close
336+
// ONLY when it advertised Connection: close; keep-alive responses stay open by design, so they get a
337+
// brief check (keeps the common path fast, and servers that ignore Connection: close still read Open).
338+
private static async Task<ConnectionState> ResolveCloseAsync(RawTcpClient client, HttpResponse? response)
339+
{
340+
var advertisedClose = response is not null
341+
&& response.Headers.TryGetValue("Connection", out var conn)
342+
&& conn.Contains("close", StringComparison.OrdinalIgnoreCase);
343+
if (advertisedClose)
344+
return await client.WaitForCloseAsync(20, 100); // up to ~2s (Envoy delays close ~1s); returns early once closed
345+
await Task.Delay(50);
346+
return client.CheckConnectionState();
347+
}
336348
}

0 commit comments

Comments
 (0)