Skip to content

Seven sites, one defect: Rx timers whose subscription reaches no owner's disposal chain #995

Closed

Description

@rbuergi

Summary

Seven sites, one defect: an Rx timer whose subscription is not owned by anything that dies with the thing its closure captured. An Observable.Timer / Observable.Interval entry lives on the process-wide TimerQueue, which is a strong GC root — so while it is pending it holds its tick closure and everything that closure transitively captured. When that tail is a MessageHub, the hub cannot be collected.

Each occurrence has been found by MeshHubDisposalLeakTest firing at random on an unrelated PR, months apart, and fixed in isolation. This issue is about the pattern, not another instance.

The ledger

Two sub-shapes.

(1) The subscription is discarded outright — nothing can cancel the pending timer, so teardown does not release it:

Site Fixed in
ActivityControlPlaneExtensions.SubscribeWithReEstablish #991
ThreadSubmission.InstallSubmissionWatcher #991
ThreadExecution.InstallExecRoundWatcher #991
ThreadExecution.InitializeThreadLifecycle #991

(2) The subscription is held, but strongly pins an owner that may never be disposed — fine for an owner that always tears down, fatal for an abandoned one (a hub created at RunLevel=1 that never reaches teardown):

Site Shape Fix at the time
MessageHub disposal watchdog uncancelled Task.Delay(25s) rooted the whole hub graph after every dispose Observable.Timer + TakeUntil(disposalCompleted)
MessageHub.InstallStaleCallbackScanner Observable.Interval closure captured this WeakReference<MessageHub> + self-dispose on dead target
JsonSynchronizationStream heartbeat Observable.Interval closure captured hub WeakReference<IMessageHub> + self-dispose on dead target

Each of those three carries a comment naming the leak it caused, which is good — but a comment on the fixed site cannot prevent the next one.

Why the current detection is not enough

