Skip to content

fix(extractor): IMemoryOwner.Memory handoff escapes the owner (oracle-driven, CodeQL-validated)#87

Merged
PhysShell merged 2 commits into
mainfrom
claude/fp-memoryowner-escape-projection
Jun 22, 2026
Merged

fix(extractor): IMemoryOwner.Memory handoff escapes the owner (oracle-driven, CodeQL-validated)#87
PhysShell merged 2 commits into
mainfrom
claude/fp-memoryowner-escape-projection

Conversation

@PhysShell

@PhysShell PhysShell commented Jun 22, 2026

Copy link
Copy Markdown
Owner

Oracle-driven precision fix (cross-tool, validated by CodeQL)

Running the cross-tool oracle (scripts/oracle_compare.pyoracle.yml) on SixLabors/ImageSharp surfaced this directly. Our flow-locals detector flagged Image.WrapMemory's memoryManager (a ByteMemoryManager : IMemoryOwner) as an undisposed-local leak ×4 — but CodeQL (interprocedural) does not, because the manager's .Memory is handed to MemoryGroup.Wrap(...) and lives on inside the returned Image.

Own.NET leak findings (product code) 7
CodeQL leak findings 3
agree 0
WrapMemory.memoryManager — own-only, CodeQL silent 4

The fix

A Memory<T> keeps its IMemoryOwner alive (it is the backing), so passing owner.Memory as an argument transfers the owner's lifetime to the consumer — disposing it at method scope would be premature, and not disposing it here is not a leak. The flow escape-exclusion now untracks a local when its .Memory view is passed as an argument, scoped tightly:

  • only IMemoryOwner.Memorynot any local.Member, so a FileStream whose .Length is read still leaks;
  • only non-pool / non-using owners — a MemoryPool/using owner keeps its dangling-borrow (OWN002) tracking intact.

This mirrors the existing #80 escaping-ctor transfer and the D5 "ambiguous handoff → conservatively exclude" rule.

Regression guard (--flow-locals)

MemoryOwnerEscapeSample.cs — an IMemoryOwner whose .Memory is handed to a consumer stays silent (handedOwner); one whose .Memory is only read locally and never disposed still warns (leakedOwner).

Note

Independent of the still-to-land #2/#3/#4 (different code region — the flow escape logic, not the field/pool/subscription detectors), so it bases cleanly on main. The other ImageSharp own-only remainders are left honest: SharedArrayPoolBuffer (guard-indirection — Codex stopped the unsound version), and MemoryStream (can't be made dispose-optional without gutting the FlowLocalsSample suite). CodeQL's 3 oracle-only (dispose-not-called-on-throw) are a separate, already-partially-modelled recall slice.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED


Generated by Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Added an escape-via-projection validation scenario to detect when projected IMemoryOwner memory can escape ownership tracking.
  • Improvements

    • Enhanced flow-sensitive analysis to better classify .Memory handoffs as ownership-escaping transfers, reducing incorrect tracking in downstream cases.
  • Tests / Validation

    • Updated CI checks to cover additional projection/leak and post-dispose usage regressions.
  • Samples

    • Added a new memory-owner escape sample demonstrating expected silent vs warning outcomes.

…he owner (mined ImageSharp; CodeQL-validated)

