Skip to content

-mm=own phase 7a: generators and async; an interface is a view of its object - #439

Merged
ASDAlexander77 merged 5 commits into
mainfrom
own-phase-7
Oct 2, 2026
Merged

ASDAlexander77 merged 5 commits into
mainfrom
own-phase-7

Conversation

@ASDAlexander77

Copy link
Copy Markdown
Owner

Phase 7a of -mm=own: generators and async (spec §2.7, §7 phase 7). The plan is docs/superpowers/plans/2026-10-02-own-phase-7.md and the results are in spec §18. The PR also fixes two use-after-free holes that already exist on main.

Pre-existing holes, fixed

  • An object literal cast to an interface was freed twice. The object-literal interface cast marks its ts.NewInterface __owned_result, and isFresh read that as a block of its own. On main, const o = {...}; use(o); use(o), with use taking an interface, frees o twice and crashes. ts.NewInterface is now a view of its object, the way a union or an optional is, and a view is exactly as fresh as its root:
    • let i = <I>raw borrows raw. rc's ts.Retain of the interface is the reference of the let that consumes it, and a borrow erases it.
    • Two releases of one block, where one can run after the other, are two owners and an error. Regression test: own_err_interface_two_views.
  • Async bodies were never checked. The async lowering outlines a for await body into a func.func, and neither own pass looked inside one. A retain there failed in the lowering (00for_await_yield). Worse, a call there to a callee that keeps its argument got no facts: the callee moved the argument while the body still released it. Both passes now run on every function. This one is verified in the IR only, because a run can't show it (see the last section).

Generators

  • The state object is an ordinary owned block. Its maker makes it with ts.New and returns it cast to !ts.object, and that cast is a view. In the pass, a ts.New'd value_ref seen as its object owns a block, so it is checked: ownsHeapMemory says no for every value_ref. The store that fills it is a borrow.
  • The initial state holds only constants, so rc's retain of it is erased.
  • A generator's locals are fields of the state object, and the existing container rules handle them, reassignment included.
  • A yield v builds a {value, done} record and returns it. A record built to be returned takes the fresh values stored into it: each store is that value's taker. The same rule covers a plain return { value: new C(), done: false }.
  • The caller's next() goes through the state object's field. The signature pass now resolves a function read from a field when that field only ever holds one function, and marks the call's result fresh.
  • What a generator captures sits in a box in the state object's .captured field. That box is treated as an escaping closure's box, so the 5b rules apply: a number parameter moves in, and an object parameter is 5b's "its value is the caller's" error. An object literal whose methods capture keeps its box the same way.

For review: fieldFunctions (signature pass) is a closed-world rule over the module. It doesn't consult open or privacy. It requires that every ts.Constant of the tuple type holds the same function in that field, that nothing stores into the field (from a method too, which sees object_storage), and that no other op makes a value of the type. It is safe for a generator's state type because the type of next carries a per-site object_storage symbol that no other module can spell. own_err_field_function_reassigned pins the "nothing stores into it" condition.

Verification

  • Release suite: 3383 of 3384 pass. The one failure, test-compile-gc-defaultlib-collector, is the same as on -mm=own phase 5a: a closure that does not escape borrows what it captures #436/-mm=own phase 5b: a closure that escapes owns what it captures; a call in a try body is a call #437: the installed default library at I:\tslang is stale. All 381 own tests pass.

  • WSL/GCC: builds, and 380 of 380 own tests pass (Windows has one more, the Windows-only shared-library check).

  • Corpus: 325 → 359 of 591.

    • Every newly compiling file passes under test-runner -mm=own, AOT and JIT, and so does every -shared pair whose two halves both compile. Their JIT output matches gc's, except the two async files, which only run under test-runner.
    • Two files that compiled on main are errors now, both correctly. 00object stores one object literal into two tuples, and rc leaks it there. 00interface_object_array reads an object after moving it into an array.
  • Memory (AOT, measure.ps1, MB):

    test gc rc none own
    own_generator 6.1 11.2 1548.6 4.3
    own_record_return 5.9 4.2 224.0 4.2
    own_interface_view 5.9 4.3 551.1 4.3
    own_async 6.1 5.7 334.7 5.8
  • Teeth: each new rule was undone with a temporary switch:

    • keeping the frame's ReleaseCell after a cell moves into a generator's box breaks own_generator, AOT (0xC0000374) and JIT;
    • keeping a temporary's release after it moves into a record breaks own_record_return, AOT (0xC0000374) and JIT;
    • skipping the released-twice check makes own_err_interface_two_views compile and crash;
    • resolving a reassigned field makes own_err_field_function_reassigned compile.

