From d3c36320b791b0c7fbc6f7fadc792014e43003d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Tue, 30 Jun 2026 09:34:01 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20add=20async=20relationship=20types?= =?UTF-8?q?=20Task,=20Func<=E2=80=A6,Task>=20and=20Lazy>?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend ClassifyParameter/ClassifyRelationship and the emitter so a service can depend on an async-initialized (or async-factory-produced) service through an awaitable relationship: Task resolves and initializes T, Func<…,Task> is an async factory forwarding runtime [Arg]s, and Lazy> is a memoized async dependency. These defer resolution like Func/Lazy, so they launder async taint - a synchronously-resolvable consumer can hold one over an async service without becoming async-tainted and without tripping AWT119/AWT120, which is the fix those diagnostics point to. An async-tainted target is produced through its async resolver (awaiting initialization); a synchronously-resolvable target is wrapped with Task.FromResult over its synchronous resolver. Add an async parameterized resolver so Func> over a parameterized async service is correct rather than merely tolerated: it forwards the runtime arguments AND awaits InitializeAsync (or the async factory), instead of wrapping the synchronous resolver in a completed Task and silently skipping initialization. In pragmatic mode (SyncResolveAfterInit) the synchronous parameterized resolver block-delegates to it, so there is a single initialization path. AWT113 now also validates the runtime arguments of a Func>. Remove AWT121 (parameterized service cannot be async-initialized). It existed only because there was no correct resolution path for an [Arg]-plus-async service until this async parameterized factory relationship; that path now exists, so the combination is legal when consumed through Func>. Misuse is caught at the consumption site instead: a synchronous Func over such a service is AWT119, and a plain / Lazy / Task dependency that supplies no arguments is AWT115. AWT121 never shipped (AnalyzerReleases.Shipped.md is empty), so removing it frees the id with no released suppression able to latch onto it. ValueTask is deliberately not a relationship type (a stored ValueTask may only be awaited once); it remains supported solely as an async factory's return type on the producer side. --- .../AnalyzerReleases.Unshipped.md | 5 +- .../AwaitenGenerator.cs | 72 +++++-- .../Awaiten.SourceGenerators/Diagnostics.cs | 17 -- Source/Awaiten.SourceGenerators/Emitter.cs | 94 +++++++-- .../Internals/DependencyKind.cs | 16 +- .../AsyncFactoryTests.cs | 29 ++- .../AsyncRelationshipTypesTests.cs | 199 ++++++++++++++++++ ....Awt121ParameterizedAsyncInitialization.cs | 92 -------- .../Awaiten.Tests/AsyncInitializationTests.cs | 90 ++++++++ 9 files changed, 459 insertions(+), 155 deletions(-) create mode 100644 Tests/Awaiten.SourceGenerators.Tests/AsyncRelationshipTypesTests.cs delete mode 100644 Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt121ParameterizedAsyncInitialization.cs diff --git a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md index 342dc07..dcae68c 100644 --- a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -14,12 +14,11 @@ AWT110 | Awaiten | Error | A registration sets both Factory and Instance AWT111 | Awaiten | Error | An implementation is registered with conflicting production strategies AWT112 | Awaiten | Error | A Factory registration names an overloaded method - AWT113 | Awaiten | Error | A Func relationship's runtime arguments do not match the service's [Arg] parameters + AWT113 | Awaiten | Error | A Func or Func> relationship's runtime arguments do not match the service's [Arg] parameters AWT114 | Awaiten | Error | A service with [Arg] parameters is registered with a non-Transient lifetime - AWT115 | Awaiten | Error | A service with [Arg] parameters is required as a plain or Lazy dependency instead of a Func + AWT115 | Awaiten | Error | A service with [Arg] parameters is required as a plain, Lazy or Task dependency instead of a Func AWT116 | Awaiten | Error | A [Container] class is not declared static AWT117 | Awaiten | Error | Two registrations share the same service type and key AWT118 | Awaiten | Warning | A root-owned instance holds a Func over a disposable build-on-demand service AWT119 | Awaiten | Error | A synchronous Func/Lazy/Owned relationship targets an async-initialized service AWT120 | Awaiten | Error | A synchronous Func/Lazy/Owned relationship reaches an async-tainted service transitively - AWT121 | Awaiten | Error | A service with [Arg] parameters also implements IAsyncInitializable diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs index 9daafba..a7f81b2 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs @@ -849,6 +849,14 @@ private static ParameterModel ClassifyParameter(IParameterSymbol parameter, bool return new ParameterModel(ownedInner.ToDisplayString(FullyQualified), DependencyKind.Owned, Key: key, Location: location); } + // A bare Task dependency: an awaitable that resolves (and initializes) T. Task lives in + // System.Threading.Tasks, not System, so it is recognized here rather than through the System-generic + // relationship gate below (which handles the Func/Lazy wrappers, including Func<…, Task>). + if (IsTask(parameter.Type, out ITypeSymbol taskResult)) + { + return new ParameterModel(taskResult.ToDisplayString(FullyQualified), DependencyKind.Task, Key: key, Location: location); + } + if (parameter.Type is INamedTypeSymbol { IsGenericType: true, } named && named.ContainingNamespace?.ToDisplayString() == "System" && ClassifyRelationship(named, key, location) is { } relationship) @@ -872,8 +880,10 @@ private static ParameterModel ClassifyParameter(IParameterSymbol parameter, bool { 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); + // Lazy> is the async counterpart of Lazy: a memoized awaitable dependency. + return IsTask(named.TypeArguments[0], out ITypeSymbol lazyTaskResult) + ? new ParameterModel(lazyTaskResult.ToDisplayString(FullyQualified), DependencyKind.LazyTask, Key: key, Location: location) + : new ParameterModel(named.TypeArguments[0].ToDisplayString(FullyQualified), DependencyKind.Lazy, Key: key, Location: location); } if (named is not { Name: "Func", TypeArguments.Length: >= 1, }) @@ -889,6 +899,15 @@ private static ParameterModel ClassifyParameter(IParameterSymbol parameter, bool .Select(t => t.ToDisplayString(FullyQualified)) .ToArray(); + // Func<…, Task> is the async counterpart of Func<…, T>: an async factory that resolves (and + // initializes) T, awaiting it. It forwards any leading runtime arguments to T's [Arg] parameters. + if (IsTask(service, out ITypeSymbol funcTaskResult)) + { + return new ParameterModel( + funcTaskResult.ToDisplayString(FullyQualified), DependencyKind.FuncTask, + new EquatableArray(argTypes), Key: key, Location: location); + } + // Func<…, Owned> is the leak-free factory: its produced value is an Owned disposal handle. if (IsOwned(service, out ITypeSymbol funcOwnedInner)) { @@ -1123,6 +1142,23 @@ private static string AsyncTaintPath(List instances, Dictionary ", chain.Select(index => Display(instances[index].ImplementationType))); } + // Whether a type is a System.Threading.Tasks.Task, yielding its result type T. Used to recognize the + // async relationship types (Task, Func<…, Task>, Lazy>); ValueTask is deliberately not a + // relationship type (a stored ValueTask may only be awaited once) - it is supported solely as an async + // factory's return type, on the producer side. + private static bool IsTask(ITypeSymbol type, out ITypeSymbol result) + { + if (type is INamedTypeSymbol { IsGenericType: true, Name: "Task", TypeArguments.Length: 1, } named + && named.ContainingNamespace?.ToDisplayString() == "System.Threading.Tasks") + { + result = named.TypeArguments[0]; + return true; + } + + result = type; + return false; + } + // Whether a type is an Awaiten.Owned disposal handle, yielding the owned service type T. private static bool IsOwned(ITypeSymbol type, out ITypeSymbol inner) { @@ -1195,21 +1231,14 @@ private static void ValidateRuntimeArguments( new EquatableArray([Display(instance.ImplementationType), instance.Lifetime.ToString(),]))); } - // A parameterized service is reachable only through a synchronous Func, which returns the - // 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, - location, - new EquatableArray([Display(instance.ImplementationType),]))); - } - + // A parameterized async service (an [Arg] service that is IAsyncInitializable, is produced by an + // asynchronous Task / ValueTask factory, or transitively reaches one) is built fresh per call + // from its runtime arguments AND must await initialization, so its correct resolution path is the + // async parameterized factory relationship Func>, which forwards the arguments to the + // async resolver. Misuse is caught at the consumption site rather than the registration: a synchronous + // Func over it is AWT119 (cannot await), and a plain / Lazy / Task dependency that + // supplies no arguments is AWT115 (parameterized requires a Func). There is therefore no + // registration-time diagnostic for the [Arg]-plus-async combination itself. foreach (ParameterModel parameter in instance.ConstructorParameters.AsArray()) { // Guard the implToIndex lookup the same way BuildDependencyGraph does: serviceToImpl can name an @@ -1231,9 +1260,10 @@ private static void ValidateRuntimeArguments( /// /// Validates a single (non-[Arg]) dependency against its target's runtime arguments - /// (): a Func<TArg…, T> must request exactly them (AWT113); - /// a plain or Lazy<T> dependency cannot supply them at all, so it must instead be a - /// Func when the target is parameterized (AWT115). + /// (): a Func<TArg…, T> or its async form + /// Func<TArg…, Task<T>> must request exactly them (AWT113); a plain, Lazy<T> + /// or Task<T> dependency cannot supply them at all, so a parameterized target must instead be + /// reached through a Func (AWT115). /// private static void ValidateDependency( InstanceModel consumer, @@ -1246,7 +1276,7 @@ private static void ValidateDependency( // parameter has no usable location. LocationInfo? location = parameter.Location ?? consumerLocation; - if (parameter.Kind == DependencyKind.Func) + if (parameter.Kind is DependencyKind.Func or DependencyKind.FuncTask) { string[] requested = parameter.FuncArgTypes.AsArray(); if (!requested.SequenceEqual(expected)) diff --git a/Source/Awaiten.SourceGenerators/Diagnostics.cs b/Source/Awaiten.SourceGenerators/Diagnostics.cs index 0a647d6..5a91a45 100644 --- a/Source/Awaiten.SourceGenerators/Diagnostics.cs +++ b/Source/Awaiten.SourceGenerators/Diagnostics.cs @@ -297,21 +297,4 @@ internal static class Diagnostics "Awaiten", DiagnosticSeverity.Error, isEnabledByDefault: true); - - /// - /// 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 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 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 31ae8f4..2e98941 100644 --- a/Source/Awaiten.SourceGenerators/Emitter.cs +++ b/Source/Awaiten.SourceGenerators/Emitter.cs @@ -413,17 +413,17 @@ private static void EmitScopeBaseClass(StringBuilder builder, int depth, Instanc for (int i = 0; i < instances.Length; i++) { - // The synchronous resolver is suppressed for an async-tainted service in the strict default - // (where it is reachable only through ResolveAsync); the async resolver is added for every - // async-tainted, non-parameterized service (a parameterized one is reached only by its Func). + // The synchronous resolver is suppressed for an async-tainted service in the strict default (where it + // is reachable only through ResolveAsync); the async resolver is added for every async-tainted + // service, including a parameterized one (reached through its Func>). if (EmitsSync(instances[i], syncResolveAfterInit)) { builder.AppendLine(); - // In pragmatic mode an async-tainted (non-parameterized) service is also resolvable - // synchronously; its synchronous resolver delegates to the memoizing async one rather than - // constructing a second, uninitialized instance, so there is a single init path. - if (instances[i].IsAsyncTainted && !instances[i].IsParameterized) + // In pragmatic mode an async-tainted service is also resolvable synchronously; its synchronous + // resolver delegates to the memoizing async one (a parameterized service forwarding its runtime + // arguments) rather than constructing a second, uninitialized instance, so there is a single init path. + if (instances[i].IsAsyncTainted) { EmitDelegatingSyncResolver(builder, body, i, instances[i], names); } @@ -433,7 +433,7 @@ private static void EmitScopeBaseClass(StringBuilder builder, int depth, Instanc } } - if (instances[i].IsAsyncTainted && !instances[i].IsParameterized) + if (instances[i].IsAsyncTainted) { builder.AppendLine(); EmitAsyncScopeResolver(builder, body, i, instances[i], instances, names, serviceToIndex); @@ -1140,6 +1140,19 @@ private static void EmitAsyncScopeResolver(StringBuilder builder, int depth, int const string task = "global::System.Threading.Tasks.Task"; const string ct = "global::System.Threading.CancellationToken cancellationToken"; + // A parameterized async service is built fresh per call from its runtime arguments AND awaits + // initialization, so it is reached only through Func>. Its async resolver takes the + // arguments alongside the token; like the synchronous parameterized resolver it lives on the base Scope + // (internal) and the Root inherits it, never caching (a parameterized service is always transient). + if (instance.IsParameterized) + { + string[] argTypes = instance.ArgTypes(); + string argSignature = string.Join("", argTypes.Select((t, i) => $"{t} a{i}, ")); + string parameterizedConstruction = EmitConstruction(instance, instances, names, serviceToIndex, asynchronous: true); + EmitAsyncFreshResolver(builder, depth, index, instance, names, parameterizedConstruction, argSignature); + return; + } + if (instance.Lifetime == Lifetime.Singleton) { Indent(builder, depth).Append("protected virtual ").Append(task).Append('<').Append(instance.ImplementationType) @@ -1170,12 +1183,18 @@ private static void EmitAsyncScopeResolver(StringBuilder builder, int depth, int /// private static void EmitDelegatingSyncResolver(StringBuilder builder, int depth, int index, InstanceModel instance, Names names) { + // A parameterized service forwards its runtime arguments to the async resolver: the synchronous resolver + // takes the same arguments, blocks on the (per-call) async resolver, and so still drives initialization. + string[] argTypes = instance.ArgTypes(); + string signature = string.Join(", ", argTypes.Select((t, i) => $"{t} a{i}")); + string forward = string.Join("", argTypes.Select((_, i) => "a" + i + ", ")); + Indent(builder, depth).Append("internal ").Append(instance.ImplementationType).Append(' ') - .Append(names.Resolver(index)).AppendLine("()"); + .Append(names.Resolver(index)).Append('(').Append(signature).AppendLine(")"); Indent(builder, depth).AppendLine("{"); EmitDisposedGuard(builder, depth + 1); Indent(builder, depth + 1).Append("return ").Append(names.AsyncResolver(index)) - .AppendLine("(default).GetAwaiter().GetResult();"); + .Append('(').Append(forward).AppendLine("default).GetAwaiter().GetResult();"); Indent(builder, depth).AppendLine("}"); } @@ -1246,16 +1265,17 @@ private static void EmitAsyncCachingResolver(StringBuilder builder, int depth, i /// /// Emits an async resolver that constructs, initializes and returns a fresh instance on every call (a - /// transient). A disposable instance is registered for teardown on the owner. + /// transient, or a parameterized service that additionally takes the runtime arguments named in + /// ). A disposable instance is registered for teardown on the owner. /// - private static void EmitAsyncFreshResolver(StringBuilder builder, int depth, int index, InstanceModel instance, Names names, string construction) + private static void EmitAsyncFreshResolver(StringBuilder builder, int depth, int index, InstanceModel instance, Names names, string construction, string argSignature = "") { string type = instance.ImplementationType; const string task = "global::System.Threading.Tasks.Task"; const string ct = "global::System.Threading.CancellationToken cancellationToken"; Indent(builder, depth).Append("internal async ").Append(task).Append('<').Append(type).Append("> ") - .Append(names.AsyncResolver(index)).Append('(').Append(ct).AppendLine(")"); + .Append(names.AsyncResolver(index)).Append('(').Append(argSignature).Append(ct).AppendLine(")"); Indent(builder, depth).AppendLine("{"); EmitDisposedGuard(builder, depth + 1); Indent(builder, depth + 1).Append(type).Append(" created = ").Append(construction).AppendLine(";"); @@ -1552,6 +1572,20 @@ private static string ResolveExpression(ParameterModel parameter, InstanceModel[ return FuncFactory(funcArgTypes, parameter.ServiceType, resolver); } + // The async relationship types resolve their target through its async resolver (awaiting + // initialization), or wrap a synchronous target in a completed Task. They defer like the synchronous + // relationships, so they are created here at construction time and carry no ambient cancellation token. + if (parameter.Kind is DependencyKind.Task or DependencyKind.FuncTask or DependencyKind.LazyTask) + { + string asyncValue = AsyncRelationshipValue(parameter, target, targetIndex, names, rootOwned, funcArgTypes); + return parameter.Kind switch + { + DependencyKind.FuncTask => AsyncFuncFactory(funcArgTypes, parameter.ServiceType, asyncValue), + DependencyKind.LazyTask => $"new global::System.Lazy>(() => {asyncValue})", + _ => asyncValue, + }; + } + string value; if (rootOwned) { @@ -1590,6 +1624,40 @@ private static string FuncFactory(string[] argTypes, string service, string reso return $"new global::System.Func<{generics}>(({lambdaArgs}) => {resolver}({lambdaArgs}))"; } + /// + /// A Task<T>-valued expression for an async relationship. An async-tainted target is + /// produced by its async resolver - which awaits initialization, and for a parameterized target forwards + /// the runtime arguments (a0…) - so the relationship hands back an initialized instance. A + /// synchronously-resolvable target is wrapped with Task.FromResult over its synchronous resolver. + /// The relationship is created at construction time and carries no ambient cancellation token + /// (default). A root-owned target is read straight off __root so the call devirtualizes. + /// + private static string AsyncRelationshipValue(ParameterModel parameter, InstanceModel target, int targetIndex, Names names, bool rootOwned, string[] argTypes) + { + string callArgs = string.Join("", argTypes.Select((_, i) => "a" + i + ", ")); + if (target.IsAsyncTainted) + { + string asyncResolver = rootOwned ? $"__root.{names.AsyncResolver(targetIndex)}" : names.AsyncResolver(targetIndex); + return $"{asyncResolver}({callArgs}default)"; + } + + string resolver = rootOwned ? $"__root.{names.Resolver(targetIndex)}" : names.Resolver(targetIndex); + return $"global::System.Threading.Tasks.Task.FromResult<{parameter.ServiceType}>({resolver}({string.Join(", ", argTypes.Select((_, i) => "a" + i))}))"; + } + + /// + /// A new Func<TArg…, Task<T>>((a0, …) => …) expression: the async counterpart of + /// , wrapping the async relationship value in a factory that forwards any + /// runtime arguments. With no argument types this is the plain async factory Func<Task<T>>. + /// + private static string AsyncFuncFactory(string[] argTypes, string service, string asyncValue) + { + string task = $"global::System.Threading.Tasks.Task<{service}>"; + string generics = argTypes.Length == 0 ? task : string.Join(", ", argTypes) + ", " + task; + string lambdaArgs = string.Join(", ", argTypes.Select((_, i) => "a" + i)); + return $"new global::System.Func<{generics}>(({lambdaArgs}) => {asyncValue})"; + } + /// /// Emits the __Owned<T> helper on the base Scope: it opens a throwaway child scope /// (sharing the root's singletons), resolves a single T into it through the supplied delegate and diff --git a/Source/Awaiten.SourceGenerators/Internals/DependencyKind.cs b/Source/Awaiten.SourceGenerators/Internals/DependencyKind.cs index f484000..6e7304f 100644 --- a/Source/Awaiten.SourceGenerators/Internals/DependencyKind.cs +++ b/Source/Awaiten.SourceGenerators/Internals/DependencyKind.cs @@ -11,7 +11,12 @@ namespace Awaiten.SourceGenerators.Internals; /// 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. +/// ordinary dependency instead. , and +/// are the asynchronous counterparts of // +/// : awaitable relationships that resolve (and initialize) the target through its async +/// resolver. Like the synchronous relationship types they defer resolution, so they contribute no graph +/// edge and launder async taint - which is what lets a synchronously-resolvable consumer hold one over an +/// async-initialized service without becoming async-tainted (and without tripping AWT119/AWT120). /// internal enum DependencyKind { @@ -21,4 +26,13 @@ internal enum DependencyKind Arg, Owned, CancellationToken, + + /// A Task<T> dependency: an awaitable that resolves (and initializes) T. + Task, + + /// A Func<…, Task<T>> async factory; like but awaitable. + FuncTask, + + /// A Lazy<Task<T>> async dependency; like but awaitable. + LazyTask, } \ No newline at end of file diff --git a/Tests/Awaiten.SourceGenerators.Tests/AsyncFactoryTests.cs b/Tests/Awaiten.SourceGenerators.Tests/AsyncFactoryTests.cs index 91fe1b8..5cb73a9 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/AsyncFactoryTests.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/AsyncFactoryTests.cs @@ -172,51 +172,64 @@ await That(source).Contains("InitializeAsync(cancellationToken).ConfigureAwait(f } [Fact] - public async Task TaskFactory_WithArgParameter_ReportsAwt121() + public async Task ParameterizedTaskFactory_ConsumedViaFuncOfTask_IsLegalAndAwaitsTheFactory() { 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> make) { } } [Container] [Transient(Factory = nameof(Create))] + [Singleton] 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"); + // A parameterized async factory now has a correct resolution path: Func> forwards the + // runtime argument to the async parameterized resolver, which awaits the factory. There is no AWT121. + await That(result.Diagnostics).IsEmpty(); + string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; + await That(source).Contains("await Create(a0).ConfigureAwait(false)") + .Because("the async parameterized resolver forwards the runtime argument to the factory and awaits it"); + await That(source).Contains("new global::System.Func>") + .Because("the consumer's Func> binds the async parameterized resolver"); } [Fact] - public async Task ParameterizedAsyncFactory_EmitsNoBrokenCode_TheErrorStubReplacesResolution() + public async Task ParameterizedTaskFactory_ConsumedViaSyncFunc_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 make) { } } [Container] [Transient(Factory = nameof(Create))] + [Singleton] 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"); + // A synchronous Func over an async-tainted (async-factory) parameterized service cannot + // await the Task, so it is rejected at the consumption site - the async form Func> is + // the fix. + await That(result.Diagnostics).Contains("*AWT119*").AsWildcard() + .Because("a synchronous Func relationship cannot await an async parameterized factory"); } [Fact] diff --git a/Tests/Awaiten.SourceGenerators.Tests/AsyncRelationshipTypesTests.cs b/Tests/Awaiten.SourceGenerators.Tests/AsyncRelationshipTypesTests.cs new file mode 100644 index 0000000..f81f424 --- /dev/null +++ b/Tests/Awaiten.SourceGenerators.Tests/AsyncRelationshipTypesTests.cs @@ -0,0 +1,199 @@ +namespace Awaiten.SourceGenerators.Tests; + +/// +/// Generator behavior for the asynchronous relationship types Task<T>, +/// Func<…, Task<T>> and Lazy<Task<T>>: awaitable counterparts of the +/// synchronous Func<T> / Lazy<T> relationships. They defer resolution and so +/// launder async taint - a synchronously-resolvable consumer can hold one over an async-initialized +/// service without becoming async-tainted and without tripping AWT119 / AWT120 - and they resolve their +/// target through its async resolver (awaiting initialization). A Func<TArg…, Task<T>> +/// additionally forwards runtime [Arg]s to a parameterized async service through its async +/// resolver, which is the correct (and only) path for an [Arg]-plus-async service. +/// +public class AsyncRelationshipTypesTests +{ + [Fact] + public async Task AsyncRelationships_ResolveAnAsyncServiceWithoutTaintingAStrictConsumer() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System; + using System.Threading; + using System.Threading.Tasks; + + namespace MyCode; + + public sealed class Connection : IAsyncInitializable + { + public Task InitializeAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + public sealed class Pool + { + public Pool(Func> factory, Lazy> lazy, Task task) { } + } + + [Container] + [Singleton] + [Singleton] + public static partial class MyContainer + { + } + """); + + // The async relationships launder the taint (like Func/Lazy), so Pool stays synchronously resolvable + // and there is no AWT119/AWT120. + await That(result.Diagnostics).IsEmpty(); + string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; + + await That(source).Contains("{ typeof(global::MyCode.Pool),") + .Because("Pool is not async-tainted, so it keeps a synchronous by-type dispatch entry"); + await That(source).Contains("new global::System.Func>(() => __root.ResolveConnectionAsync(default))"); + await That(source).Contains("new global::System.Lazy>(() => __root.ResolveConnectionAsync(default))"); + } + + [Fact] + public async Task ParameterizedFuncOfTask_ForwardsArgumentsThroughTheAsyncResolver() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System; + using System.Threading; + using System.Threading.Tasks; + + namespace MyCode; + + public sealed class Robot : IAsyncInitializable + { + public Robot([Arg] int id) { } + public Task InitializeAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + public sealed class Factory + { + public Factory(Func> make) { } + } + + [Container] + [Transient] + [Singleton] + public static partial class MyContainer + { + } + """); + + // A parameterized async service is legal now that Func> exists: there is no AWT121. + await That(result.Diagnostics).IsEmpty(); + string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; + + await That(source).Contains("new global::System.Func>((a0) => ResolveRobotAsync(a0, default))") + .Because("the async factory forwards the runtime argument to the async parameterized resolver"); + await That(source).Contains("internal async global::System.Threading.Tasks.Task ResolveRobotAsync(int a0, global::System.Threading.CancellationToken cancellationToken)") + .Because("the async parameterized resolver takes the runtime arguments alongside the token"); + await That(source).Contains("InitializeAsync(cancellationToken).ConfigureAwait(false)") + .Because("the async parameterized resolver awaits the service's initialization"); + } + + [Fact] + public async Task ParameterizedFuncOfTask_WithMismatchedArguments_ReportsAwt113() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System; + using System.Threading; + using System.Threading.Tasks; + + namespace MyCode; + + public sealed class Robot : IAsyncInitializable + { + public Robot([Arg] int id) { } + public Task InitializeAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + public sealed class Factory + { + public Factory(Func> make) { } + } + + [Container] + [Transient] + [Singleton] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).Contains("*AWT113*").AsWildcard() + .Because("a Func> requests a string but Robot's [Arg] parameter expects an int"); + } + + [Fact] + public async Task PragmaticMode_ParameterizedAsync_SyncResolverForwardsArgumentsAndBlocksOnTheAsyncResolver() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System; + using System.Threading; + using System.Threading.Tasks; + + namespace MyCode; + + public sealed class Robot : IAsyncInitializable + { + public Robot([Arg] int id) { } + public Task InitializeAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + public sealed class Factory + { + public Factory(Func make) { } + } + + [Container(SyncResolveAfterInit = true)] + [Transient] + [Singleton] + public static partial class MyContainer + { + } + """); + + // In pragmatic mode the synchronous Func is allowed (AWT119 is suppressed); its sync + // parameterized resolver must forward the argument and block on the async resolver so initialization + // still runs, never building a second uninitialized instance. + await That(result.Diagnostics).IsEmpty(); + string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; + await That(source).Contains("internal global::MyCode.Robot ResolveRobot(int a0)") + .Because("the pragmatic sync parameterized resolver keeps the runtime-argument signature"); + await That(source).Contains("return ResolveRobotAsync(a0, default).GetAwaiter().GetResult();") + .Because("it forwards the argument and blocks on the single async (initializing) resolver"); + } + + [Fact] + public async Task BareTaskOfAParameterizedService_ReportsAwt115() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System.Threading; + using System.Threading.Tasks; + + namespace MyCode; + + public sealed class Robot : IAsyncInitializable + { + public Robot([Arg] int id) { } + public Task InitializeAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + public sealed class Factory + { + public Factory(Task robot) { } + } + + [Container] + [Transient] + [Singleton] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).Contains("*AWT115*").AsWildcard() + .Because("a bare Task supplies no runtime arguments, so a parameterized service must instead be reached through a Func>"); + } +} diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt121ParameterizedAsyncInitialization.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt121ParameterizedAsyncInitialization.cs deleted file mode 100644 index 3d114ca..0000000 --- a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt121ParameterizedAsyncInitialization.cs +++ /dev/null @@ -1,92 +0,0 @@ -namespace Awaiten.SourceGenerators.Tests; - -public partial class DiagnosticTests -{ - public class Awt121ParameterizedAsyncInitialization - { - [Fact] - public async Task ReportsWhenAParameterizedServiceIsAlsoAsyncInitializable() - { - GeneratorResult result = Generator.Run(""" - using Awaiten; - using System.Threading; - using System.Threading.Tasks; - - namespace MyCode; - - public sealed class Robot : IAsyncInitializable - { - public Robot([Arg] int id) { } - public Task InitializeAsync(CancellationToken cancellationToken) => Task.CompletedTask; - } - - [Container] - [Transient] - public static partial class MyContainer - { - } - """); - - await That(result.Diagnostics).Contains("*AWT121*").AsWildcard() - .Because("a parameterized service is reachable only through a synchronous Func<…, T> that cannot await InitializeAsync"); - } - - [Fact] - public async Task ReportsEvenInPragmaticMode() - { - GeneratorResult result = Generator.Run(""" - using Awaiten; - using System; - using System.Threading; - using System.Threading.Tasks; - - namespace MyCode; - - public sealed class Robot : IAsyncInitializable - { - public Robot([Arg] int id) { } - public Task InitializeAsync(CancellationToken cancellationToken) => Task.CompletedTask; - } - - public sealed class Factory { public Factory(Func f) { } } - - [Container(SyncResolveAfterInit = true)] - [Transient] - [Singleton] - public static partial class MyContainer - { - } - """); - - // SyncResolveAfterInit would otherwise construct the parameterized service synchronously and hand it - // back without ever calling InitializeAsync; the combination is rejected regardless of the mode. - await That(result.Diagnostics).Contains("*AWT121*").AsWildcard() - .Because("the unsupported combination is rejected in both strict and pragmatic modes"); - } - - [Fact] - public async Task DoesNotReport_ForANonParameterizedAsyncInitializableService() - { - GeneratorResult result = Generator.Run(""" - using Awaiten; - using System.Threading; - using System.Threading.Tasks; - - namespace MyCode; - - public sealed class Connection : IAsyncInitializable - { - public Task InitializeAsync(CancellationToken cancellationToken) => Task.CompletedTask; - } - - [Container] - [Singleton] - public static partial class MyContainer - { - } - """); - - await That(result.Diagnostics).DoesNotContain("*AWT121*").AsWildcard(); - } - } -} diff --git a/Tests/Awaiten.Tests/AsyncInitializationTests.cs b/Tests/Awaiten.Tests/AsyncInitializationTests.cs index c096853..f7052e2 100644 --- a/Tests/Awaiten.Tests/AsyncInitializationTests.cs +++ b/Tests/Awaiten.Tests/AsyncInitializationTests.cs @@ -885,4 +885,94 @@ public static partial class TokenForwardingContainer private static Task CreateAsync(CancellationToken cancellationToken) => Task.FromResult(new TokenAwareService(cancellationToken)); } + + [Fact] + public async Task AsyncRelationship_FuncOfTask_ResolvesAnInitializedServiceWithoutTaintingTheConsumer() + { + using RelationshipContainer.Root container = new(); + + // Pool is a plain (synchronously resolvable) singleton: the async relationships launder the taint, so it + // is reachable through Resolve. It pulls the async connection lazily through Func>. + Pool pool = container.Resolve(); + Connection connection = await pool.OpenAsync(); + + await That(connection.Initialized).IsTrue(); + } + + [Fact] + public async Task AsyncRelationship_TaskAndLazyDependencies_DeliverAnInitializedService() + { + using RelationshipContainer.Root container = new(); + + Pool pool = container.Resolve(); + + await That((await pool.Eager).Initialized).IsTrue(); + await That((await pool.Deferred.Value).Initialized).IsTrue(); + } + + [Fact] + public async Task AsyncRelationship_ParameterizedFuncOfTask_ForwardsTheArgumentAndInitializes() + { + using ParameterizedAsyncContainer.Root container = new(); + + // RobotFactory is a synchronously-resolvable singleton holding Func>; invoking it builds + // a fresh Robot from the runtime argument AND awaits its asynchronous initialization. + RobotFactory factory = container.Resolve(); + Robot robot = await factory.CreateAsync(42); + + await That(robot.Id).IsEqualTo(42); + await That(robot.Initialized).IsTrue(); + } + + public sealed class Pool + { + private readonly Func> _factory; + + public Pool(Func> factory, Lazy> lazy, Task task) + { + _factory = factory; + Deferred = lazy; + Eager = task; + } + + public Task Eager { get; } + + public Lazy> Deferred { get; } + + public Task OpenAsync() => _factory(); + } + + [Container] + [Singleton] + [Singleton] + public static partial class RelationshipContainer; + + public sealed class Robot : IAsyncInitializable + { + public Robot([Arg] int id) => Id = id; + + public int Id { get; } + + public bool Initialized { get; private set; } + + public Task InitializeAsync(CancellationToken cancellationToken) + { + Initialized = true; + return Task.CompletedTask; + } + } + + public sealed class RobotFactory + { + private readonly Func> _factory; + + public RobotFactory(Func> factory) => _factory = factory; + + public Task CreateAsync(int id) => _factory(id); + } + + [Container] + [Transient] + [Singleton] + public static partial class ParameterizedAsyncContainer; } From ba056d2063cc7fb969d84bf94ab15a0c6e11b493 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Tue, 30 Jun 2026 10:26:31 +0200 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20report=20AWT118=20for=20a=20root-hel?= =?UTF-8?q?d=20Func<=E2=80=A6,Task>=20over=20a=20disposable=20build-on-?= =?UTF-8?q?demand=20service?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root-accumulating-factory check (AWT118) only matched DependencyKind.Func, so the new async factory relationship Func<…,Task> slipped through. Its async resolver tracks freshly built disposables on the owner identically to the synchronous resolver, so a root-owned holder of Func<…,Task> over a disposable transient (or one that transitively rebuilds a disposable transient) accumulates instances on the container root for its entire lifetime - the same unbounded leak AWT118 exists to catch. Because a synchronous Func/Lazy/Owned over an async-tainted service is AWT119, Func<…,Task> is the only deferred factory that can reach an async service at all, so this was the one path on which the leak went unguarded - and under strict lifetime safety it silently bypassed what is otherwise a non-suppressible error. IsRootAccumulatingFunc now matches Func or FuncTask. The leak-free remedy differs by relationship, so the AWT118 message now carries it as a {2} argument rather than hardcoding the Owned form. A synchronous Func is still redirected to Func<…,Owned>; an async Func<…,Task> cannot use Owned - a synchronous handle that cannot await initialization, itself AWT119 - so it is pointed at an explicitly scoped resolution (await CreateScopeAsync(), ResolveAsync from that scope, then dispose the scope), mirroring the existing bare-type async withholding guidance. Strict lifetime safety reports it as the same non-suppressible error as the synchronous case. Add Docs/async-owned-disposal.md recording the staged roadmap this fix opens: Stage 1 (this change), Stage 2 (Func<…,Task>> reusing the existing Owned, gated to IDisposable targets), and Stage 3 (the IAsyncDisposable disposal pipeline plus an additive Owned.DisposeAsync()). Each stage is additive on the last; the note records why none of them block IAsyncDisposable and why a sync-over-async Dispose() shim is the one choice that would. --- Docs/async-owned-disposal.md | 56 +++++++++++++++++ .../AnalyzerReleases.Unshipped.md | 2 +- .../AwaitenAnalyzer.cs | 26 ++++++-- .../Awaiten.SourceGenerators/Diagnostics.cs | 9 ++- ...sticTests.Awt118RootAccumulatingFactory.cs | 61 +++++++++++++++++++ 5 files changed, 144 insertions(+), 10 deletions(-) create mode 100644 Docs/async-owned-disposal.md diff --git a/Docs/async-owned-disposal.md b/Docs/async-owned-disposal.md new file mode 100644 index 0000000..e01f350 --- /dev/null +++ b/Docs/async-owned-disposal.md @@ -0,0 +1,56 @@ +# Async owned disposal — roadmap + +Tracks the staged path from "async services can leak at the root" to "async services support `IAsyncDisposable` with a leak-free per-use handle". Each stage is additive on the previous one; nothing here forecloses the next step. + +## Background + +The container tracks disposables on the **scope that builds them**. A `Func<…>` is a factory: every call builds a fresh instance. When a **root-owned** holder (a singleton or pre-built instance) captures that factory, it is bound to the root scope, so every instance the factory ever builds is tracked on the root and only released when the whole container is disposed — an unbounded leak. AWT118 catches this for synchronous factories and points the developer at `Func<…, Owned>`, whose handle drains into a throwaway child scope. + +`Owned` is structurally synchronous: `__Owned` opens a scope and resolves `T` through a `Func` with no `await`, and `Owned.Value` is a synchronous property. An async-tainted service has no synchronous resolver to call (it needs `InitializeAsync`), which is why a synchronous `Owned`/`Func`/`Lazy` over one is **AWT119**. So async services have **no `Owned`-based escape hatch** — confirmed by the existing `AsyncRootWithheldMessage` (`Emitter.cs`), whose comment already states "`Owned` is a synchronous relationship, so it is not offered for an async-initialized service" and which directs callers to a child scope instead. + +Disposability today is keyed strictly on `System.IDisposable` (`AwaitenGenerator.BuildInstance`). `IAsyncDisposable`-only services are treated as non-disposable everywhere: never tracked, never disposed. The scope exposes `Dispose()` only — there is no `DisposeAsync()`. + +## Stage 1 — close the silent leak (done) + +AWT118 now also fires for `Func<…, Task>` (`DependencyKind.FuncTask`): the async fresh resolver tracks disposables identically to the synchronous one, so the async factory leaks the same way, and — because `Owned` is unavailable for async — `Func<…, Task>` is the *only* deferred factory that can reach an async-tainted target, so this was the one place the leak could occur unguarded. Because the `Owned` remedy is illegal for async, the AWT118 message carries the remedy as a `{2}` argument: the sync case keeps "resolve it as `Func<…, Owned>`", the async case points at an explicitly scoped resolution (`await CreateScopeAsync()`, `ResolveAsync` from that scope, then dispose the scope), mirroring `AsyncRootWithheldMessage`. Strict lifetime safety reports it as the same non-suppressible error as the sync case. + +This is correct but blunt: under strict safety it hard-blocks a root-held `Func<…, Task>` over a disposable async transient, and the only remedy is manual scoping. Stage 2 gives async services a real one-liner. + +## Stage 2 — `Func<…, Task>>` (async owned handle, sync disposal) + +Add the async mirror of `Func<…, Owned>`, **reusing the existing `Owned` struct** — no new public type: + +- Recognize `Task>` / `Func<…, Task>>` in `ClassifyRelationship` (a branch beside the existing `IsOwned(service)` one). +- Emit an async `__OwnedAsync` helper: open a child scope with `CreateScopeAsync` (which already warms async-initialized scoped services), `await` the target's async resolver into it (covering resolution *and* `InitializeAsync`), and return an `Owned` over that scope. +- AWT113 runtime-argument validation already generalizes to `FuncTask`; extend it to this kind too. + +Once this exists, the Stage 1 AWT118 message (and `AsyncRootWithheldMessage`) can additionally point at `Func<…, Task>>` rather than only "open a child scope by hand". + +**Constraint to keep the promise honest:** disposal is still synchronous `IDisposable` at this stage. For an `IAsyncDisposable`-**only** target (no `IDisposable`), the child scope's synchronous `Dispose()` would find nothing to dispose — a silent leak. So Stage 2 must **diagnose** an `IAsyncDisposable`-only owned target ("async disposal not yet supported; implement `IDisposable`, or await Stage 3") rather than appear to handle it. Stage 3 lifts that restriction with no API change. + +## Stage 3 — `IAsyncDisposable` support (async disposal pipeline) + +The actual async-disposal feature lives in the disposal pipeline, independent of the Stage 2 surface syntax: + +- Track `IAsyncDisposable` instances in the generated scope, not just `IDisposable`. +- Give the generated `Scope`/`Root` a `DisposeAsync()` that awaits `IAsyncDisposable.DisposeAsync()` with an `IDisposable` fallback. +- Make `Owned` implement `IAsyncDisposable` **additively**: + ```csharp + public readonly struct Owned : IDisposable, IAsyncDisposable + { + public void Dispose() => _scope?.Dispose(); + public ValueTask DisposeAsync() => _scope is IAsyncDisposable a ? a.DisposeAsync() : default; + } + ``` + Existing `using`/`.Dispose()` callers are unchanged; new code opts into `await using`. The relationship type (`Task>`) does not change shape — so Stage 2 does not need to be revisited. +- Lift the Stage 2 diagnostic that rejected `IAsyncDisposable`-only owned targets. + +**Trap to avoid:** never make `Owned.Dispose()` block on `DisposeAsync().GetAwaiter().GetResult()` to fake async disposal. Sync-over-async risks deadlock and bakes the wrong contract into the public API — *that* is the choice that would foreclose Stage 3. Keep synchronous `Dispose()` for `IDisposable`; add real `DisposeAsync()` here. + +## Why the ordering is safe + +| Decision | Forecloses `IAsyncDisposable`? | +|---|---| +| Stage 2 via `Func<…, Task>>` reusing `Owned` | No — `Owned` gains `IAsyncDisposable` additively; the scope async-dispose path is needed regardless | +| A separate new `AsyncOwned` type | No, but leaves two parallel handle types to reconcile | +| Sync-over-async `Dispose()` shim | **Yes — avoid** | diff --git a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md index dcae68c..e7c769b 100644 --- a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -19,6 +19,6 @@ AWT115 | Awaiten | Error | A service with [Arg] parameters is required as a plain, Lazy or Task dependency instead of a Func AWT116 | Awaiten | Error | A [Container] class is not declared static AWT117 | Awaiten | Error | Two registrations share the same service type and key - AWT118 | Awaiten | Warning | A root-owned instance holds a Func over a disposable build-on-demand service + AWT118 | Awaiten | Warning | A root-owned instance holds a Func or Func<…,Task> over a disposable build-on-demand service AWT119 | Awaiten | Error | A synchronous Func/Lazy/Owned relationship targets an async-initialized service AWT120 | Awaiten | Error | A synchronous Func/Lazy/Owned relationship reaches an async-tainted service transitively diff --git a/Source/Awaiten.SourceGenerators/AwaitenAnalyzer.cs b/Source/Awaiten.SourceGenerators/AwaitenAnalyzer.cs index 7d0eb83..1eaa749 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenAnalyzer.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenAnalyzer.cs @@ -179,24 +179,38 @@ private static void AddAccumulatingFuncs( continue; } + string service = AwaitenGenerator.Display(parameter.ServiceType); + + // The leak-free remedy differs by relationship. A synchronous Func is redirected to a + // Func<…, Owned> disposal handle; an async Func<…, Task> cannot use Owned (a synchronous + // handle that cannot await initialization - AWT119), so it is pointed at an explicitly scoped + // resolution instead, mirroring the bare-type async withholding guidance. + string remedy = parameter.Kind == DependencyKind.FuncTask + ? "resolve it within a child scope (await CreateScopeAsync(), ResolveAsync from that scope, then dispose the scope) for per-use disposal" + : $"resolve it as Func<…, Owned<{service}>> for per-use disposal"; + diagnostics.Add(new DiagnosticInfo( descriptor, parameter.Location ?? graph.InstanceLocations[node], new EquatableArray([ - AwaitenGenerator.Display(parameter.ServiceType), + service, AwaitenGenerator.Display(holder.ImplementationType), + remedy, ]), severity)); } } - // A plain Func<…> (not a Func<…, Owned>) over a build-on-demand service (a transient or parameterized - // service) whose construction tracks a fresh disposable on its owner - the produced service itself is - // disposable, or it transitively rebuilds a disposable transient. Each call to such a Func, bound to the - // root, builds and re-tracks those disposables on the root, so they accumulate for the container's lifetime. + // A plain Func<…> or its async form Func<…, Task> (but not a Func<…, Owned>) over a build-on-demand + // service (a transient or parameterized service) whose construction tracks a fresh disposable on its owner - + // the produced service itself is disposable, or it transitively rebuilds a disposable transient. Each call to + // such a Func, bound to the root, builds and re-tracks those disposables on the root, so they accumulate for + // the container's lifetime. The async resolver tracks disposables identically to the synchronous one, so the + // async factory leaks the same way and is included here - and since Owned is unavailable for an async + // service, the async form is the only deferred factory that can reach an async-tainted target at all. private static bool IsRootAccumulatingFunc(GraphModel graph, Dictionary serviceToIndex, ParameterModel parameter) { - if (parameter.Kind != DependencyKind.Func || parameter.ProducesOwned + if (parameter.Kind is not (DependencyKind.Func or DependencyKind.FuncTask) || parameter.ProducesOwned || !graph.ServiceToImpl.TryGetValue(new ServiceKey(parameter.ServiceType, parameter.Key), out string? targetImpl) || !graph.ImplToIndex.TryGetValue(targetImpl, out int targetIndex)) { diff --git a/Source/Awaiten.SourceGenerators/Diagnostics.cs b/Source/Awaiten.SourceGenerators/Diagnostics.cs index 5a91a45..1612995 100644 --- a/Source/Awaiten.SourceGenerators/Diagnostics.cs +++ b/Source/Awaiten.SourceGenerators/Diagnostics.cs @@ -226,8 +226,11 @@ internal static class Diagnostics /// parameterized service) whose construction tracks a fresh disposable on the root - the produced /// service is itself disposable, or it transitively rebuilds a disposable transient. Each call to that /// factory builds and re-tracks those disposables on the container's root, so they accumulate for its - /// entire lifetime - an unbounded leak. Resolving through Func<…, Owned<T>> instead - /// hands each instance back as a disposal handle (draining into a throwaway scope), so nothing accumulates. + /// entire lifetime - an unbounded leak. The leak-free remedy is the {2} message argument, since it + /// differs by relationship: a synchronous Func<…> is redirected to a + /// Func<…, Owned<T>> disposal handle (draining into a throwaway scope), while an + /// asynchronous Func<…, Task<T>> cannot use Owned<T> - a synchronous handle + /// that cannot await initialization (AWT119) - so it is pointed at an explicitly scoped resolution instead. /// /// /// Unlike the retired per-registration check, this is flow-based: it fires only for the statically @@ -239,7 +242,7 @@ internal static class Diagnostics public static readonly DiagnosticDescriptor RootAccumulatingFactory = new( "AWT118", "Factory accumulates disposables on the container root", - "'{1}' holds a Func over '{0}', which is built on demand; the instances it builds - and the disposables created while constructing them - are tracked on the container root and accumulate for its lifetime; resolve it as Func<…, Owned<{0}>> for per-use disposal", + "'{1}' holds a Func over '{0}', which is built on demand; the instances it builds - and the disposables created while constructing them - are tracked on the container root and accumulate for its lifetime; {2}", "Awaiten", DiagnosticSeverity.Warning, isEnabledByDefault: true); diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt118RootAccumulatingFactory.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt118RootAccumulatingFactory.cs index 84a5e60..e36ea80 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt118RootAccumulatingFactory.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt118RootAccumulatingFactory.cs @@ -154,5 +154,66 @@ public static partial class MyContainer await That(diagnostics.Any(d => d.Contains("AWT118"))).IsTrue() .Because("building the non-disposable Tool on demand rebuilds its disposable transient Spark, which accumulates on the root just the same"); } + + [Fact] + public async Task ReportsWhenASingletonHoldsAFuncOfTaskOverADisposableAsyncTransient() + { + string[] diagnostics = await Analyzer.Run(""" + using Awaiten; + using System; + using System.Threading; + using System.Threading.Tasks; + + namespace MyCode; + + public sealed class Conn : IAsyncInitializable, IDisposable + { + public Task InitializeAsync(CancellationToken ct) => Task.CompletedTask; + public void Dispose() { } + } + public sealed class Pool { public Pool(Func> open) { } } + + [Container] + [Transient] + [Singleton] + public static partial class MyContainer + { + } + """); + + await That(diagnostics.Any(d => d.Contains("AWT118"))).IsTrue() + .Because("a singleton holding a Func<…, Task> over a disposable async transient accumulates initialized instances on the root, just as the synchronous Func does - and Func<…, Task> is the only deferred factory that can reach an async service"); + await That(diagnostics.Any(d => d.Contains("AWT118") && d.Contains("child scope") && !d.Contains("Owned<"))).IsTrue() + .Because("the async remedy points at a child scope, not the Owned handle, which is itself illegal (AWT119) for an async service"); + } + + [Fact] + public async Task DoesNotReportForANonDisposableAsyncTransientFactory() + { + string[] diagnostics = await Analyzer.Run(""" + using Awaiten; + using System; + using System.Threading; + using System.Threading.Tasks; + + namespace MyCode; + + public sealed class Conn : IAsyncInitializable + { + public Task InitializeAsync(CancellationToken ct) => Task.CompletedTask; + } + public sealed class Pool { public Pool(Func> open) { } } + + [Container] + [Transient] + [Singleton] + public static partial class MyContainer + { + } + """); + + await That(diagnostics.Any(d => d.Contains("AWT118"))).IsFalse() + .Because("a non-disposable async transient leaves nothing to accumulate, so the async factory is not flagged"); + } } } From c26ea8032fce9e19e9ca69c9b07387ea88eff578 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Tue, 30 Jun 2026 11:48:02 +0200 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20add=20async=20owned=20relationships?= =?UTF-8?q?=20(Func<=E2=80=A6,Task>>)=20and=20IAsyncDisposable=20?= =?UTF-8?q?support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the async leak-free factory relationships Task> and Func<…,Task>> - the awaitable counterparts of Owned / Func<…,Owned>. Each async-resolves (and initializes) T into a throwaway child scope through a new __OwnedAsync helper and hands back the Owned disposal handle, so a synchronously-resolvable consumer can build async-initialized, disposable services on demand without the root accumulating them. They are classified in ClassifyParameter/ClassifyRelationship as the Task/FuncTask kinds with ProducesOwned, validated by AWT113, and resolved through the existing Owned type - no new public type. The AWT118 message and AsyncRootWithheldMessage now point at Func<…,Task>> as the leak-free async remedy. This resolution path needs no IAsyncDisposable and ships on every target framework with synchronous IDisposable disposal of the handle. Add IAsyncDisposable support as an additive, net8.0+/polyfilled-only capability gated by compilation detection rather than a forced dependency. The generator checks GetTypeByMetadataName("System.IAsyncDisposable"): when the consumer's compilation can see the type (net5.0+/netstandard2.1+ in-box, or an older target that referenced Microsoft.Bcl.AsyncInterfaces) it emits the async-disposal machinery; when absent (e.g. net48 without the polyfill) the container is synchronous-dispose only and references no IAsyncDisposable, so it still compiles. No dependency was added to the library. IAsyncDisposable is folded into the disposal decision (InstanceModel.NeedsDisposal = IsDisposable || IsAsyncDisposable), which also flows into the leak analyses (AWT118 / by-type withholding / BuildsFreshDisposable); tracked instances share the existing List and the drain pattern-matches at runtime. The generated concrete Scope (inherited by the Root) implements IAsyncDisposable and gets a DisposeAsync that drains newest-first, awaiting IAsyncDisposable.DisposeAsync and falling back to Dispose. This is deliberately not added to the IAwaitenScope interface, which would break every hand-implementer (the MS.DI adapter, test doubles, external code); a concrete-class implementation gives await using on the container/scope without that break. The synchronous Dispose throws InvalidOperationException when it meets an IAsyncDisposable-only service rather than blocking on an async dispose (matching Microsoft.Extensions.DependencyInjection); the raced-during-construction teardown was restructured to flag-then-tear-down outside the lock so the async path can await, and its runtime checks go through (object)created so a sealed type implementing only one disposal interface still compiles. Owned implements IAsyncDisposable under a #if for the frameworks whose BCL has it, additively (using/.Dispose() callers are unchanged), routing DisposeAsync to the backing scope. Docs/async-owned-disposal.md records the implemented design and the decisions (net8.0+ detection over a dependency, concrete Scope over the interface, sync Dispose throws over sync-over-async). Expected public-API snapshots updated for the additive Owned change on net8.0/net10.0. --- Docs/async-owned-disposal.md | 38 +- .../AwaitenAnalyzer.cs | 6 +- .../AwaitenGenerator.cs | 56 ++- Source/Awaiten.SourceGenerators/Emitter.cs | 352 ++++++++++++++---- .../Internals/ContainerModel.cs | 3 +- .../Internals/InstanceModel.cs | 12 +- Source/Awaiten/Owned.cs | 20 +- .../Expected/Awaiten_net10.0.txt | 3 +- .../Expected/Awaiten_net8.0.txt | 3 +- .../AsyncRelationshipTypesTests.cs | 75 ++++ ...sticTests.Awt118RootAccumulatingFactory.cs | 4 +- .../GeneralTests.cs | 6 +- .../Awaiten.Tests/AsyncInitializationTests.cs | 185 +++++++++ 13 files changed, 636 insertions(+), 127 deletions(-) diff --git a/Docs/async-owned-disposal.md b/Docs/async-owned-disposal.md index e01f350..8506846 100644 --- a/Docs/async-owned-disposal.md +++ b/Docs/async-owned-disposal.md @@ -26,31 +26,27 @@ Add the async mirror of `Func<…, Owned>`, **reusing the existing `Owned` Once this exists, the Stage 1 AWT118 message (and `AsyncRootWithheldMessage`) can additionally point at `Func<…, Task>>` rather than only "open a child scope by hand". -**Constraint to keep the promise honest:** disposal is still synchronous `IDisposable` at this stage. For an `IAsyncDisposable`-**only** target (no `IDisposable`), the child scope's synchronous `Dispose()` would find nothing to dispose — a silent leak. So Stage 2 must **diagnose** an `IAsyncDisposable`-only owned target ("async disposal not yet supported; implement `IDisposable`, or await Stage 3") rather than appear to handle it. Stage 3 lifts that restriction with no API change. +Stage 2 needs **no** `IAsyncDisposable` at all — it only async-resolves into a child scope and returns an ordinary `Owned`, so it ships on every target framework (net48 included), with synchronous `IDisposable` disposal of the handle. Async *disposal* of what the handle owns is Stage 3. -## Stage 3 — `IAsyncDisposable` support (async disposal pipeline) +**Status: done.** `Task>` and `Func<…, Task>>` (`DependencyKind` `Task`/`FuncTask` with `ProducesOwned`) are classified in `ClassifyParameter`/`ClassifyRelationship`, emitted through the new `__OwnedAsync` helper (the async twin of `__Owned`: open a child scope, await the target's async resolver into it, return the `Owned`), and validated by AWT113. The AWT118 message and `AsyncRootWithheldMessage` now point at `Func<…, Task>>` as the leak-free async remedy. -The actual async-disposal feature lives in the disposal pipeline, independent of the Stage 2 surface syntax: +## Stage 3 — `IAsyncDisposable` support (async disposal pipeline) — done -- Track `IAsyncDisposable` instances in the generated scope, not just `IDisposable`. -- Give the generated `Scope`/`Root` a `DisposeAsync()` that awaits `IAsyncDisposable.DisposeAsync()` with an `IDisposable` fallback. -- Make `Owned` implement `IAsyncDisposable` **additively**: - ```csharp - public readonly struct Owned : IDisposable, IAsyncDisposable - { - public void Dispose() => _scope?.Dispose(); - public ValueTask DisposeAsync() => _scope is IAsyncDisposable a ? a.DisposeAsync() : default; - } - ``` - Existing `using`/`.Dispose()` callers are unchanged; new code opts into `await using`. The relationship type (`Task>`) does not change shape — so Stage 2 does not need to be revisited. -- Lift the Stage 2 diagnostic that rejected `IAsyncDisposable`-only owned targets. +Implemented as an additive, **net8.0+ / polyfilled-only** capability, gated by detection rather than a forced dependency (per the project decision: net8.0+ async disposal, no new NuGet dependency): -**Trap to avoid:** never make `Owned.Dispose()` block on `DisposeAsync().GetAwaiter().GetResult()` to fake async disposal. Sync-over-async risks deadlock and bakes the wrong contract into the public API — *that* is the choice that would foreclose Stage 3. Keep synchronous `Dispose()` for `IDisposable`; add real `DisposeAsync()` here. +- **Detection, not dependency.** The generator checks `compilation.GetTypeByMetadataName("System.IAsyncDisposable")`. When present (net5.0+/netstandard2.1+ in-box, or an older target that added `Microsoft.Bcl.AsyncInterfaces`), it emits the async-disposal machinery; when absent (e.g. net48 without the polyfill) the container is synchronous-dispose only and references no `IAsyncDisposable`, so it still compiles. No dependency was added to the library. +- **Tracking.** `IAsyncDisposable` is folded into the disposal decision (`InstanceModel.NeedsDisposal = IsDisposable || IsAsyncDisposable`), which also flows into the leak analyses (AWT118 / by-type withholding / `BuildsFreshDisposable`). Tracked instances live in the same `List`; the drain pattern-matches at runtime. +- **`DisposeAsync`.** The generated **concrete `Scope`** implements `IAsyncDisposable` (the `Root` inherits it) and gets a `DisposeAsync()` that drains newest-first, awaiting `IAsyncDisposable.DisposeAsync()` and falling back to `Dispose()`. This was **not** added to the `IAwaitenScope` interface — doing so would break every hand-implementer (the MS.DI adapter, test doubles, external code); a concrete-class implementation gives `await using` on the container/scope without that break. +- **Sync `Dispose()` throws.** When the synchronous `Dispose()` meets an instance that is `IAsyncDisposable` but not `IDisposable`, it throws `InvalidOperationException` (matching Microsoft.Extensions.DependencyInjection) rather than blocking on an async dispose. +- **`Owned`** implements `IAsyncDisposable` under `#if NET || NETSTANDARD2_1_OR_GREATER` (additive; `using`/`.Dispose()` callers unchanged), with `DisposeAsync()` routing to the backing scope via `_scope is IAsyncDisposable`. -## Why the ordering is safe +**Trap avoided:** `Owned.Dispose()` / the scope's `Dispose()` never block on `DisposeAsync().GetAwaiter().GetResult()`. Sync-over-async risks deadlock and would bake the wrong contract into the public API; the synchronous path stays synchronous and throws for async-only services instead. -| Decision | Forecloses `IAsyncDisposable`? | +## Why the design holds + +| Decision | Outcome | |---|---| -| Stage 2 via `Func<…, Task>>` reusing `Owned` | No — `Owned` gains `IAsyncDisposable` additively; the scope async-dispose path is needed regardless | -| A separate new `AsyncOwned` type | No, but leaves two parallel handle types to reconcile | -| Sync-over-async `Dispose()` shim | **Yes — avoid** | +| Async-owned resolution (`Func<…, Task>>`) reuses `Owned` | One handle type; ships on all TFMs with sync disposal | +| `IAsyncDisposable` detected in the compilation, not depended upon | net8.0+ (and polyfilled) get async disposal; net48 stays sync-only and still compiles; no new dependency | +| `IAsyncDisposable` on the concrete `Scope`, not the `IAwaitenScope` interface | `await using` works on containers/scopes without breaking hand-implementers | +| Sync `Dispose()` throws on an async-only service | No sync-over-async; clear contract | diff --git a/Source/Awaiten.SourceGenerators/AwaitenAnalyzer.cs b/Source/Awaiten.SourceGenerators/AwaitenAnalyzer.cs index 1eaa749..0cb45b9 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenAnalyzer.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenAnalyzer.cs @@ -183,10 +183,10 @@ private static void AddAccumulatingFuncs( // The leak-free remedy differs by relationship. A synchronous Func is redirected to a // Func<…, Owned> disposal handle; an async Func<…, Task> cannot use Owned (a synchronous - // handle that cannot await initialization - AWT119), so it is pointed at an explicitly scoped - // resolution instead, mirroring the bare-type async withholding guidance. + // handle that cannot await initialization - AWT119), so it is redirected to the async owned form + // Func<…, Task>>, which async-resolves each instance into a throwaway scope. string remedy = parameter.Kind == DependencyKind.FuncTask - ? "resolve it within a child scope (await CreateScopeAsync(), ResolveAsync from that scope, then dispose the scope) for per-use disposal" + ? $"resolve it as Func<…, Task>> for per-use disposal" : $"resolve it as Func<…, Owned<{service}>> for per-use disposal"; diagnostics.Add(new DiagnosticInfo( diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs index a7f81b2..3a55940 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs @@ -67,6 +67,13 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // InitializeAsync has warmed them, and the AWT119/AWT120 sync-resolution diagnostics are not reported. bool syncResolveAfterInit = ReadSyncResolveAfterInit(containerSymbol); + // IAsyncDisposable support (the async drain, DisposeAsync on the Scope/Root, and tracking of + // async-disposable services) is emitted only when the consumer's compilation can see the type: net5.0+ + // and netstandard2.1+ have it in-box, and an older target (e.g. net48 / netstandard2.0) may add it + // through Microsoft.Bcl.AsyncInterfaces. When it is absent the generated container is synchronous-dispose + // only and references no IAsyncDisposable, so it still compiles there. + bool hasAsyncDisposable = compilation.GetTypeByMetadataName("System.IAsyncDisposable") is not null; + List diagnostics = new(); // The container must be a static class: it is a pure definition (registrations plus static factory @@ -127,7 +134,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) new EquatableArray(graph.Instances.ToArray()), new EquatableArray(diagnostics.ToArray()), strict, - syncResolveAfterInit); + syncResolveAfterInit, + hasAsyncDisposable); } /// @@ -144,6 +152,7 @@ internal static GraphModel BuildGraph( CancellationToken cancellationToken) { INamedTypeSymbol? disposableSymbol = compilation.GetTypeByMetadataName("System.IDisposable"); + INamedTypeSymbol? asyncDisposableSymbol = compilation.GetTypeByMetadataName("System.IAsyncDisposable"); INamedTypeSymbol? asyncInitializableSymbol = compilation.GetTypeByMetadataName("Awaiten.IAsyncInitializable"); List raw = ContainerRegistrations.Collect(containerSymbol); @@ -162,7 +171,7 @@ internal static GraphModel BuildGraph( foreach (ImplInfo info in implOrder) { cancellationToken.ThrowIfCancellationRequested(); - InstanceModel? instance = BuildInstance(info, containerSymbol, compilation, serviceToImpl, disposableSymbol, asyncInitializableSymbol, diagnostics); + InstanceModel? instance = BuildInstance(info, containerSymbol, compilation, serviceToImpl, disposableSymbol, asyncDisposableSymbol, asyncInitializableSymbol, diagnostics); if (instance is not null) { implToIndex[info.ImplementationType] = instances.Count; @@ -216,7 +225,7 @@ internal static bool BuildsFreshDisposable( } InstanceModel instance = instances[node]; - if (instance.IsDisposable) + if (instance.NeedsDisposal) { return true; } @@ -389,6 +398,7 @@ private static Dictionary> BuildDependencyGraph( Compilation compilation, Dictionary serviceToImpl, INamedTypeSymbol? disposableSymbol, + INamedTypeSymbol? asyncDisposableSymbol, INamedTypeSymbol? asyncInitializableSymbol, List diagnostics) { @@ -446,15 +456,22 @@ private static Dictionary> BuildDependencyGraph( : 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 - // interface (or base class), which the static `disposable` flag above misses. When that is possible - - // the declared type is not itself disposable yet a subtype could be (an interface or a non-sealed - // class) - the emitter tracks the realized instance for disposal behind a runtime `is IDisposable` - // test instead. A sealed declared type that is not IDisposable cannot hide one, so it needs no check - // (and a runtime `is IDisposable` against it would not even compile). Constructed and pre-built + // Async disposal mirrors synchronous disposal: the container owns an IAsyncDisposable instance for + // teardown too, and the drain awaits its DisposeAsync (preferring it over IDisposable when a type is + // both). Only recognized when the compilation can see IAsyncDisposable (asyncDisposableSymbol non-null); + // otherwise the generated container is synchronous-dispose only. + bool asyncDisposable = asyncDisposableSymbol is not null && ImplementsInterface(disposalType, asyncDisposableSymbol); + + // A factory's declared return type can hide a concrete IDisposable (or IAsyncDisposable) behind a + // non-disposable service interface (or base class), which the static flags above miss. When that is + // possible - the declared type is itself neither yet a subtype could be (an interface or a non-sealed + // class) - the emitter tracks the realized instance for disposal behind a runtime + // `is IDisposable or IAsyncDisposable` test instead. A sealed declared type that is neither cannot hide + // one, so it needs no check (and the runtime test would not even compile). Constructed and pre-built // Instance production never lie: info.Symbol is the concrete type, and an Instance is not owned. bool runtimeDisposalCheck = info.Production == ProductionKind.Factory && !disposable + && !asyncDisposable && CouldHideDisposable(disposalType); // Async initialization follows the type the container actually owns - a factory's concrete return type @@ -488,7 +505,8 @@ private static Dictionary> BuildDependencyGraph( info.ProductionMember, asyncInit, IsAsyncFactory: asyncFactory, - RuntimeDisposalCheck: runtimeDisposalCheck); + RuntimeDisposalCheck: runtimeDisposalCheck, + IsAsyncDisposable: asyncDisposable); static bool ImplementsInterface(ITypeSymbol type, INamedTypeSymbol @interface) { @@ -854,7 +872,11 @@ private static ParameterModel ClassifyParameter(IParameterSymbol parameter, bool // relationship gate below (which handles the Func/Lazy wrappers, including Func<…, Task>). if (IsTask(parameter.Type, out ITypeSymbol taskResult)) { - return new ParameterModel(taskResult.ToDisplayString(FullyQualified), DependencyKind.Task, Key: key, Location: location); + // Task> is the async counterpart of a bare Owned: async-resolve (and initialize) T into a + // throwaway child scope and hand back the disposal handle. + return IsOwned(taskResult, out ITypeSymbol taskOwnedInner) + ? new ParameterModel(taskOwnedInner.ToDisplayString(FullyQualified), DependencyKind.Task, Key: key, Location: location, ProducesOwned: true) + : new ParameterModel(taskResult.ToDisplayString(FullyQualified), DependencyKind.Task, Key: key, Location: location); } if (parameter.Type is INamedTypeSymbol { IsGenericType: true, } named @@ -901,11 +923,17 @@ private static ParameterModel ClassifyParameter(IParameterSymbol parameter, bool // Func<…, Task> is the async counterpart of Func<…, T>: an async factory that resolves (and // initializes) T, awaiting it. It forwards any leading runtime arguments to T's [Arg] parameters. + // Func<…, Task>> is its leak-free form: each call async-resolves T into a throwaway child scope + // and hands back the Owned disposal handle (the async counterpart of Func<…, Owned>). if (IsTask(service, out ITypeSymbol funcTaskResult)) { - return new ParameterModel( - funcTaskResult.ToDisplayString(FullyQualified), DependencyKind.FuncTask, - new EquatableArray(argTypes), Key: key, Location: location); + return IsOwned(funcTaskResult, out ITypeSymbol funcTaskOwnedInner) + ? new ParameterModel( + funcTaskOwnedInner.ToDisplayString(FullyQualified), DependencyKind.FuncTask, + new EquatableArray(argTypes), Key: key, Location: location, ProducesOwned: true) + : new ParameterModel( + funcTaskResult.ToDisplayString(FullyQualified), DependencyKind.FuncTask, + new EquatableArray(argTypes), Key: key, Location: location); } // Func<…, Owned> is the leak-free factory: its produced value is an Owned disposal handle. diff --git a/Source/Awaiten.SourceGenerators/Emitter.cs b/Source/Awaiten.SourceGenerators/Emitter.cs index 2e98941..74aabee 100644 --- a/Source/Awaiten.SourceGenerators/Emitter.cs +++ b/Source/Awaiten.SourceGenerators/Emitter.cs @@ -42,7 +42,7 @@ public static string Emit(ContainerModel model) if (model.HasErrors) { - EmitErrorBody(builder, depth + 1, model.TypeName); + EmitErrorBody(builder, depth + 1, model.TypeName, model.HasAsyncDisposable); } else { @@ -172,9 +172,9 @@ private static void EmitContainerBody(StringBuilder builder, int depth, Containe // generated lives on it directly. All state and resolution live on those types: the base Scope holds // the static dispatch table plus scoped/transient logic and delegates singletons to the root, while // the sealed Root subclass owns the singletons and is the usable instance (new MyContainer.Root()). - EmitRootClass(builder, depth, instances, names, serviceToIndex, model.SyncResolveAfterInit); + EmitRootClass(builder, depth, instances, names, serviceToIndex, model.SyncResolveAfterInit, model.HasAsyncDisposable); builder.AppendLine(); - EmitScopeBaseClass(builder, depth, instances, names, serviceToIndex, model.Strict, model.SyncResolveAfterInit); + EmitScopeBaseClass(builder, depth, instances, names, serviceToIndex, model.Strict, model.SyncResolveAfterInit, model.HasAsyncDisposable); } /// @@ -186,7 +186,7 @@ private static void EmitContainerBody(StringBuilder builder, int depth, Containe /// the table, gates it). /// private static bool IsWithheld(InstanceModel instance, bool strict) - => strict && instance.IsDisposable && (instance.Lifetime == Lifetime.Transient || instance.IsParameterized); + => strict && instance.NeedsDisposal && (instance.Lifetime == Lifetime.Transient || instance.IsParameterized); /// /// Whether a plain Func<…> over this service is withheld from by-type resolution on the Root @@ -226,7 +226,7 @@ private enum DisposalTracking /// either statically disposable or runtime-checked, never both. /// private static DisposalTracking DisposalOf(InstanceModel instance) - => (instance.RuntimeDisposalCheck, instance.IsDisposable) switch + => (instance.RuntimeDisposalCheck, instance.NeedsDisposal) switch { (true, _) => DisposalTracking.Runtime, (_, true) => DisposalTracking.Static, @@ -285,20 +285,23 @@ private static string AsyncWithheldMessage(string service) /// build-on-demand service that needs asynchronous initialization is requested by its bare type: building it /// on demand from the Root tracks a fresh disposable on the root for the container's lifetime (an unbounded /// leak), so the Root withholds it. It stays resolvable from a child scope, whose disposal bounds it - the - /// async counterpart to BareWithheldMessage (Owned<T> is a synchronous relationship, so it is - /// not offered for an async-initialized service). + /// async counterpart to BareWithheldMessage (a synchronous Owned<T> cannot await + /// initialization, so the async owned form Func<…, Task<Owned<T>>> is offered). /// private static string AsyncRootWithheldMessage(string service) { string display = service.Replace("global::", string.Empty); - return $"\"Awaiten: '{display}' is a disposable transient that needs asynchronous initialization; resolving it through ResolveAsync on the container root would track a fresh disposable on the root for the container's lifetime, so it is withheld there under strict lifetime safety. Resolve it from a child scope (await CreateScopeAsync(), ResolveAsync from that scope, then dispose the scope), or set LifetimeSafety.Loose on the [Container].\""; + return $"\"Awaiten: '{display}' is a disposable transient that needs asynchronous initialization; resolving it through ResolveAsync on the container root would track a fresh disposable on the root for the container's lifetime, so it is withheld there under strict lifetime safety. Obtain it through Func<…, Task>> (or Task>) for per-use disposal, resolve it from a child scope (await CreateScopeAsync(), ResolveAsync from that scope, then dispose the scope), or set LifetimeSafety.Loose on the [Container].\""; } /// - /// Emits the reverse-order drain of the (possibly null) __toDispose list captured under the - /// lock - disposing what the owner created, newest first. + /// Emits the reverse-order synchronous drain of the (possibly null) __toDispose list captured + /// under the lock - disposing what the owner created, newest first. When + /// is set, an instance that is IAsyncDisposable but not IDisposable cannot be torn down on + /// this synchronous path, so it throws guidance to use DisposeAsync (matching + /// Microsoft.Extensions.DependencyInjection rather than blocking on an async dispose). /// - private static void EmitDrainDisposables(StringBuilder builder, int depth) + private static void EmitDrainDisposables(StringBuilder builder, int depth, bool asyncDisposal) { Indent(builder, depth).AppendLine("if (__toDispose is not null)"); Indent(builder, depth).AppendLine("{"); @@ -308,6 +311,37 @@ private static void EmitDrainDisposables(StringBuilder builder, int depth) Indent(builder, depth + 2).AppendLine("{"); Indent(builder, depth + 3).AppendLine("__disposable.Dispose();"); Indent(builder, depth + 2).AppendLine("}"); + if (asyncDisposal) + { + Indent(builder, depth + 2).AppendLine("else if (__toDispose[__index] is global::System.IAsyncDisposable)"); + Indent(builder, depth + 2).AppendLine("{"); + Indent(builder, depth + 3).AppendLine("throw new global::System.InvalidOperationException(\"Awaiten: a resolved service requires asynchronous disposal (it implements IAsyncDisposable but not IDisposable); dispose this scope or container with DisposeAsync ('await using') instead of a synchronous Dispose().\");"); + Indent(builder, depth + 2).AppendLine("}"); + } + + Indent(builder, depth + 1).AppendLine("}"); + Indent(builder, depth).AppendLine("}"); + } + + /// + /// Emits the reverse-order asynchronous drain of the captured __toDispose list: each instance is + /// torn down newest-first, awaiting IAsyncDisposable.DisposeAsync when available and falling back + /// to a synchronous IDisposable.Dispose otherwise. + /// + private static void EmitDrainDisposablesAsync(StringBuilder builder, int depth) + { + Indent(builder, depth).AppendLine("if (__toDispose is not null)"); + Indent(builder, depth).AppendLine("{"); + Indent(builder, depth + 1).AppendLine("for (int __index = __toDispose.Count - 1; __index >= 0; __index--)"); + Indent(builder, depth + 1).AppendLine("{"); + Indent(builder, depth + 2).AppendLine("if (__toDispose[__index] is global::System.IAsyncDisposable __asyncDisposable)"); + Indent(builder, depth + 2).AppendLine("{"); + Indent(builder, depth + 3).AppendLine("await __asyncDisposable.DisposeAsync().ConfigureAwait(false);"); + Indent(builder, depth + 2).AppendLine("}"); + Indent(builder, depth + 2).AppendLine("else if (__toDispose[__index] is global::System.IDisposable __disposable)"); + Indent(builder, depth + 2).AppendLine("{"); + Indent(builder, depth + 3).AppendLine("__disposable.Dispose();"); + Indent(builder, depth + 2).AppendLine("}"); Indent(builder, depth + 1).AppendLine("}"); Indent(builder, depth).AppendLine("}"); } @@ -317,9 +351,17 @@ private static void EmitDrainDisposables(StringBuilder builder, int depth) /// itself, constructs transients, and resolves singletons through protected virtual delegators /// that the Root subclass overrides. Child (request) scopes are instances of this type. /// - private static void EmitScopeBaseClass(StringBuilder builder, int depth, InstanceModel[] instances, Names names, Dictionary serviceToIndex, bool strict, bool syncResolveAfterInit) + private static void EmitScopeBaseClass(StringBuilder builder, int depth, InstanceModel[] instances, Names names, Dictionary serviceToIndex, bool strict, bool syncResolveAfterInit, bool asyncDisposal) { Indent(builder, depth).Append("public class Scope : global::Awaiten.IAwaitenScope"); + if (asyncDisposal) + { + // IAsyncDisposable is implemented on the concrete Scope (not added to the IAwaitenScope interface, + // which would break every hand-implementer): the Root inherits it, and `await using` works on the + // concrete container/scope. Emitted only when the compilation can see the type. + builder.Append(", global::System.IAsyncDisposable"); + } + EmitGenericResolverBases(builder, instances, strict, syncResolveAfterInit); builder.AppendLine(); Indent(builder, depth).AppendLine("{"); @@ -429,19 +471,25 @@ private static void EmitScopeBaseClass(StringBuilder builder, int depth, Instanc } else { - EmitScopeResolver(builder, body, i, instances[i], instances, names, serviceToIndex); + EmitScopeResolver(builder, body, i, instances[i], instances, names, serviceToIndex, asyncDisposal); } } if (instances[i].IsAsyncTainted) { builder.AppendLine(); - EmitAsyncScopeResolver(builder, body, i, instances[i], instances, names, serviceToIndex); + EmitAsyncScopeResolver(builder, body, i, instances[i], instances, names, serviceToIndex, asyncDisposal); } } builder.AppendLine(); - EmitDispose(builder, body); + EmitDispose(builder, body, asyncDisposal); + + if (asyncDisposal) + { + builder.AppendLine(); + EmitDisposeAsync(builder, body); + } Indent(builder, depth).AppendLine("}"); } @@ -452,7 +500,7 @@ private static void EmitScopeBaseClass(StringBuilder builder, int depth, Instanc /// singleton delegators with the real caching/member access, so a child scope delegating through /// __root lands here. /// - private static void EmitRootClass(StringBuilder builder, int depth, InstanceModel[] instances, Names names, Dictionary serviceToIndex, bool syncResolveAfterInit) + private static void EmitRootClass(StringBuilder builder, int depth, InstanceModel[] instances, Names names, Dictionary serviceToIndex, bool syncResolveAfterInit, bool asyncDisposal) { // The Root is the composition root (IAwaitenRoot): it adds InitializeAsync to warm the singletons. A // child scope is only an IAwaitenScope - it is warmed when created (CreateScopeAsync), never explicitly. @@ -489,13 +537,13 @@ private static void EmitRootClass(StringBuilder builder, int depth, InstanceMode if (EmitsSync(instance, syncResolveAfterInit) && !instance.IsAsyncTainted) { builder.AppendLine(); - EmitRootResolver(builder, body, i, instance, instances, names, serviceToIndex); + EmitRootResolver(builder, body, i, instance, instances, names, serviceToIndex, asyncDisposal); } if (instance.IsAsyncTainted) { builder.AppendLine(); - EmitAsyncRootResolver(builder, body, i, instance, instances, names, serviceToIndex); + EmitAsyncRootResolver(builder, body, i, instance, instances, names, serviceToIndex, asyncDisposal); } } @@ -919,7 +967,7 @@ private static void EmitRootWithheldMask(StringBuilder builder, int depth, List< /// delegator to __root (overridden by the Root); scoped services cache on the scope; /// transients construct fresh. /// - private static void EmitScopeResolver(StringBuilder builder, int depth, int index, InstanceModel instance, InstanceModel[] instances, Names names, Dictionary serviceToIndex) + private static void EmitScopeResolver(StringBuilder builder, int depth, int index, InstanceModel instance, InstanceModel[] instances, Names names, Dictionary serviceToIndex, bool asyncDisposal) { string type = instance.ImplementationType; string resolver = names.Resolver(index); @@ -932,7 +980,7 @@ private static void EmitScopeResolver(StringBuilder builder, int depth, int inde // Reachable from the Root (a singleton's Func binds it there) and from a throwaway // Owned scope built off any owner (__s.ResolveX(args)), so it is internal rather than protected - // protected would not be callable through a base-typed scope reference from the derived Root (CS1540). - EmitFreshResolver(builder, depth, new FreshResolver("internal", type, resolver, signature, parameterizedConstruction, DisposalOf(instance))); + EmitFreshResolver(builder, depth, new FreshResolver("internal", type, resolver, signature, parameterizedConstruction, DisposalOf(instance)), asyncDisposal); return; } @@ -954,11 +1002,11 @@ private static void EmitScopeResolver(StringBuilder builder, int depth, int inde // Internal also covers the case the Root (a subclass) reaches a captured scoped/transient's resolver. if (instance.Lifetime == Lifetime.Transient) { - EmitFreshResolver(builder, depth, new FreshResolver("internal", type, resolver, string.Empty, construction, DisposalOf(instance))); + EmitFreshResolver(builder, depth, new FreshResolver("internal", type, resolver, string.Empty, construction, DisposalOf(instance)), asyncDisposal); return; } - EmitCachingResolver(builder, depth, new CachingResolver("internal", type, resolver, names.Field(index), construction, DisposalOf(instance), "// Scoped: one instance per scope.")); + EmitCachingResolver(builder, depth, new CachingResolver("internal", type, resolver, names.Field(index), construction, DisposalOf(instance), "// Scoped: one instance per scope."), asyncDisposal); } /// @@ -967,7 +1015,7 @@ private static void EmitScopeResolver(StringBuilder builder, int depth, int inde /// registered for teardown on the owner under the lock, re-checking __disposed so one built /// during a concurrent dispose is disposed here rather than leaked. /// - private static void EmitFreshResolver(StringBuilder builder, int depth, in FreshResolver resolver) + private static void EmitFreshResolver(StringBuilder builder, int depth, in FreshResolver resolver, bool asyncDisposal) { string type = resolver.Type; string construction = resolver.Construction; @@ -978,7 +1026,7 @@ private static void EmitFreshResolver(StringBuilder builder, int depth, in Fresh if (resolver.Disposal != DisposalTracking.None) { Indent(builder, depth + 1).Append(type).Append(" created = ").Append(construction).AppendLine(";"); - EmitFreshDisposalTracking(builder, depth + 1, resolver.Disposal == DisposalTracking.Runtime); + EmitFreshDisposalTracking(builder, depth + 1, resolver.Disposal == DisposalTracking.Runtime, asyncDisposal, asyncContext: false); builder.AppendLine(); Indent(builder, depth + 1).AppendLine("return created;"); } @@ -992,37 +1040,43 @@ private static void EmitFreshResolver(StringBuilder builder, int depth, in Fresh /// /// Emits the disposal tracking for a freshly built created instance: under lock (__gate), - /// re-check __disposed so one built during a concurrent dispose is disposed here rather than - /// leaked, then add it to __disposables. When is set (a factory - /// output, whose declared return type may hide a concrete IDisposable), the whole block is gated - /// on a runtime is global::System.IDisposable test so only genuinely-disposable outputs are - /// retained; otherwise the static flag already guarantees disposability and the instance is cast directly. - /// Shared by the synchronous fresh resolver and the async fresh resolver, which emit identical tracking. + /// re-check __disposed so one built during a concurrent dispose is torn down here rather than + /// leaked, otherwise add it to __disposables. When is set (a + /// factory output, whose declared return type may hide a concrete disposable), the whole block is gated on + /// a runtime test so only genuinely-disposable outputs are retained; otherwise the static flag already + /// guarantees disposability. When is set the tracked set and the + /// raced-teardown also cover IAsyncDisposable; the teardown runs outside the lock (an await + /// cannot occur inside one) and, in an (the async fresh resolver), awaits + /// DisposeAsync. Shared by the synchronous and asynchronous fresh resolvers. /// - private static void EmitFreshDisposalTracking(StringBuilder builder, int depth, bool runtimeCheck) + private static void EmitFreshDisposalTracking(StringBuilder builder, int depth, bool runtimeCheck, bool asyncDisposal, bool asyncContext) { - string disposable; if (runtimeCheck) { - Indent(builder, depth).AppendLine("if (created is global::System.IDisposable __disposable)"); + string test = asyncDisposal + ? "created is global::System.IDisposable or global::System.IAsyncDisposable" + : "created is global::System.IDisposable"; + Indent(builder, depth).Append("if (").Append(test).AppendLine(")"); Indent(builder, depth).AppendLine("{"); depth++; - disposable = "__disposable"; - } - else - { - disposable = "((global::System.IDisposable)created)"; } + // Record whether the scope was already disposed under the lock, then tear down the raced instance outside + // it (so an async teardown can await, and user code never runs under the lock). + Indent(builder, depth).AppendLine("bool __raced;"); Indent(builder, depth).AppendLine("lock (__gate)"); Indent(builder, depth).AppendLine("{"); - Indent(builder, depth + 1).AppendLine("if (__disposed)"); + Indent(builder, depth + 1).AppendLine("__raced = __disposed;"); + Indent(builder, depth + 1).AppendLine("if (!__raced)"); Indent(builder, depth + 1).AppendLine("{"); - Indent(builder, depth + 2).Append(disposable).AppendLine(".Dispose();"); - Indent(builder, depth + 2).AppendLine("throw new global::System.ObjectDisposedException(GetType().FullName);"); + Indent(builder, depth + 2).AppendLine("(__disposables ??= new global::System.Collections.Generic.List()).Add(created);"); Indent(builder, depth + 1).AppendLine("}"); + Indent(builder, depth).AppendLine("}"); builder.AppendLine(); - Indent(builder, depth + 1).AppendLine("(__disposables ??= new global::System.Collections.Generic.List()).Add(created);"); + Indent(builder, depth).AppendLine("if (__raced)"); + Indent(builder, depth).AppendLine("{"); + EmitRacedTeardown(builder, depth + 1, asyncDisposal, asyncContext); + Indent(builder, depth + 1).AppendLine("throw new global::System.ObjectDisposedException(GetType().FullName);"); Indent(builder, depth).AppendLine("}"); if (runtimeCheck) @@ -1032,12 +1086,42 @@ private static void EmitFreshDisposalTracking(StringBuilder builder, int depth, } } + /// + /// Tears down a single created instance built during a concurrent dispose. Without async disposal + /// it is a synchronous Dispose. With async disposal in an async resolver it awaits + /// DisposeAsync (preferring it), falling back to Dispose; in a synchronous resolver it + /// disposes a synchronous IDisposable and leaves an async-only instance to its (rare) race - a + /// synchronous path cannot await, matching the synchronous Dispose contract. The runtime checks go through + /// (object)created so a sealed concrete type that implements only one of the disposal interfaces + /// still compiles (a direct is against such a type would be a CS8121 error). + /// + private static void EmitRacedTeardown(StringBuilder builder, int depth, bool asyncDisposal, bool asyncContext) + { + if (asyncDisposal && asyncContext) + { + Indent(builder, depth).AppendLine("if ((object)created is global::System.IAsyncDisposable __racedAsync)"); + Indent(builder, depth).AppendLine("{"); + Indent(builder, depth + 1).AppendLine("await __racedAsync.DisposeAsync().ConfigureAwait(false);"); + Indent(builder, depth).AppendLine("}"); + Indent(builder, depth).AppendLine("else if ((object)created is global::System.IDisposable __racedSync)"); + Indent(builder, depth).AppendLine("{"); + Indent(builder, depth + 1).AppendLine("__racedSync.Dispose();"); + Indent(builder, depth).AppendLine("}"); + return; + } + + Indent(builder, depth).AppendLine("if ((object)created is global::System.IDisposable __racedSync)"); + Indent(builder, depth).AppendLine("{"); + Indent(builder, depth + 1).AppendLine("__racedSync.Dispose();"); + Indent(builder, depth).AppendLine("}"); + } + /// /// Emits a singleton resolver on the Root as a protected override: a pre-built Instance /// returns the static container member by simple name; a constructed/factory singleton is cached once /// under the lock. /// - private static void EmitRootResolver(StringBuilder builder, int depth, int index, InstanceModel instance, InstanceModel[] instances, Names names, Dictionary serviceToIndex) + private static void EmitRootResolver(StringBuilder builder, int depth, int index, InstanceModel instance, InstanceModel[] instances, Names names, Dictionary serviceToIndex, bool asyncDisposal) { string type = instance.ImplementationType; string resolver = names.Resolver(index); @@ -1054,7 +1138,7 @@ private static void EmitRootResolver(StringBuilder builder, int depth, int index } string construction = EmitConstruction(instance, instances, names, serviceToIndex); - EmitCachingResolver(builder, depth, new CachingResolver("protected override", type, resolver, names.Field(index), construction, DisposalOf(instance), null)); + EmitCachingResolver(builder, depth, new CachingResolver("protected override", type, resolver, names.Field(index), construction, DisposalOf(instance), null), asyncDisposal); } /// @@ -1135,7 +1219,7 @@ private static void EmitAsyncResolutionApi(StringBuilder builder, int depth, Ins /// caching creator); a scoped service memoizes its construction-and-initialization Task on the /// scope; a transient constructs, initializes and returns each call. /// - private static void EmitAsyncScopeResolver(StringBuilder builder, int depth, int index, InstanceModel instance, InstanceModel[] instances, Names names, Dictionary serviceToIndex) + private static void EmitAsyncScopeResolver(StringBuilder builder, int depth, int index, InstanceModel instance, InstanceModel[] instances, Names names, Dictionary serviceToIndex, bool asyncDisposal) { const string task = "global::System.Threading.Tasks.Task"; const string ct = "global::System.Threading.CancellationToken cancellationToken"; @@ -1149,7 +1233,7 @@ private static void EmitAsyncScopeResolver(StringBuilder builder, int depth, int string[] argTypes = instance.ArgTypes(); string argSignature = string.Join("", argTypes.Select((t, i) => $"{t} a{i}, ")); string parameterizedConstruction = EmitConstruction(instance, instances, names, serviceToIndex, asynchronous: true); - EmitAsyncFreshResolver(builder, depth, index, instance, names, parameterizedConstruction, argSignature); + EmitAsyncFreshResolver(builder, depth, index, instance, names, parameterizedConstruction, asyncDisposal, argSignature); return; } @@ -1164,12 +1248,12 @@ private static void EmitAsyncScopeResolver(StringBuilder builder, int depth, int string construction = EmitConstruction(instance, instances, names, serviceToIndex, asynchronous: true); if (instance.Lifetime == Lifetime.Transient) { - EmitAsyncFreshResolver(builder, depth, index, instance, names, construction); + EmitAsyncFreshResolver(builder, depth, index, instance, names, construction, asyncDisposal); return; } // Scoped: a memoized Task on the scope guards construction-and-initialization. - EmitAsyncCachingResolver(builder, depth, index, instance, names, construction, "internal"); + EmitAsyncCachingResolver(builder, depth, index, instance, names, construction, "internal", asyncDisposal); } /// @@ -1203,10 +1287,10 @@ private static void EmitDelegatingSyncResolver(StringBuilder builder, int depth, /// creator: the memoized Task guarantees the construction and InitializeAsync run at most /// once, thread-safely under __gate. /// - private static void EmitAsyncRootResolver(StringBuilder builder, int depth, int index, InstanceModel instance, InstanceModel[] instances, Names names, Dictionary serviceToIndex) + private static void EmitAsyncRootResolver(StringBuilder builder, int depth, int index, InstanceModel instance, InstanceModel[] instances, Names names, Dictionary serviceToIndex, bool asyncDisposal) { string construction = EmitConstruction(instance, instances, names, serviceToIndex, asynchronous: true); - EmitAsyncCachingResolver(builder, depth, index, instance, names, construction, "protected override"); + EmitAsyncCachingResolver(builder, depth, index, instance, names, construction, "protected override", asyncDisposal); } /// @@ -1216,7 +1300,7 @@ private static void EmitAsyncRootResolver(StringBuilder builder, int depth, int /// canceled is evicted from the cache so a later call retries rather than replaying the same failure /// (and so one caller's cancellation does not permanently poison a shared singleton). /// - private static void EmitAsyncCachingResolver(StringBuilder builder, int depth, int index, InstanceModel instance, Names names, string construction, string modifiers) + private static void EmitAsyncCachingResolver(StringBuilder builder, int depth, int index, InstanceModel instance, Names names, string construction, string modifiers, bool asyncDisposal) { string type = instance.ImplementationType; string asyncResolver = names.AsyncResolver(index); @@ -1257,7 +1341,7 @@ private static void EmitAsyncCachingResolver(StringBuilder builder, int depth, i .Append(creator).Append('(').Append(ct).AppendLine(")"); Indent(builder, depth).AppendLine("{"); Indent(builder, depth + 1).Append(type).Append(" created = ").Append(construction).AppendLine(";"); - EmitAsyncDisposableRegistration(builder, depth + 1, instance); + EmitAsyncDisposableRegistration(builder, depth + 1, instance, asyncDisposal); EmitAsyncInitialization(builder, depth + 1, instance, "created"); Indent(builder, depth + 1).AppendLine("return created;"); Indent(builder, depth).AppendLine("}"); @@ -1268,7 +1352,7 @@ private static void EmitAsyncCachingResolver(StringBuilder builder, int depth, i /// transient, or a parameterized service that additionally takes the runtime arguments named in /// ). A disposable instance is registered for teardown on the owner. /// - private static void EmitAsyncFreshResolver(StringBuilder builder, int depth, int index, InstanceModel instance, Names names, string construction, string argSignature = "") + private static void EmitAsyncFreshResolver(StringBuilder builder, int depth, int index, InstanceModel instance, Names names, string construction, bool asyncDisposal, string argSignature = "") { string type = instance.ImplementationType; const string task = "global::System.Threading.Tasks.Task"; @@ -1279,7 +1363,7 @@ private static void EmitAsyncFreshResolver(StringBuilder builder, int depth, int Indent(builder, depth).AppendLine("{"); EmitDisposedGuard(builder, depth + 1); Indent(builder, depth + 1).Append(type).Append(" created = ").Append(construction).AppendLine(";"); - EmitAsyncDisposableRegistration(builder, depth + 1, instance); + EmitAsyncDisposableRegistration(builder, depth + 1, instance, asyncDisposal); EmitAsyncInitialization(builder, depth + 1, instance, "created"); Indent(builder, depth + 1).AppendLine("return created;"); Indent(builder, depth).AppendLine("}"); @@ -1288,9 +1372,10 @@ private static void EmitAsyncFreshResolver(StringBuilder builder, int depth, int /// /// Registers a freshly built disposable instance for teardown on the owner under __gate, /// re-checking __disposed so one built during a concurrent dispose is disposed here rather than - /// leaked. Mirrors the synchronous fresh-resolver registration. + /// leaked. Mirrors the synchronous fresh-resolver registration, but runs in an async context so its + /// raced-teardown can await DisposeAsync. /// - private static void EmitAsyncDisposableRegistration(StringBuilder builder, int depth, InstanceModel instance) + private static void EmitAsyncDisposableRegistration(StringBuilder builder, int depth, InstanceModel instance, bool asyncDisposal) { DisposalTracking disposal = DisposalOf(instance); if (disposal == DisposalTracking.None) @@ -1298,7 +1383,7 @@ private static void EmitAsyncDisposableRegistration(StringBuilder builder, int d return; } - EmitFreshDisposalTracking(builder, depth, disposal == DisposalTracking.Runtime); + EmitFreshDisposalTracking(builder, depth, disposal == DisposalTracking.Runtime, asyncDisposal, asyncContext: true); } /// @@ -1435,7 +1520,7 @@ private static int[] WarmUpTargets(InstanceModel[] instances, Lifetime lifetime) /// Emits a lock-free-read, lock-on-write cached resolver: return the cached field if set, otherwise /// construct once under lock (__gate), registering a disposable instance for teardown. /// - private static void EmitCachingResolver(StringBuilder builder, int depth, in CachingResolver resolver) + private static void EmitCachingResolver(StringBuilder builder, int depth, in CachingResolver resolver, bool asyncDisposal) { if (resolver.Comment is not null) { @@ -1458,9 +1543,12 @@ private static void EmitCachingResolver(StringBuilder builder, int depth, in Cac Indent(builder, depth + 3).Append(resolver.Field).Append(" = ").Append(resolver.Construction).AppendLine(";"); if (resolver.Disposal == DisposalTracking.Runtime) { - // A factory's declared return type may hide a concrete IDisposable, so retain the realized instance + // A factory's declared return type may hide a concrete disposable, so retain the realized instance // only when it genuinely is one. The add stays under the lock that guards the field assignment. - Indent(builder, depth + 3).Append("if (").Append(resolver.Field).AppendLine(" is global::System.IDisposable)"); + string test = asyncDisposal + ? " is global::System.IDisposable or global::System.IAsyncDisposable)" + : " is global::System.IDisposable)"; + Indent(builder, depth + 3).Append("if (").Append(resolver.Field).AppendLine(test); Indent(builder, depth + 3).AppendLine("{"); Indent(builder, depth + 4).Append("(__disposables ??= new global::System.Collections.Generic.List()).Add(").Append(resolver.Field).AppendLine(");"); Indent(builder, depth + 3).AppendLine("}"); @@ -1553,6 +1641,31 @@ private static string ResolveExpression(ParameterModel parameter, InstanceModel[ string[] funcArgTypes = parameter.FuncArgTypes.AsArray(); bool rootOwned = IsRootOwned(target); + // The async relationship types resolve their target through its async resolver (awaiting + // initialization), or wrap a synchronous target in a completed Task. They defer like the synchronous + // relationships, so they are created here at construction time and carry no ambient cancellation token. + // Their leak-free Owned forms (Task> / Func<…, Task>>) async-resolve into a + // throwaway child scope through the async __OwnedAsync helper - the counterpart of the synchronous + // ProducesOwned path below - so they are handled first, before that synchronous path. + if (parameter.Kind is DependencyKind.Task or DependencyKind.FuncTask or DependencyKind.LazyTask) + { + if (parameter.ProducesOwned) + { + string ownedValue = $"__OwnedAsync<{parameter.ServiceType}>({AsyncOwnedInner(parameter, target, targetIndex, names, rootOwned, funcArgTypes)}, default)"; + return parameter.Kind == DependencyKind.FuncTask + ? AsyncOwnedFuncFactory(funcArgTypes, parameter.ServiceType, ownedValue) + : ownedValue; + } + + string asyncValue = AsyncRelationshipValue(parameter, target, targetIndex, names, rootOwned, funcArgTypes); + return parameter.Kind switch + { + DependencyKind.FuncTask => AsyncFuncFactory(funcArgTypes, parameter.ServiceType, asyncValue), + DependencyKind.LazyTask => $"new global::System.Lazy>(() => {asyncValue})", + _ => asyncValue, + }; + } + // Owned relationships build into a throwaway child scope, independent of this owner, so they need none // of the root-routing below: a Func<…, Owned> factory, or a bare Owned resolved once. if (parameter.ProducesOwned) @@ -1572,20 +1685,6 @@ private static string ResolveExpression(ParameterModel parameter, InstanceModel[ return FuncFactory(funcArgTypes, parameter.ServiceType, resolver); } - // The async relationship types resolve their target through its async resolver (awaiting - // initialization), or wrap a synchronous target in a completed Task. They defer like the synchronous - // relationships, so they are created here at construction time and carry no ambient cancellation token. - if (parameter.Kind is DependencyKind.Task or DependencyKind.FuncTask or DependencyKind.LazyTask) - { - string asyncValue = AsyncRelationshipValue(parameter, target, targetIndex, names, rootOwned, funcArgTypes); - return parameter.Kind switch - { - DependencyKind.FuncTask => AsyncFuncFactory(funcArgTypes, parameter.ServiceType, asyncValue), - DependencyKind.LazyTask => $"new global::System.Lazy>(() => {asyncValue})", - _ => asyncValue, - }; - } - string value; if (rootOwned) { @@ -1658,6 +1757,47 @@ private static string AsyncFuncFactory(string[] argTypes, string service, string return $"new global::System.Func<{generics}>(({lambdaArgs}) => {asyncValue})"; } + /// + /// The Func<Scope, CancellationToken, Task<T>> delegate passed to + /// __OwnedAsync<T> that async-resolves the single value into the throwaway scope __s - + /// the async counterpart of . A root-owned target (a singleton or pre-built + /// instance) goes through the public ResolveAsync(Type) surface (never withheld off a child scope, + /// and shared off the root) with a cast; any other target calls its async resolver directly - internal on + /// the base Scope, so reachable even when the type is withheld - or, for a synchronously-resolvable + /// target, wraps its synchronous resolver in a completed task. A parameterized target forwards the runtime + /// arguments (a0…) captured from the enclosing factory lambda. + /// + private static string AsyncOwnedInner(ParameterModel parameter, InstanceModel target, int targetIndex, Names names, bool rootOwned, string[] argTypes) + { + if (rootOwned) + { + return $"async (__s, __ct) => ({parameter.ServiceType})await __s.ResolveAsync(typeof({parameter.ServiceType}), __ct).ConfigureAwait(false)"; + } + + if (target.IsAsyncTainted) + { + string callArgs = string.Join("", argTypes.Select((_, i) => "a" + i + ", ")); + return $"(__s, __ct) => __s.{names.AsyncResolver(targetIndex)}({callArgs}__ct)"; + } + + string syncArgs = string.Join(", ", argTypes.Select((_, i) => "a" + i)); + return $"(__s, __ct) => global::System.Threading.Tasks.Task.FromResult<{parameter.ServiceType}>(__s.{names.Resolver(targetIndex)}({syncArgs}))"; + } + + /// + /// A new Func<TArg…, Task<Owned<T>>>((a0, …) => __OwnedAsync<T>(…)) + /// expression: the async leak-free factory, each call async-resolving T into a throwaway child + /// scope and handing back the Owned<T> disposal handle (the async counterpart of + /// ). With no argument types this is the plain Func<Task<Owned<T>>>. + /// + private static string AsyncOwnedFuncFactory(string[] argTypes, string service, string ownedValue) + { + string task = $"global::System.Threading.Tasks.Task>"; + string generics = argTypes.Length == 0 ? task : string.Join(", ", argTypes) + ", " + task; + string lambdaArgs = string.Join(", ", argTypes.Select((_, i) => "a" + i)); + return $"new global::System.Func<{generics}>(({lambdaArgs}) => {ownedValue})"; + } + /// /// Emits the __Owned<T> helper on the base Scope: it opens a throwaway child scope /// (sharing the root's singletons), resolves a single T into it through the supplied delegate and @@ -1671,6 +1811,17 @@ private static void EmitOwnedHelper(StringBuilder builder, int depth) Indent(builder, depth + 1).AppendLine("Scope __owned = CreateScope();"); Indent(builder, depth + 1).AppendLine("return new global::Awaiten.Owned(__owned, __resolve(__owned));"); Indent(builder, depth).AppendLine("}"); + builder.AppendLine(); + + // The async counterpart of __Owned: open a throwaway child scope, await the resolution (and any + // initialization) of a single T into it, and hand back the Owned over that scope. Disposing the handle + // disposes only that scope. Like __Owned it does not roll back the scope if resolution throws (the + // synchronous helper does not either); CreateScopeAsync is the warming entry that does. + Indent(builder, depth).AppendLine("protected async global::System.Threading.Tasks.Task> __OwnedAsync(global::System.Func> __resolve, global::System.Threading.CancellationToken cancellationToken)"); + Indent(builder, depth).AppendLine("{"); + Indent(builder, depth + 1).AppendLine("Scope __owned = CreateScope();"); + Indent(builder, depth + 1).AppendLine("return new global::Awaiten.Owned(__owned, await __resolve(__owned, cancellationToken).ConfigureAwait(false));"); + Indent(builder, depth).AppendLine("}"); } /// @@ -1709,7 +1860,7 @@ private static string OwnedInner(string service, string resolver, string[] argTy return $"__s => __s.{resolver}({lambdaArgs})"; } - private static void EmitDispose(StringBuilder builder, int depth) + private static void EmitDispose(StringBuilder builder, int depth, bool asyncDisposal) { Indent(builder, depth).AppendLine("public void Dispose()"); Indent(builder, depth).AppendLine("{"); @@ -1728,19 +1879,56 @@ private static void EmitDispose(StringBuilder builder, int depth) Indent(builder, depth + 1).AppendLine("}"); builder.AppendLine(); // Dispose outside the lock so user code does not run under the lock. - EmitDrainDisposables(builder, depth + 1); + EmitDrainDisposables(builder, depth + 1, asyncDisposal); Indent(builder, depth).AppendLine("}"); } - private static void EmitErrorBody(StringBuilder builder, int depth, string typeName) + /// + /// Emits DisposeAsync (the IAsyncDisposable member): the asynchronous counterpart of + /// . It captures and clears the disposables list under the lock exactly as the + /// synchronous path does (so a single drain happens once), then awaits each instance's teardown outside + /// the lock, preferring DisposeAsync over Dispose. Emitted only when the compilation can see + /// IAsyncDisposable; the base Scope defines it and the Root inherits it. + /// + private static void EmitDisposeAsync(StringBuilder builder, int depth) + { + Indent(builder, depth).AppendLine("public async global::System.Threading.Tasks.ValueTask DisposeAsync()"); + Indent(builder, depth).AppendLine("{"); + Indent(builder, depth + 1).AppendLine("global::System.Collections.Generic.List? __toDispose;"); + Indent(builder, depth + 1).AppendLine("lock (__gate)"); + Indent(builder, depth + 1).AppendLine("{"); + Indent(builder, depth + 2).AppendLine("if (__disposed)"); + Indent(builder, depth + 2).AppendLine("{"); + Indent(builder, depth + 3).AppendLine("return;"); + Indent(builder, depth + 2).AppendLine("}"); + builder.AppendLine(); + Indent(builder, depth + 2).AppendLine("__disposed = true;"); + Indent(builder, depth + 2).AppendLine("__toDispose = __disposables;"); + Indent(builder, depth + 2).AppendLine("__disposables = null;"); + Indent(builder, depth + 1).AppendLine("}"); + builder.AppendLine(); + // Tear down outside the lock so an awaited DisposeAsync (user code) never runs under it. + EmitDrainDisposablesAsync(builder, depth + 1); + Indent(builder, depth).AppendLine("}"); + } + + private static void EmitErrorBody(StringBuilder builder, int depth, string typeName, bool asyncDisposal) { string message = $"\"Awaiten: container '{typeName}' has registration errors; see the build diagnostics (AWT1xx).\""; // The container has registration errors, so emit a throwing Root that still satisfies the shape // consumers depend on (new MyContainer.Root(), Resolve, CreateScope, InitializeAsync, Dispose). This // keeps the build focused on the actionable AWT diagnostics rather than cascading "type not found" - // errors. It implements IAwaitenRoot (which includes IAwaitenScope), matching the real Root. - Indent(builder, depth).AppendLine("public sealed class Root : global::Awaiten.IAwaitenRoot"); + // errors. It implements IAwaitenRoot (which includes IAwaitenScope), matching the real Root - and, when + // IAsyncDisposable is available, that too (the real Root implements it concretely), so `await using` over + // the stub compiles the same way. + Indent(builder, depth).Append("public sealed class Root : global::Awaiten.IAwaitenRoot"); + if (asyncDisposal) + { + builder.Append(", global::System.IAsyncDisposable"); + } + + builder.AppendLine(); Indent(builder, depth).AppendLine("{"); Indent(builder, depth + 1).Append( "public object Resolve(global::System.Type serviceType) => throw new global::System.InvalidOperationException(") @@ -1769,6 +1957,12 @@ private static void EmitErrorBody(StringBuilder builder, int depth, string typeN .Append(message).AppendLine(");"); builder.AppendLine(); Indent(builder, depth + 1).AppendLine("public void Dispose() { }"); + if (asyncDisposal) + { + builder.AppendLine(); + Indent(builder, depth + 1).AppendLine("public global::System.Threading.Tasks.ValueTask DisposeAsync() => default;"); + } + Indent(builder, depth).AppendLine("}"); } diff --git a/Source/Awaiten.SourceGenerators/Internals/ContainerModel.cs b/Source/Awaiten.SourceGenerators/Internals/ContainerModel.cs index b5e1436..2a28b6a 100644 --- a/Source/Awaiten.SourceGenerators/Internals/ContainerModel.cs +++ b/Source/Awaiten.SourceGenerators/Internals/ContainerModel.cs @@ -14,7 +14,8 @@ internal sealed record ContainerModel( EquatableArray Instances, EquatableArray Diagnostics, bool Strict, - bool SyncResolveAfterInit) + bool SyncResolveAfterInit, + bool HasAsyncDisposable) { public bool HasErrors { diff --git a/Source/Awaiten.SourceGenerators/Internals/InstanceModel.cs b/Source/Awaiten.SourceGenerators/Internals/InstanceModel.cs index 2542761..54c4bf0 100644 --- a/Source/Awaiten.SourceGenerators/Internals/InstanceModel.cs +++ b/Source/Awaiten.SourceGenerators/Internals/InstanceModel.cs @@ -40,8 +40,18 @@ internal sealed record InstanceModel( bool IsAsyncInitializable = false, bool IsAsyncTainted = false, bool IsAsyncFactory = false, - bool RuntimeDisposalCheck = false) + bool RuntimeDisposalCheck = false, + bool IsAsyncDisposable = false) { + /// + /// Whether the container owns this instance for disposal in either sense - its declared type implements + /// IDisposable or IAsyncDisposable - so it is tracked for teardown and its on-demand + /// construction accumulates on the owner. The drain selects the right disposal at runtime; + /// covers a factory whose declared type could hide either behind a + /// non-disposable service type. + /// + public bool NeedsDisposal => IsDisposable || IsAsyncDisposable; + /// /// 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 diff --git a/Source/Awaiten/Owned.cs b/Source/Awaiten/Owned.cs index 313564c..3b2b2d6 100644 --- a/Source/Awaiten/Owned.cs +++ b/Source/Awaiten/Owned.cs @@ -1,4 +1,5 @@ using System; +using System.Threading.Tasks; namespace Awaiten; @@ -11,7 +12,11 @@ namespace Awaiten; /// disposable transient on demand - the container never accumulates it on the root. /// /// The resolved service type. -public readonly struct Owned : IDisposable +public readonly struct Owned : +#if NET || NETSTANDARD2_1_OR_GREATER + IAsyncDisposable, +#endif + IDisposable { private readonly IAwaitenScope _scope; @@ -39,4 +44,17 @@ public Owned(IAwaitenScope scope, T value) /// handle, is a no-op. /// public void Dispose() => _scope?.Dispose(); + +#if NET || NETSTANDARD2_1_OR_GREATER + /// + /// Asynchronously disposes the scope backing this handle, awaiting the + /// of the instances it owns (falling back to + /// for those that are only synchronously disposable). Use this - + /// through await using - when (or anything built for it) is + /// ; a synchronous of such a handle throws. + /// Shared singletons are unaffected. Disposing a handle is a no-op. + /// + public ValueTask DisposeAsync() + => _scope is IAsyncDisposable asyncScope ? asyncScope.DisposeAsync() : default; +#endif } diff --git a/Tests/Awaiten.Api.Tests/Expected/Awaiten_net10.0.txt b/Tests/Awaiten.Api.Tests/Expected/Awaiten_net10.0.txt index f785d3a..4be3310 100644 --- a/Tests/Awaiten.Api.Tests/Expected/Awaiten_net10.0.txt +++ b/Tests/Awaiten.Api.Tests/Expected/Awaiten_net10.0.txt @@ -63,11 +63,12 @@ namespace Awaiten Strict = 0, Loose = 1, } - public readonly struct Owned : System.IDisposable + public readonly struct Owned : System.IAsyncDisposable, System.IDisposable { public Owned(Awaiten.IAwaitenScope scope, T value) { } public T Value { get; } public void Dispose() { } + public System.Threading.Tasks.ValueTask DisposeAsync() { } } [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] public sealed class ScopedAttribute : System.Attribute diff --git a/Tests/Awaiten.Api.Tests/Expected/Awaiten_net8.0.txt b/Tests/Awaiten.Api.Tests/Expected/Awaiten_net8.0.txt index 2e1aff5..0ad86be 100644 --- a/Tests/Awaiten.Api.Tests/Expected/Awaiten_net8.0.txt +++ b/Tests/Awaiten.Api.Tests/Expected/Awaiten_net8.0.txt @@ -63,11 +63,12 @@ namespace Awaiten Strict = 0, Loose = 1, } - public readonly struct Owned : System.IDisposable + public readonly struct Owned : System.IAsyncDisposable, System.IDisposable { public Owned(Awaiten.IAwaitenScope scope, T value) { } public T Value { get; } public void Dispose() { } + public System.Threading.Tasks.ValueTask DisposeAsync() { } } [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] public sealed class ScopedAttribute : System.Attribute diff --git a/Tests/Awaiten.SourceGenerators.Tests/AsyncRelationshipTypesTests.cs b/Tests/Awaiten.SourceGenerators.Tests/AsyncRelationshipTypesTests.cs index f81f424..89bab6b 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/AsyncRelationshipTypesTests.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/AsyncRelationshipTypesTests.cs @@ -196,4 +196,79 @@ public static partial class MyContainer await That(result.Diagnostics).Contains("*AWT115*").AsWildcard() .Because("a bare Task supplies no runtime arguments, so a parameterized service must instead be reached through a Func>"); } + + [Fact] + public async Task AsyncOwned_FuncOfTaskOfOwned_AsyncResolvesIntoAThrowawayScopeAndIsExemptFromAwt118() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System; + using System.Threading; + using System.Threading.Tasks; + + namespace MyCode; + + public sealed class Connection : IAsyncInitializable, IDisposable + { + public Task InitializeAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public void Dispose() { } + } + public sealed class Pool { public Pool(Func>> open) { } } + + [Container] + [Transient] + [Singleton] + public static partial class MyContainer + { + } + """); + + // The owned form is leak-free, so a root-held async factory over a disposable transient is not AWT118. + await That(result.Diagnostics).IsEmpty(); + string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; + await That(source).Contains("new global::System.Func>>") + .Because("the consumer binds a Func>> factory"); + await That(source).Contains("__OwnedAsync") + .Because("the async owned handle resolves the service into a throwaway child scope through the async owned helper"); + await That(source).Contains("ResolveConnectionAsync(__ct)") + .Because("the throwaway scope awaits the service's async resolver (initialization included)"); + } + + [Fact] + public async Task AsyncDisposableService_EmitsDisposeAsyncAndAnAwaitingDrain() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System; + using System.Threading; + using System.Threading.Tasks; + + namespace MyCode; + + public sealed class Connection : IAsyncInitializable, IAsyncDisposable + { + public Task InitializeAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public ValueTask DisposeAsync() => default; + } + + [Container] + [Singleton] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).IsEmpty(); + string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; + await That(source).Contains("public class Scope : global::Awaiten.IAwaitenScope") + .Because("the IAsyncDisposable surface is on the concrete Scope, not added to the IAwaitenScope interface"); + await That(source).Contains("global::System.IAsyncDisposable") + .Because("the generated Scope implements IAsyncDisposable when the compilation can see the type"); + await That(source).Contains("public async global::System.Threading.Tasks.ValueTask DisposeAsync()") + .Because("a DisposeAsync drain is emitted alongside the synchronous Dispose"); + await That(source).Contains("await __asyncDisposable.DisposeAsync().ConfigureAwait(false)") + .Because("the async drain awaits IAsyncDisposable, preferring it over a synchronous Dispose"); + await That(source).Contains("global::System.InvalidOperationException") + .Because("the synchronous Dispose throws when it meets an IAsyncDisposable-only service rather than blocking"); + } } diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt118RootAccumulatingFactory.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt118RootAccumulatingFactory.cs index e36ea80..7bc5998 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt118RootAccumulatingFactory.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt118RootAccumulatingFactory.cs @@ -183,8 +183,8 @@ public static partial class MyContainer await That(diagnostics.Any(d => d.Contains("AWT118"))).IsTrue() .Because("a singleton holding a Func<…, Task> over a disposable async transient accumulates initialized instances on the root, just as the synchronous Func does - and Func<…, Task> is the only deferred factory that can reach an async service"); - await That(diagnostics.Any(d => d.Contains("AWT118") && d.Contains("child scope") && !d.Contains("Owned<"))).IsTrue() - .Because("the async remedy points at a child scope, not the Owned handle, which is itself illegal (AWT119) for an async service"); + await That(diagnostics.Any(d => d.Contains("AWT118") && d.Contains("Task>>, the leak-free way to obtain a disposable async service per use (a synchronous Owned is illegal here - AWT119)"); } [Fact] diff --git a/Tests/Awaiten.SourceGenerators.Tests/GeneralTests.cs b/Tests/Awaiten.SourceGenerators.Tests/GeneralTests.cs index b75eac9..c89a7eb 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/GeneralTests.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/GeneralTests.cs @@ -369,12 +369,12 @@ public static partial class MyContainer await That(result.Diagnostics).IsEmpty(); string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; - await That(source).Contains("if (created is global::System.IDisposable __disposable)") - .Because("a factory declared to return a non-disposable interface may build a concrete IDisposable, so disposal is tracked by a runtime check on the realized instance"); + await That(source).Contains("if (created is global::System.IDisposable or global::System.IAsyncDisposable)") + .Because("a factory declared to return a non-disposable interface may build a concrete IDisposable (or IAsyncDisposable), so disposal is tracked by a runtime check on the realized instance"); await That(source).Contains("(__disposables ??= new global::System.Collections.Generic.List()).Add(created);") .Because("a genuinely-disposable factory output is still registered for teardown"); await That(source).DoesNotContain("((global::System.IDisposable)created).Dispose();") - .Because("the realized instance is reached through the runtime pattern variable, never an unchecked cast"); + .Because("the realized instance is reached through the runtime pattern, never an unchecked cast"); } [Fact] diff --git a/Tests/Awaiten.Tests/AsyncInitializationTests.cs b/Tests/Awaiten.Tests/AsyncInitializationTests.cs index f7052e2..ac7d8e8 100644 --- a/Tests/Awaiten.Tests/AsyncInitializationTests.cs +++ b/Tests/Awaiten.Tests/AsyncInitializationTests.cs @@ -975,4 +975,189 @@ public sealed class RobotFactory [Transient] [Singleton] public static partial class ParameterizedAsyncContainer; + + [Fact] + public async Task AsyncOwned_FuncOfTaskOfOwned_ResolvesInitializesAndScopesDisposalPerHandle() + { + using AsyncOwnedContainer.Root container = new(); + + // GizmoPlant is a synchronously-resolvable singleton holding Func>>: each call + // async-resolves (and initializes) a fresh transient into its own child scope and hands back the handle. + GizmoPlant plant = container.Resolve(); + + Owned first = await plant.MakeAsync(); + Owned second = await plant.MakeAsync(); + + await That(first.Value.Initialized).IsTrue() + .Because("the async owned handle awaits the service's initialization before handing it back"); + await That(ReferenceEquals(first.Value, second.Value)).IsFalse() + .Because("each call builds a fresh transient in its own scope"); + + first.Dispose(); + await That(first.Value.Disposed).IsTrue(); + await That(second.Value.Disposed).IsFalse() + .Because("disposing one handle disposes only its own child scope, not another's"); + + second.Dispose(); + await That(second.Value.Disposed).IsTrue(); + } + + [Fact] + public async Task AsyncOwned_ParameterizedFuncOfTaskOfOwned_ForwardsArgumentInitializesAndDisposes() + { + using ParameterizedAsyncOwnedContainer.Root container = new(); + + WidgetPlant plant = container.Resolve(); + Owned handle = await plant.MakeAsync(7); + + await That(handle.Value.Id).IsEqualTo(7) + .Because("the async owned factory forwards the runtime argument to the service's [Arg] parameter"); + await That(handle.Value.Initialized).IsTrue(); + + handle.Dispose(); + await That(handle.Value.Disposed).IsTrue(); + } + + public sealed class Gizmo : IAsyncInitializable, IDisposable + { + public bool Initialized { get; private set; } + + public bool Disposed { get; private set; } + + public Task InitializeAsync(CancellationToken cancellationToken) + { + Initialized = true; + return Task.CompletedTask; + } + + public void Dispose() => Disposed = true; + } + + public sealed class GizmoPlant + { + private readonly Func>> _factory; + + public GizmoPlant(Func>> factory) => _factory = factory; + + public Task> MakeAsync() => _factory(); + } + + [Container] + [Transient] + [Singleton] + public static partial class AsyncOwnedContainer; + + public sealed class Widget : IAsyncInitializable, IDisposable + { + public Widget([Arg] int id) => Id = id; + + public int Id { get; } + + public bool Initialized { get; private set; } + + public bool Disposed { get; private set; } + + public Task InitializeAsync(CancellationToken cancellationToken) + { + Initialized = true; + return Task.CompletedTask; + } + + public void Dispose() => Disposed = true; + } + + public sealed class WidgetPlant + { + private readonly Func>> _factory; + + public WidgetPlant(Func>> factory) => _factory = factory; + + public Task> MakeAsync(int id) => _factory(id); + } + + [Container] + [Transient] + [Singleton] + public static partial class ParameterizedAsyncOwnedContainer; + +#if NET || NETSTANDARD2_1_OR_GREATER + [Fact] + public async Task AsyncOwned_FuncOfTaskOfOwned_DisposeAsync_TearsDownAnAsyncDisposableService() + { + using AsyncDisposableOwnedContainer.Root container = new(); + + ValvePlant plant = container.Resolve(); + Owned handle = await plant.MakeAsync(); + + await That(handle.Value.Initialized).IsTrue(); + await That(handle.Value.DisposeAsyncCount).IsEqualTo(0); + + await handle.DisposeAsync(); + + await That(handle.Value.DisposeAsyncCount).IsEqualTo(1) + .Because("await using the async owned handle drains its child scope through DisposeAsync"); + } + + [Fact] + public async Task AsyncDisposableSingleton_DisposedThroughTheContainerDisposeAsync() + { + Valve captured; + await using (ValveSingletonContainer.Root container = new()) + { + captured = await container.ResolveAsync(); + await That(captured.DisposeAsyncCount).IsEqualTo(0); + } + + await That(captured.DisposeAsyncCount).IsEqualTo(1) + .Because("disposing the container through DisposeAsync awaits the async-disposable instances it owns"); + } + + [Fact] + public async Task AsyncDisposableSingleton_SyncDispose_Throws() + { + ValveSingletonContainer.Root container = new(); + await container.ResolveAsync(); + + // The container now owns an IAsyncDisposable-only instance; a synchronous Dispose cannot tear it down. + await That(() => container.Dispose()).Throws() + .Because("an IAsyncDisposable-only service requires DisposeAsync; a synchronous Dispose throws rather than leak or block"); + } + + public sealed class Valve : IAsyncInitializable, IAsyncDisposable + { + public bool Initialized { get; private set; } + + public int DisposeAsyncCount { get; private set; } + + public Task InitializeAsync(CancellationToken cancellationToken) + { + Initialized = true; + return Task.CompletedTask; + } + + public ValueTask DisposeAsync() + { + DisposeAsyncCount++; + return default; + } + } + + public sealed class ValvePlant + { + private readonly Func>> _factory; + + public ValvePlant(Func>> factory) => _factory = factory; + + public Task> MakeAsync() => _factory(); + } + + [Container] + [Transient] + [Singleton] + public static partial class AsyncDisposableOwnedContainer; + + [Container] + [Singleton] + public static partial class ValveSingletonContainer; +#endif } From bfb8ec57796586b3693c732edf9f7ebdd300af39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Tue, 30 Jun 2026 13:49:15 +0200 Subject: [PATCH 4/5] docs: correct stale comments on the parameterized-async resolver exclusions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two comments in Emitter.cs predate async relationship types and no longer describe the code. EmitAsyncResolutionApi claimed a parameterized service "has no async resolver of its own" - it does now (Func> binds the async parameterized resolver); it simply has no by-type ResolveAsync entry because by-type resolution cannot supply the [Arg]s. AsyncWithheldServices excludes parameterized services with no stated reason; document that the exclusion is intentional - a parameterized service is never bare-type resolvable (it needs its arguments through a Func), so the "resolve through ResolveAsync" guidance would not fit, and its unavailability is governed by parameterization rather than asynchronous initialization. Comment-only; no behavior change. --- Source/Awaiten.SourceGenerators/Emitter.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Source/Awaiten.SourceGenerators/Emitter.cs b/Source/Awaiten.SourceGenerators/Emitter.cs index 74aabee..95dd599 100644 --- a/Source/Awaiten.SourceGenerators/Emitter.cs +++ b/Source/Awaiten.SourceGenerators/Emitter.cs @@ -869,6 +869,9 @@ private static List Withheld(List entries) /// async-tainted, non-parameterized service's (non-keyed) service types, when the container is not in /// pragmatic mode (where the same services are synchronously resolvable after warm-up). They have no /// synchronous dispatch entry, so without this they would surface as a generic "no registration". + /// A parameterized service is excluded: it is never resolvable by its bare type (it needs its runtime + /// arguments through a Func<TArg…, …>), so the "resolve through ResolveAsync" guidance would + /// not fit - its bare-type unavailability is governed by parameterization, not asynchronous initialization. /// private static IEnumerable<(string Service, string Guidance)> AsyncWithheldServices( InstanceModel[] instances, bool syncResolveAfterInit) @@ -1160,8 +1163,9 @@ private static void EmitAsyncResolutionApi(StringBuilder builder, int depth, Ins bool hasAsync = false; for (int i = 0; i < instances.Length; i++) { - // A parameterized service is reached only through its synchronous Func, so it has no - // async resolver of its own. + // A parameterized service is built fresh from its runtime arguments, so it is reached only through + // its Func / Func> factory; by-type ResolveAsync cannot supply those [Arg]s, + // so it gets no entry here (it does have an async resolver - the async factory relationship binds it). if (!instances[i].IsAsyncTainted || instances[i].IsParameterized) { continue; From 8bf6e37fd9dc51b9c9266a26613ff32b6f1e0c52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Tue, 30 Jun 2026 14:27:51 +0200 Subject: [PATCH 5/5] docs: remove Docs/async-owned-disposal.md --- Docs/async-owned-disposal.md | 52 ------------------------------------ 1 file changed, 52 deletions(-) delete mode 100644 Docs/async-owned-disposal.md diff --git a/Docs/async-owned-disposal.md b/Docs/async-owned-disposal.md deleted file mode 100644 index 8506846..0000000 --- a/Docs/async-owned-disposal.md +++ /dev/null @@ -1,52 +0,0 @@ -# Async owned disposal — roadmap - -Tracks the staged path from "async services can leak at the root" to "async services support `IAsyncDisposable` with a leak-free per-use handle". Each stage is additive on the previous one; nothing here forecloses the next step. - -## Background - -The container tracks disposables on the **scope that builds them**. A `Func<…>` is a factory: every call builds a fresh instance. When a **root-owned** holder (a singleton or pre-built instance) captures that factory, it is bound to the root scope, so every instance the factory ever builds is tracked on the root and only released when the whole container is disposed — an unbounded leak. AWT118 catches this for synchronous factories and points the developer at `Func<…, Owned>`, whose handle drains into a throwaway child scope. - -`Owned` is structurally synchronous: `__Owned` opens a scope and resolves `T` through a `Func` with no `await`, and `Owned.Value` is a synchronous property. An async-tainted service has no synchronous resolver to call (it needs `InitializeAsync`), which is why a synchronous `Owned`/`Func`/`Lazy` over one is **AWT119**. So async services have **no `Owned`-based escape hatch** — confirmed by the existing `AsyncRootWithheldMessage` (`Emitter.cs`), whose comment already states "`Owned` is a synchronous relationship, so it is not offered for an async-initialized service" and which directs callers to a child scope instead. - -Disposability today is keyed strictly on `System.IDisposable` (`AwaitenGenerator.BuildInstance`). `IAsyncDisposable`-only services are treated as non-disposable everywhere: never tracked, never disposed. The scope exposes `Dispose()` only — there is no `DisposeAsync()`. - -## Stage 1 — close the silent leak (done) - -AWT118 now also fires for `Func<…, Task>` (`DependencyKind.FuncTask`): the async fresh resolver tracks disposables identically to the synchronous one, so the async factory leaks the same way, and — because `Owned` is unavailable for async — `Func<…, Task>` is the *only* deferred factory that can reach an async-tainted target, so this was the one place the leak could occur unguarded. Because the `Owned` remedy is illegal for async, the AWT118 message carries the remedy as a `{2}` argument: the sync case keeps "resolve it as `Func<…, Owned>`", the async case points at an explicitly scoped resolution (`await CreateScopeAsync()`, `ResolveAsync` from that scope, then dispose the scope), mirroring `AsyncRootWithheldMessage`. Strict lifetime safety reports it as the same non-suppressible error as the sync case. - -This is correct but blunt: under strict safety it hard-blocks a root-held `Func<…, Task>` over a disposable async transient, and the only remedy is manual scoping. Stage 2 gives async services a real one-liner. - -## Stage 2 — `Func<…, Task>>` (async owned handle, sync disposal) - -Add the async mirror of `Func<…, Owned>`, **reusing the existing `Owned` struct** — no new public type: - -- Recognize `Task>` / `Func<…, Task>>` in `ClassifyRelationship` (a branch beside the existing `IsOwned(service)` one). -- Emit an async `__OwnedAsync` helper: open a child scope with `CreateScopeAsync` (which already warms async-initialized scoped services), `await` the target's async resolver into it (covering resolution *and* `InitializeAsync`), and return an `Owned` over that scope. -- AWT113 runtime-argument validation already generalizes to `FuncTask`; extend it to this kind too. - -Once this exists, the Stage 1 AWT118 message (and `AsyncRootWithheldMessage`) can additionally point at `Func<…, Task>>` rather than only "open a child scope by hand". - -Stage 2 needs **no** `IAsyncDisposable` at all — it only async-resolves into a child scope and returns an ordinary `Owned`, so it ships on every target framework (net48 included), with synchronous `IDisposable` disposal of the handle. Async *disposal* of what the handle owns is Stage 3. - -**Status: done.** `Task>` and `Func<…, Task>>` (`DependencyKind` `Task`/`FuncTask` with `ProducesOwned`) are classified in `ClassifyParameter`/`ClassifyRelationship`, emitted through the new `__OwnedAsync` helper (the async twin of `__Owned`: open a child scope, await the target's async resolver into it, return the `Owned`), and validated by AWT113. The AWT118 message and `AsyncRootWithheldMessage` now point at `Func<…, Task>>` as the leak-free async remedy. - -## Stage 3 — `IAsyncDisposable` support (async disposal pipeline) — done - -Implemented as an additive, **net8.0+ / polyfilled-only** capability, gated by detection rather than a forced dependency (per the project decision: net8.0+ async disposal, no new NuGet dependency): - -- **Detection, not dependency.** The generator checks `compilation.GetTypeByMetadataName("System.IAsyncDisposable")`. When present (net5.0+/netstandard2.1+ in-box, or an older target that added `Microsoft.Bcl.AsyncInterfaces`), it emits the async-disposal machinery; when absent (e.g. net48 without the polyfill) the container is synchronous-dispose only and references no `IAsyncDisposable`, so it still compiles. No dependency was added to the library. -- **Tracking.** `IAsyncDisposable` is folded into the disposal decision (`InstanceModel.NeedsDisposal = IsDisposable || IsAsyncDisposable`), which also flows into the leak analyses (AWT118 / by-type withholding / `BuildsFreshDisposable`). Tracked instances live in the same `List`; the drain pattern-matches at runtime. -- **`DisposeAsync`.** The generated **concrete `Scope`** implements `IAsyncDisposable` (the `Root` inherits it) and gets a `DisposeAsync()` that drains newest-first, awaiting `IAsyncDisposable.DisposeAsync()` and falling back to `Dispose()`. This was **not** added to the `IAwaitenScope` interface — doing so would break every hand-implementer (the MS.DI adapter, test doubles, external code); a concrete-class implementation gives `await using` on the container/scope without that break. -- **Sync `Dispose()` throws.** When the synchronous `Dispose()` meets an instance that is `IAsyncDisposable` but not `IDisposable`, it throws `InvalidOperationException` (matching Microsoft.Extensions.DependencyInjection) rather than blocking on an async dispose. -- **`Owned`** implements `IAsyncDisposable` under `#if NET || NETSTANDARD2_1_OR_GREATER` (additive; `using`/`.Dispose()` callers unchanged), with `DisposeAsync()` routing to the backing scope via `_scope is IAsyncDisposable`. - -**Trap avoided:** `Owned.Dispose()` / the scope's `Dispose()` never block on `DisposeAsync().GetAwaiter().GetResult()`. Sync-over-async risks deadlock and would bake the wrong contract into the public API; the synchronous path stays synchronous and throws for async-only services instead. - -## Why the design holds - -| Decision | Outcome | -|---|---| -| Async-owned resolution (`Func<…, Task>>`) reuses `Owned` | One handle type; ships on all TFMs with sync disposal | -| `IAsyncDisposable` detected in the compilation, not depended upon | net8.0+ (and polyfilled) get async disposal; net48 stays sync-only and still compiles; no new dependency | -| `IAsyncDisposable` on the concrete `Scope`, not the `IAwaitenScope` interface | `await using` works on containers/scopes without breaking hand-implementers | -| Sync `Dispose()` throws on an async-only service | No sync-over-async; clear contract |