Skip to content

Tier the ignored-.Subscribe() leak by source lifetime (self-cycle → silent)#40

Merged
PhysShell merged 3 commits into
mainfrom
claude/subscribe-source-tiering
Jun 19, 2026
Merged

Tier the ignored-.Subscribe() leak by source lifetime (self-cycle → silent)#40
PhysShell merged 3 commits into
mainfrom
claude/subscribe-source-tiering

Conversation

@PhysShell

@PhysShell PhysShell commented Jun 19, 2026

Copy link
Copy Markdown
Owner

Source-lifetime tiering for the ignored-.Subscribe() leak detector (WPF004) — the precision half of the WalletWasabi find. Two commits, both halves of the pipeline.

The problem

The detector flagged any ignored .Subscribe() as a flat OWN001. But a self-rooted this.WhenAnyValue(x => x.SelfProp).…Subscribe(...) is a GC-collectible self-cycle (the observable, its handler and this form one cycle the GC collects together) — not a leak. Only an external source (EventBus, an injected model/field) holds the component from a longer-lived root.

Extractor (bd2106b)

IsSelfRootedWhenAny — purely syntactic, no SemanticModel: walk the fluent chain to its leftmost invocation; if it is this.WhenAnyValue(p => p.Member) with a single-hop self-member lambda, stamp source: "self". Anything else (external receiver, EventBus.Subscribe, an injected field, a nested p => p.A.B) stays unclassified (source: null) and remains flagged. Conservative by design — only the unambiguous self-cycle is touched.

Bridge (3b6f0e1)

Tier the subscribe finding by source, mirroring the += else branch:

source verdict
self dropped in lowering → silent
injected OWN001 warning (unknown lifetime)
static / external / unannotated OWN001 leak (unchanged)

Validation

  • WalletWasabi.Fluent re-mine: 118 → 65 findings. The 53 dropped are single-hop this.WhenAnyValue(x => x.OwnProp) self-cycles; every external-source leak survived (EventBus.Subscribe<ExchangeRateChanged>, amountProvider.X, wallet.Balances, WalletRepository.Wallets.Connect(), and triggerSource.WhenAnyValue — an external receiver correctly not silenced). No external-source leak was silenced (no-FP-first held).
  • ownir 77/77, full suite, ruff, mypy --strict. CI green: the C# leak extractor job built + ran the modified extractor and the MessengerViewModel sample is unchanged (messenger.Subscribe is a field receiver → still OWN001).

Honest residual (conservative first cut)

Still flagged on purpose: multi-arg this.WhenAnyValue(x => x.A, x => x.B) self-watches (also self-cycles → a richer classifier would trim these), and nested-path this.WhenAnyValue(x => x.Settings.X) (genuinely ambiguous — Settings may be an injected model → a real leak). Multi-arg self-watch detection is the clean next refinement.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED


Generated by Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Added source-aware lifetime classification for ignored WPF .Subscribe() patterns and subscribe facts: self-rooted cases are silently skipped, injected sources now raise warnings (“may outlive”), and static/external/unknown sources are treated as confirmed leaks.
  • Documentation

    • Updated resource: "subscribe" documentation to reflect the new source-based tiering behavior.
  • Tests

    • Added dedicated test coverage for subscribe source-lifetime tiering.

claude added 2 commits June 19, 2026 10:23
… result)

The bridge half of the WalletWasabi precision win (118 raw -> the real leaks).
The ignored-`.Subscribe()` detector was a flat OWN001 ("always a leak"), but a
self-rooted `this.WhenAnyValue(x => x.SelfProp).Subscribe(...)` is a
GC-collectible self-cycle (the observable, its handler and `this` form one cycle
the GC collects together) -- NOT a leak. Only an EXTERNAL source (EventBus, an
injected model/field) holds the component from a longer-lived root.

Tier the `subscribe` finding by `source`, mirroring the `+=` else branch:
  - source == "self"     -> dropped in to_module/to_own (silent, no finding)
  - source == "injected" -> OWN001 WARNING (unknown lifetime, "may outlive")
  - source == "static"/external/unannotated -> OWN001 leak (error, unchanged)

Additive + zero regression: an unannotated `subscribe` (every current fixture)
stays an OWN001 error. The extractor half -- classifying the `.Subscribe()` chain
root and stamping `source` -- is CI-validated next; this engine is what makes the
self-cycles go silent once it does. ownir 77/77, full suite, ruff, mypy --strict.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED
The extractor half of the WalletWasabi precision win. The ignored-`.Subscribe()`
detector (WPF004) now classifies the chain root and stamps `source`: a self-rooted
`this.WhenAnyValue(p => p.SelfProp).….Subscribe(...)` is a GC-collectible
self-cycle (`source: "self"`, which the bridge drops), while any other root --
an external receiver, `EventBus.Subscribe`, an injected field, or a NESTED path
`p => p.A.B` that roots through `A` -- stays unclassified (`source: null`) and
remains a flagged leak.

