Skip to content
Merged
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
6 changes: 5 additions & 1 deletion src/MediatR/Licensing/LicenseAccessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,11 @@ private Claim[] ValidateKey(string licenseKey)
ValidateLifetime = false
};

var validateResult = Task.Run(() => handler.ValidateTokenAsync(licenseKey, parms)).GetAwaiter().GetResult();
// Runs on the dedicated background thread started during Mediator construction
// (see MediatRServiceCollectionExtensions.CheckLicense / AutoMapper #4640), so there is
// no SynchronizationContext to deadlock on; local JWT validation completes synchronously,
// so this does not depend on the thread pool.
Comment on lines +83 to +86
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
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
using MediatR.Licensing;
using MediatR.Pipeline;
Expand Down Expand Up @@ -60,16 +62,44 @@ public static IServiceCollection AddMediatR(this IServiceCollection services,

internal static void CheckLicense(this IServiceProvider serviceProvider)
{
if (LicenseChecked == false)
if (LicenseChecked)
{
var licenseAccessor = serviceProvider.GetRequiredService<LicenseAccessor>();
var licenseValidator = serviceProvider.GetRequiredService<LicenseValidator>();

var license = licenseAccessor.Current;
licenseValidator.Validate(license);
return;
}

Comment on lines +65 to 69
// Resolve the license services synchronously so a missing registration still surfaces
// on the caller. The resolutions are cheap; only the JWT validation below is expensive.
var licenseAccessor = serviceProvider.GetRequiredService<LicenseAccessor>();
var licenseValidator = serviceProvider.GetRequiredService<LicenseValidator>();

LicenseChecked = true;

// License validation is logging-only — it gates no Mediator behavior. Running it on the
// Mediator construction path is unsafe: under a lazily-built DI singleton the container
// holds its build lock, and a cold-start thread-pool starvation then deadlocks the whole
// app (same root cause as AutoMapper #4640, which used Task.Run(...).GetResult() under a
// singleton-build lock). Offload the JWT validation to a dedicated background thread.
_ = Task.Factory.StartNew(
() => ValidateLicense(serviceProvider, licenseAccessor, licenseValidator),
CancellationToken.None,
TaskCreationOptions.LongRunning, // dedicated thread, never the (possibly starved) pool
TaskScheduler.Default); // honor LongRunning regardless of the ambient scheduler
}

private static void ValidateLicense(IServiceProvider serviceProvider, LicenseAccessor licenseAccessor, LicenseValidator licenseValidator)
{
try
{
var license = licenseAccessor.Current;
licenseValidator.Validate(license);
}
catch (Exception ex)
{
// Never let a fire-and-forget failure surface as an unobserved task exception.
serviceProvider.GetService<ILoggerFactory>()?
.CreateLogger("LuckyPennySoftware.MediatR.License")
.LogError(ex, "Error validating the Lucky Penny software license key");
}
Comment on lines +96 to +102
}

internal static bool LicenseChecked { get; set; }
Expand Down
82 changes: 82 additions & 0 deletions test/MediatR.Tests/Licensing/LicenseValidationBackgroundTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using System;
using System.Threading;
using MediatR.Licensing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;

namespace MediatR.Tests.Licensing;

// Shares the non-parallel ServiceFactory collection because it mutates the static
// MediatRServiceCollectionExtensions.LicenseChecked flag.
[Collection(nameof(ServiceFactoryCollectionBehavior))]
public class LicenseValidationBackgroundTests
{
// Regression test for the license-validation deadlock (AutoMapper #4640, same root cause):
// validation must not run on the Mediator construction thread. Constructing a Mediator 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: constructing
// the Mediator returns without validating, and validation logs on a *different* thread.
[Fact]
public void License_validation_runs_off_the_construction_thread()
{
MediatRServiceCollectionExtensions.LicenseChecked = false;

var loggerProvider = new ThreadCapturingLoggerProvider();

var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<ILoggerProvider>(loggerProvider);
services.AddTransient<IMediator, Mediator>();
// A non-empty (junk) key forces the validation path to run; its result is logged.
services.AddSingleton(new MediatRServiceConfiguration { LicenseKey = "not-a-real-license-key" });
services.AddSingleton<LicenseAccessor>();
services.AddSingleton<LicenseValidator>();

var container = services.BuildServiceProvider();

var constructingThreadId = Environment.CurrentManagedThreadId;

_ = container.GetRequiredService<IMediator>(); // constructs Mediator -> CheckLicense

// Construction returned without blocking on validation; the license log should arrive
// shortly, on a thread other than the one that constructed the Mediator.
loggerProvider.LicenseLogged.Wait(TimeSpan.FromSeconds(5)).ShouldBeTrue();
loggerProvider.LoggingThreadId.ShouldNotBe(constructingThreadId);
}

private sealed class ThreadCapturingLoggerProvider : ILoggerProvider
{
public readonly ManualResetEventSlim LicenseLogged = new(false);
public int LoggingThreadId;

public ILogger CreateLogger(string categoryName) =>
categoryName == "LuckyPennySoftware.MediatR.License"
? new CapturingLogger(this)
: NullLogger.Instance;

public void Dispose() => LicenseLogged.Dispose();

private sealed class CapturingLogger : ILogger
{
private readonly ThreadCapturingLoggerProvider _owner;

public CapturingLogger(ThreadCapturingLoggerProvider owner) => _owner = owner;

public IDisposable? BeginScope<TState>(TState state) where TState : notnull => 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