Skip to content

fix(extractor): model TcpListener.Stop() as a release (Stop is the cleanup, not a leak)#62

Merged
PhysShell merged 1 commit into
mainfrom
claude/tcplistener-stop-release
Jun 21, 2026
Merged

fix(extractor): model TcpListener.Stop() as a release (Stop is the cleanup, not a leak)#62
PhysShell merged 1 commit into
mainfrom
claude/tcplistener-stop-release

Conversation

@PhysShell

@PhysShell PhysShell commented Jun 21, 2026

Copy link
Copy Markdown
Owner

The false positive (Codex catch on #61)

Triaging the ShareX re-mine, I nearly locked an undisposed-TcpListener finding as a corpus regression — Codex caught that it's a false positive, and it was right:

public static int GetRandomUnusedPort()   // ShareX WebHelpers.cs:191
{
    TcpListener listener = new TcpListener(IPAddress.Loopback, 0);
    try { listener.Start(); return ((IPEndPoint)listener.LocalEndpoint).Port; }
    finally { listener.Stop(); }
}

TcpListener is IDisposable, and Stop() isn't in our release set — so the flow detector flagged listener OWN001. But TcpListener.Dispose() just delegates to Stop() (which disposes the listen socket). After Stop() there's no held resource — so it's not a leak, and flagging it dents the 0-FP precision claim.

The fix

EmitFlowExpr now models tcpListener.Stop() as a release, alongside the Dispose()/Close()/pool-Return/Show() releases:

if (expr is InvocationExpressionSyntax tlinv
    && tlinv.Expression is MemberAccessExpressionSyntax
        { Name.Identifier.Text: "Stop", Expression: IdentifierNameSyntax tlid }
    && tracked.Contains(tlid.Identifier.Text)
    && model.GetSymbolInfo(tlinv).Symbol is IMethodSymbol { ContainingType: { Name: "TcpListener" } tct }
    && IsInNamespace(tct, "System", "Net", "Sockets"))
{ nodes.Add(new { op = "release", var = tlid.Identifier.Text, line = LineOf(tlinv) }); return; }

TcpListener-specific (resolved on the method's containing type — Stop() on a Timer/Process/Stopwatch is untouched) and path-sensitive like the WinForms Show() model: a listener never Stop()'d (nor disposed) still leaks.

Pinned in CI

FlowLocalsSample method shape verdict
TcpListenerStopped new TcpListener(...); Start(); Stop(); silent (the FP this removes)
TcpListenerNeverStopped new TcpListener(...); Start(); (no Stop) OWN001 (control — proves the release is Stop()-specific)

The sibling not fixed (StreamReader)

The same triage had a StreamReader-over-using'd-stream finding — also a non-leak in that shape. But unlike TcpListener, the extractor's general behaviour there is correct: a StreamReader over a stream not otherwise disposed genuinely leaks it. Suppressing it would need stream-ownership reasoning and cost real recall, so it's left as a rare accepted residual (not a systematic FP class). Detailed in docs/notes/tcplistener-stop-release.md.

This supersedes the closed #61 (which would have locked these FPs).

🤖 Generated with Claude Code

https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED


Generated by Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Enhanced resource disposal tracking for TcpListener instances to improve diagnostic accuracy
    • TcpListener.Stop() method is now recognized as a valid resource release operation
  • Documentation

    • Added documentation explaining TcpListener disposal semantics and the relationship between Stop() and Dispose() methods

…eanup)

Codex review on #61: triaging the ShareX re-mine, the undisposed-TcpListener finding
(WebHelpers.GetRandomUnusedPort: Start() then Stop() in a finally, no Dispose()) is a FALSE
POSITIVE, not a leak. TcpListener.Dispose() just delegates to Stop(), which disposes the
listen socket and clears it — so a Stop()'d listener holds no resource. Flagging it OWN001
dents the 0-FP precision claim (CA2000 / CodeQL flag it conventionally, but it is not a real
resource leak).

EmitFlowExpr now models tcpListener.Stop() as a release of the local, alongside the
Dispose()/Close()/pool-Return/Show() releases. TcpListener-specific, resolved on the
method's containing type via the SemanticModel (Stop() on a Timer / Process / Stopwatch /
etc. does NOT dispose and stays a tracked use), and path-sensitive like the WinForms Show()
model: a listener never Stop()'d (nor disposed) still leaks.

Pinned by two FlowLocalsSample cases in the wpf-extractor --flow-locals step:
TcpListenerStopped (Start() + Stop() -> silent, the FP this removes) and
TcpListenerNeverStopped (Start(), no Stop -> OWN001, the control proving the release is
Stop()-specific). Validated locally: tcpLeak -> OWN001, stopped silent.

The sibling StreamReader FP from the same triage is deliberately NOT changed: unlike
TcpListener, the extractor's general behaviour there is correct (a StreamReader over a stream
not otherwise disposed genuinely leaks the stream); only the narrow using-scoped-stream
variant is a non-leak, a rare accepted residual. See docs/notes/tcplistener-stop-release.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED
// exemption (a Timer/Process Stop() would NOT release).
public void TcpListenerNeverStopped()
{
var tcpLeak = new TcpListener(IPAddress.Loopback, 0);
@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: 29441158-0d73-4fc8-8ace-73cc6c9ef13b

📥 Commits

Reviewing files that changed from the base of the PR and between cfcf7f6 and 1d6c475.

📒 Files selected for processing (4)
  • .github/workflows/ci.yml
  • docs/notes/tcplistener-stop-release.md
  • frontend/roslyn/OwnSharp.Extractor/Program.cs
  • frontend/roslyn/samples/FlowLocalsSample.cs

📝 Walkthrough

Walkthrough

Adds TcpListener.Stop()-as-release detection to the Roslyn extractor's EmitFlowExpr method, treating a Stop() call on a tracked System.Net.Sockets.TcpListener local as equivalent to disposal. Two sample methods and CI selftest assertions cover the stopped (silent) and never-stopped (OWN001 leak) cases. A design note documents the rationale and scope.

Changes

TcpListener.Stop() release detection

Layer / File(s) Summary
EmitFlowExpr: TcpListener.Stop() emits release
frontend/roslyn/OwnSharp.Extractor/Program.cs
When a tracked local's .Stop() is invoked and resolves to System.Net.Sockets.TcpListener, an OwnIR release is emitted and the function returns early before the existing null-conditional dispose handling.
FlowLocalsSample: stopped and never-stopped cases
frontend/roslyn/samples/FlowLocalsSample.cs
Adds using System.Net / using System.Net.Sockets, plus TcpListenerStopped (creates, starts, stops — expected silent) and TcpListenerNeverStopped (creates, starts, no stop — expected OWN001 leak).
CI selftest assertions and allowlist
.github/workflows/ci.yml
Asserts TcpListenerNeverStopped raises OWN001 for tcpLeak while the stopped case stays silent; adds stopped to the selftest's silent-case allowlist.
Design note
docs/notes/tcplistener-stop-release.md
Documents why TcpListener.Stop() is modeled as a release, the namespace-qualified and path-sensitive scope of the change, and the intentional non-suppression of the related StreamReader scenario.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related PRs

  • PhysShell/Own.NET#15: Introduced the initial flow-locals (--flow-locals) mode in the Roslyn extractor that this PR extends with TcpListener.Stop() release handling.
  • PhysShell/Own.NET#34: Added null-conditional dispose-as-release handling in EmitFlowExpr — the same code region where TcpListener.Stop()-as-release is now inserted before it.
  • PhysShell/Own.NET#57: Added Form.Show()-as-release in the same invocation-lowering logic, establishing the pattern this PR follows for TcpListener.Stop().

Poem

🐇 Hop, hop! A listener once ran free,
Stop() called — and now it's set to flee.
No OWN001 where the socket's hushed,
But never-stopped? The leak flag's flushed!
In namespaces precise I dwell,
False positives begone — all's well. 🎉

🚥 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 clearly and specifically summarizes the main change: modeling TcpListener.Stop() as a release operation to fix a false positive in the resource leak detector.
Docstring Coverage ✅ Passed Docstring coverage is 100.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/tcplistener-stop-release

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

@PhysShell
PhysShell merged commit 8f69f46 into main Jun 21, 2026
22 checks passed

@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: 1d6c475392

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

&& model.GetSymbolInfo(tlinv).Symbol is IMethodSymbol { ContainingType: { Name: "TcpListener" } tct }
&& IsInNamespace(tct, "System", "Net", "Sockets"))
{
nodes.Add(new { op = "release", var = tlid.Identifier.Text, line = LineOf(tlinv) });

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 Model Start after Stop as reacquire

When callers reuse a listener (listener.Stop(); listener.Start(); ...), this release leaves the local in the released state forever. TcpListener.Stop closes the current socket and the next Start creates a new one on the same object (see Microsoft docs: https://learn.microsoft.com/en-us/dotnet/api/system.net.sockets.tcplistener.stop), so a valid restart followed by a final Stop/Dispose will be reported as OWN002/OWN003, while a restart without final cleanup can be reported as use-after-release instead of the real OWN001 leak. Please either model Start as a reacquire after Stop or avoid treating Stop as a terminal release when the object is reused.

Useful? React with 👍 / 👎.

Comment on lines +721 to +726
if (expr is InvocationExpressionSyntax tlinv
&& tlinv.Expression is MemberAccessExpressionSyntax
{ Name.Identifier.Text: "Stop", Expression: IdentifierNameSyntax tlid }
&& tracked.Contains(tlid.Identifier.Text)
&& model.GetSymbolInfo(tlinv).Symbol is IMethodSymbol { ContainingType: { Name: "TcpListener" } tct }
&& IsInNamespace(tct, "System", "Net", "Sockets"))

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 Treat TcpListener.Stop as release-shaped in try blocks

Because this release predicate only lives in EmitFlowExpr, StatementMayThrow still treats TcpListener.Stop() as an arbitrary throwing call and injects an exceptional exit before the cleanup when it appears inside a try. That creates a synthetic path that skips the release, so shapes like try { listener.Stop(); } finally { } can still produce OWN001 even though this change is explicitly modeling Stop as cleanup; the Stop predicate needs to be shared with the dispose-shaped/throw-edge logic too.

Useful? React with 👍 / 👎.

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