-
Notifications
You must be signed in to change notification settings - Fork 5.1k
Drain HTTP/3 response after trailers #116319
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull Request Overview
This PR unifies sync/async disposal in Http3RequestStream, ensures HTTP/3 responses are drained after trailers, and refactors test helper stream usage.
- Merge sync and async
Dispose
implementations into a single async-driven flow. - Introduce
DrainResponseAsync
to consume leftover response data after trailers. - Update
Http3LoopbackStream
in tests to use aStream
property instead of a private field.
Reviewed Changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
File | Description |
---|---|
Http3RequestStream.cs | Unified Dispose logic, added response-drain task and helper. |
Http3LoopbackStream.cs | Replaced _stream field with Stream property and updated refs. |
Comments suppressed due to low confidence (2)
src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3RequestStream.cs:581
- There’s a TODO placeholder here; please add a descriptive comment explaining why it's safe to stop reading after the trailing headers and what extensions are skipped.
// TODO: Add comments here.
src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3RequestStream.cs:1423
- Add a clear comment describing why initiating response drain on trailing headers is correct and what kinds of frames may be skipped safely.
// TODO: add proper comment.
src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3RequestStream.cs
Outdated
Show resolved
Hide resolved
src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3RequestStream.cs
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The routine for draining looks OK with some suggestion for short-circuiting. But how it's handled in Dispose
is not ideal.
Also, we'll need to run the benchmarks from #104035 to make sure this doesn't regress it.
// frames that we are allowed to skip. Just close the stream early. | ||
// We do not expect more DATA frames after the trailers. | ||
// Start draining the response to avoid aborting reads during disposal. | ||
_responseDrainTask = DrainResponseAsync(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So this change will only drain the stream if there are trailers, right?
If the message doesn't have the trailers (ends with DATA frame or is just HEADERS without body and trailers), we still could mistakenly abort the reading side. Or am I overlooking something?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So this change will only drain the stream if there are trailers, right?
Yes. Since #60118 talked about only this specific case, and I didn't consider others. Should we do it? If yes should it happen in this PR?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmmm, what I'm thinking is that if it means just to just plug-in DrainResponseAsync
to few other places then let's do it. On the other hand, if it's more complicated we should probably at least think it through before merging this. Just to make sure we're not doing here something that would have to be undone.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the message doesn't have the trailers (ends with DATA frame or is just HEADERS without body and trailers), we still could mistakenly abort the reading side
I spent more time examining Http3RequestStream
code, and I don't think this could happen. If the message ends with a DATA frame (or an uknown frame we skipped), Http3ReadStream.ReadAsync
will return with the data read into its' buffer. There will be a subsequent read issued against the Http3ReadStream
which will make ReadNextDataFrameAsync
hit an EOS and stop processing data by setting _responseDataPayloadRemaining = -1
in case null
(and then return 0 from Http3ReadStream.Read(Async)
):
runtime/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3RequestStream.cs
Lines 1374 to 1379 in 0d628da
case null: | |
// End of stream. | |
CopyTrailersToResponseMessage(response); | |
_responseDataPayloadRemaining = -1; // Set to -1 to indicate EOS. | |
return false; |
The primary issue with the trailer handling logic on main
is that we execute the code in case null
without actually reading the EOS.
This will short circuit subsequent calls to Http3ReadStream.ReadAsync
to return 0 while in fact never reading the EOS from the underlying QuicStream
:
runtime/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3RequestStream.cs
Lines 1340 to 1344 in 0d628da
if (_responseDataPayloadRemaining == -1) | |
{ | |
// EOS -- this branch will only be taken if user calls Read again after EOS. | |
return false; | |
} |
src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3RequestStream.cs
Outdated
Show resolved
Hide resolved
/// </summary> | ||
private async Task DrainResponseAsync() | ||
{ | ||
HttpConnectionSettings settings = _connection.Pool.Settings; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You could short-circuit this with checking ReadsClosed
. Also I'd prefer this to finish synchronously in the most common scenario.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ReadsClosed.IsCompleted
is never true
at the moment we finish reading the trailers. See my #116319 (comment) above.
src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3RequestStream.cs
Outdated
Show resolved
Hide resolved
} | ||
else | ||
{ | ||
await _stream.DisposeAsync().ConfigureAwait(false); | ||
} | ||
DisposeSyncHelper(); | ||
|
||
_connection.RemoveStream(_stream); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm wondering if we could hit the issue with GC eating the H3 stream while inside WaitForDrainCompletionAndDisposeAsync
. For a reference: https://devblogs.microsoft.com/dotnet/keeping-async-methods-alive/
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see us doing the same in HttpContentReadStream
(which can get unrooted quickly after returning from an HttpRequest.Dispose
):
Line 65 in ff8c934
_ = DrainOnDisposeAsync(); |
I must admit that I'm confused. According to my understanding of the article, the problem is that the state machine is unrooted (thus all its' references to locals and this
), however this doesn't make us worry and run GC.KeepAlive
when we fire-and-forget tasks like at the quoted line.
Edit: changed the comment to keep it on point
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@stephentoub can you please help and give some pointers what should the right approach here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is the question?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm introducing a method here that is usually called in a fire-and-forget manner:
runtime/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http3RequestStream.cs
Lines 156 to 163 in bb85d20
async ValueTask WaitForDrainCompletionAndDisposeAsync() | |
{ | |
Debug.Assert(_responseDrainTask is not null); | |
await _responseDrainTask.ConfigureAwait(false); | |
AbortStream(); | |
await _stream.DisposeAsync().ConfigureAwait(false); | |
_recvBuffer.Dispose(); | |
} |
The owning Http3RequestStream
may become unrooted while WaitForDrainCompletionAndDisposeAsync
is running. Can this result in the GC killing the objects under Http3RequestStream
thus breaking WaitForDrainCompletionAndDisposeAsync
? If the answer is yes, why don't we worry about the same problem in the quoted HttpContentReadStream
code where we fire-and-forget a draining Task in a very similar manner?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As noted in the linked blog post, async method state machine objects are kept alive by the thing they're awaiting. The question then isn't whether Http3RequestStream is rooted, but whether _responseDrainTask will eventually complete and is thus itself rooted (since the only way it could be completed is if something rooted was referencing it in order to complete it).
# Conflicts: # src/libraries/System.Net.Http/tests/FunctionalTests/SocketsHttpHandlerTest.cs
This comment was marked as outdated.
This comment was marked as outdated.
/azp run runtime-libraries stress-http |
Azure Pipelines successfully started running 1 pipeline(s). |
@ManickaP I still owe the benchmark runs so no need to rush with the review, but I reworked the implementation based on observations I made about The new logic will not start |
BenchmarksI did 5-5 consecutive runs Collecting the RPS values, I think the difference is well below the margin of error. (There seems to be quite a high varience.)
|
Fixes #60118.
Merges the sync and async implementation of
Http3RequestStream
disposal, sinceQuicStream.Dispose()
does sync over async anyways.The test changes are built on top of #116113 which is a rather independent and safe change, it would be desireable to merge that PR first.
Note: I wasn't able to reproduce #60118 with the
POST Duplex Slow
stress case. The server-sidePEER_RECEIVE_ABORTED
events seen there were results of cancellation and by settingcancelRate=0
they stopped to occur.GetAsync_TrailersWithoutServerStreamClosure_Success
reproduces the issue without the product code change.