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
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.