`IsSelfRootedWhenAny` is purely syntactic (no SemanticModel): walk the fluent
chain to its leftmost invocation and require `this.WhenAnyValue(p => p.Member)`
with a single-hop self-member lambda. Conservative by design -- it silences only
the unambiguous self-cycle, never an external-source subscription.

Safe on the existing sample: `MessengerViewModel`'s `messenger.Subscribe(OnMessage)`
has a field receiver (not WhenAnyValue) -> `source: null` -> unchanged OWN001. The
bridge tiering that consumes `source` landed in the previous commit; together they
take the WalletWasabi mine's 118 raw findings down to the real (external-source)
leaks. CI-validated (no local dotnet).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED
@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: acddfa86-2075-44c8-b46a-ca83c7ce87a6

📥 Commits

Reviewing files that changed from the base of the PR and between bd2106b and 556f111.

📒 Files selected for processing (1)
  • frontend/roslyn/OwnSharp.Extractor/Program.cs
🚧 Files skipped from review as they are similar to previous changes (1)
  • frontend/roslyn/OwnSharp.Extractor/Program.cs

📝 Walkthrough

Walkthrough

The Roslyn extractor gains IsSelfRootedWhenAny, a syntax-only helper that detects this.WhenAnyValue(p => p.SelfProp).Subscribe(...) chains and tags WPF004 facts with source: "self". The OwnIR pipeline then skips source == "self" entries in both lowering passes and downgrades source == "injected" to a warning verdict in check_facts, with tests covering all three tiers.

Changes

Subscribe Source-Lifetime Tiering

Layer / File(s) Summary
Roslyn classifier and WPF004 fact emission
frontend/roslyn/OwnSharp.Extractor/Program.cs
Adds IsSelfRootedWhenAny to validate a this.WhenAnyValue(p => p.Member).Subscribe(...) fluent chain by walking left through self-preserving operators, checking the chain head, and confirming a single-hop lambda shape; updates WPF004 emission to set source: "self" on match, null otherwise.
OwnIR schema docs and lowering self-cycle skip
ownlang/ownir.py
Schema documentation expanded to describe all three source tiers: self (silent skip), injected (warning), static/external/unknown (error leak). In both to_own and to_module lowering, resource: "subscribe" facts with source == "self" are now skipped entirely so no synthetic ownership nodes or leak candidates are created for GC-collectible self-cycles.
check_facts injected-source warning and tests
ownlang/ownir.py, tests/test_ownir.py
check_facts branches on source == "injected" to emit warning severity with a "may outlive / possible leak" message; other sources keep error-tier messaging. P-004 tests verify: self → silent, injected → OWN001 warning, static/None → OWN001 error-tier.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • PhysShell/Own.NET#28: Directly modifies the same resource: "subscribe" classification pipeline in Program.cs and ownir.py, including check_facts severity/message handling based on the source field.
  • PhysShell/Own.NET#13: Modifies subscription-lowering and check_facts to treat special subscription sources differently, adding resource: "unresolved-subscription" and advisory handling adjacent to this PR's tiering work.

Poem

🐇 Hop, hop along the subscribe chain,
this.WhenAnyValue loops back again—
"Self-cycle!" I cry, "No leak today!"
Injected gets a warning, not full dismay,
And static flows earn their error display.
The rabbit checks verdicts, then hops away~ 🌿

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and specifically describes the main change: tiering the ignored-.Subscribe() leak detector by source lifetime, with the key insight that self-cycles are now silent instead of flagged.
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/subscribe-source-tiering

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bd2106bad1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread frontend/roslyn/OwnSharp.Extractor/Program.cs
…ns (codex P1)

codex flagged a real recall regression: IsSelfRootedWhenAny only inspected the
leftmost invocation, so `this.WhenAnyValue(x => x.Foo).CombineLatest(_bus.Stream)
.Subscribe(...)` (or `.SelectMany(_ => _bus.Events)`) stamped source:"self" and got
silenced -- yet the downstream external observable roots the subscription
externally, so a real leak went quiet.

Fix: a chain is self-rooted only if the HEAD is `this.WhenAnyValue(p => p.Member)`
AND every downstream operator is self-preserving (args are only funcs/schedulers/
scalars). A combinator (CombineLatest/Merge/SelectMany/WithLatestFrom/Zip/Switch/
...), an operator with an observable-taking overload (Throttle/Buffer/Sample/
TakeUntil/Window), or any UNKNOWN operator is treated as possibly-external, so the
chain stays flagged. Conservative by construction: an unrecognised op never
silences a real leak.

Trade-off: a few self-cycles using ambiguous-overload ops (Throttle/Buffer) are
re-flagged (residual FP), but no external-source leak can be silenced. CI
validates the build + the unchanged MessengerViewModel sample.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED
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.

2 participants