Tests

  • Positive:
    • own_generator: numbers, a class local, a reassigned local, a generator given up early, fresh objects and strings yielded through for...of and by hand, number parameters, an object literal with a method.
    • own_record_return.
    • own_interface_view.
    • own_async: a heap local across await, a fresh result, a heap parameter, a string, a for await body.
  • Negatives: own_err_interface_two_views, own_err_field_function_reassigned, own_err_generator_param_object.
  • Removed: own_err_interface, a phase 0 placeholder for an interface over a class instance, which now compiles.
  • Changed check: the no-counting test's fail pattern is error: now, because error alone matched the async runtime's message string "Awaited async operand is in error state".

Found, not fixed

  • A temporary made just before a yield is never released, under rc as well. OwnedReturnConsumptionPass doesn't release past a resume point, so yield "s" + i leaks the string made from i.
  • rc leaks yielded objects. That's why rc is above gc on own_generator.
  • await f() returns before f's for await body has run, under every model: after for await (const x of g()) keep.push(1), keep is empty. That's why the async-body facts fix can't get a runtime test.
  • Phase 7b: a generator over an object parameter, which needs the result to borrow the argument; .map/.filter; and yielded borrows.

🤖 Generated with Claude Code

ASDAlexander77 and others added 5 commits October 2, 2026 11:57
The maker of a generator makes its state object with `ts.New` and casts the `value_ref` to the
`!ts.object` it returns. The cast is the same block, so it is now a view, and the maker's result is
fresh: a generator whose locals own nothing compiles. An object literal with methods is the same
shape.

With that, `let raw = {...}; let i = <I>raw` gave `raw`'s block two owners, because the object
literal's interface cast is a `ts.NewInterface {__owned_result}` and `isFresh` took the mark at its
word. A `ts.NewInterface` is now fresh only when the object it wraps is fresh and goes nowhere
else.

Plan: docs/superpowers/plans/2026-10-02-own-phase-7.md.

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

The first commit's rule for `ts.NewInterface` (fresh only when its object is used nowhere else)
was sound but lost twelve corpus files that compile on main, and main was not sound either: a
folded object literal given twice as an interface (`use(o); use(o)`) is freed twice there. An
interface over an object is now a view of it, as a union or an optional is, and a view is as fresh
as its root:

- `let ri = <I>raw` borrows `raw`. rc's `ts.Retain` of the interface is the reference of the `let`
  that consumes it, and a borrow erases it.
- A class instance behind an interface is one block, so `own_err_interface` compiles (its two
  instances are released once each) and is gone, with the first commit's alias negative.
- Two releases of one block, one reachable after the other, are two owners: the error
  `own_err_interface_two_views` pins.

Checking the view's root showed that it had not been checked at all: `ownsHeapMemory` says a
`value_ref` owns nothing, so a `ts.New`'d block passed every check. Inside the pass such a block
seen as its object owns now, and the store that fills it is a borrow, not a taker.

The initial state a generator's maker copies into its state object is a tuple loaded from a local
a `ts.Constant` initializes. Where nothing stores into its owning fields it holds nulls, numbers
and immortal literals, and rc's retain of it is erased: a generator whose locals own blocks
compiles.

Corpus: 325 of 591 on main, 353 now. Two files that compiled on main are errors now, both
correctly: `00object` stores one object literal into two tuples (rc leaks it), and
`00interface_object_array` reads an object after it moved into an array. Every newly compiling
file runs under test-runner -mm=own, AOT and JIT, with gc's output.

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

A `yield v` stores `v` into a `{value, done}` record and returns it, and the caller calls `next`
through the state object. Three pieces:

- A record built to be returned is a local a `ts.Constant` initializes, read once, the read only
  returned. A fresh value stored into one of its fields moves in: the store is the value's taker,
  and rc's retain of the record, after it is filled, is the acquisition, so the temporary's
  release after it goes. The record's own retain goes with the initial-state rule.
- The signature pass resolves a function read out of an object's field when that field only ever
  holds one function: every `ts.Constant` of the object's tuple type holds it there, nothing
  stores into the field (a method sees its object as `object_storage`, whose fields count too),
  and no other op makes a value of the type. A generator's `next` is such a field. The call gets
  `__own_fresh_result`: `next` is open (its symbol sits in a constant), so its body may not return
  a borrow.
- The inference runs on every function, including the `func.func` the async lowering outlines a
  `for await` body or an `await`'s continuation into. Before, a retain there was neither erased
  nor reported, and `00for_await_yield` failed in the lowering with "ownership inference left a
  retain behind".

Also two tightenings of the previous commit: a store through a value is a borrow only for a
`value_ref`, and a borrow erases only the `ts.Retain` its `let` was found from.

`own_generator` yields fresh objects and strings, read past a churn() by for...of and by hand:
gc 6.1 MB, rc 11.2, none 1221.0, own 4.3. Found, not fixed (rc and own): a temporary made in front
of a `yield` in the generator's body is never released, since OwnedReturnConsumptionPass does not
release past a resume point (`yield "s" + i` leaks the number's string), and rc leaks yielded
objects.

Corpus: 355 of 591 (main 325).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ect; spec section 18

A generator's maker puts the cells of its parameters into a capture box and the box into the
state object's `.captured` field. There is no closure value, but the box is an escaping closure's:
the store into the state is its escape, and phase 5b's rules apply as they are. A cell holding a
number moves in (`range(from, to)`); a parameter's object is the 5b error "its value is the
caller's" (`own_err_generator_param_object`). rc's `ts.RetainCell` of the box is the state
object's reference and moves in with it. An object literal whose methods capture keeps its box the
same way.

Tests: `own_generator` gains number parameters, and `own_record_return` (a plain function
returning a record that holds a fresh object) and `own_async` (from the phase's probe, plus a
`for await` body) are new. The no-counting check fails on `error:` now: `error` alone matched the
async runtime's message "Awaited async operand is in error state".

Teeth, each a temporary getenv switch: keeping the frame's ReleaseCell after a move into a
generator's box, or a value's temporary release after it moves into a returned record, fails
under AOT (0xC0000374) and JIT; skipping the released-twice check, or resolving a reassigned field,
makes the matching negative compile.

Corpus: 325 of 591 on main, 359 now. Spec section 18 has the results, the found-not-fixed list,
and the input to 7b.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The signature pass collected only the calls inside a `ts.Func`. A call in the `func.func` the
async lowering outlines a `for await` body into got no facts, while findOpen counted it as a
resolved call. So a callee that keeps its argument (`stash(c)` pushing into a global) was closed
and moved the argument in its own body, while the body that called it kept its release: a use
after free, on main as well. Calls, and callee-only references, are collected in any function now.

Verified in the IR: the release after `stash(c)` is gone. A run cannot show it, under any model:
`await f()` returns before `f`'s `for await` body has run (spec 18.5).

Spec section 18: that, and the wording of 18.2 and 18.5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ASDAlexander77
ASDAlexander77 merged commit af7b150 into main Oct 2, 2026
2 checks passed
@ASDAlexander77
ASDAlexander77 deleted the own-phase-7 branch October 2, 2026 12:59
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