Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
39 changes: 19 additions & 20 deletions src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,7 @@ public Host(IServiceProvider services,
/// <summary>
/// Order:
/// IHostLifetime.WaitForStartAsync
/// Services.GetService{IStartupValidator}().Validate()
/// Services.GetService{IAsyncStartupValidator}().ValidateAsync()
/// Startup validation: a custom sync IStartupValidator (if any) via Validate(), otherwise every IAsyncStartupValidator via ValidateAsync()
/// IHostedLifecycleService.StartingAsync
/// IHostedService.Start
/// IHostedLifecycleService.StartedAsync
Expand Down Expand Up @@ -94,26 +93,26 @@ public async Task StartAsync(CancellationToken cancellationToken = default)

try
{
_hostedServices ??= Services.GetRequiredService<IEnumerable<IHostedService>>();
_hostedLifecycleServices = GetHostLifecycles(_hostedServices);

// Two-stage startup validation:
// Stage 1 (sync): Run IStartupValidator.Validate() — iterates _validators dictionary
// (or user's custom implementation if registered).
// If sync validation fails, skip async to avoid expensive I/O on invalid config.
// Stage 2 (async): Run IAsyncStartupValidator.ValidateAsync() — iterates _asyncValidators
// dictionary (or user's custom implementation if registered).
//
// Each interface is resolved independently via DI. TryAddTransient semantics ensure
// user-registered implementations replace the built-in for each interface separately.
IStartupValidator? validator = Services.GetService<IStartupValidator>();
validator?.Validate();

IAsyncStartupValidator? asyncValidator = Services.GetService<IAsyncStartupValidator>();
if (asyncValidator is not null)
// Run startup validation before resolving hosted services so a hosted service that
// reads validated options in its constructor observes the validated instance.
IStartupValidator? startupValidator = Services.GetService<IStartupValidator>();
if (startupValidator is not null && startupValidator is not IAsyncStartupValidator)
{
await asyncValidator.ValidateAsync(cancellationToken).ConfigureAwait(false);
// A custom IStartupValidator takes precedence for back-compatibility and fully controls
// startup validation, overriding any registered IAsyncStartupValidator instances
// (including the one registered by ValidateOnStart).
startupValidator.Validate();
}
else
{
foreach (IAsyncStartupValidator asyncValidator in Services.GetServices<IAsyncStartupValidator>())
{
await asyncValidator.ValidateAsync(cancellationToken).ConfigureAwait(false);
}
}
Comment on lines +96 to +112

_hostedServices ??= Services.GetRequiredService<IEnumerable<IHostedService>>();
_hostedLifecycleServices = GetHostLifecycles(_hostedServices);
}
catch (Exception ex)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
Expand Down Expand Up @@ -404,6 +405,125 @@ public async Task ValidateOnStart_MultipleErrorsInOneValidationCallUsingCustomEr
}
}

[Fact]
public async Task ValidateOnStart_CustomSyncStartupValidator_OverridesAsyncValidationOnStart()
{
var custom = new TrackingStartupValidator();
var hostBuilder = CreateHostBuilder(services =>
{
services.AddSingleton<IStartupValidator>(custom);
services.AddOptions<ComplexOptions>()
.Configure(o => o.Boolean = false)
.Validate(o => o.Boolean, "should not run")
.ValidateOnStart();
});

using (var host = hostBuilder.Build())
{
// The custom synchronous validator takes precedence and fully controls startup validation,
// so the failing ValidateOnStart (async) validation never runs and the host starts.
await host.StartAsync();
}

Assert.True(custom.Validated);
}

[Fact]
public async Task ValidateOnStart_CustomSyncStartupValidatorThatFails_ThrowsOnStart()
{
var hostBuilder = CreateHostBuilder(services =>
services.AddSingleton<IStartupValidator>(new ThrowingStartupValidator()));

using (var host = hostBuilder.Build())
{
await Assert.ThrowsAsync<OptionsValidationException>(async () => await host.StartAsync());
}
}

