DI025
Event Subscription On Longer-Lived Publisher Without Unsubscribe
A transient- or scoped-registered service that subscribes (+=) an instance-capturing handler — an instance method group, a this-capturing lambda, or a stored instance-bound delegate field — to an event on a longer-lived publisher and never unsubscribes. Subscriber lifetime is evaluated per effective service slot: a shorter-lived slot keeps the leak report even when the same implementation also has a singleton slot, while an overridden same-slot transient descriptor does not. Longer-lived publishers are injected dependencies whose registration is provably singleton — closed registrations preferred, open-generic singleton registrations matched for constructed injections — via a constructor parameter or a field/property assigned only from a constructor parameter, and static events. Identity and reference casts preserve that proof, so ((IBaseBus)_bus).Changed += H reports for direct injected receivers and already-proven stable chains. Chained receivers (_host.Bus.Changed += H) report when the publisher is a stable projection of an injected root: the lifetime proof anchors on the chain root's registration, and every intermediate segment must be a readonly field, a get-only auto-property, or a getter returning one, with interface segments proven through the root's registered implementation types. Because C# forbids assigning another type's field-like event, the cross-type delegate leak lives on a delegate-typed field or property of the publisher instead: _bus.Handlers += OnMessage and the equivalent self-assignment _bus.Handlers = (EventHandler)Delegate.Combine(_bus.Handlers, OnMessage) report identically to an event +=, with a mirrored Delegate.Remove self-assignment recognized as the matching unsubscription. A -= written with a different lambda instance is recognized as the classic no-op unsubscribe bug: the subscription still reports and the diagnostic points at the ineffective -=.
Warning Default severity · Code fix: Yes
README problem example
services.AddSingleton<IMessageBus, MessageBus>();
services.AddTransient<OrderHandler>();
public class OrderHandler
{
private readonly IMessageBus _bus;
public OrderHandler(IMessageBus bus)
{
_bus = bus;
_bus.MessageReceived += OnMessage; // every OrderHandler instance stays rooted
}
private void OnMessage(object sender, EventArgs e) { }
}
README better pattern
public class OrderHandler : IDisposable
{
private readonly IMessageBus _bus;
public OrderHandler(IMessageBus bus)
{
_bus = bus;
_bus.MessageReceived += OnMessage;
}
public void Dispose() => _bus.MessageReceived -= OnMessage;
private void OnMessage(object sender, EventArgs e) { }
}
Yes, in three tiers, all gated on a method-group handler whose receiver (a field/property, a field/property-rooted chain, or a static event) still resolves inside Dispose. (1) Insert into an existing Dispose — when the type already declares a block-bodied Dispose(), Dispose(bool), or DisposeAsync() and implements the matching disposal interface (IDisposable/IAsyncDisposable), the fix inserts the mirrored -= at the top of that method. (2) Create the Dispose path when the contract is inherited — when disposability comes from a base type that follows the standard virtual Dispose(bool) pattern, the fix adds a protected override void Dispose(bool disposing) that unsubscribes and chains to base.Dispose(disposing); overriding the pattern is what guarantees the unsubscribe actually runs (through the base's Dispose() → Dispose(true) dispatch). Inherited shapes with no such hook — a non-virtual or explicitly-implemented base Dispose — are refused, because an added method the container never calls would be a fake repair. (3) Implement IDisposable outright for scoped subscribers — a subscriber registered scoped that implements neither disposal interface gets IDisposable added to its base list plus a public void Dispose() that unsubscribes; its owning scope disposes it deterministically, so no leak is introduced. Introducing IDisposable on a transient subscriber is refused — that is exactly the DI008 disposable-transient-capture shape, so the fix must never trade a DI025 for a DI008 — and hoisting a lambda into a field stays refused because it changes capture semantics.
Guardrails
When DI025 stays silent
Singleton subscribers stay silent (a population of one cannot grow the delegate list — hosted services subscribing to singleton buses are the canonical safe shape), as do transient publishers (scoped publishers report the DI026 Info tier instead), any matching -= anywhere in the type (Dispose, StopAsync, teardown methods, the unsubscribe-then-resubscribe idiom) with the same method group — override chains normalized — or the same stored delegate field/local, static handlers and this-free lambdas, publishers assigned from new or ordinary method parameters, user-defined or value-changing receiver conversions, chained receivers whose projection is not provably stable (settable or computed segments, metadata-only or virtual segments), unregistered subscriber or publisher types, keyed-only publisher registrations, EventSource-derived publishers, and factory registrations with unknown implementation types. Casted and uncast receiver syntax canonicalize to the same publisher identity when matching += with -=. Removals are recorded structurally, so a branch-conditional -= (if (_attached) { _bus.E -= H; }) suppresses unconditionally — a deliberate, documented FN that favours FP-safety over proving the guard always runs.