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(); + } + } + } +}