diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs index a8a4c22..d4f5ce9 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs @@ -454,14 +454,24 @@ 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 _); + + // 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 + // 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 +488,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 +505,7 @@ private static Dictionary> BuildDependencyGraph( info.Production, info.ProductionMember, asyncInit, + IsAsyncFactory: asyncFactory, RuntimeDisposalCheck: runtimeDisposalCheck); static bool ImplementsInterface(ITypeSymbol type, INamedTypeSymbol @interface) @@ -519,16 +533,20 @@ static bool CouldHideDisposable(ITypeSymbol type) private static List ClassifyParameters( IMethodSymbol producer, ImplInfo info, + bool asyncFactory, Dictionary serviceToImpl, List diagnostics) { List parameters = new(); foreach (IParameterSymbol parameter in producer.Parameters) { - ParameterModel parameterModel = ClassifyParameter(parameter); + ParameterModel parameterModel = ClassifyParameter(parameter, asyncFactory); 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, @@ -617,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) @@ -648,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) + private static ParameterModel ClassifyParameter(IParameterSymbol parameter, bool asyncFactory) { LocationInfo? location = LocationInfo.From(parameter.Locations.FirstOrDefault()); @@ -657,6 +676,20 @@ private static ParameterModel ClassifyParameter(IParameterSymbol parameter) return new ParameterModel(parameter.Type.ToDisplayString(FullyQualified), DependencyKind.Arg, Location: location); } + // 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") + { + 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. @@ -669,38 +702,10 @@ private static ParameterModel ClassifyParameter(IParameterSymbol parameter) } 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]. @@ -708,6 +713,49 @@ private static ParameterModel ClassifyParameter(IParameterSymbol parameter) 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) @@ -782,16 +830,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 +909,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 +949,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 +1048,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..31ae8f4 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,13 @@ private static string EmitConstruction(InstanceModel instance, InstanceModel[] i { arguments.Append("a" + argIndex++); } + else if (parameters[p].Kind == DependencyKind.CancellationToken) + { + // 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) && instances[dependency].IsAsyncTainted) @@ -1490,7 +1499,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/DependencyKind.cs b/Source/Awaiten.SourceGenerators/Internals/DependencyKind.cs index 690d29b..f484000 100644 --- a/Source/Awaiten.SourceGenerators/Internals/DependencyKind.cs +++ b/Source/Awaiten.SourceGenerators/Internals/DependencyKind.cs @@ -7,7 +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). +/// 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 { @@ -16,4 +20,5 @@ internal enum DependencyKind Lazy, Arg, Owned, + CancellationToken, } \ No newline at end of file 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..91fe1b8 --- /dev/null +++ b/Tests/Awaiten.SourceGenerators.Tests/AsyncFactoryTests.cs @@ -0,0 +1,343 @@ +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"]; + await That(source).Contains("InitializeAsync") + .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'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] + 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"); + await That(source).Contains("InitializeAsync(cancellationToken).ConfigureAwait(false)") + .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] + 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()); + } + """); + + await That(string.Join("\n", result.Diagnostics)).DoesNotContain("error CS") + .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 in a non-async method (which would not compile) 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"]; + await That(source).Contains("global::System.IDisposable") + .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 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"); + } +} diff --git a/Tests/Awaiten.Tests/AsyncInitializationTests.cs b/Tests/Awaiten.Tests/AsyncInitializationTests.cs index 593b5ca..c096853 100644 --- a/Tests/Awaiten.Tests/AsyncInitializationTests.cs +++ b/Tests/Awaiten.Tests/AsyncInitializationTests.cs @@ -632,4 +632,257 @@ 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(); + + 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] + public async Task AsyncFactory_Singleton_WarmedByInitializeAsync_IsSyncResolvableUnderSyncResolveAfterInit() + { + PragmaticAsyncFactoryService.Reset(); + using PragmaticAsyncFactoryContainer.Root container = new(); + + await container.InitializeAsync(Ct); + + PragmaticAsyncFactoryService sync = container.Resolve(); + PragmaticAsyncFactoryService async = await container.ResolveAsync(Ct); + + 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] + 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) + .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] + public async Task AsyncFactory_WhoseResultIsAsyncInitializable_AwaitsBothTheFactoryAndInitializeAsync() + { + using InitializingAsyncFactoryContainer.Root container = new(); + + InitializingAsyncFactoryService service = await container.ResolveAsync(Ct); + + 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] + public async Task AsyncFactory_MayAwaitInternalInitializationItself_WithoutImplementingIAsyncInitializable() + { + using SelfInitializingFactoryContainer.Root container = new(); + + SelfInitializingService service = await container.ResolveAsync(Ct); + + 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; + + 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(CancellationToken _) + => 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; + } + } + + // 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)); + } }