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
218 changes: 218 additions & 0 deletions tslang/docs/superpowers/plans/2026-09-29-own-phase-4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
# `-mm=own` Phase 4 Implementation Plan: function signatures and `any`

> **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 function's body tells its callers three facts (spec §2.4, §3.2), and
the inference uses them at every call it can resolve. Today:

- `function keep(h: H, c: C) { h.c = c; }` is rejected ("takes a second reference"): nothing lets
a callee keep what it is given.
- `get c() { return this.c; }` and `function first(h: H) { return h.c; }` are rejected ("borrows
'this.c' and cannot be stored, returned or captured").
- `` `${this.color} area=${this.area()}` `` is rejected: any call drops a borrow of a field of
`this`, even one to a method that only reads numbers (§14.4's three corpus regressions).
- `any` is rejected, and rc leaks every `any` box that no `let` holds (§14.5: rc 66.2 MB).

After this phase all four compile, and the unsound shapes around them are errors.

**Architecture:**

1. **`OwnershipSignaturePass`** (new module pass, own only, just before inference). It resolves
every call to the functions it may reach, computes each function's facts, and pins them on the
call ops, where the per-function inference reads them without looking anything up:
- `__own_params` (array of indices): the parameters the callee keeps (owned-by-callee).
- `__own_result_borrows` (index): the result is a borrow of that argument.
- `__own_no_drops` (unit): the callee destroys nothing its caller can reach.
The same attributes on a `ts.Func` are the facts its own body must honour.
2. **`OwnershipInferencePass`** learns the three facts on both sides of a call.
3. **MLIRGen**: the boxing cast into `any` carries its birth reference like every other fresh
value (`__owned_result` + `ts.Retain`), which is rc's leak fix and own's owner.

**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.4 (calls), §3.2 (the
signature pass), §14.5-14.6 (this phase's input).

## Global Constraints

- Branch `own-phase-4` from `origin/main` (edaf802a, with #403 and #404 merged).
- `gc`, `rc` and `none` output does not change, except Task 5's deliberate rc leak fix (the `any`
box), which changes rc's and gc's IR by one retain and one release per box.
- Never branch on the memory model in MLIRGen (§4.3). The signature pass runs only under own.
- `__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.
- 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.
- Source files are CRLF. Commits are GPG-signed, each ending with the session's `Co-Authored-By:`.
- Check IR shapes with `--emit=mlir-affine --own-skip-inference` and, since CSE runs first under
`--opt`/JIT, with `--emit=jit --mlir-print-ir-after-all --mlir-disable-threading`.
- Baseline corpus on this tree: 272 of 564 compile under `-mm=own --no-default-lib`.

## Shapes (measured on edaf802a)

- A parameter arrives as a non-owning `ts.Variable(%argN)`; `this` is `%arg0`. rc never releases
it. A callee that keeps it reads the slot, `ts.Retain`s what it read and stores it.
- A result goes through a non-owning result slot: `ts.Store(v, %ret)`, then `ts.Load(%ret)` into
`ts.ReturnInternal`. A returned field read is `ts.Retain`ed before its store: rc returns +1.
- The caller releases a call's result as a temporary (`ts.Release`), or declares a
`__owned_consumed` `let` from it (no `ts.RetainSlot`, one `ts.ReleaseSlot`).
- Every method call goes through the vtable, even with no subclass: `ts.ThisVirtualSymbolRef` or
`ts.VirtualSymbolRef` (`identifier`, `index`) feeding `ts.CallInternal`, directly or through
`ts.GetMethod`. An override sits at the same position of its class's `..vtbl` global under the
same method name (`B.get` and `D.get` at 2).
- `___unbox<T>` reads `ts.Unbox` of its parameter, calls the payload's `.instanceOf` through the
vtable's first slot (`ts.VTableOffsetRef` index 0, twice), casts `!ts.opaque` to `T`, retains it
and returns it. `___cast<T, U>` returns its parameter narrowed on each branch, or `null`.
- The boxing cast is +0 and unmarked. A box a `let` holds gets a `ts.RetainSlot` and is freed; a
box held by a folded `const` or passed straight to a call is never released.

## Rulings made while planning

- **Closed world.** An owned parameter or a borrowed result is sound only if *every* call that can
reach the function knows it: a caller that does not know borrows the argument and releases it,
or releases a result it does not own - a double free either way. So a function has those facts
only if it is not public, and every reference to its symbol is a direct call
(`ts.SymbolCallInternal`), its entry in a class's `..vtbl` global, or the `identifier` of a
virtual reference used only as a call's callee (directly or through `ts.GetMethod`/`ts.GetThis`).
A function reached any other way - a method taken as a value, an interface, an export - keeps
rc's convention, and its body's retain stays an error, now saying why. `__own_no_drops` goes the
other way (an unknown call is assumed to drop), so it needs no such check. Cost if wrong: none
for soundness; exported functions and interface methods get no facts until the export/import
work.
- **Virtual families meet.** A virtual call may reach every entry at its index with its method
name, in any `..vtbl` of the module (an unrelated class that happens to match only adds a
candidate). The call has an owned parameter only if every candidate owns it, a borrowed result
only if every candidate borrows the same argument, no drops only if no candidate drops. A
function whose own facts differ from the meet of a family it is in loses them (it keeps rc's
convention, and its body errors). Cost if wrong: a spurious error where a subclass disagrees.
- **A returned parameter is a borrowed result, not an owned parameter.** §2.4 says a callee that
returns a parameter owns it. `___cast` returns its parameter narrowed, and owning it would make
every `<C>u` move `u`. Rust's elision (`fn id(c: &C) -> &C`) is the model. Cost if wrong:
`h.c = id(new C())` is an error (a borrow cannot be stored) where the owned reading would accept
it.
- **An owned parameter is moved on every path or it is an error.** The callee has no release for
it, so a path that does not move it would leak it and one that moves it twice would free it
twice. The move must dominate every `ts.ReturnInternal`, must not be in a loop, and nothing may
read the parameter after it. A throw before the move leaks it, as rc leaks a try-less function's
locals. Cost if wrong: `if (x) h.c = c;` is rejected (drop elaboration is not in scope).
- **A parameter that is assigned has no facts.** Its reads no longer see the argument.
- **A borrowed result's places are a wildcard.** The caller does not know which place under the
argument the result came from (`h.c`, `h.c.d`), so any overwrite of any field or element, and
any array op that removes elements, drops it. Its roots are the argument's, found as a place
read's are. Cost if wrong: spurious errors, never a missed one.
- **Drops.** A function may drop if it overwrites a field or an element (`ts.ReleaseSlot` of a
place) whose root it does not own, assigns a global, pops, shifts, splices or sets the length of
an array it does not own, or makes a call that may drop (an unresolved one always may). Least
fixpoint over the call graph. A constructor's stores into its own `this` are not drops: the
object is new, and nothing outside holds a borrow into it. Cost if wrong: a constructor called
through `super` on an object whose fields someone borrows - impossible in the current MLIRGen,
where a borrow of a field of `this` inside a constructor already ends at the call.
- **A call is a use of what it is given for as long as it runs.** Phase 3 skipped a call's own
arguments when the call was the drop ("read before it runs"). That is unsound: `f(h.c)` where
`f` resets `h.c` through a global and then reads its parameter reads freed memory. A call that
drops a chain is now an error when it is also given the borrow. Cost if wrong: extra errors in
code that passes a field to a function that overwrites fields.
- **The `.instanceOf` slot.** A call through a vtable's first slot is built only by
`mlirGenInstanceOfOpaque`, to reach a class's generated `..instanceOf` (a string compare), which
drops nothing. Without this, `___unbox` has an unknown call between its read and its return.
Found while reviewing: a class that implements an interface keeps the interface's vtable in that
slot, so the call crashes under every model (§15.7). It destroys nothing either way.
- **The `any` box fix is in MLIRGen and changes rc.** The boxing cast gets `__owned_result` and a
`ts.Retain`, as `markFreshStringOwned` does for a printed number. That is rc's §14.5 leak fix,
and under own it makes the box a fresh value with one owner. Cost if wrong: rc suite failures,
which the full suite shows.
- **Export and import of facts is deferred.** §3.2 has a `-shared` build export them beside the
`__tsmm_own_*` marker. Under the closed-world rule an exported function gets no facts, so the
default stays sound without it. It is its own PR.

## Review Focus

1. Soundness of the closed world: any reference to a function's symbol that the classifier does
not recognise must drop the function's owned/borrowed facts. Grep the IR of every new test for
`SymbolRef` uses outside `..vtbl` globals.
2. A caller passing something it does not own (a parameter, a place read, a global) to an owned
parameter is an error, never a silent move.
3. The borrowed-result caller erases *both* the temporary's `ts.Release` and a consumed `let`'s
`ts.ReleaseSlot`s, and the callee erases the retain before its result store. Each is a teeth
test.
4. The `.instanceOf` resolution matches only a callee loaded from vtable slot 0 of a vtable
loaded from slot 0 of an object (`___unbox`'s shape).

---

### Task 1: the signature pass and drops

**Files:**
- Create: `lib/TypeScript/OwnershipSignaturePass.cpp`, `lib/TypeScript/OwnershipFacts.h`
- Modify: `include/TypeScript/Passes.h`, `lib/TypeScript/CMakeLists.txt`, `tslang/transform.cpp`,
`lib/TypeScript/OwnershipInferencePass.cpp`
- Tests: `test/tester/own/own_call_no_drops.ts` (``${this.color} area=${this.area()}``, a
`churn()` between a field read and its use), `own_err_call_drops_indirectly.ts` (a field of a
parameter read across a call to a function that calls one that assigns the global owner)

Steps: resolve calls (`resolveCallees`: direct, virtual family, `.instanceOf` slot, else unknown);
compute `__own_no_drops` to a least fixpoint and pin it on functions and resolved calls. In the
inference, `dropsChain` asks a call's `__own_no_drops` first; `isCall` drops stay otherwise.
Remove the "a call's own arguments are read before it runs" exclusion when the call drops.
Corpus: the three `export_class_abstract*` files were expected to compile again. They do not:
their class is exported, so `area`'s family may have an override in another module (see §15.7).
Commit: `-mm=own phase 4: a call drops a borrow only if its callee may drop`.

### Task 2: borrowed-from-argument results

**Tests:** `own_getter_borrow.ts` (getter, method and free function returning a field, used and
held by a `let`), `own_cast_borrow.ts` (a function returning its parameter), negatives
`own_err_borrowed_result_stored.ts` (stored into a field), `own_err_borrowed_result_outlives.ts`
(the argument's field overwritten, then the result used), `own_err_borrowed_result_escapes.ts`
(the getter's function reached as a value: the callee body's error says why),
`own_err_borrowed_result_family.ts` (an override that returns a new value).

Signature: a function whose every returned heap value is, through views, opaque casts, `ts.Unbox`
and place reads, the parameter `K`'s slot (never assigned) - `null`/`undefined` and non-heap values
allowed - has `__own_result_borrows = K`, subject to the closed world and the family meet.
Inference, callee: a place read or parameter view that reaches the result slot of such a function
is not kept; its `ts.Retain` is erased. Caller: a call carrying `__own_result_borrows` is a borrow
read like a place read - wildcard places, roots through argument `K` - whose `ts.Release` and whose
consumed `let`s' `ts.ReleaseSlot`s are erased.
Commit: `-mm=own phase 4: a result that borrows an argument is a borrow at the call`.

### Task 3: owned-by-callee parameters

**Tests:** `own_param_kept.ts` (`keep(h, new C(i))`, `keep(h, a)` from a `let`, forwarding through
a second function, a method storing its parameter into `this`), negatives
`own_err_param_kept_used.ts` (the argument read after the call),
`own_err_param_kept_some_paths.ts`, `own_err_param_kept_loop.ts` (an argument made outside a loop),
`own_err_param_kept_not_owned.ts` (a caller passing its own parameter or a field).

Signature: parameter `K` (never assigned) is owned if a read of it is taken - stored into a place,
inserted into an array, or passed to an owned parameter - subject to the closed world and the
meet; fixpoint for forwarding. Inference, callee: the take is a move out of the parameter's slot
(no releases to erase; the retain goes); it must dominate every return, not loop, and nothing may
read the parameter after it. Caller: the call is a taker of each owned argument
(`takerAcquires` is true: rc's retain is inside the callee); a slot argument is a slot receiver
whose acquisition is the call; an argument the caller does not own is an error:
`argument 'x' is given to 'f', which keeps it, but -mm=own cannot move it here`.
Commit: `-mm=own phase 4: a callee may keep a parameter its caller moves to it`.

### Task 4: `any`

**Tests:** `own_any_box.ts` (`let` and `const` boxes, a box passed to a call, unboxed and read
after a `churn()`), negative `own_err_any_unboxed_outlives.ts` (the unboxed value used after its
box is reassigned). rc: `00owned_any_boxing.ts` and the §14.5 program under `measure.ps1`.

MLIRGen: mark the boxing cast fresh (`__owned_result` + `ts.Retain`). Inference: a boxing cast is
a taker of its payload (it already is) and a fresh value; `___unbox` gets its borrowed result from
Task 2's signature rules through `ts.Unbox`, and its `.instanceOf` call is drop-free (Task 1).
Commit: `-mm=own phase 4: any`.

### Task 5: results

Measure (`measure.ps1`, O3 and O1) an owned-parameter loop, a getter loop and an `any` loop under
all four models, and rc on §14.5's program. Corpus report with the histogram. Teeth: keep the
caller's release of an owned argument; keep the callee's retain of a borrowed result; keep the
caller's release of a borrowed result. Debug `ctest -R own`. Spec §15 and the status line.
Commit: `-mm=own phase 4: results`.
Loading
Loading