Skip to content

flow: model explicit throw as an abnormal exit (body-level dispose-not-called-on-throw)#94

Merged
PhysShell merged 2 commits into
mainfrom
claude/flow-explicit-throw
Jun 23, 2026
Merged

flow: model explicit throw as an abnormal exit (body-level dispose-not-called-on-throw)#94
PhysShell merged 2 commits into
mainfrom
claude/flow-explicit-throw

Conversation

@PhysShell

@PhysShell PhysShell commented Jun 23, 2026

Copy link
Copy Markdown
Owner

Why

The cross-tool oracle on Serilog and Npgsql both surfaced CodeQL's cs/dispose-not-called-on-throw as oracle-only (a recall gap). Tracing it: dispose-not-called-on-throw is already modelled — but only inside a try (InjectThrowEdge fires only when onThrow != null, which holds only in a try body). The gap is the body-level, no-try slice.

Worse, an explicit throw statement hit the unmodelled default and bailed the WHOLE method — so any leak in a method guarded by a top-level validation throw (if (x is null) throw …; — everywhere in real .NET) was invisible to every flow detector.

What

Model a throw statement with no enclosing try (canEscape && onThrow is null) as a bare CFG exit — the same synthetic {op:return} exit the injected may-throw edges already use. A resource owned there and disposed only later leaks on the throw path.

Two wins, both sound / FP-free (a throw with no enclosing try definitely exits, so an owned resource definitely leaks):

  • No-try dispose-not-called-on-throwacquire; if (bad) throw; dispose; leaks on the throw path (dotNoTry, "may not be disposed on every path").
  • Un-bailing validation-throw-guarded methods — a later undisposed local that the early throw used to hide is now caught (vtl, "is never disposed"), and so is everything else the rest of the body contains.

Stays conservative inside a try: an explicit throw there may run a finally or be caught (typed / catch-all), which needs the thrown-type-vs-catch match (not threaded here), so it keeps bailing — no new false escape past a catch.

Tests

FlowLocalsSample.cs (asserted end-to-end in CI — real extractor → core):

sample local verdict
ValidatedThenLeaks vtl OWN001 is never disposed (recall: un-bail)
ThrowAfterAcquireLeaks dotNoTry OWN001 may not be disposed on every path (no-try dispose-on-throw)
ThrowAfterDisposeClean tdClean silent (release before the throw)
ValidatedThenClean vtc silent (analysed + balanced — un-bail must not over-flag)

Core handling of the {op:return} exit is unchanged (already pinned by the existing return/leak fixtures, and re-validated against this IR shape locally); the C# lowering is the only new behaviour, covered by the CI flow step.

Scope / posture

This is the sound default half of a tiered plan. The broader "any call may throw" at body level = CodeQL's full recall, but it's the CA2000 firehose (would flag harmless MemoryStream/StringWriter dispose-on-throw, needs a fuzzy managed-only exemption) — so it'll land separately behind an opt-in flag (off by default), letting the oracle measure full recall without polluting the default. Default posture (lower FP than CA2000) is preserved here.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED


Generated by Claude Code

Summary by CodeRabbit

  • New Features

    • Improved flow-sensitive analysis for methods with explicit throw, including cases with validation throws and throws that occur inside finally.
    • More accurate control-flow modeling for throw in switch sections.
  • Tests

    • Expanded CI assertions to cover additional leak/disposal edge cases and expected “silent” scenarios.
  • Samples

    • Added new flow-locals sample methods demonstrating how resource disposal behaves on different throw paths.

…-not-called-on-throw (no try)

An explicit `throw` statement hit the unmodelled `default` and bailed the WHOLE
method, so any leak in a method guarded by a top-level validation throw
(`if (x is null) throw …;`) was invisible to every flow detector. Model a `throw`
with no enclosing try (onThrow null && canEscape) as a bare CFG exit: a resource
owned there and disposed only later leaks on the throw path — exactly the
synthetic exit the injected may-throw edges already use.

Two wins, both sound (a throw with no enclosing try definitely exits, so an owned
resource definitely leaks — FP-free):
  * the no-try slice of CodeQL's cs/dispose-not-called-on-throw: `acquire;
    if (bad) throw; dispose;` leaks on the throw path (dotNoTry, partial-path);
  * un-bailing every validation-throw-guarded method, lighting up all detectors
    on the rest of the body (vtl, a later undisposed local, now caught).

Stays conservative inside a try: an explicit throw there may run a finally or be
caught (typed/catch-all), which needs the thrown-type-vs-catch match (not threaded
here), so it keeps bailing — no new false escape past a catch.

Samples (FlowLocalsSample): ValidatedThenLeaks (vtl, never-disposed, recall),
ThrowAfterAcquireLeaks (dotNoTry, partial-path), + ThrowAfterDisposeClean (release
before throw) and ValidatedThenClean (balanced) as silent controls. Core handling
of the {op:return} exit is unchanged (already pinned); the C# lowering is asserted
end-to-end in CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED
public void ValidatedThenLeaks(object arg)
{
if (arg is null) throw new ArgumentNullException(nameof(arg));
var vtl = new MemoryStream();
// throw path -> OWN001 "may not be disposed on every path". The fix is `using`.
public void ThrowAfterAcquireLeaks(bool bad)
{
var dotNoTry = new MemoryStream();
@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: 97e31ecd-d496-40c3-a015-a68ffcfb46ac

📥 Commits

Reviewing files that changed from the base of the PR and between 7f598b0 and 09d3365.

📒 Files selected for processing (3)
  • .github/workflows/ci.yml
  • frontend/roslyn/OwnSharp.Extractor/Program.cs
  • frontend/roslyn/samples/FlowLocalsSample.cs
🚧 Files skipped from review as they are similar to previous changes (3)
  • .github/workflows/ci.yml
  • frontend/roslyn/OwnSharp.Extractor/Program.cs
  • frontend/roslyn/samples/FlowLocalsSample.cs

📝 Walkthrough

Walkthrough

LowerFlowStmt in the Roslyn extractor now handles ThrowStatementSyntax as a modelled abnormal exit when the throw escapes the method without an enclosing try or inside a finally block. An IsInsideFinally helper distinguishes throws in finally clauses. Four sample methods are added to FlowLocalsSample.cs covering leak and no-leak scenarios, and CI assertions are updated accordingly.

Changes

Throw-exit flow modeling and validation

Layer / File(s) Summary
Throw-exit infrastructure and lowering
frontend/roslyn/OwnSharp.Extractor/Program.cs
Adds IsInsideFinally helper to detect whether a throw is lexically inside a finally clause. Introduces a ThrowStatementSyntax case in LowerFlowStmt that, when canEscape is true, onThrow is null, and the throw is not in finally, emits a synthetic return node (modeled exit); otherwise returns failure. Switch-section comment clarified to reflect that bare throw is now modeled.
Sample methods demonstrating throw-exit behavior
frontend/roslyn/samples/FlowLocalsSample.cs
Adds four public sample methods (ValidatedThenLeaks, ThrowAfterAcquireLeaks, ThrowAfterDisposeClean, ValidatedThenClean) with explanatory comment block demonstrating how explicit body-level throw statements affect whether MemoryStream acquisition is disposed on all paths (two leak scenarios and two non-leak scenarios).
CI assertions and silent-case validation
.github/workflows/ci.yml
Adds assertions that vtl and dotNoTry cases produce OWN001 diagnostics with correct "is never disposed" and "may not be disposed on every path" wording. Extends the expected-silent allowlist to include tdClean (dispose before throw), vtc (acquire after top-level validation throw), and tif (throw inside inner finally).

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • PhysShell/Own.NET#15: Introduced the flow-locals lowering and initial FlowLocalsSample coverage that this PR extends.
  • PhysShell/Own.NET#31: Modified LowerFlowStmt's TryStatementSyntax handling and try/finally exceptional-path modeling, directly adjacent code region to the throw-exit modeling this PR adds.
  • PhysShell/Own.NET#33: Extended onThrow threading and exceptional-exit behavior in LowerFlowStmt and added corresponding FlowLocalsSample cases, overlapping the same lowering and sample code areas.

Poem

🐇 A throw escapes with no try to catch,
I model the exit, confirm the match.
If the stream isn't closed before you flee,
OWN001 warns: "that leak's on thee!"
But dispose it first and the path stays clean—
The tidiest flow this rabbit has seen. ✨

🚥 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 accurately summarizes the main change: modeling explicit throw statements as abnormal exits for dispose-not-called-on-throw detection.
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/flow-explicit-throw

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

@PhysShell

Copy link
Copy Markdown
Owner Author

@codex yo bro

@chatgpt-codex-connector

Copy link
Copy Markdown

Summary

  • No code changes were needed for the trigger @codex yo bro.
  • Working tree is clean, so I did not commit or create a PR.

Testing

  • git status --short
  • git branch --show-current
  • git log --oneline -3

View task →

@PhysShell

Copy link
Copy Markdown
Owner Author

@codex review

@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: 7f598b0043

ℹ️ 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 +965 to +968
if (canEscape && onThrow is null)
{
nodes.Add(new { op = "return", var = (string?)null, line = LineOf(thr) });
return true;

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 Keep throws in finally from bypassing enclosing cleanup

This treats onThrow is null as proof that no enclosing finally must run, but finallyNodes are currently lowered with the default arguments, so a throw inside an inner finally also takes this branch. In a valid shape such as var s = new MemoryStream(); try { try { } finally { throw new InvalidOperationException(); } } finally { s.Dispose(); }, the outer finally disposes s, but the generated bare return terminates before the outer finally release and can report a false OWN001; either thread the enclosing continuation when lowering finally blocks or keep bailing on those throws.

Useful? React with 👍 / 👎.

…outer finally (Codex P2)

The explicit-throw exit keyed on `onThrow is null && canEscape`, but `finally`
bodies are lowered with the default (null) onThrow — so a `throw` INSIDE a finally
also took the bare-return branch, terminating before any ENCLOSING finally runs.
For `try { try {} finally { throw; } } finally { s.Dispose(); }` that bare exit
skips the outer `s.Dispose()` and falsely reports OWN001 on `s`.

Guard the body-level throw branch with `!IsInsideFinally(thr)`: a throw lexically
inside a finally keeps bailing the whole method (the sound honest-skip it had
before this feature), since its real continuation is the OUTER cleanup the bare
exit cannot run. Body-level throws (no enclosing try/finally) are unaffected.

Sample ThrowInFinallyBails pins the Codex repro as silent; the four body-level
throw samples keep their verdicts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED
@PhysShell PhysShell merged commit b66aa43 into main Jun 23, 2026
22 checks passed
PhysShell pushed a commit that referenced this pull request Jun 23, 2026
…rehose (Codex P2)

Symmetric to the explicit-throw fix on PR #94: under --body-throw-edges, a may-throw
call lexically inside a `finally` is lowered with a null onThrow, so InjectThrowEdge
synthesized a bare method exit for it — skipping any ENCLOSING finally/using cleanup
and falsely flagging a resource the outer finally disposes (Codex P2:
`using var o = ...; try {} finally { Log(); }`).

Guard the synthesized body-level continuation with `!IsInsideFinally(st)` — the same
predicate the explicit-throw path already uses. A finally-internal may-throw now gets
no firehose edge (its real continuation is the outer cleanup), so the flag-on
behaviour there matches flag-off. Body-level may-throws (not in a finally) unaffected.

Sample MayThrowInFinallyClean ('mtf', disposed by the outer finally) pins it silent
in both modes; CI asserts mtbd/mtf/adc silent in off-mode and adc/mtf silent under
the flag.

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 23, 2026
… recall on Npgsql v8.0.9

Bring zen-pasteur's extractor/own-check/core current with main (PR #94/#95 — the
explicit-throw model + the opt-in --body-throw-edges tier), plumb body_throw_edges=
through oracle.yml (workflow_dispatch input + sentinel parse + own-check append), and
flip it on for the Npgsql v8.0.9 oracle. Clean before/after vs the baseline
(Own.NET 10 / CodeQL 19 / Infer# 73, Agree 0): measures how much body-level
dispose-not-called-on-throw recall the firehose recovers (new Agreements) vs the
CA2000 noise it adds (own-only jump) — both halves of the opt-in tradeoff.

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