Skip to content

feat(extractor): transitive consume contract — use-after-handoff through a forwarding consumer#68

Merged
PhysShell merged 3 commits into
mainfrom
claude/cross-method-uad
Jun 21, 2026
Merged

feat(extractor): transitive consume contract — use-after-handoff through a forwarding consumer#68
PhysShell merged 3 commits into
mainfrom
claude/cross-method-uad

Conversation

@PhysShell

@PhysShell PhysShell commented Jun 21, 2026

Copy link
Copy Markdown
Owner

Cross-method use-after-dispose, through a forwarding consumer

The D4 frontier. The inter-procedural consume-handoff already caught a use-after-handoff when the callee disposes a by-value IDisposable parameter directly (#55). But the inference deliberately stopped one hop short — a parameter merely forwarded to another call was left ambiguous (the passed branch in ownir.py: "awaiting … a later transitive pass"). This is that pass.

static void Inner(Stream s)    { s.CopyTo(Stream.Null); s.Dispose(); }  // direct consumer
static void Consume(Stream k)  { Inner(k); }                            // FORWARDS — consumes transitively
static long Run(string p) {
    var s = File.OpenRead(p);
    Consume(s);          // ownership moves Consume → Inner (disposed)
    return s.Length;     // use-after-handoff → OWN002   ← MISSED before, CAUGHT now
}

Before this PR, Consume (which forwards rather than disposes) wasn't seen as a consumer, so Consume(s) wasn't a release and the later s.Length was invisible.

How

The extractor's consumer detection (ConsumesParam) is now transitive: a parameter is consumed if the body disposes it directly or forwards it to another first-party consumer that consumes it — following Consume → Inner → Dispose through the chain, cycle-guarded (keyed per parameter), and binding each callee's body with its own tree's SemanticModel so cross-file chains resolve correctly. So Consume(s) is still modelled as a call-site release (same shape as pool Return(buf)), and a use after it trips OWN002.

The emitted facts are identical to the direct handoff (a release at the call site), so the core is unchanged — only the extractor's consumer-recognition reaches one+ hops further.

Precision (0 FP): a parameter handed to an unknown or merely-borrowing callee is not treated as consumed — no false release, no false OWN002 (the conservative default, gated absolutely by the corpus benchmark's 0-FP rule on every after.cs).

Pinned

New corpus case ownership-handoff-use-transitive (before.cs → OWN002, after.cs silent), lifting the benchmark recall floor 10 → 11 — the ratchet that asserts the catch (if the transitive logic didn't work, recall stays 10 < 11 and CI fails).

Validated locally: case.own → OWN002 (the .own front-end already does transitive consume inference), corpus 9/9, and the constructed extractor facts (acquire s; release s @ handoff; use s) → OWN002.

Remaining backlog (now narrower): a field-mediated cross-method use-after-dispose (dispose in one method, use in another via shared state) — genuinely whole-program, out of the narrow-frontend scope for now.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED


Generated by Claude Code

Summary by CodeRabbit

  • New Features

    • Improved ownership handoff detection to flag use-after-handoff cases through transitive call/forwarding chains.
  • Bug Fixes

    • Updated flow-local disposable “silent/exempt” handling to also treat deferred (defer) paths as non-reporting.
    • Reduced false positives in deferred-disposal patterns.
  • Tests

    • Added new real-world corpus scenario covering transitive inter-procedural use-after-handoff (including updated expected diagnostics).
    • Added flow-local analysis sample to prevent regressions.
  • Documentation

    • Expanded corpus and proposal documentation to clarify the updated consume/handoff triggering behavior.
  • Chores

    • Raised the corpus benchmark recall floor.

…ugh a forwarding consumer

Cross-method D4: the consume-handoff was modelled only for a callee that disposes a
by-value IDisposable parameter DIRECTLY (#55). The inference deliberately stopped one hop
short — a parameter merely forwarded to another call was left ambiguous (ownir.py's
`passed` branch: "awaiting ... a later transitive pass"). So a consumer that forwards
rather than disposes (`Consume(sink) -> Inner(sink) -> sink.Dispose()`) was NOT seen as a
consumer, the handoff was not a release, and a later use of the argument was invisible.

The extractor's consumer detection is now transitive: `ConsumesParam` recognises a parameter
as consumed if the body disposes it directly OR forwards it to another first-party consumer
that consumes it — following the chain, guarded against cycles (keyed per parameter), binding
each callee's body with its own tree's SemanticModel so cross-file chains resolve. So
`Consume(s)` is still a call-site release and a use after it trips OWN002. Conservative: a
parameter handed to an unknown or merely-borrowing callee is not treated as consumed (no false
release, no false OWN002). The facts are identical to the direct handoff (a release at the call
site), so the core is unchanged.

Pinned by a new corpus case `ownership-handoff-use-transitive` (before -> OWN002, after silent),
which lifts the benchmark recall floor 10 -> 11. The .own reduction already checks the
transitive shape (the front-end's signature inference recurses); this brings it to real C#.
Validated locally: case.own -> OWN002, corpus 9/9, the constructed extractor facts -> OWN002.

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 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: 07eee855-37db-477e-bf00-4aae03736424

📥 Commits

Reviewing files that changed from the base of the PR and between c99cee3 and dbbfd65.

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

📝 Walkthrough

Walkthrough

The Roslyn extractor's ConsumeReleaseArgs function is upgraded to detect transitive ownership handoffs: a new ConsumesParam recursive helper follows forwarding chains across first-party callees (with a cycle guard), and ConsumerBody extracts inspectable method syntax. DisposesLocal is refined to exclude deferred disposal in nested lambdas. A new corpus fixture (ownership-handoff-use-transitive) and its OWN002 expected diagnostic are added, along with extractor test coverage, documentation updates, and a CI recall gate bump from 10 to 11.

Changes

Transitive Ownership Handoff Detection

Layer / File(s) Summary
Extractor: transitive consumer detection via ConsumesParam
frontend/roslyn/OwnSharp.Extractor/Program.cs
ConsumeReleaseArgs now delegates to a new recursive ConsumesParam helper that proves inter-procedural parameter consumption by checking direct disposal or following immediate invocations that forward the parameter to first-party callees. ConsumerBody extracts inspectable syntax bodies. ImmediateInvocations scans top-level invocations excluding nested lambdas. DisposesLocal is refined to exclude nested lambda/local-function disposal calls. A per-parameter visited set prevents cyclic call graphs; unknown/absent bodies conservatively do not emit consume signals.
Extractor test: deferred-disposal validation
frontend/roslyn/samples/FlowLocalsSample.cs, .github/workflows/ci.yml
Adds System.Collections.Generic import, a _deferredDisposers registry, DeferredConsumer helper that stores disposal in a nested lambda without immediate release, and DeferredHandoffNoFalsePositive test that verifies no false use-after-handoff is reported. Updates CI flow-sensitive test to include defer in the silent/exempt allowlist.
Corpus fixture: ownership-handoff-use-transitive
corpus/real-world/ownership-handoff-use-transitive/case.own, corpus/real-world/ownership-handoff-use-transitive/before.cs, corpus/real-world/ownership-handoff-use-transitive/after.cs, corpus/real-world/ownership-handoff-use-transitive/expected-diagnostics.txt, corpus/real-world/ownership-handoff-use-transitive/notes.md
Introduces the complete transitive handoff corpus case: an OwnLang model declaring Stream semantics and inner/take/run functions where ownership is forwarded transitively and then used (OWN002); C# before/after source files demonstrating the Consume → Inner disposal chain; expected OWN002 diagnostic; and notes documenting the prior one-hop detection miss and the updated transitive-chain-following behavior.
Documentation and CI gating
corpus/real-world/README.md, docs/proposals/P-016-deep-fact-extraction.md, .github/workflows/ci.yml
Adds ownership-handoff-use and ownership-handoff-use-transitive rows to the corpus README table, expands the B3 (Move / ownership transfer) proposal section with ConsumesParam transitive-forwarding semantics, and raises the corpus-benchmark --min-recall threshold from 10 to 11.

Sequence Diagram(s)

sequenceDiagram
  participant ConsumeReleaseArgs
  participant ConsumesParam
  participant ConsumerBody
  participant DisposesLocal
  ConsumeReleaseArgs->>ConsumesParam: (param, methodBody, visited={})
  ConsumesParam->>DisposesLocal: check direct Dispose/Close/DisposeAsync
  alt direct disposal found
    DisposesLocal-->>ConsumesParam: true
  else no direct disposal
    ConsumesParam->>ConsumesParam: scan ImmediateInvocations for param reference
    ConsumesParam->>ConsumerBody: resolve forwarding callee IMethodSymbol
    ConsumerBody-->>ConsumesParam: callee body (or null)
    alt callee body available
      ConsumesParam->>ConsumesParam: map argument to callee parameter by name/position
      ConsumesParam->>ConsumesParam: recurse(mapped param, visited ∪ {param})
    else unknown/no body
      ConsumesParam-->>ConsumesParam: false (conservative)
    end
  end
  ConsumesParam-->>ConsumeReleaseArgs: consumed → mark release at call site
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • PhysShell/Own.NET#15: Both PRs modify frontend/roslyn/OwnSharp.Extractor/Program.cs around the extractor's --flow-locals/inter-procedural handling of IDisposable release/consume detection.
  • PhysShell/Own.NET#55: Directly modifies frontend/roslyn/OwnSharp.Extractor/Program.cs for inter-procedural consume-contract detection via ConsumeReleaseArgs and updates OWN002 corpus fixtures — the same components extended here for transitive forwarding.
  • PhysShell/Own.NET#50: Also adjusts the --min-recall threshold in the corpus-benchmark CI step of .github/workflows/ci.yml.

Poem

🐇 A stream passed along, from hand to hand it goes,
Through Consume, through Inner, until Dispose it flows.
I hopped the whole chain, no cycle could fool me,
Each forwarded stream now counted, OWN002 set free!
The recall gate rises — one more bug caught with glee. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 55.56% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ 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 directly describes the main change: implementing transitive consume contract detection for use-after-handoff scenarios through forwarding consumers, which is the core feature of this PR.
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/cross-method-uad

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: 304aaaee67

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

// live in another file, so bind its calls with that tree's OWN model (a SemanticModel only
// resolves nodes in its own tree); the Compilation is shared across all parsed inputs.
var bodyModel = model.Compilation.GetSemanticModel(body.SyntaxTree);
foreach (var inv in body.DescendantNodesAndSelf().OfType<InvocationExpressionSyntax>())

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 Skip nested callbacks when inferring transitive consumes

When a parameter is only captured by a nested lambda/local function that later forwards it to a real consumer, this DescendantNodesAndSelf() scan still sees the inner Inner(s) call and marks the outer method as consuming s immediately. For example, Register(Stream s) { callbacks.Add(() => Inner(s)); } would now release the caller's argument at Register(s) and report OWN002 on any following use, even though the stream has not been handed off or disposed at that call site; the traversal should avoid descending into nested function bodies for this inference.

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 `@frontend/roslyn/OwnSharp.Extractor/Program.cs`:
- Around line 896-903: The ConsumerBody method currently only checks for
BaseMethodDeclarationSyntax but misses local functions which are represented as
LocalFunctionStatementSyntax. Add a parallel check in the foreach loop to handle
LocalFunctionStatementSyntax in addition to BaseMethodDeclarationSyntax. Since
LocalFunctionStatementSyntax has the same Body and ExpressionBody properties as
BaseMethodDeclarationSyntax, extract and return the body or expression body from
local functions using the same pattern of checking d.Body ?? d.ExpressionBody to
avoid missing forwarding chains through local functions.
- Around line 930-944: Replace the `DescendantNodesAndSelf()` call in the
foreach loop within `ConsumesParam()` function with the same filtering pattern
used elsewhere in the codebase (referenced at line 396) that excludes lambda and
local-function bodies. Additionally, apply the identical filter to the
`DisposesLocal()` function wherever it uses `DescendantNodesAndSelf()`. This
prevents deferred or conditional code paths within lambdas and local functions
from being incorrectly treated as immediate execution, which is causing false
positive/negative OWN002/OWN001 violations.
🪄 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: 771307e5-e1c5-4183-ac52-50c8b7487420

📥 Commits

Reviewing files that changed from the base of the PR and between 53950f8 and 304aaae.

📒 Files selected for processing (9)
  • .github/workflows/ci.yml
  • corpus/real-world/README.md
  • corpus/real-world/ownership-handoff-use-transitive/after.cs
  • corpus/real-world/ownership-handoff-use-transitive/before.cs
  • corpus/real-world/ownership-handoff-use-transitive/case.own
  • corpus/real-world/ownership-handoff-use-transitive/expected-diagnostics.txt
  • corpus/real-world/ownership-handoff-use-transitive/notes.md
  • docs/proposals/P-016-deep-fact-extraction.md
  • frontend/roslyn/OwnSharp.Extractor/Program.cs

Comment thread frontend/roslyn/OwnSharp.Extractor/Program.cs
Comment thread frontend/roslyn/OwnSharp.Extractor/Program.cs Outdated
…es for consume inference

Codex P2: ConsumesParam's DescendantNodesAndSelf scan (and the pre-existing DisposesLocal)
descended into nested lambdas / local functions, so a parameter disposed or forwarded ONLY
inside a stored callback — `Register(Stream s) { callbacks.Add(() => Inner(s)); }` — was
wrongly treated as consumed at the call site. That would release the caller's argument at
`Register(s)` and report a phantom use-after-handoff (false OWN002), even though the dispose
runs DEFERRED, not at the call boundary.

Both scans now use DescendantNodesAndSelf(descendIntoChildren: not lambda/local-function) —
the same deferred-body rule the flow lowering already follows (PR #57/#59). A dispose/forward
inside a nested function contributes nothing to the consume inference. Conservative: it can
only DROP a consume (a missed handoff = a leak, never a false use-after-release).

Pinned by a flow-locals silent control: DeferredHandoffNoFalsePositive's `defer` is passed to
a deferred-lambda "consumer", used, then disposed normally — it must stay SILENT (it would
trip a false OWN002 without the fix). The transitive corpus case (Inner forwarded directly,
not in a lambda) is unchanged. corpus 9/9, ownir 116/116.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED

@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/samples/FlowLocalsSample.cs`:
- Around line 492-496: The DeferredConsumer method currently invokes the
discharge action immediately (discharge()), which causes the Stream parameter to
be disposed synchronously before the method returns. To make this a true
deferred-handoff case as intended, remove the immediate invocation of
discharge() within the DeferredConsumer method. Instead, only create the Action
that wraps the Dispose call, and let the caller decide when or if to invoke it.
This will ensure the parameter is not consumed at the call site and properly
demonstrates the deferred handoff pattern without creating a use-after-dispose
scenario.
🪄 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: 9e5e73b6-8ea3-4b62-a66b-6f4f8c90d0e3

📥 Commits

Reviewing files that changed from the base of the PR and between 304aaae and c99cee3.

📒 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 (1)
  • frontend/roslyn/OwnSharp.Extractor/Program.cs

Comment thread frontend/roslyn/samples/FlowLocalsSample.cs Outdated
…callees, fix deferred control

Addresses the CodeRabbit review on the transitive consume slice:

- Extract `ImmediateInvocations(body)` — the shared "descendants excluding nested lambda /
  local-function bodies" scan — and use it in both ConsumesParam and DisposesLocal (the
  deferred-scope fix from c99cee3, now de-duplicated; CodeRabbit Major, already addressed).
- ConsumerBody now also returns a LOCAL FUNCTION's body (LocalFunctionStatementSyntax has the
  same Body/ExpressionBody): a directly-called local function runs synchronously, so a
  forwarding chain through one is followed too — closes a transitive false-negative (CodeRabbit).
- Fix the deferred-handoff control: DeferredConsumer stored the disposer in a lambda but then
  INVOKED it synchronously, so the stream was actually disposed at the call (a real bug, not a
  clean control — CodeRabbit Major). It now only STORES the callback (a registry, not drained),
  so nothing is disposed at the call site; `defer` is disposed once by its own Dispose() and
  stays SILENT — the genuine no-false-OWN002 guard.

Behaviour unchanged on the catching path: the transitive corpus case (Inner forwarded via a
direct method call) still hits recall 11 — c99cee3's benchmark was green at the floor. corpus
9/9, ownir 116/116.

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