Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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<OptionalWork>("blue");
Expand All @@ -152,7 +152,7 @@ public void KeyAwareConstructorSelectionPreservesDefaultsAndPreferredConstructor
var optional = scope.ServiceProvider.GetRequiredKeyedService<OptionalWork>("blue");
Assert.AreEqual("blue", optional.Key); Assert.AreEqual(7, optional.Count); Assert.IsNull(optional.Part);
var preferred = scope.ServiceProvider.GetRequiredKeyedService<PreferredWork>("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<AlternativeWork>("blue");
Assert.AreEqual("blue", alternative.Key); Assert.AreEqual("short", alternative.Selected);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -304,7 +304,7 @@ bool allowSingletonToResolveTransientDisposables
"ImplementationType is null.");
}

return ActivatorUtilities.CreateInstance(sp,
return NativeConstructorActivator.CreateInstance(sp,
original.ImplementationType);
},
ServiceLifetime.Transient);
Expand All @@ -330,7 +330,7 @@ bool allowSingletonToResolveTransientDisposables
"KeyedImplementationType is null.");
}

return ConstructorActivator.CreateKeyed(sp,
return NativeConstructorActivator.CreateInstance(sp,
original.KeyedImplementationType, key);
},
ServiceLifetime.Transient);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<IServiceProviderIsService>();
var keyedProbe = provider.GetRequiredService<IServiceProviderIsKeyedService>();
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<object?>[]? arguments = null;
HashSet<Type>? 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<Type>(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<object?>[]? PlanArguments(ParameterInfo[] parameters)
{
var result = new Func<object?>[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<ServiceProviderRegistrationSnapshot>().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<ServiceProviderRegistrationSnapshot>();
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ internal sealed class ServiceProviderRegistrationSnapshot
private readonly HashSet<object> _keys = [];
private readonly Dictionary<Type, IReadOnlyCollection<object>> _keysByType = [];
private readonly ServiceLifetimes _lifetimes = new();
private readonly Dictionary<ServiceIdentifier, ServiceDescriptor> _descriptors = [];

internal ServiceProviderRegistrationSnapshot(IEnumerable<ServiceDescriptor> descriptors,
IEnumerable<Type> types, IEnumerable<object> keys, Dictionary<Type, HashSet<object>> keysByType)
Expand All @@ -20,11 +21,32 @@ internal ServiceProviderRegistrationSnapshot(IEnumerable<ServiceDescriptor> 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);
Expand Down