Skip to content

Commit fe3aab6

Browse files
jbogardclaude
andcommitted
Run license validation off the construction path (fixes #4640)
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>
1 parent fed6e64 commit fe3aab6

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)