Skip to content

Fix/v3.2 correctness - #2

Merged
HBartosch merged 32 commits into
mainfrom
fix/v3.2-correctness
Sep 29, 2026
Merged

HBartosch merged 32 commits into
mainfrom
fix/v3.2-correctness

Conversation

@HBartosch

@HBartosch HBartosch commented Sep 28, 2026 •

Copy link
Copy Markdown
Owner

🚀 Pull Request: Routya.Core


📋 Summary

An audit of the dispatch paths turned up six defects: three broke the consumer's build outright, and
two silently corrupted scope handling at runtime. This fixes all of them, adds the regression tests
that would have caught them, replaces the IRequest<IAsyncEnumerable<T>> streaming workaround with a
first class IStreamRequest<T> API, and verifies Routya.Core under trimming and Native AOT.

Released as 4.0.0. Test coverage goes from 30 tests in one suite to 118 across three.

🔗 Related Issues (If Any)

Closes #___
Linked to: #___

🛠️ Changes Made

  • Request/notification handler logic
  • Pipeline behavior changes
  • Dispatcher performance or safety
  • DI registration improvements
  • Unit tests added or updated
  • Benchmarks updated (if performance-related)

✅ Checklist

  • Code compiles and passes unit tests
  • No breaking changes introduced (or documented)
  • Docs/readme updated if needed
  • Follows Routya code style and conventions
  • Scoped vs root resolution behavior remains correct

Two breaking changes are introduced. Both are recorded under Upgrade notes in CHANGELOG.md,
in a new Breaking Changes section in README.md, and under Risk below.

🧪 Manual Testing Notes

Every fix has a test that was confirmed failing against the previous code before the fix landed, so
none of them pass vacuously. Three things were additionally verified by hand:

  • Native AOT: published a console application against the source generator and ran the native
    binary. It covers an async handler, a synchronous handler returning decimal, pipeline behaviours
    and a two handler notification. ILCompiler emitted no IL warnings and the 2.4 MB binary ran
    correctly. Note for anyone reproducing: this needs the Desktop Development with C++ workload, and
    ILCompiler also needs vswhere.exe on PATH or the link step fails.
  • Scope defects: reproduced with a standalone probe before and after. A scoped pipeline behaviour
    previously saw the same disposed dependency on every dispatch; it now sees a fresh one each time.
  • Packaging: packed the artefacts and inspected the nuspec. The new Microsoft.Bcl.AsyncInterfaces
    reference appears only in the .NETStandard2.0 dependency group, so no other target framework
    gains a dependency.

Automated, all run and passing:

Suite Tests
Routya.Test 64
Routya.SourceGenerators.Test 31 (new project)
Routya.SourceGen.Test 23

All six demo projects and all four benchmark projects build. Routya.Core builds across all five
target frameworks with CS8632 the only warning code, all of which predate this branch, and no IL
trimming or AOT warnings.

💬 Additional Context

29 commits, 67 files, +5096 / -746. Grouped by intent for review:

Runtime dispatch correctness

  • Pipeline behaviours were resolved once and cached in a static dictionary, so a Scoped behaviour
    was built from the first dispatch scope and reused after that scope had been disposed. Any
    behaviour holding a DbContext operated on a disposed scope from the second dispatch onwards. The
    cache being static also leaked instances between DI containers in one process.
  • Notification handlers registered directly against IServiceCollection were resolved from the root
    provider before the dispatch scope existed. Under scope validation, the ASP.NET Core Development
    default, the first publish threw and the notification never reached its handlers.
  • PublishParallelAsync gave every handler the same scoped dependency while running them
    concurrently, producing A second operation was started on this context with EF Core. Each handler
    now gets its own scope. Sequential publishing deliberately still shares one, and both behaviours
    are pinned by tests.
  • Assembly.GetTypes() threw ReflectionTypeLoadException and failed application startup if one
    type in a scanned assembly could not load.

Source generator

  • Crashed outright on array response types. IRequest<string[]> produced an InvalidCastException,
    surfaced as CS8785, after which the generator contributed nothing and the build failed with
    unrelated looking CS0234 errors.
  • Emitted broken names for nested request types, dropping the containing type, which breaks the
    feature folder convention.
  • Emitted SendAsync calling HandleAsync for synchronous handlers, which do not have that method.
  • Silently skipped internal and record handlers, so they were never registered.
  • A faulting notification handler could hide another handler's exception in the two handler case.

New: first class streaming

IStreamRequest<T>, IStreamRequestHandler<,>, IStreamPipelineBehavior<,> and
IRoutya.CreateStream, plus a generated CreateStream member. The old
IRequest<IAsyncEnumerable<T>> shape returned a task that handed back a sequence, so the pipeline
completed before a single item was produced, and a behaviour could not observe items or catch a mid
stream failure. The dispatch scope now lives for the whole enumeration, so a handler can hold a
DbContext across the stream. Demonstrated in both Web API demos.

New: trimming and Native AOT

Zero IL analyzer warnings, enforced on every build through IsAotCompatible. Removed five
unreferenced internal types that held the last Expression.Compile calls in the library. AddRoutya
is split so the scanning overload carries RequiresUnreferencedCode without warning callers who
never scan.

Testing and CI

  • New Routya.SourceGenerators.Test, a Roslyn harness that drives the generator against source text
    in memory. Without it, a generator defect that breaks the consuming build cannot be expressed as a
    failing test.
  • Allocation budget tests, reported to the job summary so the figures are visible on a green run.
  • build.yml no longer runs BenchmarkDotNet. It passed --job short, but the entry point ignored
    command line arguments, so CI had been running full benchmark jobs and publishing numbers
    nobody read. The same workflow ran only one test suite, behind a Test-Path guard that would have
    silently skipped it; it now runs all three.

Documentation accuracy

