Skip to content

Conversation

@BrennanConroy
Copy link
Member

@BrennanConroy BrennanConroy commented Jul 24, 2025

Use PipeReader JsonSerializer overloads

Description

Updates MVC and HttpRequestJsonExtensions (minimal APIs and user code) to use the new PipeReader overloads on JsonSerializer.DeserializeAsync. Because the ReadOnlySequence property on Utf8JsonReader is now being exercised, we're being cautious of custom JsonConverter implementations that don't properly handle that scenario, by providing an AppContext switch to go back to the Stream based overloads. We'll remove the switch in 11.0.

Closes #60657

Customer Impact

Decreased buffer pool usage and byte copying. Also, finishes the work we started in 9.0 to integrate System.IO.Pipelines into Json as an alternative to using Stream.

Regression?

  • Yes
  • No

Risk

  • High
  • Medium
  • Low

Change at the JSON layer has been well tested. Decent test coverage for the HTTP layer that makes use of JSON. We also have added an AppContext switch to go back to the previous code as we anticipate user code having bugs in custom JsonConverter implementations.

Verification

  • Manual (required)
  • Automated

Packaging changes reviewed?

  • Yes
  • No
  • N/A

@BrennanConroy BrennanConroy added the area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates label Jul 24, 2025
Copilot AI review requested due to automatic review settings July 24, 2025 01:04
@BrennanConroy BrennanConroy added the area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc label Jul 24, 2025
@BrennanConroy BrennanConroy requested review from a team, captainsafia and halter73 as code owners July 24, 2025 01:04
Copy link
Contributor

Copilot AI left a 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 updates ASP.NET Core's JSON deserialization to use the new PipeReader overloads on JsonSerializer.DeserializeAsync instead of the existing Stream overloads for better performance. The changes include an AppContext switch (Microsoft.AspNetCore.UseStreamBasedJsonParsing) to allow fallback to the old Stream based overloads for backwards compatibility with custom JsonConverter implementations that may not handle ReadOnlySequence properly.

  • Adds AppContext switch for backward compatibility with custom JsonConverter implementations
  • Updates JSON deserialization to use PipeReader overloads by default for UTF-8 encoding
  • Refactors resource management to only dispose transcoding streams when necessary

Reviewed Changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonInputFormatter.cs Updates MVC input formatter to use PipeReader JsonSerializer overloads with AppContext switch
src/Http/Http.Extensions/src/HttpRequestJsonExtensions.cs Updates HTTP request JSON extensions to use PipeReader overloads with AppContext switch
src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.BindAsync.cs Skips test that relies on Stream.Position reset behavior
src/Http/Http.Extensions/test/HttpRequestJsonExtensionsTests.cs Updates test assertion for broader exception type

// Fallback to the stream-based overloads for JsonSerializer.DeserializeAsync
// This is to give users with custom JsonConverter implementations the chance to update their
// converters to support ReadOnlySequence<T> if needed while still keeping their apps working.
_useStreamJsonOverload = AppContext.TryGetSwitch("Microsoft.AspNetCore.UseStreamBasedJsonParsing", out var isEnabled) && isEnabled;
Copy link

Copilot AI Jul 24, 2025

Choose a reason for hiding this comment

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

The AppContext switch name contains a typo or inconsistency. The switch is named 'UseStreamBasedJsonParsing' but the behavior is actually controlling whether to use Stream vs PipeReader overloads. Consider renaming to 'Microsoft.AspNetCore.Json.UseStreamOverloads' for clarity.

Suggested change
_useStreamJsonOverload = AppContext.TryGetSwitch("Microsoft.AspNetCore.UseStreamBasedJsonParsing", out var isEnabled) && isEnabled;
_useStreamJsonOverload =
(AppContext.TryGetSwitch("Microsoft.AspNetCore.Json.UseStreamOverloads", out var isNewEnabled) && isNewEnabled) ||
(AppContext.TryGetSwitch("Microsoft.AspNetCore.UseStreamBasedJsonParsing", out var isOldEnabled) && isOldEnabled);

Copilot uses AI. Check for mistakes.
// Fallback to the stream-based overloads for JsonSerializer.DeserializeAsync
// This is to give users with custom JsonConverter implementations the chance to update their
// converters to support ReadOnlySequence<T> if needed while still keeping their apps working.
private static readonly bool _useStreamJsonOverload = AppContext.TryGetSwitch("Microsoft.AspNetCore.UseStreamBasedJsonParsing", out var isEnabled) && isEnabled;
Copy link

Copilot AI Jul 24, 2025

Choose a reason for hiding this comment

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

The same AppContext switch name issue exists here. Both classes use the same switch name but it should be consistent and clearly indicate its purpose of choosing between Stream and PipeReader overloads.

Suggested change
private static readonly bool _useStreamJsonOverload = AppContext.TryGetSwitch("Microsoft.AspNetCore.UseStreamBasedJsonParsing", out var isEnabled) && isEnabled;
private static readonly bool _useStreamJsonOverload = AppContext.TryGetSwitch("Microsoft.AspNetCore.Json.UseStreamOverPipeReader", out var isEnabled) && isEnabled;

