Skip to content

feat(extractor): exception-edge recall slice — nested try, ctor throw-points, typed catches#33

Merged
PhysShell merged 2 commits into
mainfrom
claude/zen-pasteur-76hfs1
Jun 18, 2026
Merged

feat(extractor): exception-edge recall slice — nested try, ctor throw-points, typed catches#33
PhysShell merged 2 commits into
mainfrom
claude/zen-pasteur-76hfs1

Conversation

@PhysShell

@PhysShell PhysShell commented Jun 18, 2026

Copy link
Copy Markdown
Owner

Lands the exception-edge recall slice deferred from #32. Three sound recall gaps in the try exception-edge model are closed — all matching CodeQL's cs/dispose-not-called-on-throw, none introducing a false positive (missed leaks become caught; nothing safe starts being flagged).

What changed

Recall gap How it's closed CI-pinned sample
Nested compound statements Edges now recurse into if/loop/block bodies and are injected before the nested leaf statement — where the resource's ownership is exact (after any in-branch dispose). The throw continuation (onThrow) is threaded through the recursion and composes across nested tries (inner finally → outer finallyreturn); a canEscape flag propagates an enclosing catch-all's swallow, so no edge is injected in a region whose throws can never reach method exit. nestedLeak leaks; cif (DisposeInsideIfWithThrow) now stays silent for the right reason — leaf-level placement, not by skipping compounds
Constructor (new) as a throw point A throwing constructor skips a prior owned resource's dispose; StatementMayThrow now treats object creation as may-throw. The edge lands before the resource's own acquire (harmless — not yet owned there). ctorPrior leaks; ctorLater stays silent
Typed / filtered catches A non-tail catch no longer blanket-suppresses edges. Only a genuine catch-all (catch {} / catch (Exception), no when filter) does; a typed catch lets the uncaught exception types propagate past the post-try dispose and really leak. typedLeak leaks

Validation

  • Core IR pinned locally — a new tests/fixtures/ownir/flow_nested_throw.facts.json fixture encodes the exact lowered IR the extractor now emits for all three shapes; test_ownir.py asserts the verdicts (nestedLeak + ctorPrior leak, cif + ctorLater silent). Suite is 52/52 bridge checks (was 51).
  • C# lowering — validated end-to-end in CI: new golden assertions for the three leaks, plus ctorLater added to the must-stay-silent allowlist. Every existing try-sample keeps its verdict, and the --stats coverage invariant still holds.
  • Each new sample's lowering was hand-traced against its fixture — they match exactly, so the core verdicts above are what CI will see.

Deferred (still sound recall gaps)

finally-before-return threading (bailed today) and switch/do. Docs (ROADMAP + real-world-mining note) updated to move the three landed items out of the deferred list.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED


Generated by Claude Code

Summary by CodeRabbit

  • Documentation

    • Updated the explanation of how exception-edge recall works for try/catch blocks, including constructor-throw behavior and how typed/filtered catches affect injected exception paths.
  • Improvements

    • Strengthened resource leak detection on exceptional control flow in local IDisposable scenarios, especially for nested exception edges and new/constructor-throw cases where disposal can be skipped.
    • Expanded validated scenarios to better match expected leak vs. non-leak outcomes for typed catch behavior.

…-points, typed catches

Lands the deferred exception-edge recall slice from PR #32. Three sound recall
gaps (missed leaks, never false positives) in the try exception-edge model close:

- Nested compound statements: edges now recurse into if/loop/block bodies and are
  injected before the nested LEAF statement — where the resource's ownership is
  exact (after any in-branch dispose). This catches a throw-before-dispose inside a
  branch while keeping the dispose-before-throw shape silent (DisposeInsideIfWithThrow
  'cif' stays silent for this better reason now, not by skipping compounds). The throw
  continuation is threaded (onThrow) and composes across nested tries (inner finally,
  then outer, then return); a canEscape flag propagates an enclosing catch-all's
  swallow so no edge is injected in a region whose throws never reach method exit.
- Constructor (new) as a throw point: a throwing ctor skips a PRIOR owned resource's
  dispose; StatementMayThrow now treats object creation as may-throw. The edge lands
  before the resource's own acquire (harmless — not yet owned there).
- Typed/filtered catches: a non-tail catch no longer blanket-suppresses edges. Only a
  genuine catch-all (catch {} / catch (Exception), no when-filter) does; a typed catch
  lets the uncaught exception types propagate past the post-try dispose and really leak.

All three match CodeQL's cs/dispose-not-called-on-throw. Validated end-to-end in CI on
new samples (nestedLeak, ctorPrior, typedLeak leak; ctorLater stays silent) and on the
core IR via the flow_nested_throw ownir fixture (52/52 bridge checks). Every existing
try-sample keeps its verdict. Remaining deferred: finally-before-return and switch/do.

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 18, 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: c065b69f-886e-4a62-bdf4-9e117a1b57e9

