Skip to content

rc: a folded const's reference is not taken over by every receiver that meets it - #400

Merged
ASDAlexander77 merged 3 commits into
mainfrom
rc-consumption-over-release
Sep 29, 2026
Merged

ASDAlexander77 merged 3 commits into
mainfrom
rc-consumption-over-release

Conversation

@ASDAlexander77

@ASDAlexander77 ASDAlexander77 commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Fixes the rc over-releases that the -mm=own phase-0 review found (#399). The same programs double-free or read freed memory under -mm=rc today.

The bug

const a = new C() gets no storage: the name is the new's result, marked __owned_result. Every receiver that met a later mention of a took that one reference over:

shape effect under rc before
{ let b = a; } … a.v inner let releases it at scope exit; a reads freed memory
for (…) { let b = a; } taken every iteration: heap corruption (JIT 127, AOT 0xC0000374)
let b = a; let c = a; two holders, one count: heap corruption
arr.push(a); let d = a; same
for (…) arr.push(a) three elements, one count
for (…) h.s = s (fresh string) each overwrite releases the string being stored
const r = make(); { let b = r; } same, through OwnedReturnConsumptionPass
let b = h.c = new C() one new taken by the field and the local

test/tester/tests/00owned_named_consts.ts has all eight cases. Before this change each one failed on its own under -mm=rc, in JIT and AOT.

The fix

  • MLIROwnedReference.h gives every receiver the same question. A storing receiver may take a reference over only if the producer carries one, it hasn't been taken yet, and it isn't a folded const.

  • MLIRGen marks a folded const's producer __owned_result_named. Storing receivers retain it, and §9.30 gives the name's reference back at the end of its block.

  • A return or delete still takes it over, on every path (if (x) return c; return c;).

  • OwnedReturnConsumptionPass takes over every return retain of a named call result, not just the first.

  • -mm=own: one taker plus the temporary's later release in the same block count as a move when rc gave the taker a reference of its own. So const a = new C(); let b = a; stays accepted. NewInterface gets no reference from rc, so it is still rejected as a second owner.

  • A leak the change exposed is fixed too. §9.30 released a temporary only if every use was in its own block, but an if/loop body is a nested region. const a = new C(); if (x) { let b = a; } therefore never released a: 1M calls reached 89.2 MB at -O1 under rc, and 4.7 MB after the fix. Uses inside if/loop/switch/label/try bodies now count as in-block. Nested functions and async.execute bodies still don't.

Cost: a retain/release pair for const a = new C(); arr.push(a), which used to be free.

Verification

  • Release suite (Windows): 3102/3102.
  • Debug ownership subset: the new tests pass. The three failures there also happen on main: the async runtime's ~AsyncToken debug-heap assert (fails under -mm=none too), and the linkname DictionaryAttr assert, which now lands in verifier shard 4.
  • Peak memory for 1M const p = make(); let q = p;: rc 4.2 MB, own 4.2 MB, none 50.3 MB.
  • Peak memory for 1M two-return calls: rc 4.7 MB. An intermediate version of this fix leaked it to 20.1 MB.
  • Linux not run.

Still open (recorded in docs)

  • rc leaks a named const on the non-returning path of if (x) return c; use(c). This predates the branch.
  • own rejects a field initialized from an eight-number array literal (four are fine). Not yet diagnosed. It's phase-1 input.

Docs: reference-counting-evaluation.md §9.79; own spec §11.1 and §11.4.

🤖 Generated with Claude Code

ASDAlexander77 and others added 3 commits September 29, 2026 10:55
`const a = new C()` has no storage: the name is the `new`'s own result, marked
__owned_result, so every later mention offered that one reference to its receiver,
and each took it. An inner `let` gave it back while the name still read it; two
`let`s, or a push then a `let`, gave one count two holders; a push or a field store
in a loop took it on every iteration. `let b = h.c = new C()` took one `new` twice.
Under rc these double-freed or read freed memory (00owned_named_consts.ts: eight
cases, each failing alone under -mm=rc before this change).

- MLIROwnedReference.h: carriesUnclaimedReference (owned, not yet consumed) and
  mayTakeOverReference (also not a folded const), used by every receiver.
- MLIRGen marks a folded const's producer __owned_result_named. Storing receivers
  retain it; a return or a delete still takes it over.
- OwnedReturnConsumptionPass keeps a named call result's receiver retain unless it
  is the one a return took.
- -mm=own: a taker plus the temporary's later release in the same block is a move
  when rc gave the taker its own reference, so `const a = new C(); let b = a;` stays
  accepted. NewInterface, which rc gives no reference, is still a second owner.

Docs: reference-counting-evaluation.md 9.79; own spec 11.1 and 11.4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… first

The first return marked the producer consumed, so a second return on another path
(`if (x) return c; return c;`) retained instead and handed its caller two
references: 1M calls grew from 4.7 MB to 20.1 MB under -mm=rc. A return or delete
may take a named value over even when it is marked consumed, since no storing
receiver takes a named value and returns on different paths exclude each other.
OwnedReturnConsumptionPass likewise takes over every retain that feeds a return
of a named call result, where it took only the first (that half predates this).

Records as open: a named const still leaks on the path that does not return.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…at its block end

Section 9.30 released an unclaimed reference only when every use sat in the producer's
own block, and a statement body is a region of an op in that block. With named consts
now retained by their receivers, `const a = new C(); if (x) { let b = a; }` never gave
`a`'s own reference back: 1M calls reached 89.2 MB at -O1 under rc (none 89.2; -O3
hid it by removing the allocation). A use nested in an if, loop, switch, label or try
body now counts as in the block, since those bodies finish before the next op runs;
a nested function or an async.execute body still does not. After: 4.7 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ASDAlexander77
ASDAlexander77 merged commit ba80c55 into main Sep 29, 2026
2 checks passed
@ASDAlexander77
ASDAlexander77 deleted the rc-consumption-over-release branch September 29, 2026 10:25
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.

1 participant