Copilot uses AI. Check for mistakes.
Copy link
Member

@captainsafia captainsafia left a comment

Choose a reason for hiding this comment

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

LGTM assuming fix for skipped test is forthcoming.

try
{
return await JsonSerializer.DeserializeAsync<TValue>(inputStream, options, cancellationToken);
if (encoding == null || encoding.CodePage == Encoding.UTF8.CodePage)
Copy link
Member

Choose a reason for hiding this comment

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

Nit: Is there anyway for us to dedupe this logic across the different extension methods? Maybe a helper that figures out what serialization invocation to use based on the context switch and return the associated task?

Copy link
Member Author

Choose a reason for hiding this comment

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

The difficulty here is that each of the HttpRequestJsonExtension methods use a different overload of DeserializeAsync.
We could in theory share the other checks, but I'm not sure it's much cleaner:

public static async ValueTask<object?> ReadFromJsonAsync(
    this HttpRequest request,
    Type type,
    JsonSerializerContext context,
    CancellationToken cancellationToken = default)
{
    Stream? inputStream = null;
    bool isTranscodedStream = false;
    ValueTask<object?> deserializeTask;

    try
    {
        (var pipeReader, inputStream, isTranscodedStream) = Shared(request);
        if (pipeReader is not null)
        {
            deserializeTask = JsonSerializer.DeserializeAsync(pipeReader, type, context, cancellationToken);
        }
        else
        {
            deserializeTask = JsonSerializer.DeserializeAsync(inputStream, type, context, cancellationToken);
        }

        return await deserializeTask;
    }
    finally
    {
        if (isTranscodedStream)
        {
            await inputStream!.DisposeAsync();
        }
    }
}

private static (PipeReader?, Stream?, bool) Shared(HttpRequest request)
{
    if (!request.HasJsonContentType(out var charset))
    {
        ThrowContentTypeError(request);
    }

    var encoding = GetEncodingFromCharset(charset);

    if (encoding == null || encoding.CodePage == Encoding.UTF8.CodePage)
    {
        if (_useStreamJsonOverload)
        {
            return (null, request.Body, false);
        }
        return (request.BodyReader, null, false);
    }

    var inputStream = Encoding.CreateTranscodingStream(request.Body, encoding, Encoding.UTF8, leaveOpen: true);
    return (null, inputStream, true);
}

@wtgodbe wtgodbe merged commit 92f4a6e into release/10.0-preview7 Jul 25, 2025
29 checks passed
@wtgodbe wtgodbe deleted the brecon/jsonreader branch July 25, 2025 00:15
@dotnet-policy-service dotnet-policy-service bot added this to the 10.0-preview7 milestone Jul 25, 2025
@BrennanConroy
Copy link
Member Author

/backport to main

@github-actions
Copy link
Contributor

@davidfowl
Copy link
Member

woop!

jeffhandley pushed a commit to dotnet/runtime that referenced this pull request Jan 8, 2026
…#122670)

Backport of #118041 to release/10.0

/cc @BrennanConroy

## Customer Impact

- [x] Customer reported
- [x] Found internally

Reported in dotnet/aspnetcore#64323. Customers
using request buffering (or similar seekable streams) and reading
through the request before framework/app code processes the request are
now getting empty data or errors in the app/framework code.

The expectation is that if using a seekable stream and doing some
(pre-)processing of the request before letting application code see the
request, then the application code should be able to read from the
stream and get the full request as if no (pre-)processing occurred.

## Regression

- [x] Yes
- [ ] No

`StreamPipeReader` didn't regress, but ASP.NET Core used it in more
places in 10.0 which caused a regression in ASP.NET Core.
dotnet/aspnetcore#62895 added `PipeReader`
support for Json deserialization to most of ASP.NET Core's json parsing
code which meant the `StreamPipeReader` ended up getting used during
json deserialization where previously it was using the `Stream`.

## Testing

Verified that the original issues described in
dotnet/aspnetcore#64323 were fixed with this
change. (both MVCs `IAsyncResourceFilter` and Minimals
`IEndpointFilter`)

Also verified skipped test

https://github.com/dotnet/aspnetcore/blob/1a2c0fd99c05db9f9ef167165386d14d3e24c9b4/src/Http/Http.Extensions/test/RequestDelegateGenerator/RequestDelegateCreationTests.BindAsync.cs#L257-L258
was fixed by this change.

## Risk

Low;
This could cause a regression if someone was reading from a `Stream`
after it had returned `0` (exceptions below). This is not a common
pattern as most code would exit the read loop or be done processing the
`Stream` at that point.

Scanned a few dozen `Stream` implementations to verify that calling read
after a previous read returned 0 does not throw. Most code that calls
read after a previous read returned `0` are either seeking (rewinding)
or doing zero-byte reads.

---------

Co-authored-by: Brennan <brecon@microsoft.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-minimal Includes minimal APIs, endpoint filters, parameter binding, request delegate generator etc area-mvc Includes: MVC, Actions and Controllers, Localization, CORS, most templates Servicing-approved Shiproom has approved the issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants