Skip to content

Run license validation off the Mediator construction path - #1174

Merged
jbogard merged 1 commit into
mainfrom
fix/license-validation-deadlock
Jul 1, 2026
Merged

Run license validation off the Mediator construction path#1174
jbogard merged 1 commit into
mainfrom
fix/license-validation-deadlock

Conversation

@jbogard

@jbogard jbogard commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Ports the AutoMapper #4640 fix (AutoMapper#4645) to MediatR, which ships the same LicenseAccessor and has the same deadlock.

Problem

Mediator's constructor calls serviceProvider.CheckLicense(), which runs LicenseAccessor.ValidateKey:

var validateResult = Task.Run(() => handler.ValidateTokenAsync(licenseKey, parms))
    .GetAwaiter().GetResult();

LicenseAccessor is a lazily-built singleton, so on the first Mediator construction this runs while the DI container holds its singleton-build lock. Task.Run queues 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-onlyLicenseValidator.Validate just emits log messages and gates no Mediator behavior. So validation needn't be synchronous and can move off the construction path.

Fix

  • CheckLicense still resolves LicenseAccessor / LicenseValidator synchronously (cheap DI resolutions — and this preserves the existing behavior where a missing registration surfaces on the caller, covered by Should_throw_when_missing_required_configuration). It 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.

Behavior change

License log lines now appear shortly after the first Mediator is constructed rather than synchronously during construction. No functional impact — Mediator behavior never depends on the license.

Tests

  • New LicenseValidationBackgroundTests asserts validation logs on a different thread than the constructor's. Verified it fails on the old synchronous behavior and passes here.
  • Full suite green: MediatR.Tests 179 passed / 4 skipped / 0 failed, DI tests 66/66. Clean under TreatWarningsAsErrors.

🤖 Generated with Claude Code

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>
Copilot AI review requested due to automatic review settings July 1, 2026 16:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 CheckLicense so Mediator construction can return immediately.
  • Removes the Task.Run(...).GetResult() wrapper inside LicenseAccessor.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;
}

@jbogard
jbogard merged commit f5cae96 into main Jul 1, 2026
7 checks passed
@jbogard
jbogard deleted the fix/license-validation-deadlock branch July 1, 2026 16:42
This was referenced Jul 2, 2026
This was referenced Aug 17, 2026
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.

2 participants