Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/probe.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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']]
Expand Down
14 changes: 10 additions & 4 deletions docs/static/probe/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 += '<div style="color:#d4880f;font-family:sans-serif;font-weight:600;font-size:10px;margin-bottom:6px;white-space:normal;">Potential double flush</div>';
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>';
if (note) html += '<div class="probe-note">' + escapeAttr(note) + '</div>';
if (req) html += '<div class="probe-label">Request</div>' + escapeAttr(req);
Expand Down Expand Up @@ -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 = '<button class="probe-modal-close" title="Close">&times;</button>';
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>';
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>';
if (note) html += '<div class="probe-note">' + escapeAttr(note) + '</div>';
if (req) html += '<div class="probe-label">Request</div>' + escapeAttr(req);
Expand Down Expand Up @@ -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 '<span style="' + pillCss + cursor + 'background:' + bg + ';"' + extra + '>' + label + '</span>';
var border = doubleFlush ? 'border:2px solid #d4880f;' : '';
return '<span style="' + pillCss + cursor + 'background:' + bg + ';' + border + '"' + extra + '>' + label + '</span>';
}

function verdictBg(v) {
Expand Down Expand Up @@ -738,7 +744,7 @@ window.ProbeRender = (function () {
t += '<td data-test-label="' + escapeAttr(shortLabels[i]) + '" class="' + sepCls + '" style="text-align:center;padding:3px 4px;' + opacity + '">' + pill(SKIP_BG, '\u2014') + '</td>';
return;
}
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>';
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>';
});
t += '</tr>';
});
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion src/Http11Probe.Cli/Reporting/JsonReporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
};

Expand Down
55 changes: 43 additions & 12 deletions src/Http11Probe/Client/RawTcpClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,10 @@ public async Task SendAsync(ReadOnlyMemory<byte> 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;
Expand All @@ -67,6 +67,7 @@ public async Task SendAsync(ReadOnlyMemory<byte> data)

try
{
// Phase 1: Read until we have the complete headers (\r\n\r\n)
while (totalRead < buffer.Length)
{
var read = await _socket.ReceiveAsync(
Expand All @@ -75,29 +76,60 @@ public async Task SendAsync(ReadOnlyMemory<byte> 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);
}
}

/// <summary>
/// Non-blocking drain: reads whatever bytes are already in the socket buffer
/// without waiting for more data to arrive.
/// </summary>
private async Task<int> 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()
Expand All @@ -123,11 +155,10 @@ public ConnectionState CheckConnectionState()
}
}

private static bool ContainsHeaderTerminator(ReadOnlySpan<byte> data)
private static int FindHeaderTerminator(ReadOnlySpan<byte> data)
{
// Look for \r\n\r\n
ReadOnlySpan<byte> terminator = [0x0D, 0x0A, 0x0D, 0x0A];
return data.IndexOf(terminator) >= 0;
return data.IndexOf(terminator);
}

public async ValueTask DisposeAsync()
Expand Down
5 changes: 3 additions & 2 deletions src/Http11Probe/Runner/TestRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ private async Task<TestResult> 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;
Expand All @@ -97,7 +97,7 @@ private async Task<TestResult> 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;
}
Expand All @@ -120,6 +120,7 @@ private async Task<TestResult> RunSingleAsync(TestCase testCase, TestContext con
ConnectionState = connectionState,
BehavioralNote = behavioralNote,
RawRequest = rawRequest,
DrainCaughtData = drainCaughtData,
Duration = sw.Elapsed
};
}
Expand Down
1 change: 1 addition & 0 deletions src/Http11Probe/TestCases/TestResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
Loading