Skip to content

[OpenAPI] Avoid double PipeReader.AdvanceTo when reading @body - #9961

Merged
tobias-tengler merged 12 commits into
ChilliCream:mainfrom
hemed:fix/openapi-adapter-double-advanceto
Jun 22, 2026
Merged

tobias-tengler merged 12 commits into
ChilliCream:mainfrom
hemed:fix/openapi-adapter-double-advanceto

Conversation

@hemed

@hemed hemed commented Jun 21, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Every OpenAPI-adapter endpoint that binds the request body via @body
(POST/PUT/PATCH) returns an empty HTTP 500 when hosted on Kestrel.
GET endpoints (no @body) are unaffected.

DynamicEndpointMiddleware.BuildVariablesAsync reads the body like this:

do
{
    result = await body.ReadAsync(cancellationToken);
    body.AdvanceTo(result.Buffer.Start, result.Buffer.End); // advances every iteration
} while (result is { IsCompleted: false, IsCanceled: false });
// ...
var bodyValue = jsonValueParser.Parse(result.Buffer);
variables[bodyVariable] = bodyValue;
body.AdvanceTo(result.Buffer.End);   // second AdvanceTo, no ReadAsync in between

The loop already calls AdvanceTo for the final (completed) ReadResult, then
the post-loop AdvanceTo(result.Buffer.End) advances a second time with no
intervening ReadAsync. That violates the PipeReader contract, so Kestrel
throws:

System.InvalidOperationException: No reading operation to complete.
   at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.Http1ContentLengthMessageBody.AdvanceTo(SequencePosition consumed, SequencePosition examined)
   at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpRequestPipeReader.AdvanceTo(SequencePosition consumed)
   at HotChocolate.Adapters.OpenApi.DynamicEndpointMiddleware.BuildVariablesAsync(...)

The middleware's catch { ... Results.InternalServerError() ... } swallows it,
so the caller sees a bare 500 with nothing logged.

There is also a latent correctness issue: Parse(result.Buffer) ran after
AdvanceTo had already been called for that buffer.

Reproduced on HotChocolate.Adapters.OpenApi 16.1.4, 16.2.2 and 16.3.0-p.1 — the
method is identical in all three.

How to reproduce

Host any GraphQL operation that has an @body variable over POST on Kestrel
(not TestServer — see below) and call it with a JSON body:

mutation CreateUser($user: UserInput! @body)
  @http(method: POST, route: "/users") {
  createUser(user: $user) { id name email }
}
curl -i -X POST http://127.0.0.1:5000/users \
  -H "Content-Type: application/json" \
  -d '{"id":"6","name":"Test","email":"test@example.com"}'
# before this PR: HTTP/1.1 500 (empty body)
# after this PR:  HTTP/1.1 200 with the mapped JSON response

Confirming it is adapter-side and not the operation/schema:

  • The same operation + variables execute fine via POST /graphql (200).
  • A GET endpoint on the same schema works.
  • Wrong Content-Type → 415 and an empty body → 400, so the failure is after
    request validation, inside the body read.
  • Running JsonValueParser.Parse → ValueJsonFormatter.Format over the same
    bytes with a plain ReadOnlySequence<byte> succeeds — the throw only happens
    against a real Kestrel PipeReader.

Fix

Keep the do/while, but only advance while more data is pending. The final
(completed) read is left un-advanced inside the loop, so the single post-loop
AdvanceTo after parsing is the only advance for it:

do
{
    result = await body.ReadAsync(cancellationToken);

    if (result.IsCanceled)
        throw new OperationCanceledException();

    // Only advance while more data is pending; the final (completed)
    // read is advanced once below, after the parser consumes it.
    if (!result.IsCompleted)
        body.AdvanceTo(result.Buffer.Start, result.Buffer.End);

} while (!result.IsCompleted);
// ...
var bodyValue = jsonValueParser.Parse(result.Buffer);
variables[bodyVariable] = bodyValue;
body.AdvanceTo(result.Buffer.End); // single AdvanceTo for the final read

I verified the read pattern in isolation against a real Kestrel host: the
original (double AdvanceTo) endpoint returns 500, the new pattern returns
200 (checked with both a small body and a multi-read 5 MB body).

Tests

Added Endpoints/KestrelHttpEndpointIntegrationTests.cs with POST and PUT
@body regression tests that run on a real Kestrel host (WebHostBuilder +
UseKestrel, dynamic port). They fail before the fix (empty 500) and pass after.

Why the existing tests didn't catch this: HttpEndpointIntegrationTests
(including the POST /users body test) run on ASP.NET TestServer, whose
in-memory request-body PipeReader tolerates the redundant AdvanceTo. Only a
real Kestrel host enforces the PipeReader contract and throws, so a Kestrel-
hosted test is required to guard this.

…Body

DynamicEndpointMiddleware.BuildVariablesAsync advanced the request body
PipeReader on every loop iteration and then advanced it a second time after
the loop, with no intervening ReadAsync. Kestrel's request pipe reader enforces
the PipeReader contract and throws InvalidOperationException ("No reading
operation to complete."), which the middleware's catch-all turns into an empty
500. This broke every OpenAPI endpoint that binds the request body via @Body
(POST/PUT/PATCH).

Restructure the read loop so AdvanceTo is only called inside the loop while more
data is pending, then exactly once after parsing the completed buffer.

The existing integration tests do not catch this because they run on ASP.NET
TestServer, whose in-memory body PipeReader tolerates the redundant AdvanceTo;
only a real Kestrel host reproduces it.
@CLAassistant

CLAassistant commented Jun 21, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@tobias-tengler tobias-tengler left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your contribution @hemed :)

I left a few comments. Once addressed we can get this in!

@tobias-tengler tobias-tengler changed the title fix(adapters-openapi): avoid double PipeReader.AdvanceTo when reading @body [OpenAPI] Avoid double PipeReader.AdvanceTo when reading @body Jun 22, 2026
Addresses review feedback: use braces on the guard clauses, and move the
final AdvanceTo into a finally block so the reader is advanced even when the
empty-body guard or the JSON parse throws — otherwise Kestrel violates the
PipeReader contract while draining the request body.
@hemed

hemed commented Jun 22, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review @tobias-tengler! Addressed all three:

  • Braces added on the guard clauses.
  • Final AdvanceTo moved into a finally block.
  • The finally now also covers the result.Buffer.Length == 0 guard, so the empty-body path advances the reader too.

I re-verified on a real Kestrel host that normal, empty, large (multi-read), and parse-failure request bodies all behave correctly (no "No reading operation to complete.").

@hemed
hemed requested a review from tobias-tengler June 22, 2026 07:28
@tobias-tengler

Copy link
Copy Markdown
Member

@hemed Thanks :)
The new test you've added seems to be failing: https://github.com/ChilliCream/graphql-platform/actions/runs/27936439041/job/82660620573?pr=9961
Could you have a look?

The PUT route is /users/{userId:$user.id}; including id in the body trips the
adapter's 'Unknown field' guard (route-param fields must not also be in the
body). Removing it lets the route segment supply user.id.
@hemed

hemed commented Jun 22, 2026 •

Copy link
Copy Markdown
Contributor Author

@tobias-tengler Can you try running the test again?. Fixed the failing Http_Put_Body_Succeeds_On_Kestrel test. The PUT route is /users/{userId:$user.id}, so id was supplied by the route segment — including it in the body tripped the adapter's "Unknown field 'id'" guard (RewriteObjectValueNode), returning 400. Removed id from the PUT body so the route supplies user.id; the POST test was already green.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants