diff --git a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md index 342dc07..e7c769b 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 + 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 - AWT121 | Awaiten | Error | A service with [Arg] parameters also implements IAsyncInitializable diff --git a/Source/Awaiten.SourceGenerators/AwaitenAnalyzer.cs b/Source/Awaiten.SourceGenerators/AwaitenAnalyzer.cs index 7d0eb83..0cb45b9 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 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 as Func<…, Task>> 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/AwaitenGenerator.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs index 9daafba..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) { @@ -849,6 +867,18 @@ 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)) + { + // 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 && named.ContainingNamespace?.ToDisplayString() == "System" && ClassifyRelationship(named, key, location) is { } relationship) @@ -872,8 +902,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 +921,21 @@ 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. + // 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 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. if (IsOwned(service, out ITypeSymbol funcOwnedInner)) { @@ -1123,6 +1170,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 +1259,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 +1288,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 +1304,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..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); @@ -297,21 +300,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..95dd599 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("{"); @@ -413,35 +455,41 @@ 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); } else { - EmitScopeResolver(builder, body, i, instances[i], instances, names, serviceToIndex); + EmitScopeResolver(builder, body, i, instances[i], instances, names, serviceToIndex, asyncDisposal); } } - if (instances[i].IsAsyncTainted && !instances[i].IsParameterized) + 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); } } @@ -821,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) @@ -919,7 +970,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 +983,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 +1005,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 +1018,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 +1029,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 +1043,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 +1089,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 +1141,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); } /// @@ -1076,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; @@ -1135,11 +1223,24 @@ 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"; + // 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, asyncDisposal, argSignature); + return; + } + if (instance.Lifetime == Lifetime.Singleton) { Indent(builder, depth).Append("protected virtual ").Append(task).Append('<').Append(instance.ImplementationType) @@ -1151,12 +1252,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); } /// @@ -1170,12 +1271,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("}"); } @@ -1184,10 +1291,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); } /// @@ -1197,7 +1304,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); @@ -1238,7 +1345,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("}"); @@ -1246,20 +1353,21 @@ 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, bool asyncDisposal, 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(";"); - 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,9 +1376,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) @@ -1278,7 +1387,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); } /// @@ -1415,7 +1524,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) { @@ -1438,9 +1547,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("}"); @@ -1533,6 +1645,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) @@ -1590,6 +1727,81 @@ 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})"; + } + + /// + /// 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 @@ -1603,6 +1815,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("}"); } /// @@ -1641,7 +1864,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("{"); @@ -1660,19 +1883,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("}"); + } + + /// + /// 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) + 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(") @@ -1701,6 +1961,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/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/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/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..89bab6b --- /dev/null +++ b/Tests/Awaiten.SourceGenerators.Tests/AsyncRelationshipTypesTests.cs @@ -0,0 +1,274 @@ +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>"); + } + + [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 84a5e60..7bc5998 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("Task>>, the leak-free way to obtain a disposable async service per use (a synchronous Owned is illegal here - AWT119)"); + } + + [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"); + } } } 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.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 c096853..ac7d8e8 100644 --- a/Tests/Awaiten.Tests/AsyncInitializationTests.cs +++ b/Tests/Awaiten.Tests/AsyncInitializationTests.cs @@ -885,4 +885,279 @@ 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; + + [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 }