Skip to content

Commit 0c7d43e

Browse files
authored
Http3RequestStream: read EOS after trailers (#117026)
This fixes the primary bug captured in #60118 without attempting to drain the stream and without touching the Dispose logic. We expect trailers to be sent in a single HEADER frame, which is read by `ReadHeadersAsync` but that method doesn't read the EOS. Despite of that, we set `_responseDataPayloadRemaining = -1` afterwards https://github.com/dotnet/runtime/blob/27c8fe04453195039686fb375a73fac7ff1ea968/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3RequestStream.cs#L577-L583 which then makes `Http3RequestStream` pretend that it reached EOS in subsequent read calls without ever trying to issue the read to the underlying `QuicStream`: https://github.com/dotnet/runtime/blob/0d628dae1cb5b8d0eb76d9549134edfe30d6219c/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3RequestStream.cs#L1340-L1344 This can be easily fixed by issuing one more read after reading the headers.
1 parent 5545d52 commit 0c7d43e

File tree

2 files changed

+87
-18
lines changed

2 files changed

+87
-18
lines changed

src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3RequestStream.cs

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -566,14 +566,9 @@ private async ValueTask DrainContentLength0Frames(CancellationToken cancellation
566566
switch (frameType)
567567
{
568568
case Http3FrameType.Headers:
569-
// Pick up any trailing headers.
570-
_trailingHeaders = new List<(HeaderDescriptor name, string value)>();
571-
await ReadHeadersAsync(payloadLength, cancellationToken).ConfigureAwait(false);
572-
573-
// Stop looping after a trailing header.
574-
// There may be extra frames after this one, but they would all be unknown extension
575-
// frames that can be safely ignored. Just stop reading here.
576-
// Note: this does leave us open to a bad server sending us an out of order DATA frame.
569+
// Pick up any trailing headers and stop processing.
570+
await ProcessTrailersAsync(payloadLength, cancellationToken).ConfigureAwait(false);
571+
577572
goto case null;
578573
case null:
579574
// Done receiving: copy over trailing headers.
@@ -601,6 +596,25 @@ private async ValueTask DrainContentLength0Frames(CancellationToken cancellation
601596
}
602597
}
603598

599+
private async ValueTask ProcessTrailersAsync(long payloadLength, CancellationToken cancellationToken)
600+
{
601+
_trailingHeaders = new List<(HeaderDescriptor name, string value)>();
602+
await ReadHeadersAsync(payloadLength, cancellationToken).ConfigureAwait(false);
603+
604+
// In typical cases, there should be no more frames. Make sure to read the EOS.
605+
_recvBuffer.EnsureAvailableSpace(1);
606+
int bytesRead = await _stream.ReadAsync(_recvBuffer.AvailableMemory, cancellationToken).ConfigureAwait(false);
607+
if (bytesRead > 0)
608+
{
609+
// The server may send us frames of unknown types after the trailer. Ideally we should drain the response by eating and ignoring them
610+
// but this is a rare case so we just stop reading and let Dispose() send an ABORT_RECEIVE.
611+
// Note: if a server sends additional HEADERS or DATA frames at this point, it
612+
// would be a connection error -- not draining the stream also means we won't catch this.
613+
_recvBuffer.Commit(bytesRead);
614+
_recvBuffer.Discard(bytesRead);
615+
}
616+
}
617+
604618
private void CopyTrailersToResponseMessage(HttpResponseMessage responseMessage)
605619
{
606620
if (_trailingHeaders?.Count > 0)
@@ -1367,15 +1381,9 @@ private async ValueTask<bool> ReadNextDataFrameAsync(HttpResponseMessage respons
13671381
_responseDataPayloadRemaining = payloadLength;
13681382
return true;
13691383
case Http3FrameType.Headers:
1370-
// Read any trailing headers.
1371-
_trailingHeaders = new List<(HeaderDescriptor name, string value)>();
1372-
await ReadHeadersAsync(payloadLength, cancellationToken).ConfigureAwait(false);
1373-
1374-
// There may be more frames after this one, but they would all be unknown extension
1375-
// frames that we are allowed to skip. Just close the stream early.
1384+
// Pick up any trailing headers and stop processing.
1385+
await ProcessTrailersAsync(payloadLength, cancellationToken).ConfigureAwait(false);
13761386

1377-
// Note: if a server sends additional HEADERS or DATA frames at this point, it
1378-
// would be a connection error -- not draining the stream means we won't catch this.
13791387
goto case null;
13801388
case null:
13811389
// End of stream.

src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,9 @@
77
using System.IO.Pipes;
88
using System.Linq;
99
using System.Net.Http.Headers;
10-
using System.Net.Quic;
1110
using System.Net.Security;
1211
using System.Net.Sockets;
1312
using System.Net.Test.Common;
14-
using System.Numerics;
1513
using System.Reflection;
1614
using System.Runtime.CompilerServices;
1715
using System.Security.Authentication;
@@ -1285,6 +1283,69 @@ protected override async Task AcceptConnectionAndSendResponseAsync(
12851283
await stream.SendResponseHeadersAsync(statusCode: null, headers: trailers);
12861284
stream.Stream.CompleteWrites();
12871285
}
1286+
1287+
[Theory]
1288+
[InlineData(false, HttpCompletionOption.ResponseContentRead)]
1289+
[InlineData(false, HttpCompletionOption.ResponseHeadersRead)]
1290+
[InlineData(true, HttpCompletionOption.ResponseContentRead)]
1291+
[InlineData(true, HttpCompletionOption.ResponseHeadersRead)]
1292+
public async Task GetAsync_TrailersWithoutServerStreamClosure_Success(bool emptyResponse, HttpCompletionOption httpCompletionOption)
1293+
{
1294+
SemaphoreSlim serverCompleted = new SemaphoreSlim(0);
1295+
1296+
await LoopbackServerFactory.CreateClientAndServerAsync(async uri =>
1297+
{
1298+
HttpClientHandler handler = CreateHttpClientHandler();
1299+
1300+
// Avoid drain timeout if CI is slow.
1301+
GetUnderlyingSocketsHttpHandler(handler).ResponseDrainTimeout = TimeSpan.FromSeconds(10);
1302+
using HttpClient client = CreateHttpClient(handler);
1303+
1304+
using (HttpResponseMessage response = await client.GetAsync(uri, httpCompletionOption))
1305+
{
1306+
if (httpCompletionOption == HttpCompletionOption.ResponseHeadersRead)
1307+
{
1308+
using Stream stream = await response.Content.ReadAsStreamAsync();
1309+
byte[] buffer = new byte[512];
1310+
// Consume the stream
1311+
while ((await stream.ReadAsync(buffer)) > 0) ;
1312+
}
1313+
1314+
Assert.Equal(TrailingHeaders.Count, response.TrailingHeaders.Count());
1315+
}
1316+
1317+
await serverCompleted.WaitAsync();
1318+
},
1319+
async server =>
1320+
{
1321+
try
1322+
{
1323+
await using Http3LoopbackConnection connection = (Http3LoopbackConnection)await server.EstablishGenericConnectionAsync();
1324+
await using Http3LoopbackStream stream = await connection.AcceptRequestStreamAsync();
1325+
_ = await stream.ReadRequestDataAsync();
1326+
1327+
HttpHeaderData[] headers = emptyResponse ? [new HttpHeaderData("Content-Length", "0")] : null;
1328+
1329+
await stream.SendResponseHeadersAsync(statusCode: HttpStatusCode.OK, headers);
1330+
if (!emptyResponse)
1331+
{
1332+
await stream.SendResponseBodyAsync(new byte[16384], isFinal: false);
1333+
}
1334+
1335+
await stream.SendResponseHeadersAsync(statusCode: null, headers: TrailingHeaders);
1336+
1337+
// Small delay to make sure we do test if the client is waiting for EOS.
1338+
await Task.Delay(15);
1339+
1340+
await stream.DisposeAsync();
1341+
await stream.Stream.WritesClosed;
1342+
}
1343+
finally
1344+
{
1345+
serverCompleted.Release();
1346+
}
1347+
}).WaitAsync(TimeSpan.FromSeconds(30));
1348+
}
12881349
}
12891350

12901351
public sealed class SocketsHttpHandler_HttpClientHandlerTest : HttpClientHandlerTest

0 commit comments

Comments
 (0)