Skip to content

Use PipeReader JsonSerializer overloads #62895

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

Merged
merged 3 commits into from
Jul 25, 2025

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 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, halter73 and captainsafia as code owners July 24, 2025 01:04
Copy link
Contributor

@Copilot 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
Preview

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
Preview

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

Copy link
Contributor

@davidfowl
Copy link
Member

woop!

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.

6 participants