From 925b7cf5a3fc199ac884b64362863d321f501f67 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 24 Sep 2026 23:02:26 +0100 Subject: [PATCH 01/14] Design spec for -mm=own, a single-owner memory model A fourth memory model beside gc, rc and none: ownership inferred at compile time over the ownership ops MLIRGen already emits, moves and borrows instead of counts, and a compile error where neither can be proven. Co-Authored-By: Claude Fable 5.1 --- .../2026-09-24-own-memory-model-design.md | 320 ++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md 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 new file mode 100644 index 000000000..71d0972dc --- /dev/null +++ b/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md @@ -0,0 +1,320 @@ +# `-mm=own`: a single-owner memory model + +Date: 2026-09-24. Status: design approved in conversation, awaiting written review. + +## 1. Purpose + +A fourth memory model beside `gc`, `rc` and `none`, spelled `-mm=own`, in which compiled +code reclaims heap memory with **no runtime reference counting**. Ownership is inferred at +compile time the way Rust's borrow checker checks it: every mortal heap block has exactly one +owner, ownership moves, other uses are borrows bounded by the owner's lifetime, and a program +whose ownership cannot be proven is **rejected with a compile error** rather than compiled with +a fallback. + +What it gives that `-mm=rc` does not: no `__tslang_inc_ref`/`__tslang_dec_ref` traffic at all, +destruction at a statically known point, and a diagnostic instead of a leak when ownership is +unclear. What it costs: some valid TypeScript does not compile under this model. That is the +point, and it is why it is a separate model rather than an optimisation of `rc`. + +Decisions taken in the design conversation (do not re-litigate): + +| Question | Decision | +|---|---| +| Goal | No runtime counting | +| Shape | Separate model `-mm=own`, not an elision stage inside `rc` | +| When ownership cannot be proven | Compile error (Rust), not warning+leak, not per-value rc | +| Shared ownership | `Shared`, explicit and counted, as a **later phase** | +| Where the analysis lives | A pass over the ownership ops MLIRGen already emits (approach A), not a borrow checker inside MLIRGen, not user-written annotations | +| Aliasing inside a loop of a value declared outside it | Borrow only; never a move | + +## 2. Semantics + +### 2.1 Invariant + +Every **mortal** heap block - string, array, class instance, capture box, captured-variable +cell, `any` box, tagged-union payload, generator state object - has exactly one owner at every +program point: a local slot, a field, an element, a cell, a global, or a temporary in flight. +There is no count. The owner destroys the block when it gives the block up: scope exit, +overwrite, `delete`, `pop`/`shift`/`splice`. + +**Immortal** blocks - string literals, `typeof` tags, objects that crossed in from a module +built under another model - have no owner and may be aliased freely. The free path keeps its +single header read to skip them; that read is the only header access left in this model. + +### 2.2 The three verdicts + +At every point where `rc` would take a second reference, the compiler reaches one of three +verdicts, tried in this order: + +1. **Move.** The source place has no use reachable after the aliasing point. The source gives + the value up: its scope-exit release disappears statically. A use reachable after a move is + a *use-after-move* error citing both points. The moved-from slot is not nulled at runtime; + "moved" is a compile-time fact. +2. **Borrow.** The receiver is a local slot or a temporary, and on every path its last use + precedes every release and every *dropping mutation* of the owner, and the borrowed value is + never stored into a longer-lived place, returned, or captured by an escaping closure. The + receiver takes no reference and releases nothing. Borrows chain: a borrow of a borrow is + bounded by the outer tenure. +3. **Error.** Neither applies. + +A *dropping mutation* of a place is anything that can destroy the value it holds: assignment +to a variable, field or captured variable; `pop`, `shift`, `splice`, `length =` on an array; +`delete`. + +### 2.3 Containers own + +A store into a field, element, cell or global is a **move into the container**: the source must +be fresh (a `new`, a literal, a call result) or dead afterwards. A read out of a container is a +borrow bounded by the container's own tenure. Boxing into `any` or a tagged union moves into the +box; unboxing borrows. + +### 2.4 Calls + +Arguments are borrowed, as today. A return is a move: the caller owns the result (today's +"+1 from every call", §9.24 of the rc document, read as "you own it"). + +Two Rust rules are ported as *inference*, never as annotation: + +- **Owned-by-callee parameter.** A callee that stores a parameter into a container or returns + it makes that parameter owned-by-callee. The caller must reach a *move* verdict for the + argument. +- **Borrowed-from-argument result.** A callee that returns a borrow of a parameter or of `this` + (a getter returning `this.field`) marks its result borrowed-from-argument *i*. At the call site + the result's tenure is the argument's, so the result can only be borrowed. This is Rust's + lifetime-elision rule. + +Both are intra-module facts. A callee without facts - declared, imported from a module that +does not export them, a virtual call whose candidates disagree - defaults to borrowed parameters +and an owned result, the leak-side default `OwnedReturnConsumptionPass` already uses. A module +built under `own` exports its facts beside the existing `__tsmm_own_*` marker. + +### 2.5 Closures + +A closure that does not escape borrows what it captures; the frame keeps ownership of the +cells. A closure that escapes - stored into a container, returned, boxed, or passed to an +owned-by-callee parameter - takes its cells with it, and any use of those variables in the +enclosing frame after the closure's creation is an error. + +### 2.6 Loops + +An acquisition inside a loop whose source was declared outside the loop can only be a borrow. +A move there would leave the next iteration reading a moved slot. + +### 2.7 Out of scope for the first version + +- Generators and async functions: the state object as owner. Own phase (§7, phase 7). +- `Shared`: an explicit, counted generic for graphs that a tree of owners cannot express, + reusing `rc`'s retain/release routines for that type only. Own phase (§7, phase 8). It rides + the generics machinery like the settled `WeakRef` design (rc document §9.8); no parser + change. +- `&mut`-style exclusivity beyond "the owner is not mutated while borrowed". Single-threaded, + so read/read aliasing needs no rule. +- Lifetime annotations of any kind. + +## 3. Architecture + +Five pieces; MLIRGen is not one of them. MLIRGen keeps emitting exactly the ownership ops it +emits today (`ts.Retain`/`ts.RetainSlot`/`ts.RetainCell`, their releases, `__owned_consumed` +declarations) and those ops keep erasing under `gc` and `none`, so no existing model's output +changes by a single instruction. + +### 3.1 The model + +- `MemoryModelOwn` in `include/TypeScript/TypeScriptCompiler/Defines.h`; `memoryModelName()` + returns `"own"`, so `-mm=own` and the shared-library marker `__tsmm_own__` agree + by construction. +- `CompileOptions` (`include/TypeScript/DataStructs.h`) gains `tracksOwnership()` = rc **or** + own ("ownership ops are live and lower to code") beside the existing `isRefCounted()` = rc + only ("a count is maintained in the block header"). Every current `isRefCounted()` site + (21 across `LowerToLLVM.cpp`, `MLIRGenImpl.h`, `MLIRGenExpressions.cpp`, `LLVMCodeHelper.h`, + `LLVMCodeHelperBase.h`, `OwnershipRoutineLogic.h`, `CastLogicHelper.h`, `DataStructs.h`) is + audited into one of the two. +- `needsGCRuntime()` stays false for own: allocation goes through `malloc` as under rc. The block + header stays (uniform ABI across models, immortal marker); `_MemoryAlloc` stores no count. +- `tslang.cpp`'s `-mm=` option, `opts.cpp`, `transform.cpp`, `exe.cpp`, `jit.cpp` accept the + new spelling wherever they enumerate models. + +### 3.2 `OwnershipSignaturePass` (module pass) + +Computes the interprocedural facts of §2.4 and pins them on each `FuncOp` as attributes: + +- `__own_params`: the parameter indices the body stores into a container or returns. +- `__own_result_borrows`: the parameter index (or `this`) the result borrows from, if any. + +Runs to a fixpoint over the call graph (a function that returns what a callee returns depends +on the callee's fact). Unknown callees default as in §2.4. For a `-shared` build the facts are +serialised into an exported symbol beside the marker; the importer reads them during the symbol +enumeration it already does for `__tsmm_` and applies them to the declared `FuncOp`s. + +### 3.3 `OwnershipInferencePass` (per `ts.FuncOp`) + +Scheduled in `transform.cpp` where `OwnershipVerifierPass` is scheduled - after +`LowerToAffine*`, before the optimiser - and only under `own`. That level is the right one: +control flow is an ordinary CFG with unwind edges as edges, so liveness is standard; yet +`ts.VariableOp` slots and every ownership op are still present and still carry source +locations. Its output is IR with **zero** retain ops left, or errors. + +### 3.4 Lowering under own + +- `RetainOpLowering`, `RetainSlotOpLowering`, `RetainCellOpLowering`: unreachable under own. If + one survives, `emitError` ("ownership inference left a retain behind") and fail - never + silently erase. +- `Release*` lowerings call `OwnershipRoutineLogic` as today; `OwnershipRoutineLogic` gains an + own flavour where `emitIfLastReference` becomes "load header; if not immortal, run the body" + - one load, no read-modify-write - and the per-type `tsrel_`/`tsrelv_` routines are + destroy-shaped. `__tslang_inc_ref` and `__tslang_dec_ref` are never referenced. +- `_MemoryAlloc` under own: header word left as the allocator returned it (zero after the + memset), no count store. + +### 3.5 Verifier and test runner + +- `--verify-ownership` runs after inference and learns one rule: a slot the inference marked + `__own_borrowed` or `__own_moved` has neither an acquisition nor a release. Everything else it + checks today still holds (moved sources lose both halves of their pair; borrowers never had + either). +- `test/tester/test-runner.cpp` gains the `-mm=own` variant using the existing + `optVariantSuffix()` cached-script mechanism (`jitown`, `compileown`), and the CMake lists gain + `test-jit-own-*`/`test-compile-own-*` targets. +- The runner gains an **expected-compile-error mode** (it has none today: `00union_errors.ts` + is a runtime test). A test file whose first line is `// @error: ` must fail to compile + and its diagnostics must contain ``; a compile that succeeds, or fails with a different + message, fails the test. + +### 3.6 Data flow + +``` +MLIRGen (unchanged) + -> OwnedReturnConsumptionPass (every model, as today) + -> LowerToAffine* (scopes become CFG, unwind edges become edges) + -> OwnershipSignaturePass (own only, module) + -> OwnershipInferencePass (own only, per function; deletes retains or errors) + -> OwnershipVerifierPass (if --verify-ownership) + -> optimiser + -> LowerToLLVM (own flavour of OwnershipRoutineLogic) +``` + +## 4. The inference + +Everything the pass decides is a property of a **slot** (a `ts.VariableOp` result, a parameter, +or a heap-owning temporary SSA value) and of the **places** a value can be stored into. + +### 4.1 Inputs, all present at affine level today + +- Acquisitions: `ts.RetainSlot`, `ts.Retain`, `ts.RetainCell`, and `ts.VariableOp` carrying + `OWNED_LOCAL_CONSUMED_ATTR_NAME` (owns a fresh value: never an alias, never an error). +- Releases: `ts.ReleaseSlot`, `ts.Release`, `ts.ReleaseCell` - where each owner's tenure ends. +- Stores into places: `ts.StoreOp` whose reference is a field, element, cell or global; + `ts.ReturnVal`; boxing casts; capture-box construction (`__capture_box`). +- Dropping mutations on a place (§2.2): assignment stores, the array-mutating ops of rc step 5f, + `ts.DeleteOp`. +- Callee facts (§3.2) at every call, direct, virtual or through a value. + +### 4.2 Algorithm, per function + +1. **Liveness.** `mlir::Liveness` over the CFG; loads and stores through a slot's ref count as + uses of the slot. +2. **Tenures.** For each owner: from its acquisition to each of its releases, plus every + dropping mutation of the place inside that range. +3. **Verdicts**, for each acquisition in program order: + - *Move* if no use of the source is reachable from the aliasing point (its own releases + excluded). Delete the retain and the source's releases on paths from here; record + `__own_moved` on the source slot. A reachable use is a use-after-move error. + - Else *borrow* if the receiver is a local slot or temporary and, on every path, its last use + precedes every release and dropping mutation of the owner, and the receiver never reaches a + store into a longer-lived place, a return, or an escaping capture. Delete the retain and the + receiver's releases; record `__own_borrowed` on the receiver with the owner as its bound. + - Else error (§5). +4. **Loops.** An acquisition inside a loop of a source declared outside: borrow only (§2.6). +5. **Containers.** A store into a container is an acquisition whose receiver is the container's + place; only a move verdict is acceptable. +6. **Closures.** A capture box whose closure value escapes moves its cells in; frame uses of + those variables after the creation point are errors. A non-escaping closure borrows. +7. **Calls.** An argument to an owned-by-callee parameter goes through step 3 as a move. A + result flagged borrowed-from-argument gets the argument's tenure. + +### 4.3 Precision escape valve + +If a shape proves unreadable at the ops level, MLIRGen tags the op with an attribute (precedent: +`__owns_capture`, `__capture_box`) so the pass can read the source-level fact. Never a branch +on the memory model in MLIRGen: that is what keeps the erase-under-gc property, and with it the +structural immunity of the ~2,800 gc tests to this work. + +## 5. Diagnostics + +One `emitError` at the aliasing point, `attachNote`s at the reason, in the language of the rules: + +| Rule | Error | Notes | +|---|---|---| +| use-after-move | `'x' is used here after its value was moved` | `value moved here` | +| borrow outlives owner | `'y' borrows 'x' but is used here after 'x' is released` | `'x' released here` | +| borrow across dropping mutation | `'e' borrows an element of 'arr' but 'arr.pop()' here may destroy it` | `borrowed here` | +| borrow escapes | `'y' borrows 'x' and cannot be stored into 'node.next' / returned / captured` | `borrowed here` | +| container needs a move | `'x' is stored into 'node.next' but still used here` | `stored here` | +| escaping closure | `'x' is captured by a closure that escapes here and is still used here` | `closure created here` | +| callee owns parameter | `argument 'x' is given to 'f', which keeps it, but 'x' is still used here` | `'f' stores its parameter here` | + +Errors are collected per function and all reported, then the pass signals failure so the +pipeline stops before lowering. Locations are the ones MLIRGen attached; the verifier already +reports through them at this level. + +## 6. Testing + +1. **The promise, mechanically.** `--emit=llvm -mm=own` output of every own test contains + neither `__tslang_inc_ref` nor `__tslang_dec_ref`. +2. **Positive tests, one per verdict shape**: move, borrow, container move, callee-owned + parameter, borrowed-from-argument result, non-escaping closure, `for...of`, `any` and union + boxing, `using`/unwind. Each runs as `jitown` and `compileown`, asserts behaviour, and is + measured: a 1M-iteration allocation loop with the AOT measuring script (rc's + `measure.ps1`, promoted from the scratchpad into `test/tester/tools/`) must hold flat like + rc's 3.8 MB, not climb like `none`'s 172.8 MB. +3. **Negative tests, one per row of §5**, through the `// @error:` mode of §3.5, asserting the + message text. +4. **Teeth**, rc's method adapted: for each positive test, temporarily re-emit the moved + source's release (a double free) and confirm the test fails. Proves each verdict is + load-bearing. +5. **Corpus under `-mm=own` as a report, not a gate**: files compiling clean, and a histogram of + §5 rules hit. This is the model's usability metric and decides which shapes phase 6 refines. +6. `--verify-ownership` in every own test; a silent verifier is a regression. +7. Windows and Linux (WSL) suites green at every phase; rc, gc and none suites unchanged. + +## 7. Phasing + +Each phase is a PR series leaving `main` green. + +0. **Flag and lowering.** `MemoryModelOwn`, the `tracksOwnership()` audit, own flavour of + `OwnershipRoutineLogic`, retain lowerings that error, runner variant, `// @error:` mode, + measuring script checked in. Result: every alias is an error, but alias-free programs + compile, run and reclaim with no counting. First measured numbers. +1. **Move inference** (§4.2 step 3, first verdict) and use-after-move. +2. **Borrow inference** for locals and temporaries: tenures, dropping mutations, loop rule. +3. **Containers**: stores as moves, reads as borrows; `any`/union boxing. +4. **Signature pass**: owned-by-callee parameters, borrowed-from-argument results, export beside + the marker and import on the other side. +5. **Closures**: escaping vs non-escaping, cells. +6. **Corpus report and diagnostics polish**: refine the shapes the histogram says matter. +7. **Generators and async**: the state object as owner. +8. **`Shared`**: counted opt-in reusing rc's routines; the verifier learns it. + +Phase 0 is shippable behind the flag; 1-3 make the model usable; 4-5 make real programs compile. + +## 8. Prerequisites and open items shared with `rc` + +- **Foreign blocks are not marked immortal at runtime.** The rc design decided that an object + crossing from a module built under another model is marked immortal so the counted side never + frees it (mixed links allowed, leak rather than corrupt). Only *static* blocks carry the + marker today (`LLVMCodeHelper.h` string and array literals); no import-boundary write exists. + Consequence for own as for rc: a GC-built default library's objects would be freed with + `free()`; the ctest suites run `--no-default-lib`. Phase 0 inherits the restriction; + closing it is one piece of work that serves both models and is tracked here, not in own's + phases. +- `WeakRef` (rc document §9.8) is designed and not implemented; own needs it no more and no + less than rc does, and only once `Shared` exists (a tree of single owners cannot form a + cycle). +- The WASM allocator path is unvalidated under rc and will be equally so under own. + +## 9. Non-goals + +- Replacing or changing `gc`, `rc` or `none`. +- Making every TypeScript program compile under `own`. +- Any new syntax. `Shared` and `WeakRef` are ordinary generic types. +- Thread safety of ownership; revisit with threading, not before. From bf35a356179c2b156f9235f8bd18b9faa4c04595 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 24 Sep 2026 23:02:52 +0100 Subject: [PATCH 02/14] Satisfy markdownlint in the own-model spec Co-Authored-By: Claude Fable 5.1 --- .../superpowers/specs/2026-09-24-own-memory-model-design.md | 6 +++--- 1 file changed, 3 insertions(+), 3 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 71d0972dc..7fb21caac 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 @@ -19,7 +19,7 @@ point, and it is why it is a separate model rather than an optimisation of `rc`. Decisions taken in the design conversation (do not re-litigate): | Question | Decision | -|---|---| +| --- | --- | | Goal | No runtime counting | | Shape | Separate model `-mm=own`, not an elision stage inside `rc` | | When ownership cannot be proven | Compile error (Rust), not warning+leak, not per-value rc | @@ -182,7 +182,7 @@ locations. Its output is IR with **zero** retain ops left, or errors. ### 3.6 Data flow -``` +```text MLIRGen (unchanged) -> OwnedReturnConsumptionPass (every model, as today) -> LowerToAffine* (scopes become CFG, unwind edges become edges) @@ -244,7 +244,7 @@ structural immunity of the ~2,800 gc tests to this work. One `emitError` at the aliasing point, `attachNote`s at the reason, in the language of the rules: | Rule | Error | Notes | -|---|---|---| +| --- | --- | --- | | use-after-move | `'x' is used here after its value was moved` | `value moved here` | | borrow outlives owner | `'y' borrows 'x' but is used here after 'x' is released` | `'x' released here` | | borrow across dropping mutation | `'e' borrows an element of 'arr' but 'arr.pop()' here may destroy it` | `borrowed here` | From f8cf808391d6c036fd0d61bd911130c12369ea80 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Mon, 28 Sep 2026 23:51:13 +0100 Subject: [PATCH 03/14] Written review of the -mm=own spec against main at #397 Six amendments in a new section 10: a lowering error does not fail the compile today (prerequisite for every model); the phase-0 gate is a pass, not the lowering; the default library is refused under own; the header word keeps its store on native targets; expected-error tests reuse the call-arity ctest pattern; and almost every retain is a birth reference, so a gate rejecting every retain compiles only heap-free programs. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 68 ++++++++++++++++++- 1 file changed, 66 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 7fb21caac..444ddbbe9 100644 --- a/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md +++ b/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md @@ -1,6 +1,7 @@ # `-mm=own`: a single-owner memory model -Date: 2026-09-24. Status: design approved in conversation, awaiting written review. +Date: 2026-09-24. Status: design approved in conversation; written review 2026-09-28 (§10), +amendments folded in. Phase-0 plan pending the scope decision of §10.6. ## 1. Purpose @@ -158,7 +159,8 @@ locations. Its output is IR with **zero** retain ops left, or errors. - `RetainOpLowering`, `RetainSlotOpLowering`, `RetainCellOpLowering`: unreachable under own. If one survives, `emitError` ("ownership inference left a retain behind") and fail - never - silently erase. + silently erase. This is the backstop only; the gate is `OwnershipInferencePass` (§3.3), which + in phase 0 is a stub that reports every retain at its source location and fails (§10.2). - `Release*` lowerings call `OwnershipRoutineLogic` as today; `OwnershipRoutineLogic` gains an own flavour where `emitIfLastReference` becomes "load header; if not immortal, run the body" - one load, no read-modify-write - and the per-type `tsrel_`/`tsrelv_` routines are @@ -318,3 +320,65 @@ Phase 0 is shippable behind the flag; 1-3 make the model usable; 4-5 make real p - Making every TypeScript program compile under `own`. - Any new syntax. `Shared` and `WeakRef` are ordinary generic types. - Thread safety of ownership; revisit with threading, not before. + +## 10. Written review, 2026-09-28 + +Checked against `main` at #397 (`6cb9eecf`). The references in §3 still hold: the six +`ts.Retain*`/`ts.Release*` ops, `OWNED_LOCAL_CONSUMED_ATTR_NAME`, the `__tsmm_` marker, +`memoryModelName()`, `optVariantSuffix()`, the verifier's slot in `transform.cpp`, and +21 `isRefCounted()` call sites plus its definition. Six amendments: + +### 10.1 A lowering error does not fail the compile (prerequisite, every model) + +`runMLIRPasses` (`tslang/transform.cpp`) collects every diagnostic and prints it, but its +result reflects only `pm.run()`. A pattern that calls `emitError` and still returns success - +`CastLogicHelper.h`'s "cast from ... is not supported" is the example that was found - prints +`error:` and the program is then emitted and run anyway: `(number[] | null)` +under `--emit=jit` prints the error and crashes with 0xC0000005. §3.4's backstop would +inherit the hole: a surviving retain would print an error and ship a double free. Fix, +before phase 0 and in every model: an error-severity diagnostic fails the compile. It may +surface latent errors the suite tolerates today; each is a real bug. + +### 10.2 The phase-0 gate is a pass, not the lowering + +Phase 0 schedules `OwnershipInferencePass` as a stub: under own it walks each `ts.FuncOp` at +the verifier's slot, reports every surviving `ts.Retain*` at the location MLIRGen gave it +("... takes a second reference; ownership inference cannot prove a move or borrow yet"), and +signals failure. Lowering has lost the slot names by then; the pass has not. Phase 1 grows the +same pass. The lowering `emitError` of §3.4 stays as the backstop. + +### 10.3 Default library + +`exe.cpp` and `jit.cpp` resolve the default library directory by `memoryModelName()`, so +`-mm=own` without `--no-default-lib` fails with "no default library built for -mm=own" rather +than linking a GC-built library whose objects `free()` would destroy. That is the right +answer until §8's import-boundary marking exists; own tests run `--no-default-lib` as rc's do. + +### 10.4 The header word still needs its store + +§3.4 says `_MemoryAlloc` under own leaves the header "zero after the memset". The memset runs +on wasm only (`useCalloc`/`zeroing`); a native block's header is whatever `malloc` returned. +The own flavour of the free path reads the header to skip immortal blocks, so `_MemoryAlloc` +keeps its store of 0 under own - the same single store rc makes, meaning "mortal" rather than +"count 0". What own drops is the read-modify-write on every retain and release. + +### 10.5 Expected-error tests use the existing ctest pattern + +§3.5's `// @error:` runner mode is not needed: `test/tester/CMakeLists.txt` already tests +compile errors with plain `add_test` + `PASS_REGULAR_EXPRESSION` + `FAIL_REGULAR_EXPRESSION` +(`call_arity_cases`). The runner's compile scripts do not capture the compiler's diagnostics, +so a runner mode would have to re-invoke tslang itself; own's negative tests follow the +call-arity pattern instead. + +### 10.6 Almost every retain is a birth reference, not an alias + +Blocks are born unowned (count 0, rc §9.24), so the first owner of *every* fresh value - a +local's declaration, a temporary passed to `print`, a `new` - takes it with a `ts.Retain` or +`ts.RetainSlot`. Measured on `main` with `--emit=mlir-affine -mm=rc --no-default-lib`: +`print(i)` for an `s32` is `Cast {__owned_result}` + `Retain` + `Print` + `Release`; +`const s = "item " + i` is two retains and a release; only programs that allocate nothing are +retain-free. A phase-0 gate that rejects every retain therefore compiles only heap-free +programs, and §7's "alias-free programs compile, run and reclaim" is not reachable without +the move verdict for a fresh value's single acquisition - which depends on each producer's ++0/+1 convention (`__owned_result`, the retaining return of rc §9.24/§9.25, pop/shift). How +phase 0 and phase 1 split around this is decided in the plan. From a648f657c3508d5232bb14a202c7ee991d62e9c0 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Tue, 29 Sep 2026 00:01:34 +0100 Subject: [PATCH 04/14] Plan for -mm=own phase 0, with the birth take Phase 0 accepts a fresh value's single acquisition - rc's count provably never above one - as a move, and reports every other retain. Seven tasks: the lowering-error prerequisite (PR #398), the model and flag, the isRefCounted() audit site by site, destroy-shaped lowering with a retain backstop, the inference pass, positive/negative/no-counting tests, and a rebuilt measuring script. Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-09-28-own-phase-0.md | 828 ++++++++++++++++++ .../2026-09-24-own-memory-model-design.md | 3 +- 2 files changed, 830 insertions(+), 1 deletion(-) create mode 100644 tslang/docs/superpowers/plans/2026-09-28-own-phase-0.md diff --git a/tslang/docs/superpowers/plans/2026-09-28-own-phase-0.md b/tslang/docs/superpowers/plans/2026-09-28-own-phase-0.md new file mode 100644 index 000000000..97ebbfbc6 --- /dev/null +++ b/tslang/docs/superpowers/plans/2026-09-28-own-phase-0.md @@ -0,0 +1,828 @@ +# `-mm=own` Phase 0 Implementation Plan + +> **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:** `-mm=own` exists, and a program whose every heap block has a single owner compiles, +runs, reclaims with no reference counting, and is measured. Any other ownership shape is a +compile error at its source location. + +**Architecture:** MLIRGen is not touched. It keeps emitting rc's ownership ops. +A new `OwnershipInferencePass` runs at the affine level under own only. In phase 0 it knows one +verdict, the *birth take* (the move of a fresh value into its one owner): it erases that +retain and reports every other retain as an error. Lowering under own keeps every release but +makes it destroy-shaped: a header load, an immortal check, then destroy, with no count +arithmetic. A retain that survives to lowering is a backstop error. + +**Tech Stack:** C++17, MLIR/LLVM (tslang's LLVM 19 tree), CMake/ctest, the +`test/tester/test-runner.cpp` harness, PowerShell for the measuring script. + +**Spec:** `docs/superpowers/specs/2026-09-24-own-memory-model-design.md`. Read §2, §3 and all of +§10 first: §10 amends §3.4, §3.5 and §7 phase 0, and this plan follows the amendments. + +## Global Constraints + +- The `-mm=` spelling is `own`, and `memoryModelName(MemoryModelOwn)` returns `"own"`. That name + also forms the shared-library marker `__tsmm_own__`. +- `gc`, `rc` and `none` output must not change by a single instruction. Everything new is behind + `memoryModel == MemoryModelOwn`, or behind `tracksOwnership()` where rc behaviour is unchanged + by construction. +- Never branch on the memory model in MLIRGen (spec §4.3). The four `isRefCounted()` sites in + MLIRGen become `tracksOwnership()`: under own they must emit exactly what they emit under rc. +- `needsGCRuntime()` stays false for own: allocate through `malloc` as rc does. `_MemoryAlloc` + keeps its header store of 0 under own (spec §10.4). +- `__tslang_inc_ref` and `__tslang_dec_ref` never appear in own output. +- Own tests run with `--no-default-lib`, as rc's do (spec §10.3). +- Build: `cmake --build __build/tslang/windows-msbuild-2026-release --config Release --target tslang` + (add `test-runner` when the runner changes). Test: from that directory, `ctest -j 16 -C Release`. + A bare `ctest` reports every test as "(Not Run)". +- Do not edit `TypeScriptOps.td`, since phase 0 adds no ops. If a task seems to need it, stop and ask. +- Commits are GPG-signed. On a signing timeout, retry, and never pass `--no-gpg-sign`. +- Every commit message ends with the `Co-Authored-By:` line the session gives. +- Branch `own-phase-0` off `origin/main` with `git checkout --no-track -b own-phase-0 origin/main`. + It needs PR #398 (an error diagnostic fails the compile) merged first. Without #398 the + backstop in Task 4 prints an error and ships the program anyway. + +## Review Focus + +1. **A release with no acquisition.** rc tolerates one: a block released from count 0 + underflows to `HEAP_BLOCK_IMMORTAL` and leaks. Under own the same release destroys, so a + latent rc over-release becomes a use-after-free. Task 5's pass reports a `ts.Release`, + `ts.ReleaseSlot` or `ts.ReleaseCell` whose value or slot has no acquisition in the function + and no birth (see "released but never taken" in Task 5). Task 6 has a negative test for it. +2. **A fresh value with two owners.** `const a = [1]; const b = a;` has a `ts.Variable` for `b` + whose initializer is a load of `a`, and rc retains that load. The retain's operand is not + fresh, so it is an error. Task 6 has this as a negative test, and it must never compile. +3. **A value that leaves the function.** `return new C()` retains before returning, which is + rc's retaining-return convention. The caller consumes the result through `__owned_result`. + Both halves must stay balanced: Task 6's `own_return_new.ts` returns a fresh instance 1M + times and is measured flat. +4. **Unwind paths.** A birth-taken local released on the unwind edge of a `try` must be released + once on each path, not twice on the path that throws. Task 6's `own_try_local.ts` throws + through a function holding a fresh array and is run 100k times under the measuring script. +5. **An unknown producer.** A heap value returned by a runtime helper, or by an imported + function, has no `__owned_result` and is not an allocating op, so it is not fresh. A retain of + it must be an error, never a birth take. Task 6's negative test `own_err_unknown_producer.ts` + uses `.slice()` on an array (a runtime-helper result) and holds it in a local. + +--- + +## Task 1: An error diagnostic fails the compile (DONE: PR #398) + +Branch `lowering-error-fails-compile`, PR #398, full Release suite 3061/3061. `runMLIRPasses` +returns 4 when any collected diagnostic has error severity. Regression tests: +`test-compile-lowering-error-message` and `test-compile-lowering-error-exit`. + +- [x] Test, fix, full suite, PR. +- [ ] Merge #398 before starting Task 4. + +## Task 2: The model exists (`MemoryModelOwn`, `-mm=own`, runner variant) + +**Files:** + +- Modify: `include/TypeScript/TypeScriptCompiler/Defines.h` (enum `MemoryModel`, `memoryModelName`) +- Modify: `include/TypeScript/DataStructs.h:66-76` (add `tracksOwnership()`) +- Modify: `tslang/tslang.cpp:124-128` (`memoryModelOpt` values) +- Modify: `test/tester/test-runner.cpp:785-795` (accept `-mm=own`) +- Modify: `test/tester/CMakeLists.txt:150-165` (the cached-script list gains the `own` names) +- Test: `test/tester/CMakeLists.txt` (new `test-own-flag-*` tests) + +**Interfaces:** + +- Produces: `MemoryModelOwn`, `memoryModelName(MemoryModelOwn) == "own"`, and + `CompileOptions::tracksOwnership() const`, true for rc or own ("the ownership ops are live + and lower to code"). `isRefCounted()` stays rc only ("a count is kept in the header"). + `test-runner -mm=own` uses the cached scripts `jitown`/`compileown` (debug: `jitdown`/`compiledown`). + +- [ ] **Step 1: Write the failing test.** Add after the lowering-error tests in + `test/tester/CMakeLists.txt`: + +```cmake +# -mm=own is accepted. Phase 0: a program that allocates nothing compiles and runs under it. +add_test(NAME test-own-flag-accepted + COMMAND $ --emit=mlir --no-default-lib -mm=own + "${PROJECT_SOURCE_DIR}/test/tester/tests/00assert.ts") +set_tests_properties(test-own-flag-accepted PROPERTIES FAIL_REGULAR_EXPRESSION "error|Stack dump") +``` + +- [ ] **Step 2: Run it and see it fail.** + `cmake .` in the build directory, then `ctest -C Release -R test-own-flag`. Expected: FAIL, + because tslang reports `Cannot find option named 'own'` and exits non-zero. + +- [ ] **Step 3: Implement.** In `Defines.h`, add a fourth enumerator after `MemoryModelNone`: + +```cpp + // No reclamation at all. + MemoryModelNone, + // Single ownership, inferred at compile time: every mortal block has exactly one owner, + // which destroys it. No count is kept; a program whose ownership cannot be proven is a + // compile error. See docs/superpowers/specs/2026-09-24-own-memory-model-design.md. + MemoryModelOwn +``` + +and a case in `memoryModelName`: + +```cpp + case MemoryModelOwn: + return "own"; +``` + +In `DataStructs.h`, after `isRefCounted()`: + +```cpp + // Whether the ownership operations are live and lower to code - retains and releases under + // `rc`, releases that destroy under `own`. Where only the count itself matters, the + // question is isRefCounted(). + bool tracksOwnership() const + { + return memoryModel == MemoryModelRC || memoryModel == MemoryModelOwn; + } +``` + +In `tslang.cpp`, add the value after `none`: + +```cpp + cl::values(clEnumValN(MemoryModelOwn, "own", "single ownership inferred at compile time, no counting (in development; ownership that cannot be proven is a compile error)")), +``` + +In `test-runner.cpp`, extend the accepted spellings and the comment above them: + +```cpp + else if (std::string(argv[index]) == "-mm=gc" || std::string(argv[index]) == "-mm=rc" || + std::string(argv[index]) == "-mm=none" || std::string(argv[index]) == "-mm=own") +``` + +In `test/tester/CMakeLists.txt`, add to the cached-script list, next to the `none` lines, the +eight names `compileown.bat jitown.bat compiledown.bat jitdown.bat compileown.sh jitown.sh +compiledown.sh jitdown.sh` in the same `"${CMAKE_CURRENT_BINARY_DIR}/"` form. + +- [ ] **Step 4: Run it and see it pass.** Build `tslang` and `test-runner`, then run + `ctest -C Release -R test-own-flag`. Expected: PASS. +- [ ] **Step 5: Confirm nothing moved.** Run the full suite with `ctest -j 16 -C Release`. + Expected: every test passes, because no existing path reads the new enumerator yet. +- [ ] **Step 6: Commit.** Use the message "The -mm=own memory model exists: flag, name, tracksOwnership()". + +## Task 3: The `isRefCounted()` audit + +Each site keeps `isRefCounted()` (rc only: it reads or writes a count) or becomes +`tracksOwnership()` (rc or own: a release will read what this writes). The line numbers are +from `main` at #397. Re-run `git grep -n "isRefCounted()"` first and reconcile before editing. + +| Site | What it does | Verdict | +| --- | --- | --- | +| `CastLogicHelper.h:1090` | `undefined` as an array is null, not undef | tracksOwnership | +| `LLVMCodeHelper.h:555` | constant arrays carry an immortal header | tracksOwnership | +| `LLVMCodeHelper.h:725` | an unspecified owning field is null | tracksOwnership | +| `LLVMCodeHelperBase.h:384` | `_MemoryAlloc` stores 0 in the header | tracksOwnership (§10.4) | +| `OwnershipRoutineLogic.h:325` | `emitReleaseArrayElements` is live | tracksOwnership | +| `LowerToLLVM.cpp:408` | `RetainOp` lowering | Task 4 (backstop) | +| `LowerToLLVM.cpp:427` | `ReleaseOp` lowering | tracksOwnership | +| `LowerToLLVM.cpp:448` | `RetainSlotOp` lowering | Task 4 (backstop) | +| `LowerToLLVM.cpp:467` | `ReleaseSlotOp` lowering | tracksOwnership | +| `LowerToLLVM.cpp:488` | `RetainCellOp` lowering | Task 4 (backstop) | +| `LowerToLLVM.cpp:507` | `ReleaseCellOp` lowering | tracksOwnership | +| `LowerToLLVM.cpp:636` | memset exposed slots on `length =` | tracksOwnership | +| `LowerToLLVM.cpp:1466` | owning `undefined` is null | tracksOwnership | +| `LowerToLLVM.cpp:2339` | a cell is born owned by its frame (`emitRetainCell`) | isRefCounted: under own the frame owns the cell with no count | +| `LowerToLLVM.cpp:2380` | a hoisted owned local or cell starts null | tracksOwnership | +| `LowerToLLVM.cpp:2417` | a captured parameter's cell retains the argument | Task 4 (error under own) | +| `LowerToLLVM.cpp:3339` | `delete` releases rather than frees | tracksOwnership | +| `MLIRGenExpressions.cpp:475` | an awaited result slot takes its value | tracksOwnership (MLIRGen) | +| `MLIRGenExpressions.cpp:1133` | `delete` claims its reference | tracksOwnership (MLIRGen) | +| `MLIRGenImpl.h:1837` | a generator local's field takes its value | tracksOwnership (MLIRGen) | +| `MLIRGenImpl.h:9435` | an object owns its capture box | tracksOwnership (MLIRGen) | + +**Files:** the sites above, except the four marked Task 4. + +- [ ] **Step 1: Write the failing test.** This checks that own IR carries rc's ownership ops. + Add to `test/tester/CMakeLists.txt`: + +```cmake +# Under own MLIRGen emits exactly what it emits under rc: an awaited, deleted or captured value +# still carries its ownership ops for the inference pass to judge. Checked on `delete`, whose +# claim (MLIRGenExpressions.cpp) is one of the four MLIRGen sites. +add_test(NAME test-own-mlirgen-matches-rc + COMMAND ${CMAKE_COMMAND} -DTSLANG=$ + "-DFILE=${PROJECT_SOURCE_DIR}/test/tester/own/own_mlirgen_delete.ts" + -P "${CMAKE_CURRENT_SOURCE_DIR}/own-mlirgen-matches-rc.cmake") +``` + +Create `test/tester/own/own_mlirgen_delete.ts`: + +```ts +class C { v = 1; } +function main() { + let c = new C(); + delete c; + print("done."); +} +``` + +Create `test/tester/own-mlirgen-matches-rc.cmake`: + +```cmake +# Under own MLIRGen must emit exactly what it emits under rc. Compares the two `--emit=mlir` +# outputs after erasing the one word that is allowed to differ, the model name in the marker. +cmake_minimum_required(VERSION 3.17.3) +foreach(model rc own) + execute_process(COMMAND "${TSLANG}" --emit=mlir --no-default-lib -mm=${model} "${FILE}" + OUTPUT_VARIABLE out_${model} ERROR_VARIABLE err_${model} RESULT_VARIABLE rc_${model}) + string(APPEND out_${model} "${err_${model}}") + string(REPLACE "__tsmm_${model}_" "__tsmm_MODEL_" out_${model} "${out_${model}}") +endforeach() +if(NOT out_rc STREQUAL out_own) + message(FATAL_ERROR "MLIRGen output differs between -mm=rc and -mm=own:\n--- rc\n${out_rc}\n--- own\n${out_own}") +endif() +``` + +- [ ] **Step 2: Run it and see it fail.** `ctest -C Release -R test-own-mlirgen-matches-rc`. + Expected: FAIL, because under own the `delete` claim is not emitted yet. +- [ ] **Step 3: Implement the table.** Replace `isRefCounted()` with `tracksOwnership()` at every + site marked tracksOwnership. Keep `LowerToLLVM.cpp:2339` as `isRefCounted()`, and extend its + comment with one sentence: "Under own the frame owns the cell outright; there is no count to + start at one." Rewrite the comment at `LLVMCodeHelperBase.h:384` for both models: under rc the + word is the count and starts at 0; under own it only says "mortal", because the free path's + immortal test reads it and a native `malloc` block's header is otherwise garbage. +- [ ] **Step 4: Run it and see it pass.** Expected: PASS. +- [ ] **Step 5: Full suite.** Expected: all pass, since rc is unchanged: for rc, + `tracksOwnership()` equals `isRefCounted()`. +- [ ] **Step 6: Commit.** Use the message "Audit isRefCounted(): what own shares with rc is + tracksOwnership()". + +## Task 4: Lowering under own (destroy-shaped releases, retain backstop) + +**Files:** + +- Modify: `include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h:593` (`emitIfLastReference`) +- Modify: `lib/TypeScript/LowerToLLVM.cpp:404-520` (the three retain lowerings) +- Modify: `lib/TypeScript/LowerToLLVM.cpp:2417` (captured parameter) +- Test: `test/tester/CMakeLists.txt` + +**Interfaces:** + +- Consumes: `tracksOwnership()` and `MemoryModelOwn` from Task 2. +- Produces: under own, `emitIfLastReference(ptr, body)` runs `body` when `ptr` is non-null and + its header is not `HEAP_BLOCK_IMMORTAL`. A surviving retain op under own is the error + `"ownership inference left a retain behind"` and fails the conversion. + +- [ ] **Step 1: Write the failing test.** Without Task 5's pass nothing erases retains yet, so + this is the backstop firing on the smallest program with a retain: + +```cmake +# The lowering backstop: a retain that reaches LLVM lowering under own is an error, never +# silently erased. Checked with the inference pass switched off, which only this test does. +add_test(NAME test-own-lowering-backstop + COMMAND $ --emit=llvm --no-default-lib -mm=own --own-skip-inference + "${PROJECT_SOURCE_DIR}/test/tester/own/own_print_number.ts") +set_tests_properties(test-own-lowering-backstop + PROPERTIES PASS_REGULAR_EXPRESSION "ownership inference left a retain behind" + FAIL_REGULAR_EXPRESSION "Stack dump|Assertion failed") +``` + +Create `test/tester/own/own_print_number.ts`: + +```ts +function main() { + for (let i = 0; i < 3; i++) print(i); + print("done."); +} +``` + +`--own-skip-inference` is a hidden, debug-category flag added in Task 5. Until then, add it in +this task as a `cl::opt` in `tslang.cpp` beside `verifyOwnership`, with `cl::Hidden` and +`cl::cat(TypeScriptCompilerDebugCategory)`, declared `extern` in `transform.cpp`. It exists for +this test only. + +- [ ] **Step 2: Run it and see it fail.** Expected: FAIL, because under own the retain lowerings + still erase silently. +- [ ] **Step 3: Implement.** In each of `RetainOpLowering`, `RetainSlotOpLowering` and + `RetainCellOpLowering`, before the `isRefCounted()` branch: + +```cpp + // Under own, ownership inference erased every retain it could prove, and reported the + // rest; one that reaches here was neither, and erasing it would ship a double free. + if (tsLlvmContext->compileOptions.memoryModel == MemoryModelOwn) + { + return op.emitError("ownership inference left a retain behind"); + } +``` + +At `LowerToLLVM.cpp:2417`, change `isRefCounted()` to `tracksOwnership()` and, inside the +branch, before creating `OwnershipRoutineLogic`: + +```cpp + // The argument is the caller's; a cell that destroyed it would free the caller's + // value. Own has no count to take here: capturing a parameter is phase 5. + if (tsLlvmContext->compileOptions.memoryModel == MemoryModelOwn) + { + return varOp.emitError("a captured parameter cannot own its value under -mm=own yet"); + } +``` + +In `OwnershipRoutineLogic::emitIfLastReference`, replace the `decBlock` body with a +model-dependent test, and leave the rest unchanged: + +```cpp + rewriter.setInsertionPointToEnd(decBlock); + // Under own there is no count: the one owner is giving the block up, so it goes unless + // it is immortal (a literal, a `typeof` tag). One load, no read-modify-write, and + // `__tslang_dec_ref` is never referenced. + auto wasLast = compileOptions.memoryModel == MemoryModelOwn ? emitIsMortal(payloadPtr) : emitDecRef(payloadPtr); + rewriter.create(loc, wasLast, thenBlock, continuationBlock); +``` + +and add, beside `emitDecRef`: + +```cpp + // Is this block mortal - that is, not HEAP_BLOCK_IMMORTAL? Inline rather than a helper: it + // is one load and one compare, and it is the whole of what a release costs under own. + mlir::Value emitIsMortal(mlir::Value payloadPtr) + { + TypeConverterHelper tch(typeConverter); + TypeHelper th(rewriter); + LLVMCodeHelperBase ch(op, rewriter, typeConverter, compileOptions); + auto loc = op->getLoc(); + + auto llvmIndexType = tch.convertType(th.getIndexType()); + auto blockPtr = ch.getBlockPtrFromPayloadPtr(loc, payloadPtr, llvmIndexType); + auto header = rewriter.create(loc, llvmIndexType, blockPtr); + auto immortal = rewriter.create( + loc, llvmIndexType, rewriter.getIntegerAttr(llvmIndexType, HEAP_BLOCK_IMMORTAL)); + return rewriter.create(loc, LLVM::ICmpPredicate::ne, header, immortal); + } +``` + +Then grep the whole of `OwnershipRoutineLogic.h` for any other `emitDecRef` or `emitIncRef` +call not reached through `emitIfLastReference` or a retain. Any found must be unreachable +under own. List them in the commit message. + +- [ ] **Step 4: Run it and see it pass.** Expected: PASS. +- [ ] **Step 5: Full suite.** Expected: all pass. +- [ ] **Step 6: Commit.** Use the message "Lowering under own: releases destroy without counting; + a surviving retain is an error". + +## Task 5: `OwnershipInferencePass`, phase 0: the birth take + +**Files:** + +- Create: `lib/TypeScript/OwnershipInferencePass.cpp` +- Modify: `include/TypeScript/Passes.h` (declaration beside `createOwnershipVerifierPass`) +- Modify: `lib/TypeScript/CMakeLists.txt:34` (add the source beside `OwnershipVerifierPass.cpp`) +- Modify: `tslang/transform.cpp:133-139` (schedule it under own, before the verifier) +- Test: Task 6's tests. This task's own test is the IR check in Step 1. + +**Interfaces:** + +- Consumes: `OWNED_RESULT_ATTR_NAME`, `OWNED_LOCAL_ATTR_NAME` and + `OWNED_LOCAL_CONSUMED_ATTR_NAME` from `include/TypeScript/Defines.h`, and `MemoryModelOwn`. +- Produces: `std::unique_ptr mlir::typescript::createOwnershipInferencePass();`, a + `TypeScriptFunctionPass`. After it runs without error, the function has no `ts.Retain`, + `ts.RetainSlot` or `ts.RetainCell`. + +**The phase-0 rule.** rc's count for a block is its births plus its retains. The pass erases a +function's retains of a value only when that count provably never exceeds one, which makes +the single release that rc already emits the owner's destroy. For a heap-typed SSA value `V`: + +- `V` is **fresh** when its defining op is one of `ts.New`, `ts.CreateArray`, `ts.NewArray`, + `ts.StringConcat` or `ts.CharToString`, or carries `OWNED_RESULT_ATTR_NAME`. String `+` + reaches the affine level as `ts.ArithmeticBinary {__owned_result}`, and number-to-string as + `ts.Cast {__owned_result}`, so both are fresh through the attribute. These are the + lowerings that allocate (checked with `LowerToLLVM.cpp`'s `MemoryAlloc` users on #397), plus + the ops rc already marks as "arrives with a reference". +- `V`'s **birth** is 1 when its defining op carries `OWNED_RESULT_ATTR_NAME` *and* is a call + (`ts.Call`, `ts.CallIndirect`, `ts.CallInternal`). The callee retained on its way out (rc + §9.24), and the receiver consumed it. Otherwise the birth is 0. `markFreshStringOwned` marks a + string and emits the retain that *is* its birth, so that retain is counted below as an + acquisition. +- `V`'s **acquisitions** are its birth, plus every `ts.Retain` whose operand is `V`, plus every + `ts.RetainSlot` on a `ts.Variable` whose initializer is `V`. +- **Birth take:** `V` is fresh and its acquisitions total exactly 1. Erase those retains. +- **Anything else is an error**, at the retain's location. The error is + `"'' takes a second reference; -mm=own cannot prove a move or a borrow here yet"`. Use + the `ts.Variable`'s name when the retain is a `RetainSlot`, and "this value" otherwise. Every + `ts.RetainCell` is an error: `"closures that capture a heap value are not supported by -mm=own yet"`. +- **A `ts.RetainSlot` on a variable with no initializer** is an error in phase 0. Its storage was + hoisted in front of a `try`, and its initializing store stayed at the declaration + (`LowerToLLVM.cpp:2380`). Phase 1 follows the store. The error is known to reject an owned + local declared inside a `try` body. +- **A fresh value with a retain and no `ts.Release`** is not an error and not a hazard. On + 2026-09-28, `const s = "item " + i` was folded into its SSA value (`ts.ArithmeticBinary + {__owned_result}` + `Retain`), and no release was visible at the affine level. The rule + bounds acquisitions only, so a missing release can leak but never double-free. Task 7's + measurement is where such a leak shows. +- **Released but never taken:** a `ts.ReleaseSlot` whose slot is a `ts.Variable` with no + acquisition (no `RetainSlot`, no `OWNED_LOCAL_CONSUMED_ATTR_NAME`, not fresh-initialized) is an + error `"'' is released but never owned"`. Skip it when it is the hand-over half of an + overwrite, reusing `OwnershipVerifierPass::isHandOver`'s test: move that function into a + small shared header `include/TypeScript/OwnershipOps.h` and include it from both passes. + +- [ ] **Step 1: Write the failing test.** This checks the IR the pass produces: + +```cmake +# After inference under own, no retain survives in a program of single owners. +add_test(NAME test-own-inference-erases-birth-takes + COMMAND $ --emit=mlir-affine --no-default-lib -mm=own + "${PROJECT_SOURCE_DIR}/test/tester/own/own_print_number.ts") +set_tests_properties(test-own-inference-erases-birth-takes + PROPERTIES PASS_REGULAR_EXPRESSION "ts.Release" + FAIL_REGULAR_EXPRESSION "ts\\.Retain|error|Stack dump") +``` + +- [ ] **Step 2: Run it and see it fail.** Expected: FAIL, because `ts.Retain` is present. +- [ ] **Step 3: Implement.** Create `lib/TypeScript/OwnershipInferencePass.cpp`: + +```cpp +#include "mlir/Pass/Pass.h" + +#include "TypeScript/TypeScriptDialect.h" +#include "TypeScript/TypeScriptOps.h" +#include "TypeScript/TypeScriptFunctionPass.h" +#include "TypeScript/Passes.h" +#include "TypeScript/Defines.h" +#include "TypeScript/OwnershipOps.h" + +#include "llvm/ADT/DenseMap.h" +#include "llvm/ADT/SmallVector.h" + +#define DEBUG_TYPE "own" + +namespace mlir_ts = mlir::typescript; + +namespace +{ + +// Ownership inference for -mm=own, phase 0. See +// docs/superpowers/specs/2026-09-24-own-memory-model-design.md, section 10.6. +// +// rc's count for a block is its births plus its retains. Where that provably never exceeds one, +// the block has one owner, the release rc already emits is that owner's destroy, and every +// retain on the way is erased. Anything else is reported: phase 0 knows no other verdict. +class OwnershipInferencePass : public mlir::PassWrapper +{ + public: + MLIR_DEFINE_EXPLICIT_INTERNAL_INLINE_TYPE_ID(OwnershipInferencePass) + + void runOnFunction() override + { + auto f = getFunction(); + + // value -> the retain ops that acquire it (Retain on the value, RetainSlot on the + // variable it initializes) + llvm::DenseMap> acquirers; + llvm::SmallVector unattributed; + llvm::SmallVector slotReleases; + + f.walk([&](mlir::Operation *op) { + if (auto retainOp = mlir::dyn_cast(op)) + { + acquirers[retainOp.getReference()].push_back(op); + } + else if (auto retainSlotOp = mlir::dyn_cast(op)) + { + auto varOp = retainSlotOp.getSlot().getDefiningOp(); + if (varOp && varOp.getInitializer()) + { + acquirers[varOp.getInitializer()].push_back(op); + } + else + { + unattributed.push_back(op); + } + } + else if (mlir::isa(op)) + { + op->emitError("closures that capture a heap value are not supported by -mm=own yet"); + signalPassFailure(); + } + else if (auto releaseSlotOp = mlir::dyn_cast(op)) + { + slotReleases.push_back(releaseSlotOp); + } + }); + + for (auto *op : unattributed) + { + reportSecondReference(op); + } + + llvm::SmallVector toErase; + for (auto &[value, retains] : acquirers) + { + auto total = birth(value) + retains.size(); + if (isFresh(value) && total == 1) + { + toErase.append(retains.begin(), retains.end()); + continue; + } + + for (auto *op : retains) + { + reportSecondReference(op); + } + } + + for (auto releaseOp : slotReleases) + { + auto varOp = releaseOp.getSlot().getDefiningOp(); + if (!varOp || mlir_ts::isHandOver(releaseOp)) + { + continue; + } + + auto init = varOp.getInitializer(); + auto owned = varOp->hasAttr(OWNED_LOCAL_CONSUMED_ATTR_NAME) || (init && acquirers.count(init)); + if (!owned) + { + releaseOp.emitError("'") << varName(varOp) << "' is released but never owned"; + signalPassFailure(); + } + } + + for (auto *op : toErase) + { + op->erase(); + } + } + + private: + static bool isFresh(mlir::Value value) + { + auto *def = value.getDefiningOp(); + if (!def) + { + return false; // a block argument: a parameter, or a value merged from branches + } + + return def->hasAttr(OWNED_RESULT_ATTR_NAME) || + mlir::isa(def); + } + + // 1 when the value arrived with a reference the callee took (the retaining return, rc + // §9.24); 0 otherwise - including a string marked by markFreshStringOwned, whose birth is + // the retain MLIRGen emitted beside the mark and is counted as one of its acquisitions. + static unsigned birth(mlir::Value value) + { + auto *def = value.getDefiningOp(); + return def && def->hasAttr(OWNED_RESULT_ATTR_NAME) && + mlir::isa(def) + ? 1 + : 0; + } + + static llvm::StringRef varName(mlir_ts::VariableOp varOp) + { + // the name MLIRGen gave the declaration, when it kept one + if (auto nameLoc = mlir::dyn_cast(varOp.getLoc())) + { + return nameLoc.getName().getValue(); + } + + return "this value"; + } + + void reportSecondReference(mlir::Operation *op) + { + llvm::StringRef name = "this value"; + if (auto retainSlotOp = mlir::dyn_cast(op)) + { + if (auto varOp = retainSlotOp.getSlot().getDefiningOp()) + { + name = varName(varOp); + } + } + + op->emitError("'") << name << "' takes a second reference; -mm=own cannot prove a move or a borrow here yet"; + signalPassFailure(); + } +}; + +} // end anonymous namespace + +#undef DEBUG_TYPE + +std::unique_ptr mlir_ts::createOwnershipInferencePass() +{ + return std::make_unique(); +} +``` + +Before compiling, check three names against `TypeScriptOps.td` on the branch, and fix any that +differ: the call ops' C++ class names, `VariableOp::getInitializer()`, and how MLIRGen attaches +a variable's name to its location (`NameLoc` or a `FusedLoc` with metadata; see +`LowerToLLVM.cpp:2370-2380`). If the name is not recoverable, the message says "this value" and +the location still points at the declaration. + +In `Passes.h`, beside the verifier: + +```cpp +/// Ownership inference for -mm=own: erases the retains it can prove are a fresh value's single +/// acquisition and reports every other one. Runs at the affine level under own only. +std::unique_ptr createOwnershipInferencePass(); +``` + +In `transform.cpp`, immediately before the `if (verifyOwnership)` block: + +```cpp + // Under own, before the verifier so it checks the IR that will be lowered: inference + // either erases a retain or reports it, and a program it reports stops here. + if (compileOptions.memoryModel == MemoryModelOwn && !ownSkipInference) + { + pm.nest().addPass(mlir::typescript::createOwnershipInferencePass()); + } +``` + +- [ ] **Step 4: Run it and see it pass.** Expected: PASS. If the retain is still there, dump + `--emit=mlir-affine -mm=rc` for the same file and check the rule against the actual ops. The + 2026-09-28 dump of `print(i)` was `Cast {__owned_result}` + `Retain` + `Print` + `Release`, + which is fresh with 0 + 1 = 1 acquisition. +- [ ] **Step 5: Full suite.** Expected: all pass. Own is not in any existing test. +- [ ] **Step 6: Commit.** Use the message "OwnershipInferencePass, phase 0: a fresh value's single + acquisition is a move; every other retain is an error". + +## Task 6: Own tests: positive, negative, and the no-counting promise + +**Files:** + +- Create: `test/tester/own/*.ts` (listed below) +- Modify: `test/tester/CMakeLists.txt` + +**Interfaces:** consumes everything above. Produces the `test-jit-own-*`, `test-compile-own-*`, +`test-own-err-*` and `test-own-no-counting-*` ctest names. + +Positive programs. Each prints `done.` and asserts its result, and each allocates in a loop so +the measuring script (Task 7) has something to hold flat: + +`own_fresh_string.ts`: + +```ts +function main() { + for (let i = 0; i < 1000000; i++) { + const s = "item " + i; + if (i == 999999) print(s); + } + print("done."); +} +``` + +`own_fresh_array.ts`: + +```ts +function sum(a: number[]) { + let t = 0; + for (const v of a) t += v; + return t; +} +function main() { + let total = 0; + for (let i = 0; i < 1000000; i++) { + const a: number[] = [i, i + 1, i + 2]; + total += sum(a); + } + assert(total == 1500001500000); + print("done."); +} +``` + +`own_return_new.ts` (Review Focus 3): + +```ts +class Point { + constructor(public x: number, public y: number) {} + len2() { return this.x * this.x + this.y * this.y; } +} +function make(i: number) { return new Point(i, i); } +function main() { + let t = 0; + for (let i = 0; i < 1000000; i++) { + const p = make(i % 10); + t += p.len2(); + } + assert(t == 57000000); + print("done."); +} +``` + +`own_try_local.ts` (Review Focus 4): + +```ts +function f(i: number) { + const a = [i, i]; + if (i % 2 == 0) throw i; + return a.length; +} +function main() { + let caught = 0; + for (let i = 0; i < 100000; i++) { + try { f(i); } catch (e) { caught++; } + } + assert(caught == 50000); + print("done."); +} +``` + +Negative programs, one error each. The expected text is in the CMake list below. + +- `own_err_alias.ts`: `const a = [1]; const b = a; print(b.length);`. This is Review Focus 2. +- `own_err_field_alias.ts`: `class N { v: number[] = []; } const a = [1]; const n = new N(); n.v = a; print(a.length);`. +- `own_err_closure.ts`: `const a = [1]; const f = () => a.length; print(f());`. +- `own_err_unknown_producer.ts`: `const a = [1, 2, 3]; const b = a.slice(1); print(b.length);`. + This is Review Focus 5. If `slice` is not available without the default library, use any + array method `--no-default-lib` provides whose lowering calls a runtime helper, and write the + choice into the file's comment. +- `own_err_released_never_owned.ts`: the smallest program the "released but never owned" rule + rejects. Find it by writing the rc-balanced-but-unacquired shape Review Focus 1 describes. If + no program in the language reaches that shape, delete the rule from Task 5 rather than keep + untested code, and record why in the spec's §10. + +Every `main` wraps its body so it prints `done.`, as the corpus does. + +- [ ] **Step 1: Write the tests.** Add the files above, then this block to `test/tester/CMakeLists.txt`: + +```cmake +# -mm=own, phase 0 (docs/superpowers/plans/2026-09-28-own-phase-0.md). Positive programs run +# under JIT and ahead of time; each owns every block once. +foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local own_print_number) + 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. + add_test(NAME test-own-no-counting-${own_test} + COMMAND $ --emit=llvm --no-default-lib -mm=own + "${PROJECT_SOURCE_DIR}/test/tester/own/${own_test}.ts") + 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") +endforeach() + +# Each negative program has one shape phase 0 cannot prove, and must be rejected with this text. +set(own_error_cases + "own_err_alias|takes a second reference" + "own_err_field_alias|takes a second reference" + "own_err_closure|closures that capture a heap value are not supported" + "own_err_unknown_producer|takes a second reference" + "own_err_released_never_owned|is released but never owned") +foreach(own_error_case ${own_error_cases}) + string(FIND "${own_error_case}" "|" own_error_sep) + string(SUBSTRING "${own_error_case}" 0 ${own_error_sep} own_error_name) + math(EXPR own_error_sep "${own_error_sep} + 1") + string(SUBSTRING "${own_error_case}" ${own_error_sep} -1 own_error_message) + add_test(NAME test-own-err-${own_error_name} + COMMAND $ --emit=obj --no-default-lib -mm=own + "${PROJECT_SOURCE_DIR}/test/tester/own/${own_error_name}.ts" + -o "${CMAKE_CURRENT_BINARY_DIR}/own-err-${own_error_name}.obj") + set_tests_properties(test-own-err-${own_error_name} + PROPERTIES PASS_REGULAR_EXPRESSION "${own_error_message}" + FAIL_REGULAR_EXPRESSION "Stack dump|Assertion failed") +endforeach() +``` + +`tslang_add_test` creates an x86 twin. If the x86 twins fail for a reason unrelated to own (for +example an x86 runtime gap), add the own tests to `x86-exclusions.cmake` with a one-line reason. +Do not drop the x64 tests. + +- [ ] **Step 2: Run them.** `ctest -C Release -R "own"`. Expected: all pass. A positive test + that fails is the rule of Task 5 meeting a shape it did not expect. Dump its rc affine IR, + decide whether the shape is a birth take, and fix the rule or move the program to the + negative list. Never loosen the rule to "any retain of an allocating op". +- [ ] **Step 3: Teeth.** For each positive test, temporarily change Task 5 to keep every + retain (skip the `op->erase()` loop). Run the test and expect the backstop error. Then + temporarily re-emit one release in a positive program by hand: add `print(s)` after the + loop in `own_fresh_string.ts`, reading a destroyed slot. Expect a crash or garbage, not a + pass. Revert both, and write what each did into the commit message. +- [ ] **Step 4: Debug build.** Run the own tests in the Debug tree + (`__build/tslang/windows-msbuild-2026-debug`, `ctest -C Debug -R own`). The debug CRT's heap + checks catch a double free that Release lets through. Expected: pass. +- [ ] **Step 5: Full suite.** Run it in both trees. +- [ ] **Step 6: Commit.** Use the message "-mm=own phase-0 tests: positive, negative, and no + counting in the output". + +## Task 7: Measure, and record + +**Files:** + +- Create: `test/tester/tools/measure.ps1` +- Modify: `docs/superpowers/specs/2026-09-24-own-memory-model-design.md` (a §11 of results) + +The rc-era script did not survive; it lived in a scratchpad. Rebuild it to the recipe in the +rc work notes. For each model it builds a native exe with `--emit=obj` and `lld -flavor link`, +using the link line `createCompileBatchFile` writes (`test-runner.cpp:203-230`). It runs the +exe, samples `PeakWorkingSet64` in a tight loop with **no sleep** (the value reads 0 after exit), +and checks the exit code. The work directory must never sit beside the source. + +- [ ] **Step 1: Write the script.** Its parameters are `-Source -Models gc,rc,none,own`. + For each model it prints `model peakMB exitCode`, and it exits non-zero if any exit code is + non-zero. +- [ ] **Step 2: Measure.** Run the four positive loop programs under all four models. Expected: + own at or below rc, and flat. For `own_fresh_string`, rc held about 3.8 MB and none climbed + to about 172.8 MB. +- [ ] **Step 3: Record.** Add §11 "Phase 0 results" to the spec: the table, the date, and the + commit. Then update the status line. +- [ ] **Step 4: Commit.** Use the message "measure.ps1, and the phase-0 numbers for -mm=own". + +## After the plan + +Open one PR for `own-phase-0` with Tasks 2-7 as its commits, the same way the rc work's slices +were merged. Update the memory note `own-memory-model.md`. Phase 1 starts from spec §4.2 step 3, +replacing the birth-take rule with liveness-based moves. Its first input is Task 6's list of +programs the phase-0 rule rejected. 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 444ddbbe9..1fe2c5a0a 100644 --- a/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md +++ b/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md @@ -1,7 +1,8 @@ # `-mm=own`: a single-owner memory model Date: 2026-09-24. Status: design approved in conversation; written review 2026-09-28 (§10), -amendments folded in. Phase-0 plan pending the scope decision of §10.6. +amendments folded in. Phase 0 includes the birth take (§10.6); plan: +`docs/superpowers/plans/2026-09-28-own-phase-0.md`. ## 1. Purpose From 07e1b90c21964791221f004847c991b85cc851ca Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Tue, 29 Sep 2026 00:05:42 +0100 Subject: [PATCH 05/14] Check every phase-0 test program against rc affine IR Every positive and negative program was dumped under -mm=rc on #397 and the plan now states what each produces: make() returns +1 with no retain on either side; a throw out of a try-less function leaks its local in rc too; the alias test needs `let` (a folded const emits no retain); the unknown producer is a declared function (.slice needs the default library). Also the verified call-op names, the --di-only variable name, number-typed accumulators, and the isHandOver move spelled out. Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-09-28-own-phase-0.md | 95 ++++++++++++------- 1 file changed, 62 insertions(+), 33 deletions(-) diff --git a/tslang/docs/superpowers/plans/2026-09-28-own-phase-0.md b/tslang/docs/superpowers/plans/2026-09-28-own-phase-0.md index 97ebbfbc6..ee6c17214 100644 --- a/tslang/docs/superpowers/plans/2026-09-28-own-phase-0.md +++ b/tslang/docs/superpowers/plans/2026-09-28-own-phase-0.md @@ -51,20 +51,30 @@ arithmetic. A retain that survives to lowering is a backstop error. latent rc over-release becomes a use-after-free. Task 5's pass reports a `ts.Release`, `ts.ReleaseSlot` or `ts.ReleaseCell` whose value or slot has no acquisition in the function and no birth (see "released but never taken" in Task 5). Task 6 has a negative test for it. -2. **A fresh value with two owners.** `const a = [1]; const b = a;` has a `ts.Variable` for `b` - whose initializer is a load of `a`, and rc retains that load. The retain's operand is not - fresh, so it is an error. Task 6 has this as a negative test, and it must never compile. -3. **A value that leaves the function.** `return new C()` retains before returning, which is - rc's retaining-return convention. The caller consumes the result through `__owned_result`. - Both halves must stay balanced: Task 6's `own_return_new.ts` returns a fresh instance 1M - times and is measured flat. -4. **Unwind paths.** A birth-taken local released on the unwind edge of a `try` must be released - once on each path, not twice on the path that throws. Task 6's `own_try_local.ts` throws - through a function holding a fresh array and is run 100k times under the measuring script. -5. **An unknown producer.** A heap value returned by a runtime helper, or by an imported - function, has no `__owned_result` and is not an allocating op, so it is not fresh. A retain of - it must be an error, never a birth take. Task 6's negative test `own_err_unknown_producer.ts` - uses `.slice()` on an array (a runtime-helper result) and holds it in a local. +2. **A fresh value with two owners.** `let a: number[] = [1]; let b = a;` gives `b` a + `ts.Variable` whose initializer is a load of `a`, plus a `RetainSlot`. That operand is not + fresh, so it is an error. It has to be `let`: with `const b = a`, `b` is folded into + `a`'s SSA value and there is no retain at all. That is a borrow rc also leaves uncounted, + and it is safe for as long as `a` lives. Task 6's `own_err_alias.ts` uses `let`, and it must + never compile. +3. **A value that leaves the function.** Checked on #397: `make()` stores the + `ts.SymbolCallInternal @Point..new` result into its return slot and returns it with no + retain. `main` holds the result as a folded `const` with no retain and one `ts.Release`. + `Point..new` itself is `ts.New` plus one `ts.Retain`, a single acquisition that serves as + both birth and retaining return. So the whole program is birth takes and plain releases. + Task 6's `own_return_new.ts` runs this 1M times and is measured flat. +4. **A throw out of a function with no `try`.** Checked on #397: in `f`, which holds a fresh + array and throws, the throw path (`ts.ThrowCall`) has no release for the array. rc leaks it + too. Own must not turn this into anything worse: no release means no destroy. Task 6's + `own_try_local.ts` runs it 100k times, and Task 7 records the leak it shares with rc (about + 50k arrays). The genuine unwind-edge case is an owned local *inside* a `try`. Its storage is + hoisted, so phase 0 rejects it (see Task 5). +5. **An unknown producer.** A heap value from a `declare`d or imported function, or a runtime + helper, has no `__owned_result` and is not an allocating op, so it is not fresh. Checked on + #397: `const b = makeNumbers()` for `declare function makeNumbers(): number[]` is a + `ts.SymbolCallInternal` with no attribute, then a `RetainSlot`. That must be an error, never + a birth take. It is Task 6's `own_err_unknown_producer.ts`. The test compiles to `.obj` only, + so the missing definition is never linked. --- @@ -566,17 +576,23 @@ class OwnershipInferencePass : public mlir::PassWrapperhasAttr(OWNED_RESULT_ATTR_NAME) && - mlir::isa(def) + mlir::isa(def) ? 1 : 0; } static llvm::StringRef varName(mlir_ts::VariableOp varOp) { - // the name MLIRGen gave the declaration, when it kept one - if (auto nameLoc = mlir::dyn_cast(varOp.getLoc())) + // The declaration's name survives only as debug metadata (`--di`), the way + // LowerToLLVM's VariableOp lowering reads it. Without it the error still points at the + // declaration. + if (auto fused = mlir::dyn_cast>(varOp.getLoc())) { - return nameLoc.getName().getValue(); + if (auto name = fused.getMetadata().getName()) + { + return name.getValue(); + } } return "this value"; @@ -608,11 +624,17 @@ std::unique_ptr mlir_ts::createOwnershipInferencePass() } ``` -Before compiling, check three names against `TypeScriptOps.td` on the branch, and fix any that -differ: the call ops' C++ class names, `VariableOp::getInitializer()`, and how MLIRGen attaches -a variable's name to its location (`NameLoc` or a `FusedLoc` with metadata; see -`LowerToLLVM.cpp:2370-2380`). If the name is not recoverable, the message says "this value" and -the location still points at the declaration. +The names above were checked on #397. The call ops are `TypeScript_CallOp`, `CallIndirectOp`, +`SymbolCallInternalOp`, `CallInternalOp` and `CallHybridInternalOp` in `TypeScriptOps.td`, and +a direct call to a local function reaches the affine level as `ts.SymbolCallInternal`. +`VariableOp::getInitializer()` is used by the lowering. A variable's name is in a +`FusedLocWith` only under `--di` (`LowerToLLVM.cpp:2366`). The pass +needs `#include "mlir/Dialect/LLVMIR/LLVMAttrs.h"` for it. + +Moving `isHandOver` into `include/TypeScript/OwnershipOps.h` means a free +`inline bool isHandOver(mlir_ts::ReleaseSlotOp)` in namespace `mlir::typescript`, with its +comment moved along with it. `OwnershipVerifierPass`'s call site changes from the private static +to `mlir_ts::isHandOver(releaseOp)`, and the static is deleted. In `Passes.h`, beside the verifier: @@ -670,12 +692,12 @@ function main() { ```ts function sum(a: number[]) { - let t = 0; + let t: number = 0; for (const v of a) t += v; return t; } function main() { - let total = 0; + let total: number = 0; for (let i = 0; i < 1000000; i++) { const a: number[] = [i, i + 1, i + 2]; total += sum(a); @@ -694,7 +716,7 @@ class Point { } function make(i: number) { return new Point(i, i); } function main() { - let t = 0; + let t: number = 0; for (let i = 0; i < 1000000; i++) { const p = make(i % 10); t += p.len2(); @@ -724,13 +746,20 @@ function main() { Negative programs, one error each. The expected text is in the CMake list below. -- `own_err_alias.ts`: `const a = [1]; const b = a; print(b.length);`. This is Review Focus 2. -- `own_err_field_alias.ts`: `class N { v: number[] = []; } const a = [1]; const n = new N(); n.v = a; print(a.length);`. -- `own_err_closure.ts`: `const a = [1]; const f = () => a.length; print(f());`. -- `own_err_unknown_producer.ts`: `const a = [1, 2, 3]; const b = a.slice(1); print(b.length);`. - This is Review Focus 5. If `slice` is not available without the default library, use any - array method `--no-default-lib` provides whose lowering calls a runtime helper, and write the - choice into the file's comment. +Each shape below was checked against `--emit=mlir-affine -mm=rc` on #397. The rejected op +is named after the shape. + +- `own_err_alias.ts`: `let a: number[] = [1]; let b = a; print(b.length);`. `RetainSlot(b)` + has a load as its initializer. This is Review Focus 2. +- `own_err_field_alias.ts`: `class N { v: number[] = []; }`, then in `main` + `const a: number[] = [1]; const n = new N(); n.v = a; print(a.length);`. The rejected op is + the `ts.Retain` of the load of `a` ahead of the field store. +- `own_err_closure.ts`: `const a: number[] = [1]; const f = () => a.length; print(f());`. The + rejected op is `ts.RetainCell` on `a`'s cell. +- `own_err_unknown_producer.ts`: `declare function makeNumbers(): number[];`, then in `main` + `const b = makeNumbers(); print(b.length);`. The rejected op is `RetainSlot(b)`, initialized + by an unmarked call. This is Review Focus 5. (`.slice()` does not resolve under + `--no-default-lib`.) - `own_err_released_never_owned.ts`: the smallest program the "released but never owned" rule rejects. Find it by writing the rc-balanced-but-unacquired shape Review Focus 1 describes. If no program in the language reaches that shape, delete the rule from Task 5 rather than keep From ff023059dd02475a814e69ed4eb3fd9100627f90 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Tue, 29 Sep 2026 00:15:28 +0100 Subject: [PATCH 06/14] The -mm=own memory model exists: flag, name, tracksOwnership() Co-Authored-By: Claude Opus 5.5 --- tslang/include/TypeScript/DataStructs.h | 8 ++++++++ .../include/TypeScript/TypeScriptCompiler/Defines.h | 8 +++++++- tslang/test/tester/CMakeLists.txt | 12 +++++++++++- tslang/test/tester/test-runner.cpp | 6 +++--- tslang/tslang/tslang.cpp | 1 + 5 files changed, 30 insertions(+), 5 deletions(-) diff --git a/tslang/include/TypeScript/DataStructs.h b/tslang/include/TypeScript/DataStructs.h index c029221f8..3204ee881 100644 --- a/tslang/include/TypeScript/DataStructs.h +++ b/tslang/include/TypeScript/DataStructs.h @@ -73,6 +73,14 @@ struct CompileOptions { return memoryModel == MemoryModelRC; } + + // Whether the ownership operations are live and lower to code - retains and releases under + // `rc`, releases that destroy under `own`. Where only the count itself matters, the + // question is isRefCounted(). + bool tracksOwnership() const + { + return memoryModel == MemoryModelRC || memoryModel == MemoryModelOwn; + } }; #endif // TYPESCRIPT_DATASTRUCT_H_ \ No newline at end of file diff --git a/tslang/include/TypeScript/TypeScriptCompiler/Defines.h b/tslang/include/TypeScript/TypeScriptCompiler/Defines.h index 7878bc096..4ad572b34 100644 --- a/tslang/include/TypeScript/TypeScriptCompiler/Defines.h +++ b/tslang/include/TypeScript/TypeScriptCompiler/Defines.h @@ -30,7 +30,11 @@ enum MemoryModel // covered - leaks rather than being swept up. See sections 9.6 and 9.28. MemoryModelRC, // No reclamation at all. - MemoryModelNone + MemoryModelNone, + // Single ownership, inferred at compile time: every mortal block has exactly one owner, + // which destroys it. No count is kept; a program whose ownership cannot be proven is a + // compile error. See docs/superpowers/specs/2026-09-24-own-memory-model-design.md. + MemoryModelOwn }; // The spelling used both by the `-mm=` flag and by the shared-library marker symbol, so the @@ -43,6 +47,8 @@ inline const char *memoryModelName(enum MemoryModel model) return "rc"; case MemoryModelNone: return "none"; + case MemoryModelOwn: + return "own"; default: return "gc"; } diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 5bfed70f5..cd7d1b621 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -161,7 +161,11 @@ file(REMOVE "${CMAKE_CURRENT_BINARY_DIR}/compilenone.bat" "${CMAKE_CURRENT_BINARY_DIR}/jitnone.bat" "${CMAKE_CURRENT_BINARY_DIR}/compilednone.bat" "${CMAKE_CURRENT_BINARY_DIR}/jitdnone.bat" "${CMAKE_CURRENT_BINARY_DIR}/compilenone.sh" "${CMAKE_CURRENT_BINARY_DIR}/jitnone.sh" - "${CMAKE_CURRENT_BINARY_DIR}/compilednone.sh" "${CMAKE_CURRENT_BINARY_DIR}/jitdnone.sh") + "${CMAKE_CURRENT_BINARY_DIR}/compilednone.sh" "${CMAKE_CURRENT_BINARY_DIR}/jitdnone.sh" + "${CMAKE_CURRENT_BINARY_DIR}/compileown.bat" "${CMAKE_CURRENT_BINARY_DIR}/jitown.bat" + "${CMAKE_CURRENT_BINARY_DIR}/compiledown.bat" "${CMAKE_CURRENT_BINARY_DIR}/jitdown.bat" + "${CMAKE_CURRENT_BINARY_DIR}/compileown.sh" "${CMAKE_CURRENT_BINARY_DIR}/jitown.sh" + "${CMAKE_CURRENT_BINARY_DIR}/compiledown.sh" "${CMAKE_CURRENT_BINARY_DIR}/jitdown.sh") # and the 32-bit variants (`-x86`), every one of which ends in "x86" file(GLOB TSLANG_X86_CACHED_SCRIPTS "${CMAKE_CURRENT_BINARY_DIR}/compile*x86.bat") if (TSLANG_X86_CACHED_SCRIPTS) @@ -2355,6 +2359,12 @@ add_test(NAME test-compile-lowering-error-exit -o "${CMAKE_CURRENT_BINARY_DIR}/lowering-error-exit.obj") set_tests_properties(test-compile-lowering-error-exit PROPERTIES WILL_FAIL TRUE) +# -mm=own is accepted. Phase 0: a program that allocates nothing compiles and runs under it. +add_test(NAME test-own-flag-accepted + COMMAND $ --emit=mlir --no-default-lib -mm=own + "${PROJECT_SOURCE_DIR}/test/tester/tests/00assert.ts") +set_tests_properties(test-own-flag-accepted PROPERTIES FAIL_REGULAR_EXPRESSION "error|Stack dump") + # @dllname and @linkname are one decorator under two names, and a symbol is bound once: each # case is a program the compiler must reject with the given message instead of binding # something else or making the name unique. diff --git a/tslang/test/tester/test-runner.cpp b/tslang/test/tester/test-runner.cpp index dad3057cf..70be76a15 100644 --- a/tslang/test/tester/test-runner.cpp +++ b/tslang/test/tester/test-runner.cpp @@ -782,12 +782,12 @@ void readParams(int argc, char **argv, std::vector &files) fastMath = true; tslang_opt_ext += " --fast-math"; } - // `-mm=gc` is accepted as well as the two that change behaviour, and it is not a no-op: - // it names the default explicitly, which is what anyone comparing the three models types. + // `-mm=gc` is accepted as well as the three that change behaviour, and it is not a no-op: + // it names the default explicitly, which is what anyone comparing the models types. // It gets its own cached script like the others - the suffix is what keeps two runners // with different flags from sharing one - so passing it costs a script and nothing else. else if (std::string(argv[index]) == "-mm=gc" || std::string(argv[index]) == "-mm=rc" || - std::string(argv[index]) == "-mm=none") + std::string(argv[index]) == "-mm=none" || std::string(argv[index]) == "-mm=own") { memoryModel = std::string(argv[index]).substr(4); tslang_opt_ext += " "; diff --git a/tslang/tslang/tslang.cpp b/tslang/tslang/tslang.cpp index 08f71b115..220632dfb 100644 --- a/tslang/tslang/tslang.cpp +++ b/tslang/tslang/tslang.cpp @@ -127,6 +127,7 @@ cl::opt memoryModelOpt("mm", cl::desc("Memory management of co cl::values(clEnumValN(MemoryModelGC, "gc", "garbage collection (default)")), cl::values(clEnumValN(MemoryModelRC, "rc", "reference counting, no collector (in development; cycles and anything the counts miss leak)")), cl::values(clEnumValN(MemoryModelNone, "none", "no reclamation, leak everything")), + cl::values(clEnumValN(MemoryModelOwn, "own", "single ownership inferred at compile time, no counting (in development; ownership that cannot be proven is a compile error)")), cl::init(MemoryModelGC), cl::cat(TypeScriptCompilerCategory)); cl::opt disableWarnings("nowarn", cl::desc("Disable Warnings"), cl::cat(TypeScriptCompilerCategory)); cl::opt verifyOwnership("verify-ownership", cl::desc("Check that every slot taking a reference gives it back on every path out of the function, unwind paths included"), cl::cat(TypeScriptCompilerCategory)); From 49ad135b89c9e40154d3be80491f04555f7db78a Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Tue, 29 Sep 2026 00:18:47 +0100 Subject: [PATCH 07/14] Audit isRefCounted(): what own shares with rc is tracksOwnership() Sixteen of the 21 sites write something a release will read, or emit the ownership ops the inference pass judges, and become tracksOwnership(). Three retain lowerings and the captured-parameter retain are Task 4's; a cell's birth retain stays rc only. Co-Authored-By: Claude Opus 5.5 --- .../TypeScript/LowerToLLVM/CastLogicHelper.h | 2 +- .../TypeScript/LowerToLLVM/LLVMCodeHelper.h | 4 ++-- .../TypeScript/LowerToLLVM/LLVMCodeHelperBase.h | 10 +++++++--- .../LowerToLLVM/OwnershipRoutineLogic.h | 2 +- tslang/lib/TypeScript/LowerToLLVM.cpp | 15 ++++++++------- tslang/lib/TypeScript/MLIRGenExpressions.cpp | 4 ++-- tslang/lib/TypeScript/MLIRGenImpl.h | 4 ++-- tslang/test/tester/CMakeLists.txt | 8 ++++++++ tslang/test/tester/own-mlirgen-matches-rc.cmake | 12 ++++++++++++ tslang/test/tester/own/own_mlirgen_delete.ts | 6 ++++++ 10 files changed, 49 insertions(+), 18 deletions(-) create mode 100644 tslang/test/tester/own-mlirgen-matches-rc.cmake create mode 100644 tslang/test/tester/own/own_mlirgen_delete.ts diff --git a/tslang/include/TypeScript/LowerToLLVM/CastLogicHelper.h b/tslang/include/TypeScript/LowerToLLVM/CastLogicHelper.h index 755a39fa1..fce51a20f 100644 --- a/tslang/include/TypeScript/LowerToLLVM/CastLogicHelper.h +++ b/tslang/include/TypeScript/LowerToLLVM/CastLogicHelper.h @@ -1087,7 +1087,7 @@ class CastLogicHelper // pointer's block header is undefined behaviour. An iterator's final // `{ value: undefined, done: true }` is built exactly this way, and the caller // retains the result before it looks at `done`. - if (compileOptions.isRefCounted()) + if (compileOptions.tracksOwnership()) { return rewriter.create(loc, llvmRtArrayStructType).getResult(); } diff --git a/tslang/include/TypeScript/LowerToLLVM/LLVMCodeHelper.h b/tslang/include/TypeScript/LowerToLLVM/LLVMCodeHelper.h index 5f76a081a..83c734370 100644 --- a/tslang/include/TypeScript/LowerToLLVM/LLVMCodeHelper.h +++ b/tslang/include/TypeScript/LowerToLLVM/LLVMCodeHelper.h @@ -552,7 +552,7 @@ class LLVMCodeHelper : public LLVMCodeHelperBase // can be bound to a local that takes a reference, so the same header word has to sit in // front of the data, marked immortal - exactly as it does for a string literal. Without // it a retain reads, and a release writes, the word before a read-only global. - auto withHeader = compileOptions.isRefCounted(); + auto withHeader = compileOptions.tracksOwnership(); auto headerSize = getHeapBlockHeaderSize(); // Create the global at the entry of the module. @@ -722,7 +722,7 @@ class LLVMCodeHelper : public LLVMCodeHelperBase // field (a generator's state object is built exactly this way), and walking an // undef pointer to reach its header is undefined behaviour, which the optimizer // is entitled to - and does - fold the whole caller away for. - auto unspecifiedFieldIsOwning = compileOptions.isRefCounted() && + auto unspecifiedFieldIsOwning = compileOptions.tracksOwnership() && MLIRTypeHelper(rewriter.getContext(), compileOptions).ownsHeapMemory(loc, type); mlir::Value itemValue = unspecifiedFieldIsOwning diff --git a/tslang/include/TypeScript/LowerToLLVM/LLVMCodeHelperBase.h b/tslang/include/TypeScript/LowerToLLVM/LLVMCodeHelperBase.h index b3ecab79a..13cde311f 100644 --- a/tslang/include/TypeScript/LowerToLLVM/LLVMCodeHelperBase.h +++ b/tslang/include/TypeScript/LowerToLLVM/LLVMCodeHelperBase.h @@ -381,7 +381,7 @@ class LLVMCodeHelperBase } } - if (compileOptions.isRefCounted()) + if (compileOptions.tracksOwnership()) { // The block starts *unowned*. Whoever first takes it - a local's declaration, a // field or element store, a literal capturing it, a push - is what brings the count @@ -398,9 +398,13 @@ class LLVMCodeHelperBase // HEAP_BLOCK_IMMORTAL, so the block leaks instead of being freed out from under a // live reference. // + // Under `-mm=own` there is no count, and the same 0 only says "mortal": the free path + // reads the word to skip immortal blocks, and a native `malloc` block's header is + // otherwise whatever the allocator left there (the zeroing above is wasm only). + // // Written after the zeroing above, which covers the header along with the payload. - // Only under `-mm=rc` -- under `gc` nothing reads the word, and a store per - // allocation on the hot path is not worth paying for dead code. + // Only under `-mm=rc` and `-mm=own` -- under `gc` nothing reads the word, and a store + // per allocation on the hot path is not worth paying for dead code. rewriter.create( loc, rewriter.create(loc, llvmIndexType, rewriter.getIntegerAttr(llvmIndexType, 0)), blockPtr); diff --git a/tslang/include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h b/tslang/include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h index 759c27b11..55f51477d 100644 --- a/tslang/include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h +++ b/tslang/include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h @@ -322,7 +322,7 @@ class OwnershipRoutineLogic void emitReleaseArrayElements(mlir::Type elementType, mlir::Value dataPtr, mlir::Value startIndex, mlir::Value count) { - if (!compileOptions.isRefCounted()) + if (!compileOptions.tracksOwnership()) { return; } diff --git a/tslang/lib/TypeScript/LowerToLLVM.cpp b/tslang/lib/TypeScript/LowerToLLVM.cpp index f43c85003..696778ba4 100644 --- a/tslang/lib/TypeScript/LowerToLLVM.cpp +++ b/tslang/lib/TypeScript/LowerToLLVM.cpp @@ -439,7 +439,7 @@ class ReleaseOpLowering : public TsLlvmPattern LogicalResult matchAndRewrite(mlir_ts::ReleaseOp op, Adaptor transformed, ConversionPatternRewriter &rewriter) const final { - if (tsLlvmContext->compileOptions.isRefCounted()) + if (tsLlvmContext->compileOptions.tracksOwnership()) { OwnershipRoutineLogic orl(op, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); orl.emitReleaseValue(op.getReference().getType(), transformed.getReference()); @@ -479,7 +479,7 @@ class ReleaseSlotOpLowering : public TsLlvmPattern LogicalResult matchAndRewrite(mlir_ts::ReleaseSlotOp op, Adaptor transformed, ConversionPatternRewriter &rewriter) const final { - if (tsLlvmContext->compileOptions.isRefCounted()) + if (tsLlvmContext->compileOptions.tracksOwnership()) { OwnershipRoutineLogic orl(op, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); orl.emitReleaseSlot(cast(op.getSlot().getType()).getElementType(), transformed.getSlot()); @@ -519,7 +519,7 @@ class ReleaseCellOpLowering : public TsLlvmPattern LogicalResult matchAndRewrite(mlir_ts::ReleaseCellOp op, Adaptor transformed, ConversionPatternRewriter &rewriter) const final { - if (tsLlvmContext->compileOptions.isRefCounted()) + if (tsLlvmContext->compileOptions.tracksOwnership()) { OwnershipRoutineLogic orl(op, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); orl.emitReleaseCell(cast(op.getSlot().getType()).getElementType(), @@ -648,7 +648,7 @@ class SetLengthOfOpLowering : public TsLlvmPattern // // Only where an element owns something, and only under -mm=rc: nothing reads an // unwritten slot in the other models, and the memset is not free. - if (tsLlvmContext->compileOptions.isRefCounted()) + if (tsLlvmContext->compileOptions.tracksOwnership()) { MLIRTypeHelper mth(rewriter.getContext(), tsLlvmContext->compileOptions); if (mth.ownsHeapMemory(loc, elementType)) @@ -1484,7 +1484,7 @@ class UndefOpLowering : public TsLlvmPattern // reaching an undef pointer's block header is undefined behaviour. An iterator's final // `{ value: undefined, done: true }` is built exactly this way, and the caller retains // the result before it looks at `done`. - if (tsLlvmContext->compileOptions.isRefCounted()) + if (tsLlvmContext->compileOptions.tracksOwnership()) { MLIRTypeHelper mth(rewriter.getContext(), tsLlvmContext->compileOptions); if (mth.ownsHeapMemory(op.getLoc(), op.getType())) @@ -2406,6 +2406,7 @@ struct VariableOpLowering : public TsLlvmPattern // is born unowned because a receiver is about to take it (§9.24), a cell is born // owned: the frame is its first owner, and the frame's scope exit is what gives that // reference back. A box is the one heap variable with no frame owner, and says so. + // Under own the frame owns the cell outright; there is no count to start at one. if (tsLlvmContext->compileOptions.isRefCounted() && !varOp->hasAttr(CAPTURE_BOX_ATTR_NAME)) { OwnershipRoutineLogic orl(varOp, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); @@ -2447,7 +2448,7 @@ struct VariableOpLowering : public TsLlvmPattern auto value = transformed.getInitializer(); auto isUnwrittenCell = isCaptured && !varOp->hasAttr(CAPTURE_BOX_ATTR_NAME); - if (!value && tsLlvmContext->compileOptions.isRefCounted() && + if (!value && tsLlvmContext->compileOptions.tracksOwnership() && (varOp->hasAttr(OWNED_LOCAL_ATTR_NAME) || isUnwrittenCell)) { // An owned local with no initializer here is one whose storage was hoisted out in @@ -3427,7 +3428,7 @@ struct DeleteOpLowering : public TsLlvmPattern // Under reference counting `delete` drops a reference rather than freeing outright: // the object goes only if this was the last one, and what it owns is released with it. // That also keeps `delete` off an immortal block, which a bare free would not. - if (tsLlvmContext->compileOptions.isRefCounted()) + if (tsLlvmContext->compileOptions.tracksOwnership()) { OwnershipRoutineLogic orl(deleteOp, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); orl.emitReleaseValue(deleteOp.getReference().getType(), transformed.getReference()); diff --git a/tslang/lib/TypeScript/MLIRGenExpressions.cpp b/tslang/lib/TypeScript/MLIRGenExpressions.cpp index 052b6b73b..b96cfd679 100644 --- a/tslang/lib/TypeScript/MLIRGenExpressions.cpp +++ b/tslang/lib/TypeScript/MLIRGenExpressions.cpp @@ -472,7 +472,7 @@ namespace mlirgen // Same two cases as a local's declaration: a value that already carries a // reference hands it over, anything else is retained. Either way the slot // is the owner from here, and the awaiting scope gives that reference back. - if (compileOptions.isRefCounted() && mth.ownsHeapMemory(location, resultType)) + if (compileOptions.tracksOwnership() && mth.ownsHeapMemory(location, resultType)) { if (producesOwnedReference(value)) { @@ -1191,7 +1191,7 @@ namespace mlirgen // wherever the allocator has since put that memory. Whether that faults depends on what // was allocated in between, which is why `00class_static.ts` needed two `print` calls // between the delete and the end of the function to show it. - if (compileOptions.isRefCounted()) + if (compileOptions.tracksOwnership()) { // `delete new C()` and `delete c`, where `c` is a `const` the compiler kept as a // value rather than storage: the reference is one nobody has claimed, and §9.30 diff --git a/tslang/lib/TypeScript/MLIRGenImpl.h b/tslang/lib/TypeScript/MLIRGenImpl.h index 866ade055..0b50f9439 100644 --- a/tslang/lib/TypeScript/MLIRGenImpl.h +++ b/tslang/lib/TypeScript/MLIRGenImpl.h @@ -1858,7 +1858,7 @@ class MLIRGenImpl // reference the object will later give back. The store is where it has to happen: // this is the object's field gaining a value, which is the same debt `obj.f = x` // carries. See docs/reference-counting-evaluation.md section 9.50. - if (variableDeclarationInfo.allocateInContextThis && compileOptions.isRefCounted() && + if (variableDeclarationInfo.allocateInContextThis && compileOptions.tracksOwnership() && mth.ownsHeapMemory(location, variableDeclarationInfo.type)) { if (producesOwnedReference(variableDeclarationInfo.initial)) @@ -9516,7 +9516,7 @@ class MLIRGenImpl // the bound function (§9.33); this is the object-shaped half of that, and the half a // generator's state object needs, since a generator is an object literal the compiler // wrote. See docs/reference-counting-evaluation.md section 9.52. - if (compileOptions.isRefCounted() && capturedValue) + if (compileOptions.tracksOwnership() && capturedValue) { builder.create(location, capturedValue); } diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index cd7d1b621..14b0f6c66 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2365,6 +2365,14 @@ add_test(NAME test-own-flag-accepted "${PROJECT_SOURCE_DIR}/test/tester/tests/00assert.ts") set_tests_properties(test-own-flag-accepted PROPERTIES FAIL_REGULAR_EXPRESSION "error|Stack dump") +# Under own MLIRGen emits exactly what it emits under rc: an awaited, deleted or captured value +# still carries its ownership ops for the inference pass to judge. Checked on `delete`, whose +# claim (MLIRGenExpressions.cpp) is one of the four MLIRGen sites. +add_test(NAME test-own-mlirgen-matches-rc + COMMAND ${CMAKE_COMMAND} -DTSLANG=$ + "-DFILE=${PROJECT_SOURCE_DIR}/test/tester/own/own_mlirgen_delete.ts" + -P "${CMAKE_CURRENT_SOURCE_DIR}/own-mlirgen-matches-rc.cmake") + # @dllname and @linkname are one decorator under two names, and a symbol is bound once: each # case is a program the compiler must reject with the given message instead of binding # something else or making the name unique. diff --git a/tslang/test/tester/own-mlirgen-matches-rc.cmake b/tslang/test/tester/own-mlirgen-matches-rc.cmake new file mode 100644 index 000000000..19238213b --- /dev/null +++ b/tslang/test/tester/own-mlirgen-matches-rc.cmake @@ -0,0 +1,12 @@ +# Under own MLIRGen must emit exactly what it emits under rc. Compares the two `--emit=mlir` +# outputs after erasing the one word that is allowed to differ, the model name in the marker. +cmake_minimum_required(VERSION 3.17.3) +foreach(model rc own) + execute_process(COMMAND "${TSLANG}" --emit=mlir --no-default-lib -mm=${model} "${FILE}" + OUTPUT_VARIABLE out_${model} ERROR_VARIABLE err_${model} RESULT_VARIABLE rc_${model}) + string(APPEND out_${model} "${err_${model}}") + string(REPLACE "__tsmm_${model}_" "__tsmm_MODEL_" out_${model} "${out_${model}}") +endforeach() +if(NOT out_rc STREQUAL out_own) + message(FATAL_ERROR "MLIRGen output differs between -mm=rc and -mm=own:\n--- rc\n${out_rc}\n--- own\n${out_own}") +endif() diff --git a/tslang/test/tester/own/own_mlirgen_delete.ts b/tslang/test/tester/own/own_mlirgen_delete.ts new file mode 100644 index 000000000..8bddaec24 --- /dev/null +++ b/tslang/test/tester/own/own_mlirgen_delete.ts @@ -0,0 +1,6 @@ +class C { v = 1; } +function main() { + let c = new C(); + delete c; + print("done."); +} From 6459bc433acfa792515ce2bf1c06edfccff06ff1 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Tue, 29 Sep 2026 00:22:46 +0100 Subject: [PATCH 08/14] Lowering under own: releases destroy without counting; a surviving retain is an error Under -mm=own emitIfLastReference tests the header against HEAP_BLOCK_IMMORTAL instead of decrementing it, so a release is one load and a compare. A retain that reaches LLVM lowering is an error, never erased; a captured variable holding a heap value is an error (closures are phase 5); and a type descriptor carries no retain routine, since retainViaDescriptor is reached only from retain routines. emitIncRef's other callers (emitRetainCell, buildCaptureBoxRoutine(retaining), buildRetainBody) are all retain paths. --own-skip-inference (hidden) lets the backstop be tested. Co-Authored-By: Claude Opus 5.5 --- .../LowerToLLVM/OwnershipRoutineLogic.h | 23 ++- tslang/lib/TypeScript/LowerToLLVM.cpp | 40 ++++- tslang/test/tester/CMakeLists.txt | 9 ++ tslang/test/tester/own/own_print_number.ll | 145 ++++++++++++++++++ tslang/test/tester/own/own_print_number.ts | 4 + tslang/tslang/transform.cpp | 1 + tslang/tslang/tslang.cpp | 1 + 7 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 tslang/test/tester/own/own_print_number.ll create mode 100644 tslang/test/tester/own/own_print_number.ts diff --git a/tslang/include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h b/tslang/include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h index 55f51477d..1a6394613 100644 --- a/tslang/include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h +++ b/tslang/include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h @@ -588,8 +588,27 @@ class OwnershipRoutineLogic ValueRange{payloadPtr}); } + // Is this block mortal - that is, not HEAP_BLOCK_IMMORTAL? Inline rather than a helper: it + // is one load and one compare, and it is the whole of what a release costs under own. + mlir::Value emitIsMortal(mlir::Value payloadPtr) + { + TypeConverterHelper tch(typeConverter); + TypeHelper th(rewriter); + LLVMCodeHelperBase ch(op, rewriter, typeConverter, compileOptions); + auto loc = op->getLoc(); + + auto llvmIndexType = tch.convertType(th.getIndexType()); + auto blockPtr = ch.getBlockPtrFromPayloadPtr(loc, payloadPtr, llvmIndexType); + auto header = rewriter.create(loc, llvmIndexType, blockPtr); + auto immortal = rewriter.create( + loc, llvmIndexType, rewriter.getIntegerAttr(llvmIndexType, HEAP_BLOCK_IMMORTAL)); + return rewriter.create(loc, LLVM::ICmpPredicate::ne, header, immortal); + } + // Runs `thenBody` -- the destroy half: release what the value owns, then free it -- only - // when `payloadPtr` is non-null and the reference being dropped was the last one. + // when `payloadPtr` is non-null and the reference being dropped was the last one. Under + // own there is no count: the one owner is giving the block up, so it goes unless it is + // immortal (a literal, a `typeof` tag). void emitIfLastReference(mlir::Value payloadPtr, llvm::function_ref thenBody) { TypeHelper th(rewriter); @@ -605,7 +624,7 @@ class OwnershipRoutineLogic rewriter.create(loc, ValueRange{}, continuationBlock); rewriter.setInsertionPointToEnd(decBlock); - auto wasLast = emitDecRef(payloadPtr); + auto wasLast = compileOptions.memoryModel == MemoryModelOwn ? emitIsMortal(payloadPtr) : emitDecRef(payloadPtr); rewriter.create(loc, wasLast, thenBlock, continuationBlock); rewriter.setInsertionPointToEnd(currentBlock); diff --git a/tslang/lib/TypeScript/LowerToLLVM.cpp b/tslang/lib/TypeScript/LowerToLLVM.cpp index 696778ba4..b671a5b2c 100644 --- a/tslang/lib/TypeScript/LowerToLLVM.cpp +++ b/tslang/lib/TypeScript/LowerToLLVM.cpp @@ -398,7 +398,12 @@ class TypeDescriptorOpLowering : public TsLlvmPattern // symbol has to exist before the global is built OwnershipRoutineLogic orl(op, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); auto releaseRoutineName = orl.getOrCreateReleaseRoutine(descriptorType); - auto retainRoutineName = orl.getOrCreateRetainRoutine(descriptorType); + // Under own nothing retains: the descriptor's retain slot is read only from inside + // retain routines (retainViaDescriptor), none of which can run, and building one would + // bring `__tslang_inc_ref` into a model that promises it is never referenced. + auto retainRoutineName = tsLlvmContext->compileOptions.memoryModel == MemoryModelOwn + ? std::string() + : orl.getOrCreateRetainRoutine(descriptorType); rewriter.replaceOp(op, ch.getOrCreateTypeDescriptorName(descriptorType, name, TypeOfOpHelper::typeKindFromName(name), @@ -420,6 +425,13 @@ class RetainOpLowering : public TsLlvmPattern LogicalResult matchAndRewrite(mlir_ts::RetainOp op, Adaptor transformed, ConversionPatternRewriter &rewriter) const final { + // Under own, ownership inference erased every retain it could prove, and reported the + // rest; one that reaches here was neither, and erasing it would ship a double free. + if (tsLlvmContext->compileOptions.memoryModel == MemoryModelOwn) + { + return op.emitError("ownership inference left a retain behind"); + } + if (tsLlvmContext->compileOptions.isRefCounted()) { OwnershipRoutineLogic orl(op, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); @@ -460,6 +472,13 @@ class RetainSlotOpLowering : public TsLlvmPattern LogicalResult matchAndRewrite(mlir_ts::RetainSlotOp op, Adaptor transformed, ConversionPatternRewriter &rewriter) const final { + // Under own, ownership inference erased every retain it could prove, and reported the + // rest; one that reaches here was neither, and erasing it would ship a double free. + if (tsLlvmContext->compileOptions.memoryModel == MemoryModelOwn) + { + return op.emitError("ownership inference left a retain behind"); + } + if (tsLlvmContext->compileOptions.isRefCounted()) { OwnershipRoutineLogic orl(op, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); @@ -500,6 +519,13 @@ class RetainCellOpLowering : public TsLlvmPattern LogicalResult matchAndRewrite(mlir_ts::RetainCellOp op, Adaptor transformed, ConversionPatternRewriter &rewriter) const final { + // Under own, ownership inference erased every retain it could prove, and reported the + // rest; one that reaches here was neither, and erasing it would ship a double free. + if (tsLlvmContext->compileOptions.memoryModel == MemoryModelOwn) + { + return op.emitError("ownership inference left a retain behind"); + } + if (tsLlvmContext->compileOptions.isRefCounted()) { OwnershipRoutineLogic orl(op, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); @@ -2485,9 +2511,19 @@ struct VariableOpLowering : public TsLlvmPattern // (`isOwningSlot`). So the first value has to be taken too, unless the frame has // already taken it - which is what an owned local's mark says, and what a captured // parameter's storage is precisely missing, the argument being the caller's. - if (isCaptured && tsLlvmContext->compileOptions.isRefCounted() && + if (isCaptured && tsLlvmContext->compileOptions.tracksOwnership() && !varOp->hasAttr(CAPTURE_BOX_ATTR_NAME) && !varOp->hasAttr(OWNED_LOCAL_ATTR_NAME)) { + // The value is someone else's - a parameter's is the caller's - and a cell that + // destroyed it would free it under them. Own has no count to take here: closures + // are phase 5. + if (tsLlvmContext->compileOptions.memoryModel == MemoryModelOwn && + MLIRTypeHelper(rewriter.getContext(), tsLlvmContext->compileOptions) + .ownsHeapMemory(location, referenceType.getElementType())) + { + return varOp.emitError("a captured variable cannot own its value under -mm=own yet"); + } + OwnershipRoutineLogic orl(varOp, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); orl.emitRetainSlot(referenceType.getElementType(), allocated); } diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 14b0f6c66..d62fce106 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2373,6 +2373,15 @@ add_test(NAME test-own-mlirgen-matches-rc "-DFILE=${PROJECT_SOURCE_DIR}/test/tester/own/own_mlirgen_delete.ts" -P "${CMAKE_CURRENT_SOURCE_DIR}/own-mlirgen-matches-rc.cmake") +# The lowering backstop: a retain that reaches LLVM lowering under own is an error, never +# silently erased. Checked with the inference pass switched off, which only this test does. +add_test(NAME test-own-lowering-backstop + COMMAND $ --emit=llvm --no-default-lib -mm=own --own-skip-inference + "${PROJECT_SOURCE_DIR}/test/tester/own/own_print_number.ts") +set_tests_properties(test-own-lowering-backstop + PROPERTIES PASS_REGULAR_EXPRESSION "ownership inference left a retain behind" + FAIL_REGULAR_EXPRESSION "Stack dump|Assertion failed") + # @dllname and @linkname are one decorator under two names, and a symbol is bound once: each # case is a program the compiler must reject with the given message instead of binding # something else or making the name unique. diff --git a/tslang/test/tester/own/own_print_number.ll b/tslang/test/tester/own/own_print_number.ll new file mode 100644 index 000000000..23af1c750 --- /dev/null +++ b/tslang/test/tester/own/own_print_number.ll @@ -0,0 +1,145 @@ +; ModuleID = 'LLVMDialectModule' +source_filename = "LLVMDialectModule" +target datalayout = "e-m:w-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-f80:128-n8:16:32:64-S128" +target triple = "x86_64-pc-windows-msvc" + +@s_6682479467004374669 = internal constant [14 x i8] c"\FF\FF\FF\FF\FF\FF\FF\FFdone.\00", align 8 +@frmt_11053768727887132666 = internal constant [12 x i8] c"\FF\FF\FF\FF\FF\FF\FF\FF%-i\00", align 8 +@s_6601085983368743140 = internal constant [13 x i8] c"\FF\FF\FF\FF\FF\FF\FF\FFnull\00", align 8 + +define internal void @tsrelv_85109662(ptr %0) { + %2 = alloca ptr, align 8 + store ptr %0, ptr %2, align 8 + call void @tsrel_85109662(ptr %2) + ret void +} + +define internal i1 @__tslang_dec_ref(ptr %0) { + %2 = getelementptr i8, ptr %0, i64 -8 + %3 = load i64, ptr %2, align 8 + %4 = icmp ne i64 %3, -1 + br i1 %4, label %5, label %8 + +5: ; preds = %1 + %6 = sub i64 %3, 1 + store i64 %6, ptr %2, align 8 + %7 = icmp eq i64 %6, 0 + ret i1 %7 + +8: ; preds = %1 + ret i1 false +} + +declare void @free(ptr) + +define internal void @__tslang_free_block(ptr %0) { + %2 = getelementptr i8, ptr %0, i64 -8 + call void @free(ptr %2) + ret void +} + +define internal void @tsrel_85109662(ptr %0) { + %2 = load ptr, ptr %0, align 8 + %3 = icmp ne ptr %2, null + br i1 %3, label %4, label %7 + +4: ; preds = %1 + %5 = call i1 @__tslang_dec_ref(ptr %2) + br i1 %5, label %6, label %7 + +6: ; preds = %4 + call void @__tslang_free_block(ptr %2) + br label %7 + +7: ; preds = %6, %4, %1 + ret void +} + +declare void @puts(ptr) + +declare ptr @malloc(i64) + +declare i32 @sprintf_s(ptr, i64, ptr, ...) + +; Function Attrs: mustprogress +define i32 @main() #0 { + %1 = alloca i32, align 4 + store i32 0, ptr %1, align 4 + br label %2 + +2: ; preds = %5, %0 + %3 = load i32, ptr %1, align 4 + %4 = icmp slt i32 %3, 3 + br i1 %4, label %5, label %14 + +5: ; preds = %2 + %6 = load i32, ptr %1, align 4 + %7 = call ptr @malloc(i64 58) + store i64 0, ptr %7, align 8 + %8 = getelementptr i8, ptr %7, i64 8 + %9 = call i32 (ptr, i64, ptr, ...) @sprintf_s(ptr %8, i64 50, ptr getelementptr inbounds nuw (i8, ptr @frmt_11053768727887132666, i64 8), i32 %6) + %10 = icmp eq ptr %8, null + %11 = select i1 %10, ptr getelementptr inbounds nuw (i8, ptr @s_6601085983368743140, i64 8), ptr %8 + call void @puts(ptr %11) + call void @tsrelv_85109662(ptr %8) + %12 = load i32, ptr %1, align 4 + %13 = add i32 %12, 1 + store i32 %13, ptr %1, align 4 + br label %2 + +14: ; preds = %2 + call void @puts(ptr getelementptr inbounds nuw (i8, ptr @s_6682479467004374669, i64 8)) + ret i32 0 +} + +declare void @mlirAsyncRuntimeAddRef(ptr, i64) + +declare void @mlirAsyncRuntimeDropRef(ptr, i64) + +declare ptr @mlirAsyncRuntimeCreateToken() + +declare ptr @mlirAsyncRuntimeCreateValue(i64) + +declare ptr @mlirAsyncRuntimeCreateGroup(i64) + +declare void @mlirAsyncRuntimeEmplaceToken(ptr) + +declare void @mlirAsyncRuntimeEmplaceValue(ptr) + +declare void @mlirAsyncRuntimeSetTokenError(ptr) + +declare void @mlirAsyncRuntimeSetValueError(ptr) + +declare i1 @mlirAsyncRuntimeIsTokenError(ptr) + +declare i1 @mlirAsyncRuntimeIsValueError(ptr) + +declare i1 @mlirAsyncRuntimeIsGroupError(ptr) + +declare void @mlirAsyncRuntimeAwaitToken(ptr) + +declare void @mlirAsyncRuntimeAwaitValue(ptr) + +declare void @mlirAsyncRuntimeAwaitAllInGroup(ptr) + +declare void @mlirAsyncRuntimeExecute(ptr, ptr) + +declare ptr @mlirAsyncRuntimeGetValueStorage(ptr) + +declare i64 @mlirAsyncRuntimeAddTokenToGroup(ptr, ptr) + +declare void @mlirAsyncRuntimeAwaitTokenAndExecute(ptr, ptr, ptr) + +declare void @mlirAsyncRuntimeAwaitValueAndExecute(ptr, ptr, ptr) + +declare void @mlirAsyncRuntimeAwaitAllInGroupAndExecute(ptr, ptr, ptr) + +declare i64 @mlirAsyncRuntimGetNumWorkerThreads() + +attributes #0 = { mustprogress } + +!llvm.module.flags = !{!0, !1} + +!0 = !{i32 2, !"Debug Info Version", i32 3} +!1 = !{i32 2, !"CodeView", i32 1} + diff --git a/tslang/test/tester/own/own_print_number.ts b/tslang/test/tester/own/own_print_number.ts new file mode 100644 index 000000000..1586abff0 --- /dev/null +++ b/tslang/test/tester/own/own_print_number.ts @@ -0,0 +1,4 @@ +function main() { + for (let i = 0; i < 3; i++) print(i); + print("done."); +} diff --git a/tslang/tslang/transform.cpp b/tslang/tslang/transform.cpp index 3e605d377..033f21088 100644 --- a/tslang/tslang/transform.cpp +++ b/tslang/tslang/transform.cpp @@ -78,6 +78,7 @@ extern cl::opt optLevel; extern cl::opt sizeLevel; extern cl::opt disableWarnings; extern cl::opt verifyOwnership; +extern cl::opt ownSkipInference; int runMLIRPasses(mlir::MLIRContext &context, llvm::SourceMgr &sourceMgr, mlir::OwningOpRef &module, CompileOptions &compileOptions) { diff --git a/tslang/tslang/tslang.cpp b/tslang/tslang/tslang.cpp index 220632dfb..b89797c81 100644 --- a/tslang/tslang/tslang.cpp +++ b/tslang/tslang/tslang.cpp @@ -131,6 +131,7 @@ cl::opt memoryModelOpt("mm", cl::desc("Memory management of co cl::init(MemoryModelGC), cl::cat(TypeScriptCompilerCategory)); cl::opt disableWarnings("nowarn", cl::desc("Disable Warnings"), cl::cat(TypeScriptCompilerCategory)); cl::opt verifyOwnership("verify-ownership", cl::desc("Check that every slot taking a reference gives it back on every path out of the function, unwind paths included"), cl::cat(TypeScriptCompilerCategory)); +cl::opt ownSkipInference("own-skip-inference", cl::Hidden, cl::desc("Under -mm=own, skip ownership inference so the lowering backstop can be tested"), cl::cat(TypeScriptCompilerDebugCategory)); cl::opt generateDebugInfo("di", cl::desc("Generate Debug Infomation"), cl::cat(TypeScriptCompilerCategory)); cl::opt lldbDebugInfo("lldb", cl::desc("Debug Infomation for LLDB"), cl::cat(TypeScriptCompilerCategory)); cl::list exportFilters("export", cl::desc("Export Symbols, comma separated: 'all', 'none', names or globs (short or namespaced, e.g. 'add' or 'M.*') to export, '!name' to exclude. Without an including filter the 'export' keyword decides. (Useful to compile the same code into 'lib' (static library) and/or 'dll/so' (dynamic library))"), From a376962f549d1a38b30278fcaa5f991b1be3b40d Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Tue, 29 Sep 2026 00:26:38 +0100 Subject: [PATCH 09/14] OwnershipInferencePass, phase 0: a fresh value's single acquisition is a move; every other retain is an error Under -mm=own, at the affine level before the verifier: a heap value made by an allocating op (or marked __owned_result) whose births plus retains total one has exactly one owner, so its retains are erased and the release rc already emits becomes the owner's destroy. Any other retain, a RetainCell, a RetainSlot on a hoisted (initializer-less) variable, and a slot released without ever being owned are reported. isHandOver moves to OwnershipOps.h, shared with the ownership verifier. Co-Authored-By: Claude Opus 5.5 --- tslang/include/TypeScript/OwnershipOps.h | 51 +++++ tslang/include/TypeScript/Passes.h | 4 + tslang/lib/TypeScript/CMakeLists.txt | 1 + .../lib/TypeScript/OwnershipInferencePass.cpp | 182 ++++++++++++++++++ .../lib/TypeScript/OwnershipVerifierPass.cpp | 38 +--- tslang/test/tester/CMakeLists.txt | 8 + tslang/tslang/transform.cpp | 7 + 7 files changed, 255 insertions(+), 36 deletions(-) create mode 100644 tslang/include/TypeScript/OwnershipOps.h create mode 100644 tslang/lib/TypeScript/OwnershipInferencePass.cpp diff --git a/tslang/include/TypeScript/OwnershipOps.h b/tslang/include/TypeScript/OwnershipOps.h new file mode 100644 index 000000000..4793cdb4e --- /dev/null +++ b/tslang/include/TypeScript/OwnershipOps.h @@ -0,0 +1,51 @@ +#ifndef MLIR_TYPESCRIPT_OWNERSHIPOPS_H +#define MLIR_TYPESCRIPT_OWNERSHIPOPS_H + +#include "TypeScript/TypeScriptOps.h" + +namespace mlir +{ +namespace typescript +{ + +// Is this release the giving-up half of an overwrite, rather than an unmatched release? +// +// An assignment into owning storage hands the count over: `ts.Retain` on the value coming +// in, `ts.ReleaseSlot` on what the slot still holds, then the store. The two halves are not +// expressed on the same thing - the retain names a value, the release names a slot - so the +// slot never appears in a `ts.RetainSlot` and the plain "released but never retained" test +// reports every field store in the suite. Recognising the store that follows is what tells +// the two apart: a release about to be overwritten is a hand-over, and the reference it +// gives up was taken by whoever stored the old value. +// +// Shared by the ownership verifier and, under -mm=own, ownership inference. +inline bool isHandOver(ReleaseSlotOp releaseOp) +{ + auto slot = releaseOp.getSlot(); + for (auto it = std::next(releaseOp->getIterator()); it != releaseOp->getBlock()->end(); ++it) + { + if (auto storeOp = mlir::dyn_cast(*it)) + { + if (storeOp.getReference() == slot) + { + return true; + } + } + + // another release of the same slot first means this one was not the overwrite's + if (auto otherRelease = mlir::dyn_cast(*it)) + { + if (otherRelease.getSlot() == slot) + { + return false; + } + } + } + + return false; +} + +} // namespace typescript +} // namespace mlir + +#endif // MLIR_TYPESCRIPT_OWNERSHIPOPS_H diff --git a/tslang/include/TypeScript/Passes.h b/tslang/include/TypeScript/Passes.h index 08e197900..396d99724 100644 --- a/tslang/include/TypeScript/Passes.h +++ b/tslang/include/TypeScript/Passes.h @@ -31,6 +31,10 @@ std::unique_ptr createRelocateConstantPass(); /// and in every memory model - the ownership ops survive to there regardless of model. std::unique_ptr createOwnershipVerifierPass(); +/// Ownership inference for -mm=own: erases the retains it can prove are a fresh value's single +/// acquisition and reports every other one. Runs at the affine level under own only. +std::unique_ptr createOwnershipInferencePass(); + /// Lets a call take over the reference its callee returned instead of retaining a second one. /// Runs on the whole module, after MLIRGen, because deciding which callees retain their result /// needs every function to be present - see docs/reference-counting-evaluation.md section 9.27. diff --git a/tslang/lib/TypeScript/CMakeLists.txt b/tslang/lib/TypeScript/CMakeLists.txt index c17d3260c..3f42cc3dd 100644 --- a/tslang/lib/TypeScript/CMakeLists.txt +++ b/tslang/lib/TypeScript/CMakeLists.txt @@ -32,6 +32,7 @@ add_mlir_dialect_library(MLIRTypeScript LowerToLLVM.cpp RelocateConstantPass.cpp OwnershipVerifierPass.cpp + OwnershipInferencePass.cpp OwnedReturnConsumptionPass.cpp GCPass.cpp AsyncTargetWidthPass.cpp diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp new file mode 100644 index 000000000..f3d51d334 --- /dev/null +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -0,0 +1,182 @@ +#include "mlir/Pass/Pass.h" +#include "mlir/Dialect/LLVMIR/LLVMAttrs.h" + +#include "TypeScript/TypeScriptDialect.h" +#include "TypeScript/TypeScriptOps.h" +#include "TypeScript/TypeScriptFunctionPass.h" +#include "TypeScript/Passes.h" +#include "TypeScript/Defines.h" +#include "TypeScript/OwnershipOps.h" + +#include "llvm/ADT/DenseMap.h" +#include "llvm/ADT/SmallVector.h" + +#define DEBUG_TYPE "own" + +namespace mlir_ts = mlir::typescript; + +namespace +{ + +// Ownership inference for -mm=own, phase 0. See +// docs/superpowers/specs/2026-09-24-own-memory-model-design.md, section 10.6. +// +// rc's count for a block is its births plus its retains. Where that provably never exceeds one, +// the block has one owner, the release rc already emits is that owner's destroy, and every +// retain on the way is erased. Anything else is reported: phase 0 knows no other verdict. +class OwnershipInferencePass : public mlir::PassWrapper +{ + public: + MLIR_DEFINE_EXPLICIT_INTERNAL_INLINE_TYPE_ID(OwnershipInferencePass) + + void runOnFunction() override + { + auto f = getFunction(); + + // value -> the retain ops that acquire it (a Retain of the value, a RetainSlot of the + // variable it initializes) + llvm::DenseMap> acquirers; + llvm::SmallVector unattributed; + llvm::SmallVector slotReleases; + + f.walk([&](mlir::Operation *op) { + if (auto retainOp = mlir::dyn_cast(op)) + { + acquirers[retainOp.getReference()].push_back(op); + } + else if (auto retainSlotOp = mlir::dyn_cast(op)) + { + // A variable with no initializer had its storage hoisted in front of a try, and + // its value arrives by a store this phase does not follow. + auto varOp = retainSlotOp.getSlot().getDefiningOp(); + if (varOp && varOp.getInitializer()) + { + acquirers[varOp.getInitializer()].push_back(op); + } + else + { + unattributed.push_back(op); + } + } + else if (mlir::isa(op)) + { + op->emitError("closures that capture a heap value are not supported by -mm=own yet"); + signalPassFailure(); + } + else if (auto releaseSlotOp = mlir::dyn_cast(op)) + { + slotReleases.push_back(releaseSlotOp); + } + }); + + for (auto *op : unattributed) + { + reportSecondReference(op); + } + + llvm::SmallVector toErase; + for (auto &[value, retains] : acquirers) + { + auto total = birth(value) + retains.size(); + if (isFresh(value) && total == 1) + { + toErase.append(retains.begin(), retains.end()); + continue; + } + + for (auto *op : retains) + { + reportSecondReference(op); + } + } + + for (auto releaseOp : slotReleases) + { + auto varOp = releaseOp.getSlot().getDefiningOp(); + if (!varOp || mlir_ts::isHandOver(releaseOp)) + { + continue; + } + + auto init = varOp.getInitializer(); + auto owned = varOp->hasAttr(OWNED_LOCAL_CONSUMED_ATTR_NAME) || (init && acquirers.count(init)); + if (!owned) + { + releaseOp.emitError("'") << varName(varOp) << "' is released but never owned"; + signalPassFailure(); + } + } + + for (auto *op : toErase) + { + op->erase(); + } + } + + private: + static bool isFresh(mlir::Value value) + { + auto *def = value.getDefiningOp(); + if (!def) + { + return false; // a block argument: a parameter, or a value merged from branches + } + + return def->hasAttr(OWNED_RESULT_ATTR_NAME) || + mlir::isa(def); + } + + // 1 when the value arrived with a reference the callee took (the retaining return, rc + // §9.24); 0 otherwise - including a string marked by markFreshStringOwned, whose birth is + // the retain MLIRGen emitted beside the mark and is counted as one of its acquisitions. + static unsigned birth(mlir::Value value) + { + auto *def = value.getDefiningOp(); + return def && def->hasAttr(OWNED_RESULT_ATTR_NAME) && + mlir::isa(def) + ? 1 + : 0; + } + + static llvm::StringRef varName(mlir_ts::VariableOp varOp) + { + // The declaration's name survives only as debug metadata (`--di`), the way + // LowerToLLVM's VariableOp lowering reads it. Without it the error still points at the + // declaration. + if (auto fused = mlir::dyn_cast>(varOp.getLoc())) + { + if (auto name = fused.getMetadata().getName()) + { + return name.getValue(); + } + } + + return "this value"; + } + + void reportSecondReference(mlir::Operation *op) + { + llvm::StringRef name = "this value"; + if (auto retainSlotOp = mlir::dyn_cast(op)) + { + if (auto varOp = retainSlotOp.getSlot().getDefiningOp()) + { + name = varName(varOp); + } + } + + op->emitError("'") << name << "' takes a second reference; -mm=own cannot prove a move or a borrow here yet"; + signalPassFailure(); + } +}; + +} // end anonymous namespace + +#undef DEBUG_TYPE + +std::unique_ptr mlir_ts::createOwnershipInferencePass() +{ + return std::make_unique(); +} diff --git a/tslang/lib/TypeScript/OwnershipVerifierPass.cpp b/tslang/lib/TypeScript/OwnershipVerifierPass.cpp index 8eb433bac..b50f0bcab 100644 --- a/tslang/lib/TypeScript/OwnershipVerifierPass.cpp +++ b/tslang/lib/TypeScript/OwnershipVerifierPass.cpp @@ -5,6 +5,7 @@ #include "TypeScript/TypeScriptFunctionPass.h" #include "TypeScript/Passes.h" #include "TypeScript/Defines.h" +#include "TypeScript/OwnershipOps.h" #include "llvm/ADT/DenseMap.h" #include "llvm/ADT/DenseSet.h" @@ -79,7 +80,7 @@ class OwnershipVerifierPass : public mlir::PassWrappergetIterator()); it != releaseOp->getBlock()->end(); ++it) - { - if (auto storeOp = mlir::dyn_cast(*it)) - { - if (storeOp.getReference() == slot) - { - return true; - } - } - - // another release of the same slot first means this one was not the overwrite's - if (auto otherRelease = mlir::dyn_cast(*it)) - { - if (otherRelease.getSlot() == slot) - { - return false; - } - } - } - - return false; - } - // Does this operation give back what `slot` took? Either release does. A `ts.ReleaseCell` // gives up a captured variable's whole cell, and the value in the cell goes with it when // the last owner lets go - so a captured local's scope exit discharges its declaration diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index d62fce106..dc6a1c244 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2382,6 +2382,14 @@ set_tests_properties(test-own-lowering-backstop PROPERTIES PASS_REGULAR_EXPRESSION "ownership inference left a retain behind" FAIL_REGULAR_EXPRESSION "Stack dump|Assertion failed") +# After inference under own, no retain survives in a program of single owners. +add_test(NAME test-own-inference-erases-birth-takes + COMMAND $ --emit=mlir-affine --no-default-lib -mm=own + "${PROJECT_SOURCE_DIR}/test/tester/own/own_print_number.ts") +set_tests_properties(test-own-inference-erases-birth-takes + PROPERTIES PASS_REGULAR_EXPRESSION "ts.Release" + FAIL_REGULAR_EXPRESSION "ts\\.Retain|error|Stack dump") + # @dllname and @linkname are one decorator under two names, and a symbol is bound once: each # case is a program the compiler must reject with the given message instead of binding # something else or making the name unique. diff --git a/tslang/tslang/transform.cpp b/tslang/tslang/transform.cpp index 033f21088..952df7220 100644 --- a/tslang/tslang/transform.cpp +++ b/tslang/tslang/transform.cpp @@ -134,6 +134,13 @@ int runMLIRPasses(mlir::MLIRContext &context, llvm::SourceMgr &sourceMgr, mlir:: // Ahead of the optimisation passes, so it checks what MLIRGen and the affine lowering // actually produced rather than what the inliner left of it, and after the lowering, // because that is what turns unwind paths into ordinary CFG edges. + // Under own, before the verifier so it checks the IR that will be lowered: inference + // either erases a retain or reports it, and a program it reports stops here. + if (compileOptions.memoryModel == MemoryModelOwn && !ownSkipInference) + { + pm.nest().addPass(mlir::typescript::createOwnershipInferencePass()); + } + if (verifyOwnership) { pm.nest().addPass(mlir::typescript::createOwnershipVerifierPass()); From dd6e1c7897113421e978db690b832c35acaf6171 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Tue, 29 Sep 2026 00:28:25 +0100 Subject: [PATCH 10/14] The own backstop test writes its .ll to the build tree, not beside the source Co-Authored-By: Claude Opus 5.5 --- tslang/test/tester/CMakeLists.txt | 3 +- tslang/test/tester/own/own_print_number.ll | 145 --------------------- 2 files changed, 2 insertions(+), 146 deletions(-) delete mode 100644 tslang/test/tester/own/own_print_number.ll diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index dc6a1c244..8dcf39225 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2377,7 +2377,8 @@ add_test(NAME test-own-mlirgen-matches-rc # silently erased. Checked with the inference pass switched off, which only this test does. add_test(NAME test-own-lowering-backstop COMMAND $ --emit=llvm --no-default-lib -mm=own --own-skip-inference - "${PROJECT_SOURCE_DIR}/test/tester/own/own_print_number.ts") + "${PROJECT_SOURCE_DIR}/test/tester/own/own_print_number.ts" + -o "${CMAKE_CURRENT_BINARY_DIR}/own-lowering-backstop.ll") set_tests_properties(test-own-lowering-backstop PROPERTIES PASS_REGULAR_EXPRESSION "ownership inference left a retain behind" FAIL_REGULAR_EXPRESSION "Stack dump|Assertion failed") diff --git a/tslang/test/tester/own/own_print_number.ll b/tslang/test/tester/own/own_print_number.ll deleted file mode 100644 index 23af1c750..000000000 --- a/tslang/test/tester/own/own_print_number.ll +++ /dev/null @@ -1,145 +0,0 @@ -; ModuleID = 'LLVMDialectModule' -source_filename = "LLVMDialectModule" -target datalayout = "e-m:w-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-f80:128-n8:16:32:64-S128" -target triple = "x86_64-pc-windows-msvc" - -@s_6682479467004374669 = internal constant [14 x i8] c"\FF\FF\FF\FF\FF\FF\FF\FFdone.\00", align 8 -@frmt_11053768727887132666 = internal constant [12 x i8] c"\FF\FF\FF\FF\FF\FF\FF\FF%-i\00", align 8 -@s_6601085983368743140 = internal constant [13 x i8] c"\FF\FF\FF\FF\FF\FF\FF\FFnull\00", align 8 - -define internal void @tsrelv_85109662(ptr %0) { - %2 = alloca ptr, align 8 - store ptr %0, ptr %2, align 8 - call void @tsrel_85109662(ptr %2) - ret void -} - -define internal i1 @__tslang_dec_ref(ptr %0) { - %2 = getelementptr i8, ptr %0, i64 -8 - %3 = load i64, ptr %2, align 8 - %4 = icmp ne i64 %3, -1 - br i1 %4, label %5, label %8 - -5: ; preds = %1 - %6 = sub i64 %3, 1 - store i64 %6, ptr %2, align 8 - %7 = icmp eq i64 %6, 0 - ret i1 %7 - -8: ; preds = %1 - ret i1 false -} - -declare void @free(ptr) - -define internal void @__tslang_free_block(ptr %0) { - %2 = getelementptr i8, ptr %0, i64 -8 - call void @free(ptr %2) - ret void -} - -define internal void @tsrel_85109662(ptr %0) { - %2 = load ptr, ptr %0, align 8 - %3 = icmp ne ptr %2, null - br i1 %3, label %4, label %7 - -4: ; preds = %1 - %5 = call i1 @__tslang_dec_ref(ptr %2) - br i1 %5, label %6, label %7 - -6: ; preds = %4 - call void @__tslang_free_block(ptr %2) - br label %7 - -7: ; preds = %6, %4, %1 - ret void -} - -declare void @puts(ptr) - -declare ptr @malloc(i64) - -declare i32 @sprintf_s(ptr, i64, ptr, ...) - -; Function Attrs: mustprogress -define i32 @main() #0 { - %1 = alloca i32, align 4 - store i32 0, ptr %1, align 4 - br label %2 - -2: ; preds = %5, %0 - %3 = load i32, ptr %1, align 4 - %4 = icmp slt i32 %3, 3 - br i1 %4, label %5, label %14 - -5: ; preds = %2 - %6 = load i32, ptr %1, align 4 - %7 = call ptr @malloc(i64 58) - store i64 0, ptr %7, align 8 - %8 = getelementptr i8, ptr %7, i64 8 - %9 = call i32 (ptr, i64, ptr, ...) @sprintf_s(ptr %8, i64 50, ptr getelementptr inbounds nuw (i8, ptr @frmt_11053768727887132666, i64 8), i32 %6) - %10 = icmp eq ptr %8, null - %11 = select i1 %10, ptr getelementptr inbounds nuw (i8, ptr @s_6601085983368743140, i64 8), ptr %8 - call void @puts(ptr %11) - call void @tsrelv_85109662(ptr %8) - %12 = load i32, ptr %1, align 4 - %13 = add i32 %12, 1 - store i32 %13, ptr %1, align 4 - br label %2 - -14: ; preds = %2 - call void @puts(ptr getelementptr inbounds nuw (i8, ptr @s_6682479467004374669, i64 8)) - ret i32 0 -} - -declare void @mlirAsyncRuntimeAddRef(ptr, i64) - -declare void @mlirAsyncRuntimeDropRef(ptr, i64) - -declare ptr @mlirAsyncRuntimeCreateToken() - -declare ptr @mlirAsyncRuntimeCreateValue(i64) - -declare ptr @mlirAsyncRuntimeCreateGroup(i64) - -declare void @mlirAsyncRuntimeEmplaceToken(ptr) - -declare void @mlirAsyncRuntimeEmplaceValue(ptr) - -declare void @mlirAsyncRuntimeSetTokenError(ptr) - -declare void @mlirAsyncRuntimeSetValueError(ptr) - -declare i1 @mlirAsyncRuntimeIsTokenError(ptr) - -declare i1 @mlirAsyncRuntimeIsValueError(ptr) - -declare i1 @mlirAsyncRuntimeIsGroupError(ptr) - -declare void @mlirAsyncRuntimeAwaitToken(ptr) - -declare void @mlirAsyncRuntimeAwaitValue(ptr) - -declare void @mlirAsyncRuntimeAwaitAllInGroup(ptr) - -declare void @mlirAsyncRuntimeExecute(ptr, ptr) - -declare ptr @mlirAsyncRuntimeGetValueStorage(ptr) - -declare i64 @mlirAsyncRuntimeAddTokenToGroup(ptr, ptr) - -declare void @mlirAsyncRuntimeAwaitTokenAndExecute(ptr, ptr, ptr) - -declare void @mlirAsyncRuntimeAwaitValueAndExecute(ptr, ptr, ptr) - -declare void @mlirAsyncRuntimeAwaitAllInGroupAndExecute(ptr, ptr, ptr) - -declare i64 @mlirAsyncRuntimGetNumWorkerThreads() - -attributes #0 = { mustprogress } - -!llvm.module.flags = !{!0, !1} - -!0 = !{i32 2, !"Debug Info Version", i32 3} -!1 = !{i32 2, !"CodeView", i32 1} - From c1cbe10ad795a499b8eabb8ecab63688ca77e915 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Tue, 29 Sep 2026 00:47:04 +0100 Subject: [PATCH 11/14] -mm=own phase-0 tests: positive, negative, and no counting in the output Six programs run under JIT and ahead of time with -mm=own, each checked to emit neither __tslang_inc_ref nor __tslang_dec_ref; five are rejected with the expected error. A literal acquired once is now a birth take too: `let s = "abc"` and `v: number[] = []` were rejected, and a cast constant is either the immortal global (its release skips it) or a copy one owner holds. The "released but never owned" rule is removed: the ownership verifier's stricter form of it is green over the corpus, so no program reaches it, and isHandOver goes back into the verifier. Teeth: with every retain kept, all 15 positive checks fail at the backstop. With the count check loosened to two, own_err_two_owners.ts compiles and segfaults under own (rc runs it: 500000, done.) - the count is what stands between a second owner and a double free. Debug tree: every own test passes. Co-Authored-By: Claude Opus 5.5 --- tslang/include/TypeScript/OwnershipOps.h | 51 ------------------- .../lib/TypeScript/OwnershipInferencePass.cpp | 35 ++++--------- .../lib/TypeScript/OwnershipVerifierPass.cpp | 38 +++++++++++++- tslang/test/tester/CMakeLists.txt | 35 +++++++++++++ tslang/test/tester/own/own_err_alias.ts | 7 +++ tslang/test/tester/own/own_err_closure.ts | 7 +++ tslang/test/tester/own/own_err_field_alias.ts | 9 ++++ tslang/test/tester/own/own_err_two_owners.ts | 15 ++++++ .../tester/own/own_err_unknown_producer.ts | 8 +++ tslang/test/tester/own/own_fresh_array.ts | 15 ++++++ tslang/test/tester/own/own_fresh_string.ts | 8 +++ tslang/test/tester/own/own_literals.ts | 13 +++++ tslang/test/tester/own/own_return_new.ts | 15 ++++++ tslang/test/tester/own/own_try_local.ts | 15 ++++++ 14 files changed, 194 insertions(+), 77 deletions(-) delete mode 100644 tslang/include/TypeScript/OwnershipOps.h create mode 100644 tslang/test/tester/own/own_err_alias.ts create mode 100644 tslang/test/tester/own/own_err_closure.ts create mode 100644 tslang/test/tester/own/own_err_field_alias.ts create mode 100644 tslang/test/tester/own/own_err_two_owners.ts create mode 100644 tslang/test/tester/own/own_err_unknown_producer.ts create mode 100644 tslang/test/tester/own/own_fresh_array.ts create mode 100644 tslang/test/tester/own/own_fresh_string.ts create mode 100644 tslang/test/tester/own/own_literals.ts create mode 100644 tslang/test/tester/own/own_return_new.ts create mode 100644 tslang/test/tester/own/own_try_local.ts diff --git a/tslang/include/TypeScript/OwnershipOps.h b/tslang/include/TypeScript/OwnershipOps.h deleted file mode 100644 index 4793cdb4e..000000000 --- a/tslang/include/TypeScript/OwnershipOps.h +++ /dev/null @@ -1,51 +0,0 @@ -#ifndef MLIR_TYPESCRIPT_OWNERSHIPOPS_H -#define MLIR_TYPESCRIPT_OWNERSHIPOPS_H - -#include "TypeScript/TypeScriptOps.h" - -namespace mlir -{ -namespace typescript -{ - -// Is this release the giving-up half of an overwrite, rather than an unmatched release? -// -// An assignment into owning storage hands the count over: `ts.Retain` on the value coming -// in, `ts.ReleaseSlot` on what the slot still holds, then the store. The two halves are not -// expressed on the same thing - the retain names a value, the release names a slot - so the -// slot never appears in a `ts.RetainSlot` and the plain "released but never retained" test -// reports every field store in the suite. Recognising the store that follows is what tells -// the two apart: a release about to be overwritten is a hand-over, and the reference it -// gives up was taken by whoever stored the old value. -// -// Shared by the ownership verifier and, under -mm=own, ownership inference. -inline bool isHandOver(ReleaseSlotOp releaseOp) -{ - auto slot = releaseOp.getSlot(); - for (auto it = std::next(releaseOp->getIterator()); it != releaseOp->getBlock()->end(); ++it) - { - if (auto storeOp = mlir::dyn_cast(*it)) - { - if (storeOp.getReference() == slot) - { - return true; - } - } - - // another release of the same slot first means this one was not the overwrite's - if (auto otherRelease = mlir::dyn_cast(*it)) - { - if (otherRelease.getSlot() == slot) - { - return false; - } - } - } - - return false; -} - -} // namespace typescript -} // namespace mlir - -#endif // MLIR_TYPESCRIPT_OWNERSHIPOPS_H diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index f3d51d334..b328dd074 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -6,7 +6,6 @@ #include "TypeScript/TypeScriptFunctionPass.h" #include "TypeScript/Passes.h" #include "TypeScript/Defines.h" -#include "TypeScript/OwnershipOps.h" #include "llvm/ADT/DenseMap.h" #include "llvm/ADT/SmallVector.h" @@ -37,7 +36,6 @@ class OwnershipInferencePass : public mlir::PassWrapper> acquirers; llvm::SmallVector unattributed; - llvm::SmallVector slotReleases; f.walk([&](mlir::Operation *op) { if (auto retainOp = mlir::dyn_cast(op)) @@ -63,10 +61,6 @@ class OwnershipInferencePass : public mlir::PassWrapperemitError("closures that capture a heap value are not supported by -mm=own yet"); signalPassFailure(); } - else if (auto releaseSlotOp = mlir::dyn_cast(op)) - { - slotReleases.push_back(releaseSlotOp); - } }); for (auto *op : unattributed) @@ -90,23 +84,6 @@ class OwnershipInferencePass : public mlir::PassWrapper(); - if (!varOp || mlir_ts::isHandOver(releaseOp)) - { - continue; - } - - auto init = varOp.getInitializer(); - auto owned = varOp->hasAttr(OWNED_LOCAL_CONSUMED_ATTR_NAME) || (init && acquirers.count(init)); - if (!owned) - { - releaseOp.emitError("'") << varName(varOp) << "' is released but never owned"; - signalPassFailure(); - } - } - for (auto *op : toErase) { op->erase(); @@ -122,11 +99,21 @@ class OwnershipInferencePass : public mlir::PassWrapperhasAttr(OWNED_RESULT_ATTR_NAME) || + return def->hasAttr(OWNED_RESULT_ATTR_NAME) || isLiteral(def) || mlir::isa(def); } + // A string literal or a constant array cast to its value type. The result is either the + // immortal global itself, which a release skips, or a copy nobody else holds, which a + // release destroys - so, acquired once, it has one owner either way. Acquired twice it is + // still an error: which of the two it is depends on the cast. + static bool isLiteral(mlir::Operation *def) + { + auto castOp = mlir::dyn_cast(def); + return castOp && castOp.getIn().getDefiningOp(); + } + // 1 when the value arrived with a reference the callee took (the retaining return, rc // §9.24); 0 otherwise - including a string marked by markFreshStringOwned, whose birth is // the retain MLIRGen emitted beside the mark and is counted as one of its acquisitions. diff --git a/tslang/lib/TypeScript/OwnershipVerifierPass.cpp b/tslang/lib/TypeScript/OwnershipVerifierPass.cpp index b50f0bcab..8eb433bac 100644 --- a/tslang/lib/TypeScript/OwnershipVerifierPass.cpp +++ b/tslang/lib/TypeScript/OwnershipVerifierPass.cpp @@ -5,7 +5,6 @@ #include "TypeScript/TypeScriptFunctionPass.h" #include "TypeScript/Passes.h" #include "TypeScript/Defines.h" -#include "TypeScript/OwnershipOps.h" #include "llvm/ADT/DenseMap.h" #include "llvm/ADT/DenseSet.h" @@ -80,7 +79,7 @@ class OwnershipVerifierPass : public mlir::PassWrappergetIterator()); it != releaseOp->getBlock()->end(); ++it) + { + if (auto storeOp = mlir::dyn_cast(*it)) + { + if (storeOp.getReference() == slot) + { + return true; + } + } + + // another release of the same slot first means this one was not the overwrite's + if (auto otherRelease = mlir::dyn_cast(*it)) + { + if (otherRelease.getSlot() == slot) + { + return false; + } + } + } + + return false; + } + // Does this operation give back what `slot` took? Either release does. A `ts.ReleaseCell` // gives up a captured variable's whole cell, and the value in the cell goes with it when // the last owner lets go - so a captured local's scope exit discharges its declaration diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 8dcf39225..9b84bd0a7 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2391,6 +2391,41 @@ set_tests_properties(test-own-inference-erases-birth-takes PROPERTIES PASS_REGULAR_EXPRESSION "ts.Release" FAIL_REGULAR_EXPRESSION "ts\\.Retain|error|Stack dump") +# -mm=own, phase 0 (docs/superpowers/plans/2026-09-28-own-phase-0.md). Positive programs run +# under JIT and ahead of time; each owns every block once. +foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local own_print_number own_literals) + 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. + add_test(NAME test-own-no-counting-${own_test} + COMMAND $ --emit=llvm --no-default-lib -mm=own + "${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") +endforeach() + +# Each negative program has one shape phase 0 cannot prove, and must be rejected with this text. +set(own_error_cases + "own_err_alias|takes a second reference" + "own_err_field_alias|takes a second reference" + "own_err_closure|closures that capture a heap value are not supported" + "own_err_unknown_producer|takes a second reference" + "own_err_two_owners|takes a second reference") +foreach(own_error_case ${own_error_cases}) + string(FIND "${own_error_case}" "|" own_error_sep) + string(SUBSTRING "${own_error_case}" 0 ${own_error_sep} own_error_name) + math(EXPR own_error_sep "${own_error_sep} + 1") + string(SUBSTRING "${own_error_case}" ${own_error_sep} -1 own_error_message) + add_test(NAME test-own-err-${own_error_name} + COMMAND $ --emit=obj --no-default-lib -mm=own + "${PROJECT_SOURCE_DIR}/test/tester/own/${own_error_name}.ts" + -o "${CMAKE_CURRENT_BINARY_DIR}/own-err-${own_error_name}.obj") + set_tests_properties(test-own-err-${own_error_name} + PROPERTIES PASS_REGULAR_EXPRESSION "${own_error_message}" + FAIL_REGULAR_EXPRESSION "Stack dump|Assertion failed") +endforeach() + # @dllname and @linkname are one decorator under two names, and a symbol is bound once: each # case is a program the compiler must reject with the given message instead of binding # something else or making the name unique. diff --git a/tslang/test/tester/own/own_err_alias.ts b/tslang/test/tester/own/own_err_alias.ts new file mode 100644 index 000000000..34552ed84 --- /dev/null +++ b/tslang/test/tester/own/own_err_alias.ts @@ -0,0 +1,7 @@ +// -mm=own, phase 0 rejects: `b` would be a second owner of `a`'s array. +function main() { + let a: number[] = [1]; + let b = a; + print(b.length); + print("done."); +} diff --git a/tslang/test/tester/own/own_err_closure.ts b/tslang/test/tester/own/own_err_closure.ts new file mode 100644 index 000000000..3e77e1f71 --- /dev/null +++ b/tslang/test/tester/own/own_err_closure.ts @@ -0,0 +1,7 @@ +// -mm=own, phase 0 rejects: a closure capturing a heap value (closures are phase 5). +function main() { + const a: number[] = [1]; + const f = () => a.length; + print(f()); + print("done."); +} diff --git a/tslang/test/tester/own/own_err_field_alias.ts b/tslang/test/tester/own/own_err_field_alias.ts new file mode 100644 index 000000000..04c466d72 --- /dev/null +++ b/tslang/test/tester/own/own_err_field_alias.ts @@ -0,0 +1,9 @@ +// -mm=own, phase 0 rejects: the field would be a second owner of `a`'s array. +class N { v: number[] = []; } +function main() { + const a: number[] = [1]; + const n = new N(); + n.v = a; + print(a.length); + print("done."); +} diff --git a/tslang/test/tester/own/own_err_two_owners.ts b/tslang/test/tester/own/own_err_two_owners.ts new file mode 100644 index 000000000..c844de441 --- /dev/null +++ b/tslang/test/tester/own/own_err_two_owners.ts @@ -0,0 +1,15 @@ +// -mm=own, phase 0 rejects: the array literal would have two owners, the field and `a`. With +// the count check loosened this compiles and double-frees; rc runs it correctly. +class N { v: number[] = [0]; } +function main() { + let sum = 0; + for (let i = 0; i < 100000; i++) { + const n = new N(); + let a = (n.v = [i, i + 1]); + const filler = [i, i, i, i]; + sum += a[1] - i + filler.length; + } + print(sum); + assert(sum == 500000); + print("done."); +} diff --git a/tslang/test/tester/own/own_err_unknown_producer.ts b/tslang/test/tester/own/own_err_unknown_producer.ts new file mode 100644 index 000000000..9d90a21b1 --- /dev/null +++ b/tslang/test/tester/own/own_err_unknown_producer.ts @@ -0,0 +1,8 @@ +// -mm=own, phase 0 rejects: a declared function's result is not known to be fresh, so holding +// it would be a second owner of something the callee may still own. Compiled, never linked. +declare function makeNumbers(): number[]; +function main() { + const b = makeNumbers(); + print(b.length); + print("done."); +} diff --git a/tslang/test/tester/own/own_fresh_array.ts b/tslang/test/tester/own/own_fresh_array.ts new file mode 100644 index 000000000..ab06504ec --- /dev/null +++ b/tslang/test/tester/own/own_fresh_array.ts @@ -0,0 +1,15 @@ +// -mm=own, phase 0: a fresh array per iteration, owned by a local and lent to a call. +function sum(a: number[]) { + let t: number = 0; + for (const v of a) t += v; + return t; +} +function main() { + let total: number = 0; + for (let i = 0; i < 1000000; i++) { + const a: number[] = [i, i + 1, i + 2]; + total += sum(a); + } + assert(total == 1500001500000); + print("done."); +} diff --git a/tslang/test/tester/own/own_fresh_string.ts b/tslang/test/tester/own/own_fresh_string.ts new file mode 100644 index 000000000..59caa91f8 --- /dev/null +++ b/tslang/test/tester/own/own_fresh_string.ts @@ -0,0 +1,8 @@ +// -mm=own, phase 0: a fresh string per iteration, owned once and destroyed at the end of it. +function main() { + for (let i = 0; i < 1000000; i++) { + const s = "item " + i; + if (i == 999999) print(s); + } + print("done."); +} diff --git a/tslang/test/tester/own/own_literals.ts b/tslang/test/tester/own/own_literals.ts new file mode 100644 index 000000000..c97eb1eab --- /dev/null +++ b/tslang/test/tester/own/own_literals.ts @@ -0,0 +1,13 @@ +// -mm=own, phase 0: a literal owned once. A string literal or a constant array is an immortal +// global (its release skips it) or a copy (its release destroys it) - either way one owner. +class N { v: number[] = []; } +function main() { + let count = 0; + for (let i = 0; i < 100000; i++) { + let s = "abc"; + const n = new N(); + count += s.length + n.v.length; + } + assert(count == 300000); + print("done."); +} diff --git a/tslang/test/tester/own/own_return_new.ts b/tslang/test/tester/own/own_return_new.ts new file mode 100644 index 000000000..7d57603b1 --- /dev/null +++ b/tslang/test/tester/own/own_return_new.ts @@ -0,0 +1,15 @@ +// -mm=own, phase 0: a factory returns a fresh instance; the caller owns it and destroys it. +class Point { + constructor(public x: number, public y: number) {} + len2() { return this.x * this.x + this.y * this.y; } +} +function make(i: number) { return new Point(i, i); } +function main() { + let t: number = 0; + for (let i = 0; i < 1000000; i++) { + const p = make(i % 10); + t += p.len2(); + } + assert(t == 57000000); + print("done."); +} diff --git a/tslang/test/tester/own/own_try_local.ts b/tslang/test/tester/own/own_try_local.ts new file mode 100644 index 000000000..be8c9183c --- /dev/null +++ b/tslang/test/tester/own/own_try_local.ts @@ -0,0 +1,15 @@ +// -mm=own, phase 0: a throw out of a function with no try leaves its local unreleased - a leak +// rc shares - and must never become a double free. +function f(i: number) { + const a: number[] = [i, i]; + if (i % 2 == 0) throw i; + return a.length; +} +function main() { + let caught = 0; + for (let i = 0; i < 100000; i++) { + try { f(i); } catch (e) { caught++; } + } + assert(caught == 50000); + print("done."); +} From caa344f40f3804ea8a5be8593ed4885d5c5c8d90 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Tue, 29 Sep 2026 00:51:12 +0100 Subject: [PATCH 12/14] measure.ps1, and the phase-0 numbers for -mm=own The AOT measuring script, rebuilt: it links the way the test runner does (read from the generated compile.bat) and reads the peak working set from the process handle after exit - sampling while it runs read 0.0 MB for programs that finish in milliseconds. Spec section 11 records the results: own holds flat exactly where rc does (4.2 MB for 1M strings, none 80.9 MB), the teeth, and phase 0's known limits. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 64 +++++++++- tslang/test/tester/tools/measure.ps1 | 115 ++++++++++++++++++ 2 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 tslang/test/tester/tools/measure.ps1 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 1fe2c5a0a..45a7b47c7 100644 --- a/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md +++ b/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md @@ -1,8 +1,8 @@ # `-mm=own`: a single-owner memory model Date: 2026-09-24. Status: design approved in conversation; written review 2026-09-28 (§10), -amendments folded in. Phase 0 includes the birth take (§10.6); plan: -`docs/superpowers/plans/2026-09-28-own-phase-0.md`. +amendments folded in. Phase 0 implemented on branch `own-phase-0` (2026-09-29, results §11); +plan: `docs/superpowers/plans/2026-09-28-own-phase-0.md`. ## 1. Purpose @@ -383,3 +383,63 @@ programs, and §7's "alias-free programs compile, run and reclaim" is not reacha the move verdict for a fresh value's single acquisition - which depends on each producer's +0/+1 convention (`__owned_result`, the retaining return of rc §9.24/§9.25, pop/shift). How phase 0 and phase 1 split around this is decided in the plan. + +## 11. Phase 0 results, 2026-09-29 + +Branch `own-phase-0`, stacked on PR #398. `-mm=own` compiles a program in which every heap +block has one owner and rejects every other ownership shape at its source location. Its LLVM +output references neither `__tslang_inc_ref` nor `__tslang_dec_ref`: a release is one header +load, an immortal check and the destroy. + +### 11.1 What phase 0 accepts + +A retain is erased when its value is fresh and has exactly one acquisition in the function, +counting births and retains: rc's count for that block provably never exceeds one. Fresh means +made by `ts.New`, `ts.CreateArray`, `ts.NewArray`, `ts.StringConcat` or `ts.CharToString`, +carrying `__owned_result`, or a cast of a constant (a string literal or a constant array). The +last of these was added during implementation, because `let s = "abc"` and `v: number[] = []` +were rejected. A cast constant is the immortal global, which a release skips, or a copy with +one owner, which a release destroys, so it is correct either way when acquired once. + +Everything else is an error. That covers a retain of a loaded or unknown value, two +acquisitions of one fresh value, any `ts.RetainCell`, a `RetainSlot` on a variable hoisted in +front of a `try`, and a captured variable holding a heap value. A retain that reaches LLVM +lowering is a backstop error, never erased. + +The plan's "released but never owned" rule was dropped. The ownership verifier checks a +stricter form of it and is green over the whole corpus, so no program reaches it. + +### 11.2 Measured + +`test/tester/tools/measure.ps1` builds each program AOT at `-O3` and reads the peak working set +from the process handle after exit: + +| Program | gc | rc | none | own | +| --- | --- | --- | --- | --- | +| `own_fresh_string` (1M strings) | 5.8 | 4.2 | 80.9 | 4.2 | +| `own_return_new` (1M instances via a factory) | 5.8 | 4.2 | 50.2 | 4.2 | +| `own_try_local` (100k throws holding an array) | 5.9 | 6.5 | 6.5 | 6.5 | +| `own_fresh_array` | 5.6 | 4.2 | 4.2 | 4.2 | +| `own_literals` | 5.6 | 4.1 | 4.1 | 4.1 | + +All figures are MB. Own holds flat exactly where rc does. In `own_fresh_array` and +`own_literals`, LLVM removes the allocations at `-O3`, so none does not grow and the rows show +nothing about reclamation. `own_try_local`'s throw path does not release its local under any +model, which is rc's leak too. + +### 11.3 The count is load-bearing + +Tested both ways: + +- With every retain kept, all 15 positive checks fail at the backstop. +- With the rule loosened to two acquisitions, `own_err_two_owners.ts` (a fresh array stored + into a field and a local) compiles and segfaults under own, while rc runs it correctly. It + is now a negative test. + +### 11.4 Known limits (the input to phase 1) + +- An owned local declared inside a `try` body is rejected, because its storage is hoisted. +- A class field initialized from a parameter, an alias through `let`, and a store of a local + into a field are all rejected (a retain of a load). +- Closures that capture heap values are rejected. +- Results of runtime helpers and `declare`d functions are rejected. diff --git a/tslang/test/tester/tools/measure.ps1 b/tslang/test/tester/tools/measure.ps1 new file mode 100644 index 000000000..7dc9276ad --- /dev/null +++ b/tslang/test/tester/tools/measure.ps1 @@ -0,0 +1,115 @@ +<# +.SYNOPSIS + Peak memory of one program under each memory model, ahead-of-time compiled. + +.DESCRIPTION + Builds a native exe per model (--emit=obj + lld, the same link line the test runner uses), + runs it, and samples the process's peak working set until it exits. Prints one line per + model: model, peak MB, exit code. Exits 1 if any run exited non-zero. + + Why AOT and not the JIT: under the JIT, 13-16 MB of every reading is tslang.exe itself, and + each model's allocations are elided differently, so the same program read 41 MB one hour and + 12.6 MB the next. A native exe's floor is about 3 MB. + + Two things this must keep doing: + - read the peak from the process handle after exit (GetProcessMemoryInfo), not by sampling + while it runs: Process.PeakWorkingSet64 is unavailable once the process has exited, and a + program that finishes in a few milliseconds is gone before the first sample - it read + 0.0 MB under every model; + - the work directory is a fresh temp directory, never beside the source (the source is + usually a repo file). + + The tool paths and the link line are read from the `compile.bat` the test runner generates + in the build tree, so this links exactly the way the suite does. + +.EXAMPLE + ./measure.ps1 -Source ../own/own_fresh_string.ts + ./measure.ps1 -Source raytrace.ts -Models gc,rc +#> +param( + [Parameter(Mandatory = $true)][string]$Source, + [string[]]$Models = @('gc', 'rc', 'none', 'own'), + [string]$BuildDir = (Join-Path $PSScriptRoot '../../../../__build/tslang/windows-msbuild-2026-release') +) + +$ErrorActionPreference = 'Stop' + +if (-not ('TslangMeasure.Psapi' -as [type])) { + Add-Type -Namespace TslangMeasure -Name Psapi -MemberDefinition @' +[StructLayout(LayoutKind.Sequential)] +public struct PROCESS_MEMORY_COUNTERS { + public uint cb; public uint PageFaultCount; + public UIntPtr PeakWorkingSetSize; public UIntPtr WorkingSetSize; + public UIntPtr QuotaPeakPagedPoolUsage; public UIntPtr QuotaPagedPoolUsage; + public UIntPtr QuotaPeakNonPagedPoolUsage; public UIntPtr QuotaNonPagedPoolUsage; + public UIntPtr PagefileUsage; public UIntPtr PeakPagefileUsage; +} +[DllImport("psapi.dll", SetLastError = true)] +static extern bool GetProcessMemoryInfo(IntPtr process, out PROCESS_MEMORY_COUNTERS counters, uint size); +// Valid after the process has exited, for as long as the handle is open. +public static long PeakWorkingSet(IntPtr process) { + PROCESS_MEMORY_COUNTERS counters; + if (!GetProcessMemoryInfo(process, out counters, (uint)Marshal.SizeOf(typeof(PROCESS_MEMORY_COUNTERS)))) + throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error()); + return (long)counters.PeakWorkingSetSize.ToUInt64(); +} +'@ +} + +$Source = (Resolve-Path $Source).Path +$bat = Join-Path $BuildDir 'test/tester/compile.bat' +if (-not (Test-Path $bat)) { + throw "no $bat - run any test-runner compile test once (ctest -C Release -R test-compile-00) to generate it" +} + +# `set NAME=value` lines, and the lld line with its %NAME% references expanded +$vars = @{} +$linkTemplate = $null +foreach ($line in Get-Content $bat) { + if ($line -match '^set ([A-Z_]+)=(.*)$') { + $vars[$Matches[1]] = $Matches[2].Trim('"') + } + elseif ($line -match 'lld\.exe -flavor link') { + $linkTemplate = $line + } +} +if (-not $linkTemplate) { throw "no lld line in $bat" } + +$work = Join-Path ([System.IO.Path]::GetTempPath()) ("tslang-measure-" + [System.Guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $work | Out-Null + +$failed = $false +try { + Push-Location $work + foreach ($model in $Models) { + $name = "m_$model" + & "$($vars['TSLANGEXEPATH'])/tslang.exe" --emit=obj --entry-point --opt --opt_level=3 --no-default-lib "-mm=$model" $Source "-o=$name.obj" + if ($LASTEXITCODE -ne 0) { Write-Output ("{0,-5} compile failed ({1})" -f $model, $LASTEXITCODE); $failed = $true; continue } + + $link = $linkTemplate -replace '%FILENAME%', $name -replace '%LINKER_OPTS%', '' + foreach ($key in $vars.Keys) { $link = $link.Replace("%$key%", '"' + $vars[$key] + '"') } + cmd /c $link | Out-Null + if (-not (Test-Path "$name.exe")) { Write-Output ("{0,-5} link failed" -f $model); $failed = $true; continue } + + $startInfo = New-Object System.Diagnostics.ProcessStartInfo (Join-Path $work "$name.exe") + $startInfo.UseShellExecute = $false + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $process = [System.Diagnostics.Process]::Start($startInfo) + $stdout = $process.StandardOutput.ReadToEndAsync() + $stderr = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $stdout.Result | Set-Content "$name.txt" + $stderr.Result | Set-Content "$name.err" + $peak = [TslangMeasure.Psapi]::PeakWorkingSet($process.Handle) + $code = $process.ExitCode + if ($code -ne 0) { $failed = $true } + Write-Output ("{0,-5} {1,8:N1} MB exit {2}" -f $model, ($peak / 1MB), $code) + } +} +finally { + Pop-Location + Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue +} + +if ($failed) { exit 1 } From a3046a01b14db755fc956d844ed752bbed09c77b Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Tue, 29 Sep 2026 00:51:38 +0100 Subject: [PATCH 13/14] The lowering-error test uses an input that reports its error in a Debug build too The array-to-string cast hits llvm_unreachable (CastLogicHelper.h:856) in a Debug build before it reports anything, so the message test failed there. An array literal lowering cannot build as number[] | null reports its error the same way in Release and Debug. Co-Authored-By: Claude Opus 5.5 --- tslang/test/tester/CMakeLists.txt | 8 ++++---- .../tester/lowering-errors/array_literal_element_type.ts | 8 ++++++++ tslang/test/tester/lowering-errors/cast_not_supported.ts | 6 ------ 3 files changed, 12 insertions(+), 10 deletions(-) create mode 100644 tslang/test/tester/lowering-errors/array_literal_element_type.ts delete mode 100644 tslang/test/tester/lowering-errors/cast_not_supported.ts diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 9b84bd0a7..7ac35dbed 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2343,16 +2343,16 @@ foreach(call_arity_case ${call_arity_cases}) FAIL_REGULAR_EXPRESSION "Stack dump|Assertion failed") endforeach() -# An error reported by a pass that still returns success - a lowering pattern that cannot cast, -# says so and carries on - fails the compile. It used to be printed and then ignored: the object +# An error reported by a pass that still returns success - a lowering pattern that cannot build a +# value, says so and carries on - fails the compile. It used to be printed and then ignored: the object # file was written, and under --emit=jit the program ran and crashed. Two tests because one # property cannot say both things: the message is the error we meant, and the exit code is not 0. -set(lowering_error_file "${PROJECT_SOURCE_DIR}/test/tester/lowering-errors/cast_not_supported.ts") +set(lowering_error_file "${PROJECT_SOURCE_DIR}/test/tester/lowering-errors/array_literal_element_type.ts") add_test(NAME test-compile-lowering-error-message COMMAND $ --emit=obj --no-default-lib -mm=none "${lowering_error_file}" -o "${CMAKE_CURRENT_BINARY_DIR}/lowering-error-message.obj") set_tests_properties(test-compile-lowering-error-message - PROPERTIES PASS_REGULAR_EXPRESSION "cast from '!ts.array' to '!ts.string' is not supported" + PROPERTIES PASS_REGULAR_EXPRESSION "source array and destination array have different types" FAIL_REGULAR_EXPRESSION "Stack dump|Assertion failed") add_test(NAME test-compile-lowering-error-exit COMMAND $ --emit=obj --no-default-lib -mm=none "${lowering_error_file}" diff --git a/tslang/test/tester/lowering-errors/array_literal_element_type.ts b/tslang/test/tester/lowering-errors/array_literal_element_type.ts new file mode 100644 index 000000000..7d87efc35 --- /dev/null +++ b/tslang/test/tester/lowering-errors/array_literal_element_type.ts @@ -0,0 +1,8 @@ +// Lowering cannot build this array literal as a number[] | null, and says so with an error. That +// error has to stop the compile: before it did, the object file was written and the program run. +// (The array-to-string cast this test used first hits llvm_unreachable in a Debug build before it +// reports anything; this error is reported the same way in both.) +function main() { + let a: number[] | null = [1, 2]; + print("done."); +} diff --git a/tslang/test/tester/lowering-errors/cast_not_supported.ts b/tslang/test/tester/lowering-errors/cast_not_supported.ts deleted file mode 100644 index c3d3bb112..000000000 --- a/tslang/test/tester/lowering-errors/cast_not_supported.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Lowering has no cast from an array to a string, and says so with an error. That error has to -// stop the compile: before it did, the program was emitted and run anyway and crashed. -function main() { - let a: number[] | null = [1, 2]; - print("a=" + a); -} From 75714b335b2b67ca114c20d6b5506b79011f9d2f Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Tue, 29 Sep 2026 09:07:48 +0100 Subject: [PATCH 14/14] Implement phase 0 ownership rules and error handling for -mm=own; add tests for various ownership violations --- .../2026-09-24-own-memory-model-design.md | 66 +++- tslang/lib/TypeScript/LowerToLLVM.cpp | 5 +- .../lib/TypeScript/OwnershipInferencePass.cpp | 293 +++++++++++++++--- tslang/test/tester/CMakeLists.txt | 17 +- tslang/test/tester/own/own_err_const_twice.ts | 9 + tslang/test/tester/own/own_err_delete.ts | 7 + tslang/test/tester/own/own_err_field_loop.ts | 13 + tslang/test/tester/own/own_err_interface.ts | 11 + tslang/test/tester/own/own_err_push_let.ts | 10 + tslang/test/tester/own/own_err_push_loop.ts | 9 + .../test/tester/own/own_err_use_after_move.ts | 9 + tslang/tslang/transform.cpp | 13 +- 12 files changed, 389 insertions(+), 73 deletions(-) create mode 100644 tslang/test/tester/own/own_err_const_twice.ts create mode 100644 tslang/test/tester/own/own_err_delete.ts create mode 100644 tslang/test/tester/own/own_err_field_loop.ts create mode 100644 tslang/test/tester/own/own_err_interface.ts create mode 100644 tslang/test/tester/own/own_err_push_let.ts create mode 100644 tslang/test/tester/own/own_err_push_loop.ts create mode 100644 tslang/test/tester/own/own_err_use_after_move.ts diff --git a/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md b/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md index 45a7b47c7..e3038b216 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 @@ -393,21 +393,51 @@ load, an immortal check and the destroy. ### 11.1 What phase 0 accepts -A retain is erased when its value is fresh and has exactly one acquisition in the function, -counting births and retains: rc's count for that block provably never exceeds one. Fresh means -made by `ts.New`, `ts.CreateArray`, `ts.NewArray`, `ts.StringConcat` or `ts.CharToString`, -carrying `__owned_result`, or a cast of a constant (a string literal or a constant array). The -last of these was added during implementation, because `let s = "abc"` and `v: number[] = []` -were rejected. A cast constant is the immortal global, which a release skips, or a copy with -one owner, which a release destroys, so it is correct either way when acquired once. - -Everything else is an error. That covers a retain of a loaded or unknown value, two -acquisitions of one fresh value, any `ts.RetainCell`, a `RetainSlot` on a variable hoisted in -front of a `try`, and a captured variable holding a heap value. A retain that reaches LLVM -lowering is a backstop error, never erased. - -The plan's "released but never owned" rule was dropped. The ownership verifier checks a -stricter form of it and is green over the whole corpus, so no program reaches it. +Owners are counted from the uses of a heap value, not from rc's retains. rc also transfers +ownership by *consuming* a value, which leaves no retain behind: a declaration marked +`__owned_consumed`, a push, a field store. The first implementation counted retain ops, and the +whole-branch review found programs that compiled and then double-freed: `const c = new C()` +pushed in a loop, pushed and then held by a `let`, or taken by two `let`s. `delete` was a +further problem, because it destroys a value that its slot releases again at scope exit. + +The rule as built: + +- A **taking use** of a value V is one of these: a declaration marked `__owned` or + `__owned_consumed` whose initializer is V; a `ts.Store` of V into any reference (a field, an + element, a global or a return slot); an insertion of V by push, unshift or splice; a return; + and any use not on a short borrow list. The borrow list is print, calls, property and element + references, `length`, method and `this` references, arithmetic, comparison, concatenation, a + non-owning declaration, `--di`'s `ts.DebugVariable`, and the retain/release bookkeeping. +- **Owners** of V are its taking uses, plus one if V is released as a temporary. There must be + at most one owner. Zero is a leak, as under rc. +- **Locality**: the taking use is in V's defining block, and nothing uses V after it. That one + check rejects a move inside a loop (§2.6), a move on one branch, and use after move, with no + liveness analysis. Phase 1 relaxes it. +- A **fresh** V is made by `ts.New`, `ts.CreateArray`, `ts.NewArray`, `ts.StringConcat` or + `ts.CharToString`, by a non-call op marked `__owned_result`, by a cast of a constant, or by a + direct call of a function defined in this module. Every function returns its result retained + (rc §9.24), and the call's own `__owned_result` mark does not survive the affine lowering. + Only a fresh V's retains are erased. A retain of anything else is an error, and so is a + retain of a variable hoisted in front of a `try`. +- A cast of a constant is the immortal global, which a release skips, or a copy with one owner, + which a release destroys. It was added because `let s = "abc"` and `v: number[] = []` were + rejected. +- `delete`, `ts.RetainCell` (any captured variable, even a number) and a captured variable + holding a heap value are errors in phase 0. A retain that reaches LLVM lowering is a + backstop error. + +Interface and `any` boxing count as a second owner beside the temporary, so they are rejected. +That is phase 3. + +The planned "released but never owned" rule was dropped on the premise that no program reaches +it. The premise was wrong: rc's latent over-releases are reachable through consumption and +`delete`, as shown above. What closes them is counting consumptions as owners and rejecting +`delete`, not that rule. The ownership verifier now runs *before* inference under own, so +`--verify-ownership` checks rc's pairing on the IR as MLIRGen made it. + +Every probe from the review (32 programs) is now either rejected or produces gc's output under +own AOT. Several of them also mis-run under `-mm=rc`, so the rc model has latent +over-releases of its own; they are listed in the final report for a separate fix. ### 11.2 Measured @@ -441,5 +471,9 @@ Tested both ways: - An owned local declared inside a `try` body is rejected, because its storage is hoisted. - A class field initialized from a parameter, an alias through `let`, and a store of a local into a field are all rejected (a retain of a load). -- Closures that capture heap values are rejected. +- Closures are rejected, whatever they capture: every captured variable's cell gets a + `ts.RetainCell`. +- Interface and `any` boxing, `delete`, a callee that keeps its parameter (`g = c` retains a + value the callee does not own; spec §2.4's owned-by-callee parameter is phase 4), and a move + out of the value's own block are all rejected. - Results of runtime helpers and `declare`d functions are rejected. diff --git a/tslang/lib/TypeScript/LowerToLLVM.cpp b/tslang/lib/TypeScript/LowerToLLVM.cpp index b671a5b2c..212caa511 100644 --- a/tslang/lib/TypeScript/LowerToLLVM.cpp +++ b/tslang/lib/TypeScript/LowerToLLVM.cpp @@ -5728,7 +5728,10 @@ struct NewInterfaceOpLowering : public TsLlvmPattern // generated first: the descriptor's initializer takes their addresses OwnershipRoutineLogic orl(newInterfaceOp, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); auto releaseRoutineName = orl.getOrCreateReleaseRoutine(thisType); - auto retainRoutineName = orl.getOrCreateRetainRoutine(thisType); + // under own nothing retains: see TypeDescriptorOpLowering + auto retainRoutineName = tsLlvmContext->compileOptions.memoryModel == MemoryModelOwn + ? std::string() + : orl.getOrCreateRetainRoutine(thisType); LLVMCodeHelper ch(newInterfaceOp, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); return ch.getOrCreateTypeDescriptorName(thisType, name, TypeOfOpHelper::typeKindFromName(name), diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index b328dd074..d78c00770 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -7,7 +7,7 @@ #include "TypeScript/Passes.h" #include "TypeScript/Defines.h" -#include "llvm/ADT/DenseMap.h" +#include "llvm/ADT/SetVector.h" #include "llvm/ADT/SmallVector.h" #define DEBUG_TYPE "own" @@ -18,11 +18,20 @@ namespace { // Ownership inference for -mm=own, phase 0. See -// docs/superpowers/specs/2026-09-24-own-memory-model-design.md, section 10.6. +// docs/superpowers/specs/2026-09-24-own-memory-model-design.md, sections 10.6 and 11. // -// rc's count for a block is its births plus its retains. Where that provably never exceeds one, -// the block has one owner, the release rc already emits is that owner's destroy, and every -// retain on the way is erased. Anything else is reported: phase 0 knows no other verdict. +// A fresh heap value may have at most one owner: one use that takes it - a local's +// declaration, a store into a field, element, global or return slot, an insertion into an +// array, a return - or, with none of those, the temporary itself, which rc gives back with a +// `ts.Release`. That owner's release is then its destroy, and every retain rc planted on the +// way is erased. Owners are counted from the uses themselves, not from rc's retains: rc also +// transfers ownership by *consuming* a value (`__owned_consumed`, a push, a field store), +// which leaves no retain behind to count. +// +// Phase 0 also keeps the move trivially safe: every taking use sits in the block that made the +// value, and nothing uses the value after it. That one rule rules out a move inside a loop +// (spec 2.6), a move on one branch, and use after move, without a liveness analysis. Anything +// else is an error at its location. class OwnershipInferencePass : public mlir::PassWrapper { public: @@ -32,53 +41,75 @@ class OwnershipInferencePass : public mlir::PassWrapper the retain ops that acquire it (a Retain of the value, a RetainSlot of the - // variable it initializes) - llvm::DenseMap> acquirers; - llvm::SmallVector unattributed; + // every value some ownership operation names, in the order the walk meets them + llvm::SetVector candidates; + llvm::SmallVector retains; f.walk([&](mlir::Operation *op) { if (auto retainOp = mlir::dyn_cast(op)) { - acquirers[retainOp.getReference()].push_back(op); + retains.push_back(op); + candidates.insert(retainOp.getReference()); } else if (auto retainSlotOp = mlir::dyn_cast(op)) { - // A variable with no initializer had its storage hoisted in front of a try, and - // its value arrives by a store this phase does not follow. - auto varOp = retainSlotOp.getSlot().getDefiningOp(); - if (varOp && varOp.getInitializer()) - { - acquirers[varOp.getInitializer()].push_back(op); - } - else + retains.push_back(op); + } + else if (auto releaseOp = mlir::dyn_cast(op)) + { + candidates.insert(releaseOp.getReference()); + } + else if (auto varOp = mlir::dyn_cast(op)) + { + if (varOp.getInitializer() && isOwningVariable(varOp)) { - unattributed.push_back(op); + candidates.insert(varOp.getInitializer()); } } else if (mlir::isa(op)) { - op->emitError("closures that capture a heap value are not supported by -mm=own yet"); + op->emitError("closures that capture a variable are not supported by -mm=own yet"); signalPassFailure(); } + else if (mlir::isa(op)) + { + op->emitError("'delete' is not supported by -mm=own yet"); + signalPassFailure(); + } + + for (auto result : op->getResults()) + { + if (isFresh(result)) + { + candidates.insert(result); + } + } }); - for (auto *op : unattributed) + // Every value gets the owner check, fresh or not: rc can hand a value it did not make to + // two consuming declarations without a single retain (the result of an indirect call + // taken by `let b = a; let c = a;`). Only a fresh value's retains can be erased. + llvm::DenseSet proven; + for (auto value : candidates) { - reportSecondReference(op); + if (hasOneOwner(value) && isFresh(value)) + { + proven.insert(value); + } } + // What is proven loses its retains; any other retain is a second reference nobody + // proved safe - of a loaded value, a parameter, an unknown producer, or of a fresh value + // whose own check was reported above. llvm::SmallVector toErase; - for (auto &[value, retains] : acquirers) + for (auto *op : retains) { - auto total = birth(value) + retains.size(); - if (isFresh(value) && total == 1) + auto value = retainedValue(op); + if (value && proven.contains(value)) { - toErase.append(retains.begin(), retains.end()); - continue; + toErase.push_back(op); } - - for (auto *op : retains) + else if (!value || isFresh(value) == false) { reportSecondReference(op); } @@ -91,47 +122,196 @@ class OwnershipInferencePass : public mlir::PassWrapper(op)) + { + return retainOp.getReference(); + } + + auto varOp = mlir::cast(op).getSlot().getDefiningOp(); + return varOp ? varOp.getInitializer() : mlir::Value(); + } + + static bool isOwningVariable(mlir_ts::VariableOp varOp) + { + return varOp->hasAttr(OWNED_LOCAL_ATTR_NAME) || varOp->hasAttr(OWNED_LOCAL_CONSUMED_ATTR_NAME); + } + + // Made here and held by nobody else: an allocation, a literal cast to its value type, an + // operation rc marks as arriving with a reference, or a direct call of a function this module + // defines (every function returns its result retained, rc 9.24; the call's own mark does not + // survive the affine lowering). A declared callee, an indirect call, a parameter, a load or a + // value merged from branches is not fresh. static bool isFresh(mlir::Value value) { auto *def = value.getDefiningOp(); if (!def) { - return false; // a block argument: a parameter, or a value merged from branches + return false; } - return def->hasAttr(OWNED_RESULT_ATTR_NAME) || isLiteral(def) || - mlir::isa(def); + if (mlir::isa(def)) + { + return true; + } + + if (auto callOp = mlir::dyn_cast(def)) + { + auto callee = mlir::SymbolTable::lookupNearestSymbolFrom(def, callOp.getCalleeAttr()); + return callee && !callee.isDeclaration(); + } + + if (mlir::isa(def)) + { + return false; + } + + return def->hasAttr(OWNED_RESULT_ATTR_NAME) || isLiteral(def); } // A string literal or a constant array cast to its value type. The result is either the // immortal global itself, which a release skips, or a copy nobody else holds, which a - // release destroys - so, acquired once, it has one owner either way. Acquired twice it is - // still an error: which of the two it is depends on the cast. + // release destroys - so, owned once, it has one owner either way. static bool isLiteral(mlir::Operation *def) { auto castOp = mlir::dyn_cast(def); return castOp && castOp.getIn().getDefiningOp(); } - // 1 when the value arrived with a reference the callee took (the retaining return, rc - // §9.24); 0 otherwise - including a string marked by markFreshStringOwned, whose birth is - // the retain MLIRGen emitted beside the mark and is counted as one of its acquisitions. - static unsigned birth(mlir::Value value) + // A use that reads the value without keeping it. Kept deliberately short: anything not listed + // is treated as taking the value, which can only turn a program into an error. + static bool isBorrow(mlir::Operation *user, mlir::Value value) { - auto *def = value.getDefiningOp(); - return def && def->hasAttr(OWNED_RESULT_ATTR_NAME) && - mlir::isa(def) - ? 1 - : 0; + if (mlir::isa(user)) + { + return true; + } + + // `--di`'s record of a folded const's value for the debugger: names it, keeps nothing + if (mlir::isa(user)) + { + return true; + } + + // arguments are borrowed; a callee that keeps one retains it, and that retain is its own + // error + if (mlir::isa(user)) + { + return true; + } + + // the array being pushed onto is written, not taken; what is inserted is taken + if (auto pushOp = mlir::dyn_cast(user)) + { + return pushOp.getOp() == value && !llvm::is_contained(pushOp.getItems(), value); + } + + if (auto unshiftOp = mlir::dyn_cast(user)) + { + return unshiftOp.getOp() == value && !llvm::is_contained(unshiftOp.getItems(), value); + } + + if (auto spliceOp = mlir::dyn_cast(user)) + { + return spliceOp.getOp() == value && !llvm::is_contained(spliceOp.getItems(), value); + } + + // a declaration that does not own what it holds only reads it + if (auto varOp = mlir::dyn_cast(user)) + { + return !isOwningVariable(varOp); + } + + return false; + } + + // One owner, taken where the value was made, and no use after the taking. Reports and returns + // false otherwise. + bool hasOneOwner(mlir::Value value) + { + auto *home = value.getParentBlock(); + + mlir::Operation *taker = nullptr; + auto released = false; + for (auto *user : value.getUsers()) + { + if (mlir::isa(user)) + { + continue; + } + + if (mlir::isa(user)) + { + released = true; + continue; + } + + if (isBorrow(user, value)) + { + continue; + } + + if (user->getBlock() != home) + { + user->emitError("'") << describe(value) << "' is given an owner in another block (a loop or a branch); " + "-mm=own cannot prove a move here yet"; + signalPassFailure(); + return false; + } + + if (taker) + { + reportSecondReference(taker->isBeforeInBlock(user) ? user : taker); + return false; + } + + taker = user; + } + + if (!taker) + { + return true; // the temporary is the one owner, or nobody is and it leaks as under rc + } + + if (released) + { + reportSecondReference(taker); + return false; + } + + // Every other use must come first. A use in another block runs after the whole of this + // one, and so after the move. + for (auto *user : value.getUsers()) + { + if (user == taker || mlir::isa(user)) + { + continue; + } + + if (user->getBlock() != home || taker->isBeforeInBlock(user)) + { + user->emitError("'") << describe(value) << "' is used after its value was moved"; + signalPassFailure(); + return false; + } + } + + return true; } + // The declaration's name survives only as debug metadata (`--di`), the way LowerToLLVM's + // VariableOp lowering reads it. Without it the error still points at the right place. static llvm::StringRef varName(mlir_ts::VariableOp varOp) { - // The declaration's name survives only as debug metadata (`--di`), the way - // LowerToLLVM's VariableOp lowering reads it. Without it the error still points at the - // declaration. if (auto fused = mlir::dyn_cast>(varOp.getLoc())) { if (auto name = fused.getMetadata().getName()) @@ -143,6 +323,19 @@ class OwnershipInferencePass : public mlir::PassWrapper(user)) + { + return varName(varOp); + } + } + + return "this value"; + } + void reportSecondReference(mlir::Operation *op) { llvm::StringRef name = "this value"; @@ -153,6 +346,10 @@ class OwnershipInferencePass : public mlir::PassWrapper(op)) + { + name = varName(varOp); + } op->emitError("'") << name << "' takes a second reference; -mm=own cannot prove a move or a borrow here yet"; signalPassFailure(); diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 7ac35dbed..f9625e479 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2405,13 +2405,26 @@ foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local o FAIL_REGULAR_EXPRESSION "__tslang_inc_ref|__tslang_dec_ref|error|Stack dump") endforeach() +# The ownership verifier runs under own too, on the IR before inference erases its birth takes. +add_test(NAME test-own-verify-ownership + COMMAND $ --emit=mlir-affine --no-default-lib -mm=own --verify-ownership + "${PROJECT_SOURCE_DIR}/test/tester/own/own_fresh_array.ts") +set_tests_properties(test-own-verify-ownership PROPERTIES FAIL_REGULAR_EXPRESSION "error|Stack dump") + # Each negative program has one shape phase 0 cannot prove, and must be rejected with this text. set(own_error_cases "own_err_alias|takes a second reference" "own_err_field_alias|takes a second reference" - "own_err_closure|closures that capture a heap value are not supported" + "own_err_closure|closures that capture a variable are not supported" "own_err_unknown_producer|takes a second reference" - "own_err_two_owners|takes a second reference") + "own_err_two_owners|takes a second reference" + "own_err_const_twice|takes a second reference" + "own_err_push_let|takes a second reference" + "own_err_push_loop|is given an owner in another block" + "own_err_field_loop|is given an owner in another block" + "own_err_use_after_move|is used after its value was moved" + "own_err_interface|takes a second reference" + "own_err_delete|'delete' is not supported by -mm=own yet") foreach(own_error_case ${own_error_cases}) string(FIND "${own_error_case}" "|" own_error_sep) string(SUBSTRING "${own_error_case}" 0 ${own_error_sep} own_error_name) diff --git a/tslang/test/tester/own/own_err_const_twice.ts b/tslang/test/tester/own/own_err_const_twice.ts new file mode 100644 index 000000000..1bb5a1c0a --- /dev/null +++ b/tslang/test/tester/own/own_err_const_twice.ts @@ -0,0 +1,9 @@ +// -mm=own, phase 0 rejects: two locals would each own the one instance `a` names. +class C { v: number[] = [1,2,3]; } +function main() { + const a = new C(); + let b = a; + let c = a; + print(b.v.length + c.v.length); + print("done."); +} diff --git a/tslang/test/tester/own/own_err_delete.ts b/tslang/test/tester/own/own_err_delete.ts new file mode 100644 index 000000000..0653ac901 --- /dev/null +++ b/tslang/test/tester/own/own_err_delete.ts @@ -0,0 +1,7 @@ +// -mm=own, phase 0 rejects: `delete` destroys the value while the slot still releases it at scope exit. +class C { v = 1; } +function main() { + let c = new C(); + delete c; + print("done."); +} diff --git a/tslang/test/tester/own/own_err_field_loop.ts b/tslang/test/tester/own/own_err_field_loop.ts new file mode 100644 index 000000000..e89053ce5 --- /dev/null +++ b/tslang/test/tester/own/own_err_field_loop.ts @@ -0,0 +1,13 @@ +// -mm=own, phase 0 rejects: a field store inside a loop of a string made outside it. +class N { v: number[] = []; s: string = ""; } +function main() { + let k: number = 3; + const n = new N(); + const s = "a" + k; + for (let i = 0; i < 3; i++) { + n.s = s; + const filler = "zzzzzzzz" + i; + print(n.s, filler); + } + print("done."); +} diff --git a/tslang/test/tester/own/own_err_interface.ts b/tslang/test/tester/own/own_err_interface.ts new file mode 100644 index 000000000..579e278f9 --- /dev/null +++ b/tslang/test/tester/own/own_err_interface.ts @@ -0,0 +1,11 @@ +// -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_push_let.ts b/tslang/test/tester/own/own_err_push_let.ts new file mode 100644 index 000000000..381cbcbad --- /dev/null +++ b/tslang/test/tester/own/own_err_push_let.ts @@ -0,0 +1,10 @@ +// -mm=own, phase 0 rejects: the array and `d` would both own the instance. +class C { x: number = 5; v: number[] = [1, 2]; } +function main() { + const arr: C[] = []; + const c = new C(); + arr.push(c); + let d = c; + print(arr[0].x, d.x); + print("done."); +} diff --git a/tslang/test/tester/own/own_err_push_loop.ts b/tslang/test/tester/own/own_err_push_loop.ts new file mode 100644 index 000000000..7afc78ec7 --- /dev/null +++ b/tslang/test/tester/own/own_err_push_loop.ts @@ -0,0 +1,9 @@ +// -mm=own, phase 0 rejects: a push inside a loop would give the one instance a new owner each iteration. +class C { x: number = 5; v: number[] = [1, 2]; } +function main() { + const arr: C[] = []; + const c = new C(); + for (let i = 0; i < 3; i++) arr.push(c); + print(arr[0].x, arr[2].x); + print("done."); +} diff --git a/tslang/test/tester/own/own_err_use_after_move.ts b/tslang/test/tester/own/own_err_use_after_move.ts new file mode 100644 index 000000000..d41b232c8 --- /dev/null +++ b/tslang/test/tester/own/own_err_use_after_move.ts @@ -0,0 +1,9 @@ +// -mm=own, phase 0 rejects: `b` takes `a`'s instance and gives it up at the end of its block; `a` is used after. +class C { v: number[] = [1,2,3]; } +function main() { + const a = new C(); + { let b = a; print(b.v.length); } + const f = [9,9,9,9,9,9,9]; + print(a.v.length); + print("done."); +} diff --git a/tslang/tslang/transform.cpp b/tslang/tslang/transform.cpp index 952df7220..006699905 100644 --- a/tslang/tslang/transform.cpp +++ b/tslang/tslang/transform.cpp @@ -134,16 +134,17 @@ int runMLIRPasses(mlir::MLIRContext &context, llvm::SourceMgr &sourceMgr, mlir:: // Ahead of the optimisation passes, so it checks what MLIRGen and the affine lowering // actually produced rather than what the inliner left of it, and after the lowering, // because that is what turns unwind paths into ordinary CFG edges. - // Under own, before the verifier so it checks the IR that will be lowered: inference - // either erases a retain or reports it, and a program it reports stops here. - if (compileOptions.memoryModel == MemoryModelOwn && !ownSkipInference) + if (verifyOwnership) { - pm.nest().addPass(mlir::typescript::createOwnershipInferencePass()); + pm.nest().addPass(mlir::typescript::createOwnershipVerifierPass()); } - if (verifyOwnership) + // Under own, after the verifier: the verifier pairs rc's acquisitions with their + // releases, and inference erases the acquisitions it proves are the one owner's. Inference + // either erases a retain or reports it, and a program it reports stops here. + if (compileOptions.memoryModel == MemoryModelOwn && !ownSkipInference) { - pm.nest().addPass(mlir::typescript::createOwnershipVerifierPass()); + pm.nest().addPass(mlir::typescript::createOwnershipInferencePass()); } #ifdef ENABLE_OPT_PASSES