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; } }