Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions src/AutoMapper/Configuration/MapperConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion src/AutoMapper/Licensing/LicenseAccessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
60 changes: 60 additions & 0 deletions src/UnitTests/Licensing/LicenseValidationBackgroundTests.cs
Original file line number Diff line number Diff line change
@@ -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);
Comment on lines +18 to +20

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>(TState state) => null;

public bool IsEnabled(LogLevel logLevel) => true;

public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception,
Func<TState, Exception, string> formatter)
{
owner.LoggingThreadId = Environment.CurrentManagedThreadId;
owner.LicenseLogged.Set();
}
}
}
}
Loading