Several published claims were wrong before this release rather than made wrong by it:

  • Install instructions told people to use Routya.SourceGenerators 3.0.0 and Routya.Core 2.0.0.
  • The banner claimed 46% faster on notifications, from a benchmark set measured on different
    hardware. It appeared in three places, two of which ship inside the NuGet packages. All now
    carry allocation figures instead, which are deterministic and asserted on every build.
  • Every Routya allocation figure in the benchmark tables was overstated, by 200 B on Send rows,
    240 B on SendAsync rows and 32 B on every notification row.
  • 21 hard coded nanosecond figures in the XML documentation, which ships in the package and drives
    IntelliSense.
  • The README advertised auto caching of discovered handlers, which never worked.

Risk and rollout

Two breaking changes, both documented:

  1. IRoutya gained CreateStream. Breaks hand written test doubles implementing the interface.
    Consumers and runtime mocking frameworks are unaffected.
  2. DefaultRoutya's public constructor gained an IRoutyaStreamDispatcher parameter. Only affects
    code calling new DefaultRoutya(...) directly.

Three behavioural changes that are correct but different:

  1. Scoped pipeline behaviours are now built per dispatch, costing about 272 B per dispatch for two
    behaviours. Register them Singleton where stateless.
  2. Parallel notification handlers can no longer share scoped state with one another, costing about
    300 B per additional handler.
  3. internal handlers previously skipped by the generator are now registered. If one was registered
    manually as a workaround, that becomes a duplicate registration.

New dependency: Microsoft.Bcl.AsyncInterfaces on the netstandard2.0 target only, verified in
the packed nuspec.

Publishing: dotnet.yml triggers on a v* tag, so merging alone publishes nothing. Tag
v4.0.0 when you are ready to push to NuGet.

HBartosch and others added 30 commits September 11, 2026 09:18
…nces

CompiledPipelineFactory cached resolved IPipelineBehavior instances in a
static dictionary keyed on request type. A scoped behavior was therefore
resolved once, from the first dispatch scope, and reused for the lifetime
of the process. Any behavior holding a DbContext, unit of work or other
scoped dependency operated on a disposed scope from the second dispatch
onwards. Because the cache was static it also leaked behavior instances
between DI containers in the same process, which breaks tests that build
more than one host.

Changes:

- CompiledPipelineFactory is no longer static. It is instantiated once per
  CompiledRequestInvokerDispatcher, and therefore once per container, so
  the pipeline delegate caches no longer outlive the container that
  populated them or capture another container's handler registry.
- Deleted the behavior instance cache. All three call sites now resolve
  behaviors through ResolveBehaviors, which uses the provider passed into
  the dispatch. That is the dispatch scope under RoutyaDispatchScope.Scoped.
- Cache only whether a request type has any behavior registered, not the
  instances. Registrations are immutable once the container is built, so
  that fact is safe to cache and keeps the no behavior fast path free of a
  GetServices call.
- Removed three Expression.Parameter locals that were created but never used.

Handler resolution is deliberately left untouched so this change can be
reviewed on its own.

Adds four regression tests, all of which failed before this change:
behaviors resolve once per dispatch (async and sync), never observe a
disposed dependency, and do not leak between service providers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…oot provider

CompiledNotificationDispatcher built its handler wrappers before creating
the dispatch scope. For handlers that were not described in the registry,
which is every handler registered directly against IServiceCollection, it
called GetServices on the root provider and then cached the resolved
instances for the lifetime of the process.

That had two consequences for the registration style the README documents
as supported:

- Under scope validation, which is the ASP.NET Core Development default,
  the first publish threw "Cannot resolve scoped service ... from root
  provider" and the notification never reached its handlers.
- With validation disabled, the handler was captured as a root singleton
  and its scoped dependencies were reused after their scope was disposed.

Changes:

- PublishAsync now creates the dispatch scope first and passes that
  provider down, so every resolution happens inside the scope.
- Handlers outside the registry are resolved through GetServices on the
  dispatch scope on every publish and invoked directly. They cannot be
  resolved by concrete type, because registering INotificationHandler<T>
  against an implementation does not register the implementation itself.
- Registry backed handlers keep their existing cached wrappers, which
  already deferred Scoped and Transient resolution correctly. Singleton
  instances are still resolved once from the root provider.
- Removed the fallback write into the shared notification registry. It
  recorded every discovered handler as Transient regardless of its real
  lifetime, and nothing ever read it back, so it was misleading rather
  than an optimisation. Removing it also takes a lock off the publish path.
- Notification types with no handlers are recorded so later publishes
  return without creating a scope.
- Deleted NotificationHandlerWrapperWithInstance, now unused.

Adds five tests covering publish under scope validation, resolution per
publish for both sequential and parallel strategies, no reuse of a disposed
dependency, and a guard that the registry path still resolves per publish.
The first four failed before this change.

Also corrects RegistryCachingTests: the notification test asserted
Assert.True(true) under a name describing the registry write removed here.
It now asserts the handler is invoked on every publish.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Routya.SourceGen.Test runs the generator over its own compilation, so a
generator defect that emits invalid code breaks that project's build rather
than producing a failing test. That makes the four known generator defects
impossible to express as tests.

This adds Routya.SourceGenerators.Test, which drives
HandlerRegistrationGenerator against source text held in the test and
reports three things separately:

- errors in the test's own input, so a broken test is distinguishable from
  a generator defect
- diagnostics the generator reported, including CS8785 when it throws
- errors in the compilation once the generated sources are added

GeneratorRunResult.AssertCompiles asserts all three are clean, which is the
assertion the upcoming generator fixes need.

Includes three sanity tests covering an async request handler, two
notification handlers, and a deliberately invalid input. Wired into the
build-and-test workflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rings

The generator had four separate type name renderers, all of which built a
name by concatenating ContainingNamespace and Name. That is wrong in
several ways, and each way broke the consumer's build:

- Containing types were discarded, so a nested request type such as
  Features.CreateOrder.Command was emitted as Features.Command and the
  consumer got CS0234. This affects the feature folder convention, where
  the request and its handler are nested in one type per feature.
- Type arguments that were not named types could not be rendered. A
  response of List<byte[]> was emitted as List<T>, giving CS7003 unbound
  generic name and a cascade of follow on errors.
