From dae92c8bfd46525ba2ed0659930a2dddbdaaf916 Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Tue, 6 Oct 2026 11:04:33 +0200 Subject: [PATCH] fix: preserve native constructor selection in diagnostics (#88) Keep ordinary type activation separate from DependsOn and decorator constructor rules. Add native-parity coverage for constructor ambiguity, attributes, contextual keys, optional defaults, constrained generics, lifetimes and ownership. Co-authored-by: Codex --- Changelog.md | 2 + README.md | 2 + ...TransientDisposables.Constructors.Tests.cs | 433 ++++++++++++++++++ ...lectionExtensions.KeyedActivation.Tests.cs | 4 +- ...ectIncorrectUsageOfTransientDisposables.cs | 8 +- .../NativeConstructorActivator.cs | 147 ++++++ .../ServiceProviderRegistrationSnapshot.cs | 22 + 7 files changed, 612 insertions(+), 6 deletions(-) create mode 100644 src/Mammoth.Extensions.DependencyInjection.Tests/DetectIncorrectUsageOfTransientDisposables.Constructors.Tests.cs create mode 100644 src/Mammoth.Extensions.DependencyInjection/NativeConstructorActivator.cs diff --git a/Changelog.md b/Changelog.md index f6dbec1..44befd2 100644 --- a/Changelog.md +++ b/Changelog.md @@ -8,6 +8,8 @@ ### Bug Fixes +- Preserve native DI constructor preference and parameter-type ambiguity rules when transient-disposable diagnostics instrument ordinary type registrations, including keyed context, optional defaults, null factory results and disposal ownership [#88](https://github.com/PrimordialCode/Mammoth.Extensions.DependencyInjection/issues/88). + - Build provider metadata on a private collection copy so repeated or failed builds leave caller registrations unchanged, avoid accumulating support services, and keep each provider's discovery and compatibility metadata independent [#64](https://github.com/PrimordialCode/Mammoth.Extensions.DependencyInjection/issues/64). - Decorate the last registration occurrence when the same ServiceDescriptor instance is added more than once, preserving keyed and unkeyed registration order, repeated decorator layers, lifetimes and disposal ownership [#63](https://github.com/PrimordialCode/Mammoth.Extensions.DependencyInjection/issues/63). diff --git a/README.md b/README.md index b4a5886..0f3df0b 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,8 @@ new HostBuilder().UseServiceProviderFactory(new ServiceProviderFactory( })); ``` +Ordinary implementation-type registrations retain native DI constructor preference and ambiguity rules when diagnostics are enabled, including keyed attributes and optional defaults. The `[ActivatorUtilitiesConstructor]` attribute does not override native type-registration selection; non-empty `DependsOn` maps retain their separate preferred-constructor rules. + Diagnostic messages format service keys that implement `IFormattable` with invariant culture on every library target. For example, a decimal key prints `1234.5` even under `fr-FR`, including when using the `netstandard2.0` library. Keys that supply only their own `ToString()` retain that method's formatting. Rejected transient factory results remain owned by the root provider until it is disposed. Results already captured by that root are not captured again. Dispose the provider even after a diagnostic failure; use `DisposeAsync` for async-only resources. diff --git a/src/Mammoth.Extensions.DependencyInjection.Tests/DetectIncorrectUsageOfTransientDisposables.Constructors.Tests.cs b/src/Mammoth.Extensions.DependencyInjection.Tests/DetectIncorrectUsageOfTransientDisposables.Constructors.Tests.cs new file mode 100644 index 0000000..d015284 --- /dev/null +++ b/src/Mammoth.Extensions.DependencyInjection.Tests/DetectIncorrectUsageOfTransientDisposables.Constructors.Tests.cs @@ -0,0 +1,433 @@ +using Mammoth.Extensions.DependencyInjection.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Mammoth.Extensions.DependencyInjection.Tests; + +[TestClass] +public class DiagnosticConstructorParityTests +{ + public static IEnumerable ValidCases() + { + foreach (var type in new[] { typeof(Preferred), typeof(MultiplePreferred), typeof(UnavailablePreferred), + typeof(Permuted), typeof(Subset), typeof(Repeated), typeof(Optional), typeof(RejectLonger) }) + foreach (var lifetime in new[] { ServiceLifetime.Transient, ServiceLifetime.Scoped, ServiceLifetime.Singleton }) + foreach (var keyed in new[] { false, true }) + foreach (var validate in new[] { false, true }) + yield return [type, lifetime, keyed, validate]; + } + + [TestMethod] + [DynamicData(nameof(ValidCases))] + public void OrdinaryTypeRegistrationsKeepNativeSelectionAndLifetime(Type type, ServiceLifetime lifetime, bool keyed, bool validate) + { + string? nativeChoice = null; + foreach (var mode in new[] { "native", "disabled", "enabled" }) + { + var counts = new Counts(); + var services = Services(counts); + Add(services, type, lifetime, keyed); + using var provider = Build(services, mode, validate); + Assert.AreEqual(0, counts.Dependencies, "Building must not activate dependencies."); + using var scope = provider.CreateScope(); + var first = Resolve(scope.ServiceProvider, type, keyed); + var again = Resolve(scope.ServiceProvider, type, keyed); + using var other = provider.CreateScope(); + var acrossScopes = Resolve(other.ServiceProvider, type, keyed); + nativeChoice ??= first.Selected; + Assert.AreEqual(nativeChoice, first.Selected, mode); + Assert.AreEqual(0, counts.Rejected, "Rejected candidates must not activate their dependencies."); + if (lifetime == ServiceLifetime.Transient) Assert.AreNotSame(first, again); + else Assert.AreSame(first, again); + if (lifetime == ServiceLifetime.Singleton) Assert.AreSame(first, acrossScopes); + else Assert.AreNotSame(first, acrossScopes); + } + } + + public static IEnumerable AmbiguousCases() + { + foreach (var type in new[] { typeof(DifferentParameters), typeof(EqualLengthDifferentParameters) }) + foreach (var lifetime in new[] { ServiceLifetime.Transient, ServiceLifetime.Scoped, ServiceLifetime.Singleton }) + foreach (var keyed in new[] { false, true }) + foreach (var validate in new[] { false, true }) + yield return [type, lifetime, keyed, validate]; + } + + [TestMethod] + [DynamicData(nameof(AmbiguousCases))] + public void OrdinaryTypeRegistrationsRejectNativeAmbiguityBeforeActivation(Type type, ServiceLifetime lifetime, bool keyed, bool validate) + { + foreach (var mode in new[] { "native", "disabled", "enabled" }) + { + var counts = new Counts(); + var services = Services(counts); + Add(services, type, lifetime, keyed); + if (validate) + { + var error = Assert.ThrowsExactly(() => Build(services, mode, validate)); + StringAssert.Contains(error.ToString(), "ambiguous"); + } + else + { + using var provider = Build(services, mode, validate); + using var scope = provider.CreateScope(); + var error = Assert.ThrowsExactly(() => Resolve(scope.ServiceProvider, type, keyed), mode); + StringAssert.Contains(error.Message, "ambiguous"); + } + Assert.AreEqual(0, counts.Dependencies, mode); + Assert.AreEqual(0, counts.Constructors, mode); + } + } + + [TestMethod] + [DataRow(false, false)] + [DataRow(false, true)] + [DataRow(true, false)] + [DataRow(true, true)] + public void ContextualAttributesPreserveNativeSelectionAndRequestedKey(bool wildcard, bool validate) + { + bool? nativeUsesRequestedKey = null; + foreach (var mode in new[] { "native", "disabled", "enabled" }) + { + var services = new ServiceCollection(); + var ordinary = new A(); + var inherited = new A(); + var explicitKey = new A(); + services.AddSingleton(ordinary); + services.AddKeyedSingleton("blue", inherited); + services.AddKeyedSingleton("fixed", explicitKey); + services.AddKeyedTransient(wildcard ? KeyedService.AnyKey : "blue"); + using var provider = Build(services, mode, validate); + var requestedKey = new string("blue".ToCharArray()); + var result = provider.GetRequiredKeyedService(requestedKey); + nativeUsesRequestedKey ??= ReferenceEquals(requestedKey, result.Key); + Assert.AreEqual(requestedKey, result.Key, mode); + Assert.AreEqual(nativeUsesRequestedKey.Value, ReferenceEquals(requestedKey, result.Key), mode); + Assert.AreSame(inherited, result.Inherited, mode); + Assert.AreSame(explicitKey, result.Explicit, mode); + Assert.AreSame(ordinary, result.Ordinary, mode); + Assert.AreEqual("long", result.Selected, mode); + } + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void UnkeyedServiceKeyAttributeUsesOrdinaryResolution(bool validate) + { + foreach (var mode in new[] { "native", "disabled", "enabled" }) + { + var services = new ServiceCollection(); + services.AddSingleton("ordinary"); + services.AddTransient(); + using var provider = Build(services, mode, validate); + Assert.AreEqual("ordinary", provider.GetRequiredService().Key, mode); + } + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void OptionalValuesAndRegisteredNullResultsMatchNative(bool keyed) + { + foreach (var mode in new[] { "native", "disabled", "enabled" }) + { + IServiceCollection services = new ServiceCollection(); + services.AddTransient(_ => null!); + services.AddKeyedTransient("missing", (_, _) => null!); + Add(services, typeof(DefaultValues), ServiceLifetime.Transient, keyed); + using var provider = Build(services, mode, validate: true); + var result = (DefaultValues)Resolve(provider, typeof(DefaultValues), keyed); + Assert.IsNull(result.Ordinary, mode); + Assert.IsNull(result.Keyed, mode); + Assert.AreEqual(7, result.Count, mode); + Assert.AreEqual(default(DateTime), result.Date, mode); + Assert.AreEqual(DayOfWeek.Friday, result.Day, mode); + Assert.IsNull(result.Optional, mode); + } + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void DisposableTypePathsKeepSelectionAndDisposalOwnership(bool keyed) + { + foreach (var mode in new[] { "native", "disabled", "enabled" }) + { + var counts = new Counts(); + var services = Services(counts); + Add(services, typeof(DisposablePreferred), ServiceLifetime.Transient, keyed); + DisposablePreferred result; + using (var provider = Build(services, mode, validate: true)) + { + using (var scope = provider.CreateScope()) + { + result = (DisposablePreferred)Resolve(scope.ServiceProvider, typeof(DisposablePreferred), keyed); + Assert.AreEqual("AB", result.Selected, mode); + Assert.AreEqual(0, result.Disposals, mode); + } + Assert.AreEqual(1, result.Disposals, mode); + } + Assert.AreEqual(1, result.Disposals, mode); + } + } + + [TestMethod] + [DataRow(false, false)] + [DataRow(false, true)] + [DataRow(true, false)] + [DataRow(true, true)] + public void NonemptyDependsOnRetainsItsPreferredConstructorContract(bool keyed, bool diagnostics) + { + var services = Services(new Counts()); + Dependency[] map = [Dependency.OnValue("a", new A())]; + if (keyed) services.AddKeyedTransient("blue", map); + else services.AddTransient(map); + using var provider = Build(services, diagnostics ? "enabled" : "disabled", validate: true); + Assert.AreEqual("A", Resolve(provider, typeof(Preferred), keyed).Selected); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void KeyedBuiltInsRequireAnExplicitRegistration(bool registered) + { + foreach (var mode in new[] { "native", "disabled", "enabled" }) + { + var services = new ServiceCollection(); + var explicitProvider = new EmptyProvider(); + if (registered) services.AddKeyedSingleton("blue", explicitProvider); + services.AddKeyedTransient("blue"); + using var provider = Build(services, mode, validate: false); + var result = provider.GetRequiredKeyedService("blue"); + Assert.AreEqual(registered ? "provider" : "empty", result.Selected, mode); + Assert.AreSame(registered ? explicitProvider : null, result.Provider, mode); + } + } + + [TestMethod] + public void InheritedOpenGenericWildcardDependencyParticipatesInSelection() + { + foreach (var mode in new[] { "native", "disabled", "enabled" }) + { + var services = new ServiceCollection(); + services.AddKeyedTransient(typeof(IOpen<>), KeyedService.AnyKey, typeof(Open<>)); + services.AddKeyedTransient("blue"); + using var provider = Build(services, mode, validate: true); + Assert.AreEqual("generic", provider.GetRequiredKeyedService("blue").Selected, mode); + } + } + + [TestMethod] + [DataRow(false, false)] + [DataRow(false, true)] + [DataRow(true, false)] + [DataRow(true, true)] + public void UnselectedGenericCandidatesValidateConstraintsWithoutActivation(bool wildcard, bool closedOverride) + { + foreach (var mode in new[] { "native", "disabled", "enabled" }) + { + var counts = new Counts(); + var services = Services(counts); + services.AddTransient>(_ => { counts.Dependencies++; return new Open(); }); + services.AddKeyedTransient(typeof(IOpen<>), wildcard ? KeyedService.AnyKey : "bad", typeof(ReferenceOnly<>)); + if (closedOverride) services.AddKeyedTransient, Open>("bad"); + services.AddTransient(); + using var provider = Build(services, mode, validate: false); + if (closedOverride) + Assert.AreEqual("long", provider.GetRequiredService().Selected, mode); + else + { + Assert.ThrowsExactly(() => provider.GetRequiredService(), mode); + Assert.AreEqual(0, counts.Dependencies, mode); + Assert.AreEqual(0, counts.Constructors, mode); + } + } + } + + [TestMethod] + public void OptionalStructDefaultsDoNotRunUserDefinedConstructors() + { + foreach (var mode in new[] { "native", "disabled", "enabled" }) + { + var services = new ServiceCollection(); + services.AddTransient(); + using var provider = Build(services, mode, validate: false); + Assert.AreEqual(0, provider.GetRequiredService().Value.Marker, mode); + } + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void ConstructorExceptionsRetainTheirOriginalType(bool keyed) + { + foreach (var mode in new[] { "native", "disabled", "enabled" }) + { + IServiceCollection services = new ServiceCollection(); + Add(services, typeof(Throwing), ServiceLifetime.Transient, keyed); + using var provider = Build(services, mode, validate: false); + Assert.ThrowsExactly(() => Resolve(provider, typeof(Throwing), keyed), mode); + } + } + + private static ServiceCollection Services(Counts counts) + { + var services = new ServiceCollection(); + services.AddSingleton(counts); + services.AddTransient(_ => { counts.Dependencies++; return new A(); }); + services.AddTransient(_ => { counts.Dependencies++; return new B(); }); + services.AddTransient(_ => { counts.Dependencies++; return new C(); }); + services.AddTransient(_ => { counts.Rejected++; return new Rejected(); }); + return services; + } + + private static void Add(IServiceCollection services, Type type, ServiceLifetime lifetime, bool keyed) + => services.Add(keyed ? ServiceDescriptor.DescribeKeyed(type, "blue", type, lifetime) + : ServiceDescriptor.Describe(type, type, lifetime)); + + private static IChoice Resolve(IServiceProvider provider, Type type, bool keyed) + => (IChoice)(keyed ? provider.GetRequiredKeyedService(type, "blue") : provider.GetRequiredService(type)); + + private static ServiceProvider Build(IServiceCollection services, string mode, bool validate) + => mode == "native" ? services.BuildServiceProvider(new ServiceProviderOptions { ValidateOnBuild = validate }) + : ServiceProviderFactory.CreateServiceProvider(services, new ExtendedServiceProviderOptions + { DetectIncorrectUsageOfTransientDisposables = mode == "enabled", ValidateOnBuild = validate }); + + public interface IChoice { string Selected { get; } } + public sealed class Counts { public int Dependencies; public int Rejected; public int Constructors; } + public sealed class A { } + public sealed class B { } + public sealed class C { } + public sealed class Missing { } + public sealed class Rejected { } + public sealed class Preferred : IChoice + { + public string Selected { get; } + [ActivatorUtilitiesConstructor] public Preferred(A a) => Selected = "A"; + public Preferred(A a, B b) => Selected = "AB"; + } + public sealed class MultiplePreferred : IChoice + { + public string Selected { get; } + [ActivatorUtilitiesConstructor] public MultiplePreferred(A a) => Selected = "A"; + [ActivatorUtilitiesConstructor] public MultiplePreferred(A a, B b) => Selected = "AB"; + } + public sealed class UnavailablePreferred : IChoice + { + public string Selected { get; } + [ActivatorUtilitiesConstructor] public UnavailablePreferred(Missing missing) => Selected = "missing"; + public UnavailablePreferred(A a) => Selected = "A"; + } + public sealed class Permuted : IChoice + { + public string Selected { get; } + public Permuted(A a, B b) => Selected = "AB"; + public Permuted(B b, A a) => Selected = "BA"; + } + public sealed class Subset : IChoice + { + public string Selected { get; } + public Subset(A a) => Selected = "A"; + public Subset(A a, B b) => Selected = "AB"; + } + public sealed class Repeated : IChoice + { + public string Selected { get; } + public Repeated(A a) => Selected = "A"; + public Repeated(A first, A second) => Selected = "AA"; + } + public sealed class Optional : IChoice + { + public string Selected { get; } + public Optional(A a) => Selected = "A"; + public Optional(A a, int count = 7) => Selected = "A:" + count; + } + public sealed class RejectLonger : IChoice + { + public string Selected { get; } + public RejectLonger(A a) => Selected = "A"; + public RejectLonger(Rejected rejected, Missing missing) => Selected = "missing"; + } + public sealed class DifferentParameters : IChoice + { + public string Selected => "unused"; + public DifferentParameters(Counts counts, A a, B b) => counts.Constructors++; + public DifferentParameters(Counts counts, C c) => counts.Constructors++; + } + public sealed class EqualLengthDifferentParameters : IChoice + { + public string Selected => "unused"; + [ActivatorUtilitiesConstructor] public EqualLengthDifferentParameters(Counts counts, A a) => counts.Constructors++; + public EqualLengthDifferentParameters(Counts counts, B b) => counts.Constructors++; + } + public sealed class Contextual : IChoice + { + public string Selected { get; } + public object Key { get; } + public A? Inherited { get; } + public A? Explicit { get; } + public A? Ordinary { get; } + [ActivatorUtilitiesConstructor] + public Contextual([ServiceKey] object key) { Key = key; Selected = "short"; } + public Contextual([ServiceKey] object key, [FromKeyedServices] A inherited, + [FromKeyedServices("fixed")] A explicitKey, [FromKeyedServices(null)] A ordinary) + { Key = key; Inherited = inherited; Explicit = explicitKey; Ordinary = ordinary; Selected = "long"; } + } + public sealed class UnkeyedContext([ServiceKey] string key) { public string Key { get; } = key; } + public sealed class DefaultValues(A? ordinary, [FromKeyedServices("missing")] A? keyed, + int count = 7, DateTime date = default, DayOfWeek? day = DayOfWeek.Friday, Missing? optional = null) : IChoice + { + public string Selected => "defaults"; + public A? Ordinary { get; } = ordinary; + public A? Keyed { get; } = keyed; + public int Count { get; } = count; + public DateTime Date { get; } = date; + public DayOfWeek? Day { get; } = day; + public Missing? Optional { get; } = optional; + } + public sealed class DisposablePreferred : IChoice, IDisposable + { + public string Selected { get; } + public int Disposals { get; private set; } + [ActivatorUtilitiesConstructor] public DisposablePreferred(A a) => Selected = "A"; + public DisposablePreferred(A a, B b) => Selected = "AB"; + public void Dispose() => Disposals++; + } + public sealed class EmptyProvider : IServiceProvider { public object? GetService(Type serviceType) => null; } + public sealed class KeyedBuiltIn : IChoice + { + public string Selected { get; } + public IServiceProvider? Provider { get; } + public KeyedBuiltIn() => Selected = "empty"; + public KeyedBuiltIn([FromKeyedServices("blue")] IServiceProvider provider) + { Provider = provider; Selected = "provider"; } + } + public interface IOpen { } + public sealed class Open : IOpen { } + public sealed class InheritedGeneric : IChoice + { + public string Selected { get; } + public InheritedGeneric() => Selected = "empty"; + public InheritedGeneric([FromKeyedServices] IOpen dependency) => Selected = "generic"; + } + public sealed class ReferenceOnly : IOpen where T : class { } + public sealed class ConstrainedCandidate : IChoice + { + public string Selected { get; } + public ConstrainedCandidate(Counts counts, IOpen generic, B b) + { counts.Constructors++; Selected = "long"; } + public ConstrainedCandidate(Counts counts, [FromKeyedServices("bad")] IOpen generic) + { counts.Constructors++; Selected = "short"; } + } + public readonly struct CustomDefault + { + public int Marker { get; } + public CustomDefault() => Marker = 42; + } + public sealed class StructDefault(CustomDefault value = default) { public CustomDefault Value { get; } = value; } + public sealed class Throwing : IChoice + { + public string Selected => "unused"; + public Throwing() => throw new ArgumentException("constructor failed"); + } +} diff --git a/src/Mammoth.Extensions.DependencyInjection.Tests/ServiceCollectionExtensions.KeyedActivation.Tests.cs b/src/Mammoth.Extensions.DependencyInjection.Tests/ServiceCollectionExtensions.KeyedActivation.Tests.cs index ec4b1b9..d658304 100644 --- a/src/Mammoth.Extensions.DependencyInjection.Tests/ServiceCollectionExtensions.KeyedActivation.Tests.cs +++ b/src/Mammoth.Extensions.DependencyInjection.Tests/ServiceCollectionExtensions.KeyedActivation.Tests.cs @@ -141,7 +141,7 @@ public void KeyedDecoratorUsesContextForFactoryAndCallerOwnedInstance(bool calle [TestMethod] [DataRow(false)] [DataRow(true)] - public void KeyAwareConstructorSelectionPreservesDefaultsAndPreferredConstructor(bool diagnostic) + public void KeyAwareConstructorSelectionPreservesDefaultsAndNativePreference(bool diagnostic) { var services = new ServiceCollection(); services.AddKeyedTransient("blue"); @@ -152,7 +152,7 @@ public void KeyAwareConstructorSelectionPreservesDefaultsAndPreferredConstructor var optional = scope.ServiceProvider.GetRequiredKeyedService("blue"); Assert.AreEqual("blue", optional.Key); Assert.AreEqual(7, optional.Count); Assert.IsNull(optional.Part); var preferred = scope.ServiceProvider.GetRequiredKeyedService("blue"); - Assert.AreEqual("blue", preferred.Key); Assert.AreEqual(diagnostic ? "preferred" : "long", preferred.Selected); + Assert.AreEqual("blue", preferred.Key); Assert.AreEqual("long", preferred.Selected); var alternative = scope.ServiceProvider.GetRequiredKeyedService("blue"); Assert.AreEqual("blue", alternative.Key); Assert.AreEqual("short", alternative.Selected); } diff --git a/src/Mammoth.Extensions.DependencyInjection/DetectIncorrectUsageOfTransientDisposables.cs b/src/Mammoth.Extensions.DependencyInjection/DetectIncorrectUsageOfTransientDisposables.cs index f224b94..3c506c8 100644 --- a/src/Mammoth.Extensions.DependencyInjection/DetectIncorrectUsageOfTransientDisposables.cs +++ b/src/Mammoth.Extensions.DependencyInjection/DetectIncorrectUsageOfTransientDisposables.cs @@ -68,7 +68,7 @@ public static IServiceCollection PatchForResolutionContextTracking(IServiceColle var previous = ResolutionContext.Push(descriptor); try { - return ActivatorUtilities.CreateInstance(sp, implementationType); + return NativeConstructorActivator.CreateInstance(sp, implementationType); } finally { @@ -126,7 +126,7 @@ public static IServiceCollection PatchForResolutionContextTracking(IServiceColle var previous = ResolutionContext.Push(descriptor); try { - return ConstructorActivator.CreateKeyed(sp, implementationType, key); + return NativeConstructorActivator.CreateInstance(sp, implementationType, key); } finally { @@ -304,7 +304,7 @@ bool allowSingletonToResolveTransientDisposables "ImplementationType is null."); } - return ActivatorUtilities.CreateInstance(sp, + return NativeConstructorActivator.CreateInstance(sp, original.ImplementationType); }, ServiceLifetime.Transient); @@ -330,7 +330,7 @@ bool allowSingletonToResolveTransientDisposables "KeyedImplementationType is null."); } - return ConstructorActivator.CreateKeyed(sp, + return NativeConstructorActivator.CreateInstance(sp, original.KeyedImplementationType, key); }, ServiceLifetime.Transient); diff --git a/src/Mammoth.Extensions.DependencyInjection/NativeConstructorActivator.cs b/src/Mammoth.Extensions.DependencyInjection/NativeConstructorActivator.cs new file mode 100644 index 0000000..d5e56c4 --- /dev/null +++ b/src/Mammoth.Extensions.DependencyInjection/NativeConstructorActivator.cs @@ -0,0 +1,147 @@ +using Microsoft.Extensions.DependencyInjection; +using System.Reflection; +using System.Runtime.ExceptionServices; + +namespace Mammoth.Extensions.DependencyInjection; + +// Diagnostics replaces ordinary implementation-type descriptors with tracking factories. +// Keep native DI's constructor rules separate from ActivatorUtilities / DependsOn rules. +internal static class NativeConstructorActivator +{ + internal static object CreateInstance(IServiceProvider provider, Type implementationType, object? serviceKey = null) + { + var probe = provider.GetRequiredService(); + var keyedProbe = provider.GetRequiredService(); + var constructors = implementationType.GetConstructors(); + // Use the same arity ordering as native DI, including its tie ordering. + Array.Sort(constructors, (left, right) => right.GetParameters().Length.CompareTo(left.GetParameters().Length)); + ConstructorInfo? selected = null; + Func[]? arguments = null; + HashSet? selectedTypes = null; + foreach (var constructor in constructors) + { + var parameters = constructor.GetParameters(); + var candidateArguments = PlanArguments(parameters); + if (candidateArguments == null) continue; + if (selected == null) + { + selected = constructor; + arguments = candidateArguments; + } + else + { + selectedTypes ??= new HashSet(selected.GetParameters().Select(parameter => parameter.ParameterType)); + // Native ambiguity compares parameter TYPE sets, not arity alone: a + // permutation/subset is valid; even a shorter non-subset is ambiguous. + if (parameters.Any(parameter => !selectedTypes.Contains(parameter.ParameterType))) + throw new InvalidOperationException($"Unable to activate type '{implementationType}'. The following constructors are ambiguous:{Environment.NewLine}{selected}{Environment.NewLine}{constructor}"); + } + } + if (selected == null) + throw new InvalidOperationException($"No satisfiable public constructor on {implementationType}."); + + // No service is activated until all constructor candidates have been checked. + var values = arguments!.Select(resolve => resolve()).ToArray(); + try + { + return selected.Invoke(values); + } + catch (TargetInvocationException error) when (error.InnerException != null) + { + ExceptionDispatchInfo.Capture(error.InnerException).Throw(); + throw; + } + + Func[]? PlanArguments(ParameterInfo[] parameters) + { + var result = new Func[parameters.Length]; + for (var index = 0; index < parameters.Length; index++) + { + var parameter = parameters[index]; + object? dependencyKey = null; + bool injectKey = false; + foreach (var attribute in parameter.GetCustomAttributes(true)) + { + if (serviceKey != null && attribute is ServiceKeyAttribute) + { + if (serviceKey != KeyedService.AnyKey && parameter.ParameterType != typeof(object) + && parameter.ParameterType != serviceKey.GetType()) + throw new InvalidOperationException("The ServiceKey parameter type must match the service key type or be object."); + injectKey = true; + break; + } + if (attribute is FromKeyedServicesAttribute fromKey) + { + dependencyKey = fromKey.LookupMode switch + { + ServiceKeyLookupMode.InheritKey => serviceKey, + ServiceKeyLookupMode.ExplicitKey => fromKey.Key, + _ => null + }; + if (dependencyKey != null) break; + } + } + if (injectKey) + result[index] = () => serviceKey; + else if (IsRegistered(parameter.ParameterType, dependencyKey)) + { + // A registered factory may return null. Defaults apply to missing + // registrations, never to the value returned by a registered service. + result[index] = dependencyKey == null + ? () => provider.GetService(parameter.ParameterType) + : () => ((IKeyedServiceProvider)provider).GetKeyedService(parameter.ParameterType, dependencyKey); + } + else if (TryGetDefaultValue(parameter, out var value)) + result[index] = () => value; + else + { + if (constructors.Length == 1) + throw new InvalidOperationException($"Unable to resolve service for type '{parameter.ParameterType}' while attempting to activate '{implementationType}'."); + return null; + } + } + return result; + } + + bool IsRegistered(Type type, object? key) + { + if (type.IsConstructedGenericType) + provider.GetRequiredService().ValidateGenericConstraints(type, key); + if (key == null) return probe.IsService(type); + // Native's public keyed probe reports built-ins for every key, although + // their implicit call sites are unkeyed. Explicit keyed registrations work. + if (type == typeof(IServiceProvider) || type == typeof(IServiceScopeFactory) + || type == typeof(IServiceProviderIsService) || type == typeof(IServiceProviderIsKeyedService)) + { + var snapshot = provider.GetRequiredService(); + return snapshot.GetLifetime(type, key) != null || snapshot.GetLifetime(type, KeyedService.AnyKey) != null; + } + // The DI 10 public probe does not check AnyKey open-generic fallback. + return keyedProbe.IsKeyedService(type, key) + || (type.IsConstructedGenericType && keyedProbe.IsKeyedService(type, KeyedService.AnyKey)); + } + } + + private static bool TryGetDefaultValue(ParameterInfo parameter, out object? value) + { + value = null; + bool hasDefault; + try { hasDefault = parameter.HasDefaultValue; } + catch (FormatException) when (parameter.ParameterType == typeof(DateTime)) + { + // .NET Framework reflection cannot read a default(DateTime) constant. + value = default(DateTime); + return true; + } + if (!hasDefault) return false; + value = parameter.DefaultValue; + var underlying = Nullable.GetUnderlyingType(parameter.ParameterType); + if (value == null && parameter.ParameterType.IsValueType && underlying == null) + // Array elements are zero-initialized even for structs with a public + // parameterless constructor; optional defaults must not run user code. + value = Array.CreateInstance(parameter.ParameterType, 1).GetValue(0); + if (value != null && underlying?.IsEnum == true) + value = Enum.ToObject(underlying, value); + return true; + } +} diff --git a/src/Mammoth.Extensions.DependencyInjection/ServiceProviderRegistrationSnapshot.cs b/src/Mammoth.Extensions.DependencyInjection/ServiceProviderRegistrationSnapshot.cs index cc7331b..e3260c9 100644 --- a/src/Mammoth.Extensions.DependencyInjection/ServiceProviderRegistrationSnapshot.cs +++ b/src/Mammoth.Extensions.DependencyInjection/ServiceProviderRegistrationSnapshot.cs @@ -9,6 +9,7 @@ internal sealed class ServiceProviderRegistrationSnapshot private readonly HashSet _keys = []; private readonly Dictionary> _keysByType = []; private readonly ServiceLifetimes _lifetimes = new(); + private readonly Dictionary _descriptors = []; internal ServiceProviderRegistrationSnapshot(IEnumerable descriptors, IEnumerable types, IEnumerable keys, Dictionary> keysByType) @@ -20,11 +21,32 @@ internal ServiceProviderRegistrationSnapshot(IEnumerable desc foreach (var descriptor in descriptors) { _lifetimes.Add(descriptor.ServiceType, descriptor.Lifetime, descriptor.ServiceKey); + _descriptors[ServiceIdentifier.FromDescriptor(descriptor)] = descriptor; } foreach (var entry in keysByType) _keysByType.Add(entry.Key, Array.AsReadOnly(entry.Value.ToArray())); } + internal void ValidateGenericConstraints(Type type, object? key) + { + if (!type.IsConstructedGenericType) return; + // Exact registrations (including wildcard keys) win over open generics. + if (Find(type, key) != null) return; + var descriptor = Find(type.GetGenericTypeDefinition(), key); + var implementation = descriptor?.IsKeyedService == true + ? descriptor.KeyedImplementationType : descriptor?.ImplementationType; + // Closing a candidate binding can throw even when another constructor is + // selected. Native DI checks this before activating any dependencies. + implementation?.MakeGenericType(type.GenericTypeArguments); + } + + private ServiceDescriptor? Find(Type type, object? key) + { + if (_descriptors.TryGetValue(new ServiceIdentifier(key, type), out var descriptor)) return descriptor; + return key != null && _descriptors.TryGetValue(new ServiceIdentifier(KeyedService.AnyKey, type), out descriptor) + ? descriptor : null; + } + internal bool ContainsType(Type type) => _types.Contains(type) || (type.IsConstructedGenericType && _types.Contains(type.GetGenericTypeDefinition())); internal bool ContainsKey(object key) => _keys.Contains(key);