From 80c9e27e961ecff8d0ad0f5ebb75f1ffa64b84bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Mon, 29 Jun 2026 12:40:29 +0200 Subject: [PATCH 1/4] fix: dispose factory outputs hidden behind a non-disposable return type A factory registration computes disposability from the producer method's declared return type, so a factory declared `static IFoo Create()` that builds a `DisposableFooImpl : IFoo, IDisposable` was flagged non-disposable and never tracked, leaking the instance when the owning scope or root tore down. Constructed types use the concrete `info.Symbol` (always truthful) and a pre-built `Instance` is intentionally not owned, so the lie is possible only for factory production. For a factory whose declared return type is not itself `IDisposable` yet could produce one at runtime (an interface, a type parameter, or a non-sealed class), the emitted resolver now tracks the realized instance behind a runtime `is IDisposable` check instead of trusting the static flag. This retains only genuinely-disposable outputs (no retention cost otherwise) and closes the gap across the synchronous fresh resolver, the async fresh/caching registration, and the caching singleton/scoped disposal add. A sealed return type that is not `IDisposable` cannot hide one, so it emits no check (and a runtime `is IDisposable` against it would not even compile). The existing `__gate` lock and `__disposed` re-check semantics are preserved for factory outputs, so one built during a concurrent dispose is still disposed rather than leaked. The static `InstanceModel.IsDisposable` semantics are unchanged: the AWT118 root-accumulation analyzer and strict-lifetime withholding keep their compile-time behavior. AWT118 still under-fires for factory-hidden disposables (the same declared-type gap, but compile-time) - that is left to a separate body-analysis linter PR. `IAsyncDisposable` remains out of scope, matching the current `IDisposable`-only drain. --- .../AwaitenGenerator.cs | 23 +++- Source/Awaiten.SourceGenerators/Emitter.cs | 112 ++++++++++++----- .../Internals/InstanceModel.cs | 8 +- .../GeneralTests.cs | 63 +++++++++- .../Awaiten.Tests/FactoryAndInstanceTests.cs | 117 ++++++++++++++++++ 5 files changed, 288 insertions(+), 35 deletions(-) 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..0f0a941 100644 --- a/Source/Awaiten.SourceGenerators/Emitter.cs +++ b/Source/Awaiten.SourceGenerators/Emitter.cs @@ -199,6 +199,15 @@ private static bool IsFuncWithheld(InstanceModel[] instances, int index, Diction private static bool IsRootOwned(InstanceModel instance) => instance.Lifetime == Lifetime.Singleton || instance.Production == ProductionKind.Instance; + // A factory's declared return type can hide a concrete IDisposable behind a non-disposable service + // interface, so the static IsDisposable flag under-reports for such factory production. Its output is then + // tracked by a runtime `is IDisposable` check on the realized instance (retaining only genuinely-disposable + // outputs), instead of being statically gated. Constructed and pre-built-Instance production use the + // truthful static flag: info.Symbol is the concrete type, and a pre-built Instance is never owned. The + // model decides where the check is legal and needed (RuntimeDisposalCheck). + private static bool TracksDisposalAtRuntime(InstanceModel instance) + => instance.RuntimeDisposalCheck; + // 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 @@ -883,7 +892,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, instance.IsDisposable || TracksDisposalAtRuntime(instance), TracksDisposalAtRuntime(instance))); return; } @@ -905,11 +914,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, instance.IsDisposable || TracksDisposalAtRuntime(instance), TracksDisposalAtRuntime(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, instance.IsDisposable || TracksDisposalAtRuntime(instance), TracksDisposalAtRuntime(instance), "// Scoped: one instance per scope.")); } /// @@ -929,16 +938,7 @@ private static void EmitFreshResolver(StringBuilder builder, int depth, in Fresh if (resolver.Disposable) { 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.RuntimeCheck); builder.AppendLine(); Indent(builder, depth + 1).AppendLine("return created;"); } @@ -950,6 +950,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 +1014,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, instance.IsDisposable || TracksDisposalAtRuntime(instance), TracksDisposalAtRuntime(instance), null)); } /// @@ -1190,21 +1232,12 @@ private static void EmitAsyncFreshResolver(StringBuilder builder, int depth, int /// private static void EmitAsyncDisposableRegistration(StringBuilder builder, int depth, InstanceModel instance) { - if (!instance.IsDisposable) + if (!instance.IsDisposable && !TracksDisposalAtRuntime(instance)) { 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, TracksDisposalAtRuntime(instance)); } /// @@ -1362,7 +1395,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.Disposable && resolver.RuntimeCheck) + { + // 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.Disposable) { Indent(builder, depth + 3).Append("(__disposables ??= new global::System.Collections.Generic.List()).Add(").Append(resolver.Field).AppendLine(");"); } @@ -1617,9 +1659,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 . + /// whether disposal tracking is gated by a runtime is IDisposable check + /// on the realized instance (a factory output) rather than the static flag, 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, bool disposable, bool runtimeCheck, string? comment) { public string Modifiers { get; } = modifiers; @@ -1633,6 +1677,8 @@ private readonly struct CachingResolver(string modifiers, string type, string me public bool Disposable { get; } = disposable; + public bool RuntimeCheck { get; } = runtimeCheck; + public string? Comment { get; } = comment; } @@ -1640,10 +1686,12 @@ 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, whether the instance needs + /// disposal and whether disposal tracking is gated by a runtime + /// is IDisposable check on the realized instance (a factory output) rather than the static + /// flag. /// - 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, bool disposable, bool runtimeCheck) { public string Modifiers { get; } = modifiers; @@ -1656,6 +1704,8 @@ private readonly struct FreshResolver(string modifiers, string type, string meth public string Construction { get; } = construction; public bool Disposable { get; } = disposable; + + public bool RuntimeCheck { get; } = runtimeCheck; } /// 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/FactoryAndInstanceTests.cs b/Tests/Awaiten.Tests/FactoryAndInstanceTests.cs index f8c8596..dda625c 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.Disposed).IsTrue() + .Because("a singleton factory declared to return the non-disposable interface still builds a concrete IDisposable, which the container must dispose"); + } + + [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.Disposed).IsFalse() + .Because("the scope still owns the instance"); + } + + await That(hidden.Disposed).IsTrue() + .Because("a scoped factory declared to return the non-disposable interface still builds a concrete IDisposable, which the scope must dispose"); + } + + [Fact] + public async Task Factory_TransientReturningInterfaceButBuildingADisposable_IsDisposedWithTheContainer() + { + HiddenDisposable hidden; + using (HiddenDisposableContainer.Root container = new()) + { + hidden = (HiddenDisposable)container.Resolve(); + } + + await That(hidden.Disposed).IsTrue() + .Because("a transient factory declared to return the non-disposable interface still builds a concrete IDisposable, which the owner must dispose"); + } + + [Fact] + public async Task Factory_ReturnTypeAlreadyDisposable_IsStillDisposed_NoRegression() + { + PlainDisposable plain; + using (HiddenDisposableContainer.Root container = new()) + { + plain = container.Resolve(); + } + + await That(plain.Disposed).IsTrue() + .Because("a factory whose declared return type already implements IDisposable is tracked 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,54 @@ 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. + public sealed class HiddenDisposable : IHidden, IScopedHidden, ITransientHidden, IDisposable + { + public bool Disposed { get; private set; } + + public void Dispose() => Disposed = true; + } + + // A factory whose declared return type already implements IDisposable: the no-regression baseline. + public sealed class PlainDisposable : IDisposable + { + public bool Disposed { get; private set; } + + public void Dispose() => Disposed = true; + } + + // 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 + { + // 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(); + } + public interface IRead; public interface IWrite; From 4576b9b7441669c92df57deed891289f566ffb57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Mon, 29 Jun 2026 13:45:16 +0200 Subject: [PATCH 2/4] test: cover async-tainted factory-hidden disposables and assert exactly-once disposal Add a runtime test for a factory whose declared return type extends IAsyncInitializable but hides a concrete IDisposable behind it, exercising the async creator path (EmitAsyncDisposableRegistration with the runtime is-IDisposable check) that the synchronous hidden-disposable tests do not reach. It asserts the instance is both initialized and disposed exactly once. Convert the HiddenDisposable and PlainDisposable test fixtures from a bool Disposed toggle to an instance-level DisposeCount, and tighten the singleton/scoped/transient/no-regression assertions to expect exactly one disposal. A double-add to the disposal list would now fail rather than pass silently. --- .../Awaiten.Tests/AsyncInitializationTests.cs | 52 +++++++++++++++++++ .../Awaiten.Tests/FactoryAndInstanceTests.cs | 27 +++++----- 2 files changed, 66 insertions(+), 13 deletions(-) diff --git a/Tests/Awaiten.Tests/AsyncInitializationTests.cs b/Tests/Awaiten.Tests/AsyncInitializationTests.cs index a4ec3a1..215240e 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,36 @@ 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 + { + private static IAsyncHidden MakeAsyncHidden() => new AsyncHiddenDisposable(); + } + // 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 dda625c..158f117 100644 --- a/Tests/Awaiten.Tests/FactoryAndInstanceTests.cs +++ b/Tests/Awaiten.Tests/FactoryAndInstanceTests.cs @@ -135,8 +135,8 @@ public async Task Factory_SingletonReturningInterfaceButBuildingADisposable_IsDi hidden = (HiddenDisposable)container.Resolve(); } - await That(hidden.Disposed).IsTrue() - .Because("a singleton factory declared to return the non-disposable interface still builds a concrete IDisposable, which the container must dispose"); + 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] @@ -147,12 +147,12 @@ public async Task Factory_ScopedReturningInterfaceButBuildingADisposable_IsDispo using (IAwaitenScope scope = container.CreateScope()) { hidden = (HiddenDisposable)scope.Resolve(); - await That(hidden.Disposed).IsFalse() + await That(hidden.DisposeCount).IsEqualTo(0) .Because("the scope still owns the instance"); } - await That(hidden.Disposed).IsTrue() - .Because("a scoped factory declared to return the non-disposable interface still builds a concrete IDisposable, which the scope must dispose"); + 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] @@ -164,8 +164,8 @@ public async Task Factory_TransientReturningInterfaceButBuildingADisposable_IsDi hidden = (HiddenDisposable)container.Resolve(); } - await That(hidden.Disposed).IsTrue() - .Because("a transient factory declared to return the non-disposable interface still builds a concrete IDisposable, which the owner must dispose"); + 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] @@ -177,8 +177,8 @@ public async Task Factory_ReturnTypeAlreadyDisposable_IsStillDisposed_NoRegressi plain = container.Resolve(); } - await That(plain.Disposed).IsTrue() - .Because("a factory whose declared return type already implements IDisposable is tracked as before"); + 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] @@ -278,19 +278,20 @@ 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 bool Disposed { get; private set; } + public int DisposeCount { get; private set; } - public void Dispose() => Disposed = true; + public void Dispose() => DisposeCount++; } // A factory whose declared return type already implements IDisposable: the no-regression baseline. public sealed class PlainDisposable : IDisposable { - public bool Disposed { get; private set; } + public int DisposeCount { get; private set; } - public void Dispose() => Disposed = true; + public void Dispose() => DisposeCount++; } // A non-disposable factory output behind an interface: the runtime check must leave it untracked. From a3e67df1468978aa213e36303425edc09e08f339 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Mon, 29 Jun 2026 13:56:47 +0200 Subject: [PATCH 3/4] refactor: collapse resolver disposal flags into a DisposalTracking enum The CachingResolver constructor reached 8 parameters after the runtime disposal check was added (modifiers, type, method, field, construction, disposable, runtimeCheck, comment), tripping the maintainability limit. The two disposal booleans carry an invariant - a runtime check is only ever set when the static disposable flag is not - so they collapse cleanly into a single DisposalTracking value (None, Static, Runtime), bringing CachingResolver to 7 parameters and FreshResolver to 6. This also removes the repeated 'IsDisposable || TracksDisposalAtRuntime' computation at the five resolver call sites and the parallel pair of bool checks in the emitter bodies. The generated output is unchanged (the source-generator snapshot tests pass verbatim). --- Source/Awaiten.SourceGenerators/Emitter.cs | 75 ++++++++++--------- .../Awaiten.Tests/AsyncInitializationTests.cs | 2 + .../Awaiten.Tests/FactoryAndInstanceTests.cs | 2 + 3 files changed, 45 insertions(+), 34 deletions(-) diff --git a/Source/Awaiten.SourceGenerators/Emitter.cs b/Source/Awaiten.SourceGenerators/Emitter.cs index 0f0a941..d39b0e5 100644 --- a/Source/Awaiten.SourceGenerators/Emitter.cs +++ b/Source/Awaiten.SourceGenerators/Emitter.cs @@ -199,14 +199,26 @@ private static bool IsFuncWithheld(InstanceModel[] instances, int index, Diction private static bool IsRootOwned(InstanceModel instance) => instance.Lifetime == Lifetime.Singleton || instance.Production == ProductionKind.Instance; - // A factory's declared return type can hide a concrete IDisposable behind a non-disposable service - // interface, so the static IsDisposable flag under-reports for such factory production. Its output is then - // tracked by a runtime `is IDisposable` check on the realized instance (retaining only genuinely-disposable - // outputs), instead of being statically gated. Constructed and pre-built-Instance production use the - // truthful static flag: info.Symbol is the concrete type, and a pre-built Instance is never owned. The - // model decides where the check is legal and needed (RuntimeDisposalCheck). - private static bool TracksDisposalAtRuntime(InstanceModel instance) - => instance.RuntimeDisposalCheck; + // 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 ? DisposalTracking.Runtime + : instance.IsDisposable ? 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 @@ -892,7 +904,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 || TracksDisposalAtRuntime(instance), TracksDisposalAtRuntime(instance))); + EmitFreshResolver(builder, depth, new FreshResolver("internal", type, resolver, signature, parameterizedConstruction, DisposalOf(instance))); return; } @@ -914,11 +926,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 || TracksDisposalAtRuntime(instance), TracksDisposalAtRuntime(instance))); + 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 || TracksDisposalAtRuntime(instance), TracksDisposalAtRuntime(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.")); } /// @@ -935,10 +947,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(";"); - EmitFreshDisposalTracking(builder, depth + 1, resolver.RuntimeCheck); + EmitFreshDisposalTracking(builder, depth + 1, resolver.Disposal == DisposalTracking.Runtime); builder.AppendLine(); Indent(builder, depth + 1).AppendLine("return created;"); } @@ -1014,7 +1026,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 || TracksDisposalAtRuntime(instance), TracksDisposalAtRuntime(instance), null)); + EmitCachingResolver(builder, depth, new CachingResolver("protected override", type, resolver, names.Field(index), construction, DisposalOf(instance), null)); } /// @@ -1232,12 +1244,13 @@ private static void EmitAsyncFreshResolver(StringBuilder builder, int depth, int /// private static void EmitAsyncDisposableRegistration(StringBuilder builder, int depth, InstanceModel instance) { - if (!instance.IsDisposable && !TracksDisposalAtRuntime(instance)) + DisposalTracking disposal = DisposalOf(instance); + if (disposal == DisposalTracking.None) { return; } - EmitFreshDisposalTracking(builder, depth, TracksDisposalAtRuntime(instance)); + EmitFreshDisposalTracking(builder, depth, disposal == DisposalTracking.Runtime); } /// @@ -1395,7 +1408,7 @@ 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 && resolver.RuntimeCheck) + 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. @@ -1404,7 +1417,7 @@ private static void EmitCachingResolver(StringBuilder builder, int depth, in Cac 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.Disposable) + else if (resolver.Disposal == DisposalTracking.Static) { Indent(builder, depth + 3).Append("(__disposables ??= new global::System.Collections.Generic.List()).Add(").Append(resolver.Field).AppendLine(");"); } @@ -1658,12 +1671,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, - /// whether disposal tracking is gated by a runtime is IDisposable check - /// on the realized instance (a factory output) rather than the static flag, 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, bool runtimeCheck, 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; @@ -1675,9 +1687,7 @@ private readonly struct CachingResolver(string modifiers, string type, string me public string Construction { get; } = construction; - public bool Disposable { get; } = disposable; - - public bool RuntimeCheck { get; } = runtimeCheck; + public DisposalTracking Disposal { get; } = disposal; public string? Comment { get; } = comment; } @@ -1686,12 +1696,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, whether the instance needs - /// disposal and whether disposal tracking is gated by a runtime - /// is IDisposable check on the realized instance (a factory output) rather than the static - /// flag. + /// 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, bool runtimeCheck) + private readonly struct FreshResolver(string modifiers, string type, string method, string signature, string construction, DisposalTracking disposal) { public string Modifiers { get; } = modifiers; @@ -1703,9 +1712,7 @@ private readonly struct FreshResolver(string modifiers, string type, string meth public string Construction { get; } = construction; - public bool Disposable { get; } = disposable; - - public bool RuntimeCheck { get; } = runtimeCheck; + public DisposalTracking Disposal { get; } = disposal; } /// diff --git a/Tests/Awaiten.Tests/AsyncInitializationTests.cs b/Tests/Awaiten.Tests/AsyncInitializationTests.cs index 215240e..593b5ca 100644 --- a/Tests/Awaiten.Tests/AsyncInitializationTests.cs +++ b/Tests/Awaiten.Tests/AsyncInitializationTests.cs @@ -607,7 +607,9 @@ public Task InitializeAsync(CancellationToken cancellationToken) [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 diff --git a/Tests/Awaiten.Tests/FactoryAndInstanceTests.cs b/Tests/Awaiten.Tests/FactoryAndInstanceTests.cs index 158f117..33c0a80 100644 --- a/Tests/Awaiten.Tests/FactoryAndInstanceTests.cs +++ b/Tests/Awaiten.Tests/FactoryAndInstanceTests.cs @@ -305,6 +305,7 @@ public sealed class PlainImpl : IPlain; [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(); @@ -315,6 +316,7 @@ public static partial class HiddenDisposableContainer private static PlainDisposable MakePlainDisposable() => new(); private static IPlain MakePlain() => new PlainImpl(); +#pragma warning restore CA1859 } public interface IRead; From 959470bd181f3a1182f0e30b0af2d9936fd3b707 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Mon, 29 Jun 2026 14:32:17 +0200 Subject: [PATCH 4/4] docs: convert Emitter member comments to XML docs; express DisposalOf as a tuple switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert the eleven member-level line comments in Emitter (IsWithheld, IsFuncWithheld, the DisposalTracking enum, DisposalOf, EmitsSync, the four guidance-message helpers, Withheld, and OwnedBare) to /// XML-doc summaries, matching the file's existing convention. Generic types that appear in the prose are wrapped in with their angle brackets escaped (Owned<T>, Func<…, Owned<T>>) so the comments remain well-formed XML; in-body comments are left as line comments since they document statements rather than members. Rewrite DisposalOf's body as a tuple switch over (RuntimeDisposalCheck, IsDisposable) instead of nested ternaries. The mapping is unchanged - (true, _) => Runtime, (_, true) => Static, (_, _) => None - so the generated output is identical and the source-generator snapshot tests pass verbatim. --- Source/Awaiten.SourceGenerators/Emitter.cs | 126 +++++++++++++-------- 1 file changed, 77 insertions(+), 49 deletions(-) diff --git a/Source/Awaiten.SourceGenerators/Emitter.cs b/Source/Awaiten.SourceGenerators/Emitter.cs index d39b0e5..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,12 +205,14 @@ private static bool IsFuncWithheld(InstanceModel[] instances, int index, Diction private static bool IsRootOwned(InstanceModel instance) => instance.Lifetime == Lifetime.Singleton || instance.Production == ProductionKind.Instance; - // 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. + /// + /// 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, @@ -212,56 +220,72 @@ private enum DisposalTracking 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. + /// + /// 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 ? DisposalTracking.Runtime - : instance.IsDisposable ? 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. + => (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); @@ -743,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(); @@ -1561,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)})";