From 609fdbd9dabf06a0ffbfc37ff77d0014c60098e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 03:49:45 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(pool):=20MemoryPool=20using-owned=20vi?= =?UTF-8?q?ew=20escape=20=E2=80=94=20`using=20owner;=20return=20owner.Memo?= =?UTF-8?q?ry`=20=E2=86=92=20OWN002=20(POOL004)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The follow-up Codex flagged on #73. `using IMemoryOwner owner = MemoryPool.Shared.Rent(n); return owner.Memory;` is sugar for `try { return owner.Memory; } finally { owner.Dispose(); }` — the implicit scope-exit dispose hands the caller a Memory backed by a buffer already returned to the pool: a dangling borrow. Previously silent because the flow-locals candidate pass skips `using` locals (auto-disposed, non-leak), so the owner never entered `tracked`. Fix (extractor only, reuses #70's onReturn / ReturnedViewOwners machinery): a new LowerFlowStatements desugars a TRACKED `using IMemoryOwner = MemoryPool.Rent(...)` declaration into `acquire; try { rest } finally { release }` — the using-dispose is threaded onto the rest's returns/throws, so a returned view of the owner is read after the release -> OWN002. Routed from LowerFlowBody, the BlockSyntax case, and the try-body. With no such `using` declaration, LowerFlowStatements lowers each statement exactly as the old loop, so every existing shape (incl. #73's `using owner; Consume(owner.Memory.Span)` after.cs) is unchanged. The candidate pass now tracks `using` MemoryPool owners (gated to IsMemoryPoolRent). Corpus: memorypool-using-view-escape (before: `using owner; return owner.Memory` -> OWN002; after: return the IMemoryOwner, ownership transferred -> silent) — the MemoryPool twin of arraypool-memory-view-escape (#70). Benchmark recall floor 17 -> 18. P-007 status updated. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED --- .github/workflows/ci.yml | 2 +- .../memorypool-using-view-escape/after.cs | 16 +++++ .../memorypool-using-view-escape/before.cs | 23 ++++++ .../memorypool-using-view-escape/case.own | 17 +++++ .../expected-diagnostics.txt | 1 + .../memorypool-using-view-escape/notes.md | 31 ++++++++ docs/proposals/P-007-arraypool-span.md | 6 +- frontend/roslyn/OwnSharp.Extractor/Program.cs | 70 ++++++++++++++++--- 8 files changed, 154 insertions(+), 12 deletions(-) create mode 100644 corpus/real-world/memorypool-using-view-escape/after.cs create mode 100644 corpus/real-world/memorypool-using-view-escape/before.cs create mode 100644 corpus/real-world/memorypool-using-view-escape/case.own create mode 100644 corpus/real-world/memorypool-using-view-escape/expected-diagnostics.txt create mode 100644 corpus/real-world/memorypool-using-view-escape/notes.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a88abe0..d9c98fa9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -798,5 +798,5 @@ jobs: # (`ViewOwner`), so reading it after Dispose trips OWN002 (POOL002 — `memorypool-view-after- # dispose`). Remaining backlog: a FIELD-mediated cross-method use-after-dispose, a view stored in # a FIELD, and an injected-source region-escape. A drop below the floor is a regression. - run: python scripts/benchmark.py --min-recall 17 + run: python scripts/benchmark.py --min-recall 18 diff --git a/corpus/real-world/memorypool-using-view-escape/after.cs b/corpus/real-world/memorypool-using-view-escape/after.cs new file mode 100644 index 00000000..499ce51f --- /dev/null +++ b/corpus/real-world/memorypool-using-view-escape/after.cs @@ -0,0 +1,16 @@ +// AFTER (fixed). Ownership is TRANSFERRED to the caller: the method returns the +// `IMemoryOwner` itself (no `using`), so nothing is disposed here. The caller owns the +// owner's lifetime (`using var owner = Borrow(n); … owner.Memory …`) and the pooled buffer +// stays alive as long as the returned view is used. No dangling borrow, so the extractor +// untracks the escaped owner and the checker is silent. +using System; +using System.Buffers; + +static class MemoryPoolUsingViewEscape +{ + static IMemoryOwner Borrow(int n) + { + IMemoryOwner owner = MemoryPool.Shared.Rent(n); + return owner; // transfer ownership to the caller — the caller disposes it + } +} diff --git a/corpus/real-world/memorypool-using-view-escape/before.cs b/corpus/real-world/memorypool-using-view-escape/before.cs new file mode 100644 index 00000000..45437c15 --- /dev/null +++ b/corpus/real-world/memorypool-using-view-escape/before.cs @@ -0,0 +1,23 @@ +// BEFORE (buggy). POOL004 for MemoryPool, the idiomatic-`using` dangle. An +// `IMemoryOwner` from `MemoryPool` is held with a `using` declaration — so it is +// `Dispose()`d at scope exit — but the method RETURNS `owner.Memory`, a borrow of the +// owner's pooled buffer. The implicit dispose runs as the method returns, so the caller +// receives a `Memory` backed by memory already handed back to the pool: a dangling +// borrow / use-after-free. `using owner = …; return owner.Memory;` is exactly +// `try { return owner.Memory; } finally { owner.Dispose(); }` — the MemoryPool twin of +// the ArrayPool try/finally `Memory` escape. The fix is to TRANSFER ownership: return the +// `IMemoryOwner` itself (no `using`) and let the caller own its lifetime (see after.cs). +// +// Wrapped in a class so the extractor's per-class flow pass visits it. +using System; +using System.Buffers; + +static class MemoryPoolUsingViewEscape +{ + static Memory Borrow(int n) + { + using IMemoryOwner owner = MemoryPool.Shared.Rent(n); + return owner.Memory; // <-- BUG: the `using` disposes owner as we return, so the + // caller gets a view of buffer already returned to the pool + } +} diff --git a/corpus/real-world/memorypool-using-view-escape/case.own b/corpus/real-world/memorypool-using-view-escape/case.own new file mode 100644 index 00000000..656a05b2 --- /dev/null +++ b/corpus/real-world/memorypool-using-view-escape/case.own @@ -0,0 +1,17 @@ +// OwnLang model of the MemoryPool `using`-view escape. `acquire` == MemoryPool.Rent, +// `release` == the implicit `using` scope-exit Dispose. The method returns `owner.Memory` +// — a borrow of the owner — but the `using` disposes the owner FIRST (the extractor +// desugars `using owner = …; return owner.Memory;` to `acquire; release; use`), so the +// caller reads a view of memory already returned to the pool: a use-after-release lowered +// to OWN002. (The using-dispose is threaded before the return's view-use, mirroring the +// ArrayPool try/finally Memory escape.) +module Corpus +resource MemoryOwner { + acquire Rent + release Dispose +} +fn lease(n: int) { + let owner = acquire MemoryOwner(n); // using MemoryPool.Rent + release owner; // implicit `using` Dispose at scope exit + use owner; // return owner.Memory — read by the caller AFTER Dispose -> OWN002 +} diff --git a/corpus/real-world/memorypool-using-view-escape/expected-diagnostics.txt b/corpus/real-world/memorypool-using-view-escape/expected-diagnostics.txt new file mode 100644 index 00000000..3a36fa92 --- /dev/null +++ b/corpus/real-world/memorypool-using-view-escape/expected-diagnostics.txt @@ -0,0 +1 @@ +OWN002 diff --git a/corpus/real-world/memorypool-using-view-escape/notes.md b/corpus/real-world/memorypool-using-view-escape/notes.md new file mode 100644 index 00000000..6b16cd5c --- /dev/null +++ b/corpus/real-world/memorypool-using-view-escape/notes.md @@ -0,0 +1,31 @@ +# MemoryPool `using`-owned view escape (POOL004, the idiomatic dangle) + +**Pattern:** an `IMemoryOwner` from `MemoryPool` is held with a `using` +declaration (so it is `Dispose()`d at scope exit), but the method **returns a view of +it** — `using owner = …; return owner.Memory;`. The implicit dispose runs as the method +returns, so the caller receives a `Memory` backed by a buffer already returned to the +pool: a dangling borrow / use-after-free. It is exactly +`try { return owner.Memory; } finally { owner.Dispose(); }` — the MemoryPool twin of the +ArrayPool try/finally `Memory` escape (`arraypool-memory-view-escape`, #70). The fix is to +**transfer ownership**: return the `IMemoryOwner` itself (no `using`) so the caller owns +its lifetime. + +**Why it was missed before (Codex review on #73):** `using` locals are skipped by the +flow-locals candidate pass — they are auto-disposed, so normally not leak candidates — so +the owner never entered `tracked` and the returned view was not mapped to it. This slice +**desugars a tracked `using IMemoryOwner = MemoryPool.Rent(…)` declaration** into +`acquire; try { rest } finally { release }`: the implicit scope-exit dispose is threaded +onto the rest's returns (and throws), so the returned view's caller-use lands *after* the +release and trips OWN002 — reusing the same `onReturn` / `ReturnedViewOwners` machinery +that catches the ArrayPool try/finally form. Methods with no such `using` declaration are +lowered exactly as before. + +**What the checker says:** the OwnLang model and the real `before.cs` both trip +**OWN002**. The ownership-transfer fix in `after.cs` returns the owner (no `using`), so the +escaped owner is untracked and the checker is silent. + +**Honesty / scope.** `case.own` is a faithful hand reduction (not C# ingested by the +checker); `before.cs` / `after.cs` are representative of the bug and its fix. + +Reference: [P-007](../../../docs/proposals/P-007-arraypool-span.md); the ArrayPool twin is +`arraypool-memory-view-escape` (#70). diff --git a/docs/proposals/P-007-arraypool-span.md b/docs/proposals/P-007-arraypool-span.md index 7883fc1e..e5572b6a 100644 --- a/docs/proposals/P-007-arraypool-span.md +++ b/docs/proposals/P-007-arraypool-span.md @@ -22,7 +22,11 @@ so its leak / double-dispose ride the same flow as POOL001/003 (corpus `memorypool-double-dispose` → OWN003), and its `owner.Memory` / `owner.Memory.Span` is a borrow lowered to a use of the OWNER (`ViewOwner`), so reading the view after `Dispose` trips **POOL002 → OWN002** (corpus - `memorypool-view-after-dispose`). A POOL005 view stored in a FIELD is next + `memorypool-view-after-dispose`). The idiomatic `using owner = MemoryPool.Rent(…); return + owner.Memory;` — which dangles after the implicit scope-exit dispose — is caught too: + the flow desugars a tracked `using IMemoryOwner` declaration to `acquire; try { rest } + finally { release }`, so the returned view is read after the release (**POOL004 → OWN002**; + corpus `memorypool-using-view-escape`). A POOL005 view stored in a FIELD is next - **Depends on:** `spec/OwnCore.md` (OWN001 leak, OWN002 use-after-release, OWN003 double-release, OWN008 release-while-borrowed), the buffer/borrow model in `spec/`, [P-001](P-001-csharp-extractor.md). See diff --git a/frontend/roslyn/OwnSharp.Extractor/Program.cs b/frontend/roslyn/OwnSharp.Extractor/Program.cs index 1c18864a..31851e17 100644 --- a/frontend/roslyn/OwnSharp.Extractor/Program.cs +++ b/frontend/roslyn/OwnSharp.Extractor/Program.cs @@ -422,12 +422,58 @@ cc.Filter is null static List? LowerFlowBody(BlockSyntax block, HashSet tracked, SemanticModel model) { var nodes = new List(); - foreach (var st in block.Statements) - if (!LowerFlowStmt(st, tracked, model, nodes)) - return null; + if (!LowerFlowStatements(block.Statements, 0, tracked, model, nodes, + canEscape: true, onThrow: null, onReturn: null)) + return null; return nodes; } +// Lower a statement LIST, desugaring a tracked `using IMemoryOwner owner = MemoryPool.Rent(...)` +// declaration into `acquire; try { rest } finally { release }` — the implicit scope-exit Dispose is +// threaded onto the rest's returns/throws (exactly like a `finally`), so a RETURNED view of the owner +// (`using owner = …; return owner.Memory;`) is a dangling borrow read by the caller after the dispose +// -> OWN002 (Codex review on #73). With no such declaration this is identical to lowering each +// statement in turn, so non-pooled `using` locals and every existing shape are unaffected. +static bool LowerFlowStatements(IReadOnlyList stmts, int start, + HashSet tracked, SemanticModel model, List nodes, + bool canEscape, List? onThrow, List? onReturn) +{ + for (var i = start; i < stmts.Count; i++) + { + var st = stmts[i]; + if (st is LocalDeclarationStatementSyntax usingDecl + && usingDecl.UsingKeyword != default + && usingDecl.Declaration.Variables.Count == 1 + && tracked.Contains(usingDecl.Declaration.Variables[0].Identifier.Text) + && IsMemoryPoolRent(usingDecl.Declaration.Variables[0].Initializer?.Value, model)) + { + var uv = usingDecl.Declaration.Variables[0]; + var owner = uv.Identifier.Text; + var exit = new List { new { op = "return", var = (string?)null, line = LineOf(usingDecl) } }; + InjectThrowEdge(usingDecl, nodes, onThrow); // a throw DURING Rent() runs the OUTER path (owner not yet acquired) + nodes.Add(new { op = "acquire", var = owner, line = LineOf(uv) }); + var release = new { op = "release", var = owner, line = LineOf(uv) }; + // The rest of THIS block is the try-body; the implicit using-dispose is its finally — run + // before a return (so a returned view is read after release) and on normal completion. + var restOnReturn = new List { release }; + restOnReturn.AddRange(onReturn ?? exit); + List? restOnThrow = null; + if (canEscape) + { + restOnThrow = new List { release }; + restOnThrow.AddRange(onThrow ?? exit); + } + if (!LowerFlowStatements(stmts, i + 1, tracked, model, nodes, canEscape, restOnThrow, restOnReturn)) + return false; + nodes.Add(release); // normal completion (no return) disposes at scope exit too + return true; + } + if (!LowerFlowStmt(st, tracked, model, nodes, canEscape, onThrow, onReturn)) + return false; + } + return true; +} + // `canEscape`: can a throw at the current position leave the METHOD (no enclosing // catch-all swallows it)? `onThrow`: the continuation a throw here runs to leave the // method (finally-stack + return), or null when no exception edge should be injected @@ -442,10 +488,7 @@ static bool LowerFlowStmt(StatementSyntax st, HashSet tracked, SemanticM switch (st) { case BlockSyntax b: - foreach (var s2 in b.Statements) - if (!LowerFlowStmt(s2, tracked, model, nodes, canEscape, onThrow, onReturn)) - return false; - return true; + return LowerFlowStatements(b.Statements, 0, tracked, model, nodes, canEscape, onThrow, onReturn); case LocalDeclarationStatementSyntax ld: InjectThrowEdge(ld, nodes, onThrow); if (ld.UsingKeyword == default) @@ -608,9 +651,8 @@ or ImplicitObjectCreationExpressionSyntax var bodyOnReturn = new List(finallyNodes); bodyOnReturn.AddRange(onReturn ?? new List { new { op = "return", var = (string?)null, line = LineOf(trys) } }); - foreach (var stmt in trys.Block.Statements) - if (!LowerFlowStmt(stmt, tracked, model, nodes, bodyCanEscape, bodyOnThrow, bodyOnReturn)) - return false; + if (!LowerFlowStatements(trys.Block.Statements, 0, tracked, model, nodes, bodyCanEscape, bodyOnThrow, bodyOnReturn)) + return false; nodes.AddRange(finallyNodes); // normal completion runs the finally return true; } @@ -1941,7 +1983,15 @@ or ImplicitObjectCreationExpressionSyntax foreach (var ld in mbody.DescendantNodes().OfType()) { if (ld.UsingKeyword != default) + { + // A `using` local is auto-disposed (not a leak candidate). But a `using` MemoryPool + // owner whose Memory VIEW is returned dangles after the implicit scope-exit dispose — + // track it so the flow's using-desugaring catches that escape. Others stay skipped. + foreach (var v in ld.Declaration.Variables) + if (IsMemoryPoolRent(v.Initializer?.Value, model)) + candidates.Add(v.Identifier.Text); continue; + } foreach (var v in ld.Declaration.Variables) if (v.Initializer is { Value: ObjectCreationExpressionSyntax or ImplicitObjectCreationExpressionSyntax } init From 9230647bb0ff4603691acad71e430150b4001705 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 04:01:45 +0000 Subject: [PATCH 2/2] fix(pool): also catch the `using (...)` STATEMENT form of the MemoryPool view escape (CodeRabbit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #74's using-desugar only matched the `using` DECLARATION form (LocalDeclarationStatementSyntax with UsingKeyword). The equivalent `using (IMemoryOwner owner = MemoryPool.Rent(n)) { return owner.Memory; }` STATEMENT form has the same scope-exit dispose, so the returned view dangles the same way — but UsingStatementSyntax only lowered its body and the candidate pass only scanned LocalDeclarationStatementSyntax, so the statement-form owner was untracked and silent. Extend both: the candidate pass now scans UsingStatementSyntax declarations for IsMemoryPoolRent, and the UsingStatementSyntax flow case desugars a tracked MemoryPool owner into `acquire; try { body } finally { release }` exactly like the declaration form. Corpus: memorypool-using-statement-view-escape (-> OWN002; after.cs returns the owner -> silent). Benchmark recall floor 18 -> 19. Not in scope (distinct follow-up, Codex review): `using owner; return owner;` — returning the OWNER itself (not a view) stays silent because the escape pass untracks directly-returned owners as ownership transfers; catching it needs the escape pass to treat a using-owner's direct return as a dangling rather than a transfer. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Rg8kSk1YT14x7A1vo5zgED --- .github/workflows/ci.yml | 2 +- .../after.cs | 14 +++++++ .../before.cs | 23 +++++++++++ .../case.own | 17 ++++++++ .../expected-diagnostics.txt | 1 + .../notes.md | 27 +++++++++++++ frontend/roslyn/OwnSharp.Extractor/Program.cs | 40 ++++++++++++++++++- 7 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 corpus/real-world/memorypool-using-statement-view-escape/after.cs create mode 100644 corpus/real-world/memorypool-using-statement-view-escape/before.cs create mode 100644 corpus/real-world/memorypool-using-statement-view-escape/case.own create mode 100644 corpus/real-world/memorypool-using-statement-view-escape/expected-diagnostics.txt create mode 100644 corpus/real-world/memorypool-using-statement-view-escape/notes.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9c98fa9..41de58f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -798,5 +798,5 @@ jobs: # (`ViewOwner`), so reading it after Dispose trips OWN002 (POOL002 — `memorypool-view-after- # dispose`). Remaining backlog: a FIELD-mediated cross-method use-after-dispose, a view stored in # a FIELD, and an injected-source region-escape. A drop below the floor is a regression. - run: python scripts/benchmark.py --min-recall 18 + run: python scripts/benchmark.py --min-recall 19 diff --git a/corpus/real-world/memorypool-using-statement-view-escape/after.cs b/corpus/real-world/memorypool-using-statement-view-escape/after.cs new file mode 100644 index 00000000..2466da9d --- /dev/null +++ b/corpus/real-world/memorypool-using-statement-view-escape/after.cs @@ -0,0 +1,14 @@ +// AFTER (fixed). Ownership is TRANSFERRED to the caller: the method returns the +// `IMemoryOwner` directly (no `using` scope), so nothing is disposed here and the pooled +// buffer stays alive for as long as the caller keeps the owner. No dangling borrow, so the +// checker is silent. +using System; +using System.Buffers; + +static class MemoryPoolUsingStatementViewEscape +{ + static IMemoryOwner Borrow(int n) + { + return MemoryPool.Shared.Rent(n); // transfer ownership — the caller disposes it + } +} diff --git a/corpus/real-world/memorypool-using-statement-view-escape/before.cs b/corpus/real-world/memorypool-using-statement-view-escape/before.cs new file mode 100644 index 00000000..afc9ea74 --- /dev/null +++ b/corpus/real-world/memorypool-using-statement-view-escape/before.cs @@ -0,0 +1,23 @@ +// BEFORE (buggy). The STATEMENT form of `memorypool-using-view-escape` (POOL004). The +// MemoryPool owner is scoped by a `using (...)` STATEMENT rather than a `using` +// declaration, but the dispose semantics are identical: the owner is `Dispose()`d as the +// block exits, so `return owner.Memory` hands the caller a `Memory` backed by buffer +// already returned to the pool — a dangling borrow. Both `using` syntaxes need the same +// scope-exit desugaring (CodeRabbit review on #74). The fix is to transfer ownership: +// return the `IMemoryOwner` itself (see after.cs). +// +// Wrapped in a class so the extractor's per-class flow pass visits it. +using System; +using System.Buffers; + +static class MemoryPoolUsingStatementViewEscape +{ + static Memory Borrow(int n) + { + using (IMemoryOwner owner = MemoryPool.Shared.Rent(n)) + { + return owner.Memory; // <-- BUG: owner is disposed as the using block exits, so the + // caller reads a view of buffer already back in the pool + } + } +} diff --git a/corpus/real-world/memorypool-using-statement-view-escape/case.own b/corpus/real-world/memorypool-using-statement-view-escape/case.own new file mode 100644 index 00000000..5c506091 --- /dev/null +++ b/corpus/real-world/memorypool-using-statement-view-escape/case.own @@ -0,0 +1,17 @@ +// OwnLang model of the MemoryPool `using`-STATEMENT view escape (the `using (...) { … }` +// form of memorypool-using-view-escape). `acquire` == MemoryPool.Rent, `release` == the +// implicit scope-exit Dispose of the `using` statement. The block returns `owner.Memory` +// — a borrow of the owner — but the `using` disposes the owner FIRST, so the caller reads +// a view of memory already returned to the pool: a use-after-release lowered to OWN002. +// (The `.own` model is the same as the declaration form — the OwnLang reduction does not +// distinguish the two C# `using` syntaxes; the extractor desugars both identically.) +module Corpus +resource MemoryOwner { + acquire Rent + release Dispose +} +fn lease(n: int) { + let owner = acquire MemoryOwner(n); // using (... = MemoryPool.Rent) + release owner; // implicit scope-exit Dispose + use owner; // return owner.Memory — read by the caller AFTER Dispose -> OWN002 +} diff --git a/corpus/real-world/memorypool-using-statement-view-escape/expected-diagnostics.txt b/corpus/real-world/memorypool-using-statement-view-escape/expected-diagnostics.txt new file mode 100644 index 00000000..3a36fa92 --- /dev/null +++ b/corpus/real-world/memorypool-using-statement-view-escape/expected-diagnostics.txt @@ -0,0 +1 @@ +OWN002 diff --git a/corpus/real-world/memorypool-using-statement-view-escape/notes.md b/corpus/real-world/memorypool-using-statement-view-escape/notes.md new file mode 100644 index 00000000..991e6f64 --- /dev/null +++ b/corpus/real-world/memorypool-using-statement-view-escape/notes.md @@ -0,0 +1,27 @@ +# MemoryPool `using (...)`-statement view escape (POOL004, the statement form) + +**Pattern:** identical to `memorypool-using-view-escape`, but the owner is scoped by a +`using (...)` **statement** instead of a `using` **declaration**: +`using (IMemoryOwner owner = MemoryPool.Shared.Rent(n)) { return owner.Memory; }`. +Both syntaxes dispose the owner at scope exit, so the returned `Memory` dangles either +way — a use-after-free of pooled memory. The fix is the same: transfer ownership (return the +`IMemoryOwner`). + +**Why this case exists (CodeRabbit review on #74):** the `using`-declaration desugaring in +#74 only matched `LocalDeclarationStatementSyntax` with a `using` keyword. The statement +form (`UsingStatementSyntax`) was still lowered as a plain body, so its MemoryPool owner was +neither tracked nor release-threaded — the same dangle, silent. This slice extends the +desugaring (and the flow-locals candidate scan) to the `using (...)` statement form, so a +tracked `using (owner = MemoryPool.Rent(…)) { return owner.Memory; }` is lowered to +`acquire; try { body } finally { release }` exactly like the declaration form → **OWN002**. + +**What the checker says:** the OwnLang model and the real `before.cs` both trip **OWN002**. +The ownership-transfer fix in `after.cs` (return the owner directly, no `using`) is silent. + +**Honesty / scope.** `case.own` is a faithful hand reduction (not C# ingested by the +checker); `before.cs` / `after.cs` are representative of the bug and its fix. (Returning the +owner itself from a `using` scope — `using owner; return owner;` — is a distinct, follow-up +gap: the escape pass untracks directly-returned owners as ownership transfers.) + +Reference: [P-007](../../../docs/proposals/P-007-arraypool-span.md); the declaration form is +`memorypool-using-view-escape`. diff --git a/frontend/roslyn/OwnSharp.Extractor/Program.cs b/frontend/roslyn/OwnSharp.Extractor/Program.cs index 31851e17..8a063d99 100644 --- a/frontend/roslyn/OwnSharp.Extractor/Program.cs +++ b/frontend/roslyn/OwnSharp.Extractor/Program.cs @@ -524,9 +524,37 @@ or ImplicitObjectCreationExpressionSyntax return true; } case UsingStatementSyntax us: - // using(...) {body}: the using local is auto-disposed (untracked); still - // lower the body so a tracked plain local used inside is seen. + { + // using (IMemoryOwner owner = MemoryPool.Rent(...)) { body }: the STATEMENT form of the same + // scope-exit dispose as the `using` declaration — desugar a tracked MemoryPool owner the same + // way (acquire; thread the dispose onto the body's returns/throws; release on completion) so a + // returned view of it dangles -> OWN002 (CodeRabbit). Other using-statements just lower the + // body (the using local is auto-disposed, untracked). + if (us.Declaration is { Variables.Count: 1 } ud + && tracked.Contains(ud.Variables[0].Identifier.Text) + && IsMemoryPoolRent(ud.Variables[0].Initializer?.Value, model)) + { + var uv = ud.Variables[0]; + var owner = uv.Identifier.Text; + var exit = new List { new { op = "return", var = (string?)null, line = LineOf(us) } }; + nodes.Add(new { op = "acquire", var = owner, line = LineOf(uv) }); + var release = new { op = "release", var = owner, line = LineOf(uv) }; + var bodyOnReturn = new List { release }; + bodyOnReturn.AddRange(onReturn ?? exit); + List? bodyOnThrow = null; + if (canEscape) + { + bodyOnThrow = new List { release }; + bodyOnThrow.AddRange(onThrow ?? exit); + } + if (us.Statement is not null + && !LowerFlowStmt(us.Statement, tracked, model, nodes, canEscape, bodyOnThrow, bodyOnReturn)) + return false; + nodes.Add(release); // normal completion disposes at scope exit too + return true; + } return us.Statement is null || LowerFlowStmt(us.Statement, tracked, model, nodes, canEscape, onThrow, onReturn); + } case ReturnStatementSyntax rs: { // A tracked local READ in the return value is a use at the return point — @@ -2008,6 +2036,14 @@ or ImplicitObjectCreationExpressionSyntax } init else if (IsMemoryPoolRent(v.Initializer?.Value, model)) // MemoryPool IMemoryOwner (Dispose-released, NOT a poolBuffer) candidates.Add(v.Identifier.Text); } + // `using (IMemoryOwner owner = MemoryPool.Rent(...)) { … }` STATEMENT form: track the owner + // too, so its returned view dangles after the scope-exit dispose (the desugar mirrors the + // `using` DECLARATION form handled in the loop above). + foreach (var us in mbody.DescendantNodes().OfType()) + if (us.Declaration is { } usd) + foreach (var v in usd.Variables) + if (IsMemoryPoolRent(v.Initializer?.Value, model)) + candidates.Add(v.Identifier.Text); if (candidates.Count == 0) continue; // A local that escapes (returned / assigned out) is conservatively not