Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions Docs/pages/09-diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,23 @@ public sealed class Counter(Lazy<Owned<BrewSession>> 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<Boiler>(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
Expand Down
22 changes: 22 additions & 0 deletions Docs/pages/lifetime/02-disposal.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,28 @@ 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<T>`](./owned) the rent-and-return is bounded to a `using` block.

```csharp
[Container]
[Transient<Cup>(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)).

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<T>`](./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.
Expand Down
2 changes: 2 additions & 0 deletions Docs/pages/lifetime/04-lifetime-safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ BrewSession session = shop.Resolve<BrewSession>(); // 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:
Expand Down
2 changes: 1 addition & 1 deletion Docs/pages/lifetime/05-lifecycle-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions Docs/pages/registration/02-factories-and-instances.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 3 additions & 1 deletion Source/Awaiten.SourceGenerators/AwaitenAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -398,6 +399,7 @@ private static void ReportIfAsyncOnlyOwner(
private static ImmutableArray<string> 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)
Expand Down
14 changes: 8 additions & 6 deletions Source/Awaiten.SourceGenerators/AwaitenGenerator.Analysis.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,13 @@ private static Dictionary<string, List<int>> KeyedCollectionMemberIndices(
}

/// <summary>
/// Whether building the service at <paramref name="start" /> 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
/// <c>Func&lt;…&gt;</c> over the service accumulates on the container root (AWT118 / strict withholding).
/// Whether building the service at <paramref name="start" /> on its owner tracks a fresh teardown
/// there: the service itself is disposable or queues an <c>OnRelease</c> closure (which retains the
/// instance on the owner until teardown exactly like disposal tracking, so a <c>SuppressDisposal</c>
/// 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 <c>Func&lt;…&gt;</c> over the service accumulates on the container root (AWT118 / strict withholding).
/// </summary>
internal static bool BuildsFreshDisposable(
IReadOnlyList<InstanceModel> instances,
Expand All @@ -88,7 +90,7 @@ internal static bool BuildsFreshDisposable(
}

InstanceModel instance = instances[node];
if (instance.NeedsDisposal)
if (instance.NeedsDisposal || instance.HasReleaseHook)
{
return true;
}
Expand Down
25 changes: 16 additions & 9 deletions Source/Awaiten.SourceGenerators/AwaitenGenerator.Coalescing.cs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ static ImplInfo EnsureImpl(Dictionary<string, ImplInfo> implInfos, List<ImplInfo
Eager = reg.Eager,
OnActivated = reg.OnActivated,
OnRelease = reg.OnRelease,
SuppressDisposal = reg.SuppressDisposal,
};
implInfos.Add(reg.ImplementationType, info);
implOrder.Add(info);
Expand Down Expand Up @@ -412,8 +413,9 @@ private static HashSet<int> DroppedOverridableDefaults(List<RawRegistration> raw

/// <summary>
/// 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.
/// </summary>
private static void ReportCoalescingConflicts(
Expand Down Expand Up @@ -474,13 +476,13 @@ private static void ReportCoalescingConflicts(
}

/// <summary>
/// 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.
/// </summary>
private static IEnumerable<(string Directive, string Winner, string Loser)> ConflictingDirectives(ImplInfo info, RawRegistration registration)
{
Expand All @@ -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}'";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
Expand Down
13 changes: 12 additions & 1 deletion Source/Awaiten.SourceGenerators/AwaitenGenerator.Production.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -199,6 +200,16 @@ private static InstanceModel BuildPrebuiltInstance(
new EquatableArray<string>([Display(info.OwningServiceOrImpl),])));
}

// 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)
{
diagnostics.Add(new DiagnosticInfo(
Diagnostics.SuppressDisposalOnInstance,
info.Location,
new EquatableArray<string>([Display(info.OwningServiceOrImpl),])));
}

return new InstanceModel(
info.ImplementationType,
info.Symbol.Name,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ internal sealed record RawRegistration(
bool Eager = false,
string? OnActivated = null,
string? OnRelease = null,
bool SuppressDisposal = false,
string? WhenInjectedInto = null);

/// <summary>
Expand Down Expand Up @@ -167,6 +168,14 @@ public ImplInfo(
/// <inheritdoc cref="OnActivated" />
public string? OnRelease { get; init; }

/// <summary>
/// Whether the winning registration opted out of the container's built-in disposal
/// (<c>SuppressDisposal = true</c>): the container constructs the instance but never calls its
/// <c>Dispose</c>/<c>DisposeAsync</c>, leaving teardown to an <see cref="OnRelease" /> hook or an
/// owner outside the container. Set from the first registration like the implementation's other options.
/// </summary>
public bool SuppressDisposal { get; init; }

public List<ServiceKey> Services { get; }

/// <summary>
Expand Down
14 changes: 14 additions & 0 deletions Source/Awaiten.SourceGenerators/Diagnostics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1360,4 +1360,18 @@ internal static class Diagnostics
"Awaiten",
DiagnosticSeverity.Error,
isEnabledByDefault: true);

/// <summary>
/// <c>SuppressDisposal</c> is set on a pre-built <c>Instance</c> 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 <see cref="LifecycleHookOnInstance">AWT165</see>, which rejects a
/// lifecycle hook on an <c>Instance</c> for the same reason.
/// </summary>
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);
}
13 changes: 10 additions & 3 deletions Source/Awaiten.SourceGenerators/Entities/InstanceModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <see cref="ActivationParameters" /> / <see cref="ReleaseParameters" />
/// 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). <see cref="SuppressDisposal" /> opts the instance
/// out of the container's built-in disposal entirely (teardown is left to an <see cref="OnRelease" /> hook or
/// an owner outside the container), so a suppressed instance is never tracked for teardown.
/// </summary>
internal sealed record InstanceModel(
string ImplementationType,
Expand All @@ -45,7 +47,8 @@ internal sealed record InstanceModel(
string? OnActivated = null,
string? OnRelease = null,
EquatableArray<ParameterModel> ActivationParameters = default,
EquatableArray<ParameterModel> ReleaseParameters = default)
EquatableArray<ParameterModel> ReleaseParameters = default,
bool SuppressDisposal = false)
{
/// <summary>
/// The concrete type to construct and to use for cache fields and resolver return types. Normally the same
Expand All @@ -58,8 +61,12 @@ internal sealed record InstanceModel(
/// <summary>
/// Whether the container owns this instance for disposal (its declared type implements <c>IDisposable</c> or
/// <c>IAsyncDisposable</c>), so it is tracked for teardown. The drain selects the right disposal at runtime.
/// <see cref="SuppressDisposal" /> opts out: a suppressed instance is not tracked and reads here as
/// non-disposable. Lifetime-safety analysis (strict withholding / AWT118) does not read this alone: an
/// <see cref="OnRelease" /> 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.
/// </summary>
public bool NeedsDisposal => IsDisposable || IsAsyncDisposable;
public bool NeedsDisposal => !SuppressDisposal && (IsDisposable || IsAsyncDisposable);

/// <summary>
/// Whether this instance is itself an async-taint source (not merely tainted through a dependency): it is
Expand Down
18 changes: 11 additions & 7 deletions Source/Awaiten.SourceGenerators/Sources/Sources.Disposal.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,19 @@ private enum DisposalTracking
/// <summary>
/// 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 <c>SuppressDisposal</c> 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.
/// </summary>
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,
};

/// <summary>
/// Whether any instance registers an <c>OnRelease</c> hook, so the base <c>Scope</c> needs the
Expand Down
Loading
Loading