From 9a0d9efe77459f391d53d722e9b068617d22edf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Sat, 1 Aug 2026 03:35:53 +0200 Subject: [PATCH] fix: resolve IEnumerable of an unregistered element type as an empty sequence MS.DI guarantees that `IEnumerable` resolves for every `T`, empty when nothing is registered, so consumers enumerate one without a null check and frameworks use a collection as their extension point. The provider-replacement path answered null instead, because the generator emits collection cases only for element types the graph mentions. The objection recorded against this gap was that matching MS.DI means building a collection for a type unknown at compile time. That holds for a non-empty collection; an empty one constructs no elements, only an array of the element type the caller already handed over, so it needs no compile-time expansion. An element type the container does have a registration for is excluded, so a collection with an async-tainted member still throws the container's guidance rather than quietly reporting nothing. Only `IEnumerable` is covered, matching MS.DI, which answers null for `T[]`, `IList` and `IReadOnlyList` of an unregistered element type as well. The keyed surface is untouched: MS.DI has no equivalent guarantee there. A value-typed element stays unreported, which is the one remaining divergence and an AOT constraint rather than a semantic one: manifesting the array needs the `T[]` type, which native AOT generates on demand for every reference element kind but not for a value one, where `Array.CreateInstance` throws `NotSupportedException`. --- Docs/pages/08-msdi-bridge.md | 6 +- Samples/Awaiten.AotSample/Domain/Banner.cs | 9 ++ Samples/Awaiten.AotSample/Domain/IClock.cs | 6 ++ Samples/Awaiten.AotSample/Domain/Report.cs | 19 ++++ .../Awaiten.AotSample/Domain/SystemClock.cs | 6 ++ .../Awaiten.AotSample/Domain/Unmentioned.cs | 6 ++ Samples/Awaiten.AotSample/Domain/Warmup.cs | 21 +++++ Samples/Awaiten.AotSample/Program.cs | 79 ++++------------ Samples/Awaiten.AotSample/SampleContainer.cs | 10 +++ .../AwaitenServiceProvider.cs | 90 ++++++++++++++++++- .../FeatureDetectionTests.cs | 20 +++-- .../MsDiConformanceTests.cs | 32 ++++++- 12 files changed, 230 insertions(+), 74 deletions(-) create mode 100644 Samples/Awaiten.AotSample/Domain/Banner.cs create mode 100644 Samples/Awaiten.AotSample/Domain/IClock.cs create mode 100644 Samples/Awaiten.AotSample/Domain/Report.cs create mode 100644 Samples/Awaiten.AotSample/Domain/SystemClock.cs create mode 100644 Samples/Awaiten.AotSample/Domain/Unmentioned.cs create mode 100644 Samples/Awaiten.AotSample/Domain/Warmup.cs create mode 100644 Samples/Awaiten.AotSample/SampleContainer.cs diff --git a/Docs/pages/08-msdi-bridge.md b/Docs/pages/08-msdi-bridge.md index ca7a41fe..5fd3caef 100644 --- a/Docs/pages/08-msdi-bridge.md +++ b/Docs/pages/08-msdi-bridge.md @@ -70,7 +70,11 @@ Wrapping a bare scope rather than a `Root` leaves the provider without metadata, `IsService` answers whether the service *exists*, not whether this scope will build it, which is MS.DI's own semantics. A disposable transient asked on the root is reported, and resolving it there throws the container's guidance naming the fix rather than returning `null`, so the failure lands at the cause. -Two answers still differ from MS.DI, both under-reporting, and both want a `[FromServices]` on the parameter. An [open generic](./registration/open-generics) registration is expanded per closing that something in the container's own graph asks for, so a closing only a framework asks for is never synthesized: with `[Singleton(typeof(Repo<>), typeof(IRepo<>))]` and nothing in the graph consuming `IRepo`, `IsService(typeof(IRepo))` is `false` where MS.DI answers `true`. And MS.DI special-cases `IEnumerable`, answering `true` for any `T` because it can always manifest an empty sequence, where a compile-time container has no case emitted for an element type the graph never mentioned. In both the probe is faithful to this container, which has no resolution either; matching MS.DI would mean building a collection for a type unknown at compile time. Each is pinned by a test in `FeatureDetectionTests`. +`IEnumerable` resolves for every `T`, empty when the container has no registration for the element type, matching the guarantee MS.DI consumers rely on: a collection is a framework's usual extension point, and "every registered handler, of which there may be none" has to answer rather than hand back `null`. The guarantee covers `IEnumerable` alone, exactly as in MS.DI, which answers `null` for `T[]`, `IList` and `IReadOnlyList` of an unregistered element type. A collection *whose* element type is registered is untouched by this: one that fails to resolve was withheld rather than absent, so it still throws the container's guidance instead of quietly reporting nothing. + +Two answers still under-report, and both want a `[FromServices]` on the parameter. An [open generic](./registration/open-generics) registration is expanded per closing that something in the container's own graph asks for, so a closing only a framework asks for is never synthesized: with `[Singleton(typeof(Repo<>), typeof(IRepo<>))]` and nothing in the graph consuming `IRepo`, `IsService(typeof(IRepo))` is `false` where MS.DI answers `true`. And an `IEnumerable` over a *value* element type the graph never mentioned stays unreported, which is a native-AOT constraint rather than a semantic one: the empty sequence needs the `T[]` type, which AOT generates on demand for a reference element type but not for a value one. In both the probe is faithful to what the bridge will do. Each is pinned by a test in `FeatureDetectionTests`. + +Writing an adapter for a framework whose dependency-injection surface is not MS.DI means honouring the same convention yourself, because it is the framework's expectation rather than MS.DI's implementation detail. A resolver that answers `null` for an unresolvable `IEnumerable` breaks any framework that uses a collection as an extension point, and the failure names the collection rather than the cause. Answer an empty array of the element type instead, and only for a reference element type, for the AOT reason above. ## Warm async services on startup diff --git a/Samples/Awaiten.AotSample/Domain/Banner.cs b/Samples/Awaiten.AotSample/Domain/Banner.cs new file mode 100644 index 00000000..4758f980 --- /dev/null +++ b/Samples/Awaiten.AotSample/Domain/Banner.cs @@ -0,0 +1,9 @@ +namespace Awaiten.AotSample.Domain; + +/// +/// A host-owned (external) service, registered directly in the service collection. +/// +public sealed class Banner +{ + public string Text { get; } = "Awaiten on AOT"; +} diff --git a/Samples/Awaiten.AotSample/Domain/IClock.cs b/Samples/Awaiten.AotSample/Domain/IClock.cs new file mode 100644 index 00000000..b7ef69ee --- /dev/null +++ b/Samples/Awaiten.AotSample/Domain/IClock.cs @@ -0,0 +1,6 @@ +namespace Awaiten.AotSample.Domain; + +public interface IClock +{ + string Today(); +} diff --git a/Samples/Awaiten.AotSample/Domain/Report.cs b/Samples/Awaiten.AotSample/Domain/Report.cs new file mode 100644 index 00000000..c13f319a --- /dev/null +++ b/Samples/Awaiten.AotSample/Domain/Report.cs @@ -0,0 +1,19 @@ +namespace Awaiten.AotSample.Domain; + +/// +/// A transient Awaiten service that mixes an Awaiten-owned dependency () with a +/// host-owned one (, resolved across the seam). +/// +public sealed class Report +{ + private readonly Banner _banner; + private readonly IClock _clock; + + public Report(IClock clock, Banner banner) + { + _clock = clock; + _banner = banner; + } + + public string Render() => $"{_banner.Text} @ {_clock.Today()}"; +} diff --git a/Samples/Awaiten.AotSample/Domain/SystemClock.cs b/Samples/Awaiten.AotSample/Domain/SystemClock.cs new file mode 100644 index 00000000..f31ea526 --- /dev/null +++ b/Samples/Awaiten.AotSample/Domain/SystemClock.cs @@ -0,0 +1,6 @@ +namespace Awaiten.AotSample.Domain; + +public sealed class SystemClock : IClock +{ + public string Today() => "2026-06-24"; +} diff --git a/Samples/Awaiten.AotSample/Domain/Unmentioned.cs b/Samples/Awaiten.AotSample/Domain/Unmentioned.cs new file mode 100644 index 00000000..769d78c7 --- /dev/null +++ b/Samples/Awaiten.AotSample/Domain/Unmentioned.cs @@ -0,0 +1,6 @@ +namespace Awaiten.AotSample.Domain; + +/// +/// A type the container never mentions, used to check the bridge's empty-collection answer. +/// +public sealed class Unmentioned; diff --git a/Samples/Awaiten.AotSample/Domain/Warmup.cs b/Samples/Awaiten.AotSample/Domain/Warmup.cs new file mode 100644 index 00000000..169116c8 --- /dev/null +++ b/Samples/Awaiten.AotSample/Domain/Warmup.cs @@ -0,0 +1,21 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Awaiten.AotSample.Domain; + +/// +/// An async-initialized Awaiten service. Because it is , the bridge +/// has no synchronous resolution path and projects it as Task<Warmup>, building the closed +/// Task<T> and its converter from generator-emitted metadata so it publishes natively +/// without reflection. +/// +public sealed class Warmup : IAsyncInitializable +{ + public bool Ready { get; private set; } + + public Task InitializeAsync(CancellationToken cancellationToken) + { + Ready = true; + return Task.CompletedTask; + } +} diff --git a/Samples/Awaiten.AotSample/Program.cs b/Samples/Awaiten.AotSample/Program.cs index 9de9b227..53a2fcb6 100644 --- a/Samples/Awaiten.AotSample/Program.cs +++ b/Samples/Awaiten.AotSample/Program.cs @@ -1,70 +1,13 @@ using System; -using System.Threading; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; -using Awaiten; +using Awaiten.AotSample.Domain; using Awaiten.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection; namespace Awaiten.AotSample; -public interface IClock -{ - string Today(); -} - -public sealed class SystemClock : IClock -{ - public string Today() => "2026-06-24"; -} - -/// A host-owned (external) service, registered directly in the service collection. -public sealed class Banner -{ - public string Text { get; } = "Awaiten on AOT"; -} - -/// -/// A transient Awaiten service that mixes an Awaiten-owned dependency () with a -/// host-owned one (, resolved across the seam). -/// -public sealed class Report -{ - private readonly IClock _clock; - private readonly Banner _banner; - - public Report(IClock clock, Banner banner) - { - _clock = clock; - _banner = banner; - } - - public string Render() => $"{_banner.Text} @ {_clock.Today()}"; -} - -/// -/// An async-initialized Awaiten service. Because it is , the bridge -/// has no synchronous resolution path and projects it as Task<Warmup>, building the closed -/// Task<T> and its converter from generator-emitted metadata so it publishes natively -/// without reflection. -/// -public sealed class Warmup : IAsyncInitializable -{ - public bool Ready { get; private set; } - - public Task InitializeAsync(CancellationToken cancellationToken) - { - Ready = true; - return Task.CompletedTask; - } -} - -[Container] -[ImportService] -[Singleton] -[Transient] -[Singleton] -public static partial class SampleContainer; - public static class Program { public static async Task Main() @@ -73,7 +16,7 @@ public static async Task Main() services.AddSingleton(); services.AddGeneratedContainer(); - using ServiceProvider provider = services.BuildServiceProvider(validateScopes: true); + using ServiceProvider provider = services.BuildServiceProvider(true); provider.VerifyAwaitenContainers(); using IServiceScope scope = provider.CreateScope(); @@ -86,6 +29,18 @@ public static async Task Main() Warmup warmup = await scope.ServiceProvider.GetRequiredService>(); Console.WriteLine($"warmup ready: {warmup.Ready}"); - return rendered == "Awaiten on AOT @ 2026-06-24" && warmup.Ready ? 0 : 1; + // The provider-replacement path's empty-collection answer, and the one place the bridge constructs a type + // at run time: the T[] backing IEnumerable for an element type the container never saw. Native AOT + // generates that array type on demand for a reference element type, which is why the bridge suppresses the + // dynamic-code warning there, and running it here is what keeps that suppression honest rather than + // asserted. + SampleContainer.Root root = provider.GetRequiredService(); + using AwaitenServiceProvider replacement = new(root, false); + IEnumerable? unmentioned = + (IEnumerable?)replacement.GetService(typeof(IEnumerable)); + bool emptySequence = unmentioned is not null && !unmentioned.Any(); + Console.WriteLine($"empty sequence for an unmentioned element type: {emptySequence}"); + + return rendered == "Awaiten on AOT @ 2026-06-24" && warmup.Ready && emptySequence ? 0 : 1; } } diff --git a/Samples/Awaiten.AotSample/SampleContainer.cs b/Samples/Awaiten.AotSample/SampleContainer.cs new file mode 100644 index 00000000..bfd416a7 --- /dev/null +++ b/Samples/Awaiten.AotSample/SampleContainer.cs @@ -0,0 +1,10 @@ +using Awaiten.AotSample.Domain; + +namespace Awaiten.AotSample; + +[Container] +[ImportService] +[Singleton] +[Transient] +[Singleton] +public static partial class SampleContainer; diff --git a/Source/Awaiten.Extensions.DependencyInjection/AwaitenServiceProvider.cs b/Source/Awaiten.Extensions.DependencyInjection/AwaitenServiceProvider.cs index 1acb0a8a..e9789586 100644 --- a/Source/Awaiten.Extensions.DependencyInjection/AwaitenServiceProvider.cs +++ b/Source/Awaiten.Extensions.DependencyInjection/AwaitenServiceProvider.cs @@ -13,7 +13,9 @@ namespace Awaiten.Extensions.DependencyInjection; /// , returning for an unregistered /// service as requires. A service that requires asynchronous resolution /// (advertised through ) is served as a Task<T> -/// (request Task<TService> and await it), mirroring the collection projection. +/// (request Task<TService> and await it), mirroring the collection projection. An +/// IEnumerable<T> the container has no registration for is the one exception to the null: it +/// resolves to an empty sequence for a reference element type, as MS.DI guarantees for every element type. /// /// /// @@ -107,6 +109,14 @@ private AwaitenServiceProvider(IAwaitenScope scope, IAwaitenContainerMetadata? m return _container.Resolve(serviceType); } + // MS.DI resolves IEnumerable for every T, so a consumer enumerates one without a null check and a + // framework uses it as an extension point. See EmptyEnumerableElement for which element types the bridge + // can honour that for. + if (EmptyEnumerableElement(serviceType) is { } elementType) + { + return EmptyArrayOf(elementType); + } + return null; } @@ -198,7 +208,11 @@ public bool IsService(Type serviceType) throw new ArgumentNullException(nameof(serviceType)); } - return IsProviderService(serviceType) || IsResolvableShape(serviceType, null); + // The empty-sequence answer is unkeyed only, matching GetService: MS.DI's keyed surface has no equivalent + // guarantee, and IsResolvableShape is shared with the keyed probe. + return IsProviderService(serviceType) + || IsResolvableShape(serviceType, null) + || EmptyEnumerableElement(serviceType) is not null; } /// @@ -298,6 +312,78 @@ private bool IsResolvableShape(Type serviceType, object? key) && AsyncConverterFor(serviceType.GenericTypeArguments[0], key) is not null; } + /// + /// The element type of an IEnumerable<T> the container has no registration for, which the + /// bridge answers with an empty sequence, or when the shape is not one of those. + /// + /// + /// + /// MS.DI resolves IEnumerable<T> for every T, empty when nothing is registered, so + /// consumers enumerate one without a null check and frameworks use it as an extension point ("every + /// registered handler, of which there may be none"). The generator emits collection cases only for + /// element types the graph mentions, so one it never saw has no case to hit, and returning + /// there breaks such a consumer on its first enumeration. + /// + /// + /// Only IEnumerable<T> qualifies, because that is the extent of MS.DI's guarantee: it + /// answers for T[], IList<T> and + /// IReadOnlyList<T> of an unregistered element type as well. + /// + /// + /// An element type the container does have a registration for is excluded. A collection of it that did + /// not resolve was withheld rather than absent (a collection with an async-tainted member cannot be + /// materialized synchronously), and an empty sequence would both hide the container's guidance and + /// silently drop the members that do exist, which is the worst answer available. + /// + /// + /// A value-typed element is excluded too, and that is an AOT constraint rather than a semantic one. + /// Manifesting the empty array needs the T[] type, which native AOT generates on demand for a + /// reference element type but not for a value one, where it throws + /// at run time. Reporting the shape as unavailable is honest; + /// trading a for a crash would not be. + /// + /// + private Type? EmptyEnumerableElement(Type serviceType) + { + if (_metadata is null + || !serviceType.IsConstructedGenericType + || serviceType.GetGenericTypeDefinition() != typeof(IEnumerable<>)) + { + return null; + } + + Type elementType = serviceType.GenericTypeArguments[0]; + if (elementType.IsValueType) + { + return null; + } + + foreach (AwaitenRegistration registration in _metadata.Registrations) + { + if (registration.ServiceType == elementType) + { + return null; + } + } + + return elementType; + } + + /// The empty T[] for a reference element type, which is an IEnumerable<T>. + /// + /// The only place either shipped assembly constructs a type at run time. Native AOT generates the array + /// type on demand for every reference element kind (interface, class, abstract class, generic closing, + /// string), and only a value-typed element fails, which has already + /// excluded. So the dynamic-code warning does not apply to the calls that reach here. + /// +#if NET8_0_OR_GREATER + // Conditional because UnconditionalSuppressMessageAttribute is not public in netstandard2.0, and nothing + // publishes that target with native AOT, so the suppression has nowhere to apply there. + [UnconditionalSuppressMessage("AotAnalysis", "IL3050:RequiresDynamicCode", + Justification = "The element type is always a reference type here, whose array type native AOT generates on demand; a value-typed element is excluded by EmptyEnumerableElement.")] +#endif + private static Array EmptyArrayOf(Type elementType) => Array.CreateInstance(elementType, 0); + /// /// The generator-emitted Task<object> to Task<T> converter for an unkeyed /// async-advertised service type, or null when the type is not an async registration. diff --git a/Tests/Awaiten.Extensions.DependencyInjection.Tests/FeatureDetectionTests.cs b/Tests/Awaiten.Extensions.DependencyInjection.Tests/FeatureDetectionTests.cs index 068ad098..73a1b654 100644 --- a/Tests/Awaiten.Extensions.DependencyInjection.Tests/FeatureDetectionTests.cs +++ b/Tests/Awaiten.Extensions.DependencyInjection.Tests/FeatureDetectionTests.cs @@ -390,8 +390,10 @@ public async Task EveryShapeOverARegisteredElementTypeIsReported() await That(provider.IsService(typeof(ICollection))).IsTrue(); await That(provider.IsService(typeof(IThing[]))).IsTrue(); await That(provider.IsService(typeof(IAsyncEnumerable))).IsTrue(); - await That(provider.IsService(typeof(IEnumerable))).IsFalse() - .Because("the container has no collection case for an element type the graph never mentioned"); + await That(provider.IsService(typeof(IEnumerable))).IsTrue() + .Because("the container has no collection case for an element type the graph never mentioned, and the bridge answers that shape with the empty sequence MS.DI guarantees for every element type"); + await That(provider.IsService(typeof(Unregistered[]))).IsFalse() + .Because("the guarantee covers IEnumerable only, so the other shapes over an unmentioned element type stay unreported"); } [Fact] @@ -755,15 +757,19 @@ public static partial class OpenGenericContainer; public static partial class SingleRegistrationContainer; [Fact] - public async Task AnEnumerableOfAnUnmentionedElementTypeIsUnderReported() + public async Task AnEnumerableOfAnUnmentionedValueElementTypeIsUnderReported() { using SingleRegistrationContainer.Root container = new(); using AwaitenServiceProvider provider = new(container, ownsContainer: false); - await That(provider.IsService(typeof(IEnumerable))).IsFalse() - .Because("MS.DI special-cases IEnumerable and answers true for any T, registered or not, because it can always manifest an empty sequence; the generator emits collection cases only for element types the graph mentions, so this one has no resolution to report"); - await That(provider.GetService(typeof(IEnumerable))).IsNull() - .Because("the under-report is faithful to the container: MS.DI would hand back an empty sequence here, and matching that needs a collection built for a type unknown at compile time, which is the reflection this package does not do"); + await That(provider.IsService(typeof(IEnumerable))).IsFalse() + .Because("MS.DI answers true for any element type, because it can always manifest an empty sequence; the bridge can only do so for a reference element type, whose array type native AOT generates on demand, and a value one throws NotSupportedException there instead"); + await That(provider.GetService(typeof(IEnumerable))).IsNull() + .Because("the under-report is faithful to what the bridge will do: reporting a shape it would crash on under AOT would be the worse trade"); + + await That(ProbeAgreement.Disagreements(provider, [typeof(IEnumerable), typeof(IEnumerable),])) + .IsEqualTo(string.Empty) + .Because("the residue is an under-report on both sides at once, so the probe and resolution still agree; the reference-element case beside it is answered rather than under-reported"); } [Fact] diff --git a/Tests/Awaiten.Extensions.DependencyInjection.Tests/MsDiConformanceTests.cs b/Tests/Awaiten.Extensions.DependencyInjection.Tests/MsDiConformanceTests.cs index 669888f2..8baeac67 100644 --- a/Tests/Awaiten.Extensions.DependencyInjection.Tests/MsDiConformanceTests.cs +++ b/Tests/Awaiten.Extensions.DependencyInjection.Tests/MsDiConformanceTests.cs @@ -155,15 +155,43 @@ await That(resolved).IsSameAs(scope.ServiceProvider) public sealed class AbsentServices { [Fact] - public async Task AnEnumerableOfAnUnmentionedTypeReturnsNull_KnownGap() + public async Task AnEnumerableOfAnUnmentionedTypeIsEmpty() { using ConformanceContainer.Root container = new(); using AwaitenServiceProvider provider = new(container, false); object? resolved = provider.GetService(typeof(IEnumerable)); + await That(resolved).IsNotNull() + .Because("MS.DI guarantees IEnumerable resolves for any T, so consumers enumerate without a null check and frameworks use it as an extension point"); + await That((IEnumerable)resolved!).IsEmpty() + .Because("nothing is registered for the element type, and the empty sequence is what MS.DI hands back"); + } + + [Fact] + public async Task AnEnumerableOfAnUnmentionedValueTypeReturnsNull_AotConstraint() + { + using ConformanceContainer.Root container = new(); + using AwaitenServiceProvider provider = new(container, false); + + object? resolved = provider.GetService(typeof(IEnumerable)); + await That(resolved).IsNull() - .Because("MS.DI guarantees IEnumerable resolves to an empty sequence for any T, so consumers enumerate without a null check; the generator only emits collection cases for element types the graph mentions, so one it never saw has no case to hit. Pinned so that closing the gap breaks this test rather than passing unnoticed"); + .Because("manifesting the empty sequence needs the T[] type, which native AOT generates on demand for a reference element type but not for a value one, where it throws NotSupportedException at run time; reporting the shape as unavailable beats trading a null for a crash. Pinned so that a change of behaviour fails here rather than surfacing as an AOT crash"); + } + + [Fact] + public async Task AnArrayOfAnUnmentionedTypeReturnsNull() + { + using ConformanceContainer.Root container = new(); + using AwaitenServiceProvider provider = new(container, false); + + await That(provider.GetService(typeof(UnregisteredService[]))).IsNull() + .Because("MS.DI's guarantee covers IEnumerable only; it answers null for an array of an unregistered element type too"); + await That(provider.GetService(typeof(IList))).IsNull() + .Because("the same limit applies to the other collection shapes MS.DI does not synthesize"); + await That(provider.GetService(typeof(IReadOnlyList))).IsNull() + .Because("the same limit applies to the other collection shapes MS.DI does not synthesize"); } [Fact]