Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions tslang/docs/superpowers/plans/2026-10-02-own-phase-7.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
# `-mm=own` Phase 7 Implementation Plan: generators and async

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development
> (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use
> checkbox (`- [ ]`) syntax for tracking.

**Goal:** Under `-mm=own`, a generator compiles when its ownership can be proven (spec §2.7, §7
phase 7: "the state object as owner"). Today even `function* g() { yield 1; }` is an error, and
about 40 corpus files with a generator, an iterator object or a `.map`/`.filter` (which MLIRGen
builds as a generator) stop on one.

After this phase:

- A generator's **state object** is an ordinary owned block: its maker makes it and returns it,
and its caller owns it like any other result.
- The generator's **locals** are fields of the state object. A store into one is a move into the
container, a read is a borrow bounded by it, and reassignment releases the old value, as for any
field.
- A **yielded value** leaves `next` in its result, which the caller owns.
- A **captured variable** of a generator (a parameter, an outer local) is a cell in a box that the
state object holds: the escaping-closure rule of phase 5b, so a cell holding a number moves in
and a parameter's object is an error (7b decides whether it can borrow).
- **Async** already works under own and gets its test.

**Architecture:** inference and signature passes only. MLIRGen does not change
(`test-own-mlirgen-matches-rc` keeps passing), and nothing new reaches the lowering in 7a.

**Tech Stack:** C++17, MLIR, CMake/ctest, `test/tester/test-runner.cpp`,
`test/tester/tools/measure.ps1`.

**Spec:** `docs/superpowers/specs/2026-09-24-own-memory-model-design.md`: §2.1 (the state object is
a mortal block), §2.7, §7. Results go into §18.

## Global Constraints

- Branch `own-phase-7` from `origin/main` (8cfb0f17, with #437 merged).
- `gc`, `rc` and `none` output does not change.
- Never branch on the memory model in MLIRGen (§4.3).
- `__tslang_inc_ref` and `__tslang_dec_ref` never appear in own output.
- Own tests run with `--no-default-lib`, under JIT and AOT, and read through a `churn()` after the
point where a wrong release would free. Expected values are checked under `gc` first.
- Every corpus file that a change flips to "compiles" is run through test-runner `-mm=own`, AOT and
JIT, and its output compared with gc's, before the change is committed.
- Build: `cmake --build __build/tslang/windows-msbuild-2026-release --config Release --target tslang test-runner`
from `I:/TypeScriptCompiler`. Test: `ctest -j 16 -C Release` from the build directory. Build the
branch in WSL (GCC) before pushing.
- Source files are CRLF. Commits are GPG-signed, each ending with the session's `Co-Authored-By:`.
Commit before any temporary teeth switch.
- Check IR shapes with `--emit=mlir-affine --own-skip-inference`.
- `measure.ps1` reads `test/tester/compile.bat`, which only a test-runner run without `-mm` writes
(`ctest -C Release -R "^test-compile-00-print$"`).
- Baseline corpus on this tree: 325 of 591 compile under `-mm=own --no-default-lib`.

## Shapes (measured on 8cfb0f17)

- **The maker** `g` builds the initial state as a `ts.Constant` tuple
`{".step", <locals>..., "next" = @<g>.next, ".captured"?}` in a `ts.Variable`, loads it, and stores
it into `N = ts.New : !ts.value_ref<T>`. Then it does `O = ts.Cast(N) : !ts.object<T>`,
`ts.Retain(O)`, stores `O` into its result variable and returns it. The state's release routine
is generated from `T` and destroys every owning field and the `.captured` box (`tsrelcb_`).
- **The initial tuple** is `ts.Retain`ed before the store into `N` when `T` has an owning field (a
local of class type). The constant holds `unit` there, so the retain holds nothing.
- **`next`** takes the state as `%arg0` and keeps it in a `ts.Variable`. Each local is
`ts.PropertyRef(ts.Load(var), i)`, a store into it is a field store, and a reassignment is
`ts.ReleaseSlot` + `ts.Store`, as for any field. A `yield v` stores `v` into the `{value, done}`
result tuple, which rc `ts.Retain`s when `v` owns a block.
- **The caller** calls `next` through the state object:
`ts.CallInternal(ts.GetMethod(F), ts.GetThis(F))`, `F = ts.Load(ts.PropertyRef(S, next))`. The
signature pass does not resolve it, so the result is not fresh.
- **A captured variable** (a parameter, an outer local) is a captured `ts.Variable` cell, put in a
box (`ts.Variable {captured, __capture_box}`) and `ts.RetainCell`ed. The box is stored into the
initial tuple's `.captured` field, which goes into `N`. The frame's `ts.ReleaseCell` of the cell
follows the `ts.Retain(O)`. The box has no frame release, so it moves into the state already.
- **Async** functions lower to an `async.execute` region whose locals are ordinary locals. The
probe (a heap local across an `await`, a fresh object returned, a heap parameter read after an
`await`, a string) compiles and runs under own, AOT and JIT, with gc's output, at 5.8 MB (gc
6.1, rc 5.7, none 334.5).
- **An object literal with methods** is the same `ts.New`/`ts.Cast` to `!ts.object<T>` shape as
the state object. The cast of one to an interface is `ts.NewInterface {__owned_result}`.

## Rulings made while planning

- **The cast of a `ts.New`'d `value_ref` to its `!ts.object` is a view** (`isView`): the same block.
This is what makes the maker's result fresh.
- **`ts.NewInterface` is a view of the object it wraps**, and a view is exactly as fresh as its root.
The object-literal interface cast marks it `__owned_result`, and `isFresh` read that mark as a
block of its own: with the view above, `let raw = {...}; let i = <I>raw` gave `raw`'s block two
owners (double free, found by the gate on `00object_annotated_method_extends_interface`), and
on `main` already a folded literal given twice as an interface (`use(o); use(o)`) is freed twice.
As a view, the interface is a receiver of its object: `let i = <I>raw` borrows `raw` (rc's
`ts.Retain` of the interface is the consuming `let`'s reference, and a borrow erases it), and a
class instance behind an interface is one block (`own_err_interface` compiles).
- **A temporary is released once on each path.** Two releases of one block, one reachable after the
other, are two owners: the error at the second view.
- **A `ts.New`'d `value_ref` seen as its object owns a block** inside the pass (`ownsHeapMemory`
says no for any `value_ref`), so its root is checked, and a store through it (filling it) is a
borrow, not a taker.
- **The initial tuple's retain holds nothing.** A `ts.Retain` of a `ts.Load` of a `ts.Variable` whose
initializer is a `ts.Constant`, and whose owning fields are never stored into, is erased.
- **A call of a generator's `next` resolves to it.** A function read out of field `p` of an object of
tuple type `T` resolves to `@f` when, in this module, every `ts.Constant` of `T` holds `@f` at `p`
and nothing stores into field `p` of a `T`. The call then gets that function's facts, and its
result is fresh (`__own_fresh_result`). Cost if wrong: a fresh result that is someone else's.
- **A generator's capture box is an escaping closure's box.** `closureOfBox` learns the state
object: a box stored into `.captured` of a tuple that goes into a `ts.New`. Phase 5b's rules then
apply as they are: cells move in at the box's fill, the frame's `ts.ReleaseCell`s go, a frame
use after is an error, and a parameter's heap cell is the 5b error.
- **Phasing.** 7a: tasks 0-4. 7b, a parameter's object borrowed by the generator (its result
borrows the argument; a box that frees its cells but not their values) and the synthesized
`.map`/`.filter`, is scoped from the corpus after 7a. One PR each.

## Review Focus

1. The views and `ts.NewInterface`: every block still has one owner. An interface over a `let` borrows
it, a literal cast directly is one block, and two interfaces rc counts separately are an error.
2. `next` resolution: a field that can hold anything but the one function must stay unresolved.
3. The initial-tuple retain: erased only when nothing mortal can be in the tuple.

---

### Task 0: the state object is a fresh block

**Files:** `lib/TypeScript/OwnershipFacts.h` (`isView`, `isFresh`).

- [x] `ts.Cast` from `!ts.value_ref<T>` to `!ts.object<T>` is a view.
- [x] `ts.NewInterface` is a view; a view is as fresh as its root; a consuming `let` of a view can
borrow.
- [x] A block released twice on one path is an error.
- [x] Gate: the flipped corpus files, the own ctest subset.

**Tests:** positives `own_generator.ts` (numbers yielded through `for...of` and by hand),
`own_interface_view.ts`; negative `own_err_interface_two_views.ts`. `own_err_interface` and the
first commit's `own_err_object_literal_interface_alias` compile now and are gone.

### Task 1: the initial state

**Files:** `lib/TypeScript/OwnershipInferencePass.cpp`.

- [x] Erase the initial tuple's `ts.Retain` when the tuple holds nothing mortal.

**Tests:** `own_generator.ts`: a class local, reassigned between yields.

### Task 2: the caller's `next`

**Files:** `lib/TypeScript/OwnershipSignaturePass.cpp` (`resolveValue`), `lib/TypeScript/OwnershipFacts.h`.

- [x] Resolve a function read from a field that only ever holds one function.
- [x] Pin `__own_fresh_result` on such a call to a defined function.
- [x] A record built to be returned (a local a `ts.Constant` initializes, read once, the read only
returned) takes fresh values into its fields: each store is the value's taker, and rc's
retain of the record is where it moves in.
- [x] The inference runs on the `func.func`s the async lowering outlines (a `for await` body, an
`await`'s continuation): a retain there was neither erased nor reported.

**Tests:** `own_generator.ts`: yields of fresh objects and strings, kept by the caller past a
`churn()`. Negative: a field that is assigned another function stays unresolved
(`own_err_field_function_reassigned.ts`).

### Task 3: captured variables

**Files:** `lib/TypeScript/OwnershipInferencePass.cpp` (`closureOfBox`, `classifyClosures`).

- [x] A box stored into the state's `.captured` is an escaping closure's box.

**Tests:** `own_generator.ts`: number parameters. Negative: `own_err_generator_param_object.ts`
(the 5b error). A frame use after the capture cannot be written: the maker's body is the
generator's own, which only builds the state and returns it. An outer local captured by a nested
generator is an inherited cell, 5b's error.

### Task 4: async, measure, teeth, corpus, spec

- [x] `own_async.ts` from the probe, and `own_record_return.ts`.
- [x] `measure.ps1` for the positives under all four models.
- [x] Teeth: keep the frame's `ts.ReleaseCell` of a captured local after its move (the generator
reads a freed cell); let a reassigned field's function resolve (the negative compiles);
read every `ts.NewInterface {__owned_result}` as fresh (the alias negative compiles and
frees twice).
- [x] Corpus before/after; spec §18; memory.
Loading
Loading