From b6962f5fcb4306b0d43147c9039468715b03b2c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Mon, 29 Jun 2026 12:40:35 +0200 Subject: [PATCH 1/3] feat: add async factory methods as a Task async-init registration channel Adds a second, statically-visible async-registration channel parallel to IAsyncInitializable: a Factory method may return Task or ValueTask and is matched against the unwrapped result type T. An async-returning factory is an async-taint source independent of whether T implements IAsyncInitializable, so the container reaches its result only by awaiting the factory call. This lets a factory author declare async-ness honestly without leaking the concrete type, and makes the synchronous path structurally impossible (a Task cannot be unwrapped synchronously) so AWT119 and strict withholding fall out for free with no special-casing. ContainerRegistrations.FindFactoryCandidates now matches the AWT108 "returns the registered type" check against the unwrapped T (ProducedType / IsAsyncFactoryReturn), and BuildInstance computes disposability and IAsyncInitializable from that same produced T rather than the Task. The new InstanceModel.IsAsyncFactory seeds PropagateAsyncTaint alongside IsAsyncInitializable, and InstanceModel.IsAsyncSource generalizes the AWT119-vs-AWT120 split and the AWT121 parameterized guard to either taint source. The emitter awaits the factory call (await Create(...).ConfigureAwait(false)) only on the async construction path, which is the only path an async-tainted service is reached on; the existing memoized-Task machinery caches the awaited result for singletons and scoped services, and a transient async-factory is async-only. ValueTask is included: it is matched by canonical metadata symbol (absent on netstandard2.0, where GetTypeByMetadataName returns null and the branch is skipped), and the emitted await is uniform for Task and ValueTask so no TFM gating of generated code is needed. A parameterized async factory (Task with [Arg] parameters) reuses the broadened AWT121, since it is reachable only through a synchronous Func that cannot await. --- .../AwaitenGenerator.cs | 51 ++-- .../ContainerRegistrations.cs | 45 ++- .../Awaiten.SourceGenerators/Diagnostics.cs | 13 +- Source/Awaiten.SourceGenerators/Emitter.cs | 11 +- .../Internals/InstanceModel.cs | 12 + .../AsyncFactoryTests.cs | 282 ++++++++++++++++++ .../Awaiten.Tests/AsyncInitializationTests.cs | 189 ++++++++++++ 7 files changed, 574 insertions(+), 29 deletions(-) create mode 100644 Tests/Awaiten.SourceGenerators.Tests/AsyncFactoryTests.cs diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs index a8a4c22..841208a 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs @@ -457,11 +457,19 @@ private static Dictionary> BuildDependencyGraph( // A factory's parameters resolve from the graph exactly like a constructor's. List parameters = ClassifyParameters(producer, info, serviceToImpl, diagnostics); - // Disposability follows the type the container actually owns: a factory's concrete return type - // (which may implement IDisposable behind a non-disposable service interface), or the constructed - // implementation type. Using info.Symbol for a factory would miss a DisposableX behind an IX and - // leak it. - ITypeSymbol disposalType = info.Production == ProductionKind.Factory ? producer.ReturnType : info.Symbol; + // An asynchronous factory returns Task / ValueTask: the container awaits it, so the type it + // actually owns is the awaited result T, not the Task. A synchronous factory owns its return type + // directly, and a constructed implementation owns info.Symbol. + bool asyncFactory = info.Production == ProductionKind.Factory + && ContainerRegistrations.IsAsyncFactoryReturn(producer.ReturnType, compilation, out _); + + // Disposability follows the type the container actually owns: a factory's produced type (which may + // implement IDisposable behind a non-disposable service interface; for an async factory this is the + // awaited T, not the Task), or the constructed implementation type. Using info.Symbol for a factory + // would miss a DisposableX behind an IX and leak it. + ITypeSymbol disposalType = info.Production == ProductionKind.Factory + ? ContainerRegistrations.ProducedType(producer.ReturnType, compilation) + : info.Symbol; bool disposable = disposableSymbol is not null && ImplementsInterface(disposalType, disposableSymbol); // A factory's declared return type can hide a concrete IDisposable behind a non-disposable service @@ -478,7 +486,10 @@ private static Dictionary> BuildDependencyGraph( // Async initialization follows the type the container actually owns - a factory's concrete return type // (which may implement IAsyncInitializable behind a non-async service interface) or the constructed // implementation type - mirroring the disposal-type choice above. A pre-built Instance is returned - // early above and is never initialized here (the caller owns it). + // early above and is never initialized here (the caller owns it). An async factory is async-tainted + // regardless of whether its produced type implements IAsyncInitializable: its result is reached only by + // awaiting the Task (see the IsAsyncFactory seed in PropagateAsyncTaint). When the produced type IS + // IAsyncInitializable, the container additionally awaits its InitializeAsync after the factory completes. bool asyncInit = asyncInitializableSymbol is not null && ImplementsInterface(disposalType, asyncInitializableSymbol); return new InstanceModel( @@ -492,6 +503,7 @@ private static Dictionary> BuildDependencyGraph( info.Production, info.ProductionMember, asyncInit, + IsAsyncFactory: asyncFactory, RuntimeDisposalCheck: runtimeDisposalCheck); static bool ImplementsInterface(ITypeSymbol type, INamedTypeSymbol @interface) @@ -782,16 +794,18 @@ internal static bool ReadSyncResolveAfterInit(INamedTypeSymbol containerSymbol) } /// - /// Marks every instance that is async-initialized or that reaches one through non-deferred (Direct) - /// edges, by fixpoint over the dependency graph. The edges already exclude relationship/Owned/Arg - /// parameters, so the taint is laundered by exactly the deferrals that break cycles. + /// Marks every instance that is an async-taint source - its implementation is async-initialized, or it + /// is produced by an asynchronous factory (Task<T> / ValueTask<T>), which the container can + /// only reach by awaiting - or that reaches one through non-deferred (Direct) edges, by fixpoint over + /// the dependency graph. The edges already exclude relationship/Owned/Arg parameters, so the taint is + /// laundered by exactly the deferrals that break cycles. /// private static bool[] PropagateAsyncTaint(List instances, Dictionary> dependencies) { bool[] tainted = new bool[instances.Count]; for (int i = 0; i < instances.Count; i++) { - tainted[i] = instances[i].IsAsyncInitializable; + tainted[i] = instances[i].IsAsyncInitializable || instances[i].IsAsyncFactory; } bool changed = true; @@ -859,7 +873,7 @@ private static void DetectSynchronousAsyncResolution( // Point the diagnostic at the offending parameter; fall back to the consumer's registration. LocationInfo? location = parameter.Location ?? instanceLocations[i]; - if (instances[target].IsAsyncInitializable) + if (instances[target].IsAsyncSource) { diagnostics.Add(new DiagnosticInfo( Diagnostics.SynchronousAsyncResolution, @@ -899,7 +913,7 @@ private static string AsyncTaintPath(List instances, Dictionary 0) { int node = queue.Dequeue(); - if (instances[node].IsAsyncInitializable) + if (instances[node].IsAsyncSource) { end = node; break; @@ -998,12 +1012,13 @@ private static void ValidateRuntimeArguments( } // A parameterized service is reachable only through a synchronous Func, which returns the - // service directly and so cannot await InitializeAsync. Combining [Arg] with IAsyncInitializable - // would therefore either hand back an uninitialized instance (SyncResolveAfterInit) or be silently - // unreachable (strict) - neither has a correct resolution path until an async parameterized factory - // exists. Reported in both modes (it is not a sync-vs-async resolution choice but an unsupported - // combination). - if (instance.IsParameterized && instance.IsAsyncInitializable) + // service directly and so cannot await it. Combining [Arg] with an async-taint source (an + // IAsyncInitializable implementation, or an asynchronous Task / ValueTask factory) would + // therefore either hand back an uninitialized/unawaited instance (SyncResolveAfterInit) or be + // silently unreachable (strict) - neither has a correct resolution path until an async parameterized + // factory relationship (Func>) exists. Reported in both modes (it is not a + // sync-vs-async resolution choice but an unsupported combination). + if (instance.IsParameterized && instance.IsAsyncSource) { diagnostics.Add(new DiagnosticInfo( Diagnostics.ParameterizedAsyncInitialization, diff --git a/Source/Awaiten.SourceGenerators/ContainerRegistrations.cs b/Source/Awaiten.SourceGenerators/ContainerRegistrations.cs index 2b24ecc..8c26320 100644 --- a/Source/Awaiten.SourceGenerators/ContainerRegistrations.cs +++ b/Source/Awaiten.SourceGenerators/ContainerRegistrations.cs @@ -157,9 +157,11 @@ static bool IsAccessibleFromDerived(ISymbol member, INamedTypeSymbol container) /// /// The ordinary methods named on the container (or an accessible base - /// type) whose return type is implicitly convertible to - the - /// candidate factory methods for a Factory registration. None means AWT108; more than one - /// means an ambiguous factory (AWT112). + /// type) whose return type produces - the candidate factory methods + /// for a Factory registration. A synchronous factory's return type is implicitly convertible to + /// the service type; an asynchronous factory returns Task<T> / ValueTask<T> + /// and is matched against the unwrapped T (the container awaits it). None means AWT108; more + /// than one means an ambiguous factory (AWT112). /// public static List FindFactoryCandidates( INamedTypeSymbol container, string name, ITypeSymbol serviceType, Compilation compilation) @@ -168,7 +170,7 @@ public static List FindFactoryCandidates( foreach (ISymbol member in AccessibleMembers(container, name)) { if (member is IMethodSymbol { MethodKind: MethodKind.Ordinary, } method - && compilation.HasImplicitConversion(method.ReturnType, serviceType)) + && compilation.HasImplicitConversion(ProducedType(method.ReturnType, compilation), serviceType)) { candidates.Add(method); } @@ -176,4 +178,39 @@ public static List FindFactoryCandidates( return candidates; } + + /// + /// The service type a factory's return type produces: the awaited result T for an asynchronous + /// factory returning Task<T> / ValueTask<T>, otherwise the return type + /// itself. A non-generic Task / ValueTask (no result) is not unwrapped, so it is matched + /// as-is and falls out as AWT108 (it produces no service). + /// + public static ITypeSymbol ProducedType(ITypeSymbol returnType, Compilation compilation) + => IsAsyncFactoryReturn(returnType, compilation, out ITypeSymbol produced) ? produced : returnType; + + /// + /// Whether is an awaitable factory return - Task<T> or + /// ValueTask<T> - yielding the produced result type T. Matched by the canonical + /// metadata symbols so a user-defined Task`1 in another namespace is not mistaken for one. + /// ValueTask<T> is absent on netstandard2.0; + /// returns there and that branch is simply skipped. + /// + public static bool IsAsyncFactoryReturn(ITypeSymbol returnType, Compilation compilation, out ITypeSymbol produced) + { + if (returnType is INamedTypeSymbol { IsGenericType: true, TypeArguments.Length: 1, } named) + { + INamedTypeSymbol definition = named.ConstructedFrom; + INamedTypeSymbol? task = compilation.GetTypeByMetadataName("System.Threading.Tasks.Task`1"); + INamedTypeSymbol? valueTask = compilation.GetTypeByMetadataName("System.Threading.Tasks.ValueTask`1"); + if (SymbolEqualityComparer.Default.Equals(definition, task) + || (valueTask is not null && SymbolEqualityComparer.Default.Equals(definition, valueTask))) + { + produced = named.TypeArguments[0]; + return true; + } + } + + produced = returnType; + return false; + } } diff --git a/Source/Awaiten.SourceGenerators/Diagnostics.cs b/Source/Awaiten.SourceGenerators/Diagnostics.cs index 502ab0d..a68117a 100644 --- a/Source/Awaiten.SourceGenerators/Diagnostics.cs +++ b/Source/Awaiten.SourceGenerators/Diagnostics.cs @@ -278,17 +278,18 @@ internal static class Diagnostics isEnabledByDefault: true); /// - /// A service with [Arg]-marked parameters is also IAsyncInitializable. A parameterized - /// service is built fresh per request and reachable only through a synchronous - /// Func<TArg…, T>, which returns the service directly and so cannot await - /// InitializeAsync - it would hand back an uninitialized instance (under + /// A service with [Arg]-marked parameters is also an async-taint source - it implements + /// IAsyncInitializable, or it is produced by an asynchronous Task<T> / + /// ValueTask<T> factory. A parameterized service is built fresh per request and reachable + /// only through a synchronous Func<TArg…, T>, which returns the service directly and so + /// cannot await it - it would hand back an uninitialized/unawaited instance (under /// SyncResolveAfterInit) or be unreachable (in the strict default). The two cannot be combined - /// until an async parameterized factory (Func<TArg…, Task<T>>) exists. + /// until an async parameterized factory relationship (Func<TArg…, Task<T>>) exists. /// public static readonly DiagnosticDescriptor ParameterizedAsyncInitialization = new( "AWT121", "Parameterized service cannot be async-initialized", - "'{0}' has [Arg] parameters and is reachable only through a synchronous Func<…, {0}>, which cannot await IAsyncInitializable.InitializeAsync; a parameterized service therefore cannot implement IAsyncInitializable", + "'{0}' has [Arg] parameters and is reachable only through a synchronous Func<…, {0}>, which cannot await its asynchronous initialization; a parameterized service therefore cannot be async-initialized (neither IAsyncInitializable nor an async Task factory)", "Awaiten", DiagnosticSeverity.Error, isEnabledByDefault: true); diff --git a/Source/Awaiten.SourceGenerators/Emitter.cs b/Source/Awaiten.SourceGenerators/Emitter.cs index 324f37f..997fe0f 100644 --- a/Source/Awaiten.SourceGenerators/Emitter.cs +++ b/Source/Awaiten.SourceGenerators/Emitter.cs @@ -1490,7 +1490,16 @@ private static string EmitConstruction(InstanceModel instance, InstanceModel[] i if (instance.Production == ProductionKind.Factory) { // The container is a static class nested-enclosing both Scope and Root, so its static factory - // method is in scope by simple name - no receiver. + // method is in scope by simple name - no receiver. An asynchronous factory returns Task / + // ValueTask; it is awaited here so the construction expression yields the produced T. This is + // only ever reached on the async path (asynchronous: true): an async factory is async-tainted, so + // its synchronous resolver delegates to the async one rather than constructing directly. The await + // expression is uniform for Task and ValueTask, so no TFM gating of the emitted code is needed. + if (instance.IsAsyncFactory) + { + return $"await {instance.ProductionMember}({arguments}).ConfigureAwait(false)"; + } + return $"{instance.ProductionMember}({arguments})"; } diff --git a/Source/Awaiten.SourceGenerators/Internals/InstanceModel.cs b/Source/Awaiten.SourceGenerators/Internals/InstanceModel.cs index 7a59cb8..2542761 100644 --- a/Source/Awaiten.SourceGenerators/Internals/InstanceModel.cs +++ b/Source/Awaiten.SourceGenerators/Internals/InstanceModel.cs @@ -16,6 +16,9 @@ namespace Awaiten.SourceGenerators.Internals; /// a factory method or instance member is always reached by simple name. /// is set when the constructed/factory-produced type implements /// IAsyncInitializable (so it must be awaited once after construction); +/// is set when the instance is produced by an asynchronous factory +/// (returning Task<T> / ValueTask<T>), which the container can only reach by +/// awaiting the factory call - an async-taint source independent of ; /// additionally covers an instance that only reaches one through its /// non-deferred dependencies, so it too must be resolved asynchronously. /// is set for a factory whose declared return type is not itself @@ -36,8 +39,17 @@ internal sealed record InstanceModel( string? ProductionMember = null, bool IsAsyncInitializable = false, bool IsAsyncTainted = false, + bool IsAsyncFactory = false, bool RuntimeDisposalCheck = false) { + /// + /// Whether this instance is itself an async-taint source (as opposed to being tainted only through a + /// dependency): its implementation is IAsyncInitializable, or it is produced by an asynchronous + /// factory. Either way it is reachable only by awaiting, so a synchronous relationship over it is an + /// AWT119 rather than a transitive AWT120. + /// + public bool IsAsyncSource => IsAsyncInitializable || IsAsyncFactory; + /// /// The ordered runtime-argument types of this instance: the service types of its [Arg]-marked /// constructor parameters, in declaration order. These are supplied at resolve time through a diff --git a/Tests/Awaiten.SourceGenerators.Tests/AsyncFactoryTests.cs b/Tests/Awaiten.SourceGenerators.Tests/AsyncFactoryTests.cs new file mode 100644 index 0000000..c64fdff --- /dev/null +++ b/Tests/Awaiten.SourceGenerators.Tests/AsyncFactoryTests.cs @@ -0,0 +1,282 @@ +using System.Linq; + +namespace Awaiten.SourceGenerators.Tests; + +/// +/// Generator behavior for asynchronous factory methods: a factory whose return type is +/// Task<T> / ValueTask<T> produces service type T and is an +/// async-taint source (parallel to IAsyncInitializable), independent of whether T +/// itself implements IAsyncInitializable. The container awaits the factory on the async path; the +/// synchronous path cannot unwrap a Task, so AWT119 / strict withholding falls out for free. +/// +public class AsyncFactoryTests +{ + [Fact] + public async Task TaskFactory_ProducesTheUnwrappedServiceType_AndIsResolvedByAwaitingTheFactory() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System.Threading.Tasks; + + namespace MyCode; + + public interface IFoo { } + public sealed class FooImpl : IFoo { } + + [Container] + [Singleton(Factory = nameof(Create))] + public static partial class MyContainer + { + private static Task Create() => Task.FromResult(new FooImpl()); + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("a Task factory matches the FooImpl registration through the unwrapped result type"); + string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; + await That(source).Contains("await Create().ConfigureAwait(false)") + .Because("the async creator awaits the Task-returning factory to obtain the produced instance"); + } + + [Fact] + public async Task TaskFactory_IsAsyncTainted_SoTheSyncPathDoesNotExposeIt() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System.Threading.Tasks; + + namespace MyCode; + + public sealed class Foo { } + + [Container] + [Singleton(Factory = nameof(Create))] + public static partial class MyContainer + { + private static Task Create() => Task.FromResult(new Foo()); + } + """); + + await That(result.Diagnostics).IsEmpty(); + string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; + // Async-tainted in the strict default: an async resolver exists, but no synchronous caching field for it + // (its sole home is the memoized async Task cache), so the sync path cannot hand out an unawaited instance. + await That(source).Contains("InitializeAsync") + .Because("an async factory makes the container async-initializable"); + await That(source).Contains("global::System.Threading.Tasks.Task? _fooAsyncTask") + .Because("the async-tainted singleton is cached as a memoized Task"); + } + + [Fact] + public async Task TaskFactory_TargetedByASyncFunc_ReportsAwt119() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System; + using System.Threading.Tasks; + + namespace MyCode; + + public sealed class Foo { } + public sealed class Consumer { public Consumer(Func foo) { } } + + [Container] + [Singleton] + [Singleton(Factory = nameof(Create))] + public static partial class MyContainer + { + private static Task Create() => Task.FromResult(new Foo()); + } + """); + + await That(result.Diagnostics).Contains("*AWT119*").AsWildcard() + .Because("a synchronous Func over an async-factory service resolves it without awaiting the factory"); + } + + [Fact] + public async Task ValueTaskFactory_ProducesTheUnwrappedServiceType() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System.Threading.Tasks; + + namespace MyCode; + + public sealed class Foo { } + + [Container] + [Singleton(Factory = nameof(Create))] + public static partial class MyContainer + { + private static ValueTask Create() => new ValueTask(new Foo()); + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("a ValueTask factory matches the Foo registration through the unwrapped result type"); + string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; + await That(source).Contains("await Create().ConfigureAwait(false)") + .Because("a ValueTask-returning factory is awaited the same way a Task-returning one is"); + } + + [Fact] + public async Task NonGenericTaskFactory_ProducesNoService_AndIsReportedAsAwt108() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System.Threading.Tasks; + + namespace MyCode; + + public sealed class Foo { } + + [Container] + [Singleton(Factory = nameof(Create))] + public static partial class MyContainer + { + private static Task Create() => Task.CompletedTask; + } + """); + + await That(result.Diagnostics).Contains("*AWT108*").AsWildcard() + .Because("a non-generic Task produces no result type, so it is not a usable factory for Foo"); + } + + [Fact] + public async Task TaskFactory_WhoseResultIsAlsoAsyncInitializable_AwaitsTheFactoryThenInitializeAsyncExactlyOnceEach() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System.Threading; + using System.Threading.Tasks; + + namespace MyCode; + + public sealed class Foo : IAsyncInitializable + { + public Task InitializeAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + + [Container] + [Singleton(Factory = nameof(Create))] + public static partial class MyContainer + { + private static Task Create() => Task.FromResult(new Foo()); + } + """); + + await That(result.Diagnostics).IsEmpty(); + string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; + await That(source).Contains("await Create().ConfigureAwait(false)") + .Because("the factory is awaited to build the instance"); + // The produced type is IAsyncInitializable, so its InitializeAsync is also awaited - once for the + // factory call, once for initialization. The single InitializeAsync call site (not two) proves there is + // no double-initialization. + await That(source).Contains("InitializeAsync(cancellationToken).ConfigureAwait(false)") + .Because("an async-initializable factory result still has its InitializeAsync awaited after construction"); + } + + [Fact] + public async Task TaskFactory_WithArgParameter_ReportsAwt121() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System.Threading.Tasks; + + namespace MyCode; + + public sealed class Foo { } + + [Container] + [Transient(Factory = nameof(Create))] + public static partial class MyContainer + { + private static Task Create([Arg] string name) => Task.FromResult(new Foo()); + } + """); + + await That(result.Diagnostics).Contains("*AWT121*").AsWildcard() + .Because("a parameterized async factory is reachable only through a synchronous Func<…, Foo>, which cannot await the Task"); + } + + [Fact] + public async Task ParameterizedAsyncFactory_EmitsNoBrokenCode_TheErrorStubReplacesResolution() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System.Threading.Tasks; + + namespace MyCode; + + public sealed class Foo { } + + [Container] + [Transient(Factory = nameof(Create))] + public static partial class MyContainer + { + private static Task Create([Arg] string name) => Task.FromResult(new Foo()); + } + """); + + // AWT121 is an error, so the emitter replaces resolution with a throwing stub rather than emitting a + // parameterized resolver that would 'await' the factory in a non-async method (which would not compile). + await That(string.Join("\n", result.Diagnostics)).DoesNotContain("error CS") + .Because("an error-stubbed container must not also produce spurious compile errors"); + await That(result.Sources["Awaiten.MyCode.MyContainer.g.cs"]).DoesNotContain("await Create(") + .Because("no parameterized resolver awaiting the factory is emitted once AWT121 stubs the container"); + } + + [Fact] + public async Task TaskFactory_UnderSyncResolveAfterInit_WarmsTheSingletonAndExposesItSynchronously() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System.Threading.Tasks; + + namespace MyCode; + + public sealed class Foo { } + + [Container(SyncResolveAfterInit = true)] + [Singleton(Factory = nameof(Create))] + public static partial class MyContainer + { + private static Task Create() => Task.FromResult(new Foo()); + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("pragmatic mode allows synchronous resolution of an async-factory singleton after warm-up"); + string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; + await That(source).Contains("await Create().ConfigureAwait(false)"); + } + + [Fact] + public async Task TaskFactory_BehindAnInterface_WhoseImplIsDisposable_TracksDisposalFromTheUnwrappedType() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System; + using System.Threading.Tasks; + + namespace MyCode; + + public interface IFoo { } + public sealed class FooImpl : IFoo, IDisposable { public void Dispose() { } } + + [Container] + [Singleton(Factory = nameof(Create))] + public static partial class MyContainer + { + private static Task Create() => Task.FromResult(new FooImpl()); + } + """); + + await That(result.Diagnostics).IsEmpty(); + string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; + // Disposability is computed from the unwrapped FooImpl (behind the IFoo service and the Task), so the + // singleton is registered for disposal. + await That(source).Contains("global::System.IDisposable") + .Because("the disposable produced type is tracked even though it sits behind Task<> and an interface"); + } +} diff --git a/Tests/Awaiten.Tests/AsyncInitializationTests.cs b/Tests/Awaiten.Tests/AsyncInitializationTests.cs index 593b5ca..75a2e2e 100644 --- a/Tests/Awaiten.Tests/AsyncInitializationTests.cs +++ b/Tests/Awaiten.Tests/AsyncInitializationTests.cs @@ -632,4 +632,193 @@ public Task InitializeAsync(CancellationToken cancellationToken) [Container] [Transient] public static partial class DisposableWorkerContainer; + + [Fact] + public async Task AsyncFactory_Singleton_AwaitsTheFactoryAndReturnsTheBuiltInstance() + { + AsyncFactoryService.Reset(); + using AsyncFactoryContainer.Root container = new(); + + IAsyncFactoryService service = await container.ResolveAsync(Ct); + + await That(service).IsNotNull(); + await That(AsyncFactoryService.BuildCount).IsEqualTo(1); + } + + [Fact] + public async Task AsyncFactory_Singleton_IsBuiltExactlyOnceAndReturnsTheSameInstance() + { + AsyncFactoryService.Reset(); + using AsyncFactoryContainer.Root container = new(); + + IAsyncFactoryService first = await container.ResolveAsync(Ct); + IAsyncFactoryService second = await container.ResolveAsync(Ct); + + await That(first).IsSameAs(second); + await That(AsyncFactoryService.BuildCount).IsEqualTo(1); + } + + [Fact] + public async Task AsyncFactory_Singleton_IsWithheldFromSynchronousResolution() + { + AsyncFactoryService.Reset(); + using AsyncFactoryContainer.Root container = new(); + + // An async-factory service is async-tainted: in the strict default the synchronous path cannot unwrap + // the Task, so it is not exposed there (it would otherwise hand back an unawaited instance). + await That(container.TryResolve(typeof(IAsyncFactoryService), out _)).IsFalse(); + await That(() => container.Resolve()).Throws(); + } + + [Fact] + public async Task AsyncFactory_Singleton_WarmedByInitializeAsync_IsSyncResolvableUnderSyncResolveAfterInit() + { + PragmaticAsyncFactoryService.Reset(); + using PragmaticAsyncFactoryContainer.Root container = new(); + + await container.InitializeAsync(Ct); + + // SyncResolveAfterInit = true: once warmed, the async-factory singleton is also resolvable synchronously + // and returns the very instance ResolveAsync built. + PragmaticAsyncFactoryService sync = container.Resolve(); + PragmaticAsyncFactoryService async = await container.ResolveAsync(Ct); + + await That(sync).IsSameAs(async); + await That(PragmaticAsyncFactoryService.BuildCount).IsEqualTo(1); + } + + [Fact] + public async Task AsyncFactory_Transient_BuildsAFreshInstanceEachCall_AndIsAsyncOnly() + { + using TransientAsyncFactoryContainer.Root container = new(); + + TransientAsyncFactoryService first = await container.ResolveAsync(Ct); + TransientAsyncFactoryService second = await container.ResolveAsync(Ct); + + await That(first).IsNotSameAs(second); + // An async-factory transient is async-only forever (never warmed by InitializeAsync, so the strict + // default has no synchronous path to it). + await That(container.TryResolve(typeof(TransientAsyncFactoryService), out _)).IsFalse(); + } + + [Fact] + public async Task AsyncFactory_WhoseResultIsAsyncInitializable_AwaitsBothTheFactoryAndInitializeAsync() + { + using InitializingAsyncFactoryContainer.Root container = new(); + + InitializingAsyncFactoryService service = await container.ResolveAsync(Ct); + + // The factory built it asynchronously and the container additionally awaited its InitializeAsync (once). + await That(service.Initialized).IsTrue(); + await That(service.InitializeCount).IsEqualTo(1); + } + + [Fact] + public async Task AsyncFactory_MayAwaitInternalInitializationItself_WithoutImplementingIAsyncInitializable() + { + using SelfInitializingFactoryContainer.Root container = new(); + + SelfInitializingService service = await container.ResolveAsync(Ct); + + // The factory body did its own async work; the container did not call InitializeAsync (the type is not + // IAsyncInitializable), yet the instance is fully initialized. + await That(service.Initialized).IsTrue(); + } + + public interface IAsyncFactoryService; + + public sealed class AsyncFactoryService : IAsyncFactoryService + { + public static int BuildCount { get; private set; } + + public static void Reset() => BuildCount = 0; + + public static void Built() => BuildCount++; + } + + [Container] + [Singleton(Factory = nameof(CreateAsync))] + public static partial class AsyncFactoryContainer + { + private static async Task CreateAsync() + { + await Task.Yield(); + AsyncFactoryService.Built(); + return new AsyncFactoryService(); + } + } + + public sealed class PragmaticAsyncFactoryService + { + public static int BuildCount { get; private set; } + + public static void Reset() => BuildCount = 0; + + public static void Built() => BuildCount++; + } + + [Container(SyncResolveAfterInit = true)] + [Singleton(Factory = nameof(CreateAsync))] + public static partial class PragmaticAsyncFactoryContainer + { + private static async Task CreateAsync() + { + await Task.Yield(); + PragmaticAsyncFactoryService.Built(); + return new PragmaticAsyncFactoryService(); + } + } + + public sealed class TransientAsyncFactoryService; + + [Container] + [Transient(Factory = nameof(CreateAsync))] + public static partial class TransientAsyncFactoryContainer + { + private static Task CreateAsync() => Task.FromResult(new TransientAsyncFactoryService()); + } + + // The factory result is itself IAsyncInitializable: the container awaits the factory, then awaits + // InitializeAsync - so it is built and initialized exactly once each, with no double-initialization. + public sealed class InitializingAsyncFactoryService : IAsyncInitializable + { + public bool Initialized { get; private set; } + + public int InitializeCount { get; private set; } + + public Task InitializeAsync(CancellationToken cancellationToken) + { + Initialized = true; + InitializeCount++; + return Task.CompletedTask; + } + } + + [Container] + [Singleton(Factory = nameof(CreateAsync))] + public static partial class InitializingAsyncFactoryContainer + { + private static Task CreateAsync() => Task.FromResult(new InitializingAsyncFactoryService()); + } + + // The factory does its own async initialization without the type implementing IAsyncInitializable. + public sealed class SelfInitializingService + { + public bool Initialized { get; private set; } + + public void MarkInitialized() => Initialized = true; + } + + [Container] + [Singleton(Factory = nameof(CreateAsync))] + public static partial class SelfInitializingFactoryContainer + { + private static async Task CreateAsync() + { + SelfInitializingService service = new(); + await Task.Yield(); + service.MarkInitialized(); + return service; + } + } } From 0ceb277dc239c0549430a8f0e14a636c615586a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Mon, 29 Jun 2026 16:10:54 +0200 Subject: [PATCH 2/3] feat: forward the resolve-time CancellationToken into async factory methods A factory method may now take a System.Threading.CancellationToken parameter; the container forwards the resolve-time token (the async creator's cancellationToken; default on a synchronous path) instead of treating it as a graph dependency. The new DependencyKind.CancellationToken contributes no dependency edge and no async taint, mirroring [Arg], and is excluded from the AWT101 missing-dependency check. Forwarding is scoped to factory methods - a constructor has no ambient resolve-time token, so its CancellationToken parameter stays an ordinary (and therefore unregistered) dependency. Also tighten the async-factory surface from review: the synchronous-resolution guidance message now names the async Task factory channel alongside IAsyncInitializable, and a runtime test covers disposal of an async-factory-produced IDisposable hidden behind a non-disposable service interface (previously only asserted at the generated-source level). --- .../AwaitenGenerator.cs | 22 +++- Source/Awaiten.SourceGenerators/Emitter.cs | 14 ++- .../Internals/DependencyKind.cs | 5 +- .../AsyncFactoryTests.cs | 68 +++++++++--- .../Awaiten.Tests/AsyncInitializationTests.cs | 102 ++++++++++++++---- 5 files changed, 170 insertions(+), 41 deletions(-) diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs index 841208a..19e8450 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs @@ -534,13 +534,17 @@ private static List ClassifyParameters( Dictionary serviceToImpl, List diagnostics) { + bool isFactory = info.Production == ProductionKind.Factory; List parameters = new(); foreach (IParameterSymbol parameter in producer.Parameters) { - ParameterModel parameterModel = ClassifyParameter(parameter); + ParameterModel parameterModel = ClassifyParameter(parameter, isFactory); parameters.Add(parameterModel); - if (parameterModel.Kind != DependencyKind.Arg && !serviceToImpl.ContainsKey(KeyOf(parameterModel))) + // A CancellationToken is forwarded from the resolve-time token, not resolved from the graph (like + // [Arg]), so it is never a missing dependency. + if (parameterModel.Kind is not (DependencyKind.Arg or DependencyKind.CancellationToken) + && !serviceToImpl.ContainsKey(KeyOf(parameterModel))) { diagnostics.Add(new DiagnosticInfo( Diagnostics.MissingDependency, @@ -660,7 +664,7 @@ static bool IsAccessibleConstructor(IMethodSymbol constructor, INamedTypeSymbol /// (e.g. Func<Func<T>>) is classified as a direct dependency so it surfaces as an /// unregistered service type rather than a misleading diagnostic about the inner relationship. /// - private static ParameterModel ClassifyParameter(IParameterSymbol parameter) + private static ParameterModel ClassifyParameter(IParameterSymbol parameter, bool isFactory = false) { LocationInfo? location = LocationInfo.From(parameter.Locations.FirstOrDefault()); @@ -669,6 +673,18 @@ private static ParameterModel ClassifyParameter(IParameterSymbol parameter) return new ParameterModel(parameter.Type.ToDisplayString(FullyQualified), DependencyKind.Arg, Location: location); } + // A factory's CancellationToken parameter is not resolved from the graph: the container forwards the + // resolve-time token (the async creator's cancellationToken; default on a synchronous path). Limited to + // factory methods - a constructor has no ambient token to forward. An [Arg] CancellationToken is handled + // above as a caller-supplied runtime argument and is left untouched. + if (isFactory + && parameter.Type is INamedTypeSymbol { Name: "CancellationToken", } token + && token.ContainingNamespace?.ToDisplayString() == "System.Threading") + { + return new ParameterModel( + parameter.Type.ToDisplayString(FullyQualified), DependencyKind.CancellationToken, Location: location); + } + // A [FromKey] selects the keyed registration of the dependency's service type, whether it is required // directly, deferred behind a Func/Lazy, or wrapped in an Owned handle - the service type is // the same, only the delivery differs. diff --git a/Source/Awaiten.SourceGenerators/Emitter.cs b/Source/Awaiten.SourceGenerators/Emitter.cs index 997fe0f..625f331 100644 --- a/Source/Awaiten.SourceGenerators/Emitter.cs +++ b/Source/Awaiten.SourceGenerators/Emitter.cs @@ -269,13 +269,15 @@ private static string FuncWithheldMessage(string service) /// /// The guidance message (a quoted string literal) thrown by Resolve(Type) when an async-tainted service is - /// requested by type: it is async-initialized (or reaches one through its non-deferred dependencies), so it - /// has no synchronous resolution path in the strict default and must be obtained asynchronously. + /// requested by type: it is async-initialized - it implements IAsyncInitializable, is produced by an + /// asynchronous Task<T> / ValueTask<T> factory, or reaches one through its non-deferred + /// dependencies - so it has no synchronous resolution path in the strict default and must be obtained + /// asynchronously. /// private static string AsyncWithheldMessage(string service) { string display = service.Replace("global::", string.Empty); - return $"\"Awaiten: '{display}' requires asynchronous initialization (it is IAsyncInitializable, or depends on one) and cannot be resolved synchronously; resolve it through ResolveAsync (or warm it through InitializeAsync / CreateScopeAsync), or set SyncResolveAfterInit on the [Container].\""; + return $"\"Awaiten: '{display}' requires asynchronous initialization (it is IAsyncInitializable, is produced by an async Task factory, or depends on one) and cannot be resolved synchronously; resolve it through ResolveAsync (or warm it through InitializeAsync / CreateScopeAsync), or set SyncResolveAfterInit on the [Container].\""; } /// @@ -1475,6 +1477,12 @@ private static string EmitConstruction(InstanceModel instance, InstanceModel[] i { arguments.Append("a" + argIndex++); } + else if (parameters[p].Kind == DependencyKind.CancellationToken) + { + // Forward the resolve-time token on the async path (the creator's cancellationToken is in scope); + // a synchronous resolver has no ambient token, so it passes default. + arguments.Append(asynchronous ? "cancellationToken" : "default"); + } else if (asynchronous && parameters[p].Kind == DependencyKind.Direct && serviceToIndex.TryGetValue(new ServiceKey(parameters[p].ServiceType, parameters[p].Key), out int dependency) && instances[dependency].IsAsyncTainted) diff --git a/Source/Awaiten.SourceGenerators/Internals/DependencyKind.cs b/Source/Awaiten.SourceGenerators/Internals/DependencyKind.cs index 690d29b..1c0d941 100644 --- a/Source/Awaiten.SourceGenerators/Internals/DependencyKind.cs +++ b/Source/Awaiten.SourceGenerators/Internals/DependencyKind.cs @@ -7,7 +7,9 @@ namespace Awaiten.SourceGenerators.Internals; /// is supplied at resolve time from a Func<TArg…, T> relationship /// rather than from the graph. resolves the service into a dedicated throwaway /// scope and hands the caller an Owned<T> disposal handle (it defers like a relationship -/// type, so it contributes no graph edge). +/// type, so it contributes no graph edge). is a factory method's +/// System.Threading.CancellationToken parameter, satisfied by forwarding the resolve-time token +/// (the async creator's) rather than from the graph - so, like , it contributes no edge. /// internal enum DependencyKind { @@ -16,4 +18,5 @@ internal enum DependencyKind Lazy, Arg, Owned, + CancellationToken, } \ No newline at end of file diff --git a/Tests/Awaiten.SourceGenerators.Tests/AsyncFactoryTests.cs b/Tests/Awaiten.SourceGenerators.Tests/AsyncFactoryTests.cs index c64fdff..c62e272 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/AsyncFactoryTests.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/AsyncFactoryTests.cs @@ -59,12 +59,10 @@ public static partial class MyContainer await That(result.Diagnostics).IsEmpty(); string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; - // Async-tainted in the strict default: an async resolver exists, but no synchronous caching field for it - // (its sole home is the memoized async Task cache), so the sync path cannot hand out an unawaited instance. await That(source).Contains("InitializeAsync") - .Because("an async factory makes the container async-initializable"); + .Because("an async factory makes the container async-initializable, so an async resolver exists in the strict default"); await That(source).Contains("global::System.Threading.Tasks.Task? _fooAsyncTask") - .Because("the async-tainted singleton is cached as a memoized Task"); + .Because("the async-tainted singleton's sole home is the memoized async Task cache - there is no synchronous caching field, so the sync path cannot hand out an unawaited instance"); } [Fact] @@ -169,11 +167,8 @@ public static partial class MyContainer string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; await That(source).Contains("await Create().ConfigureAwait(false)") .Because("the factory is awaited to build the instance"); - // The produced type is IAsyncInitializable, so its InitializeAsync is also awaited - once for the - // factory call, once for initialization. The single InitializeAsync call site (not two) proves there is - // no double-initialization. await That(source).Contains("InitializeAsync(cancellationToken).ConfigureAwait(false)") - .Because("an async-initializable factory result still has its InitializeAsync awaited after construction"); + .Because("an async-initializable factory result still has its InitializeAsync awaited after construction - once for the factory call, once for initialization, with a single InitializeAsync call site proving no double-initialization"); } [Fact] @@ -218,12 +213,10 @@ public static partial class MyContainer } """); - // AWT121 is an error, so the emitter replaces resolution with a throwing stub rather than emitting a - // parameterized resolver that would 'await' the factory in a non-async method (which would not compile). await That(string.Join("\n", result.Diagnostics)).DoesNotContain("error CS") - .Because("an error-stubbed container must not also produce spurious compile errors"); + .Because("AWT121 is an error, so the emitter replaces resolution with a throwing stub and must not also produce spurious compile errors"); await That(result.Sources["Awaiten.MyCode.MyContainer.g.cs"]).DoesNotContain("await Create(") - .Because("no parameterized resolver awaiting the factory is emitted once AWT121 stubs the container"); + .Because("no parameterized resolver awaiting the factory in a non-async method (which would not compile) is emitted once AWT121 stubs the container"); } [Fact] @@ -274,9 +267,54 @@ public static partial class MyContainer await That(result.Diagnostics).IsEmpty(); string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; - // Disposability is computed from the unwrapped FooImpl (behind the IFoo service and the Task), so the - // singleton is registered for disposal. await That(source).Contains("global::System.IDisposable") - .Because("the disposable produced type is tracked even though it sits behind Task<> and an interface"); + .Because("disposability is computed from the unwrapped FooImpl (behind the IFoo service and the Task), so the singleton is registered for disposal even though it sits behind Task<> and an interface"); + } + + [Fact] + public async Task TaskFactory_WithCancellationTokenParameter_ForwardsTheResolveTimeToken() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System.Threading; + using System.Threading.Tasks; + + namespace MyCode; + + public sealed class Foo { } + + [Container] + [Singleton(Factory = nameof(Create))] + public static partial class MyContainer + { + private static Task Create(CancellationToken cancellationToken) => Task.FromResult(new Foo()); + } + """); + + await That(result.Diagnostics).IsEmpty() + .Because("a factory's CancellationToken parameter is forwarded from the resolve-time token, not resolved from the graph, so it is not a missing dependency (AWT101)"); + string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; + await That(source).Contains("await Create(cancellationToken).ConfigureAwait(false)") + .Because("the async creator forwards its in-scope resolve-time token into the factory's CancellationToken parameter"); + } + + [Fact] + public async Task ConstructorCancellationTokenParameter_IsNotForwarded_AndReportsAwt101() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System.Threading; + + namespace MyCode; + + public sealed class Foo { public Foo(CancellationToken cancellationToken) { } } + + [Container] + [Singleton] + public static partial class MyContainer { } + """); + + await That(result.Diagnostics).Contains("*AWT101*").AsWildcard() + .Because("token forwarding is scoped to factory methods - a constructor has no ambient resolve-time token, so its CancellationToken parameter stays an ordinary (unregistered) dependency"); } } diff --git a/Tests/Awaiten.Tests/AsyncInitializationTests.cs b/Tests/Awaiten.Tests/AsyncInitializationTests.cs index 75a2e2e..c096853 100644 --- a/Tests/Awaiten.Tests/AsyncInitializationTests.cs +++ b/Tests/Awaiten.Tests/AsyncInitializationTests.cs @@ -664,10 +664,10 @@ public async Task AsyncFactory_Singleton_IsWithheldFromSynchronousResolution() AsyncFactoryService.Reset(); using AsyncFactoryContainer.Root container = new(); - // An async-factory service is async-tainted: in the strict default the synchronous path cannot unwrap - // the Task, so it is not exposed there (it would otherwise hand back an unawaited instance). - await That(container.TryResolve(typeof(IAsyncFactoryService), out _)).IsFalse(); - await That(() => container.Resolve()).Throws(); + await That(container.TryResolve(typeof(IAsyncFactoryService), out _)).IsFalse() + .Because("an async-factory service is async-tainted: in the strict default the synchronous path cannot unwrap the Task, so it is not exposed there (it would otherwise hand back an unawaited instance)"); + await That(() => container.Resolve()).Throws() + .Because("synchronous by-type resolution of an async-factory service is withheld in the strict default"); } [Fact] @@ -678,13 +678,13 @@ public async Task AsyncFactory_Singleton_WarmedByInitializeAsync_IsSyncResolvabl await container.InitializeAsync(Ct); - // SyncResolveAfterInit = true: once warmed, the async-factory singleton is also resolvable synchronously - // and returns the very instance ResolveAsync built. PragmaticAsyncFactoryService sync = container.Resolve(); PragmaticAsyncFactoryService async = await container.ResolveAsync(Ct); - await That(sync).IsSameAs(async); - await That(PragmaticAsyncFactoryService.BuildCount).IsEqualTo(1); + await That(sync).IsSameAs(async) + .Because("SyncResolveAfterInit = true: once warmed, the async-factory singleton is also resolvable synchronously and returns the very instance ResolveAsync built"); + await That(PragmaticAsyncFactoryService.BuildCount).IsEqualTo(1) + .Because("warm-up and the two resolutions share the single memoized instance, so the factory ran once"); } [Fact] @@ -695,10 +695,10 @@ public async Task AsyncFactory_Transient_BuildsAFreshInstanceEachCall_AndIsAsync TransientAsyncFactoryService first = await container.ResolveAsync(Ct); TransientAsyncFactoryService second = await container.ResolveAsync(Ct); - await That(first).IsNotSameAs(second); - // An async-factory transient is async-only forever (never warmed by InitializeAsync, so the strict - // default has no synchronous path to it). - await That(container.TryResolve(typeof(TransientAsyncFactoryService), out _)).IsFalse(); + await That(first).IsNotSameAs(second) + .Because("an async-factory transient builds a fresh instance on each ResolveAsync"); + await That(container.TryResolve(typeof(TransientAsyncFactoryService), out _)).IsFalse() + .Because("an async-factory transient is async-only forever (never warmed by InitializeAsync, so the strict default has no synchronous path to it)"); } [Fact] @@ -708,9 +708,10 @@ public async Task AsyncFactory_WhoseResultIsAsyncInitializable_AwaitsBothTheFact InitializingAsyncFactoryService service = await container.ResolveAsync(Ct); - // The factory built it asynchronously and the container additionally awaited its InitializeAsync (once). - await That(service.Initialized).IsTrue(); - await That(service.InitializeCount).IsEqualTo(1); + await That(service.Initialized).IsTrue() + .Because("the factory built it asynchronously and the container additionally awaited its InitializeAsync"); + await That(service.InitializeCount).IsEqualTo(1) + .Because("InitializeAsync runs exactly once after the factory completes - no double-initialization"); } [Fact] @@ -720,9 +721,35 @@ public async Task AsyncFactory_MayAwaitInternalInitializationItself_WithoutImple SelfInitializingService service = await container.ResolveAsync(Ct); - // The factory body did its own async work; the container did not call InitializeAsync (the type is not - // IAsyncInitializable), yet the instance is fully initialized. - await That(service.Initialized).IsTrue(); + await That(service.Initialized).IsTrue() + .Because("the factory body did its own async work; the container did not call InitializeAsync (the type is not IAsyncInitializable), yet the instance is fully initialized"); + } + + [Fact] + public async Task AsyncFactory_DisposableHiddenBehindTheServiceInterface_IsDisposedOnContainerTeardown() + { + HiddenDisposableFactoryService hidden; + using (HiddenDisposableAsyncFactoryContainer.Root container = new()) + { + hidden = (HiddenDisposableFactoryService)await container.ResolveAsync(Ct); + await That(hidden.DisposeCount).IsEqualTo(0) + .Because("the container still owns the instance"); + } + + await That(hidden.DisposeCount).IsEqualTo(1) + .Because("the disposable produced behind Task<> and a non-disposable interface is tracked by the generated runtime is-IDisposable check on the awaited result, so container teardown disposes it exactly once"); + } + + [Fact] + public async Task AsyncFactory_WithCancellationTokenParameter_ReceivesTheResolveTimeToken() + { + using TokenForwardingContainer.Root container = new(); + using CancellationTokenSource cts = new(); + + TokenAwareService service = await container.ResolveAsync(cts.Token); + + await That(service.ReceivedToken).IsEqualTo(cts.Token) + .Because("the container forwards the exact resolve-time token into the factory's CancellationToken parameter"); } public interface IAsyncFactoryService; @@ -798,7 +825,8 @@ public Task InitializeAsync(CancellationToken cancellationToken) [Singleton(Factory = nameof(CreateAsync))] public static partial class InitializingAsyncFactoryContainer { - private static Task CreateAsync() => Task.FromResult(new InitializingAsyncFactoryService()); + private static Task CreateAsync(CancellationToken _) + => Task.FromResult(new InitializingAsyncFactoryService()); } // The factory does its own async initialization without the type implementing IAsyncInitializable. @@ -821,4 +849,40 @@ private static async Task CreateAsync() return service; } } + + // A non-disposable service interface hiding a concrete IDisposable, produced by an async Task factory. + // Async-taint comes purely from the factory being asynchronous (the type is not IAsyncInitializable), and + // the disposable stays hidden behind the interface and the Task - so disposal can only be tracked by the + // generated runtime is-IDisposable check on the awaited result. DisposeCount (not a bool) proves exactly once. + public interface IHiddenDisposableFactoryService; + + public sealed class HiddenDisposableFactoryService : IHiddenDisposableFactoryService, IDisposable + { + public int DisposeCount { get; private set; } + + public void Dispose() => DisposeCount++; + } + + [Container] + [Singleton(Factory = nameof(CreateAsync))] + public static partial class HiddenDisposableAsyncFactoryContainer + { + private static Task CreateAsync() + => Task.FromResult(new HiddenDisposableFactoryService()); + } + + // A factory that takes a CancellationToken: the container forwards the resolve-time token, which the + // service captures so the test can assert the exact token flowed through. + public sealed class TokenAwareService(CancellationToken token) + { + public CancellationToken ReceivedToken { get; } = token; + } + + [Container] + [Singleton(Factory = nameof(CreateAsync))] + public static partial class TokenForwardingContainer + { + private static Task CreateAsync(CancellationToken cancellationToken) + => Task.FromResult(new TokenAwareService(cancellationToken)); + } } From 979945f575d1183dcd82fdd929c3e64ccac16999 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Mon, 29 Jun 2026 16:49:29 +0200 Subject: [PATCH 3/3] refactor: scope CancellationToken forwarding to async factories and reduce complexity Restrict resolve-time CancellationToken forwarding to asynchronous factory methods. Only an async factory is constructed on the async path where that token is in scope; a synchronous factory (like a constructor) has no ambient token, so a forwarded CancellationToken would always have received default silently. Such a parameter now stays an ordinary graph dependency and is reported as AWT101 when unregistered rather than compiling to a silent default. Also resolve two maintainability findings. Extract the Lazy/Func relationship classification out of ClassifyParameter into a ClassifyRelationship helper, dropping its cognitive complexity well below the threshold while preserving behavior on every path. And remove the now-dead synchronous branch of the emitted CancellationToken argument: a CancellationToken dependency only ever arises from an async factory, which is built solely on the async path, so the argument is always the in-scope token. --- .../AwaitenGenerator.cs | 106 +++++++++++------- Source/Awaiten.SourceGenerators/Emitter.cs | 7 +- .../Internals/DependencyKind.cs | 8 +- .../AsyncFactoryTests.cs | 25 ++++- 4 files changed, 96 insertions(+), 50 deletions(-) diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs index 19e8450..d4f5ce9 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs @@ -454,15 +454,17 @@ private static Dictionary> BuildDependencyGraph( } } - // A factory's parameters resolve from the graph exactly like a constructor's. - List parameters = ClassifyParameters(producer, info, serviceToImpl, diagnostics); - // An asynchronous factory returns Task / ValueTask: the container awaits it, so the type it // actually owns is the awaited result T, not the Task. A synchronous factory owns its return type // directly, and a constructed implementation owns info.Symbol. bool asyncFactory = info.Production == ProductionKind.Factory && ContainerRegistrations.IsAsyncFactoryReturn(producer.ReturnType, compilation, out _); + // A factory's parameters resolve from the graph exactly like a constructor's. An async factory + // additionally forwards the resolve-time CancellationToken (the async creator's) into a matching + // parameter rather than resolving it from the graph. + List parameters = ClassifyParameters(producer, info, asyncFactory, serviceToImpl, diagnostics); + // Disposability follows the type the container actually owns: a factory's produced type (which may // implement IDisposable behind a non-disposable service interface; for an async factory this is the // awaited T, not the Task), or the constructed implementation type. Using info.Symbol for a factory @@ -531,14 +533,14 @@ static bool CouldHideDisposable(ITypeSymbol type) private static List ClassifyParameters( IMethodSymbol producer, ImplInfo info, + bool asyncFactory, Dictionary serviceToImpl, List diagnostics) { - bool isFactory = info.Production == ProductionKind.Factory; List parameters = new(); foreach (IParameterSymbol parameter in producer.Parameters) { - ParameterModel parameterModel = ClassifyParameter(parameter, isFactory); + ParameterModel parameterModel = ClassifyParameter(parameter, asyncFactory); parameters.Add(parameterModel); // A CancellationToken is forwarded from the resolve-time token, not resolved from the graph (like @@ -633,7 +635,8 @@ private static void ValidateInstanceMember( IMethodSymbol? resolvable = constructors .Where(c => c.Parameters.All(p => { - ParameterModel parameter = ClassifyParameter(p); + // Selecting a constructor, never an async factory, so no CancellationToken forwarding applies. + ParameterModel parameter = ClassifyParameter(p, asyncFactory: false); return parameter.Kind == DependencyKind.Arg || registered.Contains(parameter.ServiceType); })) .OrderByDescending(c => c.Parameters.Length) @@ -664,7 +667,7 @@ static bool IsAccessibleConstructor(IMethodSymbol constructor, INamedTypeSymbol /// (e.g. Func<Func<T>>) is classified as a direct dependency so it surfaces as an /// unregistered service type rather than a misleading diagnostic about the inner relationship. /// - private static ParameterModel ClassifyParameter(IParameterSymbol parameter, bool isFactory = false) + private static ParameterModel ClassifyParameter(IParameterSymbol parameter, bool asyncFactory) { LocationInfo? location = LocationInfo.From(parameter.Locations.FirstOrDefault()); @@ -673,11 +676,13 @@ private static ParameterModel ClassifyParameter(IParameterSymbol parameter, bool return new ParameterModel(parameter.Type.ToDisplayString(FullyQualified), DependencyKind.Arg, Location: location); } - // A factory's CancellationToken parameter is not resolved from the graph: the container forwards the - // resolve-time token (the async creator's cancellationToken; default on a synchronous path). Limited to - // factory methods - a constructor has no ambient token to forward. An [Arg] CancellationToken is handled - // above as a caller-supplied runtime argument and is left untouched. - if (isFactory + // An asynchronous factory's CancellationToken parameter is not resolved from the graph: the container + // forwards the resolve-time token (the async creator's cancellationToken). Limited to async factories - + // only they are constructed on the async path where that token exists; a synchronous factory (or a + // constructor) has no ambient token to forward, so its CancellationToken stays an ordinary dependency + // and is reported as AWT101 when unregistered rather than silently receiving default. An [Arg] + // CancellationToken is handled above as a caller-supplied runtime argument and is left untouched. + if (asyncFactory && parameter.Type is INamedTypeSymbol { Name: "CancellationToken", } token && token.ContainingNamespace?.ToDisplayString() == "System.Threading") { @@ -697,38 +702,10 @@ private static ParameterModel ClassifyParameter(IParameterSymbol parameter, bool } if (parameter.Type is INamedTypeSymbol { IsGenericType: true, } named - && named.ContainingNamespace?.ToDisplayString() == "System") + && named.ContainingNamespace?.ToDisplayString() == "System" + && ClassifyRelationship(named, key, location) is { } relationship) { - if (named is { Name: "Lazy", TypeArguments.Length: 1, } && !IsRelationshipType(named.TypeArguments[0])) - { - return new ParameterModel( - named.TypeArguments[0].ToDisplayString(FullyQualified), DependencyKind.Lazy, Key: key, Location: location); - } - - if (named is { Name: "Func", TypeArguments.Length: >= 1, }) - { - // Func defers resolution; Func additionally supplies runtime arguments (the - // leading type arguments) to the produced service's [Arg]-marked parameters. - ITypeSymbol[] typeArgs = named.TypeArguments.ToArray(); - ITypeSymbol service = typeArgs[typeArgs.Length - 1]; - string[] argTypes = typeArgs.Take(typeArgs.Length - 1) - .Select(t => t.ToDisplayString(FullyQualified)) - .ToArray(); - - // Func<…, Owned> is the leak-free factory: its produced value is an Owned disposal handle. - if (IsOwned(service, out ITypeSymbol funcOwnedInner)) - { - return new ParameterModel( - funcOwnedInner.ToDisplayString(FullyQualified), DependencyKind.Func, - new EquatableArray(argTypes), Key: key, Location: location, ProducesOwned: true); - } - - if (!IsRelationshipType(service)) - { - return new ParameterModel( - service.ToDisplayString(FullyQualified), DependencyKind.Func, new EquatableArray(argTypes), Key: key, Location: location); - } - } + return relationship; } // A direct dependency, optionally selecting a keyed registration with [FromKey]. @@ -736,6 +713,49 @@ private static ParameterModel ClassifyParameter(IParameterSymbol parameter, bool parameter.Type.ToDisplayString(FullyQualified), DependencyKind.Direct, Key: key, Location: location); } + /// + /// Classifies a System generic as the single-level relationship it defers - Lazy<T>, + /// Func<T> or Func<TArg…, T> (the latter optionally producing an + /// Owned<T> disposal handle) - returning the underlying service type. A type that is not a + /// recognized relationship, or whose produced type is itself a relationship (nesting beyond one level), + /// returns so the caller treats it as a direct dependency on the whole type. + /// + private static ParameterModel? ClassifyRelationship(INamedTypeSymbol named, string? key, LocationInfo? location) + { + if (named is { Name: "Lazy", TypeArguments.Length: 1, } && !IsRelationshipType(named.TypeArguments[0])) + { + return new ParameterModel( + named.TypeArguments[0].ToDisplayString(FullyQualified), DependencyKind.Lazy, Key: key, Location: location); + } + + if (named is not { Name: "Func", TypeArguments.Length: >= 1, }) + { + return null; + } + + // Func defers resolution; Func additionally supplies runtime arguments (the leading type + // arguments) to the produced service's [Arg]-marked parameters. + ITypeSymbol[] typeArgs = named.TypeArguments.ToArray(); + ITypeSymbol service = typeArgs[typeArgs.Length - 1]; + string[] argTypes = typeArgs.Take(typeArgs.Length - 1) + .Select(t => t.ToDisplayString(FullyQualified)) + .ToArray(); + + // Func<…, Owned> is the leak-free factory: its produced value is an Owned disposal handle. + if (IsOwned(service, out ITypeSymbol funcOwnedInner)) + { + return new ParameterModel( + funcOwnedInner.ToDisplayString(FullyQualified), DependencyKind.Func, + new EquatableArray(argTypes), Key: key, Location: location, ProducesOwned: true); + } + + // Func<…, T> over a relationship type (nesting beyond one level) falls through to a direct dependency. + return IsRelationshipType(service) + ? null + : new ParameterModel( + service.ToDisplayString(FullyQualified), DependencyKind.Func, new EquatableArray(argTypes), Key: key, Location: location); + } + private static ServiceKey KeyOf(ParameterModel parameter) => new(parameter.ServiceType, parameter.Key); private static string DisplayKeyed(string serviceType, string? key) diff --git a/Source/Awaiten.SourceGenerators/Emitter.cs b/Source/Awaiten.SourceGenerators/Emitter.cs index 625f331..31ae8f4 100644 --- a/Source/Awaiten.SourceGenerators/Emitter.cs +++ b/Source/Awaiten.SourceGenerators/Emitter.cs @@ -1479,9 +1479,10 @@ private static string EmitConstruction(InstanceModel instance, InstanceModel[] i } else if (parameters[p].Kind == DependencyKind.CancellationToken) { - // Forward the resolve-time token on the async path (the creator's cancellationToken is in scope); - // a synchronous resolver has no ambient token, so it passes default. - arguments.Append(asynchronous ? "cancellationToken" : "default"); + // Forward the resolve-time token: the async creator's cancellationToken is in scope here. Only an + // async factory yields a CancellationToken dependency, and an async factory is built solely on the + // async path, so this is never reached with asynchronous == false. + arguments.Append("cancellationToken"); } else if (asynchronous && parameters[p].Kind == DependencyKind.Direct && serviceToIndex.TryGetValue(new ServiceKey(parameters[p].ServiceType, parameters[p].Key), out int dependency) diff --git a/Source/Awaiten.SourceGenerators/Internals/DependencyKind.cs b/Source/Awaiten.SourceGenerators/Internals/DependencyKind.cs index 1c0d941..f484000 100644 --- a/Source/Awaiten.SourceGenerators/Internals/DependencyKind.cs +++ b/Source/Awaiten.SourceGenerators/Internals/DependencyKind.cs @@ -7,9 +7,11 @@ namespace Awaiten.SourceGenerators.Internals; /// is supplied at resolve time from a Func<TArg…, T> relationship /// rather than from the graph. resolves the service into a dedicated throwaway /// scope and hands the caller an Owned<T> disposal handle (it defers like a relationship -/// type, so it contributes no graph edge). is a factory method's -/// System.Threading.CancellationToken parameter, satisfied by forwarding the resolve-time token -/// (the async creator's) rather than from the graph - so, like , it contributes no edge. +/// type, so it contributes no graph edge). is an asynchronous factory +/// method's System.Threading.CancellationToken parameter, satisfied by forwarding the resolve-time +/// token (the async creator's) rather than from the graph - so, like , it contributes no +/// edge. A synchronous factory or a constructor has no ambient token, so its CancellationToken is an +/// ordinary dependency instead. /// internal enum DependencyKind { diff --git a/Tests/Awaiten.SourceGenerators.Tests/AsyncFactoryTests.cs b/Tests/Awaiten.SourceGenerators.Tests/AsyncFactoryTests.cs index c62e272..91fe1b8 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/AsyncFactoryTests.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/AsyncFactoryTests.cs @@ -315,6 +315,29 @@ public static partial class MyContainer { } """); await That(result.Diagnostics).Contains("*AWT101*").AsWildcard() - .Because("token forwarding is scoped to factory methods - a constructor has no ambient resolve-time token, so its CancellationToken parameter stays an ordinary (unregistered) dependency"); + .Because("token forwarding is scoped to asynchronous factory methods - a constructor has no ambient resolve-time token, so its CancellationToken parameter stays an ordinary (unregistered) dependency"); + } + + [Fact] + public async Task SynchronousFactoryCancellationTokenParameter_IsNotForwarded_AndReportsAwt101() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System.Threading; + + namespace MyCode; + + public sealed class Foo { } + + [Container] + [Singleton(Factory = nameof(Create))] + public static partial class MyContainer + { + private static Foo Create(CancellationToken cancellationToken) => new Foo(); + } + """); + + await That(result.Diagnostics).Contains("*AWT101*").AsWildcard() + .Because("token forwarding is scoped to asynchronous factories: a synchronous factory is only ever built on the synchronous path, which has no ambient resolve-time token, so its CancellationToken would always be default - it stays an ordinary (unregistered) dependency rather than silently receiving default"); } }