Skip to content

fix(extractor): recognize .Close() as a field release (receiver-scoped) — mined Npgsql#91

Merged
PhysShell merged 2 commits into
mainfrom
claude/fp-semaphore-conn-close
Jun 23, 2026
Merged

fix(extractor): recognize .Close() as a field release (receiver-scoped) — mined Npgsql#91
PhysShell merged 2 commits into
mainfrom
claude/fp-semaphore-conn-close

Conversation

@PhysShell

@PhysShell PhysShell commented Jun 23, 2026

Copy link
Copy Markdown
Owner

Scoped to the uncontentious fix after review.

.Close() releases a field

The field-disposable disposed scan credited a field only on Dispose()/DisposeAsync() — but the local detector (DisposesLocal + the flow detector) has always accepted Dispose/Close/DisposeAsync. That asymmetry flagged a field cleaned up by Close() as a leak. The fix mirrors the local set on both the direct and null-conditional field-release checks.

Mined: ReplicationConnection releases its NpgsqlConnection field via await _npgsqlConnection.Close(async: true) — a genuine Own.NET false positive (the field is released, via Close, not Dispose).

Receiver-scoped (Codex + CodeRabbit): credit only this instance's field or a validated alias via ThisFieldName, so other._conn.Close() on another instance of the same class cannot mark this object's _conn released. Note a field-symbol ContainingType check (as suggested in review) would not catch this — same class ⇒ same ContainingType — so the receiver is keyed syntactically. This also tightens the pre-existing Dispose path.

Dropped from this PR: the SemaphoreSlim exemption

Putting SemaphoreSlim in the shared IsDisposeOptional also affected the flow-locals detector and tripped a deliberate control (FlowLocalsSample.semLeak). A prior ShareX fix explicitly decided SemaphoreSlim stays tracked for method-bounded locals, because reading AvailableWaitHandle allocates a handle that Dispose must release — Codex flagged exactly this. A sound version would be field-scoped + AvailableWaitHandle-gated; deferred as a separate decision.

Regression sample (CloseReleaseSample.cs)

  • ReleasesViaClose._closedConn / _closedConnQ (direct + null-conditional Close()) → silent
  • Controls still warn: LeaksUnclosed._leakedConn (never closed) and ClosesOtherInstanceField._xconn (closed only on another instance — proves receiver-scoping)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Enhanced resource release detection to recognize .Close() as a valid release operation, equivalent to .Dispose().
    • Improved instance field scoping to ensure only the current instance's field closure is credited, not other instances'.
  • Tests

    • Added comprehensive test cases for .Close() release patterns and scope validation.

…es a field (Npgsql re-mine)

Two precision fixes for the field-disposable detector, both surfaced by re-mining
npgsql/npgsql after #88-#90 (which confirmed those three FP classes were cleared):

#2  SemaphoreSlim dispose-optional. SemaphoreSlim.Dispose() only frees a LAZILY-
    allocated wait handle (allocated solely if AvailableWaitHandle is read); the
    common WaitAsync/Release usage allocates nothing, so an undisposed SemaphoreSlim
    field is not a leak. Added to IsDisposeOptional alongside Task/DataTable, which
    share the "Dispose is a no-op / lazy wait handle" rationale. Mined:
    NpgsqlDataSource._setupMappingsSemaphore.

#1  `.Close()` releases a field. The field-disposable `disposed` scan credited a
    field only on Dispose()/DisposeAsync(), but the LOCAL detector (DisposesLocal and
    the flow detector) has always accepted Dispose/Close/DisposeAsync. That asymmetry
    flagged a field cleaned up by Close() as a leak. Mirror the local set — add Close
    to both the direct and null-conditional field-dispose checks — so a Stream /
    DbConnection-style field released by Close() is silent. Mined: Npgsql's
    ReplicationConnection releases its NpgsqlConnection via
    `await _npgsqlConnection.Close(async: true)` (a real Own.NET false positive).

Regression sample SemaphoreAndCloseSample.cs: HoldsSemaphore._gate (SemaphoreSlim)
silent; ReleasesViaClose._closedConn / _closedConnQ (direct + null-conditional Close)
silent; controls still warn — HoldsRealDisposable._ctsControl (a non-optional CTS)
and LeaksUnclosed._leakedConn (a connection-like field never closed). CI asserts all.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED
Comment thread frontend/roslyn/samples/SemaphoreAndCloseSample.cs Fixed
Comment thread frontend/roslyn/samples/SemaphoreAndCloseSample.cs Fixed
@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: bd14239f-4182-452b-ae6e-9fea5d9dd6e4

📥 Commits

Reviewing files that changed from the base of the PR and between d2f9599 and 3648802.

📒 Files selected for processing (3)
  • .github/workflows/ci.yml
  • frontend/roslyn/OwnSharp.Extractor/Program.cs
  • frontend/roslyn/samples/CloseReleaseSample.cs

📝 Walkthrough

Walkthrough

