DI028

Discarded Callback Registration On A Longer-Lived Source

The third member of the DI025/DI027 family. Where DI025 proves a missing -= and DI027 proves a discarded Subscribe token, DI028 covers every remaining way .NET hands out a callback registration: IOptionsMonitor<T>.OnChange, CancellationToken.Register / UnsafeRegister, ChangeToken.OnChange, IChangeToken.RegisterChangeCallback, and CancellationTokenSource.CreateLinkedTokenSource. A transient or scoped registered service registers a callback on a longer-lived source — an injected singleton options monitor, IHostApplicationLifetime.ApplicationStopping, a token from a singleton-held CancellationTokenSource, a configuration reload token — and discards the registration that would detach it.

Warning Default severity · Code fix: No

Why it matters

A discarded registration is a live one. The callback must provably capture the subscriber, either through the handler (method group, this-capturing lambda, stored delegate) or through the object? state argument, so the source roots every subscriber instance the container creates along with everything it holds — for a typical service, a DbContext and its change tracker. ApplicationStopping lives for the whole process, so the leak grows once per resolution and is never reclaimed.

README problem example

services.AddScoped<OrderProcessor>();

public class OrderProcessor
{
    public OrderProcessor(IHostApplicationLifetime lifetime, AppDbContext db)
    {
        // DI028: the registration is discarded; every OrderProcessor stays rooted
        lifetime.ApplicationStopping.Register(() => Flush(db));
    }

    private void Flush(AppDbContext db) { }
}

README better pattern

public class OrderProcessor : IDisposable
{
    private readonly CancellationTokenRegistration _registration;

    public OrderProcessor(IHostApplicationLifetime lifetime, AppDbContext db) =>
        _registration = lifetime.ApplicationStopping.Register(() => Flush(db));

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

    private void Flush(AppDbContext db) { }
}

No — planned. Introducing IDisposable on a transient subscriber recreates the DI008 disposable-transient shape, the linked-source arm needs a different repair from the registration arm, and CancellationTokenRegistration is a struct with defensive-copy pitfalls.

Guardrails

When DI028 stays silent

The subscriber must be registered and shorter-lived than the source, so a singleton or hosted-service subscriber registering on ApplicationStopping — the idiomatic, correct pattern — never fires. Method-parameter tokens stay silent (an ASP.NET RequestAborted registration is request-scoped and correct), as do locally created CancellationTokenSource tokens, IOptionsSnapshot sources above scoped subscribers, and any scoped-on-scoped or equal-lifetime pair. A static source qualifies only when the receiver is the exact framework Token property on an exact private static readonly CancellationTokenSource field initialized inline by the exact parameterless framework constructor and every compilation-visible use is either a direct Register/UnsafeRegister receiver read or a provably infinite CancelAfter; timed, already-canceled, factory-created, reassigned, canceled, disposed, mutable, public, aliased, and stored-token sources remain silent, while Timeout.Infinite, -1, and Timeout.InfiniteTimeSpan preserve the process-lifetime proof. Discard proof mirrors DI027 for callback registrations: an ignored expression statement, a _ = discard, a never-referenced non-using local, or an otherwise-unused private field reports. Linked token sources also report when a declaration initializer or assignment to a predeclared local—including an assignment expression used as the Token receiver—is consumed through ordinary or conditional-access Token reads and is not reliably disposed. A conditional read remains visible when another operation such as Register is chained after Token; disposing that later operation does not dispose the linked source. Direct .Token or ?.Token extraction reports too because it loses the only handle through which the linked source can be disposed. The ownership proof is shared with DI014: using, reachable straight-line or finally disposal, and unconditional return to the caller stay silent; conditional or bypassed cleanup and reassignment before disposal report. Parentheses, null-forgiving operators, and identity casts cannot hide a Token read or wrapped initializer, and real cleanup through the same wrappers remains recognized; extension methods merely named Dispose or DisposeAsync do not establish cleanup. References before the current assignment belong to the older local value and are ignored; capture by a nested local function or lambda stays conservative because execution order is not proven. Unknown transfer calls—including fluent calls before a later .Token read—stay conservative and silent. Chained sources are followed only through provably stable projections; the metadata-only framework projections CancellationTokenSource.Token and IHostApplicationLifetime.ApplicationStopping/ApplicationStarted/ApplicationStopped are accepted only as a contiguous suffix, so nothing can be laundered through them. Known false negatives: IChangeToken reached through a field or local and non-trivial ChangeToken.OnChange producer lambdas are silent.

Repo sample extraction

Examples pulled from the sample app

Open full sample file

Sample app discarded callback-registration warning

    public class Bad_RegisterWithoutDispose
    {
        private readonly IHostApplicationLifetime _lifetime;

        public Bad_RegisterWithoutDispose(IHostApplicationLifetime lifetime)
        {
            _lifetime = lifetime;
            // [DI028] The registration is discarded, so every instance the container creates stays
            // rooted in the singleton lifetime's shutdown token for the life of the process.
            _lifetime.ApplicationStopping.Register(OnStopping);
        }

        private void OnStopping() { }
    }

Sample app store-and-dispose pattern

    public class Good_StoreAndDispose : IDisposable
    {
        private readonly CancellationTokenRegistration _registration;

        public Good_StoreAndDispose(IHostApplicationLifetime lifetime)
        {
            // Keep the registration and dispose it when the subscriber is released.
            _registration = lifetime.ApplicationStopping.Register(OnStopping);
        }

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

        private void OnStopping() { }
    }

Related guides

  • No problem-guide pages point here yet.

Nearby diagnostics

Other rules in this family

All 37 rules