Skip to content

Run license validation off the construction path (fixes #4640)#4645

Open
jbogard wants to merge 1 commit into
mainfrom
fix/4640-license-validation-deadlock
Open

Run license validation off the construction path (fixes #4640)#4645
jbogard wants to merge 1 commit into
mainfrom
fix/4640-license-validation-deadlock

Conversation

@jbogard

@jbogard jbogard commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Fixes #4640 — license validation can deadlock the whole app under cold-start thread-pool starvation.

Problem

MapperConfiguration's constructor validated the license key synchronously:

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

Under a DI container that builds IMapper / MapperConfiguration as a lazy singleton (e.g. Lamar), that construction runs while the 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 — every request thread is parked waiting on that same singleton lock — so the queued validation work can never be scheduled. The lock holder blocks forever and the whole process convoys behind it. The 16.1.1 production dump in #4640 pins it exactly: 1 lock owner, 524 waiters, CPU idle (~12%).

This is a follow-up to #4612 / #4613. The earlier .ResultTask.Run(...).GetResult() change dodged the SynchronizationContext deadlock but requires a free pool thread — the new failure mode. The synchronous JsonWebTokenHandler.ValidateToken is, in 8.x, itself ValidateTokenAsync(...).ConfigureAwait(false).GetAwaiter().GetResult() (same hazard) and is [Obsolete], so it's not a fix either.

Key insight

The validated license is logging-onlyLicenseValidator.Validate just emits log messages and gates no mapping behavior, and the result has exactly one reader (that one call). So validation needn't be synchronous and can move off the construction path entirely.

Fix

  • MapperConfiguration — offload validation + logging to a dedicated LongRunning thread (Task.Factory.StartNew + TaskScheduler.Default) and return immediately. The constructor never blocks under the DI build 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 — drop the Task.Run wrapper now that validation always runs on that dedicated background thread (no SynchronizationContext, and local JWT validation completes synchronously, so no pool dependency).

Behavior change

License log lines (valid / missing / expired) now appear shortly after startup rather than synchronously during the first config build. A process that builds a config and exits immediately may not emit them. No functional impact — mapping 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: 1218 unit tests + 33 DI tests, zero failures. Clean under TreatWarningsAsErrors.

Follow-up (not in this PR)

MediatR ships the same LicenseAccessor sync-over-async validation and almost certainly has the identical deadlock — to be ported separately.

🤖 Generated with Claude Code

MapperConfiguration validated the license key synchronously in its
constructor via Task.Run(() => handler.ValidateTokenAsync(...)).GetResult().
Under a lazily-built DI singleton (e.g. Lamar) that runs while the container
holds its singleton-build lock, and Task.Run needs a free thread-pool thread
to complete. During a cold start that takes immediate traffic the pool is
saturated (every request thread is parked waiting on that same lock), so the
queued validation work can never be scheduled — the lock holder blocks
forever and the whole app convoys behind it. A production dump on 16.1.1 in
#4640 shows exactly this: one lock owner, 524 waiters, CPU idle.

The validated license is logging-only: LicenseValidator.Validate just emits
log messages and gates no mapping behavior, and the result has exactly one
reader (this call). So validation need not be synchronous and can move off
the construction path entirely:

- MapperConfiguration: offload validation+logging to a dedicated LongRunning
  thread (Task.Factory.StartNew + TaskScheduler.Default) and return
  immediately. The constructor 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: drop the Task.Run wrapper now that validation
  always runs on that dedicated background thread (no SynchronizationContext,
  and local JWT validation completes synchronously, so no pool dependency).

Adds a regression test asserting validation logs on a different thread than
the constructor's (fails on the old synchronous behavior, passes here).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 18, 2026 14:46

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 addresses a startup deadlock scenario (#4640) caused by synchronous license validation during MapperConfiguration construction under DI singleton-build locks, by moving license validation/logging off the construction path.

Changes:

  • Offloads license validation from MapperConfiguration construction to a background LongRunning task.
  • Simplifies LicenseAccessor.ValidateKey by removing the Task.Run(...).GetResult() wrapper.
  • Adds a regression test asserting that license validation logging occurs on a different thread than the constructor.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
src/AutoMapper/Configuration/MapperConfiguration.cs Starts license validation on a dedicated background task instead of running synchronously during configuration construction.
src/AutoMapper/Licensing/LicenseAccessor.cs Removes Task.Run wrapper around JWT validation now that validation is performed off the construction path.
src/UnitTests/Licensing/LicenseValidationBackgroundTests.cs Adds a regression test ensuring license validation logs are emitted from a non-construction thread.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +18 to +20
var provider = new ThreadCapturingLoggerProvider();
var factory = new LoggerFactory();
factory.AddProvider(provider);
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.

License validation still deadlocks under cold-start thread-pool starvation in 16.1.1 (Task.Run sync-over-async, follow-up to #4612)

2 participants