Cross-tool oracle (scripts/oracle_compare.py) on SixLabors/ImageSharp surfaced this: our
flow-locals detector flagged `Image.WrapMemory`'s `memoryManager` (a `ByteMemoryManager :
IMemoryOwner`) as an undisposed-local leak, but CodeQL (interprocedural) does NOT — the
manager's `.Memory` is handed to `MemoryGroup.Wrap(...)` and lives on in the returned `Image`.
A `Memory<T>` keeps its `IMemoryOwner` alive (it IS the backing), so passing `owner.Memory` as
an argument transfers the owner's lifetime to the consumer; disposing it at method scope would
be premature, and not disposing it here is not a leak.

The flow escape-exclusion now untracks a local when its `.Memory` view is passed as an argument,
scoped tightly: only `IMemoryOwner.Memory` (not any `local.Member`, so a `FileStream` whose
`.Length` is read still leaks), and only for non-pool / non-`using` owners (a MemoryPool/`using`
owner keeps its dangling-borrow tracking — OWN002 — intact). Mirrors the existing #80
escaping-ctor transfer and the D5 "ambiguous handoff -> conservatively exclude" rule.

Regression sample MemoryOwnerEscapeSample.cs (--flow-locals): an IMemoryOwner whose `.Memory`
is handed to a consumer stays silent ('handedOwner'); one whose `.Memory` is only read locally
and never disposed still warns ('leakedOwner').

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 22, 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: 3ec74f53-b8b0-4723-b4cd-d3e4b1a2760a

📥 Commits

Reviewing files that changed from the base of the PR and between c7a24f4 and 397395d.

📒 Files selected for processing (3)
  • .github/workflows/ci.yml
  • frontend/roslyn/OwnSharp.Extractor/Program.cs
  • frontend/roslyn/samples/MemoryOwnerEscapeSample.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 P-016 "escape-via-projection" escape rule to the flow-locals extractor: when a tracked IMemoryOwner<T> local's .Memory view is passed directly as a call argument, the local is marked escaped. A new sample file exercises the handoff (no warning), local leak (OWN001), and use-after-dispose (OWN002) scenarios, and the CI flow-locals job validates the expected findings.

Changes

P-016 IMemoryOwner escape-via-projection

Layer / File(s) Summary
Escape rule implementation
frontend/roslyn/OwnSharp.Extractor/Program.cs
Introduces a newedDisposables set to track disposables created via new. The escapedLocals computation gains a branch that marks an IMemoryOwner<T> local as escaped when its .Memory member-access is passed directly as a call argument, gated to newedDisposables and verified via IsMemoryOwnerType.
Sample test scenarios
frontend/roslyn/samples/MemoryOwnerEscapeSample.cs
Introduces PixelOwner (IMemoryOwner<byte> implementation) and a Store() sink. Implements Transferred() passing owner.Memory to consumer (handoff, no warning expected), ReadOnlyLeak() reading Memory.Length locally without disposal (OWN001 expected), and PoolOwnerNotEscaped() renting from MemoryPool, disposing, then using .Memory afterward (OWN002 expected for use-after-dispose).
CI validation
.github/workflows/ci.yml
Extends the --flow-locals extractor invocation to include MemoryOwnerEscapeSample.cs alongside FlowLocalsSample.cs. Adds P-016 assertions for OWN001 on leakedOwner, OWN002 on pooled, and marks handedOwner as silent/exempt.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Possibly related PRs

  • PhysShell/Own.NET#15: Introduced the --flow-locals machinery and CI flow that this PR extends with the projection-escape rule.
  • PhysShell/Own.NET#70: Both PRs extend the extractor to map Memory<T> view expressions back to their owning IMemoryOwner locals and enforce OWN002 when views are used after disposal.

Poem

🐇 A memory owner lends its view,
But handing .Memory lets it through!
If you just peek and never free,
OWN001 hops out — look and see.
The rabbit tracks escape with care,
No borrowed memory unaware! 🥕

🚥 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: IMemoryOwner.Memory handoff now escapes the owner in the flow-locals detector.
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/fp-memoryowner-escape-projection

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

Comment thread frontend/roslyn/samples/MemoryOwnerEscapeSample.cs Fixed

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

ℹ️ 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 +2589 to +2593
|| (!poolBuffers.Contains(nm) && !usingMemoryOwners.Contains(nm)
&& idn.Parent is MemberAccessExpressionSyntax { Name.Identifier.Text: "Memory" } projMem
&& projMem.Expression == idn
&& projMem.Parent is ArgumentSyntax
&& IsMemoryOwnerType(model.GetTypeInfo(idn).Type))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep MemoryPool rentals tracked through .Memory handoffs

When the candidate is a normal MemoryPool<T>.Rent() local, this new escape path still fires: such locals are not in poolBuffers, and usingMemoryOwners only contains using-declared rentals. As a result, var owner = MemoryPool<byte>.Shared.Rent(16); owner.Dispose(); Store(owner.Memory); is removed from tracked, so the flow pass no longer emits the intended dangling-borrow/use-after-release OWN002 for passing owner.Memory after disposal, despite the comment saying MemoryPool owners keep that tracking.

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

🧹 Nitpick comments (1)
.github/workflows/ci.yml (1)

568-574: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Add a regression assertion for MemoryPool-owner projection to lock the rule boundary.

Current checks validate handedOwner (custom IMemoryOwner) and leakedOwner, but they don’t pin the “non-pool only” constraint. Add a sample/assertion where MemoryPool<T>.Rent owner passes .Memory as an argument and must not be silenced by projection escape.

🤖 Prompt for 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.

In @.github/workflows/ci.yml around lines 568 - 574, The current validation
checks for leakedOwner and handedOwner cases but does not have a regression test
for MemoryPool-owner projection escape. Add a new assertion (similar to the
existing OWN001 leakedOwner check) that validates when MemoryPool<T>.Rent owner
passes its .Memory as an argument, it must NOT be silenced by projection escape,
then add this new MemoryPool test case to the for loop starting with "for ok in"
that lists all the valid test case scenarios like handedOwner, clean, looped,
etc.
🤖 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 2589-2593: The guard condition at line 2589 that checks
!poolBuffers.Contains(nm) and !usingMemoryOwners.Contains(nm) is incomplete
because it still allows non-using MemoryPool owners to escape through the
owner.Memory projection path. Add another condition to the guard that also
excludes non-using MemoryPool owners, similar to how poolBuffers and
usingMemoryOwners are already excluded. This will ensure that only appropriately
scoped memory owners can proceed through this projection-escape path.

---

Nitpick comments:
In @.github/workflows/ci.yml:
- Around line 568-574: The current validation checks for leakedOwner and
handedOwner cases but does not have a regression test for MemoryPool-owner
projection escape. Add a new assertion (similar to the existing OWN001
leakedOwner check) that validates when MemoryPool<T>.Rent owner passes its
.Memory as an argument, it must NOT be silenced by projection escape, then add
this new MemoryPool test case to the for loop starting with "for ok in" that
lists all the valid test case scenarios like handedOwner, clean, looped, etc.
🪄 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: 598a8b64-a7d6-41de-95bb-8023a0f91c45

📥 Commits

Reviewing files that changed from the base of the PR and between b787ffc and c7a24f4.

📒 Files selected for processing (3)
  • .github/workflows/ci.yml
  • frontend/roslyn/OwnSharp.Extractor/Program.cs
  • frontend/roslyn/samples/MemoryOwnerEscapeSample.cs

Comment on lines +2589 to +2593
|| (!poolBuffers.Contains(nm) && !usingMemoryOwners.Contains(nm)
&& idn.Parent is MemberAccessExpressionSyntax { Name.Identifier.Text: "Memory" } projMem
&& projMem.Expression == idn
&& projMem.Parent is ArgumentSyntax
&& IsMemoryOwnerType(model.GetTypeInfo(idn).Type))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Exclude non-using MemoryPool<T>.Rent owners from this new projection-escape path.

Line 2589’s guard only excludes poolBuffers (ArrayPool) and usingMemoryOwners, so a non-using MemoryPool owner still escapes via owner.Memory argument at Line 2590. That conflicts with the stated non-pool/non-using scope and can hide valid owner-lifetime findings.

Proposed fix
-                var usingMemoryOwners = new HashSet<string>();   // `using`-declared MemoryPool owners
+                var usingMemoryOwners = new HashSet<string>();   // `using`-declared MemoryPool owners
+                var memoryPoolOwners = new HashSet<string>();    // all MemoryPool<Rent> owners
@@
-                        else if (IsMemoryPoolRent(v.Initializer?.Value, model))   // MemoryPool<T> IMemoryOwner (Dispose-released, NOT a poolBuffer)
-                            candidates.Add(v.Identifier.Text);
+                        else if (IsMemoryPoolRent(v.Initializer?.Value, model))   // MemoryPool<T> IMemoryOwner (Dispose-released, NOT a poolBuffer)
+                        {
+                            candidates.Add(v.Identifier.Text);
+                            memoryPoolOwners.Add(v.Identifier.Text);
+                        }
@@
-                        || (!poolBuffers.Contains(nm) && !usingMemoryOwners.Contains(nm)
+                        || (!poolBuffers.Contains(nm) && !usingMemoryOwners.Contains(nm)
+                            && !memoryPoolOwners.Contains(nm)
                             && idn.Parent is MemberAccessExpressionSyntax { Name.Identifier.Text: "Memory" } projMem
                             && projMem.Expression == idn
                             && projMem.Parent is ArgumentSyntax
                             && IsMemoryOwnerType(model.GetTypeInfo(idn).Type))
🤖 Prompt for 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.

In `@frontend/roslyn/OwnSharp.Extractor/Program.cs` around lines 2589 - 2593, The
guard condition at line 2589 that checks !poolBuffers.Contains(nm) and
!usingMemoryOwners.Contains(nm) is incomplete because it still allows non-using
MemoryPool owners to escape through the owner.Memory projection path. Add
another condition to the guard that also excludes non-using MemoryPool owners,
similar to how poolBuffers and usingMemoryOwners are already excluded. This will
ensure that only appropriately scoped memory owners can proceed through this
projection-escape path.

… pool-rental tracking (Codex/CodeRabbit P1)

The first cut excluded only poolBuffers (ArrayPool) and usingMemoryOwners (using-declared
MemoryPool), but a `var` MemoryPool.Rent owner is in neither — so its `.Memory` handoff wrongly
untracked it, dropping the dangling-borrow / double-dispose findings (OWN002/OWN003). The
corpus benchmark caught it: memorypool-double-dispose regressed (recall 23 -> 22).

Scope the projection-escape to a new `newedDisposables` whitelist — locals created via `new`
(the WrapMemory `new ByteMemoryManager(...)` shape). A pool rental of ANY kind (ArrayPool or
MemoryPool, `var` or `using`) is never `new`'d, so it keeps its full use-after-dispose tracking;
the renter owns the Return/Dispose. Stricter and more self-evident than enumerating pool sets.

Sample/CI gain a MemoryPool-owner boundary case: `pooled.Dispose(); Store(pooled.Memory)` must
still trip OWN002 (not be silenced). handedOwner stays silent, leakedOwner still warns.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED
// owner.Memory only READ locally (a length); owner never disposed -> real leak -> must WARN.
public static int ReadOnlyLeak()
{
var leakedOwner = new PixelOwner();
// after Dispose still trips OWN002 — the rule must NOT silence it (Codex/CodeRabbit P1).
public static void PoolOwnerNotEscaped()
{
var pooled = MemoryPool<byte>.Shared.Rent(16);
@PhysShell
PhysShell merged commit 78c234b into main Jun 22, 2026
22 checks passed
PhysShell pushed a commit that referenced this pull request Jun 22, 2026
…handlers (Codex P1)

Codex: the first cut keyed only off the EVENT (the AppDomain source), ignoring the handler —
so it also dropped a real region escape, e.g. `AppDomain.CurrentDomain.ProcessExit += (_,_) =>
_field++` or an instance-method handler, whose delegate target is the subscriber and is pinned
to the process until shutdown. The Npgsql case is safe only because its lambda is non-capturing
(a static `ClearAll()` call).

HandlerRetainsNoInstance now gates the exemption: a static method group (null target) or a
lambda that captures neither `this` (explicit, or implicit via an instance member) nor an
enclosing local/parameter. A capturing handler stays OWN014.

Sample: ShutdownCleanup gains the 4th event (FirstChanceException, CodeRabbit) and a new
CapturingShutdownSubscriber (`(_,_) => _count++`) that must STILL warn. CI: assert no OWN014
anywhere in the sample file (format-insensitive, CodeRabbit) for the exempt cases, and OWN014
for both the non-AppDomain and the capturing controls.

(Rebased onto current main — #86/#87 landed; resolved the ci.yml sample-list conflict.)

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