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 Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
<PackageVersion Include="System.Memory" Version="4.6.3"/>
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9"/>
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9"/>
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.9"/>
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9"/>
</ItemGroup>
<ItemGroup>
<PackageVersion Include="Nuke.Common" Version="10.1.0"/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
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;

/// <summary>
/// Registers a hosted service that initializes a generated Awaiten container on application startup,
/// warming its async-initialized singletons in dependency order (see <see cref="IAsyncInitializable" />)
/// before the host begins serving.
/// </summary>
public static class AwaitenInitializationServiceCollectionExtensions
{
/// <summary>
/// Adds an <see cref="IHostedService" /> that calls <c>InitializeAsync</c> on the
/// <typeparamref name="TContainer" /> registered with
/// <see cref="AwaitenServiceCollectionExtensions.AddGeneratedContainer{TRoot}(IServiceCollection)" />,
/// honoring the host's startup <see cref="CancellationToken" />. Register the container first; a
/// container that hosts async-initialized services through MS.DI should also opt into
/// <c>SyncResolveAfterInit</c> so the warmed services can then be resolved synchronously.
/// </summary>
/// <remarks>
/// The warm-up resolves the container root directly rather than through a bridged registration, which
/// is where a container's external (<c>[FromServices]</c> / <c>[ImportServices]</c>) dependencies are
/// normally wired to the host's provider. So when the container has external dependencies and its
/// <see cref="IExternalResolverHost.ExternalResolver" /> 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.
/// </remarks>
/// <typeparam name="TContainer">The generated Awaiten container root type.</typeparam>
public static IServiceCollection AddAwaitenInitialization<TContainer>(this IServiceCollection services)
where TContainer : class, IAwaitenContainerMetadata
{
if (services is null)
{
throw new ArgumentNullException(nameof(services));
}

services.TryAddEnumerable(
ServiceDescriptor.Singleton<IHostedService, AwaitenInitializationHostedService<TContainer>>());
return services;
}

private sealed class AwaitenInitializationHostedService<TContainer> : IHostedService
where TContainer : class, IAwaitenContainerMetadata
{
private readonly TContainer _container;
private readonly IServiceProvider _provider;

[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)
{
// 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<TContainer>(this Microsoft.Extensions.DependencyInjection.IServiceCollection services)
where TContainer : class, Awaiten.IAwaitenContainerMetadata { }
}
public static class AwaitenServiceCollectionExtensions
{
public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddGeneratedContainer<TRoot>(this Microsoft.Extensions.DependencyInjection.IServiceCollection services)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<TContainer>(this Microsoft.Extensions.DependencyInjection.IServiceCollection services)
where TContainer : class, Awaiten.IAwaitenContainerMetadata { }
}
public static class AwaitenServiceCollectionExtensions
{
public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddGeneratedContainer<TRoot>(this Microsoft.Extensions.DependencyInjection.IServiceCollection services)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<TContainer>(this Microsoft.Extensions.DependencyInjection.IServiceCollection services)
where TContainer : class, Awaiten.IAwaitenContainerMetadata { }
}
public static class AwaitenServiceCollectionExtensions
{
public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddGeneratedContainer<TRoot>(this Microsoft.Extensions.DependencyInjection.IServiceCollection services)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
using System.Linq;
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.
internal 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<AsyncResource>]
public static partial class HostContainer;

[Fact]
public async Task AddAwaitenInitialization_WarmsAsyncSingletonsOnStartup()
{
using IHost host = new HostBuilder()
.ConfigureServices(services =>
{
services.AddGeneratedContainer<HostContainer.Root>();
services.AddAwaitenInitialization<HostContainer.Root>();
})
.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<AsyncResource>();
await That(AsyncResource.InitializeCount).IsEqualTo(1);
await That(resource).IsNotNull();

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<ExternalAsyncResource>]
public static partial class ExternalHostContainer;

[Fact]
public async Task AddAwaitenInitialization_WithExternalDependency_WiresResolverBeforeWarming()
{
using IHost host = new HostBuilder()
.ConfigureServices(services =>
{
services.AddSingleton<IClock>(new FixedClock());
services.AddGeneratedContainer<ExternalHostContainer.Root>();
services.AddAwaitenInitialization<ExternalHostContainer.Root>();
})
.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<ExternalAsyncResource>();
await That(resource.Clock.Now).IsEqualTo("noon");
await That(ExternalAsyncResource.InitializeCount).IsEqualTo(1);

await host.StopAsync(TestContext.Current.CancellationToken);
}

[Container]
[Singleton<AsyncResource>]
public static partial class DoubleContainer;

[Fact]
public async Task AddAwaitenInitialization_CalledTwice_RegistersHostedServiceOnce()
{
ServiceCollection services = new();
services.AddGeneratedContainer<DoubleContainer.Root>();
services.AddAwaitenInitialization<DoubleContainer.Root>();
services.AddAwaitenInitialization<DoubleContainer.Root>();

// 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<HostContainer.Root>(null!);

await That(Act).Throws<ArgumentNullException>().WithParamName("services");
}
}
Loading