From 99d01f214483ee66485126f8722b866fd5231103 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Tue, 29 Sep 2026 12:33:55 +0100 Subject: [PATCH 1/5] -mm=own phase 2 plan Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-09-29-own-phase-2.md | 767 ++++++++++++++++++ 1 file changed, 767 insertions(+) create mode 100644 tslang/docs/superpowers/plans/2026-09-29-own-phase-2.md diff --git a/tslang/docs/superpowers/plans/2026-09-29-own-phase-2.md b/tslang/docs/superpowers/plans/2026-09-29-own-phase-2.md new file mode 100644 index 000000000..4e59a39ba --- /dev/null +++ b/tslang/docs/superpowers/plans/2026-09-29-own-phase-2.md @@ -0,0 +1,767 @@ +# `-mm=own` Phase 2 Implementation Plan: borrows for locals + +> **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 `let` declared from a value it cannot take over *borrows* it +(spec §2.2, verdict 2). A borrow takes no reference and releases nothing. It is sound when no use +of the borrower can run after any release or assignment of the owner, and when the borrower never +escapes, meaning it is never stored, returned, retained, or assigned. These phase-1 errors become +legal programs: + +- `const a = new C(); { let b = a; ... } a.x`: a use of the owner after the "move"; +- `let b = a; let c = a;`: two receivers of one value; +- a `let` in a loop body declared from a value made outside the loop; +- a `let` declared on one branch only, which phase 1 rejected as "moved on some paths only". + +**Architecture:** Only `lib/TypeScript/OwnershipInferencePass.cpp` changes. The verdicts are tried +in the spec's order: move, then borrow, then error. The phase-1 move check runs *quietly*. If it +fails and the receiver can borrow (it is an owning `let` with its own `ts.RetainSlot`), `tryBorrow` +decides. If both fail, the error reported is the borrow's when the receiver could borrow, else the +move's. + +A borrow erases: +- the borrower's `ts.RetainSlot`; +- every `ts.ReleaseSlot` of the borrower's slot. + +The owner keeps all its releases. When a source has more than one receiver, every receiver must +be a borrow: a borrow pins its owner, so none of them may move it. + +**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`. Read §2.2 (verdict 2), +§2.6, §4.2 step 3 (borrow), §5 (the borrow rows), and §12 (phase 1 as built, and §12.5, this +phase's input). + +## Global Constraints + +- Branch `own-phase-2` from `origin/main` (13b390e9, with #401 merged). +- `gc`, `rc` and `none` output must not change. Only `OwnershipInferencePass.cpp` changes, and it + runs only under `-mm=own`. +- 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`. Every positive test runs under JIT and AOT, and reads + through a `churn()` after the point where a wrong release would free. +- Build: `cmake --build __build/tslang/windows-msbuild-2026-release --config Release --target tslang test-runner`. + Test: `ctest -j 16 -C Release` from that directory. Rebuild after editing `CMakeLists.txt` so + that ctest picks up the new regexes. +- Source files are CRLF. Edit them with the Edit tool or a Python byte-replace. Run `unix2dos` + on new files. On Windows, write multi-line Python to a file rather than a Bash heredoc. +- Commits are GPG-signed (never `--no-gpg-sign`), and each ends with the session's + `Co-Authored-By:` line. +- Debug rule: a Debug `tslang` idle for 30 s or more is probably on an assert dialog. Watch for + it. +- The IR the pass sees under `--opt`/JIT comes after CSE (spec §12.1). Check shapes with + `--emit=jit --mlir-print-ir-after-all --mlir-disable-threading`, not only with `--emit=mlir-affine`. + +## Review Focus + +1. **An owner assigned while a borrower is still used.** For + `let b = a; a = new C(); print(b.x)`, expect "'b' borrows 'a' but is used here after 'a' is + released or overwritten". It must never compile to a read of freed memory. Task 1 test: + `own_err_assign_after_move` (text changes). +2. **A borrower that escapes.** For `let b = a; h.c = b;` or `return b` or `arr.push(b)`, expect + "cannot be stored, returned or captured". Treating it as a borrow would give the field a value + whose owner frees it. Task 1 test: `own_err_borrow_escape`. +3. **A borrower re-declared in a loop whose owner is assigned in the same body.** For + `for (...) { let b = a; use(b); a = new C(); }`, each iteration's `b` borrows the new `a`. That + is sound, because the path back to `b`'s uses passes `b`'s declaration. Task 1 test: + `own_borrow_loop`. +4. **Mixed receivers.** For `let b = a; h.c = a;`, the second receiver has to move, and the first + pins `a`. Expect an error, not a borrow plus a move. Task 2 test: `own_err_borrow_and_move`. +5. **A borrow of a temporary whose release was consumed elsewhere.** With no temporary release, + the owner is another taker, so there is nothing to bound the borrow. Expect an error. Task 1 + keeps `own_err_two_owners` and `own_err_push_let` negative. + +--- + +## File structure + +- Modify: `lib/TypeScript/OwnershipInferencePass.cpp`. Add: + - the quiet mode (`quiet`, `quietly`); + - `borrowerOf`, `slotEnds`, `tryBorrow`; + - `decideSlot` and `struct SlotReceiver`; + - three borrow diagnostics. + + Also change the SSA path in `checkValueMoves` and the retain loop in `runOnFunction`. +- Delete from `test/tester/own/` the negatives that phase 2 accepts: `own_err_use_after_move.ts`, + `own_err_const_twice.ts`, `own_err_let_twice.ts`, `own_err_let_loop.ts`, `own_err_alias.ts`, + `own_err_move_some_paths.ts`. +- Create in `test/tester/own/`: + - positives: `own_borrow_inner_let.ts`, `own_borrow_two_lets.ts`, `own_borrow_loop.ts`, + `own_borrow_branch.ts`; + - negatives: `own_err_borrow_escape.ts`, `own_err_move_some_paths_field.ts`, + `own_err_borrow_and_move.ts`. +- Modify: `test/tester/CMakeLists.txt`, spec (new §13). + +--- + +### Task 1: borrow for a single `let` receiver + +**Files:** `lib/TypeScript/OwnershipInferencePass.cpp`, `test/tester/own/*`, `test/tester/CMakeLists.txt` + +**Interfaces:** +- Consumes (phase 1): `checkSlotMove`, `reachableAfter`, `isBorrow`, `isOwningVariable`, + `varName`, `describe`, `slotLoadOf`, `takerAcquires`, and the `report*` functions. +- Produces, used by Task 2: + - `template bool quietly(F check)` + - `static mlir_ts::VariableOp borrowerOf(mlir::Operation *acquisition)` + - `static llvm::SmallVector slotEnds(mlir::Value slot)` + - `bool tryBorrow(mlir_ts::VariableOp borrower, llvm::ArrayRef ends, llvm::StringRef ownerName, llvm::SetVector &toErase)` + - `struct SlotReceiver { mlir::Operation *retain; mlir::Value value; mlir_ts::LoadOp load; };` + +- [ ] **Step 1: Failing positive tests.** Each defines the same `C`/`churn` as the phase-1 tests: + +```ts +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); + } +} +``` + +`own_borrow_inner_let.ts`, which a `const` and a `let` owner both take part in: + +```ts +// -mm=own, phase 2: a `let` in an inner scope borrows a value that is used again after that +// scope. The `let` takes no reference and releases nothing; the owner's release destroys it once. + + +function fromConst(i: number) { + const a = new C(i); + a.v.push(i); + { + let b = a; + b.x = b.x + 1; + } + + churn(); + return a.x + a.v.length; +} + +function fromLet(i: number) { + let a = new C(i); + a.v.push(i); + { + let b = a; + b.v.push(i); + } + + churn(); + return a.x + a.v.length; +} + +function main() { + let t: number = 0; + for (let i = 0; i < 100000; i++) { + t += fromConst(i % 10) + fromLet(i % 10); + } + + assert(t == 1300000); + print("done."); +} +``` + +(`fromConst` gives `x + 1 + 1`: 65 per ten. `fromLet` gives `x + 2`: 65 per ten. That is 130 per +ten, 1300000 in all.) + +`own_borrow_loop.ts`: + +```ts +// -mm=own, phase 2: a `let` inside a loop borrows a value made before the loop (spec 2.6), and +// a `let` that borrows an owner reassigned later in the same body borrows the new value on the +// next iteration. + + +function fromOutside(n: number) { + const a = new C(n); + a.v.push(n); + let t: number = 0; + for (let i = 0; i < 3; i++) { + let b = a; + t += b.x + b.v.length; + } + + churn(); + return t + a.x; +} + +function reassigned(n: number) { + let a = new C(n); + let t: number = 0; + for (let i = 0; i < 3; i++) { + let b = a; + t += b.x; + a = new C(b.x + 1); + } + + churn(); + return t + a.x; +} + +function main() { + let t: number = 0; + for (let i = 0; i < 10000; i++) { + t += fromOutside(i % 10) + reassigned(i % 10); + } + + assert(t == 450000); + print("done."); +} +``` + +(`fromOutside(n)` = `3(n+1) + n` = `4n + 3`. `reassigned(n)` = `n + (n+1) + (n+2) + (n+3)` = +`4n + 6`. Their sum is `8n + 9`, which over n = 0..9 is 450 per ten, so 450000 for 10000.) + +`own_borrow_branch.ts`: + +```ts +// -mm=own, phase 2: a `let` declared from a value on one branch only borrows it; phase 1 had to +// reject it as "moved on some paths only". + + +function pick(i: number) { + const a = new C(i); + a.v.push(i); + let t: number = 0; + if (i % 2 == 0) { + let b = a; + t = b.x + b.v.length; + } + + churn(); + return t + a.x; +} + +function main() { + let t: number = 0; + for (let i = 0; i < 100000; i++) { + t += pick(i % 10); + } + + assert(t == 700000); + print("done."); +} +``` + +(Even `i` gives `(x + 1) + x`, and 0, 2, 4, 6, 8 sum to `2*20 + 5` = 45. Odd `i` gives `x`: 25. +That is 70 per ten, 700000 in all.) + +Before committing, compute every expected total by running the file under +`-mm=gc --emit=jit`. The arithmetic above is a check, not the source of truth. + +Register them by adding to the own positive `foreach`: +`own_borrow_inner_let own_borrow_loop own_borrow_branch`. + +- [ ] **Step 2: Negative tests, and the negatives phase 2 accepts** + +Delete `own_err_use_after_move.ts`, `own_err_const_twice.ts`, `own_err_alias.ts`, +`own_err_let_loop.ts` and `own_err_move_some_paths.ts`, along with their `own_error_cases` +entries. Their shapes are now covered by the positives above. `own_err_let_twice.ts` goes in +Task 2. + +`own_err_borrow_escape.ts`: + +```ts +// -mm=own, phase 2 rejects: `b` borrows `a` (which is used again), and is then stored into a +// field - the field would hold a value `a` goes on owning and will free. +class C { x: number = 5; } +class H { c: C | null = null; } +function main() { + const h = new H(); + let a = new C(); + let b = a; + h.c = b; + print(a.x); + print("done."); +} +``` + +`own_err_move_some_paths_field.ts`, to keep the "some paths" error covered: + +```ts +// -mm=own rejects: `a` moves into the field on one path only, and a field store cannot borrow. +class C { x: number = 5; } +class H { c: C | null = null; } +function main(argc: number) { + const h = new H(); + const a = new C(); + if (argc > 5) { + h.c = a; + } + print("done."); +} +``` + +`own_error_cases` changes: + +```cmake + "own_err_borrow_escape|borrows .* and cannot be stored, returned or captured" + "own_err_move_some_paths_field|is moved here on some paths only" + "own_err_assign_after_move|borrows .* but is used here after .* is released or overwritten" + "own_err_const_alias_move|borrows .* and cannot be assigned" +``` + +`own_err_const_alias_move` is `const b = a; let c = a; c = new C(0);`. `c` cannot move, +because `b` is read after it, so it tries to borrow, and it is assigned. + +- [ ] **Step 3: Run to see them fail** + +Build, then `ctest -C Release -R "own_borrow|own_err_borrow|some_paths_field|assign_after_move|const_alias_move" --output-on-failure`. + +Expected: +- The positives fail with "is used here after its value was moved", or with "moved inside a + loop" / "on some paths only". +- `own_err_borrow_escape` fails on its message: it reports a use-after-move instead. +- `own_err_move_some_paths_field` passes already. That is fine: it is coverage, not RED. + +- [ ] **Step 4: Quiet mode and the borrow verdict** + +Add a member `unsigned quiet = 0;` and a helper: + +```cpp + // Runs a verdict without reporting: the next verdict gets its turn before anything is said. + template bool quietly(F check) + { + ++quiet; + auto result = check(); + --quiet; + return result; + } +``` + +Begin each of `reportSecondReference`, `reportUseAfterMove`, `reportMovedInLoop` and +`reportMovedOnSomePaths` with `if (quiet) return;`. + +Add the borrow diagnostics: + +```cpp + void reportBorrowOutlives(mlir::Operation *use, mlir::Operation *end, llvm::StringRef name, llvm::StringRef owner) + { + if (quiet) return; + auto diag = use->emitError("'") << name << "' borrows '" << owner + << "' but is used here after '" << owner << "' is released or overwritten"; + diag.attachNote(end->getLoc()) << "'" << owner << "' is released or overwritten here"; + signalPassFailure(); + } + + void reportBorrowEscapes(mlir::Operation *op, llvm::StringRef name, llvm::StringRef owner) + { + if (quiet) return; + op->emitError("'") << name << "' borrows '" << owner << "' and cannot be stored, returned or captured"; + signalPassFailure(); + } + + void reportBorrowerAssigned(mlir::Operation *op, llvm::StringRef name, llvm::StringRef owner) + { + if (quiet) return; + op->emitError("'") << name << "' borrows '" << owner + << "' and cannot be assigned; -mm=own cannot prove that yet"; + signalPassFailure(); + } +``` + +Add the borrow machinery: + +```cpp + // A `let` that could borrow: owning, declared from a value, holding a reference it took + // itself (a `ts.RetainSlot`, not the value's own consumed one), and not captured. + static mlir_ts::VariableOp borrowerOf(mlir::Operation *acquisition) + { + mlir_ts::VariableOp varOp; + if (auto retainSlotOp = mlir::dyn_cast(acquisition)) + { + varOp = retainSlotOp.getSlot().getDefiningOp(); + } + else + { + varOp = mlir::dyn_cast(acquisition); + } + + if (!varOp || !varOp.getInitializer() || !isOwningVariable(varOp) || + varOp->hasAttr(OWNED_LOCAL_CONSUMED_ATTR_NAME) || varOp.getCaptured().value_or(false)) + { + return {}; + } + + auto retains = llvm::any_of(varOp.getResult().getUsers(), + [](mlir::Operation *user) { return mlir::isa(user); }); + return retains ? varOp : mlir_ts::VariableOp(); + } + + // Where an owning local stops holding its value: each release of its slot and each store + // into it. + static llvm::SmallVector slotEnds(mlir::Value slot) + { + llvm::SmallVector ends; + for (auto *user : slot.getUsers()) + { + if (mlir::isa(user)) + { + ends.push_back(user); + } + } + + return ends; + } + + // A borrow (spec 2.2, verdict 2): `borrower` takes no reference and gives none back, so its + // retain and every release of its slot go, and the owner keeps its own. Sound while no use of + // the borrower can run after any of `ends` - the owner's releases and assignments - and while + // nothing it holds is kept: not stored, returned, retained or assigned. A path back to a use + // through the borrower's own declaration is a new borrow, not this one. Reports and returns + // false otherwise. + bool tryBorrow(mlir_ts::VariableOp borrower, llvm::ArrayRef ends, llvm::StringRef owner, + llvm::SetVector &toErase) + { + auto slot = borrower.getResult(); + auto name = varName(borrower); + llvm::SmallVector uses; + llvm::SmallVector bookkeeping; + for (auto *user : slot.getUsers()) + { + if (mlir::isa(user)) + { + bookkeeping.push_back(user); + continue; + } + + if (mlir::isa(user)) + { + reportBorrowerAssigned(user, name, owner); + return false; + } + + auto readOp = mlir::dyn_cast(user); + if (!readOp) + { + reportBorrowEscapes(user, name, owner); + return false; + } + + uses.push_back(user); + llvm::SmallVector values{readOp.getResult()}; + while (!values.empty()) + { + auto current = values.pop_back_val(); + for (auto *valueUser : current.getUsers()) + { + uses.push_back(valueUser); + if (auto castOp = mlir::dyn_cast(valueUser)) + { + values.push_back(castOp.getResult()); + continue; + } + + if (mlir::isa(valueUser) || !isBorrow(valueUser, current)) + { + reportBorrowEscapes(valueUser, name, owner); + return false; + } + } + } + } + + for (auto *end : ends) + { + for (auto *use : uses) + { + if (reachableAfter(end, use, borrower)) + { + reportBorrowOutlives(use, end, name, owner); + return false; + } + } + } + + toErase.insert(bookkeeping.begin(), bookkeeping.end()); + return true; + } +``` + +- [ ] **Step 5: Wire the verdict order for a single receiver** + +SSA sources: in `checkValueMoves`, rename the current body after the early `return true` to a new +function `bool movesOf(mlir::Value value, llvm::SetVector &toErase)`. This is +the phase-1 move verdict, and it reports. `checkValueMoves` then becomes: + +```cpp + bool checkValueMoves(mlir::Value value, llvm::SetVector &toErase) + { + if (!ownsHeap(value) || isImmortalLiteral(value)) + { + return true; + } + + if (quietly([&] { return movesOf(value, toErase); })) + { + return true; + } + + // Not a move. Every taker a `let` that can borrow, and the temporary's own release the + // owner that bounds them - else the move's error stands. + llvm::SmallVector takers; + llvm::SmallVector releases; + for (auto *user : value.getUsers()) + { + if (mlir::isa(user)) + { + continue; + } + + if (mlir::isa(user)) + { + releases.push_back(user); + } + else if (!isBorrow(user, value)) + { + takers.push_back(user); + } + } + + if (!releases.empty() && !takers.empty() && + llvm::all_of(takers, [](mlir::Operation *taker) { return !!borrowerOf(taker); })) + { + auto owner = describe(value); + auto ok = true; + for (auto *taker : takers) + { + ok = tryBorrow(borrowerOf(taker), releases, owner, toErase) && ok; + } + + return ok; + } + + return movesOf(value, toErase); + } +``` + +`describe(value)` returns the name of the first `ts.Variable` user, which is a borrower. For a +folded `const` that is the borrower's own name. **Ruling to make and ledger:** give +`describe` a parameter, or add an `ownerName(value)` that looks for a `ts.DebugVariable` user +(`--di` names a folded const through `ts.DebugVariable`), falling back to "this value". + +Slot sources: in `runOnFunction`, collect instead of deciding per retain: + +```cpp + llvm::MapVector> slotReceivers; + 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()) + { + slotReceivers[load.getReference()].push_back({op, value, load}); + continue; + } + + if (!value || isFresh(value) == false) + { + reportSecondReference(op); + } + } + + for (auto &entry : slotReceivers) + { + decideSlot(entry.first, entry.second, toErase); + } +``` + +For a single receiver: + +```cpp + struct SlotReceiver + { + mlir::Operation *retain; + mlir::Value value; + mlir_ts::LoadOp load; + }; + + // Move, else borrow, else error (spec 2.2) for the receivers of one owning local. + void decideSlot(mlir::Value slot, llvm::ArrayRef receivers, llvm::SetVector &toErase) + { + auto owner = varName(slot.getDefiningOp()); + if (receivers.size() == 1) + { + auto &receiver = receivers.front(); + if (quietly([&] { return checkSlotMove(receiver.retain, receiver.value, receiver.load, toErase); })) + { + toErase.insert(receiver.retain); + return; + } + + if (auto borrower = borrowerOf(receiver.retain)) + { + tryBorrow(borrower, slotEnds(slot), owner, toErase); + return; + } + + checkSlotMove(receiver.retain, receiver.value, receiver.load, toErase); // reports + return; + } + + decideSlotReceivers(slot, receivers, owner, toErase); // Task 2 + } +``` + +For Task 1, make `decideSlotReceivers` run the single-receiver path on each receiver. That is the +phase-1 behavior, and Task 2 replaces it. + +- [ ] **Step 6: Build, run every own test, fix expected texts** + +Run `ctest -C Release -j 12 -R own --output-on-failure`. + +Expected: everything passes. A negative that changed text is fine only if its new error points +at a real problem. Check each one, update `own_error_cases`, and write a ruling for each change. + +- [ ] **Step 7: Commit** ("-mm=own phase 2: a let that cannot move borrows"). + +--- + +### Task 2: several receivers of one owner + +When a source has more than one receiver, a borrow pins the owner, so none of the receivers may +move it. Every receiver must be a `let` that borrows. Anything else reports: + +- for a receiver that is not a `let`, its phase-1 move error (another receiver's reads come after + it); +- or "takes a second reference", when that move check would pass. + +**Files:** `lib/TypeScript/OwnershipInferencePass.cpp`, `test/tester/own/*`, `test/tester/CMakeLists.txt` + +- [ ] **Step 1: Failing tests** + +`own_borrow_two_lets.ts`: + +```ts +// -mm=own, phase 2: two `let`s of one `let`, both borrowing it; neither may move it. + + +function twice(i: number) { + let a = new C(i); + a.v.push(i); + let b = a; + let c = a; + churn(); + return b.x + c.v.length + a.x; +} + +function main() { + let t: number = 0; + for (let i = 0; i < 100000; i++) { + t += twice(i % 10); + } + + assert(t == 1000000); + print("done."); +} +``` + +(`2x + 1`: 100 per ten. Confirm the total under gc before committing.) + +`own_err_borrow_and_move.ts`: + +```ts +// -mm=own, phase 2 rejects: `b` borrows `a`, which pins it, so the field store cannot move it. +class C { x: number = 5; } +class H { c: C | null = null; } +function main() { + const h = new H(); + let a = new C(); + let b = a; + h.c = a; + print(b.x); + print("done."); +} +``` + +Delete `own_err_let_twice.ts` and its entry. Add: + +```cmake + "own_err_borrow_and_move|is used here after its value was moved|takes a second reference" +``` + +- [ ] **Step 2: Run to see them fail** + +Expected: `own_borrow_two_lets` fails with "is used here after its value was moved". + +- [ ] **Step 3: Implement** + +```cpp + // More than one receiver of one owning local. Each has to borrow: a borrow pins the owner, + // so none may move it. + void decideSlotReceivers(mlir::Value slot, llvm::ArrayRef receivers, llvm::StringRef owner, + llvm::SetVector &toErase) + { + auto ends = slotEnds(slot); + for (auto &receiver : receivers) + { + if (auto borrower = borrowerOf(receiver.retain)) + { + tryBorrow(borrower, ends, owner, toErase); + continue; + } + + llvm::SetVector unused; + if (checkSlotMove(receiver.retain, receiver.value, receiver.load, unused)) + { + reportSecondReference(receiver.retain); + } + } + } +``` + +In `checkValueMoves`, the SSA path already requires every taker to borrow once the move fails. No +change is needed there. + +- [ ] **Step 4: Build, run own tests, commit** ("-mm=own phase 2: several lets of one owner all + borrow it"). + +--- + +### Task 3: teeth, measurement, suites, record + +- [ ] **Step 1: Teeth.** In `tryBorrow`, keep the borrower's `ts.ReleaseSlot`s by inserting only + the `ts.RetainSlot`s from `bookkeeping`. Rebuild and run `ctest -R "own-own_borrow"`. + + Expected: every borrow positive fails, because the borrower's release destroys what the owner + still holds. Strengthen any test that passes. Restore, and write the result in the ledger. +- [ ] **Step 2: Measure.** Run `measure.ps1` on `own_borrow_inner_let.ts` and + `own_borrow_loop.ts` with `-Models gc,rc,none,own`, at O3 and at O1 (a scratch copy with + `--opt_level=1`). + + Expected: own is flat like rc. +- [ ] **Step 3: Full Release suite.** Every test passes. +- [ ] **Step 4: Debug own subset.** + + ``` + ctest -C Debug -j 12 -R own -E owned-async + ``` + + Expected: only `test-ownership-verifier-4` fails, which is the known linkname assert. +- [ ] **Step 5: Spec §13 "Phase 2 results".** Cover: + - the borrow rule as built; + - the receivers rule; + - the numbers; + - the teeth result; + - the tests that changed; + - the limits left for phase 3: element and field borrows, borrow chains, borrowers that are + assigned, borrows of parameters, moves after borrows. + + Update the status line. +- [ ] **Step 6: Commit** ("-mm=own phase 2: results"). From 8619d51de4970e8c9498a13e555e7425e826cb57 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Tue, 29 Sep 2026 12:37:01 +0100 Subject: [PATCH 2/5] -mm=own phase 2: a let that cannot move borrows Verdicts run in the spec's order: move, then borrow, then error. The phase-1 move check now runs quietly first; when it fails and the receiver is an owning `let` that took its own reference, it borrows: its retain and every release of its slot go, and the owner keeps its own. That holds while no use of the borrower can run after a release or an assignment of the owner, and while nothing it holds is stored, returned, retained or assigned - each broken rule has its own error, naming both variables under --di. Five phase-1 negatives are now legal and are replaced by positives: an inner `let` of a value used again, a `let` in a loop of a value made outside it, a `let` on one branch. Co-Authored-By: Claude Opus 5.5 --- .../lib/TypeScript/OwnershipInferencePass.cpp | 307 +++++++++++++++++- tslang/test/tester/CMakeLists.txt | 14 +- tslang/test/tester/own/own_borrow_branch.ts | 38 +++ .../test/tester/own/own_borrow_inner_let.ts | 49 +++ tslang/test/tester/own/own_borrow_loop.ts | 52 +++ tslang/test/tester/own/own_err_alias.ts | 7 - .../test/tester/own/own_err_borrow_escape.ts | 12 + tslang/test/tester/own/own_err_const_twice.ts | 9 - tslang/test/tester/own/own_err_let_loop.ts | 11 - .../tester/own/own_err_move_some_paths.ts | 12 - .../own/own_err_move_some_paths_field.ts | 11 + .../test/tester/own/own_err_use_after_move.ts | 9 - 12 files changed, 469 insertions(+), 62 deletions(-) create mode 100644 tslang/test/tester/own/own_borrow_branch.ts create mode 100644 tslang/test/tester/own/own_borrow_inner_let.ts create mode 100644 tslang/test/tester/own/own_borrow_loop.ts delete mode 100644 tslang/test/tester/own/own_err_alias.ts create mode 100644 tslang/test/tester/own/own_err_borrow_escape.ts delete mode 100644 tslang/test/tester/own/own_err_const_twice.ts delete mode 100644 tslang/test/tester/own/own_err_let_loop.ts delete mode 100644 tslang/test/tester/own/own_err_move_some_paths.ts create mode 100644 tslang/test/tester/own/own_err_move_some_paths_field.ts delete mode 100644 tslang/test/tester/own/own_err_use_after_move.ts diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index aa38225e9..b21bd9395 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -9,6 +9,7 @@ #include "TypeScript/Defines.h" #include "TypeScript/MLIRLogic/MLIRTypeHelper.h" +#include "llvm/ADT/MapVector.h" #include "llvm/ADT/SetVector.h" #include "llvm/ADT/SmallPtrSet.h" #include "llvm/ADT/SmallVector.h" @@ -108,6 +109,9 @@ class OwnershipInferencePass : public mlir::PassWrapper> slotReceivers; for (auto *op : retains) { auto value = retainedValue(op); @@ -119,11 +123,7 @@ class OwnershipInferencePass : public mlir::PassWrappererase(); @@ -142,6 +147,192 @@ class OwnershipInferencePass : public mlir::PassWrapper bool quietly(F check) + { + ++quiet; + auto result = check(); + --quiet; + return result; + } + + // Move, else borrow, else error (spec 2.2) for the receivers of one owning local. + void decideSlot(mlir::Value slot, llvm::ArrayRef receivers, llvm::SetVector &toErase) + { + auto owner = varName(slot.getDefiningOp()); + if (receivers.size() == 1) + { + auto &receiver = receivers.front(); + if (quietly([&] { return checkSlotMove(receiver.retain, receiver.value, receiver.load, toErase); })) + { + toErase.insert(receiver.retain); + return; + } + + if (auto borrower = borrowerOf(receiver.retain)) + { + tryBorrow(borrower, slotEnds(slot), owner, toErase); + return; + } + + checkSlotMove(receiver.retain, receiver.value, receiver.load, toErase); // reports + return; + } + + decideSlotReceivers(slot, receivers, owner, toErase); + } + + // More than one receiver of one owning local: phase 1's verdict for each (Task 2 replaces it). + void decideSlotReceivers(mlir::Value slot, llvm::ArrayRef receivers, llvm::StringRef owner, + llvm::SetVector &toErase) + { + for (auto &receiver : receivers) + { + if (checkSlotMove(receiver.retain, receiver.value, receiver.load, toErase)) + { + toErase.insert(receiver.retain); + } + } + } + + // A `let` that could borrow: owning, declared from a value, holding a reference it took + // itself (a `ts.RetainSlot`, not the value's own consumed one), and not captured. + static mlir_ts::VariableOp borrowerOf(mlir::Operation *acquisition) + { + mlir_ts::VariableOp varOp; + if (auto retainSlotOp = mlir::dyn_cast(acquisition)) + { + varOp = retainSlotOp.getSlot().getDefiningOp(); + } + else + { + varOp = mlir::dyn_cast(acquisition); + } + + if (!varOp || !varOp.getInitializer() || !isOwningVariable(varOp) || + varOp->hasAttr(OWNED_LOCAL_CONSUMED_ATTR_NAME) || varOp.getCaptured().value_or(false)) + { + return {}; + } + + auto retains = llvm::any_of(varOp.getResult().getUsers(), + [](mlir::Operation *user) { return mlir::isa(user); }); + return retains ? varOp : mlir_ts::VariableOp(); + } + + // Where an owning local stops holding its value: each release of its slot and each store + // into it. + static llvm::SmallVector slotEnds(mlir::Value slot) + { + llvm::SmallVector ends; + for (auto *user : slot.getUsers()) + { + if (mlir::isa(user)) + { + ends.push_back(user); + } + } + + return ends; + } + + // A borrow (spec 2.2, verdict 2): `borrower` takes no reference and gives none back, so its + // retain and every release of its slot go, and the owner keeps its own. Sound while no use of + // the borrower can run after any of `ends` - the owner's releases and assignments - and while + // nothing it holds is kept: not stored, returned, retained or assigned. A path back to a use + // through the borrower's own declaration is a new borrow, not this one. Reports and returns + // false otherwise. + bool tryBorrow(mlir_ts::VariableOp borrower, llvm::ArrayRef ends, llvm::StringRef owner, + llvm::SetVector &toErase) + { + auto slot = borrower.getResult(); + auto name = varName(borrower); + llvm::SmallVector uses; + llvm::SmallVector bookkeeping; + for (auto *user : slot.getUsers()) + { + if (mlir::isa(user)) + { + bookkeeping.push_back(user); + continue; + } + + if (mlir::isa(user)) + { + reportBorrowerAssigned(user, name, owner); + return false; + } + + auto readOp = mlir::dyn_cast(user); + if (!readOp) + { + reportBorrowEscapes(user, name, owner); + return false; + } + + uses.push_back(user); + llvm::SmallVector values{readOp.getResult()}; + while (!values.empty()) + { + auto current = values.pop_back_val(); + for (auto *valueUser : current.getUsers()) + { + uses.push_back(valueUser); + if (auto castOp = mlir::dyn_cast(valueUser)) + { + values.push_back(castOp.getResult()); + continue; + } + + if (mlir::isa(valueUser) || !isBorrow(valueUser, current)) + { + reportBorrowEscapes(valueUser, name, owner); + return false; + } + } + } + } + + for (auto *end : ends) + { + for (auto *use : uses) + { + if (reachableAfter(end, use, borrower)) + { + reportBorrowOutlives(use, end, name, owner); + return false; + } + } + } + + toErase.insert(bookkeeping.begin(), bookkeeping.end()); + return true; + } + + // The name of a temporary that owns: a folded `const` has no ts.Variable, but under --di it + // has a ts.DebugVariable, whose location carries the name. + static llvm::StringRef ownerName(mlir::Value value) + { + for (auto *user : value.getUsers()) + { + if (mlir::isa(user)) + { + return nameAt(user->getLoc()); + } + } + + return "this value"; + } + // The value a retain acquires: a Retain's operand, or a RetainSlot's variable's initializer. // None for a variable with no initializer - its storage was hoisted in front of a try and its // value arrives by a store this phase does not follow. @@ -295,6 +486,51 @@ class OwnershipInferencePass : public mlir::PassWrapper takers; + llvm::SmallVector releases; + for (auto *user : value.getUsers()) + { + if (mlir::isa(user)) + { + continue; + } + + if (mlir::isa(user)) + { + releases.push_back(user); + } + else if (!isBorrow(user, value)) + { + takers.push_back(user); + } + } + + if (!releases.empty() && !takers.empty() && + llvm::all_of(takers, [](mlir::Operation *taker) { return !!borrowerOf(taker); })) + { + auto owner = ownerName(value); + auto ok = true; + for (auto *taker : takers) + { + ok = tryBorrow(borrowerOf(taker), releases, owner, toErase) && ok; + } + + return ok; + } + + return movesOf(value, toErase); + } + + // The move verdict for an SSA value (phase 1). Reports and returns false when it is not one. + bool movesOf(mlir::Value value, llvm::SetVector &toErase) + { llvm::SmallVector takers; llvm::SmallVector releases; llvm::SmallVector reads; @@ -698,7 +934,11 @@ class OwnershipInferencePass : public mlir::PassWrapper>(location)) { if (auto name = fused.getMetadata().getName()) @@ -733,6 +973,11 @@ class OwnershipInferencePass : public mlir::PassWrapper(op)) { @@ -752,6 +997,11 @@ class OwnershipInferencePass : public mlir::PassWrapperemitError("'") << name << "' is used here after its value was moved"; diag.attachNote(move->getLoc()) << "value moved here"; signalPassFailure(); @@ -759,14 +1009,59 @@ class OwnershipInferencePass : public mlir::PassWrapperemitError("'") << 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 reportBorrowOutlives(mlir::Operation *use, mlir::Operation *end, llvm::StringRef name, llvm::StringRef owner) + { + if (quiet) + { + return; + } + + auto diag = use->emitError("'") << name << "' borrows '" << owner << "' but is used here after '" << owner + << "' is released or overwritten"; + diag.attachNote(end->getLoc()) << "'" << owner << "' is released or overwritten here"; + signalPassFailure(); + } + + void reportBorrowEscapes(mlir::Operation *op, llvm::StringRef name, llvm::StringRef owner) + { + if (quiet) + { + return; + } + + op->emitError("'") << name << "' borrows '" << owner << "' and cannot be stored, returned or captured"; + signalPassFailure(); + } + + void reportBorrowerAssigned(mlir::Operation *op, llvm::StringRef name, llvm::StringRef owner) + { + if (quiet) + { + return; + } + + op->emitError("'") << name << "' borrows '" << owner << "' and cannot be assigned; -mm=own cannot prove that yet"; + signalPassFailure(); + } + void reportMovedOnSomePaths(mlir::Operation *move, mlir::Operation *release, llvm::StringRef name) { + if (quiet) + { + return; + } + auto diag = move->emitError("'") << name << "' is moved here on some paths only; -mm=own cannot release it " "on the others yet"; diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index fb1c35991..d3ea86585 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2402,7 +2402,8 @@ set_tests_properties(test-own-inference-erases-birth-takes # 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 own_move_const_branch own_move_let own_move_field own_move_return own_move_loop_body - own_literal_reuse own_move_control_flow) + own_literal_reuse own_move_control_flow + own_borrow_inner_let own_borrow_loop own_borrow_branch) 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. @@ -2432,22 +2433,19 @@ set_tests_properties(test-own-err-names-the-variable # Each negative program has one shape phase 0 cannot prove, and must be rejected with this text. set(own_error_cases - "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_assign_after_move|borrows .* but is used here after .* is released or overwritten" + "own_err_borrow_escape|borrows .* and cannot be stored, returned or captured" + "own_err_move_some_paths_field|is moved here on some paths only" "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_move|borrows .* and cannot be assigned" "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|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_borrow_branch.ts b/tslang/test/tester/own/own_borrow_branch.ts new file mode 100644 index 000000000..5767de80d --- /dev/null +++ b/tslang/test/tester/own/own_borrow_branch.ts @@ -0,0 +1,38 @@ +// -mm=own, phase 2: a `let` declared from a value on one branch only borrows it; phase 1 had to +// reject it as "moved on some paths only". +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 pick(i: number) { + const a = new C(i); + a.v.push(i); + let t: number = 0; + if (i % 2 == 0) { + let b = a; + t = b.x + b.v.length; + } + + churn(); + return t + a.x; +} + +function main() { + let t: number = 0; + for (let i = 0; i < 100000; i++) { + t += pick(i % 10); + } + + assert(t == 700000); + print("done."); +} diff --git a/tslang/test/tester/own/own_borrow_inner_let.ts b/tslang/test/tester/own/own_borrow_inner_let.ts new file mode 100644 index 000000000..5d1fe82e5 --- /dev/null +++ b/tslang/test/tester/own/own_borrow_inner_let.ts @@ -0,0 +1,49 @@ +// -mm=own, phase 2: a `let` in an inner scope borrows a value that is used again after that +// scope. The `let` takes no reference and releases nothing; the owner's release destroys it once. +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 fromConst(i: number) { + const a = new C(i); + a.v.push(i); + { + let b = a; + b.x = b.x + 1; + } + + churn(); + return a.x + a.v.length; +} + +function fromLet(i: number) { + let a = new C(i); + a.v.push(i); + { + let b = a; + b.v.push(i); + } + + churn(); + return a.x + a.v.length; +} + +function main() { + let t: number = 0; + for (let i = 0; i < 100000; i++) { + t += fromConst(i % 10) + fromLet(i % 10); + } + + assert(t == 1300000); + print("done."); +} diff --git a/tslang/test/tester/own/own_borrow_loop.ts b/tslang/test/tester/own/own_borrow_loop.ts new file mode 100644 index 000000000..b21d28fbe --- /dev/null +++ b/tslang/test/tester/own/own_borrow_loop.ts @@ -0,0 +1,52 @@ +// -mm=own, phase 2: a `let` inside a loop borrows a value made before the loop (spec 2.6), and +// a `let` that borrows an owner reassigned later in the same body borrows the new value on the +// next iteration. +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 fromOutside(n: number) { + const a = new C(n); + a.v.push(n); + let t: number = 0; + for (let i = 0; i < 3; i++) { + let b = a; + t += b.x + b.v.length; + } + + churn(); + return t + a.x; +} + +function reassigned(n: number) { + let a = new C(n); + let t: number = 0; + for (let i = 0; i < 3; i++) { + let b = a; + t += b.x; + a = new C(b.x + 1); + } + + churn(); + return t + a.x; +} + +function main() { + let t: number = 0; + for (let i = 0; i < 10000; i++) { + t += fromOutside(i % 10) + reassigned(i % 10); + } + + assert(t == 450000); + print("done."); +} diff --git a/tslang/test/tester/own/own_err_alias.ts b/tslang/test/tester/own/own_err_alias.ts deleted file mode 100644 index 80f2b2fa5..000000000 --- a/tslang/test/tester/own/own_err_alias.ts +++ /dev/null @@ -1,7 +0,0 @@ -// -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."); -} diff --git a/tslang/test/tester/own/own_err_borrow_escape.ts b/tslang/test/tester/own/own_err_borrow_escape.ts new file mode 100644 index 000000000..9cf909322 --- /dev/null +++ b/tslang/test/tester/own/own_err_borrow_escape.ts @@ -0,0 +1,12 @@ +// -mm=own, phase 2 rejects: `b` borrows `a` (which is used again), and is then stored into a +// field - the field would hold a value `a` goes on owning and will free. +class C { x: number = 5; } +class H { c: C | null = null; } +function main() { + const h = new H(); + let a = new C(); + let b = a; + h.c = b; + print(a.x); + print("done."); +} diff --git a/tslang/test/tester/own/own_err_const_twice.ts b/tslang/test/tester/own/own_err_const_twice.ts deleted file mode 100644 index 1bb5a1c0a..000000000 --- a/tslang/test/tester/own/own_err_const_twice.ts +++ /dev/null @@ -1,9 +0,0 @@ -// -mm=own, phase 0 rejects: two locals would each own the one instance `a` names. -class C { v: number[] = [1,2,3]; } -function main() { - const a = new C(); - let b = a; - let c = a; - print(b.v.length + c.v.length); - print("done."); -} diff --git a/tslang/test/tester/own/own_err_let_loop.ts b/tslang/test/tester/own/own_err_let_loop.ts deleted file mode 100644 index 7a4598f91..000000000 --- a/tslang/test/tester/own/own_err_let_loop.ts +++ /dev/null @@ -1,11 +0,0 @@ -// -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_move_some_paths.ts b/tslang/test/tester/own/own_err_move_some_paths.ts deleted file mode 100644 index 00491fd5d..000000000 --- a/tslang/test/tester/own/own_err_move_some_paths.ts +++ /dev/null @@ -1,12 +0,0 @@ -// -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_err_move_some_paths_field.ts b/tslang/test/tester/own/own_err_move_some_paths_field.ts new file mode 100644 index 000000000..62e2495c7 --- /dev/null +++ b/tslang/test/tester/own/own_err_move_some_paths_field.ts @@ -0,0 +1,11 @@ +// -mm=own rejects: `a` moves into the field on one path only, and a field store cannot borrow. +class C { x: number = 5; } +class H { c: C = new C(); } +function main(argc: number) { + const h = new H(); + const a = new C(); + if (argc > 5) { + h.c = a; + } + print("done."); +} diff --git a/tslang/test/tester/own/own_err_use_after_move.ts b/tslang/test/tester/own/own_err_use_after_move.ts deleted file mode 100644 index d41b232c8..000000000 --- a/tslang/test/tester/own/own_err_use_after_move.ts +++ /dev/null @@ -1,9 +0,0 @@ -// -mm=own, phase 0 rejects: `b` takes `a`'s instance and gives it up at the end of its block; `a` is used after. -class C { v: number[] = [1,2,3]; } -function main() { - const a = new C(); - { let b = a; print(b.v.length); } - const f = [9,9,9,9,9,9,9]; - print(a.v.length); - print("done."); -} From 77b2c9c098d08f02ca3783abe1e11063496f98c7 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Tue, 29 Sep 2026 12:41:07 +0100 Subject: [PATCH 3/5] -mm=own phase 2: several lets of one owner all borrow it Where an owning local has more than one receiver, each must be a `let` that borrows it: a borrow pins its owner, so none of them may move it. `let b = a; let c = a;` is now legal; a field store beside a borrow reports "'a' is moved here while 'b' borrows it". Co-Authored-By: Claude Opus 5.5 --- .../lib/TypeScript/OwnershipInferencePass.cpp | 37 +++++++++++++++++-- tslang/test/tester/CMakeLists.txt | 7 ++-- tslang/test/tester/own/own_borrow_two_lets.ts | 33 +++++++++++++++++ .../tester/own/own_err_borrow_and_move.ts | 11 ++++++ tslang/test/tester/own/own_err_let_twice.ts | 9 ----- 5 files changed, 82 insertions(+), 15 deletions(-) create mode 100644 tslang/test/tester/own/own_borrow_two_lets.ts create mode 100644 tslang/test/tester/own/own_err_borrow_and_move.ts delete mode 100644 tslang/test/tester/own/own_err_let_twice.ts diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index b21bd9395..617479bdf 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -191,15 +191,34 @@ class OwnershipInferencePass : public mlir::PassWrapper receivers, llvm::StringRef owner, llvm::SetVector &toErase) { + auto ends = slotEnds(slot); + mlir_ts::VariableOp someBorrower; for (auto &receiver : receivers) { - if (checkSlotMove(receiver.retain, receiver.value, receiver.load, toErase)) + if (auto borrower = borrowerOf(receiver.retain)) { - toErase.insert(receiver.retain); + someBorrower = borrower; + tryBorrow(borrower, ends, owner, toErase); + } + } + + for (auto &receiver : receivers) + { + if (borrowerOf(receiver.retain)) + { + continue; + } + + llvm::SetVector unused; + if (checkSlotMove(receiver.retain, receiver.value, receiver.load, unused)) + { + reportMovedWhileBorrowed(receiver.retain, owner, someBorrower ? varName(someBorrower) : "another variable"); } } } @@ -1033,6 +1052,18 @@ class OwnershipInferencePass : public mlir::PassWrapperemitError("'") << owner << "' is moved here while '" << borrower + << "' borrows it; a borrowed value cannot be moved"; + signalPassFailure(); + } + void reportBorrowEscapes(mlir::Operation *op, llvm::StringRef name, llvm::StringRef owner) { if (quiet) diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index d3ea86585..34fb4bc2b 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2403,7 +2403,8 @@ set_tests_properties(test-own-inference-erases-birth-takes 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 - own_borrow_inner_let own_borrow_loop own_borrow_branch) + own_borrow_inner_let own_borrow_loop own_borrow_branch + own_borrow_two_lets) 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. @@ -2425,7 +2426,7 @@ set_tests_properties(test-own-verify-ownership PROPERTIES FAIL_REGULAR_EXPRESSIO # 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" + "${PROJECT_SOURCE_DIR}/test/tester/own/own_err_const_alias_field.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" @@ -2436,7 +2437,7 @@ set(own_error_cases "own_err_assign_after_move|borrows .* but is used here after .* is released or overwritten" "own_err_borrow_escape|borrows .* and cannot be stored, returned or captured" "own_err_move_some_paths_field|is moved here on some paths only" - "own_err_let_twice|is used here after its value was moved" + "own_err_borrow_and_move|is moved here while .* borrows it" "own_err_const_alias_move|borrows .* and cannot be assigned" "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" diff --git a/tslang/test/tester/own/own_borrow_two_lets.ts b/tslang/test/tester/own/own_borrow_two_lets.ts new file mode 100644 index 000000000..9e9636a1d --- /dev/null +++ b/tslang/test/tester/own/own_borrow_two_lets.ts @@ -0,0 +1,33 @@ +// -mm=own, phase 2: two `let`s of one `let`, both borrowing it; neither may move it. +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 twice(i: number) { + let a = new C(i); + a.v.push(i); + let b = a; + let c = a; + churn(); + return b.x + c.v.length + a.x; +} + +function main() { + let t: number = 0; + for (let i = 0; i < 100000; i++) { + t += twice(i % 10); + } + + assert(t == 1000000); + print("done."); +} diff --git a/tslang/test/tester/own/own_err_borrow_and_move.ts b/tslang/test/tester/own/own_err_borrow_and_move.ts new file mode 100644 index 000000000..77e39a356 --- /dev/null +++ b/tslang/test/tester/own/own_err_borrow_and_move.ts @@ -0,0 +1,11 @@ +// -mm=own, phase 2 rejects: `b` borrows `a`, which pins it, so the field store cannot move it. +class C { x: number = 5; } +class H { c: C | null = null; } +function main() { + const h = new H(); + let a = new C(); + let b = a; + h.c = 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 deleted file mode 100644 index 4f2e9a503..000000000 --- a/tslang/test/tester/own/own_err_let_twice.ts +++ /dev/null @@ -1,9 +0,0 @@ -// -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."); -} From 7b5dcce5219dec2b138b0c114406ead43ce73a72 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Tue, 29 Sep 2026 12:47:38 +0100 Subject: [PATCH 4/5] -mm=own phase 2: results Spec section 13: the borrow rule as built, the several-receivers rule, 1M-iteration numbers (own 4.7-4.8 MB like rc; none up to 1.1 GB), the teeth run (8/8 borrow checks fail with the borrower's releases kept), the tests that changed, and phase 3's inputs. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 80 ++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) 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 c4c873f29..378a51b49 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 @@ -2,7 +2,8 @@ Date: 2026-09-24. Status: design approved in conversation; written review 2026-09-28 (§10), 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/`. +(moves by reachability) merged as #401 (results §12); phase 2 (borrows for locals) on branch +`own-phase-2` (results §13). Plans in `docs/superpowers/plans/`. ## 1. Purpose @@ -569,3 +570,80 @@ after the move. The loop cases report `is moved inside a loop`. - 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. + +## 13. Phase 2 results, 2026-09-29 + +Plan: `docs/superpowers/plans/2026-09-29-own-phase-2.md`. Only `OwnershipInferencePass.cpp` +changed. + +### 13.1 What phase 2 accepts + +The verdicts run in §2.2's order. Phase 1's move check runs first, quietly. When it fails, a +receiver that is a `let` borrows (verdict 2). That means an owning `ts.Variable`, declared from +the value, that took its own reference (a `ts.RetainSlot`, not `__owned_consumed`) and is not +captured. Its retain and every `ts.ReleaseSlot` of its slot are erased, and the owner keeps all +of its releases. + +A borrow is sound while both of these hold: + +- **No use of the borrower can run after any end of the owner.** The ends are a local owner's + `ts.ReleaseSlot`s and assignments (`ts.Store` into its slot), or a temporary owner's + `ts.Release`s. The uses are every read of the borrower's slot and every user of what a read + returned, through casts. A path back to a use through the borrower's own declaration is a new + borrow: `for (...) { let b = a; ...; a = new C(); }` is fine. +- **Nothing the borrower holds is kept.** It must not be retained, taken by a non-borrowing use + (stored, pushed, returned, declared into another owning `let`), or assigned. + +Where an owner has **more than one receiver**, every one of them has to borrow: a borrow pins +its owner. A field store beside a borrowing `let` is `'a' is moved here while 'b' borrows it`. + +The errors follow §5. Under `--di` they name both variables, and a folded `const` owner is named +through its `ts.DebugVariable`: + +- `'b' borrows 'a' but is used here after 'a' is released or overwritten`, with a note at the end; +- `'b' borrows 'a' and cannot be stored, returned or captured`; +- `'b' borrows 'a' and cannot be assigned`. + +### 13.2 Measured + +1M iterations (100K calls), AOT, `measure.ps1`, at `-O3` and `-O1` (identical within 1.5%): + +| program | gc | rc | none | own | +| --- | --- | --- | --- | --- | +| `own_borrow_inner_let` | 6.6 | 4.8 | 1088.3 | 4.8 | +| `own_borrow_loop` | 6.4 | 4.7 | 113.4 | 4.7 | +| `own_borrow_two_lets` | 6.6 | 4.8 | 546.8 | 4.8 | + +### 13.3 Teeth + +With each borrower's releases kept (only its retain erased), 8 of 8 borrow checks fail. The +borrower's release destroys what the owner still holds. + +### 13.4 Changed tests + +These phase-1 negatives are legal now, and positives replace them: + +- `own_err_use_after_move`, `own_err_const_twice` → `own_borrow_inner_let`; +- `own_err_alias`, `own_err_let_twice` → `own_borrow_two_lets`; +- `own_err_let_loop` → `own_borrow_loop`; +- `own_err_move_some_paths` → `own_borrow_branch`. + +Other negatives: + +- `own_err_move_some_paths_field` keeps "some paths" covered (a field store cannot borrow). +- `own_err_assign_after_move` and `own_err_const_alias_move` now report the borrow's error. +- New: `own_err_borrow_escape`, `own_err_borrow_and_move`. + +### 13.5 Known limits (the input to phase 3) + +- Reads out of containers as borrows (§2.3): `const e = arr[i]`, `const c = h.c`, bounded by the + container's tenure and its dropping mutations (`pop`, `shift`, `splice`, `length =`, an + overwrite). +- Borrow chains (`let c = b` where `b` borrows) and borrowers that are assigned. +- Borrows of parameters and of `this`, which need callee facts (phase 4). +- A move after the last use of every borrower. It is rejected today, since any borrower pins + its owner. +- A temporary taken by a `let` and also consumed elsewhere (`let b = h.c = new C()`): with no + temporary release there is nothing to bound a borrow, so it is still an error. +- From §12.5: moves on some paths only, and a `const` alias of a `let` held across an assignment + of the `let` (rc has the same bug). From 13ae647e84f7979d55c1d234f670d51e6f3f4c2e Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Tue, 29 Sep 2026 14:32:43 +0100 Subject: [PATCH 5/5] -mm=own phase 2: review fixes - what a borrower's reads produce is the borrower too A borrow checked the borrower's reads and the casts of them, but not what those reads produce: a bound method (`const f = b.m`), a field reference that a for...of reads on every iteration, a catch or finally clause's non-owning local. Each of those still points into the borrowed block, and four programs compiled into reads of freed memory when the owner was overwritten. Every use of anything derived from a read that can point into the block is now a use of the borrower; the walk stops at numbers and booleans, which also ends a false escape on `if (b)` for a nullable borrower. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 20 +++++--- .../lib/TypeScript/OwnershipInferencePass.cpp | 48 +++++++++++++++---- tslang/test/tester/CMakeLists.txt | 6 ++- tslang/test/tester/own/own_borrow_nullable.ts | 36 ++++++++++++++ .../tester/own/own_err_borrow_bound_method.ts | 25 ++++++++++ .../tester/own/own_err_borrow_catch_local.ts | 30 ++++++++++++ .../tester/own/own_err_borrow_derived_ref.ts | 32 +++++++++++++ .../own/own_err_borrow_finally_local.ts | 29 +++++++++++ 8 files changed, 211 insertions(+), 15 deletions(-) create mode 100644 tslang/test/tester/own/own_borrow_nullable.ts create mode 100644 tslang/test/tester/own/own_err_borrow_bound_method.ts create mode 100644 tslang/test/tester/own/own_err_borrow_catch_local.ts create mode 100644 tslang/test/tester/own/own_err_borrow_derived_ref.ts create mode 100644 tslang/test/tester/own/own_err_borrow_finally_local.ts 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 378a51b49..2006ba8d8 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 @@ -588,9 +588,14 @@ A borrow is sound while both of these hold: - **No use of the borrower can run after any end of the owner.** The ends are a local owner's `ts.ReleaseSlot`s and assignments (`ts.Store` into its slot), or a temporary owner's - `ts.Release`s. The uses are every read of the borrower's slot and every user of what a read - returned, through casts. A path back to a use through the borrower's own declaration is a new - borrow: `for (...) { let b = a; ...; a = new C(); }` is fine. + `ts.Release`s. The uses are every read of the borrower's slot and every use of anything derived + from a read that can still point into the block: casts that keep the block, field and element + references and values loaded through them, bound methods, and a catch or finally clause's + non-owning local. The walk stops at a number or a boolean. The final review found the first + version, which followed only casts, compiling `const f = b.m; a = new C(); f()`, a `for...of` + over `b.v`, and a catch local of `b` into reads of freed memory. A path back to a use through + the borrower's own declaration is a new borrow: `for (...) { let b = a; ...; a = new C(); }` is + fine. - **Nothing the borrower holds is kept.** It must not be retained, taken by a non-borrowing use (stored, pushed, returned, declared into another owning `let`), or assigned. @@ -616,8 +621,8 @@ through its `ts.DebugVariable`: ### 13.3 Teeth -With each borrower's releases kept (only its retain erased), 8 of 8 borrow checks fail. The -borrower's release destroys what the owner still holds. +With each borrower's releases kept (only its retain erased), 10 of 10 borrow checks fail +(`own_borrow_nullable` included). The borrower's release destroys what the owner still holds. ### 13.4 Changed tests @@ -632,7 +637,10 @@ Other negatives: - `own_err_move_some_paths_field` keeps "some paths" covered (a field store cannot borrow). - `own_err_assign_after_move` and `own_err_const_alias_move` now report the borrow's error. -- New: `own_err_borrow_escape`, `own_err_borrow_and_move`. +- New: `own_err_borrow_escape`, `own_err_borrow_and_move`, and from the final review + `own_err_borrow_bound_method`, `own_err_borrow_catch_local`, `own_err_borrow_finally_local`, + `own_err_borrow_derived_ref`, with `own_borrow_nullable` (`if (b)` on a nullable borrower was + a false escape). ### 13.5 Known limits (the input to phase 3) diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index 617479bdf..942908237 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -299,23 +299,45 @@ class OwnershipInferencePass : public mlir::PassWrapper values{readOp.getResult()}; + + // The borrowed value itself - the read and the casts that keep it the same block - must + // not be kept anywhere. What it produces - a field reference, a value loaded through + // one, a bound method, a catch clause's non-owning local - still points into the + // borrowed block, so each of its uses is a use of the borrower too. A value that + // cannot point anywhere (a number, a boolean) ends the walk. + llvm::SmallVector> values{{readOp.getResult(), true}}; + llvm::SmallPtrSet seen; while (!values.empty()) { - auto current = values.pop_back_val(); + auto [current, borrowed] = values.pop_back_val(); for (auto *valueUser : current.getUsers()) { uses.push_back(valueUser); - if (auto castOp = mlir::dyn_cast(valueUser)) + if (borrowed) { - values.push_back(castOp.getResult()); - continue; + if (auto castOp = mlir::dyn_cast(valueUser)) + { + if (mayPointInto(castOp.getResult()) && seen.insert(castOp.getResult()).second) + { + values.push_back({castOp.getResult(), true}); + } + + continue; + } + + if (mlir::isa(valueUser) || !isBorrow(valueUser, current)) + { + reportBorrowEscapes(valueUser, name, owner); + return false; + } } - if (mlir::isa(valueUser) || !isBorrow(valueUser, current)) + for (auto result : valueUser->getResults()) { - reportBorrowEscapes(valueUser, name, owner); - return false; + if (mayPointInto(result) && seen.insert(result).second) + { + values.push_back({result, false}); + } } } } @@ -337,6 +359,16 @@ class OwnershipInferencePass : public mlir::PassWrapper(type) || + ownsHeap(value); + } + // The name of a temporary that owns: a folded `const` has no ts.Variable, but under --di it // has a ts.DebugVariable, whose location carries the name. static llvm::StringRef ownerName(mlir::Value value) diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 34fb4bc2b..a3e603ca3 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2404,7 +2404,7 @@ foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local o own_move_const_branch own_move_let own_move_field own_move_return own_move_loop_body own_literal_reuse own_move_control_flow own_borrow_inner_let own_borrow_loop own_borrow_branch - own_borrow_two_lets) + own_borrow_two_lets own_borrow_nullable) 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. @@ -2438,6 +2438,10 @@ set(own_error_cases "own_err_borrow_escape|borrows .* and cannot be stored, returned or captured" "own_err_move_some_paths_field|is moved here on some paths only" "own_err_borrow_and_move|is moved here while .* borrows it" + "own_err_borrow_bound_method|borrows .* but is used here after .* is released or overwritten" + "own_err_borrow_catch_local|borrows .* but is used here after .* is released or overwritten" + "own_err_borrow_finally_local|borrows .* but is used here after .* is released or overwritten" + "own_err_borrow_derived_ref|borrows .* but is used here after .* is released or overwritten" "own_err_const_alias_move|borrows .* and cannot be assigned" "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" diff --git a/tslang/test/tester/own/own_borrow_nullable.ts b/tslang/test/tester/own/own_borrow_nullable.ts new file mode 100644 index 000000000..4e3e121b6 --- /dev/null +++ b/tslang/test/tester/own/own_borrow_nullable.ts @@ -0,0 +1,36 @@ +// -mm=own, phase 2: a nullable `let` borrowing a class value, tested for null. The test reads a +// boolean out of the borrower, which keeps nothing. +class C { + v: number[] = []; + constructor(public x: number) {} + m() { return this.x; } +} + +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() { + let t: number = 0; + for (let i = 0; i < 100000; i++) { + let a = new C(i % 10); + a.v.push(3); + { + let b: C | null = a; + if (b) { + t += b.x + b.v.length; + } + } + + churn(); + t += a.x; + } + + assert(t == 1000000); + print("done."); +} diff --git a/tslang/test/tester/own/own_err_borrow_bound_method.ts b/tslang/test/tester/own/own_err_borrow_bound_method.ts new file mode 100644 index 000000000..4511a2359 --- /dev/null +++ b/tslang/test/tester/own/own_err_borrow_bound_method.ts @@ -0,0 +1,25 @@ +// -mm=own, phase 2 rejects: `f` is `b.m` bound to what `b` borrows, so calling it after `a` is +// overwritten reads the old `a`. What a borrower's reads produce is the borrower too. +class C { + v: number[] = []; + constructor(public x: number) {} + m() { return this.x; } +} + +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() { + let a = new C(1); + let b = a; + const f = b.m; + a = new C(2); + churn(); + print(f()); +} diff --git a/tslang/test/tester/own/own_err_borrow_catch_local.ts b/tslang/test/tester/own/own_err_borrow_catch_local.ts new file mode 100644 index 000000000..86969b530 --- /dev/null +++ b/tslang/test/tester/own/own_err_borrow_catch_local.ts @@ -0,0 +1,30 @@ +// -mm=own, phase 2 rejects: a catch clause's locals own nothing, so `x` is one more name for what +// `b` borrows - and it is read after `a` is overwritten. +class C { + v: number[] = []; + constructor(public x: number) {} + m() { return this.x; } +} + +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() { + let a = new C(1); + a.v.push(1); + let b = a; + try { + throw 1; + } catch (e) { + let x = b; + a = new C(2); + churn(); + print(x.x, x.v.length); + } +} diff --git a/tslang/test/tester/own/own_err_borrow_derived_ref.ts b/tslang/test/tester/own/own_err_borrow_derived_ref.ts new file mode 100644 index 000000000..1921f6f28 --- /dev/null +++ b/tslang/test/tester/own/own_err_borrow_derived_ref.ts @@ -0,0 +1,32 @@ +// -mm=own, phase 2 rejects: the loop iterates `b.v`, a field of what `b` borrows, through one +// reference into it that every iteration reads - after the body has overwritten `a`. +class C { + v: number[] = []; + constructor(public x: number) {} + m() { return this.x; } +} + +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() { + let a = new C(1); + a.v.push(1); + a.v.push(2); + a.v.push(3); + let b = a; + let t: number = 0; + for (const x of b.v) { + a = new C(2); + churn(); + t += x; + } + + print(t); +} diff --git a/tslang/test/tester/own/own_err_borrow_finally_local.ts b/tslang/test/tester/own/own_err_borrow_finally_local.ts new file mode 100644 index 000000000..c1189ea1e --- /dev/null +++ b/tslang/test/tester/own/own_err_borrow_finally_local.ts @@ -0,0 +1,29 @@ +// -mm=own, phase 2 rejects: the same through a finally clause's local. +class C { + v: number[] = []; + constructor(public x: number) {} + m() { return this.x; } +} + +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() { + let a = new C(1); + a.v.push(1); + let b = a; + try { + print(b.x); + } finally { + let x = b; + a = new C(2); + churn(); + print(x.x, x.v.length); + } +}