Extends the OwnSharp extractor to recognize Close() as a valid field-release operation alongside Dispose()/DisposeAsync(), and tightens release credit to the current instance via ThisFieldName(). A new sample file CloseReleaseSample.cs defines four test scenarios, and CI is updated to assert the expected OWN001 outcomes.

Changes

Close() Field Release Detection

Layer / File(s) Summary
Sample scenarios for Close() release patterns
frontend/roslyn/samples/CloseReleaseSample.cs
Adds FakeConnection (IDisposable with Close() and Dispose()), ReleasesViaClose (fields released via direct and null-conditional Close(), expected silent), LeaksUnclosed (field never closed/disposed, expected OWN001), and ClosesOtherInstanceField (closes another instance's field, own field never released, expected OWN001).
Extractor: Close() recognition and ThisFieldName scoping
frontend/roslyn/OwnSharp.Extractor/Program.cs
Direct field-release scan now matches Close alongside Dispose/DisposeAsync and uses ThisFieldName(...) instead of FieldName(...) to scope release credit to the current instance. Null-conditional release scan adds Close as an accepted member name.
CI: register sample and assert OWN001 outcomes
.github/workflows/ci.yml
Adds CloseReleaseSample.cs to the extractor input list and extends the post-processing script with assertions: _closedConn/_closedConnQ must be silent, _leakedConn must raise OWN001, and _xconn must still raise OWN001 (cross-instance Close() does not suppress the current object's leak).

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related PRs

  • PhysShell/Own.NET#20: Both modify the owned-disposable field-release detection in Program.cs to recognize additional cleanup patterns (DisposeAsync + ConfigureAwait vs. Close()).
  • PhysShell/Own.NET#86: Both extend null-conditional disposal call recognition on owned fields in the same detection logic area.
  • PhysShell/Own.NET#90: Both change the same OWN001 field-release scan in Program.cs—one credits local-alias disposals, this PR adds Close() with receiver scoping.

Poem

🐇 A bunny hops by the connection pool,
Sniffing for leaks—Close() is now the rule!
_closedConn rests, no OWN001 in sight,
_leakedConn still warned, as is right.
ThisFieldName guards who gets the credit fair,
No cross-instance close can patch your own affair! 🎉

🚥 Pre-merge checks | ✅ 4
✅ 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 clearly and specifically describes the main change: adding .Close() recognition as a field release operation with receiver scoping, and mentions the concrete motivation (Npgsql mining).
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/fp-semaphore-conn-close

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: d2f95994e6

ℹ️ 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".

// SemaphoreSlim.Dispose() only frees a LAZILY-allocated wait handle (allocated solely if
// AvailableWaitHandle is read); the common WaitAsync/Release usage allocates nothing, so an
// undisposed SemaphoreSlim field is not a leak. Mined: Npgsql NpgsqlDataSource._setupMappingsSemaphore.
|| (ns == "System.Threading" && t.Name == "SemaphoreSlim");

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 wait-handle-backed SemaphoreSlim

With this type-only exemption, a SemaphoreSlim field/local is dropped before any usage is inspected. That hides the real leak when code reads AvailableWaitHandle (for example, to pass it to WaitHandle.WaitAny), because the wait handle is then allocated and the semaphore needs disposal to release it; the comment immediately above calls out that allocation path. Please keep SemaphoreSlim candidates tracked when AvailableWaitHandle is accessed rather than returning true for every instance.

Useful? React with 👍 / 👎.

Comment on lines 2275 to 2276
&& m.Name.Identifier.Text is "Dispose" or "DisposeAsync" or "Close"
&& FieldName(m.Expression) is { } df)

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 Require Close() to target this instance's field

Adding Close here means other._conn.Close() now satisfies the release check for the current class: FieldName(m.Expression) strips the receiver and returns _conn for any member access, and C# permits another instance of the same class to access that private field. In that scenario a constructed this._conn that is never closed is marked released, so OWN001 is suppressed; the check should only credit Close() when the receiver is this instance's field or a validated alias.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/roslyn/OwnSharp.Extractor/Program.cs (1)

468-476: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Scope SemaphoreSlim exemption to field-disposable detection only.

At Line 476, adding SemaphoreSlim to shared IsDisposeOptional also affects flow-locals (Line 2686), which suppresses real local leaks and conflicts with the existing semLeak expectation in CI. This should not be a global exemption.

Suggested direction
 static bool IsDisposeOptional(ITypeSymbol t)
 {
     var ns = t.ContainingNamespace?.ToString();
     return (ns == "System.Threading.Tasks" && t.Name is "Task" or "ValueTask")
         || (ns == "System.Data" && t.Name is "DataTable" or "DataSet" or "DataView")
-        || (ns == "System.Threading" && t.Name == "SemaphoreSlim");
+        ;
 }
+
+static bool IsFieldDisposeOptional(ITypeSymbol t)
+{
+    var ns = t.ContainingNamespace?.ToString();
+    return IsDisposeOptional(t)
+        || (ns == "System.Threading" && t.Name == "SemaphoreSlim");
+}
 
 // field path
-return ImplementsIDisposable(sym) && !IsDisposeOptional(sym);
+return ImplementsIDisposable(sym) && !IsFieldDisposeOptional(sym);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/roslyn/OwnSharp.Extractor/Program.cs` around lines 468 - 476, The
SemaphoreSlim exemption in the IsDisposeOptional method is being applied
globally to both field-disposable detection and flow-locals analysis, which
suppresses real leaks in local variables and breaks the existing semLeak test
expectation. Remove the SemaphoreSlim condition from the IsDisposeOptional
method at Line 476 (the condition checking ns == "System.Threading" && t.Name ==
"SemaphoreSlim"), and instead add this exemption only in the field-disposable
detection logic around Line 2686 to keep the exemption scoped to fields only.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/roslyn/OwnSharp.Extractor/Program.cs`:
- Around line 2273-2278: The issue is that the field name fallback (the df
variable) in the disposed.Add call allows calls like other._conn.Close() to
incorrectly mark this class's _conn field as released, creating false negatives
for OWN001 detection. To fix this, restrict the Close method (added at line
2275) to only credit releases when there is a proven local symbol alias mapping
through aliasToField, rather than using the field name fallback. Modify the
logic so that for Close calls specifically, only add to the disposed collection
when the ILocalSymbol branch succeeds with the aliasToField lookup, and skip the
fallback to df for Close. Apply the same restriction to the similar code block
mentioned at lines 2285-2290.

---

Outside diff comments:
In `@frontend/roslyn/OwnSharp.Extractor/Program.cs`:
- Around line 468-476: The SemaphoreSlim exemption in the IsDisposeOptional
method is being applied globally to both field-disposable detection and
flow-locals analysis, which suppresses real leaks in local variables and breaks
the existing semLeak test expectation. Remove the SemaphoreSlim condition from
the IsDisposeOptional method at Line 476 (the condition checking ns ==
"System.Threading" && t.Name == "SemaphoreSlim"), and instead add this exemption
only in the field-disposable detection logic around Line 2686 to keep the
exemption scoped to fields only.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: a766b06b-8044-47d0-b125-0b019e022786

📥 Commits

Reviewing files that changed from the base of the PR and between f9605da and d2f9599.

📒 Files selected for processing (3)
  • .github/workflows/ci.yml
  • frontend/roslyn/OwnSharp.Extractor/Program.cs
  • frontend/roslyn/samples/SemaphoreAndCloseSample.cs

Comment thread frontend/roslyn/OwnSharp.Extractor/Program.cs
…ped); drop the SemaphoreSlim exemption

Scopes this PR to the uncontentious fix and addresses the review.

#1 `.Close()` releases a field — the field-disposable `disposed` scan credited a
   field only on Dispose()/DisposeAsync(), but the LOCAL detector (DisposesLocal and
   the flow detector) has always accepted Dispose/Close/DisposeAsync. That asymmetry
   flagged a field cleaned up by Close() as a leak. Mirror the local set on both the
   direct and null-conditional field-release checks. Mined: Npgsql ReplicationConnection
   releases its NpgsqlConnection field via `await _npgsqlConnection.Close(async: true)`.

   Receiver-scoped (Codex/CodeRabbit): credit only THIS instance's field or a validated
   alias via ThisFieldName, so `other._conn.Close()` on ANOTHER instance of the SAME
   class cannot mark this object's `_conn` released. (A field-symbol ContainingType
   check would NOT catch this — same class, same ContainingType — so the receiver is
   keyed syntactically; this also tightens the pre-existing Dispose path.)

Dropped: the SemaphoreSlim dispose-optional change. Putting SemaphoreSlim in the
shared IsDisposeOptional also affected the flow-locals detector and tripped the
deliberate `semLeak` control (a prior ShareX fix explicitly decided SemaphoreSlim
stays tracked for method-bounded locals, since reading AvailableWaitHandle allocates
a handle Dispose must release — Codex flagged the same). A sound, field-scoped +
AvailableWaitHandle-gated version is a separate decision, deferred.

Regression sample CloseReleaseSample.cs: ReleasesViaClose._closedConn / _closedConnQ
(direct + null-conditional Close) silent; controls still warn — LeaksUnclosed._leakedConn
(never closed) and ClosesOtherInstanceField._xconn (closed only on ANOTHER instance).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED
@PhysShell PhysShell changed the title fix(extractor): SemaphoreSlim dispose-optional + .Close() releases a field (Npgsql re-mine) fix(extractor): recognize .Close() as a field release (receiver-scoped) — mined Npgsql Jun 23, 2026
// control: a field NEITHER closed NOR disposed must STILL warn OWN001.
public sealed class LeaksUnclosed : IDisposable
{
private readonly FakeConnection _leakedConn = new FakeConnection();
// This object's own _xconn is never closed, so it STILL leaks.
public sealed class ClosesOtherInstanceField : IDisposable
{
private readonly FakeConnection _xconn = new FakeConnection();
@PhysShell PhysShell merged commit a6c5490 into main Jun 23, 2026
22 checks passed
PhysShell pushed a commit that referenced this pull request Jun 23, 2026
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