[Fact]
public async Task ValidateOnStart_MultipleAsyncStartupValidators_AllRunOnStart()
{
var custom = new TrackingAsyncStartupValidator();
bool validateOnStartRan = false;
var hostBuilder = CreateHostBuilder(services =>
{
services.AddSingleton<IAsyncStartupValidator>(custom);
services.AddOptions<ComplexOptions>()
.Configure(o => o.Boolean = true)
.Validate(o =>
{
validateOnStartRan = true;
return o.Boolean;
})
.ValidateOnStart();
});

using (var host = hostBuilder.Build())
{
await host.StartAsync();
}

// Both the custom async validator and the built-in ValidateOnStart validator participate.
Assert.True(custom.Validated);
Assert.True(validateOnStartRan);
}

[Fact]
public async Task ValidateOnStart_StandaloneAsyncStartupValidator_RunsOnStart()
{
var custom = new TrackingAsyncStartupValidator();
var hostBuilder = CreateHostBuilder(services => services.AddSingleton<IAsyncStartupValidator>(custom));

using (var host = hostBuilder.Build())
{
await host.StartAsync();
}

Assert.True(custom.Validated);
}

[Fact]
public async Task ValidateOnStart_AsyncStartupValidatorThatFails_ThrowsOnStart()
{
var hostBuilder = CreateHostBuilder(services =>
services.AddSingleton<IAsyncStartupValidator>(new ThrowingAsyncStartupValidator()));

using (var host = hostBuilder.Build())
{
await Assert.ThrowsAsync<OptionsValidationException>(async () => await host.StartAsync());
}
}

private sealed class TrackingStartupValidator : IStartupValidator
{
public bool Validated { get; private set; }

public void Validate() => Validated = true;
}

private sealed class TrackingAsyncStartupValidator : IAsyncStartupValidator
{
public bool Validated { get; private set; }

public Task ValidateAsync(CancellationToken cancellationToken = default)
{
Validated = true;
return Task.CompletedTask;
}
}

private sealed class ThrowingStartupValidator : IStartupValidator
{
public void Validate() =>
throw new OptionsValidationException("name", typeof(object), new[] { "sync startup validation failed" });
}

private sealed class ThrowingAsyncStartupValidator : IAsyncStartupValidator
{
public Task ValidateAsync(CancellationToken cancellationToken = default) =>
throw new OptionsValidationException("name", typeof(object), new[] { "async startup validation failed" });
}

private static void ValidateFailure(Type type, OptionsValidationException e, int count = 1, params string[] errorsToMatch)
{
Assert.Equal(type, e.OptionsType);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,6 @@ public static class OptionsBuilderDataAnnotationsExtensions
{
var instance = new DataAnnotationValidateOptions<TOptions>(optionsBuilder.Name);
optionsBuilder.Services.AddSingleton<IValidateOptions<TOptions>>(instance);
#if NET11_0_OR_GREATER
optionsBuilder.Services.AddSingleton<IAsyncValidateOptions<TOptions>>(instance);
#endif
return optionsBuilder;
}
}
Expand Down
12 changes: 12 additions & 0 deletions src/libraries/Microsoft.Extensions.Options/gen/DiagDescriptors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,5 +119,17 @@ internal sealed class DiagDescriptors : DiagDescriptorsBase
messageFormat: SR.TypeCannotBeUsedWithTheValidationAttributeMessage,
category: Category,
defaultSeverity: DiagnosticSeverity.Warning);

public static DiagnosticDescriptor AsyncValidationRequiresNet11 { get; } = Make(
id: "SYSLIB1218",
title: SR.AsyncValidationRequiresNet11Title,
messageFormat: SR.AsyncValidationRequiresNet11Message,
category: Category);

public static DiagnosticDescriptor AlreadyImplementsValidateAsyncMethod { get; } = Make(
id: "SYSLIB1219",
title: SR.AlreadyImplementsValidateAsyncMethodTitle,
messageFormat: SR.AlreadyImplementsValidateAsyncMethodMessage,
category: Category);
}
}
Loading
Loading