diff --git a/.github/workflows/probe.yml b/.github/workflows/probe.yml
index ff430d1..97f36c4 100644
--- a/.github/workflows/probe.yml
+++ b/.github/workflows/probe.yml
@@ -160,6 +160,7 @@ jobs:
'rawRequest': r.get('rawRequest'),
'rawResponse': r.get('rawResponse'),
'behavioralNote': r.get('behavioralNote'),
+ 'doubleFlush': r.get('doubleFlush'),
})
scored_results = [r for r in results if r['scored']]
diff --git a/docs/static/probe/render.js b/docs/static/probe/render.js
index c8dfa0c..ddc1999 100644
--- a/docs/static/probe/render.js
+++ b/docs/static/probe/render.js
@@ -155,9 +155,11 @@ window.ProbeRender = (function () {
tip = document.createElement('div');
tip.className = 'probe-tooltip';
var note = target.getAttribute('data-note');
+ var dblFlush = target.getAttribute('data-double-flush');
var req = target.getAttribute('data-request');
var truncated = isTruncated(req) || isTruncated(text);
var html = '';
+ if (dblFlush) html += '
Potential double flush
';
if (truncated) html += '[Truncated \u2014 payload exceeds display limit]
';
if (note) html += '' + escapeAttr(note) + '
';
if (req) html += 'Request
' + escapeAttr(req);
@@ -198,8 +200,10 @@ window.ProbeRender = (function () {
dismissTip();
var note = target.getAttribute('data-note');
+ var dblFlush = target.getAttribute('data-double-flush');
var truncated = isTruncated(req) || isTruncated(text);
var html = '';
+ if (dblFlush) html += 'Potential double flush \u2014 response body arrived in a separate write from the headers
';
if (truncated) html += '[Truncated \u2014 payload exceeds display limit]
';
if (note) html += '' + escapeAttr(note) + '
';
if (req) html += 'Request
' + escapeAttr(req);
@@ -435,14 +439,16 @@ window.ProbeRender = (function () {
};
function serverUrl(name) { return SERVER_URLS[name] || ''; }
- function pill(bg, label, tooltipRaw, tooltipNote, tooltipReq) {
+ function pill(bg, label, tooltipRaw, tooltipNote, tooltipReq, doubleFlush) {
var extra = '';
var hasData = tooltipRaw || tooltipReq;
if (hasData) extra += ' data-tooltip="' + escapeAttr(tooltipRaw || '') + '"';
if (tooltipNote) extra += ' data-note="' + escapeAttr(tooltipNote) + '"';
if (tooltipReq) extra += ' data-request="' + escapeAttr(tooltipReq) + '"';
+ if (doubleFlush) extra += ' data-double-flush="1"';
var cursor = hasData ? 'cursor:pointer;' : 'cursor:default;';
- return '' + label + '';
+ var border = doubleFlush ? 'border:2px solid #d4880f;' : '';
+ return '' + label + '';
}
function verdictBg(v) {
@@ -738,7 +744,7 @@ window.ProbeRender = (function () {
t += '' + pill(SKIP_BG, '\u2014') + ' | ';
return;
}
- t += '' + pill(verdictBg(r.verdict), r.got, r.rawResponse, r.behavioralNote, r.rawRequest) + ' | ';
+ t += '' + pill(verdictBg(r.verdict), r.got, r.rawResponse, r.behavioralNote, r.rawRequest, r.doubleFlush) + ' | ';
});
t += '';
});
@@ -840,7 +846,7 @@ window.ProbeRender = (function () {
if (!r) {
gotCell = pill(SKIP_BG, '\u2014');
} else {
- gotCell = pill(verdictBg(r.verdict), r.got, r.rawResponse, r.behavioralNote, r.rawRequest);
+ gotCell = pill(verdictBg(r.verdict), r.got, r.rawResponse, r.behavioralNote, r.rawRequest, r.doubleFlush);
}
var method = r ? methodFromRequest(r.rawRequest) : methodFromRequest(first.rawRequest);
diff --git a/src/Http11Probe.Cli/Reporting/JsonReporter.cs b/src/Http11Probe.Cli/Reporting/JsonReporter.cs
index c09e7ea..debbc04 100644
--- a/src/Http11Probe.Cli/Reporting/JsonReporter.cs
+++ b/src/Http11Probe.Cli/Reporting/JsonReporter.cs
@@ -45,7 +45,8 @@ public static string Generate(TestRunReport report)
durationMs = r.Duration.TotalMilliseconds,
rawRequest = r.RawRequest,
rawResponse = r.Response?.RawResponse,
- behavioralNote = r.BehavioralNote
+ behavioralNote = r.BehavioralNote,
+ doubleFlush = r.DrainCaughtData ? true : (bool?)null
})
};
diff --git a/src/Http11Probe/Client/RawTcpClient.cs b/src/Http11Probe/Client/RawTcpClient.cs
index 9f6817b..74a8264 100644
--- a/src/Http11Probe/Client/RawTcpClient.cs
+++ b/src/Http11Probe/Client/RawTcpClient.cs
@@ -55,10 +55,10 @@ public async Task SendAsync(ReadOnlyMemory data)
}
}
- public async Task<(byte[] Data, int Length, ConnectionState State)> ReadResponseAsync()
+ public async Task<(byte[] Data, int Length, ConnectionState State, bool DrainCaughtData)> ReadResponseAsync()
{
if (_socket is null)
- return ([], 0, ConnectionState.Error);
+ return ([], 0, ConnectionState.Error, false);
var buffer = new byte[65536];
var totalRead = 0;
@@ -67,6 +67,7 @@ public async Task SendAsync(ReadOnlyMemory data)
try
{
+ // Phase 1: Read until we have the complete headers (\r\n\r\n)
while (totalRead < buffer.Length)
{
var read = await _socket.ReceiveAsync(
@@ -75,29 +76,60 @@ public async Task SendAsync(ReadOnlyMemory data)
cts.Token);
if (read == 0)
- return (buffer, totalRead, ConnectionState.ClosedByServer);
+ return (buffer, totalRead, ConnectionState.ClosedByServer, false);
totalRead += read;
- // Check if we've received the end of headers
- if (ContainsHeaderTerminator(buffer.AsSpan(0, totalRead)))
+ if (FindHeaderTerminator(buffer.AsSpan(0, totalRead)) >= 0)
break;
}
- return (buffer, totalRead, ConnectionState.Open);
+ // Phase 2: Wait briefly for the body to arrive, then drain
+ await Task.Delay(100, cts.Token);
+ var beforeDrain = totalRead;
+ totalRead = await DrainAvailable(buffer, totalRead, cts.Token);
+ var drainCaughtData = totalRead > beforeDrain;
+
+ return (buffer, totalRead, ConnectionState.Open, drainCaughtData);
}
catch (OperationCanceledException)
{
- return (buffer, totalRead, ConnectionState.TimedOut);
+ return (buffer, totalRead, ConnectionState.TimedOut, false);
}
catch (SocketException)
{
- return (buffer, totalRead, ConnectionState.ClosedByServer);
+ return (buffer, totalRead, ConnectionState.ClosedByServer, false);
}
catch
{
- return (buffer, totalRead, ConnectionState.Error);
+ return (buffer, totalRead, ConnectionState.Error, false);
+ }
+ }
+
+ ///
+ /// Non-blocking drain: reads whatever bytes are already in the socket buffer
+ /// without waiting for more data to arrive.
+ ///
+ private async Task DrainAvailable(byte[] buffer, int totalRead, CancellationToken ct)
+ {
+ if (_socket is null) return totalRead;
+
+ while (totalRead < buffer.Length)
+ {
+ // Poll with zero timeout — returns true only if data is ready right now
+ if (!_socket.Poll(0, SelectMode.SelectRead))
+ break;
+
+ var read = await _socket.ReceiveAsync(
+ buffer.AsMemory(totalRead),
+ SocketFlags.None,
+ ct);
+
+ if (read == 0) break; // peer closed
+ totalRead += read;
}
+
+ return totalRead;
}
public ConnectionState CheckConnectionState()
@@ -123,11 +155,10 @@ public ConnectionState CheckConnectionState()
}
}
- private static bool ContainsHeaderTerminator(ReadOnlySpan data)
+ private static int FindHeaderTerminator(ReadOnlySpan data)
{
- // Look for \r\n\r\n
ReadOnlySpan terminator = [0x0D, 0x0A, 0x0D, 0x0A];
- return data.IndexOf(terminator) >= 0;
+ return data.IndexOf(terminator);
}
public async ValueTask DisposeAsync()
diff --git a/src/Http11Probe/Runner/TestRunner.cs b/src/Http11Probe/Runner/TestRunner.cs
index 63a4fe7..7b7bbce 100644
--- a/src/Http11Probe/Runner/TestRunner.cs
+++ b/src/Http11Probe/Runner/TestRunner.cs
@@ -85,7 +85,7 @@ private async Task RunSingleAsync(TestCase testCase, TestContext con
await client.SendAsync(payload);
// Read primary response
- var (data, length, readState) = await client.ReadResponseAsync();
+ var (data, length, readState, drainCaughtData) = await client.ReadResponseAsync();
var response = ResponseParser.TryParse(data.AsSpan(), length);
HttpResponse? followUpResponse = null;
@@ -97,7 +97,7 @@ private async Task RunSingleAsync(TestCase testCase, TestContext con
var followUpPayload = testCase.FollowUpPayloadFactory(context);
await client.SendAsync(followUpPayload);
- var (fuData, fuLength, fuState) = await client.ReadResponseAsync();
+ var (fuData, fuLength, fuState, _) = await client.ReadResponseAsync();
followUpResponse = ResponseParser.TryParse(fuData.AsSpan(), fuLength);
connectionState = fuState;
}
@@ -120,6 +120,7 @@ private async Task RunSingleAsync(TestCase testCase, TestContext con
ConnectionState = connectionState,
BehavioralNote = behavioralNote,
RawRequest = rawRequest,
+ DrainCaughtData = drainCaughtData,
Duration = sw.Elapsed
};
}
diff --git a/src/Http11Probe/TestCases/TestResult.cs b/src/Http11Probe/TestCases/TestResult.cs
index 79e1bcc..6f3467d 100644
--- a/src/Http11Probe/TestCases/TestResult.cs
+++ b/src/Http11Probe/TestCases/TestResult.cs
@@ -13,5 +13,6 @@ public sealed class TestResult
public string? ErrorMessage { get; init; }
public string? BehavioralNote { get; init; }
public string? RawRequest { get; init; }
+ public bool DrainCaughtData { get; init; }
public TimeSpan Duration { get; init; }
}