Skip to content

fix(extractor): exempt WinForms modeless Form.Show() locals from the disposable-leak detector#57

Merged
PhysShell merged 2 commits into
mainfrom
claude/winforms-modeless-precision
Jun 21, 2026
Merged

fix(extractor): exempt WinForms modeless Form.Show() locals from the disposable-leak detector#57
PhysShell merged 2 commits into
mainfrom
claude/winforms-modeless-precision

Conversation

@PhysShell

@PhysShell PhysShell commented Jun 21, 2026

Copy link
Copy Markdown
Owner

The false positive

Mining ShareX (a large, real WinForms app) with the --flow-locals
local-IDisposable detector surfaced a systematic false-positive class — our
WPF-tuned detector flagged the single most idiomatic line in WinForms:

void OpenSettings()
{
    var form = new SettingsForm();   // SettingsForm : System.Windows.Forms.Form
    form.Show();                     // modeless — returns immediately
}

as an OWN001 leak (form is an IDisposable, constructed, never disposed). It
is not a leak.
Control.Show() opens the form modeless: the window stays open
after the method returns and WinForms disposes it when the user closes it — the
framework owns the lifetime. Disposing it yourself in OpenSettings would be the
bug. This shape is in every "open a tool window" handler, so the FP is systematic.

The contrast — and the line that must stay flagged — is a modal dialog:

var dlg = new ConfirmDialog();
dlg.ShowDialog();    // modal — caller owns dlg, MUST dispose -> real leak if it doesn't

The discriminator is exactly the show method: Show() → framework-owned (exempt);
ShowDialog() → caller-owned (tracked).

The fix

Two extractor helpers + one guard on the --flow-locals candidate condition:

  • DerivesFromWinFormsForm(ITypeSymbol?) — walks the base chain for
    System.Windows.Forms.Form;
  • IsModelessShownForm(name, body, type) — a Form-derived local with a
    local.Show() somewhere in the method body (a ShowDialog() is deliberately
    not matched);
  • the candidate loop drops such a local from the tracked set.

It is per-local and Show()-only by design: a ShowDialog()'d form, a Form
constructed but never shown, and any non-Form disposable are all untouched. No new
finding path, no core change — a narrowing of the candidate set, exactly like the
existing IsDisposeOptional exemptions.

Pinned in CI

frontend/roslyn/samples/WinFormsModelessSample.cs (self-contained — a stub
System.Windows.Forms.Form stands in for the framework type, which is not loaded in
the flow step) wired into the wpf-extractor --flow-locals steps:

method shape verdict
OpenModeless new ModelessForm().Show() silent (the FP this removes)
OpenModalLeak new ModalDialog().ShowDialog(), never disposed OWN001 'modalLeak' is never disposed
OpenModalOk the same, disposed on every path silent

The core half (facts → verdict) was validated locally on the hand-built flow facts
before pushing; the C# half (source → facts) is validated by the sample in CI, where
the rest of the frontend is. Writeup: docs/notes/winforms-modeless-precision.md.

This closes the one systematic WinForms FP (modeless forms); the broader
Controls.Add / components ownership story is a separate slice on the
precision-hardening backlog the ShareX hunt produced.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED


Generated by Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved WinForms flow-locals leak detection: modeless Form.Show() is handled as an ownership transfer (avoids false positives), while modal ShowDialog() still correctly flags undisposed dialogs and stays silent when properly disposed.
  • Documentation
    • Added a precision note explaining the differing ownership/disposal semantics of Show() vs ShowDialog() and the path-sensitive handling for conditional modeless forms.
  • Tests / CI
    • Added CI validation that runs the Roslyn extractor with --flow-locals on an updated WinForms sample and asserts expected leak findings.

…disposable-leak detector

Mining ShareX (a real WinForms app) with the --flow-locals local-IDisposable
detector surfaced a systematic false-positive class: our WPF-tuned detector
flagged the single most idiomatic WinForms line —

    var form = new SettingsForm();   // : System.Windows.Forms.Form
    form.Show();                     // modeless

as an OWN001 leak. It is not. Control.Show() opens the form *modeless*: WinForms
owns the lifetime and disposes the form when the user closes it (disposing it
yourself would be a bug). A *modal* dialog shown via ShowDialog() is the caller's
to dispose — and an undisposed one IS a real leak that must stay flagged. The
discriminator is exactly the show method.

The fix adds two extractor helpers — DerivesFromWinFormsForm (walks the base
chain for System.Windows.Forms.Form) and IsModelessShownForm (a Form-derived
local with a local.Show() in the method body) — and one guard on the
--flow-locals candidate condition: drop a modeless-shown Form-derived local from
the tracked set. It is per-local and Show()-only by design: a ShowDialog()'d
form, a Form constructed but never shown, and any non-Form disposable are all
untouched. No new finding path, no core change — a narrowing of the candidate
set, exactly like the IsDisposeOptional exemptions.

Pinned by a self-contained WinFormsModelessSample.cs (a stub
System.Windows.Forms.Form stands in for the framework type, which is not loaded
in the flow step) wired into the wpf-extractor --flow-locals steps:
modeless Show() silent; modal ShowDialog() never disposed -> OWN001; modal
ShowDialog() + Dispose silent. The core half (facts -> verdict) was validated
locally on the hand-built flow facts before pushing. Writeup in
docs/notes/winforms-modeless-precision.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED
// never is -> real leak. The Show()-only exemption deliberately does not cover it.
public void OpenModalLeak()
{
var modalLeak = new ModalDialog();
@coderabbitai

coderabbitai Bot commented Jun 21, 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: 9dbccab8-9123-4b19-b23f-79a652dcf91e

📥 Commits

Reviewing files that changed from the base of the PR and between 627f0c2 and 0ac3388.

📒 Files selected for processing (4)
  • .github/workflows/ci.yml
  • docs/notes/winforms-modeless-precision.md
  • frontend/roslyn/OwnSharp.Extractor/Program.cs
  • frontend/roslyn/samples/WinFormsModelessSample.cs
🚧 Files skipped from review as they are similar to previous changes (1)
  • .github/workflows/ci.yml

📝 Walkthrough

Walkthrough

Extends the --flow-locals leak detector with a Form.Show() call-site exemption: a new helper detects System.Windows.Forms.Form type derivation, and EmitFlowExpr() recognizes Show() invocations on tracked locals, emitting release immediately (modeless ownership transfer) rather than reporting a leak. A self-contained sample file demonstrates modeless (silent), conditional modeless (silent), modal-leaked (OWN001), and modal-disposed (silent) cases with a stubbed Form type. A CI step validates expectations via grep assertions, and design notes document the Show/ShowDialog discriminator and path-sensitive precision.

Changes

WinForms modeless Form exemption in --flow-locals

Layer / File(s) Summary
Form derivation helper and Show() call-site release emission
frontend/roslyn/OwnSharp.Extractor/Program.cs
Adds DerivesFromWinFormsForm() helper that walks the base-type chain to detect System.Windows.Forms.Form inheritance. Updates EmitFlowExpr() to recognize Show() invocations on tracked locals whose receiver type derives from Form, emitting a release fact at the call site (modeling framework-owned modeless lifetime) and returning early instead of generic use handling.
WinFormsModelessSample: modeless, modal-leak, modal-disposed cases
frontend/roslyn/samples/WinFormsModelessSample.cs
Adds a self-contained sample with OpenModeless() (unconditional modeless Show()), OpenModelessConditional() (conditional modeless Show() on one branch), OpenModalLeak() (modal ShowDialog() without disposal), and OpenModalOk() (modal ShowDialog() + Dispose()). Includes derived ModelessForm and ModalDialog classes and a stub System.Windows.Forms.Form : IDisposable.
CI assertions and design documentation
.github/workflows/ci.yml, docs/notes/winforms-modeless-precision.md
Adds a CI workflow step extracting the sample with --flow-locals, asserting OWN001 for modalLeak and silence for modeless and modalOk. Documents the ownership contract discriminator (Show = framework-owned, ShowDialog = caller-owned), the call-site release emission preserving path sensitivity, CI wiring with stub Form matching, and deferred broader WinForms lifetime topics.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Possibly related PRs

  • PhysShell/Own.NET#15: Introduced the core --flow-locals detector and CFG/JSON flow-body infrastructure that this PR directly extends with Form-specific call-site release emission.
  • PhysShell/Own.NET#16: Implements the overall --flow-locals flow-lowering machinery in Program.cs that the new DerivesFromWinFormsForm helper and EmitFlowExpr enhancement hook into.

Poem

🐇 A Form that whispers Show() to the frame,
No disposal required—the framework's claim.
One release fact, right at the call,
Path-sensitive precision prevents the fall.
Modal dialogs still leak till Dispose() lands,
One helper, two lines—the exemption stands! 🌸

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: exempting WinForms modeless Form.Show() calls from the disposable-leak detector via a targeted flow-locals fix.
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/winforms-modeless-precision

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: 627f0c2f74

ℹ️ 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 Outdated
…th-sensitive)

Codex review on #57: the first cut dropped a Form local from tracking whenever a
local.Show() appeared anywhere in the method body — a method-wide exemption that is
not path-sensitive. `var f = new Form(); if (open) f.Show();` went silent even though
the open == false path constructs a form that is never handed to WinForms and never
disposed (a real leak).

Switch to the project's own call-site-release shape — the same one pool Return and
the inter-procedural consume contract use: a modeless local.Show() transfers
ownership to the framework ON THAT PATH, so EmitFlowExpr emits a `release` of the
local at the show site (guarded by the Form-derived receiver type). The flow engine
then does the rest path-sensitively:

  - `f.Show();`               -> acquire+release balanced       -> silent
  - `if (open) f.Show();`     -> released on then, not else     -> OWN001 (every-path)
  - `d.ShowDialog();`         -> ShowDialog not matched (use)   -> OWN001 if undisposed

Removes the IsModelessShownForm helper and the candidate-loop guard; keeps
DerivesFromWinFormsForm (now used by the release branch). No core change — reuses the
existing release op like the other call-site-release branches.

WinFormsModelessSample gains OpenModelessConditional pinning the path-sensitivity
(`'condForm' may not be disposed on every path`); the CI step asserts it alongside
the unconditional-modeless silence, the modal ShowDialog leak, and the disposed-modal
silence. Core half validated locally on the hand-built flow facts before pushing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED
// would have wrongly silenced this.
public void OpenModelessConditional(bool open)
{
var condForm = new ModelessForm();
@PhysShell PhysShell merged commit 8355251 into main Jun 21, 2026
22 checks passed
PhysShell pushed a commit that referenced this pull request Jun 21, 2026
#57 models a modeless WinForms Form.Show() as a path-sensitive call-site release, so
the first hunt's dominant noise (`new SomeForm().Show()` flagged as OWN001) should be
gone. Re-mine ShareX on the post-#57 extractor to confirm the drop and surface the
next layer — remaining precision targets (Controls.Add / components-container
ownership) or genuine leaks underneath.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED
PhysShell added a commit that referenced this pull request Jun 21, 2026
… (recall 9→10) (#58)

Re-mining ShareX on the post-#57 extractor (modeless-Form FPs gone) left a short,
mostly-real list of local-disposable findings. The cleanest real bug:
ShareX.UploadersLib/FileUploaders/Vault_ooo.cs:216 (DeriveCryptoData) creates an
Rfc2898DeriveBytes (PBKDF2 deriver, holds an HMAC) to derive the AES key + IV, then
returns without disposing it — a real leak on every upload. It does not escape (the
returned CryptoData holds only the derived byte[]s), so the flow detector catches it
as OWN001. An oversight, not a pattern: the sibling EncryptBytes() already
`using`-scopes its aes/MemoryStream/CryptoStream.

Captured as corpus/real-world/sharex-rfc2898-derivebytes-leak/ (before.cs / after.cs /
case.own / expected-diagnostics.txt / notes.md) and ratchets the benchmark floor 9→10
so the catch is pinned. notes.md records the honest recall gap the same method exposes:
a second leak, rng = RandomNumberGenerator.Create(), missed because it is a static
factory (the detector knows `new` and File.Open*/Create*, not arbitrary X.Create()) —
a separate slice; after.cs disposes both so the fix is genuinely clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED
PhysShell pushed a commit that referenced this pull request Jun 21, 2026
…es for consume inference

Codex P2: ConsumesParam's DescendantNodesAndSelf scan (and the pre-existing DisposesLocal)
descended into nested lambdas / local functions, so a parameter disposed or forwarded ONLY
inside a stored callback — `Register(Stream s) { callbacks.Add(() => Inner(s)); }` — was
wrongly treated as consumed at the call site. That would release the caller's argument at
`Register(s)` and report a phantom use-after-handoff (false OWN002), even though the dispose
runs DEFERRED, not at the call boundary.

Both scans now use DescendantNodesAndSelf(descendIntoChildren: not lambda/local-function) —
the same deferred-body rule the flow lowering already follows (PR #57/#59). A dispose/forward
inside a nested function contributes nothing to the consume inference. Conservative: it can
only DROP a consume (a missed handoff = a leak, never a false use-after-release).

Pinned by a flow-locals silent control: DeferredHandoffNoFalsePositive's `defer` is passed to
a deferred-lambda "consumer", used, then disposed normally — it must stay SILENT (it would
trip a false OWN002 without the fix). The transitive corpus case (Inner forwarded directly,
not in a lambda) is unchanged. corpus 9/9, ownir 116/116.

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.

3 participants