- HandlerDescriptor cast request and response type arguments to
  INamedTypeSymbol. An array response such as IRequest<string[]> is an
  IArrayTypeSymbol, so the cast threw InvalidCastException. Roslyn
  surfaced this as CS8785 and the generator then contributed nothing at
  all, leaving the user with unexplained CS0234 errors everywhere.
- Names had no global:: prefix, so a user type that shadowed a framework
  type broke the generated code.

Changes:

- Added SymbolNames.ToGeneratedName, a single renderer built on Roslyn's
  own fully qualified display format, which handles nested types, arrays,
  constructed generics, keyword aliases and nullable annotations, and
  prefixes global::. Replaced all four hand built renderers with it.
- HandlerDescriptor.RequestType and ResponseType are now ITypeSymbol
  rather than INamedTypeSymbol, removing the casts that threw.
- Handler interfaces are now matched on namespace, name and arity through
  SymbolNames.IsRoutyaInterface, rather than by string prefix. The prefix
  test would also have matched an unrelated user interface whose name
  began with IRequestHandler in the Routya.Core.Abstractions namespace.

Adds seven tests covering array responses, arrays nested inside generics,
nested request and notification types, generic responses, the global
namespace, and a user type shadowing a framework type. Four failed before
this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…andleAsync

The registration emitter declared a synchronous member on the generated
interface for handlers implementing IRequestHandler:

    TResponse Send(TRequest request);

The dispatcher emitter ignored HandlerDescriptor.IsAsync and always emitted
an asynchronous SendAsync whose body called handler.HandleAsync. A
synchronous handler has no such method, so any project with a synchronous
handler failed to build with two errors:

    CS0535: 'GeneratedRoutya' does not implement interface member
            'IGeneratedRoutya.Send(Ping)'
    CS1061: 'PingHandler' does not contain a definition for 'HandleAsync'

No source generator project in the repository used a synchronous handler,
which is why CI did not catch this.

Changes:

- EmitRequestDispatchMethod now branches on IsAsync. Asynchronous handlers
  get SendAsync calling HandleAsync, synchronous handlers get Send calling
  Handle, which matches what the interface declares in each case.
- Behavior chain construction and handler resolution are shared between the
  two, so the pipeline semantics cannot drift apart again.
- Behaviors are materialized into an array once. The previous code called
  Any() and then Reverse() on the same lazy sequence, enumerating it twice.
- The chain is walked by index rather than through Reverse(), which removes
  an allocation from the behavior path.

Where behaviors are present, the synchronous Send waits for the chain,
because IPipelineBehavior is asynchronous. That matches the documented
behaviour of IRoutya.Send. Blocking on an asynchronous chain is a wider
design question that applies to the runtime dispatcher too, so it is left
alone here rather than changed on one path only.

Adds five harness tests proving generated code compiles for a synchronous
handler, with a behavior, alongside an async handler, and with a nested
request type, plus three runtime tests in Routya.SourceGen.Test proving a
synchronous handler is registered, dispatches correctly, and runs behaviors.
All five harness tests failed before this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… members

The generator only considered ClassDeclarationSyntax nodes whose symbol was
declared public. Two common shapes were therefore skipped in silence, with
no diagnostic and no registration:

- "internal sealed class Handler", the usual Clean Architecture convention,
  where the request is public contract and the handler is an implementation
  detail
- handlers declared as records

A skipped handler is not registered and gets no typed member, so the failure
only appears later as a missing service or a missing method.

Changes:

- The syntax predicate now matches TypeDeclarationSyntax, so records and
  structs are considered alongside classes. Interfaces remain excluded by
  the existing IsAbstract check.
- The public only accessibility filter is removed. An internal handler is
  referenced solely from generated method bodies in the same assembly, so
  registration and dispatch both work.
- Added SymbolNames.IsExternallyVisible, which walks containing types and
  type arguments, and HandlerDescriptor.SupportsTypedDispatch built on it.

IGeneratedRoutya and GeneratedRoutya are public, so they cannot mention a
type that is not externally visible without CS0051 or CS0050. Where the
request or notification type is internal, the handler is still registered
but gets no typed member, and the new ROUTYA005 warning names the handler
and the offending type and points at runtime IRoutya dispatch. That was
preferred over flipping the generated types to internal, which would change
the consumer's public API surface based on one handler's accessibility.

Adds seven harness tests covering internal request, notification and nested
handlers, record handlers, the ROUTYA005 cases, and a check that a fully
public handler reports nothing. Adds two runtime tests proving an internal
handler is registered and dispatches. Six failed before this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both benchmark entry points declared Main without parameters and called
BenchmarkRunner.Run without passing args, so BenchmarkDotNet's command line
configuration was ignored. Selecting a job, filtering benchmarks or changing
the exporter all required editing code first. Main now takes args and passes
them through.

Also adds Routya_ScopedBehaviors_Send and Routya_ScopedBehaviors_SendAsync
to the request benchmarks. Every existing case registers its pipeline
behaviors as Singleton, so none of them exercised the cost of constructing a
behavior per dispatch scope. That is the configuration most affected by
resolving behaviors per dispatch rather than caching the instances, and it
had no coverage at all.

Measured on the new cases, Scoped behaviors cost a consistent 272 B per
dispatch over Singleton behaviors on both the sync and async paths. That is
two behavior instances per scope plus the array, which is inherent to the
Scoped lifetime rather than specific to this implementation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both benchmark tables stated the comparison was against MediatR 13.1.0.
Routya.Benchmark and Routya.Notification.Benchmark both reference MediatR
12.5.0, confirmed in the project files, in project.assets.json for both
projects, and in the product version of the built MediatR.dll.

The distinction matters beyond a version number: 12.5.0 is the Apache 2.0
release, whereas 13.x ships under the dual licence introduced in July 2025.
A reader choosing between the two is not looking at the same library.

This corrects the stated version only. The figures themselves still need
regenerating, which is tracked separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…de another

