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
6 changes: 5 additions & 1 deletion Docs/pages/08-msdi-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Order>`, `IsService(typeof(IRepo<Order>))` is `false` where MS.DI answers `true`. And MS.DI special-cases `IEnumerable<T>`, 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<T>` 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<T>` alone, exactly as in MS.DI, which answers `null` for `T[]`, `IList<T>` and `IReadOnlyList<T>` 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<Order>`, `IsService(typeof(IRepo<Order>))` is `false` where MS.DI answers `true`. And an `IEnumerable<T>` 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<T>` 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

Expand Down
9 changes: 9 additions & 0 deletions Samples/Awaiten.AotSample/Domain/Banner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Awaiten.AotSample.Domain;

/// <summary>
/// A host-owned (external) service, registered directly in the service collection.
/// </summary>
public sealed class Banner
{
public string Text { get; } = "Awaiten on AOT";
}
6 changes: 6 additions & 0 deletions Samples/Awaiten.AotSample/Domain/IClock.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Awaiten.AotSample.Domain;

public interface IClock
{
string Today();
}
19 changes: 19 additions & 0 deletions Samples/Awaiten.AotSample/Domain/Report.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace Awaiten.AotSample.Domain;

/// <summary>
/// A transient Awaiten service that mixes an Awaiten-owned dependency (<see cref="IClock" />) with a
/// host-owned one (<see cref="Banner" />, resolved across the seam).
/// </summary>
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()}";
}
6 changes: 6 additions & 0 deletions Samples/Awaiten.AotSample/Domain/SystemClock.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Awaiten.AotSample.Domain;

public sealed class SystemClock : IClock
{
public string Today() => "2026-06-24";
}
6 changes: 6 additions & 0 deletions Samples/Awaiten.AotSample/Domain/Unmentioned.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Awaiten.AotSample.Domain;

/// <summary>
/// A type the container never mentions, used to check the bridge's empty-collection answer.
/// </summary>
public sealed class Unmentioned;
21 changes: 21 additions & 0 deletions Samples/Awaiten.AotSample/Domain/Warmup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System.Threading;
using System.Threading.Tasks;

namespace Awaiten.AotSample.Domain;

/// <summary>
/// An async-initialized Awaiten service. Because it is <see cref="IAsyncInitializable" />, the bridge
/// has no synchronous resolution path and projects it as <c>Task&lt;Warmup&gt;</c>, building the closed
/// <c>Task&lt;T&gt;</c> and its converter from generator-emitted metadata so it publishes natively
/// without reflection.
/// </summary>
public sealed class Warmup : IAsyncInitializable
{
public bool Ready { get; private set; }

public Task InitializeAsync(CancellationToken cancellationToken)
{
Ready = true;
return Task.CompletedTask;
}
}
79 changes: 17 additions & 62 deletions Samples/Awaiten.AotSample/Program.cs
Original file line number Diff line number Diff line change
@@ -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";
}

/// <summary>A host-owned (external) service, registered directly in the service collection.</summary>
public sealed class Banner
{
public string Text { get; } = "Awaiten on AOT";
}

/// <summary>
/// A transient Awaiten service that mixes an Awaiten-owned dependency (<see cref="IClock" />) with a
/// host-owned one (<see cref="Banner" />, resolved across the seam).
/// </summary>
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()}";
}

/// <summary>
/// An async-initialized Awaiten service. Because it is <see cref="IAsyncInitializable" />, the bridge
/// has no synchronous resolution path and projects it as <c>Task&lt;Warmup&gt;</c>, building the closed
/// <c>Task&lt;T&gt;</c> and its converter from generator-emitted metadata so it publishes natively
/// without reflection.
/// </summary>
public sealed class Warmup : IAsyncInitializable
{
public bool Ready { get; private set; }

public Task InitializeAsync(CancellationToken cancellationToken)
{
Ready = true;
return Task.CompletedTask;
}
}

[Container]
[ImportService<Banner>]
[Singleton<SystemClock, IClock>]
[Transient<Report>]
[Singleton<Warmup>]
public static partial class SampleContainer;

public static class Program
{
public static async Task<int> Main()
Expand All @@ -73,7 +16,7 @@ public static async Task<int> Main()
services.AddSingleton<Banner>();
services.AddGeneratedContainer<SampleContainer.Root>();

using ServiceProvider provider = services.BuildServiceProvider(validateScopes: true);
using ServiceProvider provider = services.BuildServiceProvider(true);
provider.VerifyAwaitenContainers();

using IServiceScope scope = provider.CreateScope();
Expand All @@ -86,6 +29,18 @@ public static async Task<int> Main()
Warmup warmup = await scope.ServiceProvider.GetRequiredService<Task<Warmup>>();
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<T> 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<SampleContainer.Root>();
using AwaitenServiceProvider replacement = new(root, false);
IEnumerable<Unmentioned>? unmentioned =
(IEnumerable<Unmentioned>?)replacement.GetService(typeof(IEnumerable<Unmentioned>));
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;
}
}
10 changes: 10 additions & 0 deletions Samples/Awaiten.AotSample/SampleContainer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using Awaiten.AotSample.Domain;

namespace Awaiten.AotSample;

[Container]
[ImportService<Banner>]
[Singleton<SystemClock, IClock>]
[Transient<Report>]
[Singleton<Warmup>]
public static partial class SampleContainer;
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ namespace Awaiten.Extensions.DependencyInjection;
/// <see cref="IAwaitenResolver.TryResolve(Type, out object)" />, returning <see langword="null" /> for an unregistered
/// service as <see cref="IServiceProvider" /> requires. A service that requires asynchronous resolution
/// (advertised through <see cref="IAwaitenContainerMetadata" />) is served as a <c>Task&lt;T&gt;</c>
/// (request <c>Task&lt;TService&gt;</c> and await it), mirroring the collection projection.
/// (request <c>Task&lt;TService&gt;</c> and await it), mirroring the collection projection. An
/// <c>IEnumerable&lt;T&gt;</c> 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.
/// </summary>
/// <remarks>
/// <para>
Expand Down Expand Up @@ -107,6 +109,14 @@ private AwaitenServiceProvider(IAwaitenScope scope, IAwaitenContainerMetadata? m
return _container.Resolve(serviceType);
}

// MS.DI resolves IEnumerable<T> 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;
}

Expand Down Expand Up @@ -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;
}

/// <inheritdoc />
Expand Down Expand Up @@ -298,6 +312,78 @@ private bool IsResolvableShape(Type serviceType, object? key)
&& AsyncConverterFor(serviceType.GenericTypeArguments[0], key) is not null;
}

/// <summary>
/// The element type of an <c>IEnumerable&lt;T&gt;</c> the container has no registration for, which the
/// bridge answers with an empty sequence, or <see langword="null" /> when the shape is not one of those.
/// </summary>
/// <remarks>
/// <para>
/// MS.DI resolves <c>IEnumerable&lt;T&gt;</c> for every <c>T</c>, 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
/// <see langword="null" /> there breaks such a consumer on its first enumeration.
/// </para>
/// <para>
/// Only <c>IEnumerable&lt;T&gt;</c> qualifies, because that is the extent of MS.DI's guarantee: it
/// answers <see langword="null" /> for <c>T[]</c>, <c>IList&lt;T&gt;</c> and
/// <c>IReadOnlyList&lt;T&gt;</c> of an unregistered element type as well.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// A value-typed element is excluded too, and that is an AOT constraint rather than a semantic one.
/// Manifesting the empty array needs the <c>T[]</c> type, which native AOT generates on demand for a
/// reference element type but not for a value one, where it throws
/// <see cref="NotSupportedException" /> at run time. Reporting the shape as unavailable is honest;
/// trading a <see langword="null" /> for a crash would not be.
/// </para>
/// </remarks>
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;
}

/// <summary>The empty <c>T[]</c> for a reference element type, which is an <c>IEnumerable&lt;T&gt;</c>.</summary>
/// <remarks>
/// 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 <see cref="EmptyEnumerableElement" /> has already
/// excluded. So the dynamic-code warning does not apply to the calls that reach here.
/// </remarks>
#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);

/// <summary>
/// The generator-emitted <c>Task&lt;object&gt;</c> to <c>Task&lt;T&gt;</c> converter for an unkeyed
/// async-advertised service type, or null when the type is not an async registration.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -390,8 +390,10 @@ public async Task EveryShapeOverARegisteredElementTypeIsReported()
await That(provider.IsService(typeof(ICollection<IThing>))).IsTrue();
await That(provider.IsService(typeof(IThing[]))).IsTrue();
await That(provider.IsService(typeof(IAsyncEnumerable<IThing>))).IsTrue();
await That(provider.IsService(typeof(IEnumerable<Unregistered>))).IsFalse()
.Because("the container has no collection case for an element type the graph never mentioned");
await That(provider.IsService(typeof(IEnumerable<Unregistered>))).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<T> only, so the other shapes over an unmentioned element type stay unreported");
}

[Fact]
Expand Down Expand Up @@ -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<Unregistered>))).IsFalse()
.Because("MS.DI special-cases IEnumerable<T> 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<Unregistered>))).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<Speed>))).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<Speed>))).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<Speed>), typeof(IEnumerable<Unregistered>),]))
.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]
Expand Down
Loading
Loading