Skip to content

Commit fb01dc6

Browse files
authored
Merge pull request #1 from malforge/container-disposal
Dispose container-owned singletons
2 parents b7c76c1 + 718607c commit fb01dc6

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)