From 7c730b90ab5a26a2520d2d8fcb8c479aa80077a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Thu, 16 Jul 2026 18:23:38 +0200 Subject: [PATCH 1/3] feat: add SuppressDisposal to opt out of container disposal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SuppressDisposal = true` on the generic lifetime attributes tells the container to construct a service but not tear it down: it never calls the instance's `Dispose`/`DisposeAsync`. On its own this is the built counterpart of a pre-built `Instance` (ExternallyOwned — teardown belongs to you or to something outside the container). Paired with an `OnRelease` hook it enables pooling, where the hook returns the object to a pool instead of disposing it, and with `Owned` the rent/return cycle is bounded to a `using` block. ## Behavior - `DisposalOf` short-circuits to `None` and `NeedsDisposal` reads `false` for a suppressed instance, so it is never tracked for teardown and lifetime-safety analysis (AWT118) treats it like any other non-owned service. - AWT156 (synchronous dispose of an async-only disposable) excludes suppressed instances — the container never disposes them, so no drain can throw on one. - Coalescing reports AWT166 when a later registration opts into `SuppressDisposal` the winning registration did not, consistent with how `Eager` and the lifecycle hooks already coalesce. - New diagnostic **AWT192**: `SuppressDisposal` on a pre-built `Instance` is a silent no-op (the container never owns or disposes an `Instance`), so it is rejected — mirroring AWT165 for lifecycle hooks. It is allowed on constructor- and factory-produced registrations. ## Docs Adds a "Suppressing disposal" section to the disposal page (ExternallyOwned plus return-to-pool via `OnRelease` and `Owned`), a note on the lifecycle-hooks page that `OnRelease` can replace disposal, a cross-reference from the factories page, and the AWT192 diagnostics entry. ## Tests Runtime coverage for suppression (instance not disposed, `OnRelease` still runs) and a full rent/return/reuse pooling round-trip through `Owned`; generator coverage for the AWT166 coalescing conflict and the AWT192 guard; public API approvals updated for the new attribute property. --- Docs/pages/09-diagnostics.md | 17 ++++ Docs/pages/lifetime/02-disposal.md | 20 +++++ Docs/pages/lifetime/05-lifecycle-hooks.md | 2 +- .../02-factories-and-instances.md | 2 + .../AnalyzerReleases.Unshipped.md | 1 + .../AwaitenAnalyzer.cs | 4 +- .../AwaitenGenerator.Coalescing.cs | 25 +++--- .../AwaitenGenerator.Collection.cs | 3 + .../AwaitenGenerator.Production.cs | 13 +++- .../AwaitenGenerator.Registrations.cs | 9 +++ .../Awaiten.SourceGenerators/Diagnostics.cs | 14 ++++ .../Entities/InstanceModel.cs | 11 ++- .../Sources/Sources.Disposal.cs | 18 +++-- Source/Awaiten/ScopedAttribute.cs | 18 +++++ Source/Awaiten/SingletonAttribute.cs | 18 +++++ Source/Awaiten/TransientAttribute.cs | 18 +++++ .../Expected/Awaiten_net10.0.txt | 6 ++ .../Expected/Awaiten_net8.0.txt | 6 ++ .../Expected/Awaiten_netstandard2.0.txt | 6 ++ ...ts.Awt166ConflictingLifecycleDirectives.cs | 24 ++++++ ...cTests.Awt192SuppressDisposalOnInstance.cs | 77 +++++++++++++++++++ Tests/Awaiten.Tests/LifecycleHookTests.cs | 73 ++++++++++++++++++ 22 files changed, 363 insertions(+), 22 deletions(-) create mode 100644 Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt192SuppressDisposalOnInstance.cs diff --git a/Docs/pages/09-diagnostics.md b/Docs/pages/09-diagnostics.md index 6bc61cbf..0db04de6 100644 --- a/Docs/pages/09-diagnostics.md +++ b/Docs/pages/09-diagnostics.md @@ -598,6 +598,23 @@ public sealed class Counter(Lazy> sessions); // Owned canno public static partial class CoffeeShop; ``` +### AWT192 + +:::danger[Error] +`SuppressDisposal` is set on a pre-built `Instance`, which the container does not own or dispose, so it has no effect. Remove it, or register the type for construction (by constructor or `Factory`) instead of as an `Instance`. +::: + +```csharp +public sealed class Boiler : IDisposable { public void Dispose() { } } + +[Container] +[Singleton(Instance = nameof(Shared), SuppressDisposal = true)] // an Instance is never disposed anyway +public static partial class CoffeeShop +{ + private static Boiler Shared { get; } = new(); +} +``` + ## Decorators and composites ### AWT123 diff --git a/Docs/pages/lifetime/02-disposal.md b/Docs/pages/lifetime/02-disposal.md index 5e378ba8..6bfdcbaf 100644 --- a/Docs/pages/lifetime/02-disposal.md +++ b/Docs/pages/lifetime/02-disposal.md @@ -32,6 +32,26 @@ A transient is disposed by the owner that built it. Resolve one inside a scope a An object you supplied with `Instance = nameof(...)` is yours. Awaiten never disposes it. The same is true for anything resolved from an external provider across the [MS.DI bridge](../msdi-bridge). +## Suppressing disposal + +Sometimes the container builds a service but should not tear it down: the object is rented from a pool you return it to, or its lifetime belongs to something outside the container. Set `SuppressDisposal = true` and Awaiten constructs the service as usual but never calls its `Dispose`/`DisposeAsync`. + +On its own this is opt-out ownership, the built counterpart of an `Instance`: the container makes the object but hands its teardown to you. Paired with an [`OnRelease` hook](./lifecycle-hooks) it becomes a clean return-to-pool, and with [`Owned`](./owned) the rent-and-return is bounded to a `using` block. + +```csharp +[Container] +[Transient(Factory = nameof(RentCup), OnRelease = nameof(ReturnCup), SuppressDisposal = true)] +public static partial class CoffeeShop +{ + private static readonly CupPool Pool = new(); + + private static Cup RentCup() => Pool.Rent(); + private static void ReturnCup(Cup cup) => Pool.Return(cup); // returned, never disposed +} +``` + +`SuppressDisposal` applies to a service the container constructs, by constructor or `Factory`. Setting it on a pre-built `Instance`, which the container never owns or disposes anyway, is a build error ([AWT192](../diagnostics#awt192)). + ## Async disposal Dispose with `await using` and Awaiten awaits `DisposeAsync` on every async-disposable instance it owns. diff --git a/Docs/pages/lifetime/05-lifecycle-hooks.md b/Docs/pages/lifetime/05-lifecycle-hooks.md index 230876a9..0d89e10d 100644 --- a/Docs/pages/lifetime/05-lifecycle-hooks.md +++ b/Docs/pages/lifetime/05-lifecycle-hooks.md @@ -18,7 +18,7 @@ public static partial class CoffeeShop You can type the parameter as `object` instead when one hook serves several implementations. -`OnActivated` runs once, right after construction. For an async service it runs before `InitializeAsync`. `OnRelease` runs when the owner or scope is disposed, before the instance's own `Dispose`, and in reverse creation order. +`OnActivated` runs once, right after construction. For an async service it runs before `InitializeAsync`. `OnRelease` runs when the owner or scope is disposed, before the instance's own `Dispose`, and in reverse creation order. By default it runs *in addition to* that disposal; pair it with [`SuppressDisposal`](./disposal#suppressing-disposal) to replace disposal entirely, as when a hook returns an object to a pool. ## Hook parameters diff --git a/Docs/pages/registration/02-factories-and-instances.md b/Docs/pages/registration/02-factories-and-instances.md index 82eabbe8..9fd21206 100644 --- a/Docs/pages/registration/02-factories-and-instances.md +++ b/Docs/pages/registration/02-factories-and-instances.md @@ -42,6 +42,8 @@ A factory can return an interface that hides a disposable concrete type. Awaiten An `Instance` member is the opposite. You own it, so Awaiten leaves its disposal to you. +To keep a factory-built service but opt out of that disposal, set `SuppressDisposal = true`. Awaiten builds it and never disposes it, leaving teardown to you or to an `OnRelease` hook, which is how you [pool a rented object](../lifetime/disposal#suppressing-disposal). + ## Where to go next - [Context-aware factories](../resolution/context-aware-factories) to build a service based on who asked for it. diff --git a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md index 18e42fdf..8cf76113 100644 --- a/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/Source/Awaiten.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -93,3 +93,4 @@ AWT189 | Awaiten | Error | A lifecycle hook parameter (after the instance) is marked [Arg], but a hook resolves its parameters from the graph AWT190 | Awaiten | Error | A lifecycle hook (OnActivated / OnRelease) names an overloaded method, so the container cannot choose which one to call AWT191 | Awaiten | Error | An OnRelease hook parameter is a Func/Lazy relationship, which would defer resolution past the owner's teardown + AWT192 | Awaiten | Error | SuppressDisposal is set on a pre-built Instance, which the container does not own or dispose, so it has no effect diff --git a/Source/Awaiten.SourceGenerators/AwaitenAnalyzer.cs b/Source/Awaiten.SourceGenerators/AwaitenAnalyzer.cs index d734cef7..3d3b5b5a 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenAnalyzer.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenAnalyzer.cs @@ -346,7 +346,8 @@ private static void AnalyzeSynchronousDisposeCall( // async-only disposable the disposed owner could track. IsAsyncDisposable is read off a registration's // declared/produced type; a factory output hiding one behind a non-disposable declared type is left to // the runtime backstop, and a pre-built Instance registration is never owned, so it carries neither flag - // and is naturally exempt. A scoped/transient async-only service also warns on a Root using, since the + // and is naturally exempt. A SuppressDisposal instance is likewise exempt: the container never disposes it, + // so no synchronous drain can throw on it. A scoped/transient async-only service also warns on a Root using, since the // root is itself a scope and may track one; a root-owned one never warns on a child Scope using, since a // singleton always tracks on the Root (its resolver runs against the root even when first hit inside a // child scope), so a Scope's drain cannot reach it. @@ -398,6 +399,7 @@ private static void ReportIfAsyncOnlyOwner( private static ImmutableArray AsyncOnlyDisposables(GraphModel graph, bool includeRootOwned) => graph.Instances .Where(instance => instance.IsAsyncDisposable && !instance.IsDisposable + && !instance.SuppressDisposal && (includeRootOwned || !IsRootOwned(instance))) .Select(instance => AwaitenGenerator.DisplayInstance(instance.ImplementationType)) .Distinct(StringComparer.Ordinal) diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Coalescing.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Coalescing.cs index 5b08395c..fc1f11a7 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Coalescing.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Coalescing.cs @@ -186,6 +186,7 @@ static ImplInfo EnsureImpl(Dictionary implInfos, List DroppedOverridableDefaults(List raw /// /// Reports the coalescing conflicts a re-registration of an already-seen implementation raises: a different - /// lifetime (AWT107), a different production strategy (AWT111), or a contradicting OnActivated/OnRelease/Eager - /// directive (AWT166). Lifetime and production conflicts are reported once per implementation, each directive + /// lifetime (AWT107), a different production strategy (AWT111), or a contradicting + /// OnActivated/OnRelease/Eager/SuppressDisposal directive (AWT166). Lifetime and production conflicts are + /// reported once per implementation, each directive /// conflict once per (implementation, directive), since coalescing keeps the first registration. /// private static void ReportCoalescingConflicts( @@ -474,13 +476,13 @@ private static void ReportCoalescingConflicts( } /// - /// Every per-instance directive (OnActivated, OnRelease, or Eager) this registration sets to a value the - /// coalesced instance will not use, each yielded independently so it can be reported on its own. Coalescing - /// keeps the first (winning) registration's directives, so a conflict is a later registration explicitly - /// naming a directive value that differs from the winner's: a differing hook, or opting into Eager the winner - /// did not. A registration that leaves a directive unset (a null hook, or Eager left at its default false) - /// states no opinion and merges with the winner rather than conflicting, so the winner's own directives, - /// which this registration inherits, are never a conflict against themselves. + /// Every per-instance directive (OnActivated, OnRelease, Eager, or SuppressDisposal) this registration sets to + /// a value the coalesced instance will not use, each yielded independently so it can be reported on its own. + /// Coalescing keeps the first (winning) registration's directives, so a conflict is a later registration + /// explicitly naming a directive value that differs from the winner's: a differing hook, or opting into Eager + /// or SuppressDisposal the winner did not. A registration that leaves a directive unset (a null hook, or a + /// bool flag left at its default false) states no opinion and merges with the winner rather than conflicting, + /// so the winner's own directives, which this registration inherits, are never a conflict against themselves. /// private static IEnumerable<(string Directive, string Winner, string Loser)> ConflictingDirectives(ImplInfo info, RawRegistration registration) { @@ -498,6 +500,11 @@ private static void ReportCoalescingConflicts( { yield return ("Eager", "false", "true"); } + + if (registration.SuppressDisposal && !info.SuppressDisposal) + { + yield return ("SuppressDisposal", "false", "true"); + } } private static string DescribeHook(string? hook) => hook is null ? "unset" : $"'{hook}'"; diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Collection.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Collection.cs index e85bb84e..06a8e0b6 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Collection.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Collection.cs @@ -152,6 +152,9 @@ private static void CollectLifetimeRegistrations( Eager: NamedFlag(attribute, "Eager"), OnActivated: NamedArgument(attribute, "OnActivated"), OnRelease: NamedArgument(attribute, "OnRelease"), + // SuppressDisposal is exposed on the generic [Singleton<…>]/[Transient<…>]/[Scoped<…>] forms; the + // open-generic Type-ctor form has no such property, so NamedFlag reads false there. + SuppressDisposal: NamedFlag(attribute, "SuppressDisposal"), WhenInjectedInto: whenInjectedInto)); } } diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs index 47e72c2e..798749db 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs @@ -121,7 +121,8 @@ partial class AwaitenGenerator OnActivated: onActivated, OnRelease: onRelease, ActivationParameters: activationParameters, - ReleaseParameters: releaseParameters); + ReleaseParameters: releaseParameters, + SuppressDisposal: info.SuppressDisposal); static bool ImplementsInterface(ITypeSymbol type, INamedTypeSymbol @interface) { @@ -199,6 +200,16 @@ private static InstanceModel BuildPrebuiltInstance( new EquatableArray([Display(info.OwningServiceOrImpl),]))); } + // AWT189: the container never disposes a pre-built Instance (the caller owns it), so opting out of that + // disposal is a silent no-op, rejected for the same reason as a lifecycle hook above. + if (info.SuppressDisposal) + { + diagnostics.Add(new DiagnosticInfo( + Diagnostics.SuppressDisposalOnInstance, + info.Location, + new EquatableArray([Display(info.OwningServiceOrImpl),]))); + } + return new InstanceModel( info.ImplementationType, info.Symbol.Name, diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Registrations.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Registrations.cs index d7a0eae0..2b61a3fb 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Registrations.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Registrations.cs @@ -40,6 +40,7 @@ internal sealed record RawRegistration( bool Eager = false, string? OnActivated = null, string? OnRelease = null, + bool SuppressDisposal = false, string? WhenInjectedInto = null); /// @@ -167,6 +168,14 @@ public ImplInfo( /// public string? OnRelease { get; init; } + /// + /// Whether the winning registration opted out of the container's built-in disposal + /// (SuppressDisposal = true): the container constructs the instance but never calls its + /// Dispose/DisposeAsync, leaving teardown to an hook or an + /// owner outside the container. Set from the first registration like the implementation's other options. + /// + public bool SuppressDisposal { get; init; } + public List Services { get; } /// diff --git a/Source/Awaiten.SourceGenerators/Diagnostics.cs b/Source/Awaiten.SourceGenerators/Diagnostics.cs index 180971e5..5980bf8c 100644 --- a/Source/Awaiten.SourceGenerators/Diagnostics.cs +++ b/Source/Awaiten.SourceGenerators/Diagnostics.cs @@ -1360,4 +1360,18 @@ internal static class Diagnostics "Awaiten", DiagnosticSeverity.Error, isEnabledByDefault: true); + + /// + /// SuppressDisposal is set on a pre-built Instance registration. The container does not own a + /// pre-built instance - it is constructed, and disposed, by the caller - so it never disposes it; suppressing + /// that disposal is a silent no-op. Mirrors AWT165, which rejects a + /// lifecycle hook on an Instance for the same reason. + /// + public static readonly DiagnosticDescriptor SuppressDisposalOnInstance = new( + "AWT192", + "SuppressDisposal on a pre-built instance", + "'{0}' sets SuppressDisposal on a pre-built Instance, which the container does not own or dispose, so it has no effect; remove SuppressDisposal, or register the type for construction instead of as an Instance", + "Awaiten", + DiagnosticSeverity.Error, + isEnabledByDefault: true); } diff --git a/Source/Awaiten.SourceGenerators/Entities/InstanceModel.cs b/Source/Awaiten.SourceGenerators/Entities/InstanceModel.cs index dfa45809..99e4af55 100644 --- a/Source/Awaiten.SourceGenerators/Entities/InstanceModel.cs +++ b/Source/Awaiten.SourceGenerators/Entities/InstanceModel.cs @@ -22,7 +22,9 @@ namespace Awaiten.SourceGenerators.Entities; /// before the instance's own disposal. Each hook's first parameter is the instance; any parameters after it /// are graph dependencies, carried as / /// and resolved exactly like a constructor parameter (an activation dependency inline at the call, a release -/// dependency captured by value into the queued closure). +/// dependency captured by value into the queued closure). opts the instance +/// out of the container's built-in disposal entirely (teardown is left to an hook or +/// an owner outside the container), so a suppressed instance is never tracked for teardown. /// internal sealed record InstanceModel( string ImplementationType, @@ -45,7 +47,8 @@ internal sealed record InstanceModel( string? OnActivated = null, string? OnRelease = null, EquatableArray ActivationParameters = default, - EquatableArray ReleaseParameters = default) + EquatableArray ReleaseParameters = default, + bool SuppressDisposal = false) { /// /// The concrete type to construct and to use for cache fields and resolver return types. Normally the same @@ -58,8 +61,10 @@ internal sealed record InstanceModel( /// /// Whether the container owns this instance for disposal (its declared type implements IDisposable or /// IAsyncDisposable), so it is tracked for teardown. The drain selects the right disposal at runtime. + /// opts out: a suppressed instance is not tracked and reads here as + /// non-disposable, so lifetime-safety analysis treats it like any other non-owned service. /// - public bool NeedsDisposal => IsDisposable || IsAsyncDisposable; + public bool NeedsDisposal => !SuppressDisposal && (IsDisposable || IsAsyncDisposable); /// /// Whether this instance is itself an async-taint source (not merely tainted through a dependency): it is diff --git a/Source/Awaiten.SourceGenerators/Sources/Sources.Disposal.cs b/Source/Awaiten.SourceGenerators/Sources/Sources.Disposal.cs index abcd4e57..899d1856 100644 --- a/Source/Awaiten.SourceGenerators/Sources/Sources.Disposal.cs +++ b/Source/Awaiten.SourceGenerators/Sources/Sources.Disposal.cs @@ -24,15 +24,19 @@ private enum DisposalTracking /// /// 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. + /// either statically disposable or runtime-checked, never both. A SuppressDisposal instance is never + /// tracked (the container does not own its teardown), so it short-circuits to None ahead of either check, + /// including the runtime check a hidden-disposable factory output would otherwise get. /// private static DisposalTracking DisposalOf(InstanceModel instance) - => (instance.RuntimeDisposalCheck, instance.NeedsDisposal) switch - { - (true, _) => DisposalTracking.Runtime, - (_, true) => DisposalTracking.Static, - _ => DisposalTracking.None, - }; + => instance.SuppressDisposal + ? DisposalTracking.None + : (instance.RuntimeDisposalCheck, instance.NeedsDisposal) switch + { + (true, _) => DisposalTracking.Runtime, + (_, true) => DisposalTracking.Static, + _ => DisposalTracking.None, + }; /// /// Whether any instance registers an OnRelease hook, so the base Scope needs the diff --git a/Source/Awaiten/ScopedAttribute.cs b/Source/Awaiten/ScopedAttribute.cs index d4b619bb..168af0e1 100644 --- a/Source/Awaiten/ScopedAttribute.cs +++ b/Source/Awaiten/ScopedAttribute.cs @@ -98,6 +98,15 @@ public sealed class ScopedAttribute : Attribute /// instead of, that disposal). /// public string? OnRelease { get; set; } + + /// + /// Suppresses the container's built-in disposal of this instance. The container still constructs it + /// (by constructor or ), but does not call its Dispose/DisposeAsync + /// on teardown; releasing it becomes your responsibility - typically through an + /// hook (for example, returning it to a pool), or because its lifetime is owned outside the container. + /// Defaults to , where the container disposes what it builds. + /// + public bool SuppressDisposal { get; set; } } /// @@ -154,6 +163,15 @@ public sealed class ScopedAttribute : Attribute /// instead of, that disposal). /// public string? OnRelease { get; set; } + + /// + /// Suppresses the container's built-in disposal of this instance. The container still constructs it + /// (by constructor or ), but does not call its Dispose/DisposeAsync + /// on teardown; releasing it becomes your responsibility - typically through an + /// hook (for example, returning it to a pool), or because its lifetime is owned outside the container. + /// Defaults to , where the container disposes what it builds. + /// + public bool SuppressDisposal { get; set; } } #pragma warning restore S2326 diff --git a/Source/Awaiten/SingletonAttribute.cs b/Source/Awaiten/SingletonAttribute.cs index a202ab09..41483d82 100644 --- a/Source/Awaiten/SingletonAttribute.cs +++ b/Source/Awaiten/SingletonAttribute.cs @@ -111,6 +111,15 @@ public sealed class SingletonAttribute : Attribute /// instead of, that disposal). /// public string? OnRelease { get; set; } + + /// + /// Suppresses the container's built-in disposal of this instance. The container still constructs it + /// (by constructor or ), but does not call its Dispose/DisposeAsync + /// on teardown; releasing it becomes your responsibility - typically through an + /// hook (for example, returning it to a pool), or because its lifetime is owned outside the container. + /// Defaults to , where the container disposes what it builds. + /// + public bool SuppressDisposal { get; set; } } /// @@ -180,6 +189,15 @@ public sealed class SingletonAttribute : Attribute /// instead of, that disposal). /// public string? OnRelease { get; set; } + + /// + /// Suppresses the container's built-in disposal of this instance. The container still constructs it + /// (by constructor or ), but does not call its Dispose/DisposeAsync + /// on teardown; releasing it becomes your responsibility - typically through an + /// hook (for example, returning it to a pool), or because its lifetime is owned outside the container. + /// Defaults to , where the container disposes what it builds. + /// + public bool SuppressDisposal { get; set; } } #pragma warning restore S2326 diff --git a/Source/Awaiten/TransientAttribute.cs b/Source/Awaiten/TransientAttribute.cs index 448bcda2..0cc99904 100644 --- a/Source/Awaiten/TransientAttribute.cs +++ b/Source/Awaiten/TransientAttribute.cs @@ -98,6 +98,15 @@ public sealed class TransientAttribute : Attribute /// instead of, that disposal). /// public string? OnRelease { get; set; } + + /// + /// Suppresses the container's built-in disposal of this instance. The container still constructs it + /// (by constructor or ), but does not call its Dispose/DisposeAsync + /// on teardown; releasing it becomes your responsibility - typically through an + /// hook (for example, returning it to a pool), or because its lifetime is owned outside the container. + /// Defaults to , where the container disposes what it builds. + /// + public bool SuppressDisposal { get; set; } } /// @@ -153,6 +162,15 @@ public sealed class TransientAttribute : Attribute /// instead of, that disposal). /// public string? OnRelease { get; set; } + + /// + /// Suppresses the container's built-in disposal of this instance. The container still constructs it + /// (by constructor or ), but does not call its Dispose/DisposeAsync + /// on teardown; releasing it becomes your responsibility - typically through an + /// hook (for example, returning it to a pool), or because its lifetime is owned outside the container. + /// Defaults to , where the container disposes what it builds. + /// + public bool SuppressDisposal { get; set; } } #pragma warning restore S2326 diff --git a/Tests/Awaiten.Api.Tests/Expected/Awaiten_net10.0.txt b/Tests/Awaiten.Api.Tests/Expected/Awaiten_net10.0.txt index 4e68d237..a5d41044 100644 --- a/Tests/Awaiten.Api.Tests/Expected/Awaiten_net10.0.txt +++ b/Tests/Awaiten.Api.Tests/Expected/Awaiten_net10.0.txt @@ -259,6 +259,7 @@ namespace Awaiten public object? Key { get; set; } public string? OnActivated { get; set; } public string? OnRelease { get; set; } + public bool SuppressDisposal { get; set; } public System.Type? WhenInjectedInto { get; set; } } [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] @@ -271,6 +272,7 @@ namespace Awaiten public object? Key { get; set; } public string? OnActivated { get; set; } public string? OnRelease { get; set; } + public bool SuppressDisposal { get; set; } public System.Type? WhenInjectedInto { get; set; } } [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] @@ -294,6 +296,7 @@ namespace Awaiten public object? Key { get; set; } public string? OnActivated { get; set; } public string? OnRelease { get; set; } + public bool SuppressDisposal { get; set; } public System.Type? WhenInjectedInto { get; set; } } [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] @@ -308,6 +311,7 @@ namespace Awaiten public object? Key { get; set; } public string? OnActivated { get; set; } public string? OnRelease { get; set; } + public bool SuppressDisposal { get; set; } public System.Type? WhenInjectedInto { get; set; } } [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] @@ -329,6 +333,7 @@ namespace Awaiten public object? Key { get; set; } public string? OnActivated { get; set; } public string? OnRelease { get; set; } + public bool SuppressDisposal { get; set; } public System.Type? WhenInjectedInto { get; set; } } [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] @@ -341,6 +346,7 @@ namespace Awaiten public object? Key { get; set; } public string? OnActivated { get; set; } public string? OnRelease { get; set; } + public bool SuppressDisposal { get; set; } public System.Type? WhenInjectedInto { get; set; } } } \ No newline at end of file diff --git a/Tests/Awaiten.Api.Tests/Expected/Awaiten_net8.0.txt b/Tests/Awaiten.Api.Tests/Expected/Awaiten_net8.0.txt index 1f516a03..431ad891 100644 --- a/Tests/Awaiten.Api.Tests/Expected/Awaiten_net8.0.txt +++ b/Tests/Awaiten.Api.Tests/Expected/Awaiten_net8.0.txt @@ -259,6 +259,7 @@ namespace Awaiten public object? Key { get; set; } public string? OnActivated { get; set; } public string? OnRelease { get; set; } + public bool SuppressDisposal { get; set; } public System.Type? WhenInjectedInto { get; set; } } [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] @@ -271,6 +272,7 @@ namespace Awaiten public object? Key { get; set; } public string? OnActivated { get; set; } public string? OnRelease { get; set; } + public bool SuppressDisposal { get; set; } public System.Type? WhenInjectedInto { get; set; } } [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] @@ -294,6 +296,7 @@ namespace Awaiten public object? Key { get; set; } public string? OnActivated { get; set; } public string? OnRelease { get; set; } + public bool SuppressDisposal { get; set; } public System.Type? WhenInjectedInto { get; set; } } [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] @@ -308,6 +311,7 @@ namespace Awaiten public object? Key { get; set; } public string? OnActivated { get; set; } public string? OnRelease { get; set; } + public bool SuppressDisposal { get; set; } public System.Type? WhenInjectedInto { get; set; } } [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] @@ -329,6 +333,7 @@ namespace Awaiten public object? Key { get; set; } public string? OnActivated { get; set; } public string? OnRelease { get; set; } + public bool SuppressDisposal { get; set; } public System.Type? WhenInjectedInto { get; set; } } [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] @@ -341,6 +346,7 @@ namespace Awaiten public object? Key { get; set; } public string? OnActivated { get; set; } public string? OnRelease { get; set; } + public bool SuppressDisposal { get; set; } public System.Type? WhenInjectedInto { get; set; } } } \ No newline at end of file diff --git a/Tests/Awaiten.Api.Tests/Expected/Awaiten_netstandard2.0.txt b/Tests/Awaiten.Api.Tests/Expected/Awaiten_netstandard2.0.txt index cef38831..a8c5117f 100644 --- a/Tests/Awaiten.Api.Tests/Expected/Awaiten_netstandard2.0.txt +++ b/Tests/Awaiten.Api.Tests/Expected/Awaiten_netstandard2.0.txt @@ -258,6 +258,7 @@ namespace Awaiten public object? Key { get; set; } public string? OnActivated { get; set; } public string? OnRelease { get; set; } + public bool SuppressDisposal { get; set; } public System.Type? WhenInjectedInto { get; set; } } [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] @@ -270,6 +271,7 @@ namespace Awaiten public object? Key { get; set; } public string? OnActivated { get; set; } public string? OnRelease { get; set; } + public bool SuppressDisposal { get; set; } public System.Type? WhenInjectedInto { get; set; } } [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] @@ -293,6 +295,7 @@ namespace Awaiten public object? Key { get; set; } public string? OnActivated { get; set; } public string? OnRelease { get; set; } + public bool SuppressDisposal { get; set; } public System.Type? WhenInjectedInto { get; set; } } [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] @@ -307,6 +310,7 @@ namespace Awaiten public object? Key { get; set; } public string? OnActivated { get; set; } public string? OnRelease { get; set; } + public bool SuppressDisposal { get; set; } public System.Type? WhenInjectedInto { get; set; } } [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] @@ -328,6 +332,7 @@ namespace Awaiten public object? Key { get; set; } public string? OnActivated { get; set; } public string? OnRelease { get; set; } + public bool SuppressDisposal { get; set; } public System.Type? WhenInjectedInto { get; set; } } [System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true, Inherited=false)] @@ -340,6 +345,7 @@ namespace Awaiten public object? Key { get; set; } public string? OnActivated { get; set; } public string? OnRelease { get; set; } + public bool SuppressDisposal { get; set; } public System.Type? WhenInjectedInto { get; set; } } } \ No newline at end of file diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt166ConflictingLifecycleDirectives.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt166ConflictingLifecycleDirectives.cs index c71f20f9..de9f7fec 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt166ConflictingLifecycleDirectives.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt166ConflictingLifecycleDirectives.cs @@ -82,6 +82,30 @@ await That(result.Diagnostics).Contains("*AWT166*").AsWildcard() .Because("the first registration wins and is not eager, so the later Eager = true would be silently dropped"); } + [Fact] + public async Task ReportsWhenALaterRegistrationOptsIntoSuppressDisposalTheWinnerDoesNot() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public interface IRead { } + public interface IWrite { } + public sealed class Store : IRead, IWrite { } + + [Container] + [Singleton] + [Singleton(SuppressDisposal = true)] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics).Contains("*AWT166*").AsWildcard() + .Because("the first registration wins and disposes the instance, so the later SuppressDisposal = true would be silently dropped"); + } + [Fact] public async Task ReportsWhenALaterRegistrationSetsAHookTheWinnerOmits() { diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt192SuppressDisposalOnInstance.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt192SuppressDisposalOnInstance.cs new file mode 100644 index 00000000..9b80958c --- /dev/null +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt192SuppressDisposalOnInstance.cs @@ -0,0 +1,77 @@ +using System.Linq; + +namespace Awaiten.SourceGenerators.Tests; + +public partial class DiagnosticTests +{ + public class Awt192SuppressDisposalOnInstance + { + [Fact] + public async Task ReportsWhenSuppressDisposalIsSetOnAPreBuiltInstance() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public sealed class Service { } + + [Container] + [Singleton(Instance = nameof(Shared), SuppressDisposal = true)] + public static partial class MyContainer + { + private static readonly Service Shared = new(); + } + """); + + await That(result.Diagnostics.Any(d => d.Contains("AWT192"))).IsTrue() + .Because("the container never disposes a pre-built instance, so suppressing that disposal is a no-op"); + } + + [Fact] + public async Task DoesNotReportForAPreBuiltInstanceWithoutSuppressDisposal() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public sealed class Service { } + + [Container] + [Singleton(Instance = nameof(Shared))] + public static partial class MyContainer + { + private static readonly Service Shared = new(); + } + """); + + await That(result.Diagnostics.Any(d => d.Contains("AWT192"))).IsFalse() + .Because("a pre-built instance without SuppressDisposal is valid"); + } + + [Fact] + public async Task DoesNotReportForSuppressDisposalOnAConstructedRegistration() + { + GeneratorResult result = Generator.Run(""" + using Awaiten; + + namespace MyCode; + + public sealed class Service : System.IDisposable + { + public void Dispose() { } + } + + [Container] + [Singleton(SuppressDisposal = true)] + public static partial class MyContainer + { + } + """); + + await That(result.Diagnostics.Any(d => d.Contains("AWT192"))).IsFalse() + .Because("SuppressDisposal is meaningful on a container-constructed instance, which the container would otherwise dispose"); + } + } +} diff --git a/Tests/Awaiten.Tests/LifecycleHookTests.cs b/Tests/Awaiten.Tests/LifecycleHookTests.cs index 4317466e..6f239e9c 100644 --- a/Tests/Awaiten.Tests/LifecycleHookTests.cs +++ b/Tests/Awaiten.Tests/LifecycleHookTests.cs @@ -47,6 +47,49 @@ public async Task OnRelease_RunsBeforeTheInstancesOwnDisposal() await That(Probe.Log.IndexOf("released:Tracked") < Probe.Log.IndexOf("disposed:Tracked")).IsTrue(); } + [Fact] + public async Task SuppressDisposal_SkipsTheContainersDisposal_ButOnReleaseStillRuns() + { + Probe.Log.Clear(); + using (SuppressedDisposalContainer.Root container = new()) + { + container.Resolve(); + } + + await That(Probe.Log).Contains("released:Tracked") + .Because("SuppressDisposal opts out of the container's disposal, not out of the release hook"); + await That(Probe.Log).DoesNotContain("disposed:Tracked") + .Because("SuppressDisposal tells the container not to dispose an instance it built"); + } + + [Fact] + public async Task Pooling_RentReturnsToPoolAndReusesTheInstance_WithoutDisposingIt() + { + Probe.Reset(); + PoolingContainer.ClearPool(); + using PoolingContainer.Root container = new(); + + RentedBuffer first; + using (Owned rented = container.Resolve>()) + { + first = rented.Value; + } + + await That(Probe.Log).DoesNotContain("disposed:RentedBuffer") + .Because("a returned-to-pool buffer is released, never disposed by the container"); + + using (Owned rented = container.Resolve>()) + { + await That(ReferenceEquals(rented.Value, first)).IsTrue() + .Because("the second rent draws the same buffer back out of the pool"); + } + + await That(Probe.Constructions).IsEqualTo(1) + .Because("the buffer was rented, returned, and reused rather than reconstructed"); + await That(Probe.Log).DoesNotContain("disposed:RentedBuffer") + .Because("the container never disposes a SuppressDisposal instance, even across reuse"); + } + [Fact] public async Task TransientHooks_RunOncePerConstructedInstance() { @@ -341,6 +384,13 @@ public sealed class Tracked : IDisposable public void Dispose() => Probe.Log.Add("disposed:Tracked"); } + public sealed class RentedBuffer : IDisposable + { + public RentedBuffer() => Probe.Constructions++; + + public void Dispose() => Probe.Log.Add("disposed:RentedBuffer"); + } + public sealed class Flaky : IDisposable { public Flaky() => Probe.Constructions++; @@ -411,6 +461,29 @@ public static partial class DisposableHookContainer private static void Release(Tracked tracked) => Probe.Log.Add("released:Tracked"); } + [Container] + [Singleton(OnRelease = nameof(Release), SuppressDisposal = true)] + public static partial class SuppressedDisposalContainer + { + private static void Release(Tracked tracked) => Probe.Log.Add("released:Tracked"); + } + + [Container] + [Transient(Factory = nameof(Rent), OnRelease = nameof(ReturnToPool), SuppressDisposal = true)] + public static partial class PoolingContainer + { + // A pool the container rents from and returns to: the container constructs the buffer (via Rent) but never + // disposes it (SuppressDisposal), leaving its teardown to ReturnToPool. The pool here is a static member the + // static hook reaches directly; a hook can also take it as a graph dependency (see the hook-parameter tests). + private static readonly Stack _pool = new(); + + public static void ClearPool() => _pool.Clear(); + + private static RentedBuffer Rent() => _pool.Count > 0 ? _pool.Pop() : new RentedBuffer(); + + private static void ReturnToPool(RentedBuffer buffer) => _pool.Push(buffer); + } + [Container] [Transient(OnActivated = nameof(Activated), OnRelease = nameof(Released))] public static partial class TransientHookContainer From 8f868679b9f1d3497cfbc53679a5dbd77411aedc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Thu, 16 Jul 2026 19:10:26 +0200 Subject: [PATCH 2/3] test: cover SuppressDisposal async-disposal and AWT156 exemption Fix a comment referencing AWT189 instead of AWT192, and add tests for the two uncovered SuppressDisposal paths: the AWT156 exemption for an async-only disposable that suppresses disposal, and a runtime check that suppression skips DisposeAsync, not only synchronous Dispose. --- .../AwaitenGenerator.Production.cs | 2 +- ...DiagnosticTests.Awt156AsyncOnlyDisposal.cs | 31 ++++++++++++++++++ Tests/Awaiten.Tests/LifecycleHookTests.cs | 32 +++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs index 798749db..2aadf011 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs @@ -200,7 +200,7 @@ private static InstanceModel BuildPrebuiltInstance( new EquatableArray([Display(info.OwningServiceOrImpl),]))); } - // AWT189: the container never disposes a pre-built Instance (the caller owns it), so opting out of that + // AWT192: the container never disposes a pre-built Instance (the caller owns it), so opting out of that // disposal is a silent no-op, rejected for the same reason as a lifecycle hook above. if (info.SuppressDisposal) { diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt156AsyncOnlyDisposal.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt156AsyncOnlyDisposal.cs index d135b391..ae6920d9 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt156AsyncOnlyDisposal.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt156AsyncOnlyDisposal.cs @@ -104,6 +104,37 @@ await That(diagnostics.Any(d => d.Contains("AWT156"))).IsFalse() .Because("a service that also implements IDisposable is torn down on either disposal path"); } + [Fact] + public async Task DoesNotReportWhenTheAsyncOnlyDisposableSuppressesDisposal() + { + string[] diagnostics = await Analyzer.Run(""" + using Awaiten; + using System; + using System.Threading.Tasks; + + namespace MyCode; + + public sealed class Connection : IAsyncDisposable { public ValueTask DisposeAsync() => default; } + + [Container] + [Singleton(SuppressDisposal = true)] + public static partial class MyContainer + { + } + + public static class Consumer + { + public static void Use() + { + using MyContainer.Root root = new(); + } + } + """); + + await That(diagnostics.Any(d => d.Contains("AWT156"))).IsFalse() + .Because("a SuppressDisposal instance is never tracked for teardown, so a synchronous drain can never reach it and throw"); + } + [Fact] public async Task DoesNotReportForASynchronousUsingOfAScopeWhenTheAsyncOnlyDisposableIsASingleton() { diff --git a/Tests/Awaiten.Tests/LifecycleHookTests.cs b/Tests/Awaiten.Tests/LifecycleHookTests.cs index 6f239e9c..85e88a02 100644 --- a/Tests/Awaiten.Tests/LifecycleHookTests.cs +++ b/Tests/Awaiten.Tests/LifecycleHookTests.cs @@ -90,6 +90,21 @@ await That(Probe.Log).DoesNotContain("disposed:RentedBuffer") .Because("the container never disposes a SuppressDisposal instance, even across reuse"); } +#if NET || NETSTANDARD2_1_OR_GREATER + [Fact] + public async Task SuppressDisposal_SkipsDisposeAsync_OnAnAsyncDisposableService() + { + Probe.Log.Clear(); + await using (SuppressedAsyncDisposalContainer.Root container = new()) + { + container.Resolve(); + } + + await That(Probe.Log).DoesNotContain("disposedAsync:AsyncTracked") + .Because("SuppressDisposal opts out of DisposeAsync too, not only the synchronous Dispose"); + } +#endif + [Fact] public async Task TransientHooks_RunOncePerConstructedInstance() { @@ -391,6 +406,17 @@ public sealed class RentedBuffer : IDisposable public void Dispose() => Probe.Log.Add("disposed:RentedBuffer"); } +#if NET || NETSTANDARD2_1_OR_GREATER + public sealed class AsyncTracked : System.IAsyncDisposable + { + public ValueTask DisposeAsync() + { + Probe.Log.Add("disposedAsync:AsyncTracked"); + return default; + } + } +#endif + public sealed class Flaky : IDisposable { public Flaky() => Probe.Constructions++; @@ -468,6 +494,12 @@ public static partial class SuppressedDisposalContainer private static void Release(Tracked tracked) => Probe.Log.Add("released:Tracked"); } +#if NET || NETSTANDARD2_1_OR_GREATER + [Container] + [Singleton(SuppressDisposal = true)] + public static partial class SuppressedAsyncDisposalContainer; +#endif + [Container] [Transient(Factory = nameof(Rent), OnRelease = nameof(ReturnToPool), SuppressDisposal = true)] public static partial class PoolingContainer From b8956fefdfe8e1777e742632e097f6dfe7b6c268 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valentin=20Breu=C3=9F?= Date: Thu, 16 Jul 2026 19:46:45 +0200 Subject: [PATCH 3/3] feat: withhold release-hooked transients under strict lifetime safety An OnRelease hook queues a closure on the owner per construction, retaining the instance until teardown exactly like disposal tracking, so a SuppressDisposal pooled transient accumulates on the root the same way a tracked disposable would (and starves its pool until the container dies). Both withholding predicates (IsWithheld, BuildsFreshDisposable - the latter also backing AWT118) now test NeedsDisposal || HasReleaseHook. Deliberate consequence: a non-disposable transient with OnRelease is now root-withheld under strict safety too; the lifecycle-hook tests that exercise root-owned release semantics opt into LifetimeSafety.Loose. A SuppressDisposal service without a release hook tracks nothing and stays resolvable everywhere. --- Docs/pages/lifetime/02-disposal.md | 2 + Docs/pages/lifetime/04-lifetime-safety.md | 2 + .../AwaitenGenerator.Analysis.cs | 14 +-- .../Entities/InstanceModel.cs | 4 +- .../Sources/Sources.Guidance.cs | 18 ++-- ...sticTests.Awt118RootAccumulatingFactory.cs | 49 +++++++++++ Tests/Awaiten.Tests/LifecycleHookTests.cs | 13 ++- Tests/Awaiten.Tests/StrictModeTests.cs | 85 +++++++++++++++++++ 8 files changed, 170 insertions(+), 17 deletions(-) diff --git a/Docs/pages/lifetime/02-disposal.md b/Docs/pages/lifetime/02-disposal.md index 6bfdcbaf..ec4bc87e 100644 --- a/Docs/pages/lifetime/02-disposal.md +++ b/Docs/pages/lifetime/02-disposal.md @@ -52,6 +52,8 @@ public static partial class CoffeeShop `SuppressDisposal` applies to a service the container constructs, by constructor or `Factory`. Setting it on a pre-built `Instance`, which the container never owns or disposes anyway, is a build error ([AWT192](../diagnostics#awt192)). +An `OnRelease` hook counts like disposal for [strict lifetime safety](./lifetime-safety): each construction queues the release on the owner, so a release-hooked transient like `Cup` above is withheld from by-type resolution on the container root just as a disposable one is — on the root, every rented cup would be held until the container itself is disposed, and none would return to the pool before then. Rent through [`Owned`](./owned) or from a child scope and the return-to-pool runs when the handle or scope is disposed. A `SuppressDisposal` service *without* a release hook tracks nothing at all, so it stays resolvable everywhere. + ## Async disposal Dispose with `await using` and Awaiten awaits `DisposeAsync` on every async-disposable instance it owns. diff --git a/Docs/pages/lifetime/04-lifetime-safety.md b/Docs/pages/lifetime/04-lifetime-safety.md index bbd63c43..b9c70e3b 100644 --- a/Docs/pages/lifetime/04-lifetime-safety.md +++ b/Docs/pages/lifetime/04-lifetime-safety.md @@ -16,6 +16,8 @@ BrewSession session = shop.Resolve(); // withheld At runtime this `Resolve` throws with guidance, and `TryResolve` returns `false`. The message steers you toward a bounded option. +A transient with an [`OnRelease` hook](./lifecycle-hooks) is withheld the same way, even when [`SuppressDisposal`](./disposal#suppressing-disposal) opts it out of disposal itself: each construction queues the release on the owner, so on the root the instances pile up just like undisposed ones would. + ## What is still allowed Strict mode does not get in your way for the safe shapes: diff --git a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Analysis.cs b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Analysis.cs index aba7f30b..d08bb475 100644 --- a/Source/Awaiten.SourceGenerators/AwaitenGenerator.Analysis.cs +++ b/Source/Awaiten.SourceGenerators/AwaitenGenerator.Analysis.cs @@ -63,11 +63,13 @@ private static Dictionary> KeyedCollectionMemberIndices( } /// - /// Whether building the service at on its owner tracks a fresh disposable - /// there: the service itself is disposable, or its construction transitively rebuilds one. The walk - /// follows only transient edges (a scoped/singleton dependency is cached, so bounded) and collection - /// (Enumerable / keyed) edges, which materialize members eagerly. Used to decide whether a plain - /// Func<…> over the service accumulates on the container root (AWT118 / strict withholding). + /// Whether building the service at on its owner tracks a fresh teardown + /// there: the service itself is disposable or queues an OnRelease closure (which retains the + /// instance on the owner until teardown exactly like disposal tracking, so a SuppressDisposal + /// pooled service accumulates the same way), or its construction transitively rebuilds such a service. + /// The walk follows only transient edges (a scoped/singleton dependency is cached, so bounded) and + /// collection (Enumerable / keyed) edges, which materialize members eagerly. Used to decide whether a + /// plain Func<…> over the service accumulates on the container root (AWT118 / strict withholding). /// internal static bool BuildsFreshDisposable( IReadOnlyList instances, @@ -88,7 +90,7 @@ internal static bool BuildsFreshDisposable( } InstanceModel instance = instances[node]; - if (instance.NeedsDisposal) + if (instance.NeedsDisposal || instance.HasReleaseHook) { return true; } diff --git a/Source/Awaiten.SourceGenerators/Entities/InstanceModel.cs b/Source/Awaiten.SourceGenerators/Entities/InstanceModel.cs index 99e4af55..35014c11 100644 --- a/Source/Awaiten.SourceGenerators/Entities/InstanceModel.cs +++ b/Source/Awaiten.SourceGenerators/Entities/InstanceModel.cs @@ -62,7 +62,9 @@ internal sealed record InstanceModel( /// Whether the container owns this instance for disposal (its declared type implements IDisposable or /// IAsyncDisposable), so it is tracked for teardown. The drain selects the right disposal at runtime. /// opts out: a suppressed instance is not tracked and reads here as - /// non-disposable, so lifetime-safety analysis treats it like any other non-owned service. + /// non-disposable. Lifetime-safety analysis (strict withholding / AWT118) does not read this alone: an + /// hook's queued closure retains the instance on the owner until teardown, so a + /// suppressed instance with a release hook still counts as accumulating there. /// public bool NeedsDisposal => !SuppressDisposal && (IsDisposable || IsAsyncDisposable); diff --git a/Source/Awaiten.SourceGenerators/Sources/Sources.Guidance.cs b/Source/Awaiten.SourceGenerators/Sources/Sources.Guidance.cs index 12e7697b..7f3ffb0b 100644 --- a/Source/Awaiten.SourceGenerators/Sources/Sources.Guidance.cs +++ b/Source/Awaiten.SourceGenerators/Sources/Sources.Guidance.cs @@ -6,15 +6,19 @@ namespace Awaiten.SourceGenerators; internal static partial class Sources { /// - /// 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). + /// A build-on-demand service (a transient or parameterized service) whose construction tracks a teardown on + /// its owner 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). An OnRelease hook counts like + /// disposal: its queued closure retains the instance on the owner until teardown, so a + /// SuppressDisposal pooled service accumulates on the Root the same way a tracked disposable would. /// private static bool IsWithheld(InstanceModel instance, bool strict) - => strict && instance.NeedsDisposal && (instance.Lifetime == Lifetime.Transient || instance.IsParameterized); + => strict + && (instance.NeedsDisposal || instance.HasReleaseHook) + && (instance.Lifetime == Lifetime.Transient || instance.IsParameterized); /// /// Whether a plain Func<…> over this service is withheld from by-type resolution on the Root diff --git a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt118RootAccumulatingFactory.cs b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt118RootAccumulatingFactory.cs index fee24454..de3a091b 100644 --- a/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt118RootAccumulatingFactory.cs +++ b/Tests/Awaiten.SourceGenerators.Tests/DiagnosticTests.Awt118RootAccumulatingFactory.cs @@ -103,6 +103,55 @@ await That(diagnostics.Any(d => d.Contains("AWT118"))).IsFalse() .Because("a Func held by a scoped (not root-owned) consumer is bounded by that scope's lifetime, not the root's"); } + [Fact] + public async Task ReportsWhenTheFuncTargetIsAReleaseHookedPooledTransient() + { + string[] diagnostics = await Analyzer.Run(""" + using Awaiten; + using System; + + namespace MyCode; + + public sealed class Tool : IDisposable { public void Dispose() { } } + public sealed class Depot { public Depot(Func tools) { } } + + [Container] + [Transient(OnRelease = nameof(Return), SuppressDisposal = true)] + [Singleton] + public static partial class MyContainer + { + private static void Return(Tool tool) { } + } + """); + + await That(diagnostics.Any(d => d.Contains("AWT118"))).IsTrue() + .Because("each Func call queues a release closure that retains the instance on the root, so a SuppressDisposal pooled transient accumulates there like a tracked disposable"); + } + + [Fact] + public async Task DoesNotReportWhenTheFuncTargetSuppressesDisposalWithoutAReleaseHook() + { + string[] diagnostics = await Analyzer.Run(""" + using Awaiten; + using System; + + namespace MyCode; + + public sealed class Tool : IDisposable { public void Dispose() { } } + public sealed class Depot { public Depot(Func tools) { } } + + [Container] + [Transient(SuppressDisposal = true)] + [Singleton] + public static partial class MyContainer + { + } + """); + + await That(diagnostics.Any(d => d.Contains("AWT118"))).IsFalse() + .Because("a SuppressDisposal transient without a release hook tracks nothing on the root, so the Func accumulates nothing"); + } + [Fact] public async Task DoesNotReportForANonDisposableTransientFactory() { diff --git a/Tests/Awaiten.Tests/LifecycleHookTests.cs b/Tests/Awaiten.Tests/LifecycleHookTests.cs index 85e88a02..f2cb5114 100644 --- a/Tests/Awaiten.Tests/LifecycleHookTests.cs +++ b/Tests/Awaiten.Tests/LifecycleHookTests.cs @@ -516,7 +516,10 @@ public static partial class PoolingContainer private static void ReturnToPool(RentedBuffer buffer) => _pool.Push(buffer); } - [Container] + // Loose: a release-hooked transient is withheld from root by-type resolution under strict lifetime safety + // (its queued release closure accumulates on the root); these tests exercise root-owned release semantics, + // so they opt out. StrictModeTests covers the withholding itself. + [Container(LifetimeSafety = LifetimeSafety.Loose)] [Transient(OnActivated = nameof(Activated), OnRelease = nameof(Released))] public static partial class TransientHookContainer { @@ -652,7 +655,9 @@ public static partial class ActivationDependencyContainer private static void Calibrate(EspressoMachine machine, Settings settings) => machine.Calibrate(settings); } - [Container] + // Loose so PooledBuffer stays root-resolvable (see TransientHookContainer): these tests need the buffer's and + // the Pool's releases queued on the same owner to observe reverse creation order within one queue. + [Container(LifetimeSafety = LifetimeSafety.Loose)] [Singleton(OnRelease = nameof(ReleasePool))] [Transient(OnRelease = nameof(ReturnToPool))] public static partial class PoolContainer @@ -702,7 +707,9 @@ private static void Attach(AsyncMachine machine, AsyncGauge gauge) } } - [Container] + // Loose so AsyncBuffer stays root-resolvable (see TransientHookContainer): the test observes the capture's + // state when the root tears down. + [Container(LifetimeSafety = LifetimeSafety.Loose)] [Singleton] [Transient(OnRelease = nameof(ReturnAsyncBuffer))] public static partial class AsyncReleaseDependencyContainer diff --git a/Tests/Awaiten.Tests/StrictModeTests.cs b/Tests/Awaiten.Tests/StrictModeTests.cs index 9da2f5f0..7503f97f 100644 --- a/Tests/Awaiten.Tests/StrictModeTests.cs +++ b/Tests/Awaiten.Tests/StrictModeTests.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; + namespace Awaiten.Tests; /// @@ -163,6 +165,58 @@ await That(consumer.Widget).IsNotNull() .Because("constructor injection of a disposable transient is bounded and stays allowed"); } + [Fact] + public async Task Strict_ResolvingAReleaseHookedPooledTransientByType_ThrowsGuidance() + { + using PooledContainer.Root container = new(); + + await That(() => container.Resolve()).Throws() + .Because("a release-hooked transient is withheld like a disposable one: each resolve queues a release closure that retains the instance on the root, so a SuppressDisposal pooled transient accumulates there the same way"); + } + + [Fact] + public async Task Strict_ThePlainFuncOverAReleaseHookedPooledTransient_IsWithheldOnTheRoot() + { + using PooledContainer.Root container = new(); + + await That(() => container.Resolve>()).Throws() + .Because("each call to a root-bound Func over a release-hooked transient queues another release closure on the root, accumulating for the container's lifetime"); + } + + [Fact] + public async Task Strict_AReleaseHookedPooledTransient_ResolvesThroughOwned_AndIsReleasedNotDisposed() + { + PooledContainer.Returned.Clear(); + using PooledContainer.Root container = new(); + + Pouch value; + using (Owned owned = container.Resolve>()) + { + value = owned.Value; + } + + await That(PooledContainer.Returned).Contains(value) + .Because("Owned is the sanctioned way to reach a withheld pooled transient; disposing the handle runs its release hook"); + await That(value.Disposed).IsFalse() + .Because("SuppressDisposal keeps the container from disposing the pooled instance it released"); + } + + [Fact] + public async Task Strict_ASuppressedDisposableTransientWithoutAReleaseHook_ResolvesOnTheRoot() + { + Loner loner; + using (LonerContainer.Root container = new()) + { + loner = container.Resolve(); + + await That(loner).IsNotNull() + .Because("a SuppressDisposal transient without a release hook tracks nothing on the root, so strict safety has no accumulation to withhold it for"); + } + + await That(loner.Disposed).IsFalse() + .Because("the container never disposes a SuppressDisposal instance; its teardown belongs to the caller"); + } + [Fact] public async Task Loose_ResolvingADisposableTransientByType_Works() { @@ -224,12 +278,43 @@ public sealed class Consumer public Widget Widget { get; } } + // A pooled service: the container rents it (constructs it), returns it through the release hook, and never + // disposes it (SuppressDisposal). The hook makes it root-accumulating, so strict safety withholds it there. + public sealed class Pouch : IDisposable + { + public bool Disposed { get; private set; } + + public void Dispose() => Disposed = true; + } + + // A suppressed disposable without a release hook: the container tracks nothing for it, so strict safety + // leaves it resolvable everywhere. + public sealed class Loner : IDisposable + { + public bool Disposed { get; private set; } + + public void Dispose() => Disposed = true; + } + [Container] [Transient] [Transient] [Transient] public static partial class StrictContainer; + [Container] + [Transient(OnRelease = nameof(Return), SuppressDisposal = true)] + public static partial class PooledContainer + { + public static readonly List Returned = new(); + + private static void Return(Pouch pouch) => Returned.Add(pouch); + } + + [Container] + [Transient(SuppressDisposal = true)] + public static partial class LonerContainer; + [Container(LifetimeSafety = LifetimeSafety.Loose)] [Transient] public static partial class LooseContainer;