diff --git a/tslang/docs/superpowers/plans/2026-09-29-own-phase-1.md b/tslang/docs/superpowers/plans/2026-09-29-own-phase-1.md new file mode 100644 index 000000000..7b4480cf1 --- /dev/null +++ b/tslang/docs/superpowers/plans/2026-09-29-own-phase-1.md @@ -0,0 +1,1118 @@ +# `-mm=own` Phase 1 Implementation Plan: moves by reachability + +> **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 value can be moved (spec §2.2, verdict 1). Phase 0 allowed a move +only into one owner in the value's own block, with no later use. Phase 1 decides a move by +control-flow reachability and dominance, so it can happen: + +- in a branch; +- out of a `let` into another `let`, a field or a return; +- inside a loop body that also made the value. + +A use reachable after the move is a use-after-move error that cites both points (§5). + +**Architecture:** Only `lib/TypeScript/OwnershipInferencePass.cpp` changes. MLIRGen and +lowering stay untouched. The pass runs on the affine-level CFG, where structured statements are +already `cf.br`/`cf.cond_br` between blocks. Two kinds of source can be moved: + +- **Fresh SSA value.** A `new`, a literal, a fresh string, or a local call result, the + temporary that phase 0 already handles. +- **Owning local slot.** A `ts.Variable` marked `__owned`, whose value rc reads with `ts.Load` + and hands to a receiver with a `ts.Retain` or `ts.RetainSlot`. + +For each move: + +- The receiver's retain is erased. +- Each release of the source that the move can reach must be dominated by the move, and is then + erased as well. A release the move cannot reach stays: it is the source's owner on the paths + that did not move. +- A reachable release the move does not dominate is an error, "moved on some paths only". + Splitting it is drop elaboration, which is not in this phase. + +**Tech Stack:** C++17, MLIR (tslang's LLVM tree: `mlir::DominanceInfo`, `mlir::Block` +successors), CMake/ctest, `test/tester/test-runner.cpp`, `test/tester/tools/measure.ps1`. + +**Spec:** `docs/superpowers/specs/2026-09-24-own-memory-model-design.md`. Read these first: + +- §2.2: move, then borrow, then error. +- §2.6: loops. +- §4.2 step 3: the move verdict. +- §5: diagnostics. +- §11.1: the phase-0 rule as built, including the move-out-of-a-temporary rule that PR #400 + added. +- §11.4: the phase-0 limits, which are this phase's input. + +Borrows (§2.2 verdict 2) are phase 2. Anything that needs a borrow stays an error here. + +## Global Constraints + +- Branch `own-phase-1` from `rc-consumption-over-release` (PR #400): + `git checkout --no-track -b own-phase-1 origin/rc-consumption-over-release`. The branch needs + #400's named-const marking and its `takerAcquires` rule. Rebase it onto `main` once #400 merges. +- `gc`, `rc` and `none` output must not change by a single instruction. Only + `OwnershipInferencePass.cpp` changes, and it runs only under `-mm=own`. +- Never branch on the memory model in MLIRGen (spec §4.3). +- `__tslang_inc_ref` and `__tslang_dec_ref` never appear in own output. +- Own tests run with `--no-default-lib`. +- Build: `cmake --build __build/tslang/windows-msbuild-2026-release --config Release --target tslang test-runner`. + Test: from that directory, `ctest -j 16 -C Release`. A bare `ctest` reports every test as + "(Not Run)". +- Source files are CRLF. Edit them with the Edit tool or a Python byte-replace, never `sed -i`. + Run `unix2dos` on each new file. +- Do not edit `TypeScriptOps.td`. +- Every positive own test is *executed* under JIT and AOT (`tslang_add_test` with `-jit -mm=own` + and `-mm=own`), and reads through a `churn()` after the point where a wrong release would free, + so a double free or a use-after-free reads a wrong answer rather than a lucky one. See + `tests/00owned_transfer.ts` for why. +- Commits are GPG-signed. On a signing timeout, retry, and never pass `--no-gpg-sign`. Every + commit message ends with the `Co-Authored-By:` line the session gives. +- Debug-build rule: a Debug `tslang`/`test-runner` idle for 30 s or more is probably on an + assert dialog. Check window titles while any Debug suite runs. + +## Review Focus + +1. **A move on one branch whose source is released after the merge.** For + `const a = new C(); if (x) { let b = a; } use();`, expect the error "moved here on some paths + only", never a silent double free. Task 1 test: `own_err_move_some_paths`. +2. **A `let` moved and then assigned again.** For `let a = new C(); let b = a; a = new C();`, + rc's assignment releases the old value (the moved one) before storing. Expect the error "used + here after its value was moved", not a double free. Task 2 test: `own_err_assign_after_move`. +3. **A move inside a loop from a value made outside it (§2.6).** Expect a loop error. Moving + from a slot declared *inside* the loop body is fine, because each iteration makes a new value. + Task 1 test: updated `own_err_push_loop`. Task 2 tests: `own_move_loop_body` (positive) and + `own_err_let_loop` (negative). +4. **A return out of a `let`.** At the affine level a `return` is a `ts.Store` into the return + slot. The `ts.Retain` comes *before* the source's scope-exit `ts.ReleaseSlot`, so the move must + be measured from the retain, not from the store. Otherwise the release survives and frees + what was returned. Task 2 test: `own_move_return`. +5. **A taker that rc gives no reference, such as `ts.NewInterface`.** It must stay a "second + reference" error. Treating it as a move would erase the instance's only release and leak it. + Task 1 keeps `own_err_interface` in the negative list unchanged. + +--- + +## File structure + +- Modify: `lib/TypeScript/OwnershipInferencePass.cpp`. This is the only code change. It gets: + - reachability (`reachableAfter`); + - `mlir::DominanceInfo`; + - `checkValueMoves`, which replaces `hasOneOwner`; + - `slotLoadOf` and `checkSlotMove`; + - three new diagnostics. +- Create in `test/tester/own/`: + - positives: `own_move_const_branch.ts`, `own_move_let.ts`, `own_move_field.ts`, + `own_move_return.ts`, `own_move_loop_body.ts`; + - negatives: `own_err_move_some_paths.ts`, `own_err_assign_after_move.ts`, + `own_err_let_twice.ts`, `own_err_let_loop.ts`. +- Modify: `test/tester/own/own_err_alias.ts`. It becomes a move followed by a use. +- Modify: `test/tester/CMakeLists.txt`. Add the positives to the own `foreach`, and add and + update entries in `own_error_cases`. +- Modify: the spec, adding §12 "Phase 1 results". + +--- + +### Task 1: SSA moves by reachability + +Replace phase 0's locality rule ("taker in the defining block, no use after") with reachability. +A fresh value may be taken in any block. Every other use must be unreachable from the taker. +Each release the move can reach must be dominated by the move. + +**Files:** +- Modify: `lib/TypeScript/OwnershipInferencePass.cpp` +- Create: `test/tester/own/own_move_const_branch.ts`, `test/tester/own/own_err_move_some_paths.ts` +- Modify: `test/tester/CMakeLists.txt` (own positive `foreach`, `own_error_cases`) + +**Interfaces:** +- Consumes (from #400): `takerAcquires(mlir::Operation *taker, mlir::Value value)`, + `isBorrow`, `isFresh`, `isOwningVariable`, `describe`, `varName`, `reportSecondReference`. +- Produces, used by Task 2: + - `bool reachableAfter(mlir::Operation *from, mlir::Operation *to, mlir::Operation *kill)` + - `mlir::DominanceInfo *dominance` (member, set in `runOnFunction`) + - `void reportUseAfterMove(mlir::Operation *use, mlir::Operation *move, llvm::StringRef name)` + - `void reportMovedInLoop(mlir::Operation *move, llvm::StringRef name)` + - `void reportMovedOnSomePaths(mlir::Operation *move, mlir::Operation *release, llvm::StringRef name)` + - `mlir::Operation *acquirePoint(mlir::Operation *taker, mlir::Value value)` + +- [ ] **Step 1: Write the failing positive test** + +`test/tester/own/own_move_const_branch.ts`: + +```ts +// -mm=own, phase 1: a value moved in a branch that returns. The move reaches only that branch's +// own release, which goes, and the release on the path that falls through stays. +class C { + v: number[] = []; + constructor(public x: number) {} +} + +function churn() { + const keep: C[] = []; + for (let i = 0; i < 64; i++) { + const c = new C(999); + c.v.push(99); + keep.push(c); + } +} + +function pick(i: number) { + const a = new C(i); + a.v.push(i); + if (i % 2 == 0) { + let b = a; + churn(); + return b.x + b.v.length; + } + + churn(); + return a.x; +} + +function main() { + let t: number = 0; + for (let i = 0; i < 100000; i++) { + t += pick(i % 10); + } + + assert(t == 500000); + print("done."); +} +``` + +The expected total: even `i % 10` in {0,2,4,6,8} gives `x + 1`, summing to 25 per ten +iterations; odd gives `x`, summing to 25. That is 50 per ten, so 500000 over 100000. + +Register it by adding `own_move_const_branch` to the own positive `foreach` in +`test/tester/CMakeLists.txt`: + +```cmake +foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local own_print_number own_literals + own_move_const_branch) +``` + +- [ ] **Step 2: Write the failing negative test** + +`test/tester/own/own_err_move_some_paths.ts`: + +```ts +// -mm=own, phase 1 rejects: `a` moves into `b` on one path only, and the release after the `if` +// is reached from both. Keeping it double-frees on the path that moved; erasing it leaks on the +// other. Splitting it (drop elaboration) is not in this phase. +class C { x: number = 5; } +function main(argc: number) { + const a = new C(); + if (argc > 5) { + let b = a; + print(b.x); + } + print("done."); +} +``` + +Add it to `own_error_cases`, and change the two loop cases to the loop message: + +```cmake + "own_err_push_loop|is moved inside a loop" + "own_err_field_loop|is moved inside a loop" + "own_err_move_some_paths|is moved here on some paths only" +``` + +- [ ] **Step 3: Run the tests to verify they fail** + +Run: +`cmake --build __build/tslang/windows-msbuild-2026-release --config Release --target tslang test-runner`, +then from the build directory: +`ctest -C Release -R "own-move-const-branch|own_err_move_some_paths|own_err_push_loop|own_err_field_loop" --output-on-failure` + +Expected: +- `test-jit-own-own_move_const_branch` fails with "is given an owner in another block". +- `test-own-err-own_err_move_some_paths` fails, because it gets the same phase-0 message + instead of "on some paths only". +- The two loop cases fail on the message change. + +- [ ] **Step 4: Add reachability, dominance and the three diagnostics** + +In `OwnershipInferencePass.cpp`, add `#include "mlir/IR/Dominance.h"` and +`#include "llvm/ADT/SmallPtrSet.h"`. Add a member `mlir::DominanceInfo *dominance = nullptr;`, +and set it at the top of `runOnFunction`: + +```cpp + auto f = getFunction(); + mlir::DominanceInfo dominanceInfo(f); + dominance = &dominanceInfo; +``` + +Add these private members: + +```cpp + // Can control reach `to` after `from` has run, without passing `kill` on the way? `kill` is + // the source's definition - the producer of an SSA value, or a local's `ts.Variable` - and a + // path through it is a new value, not this one. Straight-line order within a block, then the + // CFG from the block's successors; a path back into `from`'s own block reaches every op in it. + // + // Ops nested in a region that is not the function body (an `async.execute` body, a + // lambda) are compared by their ancestors in the body. That answers "reachable" whenever the + // two share an ancestor, which can only turn a program into an error. + bool reachableAfter(mlir::Operation *from, mlir::Operation *to, mlir::Operation *kill) + { + auto &body = getFunction().getBody(); + from = body.findAncestorOpInRegion(*from); + to = body.findAncestorOpInRegion(*to); + if (kill) + { + kill = body.findAncestorOpInRegion(*kill); + } + + if (!from || !to) + { + return true; + } + + if (from == to) + { + // Two distinct ops that share one ancestor statement: the order inside it is not + // known here, and "reachable" can only turn a program into an error. + return true; + } + + auto *fromBlock = from->getBlock(); + auto *toBlock = to->getBlock(); + auto killIn = [&](mlir::Block *block) { return kill && kill->getBlock() == block; }; + + if (fromBlock == toBlock && from->isBeforeInBlock(to)) + { + return !(killIn(fromBlock) && from->isBeforeInBlock(kill) && kill->isBeforeInBlock(to)); + } + + // every way out of this block runs `kill` first + if (killIn(fromBlock) && from->isBeforeInBlock(kill)) + { + return false; + } + + llvm::SmallPtrSet seen; + llvm::SmallVector work(fromBlock->getSuccessors().begin(), fromBlock->getSuccessors().end()); + while (!work.empty()) + { + auto *block = work.pop_back_val(); + if (!seen.insert(block).second) + { + continue; + } + + if (block == toBlock && (!killIn(block) || to->isBeforeInBlock(kill))) + { + return true; + } + + if (killIn(block)) + { + continue; + } + + work.append(block->getSuccessors().begin(), block->getSuccessors().end()); + } + + return false; + } + + // Where rc takes the receiver's reference: its `ts.Retain` of the value, when that comes + // first in the taker's block, else the taker itself (a declaration, whose `ts.RetainSlot` + // follows it). A release between the two - a `return`'s scope exit runs after its retain + // and before the store into the return slot - is after the move. + static mlir::Operation *acquirePoint(mlir::Operation *taker, mlir::Value value) + { + mlir::Operation *first = taker; + for (auto *user : value.getUsers()) + { + if (mlir::isa(user) && user->getBlock() == taker->getBlock() && + user->isBeforeInBlock(first)) + { + first = user; + } + } + + return first; + } + + void reportUseAfterMove(mlir::Operation *use, mlir::Operation *move, llvm::StringRef name) + { + auto diag = use->emitError("'") << name << "' is used here after its value was moved"; + diag.attachNote(move->getLoc()) << "value moved here"; + signalPassFailure(); + } + + void reportMovedInLoop(mlir::Operation *move, llvm::StringRef name) + { + move->emitError("'") << name + << "' is moved inside a loop but was made outside it; only a borrow could do " + "that, and -mm=own cannot prove one yet"; + signalPassFailure(); + } + + void reportMovedOnSomePaths(mlir::Operation *move, mlir::Operation *release, llvm::StringRef name) + { + auto diag = move->emitError("'") << name + << "' is moved here on some paths only; -mm=own cannot release it " + "on the others yet"; + diag.attachNote(release->getLoc()) << "released here on every path"; + signalPassFailure(); + } +``` + +- [ ] **Step 5: Replace `hasOneOwner` with `checkValueMoves`** + +Delete `hasOneOwner`. Add: + +```cpp + // The moves out of a fresh or unknown SSA value. Each taking use (§11.1) is a move: + // - nothing else may use the value after it - another taker included, and the taker itself + // around a loop, which is a value made outside the loop moved on every iteration (§2.6); + // - where the temporary is also released (`__owned_result` rc gives back at block end), + // the taker must be one rc gave its own reference (takerAcquires), and each release the + // move reaches must be dominated by it - that release then has nothing to give back and + // is erased. A release the move cannot reach stays: it is the owner on the other paths. + // Reports and returns false otherwise. + bool checkValueMoves(mlir::Value value, llvm::SetVector &toErase) + { + llvm::SmallVector takers; + llvm::SmallVector releases; + llvm::SmallVector reads; + for (auto *user : value.getUsers()) + { + if (mlir::isa(user)) + { + continue; + } + + if (mlir::isa(user)) + { + releases.push_back(user); + } + else if (isBorrow(user, value)) + { + reads.push_back(user); + } + else + { + takers.push_back(user); + } + } + + auto *definition = value.getDefiningOp(); + auto name = describe(value); + llvm::SmallVector moved; + for (auto *taker : takers) + { + if (takerLoops(taker, definition)) + { + reportMovedInLoop(taker, name); + return false; + } + + for (auto *use : llvm::concat(takers, reads)) + { + if (use != taker && reachableAfter(taker, use, definition)) + { + reportUseAfterMove(use, taker, name); + return false; + } + } + + if (releases.empty()) + { + continue; // rc handed the taker the value's own reference: nothing to cancel + } + + if (!takerAcquires(taker, value)) + { + reportSecondReference(taker); + return false; + } + + auto *acquire = acquirePoint(taker, value); + for (auto *release : releases) + { + if (!reachableAfter(acquire, release, definition)) + { + continue; + } + + if (!dominance->properlyDominates(acquire, release)) + { + reportMovedOnSomePaths(taker, release, name); + return false; + } + + moved.push_back(release); + } + } + + toErase.insert(moved.begin(), moved.end()); + return true; + } + + // Does control come back round to `taker` without making the value again? Only then is it a + // move on every iteration of a loop. + bool takerLoops(mlir::Operation *taker, mlir::Operation *definition) + { + auto &body = getFunction().getBody(); + auto *top = body.findAncestorOpInRegion(*taker); + if (!top) + { + return true; + } + + auto *kill = definition ? body.findAncestorOpInRegion(*definition) : nullptr; + llvm::SmallPtrSet seen; + llvm::SmallVector work(top->getBlock()->getSuccessors().begin(), + top->getBlock()->getSuccessors().end()); + while (!work.empty()) + { + auto *block = work.pop_back_val(); + if (!seen.insert(block).second) + { + continue; + } + + if (kill && kill->getBlock() == block && + (block != top->getBlock() || kill->isBeforeInBlock(top))) + { + continue; // the value is made again before the taker runs again + } + + if (block == top->getBlock()) + { + return true; + } + + work.append(block->getSuccessors().begin(), block->getSuccessors().end()); + } + + return false; + } +``` + +In `runOnFunction`, the owner loop and the retain loop become: + +```cpp + llvm::DenseSet proven; + llvm::SetVector toErase; + for (auto value : candidates) + { + if (checkValueMoves(value, toErase) && isFresh(value)) + { + proven.insert(value); + } + } + + for (auto *op : retains) + { + auto value = retainedValue(op); + if (value && proven.contains(value)) + { + toErase.insert(op); + } + else if (!value || isFresh(value) == false) + { + reportSecondReference(op); + } + } + + for (auto *op : toErase) + { + op->erase(); + } +``` + +Remove the `movedRelease` out-parameter code that #400 added. `checkValueMoves` covers it: a +single release after the taker in the same block is reachable from, and dominated by, the +acquire point. Keep `takerAcquires`, whose comment still applies. + +Update the file's header comment. The paragraph beginning "Phase 0 also keeps the move trivially +safe" becomes: + +```cpp +// A move is decided by reachability on the affine CFG (spec 4.2 step 3): nothing may use the value +// after its taker, a taker that control comes back to without the value being made again is a +// move on every loop iteration (2.6), and each release the move reaches must be dominated by it +// and is erased. A release the move cannot reach stays - the owner on the paths that did not +// move. Anything else is an error at its location. +``` + +- [ ] **Step 6: Build and run the Task 1 tests, then every own test** + +Run: build as in Step 3, then +`ctest -C Release -R "own" --output-on-failure` + +Expected: all own tests pass, including the new ones: + +| test | result | +| --- | --- | +| `test-jit-own-own_move_const_branch`, `test-compile-own-own_move_const_branch` | print `done.` | +| `test-own-no-counting-own_move_const_branch` | no `__tslang_inc_ref` | +| `own_err_move_some_paths` | "is moved here on some paths only" | +| `own_err_push_loop`, `own_err_field_loop` | "is moved inside a loop" | +| `own_err_use_after_move` | "is used here after its value was moved" | + +Four phase-0 negatives change text, because two takers are now one move followed by a use. +Update their entries in `own_error_cases`, and check that each error points at the second use: + +```cmake + "own_err_use_after_move|is used here after its value was moved" + "own_err_const_twice|is used here after its value was moved" + "own_err_push_let|is used here after its value was moved" + "own_err_two_owners|is used here after its value was moved" +``` + +`own_err_interface` keeps "takes a second reference". Its taker, `ts.NewInterface`, gets no +reference from rc (`takerAcquires` is false). + +If a phase-0 negative now *compiles*, it is a shape phase 1 accepts. Check the program runs +correctly under own with `bin/tslang.exe --emit=jit -mm=own --no-default-lib `. Then move +it into the positive `foreach` with a `churn()` read, and write a `Ruling:` line in the ledger. + +- [ ] **Step 7: Commit** + +```bash +git add lib/TypeScript/OwnershipInferencePass.cpp test/tester/own test/tester/CMakeLists.txt +git commit -m "-mm=own phase 1: a move is decided by reachability, not by block" +``` + +--- + +### Task 2: moves out of a local slot + +A `let` that owns its value can hand it on: + +- to another `let`: `let b = a;` +- to a field: `h.c = a;` +- to a return: `return a;` + +rc reads the slot with `ts.Load` (through `ts.Cast`s that keep the block) and retains the loaded +value for the receiver. The move erases that retain and the slot's `ts.ReleaseSlot`s that the +move reaches, under the same dominance rule as Task 1. + +Any other use of the slot the move can reach is a use-after-move error, including an assignment. +A move whose own load is reachable again without the slot being declared again is a loop move. + +**Files:** +- Modify: `lib/TypeScript/OwnershipInferencePass.cpp` +- Create in `test/tester/own/`: + - positives: `own_move_let.ts`, `own_move_field.ts`, `own_move_return.ts`, + `own_move_loop_body.ts`; + - negatives: `own_err_assign_after_move.ts`, `own_err_let_twice.ts`, `own_err_let_loop.ts`. +- Modify: `test/tester/own/own_err_alias.ts`, `test/tester/CMakeLists.txt` + +**Interfaces:** +- Consumes (Task 1): `reachableAfter`, `dominance`, `acquirePoint`, `reportUseAfterMove`, + `reportMovedInLoop`, `reportMovedOnSomePaths`. +- Produces: `static mlir_ts::LoadOp slotLoadOf(mlir::Value value)` and + `bool checkSlotMove(mlir::Operation *retain, mlir::Value value, mlir_ts::LoadOp load, llvm::SetVector &toErase)`. + +- [ ] **Step 1: Write the four failing positive tests** + +`test/tester/own/own_move_let.ts`: + +```ts +// -mm=own, phase 1: a `let` hands its value to another `let` and is not used again. The first +// slot's release goes; the second slot's release destroys the value once. +class C { + v: number[] = []; + constructor(public x: number) {} +} + +function churn() { + const keep: C[] = []; + for (let i = 0; i < 64; i++) { + const c = new C(999); + c.v.push(99); + keep.push(c); + } +} + +function relay(i: number) { + let a = new C(i); + a.v.push(i); + let b = a; + churn(); + return b.x + b.v.length; +} + +function main() { + let t: number = 0; + for (let i = 0; i < 100000; i++) { + t += relay(i % 10); + } + + assert(t == 550000); + print("done."); +} +``` + +(`x + 1` over `i % 10` in 0..9 is 55 per ten: 550000.) + +`test/tester/own/own_move_field.ts`: + +```ts +// -mm=own, phase 1: a `let` moved into a field. The field owns it; the holder's release routine +// destroys it; the `let`'s own release goes. +class C { + v: number[] = []; + constructor(public x: number) {} +} + +class H { + c: C | null = null; +} + +function churn() { + const keep: C[] = []; + for (let i = 0; i < 64; i++) { + const c = new C(999); + c.v.push(99); + keep.push(c); + } +} + +function store(i: number) { + const h = new H(); + let a = new C(i); + a.v.push(i); + h.c = a; + churn(); + const c = h.c; + return c ? c.x + c.v.length : -1000; +} + +function main() { + let t: number = 0; + for (let i = 0; i < 100000; i++) { + t += store(i % 10); + } + + assert(t == 550000); + print("done."); +} +``` + +`test/tester/own/own_move_return.ts`: + +```ts +// -mm=own, phase 1: a `let` returned. At the affine level the return is a store into the return +// slot, and the `let`'s scope-exit release sits between rc's retain and that store - after the +// move, so it goes. +class C { + v: number[] = []; + constructor(public x: number) {} +} + +function churn() { + const keep: C[] = []; + for (let i = 0; i < 64; i++) { + const c = new C(999); + c.v.push(99); + keep.push(c); + } +} + +function make(i: number) { + let a = new C(i); + a.v.push(i); + a.x = a.x + 1; + return a; +} + +function main() { + let t: number = 0; + for (let i = 0; i < 100000; i++) { + const c = make(i % 10); + churn(); + t += c.x + c.v.length; + } + + assert(t == 650000); + print("done."); +} +``` + +(`x + 1 + 1` over 0..9 is 65 per ten: 650000.) + +`test/tester/own/own_move_loop_body.ts`: + +```ts +// -mm=own, phase 1: a move inside a loop body whose source is declared in that body. Each +// iteration makes a new `a`, so the move is not "made outside the loop" (spec 2.6). +class C { + v: number[] = []; + constructor(public x: number) {} +} + +function main() { + let t: number = 0; + for (let i = 0; i < 100000; i++) { + let a = new C(i % 10); + a.v.push(i); + let b = a; + t += b.x + b.v.length; + } + + assert(t == 550000); + print("done."); +} +``` + +Add all four to the own positive `foreach`: + +```cmake +foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local own_print_number own_literals + own_move_const_branch own_move_let own_move_field own_move_return own_move_loop_body) +``` + +- [ ] **Step 2: Write the three failing negative tests and update `own_err_alias`** + +`test/tester/own/own_err_assign_after_move.ts`: + +```ts +// -mm=own, phase 1 rejects: `a` is assigned after its value moved into `b`. rc's assignment +// releases what the slot holds before storing - the moved value, which `b` owns. +class C { x: number = 5; } +function main() { + let a = new C(); + let b = a; + a = new C(); + print(a.x, b.x); + print("done."); +} +``` + +`test/tester/own/own_err_let_twice.ts`: + +```ts +// -mm=own, phase 1 rejects: `a` moves into `b`, then is read again for `c`. +class C { x: number = 5; } +function main() { + let a = new C(); + let b = a; + let c = a; + print(b.x, c.x); + print("done."); +} +``` + +`test/tester/own/own_err_let_loop.ts`: + +```ts +// -mm=own, phase 1 rejects: `a` is declared outside the loop and moved inside it, so the +// second iteration would move a value that is already gone (spec 2.6). +class C { x: number = 5; } +function main() { + let a = new C(); + for (let i = 0; i < 3; i++) { + let b = a; + print(b.x); + } + print("done."); +} +``` + +`test/tester/own/own_err_alias.ts` is accepted as a move in phase 1. Give it a use after the move: + +```ts +// -mm=own, phase 1 rejects: `a`'s array moves into `b`, and `a` is read after that. +function main() { + let a: number[] = [1]; + let b = a; + print(b.length, a.length); + print("done."); +} +``` + +Update `own_error_cases`: + +```cmake + "own_err_alias|is used here after its value was moved" + "own_err_assign_after_move|is used here after its value was moved" + "own_err_let_twice|is used here after its value was moved" + "own_err_let_loop|is moved inside a loop" +``` + +- [ ] **Step 3: Run them to verify they fail** + +Run: build, then +`ctest -C Release -R "own_move_let|own_move_field|own_move_return|own_move_loop_body|own_err_alias|own_err_assign_after_move|own_err_let" --output-on-failure` + +Expected: +- Every positive fails with "takes a second reference". That is phase 0's retain-of-a-load. +- The negatives fail on the message: they report "takes a second reference" instead of the + expected text. + +- [ ] **Step 4: Implement slot moves** + +Add: + +```cpp + // The load of an owning local that `value` was read from, through casts that keep the same + // block (a class widened to a union with null, an upcast). None when the value is anything + // else - a parameter, a field, a boxing cast - which Task 2 does not move out of. + static mlir_ts::LoadOp slotLoadOf(mlir::Value value) + { + while (value) + { + auto *def = value.getDefiningOp(); + if (auto castOp = mlir::dyn_cast_or_null(def)) + { + if (!mlir::isa(castOp.getType())) + { + return {}; + } + + value = castOp.getIn(); + continue; + } + + auto loadOp = mlir::dyn_cast_or_null(def); + if (!loadOp) + { + return {}; + } + + auto varOp = loadOp.getReference().getDefiningOp(); + if (!varOp || !isOwningVariable(varOp) || varOp.getCaptured().value_or(false)) + { + return {}; + } + + return loadOp; + } + + return {}; + } + + // The use that takes `value` for this retain: the declaration a `ts.RetainSlot` belongs to, + // or the one use of the value after a `ts.Retain` that is not a read. + mlir::Operation *takerOf(mlir::Operation *retain, mlir::Value value) + { + if (auto retainSlotOp = mlir::dyn_cast(retain)) + { + return retainSlotOp.getSlot().getDefiningOp(); + } + + mlir::Operation *taker = nullptr; + for (auto *user : value.getUsers()) + { + if (mlir::isa(user) || isBorrow(user, value)) + { + continue; + } + + if (taker) + { + return nullptr; // two takers of one read: not a move this phase proves + } + + taker = user; + } + + return taker; + } + + // A move out of an owning local (Task 2). Reports and returns false when it is not one. + bool checkSlotMove(mlir::Operation *retain, mlir::Value value, mlir_ts::LoadOp load, + llvm::SetVector &toErase) + { + auto slot = load.getReference(); + auto varOp = slot.getDefiningOp(); + auto name = varName(varOp); + + auto *taker = takerOf(retain, value); + if (!taker) + { + reportSecondReference(retain); + return false; + } + + // the move's own read, met again around a loop the slot was declared outside of + if (reachableAfter(taker, load, varOp)) + { + reportMovedInLoop(taker, name); + return false; + } + + for (auto *use : slot.getUsers()) + { + if (use == load.getOperation() || mlir::isa(use)) + { + continue; + } + + if (reachableAfter(taker, use, varOp)) + { + reportUseAfterMove(use, taker, name); + return false; + } + } + + for (auto *use : value.getUsers()) + { + if (use == retain || use == taker || mlir::isa(use)) + { + continue; + } + + if (reachableAfter(taker, use, load)) + { + reportUseAfterMove(use, taker, name); + return false; + } + } + + auto *acquire = mlir::isa(retain) ? taker : retain; + llvm::SmallVector moved; + for (auto *use : slot.getUsers()) + { + if (!mlir::isa(use) || !reachableAfter(acquire, use, varOp)) + { + continue; + } + + if (!dominance->properlyDominates(acquire, use)) + { + reportMovedOnSomePaths(taker, use, name); + return false; + } + + moved.push_back(use); + } + + toErase.insert(moved.begin(), moved.end()); + return true; + } +``` + +In `runOnFunction`'s retain loop, try a slot move before reporting: + +```cpp + for (auto *op : retains) + { + auto value = retainedValue(op); + if (value && proven.contains(value)) + { + toErase.insert(op); + continue; + } + + if (auto load = value ? slotLoadOf(value) : mlir_ts::LoadOp()) + { + if (checkSlotMove(op, value, load, toErase)) + { + toErase.insert(op); + } + + continue; + } + + if (!value || isFresh(value) == false) + { + reportSecondReference(op); + } + } +``` + +Check the accessor names against `include/TypeScript/TypeScriptOps.td`: +- `LoadOp::getReference` +- `CastOp::getIn` and `getType` (the result is `$res`, so `getRes().getType()` if `getType` is + not generated) +- `VariableOp::getCaptured` (`OptionalAttr`, so it returns an optional) +- `RetainSlotOp::getSlot` + +- [ ] **Step 5: Build and run the Task 2 tests, then every own test** + +Run: build, then `ctest -C Release -R "own" --output-on-failure` + +Expected: every own test passes. The four new positives print `done.` under JIT and AOT with no +counting. The negatives report their messages. + +`own_err_field_alias` is `const a: number[] = [1]; n.v = a; print(a.length)`. The const gets +identity storage, so it is a slot: a move into the field, then a read. Its expected text becomes +"is used here after its value was moved". Update its entry in `own_error_cases`. + +- [ ] **Step 6: Commit** + +```bash +git add lib/TypeScript/OwnershipInferencePass.cpp test/tester/own test/tester/CMakeLists.txt +git commit -m "-mm=own phase 1: a let moves into another let, a field or a return" +``` + +--- + +### Task 3: teeth, measurement, suites, record + +**Files:** +- Modify: `docs/superpowers/specs/2026-09-24-own-memory-model-design.md` (new §12) + +- [ ] **Step 1: Teeth (spec §6.4), not committed** + +Temporarily keep every release the pass would erase as moved: + +- in `checkValueMoves`, comment out `toErase.insert(moved.begin(), moved.end());`; +- in `checkSlotMove`, comment out the same line. + +Build, then run: +`ctest -C Release -R "own_move" --output-on-failure` + +Expected: every `test-jit-own-own_move_*` and `test-compile-own-own_move_*` except +`own_move_const_branch` fails. The source's release destroys the value the receiver still holds, +so a crash or a failed assert follows `churn()`. `own_move_const_branch` fails too if its moved +branch's release survives. Put both lines back and rebuild. + +If a test still passes with its releases kept, it proves nothing. Strengthen its `churn()` read +before going on. + +- [ ] **Step 2: Measure (spec §6.2)** + +Run from PowerShell: + +```powershell +& "I:\TypeScriptCompiler\tslang\test\tester\tools\measure.ps1" -Source "I:\TypeScriptCompiler\tslang\test\tester\own\own_move_let.ts" -Models gc,rc,none,own +& "I:\TypeScriptCompiler\tslang\test\tester\tools\measure.ps1" -Source "I:\TypeScriptCompiler\tslang\test\tester\own\own_move_return.ts" -Models gc,rc,none,own +``` + +Expected: `own` holds flat, like `rc`. If `none` also stays flat, O3 removed the allocation and +the numbers say nothing. Repeat with a copy of `measure.ps1` that passes `--opt_level=1`, and +record which level was used. + +- [ ] **Step 3: Full Release suite** + +Run: `ctest -j 16 -C Release`, from `__build/tslang/windows-msbuild-2026-release`. + +Expected: everything that passed on the branch base still passes. The count is 3102 plus the +new tests. + +- [ ] **Step 4: Debug own subset** + +Build `__build/tslang/windows-msbuild-2026-debug` (`--config Debug`, same targets). Run +`ctest -C Debug -j 12 -R "own"` while watching for idle processes and assert dialogs. + +Expected: every own test passes. Debug adds `--di`, which gives each folded const a +`ts.DebugVariable` user, and that is on the borrow list. + +- [ ] **Step 5: Write spec §12 "Phase 1 results"** + +Append to the spec: +- the rule as built, stated as §11.1 states phase 0's; +- the measured numbers from Step 2, with the opt level; +- the teeth result from Step 1; +- the limits that remain for phase 2: borrows, moves on some paths (drop elaboration), + assignment after a move, moves out of fields and parameters, moves out of a slot through a + boxing cast; +- the phase-0 negative tests whose text changed, and why. + +- [ ] **Step 6: Commit** + +```bash +git add docs/superpowers/specs/2026-09-24-own-memory-model-design.md +git commit -m "-mm=own phase 1: results" +``` diff --git a/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md b/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md index 3f836d3eb..c4c873f29 100644 --- a/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md +++ b/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md @@ -1,8 +1,8 @@ # `-mm=own`: a single-owner memory model Date: 2026-09-24. Status: design approved in conversation; written review 2026-09-28 (§10), -amendments folded in. Phase 0 implemented on branch `own-phase-0` (2026-09-29, results §11); -plan: `docs/superpowers/plans/2026-09-28-own-phase-0.md`. +amendments folded in. Phase 0 merged as #399 (results §11); phase 1 +(moves by reachability) on branch `own-phase-1` (results §12). Plans in `docs/superpowers/plans/`. ## 1. Purpose @@ -487,3 +487,85 @@ Tested both ways: - Results of runtime helpers and `declare`d functions are rejected. - A field initialized from an array literal of eight numbers is rejected ("takes a second reference" at the literal); four are accepted. Not yet diagnosed. + +## 12. Phase 1 results, 2026-09-29 + +Plan: `docs/superpowers/plans/2026-09-29-own-phase-1.md`. Only `OwnershipInferencePass.cpp` +changed; MLIRGen and the lowering did not. + +### 12.1 What phase 1 accepts + +A move (§2.2, verdict 1) is decided by reachability on the affine CFG, where structured +statements are already `cf.br`/`cf.cond_br` between blocks. + +- **Sources.** A fresh SSA value, as in phase 0, or an **owning local slot**: a `ts.Variable` + marked `__owned`, not captured, read with `ts.Load` through casts that keep the same block (a + class widened to a union with null, an upcast). Parameters, fields and boxing casts are not + sources yet. +- **The move point** is where rc takes the receiver's reference: its `ts.Retain` of the value + when that comes first, else the taker. A `return` at this level is a `ts.Store` into the return + slot, with the retain *before* the source's scope-exit `ts.ReleaseSlot`, so measuring from the + store would leave that release alive. +- **Use after move.** Any other use of the source reachable from the taker, without passing the + source's definition (its producer, or the slot's `ts.Variable`), is an error: `'a' is used here + after its value was moved`, with a note at the move. For a slot that includes an assignment, and + every use of what *any* read of the slot returned: `const b = a` is folded into a `ts.Load` of + `a`'s slot, so `b.x` after `a` moves reads the moved value through a load that came before the + move (found by the final review: it compiled and read freed memory). +- **Loops (§2.6).** A taker control comes back to without the value being made again is `moved + inside a loop but was made outside it`. A move out of a `let` declared in the loop body is fine: + each iteration passes the declaration. +- **Releases.** Each release of the source the move can reach must be dominated by the move, and + is erased. A release the move cannot reach stays: it is the owner on the paths that did not + move (`if (x) { let b = a; return ...; } use(a)`). A reachable release the move does not + dominate is `moved here on some paths only`. +- **A taker rc gives no reference** (`ts.NewInterface`) with the temporary also released is still + a second owner (§11.1). + +Also changed: a cast of `null` or `undefined` counts as a literal, so `c: C | null = null` +compiles; under `--di` the errors name the variable, read from MLIRGen's `NameLoc`; and only a +value whose type owns heap memory is a candidate, while a string literal (the immortal global) +may be held by any number of places. Under `--opt`, and so under the JIT, CSE merges identical +literals before inference; one merged `0` stored into a variable inside a loop and before it was +reported as a move inside a loop. + +### 12.2 Measured + +1M iterations, AOT, `measure.ps1`, both `-O3` and `-O1` (identical within 0.1 MB): + +| program | gc | rc | none | own | +| --- | --- | --- | --- | --- | +| `own_move_let` (`let b = a`) | 6.6 | 4.8 | 546.8 | 4.8 | +| `own_move_return` (`return a`) | 6.6 | 4.8 | 546.8 | 4.8 | +| `own_move_field` (`h.c = a`) | 6.5 | 4.8 | 546.8 | 4.8 | + +### 12.3 Teeth + +With the moved releases kept (both `toErase.insert(moved...)` lines disabled), 8 of the 10 +move checks fail. `own_move_loop_body` passed, because it read the value before either release +ran and allocated nothing over it. It now keeps every value in an array past the loop and reads +them after `churn()`, and fails too (JIT exit 127). + +### 12.4 Changed negative tests + +Two takers of one value are now a move followed by a use, so `own_err_const_twice`, +`own_err_push_let`, `own_err_two_owners`, `own_err_use_after_move`, `own_err_field_alias` and +`own_err_alias` report `is used here after its value was moved` instead of `takes a second +reference`. `own_err_alias` was a bare `let b = a`, which phase 1 accepts; it now reads `a` +after the move. The loop cases report `is moved inside a loop`. + +### 12.5 Known limits (the input to phase 2) + +- Borrows (§2.2 verdict 2): a value read after it was stored into a field or a local, a value + made outside a loop and used as an owner inside it, and a `const` alias of a `let` held across + the `let`'s move (`const b = a; let c = a; ... b.x`). +- A `const` alias of a `let` held across an *assignment* of the `let` (`const b = a; a = new C(); + b.x`) is not caught: the assignment is not a move, and it destroys what `b` reads. rc has the + same bug today (it prints garbage); own inherits it until phase 2's dropping-mutation rule. +- A move on some paths only: needs drop elaboration, releasing on the paths that did not move. +- Assigning a moved `let`: rc's assignment releases what the slot holds, the moved value. +- Moves out of fields, parameters, and through boxing casts; moves into a nested region's op + (compared by the statement that holds them, which answers "reachable"). +- Everything in §11.4 not listed above as accepted: owned locals in a `try` body, closures, + interface and `any` boxing, `delete`, callee-owned parameters, runtime helpers and `declare`d + functions, the eight-element field literal. diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index 3625a2858..aa38225e9 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -1,4 +1,5 @@ #include "mlir/Pass/Pass.h" +#include "mlir/IR/Dominance.h" #include "mlir/Dialect/LLVMIR/LLVMAttrs.h" #include "TypeScript/TypeScriptDialect.h" @@ -6,8 +7,10 @@ #include "TypeScript/TypeScriptFunctionPass.h" #include "TypeScript/Passes.h" #include "TypeScript/Defines.h" +#include "TypeScript/MLIRLogic/MLIRTypeHelper.h" #include "llvm/ADT/SetVector.h" +#include "llvm/ADT/SmallPtrSet.h" #include "llvm/ADT/SmallVector.h" #define DEBUG_TYPE "own" @@ -17,8 +20,8 @@ namespace mlir_ts = mlir::typescript; namespace { -// Ownership inference for -mm=own, phase 0. See -// docs/superpowers/specs/2026-09-24-own-memory-model-design.md, sections 10.6 and 11. +// Ownership inference for -mm=own, phases 0 and 1. See +// docs/superpowers/specs/2026-09-24-own-memory-model-design.md, sections 4.2, 10.6, 11 and 12. // // A fresh heap value may have at most one owner: one use that takes it - a local's // declaration, a store into a field, element, global or return slot, an insertion into an @@ -28,10 +31,11 @@ namespace // transfers ownership by *consuming* a value (`__owned_consumed`, a push, a field store), // which leaves no retain behind to count. // -// Phase 0 also keeps the move trivially safe: every taking use sits in the block that made the -// value, and nothing uses the value after it. That one rule rules out a move inside a loop -// (spec 2.6), a move on one branch, and use after move, without a liveness analysis. Anything -// else is an error at its location. +// A move is decided by reachability on the affine CFG (spec 4.2 step 3): nothing may use the value +// after its taker, a taker that control comes back to without the value being made again is a +// move on every loop iteration (2.6), and each release the move reaches must be dominated by it +// and is erased. A release the move cannot reach stays - the owner on the paths that did not +// move. Anything else is an error at its location. class OwnershipInferencePass : public mlir::PassWrapper { public: @@ -40,6 +44,8 @@ class OwnershipInferencePass : public mlir::PassWrapper candidates; @@ -79,7 +85,7 @@ class OwnershipInferencePass : public mlir::PassWrappergetResults()) { - if (isFresh(result)) + if (isFresh(result) && ownsHeap(result)) { candidates.insert(result); } @@ -90,17 +96,12 @@ class OwnershipInferencePass : public mlir::PassWrapper proven; - llvm::SmallVector toErase; + llvm::SetVector toErase; for (auto value : candidates) { - mlir::Operation *movedRelease = nullptr; - if (hasOneOwner(value, movedRelease) && isFresh(value)) + if (checkValueMoves(value, toErase) && isFresh(value)) { proven.insert(value); - if (movedRelease) - { - toErase.push_back(movedRelease); - } } } @@ -112,9 +113,21 @@ class OwnershipInferencePass : public mlir::PassWrapperhasAttr(OWNED_RESULT_ATTR_NAME) || isLiteral(def); } + // Does a value of this type own a block that a release would destroy? + bool ownsHeap(mlir::Value value) + { + MLIRTypeHelper mth(&getContext(), CompileOptions{}); + return mth.ownsHeapMemory(value.getLoc(), value.getType()); + } + + // A string constant cast to `string` is the global itself, which a release skips. + static bool isImmortalLiteral(mlir::Value value) + { + auto castOp = value.getDefiningOp(); + return castOp && mlir::isa(castOp.getType()) && + castOp.getIn().getDefiningOp(); + } + // A string literal or a constant array cast to its value type. The result is either the // immortal global itself, which a release skips, or a copy nobody else holds, which a - // release destroys - so, owned once, it has one owner either way. + // release destroys - so, owned once, it has one owner either way. `null` and `undefined` + // widened to a nullable type (`c: C | null = null`) hold no block at all. static bool isLiteral(mlir::Operation *def) { auto castOp = mlir::dyn_cast(def); - return castOp && castOp.getIn().getDefiningOp(); + if (!castOp) + { + return false; + } + + auto *in = castOp.getIn().getDefiningOp(); + return in && mlir::isa(in); } // A use that reads the value without keeping it. Kept deliberately short: anything not listed @@ -239,21 +276,28 @@ class OwnershipInferencePass : public mlir::PassWrapper &toErase) { - auto *home = value.getParentBlock(); + // Nothing to move: a number owns no block, and a string literal is the immortal global, + // which any number of places may hold. Under --opt, CSE merges identical literals before + // this pass, so one such value is routinely stored into several places. + if (!ownsHeap(value) || isImmortalLiteral(value)) + { + return true; + } - mlir::Operation *taker = nullptr; + llvm::SmallVector takers; llvm::SmallVector releases; + llvm::SmallVector reads; for (auto *user : value.getUsers()) { if (mlir::isa(user)) @@ -264,68 +308,371 @@ class OwnershipInferencePass : public mlir::PassWrapper(user)) { releases.push_back(user); + } + else if (isBorrow(user, value)) + { + reads.push_back(user); + } + else + { + takers.push_back(user); + } + } + + auto *definition = value.getDefiningOp(); + auto name = describe(value); + llvm::SmallVector moved; + for (auto *taker : takers) + { + if (takerLoops(taker, definition)) + { + reportMovedInLoop(taker, name); + return false; + } + + for (auto *use : llvm::concat(takers, reads)) + { + if (use != taker && reachableAfter(taker, use, definition)) + { + reportUseAfterMove(use, taker, name); + return false; + } + } + + if (releases.empty()) + { + continue; // rc handed the taker the value's own reference: nothing to cancel + } + + if (!takerAcquires(taker, value)) + { + reportSecondReference(taker); + return false; + } + + auto *acquire = acquirePoint(taker, value); + for (auto *release : releases) + { + if (!reachableAfter(acquire, release, definition)) + { + continue; + } + + if (!dominance->properlyDominates(acquire, release)) + { + reportMovedOnSomePaths(taker, release, name); + return false; + } + + moved.push_back(release); + } + } + + toErase.insert(moved.begin(), moved.end()); + return true; + } + + // Does control come back round to `taker` without making the value again? Only then is it a + // move on every iteration of a loop. + bool takerLoops(mlir::Operation *taker, mlir::Operation *definition) + { + auto &body = getFunction().getBody(); + auto *top = body.findAncestorOpInRegion(*taker); + if (!top) + { + return true; + } + + auto *kill = definition ? body.findAncestorOpInRegion(*definition) : nullptr; + llvm::SmallPtrSet seen; + llvm::SmallVector work(top->getBlock()->getSuccessors().begin(), + top->getBlock()->getSuccessors().end()); + while (!work.empty()) + { + auto *block = work.pop_back_val(); + if (!seen.insert(block).second) + { + continue; + } + + if (kill && kill->getBlock() == block && + (block != top->getBlock() || kill->isBeforeInBlock(top))) + { + continue; // the value is made again before the taker runs again + } + + if (block == top->getBlock()) + { + return true; + } + + work.append(block->getSuccessors().begin(), block->getSuccessors().end()); + } + + return false; + } + + // Can control reach `to` after `from` has run, without passing `kill` on the way? `kill` is + // the source's definition - the producer of an SSA value, or a local's `ts.Variable` - and a + // path through it is a new value, not this one. Straight-line order within a block, then the + // CFG from the block's successors; a path back into `from`'s own block reaches every op in it. + // + // Ops nested in a region that is not the function body (an `async.execute` body, a lambda) + // are compared by their ancestors in the body. That answers "reachable" whenever the two + // share an ancestor, which can only turn a program into an error. + bool reachableAfter(mlir::Operation *from, mlir::Operation *to, mlir::Operation *kill) + { + auto &body = getFunction().getBody(); + from = body.findAncestorOpInRegion(*from); + to = body.findAncestorOpInRegion(*to); + if (kill) + { + kill = body.findAncestorOpInRegion(*kill); + } + + if (!from || !to) + { + return true; + } + + if (from == to) + { + // Two distinct ops that share one ancestor statement: the order inside it is not + // known here, and "reachable" can only turn a program into an error. + return true; + } + + auto *fromBlock = from->getBlock(); + auto *toBlock = to->getBlock(); + auto killIn = [&](mlir::Block *block) { return kill && kill->getBlock() == block; }; + + if (fromBlock == toBlock && from->isBeforeInBlock(to)) + { + return !(killIn(fromBlock) && from->isBeforeInBlock(kill) && kill->isBeforeInBlock(to)); + } + + // every way out of this block runs `kill` first + if (killIn(fromBlock) && from->isBeforeInBlock(kill)) + { + return false; + } + + llvm::SmallPtrSet seen; + llvm::SmallVector work(fromBlock->getSuccessors().begin(), fromBlock->getSuccessors().end()); + while (!work.empty()) + { + auto *block = work.pop_back_val(); + if (!seen.insert(block).second) + { continue; } - if (isBorrow(user, value)) + if (block == toBlock && (!killIn(block) || to->isBeforeInBlock(kill))) + { + return true; + } + + if (killIn(block)) { continue; } - if (user->getBlock() != home) + work.append(block->getSuccessors().begin(), block->getSuccessors().end()); + } + + return false; + } + + // The load of an owning local that `value` was read from, through casts that keep the same + // block (a class widened to a union with null, an upcast). None when the value is anything + // else - a parameter, a field, a boxing cast - which phase 1 does not move out of. + static mlir_ts::LoadOp slotLoadOf(mlir::Value value) + { + while (value) + { + auto *def = value.getDefiningOp(); + if (auto castOp = mlir::dyn_cast_or_null(def)) { - user->emitError("'") << describe(value) << "' is given an owner in another block (a loop or a branch); " - "-mm=own cannot prove a move here yet"; - signalPassFailure(); - return false; + if (!mlir::isa(castOp.getType())) + { + return {}; + } + + value = castOp.getIn(); + continue; + } + + auto loadOp = mlir::dyn_cast_or_null(def); + if (!loadOp) + { + return {}; + } + + auto varOp = loadOp.getReference().getDefiningOp(); + if (!varOp || !isOwningVariable(varOp) || varOp.getCaptured().value_or(false)) + { + return {}; + } + + return loadOp; + } + + return {}; + } + + // The use that takes `value` for this retain: the declaration a `ts.RetainSlot` belongs to, + // or the one use of the value after a `ts.Retain` that is not a read. + static mlir::Operation *takerOf(mlir::Operation *retain, mlir::Value value) + { + if (auto retainSlotOp = mlir::dyn_cast(retain)) + { + return retainSlotOp.getSlot().getDefiningOp(); + } + + mlir::Operation *taker = nullptr; + for (auto *user : value.getUsers()) + { + if (mlir::isa(user) || isBorrow(user, value)) + { + continue; } if (taker) { - reportSecondReference(taker->isBeforeInBlock(user) ? user : taker); - return false; + return nullptr; // two takers of one read: not a move this phase proves } taker = user; } + return taker; + } + + // A move out of an owning local: `let b = a`, `h.c = a`, `return a`. rc read the slot and + // retained what it read for the receiver; the move erases that retain and every release of + // the slot the move reaches, which must be dominated by it. Any other use of the slot the + // move reaches - a read, an assignment - is a use after the move. Reports and returns false + // when it is not a move. + bool checkSlotMove(mlir::Operation *retain, mlir::Value value, mlir_ts::LoadOp load, + llvm::SetVector &toErase) + { + auto slot = load.getReference(); + auto varOp = slot.getDefiningOp(); + auto name = varName(varOp); + + auto *taker = takerOf(retain, value); if (!taker) { - return true; // the temporary is the one owner, or nobody is and it leaks as under rc + reportSecondReference(retain); + return false; } - if (!releases.empty()) + // the move's own read, met again around a loop the slot was declared outside of + if (reachableAfter(taker, load, varOp)) { - if (releases.size() != 1 || releases.front()->getBlock() != home || - !taker->isBeforeInBlock(releases.front()) || !takerAcquires(taker, value)) + reportMovedInLoop(taker, name); + return false; + } + + // Every read of the slot is a use of `a`, and so is every use of what a read returned: a + // `const b = a` is folded into its load, so `b.x` after the move reads the moved value + // through a load that came before it. + for (auto *use : slot.getUsers()) + { + if (mlir::isa(use)) { - reportSecondReference(taker); + continue; + } + + if (use != load.getOperation() && reachableAfter(taker, use, varOp)) + { + reportUseAfterMove(use, taker, name); return false; } - movedRelease = releases.front(); + if (auto readOp = mlir::dyn_cast(use)) + { + if (auto *late = readUsedAfter(readOp, retain, taker, varOp)) + { + reportUseAfterMove(late, taker, name); + return false; + } + } } - // Every other use must come first. A use in another block runs after the whole of this - // one, and so after the move. - for (auto *user : value.getUsers()) + auto *acquire = mlir::isa(retain) ? taker : retain; + llvm::SmallVector moved; + for (auto *use : slot.getUsers()) { - if (user == taker || user == movedRelease || mlir::isa(user)) + if (!mlir::isa(use) || !reachableAfter(acquire, use, varOp)) { continue; } - if (user->getBlock() != home || taker->isBeforeInBlock(user)) + if (!dominance->properlyDominates(acquire, use)) { - user->emitError("'") << describe(value) << "' is used after its value was moved"; - signalPassFailure(); + reportMovedOnSomePaths(taker, use, name); return false; } + + moved.push_back(use); } + toErase.insert(moved.begin(), moved.end()); return true; } + // A use of what `read` returned - directly or through casts - that the move at `taker` can + // reach, other than the move's own retain and taker. None if there is none. + mlir::Operation *readUsedAfter(mlir_ts::LoadOp read, mlir::Operation *retain, mlir::Operation *taker, + mlir::Operation *kill) + { + llvm::SmallVector values{read.getResult()}; + while (!values.empty()) + { + auto current = values.pop_back_val(); + for (auto *user : current.getUsers()) + { + if (user == retain || user == taker || mlir::isa(user)) + { + continue; + } + + if (auto castOp = mlir::dyn_cast(user)) + { + values.push_back(castOp.getResult()); + continue; + } + + if (reachableAfter(taker, user, kill)) + { + return user; + } + } + } + + return nullptr; + } + + // Where rc takes the receiver's reference: its `ts.Retain` of the value, when that comes + // first in the taker's block, else the taker itself (a declaration, whose `ts.RetainSlot` + // follows it). A release between the two - a `return`'s scope exit runs after its retain + // and before the store into the return slot - is after the move. + static mlir::Operation *acquirePoint(mlir::Operation *taker, mlir::Value value) + { + mlir::Operation *first = taker; + for (auto *user : value.getUsers()) + { + if (mlir::isa(user) && user->getBlock() == taker->getBlock() && + user->isBeforeInBlock(first)) + { + first = user; + } + } + + return first; + } + // Did rc give this taker a reference of its own - a `ts.Retain` of the value in front of it, // or the `ts.RetainSlot` of an owning local declared from it? Only then is there a release on // the taker's side to give the value back once it has moved. `ts.NewInterface` is a taker to @@ -345,11 +692,14 @@ class OwnershipInferencePass : public mlir::PassWrapper>(varOp.getLoc())) + auto location = varOp.getLoc(); + if (auto fused = mlir::dyn_cast>(location)) { if (auto name = fused.getMetadata().getName()) { @@ -357,6 +707,14 @@ class OwnershipInferencePass : public mlir::PassWrapper>(location)) + { + if (auto named = mlir::dyn_cast_or_null(scoped.getLocations().front())) + { + return named.getName().getValue(); + } + } + return "this value"; } @@ -391,6 +749,30 @@ class OwnershipInferencePass : public mlir::PassWrapperemitError("'") << name << "' takes a second reference; -mm=own cannot prove a move or a borrow here yet"; signalPassFailure(); } + + void reportUseAfterMove(mlir::Operation *use, mlir::Operation *move, llvm::StringRef name) + { + auto diag = use->emitError("'") << name << "' is used here after its value was moved"; + diag.attachNote(move->getLoc()) << "value moved here"; + signalPassFailure(); + } + + void reportMovedInLoop(mlir::Operation *move, llvm::StringRef name) + { + move->emitError("'") << name + << "' is moved inside a loop but was made outside it; only a borrow could do " + "that, and -mm=own cannot prove one yet"; + signalPassFailure(); + } + + void reportMovedOnSomePaths(mlir::Operation *move, mlir::Operation *release, llvm::StringRef name) + { + auto diag = move->emitError("'") << name + << "' is moved here on some paths only; -mm=own cannot release it " + "on the others yet"; + diag.attachNote(release->getLoc()) << "released here on every path"; + signalPassFailure(); + } }; } // end anonymous namespace diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 6d4ceb2cb..fb1c35991 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2400,7 +2400,9 @@ set_tests_properties(test-own-inference-erases-birth-takes # -mm=own, phase 0 (docs/superpowers/plans/2026-09-28-own-phase-0.md). Positive programs run # under JIT and ahead of time; each owns every block once. -foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local own_print_number own_literals) +foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local own_print_number own_literals + own_move_const_branch own_move_let own_move_field own_move_return own_move_loop_body + own_literal_reuse own_move_control_flow) tslang_add_test(NAME test-jit-own-${own_test} COMMAND test-runner -jit -mm=own "${PROJECT_SOURCE_DIR}/test/tester/own/${own_test}.ts") tslang_add_test(NAME test-compile-own-${own_test} COMMAND test-runner -mm=own "${PROJECT_SOURCE_DIR}/test/tester/own/${own_test}.ts") # The promise: no counting at all in what own emits. @@ -2418,18 +2420,34 @@ add_test(NAME test-own-verify-ownership "${PROJECT_SOURCE_DIR}/test/tester/own/own_fresh_array.ts") set_tests_properties(test-own-verify-ownership PROPERTIES FAIL_REGULAR_EXPRESSION "error|Stack dump") +# Under --di a diagnostic names the variable: MLIRGen puts a NameLoc inside each local's +# scope-fused location, and the error reads it. +add_test(NAME test-own-err-names-the-variable + COMMAND $ --emit=obj --no-default-lib -mm=own --di + "${PROJECT_SOURCE_DIR}/test/tester/own/own_err_let_twice.ts" + -o "${CMAKE_CURRENT_BINARY_DIR}/own-err-names-the-variable.obj") +set_tests_properties(test-own-err-names-the-variable + PROPERTIES PASS_REGULAR_EXPRESSION "'a' is used here after its value was moved" + FAIL_REGULAR_EXPRESSION "Stack dump|Assertion failed") + # Each negative program has one shape phase 0 cannot prove, and must be rejected with this text. set(own_error_cases - "own_err_alias|takes a second reference" - "own_err_field_alias|takes a second reference" + "own_err_alias|is used here after its value was moved" + "own_err_assign_after_move|is used here after its value was moved" + "own_err_let_twice|is used here after its value was moved" + "own_err_let_loop|is moved inside a loop" + "own_err_const_alias_move|is used here after its value was moved" + "own_err_const_alias_field|is used here after its value was moved" + "own_err_field_alias|is used here after its value was moved" "own_err_closure|closures that capture a variable are not supported" "own_err_unknown_producer|takes a second reference" - "own_err_two_owners|takes a second reference" - "own_err_const_twice|takes a second reference" - "own_err_push_let|takes a second reference" - "own_err_push_loop|is given an owner in another block" - "own_err_field_loop|is given an owner in another block" - "own_err_use_after_move|is used after its value was moved" + "own_err_two_owners|is used here after its value was moved" + "own_err_const_twice|is used here after its value was moved" + "own_err_push_let|is used here after its value was moved" + "own_err_push_loop|is moved inside a loop" + "own_err_field_loop|is moved inside a loop" + "own_err_move_some_paths|is moved here on some paths only" + "own_err_use_after_move|is used here after its value was moved" "own_err_interface|takes a second reference" "own_err_delete|'delete' is not supported by -mm=own yet") foreach(own_error_case ${own_error_cases}) diff --git a/tslang/test/tester/own/own_err_alias.ts b/tslang/test/tester/own/own_err_alias.ts index 34552ed84..80f2b2fa5 100644 --- a/tslang/test/tester/own/own_err_alias.ts +++ b/tslang/test/tester/own/own_err_alias.ts @@ -1,7 +1,7 @@ -// -mm=own, phase 0 rejects: `b` would be a second owner of `a`'s array. +// -mm=own, phase 1 rejects: `a`'s array moves into `b`, and `a` is read after that. function main() { let a: number[] = [1]; let b = a; - print(b.length); + print(b.length, a.length); print("done."); } diff --git a/tslang/test/tester/own/own_err_assign_after_move.ts b/tslang/test/tester/own/own_err_assign_after_move.ts new file mode 100644 index 000000000..75cb12803 --- /dev/null +++ b/tslang/test/tester/own/own_err_assign_after_move.ts @@ -0,0 +1,10 @@ +// -mm=own, phase 1 rejects: `a` is assigned after its value moved into `b`. rc's assignment +// releases what the slot holds before storing - the moved value, which `b` owns. +class C { x: number = 5; } +function main() { + let a = new C(); + let b = a; + a = new C(); + print(a.x, b.x); + print("done."); +} diff --git a/tslang/test/tester/own/own_err_const_alias_field.ts b/tslang/test/tester/own/own_err_const_alias_field.ts new file mode 100644 index 000000000..5203cd1a8 --- /dev/null +++ b/tslang/test/tester/own/own_err_const_alias_field.ts @@ -0,0 +1,18 @@ +// -mm=own, phase 1 rejects: `b` is a later read of `a`'s slot, and `a` moves into the field in +// between. Overwriting the field destroys the value `b` still reads. +class C { + x: number; + constructor(x: number) { this.x = x; } +} +class H { + c: C | null = null; +} +function main() { + const h = new H(); + let a = new C(5); + const b = a; + h.c = a; + h.c = null; + print(b.x); + print("done."); +} diff --git a/tslang/test/tester/own/own_err_const_alias_move.ts b/tslang/test/tester/own/own_err_const_alias_move.ts new file mode 100644 index 000000000..cd2edfbe7 --- /dev/null +++ b/tslang/test/tester/own/own_err_const_alias_move.ts @@ -0,0 +1,15 @@ +// -mm=own, phase 1 rejects: `const b = a` is another read of `a`'s slot, not a declaration of its +// own, so `b` is `a` read later. `a` then moves into `c`, and reading `b` after that reads the +// moved value - here, after `c` has destroyed it. Holding `b` across the move is a borrow (phase 2). +class C { + x: number; + constructor(x: number) { this.x = x; } +} +function main() { + let a = new C(5); + const b = a; + let c = a; + c = new C(0); + print(b.x, c.x); + print("done."); +} diff --git a/tslang/test/tester/own/own_err_let_loop.ts b/tslang/test/tester/own/own_err_let_loop.ts new file mode 100644 index 000000000..7a4598f91 --- /dev/null +++ b/tslang/test/tester/own/own_err_let_loop.ts @@ -0,0 +1,11 @@ +// -mm=own, phase 1 rejects: `a` is declared outside the loop and moved inside it, so the +// second iteration would move a value that is already gone (spec 2.6). +class C { x: number = 5; } +function main() { + let a = new C(); + for (let i = 0; i < 3; i++) { + let b = a; + print(b.x); + } + print("done."); +} diff --git a/tslang/test/tester/own/own_err_let_twice.ts b/tslang/test/tester/own/own_err_let_twice.ts new file mode 100644 index 000000000..4f2e9a503 --- /dev/null +++ b/tslang/test/tester/own/own_err_let_twice.ts @@ -0,0 +1,9 @@ +// -mm=own, phase 1 rejects: `a` moves into `b`, then is read again for `c`. +class C { x: number = 5; } +function main() { + let a = new C(); + let b = a; + let c = a; + print(b.x, c.x); + print("done."); +} diff --git a/tslang/test/tester/own/own_err_move_some_paths.ts b/tslang/test/tester/own/own_err_move_some_paths.ts new file mode 100644 index 000000000..00491fd5d --- /dev/null +++ b/tslang/test/tester/own/own_err_move_some_paths.ts @@ -0,0 +1,12 @@ +// -mm=own, phase 1 rejects: `a` moves into `b` on one path only, and the release after the `if` +// is reached from both. Keeping it double-frees on the path that moved; erasing it leaks on the +// other. Splitting it (drop elaboration) is not in this phase. +class C { x: number = 5; } +function main(argc: number) { + const a = new C(); + if (argc > 5) { + let b = a; + print(b.x); + } + print("done."); +} diff --git a/tslang/test/tester/own/own_literal_reuse.ts b/tslang/test/tester/own/own_literal_reuse.ts new file mode 100644 index 000000000..e190f190f --- /dev/null +++ b/tslang/test/tester/own/own_literal_reuse.ts @@ -0,0 +1,26 @@ +// -mm=own: literals repeated through a function. Under --opt, CSE merges identical literal casts +// before ownership inference, so one SSA value is stored into several places. A number owns +// nothing, and a string literal is the immortal global, which any number of places may hold - +// neither is moved by being stored. +function count(n: number) { + let t = 0; + let u = 2; + for (let i = 0; i < n; i++) { + t = 0; + t += i; + if (i % 3 == 0) { + u = 0; + } + } + + return t + u; +} + +function main() { + let s = "abc"; + s = "abc"; + let q = "abc"; + assert(count(5) == 4); + assert(s.length + q.length == 6); + print("done."); +} diff --git a/tslang/test/tester/own/own_move_const_branch.ts b/tslang/test/tester/own/own_move_const_branch.ts new file mode 100644 index 000000000..eb6fe07d2 --- /dev/null +++ b/tslang/test/tester/own/own_move_const_branch.ts @@ -0,0 +1,38 @@ +// -mm=own, phase 1: a value moved in a branch that returns. The move reaches only that branch's +// own release, which goes, and the release on the path that falls through stays. +class C { + v: number[] = []; + constructor(public x: number) {} +} + +function churn() { + const keep: C[] = []; + for (let i = 0; i < 64; i++) { + const c = new C(999); + c.v.push(99); + keep.push(c); + } +} + +function pick(i: number) { + const a = new C(i); + a.v.push(i); + if (i % 2 == 0) { + let b = a; + churn(); + return b.x + b.v.length; + } + + churn(); + return a.x; +} + +function main() { + let t: number = 0; + for (let i = 0; i < 100000; i++) { + t += pick(i % 10); + } + + assert(t == 500000); + print("done."); +} diff --git a/tslang/test/tester/own/own_move_control_flow.ts b/tslang/test/tester/own/own_move_control_flow.ts new file mode 100644 index 000000000..966dcb7e9 --- /dev/null +++ b/tslang/test/tester/own/own_move_control_flow.ts @@ -0,0 +1,72 @@ +// -mm=own, phase 1: moves on the control-flow shapes phase 1 opened, each read after churn(): +// a loop body that moves out of its own `let` and leaves early by `break` or `continue`, a +// return from inside a loop of a value made in the body, and one made before the loop. +class C { + v: number[] = []; + constructor(public x: number) {} +} + +function churn() { + const junk: C[] = []; + for (let i = 0; i < 64; i++) { + const c = new C(999); + c.v.push(99); + junk.push(c); + } +} + +function collect() { + const keep: C[] = []; + for (let n = 0; n < 100; n++) { + let a = new C(n % 10); + a.v.push(n); + if (n == 70) break; + if (n % 3 == 0) continue; + let b = a; + keep.push(b); + } + + churn(); + let t: number = 0; + for (const c of keep) { + t += c.x + c.v.length; + } + + return t; +} + +// made in the loop body, returned from it +function findInBody(n: number) { + for (let i = 0; i < 10; i++) { + let a = new C(i); + a.v.push(i); + if (i == n) return a; + } + + return new C(-1); +} + +// made before the loop, returned from inside it: the return is the move, reached once +function findBefore(n: number) { + let a = new C(n); + a.v.push(n); + for (let i = 0; i < 10; i++) { + if (i == n) return a; + } + + return new C(-1); +} + +function main() { + let t: number = 0; + for (let k = 0; k < 1000; k++) { + const c = findInBody(k % 10); + const d = findBefore(k % 12); + churn(); + t += c.x + c.v.length + d.x + d.v.length; + } + + assert(collect() == 253); + assert(t == 9909); + print("done."); +} diff --git a/tslang/test/tester/own/own_move_field.ts b/tslang/test/tester/own/own_move_field.ts new file mode 100644 index 000000000..05bbea9a2 --- /dev/null +++ b/tslang/test/tester/own/own_move_field.ts @@ -0,0 +1,39 @@ +// -mm=own, phase 1: a `let` moved into a field. The field owns it; the holder's release routine +// destroys it; the `let`'s own release goes. +class C { + v: number[] = []; + constructor(public x: number) {} +} + +class H { + c: C | null = null; +} + +function churn() { + const keep: C[] = []; + for (let i = 0; i < 64; i++) { + const c = new C(999); + c.v.push(99); + keep.push(c); + } +} + +function store(i: number) { + const h = new H(); + let a = new C(i); + a.v.push(i); + h.c = a; + churn(); + const c = h.c; + return c ? c.x + c.v.length : -1000; +} + +function main() { + let t: number = 0; + for (let i = 0; i < 100000; i++) { + t += store(i % 10); + } + + assert(t == 550000); + print("done."); +} diff --git a/tslang/test/tester/own/own_move_let.ts b/tslang/test/tester/own/own_move_let.ts new file mode 100644 index 000000000..29cd0d7c9 --- /dev/null +++ b/tslang/test/tester/own/own_move_let.ts @@ -0,0 +1,33 @@ +// -mm=own, phase 1: a `let` hands its value to another `let` and is not used again. The first +// slot's release goes; the second slot's release destroys the value once. +class C { + v: number[] = []; + constructor(public x: number) {} +} + +function churn() { + const keep: C[] = []; + for (let i = 0; i < 64; i++) { + const c = new C(999); + c.v.push(99); + keep.push(c); + } +} + +function relay(i: number) { + let a = new C(i); + a.v.push(i); + let b = a; + churn(); + return b.x + b.v.length; +} + +function main() { + let t: number = 0; + for (let i = 0; i < 100000; i++) { + t += relay(i % 10); + } + + assert(t == 550000); + print("done."); +} diff --git a/tslang/test/tester/own/own_move_loop_body.ts b/tslang/test/tester/own/own_move_loop_body.ts new file mode 100644 index 000000000..ea0903c60 --- /dev/null +++ b/tslang/test/tester/own/own_move_loop_body.ts @@ -0,0 +1,36 @@ +// -mm=own, phase 1: a move inside a loop body whose source is declared in that body. Each +// iteration makes a new `a`, so the move is not "made outside the loop" (spec 2.6). The values +// outlive the loop in `keep`, and are read after churn(), so a release of `a` left behind would +// free a block `keep` still holds and the sum would come out wrong. +class C { + v: number[] = []; + constructor(public x: number) {} +} + +function churn() { + const junk: C[] = []; + for (let i = 0; i < 64; i++) { + const c = new C(999); + c.v.push(99); + junk.push(c); + } +} + +function main() { + const keep: C[] = []; + for (let i = 0; i < 1000; i++) { + let a = new C(i % 10); + a.v.push(i); + let b = a; + keep.push(b); + } + + churn(); + let t: number = 0; + for (const c of keep) { + t += c.x + c.v.length; + } + + assert(t == 5500); + print("done."); +} diff --git a/tslang/test/tester/own/own_move_return.ts b/tslang/test/tester/own/own_move_return.ts new file mode 100644 index 000000000..80eb56841 --- /dev/null +++ b/tslang/test/tester/own/own_move_return.ts @@ -0,0 +1,35 @@ +// -mm=own, phase 1: a `let` returned. At the affine level the return is a store into the return +// slot, and the `let`'s scope-exit release sits between rc's retain and that store - after the +// move, so it goes. +class C { + v: number[] = []; + constructor(public x: number) {} +} + +function churn() { + const keep: C[] = []; + for (let i = 0; i < 64; i++) { + const c = new C(999); + c.v.push(99); + keep.push(c); + } +} + +function make(i: number) { + let a = new C(i); + a.v.push(i); + a.x = a.x + 1; + return a; +} + +function main() { + let t: number = 0; + for (let i = 0; i < 100000; i++) { + const c = make(i % 10); + churn(); + t += c.x + c.v.length; + } + + assert(t == 650000); + print("done."); +}