From fe3aab62482f1f0f64b9dd3005b8597a42e8af04 Mon Sep 17 00:00:00 2001 From: Jimmy Bogard Date: Thu, 18 Jun 2026 09:45:43 -0500 Subject: [PATCH] Run license validation off the construction path (fixes #4640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Configuration/MapperConfiguration.cs | 28 ++++++++- src/AutoMapper/Licensing/LicenseAccessor.cs | 5 +- .../LicenseValidationBackgroundTests.cs | 60 +++++++++++++++++++ 3 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 src/UnitTests/Licensing/LicenseValidationBackgroundTests.cs diff --git a/src/AutoMapper/Configuration/MapperConfiguration.cs b/src/AutoMapper/Configuration/MapperConfiguration.cs index 986143a935..1e9f46c7a5 100644 --- a/src/AutoMapper/Configuration/MapperConfiguration.cs +++ b/src/AutoMapper/Configuration/MapperConfiguration.cs @@ -116,10 +116,34 @@ public MapperConfiguration(MapperConfigurationExpression configurationExpression _typesInheritance = null; _runtimeMaps = new(GetTypeMap, openTypeMapsCount); - var validator = new LicenseValidator(loggerFactory); - validator.Validate(_licenseAccessor.Current); + // License validation is logging-only — it gates no mapping behavior and has no + // reader other than this call. Running it on the construction path is unsafe: under + // a lazily-built DI singleton the constructor holds the container's build lock, and a + // cold-start thread-pool starvation then deadlocks the whole app (issue #4640). + // Offload to a dedicated background thread and return immediately. + _ = Task.Factory.StartNew( + ValidateLicense, + CancellationToken.None, + TaskCreationOptions.LongRunning, // dedicated thread, never the (possibly starved) pool + TaskScheduler.Default); // honor LongRunning regardless of the ambient scheduler return; + + void ValidateLicense() + { + try + { + var validator = new LicenseValidator(_loggerFactory); + validator.Validate(_licenseAccessor.Current); + } + catch (Exception ex) + { + _loggerFactory + .CreateLogger("LuckyPennySoftware.AutoMapper.License") + .LogError(ex, "Error validating the Lucky Penny software license key"); + } + } + void Seal() { foreach (var profile in Profiles) diff --git a/src/AutoMapper/Licensing/LicenseAccessor.cs b/src/AutoMapper/Licensing/LicenseAccessor.cs index 3292a2a491..ba4bfa152c 100644 --- a/src/AutoMapper/Licensing/LicenseAccessor.cs +++ b/src/AutoMapper/Licensing/LicenseAccessor.cs @@ -84,7 +84,10 @@ private Claim[] ValidateKey(string licenseKey) ValidateLifetime = false }; - var validateResult = Task.Run(() => handler.ValidateTokenAsync(licenseKey, parms)).GetAwaiter().GetResult(); + // Runs on the dedicated background thread started by MapperConfiguration (issue #4640), + // so there is no SynchronizationContext to deadlock on; local JWT validation completes + // synchronously, so this does not depend on the thread pool. + var validateResult = handler.ValidateTokenAsync(licenseKey, parms).GetAwaiter().GetResult(); if (!validateResult.IsValid) { _logger.LogCritical(validateResult.Exception, "Error validating the Lucky Penny software license key"); diff --git a/src/UnitTests/Licensing/LicenseValidationBackgroundTests.cs b/src/UnitTests/Licensing/LicenseValidationBackgroundTests.cs new file mode 100644 index 0000000000..fbdc687be7 --- /dev/null +++ b/src/UnitTests/Licensing/LicenseValidationBackgroundTests.cs @@ -0,0 +1,60 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace AutoMapper.UnitTests.Licensing; + +public class LicenseValidationBackgroundTests +{ + // Regression test for #4640: license validation must not run on the construction + // thread. Building MapperConfiguration under a lazily-built DI singleton holds the + // container's build lock, and validating there could deadlock the whole app under a + // cold-start thread-pool starvation. Validation is logging-only, so it is offloaded + // to a dedicated background thread. Rather than clamp the global thread pool (flaky, + // process-wide), we assert the property that makes the deadlock impossible: the + // constructor returns without validating, and validation logs on a *different* thread. + [Fact] + public void License_validation_runs_off_the_construction_thread() + { + var provider = new ThreadCapturingLoggerProvider(); + var factory = new LoggerFactory(); + factory.AddProvider(provider); + + var constructingThreadId = Environment.CurrentManagedThreadId; + + // A non-empty (junk) key forces the validation path to run; its result is logged, + // and that logging must happen on a background thread, never on this one. + _ = new MapperConfiguration(cfg => cfg.LicenseKey = "not-a-real-license-key", factory); + + // The constructor returned without blocking on validation. The license log should + // arrive shortly, on a thread other than the one that built the configuration. + provider.LicenseLogged.Wait(TimeSpan.FromSeconds(5)).ShouldBeTrue(); + provider.LoggingThreadId.ShouldNotBe(constructingThreadId); + } + + private sealed class ThreadCapturingLoggerProvider : ILoggerProvider + { + public readonly ManualResetEventSlim LicenseLogged = new(false); + public int LoggingThreadId; + + public ILogger CreateLogger(string categoryName) => + categoryName == "LuckyPennySoftware.AutoMapper.License" + ? new CapturingLogger(this) + : NullLogger.Instance; + + public void Dispose() => LicenseLogged.Dispose(); + + private sealed class CapturingLogger(ThreadCapturingLoggerProvider owner) : ILogger + { + public IDisposable BeginScope(TState state) => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + Func formatter) + { + owner.LoggingThreadId = Environment.CurrentManagedThreadId; + owner.LicenseLogged.Set(); + } + } + } +}