Skip to content

Commit dfa6dd5

Browse files
authored
Merge pull request #4645 from LuckyPennySoftware/fix/4640-license-validation-deadlock
Run license validation off the construction path (fixes #4640)
2 parents fed6e64 + fe3aab6 commit dfa6dd5

3 files changed

Lines changed: 90 additions & 3 deletions

File tree

src/AutoMapper/Configuration/MapperConfiguration.cs

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,10 +116,34 @@ public MapperConfiguration(MapperConfigurationExpression configurationExpression
116116
_typesInheritance = null;
117117
_runtimeMaps = new(GetTypeMap, openTypeMapsCount);
118118

119-
var validator = new LicenseValidator(loggerFactory);
120-
validator.Validate(_licenseAccessor.Current);
119+
// License validation is logging-only — it gates no mapping behavior and has no
120+
// reader other than this call. Running it on the construction path is unsafe: under
121+
// a lazily-built DI singleton the constructor holds the container's build lock, and a
122+
// cold-start thread-pool starvation then deadlocks the whole app (issue #4640).
123+
// Offload to a dedicated background thread and return immediately.
124+
_ = Task.Factory.StartNew(
125+
ValidateLicense,
126+
CancellationToken.None,
127+
TaskCreationOptions.LongRunning, // dedicated thread, never the (possibly starved) pool
128+
TaskScheduler.Default); // honor LongRunning regardless of the ambient scheduler
121129

122130
return;
131+
132+
void ValidateLicense()
133+
{
134+
try
135+
{
136+
var validator = new LicenseValidator(_loggerFactory);
137+
validator.Validate(_licenseAccessor.Current);
138+
}
139+
catch (Exception ex)
140+
{
141+
_loggerFactory
142+
.CreateLogger("LuckyPennySoftware.AutoMapper.License")
143+
.LogError(ex, "Error validating the Lucky Penny software license key");
144+
}
145+
}
146+
123147
void Seal()
124148
{
125149
foreach (var profile in Profiles)

src/AutoMapper/Licensing/LicenseAccessor.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,10 @@ private Claim[] ValidateKey(string licenseKey)
8484
ValidateLifetime = false
8585
};
8686

87-
var validateResult = Task.Run(() => handler.ValidateTokenAsync(licenseKey, parms)).GetAwaiter().GetResult();
87+
// Runs on the dedicated background thread started by MapperConfiguration (issue #4640),
88+
// so there is no SynchronizationContext to deadlock on; local JWT validation completes
89+
// synchronously, so this does not depend on the thread pool.
90+
var validateResult = handler.ValidateTokenAsync(licenseKey, parms).GetAwaiter().GetResult();
8891
if (!validateResult.IsValid)
8992
{
9093
_logger.LogCritical(validateResult.Exception, "Error validating the Lucky Penny software license key");
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
using Microsoft.Extensions.Logging;
2+
using Microsoft.Extensions.Logging.Abstractions;
3+
4+
namespace AutoMapper.UnitTests.Licensing;
5+
6+
public class LicenseValidationBackgroundTests
7+
{
8+
// Regression test for #4640: license validation must not run on the construction
9+
// thread. Building MapperConfiguration under a lazily-built DI singleton holds the
10+
// container's build lock, and validating there could deadlock the whole app under a
11+
// cold-start thread-pool starvation. Validation is logging-only, so it is offloaded
12+
// to a dedicated background thread. Rather than clamp the global thread pool (flaky,
13+
// process-wide), we assert the property that makes the deadlock impossible: the
14+
// constructor returns without validating, and validation logs on a *different* thread.
15+
[Fact]
16+
public void License_validation_runs_off_the_construction_thread()
17+
{
18+
var provider = new ThreadCapturingLoggerProvider();
19+
var factory = new LoggerFactory();
20+
factory.AddProvider(provider);
21+
22+
var constructingThreadId = Environment.CurrentManagedThreadId;
23+
24+
// A non-empty (junk) key forces the validation path to run; its result is logged,
25+
// and that logging must happen on a background thread, never on this one.
26+
_ = new MapperConfiguration(cfg => cfg.LicenseKey = "not-a-real-license-key", factory);
27+
28+
// The constructor returned without blocking on validation. The license log should
29+
// arrive shortly, on a thread other than the one that built the configuration.
30+
provider.LicenseLogged.Wait(TimeSpan.FromSeconds(5)).ShouldBeTrue();
31+
provider.LoggingThreadId.ShouldNotBe(constructingThreadId);
32+
}
33+
34+
private sealed class ThreadCapturingLoggerProvider : ILoggerProvider
35+
{
36+
public readonly ManualResetEventSlim LicenseLogged = new(false);
37+
public int LoggingThreadId;
38+
39+
public ILogger CreateLogger(string categoryName) =>
40+
categoryName == "LuckyPennySoftware.AutoMapper.License"
41+
? new CapturingLogger(this)
42+
: NullLogger.Instance;
43+
44+
public void Dispose() => LicenseLogged.Dispose();
45+
46+
private sealed class CapturingLogger(ThreadCapturingLoggerProvider owner) : ILogger
47+
{
48+
public IDisposable BeginScope<TState>(TState state) => null;
49+
50+
public bool IsEnabled(LogLevel logLevel) => true;
51+
52+
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception,
53+
Func<TState, Exception, string> formatter)
54+
{
55+
owner.LoggingThreadId = Environment.CurrentManagedThreadId;
56+
owner.LicenseLogged.Set();
57+
}
58+
}
59+
}
60+
}

0 commit comments

Comments
 (0)