📥 Commits

Reviewing files that changed from the base of the PR and between fc2192d and 83a9d3e.

📒 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 (2)
  • .github/workflows/ci.yml
  • frontend/roslyn/OwnSharp.Extractor/Program.cs

📝 Walkthrough

Walkthrough

The PR refines the --flow-locals lowering pass to inject exceptional-exit if(*) edges at individual may-throw leaf statements inside try blocks, threading canEscape and onThrow continuations through all control-flow constructs. Three new sample methods covering nested-if, constructor-throw, and typed-catch shapes are added, together with a new IR fixture, Python test, CI grep assertions, and updated documentation.

Changes

Exception-edge recall: lowering, samples, tests, and docs

Layer / File(s) Summary
InjectThrowEdge helpers and StatementMayThrow detection
frontend/roslyn/OwnSharp.Extractor/Program.cs
Introduces InjectThrowEdge to emit if(*){onThrow} before leaf statements; expands StatementMayThrow to treat non-dispose calls and object-creation expressions as throw points; updates IsCatchAll for try-escape classification.
LowerFlowStmt refactoring with canEscape/onThrow threading
frontend/roslyn/OwnSharp.Extractor/Program.cs
Refactors LowerFlowStmt from single-parameter to recursive form accepting context parameters; wires local-declaration lowering to call InjectThrowEdge before the acquire step to thread exception continuations through nested blocks.
Control-flow propagation through if, using, while, foreach, for
frontend/roslyn/OwnSharp.Extractor/Program.cs
Updates expression-statement, if, using, while, foreach, and for lowering to propagate canEscape and onThrow into nested statement recursion, preserving escape context through all control-flow structures.
Try-lowering rewrite with escapesThisTry and bodyOnThrow
frontend/roslyn/OwnSharp.Extractor/Program.cs
Completes the refactor by computing escapesThisTry via catch-all detection, deriving bodyOnThrow continuation (finally nodes plus enclosing or default return), and lowering the try body with injected throw edges; removes old EdgeEligible-based injection.
Sample methods for exception-edge scenarios
frontend/roslyn/samples/FlowLocalsSample.cs
Adds NestedThrowLeaks, CtorThrowLeaksPrior, TypedCatchLeaks, QualifiedTypedCatchLeaks, and CtorInLambdaNotThrow covering nested-if ordering, constructor-throw prior-owned skipping, typed-catch propagation, and lambda-deferred new. Updates DisposeInsideIfWithThrow comment and introduces DomainErrors.Exception for typed-catch testing.
IR fixture and Python test validation
tests/fixtures/ownir/flow_nested_throw.facts.json, tests/test_ownir.py
Adds flow_nested_throw.facts.json with IR expectations for three try-shaped scenarios. Adds _NESTED_THROW_FIXTURE constant and test block asserting exactly two OWN001 findings (nestedLeak@201, ctorPrior@217).
CI workflow assertions for recall coverage
.github/workflows/ci.yml
Adds grep assertions for OWN001 emissions from nestedLeak, ctorPrior, typedLeak, and qualLeak. Updates the silent/exempt loop with ctorLater, clarifying it must remain silent because it is acquired after the constructor-throw edge.
Roadmap and mining-notes documentation
docs/ROADMAP.md, docs/notes/real-world-mining.md
Refines ROADMAP milestone wording to describe may-throw leaf placement in nested branches, new as a throw point, and typed-catch handling. Rewrites the exception-edge recall section in real-world-mining.md to document the implemented behavior including catch-all suppression and deferred gaps.

Sequence Diagram(s)

sequenceDiagram
  participant LowerFlowStmt
  participant StatementMayThrow
  participant InjectThrowEdge
  participant TryLower as Try Lowering
  participant IREmitter as IR Emitter

  LowerFlowStmt->>StatementMayThrow: stmt
  StatementMayThrow-->>LowerFlowStmt: mayThrow: true/false
  
  alt mayThrow and canEscape
    LowerFlowStmt->>InjectThrowEdge: stmt, onThrow
    InjectThrowEdge->>IREmitter: emit if(*) { onThrow continuation }
  end
  
  LowerFlowStmt->>IREmitter: emit acquire / flow facts
  
  Note over TryLower: For try blocks
  TryLower->>TryLower: compute escapesThisTry
  TryLower->>TryLower: build bodyOnThrow = finally + (enclosing | return)
  TryLower->>LowerFlowStmt: lower block with bodyOnThrow
  LowerFlowStmt->>IREmitter: inject throw edges at leaves
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • PhysShell/Own.NET#31: Modifies the same --flow-locals try/catch/finally lowering in Program.cs and adjusts the corresponding CI and fixture expectations for exception-exit leak findings.
  • PhysShell/Own.NET#32: Further refines the same leaf-level throw-edge injection and StatementMayThrow/dispose-shape handling in Program.cs, with matching OWNIR and CI updates.
  • PhysShell/Own.NET#16: Introduces the foundational PoC flow-local lowering in Program.cs that this PR extends with the finer-grained exceptional-exit modeling.