The notification emitter special cased exactly two handlers, starting both
tasks and then awaiting them one after another to avoid the array that
Task.WhenAll allocates:

    var task0 = handler0.Handle(notification, cancellationToken);
    var task1 = handler1.Handle(notification, cancellationToken);
    await task0.ConfigureAwait(false);
    await task1.ConfigureAwait(false);

That is not equivalent to awaiting both. If task0 faults, the first await
rethrows straight away and task1 is never awaited, so its exception is never
observed and the caller resumes while that handler is still running. Two
handlers is the most common fan out, so this was the likeliest case to hit.

Every count above one now goes through Task.WhenAll, which observes all
tasks and waits for all of them before propagating. A single handler is
still awaited directly. The cost is one array per publish on a path that was
previously silently losing exceptions.

Proven by a runtime test in Routya.SourceGen.Test: with two handlers, one
faulting asynchronously and one taking 75ms, the publish must still throw
and the slow handler must have completed by the time it does. That test was
confirmed to fail against the previous emitter before this change. Three
harness tests pin the generated shape for one, two and three handlers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
When a handler was not described in the registry, the pipeline resolved it
through its interface and then wrote the discovered concrete type into the
registry, described as an optimisation that later calls would benefit from.

They never did. The registry lookup happens once, when the pipeline delegate
is built, and its result is captured in that delegate's closure. The delegate
is then cached per request type for the lifetime of the dispatcher, so it
never re-reads the registry it just wrote. Every dispatch took the same
interface resolution path as the first.

Making the write effective instead of removing it would have introduced a
real failure. Registering IAsyncRequestHandler<,> against an implementation
does not register the implementation type, so the registry path's
GetRequiredService(concreteType) would throw for exactly the handlers this
code was discovering. The write also recorded every handler as Transient
regardless of its real lifetime.

So the write is removed, along with the lock it took on a dictionary shared
across the container, and the now unused needsFallbackCheck flags. Behaviour
is unchanged; only dead work is gone.

README corrected to match. It claimed discovered handlers were added to the
registry, that second and subsequent calls used registry optimised dispatch,
and that this was around 28% faster for notifications. It now states that
handlers registered directly against IServiceCollection are resolved through
their interface on every dispatch, explains why that is required for Scoped
handlers, and points at the AddRoutya*Handler methods and assembly scanning
for the registry path.

Adds three tests covering async, sync and notification handlers registered
by interface only, with the implementation type deliberately unregistered,
asserting dispatch keeps working across repeated calls. These guard against
reintroducing resolution by concrete type. Also renames the existing
RegistryCachingTests case, whose name described the removed behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RegisterRoutyaHandlersFromAssembly called assembly.GetTypes() directly.
That throws ReflectionTypeLoadException whenever any single type in the
assembly fails to load, which happens in ordinary applications when a
scanned assembly references something that is not deployed, such as an
optional provider package.

The exception escaped AddRoutya, so one unloadable type anywhere in a
scanned assembly failed application startup, even when every Routya handler
in that assembly had loaded without trouble.

ReflectionTypeLoadException carries the types that did load, with nulls in
place of the ones that did not, so scanning now continues with those.

Adds two tests using an Assembly subclass that reproduces the failure.
Assembly.GetTypes is virtual, so this needs no broken assembly on disk. Both
failed with an escaping ReflectionTypeLoadException before this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five internal files were referenced by nothing:

  Dispatchers/Requests/CompiledRequestInvokerFactory.cs
  Dispatchers/Notifications/CompiledNotificationInvokerFactory.cs
  Extensions/ExpressionExtension.cs
  Extensions/Configurations/ExtensionConstant.cs
  Dispatchers/Configurations/CompiledConstant.cs

The first three built and compiled expression trees. The last two existed
only to supply parameter names to them. Nothing in the library, the tests or
the demos called into any of them. Both dispatchers build plain closures and
resolve handlers through the container instead.

All five were internal, so removing them changes no public API.

They held every remaining .Compile() call in Routya.Core, and the last using
of System.Linq.Expressions went with them. The library now has no expression
tree construction or compilation on any path, which matters well beyond
tidiness: runtime expression compilation is the usual obstacle to Native AOT
and trimming. Whether Routya.Core is actually AOT clean is a separate
question that needs verifying rather than assuming, but the most likely
blocker is now gone.

Behaviour is unchanged. Routya.Core builds across all five target frameworks
with the same nine pre-existing warnings, and all 44 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t in use

Both dispatchers documented themselves as using compiled expression trees.
Neither does, and neither did before the expression code was removed: they
build plain closures and resolve handlers through the container. The
notification dispatcher went further and claimed handlers were "compiled on
first use".

These docs ship in the NuGet package and drive IntelliSense, so they were
telling users something untrue about how their dispatch works, and about the
library's suitability for trimming and Native AOT.

Both now describe what actually happens, including which registration styles
get direct resolution and which are resolved through their interface, and
state plainly that no expression trees are built or compiled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every Routya allocation figure in both benchmark tables was wrong. The
offsets were consistent, which rules out measurement drift and points to a
real reduction in the library between when the tables were produced and now:

  Send rows        published 200 B higher than measured
  SendAsync rows   published 240 B higher than measured
  Notification     published  32 B higher on every row

Confirmed by measuring the unmodified main branch as well as this one, so
the tables were already stale before any of the recent work. MediatR's own
figures were correct and are unchanged.

Corrected Gen0 and Allocated on both tables, and the memory percentages
derived from them. Several were understating Routya:

  Singleton sequential   56% less memory -> 64% (160 B vs 440 B)
  Transient sequential   45% less memory -> 53% (208 B vs 440 B)
  Singleton parallel     29% less memory -> 36% (280 B vs 440 B)
  Singleton request      50% less memory -> 20% (808 B vs 1016 B)

The last of those was overstated rather than understated.

Mean and Ratio are deliberately left alone. They came from different
hardware and a different runtime, and this machine cannot produce publishable
timings: BenchmarkDotNet flagged multimodal distributions on most rows and
the unchanged MediatR baseline drifted around 9% between runs. Both tables
now carry a note saying which columns were re-measured and which are
indicative, rather than presenting all four columns as equally current.

Also documents that Scoped pipeline behaviors cost a further 272 B per
dispatch over Singleton ones, measured consistently across two runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
[Unreleased] was empty despite fourteen commits on this branch, six of them
behaviour fixes. Users cannot act on a release they cannot read.

Entries are grouped by package, since which defects could affect a given
user depends entirely on whether they use Routya.Core's runtime dispatch or
the source generator, and those two sets barely overlap.

Adds an Upgrade notes section covering the three ways this release can
change behaviour for an existing project: internal handlers that were
previously skipped are now registered and may duplicate a manual
registration, the new ROUTYA005 warning can fail a build using
TreatWarningsAsErrors, and Scoped pipeline behaviors are now correctly built
per dispatch scope at a cost of roughly 272 B per dispatch.

Left under [Unreleased] rather than assigned a version number. Nothing here
breaks the public API, so 3.2.0 fits, but that is a release decision.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Matches the change already made to the other two benchmark entry points.
Main declared no parameters and BenchmarkRunner.Run was called without args,
so BenchmarkDotNet's command line configuration was ignored and selecting a
job or filtering benchmarks required editing code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Routya.Core now compiles with zero IL trimming and AOT analyzer warnings,
and a Native AOT binary using the source generator has been confirmed to
build and run.

Changes:

- Annotated the THandler type parameter on AddRoutyaRequestHandler,
  AddRoutyaAsyncRequestHandler and AddRoutyaNotificationHandler with
  DynamicallyAccessedMembers(PublicConstructors). Without it the trimmer
  could remove a handler's constructor, giving 18 IL2087 warnings
- Split AddRoutya into two overloads. The existing signature took optional
  assemblies, so marking it RequiresUnreferencedCode would have warned every
  caller including those who never scan. There is now a clean
  AddRoutya(services, configure) and a separate scanning overload carrying
  the annotation. Overload resolution prefers the clean one in normal form,
  so existing source and existing compiled callers both keep working
- Annotated the scanning path, which cannot be made trim safe, so callers
  get a build time warning naming the alternatives rather than a handler
  silently disappearing at runtime
- Added IsAotCompatible to the net8.0, net9.0 and net10.0 targets so the
  analyzers run on every build and a regression fails here rather than in a
  consumer's published application
- Added internal copies of the trimming attributes for the netstandard
  targets, which predate them, so one set of signatures serves all five
  target frameworks

Verified by publishing a Native AOT console application against the source
generator, with an async handler, a synchronous handler returning decimal,
two pipeline behaviors and a notification with two handlers. ILCompiler
produced no IL warnings and the 2.4 MB native binary ran correctly.

One limitation found, which belongs to Microsoft.Extensions.DependencyInjection
rather than to Routya: open generic service registrations cannot be
constructed under Native AOT when a type argument is a value type. Registering
a behavior as typeof(IPipelineBehavior<,>) therefore throws at runtime for a
request whose response is, for example, decimal or int. Adding a closed
registration alongside the open generic one does not help, because GetServices
enumerates every registration for the service type. Behaviors must be
registered closed, with no open generic registration present. Documented
separately.
…e AOT work

Routya.Aot.Benchmark runs the source generated dispatch path under both the
ordinary JIT runtime and Native AOT, using BenchmarkDotNet's NativeAot80
toolchain, so the two can be compared directly.

Three deliberate scoping decisions, each forced by a limitation found while
verifying AOT support:

- Only the source generated path is measured. The runtime dispatcher depends
  on assembly scanning, which is now annotated RequiresUnreferencedCode
  precisely because it is not AOT compatible
- MediatR is excluded rather than used as a baseline. It resolves handlers
  reflectively, so under AOT the question is not which is faster
- Pipeline behaviors are registered closed rather than as open generics,
  because Microsoft's DI container cannot construct an open generic service
  under AOT when a type argument is a value type, which the decimal
  benchmark would hit immediately

Also fixes three warnings introduced by the previous commit:

- CS1572 and CS1734: the clean AddRoutya overload still documented a
  scanAssemblies parameter it no longer has after the overload split
- CS8618: the trimming attribute polyfill declared a non-nullable Url
  property that was never set. It was unused, so it is removed

A forced full rebuild now reports CS8632 as the only warning code, all of
which predate this branch, and no IL trimming or AOT warnings.
Routya is a maintained dependency in customer projects, so performance
regressions need to be caught by CI rather than noticed later. Timings
cannot do that on a shared runner: measured locally, the MediatR baseline in
these benchmarks drifted by a third between runs on identical code, which
dwarfs any regression worth catching. Comparing one CI run against a
previous one is worse still, because each lands on different hardware.

Allocated bytes do not have that problem. They are deterministic, so they
hold on any machine, any CPU and either GC mode, and they are what the
README actually advertises.

Adds eight allocation budget tests, five for the runtime dispatcher and
three for the source generated one, covering Send, SendAsync and
PublishAsync across Root and Scoped configurations. Each measures bytes per
operation using the per thread allocation counter, after a warm up that
excludes the one off cost of building the cached dispatch delegate.

Budgets were measured on net8.0 and carry roughly 25% headroom:

  runtime, Root scope, Singleton handlers
    Send                      104 B, budget 128
    SendAsync                 208 B, budget 256
    PublishAsync, 2 handlers   32 B, budget  64
  runtime, Scoped
    SendAsync                 544 B, budget 640
    PublishAsync, 2 handlers  392 B, budget 480
  source generated
    SendAsync                 200 B, budget 256
    Send                       56 B, budget  96
    PublishAsync, 2 handlers  168 B, budget 224

Verified to fail as intended: lowering one budget produced
"allocated 208 B per operation, budget is 16 B".

Also removes the benchmark run from the build workflow. It ran BenchmarkDotNet
on every push to main and develop. It passed --job short, but until the
benchmark entry points were changed to accept command line arguments that
flag was silently discarded, so CI had been running full benchmark jobs and
publishing numbers nobody read. The project is still built, to keep it
compiling, but no longer executed.

The same workflow ran only Routya.Test, and only behind a Test-Path guard
that would silently skip it if the path ever changed. It now runs all three
test suites unconditionally, matching build-and-test.yml.
… parallel

PublishParallelAsync created one dispatch scope and then started every
handler against it concurrently. Any scoped service that two handlers both
resolved was therefore the same instance, in use by both at once. For a
DbContext that means "A second operation was started on this context before
a previous operation completed", which appears under load rather than in
tests, in exactly the fan out scenario parallel publishing is chosen for.

Parallel dispatch under RoutyaDispatchScope.Scoped now creates a scope per
handler. Sequential dispatch deliberately keeps a single shared scope:
handlers run one after another so there is no concurrency hazard, and
sharing is useful when a later handler commits work an earlier one staged.
The two strategies now differ on purpose, and both behaviours are pinned by
tests.

Handlers registered directly against IServiceCollection need extra care.
They can only be reached through their interface, and resolving the whole
enumerable once would bind every instance to a single scope, which is the
thing being avoided. Each is therefore resolved inside its own scope, at the
cost of one extra resolution of the enumerable per handler. Handler counts
are small in practice, and the registry path avoids this entirely.

The cost is measured rather than estimated: parallel publish with two Scoped
handlers goes from 512 B to 824 B per publish, which is the second scope. An
allocation budget test records that figure so it cannot drift unnoticed.
Sequential publish is unchanged at 392 B.

Adds four tests: parallel gives each handler its own scope, via both the
registry and plain DI registration; repeated parallel publishes keep every
scope distinct; and sequential still shares one scope. The three parallel
tests failed before this change, and the sequential one passed, confirming
the existing shared scope behaviour was already correct there.
…ations

The XML documentation quoted twenty one hard coded nanosecond figures across
six files, for example "Performance: ~334ns for Singleton handlers". These
ship in the NuGet package and drive IntelliSense, so every consumer sees
them. They were measured once on different hardware and never revisited, and
the machine used for the recent runs produces figures roughly 20% higher for
the same code. Absolute timings cannot be stated in documentation that is
read on someone else's machine.

They are replaced with allocation figures, which are deterministic and hold
anywhere, taken from the allocation budget tests added in 5d62d2b:

  Send, Singleton handler, Root scope        about 104 B
  SendAsync, Singleton handler, Root scope   about 208 B
  SendAsync, Scoped handler                  about 544 B
  PublishAsync, Singleton, Root scope        about  32 B
  PublishAsync, Scoped                       about 392 B
  PublishParallelAsync, Scoped               about 824 B

Where no measurement exists, for example Transient lifetimes, the claim is
described qualitatively rather than given an invented number.

Also documents the notification scope semantics introduced in d90c4bf on
both IRoutya.PublishAsync and IRoutya.PublishParallelAsync: sequential shares
one dispatch scope so a later handler can commit work an earlier one staged,
parallel gives each handler its own because concurrent handlers must not
share a DbContext. Each points at the other so the difference is discoverable
from either.
The README said nothing about trimming or Native AOT, despite Routya.Core
now building with the analyzers enabled and producing no IL warnings, and
despite a Native AOT binary having been verified to run. It also said nothing
about the scope behaviour introduced in d90c4bf, which users need to know
because it differs between the two publish methods.

Adds a Trimming and Native AOT section covering what is supported, and two
things a user would otherwise discover the hard way:

- Microsoft's DI container cannot construct an open generic service under
  Native AOT when a type argument is a value type, so a behaviour registered
  as typeof(IPipelineBehavior<,>) throws for any request returning int,
  decimal, bool, a Guid or an enum. Adding a closed registration alongside
  the open generic one does not help, because GetServices enumerates every
  registration and still tries to construct the open generic. This is a
  DI container limitation rather than a Routya one, but it bites Routya users
- Native AOT is roughly three to four times slower per dispatch than the JIT,
  because it gives up tiered compilation and dynamic profile guided
  optimisation. It buys startup time, binary size and no runtime dependency.
  Stating this plainly avoids implying AOT is the fast path

Adds a table contrasting the two publish methods: sequential shares one
dispatch scope so handlers can share scoped state, parallel gives each
handler its own so concurrent handlers cannot collide on a DbContext.

Also removes "Streaming support" from the feature list. It was listed as a
feature but is IRequest<IAsyncEnumerable<T>>, which the demo itself
describes as a workaround, with first class IStreamRequest support still
unimplemented. Claiming it invites a bad first impression. The same claim
remains in RELEASE_NOTES_V3.md, which is a historical document.
[Unreleased] was written at f3c357e and predates the parallel publish fix,
the trimming and AOT work, the allocation budget tests, the CI change and
the two documentation passes.

Adds to Fixed: PublishParallelAsync handing every handler the same scoped
dependency, which produced concurrent DbContext use under load.

Adds to Added: trimming and Native AOT support, and the allocation budget
tests.

Adds to Changed: the AddRoutya overload split, and the removal of the
BenchmarkDotNet run from CI along with the test step that ran only one suite
behind a guard that could silently skip it.

Adds to Documentation: the XML timing figures replaced with allocations, the
README Trimming and Native AOT section, the notification scope semantics,
and the removal of the overstated streaming claim.

Adds two upgrade notes, both of which can change behaviour for an existing
project: parallel notification handlers can no longer share scoped state
with one another, and open generic pipeline behaviour registrations throw
under Native AOT for value type responses.
Routya advertised streaming as a feature while the demo it pointed at
described the same thing as a workaround. Both statements were true of
IRequest<IAsyncEnumerable<T>>, which is why the claim had to go one way or
the other. It is now a real feature.

The old shape returned a task that handed back a sequence, so the pipeline
completed the moment the sequence was handed over, before a single item had
been produced. A behavior wrapped around it could not observe items, could
not catch an exception thrown part way through, and timed only the handover.
That is not streaming in any useful sense.

Adds:

- IStreamRequest<TResponse>, IStreamRequestHandler<TRequest, TResponse>,
  IStreamPipelineBehavior<TRequest, TResponse> and StreamHandlerDelegate
- IRoutya.CreateStream<TRequest, TResponse>, backed by CompiledStreamDispatcher
- AddRoutyaStreamRequestHandler, which records the handler in the registry so
  dispatch resolves it directly
- A generated CreateStream member per stream handler on IGeneratedRoutya,
  so source generator users get it with no generic arguments at the call site

Stream behaviors wrap the enumeration itself, so they see every item and any
failure. Items are produced lazily and nothing is buffered.

Scope lifetime needed care. The dispatch scope is declared inside the async
iterator, which ties its disposal to the iterator's own disposal rather than
to the call that starts enumeration. It therefore survives the whole stream
and is released when enumeration completes or the consumer abandons it, so a
handler can hold a DbContext across the stream. Each CreateStream call gets
its own scope.

Routya.Core references Microsoft.Bcl.AsyncInterfaces on the netstandard2.0
target only, which has no IAsyncEnumerable<T> of its own. The API is
identical across all five target frameworks.

Adds 10 runtime tests covering ordering, laziness, absence of buffering,
behaviors observing every item, behaviors observing a mid stream exception,
behavior ordering, scope lifetime across enumeration, scope isolation between
calls, cancellation part way through, and the missing handler error. Adds 6
generator tests and 3 further runtime tests for the generated member.

Converts Routya.WebApi.SourceGen.Demo to the new API and removes the
workaround comments. The streaming endpoint now returns the sequence directly
rather than awaiting a task to obtain it.

Note for anyone implementing IRoutya themselves, such as a test double: the
interface has gained a member and those implementations need updating.
Consumers are unaffected.
…kage

Routya.Events is upcoming work with no implementation, only a placeholder
Class1 and a description copied from Routya.Core. It nonetheless had
GeneratePackageOnBuild enabled, so every build wrote a .nupkg carrying the
real Routya.Events package id. Both 1.0.0 and 2.0.0 artefacts were sitting in
bin.

Nothing in CI pushes it: dotnet.yml packs and pushes only Routya.Core,
Routya.SourceGenerators and Routya.Package. But a NuGet version, once
published, can only be delisted and never withdrawn, so any process that
globbed for nupkgs would have permanently squatted the id with an empty
assembly.

Marked IsPackable=false until there is something to ship. The project still
builds, so the placeholder cannot rot.
… stray nul file

IRoutya.CreateStream had no worked example. The source generator demo covers
the generated CreateStream member, but the runtime dispatcher path was
undemonstrated, and it is the one where scope lifetime across an enumeration
actually matters.

Adds ExportProductsRequest, a Scoped ExportProductsHandler and a
GET /api/products/stream endpoint to Routya.WebApi.Demo. The handler injects
AppDbContext directly rather than creating its own scope, and returns
AsAsyncEnumerable() so rows are streamed from the database instead of
materialised into a list. ASP.NET Core writes each product to the response as
it arrives, so memory stays flat however many products match.

That combination is the point of the example: under RoutyaDispatchScope.Scoped
the dispatch scope lives for the whole enumeration, so the DbContext is still
usable on the last item as well as the first, and the scope is released when
the response finishes or the client disconnects part way through.

Also removes the stray nul file from the working directory. It was never
tracked, so nothing changes in history.
Adding CreateStream to IRoutya was recorded as a breaking change for anyone
implementing the interface, but DefaultRoutya's public constructor also
gained an IRoutyaStreamDispatcher parameter and that was missed. Resolving
IRoutya from the container is unaffected; only direct construction breaks.
…n failure

The allocation budget tests printed their measured figure inside the assert
message, so it appeared only when a budget was exceeded. On a green run the
numbers were invisible, which meant a pipeline could confirm nothing had
regressed but could not tell you what the code actually allocates on that
hardware.

Each test now reports its figure two ways:

- To the test output, so it appears in the console log
- To GITHUB_STEP_SUMMARY when running under GitHub Actions, which renders as
  a table on the workflow run page

Both build workflows now run the two suites carrying these tests with
"console;verbosity=detailed", since the default logger discards test output
on a passing run.

Verified locally by setting GITHUB_STEP_SUMMARY and running both suites,
which produced:

  Routya.Core runtime dispatch
    Send, Singleton                  104 B (budget 128)
    SendAsync, Singleton             208 B (budget 256)
    PublishAsync, 2 Singleton         32 B (budget  64)
    SendAsync, Scoped                544 B (budget 640)
    PublishAsync, 2 Scoped           392 B (budget 480)
    PublishParallelAsync, 2 Scoped   824 B (budget 1024)

  Routya source generated dispatch
    Send                              56 B (budget  96)
    SendAsync                        200 B (budget 256)
    PublishAsync, 2 handlers         168 B (budget 224)
Bumps Routya.Core, Routya.SourceGenerators and Routya.Package from 3.1.0 to
4.0.0, and closes the changelog into a dated 4.0.0 release.

A major version rather than 3.2.0, because two public API changes are
breaking, both narrow: IRoutya gained CreateStream, which affects anyone
implementing the interface such as a hand written test double, and
DefaultRoutya's public constructor gained an IRoutyaStreamDispatcher
parameter. Consumers resolving IRoutya from the container need no source
change.

README corrections, all of which were wrong before this release rather than
made wrong by it:

- Install instructions told people to use Routya.SourceGenerators 3.0.0 and
  Routya.Core 2.0.0. Both now say 4.0.0, and the meta package is documented
- The banner claimed "46% faster" on notifications, from a benchmark set
  measured on different hardware. Replaced with the allocation figures, which
  are deterministic and asserted by the budget tests on every build: 272 B per
  notification publish against MediatR's 600 B, and 976 B per request against
  1008 B
- The v2.0.0 section still advertised auto-caching of discovered handlers,
  which never worked and has been removed
