Skip to content

.NET 10 + .NET 11 TFMs with NativeAOT and Runtime Async support#41

Merged
buvinghausen merged 27 commits into
masterfrom
feature/net11-aot
May 13, 2026
Merged

.NET 10 + .NET 11 TFMs with NativeAOT and Runtime Async support#41
buvinghausen merged 27 commits into
masterfrom
feature/net11-aot

Conversation

@buvinghausen

@buvinghausen buvinghausen commented May 12, 2026

Copy link
Copy Markdown
Owner

Summary

  • Bump library TFMs to netstandard2.0;net462;net10.0;net11.0 (drop net8.0 — EOL 2026-11-10).
  • Mark the modern TFMs <IsAotCompatible>true</IsAotCompatible> so the Roslyn AOT analyzer runs at library build time.
  • Add test/TaskTupleAwaiter.AotSmokeTest/ — a downstream-consumer console project that exercises every generator codepath (arity-1, arity-2 with both ConfigureAwait overloads, arity-16, non-generic) under PublishAot=true.
  • Add net11.0 to the test TFM matrix and enable <Features>runtime-async=on</Features> for that target only, so every async test method exercises our awaiters via the .NET 11 runtime-managed async lowering path.
  • CI now installs the .NET 11 SDK alongside 8/9/10 and publishes the AOT smoke-test for both net10.0 and net11.0; either failure fails the PR.
  • Release workflow bumped to 11.0.* (single SDK suffices for a multi-TFM dotnet pack).
  • README compatibility table + new "NativeAOT ready" / "Runtime Async compatible" feature bullets.
  • CLAUDE.md repo layout corrected (the hand-authored TaskTupleExtensions.cs mentioned there was deleted when the source generator landed in Add source generator #37).
  • Side-cleanup: drop Microsoft.SourceLink.GitHub package — redundant under .NET SDK 8+'s implicit source link.

Design and plan committed under docs/superpowers/:

  • docs/superpowers/specs/2026-05-12-net11-aot-design.md
  • docs/superpowers/plans/2026-05-12-net11-aot.md

Test Plan

  • Existing 483-test suite passes on net10.0 locally
  • AOT analyzer pass (managed build with PublishAot=true) clean — zero warnings on the smoke-test
  • CI: dotnet test on the full TFM matrix (net11.0;net10.0;net9.0;net8.0;net472)
  • CI: dotnet publish AOT smoke-test on net10.0 (Windows native link)
  • CI: dotnet publish AOT smoke-test on net11.0
  • CI: net11.0 test target compiles under <Features>runtime-async=on</Features> and all 483 tests pass

buvinghausen and others added 15 commits May 12, 2026 17:32
Drops the net8.0 TFM (EOL 2026-11-10, same week as .NET 11 GA).
Consumers on .NET 8 continue to resolve to the netstandard2.0 target.
Adds <IsAotCompatible>true</IsAotCompatible> on the modern TFMs so
the Roslyn AOT analyzer runs at library build time.
Since .NET SDK 8.0+, the Roslyn compiler emits source link metadata
implicitly when ContinuousIntegrationBuild=true (already set on line 19).
The explicit Microsoft.SourceLink.GitHub package is now a no-op.

EmbedUntrackedSources and PublishRepositoryUrl remain — they still
govern PDB content and pack-time URL embedding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Existing test suite must pass on .NET 11 with default (compiler-generated)
state machines before we layer Runtime Async on top in the next commit.
Compiles the test suite with <Features>runtime-async=on</Features> on
net11.0 only, so every async test method exercises our awaiters via the
.NET 11 runtime-managed async lowering path. Other TFMs keep the default
compiler-generated state machines.
PublishAot=true turns on the Roslyn AOT analyzer at build time; the project references the TaskTupleAwaiter library so any AOT-incompatible codegen would surface as an analyzer warning (= build error under TreatWarningsAsErrors=true). Program.cs lands in the next commit.
Program.cs awaits arity-1, arity-2 (bool and ConfigureAwaitOptions), arity-16, and the non-generic Task tuple. Each result is written to stdout to defeat linker trimming.

Local build with PublishAot=true runs the Roslyn AOT analyzer over the library's full surface; no warnings emitted, confirming the source generator's output is AOT-clean. End-to-end NativeAOT publish on net10.0 + net11.0 deferred to CI (windows-latest with the standard VS Build Tools image).
setup-dotnet now installs the .NET 11 SDK alongside 8/9/10 (the older SDKs remain because the test project still targets net8.0 and net9.0). Two new steps publish the AOT smoke-test on net10.0 and net11.0; either failing fails the PR.
dotnet pack on a multi-TFM project produces one package containing all targets; only the highest SDK needs to be installed on the runner.
Updates the compatibility table to list .NET 10 and .NET 11 as the modern targets, adds Features bullets for NativeAOT compatibility and Runtime Async verification, and notes the netstandard2.0 fallback for .NET 8 / .NET 9 consumers.
Removes references to the deleted hand-authored TaskTupleExtensions.cs, documents the new AotSmokeTest project, and lists the AOT + Runtime Async TFM coverage so future sessions inherit the correct mental model.
…lish

Final whole-branch review flagged two Important items:

1. SelfContained=true was in the plan's smoke-test csproj but got dropped when I dispatched Task 4. PublishAot=true implies it, but stating it explicitly closes the spec/impl paper-trail gap.

2. The CI Test step uses --no-build because Build ran with --no-restore. The two new AOT publish steps re-restored unnecessarily; --no-restore makes them consistent with the rest of the workflow.
The .NET 11 SDK elevates several IDE rules into latest-Recommended that were previously silent. Rather than rewrite working code to match the new defaults, declare the codebase's chosen style as canonical in .editorconfig.

Global rules added (apply everywhere):

- dotnet_style_require_accessibility_modifiers = omit_if_default:error (matches the existing 'Remove default accessibility modifiers' pass in fd8baf0)

- csharp_style_namespace_declarations = file_scoped:error (file-scoped namespaces are already the codebase pattern)

- csharp_prefer_braces = false:silent (allow single-statement bodies without braces, e.g. for/if in the generator)

Path-scoped silences:

- src/TaskTupleAwaiter.Generator/**.cs: silence CS1591 (missing XML doc) because the public surface exists for [Generator] discovery, not external consumption

- test/**.cs: silence IDE0005/IDE0040/IDE0044/IDE0046/IDE0051/IDE0055 (xUnit reflection-discovered methods look unused; adapter-pattern fields aren't readonly by intent; not worth chasing down across the test suite)

Also drops the explicit GenerateDocumentationFile=false override from the generator csproj — required for IDE0005 to function under .NET 11 (dotnet/roslyn#41640).

Verified: full solution builds clean across all 9 TFM permutations; 2431 tests pass on net472/net8.0/net9.0/net10.0/net11.0 (net11.0 under <Features>runtime-async=on</Features>). AOT smoke-test managed compile + analyzer pass clean; native link still deferred to CI (Windows C++ toolchain missing locally).
TaskTupleAwaiter.slnx is what 'dotnet restore' (and build/test) auto-discovers when invoked at the repo root. Without listing the AotSmokeTest project in the solution, restore skipped it, so the AOT publish step in CI failed with NETSDK1004 ('project.assets.json not found') when invoked with --no-restore.
Routes the generator's 15 sb.AppendLine(...) calls through a thin wrapper sb.AppendCSharp(...) whose parameter carries [StringSyntax("C#")]. Visual Studio 2022 and JetBrains Rider read the attribute and apply C# classification + IntelliSense inside the raw-string content at every call site, making the emitted template far easier to read and edit.

Includes a single-file polyfill of System.Diagnostics.CodeAnalysis.StringSyntaxAttribute (added to the BCL in .NET 7) because the generator targets netstandard2.0. Roslyn's IDE classifiers match the attribute by namespace + type name, not by assembly identity, so an internal declaration in the project is enough.

Generator output is byte-identical (AppendCSharp delegates to AppendLine); the full test suite still passes including the net11.0 target under <Features>runtime-async=on</Features>.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

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 TaskTupleAwaiter to target .NET 10/.NET 11, adds NativeAOT validation via a downstream “smoke test” project, and enables .NET 11 “runtime async” compilation for the test suite to exercise the awaiters under the new lowering path.

Changes:

  • Update library/test target framework matrices (add net11.0, keep modern targets focused on net10.0/net11.0) and mark modern TFMs as AOT compatible.
  • Add TaskTupleAwaiter.AotSmokeTest console app and publish it in CI for both net10.0 and net11.0.
  • Improve generator authoring ergonomics (C#-annotated emit helper + StringSyntaxAttribute polyfill) and update docs/config (README/CLAUDE, workflow SDK version, editorconfig rules).

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
test/TaskTupleAwaiter.Tests/TaskTupleAwaiter.Tests.csproj Enables runtime-async=on when targeting net11.0.
test/TaskTupleAwaiter.AotSmokeTest/TaskTupleAwaiter.AotSmokeTest.csproj Adds a NativeAOT-published downstream-consumer console project.
test/TaskTupleAwaiter.AotSmokeTest/Program.cs Exercises key generator/awaiter codepaths (typed/non-generic, CA overloads, max arity).
test/Directory.Build.props Adjusts the shared test TFM matrix.
TaskTupleAwaiter.slnx Registers the new smoke test project in the solution.
src/TaskTupleAwaiter.Generator/TaskTupleExtensionsGenerator.cs Switches generator emission calls to a C#-annotated append helper for IDE highlighting.
src/TaskTupleAwaiter.Generator/TaskTupleAwaiter.Generator.csproj Relies on shared props for doc generation behavior.
src/TaskTupleAwaiter.Generator/StringSyntaxAttribute.cs Adds a StringSyntaxAttribute polyfill for generator project targeting netstandard2.0.
src/TaskTupleAwaiter.Generator/CSharpEmit.cs Adds AppendCSharp extension for emitting annotated C# strings.
src/Directory.Build.props Updates library TFMs and enables <IsAotCompatible> on net10.0/net11.0; removes SourceLink package reference.
README.md Updates compatibility/feature bullets and adds notes about AOT + runtime-async verification.
CLAUDE.md Updates repo layout and target/framework notes to match generator-based implementation.
.github/workflows/release.yml Updates release workflow SDK to 11.0.*.
.github/workflows/ci.yml Updates SDK installs and adds AOT smoke-test publish steps.
.editorconfig Adds/adjusts style rules and per-path diagnostic severity configuration.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread test/Directory.Build.props Outdated
Comment thread .github/workflows/ci.yml
Comment thread README.md

Copilot AI commented May 13, 2026

Copy link
Copy Markdown
Contributor

@copilot apply changes based on the comments in this thread

Fix the comments regarding the TFMs for that ite...

Done in bf00787. I re-added net9.0 and net8.0 to test/Directory.Build.props and updated CI to install 8.0.* and 9.0.* SDKs in .github/workflows/ci.yml, so the TFM matrix now matches the expected coverage.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • aka.ms
    • Triggering command: /usr/bin/curl curl -I -sSL --retry 5 --retry-delay 2 --connect-timeout 15 REDACTED (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

@buvinghausen

Copy link
Copy Markdown
Owner Author

@jnm2 I'll leave it up to you to decide but I'm assuming the juice may not be worth the squeeze for a net11.0-only code path to rewrite the awaiter internals to call AsyncHelpers.Await(Task) instead of the manual INotifyCompletion dance — that's the path for maximum runtime-native benefit (task-to-task call fusion, no unnecessary suspension).

Comment thread src/Directory.Build.props Outdated

Copilot AI commented May 13, 2026

Copy link
Copy Markdown
Contributor

Just as a heads up, I was blocked by some firewall rules while working on your feedback. Expand below for details.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • aka.ms
    • Triggering command: /usr/bin/curl curl -I -sSL --retry 5 --retry-delay 2 --connect-timeout 15 REDACTED (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

Copilot finished work on behalf of jnm2 May 13, 2026 01:19
Comment thread test/TaskTupleAwaiter.Tests/TaskTupleAwaiter.Tests.csproj
Comment thread README.md Outdated
Comment thread README.md Outdated
Comment thread CLAUDE.md Outdated
Comment thread CLAUDE.md Outdated
Comment thread README.md Outdated
@jnm2

jnm2 commented May 13, 2026

Copy link
Copy Markdown
Collaborator

I'll leave it up to you to decide but I'm assuming the juice may not be worth the squeeze for a net11.0-only code path to rewrite the awaiter internals to call AsyncHelpers.Await(Task) instead of the manual INotifyCompletion dance — that's the path for maximum runtime-native benefit (task-to-task call fusion, no unnecessary suspension).

I thought about this for a bit, but I don't see any way for us to make use of runtime async in this library. Runtime async comes into play when you have an async method somewhere. We just don't. Calls to AsyncHelpers.Await are generated by the compiler for each await operator but we don't have an await operator anywhere in the library.

We also aren't doing anything that has runtime-async speedups. Runtime-async shines when no Task object needs to be materialized. But Task objects are already materialized in order to populate a tuple, before TaskTupleAwaiter even gets invoked. By the time TaskTupleAwaiter kicks in, there's only a Task.WhenAll to call with the already-materialized tasks, and Task.WhenAll creates a new Task object so that can't be avoided either.

(It's worth noting that TaskTupleAwaiter is no worse in this regard than Task.WhenAll for runtime-async- it's exactly the same)

Personally, I would not mention runtime async at all and I would not even run tests using it. Runtime-async is only happening in .NET because it's non-breaking. So what signal could we extract from our tests that would be helpful? But, if it's important to you to have confirmed that runtime-async lives up to its goals with respect to simple uses of this library, that's fine with me.

Comment thread src/Directory.Build.props Outdated
@jnm2

jnm2 commented May 13, 2026

Copy link
Copy Markdown
Collaborator

@copilot Please take the actions agreed on in each comment thread.

Copilot AI commented May 13, 2026

Copy link
Copy Markdown
Contributor

@copilot Please take the actions agreed on in each comment thread.

Completed in fc1559b: I applied the agreed changes across the open threads (library TFM/AOT condition updates plus README and CLAUDE.md corrections).

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • aka.ms
    • Triggering command: /usr/bin/curl curl -I -sSL --retry 5 --retry-delay 2 --connect-timeout 15 REDACTED (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

Copilot finished work on behalf of jnm2 May 13, 2026 19:29
Copilot AI requested a review from jnm2 May 13, 2026 19:29
@jnm2

jnm2 commented May 13, 2026

Copy link
Copy Markdown
Collaborator

@buvinghausen Lgtm! The NAOT stuff is great to have added. Thanks for your work keeping up with the new stuff!

@buvinghausen

Copy link
Copy Markdown
Owner Author

@jnm2 I have one last push to make claude code dumped all the no warn stuff in .editorconfig and I don't think it belongs there so I hoisted it up into the msbuild props where I think it does belong.

@buvinghausen

Copy link
Copy Markdown
Owner Author

Microsoft got real aggressive in elevating things to warnings (I think that's a good thing) in .NET 11 but this should leave us ready to go.

Should I just release this as 2.2 to indicate the AOT stuff?

@buvinghausen buvinghausen merged commit d92ac17 into master May 13, 2026
1 check passed
@buvinghausen buvinghausen deleted the feature/net11-aot branch May 13, 2026 19:51
@buvinghausen

Copy link
Copy Markdown
Owner Author

@jnm2 I went ahead and ran with the 2.2 version you can see the full release here:

https://github.com/buvinghausen/TaskTupleAwaiter/releases/tag/2.2.0

@jnm2

jnm2 commented May 13, 2026

Copy link
Copy Markdown
Collaborator

Overall the only thing that the release does is set [AssemblyMetadata("IsTrimmable", "True")] in the net8.0 assembly in the NuGet package, so that sounds good. I wonder why it's not setting IsAotCompatible, given:

https://github.com/dotnet/runtime/blob/9bb2c58ba99e484348cc9be1c7ff370944758500/src/tools/illink/src/ILLink.Tasks/build/Microsoft.NET.ILLink.targets#L24-L32

@jnm2

jnm2 commented May 13, 2026

Copy link
Copy Markdown
Collaborator

This is because IsAotCompatible tracking wasn't added till .NET 10, and so we'd have to add a net10 build if we wanted to ship an assembly with [AssemblyMetadata("IsAotCompatible", "True")] automatically present. We could also manually add it for the net8 build.

I'm not sure what difference this makes to folks.

@buvinghausen

Copy link
Copy Markdown
Owner Author

@jnm2 I have claude running a benchmark test

https://github.com/buvinghausen/TaskTupleAwaiter/blob/master/docs/superpowers/plans/2026-05-13-net10-span-whenall.md

If that goes well and performance does get a boost I'll open a PR and let you take a look but that will put .net10 in the mix if so.

@jnm2

jnm2 commented May 13, 2026

Copy link
Copy Markdown
Collaborator

That's a neat idea. We might be able to eke out even more perf by avoiding the element copy from the tuple to the compiler-allocated span on the stack, by passing a span that is a pointer into the tuple parameter itself:

M((
    Task.FromResult(1),
    Task.FromResult("2"),
    Task.FromResult(3),
    Task.FromResult("4"),
    Task.FromResult(5),
    Task.FromResult("6"),
    Task.FromResult(7),
    Task.FromResult("8")));

void M((Task<int>, Task<string>, Task<int>, Task<string>, Task<int>, Task<string>, Task<int>, Task<string>) t)
{
    _ = Task.WhenAll(MemoryMarshal.CreateReadOnlySpan(
        in Unsafe.As<Task<int>, Task>(ref t.Item1), 
        length: 8));
}

Note that the span that is created is pointing at the stack space that holds the method parameter, and this stack space is freed when the method which defines the parameter is returning, so the span must not be passed anywhere that stores it past the point in time that the method returns.

Since Task.WhenAll cannot save a reference to the span anywhere, a human can verify that the code is memory-safe even though the runtime and compiler cannot vouch for this.

@jnm2

jnm2 commented May 13, 2026

Copy link
Copy Markdown
Collaborator

We could have even fewer copies by doing:

internal TupleTaskAwaiter(in (Task<T1>, Task<T2>) tasks)
{
    _tasks = tasks;
    _whenAllAwaiter = Task.WhenAll(
        (ReadOnlySpan<Task>)MemoryMarshal.CreateReadOnlySpan(in tasks.Item1, length: 2)).GetAwaiter();
}

This avoids the copy of the tuple contents from the GetAwaiter parameter to the constructor argument.

Basically, the span we pass to Task.WhenAll points directly at the contents of the GetAwaiter parameter.

@jnm2

jnm2 commented May 13, 2026

Copy link
Copy Markdown
Collaborator

Ah, too bad. We can't get a span over tuple memory because ValueTuple structs are not sequential layout:
https://github.com/dotnet/runtime/blob/v10.0.8/src/libraries/System.Private.CoreLib/src/System/ValueTuple.cs#L425-L427

So none of the above is guaranteed to work. It does work, but the runtime could break it at any time.

However, we could still avoid some extra copies by using in for passing tuples between GetAwaiter and awaiters. For this to be worthwhile, I think the tuples would need to have at least three elements.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants