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. Longer-lived publishers are injected dependencies whose registration is provably singleton — closed registrations preferred, open-generic singleton registrations (`AddSingleton(typeof(IEventBus<>), typeof(EventBus<>))`) 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 too 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 — interface segments proven through the root's registered implementation types. C# forbids assigning another type's field-like event, so 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. An unsubscription written with a *different* lambda instance (`-= (s, e) => Handle()` after `+= (s, e) => Handle()`) is recognized as the classic no-op unsubscribe bug: the subscription still reports, and the diagnostic points at the ineffective `-=`.

Default severity: Warning · Code fix: Yes

Why it matters

This is the most common managed memory leak in .NET. The publisher's delegate list holds a strong reference to every handler target, so a singleton publisher roots **every subscriber instance the container ever creates** — each resolution leaks a handler plus the full object graph behind it, and every event raise fans out to thousands of stale handlers executing against released state. Catching it precisely requires knowing registration lifetimes, which is exactly what this analyzer knows and lifetime-blind analyzers cannot.

README problem example

README better pattern

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` — a method merely named Dispose is never called by the container), 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 following the standard virtual `Dispose(bool)` pattern, the fix adds a `protected override void Dispose(bool disposing)` that unsubscribes and calls `base.Dispose(disposing)`; inherited shapes with no such hook (a non-virtual or explicitly-implemented base `Dispose`) are refused so the fix can never add a method the container won't call. (3) **Implement `IDisposable` for scoped subscribers** — a subscriber registered **scoped** that implements neither disposal interface gets `IDisposable` plus a `public void Dispose()` that unsubscribes, since its owning scope disposes it deterministically. Introducing `IDisposable` on a **transient** subscriber stays refused — that is the DI008 disposable-transient-capture shape, so the fix never trades a DI025 for a DI008 — and hoisting a lambda into a field stays refused because it changes capture semantics.

Repo sample extraction

Examples pulled from the sample app

Open full sample file

Sample app event-subscription leak warning

    public class Bad_SubscribeWithoutUnsubscribe
    {
        private readonly IMessageBus _bus;

        public Bad_SubscribeWithoutUnsubscribe(IMessageBus bus)
        {
            _bus = bus;
            // [DI025] 'Bad_SubscribeWithoutUnsubscribe' is registered as transient but never unsubscribes
            _bus.MessageReceived += OnMessage;
        }

        private void OnMessage(object sender, EventArgs e) { }
    }

Sample app unsubscribe-in-dispose pattern

    public class Good_UnsubscribeInDispose : IDisposable
    {
        private readonly IMessageBus _bus;

        public Good_UnsubscribeInDispose(IMessageBus bus)
        {
            _bus = bus;
            _bus.MessageReceived += OnMessage;
        }

        public void Dispose()
        {
            // The matching -= releases this instance from the singleton's delegate list.
            _bus.MessageReceived -= OnMessage;
        }

        private void OnMessage(object sender, EventArgs e) { }
    }

Related guides

  • No problem-guide pages point here yet.