Skip to content

Three idioms, nine sites, rediscovered by chance — the convention was never written down - #1014

Merged
rbuergi merged 3 commits into
mainfrom
docs/995-subscription-ownership-convention
Aug 9, 2026
Merged

rbuergi merged 3 commits into
mainfrom
docs/995-subscription-ownership-convention

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Implements the audit's recommendation on #995: option (2), write the convention down. No analyzer, no enumerating test, no behaviour change — one new architecture page and four cross-links.

Why not the analyzer

The audit produced the number that settles it. A sub-shape-1 Roslyn rule — "Observable.Timer/Interval whose .Subscribe(...) result is discarded" — would have flagged 1 of the 46 timer/gate sites in src/ and 1 of the 6 findings. The second live defect (MeshNodePickerView) was held in a field and reads as correct to any static rule, as do all four ambiguous sites, as does the majority of the prior art. Widening it is worse: an unrestricted discarded-Subscribe rule fires on 388 of the 697 Subscribe/Connect invocations in src/, ~386 of which are the cold-observable stream.Update(...).Subscribe(...) pattern AGENTS.md mandates.

The page says all of that, with the numbers, because someone will propose the analyzer again.

New page, not a section — and why

Doc/Architecture/SubscriptionOwnership, a sibling of AsynchronousCalls rather than a section inside it. Two reasons: AsynchronousCalls.md is already 1456 lines and its scope is composing async work without await, whereas this is a lifetime/disposal rule; and it needs to be linkable from HubDisposalModel and DebuggingDisposalAndLeaks, which a subsection of a 1456-line page serves badly. AsynchronousCalls gets a short new subsection under "Subscribe is mandatory" — the rule that makes this class easy to get wrong — that states the pending-vs-completed distinction and hands off.

