Skip to content

Commit c2383bd

Browse files
authored
Merge pull request #71 from MDA2AV/fix/response-missing-terminator
Add second read from wire
2 parents dfe74ec + 119ad7f commit c2383bd

6 files changed

Lines changed: 60 additions & 19 deletions

File tree

.github/workflows/probe.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ jobs:
160160
'rawRequest': r.get('rawRequest'),
161161
'rawResponse': r.get('rawResponse'),
162162
'behavioralNote': r.get('behavioralNote'),
163+
'doubleFlush': r.get('doubleFlush'),
163164
})
164165
165166
scored_results = [r for r in results if r['scored']]

docs/static/probe/render.js

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -155,9 +155,11 @@ window.ProbeRender = (function () {
155155
tip = document.createElement('div');
156156
tip.className = 'probe-tooltip';
157157
var note = target.getAttribute('data-note');
158+
var dblFlush = target.getAttribute('data-double-flush');
158159
var req = target.getAttribute('data-request');
159160
var truncated = isTruncated(req) || isTruncated(text);
160161
var html = '';
162+
if (dblFlush) html += '<div style="color:#d4880f;font-family:sans-serif;font-weight:600;font-size:10px;margin-bottom:6px;white-space:normal;">Potential double flush</div>';
161163
if (truncated) html += '<div style="color:#f0c674;font-family:sans-serif;font-weight:600;font-size:10px;margin-bottom:6px;white-space:normal;">[Truncated \u2014 payload exceeds display limit]</div>';
162164
if (note) html += '<div class="probe-note">' + escapeAttr(note) + '</div>';
163165
if (req) html += '<div class="probe-label">Request</div>' + escapeAttr(req);
@@ -198,8 +200,10 @@ window.ProbeRender = (function () {
198200
dismissTip();
199201

200202
var note = target.getAttribute('data-note');
203+
var dblFlush = target.getAttribute('data-double-flush');
201204
var truncated = isTruncated(req) || isTruncated(text);
202205
var html = '<button class="probe-modal-close" title="Close">&times;</button>';
206+
if (dblFlush) html += '<div style="color:#d4880f;font-family:sans-serif;font-weight:600;font-size:12px;margin-bottom:8px;white-space:normal;">Potential double flush \u2014 response body arrived in a separate write from the headers</div>';
203207
if (truncated) html += '<div style="color:#f0c674;font-family:sans-serif;font-weight:600;font-size:12px;margin-bottom:8px;white-space:normal;">[Truncated \u2014 payload exceeds display limit]</div>';
204208
if (note) html += '<div class="probe-note">' + escapeAttr(note) + '</div>';
205209
if (req) html += '<div class="probe-label">Request</div>' + escapeAttr(req);
@@ -435,14 +439,16 @@ window.ProbeRender = (function () {
435439
};
436440
function serverUrl(name) { return SERVER_URLS[name] || ''; }
437441

438-
function pill(bg, label, tooltipRaw, tooltipNote, tooltipReq) {
442+
function pill(bg, label, tooltipRaw, tooltipNote, tooltipReq, doubleFlush) {
439443
var extra = '';
440444
var hasData = tooltipRaw || tooltipReq;
441445
if (hasData) extra += ' data-tooltip="' + escapeAttr(tooltipRaw || '') + '"';
442446
if (tooltipNote) extra += ' data-note="' + escapeAttr(tooltipNote) + '"';
443447
if (tooltipReq) extra += ' data-request="' + escapeAttr(tooltipReq) + '"';
448+
if (doubleFlush) extra += ' data-double-flush="1"';
444449
var cursor = hasData ? 'cursor:pointer;' : 'cursor:default;';
445-
return '<span style="' + pillCss + cursor + 'background:' + bg + ';"' + extra + '>' + label + '</span>';
450+
var border = doubleFlush ? 'border:2px solid #d4880f;' : '';
451+
return '<span style="' + pillCss + cursor + 'background:' + bg + ';' + border + '"' + extra + '>' + label + '</span>';
446452
}
447453

448454
function verdictBg(v) {
@@ -738,7 +744,7 @@ window.ProbeRender = (function () {
738744
t += '<td data-test-label="' + escapeAttr(shortLabels[i]) + '" class="' + sepCls + '" style="text-align:center;padding:3px 4px;' + opacity + '">' + pill(SKIP_BG, '\u2014') + '</td>';
739745
return;
740746
}
741-
t += '<td data-test-label="' + escapeAttr(shortLabels[i]) + '" class="' + sepCls + '" style="text-align:center;padding:3px 4px;' + opacity + '">' + pill(verdictBg(r.verdict), r.got, r.rawResponse, r.behavioralNote, r.rawRequest) + '</td>';
747+
t += '<td data-test-label="' + escapeAttr(shortLabels[i]) + '" class="' + sepCls + '" style="text-align:center;padding:3px 4px;' + opacity + '">' + pill(verdictBg(r.verdict), r.got, r.rawResponse, r.behavioralNote, r.rawRequest, r.doubleFlush) + '</td>';
742748
});
743749
t += '</tr>';
744750
});
@@ -840,7 +846,7 @@ window.ProbeRender = (function () {
840846
if (!r) {
841847
gotCell = pill(SKIP_BG, '\u2014');
842848
} else {
843-
gotCell = pill(verdictBg(r.verdict), r.got, r.rawResponse, r.behavioralNote, r.rawRequest);
849+
gotCell = pill(verdictBg(r.verdict), r.got, r.rawResponse, r.behavioralNote, r.rawRequest, r.doubleFlush);
844850
}
845851

846852
var method = r ? methodFromRequest(r.rawRequest) : methodFromRequest(first.rawRequest);

src/Http11Probe.Cli/Reporting/JsonReporter.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ public static string Generate(TestRunReport report)
4545
durationMs = r.Duration.TotalMilliseconds,
4646
rawRequest = r.RawRequest,
4747
rawResponse = r.Response?.RawResponse,
48-
behavioralNote = r.BehavioralNote
48+
behavioralNote = r.BehavioralNote,
49+
doubleFlush = r.DrainCaughtData ? true : (bool?)null
4950
})
5051
};
5152

src/Http11Probe/Client/RawTcpClient.cs

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,10 @@ public async Task SendAsync(ReadOnlyMemory<byte> data)
5555
}
5656
}
5757

58-
public async Task<(byte[] Data, int Length, ConnectionState State)> ReadResponseAsync()
58+
public async Task<(byte[] Data, int Length, ConnectionState State, bool DrainCaughtData)> ReadResponseAsync()
5959
{
6060
if (_socket is null)
61-
return ([], 0, ConnectionState.Error);
61+
return ([], 0, ConnectionState.Error, false);
6262

6363
var buffer = new byte[65536];
6464
var totalRead = 0;
@@ -67,6 +67,7 @@ public async Task SendAsync(ReadOnlyMemory<byte> data)
6767

6868
try
6969
{
70+
// Phase 1: Read until we have the complete headers (\r\n\r\n)
7071
while (totalRead < buffer.Length)
7172
{
7273
var read = await _socket.ReceiveAsync(
@@ -75,29 +76,60 @@ public async Task SendAsync(ReadOnlyMemory<byte> data)
7576
cts.Token);
7677

7778
if (read == 0)
78-
return (buffer, totalRead, ConnectionState.ClosedByServer);
79+
return (buffer, totalRead, ConnectionState.ClosedByServer, false);
7980

8081
totalRead += read;
8182

82-
// Check if we've received the end of headers
83-
if (ContainsHeaderTerminator(buffer.AsSpan(0, totalRead)))
83+
if (FindHeaderTerminator(buffer.AsSpan(0, totalRead)) >= 0)
8484
break;
8585
}
8686

87-
return (buffer, totalRead, ConnectionState.Open);
87+
// Phase 2: Wait briefly for the body to arrive, then drain
88+
await Task.Delay(100, cts.Token);
89+
var beforeDrain = totalRead;
90+
totalRead = await DrainAvailable(buffer, totalRead, cts.Token);
91+
var drainCaughtData = totalRead > beforeDrain;
92+
93+
return (buffer, totalRead, ConnectionState.Open, drainCaughtData);
8894
}
8995
catch (OperationCanceledException)
9096
{
91-
return (buffer, totalRead, ConnectionState.TimedOut);
97+
return (buffer, totalRead, ConnectionState.TimedOut, false);
9298
}
9399
catch (SocketException)
94100
{
95-
return (buffer, totalRead, ConnectionState.ClosedByServer);
101+
return (buffer, totalRead, ConnectionState.ClosedByServer, false);
96102
}
97103
catch
98104
{
99-
return (buffer, totalRead, ConnectionState.Error);
105+
return (buffer, totalRead, ConnectionState.Error, false);
106+
}
107+
}
108+
109+
/// <summary>
110+
/// Non-blocking drain: reads whatever bytes are already in the socket buffer
111+
/// without waiting for more data to arrive.
112+
/// </summary>
113+
private async Task<int> DrainAvailable(byte[] buffer, int totalRead, CancellationToken ct)
114+
{
115+
if (_socket is null) return totalRead;
116+
117+
while (totalRead < buffer.Length)
118+
{
119+
// Poll with zero timeout — returns true only if data is ready right now
120+
if (!_socket.Poll(0, SelectMode.SelectRead))
121+
break;
122+
123+
var read = await _socket.ReceiveAsync(
124+
buffer.AsMemory(totalRead),
125+
SocketFlags.None,
126+
ct);
127+
128+
if (read == 0) break; // peer closed
129+
totalRead += read;
100130
}
131+
132+
return totalRead;
101133
}
102134

103135
public ConnectionState CheckConnectionState()
@@ -123,11 +155,10 @@ public ConnectionState CheckConnectionState()
123155
}
124156
}
125157

126-
private static bool ContainsHeaderTerminator(ReadOnlySpan<byte> data)
158+
private static int FindHeaderTerminator(ReadOnlySpan<byte> data)
127159
{
128-
// Look for \r\n\r\n
129160
ReadOnlySpan<byte> terminator = [0x0D, 0x0A, 0x0D, 0x0A];
130-
return data.IndexOf(terminator) >= 0;
161+
return data.IndexOf(terminator);
131162
}
132163

133164
public async ValueTask DisposeAsync()

src/Http11Probe/Runner/TestRunner.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ private async Task<TestResult> RunSingleAsync(TestCase testCase, TestContext con
8585
await client.SendAsync(payload);
8686

8787
// Read primary response
88-
var (data, length, readState) = await client.ReadResponseAsync();
88+
var (data, length, readState, drainCaughtData) = await client.ReadResponseAsync();
8989
var response = ResponseParser.TryParse(data.AsSpan(), length);
9090

9191
HttpResponse? followUpResponse = null;
@@ -97,7 +97,7 @@ private async Task<TestResult> RunSingleAsync(TestCase testCase, TestContext con
9797
var followUpPayload = testCase.FollowUpPayloadFactory(context);
9898
await client.SendAsync(followUpPayload);
9999

100-
var (fuData, fuLength, fuState) = await client.ReadResponseAsync();
100+
var (fuData, fuLength, fuState, _) = await client.ReadResponseAsync();
101101
followUpResponse = ResponseParser.TryParse(fuData.AsSpan(), fuLength);
102102
connectionState = fuState;
103103
}
@@ -120,6 +120,7 @@ private async Task<TestResult> RunSingleAsync(TestCase testCase, TestContext con
120120
ConnectionState = connectionState,
121121
BehavioralNote = behavioralNote,
122122
RawRequest = rawRequest,
123+
DrainCaughtData = drainCaughtData,
123124
Duration = sw.Elapsed
124125
};
125126
}

src/Http11Probe/TestCases/TestResult.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,6 @@ public sealed class TestResult
1313
public string? ErrorMessage { get; init; }
1414
public string? BehavioralNote { get; init; }
1515
public string? RawRequest { get; init; }
16+
public bool DrainCaughtData { get; init; }
1617
public TimeSpan Duration { get; init; }
1718
}

0 commit comments

Comments
 (0)