diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fe6264a..17e4461 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -15,7 +15,6 @@ jobs:
dotnet-version: |
8.0.*
9.0.*
- 10.0.*
11.0.*
env:
DOTNET_NOLOGO: 1
@@ -26,7 +25,13 @@ jobs:
run: dotnet build --no-restore
- name: Test
run: dotnet test --no-build -v n /p:CollectCoverage=true /p:CoverletOutput='../../' /p:CoverletOutputFormat=opencover /p:Threshold=91 /p:SkipAutoProps=true /p:UseSourceLink=true
- - name: AOT smoke-test (net8.0)
- run: dotnet publish test/TaskTupleAwaiter.AotSmokeTest -c Release -f net8.0 --no-restore
- - name: AOT smoke-test (net11.0)
- run: dotnet publish test/TaskTupleAwaiter.AotSmokeTest -c Release -f net11.0 --no-restore
+ - name: AOT smoke test (net8.0)
+ run: |
+ dotnet publish tests/smoke/TaskTupleAwaiter.AotSmokeTest -c Release -f net8.0 -r win-x64 -o aot-out --no-restore
+ .\aot-out\TaskTupleAwaiter.AotSmokeTest.exe
+ shell: pwsh
+ - name: AOT smoke test (net11.0)
+ run: |
+ dotnet publish tests/smoke/TaskTupleAwaiter.AotSmokeTest -c Release -f net11.0 -r win-x64 -o aot-out --no-restore
+ .\aot-out\TaskTupleAwaiter.AotSmokeTest.exe
+ shell: pwsh
diff --git a/.gitignore b/.gitignore
index 10d0b66..b65666d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -157,3 +157,13 @@ coverage.*.info
coverage.*.json
coverage.*.xml
+
+# BenchmarkDotNet output
+BenchmarkDotNet.Artifacts/
+
+# Local `dotnet pack` artifacts (we publish from CI, not from working copies)
+*.nupkg
+*.snupkg
+
+# Claude worktree fiesta
+.claude/
diff --git a/CLAUDE.md b/CLAUDE.md
index 3a1b6b5..4fc8992 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -9,7 +9,7 @@ TaskTupleAwaiter provides extension methods that allow you to `await` a `ValueTu
```
TaskTupleAwaiter/
├── src/
-│ ├── TaskTupleAwaiter/ # Main library shell (netstandard2.0, net462, net8.0)
+│ ├── TaskTupleAwaiter/ # Main library shell (netstandard2.0, net462, net8.0, net9.0)
│ │ # No hand-authored .cs sources — code is generated at build and compiled into the library.
│ └── TaskTupleAwaiter.Generator/ # Roslyn incremental source generator (netstandard2.0)
│ └── TaskTupleExtensionsGenerator.cs
@@ -22,9 +22,16 @@ TaskTupleAwaiter/
│ │ ├── DummyException.cs
│ │ ├── On.cs
│ │ └── SpySynchronizationContext.cs
-│ └── TaskTupleAwaiter.AotSmokeTest/ # NativeAOT downstream-consumer smoke-test (net8.0, net11.0)
-│ ├── TaskTupleAwaiter.AotSmokeTest.csproj
-│ └── Program.cs
+│ ├── TaskTupleAwaiter.AotSmokeTest/ # NativeAOT downstream-consumer smoke-test (net8.0, net9.0, net11.0)
+│ │ ├── TaskTupleAwaiter.AotSmokeTest.csproj
+│ │ └── Program.cs
+│ └── TaskTupleAwaiter.Benchmarks/ # BenchmarkDotNet harness (net8.0, net9.0)
+│ ├── TaskTupleAwaiter.Benchmarks.csproj # xUnit/Shouldly inheritance from test/Directory.Build.props is bypassed via the MSBuildProjectName condition there, not via a local Directory.Build.props.
+│ ├── Program.cs # BenchmarkSwitcher entry point.
+│ ├── TypedTupleAwaitBenchmarks.cs
+│ ├── NonGenericTupleAwaitBenchmarks.cs
+│ ├── ConfigureAwaitBenchmarks.cs
+│ └── README.md # How to run; the runs are local-only, not CI.
├── docs/superpowers/ # Specs and implementation plans
├── README.md
├── LICENSE.txt
@@ -36,7 +43,7 @@ TaskTupleAwaiter/
| Concern | Choice |
|---|---|
| Language | C# 14.0 |
-| Library TFMs | netstandard2.0, net462, net8.0 |
+| Library TFMs | netstandard2.0, net462, net8.0, net9.0 |
| Generator target | netstandard2.0 (Roslyn analyzer requirement) |
| AOT-compatible TFMs | net8.0+ (`true` via `IsTargetFrameworkCompatible`) |
| Generator framework | Roslyn `IIncrementalGenerator` |
@@ -49,6 +56,7 @@ TaskTupleAwaiter/
### Source Generator (`TaskTupleExtensionsGenerator`)
- Implements `IIncrementalGenerator` (not the older `ISourceGenerator`).
- **Feature-detects** `ConfigureAwaitOptions` at compile time by resolving the type `System.Threading.Tasks.ConfigureAwaitOptions` from the target compilation — **do not use** `#if NET8_0_OR_GREATER` or preprocessor symbols.
+- Emits `Task.WhenAll([tasks.Item1, ..., tasks.ItemN])`. Overload binding is determined by the library TFM: `netstandard2.0` / `net462` / `net8.0` bind to `Task.WhenAll(params Task[])` (heap-allocated array — same IL as before), while `net9.0`+ binds to `Task.WhenAll(ReadOnlySpan)` and stack-allocates the buffer. No runtime feature detection needed for this — overload preference is purely a compiler/TFM concern.
- Emits a single file `TaskTupleExtensions.g.cs` into the `System.Threading.Tasks` namespace (suppressing `IDE0130`).
- Arity-1 typed tuples (`ValueTuple>`) delegate directly to the inner task's awaiter — no custom awaiter struct is generated.
- Arities 2–16 emit `TupleTaskAwaiter` and `TupleConfiguredTaskAwaitable` `readonly record struct` types per arity.
diff --git a/README.md b/README.md
index 20f7296..e4dcd4e 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@ var (user, orders) = await (GetUserAsync(id), GetOrdersAsync(id));
- **`ConfigureAwait` support** — works with `ConfigureAwait(false)` and .NET 8+ `ConfigureAwaitOptions`
- **Non-generic `Task` support** — await tuples of `Task` (not just `Task`) when you don't need return values
- **Zero dependencies** — a single file, no external packages (except `System.ValueTuple` on .NET Framework 4.6.2)
-- **Broad compatibility** — targets .NET Standard 2.0, .NET Framework 4.6.2, and .NET 8+
+- **Broad compatibility** — supports .NET Standard 2.0+, .NET Framework 4.6.2+, and .NET 8+
- **NativeAOT ready** — the package sets `true` for .NET 8+ targets, and CI publishes downstream NativeAOT smoke tests
## Installation
diff --git a/TaskTupleAwaiter.slnx b/TaskTupleAwaiter.slnx
index 1e55bc5..358ecf8 100644
--- a/TaskTupleAwaiter.slnx
+++ b/TaskTupleAwaiter.slnx
@@ -2,8 +2,8 @@
-
-
+
+
@@ -13,19 +13,14 @@
-
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+
+
diff --git a/benches/TaskTupleAwaiter.Benchmarks/ConfigureAwaitBenchmarks.cs b/benches/TaskTupleAwaiter.Benchmarks/ConfigureAwaitBenchmarks.cs
new file mode 100644
index 0000000..fadb669
--- /dev/null
+++ b/benches/TaskTupleAwaiter.Benchmarks/ConfigureAwaitBenchmarks.cs
@@ -0,0 +1,52 @@
+using BenchmarkDotNet.Attributes;
+
+namespace TaskTupleAwaiter.Benchmarks;
+
+[MemoryDiagnoser]
+public class ConfigureAwaitBenchmarks
+{
+ [Benchmark]
+ public async Task Typed_Arity4_Bool_False() =>
+ _ = await (
+ Task.FromResult(1), Task.FromResult(2),
+ Task.FromResult(3), Task.FromResult(4)).ConfigureAwait(false);
+
+ [Benchmark]
+ public async Task Typed_Arity4_Options_None() =>
+ _ = await (
+ Task.FromResult(1), Task.FromResult(2),
+ Task.FromResult(3), Task.FromResult(4)).ConfigureAwait(ConfigureAwaitOptions.None);
+
+ [Benchmark]
+ public async Task Typed_Arity16_Bool_False() =>
+ _ = await (
+ Task.FromResult(1), Task.FromResult(2), Task.FromResult(3), Task.FromResult(4),
+ Task.FromResult(5), Task.FromResult(6), Task.FromResult(7), Task.FromResult(8),
+ Task.FromResult(9), Task.FromResult(10), Task.FromResult(11), Task.FromResult(12),
+ Task.FromResult(13), Task.FromResult(14), Task.FromResult(15), Task.FromResult(16)
+ ).ConfigureAwait(false);
+
+ [Benchmark]
+ public async Task Typed_Arity16_Options_None() =>
+ _ = await (
+ Task.FromResult(1), Task.FromResult(2), Task.FromResult(3), Task.FromResult(4),
+ Task.FromResult(5), Task.FromResult(6), Task.FromResult(7), Task.FromResult(8),
+ Task.FromResult(9), Task.FromResult(10), Task.FromResult(11), Task.FromResult(12),
+ Task.FromResult(13), Task.FromResult(14), Task.FromResult(15), Task.FromResult(16)
+ ).ConfigureAwait(ConfigureAwaitOptions.None);
+
+ [Benchmark]
+ public async Task NonGeneric_Arity4_Bool_False() =>
+ await (
+ (Task)Task.CompletedTask, Task.CompletedTask,
+ Task.CompletedTask, Task.CompletedTask).ConfigureAwait(false);
+
+ [Benchmark]
+ public async Task NonGeneric_Arity16_Options_None() =>
+ await (
+ (Task)Task.CompletedTask, Task.CompletedTask, Task.CompletedTask, Task.CompletedTask,
+ Task.CompletedTask, Task.CompletedTask, Task.CompletedTask, Task.CompletedTask,
+ Task.CompletedTask, Task.CompletedTask, Task.CompletedTask, Task.CompletedTask,
+ Task.CompletedTask, Task.CompletedTask, Task.CompletedTask, Task.CompletedTask
+ ).ConfigureAwait(ConfigureAwaitOptions.None);
+}
diff --git a/benches/TaskTupleAwaiter.Benchmarks/NonGenericTupleAwaitBenchmarks.cs b/benches/TaskTupleAwaiter.Benchmarks/NonGenericTupleAwaitBenchmarks.cs
new file mode 100644
index 0000000..664ddb1
--- /dev/null
+++ b/benches/TaskTupleAwaiter.Benchmarks/NonGenericTupleAwaitBenchmarks.cs
@@ -0,0 +1,55 @@
+using BenchmarkDotNet.Attributes;
+
+namespace TaskTupleAwaiter.Benchmarks;
+
+[MemoryDiagnoser]
+public class NonGenericTupleAwaitBenchmarks
+{
+ [Benchmark]
+ public async Task Arity2_PreCompleted() =>
+ await ((Task)Task.CompletedTask, Task.CompletedTask);
+
+ [Benchmark]
+ public async Task Arity4_PreCompleted() =>
+ await (
+ (Task)Task.CompletedTask, Task.CompletedTask,
+ Task.CompletedTask, Task.CompletedTask);
+
+ [Benchmark]
+ public async Task Arity8_PreCompleted() =>
+ await (
+ (Task)Task.CompletedTask, Task.CompletedTask, Task.CompletedTask, Task.CompletedTask,
+ Task.CompletedTask, Task.CompletedTask, Task.CompletedTask, Task.CompletedTask);
+
+ [Benchmark]
+ public async Task Arity16_PreCompleted() =>
+ await (
+ (Task)Task.CompletedTask, Task.CompletedTask, Task.CompletedTask, Task.CompletedTask,
+ Task.CompletedTask, Task.CompletedTask, Task.CompletedTask, Task.CompletedTask,
+ Task.CompletedTask, Task.CompletedTask, Task.CompletedTask, Task.CompletedTask,
+ Task.CompletedTask, Task.CompletedTask, Task.CompletedTask, Task.CompletedTask);
+
+ [Benchmark]
+ public async Task Arity2_Async() =>
+ await (YieldAsync(), YieldAsync());
+
+ [Benchmark]
+ public async Task Arity4_Async() =>
+ await (YieldAsync(), YieldAsync(), YieldAsync(), YieldAsync());
+
+ [Benchmark]
+ public async Task Arity8_Async() =>
+ await (
+ YieldAsync(), YieldAsync(), YieldAsync(), YieldAsync(),
+ YieldAsync(), YieldAsync(), YieldAsync(), YieldAsync());
+
+ [Benchmark]
+ public async Task Arity16_Async() =>
+ await (
+ YieldAsync(), YieldAsync(), YieldAsync(), YieldAsync(),
+ YieldAsync(), YieldAsync(), YieldAsync(), YieldAsync(),
+ YieldAsync(), YieldAsync(), YieldAsync(), YieldAsync(),
+ YieldAsync(), YieldAsync(), YieldAsync(), YieldAsync());
+
+ static async Task YieldAsync() => await Task.Yield();
+}
diff --git a/benches/TaskTupleAwaiter.Benchmarks/Program.cs b/benches/TaskTupleAwaiter.Benchmarks/Program.cs
new file mode 100644
index 0000000..c9a0467
--- /dev/null
+++ b/benches/TaskTupleAwaiter.Benchmarks/Program.cs
@@ -0,0 +1,3 @@
+using BenchmarkDotNet.Running;
+
+BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);
diff --git a/benches/TaskTupleAwaiter.Benchmarks/README.md b/benches/TaskTupleAwaiter.Benchmarks/README.md
new file mode 100644
index 0000000..68c1b03
--- /dev/null
+++ b/benches/TaskTupleAwaiter.Benchmarks/README.md
@@ -0,0 +1,41 @@
+# TaskTupleAwaiter.Benchmarks
+
+BenchmarkDotNet harness measuring the allocation and time profile of awaiting `ValueTuple` of `Task` / `Task` across:
+
+- Arities 2, 4, 8, 16
+- Typed (`Task`) and non-generic (`Task`) tuples
+- Pre-completed (`Task.FromResult`) and async (`Task.Yield`) completion modes
+- `ConfigureAwait(bool)` and `ConfigureAwait(ConfigureAwaitOptions)` paths
+
+The point of these benchmarks is to compare allocation profiles between `net8.0` (where the generated `Task.WhenAll(...)` call binds to `params Task[]` and heap-allocates the array) and `net9.0` (where compiling the library for `net9.0` binds to `Task.WhenAll(ReadOnlySpan)` and stack-allocates).
+
+## Running
+
+Run all benchmarks on net9.0:
+
+```sh
+dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net9.0
+```
+
+Run all benchmarks on net8.0:
+
+```sh
+dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net8.0
+```
+
+Filter to one class:
+
+```sh
+dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net9.0 -- --filter "*TypedTupleAwaitBenchmarks*"
+```
+
+## Expected outcome
+
+With the `net9.0` library target in place:
+
+- **net8.0:** allocations and timing unchanged from baseline. Same IL as today.
+- **net9.0:** `Allocated` per op drops by approximately `24 + 8·N` bytes (the `Task[N]` array we no longer allocate). Mean time per op is flat or slightly improved due to reduced GC pressure.
+
+## Not run in CI
+
+Benchmark runs are slow (multi-minute) and have enough run-to-run variance that they are unsuitable for CI gating. They are a manual local check before tagging a release or when validating perf-sensitive changes.
diff --git a/benches/TaskTupleAwaiter.Benchmarks/TaskTupleAwaiter.Benchmarks.csproj b/benches/TaskTupleAwaiter.Benchmarks/TaskTupleAwaiter.Benchmarks.csproj
new file mode 100644
index 0000000..f143145
--- /dev/null
+++ b/benches/TaskTupleAwaiter.Benchmarks/TaskTupleAwaiter.Benchmarks.csproj
@@ -0,0 +1,16 @@
+
+
+
+ false
+ Exe
+ net8.0;net9.0
+
+ $(NoWarn);CA1707;CA1822;CS1591
+
+
+
+
+
+
+
+
diff --git a/benches/TaskTupleAwaiter.Benchmarks/TypedTupleAwaitBenchmarks.cs b/benches/TaskTupleAwaiter.Benchmarks/TypedTupleAwaitBenchmarks.cs
new file mode 100644
index 0000000..26f73e8
--- /dev/null
+++ b/benches/TaskTupleAwaiter.Benchmarks/TypedTupleAwaitBenchmarks.cs
@@ -0,0 +1,57 @@
+using BenchmarkDotNet.Attributes;
+
+namespace TaskTupleAwaiter.Benchmarks;
+
+[MemoryDiagnoser]
+public class TypedTupleAwaitBenchmarks
+{
+ [Benchmark]
+ public async Task Arity2_PreCompleted() =>
+ _ = await (Task.FromResult(1), Task.FromResult(2));
+
+ [Benchmark]
+ public async Task Arity4_PreCompleted() =>
+ _ = await (Task.FromResult(1), Task.FromResult(2), Task.FromResult(3), Task.FromResult(4));
+
+ [Benchmark]
+ public async Task Arity8_PreCompleted() =>
+ _ = await (
+ Task.FromResult(1), Task.FromResult(2), Task.FromResult(3), Task.FromResult(4),
+ Task.FromResult(5), Task.FromResult(6), Task.FromResult(7), Task.FromResult(8));
+
+ [Benchmark]
+ public async Task Arity16_PreCompleted() =>
+ _ = await (
+ Task.FromResult(1), Task.FromResult(2), Task.FromResult(3), Task.FromResult(4),
+ Task.FromResult(5), Task.FromResult(6), Task.FromResult(7), Task.FromResult(8),
+ Task.FromResult(9), Task.FromResult(10), Task.FromResult(11), Task.FromResult(12),
+ Task.FromResult(13), Task.FromResult(14), Task.FromResult(15), Task.FromResult(16));
+
+ [Benchmark]
+ public async Task Arity2_Async() =>
+ _ = await (YieldAsync(1), YieldAsync(2));
+
+ [Benchmark]
+ public async Task Arity4_Async() =>
+ _ = await (YieldAsync(1), YieldAsync(2), YieldAsync(3), YieldAsync(4));
+
+ [Benchmark]
+ public async Task Arity8_Async() =>
+ _ = await (
+ YieldAsync(1), YieldAsync(2), YieldAsync(3), YieldAsync(4),
+ YieldAsync(5), YieldAsync(6), YieldAsync(7), YieldAsync(8));
+
+ [Benchmark]
+ public async Task Arity16_Async() =>
+ _ = await (
+ YieldAsync(1), YieldAsync(2), YieldAsync(3), YieldAsync(4),
+ YieldAsync(5), YieldAsync(6), YieldAsync(7), YieldAsync(8),
+ YieldAsync(9), YieldAsync(10), YieldAsync(11), YieldAsync(12),
+ YieldAsync(13), YieldAsync(14), YieldAsync(15), YieldAsync(16));
+
+ static async Task YieldAsync(int value)
+ {
+ await Task.Yield();
+ return value;
+ }
+}
diff --git a/docs/superpowers/plans/2026-05-13-net10-span-whenall.md b/docs/superpowers/plans/2026-05-13-net9-span-whenall.md
similarity index 78%
rename from docs/superpowers/plans/2026-05-13-net10-span-whenall.md
rename to docs/superpowers/plans/2026-05-13-net9-span-whenall.md
index 709ff0a..149ef61 100644
--- a/docs/superpowers/plans/2026-05-13-net10-span-whenall.md
+++ b/docs/superpowers/plans/2026-05-13-net9-span-whenall.md
@@ -1,14 +1,14 @@
-# net10.0 Span-Based WhenAll + BenchmarkDotNet Implementation Plan
+# net9.0 Span-Based WhenAll + BenchmarkDotNet Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-**Goal:** Cut one `Task[]` heap allocation per `await` of a task tuple for .NET 10+ consumers by adding a `net10.0` TFM and changing the source generator to emit collection-expression `WhenAll` calls; add a BenchmarkDotNet project to measure the win.
+**Goal:** Cut one `Task[]` heap allocation per `await` of a task tuple for .NET 9+ consumers by adding a `net9.0` TFM so that the span-based `Task.WhenAll` overload is used; add a BenchmarkDotNet project to measure the win.
-**Architecture:** Add `net10.0` as a fourth library TFM. Change the generator's `Items` helper so every generated `Task.WhenAll(t1, ..., tN)` becomes `Task.WhenAll([t1, ..., tN])`. The C# 14 compiler picks `params Task[]` on `netstandard2.0`/`net462`/`net8.0` (unchanged IL) and `ReadOnlySpan` on `net10.0` (stack-allocated buffer, zero heap allocation). New `test/TaskTupleAwaiter.Benchmarks` project measures the delta with `[MemoryDiagnoser]` across arities 2/4/8/16, both pre-completed and async completion modes.
+**Architecture:** Add `net9.0` as a fourth library TFM. Overload selection for `Task.WhenAll` is driven by the targeted library TFM: `netstandard2.0`/`net462`/`net8.0` bind to `params Task[]` (unchanged IL), while `net9.0` binds to `ReadOnlySpan` (stack-allocated buffer, zero heap allocation). New `test/TaskTupleAwaiter.Benchmarks` project measures the delta with `[MemoryDiagnoser]` across arities 2/4/8/16, both pre-completed and async completion modes.
**Tech Stack:** Roslyn `IIncrementalGenerator`, BenchmarkDotNet, xUnit v3, NativeAOT publish for smoke testing, MSBuild multi-TFM, C# 14 collection expressions.
-**Spec:** `docs/superpowers/specs/2026-05-13-net10-span-whenall-design.md`
+**Spec:** `docs/superpowers/specs/2026-05-13-net9-span-whenall-design.md`
---
@@ -18,55 +18,55 @@
| Path | Purpose |
|---|---|
-| `test/TaskTupleAwaiter.Benchmarks/TaskTupleAwaiter.Benchmarks.csproj` | Exe project, net8.0;net10.0, BenchmarkDotNet, refs library |
+| `test/TaskTupleAwaiter.Benchmarks/TaskTupleAwaiter.Benchmarks.csproj` | Exe project, net8.0;net9.0, BenchmarkDotNet, refs library |
| `test/TaskTupleAwaiter.Benchmarks/Program.cs` | `BenchmarkSwitcher` entry point |
| `test/TaskTupleAwaiter.Benchmarks/TypedTupleAwaitBenchmarks.cs` | `(Task, Task, ...)` tuple awaits, arities 2/4/8/16, pre-completed + async |
| `test/TaskTupleAwaiter.Benchmarks/NonGenericTupleAwaitBenchmarks.cs` | `(Task, Task, ...)` tuple awaits, same shape |
| `test/TaskTupleAwaiter.Benchmarks/ConfigureAwaitBenchmarks.cs` | `ConfigureAwait(bool)` and `ConfigureAwait(ConfigureAwaitOptions)` variants |
| `test/TaskTupleAwaiter.Benchmarks/README.md` | How to run, what to expect |
-| `docs/superpowers/specs/2026-05-13-net10-span-whenall-benchmark-results.md` | Captured baseline + post-change numbers |
+| `docs/superpowers/specs/2026-05-13-net9-span-whenall-benchmark-results.md` | Captured baseline + post-change numbers |
**Files modified:**
| Path | Change |
|---|---|
-| `src/TaskTupleAwaiter/TaskTupleAwaiter.csproj` | Add `net10.0` to `TargetFrameworks` |
-| `src/TaskTupleAwaiter.Generator/TaskTupleExtensionsGenerator.cs` | Change `Items` helper so output is wrapped `[...]` (collection expression) |
-| `test/TaskTupleAwaiter.AotSmokeTest/TaskTupleAwaiter.AotSmokeTest.csproj` | Add `net10.0` to `TargetFrameworks` |
-| `CLAUDE.md` | Note `net10.0` TFM and collection-expression emission in design decisions |
-| `README.md` | Short perf note for net10+ consumers |
+| `src/TaskTupleAwaiter/TaskTupleAwaiter.csproj` | Add `net9.0` to `TargetFrameworks` |
+| `src/TaskTupleAwaiter.Generator/TaskTupleExtensionsGenerator.cs` | Change `Items` helper so output is wrapped `[...]` |
+| `test/TaskTupleAwaiter.AotSmokeTest/TaskTupleAwaiter.AotSmokeTest.csproj` | Add `net9.0` to `TargetFrameworks` |
+| `CLAUDE.md` | Note `net9.0` TFM behavior in design decisions |
+| `README.md` | Short perf note for net9+ consumers |
**Files unchanged but referenced for context:**
-- `test/Directory.Build.props` — already targets `net11.0;net10.0;net9.0;net8.0;net472` for test projects; the benchmark project will need to opt out of the inherited xUnit/Shouldly bits like the AOT smoke test does.
+- `test/Directory.Build.props` — already targets `net11.0;net9.0;net8.0;net472` for test projects; the benchmark project will need to opt out of the inherited xUnit/Shouldly bits like the AOT smoke test does.
- `Directory.Build.props` (root) — sets `LangVersion=latest`, no change needed.
---
-## Task 1: Add `net10.0` TFM to the library
+## Task 1: Add `net9.0` TFM to the library
**Files:**
- Modify: `src/TaskTupleAwaiter/TaskTupleAwaiter.csproj`
-- [ ] **Step 1: Confirm net10 SDK is installed**
+- [ ] **Step 1: Confirm net9 SDK is installed**
Run: `dotnet --list-sdks`
-Expected: a line starting with `10.` is present. If not present, install via `winget install Microsoft.DotNet.SDK.10` (or download the .NET 10 SDK) and rerun.
+Expected: a line starting with `10.` is present. If not present, install via `winget install Microsoft.DotNet.SDK.9` (or download the .NET 9 SDK) and rerun.
- [ ] **Step 2: Verify baseline build is clean before touching anything**
Run: `dotnet build -c Release`
Expected: build succeeds for all current TFMs (`netstandard2.0`, `net462`, `net8.0`). No warnings escalated to errors.
-- [ ] **Step 3: Add `net10.0` to library `TargetFrameworks`**
+- [ ] **Step 3: Add `net9.0` to library `TargetFrameworks`**
-In `src/TaskTupleAwaiter/TaskTupleAwaiter.csproj`, replace the existing `PropertyGroup` block to declare the TFMs explicitly. Current file has no `` element because it inherits `netstandard2.0;net462;net8.0` from a default — open the file and confirm where TFMs are declared, then add or extend that list to `netstandard2.0;net462;net8.0;net10.0`.
+In `src/TaskTupleAwaiter/TaskTupleAwaiter.csproj`, replace the existing `PropertyGroup` block to declare the TFMs explicitly. Current file has no `` element because it inherits `netstandard2.0;net462;net8.0` from a default — open the file and confirm where TFMs are declared, then add or extend that list to `netstandard2.0;net462;net8.0;net9.0`.
Concretely, the csproj should contain:
```xml
- netstandard2.0;net462;net8.0;net10.0
+ netstandard2.0;net462;net8.0;net9.0
TaskTupleAwaiter
Enable using the new Value Tuple structure to write elegant code that allows async methods to be fired in parallel despite having different return types
@@ -82,18 +82,18 @@ If a `TargetFrameworks` element already exists elsewhere (`Directory.Build.props
- [ ] **Step 4: Build all TFMs**
Run: `dotnet build -c Release`
-Expected: build succeeds for all four TFMs. Inspect `src/TaskTupleAwaiter/bin/Release/` and confirm four subfolders exist: `netstandard2.0`, `net462`, `net8.0`, `net10.0`, each containing `TaskTupleAwaiter.dll`.
+Expected: build succeeds for all four TFMs. Inspect `src/TaskTupleAwaiter/bin/Release/` and confirm four subfolders exist: `netstandard2.0`, `net462`, `net8.0`, `net9.0`, each containing `TaskTupleAwaiter.dll`.
- [ ] **Step 5: Run tests on every test TFM**
Run: `dotnet test -c Release`
-Expected: all tests pass on `net472`, `net8.0`, `net9.0`, `net10.0`, `net11.0` (the test project already targets these via `test/Directory.Build.props`).
+Expected: all tests pass on `net472`, `net8.0`, `net9.0`, `net9.0`, `net11.0` (the test project already targets these via `test/Directory.Build.props`).
- [ ] **Step 6: Commit**
```bash
git add src/TaskTupleAwaiter/TaskTupleAwaiter.csproj
-git commit -m "Add net10.0 TFM to library"
+git commit -m "Add net9.0 TFM to library"
```
---
@@ -118,7 +118,7 @@ Create `test/TaskTupleAwaiter.Benchmarks/TaskTupleAwaiter.Benchmarks.csproj` wit
false
Exe
- net8.0;net10.0
+ net8.0;net9.0
false
false
true
@@ -183,11 +183,11 @@ This top-level statement form makes `Program` an implicit class so `typeof(Progr
- [ ] **Step 2: Build benchmarks project (smoke)**
Run: `dotnet build test/TaskTupleAwaiter.Benchmarks -c Release`
-Expected: build succeeds for `net8.0` and `net10.0`. No warnings.
+Expected: build succeeds for `net8.0` and `net9.0`. No warnings.
- [ ] **Step 3: Run the benchmarks runner with `--help`**
-Run: `dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net10.0 -- --help`
+Run: `dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net9.0 -- --help`
Expected: BenchmarkDotNet help text printed. No exception. (No benchmark classes yet so `--list` would show zero items — `--help` is enough to confirm the entry point compiles and BDN initializes.)
- [ ] **Step 4: Commit**
@@ -273,12 +273,12 @@ Note: tabs for indentation per repo convention.
- [ ] **Step 2: Build to verify all four library TFMs of generated extensions resolve**
Run: `dotnet build test/TaskTupleAwaiter.Benchmarks -c Release`
-Expected: build succeeds for `net8.0` and `net10.0`. The benchmark uses `await (Task, Task, ...)` syntax which exercises the library's generated `GetAwaiter` extension methods.
+Expected: build succeeds for `net8.0` and `net9.0`. The benchmark uses `await (Task, Task, ...)` syntax which exercises the library's generated `GetAwaiter` extension methods.
- [ ] **Step 3: Smoke-run a single benchmark**
-Run: `dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net10.0 -- --filter "*Arity2_PreCompleted*"`
-Expected: BenchmarkDotNet runs and reports `Mean`, `Allocated`, etc. for `Arity2_PreCompleted` on net10. Takes 30-60 seconds. No crashes.
+Run: `dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net9.0 -- --filter "*Arity2_PreCompleted*"`
+Expected: BenchmarkDotNet runs and reports `Mean`, `Allocated`, etc. for `Arity2_PreCompleted` on net9. Takes 30-60 seconds. No crashes.
- [ ] **Step 4: Commit**
@@ -365,8 +365,8 @@ Expected: build succeeds.
- [ ] **Step 3: Smoke-run a single benchmark**
-Run: `dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net10.0 -- --filter "*NonGenericTupleAwaitBenchmarks.Arity2_PreCompleted*"`
-Expected: BDN reports results for `Arity2_PreCompleted` on net10. No crashes.
+Run: `dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net9.0 -- --filter "*NonGenericTupleAwaitBenchmarks.Arity2_PreCompleted*"`
+Expected: BDN reports results for `Arity2_PreCompleted` on net9. No crashes.
- [ ] **Step 4: Commit**
@@ -450,7 +450,7 @@ Expected: build succeeds.
- [ ] **Step 3: Smoke-run a single benchmark**
-Run: `dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net10.0 -- --filter "*ConfigureAwaitBenchmarks.Typed_Arity4_Bool_False*"`
+Run: `dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net9.0 -- --filter "*ConfigureAwaitBenchmarks.Typed_Arity4_Bool_False*"`
Expected: BDN reports results. No crashes.
- [ ] **Step 4: Commit**
@@ -481,14 +481,14 @@ BenchmarkDotNet harness measuring the allocation and time profile of awaiting `V
- Pre-completed (`Task.FromResult`) and async (`Task.Yield`) completion modes
- `ConfigureAwait(bool)` and `ConfigureAwait(ConfigureAwaitOptions)` paths
-The point of these benchmarks is to compare allocation profiles between `net8.0` (where the generated `Task.WhenAll(...)` call binds to `params Task[]` and heap-allocates the array) and `net10.0` (where it binds to `Task.WhenAll(ReadOnlySpan)` and stack-allocates).
+The point of these benchmarks is to compare allocation profiles between `net8.0` (where the generated `Task.WhenAll(...)` call binds to `params Task[]` and heap-allocates the array) and `net9.0` (where it binds to `Task.WhenAll(ReadOnlySpan)` and stack-allocates).
## Running
-Run all benchmarks on net10.0:
+Run all benchmarks on net9.0:
```sh
-dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net10.0
+dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net9.0
```
Run all benchmarks on net8.0:
@@ -500,15 +500,15 @@ dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net8.0
Filter to one class:
```sh
-dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net10.0 -- --filter "*TypedTupleAwaitBenchmarks*"
+dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net9.0 -- --filter "*TypedTupleAwaitBenchmarks*"
```
## Expected outcome
-After the generator change to emit collection-expression `Task.WhenAll([...])`:
+With the `net9.0` library target in place:
- **net8.0:** allocations and timing unchanged from baseline. Same IL as today.
-- **net10.0:** `Allocated` per op drops by approximately `24 + 8·N` bytes (the `Task[N]` array we no longer allocate). Mean time per op is flat or slightly improved due to reduced GC pressure.
+- **net9.0:** `Allocated` per op drops by approximately `24 + 8·N` bytes (the `Task[N]` array we no longer allocate). Mean time per op is flat or slightly improved due to reduced GC pressure.
## Not run in CI
@@ -527,7 +527,7 @@ git commit -m "Document how to run the benchmarks"
## Task 8: Capture baseline benchmark numbers
**Files:**
-- Create: `docs/superpowers/specs/2026-05-13-net10-span-whenall-benchmark-results.md`
+- Create: `docs/superpowers/specs/2026-05-13-net9-span-whenall-benchmark-results.md`
- [ ] **Step 1: Run full benchmark suite on net8.0**
@@ -535,16 +535,16 @@ Run: `dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net8.0
(Run `dotnet build -c Release` first if you skipped the smoke runs above.)
Expected: full suite completes. Takes several minutes. Per-method `Mean` and `Allocated` columns appear in the summary table.
-- [ ] **Step 2: Run full benchmark suite on net10.0**
+- [ ] **Step 2: Run full benchmark suite on net9.0**
-Run: `dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net10.0 --no-build`
+Run: `dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net9.0 --no-build`
Expected: full suite completes. Per-method `Mean` and `Allocated` columns appear.
- [ ] **Step 3: Capture summary tables**
BenchmarkDotNet writes Markdown reports to `BenchmarkDotNet.Artifacts/results/*.md` in the project directory. Locate the most recent `*-report-github.md` files (one per benchmark class per TFM).
-Create `docs/superpowers/specs/2026-05-13-net10-span-whenall-benchmark-results.md` with this structure:
+Create `docs/superpowers/specs/2026-05-13-net9-span-whenall-benchmark-results.md` with this structure:
```markdown
# Benchmark Results
@@ -563,9 +563,9 @@ Generator emits `Task.WhenAll(tasks.Item1, ..., tasks.ItemN)`.
[paste ConfigureAwaitBenchmarks-report-github.md table here]
-### net10.0
+### net9.0
-[paste the same three tables for net10.0 here]
+[paste the same three tables for net9.0 here]
## After generator change
@@ -577,13 +577,13 @@ Fill in the bracketed placeholders with the actual report contents. Keep the per
- [ ] **Step 4: Commit**
```bash
-git add docs/superpowers/specs/2026-05-13-net10-span-whenall-benchmark-results.md
+git add docs/superpowers/specs/2026-05-13-net9-span-whenall-benchmark-results.md
git commit -m "Capture baseline benchmark numbers"
```
---
-## Task 9: Update generator to emit collection expression
+## Task 9: Update generator to emit bracket-form syntax (stylistic)
**Files:**
- Modify: `src/TaskTupleAwaiter.Generator/TaskTupleExtensionsGenerator.cs` — line ~308, the `Items` helper
@@ -609,7 +609,7 @@ static string Items(int arity) =>
$"[{string.Join(", ", Enumerable.Range(1, arity).Select(i => $"tasks.Item{i}"))}]";
```
-Effect: every generated `Task.WhenAll({Items(arity)})` call site (5 locations in this file, all already inspecting `Task.WhenAll(...)`) now emits `Task.WhenAll([tasks.Item1, ..., tasks.ItemN])` — a C# 14 collection expression that the compiler binds to `params Task[]` on TFMs without the span overload (identical IL to today) and to `ReadOnlySpan` on net9+ (stack-allocated buffer).
+Effect: every generated `Task.WhenAll({Items(arity)})` call site (5 locations in this file, all already inspecting `Task.WhenAll(...)`) emits `Task.WhenAll([tasks.Item1, ..., tasks.ItemN])`. Overload selection comes from target framework availability and compiler preference: TFMs without the span overload bind to `params Task[]` (identical IL to today), while `net9.0` binds to `ReadOnlySpan` (stack-allocated buffer).
- [ ] **Step 3: Build the library**
@@ -618,7 +618,7 @@ Expected: build succeeds for all four TFMs. No warnings.
- [ ] **Step 4: Inspect generated source**
-Open `src/TaskTupleAwaiter/obj/Release/net10.0/generated/TaskTupleAwaiter.Generator/TaskTupleAwaiter.Generator.TaskTupleExtensionsGenerator/TaskTupleExtensions.g.cs` and confirm at least one `WhenAll` call uses brackets, e.g.:
+Open `src/TaskTupleAwaiter/obj/Release/net9.0/generated/TaskTupleAwaiter.Generator/TaskTupleAwaiter.Generator.TaskTupleExtensionsGenerator/TaskTupleExtensions.g.cs` and confirm at least one `WhenAll` call uses brackets, e.g.:
```csharp
_whenAllAwaiter = Task.WhenAll([tasks.Item1, tasks.Item2]).GetAwaiter();
@@ -635,7 +635,7 @@ Expected: all tests pass on every test TFM. Semantics are unchanged — only the
```bash
git add src/TaskTupleAwaiter.Generator/TaskTupleExtensionsGenerator.cs
-git commit -m "Emit collection-expression WhenAll calls for span overload on net10+"
+git commit -m "Emit bracket-form WhenAll calls in generated extensions"
```
---
@@ -643,26 +643,26 @@ git commit -m "Emit collection-expression WhenAll calls for span overload on net
## Task 10: Re-run benchmarks and record post-change numbers
**Files:**
-- Modify: `docs/superpowers/specs/2026-05-13-net10-span-whenall-benchmark-results.md`
+- Modify: `docs/superpowers/specs/2026-05-13-net9-span-whenall-benchmark-results.md`
- [ ] **Step 1: Re-run net8.0 suite**
Run: `dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net8.0`
Expected: completes. `Allocated` columns should be **identical** to baseline (within run-to-run noise). If they differ materially, the source change has affected the older TFM and Task 9's change needs revisiting.
-- [ ] **Step 2: Re-run net10.0 suite**
+- [ ] **Step 2: Re-run net9.0 suite**
-Run: `dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net10.0`
-Expected: completes. `Allocated` columns should be **lower** than baseline net10.0 for every benchmark, by approximately `24 + 8·N` bytes (typical `Task[N]` array overhead on 64-bit) per op for arity-N benchmarks.
+Run: `dotnet run -c Release --project test/TaskTupleAwaiter.Benchmarks -f net9.0`
+Expected: completes. `Allocated` columns should be **lower** than baseline net9.0 for every benchmark, by approximately `24 + 8·N` bytes (typical `Task[N]` array overhead on 64-bit) per op for arity-N benchmarks.
- [ ] **Step 3: Update the results doc**
-Open `docs/superpowers/specs/2026-05-13-net10-span-whenall-benchmark-results.md` and fill in the "After generator change" section with the new tables, mirroring the baseline structure.
+Open `docs/superpowers/specs/2026-05-13-net9-span-whenall-benchmark-results.md` and fill in the "After generator change" section with the new tables, mirroring the baseline structure.
-Add a "Delta summary" subsection at the bottom showing per-arity allocation reduction on net10, e.g.:
+Add a "Delta summary" subsection at the bottom showing per-arity allocation reduction on net9, e.g.:
```markdown
-## Delta summary (net10.0 only)
+## Delta summary (net9.0 only)
| Benchmark | Baseline allocated | After allocated | Reduction |
|---|---|---|---|
@@ -675,13 +675,13 @@ Fill the cells with actual numbers from the reports.
- [ ] **Step 4: Commit**
```bash
-git add docs/superpowers/specs/2026-05-13-net10-span-whenall-benchmark-results.md
+git add docs/superpowers/specs/2026-05-13-net9-span-whenall-benchmark-results.md
git commit -m "Record post-change benchmark numbers"
```
---
-## Task 11: Add `net10.0` to AOT smoke test
+## Task 11: Add `net9.0` to AOT smoke test
**Files:**
- Modify: `test/TaskTupleAwaiter.AotSmokeTest/TaskTupleAwaiter.AotSmokeTest.csproj`
@@ -697,19 +697,19 @@ In `test/TaskTupleAwaiter.AotSmokeTest/TaskTupleAwaiter.AotSmokeTest.csproj`, ch
to:
```xml
-net8.0;net10.0;net11.0
+net8.0;net9.0;net11.0
```
-- [ ] **Step 2: Publish for net10.0**
+- [ ] **Step 2: Publish for net9.0**
-Run: `dotnet publish test/TaskTupleAwaiter.AotSmokeTest -c Release -f net10.0`
-Expected: publish succeeds. The published exe is in `test/TaskTupleAwaiter.AotSmokeTest/bin/Release/net10.0//publish/`. AOT analyzer emits no errors or warnings.
+Run: `dotnet publish test/TaskTupleAwaiter.AotSmokeTest -c Release -f net9.0`
+Expected: publish succeeds. The published exe is in `test/TaskTupleAwaiter.AotSmokeTest/bin/Release/net9.0//publish/`. AOT analyzer emits no errors or warnings.
- [ ] **Step 3: Run the published exe**
Run: locate the published exe (the project's `` is host-specific; on Windows x64 it'd be `win-x64`) and run it.
-For Windows x64: `test\TaskTupleAwaiter.AotSmokeTest\bin\Release\net10.0\win-x64\publish\TaskTupleAwaiter.AotSmokeTest.exe`
+For Windows x64: `test\TaskTupleAwaiter.AotSmokeTest\bin\Release\net9.0\win-x64\publish\TaskTupleAwaiter.AotSmokeTest.exe`
Expected: program prints the expected smoke output (matches the existing net8.0 / net11.0 runs) and exits 0.
@@ -722,7 +722,7 @@ Expected: publishes for all three TFMs without error.
```bash
git add test/TaskTupleAwaiter.AotSmokeTest/TaskTupleAwaiter.AotSmokeTest.csproj
-git commit -m "Add net10.0 to AOT smoke test TFMs"
+git commit -m "Add net9.0 to AOT smoke test TFMs"
```
---
@@ -744,7 +744,7 @@ In `CLAUDE.md`, find the Technology Stack table. Change the "Library TFMs" row f
to:
```
-| Library TFMs | netstandard2.0, net462, net8.0, net10.0 |
+| Library TFMs | netstandard2.0, net462, net8.0, net9.0 |
```
- [ ] **Step 2: Update `CLAUDE.md` Key Design Decisions section**
@@ -752,7 +752,7 @@ to:
Under the "Source Generator (`TaskTupleExtensionsGenerator`)" subsection, add a new bullet after the existing `Feature-detects ConfigureAwaitOptions...` bullet:
```markdown
-- Emits `Task.WhenAll([tasks.Item1, ..., tasks.ItemN])` as a **collection expression** so the C# compiler picks `params Task[]` on `netstandard2.0`/`net462`/`net8.0` (same IL as before) and `Task.WhenAll(ReadOnlySpan)` on `net10.0`+, eliminating the per-await `Task[]` heap allocation. No runtime feature detection needed.
+- Emits `Task.WhenAll([tasks.Item1, ..., tasks.ItemN])`. Overload selection comes from the targeted TFM: `params Task[]` on `netstandard2.0`/`net462`/`net8.0` (same IL as before) and `Task.WhenAll(ReadOnlySpan)` on `net9.0`+, eliminating the per-await `Task[]` heap allocation. No runtime feature detection needed.
```
- [ ] **Step 3: Update `README.md`**
@@ -767,10 +767,10 @@ Add:
- `netstandard2.0` — broadest compatibility
- `net462` — .NET Framework consumers
- `net8.0` — LTS
-- `net10.0` — current LTS; uses `Task.WhenAll(ReadOnlySpan)` to eliminate the per-await `Task[]` heap allocation
+- `net9.0` — STS; uses `Task.WhenAll(ReadOnlySpan)` to eliminate the per-await `Task[]` heap allocation
```
-(Adjust the wording to fit the existing README tone — keep the bullet about net10's allocation win.)
+(Adjust the wording to fit the existing README tone — keep the bullet about net9's allocation win.)
- [ ] **Step 4: Verify both docs render correctly**
@@ -781,7 +781,7 @@ Open `CLAUDE.md` and `README.md` and confirm formatting looks right.
```bash
git add CLAUDE.md README.md
-git commit -m "Document net10.0 TFM and span WhenAll emission"
+git commit -m "Document net9.0 TFM and span WhenAll emission"
```
---
@@ -801,20 +801,20 @@ Expected: all tests pass on every test TFM.
- [ ] **Step 3: AOT publish for every smoke-test TFM**
Run: `dotnet publish test/TaskTupleAwaiter.AotSmokeTest -c Release`
-Expected: publishes for `net8.0`, `net10.0`, `net11.0`. No AOT warnings.
+Expected: publishes for `net8.0`, `net9.0`, `net11.0`. No AOT warnings.
- [ ] **Step 4: Confirm package layout**
Run: `dotnet pack src/TaskTupleAwaiter -c Release`
-Expected: produces `TaskTupleAwaiter..nupkg` containing `lib/netstandard2.0/`, `lib/net462/`, `lib/net8.0/`, `lib/net10.0/`. Inspect with `dotnet tool run dotnet-pack-check` or open the `.nupkg` as a zip and verify the four lib folders.
+Expected: produces `TaskTupleAwaiter..nupkg` containing `lib/netstandard2.0/`, `lib/net462/`, `lib/net8.0/`, `lib/net9.0/`. Inspect with `dotnet tool run dotnet-pack-check` or open the `.nupkg` as a zip and verify the four lib folders.
- [ ] **Step 5: Confirm benchmark results doc is complete**
-Open `docs/superpowers/specs/2026-05-13-net10-span-whenall-benchmark-results.md` and verify:
+Open `docs/superpowers/specs/2026-05-13-net9-span-whenall-benchmark-results.md` and verify:
- Both "Baseline" and "After generator change" sections are populated.
- "Delta summary" table exists with non-empty rows.
- net8.0 delta values are ≈ 0 (within noise).
-- net10.0 delta values show consistent allocation reduction.
+- net9.0 delta values show consistent allocation reduction.
If any of those is missing, return to Task 8 or Task 10 to capture the missing data.
@@ -836,5 +836,5 @@ No automatic push. Hand back to the user with the commit list and offer to open
- **Don't `--no-verify` past failing hooks.** If a pre-commit hook fails, diagnose and fix rather than bypass.
- **Generator output location** depends on the project's `obj/` structure and the analyzer's namespace. If you can't find `TaskTupleExtensions.g.cs` in the path given, run `dotnet build` with `-bl` to produce a binlog and inspect, or `find obj -name TaskTupleExtensions.g.cs`.
- **BenchmarkDotNet artifacts** land in `BenchmarkDotNet.Artifacts/` adjacent to the project directory. Don't commit them — add to `.gitignore` if not already excluded (the repo's existing `.gitignore` likely covers `bin/` and `obj/` but not `BenchmarkDotNet.Artifacts/`; add an entry if missing).
-- **If a `dotnet --list-sdks` doesn't show .NET 10** at Task 1, install it via your platform's installer or `winget install Microsoft.DotNet.SDK.10` before continuing. Don't try to suppress the missing-TFM error.
+- **If a `dotnet --list-sdks` doesn't show .NET 9** at Task 1, install it via your platform's installer or `winget install Microsoft.DotNet.SDK.9` before continuing. Don't try to suppress the missing-TFM error.
- **If the collection expression fails to compile** on netstandard2.0/net462 at Task 9 (e.g., because of a `LangVersion` quirk), check `Directory.Build.props` — it sets `LangVersion=latest` which should handle C# 14 collection expressions on all TFMs. If not, the fallback is to feature-detect at the generator level (the spec's "Risks" section calls this out as the contingency path).
diff --git a/docs/superpowers/specs/2026-05-13-net9-span-whenall-benchmark-results.md b/docs/superpowers/specs/2026-05-13-net9-span-whenall-benchmark-results.md
new file mode 100644
index 0000000..0959e89
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-13-net9-span-whenall-benchmark-results.md
@@ -0,0 +1,186 @@
+# Benchmark Results
+
+Captured on 2026-05-14 with the shipped state of this branch (HEAD `7c98fd2`).
+
+- **BenchmarkDotNet** v0.15.8
+- **Machine:** Intel Core Ultra 9 185H, 1 CPU, 22 logical / 16 physical cores, Windows 11 25H2
+- **.NET SDK** 11.0.100-preview.4.26230.115
+- **Hosts:**
+ - net9.0 runs: `.NET 9.0.8, X64 RyuJIT x86-64-v3`
+ - net8.0 runs: `.NET 8.0.27 (8.0.2726.22922), X64 RyuJIT x86-64-v3`
+
+## What changed on this branch
+
+Three layered changes deliver the per-await allocation reduction and the runtime speed-up:
+
+1. **Added `net9.0` to the library `TargetFrameworks`.** On net9.0 the C# 13+ compiler binds the generated `Task.WhenAll(...)` call to `Task.WhenAll(ReadOnlySpan)` (added in .NET 9) instead of `Task.WhenAll(params Task[])`, stack-allocating the buffer.
+2. **Dropped `record struct` to plain `readonly struct`** on the generated awaiter types (no equality semantics needed for awaiters, drops a substantial chunk of synthesized members per arity).
+3. **Annotated trivial forwarders with `[MethodImpl(MethodImplOptions.AggressiveInlining)]`**: `IsCompleted` accessor, `OnCompleted`, `UnsafeOnCompleted`, the `GetAwaiter` / `ConfigureAwait` extension methods, and the `TupleConfiguredTaskAwaitable.GetAwaiter()` helper. Constructors and `GetResult` are deliberately left un-annotated because their bodies grow with arity.
+
+## Methodology
+
+Each run executed the full benchmark suite with default BenchmarkDotNet config: full warmup, multiple iterations, statistical analysis. Filters: `--filter "*"` against the harness in `benches/TaskTupleAwaiter.Benchmarks/`.
+
+The numbers in this doc are direct copies of the BDN markdown reports for the `net9.0` and `net8.0` builds of the **same source tree** (HEAD `7c98fd2`). They show what a consumer running on .NET 9 vs .NET 8 will observe when calling into the shipped library.
+
+## TypedTupleAwaitBenchmarks
+
+`await (Task, Task, ...)` returning a tuple of results.
+
+### net9.0
+
+| Method | Mean | Error | StdDev | Gen0 | Allocated |
+|--------------------- |------------:|----------:|----------:|-------:|----------:|
+| Arity2_PreCompleted | 33.81 ns | 1.100 ns | 3.242 ns | 0.0057 | 72 B |
+| Arity4_PreCompleted | 47.41 ns | 0.963 ns | 1.661 ns | 0.0057 | 72 B |
+| Arity8_PreCompleted | 86.24 ns | 1.754 ns | 2.281 ns | 0.0057 | 72 B |
+| Arity16_PreCompleted | 172.21 ns | 3.466 ns | 5.497 ns | 0.0515 | 648 B |
+| Arity2_Async | 1,045.53 ns | 20.764 ns | 36.366 ns | 0.0324 | 430 B |
+| Arity4_Async | 1,716.75 ns | 21.497 ns | 20.108 ns | 0.0477 | 612 B |
+| Arity8_Async | 3,082.50 ns | 58.255 ns | 64.750 ns | 0.0763 | 996 B |
+| Arity16_Async | 6,648.97 ns | 128.380 ns | 152.827 ns | 0.1373 | 1778 B |
+
+### net8.0
+
+| Method | Mean | Error | StdDev | Gen0 | Allocated |
+|--------------------- |------------:|----------:|----------:|-------:|----------:|
+| Arity2_PreCompleted | 41.41 ns | 0.829 ns | 1.018 ns | 0.0095 | 120 B |
+| Arity4_PreCompleted | 61.23 ns | 0.960 ns | 0.851 ns | 0.0107 | 136 B |
+| Arity8_PreCompleted | 98.95 ns | 2.011 ns | 2.394 ns | 0.0134 | 168 B |
+| Arity16_PreCompleted | 259.30 ns | 5.159 ns | 9.562 ns | 0.0644 | 808 B |
+| Arity2_Async | 1,017.41 ns | 20.153 ns | 23.208 ns | 0.0362 | 469 B |
+| Arity4_Async | 1,610.15 ns | 19.056 ns | 17.825 ns | 0.0515 | 664 B |
+| Arity8_Async | 3,021.31 ns | 57.586 ns | 59.137 ns | 0.0839 | 1075 B |
+| Arity16_Async | 5,804.34 ns | 33.364 ns | 31.208 ns | 0.1450 | 1894 B |
+
+### net9.0 vs net8.0 — allocation delta
+
+| Method | net8.0 | net9.0 | Δ (B) | Δ (%) |
+|--------------------- |-------:|--------:|------:|------:|
+| Arity2_PreCompleted | 120 B | 72 B | -48 | -40% |
+| Arity4_PreCompleted | 136 B | 72 B | -64 | -47% |
+| Arity8_PreCompleted | 168 B | 72 B | -96 | -57% |
+| Arity16_PreCompleted | 808 B | 648 B | -160 | -20% |
+| Arity2_Async | 469 B | 430 B | -39 | -8% |
+| Arity4_Async | 664 B | 612 B | -52 | -8% |
+| Arity8_Async | 1075 B | 996 B | -79 | -7% |
+| Arity16_Async | 1894 B | 1778 B | -116 | -6% |
+
+## NonGenericTupleAwaitBenchmarks
+
+`await (Task, Task, ...)` returning `void` (the awaiter just observes completion).
+
+### net9.0
+
+| Method | Mean | Error | StdDev | Median | Gen0 | Allocated |
+|--------------------- |------------:|----------:|----------:|------------:|-------:|----------:|
+| Arity2_PreCompleted | 32.68 ns | 1.230 ns | 3.450 ns | 32.53 ns | 0.0057 | 72 B |
+| Arity4_PreCompleted | 42.10 ns | 0.675 ns | 0.631 ns | 42.06 ns | 0.0057 | 72 B |
+| Arity8_PreCompleted | 68.80 ns | 1.404 ns | 3.140 ns | 66.87 ns | 0.0057 | 72 B |
+| Arity16_PreCompleted | 122.53 ns | 2.439 ns | 3.338 ns | 122.99 ns | 0.0057 | 72 B |
+| Arity2_Async | 926.70 ns | 17.806 ns | 16.656 ns | 929.20 ns | 0.0267 | 352 B |
+| Arity4_Async | 1,499.61 ns | 15.313 ns | 14.324 ns | 1,498.84 ns | 0.0420 | 535 B |
+| Arity8_Async | 2,746.35 ns | 15.477 ns | 13.720 ns | 2,744.15 ns | 0.0687 | 905 B |
+| Arity16_Async | 5,716.92 ns | 41.383 ns | 36.685 ns | 5,726.46 ns | 0.1297 | 1662 B |
+
+### net8.0
+
+| Method | Mean | Error | StdDev | Median | Gen0 | Allocated |
+|--------------------- |------------:|-----------:|----------:|------------:|-------:|----------:|
+| Arity2_PreCompleted | 45.57 ns | 1.640 ns | 4.836 ns | 46.22 ns | 0.0095 | 120 B |
+| Arity4_PreCompleted | 71.58 ns | 2.982 ns | 8.793 ns | 69.49 ns | 0.0107 | 136 B |
+| Arity8_PreCompleted | 130.76 ns | 12.259 ns | 36.145 ns | 109.91 ns | 0.0134 | 168 B |
+| Arity16_PreCompleted | 182.65 ns | 3.636 ns | 7.092 ns | 180.90 ns | 0.0184 | 232 B |
+| Arity2_Async | 970.39 ns | 16.301 ns | 15.248 ns | 966.04 ns | 0.0305 | 398 B |
+| Arity4_Async | 1,503.97 ns | 14.793 ns | 13.837 ns | 1,502.62 ns | 0.0458 | 589 B |
+| Arity8_Async | 2,868.08 ns | 52.956 ns | 49.535 ns | 2,870.56 ns | 0.0763 | 984 B |
+| Arity16_Async | 5,742.70 ns | 102.436 ns | 95.819 ns | 5,723.73 ns | 0.1373 | 1814 B |
+
+### net9.0 vs net8.0 — allocation delta
+
+| Method | net8.0 | net9.0 | Δ (B) | Δ (%) |
+|--------------------- |-------:|--------:|------:|------:|
+| Arity2_PreCompleted | 120 B | 72 B | -48 | -40% |
+| Arity4_PreCompleted | 136 B | 72 B | -64 | -47% |
+| Arity8_PreCompleted | 168 B | 72 B | -96 | -57% |
+| Arity16_PreCompleted | 232 B | 72 B | -160 | -69% |
+| Arity2_Async | 398 B | 352 B | -46 | -12% |
+| Arity4_Async | 589 B | 535 B | -54 | -9% |
+| Arity8_Async | 984 B | 905 B | -79 | -8% |
+| Arity16_Async | 1814 B | 1662 B | -152 | -8% |
+
+## ConfigureAwaitBenchmarks
+
+Spot checks of the `ConfigureAwait(bool)` and `ConfigureAwait(ConfigureAwaitOptions)` paths, at arities 4 and 16, in both the typed and non-generic tuple flavors.
+
+### net9.0
+
+| Method | Mean | Error | StdDev | Gen0 | Allocated |
+|-------------------------------- |----------:|---------:|----------:|-------:|----------:|
+| Typed_Arity4_Bool_False | 44.20 ns | 0.907 ns | 1.726 ns | 0.0057 | 72 B |
+| Typed_Arity4_Options_None | 46.14 ns | 0.842 ns | 1.235 ns | 0.0057 | 72 B |
+| Typed_Arity16_Bool_False | 165.95 ns | 3.283 ns | 4.493 ns | 0.0515 | 648 B |
+| Typed_Arity16_Options_None | 168.75 ns | 3.269 ns | 4.475 ns | 0.0515 | 648 B |
+| NonGeneric_Arity4_Bool_False | 47.52 ns | 0.979 ns | 2.364 ns | 0.0057 | 72 B |
+| NonGeneric_Arity16_Options_None | 127.29 ns | 3.042 ns | 7.960 ns | 0.0057 | 72 B |
+
+### net8.0
+
+| Method | Mean | Error | StdDev | Gen0 | Allocated |
+|-------------------------------- |----------:|---------:|----------:|-------:|----------:|
+| Typed_Arity4_Bool_False | 58.26 ns | 1.012 ns | 0.897 ns | 0.0107 | 136 B |
+| Typed_Arity4_Options_None | 58.48 ns | 0.700 ns | 0.654 ns | 0.0107 | 136 B |
+| Typed_Arity16_Bool_False | 272.90 ns | 5.401 ns | 11.274 ns | 0.0644 | 808 B |
+| Typed_Arity16_Options_None | 314.93 ns | 6.414 ns | 18.812 ns | 0.0644 | 808 B |
+| NonGeneric_Arity4_Bool_False | 69.85 ns | 1.593 ns | 4.697 ns | 0.0107 | 136 B |
+| NonGeneric_Arity16_Options_None | 205.68 ns | 4.472 ns | 13.187 ns | 0.0184 | 232 B |
+
+### net9.0 vs net8.0 — allocation delta
+
+| Method | net8.0 | net9.0 | Δ (B) | Δ (%) |
+|-------------------------------- |-------:|--------:|------:|------:|
+| Typed_Arity4_Bool_False | 136 B | 72 B | -64 | -47% |
+| Typed_Arity4_Options_None | 136 B | 72 B | -64 | -47% |
+| Typed_Arity16_Bool_False | 808 B | 648 B | -160 | -20% |
+| Typed_Arity16_Options_None | 808 B | 648 B | -160 | -20% |
+| NonGeneric_Arity4_Bool_False | 136 B | 72 B | -64 | -47% |
+| NonGeneric_Arity16_Options_None | 232 B | 72 B | -160 | -69% |
+
+## Headline summary
+
+For the most common consumer pattern — a typed tuple of pre-completed `Task` values, arity 2–16 — a .NET 9 consumer sees:
+
+- **Allocations: 40–57% lower** at arities 2/4/8 (where the eliminated `Task[N]` array dominates) and **20% lower** at arity 16 (where the state-machine box itself is the larger contributor).
+- **Mean time: 13–34% lower.** Some of that is the lack of the heap allocation; the rest comes from JIT improvements between .NET 8 and .NET 9 plus the inlining hints.
+
+The non-generic tuple path is even more dramatic at arity 16 (`-69%` allocation) because the non-generic awaiter has no per-`T` metadata in the box — the `Task[]` was a larger share of its total.
+
+The async (yielding) benchmarks see smaller proportional improvements because the async state machine itself dominates allocations — but they're consistently lower on net9.0 too, by `4–12%`.
+
+## Library DLL size
+
+For reference, the shipped library binaries on the `7c98fd2` HEAD:
+
+| TFM | Size |
+|----------------|-----------:|
+| netstandard2.0 | 53,248 B |
+| net462 | 53,248 B |
+| net8.0 | 57,856 B |
+| net9.0 | 64,512 B |
+
+Down from ~80–92 KB before dropping `record struct` to plain `readonly struct`. The `[MethodImpl]` attribute additions cost 0 B (the metadata fits within existing PE 4 KB alignment padding).
+
+## Reproducing locally
+
+```sh
+dotnet run -c Release --project benches/TaskTupleAwaiter.Benchmarks -f net9.0
+dotnet run -c Release --project benches/TaskTupleAwaiter.Benchmarks -f net8.0
+```
+
+Filter to one class, e.g.:
+
+```sh
+dotnet run -c Release --project benches/TaskTupleAwaiter.Benchmarks -f net9.0 -- --filter "*TypedTupleAwaitBenchmarks*"
+```
+
+Each TFM takes ~13 minutes to run the full suite end-to-end on this machine. Reports land in `BenchmarkDotNet.Artifacts/` (gitignored).
diff --git a/docs/superpowers/specs/2026-05-13-net10-span-whenall-design.md b/docs/superpowers/specs/2026-05-13-net9-span-whenall-design.md
similarity index 59%
rename from docs/superpowers/specs/2026-05-13-net10-span-whenall-design.md
rename to docs/superpowers/specs/2026-05-13-net9-span-whenall-design.md
index 0607274..ac13160 100644
--- a/docs/superpowers/specs/2026-05-13-net10-span-whenall-design.md
+++ b/docs/superpowers/specs/2026-05-13-net9-span-whenall-design.md
@@ -1,11 +1,11 @@
-# Design — net10.0 TFM + span-based `Task.WhenAll` + BenchmarkDotNet
+# Design — net9.0 TFM + span-based `Task.WhenAll` + BenchmarkDotNet
**Date:** 2026-05-13
**Status:** Drafted, pending review
## Goal
-Cut one `Task[]` heap allocation per `await` of a task tuple for consumers running on .NET 10+, without compromising the existing `netstandard2.0` / `net462` / `net8.0` surface or adding conditional compilation complexity. Establish a BenchmarkDotNet project so this and future perf claims can be measured rather than guessed.
+Cut one `Task[]` heap allocation per `await` of a task tuple for consumers running on .NET 9+, without compromising the existing `netstandard2.0` / `net462` / `net8.0` surface or adding conditional compilation complexity. Establish a BenchmarkDotNet project so this and future perf claims can be measured rather than guessed.
## Background
@@ -17,22 +17,22 @@ Task.WhenAll(tasks.Item1, tasks.Item2, ..., tasks.ItemN)
That call binds to `Task.WhenAll(params Task[])`, which heap-allocates a `Task[N]` on every await. For a tuple of arity 16, that is 16 references plus array header per await.
-.NET 9 added `Task.WhenAll(scoped ReadOnlySpan)` (and its `Task` flavor). When a C# 13+ collection expression targets a method call that has both `params Task[]` and `ReadOnlySpan` overloads, the compiler prefers the span overload and stack-allocates the buffer — zero heap allocation.
+.NET 9 added `Task.WhenAll(scoped ReadOnlySpan)` (and its `Task` flavor). For this library, compiling the generated awaiter calls in the `net9.0` target is what enables binding to the span overload and stack-allocating the buffer — zero heap allocation.
.NET 11 / C# 15's headline feature for async perf is *runtime async*. It is a **caller-side compile feature**: the consumer's async method codegen changes. The library's awaiter structs already implement the standard `ICriticalNotifyCompletion` / `IsCompleted` / `GetResult` pattern, so consumers compiling with `runtime-async=on` benefit automatically. **No library changes are required for runtime async**, and the tests already opt in for `net11.0`.
## Scope
In scope:
-1. Add `net10.0` to the library's `TargetFrameworks`.
-2. Change the generator to emit `Task.WhenAll([t1, ..., tN])` (collection-expression form) in place of `Task.WhenAll(t1, ..., tN)`.
+1. Add `net9.0` to the library's `TargetFrameworks`.
+2. Keep the generator emitting `Task.WhenAll([t1, ..., tN])` (bracket-form syntax) as a stylistic choice.
3. Add a `test/TaskTupleAwaiter.Benchmarks` project using BenchmarkDotNet.
-4. Add `net10.0` to the AOT smoke-test TFMs so the new generated code path is verified under NativeAOT.
+4. Add `net9.0` to the AOT smoke-test TFMs so the new generated code path is verified under NativeAOT.
Out of scope:
- Dropping `record struct` in favor of `struct` (speculative; would be benchmark-driven future work).
- `[MethodImpl(AggressiveInlining)]` annotations (same reason).
-- A `net11.0` TFM for the library (no library code difference between net10 and net11 — runtime async benefits consumers, not library authors).
+- A `net11.0` TFM for the library (no library code difference between net9 and net11 — runtime async benefits consumers, not library authors).
- Heterogeneous `Task.WhenAll` (does not exist in BCL).
- Pooling, custom WhenAll, or other in-house allocations work.
@@ -40,10 +40,10 @@ Out of scope:
### Library TFMs
-`src/TaskTupleAwaiter/TaskTupleAwaiter.csproj` adds `net10.0`:
+`src/TaskTupleAwaiter/TaskTupleAwaiter.csproj` adds `net9.0`:
```xml
-netstandard2.0;net462;net8.0;net10.0
+netstandard2.0;net462;net8.0;net9.0
```
Resulting NuGet asset selection:
@@ -52,14 +52,14 @@ Resulting NuGet asset selection:
|---|---|
| .NET Framework 4.6.2+ | `net462` |
| .NET Standard 2.0 consumers | `netstandard2.0` |
-| .NET 8 / .NET 9 | `net8.0` |
-| .NET 10+ | `net10.0` ← new, gets span optimization |
+| .NET 8 | `net8.0` |
+| .NET 9+ | `net9.0` ← new, gets span optimization |
-.NET 9 consumers stay on `net8.0` rather than gaining a `net9.0` asset. This is deliberate: .NET 9 is STS and adding another TFM for one minor optimization isn't justified. Consumers wanting the win update to .NET 10 (current LTS).
+.NET 9+ consumers get the `net9.0` asset and the span-based `Task.WhenAll` path.
### Generator change
-`src/TaskTupleAwaiter.Generator/TaskTupleExtensionsGenerator.cs` — only the `Items(int arity)` helper changes (or its call sites), so that every `Task.WhenAll(tasks.Item1, ..., tasks.ItemN)` becomes `Task.WhenAll([tasks.Item1, ..., tasks.ItemN])`. This affects:
+`src/TaskTupleAwaiter.Generator/TaskTupleExtensionsGenerator.cs` — only the `Items(int arity)` helper changes (or its call sites), so every call remains in `Task.WhenAll([tasks.Item1, ..., tasks.ItemN])` bracket form. This affects:
- `AppendTupleTaskAwaiterStruct` — typed awaiter constructor
- `AppendTupleConfiguredTaskAwaitableStruct` — typed configured awaiter inner constructor
@@ -67,12 +67,12 @@ Resulting NuGet asset selection:
**No feature detection is needed.** The C# compiler picks the overload per target framework:
-- `netstandard2.0` / `net462` / `net8.0`: collection expression targets `params Task[]` — compiles to `new Task[]{...}`, identical IL to today.
-- `net10.0`: collection expression targets `ReadOnlySpan` — compiles to a stack-allocated buffer via the inline-array pattern the compiler generates for collection expressions. Zero heap allocation for the task list.
+- `netstandard2.0` / `net462` / `net8.0`: calls bind to `params Task[]` — compiles to `new Task[]{...}`, identical IL to today.
+- `net9.0`: calls bind to `ReadOnlySpan` — compiles to a stack-allocated buffer. Zero heap allocation for the task list.
Overload-resolution note: in the typed awaiter, the generic parameters `T1..TN` are distinct type parameters, so `Task.WhenAll(params Task[])` and its span sibling do not bind. Only the non-generic overloads (`params Task[]` and `ReadOnlySpan`) match — exactly what we want.
-The existing `hasAwaitOptionsProvider` feature detection stays as-is; it controls API surface (`ConfigureAwaitOptions` overload) and is unrelated to this change.
+The existing `hasAwaitOptionsProvider` feature-detection path stays as-is; it controls API surface (`ConfigureAwaitOptions` overload availability) and is unrelated to this TFM/overload-binding change.
### Benchmark project
@@ -91,7 +91,7 @@ test/TaskTupleAwaiter.Benchmarks/
`TaskTupleAwaiter.Benchmarks.csproj`:
-- `net8.0;net10.0` — running benchmarks against both TFMs in one job produces a side-by-side allocation table.
+- `net8.0;net9.0` — running benchmarks against both TFMs in one job produces a side-by-side allocation table.
- `Exe`, `false`.
- `PackageReference Include="BenchmarkDotNet"`.
- Same pattern as `TaskTupleAwaiter.AotSmokeTest`: remove the `Shouldly`, `Xunit` usings and `xunit.v3.mtp-v2` / `Shouldly` package references that `test/Directory.Build.props` injects, and set `TestingPlatformDotnetTestSupport=false` so `dotnet test` ignores this project. Set `false`.
@@ -108,32 +108,32 @@ Benchmark coverage:
`Program.cs` is the standard `BenchmarkSwitcher.FromAssembly(...).Run(args)` entry point so individual benchmark classes can be filtered from the command line.
-CI: benchmarks are not run in CI (BenchmarkDotNet runs are slow and non-deterministic enough to flake CI). They are a manual `dotnet run -c Release -f net10.0` operation. Document this in the project README addendum.
+CI: benchmarks are not run in CI (BenchmarkDotNet runs are slow and non-deterministic enough to flake CI). They are a manual `dotnet run -c Release -f net9.0` operation. Document this in the project README addendum.
### AOT smoke-test update
-`test/TaskTupleAwaiter.AotSmokeTest/TaskTupleAwaiter.AotSmokeTest.csproj` currently has `net8.0;net11.0`. Add `net10.0` so the new generator output (collection expression binding to `ReadOnlySpan` overload) is exercised under NativeAOT.
+`test/TaskTupleAwaiter.AotSmokeTest/TaskTupleAwaiter.AotSmokeTest.csproj` currently has `net8.0;net11.0`. Add `net9.0` so the net9.0-generated code path (binding to `ReadOnlySpan` overload) is exercised under NativeAOT.
-Result: `net8.0;net10.0;net11.0`.
+Result: `net8.0;net9.0;net11.0`.
## Testing & verification
1. **Existing tests pass on every existing TFM.** The semantics of the awaiter don't change — only the allocation profile of the `WhenAll` call.
-2. **Existing tests pass on the new net10.0 TFM.** `test/Directory.Build.props` already includes `net10.0` in `TargetFrameworks`, so the existing xUnit suite picks it up automatically.
-3. **AOT smoke test passes on net10.0.** Publishes successfully and the smoke-test program prints its expected output. Validates the generated code is trim-/AOT-safe under the new code path.
-4. **Benchmark report**, captured manually before and after the generator change. The expected outcome is `Allocated` per op drops by `sizeof(Task[N])` (24 + 8·N bytes on 64-bit) for every benchmark on net10.0, and is unchanged on net8.0. Mean time per op should be flat or slightly improved on net10.0 due to less GC pressure; net8.0 should be unchanged.
+2. **Existing tests pass on the new net9.0 TFM.** `test/Directory.Build.props` already includes `net9.0` in `TargetFrameworks`, so the existing xUnit suite picks it up automatically.
+3. **AOT smoke test passes on net9.0.** Publishes successfully and the smoke-test program prints its expected output. Validates the generated code is trim-/AOT-safe under the new code path.
+4. **Benchmark report**, captured manually before and after the generator change. The expected outcome is `Allocated` per op drops by `sizeof(Task[N])` (24 + 8·N bytes on 64-bit) for every benchmark on net9.0, and is unchanged on net8.0. Mean time per op should be flat or slightly improved on net9.0 due to less GC pressure; net8.0 should be unchanged.
## Risks
- **Collection-expression IL on the older TFMs.** Source emits `[t1, ..., tN]` but on netstandard2.0/net462/net8.0 this still compiles to `new Task[]{...}` and binds to `params Task[]`. Verify by inspecting generated IL on the netstandard2.0 build; if for any reason the IL differs from today's `new Task[]{...}` shape, treat that as a build break and fall back to feature-detected emission. **Mitigation:** spot-check via `ildasm` / `dotnet-ildasm` on one arity per TFM during implementation.
-- **NuGet asset selection.** Verify the resulting `.nupkg` exposes the four library folders correctly and that a sample net10.0 consumer picks `lib/net10.0/`. Done via a smoke restore against a scratch consumer project in the implementation plan.
-- **AOT under net10.0.** The collection-expression form lowers to an inline array struct with `[InlineArray]`. The compiler-generated type is AOT-friendly in net8+ (validated by countless BCL APIs), but the smoke test confirms it for our specific code shape.
-- **CI SDK availability.** The CI workflow must have the .NET 10 SDK installed. Confirm against the existing `global.json` / workflow file; add a setup step if missing.
+- **NuGet asset selection.** Verify the resulting `.nupkg` exposes the four library folders correctly and that a sample net9.0 consumer picks `lib/net9.0/`. Done via a smoke restore against a scratch consumer project in the implementation plan.
+- **AOT under net9.0.** The collection-expression form lowers to an inline array struct with `[InlineArray]`. The compiler-generated type is AOT-friendly in net8+ (validated by countless BCL APIs), but the smoke test confirms it for our specific code shape.
+- **CI SDK availability.** The CI workflow must have the .NET 9 SDK installed. Confirm against the existing `global.json` / workflow file; add a setup step if missing.
## Documentation
-- `CLAUDE.md`: update the Technology Stack table to include `net10.0` as a library TFM, and add a short note under "Key Design Decisions" that the generator emits collection-expression `WhenAll` calls so the compiler can pick the span overload on net9+.
-- `README.md`: a short bullet under a "Performance" or "Targets" section noting net10.0 consumers get span-based `WhenAll`.
+- `CLAUDE.md`: update the Technology Stack table to include `net9.0` as a library TFM, and add a short note under "Key Design Decisions" that overload selection is driven by target framework (with bracket-form call syntax retained for style).
+- `README.md`: a short bullet under a "Performance" or "Targets" section noting net9.0 consumers get span-based `WhenAll`.
- Benchmark project gets a brief `README.md` describing how to run it locally.
## Open questions
diff --git a/src/TaskTupleAwaiter.Generator/TaskTupleExtensionsGenerator.cs b/src/TaskTupleAwaiter.Generator/TaskTupleExtensionsGenerator.cs
index 05a8b6d..3be7656 100644
--- a/src/TaskTupleAwaiter.Generator/TaskTupleExtensionsGenerator.cs
+++ b/src/TaskTupleAwaiter.Generator/TaskTupleExtensionsGenerator.cs
@@ -68,10 +68,12 @@ static void AppendTypedArity1(StringBuilder sb, bool hasAwaitOptions)
#region (Task)
/// This type and its members are intended for use by the compiler.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public static TaskAwaiter GetAwaiter(this ValueTuple> tasks) =>
tasks.Item1.GetAwaiter();
/// This type and its members are intended for use by the compiler.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public static ConfiguredTaskAwaitable ConfigureAwait(this ValueTuple> tasks, bool continueOnCapturedContext) =>
tasks.Item1.ConfigureAwait(continueOnCapturedContext);
@@ -81,9 +83,10 @@ public static ConfiguredTaskAwaitable ConfigureAwait(this ValueTupleThis type and its members are intended for use by the compiler.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public static ConfiguredTaskAwaitable ConfigureAwait(this ValueTuple> tasks, ConfigureAwaitOptions options) =>
tasks.Item1.ConfigureAwait(options);
-
+
""");
sb.AppendCSharp(
@@ -107,10 +110,12 @@ static void AppendTypedArity(StringBuilder sb, int arity, bool hasAwaitOptions)
#region (Task..Task)
/// This type and its members are intended for use by the compiler.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public static TupleTaskAwaiter<{tp}> GetAwaiter<{tp}>(this {tupleType} tasks) =>
new(tasks);
/// This type and its members are intended for use by the compiler.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public static TupleConfiguredTaskAwaitable<{tp}> ConfigureAwait<{tp}>(this {tupleType} tasks, bool continueOnCapturedContext) =>
new(tasks, {configureAwaitBoolArg});
@@ -120,6 +125,7 @@ static void AppendTypedArity(StringBuilder sb, int arity, bool hasAwaitOptions)
sb.AppendCSharp(
$"""
/// This type and its members are intended for use by the compiler.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public static TupleConfiguredTaskAwaitable<{tp}> ConfigureAwait<{tp}>(this {tupleType} tasks, ConfigureAwaitOptions options) =>
new(tasks, options);
@@ -139,7 +145,7 @@ static void AppendTypedArity(StringBuilder sb, int arity, bool hasAwaitOptions)
static void AppendTupleTaskAwaiterStruct(StringBuilder sb, int arity, string tp, string tupleType) => sb.AppendCSharp(
$$"""
/// This type and its members are intended for use by the compiler.
- public readonly record struct TupleTaskAwaiter<{{tp}}> : ICriticalNotifyCompletion
+ public readonly struct TupleTaskAwaiter<{{tp}}> : ICriticalNotifyCompletion
{
readonly {{tupleType}} _tasks;
readonly TaskAwaiter _whenAllAwaiter;
@@ -151,15 +157,20 @@ internal TupleTaskAwaiter({{tupleType}} tasks)
}
/// This type and its members are intended for use by the compiler.
- public bool IsCompleted =>
- _whenAllAwaiter.IsCompleted;
+ public bool IsCompleted
+ {
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ get => _whenAllAwaiter.IsCompleted;
+ }
/// This type and its members are intended for use by the compiler.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public void OnCompleted(Action continuation) =>
_whenAllAwaiter.OnCompleted(continuation);
/// This type and its members are intended for use by the compiler.
[SecurityCritical]
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public void UnsafeOnCompleted(Action continuation) =>
_whenAllAwaiter.UnsafeOnCompleted(continuation);
@@ -177,7 +188,7 @@ static void AppendTupleConfiguredTaskAwaitableStruct(StringBuilder sb, int arity
string tupleType, string optionsType) => sb.AppendCSharp(
$$"""
/// This type and its members are intended for use by the compiler.
- public readonly record struct TupleConfiguredTaskAwaitable<{{tp}}>
+ public readonly struct TupleConfiguredTaskAwaitable<{{tp}}>
{
readonly {{tupleType}} _tasks;
readonly {{optionsType}} _options;
@@ -189,11 +200,12 @@ internal TupleConfiguredTaskAwaitable({{tupleType}} tasks, {{optionsType}} optio
}
/// This type and its members are intended for use by the compiler.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public Awaiter GetAwaiter() =>
new(_tasks, _options);
/// This type and its members are intended for use by the compiler.
- public readonly record struct Awaiter : ICriticalNotifyCompletion
+ public readonly struct Awaiter : ICriticalNotifyCompletion
{
readonly {{tupleType}} _tasks;
readonly ConfiguredTaskAwaitable.ConfiguredTaskAwaiter _whenAllAwaiter;
@@ -205,15 +217,20 @@ internal Awaiter({{tupleType}} tasks, {{optionsType}} options)
}
/// This type and its members are intended for use by the compiler.
- public bool IsCompleted =>
- _whenAllAwaiter.IsCompleted;
+ public bool IsCompleted
+ {
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ get => _whenAllAwaiter.IsCompleted;
+ }
/// This type and its members are intended for use by the compiler.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public void OnCompleted(Action continuation) =>
_whenAllAwaiter.OnCompleted(continuation);
/// This type and its members are intended for use by the compiler.
[SecurityCritical]
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public void UnsafeOnCompleted(Action continuation) =>
_whenAllAwaiter.UnsafeOnCompleted(continuation);
@@ -237,10 +254,12 @@ static void AppendNonGenericSection(StringBuilder sb, bool hasAwaitOptions)
#region Task
/// This type and its members are intended for use by the compiler.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public static TaskAwaiter GetAwaiter(this ValueTuple tasks) =>
tasks.Item1.GetAwaiter();
/// This type and its members are intended for use by the compiler.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public static ConfiguredTaskAwaitable ConfigureAwait(this ValueTuple tasks, bool continueOnCapturedContext) =>
tasks.Item1.ConfigureAwait(continueOnCapturedContext);
@@ -250,6 +269,7 @@ public static ConfiguredTaskAwaitable ConfigureAwait(this ValueTuple tasks
sb.AppendCSharp(
"""
/// This type and its members are intended for use by the compiler.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
public static ConfiguredTaskAwaitable ConfigureAwait(this ValueTuple tasks, ConfigureAwaitOptions options) =>
tasks.Item1.ConfigureAwait(options);
@@ -305,8 +325,13 @@ static string TypedTaskTupleType(int arity) =>
static string NonGenericTaskTupleType(int arity) =>
$"({string.Join(", ", Enumerable.Repeat("Task", arity))})";
+ // Returns the generated WhenAll argument list in bracket form (stylistic).
+ // Overload binding is determined by library target framework:
+ // net9.0 binds Task.WhenAll(ReadOnlySpan) (stack-allocated buffer),
+ // while netstandard2.0/net462/net8.0 bind Task.WhenAll(params Task[])
+ // (array allocation, same IL shape as before this change).
static string Items(int arity) =>
- string.Join(", ", Enumerable.Range(1, arity).Select(i => $"tasks.Item{i}"));
+ $"[{string.Join(", ", Enumerable.Range(1, arity).Select(i => $"tasks.Item{i}"))}]";
static string Results(int arity) =>
string.Join(", ", Enumerable.Range(1, arity).Select(i => $"_tasks.Item{i}.Result"));
diff --git a/src/TaskTupleAwaiter/TaskTupleAwaiter.csproj b/src/TaskTupleAwaiter/TaskTupleAwaiter.csproj
index 5242b1b..0618991 100644
--- a/src/TaskTupleAwaiter/TaskTupleAwaiter.csproj
+++ b/src/TaskTupleAwaiter/TaskTupleAwaiter.csproj
@@ -1,6 +1,7 @@
+ netstandard2.0;net462;net8.0;net9.0
TaskTupleAwaiter
Enable using the new Value Tuple structure to write elegant code that allows async methods to be fired in parallel despite having different return types
diff --git a/test/TaskTupleAwaiter.AotSmokeTest/TaskTupleAwaiter.AotSmokeTest.csproj b/test/TaskTupleAwaiter.AotSmokeTest/TaskTupleAwaiter.AotSmokeTest.csproj
deleted file mode 100644
index 6d604de..0000000
--- a/test/TaskTupleAwaiter.AotSmokeTest/TaskTupleAwaiter.AotSmokeTest.csproj
+++ /dev/null
@@ -1,28 +0,0 @@
-
-
-
- false
- Exe
- true
- true
- net8.0;net11.0
- false
- false
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/test/TaskTupleAwaiter.Benchmarks/TaskTupleAwaiter.Benchmarks.csproj b/test/TaskTupleAwaiter.Benchmarks/TaskTupleAwaiter.Benchmarks.csproj
deleted file mode 100644
index cbd7ce3..0000000
--- a/test/TaskTupleAwaiter.Benchmarks/TaskTupleAwaiter.Benchmarks.csproj
+++ /dev/null
@@ -1,27 +0,0 @@
-
-
-
- false
- Exe
- net8.0;net10.0
- false
- false
- true
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/test/TaskTupleAwaiter.AotSmokeTest/Program.cs b/tests/smoke/TaskTupleAwaiter.AotSmokeTest/Program.cs
similarity index 100%
rename from test/TaskTupleAwaiter.AotSmokeTest/Program.cs
rename to tests/smoke/TaskTupleAwaiter.AotSmokeTest/Program.cs
diff --git a/tests/smoke/TaskTupleAwaiter.AotSmokeTest/TaskTupleAwaiter.AotSmokeTest.csproj b/tests/smoke/TaskTupleAwaiter.AotSmokeTest/TaskTupleAwaiter.AotSmokeTest.csproj
new file mode 100644
index 0000000..8d1f5c4
--- /dev/null
+++ b/tests/smoke/TaskTupleAwaiter.AotSmokeTest/TaskTupleAwaiter.AotSmokeTest.csproj
@@ -0,0 +1,12 @@
+
+
+ false
+ Exe
+ true
+ true
+ net8.0;net9.0;net11.0
+
+
+
+
+
diff --git a/test/Directory.Build.props b/tests/unit/Directory.Build.props
similarity index 82%
rename from test/Directory.Build.props
rename to tests/unit/Directory.Build.props
index 0297231..a355b88 100644
--- a/test/Directory.Build.props
+++ b/tests/unit/Directory.Build.props
@@ -1,6 +1,6 @@
-
+
false
diff --git a/test/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.AwaiterAdapterAwaiter.cs b/tests/unit/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.AwaiterAdapterAwaiter.cs
similarity index 100%
rename from test/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.AwaiterAdapterAwaiter.cs
rename to tests/unit/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.AwaiterAdapterAwaiter.cs
diff --git a/test/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.ConfiguredTaskAwaiterAdapter.cs b/tests/unit/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.ConfiguredTaskAwaiterAdapter.cs
similarity index 100%
rename from test/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.ConfiguredTaskAwaiterAdapter.cs
rename to tests/unit/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.ConfiguredTaskAwaiterAdapter.cs
diff --git a/test/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.ConfiguredTaskTupleAwaiterAdapter.cs b/tests/unit/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.ConfiguredTaskTupleAwaiterAdapter.cs
similarity index 100%
rename from test/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.ConfiguredTaskTupleAwaiterAdapter.cs
rename to tests/unit/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.ConfiguredTaskTupleAwaiterAdapter.cs
diff --git a/test/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.TaskAwaiterAdapter.cs b/tests/unit/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.TaskAwaiterAdapter.cs
similarity index 100%
rename from test/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.TaskAwaiterAdapter.cs
rename to tests/unit/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.TaskAwaiterAdapter.cs
diff --git a/test/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.TaskTupleAwaiterAdapter.cs b/tests/unit/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.TaskTupleAwaiterAdapter.cs
similarity index 100%
rename from test/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.TaskTupleAwaiterAdapter.cs
rename to tests/unit/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.TaskTupleAwaiterAdapter.cs
diff --git a/test/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.VoidResultConfiguredTaskAwaiterAdapter.cs b/tests/unit/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.VoidResultConfiguredTaskAwaiterAdapter.cs
similarity index 100%
rename from test/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.VoidResultConfiguredTaskAwaiterAdapter.cs
rename to tests/unit/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.VoidResultConfiguredTaskAwaiterAdapter.cs
diff --git a/test/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.VoidResultTaskAwaiterAdapter.cs b/tests/unit/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.VoidResultTaskAwaiterAdapter.cs
similarity index 100%
rename from test/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.VoidResultTaskAwaiterAdapter.cs
rename to tests/unit/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.VoidResultTaskAwaiterAdapter.cs
diff --git a/test/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.cs b/tests/unit/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.cs
similarity index 100%
rename from test/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.cs
rename to tests/unit/TaskTupleAwaiter.Tests/Adapters/AwaiterAdapter.cs
diff --git a/test/TaskTupleAwaiter.Tests/BehaviorComparisonTests.cs b/tests/unit/TaskTupleAwaiter.Tests/BehaviorComparisonTests.cs
similarity index 100%
rename from test/TaskTupleAwaiter.Tests/BehaviorComparisonTests.cs
rename to tests/unit/TaskTupleAwaiter.Tests/BehaviorComparisonTests.cs
diff --git a/test/TaskTupleAwaiter.Tests/CopyableSynchronizationContext.cs b/tests/unit/TaskTupleAwaiter.Tests/CopyableSynchronizationContext.cs
similarity index 100%
rename from test/TaskTupleAwaiter.Tests/CopyableSynchronizationContext.cs
rename to tests/unit/TaskTupleAwaiter.Tests/CopyableSynchronizationContext.cs
diff --git a/test/TaskTupleAwaiter.Tests/DummyException.cs b/tests/unit/TaskTupleAwaiter.Tests/DummyException.cs
similarity index 100%
rename from test/TaskTupleAwaiter.Tests/DummyException.cs
rename to tests/unit/TaskTupleAwaiter.Tests/DummyException.cs
diff --git a/test/TaskTupleAwaiter.Tests/On.cs b/tests/unit/TaskTupleAwaiter.Tests/On.cs
similarity index 100%
rename from test/TaskTupleAwaiter.Tests/On.cs
rename to tests/unit/TaskTupleAwaiter.Tests/On.cs
diff --git a/test/TaskTupleAwaiter.Tests/SpySynchronizationContext.cs b/tests/unit/TaskTupleAwaiter.Tests/SpySynchronizationContext.cs
similarity index 100%
rename from test/TaskTupleAwaiter.Tests/SpySynchronizationContext.cs
rename to tests/unit/TaskTupleAwaiter.Tests/SpySynchronizationContext.cs
diff --git a/test/TaskTupleAwaiter.Tests/TaskTupleAwaiter.Tests.csproj b/tests/unit/TaskTupleAwaiter.Tests/TaskTupleAwaiter.Tests.csproj
similarity index 69%
rename from test/TaskTupleAwaiter.Tests/TaskTupleAwaiter.Tests.csproj
rename to tests/unit/TaskTupleAwaiter.Tests/TaskTupleAwaiter.Tests.csproj
index 01ac4b3..c48f322 100644
--- a/test/TaskTupleAwaiter.Tests/TaskTupleAwaiter.Tests.csproj
+++ b/tests/unit/TaskTupleAwaiter.Tests/TaskTupleAwaiter.Tests.csproj
@@ -3,6 +3,6 @@
runtime-async=on
-
+
diff --git a/test/TaskTupleAwaiter.Tests/TaskTupleAwaiterTests.cs b/tests/unit/TaskTupleAwaiter.Tests/TaskTupleAwaiterTests.cs
similarity index 100%
rename from test/TaskTupleAwaiter.Tests/TaskTupleAwaiterTests.cs
rename to tests/unit/TaskTupleAwaiter.Tests/TaskTupleAwaiterTests.cs