From 86f9a6bfe17474640233ad8e28e90b2515996a72 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Tue, 6 Oct 2026 19:50:52 +0100 Subject: [PATCH 1/2] -mm=own: a let given a new value after its value moved owns the new one (#528) `let b = a; a = new C()` and `h.c = a; a = new C()` were rejected: the assignment was a use of `a` after the move, because rc's assignment releases what the slot holds before storing - the moved value, which the receiver owns. checkSlotMove now treats an assignment the move reaches (ts.ReleaseSlot right before ts.Store of the slot) as a new value, as it treats the declaration: uses and releases reached only through one are the new value's. The assignment's own release goes with the move; the releases after it stay and destroy the new value. A release reached both holding the moved value and a newer one is "moved on some paths". Receivers of one slot that each read a value of their own (an assignment between every two of their reads) are decided one at a time, so `h.c = a; a = new C(); g.c = a` moves twice. readUsedAfter also kills at the read itself: around a loop, a path back through the read uses a value read again. own_move_reassign runs relay/field/twice/loop shapes under every model. own_err_assign_after_move (the old shape now compiles) becomes a read after an assignment on one path only; own_err_reassign_some_paths pins the new release check. own_err_borrow_{bound_method,catch_local, finally_local} used `let b = a; ... a = new C()` to make `b` borrow; that is now a move, so each reads `a` once after `let b = a` to keep `b` a borrow. Corpus under own unchanged (467/628 either way). Closes #528 Co-Authored-By: Claude Opus 5.5 --- .../lib/TypeScript/OwnershipInferencePass.cpp | 140 +++++++++++++++--- tslang/test/tester/CMakeLists.txt | 9 ++ .../tester/own/own_err_assign_after_move.ts | 10 +- .../tester/own/own_err_borrow_bound_method.ts | 4 +- .../tester/own/own_err_borrow_catch_local.ts | 4 +- .../own/own_err_borrow_finally_local.ts | 4 +- .../tester/own/own_err_reassign_some_paths.ts | 15 ++ tslang/test/tester/own/own_move_reassign.ts | 99 +++++++++++++ 8 files changed, 257 insertions(+), 28 deletions(-) create mode 100644 tslang/test/tester/own/own_err_reassign_some_paths.ts create mode 100644 tslang/test/tester/own/own_move_reassign.ts diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index c2055a990..2d03e4f93 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -1817,31 +1817,71 @@ class OwnershipInferencePass void decideSlot(mlir::Value slot, llvm::ArrayRef receivers, llvm::SetVector &toErase) { auto owner = varName(slot.getDefiningOp()); - if (receivers.size() == 1) + if (receivers.size() == 1 || takeValuesOfTheirOwn(slot, receivers)) { - auto &receiver = receivers.front(); - if (quietly([&] { return checkSlotMove(receiver.retain, receiver.value, receiver.load, toErase); })) + for (auto &receiver : receivers) { - // rc's retain goes with the move; a call that keeps the argument is the move itself - if (!isCall(receiver.retain)) - { - toErase.insert(receiver.retain); - } - - return; + decideSlotReceiver(slot, receiver, owner, toErase); } - if (auto borrower = borrowerOf(receiver.retain)) + return; + } + + decideSlotReceivers(slot, receivers, owner, toErase); + } + + void decideSlotReceiver(mlir::Value slot, const SlotReceiver &receiver, llvm::StringRef owner, + llvm::SetVector &toErase) + { + if (quietly([&] { return checkSlotMove(receiver.retain, receiver.value, receiver.load, toErase); })) + { + // rc's retain goes with the move; a call that keeps the argument is the move itself + if (!isCall(receiver.retain)) { - borrowWith(borrower, receiver.retain, slotEnds(slot), owner, toErase); - return; + toErase.insert(receiver.retain); } - checkSlotMove(receiver.retain, receiver.value, receiver.load, toErase); // reports return; } - decideSlotReceivers(slot, receivers, owner, toErase); + if (auto borrower = borrowerOf(receiver.retain)) + { + borrowWith(borrower, receiver.retain, slotEnds(slot), owner, toErase); + return; + } + + checkSlotMove(receiver.retain, receiver.value, receiver.load, toErase); // reports + } + + // Receivers of one owning local that each read a value of its own (#528): `h.c = a; a = new C(); + // g.c = a`. An assignment comes between every two of their reads on every path from one to the + // other, so each is decided alone, as the one receiver of the value it reads. + bool takeValuesOfTheirOwn(mlir::Value slot, llvm::ArrayRef receivers) + { + auto assignments = slotAssignments(slot); + if (assignments.empty()) + { + return false; + } + + llvm::SmallVector kills(assignments.begin(), assignments.end()); + if (auto varOp = slot.getDefiningOp()) + { + kills.push_back(varOp); + } + + for (auto &from : receivers) + { + for (auto &to : receivers) + { + if (&from != &to && reachableAfter(from.load, to.load, kills)) + { + return false; + } + } + } + + return true; } // A parameter this function keeps (`__own_params`): its caller moved the argument in, and this @@ -4172,8 +4212,13 @@ class OwnershipInferencePass // 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. + // move reaches - a read, an assignment's own read of it - is a use after the move. Reports and + // returns false when it is not a move. + // + // An assignment the move reaches gives the slot a new value to own (#528): a path through it + // is a new value, as a path through the declaration is. Its release before the store goes + // with the move - what the slot held is the receiver's - and the releases only a path through + // an assignment reaches stay, to destroy the new value. bool checkSlotMove(mlir::Operation *retain, mlir::Value value, mlir_ts::LoadOp load, llvm::SetVector &toErase) { @@ -4188,8 +4233,15 @@ class OwnershipInferencePass return false; } + auto assignments = slotAssignments(slot); + llvm::SmallVector kills(assignments.begin(), assignments.end()); + if (varOp) + { + kills.push_back(varOp); + } + // the move's own read, met again around a loop the slot was declared outside of - if (reachableAfter(taker, load, varOp)) + if (reachableAfter(taker, load, kills)) { reportMovedInLoop(taker, name); return false; @@ -4205,7 +4257,7 @@ class OwnershipInferencePass continue; } - if (use != load.getOperation() && reachableAfter(taker, use, varOp)) + if (use != load.getOperation() && reachableAfter(taker, use, kills)) { reportUseAfterMove(use, taker, name); return false; @@ -4225,7 +4277,7 @@ class OwnershipInferencePass llvm::SmallVector moved; for (auto *use : slot.getUsers()) { - if (!mlir::isa(use) || !reachableAfter(acquire, use, varOp)) + if (!mlir::isa(use) || !reachableAfter(acquire, use, kills)) { continue; } @@ -4236,6 +4288,23 @@ class OwnershipInferencePass return false; } + // a release that one path reaches holding the moved value and another holding a new + // one an assignment after the move stored: there is no single answer for it + for (auto *assignment : assignments) + { + llvm::SmallVector movedAgain{load.getOperation()}; + if (varOp) + { + movedAgain.push_back(varOp); + } + + if (reachableAfter(acquire, assignment, varOp) && reachableAfter(assignment, use, movedAgain)) + { + reportMovedOnSomePaths(taker, use, name); + return false; + } + } + moved.push_back(use); } @@ -4243,8 +4312,29 @@ class OwnershipInferencePass return true; } + // The stores of the assignments to an owning local: rc releases what the slot holds right + // before it stores the new value (an assignment, and a `var` declaration met again). + static llvm::SmallVector slotAssignments(mlir::Value slot) + { + llvm::SmallVector assignments; + for (auto *user : slot.getUsers()) + { + auto storeOp = mlir::dyn_cast(user); + auto releaseOp = storeOp ? mlir::dyn_cast_or_null(storeOp->getPrevNode()) + : mlir_ts::ReleaseSlotOp(); + if (releaseOp && storeOp.getReference() == slot && releaseOp.getSlot() == slot) + { + assignments.push_back(storeOp); + } + } + + return assignments; + } + // 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. + // reach, other than the move's own retain and taker. None if there is none. A path back + // through `read` itself reaches a use of a value read again: the read, met after the move, + // is the slot's own use to check (around a loop, of a value an assignment stored). mlir::Operation *readUsedAfter(mlir_ts::LoadOp read, mlir::Operation *retain, mlir::Operation *taker, mlir::Operation *kill) { @@ -4265,7 +4355,13 @@ class OwnershipInferencePass continue; } - if (reachableAfter(taker, user, kill)) + llvm::SmallVector kills{read.getOperation()}; + if (kill) + { + kills.push_back(kill); + } + + if (reachableAfter(taker, user, kills)) { return user; } diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 7b0392f13..32bde7671 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2764,6 +2764,7 @@ set_tests_properties(test-rc-interface-call-owned-result-marked # 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_move_reassign own_literal_reuse own_move_control_flow own_borrow_inner_let own_borrow_loop own_borrow_branch own_borrow_two_lets own_borrow_nullable @@ -2797,6 +2798,13 @@ foreach(global_mm rc none gc) tslang_add_test(NAME test-compile-${global_mm}-own-global-script COMMAND test-runner -mm=${global_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_global_script.ts") endforeach() +# A `let` given a new value after its value moved owns the new one (#528); the same program under +# the models that do not infer ownership. +foreach(reassign_mm rc none gc) + tslang_add_test(NAME test-jit-${reassign_mm}-own_move_reassign COMMAND test-runner -jit -mm=${reassign_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_move_reassign.ts") + tslang_add_test(NAME test-compile-${reassign_mm}-own_move_reassign COMMAND test-runner -mm=${reassign_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_move_reassign.ts") +endforeach() + # Strings as values (spec 22): no model but own makes a copy, and each program means the same # under every model. foreach(strings_mm rc none gc) @@ -2924,6 +2932,7 @@ set(own_error_cases "own_err_generator_map_outlives|borrows .* but is used here after .* is released or overwritten" "own_err_generator_place_overwritten|borrows a field but is used here after it may be released or overwritten" "own_err_two_owners|is used here after its value was moved" + "own_err_reassign_some_paths|is moved here on some paths only" "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" diff --git a/tslang/test/tester/own/own_err_assign_after_move.ts b/tslang/test/tester/own/own_err_assign_after_move.ts index 75cb12803..763942879 100644 --- a/tslang/test/tester/own/own_err_assign_after_move.ts +++ b/tslang/test/tester/own/own_err_assign_after_move.ts @@ -1,10 +1,14 @@ -// -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. +// -mm=own rejects: `a` is read after its value moved into `b`. It is given a new value on one path +// only, and on the other it still holds the moved one. (Given a new value on every path, it owns +// that one: own_move_reassign.) class C { x: number = 5; } function main() { let a = new C(); let b = a; - a = new C(); + if (b.x > 3) { + a = new C(); + } + print(a.x, b.x); 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 index 4511a2359..8a197cda2 100644 --- a/tslang/test/tester/own/own_err_borrow_bound_method.ts +++ b/tslang/test/tester/own/own_err_borrow_bound_method.ts @@ -1,5 +1,6 @@ // -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. +// overwritten reads the old `a`. What a borrower's reads produce is the borrower too. (`a` is read +// after `let b = a`, so `b` cannot take its value over and borrows it.) class C { v: number[] = []; constructor(public x: number) {} @@ -18,6 +19,7 @@ function churn() { function main() { let a = new C(1); let b = a; + print(a.x); const f = b.m; a = new C(2); churn(); diff --git a/tslang/test/tester/own/own_err_borrow_catch_local.ts b/tslang/test/tester/own/own_err_borrow_catch_local.ts index 86969b530..3d218b686 100644 --- a/tslang/test/tester/own/own_err_borrow_catch_local.ts +++ b/tslang/test/tester/own/own_err_borrow_catch_local.ts @@ -1,5 +1,6 @@ // -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. +// `b` borrows - and it is read after `a` is overwritten. (`a` is read after `let b = a`, so `b` +// cannot take its value over and borrows it.) class C { v: number[] = []; constructor(public x: number) {} @@ -19,6 +20,7 @@ function main() { let a = new C(1); a.v.push(1); let b = a; + print(a.x); try { throw 1; } catch (e) { diff --git a/tslang/test/tester/own/own_err_borrow_finally_local.ts b/tslang/test/tester/own/own_err_borrow_finally_local.ts index c1189ea1e..cf190750d 100644 --- a/tslang/test/tester/own/own_err_borrow_finally_local.ts +++ b/tslang/test/tester/own/own_err_borrow_finally_local.ts @@ -1,4 +1,5 @@ -// -mm=own, phase 2 rejects: the same through a finally clause's local. +// -mm=own, phase 2 rejects: the same through a finally clause's local. (`a` is read after +// `let b = a`, so `b` cannot take its value over and borrows it.) class C { v: number[] = []; constructor(public x: number) {} @@ -18,6 +19,7 @@ function main() { let a = new C(1); a.v.push(1); let b = a; + print(a.x); try { print(b.x); } finally { diff --git a/tslang/test/tester/own/own_err_reassign_some_paths.ts b/tslang/test/tester/own/own_err_reassign_some_paths.ts new file mode 100644 index 000000000..c2aa8fce5 --- /dev/null +++ b/tslang/test/tester/own/own_err_reassign_some_paths.ts @@ -0,0 +1,15 @@ +// -mm=own rejects: `a` is given a new value after its value moved on one path only, so its release +// at scope exit would destroy the new value on that path and the moved one on the other. +class C { x: number = 5; } +class H { c: C | null = null; } +function main() { + const h = new H(); + let a = new C(); + h.c = a; + if (h.c.x > 3) { + a = new C(); + print(a.x); + } + + print("done."); +} diff --git a/tslang/test/tester/own/own_move_reassign.ts b/tslang/test/tester/own/own_move_reassign.ts new file mode 100644 index 000000000..bc137d89c --- /dev/null +++ b/tslang/test/tester/own/own_move_reassign.ts @@ -0,0 +1,99 @@ +// -mm=own (#528): a `let` whose value moved is assigned a new value and owns that one. The +// assignment gives nothing up - what the slot held is its receiver's now - and the slot's release +// at scope exit destroys the new value once. +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 x(c: C | null) { + return c ? c.x + c.v.length : -1000; +} + +// into another `let`, then a new value +function relay(i: number) { + let a = new C(i); + a.v.push(i); + let b = a; + a = new C(i + 1); + a.v.push(i); + churn(); + return b.x + b.v.length + a.x + a.v.length; +} + +// into a field, then a new value +function store(i: number) { + const h = new H(); + let a = new C(i); + a.v.push(i); + h.c = a; + a = new C(i + 1); + a.v.push(i); + churn(); + return x(h.c) + a.x + a.v.length; +} + +// two values in turn, each moved into its own field, then a third the slot keeps +function twice(i: number) { + const h = new H(); + const g = new H(); + let a = new C(i); + a.v.push(i); + h.c = a; + a = new C(i + 1); + a.v.push(i); + g.c = a; + a = new C(i + 2); + a.v.push(i); + churn(); + return x(h.c) + x(g.c) + a.x + a.v.length; +} + +// moved and given a new value on every iteration of a loop: each one moves into the array +function loop(i: number) { + const keep: C[] = []; + let a = new C(i); + for (let k = 0; k < 3; k++) { + a.v.push(k); + keep.push(a); + a = new C(i + k + 1); + } + + churn(); + return keep[0].x + keep[2].x + keep[2].v.length + a.x; +} + +function main() { + let t1: number = 0; + let t2: number = 0; + let t3: number = 0; + let t4: number = 0; + for (let i = 0; i < 20000; i++) { + t1 += relay(i % 10); + t2 += store(i % 10); + t3 += twice(i % 10); + t4 += loop(i % 10); + } + + // relay and store: (i + 1) + (i + 2), summed over i = 0..9 two thousand times + assert(t1 == 2000 * (2 * 45 + 30)); + assert(t2 == 2000 * (2 * 45 + 30)); + // twice: (i + 1) + (i + 2) + (i + 3) + assert(t3 == 2000 * (3 * 45 + 60)); + // loop: i + (i + 2) + 1 + (i + 3) + assert(t4 == 2000 * (3 * 45 + 60)); + print("done."); +} From 2658aa02a06e3ccdff57c3ad465a91abf1c6e9ca Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Tue, 6 Oct 2026 20:04:52 +0100 Subject: [PATCH 2/2] own_move_reassign under rc and gc only; spec section 24 Under -mm=none nothing is freed and the test's loops hold about 800 MB, too much for a CI run; rc and gc show the program means the same. The spec gets section 24: an assignment after a move is a new value, the "some paths" release rule, receivers decided per value, loops. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 31 +++++++++++++++++++ tslang/test/tester/CMakeLists.txt | 4 +-- 2 files changed, 33 insertions(+), 2 deletions(-) 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 2611590f1..d44693279 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 @@ -2754,3 +2754,34 @@ errors and the same pattern passes 267 of 267, the Windows build's count. popped out of an array keeps its count (2 after the pop of a two-handle block, under rc and own alike). The re-review's `arronly` (a `const` read out of a global array, then `arr = []`) prints 2 under rc and 1 under own, as before this round. + +## 24. Assignment after a move (#528), 2026-10-06 + +Phase 1 (§12.1) counted an assignment to a moved-from `let` as a use after the move: rc's +assignment releases what the slot holds before it stores (`ts.ReleaseSlot` right before +`ts.Store`), and after a move that is the receiver's value. So `let b = a; a = new C()` and +`h.c = a; a = new C()` were errors, although Rust accepts both. Now the moved-from slot owns the +new value: + +- **An assignment is a new value.** A path from the move through an assignment of the slot + reaches a new value, as a path through the declaration does. Uses only such paths reach are + not uses after the move. The assignment's own release goes with the move, since what the slot + holds is the receiver's. A release only a path through an assignment reaches stays, and + destroys the new value. +- **Some paths.** A release that the move reaches both holding the moved value and holding a + newer one is `moved here on some paths only`. For example, `h.c = a; if (x) { a = new C(); }` + reaches the scope exit both ways (`own_err_reassign_some_paths`). A read on such a path is + still a use after the move (`own_err_assign_after_move`, now this shape). +- **Receivers per value.** Receivers of one slot that each read a value of their own are + decided one at a time: `h.c = a; a = new C(); g.c = a` moves twice. Two receivers read values + of their own when an assignment comes between their reads on every path between them. + Otherwise the receivers are decided together, as before (§13). +- **Loops.** `keep.push(a); a = new C()` in a loop body moves each iteration's value: the move's + read is met again only through the assignment. A path from the move back to a use of a read + that passes through the read again uses the value read again (`readUsedAfter`). + +`own_err_borrow_bound_method`, `own_err_borrow_catch_local` and `own_err_borrow_finally_local` +used `let b = a; … a = new C()` to make `b` a borrow, but that is a move now. Each reads `a` once +after `let b = a`, which keeps `b` a borrow. `own_move_reassign` runs the shapes under own, rc +and gc: own holds 4.4 MB, as rc's 4.5 MB. On a quarantine heap it runs clean; with the +assignment's release kept, it is a double free. Corpus: 467 of 628 compile under own, unchanged. diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 32bde7671..7a9a4d06e 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2799,8 +2799,8 @@ foreach(global_mm rc none gc) endforeach() # A `let` given a new value after its value moved owns the new one (#528); the same program under -# the models that do not infer ownership. -foreach(reassign_mm rc none gc) +# rc and gc (not none: nothing is freed there, and the loops would hold about 800 MB). +foreach(reassign_mm rc gc) tslang_add_test(NAME test-jit-${reassign_mm}-own_move_reassign COMMAND test-runner -jit -mm=${reassign_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_move_reassign.ts") tslang_add_test(NAME test-compile-${reassign_mm}-own_move_reassign COMMAND test-runner -mm=${reassign_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_move_reassign.ts") endforeach()