diff --git a/src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs
index 4625a1e57685d2..c8a817e3b76cbb 100644
--- a/src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs
+++ b/src/libraries/Microsoft.Extensions.Hosting/src/Internal/Host.cs
@@ -65,8 +65,7 @@ public Host(IServiceProvider services,
///
/// 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
@@ -94,26 +93,26 @@ public async Task StartAsync(CancellationToken cancellationToken = default)
try
{
- _hostedServices ??= Services.GetRequiredService>();
- _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();
- validator?.Validate();
-
- IAsyncStartupValidator? asyncValidator = Services.GetService();
- 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();
+ 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())
+ {
+ await asyncValidator.ValidateAsync(cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ _hostedServices ??= Services.GetRequiredService>();
+ _hostedLifecycleServices = GetHostLifecycles(_hostedServices);
}
catch (Exception ex)
{
diff --git a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/OptionsBuilderExtensionsTests.cs b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/OptionsBuilderExtensionsTests.cs
index 6a67cbcaa9508e..9348c20cc6f6bd 100644
--- a/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/OptionsBuilderExtensionsTests.cs
+++ b/src/libraries/Microsoft.Extensions.Hosting/tests/UnitTests/OptionsBuilderExtensionsTests.cs
@@ -3,6 +3,7 @@
using System;
using System.Linq;
+using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
@@ -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(custom);
+ services.AddOptions()
+ .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(new ThrowingStartupValidator()));
+
+ using (var host = hostBuilder.Build())
+ {
+ await Assert.ThrowsAsync(async () => await host.StartAsync());
+ }
+ }
+
+ [Fact]
+ public async Task ValidateOnStart_MultipleAsyncStartupValidators_AllRunOnStart()
+ {
+ var custom = new TrackingAsyncStartupValidator();
+ bool validateOnStartRan = false;
+ var hostBuilder = CreateHostBuilder(services =>
+ {
+ services.AddSingleton(custom);
+ services.AddOptions()
+ .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(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(new ThrowingAsyncStartupValidator()));
+
+ using (var host = hostBuilder.Build())
+ {
+ await Assert.ThrowsAsync(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);
diff --git a/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/OptionsBuilderDataAnnotationsExtensions.cs b/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/OptionsBuilderDataAnnotationsExtensions.cs
index 748af2943b1f80..7dce4253c406ce 100644
--- a/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/OptionsBuilderDataAnnotationsExtensions.cs
+++ b/src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/OptionsBuilderDataAnnotationsExtensions.cs
@@ -35,9 +35,6 @@ public static class OptionsBuilderDataAnnotationsExtensions
{
var instance = new DataAnnotationValidateOptions(optionsBuilder.Name);
optionsBuilder.Services.AddSingleton>(instance);
-#if NET11_0_OR_GREATER
- optionsBuilder.Services.AddSingleton>(instance);
-#endif
return optionsBuilder;
}
}
diff --git a/src/libraries/Microsoft.Extensions.Options/gen/DiagDescriptors.cs b/src/libraries/Microsoft.Extensions.Options/gen/DiagDescriptors.cs
index 49562a0a128c2b..eea1d749072df5 100644
--- a/src/libraries/Microsoft.Extensions.Options/gen/DiagDescriptors.cs
+++ b/src/libraries/Microsoft.Extensions.Options/gen/DiagDescriptors.cs
@@ -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);
}
}
diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Emitter.cs b/src/libraries/Microsoft.Extensions.Options/gen/Emitter.cs
index bc020a61aa70ce..d7c1a003b41124 100644
--- a/src/libraries/Microsoft.Extensions.Options/gen/Emitter.cs
+++ b/src/libraries/Microsoft.Extensions.Options/gen/Emitter.cs
@@ -130,6 +130,14 @@ private void GenValidatorType(ValidatorType vt, ref Dictionary for this model and the required async validation symbols are available (.NET 11+).
+ if (modelToValidate.GenerateAsyncValidateMethod && _symbolHolder.AsyncValidateOptionsSymbol is not null && _symbolHolder.IAsyncValidatableObjectSymbol is not null)
+ {
+ OutLn();
+ GenAsyncModelValidationMethod(modelToValidate, vt.IsSynthetic, ref staticValidationAttributesDict, ref staticValidatorsDict);
+ }
}
OutCloseBrace();
@@ -734,6 +742,251 @@ private void GenModelValidationMethod(
OutCloseBrace();
}
+ private void GenAsyncModelValidationMethod(
+ ValidatedModel modelToValidate,
+ bool makeStatic,
+ ref Dictionary staticValidationAttributesDict,
+ ref Dictionary staticValidatorsDict)
+ {
+ // Determine whether the generated method body will contain any await. When it won't (for example the model
+ // only self-validates synchronously via IValidatableObject, or every nested validator resolves to a
+ // synchronous one), we emit a non-async method that returns a completed Task to avoid a CS1998
+ // "async method lacks await" warning. Only nested validators that actually emit ValidateAsync are awaited.
+ bool willAwait = modelToValidate.SelfValidatesAsync;
+ if (!willAwait)
+ {
+ foreach (var vm in modelToValidate.MembersToValidate)
+ {
+ if (vm.ValidationAttributes.Count > 0 ||
+ vm.TransValidatorEmitsAsync ||
+ vm.EnumerationValidatorEmitsAsync)
+ {
+ willAwait = true;
+ break;
+ }
+ }
+ }
+
+ OutLn($"/// ");
+ OutLn($"/// Validates a specific named options instance asynchronously (or all when is ).");
+ OutLn($"/// ");
+ OutLn($"/// The name of the options instance being validated.");
+ OutLn($"/// The options instance.");
+ OutLn($"/// The to monitor for cancellation requests.");
+ OutLn($"/// A task representing the asynchronous validation operation, containing the validation result.");
+ OutGeneratedCodeAttribute();
+
+ if (_symbolHolder.UnconditionalSuppressMessageAttributeSymbol is not null)
+ {
+ // We disable the warning on `new ValidationContext(object)` usage as we use it in a safe way that not require executing the reflection code.
+ // This is done by initializing the DisplayName in the context which is the part trigger reflection if it is not initialized. For
+ // projects targeting .NET 10 and above, we can avoid the suppression since we use the new trim-safe constructor.
+ OutLn("#if !NET");
+ OutLn($"[global::System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage(\"Trimming\", \"IL2026:RequiresUnreferencedCode\",");
+ OutLn($" Justification = \"The created ValidationContext object is used in a way that never call reflection\")]");
+ OutLn("#endif");
+ }
+
+ OutLn($"public {(makeStatic ? "static " : string.Empty)}{(willAwait ? "async " : string.Empty)}global::System.Threading.Tasks.Task ValidateAsync(string? name, {modelToValidate.Name} options, global::System.Threading.CancellationToken cancellationToken = default)");
+ OutOpenBrace();
+ OutLn($"global::Microsoft.Extensions.Options.ValidateOptionsResultBuilder? builder = null;");
+ OutLn("#if NET");
+ OutLn($"var context = new {StaticValidationContextType}(options, \"{modelToValidate.SimpleName}\", null, null);");
+ OutLn("#else");
+ OutLn($"var context = new {StaticValidationContextType}(options);");
+ OutLn("#endif");
+
+ int capacity = modelToValidate.MembersToValidate.Count == 0 ? 0 : modelToValidate.MembersToValidate.Max(static vm => vm.ValidationAttributes.Count);
+ if (capacity > 0)
+ {
+ OutLn($"var validationResults = new {StaticListType}<{StaticValidationResultType}>();");
+ OutLn($"var validationAttributes = new {StaticListType}<{StaticValidationAttributeType}>({capacity});");
+ }
+ OutLn();
+
+ bool cleanListsBeforeUse = false;
+ foreach (var vm in modelToValidate.MembersToValidate)
+ {
+ if (vm.ValidationAttributes.Count > 0)
+ {
+ GenAsyncMemberValidation(vm, ref staticValidationAttributesDict, cleanListsBeforeUse);
+ cleanListsBeforeUse = true;
+ OutLn();
+ }
+
+ if (vm.TransValidatorType is not null)
+ {
+ GenAsyncTransitiveValidation(vm, ref staticValidatorsDict);
+ OutLn();
+ }
+
+ if (vm.EnumerationValidatorType is not null)
+ {
+ GenAsyncEnumerationValidation(vm, ref staticValidatorsDict);
+ OutLn();
+ }
+ }
+
+ GenAsyncModelSelfValidationIfNecessary(modelToValidate);
+
+ if (willAwait)
+ {
+ OutLn($"return builder is null ? global::Microsoft.Extensions.Options.ValidateOptionsResult.Success : builder.Build();");
+ }
+ else
+ {
+ OutLn($"return global::System.Threading.Tasks.Task.FromResult(builder is null ? global::Microsoft.Extensions.Options.ValidateOptionsResult.Success : builder.Build());");
+ }
+
+ OutCloseBrace();
+ }
+
+ private void GenAsyncMemberValidation(ValidatedMember vm, ref Dictionary staticValidationAttributesDict, bool cleanListsBeforeUse)
+ {
+ OutLn($"context.MemberName = \"{vm.Name}\";");
+ OutLn($"context.DisplayName = string.IsNullOrEmpty(name) ? \"{vm.Name}\" : $\"{{name}}.{vm.Name}\";");
+
+ if (cleanListsBeforeUse)
+ {
+ OutLn($"validationResults.Clear();");
+ OutLn($"validationAttributes.Clear();");
+ }
+
+ foreach (var attr in vm.ValidationAttributes)
+ {
+ var staticValidationAttributeInstance = GetOrAddStaticValidationAttribute(ref staticValidationAttributesDict, attr);
+ OutLn($"validationAttributes.Add({_staticValidationAttributeHolderClassFQN}.{staticValidationAttributeInstance.FieldName});");
+ }
+
+ OutLn($"if (!await global::System.ComponentModel.DataAnnotations.Validator.TryValidateValueAsync(options.{vm.Name}{_TryGetValueNullableAnnotation}, context, validationResults, validationAttributes, cancellationToken).ConfigureAwait(false))");
+ OutOpenBrace();
+ OutLn($"(builder ??= new()).AddResults(validationResults);");
+ OutCloseBrace();
+ }
+
+ private void GenAsyncTransitiveValidation(ValidatedMember vm, ref Dictionary staticValidatorsDict)
+ {
+ string callSequence;
+ if (vm.TransValidateTypeIsSynthetic)
+ {
+ callSequence = vm.TransValidatorType!;
+ }
+ else
+ {
+ var staticValidatorInstance = GetOrAddStaticValidator(ref staticValidatorsDict, vm.TransValidatorType!);
+
+ callSequence = $"{_staticValidatorHolderClassFQN}.{staticValidatorInstance.FieldName}";
+ }
+
+ var valueAccess = (vm.IsNullable && vm.IsValueType) ? ".Value" : string.Empty;
+
+ var baseName = $"string.IsNullOrEmpty(name) ? \"{vm.Name}\" : $\"{{name}}.{vm.Name}\"";
+
+ // A nested validator emits an awaited ValidateAsync call only when it was synthesized for an async context or
+ // when it implements IAsyncValidateOptions for the member type.
+ // Otherwise we fall back to its synchronous Validate method to guarantee the generated code compiles.
+ string resultExpression = vm.TransValidatorEmitsAsync
+ ? $"await {callSequence}.ValidateAsync({baseName}, options.{vm.Name}{valueAccess}, cancellationToken).ConfigureAwait(false)"
+ : $"{callSequence}.Validate({baseName}, options.{vm.Name}{valueAccess})";
+
+ if (vm.IsNullable)
+ {
+ OutLn($"if (options.{vm.Name} is not null)");
+ OutOpenBrace();
+ OutLn($"(builder ??= new()).AddResult({resultExpression});");
+ OutCloseBrace();
+ }
+ else
+ {
+ OutLn($"(builder ??= new()).AddResult({resultExpression});");
+ }
+ }
+
+ private void GenAsyncEnumerationValidation(ValidatedMember vm, ref Dictionary staticValidatorsDict)
+ {
+ var valueAccess = (vm.IsValueType && vm.IsNullable) ? ".Value" : string.Empty;
+ var enumeratedValueAccess = (vm.EnumeratedIsNullable && vm.EnumeratedIsValueType) ? ".Value" : string.Empty;
+ string callSequence;
+ if (vm.EnumerationValidatorTypeIsSynthetic)
+ {
+ callSequence = vm.EnumerationValidatorType!;
+ }
+ else
+ {
+ var staticValidatorInstance = GetOrAddStaticValidator(ref staticValidatorsDict, vm.EnumerationValidatorType!);
+
+ callSequence = $"{_staticValidatorHolderClassFQN}.{staticValidatorInstance.FieldName}";
+ }
+
+ bool awaitItem = vm.EnumerationValidatorEmitsAsync;
+
+ if (vm.IsNullable)
+ {
+ OutLn($"if (options.{vm.Name} is not null)");
+ }
+
+ OutOpenBrace();
+
+ OutLn($"var count = 0;");
+ OutLn($"foreach (var o in options.{vm.Name}{valueAccess})");
+ OutOpenBrace();
+
+ if (vm.EnumeratedIsNullable)
+ {
+ OutLn($"if (o is not null)");
+ OutOpenBrace();
+ var propertyName = $"string.IsNullOrEmpty(name) ? $\"{vm.Name}[{{count}}]\" : $\"{{name}}.{vm.Name}[{{count}}]\"";
+ string itemResult = awaitItem
+ ? $"await {callSequence}.ValidateAsync({propertyName}, o{enumeratedValueAccess}, cancellationToken).ConfigureAwait(false)"
+ : $"{callSequence}.Validate({propertyName}, o{enumeratedValueAccess})";
+ OutLn($"(builder ??= new()).AddResult({itemResult});");
+ OutCloseBrace();
+
+ if (!vm.EnumeratedMayBeNull)
+ {
+ OutLn($"else");
+ OutOpenBrace();
+ var error = $"string.IsNullOrEmpty(name) ? $\"{vm.Name}[{{count}}] is null\" : $\"{{name}}.{vm.Name}[{{count}}] is null\"";
+ OutLn($"(builder ??= new()).AddError({error});");
+ OutCloseBrace();
+ }
+
+ OutLn($"count++;");
+ }
+ else
+ {
+ var propertyName = $"string.IsNullOrEmpty(name) ? $\"{vm.Name}[{{count++}}]\" : $\"{{name}}.{vm.Name}[{{count++}}]\"";
+ string itemResult = awaitItem
+ ? $"await {callSequence}.ValidateAsync({propertyName}, o{enumeratedValueAccess}, cancellationToken).ConfigureAwait(false)"
+ : $"{callSequence}.Validate({propertyName}, o{enumeratedValueAccess})";
+ OutLn($"(builder ??= new()).AddResult({itemResult});");
+ }
+
+ OutCloseBrace();
+ OutCloseBrace();
+ }
+
+ private void GenAsyncModelSelfValidationIfNecessary(ValidatedModel modelToValidate)
+ {
+ if (modelToValidate.SelfValidatesAsync)
+ {
+ OutLn($"context.MemberName = \"ValidateAsync\";");
+ OutLn($"context.DisplayName = string.IsNullOrEmpty(name) ? \"ValidateAsync\" : $\"{{name}}.ValidateAsync\";");
+ OutLn($"await foreach (var asyncValidationResult in global::System.Threading.Tasks.TaskAsyncEnumerableExtensions.ConfigureAwait(((global::System.ComponentModel.DataAnnotations.IAsyncValidatableObject)options).ValidateAsync(context, cancellationToken), false))");
+ OutOpenBrace();
+ OutLn($"(builder ??= new()).AddResult(asyncValidationResult);");
+ OutCloseBrace();
+ OutLn();
+ }
+ else if (modelToValidate.SelfValidates)
+ {
+ OutLn($"context.MemberName = \"Validate\";");
+ OutLn($"context.DisplayName = string.IsNullOrEmpty(name) ? \"Validate\" : $\"{{name}}.Validate\";");
+ OutLn($"(builder ??= new()).AddResults(((global::System.ComponentModel.DataAnnotations.IValidatableObject)options).Validate(context));");
+ OutLn();
+ }
+ }
+
private void GenMemberValidation(ValidatedMember vm, ref Dictionary staticValidationAttributesDict, bool cleanListsBeforeUse)
{
OutLn($"context.MemberName = \"{vm.Name}\";");
diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Model/ValidatedMember.cs b/src/libraries/Microsoft.Extensions.Options/gen/Model/ValidatedMember.cs
index cfed0135916740..3403567cbd775e 100644
--- a/src/libraries/Microsoft.Extensions.Options/gen/Model/ValidatedMember.cs
+++ b/src/libraries/Microsoft.Extensions.Options/gen/Model/ValidatedMember.cs
@@ -16,5 +16,7 @@ internal sealed record class ValidatedMember(
bool IsValueType,
bool EnumeratedIsNullable,
bool EnumeratedIsValueType,
- bool EnumeratedMayBeNull);
+ bool EnumeratedMayBeNull,
+ bool TransValidatorEmitsAsync = false,
+ bool EnumerationValidatorEmitsAsync = false);
}
diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Model/ValidatedModel.cs b/src/libraries/Microsoft.Extensions.Options/gen/Model/ValidatedModel.cs
index 0d40d930051b1b..3fb44fc4c6f89e 100644
--- a/src/libraries/Microsoft.Extensions.Options/gen/Model/ValidatedModel.cs
+++ b/src/libraries/Microsoft.Extensions.Options/gen/Model/ValidatedModel.cs
@@ -9,5 +9,7 @@ internal sealed record class ValidatedModel(
string Name,
string SimpleName,
bool SelfValidates,
+ bool SelfValidatesAsync,
+ bool GenerateAsyncValidateMethod,
List MembersToValidate);
}
diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Parser.cs b/src/libraries/Microsoft.Extensions.Options/gen/Parser.cs
index 647259a87aab06..b682466d6fe361 100644
--- a/src/libraries/Microsoft.Extensions.Options/gen/Parser.cs
+++ b/src/libraries/Microsoft.Extensions.Options/gen/Parser.cs
@@ -26,7 +26,12 @@ internal sealed class Parser
private readonly Action _reportDiagnostic;
private readonly SymbolHolder _symbolHolder;
private readonly OptionsSourceGenContext _optionsSourceGenContext;
- private readonly Dictionary _synthesizedValidators = new(SymbolEqualityComparer.Default);
+ // Synthesized validators are cached per model type. A model reached from a synchronous root and a model
+ // reached from an asynchronous root produce different generated types (the latter also emits ValidateAsync),
+ // so each model caches its synchronous and asynchronous validators separately. Keying by model type alone
+ // would let an asynchronous root reuse a synchronous-only validator (or vice versa) depending on discovery
+ // order, which would silently drop nested async validation.
+ private readonly Dictionary _synthesizedValidators = new(SymbolEqualityComparer.Default);
private readonly HashSet _visitedModelTypes = new(SymbolEqualityComparer.Default);
public Parser(
@@ -97,14 +102,43 @@ public IReadOnlyList GetValidatorTypes(IEnumerable<(TypeDeclarati
continue;
}
+ // Decide, per model, whether we additionally emit a ValidateAsync method. We do so when the
+ // validator type explicitly implements IAsyncValidateOptions for this specific model. A
+ // multi-model validator therefore only gets async validation for the models it opted into.
+ // The async requirement is propagated to any synthesized child validators reached from this model.
+ bool wantsAsync = ValidatorImplementsAsyncInterfaceFor(validatorType, modelType);
+
+ if (wantsAsync && AlreadyImplementsValidateAsyncMethod(validatorType, modelType))
+ {
+ // The user hand-wrote a matching ValidateAsync on an [OptionsValidator] type. This is an
+ // error (symmetric with SYSLIB1205 for a hand-written Validate): a generated synchronous
+ // Validate combined with a hand-written ValidateAsync would validate differently on the
+ // sync vs async access paths, silently skipping the generated attribute validation on the
+ // async path. Report it and skip only the async generation - the synchronous Validate is
+ // still generated so the sole failure surfaced is this diagnostic, not a cascading CS0535.
+ Diag(DiagDescriptors.AlreadyImplementsValidateAsyncMethod, syntax.GetLocation(), validatorType.Name);
+ wantsAsync = false;
+ }
+
+ // Asynchronous validation relies on the async DataAnnotations APIs (IAsyncValidatableObject /
+ // Validator.TryValidateValueAsync), which are only available when targeting .NET 11 or later.
+ // When they are unavailable, emit a diagnostic instead of generating code that wouldn't compile.
+ bool generateAsync = wantsAsync;
+ if (wantsAsync && _symbolHolder.IAsyncValidatableObjectSymbol is null)
+ {
+ Diag(DiagDescriptors.AsyncValidationRequiresNet11, syntax.GetLocation(), validatorType.Name);
+ generateAsync = false;
+ }
+
Location? modelTypeLocation = modelType.GetLocation();
Location lowerLocationInCompilation = modelTypeLocation is not null && modelTypeLocation.SourceTree is not null && _compilation.ContainsSyntaxTree(modelTypeLocation.SourceTree)
? modelTypeLocation
: syntax.GetLocation();
- var membersToValidate = GetMembersToValidate(modelType, true, lowerLocationInCompilation, validatorType);
+ var membersToValidate = GetMembersToValidate(modelType, true, lowerLocationInCompilation, validatorType, generateAsync);
bool selfValidate = ModelSelfValidates(modelType);
- if (membersToValidate.Count == 0 && !selfValidate)
+ bool selfValidateAsync = generateAsync && ModelSelfValidatesAsync(modelType);
+ if (membersToValidate.Count == 0 && !selfValidate && !selfValidateAsync)
{
// this type lacks any eligible members
Diag(DiagDescriptors.NoEligibleMembersFromValidator, syntax.GetLocation(), modelType.ToString(), validatorType.ToString());
@@ -115,6 +149,8 @@ public IReadOnlyList GetValidatorTypes(IEnumerable<(TypeDeclarati
GetFQN(modelType),
modelType.Name,
selfValidate,
+ selfValidateAsync,
+ generateAsync,
membersToValidate));
}
@@ -145,7 +181,19 @@ public IReadOnlyList GetValidatorTypes(IEnumerable<(TypeDeclarati
}
}
- results.AddRange(_synthesizedValidators.Values);
+ foreach (var entry in _synthesizedValidators.Values)
+ {
+ if (entry.Sync is not null)
+ {
+ results.Add(entry.Sync);
+ }
+
+ if (entry.Async is not null)
+ {
+ results.Add(entry.Async);
+ }
+ }
+
_synthesizedValidators.Clear();
if (results.Count > 0 && _compilation is CSharpCompilation { LanguageVersion : LanguageVersion version and < LanguageVersion.CSharp8 })
@@ -193,16 +241,44 @@ private static string GetMinimalFQNWithoutGenerics(ISymbol type)
=> type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat.WithGenericsOptions(SymbolDisplayGenericsOptions.None));
///
- /// Checks whether the given validator already implement the IValidationOptions>T< interface.
+ /// Checks whether the given validator already implements the generated Validate method
+ /// (implicitly or as an explicit interface implementation).
///
private static bool AlreadyImplementsValidateMethod(INamespaceOrTypeSymbol validatorType, ISymbol modelType)
=> validatorType
- .GetMembers("Validate")
- .Where(m => m.Kind == SymbolKind.Method)
- .Select(m => (IMethodSymbol)m)
- .Any(m => m.Parameters.Length == NumValidationMethodArgs
- && m.Parameters[0].Type.SpecialType == SpecialType.System_String
- && SymbolEqualityComparer.Default.Equals(m.Parameters[1].Type, modelType));
+ .GetMembers()
+ .OfType()
+ .Any(m => MatchesGeneratedValidateSignature(m, "Validate", NumValidationMethodArgs, modelType, requireCancellationToken: false));
+
+ // Detects a user-supplied ValidateAsync(string, TModel, CancellationToken) overload so the generator doesn't emit
+ // a duplicate member for a validator that already provides its own asynchronous implementation.
+ private static bool AlreadyImplementsValidateAsyncMethod(INamespaceOrTypeSymbol validatorType, ISymbol modelType)
+ => validatorType
+ .GetMembers()
+ .OfType()
+ .Any(m => MatchesGeneratedValidateSignature(m, "ValidateAsync", NumValidationMethodArgs + 1, modelType, requireCancellationToken: true));
+
+ // Matches a method with the signature the generator would emit, whether declared implicitly or as an explicit
+ // interface implementation. The return type is intentionally ignored (C# does not overload on return type), and
+ // no display-string comparison is used.
+ private static bool MatchesGeneratedValidateSignature(IMethodSymbol method, string name, int parameterCount, ISymbol modelType, bool requireCancellationToken)
+ {
+ bool nameMatches = method.Name == name
+ || method.ExplicitInterfaceImplementations.Any(impl => impl.Name == name);
+
+ if (!nameMatches
+ || method.Parameters.Length != parameterCount
+ || method.Parameters[0].Type.SpecialType != SpecialType.System_String
+ || !SymbolEqualityComparer.Default.Equals(method.Parameters[1].Type, modelType))
+ {
+ return false;
+ }
+
+ return !requireCancellationToken || IsCancellationToken(method.Parameters[parameterCount - 1].Type);
+ }
+
+ private static bool IsCancellationToken(ITypeSymbol type)
+ => type is { Name: "CancellationToken", ContainingNamespace: { Name: "Threading", ContainingNamespace: { Name: "System", ContainingNamespace.IsGlobalNamespace: true } } };
///
/// Checks whether the given type contain any unbound generic type arguments.
@@ -263,7 +339,7 @@ private static bool HasOpenGenerics(ITypeSymbol type, out string genericType)
return null;
}
- private List GetMembersToValidate(ITypeSymbol modelType, bool speculate, Location lowerLocationInCompilation, ITypeSymbol validatorType)
+ private List GetMembersToValidate(ITypeSymbol modelType, bool speculate, Location lowerLocationInCompilation, ITypeSymbol validatorType, bool isAsync)
{
// make a list of the most derived members in the model type
@@ -297,7 +373,7 @@ private List GetMembersToValidate(ITypeSymbol modelType, bool s
? memberLocation
: lowerLocationInCompilation;
- var memberInfo = GetMemberInfo(member, speculate, location, modelType, validatorType);
+ var memberInfo = GetMemberInfo(member, speculate, location, modelType, validatorType, isAsync);
if (memberInfo is not null)
{
if (member.DeclaredAccessibility != Accessibility.Public)
@@ -313,7 +389,7 @@ private List GetMembersToValidate(ITypeSymbol modelType, bool s
return membersToValidate;
}
- private ValidatedMember? GetMemberInfo(ISymbol member, bool speculate, Location location, ITypeSymbol modelType, ITypeSymbol validatorType)
+ private ValidatedMember? GetMemberInfo(ISymbol member, bool speculate, Location location, ITypeSymbol modelType, ITypeSymbol validatorType, bool isAsync)
{
ITypeSymbol memberType;
switch (member)
@@ -346,6 +422,8 @@ private List GetMembersToValidate(ITypeSymbol modelType, bool s
var enumeratedMayBeNull = false;
var transValidatorIsSynthetic = false;
var enumerationValidatorIsSynthetic = false;
+ var transValidatorEmitsAsync = false;
+ var enumerationValidatorEmitsAsync = false;
foreach (var attribute in member.GetAttributes().Where(a => a.AttributeClass is not null))
{
@@ -373,6 +451,7 @@ private List GetMembersToValidate(ITypeSymbol modelType, bool s
if (transValidatorType.Constructors.Where(c => !c.Parameters.Any()).Any())
{
transValidatorTypeName = transValidatorType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ transValidatorEmitsAsync = isAsync && ValidatorImplementsAsyncInterfaceFor(transValidatorType, memberType);
}
else
{
@@ -399,7 +478,7 @@ private List GetMembersToValidate(ITypeSymbol modelType, bool s
if (transValidatorTypeName == null)
{
transValidatorIsSynthetic = true;
- transValidatorTypeName = AddSynthesizedValidator(memberType, member, location, validatorType);
+ transValidatorTypeName = AddSynthesizedValidator(memberType, member, location, validatorType, isAsync, out transValidatorEmitsAsync);
}
// pop the stack
@@ -436,6 +515,7 @@ private List GetMembersToValidate(ITypeSymbol modelType, bool s
if (enumerationValidatorType.Constructors.Where(c => c.Parameters.Length == 0).Any())
{
enumerationValidatorTypeName = enumerationValidatorType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
+ enumerationValidatorEmitsAsync = isAsync && ValidatorImplementsAsyncInterfaceFor(enumerationValidatorType, enumeratedType);
}
else
{
@@ -462,7 +542,7 @@ private List GetMembersToValidate(ITypeSymbol modelType, bool s
if (enumerationValidatorTypeName == null)
{
enumerationValidatorIsSynthetic = true;
- enumerationValidatorTypeName = AddSynthesizedValidator(enumeratedType, member, location, validatorType);
+ enumerationValidatorTypeName = AddSynthesizedValidator(enumeratedType, member, location, validatorType, isAsync, out enumerationValidatorEmitsAsync);
}
// pop the stack
@@ -551,7 +631,7 @@ private List GetMembersToValidate(ITypeSymbol modelType, bool s
{
if (!HasOpenGenerics(memberType, out var genericType))
{
- var membersToValidate = GetMembersToValidate(memberType, false, location, validatorType);
+ var membersToValidate = GetMembersToValidate(memberType, false, location, validatorType, isAsync);
if (membersToValidate.Count > 0)
{
Diag(DiagDescriptors.PotentiallyMissingTransitiveValidation, location, memberType.Name, member.Name);
@@ -567,7 +647,7 @@ private List GetMembersToValidate(ITypeSymbol modelType, bool s
{
if (!HasOpenGenerics(enumeratedType, out var genericType))
{
- var membersToValidate = GetMembersToValidate(enumeratedType, false, location, validatorType);
+ var membersToValidate = GetMembersToValidate(enumeratedType, false, location, validatorType, isAsync);
if (membersToValidate.Count > 0)
{
Diag(DiagDescriptors.PotentiallyMissingEnumerableValidation, location, enumeratedType.Name, member.Name);
@@ -589,7 +669,9 @@ private List GetMembersToValidate(ITypeSymbol modelType, bool s
memberType.IsValueType,
enumeratedIsNullable,
enumeratedIsValueType,
- enumeratedMayBeNull);
+ enumeratedMayBeNull,
+ transValidatorEmitsAsync,
+ enumerationValidatorEmitsAsync);
}
return null;
@@ -681,8 +763,9 @@ private void TrackRangeAttributeForSubstitution(AttributeData attribute, ITypeSy
}
}
- private string? AddSynthesizedValidator(ITypeSymbol modelType, ISymbol member, Location location, ITypeSymbol validatorType)
+ private string? AddSynthesizedValidator(ITypeSymbol modelType, ISymbol member, Location location, ITypeSymbol validatorType, bool isAsync, out bool emitsAsync)
{
+ emitsAsync = false;
var mt = modelType.WithNullableAnnotation(NullableAnnotation.None);
if (mt.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T)
{
@@ -690,14 +773,19 @@ private void TrackRangeAttributeForSubstitution(AttributeData attribute, ITypeSy
mt = ((INamedTypeSymbol)mt).TypeArguments[0];
}
- if (_synthesizedValidators.TryGetValue(mt, out var validator))
+ _synthesizedValidators.TryGetValue(mt, out var cached);
+ ValidatorType? reusable = isAsync ? cached.Async : cached.Sync;
+ if (reusable is not null)
{
- return "global::" + validator.Namespace + "." + validator.Name;
+ // A validator matching the requested capability was already synthesized for this model, so reuse it.
+ emitsAsync = isAsync;
+ return "global::" + (reusable.Namespace.Length > 0 ? reusable.Namespace + "." + reusable.Name : reusable.Name);
}
bool selfValidate = ModelSelfValidates(mt);
- var membersToValidate = GetMembersToValidate(mt, true, location, validatorType);
- if (membersToValidate.Count == 0 && !selfValidate)
+ bool selfValidateAsync = isAsync && ModelSelfValidatesAsync(mt);
+ var membersToValidate = GetMembersToValidate(mt, true, location, validatorType, isAsync);
+ if (membersToValidate.Count == 0 && !selfValidate && !selfValidateAsync)
{
// this type lacks any eligible members
Diag(DiagDescriptors.NoEligibleMember, location, mt.ToString(), member.ToString());
@@ -708,9 +796,13 @@ private void TrackRangeAttributeForSubstitution(AttributeData attribute, ITypeSy
GetFQN(mt),
mt.Name,
selfValidate,
+ selfValidateAsync,
+ isAsync,
membersToValidate);
- var validatorTypeName = "__" + mt.Name + "Validator__";
+ // Asynchronous validators get a distinct name so a model reached from both a synchronous and an
+ // asynchronous root can emit two non-conflicting synthesized types. Synchronous naming is unchanged.
+ var validatorTypeName = "__" + mt.Name + (isAsync ? "AsyncValidator__" : "Validator__");
var result = new ValidatorType(
mt.ContainingNamespace.IsGlobalNamespace ? string.Empty : mt.ContainingNamespace.ToString()!,
@@ -721,7 +813,8 @@ private void TrackRangeAttributeForSubstitution(AttributeData attribute, ITypeSy
true,
new[] { model });
- _synthesizedValidators[mt] = result;
+ _synthesizedValidators[mt] = isAsync ? (cached.Sync, result) : (result, cached.Async);
+ emitsAsync = isAsync;
return "global::" + (result.Namespace.Length > 0 ? result.Namespace + "." + result.Name : result.Name);
}
@@ -744,6 +837,43 @@ private bool ModelSelfValidates(ITypeSymbol modelType)
return false;
}
+ private bool ModelSelfValidatesAsync(ITypeSymbol modelType)
+ {
+ if (_symbolHolder.IAsyncValidatableObjectSymbol is null)
+ {
+ return false;
+ }
+
+ foreach (var implementingInterface in modelType.AllInterfaces)
+ {
+ if (SymbolEqualityComparer.Default.Equals(implementingInterface.OriginalDefinition, _symbolHolder.IAsyncValidatableObjectSymbol))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private bool ValidatorImplementsAsyncInterfaceFor(ITypeSymbol validatorType, ITypeSymbol modelType)
+ {
+ if (_symbolHolder.AsyncValidateOptionsSymbol is null)
+ {
+ return false;
+ }
+
+ foreach (var implementingInterface in validatorType.AllInterfaces)
+ {
+ if (SymbolEqualityComparer.Default.Equals(implementingInterface.OriginalDefinition, _symbolHolder.AsyncValidateOptionsSymbol)
+ && SymbolEqualityComparer.Default.Equals(implementingInterface.TypeArguments.First(), modelType))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
private List GetModelTypes(ITypeSymbol validatorType)
{
var result = new List();
diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Resources/Strings.resx b/src/libraries/Microsoft.Extensions.Options/gen/Resources/Strings.resx
index be9fe963b83724..6cd461ceaa762d 100644
--- a/src/libraries/Microsoft.Extensions.Options/gen/Resources/Strings.resx
+++ b/src/libraries/Microsoft.Extensions.Options/gen/Resources/Strings.resx
@@ -117,12 +117,24 @@
System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+ Type {0} already implements the ValidateAsync method.
+
+
+ A type already includes an implementation of the 'ValidateAsync' method.
+
Type {0} already implements the Validate method.A type already includes an implementation of the 'Validate' method.
+
+ Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later.
+
+
+ Asynchronous options validation requires targeting .NET 11 or later.
+
[OptionsValidator] cannot be applied to static class {0}.
diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.cs.xlf b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.cs.xlf
index c8490d951761cb..f386aeab895490 100644
--- a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.cs.xlf
+++ b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.cs.xlf
@@ -2,6 +2,16 @@
+
+ Type {0} already implements the ValidateAsync method.
+ Type {0} already implements the ValidateAsync method.
+
+
+
+ A type already includes an implementation of the 'ValidateAsync' method.
+ A type already includes an implementation of the 'ValidateAsync' method.
+
+ Type {0} already implements the Validate method.Typ {0} již implementuje metodu Validate.
@@ -12,6 +22,16 @@
Typ už obsahuje implementaci metody Validate.
+
+ Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later.
+ Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later.
+
+
+
+ Asynchronous options validation requires targeting .NET 11 or later.
+ Asynchronous options validation requires targeting .NET 11 or later.
+
+ [OptionsValidator] cannot be applied to static class {0}.[OptionsValidator] nelze použít pro statickou třídu {0}.
diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.de.xlf b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.de.xlf
index eb7a7f423a3e70..d7377aa1716cd2 100644
--- a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.de.xlf
+++ b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.de.xlf
@@ -2,6 +2,16 @@
+
+ Type {0} already implements the ValidateAsync method.
+ Type {0} already implements the ValidateAsync method.
+
+
+
+ A type already includes an implementation of the 'ValidateAsync' method.
+ A type already includes an implementation of the 'ValidateAsync' method.
+
+ Type {0} already implements the Validate method.Der Typ "{0}" implementiert bereits die Validate-Methode.
@@ -12,6 +22,16 @@
Ein Typ enthält bereits eine Implementierung der Validate-Methode.
+
+ Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later.
+ Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later.
+
+
+
+ Asynchronous options validation requires targeting .NET 11 or later.
+ Asynchronous options validation requires targeting .NET 11 or later.
+
+ [OptionsValidator] cannot be applied to static class {0}.[OptionsValidator] kann nicht auf die statische Klasse "{0}" angewendet werden.
diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.es.xlf b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.es.xlf
index 19c69bb8c864f9..bc6c48c40722ac 100644
--- a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.es.xlf
+++ b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.es.xlf
@@ -2,6 +2,16 @@
+
+ Type {0} already implements the ValidateAsync method.
+ Type {0} already implements the ValidateAsync method.
+
+
+
+ A type already includes an implementation of the 'ValidateAsync' method.
+ A type already includes an implementation of the 'ValidateAsync' method.
+
+ Type {0} already implements the Validate method.El tipo {0} ya implementa el método Validate.
@@ -12,6 +22,16 @@
Un tipo ya incluye una implementación del método “Validate”.
+
+ Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later.
+ Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later.
+
+
+
+ Asynchronous options validation requires targeting .NET 11 or later.
+ Asynchronous options validation requires targeting .NET 11 or later.
+
+ [OptionsValidator] cannot be applied to static class {0}.No se puede aplicar [OptionsValidator] a la clase estática {0}.
diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.fr.xlf b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.fr.xlf
index 651ab8b303719a..84e88a00890804 100644
--- a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.fr.xlf
+++ b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.fr.xlf
@@ -2,6 +2,16 @@
+
+ Type {0} already implements the ValidateAsync method.
+ Type {0} already implements the ValidateAsync method.
+
+
+
+ A type already includes an implementation of the 'ValidateAsync' method.
+ A type already includes an implementation of the 'ValidateAsync' method.
+
+ Type {0} already implements the Validate method.Le type {0} implémente déjà la méthode Validate.
@@ -12,6 +22,16 @@
Un type inclut déjà une implémentation de la méthode 'Validate'.
+
+ Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later.
+ Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later.
+
+
+
+ Asynchronous options validation requires targeting .NET 11 or later.
+ Asynchronous options validation requires targeting .NET 11 or later.
+
+ [OptionsValidator] cannot be applied to static class {0}.[OptionsValidator] ne peut pas être appliqué à la classe statique {0}.
diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.it.xlf b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.it.xlf
index 575f60469e48dc..a015ecd7564421 100644
--- a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.it.xlf
+++ b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.it.xlf
@@ -2,6 +2,16 @@
+
+ Type {0} already implements the ValidateAsync method.
+ Type {0} already implements the ValidateAsync method.
+
+
+
+ A type already includes an implementation of the 'ValidateAsync' method.
+ A type already includes an implementation of the 'ValidateAsync' method.
+
+ Type {0} already implements the Validate method.Il tipo {0} implementa già il metodo Validate.
@@ -12,6 +22,16 @@
Un tipo include già un'implementazione del metodo 'Validate'.
+
+ Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later.
+ Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later.
+
+
+
+ Asynchronous options validation requires targeting .NET 11 or later.
+ Asynchronous options validation requires targeting .NET 11 or later.
+
+ [OptionsValidator] cannot be applied to static class {0}.impossibile applicare [OptionsValidator] alla classe statica {0}.
diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.ja.xlf b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.ja.xlf
index 8251ac27c6da15..6c81ecb81c2354 100644
--- a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.ja.xlf
+++ b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.ja.xlf
@@ -2,6 +2,16 @@
+
+ Type {0} already implements the ValidateAsync method.
+ Type {0} already implements the ValidateAsync method.
+
+
+
+ A type already includes an implementation of the 'ValidateAsync' method.
+ A type already includes an implementation of the 'ValidateAsync' method.
+
+ Type {0} already implements the Validate method.型 {0} に Validate メソッドが既に実装されています。
@@ -12,6 +22,16 @@
型には 'Validate' メソッドの実装が既に含まれています。
+
+ Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later.
+ Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later.
+
+
+
+ Asynchronous options validation requires targeting .NET 11 or later.
+ Asynchronous options validation requires targeting .NET 11 or later.
+
+ [OptionsValidator] cannot be applied to static class {0}.静的クラス {0} には [OptionsValidator] を適用できません。
diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.ko.xlf b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.ko.xlf
index 4c8ce2865d1070..913271ec0b19c7 100644
--- a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.ko.xlf
+++ b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.ko.xlf
@@ -2,6 +2,16 @@
+
+ Type {0} already implements the ValidateAsync method.
+ Type {0} already implements the ValidateAsync method.
+
+
+
+ A type already includes an implementation of the 'ValidateAsync' method.
+ A type already includes an implementation of the 'ValidateAsync' method.
+
+ Type {0} already implements the Validate method.형식 {0}은(는) Validate 메서드를 이미 구현하고 있습니다.
@@ -12,6 +22,16 @@
형식에 'Validate' 메서드 구현이 이미 포함되어 있습니다.
+
+ Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later.
+ Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later.
+
+
+
+ Asynchronous options validation requires targeting .NET 11 or later.
+ Asynchronous options validation requires targeting .NET 11 or later.
+
+ [OptionsValidator] cannot be applied to static class {0}.[OptionsValidator]은(는) 정적 클래스 {0}에 적용할 수 없습니다.
diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.pl.xlf b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.pl.xlf
index e07e568d131874..6f984561f85200 100644
--- a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.pl.xlf
+++ b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.pl.xlf
@@ -2,6 +2,16 @@
+
+ Type {0} already implements the ValidateAsync method.
+ Type {0} already implements the ValidateAsync method.
+
+
+
+ A type already includes an implementation of the 'ValidateAsync' method.
+ A type already includes an implementation of the 'ValidateAsync' method.
+
+ Type {0} already implements the Validate method.Typ {0} już implementuje metodę Validate.
@@ -12,6 +22,16 @@
Typ zawiera już implementację metody „Validate”.
+
+ Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later.
+ Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later.
+
+
+
+ Asynchronous options validation requires targeting .NET 11 or later.
+ Asynchronous options validation requires targeting .NET 11 or later.
+
+ [OptionsValidator] cannot be applied to static class {0}.Nie można zastosować elementu [OptionsValidator] do klasy statycznej {0}.
diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.pt-BR.xlf b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.pt-BR.xlf
index d422309a40995b..f396cd5b422fb8 100644
--- a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.pt-BR.xlf
+++ b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.pt-BR.xlf
@@ -2,6 +2,16 @@
+
+ Type {0} already implements the ValidateAsync method.
+ Type {0} already implements the ValidateAsync method.
+
+
+
+ A type already includes an implementation of the 'ValidateAsync' method.
+ A type already includes an implementation of the 'ValidateAsync' method.
+
+ Type {0} already implements the Validate method.O tipo {0} já implementa o método Validar.
@@ -12,6 +22,16 @@
Um tipo já inclui uma implementação do método "Validar".
+
+ Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later.
+ Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later.
+
+
+
+ Asynchronous options validation requires targeting .NET 11 or later.
+ Asynchronous options validation requires targeting .NET 11 or later.
+
+ [OptionsValidator] cannot be applied to static class {0}.[OptionsValidator] não pode ser aplicado à classe estática {0}.
diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.ru.xlf b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.ru.xlf
index bd3a0be55eb270..6a3ffa0969d4ff 100644
--- a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.ru.xlf
+++ b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.ru.xlf
@@ -2,6 +2,16 @@
+
+ Type {0} already implements the ValidateAsync method.
+ Type {0} already implements the ValidateAsync method.
+
+
+
+ A type already includes an implementation of the 'ValidateAsync' method.
+ A type already includes an implementation of the 'ValidateAsync' method.
+
+ Type {0} already implements the Validate method.Тип {0} уже реализует метод Validate.
@@ -12,6 +22,16 @@
Тип уже содержит реализацию метода Validate.
+
+ Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later.
+ Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later.
+
+
+
+ Asynchronous options validation requires targeting .NET 11 or later.
+ Asynchronous options validation requires targeting .NET 11 or later.
+
+ [OptionsValidator] cannot be applied to static class {0}.[OptionsValidator] невозможно применить к статическому классу {0}.
diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.tr.xlf b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.tr.xlf
index e478a77cd3c238..d7441e5d7440fd 100644
--- a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.tr.xlf
+++ b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.tr.xlf
@@ -2,6 +2,16 @@
+
+ Type {0} already implements the ValidateAsync method.
+ Type {0} already implements the ValidateAsync method.
+
+
+
+ A type already includes an implementation of the 'ValidateAsync' method.
+ A type already includes an implementation of the 'ValidateAsync' method.
+
+ Type {0} already implements the Validate method.{0} türü Doğrulama yöntemini zaten uyguluyor.
@@ -12,6 +22,16 @@
Bir tür zaten 'Validate' yönteminin bir uygulamasını içeriyor.
+
+ Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later.
+ Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later.
+
+
+
+ Asynchronous options validation requires targeting .NET 11 or later.
+ Asynchronous options validation requires targeting .NET 11 or later.
+
+ [OptionsValidator] cannot be applied to static class {0}.[OptionsValidator], {0} statik sınıfına uygulanamıyor.
diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.zh-Hans.xlf b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.zh-Hans.xlf
index 76a26db32a673c..8870d990790d07 100644
--- a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.zh-Hans.xlf
+++ b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.zh-Hans.xlf
@@ -2,6 +2,16 @@
+
+ Type {0} already implements the ValidateAsync method.
+ Type {0} already implements the ValidateAsync method.
+
+
+
+ A type already includes an implementation of the 'ValidateAsync' method.
+ A type already includes an implementation of the 'ValidateAsync' method.
+
+ Type {0} already implements the Validate method.类型 {0} 已实现 Validate 方法。
@@ -12,6 +22,16 @@
类型已包含“Validate”方法的实现。
+
+ Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later.
+ Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later.
+
+
+
+ Asynchronous options validation requires targeting .NET 11 or later.
+ Asynchronous options validation requires targeting .NET 11 or later.
+
+ [OptionsValidator] cannot be applied to static class {0}.[OptionsValidator] 无法应用于静态类 {0}。
diff --git a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.zh-Hant.xlf b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.zh-Hant.xlf
index 9997092e3be6f3..cfc64895d57d10 100644
--- a/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.zh-Hant.xlf
+++ b/src/libraries/Microsoft.Extensions.Options/gen/Resources/xlf/Strings.zh-Hant.xlf
@@ -2,6 +2,16 @@
+
+ Type {0} already implements the ValidateAsync method.
+ Type {0} already implements the ValidateAsync method.
+
+
+
+ A type already includes an implementation of the 'ValidateAsync' method.
+ A type already includes an implementation of the 'ValidateAsync' method.
+
+ Type {0} already implements the Validate method.類型 {0} 已實作 [驗證] 方法。
@@ -12,6 +22,16 @@
類型已經包含 [驗證] 方法的實作。
+
+ Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later.
+ Type {0} requires asynchronous options validation, which is only supported when targeting .NET 11 or later.
+
+
+
+ Asynchronous options validation requires targeting .NET 11 or later.
+ Asynchronous options validation requires targeting .NET 11 or later.
+
+ [OptionsValidator] cannot be applied to static class {0}.[OptionsValidator] 無法套用至靜態類別 {0}。
diff --git a/src/libraries/Microsoft.Extensions.Options/gen/SymbolHolder.cs b/src/libraries/Microsoft.Extensions.Options/gen/SymbolHolder.cs
index 8e7073f8ec2182..57da98b3a56b2a 100644
--- a/src/libraries/Microsoft.Extensions.Options/gen/SymbolHolder.cs
+++ b/src/libraries/Microsoft.Extensions.Options/gen/SymbolHolder.cs
@@ -25,5 +25,7 @@ internal sealed record class SymbolHolder(
INamedTypeSymbol TypeSymbol,
INamedTypeSymbol TimeSpanSymbol,
INamedTypeSymbol ValidateObjectMembersAttributeSymbol,
- INamedTypeSymbol ValidateEnumeratedItemsAttributeSymbol);
+ INamedTypeSymbol ValidateEnumeratedItemsAttributeSymbol,
+ INamedTypeSymbol? IAsyncValidatableObjectSymbol = null,
+ INamedTypeSymbol? AsyncValidateOptionsSymbol = null);
}
diff --git a/src/libraries/Microsoft.Extensions.Options/gen/SymbolLoader.cs b/src/libraries/Microsoft.Extensions.Options/gen/SymbolLoader.cs
index 5be4932d8d6c41..6e6f8e1c47cd9e 100644
--- a/src/libraries/Microsoft.Extensions.Options/gen/SymbolLoader.cs
+++ b/src/libraries/Microsoft.Extensions.Options/gen/SymbolLoader.cs
@@ -24,6 +24,8 @@ internal static class SymbolLoader
internal const string ValidateEnumeratedItemsAttribute = "Microsoft.Extensions.Options.ValidateEnumeratedItemsAttribute";
internal const string GenericIEnumerableType = "System.Collections.Generic.IEnumerable`1";
internal const string UnconditionalSuppressMessageAttributeType = "System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessageAttribute";
+ internal const string IAsyncValidatableObjectType = "System.ComponentModel.DataAnnotations.IAsyncValidatableObject";
+ internal const string IAsyncValidateOptionsType = "Microsoft.Extensions.Options.IAsyncValidateOptions`1";
public static bool TryLoad(Compilation compilation, out SymbolHolder? symbolHolder)
{
@@ -81,6 +83,11 @@ public static bool TryLoad(Compilation compilation, out SymbolHolder? symbolHold
}
#pragma warning restore S1067 // Expressions should not be too complex
+ // Optional async validation symbols (available in .NET 11+).
+ // When absent (e.g. downlevel targets), the generator degrades to emitting only the synchronous Validate method.
+ var iAsyncValidatableObjectSymbol = GetSymbol(IAsyncValidatableObjectType);
+ var asyncValidateOptionsSymbol = GetSymbol(IAsyncValidateOptionsType);
+
symbolHolder = new(
optionsValidatorSymbol,
validationAttributeSymbol,
@@ -98,7 +105,9 @@ public static bool TryLoad(Compilation compilation, out SymbolHolder? symbolHold
typeSymbol,
timeSpanSymbol,
validateObjectMembersAttribute,
- validateEnumeratedItemsAttribute);
+ validateEnumeratedItemsAttribute,
+ iAsyncValidatableObjectSymbol,
+ asyncValidateOptionsSymbol);
return true;
}
diff --git a/src/libraries/Microsoft.Extensions.Options/ref/Microsoft.Extensions.Options.cs b/src/libraries/Microsoft.Extensions.Options/ref/Microsoft.Extensions.Options.cs
index 1618851dc52682..8e7189b260581e 100644
--- a/src/libraries/Microsoft.Extensions.Options/ref/Microsoft.Extensions.Options.cs
+++ b/src/libraries/Microsoft.Extensions.Options/ref/Microsoft.Extensions.Options.cs
@@ -9,6 +9,7 @@ namespace Microsoft.Extensions.DependencyInjection
public static partial class OptionsBuilderExtensions
{
public static Microsoft.Extensions.Options.OptionsBuilder ValidateOnStart<[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] TOptions>(this Microsoft.Extensions.Options.OptionsBuilder optionsBuilder) where TOptions : class { throw null; }
+ public static Microsoft.Extensions.Options.OptionsBuilder ValidateOnChange(this Microsoft.Extensions.Options.OptionsBuilder optionsBuilder, Microsoft.Extensions.Options.OptionsReloadValidationBehavior behavior = Microsoft.Extensions.Options.OptionsReloadValidationBehavior.KeepLastGood, System.Action? onError = null) where TOptions : class { throw null; }
}
public static partial class OptionsServiceCollectionExtensions
{
@@ -153,7 +154,7 @@ public partial interface IValidateOptions where TOptions : class
{
Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, TOptions options);
}
- public partial interface IAsyncValidateOptions where TOptions : class
+ public partial interface IAsyncValidateOptions : Microsoft.Extensions.Options.IValidateOptions where TOptions : class
{
System.Threading.Tasks.Task ValidateAsync(string? name, TOptions options, System.Threading.CancellationToken cancellationToken = default);
}
@@ -217,6 +218,7 @@ public partial class OptionsFactory<[System.Diagnostics.CodeAnalysis.Dynamically
{
public OptionsFactory(System.Collections.Generic.IEnumerable> setups, System.Collections.Generic.IEnumerable> postConfigures) { }
public OptionsFactory(System.Collections.Generic.IEnumerable> setups, System.Collections.Generic.IEnumerable> postConfigures, System.Collections.Generic.IEnumerable> validations) { }
+ public OptionsFactory(System.Collections.Generic.IEnumerable> setups, System.Collections.Generic.IEnumerable> postConfigures, System.Collections.Generic.IEnumerable> validations, System.Collections.Generic.IEnumerable> asyncValidations) { }
public TOptions Create(string name) { throw null; }
protected virtual TOptions CreateInstance(string name) { throw null; }
}
@@ -233,11 +235,17 @@ public static partial class OptionsMonitorExtensions
public partial class OptionsMonitor<[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicParameterlessConstructor)] TOptions> : Microsoft.Extensions.Options.IOptionsMonitor, System.IDisposable where TOptions : class
{
public OptionsMonitor(Microsoft.Extensions.Options.IOptionsFactory factory, System.Collections.Generic.IEnumerable> sources, Microsoft.Extensions.Options.IOptionsMonitorCache cache) { }
+ public OptionsMonitor(Microsoft.Extensions.Options.IOptionsFactory factory, System.Collections.Generic.IEnumerable> sources, Microsoft.Extensions.Options.IOptionsMonitorCache cache, System.Collections.Generic.IEnumerable> reloadValidationConfigs) { }
public TOptions CurrentValue { get { throw null; } }
public void Dispose() { }
public virtual TOptions Get(string? name) { throw null; }
public System.IDisposable OnChange(System.Action listener) { throw null; }
}
+ public enum OptionsReloadValidationBehavior
+ {
+ KeepLastGood = 0,
+ FailReads = 1,
+ }
public partial class OptionsValidationException : System.Exception
{
public OptionsValidationException(string optionsName, System.Type optionsType, System.Collections.Generic.IEnumerable? failureMessages) { }
@@ -317,6 +325,13 @@ public PostConfigureOptions(string? name, TDep1 dependency1, TDep2 dependency2,
public virtual void PostConfigure(string? name, TOptions options) { }
public void PostConfigure(TOptions options) { }
}
+ public sealed partial class ReloadValidationConfiguration where TOptions : class
+ {
+ public ReloadValidationConfiguration(string name, Microsoft.Extensions.Options.OptionsReloadValidationBehavior behavior, System.Action? onError) { }
+ public Microsoft.Extensions.Options.OptionsReloadValidationBehavior Behavior { get { throw null; } }
+ public string Name { get { throw null; } }
+ public System.Action? OnError { get { throw null; } }
+ }
[System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Field)]
public sealed class ValidateEnumeratedItemsAttribute : System.Attribute
{
@@ -425,6 +440,7 @@ public AsyncValidateOptions(string? name, System.Func> Validation { get { throw null; } }
+ public Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, TOptions options) { throw null; }
public System.Threading.Tasks.Task ValidateAsync(string? name, TOptions options, System.Threading.CancellationToken cancellationToken = default) { throw null; }
}
public partial class AsyncValidateOptions : Microsoft.Extensions.Options.IAsyncValidateOptions where TOptions : class
@@ -434,6 +450,7 @@ public AsyncValidateOptions(string? name, TDep dependency, System.Func> Validation { get { throw null; } }
+ public Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, TOptions options) { throw null; }
public System.Threading.Tasks.Task ValidateAsync(string? name, TOptions options, System.Threading.CancellationToken cancellationToken = default) { throw null; }
}
public partial class AsyncValidateOptions : Microsoft.Extensions.Options.IAsyncValidateOptions where TOptions : class
@@ -444,6 +461,7 @@ public AsyncValidateOptions(string? name, TDep1 dependency1, TDep2 dependency2,
public string FailureMessage { get { throw null; } }
public string? Name { get { throw null; } }
public System.Func> Validation { get { throw null; } }
+ public Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, TOptions options) { throw null; }
public System.Threading.Tasks.Task ValidateAsync(string? name, TOptions options, System.Threading.CancellationToken cancellationToken = default) { throw null; }
}
public partial class AsyncValidateOptions : Microsoft.Extensions.Options.IAsyncValidateOptions where TOptions : class
@@ -455,6 +473,7 @@ public AsyncValidateOptions(string? name, TDep1 dependency1, TDep2 dependency2,
public string FailureMessage { get { throw null; } }
public string? Name { get { throw null; } }
public System.Func> Validation { get { throw null; } }
+ public Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, TOptions options) { throw null; }
public System.Threading.Tasks.Task ValidateAsync(string? name, TOptions options, System.Threading.CancellationToken cancellationToken = default) { throw null; }
}
public partial class AsyncValidateOptions : Microsoft.Extensions.Options.IAsyncValidateOptions where TOptions : class
@@ -467,6 +486,7 @@ public AsyncValidateOptions(string? name, TDep1 dependency1, TDep2 dependency2,
public string FailureMessage { get { throw null; } }
public string? Name { get { throw null; } }
public System.Func> Validation { get { throw null; } }
+ public Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, TOptions options) { throw null; }
public System.Threading.Tasks.Task ValidateAsync(string? name, TOptions options, System.Threading.CancellationToken cancellationToken = default) { throw null; }
}
public partial class AsyncValidateOptions : Microsoft.Extensions.Options.IAsyncValidateOptions where TOptions : class
@@ -480,6 +500,7 @@ public AsyncValidateOptions(string? name, TDep1 dependency1, TDep2 dependency2,
public string FailureMessage { get { throw null; } }
public string? Name { get { throw null; } }
public System.Func> Validation { get { throw null; } }
+ public Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, TOptions options) { throw null; }
public System.Threading.Tasks.Task ValidateAsync(string? name, TOptions options, System.Threading.CancellationToken cancellationToken = default) { throw null; }
}
}
diff --git a/src/libraries/Microsoft.Extensions.Options/src/AsyncValidateOptions.cs b/src/libraries/Microsoft.Extensions.Options/src/AsyncValidateOptions.cs
index 8dbdd76605ce20..bbafa6cd991432 100644
--- a/src/libraries/Microsoft.Extensions.Options/src/AsyncValidateOptions.cs
+++ b/src/libraries/Microsoft.Extensions.Options/src/AsyncValidateOptions.cs
@@ -43,6 +43,24 @@ public AsyncValidateOptions(string? name, Func
public string FailureMessage { get; }
+ ///
+ /// Validates a specific named options instance (or all when is ).
+ ///
+ /// The name of the options instance being validated.
+ /// The options instance.
+ /// The result.
+ public ValidateOptionsResult Validate(string? name, TOptions options)
+ {
+ // null name is used to configure all named options
+ if (Name is null || name == Name)
+ {
+ return ValidateOptionsResult.Fail(SR.Format(SR.AsyncValidatorSyncValidationUnsupported, typeof(TOptions)));
+ }
+
+ // ignored if not validating this instance
+ return ValidateOptionsResult.Skip;
+ }
+
///
/// Asynchronously validates a specific named options instance (or all when is null).
///
@@ -112,6 +130,22 @@ public AsyncValidateOptions(string? name, TDep dependency, Func
public string FailureMessage { get; }
+ ///
+ /// Validates a specific named options instance (or all when is ).
+ ///
+ /// The name of the options instance being validated.
+ /// The options instance.
+ /// The result.
+ public ValidateOptionsResult Validate(string? name, TOptions options)
+ {
+ if (Name is null || name == Name)
+ {
+ return ValidateOptionsResult.Fail(SR.Format(SR.AsyncValidatorSyncValidationUnsupported, typeof(TOptions)));
+ }
+
+ return ValidateOptionsResult.Skip;
+ }
+
///
public async Task ValidateAsync(string? name, TOptions options, CancellationToken cancellationToken = default)
{
@@ -181,6 +215,22 @@ public AsyncValidateOptions(string? name, TDep1 dependency1, TDep2 dependency2,
///
public string FailureMessage { get; }
+ ///
+ /// Validates a specific named options instance (or all when is ).
+ ///
+ /// The name of the options instance being validated.
+ /// The options instance.
+ /// The result.
+ public ValidateOptionsResult Validate(string? name, TOptions options)
+ {
+ if (Name is null || name == Name)
+ {
+ return ValidateOptionsResult.Fail(SR.Format(SR.AsyncValidatorSyncValidationUnsupported, typeof(TOptions)));
+ }
+
+ return ValidateOptionsResult.Skip;
+ }
+
///
public async Task ValidateAsync(string? name, TOptions options, CancellationToken cancellationToken = default)
{
@@ -258,6 +308,22 @@ public AsyncValidateOptions(string? name, TDep1 dependency1, TDep2 dependency2,
///
public string FailureMessage { get; }
+ ///
+ /// Validates a specific named options instance (or all when is ).
+ ///
+ /// The name of the options instance being validated.
+ /// The options instance.
+ /// The result.
+ public ValidateOptionsResult Validate(string? name, TOptions options)
+ {
+ if (Name is null || name == Name)
+ {
+ return ValidateOptionsResult.Fail(SR.Format(SR.AsyncValidatorSyncValidationUnsupported, typeof(TOptions)));
+ }
+
+ return ValidateOptionsResult.Skip;
+ }
+
///
public async Task ValidateAsync(string? name, TOptions options, CancellationToken cancellationToken = default)
{
@@ -343,6 +409,22 @@ public AsyncValidateOptions(string? name, TDep1 dependency1, TDep2 dependency2,
///
public string FailureMessage { get; }
+ ///
+ /// Validates a specific named options instance (or all when is ).
+ ///
+ /// The name of the options instance being validated.
+ /// The options instance.
+ /// The result.
+ public ValidateOptionsResult Validate(string? name, TOptions options)
+ {
+ if (Name is null || name == Name)
+ {
+ return ValidateOptionsResult.Fail(SR.Format(SR.AsyncValidatorSyncValidationUnsupported, typeof(TOptions)));
+ }
+
+ return ValidateOptionsResult.Skip;
+ }
+
///
public async Task ValidateAsync(string? name, TOptions options, CancellationToken cancellationToken = default)
{
@@ -436,6 +518,22 @@ public AsyncValidateOptions(string? name, TDep1 dependency1, TDep2 dependency2,
///
public string FailureMessage { get; }
+ ///
+ /// Validates a specific named options instance (or all when is ).
+ ///
+ /// The name of the options instance being validated.
+ /// The options instance.
+ /// The result.
+ public ValidateOptionsResult Validate(string? name, TOptions options)
+ {
+ if (Name is null || name == Name)
+ {
+ return ValidateOptionsResult.Fail(SR.Format(SR.AsyncValidatorSyncValidationUnsupported, typeof(TOptions)));
+ }
+
+ return ValidateOptionsResult.Skip;
+ }
+
///
public async Task ValidateAsync(string? name, TOptions options, CancellationToken cancellationToken = default)
{
diff --git a/src/libraries/Microsoft.Extensions.Options/src/IAsyncValidateOptions.cs b/src/libraries/Microsoft.Extensions.Options/src/IAsyncValidateOptions.cs
index 66caef599340b1..6556af634e74c1 100644
--- a/src/libraries/Microsoft.Extensions.Options/src/IAsyncValidateOptions.cs
+++ b/src/libraries/Microsoft.Extensions.Options/src/IAsyncValidateOptions.cs
@@ -10,7 +10,7 @@ namespace Microsoft.Extensions.Options
/// Asynchronously validates options.
///
/// The options type to validate.
- public interface IAsyncValidateOptions where TOptions : class
+ public interface IAsyncValidateOptions : IValidateOptions where TOptions : class
{
///
/// Asynchronously validates a specified named options instance (or all if is ).
diff --git a/src/libraries/Microsoft.Extensions.Options/src/Microsoft.Extensions.Options.csproj b/src/libraries/Microsoft.Extensions.Options/src/Microsoft.Extensions.Options.csproj
index babbacd5fab98b..84f208addd8606 100644
--- a/src/libraries/Microsoft.Extensions.Options/src/Microsoft.Extensions.Options.csproj
+++ b/src/libraries/Microsoft.Extensions.Options/src/Microsoft.Extensions.Options.csproj
@@ -40,7 +40,8 @@
-
+
+
diff --git a/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilder.cs b/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilder.cs
index 3f7b914bf81ee9..809a787235b0ce 100644
--- a/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilder.cs
+++ b/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilder.cs
@@ -591,7 +591,7 @@ public virtual OptionsBuilder Validate(Func>(new AsyncValidateOptions(Name, validation, failureMessage));
+ Services.AddSingleton>(new AsyncValidateOptions(Name, validation, failureMessage));
return this;
}
@@ -615,7 +615,7 @@ public virtual OptionsBuilder Validate(Func>(sp =>
+ Services.AddTransient>(sp =>
new AsyncValidateOptions(Name, sp.GetRequiredService(), validation, failureMessage));
return this;
}
@@ -646,7 +646,7 @@ public virtual OptionsBuilder Validate(Func>(sp =>
+ Services.AddTransient>(sp =>
new AsyncValidateOptions(Name,
sp.GetRequiredService(),
sp.GetRequiredService(),
@@ -685,7 +685,7 @@ public virtual OptionsBuilder Validate(Func>(sp =>
+ Services.AddTransient>(sp =>
new AsyncValidateOptions(Name,
sp.GetRequiredService(),
sp.GetRequiredService(),
@@ -729,7 +729,7 @@ public virtual OptionsBuilder Validate(Fun
{
ArgumentNullException.ThrowIfNull(validation);
- Services.AddTransient>(sp =>
+ Services.AddTransient>(sp =>
new AsyncValidateOptions(Name,
sp.GetRequiredService(),
sp.GetRequiredService(),
@@ -778,7 +778,7 @@ public virtual OptionsBuilder Validate>(sp =>
+ Services.AddTransient>(sp =>
new AsyncValidateOptions(Name,
sp.GetRequiredService(),
sp.GetRequiredService(),
diff --git a/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilderExtensions.cs b/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilderExtensions.cs
index 205adb41022949..e0a634ca70af7b 100644
--- a/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilderExtensions.cs
+++ b/src/libraries/Microsoft.Extensions.Options/src/OptionsBuilderExtensions.cs
@@ -2,7 +2,6 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System;
-using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
@@ -27,50 +26,77 @@ public static class OptionsBuilderExtensions
{
ArgumentNullException.ThrowIfNull(optionsBuilder);
+ string name = optionsBuilder.Name;
+
+ // Register the built-in validator as a single IStartupValidator (for back-compatibility)
+ // and as an enumerable IAsyncStartupValidator so the host can run it alongside any custom async validators.
optionsBuilder.Services.TryAddTransient();
- optionsBuilder.Services.TryAddTransient();
+ optionsBuilder.Services.TryAddEnumerable(ServiceDescriptor.Transient());
optionsBuilder.Services.AddOptions()
- .Configure>((vo, options) =>
+ .Configure, IOptionsFactory, IOptionsMonitorCache>((vo, monitor, factory, cache) =>
{
- // This adds an action that resolves the options value to force evaluation
- // We don't care about the result as duplicates are not important
- vo._validators[(typeof(TOptions), optionsBuilder.Name)] = () => options.Get(optionsBuilder.Name);
- });
+ // Sync path (custom sync-only IStartupValidator): force evaluation through the
+ // monitor, which runs every validator (including an async validator's fail-fast
+ // synchronous Validate) and populates the cache.
+ vo._validators[(typeof(TOptions), name)] = () => monitor.Get(name);
- // Register async validator entries if any IAsyncValidateOptions are registered
- optionsBuilder.Services.AddOptions()
- .Configure, IEnumerable>>((vo, options, asyncValidators) =>
- {
- // Materialize the validators into a list to check if any are registered
- var validators = new List>(asyncValidators);
- if (validators.Count > 0)
+ // Async path: run the complete validation (both sync and async validators) for
+ // this (type, name) and seed the monitor cache with the validated instance so the
+ // first Get after startup returns it instead of re-running Create.
+ vo._asyncValidators[(typeof(TOptions), name)] = async (CancellationToken ct) =>
{
- vo._asyncValidators[(typeof(TOptions), optionsBuilder.Name)] = async (CancellationToken ct) =>
+ if (factory is OptionsFactory asyncFactory)
{
- // Retrieve the options value (already created by sync Validate() call)
- TOptions optionsValue = options.Get(optionsBuilder.Name);
-
- // Run async validators
- List? failures = null;
- foreach (IAsyncValidateOptions validator in validators)
+ TOptions validated = await asyncFactory.CreateAsync(name, ct).ConfigureAwait(false);
+ if (cache is OptionsCache optionsCache)
{
- ValidateOptionsResult result = await validator.ValidateAsync(optionsBuilder.Name, optionsValue, ct).ConfigureAwait(false);
- if (result is not null && result.Failed)
- {
- failures ??= new List();
- failures.AddRange(result.Failures);
- }
+ optionsCache.AddOrReplace(name, validated);
}
-
- if (failures is not null && failures.Count > 0)
+ else
{
- throw new OptionsValidationException(optionsBuilder.Name, typeof(TOptions), failures);
+ cache.TryRemove(name);
+ cache.TryAdd(name, validated);
}
- };
- }
+ }
+ else
+ {
+ // Custom IOptionsFactory: no async validation path is available,
+ // so fall back to synchronous evaluation (also populates the cache).
+ monitor.Get(name);
+ }
+ };
});
return optionsBuilder;
}
+
+ ///
+ /// Enables eager asynchronous revalidation of the options whenever their configuration reloads, instead of the
+ /// default lazy revalidation on next access.
+ ///
+ /// The type of options.
+ /// The to configure.
+ /// How reads are served when revalidation of a reloaded configuration fails.
+ /// An optional callback invoked with the options name and the exception when revalidation of a reloaded configuration fails.
+ /// The so that additional calls can be chained.
+ ///
+ /// On reload the options are re-created and validated in the background (running every registered validator,
+ /// awaiting asynchronous ones); the last successfully validated value keeps being served until the new value
+ /// validates, at which point it is swapped in atomically. This does not run startup validation; combine it with
+ /// to also validate the initial value.
+ ///
+ public static OptionsBuilder ValidateOnChange(
+ this OptionsBuilder optionsBuilder,
+ OptionsReloadValidationBehavior behavior = OptionsReloadValidationBehavior.KeepLastGood,
+ Action? onError = null)
+ where TOptions : class
+ {
+ ArgumentNullException.ThrowIfNull(optionsBuilder);
+
+ optionsBuilder.Services.AddOptions();
+ optionsBuilder.Services.AddSingleton(new ReloadValidationConfiguration(optionsBuilder.Name, behavior, onError));
+
+ return optionsBuilder;
+ }
}
}
diff --git a/src/libraries/Microsoft.Extensions.Options/src/OptionsCache.cs b/src/libraries/Microsoft.Extensions.Options/src/OptionsCache.cs
index ac6bab4c221656..83d112e2dd800c 100644
--- a/src/libraries/Microsoft.Extensions.Options/src/OptionsCache.cs
+++ b/src/libraries/Microsoft.Extensions.Options/src/OptionsCache.cs
@@ -104,6 +104,31 @@ public virtual bool TryAdd(string? name, TOptions options)
options));
}
+ ///
+ /// Adds or replaces a named options instance atomically.
+ ///
+ /// The name of the options instance.
+ /// The options instance.
+ internal void AddOrReplace(string? name, TOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ name ??= Options.DefaultName;
+
+ if (GetType() != typeof(OptionsCache))
+ {
+ TryRemove(name);
+ TryAdd(name, options);
+ return;
+ }
+
+ _cache[name] = new Lazy(
+#if !(NET || NETSTANDARD2_1)
+ () =>
+#endif
+ options);
+ }
+
///
/// Tries to remove an options instance.
///
diff --git a/src/libraries/Microsoft.Extensions.Options/src/OptionsEventSource.cs b/src/libraries/Microsoft.Extensions.Options/src/OptionsEventSource.cs
new file mode 100644
index 00000000000000..b30e985726b56f
--- /dev/null
+++ b/src/libraries/Microsoft.Extensions.Options/src/OptionsEventSource.cs
@@ -0,0 +1,29 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Diagnostics.Tracing;
+
+namespace Microsoft.Extensions.Options
+{
+ [EventSource(Name = "Microsoft-Extensions-Options")]
+ internal sealed partial class OptionsEventSource : EventSource
+ {
+ public static readonly OptionsEventSource Log = new OptionsEventSource();
+
+ [Event(1, Level = EventLevel.Error)]
+ public void ReloadValidationFailed(string optionsName, string optionsType, string exception)
+ {
+ WriteEvent(1, optionsName, optionsType, exception);
+ }
+
+ [NonEvent]
+ public void ReloadValidationFailed(string? optionsName, Type optionsType, Exception exception)
+ {
+ if (IsEnabled(EventLevel.Error, EventKeywords.All))
+ {
+ ReloadValidationFailed(optionsName ?? string.Empty, optionsType.ToString(), exception.ToString());
+ }
+ }
+ }
+}
diff --git a/src/libraries/Microsoft.Extensions.Options/src/OptionsFactory.cs b/src/libraries/Microsoft.Extensions.Options/src/OptionsFactory.cs
index c0da2adccbf887..c179c875b3a8a4 100644
--- a/src/libraries/Microsoft.Extensions.Options/src/OptionsFactory.cs
+++ b/src/libraries/Microsoft.Extensions.Options/src/OptionsFactory.cs
@@ -4,6 +4,8 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using System.Threading.Tasks;
namespace Microsoft.Extensions.Options
{
@@ -18,6 +20,8 @@ public class OptionsFactory<[DynamicallyAccessedMembers(Options.DynamicallyAcces
private readonly IConfigureOptions[] _setups;
private readonly IPostConfigureOptions[] _postConfigures;
private readonly IValidateOptions[] _validations;
+ private readonly bool _hasAsyncOnlyValidators;
+ private readonly bool _hasAsyncValidators;
///
/// Initializes a new instance with the specified options configurations.
@@ -43,8 +47,43 @@ public OptionsFactory(IEnumerable> setups, IEnumerab
_setups = setups as IConfigureOptions[] ?? new List>(setups).ToArray();
_postConfigures = postConfigures as IPostConfigureOptions[] ?? new List>(postConfigures).ToArray();
_validations = validations as IValidateOptions[] ?? new List>(validations).ToArray();
+
+ foreach (IValidateOptions validation in _validations)
+ {
+ if (validation is IAsyncValidateOptions)
+ {
+ _hasAsyncValidators = true;
+ break;
+ }
+ }
}
+ ///
+ /// Initializes a new instance with the specified options configurations.
+ ///
+ /// The configuration actions to run.
+ /// The initialization actions to run.
+ /// The validations to run.
+ /// Services registered as , used to detect an invalid registration.
+ /// A validator is registered only as and not as .
+ public OptionsFactory(IEnumerable> setups, IEnumerable> postConfigures, IEnumerable> validations, IEnumerable> asyncValidations)
+ : this(setups, postConfigures, validations)
+ {
+ // Any service resolved here is registered only as the IAsyncValidateOptions interface,
+ // which is an invalid registration. Synchronous paths enumerate IValidateOptions and would never see it.
+ // The error is surfaced when the options are created via Create or CreateAsync.
+ foreach (IAsyncValidateOptions _ in asyncValidations)
+ {
+ _hasAsyncOnlyValidators = true;
+ break;
+ }
+ }
+
+ // True when at least one validator registered as IValidateOptions also implements
+ // IAsyncValidateOptions, so a synchronous Create may fail for a genuinely-asynchronous validator.
+ // Used by the options managers to prefer a startup-validated value over re-running synchronous validation.
+ internal bool HasAsyncValidators => _hasAsyncValidators;
+
///
/// Returns a configured instance with the given .
///
@@ -53,6 +92,53 @@ public OptionsFactory(IEnumerable> setups, IEnumerab
/// One or more return failed when validating the instance created.
/// The does not have a public parameterless constructor or is .
public TOptions Create(string name)
+ {
+ ThrowIfAsyncOnlyValidatorRegistered();
+
+ TOptions options = CreateAndConfigure(name);
+
+ if (_validations.Length > 0)
+ {
+ List? failures = null;
+ foreach (IValidateOptions validate in _validations)
+ {
+ CollectFailures(ref failures, validate.Validate(name, options));
+ }
+ ThrowIfValidationFailed(name, failures);
+ }
+
+ return options;
+ }
+
+ ///
+ /// Creates, configures, and asynchronously validates a instance with the given .
+ ///
+ internal async Task CreateAsync(string name, CancellationToken cancellationToken)
+ {
+ ThrowIfAsyncOnlyValidatorRegistered();
+
+ TOptions options = CreateAndConfigure(name);
+
+ if (_validations.Length > 0)
+ {
+ List? failures = null;
+ foreach (IValidateOptions validate in _validations)
+ {
+ // Dispatch in registration order depending on capability:
+ // async validators are awaited, all others run synchronously.
+ ValidateOptionsResult result = validate is IAsyncValidateOptions asyncValidate
+ ? await asyncValidate.ValidateAsync(name, options, cancellationToken).ConfigureAwait(false)
+ : validate.Validate(name, options);
+
+ CollectFailures(ref failures, result);
+ }
+ ThrowIfValidationFailed(name, failures);
+ }
+
+ return options;
+ }
+
+ private TOptions CreateAndConfigure(string name)
{
TOptions options = CreateInstance(name);
foreach (IConfigureOptions setup in _setups)
@@ -70,25 +156,31 @@ public TOptions Create(string name)
{
post.PostConfigure(name, options);
}
+ return options;
+ }
- if (_validations.Length > 0)
+ private static void CollectFailures(ref List? failures, ValidateOptionsResult? result)
+ {
+ if (result is not null && result.Failed)
{
- var failures = new List();
- foreach (IValidateOptions validate in _validations)
- {
- ValidateOptionsResult result = validate.Validate(name, options);
- if (result is not null && result.Failed)
- {
- failures.AddRange(result.Failures);
- }
- }
- if (failures.Count > 0)
- {
- throw new OptionsValidationException(name, typeof(TOptions), failures);
- }
+ (failures ??= new List()).AddRange(result.Failures);
}
+ }
- return options;
+ private static void ThrowIfValidationFailed(string name, List? failures)
+ {
+ if (failures is { Count: > 0 })
+ {
+ throw new OptionsValidationException(name, typeof(TOptions), failures);
+ }
+ }
+
+ private void ThrowIfAsyncOnlyValidatorRegistered()
+ {
+ if (_hasAsyncOnlyValidators)
+ {
+ throw new InvalidOperationException(SR.Format(SR.AsyncOnlyValidatorRegistration, typeof(TOptions)));
+ }
}
///
diff --git a/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs b/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs
index 2c8180d0205674..1e39ab66bb4240 100644
--- a/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs
+++ b/src/libraries/Microsoft.Extensions.Options/src/OptionsMonitor.cs
@@ -2,8 +2,11 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System;
+using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
+using System.Threading;
+using System.Threading.Tasks;
using Microsoft.Extensions.Primitives;
namespace Microsoft.Extensions.Options
@@ -20,6 +23,8 @@ public class OptionsMonitor<[DynamicallyAccessedMembers(Options.DynamicallyAcces
private readonly IOptionsMonitorCache _cache;
private readonly IOptionsFactory _factory;
private readonly List _registrations = new List();
+ private readonly Dictionary>? _reloadConfigs;
+ private readonly ConcurrentDictionary? _eagerStates;
internal event Action? _onChange;
///
@@ -29,10 +34,33 @@ public class OptionsMonitor<[DynamicallyAccessedMembers(Options.DynamicallyAcces
/// The sources used to listen for changes to the options instance.
/// The cache used to store options.
public OptionsMonitor(IOptionsFactory factory, IEnumerable> sources, IOptionsMonitorCache cache)
+ : this(factory, sources, cache, reloadValidationConfigs: Array.Empty>())
+ {
+ }
+
+ ///
+ /// Initializes a new instance of with the specified factory, sources, cache, and reload-validation opt-ins.
+ ///
+ /// The factory to use to create options.
+ /// The sources used to listen for changes to the options instance.
+ /// The cache used to store options.
+ /// The reload-validation opt-ins registered through the ValidateOnChange options-builder extension.
+ public OptionsMonitor(IOptionsFactory factory, IEnumerable> sources, IOptionsMonitorCache cache, IEnumerable> reloadValidationConfigs)
{
_factory = factory;
_cache = cache;
+ foreach (ReloadValidationConfiguration config in reloadValidationConfigs)
+ {
+ // The last opt-in for a given name wins, mirroring the "greediest configuration wins" behavior elsewhere.
+ (_reloadConfigs ??= new Dictionary>(StringComparer.Ordinal))[config.Name] = config;
+ }
+
+ if (_reloadConfigs is not null)
+ {
+ _eagerStates = new ConcurrentDictionary(StringComparer.Ordinal);
+ }
+
void RegisterSource(IOptionsChangeTokenSource source)
{
IDisposable registration = ChangeToken.OnChange(
@@ -64,11 +92,124 @@ void RegisterSource(IOptionsChangeTokenSource source)
private void InvokeChanged(string? name)
{
name ??= Options.DefaultName;
+
+ if (_reloadConfigs is not null && _reloadConfigs.TryGetValue(name, out ReloadValidationConfiguration? config))
+ {
+ // Eager revalidation: keep serving the last validated value while the reloaded configuration is
+ // re-created and validated in the background, then swap it in only if validation succeeds.
+ InvokeEagerReload(name, config);
+ return;
+ }
+
_cache.TryRemove(name);
TOptions options = Get(name);
_onChange?.Invoke(options, name);
}
+ private void InvokeEagerReload(string name, ReloadValidationConfiguration config)
+ {
+ EagerReloadState state = _eagerStates!.GetOrAdd(name, static _ => new EagerReloadState());
+
+ int generation;
+ CancellationToken cancellationToken;
+ lock (state)
+ {
+ // Latest-wins: supersede any in-flight refresh for this name so only the newest reload publishes.
+ state.Cancel();
+ state.Cts = new CancellationTokenSource();
+ cancellationToken = state.Cts.Token;
+ generation = ++state.Generation;
+ }
+
+ _ = RefreshAsync(name, config, state, generation, cancellationToken);
+ }
+
+ private async Task RefreshAsync(string name, ReloadValidationConfiguration config, EagerReloadState state, int generation, CancellationToken cancellationToken)
+ {
+ TOptions? published = null;
+ try
+ {
+ TOptions validated = _factory is OptionsFactory asyncFactory
+ ? await asyncFactory.CreateAsync(name, cancellationToken).ConfigureAwait(false)
+ : _factory.Create(name);
+
+ lock (state)
+ {
+ // Re-check under the lock and publish atomically so a superseded reload can never overwrite a newer
+ // published value (the generation check and the cache write must not be interleaved with another reload).
+ if (state.Generation != generation)
+ {
+ return;
+ }
+
+ if (_cache is OptionsCache optionsCache)
+ {
+ optionsCache.AddOrReplace(name, validated);
+ }
+ else
+ {
+ _cache.TryRemove(name);
+ _cache.TryAdd(name, validated);
+ }
+
+ published = validated;
+ }
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // Our token was canceled: superseded by a newer reload (or disposal).
+ // A cancellation that does not originate from our token (e.g. a validator's own timeout) falls
+ // through to the general handler below and is treated as a genuine reload failure.
+ return;
+ }
+ catch (Exception ex)
+ {
+ lock (state)
+ {
+ if (state.Generation != generation)
+ {
+ return;
+ }
+
+ if (config.Behavior == OptionsReloadValidationBehavior.FailReads)
+ {
+ // Drop the cached value so the next read re-creates and surfaces the failure.
+ _cache.TryRemove(name);
+ }
+ }
+
+ // Always surface the failure through the event source so a reload failure is observable even when no
+ // error callback was supplied. This is independent of the user callback below.
+ OptionsEventSource.Log.ReloadValidationFailed(name, typeof(TOptions), ex);
+
+ try
+ {
+ config.OnError?.Invoke(name, ex);
+ }
+ catch
+ {
+ // The error callback is user code. We catch here to prevent an unobserved task exception.
+ }
+
+ return;
+ }
+
+ if (published is not null)
+ {
+ try
+ {
+ _onChange?.Invoke(published, name);
+ }
+ catch
+ {
+ // Change listeners are user code. RefreshAsync runs as a fire-and-forget task, so a throwing
+ // listener must be swallowed here to avoid faulting that task (an unobserved task exception).
+ // A listener throwing is a listener bug and is not a reload failure, so it is not surfaced
+ // through the failure channels above.
+ }
+ }
+ }
+
///
/// Gets the present value of the options (equivalent to Get(Options.DefaultName)).
///
@@ -125,6 +266,37 @@ public void Dispose()
}
_registrations.Clear();
+
+ if (_eagerStates is not null)
+ {
+ // Cancel any in-flight eager revalidation and bump the generation so a refresh that ignores the
+ // cancellation token still fails the generation check and does not publish after disposal.
+ foreach (EagerReloadState state in _eagerStates.Values)
+ {
+ lock (state)
+ {
+ state.Cancel();
+ state.Generation++;
+ }
+ }
+ }
+ }
+
+ private sealed class EagerReloadState
+ {
+ public int Generation;
+ public CancellationTokenSource? Cts;
+
+ public void Cancel()
+ {
+ CancellationTokenSource? cts = Cts;
+ if (cts is not null)
+ {
+ cts.Cancel();
+ cts.Dispose();
+ Cts = null;
+ }
+ }
}
internal sealed class ChangeTrackerDisposable : IDisposable
diff --git a/src/libraries/Microsoft.Extensions.Options/src/OptionsReloadValidationBehavior.cs b/src/libraries/Microsoft.Extensions.Options/src/OptionsReloadValidationBehavior.cs
new file mode 100644
index 00000000000000..256846985f7d59
--- /dev/null
+++ b/src/libraries/Microsoft.Extensions.Options/src/OptionsReloadValidationBehavior.cs
@@ -0,0 +1,24 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.Extensions.Options
+{
+ ///
+ /// Controls how an serves values when asynchronous revalidation of a
+ /// reloaded configuration fails, for options that opted in through the ValidateOnChange options-builder extension.
+ ///
+ public enum OptionsReloadValidationBehavior
+ {
+ ///
+ /// Keeps serving the last successfully validated value.
+ /// The failure is reported to the Microsoft-Extensions-Options event source
+ /// and, if one was supplied, to the error callback.
+ ///
+ KeepLastGood,
+
+ ///
+ /// Clears the cached value so the next read re-creates and validates it, surfacing the failure to the reader.
+ ///
+ FailReads,
+ }
+}
diff --git a/src/libraries/Microsoft.Extensions.Options/src/ReloadValidationConfiguration.cs b/src/libraries/Microsoft.Extensions.Options/src/ReloadValidationConfiguration.cs
new file mode 100644
index 00000000000000..4eba2c14368a20
--- /dev/null
+++ b/src/libraries/Microsoft.Extensions.Options/src/ReloadValidationConfiguration.cs
@@ -0,0 +1,45 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+
+namespace Microsoft.Extensions.Options
+{
+ ///
+ /// Represents a single opt-in to eager asynchronous revalidation on configuration reload for a named
+ /// instance, as registered by the ValidateOnChange options-builder extension.
+ ///
+ /// The options type this registration applies to.
+ public sealed class ReloadValidationConfiguration where TOptions : class
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The name of the options instance to revalidate on reload.
+ /// How reads are served when revalidation of a reloaded configuration fails.
+ /// An optional callback invoked with the options name and the exception when revalidation of a reloaded configuration fails.
+ public ReloadValidationConfiguration(string name, OptionsReloadValidationBehavior behavior, Action? onError)
+ {
+ ArgumentNullException.ThrowIfNull(name);
+
+ Name = name;
+ Behavior = behavior;
+ OnError = onError;
+ }
+
+ ///
+ /// Gets the name of the options instance to revalidate on reload.
+ ///
+ public string Name { get; }
+
+ ///
+ /// Gets a value that indicates how reads are served when revalidation of a reloaded configuration fails.
+ ///
+ public OptionsReloadValidationBehavior Behavior { get; }
+
+ ///
+ /// Gets the callback invoked with the options name and the exception when revalidation of a reloaded configuration fails.
+ ///
+ public Action? OnError { get; }
+ }
+}
diff --git a/src/libraries/Microsoft.Extensions.Options/src/Resources/Strings.resx b/src/libraries/Microsoft.Extensions.Options/src/Resources/Strings.resx
index fcb0d92415843d..33a60e867a656c 100644
--- a/src/libraries/Microsoft.Extensions.Options/src/Resources/Strings.resx
+++ b/src/libraries/Microsoft.Extensions.Options/src/Resources/Strings.resx
@@ -135,4 +135,10 @@
No IConfigureOptions<>, IPostConfigureOptions<>, or IValidateOptions<> implementations were found, did you mean to call Configure<> or PostConfigure<>?
+
+ Options instance of type '{0}' is validated by an asynchronous validator that cannot run during synchronous validation. Validate these options asynchronously before accessing them synchronously.
+
+
+ A validator for options type '{0}' is registered as 'IAsyncValidateOptions<TOptions>'. Register it as 'IValidateOptions<TOptions>' instead.
+
\ No newline at end of file
diff --git a/src/libraries/Microsoft.Extensions.Options/src/StartupValidatorOptions.cs b/src/libraries/Microsoft.Extensions.Options/src/StartupValidatorOptions.cs
index 7dbb192de4858d..53ddd4d4d22af6 100644
--- a/src/libraries/Microsoft.Extensions.Options/src/StartupValidatorOptions.cs
+++ b/src/libraries/Microsoft.Extensions.Options/src/StartupValidatorOptions.cs
@@ -13,7 +13,8 @@ internal sealed class StartupValidatorOptions
// Maps each pair of a) options type and b) options name to a method that forces its evaluation, e.g. IOptionsMonitor.Get(name)
public Dictionary<(Type, string), Action> _validators { get; } = new Dictionary<(Type, string), Action>();
- // Maps each pair of a) options type and b) options name to an async method that forces evaluation and runs async validators
+ // Maps each pair of a) options type and b) options name to an async method that runs the complete
+ // validation (synchronous and asynchronous validators) and seeds the monitor cache with the result
public Dictionary<(Type, string), Func> _asyncValidators { get; } = new Dictionary<(Type, string), Func>();
}
}
diff --git a/src/libraries/Microsoft.Extensions.Options/src/UnnamedOptionsManager.cs b/src/libraries/Microsoft.Extensions.Options/src/UnnamedOptionsManager.cs
index e6ce20b94d1871..fa844c6adcd0b4 100644
--- a/src/libraries/Microsoft.Extensions.Options/src/UnnamedOptionsManager.cs
+++ b/src/libraries/Microsoft.Extensions.Options/src/UnnamedOptionsManager.cs
@@ -11,11 +11,24 @@ internal sealed class UnnamedOptionsManager<[DynamicallyAccessedMembers(Options.
where TOptions : class
{
private readonly IOptionsFactory _factory;
+ private readonly IOptionsMonitorCache? _validatedCache;
private object? _syncObj;
private volatile TOptions? _value;
public UnnamedOptionsManager(IOptionsFactory factory) => _factory = factory;
+ public UnnamedOptionsManager(IOptionsFactory factory, IOptionsMonitorCache cache)
+ {
+ _factory = factory;
+
+ // Only consult the shared validated cache for types that have an asynchronous validator; sync-only types
+ // keep the existing behavior of creating and validating synchronously.
+ if (factory is OptionsFactory optionsFactory && optionsFactory.HasAsyncValidators)
+ {
+ _validatedCache = cache;
+ }
+ }
+
public TOptions Value
{
get
@@ -27,9 +40,23 @@ public TOptions Value
lock (_syncObj ?? Interlocked.CompareExchange(ref _syncObj, new object(), null) ?? _syncObj)
{
- return _value ??= _factory.Create(Options.DefaultName);
+ return _value ??= CreateValue();
}
}
}
+
+ private TOptions CreateValue()
+ {
+ // For an async-validated type, prefer the value validated during startup (seeded into the shared cache) so a
+ // synchronous access returns the last validated value instead of re-running the throwing synchronous Validate.
+ // If nothing has been validated yet (no host, pre-startup, or no ValidateOnStart), fall back to Create, which
+ // fails fast with an actionable message.
+ if (_validatedCache is OptionsCache optionsCache && optionsCache.TryGetValue(Options.DefaultName, out TOptions? validated))
+ {
+ return validated;
+ }
+
+ return _factory.Create(Options.DefaultName);
+ }
}
}
diff --git a/src/libraries/Microsoft.Extensions.Options/src/ValidateOnStart.cs b/src/libraries/Microsoft.Extensions.Options/src/ValidateOnStart.cs
index 6a611700c090fa..e87c2831cc74f1 100644
--- a/src/libraries/Microsoft.Extensions.Options/src/ValidateOnStart.cs
+++ b/src/libraries/Microsoft.Extensions.Options/src/ValidateOnStart.cs
@@ -55,8 +55,6 @@ public void Validate()
public async Task ValidateAsync(CancellationToken cancellationToken = default)
{
- // Async validators only — sync validation is handled separately by
- // IStartupValidator.Validate() in Host.StartAsync() (two-stage orchestration).
List? exceptions = null;
foreach (Func asyncValidator in _validatorOptions._asyncValidators.Values)
diff --git a/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/AsyncOptionsValidationTests.cs b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/AsyncOptionsValidationTests.cs
index 8979090f01a3c5..81cb0488197fe6 100644
--- a/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/AsyncOptionsValidationTests.cs
+++ b/src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/AsyncOptionsValidationTests.cs
@@ -2,16 +2,22 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System;
+using System.Collections.Generic;
+using System.Diagnostics.Tracing;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Primitives;
using Xunit;
namespace Microsoft.Extensions.Options.Tests
{
public class AsyncOptionsValidationTests
{
+ private static IAsyncStartupValidator GetAsyncStartupValidator(IServiceProvider sp) =>
+ Assert.IsAssignableFrom(sp.GetRequiredService());
+
[Fact]
public async Task AsyncValidateOptions_SkipsWhenNameDoesNotMatch()
{
@@ -68,7 +74,7 @@ public async Task OptionsBuilder_AsyncValidate_RegistersAndExecutes()
.ValidateOnStart();
ServiceProvider sp = services.BuildServiceProvider();
- var validator = sp.GetRequiredService();
+ IAsyncStartupValidator validator = GetAsyncStartupValidator(sp);
await validator.ValidateAsync(CancellationToken.None);
@@ -76,7 +82,7 @@ public async Task OptionsBuilder_AsyncValidate_RegistersAndExecutes()
}
[Fact]
- public async Task StartupValidator_TwoStage_RunsBothSyncAndAsyncValidators()
+ public async Task StartupValidator_SinglePath_RunsBothSyncAndAsyncValidators()
{
var services = new ServiceCollection();
bool syncRan = false;
@@ -94,18 +100,17 @@ public async Task StartupValidator_TwoStage_RunsBothSyncAndAsyncValidators()
ServiceProvider sp = services.BuildServiceProvider();
- // Two-stage orchestration: Host.cs calls Validate() then ValidateAsync() independently
- var syncValidator = sp.GetRequiredService();
- syncValidator.Validate();
- Assert.True(syncRan);
+ // Single-path orchestration: one ValidateAsync runs every validator (sync and async) for the type,
+ // dispatching each by capability.
+ IAsyncStartupValidator validator = GetAsyncStartupValidator(sp);
+ await validator.ValidateAsync(CancellationToken.None);
- var asyncValidator = sp.GetRequiredService();
- await asyncValidator.ValidateAsync(CancellationToken.None);
+ Assert.True(syncRan);
Assert.True(asyncRan);
}
[Fact]
- public async Task StartupValidator_TwoStage_SyncFailureSkipsAsyncValidators()
+ public async Task StartupValidator_SinglePath_AggregatesSyncAndAsyncFailures()
{
var services = new ServiceCollection();
bool asyncRan = false;
@@ -116,19 +121,21 @@ public async Task StartupValidator_TwoStage_SyncFailureSkipsAsyncValidators()
.Validate(async (FakeOptions o, CancellationToken ct) =>
{
asyncRan = true;
- return await Task.FromResult(true);
- }, "async")
+ return await Task.FromResult(false);
+ }, "async validation failed")
.ValidateOnStart();
ServiceProvider sp = services.BuildServiceProvider();
+ IAsyncStartupValidator validator = GetAsyncStartupValidator(sp);
- // Stage 1: Sync throws — in Host.cs, this prevents reaching Stage 2
- var syncValidator = sp.GetRequiredService();
- Assert.Throws(() => syncValidator.Validate());
+ // The single path does not short-circuit on the first failure: every validator runs and
+ // all failures are aggregated into one OptionsValidationException.
+ OptionsValidationException ex = await Assert.ThrowsAsync(
+ () => validator.ValidateAsync(CancellationToken.None));
- // Stage 2 is never reached because the exception propagates.
- // Verify async didn't run (simulating Host.cs short-circuit behavior).
- Assert.False(asyncRan);
+ Assert.True(asyncRan);
+ Assert.Contains("sync validation failed", ex.Failures);
+ Assert.Contains("async validation failed", ex.Failures);
}
[Fact]
@@ -147,7 +154,7 @@ public async Task StartupValidator_ValidateAsync_OnlyAsyncValidators()
.ValidateOnStart();
ServiceProvider sp = services.BuildServiceProvider();
- var validator = sp.GetRequiredService();
+ IAsyncStartupValidator validator = GetAsyncStartupValidator(sp);
await validator.ValidateAsync(CancellationToken.None);
@@ -169,7 +176,7 @@ public async Task StartupValidator_ValidateAsync_AsyncFailureThrowsOptionsValida
.ValidateOnStart();
ServiceProvider sp = services.BuildServiceProvider();
- var validator = sp.GetRequiredService();
+ IAsyncStartupValidator validator = GetAsyncStartupValidator(sp);
OptionsValidationException ex = await Assert.ThrowsAsync(
() => validator.ValidateAsync(CancellationToken.None));
@@ -177,11 +184,12 @@ public async Task StartupValidator_ValidateAsync_AsyncFailureThrowsOptionsValida
}
[Fact]
- public async Task ValidateOnStart_CustomSyncOnlyValidator_DoesNotThrowInvalidCast()
+ public void ValidateOnStart_CustomSyncOnlyValidator_UsesSyncPath()
{
var services = new ServiceCollection();
- // Register a custom IStartupValidator that does NOT implement IAsyncStartupValidator
+ // A custom sync-only IStartupValidator registered before ValidateOnStart wins the
+ // TryAddTransient, so it is the resolved IStartupValidator.
services.AddSingleton(new CustomSyncOnlyValidator());
services.AddOptions()
@@ -191,9 +199,56 @@ public async Task ValidateOnStart_CustomSyncOnlyValidator_DoesNotThrowInvalidCas
ServiceProvider sp = services.BuildServiceProvider();
- // Should NOT throw InvalidCastException — IAsyncStartupValidator gets its own StartupValidator instance
- var asyncValidator = sp.GetRequiredService();
- await asyncValidator.ValidateAsync(CancellationToken.None);
+ // The custom validator is not async-capable, so the host falls back to the sync path (validator.Validate())
+ // This means no InvalidCastException and no async validation.
+ IStartupValidator validator = sp.GetRequiredService();
+ Assert.IsType(validator);
+ Assert.False(validator is IAsyncStartupValidator);
+ validator.Validate();
+ }
+
+ [Fact]
+ public void ValidateOnStart_RegistersBuiltInValidatorAsBothInterfaces()
+ {
+ var services = new ServiceCollection();
+
+ services.AddOptions()
+ .Configure(o => o.Message = "test")
+ .Validate(o => true)
+ .ValidateOnStart();
+
+ ServiceProvider sp = services.BuildServiceProvider();
+
+ IStartupValidator sync = sp.GetRequiredService();
+ Assert.IsType(sync, exactMatch: false);
+ Assert.Single(sp.GetServices());
+ }
+
+ [Fact]
+ public void ValidateOnStart_CalledMultipleTimes_RegistersSingleAsyncStartupValidator()
+ {
+ var services = new ServiceCollection();
+
+ services.AddOptions("a").Configure(o => o.Message = "a").Validate(o => true).ValidateOnStart();
+ services.AddOptions("b").Configure(o => o.Message = "b").Validate(o => true).ValidateOnStart();
+
+ ServiceProvider sp = services.BuildServiceProvider();
+
+ Assert.Single(sp.GetServices());
+ }
+
+ [Fact]
+ public void ValidateOnStart_CustomAsyncStartupValidator_CoexistsWithBuiltInInEnumerable()
+ {
+ var services = new ServiceCollection();
+
+ services.AddSingleton(new TrackingAsyncStartupValidator());
+ services.AddOptions().Configure(o => o.Message = "test").Validate(o => true).ValidateOnStart();
+
+ ServiceProvider sp = services.BuildServiceProvider();
+
+ // A custom async startup validator (a different implementation type) coexists with the built-in one.
+ Assert.Equal(2, sp.GetServices().Count());
}
[Fact]
@@ -212,7 +267,7 @@ public async Task StartupValidator_ValidateAsync_CancellationTokenPropagated()
.ValidateOnStart();
ServiceProvider sp = services.BuildServiceProvider();
- var validator = sp.GetRequiredService();
+ IAsyncStartupValidator validator = GetAsyncStartupValidator(sp);
cts.Cancel();
await Assert.ThrowsAsync(() => validator.ValidateAsync(cts.Token));
@@ -264,16 +319,648 @@ public async Task StartupValidator_ValidateAsync_MultipleFailures_ThrowsAggregat
.ValidateOnStart();
using ServiceProvider sp = services.BuildServiceProvider();
- var validator = sp.GetRequiredService();
+ IAsyncStartupValidator validator = GetAsyncStartupValidator(sp);
AggregateException ex = await Assert.ThrowsAsync(() => validator.ValidateAsync());
Assert.Equal(2, ex.InnerExceptions.Count);
Assert.All(ex.InnerExceptions, e => Assert.IsType(e));
}
+ [Fact]
+ public async Task StartupValidator_ValidatorImplementingBoth_DispatchesToAsync()
+ {
+ var spy = new CapabilitySpyValidator();
+ var services = new ServiceCollection();
+
+ services.AddOptions()
+ .Configure(o => o.Message = "test")
+ .ValidateOnStart();
+ services.AddSingleton>(spy);
+
+ ServiceProvider sp = services.BuildServiceProvider();
+ IAsyncStartupValidator validator = GetAsyncStartupValidator(sp);
+
+ await validator.ValidateAsync(CancellationToken.None);
+
+ // A validator that implements both contracts is dispatched through ValidateAsync only.
+ Assert.True(spy.AsyncCalled);
+ Assert.False(spy.SyncCalled);
+ }
+
+ [Fact]
+ public void MisregisteredAsyncOnlyValidator_ThrowsClearErrorOnSyncAccess()
+ {
+ var services = new ServiceCollection();
+ services.AddOptions();
+ // Misregistration: registered only as the IAsyncValidateOptions capability interface.
+ services.AddSingleton>(
+ new AsyncValidateOptions(null, (o, ct) => Task.FromResult(false), "async fail"));
+ using ServiceProvider sp = services.BuildServiceProvider();
+
+ InvalidOperationException ex = Assert.Throws(
+ () => sp.GetRequiredService>().Value);
+
+ Assert.Contains(nameof(FakeOptions), ex.Message);
+ Assert.Contains(nameof(IValidateOptions), ex.Message);
+ }
+
+ [Fact]
+ public async Task MisregisteredAsyncOnlyValidator_ThrowsClearErrorOnStartup()
+ {
+ var services = new ServiceCollection();
+ services.AddOptions().ValidateOnStart();
+ services.AddSingleton>(
+ new AsyncValidateOptions(null, (o, ct) => Task.FromResult(false), "async fail"));
+ using ServiceProvider sp = services.BuildServiceProvider();
+ IAsyncStartupValidator validator = GetAsyncStartupValidator(sp);
+
+ await Assert.ThrowsAsync(() => validator.ValidateAsync(CancellationToken.None));
+ }
+
+ [Fact]
+ public void CorrectlyRegisteredAsyncValidator_DoesNotThrowMisregistrationError()
+ {
+ var services = new ServiceCollection();
+ services.AddOptions().Configure(o => o.Message = "ok");
+ // Correct: an async-capable validator registered through IValidateOptions.
+ services.AddSingleton>(new CapabilitySpyValidator());
+ using ServiceProvider sp = services.BuildServiceProvider();
+
+ FakeOptions value = sp.GetRequiredService>().Value;
+
+ Assert.Equal("ok", value.Message);
+ }
+
+ [Fact]
+ public void NoAsyncValidators_DoesNotThrowMisregistrationError()
+ {
+ var services = new ServiceCollection();
+ services.AddOptions().Configure(o => o.Message = "ok");
+ using ServiceProvider sp = services.BuildServiceProvider();
+
+ FakeOptions value = sp.GetRequiredService>().Value;
+
+ Assert.Equal("ok", value.Message);
+ }
+
+ [Fact]
+ public async Task AsyncValidatedOptions_SyncAccessorsReturnStartupValidatedValue()
+ {
+ var services = new ServiceCollection();
+ services.AddOptions()
+ .Configure(o => o.Message = "validated")
+ .Validate(async (FakeOptions o, CancellationToken ct) => await Task.FromResult(true), "async fail")
+ .ValidateOnStart();
+ using ServiceProvider sp = services.BuildServiceProvider();
+
+ // Before startup nothing has been validated, so synchronous access fails fast rather than silently skipping.
+ Assert.Throws(() => sp.GetRequiredService>().Value);
+
+ await GetAsyncStartupValidator(sp).ValidateAsync(CancellationToken.None);
+
+ // After startup seeds the shared cache, the singleton IOptions.Value returns the validated value.
+ Assert.Equal("validated", sp.GetRequiredService>().Value.Message);
+
+ // IOptionsSnapshot is scoped and creates per scope; it does not consult the shared validated cache, so
+ // for an async-only validator (whose synchronous Validate cannot run) a scoped access fails fast.
+ using IServiceScope scope = sp.CreateScope();
+ Assert.Throws(
+ () => scope.ServiceProvider.GetRequiredService>().Get(null));
+ }
+
+ [Fact]
+ public void AsyncValidatedOptions_SyncCapableValidator_SnapshotIsPerScope()
+ {
+ int configureCount = 0;
+ var services = new ServiceCollection();
+ services.AddOptions()
+ .Configure(o => o.Message = $"v{Interlocked.Increment(ref configureCount)}")
+ .ValidateOnStart();
+ services.AddSingleton>(new SyncCapableAsyncValidator());
+ using ServiceProvider sp = services.BuildServiceProvider();
+
+ FakeOptions first, second;
+ using (IServiceScope scope = sp.CreateScope())
+ {
+ first = scope.ServiceProvider.GetRequiredService>().Get(null);
+ }
+ using (IServiceScope scope = sp.CreateScope())
+ {
+ second = scope.ServiceProvider.GetRequiredService>().Get(null);
+ }
+
+ // A validator that is async-capable but whose synchronous Validate works keeps the per-scope IOptionsSnapshot
+ // semantics: each scope creates and validates a fresh instance rather than sharing the startup-validated one.
+ Assert.NotSame(first, second);
+ }
+
+ [Fact]
+ public void SyncOnlyValidatedOptions_SyncAccessorsBehaviorUnchanged()
+ {
+ var services = new ServiceCollection();
+ services.AddOptions()
+ .Configure(o => o.Message = "sync")
+ .Validate(o => o.Message == "sync", "sync fail");
+ using ServiceProvider sp = services.BuildServiceProvider();
+
+ // A sync-only type is not async-capable, so the accessors create and validate synchronously as before.
+ Assert.Equal("sync", sp.GetRequiredService>().Value.Message);
+ using IServiceScope scope = sp.CreateScope();
+ Assert.Equal("sync", scope.ServiceProvider.GetRequiredService>().Get(null).Message);
+ }
+
+ [Fact]
+ public void ValidateOnChange_ValidReload_PublishesNewValidatedValueAndNotifies()
+ {
+ var source = new ReloadSource();
+ var validator = new ReloadTestValidator();
+ int configureCount = 0;
+ var services = new ServiceCollection();
+ services.AddOptions()
+ .Configure(o => o.Message = $"v{Interlocked.Increment(ref configureCount)}")
+ .ValidateOnChange();
+ services.AddSingleton>(validator);
+ services.AddSingleton>(source);
+ using ServiceProvider sp = services.BuildServiceProvider();
+
+ SeedCache(sp, "seed");
+ IOptionsMonitor monitor = sp.GetRequiredService>();
+ Assert.Equal("seed", monitor.CurrentValue.Message);
+
+ using var changed = new ManualResetEventSlim();
+ string? notified = null;
+ using IDisposable _ = monitor.OnChange((o, n) => { notified = o.Message; changed.Set(); });
+
+ source.Trigger();
+
+ Assert.True(changed.Wait(TimeSpan.FromSeconds(30)), "The change notification was not raised.");
+ Assert.NotEqual("seed", monitor.CurrentValue.Message);
+ Assert.Equal(monitor.CurrentValue.Message, notified);
+ }
+
+ [Fact]
+ public void ValidateOnChange_ThrowingChangeListener_DoesNotEvictPublishedValueOrReportError()
+ {
+ var source = new ReloadSource();
+ var validator = new ReloadTestValidator();
+ var services = new ServiceCollection();
+ bool errorReported = false;
+ services.AddOptions()
+ .Configure(o => o.Message = "reloaded")
+ .ValidateOnChange(OptionsReloadValidationBehavior.FailReads, (name, ex) => errorReported = true);
+ services.AddSingleton>(validator);
+ services.AddSingleton>(source);
+ using ServiceProvider sp = services.BuildServiceProvider();
+
+ SeedCache(sp, "seed");
+ IOptionsMonitor monitor = sp.GetRequiredService>();
+
+ using var listenerRan = new ManualResetEventSlim();
+ using IDisposable _ = monitor.OnChange((o, n) =>
+ {
+ listenerRan.Set();
+ throw new InvalidOperationException("listener boom");
+ });
+
+ source.Trigger();
+
+ Assert.True(listenerRan.Wait(TimeSpan.FromSeconds(30)), "The change listener did not run.");
+ Thread.Sleep(200);
+
+ // A throwing listener runs after a successful reload; it must not be treated as a reload failure: the
+ // published value is served (not evicted by FailReads) and the error callback is not invoked.
+ Assert.Equal("reloaded", monitor.CurrentValue.Message);
+ Assert.False(errorReported);
+ }
+
+ [Fact]
+ public void ValidateOnChange_ThrowingChangeListener_DoesNotFaultBackgroundRefresh()
+ {
+ var unobserved = new List();
+ void OnUnobserved(object? sender, UnobservedTaskExceptionEventArgs e)
+ {
+ lock (unobserved)
+ {
+ unobserved.Add(e.Exception);
+ }
+ }
+
+ TaskScheduler.UnobservedTaskException += OnUnobserved;
+ try
+ {
+ var source = new ReloadSource();
+ var validator = new ReloadTestValidator();
+ var services = new ServiceCollection();
+ services.AddOptions()
+ .Configure(o => o.Message = "reloaded")
+ .ValidateOnChange();
+ services.AddSingleton>(validator);
+ services.AddSingleton>(source);
+ using ServiceProvider sp = services.BuildServiceProvider();
+
+ SeedCache(sp, "seed");
+ IOptionsMonitor monitor = sp.GetRequiredService>();
+
+ using var listenerRan = new ManualResetEventSlim();
+ using IDisposable _ = monitor.OnChange((o, n) =>
+ {
+ listenerRan.Set();
+ throw new InvalidOperationException("listener boom");
+ });
+
+ source.Trigger();
+ Assert.True(listenerRan.Wait(TimeSpan.FromSeconds(30)), "The change listener did not run.");
+ Thread.Sleep(200);
+
+ // Force finalization so a faulted, unobserved fire-and-forget refresh task would raise
+ // UnobservedTaskException. With the listener guarded, the task completes without faulting.
+ for (int i = 0; i < 3; i++)
+ {
+ GC.Collect();
+ GC.WaitForPendingFinalizers();
+ }
+
+ lock (unobserved)
+ {
+ Assert.DoesNotContain(unobserved, e => e.ToString().Contains("listener boom"));
+ }
+ }
+ finally
+ {
+ TaskScheduler.UnobservedTaskException -= OnUnobserved;
+ }
+ }
+
+ [Fact]
+ public void ValidateOnChange_ThrowingOnErrorCallback_DoesNotFaultBackgroundRefresh()
+ {
+ var source = new ReloadSource();
+ var validator = new ReloadTestValidator { Fail = true };
+ var services = new ServiceCollection();
+ using var errored = new ManualResetEventSlim();
+ services.AddOptions()
+ .Configure(o => o.Message = "reloaded")
+ .ValidateOnChange(OptionsReloadValidationBehavior.KeepLastGood, (name, ex) =>
+ {
+ errored.Set();
+ throw new InvalidOperationException("onError boom");
+ });
+ services.AddSingleton>(validator);
+ services.AddSingleton>(source);
+ using ServiceProvider sp = services.BuildServiceProvider();
+
+ SeedCache(sp, "good");
+ IOptionsMonitor monitor = sp.GetRequiredService>();
+
+ source.Trigger();
+
+ // A throwing OnError must be swallowed by the background refresh (no unobserved task fault); the last good
+ // value keeps being served.
+ Assert.True(errored.Wait(TimeSpan.FromSeconds(30)), "The error callback was not invoked.");
+ Assert.Equal("good", monitor.CurrentValue.Message);
+ }
+
+ [Fact]
+ public void ValidateOnChange_InvalidReload_KeepLastGood_ServesLastGoodAndReportsError()
+ {
+ var source = new ReloadSource();
+ var validator = new ReloadTestValidator { Fail = true };
+ var services = new ServiceCollection();
+ using var errored = new ManualResetEventSlim();
+ Exception? reportedError = null;
+ services.AddOptions()
+ .Configure(o => o.Message = "reloaded")
+ .ValidateOnChange(OptionsReloadValidationBehavior.KeepLastGood, (name, ex) => { reportedError = ex; errored.Set(); });
+ services.AddSingleton>(validator);
+ services.AddSingleton>(source);
+ using ServiceProvider sp = services.BuildServiceProvider();
+
+ SeedCache(sp, "good");
+ IOptionsMonitor monitor = sp.GetRequiredService>();
+
+ bool changeFired = false;
+ using IDisposable _ = monitor.OnChange((o, n) => changeFired = true);
+
+ source.Trigger();
+
+ Assert.True(errored.Wait(TimeSpan.FromSeconds(30)), "The error callback was not invoked.");
+ Assert.IsType(reportedError);
+ // The last validated value keeps being served, and no change notification is raised for a failed reload.
+ Assert.Equal("good", monitor.CurrentValue.Message);
+ Assert.False(changeFired);
+ }
+
+ [Fact]
+ public void ValidateOnChange_InvalidReload_WithoutErrorCallback_EmitsEventSourceFailure()
+ {
+ using var listener = new OptionsEventSourceListener();
+ var source = new ReloadSource();
+ var validator = new ReloadTestValidator { Fail = true };
+ var services = new ServiceCollection();
+ services.AddOptions()
+ .Configure(o => o.Message = "reloaded")
+ .ValidateOnChange();
+ services.AddSingleton>(validator);
+ services.AddSingleton>(source);
+ using ServiceProvider sp = services.BuildServiceProvider();
+
+ SeedCache(sp, "good");
+ IOptionsMonitor monitor = sp.GetRequiredService>();
+
+ source.Trigger();
+
+ Assert.True(listener.FailureReported.Wait(TimeSpan.FromSeconds(30)), "The reload failure was not reported to the event source.");
+ // The failure is observable through the event source even though no onError callback was supplied.
+ Assert.Equal("good", monitor.CurrentValue.Message);
+ }
+
+ [Fact]
+ public void ValidateOnChange_ReloadValidatorThrowsOwnCancellation_ReportsError()
+ {
+ var source = new ReloadSource();
+ var validator = new ReloadTestValidator { ThrowOwnCancellation = true };
+ var services = new ServiceCollection();
+ using var errored = new ManualResetEventSlim();
+ Exception? reportedError = null;
+ services.AddOptions()
+ .Configure(o => o.Message = "reloaded")
+ .ValidateOnChange(OptionsReloadValidationBehavior.KeepLastGood, (name, ex) => { reportedError = ex; errored.Set(); });
+ services.AddSingleton>(validator);
+ services.AddSingleton>(source);
+ using ServiceProvider sp = services.BuildServiceProvider();
+
+ SeedCache(sp, "good");
+ IOptionsMonitor monitor = sp.GetRequiredService>();
+
+ source.Trigger();
+
+ Assert.True(errored.Wait(TimeSpan.FromSeconds(30)), "A validator-originated cancellation was not reported as a failure.");
+ // A cancellation that does not originate from the monitor's own token is a genuine failure, not a supersession.
+ Assert.IsType(reportedError);
+ Assert.Equal("good", monitor.CurrentValue.Message);
+ }
+
+ [Fact]
+ public void ValidateOnChange_InvalidReload_FailReads_NextReadThrows()
+ {
+ var source = new ReloadSource();
+ var validator = new ReloadTestValidator { Fail = true };
+ var services = new ServiceCollection();
+ using var errored = new ManualResetEventSlim();
+ services.AddOptions()
+ .Configure(o => o.Message = "reloaded")
+ .ValidateOnChange(OptionsReloadValidationBehavior.FailReads, (name, ex) => errored.Set());
+ services.AddSingleton>(validator);
+ services.AddSingleton>(source);
+ using ServiceProvider sp = services.BuildServiceProvider();
+
+ SeedCache(sp, "good");
+ IOptionsMonitor