-
Notifications
You must be signed in to change notification settings - Fork 10.2k
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
Added support for binding the raw request body #39388
Conversation
- Added support for Stream, ReadOnlySequence<byte>, ReadOnlyMemory<byte> and byte[] - Added test
// REVIEW: Does this need to be a copy? We can tell users to consume the buffer | ||
// immediately in the action | ||
return (buffer, true); |
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.
When is PipeReader.AdvanceTo
called to say the body is consumed? Before or after the request delegate is executed?
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.
After thinking about this, it's actually broken for non Kestrel servers since nobody completes that PipeReader currently. I'll need to add more logic there. Good catch.
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.
Hmm actually no, it seems like we complete the pipe reader in both cases
aspnetcore/src/Http/Http/src/Features/RequestBodyPipeFeature.cs
Lines 35 to 48 in 694507a
if (_internalPipeReader == null || | |
!ReferenceEquals(_streamInstanceWhenWrapped, _context.Request.Body)) | |
{ | |
_streamInstanceWhenWrapped = _context.Request.Body; | |
_internalPipeReader = PipeReader.Create(_context.Request.Body); | |
_context.Response.OnCompleted((self) => | |
{ | |
((PipeReader)self).Complete(); | |
return Task.CompletedTask; | |
}, _internalPipeReader); | |
} | |
return _internalPipeReader; |
- Advance the PipeReader preemptively in the cases where we copy the buffer. - Add support for PipeReader as an argument.
- Split the code path for raw body binding with ReadOnlySequence<byte> to avoid boxing.
|
||
if (result.IsCompleted) | ||
{ | ||
return buffer; |
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 think calling the route handler with the reader in an incomplete/unadvanced state is problematic. Any code accessing the BodyReader
, either through the HttpContext
or as a separate PipeReader
parameter, will be unable to partially examine the body. Any attempt will throw an InvalidOperationException complaining that "The examined position cannot be less than the previously examined position."
I think we're better off just fully consuming the body if you take a ReadOnlySequence<byte>
parameter.
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 don’t want to copy but I appreciate the potential problems. I’d rather leave that with the caveats or cut the whole feature (which would make me sad but I’d get over it).
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.
Given this endpoint:
app.MapPost("/read-twice", async (HttpContext httpContext, ReadOnlySequence<byte> body) =>
{
await httpContext.Request.Body.ReadAsync(new byte[1], 0, 0);
});
you get the following error:
System.InvalidOperationException: Reading is already in progress.
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.Http1ContentLengthMessageBody.ReadAsyncInternal(CancellationToken cancellationToken) in C:\dev\dotnet\aspnetcore\src\Servers\Kestrel\Core\src\Internal\Http\Http1ContentLengthMessageBody.cs:line 34
at System.Runtime.CompilerServices.PoolingAsyncValueTaskMethodBuilder`1.StateMachineBox`1.System.Threading.Tasks.Sources.IValueTaskSource<TResult>.GetResult(Int16 token)
at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpRequestStream.ReadAsyncInternal(Memory`1 destination, CancellationToken cancellationToken) in C:\dev\dotnet\aspnetcore\src\Servers\Kestrel\Core\src\Internal\Http\HttpRequestStream.cs:line 114
at Program.<>c.<<<Main>$>b__0_7>d.MoveNext() in C:\dev\dotnet\aspnetcore\src\Http\samples\MinimalSample\Program.cs:line 42
--- End of stack trace from previous location ---
at Microsoft.AspNetCore.Http.RequestDelegateFactory.<>c__DisplayClass54_1.<<HandleRequestBodyAndCompileRequestDelegateForRawBody>b__2>d.MoveNext() in C:\dev\dotnet\aspnetcore\src\Http\Http.Extensions\src\RequestDelegateFactory.cs:line 647
I don't think this is acceptable. We should either:
- Consume the entire body so this read returns 0 (edit: This would require a copy anyway to avoid referencing memory that's already returned to the pool, so at this point we might as well do option 2)
- Buffer the entire body so you can actually read it again
- Poison the body so that it throws a better error than
System.InvalidOperationException: Reading is already in progress.
- Cut the whole feature
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.
OK I pushed another commit with option 3a. I set the body to Stream.Null
. This fixes trying to consume the body after it's already been consumed by our binding logic.
|
||
// Looping over arrays is faster | ||
var binders = factoryContext.ParameterBinders.ToArray(); | ||
var count = binders.Length; |
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 capture this inside the lamda, no?
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 originally didn’t do it would only capture these locals only (in case we nulled out the factory context later).
|
||
private static Func<object?, HttpContext, Task> HandleRequestBodyAndCompileRequestDelegateForRawBody(Expression responseWritingMethodCall, FactoryContext factoryContext) | ||
{ | ||
Debug.Assert(factoryContext.RequestBodyParameter is not null, "factoryContext.RequestBodyParameter is null for a body parameter."); |
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.
Did you mean
Debug.Assert(factoryContext.RequestBodyParameter is not null, "factoryContext.RequestBodyParameter is null for a body parameter."); | |
Debug.Assert(factoryContext.RequestBodyParameter is null, "factoryContext.RequestBodyParameter is null for a body parameter."); |
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.
Don't think so. We're asserting that it isn't null
if (factoryContext.ParameterBinders.Count > 0) | ||
{ | ||
// We need to generate the code for reading from the body before calling into the delegate | ||
var continuation = Expression.Lambda<Func<object?, HttpContext, ReadOnlySequence<byte>, object?[], Task>>( |
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.
Noob question - why is this task returning (vs ValueTask returning)?
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.
This could be either but using Task vs ValueTask but there’s no benefit to using ValueTask here. There’s no pooling to be had and synchronous completion won’t allocate regardless
{ | ||
var feature = httpContext.Features.Get<IHttpRequestBodyDetectionFeature>(); | ||
|
||
if (feature?.CanHaveBody == true) |
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's the behavior with JSON if there isn't a body to read from? We should try and emulate that behavior.
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 allow empty body is true it’ll bind otherwise 400
httpContext.Items.Add("body", body.ToArray()); | ||
} | ||
|
||
void TestStream(HttpContext httpContext, Stream 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.
Neat.
public bool AllowEmptyRequestBody { get; set; } | ||
public bool IsRawRequestBody => RequestBodyParameter?.ParameterType == typeof(ReadOnlySequence<byte>); |
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.
It'd be interesting to see if people actually start using it. It's a somewhat obtuse type to actually use (particularly when you can bind the PipeReader instead).
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.
It’s something we’d have to advertise. I had support for ReadOnlyMemory<byte> and byte[] but removed them.
- This prevents code that would otherwise read the body from doing so after consuming it.
// We're not buffering the body so we want to block consuming code from reading again | ||
// and getting weird errors. Treat further reads as a fully consumed body. | ||
httpContext.Request.Body = Stream.Null; |
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.
This is odd, what if someone else is buffering the body?
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 guess you'd have to check if the body is seekeable, if so seek it. RadFormAsync / MVC doesn't change / reset the Body once it has been read, couldn't we do the same 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.
I think seeking given a seekable stream is fine.
If we're replacing the body anyway, we should advance the reader before returning even if Kestrel will do it anyway.
We would have to do this after invoking the route handler, so memory doesn't get returned too soon. I'm still worried about people keeping a reference to the ReadOnlySequence<byte>
after the route handler completes if we don't copy.
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 we're replacing the body anyway, we should advance the reader before returning even if Kestrel will do it anyway.
Just for cleanliness? It's not just Kestrel doing it, it would happen for any properly implemented pipereader (it will complete the pipereader).
I'm still worried about people keeping a reference to the ReadOnlySequence after the route handler completes if we don't copy.
Yea but this feature is about not copying the buffer. That's why it's a ROS<byte>
. That's the one thing I'm unwilling to compromise here on as I think it makes the original scenario (posted in the issue) less efficient.
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.
This is odd, what if someone else is buffering the body?
I don't see how that affects this? I can revert and set the the original stream after executing the handler. The body is being buffered for the logic that is consuming the buffer.
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.
We have the reference to the pipe that we read and we can complete it. It's just redundant is all. Only a badly written application would end up never completing the pipe anyways. It would also need to be explicit written to do so.
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.
It's more about generic code that might try to read any unconsumed bytes from the body after the route handler runs but before middleware completes.
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.
Also @Tratcher I don't think we should check is seekable here. Specifically because we need to poison both the stream and the PipeReader to be unusable 😄. Also our buffering logic doesn't work if you don't read using the Stream AFAIK.
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.
At least revert it when you're done then.
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.
Done.
- Restore the original stream after the handler executes.
This PR is ready now. I removed reading the request body as a raw buffer and am only supporting Stream and PipeReader. |
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.
Looks a lot simpler than before 😃
Do we have an issue to track doc updates for .NET 7? We need to add IFormFileCollection, PipeReader, and Stream to https://docs.microsoft.com/aspnet/core/fundamentals/minimal-apis?view=aspnetcore-6.0#special-types now
We don't have one yet. Good idea. |
- Added support for Stream, and PipeReader - Added tests
ReadOnlySequence, ReadOnlyMemory and byte[]This will potentially break custom JSON converters for byte[] (maybe base64 JSON encoded content?)Fixes #38153
TBD: Fix the error messages that talk about JSON binding when these types are being used.