Poem

🐇 Hoppity-hop through the try block maze,
Where leaves may throw in exceptional ways—
InjectThrowEdge marks each perilous call,
canEscape threads the path through it all.
No ctorLater shall leak unaware,
For the rabbit has laid exception snares! 🌿

🚥 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 pull request title accurately and specifically describes the main change: implementing exception-edge recall slice with nested try blocks, constructor throw-points, and typed catches support.
Docstring Coverage ✅ Passed Docstring coverage is 90.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/zen-pasteur-76hfs1

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

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

ℹ️ 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 316 to +319
static bool StatementMayThrow(StatementSyntax st) =>
st.DescendantNodes().OfType<InvocationExpressionSyntax>().Any(i => !IsDisposeShaped(i));
st.DescendantNodes().Any(n =>
(n is InvocationExpressionSyntax i && !IsDisposeShaped(i))
|| n is ObjectCreationExpressionSyntax or ImplicitObjectCreationExpressionSyntax);

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 treating constructors inside lambdas as immediate throws

When constructor throw points are found with DescendantNodes(), the scan also enters anonymous-function bodies, so a statement like Func<Stream> f = () => new MemoryStream(); inside a try is given a synthetic throw edge even though that constructor is not executed when the delegate is created. If a tracked disposable is live before that statement and disposed after the try, the new edge skips the later dispose and reports a leak that cannot occur on that path. Exclude deferred bodies such as lambdas/expression-tree lambdas when looking for object-creation throw points.

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 83a9d3e. StatementMayThrow now passes descendIntoChildren: n => n is not AnonymousFunctionExpressionSyntax to DescendantNodes, so a new (or call) inside a lambda / anonymous-method body no longer counts as a throw point at the declaring statement — that body runs on invoke, not on declaration. An immediately-invoked lambda is still caught, since the outer invocation is itself the throw point. This also closes the same latent issue the pre-existing invocation scan had (it descended into lambdas too). Pinned by a new sample CtorInLambdaNotThrow (lamPrior must stay silent in CI).


Generated by Claude Code

@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

🤖 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 328-336: The IsCatchAll method incorrectly classifies any
qualified exception type ending in Exception (like Foo.Exception) as a catch-all
by extracting only the rightmost identifier. To fix this, modify the switch
expression to properly validate qualified names: for QualifiedNameSyntax, check
that it specifically represents System.Exception (by verifying the left side is
"System" and the right identifier is "Exception"), not just any qualified name
with Exception as the rightmost identifier. This ensures only truly system-level
exception catches are treated as catch-all, while domain-specific exceptions
like Foo.Exception correctly allow exception edges to be injected.
🪄 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: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 045fd5f8-952a-4703-b278-36c69451fb03

📥 Commits

Reviewing files that changed from the base of the PR and between 57253ad and fc2192d.

📒 Files selected for processing (7)
  • .github/workflows/ci.yml
  • docs/ROADMAP.md
  • docs/notes/real-world-mining.md
  • frontend/roslyn/OwnSharp.Extractor/Program.cs
  • frontend/roslyn/samples/FlowLocalsSample.cs
  • tests/fixtures/ownir/flow_nested_throw.facts.json
  • tests/test_ownir.py

Comment thread frontend/roslyn/OwnSharp.Extractor/Program.cs Outdated
…dge slice

- StatementMayThrow: don't descend into lambda / anonymous-method bodies when
  scanning for throw points. A `new` (or call) inside `() => …` runs when the
  delegate is invoked, not where it is declared, so the declaring statement is not a
  throw point; counting it injected a phantom edge that could falsely flag a prior
  resource disposed after the try (Codex review). An immediately-invoked lambda is
  still caught via its outer invocation, and this also closes the same latent issue
  the pre-existing invocation scan had. Pinned by CtorInLambdaNotThrow (lamPrior silent).
- IsCatchAll: match the canonical System.Exception spellings by full text instead of
  the rightmost identifier. `catch (Foo.Exception)` — a domain type whose rightmost
  name is `Exception` but which is not System.Exception — was misclassified as a
  catch-all, suppressing edges and missing a real leak (CodeRabbit review). Now only
  Exception / System.Exception / global::System.Exception are catch-alls; a qualified
  domain catch is typed and injects edges. Pinned by QualifiedTypedCatchLeaks (qualLeak).

Both are no-false-positive-preserving. Tests 52/52; C# end-to-end validated in CI.

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