Skip to content

Commit 718607c

Browse files
malware-devclaude
andcommitted
Dispose container-owned singletons
The container constructed singletons and never disposed them, so anything holding an unmanaged resource leaked for the process lifetime. It now owns what it constructs: disposing the container disposes those instances in reverse construction order, so a dependent is always disposed before the dependency it was handed. Instances supplied through AddInstance belong to the caller and are left alone. Async disposal needs IAsyncDisposable, which netstandard2.0 cannot name. Rather than take a dependency or add a second target framework, the generator emits the disposer into the consuming assembly — which is compiled against a framework that has the interface — and the container stores it as a plain delegate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b7c76c1 commit 718607c

9 files changed

Lines changed: 526 additions & 6 deletions

File tree

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,29 @@ var container = new DependencyContainerBuilder()
158158
var userRepo = container.Resolve<UserRepository>();
159159
```
160160

161+
### Disposal
162+
163+
The container owns the lifetime of every singleton it **constructs**. Disposing the container disposes those singletons in **reverse construction order**, so a dependent is always disposed before the dependency it was handed:
164+
165+
```csharp
166+
using var container = new DependencyContainerBuilder()
167+
.AddRegistry<GeneratedRegistry>()
168+
.Build();
169+
170+
// ... resolve and use services ...
171+
// leaving the scope disposes every singleton the container constructed
172+
```
173+
174+
Instances you supply yourself — through `RegisterInstance` or `AddInstance` — are **not** the container's to dispose, and are left alone. Resolving from a disposed container throws `ObjectDisposedException`.
175+
176+
A singleton that implements `IAsyncDisposable` is disposed with `DisposeAsync`:
177+
178+
```csharp
179+
await container.DisposeAsync();
180+
```
181+
182+
Calling the synchronous `Dispose()` throws if a singleton disposes *only* asynchronously; one that implements both interfaces is disposed synchronously. If a singleton throws while disposing, the rest are still disposed and the failures are reported together as an `AggregateException`.
183+
161184
## Advanced Usage
162185

163186
### Assembly-Level Registrations

Source/Mal.SourceGeneratedDI.Abstractions/DependencyInjectionAbstractions.cs

Lines changed: 164 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System;
22
using System.Collections.Generic;
33
using System.Linq;
4+
using System.Threading.Tasks;
45

56
namespace Mal.SourceGeneratedDI;
67

@@ -152,6 +153,14 @@ public interface IDependencyContainer : IServiceProvider
152153
public interface IServiceRegistry
153154
{
154155
void AddSingleton(Type serviceType, Func<IDependencyContainer, object> factory);
156+
157+
/// <summary>
158+
/// Registers a container-owned singleton that disposes asynchronously. <paramref name="asyncDisposer"/>
159+
/// disposes an instance of the service; it is supplied by the caller because this assembly targets
160+
/// netstandard2.0 and cannot name <c>IAsyncDisposable</c> itself.
161+
/// </summary>
162+
void AddSingleton(Type serviceType, Func<IDependencyContainer, object> factory, Func<object, Task> asyncDisposer);
163+
155164
void AddInstance(Type serviceType, Func<IDependencyContainer, object> factory);
156165
}
157166

@@ -165,14 +174,22 @@ public interface IRegistrationSource
165174

166175
internal sealed class Registration
167176
{
168-
public Registration(Func<IDependencyContainer, object> factory, bool isInstance)
177+
public Registration(Func<IDependencyContainer, object> factory, bool isInstance,
178+
Func<object, Task>? asyncDisposer = null)
169179
{
170180
Factory = factory ?? throw new ArgumentNullException(nameof(factory));
171181
IsInstance = isInstance;
182+
AsyncDisposer = asyncDisposer;
172183
}
173184

174185
public Func<IDependencyContainer, object> Factory { get; }
175186
public bool IsInstance { get; }
187+
188+
/// <summary>
189+
/// Disposes an instance of this service asynchronously, or <c>null</c> if it disposes synchronously
190+
/// (or not at all). Supplied by the caller because this assembly cannot name <c>IAsyncDisposable</c>.
191+
/// </summary>
192+
public Func<object, Task>? AsyncDisposer { get; }
176193
}
177194

178195
/// <summary>
@@ -294,6 +311,11 @@ public DependencyContainerBuilder RegisterInstance<TService>(Func<IDependencyCon
294311
public void AddSingleton(Type serviceType, Func<IDependencyContainer, object> factory)
295312
=> Add(serviceType, new Registration(factory, isInstance: false));
296313

314+
/// <inheritdoc />
315+
public void AddSingleton(Type serviceType, Func<IDependencyContainer, object> factory, Func<object, Task> asyncDisposer)
316+
=> Add(serviceType, new Registration(factory, isInstance: false,
317+
asyncDisposer ?? throw new ArgumentNullException(nameof(asyncDisposer))));
318+
297319
/// <inheritdoc />
298320
public void AddInstance(Type serviceType, Func<IDependencyContainer, object> factory)
299321
=> Add(serviceType, new Registration(factory, isInstance: true));
@@ -336,14 +358,31 @@ private void Add(Type serviceType, Registration registration)
336358
/// <summary>
337359
/// Runtime container built from one or more registration sources.
338360
/// </summary>
339-
public sealed class DependencyContainer : IDependencyContainer, IServiceProvider
361+
/// <remarks>
362+
/// The container owns the lifetime of every singleton it constructs: disposing it disposes those
363+
/// instances, in reverse construction order, so a dependent is always disposed before the dependency
364+
/// it was given. Instances supplied by the caller through <see cref="IServiceRegistry.AddInstance"/>
365+
/// are not owned and are never disposed. Resolving from a disposed container throws.
366+
/// <para>
367+
/// This assembly targets netstandard2.0, where <c>IAsyncDisposable</c> does not exist. A singleton that
368+
/// disposes asynchronously is therefore registered with an <c>asyncDisposer</c> delegate, produced by the
369+
/// caller — normally the generated registry, which is compiled against a framework that does have the
370+
/// interface. Await <see cref="DisposeAsync"/> to run those; <see cref="Dispose"/> throws if any are present.
371+
/// </para>
372+
/// </remarks>
373+
public sealed class DependencyContainer : IDependencyContainer, IServiceProvider, IDisposable
340374
{
341375
private readonly Dictionary<Type, Registration> _registrations;
342376
private readonly Dictionary<Type, object> _singletons = new();
343377
private readonly IServiceProvider? _fallbackProvider;
344378
private readonly Stack<Type> _resolvingStack = new();
345379
private readonly HashSet<Type> _resolvingSet = new();
346380

381+
// Construction order. A dependency is always constructed before the dependent that asked for it,
382+
// so disposing this list in reverse disposes dependents first.
383+
private readonly List<OwnedInstance> _ownedInstances = new();
384+
private bool _disposed;
385+
347386
internal DependencyContainer(Dictionary<Type, Registration> registrations, IServiceProvider? fallbackProvider)
348387
{
349388
_registrations = registrations ?? throw new ArgumentNullException(nameof(registrations));
@@ -389,6 +428,9 @@ public bool TryResolve(Type serviceType, out object? instance)
389428
if (serviceType == null)
390429
throw new ArgumentNullException(nameof(serviceType));
391430

431+
if (_disposed)
432+
throw new ObjectDisposedException(nameof(DependencyContainer));
433+
392434
if (_singletons.TryGetValue(serviceType, out instance))
393435
return true;
394436

@@ -423,8 +465,128 @@ public bool TryResolve(Type serviceType, out object? instance)
423465
}
424466

425467
if (!registration.IsInstance)
468+
{
426469
_singletons[serviceType] = instance;
470+
TrackOwned(instance!, registration.AsyncDisposer);
471+
}
427472

428473
return true;
429474
}
475+
476+
/// <summary>
477+
/// Disposes every singleton this container constructed, in reverse construction order. Instances
478+
/// registered through <see cref="IServiceRegistry.AddInstance"/> belong to the caller and are left alone.
479+
/// Throws if any owned singleton disposes asynchronously — await <see cref="DisposeAsync"/> for those.
480+
/// </summary>
481+
public void Dispose()
482+
{
483+
if (_disposed)
484+
return;
485+
486+
_disposed = true;
487+
488+
List<Exception>? failures = null;
489+
490+
for (var i = _ownedInstances.Count - 1; i >= 0; i--)
491+
{
492+
var owned = _ownedInstances[i];
493+
try
494+
{
495+
// A singleton that disposes both ways is disposed synchronously here; only one that has no
496+
// synchronous path at all forces the caller to DisposeAsync.
497+
if (owned.Instance is IDisposable disposable)
498+
{
499+
disposable.Dispose();
500+
}
501+
else
502+
{
503+
throw new InvalidOperationException(
504+
$"Singleton of type {owned.Instance.GetType()} disposes only asynchronously and cannot be "
505+
+ "disposed synchronously. Await DisposeAsync on the container instead.");
506+
}
507+
}
508+
catch (Exception ex)
509+
{
510+
(failures ??= new List<Exception>()).Add(ex);
511+
}
512+
}
513+
514+
Clear();
515+
516+
if (failures != null)
517+
throw new AggregateException("One or more singletons failed to dispose.", failures);
518+
}
519+
520+
/// <summary>
521+
/// Asynchronously disposes every singleton this container constructed, in reverse construction order,
522+
/// using each singleton's async disposer where it has one. Instances registered through
523+
/// <see cref="IServiceRegistry.AddInstance"/> belong to the caller and are left alone.
524+
/// </summary>
525+
public async Task DisposeAsync()
526+
{
527+
if (_disposed)
528+
return;
529+
530+
_disposed = true;
531+
532+
List<Exception>? failures = null;
533+
534+
for (var i = _ownedInstances.Count - 1; i >= 0; i--)
535+
{
536+
var owned = _ownedInstances[i];
537+
try
538+
{
539+
if (owned.AsyncDisposer != null)
540+
await owned.AsyncDisposer(owned.Instance).ConfigureAwait(false);
541+
else
542+
((IDisposable)owned.Instance).Dispose();
543+
}
544+
catch (Exception ex)
545+
{
546+
(failures ??= new List<Exception>()).Add(ex);
547+
}
548+
}
549+
550+
Clear();
551+
552+
if (failures != null)
553+
throw new AggregateException("One or more singletons failed to dispose.", failures);
554+
}
555+
556+
/// <summary>
557+
/// Records an instance the container constructed, so it can be disposed with the container. The same
558+
/// object may be registered under several service types; it is constructed once and disposed once.
559+
/// </summary>
560+
private void TrackOwned(object instance, Func<object, Task>? asyncDisposer)
561+
{
562+
if (asyncDisposer == null && !(instance is IDisposable))
563+
return;
564+
565+
foreach (var owned in _ownedInstances)
566+
{
567+
if (ReferenceEquals(owned.Instance, instance))
568+
return;
569+
}
570+
571+
_ownedInstances.Add(new OwnedInstance(instance, asyncDisposer));
572+
}
573+
574+
private void Clear()
575+
{
576+
_ownedInstances.Clear();
577+
_singletons.Clear();
578+
_registrations.Clear();
579+
}
580+
581+
private readonly struct OwnedInstance
582+
{
583+
public OwnedInstance(object instance, Func<object, Task>? asyncDisposer)
584+
{
585+
Instance = instance;
586+
AsyncDisposer = asyncDisposer;
587+
}
588+
589+
public object Instance { get; }
590+
public Func<object, Task>? AsyncDisposer { get; }
591+
}
430592
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
2.0.3
1+
2.1.0

Source/Mal.SourceGeneratedDI.Abstractions/ReleaseNotes.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
v2.1.0
2+
- `DependencyContainer` now implements `IDisposable`: it disposes every singleton it constructed, in reverse construction order, so a dependent is disposed before the dependency it was given
3+
- Instances registered through `AddInstance`/`RegisterInstance` belong to the caller and are never disposed
4+
- Added `DependencyContainer.DisposeAsync()` for singletons that dispose asynchronously. `Dispose()` throws for a singleton that has no synchronous path; one that disposes both ways is disposed synchronously
5+
- A singleton failing to dispose no longer strands the rest — all are attempted and the failures reported together as an `AggregateException`
6+
- Resolving from a disposed container throws `ObjectDisposedException`
7+
- Breaking: `IServiceRegistry` gains an `AddSingleton` overload taking an async disposer. This package targets netstandard2.0 and cannot name `IAsyncDisposable`, so the delegate is supplied by the caller — normally the generated registry, which is compiled against a framework that has the interface
8+
19
v2.0.3
210
- IDependencyContainer now extends IServiceProvider for compatibility with generalized service consumers
311

0 commit comments

Comments
 (0)