diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs index b3bdd34..a8a4c22 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.cs @@ -464,6 +464,17 @@ private static Dictionary> BuildDependencyGraph( ITypeSymbol disposalType = info.Production == ProductionKind.Factory ? producer.ReturnType : 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 + // Instance production never lie: info.Symbol is the concrete type, and an Instance is not owned. + bool runtimeDisposalCheck = info.Production == ProductionKind.Factory + && !disposable + && CouldHideDisposable(disposalType); + // Async initialization follows the type the container actually owns - a factory's concrete return type // (which may implement IAsyncInitializable behind a non-async service interface) or the constructed // implementation type - mirroring the disposal-type choice above. A pre-built Instance is returned @@ -480,13 +491,23 @@ private static Dictionary> BuildDependencyGraph( info.Symbol.IsReferenceType, info.Production, info.ProductionMember, - asyncInit); + asyncInit, + RuntimeDisposalCheck: runtimeDisposalCheck); static bool ImplementsInterface(ITypeSymbol type, INamedTypeSymbol @interface) { return SymbolEqualityComparer.Default.Equals(type, @interface) || type.AllInterfaces.Any(implemented => SymbolEqualityComparer.Default.Equals(implemented, @interface)); } + + // Whether a value of this declared type could be IDisposable at runtime through a subtype the + // declaration does not reveal: an interface or type parameter (any implementer qualifies) or a + // non-sealed class (a derived type may implement it). A sealed class or a struct that does not + // itself implement IDisposable cannot, so a runtime `is IDisposable` test against it is pointless + // (and, for a sealed class, a compile error - CS8121/CS0184). + static bool CouldHideDisposable(ITypeSymbol type) + => type.TypeKind is TypeKind.Interface or TypeKind.TypeParameter + || (type.TypeKind == TypeKind.Class && !type.IsSealed); } /// diff --git a/Source/Awaiten.SourceGenerators/Emitter.cs b/Source/Awaiten.SourceGenerators/Emitter.cs index c009903..324f37f 100644 --- a/Source/Awaiten.SourceGenerators/Emitter.cs +++ b/Source/Awaiten.SourceGenerators/Emitter.cs @@ -177,20 +177,26 @@ private static void EmitContainerBody(StringBuilder builder, int depth, Containe EmitScopeBaseClass(builder, depth, instances, names, serviceToIndex, model.Strict, model.SyncResolveAfterInit); } - // A disposable build-on-demand service (a disposable transient or parameterized service) is withheld from - // by-type resolution on the container Root under strict lifetime safety: off the Root its bare type and - // plain Func factory throw a guidance exception, and it gets no typed resolver, so the leak-prone ways to - // reach it from the Root are constructor injection and Owned / Func<…, Owned>. It stays resolvable - // from a child scope, where its lifetime is bounded by the scope (the Root mask, not the table, gates it). + /// + /// A disposable build-on-demand service (a disposable transient or parameterized service) is withheld from + /// by-type resolution on the container Root under strict lifetime safety: off the Root its bare type and + /// plain Func factory throw a guidance exception, and it gets no typed resolver, so the leak-prone ways to + /// reach it from the Root are constructor injection and Owned<T> / Func<…, Owned<T>>. + /// It stays resolvable from a child scope, where its lifetime is bounded by the scope (the Root mask, not + /// the table, gates it). + /// private static bool IsWithheld(InstanceModel instance, bool strict) => strict && instance.IsDisposable && (instance.Lifetime == Lifetime.Transient || instance.IsParameterized); - // Whether a plain Func<…> over this service is withheld from by-type resolution on the Root under strict - // lifetime safety: the service is built on demand (transient or parameterized) and building it tracks a - // fresh disposable on its owner - the service itself is disposable, or its construction transitively rebuilds - // one. Such a Func re-invoked off a root binding accumulates those disposables for the container's lifetime, - // so off the Root only the Func<…, Owned> form (which drains into a throwaway scope) is offered; the plain - // Func stays resolvable from a child scope, which bounds the disposables it builds. + /// + /// Whether a plain Func<…> over this service is withheld from by-type resolution on the Root + /// under strict lifetime safety: the service is built on demand (transient or parameterized) and building + /// it tracks a fresh disposable on its owner - the service itself is disposable, or its construction + /// transitively rebuilds one. Such a Func re-invoked off a root binding accumulates those disposables for + /// the container's lifetime, so off the Root only the Func<…, Owned<T>> form (which drains + /// into a throwaway scope) is offered; the plain Func stays resolvable from a child scope, which bounds the + /// disposables it builds. + /// private static bool IsFuncWithheld(InstanceModel[] instances, int index, Dictionary serviceToIndex, bool strict) => strict && (instances[index].Lifetime == Lifetime.Transient || instances[index].IsParameterized) @@ -199,48 +205,87 @@ private static bool IsFuncWithheld(InstanceModel[] instances, int index, Diction private static bool IsRootOwned(InstanceModel instance) => instance.Lifetime == Lifetime.Singleton || instance.Production == ProductionKind.Instance; - // Whether synchronous resolution members (resolver, cache field, dispatch entries, typed fast path) are - // emitted for an instance: always for a non-tainted service, and for an async-tainted one only in - // pragmatic mode (SyncResolveAfterInit), where it may be resolved synchronously once InitializeAsync has - // warmed it. In the strict default an async-tainted service is reachable only through ResolveAsync. + /// + /// How a resolver tracks its instance for disposal. None: not disposable, nothing to track. Static: the + /// declared type is known IDisposable, so the instance is cast and added directly. Runtime: a factory's + /// declared return type can hide a concrete IDisposable behind a non-disposable service interface, so the + /// static IsDisposable flag under-reports; the output is added behind a runtime is IDisposable check + /// on the realized instance (retaining only genuinely-disposable outputs). Constructed and pre-built-Instance + /// production never need Runtime: info.Symbol is the concrete type, and a pre-built Instance is never owned. + /// + private enum DisposalTracking + { + None, + Static, + Runtime, + } + + /// + /// Runtime and Static are mutually exclusive: RuntimeDisposalCheck is set by the model only when the static + /// IsDisposable flag is false (the declared type does not reveal the disposable), so a factory output is + /// either statically disposable or runtime-checked, never both. + /// + private static DisposalTracking DisposalOf(InstanceModel instance) + => (instance.RuntimeDisposalCheck, instance.IsDisposable) switch + { + (true, _) => DisposalTracking.Runtime, + (_, true) => DisposalTracking.Static, + _ => DisposalTracking.None, + }; + + /// + /// Whether synchronous resolution members (resolver, cache field, dispatch entries, typed fast path) are + /// emitted for an instance: always for a non-tainted service, and for an async-tainted one only in + /// pragmatic mode (SyncResolveAfterInit), where it may be resolved synchronously once InitializeAsync has + /// warmed it. In the strict default an async-tainted service is reachable only through ResolveAsync. + /// private static bool EmitsSync(InstanceModel instance, bool syncResolveAfterInit) => !instance.IsAsyncTainted || syncResolveAfterInit; - // The guidance message (a quoted string literal) thrown by Resolve(Type) on the Root when a service - // withheld there under strict lifetime safety is requested by its bare type: it is itself a disposable - // build-on-demand service, reachable from the Root only by injection or through an Owned handle - but - // still resolvable from a child scope, which bounds its lifetime. + /// + /// The guidance message (a quoted string literal) thrown by Resolve(Type) on the Root when a service + /// withheld there under strict lifetime safety is requested by its bare type: it is itself a disposable + /// build-on-demand service, reachable from the Root only by injection or through an Owned<T> + /// handle - but still resolvable from a child scope, which bounds its lifetime. + /// private static string BareWithheldMessage(string service) { string display = service.Replace("global::", string.Empty); return $"\"Awaiten: '{display}' is withheld from by-type resolution on the container root under strict lifetime safety; obtain it through Owned<{display}> or Func<…, Owned<{display}>>, resolve it from a child scope, inject it directly, or set LifetimeSafety.Loose on the [Container].\""; } - // The guidance message (a quoted string literal) thrown by Resolve(Type) on the Root when a plain Func over - // the service is requested: that factory accumulates disposables on the root, so the leak-free Func<…, - // Owned> form is offered instead. The Func stays resolvable from a child scope, which bounds the - // disposables it builds; the bare type may also be resolvable (its single resolution is bounded). + /// + /// The guidance message (a quoted string literal) thrown by Resolve(Type) on the Root when a plain Func over + /// the service is requested: that factory accumulates disposables on the root, so the leak-free + /// Func<…, Owned<T>> form is offered instead. The Func stays resolvable from a child scope, + /// which bounds the disposables it builds; the bare type may also be resolvable (its single resolution is + /// bounded). + /// private static string FuncWithheldMessage(string service) { string display = service.Replace("global::", string.Empty); return $"\"Awaiten: a plain Func over '{display}' is withheld from by-type resolution on the container root under strict lifetime safety because building it on demand accumulates disposables on the container root; resolve it as Func<…, Owned<{display}>> for per-use disposal, resolve it from a child scope, or set LifetimeSafety.Loose on the [Container].\""; } - // The guidance message (a quoted string literal) thrown by Resolve(Type) when an async-tainted service is - // requested by type: it is async-initialized (or reaches one through its non-deferred dependencies), so it - // has no synchronous resolution path in the strict default and must be obtained asynchronously. + /// + /// The guidance message (a quoted string literal) thrown by Resolve(Type) when an async-tainted service is + /// requested by type: it is async-initialized (or reaches one through its non-deferred dependencies), so it + /// has no synchronous resolution path in the strict default and must be obtained asynchronously. + /// private static string AsyncWithheldMessage(string service) { string display = service.Replace("global::", string.Empty); return $"\"Awaiten: '{display}' requires asynchronous initialization (it is IAsyncInitializable, or depends on one) and cannot be resolved synchronously; resolve it through ResolveAsync (or warm it through InitializeAsync / CreateScopeAsync), or set SyncResolveAfterInit on the [Container].\""; } - // The guidance message (a quoted string literal) thrown by ResolveAsync(Type) on the Root when a disposable - // 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 is a synchronous relationship, so it is not offered for - // an async-initialized service). + /// + /// The guidance message (a quoted string literal) thrown by ResolveAsync(Type) on the Root when a disposable + /// 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). + /// private static string AsyncRootWithheldMessage(string service) { string display = service.Replace("global::", string.Empty); @@ -722,9 +767,11 @@ private static void AddRelationshipEntries(InstanceModel instance, string resolv } } - // The root-withheld entries (dispatchable, but carrying guidance) - their types and messages populate the - // __withheld table that Resolve throws from on the Root, while the parallel __rootWithheld mask (keyed by - // dispatch case) is what TryResolve consults to return false for them on the Root only. + /// + /// The root-withheld entries (dispatchable, but carrying guidance) - their types and messages populate the + /// __withheld table that Resolve throws from on the Root, while the parallel __rootWithheld + /// mask (keyed by dispatch case) is what TryResolve consults to return false for them on the Root only. + /// private static List Withheld(List entries) { List withheld = new(); @@ -883,7 +930,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, instance.IsDisposable)); + EmitFreshResolver(builder, depth, new FreshResolver("internal", type, resolver, signature, parameterizedConstruction, DisposalOf(instance))); return; } @@ -905,11 +952,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, instance.IsDisposable)); + EmitFreshResolver(builder, depth, new FreshResolver("internal", type, resolver, string.Empty, construction, DisposalOf(instance))); return; } - EmitCachingResolver(builder, depth, new CachingResolver("internal", type, resolver, names.Field(index), construction, instance.IsDisposable, "// Scoped: one instance per scope.")); + EmitCachingResolver(builder, depth, new CachingResolver("internal", type, resolver, names.Field(index), construction, DisposalOf(instance), "// Scoped: one instance per scope.")); } /// @@ -926,19 +973,10 @@ private static void EmitFreshResolver(StringBuilder builder, int depth, in Fresh .Append('(').Append(resolver.Signature).AppendLine(")"); Indent(builder, depth).AppendLine("{"); EmitDisposedGuard(builder, depth + 1); - if (resolver.Disposable) + if (resolver.Disposal != DisposalTracking.None) { Indent(builder, depth + 1).Append(type).Append(" created = ").Append(construction).AppendLine(";"); - 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("((global::System.IDisposable)created).Dispose();"); - Indent(builder, depth + 3).AppendLine("throw new global::System.ObjectDisposedException(GetType().FullName);"); - Indent(builder, depth + 2).AppendLine("}"); - builder.AppendLine(); - Indent(builder, depth + 2).AppendLine("(__disposables ??= new global::System.Collections.Generic.List()).Add(created);"); - Indent(builder, depth + 1).AppendLine("}"); + EmitFreshDisposalTracking(builder, depth + 1, resolver.Disposal == DisposalTracking.Runtime); builder.AppendLine(); Indent(builder, depth + 1).AppendLine("return created;"); } @@ -950,6 +988,48 @@ private static void EmitFreshResolver(StringBuilder builder, int depth, in Fresh Indent(builder, depth).AppendLine("}"); } + /// + /// 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. + /// + private static void EmitFreshDisposalTracking(StringBuilder builder, int depth, bool runtimeCheck) + { + string disposable; + if (runtimeCheck) + { + Indent(builder, depth).AppendLine("if (created is global::System.IDisposable __disposable)"); + Indent(builder, depth).AppendLine("{"); + depth++; + disposable = "__disposable"; + } + else + { + disposable = "((global::System.IDisposable)created)"; + } + + Indent(builder, depth).AppendLine("lock (__gate)"); + Indent(builder, depth).AppendLine("{"); + Indent(builder, depth + 1).AppendLine("if (__disposed)"); + 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 + 1).AppendLine("}"); + builder.AppendLine(); + Indent(builder, depth + 1).AppendLine("(__disposables ??= new global::System.Collections.Generic.List()).Add(created);"); + Indent(builder, depth).AppendLine("}"); + + if (runtimeCheck) + { + depth--; + 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 @@ -972,7 +1052,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, instance.IsDisposable, null)); + EmitCachingResolver(builder, depth, new CachingResolver("protected override", type, resolver, names.Field(index), construction, DisposalOf(instance), null)); } /// @@ -1190,21 +1270,13 @@ private static void EmitAsyncFreshResolver(StringBuilder builder, int depth, int /// private static void EmitAsyncDisposableRegistration(StringBuilder builder, int depth, InstanceModel instance) { - if (!instance.IsDisposable) + DisposalTracking disposal = DisposalOf(instance); + if (disposal == DisposalTracking.None) { return; } - Indent(builder, depth).AppendLine("lock (__gate)"); - Indent(builder, depth).AppendLine("{"); - Indent(builder, depth + 1).AppendLine("if (__disposed)"); - Indent(builder, depth + 1).AppendLine("{"); - Indent(builder, depth + 2).AppendLine("((global::System.IDisposable)created).Dispose();"); - Indent(builder, depth + 2).AppendLine("throw new global::System.ObjectDisposedException(GetType().FullName);"); - Indent(builder, depth + 1).AppendLine("}"); - builder.AppendLine(); - Indent(builder, depth + 1).AppendLine("(__disposables ??= new global::System.Collections.Generic.List()).Add(created);"); - Indent(builder, depth).AppendLine("}"); + EmitFreshDisposalTracking(builder, depth, disposal == DisposalTracking.Runtime); } /// @@ -1362,7 +1434,16 @@ private static void EmitCachingResolver(StringBuilder builder, int depth, in Cac Indent(builder, depth + 2).Append("if (").Append(resolver.Field).AppendLine(" is null)"); Indent(builder, depth + 2).AppendLine("{"); Indent(builder, depth + 3).Append(resolver.Field).Append(" = ").Append(resolver.Construction).AppendLine(";"); - if (resolver.Disposable) + if (resolver.Disposal == DisposalTracking.Runtime) + { + // A factory's declared return type may hide a concrete IDisposable, 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)"); + 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("}"); + } + else if (resolver.Disposal == DisposalTracking.Static) { Indent(builder, depth + 3).Append("(__disposables ??= new global::System.Collections.Generic.List()).Add(").Append(resolver.Field).AppendLine(");"); } @@ -1506,7 +1587,9 @@ private static void EmitOwnedHelper(StringBuilder builder, int depth) Indent(builder, depth).AppendLine("}"); } - // A bare Owned: resolve T once into a throwaway child scope and wrap it as a disposal handle. + /// + /// A bare Owned<T>: resolve T once into a throwaway child scope and wrap it as a disposal handle. + /// private static string OwnedBare(string service, string resolver, bool rootOwned) => $"__Owned<{service}>({OwnedInner(service, resolver, [], rootOwned)})"; @@ -1616,10 +1699,11 @@ private static StringBuilder Indent(StringBuilder builder, int depth) /// /// The inputs to : the method and return /// , the resolver name and backing , the - /// expression, whether the instance needs disposal, - /// and an optional leading . + /// expression, how the instance is tracked for (not + /// at all, by the static type, or by a runtime is IDisposable check on the realized factory + /// output), and an optional leading . /// - private readonly struct CachingResolver(string modifiers, string type, string method, string field, string construction, bool disposable, string? comment) + private readonly struct CachingResolver(string modifiers, string type, string method, string field, string construction, DisposalTracking disposal, string? comment) { public string Modifiers { get; } = modifiers; @@ -1631,7 +1715,7 @@ private readonly struct CachingResolver(string modifiers, string type, string me public string Construction { get; } = construction; - public bool Disposable { get; } = disposable; + public DisposalTracking Disposal { get; } = disposal; public string? Comment { get; } = comment; } @@ -1640,10 +1724,11 @@ private readonly struct CachingResolver(string modifiers, string type, string me /// The inputs to : the method and return /// , the resolver name, its parameter /// (empty for a transient, the runtime arguments for a parameterized service), the - /// expression and whether the instance needs - /// disposal. + /// expression and how the instance is tracked for + /// (not at all, by the static type, or by a runtime is IDisposable check on the realized factory + /// output). /// - private readonly struct FreshResolver(string modifiers, string type, string method, string signature, string construction, bool disposable) + private readonly struct FreshResolver(string modifiers, string type, string method, string signature, string construction, DisposalTracking disposal) { public string Modifiers { get; } = modifiers; @@ -1655,7 +1740,7 @@ private readonly struct FreshResolver(string modifiers, string type, string meth public string Construction { get; } = construction; - public bool Disposable { get; } = disposable; + public DisposalTracking Disposal { get; } = disposal; } /// diff --git a/Source/Awaiten.SourceGenerators/Internals/InstanceModel.cs b/Source/Awaiten.SourceGenerators/Internals/InstanceModel.cs index 84aa710..7a59cb8 100644 --- a/Source/Awaiten.SourceGenerators/Internals/InstanceModel.cs +++ b/Source/Awaiten.SourceGenerators/Internals/InstanceModel.cs @@ -18,6 +18,11 @@ namespace Awaiten.SourceGenerators.Internals; /// IAsyncInitializable (so it must be awaited once after construction); /// additionally covers an instance that only reaches one through its /// non-deferred dependencies, so it too must be resolved asynchronously. +/// is set for a factory whose declared return type is not itself +/// IDisposable yet could produce one at runtime (an interface or a non-sealed class): the emitted +/// resolver then tracks the realized instance for disposal behind a runtime is IDisposable test, +/// rather than trusting (which only sees the declared type and would leak a +/// disposable hidden behind a non-disposable service interface). /// internal sealed record InstanceModel( string ImplementationType, @@ -30,7 +35,8 @@ internal sealed record InstanceModel( ProductionKind Production = ProductionKind.Constructor, string? ProductionMember = null, bool IsAsyncInitializable = false, - bool IsAsyncTainted = false) + bool IsAsyncTainted = false, + bool RuntimeDisposalCheck = false) { /// /// The ordered runtime-argument types of this instance: the service types of its [Arg]-marked diff --git a/Tests/Awaiten.SourceGenerators.Tests/GeneralTests.cs b/Tests/Awaiten.SourceGenerators.Tests/GeneralTests.cs index 4f41ccf..b75eac9 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/GeneralTests.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/GeneralTests.cs @@ -341,12 +341,71 @@ public static partial class MyContainer await That(result.Diagnostics).IsEmpty(); string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; - await That(source).Contains("return MakeClock();") - .Because("the scope calls the static factory method by simple name instead of constructing the type"); + await That(source).Contains("global::MyCode.IClock created = MakeClock();") + .Because("the scope calls the static factory method by simple name instead of constructing the type (the realized instance is captured so its disposal can be tracked at runtime)"); await That(source).DoesNotContain("new global::MyCode.SystemClock(") .Because("a factory registration is produced by its method, never constructed directly"); } + [Fact] + public async Task FactoryReturningInterface_TracksDisposalByARuntimeCheckOnTheRealizedInstance() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + using System; + + namespace MyCode; + + public interface IClock { } + public sealed class DisposableClock : IClock, IDisposable { public void Dispose() { } } + + [Container] + [Transient(Factory = nameof(MakeClock))] + public static partial class MyContainer + { + private static IClock MakeClock() => new DisposableClock(); + } + """); + + 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("(__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"); + } + + [Fact] + public async Task FactoryReturningANonDisposableSealedType_EmitsNoDisposalTracking() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IClock { } + public sealed class PlainClock : IClock { } + + [Container] + [Transient(Factory = nameof(MakeClock))] + public static partial class MyContainer + { + private static PlainClock MakeClock() => new PlainClock(); + } + """); + + await That(result.Diagnostics).IsEmpty(); + string source = result.Sources["Awaiten.MyCode.MyContainer.g.cs"]; + await That(source).Contains("return MakeClock();") + .Because("a factory whose declared return type is a sealed non-disposable class cannot hide a disposable, so its resolver returns the call directly with no tracking"); + await That(source).DoesNotContain("created is global::System.IDisposable") + .Because("a provably non-disposable factory output gets no runtime disposal check"); + await That(source).DoesNotContain("(__disposables ??= new") + .Because("a provably non-disposable factory output is never added to the disposal list"); + } + [Fact] public async Task FactoryRegistration_ResolvesTheMethodParametersFromTheGraph() { diff --git a/Tests/Awaiten.Tests/AsyncInitializationTests.cs b/Tests/Awaiten.Tests/AsyncInitializationTests.cs index a4ec3a1..593b5ca 100644 --- a/Tests/Awaiten.Tests/AsyncInitializationTests.cs +++ b/Tests/Awaiten.Tests/AsyncInitializationTests.cs @@ -329,6 +329,28 @@ public async Task Dispose_DisposesAnAsyncInitializedSingleton() await That(DisposableConnection.DisposeCount).IsEqualTo(1); } + [Fact] + public async Task ResolveAsync_OfAFactoryHidingAnAsyncDisposable_InitializesAndDisposesItExactlyOnce() + { + // The factory is declared to return a non-async, non-disposable interface yet builds a concrete type + // that is both IAsyncInitializable and IDisposable. The async-taint follows the concrete return type + // (so the container drives InitializeAsync), and disposal is tracked by the generated runtime + // `is IDisposable` check on the realized instance - exercising the async creator path that the + // synchronous hidden-disposable tests do not reach. + AsyncHiddenDisposable hidden; + using (AsyncHiddenDisposableContainer.Root container = new()) + { + hidden = (AsyncHiddenDisposable)await container.ResolveAsync(Ct); + await That(hidden.Initialized).IsTrue() + .Because("the factory's concrete return type is IAsyncInitializable, so the container drives its initialization"); + await That(hidden.DisposeCount).IsEqualTo(0) + .Because("the container still owns the instance"); + } + + await That(hidden.DisposeCount).IsEqualTo(1) + .Because("a hidden async-disposable singleton is disposed exactly once on container teardown (not double-tracked by the async registration's runtime check)"); + } + [Fact] public async Task ResolveAsync_OfADisposableAsyncTransient_IsWithheldOnTheRoot() { @@ -558,6 +580,38 @@ public Task InitializeAsync(CancellationToken cancellationToken) [Singleton] public static partial class DisposableAsyncContainer; + // An async-but-not-disposable service interface: the factory's declared return type, so async-taint is + // statically visible (it extends IAsyncInitializable) while the concrete IDisposable stays hidden behind + // it - exactly the split that drives the async creator yet needs the runtime disposal check. + public interface IAsyncHidden : IAsyncInitializable; + + // A concrete type that is both async-initialized (through IAsyncHidden) and IDisposable. A factory + // declared to return IAsyncHidden still both drives its async initialization and disposes it - + // DisposeCount (not a bool) proves disposal happens exactly once. + public sealed class AsyncHiddenDisposable : IAsyncHidden, IDisposable + { + public bool Initialized { get; private set; } + + public int DisposeCount { get; private set; } + + public Task InitializeAsync(CancellationToken cancellationToken) + { + Initialized = true; + return Task.CompletedTask; + } + + public void Dispose() => DisposeCount++; + } + + [Container] + [Singleton(Factory = nameof(MakeAsyncHidden))] + public static partial class AsyncHiddenDisposableContainer + { +#pragma warning disable CA1859 + private static IAsyncHidden MakeAsyncHidden() => new AsyncHiddenDisposable(); +#pragma warning restore CA1859 + } + // A disposable async transient: withheld from ResolveAsync on the Root (it would accumulate there), but // resolvable from a child scope, which bounds and disposes it. public sealed class DisposableWorker : IAsyncInitializable, IDisposable diff --git a/Tests/Awaiten.Tests/FactoryAndInstanceTests.cs b/Tests/Awaiten.Tests/FactoryAndInstanceTests.cs index f8c8596..33c0a80 100644 --- a/Tests/Awaiten.Tests/FactoryAndInstanceTests.cs +++ b/Tests/Awaiten.Tests/FactoryAndInstanceTests.cs @@ -126,6 +126,75 @@ await That(gadget.Disposed).IsTrue() .Because("disposability follows the factory's concrete return type, even when the service interface is not disposable"); } + [Fact] + public async Task Factory_SingletonReturningInterfaceButBuildingADisposable_IsDisposedWithTheContainer() + { + HiddenDisposable hidden; + using (HiddenDisposableContainer.Root container = new()) + { + hidden = (HiddenDisposable)container.Resolve(); + } + + await That(hidden.DisposeCount).IsEqualTo(1) + .Because("a singleton factory declared to return the non-disposable interface still builds a concrete IDisposable, which the container must dispose exactly once (not double-tracked by the runtime check)"); + } + + [Fact] + public async Task Factory_ScopedReturningInterfaceButBuildingADisposable_IsDisposedWithTheScope() + { + using HiddenDisposableContainer.Root container = new(); + HiddenDisposable hidden; + using (IAwaitenScope scope = container.CreateScope()) + { + hidden = (HiddenDisposable)scope.Resolve(); + await That(hidden.DisposeCount).IsEqualTo(0) + .Because("the scope still owns the instance"); + } + + await That(hidden.DisposeCount).IsEqualTo(1) + .Because("a scoped factory declared to return the non-disposable interface still builds a concrete IDisposable, which the scope must dispose exactly once"); + } + + [Fact] + public async Task Factory_TransientReturningInterfaceButBuildingADisposable_IsDisposedWithTheContainer() + { + HiddenDisposable hidden; + using (HiddenDisposableContainer.Root container = new()) + { + hidden = (HiddenDisposable)container.Resolve(); + } + + await That(hidden.DisposeCount).IsEqualTo(1) + .Because("a transient factory declared to return the non-disposable interface still builds a concrete IDisposable, which the owner must dispose exactly once"); + } + + [Fact] + public async Task Factory_ReturnTypeAlreadyDisposable_IsStillDisposed_NoRegression() + { + PlainDisposable plain; + using (HiddenDisposableContainer.Root container = new()) + { + plain = container.Resolve(); + } + + await That(plain.DisposeCount).IsEqualTo(1) + .Because("a factory whose declared return type already implements IDisposable is tracked - and disposed exactly once - as before"); + } + + [Fact] + public async Task Factory_ReturningInterfaceButBuildingANonDisposable_IsNotRetained() + { + // A non-disposable factory output must not be retained: resolving it (and disposing the container) + // must not throw, and the output is simply not tracked. The interesting half is that the generated + // runtime `is IDisposable` check leaves it untracked rather than mis-casting it. + using (HiddenDisposableContainer.Root container = new()) + { + object plain = container.Resolve(); + await That(plain).IsNotNull() + .Because("a non-disposable factory output resolves normally"); + } + } + public interface IWidget; public sealed class Widget : IWidget @@ -199,6 +268,57 @@ public static partial class GadgetContainer private static DisposableGadget MakeGadget() => new(); } + public interface IHidden; + + public interface IScopedHidden; + + public interface ITransientHidden; + + public interface IPlain; + + // A concrete IDisposable behind a non-disposable service interface: the factory's *declared* return type + // is the interface, so static disposability analysis misses it - the container must track it at runtime. + // DisposeCount proves the runtime check tracks the instance exactly once, not twice. + public sealed class HiddenDisposable : IHidden, IScopedHidden, ITransientHidden, IDisposable + { + public int DisposeCount { get; private set; } + + public void Dispose() => DisposeCount++; + } + + // A factory whose declared return type already implements IDisposable: the no-regression baseline. + public sealed class PlainDisposable : IDisposable + { + public int DisposeCount { get; private set; } + + public void Dispose() => DisposeCount++; + } + + // A non-disposable factory output behind an interface: the runtime check must leave it untracked. + public sealed class PlainImpl : IPlain; + + [Container] + [Singleton(Factory = nameof(MakeHidden))] + [Scoped(Factory = nameof(MakeScopedHidden))] + [Transient(Factory = nameof(MakeTransientHidden))] + [Singleton(Factory = nameof(MakePlainDisposable))] + [Transient(Factory = nameof(MakePlain))] + public static partial class HiddenDisposableContainer + { +#pragma warning disable CA1859 + // Each factory is declared to return the non-disposable interface yet builds the concrete IDisposable. + private static IHidden MakeHidden() => new HiddenDisposable(); + + private static IScopedHidden MakeScopedHidden() => new HiddenDisposable(); + + private static ITransientHidden MakeTransientHidden() => new HiddenDisposable(); + + private static PlainDisposable MakePlainDisposable() => new(); + + private static IPlain MakePlain() => new PlainImpl(); +#pragma warning restore CA1859 + } + public interface IRead; public interface IWrite;