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 change — action 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. UnderContinueAndAggregateit aggregates with its siblings into oneAggregateException; underSequential/StopOnFirstthe remaining handlers still run. -
Only when the publish
CancellationTokenis genuinely cancelled does an unwrappedOperationCanceledExceptionsurface. 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 onlyClassDeclarationSyntax, so records were silently skipped.record structstays excluded — DI implementation types must be classes. (#22)partialtypes 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 severalTnow gets one fully-typedSend_*pipeline per(request, response)pair instead of throwingInvalidCastException. (#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 toSequential/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>leftUpdateOrderunvalidated. (#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()emittedservices.AddTransient<Concrete>()once per implemented interface, so a handler implementing twoINotificationHandler<>interfaces produced duplicateServiceDescriptors. 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 ofList<>, and the output node combines a projectedboolinstead of the rawCompilation. Reference-equality models had been defeating the incremental generator’s caching on every keystroke. Guarded byUnrelatedEdit_LeavesGeneratorOutputsCached. (#22) StopOnFirstemits its success log line before its early return. (#22)
Runtime and abstractions
ValidationException.Errorsis genuinely immutable. All public constructors now snapshot the errors into an array, so a liveList<T>can no longer be cast back throughIReadOnlyListand mutated. Theerrorsargument is null-guarded. (#22)FluentValidationBehaviornull-guards its constructor —ArgumentNullException.ThrowIfNull(validators)instead of a laterNullReferenceException. (#27)FluentValidationBehaviortolerates FluentValidation’s low-level failure API, which permits a nullPropertyName/ErrorMessagewhileValidationErrorannotates both non-null. Both are coalesced.ConfigureAwait(false)added to bothnext()awaits. (#23)[NotificationExecution]/[NotificationError]are valid on structs. (#22)- Docs corrected to match the code: the
Parallelstrategy XML doc describedTask.WhenAll; the actual mechanism is two-phaseValueTaskfan-out. Rules 20 and 60 now document the emitted error-pathLogError, which the code always did and the rules denied.MediatorDiagnostics.Listener/Eventsare documented as a reserved, currently-silent surface — nothing writes to thatDiagnosticListener, yetdocs/observability.mdshowed 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 becausepre-tool-use.shis wired as aPreToolUsehook, that failure denied every agent tool call in the repo. A new.gitattributessets* text=auto, pinseol=lfon*.sh/*.csxand the extension-less.agents/hooksscripts, keeps*.ps1at CRLF, and marks binaries. 66 files renormalized — line endings only, proven content-free withgit diff --cached --ignore-cr-at-eol. (#27) .csxhooks stopped aborting. Every hook exited 134 (SIGABRT) on Linux/WSL:Microsoft.Data.Sqlitepooling closes connections from aProcessExithandler, by which point dotnet-script’s nativee_sqlite3resolver is gone, producingDllNotFoundException. A crashingPreToolUsehook blocks the command it guards, so this blockedgit commitoutright. Fixed withPooling=Falsein all threeContextDb.csxcopies; 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 inCLAUDE.mdcould 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-commitandpre-pushare written forcore.hooksPath, which nothing set, sogit commit/git pushsilently 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/stderrconcurrently (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.csxspliced 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'='1is 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_WithNoHandlersnow publishes a genuinely handler-less event — it had been exercising a three-handler notification. (#22)
Upgrade guide
- Search for
[MediatorGeneration(Skipin 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. - Rebuild and read the warnings.
MEDL1003andMEDL1004are 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. - If you catch cancellation from
PublishAsync: underContinueAndAggregate, a handler’s ownOperationCanceledExceptionnow arrives inside anAggregateExceptionalongside its siblings, rather than unwrapped and alone. An unwrappedOperationCanceledExceptionnow means the publish token itself was cancelled. - If you target Blazor WebAssembly: no code change needed. Upgrade and it works.
- No
AddMediatorLite()orAddGeneratedHandlers()call sites change. The DI surface is untouched.
Verification
dotnet build MediatorLite.sln -c Release— 0 warnings, 0 errors. This repo setsTreatWarningsAsErrors, so a warning is a build failure.dotnet test MediatorLite.sln— 131/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/CS0111before a consumer does. .gitattributesverified per file withgit 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’sParallel+ContinueAndAggregatecancellation 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.