MeshHubDisposalLeakTest is what caught all of these, and it is a discovery probe, not a regression test (now documented on the test itself, in #991):

Net effect: the defect is found late, at random, on someone else's PR. #991 red-flagged PR #984, whose diff was five files with no C# in it at all.

What to decide

Audit every Observable.Timer / Observable.Interval in src/ and classify each: does its subscription reach an owner's disposal chain, and does its closure strongly capture something that might outlive that owner? (grep -rn "Observable\.Timer\|Observable\.Interval" --include="*.cs" src/ is ~35 hits, most of which are fine — composed inside a stream, or already assigned to a SerialDisposable / field the owner disposes.)

Then decide whether this is worth enforcing rather than remembering. Options, roughly in cost order:

  1. Nothing — accept that the probe finds one every few months, now that its limits are documented.
  2. A convention plus a review habit: a timer subscription is either assigned into an owner-disposed disposable, or composed into a stream that is; a periodic timer that outlives its subject holds it weakly. Write it down next to the existing guidance in AsynchronousCalls.md / ControlledIoPooling.md.
  3. A test that enumerates the sites — cheap and deterministic, but only as good as its list.
  4. An analyzer (Roslyn) for "Observable.Timer(...)/Interval(...) whose .Subscribe(...) result is discarded". Catches sub-shape (1) mechanically. Sub-shape (2) is a capture-lifetime question and is probably not statically decidable here.

My read: (4) covers exactly the shape that caused #991 and is mechanical, while (2) is what actually covers both. Worth a maintainer's call rather than an agent's — the cost is small either way, but so is the incident rate, and the audit in step one should probably come first to size it.

Provenance

Split out of #991 at review, where fixing the fourth instance made the pattern visible.

Activity

  1. rbuergi commented on Aug 9, 2026

    @rbuergi
    ContributorAuthor

    Audit result: the class is real but small — recommendation is (2) write the convention down, not an analyzer

    Audited src/ (+ test/ for production-type impact) at 4423f272d, with #996's four fixes present and verified in place.

    Counts

    count
    Process-rooted timer / gate sites in src/ 46
    Genuine live defects 2
    Ambiguous (sub-shape 2 — held, strong capture, owner disposal not guaranteed on every path) 4
    Verified safe 40
    Already-fixed prior-art sites found 9 (the issue's ledger lists 7 — two more below)

    The number that decides the question: a sub-shape-1 analyzer would flag 1 of the 46 sites and 1 of the 6 findings. The other live defect is held in a field and reads as correct to any static rule. All 4 ambiguous sites are held too. Sub-shape 2 — which is 5 of the 9 sites already fixed, including all three in the issue's own ledger — is not statically decidable, so only a written convention covers the class.

    There is a second reason the analyzer must stay narrow: an un-restricted "discarded Subscribe result" rule fires on 388 sites in src/, of which ~386 are the cold-observable stream.Update(...).Subscribe(...) pattern that AGENTS.md mandates. Scoped down to timers, it finds one 100 ms bug. That is not an analyzer's worth of value.

    Method — and what is measured vs. judged

    Three passes, deliberately not grep-only:

    1. Roslyn syntax scan (throwaway, scratchpad, not added to the build) over every .cs in src/ and test/: each Subscribe/Connect invocation classified by whether its value is consumed, plus the text of its receiver chain. 697 invocations in src/, 388 with the result discarded. This is what makes a discarded IDisposable visible when it is the tail of a fluent chain, which a text search misses.
    2. Manual read of all 57 Observable.Timer|Interval hits, 4 System.Threading.Timer sites, and 30 Task.Delay sites — capture tail and owner-disposal path for each.
    3. A measured GC-root truth table. I did not reason about which primitives root; I built a probe that captures a payload behind each shape, forces a Gen2 collect while the timer is still pending, and reports whether the payload survived.

    Verified by construction (measured):

    shape captured object after forced Gen2 GC
    Task.WhenAny(winner, Task.Delay(long)).ContinueWith(…) — winner completes after hookup free
    Task.WhenAny(completedTask, Task.Delay(long)).ContinueWith(…) — fast path free
    Observable.Timer(long).Subscribe(…) — result discarded ROOTED
    Observable.Timer(long).Subscribe(…) — held + Dispose() free
    Observable.Interval(long).Subscribe(…) — held, not disposed, strong capture ROOTED
    Task.Delay(long).ContinueWith(…) — no token ROOTED
    Task.Delay(long, token).ContinueWith(…) — token cancelled free
    Task.Delay(long, token).ContinueWith(…) — token not cancelled ROOTED
    new Timer(cb, …) one-shot — Dispose() before due free
    new Timer(cb, …) one-shot — not disposed, strong capture ROOTED
    new Timer(static cb, cell) — not disposed, cell severed free
    Observable.Interval + WeakReference capture free

    Judged by reading (not measured): which owners are guaranteed to reach disposal. That is the residual uncertainty behind every "ambiguous" row below, and it is exactly the part no tool can settle.

    The Task.WhenAny row changed the outcome of this audit. DataContext.OpenInitializationGate arms an uncancelled Task.Delay(120 s) on every hub with a DataContext — by inspection the biggest find in the tree, and the same shape as the original MessageHub watchdog. It is not a leak: .NET's WhenAny removes its continuation from the losing task, so the pending delay no longer reaches the closure. Had I stopped at reading, I would have reported a 120 s hub-wide leak that does not exist. Two other sites (McpMeshPlugin:871, MessageHubGrain:598) are cleared by the same measurement.

    The two live defects

    Both are real roots by the measured table, both are sub-second, and neither is on the hub-lifecycle path — I am flagging them, not fixing them.

    Site Sub-shape What it roots Window Why it is live
    src/MeshWeaver.Kernel.Hub/ActivityLogLogger.cs:116 1 — Observable.Timer(…).Subscribe(…) discarded ActivityLogLogger → hub (captured primary-ctor param, used at hub.Post, line 211) 100 ms (ThrottleMs) Nothing holds the subscription, so teardown cannot cancel the pending tail-flush. Structurally identical to the four fixed in #996, 10× shorter window.
    src/MeshWeaver.Blazor/Components/MeshNodePickerView.razor.cs:232 1 in effect — assigned to _debounceSub, but never disposed at teardown the Blazor component → injected IMeshService → mesh services 200 ms (DebounceMs) _debounceSub is disposed only by the next keystroke (line 231). The component never overrides DisposeAsync and _debounceSub is not in BlazorView.Disposables, so the last pending debounce survives component disposal. An analyzer would pass this file clean — the field assignment looks like ownership.

    The four ambiguous sites (sub-shape 2)

    Held, strongly capturing, and safe if the owner is disposed — which it is on every path I can trace. Listed because "owner is always disposed" is the assumption that failed in all three ledger cases.

    Site Shape Owner-disposal path Note
    src/MeshWeaver.Hosting/MeshNodeStreamCache.cs:520 Observable.Interval recurring, field idleSweep, strong this cacheHub.RegisterForDisposal(_ => Dispose()) One instance per silo, but its tail reaches every cached per-path stream handle → many hubs. Highest blast radius if the cache hub is ever abandoned.
    src/MeshWeaver.Graph/EventSubscriptionRunner.cs:362 one-shot Observable.Timer(delay), held in timerSubs, strong this → hub Dispose() disposes every slot delay = FireAt − now is unbounded — a subscription scheduled days out pins the runner for days if the runner is ever abandoned. Longest window in the tree by orders of magnitude.
    src/MeshWeaver.Hosting.Snowflake/SnowflakeChangeFeedPoller.cs:85 Observable.Interval recurring, field, strong this Dispose() → Stop() Opt-in backend; same shape as the two ledger WeakReference fixes.
    src/MeshWeaver.Messaging.Hub/MessageService.cs:876 Task.Delay(30 s, cts.Token).ContinueWith(…), captures this → hub token cancelled on drain (ProcessDeferredMessage) and on Dispose() Both live paths cancel. A MessageService abandoned at RunLevel=1 with a deferred delivery — precisely the abandoned-hub state that produces deferrals — roots itself for 30 s.

    Two already-fixed sites missing from the ledger

    The issue counts seven. There are nine; these two carry the same defect comment and the same ClrMD chain, so the pattern was recognised more often than the ledger records:

    • src/MeshWeaver.Kernel.Hub/KernelContainer.cs:178 — idle-disconnect Timer, fixed with WeakReference + a static callback + RegisterForDisposal. Its comment names the exact chain: StrongHandle → Timer → KernelContainer.<>c__DisplayClass → hub [RunLevel=1].
    • src/MeshWeaver.Graph/MeshNodeTypeSource.cs:534 — debounce Timer, fixed with a severable cell + static callback, with measurements in the comment (9 pinned hubs with this captured, 0 through the cell). My probe reproduces both halves (rows E2/E3).

    That the same three idioms — SerialDisposable, WeakReference + self-dispose, severable cell — were each independently rediscovered is the actual argument for writing them down.

    Feasibility of (3), a test that enumerates sites

    Feasible and cheap to write — my scanner is ~130 lines of Microsoft.CodeAnalysis.CSharp, syntax-only, no MSBuildWorkspace, runs over the source tree in about a second. But:

    • it covers sub-shape 1 only, i.e. 1 of today's 6 findings;
    • it needs a baseline allowlist (the test/ tree has two deliberate discarded-timer constructs that are the subject under test — ActivityOutlivesDisposeTest, IoPoolTest), and a stale allowlist is how an enumerating test rots into noise;
    • it needs the source tree at test time, which couples a unit test to repo layout.

    For one 100 ms site that is a bad trade. If mechanical coverage is wanted later, keep the scanner as a one-off script re-run at audit time, not a committed test.

    Recommendation, with the trade-off stated

    Option 2 — write the convention down, next to the existing guidance in AsynchronousCalls.md, and make the measured truth table part of it. Concretely: a timer subscription is either assigned into something the owner disposes, or composed into a stream that is; a timer that can outlive its subject holds that subject weakly or through a severable cell; a Task.Delay used as a gate is either inside Task.WhenAny or carries a token that is always cancelled. Those four clauses cover all nine fixed sites and both live defects.

    What you give up by not building the analyzer: the mechanical guarantee on sub-shape 1. Today that guarantee is worth one site, and the sampling probe already found four of them. What you give up by building it: nothing much, but it buys 1-in-6 coverage while implying the class is handled — which is the worse failure mode, because the shape that actually recurs is the one it cannot see.

    I'd also suggest the convention page carry the truth table verbatim. It is short, it was cheap to measure, and it settles the questions that get argued from memory — including that Task.WhenAny + Task.Delay is safe, which is counter-intuitive and which I would have got wrong by reading.

    Scope note

    This is an audit only. No analyzer added, no behaviour changed, no PR opened. The two live defects are flagged, not fixed — both are sub-second and off the hub-lifecycle path, so neither is actively harmful; route them separately if you want them closed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions