Skip to content

flow: opt-in --body-throw-edges tier — body-level (no-try) dispose-not-called-on-throw#95

Merged
PhysShell merged 3 commits into
mainfrom
claude/flow-body-throw-edges
Jun 23, 2026
Merged

flow: opt-in --body-throw-edges tier — body-level (no-try) dispose-not-called-on-throw#95
PhysShell merged 3 commits into
mainfrom
claude/flow-body-throw-edges

Conversation

@PhysShell

@PhysShell PhysShell commented Jun 23, 2026

Copy link
Copy Markdown
Owner

Why (the tiered plan, part 2)

PR #94 landed the sound default: explicit throw modelled as an abnormal exit (no-try dispose-not-called-on-throw + un-bailing validation-throw methods). This is the opt-in second tier for full CodeQL cs/dispose-not-called-on-throw recall on the no-try slice — the part the oracle flagged oracle-only on Serilog (MessageTemplateTextFormatter, a may-throw rendering call) and Npgsql.

What

When --body-throw-edges is set, InjectThrowEdge also treats any escaping body-level may-throw call/new as a throw point (synthesizing a bare method exit as its continuation), not only those inside a try. That reproduces CA2000 / CodeQL's broad dispose-on-throw recall.

Off by default — on purpose. It's the CA2000 firehose (flags even harmless MemoryStream/StringWriter dispose-on-throw), so the shipped posture stays below CA2000. The oracle flips it on to measure full recall without shifting the default. Graduating it to default would need a "managed-only, no OS-handle" dispose-optional expansion (noted in P-016).

Design

  • Read deep in InjectThrowEdge via a static Program.BodyThrowEdges (set once from the flag) rather than threading a bool through the whole LowerFlow* recursion.
  • onThrow stays null at body level → the explicit-throw case from PR flow: model explicit throw as an abnormal exit (body-level dispose-not-called-on-throw) #94 is untouched; the firehose only affects may-throw calls. The two tiers compose without interaction.
  • canEscape gates it so a catch-all-suppressed region still injects nothing.

Tests

New BodyThrowEdgesSample.cs, run in both modes in CI (kept separate so the flag doesn't flood every FlowLocalsSample acquire/use/dispose):

sample default (off) --body-throw-edges
MayThrowLeaks (mtbd) silent OWN001 may not be disposed on every path
AdjacentDisposeClean (adc) silent silent (no intervening may-throw → no edge)

adc proves the edge needs an intervening may-throw — it's not "flag any undisposed-looking local under the flag". Core handling of the {op:return} exit is unchanged (re-validated locally on the firehose IR shape); own-check.sh forwards the flag; P-016 documents the two tiers.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED


Generated by Claude Code

Summary by CodeRabbit

  • New Features
    • Added an opt-in analysis tier (--body-throw-edges) to report additional disposal-path issues when exceptions can bypass normal cleanup.
    • Updated the extraction runner and checks to support the new flag, including correct gating behavior.
  • Documentation
    • Expanded the proposal with clarifications on the new exception/try modeling tiers and when the opt-in changes detection.
  • Tests
    • Added a new sample covering flagged vs. unflagged expectations.
    • CI now validates findings differ only when the flag is enabled.

…ose-not-called-on-throw

PR #94 modelled the SOUND slice (explicit throw + the existing in-try edges). This
adds the OPT-IN firehose for full CodeQL cs/dispose-not-called-on-throw parity on
the no-try slice: when --body-throw-edges is set, InjectThrowEdge also treats any
ESCAPING body-level may-throw call/`new` as a throw point (synthesizing a bare
method exit as its continuation), not only those inside a `try`.

OFF by default: it is the CA2000 firehose (flags even harmless MemoryStream/
StringWriter dispose-on-throw), so the shipped posture stays low-FP; the oracle
flips it on to MEASURE full recall without shifting the default. Read deep in
InjectThrowEdge via a static Program.BodyThrowEdges (set from the flag) rather than
threaded through the whole LowerFlow* recursion — the explicit-throw case (onThrow
stays null at body level) is untouched, so PR #94's behaviour is unaffected.

own-check.sh forwards --body-throw-edges. New sample BodyThrowEdgesSample (run in
BOTH modes in CI) proves the flag gates it: MayThrowLeaks 'mtbd' leaks ONLY under
the flag; AdjacentDisposeClean 'adc' (no intervening may-throw) stays silent in
both modes. Core handling of the {op:return} exit is unchanged (re-validated on the
firehose IR shape). P-016 documents the two tiers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED
@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: 613d2311-9866-4723-a800-de7fec254743

📥 Commits

Reviewing files that changed from the base of the PR and between a6d3198 and 74f6842.

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

📝 Walkthrough

Walkthrough

Adds a new opt-in --body-throw-edges flag to OwnSharp.Extractor that injects exceptional-exit edges for escaping body-level may-throw statements during flow-locals analysis. The flag is parsed in Program.cs, controlled by a new BodyThrowEdges static field, and threaded through InjectThrowEdge via a canEscape parameter. It is exposed through own-check.sh, validated with a new BodyThrowEdgesSample.cs, exercised in a new CI step, and documented in the P-016 proposal.

Changes

--body-throw-edges opt-in tier

Layer / File(s) Summary
CLI flag parsing and BodyThrowEdges static field
frontend/roslyn/OwnSharp.Extractor/Program.cs
Adds --body-throw-edges to argument parsing, introduces internal static bool BodyThrowEdges on partial class Program, and prints a non-fatal warning when the flag is set without --flow-locals.
InjectThrowEdge canEscape extension and call-site updates
frontend/roslyn/OwnSharp.Extractor/Program.cs
Extends InjectThrowEdge with a canEscape parameter that synthesizes a return-like continuation when BodyThrowEdges is enabled and the statement can escape; updates call sites in the using-declaration, local-declaration, and expression-statement flow-lowering paths.
Test sample with dispose and throw patterns
frontend/roslyn/samples/BodyThrowEdgesSample.cs
Adds BodyThrowEdgesSample.cs with three methods: MayThrowLeaks models a disposal that can be skipped on a potential throw, AdjacentDisposeClean models immediate disposal with no intervening throws, and MayThrowInFinallyClean models nested try/finally where cleanup is guaranteed despite inner throws.
Script wiring, CI step, and proposal docs
scripts/own-check.sh, .github/workflows/ci.yml, docs/proposals/P-016-deep-fact-extraction.md
Extends own-check.sh to parse and forward --body-throw-edges to the extractor; adds a CI step asserting correct oracle findings in both default and opt-in modes; documents the two-tier dispose-not-called-on-throw model in P-016.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • PhysShell/Own.NET#10: Both PRs modify scripts/own-check.sh to pass flags to OwnSharp.Extractor, with the main PR adding the new --body-throw-edges forwarding.
  • PhysShell/Own.NET#15: Both PRs extend the Roslyn extractor's --flow-locals pipeline in Program.cs, with the main PR adding --body-throw-edges-gated throw-edge injection to the same flow-lowering codepath.
  • PhysShell/Own.NET#94: Both PRs address synthetic abnormal-exit modeling in Program.cs flow-lowering, with the main PR building on the same canEscape-gated continuation synthesis mechanism for body-level throws.

Poem

🐇 A flag on the path, a throw in the air,
--body-throw-edges now handles the scare.
The stream may escape when a WriteByte flies,
But AdjacentDisposeClean stays clean in our eyes.
Two tiers, one rabbit, no leaks left to find! 🎉

🚥 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 describes the main change: implementing an opt-in --body-throw-edges flag for dispose-not-called-on-throw checking at the body level, which is the core focus of this pull request.
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-body-throw-edges

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

ℹ️ 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 +614 to +616
var cont = onThrow ?? (BodyThrowEdges && canEscape
? new List<object> { new { op = "return", var = (string?)null, line = LineOf(st) } }
: null);

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 Avoid injecting bare exits from nested finally blocks

When --body-throw-edges is enabled, a may-throw statement lexically inside a finally with no local onThrow now gets a synthetic bare method exit. If that finally is nested under an outer try/finally or under a using declaration, the real exception still runs the outer cleanup, but this inserted return skips it in the IR; for example using var owner = ...; try {} finally { Log(); } can be reported as leaking even though the using cleanup runs when Log() throws. The explicit-throw path already avoids this with IsInsideFinally, but the new may-throw path does not.

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

🤖 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 @.github/workflows/ci.yml:
- Around line 731-734: The default-off assertion only verifies that 'mtbd' is
absent from the output but does not check for 'adc', which means regressions in
'adc' reporting would not be caught. Add an additional assertion similar to the
existing grep check for 'mtbd' to also verify that 'adc' is not present in the
off-mode output, ensuring both MayThrowLeaks and AdjacentDisposeClean remain
silent when the flag is off.

In `@frontend/roslyn/OwnSharp.Extractor/Program.cs`:
- Around line 55-60: The static field BodyThrowEdges is not being reset at the
start of the program, which causes state from previous in-process invocations to
leak into subsequent runs. Reset BodyThrowEdges to false before the command-line
argument parsing loop begins (before the for loop that iterates through args),
and ensure other static configuration variables like emitEvents and flowLocals
are similarly reset to their default values to prevent sticky state across
multiple invocations in the same process.
🪄 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: f2f94efc-5a30-477e-9812-da54161c6a95

📥 Commits

Reviewing files that changed from the base of the PR and between b66aa43 and a6d3198.

📒 Files selected for processing (5)
  • .github/workflows/ci.yml
  • docs/proposals/P-016-deep-fact-extraction.md
  • frontend/roslyn/OwnSharp.Extractor/Program.cs
  • frontend/roslyn/samples/BodyThrowEdgesSample.cs
  • scripts/own-check.sh

Comment thread .github/workflows/ci.yml
Comment thread frontend/roslyn/OwnSharp.Extractor/Program.cs
claude added 2 commits June 23, 2026 13:45
…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
…process state)

BodyThrowEdges is static and was only ever SET, so a prior IN-PROCESS invocation
with --body-throw-edges could leak into a later run that did not request it (the
CLI normally runs as a fresh process, but a static set-once field is fragile if it
is ever embedded/tested in-process). Reset it to false before arg parsing. The other
config (emitEvents/flowLocals/reportStats) are locals, re-initialized each call, so
only this static field needs the reset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED
@PhysShell
PhysShell merged commit 3622fe2 into main Jun 23, 2026
22 checks passed
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.

2 participants