- Release Notes linked to RELEASE_NOTES_V3.md, which still carries the 46%
  claim. Now links to the changelog, which is maintained
- Added a Breaking Changes in v4.0.0 section covering both API breaks and the
  three behaviour changes

The same 46% claim appeared in Routya.SourceGenerators/README.md and
Routya.Package/README.md, both of which ship inside the NuGet packages.
Replaced with allocation figures and an explanation of why timings are not
published. The generator README also now documents the generated CreateStream
member.

Verified by packing: Routya.Core.4.0.0.nupkg and Routya.4.0.0.nupkg build,
and the new Microsoft.Bcl.AsyncInterfaces reference appears only in the
.NETStandard2.0 dependency group, so no other target gains a dependency.
The pipeline succeeded but reported four categories of warning. All are now
resolved, and every shipping and test project builds with zero warnings.

RS2008, analyzer release tracking, five occurrences per build job.
Added AnalyzerReleases.Shipped.md and AnalyzerReleases.Unshipped.md to
Routya.SourceGenerators and wired them in as AdditionalFiles. This is the
intended fix rather than a suppression: the release tracking files document
every ROUTYA diagnostic id and severity in one place, so an accidental id
change or severity change becomes visible in review.

CS8632, nullable annotations outside an annotations context, ten
occurrences. The source is shared across all five target frameworks and uses
nullable reference annotations throughout, but netstandard2.0 had the
annotation context off. That target now uses Nullable=annotations, which
accepts the syntax without turning on flow analysis, so the shared source
compiles cleanly without the null state warnings a pre nullable target would
otherwise raise.

xUnit1031, blocking task operations in test methods, eight occurrences.
Suppressing would have been wrong, but so would the analyzer's suggested fix:
the allocation probes must run synchronously on one thread, because
GetAllocatedBytesForCurrentThread counts per thread and a continuation
resumed on the thread pool would not be counted. Making those tests async
would silently break the measurement.

Instead the blocking wait moved into an AllocationProbe overload taking
Func<Task>, so it lives in a helper rather than a test method. The behaviour
is unchanged and the reason is documented where the wait happens. Every
handler these tests use completes synchronously, so the task is already
finished and nothing actually blocks.

One xUnit1031 site was a genuine finding rather than a false positive:
CreateStream_Throws_When_No_Handler_Is_Registered drove the enumerator by
hand and blocked on MoveNextAsync. It is now an async test using
Assert.ThrowsAsync around an await foreach, which is both correct and
clearer.

Node.js 20 deprecation. actions/checkout and actions/setup-dotnet bumped
from v4 to v5 across all three workflows, since the runners are already
forcing v4 onto Node 24.
…root

Three pieces of tidying that were listed as out of scope on the pull request.

Benchmark MediatR versions. Routya.SourceGen.Benchmark pinned 12.4.1 while
the other two pinned 12.5.0, so the three suites were not comparable with
each other and nothing made that visible. The version now comes from a single
MediatRVersion property in Directory.Build.props, so they cannot drift apart
again. Held at 12.5.0, the last Apache 2.0 release; moving to 13 or later is
a licensing decision rather than a maintenance one.

Source generator benchmark baseline. Requests and notifications lived in one
class with a single Baseline = true on the request benchmark, so every
notification row was divided by a request timing. The Ratio column was
arithmetic rather than a comparison: 0.56 meant "a notification takes 56% of
the time a request does", not "56% of MediatR". Split into RequestBenchmarks
and NotificationBenchmarks, each with its own baseline. Measured correctly,
source generated notifications sit at ratio 0.65 against MediatR rather than
the meaningless 0.40 reported before.

Repository root. Twelve internal planning and analysis documents moved into
docs/, leaving README, CHANGELOG, CONTRIBUTING and SECURITY at the top level.
Added docs/README.md as an index that separates current guidance from
historical notes and states plainly that the performance figures in the
latter are superseded.

RELEASE_NOTES_V3.md now carries a banner saying the same, since it still
contains the "46% faster than MediatR" claim measured on hardware no longer
in use and against MediatR 12.4.1. The document is kept as a record rather
than edited, because rewriting history is worse than labelling it.

Also fixed while in there: the getting started guide, which the README links
to, had a Release Notes link pointing outside the repository entirely, and
still told people to install version 3.0.0.
Every published timing claim is removed and both benchmark tables are
replaced with allocation tables.

The reasoning is the same one that drove the allocation budget tests.
Allocated bytes are deterministic: they do not depend on CPU, machine load or
GC mode, so a figure measured here holds on the reader's hardware, and every
one of them is asserted on each build. Timings are not reproducible. On this
machine the MediatR baseline, running unchanged code, drifted by a third
between two runs, which is larger than any advantage the tables were
claiming. Publishing a number a reader cannot reproduce costs more trust than
the number ever earned.

Removed:

- The request table's Mean and Ratio columns, and "9-10% faster than MediatR"
- The notification table's Mean and Ratio columns, "30% faster", "7% faster"
  and "9% faster"
- "30% faster for notifications (110ns vs 158ns)" from the lifetimes section
- "High-performance dispatching, competitive with MediatR", which was vague
  enough to imply a speed claim without making a measurable one

Both tables now show allocated bytes against MediatR, with the comparison
stated per configuration, and explain why timings are absent and where to run
the benchmarks if you want them for your own hardware.

The notification table also now shows parallel publishing with Scoped
handlers at 824 B against 392 B sequential, which was previously unlisted.
That is the cost of a scope per handler, and it is what makes a DbContext
safe in concurrent handlers, so it is worth stating rather than hiding.

One reference to speed is deliberately kept: the Native AOT section still
says AOT runs roughly three to four times slower than the JIT. That is a
caveat rather than a claim, and removing it would let readers infer AOT is a
throughput win when the measurements show the opposite.
@HBartosch
HBartosch merged commit 5f3f3e9 into main Sep 29, 2026
5 checks passed
@HBartosch
HBartosch deleted the fix/v3.2-correctness branch September 29, 2026 05:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant