Skip to content

-mm=own, phase 3: containers and unions - #403

Merged
ASDAlexander77 merged 6 commits into
mainfrom
own-phase-3
Sep 29, 2026
Merged

ASDAlexander77 merged 6 commits into
mainfrom
own-phase-3

Conversation

@ASDAlexander77

Copy link
Copy Markdown
Owner

Phase 3 of -mm=own (spec §7): containers and unions. Only OwnershipInferencePass.cpp changes; gc, rc and none are untouched. Plan: docs/superpowers/plans/2026-09-29-own-phase-3.md. Results: spec §14.

What changes

  • Unions and optionals are views. A cast between class, union and optional types, CreateUnionInstance, GetValueFromUnionOp, OptionalValue and Value hand back the block their operand holds. let u: C | null = new C(i), let u: C | string = c and let u: C | undefined now compile as one owner. Before, they were rejected as "takes a second reference".
  • A read out of a field or an element is a borrow. This closes a soundness hole: const c = h.c; h.c = new C(); c.x used to compile into a read of freed memory under own (rc has the same bug). A read may not be kept ('c' borrows 'h.c' and cannot be stored, returned or captured). No use of it, or of what it produces, may be reachable from:
    • an overwrite of a place on its way to the root;
    • a pop, shift, splice or length =;
    • an end of the root (release, assignment, move);
    • a call that may reach the root.
  • A let declared from a read borrows it (let c = h.c, let e = arr[j]): no reference taken, none given back.
  • Borrows are followed through ?? merges and through locals that own nothing, each checked against where that value is made again.

Verification

  • Release suite 3196/3196.
  • Debug own tests 301/303. The two failures, test-jit-rc-owned-async and test-jit-none-owned-async, fail on main too (the ~AsyncToken debug-heap assert). ownership-verifier-4 was excluded; it also fails on main (the linkname assert).
  • Memory over 1M iterations: own is flat like rc (4.7–4.8 MB); none goes up to 582 MB.
  • Teeth:
    • with a move's releases kept, the union positives fail with heap corruption;
    • with a let borrower's releases kept, the let positives fail the same way;
    • the new negatives compiled before this change, and five of them printed 999 (freed memory) under rc and own.
  • Corpus under own: 252 → 272 of 564 compile. 3 stopped, all ${this.color} … ${this.area()}, per the ruling on calls below.

Rulings

  • any boxing moves to phase 4. Unboxing is a generated ___unbox<T> that returns +1, so treating it as a borrow needs phase 4's borrowed-from-argument result. Found along the way: rc never frees the box of const a: any = new C(i) (66 MB against gc's 6 MB).
  • Any call drops a borrow of a parameter's, this's or a global's field. The callee may reach the owner through a global. This is sound but restrictive until phase 4's callee facts.
  • Overwrites are matched by field position and type. This can only add errors, never miss one.

Final review

A fresh reviewer found two unsound cases and one false error, all fixed with tests that failed first:

  • a borrow stored into a non-owning local and read on a later iteration after the overwrite;
  • a read through cond ? a : b where a is then assigned;
  • cond ? h.c : h.d rejected after any earlier call.

Deferred minors: 'this value' naming for temporaries and merges; an extra error for nested reads; u.c ??= new C() is rejected. Also found, not own: const a2 = arr; arr.push(...) in a loop crashes the compiler under every model.

🤖 Generated with Claude Code

ASDAlexander77 and others added 6 commits September 29, 2026 15:16
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A widening or narrowing cast between class, union and optional types,
CreateUnionInstance, GetValueFromUnionOp, OptionalValue and Value hand back the
block their operand holds. The pass strips them to the root wherever it names a
value and looks through them wherever it lists a value's uses, so
`let u: C | null = new C(i)` is one owner, not a second reference. An empty
optional (`u = undefined`) holds no block, like `null` widened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the container drops it

`const c = h.c` and `arr[i]` read a value the container owns, and until now nothing
bounded them: an overwrite of the field or element, a pop, an assignment or move of
the owner, or a call given it could free the value while it was still read (rc has
the same hole). Such a read is now a borrow. It may not be kept (a store or a
retain is an error), and no use of it - nor of anything it produces, passed on
through a merge or a local that owns nothing - may be reachable from:
  - an overwrite of a place on the way to its root (matched by position and type);
  - a pop, shift, splice or length assignment of an array of its element type;
  - an end of the root: its releases, assignments and moves;
  - a call given something derived from the root, or any call where the root is a
    parameter, `this` or a global, whose owner the callee may reach.
A path back to a use through the read is a new borrow.

Testing an optional (`if (d)`, `d?.x`) and `typeof` are reads, not takers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`let c = h.c` and `let e = arr[j]` take no reference and give none back: the
let's RetainSlot and ReleaseSlots go, and its reads join the read's uses, bounded
by what may destroy the container's value. An assignment of such a let is an
error, as for phase 2's borrowers. tryBorrow's walk over a borrower's slot is
collectBorrowerUses, shared by both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Spec section 14: views, reads out of containers as borrows, let receivers of them;
measured flat like rc; teeth; corpus 252 -> 272 of 564; any boxing moved to phase 4
with the rc box leak it found; known limits for phase 4.

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

The final review found three holes in following a borrow past a merge:
- a borrow stored into a local that owns nothing and read on a later iteration,
  after the overwrite, was taken for the next iteration's borrow and compiled
  into a read of freed memory;
- a merge of two reads was rejected after any earlier call, through the other
  branch;
- a read through a merge of two owning locals saw one root it did not own, with
  no ends, and compiled an assignment of either local into a read of freed memory.

Each borrowed use now carries where its value is made again: the read or the
borrowing let for its own uses, the merge's block past a merge, and a local's
declaration and assignments past a local that owns nothing. The branch or store
that hands the value on is a use bounded by the read. reachableAfter takes a set
of kills. A container reached through a merge has each merged value as a root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ASDAlexander77
ASDAlexander77 merged commit 6e76af1 into main Sep 29, 2026
2 checks passed
@ASDAlexander77
ASDAlexander77 deleted the own-phase-3 branch September 29, 2026 15:41
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