From fff1f759b2e0f19fff80607718d8d648729277d7 Mon Sep 17 00:00:00 2001
From: Glenn Watson <5834289+glennawatson@users.noreply.github.com>
Date: Tue, 15 Sep 2026 00:33:54 +1000
Subject: [PATCH 1/4] test(benchmarks): run NativeAOT jobs with the memory
diagnoser
---
.../Shared/Configs/BenchmarkConfig.cs | 3 ++
.../Configs/NativeAotBenchmarkConfig.cs | 17 +++++++++--
.../Shared/Configs/NativeAotMemoryConfig.cs | 29 +++++++++++++++++++
.../Shared/Platforms/unix/BenchmarkHost.cs | 10 +++++--
.../Shared/Platforms/windows/BenchmarkHost.cs | 3 +-
5 files changed, 56 insertions(+), 6 deletions(-)
create mode 100644 src/benchmarks/Shared/Configs/NativeAotMemoryConfig.cs
diff --git a/src/benchmarks/Shared/Configs/BenchmarkConfig.cs b/src/benchmarks/Shared/Configs/BenchmarkConfig.cs
index 6c98eddf..71e58237 100644
--- a/src/benchmarks/Shared/Configs/BenchmarkConfig.cs
+++ b/src/benchmarks/Shared/Configs/BenchmarkConfig.cs
@@ -5,11 +5,13 @@
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Environments;
using BenchmarkDotNet.Exporters;
+using BenchmarkDotNet.Filters;
using BenchmarkDotNet.Jobs;
namespace ReactiveUI.Binding.Benchmarks.Configs;
/// Runs benchmarks on .NET 8, 10 and 11, traced with EventPipe.
+/// The filter drops the NativeAOT jobs a benchmark class adds, which runs.
public class BenchmarkConfig : ProfilerConfig
{
/// Initializes a new instance of the class.
@@ -20,5 +22,6 @@ public BenchmarkConfig()
_ = AddJob(new Job().WithRuntime(CoreRuntime.Core10_0));
_ = AddJob(new Job().WithRuntime(CoreRuntime.Core11_0));
_ = AddExporter(MarkdownExporter.GitHub);
+ _ = AddFilter(new SimpleFilter(static benchmark => !NativeAotBenchmarkConfig.IsNativeAotJob(benchmark.Job)));
}
}
diff --git a/src/benchmarks/Shared/Configs/NativeAotBenchmarkConfig.cs b/src/benchmarks/Shared/Configs/NativeAotBenchmarkConfig.cs
index db920f7a..b629425f 100644
--- a/src/benchmarks/Shared/Configs/NativeAotBenchmarkConfig.cs
+++ b/src/benchmarks/Shared/Configs/NativeAotBenchmarkConfig.cs
@@ -2,6 +2,7 @@
// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
// See the LICENSE file in the project root for full license information.
+using System.Runtime.CompilerServices;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Environments;
using BenchmarkDotNet.Jobs;
@@ -11,10 +12,22 @@ namespace ReactiveUI.Binding.Benchmarks.Configs;
/// Adds the NativeAOT runtimes, for benchmarks whose code publishes ahead of time.
public class NativeAotBenchmarkConfig : ManualConfig
{
+ /// The id of the NativeAOT 10 job.
+ private const string NativeAot10JobId = nameof(RuntimeMoniker.NativeAot10_0);
+
+ /// The id of the NativeAOT 11 job.
+ private const string NativeAot11JobId = nameof(RuntimeMoniker.NativeAot11_0);
+
/// Initializes a new instance of the class.
public NativeAotBenchmarkConfig()
{
- _ = AddJob(new Job(nameof(RuntimeMoniker.NativeAot10_0)).WithRuntime(NativeAotRuntime.Net10_0));
- _ = AddJob(new Job(nameof(RuntimeMoniker.NativeAot11_0)).WithRuntime(NativeAotRuntime.Net11_0));
+ _ = AddJob(new Job(NativeAot10JobId).WithRuntime(NativeAotRuntime.Net10_0));
+ _ = AddJob(new Job(NativeAot11JobId).WithRuntime(NativeAotRuntime.Net11_0));
}
+
+ /// Gets a value indicating whether a job is one of the NativeAOT jobs this config adds.
+ /// The job to check.
+ /// for a NativeAOT job; otherwise, .
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static bool IsNativeAotJob(Job job) => job.Id is NativeAot10JobId or NativeAot11JobId;
}
diff --git a/src/benchmarks/Shared/Configs/NativeAotMemoryConfig.cs b/src/benchmarks/Shared/Configs/NativeAotMemoryConfig.cs
new file mode 100644
index 00000000..d572483a
--- /dev/null
+++ b/src/benchmarks/Shared/Configs/NativeAotMemoryConfig.cs
@@ -0,0 +1,29 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+using BenchmarkDotNet.Configs;
+using BenchmarkDotNet.Diagnosers;
+using BenchmarkDotNet.Exporters;
+using BenchmarkDotNet.Filters;
+
+namespace ReactiveUI.Binding.Benchmarks.Configs;
+
+/// Runs the NativeAOT jobs with the memory diagnoser.
+///
+/// EventPipe cannot attach to a NativeAOT process built with the default settings, and a run keeps one set of
+/// diagnosers, so these jobs run apart from . The filter keeps only the NativeAOT jobs
+/// a benchmark class adds through .
+///
+public class NativeAotMemoryConfig : ManualConfig
+{
+ /// Initializes a new instance of the class.
+ public NativeAotMemoryConfig()
+ {
+ Add(DefaultConfig.Instance);
+ _ = AddDiagnoser(MemoryDiagnoser.Default);
+ _ = AddExporter(MarkdownExporter.GitHub);
+ _ = AddFilter(new SimpleFilter(static benchmark => NativeAotBenchmarkConfig.IsNativeAotJob(benchmark.Job)));
+ _ = WithOption(ConfigOptions.DontOverwriteResults, true);
+ }
+}
diff --git a/src/benchmarks/Shared/Platforms/unix/BenchmarkHost.cs b/src/benchmarks/Shared/Platforms/unix/BenchmarkHost.cs
index cac4b6a7..8792da63 100644
--- a/src/benchmarks/Shared/Platforms/unix/BenchmarkHost.cs
+++ b/src/benchmarks/Shared/Platforms/unix/BenchmarkHost.cs
@@ -7,12 +7,16 @@
namespace ReactiveUI.Binding.Benchmarks.Configs;
-/// Runs the benchmarks on .NET with EventPipe.
+/// Runs the benchmarks on .NET with EventPipe, and on NativeAOT with the memory diagnoser.
public static class BenchmarkHost
{
/// Runs the benchmarks the command line selects.
/// The assembly holding the benchmarks.
/// The command line arguments passed to the benchmark switcher.
- public static void Run(Assembly assembly, string[] args) =>
- _ = BenchmarkSwitcher.FromAssembly(assembly).Run(args, new BenchmarkConfig());
+ public static void Run(Assembly assembly, string[] args)
+ {
+ var switcher = BenchmarkSwitcher.FromAssembly(assembly);
+ _ = switcher.Run(args, new BenchmarkConfig());
+ _ = switcher.Run(args, new NativeAotMemoryConfig());
+ }
}
diff --git a/src/benchmarks/Shared/Platforms/windows/BenchmarkHost.cs b/src/benchmarks/Shared/Platforms/windows/BenchmarkHost.cs
index 3153e4a9..397285a4 100644
--- a/src/benchmarks/Shared/Platforms/windows/BenchmarkHost.cs
+++ b/src/benchmarks/Shared/Platforms/windows/BenchmarkHost.cs
@@ -7,7 +7,7 @@
namespace ReactiveUI.Binding.Benchmarks.Configs;
-/// Runs the benchmarks on .NET with EventPipe, then on .NET Framework with ETW.
+/// Runs the benchmarks on .NET with EventPipe, on .NET Framework with ETW, and on NativeAOT with the memory diagnoser.
public static class BenchmarkHost
{
/// Runs the benchmarks the command line selects.
@@ -18,5 +18,6 @@ public static void Run(Assembly assembly, string[] args)
var switcher = BenchmarkSwitcher.FromAssembly(assembly);
_ = switcher.Run(args, new BenchmarkConfig());
_ = switcher.Run(args, new NetFrameworkConfig());
+ _ = switcher.Run(args, new NativeAotMemoryConfig());
}
}
From 1f1b8c12cf7585e9c044f85fe1d7468e31108a1e Mon Sep 17 00:00:00 2001
From: Glenn Watson <5834289+glennawatson@users.noreply.github.com>
Date: Tue, 15 Sep 2026 00:37:59 +1000
Subject: [PATCH 2/4] ci(benchmarks): benchmark a chosen commit
---
.github/workflows/benchmarks.yml | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml
index ac642b42..6a6499a1 100644
--- a/.github/workflows/benchmarks.yml
+++ b/.github/workflows/benchmarks.yml
@@ -2,6 +2,11 @@ name: Benchmarks
on:
workflow_dispatch:
+ inputs:
+ sha:
+ description: 'Commit to benchmark. Empty benchmarks the latest commit of the chosen branch.'
+ type: string
+ default: ''
permissions:
contents: read
@@ -10,6 +15,7 @@ jobs:
benchmark:
uses: reactiveui/actions-common/.github/workflows/workflow-common-benchmarks.yml@main
with:
+ ref: ${{ inputs.sha }}
projects: |
benchmarks/ReactiveUI.Binding.Benchmarks/ReactiveUI.Binding.Benchmarks.csproj
benchmarks/ReactiveUI.Binding.Benchmarks.ReactiveUI/ReactiveUI.Binding.Benchmarks.ReactiveUI.csproj
From 3994a0a043ed3f5bb73bbd5560d93b7b7fd0bcd9 Mon Sep 17 00:00:00 2001
From: Glenn Watson <5834289+glennawatson@users.noreply.github.com>
Date: Tue, 15 Sep 2026 00:56:33 +1000
Subject: [PATCH 3/4] ci(benchmarks): compare two commits on one runner
---
.github/workflows/benchmarks-ab.yml | 28 +++++++++++++++++++
.../Shared/Configs/BenchmarkProfiling.cs | 20 +++++++++++++
.../Shared/Configs/ProfilerConfig.cs | 5 ++++
.../Platforms/windows/NetFrameworkConfig.cs | 6 +++-
4 files changed, 58 insertions(+), 1 deletion(-)
create mode 100644 .github/workflows/benchmarks-ab.yml
create mode 100644 src/benchmarks/Shared/Configs/BenchmarkProfiling.cs
diff --git a/.github/workflows/benchmarks-ab.yml b/.github/workflows/benchmarks-ab.yml
new file mode 100644
index 00000000..fe5f709c
--- /dev/null
+++ b/.github/workflows/benchmarks-ab.yml
@@ -0,0 +1,28 @@
+name: Benchmarks A/B
+
+on:
+ workflow_dispatch:
+ inputs:
+ base:
+ description: 'Baseline commit SHA.'
+ type: string
+ required: true
+ head:
+ description: 'Commit SHA compared against the baseline.'
+ type: string
+ required: true
+
+permissions:
+ contents: read
+
+jobs:
+ benchmark:
+ uses: reactiveui/actions-common/.github/workflows/workflow-common-benchmarks-ab.yml@main
+ with:
+ base: ${{ inputs.base }}
+ head: ${{ inputs.head }}
+ projects: |
+ benchmarks/ReactiveUI.Binding.Benchmarks/ReactiveUI.Binding.Benchmarks.csproj
+ benchmarks/ReactiveUI.Binding.Generator.Benchmarks/ReactiveUI.Binding.Generator.Benchmarks.csproj
+ solutionFile: ReactiveUI.Binding.SourceGenerators.slnx
+ installWorkloads: true
diff --git a/src/benchmarks/Shared/Configs/BenchmarkProfiling.cs b/src/benchmarks/Shared/Configs/BenchmarkProfiling.cs
new file mode 100644
index 00000000..70ad8705
--- /dev/null
+++ b/src/benchmarks/Shared/Configs/BenchmarkProfiling.cs
@@ -0,0 +1,20 @@
+// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
+// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for full license information.
+
+namespace ReactiveUI.Binding.Benchmarks.Configs;
+
+/// Reads whether the benchmark configs attach their profilers.
+///
+/// An A/B comparison measures timing only. It sets BENCHMARK_PROFILERS to false, which skips the
+/// second pass each profiler adds to every benchmark.
+///
+public static class BenchmarkProfiling
+{
+ /// The environment variable that turns the profilers off when set to false.
+ private const string VariableName = "BENCHMARK_PROFILERS";
+
+ /// Gets a value indicating whether the profilers are attached.
+ public static bool Enabled =>
+ !string.Equals(Environment.GetEnvironmentVariable(VariableName), "false", StringComparison.OrdinalIgnoreCase);
+}
diff --git a/src/benchmarks/Shared/Configs/ProfilerConfig.cs b/src/benchmarks/Shared/Configs/ProfilerConfig.cs
index fdc16c3b..ea62c359 100644
--- a/src/benchmarks/Shared/Configs/ProfilerConfig.cs
+++ b/src/benchmarks/Shared/Configs/ProfilerConfig.cs
@@ -27,6 +27,11 @@ public class ProfilerConfig : ManualConfig
/// Initializes a new instance of the class.
public ProfilerConfig()
{
+ if (!BenchmarkProfiling.Enabled)
+ {
+ return;
+ }
+
EventPipeProvider[] providers =
[
new(SampleProfilerProviderName, EventLevel.Informational),
diff --git a/src/benchmarks/Shared/Platforms/windows/NetFrameworkConfig.cs b/src/benchmarks/Shared/Platforms/windows/NetFrameworkConfig.cs
index edab2969..0bf0ad51 100644
--- a/src/benchmarks/Shared/Platforms/windows/NetFrameworkConfig.cs
+++ b/src/benchmarks/Shared/Platforms/windows/NetFrameworkConfig.cs
@@ -27,9 +27,13 @@ public NetFrameworkConfig()
{
Add(DefaultConfig.Instance);
_ = AddJob(new Job(NetFrameworkJobId).WithRuntime(ClrRuntime.Net462));
- _ = AddDiagnoser(new EtwProfiler(new EtwProfilerConfig()));
_ = AddExporter(MarkdownExporter.GitHub);
_ = AddFilter(new SimpleFilter(static benchmark => benchmark.Job.Id == NetFrameworkJobId));
_ = WithOption(ConfigOptions.DontOverwriteResults, true);
+
+ if (BenchmarkProfiling.Enabled)
+ {
+ _ = AddDiagnoser(new EtwProfiler(new EtwProfilerConfig()));
+ }
}
}
From 12cde61874249756d7b6a4dcc7d537ae54e9b3a2 Mon Sep 17 00:00:00 2001
From: Glenn Watson <5834289+glennawatson@users.noreply.github.com>
Date: Tue, 15 Sep 2026 01:23:50 +1000
Subject: [PATCH 4/4] test(interaction): cover the handler list a subclass
reads
---
.../Interactions/InteractionTests.cs | 35 +++++++++++++++++--
1 file changed, 33 insertions(+), 2 deletions(-)
diff --git a/src/tests/ReactiveUI.Binding.Tests/Interactions/InteractionTests.cs b/src/tests/ReactiveUI.Binding.Tests/Interactions/InteractionTests.cs
index 70e81a98..417d6933 100644
--- a/src/tests/ReactiveUI.Binding.Tests/Interactions/InteractionTests.cs
+++ b/src/tests/ReactiveUI.Binding.Tests/Interactions/InteractionTests.cs
@@ -2,6 +2,8 @@
// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
// See the LICENSE file in the project root for full license information.
+using System.Runtime.CompilerServices;
+
namespace ReactiveUI.Binding.Tests.Interactions;
/// Tests for .
@@ -10,6 +12,9 @@ public class InteractionTests
/// The result the first registered handler produces.
private const string FirstHandlerResult = "first";
+ /// The result the second registered handler produces.
+ private const string SecondHandlerResult = "second";
+
/// The expected output of the synchronous handler (the length of "hello").
private const int HelloLength = 5;
@@ -70,10 +75,27 @@ public async Task Handle_MultipleHandlers_LIFOOrder()
{
var interaction = new Interaction();
using var first = interaction.RegisterHandler(static ctx => ctx.SetOutput(FirstHandlerResult));
- using var second = interaction.RegisterHandler(static ctx => ctx.SetOutput("second"));
+ using var second = interaction.RegisterHandler(static ctx => ctx.SetOutput(SecondHandlerResult));
var result = await interaction.Handle("input");
- await Assert.That(result).IsEqualTo("second");
+ await Assert.That(result).IsEqualTo(SecondHandlerResult);
+ }
+
+ /// Verifies that GetHandlers hands back a copy, so changing it leaves the registered handlers alone.
+ /// A representing the asynchronous unit test.
+ [Test]
+ public async Task GetHandlers_ReturnsACopyOfTheRegisteredHandlers()
+ {
+ var interaction = new HandlerListingInteraction();
+ using var first = interaction.RegisterHandler(static ctx => ctx.SetOutput(FirstHandlerResult));
+ using var second = interaction.RegisterHandler(static ctx => ctx.SetOutput(SecondHandlerResult));
+
+ var handlers = interaction.ListHandlers();
+ Array.Clear(handlers);
+
+ await Assert.That(interaction.ListHandlers().Length).IsEqualTo(2);
+ await Assert.That(interaction.ListHandlers()[1]).IsNotNull();
+ await Assert.That(await interaction.Handle("input")).IsEqualTo(SecondHandlerResult);
}
/// Verifies that Handle throws UnhandledInteractionException when no handler calls SetOutput.
@@ -190,4 +212,13 @@ public IDisposable Subscribe(IObserver observer)
return EmptyDisposable.Instance;
}
}
+
+ /// An interaction that exposes its protected handler list to the tests.
+ private sealed class HandlerListingInteraction : Interaction
+ {
+ /// Lists the registered handlers.
+ /// The registered handlers, in registration order.
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public Func, Task>[] ListHandlers() => GetHandlers();
+ }
}