DI027

Rx Subscription On Longer-Lived Observable Without Dispose

The Rx twin of DI025. IObservable<T>.Subscribe(...) returns an IDisposable token that unsubscribes the observer when disposed, so there is no -= to prove missing — the leak proof inverts to a discarded token. A transient or scoped registered service subscribes an instance-capturing handler (method group, this-capturing lambda, or stored delegate) to an observable exposed by a longer-lived publisher — an injected singleton dependency, or a scoped publisher shared by a transient subscriber — and discards the returned token. The observable is reached through DI025's classified receivers (an injected member proven ctor-assigned, a constructor parameter, or a stable chained projection such as _source.Ticks), and publisher lifetime resolution follows the same rules (most conservative registration wins, closed registrations preferred over open-generic fallbacks, keyed-only registrations excluded). Matching is FQN-light: any method named Subscribe returning System.IDisposable, invoked on a System.IObservable<T> receiver, so System.Reactive, community Rx, and hand-rolled extensions all bind.

Warning Default severity · Code fix: No

Why it matters

A discarded subscription is a live one. The observable holds the observer, the observer captures the subscriber, and nothing releases it, so the longer-lived publisher roots every subscriber instance the container creates — leaking memory on each resolution and firing stale observers against released state. Unlike the DI025/DI026 Info split, DI027 is a single Warning tier: a token that outlives its subscriber is a definite leak whether the publisher is singleton or a scope-shared scoped.

Subscribing hands you a "cancel" ticket. If you drop the ticket in the bin instead of keeping it, you can never cancel — and the newsletter keeps piling up in your mailbox forever.

README problem example

services.AddSingleton<ITicker, Ticker>();   // Ticker : IObservable<int>
services.AddTransient<TickHandler>();

public class TickHandler
{
    public TickHandler(ITicker ticker)
    {
        ticker.Subscribe(OnTick); // the IDisposable is discarded; every TickHandler stays rooted
    }

    private void OnTick(int value) { }
}

README better pattern

public class TickHandler : IDisposable
{
    private readonly IDisposable _subscription;

    public TickHandler(ITicker ticker) => _subscription = ticker.Subscribe(OnTick);

    public void Dispose() => _subscription.Dispose();

    private void OnTick(int value) { }
}

No — planned. The safe repair (introduce IDisposable, store the token, dispose it) depends on the subscriber's registered lifetime exactly like the DI025 tier-3 assist, and is deferred to a follow-up.

Guardrails

When DI027 stays silent

DI027 fires only on the highest-confidence discard shapes — an ignored expression statement, a discard assignment (_ = obs.Subscribe(H)), a local initialized with the token or assigned it in a standalone statement and never otherwise referenced (and not a using declaration), or a simple assignment to a private field declared on the subscriber when that field has no other symbol-bound reference across any partial declaration. An assignment expression consumed by return, using, an argument, or another expression stays silent, as does a later disposal, return, argument pass, reassignment, or other field access; inherited and public/internal/protected fields also stay silent because external handling cannot be ruled out. A direct static readonly reference-type observable field counts as a process-lifetime publisher only when its declaration initializes it exactly once with an object creation through an identity or implicit reference conversion; mutating a member of that publisher or creating a ref readonly local alias does not change its identity. Mutable, value-type, null-initialized, static-constructor-assigned or reassigned fields—including deconstruction, compound, and increment/decrement writes—writable ref/out argument or ref-local escapes, null-producing conversions, and static properties remain conservative. using/using var, CompositeDisposable/DisposeWith/AddTo/SerialDisposable, and more complex field flows remain conservative. DI025's silence-on-unknown legs all apply: singleton subscribers, transient publishers, scoped-on-scoped pairs, static or this-free lambdas, separate observer objects, unregistered subscriber/publisher types, keyed-only publishers, unstable chained projections, non-extension static helpers named Subscribe, and non-observer Subscribe(this) overloads.

Repo sample extraction

Examples pulled from the sample app

Open full sample file

Sample app Rx subscription-leak warning

    public class Bad_SubscribeWithoutDispose
    {
        private readonly ITicker _ticker;

        public Bad_SubscribeWithoutDispose(ITicker ticker)
        {
            _ticker = ticker;
            // [DI027] The IDisposable token is discarded, so every instance the container creates
            // stays rooted in the singleton ticker's observer list.
            _ticker.Subscribe(OnTick);
        }

        private void OnTick(int value) { }
    }

Sample app store-and-dispose pattern

    public class Good_StoreAndDispose : IDisposable
    {
        private readonly IDisposable _subscription;

        public Good_StoreAndDispose(ITicker ticker)
        {
            // Keep the token and dispose it when the subscriber is released.
            _subscription = ticker.Subscribe(OnTick);
        }

        public void Dispose() => _subscription.Dispose();

        private void OnTick(int value) { }
    }

Related guides

  • No problem-guide pages point here yet.

Nearby diagnostics

Other rules in this family

All 37 rules