From 7ae47f9ecd8f4904286d9f58742c03093fbd20f0 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Fri, 2 Oct 2026 11:57:40 +0100 Subject: [PATCH 1/5] -mm=own phase 7: a generator's state object is a fresh block The maker of a generator makes its state object with `ts.New` and casts the `value_ref` to the `!ts.object` it returns. The cast is the same block, so it is now a view, and the maker's result is fresh: a generator whose locals own nothing compiles. An object literal with methods is the same shape. With that, `let raw = {...}; let i = raw` gave `raw`'s block two owners, because the object literal's interface cast is a `ts.NewInterface {__owned_result}` and `isFresh` took the mark at its word. A `ts.NewInterface` is now fresh only when the object it wraps is fresh and goes nowhere else. Plan: docs/superpowers/plans/2026-10-02-own-phase-7.md. Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-02-own-phase-7.md | 162 ++++++++++++++++++ tslang/lib/TypeScript/OwnershipFacts.h | 16 +- tslang/test/tester/CMakeLists.txt | 3 +- .../own_err_object_literal_interface_alias.ts | 16 ++ tslang/test/tester/own/own_generator.ts | 43 +++++ 5 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 tslang/docs/superpowers/plans/2026-10-02-own-phase-7.md create mode 100644 tslang/test/tester/own/own_err_object_literal_interface_alias.ts create mode 100644 tslang/test/tester/own/own_generator.ts diff --git a/tslang/docs/superpowers/plans/2026-10-02-own-phase-7.md b/tslang/docs/superpowers/plans/2026-10-02-own-phase-7.md new file mode 100644 index 000000000..b2f2d3c7c --- /dev/null +++ b/tslang/docs/superpowers/plans/2026-10-02-own-phase-7.md @@ -0,0 +1,162 @@ +# `-mm=own` Phase 7 Implementation Plan: generators and async + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development +> (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Under `-mm=own`, a generator compiles when its ownership can be proven (spec §2.7, §7 +phase 7: "the state object as owner"). Today even `function* g() { yield 1; }` is an error, and +about 40 corpus files with a generator, an iterator object or a `.map`/`.filter` (which MLIRGen +builds as a generator) stop on one. + +After this phase: + +- A generator's **state object** is an ordinary owned block: its maker makes it and returns it, + and its caller owns it like any other result. +- The generator's **locals** are fields of the state object. A store into one is a move into the + container, a read is a borrow bounded by it, and reassignment releases the old value, as for any + field. +- A **yielded value** leaves `next` in its result, which the caller owns. +- A **captured variable** of a generator (a parameter, an outer local) is a cell in a box that the + state object holds: the escaping-closure rule of phase 5b, so a cell holding a number moves in + and a parameter's object is an error (7b decides whether it can borrow). +- **Async** already works under own and gets its test. + +**Architecture:** inference and signature passes only. MLIRGen does not change +(`test-own-mlirgen-matches-rc` keeps passing), and nothing new reaches the lowering in 7a. + +**Tech Stack:** C++17, MLIR, CMake/ctest, `test/tester/test-runner.cpp`, +`test/tester/tools/measure.ps1`. + +**Spec:** `docs/superpowers/specs/2026-09-24-own-memory-model-design.md`: §2.1 (the state object is +a mortal block), §2.7, §7. Results go into §18. + +## Global Constraints + +- Branch `own-phase-7` from `origin/main` (8cfb0f17, with #437 merged). +- `gc`, `rc` and `none` output does not change. +- Never branch on the memory model in MLIRGen (§4.3). +- `__tslang_inc_ref` and `__tslang_dec_ref` never appear in own output. +- Own tests run with `--no-default-lib`, under JIT and AOT, and read through a `churn()` after the + point where a wrong release would free. Expected values are checked under `gc` first. +- Every corpus file that a change flips to "compiles" is run through test-runner `-mm=own`, AOT and + JIT, and its output compared with gc's, before the change is committed. +- Build: `cmake --build __build/tslang/windows-msbuild-2026-release --config Release --target tslang test-runner` + from `I:/TypeScriptCompiler`. Test: `ctest -j 16 -C Release` from the build directory. Build the + branch in WSL (GCC) before pushing. +- Source files are CRLF. Commits are GPG-signed, each ending with the session's `Co-Authored-By:`. + Commit before any temporary teeth switch. +- Check IR shapes with `--emit=mlir-affine --own-skip-inference`. +- `measure.ps1` reads `test/tester/compile.bat`, which only a test-runner run without `-mm` writes + (`ctest -C Release -R "^test-compile-00-print$"`). +- Baseline corpus on this tree: 325 of 591 compile under `-mm=own --no-default-lib`. + +## Shapes (measured on 8cfb0f17) + +- **The maker** `g` builds the initial state as a `ts.Constant` tuple + `{".step", ..., "next" = @.next, ".captured"?}` in a `ts.Variable`, loads it, and stores + it into `N = ts.New : !ts.value_ref`. Then it does `O = ts.Cast(N) : !ts.object`, + `ts.Retain(O)`, stores `O` into its result variable and returns it. The state's release routine + is generated from `T` and destroys every owning field and the `.captured` box (`tsrelcb_`). +- **The initial tuple** is `ts.Retain`ed before the store into `N` when `T` has an owning field (a + local of class type). The constant holds `unit` there, so the retain holds nothing. +- **`next`** takes the state as `%arg0` and keeps it in a `ts.Variable`. Each local is + `ts.PropertyRef(ts.Load(var), i)`, a store into it is a field store, and a reassignment is + `ts.ReleaseSlot` + `ts.Store`, as for any field. A `yield v` stores `v` into the `{value, done}` + result tuple, which rc `ts.Retain`s when `v` owns a block. +- **The caller** calls `next` through the state object: + `ts.CallInternal(ts.GetMethod(F), ts.GetThis(F))`, `F = ts.Load(ts.PropertyRef(S, next))`. The + signature pass does not resolve it, so the result is not fresh. +- **A captured variable** (a parameter, an outer local) is a captured `ts.Variable` cell, put in a + box (`ts.Variable {captured, __capture_box}`) and `ts.RetainCell`ed. The box is stored into the + initial tuple's `.captured` field, which goes into `N`. The frame's `ts.ReleaseCell` of the cell + follows the `ts.Retain(O)`. The box has no frame release, so it moves into the state already. +- **Async** functions lower to an `async.execute` region whose locals are ordinary locals. The + probe (a heap local across an `await`, a fresh object returned, a heap parameter read after an + `await`, a string) compiles and runs under own, AOT and JIT, with gc's output, at 5.8 MB (gc + 6.1, rc 5.7, none 334.5). +- **An object literal with methods** is the same `ts.New`/`ts.Cast` to `!ts.object` shape as + the state object. The cast of one to an interface is `ts.NewInterface {__owned_result}`. + +## Rulings made while planning + +- **The cast of a `ts.New`'d `value_ref` to its `!ts.object` is a view** (`isView`): the same block. + This is what makes the maker's result fresh. +- **`ts.NewInterface` is fresh only when the object it wraps is fresh and used nowhere else.** The + object-literal interface cast marks it `__owned_result`, and `isFresh` read that mark as a block + of its own. With the view above, `let raw = {...}; let i = raw` would give `raw`'s block two + owners (double free, found by the gate on + `00object_annotated_method_extends_interface`). A class instance's `ts.NewInterface` has no mark + and is unchanged. +- **The initial tuple's retain holds nothing.** A `ts.Retain` of a `ts.Load` of a `ts.Variable` whose + initializer is a `ts.Constant`, and whose owning fields are never stored into, is erased. +- **A call of a generator's `next` resolves to it.** A function read out of field `p` of an object of + tuple type `T` resolves to `@f` when, in this module, every `ts.Constant` of `T` holds `@f` at `p` + and nothing stores into field `p` of a `T`. The call then gets that function's facts, and its + result is fresh (`__own_fresh_result`). Cost if wrong: a fresh result that is someone else's. +- **A generator's capture box is an escaping closure's box.** `closureOfBox` learns the state + object: a box stored into `.captured` of a tuple that goes into a `ts.New`. Phase 5b's rules then + apply as they are: cells move in at the box's fill, the frame's `ts.ReleaseCell`s go, a frame + use after is an error, and a parameter's heap cell is the 5b error. +- **Phasing.** 7a: tasks 0-4. 7b, a parameter's object borrowed by the generator (its result + borrows the argument; a box that frees its cells but not their values) and the synthesized + `.map`/`.filter`, is scoped from the corpus after 7a. One PR each. + +## Review Focus + +1. The view and `ts.NewInterface`: every block still has one owner. An object literal kept in a `let` + and cast to an interface is an error, and a literal cast directly is one block. +2. `next` resolution: a field that can hold anything but the one function must stay unresolved. +3. The initial-tuple retain: erased only when nothing mortal can be in the tuple. + +--- + +### Task 0: the state object is a fresh block + +**Files:** `lib/TypeScript/OwnershipFacts.h` (`isView`, `isFresh`). + +- [ ] `ts.Cast` from `!ts.value_ref` to `!ts.object` is a view. +- [ ] `ts.NewInterface` is fresh only when its object is fresh and has no other use. +- [ ] Gate: the flipped corpus files, the own ctest subset. + +**Tests:** positive `own_generator.ts` (numbers yielded through `for...of` and by hand), negative +`own_err_object_literal_interface_alias.ts`. + +### Task 1: the initial state + +**Files:** `lib/TypeScript/OwnershipInferencePass.cpp`. + +- [ ] Erase the initial tuple's `ts.Retain` when the tuple holds nothing mortal. + +**Tests:** `own_generator.ts`: a class local, reassigned between yields. + +### Task 2: the caller's `next` + +**Files:** `lib/TypeScript/OwnershipSignaturePass.cpp` (`resolveValue`), `lib/TypeScript/OwnershipFacts.h`. + +- [ ] Resolve a function read from a field that only ever holds one function. +- [ ] Pin `__own_fresh_result` on such a call to a defined function. + +**Tests:** `own_generator.ts`: yields of fresh objects and strings, kept by the caller past a +`churn()`. Negative: a field that is assigned another function stays unresolved +(`own_err_field_function_reassigned.ts`). + +### Task 3: captured variables + +**Files:** `lib/TypeScript/OwnershipInferencePass.cpp` (`closureOfBox`, `classifyClosures`). + +- [ ] A box stored into the state's `.captured` is an escaping closure's box. + +**Tests:** `own_generator.ts`: a number parameter, an outer number local. Negatives: +`own_err_generator_param_object.ts` (the 5b error), `own_err_generator_capture_used.ts` (the frame +uses a captured local after making the generator). + +### Task 4: async, measure, teeth, corpus, spec + +- [ ] `own_async.ts` from the probe. +- [ ] `measure.ps1` for `own_generator` and `own_async` under all four models. +- [ ] Teeth: keep the frame's `ts.ReleaseCell` of a captured local after its move (the generator + reads a freed cell); let a reassigned field's function resolve (the negative compiles); + read every `ts.NewInterface {__owned_result}` as fresh (the alias negative compiles and + frees twice). +- [ ] Corpus before/after; spec §18; memory. diff --git a/tslang/lib/TypeScript/OwnershipFacts.h b/tslang/lib/TypeScript/OwnershipFacts.h index bbf2e8944..ed1d308ec 100644 --- a/tslang/lib/TypeScript/OwnershipFacts.h +++ b/tslang/lib/TypeScript/OwnershipFacts.h @@ -162,8 +162,13 @@ inline bool isView(mlir::Operation *op) auto isClosure = [](mlir::Type type) { return mlir::isa(type); }; + // a block `ts.New` made, seen as the object it is: a generator's state object + auto isMadeObject = [&]() { + return mlir::isa(castOp.getIn().getType()) && + mlir::isa(castOp.getType()); + }; return (keeps(castOp.getIn().getType()) && keeps(castOp.getType())) || - (isClosure(castOp.getIn().getType()) && isClosure(castOp.getType())); + (isClosure(castOp.getIn().getType()) && isClosure(castOp.getType())) || isMadeObject(); } // The block a value is a view of. @@ -262,6 +267,15 @@ inline bool isFresh(mlir::Value value) return true; } + // an interface over an object is that object: fresh only when the object is, and it goes + // nowhere else. The cast of an object literal to an interface marks it as arriving with a + // reference, which `let raw = {...}; raw` makes a second owner of `raw`'s block. + if (auto newInterfaceOp = mlir::dyn_cast(def)) + { + auto object = newInterfaceOp->getOperand(0); + return object.hasOneUse() && isFresh(rootOf(object)); + } + if (mlir::isa(def)) { diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 7468cfa76..6ef047af8 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2563,7 +2563,7 @@ foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local o own_field_let_borrow own_element_let_borrow own_borrow_merge_after_call own_call_no_drops own_getter_borrow own_param_kept own_any_box own_string_length_borrow - own_closure_borrow own_closure_escape) + own_closure_borrow own_closure_escape own_generator) 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. @@ -2641,6 +2641,7 @@ set(own_error_cases "own_err_cell_assigned_in_closure|borrows .* but is used here after it may be released or overwritten" "own_err_closure_assigns_captured_field|borrows .* but is used here after it may be released or overwritten" "own_err_unknown_producer|takes a second reference" + "own_err_object_literal_interface_alias|takes a second reference" "own_err_two_owners|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" diff --git a/tslang/test/tester/own/own_err_object_literal_interface_alias.ts b/tslang/test/tester/own/own_err_object_literal_interface_alias.ts new file mode 100644 index 000000000..f3ea5e077 --- /dev/null +++ b/tslang/test/tester/own/own_err_object_literal_interface_alias.ts @@ -0,0 +1,16 @@ +// -mm=own, phase 7 rejects: `i` is `raw`'s block seen through an interface, and both would own it. +interface I { + x: number; + get(): number; +} + +function main() { + let raw = { + x: 4.0, + get(): number { + return this.x; + }, + }; + let i = raw; + print(i.get()); +} diff --git a/tslang/test/tester/own/own_generator.ts b/tslang/test/tester/own/own_generator.ts new file mode 100644 index 000000000..43e642fa9 --- /dev/null +++ b/tslang/test/tester/own/own_generator.ts @@ -0,0 +1,43 @@ +// -mm=own, phase 7: a generator's state object is an ordinary owned block. Its maker makes it and +// returns it, the caller owns it, and its release destroys whatever the generator's locals hold. +// Each case runs many times, so a state object that is never freed shows in measure.ps1. + +function* evens() { + yield 2; + yield 4; +} + +// an object literal with a method: the same made block, seen as its object +function makeCounter(start: number) { + return { + n: start, + next() { + this.n++; + return this.n; + }, + }; +} + +function main() { + let t = 0; + for (let i = 0; i < 20000; i++) { + // through for...of + for (const v of evens()) { + t += v; + } + + // by hand, the generator kept in a local + const it = evens(); + let r = it.next(); + while (!r.done) { + t += r.value; + r = it.next(); + } + + const c = makeCounter(i % 10); + t += c.next() + c.next(); + } + + assert(t == 480000, "t"); + print("done."); +} From 96a755cff05d5ebbe04e52e2bd30799fb36c7bd8 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Fri, 2 Oct 2026 12:16:38 +0100 Subject: [PATCH 2/5] -mm=own phase 7: an interface is a view of its object; the initial state holds nothing The first commit's rule for `ts.NewInterface` (fresh only when its object is used nowhere else) was sound but lost twelve corpus files that compile on main, and main was not sound either: a folded object literal given twice as an interface (`use(o); use(o)`) is freed twice there. An interface over an object is now a view of it, as a union or an optional is, and a view is as fresh as its root: - `let ri = raw` borrows `raw`. rc's `ts.Retain` of the interface is the reference of the `let` that consumes it, and a borrow erases it. - A class instance behind an interface is one block, so `own_err_interface` compiles (its two instances are released once each) and is gone, with the first commit's alias negative. - Two releases of one block, one reachable after the other, are two owners: the error `own_err_interface_two_views` pins. Checking the view's root showed that it had not been checked at all: `ownsHeapMemory` says a `value_ref` owns nothing, so a `ts.New`'d block passed every check. Inside the pass such a block seen as its object owns now, and the store that fills it is a borrow, not a taker. The initial state a generator's maker copies into its state object is a tuple loaded from a local a `ts.Constant` initializes. Where nothing stores into its owning fields it holds nulls, numbers and immortal literals, and rc's retain of it is erased: a generator whose locals own blocks compiles. Corpus: 325 of 591 on main, 353 now. Two files that compiled on main are errors now, both correctly: `00object` stores one object literal into two tuples (rc leaks it), and `00interface_object_array` reads an object after it moved into an array. Every newly compiling file runs under test-runner -mm=own, AOT and JIT, with gc's output. Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-02-own-phase-7.md | 38 +++-- tslang/lib/TypeScript/OwnershipFacts.h | 26 ++-- .../lib/TypeScript/OwnershipInferencePass.cpp | 140 +++++++++++++++++- tslang/test/tester/CMakeLists.txt | 5 +- tslang/test/tester/own/own_err_interface.ts | 11 -- .../tester/own/own_err_interface_two_views.ts | 21 +++ .../own_err_object_literal_interface_alias.ts | 16 -- tslang/test/tester/own/own_generator.ts | 56 ++++++- tslang/test/tester/own/own_interface_view.ts | 88 +++++++++++ 9 files changed, 340 insertions(+), 61 deletions(-) delete mode 100644 tslang/test/tester/own/own_err_interface.ts create mode 100644 tslang/test/tester/own/own_err_interface_two_views.ts delete mode 100644 tslang/test/tester/own/own_err_object_literal_interface_alias.ts create mode 100644 tslang/test/tester/own/own_interface_view.ts diff --git a/tslang/docs/superpowers/plans/2026-10-02-own-phase-7.md b/tslang/docs/superpowers/plans/2026-10-02-own-phase-7.md index b2f2d3c7c..0b0c86062 100644 --- a/tslang/docs/superpowers/plans/2026-10-02-own-phase-7.md +++ b/tslang/docs/superpowers/plans/2026-10-02-own-phase-7.md @@ -82,12 +82,19 @@ a mortal block), §2.7, §7. Results go into §18. - **The cast of a `ts.New`'d `value_ref` to its `!ts.object` is a view** (`isView`): the same block. This is what makes the maker's result fresh. -- **`ts.NewInterface` is fresh only when the object it wraps is fresh and used nowhere else.** The - object-literal interface cast marks it `__owned_result`, and `isFresh` read that mark as a block - of its own. With the view above, `let raw = {...}; let i = raw` would give `raw`'s block two - owners (double free, found by the gate on - `00object_annotated_method_extends_interface`). A class instance's `ts.NewInterface` has no mark - and is unchanged. +- **`ts.NewInterface` is a view of the object it wraps**, and a view is exactly as fresh as its root. + The object-literal interface cast marks it `__owned_result`, and `isFresh` read that mark as a + block of its own: with the view above, `let raw = {...}; let i = raw` gave `raw`'s block two + owners (double free, found by the gate on `00object_annotated_method_extends_interface`), and + on `main` already a folded literal given twice as an interface (`use(o); use(o)`) is freed twice. + As a view, the interface is a receiver of its object: `let i = raw` borrows `raw` (rc's + `ts.Retain` of the interface is the consuming `let`'s reference, and a borrow erases it), and a + class instance behind an interface is one block (`own_err_interface` compiles). +- **A temporary is released once on each path.** Two releases of one block, one reachable after the + other, are two owners: the error at the second view. +- **A `ts.New`'d `value_ref` seen as its object owns a block** inside the pass (`ownsHeapMemory` + says no for any `value_ref`), so its root is checked, and a store through it (filling it) is a + borrow, not a taker. - **The initial tuple's retain holds nothing.** A `ts.Retain` of a `ts.Load` of a `ts.Variable` whose initializer is a `ts.Constant`, and whose owning fields are never stored into, is erased. - **A call of a generator's `next` resolves to it.** A function read out of field `p` of an object of @@ -104,8 +111,8 @@ a mortal block), §2.7, §7. Results go into §18. ## Review Focus -1. The view and `ts.NewInterface`: every block still has one owner. An object literal kept in a `let` - and cast to an interface is an error, and a literal cast directly is one block. +1. The views and `ts.NewInterface`: every block still has one owner. An interface over a `let` borrows + it, a literal cast directly is one block, and two interfaces rc counts separately are an error. 2. `next` resolution: a field that can hold anything but the one function must stay unresolved. 3. The initial-tuple retain: erased only when nothing mortal can be in the tuple. @@ -115,18 +122,21 @@ a mortal block), §2.7, §7. Results go into §18. **Files:** `lib/TypeScript/OwnershipFacts.h` (`isView`, `isFresh`). -- [ ] `ts.Cast` from `!ts.value_ref` to `!ts.object` is a view. -- [ ] `ts.NewInterface` is fresh only when its object is fresh and has no other use. -- [ ] Gate: the flipped corpus files, the own ctest subset. +- [x] `ts.Cast` from `!ts.value_ref` to `!ts.object` is a view. +- [x] `ts.NewInterface` is a view; a view is as fresh as its root; a consuming `let` of a view can + borrow. +- [x] A block released twice on one path is an error. +- [x] Gate: the flipped corpus files, the own ctest subset. -**Tests:** positive `own_generator.ts` (numbers yielded through `for...of` and by hand), negative -`own_err_object_literal_interface_alias.ts`. +**Tests:** positives `own_generator.ts` (numbers yielded through `for...of` and by hand), +`own_interface_view.ts`; negative `own_err_interface_two_views.ts`. `own_err_interface` and the +first commit's `own_err_object_literal_interface_alias` compile now and are gone. ### Task 1: the initial state **Files:** `lib/TypeScript/OwnershipInferencePass.cpp`. -- [ ] Erase the initial tuple's `ts.Retain` when the tuple holds nothing mortal. +- [x] Erase the initial tuple's `ts.Retain` when the tuple holds nothing mortal. **Tests:** `own_generator.ts`: a class local, reassigned between yields. diff --git a/tslang/lib/TypeScript/OwnershipFacts.h b/tslang/lib/TypeScript/OwnershipFacts.h index ed1d308ec..859e591ec 100644 --- a/tslang/lib/TypeScript/OwnershipFacts.h +++ b/tslang/lib/TypeScript/OwnershipFacts.h @@ -136,14 +136,14 @@ inline int resultBorrows(mlir::Operation *op) } // An op whose result is the very block its operand holds: a class widened to a union or an -// optional or narrowed back, a union made from its payload or read back from it. rc's retain -// or release of either is one of the block, so the pass looks through them. A cast that -// converts (a number printed into a string) or boxes (into `any`) makes a new block and is -// not one. +// optional or narrowed back, a union made from its payload or read back from it, an object seen +// through an interface (`{vtable, this}`, released through `this`). rc's retain or release of +// either is one of the block, so the pass looks through them. A cast that converts (a number +// printed into a string) or boxes (into `any`) makes a new block and is not one. inline bool isView(mlir::Operation *op) { if (mlir::isa_and_nonnull(op)) + mlir_ts::OptionalValueOp, mlir_ts::ValueOp, mlir_ts::NewInterfaceOp>(op)) { return true; } @@ -250,6 +250,13 @@ inline bool isFresh(mlir::Value value) return false; } + // a view is the block it shows: the object literal's interface cast is marked as arriving with + // a reference, but `let raw = {...}; raw` is `raw`'s block + if (isView(def)) + { + return isFresh(rootOf(value)); + } + if (mlir::isa(def)) { @@ -267,15 +274,6 @@ inline bool isFresh(mlir::Value value) return true; } - // an interface over an object is that object: fresh only when the object is, and it goes - // nowhere else. The cast of an object literal to an interface marks it as arriving with a - // reference, which `let raw = {...}; raw` makes a second owner of `raw`'s block. - if (auto newInterfaceOp = mlir::dyn_cast(def)) - { - auto object = newInterfaceOp->getOperand(0); - return object.hasOneUse() && isFresh(rootOf(object)); - } - if (mlir::isa(def)) { diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index 64fbe5770..ef57e745c 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -228,6 +228,12 @@ class OwnershipInferencePass : public mlir::PassWrapper(); } + else if (auto retainOp = mlir::dyn_cast(acquisition)) + { + // rc's reference for a `let` that takes the value over: `let ri = raw` retains the + // interface and declares `ri` consuming it + varOp = consumingLetOf(retainOp.getReference()); + consumed = true; + } else { varOp = mlir::dyn_cast(acquisition); @@ -1091,6 +1104,21 @@ class OwnershipInferencePass : public mlir::PassWrapper(user); + if (varOp && varOp.getInitializer() == value && varOp->hasAttr(OWNED_LOCAL_CONSUMED_ATTR_NAME)) + { + return varOp; + } + } + + return {}; + } + // 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) @@ -1171,6 +1199,18 @@ class OwnershipInferencePass : public mlir::PassWrapper &uses, llvm::SmallVectorImpl &bookkeeping) { + // a `let` that took over its value's reference: rc's retain of the value was the `let`'s + if (borrower->hasAttr(OWNED_LOCAL_CONSUMED_ATTR_NAME)) + { + for (auto *user : borrower.getInitializer().getUsers()) + { + if (mlir::isa(user)) + { + bookkeeping.push_back(user); + } + } + } + for (auto *user : borrower.getResult().getUsers()) { if (mlir::isa(user)) @@ -2078,9 +2118,63 @@ class OwnershipInferencePass : public mlir::PassWrapper() && mlir::isa(value.getType()) && + llvm::any_of(value.getUsers(), [](mlir::Operation *user) { return mlir::isa(user) && isView(user); })) + { + return true; + } + + return ownsHeap(value.getLoc(), value.getType()); + } + + bool ownsHeap(mlir::Location location, mlir::Type type) { MLIRTypeHelper mth(&getContext(), CompileOptions{}); - return mth.ownsHeapMemory(value.getLoc(), value.getType()); + return mth.ownsHeapMemory(location, type); + } + + // A tuple read out of a local that a `ts.Constant` initializes, and whose owning fields nothing + // stores into: the initial state a generator's maker copies into its state object. What it + // holds there is null, a number or an immortal literal, so rc's retain of it takes nothing. + bool holdsConstantsOnly(mlir::Value value) + { + auto loadOp = value.getDefiningOp(); + auto varOp = loadOp ? loadOp.getReference().getDefiningOp() : mlir_ts::VariableOp(); + if (!varOp || isOwningVariable(varOp) || !varOp.getInitializer() || + !varOp.getInitializer().getDefiningOp()) + { + return false; + } + + for (auto *user : varOp->getUsers()) + { + if (mlir::isa(user)) + { + continue; + } + + auto propertyRefOp = mlir::dyn_cast(user); + if (!propertyRefOp) + { + return false; + } + + // a method's field is a `!ts.bound_ref`, which holds a function, not a block + auto field = propertyRefOp.getResult(); + auto refType = mlir::dyn_cast(field.getType()); + auto stored = llvm::any_of(field.getUsers(), [&](mlir::Operation *fieldUser) { + return !mlir::isa(fieldUser); + }); + if (stored && (!refType || ownsHeap(field.getLoc(), refType.getElementType()))) + { + return false; + } + } + + return true; } // A string constant cast to `string` is the global itself, which a release skips. @@ -2159,6 +2253,13 @@ class OwnershipInferencePass : public mlir::PassWrapper(user)) + { + return storeOp.getReference() == value && storeOp.getValue() != value; + } + return false; } @@ -2222,6 +2323,14 @@ class OwnershipInferencePass : public mlir::PassWrapper releases; + forEachUse(value, [&](mlir::Operation *user, mlir::Value) { + if (mlir::isa(user)) + { + releases.push_back(user); + } + }); + + auto *definition = value.getDefiningOp(); + for (auto *first : releases) + { + for (auto *second : releases) + { + if (first != second && reachableAfter(first, second, definition)) + { + // the view the second release gives back, where rc took its reference + auto *view = second->getOperand(0).getDefiningOp(); + reportSecondReference(view ? view : second); + return false; + } + } + } + + return true; + } + // 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) { diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 6ef047af8..5cf8a46ba 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2563,7 +2563,7 @@ foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local o own_field_let_borrow own_element_let_borrow own_borrow_merge_after_call own_call_no_drops own_getter_borrow own_param_kept own_any_box own_string_length_borrow - own_closure_borrow own_closure_escape own_generator) + own_closure_borrow own_closure_escape own_generator own_interface_view) 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. @@ -2641,12 +2641,11 @@ set(own_error_cases "own_err_cell_assigned_in_closure|borrows .* but is used here after it may be released or overwritten" "own_err_closure_assigns_captured_field|borrows .* but is used here after it may be released or overwritten" "own_err_unknown_producer|takes a second reference" - "own_err_object_literal_interface_alias|takes a second reference" + "own_err_interface_two_views|takes a second reference" "own_err_two_owners|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_interface|takes a second reference" "own_err_union_moved|is used here after its value was moved" "own_err_field_read_overwritten|borrows .* but is used here after it may be released or overwritten" "own_err_element_read_overwritten|borrows .* but is used here after it may be released or overwritten" diff --git a/tslang/test/tester/own/own_err_interface.ts b/tslang/test/tester/own/own_err_interface.ts deleted file mode 100644 index 579e278f9..000000000 --- a/tslang/test/tester/own/own_err_interface.ts +++ /dev/null @@ -1,11 +0,0 @@ -// -mm=own, phase 0 rejects: boxing an instance into an interface counts as a second owner beside -// the temporary (interface and `any` boxing are phase 3). -interface I { x: number; } -class C implements I { x: number = 5; v: number[] = [1]; } -function show(i: I) { return i.x; } -function main() { - print(show(new C())); - const i: I = new C(); - print(i.x); - print("done."); -} diff --git a/tslang/test/tester/own/own_err_interface_two_views.ts b/tslang/test/tester/own/own_err_interface_two_views.ts new file mode 100644 index 000000000..b9e26892f --- /dev/null +++ b/tslang/test/tester/own/own_err_interface_two_views.ts @@ -0,0 +1,21 @@ +// -mm=own, phase 7 rejects: each call is given `o` through an interface of its own, and rc counts +// each one; with no count, both would destroy `o`'s block. +interface I { + x: number; + get(): number; +} + +function use(i: I) { + return i.get(); +} + +function main() { + const o = { + x: 4.0, + get(): number { + return this.x; + }, + }; + print(use(o)); + print(use(o)); +} diff --git a/tslang/test/tester/own/own_err_object_literal_interface_alias.ts b/tslang/test/tester/own/own_err_object_literal_interface_alias.ts deleted file mode 100644 index f3ea5e077..000000000 --- a/tslang/test/tester/own/own_err_object_literal_interface_alias.ts +++ /dev/null @@ -1,16 +0,0 @@ -// -mm=own, phase 7 rejects: `i` is `raw`'s block seen through an interface, and both would own it. -interface I { - x: number; - get(): number; -} - -function main() { - let raw = { - x: 4.0, - get(): number { - return this.x; - }, - }; - let i = raw; - print(i.get()); -} diff --git a/tslang/test/tester/own/own_generator.ts b/tslang/test/tester/own/own_generator.ts index 43e642fa9..b8aaab9d5 100644 --- a/tslang/test/tester/own/own_generator.ts +++ b/tslang/test/tester/own/own_generator.ts @@ -1,12 +1,47 @@ // -mm=own, phase 7: a generator's state object is an ordinary owned block. Its maker makes it and // returns it, the caller owns it, and its release destroys whatever the generator's locals hold. -// Each case runs many times, so a state object that is never freed shows in measure.ps1. +// Each case runs many times, so a state object that is never freed shows in measure.ps1, and +// reads through a churn() between yields, so a local freed too early reads garbage. +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* evens() { yield 2; yield 4; } +// what the generators below start from: a global, which a generator reads without capturing it +let seed = 0; + +// a local that owns a block: a field of the state object, destroyed with it +function* fromLocal() { + const k = seed; + const c = new C(k); + c.v.push(k); + yield c.x; + yield c.x + c.v.length; +} + +// a local assigned between yields: the old value is released, the state keeps the new one +function* reassigned() { + const k = seed; + let c = new C(k); + yield c.x; + c = new C(k + 1); + yield c.x; +} + // an object literal with a method: the same made block, seen as its object function makeCounter(start: number) { return { @@ -34,10 +69,27 @@ function main() { r = it.next(); } + const n = i % 10; + seed = n; + for (const v of fromLocal()) { + churn(); + t += v; + } + + for (const v of reassigned()) { + churn(); + t += v; + } + + // given up after the first value: the state object still destroys its local + const half = fromLocal(); + t += half.next().value; + const c = makeCounter(i % 10); t += c.next() + c.next(); } - assert(t == 480000, "t"); + print(t); + assert(t == 970000, "t"); print("done."); } diff --git a/tslang/test/tester/own/own_interface_view.ts b/tslang/test/tester/own/own_interface_view.ts new file mode 100644 index 000000000..afc22e958 --- /dev/null +++ b/tslang/test/tester/own/own_interface_view.ts @@ -0,0 +1,88 @@ +// -mm=own, phase 7: an object seen through an interface is that object's block (a view), so the +// interface value and the object have one owner between them. Each case reads through a churn() +// after the point where a second owner would have freed the block. +interface I { + x: number; + get(): number; +} + +class C implements I { + v: number[] = [1]; + constructor(public x: number) {} + get() { + return this.x + this.v.length; + } +} + +class Junk { + v: number[] = []; + constructor(public x: number) {} +} + +function churn() { + const junk: Junk[] = []; + for (let i = 0; i < 64; i++) { + const j = new Junk(999); + j.v.push(99); + junk.push(j); + } +} + +function show(i: I) { + churn(); + return i.get(); +} + +// an object literal made straight into an interface +function make(k: number): I { + return { + x: k, + get(): number { + return this.x; + }, + }; +} + +function main() { + let t = 0; + for (let i = 0; i < 20000; i++) { + const n = i % 10; + + // a temporary class instance given as an interface + t += show(new C(n)); + + // an interface holding a class instance + const ci: I = new C(n); + churn(); + t += ci.get(); + + // an object literal kept in a `let`, seen through an interface: the interface borrows it + let raw = { + x: n, + get(): number { + return this.x * 2; + }, + }; + let ri = raw; + churn(); + t += ri.get() + raw.x; + + // a folded object literal given once as an interface + const o = { + x: n, + get(): number { + return this.x + 1; + }, + }; + t += show(o); + t += o.x; + + const m = make(n); + churn(); + t += m.get(); + } + + print(t); + assert(t == 780000, "t"); + print("done."); +} From 8565aed6c491d4beb867af7185fe74d320f5d931 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Fri, 2 Oct 2026 13:00:39 +0100 Subject: [PATCH 3/5] -mm=own phase 7: a generator's yields move to its caller; async bodies are inferred A `yield v` stores `v` into a `{value, done}` record and returns it, and the caller calls `next` through the state object. Three pieces: - A record built to be returned is a local a `ts.Constant` initializes, read once, the read only returned. A fresh value stored into one of its fields moves in: the store is the value's taker, and rc's retain of the record, after it is filled, is the acquisition, so the temporary's release after it goes. The record's own retain goes with the initial-state rule. - The signature pass resolves a function read out of an object's field when that field only ever holds one function: every `ts.Constant` of the object's tuple type holds it there, nothing stores into the field (a method sees its object as `object_storage`, whose fields count too), and no other op makes a value of the type. A generator's `next` is such a field. The call gets `__own_fresh_result`: `next` is open (its symbol sits in a constant), so its body may not return a borrow. - The inference runs on every function, including the `func.func` the async lowering outlines a `for await` body or an `await`'s continuation into. Before, a retain there was neither erased nor reported, and `00for_await_yield` failed in the lowering with "ownership inference left a retain behind". Also two tightenings of the previous commit: a store through a value is a borrow only for a `value_ref`, and a borrow erases only the `ts.Retain` its `let` was found from. `own_generator` yields fresh objects and strings, read past a churn() by for...of and by hand: gc 6.1 MB, rc 11.2, none 1221.0, own 4.3. Found, not fixed (rc and own): a temporary made in front of a `yield` in the generator's body is never released, since OwnedReturnConsumptionPass does not release past a resume point (`yield "s" + i` leaks the number's string), and rc leaks yielded objects. Corpus: 355 of 591 (main 325). Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-02-own-phase-7.md | 9 +- .../lib/TypeScript/OwnershipInferencePass.cpp | 242 +++++++++++++++--- .../lib/TypeScript/OwnershipSignaturePass.cpp | 146 +++++++++++ tslang/test/tester/CMakeLists.txt | 1 + .../own/own_err_field_function_reassigned.ts | 20 ++ tslang/test/tester/own/own_generator.ts | 45 +++- tslang/tslang/transform.cpp | 2 + 7 files changed, 419 insertions(+), 46 deletions(-) create mode 100644 tslang/test/tester/own/own_err_field_function_reassigned.ts diff --git a/tslang/docs/superpowers/plans/2026-10-02-own-phase-7.md b/tslang/docs/superpowers/plans/2026-10-02-own-phase-7.md index 0b0c86062..29364548f 100644 --- a/tslang/docs/superpowers/plans/2026-10-02-own-phase-7.md +++ b/tslang/docs/superpowers/plans/2026-10-02-own-phase-7.md @@ -144,8 +144,13 @@ first commit's `own_err_object_literal_interface_alias` compile now and are gone **Files:** `lib/TypeScript/OwnershipSignaturePass.cpp` (`resolveValue`), `lib/TypeScript/OwnershipFacts.h`. -- [ ] Resolve a function read from a field that only ever holds one function. -- [ ] Pin `__own_fresh_result` on such a call to a defined function. +- [x] Resolve a function read from a field that only ever holds one function. +- [x] Pin `__own_fresh_result` on such a call to a defined function. +- [x] A record built to be returned (a local a `ts.Constant` initializes, read once, the read only + returned) takes fresh values into its fields: each store is the value's taker, and rc's + retain of the record is where it moves in. +- [x] The inference runs on the `func.func`s the async lowering outlines (a `for await` body, an + `await`'s continuation): a retain there was neither erased nor reported. **Tests:** `own_generator.ts`: yields of fresh objects and strings, kept by the caller past a `churn()`. Negative: a field that is assigned another function stays unresolved diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index ef57e745c..92bb7c7ba 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -2,6 +2,7 @@ #include "mlir/IR/Dominance.h" #include "mlir/Dialect/LLVMIR/LLVMAttrs.h" #include "mlir/Interfaces/ControlFlowInterfaces.h" +#include "mlir/Interfaces/FunctionInterfaces.h" #include "TypeScript/TypeScriptDialect.h" #include "TypeScript/TypeScriptOps.h" @@ -47,12 +48,29 @@ using namespace own_facts; // 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 +// +// It runs on every function: a `ts.Func`, and the `func.func` the async lowering outlines an +// `await`'s continuation or a `for await` body into, whose arguments are the values it captures. +class OwnershipInferencePass + : public mlir::PassWrapper> { public: MLIR_DEFINE_EXPLICIT_INTERNAL_INLINE_TYPE_ID(OwnershipInferencePass) - void runOnFunction() override + void runOnOperation() override + { + if (!getFunction().isExternal()) + { + runOnFunction(); + } + } + + mlir::FunctionOpInterface getFunction() + { + return getOperation(); + } + + void runOnFunction() { auto f = getFunction(); mlir::DominanceInfo dominanceInfo(f); @@ -228,7 +246,7 @@ class OwnershipInferencePass : public mlir::PassWrapper(); auto argument = loadOp ? mlir::dyn_cast(loadOp.getReference()) : mlir::BlockArgument(); - return argument && argument.getOwner()->isEntryBlock() && getFunction().getSymName().starts_with(".eh.copy."); + return argument && argument.getOwner()->isEntryBlock() && getFunction().getName().starts_with(".eh.copy."); } // The read of a parameter this function keeps, through its views. None for anything else. @@ -1047,7 +1065,7 @@ class OwnershipInferencePass : public mlir::PassWrapper ends, + llvm::StringRef owner, llvm::SetVector &toErase) + { + if (!tryBorrow(borrower, ends, owner, toErase)) + { + return false; + } + + if (mlir::isa(acquisition)) + { + toErase.insert(acquisition); + } + + return true; + } + // The owning `let` declared from `value` that takes over its reference, or none. static mlir_ts::VariableOp consumingLetOf(mlir::Value value) { @@ -1199,18 +1236,6 @@ class OwnershipInferencePass : public mlir::PassWrapper &uses, llvm::SmallVectorImpl &bookkeeping) { - // a `let` that took over its value's reference: rc's retain of the value was the `let`'s - if (borrower->hasAttr(OWNED_LOCAL_CONSUMED_ATTR_NAME)) - { - for (auto *user : borrower.getInitializer().getUsers()) - { - if (mlir::isa(user)) - { - bookkeeping.push_back(user); - } - } - } - for (auto *user : borrower.getResult().getUsers()) { if (mlir::isa(user)) @@ -2136,47 +2161,169 @@ class OwnershipInferencePass : public mlir::PassWrapper(); auto varOp = loadOp ? loadOp.getReference().getDefiningOp() : mlir_ts::VariableOp(); - if (!varOp || isOwningVariable(varOp) || !varOp.getInitializer() || - !varOp.getInitializer().getDefiningOp()) + if (!varOp || !isConstantRecordLocal(varOp)) { return false; } for (auto *user : varOp->getUsers()) { - if (mlir::isa(user)) - { - continue; - } - auto propertyRefOp = mlir::dyn_cast(user); if (!propertyRefOp) { - return false; + continue; } // a method's field is a `!ts.bound_ref`, which holds a function, not a block auto field = propertyRefOp.getResult(); auto refType = mlir::dyn_cast(field.getType()); - auto stored = llvm::any_of(field.getUsers(), [&](mlir::Operation *fieldUser) { - return !mlir::isa(fieldUser); - }); - if (stored && (!refType || ownsHeap(field.getLoc(), refType.getElementType()))) + for (auto *fieldUser : field.getUsers()) { - return false; + if (mlir::isa(fieldUser)) + { + continue; + } + + if (refType && !ownsHeap(field.getLoc(), refType.getElementType())) + { + continue; + } + + // a fresh value moved in, its move decided by its own check against this retain + auto storeOp = mlir::dyn_cast(fieldUser); + if (!storeOp || storeOp.getReference() != field || !isFresh(rootOf(storeOp.getValue())) || + recordRetainOf(varOp) == nullptr) + { + return false; + } } } return true; } + // A non-owning local a `ts.Constant` initializes, used only through its fields and its reads. + static bool isConstantRecordLocal(mlir_ts::VariableOp varOp) + { + if (isOwningVariable(varOp) || varOp.getCaptured().value_or(false) || !varOp.getInitializer() || + !varOp.getInitializer().getDefiningOp()) + { + return false; + } + + return llvm::all_of(varOp->getUsers(), [](mlir::Operation *user) { + return mlir::isa(user); + }); + } + + // rc's one `ts.Retain` of a record local's one read, where the read is only returned: stored + // into a local that owns nothing and is read only by returns (the result slot). It is the + // reference rc gives each fresh value stored into the record, so it is where they move in. + // Null for any other record. + static mlir::Operation *recordRetainOf(mlir_ts::VariableOp varOp) + { + if (!isConstantRecordLocal(varOp)) + { + return nullptr; + } + + mlir_ts::LoadOp read; + for (auto *user : varOp->getUsers()) + { + if (auto loadOp = mlir::dyn_cast(user)) + { + if (read) + { + return nullptr; + } + + read = loadOp; + } + } + + if (!read) + { + return nullptr; + } + + mlir::Operation *retain = nullptr; + for (auto *user : read->getUsers()) + { + if (mlir::isa(user)) + { + if (retain) + { + return nullptr; + } + + retain = user; + continue; + } + + if (mlir::isa(user)) + { + continue; + } + + auto storeOp = mlir::dyn_cast(user); + auto slot = storeOp && storeOp.getValue() == read.getResult() + ? storeOp.getReference().getDefiningOp() + : mlir_ts::VariableOp(); + if (!slot || !isResultSlot(slot)) + { + return nullptr; + } + } + + return retain; + } + + // A local that owns nothing, is never captured, and is read only to be returned. + static bool isResultSlot(mlir_ts::VariableOp varOp) + { + if (isOwningVariable(varOp) || varOp.getCaptured().value_or(false)) + { + return false; + } + + return llvm::all_of(varOp->getUsers(), [&](mlir::Operation *user) { + if (auto storeOp = mlir::dyn_cast(user)) + { + return storeOp.getReference() == varOp.getResult(); + } + + auto loadOp = mlir::dyn_cast(user); + return loadOp && llvm::all_of(loadOp->getUsers(), [](mlir::Operation *loadUser) { + return mlir::isa(loadUser); + }); + }); + } + + // The record whose field `taker` stores into, and rc's retain that moves the stored value in; + // null when `taker` is not such a store. + static mlir::Operation *recordRetainOfStore(mlir::Operation *taker) + { + auto storeOp = mlir::dyn_cast(taker); + auto propertyRefOp = storeOp ? storeOp.getReference().getDefiningOp() : mlir_ts::PropertyRefOp(); + auto varOp = propertyRefOp ? propertyRefOp->getOperand(0).getDefiningOp() : mlir_ts::VariableOp(); + auto *retain = varOp ? recordRetainOf(varOp) : nullptr; + if (!retain || retain->getBlock() != taker->getBlock() || !taker->isBeforeInBlock(retain)) + { + return nullptr; + } + + return retain; + } + // A string constant cast to `string` is the global itself, which a release skips. static bool isImmortalLiteral(mlir::Value value) { @@ -2253,11 +2400,11 @@ class OwnershipInferencePass : public mlir::PassWrapper(user)) { - return storeOp.getReference() == value && storeOp.getValue() != value; + return mlir::isa(value.getType()) && storeOp.getReference() == value && + storeOp.getValue() != value; } return false; @@ -2484,7 +2631,7 @@ class OwnershipInferencePass : public mlir::PassWrapper killAt; @@ -2756,6 +2903,12 @@ class OwnershipInferencePass : public mlir::PassWrapper(user) && user->getBlock() == taker->getBlock() && @@ -2781,6 +2934,11 @@ class OwnershipInferencePass : public mlir::PassWrapper(taker)) { return llvm::any_of(varOp.getResult().getUsers(), @@ -2871,7 +3029,7 @@ class OwnershipInferencePass : public mlir::PassWrappergetAttrOfType(OWN_FACTS_LOST_ATTR_NAME)) { - diag.attachNote(f.getLoc()) << "'" << f.getSymName() << "' could return a borrow or keep a parameter, but " + diag.attachNote(f.getLoc()) << "'" << f.getName() << "' could return a borrow or keep a parameter, but " << why.getValue(); } } diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index bcb01f1c1..0c65159a9 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -71,6 +71,7 @@ class OwnershipSignaturePass : public mlir::PassWrappergetParentOfType()) @@ -102,6 +103,14 @@ class OwnershipSignaturePass : public mlir::PassWrappersetAttr(OWN_FRESH_RESULT_ATTR_NAME, mlir::UnitAttr::get(&getContext())); } + + // a generator's `next`, read out of its state object: a function defined here, which + // returns its result retained like any other (its symbol sits in a constant, so it is + // open, and its body may not return a borrow) + if (call.callees.fieldFunction && call.op->getNumResults() == 1) + { + call.op->setAttr(OWN_FRESH_RESULT_ATTR_NAME, mlir::UnitAttr::get(&getContext())); + } } exportNoDrops(); @@ -238,6 +247,8 @@ class OwnershipSignaturePass : public mlir::PassWrapper funcs; @@ -259,6 +270,12 @@ class OwnershipSignaturePass : public mlir::PassWrapper importedNoDrops; + // (tuple type, field) -> the one function an object of that type holds in that field: every + // `ts.Constant` of the type's shape puts it there, nothing stores into the field, and nothing + // but a constant or a read makes a value of the type. A generator's state object holds its + // `next` so. + llvm::DenseMap, mlir::StringAttr> fieldFunctions; + // Class names, from their vtables, to split a method's symbol into class and method name. llvm::StringSet<> classNames; // (vtable position, method name) -> the functions at that position under that name, in every @@ -325,6 +342,129 @@ class OwnershipSignaturePass : public mlir::PassWrapper>`, whose + // fields are the tuple's. + mlir_ts::TupleType storageTupleOf(mlir::Type type) + { + if (auto objectType = mlir::dyn_cast(type)) + { + type = objectType.getStorageType(); + if (auto storageType = mlir::dyn_cast(type)) + { + return mlir_ts::TupleType::get(&getContext(), storageType.getFields()); + } + } + else if (auto refType = mlir::dyn_cast(type)) + { + type = refType.getElementType(); + } + else if (auto valueRefType = mlir::dyn_cast(type)) + { + type = valueRefType.getElementType(); + } + + return mlir::dyn_cast(type); + } + + void collectFieldFunctions(mlir::ModuleOp module) + { + auto *context = &getContext(); + llvm::DenseSet> unknownFields; + llvm::DenseSet unknownTuples; + module.walk([&](mlir::Operation *op) { + if (auto constantOp = mlir::dyn_cast(op)) + { + auto constTupleType = mlir::dyn_cast(constantOp.getType()); + auto values = mlir::dyn_cast(constantOp.getValue()); + if (!constTupleType || !values) + { + return; + } + + mlir::Type tupleType = mlir_ts::TupleType::get(context, constTupleType.getFields()); + for (auto [index, value] : llvm::enumerate(values)) + { + auto symbol = mlir::dyn_cast(value); + if (!symbol) + { + continue; + } + + std::pair key{tupleType, static_cast(index)}; + auto [found, inserted] = fieldFunctions.try_emplace(key, symbol.getAttr()); + if (!inserted && found->second != symbol.getAttr()) + { + unknownFields.insert(key); + } + } + + return; + } + + // a field reference that is not only read: the field may be given anything + if (auto propertyRefOp = mlir::dyn_cast(op)) + { + auto tupleType = storageTupleOf(propertyRefOp->getOperand(0).getType()); + auto onlyRead = llvm::all_of(propertyRefOp->getUsers(), + [](mlir::Operation *user) { return mlir::isa(user); }); + if (tupleType && !onlyRead) + { + unknownFields.insert({tupleType, propertyRefOp.getPosition()}); + } + + return; + } + + // a value of the type made any other way may hold anything in any field + if (mlir::isa(op)) + { + return; + } + + for (auto result : op->getResults()) + { + if (auto tupleType = mlir::dyn_cast(result.getType())) + { + unknownTuples.insert(tupleType); + } + } + }); + + for (auto &key : unknownFields) + { + fieldFunctions.erase(key); + } + + llvm::SmallVector> dropped; + for (auto &entry : fieldFunctions) + { + if (unknownTuples.contains(entry.first.first)) + { + dropped.push_back(entry.first); + } + } + + for (auto &key : dropped) + { + fieldFunctions.erase(key); + } + } + + // `ts.Load(ts.PropertyRef(object, p))` of a field that only ever holds one function. + mlir::StringAttr fieldFunctionOf(mlir::Operation *def) + { + auto loadOp = mlir::dyn_cast_or_null(def); + auto propertyRefOp = loadOp ? loadOp.getReference().getDefiningOp() : mlir_ts::PropertyRefOp(); + auto tupleType = propertyRefOp ? storageTupleOf(propertyRefOp->getOperand(0).getType()) : mlir_ts::TupleType(); + if (!tupleType) + { + return {}; + } + + return fieldFunctions.lookup({tupleType, propertyRefOp.getPosition()}); + } + // The functions a class vtable holds, by position; empty for any other global. static llvm::SmallVector> classVTableEntries(mlir_ts::GlobalOp globalOp) { @@ -533,6 +673,12 @@ class OwnershipSignaturePass : public mlir::PassWrapper().addPass(mlir::typescript::createOwnershipInferencePass()); + // an `await`'s continuation and a `for await` body, outlined by the async lowering + pm.nest().addPass(mlir::typescript::createOwnershipInferencePass()); } #ifdef ENABLE_OPT_PASSES From 6237ae86d62a8790994923aeb3da2cc279e5094a Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Fri, 2 Oct 2026 13:01:31 +0100 Subject: [PATCH 4/5] -mm=own phase 7: what a generator captures escapes with its state object; spec section 18 A generator's maker puts the cells of its parameters into a capture box and the box into the state object's `.captured` field. There is no closure value, but the box is an escaping closure's: the store into the state is its escape, and phase 5b's rules apply as they are. A cell holding a number moves in (`range(from, to)`); a parameter's object is the 5b error "its value is the caller's" (`own_err_generator_param_object`). rc's `ts.RetainCell` of the box is the state object's reference and moves in with it. An object literal whose methods capture keeps its box the same way. Tests: `own_generator` gains number parameters, and `own_record_return` (a plain function returning a record that holds a fresh object) and `own_async` (from the phase's probe, plus a `for await` body) are new. The no-counting check fails on `error:` now: `error` alone matched the async runtime's message "Awaited async operand is in error state". Teeth, each a temporary getenv switch: keeping the frame's ReleaseCell after a move into a generator's box, or a value's temporary release after it moves into a returned record, fails under AOT (0xC0000374) and JIT; skipping the released-twice check, or resolving a reassigned field, makes the matching negative compile. Corpus: 325 of 591 on main, 359 now. Spec section 18 has the results, the found-not-fixed list, and the input to 7b. Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-02-own-phase-7.md | 17 +- .../2026-09-24-own-memory-model-design.md | 140 ++++++++++++++++- .../lib/TypeScript/OwnershipInferencePass.cpp | 147 +++++++++++++++++- tslang/test/tester/CMakeLists.txt | 6 +- tslang/test/tester/own/own_async.ts | 81 ++++++++++ .../own/own_err_generator_param_object.ts | 16 ++ tslang/test/tester/own/own_generator.ts | 14 +- tslang/test/tester/own/own_record_return.ts | 44 ++++++ 8 files changed, 448 insertions(+), 17 deletions(-) create mode 100644 tslang/test/tester/own/own_async.ts create mode 100644 tslang/test/tester/own/own_err_generator_param_object.ts create mode 100644 tslang/test/tester/own/own_record_return.ts diff --git a/tslang/docs/superpowers/plans/2026-10-02-own-phase-7.md b/tslang/docs/superpowers/plans/2026-10-02-own-phase-7.md index 29364548f..cad8e6857 100644 --- a/tslang/docs/superpowers/plans/2026-10-02-own-phase-7.md +++ b/tslang/docs/superpowers/plans/2026-10-02-own-phase-7.md @@ -160,18 +160,19 @@ first commit's `own_err_object_literal_interface_alias` compile now and are gone **Files:** `lib/TypeScript/OwnershipInferencePass.cpp` (`closureOfBox`, `classifyClosures`). -- [ ] A box stored into the state's `.captured` is an escaping closure's box. +- [x] A box stored into the state's `.captured` is an escaping closure's box. -**Tests:** `own_generator.ts`: a number parameter, an outer number local. Negatives: -`own_err_generator_param_object.ts` (the 5b error), `own_err_generator_capture_used.ts` (the frame -uses a captured local after making the generator). +**Tests:** `own_generator.ts`: number parameters. Negative: `own_err_generator_param_object.ts` +(the 5b error). A frame use after the capture cannot be written: the maker's body is the +generator's own, which only builds the state and returns it. An outer local captured by a nested +generator is an inherited cell, 5b's error. ### Task 4: async, measure, teeth, corpus, spec -- [ ] `own_async.ts` from the probe. -- [ ] `measure.ps1` for `own_generator` and `own_async` under all four models. -- [ ] Teeth: keep the frame's `ts.ReleaseCell` of a captured local after its move (the generator +- [x] `own_async.ts` from the probe, and `own_record_return.ts`. +- [x] `measure.ps1` for the positives under all four models. +- [x] Teeth: keep the frame's `ts.ReleaseCell` of a captured local after its move (the generator reads a freed cell); let a reassigned field's function resolve (the negative compiles); read every `ts.NewInterface {__owned_result}` as fresh (the alias negative compiles and frees twice). -- [ ] Corpus before/after; spec §18; memory. +- [x] Corpus before/after; spec §18; memory. 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 e9f6b31bb..4947a5c95 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 @@ -5,8 +5,8 @@ amendments folded in. Phase 0 merged as #399 (results §11); phase 1 (moves by reachability) merged as #401 (results §12); phase 2 (borrows for locals) merged as #402 (results §13); phase 3 (containers and unions) merged as #403 (results §14); phase 4 (function signatures and `any`) merged as #406-#410 (results §15); phase 5a (closures that do not escape) -merged as #436 (results §16); phase 5b (closures that escape) on branch `own-phase-5b` (results -§17). Plans in `docs/superpowers/plans/`. +merged as #436 (results §16); phase 5b (closures that escape) merged as #437 (results §17); phase +7a (generators, async) on branch `own-phase-7` (results §18). Plans in `docs/superpowers/plans/`. ## 1. Purpose @@ -1180,3 +1180,139 @@ integer literal is `s32`. - A closure that escapes from a closure body with a cell it inherited, and a closure over a parameter's object, are errors. `this` captured by an escaping callback is the common case. - From §16.5: a non-escaping closure always borrows; a captured local is never `let`-borrowed. + +## 18. Phase 7a results, 2026-10-02 + +Plan: `docs/superpowers/plans/2026-10-02-own-phase-7.md`. Changed: `OwnershipFacts.h`, +`OwnershipInferencePass.cpp`, `OwnershipSignaturePass.cpp`, and `tslang/transform.cpp` (the +inference runs on `func.func` too). MLIRGen does not change. + +### 18.1 What phase 7a accepts + +A generator's maker builds its initial state as a `ts.Constant` tuple in a local, copies it into a +block `ts.New` makes, and returns that block cast to `!ts.object`. Its `next` keeps the +generator's locals in the state object's fields and returns a `{value, done}` record. The caller +calls `next` through the state object. + +- **The state object is a fresh block.** The cast of a `ts.New`'d `value_ref` to its `!ts.object` + is a view. Inside the pass such a block owns (`ownsHeapMemory` says no for any `value_ref`), so + its root is checked, and the store that fills it is a borrow, not a taker. +- **The initial state holds nothing.** rc's retain of the tuple is erased when its owning fields + hold what the constant put there: null, a number, an immortal literal. +- **Locals are fields.** A store into one is a move into the container, a read a borrow bounded + by it, and reassignment releases the old value (rc's `ts.ReleaseSlot` of the field). Nothing new + was needed. +- **A yield moves its value to the caller.** A record built to be returned (a local a + `ts.Constant` initializes, read once, the read only returned) takes the fresh values stored into + its fields: each store is the value's taker, and rc's retain of the filled record is where the + value moves in. A plain function returning `{ value: new C(), done: false }` is the same shape. +- **The caller's `next` resolves.** The signature pass resolves a function read out of an + object's field when the field only ever holds that function: every `ts.Constant` of the + object's tuple type holds it there, nothing stores into the field (a method sees its object as + `object_storage`, whose fields count too), and no other op makes a value of the type. The call + gets `__own_fresh_result`. `next` is open (its symbol sits in a constant), so its body may not + return a borrow. +- **What a generator captures is an escaping closure's box.** The maker puts the cells of its + parameters into a box and the box into the state's `.captured` field. That store is the box's + escape, and phase 5b's rules apply as they are. A cell holding a number moves in + (`range(from, to)`), and a parameter's object is 5b's "its value is the caller's" error. An + object literal whose methods capture keeps its box the same way. +- **Async.** The async lowering outlines a `for await` body and each `await`'s continuation into a + `func.func`, which the inference did not visit. A retain there was neither erased nor reported, + and the lowering failed with "ownership inference left a retain behind" (`00for_await_yield`). + The inference now runs on every function. An outlined body's arguments are the values it + captures: a retain of one is an error. + +### 18.2 An interface over an object is that object + +The view above showed that `ts.NewInterface` gave a block a second owner. The object-literal +interface cast marks it `__owned_result`, and `isFresh` took the mark at its word. +`let raw = {...}; let i = raw` freed `raw`'s block twice. On `main` already, a folded object +literal given twice as an interface (`use(o); use(o)`) was freed twice, and 12 corpus files that +compiled there have that shape somewhere. + +`ts.NewInterface` is now a view of its object, as a union or an optional is, and a view is as fresh +as its root: + +- `let i = raw` borrows `raw`. rc's `ts.Retain` of the interface is the reference of the `let` + that consumes it, and a borrow erases it. +- A class instance behind an interface is one block, so the phase-0 placeholder + `own_err_interface` compiles and is gone. +- Two releases of one block, one reachable after the other, are two owners: the error at the + second view (`own_err_interface_two_views`). + +### 18.3 Measured + +AOT, `measure.ps1`, in MB: + +| test | gc | rc | none | own | +|---|---|---|---|---| +| `own_generator` | 6.1 | 11.2 | 1548.6 | 4.3 | +| `own_record_return` | 5.9 | 4.2 | 224.0 | 4.2 | +| `own_interface_view` | 5.9 | 4.3 | 551.1 | 4.3 | +| `own_async` | 6.1 | 5.7 | 334.7 | 5.8 | + +rc is above gc on `own_generator` because it leaks yielded objects (§18.5). + +### 18.4 Teeth + +Each made with a temporary `getenv` switch: + +- Keep the frame's `ts.ReleaseCell` of a cell after it moves into a generator's box: + `own_generator` fails, AOT (0xC0000374) and JIT. +- Keep a value's temporary release after it moves into a returned record: `own_record_return` + fails, AOT (0xC0000374) and JIT. In a generator's `next` there is no such release to keep + (§18.5). +- Skip the released-twice check: `own_err_interface_two_views` compiles and crashes. +- Let a field that is assigned another function resolve: `own_err_field_function_reassigned` + compiles. + +The JIT's object cache (`__jit` beside the source) keys on the compiler binary and the options, not +on the environment. Every JIT run with the teeth build after the first reused the first one's +object, so the cache was cleared between them. + +### 18.5 Tests and the corpus + +- **Positives:** + - `own_generator`: numbers, a class local, a local reassigned between yields, a generator given + up after one value, yields of fresh objects and strings through `for...of` and by hand, number + parameters, an object literal with a method; + - `own_record_return`; + - `own_interface_view`: a class instance as an interface, a `let` borrowed through one, a + literal given once, a literal made straight into one; + - `own_async`: a heap local across an `await`, a fresh result, a heap parameter after an + `await`, a string, a `for await` body. +- **Negatives:** `own_err_interface_two_views`, `own_err_field_function_reassigned`, + `own_err_generator_param_object`. +- The no-counting check's failure pattern is `error:` now: `error` alone matched the async + runtime's own message, "Awaited async operand is in error state". + +Corpus: 325 of 591 before, 359 after. Every file that newly compiles runs under test-runner +`-mm=own`, AOT and JIT, with gc's output, and so does every `-shared` pair whose two halves both +compile. Two files that compiled on `main` are errors now, both correctly: + +- `00object` stores one object literal into two tuples, which rc leaks. +- `00interface_object_array` reads an object after it moved into an array. + +Found, not fixed: + +- **A temporary made in front of a `yield`** in the generator's own body is never released, under + rc as well. `OwnedReturnConsumptionPass` does not release past a resume point, so + `yield "s" + i` leaks the number's string (50 MB over 200000 runs). +- **rc leaks yielded objects.** The call's reference and the record's retain are two, and the + caller releases one. +- **Reading a record out of a call or a folded `const`** (`print(f().value.x)`, + `const r = f(); r.value.x`) is an error. A `let` works. +- **A method of an object literal held in a `let`**, returning a block, called and kept, is a + borrow error: `let o = {make(): C {...}}; const c = o.make()`. A `const o` works. +- **`measure.ps1` reads `compile.bat`**, which only a test-runner run without `-mm` writes. + +### 18.6 Known limits (the input to 7b) + +- A generator over a parameter's object needs its result to borrow the argument, and a box that + frees its cells but not their values. +- A nested generator over an outer local inherits the cell: 5b's "captured from an enclosing + function" error. +- `.map`/`.filter`, which MLIRGen builds as generators over the source array and the callback, stop + on "borrows a field and cannot be stored". +- A yielded borrow (a parameter's element, a state field's value) is an error. diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index 92bb7c7ba..8aec51520 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -88,12 +88,14 @@ class OwnershipInferencePass derivedCache.clear(); closures.clear(); closureOf.clear(); + stateBoxes.clear(); captureStores.clear(); captureRetains.clear(); returnsBorrowOf = resultBorrows(f); // first: whether a closure borrows what it captures changes what its box's stores are classifyClosures(); + classifyStateBoxes(); // A view of a block is that block: the candidate is always the root. f.walk([&](mlir::Operation *op) { @@ -375,6 +377,8 @@ class OwnershipInferencePass llvm::DenseSet captureRetains; // each closure over a cell, by its `ts.CreateBoundFunction` llvm::DenseMap closureOf; + // each generator's capture box, which escapes with its state object (classifyStateBoxes) + llvm::DenseMap stateBoxes; // Finds the closures over cells and what they borrow, before anything else: whether a box's // stores are reads or takers depends on it. A closure with copies only keeps what phase 4 does @@ -475,6 +479,119 @@ class OwnershipInferencePass }); } + // A generator's maker puts what the generator captures - its parameters, an outer local - into + // a box, and the box into its state object's `.captured` field: the box of a closure that + // escapes, with the state object, at that store. There is no closure value, so no use runs it + // here and nothing calls through it. rc's `ts.RetainCell` of the box is the state object's + // reference, which moves in with it. A box used any other way is left to the RetainCell error. + void classifyStateBoxes() + { + getFunction().walk([&](mlir_ts::VariableOp boxOp) { + auto box = boxOp.getResult(); + if (!boxOp->hasAttr(CAPTURE_BOX_ATTR_NAME) || closureOfBox(box)) + { + return; + } + + auto closure = std::make_shared(); + closure->box = box; + llvm::SmallVector boxRetains; + for (auto *user : box.getUsers()) + { + if (auto propertyRefOp = mlir::dyn_cast(user)) + { + for (auto *fieldUser : propertyRefOp->getUsers()) + { + auto storeOp = mlir::dyn_cast(fieldUser); + if (!storeOp || storeOp.getReference() != propertyRefOp.getResult()) + { + return; + } + + closure->fills.push_back({propertyRefOp.getPosition(), storeOp}); + closure->hasCells = closure->hasCells || mlir::isa(storeOp.getValue().getType()); + } + + continue; + } + + if (mlir::isa(user)) + { + boxRetains.push_back(user); + continue; + } + + if (!closure->escape && isStoreIntoState(user, box)) + { + closure->escape = user; + continue; + } + + return; + } + + if (!closure->escape) + { + return; + } + + for (auto &fill : closure->fills) + { + if (auto *retain = retainBefore(fill.second.getValue(), fill.second)) + { + closure->retains.push_back(retain); + captureRetains.insert(retain); + } + } + + for (auto *retain : boxRetains) + { + closure->retains.push_back(retain); + captureRetains.insert(retain); + } + + stateBoxes[box] = closure.get(); + closures.push_back(std::move(closure)); + }); + } + + // `ts.Store(box, ts.PropertyRef(state, i))`, where `state` is a local a `ts.Constant` + // initializes whose every read fills a block `ts.New` made: a generator's initial state. + static bool isStoreIntoState(mlir::Operation *user, mlir::Value box) + { + auto storeOp = mlir::dyn_cast(user); + auto propertyRefOp = storeOp && storeOp.getValue() == box + ? storeOp.getReference().getDefiningOp() + : mlir_ts::PropertyRefOp(); + auto stateOp = propertyRefOp ? propertyRefOp->getOperand(0).getDefiningOp() : mlir_ts::VariableOp(); + if (!stateOp || !isConstantRecordLocal(stateOp)) + { + return false; + } + + auto reads = 0; + for (auto *stateUser : stateOp->getUsers()) + { + auto loadOp = mlir::dyn_cast(stateUser); + if (!loadOp) + { + continue; + } + + ++reads; + for (auto *readUser : loadOp->getUsers()) + { + auto fillOp = mlir::dyn_cast(readUser); + if (!fillOp || fillOp.getValue() != loadOp.getResult() || !fillOp.getReference().getDefiningOp()) + { + return false; + } + } + } + + return reads > 0; + } + // rc's reference for the box's copy of `value`: the retain of it nearest before the store that // fills the box, in the same block. mlir::Operation *retainBefore(mlir::Value value, mlir::Operation *store) @@ -637,6 +754,12 @@ class OwnershipInferencePass { for (auto &closure : closures) { + if (!closure->op) + { + decideEscaping(*closure, toErase); // a generator's box + continue; + } + auto *boundOp = closure->op.getOperation(); auto name = ownerName(closure->op.getResult()); auto ok = true; @@ -772,10 +895,19 @@ class OwnershipInferencePass { auto storeOp = mlir::dyn_cast(op); auto propertyRefOp = storeOp ? storeOp.getReference().getDefiningOp() : mlir_ts::PropertyRefOp(); - auto boundOp = propertyRefOp ? closureOfBox(propertyRefOp.getObjectRef()) : mlir_ts::CreateBoundFunctionOp(); + if (!propertyRefOp || !mlir::isa(storeOp.getValue().getType())) + { + return false; + } + + if (stateBoxes.count(propertyRefOp.getObjectRef())) + { + return true; + } + + auto boundOp = closureOfBox(propertyRefOp.getObjectRef()); auto found = boundOp ? closureOf.find(boundOp.getOperation()) : closureOf.end(); - return found != closureOf.end() && found->second->escape && - mlir::isa(storeOp.getValue().getType()); + return found != closureOf.end() && found->second->escape; } // A closure that escapes owns what its box holds (spec 2.5, phase 5b). Its copies moved in as @@ -859,7 +991,14 @@ class OwnershipInferencePass // A call through the box itself after the closure was given away or given back. followClosure // stops at the escape, so `ends` may miss releases past it; that is enough while the only - // calls through a box are those of a folded `const f`, whose release ends its block. + // calls through a box are those of a folded `const f`, whose release ends its block. A + // generator's box has no closure value and no such call. + if (!closure.op) + { + toErase.insert(closure.retains.begin(), closure.retains.end()); + return; + } + auto name = ownerName(closure.op.getResult()); llvm::SmallVector gone(closure.ends.begin(), closure.ends.end()); gone.push_back(closure.escape); diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 785096116..1bebfe032 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2563,7 +2563,8 @@ foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local o own_field_let_borrow own_element_let_borrow own_borrow_merge_after_call own_call_no_drops own_getter_borrow own_param_kept own_any_box own_string_length_borrow - own_closure_borrow own_closure_escape own_generator own_interface_view) + own_closure_borrow own_closure_escape own_generator own_interface_view + own_async own_record_return) 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. @@ -2572,7 +2573,7 @@ foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local o "${PROJECT_SOURCE_DIR}/test/tester/own/${own_test}.ts" -o -) set_tests_properties(test-own-no-counting-${own_test} PROPERTIES PASS_REGULAR_EXPRESSION "define" - FAIL_REGULAR_EXPRESSION "__tslang_inc_ref|__tslang_dec_ref|error|Stack dump") + FAIL_REGULAR_EXPRESSION "__tslang_inc_ref|__tslang_dec_ref|error:|Stack dump") endforeach() # Across a library boundary: the library exports its functions' `__own_no_drops`, and a borrow held @@ -2643,6 +2644,7 @@ set(own_error_cases "own_err_unknown_producer|takes a second reference" "own_err_interface_two_views|takes a second reference" "own_err_field_function_reassigned|borrows .* but is used here after it may be released or overwritten" + "own_err_generator_param_object|cannot move this variable into it: its value is the caller's" "own_err_two_owners|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" diff --git a/tslang/test/tester/own/own_async.ts b/tslang/test/tester/own/own_async.ts new file mode 100644 index 000000000..f9fe7b341 --- /dev/null +++ b/tslang/test/tester/own/own_async.ts @@ -0,0 +1,81 @@ +// -mm=own, phase 7: async functions. Their locals are ordinary locals of the function the async +// lowering outlines each body and continuation into, and the inference runs on those too. Each +// case reads through a churn() after an await, where a wrong release would have freed. +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); + } +} + +async function two() { return 2; } + +// a heap local alive across an await +async function local(k: number) { + const c = new C(k); + c.v.push(k); + const v = await two(); + churn(); + return c.x + c.v.length + v; +} + +// returns a fresh object +async function make(k: number) { + const c = new C(k); + c.v.push(1); + return c; +} + +// a heap parameter read after an await +async function param(c: C) { + const v = await two(); + churn(); + return c.x + v; +} + +async function str(s: string) { + return s + "!"; +} + +// a `for await` body is outlined on its own; a string made there is its temporary +async function* numbers() { + yield 1; + await two(); + yield 2; +} + +async function loop() { + let t = 0; + for await (const x of numbers()) { + const s = "n" + x; + t += s.length; + } + + return t; +} + +async function main() { + let t = 0; + for (let i = 0; i < 20000; i++) { + const n = i % 10; + t += await local(n); + const m = await make(n); + churn(); + t += m.x + m.v.length; + const p = new C(n); + t += await param(p); + const s = await str("ab"); + t += s.length; + } + + t += await loop(); + assert(t == 450004, "t"); + print("done."); +} diff --git a/tslang/test/tester/own/own_err_generator_param_object.ts b/tslang/test/tester/own/own_err_generator_param_object.ts new file mode 100644 index 000000000..59a96d84f --- /dev/null +++ b/tslang/test/tester/own/own_err_generator_param_object.ts @@ -0,0 +1,16 @@ +// -mm=own, phase 7 rejects: the generator's state object takes `c`'s cell with it, but `c` is the +// caller's, so the state object cannot own it. +class C { + constructor(public x: number) {} +} + +function* g(c: C) { + yield c.x; +} + +function main() { + const c = new C(1); + for (const v of g(c)) { + print(v); + } +} diff --git a/tslang/test/tester/own/own_generator.ts b/tslang/test/tester/own/own_generator.ts index 88f2b4543..e7eb61807 100644 --- a/tslang/test/tester/own/own_generator.ts +++ b/tslang/test/tester/own/own_generator.ts @@ -62,6 +62,13 @@ function* strings() { } } +// parameters holding numbers: their cells move into the box the state object owns +function* range(from: number, to: number) { + for (let i = from; i < to; i++) { + yield i; + } +} + // an object literal with a method: the same made block, seen as its object function makeCounter(start: number) { return { @@ -123,6 +130,11 @@ function main() { churn(); t += o.value.x; + for (const v of range(n, n + 3)) { + churn(); + t += v; + } + // given up after the first value: the state object still destroys its local const half = fromLocal(); t += half.next().value; @@ -131,6 +143,6 @@ function main() { t += c.next() + c.next(); } - assert(t == 1490000, "t"); + assert(t == 1820000, "t"); print("done."); } diff --git a/tslang/test/tester/own/own_record_return.ts b/tslang/test/tester/own/own_record_return.ts new file mode 100644 index 000000000..4bf286f4e --- /dev/null +++ b/tslang/test/tester/own/own_record_return.ts @@ -0,0 +1,44 @@ +// -mm=own, phase 7: a record built to be returned - here the shape a generator's `next` returns - +// takes the fresh values stored into its fields, and the caller owns the record. Each case reads +// through a churn() after the call. +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 make(k: number) { + const c = new C(k); + c.v.push(k); + return { value: c, done: false }; +} + +function fresh(k: number) { + return { value: new C(k), done: k > 5 }; +} + +function main() { + let t = 0; + for (let i = 0; i < 20000; i++) { + const n = i % 10; + let r = make(n); + churn(); + t += r.value.x + r.value.v.length; + + // assigned again: the first record is released + r = fresh(n); + churn(); + t += r.value.x + (r.done ? 1 : 0); + } + + assert(t == 208000, "t"); + print("done."); +} From c87e507349dd843e5f2f90067cd7008922633aad Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Fri, 2 Oct 2026 13:10:30 +0100 Subject: [PATCH 5/5] -mm=own phase 7: calls in an outlined async body get their facts The signature pass collected only the calls inside a `ts.Func`. A call in the `func.func` the async lowering outlines a `for await` body into got no facts, while findOpen counted it as a resolved call. So a callee that keeps its argument (`stash(c)` pushing into a global) was closed and moved the argument in its own body, while the body that called it kept its release: a use after free, on main as well. Calls, and callee-only references, are collected in any function now. Verified in the IR: the release after `stash(c)` is gone. A run cannot show it, under any model: `await f()` returns before `f`'s `for await` body has run (spec 18.5). Spec section 18: that, and the wording of 18.2 and 18.5. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 19 ++++++++++++++----- .../lib/TypeScript/OwnershipSignaturePass.cpp | 6 ++++-- 2 files changed, 18 insertions(+), 7 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 4947a5c95..dd01a288c 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 @@ -1221,15 +1221,20 @@ calls `next` through the state object. `func.func`, which the inference did not visit. A retain there was neither erased nor reported, and the lowering failed with "ownership inference left a retain behind" (`00for_await_yield`). The inference now runs on every function. An outlined body's arguments are the values it - captures: a retain of one is an error. + captures: a retain of one is an error. The signature pass collects the calls in those bodies + too. It did not: a callee that keeps its argument (`stash(c)` pushing into a global) moved it + in its own body, while the `for await` body that called it kept its release of `c`, a use after + free on `main` as well. Verified in the IR only: the release is gone. A run cannot show it, + because `await f()` returns before `f`'s `for await` body has run (§18.5). ### 18.2 An interface over an object is that object The view above showed that `ts.NewInterface` gave a block a second owner. The object-literal interface cast marks it `__owned_result`, and `isFresh` took the mark at its word. `let raw = {...}; let i = raw` freed `raw`'s block twice. On `main` already, a folded object -literal given twice as an interface (`use(o); use(o)`) was freed twice, and 12 corpus files that -compiled there have that shape somewhere. +literal given twice as an interface (`use(o); use(o)`) is freed twice: it crashes there. A first +fix, fresh only when the object has no other use, lost 12 corpus files that compile on `main`; +the view below gets them back. `ts.NewInterface` is now a view of its object, as a union or an optional is, and a view is as fresh as its root: @@ -1288,8 +1293,9 @@ object, so the cache was cleared between them. runtime's own message, "Awaited async operand is in error state". Corpus: 325 of 591 before, 359 after. Every file that newly compiles runs under test-runner -`-mm=own`, AOT and JIT, with gc's output, and so does every `-shared` pair whose two halves both -compile. Two files that compiled on `main` are errors now, both correctly: +`-mm=own`, AOT and JIT, and so does every `-shared` pair whose two halves both compile. The +JIT output of each matches gc's, except the two async files (`00for_await`, `00for_await_yield`), +which the JIT runs only with the async runtime test-runner links: they pass under test-runner. Two files that compiled on `main` are errors now, both correctly: - `00object` stores one object literal into two tuples, which rc leaks. - `00interface_object_array` reads an object after it moved into an array. @@ -1306,6 +1312,9 @@ Found, not fixed: - **A method of an object literal held in a `let`**, returning a block, called and kept, is a borrow error: `let o = {make(): C {...}}; const c = o.make()`. A `const o` works. - **`measure.ps1` reads `compile.bat`**, which only a test-runner run without `-mm` writes. +- **`await f()` returns before `f`'s `for await` body has run**, under every model: with + `for await (const x of g()) keep.push(1)`, `keep` is empty after it (`none` fails the assert, + `gc` too). ### 18.6 Known limits (the input to 7b) diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index 0c65159a9..53b589530 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -1,6 +1,7 @@ #include "mlir/Pass/Pass.h" #include "mlir/IR/BuiltinOps.h" #include "mlir/IR/SymbolTable.h" +#include "mlir/Interfaces/FunctionInterfaces.h" #include "TypeScript/TypeScriptDialect.h" #include "TypeScript/TypeScriptOps.h" @@ -74,7 +75,8 @@ class OwnershipSignaturePass : public mlir::PassWrappergetParentOfType()) + // in any function: a `ts.Func`, or the `func.func` the async lowering outlines a body into + if (isCall(op) && op->getParentOfType()) { calls.push_back({op, resolve(op)}); } @@ -818,7 +820,7 @@ class OwnershipSignaturePass : public mlir::PassWrappergetParentOfType(); + auto inFunction = !!user->getParentOfType(); if (inFunction && mlir::isa(user) && onlyCalled(user->getResult(0)))