Rather than filing separate bug reports for each behaviour, I think the more useful contribution is a unit-test suite covering the common Owned scenarios. Tests are easier to read than prose, and they keep paying off as regression coverage long after this issue is closed.
The approach is differential testing: I use Autofac's Owned as the reference oracle — an implementation that has provided these semantics for years and is well proven in production — and assert Pure.DI against the same expected values. Where the two agree, we end up with an executable specification of what Owned should do; where they diverge, we get a precise, reproducible failing test. That should make it considerably easier to land a correct Owned implementation in Pure.DI, since every scenario already has a known-good target to code against.
Autofac is wired in only behind an AUTOFAC_REFERENCE compile symbol, so the suite carries no Autofac dependency by default. Happy to open a PR with it whenever that's useful.
Summary
The generated composition allocates a single Owned accumulator per composition-root resolution and reuses it for every Owned<T> created in that block, instead of giving each Owned<T> its own. Two consequences:
- The documented
Func<Owned<T>> "unit of work" pattern leaks. When the factory is invoked more than once (e.g. a message pump), only the first Owned<T> is disposed; every later one is silently not disposed.
- Nested
Owned<T> are not independent. Disposing either the outer or the inner Owned<T> disposes both graphs.
Reproduced building from current master (da274f04b), .NET 10. The Owned<T> accumulator code is long-standing, so released 2.5.x is very likely affected as well.
Repro 1 — nested Owned<T> are not independent
using System;
using Pure.DI;
var composition = new Composition();
Owned<IOuter> outer = composition.Root;
IInner inner = outer.Value.Inner;
outer.Dispose(); // dispose the OUTER Owned<T> only
Console.WriteLine($"inner disposed: {inner.IsDisposed}");
interface IInner { bool IsDisposed { get; } }
class Inner : IInner, IDisposable
{
public bool IsDisposed { get; private set; }
public void Dispose() => IsDisposed = true;
}
interface IOuter { IInner Inner { get; } }
class Outer(Func<Owned<IInner>> innerFactory) : IOuter
{
private readonly Owned<IInner> owned = innerFactory();
public IInner Inner => owned.Value;
}
partial class Composition
{
static void Setup() => DI.Setup(nameof(Composition))
.Bind().To<Inner>()
.Bind().To<Outer>()
.Root<Owned<IOuter>>("Root");
}
- Expected:
inner disposed: False — the inner Owned<T> owns an independent lifetime; disposing the outer must not touch it.
- Actual:
inner disposed: True.
(The reverse also holds: disposing the inner Owned<T> disposes disposables that belong only to the outer graph.)
Repro 2 — Func<Owned<T>> loop disposes only the first unit, leaks the rest
using System;
using System.Collections.Generic;
using Pure.DI;
var composition = new Composition();
IPump pump = composition.Pump;
pump.Process(3);
foreach (IConnection c in pump.Connections)
Console.WriteLine($"disposed: {c.IsDisposed}");
interface IConnection { bool IsDisposed { get; } }
class Connection : IConnection, IDisposable
{
public bool IsDisposed { get; private set; }
public void Dispose() => IsDisposed = true;
}
interface IPump { IReadOnlyList<IConnection> Connections { get; } void Process(int count); }
class Pump(Func<Owned<IConnection>> factory) : IPump
{
private readonly List<IConnection> connections = new();
public IReadOnlyList<IConnection> Connections => connections;
public void Process(int count)
{
for (int i = 0; i < count; i++)
{
using Owned<IConnection> owned = factory(); // begin+end a unit of work
connections.Add(owned.Value);
}
}
}
partial class Composition
{
static void Setup() => DI.Setup(nameof(Composition))
.Bind().To<Connection>()
.Bind().To<Pump>()
.Root<IPump>("Pump");
}
- Expected:
True / True / True — each using disposes its own unit of work.
- Actual:
True / False / False — only the first is disposed. Connections 2..N are never disposed and stay referenced by the shared accumulator for the pump's lifetime (so it is both a resource leak and a memory leak).
Root cause
The generated root getter creates one perBlockOwned and the Func<Owned<T>> closure captures it; every invocation adds to and wraps that same accumulator (trimmed):
var perBlockOwned = new Pure.DI.Owned(); // created ONCE
Func<Owned<IConnection>> factory = () =>
{
// ...
perBlockOwned.Add(transientConnection); // every call -> same list
perBlockOwnedIConnection = new Owned<IConnection>(value, /* IOwned = */ perBlockOwned); // same list
perBlockOwned.Add(perBlockOwnedIConnection);
return perBlockOwnedIConnection;
};
return new Pump(factory);
Combined with the Owned.Dispose() guard, which returns before the Clear():
public void Dispose()
{
if (_isDisposed) return; // after the first dispose this returns immediately
_isDisposed = true;
try { /* dispose items in reverse; nested IOwned are skipped */ }
finally { Clear(); }
}
the first using disposes the accumulator and sets _isDisposed; every subsequent Owned<T> wraps that same, already-disposed accumulator, so its Dispose() is a no-op. The nested case is the same mechanism — the inner Owned<T> wraps the outer's accumulator.
Expected behaviour
Each Owned<T> should own an independent set of disposables (its own accumulator), so that disposing one Owned<T> never affects another, and a Func<Owned<T>> invoked N times yields N independently-disposable units of work. This is what Autofac's Owned<T> does (each is its own lifetime scope).
Evidence
I put together an Owned<T> behaviour-comparison suite (28 tests) that asserts Pure.DI against Autofac's Owned<T> as a reference implementation — 23 confirm parity and 5 document the divergences above. It's on a branch and drops into tests/Pure.DI.UsageTests (Autofac is behind an AUTOFAC_REFERENCE compile symbol, so the committed suite has no Autofac dependency):
https://github.com/ekalchev/Pure.DI/tree/owned-idisposable-autofac-parity/tests/Pure.DI.UsageTests/Owned
Happy to open a PR with these tests if that's useful.
Rather than filing separate bug reports for each behaviour, I think the more useful contribution is a unit-test suite covering the common Owned scenarios. Tests are easier to read than prose, and they keep paying off as regression coverage long after this issue is closed.
The approach is differential testing: I use Autofac's Owned as the reference oracle — an implementation that has provided these semantics for years and is well proven in production — and assert Pure.DI against the same expected values. Where the two agree, we end up with an executable specification of what Owned should do; where they diverge, we get a precise, reproducible failing test. That should make it considerably easier to land a correct Owned implementation in Pure.DI, since every scenario already has a known-good target to code against.
Autofac is wired in only behind an AUTOFAC_REFERENCE compile symbol, so the suite carries no Autofac dependency by default. Happy to open a PR with it whenever that's useful.
Summary
The generated composition allocates a single
Ownedaccumulator per composition-root resolution and reuses it for everyOwned<T>created in that block, instead of giving eachOwned<T>its own. Two consequences:Func<Owned<T>>"unit of work" pattern leaks. When the factory is invoked more than once (e.g. a message pump), only the firstOwned<T>is disposed; every later one is silently not disposed.Owned<T>are not independent. Disposing either the outer or the innerOwned<T>disposes both graphs.Reproduced building from current
master(da274f04b), .NET 10. TheOwned<T>accumulator code is long-standing, so released 2.5.x is very likely affected as well.Repro 1 — nested
Owned<T>are not independentinner disposed: False— the innerOwned<T>owns an independent lifetime; disposing the outer must not touch it.inner disposed: True.(The reverse also holds: disposing the inner
Owned<T>disposes disposables that belong only to the outer graph.)Repro 2 —
Func<Owned<T>>loop disposes only the first unit, leaks the restTrue / True / True— eachusingdisposes its own unit of work.True / False / False— only the first is disposed. Connections 2..N are never disposed and stay referenced by the shared accumulator for the pump's lifetime (so it is both a resource leak and a memory leak).Root cause
The generated root getter creates one
perBlockOwnedand theFunc<Owned<T>>closure captures it; every invocation adds to and wraps that same accumulator (trimmed):Combined with the
Owned.Dispose()guard, which returns before theClear():the first
usingdisposes the accumulator and sets_isDisposed; every subsequentOwned<T>wraps that same, already-disposed accumulator, so itsDispose()is a no-op. The nested case is the same mechanism — the innerOwned<T>wraps the outer's accumulator.Expected behaviour
Each
Owned<T>should own an independent set of disposables (its own accumulator), so that disposing oneOwned<T>never affects another, and aFunc<Owned<T>>invoked N times yields N independently-disposable units of work. This is what Autofac'sOwned<T>does (each is its own lifetime scope).Evidence
I put together an
Owned<T>behaviour-comparison suite (28 tests) that asserts Pure.DI against Autofac'sOwned<T>as a reference implementation — 23 confirm parity and 5 document the divergences above. It's on a branch and drops intotests/Pure.DI.UsageTests(Autofac is behind anAUTOFAC_REFERENCEcompile symbol, so the committed suite has no Autofac dependency):https://github.com/ekalchev/Pure.DI/tree/owned-idisposable-autofac-parity/tests/Pure.DI.UsageTests/Owned
Happy to open a PR with these tests if that's useful.