Skip to content

corpus(wpf): a -= that exists is not a -= that runs — regression guard for #278#279

Open
PhysShell wants to merge 1 commit into
mainfrom
claude/corpus-unreachable-unsubscribe
Open

corpus(wpf): a -= that exists is not a -= that runs — regression guard for #278#279
PhysShell wants to merge 1 commit into
mainfrom
claude/corpus-unreachable-unsubscribe

Conversation

@PhysShell

Copy link
Copy Markdown
Owner

Adds corpus/wpf/unsubscribe-behind-a-flag/, the regression guard #278 asks for.

What the case pins

A document subscribes to a static publisher in its constructor. A matching -= does exist in the class — so the extractor's release model ("released by a matching target -= handler in the class", OwnSharp.Extractor/Program.cs:13) falls silent — but it never runs:

public Document()
{
    AppSettings.Options.PropertyChanged += new PropertyChangedEventHandler(OnOptionsChanged);  // static publisher
}

public void UnregisterEventHandlers(bool unregOnlyChildren = false)   // not a teardown
{
    if (!unregOnlyChildren)                                            // callers pass true
        AppSettings.Options.PropertyChanged -= OnOptionsChanged;       // the `-=` that never runs
}

Three independent reasons the release is unreachable, any one of which is enough: it is not in a teardown (P-001:51 and P-004:33 both require Dispose/OnClosed/Unloaded — the extractor is looser than its own spec); it is behind a parameter guard callers pass as true; and whole subsystems never call the method.

before.cs is RED today — that is the point

before.cs is a genuine leak and own-check reports nothing. after.cs moves the -= into Dispose and drops the flag, and must stay silent — so the fix cannot trade the false negative for a false positive. The two files differ only in whether the release is provably reached; the += and -= name the same (receiver, handler) in both. A model that keys on the mere existence of a matching -= cannot tell them apart.

The core already gets this right

The .own reduction is worth reading — it models the flag as the branch that returns before the release, and the checker nails it:

case.own:23:5: error: [OWN001] 'sub' is owned but not released before return
  (leaks on at least one path) [resource: subscription token]
  23 |     return;   // caller passed `true` -> the static `-=` is skipped

So this is not a core-logic gap. It is upstream, in the extractor emitting released: true from the existence of a -= without asking whether it runs.

Provenance — heap-proven, not hypothetical

Reduced from SectorTS BrokerDataClasses/GTD.cs:5192 (subscribe to the static AppData.Properties.GBProperty) and :5259 (UnregisterEventHandlers(bool UnregOnlyGoodys)). GTDService.cs passes true at five sites; DocCloud/** — including eight AutoMapper .ConstructUsing(x => new GTD(null, null)) profiles that build a document per mapping — never calls it at all.

A ClrMD root walk on a live process, after 31 documents:

on the heap          : 1 685 951 objects   223 MB
REACHABLE from roots : 1 569 072 objects   148 MB
>>> 66.3% of the heap is genuinely RETAINED

[PinnedHandle] -> KernelProperty (static) -> GBProperty
    -> PropertyChangedEventHandler -> invocation list -> GTD -> the whole document graph

Detaching after each document makes the process memory-flat: peak RSS 2.71 GB → 0.61 GB on the same 389 documents, with byte-identical output. That confirms the diagnosis rather than merely being consistent with it.

Note that OWN001 does fire on KDT.cs:88 — the same static publisher, no -= anywhere. It is only the presence of an unreachable -= that silences it.

Prior art, honestly

corpus/wpf/subscription-explicit-delegate-release/notes.md:28-41 (Codex P2 on #163) already records that the release model is not flow-sensitive and calls the gap pre-existing. But it scoped the gap to a rebinding setter, deferred it, and never filed it. This case shows the surface is much wider — a parameter guard, a non-teardown method, and an uncalled method are all ordinary code — and gives the gap its first real instance.

CI impact

  • Recall floor unaffected. --min-recall 25 is an absolute floor of caught cases; this case is not caught yet, so caught does not move.
  • No new false positive. after.cs is silent under the current model and must stay so.
  • Parity fixtures regenerated (cfg_parity.json, diag_parity.json) — the corpus grew by one case.
  • python tests/run_tests.py → exit 0, wpf: 10/10 cases match expected codes. cd rust && cargo test → exit 0. python scripts/benchmark.py --selftest → OK.

Closes nothing on its own — it is the red test that #278's fix turns green.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PjQnE1FDucd6vBVQbFswiE

A regression guard for the release-reachability soundness gap: the subscription is
to a STATIC publisher, a matching `-=` does exist in the class — so the extractor's
"any matching `-=` in the class releases it" model falls silent — but it never runs.
It sits behind a bool parameter every caller passes as `true`, in a method that is
not a teardown, and that a whole subsystem never calls at all.

before.cs is RED today: it is a real leak and own-check says nothing. That is the
point of the case. after.cs must stay silent (the release is unconditional and in
Dispose), so the fix cannot trade the false negative for a false positive.

The .own reduction shows the core already handles this correctly — it reports OWN001
"not released before return (leaks on at least one path)" and points at the guarded
branch. The bug is upstream, in the extractor's release-matching.

Reduced from SectorTS GTD.cs:5192/:5259 and proven on the heap with a ClrMD root
walk: 66.3% of the heap still reachable from the GC roots after 31 documents, held
via [PinnedHandle] -> static KernelProperty -> GBProperty -> PropertyChangedEventHandler
-> GTD. Detaching after each document makes the process memory-flat (peak RSS
2.71 GB -> 0.61 GB, byte-identical output), which confirms the diagnosis.

Prior art: corpus/wpf/subscription-explicit-delegate-release/notes.md:28-41 already
records the model as not flow-sensitive, but scoped it to a rebinding setter and
deferred it. This case shows the surface is wider and gives it a real instance.

CI: recall floor is unaffected (--min-recall is an absolute floor of caught cases,
and this one is not caught yet); after.cs adds no false positive. The cfg/diag parity
fixtures are regenerated because the corpus grew; tests/run_tests.py and cargo test
are both green.

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

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@PhysShell, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 10 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 68500dae-80f6-43f8-8186-67f226fc629c

📥 Commits

Reviewing files that changed from the base of the PR and between 53bf05a and 4f0b068.

📒 Files selected for processing (7)
  • corpus/wpf/unsubscribe-behind-a-flag/after.cs
  • corpus/wpf/unsubscribe-behind-a-flag/before.cs
  • corpus/wpf/unsubscribe-behind-a-flag/case.own
  • corpus/wpf/unsubscribe-behind-a-flag/expected-diagnostics.txt
  • corpus/wpf/unsubscribe-behind-a-flag/notes.md
  • tests/fixtures/cfg_parity.json
  • tests/fixtures/diag_parity.json
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/corpus-unreachable-unsubscribe

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@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: 4f0b068f5d

ℹ️ 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 on lines +69 to +72
**Regression guard.** `scripts/benchmark.py` runs the real C# through the extractor + core:

* `before.cs` must be **caught** (OWN001) — it is a genuine leak. *Currently it is not: this is the bug.*
* `after.cs` must be **silent** — the release is unconditional and in `Dispose`. It must stay silent

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Gate the new red corpus case

When relying on this as the #278 regression guard, scripts/benchmark.py does not actually require this before.cs to be caught: it only gates the aggregate caught count against --min-recall 25, and this commit explicitly leaves that floor unchanged, while tests/test_wpf.py only checks the hand-written .own reduction that already passes. Because before.cs is known silent today, CI can stay green with this exact false negative indefinitely; add an explicit per-case expected-fail/ratchet or otherwise make the benchmark fail once this case is still missed.

Useful? React with 👍 / 👎.

@github-actions

Copy link
Copy Markdown

@coderabbitai review

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.

1 participant