MediatorLite v2.1.0

Stability and correctness release. Six merged pull requests (#22–#27), all bug fixes. No new features, no new public API.

The headline for consumers: MediatorLite v2 now runs on Blazor WebAssembly, where every dispatch previously aborted the Mono runtime. Beyond that, this release closes a family of source-generator defects that broke consumer builds with compiler errors inside generated files, corrects notification cancellation semantics that were wrong in v2.0.5, and adds two new build-time diagnostics that turn silent misconfiguration into visible warnings.

One breaking change[MediatorGeneration(Skip = true)] is now inert. See Breaking change before upgrading.

Previous release: v2.0.5 (2026-07-04) · Packages: MediatorLite, MediatorLite.Abstractions, MediatorLite.SourceGeneration, MediatorLite.FluentValidation


At a glance

If you… Read
Target Blazor / Mono WebAssembly Blazor WebAssembly crash fixed — this release unblocks you entirely
Use [MediatorGeneration(Skip = true)] Breaking changeaction required
Rely on ContinueAndAggregate notifications Notification cancellation semantics — behavior differs from v2.0.5
Write generic handlers or open behaviors Generator: emission correctness and New diagnostics
Declare handlers as record, partial, or multi-response Generator: discovery coverage
Contribute to this repo Repository infrastructure

Breaking change

[MediatorGeneration(Skip = true)] no longer skips anything

The generator used to honour Skip = true and exclude the type from discovery. The documentation stated the opposite — that v2 discovery is unconditional. Code and docs contradicted each other, and either side could burn you.

Resolution: the code now matches the documentation. The three Skip checks were removed; the attribute is inert. The attribute type remains in MediatorLite.Abstractions with its [Obsolete] marking, because deleting a public type is itself a breaking change (repo rule 90 §4).

Who this affects: any consumer relying on Skip = true to keep a handler out of the generated registration. That handler is now discovered, registered, and dispatched. If two handlers exist for one (request, response) pair and you were suppressing one with Skip, the suppressed handler now participates — and you will see the new MEDL1003 warning naming the dead one.

Migration: exclusion is now structural. Move the type into an assembly the generator does not run on.

// Before — no longer has any effect:
[MediatorGeneration(Skip = true)]
public class ExperimentalHandler : IRequestHandler<FooQuery, FooResult> { }

// After — move the type to a project that does not reference MediatorLite.SourceGeneration.

Rationale for the minor version bump: this changes the semantics of a public attribute, which repo rule 90 §3 classifies as breaking. It ships with an ADR (.github/Memories/mediatorgeneration-skip-attribute-inert.md), a migration-guide entry, and corrections to the README, quick-start, and migrate-from-MediatR docs that had advertised Skip as working.

From #24.


Blazor WebAssembly crash fixed

Symptom: on Blazor WebAssembly (Mono), the first IMediator.SendAsync(...) / PublishAsync(...) aborted the runtime. The page froze, and the browser console showed either a native Mono assertion (object.c:8000, not catchable by try/catch) or System.TypeLoadException: Recursive type definition detected System.Threading.Tasks.ValueTask'1. Both are the Mono type loader failing to load ValueTask<Unit>.

Root cause: Unit.CompletedTask was a get-only auto-property with an initializer, which the compiler lowers to a static backing field inside Unit itself:

Unit ──(static field)──▶ ValueTask<Unit> ──(struct field _result)──▶ Unit ──▶ …

Mono’s WebAssembly type loader has an over-aggressive recursion detector (same family as dotnet/runtime#85821) that aborts on this cycle when the recursive path is the first one taken to load the type.

Two properties made it hard to diagnose. It is load-order dependent — if ValueTask<Unit> is first loaded via a benign path, the crash is masked for the rest of the process, and the generated mediator’s dispatch is typically the first place a real app instantiates it. And it is v2-only — v1’s IMediator returned Task<T>, a reference type, so no self-referential struct field ever existed. CoreCLR (server-side, console, tests) accepts the same IL happily, which is why CI never caught it.

Fix — one line, no API break, no allocation cost:

public static ValueTask<Unit> CompletedTask => ValueTask.FromResult(Value);

An expression-bodied property has no backing field, so the static self-reference disappears and the type loader has nothing to recurse into — independent of load order.

Regression guard: UnitTypeTests.Unit_HasNoStaticFieldOfTypeValueTaskOfUnit reflects over Unit and asserts it declares no static field of type ValueTask<Unit>. It fails on the old auto-property and passes with the fix, locking the regression out on ordinary CI where the runtime abort cannot be observed.

Verified on Microsoft.NETCore.App.Runtime.Mono.browser-wasm 10.0.8 and 10.0.9, against a standalone reproduction repo.

From #26, contributed by @SolemnDucc.


Notification cancellation semantics corrected

Two independent cancellation defects are fixed. If you upgrade from v2.0.5, ContinueAndAggregate behaves differently — deliberately.

A handler’s own OperationCanceledException is an ordinary fault

v2.0.5 special-cased any OperationCanceledException from a notification handler. Under Parallel + ContinueAndAggregate, the first one found was rethrown unwrapped and every sibling fault was silently dropped. Under Sequential / StopOnFirst, it stopped the remaining handlers from running — contradicting the documented contract.

The distinction that was missing: whose cancellation is it? A handler’s internal timeout or linked token is not the publish operation being cancelled.

Now:

  • A handler’s own OperationCanceledException, with the publish token not cancelled, is an ordinary fault. Under ContinueAndAggregate it aggregates with its siblings into one AggregateException; under Sequential / StopOnFirst the remaining handlers still run.
  • Only when the publish CancellationToken is genuinely cancelled does an unwrapped OperationCanceledException surface. The generated catch is explicit about it:

    catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
    

Pinned by: PublishAsync_ParallelAggregate_HandlerInternalOce_AggregatesAllFaults, PublishAsync_ParallelAggregate_GenuineCancellation_SurfacesOceUnwrapped, PublishAsync_SequentialAggregate_HandlerInternalOce_DoesNotSkipRemainingHandlers. Documented in rule 40 and the ContinueAndAggregate XML docs.

From #23 — correcting behavior introduced in #22 and shipped in v2.0.5.

Parallel now honours an already-cancelled publish token

Parallel ran every handler even when the publish token was cancelled before the call. The other three strategies refuse. Hit by cts.Cancel(); await mediator.PublishAsync(evt, cts.Token); with cancellation-agnostic handlers.

The generated Parallel path now calls ct.ThrowIfCancellationRequested() before the start phase, matching the other strategies. Pinned by PublishAsync_Parallel_WithPreCancelledToken_ThrowsOceWithoutRunningHandlers.

From #24 (B3).


Generator: emission correctness

This class of defect is the worst kind a source generator produces: the consumer’s build breaks with a compiler error inside a .g.cs file they did not write, with no diagnostic explaining why. Every fix below either emits correct code or reports a proper diagnostic and skips.

Defect How you would hit it Fix
Generic and nested-generic handlers emitted unbound type parameters (CS0246) Any generic handler class, an open generic handler (the MediatR pattern), or a handler nested in a generic outer type New MEDL1004 warning; the type is skipped instead of breaking the build (#24, B1)
Generic behaviors whose IPipelineBehavior<,> interface is closed or partially closed emitted unbound type parameters, with no diagnostic A type parameter nested inside an interface argument MEDL1002 now rejects them (#23)
Behaviors nested inside generic outer types truncated the display name at the outer <, emitting the wrong type name entirely Handler or behavior nested in a generic type Containing-type-aware guard shared across handler, behavior, and validator discovery (#24, B1)
Open behaviors constrained where TRequest : class, IRequest<TResponse> — a common MediatR shape — were rejected by MEDL1002 and silently never registered Any class-constrained open behavior IsSupportedOpenShape permits the reference-type constraint; value-type requests are filtered at expansion time so no CS0452-violating closed type is emitted (#25, F3)
Switch-arm ordering ignored implemented interfaces, producing order-dependent CS8120 subsumption errors A concrete implementor and an interface-typed message both having arms Interfaces now count as supertypes, so a concrete arm always precedes an interface-typed arm (#23)
Send_* / Publish_* suffixes collided (CS0111 / CS0121) A multi-response request whose response types sanitize to the same identifier Suffixes are deterministically uniquified (#23)
Covariant multi-response dispatch returned the wrong handler’s result IRequest<object> r = new MultiResponseQuery(5) returned boxed 50 instead of "value:5" — a boxed value counted as variance-assignable Boxing excluded from the variance check (#24, B2)
Covariant fallback picked the assignable pipeline in discovery order, not most-derived-first Multi-response request with a response hierarchy Candidates sorted most-derived-first by response-type depth; ties keep deterministic source order (#25, F2)
Generic type names leaked mangled or global::-prefixed text into log and activity display names Any generic or tuple type in a request/notification name Name sanitization fixed; StripGlobalPrefix strips every occurrence (#22, #23)
Culture-sensitive StartsWith classified validation vs ordinary behaviors — feeding the public BehaviorCount and the validation-outermost ordering A culture where the ordinal assumption fails Pinned to StringComparison.Ordinal; byte-identical output, latent culture dependency removed (#25, F1)

Generator: discovery coverage

Types that should have been discovered but were silently skipped, and types that were discovered twice.

  • record-declared handlers, behaviors, and validators are now discovered. Discovery matched only ClassDeclarationSyntax, so records were silently skipped. record struct stays excluded — DI implementation types must be classes. (#22)
  • partial types register once, not once per part. Discovery deduplicates by fully-qualified name, so a partial type with base lists in several declarations no longer registers and dispatches multiple times. (#22)
  • Multi-response requests dispatch correctly. A request implementing IRequest<T> for several T now gets one fully-typed Send_* pipeline per (request, response) pair instead of throwing InvalidCastException. (#22)
  • Attributes declared in a referenced assembly are honoured. [NotificationExecution] / [NotificationError] are read from the notification type symbol during discovery, so strategies declared on types in a referenced contracts assembly no longer fall back silently to Sequential / StopOnFirstError. (#23)
  • Attribute matching is namespace-qualified. Matching is now on namespace + name, mirroring the interface checks. A same-named attribute from a foreign namespace can no longer silently disable logging or tracing, or change strategies, orders, or skip behavior. (#23)
  • A validator covering several request types validates all of them. IValidator<T> implemented for multiple request types previously validated only the first — e.g. class Shared : AbstractValidator<CreateOrder>, IValidator<UpdateOrder> left UpdateOrder unvalidated. (#24, B4)
  • Dead closed behaviors are no longer registered. A closed behavior for a request type with no handler was registered in DI and counted in BehaviorCount. It is now filtered, matching the existing validator policy. Pinned counts are unaffected. (#24, B6)
  • Concrete notification handlers register once per class. AddGeneratedNotificationHandlers() emitted services.AddTransient<Concrete>() once per implemented interface, so a handler implementing two INotificationHandler<> interfaces produced duplicate ServiceDescriptors. Deduplicated by fully-qualified class name; interface registrations are still emitted per interface, and emission order is unchanged. (#27)
  • Incremental caching restored. Pipeline model records use EquatableArray<T> (value equality) instead of List<>, and the output node combines a projected bool instead of the raw Compilation. Reference-equality models had been defeating the incremental generator’s caching on every keystroke. Guarded by UnrelatedEdit_LeavesGeneratorOutputsCached. (#22)
  • StopOnFirst emits its success log line before its early return. (#22)

Runtime and abstractions

  • ValidationException.Errors is genuinely immutable. All public constructors now snapshot the errors into an array, so a live List<T> can no longer be cast back through IReadOnlyList and mutated. The errors argument is null-guarded. (#22)
  • FluentValidationBehavior null-guards its constructorArgumentNullException.ThrowIfNull(validators) instead of a later NullReferenceException. (#27)
  • FluentValidationBehavior tolerates FluentValidation’s low-level failure API, which permits a null PropertyName / ErrorMessage while ValidationError annotates both non-null. Both are coalesced. ConfigureAwait(false) added to both next() awaits. (#23)
  • [NotificationExecution] / [NotificationError] are valid on structs. (#22)
  • Docs corrected to match the code: the Parallel strategy XML doc described Task.WhenAll; the actual mechanism is two-phase ValueTask fan-out. Rules 20 and 60 now document the emitted error-path LogError, which the code always did and the rules denied. MediatorDiagnostics.Listener / Events are documented as a reserved, currently-silent surface — nothing writes to that DiagnosticListener, yet docs/observability.md showed a subscription sample and rule 60 pointed consumers at it. (#22, #23)

New diagnostics

Three new build-time warnings. Each replaces silent misbehavior with a named, actionable message.

ID Severity Category Meaning
MEDL1003 Warning MediatorLite.Handlers Multiple handlers are registered for the same (request, response) pair; the dead handlers are named. Dispatch is unchanged — last registration wins. (#23)
MEDL1004 Warning MediatorLite.Handlers A handler class is generic or nested inside a generic type and cannot be registered. Previously this emitted non-compiling code. (#24)
MEDL1005 Warning MediatorLite.Behaviors A discovered open pipeline behavior expanded to zero registrations — it matched no request and will never run (e.g. a where TRequest : class behavior when every discovered request is a value type). Previously it was skipped silently.

Already shipped in v2.0.5, for reference: MEDL1001 (Error — FluentValidation validators found but the MediatorLite.FluentValidation package is not referenced) and MEDL1002 (Warning — an open generic behavior does not match the supported Behavior<TRequest, TResponse> shape; message updated in #25).


Repository infrastructure (not shipped in packages)

These fixes affect contributors to this repository only. No consumer-facing package contains any of this — listed for completeness, since PRs #22 and #27 are largely infrastructure work.

  • Line endings pinned. .claude/** and .cursor/** were committed with CRLF. A CRLF bash script dies on WSL/Linux with $'\r': command not found, and because pre-tool-use.sh is wired as a PreToolUse hook, that failure denied every agent tool call in the repo. A new .gitattributes sets * text=auto, pins eol=lf on *.sh / *.csx and the extension-less .agents/hooks scripts, keeps *.ps1 at CRLF, and marks binaries. 66 files renormalized — line endings only, proven content-free with git diff --cached --ignore-cr-at-eol. (#27)
  • .csx hooks stopped aborting. Every hook exited 134 (SIGABRT) on Linux/WSL: Microsoft.Data.Sqlite pooling closes connections from a ProcessExit handler, by which point dotnet-script’s native e_sqlite3 resolver is gone, producing DllNotFoundException. A crashing PreToolUse hook blocks the command it guards, so this blocked git commit outright. Fixed with Pooling=False in all three ContextDb.csx copies; confirmed by A/B test (pooling on → exit 134, off → exit 0). (#27)
  • All 7 role agents were non-functional. Their frontmatter declared invented tool names (read, search, edit, shell, web), so every spawn was refused with “would be spawned with zero tools” — the entire team documented in CLAUDE.md could never run. Corrected to real tool names, scoped per role; read-only agents keep read-only grants. (#27)
  • Commit and push gates never ran. .agents/hooks/pre-commit and pre-push are written for core.hooksPath, which nothing set, so git commit / git push silently bypassed the build, format, lint, and test gates. Now configured, with a documented setup step in .agents/README.md. (#27)
  • Gate scripts no longer deadlock. They drain child stdout/stderr concurrently (pipe-deadlock fix) and fail open with a warning when the .NET SDK is missing, instead of crashing and hard-blocking the commit or push. (#22)
  • Hook SQL injection closed. 00-save-context.csx spliced the session id — an external environment variable — into SQL literals and the snapshot filename. Verified end-to-end: a valid id writes a snapshot; x' OR '1'='1 is refused with no file written. (#24, B5)
  • CI flake removed. xUnit parallelization is disabled: fixtures share mutable statics through the open-generic behaviors and raced across test classes. PublishAsync_WithNoHandlers now publishes a genuinely handler-less event — it had been exercising a three-handler notification. (#22)

Upgrade guide

  1. Search for [MediatorGeneration(Skip in your solution. Every hit is now inert. Move those types to an assembly the generator does not run on, or accept that they will be registered and dispatched.
  2. Rebuild and read the warnings. MEDL1003 and MEDL1004 are new and may surface pre-existing problems in your code — a duplicate handler that was always shadowed, or a generic handler that was silently dropped.
  3. If you catch cancellation from PublishAsync: under ContinueAndAggregate, a handler’s own OperationCanceledException now arrives inside an AggregateException alongside its siblings, rather than unwrapped and alone. An unwrapped OperationCanceledException now means the publish token itself was cancelled.
  4. If you target Blazor WebAssembly: no code change needed. Upgrade and it works.
  5. No AddMediatorLite() or AddGeneratedHandlers() call sites change. The DI surface is untouched.

Verification

  • dotnet build MediatorLite.sln -c Release — 0 warnings, 0 errors. This repo sets TreatWarningsAsErrors, so a warning is a build failure.
  • dotnet test MediatorLite.sln131/131 passing on the release commit (de46f39).
  • Every generator fix is TDD: the pinning test was written first and confirmed to fail for the documented reason before the fix landed.
  • Selected fixes were confirmed in both directions — reverting the #27 generator fix makes its test fail with found 2.
  • Generator output is compiled by the driver tests, which assert zero compiler errors — this is what catches emitted CS0246 / CS0452 / CS0111 before a consumer does.
  • .gitattributes verified per file with git check-attr.

Pull requests in this release

PR Title Author Status
#22 Resolve bug-hunt findings across generator, runtime, tests, and hooks behl1anmol Already released in v2.0.5
#23 Four dispatch-emission correctness bugs found in bug hunt behl1anmol New in v2.1.0
#24 Bug hunt: fix 7 verified defects (generator dispatch/discovery, parallel cancellation, hook SQL) behl1anmol New in v2.1.0
#25 Support class-constrained open behaviors + generator hardening behl1anmol New in v2.1.0
#26 Fix Blazor/Mono WebAssembly crash: make Unit.CompletedTask a computed property @SolemnDucc New in v2.1.0
#27 Repair line endings, dead agent definitions, crashing hooks, and generator registration dedupe behl1anmol New in v2.1.0

On #22: its merge commit is contained in tag v2.0.5 — it merged four minutes before that tag was cut. Its fixes are listed above for a complete picture of the v2.0.4 → v2.1.0 arc, but they are not new to this release if you are already on v2.0.5. The one exception worth knowing: #22’s Parallel + ContinueAndAggregate cancellation behavior was shipped in v2.0.5 and is corrected by #23 in this release. See Notification cancellation semantics.


Architectural notes

Three patterns account for nearly every defect in this release. They are worth naming, because they predict where the next one will be.

Discovery guards were not shared. Handlers, behaviors, and validators each grew their own generic-type guard at different times, to different standards — behaviors had MEDL1002, validators silently skipped, handlers had nothing at all and pasted unbound type parameters straight into the output. The same hole was then found again in nested-generic form. The fix was a shared containing-type-aware guard, and the lesson is recorded at .github/Lessons/2026-07-12-generator-discovery-guards-must-be-shared.md.

The generator’s output is a consumer’s build. Every emission defect here manifests as a compiler error in a file the consumer never wrote and cannot fix. That asymmetry is why the correct response to an unsupported shape is always diagnostic plus skip, never emit and hope — and why the driver tests now compile the generated output and assert zero errors rather than merely inspecting it as text.

Compile-time strategy resolution has no runtime escape hatch. Notification strategies are inlined into the generated Publish_* methods, so a wrong compile-time decision — an attribute read from the wrong assembly, a same-named attribute from a foreign namespace, an error strategy that drops faults — is unfixable at runtime by the consumer. Several fixes here (#23’s symbol-side attribute reads, namespace-qualified matching, the ContinueAndAggregate fault semantics) are all instances of getting that one-shot decision right.


Credits

Thanks to @SolemnDucc for the Blazor WebAssembly diagnosis in #26 — a native Mono assertion, load-order dependent and invisible on CoreCLR, tracked to a single self-referential static field and fixed in one line, with a standalone reproduction repo. That is an exemplary bug report.