From a728756a942abbfd3dde60d4faf23b7bc2e7711f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Fri, 3 Jul 2026 20:20:06 +0200 Subject: [PATCH 1/2] feat: initialize Awaiten containers on host startup via AddAwaitenInitialization Add AddAwaitenInitialization, which registers an IHostedService that calls the generated container root's InitializeAsync on application startup, warming its async-initialized singletons in dependency order before the host begins serving, and honoring the host's startup CancellationToken. The companion now references Microsoft.Extensions.Hosting.Abstractions for IHostedService. A container that hosts async-initialized services through MS.DI pairs this with SyncResolveAfterInit so the warmed services can then be resolved synchronously through the relay. Adds a generic-host test across net8.0/net10.0/net48 (built with HostBuilder so it runs on net48) that observes the async singleton's initialization count directly - zero before startup, one after the host starts - so it proves the hosted service drove the warm-up rather than a later synchronous resolve, and updates the companion public-API baseline. --- Directory.Packages.props | 2 + ...iten.Extensions.DependencyInjection.csproj | 1 + ...itializationServiceCollectionExtensions.cs | 48 +++++++++++++++++ ...Extensions.DependencyInjection_net10.0.txt | 5 ++ ....Extensions.DependencyInjection_net8.0.txt | 5 ++ ...ons.DependencyInjection_netstandard2.0.txt | 5 ++ ...xtensions.DependencyInjection.Tests.csproj | 1 + .../HostInitializationTests.cs | 52 +++++++++++++++++++ 8 files changed, 119 insertions(+) create mode 100644 Source/Awaiten.Extensions.DependencyInjection/AwaitenInitializationServiceCollectionExtensions.cs create mode 100644 Tests/Awaiten.Extensions.DependencyInjection.Tests/HostInitializationTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 85940ab..7ad127c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,6 +11,8 @@ + + diff --git a/Source/Awaiten.Extensions.DependencyInjection/Awaiten.Extensions.DependencyInjection.csproj b/Source/Awaiten.Extensions.DependencyInjection/Awaiten.Extensions.DependencyInjection.csproj index 8b0e1f1..dfd5248 100644 --- a/Source/Awaiten.Extensions.DependencyInjection/Awaiten.Extensions.DependencyInjection.csproj +++ b/Source/Awaiten.Extensions.DependencyInjection/Awaiten.Extensions.DependencyInjection.csproj @@ -10,6 +10,7 @@ + diff --git a/Source/Awaiten.Extensions.DependencyInjection/AwaitenInitializationServiceCollectionExtensions.cs b/Source/Awaiten.Extensions.DependencyInjection/AwaitenInitializationServiceCollectionExtensions.cs new file mode 100644 index 0000000..d472608 --- /dev/null +++ b/Source/Awaiten.Extensions.DependencyInjection/AwaitenInitializationServiceCollectionExtensions.cs @@ -0,0 +1,48 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Awaiten.Extensions.DependencyInjection; + +/// +/// Registers a hosted service that initializes a generated Awaiten container on application startup, +/// warming its async-initialized singletons in dependency order (see ) +/// before the host begins serving. +/// +public static class AwaitenInitializationServiceCollectionExtensions +{ + /// + /// Adds an that calls InitializeAsync on the + /// registered with + /// , + /// honoring the host's startup . Register the container first; a + /// container that hosts async-initialized services through MS.DI should also opt into + /// SyncResolveAfterInit so the warmed services can then be resolved synchronously. + /// + /// The generated Awaiten container root type. + public static IServiceCollection AddAwaitenInitialization(this IServiceCollection services) + where TContainer : class, IAwaitenContainerMetadata + { + if (services is null) + { + throw new ArgumentNullException(nameof(services)); + } + + services.AddSingleton>(); + return services; + } + + private sealed class AwaitenInitializationHostedService : IHostedService + where TContainer : class, IAwaitenContainerMetadata + { + private readonly TContainer _container; + + public AwaitenInitializationHostedService(TContainer container) => _container = container; + + public Task StartAsync(CancellationToken cancellationToken) => _container.InitializeAsync(cancellationToken); + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } +} diff --git a/Tests/Awaiten.Api.Tests/Expected/Awaiten.Extensions.DependencyInjection_net10.0.txt b/Tests/Awaiten.Api.Tests/Expected/Awaiten.Extensions.DependencyInjection_net10.0.txt index dd4cc30..6963897 100644 --- a/Tests/Awaiten.Api.Tests/Expected/Awaiten.Extensions.DependencyInjection_net10.0.txt +++ b/Tests/Awaiten.Api.Tests/Expected/Awaiten.Extensions.DependencyInjection_net10.0.txt @@ -2,6 +2,11 @@ [assembly: System.Runtime.Versioning.TargetFramework(".NETCoreApp,Version=v10.0", FrameworkDisplayName=".NET 10.0")] namespace Awaiten.Extensions.DependencyInjection { + public static class AwaitenInitializationServiceCollectionExtensions + { + public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddAwaitenInitialization(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) + where TContainer : class, Awaiten.IAwaitenContainerMetadata { } + } public static class AwaitenServiceCollectionExtensions { public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddGeneratedContainer(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) diff --git a/Tests/Awaiten.Api.Tests/Expected/Awaiten.Extensions.DependencyInjection_net8.0.txt b/Tests/Awaiten.Api.Tests/Expected/Awaiten.Extensions.DependencyInjection_net8.0.txt index 143c2b7..0ffbde6 100644 --- a/Tests/Awaiten.Api.Tests/Expected/Awaiten.Extensions.DependencyInjection_net8.0.txt +++ b/Tests/Awaiten.Api.Tests/Expected/Awaiten.Extensions.DependencyInjection_net8.0.txt @@ -2,6 +2,11 @@ [assembly: System.Runtime.Versioning.TargetFramework(".NETCoreApp,Version=v8.0", FrameworkDisplayName=".NET 8.0")] namespace Awaiten.Extensions.DependencyInjection { + public static class AwaitenInitializationServiceCollectionExtensions + { + public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddAwaitenInitialization(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) + where TContainer : class, Awaiten.IAwaitenContainerMetadata { } + } public static class AwaitenServiceCollectionExtensions { public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddGeneratedContainer(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) diff --git a/Tests/Awaiten.Api.Tests/Expected/Awaiten.Extensions.DependencyInjection_netstandard2.0.txt b/Tests/Awaiten.Api.Tests/Expected/Awaiten.Extensions.DependencyInjection_netstandard2.0.txt index ca3d67e..ca4d46d 100644 --- a/Tests/Awaiten.Api.Tests/Expected/Awaiten.Extensions.DependencyInjection_netstandard2.0.txt +++ b/Tests/Awaiten.Api.Tests/Expected/Awaiten.Extensions.DependencyInjection_netstandard2.0.txt @@ -2,6 +2,11 @@ [assembly: System.Runtime.Versioning.TargetFramework(".NETStandard,Version=v2.0", FrameworkDisplayName=".NET Standard 2.0")] namespace Awaiten.Extensions.DependencyInjection { + public static class AwaitenInitializationServiceCollectionExtensions + { + public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddAwaitenInitialization(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) + where TContainer : class, Awaiten.IAwaitenContainerMetadata { } + } public static class AwaitenServiceCollectionExtensions { public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddGeneratedContainer(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) diff --git a/Tests/Awaiten.Extensions.DependencyInjection.Tests/Awaiten.Extensions.DependencyInjection.Tests.csproj b/Tests/Awaiten.Extensions.DependencyInjection.Tests/Awaiten.Extensions.DependencyInjection.Tests.csproj index 2848b11..7556de2 100644 --- a/Tests/Awaiten.Extensions.DependencyInjection.Tests/Awaiten.Extensions.DependencyInjection.Tests.csproj +++ b/Tests/Awaiten.Extensions.DependencyInjection.Tests/Awaiten.Extensions.DependencyInjection.Tests.csproj @@ -7,6 +7,7 @@ + diff --git a/Tests/Awaiten.Extensions.DependencyInjection.Tests/HostInitializationTests.cs b/Tests/Awaiten.Extensions.DependencyInjection.Tests/HostInitializationTests.cs new file mode 100644 index 0000000..0365819 --- /dev/null +++ b/Tests/Awaiten.Extensions.DependencyInjection.Tests/HostInitializationTests.cs @@ -0,0 +1,52 @@ +using System.Threading; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Awaiten.Extensions.DependencyInjection.Tests; + +public sealed partial class HostInitializationTests +{ + public sealed class AsyncResource : IAsyncInitializable + { + // Counted statically so the test can observe initialization without resolving the singleton - a + // synchronous resolve of a SyncResolveAfterInit service before warm-up would itself drive it. + public static int InitializeCount; + + public Task InitializeAsync(CancellationToken cancellationToken) + { + Interlocked.Increment(ref InitializeCount); + return Task.CompletedTask; + } + } + + // Pragmatic mode so the warmed async singleton can be resolved synchronously through MS.DI. + [Container(SyncResolveAfterInit = true)] + [Singleton] + public static partial class HostContainer; + + [Fact] + public async Task AddAwaitenInitialization_WarmsAsyncSingletonsOnStartup() + { + using IHost host = new HostBuilder() + .ConfigureServices(services => + { + services.AddGeneratedContainer(); + services.AddAwaitenInitialization(); + }) + .Build(); + + await That(AsyncResource.InitializeCount).IsEqualTo(0); + + await host.StartAsync(TestContext.Current.CancellationToken); + + // The hosted service warmed the async singleton on startup, before any manual resolution. + await That(AsyncResource.InitializeCount).IsEqualTo(1); + + // It is now resolvable synchronously through the relay (pragmatic mode) without re-initializing. + AsyncResource resource = host.Services.GetRequiredService(); + await That(AsyncResource.InitializeCount).IsEqualTo(1); + await That(resource).IsNotNull(); + + await host.StopAsync(TestContext.Current.CancellationToken); + } +} From 389d820a6e425a02700d677454c5ca0c3e4cc30a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Fri, 3 Jul 2026 20:54:32 +0200 Subject: [PATCH 2/2] fix: wire the external resolver before host warm-up and de-duplicate registration The AddAwaitenInitialization warm-up resolves the container root directly (a pre-built singleton) rather than through a bridged registration, which is where a container's external ([FromServices] / [ImportServices]) dependencies are normally wired to the host's provider on first resolution. So the hosted service now wires the container's ExternalResolver to the host's root provider before warming - unless one was set explicitly - so an async-initialized singleton that draws on an external service can be constructed during startup instead of throwing. Registration now goes through TryAddEnumerable so registering the same container's initialization more than once is a no-op. Adds tests for the external-dependency warm-up, the de-duplication, and the null-argument guard. Resolves the Sonar findings: suppresses the S1144 false positive on the DI-reflection-instantiated hosted-service constructor, and makes the tests' static initialization counters internal (CA2211). --- ...itializationServiceCollectionExtensions.cs | 37 +++++++- .../HostInitializationTests.cs | 88 ++++++++++++++++++- 2 files changed, 121 insertions(+), 4 deletions(-) diff --git a/Source/Awaiten.Extensions.DependencyInjection/AwaitenInitializationServiceCollectionExtensions.cs b/Source/Awaiten.Extensions.DependencyInjection/AwaitenInitializationServiceCollectionExtensions.cs index d472608..7e37c49 100644 --- a/Source/Awaiten.Extensions.DependencyInjection/AwaitenInitializationServiceCollectionExtensions.cs +++ b/Source/Awaiten.Extensions.DependencyInjection/AwaitenInitializationServiceCollectionExtensions.cs @@ -1,7 +1,9 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; namespace Awaiten.Extensions.DependencyInjection; @@ -21,6 +23,15 @@ public static class AwaitenInitializationServiceCollectionExtensions /// container that hosts async-initialized services through MS.DI should also opt into /// SyncResolveAfterInit so the warmed services can then be resolved synchronously. /// + /// + /// The warm-up resolves the container root directly rather than through a bridged registration, which + /// is where a container's external ([FromServices] / [ImportServices]) dependencies are + /// normally wired to the host's provider. So when the container has external dependencies and its + /// was not wired explicitly, the hosted service + /// wires it to the host's (root) provider before warming, so an async-initialized singleton that draws + /// on an external service can be constructed during startup. Registering the same container's + /// initialization more than once is a no-op. + /// /// The generated Awaiten container root type. public static IServiceCollection AddAwaitenInitialization(this IServiceCollection services) where TContainer : class, IAwaitenContainerMetadata @@ -30,7 +41,8 @@ public static IServiceCollection AddAwaitenInitialization(this IServ throw new ArgumentNullException(nameof(services)); } - services.AddSingleton>(); + services.TryAddEnumerable( + ServiceDescriptor.Singleton>()); return services; } @@ -38,10 +50,29 @@ private sealed class AwaitenInitializationHostedService : IHostedSer where TContainer : class, IAwaitenContainerMetadata { private readonly TContainer _container; + private readonly IServiceProvider _provider; - public AwaitenInitializationHostedService(TContainer container) => _container = container; + [SuppressMessage("Major Code Smell", "S1144:Unused private types or members should be removed", + Justification = "Instantiated by the dependency-injection container via reflection.")] + public AwaitenInitializationHostedService(TContainer container, IServiceProvider provider) + { + _container = container; + _provider = provider; + } - public Task StartAsync(CancellationToken cancellationToken) => _container.InitializeAsync(cancellationToken); + public Task StartAsync(CancellationToken cancellationToken) + { + // The bridged registrations wire the external resolver lazily on first resolution, but warming + // resolves the root itself (a pre-built singleton, not a factory), which bypasses that wiring. + // The provider injected into this singleton hosted service is the root provider - the right scope + // for singleton external resolution - so wire it here unless a resolver was set explicitly. + if (_container.ExternalDependencies.Count > 0 && _container.ExternalResolver is null) + { + _container.ExternalResolver = new ServiceProviderExternalResolver(_provider); + } + + return _container.InitializeAsync(cancellationToken); + } public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; } diff --git a/Tests/Awaiten.Extensions.DependencyInjection.Tests/HostInitializationTests.cs b/Tests/Awaiten.Extensions.DependencyInjection.Tests/HostInitializationTests.cs index 0365819..d7fc453 100644 --- a/Tests/Awaiten.Extensions.DependencyInjection.Tests/HostInitializationTests.cs +++ b/Tests/Awaiten.Extensions.DependencyInjection.Tests/HostInitializationTests.cs @@ -1,3 +1,4 @@ +using System.Linq; using System.Threading; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -10,7 +11,7 @@ public sealed class AsyncResource : IAsyncInitializable { // Counted statically so the test can observe initialization without resolving the singleton - a // synchronous resolve of a SyncResolveAfterInit service before warm-up would itself drive it. - public static int InitializeCount; + internal static int InitializeCount; public Task InitializeAsync(CancellationToken cancellationToken) { @@ -49,4 +50,89 @@ public async Task AddAwaitenInitialization_WarmsAsyncSingletonsOnStartup() await host.StopAsync(TestContext.Current.CancellationToken); } + + public interface IClock + { + string Now { get; } + } + + public sealed class FixedClock : IClock + { + public string Now => "noon"; + } + + public sealed class ExternalAsyncResource : IAsyncInitializable + { + internal static int InitializeCount; + + // Drawn from the host provider (not the Awaiten graph): warm-up resolves the container root directly, + // so the hosted service must wire the external resolver before this singleton can be constructed. + public ExternalAsyncResource([FromServices] IClock clock) => Clock = clock; + + public IClock Clock { get; } + + public Task InitializeAsync(CancellationToken cancellationToken) + { + Interlocked.Increment(ref InitializeCount); + return Task.CompletedTask; + } + } + + [Container(SyncResolveAfterInit = true)] + [Singleton] + public static partial class ExternalHostContainer; + + [Fact] + public async Task AddAwaitenInitialization_WithExternalDependency_WiresResolverBeforeWarming() + { + using IHost host = new HostBuilder() + .ConfigureServices(services => + { + services.AddSingleton(new FixedClock()); + services.AddGeneratedContainer(); + services.AddAwaitenInitialization(); + }) + .Build(); + + await That(ExternalAsyncResource.InitializeCount).IsEqualTo(0); + + // Without the hosted service wiring the external resolver, constructing the singleton's [FromServices] + // dependency during warm-up would throw - the resolver is null until a bridged resolution wires it. + await host.StartAsync(TestContext.Current.CancellationToken); + + await That(ExternalAsyncResource.InitializeCount).IsEqualTo(1); + + // The external dependency was resolved from the host provider, and the singleton is warm. + ExternalAsyncResource resource = host.Services.GetRequiredService(); + await That(resource.Clock.Now).IsEqualTo("noon"); + await That(ExternalAsyncResource.InitializeCount).IsEqualTo(1); + + await host.StopAsync(TestContext.Current.CancellationToken); + } + + [Container] + [Singleton] + public static partial class DoubleContainer; + + [Fact] + public async Task AddAwaitenInitialization_CalledTwice_RegistersHostedServiceOnce() + { + ServiceCollection services = new(); + services.AddGeneratedContainer(); + services.AddAwaitenInitialization(); + services.AddAwaitenInitialization(); + + // TryAddEnumerable dedupes on (IHostedService, implementation type), so the second call is a no-op; + // a bare service collection holds no other hosted services. + int hostedServices = services.Count(descriptor => descriptor.ServiceType == typeof(IHostedService)); + await That(hostedServices).IsEqualTo(1); + } + + [Fact] + public async Task AddAwaitenInitialization_WhenServicesIsNull_ShouldThrowArgumentNullException() + { + void Act() => AwaitenInitializationServiceCollectionExtensions.AddAwaitenInitialization(null!); + + await That(Act).Throws().WithParamName("services"); + } }