Run license validation off the Mediator construction path - #1174
Merged
Conversation
Mediator's constructor validated the license key synchronously via CheckLicense, which calls LicenseAccessor.ValidateKey and does Task.Run(() => handler.ValidateTokenAsync(...)).GetResult(). Under a lazily-built DI singleton the container holds its singleton-build lock while constructing, and Task.Run needs a free thread-pool thread to complete. During a cold start that takes immediate traffic the pool is saturated, so the queued validation work can never be scheduled — the lock holder blocks forever and the whole app convoys behind it. This is the same root cause and mechanism fixed in AutoMapper #4640; MediatR ships the same LicenseAccessor. The validated license is logging-only: LicenseValidator.Validate just emits log messages and gates no Mediator behavior. So validation need not be synchronous and can move off the construction path: - CheckLicense still resolves LicenseAccessor/LicenseValidator synchronously (cheap, and preserves the "required services" behavior a caller relies on), then offloads the JWT validation + logging to a dedicated LongRunning thread (Task.Factory.StartNew + TaskScheduler.Default) and returns immediately. The construction thread never blocks under the DI lock, so it can't deadlock regardless of pool state. The body is wrapped in try/catch so a faulted fire-and-forget task can't surface as an unobserved exception. - LicenseAccessor.ValidateKey drops the Task.Run wrapper now that validation always runs on that dedicated background thread. Adds a regression test asserting validation logs on a different thread than the Mediator constructor's (fails on the old synchronous behavior). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR moves Lucky Penny license validation off the Mediator construction path to prevent startup deadlocks caused by blocking work while the DI container holds its singleton-build lock (matching the AutoMapper #4640 incident mechanism).
Changes:
- Offloads license validation/logging to a dedicated background thread in
CheckLicensesoMediatorconstruction can return immediately. - Removes the
Task.Run(...).GetResult()wrapper insideLicenseAccessor.ValidateKey, now that validation is expected to run off the construction path. - Adds a regression test asserting license logging occurs on a different thread than the thread constructing
Mediator.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| test/MediatR.Tests/Licensing/LicenseValidationBackgroundTests.cs | Adds a regression test asserting validation logs occur off the Mediator construction thread. |
| src/MediatR/MicrosoftExtensionsDI/MediatRServiceCollectionExtensions.cs | Changes CheckLicense to perform background validation on a LongRunning task and adds error handling for fire-and-forget execution. |
| src/MediatR/Licensing/LicenseAccessor.cs | Removes Task.Run around token validation now that validation is intended to run outside the construction path. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+96
to
+102
| catch (Exception ex) | ||
| { | ||
| // Never let a fire-and-forget failure surface as an unobserved task exception. | ||
| serviceProvider.GetService<ILoggerFactory>()? | ||
| .CreateLogger("LuckyPennySoftware.MediatR.License") | ||
| .LogError(ex, "Error validating the Lucky Penny software license key"); | ||
| } |
Comment on lines
+83
to
+86
| // Runs on the dedicated background thread started during Mediator construction | ||
| // (see MediatRServiceCollectionExtensions.CheckLicense / AutoMapper #4640), so there is | ||
| // no SynchronizationContext to deadlock on; local JWT validation completes synchronously, | ||
| // so this does not depend on the thread pool. |
Comment on lines
+65
to
69
| if (LicenseChecked) | ||
| { | ||
| var licenseAccessor = serviceProvider.GetRequiredService<LicenseAccessor>(); | ||
| var licenseValidator = serviceProvider.GetRequiredService<LicenseValidator>(); | ||
|
|
||
| var license = licenseAccessor.Current; | ||
| licenseValidator.Validate(license); | ||
| return; | ||
| } | ||
|
|
This was referenced Jul 2, 2026
This was referenced Aug 17, 2026
Bump MediatR and Microsoft.Extensions.DependencyInjection.Abstractions
eliasmatheusouza/ClinicHub#31
Open
Open
This was referenced Aug 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ports the AutoMapper #4640 fix (AutoMapper#4645) to MediatR, which ships the same
LicenseAccessorand has the same deadlock.Problem
Mediator's constructor callsserviceProvider.CheckLicense(), which runsLicenseAccessor.ValidateKey:LicenseAccessoris a lazily-built singleton, so on the firstMediatorconstruction this runs while the DI container holds its singleton-build lock.Task.Runqueues work to the thread pool and.GetResult()blocks the calling thread until it completes. During a cold start that takes immediate traffic, the pool is saturated — so the queued validation can never be scheduled, the lock holder blocks forever, and the whole app convoys behind it. Same root cause and mechanism as the AutoMapper production dump in #4640.Key insight
The validated license is logging-only —
LicenseValidator.Validatejust emits log messages and gates no Mediator behavior. So validation needn't be synchronous and can move off the construction path.Fix
CheckLicensestill resolvesLicenseAccessor/LicenseValidatorsynchronously (cheap DI resolutions — and this preserves the existing behavior where a missing registration surfaces on the caller, covered byShould_throw_when_missing_required_configuration). It then offloads the JWT validation + logging to a dedicatedLongRunningthread (Task.Factory.StartNew+TaskScheduler.Default) and returns immediately. The construction thread never blocks under the DI lock, so it can't deadlock regardless of pool state. The body is wrapped in try/catch so a faulted fire-and-forget task can't surface as an unobserved exception.LicenseAccessor.ValidateKeydrops theTask.Runwrapper now that validation always runs on that dedicated background thread.Behavior change
License log lines now appear shortly after the first
Mediatoris constructed rather than synchronously during construction. No functional impact — Mediator behavior never depends on the license.Tests
LicenseValidationBackgroundTestsasserts validation logs on a different thread than the constructor's. Verified it fails on the old synchronous behavior and passes here.TreatWarningsAsErrors.🤖 Generated with Claude Code