What it covers

  • The rule, applicable without the backstory: a timer/interval/delayed subscription must reach its owner's disposal chain, on every path. "Held in a field" is not ownership.
  • Both sub-shapes with real before/after, drawn from the actual fixes — ActivityLogLogger's discarded Subscribe → SerialDisposable + hub.RegisterForDisposal (Two more pending timers nobody owned — one discarded, one only the next keystroke could cancel #1007), and MeshNodePickerView's field-that-only-the-next-keystroke-disposed → Disposables.Add(...) registered once in OnInitialized (Two more pending timers nobody owned — one discarded, one only the next keystroke could cancel #1007), the sibling of MauiViewPack's Disposables.Add(_searchSub). Plus the two idioms for owners whose disposal is not guaranteed: WeakReference + self-dispose (InstallStaleCallbackScanner, the sync-stream heartbeat, KernelContainer) and the severable cell (MeshNodeTypeSource, with its measured 9-pinned-hubs-vs-0).
  • The measured truth table, verbatim — the most valuable part, because it stops people reasoning from memory. The three counter-intuitive rows are called out: Task.WhenAny removes its continuation from the losing task so it does not root (this cleared DataContext.OpenInitializationGate, which by inspection reads as a 120 s hub-wide leak and is not one); a fired Observable.Timer(...).Subscribe(...) handle, still held, does not retain its callback closure; a bare Task.Delay(...).ContinueWith(...) does root. That last fact is also why discarding stream.Update's handle is safe — which is the same fact that makes the analyzer's false-positive rate what it is, so the page ties the two together rather than leaving them as unrelated trivia.
  • Why a green MeshHubDisposalLeakTest is not evidence — it samples, it cannot attribute, it SKIPs on macOS (MeshHubDisposalLeakTest passes unconditionally on macOS (ClrMD unavailable → analysis never runs) #674) — and what the right pin is instead: a targeted, timing-free ownership test plus a negative control, with Two more pending timers nobody owned — one discarded, one only the next keystroke could cancel #1007's two negative-control results as the worked example. Also: assert both halves (teardown disposes the handle and the working path arms into it), or the registration can guard a slot nothing writes to and the test still passes.
  • How to re-audit: three passes, only the first mechanical. The ~130-line Roslyn scanner stays a one-off script re-run at audit time, out of the tree — the reasoning (allowlist rot, coupling a unit test to repo layout) is recorded so the next person does not re-litigate it.

Written as running prose with real code, not a bullet list of platitudes — a developer who has never seen #995 should be able to apply it, and the four-clause summary at the end is what fits in a review comment.

Cross-links

Added from the four places a reader would actually start: AsynchronousCalls ("Subscribe is mandatory"), DebuggingDisposalAndLeaks (the ClrMD probe section — which now also carries the "a pass here pins nothing" warning inline, matching the remarks already on the test), HubDisposalModel ("adding disposal work"), and the Architecture topic map under Reactive core.

Verification

  • dotnet build src/MeshWeaver.Documentation -c Release -warnaserror — 0 warnings, 0 errors.
  • DocumentationLinkIntegrityTest — passed (every link is absolute /Doc/…, per AuthoringDocumentation).
  • No H1 in the new page: the frontmatter Name is the page title, so the body opens on the lead paragraph.

Notes

Fixes #995

rbuergi and others added 2 commits August 9, 2026 15:03
…overing

Nine sites in src/ independently arrived at the same three fixes for the same
defect — an Rx timer whose subscription reaches no owner's disposal chain —
months apart, each found by chance when MeshHubDisposalLeakTest sampled an
unrelated PR. The #995 audit swept the tree and settled the enforcement
question with numbers: a sub-shape-1 analyzer would flag 1 of 46 sites and 1 of
6 findings, and an unrestricted "discarded Subscribe" rule fires on 388 sites in
src/, ~386 of which are the cold-observable stream.Update(...).Subscribe(...)
pattern AGENTS.md mandates. So: no analyzer, no enumerating test — write the
convention down.

New page Doc/Architecture/SubscriptionOwnership, a sibling of AsynchronousCalls
rather than a section inside it: that page is 1456 lines about composing async
work without await, and this is a lifetime/disposal rule that HubDisposalModel
and DebuggingDisposalAndLeaks also need to point at.

It carries both sub-shapes with the real before/after from #996 and #1007
(SerialDisposable + RegisterForDisposal on the hub; Disposables.Add registered
once in OnInitialized for Blazor), the WeakReference and severable-cell idioms
for owners that may never be disposed, and the measured GC-root truth table —
including the counter-intuitive rows: Task.WhenAny removes its continuation from
the losing task so it does NOT root (this cleared DataContext's 120 s
OpenInitializationGate, which by inspection reads as a hub-wide leak), a FIRED
Rx timer handle does not retain its callback closure, and a bare
Task.Delay(...).ContinueWith(...) does root. That same detach-on-completion fact
is why discarding stream.Update's handle is safe, which is why the analyzer's
false-positive rate is what it is.

Also records why a green MeshHubDisposalLeakTest is not evidence (it samples,
cannot attribute, SKIPs on macOS per #674) and that the right pin is a
timing-free ownership test plus a negative control; and why the audit's ~130-line
Roslyn scanner stays a one-off script rather than a committed test with an
allowlist that would rot.

Cross-linked from AsynchronousCalls' "Subscribe is mandatory" section (the rule
that makes this class easy to get wrong), the DebuggingDisposalAndLeaks ClrMD
probe, HubDisposalModel's "adding disposal work", and the Architecture topic map.

Fixes #995

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 9, 2026 13:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new architecture documentation page that formalizes the repository’s convention for subscription/timer ownership and disposal, and cross-links it from the most relevant existing guidance so the rule is discoverable during implementation and debugging.

Changes:

  • Introduces Subscription Ownership architecture page documenting timer/interval/delay rooting behavior, the two leak shapes, and the recommended ownership idioms.
  • Adds focused cross-links and short guidance callouts in existing architecture pages (AsynchronousCalls, HubDisposalModel, DebuggingDisposalAndLeaks).
  • Updates the Architecture topic map to include the new page under “Reactive core”.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.

Show a summary per file
File Description
src/MeshWeaver.Documentation/Data/Architecture/SubscriptionOwnership.md New architecture page defining the timer/subscription ownership convention and rationale.
src/MeshWeaver.Documentation/Data/Architecture/HubDisposalModel.md Adds an explicit disposal-chain reminder and link for timer/debounce subscriptions.
src/MeshWeaver.Documentation/Data/Architecture/DebuggingDisposalAndLeaks.md Clarifies probe limitations (“pass pins nothing”) and links to the ownership convention.
src/MeshWeaver.Documentation/Data/Architecture/AsynchronousCalls.md Adds a targeted subsection distinguishing completed subscriptions from pending timers and links to the new page.
src/MeshWeaver.Documentation/Data/Architecture.md Adds Subscription Ownership to the “Reactive core” topic map for discoverability.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@github-actions

github-actions Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

685 tests   685 ✅  5m 5s ⏱️
 10 suites    0 💤
 10 files      0 ❌

Results for commit 7da0edc.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 725 tests   1 533 ✅  6m 19s ⏱️
   11 suites    192 💤
   11 files        0 ❌

Results for commit 7da0edc.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

   11 files     11 suites   5m 41s ⏱️
1 929 tests 1 821 ✅ 108 💤 0 ❌
2 062 runs  1 954 ✅ 108 💤 0 ❌

Results for commit 7da0edc.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

1 898 tests   1 896 ✅  6m 15s ⏱️
   11 suites      2 💤
   11 files        0 ❌

Results for commit 7da0edc.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 272 tests   1 267 ✅  6m 39s ⏱️
   11 suites      5 💤
   11 files        0 ❌

Results for commit 7da0edc.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 190 tests   1 190 ✅  7m 35s ⏱️
   11 suites      0 💤
   11 files        0 ❌

Results for commit 7da0edc.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

   65 files     65 suites   37m 36s ⏱️
8 699 tests 8 392 ✅ 307 💤 0 ❌
8 832 runs  8 525 ✅ 307 💤 0 ❌

Results for commit 7da0edc.

♻️ This comment has been updated with latest results.

@rbuergi

rbuergi commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

CI attribution — both shard-4 failures are inherited from main, neither is in this diff

This branch is docs-only (four markdown files) and CI is red. Checked before assuming, per AGENTS.md → "if CI fails on something your diff does not touch, merge main before you investigate":

1. WhatsNewEntryIntegrityTest.EveryEntry_CarriesTheFrontmatterTheFeedRendersFrom — fails on 2026-08-09-check-a-deployment-is-current.md, which carries Category: What's New (must be Feature|Fix) and no Order. That file is on origin/main, added by #1006; the test that rejects it landed on main just before it, in #1003. Two PRs raced, so main itself is red: run 31314807609 on fd277b36f — the exact commit this branch merged — fails on the same single test. #1017 and #1018 are already open to repair it, so this one is not mine to fix and is deliberately left alone.

2. MeshWeaver.AI.Test.MeshNodeAgentFileStoreTest.Search_StaysLive_AndReEmitsWhenMatchingContentAppears — The operation has timed out. Unrelated to a markdown diff; not re-run, per the no-retry rule.

Everything this PR touches is green locally:

  • dotnet build src/MeshWeaver.Documentation -c Release -warnaserror → 0 warnings, 0 errors
  • DocumentationLinkIntegrityTest → passed (the test that guards every link on the new page)
  • Build solution (once) and shards 0, 1, 2, 3, 5 → pass on this branch

This needs a re-merge of main plus a fresh run once the What's New frontmatter fix is in. Not merging until the suite is green on its own merits.

@rbuergi
rbuergi merged commit 3345b50 into main Aug 9, 2026
16 checks passed
@rbuergi
rbuergi deleted the docs/995-subscription-ownership-convention branch August 19, 2026 10:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants