Skip to content

Migrate NuGet JSON readers to System.Text.Json for NativeAOT - #7601

Draft
baronfel wants to merge 5 commits into
NuGet:devfrom
baronfel:baronfel-migrate-runtime-graph-json
Draft

Migrate NuGet JSON readers to System.Text.Json for NativeAOT#7601
baronfel wants to merge 5 commits into
NuGet:devfrom
baronfel:baronfel-migrate-runtime-graph-json

Conversation

@baronfel

@baronfel baronfel commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Bug

Fixes: Part of dotnet/sdk#55497

Description

Migrate NuGet's read-side JSON paths used by the .NET SDK and .NET CLI from Newtonsoft.Json to System.Text.Json so NativeAOT and trimming can remove the Newtonsoft deserialization closure.

What changed

  • Added a System.Text.Json runtime-graph reader using source-generated metadata and explicit converters.
  • Added an incremental System.Text.Json reader for NuGet SDK resolver data in global.json, using the shared Utf8JsonStreamReader.
  • Added System.Text.Json reading for packages.lock.json formats V1, V2, and V3 behind NuGet's existing opt-in selection mechanism. Newtonsoft remains the default compatibility fallback, and writing remains Newtonsoft-based and intentionally out of scope.
  • All three readers follow NuGet's existing selection order: the trim-aware NuGet.UseSystemTextJsonDeserialization AppContext switch, the NUGET_USE_SYSTEM_TEXT_JSON_DESERIALIZATION environment opt-in, then the Newtonsoft reader as the compatibility fallback.
  • Preserved comments, trailing commas, root-property ordering, duplicate/property filtering behavior, malformed-file diagnostics, and existing stream ownership semantics.
  • Restored line and UTF-8 byte-column tracking in the shared incremental reader so parse diagnostics remain accurate across buffer boundaries.

This does not remove all Newtonsoft references from NuGet. ProjectModel writers and legacy plugin DTO properties typed as JObject remain unchanged. The goal of this PR is to eliminate reachable Newtonsoft read/deserialization paths used by the NativeAOT CLI.

NativeAOT .NET CLI impact

A controlled win-x64 Release experiment integrated this branch (ace289d42) into the NativeAOT .NET CLI at SDK commit 7e04840bcf. The baseline used NuGet 7.10.0-rc.36417; the variant used packages built from this branch as 7.10.0-rc.60002. These sizes are for debug builds so not the final assets, which are smaller.

Metric Baseline This change Delta
Native dotnet-aot.dll 45,888,000 B 39,744,512 B -6,143,488 B (-13.39%, -5.86 MiB)
Total publish output 250,894,649 B 244,751,161 B -6,143,488 B (-2.45%)
NativeAOT codegen nodes 2,128,652 1,789,179 -339,473 (-15.95%)
Newtonsoft nodes 48,727 4,712 -90.3%
Newtonsoft method bodies 9,998 924 -90.8%
Reachable Newtonsoft read/deserialization bodies 41 0 -100%

The remaining Newtonsoft nodes come from out-of-scope write paths, plugin DTO type metadata, and metadata/vtable/generic roots. No reachable Newtonsoft read path remains through runtime graphs, SDK resolver global.json, project.json, project.assets.json, packages.lock.json, NuGet.Protocol, or plugin payload deserialization.

The smaller graph also reduced NativeAOT build times in general:

Metric Baseline median This change median Delta
Total AOT publish 124,577.733 ms 112,851.425 ms -9.41%
IlcCompile 89,353 ms 74,050 ms -17.13%

Validation

  • All the repo tests for these components continue to pass, new STJ tests have been added
  • These binaries were used in a local build of the dotnet CLI and functionality tests there pass as well, in addition to the AOT size validations

PR Checklist

  • Meaningful title, helpful description and a linked NuGet/Home issue
  • Added tests
  • Link to an issue or pull request to update docs if this PR changes settings, environment variables, new feature, etc.

@dotnet-policy-service dotnet-policy-service Bot added the Community PRs created by someone not in the NuGet team label Jul 28, 2026
baronfel added 4 commits July 28, 2026 12:56
Forward the runtime FeatureSwitchDefinitionAttribute while retaining the compatibility polyfill for older target frameworks.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 0782b2a6-d495-4dd2-ade4-1039d29ae149
Preserve one-based line and UTF-8 byte-column positions across stream buffer boundaries and use them in package-spec parse diagnostics.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 0782b2a6-d495-4dd2-ade4-1039d29ae149
Add source-generated metadata and explicit converters, preserve the public Newtonsoft surface and fallback, and select System.Text.Json through the standard feature switch or environment opt-in.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 0782b2a6-d495-4dd2-ade4-1039d29ae149
Parse SDK resolver data incrementally with the shared buffered reader while preserving caching, diagnostics, Newtonsoft fallback, and the existing lazy-load behavior.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 0782b2a6-d495-4dd2-ade4-1039d29ae149
@baronfel
baronfel force-pushed the baronfel-migrate-runtime-graph-json branch from cf29f6b to 36956ea Compare July 28, 2026 17:57
Add opt-in V1, V2, and V3 packages.lock.json parsing with stream-based reading, preserve malformed-file diagnostics and stream ownership, and retain Newtonsoft as the default reader and writer.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 0782b2a6-d495-4dd2-ade4-1039d29ae149
@baronfel
baronfel force-pushed the baronfel-migrate-runtime-graph-json branch from 36956ea to 1ef2127 Compare July 28, 2026 19:38

@baronfel baronfel left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Some notes for the reader about why certain decisions were made

Comment on lines +4 to +12
#if NET9_0_OR_GREATER

using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;

// This is a supporting forwarder for an internal polyfill API.
[assembly: TypeForwardedTo(typeof(FeatureSwitchDefinitionAttribute))]

#else

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is porting of a pattern that @DustinCampbell corrected on MSBuild's polyfills.


internal JsonTokenType TokenType => _reader.TokenType;

internal int LineNumber

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Adding this tracking to the STJ parser means that error messages that used Newtonsoft's Line Info interfaces can keep line info on STJ parsing paths - we use this here.

Comment on lines +343 to +350
if (useSystemTextJson)
{
jsonStream = File.OpenRead(globalJsonPath);
}
else
{
json = File.ReadAllText(globalJsonPath);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This parser, like the other parsers used in this repo, chooses which implementation (NJ or STJ) to use. Here I'm also trying to be a little more performant by streaming the data for STJ instead of reading the whole global.json into a string and checking for the msbuild-sdks node via contains. We can reduce this diff a bit by loading the string in memory like we do for NJ, but that feels memory-inefficient to me.

Comment on lines +38 to 46
if (NuGetFeatureFlags.UseSystemTextJsonDeserializationFeatureSwitch)
{
return ReadRuntimeGraphWithSystemTextJson(stream);
}

if (NuGetFeatureFlags.IsSystemTextJsonDeserializationEnabledByEnvironment(environmentVariableReader))
{
return ReadRuntimeGraph(streamReader);
return ReadRuntimeGraphWithSystemTextJson(stream);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

STJ doesn't work great with a TextReader-based usage pattern. So for this parser, if we detect that STJ should be used, we pivot to using Stream directly so that we aren't forced to buffer the TextReader. This leads to a tiny bit of code duplication (if you use the TextReader-based call paths we do buffer when using STJ, which is not the best), but I think this is relatively understandable.

{
RuntimeGraphJsonModel json = STJJsonSerializer.Deserialize(
stream,
JsonRuntimeFormatContext.Default.RuntimeGraphJsonModel)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We're using source-generated STJ serializers here instead of hand-rolling the parsing. Raise a flag if this is not desired, but I think we have all of the extensibility we need even with the auto-generated parsers.

internal sealed class RuntimeGraphJsonModel
{
[JsonPropertyName("runtimes")]
[JsonConverter(typeof(RuntimeDescriptionCollectionJsonConverter))]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Having these custom converters lets us do the mapping of these complex types in a streaming fashion, without having to parse into intermediate Dictionary<string, object> mappings and then re-parse into the final structures. Should save some memory/intermediate allocations.

Comment on lines +77 to +87
PackagesLockFile lockFile;
if (NuGetFeatureFlags.UseSystemTextJsonDeserializationFeatureSwitch
|| NuGetFeatureFlags.IsSystemTextJsonDeserializationEnabledByEnvironment())
{
lockFile = ReadLockFileWithSystemTextJson(stream);
}
else
{
using var textReader = new StreamReader(stream);
lockFile = ReadLockFile(textReader);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Similar to the runtime graph format, STJ works better with streams instead of TextReaders. So if we detect that STJ is used, we do a slightly-parallel code path that works on Streams to lean into what STJ expects. Slight duplication with the "parse, the set Path, and return good errors" pattern, but worth it I think for the memory usage implications.

@dotnet-policy-service dotnet-policy-service Bot added the Status:No recent activity PRs that have not had any recent activity and will be closed if the label is not removed label Aug 6, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

This PR has been automatically marked as stale because it has no activity for 7 days. It will be closed if no further activity occurs within another 30 days of this comment. If it is closed, you may reopen it anytime when you're ready again, as long as you don't delete the branch.

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

Labels

Community PRs created by someone not in the NuGet team Status:No recent activity PRs that have not had any recent activity and will be closed if the label is not removed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant