Skip to content

fix(extractor): exempt locals of user types with a provably-empty Dispose() (Closes #225)#233

Merged
PhysShell merged 2 commits into
mainfrom
claude/empty-dispose-225
Jul 11, 2026
Merged

fix(extractor): exempt locals of user types with a provably-empty Dispose() (Closes #225)#233
PhysShell merged 2 commits into
mainfrom
claude/empty-dispose-225

Conversation

@PhysShell

Copy link
Copy Markdown
Owner

Что и зачем

Локаль user-defined типа, чей Dispose() тело буквально пустое, ложно флагалась как OWN001-утечка. Такой тип (например IEnumerator<T>-энумератор) реализует IDisposable только ради интерфейса, а не потому что держит ресурс — не вызвать его пустой Dispose нельзя «утечь». Существующая no-op-льгота (IsNoOpDisposeWrapper/IsDisposeOptional) покрывала лишь горстку именованных BCL-типов. Подтверждённый FP в ClosedXML: 5 точек, один корень (Slice.Enumerator локали); sweep #201, field-notes запись 19.

Фикс — только в экстракторе, без изменений OwnIR: HasEmptyDisposeBody распознаёт по ИСХОДНИКУ тип, чей беспараметрический Dispose() — блок с нулём стейтментов, и в цепочке баз нет своего Dispose для каскада; локаль такого типа отбрасывается из обоих путей local-disposable (flat IsDisposableType и --flow-locals ImplementsIDisposable).

Тип изменения

  • fix — исправление бага (precision / FP)

Как проверено

  • python tests/run_tests.py — exit 0 (test_ownir 276/276)
  • ruff check . и mypy --strict на ownlang/ — clean / Success
  • локально через настоящий экстрактор (.NET 8), оба режима:
EmptyDisposeSample: пустой энумератор + пустой *Reader -> SILENT (оба режима)
                    RealResource / LeakyReader / EmptyOverrideOverRealBase -> FLAGGED (контроли)
regression: diff старого/нового экстрактора по всем существующим сэмплам, flat И --flow-locals -> БАЙТ-В-БАЙТ идентичен

Границы (не перерасширяет)

  • Только ЛОКАЛИ. Поле сохраняет свой disposal-контракт, поэтому Handle-поле из OwnIgnoreSample (Alpha follow-up: implement [OwnIgnore("reason")] suppression (P-004) end to end #209, пустой Dispose как leak-контроль) не затронуто.
  • Non-empty тело → всё ещё лик (RealResource, LeakyReader).
  • Пустой override над базой с реальным Dispose → лик (EmptyOverrideOverRealBase).
  • Expression-bodied / metadata-only (нечитаемое) тело → не льготируется (никогда не угадываем).

Правка двух существующих фикстур (fidelity)

UnitOfWork и PixelOwner использовали пустой Dispose(){} как упрощение, хотя их же комментарии заявляли реальный ресурс. Сделаны честными (реальное тело Dispose) — остаются настоящими leak-фикстурами, их by-name CI-ассерты ('uow'/'leakedOwner' всё ещё лик) не тронуты:

  • UnitOfWork теперь владеет и диспозит CancellationTokenSource;
  • PixelOwner.Dispose очищает бэкинг (Array.Clear).

Связанные issue

Closes #225. Найден sweep'ом #201, каталог — field-notes-patterns.md запись 19 (помечена shipped). Разные зоны экстрактора с #218 (rotation) — можно ревьюить параллельно.

План валидации на живых репозиториях (после мержа — отдельной задачей)

Перегнать oracle-sweep по ClosedXML и показать дельту FP:

  • ClosedXML/Excel/Cells/Slice.cs:91,109,149,188,219 — пять Slice.Enumerator-локалей должны уйти из own-only (пустой Dispose() в Slice.cs:324-416).
  • Ожидаемо: −5 FP, при этом ни одна ранее-подтверждённая настоящая находка (реальные IDisposable-локали в том же репо) не исчезает. Сама перегонка — вне этого PR.

Чеклист

  • изменение покрыто сэмплом + CI-ассертами в обе стороны (silent + flagged), оба режима (flat/flow)
  • README/docs обновлены (field-notes-patterns.md запись 19 → shipped)
  • коммиты в conventional-commit стиле (fix:)

🤖 Generated with Claude Code


Generated by Claude Code

…pose() (#225)

A local of a user-defined type whose `Dispose()` body is literally empty was
false-flagged as an OWN001 leak. Such a type (e.g. an `IEnumerator<T>`
enumerator) implements IDisposable only to satisfy an interface, not because it
holds a resource — never calling its empty Dispose cannot leak anything. The
existing no-op exemption (IsNoOpDisposeWrapper / IsDisposeOptional) only reached
a handful of NAMED BCL types. Confirmed FP in ClosedXML, 5 sites, single root
cause (Slice.Enumerator locals); issue #201 oracle sweep, field-notes entry 19.

Fix (extractor-only, no OwnIR change): HasEmptyDisposeBody recognises from SOURCE
a type whose parameterless `Dispose()` is a block body of zero statements and
whose base chain owns no Dispose to cascade to, and drops a LOCAL of it from both
the flat (IsDisposableType) and --flow-locals (ImplementsIDisposable) paths.

Deliberately scoped to LOCALS: a FIELD keeps its own disposal contract, so the
OwnIgnoreSample `Handle` field stand-in (an empty-Dispose type used as a leak
control in #209) is unaffected. Precision guards, pinned by EmptyDisposeSample.cs
in both the wpf-extractor (flat) and flow-locals CI jobs: a non-empty Dispose
(RealResource, LeakyReader) still leaks; an empty override over a base whose
Dispose does real work (EmptyOverrideOverRealBase) still leaks; an
expression-bodied / metadata-only Dispose we cannot read is not exempted (never a
guessed drop). The empty enumerator (the ClosedXML shape) and an empty *Reader
stay silent.

Two existing fixtures had used an empty `Dispose()` as a modelling shortcut while
their comments claimed a real resource; made faithful (a real Dispose body) so
they stay genuine leak fixtures: UnitOfWork now owns+disposes a
CancellationTokenSource; PixelOwner clears its backing. This keeps their existing
by-name CI assertions ('uow'/'leakedOwner' still leak).

Verified locally with the real extractor (.NET 8): EmptyDisposeSample goes silent
on the two empty-Dispose locals, flags the three controls, in both flat and flow
modes; a full existing-sample diff (both modes) is byte-identical after the
fixture-fidelity fix (zero regression). Gates: run_tests.py exit 0, test_ownir
276/276, ruff clean, mypy --strict green.

Closes #225

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cg6DVXahkyY68Ruu7f76rG
// FLAGGED (control): a real IDisposable local never disposed -> OWN001.
public void LeakReal()
{
var r = new RealResource();
// FLAGGED (control): a non-empty `*Reader` local never disposed -> OWN001 (flat path).
public int LeakReader()
{
var lr = new LeakyReader();
Comment thread frontend/roslyn/samples/EmptyDisposeSample.cs Fixed
@coderabbitai

coderabbitai Bot commented Jul 11, 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: 42 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: 41e16dfc-9905-4540-a9fe-ca49a4053f29

📥 Commits

Reviewing files that changed from the base of the PR and between 3ca4bde and 447dee4.

📒 Files selected for processing (6)
  • .github/workflows/ci.yml
  • docs/notes/field-notes-patterns.md
  • frontend/roslyn/OwnSharp.Extractor/Program.cs
  • frontend/roslyn/samples/EmptyDisposeSample.cs
  • frontend/roslyn/samples/MemoryOwnerEscapeSample.cs
  • frontend/roslyn/samples/UnitOfWorkFlowSample.cs
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/empty-dispose-225

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: 8630b4ddea

ℹ️ 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 +1328 to +1332
m => m.Parameters.Length == 0 && m.ReturnsVoid && m.TypeParameters.Length == 0);
if (dispose is null || dispose.DeclaringSyntaxReferences.Length == 0)
return false; // no readable source Dispose (metadata-only) -> cannot prove empty
return dispose.DeclaringSyntaxReferences.All(
sref => sref.GetSyntax() is MethodDeclarationSyntax { Body.Statements.Count: 0 });

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 Do not exempt async-disposable resources

This exemption only proves that synchronous Dispose() is empty; it does not rule out a non-empty DisposeAsync() on the same source-defined type. For a type that implements both IDisposable and IAsyncDisposable with Dispose() as a compatibility no-op and DisposeAsync() doing the real cleanup, a local such as var r = new AsyncReader(); is now dropped from the local-disposable candidates in both detector paths, so failing to call either dispose method becomes silent even though the existing flow logic already treats DisposeAsync() as a release.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Good catch — fixed in 447dee4. You're right: HasEmptyDisposeBody only proved the sync Dispose() was empty, so a type with IDisposable (empty compat no-op) + IAsyncDisposable (real DisposeAsync) would have been silenced even though the real cleanup is async and the flow detector already treats DisposeAsync() as a release.

The guard now conservatively refuses to exempt any type that declares its own parameterless DisposeAsync. Pinned by a new AsyncReader control (empty sync Dispose, real DisposeAsync) — flagged in both the flat and --flow-locals CI jobs, while the two provably-empty-Dispose locals stay silent. The guard only ever flags more, so the existing-sample diff stays byte-identical (zero regression); gates green.


Generated by Claude Code

…a real DisposeAsync (#225)

Codex review catch. HasEmptyDisposeBody proved only that the SYNCHRONOUS Dispose()
body is empty; a type implementing both IDisposable and IAsyncDisposable can have
an empty sync Dispose() as a compatibility no-op while DisposeAsync() does the real
cleanup. The flow detector already treats DisposeAsync() as a release, so silencing
such a local would drop a real leak. Now conservatively refuse to exempt any type
that declares its own parameterless DisposeAsync.

Pinned by a new AsyncReader control in EmptyDisposeSample.cs (empty sync Dispose,
real DisposeAsync) — flagged in both the flat and --flow-locals CI jobs; the two
provably-empty-Dispose locals stay silent. Verified locally: existing-sample diff
still byte-identical (the guard only ever flags MORE, never fewer); gates green
(test_ownir 276/276, ruff, mypy).

Refs #225

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cg6DVXahkyY68Ruu7f76rG
// FLAGGED (control): empty override, but the base Dispose does real work -> OWN001.
public void LeakDerived()
{
var d = new EmptyOverrideOverRealBase();
// (sync or async) leaks; the empty sync body must not exempt it.
public int LeakAsync()
{
var ar = new AsyncReader();
@PhysShell PhysShell merged commit d089065 into main Jul 11, 2026
37 checks passed
PhysShell pushed a commit that referenced this pull request Jul 11, 2026
…source emptiness is not a runtime no-op (#238)

The post-merge re-measure caught a soundness regression from #225/PR #233:
ClosedXML came out -265 instead of the expected -5. 263 of those were real
findings silently swallowed — XLWorkbook.Dispose() is empty IN SOURCE only,
Janitor.Fody weaves the actual cleanup into it at build time, and
HasEmptyDisposeBody trusted the source tree.

Three changes, all in the same gate:

- SOUNDNESS (the 263): the exemption now applies ONLY to types implementing
  IEnumerator/IEnumerator<T> — the shape that motivated #225, where an empty
  Dispose is an idiomatic interface stub, not a weaving target. Everything
  else keeps the honest warning; source-level analysis cannot prove the
  absence of IL weaving for arbitrary types.
- Defense in depth: a FodyWeavers.xml anywhere above the type's source file
  disables the exemption even for a perfect enumerator shape (weaved/ fixture
  + CI assertion). Unlocatable source -> conservatively no exemption.
- COVERAGE (the missing 3 of -5): `void IDisposable.Dispose() { }` (explicit
  interface implementation — the actual Slice.Enumerator spelling) is now
  recognized as an empty Dispose; the DisposeAsync refusal likewise matches
  explicit `IAsyncDisposable.DisposeAsync`.

Sample rework: ExplicitDisposeEnumerator ('x', silent) pins the coverage fix;
ScratchReader — a NON-enumerator with an empty source Dispose, the XLWorkbook
shape — flips from silent to a FLAGGED control on both detector paths; flow
count assertion 4 -> 5; new weaved/WeavedEmptyDispose.cs fixture pins the
kill-switch.

Verified with the real extractor (.NET 8): flat flags s/lr/ar, flow flags
exactly s/r/lr/d/ar with e/x silent, the weaved enumerator stays flagged;
full-sample-set diff vs main (minus the reworked sample) is byte-identical.
Gates: run_tests 276/276, ruff, mypy green. Post-merge acceptance: re-measure
ClosedXML — delta vs the pre-#233 baseline must be exactly -5 with all 263
findings restored.

Closes #238

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsWw4Ay8KLTHFom1HvRu3U
PhysShell pushed a commit that referenced this pull request Jul 12, 2026
Cloned ClosedXML at the remeasure note's pinned commit (4e89dced), built the
core lib + Examples (net8.0), and ran the real own-check pipeline: 119 OWN001
findings, all undisposed XLWorkbook wb/workbook locals — the exact locals the
#233 empty-Dispose/Janitor.Fody over-exemption had silenced. On current main
they are flagged again, so the disappearing findings have returned on the real
repository, not just the isolated #238 pins. Records the scope difference
(Examples/net8.0/flow-locals) vs the note's 263-call-site figure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZjB2ggU9qm8vmuTQRtZXR
PhysShell pushed a commit that referenced this pull request Jul 12, 2026
- frozen reference marked PROVISIONALLY approved; add the Integration-gate
  dependency (final semantic-port merge gated on the separate post-batch OSS
  remeasure run by another agent; point-check does not substitute for it).
- reframe the regression as "the #238 regression class introduced by #233 and
  repaired by #240" — #238 records the problem, it is not a version boundary.
- replace the unrestricted "no new false positives" with the bounded claim:
  the two legitimate IEnumerator<T> empty-Dispose controls stay silent in the
  regression fixture; broader FP behavior is left to the full OSS remeasure.
- add an explicit proven / not-proven guarantee split and the two-branch
  integration gate (full sweep green -> final; unexplained disappearance ->
  Python-first fix, stop merge, regenerate golden explicitly, re-parity Rust).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZjB2ggU9qm8vmuTQRtZXR
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.

Precision gap: no-op Dispose() exemption limited to named BCL types, doesn't cover user-defined empty Dispose bodies

3 participants