From 7e1dc922eceecb332befa486a3fb43c06283c692 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Wed, 21 Oct 2026 22:22:40 +0100 Subject: [PATCH 01/57] Design: -mm=own compiles the ray tracer Two source edits in an own copy of the benchmark (Shared, Plane.normal returns a copy) and nine compiler gaps to close: immortal globals, borrowing records, owning literals, Shared of a literal, field reads into a let, read after a move, facts through private interfaces, reduce, and leftovers. Co-Authored-By: Claude Opus 5.5 --- .../specs/2026-10-21-own-raytrace-design.md | 266 ++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 tslang/docs/superpowers/specs/2026-10-21-own-raytrace-design.md diff --git a/tslang/docs/superpowers/specs/2026-10-21-own-raytrace-design.md b/tslang/docs/superpowers/specs/2026-10-21-own-raytrace-design.md new file mode 100644 index 000000000..12c6aeb4d --- /dev/null +++ b/tslang/docs/superpowers/specs/2026-10-21-own-raytrace-design.md @@ -0,0 +1,266 @@ +# `-mm=own` compiles the ray tracer + +Date: 2026-10-21. Status: design, approved decisions marked **USER**. + +## Why + +The ray tracer benchmark (`tslang/benchmarks/raytrace`, PR #634) runs under gc, rc and none, and +is the program the README quotes for performance. Under `-mm=own` it does not compile: 44 errors. +`own` is meant to accept ordinary object-oriented TypeScript that has a single owner for each +value, and this program almost does. Its few genuinely shared objects are the two surfaces. + +**USER:** small deliberate source edits like `Shared` for genuinely shared objects are fine; every +other error is a compiler gap and gets fixed in the compiler. `own` gets its own copy of the +benchmark with the edit; gc, rc and none keep running the unedited source. + +## Goals + +- `rtbench_own.ts` (the benchmark with the two source edits below, nothing else) compiles + under `-mm=own --opt --opt_level=3 --no-default-lib`, prints checksum 38837369, and frees what it + allocates (peak working set comparable to rc's 4 MB). +- Each gap below is fixed as a general rule, with minimal positive tests that run under gc, rc and + own, teeth (a deliberately broken variant that frees too early must fail), and negative tests for + the cases that must stay errors. +- The benchmark table gains an own row. + +## Non-goals + +- Making the unedited `rtbench.ts` compile under own. The surfaces are shared; that is `Shared`. +- Stack promotion of the ray tracer's temporaries (a separate item: caller-provided result slot). +- Performance work beyond what falls out. +- Annotations. Everything is inferred, as `__own_result_borrows` is today. + +## Anti-goals + +- **No unsound acceptance.** A rule that accepts a program and then frees a block someone still + reads, or frees it twice, is worse than the error it removes. When unsure, keep the error. +- No runtime counting under own outside `Shared`. +- No change in what gc, rc or none compile or print, except where a fix in shared MLIRGen code is + also a fix there (say so in the PR). + +## The source edits (the only two) + +`rtbench_own.ts` differs from `rtbench.ts` by two edits. + +**1. `Plane.normal` returns a fresh copy (USER).** `Plane.normal` returned `this.norm`, a borrow of +its own field, while `Sphere.normal` returns a fresh vector. Through the `Thing` interface a caller +cannot know whether it owns the result, and a run-time flag is ruled out. So: + +```ts +public normal(pos: Vector): Vector { return new Vector(this.norm.x, this.norm.y, this.norm.z); } +``` + +The compiler does not insert such copies itself: for class objects a copy changes identity. + +**2. The surfaces are `Shared`:** + +```ts +interface Thing { ...; surface: Shared; } +class Sphere { constructor(public center: Vector, radius: number, public surface: Shared) ... } +class Plane { constructor(public norm: Vector, public offset: number, public surface: Shared) ... } +module Surfaces { + export let shiny = new Shared({ ... }); + export let checkerboard = new Shared({ ... }); +} +// and every thing.surface.X becomes thing.surface.value.X +``` + +With both edits it compiles and prints the right checksum under rc today. + +## The gaps + +Each has a minimal repro (kept as the test). "Today" is main 49b5f6bb. Error texts are the +current ones. + +### G1. Immortal globals (USER decision) + +A global that is never reassigned after its initializer is **immortal**: like a string literal, a +release skips it, so a read of it may be returned, stored into a field, element or another global, +captured, or given to a call that keeps it, the way Rust hands out a `'static`. + +```ts +class K { static w = new V(1); static d = K.w; static f() { return K.w; } } +function g(n: number) { return n > 1 ? K.w : new V(n); } // mixed: immortal or fresh +``` + +Today: `'this value' borrows the global 'K.w' and cannot be stored, returned or captured`. + +- **Never reassigned** (decided here, correct me): a class static or module-level `let`/`const` + whose only store is its initializer, in this module. An exported global stays mortal unless the + module is the program's entry and nothing imports it - other modules may assign it. (In the ray + tracer, `Color.*` statics are not exported; `Surfaces.*` are `Shared` anyway.) +- **Which values** (builder check, my call): only a global whose initializer is a fresh value + (`new C()`, a fresh call result, an owning literal) or another immortal global. A borrowed + initializer (`static x = arr[0]`, a call that returns a borrow) stays mortal and keeps today's + error: marking a block someone else owns immortal would turn its later free into a silent leak. +- **Mechanism:** the existing `HEAP_BLOCK_IMMORTAL` header (OwnershipRoutineLogic.h): the global's + initializer marks the block it stores immortal; inference treats a load of such a global as it + treats `isImmortalLiteral` - owns nothing, may go anywhere, its release erased or skipped. +- **A mixed return** (`n > 1 ? K.w : new V(n)`) is then an owned result on every path: the caller + "owns" the immortal one too, and its release is a no-op at run time. Today's error + `returns a borrow of its argument on some paths and another value on others` must not fire for an + immortal path. +- **Only the global's own value is immortal.** Its fields are not: `K.w.child = x` still frees + the old child, so a borrow of `K.w.child` stays bounded as today. +- Under gc/rc/none nothing changes. + +### G2. Borrowing records (USER decision) + +A function that returns an object literal built from borrowed values returns a **borrow** of +those sources (inferred, like a Rust struct with a lifetime): + +```ts +interface I { t: T; d: number; } +class T { k = 1; hit(): I { return { t: this, d: 2 }; } } // result borrows `this` +``` + +Today: `an object literal that holds a value it owns is moved only into a local or returned as it +is` and `argument 'this value' is given to 'T.hit', which keeps it`. + +- The result is `__own_result_borrows` of the receiver/parameters it holds (directly, or a field + read of one). Several sources (ray tracer: `{ thing: this, ray: ray, dist }` borrows `this` and + `ray`) means several - today's fact names one argument; it becomes a set. +- **Mixed fields:** a record may hold owned fields (`new V()`) and borrowed ones. Its release frees + the owned fields and leaves the borrowed ones (a per-record routine skipping borrowed fields, as + `ts.own_borrowing_fields` does for generator states in phase 7b). +- **The release travels with the value** (builder check, my call): which fields a record's release + skips is decided where the record is made, not from the static type where it is released. Then + records from different creation sites (`Sphere.intersect` and `Plane.intersect`) may meet in one + local, as `closestInter` does. +- **Flow:** borrow-ness flows through returns, merges and locals as for plain borrows today: + `intersections` keeps the best of several such records in a local and returns it - its result + borrows its own `ray` and `scene`. +- **Stays an error:** storing a borrowing record where it can outlive a source (field, global, + element of a longer-lived array, closure that escapes). +- Across modules: an open (exported) function has to say it with `@lends`, which today names one + parameter; it may name several. (The ray tracer is one module, so this can come last.) + +### G3. Owning literals moved anywhere + +An object literal that owns its fields (fresh values, or locals moved in) may move into a call +argument, an array literal element, a nested literal, or be returned through an interface type: + +```ts +len({ start: p, dir: d }); // p and d move in; the callee borrows the record +const ls: L[] = [{ pos: new V(1) }, { pos: new V(2) }]; +return { ls: [new V(1), new V(2)], c: new V(3) }; +``` + +Today: `an object literal that holds a value it owns is moved only into a local or returned as it +is; -mm=own cannot move it here yet` (13 of the 44). + +- This includes a literal converted to an interface type even when it goes into a local: + `const r: Ray = { start: new V(1), dir: new V(2) }` fails today with the same error. +- An argument record is a temporary: the caller owns it and releases it after the call, as it does + a fresh `new C()` argument. Whether the callee borrows or keeps it comes from the callee's facts, + as for any argument. +- When a field is a local moved in (`{ start: p }`), `p` is moved (use after = error, G7 applies). + When it is a borrow (`{ start: this.pos }`), the record is a borrowing record (G2) bounded by the + source, and as a call-argument temporary that is always fine. + +### G4. `Shared` of an object literal + +```ts +let g = new Shared({ f: function (x: number) { return x; }, roughness: 2 }); // global or local +``` + +Today: global `the initializer of global 'g' takes a second reference`; local `borrows a field +but is used here after it may be released or overwritten`. `new Shared(new C())` already works: +the literal must move into the handle the same way. + +### G5. `reduce` takes its accumulator + +```ts +const r = a.reduce((acc: V, v: V) => V.plus(acc, v), new V(0)); +``` + +Today: `is a parameter, kept here, but -mm=own cannot make the callers of '___array_reduce<...>' give +it up` and `borrows an element but is used here after it may be released`. The initial value moves +in, each callback result replaces the accumulator (the previous one released unless the callback +returned it - `addLight` returns `col` on one path), elements are borrowed by the callback, and the +final accumulator is returned owned. Under `--no-default-lib` this is the generated +`___array_reduce`. + +- **A callback that returns its accumulator on some paths** (builder check, my call): the + callback's accumulator parameter is inferred as owned (kept), like a Rust `fn(acc: Color) -> + Color` by value. Returning it moves it out; a path that returns something else releases it. All + static: no run-time pointer comparison (anti-goal: no run-time bookkeeping). The same rule serves + any function that returns a parameter on some paths and a fresh value on others, when every + caller can give the argument up. +- **The default library's `reduce`** is a follow-up, not this series: the benchmark builds with + `--no-default-lib`, and stating "the callback's result replaces the accumulator" in `lib.d.ts` + needs a marker that does not exist yet. + +### G6. A field read into a `let` + +```ts +function f(r: R) { let d = r.dir; let p = V.plus(d, d); return V.plus(p, d); } +``` + +Today: `takes a second reference; -mm=own cannot prove a move or a borrow here yet`. `d` is a borrow +of a parameter's field, bounded by the parameter; calls that cannot drop it (signature facts) must +not end it. + +### G7. Read after a move while the new owner holds it (USER decision) + +```ts +if (v.x > m) { best = v; m = v.x; } +``` + +Today: `is used here after its value was moved`. The read is a borrow through the new owner: sound +while that owner is not overwritten, released or moved on between the move and the read (and the +value is not mutated through a path that would free it). Otherwise it stays the error. + +### G9. Facts through a module-private interface (builder check) + +`OwnershipSignaturePass` treats a call through an interface as open: it does not name what it +reaches, so it gets no facts. The ray tracer's hot calls all go through interfaces +(`scene.things[i].intersect(ray)`, `isect.thing.normal(pos)`, `surface.value.diffuse(pos)`), so +G2's borrowing result on `Sphere.intersect` would never reach `intersections`. + +- **Closed world for interfaces** (my call): when an interface is not exported and every class or + object literal that implements it is in this module (and none is exported as that interface), a + call through it gets the meet of its implementations' facts, as a private virtual family already + does. +- If the implementations disagree in a way the meet cannot express, the call stays open and the + existing errors stand. +- Its own PR, before G2. + +### G8. Not yet reproduced in isolation + +The ray tracer also has `borrows a field but is used here after it may be released or overwritten` +at `Camera`'s constructor, `Sphere.normal` and `render`'s `getPoint` (`this.pos`, `this.center`, +`camera.forward`), one `moved here on some paths only` on `shade`'s ternary, and one `takes a +second reference` at `let d = isect.ray.dir`. Minimal versions of each compile: the `Camera` +constructor with chained calls over `this.pos`, `normal()` through the `Thing` interface, the +`getPoint` closure reading `camera.forward`, and the `depth >= maxDepth ? … : this.call()` ternary +(probes h1-h4). `let d = i.ray.dir` through two interface fields reproduces, and is G6. The rest +are therefore expected to be follow-ons of G1-G7 inside the full program. Each is +re-checked after G1-G7 land; any that remains gets its own repro and fix, in this same series. + +## Order of work + +Each item is one PR, small and squash-merged, with its tests (gc, rc, own), teeth, negatives, a +corpus run (`own` corpus count may only go up) and the full suite on Windows; Linux (WSL) for any +change to what is released, since the Windows heap hides double frees. + +1. G1 immortal globals. +2. G4 `Shared` of a literal. +3. G3 owning literals moved anywhere. +4. G6 field read into a let. +5. G7 read after a move. +6. G9 facts through a module-private interface. +7. G2 borrowing records (the largest; needs G3 and G9). +8. G5 `reduce`. +9. G8 leftovers; then `tests/raytrace_own.ts` (the own copy at a test size) under own in the suite, + and `benchmarks/raytrace/rtbench_own.ts` + an own row in both READMEs (after #634 merges). + +## Left to the builder + +- Exact attribute names and which pass decides each fact (signature pass vs inference), following + the existing split: cross-function facts in `OwnershipSignaturePass`, per-function in + `OwnershipInferencePass`, routines in `OwnershipRoutineLogic.h`. +- Test names and CMake anchors (use distinct anchors per PR - parallel PRs conflicted before). +- Whether a step splits into two PRs. +- The results of each step go in `2026-09-24-own-memory-model-design.md` as a new section, as + earlier phases did. From ce79ca0189df92cb77f868e8b39ad9e912e6d2c0 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Wed, 21 Oct 2026 22:29:13 +0100 Subject: [PATCH 02/57] Plan: -mm=own compiles the ray tracer Nine tasks, one PR each: immortal globals, Shared of a literal, owning literals, field reads into a let, read after a move, facts through private interfaces, borrowing records, reduce, and the ray tracer test and benchmark row. Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-21-own-raytrace.md | 414 ++++++++++++++++++ 1 file changed, 414 insertions(+) create mode 100644 tslang/docs/superpowers/plans/2026-10-21-own-raytrace.md diff --git a/tslang/docs/superpowers/plans/2026-10-21-own-raytrace.md b/tslang/docs/superpowers/plans/2026-10-21-own-raytrace.md new file mode 100644 index 000000000..8207d12a0 --- /dev/null +++ b/tslang/docs/superpowers/plans/2026-10-21-own-raytrace.md @@ -0,0 +1,414 @@ +# `-mm=own` compiles the ray tracer - 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:** the ray tracer, with two small source edits, compiles and runs correctly under `-mm=own`, by closing nine compiler gaps as general rules. + +**Architecture:** every change is in the own analysis: cross-function facts in +`OwnershipSignaturePass.cpp`, per-function moves and borrows in `OwnershipInferencePass.cpp`, +shared predicates in `OwnershipFacts.h`, and release routines in +`include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h`. Each task is one PR off `main`, small +and squash-merged, in the order below. + +**Tech Stack:** C++17, MLIR/LLVM 22, tslang `ts` dialect, CMake/CTest, MSVC (Windows) and GCC (WSL). + +**Spec:** `tslang/docs/superpowers/specs/2026-10-21-own-raytrace-design.md` (approved 2026-10-21). +Background: `tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md` (§2 semantics, +§15 signature pass, §23 `Shared`, §24 assignment after a move). + +## Global Constraints + +- No unsound acceptance: when a rule cannot prove the block outlives every use, keep the error. +- No run-time counting under own outside `Shared`: every positive own test is also in the + `own_test` list, whose `test-own-no-counting-*` check fails on `__tslang_inc_ref|__tslang_dec_ref`. +- gc, rc and none compile and print exactly what they did; every positive test runs under gc, rc + and own (and none unless it allocates in a loop). +- Diagnostics keep their existing texts; new rejections reuse an existing text where one fits. +- Build: `cmake --build __build/tslang/windows-msbuild-2026-release --config Release --parallel 8` + (drop to `--parallel 4` if a `.td` changes). Tests: `cd __build/tslang/windows-msbuild-2026-release && ctest -j 16 -C Release` + (`-C Release` is required; without it nothing runs). Before a PR the full suite has no new + failures against `main`. +- Any change to what is released or freed also runs the own tests on Linux (the WSL build): + the Windows heap hides double frees. +- Teeth for every new acceptance rule: a temporary `getenv("OWN_TEETH")` switch that skips the + rule's safety condition must make the new positive test crash or print wrong under AOT + (`test-compile-own-*`), then the switch is removed. Commit before checking out anything. +- Test files go in `tslang/test/tester/own/`; positives join the `own_test` foreach in + `tslang/test/tester/CMakeLists.txt` (~line 3460) and get a gc/rc(/none) foreach of their own; + negatives join `own_error_cases` (~line 3624) as `"name|regex"` (no `;` in the regex). +- Each task appends a results section to `2026-09-24-own-memory-model-design.md` (§25, §26, ...). +- Commits are GPG-signed; on `gpg: signing failed: Timeout` run `gpg-connect-agent "scd killscd" /bye` + and retry. Never `--no-gpg-sign`. + +## Review Focus + +- An immortal global's field overwritten (`K.w.child = new C()`) must still free the old child, + and a borrow of `K.w.child` must still end there - Task 1, `own_err_immortal_field_overwritten`. +- A read after a move where the new owner is overwritten on one path only must stay an error - + Task 5, `own_err_read_after_move_overwritten`. +- A borrowing record stored into a field of a longer-lived object must stay an error - Task 7, + `own_err_borrowing_record_stored`. +- A literal argument given to a callee that keeps it must move (the caller must not free it) - + Task 3, `own_literal_moves` (`keepRay` case). +- An interface with an exported implementer must stay open (no facts) - Task 6, + `own_err_interface_exported_impl`. + +## Shared helpers + +Each repro below is minimal and is the test. `V` in the tests is: + +```ts +class V { + constructor(public x: number) {} + static plus(a: V, b: V) { return new V(a.x + b.x); } +} +``` + +Debugging recipe (not committed): `tslang --emit=mlir-affine --opt --opt_level=3 --no-default-lib -mm=own --mlir-print-ir-after-all --mlir-disable-threading file.ts 2> ir.txt` +shows the IR each pass gets; `--stack-promotion-report` is unrelated and stays off. + +--- + +### Task 1: Immortal globals (G1) + +**Files:** +- Modify: `tslang/lib/TypeScript/OwnershipFacts.h` (attr name + predicate) +- Modify: `tslang/lib/TypeScript/OwnershipSignaturePass.cpp` (`moveIntoGlobals`, ~line 118; `writtenGlobals`, ~line 160) +- Modify: `tslang/lib/TypeScript/OwnershipInferencePass.cpp` (`isImmortalLiteral`, ~line 3840; `reportShapeOfSecondReference`, ~line 4690) +- Modify: the own global-initializer lowering in `tslang/lib/TypeScript/LowerToLLVM.cpp` (`GlobalOpLowering`) +- Test: `tslang/test/tester/own/own_immortal_global.ts`, `own_err_global_returned.ts` (rewritten), `own_err_immortal_borrowed_init.ts`, `own_err_immortal_field_overwritten.ts` + +**Interfaces:** +- Produces: `#define OWN_IMMORTAL_GLOBAL_ATTR_NAME "__own_immortal"` (unit attr on `ts.Global`), and + `bool own_facts::isImmortalGlobalRead(mlir::Value value)` - true for a `ts.Load` of a + `ts.AddressOf` whose global carries the attr (looking through views as `rootOf` does). + Tasks 7 and 8 treat such a value as owning nothing. + +- [ ] **Step 1: Write the positive test** `own_immortal_global.ts`: + +```ts +// V as in the plan's shared helpers +class K { + static w = new V(1); + static d = K.w; // an immortal global from another + static f() { return K.w; } // returned +} +class H { v: V; constructor() { this.v = K.w; } } // stored into a field +function g(n: number) { return n > 1 ? K.w : new V(n); } // immortal or fresh +function main() { + const h = new H(); + print(K.f().x, K.d.x, h.v.x, g(2).x, g(0).x); // 1 1 1 1 0 + let s = 0; + for (let i = 0; i < 100000; i++) { s += g(i % 3).x; } // fresh ones are freed + print(s); // 66666 + assert(s == 66666); + print("done."); +} +``` + +- [ ] **Step 2: Write the negatives.** + - Rewrite `own_err_global_returned.ts`: keep its body and add `function reset() { V.def = new V(2); }` called from `main` after the print. A reassigned global stays mortal. Regex unchanged: `borrows the global 'V.def' and cannot be stored, returned or captured`. + - `own_err_immortal_borrowed_init.ts`: `class K { static list = [new V(1)]; static first = K.list[0]; }` and `function main() { print(K.first.x); }`. Regex: `the initializer of global 'K.first' takes a second reference`. + - `own_err_immortal_field_overwritten.ts`: `class N { constructor(public c: V) {} }`, `class K { static n = new N(new V(1)); }`, `function main() { const c = K.n.c; K.n.c = new V(2); print(c.x); }`. Regex: `borrows .* but is used here after .* released or overwritten`. +- [ ] **Step 3: Register** `own_immortal_global` in the `own_test` list and in a `foreach(mm rc gc none)` pair; add the three negatives to `own_error_cases`. +- [ ] **Step 4: Build and run** `ctest -C Release -R "immortal|global_returned"`. Expected: `own_immortal_global` FAILS under own with `borrows the global 'K.w'`; gc/rc/none PASS; the negatives PASS. +- [ ] **Step 5: Implement.** + - In `OwnershipSignaturePass`, after `writtenGlobals` is filled: a global gets `__own_immortal` when it is not in `writtenGlobals`, not exported, and its initializer region's stored value is fresh, holds no block, or is a load of another `__own_immortal` global (fixpoint over globals in module order). Run this before `moveIntoGlobals`, which treats a load of an immortal global like `isConstantData`. + - In inference, `isImmortalLiteral(value)` also returns `isImmortalGlobalRead(value)`; `reportShapeOfSecondReference`'s global branch is then unreachable for immortal globals. + - Lowering, own only: when a `ts.Global` with `__own_immortal` stores a heap block, write `HEAP_BLOCK_IMMORTAL` into its header (the same header word `LLVMCodeHelperBase` writes for literals, ~line 290), so every release skips it at run time. +- [ ] **Step 6: Run** `ctest -C Release -R "immortal|global_returned|own"`. Expected: all PASS, `test-own-no-counting-own_immortal_global` PASS. +- [ ] **Step 7: Teeth:** with `OWN_TEETH` set, skip the header store; `test-compile-own-own_immortal_global` must fail (the first release frees `K.w`). Remove the switch. +- [ ] **Step 8: Full suite, WSL own tests, spec §25, commit, PR** ("-mm=own: a global never reassigned is immortal"). + +### Task 2: `Shared` of an object literal (G4) + +**Files:** +- Modify: `tslang/lib/TypeScript/OwnershipSignaturePass.cpp` (`moveIntoGlobals`: `touchesHandle`, `isFresh`) +- Modify: `tslang/lib/TypeScript/OwnershipInferencePass.cpp` (the handle construction path of §23.2) +- Test: `tslang/test/tester/own/own_shared_literal.ts` + +**Interfaces:** +- Consumes: nothing new. Produces: none. + +- [ ] **Step 1: Write the test** `own_shared_literal.ts`: + +```ts +interface S { f: (x: number) => number; roughness: number; } +let g = new Shared({ f: function (x: number) { return x * 2; }, roughness: 2 }); +function main() { + let l = new Shared({ f: function (x: number) { return x + 1; }, roughness: 3 }); + const m = l; + print(g.value.f(3), g.value.roughness, l.value.f(3), m.value.roughness); // 6 2 4 3 + print("done."); +} +``` + +- [ ] **Step 2: Register** in `own_test` and a `foreach(mm rc gc none)`. Run `ctest -C Release -R own_shared_literal`. Expected: own FAILS (`the initializer of global 'g' takes a second reference`, and the local's `borrows a field`); gc/rc/none PASS. +- [ ] **Step 3: Implement:** a boxed object literal (the `ts.NewInterface`/box of a folded literal, which §18.2 treats as a view) given to the `Shared` constructor moves in exactly as `new C()` does - in `moveIntoGlobals` (the retain is the handle's own) and in the inference's handle construction. +- [ ] **Step 4: Run** `ctest -C Release -R "own_shared|shared"`. Expected: PASS, including the existing `Shared` tests. +- [ ] **Step 5: Teeth** (skip the move: the literal is freed with the frame while the handle points at it), full suite, WSL, spec §26, commit, PR. + +### Task 3: Owning literals moved anywhere (G3) + +**Files:** +- Modify: `tslang/lib/TypeScript/OwnershipInferencePass.cpp` (`recordRetainOf`, `checkValueMoves` ~line 3996, the literal branch of `reportShapeOfSecondReference` ~line 4792) +- Modify: `tslang/lib/TypeScript/OwnershipFacts.h` if the "owning record" predicate moves there +- Test: `own_literal_moves.ts`; `own_err_record_literal_given.ts` becomes the positive `own_record_literal_given.ts`; new negative `own_err_literal_moved_twice.ts` + +**Interfaces:** +- Produces: `bool own_facts::isOwningRecord(mlir::Value value)` - an object literal (or its interface view) whose fields each are fresh, a moved local, or hold no block. Task 7 builds the borrowing variant beside it. + +- [ ] **Step 1: Write the positive test** `own_literal_moves.ts`: + +```ts +// V as in the plan's shared helpers +interface Ray { start: V; dir: V; } +interface L { pos: V; } +interface Sc { ls: V[]; c: V; } +class Keeper { rays: Ray[] = []; } +function len(r: Ray) { return r.start.x + r.dir.x; } +function keepRay(k: Keeper, r: Ray) { k.rays.push(r); } +function mk(): Sc { return { ls: [new V(1), new V(2)], c: new V(3) }; } +function main() { + const p = new V(1); + const d = new V(2); + print(len({ start: p, dir: d })); // 3 (p, d move in) + const q = new V(1); + print(len({ start: V.plus(q, q), dir: q })); // 3 (fresh + moved) + const ls: L[] = [{ pos: new V(1) }, { pos: new V(2) }]; + const r: Ray = { start: new V(4), dir: new V(5) }; + const s = mk(); + print(ls[1].pos.x, r.dir.x, s.c.x, s.ls[1].x); // 2 5 3 2 + const k = new Keeper(); + for (let i = 0; i < 1000; i++) keepRay(k, { start: new V(i), dir: new V(1) }); + print(k.rays.length, k.rays[999].start.x); // 1000 999 + print("done."); +} +``` + +- [ ] **Step 2: Negatives.** Move `own_err_record_literal_given.ts` to `own_record_literal_given.ts` (positive, prints `1`) and drop its `own_error_cases` line. Add `own_err_literal_moved_twice.ts`: `const p = new V(1); const k = new Keeper(); keepRay(k, { start: p, dir: new V(2) }); keepRay(k, { start: p, dir: new V(3) });`. Regex: `is used here after its value was moved`. +- [ ] **Step 3: Register; run** `ctest -C Release -R "literal_moves|record_literal|literal_moved_twice"`. Expected: positives FAIL under own with `an object literal that holds a value it owns`; gc/rc/none PASS. +- [ ] **Step 4: Implement:** an owning record is a value like a fresh object - it may move into a call argument (the caller releases it after a borrowing callee; a keeping callee takes it), an array literal element, a nested literal field, a local through an interface view, or the result. A moved local inside it is moved at the literal (use after = existing use-after-move error). A field read (`this.pos`) inside it is left to Task 7: until then such a literal is accepted only as a call-argument temporary. +- [ ] **Step 5: Run** the same filter plus `-R own`. Expected: PASS. Teeth (skip releasing the argument record: rc/own leak shows nothing - use the double-free direction instead: release `p` after the call too; AOT must crash). Full suite, WSL, spec §27, commit, PR. + +### Task 4: A field read into a `let` (G6) + +**Files:** +- Modify: `tslang/lib/TypeScript/OwnershipInferencePass.cpp` (`decideSlot` ~line 1887 and the "takes a second reference" path through `reportSecondReference` ~line 4655) +- Test: `own_field_chain_let.ts` + +**Interfaces:** none new. + +- [ ] **Step 1: Write the test** `own_field_chain_let.ts`: + +```ts +// V as in the plan's shared helpers +interface R { dir: V; } +interface Is { ray: R; dist: number; } +function f(r: R) { let d = r.dir; let p = V.plus(d, d); return V.plus(p, d); } +function sh(i: Is) { let d = i.ray.dir; let pos = V.plus(d, i.ray.dir); return V.plus(d, pos); } +function main() { + const r: R = { dir: new V(1) }; + const i: Is = { ray: { dir: new V(2) }, dist: 3 }; + print(f(r).x, sh(i).x); // 3 6 + print("done."); +} +``` + +- [ ] **Step 2: Register; run.** Expected under own: FAIL with `takes a second reference` (after Task 3, the literals themselves pass). +- [ ] **Step 3: Implement:** a `let` initialized from a field read of a parameter (through any chain of field reads and interface views) borrows, bounded by that parameter, and a call ends it only when the call's facts say it may drop (as `own_err_param_read_call` already pins). +- [ ] **Step 4: Run** `ctest -C Release -R "field_chain_let|field_let|param_read_call"`. Expected: PASS (the existing negatives still fail as they should). Teeth, full suite, spec §28, commit, PR. + +### Task 5: Read after a move while the new owner holds it (G7) + +**Files:** +- Modify: `tslang/lib/TypeScript/OwnershipInferencePass.cpp` (`checkSlotMove` ~line 4400, `reportUseAfterMove` ~line 4983) +- Test: `own_read_after_move.ts`; `own_err_field_alias.ts` and `own_err_union_moved.ts` rewritten; new `own_err_read_after_move_overwritten.ts` + +**Interfaces:** +- Produces: in the inference, a use after a move is a **read** when it is a field/element load, a + call argument the callee borrows (signature facts), a comparison, or a method call through it + whose facts do not keep it. Any other use (a store, a `let` binding, a keeping call, a return) is + still a second owner. + +- [ ] **Step 1: Write the positive test** `own_read_after_move.ts`: + +```ts +// V as in the plan's shared helpers +class N { v: number[] = []; } +class C { x: number = 5; } +class H { c: C | null = null; } +function main() { + const a: number[] = [1]; + const n = new N(); + n.v = a; + print(a.length); // 1 + const h = new H(); + const c = new C(); + h.c = c; + print(c.x); // 5 + const vs = [new V(1), new V(3), new V(2)]; + let best: V = undefined; + let m = 0; + for (let i in vs) { + let v = V.plus(vs[i], vs[i]); + if (v.x > m) { best = v; m = v.x; } // read after the move into best + } + print(best.x); // 6 + print("done."); +} +``` + +- [ ] **Step 2: Negatives.** `own_err_field_alias.ts`: insert `n.v = [2];` between `n.v = a;` and `print(a.length);`. `own_err_union_moved.ts`: insert `h.c = null;` before `print(c.x);`. Regex for both becomes `is used here after its value was moved`. New `own_err_read_after_move_overwritten.ts` (overwritten on one path only): + +```ts +class C { x: number = 5; } +class H { c: C | null = null; } +function main() { + const h = new H(); + const c = new C(); + h.c = c; + if (c.x > 1) { h.c = null; } + print(c.x); +} +``` + +Regex: `is used here after its value was moved`. Unchanged and still failing: `own_err_param_kept_used`, `own_err_push_let`, `own_err_two_owners`, `own_err_literal_two_records`. +- [ ] **Step 3: Register; run.** Expected: positive FAILS under own (`is used here after its value was moved`). +- [ ] **Step 4: Implement:** in `checkSlotMove`/the move check, a read (definition above) after a move into a destination that is a local slot, a field of a local or of `this`, or an element store in this function is a borrow through that destination, sound when no path from the move to the read overwrites, releases or moves the destination (reuse the kill walk `readUsedAfter` does for borrows). A move into a call (kept parameter) or `push` is not such a destination. +- [ ] **Step 5: Run** `ctest -C Release -R "read_after_move|field_alias|union_moved|param_kept_used|push_let|two_owners|literal_two_records"`. Expected: PASS. Teeth (drop the kill walk: the overwritten negative compiles and its AOT run reads freed memory). Full suite, WSL, spec §29, commit, PR. + +### Task 6: Facts through a module-private interface (G9) + +**Files:** +- Modify: `tslang/lib/TypeScript/OwnershipSignaturePass.cpp` (`collectClassVTables` ~line 1127, `families`, `computeFacts` ~line 1741, `meetFacts` ~line 1790) +- Test: `own_interface_facts.ts`, `own_err_interface_exported_impl.ts` + +**Interfaces:** +- Produces: a call through an interface vtable slot (`...vtbl`) is resolved to the + family of every implementation of that slot when the interface is closed; it then carries the + meet of their facts (`__own_no_drops`, `__own_params`, `__own_result_borrows`) like a private + virtual call. Task 7 relies on `__own_result_borrows` arriving on such calls. + +- [ ] **Step 1: Write the test** `own_interface_facts.ts`: + +```ts +// V as in the plan's shared helpers +interface Th { area(v: V): number; } +class A implements Th { area(v: V) { return v.x; } } +class B implements Th { area(v: V) { return v.x * 2; } } +class Sc { ts: Th[] = [new A(), new B()]; } +function tot(sc: Sc, r: V) { + let best = 0; + for (let i in sc.ts) { let a = sc.ts[i].area(r); if (a > best) best = a; } + return best + r.x; +} +function main() { print(tot(new Sc(), new V(3))); print("done."); } // 9 +``` + +- [ ] **Step 2: Negative** `own_err_interface_exported_impl.ts`: the same program with `export class B`. Regex: `borrows .* but is used here after it may be released or overwritten`. +- [ ] **Step 3: Register; run.** Expected: positive FAILS under own with `borrows an element but is used here after it may be released or overwritten`. +- [ ] **Step 4: Implement:** an interface is closed when it is not exported, and every class vtable `...vtbl` and every object literal implementing it is in this module with no exported implementer. Group interface vtable entries into families by `(interface, position)`; a call through such a slot gets the family meet; if any member is open, the call stays open. +- [ ] **Step 5: Run** `ctest -C Release -R "interface"`. Expected: PASS, existing interface tests unchanged. Teeth (give the call `__own_no_drops` when one implementation drops: an implementation that releases its argument's field must crash the AOT run). Full suite, WSL, spec §30, commit, PR. + +### Task 7: Borrowing records (G2) + +**Files:** +- Modify: `tslang/lib/TypeScript/OwnershipSignaturePass.cpp` (result facts; `__own_result_borrows` becomes a set) +- Modify: `tslang/lib/TypeScript/OwnershipFacts.h` (`resultBorrows`, `borrowedParam` ~line 752-808) +- Modify: `tslang/lib/TypeScript/OwnershipInferencePass.cpp` (records with borrowed fields; bounded uses) +- Modify: `tslang/include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h` (release of a record skips its borrowed fields, chosen at the creation site) +- Test: `own_borrowing_record.ts`, `own_err_borrowing_record_stored.ts`, `own_err_borrowing_record_outlives.ts` + +**Interfaces:** +- Consumes: Task 3 `isOwningRecord`; Task 6 facts on interface calls; Task 1 `isImmortalGlobalRead`. +- Produces: `__own_result_borrows` as an `ArrayAttr` of argument indices (receiver = 0 for + methods); `llvm::SmallVector own_facts::resultBorrowsAll(mlir::Operation *call)`, with the + existing single-index `resultBorrows` kept as a wrapper returning the first or -1 until no caller + needs it. `@lends` accepts several names. + +- [ ] **Step 1: Write the positive test** `own_borrowing_record.ts`: + +```ts +// V as in the plan's shared helpers +interface Ray { start: V; dir: V; } +interface Hit { thing: Th; ray: Ray; dist: number; } +interface Th { hit(ray: Ray): Hit; k: number; } +class A implements Th { k = 1; hit(ray: Ray): Hit { return { thing: this, ray: ray, dist: ray.dir.x }; } } +class B implements Th { k = 2; hit(ray: Ray): Hit { return ray.dir.x > 5 ? null : { thing: this, ray: ray, dist: ray.dir.x * 2 }; } } +class Sc { ts: Th[] = [new A(), new B()]; } +function closest(ray: Ray, sc: Sc) { + let best = +Infinity; + let bestHit: Hit = undefined; + for (let i in sc.ts) { + let h = sc.ts[i].hit(ray); + if (h != null && h.dist < best) { bestHit = h; best = h.dist; } + } + return bestHit; +} +function main() { + const sc = new Sc(); + let s = 0; + for (let i = 0; i < 1000; i++) { + const h = closest({ start: new V(0), dir: new V(i % 10) }, sc); + s += h.thing.k + h.dist; + } + print(s); // 5500: A always wins (dist d against B's 2d), k 1 + assert(s == 5500); + print("done."); +} +``` + +(Before implementing, confirm 5500 under rc and gc; if it differs, fix the expected value from the +rc run, not the code.) + +- [ ] **Step 2: Negatives.** `own_err_borrowing_record_stored.ts`: `class Keep { h: Hit; }` and `keep.h = sc.ts[0].hit(ray)` where `keep` is a global. Regex: `borrows the result of .* and cannot be stored, returned or captured`. `own_err_borrowing_record_outlives.ts`: `let h = a.hit(ray); a = new A(); print(h.thing.k);` with `a` a `let`. Regex: `borrows the result of .* but is used here after it may be released or overwritten`. + Add a two-name case to `own_lends.ts`: an exported `@lends("a", "b")` function returning `{ a: a, b: b }`, used while both live. +- [ ] **Step 3: Register; run.** Expected: positive FAILS under own (`an object literal that holds a value it owns` at the `return`). +- [ ] **Step 4: Implement:** a returned literal whose fields are `this`, a parameter, or field reads of them (and possibly fresh values) makes the function's result borrow that set; the record's release frees only its fresh fields (per creation site: the box carries its own routine). At the caller a borrowing result is a borrow of each argument in the set, merged and returned as plain borrows are (`closest` returns a borrow of `ray` and `sc`). +- [ ] **Step 5: Run** `ctest -C Release -R "borrowing_record|borrowed_result|lends|own"`. Expected: PASS. Teeth (release the borrowed fields too: AOT double free). Full suite, WSL, spec §31, commit, PR. + +### Task 8: `reduce`, and a parameter returned on some paths (G5) + +**Files:** +- Modify: `tslang/lib/TypeScript/OwnershipSignaturePass.cpp` (a parameter returned on some paths and replaced by a fresh value on others is kept: `__own_params`) +- Modify: `tslang/lib/TypeScript/OwnershipInferencePass.cpp` (`otherReturn`, the mixed-return report ~line 4696) +- Modify: the `___array_reduce` generator in MLIRGen if its accumulator is not already a moved local (find it with `grep -rn "___array_reduce" tslang/lib`) +- Test: `own_reduce.ts`; `own_err_mixed_return.ts` rewritten + +**Interfaces:** +- Consumes: Task 1 (immortal start value `Color.defaultColor`). + +- [ ] **Step 1: Write the test** `own_reduce.ts`: + +```ts +// V as in the plan's shared helpers +class K { static zero = new V(0); } +function pick(acc: V, v: V) { return v.x > 2 ? acc : V.plus(acc, v); } // returns acc on one path +function main() { + const a = [new V(1), new V(2), new V(3)]; + const r = a.reduce((acc: V, v: V) => V.plus(acc, v), new V(0)); + const q = a.reduce(pick, K.zero); + print(r.x, q.x); // 6 3 + print("done."); +} +``` + +- [ ] **Step 2: Negative.** `own_err_mixed_return.ts`: change `main` to `const c = new C("a"); print(pick(c, true).n, c.n);` (the caller still uses `c`, so it cannot give it up). Regex: `is used here after its value was moved`. +- [ ] **Step 3: Register; run.** Expected: positive FAILS under own (`cannot make the callers of '___array_reduce<...>' give it up`). +- [ ] **Step 4: Implement:** when a function returns a parameter on some paths and another value on others, and every caller can move the argument in, the parameter is kept (`__own_params`): the returning path moves it out, the others release it. `___array_reduce` moves the initial value into its accumulator, gives the accumulator to the callback (kept), and takes the result as the new accumulator. +- [ ] **Step 5: Run** `ctest -C Release -R "reduce|mixed_return|param_kept"`. Expected: PASS. Teeth, full suite, WSL, spec §32, commit, PR. + +### Task 9: The ray tracer under own (G8 and the deliverable) + +**Files:** +- Create: `tslang/test/tester/own/own_raytrace.ts` (the own copy at 64x64) +- Create: `tslang/benchmarks/raytrace/rtbench_own.ts` (after PR #634 merges) +- Modify: `tslang/benchmarks/raytrace/bench.ps1`, `tslang/benchmarks/raytrace/README.md`, `README.md` (Performance) + +- [ ] **Step 1: Write the test** `own_raytrace.ts`: `tslang/benchmarks/raytrace/rtbench.ts` (from PR #634) with the two source edits of the spec (`Shared`; `Plane.normal` returns `new Vector(this.norm.x, this.norm.y, this.norm.z)`), rendering 64x64, and `exec` ending in `print("checksum:", sum); assert(sum == 606318);`. +- [ ] **Step 2: Register** in `own_test` and `foreach(mm rc gc)`; run. Expected: gc/rc PASS. Under own, list every remaining error. +- [ ] **Step 3: For each remaining error:** reduce it to a minimal repro in `own/`, decide whether an earlier task's rule should have covered it (fix that rule) or it is a new gap (add the rule here with its own test and negative). No source edits beyond the two in the spec. +- [ ] **Step 4: Run** `ctest -C Release -R own_raytrace`. Expected: PASS under own, gc and rc; `test-own-no-counting-own_raytrace` PASS. +- [ ] **Step 5: Benchmark:** `rtbench_own.ts` = the test at 512x512 with `low digits: 369`; `bench.ps1` builds it with `--mm=own` (with and without stack promotion) and replaces the `tslang own does not compile` rows; record peak working set as the README does. Update both READMEs' tables and drop "does not compile". +- [ ] **Step 6: Full suite, WSL, spec §33 (results: errors left 0, time, memory), commit, PR.** From 313c3095c35c4c6ff8db33eda1bf84af72e5bfb0 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Wed, 21 Oct 2026 23:15:49 +0100 Subject: [PATCH 03/57] own-raytrace T1: -mm=own: a global never reassigned is immortal A class-typed global of this module, not exported, written once by its initializer with a fresh object (or another immortal global's value), is immortal under own: the initializing store marks its block HEAP_BLOCK_IMMORTAL, so every release skips it, and a read of it owns nothing - it may be returned, stored into a field or another global, given to a call that keeps it, or merged with a fresh value. - OwnershipSignaturePass.findImmortalGlobals: region initializers and a class static's one store in its static constructor; every symbol use checked (a static written through a vtable reference stays mortal). Pins __own_immortal on the global, its AddressOfs and the init store. - own_facts::isImmortalGlobalRead; inference treats it as isImmortalLiteral and erases its retains; moveIntoGlobals too. - Lowering: the marked store writes the header word; a marked region global always becomes a __cctor so that store exists. Tests: own_immortal_global, own_immortal_global_region (own/rc/gc/none), own_err_immortal_borrowed_init, own_err_immortal_field_overwritten; own_err_global_returned and own_err_param_kept_not_owned now reassign their global. Spec section 25. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 53 +++++ .../LowerToLLVM/OwnershipRoutineLogic.h | 19 ++ tslang/lib/TypeScript/LowerToLLVM.cpp | 39 +++- tslang/lib/TypeScript/OwnershipFacts.h | 17 ++ .../lib/TypeScript/OwnershipInferencePass.cpp | 16 +- .../lib/TypeScript/OwnershipSignaturePass.cpp | 182 ++++++++++++++++++ tslang/test/tester/CMakeLists.txt | 13 +- .../tester/own/own_err_global_returned.ts | 7 +- .../own/own_err_immortal_borrowed_init.ts | 13 ++ .../own/own_err_immortal_field_overwritten.ts | 16 ++ .../own/own_err_param_kept_not_owned.ts | 7 +- tslang/test/tester/own/own_immortal_global.ts | 28 +++ .../tester/own/own_immortal_global_region.ts | 22 +++ 13 files changed, 427 insertions(+), 5 deletions(-) create mode 100644 tslang/test/tester/own/own_err_immortal_borrowed_init.ts create mode 100644 tslang/test/tester/own/own_err_immortal_field_overwritten.ts create mode 100644 tslang/test/tester/own/own_immortal_global.ts create mode 100644 tslang/test/tester/own/own_immortal_global_region.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 8e03e90eb..4e442668c 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 @@ -2789,3 +2789,56 @@ used `let b = a; … a = new C()` to make `b` a borrow, but that is a move now. after `let b = a`, which keeps `b` a borrow. `own_move_reassign` runs the shapes under own, rc and gc: own holds 4.4 MB, as rc's 4.5 MB. On a quarantine heap it runs clean; with the assignment's release kept, it is a double free. Corpus: 467 of 628 compile under own, unchanged. + +## 25. Immortal globals (G1 of the ray tracer plan), 2026-10-21 + +A read of a global was a borrow that could not be stored, returned or captured (§21.2): the global +owns its value, and a call or an assignment may give it another. The ray tracer's `Color.white`, +`Color.black` and `Color.background = Color.black` are never given another value, and are returned +and stored everywhere. Such a global is now **immortal**, like a string literal: its block's header +is `HEAP_BLOCK_IMMORTAL`, so every release skips it, and a read of it owns nothing and may go +anywhere - returned, stored into a field or another global, given to a call that keeps it, merged +with a fresh value (`n > 1 ? K.w : new V(n)` is an owned result on both paths; the caller's release +of `K.w` does nothing). + +- **Which globals.** OwnershipSignaturePass (`findImmortalGlobals`) marks a `ts.Global` + `__own_immortal` when it is of a class type that owns a heap block, is not exported (no `export`, + `import`, `dllexport` or `dllimport`), and is written once, by its initializer. The initializer is + either the global's own region (`const c = new V(1)`: every `ts.AddressOf` of it only loaded), or, + for a class static, its region yields only the default and the one `ts.Store` through a + `ts.AddressOf` of it is in a static constructor a `ts.GlobalConstructor` runs. Every symbol use of + the global is checked (`SymbolTable::getSymbolUses`), not only `ts.AddressOf`: a static is also + reached through its class's vtable, and `k.w = x` with `k: typeof K` writes it through a + `ts.VirtualSymbolRef` that `writtenGlobals` never sees. When any vtable reference is written + through, no static is immortal. A module-level `export let`, an exported class's static, a global + top-level script code assigns, and a global assigned anywhere else stay mortal. +- **Which values.** The initializer gives it a fresh object, nothing, or another immortal global's + value (found to a fixpoint). A borrowed initializer (`static first = K.list[0]`) stays mortal and + keeps today's error (`own_err_immortal_borrowed_init`): marking a block someone else owns immortal + would turn its owner's free into a silent leak. +- **Only the global's own block.** `K.n.c = x` still destroys the old `c`, and a borrow of `K.n.c` + stays bounded (`own_err_immortal_field_overwritten`). +- **Inference.** `own_facts::isImmortalGlobalRead` is a `ts.Load` of a `ts.AddressOf` the pass + marked, seen through views. `isImmortalLiteral` includes it, and its retains (a `ts.Retain`, or a + `ts.RetainSlot` where the slot still holds it) are erased, as a constant's are. `moveIntoGlobals` + erases a global region's retain of one. +- **Lowering.** The initializing `ts.Store` carries `__own_immortal`; under own its lowering writes + `HEAP_BLOCK_IMMORTAL` into the block's header when the value is not null. A region that gives + the global its value is always made a `__cctor` (it would otherwise become a constant + initializer, with no store), and the store the `__cctor` makes is marked from the global's + attribute. A static's region, which yields only the default, is left alone: a `__cctor` of it + would run after the static constructor and put the default back. So every block the global ever + holds is one no release frees, whatever reads of it are taken to own. + +`own_immortal_global` runs the static shapes (from another global, returned, stored into a field, +merged with a fresh value, in a loop of 100000) and `own_immortal_global_region` the module-level +ones (`const c = new V(1)`, `let e = c`), under own, rc, gc and none; own emits no counting. +`own_err_global_returned` and `own_err_param_kept_not_owned` now also give the global another +value, which keeps them errors: their programs as they were are accepted. The brief's text for +`own_err_immortal_borrowed_init` (`the initializer of global 'K.first' takes a second reference`) +is moveIntoGlobals', which only a region initializer reaches; a static's borrowed initializer is +reported by inference in its static constructor, as before (`borrows 'K.list[]' and cannot be +stored, returned or captured`). Teeth: with the header store skipped, the AOT and JIT runs of both +fail with `0xC0000374` (heap corruption: the first release frees `K.w`, and later reads and +releases use the freed block). With the loop at 30 million (20 million fresh `V`s freed), own +peaks at 4.3 MB, as rc does. diff --git a/tslang/include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h b/tslang/include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h index 77f51a713..92316d9ee 100644 --- a/tslang/include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h +++ b/tslang/include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h @@ -410,6 +410,25 @@ class OwnershipRoutineLogic }); } + // Marks the block `payloadPtr` addresses HEAP_BLOCK_IMMORTAL, when there is one: what an + // immortal global is given (own spec 25). Every release then skips it, as it skips a string + // literal's. + void emitMarkImmortal(mlir::Value payloadPtr) + { + emitIfNonNull(payloadPtr, [&]() { + TypeHelper th(rewriter); + TypeConverterHelper tch(typeConverter); + LLVMCodeHelperBase ch(op, rewriter, typeConverter, compileOptions); + auto loc = op->getLoc(); + + auto llvmIndexType = tch.convertType(th.getIndexType()); + auto blockPtr = ch.getBlockPtrFromPayloadPtr(loc, payloadPtr, llvmIndexType); + rewriter.create( + loc, rewriter.create(loc, llvmIndexType, rewriter.getIntegerAttr(llvmIndexType, HEAP_BLOCK_IMMORTAL)), + blockPtr); + }); + } + private: // Field types of a record-shaped type, empty for anything else. llvm::SmallVector getFieldTypes(mlir::Type type) diff --git a/tslang/lib/TypeScript/LowerToLLVM.cpp b/tslang/lib/TypeScript/LowerToLLVM.cpp index ca38f6eb0..835aaf16d 100644 --- a/tslang/lib/TypeScript/LowerToLLVM.cpp +++ b/tslang/lib/TypeScript/LowerToLLVM.cpp @@ -40,6 +40,9 @@ #include "TypeScript/LowerToLLVM/LLVMDebugInfoFixer.h" #include "TypeScript/LowerToLLVM/ArrayLayout.h" +// OWN_IMMORTAL_GLOBAL_ATTR_NAME +#include "OwnershipFacts.h" + #include "scanner_enums.h" #define DISABLE_SWITCH_STATE_PASS 1 @@ -4802,6 +4805,14 @@ struct StoreOpLowering : public TsLlvmPattern // isInvariant = invariantAttr.getValue(); // } + // an immortal global's initializer (own spec 25): the block it is given is marked, so every + // release of a read of the global skips it + if (tsLlvmContext->compileOptions.memoryModel == MemoryModelOwn && storeOp->hasAttr(OWN_IMMORTAL_GLOBAL_ATTR_NAME)) + { + OwnershipRoutineLogic orl(storeOp, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); + orl.emitMarkImmortal(transformed.getValue()); + } + const bool isInvariantGroup = false; // TODO: Determine if this should be set based on some condition rewriter.replaceOpWithNewOp(storeOp, transformed.getValue(), transformed.getReference(), alignment, isVolatile, isNonTemporal, isInvariantGroup, ordering, syncscope); #ifdef DBG_INFO_ADD_VALUE_OP @@ -5003,6 +5014,19 @@ struct GlobalOpLowering : public TsLlvmPattern createAsGlobalConstructor = true; } + // an immortal global (own spec 25) whose region gives it its value gets it from a store, + // which marks the block. A class static's region yields only the default: the store in the + // static constructor gives it its value, and a constructor here would run after that one. + if (globalOp->hasAttr(OWN_IMMORTAL_GLOBAL_ATTR_NAME) && globalOp.getInitializerBlock()) + { + auto *terminator = globalOp.getInitializerBlock()->getTerminator(); + auto *valueOp = terminator && terminator->getNumOperands() == 1 ? terminator->getOperand(0).getDefiningOp() : nullptr; + if (!mlir::isa_and_nonnull(valueOp)) + { + createAsGlobalConstructor = true; + } + } + auto linkage = globalOp.getLinkage(); LLVM::GlobalOp llvmGlobalOp; if (createAsGlobalConstructor) @@ -5024,7 +5048,20 @@ struct GlobalOpLowering : public TsLlvmPattern // ("DISubprogram attached to more than one function") since a DISubprogram must // identify exactly one function. Same "no debug info for this synthetic function" // precedent as setDISubProgramTypesToFormOp's bodiless-declaration handling above. - lch.createFunctionFromRegion(mlir::UnknownLoc::get(rewriter.getContext()), name, globalOp.getInitializerRegion(), globalOp.getSymName()); + auto initFuncOp = lch.createFunctionFromRegion(mlir::UnknownLoc::get(rewriter.getContext()), name, + globalOp.getInitializerRegion(), globalOp.getSymName()); + + // an immortal global (own spec 25): its one store, here, marks the block it is given + if (globalOp->hasAttr(OWN_IMMORTAL_GLOBAL_ATTR_NAME)) + { + initFuncOp.walk([&](mlir_ts::StoreOp storeOp) { + auto addressOfOp = storeOp.getReference().getDefiningOp(); + if (addressOfOp && addressOfOp.getGlobalName() == globalOp.getSymName()) + { + storeOp->setAttr(OWN_IMMORTAL_GLOBAL_ATTR_NAME, rewriter.getUnitAttr()); + } + }); + } { OpBuilder::InsertionGuard insertGuard(rewriter); diff --git a/tslang/lib/TypeScript/OwnershipFacts.h b/tslang/lib/TypeScript/OwnershipFacts.h index dff39695a..76c19c8e5 100644 --- a/tslang/lib/TypeScript/OwnershipFacts.h +++ b/tslang/lib/TypeScript/OwnershipFacts.h @@ -51,6 +51,13 @@ namespace mlir_ts = mlir::typescript; // result is the caller's to own, but no use of it may come after any of these arguments is given // back, and it may not be kept anywhere that outlives them. #define OWN_RESULT_BOUNDED_ATTR_NAME "__own_result_bounded" +// An immortal global (spec 25): a class-typed global of this module, not exported, whose only +// write is its initializer, which gives it a fresh object or another immortal global's. Like a +// string literal, its block is marked HEAP_BLOCK_IMMORTAL, so every release skips it, and a read of +// it owns nothing and may go anywhere. Pinned by OwnershipSignaturePass on the `ts.Global`, on +// every `ts.AddressOf` of it (isImmortalGlobalRead), and on the `ts.Store` that initializes it, +// whose lowering writes the header word. +#define OWN_IMMORTAL_GLOBAL_ATTR_NAME "__own_immortal" // A call inside a try body is a `ts.Invoke` (`ts.InvokeHybrid` through a hybrid function): a // terminator whose normal and unwind edges are its successors. It names its callee as a symbol, or @@ -227,6 +234,16 @@ inline mlir::Value rootOf(mlir::Value value) return value; } +// A read of an immortal global (OWN_IMMORTAL_GLOBAL_ATTR_NAME), seen through its views: the +// global's own block, which no release frees. Its fields are not immortal: a read out of one is a +// place read like any other. +inline bool isImmortalGlobalRead(mlir::Value value) +{ + auto loadOp = rootOf(value).getDefiningOp(); + auto addressOfOp = loadOp ? loadOp.getReference().getDefiningOp() : mlir_ts::AddressOfOp(); + return addressOfOp && addressOfOp->hasAttr(OWN_IMMORTAL_GLOBAL_ATTR_NAME); +} + // Every user of `root` and of the views of it, with the value it uses. A view itself is not a // use. inline void forEachUse(mlir::Value root, llvm::function_ref each) diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index aa893f2f2..e1a6f5a2f 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -352,6 +352,14 @@ class OwnershipInferencePass continue; } + // an immortal global's value (spec 25): owned by no one, so returned, stored or kept + // as often as it is read, and its releases skip it + if (value && isImmortalGlobalRead(value) && (mlir::isa(op) || slotHoldsInitializerAt(op))) + { + toErase.insert(op); + continue; + } + // an array a spread builds, read into the owning local it is made for, or returned if (value && mlir::isa(op) && isBuiltArrayRead(op, value)) { @@ -3836,9 +3844,15 @@ class OwnershipInferencePass return retain; } - // A string constant cast to `string` is the global itself, which a release skips. + // A string constant cast to `string` is the global itself, which a release skips; so is a read + // of an immortal global (spec 25). static bool isImmortalLiteral(mlir::Value value) { + if (isImmortalGlobalRead(value)) + { + return true; + } + auto castOp = rootOf(value).getDefiningOp(); return castOp && mlir::isa(castOp.getType()) && castOp.getIn().getDefiningOp(); diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index cf2934247..c9a111388 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -13,6 +13,7 @@ #include "llvm/ADT/DenseMap.h" #include "llvm/ADT/DenseSet.h" +#include "llvm/ADT/MapVector.h" #include "llvm/ADT/SetVector.h" #include "llvm/ADT/SmallVector.h" #include "llvm/ADT/StringMap.h" @@ -115,6 +116,179 @@ class OwnershipSignaturePass : public mlir::PassWrapper constructors; + module.walk([&](mlir_ts::GlobalConstructorOp constructorOp) { constructors.insert(constructorOp.getGlobalName()); }); + + // a static's address taken out of a class's vtable (`k.w = x`, `k: typeof K`) and written + // through: it may be any class's static the vtable slot holds, so no static is immortal + auto vtableWritten = false; + module.walk([&](mlir::Operation *op) { + if (mlir::isa(op) && + mlir::isa(op->getResult(0).getType()) && + !llvm::all_of(op->getUsers(), [](mlir::Operation *user) { return mlir::isa(user); })) + { + vtableWritten = true; + } + }); + + auto uses = mlir::SymbolTable::getSymbolUses(&module.getBodyRegion()); + if (!uses) + { + return; // an op this pass cannot read symbols through: nothing is known never written + } + + llvm::StringMap> usesOf; + for (auto &use : *uses) + { + usesOf[use.getSymbolRef().getRootReference().getValue()].push_back(use.getUser()); + } + + // each candidate with the value its initializer gives it, and the store that does, if any + llvm::MapVector> candidates; + MLIRTypeHelper mth(&getContext(), CompileOptions{}); + module.walk([&](mlir_ts::GlobalOp globalOp) { + if (!mlir::isa(globalOp.getType()) || !mth.ownsHeapMemory(globalOp.getLoc(), globalOp.getType()) || + globalOp->hasAttr("export") || globalOp->hasAttr("import") || globalOp->hasAttr(DLL_EXPORT) || + globalOp->hasAttr(DLL_IMPORT) || !globalOp.getInitializerRegion().hasOneBlock()) + { + return; + } + + auto resultOp = mlir::dyn_cast(globalOp.getInitializerBlock()->getTerminator()); + if (!resultOp || resultOp->getNumOperands() != 1) + { + return; + } + + mlir_ts::StoreOp initStore; + for (auto *user : usesOf.lookup(globalOp.getSymName())) + { + // its vtable's entry, which only a `ts.VirtualSymbolRef` reads (vtableWritten) + if (mlir::isa(user) && user->getParentOfType()) + { + if (vtableWritten) + { + return; + } + + continue; + } + + if (mlir::isa(user)) + { + continue; // only loaded, or vtableWritten + } + + auto addressOfOp = mlir::dyn_cast(user); + if (!addressOfOp) + { + return; + } + + auto released = false; + auto stored = false; + for (auto *addressUser : addressOfOp->getUsers()) + { + if (mlir::isa(addressUser)) + { + continue; + } + + // the old value, the default, given up before the store + if (mlir::isa(addressUser)) + { + released = true; + continue; + } + + // the one store, in a static constructor + auto storeOp = mlir::dyn_cast(addressUser); + auto funcOp = storeOp ? storeOp->getParentOfType() : mlir_ts::FuncOp(); + if (initStore || !storeOp || storeOp.getReference() != addressOfOp.getResult() || + storeOp.getValue() == addressOfOp.getResult() || !funcOp || !constructors.contains(funcOp.getSymName())) + { + return; + } + + initStore = storeOp; + stored = true; + } + + if (released && !stored) + { + return; + } + } + + // a static's region yields only its default, which the store replaces + auto regionValue = resultOp->getOperand(0); + if (initStore && !mlir::isa_and_nonnull(regionValue.getDefiningOp())) + { + return; + } + + candidates[globalOp] = {initStore ? initStore.getValue() : regionValue, initStore}; + }); + + // given a fresh object, nothing, or another immortal global's value: in module order, until + // no more are found + llvm::StringSet<> immortal; + auto readsImmortal = [&](mlir::Value value) { + auto loadOp = rootOf(value).getDefiningOp(); + auto addressOfOp = loadOp ? loadOp.getReference().getDefiningOp() : mlir_ts::AddressOfOp(); + return addressOfOp && immortal.contains(addressOfOp.getGlobalName()); + }; + + for (auto found = true; found;) + { + found = false; + for (auto &[globalOp, init] : candidates) + { + auto value = init.first; + if (!immortal.contains(globalOp.getSymName()) && (isFresh(value) || holdsNoBlock(value) || readsImmortal(value))) + { + immortal.insert(globalOp.getSymName()); + found = true; + } + } + } + + auto unit = mlir::UnitAttr::get(&getContext()); + for (auto &[globalOp, init] : candidates) + { + if (!immortal.contains(globalOp.getSymName())) + { + continue; + } + + globalOp->setAttr(OWN_IMMORTAL_GLOBAL_ATTR_NAME, unit); + if (init.second) + { + init.second->setAttr(OWN_IMMORTAL_GLOBAL_ATTR_NAME, unit); + } + + for (auto *user : usesOf.lookup(globalOp.getSymName())) + { + if (mlir::isa(user)) + { + user->setAttr(OWN_IMMORTAL_GLOBAL_ATTR_NAME, unit); + } + } + } + } + void moveIntoGlobals(mlir::ModuleOp module) { module.walk([&](mlir_ts::GlobalOp globalOp) { @@ -130,6 +304,13 @@ class OwnershipSignaturePass : public mlir::PassWrapper --emit=mlir-affine --di --opt_level=0 --no-default-lib -mm=own @@ -3701,6 +3710,8 @@ set(own_error_cases "own_err_local_owns_nothing|owns nothing under -mm=own .* so it cannot be given a new value here" "own_err_merged_result|is merged from several branches" "own_err_global_returned|borrows the global 'V.def' and cannot be stored, returned or captured" + "own_err_immortal_borrowed_init|borrows 'K.list.*' and cannot be stored, returned or captured" + "own_err_immortal_field_overwritten|borrows .* but is used here after .* released or overwritten" "own_err_global_let_call|borrows the global 'names', but -mm=own cannot prove the global keeps its value" "own_err_captured_returned|borrows a captured variable and cannot be stored, returned or captured" "own_err_record_literal_given|an object literal that holds a value it owns is moved only into a local or returned as it is" diff --git a/tslang/test/tester/own/own_err_global_returned.ts b/tslang/test/tester/own/own_err_global_returned.ts index 51916450b..93a5bbaea 100644 --- a/tslang/test/tester/own/own_err_global_returned.ts +++ b/tslang/test/tester/own/own_err_global_returned.ts @@ -1,5 +1,6 @@ // -mm=own rejects: a global owns its value, and a function that returns it gives its caller a -// second owner. +// second owner. `reset` gives the global another value, so it is not immortal: the old value is +// destroyed then, and a caller holding it would hold a freed block. class V { constructor(public x: number) {} static def = new V(1); @@ -7,6 +8,10 @@ class V { function getDef() { return V.def; } +function reset() { + V.def = new V(2); +} function main() { print(getDef().x); + reset(); } diff --git a/tslang/test/tester/own/own_err_immortal_borrowed_init.ts b/tslang/test/tester/own/own_err_immortal_borrowed_init.ts new file mode 100644 index 000000000..763a47688 --- /dev/null +++ b/tslang/test/tester/own/own_err_immortal_borrowed_init.ts @@ -0,0 +1,13 @@ +// -mm=own rejects: `K.first` is never assigned, but its initializer borrows an element of +// `K.list`, which owns it. Marking that block immortal would make the array's later free of it a +// silent leak, so `K.first` stays mortal and its initializer takes a second reference. +class V { + constructor(public x: number) {} +} +class K { + static list = [new V(1)]; + static first = K.list[0]; +} +function main() { + print(K.first.x); +} diff --git a/tslang/test/tester/own/own_err_immortal_field_overwritten.ts b/tslang/test/tester/own/own_err_immortal_field_overwritten.ts new file mode 100644 index 000000000..a858532ac --- /dev/null +++ b/tslang/test/tester/own/own_err_immortal_field_overwritten.ts @@ -0,0 +1,16 @@ +// -mm=own rejects: `K.n` is immortal, but only its own block is. Its field `c` is not: assigning +// it destroys the old value, which `c` still reads. +class V { + constructor(public x: number) {} +} +class N { + constructor(public c: V) {} +} +class K { + static n = new N(new V(1)); +} +function main() { + const c = K.n.c; + K.n.c = new V(2); + print(c.x); +} diff --git a/tslang/test/tester/own/own_err_param_kept_not_owned.ts b/tslang/test/tester/own/own_err_param_kept_not_owned.ts index f37db9bab..9e97327a9 100644 --- a/tslang/test/tester/own/own_err_param_kept_not_owned.ts +++ b/tslang/test/tester/own/own_err_param_kept_not_owned.ts @@ -1,5 +1,5 @@ // -mm=own, phase 4 rejects: `keep` keeps its parameter, but `main` passes a global, which it does -// not own and cannot move. +// not own and cannot move. `reset` gives the global another value, so it is not immortal (spec 25). class C { constructor(public x: number) {} } @@ -14,8 +14,13 @@ function keep(h: H, c: C) { h.c = c; } +function reset() { + g = new C(2); +} + function main() { const h = new H(); keep(h, g); print(h.c!.x); + reset(); } diff --git a/tslang/test/tester/own/own_immortal_global.ts b/tslang/test/tester/own/own_immortal_global.ts new file mode 100644 index 000000000..b78dba87c --- /dev/null +++ b/tslang/test/tester/own/own_immortal_global.ts @@ -0,0 +1,28 @@ +// A global never given another value after its initializer is immortal under -mm=own: like a +// string literal, a release skips it, so a read of it may be returned, stored into a field, given +// to another global, or merged with a fresh value - the caller "owns" it either way, and its release +// does nothing. The fresh values `g` makes are still freed. +class V { + constructor(public x: number) {} + static plus(a: V, b: V) { return new V(a.x + b.x); } +} + +class K { + static w = new V(1); + static d = K.w; // an immortal global from another + static f() { return K.w; } // returned +} + +class H { v: V; constructor() { this.v = K.w; } } // stored into a field + +function g(n: number) { return n > 1 ? K.w : new V(n); } // immortal or fresh + +function main() { + const h = new H(); + print(K.f().x, K.d.x, h.v.x, g(2).x, g(0).x); // 1 1 1 1 0 + let s = 0; + for (let i = 0; i < 100000; i++) { s += g(i % 3).x; } // fresh ones are freed + print(s); // 66666 + assert(s == 66666); + print("done."); +} diff --git a/tslang/test/tester/own/own_immortal_global_region.ts b/tslang/test/tester/own/own_immortal_global_region.ts new file mode 100644 index 000000000..8f94b885a --- /dev/null +++ b/tslang/test/tester/own/own_immortal_global_region.ts @@ -0,0 +1,22 @@ +// A module-level global never given another value is immortal under -mm=own, as a class static +// is (own_immortal_global): its initializer is its own region, which the lowering makes a +// constructor of, and that constructor's store marks the block. `e` is given `c`'s value. +class V { + constructor(public x: number) {} +} + +const c = new V(1); +let e = c; + +function f() { return c; } // returned +function h() { return e; } + +function g(n: number) { return n > 1 ? c : new V(n); } // immortal or fresh + +function main() { + let s: number = 0; + for (let i = 0; i < 100000; i++) { s += f().x + h().x + g(i % 3).x; } + print(s, c.x, e.x); // 266666 1 1 + assert(s == 266666 && c.x == 1 && e.x == 1); + print("done."); +} From 510ba9990e81f4974d8687d63521494596d3aa08 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Wed, 21 Oct 2026 23:33:50 +0100 Subject: [PATCH 04/57] own-raytrace T1: no call's result makes a global immortal findImmortalGlobals ran isFresh before the callees' facts were known, so a global given a call's result was marked even when the result borrows: a temporary the region frees (the header was then written into a freed block, and the global held it), or an element or field another value owns (its free became a silent leak). Now only an object made right there and not given back with a ts.Release, nothing, or another immortal global's value qualifies; such globals stay mortal and keep today's errors. New negatives own_err_immortal_region_call (const c = id(new V(1))) and own_err_immortal_region_borrow (const c = firstOf(list)). own_immortal_global declares its sum as number (no s32 warning). Spec section 25 updated, including the older region-form gap. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 22 ++++++++++++++----- tslang/lib/TypeScript/OwnershipFacts.h | 3 ++- .../lib/TypeScript/OwnershipSignaturePass.cpp | 22 ++++++++++++++++--- tslang/test/tester/CMakeLists.txt | 2 ++ .../own/own_err_immortal_region_borrow.ts | 17 ++++++++++++++ .../own/own_err_immortal_region_call.ts | 16 ++++++++++++++ tslang/test/tester/own/own_immortal_global.ts | 2 +- 7 files changed, 74 insertions(+), 10 deletions(-) create mode 100644 tslang/test/tester/own/own_err_immortal_region_borrow.ts create mode 100644 tslang/test/tester/own/own_err_immortal_region_call.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 4e442668c..06e909424 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 @@ -2812,10 +2812,16 @@ of `K.w` does nothing). `ts.VirtualSymbolRef` that `writtenGlobals` never sees. When any vtable reference is written through, no static is immortal. A module-level `export let`, an exported class's static, a global top-level script code assigns, and a global assigned anywhere else stay mortal. -- **Which values.** The initializer gives it a fresh object, nothing, or another immortal global's - value (found to a fixpoint). A borrowed initializer (`static first = K.list[0]`) stays mortal and - keeps today's error (`own_err_immortal_borrowed_init`): marking a block someone else owns immortal - would turn its owner's free into a silent leak. +- **Which values.** The initializer gives it an object made right there (`ts.New`, an owning + literal; none it gives back with a `ts.Release`), nothing, or another immortal global's value + (found to a fixpoint). A call's result never qualifies, whatever its callee: the pass decides + before it knows which callees return a borrow, and a global's region is no function the + inference checks. So `static w = id(new V(1))`, `const c = id(new V(1))` and + `const c = firstOf(list)` stay mortal and keep today's errors (`own_err_immortal_region_call`, + `own_err_immortal_region_borrow`), as does a borrowed initializer (`static first = K.list[0]`, + `own_err_immortal_borrowed_init`). Marking a block someone else owns immortal would turn its + owner's free into a silent leak; marking a temporary the region frees would write the header + into a freed block, and the global would hold it. - **Only the global's own block.** `K.n.c = x` still destroys the old `c`, and a borrow of `K.n.c` stays bounded (`own_err_immortal_field_overwritten`). - **Inference.** `own_facts::isImmortalGlobalRead` is a `ts.Load` of a `ts.AddressOf` the pass @@ -2828,7 +2834,13 @@ of `K.w` does nothing). initializer, with no store), and the store the `__cctor` makes is marked from the global's attribute. A static's region, which yields only the default, is left alone: a `__cctor` of it would run after the static constructor and put the default back. So every block the global ever - holds is one no release frees, whatever reads of it are taken to own. + holds is one no release frees, whatever reads of it are taken to own; the value test makes it a + block that was the global's to keep. + +Not fixed here, and older than this section: a mortal global whose region stores a call's result +that borrows a temporary the region frees (`export const c = id(new V(1))`, or `const c = ...` +read only in place) holds a freed block, and a read of it in place is accepted. The region is no +function, and the call's facts are not pinned there. `own_immortal_global` runs the static shapes (from another global, returned, stored into a field, merged with a fresh value, in a loop of 100000) and `own_immortal_global_region` the module-level diff --git a/tslang/lib/TypeScript/OwnershipFacts.h b/tslang/lib/TypeScript/OwnershipFacts.h index 76c19c8e5..972c5ef1d 100644 --- a/tslang/lib/TypeScript/OwnershipFacts.h +++ b/tslang/lib/TypeScript/OwnershipFacts.h @@ -52,7 +52,8 @@ namespace mlir_ts = mlir::typescript; // back, and it may not be kept anywhere that outlives them. #define OWN_RESULT_BOUNDED_ATTR_NAME "__own_result_bounded" // An immortal global (spec 25): a class-typed global of this module, not exported, whose only -// write is its initializer, which gives it a fresh object or another immortal global's. Like a +// write is its initializer, which gives it an object made there (not a call's result) or another +// immortal global's. Like a // string literal, its block is marked HEAP_BLOCK_IMMORTAL, so every release skips it, and a read of // it owns nothing and may go anywhere. Pinned by OwnershipSignaturePass on the `ts.Global`, on // every `ts.AddressOf` of it (isImmortalGlobalRead), and on the `ts.Store` that initializes it, diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index c9a111388..0585d7e7a 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -123,8 +123,12 @@ class OwnershipSignaturePass : public mlir::PassWrapper immortal; + auto madeHere = [&](mlir::Value value) { + auto root = rootOf(value); + if (!root.getDefiningOp() || isCall(root.getDefiningOp()) || !isFresh(root)) + { + return false; + } + + // and not given back before the global takes it + auto released = false; + forEachUse(root, [&](mlir::Operation *user, mlir::Value) { released = released || mlir::isa(user); }); + return !released; + }; auto readsImmortal = [&](mlir::Value value) { auto loadOp = rootOf(value).getDefiningOp(); auto addressOfOp = loadOp ? loadOp.getReference().getDefiningOp() : mlir_ts::AddressOfOp(); @@ -257,7 +273,7 @@ class OwnershipSignaturePass : public mlir::PassWrapper 1 ? K.w : new V(n); } // immortal or fresh function main() { const h = new H(); print(K.f().x, K.d.x, h.v.x, g(2).x, g(0).x); // 1 1 1 1 0 - let s = 0; + let s: number = 0; for (let i = 0; i < 100000; i++) { s += g(i % 3).x; } // fresh ones are freed print(s); // 66666 assert(s == 66666); From d3c303ee83f46741e4b1dd1487e7dbfcadd56fdc Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Wed, 21 Oct 2026 23:51:49 +0100 Subject: [PATCH 05/57] own-raytrace T2: -mm=own: an object literal moves into new Shared in a global moveIntoGlobals judged rc's retain of the payload given to ts.SharedNew and found it not going into the global's ts.GlobalResult: "the initializer of global 'g' takes a second reference". movesIntoResult now takes new Shared(x)'s payload operand as a move: the handle's block owns it. The value must still be fresh, retained once and released by nothing in the region, so another handle's payload keeps the error (own_err_shared_literal_borrowed). A local already compiled: the inference took the retain as the move. Calling a function field through a handle stays an error: a call through an interface slot has no facts (G9). Spec section 26. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 31 ++++++++++++++++ .../lib/TypeScript/OwnershipSignaturePass.cpp | 11 ++++-- tslang/test/tester/CMakeLists.txt | 16 ++++++++- .../own/own_err_shared_literal_borrowed.ts | 9 +++++ tslang/test/tester/own/own_shared_literal.ts | 35 +++++++++++++++++++ 5 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 tslang/test/tester/own/own_err_shared_literal_borrowed.ts create mode 100644 tslang/test/tester/own/own_shared_literal.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 06e909424..57b8a5d64 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 @@ -2854,3 +2854,34 @@ stored, returned or captured`). Teeth: with the header store skipped, the AOT an fail with `0xC0000374` (heap corruption: the first release frees `K.w`, and later reads and releases use the freed block). With the loop at 30 million (20 million fresh `V`s freed), own peaks at 4.3 MB, as rc does. + +## 26. `Shared` of an object literal (G4 of the ray tracer plan), 2026-10-21 + +The ray tracer's surfaces are module-level `new Shared({ ...function fields... })`. The +literal is a boxed object (`ts.New` of its record) seen as the interface through `ts.NewInterface`, +which §18.2 makes a view of the box. MLIRGen gives the payload to `ts.SharedNew` with rc's retain +right before it (`mlirGenRetainCaptured`): a `new C()` arrives carrying its reference and has no +such retain, but the interface view is retained. + +- **In a function** the inference already took that retain as the move into the handle (§23.2): + `let l = new Shared({...})` compiled before this section, and needed no change. +- **In a global's initializer** `moveIntoGlobals` judged the retain and found its value going into + `ts.SharedNew` rather than the global's `ts.GlobalResult`: `the initializer of global 'g' takes a + second reference`. `movesIntoResult` now takes `new Shared(x)`'s payload operand as a move: the + handle's block owns `x` from then on, whatever becomes of the handle, which rc counts. The other + conditions stay: the value is fresh (the box, seen through its views), retained once, and given + back by nothing in the region. So `let b = new Shared(a.value)`, a second owner of `a`'s + payload, keeps the error (`own_err_shared_literal_borrowed`). +- **Not here: calling a function field through the handle.** `g.value.f(3)` and `l.value.f(3)` are + still `'this value' borrows a field but is used here after it may be released or overwritten`. A + borrow read through a handle ends at any call that may drop (§23.3), and a call through an + interface slot has no facts: the signature pass does not name what it reaches. That is not the + literal's: `new Shared(new K())` with a class `K`, and `p.s.f(3)` on a parameter `p` with no + handle at all, fail the same way, and a direct method call through a handle (`l.value.m(3)` on a + `Shared`) compiles. It is G9 (facts through a module-private interface). + +`own_shared_literal` makes a global and a local `Shared` of a literal with function fields, +copies the local's handle, reuses freed memory, and reads `roughness` through all three handles, +under own, rc, gc and none, AOT and JIT; `--verify-ownership` is quiet on it under own and rc. Teeth: +with the global's move replaced by a release of the literal (freed while the handle points at it), +the AOT run fails its assertion that the global's literal is alive: its block was freed and reused. diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index 0585d7e7a..15f1a7b35 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -57,8 +57,8 @@ class OwnershipSignaturePass : public mlir::PassWrapper({...})`). Its retains are judged on their own. static bool movesIntoResult(mlir::Value value) { return llvm::all_of(value.getUses(), [](mlir::OpOperand &use) { @@ -68,6 +68,13 @@ class OwnershipSignaturePass : public mlir::PassWrapper(user)) + { + return sharedNewOp.getValue() == use.get(); + } + if ((isView(user) && use.getOperandNumber() == 0) || mlir::isa(user)) { return movesIntoResult(user->getResult(0)); diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index f05a8f5fe..99af75720 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3590,6 +3590,19 @@ foreach(shared_verify_mm own rc) PROPERTIES FAIL_REGULAR_EXPRESSION "error|Stack dump") endforeach() endforeach() +# Shared of an object literal (spec 26), global and local: the literal moves into the handle. +# Counted like every Shared test, so not in own_test (its no-counting check). +foreach(shared_literal_mm gc none rc own) + tslang_add_test(NAME test-jit-${shared_literal_mm}-own_shared_literal COMMAND test-runner -jit -mm=${shared_literal_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_shared_literal.ts") + tslang_add_test(NAME test-compile-${shared_literal_mm}-own_shared_literal COMMAND test-runner -mm=${shared_literal_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_shared_literal.ts") +endforeach() +foreach(shared_literal_verify_mm own rc) + add_test(NAME test-${shared_literal_verify_mm}-verify-ownership-own_shared_literal + COMMAND $ --emit=mlir-affine --no-default-lib -mm=${shared_literal_verify_mm} --verify-ownership + "${PROJECT_SOURCE_DIR}/test/tester/own/own_shared_literal.ts") + set_tests_properties(test-${shared_literal_verify_mm}-verify-ownership-own_shared_literal + PROPERTIES FAIL_REGULAR_EXPRESSION "error|Stack dump") +endforeach() # Across a library boundary: the library exports its functions' `__own_no_drops`, and a borrow held # across a call to one survives. Run as a -shared pair (the library loaded at run time) and, on @@ -3743,7 +3756,8 @@ set(own_error_cases "own_err_shared_place_arg|borrows a field but is used here after it may be released or overwritten" "own_err_shared_place_two_uses|borrows a field but is used here after it may be released or overwritten" "own_err_shared_place_literal|borrows a field and cannot be stored, returned or captured" - "own_err_shared_place_tuple|borrows a field and cannot be stored, returned or captured") + "own_err_shared_place_tuple|borrows a field and cannot be stored, returned or captured" + "own_err_shared_literal_borrowed|the initializer of global 'b' 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) diff --git a/tslang/test/tester/own/own_err_shared_literal_borrowed.ts b/tslang/test/tester/own/own_err_shared_literal_borrowed.ts new file mode 100644 index 000000000..83ca80dd4 --- /dev/null +++ b/tslang/test/tester/own/own_err_shared_literal_borrowed.ts @@ -0,0 +1,9 @@ +// Shared of an object literal (spec 26): only a value made in the global's initializer moves into +// the handle. `b`'s initializer gives `new Shared` the payload `a` holds - a second owner of `a`'s +// block, which -mm=own does not count: rejected. +interface S { f: (x: number) => number; roughness: number; } +let a = new Shared({ f: function (x: number) { return x * 2; }, roughness: 2 }); +let b = new Shared(a.value); +function main() { + print(a.value.roughness, b.value.roughness); +} diff --git a/tslang/test/tester/own/own_shared_literal.ts b/tslang/test/tester/own/own_shared_literal.ts new file mode 100644 index 000000000..0cbc2b297 --- /dev/null +++ b/tslang/test/tester/own/own_shared_literal.ts @@ -0,0 +1,35 @@ +// Shared of an object literal (spec 26): the literal, a boxed object behind an interface with +// function fields (the ray tracer's surfaces), moves into the handle the way `new C()` does - in a +// module-level global's initializer and in a function. The global's handle and the local's two +// handles are read after churn() reuses freed memory: a literal freed while its handle points at it +// reads reused memory. The function fields are not called through the handles here: a call through +// an interface slot has no facts yet (G9 of the ray tracer plan), and a handle's borrow ends at such +// a call (spec 23.3). +interface S { f: (x: number) => number; roughness: number; } +let g = new Shared({ f: function (x: number) { return x * 2; }, roughness: 2 }); + +class Junk { + a = -1; + b = -2; +} + +function churn() { + let keep: Junk[] = []; + for (let i = 0; i < 1000; i++) { + const j = new Junk(); + j.a = -i; + keep.push(j); + } + + return keep.length; +} + +function main() { + let l = new Shared({ f: function (x: number) { return x + 1; }, roughness: 3 }); + const m = l; + assert(churn() == 1000); + assert(g.value.roughness == 2, "the global's literal is alive"); + assert(l.value.roughness == 3 && m.value.roughness == 3, "the local's literal is alive"); + print(g.value.roughness, l.value.roughness, m.value.roughness); // 2 3 3 + print("done."); +} From cfb924d4572818dfb99db45f7e7b51d5b53618b8 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 00:09:08 +0100 Subject: [PATCH 06/57] own-raytrace T3: -mm=own: an owning object literal moves anywhere a fresh object does An object literal given as an object or an interface is copied into a box (a ts.New of a value_ref). recordRetainOf now takes that copy as the record's one owner, so the fresh values stored into its fields move in at the record's retain and the box's own check decides where it goes: a call argument (released after a borrowing callee, taken by a keeping one), an array literal element, a nested literal's field, a local through an interface view, the result. own_facts::isOwningRecord names the record (its read, its box, or a view of the box) whose owning fields each hold a fresh value or no block; a let read or a borrow keeps the error. own_err_record_literal_given becomes the positive own_record_literal_given; new own_literal_moves, own_err_literal_moved_twice, own_err_record_literal_let. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 2 +- tslang/lib/TypeScript/OwnershipFacts.h | 121 ++++++++++++++++++ .../lib/TypeScript/OwnershipInferencePass.cpp | 83 ++++++------ tslang/test/tester/CMakeLists.txt | 15 ++- .../tester/own/own_err_literal_moved_twice.ts | 16 +++ .../own/own_err_record_literal_given.ts | 14 -- .../tester/own/own_err_record_literal_let.ts | 12 ++ tslang/test/tester/own/own_literal_moves.ts | 30 +++++ .../tester/own/own_record_literal_given.ts | 16 +++ 9 files changed, 249 insertions(+), 60 deletions(-) create mode 100644 tslang/test/tester/own/own_err_literal_moved_twice.ts delete mode 100644 tslang/test/tester/own/own_err_record_literal_given.ts create mode 100644 tslang/test/tester/own/own_err_record_literal_let.ts create mode 100644 tslang/test/tester/own/own_literal_moves.ts create mode 100644 tslang/test/tester/own/own_record_literal_given.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 57b8a5d64..a2d43e7d5 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 @@ -1626,7 +1626,7 @@ place, so all four places that report it get the same names. Each message has a | a global's value, stored, returned or boxed | `'x' borrows the global 'g' and cannot be stored, returned or captured` | `own_err_global_returned` | | a global's value read into a local | `'x' borrows the global 'g', but -mm=own cannot prove the global keeps its value while 'x' is used: a call or an assignment may overwrite it` | `own_err_global_let_call` | | a captured variable's value, through its cell | `'x' borrows a captured variable and cannot be stored, returned or captured` | `own_err_captured_returned` | -| an object literal holding a value it owns, given anywhere but an owning local or the result | `an object literal that holds a value it owns is moved only into a local or returned as it is; -mm=own cannot move it here yet` | `own_err_record_literal_given` | +| an object literal holding a value it owns, given anywhere but an owning local or the result | `an object literal that holds a value it owns is moved only into a local or returned as it is; -mm=own cannot move it here yet` | `own_err_record_literal_let` (§27; was `own_err_record_literal_given`) | The mixed-return message is the largest bucket (17 files). Most are two synthesized helpers: - `___unbox` (`` of an `any`) returns the box's own string when it holds one, and a diff --git a/tslang/lib/TypeScript/OwnershipFacts.h b/tslang/lib/TypeScript/OwnershipFacts.h index 972c5ef1d..d32ab4047 100644 --- a/tslang/lib/TypeScript/OwnershipFacts.h +++ b/tslang/lib/TypeScript/OwnershipFacts.h @@ -635,6 +635,127 @@ inline bool isConstantData(mlir::Value value) return false; } +// A non-owning local a `ts.Constant` initializes, used only through its fields and its reads: the +// local MLIRGen builds an object literal in. +inline bool isConstantRecordLocal(mlir_ts::VariableOp varOp) +{ + if (isOwningVariable(varOp) || varOp.getCaptured().value_or(false) || !varOp.getInitializer() || + !varOp.getInitializer().getDefiningOp()) + { + return false; + } + + return llvm::all_of(varOp->getUsers(), [](mlir::Operation *user) { + return mlir::isa(user); + }); +} + +// The block an object literal is boxed into when it is given as an object or an interface: a +// `ts.New` of a `value_ref`, filled by one store of the record's read (spec 27). Its views (the +// object, the interface) are it. Null for anything else. +inline mlir_ts::NewOp recordBoxOf(mlir::Value value) +{ + auto newOp = rootOf(value).getDefiningOp(); + if (!newOp || !mlir::isa(newOp.getType())) + { + return {}; + } + + mlir_ts::StoreOp fill; + for (auto &use : newOp->getUses()) + { + auto storeOp = mlir::dyn_cast(use.getOwner()); + if (!storeOp || storeOp.getReference() != use.get()) + { + continue; + } + + if (fill) + { + return {}; + } + + fill = storeOp; + } + + auto read = fill ? fill.getValue().getDefiningOp() : mlir_ts::LoadOp(); + auto varOp = read ? read.getReference().getDefiningOp() : mlir_ts::VariableOp(); + return varOp && isConstantRecordLocal(varOp) ? newOp : mlir_ts::NewOp(); +} + +// The local an object literal is built in, from its read, or from the box it is copied into (or a +// view of that box). Null for anything else. +inline mlir_ts::VariableOp recordLocalOf(mlir::Value value) +{ + if (auto boxOp = recordBoxOf(value)) + { + for (auto *user : boxOp->getUsers()) + { + auto storeOp = mlir::dyn_cast(user); + if (storeOp && storeOp.getReference() == boxOp.getResult()) + { + value = storeOp.getValue(); + } + } + } + + auto read = value.getDefiningOp(); + auto varOp = read ? read.getReference().getDefiningOp() : mlir_ts::VariableOp(); + return varOp && isConstantRecordLocal(varOp) ? varOp : mlir_ts::VariableOp(); +} + +// An object literal that owns what it holds (spec 27): its read, the box it is copied into, or a view +// of that box (the interface it is given as), where every field that owns a block holds what the +// constant put there (null, a number, an immortal literal), nothing at all, or a fresh value stored +// into it - a new object, a call's result, a literal, a `const` local folded into the value it holds. +// Each such value moves into the record: its own check, against the record's retain, decides that +// nothing uses it after (OwnershipInferencePass's recordRetainOf). A read out of a place (`this.pos`) +// or a parameter is a borrow, and such a record is not this one. Neither is a `let` read into a field: +// its slot keeps its own release, which nothing here cancels. +inline bool isOwningRecord(mlir::Value value) +{ + auto varOp = recordLocalOf(value); + if (!varOp) + { + return false; + } + + MLIRTypeHelper mth(value.getContext(), CompileOptions{}); + for (auto *user : varOp->getUsers()) + { + auto propertyRefOp = mlir::dyn_cast(user); + if (!propertyRefOp) + { + continue; + } + + // a method's field is a `!ts.bound_ref`, which holds a function, not a block + auto field = propertyRefOp.getResult(); + auto refType = mlir::dyn_cast(field.getType()); + if (refType && !mth.ownsHeapMemory(field.getLoc(), refType.getElementType())) + { + continue; + } + + for (auto *fieldUser : field.getUsers()) + { + if (mlir::isa(fieldUser)) + { + continue; + } + + auto storeOp = mlir::dyn_cast(fieldUser); + if (!storeOp || storeOp.getReference() != field || + !(isFresh(rootOf(storeOp.getValue())) || holdsNoBlock(storeOp.getValue()))) + { + return false; + } + } + } + + return true; +} + // A captured variable's cell, as a closure body reaches it: read out of a field of its capture box // (or of any other block - a RefType loaded from somewhere is a cell). Its variable lives in // whatever function declared it, so an assignment through it may destroy a value that function, and diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index e1a6f5a2f..a9fcad653 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -3556,65 +3556,38 @@ class OwnershipInferencePass { auto loadOp = value.getDefiningOp(); auto varOp = loadOp ? loadOp.getReference().getDefiningOp() : mlir_ts::VariableOp(); - if (!varOp || !isConstantRecordLocal(varOp)) + if (!varOp || !isConstantRecordLocal(varOp) || !isOwningRecord(value)) { return false; } - for (auto *user : varOp->getUsers()) - { + // a fresh value moved in, its move decided by its own check against this retain + auto movesIn = llvm::any_of(varOp->getUsers(), [&](mlir::Operation *user) { auto propertyRefOp = mlir::dyn_cast(user); if (!propertyRefOp) { - continue; + return false; } // a method's field is a `!ts.bound_ref`, which holds a function, not a block - auto field = propertyRefOp.getResult(); - auto refType = mlir::dyn_cast(field.getType()); - for (auto *fieldUser : field.getUsers()) + auto refType = mlir::dyn_cast(propertyRefOp.getType()); + if (refType && !ownsHeap(propertyRefOp.getLoc(), refType.getElementType())) { - if (mlir::isa(fieldUser)) - { - continue; - } - - if (refType && !ownsHeap(field.getLoc(), refType.getElementType())) - { - continue; - } - - // a fresh value moved in, its move decided by its own check against this retain - auto storeOp = mlir::dyn_cast(fieldUser); - if (!storeOp || storeOp.getReference() != field || !isFresh(rootOf(storeOp.getValue())) || - recordRetainOf(varOp) == nullptr) - { - return false; - } + return false; } - } - - return true; - } - // A non-owning local a `ts.Constant` initializes, used only through its fields and its reads. - static bool isConstantRecordLocal(mlir_ts::VariableOp varOp) - { - if (isOwningVariable(varOp) || varOp.getCaptured().value_or(false) || !varOp.getInitializer() || - !varOp.getInitializer().getDefiningOp()) - { - return false; - } - - return llvm::all_of(varOp->getUsers(), [](mlir::Operation *user) { - return mlir::isa(user); + return llvm::any_of(propertyRefOp->getUsers(), + [](mlir::Operation *fieldUser) { return mlir::isa(fieldUser); }); }); + + return !movesIn || recordRetainOf(varOp) != nullptr; } // rc's one `ts.Retain` of a record local's one read, where the read is only returned: stored - // into a local that owns nothing and is read only by returns (the result slot). It is the - // reference rc gives each fresh value stored into the record, so it is where they move in. - // Null for any other record. + // into a local that owns nothing and is read only by returns (the result slot), or copied into + // the box it is given in as an object or an interface (spec 27). It is the reference rc gives + // each fresh value stored into the record, so it is where they move in. Null for any other + // record. static mlir::Operation *recordRetainOf(mlir_ts::VariableOp varOp) { if (!isConstantRecordLocal(varOp)) @@ -3642,6 +3615,8 @@ class OwnershipInferencePass } mlir::Operation *retain = nullptr; + auto boxed = false; + auto other = false; for (auto *user : read->getUsers()) { if (mlir::isa(user)) @@ -3655,6 +3630,23 @@ class OwnershipInferencePass continue; } + // copied into the block it is given in as an object or an interface (spec 27): the box + // owns what the record held, and its own check decides where the box goes + auto storeOp = mlir::dyn_cast(user); + auto box = storeOp && storeOp.getValue() == read.getResult() ? recordBoxOf(storeOp.getReference()) + : mlir_ts::NewOp(); + if (box && box.getResult() == storeOp.getReference()) + { + if (boxed) + { + return nullptr; + } + + boxed = true; + continue; + } + + other = true; if (mlir::isa(user)) { continue; @@ -3677,7 +3669,6 @@ class OwnershipInferencePass } // stored into the result slot, or into an owning local (`a = { item: new C() }`) - auto storeOp = mlir::dyn_cast(user); auto slot = storeOp && storeOp.getValue() == read.getResult() ? storeOp.getReference().getDefiningOp() : mlir_ts::VariableOp(); @@ -3687,6 +3678,12 @@ class OwnershipInferencePass } } + // a boxed record goes nowhere else: the box is its one owner + if (boxed && other) + { + return nullptr; + } + return retain; } diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 99af75720..8770d94d8 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3479,7 +3479,8 @@ foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local o own_declared_no_drops own_return_paths own_string_concat_element own_final_fresh_result own_final_field_lend own_null_narrowing_di own_param_markers own_generator_locals own_string_param_assigned own_lends - own_immortal_global own_immortal_global_region) + own_immortal_global own_immortal_global_region + own_literal_moves own_record_literal_given) 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. @@ -3499,6 +3500,15 @@ foreach(immortal_mm rc gc none) endforeach() endforeach() +# An object literal that owns its fields moves anywhere a fresh object does under own (spec 27); +# the same programs under every other model. +foreach(owning_record_mm rc gc none) + foreach(owning_record_test own_literal_moves own_record_literal_given) + tslang_add_test(NAME test-jit-${owning_record_mm}-${owning_record_test} COMMAND test-runner -jit -mm=${owning_record_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${owning_record_test}.ts") + tslang_add_test(NAME test-compile-${owning_record_mm}-${owning_record_test} COMMAND test-runner -mm=${owning_record_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${owning_record_test}.ts") + endforeach() +endforeach() + # ...and built with debug info, whose records of narrowed variables are ops of their own add_test(NAME test-own-di-null-narrowing COMMAND $ --emit=mlir-affine --di --opt_level=0 --no-default-lib -mm=own @@ -3729,7 +3739,8 @@ set(own_error_cases "own_err_immortal_region_call|borrows the global 'c' and cannot be stored, returned or captured" "own_err_global_let_call|borrows the global 'names', but -mm=own cannot prove the global keeps its value" "own_err_captured_returned|borrows a captured variable and cannot be stored, returned or captured" - "own_err_record_literal_given|an object literal that holds a value it owns is moved only into a local or returned as it is" + "own_err_literal_moved_twice|is used here after its value was moved" + "own_err_record_literal_let|an object literal that holds a value it owns is moved only into a local or returned as it is" "own_err_string_kept_param_known|is used here after its value was moved" "own_err_keeps_mismatch|'@keeps' of 'Pair.constructor' names 'a', but its body keeps 'b'" "own_err_borrows_not_generator|'@borrows' of 'copyOf' names 'w', but it neither makes a generator whose state borrows its arguments nor returns one" diff --git a/tslang/test/tester/own/own_err_literal_moved_twice.ts b/tslang/test/tester/own/own_err_literal_moved_twice.ts new file mode 100644 index 000000000..9dee98988 --- /dev/null +++ b/tslang/test/tester/own/own_err_literal_moved_twice.ts @@ -0,0 +1,16 @@ +// -mm=own rejects: `p` moves into the first record, which `keepRay` keeps, and is used again in +// the second (spec 27). +class V { + constructor(public x: number) {} + static plus(a: V, b: V) { return new V(a.x + b.x); } +} +interface Ray { start: V; dir: V; } +class Keeper { rays: Ray[] = []; } +function keepRay(k: Keeper, r: Ray) { k.rays.push(r); } +function main() { + const p = new V(1); + const k = new Keeper(); + keepRay(k, { start: p, dir: new V(2) }); + keepRay(k, { start: p, dir: new V(3) }); + print(k.rays.length); +} diff --git a/tslang/test/tester/own/own_err_record_literal_given.ts b/tslang/test/tester/own/own_err_record_literal_given.ts deleted file mode 100644 index aa5ff5fd8..000000000 --- a/tslang/test/tester/own/own_err_record_literal_given.ts +++ /dev/null @@ -1,14 +0,0 @@ -// -mm=own rejects: an object literal that holds a new value is moved only into a local or returned -// as it is; given as an interface, it is boxed into a block of its own. -class Vec { - constructor(public n: number) {} -} -interface Holder { - v: Vec; -} -function make(n: number): Holder { - return { v: new Vec(n) }; -} -function main() { - print(make(1).v.n); -} diff --git a/tslang/test/tester/own/own_err_record_literal_let.ts b/tslang/test/tester/own/own_err_record_literal_let.ts new file mode 100644 index 000000000..2e8481142 --- /dev/null +++ b/tslang/test/tester/own/own_err_record_literal_let.ts @@ -0,0 +1,12 @@ +// -mm=own rejects: a `let` read into an object literal's field. Its slot keeps its own release +// at the end of its scope, which nothing cancels, and the record's would free the block a second +// time; only a fresh value, or a `const` folded into the value it holds, moves in (spec 27). +class V { + constructor(public x: number) {} +} +interface Ray { start: V; dir: V; } +function len(r: Ray) { return r.start.x + r.dir.x; } +function main() { + let p = new V(1); + print(len({ start: p, dir: new V(2) })); +} diff --git a/tslang/test/tester/own/own_literal_moves.ts b/tslang/test/tester/own/own_literal_moves.ts new file mode 100644 index 000000000..03f2ba266 --- /dev/null +++ b/tslang/test/tester/own/own_literal_moves.ts @@ -0,0 +1,30 @@ +// -mm=own: an object literal that owns its fields - fresh values, or locals moved in - is a value +// like a fresh object. It moves into a call argument (the caller releases it after a callee that +// borrows it; one that keeps it takes it), an array literal's element, a nested literal's field, a +// local through an interface view, or the result (spec 27). +class V { + constructor(public x: number) {} + static plus(a: V, b: V) { return new V(a.x + b.x); } +} +interface Ray { start: V; dir: V; } +interface L { pos: V; } +interface Sc { ls: V[]; c: V; } +class Keeper { rays: Ray[] = []; } +function len(r: Ray) { return r.start.x + r.dir.x; } +function keepRay(k: Keeper, r: Ray) { k.rays.push(r); } +function mk(): Sc { return { ls: [new V(1), new V(2)], c: new V(3) }; } +function main() { + const p = new V(1); + const d = new V(2); + print(len({ start: p, dir: d })); // 3 (p, d move in) + const q = new V(1); + print(len({ start: V.plus(q, q), dir: q })); // 3 (fresh + moved) + const ls: L[] = [{ pos: new V(1) }, { pos: new V(2) }]; + const r: Ray = { start: new V(4), dir: new V(5) }; + const s = mk(); + print(ls[1].pos.x, r.dir.x, s.c.x, s.ls[1].x); // 2 5 3 2 + const k = new Keeper(); + for (let i = 0; i < 1000; i++) keepRay(k, { start: new V(i), dir: new V(1) }); + print(k.rays.length, k.rays[999].start.x); // 1000 999 + print("done."); +} diff --git a/tslang/test/tester/own/own_record_literal_given.ts b/tslang/test/tester/own/own_record_literal_given.ts new file mode 100644 index 000000000..cbdf5fc60 --- /dev/null +++ b/tslang/test/tester/own/own_record_literal_given.ts @@ -0,0 +1,16 @@ +// An object literal that holds a new value, given as an interface, is boxed into a block of its +// own: the new value moves into the record, the record into the box, and the box is returned +// (spec 27; -mm=own rejected it before). +class Vec { + constructor(public n: number) {} +} +interface Holder { + v: Vec; +} +function make(n: number): Holder { + return { v: new Vec(n) }; +} +function main() { + print(make(1).v.n); + print("done."); +} From 8a26bc6148da800386fbcb17a1bdb9045a6815ce Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 00:13:13 +0100 Subject: [PATCH 07/57] own-raytrace T3: spec 27, owning literals moved anywhere Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) 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 a2d43e7d5..cbe838f88 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 @@ -2885,3 +2885,58 @@ copies the local's handle, reuses freed memory, and reads `roughness` through al under own, rc, gc and none, AOT and JIT; `--verify-ownership` is quiet on it under own and rc. Teeth: with the global's move replaced by a release of the literal (freed while the handle points at it), the AOT run fails its assertion that the global's literal is alive: its block was freed and reused. + +## 27. Owning literals moved anywhere (G3 of the ray tracer plan), 2026-10-22 + +An object literal was moved only into an owning local or the result (§18.1, §20.2): `len({ start: +p, dir: d })`, `[{ pos: new V(1) }, { pos: new V(2) }]`, `const r: Ray = {...}` and `return { ls: +[...], c: new V(3) }` through an interface type were `an object literal that holds a value it owns +is moved only into a local or returned as it is`, 13 of the ray tracer's 44 errors. Such a literal is +now a value like a fresh object. + +- **The box.** A literal given as an object or an interface is built in a local a `ts.Constant` + starts, read once, and the read copied into a block of its own: a `ts.New` of a `value_ref` + filled by that one store, seen as the object (`ts.Cast`) and the interface (`ts.NewInterface`), + which are views of it (§18.2). `recordRetainOf` now takes that copy as the record's owner, beside + the result slot and an owning local, when the read goes nowhere else. rc's `ts.Retain` of the + read is then where the fresh values stored into the fields move in, as for a record built to be + returned: each value's own check erases its release, which the retain dominates, and reports a use + of it after the literal (`const p` moved into two literals, `own_err_literal_moved_twice`: `is used + here after its value was moved`; one moved in a loop it was made outside of: `is moved inside a + loop`). +- **Where the box goes** is the box's own check, as for any `ts.New`: a call argument is released + after a callee that borrows it, and moves into one that keeps it (`__own_params`: `keepRay` pushes + it, and the caller's release goes); an array literal element moves into the `ts.CreateArray`; a + `const r: Ray = {...}` is the box, released at the end of its scope; a literal returned is the + result. A literal in an interface-typed field of another literal (`{ inner: { start: ... } }` with + `inner: Ray`) is a fresh box stored into the outer record, which moves in like any fresh value. +- **`own_facts::isOwningRecord`** names such a record - its read, its box, or a view of the box - + when every field that owns a block holds what the constant put there, nothing, or a fresh value: a + new object, a call's result that borrows nothing, a literal, a `const` local folded into the value + it holds. `holdsConstantsOrMoves` reads its fields through it. A field that holds no block (`null` + in a `V | null`) is now accepted too; it moves nothing. + +Not here, and still `an object literal that holds a value it owns ...`: +- **A `let` read into a field** (`own_err_record_literal_let`). Its slot keeps its release at the end + of its scope, which nothing cancels: accepting it would free the block twice. A sound narrowing of + the plan's "a moved local": a `const` local is folded into its value and moves in. +- **A borrowed field**: a parameter (`{ start: a }`) or a field read (`{ start: this.pos }`, also + `borrows a field and cannot be stored`). The box's release would free the borrowed block. That is + G2's borrowing record, whose release skips borrowed fields; the ray tracer's `{ start: pos, dir: + rd }` is one. +- **A literal nested by value** (`const o = { inner: { item: new C() } }` with no interface type), + §20.6's catch-all: the inner record is copied into the outer one's field, not boxed. + +A literal stored into a field of a nullable interface type (`k.r = {...}` with `r: Ray | null`) is +`takes a second reference`: in the IR the pass sees, the box is retained once for the interface and +again for the union, and released nowhere in the function (the program compiles and runs under rc). +Into an `r: Ray` field it compiles. + +`own_literal_moves` runs the shapes (two moved `const`s; a fresh call result beside a moved one; an +array of literals; a `const r: Ray`; a returned interface record holding an array; 1000 literals +given to a callee that keeps them) under own, rc, gc and none, AOT and JIT, and on Linux; +`own_record_literal_given` (was `own_err_record_literal_given`) returns `{ v: new Vec(n) }` as a +`Holder`. Own emits no counting. Teeth: with the moved-in values' releases kept, both AOT and JIT +runs of both fail with `0xC0000374` (heap corruption: the box's release frees the field, then the +value's own release frees it again). A loop of 2 million iterations of every shape (about 10 +blocks each) peaks at 4.8 MB under own, as under rc (gc 6.7 MB): the box's release frees its fields. From 72129949d4a956a2ad6ffde941ce96a2d4519c45 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 00:29:20 +0100 Subject: [PATCH 08/57] own-raytrace T3: -mm=own: a method reached through an interface may not keep a parameter A class's interface vtable (`...vtbl`) holds the class's `..instanceOf`, so the signature pass took it for a class vtable and its methods for calls it resolves. A call through the interface gets none of their facts, so the caller released what the method kept: a double free (`s.take(new V(1))`), newly reachable with a literal argument. Such a method that keeps a parameter (or says @keeps) now loses its facts and its body is the existing error ("is a parameter, kept here, but -mm=own cannot make the callers of ... give it up"). G9 relaxes it for closed interfaces. New negatives own_err_interface_keeps_literal, own_err_interface_keeps_param; spec 21.2 row and 27 updated. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 17 +++- .../lib/TypeScript/OwnershipSignaturePass.cpp | 77 ++++++++++++++++++- tslang/test/tester/CMakeLists.txt | 2 + .../own/own_err_interface_keeps_literal.ts | 16 ++++ .../own/own_err_interface_keeps_param.ts | 15 ++++ 5 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 tslang/test/tester/own/own_err_interface_keeps_literal.ts create mode 100644 tslang/test/tester/own/own_err_interface_keeps_param.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 cbe838f88..9bd65b33e 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 @@ -1626,7 +1626,7 @@ place, so all four places that report it get the same names. Each message has a | a global's value, stored, returned or boxed | `'x' borrows the global 'g' and cannot be stored, returned or captured` | `own_err_global_returned` | | a global's value read into a local | `'x' borrows the global 'g', but -mm=own cannot prove the global keeps its value while 'x' is used: a call or an assignment may overwrite it` | `own_err_global_let_call` | | a captured variable's value, through its cell | `'x' borrows a captured variable and cannot be stored, returned or captured` | `own_err_captured_returned` | -| an object literal holding a value it owns, given anywhere but an owning local or the result | `an object literal that holds a value it owns is moved only into a local or returned as it is; -mm=own cannot move it here yet` | `own_err_record_literal_let` (§27; was `own_err_record_literal_given`) | +| an object literal holding a value it does not own outright: a `let` read into a field, a borrowed field (a parameter, `this.pos`), or a literal nested by value (since §27; before, any literal given anywhere but an owning local or the result) | `an object literal that holds a value it owns is moved only into a local or returned as it is; -mm=own cannot move it here yet` | `own_err_record_literal_let` (§27; was `own_err_record_literal_given`) | The mixed-return message is the largest bucket (17 files). Most are two synthesized helpers: - `___unbox` (`` of an `any`) returns the box's own string when it holds one, and a @@ -2932,6 +2932,21 @@ A literal stored into a field of a nullable interface type (`k.r = {...}` with ` again for the union, and released nowhere in the function (the program compiles and runs under rc). Into an `r: Ray` field it compiles. +**A method that keeps its argument, reached through an interface.** A call through an interface +(`s.take(x)` with `s: Sink`, a `ts.InterfaceSymbolRef` and an indirect call) is not resolved, so it +gets none of the method's facts, and the caller released what the method kept: a double free under +own, older than this section (`s.take(new V(1))`), which a literal argument now reached too. The +interface's vtable for a class (`KS.Sink..vtbl`) holds the class's `..instanceOf`, so the signature +pass took it for a class vtable and its entries for calls it resolves. Now `collectInterfaceReached` +tells it apart by its name (`...vtbl`, where the class's own `..instanceOf` is +`..instanceOf`), and a method it holds that keeps a parameter (or says `@keeps`) loses its +facts: its body is `'' is a parameter, kept here, but -mm=own cannot make the callers of 'KS.take' +give it up`, noted `it is reached through an interface, whose calls are not given its facts` +(`own_err_interface_keeps_literal`, `own_err_interface_keeps_param`). A direct call of such a method +(`ks.take(x)` in a program that also casts `ks` to `Sink`) is refused too. Only keeping is refused; +a method's result that borrows is left as it was. G9 (facts through a module-private interface) +relaxes this for closed interfaces by giving the interface call the meet of these facts. + `own_literal_moves` runs the shapes (two moved `const`s; a fresh call result beside a moved one; an array of literals; a `const r: Ray`; a returned interface record holding an array; 1000 literals given to a callee that keeps them) under own, rc, gc and none, AOT and JIT, and on Linux; diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index 15f1a7b35..0f9433c84 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -381,6 +381,7 @@ class OwnershipSignaturePass : public mlir::PassWrapper open; + // Methods an interface's vtable for a class (`...vtbl`) holds. A call through + // the interface (`ts.InterfaceSymbolRef`, then an indirect call) is not resolved, so it is given + // none of their facts: a caller that hands such a method an argument it keeps still releases it + // (a double free). So a method reached this way that keeps a parameter loses its facts, and the + // body's error says why (`is a parameter, kept here, but -mm=own cannot make the callers of ... + // give it up`). Task 6 of the ray tracer plan (G9, facts through a module-private interface) + // relaxes this for closed interfaces by giving the interface call the meet of these facts. + llvm::DenseSet interfaceReached; + static constexpr const char *INTERFACE_REACHED_WHY = + "it is reached through an interface, whose calls are not given its facts"; + // The parameter a function's result borrows and the parameters it keeps (spec 2.4); absent is // rc's convention. struct Facts @@ -1351,6 +1363,56 @@ class OwnershipSignaturePass : public mlir::PassWrapper...vtbl`, where a class's own is `..vtbl`. + void collectInterfaceReached(mlir::ModuleOp module) + { + module.walk([&](mlir_ts::GlobalOp globalOp) { + auto name = globalOp.getSymName(); + if (!name.ends_with("..vtbl")) + { + return; + } + + auto owner = name.drop_back(llvm::StringRef("..vtbl").size()); + llvm::SmallVector methods; + auto ofClass = false; + auto ownClass = false; + globalOp.getInitializerRegion().walk([&](mlir_ts::SymbolRefOp symbolRefOp) { + auto symbol = symbolRefOp.getIdentifierAttr().getAttr(); + if (symbol.getValue().ends_with("..instanceOf")) + { + ofClass = true; + ownClass = ownClass || symbol.getValue().drop_back(llvm::StringRef("..instanceOf").size()) == owner; + return; + } + + methods.push_back(symbol); + }); + + if (!ofClass || ownClass) + { + return; + } + + for (auto method : methods) + { + if (auto funcOp = functions.lookup(method.getValue())) + { + interfaceReached.insert(funcOp); + } + } + }); + } + + // Does a method reached through an interface keep a parameter (interfaceReached)? Then its + // callers through the interface cannot be told, and it has no facts at all. + bool keepsThroughInterface(mlir::Operation *funcOp, const llvm::SmallVector &owned) + { + return !owned.empty() && interfaceReached.contains(funcOp); + } + mlir_ts::TupleType storageTupleOf(mlir::Type type) { return own_facts::storageTupleOf(&getContext(), type); @@ -1997,18 +2059,24 @@ class OwnershipSignaturePass : public mlir::PassWrapper(keeps.begin(), keeps.end())}; } @@ -2091,6 +2159,11 @@ class OwnershipSignaturePass : public mlir::PassWrappersetAttr(OWN_FACTS_LOST_ATTR_NAME, mlir::StringAttr::get(&getContext(), why ? why : "an override in its class family disagrees")); } diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 8770d94d8..69367bd4d 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3741,6 +3741,8 @@ set(own_error_cases "own_err_captured_returned|borrows a captured variable and cannot be stored, returned or captured" "own_err_literal_moved_twice|is used here after its value was moved" "own_err_record_literal_let|an object literal that holds a value it owns is moved only into a local or returned as it is" + "own_err_interface_keeps_literal|is a parameter, kept here, but -mm=own cannot make the callers of 'KS.take' give it up" + "own_err_interface_keeps_param|is a parameter, kept here, but -mm=own cannot make the callers of 'KV.take' give it up" "own_err_string_kept_param_known|is used here after its value was moved" "own_err_keeps_mismatch|'@keeps' of 'Pair.constructor' names 'a', but its body keeps 'b'" "own_err_borrows_not_generator|'@borrows' of 'copyOf' names 'w', but it neither makes a generator whose state borrows its arguments nor returns one" diff --git a/tslang/test/tester/own/own_err_interface_keeps_literal.ts b/tslang/test/tester/own/own_err_interface_keeps_literal.ts new file mode 100644 index 000000000..8134c55a2 --- /dev/null +++ b/tslang/test/tester/own/own_err_interface_keeps_literal.ts @@ -0,0 +1,16 @@ +// -mm=own rejects: a method that keeps its argument, reached through an interface. The call +// through the interface is not given the method's facts, so the caller would release the literal +// the method keeps (a double free). Until facts reach interface calls (G9), the method's body is +// the error. +class V { + constructor(public x: number) {} +} +interface Ray { start: V; dir: V; } +interface Sink { take(r: Ray): void; } +class KS implements Sink { rays: Ray[] = []; take(r: Ray) { this.rays.push(r); } } +function main() { + const ks = new KS(); + const s: Sink = ks; + s.take({ start: new V(1), dir: new V(2) }); + print(ks.rays[0].start.x, ks.rays[0].dir.x); +} diff --git a/tslang/test/tester/own/own_err_interface_keeps_param.ts b/tslang/test/tester/own/own_err_interface_keeps_param.ts new file mode 100644 index 000000000..3b933ddda --- /dev/null +++ b/tslang/test/tester/own/own_err_interface_keeps_param.ts @@ -0,0 +1,15 @@ +// -mm=own rejects: a method that keeps its argument, reached through an interface. The call +// through the interface is not given the method's facts, so the caller would release the value +// the method keeps (a double free). Until facts reach interface calls (G9), the method's body is +// the error. +class V { + constructor(public x: number) {} +} +interface SinkV { take(v: V): void; } +class KV implements SinkV { vs: V[] = []; take(v: V) { this.vs.push(v); } } +function main() { + const ks = new KV(); + const s: SinkV = ks; + s.take(new V(1)); + print(ks.vs[0].x); +} From e50cfc1f931436d59e95f77623d4dbb21e3cc4cf Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 00:41:36 +0100 Subject: [PATCH 09/57] own-raytrace T3: -mm=own: a method reached through an interface may not return a borrow The mirror of the keeping rule: a call through an interface gets none of the method's facts, so a caller released a result that borrows an argument or `this` (`g.get(a)`, `i.first()`, `h.hold()`) as its own, a double free. A method an interface vtable holds now loses borrowing facts too (body or @lends), and its body is the existing error. The interface reason wins over an exported method's "@lends or @keeps would say it" note. New negatives own_err_interface_borrow_param, _element, _field; spec 27 updated. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 35 +++++++++------- .../lib/TypeScript/OwnershipSignaturePass.cpp | 40 ++++++++++--------- tslang/test/tester/CMakeLists.txt | 3 ++ .../own/own_err_interface_borrow_element.ts | 15 +++++++ .../own/own_err_interface_borrow_field.ts | 14 +++++++ .../own/own_err_interface_borrow_param.ts | 15 +++++++ 6 files changed, 89 insertions(+), 33 deletions(-) create mode 100644 tslang/test/tester/own/own_err_interface_borrow_element.ts create mode 100644 tslang/test/tester/own/own_err_interface_borrow_field.ts create mode 100644 tslang/test/tester/own/own_err_interface_borrow_param.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 9bd65b33e..408361a28 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 @@ -2932,20 +2932,27 @@ A literal stored into a field of a nullable interface type (`k.r = {...}` with ` again for the union, and released nowhere in the function (the program compiles and runs under rc). Into an `r: Ray` field it compiles. -**A method that keeps its argument, reached through an interface.** A call through an interface -(`s.take(x)` with `s: Sink`, a `ts.InterfaceSymbolRef` and an indirect call) is not resolved, so it -gets none of the method's facts, and the caller released what the method kept: a double free under -own, older than this section (`s.take(new V(1))`), which a literal argument now reached too. The -interface's vtable for a class (`KS.Sink..vtbl`) holds the class's `..instanceOf`, so the signature -pass took it for a class vtable and its entries for calls it resolves. Now `collectInterfaceReached` -tells it apart by its name (`...vtbl`, where the class's own `..instanceOf` is -`..instanceOf`), and a method it holds that keeps a parameter (or says `@keeps`) loses its -facts: its body is `'' is a parameter, kept here, but -mm=own cannot make the callers of 'KS.take' -give it up`, noted `it is reached through an interface, whose calls are not given its facts` -(`own_err_interface_keeps_literal`, `own_err_interface_keeps_param`). A direct call of such a method -(`ks.take(x)` in a program that also casts `ks` to `Sink`) is refused too. Only keeping is refused; -a method's result that borrows is left as it was. G9 (facts through a module-private interface) -relaxes this for closed interfaces by giving the interface call the meet of these facts. +**A method that keeps its argument or returns a borrow, reached through an interface.** A call +through an interface (`s.take(x)` with `s: Sink`, a `ts.InterfaceSymbolRef` and an indirect call) +is not resolved, so it gets none of the method's facts. The caller released what the method kept, +and released a result that borrows (an argument, `this.v`, `this.items[0]`) as its own: a double +free under own either way, older than this section (`s.take(new V(1))`, `g.get(a)`), which a +literal argument now reached too. The interface's vtable for a class (`KS.Sink..vtbl`) holds the +class's `..instanceOf`, so the signature pass took it for a class vtable and its entries for calls +it resolves. Now `collectInterfaceReached` tells it apart by its name (`...vtbl`, +where the class's own `..instanceOf` is `..instanceOf`), and a method it holds that keeps a +parameter or returns a borrow, by its body or by `@keeps` / `@lends`, loses its facts. Its body is +then the error the path that finds it gives - `'' is a parameter, kept here, but -mm=own +cannot make the callers of 'KS.take' give it up`, `... is a parameter, returned here, but -mm=own +cannot tell the callers of 'G.get' that the result borrows it`, `borrows a field (an element) and +cannot be stored, returned or captured` - noted `it is reached through an interface, whose calls are +not given its facts`, which wins over an exported method's `'@lends' or '@keeps' would say it` +(`own_err_interface_keeps_literal`, `own_err_interface_keeps_param`, +`own_err_interface_borrow_param`, `own_err_interface_borrow_element`, +`own_err_interface_borrow_field`). A direct call of such a method (`ks.take(x)`) is refused too +whenever the class implements the interface: its interface vtable is made for the `implements` +clause, cast or not. G9 (facts through a module-private interface) relaxes this for closed +interfaces by giving the interface call the meet of these facts. `own_literal_moves` runs the shapes (two moved `const`s; a fresh call result beside a moved one; an array of literals; a `const r: Ray`; a returned interface record holding an array; 1000 literals diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index 0f9433c84..00b7eed62 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -632,11 +632,14 @@ class OwnershipSignaturePass : public mlir::PassWrapper...vtbl`) holds. A call through // the interface (`ts.InterfaceSymbolRef`, then an indirect call) is not resolved, so it is given - // none of their facts: a caller that hands such a method an argument it keeps still releases it - // (a double free). So a method reached this way that keeps a parameter loses its facts, and the - // body's error says why (`is a parameter, kept here, but -mm=own cannot make the callers of ... - // give it up`). Task 6 of the ray tracer plan (G9, facts through a module-private interface) - // relaxes this for closed interfaces by giving the interface call the meet of these facts. + // none of their facts: a caller that hands such a method an argument it keeps still releases it, + // and a caller of one whose result borrows an argument or `this` releases the borrow as its own + // (a double free either way). So a method reached this way that keeps a parameter or returns a + // borrow - by its body or by `@keeps` / `@lends` - loses its facts, and the body's error says + // why (`is a parameter, kept here, but -mm=own cannot make the callers of ... give it up`, `is a + // parameter, returned here, but -mm=own cannot tell the callers of ... that the result borrows + // it`). Task 6 of the ray tracer plan (G9, facts through a module-private interface) relaxes + // this for closed interfaces by giving the interface call the meet of these facts. llvm::DenseSet interfaceReached; static constexpr const char *INTERFACE_REACHED_WHY = "it is reached through an interface, whose calls are not given its facts"; @@ -1406,11 +1409,12 @@ class OwnershipSignaturePass : public mlir::PassWrapper &owned) + // Does a method reached through an interface keep a parameter or return a borrow + // (interfaceReached)? Then its callers through the interface cannot be told, and it has no facts + // at all. + bool factsThroughInterface(mlir::Operation *funcOp, const Facts &facts) { - return !owned.empty() && interfaceReached.contains(funcOp); + return !facts.empty() && interfaceReached.contains(funcOp); } mlir_ts::TupleType storageTupleOf(mlir::Type type) @@ -2059,26 +2063,27 @@ class OwnershipSignaturePass : public mlir::PassWrapper(keeps.begin(), keeps.end())}; + if (factsThroughInterface(entry.second, said)) { continue; } - factsEffective[entry.second] = - Facts{declaredLends(entry.second), llvm::SmallVector(keeps.begin(), keeps.end())}; + factsEffective[entry.second] = said; } } @@ -2158,11 +2163,8 @@ class OwnershipSignaturePass : public mlir::PassWrapperremoveAttr(OWN_FACTS_LOST_ATTR_NAME); if (effective.empty() && !local.empty()) { - auto *why = open.lookup(funcOp); - if (!why && keepsThroughInterface(funcOp, local.owned)) - { - why = INTERFACE_REACHED_WHY; - } + // through an interface first: a marker would not help there (interfaceReached) + auto *why = factsThroughInterface(funcOp, local) ? INTERFACE_REACHED_WHY : open.lookup(funcOp); funcOp->setAttr(OWN_FACTS_LOST_ATTR_NAME, mlir::StringAttr::get(&getContext(), why ? why : "an override in its class family disagrees")); diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 69367bd4d..b9a953161 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3743,6 +3743,9 @@ set(own_error_cases "own_err_record_literal_let|an object literal that holds a value it owns is moved only into a local or returned as it is" "own_err_interface_keeps_literal|is a parameter, kept here, but -mm=own cannot make the callers of 'KS.take' give it up" "own_err_interface_keeps_param|is a parameter, kept here, but -mm=own cannot make the callers of 'KV.take' give it up" + "own_err_interface_borrow_param|is a parameter, returned here, but -mm=own cannot tell the callers of 'G.get' that the result borrows it" + "own_err_interface_borrow_element|borrows an element and cannot be stored, returned or captured" + "own_err_interface_borrow_field|borrows a field and cannot be stored, returned or captured" "own_err_string_kept_param_known|is used here after its value was moved" "own_err_keeps_mismatch|'@keeps' of 'Pair.constructor' names 'a', but its body keeps 'b'" "own_err_borrows_not_generator|'@borrows' of 'copyOf' names 'w', but it neither makes a generator whose state borrows its arguments nor returns one" diff --git a/tslang/test/tester/own/own_err_interface_borrow_element.ts b/tslang/test/tester/own/own_err_interface_borrow_element.ts new file mode 100644 index 000000000..8cc77e53e --- /dev/null +++ b/tslang/test/tester/own/own_err_interface_borrow_element.ts @@ -0,0 +1,15 @@ +// -mm=own rejects: a method whose result borrows an element of `this`, reached through an +// interface. The call through the interface is not given the method's facts, so the caller would +// release the borrow as its own (a double free). Until facts reach interface calls (G9), the +// method's body is the error. +class V { + constructor(public x: number) {} +} +interface First { first(): V; } +class F implements First { items: V[] = [new V(3)]; first(): V { return this.items[0]; } } +function main() { + const f = new F(); + const i: First = f; + const x = i.first(); + print(x.x, f.items[0].x); +} diff --git a/tslang/test/tester/own/own_err_interface_borrow_field.ts b/tslang/test/tester/own/own_err_interface_borrow_field.ts new file mode 100644 index 000000000..2e0098ea5 --- /dev/null +++ b/tslang/test/tester/own/own_err_interface_borrow_field.ts @@ -0,0 +1,14 @@ +// -mm=own rejects: a method whose result borrows a field of `this`, reached through an interface. +// The call through the interface is not given the method's facts, so the caller would release the +// borrow as its own (a double free). Until facts reach interface calls (G9), the method's body is +// the error. +class V { + constructor(public x: number) {} +} +interface Holder { hold(): V; } +class HH implements Holder { v: V = new V(4); hold(): V { return this.v; } } +function use(h: Holder) { const x = h.hold(); return x.x; } +function main() { + const hh = new HH(); + print(use(hh), hh.v.x); +} diff --git a/tslang/test/tester/own/own_err_interface_borrow_param.ts b/tslang/test/tester/own/own_err_interface_borrow_param.ts new file mode 100644 index 000000000..981a682a0 --- /dev/null +++ b/tslang/test/tester/own/own_err_interface_borrow_param.ts @@ -0,0 +1,15 @@ +// -mm=own rejects: a method whose result borrows its argument, reached through an interface. The +// call through the interface is not given the method's facts, so the caller would release the +// borrow as its own (a double free). Until facts reach interface calls (G9), the method's body is +// the error. +class V { + constructor(public x: number) {} +} +interface Getter { get(v: V): V; } +class G implements Getter { get(v: V): V { return v; } } +function main() { + const g: Getter = new G(); + const a = new V(5); + const b = g.get(a); + print(b.x, a.x); +} From fe25ec167e09c6693885c7bc9ed27b68cf63d9e8 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 09:03:06 +0100 Subject: [PATCH 10/57] own-raytrace T4: -mm=own: a field read through an interface is a place read `let d = r.dir` with `r: R` an interface failed with "takes a second reference": a ts.InterfaceSymbolRef field was not a place, so its read was not a borrow of the container. The same gap left `const` reads through an interface unchecked (overwritten through the interface, then read: use after free) and overwrites through an interface invisible to both passes (a class field's borrow outlived `hi.c = ...`, and a callee writing through an interface parameter was given `__own_no_drops`). An interface field (InterfaceSymbolRef with a reference result) is now a place: its read borrows, bounded by what it was read through (a chain of field reads and interface views), and ended by a call only when the call's facts say it may drop. Its position in the object is not known, so an overwrite through an interface drops every field on a chain and any field overwrite drops a read through an interface. Co-Authored-By: Claude Opus 5.5 --- tslang/lib/TypeScript/OwnershipFacts.h | 22 +++++++++- .../lib/TypeScript/OwnershipInferencePass.cpp | 35 +++++++++++++++- .../lib/TypeScript/OwnershipSignaturePass.cpp | 6 +++ tslang/test/tester/CMakeLists.txt | 16 +++++++- .../tester/own/own_err_iface_field_call.ts | 33 +++++++++++++++ .../own/own_err_iface_field_overwritten.ts | 29 +++++++++++++ .../tester/own/own_err_iface_field_result.ts | 29 +++++++++++++ .../own_err_iface_field_view_overwritten.ts | 27 ++++++++++++ tslang/test/tester/own/own_field_chain_let.ts | 41 +++++++++++++++++++ 9 files changed, 233 insertions(+), 5 deletions(-) create mode 100644 tslang/test/tester/own/own_err_iface_field_call.ts create mode 100644 tslang/test/tester/own/own_err_iface_field_overwritten.ts create mode 100644 tslang/test/tester/own/own_err_iface_field_result.ts create mode 100644 tslang/test/tester/own/own_err_iface_field_view_overwritten.ts create mode 100644 tslang/test/tester/own/own_field_chain_let.ts diff --git a/tslang/lib/TypeScript/OwnershipFacts.h b/tslang/lib/TypeScript/OwnershipFacts.h index d32ab4047..e4e690969 100644 --- a/tslang/lib/TypeScript/OwnershipFacts.h +++ b/tslang/lib/TypeScript/OwnershipFacts.h @@ -350,11 +350,20 @@ inline bool isCall(mlir::Operation *op) mlir_ts::InvokeHybridOp>(op); } -// A field, an element, or `s.value` - the payload of a handle (spec 23.1). +// A field of an object seen through an interface (`i.f`): the object's own field, reached by the +// offset the interface's vtable holds. A method of the interface is a bound function, not a place. +inline bool isInterfaceField(mlir::Value ref) +{ + return ref.getDefiningOp() && mlir::isa(ref.getType()); +} + +// A field, an element, or `s.value` - the payload of a handle (spec 23.1) - and a field reached +// through an interface (spec 28). inline bool isPlace(mlir::Value ref) { return mlir::isa_and_nonnull( - ref.getDefiningOp()); + ref.getDefiningOp()) || + isInterfaceField(ref); } // A handle (spec 23.2): counted in every model that tracks ownership, and left alone by own. @@ -447,6 +456,10 @@ inline bool reachesSharedValue(mlir::Value ref) { work.push_back(elementRefOp.getArray()); } + else if (auto interfaceRefOp = value.getDefiningOp(); interfaceRefOp && isInterfaceField(value)) + { + work.push_back(interfaceRefOp.getInterfaceVal()); + } else if (auto loadOp = value.getDefiningOp(); loadOp && isPlace(loadOp.getReference())) { work.push_back(loadOp.getReference()); @@ -922,6 +935,11 @@ inline int borrowedParam(mlir::Value value, int depth = 0) return borrowedParam(elementRefOp.getArray(), depth + 1); } + if (auto interfaceRefOp = mlir::dyn_cast_or_null(def); interfaceRefOp && isInterfaceField(value)) + { + return borrowedParam(interfaceRefOp.getInterfaceVal(), depth + 1); + } + if (auto loadOp = mlir::dyn_cast_or_null(def)) { auto ref = loadOp.getReference(); diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index a9fcad653..66ecd51ce 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -2792,9 +2792,11 @@ class OwnershipInferencePass }; // a field's position and reference type; an element is position -1, a handle's payload - // (`s.value`) is SharedValue + // (`s.value`) is SharedValue, a field reached through an interface is InterfaceField: its + // position in the object is not known here (spec 28) llvm::SmallVector> places; static constexpr int64_t SharedValue = -2; + static constexpr int64_t InterfaceField = -3; llvm::SmallVector> roots; // The roots that are handles - what a `s.value` was read through - each also NotOwned // among `roots` (spec 23.3). Known from the op, not the root's type: a view may hide it. @@ -2849,6 +2851,11 @@ class OwnershipInferencePass base = sharedValueRefOp.getShared(); handleBases.insert(rootOf(base)); } + else if (auto interfaceRefOp = ref.getDefiningOp()) + { + chain.places.push_back({Chain::InterfaceField, interfaceRefOp.getType()}); + base = interfaceRefOp.getInterfaceVal(); + } else { auto elementRefOp = ref.getDefiningOp(); @@ -3141,13 +3148,28 @@ class OwnershipInferencePass return llvm::any_of(chain.places, [&](auto &place) { return place.first == Chain::SharedValue; }); } + auto isField = [](int64_t position) { return position >= 0 || position == Chain::InterfaceField; }; int64_t position = -1; if (auto propertyRefOp = slot.getDefiningOp()) { position = propertyRefOp.getPosition(); } + else if (isInterfaceField(slot)) + { + position = Chain::InterfaceField; + } + // A field reached through an interface may be any field of the object under it, of any + // type (a class may declare the field narrower than the interface): an overwrite through + // an interface drops every field on the chain, and any field's overwrite drops a field + // read through an interface (spec 28). return llvm::any_of(chain.places, [&](auto &place) { + if (isField(position) && isField(place.first) && + (position == Chain::InterfaceField || place.first == Chain::InterfaceField)) + { + return true; + } + return place.first == position && place.second == slot.getType(); }); } @@ -3386,6 +3408,17 @@ class OwnershipInferencePass base = sharedValueRefOp.getShared(); step = ".value"; } + else if (auto interfaceRefOp = ref.getDefiningOp()) + { + auto field = mlir::dyn_cast(interfaceRefOp.getIdentifier()); + if (!field) + { + return {}; + } + + base = interfaceRefOp.getInterfaceVal(); + step = "." + field.getValue().str(); + } else { return {}; diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index 00b7eed62..e2e1147d5 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -2778,6 +2778,12 @@ class OwnershipSignaturePass : public mlir::PassWrapper(); interfaceRefOp && isInterfaceField(value)) + { + work.push_back(interfaceRefOp.getInterfaceVal()); + continue; + } + if (auto argument = mlir::dyn_cast(value)) { llvm::SmallVector merged; diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index b9a953161..7967426bb 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3480,7 +3480,8 @@ foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local o own_final_fresh_result own_final_field_lend own_null_narrowing_di own_param_markers own_generator_locals own_string_param_assigned own_lends own_immortal_global own_immortal_global_region - own_literal_moves own_record_literal_given) + own_literal_moves own_record_literal_given + own_field_chain_let) 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. @@ -3516,6 +3517,13 @@ add_test(NAME test-own-di-null-narrowing set_tests_properties(test-own-di-null-narrowing PROPERTIES PASS_REGULAR_EXPRESSION "ts.Func" FAIL_REGULAR_EXPRESSION "error|Stack dump") +# A field read through an interface is a place read under own (spec 28); the same program under +# every other model. +foreach(field_chain_mm rc gc none) + tslang_add_test(NAME test-jit-${field_chain_mm}-own_field_chain_let COMMAND test-runner -jit -mm=${field_chain_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_field_chain_let.ts") + tslang_add_test(NAME test-compile-${field_chain_mm}-own_field_chain_let COMMAND test-runner -mm=${field_chain_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_field_chain_let.ts") +endforeach() + # A global declared in top-level code owns what it is given, under every model that counts. foreach(global_mm rc none gc) tslang_add_test(NAME test-jit-${global_mm}-own-global-script COMMAND test-runner -jit -mm=${global_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_global_script.ts") @@ -3773,7 +3781,11 @@ set(own_error_cases "own_err_shared_place_two_uses|borrows a field but is used here after it may be released or overwritten" "own_err_shared_place_literal|borrows a field and cannot be stored, returned or captured" "own_err_shared_place_tuple|borrows a field and cannot be stored, returned or captured" - "own_err_shared_literal_borrowed|the initializer of global 'b' takes a second reference") + "own_err_shared_literal_borrowed|the initializer of global 'b' takes a second reference" + "own_err_iface_field_overwritten|borrows a field but is used here after it may be released or overwritten" + "own_err_iface_field_view_overwritten|borrows a field but is used here after it may be released or overwritten" + "own_err_iface_field_call|borrows a field but is used here after it may be released or overwritten" + "own_err_iface_field_result|borrows the result of 'dirOf' but is used here after it may be released or overwritten") 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_iface_field_call.ts b/tslang/test/tester/own/own_err_iface_field_call.ts new file mode 100644 index 000000000..35d429234 --- /dev/null +++ b/tslang/test/tester/own/own_err_iface_field_call.ts @@ -0,0 +1,33 @@ +// -mm=own rejects: `d` borrows `r.dir`, read through an interface, and a call given `r` overwrites +// that field through the interface (spec 28). +class V { + v: number[] = []; + constructor(public x: number) {} +} + +function churn() { + const junk: V[] = []; + for (let i = 0; i < 64; i++) { + const c = new V(999); + c.v.push(99); + junk.push(c); + } +} + +interface R { dir: V; } + +function setDir(r: R) { + r.dir = new V(5); +} + +function f(r: R) { + let d = r.dir; + setDir(r); + churn(); + print(d.x); +} + +function main() { + const r: R = { dir: new V(1) }; + f(r); +} diff --git a/tslang/test/tester/own/own_err_iface_field_overwritten.ts b/tslang/test/tester/own/own_err_iface_field_overwritten.ts new file mode 100644 index 000000000..2c05c131f --- /dev/null +++ b/tslang/test/tester/own/own_err_iface_field_overwritten.ts @@ -0,0 +1,29 @@ +// -mm=own rejects: `d` borrows `r.dir`, read through an interface, which is overwritten through it +// before `d` is read (spec 28). +class V { + v: number[] = []; + constructor(public x: number) {} +} + +function churn() { + const junk: V[] = []; + for (let i = 0; i < 64; i++) { + const c = new V(999); + c.v.push(99); + junk.push(c); + } +} + +interface R { dir: V; } + +function f(r: R) { + let d = r.dir; + r.dir = new V(5); + churn(); + print(d.x); +} + +function main() { + const r: R = { dir: new V(1) }; + f(r); +} diff --git a/tslang/test/tester/own/own_err_iface_field_result.ts b/tslang/test/tester/own/own_err_iface_field_result.ts new file mode 100644 index 000000000..46170aea7 --- /dev/null +++ b/tslang/test/tester/own/own_err_iface_field_result.ts @@ -0,0 +1,29 @@ +// -mm=own rejects: `dirOf` returns a borrow of its parameter's field, read through an interface; +// the result is read after that field is overwritten (spec 28). +class V { + v: number[] = []; + constructor(public x: number) {} +} + +function churn() { + const junk: V[] = []; + for (let i = 0; i < 64; i++) { + const c = new V(999); + c.v.push(99); + junk.push(c); + } +} + +interface R { dir: V; } + +function dirOf(r: R) { + return r.dir; +} + +function main() { + const r: R = { dir: new V(1) }; + const d = dirOf(r); + r.dir = new V(2); + churn(); + print(d.x); +} diff --git a/tslang/test/tester/own/own_err_iface_field_view_overwritten.ts b/tslang/test/tester/own/own_err_iface_field_view_overwritten.ts new file mode 100644 index 000000000..e0ee3feb4 --- /dev/null +++ b/tslang/test/tester/own/own_err_iface_field_view_overwritten.ts @@ -0,0 +1,27 @@ +// -mm=own rejects: `c` borrows the class field `h.c`, which is overwritten through an interface +// view of `h` before `c` is read (spec 28). +class C { + v: number[] = []; + constructor(public x: number) {} +} + +function churn() { + const junk: C[] = []; + for (let i = 0; i < 64; i++) { + const c = new C(999); + c.v.push(99); + junk.push(c); + } +} + +interface HI { c: C; } +class H { c: C = new C(0); } + +function main() { + const h = new H(); + const hi: HI = h; + const c = h.c; + hi.c = new C(9); + churn(); + print(c.x); +} diff --git a/tslang/test/tester/own/own_field_chain_let.ts b/tslang/test/tester/own/own_field_chain_let.ts new file mode 100644 index 000000000..351219943 --- /dev/null +++ b/tslang/test/tester/own/own_field_chain_let.ts @@ -0,0 +1,41 @@ +// -mm=own: a field read through an interface is a place read like a class field's (spec 28). A +// `let` initialized from one - through any chain of field reads and interface views - borrows the +// field, bounded by what it was read through; a call ends the borrow only when its facts say it +// may drop or overwrite it. A function may return such a read as a borrow of its parameter. +class V { + constructor(public x: number) {} + static plus(a: V, b: V) { return new V(a.x + b.x); } +} +interface R { dir: V; } +interface Is { ray: R; dist: number; } +class RayC implements R { constructor(public dir: V) {} } +function f(r: R) { let d = r.dir; let p = V.plus(d, d); return V.plus(p, d); } +function sh(i: Is) { let d = i.ray.dir; let pos = V.plus(d, i.ray.dir); return V.plus(d, pos); } +function dirOf(r: R) { return r.dir; } +function keep(r: R, v: V) { r.dir = v; } +function main() { + const r: R = { dir: new V(1) }; + const i: Is = { ray: { dir: new V(2) }, dist: 3 }; + print(f(r).x, sh(i).x); // 3 6 + const c = new RayC(new V(4)); + print(f(c).x, dirOf(c).x); // 12 4 + let s: number = 0; + for (let k = 0; k < 100000; k++) { + s += f(r).x + sh(i).x + dirOf(i.ray).x; + } + + print(s, r.dir.x, i.ray.dir.x, c.dir.x); // 1.1e+06 1 2 4 + assert(s == 1100000 && r.dir.x == 1 && i.ray.dir.x == 2 && c.dir.x == 4); + // a store through an interface field keeps the value: `nv` moves into `k.dir` + const k: R = { dir: new V(0) }; + const nv = new V(7); + keep(k, nv); + let t: number = 0; + for (let j = 0; j < 1000; j++) { + t += V.plus(k.dir, k.dir).x; + } + + print(t, k.dir.x); // 14000 7 + assert(t == 14000 && k.dir.x == 7); + print("done."); +} From c93963532d0a9b7f2e540fe77002008694787a88 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 09:03:32 +0100 Subject: [PATCH 11/57] own-raytrace T4: spec 28, a field read through an interface Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) 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 408361a28..b4f387d44 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 @@ -2962,3 +2962,78 @@ given to a callee that keeps them) under own, rc, gc and none, AOT and JIT, and runs of both fail with `0xC0000374` (heap corruption: the box's release frees the field, then the value's own release frees it again). A loop of 2 million iterations of every shape (about 10 blocks each) peaks at 4.8 MB under own, as under rc (gc 6.7 MB): the box's release frees its fields. + +## 28. A field read through an interface (G6 of the ray tracer plan), 2026-10-22 + +`let d = r.dir` with `r: R` an interface was `takes a second reference; -mm=own cannot prove a move +or a borrow here yet`, while the same read of a class field borrows (§2.3, phase 3). The field of an +interface is a `ts.InterfaceSymbolRef` - the object's field at the offset the interface's vtable +holds - and `isPlace` named only `ts.PropertyRef`, `ts.ElementRef` and `s.value`. So the read was no +borrow at all: a `let` from it fell through to the second-reference error, and a `const` from it, or +a read used in place, was not checked against anything. That was a use after free older than this +section: `const c = h.c; h.c = new C(9); print(c.x)` with `h: HI` printed a freed block's value. The +overwrite was invisible too: `hi.c = new C(9)` through an interface view of `h` did not end a borrow +of the class field `h.c`, and the signature pass gave `function set(hi: HI) { hi.c = new C(9); }` +`__own_no_drops`, so its callers kept their borrows of `hi.c` across the call. + +- **An interface field is a place** (`own_facts::isInterfaceField`: an `InterfaceSymbolRef` whose + result is a reference; a method is a bound function, not a place). Its read borrows, as a class + field's does: tenure-checked against the ends of what it was read through and against the drops + (`checkPlaceRead`), through any chain of field reads and interface views (`i.ray.dir`: the chain + `dir`, `ray`, parameter `i`). A call ends it only when the call's facts say it may drop - `V.plus` + does not - and any call does for a root the function does not own, as `own_err_param_read_call` + pins for a class field. `borrowedParam`, `reachesSharedValue` and the signature pass's + `reachesOutside` walk through it, so `function dirOf(r: R) { return r.dir; }` returns a borrow of + `r`, bounded by the caller's `r`, and an overwrite through an interface parameter is a drop. A + parameter stored through an interface field (`function keep(r: R, v: V) { r.dir = v; }`) is kept + (`__own_params`), as one stored into a class field is: the caller moves it in. Before, that was + `is a parameter, kept here, but -mm=own cannot make the callers of 'keep' give it up`. +- **Which field it is** is not known where it is read: the interface's index is not the class's + position, and a class may declare the field narrower than the interface. So the chain records it + as `Chain::InterfaceField`, and `dropsChain` matches it with any field overwrite of any position + and type, and an overwrite through an interface with every field on a chain. Elements and `s.value` + keep their own matching. + +`own_field_chain_let` runs `let d = r.dir` and `let d = i.ray.dir` (a nested interface literal, as +§27 accepts it) with `V.plus` between the reads, a class implementing the interface passed as it, +and `dirOf`'s borrowed result, 100,000 times, then `keep` moving a `const` into a field through an +interface and reading it 1000 times, under own, rc, gc and none, AOT and JIT, and on Linux; +own emits no counting. Negatives: `own_err_iface_field_overwritten` (overwritten through the +parameter), `own_err_iface_field_view_overwritten` (a class field's borrow, overwritten through an +interface view), `own_err_iface_field_call` (a callee given the interface overwrites it: no +`__own_no_drops`), `own_err_iface_field_result` (`dirOf`'s result read after the field is +overwritten), all `borrows ... but is used here after it may be released or overwritten`. Teeth: +with the borrowing `let`'s releases kept (only its retain erased), the AOT run of +`own_field_chain_let` fails with `0xC0000374` (heap corruption: the `let`'s release frees `r.dir`), +and so does the JIT run; with the interface clause of `dropsChain` skipped, +`own_err_iface_field_view_overwritten` compiles and prints a freed block's value. With the call's +`__own_result_borrows` dropped for a callee that reads through an interface field (the caller then +owns `dirOf`'s result and releases `r.dir` while the record holds it), and with the call's +`__own_params` dropped for such a callee (the caller still releases `nv` after `keep` stored it in +`k.dir`), the AOT run fails with `0xC0000374` both times. A loop of 2 million iterations (about 6 +blocks each) peaks at 4.3 MB under own, as under rc. + +Only fields: `borrowedParam`, `reachesSharedValue` and `reachesOutside` walk through an +`InterfaceSymbolRef` only when it is a field (`isInterfaceField`). A method read through an +interface (`function getM(m: M) { return m.get; }`) is what it was before this section. + +Corpus (`test/tester/tests/*.ts` under `-mm=own --no-default-lib`, `--emit=obj`): 519 of 693 +compile on Linux before this section and 519 after, the same files; none lost, none gained. On +Windows 522 after (`00catch_value`, `00cppvtable_hidden_return` and `00throw_in_block_debug_info` +compile there and not on Linux, before and after). One file that fails either way fails earlier, +with a more precise error: `00array_assignment5` was `takes a second reference` and is now `borrows +'Test.lineTokens.tokens' and cannot be stored, returned or captured`. + +Not here: the ray tracer's `shade` still fails at `let d = isect.ray.dir`, now as `borrows a field +but is used here after it may be released or overwritten`, because `isect.thing.normal(pos)` is a +call through an interface, which has no facts (§27) and so may drop anything the function does not +own. Checking reads through an interface also reports the reads the ray tracer used in place across +such calls (`scene.camera.pos` beside the `getPoint` closure, `scene.lights` given to `reduce` with +a closure, the rest of `shade`): 24 errors before, 33 now, none of them a new kind. + +They are G9's (facts through a module-private interface), G5's (`reduce`) and G2's (borrowing +records). + +Seen on the way, and rc's own, older than this section: under rc, `const c = h.c; h.c = new C(9); +churn(); print(c.x)` with a class `H` prints 999 (gc prints 0): the `const` takes no reference, so +the overwrite frees what it reads. A `let` is retained. From 1cd5e8d2145119a290d34603aeeccaadaab4d7f1 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 09:24:16 +0100 Subject: [PATCH 12/57] own-raytrace T5: -mm=own: a read after a move goes through the new owner A use of a moved value that only reads it - a field, element or length read, a method reached through it, a comparison, print/assert, an argument the callee borrows - is a borrow of the place the value moved into: an owning local, or a field or element of a local or `this`. It is bounded the way checkPlaceRead bounds a read of that place: the ends of the place's roots and the drops that may destroy what it holds (an overwrite, through an interface view too; a call whose facts may drop; a call given a closure), on a path from the move to the read on which the value is neither made again nor moved again. Any other use after a move is still a second owner. own_read_after_move runs straight-line moves into fields, a move into an owning local in a loop that makes the value each iteration, and a declaration that takes a value over, under own, rc, gc and none. New negatives: overwritten on one path, through an interface view, by a callee that may drop, around a loop's back edge, and after a loop. own_err_field_alias and own_err_union_moved now overwrite the place. own_err_borrow_{bound_method,catch_local,finally_local} give `a` to two lets: with one, the let now takes the value over and the program is correct. Co-Authored-By: Claude Opus 5.5 --- .../lib/TypeScript/OwnershipInferencePass.cpp | 247 +++++++++++++++++- tslang/test/tester/CMakeLists.txt | 16 +- .../tester/own/own_err_borrow_bound_method.ts | 8 +- .../tester/own/own_err_borrow_catch_local.ts | 7 +- .../own/own_err_borrow_finally_local.ts | 7 +- tslang/test/tester/own/own_err_field_alias.ts | 4 +- .../own/own_err_read_after_move_after_loop.ts | 15 ++ .../own/own_err_read_after_move_call.ts | 12 + .../own/own_err_read_after_move_loop.ts | 17 ++ .../own_err_read_after_move_overwritten.ts | 11 + .../own/own_err_read_after_move_view.ts | 13 + tslang/test/tester/own/own_err_union_moved.ts | 5 +- tslang/test/tester/own/own_read_after_move.ts | 68 +++++ 13 files changed, 404 insertions(+), 26 deletions(-) create mode 100644 tslang/test/tester/own/own_err_read_after_move_after_loop.ts create mode 100644 tslang/test/tester/own/own_err_read_after_move_call.ts create mode 100644 tslang/test/tester/own/own_err_read_after_move_loop.ts create mode 100644 tslang/test/tester/own/own_err_read_after_move_overwritten.ts create mode 100644 tslang/test/tester/own/own_err_read_after_move_view.ts create mode 100644 tslang/test/tester/own/own_read_after_move.ts diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index 66ecd51ce..15777d18b 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -2326,7 +2326,7 @@ class OwnershipInferencePass mlir::Operation *walkBorrowed( mlir::Value start, llvm::ArrayRef kills, llvm::SmallVectorImpl &uses, llvm::function_ref)> isBorrower = nullptr, - Keeping keeping = Keeping{nullptr, false}, bool countedHandle = false) + Keeping keeping = Keeping{nullptr, false}, bool countedHandle = false, bool startBorrowed = true) { struct Pending { @@ -2336,7 +2336,9 @@ class OwnershipInferencePass }; llvm::SmallVector values; - values.push_back({start, true, {kills.begin(), kills.end()}}); + // `startBorrowed` false: `start` is what a read produced (a field's reference), whose uses + // are uses of the borrow but not the borrowed value itself + values.push_back({start, startBorrowed, {kills.begin(), kills.end()}}); llvm::SmallPtrSet seen{start}; while (!values.empty()) { @@ -2811,12 +2813,17 @@ class OwnershipInferencePass } }; - Chain chainOf(mlir::Operation *read) + // With `place`, the chain of that place itself: where a store into it moves a value (spec 29). + Chain chainOf(mlir::Operation *read, mlir::Value place = {}) { Chain chain; llvm::SmallVector refs; llvm::SmallVector first; - if (auto loadOp = mlir::dyn_cast(read)) + if (place) + { + refs.push_back(place); + } + else if (auto loadOp = mlir::dyn_cast(read)) { refs.push_back(loadOp.getReference()); } @@ -3119,9 +3126,11 @@ class OwnershipInferencePass // anything (`__own_no_drops`, phase 4) - any such call, for a root this function does not // own, since the owner may be a global the callee assigns; else one given something derived // from the root that the read did not produce. - bool dropsChain(mlir::Operation *drop, const Chain &chain, mlir::Operation *read) + // `borrowed`, when given, is the value the borrow holds in place of the read's result (a value + // moved into the place, spec 29), and `read` may then be null. + bool dropsChain(mlir::Operation *drop, const Chain &chain, mlir::Operation *read, mlir::Value borrowed = {}) { - if (drop == read) + if (read && drop == read) { return false; // a call that returns a borrow has not destroyed it by returning it } @@ -3187,7 +3196,7 @@ class OwnershipInferencePass return true; } - auto &fromRead = derivedFrom(read->getResult(0)); + auto &fromRead = derivedFrom(borrowed ? borrowed : read->getResult(0)); auto sources = rootSources(chain); return llvm::any_of(drop->getOperands(), [&](mlir::Value operand) { return !fromRead.contains(operand) && llvm::any_of(sources, [&](mlir::Value source) { @@ -4133,6 +4142,7 @@ class OwnershipInferencePass llvm::SmallVector takers; llvm::SmallVector releases; llvm::SmallVector reads; + llvm::DenseMap readOf; forEachUse(value, [&](mlir::Operation *user, mlir::Value used) { if (mlir::isa(user)) { @@ -4146,6 +4156,7 @@ class OwnershipInferencePass else if (isBorrow(user, used)) { reads.push_back(user); + readOf.try_emplace(user, used); } else { @@ -4164,10 +4175,23 @@ class OwnershipInferencePass return false; } + llvm::SmallVector made; + if (definition) + { + made.push_back(definition); + } + for (auto *use : llvm::concat(takers, reads)) { if (use != taker && reachableAfter(taker, use, definition)) { + // a read through the new owner, while it holds the value (spec 29) + if (auto found = readOf.find(use); + found != readOf.end() && readsThroughOwner(taker, found->second, use, made)) + { + continue; + } + reportUseAfterMove(use, taker, name); return false; } @@ -4479,15 +4503,26 @@ class OwnershipInferencePass continue; } + // a read through the new owner, while it holds the value (spec 29) + auto throughOwner = [&](mlir::Operation *user, mlir::Value used) { + return readsThroughOwner(taker, used, user, kills); + }; + if (use != load.getOperation() && reachableAfter(taker, use, kills)) { - reportUseAfterMove(use, taker, name); - return false; + auto readOp = mlir::dyn_cast(use); + if (!readOp || !onlyReadThroughOwner(readOp.getResult(), throughOwner)) + { + reportUseAfterMove(use, taker, name); + return false; + } + + continue; } if (auto readOp = mlir::dyn_cast(use)) { - if (auto *late = readUsedAfter(readOp, retain, taker, varOp)) + if (auto *late = readUsedAfter(readOp, retain, taker, varOp, throughOwner)) { reportUseAfterMove(late, taker, name); return false; @@ -4557,8 +4592,10 @@ class OwnershipInferencePass // reach, other than the move's own retain and taker. None if there is none. A path back // through `read` itself reaches a use of a value read again: the read, met after the move, // is the slot's own use to check (around a loop, of a value an assignment stored). + // `isRead` accepts a use that reads the value through its new owner (spec 29). mlir::Operation *readUsedAfter(mlir_ts::LoadOp read, mlir::Operation *retain, mlir::Operation *taker, - mlir::Operation *kill) + mlir::Operation *kill, + llvm::function_ref isRead = nullptr) { llvm::SmallVector values{read.getResult()}; while (!values.empty()) @@ -4583,7 +4620,7 @@ class OwnershipInferencePass kills.push_back(kill); } - if (reachableAfter(taker, user, kills)) + if (reachableAfter(taker, user, kills) && !(isRead && isRead(user, current))) { return user; } @@ -4593,6 +4630,192 @@ class OwnershipInferencePass return nullptr; } + // ---- Reads after a move, through the new owner (spec 29) ---- + // + // `h.c = c; print(c.x)`, `best = v; s += v.x`: after the move the value is the new owner's, and + // a read of it through the old name is a borrow of the place it moved into, as a read of + // `h.c` would be. It is bounded the way checkPlaceRead bounds such a read - by the ends of the + // place's roots and by the drops that may destroy what the place holds (an overwrite, through an + // interface view too; a call whose facts may drop; a call given a closure) - on a path from the + // move to the read on which the value is not made again or moved again. + + // Every use of `value`, through its views, is a read `isRead` accepts. A retain - rc's reference + // for an owner - is not. + static bool onlyReadThroughOwner(mlir::Value value, llvm::function_ref isRead) + { + auto all = true; + forEachUse(value, [&](mlir::Operation *user, mlir::Value used) { + all = all && (mlir::isa(user) || + (!mlir::isa(user) && isRead(user, used))); + }); + + return all; + } + + // A use that only reads the value: a field, element or length read through it, a method reached + // through it, a comparison or arithmetic, a print, an assert, a test, or an argument the callee + // borrows. Anything else - a store, an owning `let`, a keeping call, a return - would be a second + // owner. + static bool isReadAfterMove(mlir::Operation *user, mlir::Value value) + { + if (mlir::isa(user)) + { + return isBorrow(user, value); + } + + return isCall(user) && !isKeptArgument(user, value); + } + + // The place a taker moves its value into, as a chain checkPlaceRead would read it: an owning + // local (a declaration, or an assignment), or a field or element - of a local or a parameter + // (`this`). False for anything else: a call that keeps it, a push, a return, a global, a + // captured variable, a handle's payload. + bool destinationChain(mlir::Operation *taker, Chain &chain) + { + auto ownSlot = [](mlir_ts::VariableOp varOp) { + return varOp && isOwningVariable(varOp) && !varOp.getCaptured().value_or(false); + }; + + if (auto varOp = mlir::dyn_cast(taker)) + { + if (!ownSlot(varOp)) + { + return false; + } + + chain.roots.push_back({varOp.getResult(), Chain::Slot}); + return true; + } + + auto storeOp = mlir::dyn_cast(taker); + if (!storeOp) + { + return false; + } + + auto ref = storeOp.getReference(); + if (auto varOp = ref.getDefiningOp()) + { + if (!ownSlot(varOp)) + { + return false; + } + + chain.roots.push_back({ref, Chain::Slot}); + return true; + } + + if (!isPlace(ref)) + { + return false; + } + + chain = chainOf(nullptr, ref); + if (!chain.handles.empty() || chain.roots.empty()) + { + return false; + } + + // a global's field, or a captured variable's: not a place of this function + return llvm::none_of(chain.roots, [](auto &root) { + auto loadOp = root.first.template getDefiningOp(); + return loadOp && (loadOp.getReference().template getDefiningOp() || + isLoadedCell(loadOp.getReference())); + }); + } + + // Is `use`, a use of `value` after `taker` moved it, a read through the place it moved into + // (spec 29)? `made` is where the value is made again (its definition, the local's declaration + // and assignments). Sound when no path from the move through an end or a drop of the place to + // the read - or to anything the read produced - makes the value again or moves it again: such a + // path is the only way the place can stop holding the value before the read. + bool readsThroughOwner(mlir::Operation *taker, mlir::Value value, mlir::Operation *use, + llvm::ArrayRef made) + { + if (!isReadAfterMove(use, value)) + { + return false; + } + + Chain chain; + if (!destinationChain(taker, chain)) + { + return false; + } + + // A read again of the old name after a drop still finds the moved value - the slot is not + // emptied by a move - so neither the read nor the slot's read is where it is made again. + llvm::SmallVector kills(made.begin(), made.end()); + kills.push_back(taker); + llvm::SmallVector uses; + uses.push_back({use, {kills.begin(), kills.end()}}); + for (auto result : use->getResults()) + { + if (!mayPointInto(result)) + { + continue; + } + + // what the read produced - a field's reference, a bound method - still points into the + // block; a call's result that borrows its argument is the value again and may not be kept + if (walkBorrowed(result, kills, uses, nullptr, Keeping{nullptr, false}, false, !!borrowingCall(result))) + { + return false; + } + } + + // a method reached through it whose callee keeps its `this`: a second owner + for (auto &borrowUse : uses) + { + if (!isCall(borrowUse.op)) + { + continue; + } + + auto args = callArgs(borrowUse.op); + for (auto index : ownedParams(borrowUse.op)) + { + if (static_cast(index) < args.size() && + mlir::isa_and_nonnull(args[index].getDefiningOp())) + { + return false; + } + } + } + + auto outlives = [&](mlir::Operation *end) { + if (end == taker || !reachableAfter(taker, end, made)) + { + return false; + } + + return llvm::any_of(uses, [&](BorrowUse &borrowUse) { + return (borrowUse.op != end || !borrowUse.callee) && reachableAfter(end, borrowUse.op, borrowUse.kills); + }); + }; + + for (auto *end : rootEnds(chain)) + { + if (outlives(end)) + { + return false; + } + } + + for (auto *drop : drops) + { + if (dropsChain(drop, chain, nullptr, value) && outlives(drop)) + { + return false; + } + } + + return true; + } + // Where rc takes the receiver's reference: its `ts.Retain` of the value, when that comes // first in the taker's block, else the taker itself (a declaration, whose `ts.RetainSlot` // follows it). A release between the two - a `return`'s scope exit runs after its retain diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 7967426bb..f514c9845 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3481,7 +3481,7 @@ foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local o own_param_markers own_generator_locals own_string_param_assigned own_lends own_immortal_global own_immortal_global_region own_literal_moves own_record_literal_given - own_field_chain_let) + own_field_chain_let own_read_after_move) 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. @@ -3524,6 +3524,13 @@ foreach(field_chain_mm rc gc none) tslang_add_test(NAME test-compile-${field_chain_mm}-own_field_chain_let COMMAND test-runner -mm=${field_chain_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_field_chain_let.ts") endforeach() +# A read after a move, through the new owner, under own (spec 29); the same program under every +# other model. +foreach(read_after_move_mm rc gc none) + tslang_add_test(NAME test-jit-${read_after_move_mm}-own_read_after_move COMMAND test-runner -jit -mm=${read_after_move_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_read_after_move.ts") + tslang_add_test(NAME test-compile-${read_after_move_mm}-own_read_after_move COMMAND test-runner -mm=${read_after_move_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_read_after_move.ts") +endforeach() + # A global declared in top-level code owns what it is given, under every model that counts. foreach(global_mm rc none gc) tslang_add_test(NAME test-jit-${global_mm}-own-global-script COMMAND test-runner -jit -mm=${global_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_global_script.ts") @@ -3785,7 +3792,12 @@ set(own_error_cases "own_err_iface_field_overwritten|borrows a field but is used here after it may be released or overwritten" "own_err_iface_field_view_overwritten|borrows a field but is used here after it may be released or overwritten" "own_err_iface_field_call|borrows a field but is used here after it may be released or overwritten" - "own_err_iface_field_result|borrows the result of 'dirOf' but is used here after it may be released or overwritten") + "own_err_iface_field_result|borrows the result of 'dirOf' but is used here after it may be released or overwritten" + "own_err_read_after_move_overwritten|is used here after its value was moved" + "own_err_read_after_move_view|is used here after its value was moved" + "own_err_read_after_move_call|is used here after its value was moved" + "own_err_read_after_move_loop|is used here after its value was moved" + "own_err_read_after_move_after_loop|is used here after its value was moved") 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_borrow_bound_method.ts b/tslang/test/tester/own/own_err_borrow_bound_method.ts index 8a197cda2..4d4cf5cbc 100644 --- a/tslang/test/tester/own/own_err_borrow_bound_method.ts +++ b/tslang/test/tester/own/own_err_borrow_bound_method.ts @@ -1,6 +1,7 @@ // -mm=own, phase 2 rejects: `f` is `b.m` bound to what `b` borrows, so calling it after `a` is -// overwritten reads the old `a`. What a borrower's reads produce is the borrower too. (`a` is read -// after `let b = a`, so `b` cannot take its value over and borrows it.) +// overwritten reads the old `a`. What a borrower's reads produce is the borrower too. (`a` is given +// to two `let`s, so neither takes its value over: each borrows it. One `let` would take it over, +// and `a.x` after it would read through it, spec 29.) class C { v: number[] = []; constructor(public x: number) {} @@ -19,7 +20,8 @@ function churn() { function main() { let a = new C(1); let b = a; - print(a.x); + let c = a; + print(a.x, c.x); const f = b.m; a = new C(2); churn(); diff --git a/tslang/test/tester/own/own_err_borrow_catch_local.ts b/tslang/test/tester/own/own_err_borrow_catch_local.ts index 3d218b686..2e0e00db5 100644 --- a/tslang/test/tester/own/own_err_borrow_catch_local.ts +++ b/tslang/test/tester/own/own_err_borrow_catch_local.ts @@ -1,6 +1,6 @@ // -mm=own, phase 2 rejects: a catch clause's locals own nothing, so `x` is one more name for what -// `b` borrows - and it is read after `a` is overwritten. (`a` is read after `let b = a`, so `b` -// cannot take its value over and borrows it.) +// `b` borrows - and it is read after `a` is overwritten. (`a` is given to two `let`s, so neither +// takes its value over: each borrows it. One `let` would take it over, spec 29.) class C { v: number[] = []; constructor(public x: number) {} @@ -20,7 +20,8 @@ function main() { let a = new C(1); a.v.push(1); let b = a; - print(a.x); + let c = a; + print(a.x, c.x); try { throw 1; } catch (e) { diff --git a/tslang/test/tester/own/own_err_borrow_finally_local.ts b/tslang/test/tester/own/own_err_borrow_finally_local.ts index cf190750d..b9b484354 100644 --- a/tslang/test/tester/own/own_err_borrow_finally_local.ts +++ b/tslang/test/tester/own/own_err_borrow_finally_local.ts @@ -1,5 +1,5 @@ -// -mm=own, phase 2 rejects: the same through a finally clause's local. (`a` is read after -// `let b = a`, so `b` cannot take its value over and borrows it.) +// -mm=own, phase 2 rejects: the same through a finally clause's local. (`a` is given to two +// `let`s, so neither takes its value over: each borrows it. One `let` would take it over, spec 29.) class C { v: number[] = []; constructor(public x: number) {} @@ -19,7 +19,8 @@ function main() { let a = new C(1); a.v.push(1); let b = a; - print(a.x); + let c = a; + print(a.x, c.x); try { print(b.x); } finally { diff --git a/tslang/test/tester/own/own_err_field_alias.ts b/tslang/test/tester/own/own_err_field_alias.ts index 04c466d72..31bfc76cd 100644 --- a/tslang/test/tester/own/own_err_field_alias.ts +++ b/tslang/test/tester/own/own_err_field_alias.ts @@ -1,9 +1,11 @@ -// -mm=own, phase 0 rejects: the field would be a second owner of `a`'s array. +// -mm=own rejects: `a`'s array moved into `n.v`, and `n.v` is overwritten before `a` is read: the +// overwrite frees the array `a` still names (spec 29). class N { v: number[] = []; } function main() { const a: number[] = [1]; const n = new N(); n.v = a; + n.v = [2]; print(a.length); print("done."); } diff --git a/tslang/test/tester/own/own_err_read_after_move_after_loop.ts b/tslang/test/tester/own/own_err_read_after_move_after_loop.ts new file mode 100644 index 000000000..8108c6bd8 --- /dev/null +++ b/tslang/test/tester/own/own_err_read_after_move_after_loop.ts @@ -0,0 +1,15 @@ +// @strict-null false +// -mm=own rejects: `v` moved into `best`, and a later loop overwrites `best` before `v` is read +// after it (spec 29). +class V { + constructor(public x: number) {} +} +function main() { + let best: V = undefined; + let v = new V(1); + best = v; + for (let i = 0; i < 3; i++) { + best = new V(i); + } + print(v.x, best.x); +} diff --git a/tslang/test/tester/own/own_err_read_after_move_call.ts b/tslang/test/tester/own/own_err_read_after_move_call.ts new file mode 100644 index 000000000..26cedf3de --- /dev/null +++ b/tslang/test/tester/own/own_err_read_after_move_call.ts @@ -0,0 +1,12 @@ +// -mm=own rejects: `c` moved into `h.c`, and a call given `h` - whose facts say it may drop - comes +// before `c` is read (spec 29). +class C { x: number = 5; } +class H { c: C | null = null; } +function clear(h: H) { h.c = null; } +function main() { + const h = new H(); + const c = new C(); + h.c = c; + clear(h); + print(c.x); +} diff --git a/tslang/test/tester/own/own_err_read_after_move_loop.ts b/tslang/test/tester/own/own_err_read_after_move_loop.ts new file mode 100644 index 000000000..843ae7dac --- /dev/null +++ b/tslang/test/tester/own/own_err_read_after_move_loop.ts @@ -0,0 +1,17 @@ +// @strict-null false +// -mm=own rejects: `v` moved into `best` before the loop, and the read at the top of each iteration +// is reached around the back edge after the iteration's overwrite of `best` freed it (spec 29). +class V { + constructor(public x: number) {} +} +function main() { + let best: V = undefined; + let v = new V(1); + best = v; + let s: number = 0; + for (let i = 0; i < 3; i++) { + s += v.x; + best = new V(i); + } + print(s, best.x); +} diff --git a/tslang/test/tester/own/own_err_read_after_move_overwritten.ts b/tslang/test/tester/own/own_err_read_after_move_overwritten.ts new file mode 100644 index 000000000..467f12c39 --- /dev/null +++ b/tslang/test/tester/own/own_err_read_after_move_overwritten.ts @@ -0,0 +1,11 @@ +// -mm=own rejects: `c` moved into `h.c`, which is overwritten on one path before `c` is read (spec +// 29). +class C { x: number = 5; } +class H { c: C | null = null; } +function main() { + const h = new H(); + const c = new C(); + h.c = c; + if (c.x > 1) { h.c = null; } + print(c.x); +} diff --git a/tslang/test/tester/own/own_err_read_after_move_view.ts b/tslang/test/tester/own/own_err_read_after_move_view.ts new file mode 100644 index 000000000..abfcc63e5 --- /dev/null +++ b/tslang/test/tester/own/own_err_read_after_move_view.ts @@ -0,0 +1,13 @@ +// -mm=own rejects: `c` moved into `h.c`, which is overwritten through an interface view of `h` +// before `c` is read (spec 29). +class C { x: number = 5; } +interface HI { c: C | null; } +class H { c: C | null = null; } +function main() { + const h = new H(); + const hv: HI = h; + const c = new C(); + h.c = c; + hv.c = null; + print(c.x); +} diff --git a/tslang/test/tester/own/own_err_union_moved.ts b/tslang/test/tester/own/own_err_union_moved.ts index 55ad90a33..c42e652cf 100644 --- a/tslang/test/tester/own/own_err_union_moved.ts +++ b/tslang/test/tester/own/own_err_union_moved.ts @@ -1,11 +1,12 @@ -// -mm=own, phase 3 rejects: `c` is moved into the nullable field through the widening view, then -// read. +// -mm=own rejects: `c` is moved into the nullable field through the widening view, and the field +// is set to null - freeing `c` - before `c` is read (spec 29). class C { x: number = 5; } class H { c: C | null = null; } function main() { const h = new H(); const c = new C(); h.c = c; + h.c = null; print(c.x); print("done."); } diff --git a/tslang/test/tester/own/own_read_after_move.ts b/tslang/test/tester/own/own_read_after_move.ts new file mode 100644 index 000000000..218c3cab4 --- /dev/null +++ b/tslang/test/tester/own/own_read_after_move.ts @@ -0,0 +1,68 @@ +// @strict-null false +// -mm=own: a value moved into a new owner - a field, an owning local - may still be read after the +// move while that owner holds it unchanged: the read borrows it through the owner (spec 29). A path +// from the move to the read that overwrites, releases or moves the owner on stays an error. +class V { + constructor(public x: number) {} + static plus(a: V, b: V) { return new V(a.x + b.x); } +} +class N { v: number[] = []; } +class C { x: number = 5; } +class H { c: C | null = null; } +// another class's field of the same type at the same position is matched as the same field +// (dropsChain), so the loop's holder keeps its field elsewhere +class HW { k: number = 0; c: C | null = null; } +function churn() { + let s: number = 0; + for (let i = 0; i < 64; i++) { + const junk = new V(999); + const c = new C(); + c.x = 999; + const n = new N(); + n.v.push(99); + s += junk.x + c.x + n.v.length; + } + + return s; +} +function main() { + const a: number[] = [1]; + const n = new N(); + n.v = a; + print(a.length); // 1 + const h = new H(); + const c = new C(); + h.c = c; + print(c.x); // 5 + const vs = [new V(1), new V(3), new V(2)]; + let best: V = undefined; + let s: number = 0; + for (let i in vs) { + let v = V.plus(vs[i], vs[i]); + best = v; + s += v.x; // read after the move into best + } + print(best.x, s); // 4 12 + let t: number = 0; + for (let k = 0; k < 100000; k++) { + const w = new V(k); + const hw = new HW(); + const cw = new C(); + hw.c = cw; + best = w; + t += w.x - k + cw.x; + } + + // a declaration takes the value over; `p` is read through `q`, then given a new value of its own + let p = new V(7); + let q = p; + print(p.x); // 7 + p = new V(8); + churn(); + print(q.x, p.x); // 7 8 + assert(q.x == 7 && p.x == 8); + print(a.length, a[0], c.x, best.x, s, t); // 1 1 5 99999 12 500000 + assert(a.length == 1 && a[0] == 1 && c.x == 5 && best.x == 99999 && s == 12 && t == 500000); + assert(n.v.length == 1 && h.c.x == 5); + print("done."); +} From 4d0bc608ec57dc14a1c3f0af42a4618d8cd93d8e Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 09:27:02 +0100 Subject: [PATCH 13/57] own-raytrace T5: spec 29, a read after a move Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) 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 b4f387d44..d8c1aeeab 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 @@ -3037,3 +3037,87 @@ records). Seen on the way, and rc's own, older than this section: under rc, `const c = h.c; h.c = new C(9); churn(); print(c.x)` with a class `H` prints 999 (gc prints 0): the `const` takes no reference, so the overwrite frees what it reads. A `let` is retained. + +## 29. A read after a move, through the new owner (G7 of the ray tracer plan), 2026-10-22 + +`h.c = c; print(c.x)`, `n.v = a; print(a.length)` and `best = v; s += v.x` were `is used here after +its value was moved`: a move (§2.2) allowed no use of the old name after it. Now a use after a move +that only reads the value is a borrow of the place the value moved into, as a read of `h.c` itself +would be (§2.3), and is accepted while that place still holds it (USER decision). + +- **What a read is** (`isReadAfterMove`): a field, element or length read through the value, a + method reached through it, a comparison or arithmetic, `print`, `assert`, a test, or an argument + the callee borrows (`!isKeptArgument`). What the read produces - a field's reference, a bound + method - is walked as a borrow's uses are (`walkBorrowed`, starting not borrowed); a call result + that borrows its argument is the value again and may not be kept. A method reached through it + whose callee keeps its `this` is not a read. Anything else after the move - a store, an owning + `let`, a keeping call, a return, rc's retain for a receiver - is still a second owner and keeps + the error. +- **Where it moved** (`destinationChain`): an owning local (a declaration, or an assignment), or a + field or element of a local or a parameter (`this`), described as the `Chain` `checkPlaceRead` + reads (`chainOf` from the store's place). A move into a call that keeps the argument, a `push`, a + return, a global's field, a captured variable or a handle's payload is not such a destination. +- **The kill walk** is `checkPlaceRead`'s: the ends of the chain's roots (`rootEnds`) and the drops + that may destroy what the place holds (`dropsChain`, with the moved value standing in for the + read's result): an overwrite of the place, matched by position and type, through an interface + view too (§28); a call whose facts may drop (no `__own_no_drops`) given something that reaches the + place's root, or any such call for a root the function does not own; a call given a closure. The + read fails when some end or drop E has a path move -> E (not through the value's definition, the + local's declaration or its assignments) and a path E -> the read or anything it produced (not + through those either, nor through the move again). Together the two are exactly one path from + the move through E to the read on which the value is neither made again nor moved again; a path + that moves the same value again without making it again is the `moved inside a loop` error + already. A read of the old name again after E is no new value - the move leaves the slot as it + was - so, unlike `checkPlaceRead`, the read itself is no kill. In a loop that makes the value in + each iteration (`let v = ...; best = v; s += v.x`), the next iteration's `best = v` overwrite is + reached from the move only through `let v`, so the read in the same iteration stands. +- `movesOf` (a fresh SSA value, a folded `const`) asks it of each read after a taker; + `checkSlotMove` (an owning local) of every use of a slot read the move reaches, and of each late + use `readUsedAfter` finds. + +`own_read_after_move` runs the straight-line moves into a field (`n.v = a`, `h.c = c`), a move +into an owning local in a loop that makes the value each iteration, 100,000 iterations of a move +into a field and into a local each read after it, and a declaration that takes a value over (`let +q = p; print(p.x); p = new V(8)`: `q` keeps the first value, §24), then allocates same-size +blocks, then reads the moved values again and asserts them, under own, rc, gc and none, AOT and +JIT, and on Linux (every `own` test passes there, 932); own emits no counting. Negatives, all `is +used here after its value was moved`: `own_err_field_alias` (`n.v = [2]` after `n.v = a`), `own_err_union_moved` (`h.c = null`), `own_err_read_after_move_overwritten` +(overwritten on one path), `own_err_read_after_move_view` (overwritten through an interface view +`hv.c = null`), `own_err_read_after_move_call` (`clear(h)`, which may drop), +`own_err_read_after_move_loop` (the read at the top of a loop reached around the back edge after +the iteration's `best = new V(i)` freed the moved value), `own_err_read_after_move_after_loop` +(a read after a loop that overwrote the destination). `own_err_param_kept_used`, `own_err_push_let`, +`own_err_two_owners` and `own_err_literal_two_records` still fail as before. + +`own_err_borrow_bound_method`, `own_err_borrow_catch_local` and `own_err_borrow_finally_local` +(phase 2) made `let b = a` a borrow by reading `a` after it. That read now goes through `b`, which +takes the value over, and `a = new C(2)` frees nothing `b` holds: the programs are correct (they +print what rc prints, on Windows and Linux). They now give `a` to a second `let`, so that each +borrows it, as before. + +Teeth: with the kill walk skipped (`readsThroughOwner` accepting every read), all seven negatives +compile, and their AOT runs with same-size allocations before the read print a freed block's +value: `c.x` prints 0 (overwritten, `clear`) and 999 (through the view) instead of 5, and +`a.length, a[0]` prints `64 6.4649e-312` instead of `1 1`. The positive cannot show it: by design +nothing in it overwrites a destination before a read. With the rule off, the positive fails to +compile with `is used here after its value was moved`, at every read after a move. A loop of 2 +million iterations peaks at 4.5 MB under own, as under rc. + +Not here: an assignment that moves on some paths only (`if (v.x > m) { best = v; }`) is `is moved +here on some paths only; -mm=own cannot release it on the others yet` - the local's one release at +the join is on a path that moved it and one that did not. The use-after-move error came first and +hid it. It is G10 (Task 5b, static drop elaboration), not a follow-on of G7; the ray tracer's +`intersections` has this shape, and the `moved here on some paths only` of design G8 is it too. +Matching an overwrite by position and type also matches another class's field of the same type at +the same position (`dropsChain`, as for any place read): `own_read_after_move`'s loop holder keeps +its field elsewhere for that reason. + +Corpus (`test/tester/tests/*.ts` under `-mm=own --no-default-lib`, `--emit=obj`, Windows): 522 of +693 before this section, 527 after; none lost. Gained: `00class_recursive`, +`00generator_manual_next2`, `00globals_entry_point`, `00owned_locals`, `00union_value_equality`, +each accepted by this rule. Each prints under own what it prints under rc (Windows AOT) and runs +clean under own on Linux. + +The ray tracer (`rtbench_own.ts`) has 33 errors, as at §28, but none of them is `is used here +after its value was moved` any more. Two are `is moved here on some paths only` (G10), which the +use-after-move errors used to hide. The rest are G9's, G5's and G2's (§28). From a024d5147aa3b24df3767974c1fe0a90c35b8ff9 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 09:30:16 +0100 Subject: [PATCH 14/57] own-raytrace T5: spec 29, teeth as measured; a store through a read Co-Authored-By: Claude Opus 5.5 --- .../specs/2026-09-24-own-memory-model-design.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 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 d8c1aeeab..bd4587dcb 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 @@ -3050,7 +3050,8 @@ would be (§2.3), and is accepted while that place still holds it (USER decision the callee borrows (`!isKeptArgument`). What the read produces - a field's reference, a bound method - is walked as a borrow's uses are (`walkBorrowed`, starting not borrowed); a call result that borrows its argument is the value again and may not be kept. A method reached through it - whose callee keeps its `this` is not a read. Anything else after the move - a store, an owning + whose callee keeps its `this` is not a read. A store through a field read (`h.c = c; c.x = 7`) + is a use of the borrow, not a keep: the block stays the place's. Anything else after the move - a store, an owning `let`, a keeping call, a return, rc's retain for a receiver - is still a second owner and keeps the error. - **Where it moved** (`destinationChain`): an owning local (a declaration, or an assignment), or a @@ -3096,9 +3097,10 @@ print what rc prints, on Windows and Linux). They now give `a` to a second `let` borrows it, as before. Teeth: with the kill walk skipped (`readsThroughOwner` accepting every read), all seven negatives -compile, and their AOT runs with same-size allocations before the read print a freed block's -value: `c.x` prints 0 (overwritten, `clear`) and 999 (through the view) instead of 5, and -`a.length, a[0]` prints `64 6.4649e-312` instead of `1 1`. The positive cannot show it: by design +compile. AOT runs of four of them with same-size allocations before the read print a freed +block's value: `c.x` prints 0 (overwritten on one path, `clear`) and 999 (through the view) +instead of 5, and `a.length, a[0]` prints `64 6.4649e-312` instead of `1 1`. The loop's run +happened to print the right sum; the other two were not run. The positive cannot show it: by design nothing in it overwrites a destination before a read. With the rule off, the positive fails to compile with `is used here after its value was moved`, at every read after a move. A loop of 2 million iterations peaks at 4.5 MB under own, as under rc. From aef265ac3ad0c98d0b7ab3b46f2b391ab1c21b5b Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 09:53:48 +0100 Subject: [PATCH 15/57] own-raytrace T5: -mm=own: an assignment to a global ends a borrow of what it may free `G = ...` releases the global's old value and all it owns, and a parameter, `this`, or a value read out of a field may be that value. The drops took only a release of a place or a cell, so a read through `h.c` (or of a value moved into `h.c`) after `G = mkH()` was accepted with `run(G)` and read a freed block. A ts.ReleaseSlot of a global is now a drop, and dropsChain takes it for every chain whose root this function does not own. An immortal global's one store is its initializer, which frees nothing. own_err_read_after_move_global (a parameter, `this`, a callee) and own_err_field_let_global_overwritten pin it. own_read_after_move gains an element and a `this`-field destination and takes its values from factories; the loop negative churns; spec 29 records it. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 67 +++++++++++------ .../lib/TypeScript/OwnershipInferencePass.cpp | 26 +++++-- tslang/test/tester/CMakeLists.txt | 4 +- .../own_err_field_let_global_overwritten.ts | 47 ++++++++++++ .../own/own_err_read_after_move_global.ts | 73 +++++++++++++++++++ .../own/own_err_read_after_move_loop.ts | 17 ++++- tslang/test/tester/own/own_read_after_move.ts | 71 +++++++++++++----- 7 files changed, 254 insertions(+), 51 deletions(-) create mode 100644 tslang/test/tester/own/own_err_field_let_global_overwritten.ts create mode 100644 tslang/test/tester/own/own_err_read_after_move_global.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 bd4587dcb..aae7bd3c6 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 @@ -3051,9 +3051,9 @@ would be (§2.3), and is accepted while that place still holds it (USER decision method - is walked as a borrow's uses are (`walkBorrowed`, starting not borrowed); a call result that borrows its argument is the value again and may not be kept. A method reached through it whose callee keeps its `this` is not a read. A store through a field read (`h.c = c; c.x = 7`) - is a use of the borrow, not a keep: the block stays the place's. Anything else after the move - a store, an owning - `let`, a keeping call, a return, rc's retain for a receiver - is still a second owner and keeps - the error. + is a use of the borrow, not a keep: the block stays the place's. Anything else after the move - + a store of the value, an owning `let`, a keeping call, a return, rc's retain for a receiver - is + still a second owner and keeps the error. - **Where it moved** (`destinationChain`): an owning local (a declaration, or an assignment), or a field or element of a local or a parameter (`this`), described as the `Chain` `checkPlaceRead` reads (`chainOf` from the store's place). A move into a call that keeps the argument, a `push`, a @@ -3062,10 +3062,11 @@ would be (§2.3), and is accepted while that place still holds it (USER decision that may destroy what the place holds (`dropsChain`, with the moved value standing in for the read's result): an overwrite of the place, matched by position and type, through an interface view too (§28); a call whose facts may drop (no `__own_no_drops`) given something that reaches the - place's root, or any such call for a root the function does not own; a call given a closure. The - read fails when some end or drop E has a path move -> E (not through the value's definition, the - local's declaration or its assignments) and a path E -> the read or anything it produced (not - through those either, nor through the move again). Together the two are exactly one path from + place's root, or any such call for a root the function does not own; a call given a closure; and + an assignment to a global, for a root the function does not own (below). The read fails when + some end or drop E has a path move -> E (not through the value's definition, the local's + declaration or its assignments) and a path E -> the read or anything it produced (not through + those either, nor through the move again). Together the two are exactly one path from the move through E to the read on which the value is neither made again nor moved again; a path that moves the same value again without making it again is the `moved inside a loop` error already. A read of the old name again after E is no new value - the move leaves the slot as it @@ -3076,15 +3077,18 @@ would be (§2.3), and is accepted while that place still holds it (USER decision `checkSlotMove` (an owning local) of every use of a slot read the move reaches, and of each late use `readUsedAfter` finds. -`own_read_after_move` runs the straight-line moves into a field (`n.v = a`, `h.c = c`), a move -into an owning local in a loop that makes the value each iteration, 100,000 iterations of a move -into a field and into a local each read after it, and a declaration that takes a value over (`let -q = p; print(p.x); p = new V(8)`: `q` keeps the first value, §24), then allocates same-size -blocks, then reads the moved values again and asserts them, under own, rc, gc and none, AOT and -JIT, and on Linux (every `own` test passes there, 932); own emits no counting. Negatives, all `is -used here after its value was moved`: `own_err_field_alias` (`n.v = [2]` after `n.v = a`), `own_err_union_moved` (`h.c = null`), `own_err_read_after_move_overwritten` -(overwritten on one path), `own_err_read_after_move_view` (overwritten through an interface view -`hv.c = null`), `own_err_read_after_move_call` (`clear(h)`, which may drop), +`own_read_after_move` runs the straight-line moves into a field (`n.v = a`, `h.c = c`), into an +element (`arr[0] = ce`) and into a field of `this` (`this.f = c` in a method), each read after +same-size allocations; a move into an owning local in a loop that makes the value each iteration; +100,000 iterations of a move into a field and into a local each read after it; and a declaration +that takes a value over (`let q = p; print(p.x); p = mkv(8)`: `q` keeps the first value, §24). +Then it allocates same-size blocks, reads the moved values again and asserts them, under own, rc, +gc and none, AOT and JIT, and on Linux; own emits no counting. Its values come from factories +(`mkc(n)`), so stack promotion leaves them heap blocks. Negatives, all `is used here after its +value was moved`: `own_err_field_alias` (`n.v = [2]` after `n.v = a`), `own_err_union_moved` +(`h.c = null`), `own_err_read_after_move_overwritten` (overwritten on one path), +`own_err_read_after_move_view` (overwritten through an interface view `hv.c = null`), +`own_err_read_after_move_call` (`clear(h)`, which may drop), `own_err_read_after_move_loop` (the read at the top of a loop reached around the back edge after the iteration's `best = new V(i)` freed the moved value), `own_err_read_after_move_after_loop` (a read after a loop that overwrote the destination). `own_err_param_kept_used`, `own_err_push_let`, @@ -3096,12 +3100,33 @@ takes the value over, and `a = new C(2)` frees nothing `b` holds: the programs a print what rc prints, on Windows and Linux). They now give `a` to a second `let`, so that each borrows it, as before. +**An assignment to a global is a drop** for a chain with a root this function does not own (found +in review). `G = mkH()` is `ts.ReleaseSlot(ts.AddressOf @G)` before its store, and the drops took +only a `ts.ReleaseSlot` of a place or a cell. So `function run(h: H) { const c = mk(); h.c = c; G = +mkH(); churn(); print(c.x); }`, called as `run(G)`, compiled and printed 999 (a freed block that +the allocations reused; rc prints 5), and so did the same through `this` in a method of the +global's class. A parameter, `this`, or a value read out of a field or a global may be the global's +old value, or under it, and the assignment frees it. The plain place read had it too, older than +this section: `h.c = mk(); const x = h.c; G = mkH(); print(x.x)`. Now such a `ts.ReleaseSlot` is a +drop, and `dropsChain` takes it for every chain that is not owned (`!chain.owned()`); a local or a +value this function made cannot be the global's. An immortal global's (§25) one store is its +initializer, whose old value is empty, so its `ts.ReleaseSlot` drops nothing. A callee that assigns +a global was already no `__own_no_drops` (`dropsInBody`), so a call of it ends such chains. +Negatives: `own_err_read_after_move_global` (the parameter, `this`, and the assignment in a callee: +`is used here after its value was moved`) and `own_err_field_let_global_overwritten` (the place +read: `borrows a field but is used here after it may be released or overwritten`). Teeth: with the +global's `ts.ReleaseSlot` dropping nothing, both compile but the callee's case; the first prints +garbage (`9.88131e-324`) for the parameter and 999 for `this`, the second `9.88131e-324`, instead +of 5. Still open, every model (rc too): a parameter itself read after the global it came from is +assigned (`function run(h: C) { G = mk(); print(h.x); }` called as `run(G)`): no place is read, +so there is no chain to end. + Teeth: with the kill walk skipped (`readsThroughOwner` accepting every read), all seven negatives -compile. AOT runs of four of them with same-size allocations before the read print a freed -block's value: `c.x` prints 0 (overwritten on one path, `clear`) and 999 (through the view) -instead of 5, and `a.length, a[0]` prints `64 6.4649e-312` instead of `1 1`. The loop's run -happened to print the right sum; the other two were not run. The positive cannot show it: by design -nothing in it overwrites a destination before a read. With the rule off, the positive fails to +compile, and AOT runs with same-size allocations before the read print a freed block's value: +`c.x` prints 0 (overwritten on one path, `clear`, set to null) and 999 (through the view) instead +of 5; `a.length, a[0]` prints `64 6.4649e-312` instead of `1 1`; the loop's sum is 1999 instead of +3; the read after the loop prints 999 instead of 1. The positive cannot show it: by design nothing +in it overwrites a destination before a read. With the rule off, the positive fails to compile with `is used here after its value was moved`, at every read after a move. A loop of 2 million iterations peaks at 4.5 MB under own, as under rc. diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index 15777d18b..0d566f154 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -205,9 +205,10 @@ class OwnershipInferencePass } else if (auto releaseSlotOp = mlir::dyn_cast(op)) { - // an overwrite of a field or an element, or an assignment to a captured variable + // an overwrite of a field or an element, or an assignment to a captured variable or + // a global (spec 29: the global may hold what `this` or a parameter is) auto slot = releaseSlotOp.getSlot(); - if (isPlace(slot) || isCellVariable(slot) || isLoadedCell(slot)) + if (isPlace(slot) || isCellVariable(slot) || isLoadedCell(slot) || slot.getDefiningOp()) { drops.push_back(op); } @@ -3150,6 +3151,15 @@ class OwnershipInferencePass return !chain.owned(); } + // An assignment to a global destroys its old value and all that value owns. A root this + // function does not own - `this`, a parameter, a global's or a field's value - may be + // that very block, or under it (spec 29). A root this function made cannot. An immortal + // global's one store is its initializer, whose old value is the empty one (spec 25). + if (auto addressOfOp = slot.getDefiningOp()) + { + return !addressOfOp->hasAttr(OWN_IMMORTAL_GLOBAL_ATTR_NAME) && !chain.owned(); + } + // `x.value = ...` through any handle, of any type, may replace the payload another // handle reads (spec 23.3): a handle converts to one of a base class's type if (slot.getDefiningOp()) @@ -4495,7 +4505,12 @@ class OwnershipInferencePass // Every read of the slot is a use of `a`, and so is every use of what a read returned: a // `const b = a` is folded into its load, so `b.x` after the move reads the moved value - // through a load that came before it. + // through a load that came before it. A read through the new owner, while it holds the + // value, is not such a use (spec 29). + auto throughOwner = [&](mlir::Operation *user, mlir::Value used) { + return readsThroughOwner(taker, used, user, kills); + }; + for (auto *use : slot.getUsers()) { if (mlir::isa(use)) @@ -4503,11 +4518,6 @@ class OwnershipInferencePass continue; } - // a read through the new owner, while it holds the value (spec 29) - auto throughOwner = [&](mlir::Operation *user, mlir::Value used) { - return readsThroughOwner(taker, used, user, kills); - }; - if (use != load.getOperation() && reachableAfter(taker, use, kills)) { auto readOp = mlir::dyn_cast(use); diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index f514c9845..0221fc302 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3797,7 +3797,9 @@ set(own_error_cases "own_err_read_after_move_view|is used here after its value was moved" "own_err_read_after_move_call|is used here after its value was moved" "own_err_read_after_move_loop|is used here after its value was moved" - "own_err_read_after_move_after_loop|is used here after its value was moved") + "own_err_read_after_move_after_loop|is used here after its value was moved" + "own_err_read_after_move_global|is used here after its value was moved" + "own_err_field_let_global_overwritten|borrows a field but is used here after it may be released or overwritten") 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_field_let_global_overwritten.ts b/tslang/test/tester/own/own_err_field_let_global_overwritten.ts new file mode 100644 index 000000000..3225f294d --- /dev/null +++ b/tslang/test/tester/own/own_err_field_let_global_overwritten.ts @@ -0,0 +1,47 @@ +// @strict-null false +// -mm=own rejects: `x` borrows the field `h.c` of a parameter, and a global is assigned before `x` +// is read. The parameter may be the global's old value, which the assignment frees, and its field +// with it (spec 29). +class C { + x: number = 5; + v: number[] = []; +} + +class H { + c: C | null = null; +} + +function mkH(): H { + const r = new H(); + return r; +} + +function mk(): C { + const r = new C(); + r.v.push(1); + return r; +} + +function churn() { + const junk: C[] = []; + for (let i = 0; i < 64; i++) { + const c = mk(); + c.x = 999; + c.v.push(99); + junk.push(c); + } +} + +let G: H = mkH(); + +function run(h: H) { + h.c = mk(); + const x = h.c; + G = mkH(); + churn(); + print(x.x); +} + +function main() { + run(G); +} diff --git a/tslang/test/tester/own/own_err_read_after_move_global.ts b/tslang/test/tester/own/own_err_read_after_move_global.ts new file mode 100644 index 000000000..054c58ce3 --- /dev/null +++ b/tslang/test/tester/own/own_err_read_after_move_global.ts @@ -0,0 +1,73 @@ +// @strict-null false +// -mm=own rejects: a value moved into a field of a parameter or of `this` is read after a global +// is assigned - here, or in a callee. The parameter or `this` may be the global's old value, which +// the assignment frees, and the moved value with it (spec 29). +class C { + x: number = 5; + v: number[] = []; +} + +class H { + c: C | null = null; +} + +function mkH(): H { + const r = new H(); + return r; +} + +function mk(): C { + const r = new C(); + r.v.push(1); + return r; +} + +function churn() { + const junk: C[] = []; + for (let i = 0; i < 64; i++) { + const c = mk(); + c.x = 999; + c.v.push(99); + junk.push(c); + } +} + +class K { + f: C | null = null; + run() { + const c = mk(); + this.f = c; + GK = new K(); + churn(); + print(c.x); + } +} + +let G: H = mkH(); +let GK: K = new K(); + +function reset() { + G = mkH(); +} + +function run(h: H) { + const c = mk(); + h.c = c; + G = mkH(); + churn(); + print(c.x); +} + +function runCallee(h: H) { + const c = mk(); + h.c = c; + reset(); + churn(); + print(c.x); +} + +function main() { + run(G); + GK.run(); + runCallee(G); +} diff --git a/tslang/test/tester/own/own_err_read_after_move_loop.ts b/tslang/test/tester/own/own_err_read_after_move_loop.ts index 843ae7dac..aefc1e826 100644 --- a/tslang/test/tester/own/own_err_read_after_move_loop.ts +++ b/tslang/test/tester/own/own_err_read_after_move_loop.ts @@ -4,14 +4,27 @@ class V { constructor(public x: number) {} } + +function mkv(n: number) { + return new V(n); +} + +function churn() { + const junk: V[] = []; + for (let i = 0; i < 64; i++) { + junk.push(mkv(999)); + } +} + function main() { let best: V = undefined; - let v = new V(1); + let v = mkv(1); best = v; let s: number = 0; for (let i = 0; i < 3; i++) { s += v.x; - best = new V(i); + best = mkv(i); + churn(); } print(s, best.x); } diff --git a/tslang/test/tester/own/own_read_after_move.ts b/tslang/test/tester/own/own_read_after_move.ts index 218c3cab4..251b99ba4 100644 --- a/tslang/test/tester/own/own_read_after_move.ts +++ b/tslang/test/tester/own/own_read_after_move.ts @@ -1,7 +1,8 @@ // @strict-null false -// -mm=own: a value moved into a new owner - a field, an owning local - may still be read after the -// move while that owner holds it unchanged: the read borrows it through the owner (spec 29). A path -// from the move to the read that overwrites, releases or moves the owner on stays an error. +// -mm=own: a value moved into a new owner - a field, an element, an owning local - may still be read +// after the move while that owner holds it unchanged: the read borrows it through the owner (spec +// 29). A path from the move to the read that overwrites, releases or moves the owner on stays an +// error. The values come from factories, so that stack promotion leaves them heap blocks. class V { constructor(public x: number) {} static plus(a: V, b: V) { return new V(a.x + b.x); } @@ -10,14 +11,37 @@ class N { v: number[] = []; } class C { x: number = 5; } class H { c: C | null = null; } // another class's field of the same type at the same position is matched as the same field -// (dropsChain), so the loop's holder keeps its field elsewhere +// (dropsChain), so the loop's holder and K keep their fields elsewhere class HW { k: number = 0; c: C | null = null; } +class K { + k: number = 0; + k2: number = 0; + f: C | null = null; + put(n: number) { + const c = mkc(n); + this.f = c; // moved into a field of `this` + churn(); + return c.x; + } +} +function mka(n: number) { + const r: number[] = []; + r.push(n); + return r; +} +function mkc(n: number) { + const r = new C(); + r.x = n; + return r; +} +function mkv(n: number) { + return new V(n); +} function churn() { let s: number = 0; for (let i = 0; i < 64; i++) { - const junk = new V(999); - const c = new C(); - c.x = 999; + const junk = mkv(999); + const c = mkc(999); const n = new N(); n.v.push(99); s += junk.x + c.x + n.v.length; @@ -26,15 +50,23 @@ function churn() { return s; } function main() { - const a: number[] = [1]; + const a = mka(1); const n = new N(); n.v = a; print(a.length); // 1 const h = new H(); - const c = new C(); + const c = mkc(5); h.c = c; print(c.x); // 5 - const vs = [new V(1), new V(3), new V(2)]; + const arr: C[] = [mkc(0)]; + const ce = mkc(8); + arr[0] = ce; // moved into an element + churn(); + print(ce.x); // 8 + const k = new K(); + const kx = k.put(6); + print(kx, k.f.x); // 6 6 + const vs = [mkv(1), mkv(3), mkv(2)]; let best: V = undefined; let s: number = 0; for (let i in vs) { @@ -44,25 +76,26 @@ function main() { } print(best.x, s); // 4 12 let t: number = 0; - for (let k = 0; k < 100000; k++) { - const w = new V(k); + for (let j = 0; j < 100000; j++) { + const w = mkv(j); const hw = new HW(); - const cw = new C(); + const cw = mkc(5); hw.c = cw; best = w; - t += w.x - k + cw.x; + t += w.x - j + cw.x; } // a declaration takes the value over; `p` is read through `q`, then given a new value of its own - let p = new V(7); + let p = mkv(7); let q = p; print(p.x); // 7 - p = new V(8); + p = mkv(8); churn(); print(q.x, p.x); // 7 8 assert(q.x == 7 && p.x == 8); - print(a.length, a[0], c.x, best.x, s, t); // 1 1 5 99999 12 500000 - assert(a.length == 1 && a[0] == 1 && c.x == 5 && best.x == 99999 && s == 12 && t == 500000); - assert(n.v.length == 1 && h.c.x == 5); + print(a.length, a[0], c.x, ce.x, best.x, s, t); // 1 1 5 8 99999 12 500000 + assert(a.length == 1 && a[0] == 1 && c.x == 5 && ce.x == 8); + assert(best.x == 99999 && s == 12 && t == 500000); + assert(n.v.length == 1 && h.c.x == 5 && arr[0].x == 8 && kx == 6 && k.f.x == 6); print("done."); } From 1edab591c5907256d6a402a2d2e967a1d82f062c Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 10:35:16 +0100 Subject: [PATCH 16/57] own-raytrace T5b: -mm=own: a move on some paths only releases on the others `if (c) { best = v; }` left one release of `v` where the paths join, on a path that moved the value and on one that did not. No run-time record of the move is kept (spec 2.2), so the release is now placed statically: a forward walk of the CFG gives what the local's slot may hold before each op (nothing, its value, a moved value); every release that may hold a moved value goes, and a release is put on each edge from a block that ends holding the value into a block where the paths have joined - at the end of the block when it has no other successor, else in a new block on the edge. An assignment's own release over a value that may have moved goes the same way. The release moves earlier, so nothing may use the value where the paths have joined: no read of the slot and nothing read out of it (followed through results, block arguments, stores into locals and regions; a call's result only when it borrows the argument). The rule applies to a local's one receiver, outside exception handlers, on cf.br/cf.cond_br edges; anything else keeps the error. Unwind edges are CFG edges and get the same treatment. own_conditional_move covers the brief's loop, straight-line moves taken and not taken, a 100,000-iteration churn, an assignment over a maybe- moved value, a move into a keeping call on one arm of ?:, and a move in a try whose call throws; same output under own, rc, gc and none. Negatives: a read after the join, through an overwritten owner, a field borrow, an outer let in a loop, and a move in a catch. own_err_reassign_some_paths and own_err_borrow_derived_ref became correct programs and were changed to keep their errors. Co-Authored-By: Claude Opus 5.5 --- .../lib/TypeScript/OwnershipInferencePass.cpp | 461 +++++++++++++++++- tslang/test/tester/CMakeLists.txt | 16 +- .../test/tester/own/own_conditional_move.ts | 168 +++++++ .../tester/own/own_err_borrow_derived_ref.ts | 7 +- .../own/own_err_conditional_move_catch.ts | 34 ++ .../own_err_conditional_move_field_borrow.ts | 34 ++ .../own/own_err_conditional_move_outer.ts | 24 + .../own/own_err_conditional_move_read.ts | 26 + ...wn_err_conditional_move_read_after_join.ts | 30 ++ .../tester/own/own_err_reassign_some_paths.ts | 7 +- 10 files changed, 785 insertions(+), 22 deletions(-) create mode 100644 tslang/test/tester/own/own_conditional_move.ts create mode 100644 tslang/test/tester/own/own_err_conditional_move_catch.ts create mode 100644 tslang/test/tester/own/own_err_conditional_move_field_borrow.ts create mode 100644 tslang/test/tester/own/own_err_conditional_move_outer.ts create mode 100644 tslang/test/tester/own/own_err_conditional_move_read.ts create mode 100644 tslang/test/tester/own/own_err_conditional_move_read_after_join.ts diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index 0d566f154..4b376d113 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -1,6 +1,7 @@ #include "mlir/Pass/Pass.h" #include "mlir/IR/Dominance.h" #include "mlir/Dialect/LLVMIR/LLVMAttrs.h" +#include "mlir/Dialect/ControlFlow/IR/ControlFlowOps.h" #include "mlir/Interfaces/ControlFlowInterfaces.h" #include "mlir/Interfaces/FunctionInterfaces.h" @@ -48,7 +49,9 @@ using namespace own_facts; // after its taker, a taker that control comes back to without the value being made again is a // move on every loop iteration (2.6), and each release the move reaches must be dominated by it // and is erased. A release the move cannot reach stays - the owner on the paths that did not -// move. Anything else is an error at its location. +// move. A local's release that paths which moved it and paths which did not both reach is placed +// instead on the edges where the latter join the former (spec 30). Anything else is an error at +// its location. // // It runs on every function: a `ts.Func`, and the `func.func` the async lowering outlines an // `await`'s continuation or a `for await` body into, whose arguments are the values it captures. @@ -120,6 +123,7 @@ class OwnershipInferencePass // calls whose callee keeps an argument: each such argument moves into the call llvm::SmallVector keepingCalls; drops.clear(); + edgeReleases.clear(); derivedCache.clear(); closures.clear(); closureOf.clear(); @@ -436,6 +440,7 @@ class OwnershipInferencePass // a handle's retains and releases are rc's, and stay (spec 23.2) toErase.remove_if([](mlir::Operation *op) { return touchesHandle(op); }); + placeEdgeReleases(); for (auto *op : toErase) { op->erase(); @@ -1898,9 +1903,10 @@ class OwnershipInferencePass auto owner = varName(slot.getDefiningOp()); if (receivers.size() == 1 || takeValuesOfTheirOwn(slot, receivers)) { + // a move on some paths only is placed when it is the local's one receiver (spec 30) for (auto &receiver : receivers) { - decideSlotReceiver(slot, receiver, owner, toErase); + decideSlotReceiver(slot, receiver, owner, toErase, receivers.size() == 1); } return; @@ -1910,9 +1916,9 @@ class OwnershipInferencePass } void decideSlotReceiver(mlir::Value slot, const SlotReceiver &receiver, llvm::StringRef owner, - llvm::SetVector &toErase) + llvm::SetVector &toErase, bool sole) { - if (quietly([&] { return checkSlotMove(receiver.retain, receiver.value, receiver.load, toErase); })) + if (quietly([&] { return checkSlotMove(receiver.retain, receiver.value, receiver.load, toErase, sole); })) { // rc's retain goes with the move; a call that keeps the argument is the move itself if (!isCall(receiver.retain)) @@ -1929,7 +1935,7 @@ class OwnershipInferencePass return; } - checkSlotMove(receiver.retain, receiver.value, receiver.load, toErase); // reports + checkSlotMove(receiver.retain, receiver.value, receiver.load, toErase, sole); // reports } // Receivers of one owning local that each read a value of its own (#528): `h.c = a; a = new C(); @@ -4467,7 +4473,8 @@ class OwnershipInferencePass // A move out of an owning local: `let b = a`, `h.c = a`, `return a`. rc read the slot and // retained what it read for the receiver; the move erases that retain and every release of - // the slot the move reaches, which must be dominated by it. Any other use of the slot the + // the slot the move reaches, which must be dominated by it - or, with `placeReleases`, is + // placed on the paths that did not move it (spec 30). Any other use of the slot the // move reaches - a read, an assignment's own read of it - is a use after the move. Reports and // returns false when it is not a move. // @@ -4476,7 +4483,7 @@ class OwnershipInferencePass // with the move - what the slot held is the receiver's - and the releases only a path through // an assignment reaches stay, to destroy the new value. bool checkSlotMove(mlir::Operation *retain, mlir::Value value, mlir_ts::LoadOp load, - llvm::SetVector &toErase) + llvm::SetVector &toErase, bool placeReleases = false) { auto slot = load.getReference(); auto varOp = slot.getDefiningOp(); @@ -4549,14 +4556,9 @@ class OwnershipInferencePass continue; } - if (!dominance->properlyDominates(acquire, use)) - { - reportMovedOnSomePaths(taker, use, name); - return false; - } - // a release that one path reaches holding the moved value and another holding a new // one an assignment after the move stored: there is no single answer for it + auto someMoved = !dominance->properlyDominates(acquire, use); for (auto *assignment : assignments) { llvm::SmallVector movedAgain{load.getOperation()}; @@ -4565,11 +4567,21 @@ class OwnershipInferencePass movedAgain.push_back(varOp); } - if (reachableAfter(acquire, assignment, varOp) && reachableAfter(assignment, use, movedAgain)) + someMoved = someMoved || + (reachableAfter(acquire, assignment, varOp) && reachableAfter(assignment, use, movedAgain)); + } + + if (someMoved) + { + // ...but the paths that did not move it can release it before they join the + // others (spec 30) + if (placeReleases && placeOnUnmovedEdges(varOp, acquire, retain, taker, assignments, toErase)) { - reportMovedOnSomePaths(taker, use, name); - return false; + return true; } + + reportMovedOnSomePaths(taker, use, name); + return false; } moved.push_back(use); @@ -4579,6 +4591,423 @@ class OwnershipInferencePass return true; } + // ---- A move on some paths only (spec 30) ---- + // + // `if (c) { best = v; }`: after the paths join, `v` holds its value on one and a moved one on + // the other, and its one release there has no single answer. No run-time record of the move is + // kept (spec 2.2); instead, where nothing uses the value after the join, the release is placed + // on each edge into the join that did not move it - freeing the value earlier, where it is + // already dead - and the releases after the join go. Each path frees the value once: through + // its new owner where it moved, through the edge where it did not. + + // What the slot of an owning local holds at a point: nothing yet or any more (declared later, + // released), its value, or a value moved out of it. A point some paths reach holding the value + // and others having moved it is where the paths join. + enum SlotState : unsigned + { + SlotDead = 1, + SlotLive = 2, + SlotMoved = 4 + }; + + static bool isJoined(unsigned state) + { + return (state & SlotLive) && (state & SlotMoved); + } + + // A value of this type can point into a block: anything but a number, a boolean, a literal or + // an empty value. + static bool carriesPointer(mlir::Type type) + { + return !mlir::isa(type); + } + + // A release on an edge into the join that did not move the local's value (spec 30), put in after + // the analysis. + struct EdgeRelease + { + mlir::Value slot; + mlir::Block *from; + unsigned successor; + mlir::Location loc; + }; + + llvm::SmallVector edgeReleases; + + // The blocks of an exception handler: from a landing pad to the end of its catch. A release there + // would be a call inside an exception funclet. + llvm::SmallPtrSet handlerBlocks() + { + llvm::SmallPtrSet handler; + llvm::SmallVector work; + for (auto &block : getFunction().getFunctionBody()) + { + if (!block.empty() && mlir::isa(block.front())) + { + work.push_back(&block); + } + } + + while (!work.empty()) + { + auto *block = work.pop_back_val(); + if (!handler.insert(block).second) + { + continue; + } + + auto ends = llvm::any_of(*block, [](mlir::Operation &op) { + return mlir::isa(op); + }); + if (!ends) + { + work.append(block->getSuccessors().begin(), block->getSuccessors().end()); + } + } + + return handler; + } + + // The move at `acquire` out of the owning local `varOp` reaches a release that other paths reach + // holding the value. Places a release of the slot on every edge into the join that holds the value + // and has not moved it, and erases every release a moved value reaches - when nothing uses the + // value, through the slot or through anything read out of it, where the paths have joined. False, + // with nothing placed or erased, when it cannot. + bool placeOnUnmovedEdges(mlir_ts::VariableOp varOp, mlir::Operation *acquire, mlir::Operation *retain, + mlir::Operation *taker, llvm::ArrayRef assignments, + llvm::SetVector &toErase) + { + auto &body = getFunction().getFunctionBody(); + if (!varOp || varOp.getCaptured().value_or(false) || varOp->getParentRegion() != &body || + acquire->getParentRegion() != &body || + llvm::any_of(assignments, [&](mlir::Operation *op) { return op->getParentRegion() != &body; })) + { + return false; + } + + auto slot = varOp.getResult(); + llvm::SmallVector releases; + for (auto *user : slot.getUsers()) + { + if (mlir::isa(user)) + { + if (user->getParentRegion() != &body) + { + return false; + } + + releases.push_back(user); + } + } + + // the slot's state through the function, forward from its entry + auto step = [&](mlir::Operation *op, unsigned state) -> unsigned { + if (op == varOp.getOperation() || llvm::is_contained(assignments, op)) + { + return SlotLive; + } + + if (op == acquire) + { + return SlotMoved; + } + + if (llvm::is_contained(releases, op)) + { + return SlotDead; + } + + return state; + }; + + llvm::DenseMap entry; + auto exitOf = [&](mlir::Block *block) { + auto state = entry.lookup(block); + for (auto &op : *block) + { + state = step(&op, state); + } + + return state; + }; + + entry[&body.front()] = SlotDead; + llvm::SmallVector work{&body.front()}; + while (!work.empty()) + { + auto *block = work.pop_back_val(); + auto state = exitOf(block); + for (auto *successor : block->getSuccessors()) + { + auto &into = entry[successor]; + if ((into | state) != into) + { + into |= state; + work.push_back(successor); + } + } + } + + // ...and before each op of the body + llvm::DenseMap before; + for (auto &block : body) + { + auto state = entry.lookup(&block); + for (auto &op : block) + { + before[&op] = state; + state = step(&op, state); + } + } + + auto joinedAt = [&](mlir::Operation *user) { + auto *top = body.findAncestorOpInRegion(*user); + return !top || isJoined(before.lookup(top)); + }; + + // every release a moved value reaches goes; one some path reaches with nothing in the slot + // is not this rule's + llvm::SmallVector erased; + for (auto *release : releases) + { + auto state = before.lookup(release); + if (state & SlotMoved) + { + if (state & SlotDead) + { + return false; + } + + erased.push_back(release); + } + } + + // Nothing uses the value where the paths have joined - a read there, on the path that did + // not move it, needs what the edge released - neither through the slot nor through anything + // read out of it: a field, a view, a borrowing local, a closure. The move itself hands the + // value to its new owner, whose reads are its own. A value the slot is given - its + // initializer, an assignment's - is the slot's only once it is stored: a use before that (a + // constructor's, or one around a loop before it is made again) is not a use of it. + struct Origin + { + mlir::Operation *stored; + mlir::Operation *made; + }; + + llvm::SmallVector origins{{nullptr, nullptr}}; + llvm::SmallVector> values; + llvm::DenseSet> seen; + auto taint = [&](mlir::Value value, unsigned origin) { + if (carriesPointer(value.getType()) && seen.insert({value, origin}).second) + { + values.push_back({value, origin}); + } + }; + + auto given = [&](mlir::Value value, mlir::Operation *stored) { + origins.push_back({stored, value.getDefiningOp()}); + taint(value, origins.size() - 1); + }; + + taint(slot, 0); + if (varOp.getInitializer()) + { + given(varOp.getInitializer(), varOp); + } + + for (auto *assignment : assignments) + { + given(mlir::cast(assignment).getValue(), assignment); + } + + auto heldAt = [&](unsigned origin, mlir::Operation *user) { + auto &from = origins[origin]; + if (!from.stored) + { + return true; + } + + llvm::SmallVector madeAgain; + if (from.made) + { + madeAgain.push_back(from.made); + } + + return reachableAfter(from.stored, user, madeAgain); + }; + + while (!values.empty()) + { + auto [value, origin] = values.pop_back_val(); + for (auto &use : value.getUses()) + { + auto *user = use.getOwner(); + if (mlir::isa(user) || user == retain) + { + continue; + } + + // the slot's own releases and assignments are decided above + if (value == slot && (mlir::isa(user) || llvm::is_contained(assignments, user))) + { + continue; + } + + if (joinedAt(user) && heldAt(origin, user)) + { + return false; + } + + if (user == taker) + { + continue; + } + + // a store of it into a local makes every read of that local one; one into a place + // is not followed, and the release is not placed + if (auto storeOp = mlir::dyn_cast(user); storeOp && storeOp.getValue() == value) + { + auto into = storeOp.getReference().getDefiningOp(); + if (!into || into.getCaptured().value_or(false)) + { + return false; + } + + taint(into.getResult(), origin); + continue; + } + + if (auto branch = mlir::dyn_cast(user)) + { + for (auto index : llvm::seq(0, user->getNumSuccessors())) + { + auto operands = branch.getSuccessorOperands(index); + for (auto [position, operand] : llvm::enumerate(operands.getForwardedOperands())) + { + if (operand == value) + { + taint(user->getSuccessor(index)->getArgument(operands.getProducedOperandCount() + position), + origin); + } + } + } + } + + // a call's result is its own block, unless its facts say it borrows this argument + auto borrows = true; + if (isCall(user)) + { + auto index = resultBorrows(user); + auto args = callArgs(user); + borrows = index >= 0 && static_cast(index) < args.size() && args[index] == value; + } + + for (auto result : user->getResults()) + { + if (borrows) + { + taint(result, origin); + } + } + + // inside a region - a closure, an async body - it is the op that holds the region + // that hands it on + if (user->getParentRegion() != &body) + { + for (auto result : body.findAncestorOpInRegion(*user)->getResults()) + { + taint(result, origin); + } + } + } + } + + // the edges into the join that hold the value and did not move it + auto handler = handlerBlocks(); + llvm::SmallVector placed; + for (auto &block : body) + { + if (!entry.count(&block)) + { + continue; // unreachable + } + + auto state = exitOf(&block); + for (auto index : llvm::seq(0, block.getNumSuccessors())) + { + auto into = entry.lookup(block.getSuccessor(index)); + if (!isJoined(into)) + { + continue; + } + + // a join some path reaches with nothing in the slot is not this rule's + if (into & SlotDead) + { + return false; + } + + // an edge that moved it, or from where the paths have joined already + if (state != SlotLive) + { + continue; + } + + auto *terminator = block.getTerminator(); + if (!mlir::isa(terminator) || handler.contains(&block) || + !dominance->dominates(varOp.getOperation(), terminator)) + { + return false; + } + + placed.push_back({slot, &block, static_cast(index), terminator->getLoc()}); + } + } + + if (placed.empty()) + { + return false; + } + + toErase.insert(erased.begin(), erased.end()); + edgeReleases.append(placed.begin(), placed.end()); + return true; + } + + // Puts in the releases placeOnUnmovedEdges decided: at the end of a block that goes only to the + // join, else in a block of its own on the edge. + void placeEdgeReleases() + { + for (auto &edge : edgeReleases) + { + auto *terminator = edge.from->getTerminator(); + mlir::OpBuilder builder(terminator); + if (terminator->getNumSuccessors() == 1) + { + builder.create(edge.loc, edge.slot); + continue; + } + + auto *join = terminator->getSuccessor(edge.successor); + auto operands = mlir::cast(terminator).getSuccessorOperands(edge.successor); + llvm::SmallVector forwarded(operands.getForwardedOperands()); + auto *split = new mlir::Block(); + split->insertAfter(edge.from); + builder.setInsertionPointToEnd(split); + builder.create(edge.loc, edge.slot); + builder.create(edge.loc, join, forwarded); + if (!forwarded.empty()) + { + operands.erase(0, forwarded.size()); + } + + terminator->setSuccessor(split, edge.successor); + } + + edgeReleases.clear(); + } + // The stores of the assignments to an owning local: rc releases what the slot holds right // before it stores the new value (an assignment, and a `var` declaration met again). static llvm::SmallVector slotAssignments(mlir::Value slot) diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 0221fc302..e061ad24f 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3481,7 +3481,7 @@ foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local o own_param_markers own_generator_locals own_string_param_assigned own_lends own_immortal_global own_immortal_global_region own_literal_moves own_record_literal_given - own_field_chain_let own_read_after_move) + own_field_chain_let own_read_after_move own_conditional_move) 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. @@ -3531,6 +3531,13 @@ foreach(read_after_move_mm rc gc none) tslang_add_test(NAME test-compile-${read_after_move_mm}-own_read_after_move COMMAND test-runner -mm=${read_after_move_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_read_after_move.ts") endforeach() +# A value moved out of a local on some paths only, released on the others (spec 30); the same +# program under every other model. +foreach(conditional_move_mm rc gc none) + tslang_add_test(NAME test-jit-${conditional_move_mm}-own_conditional_move COMMAND test-runner -jit -mm=${conditional_move_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_conditional_move.ts") + tslang_add_test(NAME test-compile-${conditional_move_mm}-own_conditional_move COMMAND test-runner -mm=${conditional_move_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_conditional_move.ts") +endforeach() + # A global declared in top-level code owns what it is given, under every model that counts. foreach(global_mm rc none gc) tslang_add_test(NAME test-jit-${global_mm}-own-global-script COMMAND test-runner -jit -mm=${global_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_global_script.ts") @@ -3799,7 +3806,12 @@ set(own_error_cases "own_err_read_after_move_loop|is used here after its value was moved" "own_err_read_after_move_after_loop|is used here after its value was moved" "own_err_read_after_move_global|is used here after its value was moved" - "own_err_field_let_global_overwritten|borrows a field but is used here after it may be released or overwritten") + "own_err_field_let_global_overwritten|borrows a field but is used here after it may be released or overwritten" + "own_err_conditional_move_read|is used here after its value was moved" + "own_err_conditional_move_outer|is moved inside a loop but was made outside it" + "own_err_conditional_move_read_after_join|is moved here on some paths only" + "own_err_conditional_move_field_borrow|is moved here on some paths only" + "own_err_conditional_move_catch|is moved here on some paths only") 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_conditional_move.ts b/tslang/test/tester/own/own_conditional_move.ts new file mode 100644 index 000000000..2184f743d --- /dev/null +++ b/tslang/test/tester/own/own_conditional_move.ts @@ -0,0 +1,168 @@ +// @strict-null false +// A value moved out of an owning local on some paths only (spec 30): the local's release where the +// paths join is placed instead on the paths that did not move it, so each path frees the value +// exactly once - through its new owner where it moved, through the local where it did not. +class V { + constructor(public x: number) {} + static plus(a: V, b: V) { + return new V(a.x + b.x); + } +} + +class H { + c: V | null = null; +} + +class Holder { + constructor(public v: V) {} +} + +// keeps its argument: a call of it is a move +function keeps(v: V) { + const h = new Holder(v); + return h.v.x; +} + +let finallies: number = 0; + +function mkt(n: number) { + if (n > 5) { + throw n; + } + + return new V(n); +} + +// a move on some paths only, then a call that may throw: the edge's release comes before the +// call, and the unwind path, which joins both, frees nothing of `v` (only `best`, if it moved) +function unwinds(c: boolean, n: number) { + let best: V = undefined; + let v = mkt(1); + try { + if (c) { + best = v; + } + + mkt(n); + } finally { + finallies++; + } + + return best != undefined; +} + +function main() { + const vs = [new V(1), new V(3), new V(2)]; + let best: V = undefined; + let m: number = 0; + for (let i in vs) { + let v = V.plus(vs[i], vs[i]); + if (v.x > m) { + best = v; // moved into best on this path only + m = v.x; + } + } + + print(best.x); // 6 + assert(best.x == 6); + + // straight-line conditional move, taken + let w = new V(7); + let keep: V = undefined; + if (m > 5) { + keep = w; + } else { + print("not moved"); + } + + print(keep.x); // 7 + assert(keep.x == 7); + + // ...and not taken: the local still owns it, and frees it + let w2 = new V(8); + let keep2: V = undefined; + if (m > 100) { + keep2 = w2; + } else { + print("not moved"); + } + + assert(keep2 == undefined); + + // a churn loop where a freed or double-freed block would show + let s: number = 0; + for (let k = 0; k < 100000; k++) { + let t = new V(k % 3); + let b: V = undefined; + s += t.x; + if (k % 2 == 0) { + b = t; + s += t.x; // a read through the new owner (spec 29) + } + + if (b != undefined) { + s += b.x; + } + } + + print(s); // 99999 + 50000 + 50000 + assert(s == 199999); + + // an assignment over a value that may have moved frees the old value only where it did not + let r = new V(0); + let kept: V = undefined; + for (let k = 0; k < 1000; k++) { + if (k % 3 == 0) { + kept = r; + } + + r = new V(k); + } + + print(r.x, kept.x); // 999 998 + assert(r.x == 999 && kept.x == 998); + + // moved on every path, then given a new value on some: the path that gave it one releases it + // before the paths join + let given: number = 0; + for (let k = 0; k < 1000; k++) { + const h = new H(); + let a = new V(k); + h.c = a; + if (h.c.x % 2 == 0) { + a = new V(k + 1); + given += a.x; + } + } + + print(given); // 1 + 3 + ... + 999 + assert(given == 250000); + + // moved into a call on one arm of a `?:` only (the ray tracer's `shade`) + let taken: number = 0; + for (let k = 0; k < 1000; k++) { + let p = new V(k); + let q = new V(k + 1); + taken += k % 2 == 0 ? keeps(p) : q.x; + } + + print(taken); // (0 + 2 + ... + 998) + (2 + 4 + ... + 1000) + assert(taken == 500000); + + let moved: number = 0; + let thrown: number = 0; + for (let k = 0; k < 20000; k++) { + try { + if (unwinds(k % 2 == 0, k % 4 < 2 ? 1 : 9)) { + moved++; + } + } catch { + thrown++; + } + } + + print(moved, thrown, finallies); // 5000 10000 20000 + assert(moved == 5000 && thrown == 10000 && finallies == 20000); + + print("done."); +} diff --git a/tslang/test/tester/own/own_err_borrow_derived_ref.ts b/tslang/test/tester/own/own_err_borrow_derived_ref.ts index 1921f6f28..414db8ab4 100644 --- a/tslang/test/tester/own/own_err_borrow_derived_ref.ts +++ b/tslang/test/tester/own/own_err_borrow_derived_ref.ts @@ -1,5 +1,7 @@ // -mm=own, phase 2 rejects: the loop iterates `b.v`, a field of what `b` borrows, through one -// reference into it that every iteration reads - after the body has overwritten `a`. +// reference into it that every iteration reads - after the body has overwritten `a`. (`a` is given +// to two `let`s, so neither takes its value over: each borrows it. One `let` would take it over, +// and the assignments in the loop would release only what they stored, spec 30.) class C { v: number[] = []; constructor(public x: number) {} @@ -21,7 +23,8 @@ function main() { a.v.push(2); a.v.push(3); let b = a; - let t: number = 0; + let c = a; + let t: number = c.x - 1; for (const x of b.v) { a = new C(2); churn(); diff --git a/tslang/test/tester/own/own_err_conditional_move_catch.ts b/tslang/test/tester/own/own_err_conditional_move_catch.ts new file mode 100644 index 000000000..072ee9d4b --- /dev/null +++ b/tslang/test/tester/own/own_err_conditional_move_catch.ts @@ -0,0 +1,34 @@ +// @strict-null false +// -mm=own rejects: `v` moves into `best` on one path only inside a catch clause. The edge that +// did not move it is inside the exception handler, where -mm=own places no release of its own +// (spec 30). +class V { + constructor(public x: number) {} +} + +function mkt(n: number) { + if (n > 5) { + throw n; + } + + return new V(n); +} + +function test(c: boolean) { + let best: V = undefined; + let v = mkt(1); + try { + mkt(9); + } catch { + if (c) { + best = v; + } + } + + print(best != undefined); +} + +function main() { + test(true); + test(false); +} diff --git a/tslang/test/tester/own/own_err_conditional_move_field_borrow.ts b/tslang/test/tester/own/own_err_conditional_move_field_borrow.ts new file mode 100644 index 000000000..7af3231c5 --- /dev/null +++ b/tslang/test/tester/own/own_err_conditional_move_field_borrow.ts @@ -0,0 +1,34 @@ +// @strict-null false +// -mm=own rejects: `f` borrows a field of `v`, `v` moves into `b` on one path only, and `f` is +// read after the paths join. Freeing `v` where the paths join, on the path that did not move it, +// would free what `f` reads (spec 30). +class W { + constructor(public x: number) {} +} + +class V { + constructor(public w: W) {} +} + +function mkv(n: number) { + return new V(new W(n)); +} + +function test(c: boolean) { + let v = mkv(1); + const f = v.w; + let b: V = undefined; + if (c) { + b = v; + } + + print(f.x); + if (b != undefined) { + print(b.w.x); + } +} + +function main() { + test(true); + test(false); +} diff --git a/tslang/test/tester/own/own_err_conditional_move_outer.ts b/tslang/test/tester/own/own_err_conditional_move_outer.ts new file mode 100644 index 000000000..b20c65d9c --- /dev/null +++ b/tslang/test/tester/own/own_err_conditional_move_outer.ts @@ -0,0 +1,24 @@ +// @strict-null false +// -mm=own rejects: `v` is declared outside the loop and moved inside it on some iterations, and +// the next iteration reads it without giving it a new value (spec 30). +class V { + constructor(public x: number) {} +} + +function mkv(n: number) { + return new V(n); +} + +function main() { + let v = mkv(1); + let best: V = undefined; + let s: number = 0; + for (let i = 0; i < 4; i++) { + s += v.x; + if (i % 2 == 0) { + best = v; + } + } + + print(s, best.x); +} diff --git a/tslang/test/tester/own/own_err_conditional_move_read.ts b/tslang/test/tester/own/own_err_conditional_move_read.ts new file mode 100644 index 000000000..4255bf5b3 --- /dev/null +++ b/tslang/test/tester/own/own_err_conditional_move_read.ts @@ -0,0 +1,26 @@ +// @strict-null false +// -mm=own rejects: `v` moves into `b` on one path only, `b` is overwritten after the paths join, +// and `v` is read after that - through a freed block on the path that moved it (spec 30). +class V { + constructor(public x: number) {} +} + +function mkv(n: number) { + return new V(n); +} + +function test(c: boolean) { + let v = mkv(1); + let b: V = undefined; + if (c) { + b = v; + } + + b = undefined; + print(v.x); +} + +function main() { + test(true); + test(false); +} diff --git a/tslang/test/tester/own/own_err_conditional_move_read_after_join.ts b/tslang/test/tester/own/own_err_conditional_move_read_after_join.ts new file mode 100644 index 000000000..18f39d62c --- /dev/null +++ b/tslang/test/tester/own/own_err_conditional_move_read_after_join.ts @@ -0,0 +1,30 @@ +// @strict-null false +// -mm=own rejects: `v` moves into `b` on one path only and is read after the paths join. On the +// path that moved it the read goes through `b` (spec 29), but on the other `v` still owns the +// value and must free it after the read: only a run-time record of the move could tell the two +// apart there (spec 30). +class V { + constructor(public x: number) {} +} + +function mkv(n: number) { + return new V(n); +} + +function test(c: boolean) { + let v = mkv(1); + let b: V = undefined; + if (c) { + b = v; + } + + print(v.x); + if (b != undefined) { + print(b.x); + } +} + +function main() { + test(true); + test(false); +} diff --git a/tslang/test/tester/own/own_err_reassign_some_paths.ts b/tslang/test/tester/own/own_err_reassign_some_paths.ts index c2aa8fce5..175d83d11 100644 --- a/tslang/test/tester/own/own_err_reassign_some_paths.ts +++ b/tslang/test/tester/own/own_err_reassign_some_paths.ts @@ -1,5 +1,7 @@ -// -mm=own rejects: `a` is given a new value after its value moved on one path only, so its release -// at scope exit would destroy the new value on that path and the moved one on the other. +// -mm=own rejects: `a` is given a new value after its value moved on one path only, and is read +// after the paths join: its release there would destroy the new value on that path and the moved +// one on the other, and it cannot come before the read. (Without the read, the path that gave it a +// new value releases it before the paths join, spec 30.) class C { x: number = 5; } class H { c: C | null = null; } function main() { @@ -11,5 +13,6 @@ function main() { print(a.x); } + print(a.x); print("done."); } From 6d57326449079499bdb6510ed2dd8c6c5e0a6328 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 10:36:51 +0100 Subject: [PATCH 17/57] own-raytrace T5b: spec 30, a move on some paths only What the static release placement accepts and leaves out, its tests, teeth, corpus (527 -> 528, 00nullish_values gained) and the ray tracer (33 -> 31 errors: both moved-on-some-paths errors gone). Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) 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 aae7bd3c6..51dce36b0 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 @@ -3148,3 +3148,99 @@ clean under own on Linux. The ray tracer (`rtbench_own.ts`) has 33 errors, as at §28, but none of them is `is used here after its value was moved` any more. Two are `is moved here on some paths only` (G10), which the use-after-move errors used to hide. The rest are G9's, G5's and G2's (§28). + +## 30. A move on some paths only (G10 of the ray tracer plan), 2026-10-22 + +`let v = ...; if (v.x > m) { best = v; }` was `is moved here on some paths only; -mm=own cannot +release it on the others yet`: where the paths join, `v`'s one `ts.ReleaseSlot` is on a path that +moved the value and on one that did not, and no run-time record of the move is kept (§2.2). Now the +release is placed statically (static drop elaboration, controller ruling; a run-time drop flag was +ruled out): on each edge into the join that holds the value and did not move it, and nowhere on +the paths that moved it. The releases after the join go. Each path frees the value once - through +the new owner where it moved, on the edge where it did not. + +- **The state of the slot** (`placeOnUnmovedEdges`): a forward walk of the function's CFG gives, + before each op of the body, what the local's slot may hold - nothing (before its `ts.Variable`, + after a release), its value (after the `ts.Variable` or an assignment), or a moved value (after + the move's acquire). A point that some paths reach holding the value and others having moved it + is *joined*. Every `ts.ReleaseSlot` that may hold a moved value goes; one that some path also + reaches with nothing in the slot keeps the error. An assignment's own release before its store + is one of them: an assignment over a value that may have moved frees the old value only where it + did not move (`if (k % 3 == 0) { kept = r; } r = new V(k);` in a loop). +- **Where the release goes**: on every edge from a block that ends holding the value, and only the + value, into a joined block - at the end of the block when it has no other successor (a `then` or + `else` branch, a loop's latch), else in a new block on the edge. A joined block that some path + reaches with nothing in the slot, an edge whose branch is not `cf.br`/`cf.cond_br` (an + `Invoke`), and an edge out of an exception handler (from a landing pad to its + `EndCatch`/`EndCleanup`) keep the error. +- **The safety condition**: the release moves earlier, from the end of the local's scope to where + the paths join, so nothing may use the value at a joined point: no read of the slot, and nothing + read out of it - followed forward through every op's results (a field's reference, a view, a + bound method, a block argument), through a store into a local (every read of that local), and + out of a region (a closure, an async body) through the op that holds it. A store of such a value + into a place or into a captured local is not followed and keeps the error. A call's result is + followed only when its facts say it borrows that argument (`__own_result_borrows`); anything + else a call returns is its own block. The move hands the value to its new owner, whose reads are + its own, and rc's retain for it is erased. A value the slot is given (its initializer, an + assignment's) is the slot's only once stored, so a use of it before that - the constructor's, or + one around a loop before the value is made again - is no use of the slot's value. +- **One receiver**: the rule applies when the move is the local's one receiver (`decideSlot`). + With two, `if (c) { a = v; } else { b = v; }`, each move's other paths include the other's, and + the error stays. +- **Reads after the join**: a read of `v` after the paths join (`if (c) { b = v; } print(v.x);`) + goes through `b` on the path that moved it (§29), but on the other `v` must free the value after + the read, where only a run-time record could tell the paths apart: the error stays. So does a + `let` declared outside a loop, moved on some iterations and read in the next without a new value + (`moved inside a loop`). +- **Unwind paths** are paths of the CFG (`ts.Invoke`'s unwind edge to a landing pad), and get the + same treatment: a release on the normal edge into a joined `try` body comes before the call that + may throw, and a cleanup's `__unwind_only` release of a joined slot goes with the others. +- The releases are recorded during the analysis (which runs twice, dry and real, and tries a move + quietly before a borrow) and put in only after it, before the erasures (`placeEdgeReleases`). + +`own_conditional_move` runs: the brief's loop that keeps the largest value (`best = v` on some +iterations, `v` declared in the loop); a straight-line move taken and one not taken; 100,000 +iterations of a move into a local on even iterations, read through the new owner inside the branch +(§29) and freed by the local on odd ones; an assignment in a loop over a value that may have moved; +a value moved into a field on every path and given a new value on some, which that path frees +before the paths join (the old `own_err_reassign_some_paths`); a move into a call that keeps its +argument on one arm of `?:` (the ray tracer's `shade`); and a move in a `try` whose call throws on +half of 20,000 calls, with a `finally`. It prints the same under own, rc, gc and none, AOT and JIT, +and on Linux; own emits no counting. Negatives: `own_err_conditional_move_read` (`b = undefined` +after the join, then `v.x`: `is used here after its value was moved`), +`own_err_conditional_move_outer` (`moved inside a loop but was made outside it`), +`own_err_conditional_move_read_after_join` (`v.x` after the join), +`own_err_conditional_move_field_borrow` (a field of `v` read after the join) and +`own_err_conditional_move_catch` (the move inside a `catch`), the last three `is moved here on some +paths only`. + +The brief's positive read `t` after the join (`if (k % 2 == 0) { b = t; } s += t.x;`). That is the +shape the rule leaves out, so the read moved into the branch (`b = t; s += t.x;`) and before the +`if`, and `s` is 199999 instead of 149999. + +`own_err_reassign_some_paths` (a value moved, then given a new value on some paths) and +`own_err_borrow_derived_ref` (phase 2: `let b = a`, then `a = new C(2)` in a loop) became correct +programs: the path that gave `a` a new value frees it before the paths join, and the move into `b` +needs no borrow. They print what rc prints. The first now reads `a` after the join, and the second +gives `a` to a second `let`, so that each borrows it; both fail as before. + +Teeth: with the release placed on the edges that moved the value as well, `own_conditional_move` +(AOT, `test-compile-own-`) exits 0xC0000374 (heap corruption), and so do the brief's loop, the `?:` +move into a keeping call and the `try` with a throwing call, each alone; the straight-line +`if (v.x > 0) { best = v; } print(best.x)` prints 0 instead of 1. With the safety condition off, +`own_err_conditional_move_read_after_join` compiles, and a copy with same-size allocations before +the read prints 0 for `v.x` on the path that did not move it, instead of 1; no other check covers +it, since §29 accepts the read on the path that moved. A field of `v` read after the join, a +`const` alias of `v` and a call result that borrows `v` are each rejected by an older check as +well (the move ends a place borrow of `v`; two receivers; `borrows the result of`). + +Corpus (`test/tester/tests/*.ts` under `-mm=own --no-default-lib`, `--emit=obj`, Windows): 527 of +693 before this section, 528 after; none lost. Gained: `00nullish_values`; it prints under own what +it prints under rc (Windows AOT) and runs clean under own on Linux. + +The ray tracer (`rtbench_own.ts`) has 31 errors, from 33: both `is moved here on some paths only` +are gone - `intersections` (`closestInter = inter` on some iterations) and `shade` (`pos` and +`reflectDir` moved into `getReflectionColor`, whose facts keep them, on one arm of `?:`). The rest +are G9's, G5's and G2's (§28): 21 `borrows a field`, 5 object-literal moves, 2 `borrows an +element`, 1 mixed return, 1 kept parameter (the `reduce`), 1 `borrows a field and cannot be +stored`. From 163c6f7bd52e509e739aa50b7f4efb8b23bba0ad Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 11:03:24 +0100 Subject: [PATCH 18/57] own-raytrace T5b: -mm=own: nothing read out of a released value is used after its edge The walk of what is read out of a local that moved on some paths only checked uses only where the paths have joined. After a new value is stored into the local, or after its scope ends, a bound method or an interface view of the old value went unchecked, and the release placed on the edge freed what it read (review: `const m = v.get; if (c) { b = v; } v = mkv(2); m()`). Now a use of anything read out of the value is refused when it is reachable from the start of a block an edge released the value into, before the read it came from (or the op that made the value) runs again. A read of the slot itself after a new value is stored reads that one, and stays accepted. The error now carries a note at the use that kept the release from being placed. own_conditional_move gains a move on the right of `&&`, whose skipping edge forwards the `&&`'s value, so the release goes in a block of its own that passes it on. Negatives: a bound method and an interface view used after the join (only this walk sees them), and both used after the local is given a new value. Co-Authored-By: Claude Opus 5.5 --- .../lib/TypeScript/OwnershipInferencePass.cpp | 194 +++++++++++++----- tslang/test/tester/CMakeLists.txt | 5 +- .../test/tester/own/own_conditional_move.ts | 13 ++ .../own_err_conditional_move_bound_method.ts | 52 +++++ .../own/own_err_conditional_move_old_view.ts | 66 ++++++ .../own/own_err_conditional_move_view.ts | 52 +++++ 6 files changed, 331 insertions(+), 51 deletions(-) create mode 100644 tslang/test/tester/own/own_err_conditional_move_bound_method.ts create mode 100644 tslang/test/tester/own/own_err_conditional_move_old_view.ts create mode 100644 tslang/test/tester/own/own_err_conditional_move_view.ts diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index 4b376d113..f3d75c861 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -4580,7 +4580,7 @@ class OwnershipInferencePass return true; } - reportMovedOnSomePaths(taker, use, name); + reportMovedOnSomePaths(taker, use, name, placeReleases ? joinedUse : nullptr); return false; } @@ -4635,6 +4635,58 @@ class OwnershipInferencePass llvm::SmallVector edgeReleases; + // The use that kept placeOnUnmovedEdges from placing the releases, for the error's note. + mlir::Operation *joinedUse = nullptr; + + // Can control reach `to` from the start of `start`, without passing `kills` on the way? + bool reachableFrom(mlir::Block *start, mlir::Operation *to, llvm::ArrayRef kills) + { + auto &body = getFunction().getFunctionBody(); + to = body.findAncestorOpInRegion(*to); + if (!to) + { + return true; + } + + llvm::SmallVector killAt; + for (auto *kill : kills) + { + if (auto *top = body.findAncestorOpInRegion(*kill)) + { + killAt.push_back(top); + } + } + + llvm::SmallPtrSet seen; + llvm::SmallVector work{start}; + while (!work.empty()) + { + auto *block = work.pop_back_val(); + if (!seen.insert(block).second) + { + continue; + } + + auto killed = [&](mlir::Operation *before) { + return llvm::any_of(killAt, [&](mlir::Operation *kill) { + return kill->getBlock() == block && (!before || kill->isBeforeInBlock(before) || kill == before); + }); + }; + + if (to->getBlock() == block && !killed(to)) + { + return true; + } + + if (!killed(nullptr)) + { + work.append(block->getSuccessors().begin(), block->getSuccessors().end()); + } + } + + return false; + } + // The blocks of an exception handler: from a landing pad to the end of its catch. A release there // would be a call inside an exception funclet. llvm::SmallPtrSet handlerBlocks() @@ -4678,6 +4730,7 @@ class OwnershipInferencePass mlir::Operation *taker, llvm::ArrayRef assignments, llvm::SetVector &toErase) { + joinedUse = nullptr; auto &body = getFunction().getFunctionBody(); if (!varOp || varOp.getCaptured().value_or(false) || varOp->getParentRegion() != &body || acquire->getParentRegion() != &body || @@ -4783,12 +4836,66 @@ class OwnershipInferencePass } } + // the edges into the join that hold the value and did not move it + auto handler = handlerBlocks(); + llvm::SmallVector placed; + llvm::SetVector releasedInto; + for (auto &block : body) + { + if (!entry.count(&block)) + { + continue; // unreachable + } + + auto state = exitOf(&block); + for (auto index : llvm::seq(0, block.getNumSuccessors())) + { + auto into = entry.lookup(block.getSuccessor(index)); + if (!isJoined(into)) + { + continue; + } + + // a join some path reaches with nothing in the slot is not this rule's + if (into & SlotDead) + { + return false; + } + + // an edge that moved it, or from where the paths have joined already + if (state != SlotLive) + { + continue; + } + + auto *terminator = block.getTerminator(); + if (!mlir::isa(terminator) || handler.contains(&block) || + !dominance->dominates(varOp.getOperation(), terminator)) + { + return false; + } + + placed.push_back({slot, &block, static_cast(index), terminator->getLoc()}); + releasedInto.insert(block.getSuccessor(index)); + } + } + + if (placed.empty()) + { + return false; + } + // Nothing uses the value where the paths have joined - a read there, on the path that did // not move it, needs what the edge released - neither through the slot nor through anything // read out of it: a field, a view, a borrowing local, a closure. The move itself hands the // value to its new owner, whose reads are its own. A value the slot is given - its // initializer, an assignment's - is the slot's only once it is stored: a use before that (a // constructor's, or one around a loop before it is made again) is not a use of it. + // + // Past the joined points too: what was read out of the value before an edge released it + // may not be used after that edge - after a new value is stored into the slot, or after its + // scope ends - unless it was read out again (spec 30). A read of the slot after a new value + // is stored reads that one. struct Origin { mlir::Operation *stored; @@ -4821,6 +4928,18 @@ class OwnershipInferencePass given(mlir::cast(assignment).getValue(), assignment); } + // reached from an edge that released the value, before the read it came from (or the op + // that made it) runs again + auto usedAfterRelease = [&](mlir::Operation *made, mlir::Operation *user) { + llvm::SmallVector madeAgain; + if (made) + { + madeAgain.push_back(made); + } + + return llvm::any_of(releasedInto, [&](mlir::Block *join) { return reachableFrom(join, user, madeAgain); }); + }; + auto heldAt = [&](unsigned origin, mlir::Operation *user) { auto &from = origins[origin]; if (!from.stored) @@ -4856,6 +4975,13 @@ class OwnershipInferencePass if (joinedAt(user) && heldAt(origin, user)) { + joinedUse = user; + return false; + } + + if (value != slot && usedAfterRelease(origins[origin].made, user)) + { + joinedUse = user; return false; } @@ -4903,11 +5029,19 @@ class OwnershipInferencePass borrows = index >= 0 && static_cast(index) < args.size() && args[index] == value; } + // a read of the slot is what its uses came from + auto from = origin; + if (value == slot && user->getNumResults() > 0) + { + origins.push_back({nullptr, user}); + from = origins.size() - 1; + } + for (auto result : user->getResults()) { if (borrows) { - taint(result, origin); + taint(result, from); } } @@ -4923,53 +5057,6 @@ class OwnershipInferencePass } } - // the edges into the join that hold the value and did not move it - auto handler = handlerBlocks(); - llvm::SmallVector placed; - for (auto &block : body) - { - if (!entry.count(&block)) - { - continue; // unreachable - } - - auto state = exitOf(&block); - for (auto index : llvm::seq(0, block.getNumSuccessors())) - { - auto into = entry.lookup(block.getSuccessor(index)); - if (!isJoined(into)) - { - continue; - } - - // a join some path reaches with nothing in the slot is not this rule's - if (into & SlotDead) - { - return false; - } - - // an edge that moved it, or from where the paths have joined already - if (state != SlotLive) - { - continue; - } - - auto *terminator = block.getTerminator(); - if (!mlir::isa(terminator) || handler.contains(&block) || - !dominance->dominates(varOp.getOperation(), terminator)) - { - return false; - } - - placed.push_back({slot, &block, static_cast(index), terminator->getLoc()}); - } - } - - if (placed.empty()) - { - return false; - } - toErase.insert(erased.begin(), erased.end()); edgeReleases.append(placed.begin(), placed.end()); return true; @@ -5860,7 +5947,8 @@ class OwnershipInferencePass signalPassFailure(); } - void reportMovedOnSomePaths(mlir::Operation *move, mlir::Operation *release, llvm::StringRef name) + void reportMovedOnSomePaths(mlir::Operation *move, mlir::Operation *release, llvm::StringRef name, + mlir::Operation *usedAt = nullptr) { if (!reporting()) { @@ -5871,6 +5959,12 @@ class OwnershipInferencePass << "' is moved here on some paths only; -mm=own cannot release it " "on the others yet"; diag.attachNote(release->getLoc()) << "released here on every path"; + if (usedAt) + { + diag.attachNote(usedAt->getLoc()) + << "used here, after the paths join, so it cannot be released before they do"; + } + signalPassFailure(); } }; diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index e061ad24f..cf060e6e9 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3811,7 +3811,10 @@ set(own_error_cases "own_err_conditional_move_outer|is moved inside a loop but was made outside it" "own_err_conditional_move_read_after_join|is moved here on some paths only" "own_err_conditional_move_field_borrow|is moved here on some paths only" - "own_err_conditional_move_catch|is moved here on some paths only") + "own_err_conditional_move_catch|is moved here on some paths only" + "own_err_conditional_move_bound_method|is moved here on some paths only" + "own_err_conditional_move_view|is moved here on some paths only" + "own_err_conditional_move_old_view|is moved here on some paths only") 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_conditional_move.ts b/tslang/test/tester/own/own_conditional_move.ts index 2184f743d..0c8796563 100644 --- a/tslang/test/tester/own/own_conditional_move.ts +++ b/tslang/test/tester/own/own_conditional_move.ts @@ -149,6 +149,19 @@ function main() { print(taken); // (0 + 2 + ... + 998) + (2 + 4 + ... + 1000) assert(taken == 500000); + // ...and on the right of `&&`: the edge that skips it carries the `&&`'s value, so the release + // goes in a block of its own on that edge, which passes the value on + let anded: number = 0; + for (let k = 0; k < 1000; k++) { + let p = new V(k); + if (k % 2 == 0 && keeps(p) >= 0) { + anded++; + } + } + + print(anded); // 500 + assert(anded == 500); + let moved: number = 0; let thrown: number = 0; for (let k = 0; k < 20000; k++) { diff --git a/tslang/test/tester/own/own_err_conditional_move_bound_method.ts b/tslang/test/tester/own/own_err_conditional_move_bound_method.ts new file mode 100644 index 000000000..12a047688 --- /dev/null +++ b/tslang/test/tester/own/own_err_conditional_move_bound_method.ts @@ -0,0 +1,52 @@ +// @strict-null false +// -mm=own rejects: `m` is `v.get`, bound to `v`, `v` moves into `b` on one path only, and `m` is +// called after the paths join. Freeing `v` where the paths join, on the path that did not move it, +// would free what `m` reads (spec 30). Only the walk of what is read out of `v` sees it. +class W { + constructor(public x: number) {} +} + +class V { + constructor(public w: W) {} + get() { + return this.w.x; + } +} + +interface I { + w: W; +} + +function mkv(n: number) { + return new V(new W(n)); +} + +function churn() { + let s: number = 0; + const a: V[] = []; + for (let i = 0; i < 64; i++) { + a.push(mkv(1000 + i)); + } + + for (const e of a) { + s += e.w.x; + } + + return s; +} + +function test(c: boolean) { + let b: V = undefined; + let v = mkv(1); + const m = v.get; + if (c) { + b = v; + } + + churn(); + return m(); +} + +function main() { + print(test(true), test(false)); +} diff --git a/tslang/test/tester/own/own_err_conditional_move_old_view.ts b/tslang/test/tester/own/own_err_conditional_move_old_view.ts new file mode 100644 index 000000000..416341614 --- /dev/null +++ b/tslang/test/tester/own/own_err_conditional_move_old_view.ts @@ -0,0 +1,66 @@ +// @strict-null false +// -mm=own rejects: a bound method of `v` and an interface view of it are used after `v`, moved into +// `b` on one path only, is given a new value. The old value, released where the paths join on the +// path that did not move it, is what they read; the joined point is long past (spec 30). +class W { + constructor(public x: number) {} +} + +class V { + constructor(public w: W) {} + get() { + return this.w.x; + } +} + +interface I { + w: W; +} + +function mkv(n: number) { + return new V(new W(n)); +} + +function churn() { + let s: number = 0; + const a: V[] = []; + for (let i = 0; i < 64; i++) { + a.push(mkv(1000 + i)); + } + + for (const e of a) { + s += e.w.x; + } + + return s; +} + +function bound(c: boolean) { + let b: V = undefined; + let v = mkv(1); + const m = v.get; + if (c) { + b = v; + } + + v = mkv(2); + churn(); + return m() + v.w.x; +} + +function view(c: boolean) { + let b: V = undefined; + let v = mkv(1); + const i: I = v; + if (c) { + b = v; + } + + v = mkv(2); + churn(); + return i.w.x + v.w.x; +} + +function main() { + print(bound(true), bound(false), view(true), view(false)); +} diff --git a/tslang/test/tester/own/own_err_conditional_move_view.ts b/tslang/test/tester/own/own_err_conditional_move_view.ts new file mode 100644 index 000000000..0cec985ce --- /dev/null +++ b/tslang/test/tester/own/own_err_conditional_move_view.ts @@ -0,0 +1,52 @@ +// @strict-null false +// -mm=own rejects: `i` is `v` seen through an interface, `v` moves into `b` on one path only, and +// `i` is read after the paths join. Freeing `v` where the paths join, on the path that did not move +// it, would free what `i` reads (spec 30). Only the walk of what is read out of `v` sees it. +class W { + constructor(public x: number) {} +} + +class V { + constructor(public w: W) {} + get() { + return this.w.x; + } +} + +interface I { + w: W; +} + +function mkv(n: number) { + return new V(new W(n)); +} + +function churn() { + let s: number = 0; + const a: V[] = []; + for (let i = 0; i < 64; i++) { + a.push(mkv(1000 + i)); + } + + for (const e of a) { + s += e.w.x; + } + + return s; +} + +function test(c: boolean) { + let b: V = undefined; + let v = mkv(1); + const i: I = v; + if (c) { + b = v; + } + + churn(); + return i.w.x; +} + +function main() { + print(test(true), test(false)); +} From 62077be1117f74e3aec1f196f1414b35a9e679ab Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 11:07:06 +0100 Subject: [PATCH 19/57] own-raytrace T5b: spec 30, uses past the join and the teeth that show them What is read out of a released value may not be used after its edge, not only where the paths join; the walk is the only check for a bound method or an interface view, and its teeth; the && split edge; the string remark; the no-move hole that stays open in every model. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md b/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md index 51dce36b0..10ca68d76 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 @@ -3184,6 +3184,16 @@ the new owner where it moved, on the edge where it did not. its own, and rc's retain for it is erased. A value the slot is given (its initializer, an assignment's) is the slot's only once stored, so a use of it before that - the constructor's, or one around a loop before the value is made again - is no use of the slot's value. +- **...and past the joined points** (found in review): what was read out of the value before an + edge released it - a bound method `v.get`, an interface view `const i: I = v` - may not be used + anywhere reachable from the start of a block the edge released it into, before the read it came + from (or the op that made the value) runs again: not after a new value is stored into the local + (`if (c) { b = v; } v = mkv(2); m()`), where the state is no longer joined, and not after the + scope ends. A read of the slot itself after a new value is stored reads the new one, and stays + accepted. The error then carries a note at the use: `used here, after the paths join, so it + cannot be released before they do`. +- A `string` read out of the value counts as a pointer into it, though own copies strings in some + places: too strict, but sound. - **One receiver**: the rule applies when the move is the local's one receiver (`decideSlot`). With two, `if (c) { a = v; } else { b = v; }`, each move's other paths include the other's, and the error stays. @@ -3204,15 +3214,19 @@ iterations of a move into a local on even iterations, read through the new owner (§29) and freed by the local on odd ones; an assignment in a loop over a value that may have moved; a value moved into a field on every path and given a new value on some, which that path frees before the paths join (the old `own_err_reassign_some_paths`); a move into a call that keeps its -argument on one arm of `?:` (the ray tracer's `shade`); and a move in a `try` whose call throws on +argument on one arm of `?:` (the ray tracer's `shade`), and on the right of `&&`, whose skipping +edge carries the `&&`'s value, so the release goes in a block of its own that passes the value on; +and a move in a `try` whose call throws on half of 20,000 calls, with a `finally`. It prints the same under own, rc, gc and none, AOT and JIT, and on Linux; own emits no counting. Negatives: `own_err_conditional_move_read` (`b = undefined` after the join, then `v.x`: `is used here after its value was moved`), `own_err_conditional_move_outer` (`moved inside a loop but was made outside it`), `own_err_conditional_move_read_after_join` (`v.x` after the join), -`own_err_conditional_move_field_borrow` (a field of `v` read after the join) and -`own_err_conditional_move_catch` (the move inside a `catch`), the last three `is moved here on some -paths only`. +`own_err_conditional_move_field_borrow` (a field of `v` read after the join), +`own_err_conditional_move_catch` (the move inside a `catch`), `own_err_conditional_move_bound_method` +(`const m = v.get` called after the join), `own_err_conditional_move_view` (`const i: I = v` read +after the join) and `own_err_conditional_move_old_view` (both, used after `v` is given a new value +past the join), the last six `is moved here on some paths only`. The brief's positive read `t` after the join (`if (k % 2 == 0) { b = t; } s += t.x;`). That is the shape the rule leaves out, so the read moved into the branch (`b = t; s += t.x;`) and before the @@ -3230,9 +3244,17 @@ move into a keeping call and the `try` with a throwing call, each alone; the str `if (v.x > 0) { best = v; } print(best.x)` prints 0 instead of 1. With the safety condition off, `own_err_conditional_move_read_after_join` compiles, and a copy with same-size allocations before the read prints 0 for `v.x` on the path that did not move it, instead of 1; no other check covers -it, since §29 accepts the read on the path that moved. A field of `v` read after the join, a -`const` alias of `v` and a call result that borrows `v` are each rejected by an older check as -well (the move ends a place borrow of `v`; two receivers; `borrows the result of`). +it, since §29 accepts the read on the path that moved. With what is read out of `v` not followed +(only the slot's reads), `own_err_conditional_move_bound_method` and `_view` compile - no older +check sees them - and their AOT runs crash (0xC0000005), where rc prints `1 1`. With the walk +checking only joined points (as first written), both forms of `own_err_conditional_move_old_view` +compile and read the freed old value: the bound method printed `3 4` on one run and crashed +(0xC0000005) on the other, instead of `3 3`; the view printed `3 1002` and `3 1004`. A field of +`v` read after the join, a `const` alias of `v` and a call result that borrows `v` are each also +rejected by an older check (the move ends a place borrow of `v`; two receivers; `borrows the +result of`). Still open, every model (rc prints garbage too): a bound method or a view of a +local used after the local is given a new value, with no move at all (`const m = v.get; v = +mkv(2); m()`). Corpus (`test/tester/tests/*.ts` under `-mm=own --no-default-lib`, `--emit=obj`, Windows): 527 of 693 before this section, 528 after; none lost. Gained: `00nullish_values`; it prints under own what From b129accfac70793606e171c836a06a609d20b43a Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 11:53:20 +0100 Subject: [PATCH 20/57] own-raytrace T6: -mm=own: a call through a closed interface gets its implementations' facts An interface only this module can see - not named by an exported declaration, not in the type of a public or declared function or an exported global, every value of it made by ts.NewInterface over a vtable the signature pass reads, every vtable written only by its initializer, no exported class holding one - has, per vtable slot, a known family of functions. A call through the slot (ts.InterfaceSymbolRef) is resolved to that family like a private virtual call: it gets __own_no_drops when every member destroys nothing, and the kept parameters and borrowed result its members agree on (meetFacts demotes a family that disagrees). Its members keep their facts, for direct calls too: Task 3's stripping (interfaceReached) now applies only to members of open families and to vtables no interface value is made with. A cast into an interface from another interface opens both, one from `any` or an opaque pointer opens every interface boxed into one, and an untraceable vtable opens every interface. boundThis sees an interface-bound method's object, so a result that borrows `this` through the interface is bounded by the interface value. The five own_err_interface_* negatives of Task 3 now export their interface (the open form, same texts); their closed forms are in own_interface_facts. New negatives: an exported implementation, a family that disagrees, a member that drops, an interface made from another, a borrowed result outliving its owner. Co-Authored-By: Claude Opus 5.5 --- tslang/lib/TypeScript/OwnershipFacts.h | 9 +- .../lib/TypeScript/OwnershipSignaturePass.cpp | 847 ++++++++++++++++-- tslang/test/tester/CMakeLists.txt | 15 +- .../own/own_err_interface_borrow_element.ts | 10 +- .../own/own_err_interface_borrow_field.ts | 10 +- .../own/own_err_interface_borrow_param.ts | 10 +- .../own/own_err_interface_exported_impl.ts | 18 + .../own/own_err_interface_family_disagrees.ts | 16 + .../own/own_err_interface_from_interface.ts | 15 + .../own/own_err_interface_keeps_literal.ts | 10 +- .../own/own_err_interface_keeps_param.ts | 10 +- .../own/own_err_interface_member_drops.ts | 18 + .../own/own_err_interface_result_outlives.ts | 13 + tslang/test/tester/own/own_interface_facts.ts | 125 +++ 14 files changed, 1027 insertions(+), 99 deletions(-) create mode 100644 tslang/test/tester/own/own_err_interface_exported_impl.ts create mode 100644 tslang/test/tester/own/own_err_interface_family_disagrees.ts create mode 100644 tslang/test/tester/own/own_err_interface_from_interface.ts create mode 100644 tslang/test/tester/own/own_err_interface_member_drops.ts create mode 100644 tslang/test/tester/own/own_err_interface_result_outlives.ts create mode 100644 tslang/test/tester/own/own_interface_facts.ts diff --git a/tslang/lib/TypeScript/OwnershipFacts.h b/tslang/lib/TypeScript/OwnershipFacts.h index e4e690969..727b0e323 100644 --- a/tslang/lib/TypeScript/OwnershipFacts.h +++ b/tslang/lib/TypeScript/OwnershipFacts.h @@ -833,7 +833,9 @@ inline bool isParameterSlot(mlir_ts::VariableOp varOp, int &index) return true; } -// The object a method call is made on: `ts.GetThis` of a method bound to it. Null otherwise. +// The object a method call is made on: `ts.GetThis` of a method bound to it - by its class, or by an +// interface value whose method it is (`i.m`: the object the interface value is a view of, spec 31). +// Null otherwise. inline mlir::Value boundThis(mlir::Value value) { auto getThisOp = value.getDefiningOp(); @@ -848,6 +850,11 @@ inline mlir::Value boundThis(mlir::Value value) return refOp.getThisVal(); } + if (auto refOp = mlir::dyn_cast_or_null(bound); refOp && !isInterfaceField(refOp.getResult())) + { + return refOp.getInterfaceVal(); + } + return {}; } diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index e2e1147d5..116fd535f 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -381,7 +381,7 @@ class OwnershipSignaturePass : public mlir::PassWrapper funcs; @@ -630,17 +632,705 @@ class OwnershipSignaturePass : public mlir::PassWrapper open; - // Methods an interface's vtable for a class (`...vtbl`) holds. A call through - // the interface (`ts.InterfaceSymbolRef`, then an indirect call) is not resolved, so it is given - // none of their facts: a caller that hands such a method an argument it keeps still releases it, - // and a caller of one whose result borrows an argument or `this` releases the borrow as its own - // (a double free either way). So a method reached this way that keeps a parameter or returns a - // borrow - by its body or by `@keeps` / `@lends` - loses its facts, and the body's error says - // why (`is a parameter, kept here, but -mm=own cannot make the callers of ... give it up`, `is a - // parameter, returned here, but -mm=own cannot tell the callers of ... that the result borrows - // it`). Task 6 of the ray tracer plan (G9, facts through a module-private interface) relaxes - // this for closed interfaces by giving the interface call the meet of these facts. + // Methods an interface's vtable holds (`...vtbl`, or `...vtbl` + // for an object literal) whose calls through the interface are not resolved: the slot's family + // is open (interfaceFamilies), or the vtable is one this pass cannot tie to an interface. Such + // a call (`ts.InterfaceSymbolRef`, then an indirect call) is given none of the method's facts: a + // caller that hands it an argument it keeps still releases it, and a caller of one whose result + // borrows an argument or `this` releases the borrow as its own (a double free either way). So a + // method reached this way that keeps a parameter or returns a borrow - by its body or by `@keeps` + // / `@lends` - loses its facts, and the body's error says why (`is a parameter, kept here, but + // -mm=own cannot make the callers of ... give it up`, `is a parameter, returned here, but -mm=own + // cannot tell the callers of ... that the result borrows it`). A method reached only through + // closed interfaces keeps its facts: their calls get them (spec 31). llvm::DenseSet interfaceReached; + + // ---- Interfaces closed in this module (G9, spec 31) ---- + // + // A call through an interface reaches whatever function the slot of the interface value's vtable + // holds. When every such vtable is one this module defines and every interface value is made + // from one of them, the slot holds one of a known set of functions - the slot's family - and the + // call is resolved to it like a virtual call of a private class family: it gets the facts its + // members agree on (meetFacts demotes a family that disagrees), and `__own_no_drops` when every + // member destroys nothing. Anything this cannot see opens the interface, and then the call is + // what it was: no facts, and its members reached that way lose theirs (interfaceReached). + // + // Open, for an interface I: + // - another module can see it: its name in an exported declaration (`__decls`), or `!ts.iface<@I>` + // in the type of a function or global another module can reach - a public or declared + // function, an exported or imported global; + // - an I value made other than by `ts.NewInterface` over a vtable this pass reads (the global, a + // heap copy of it, or the one an object's class vtable holds for I): a cast from another + // interface (`J` to `I`, which opens `J` too: its members are reached through `I`), from + // `any` or an opaque pointer (which opens every interface put into one), from a tuple or array + // that does not hold I already (castMakesInterface), a `ts.InterfaceRef` used as a value; + // - a vtable of I written after its initializer, or read other than to make an interface value; + // - a class vtable that holds a vtable of I and is not private (an exported class: another + // module may extend it); + // - and, per slot, a member that is not a function this module defines, a slot that holds no + // function (a field's offset, a thunk's cell), or a member that says `@keeps` or `@lends` (its + // facts are its signature's, which a family cannot change). A member that is not private (a + // `public` method) is open (findOpen): it has no facts of its own to give the family. + struct InterfaceFamily + { + // the slot's name, which every vtable of the interface agrees on + mlir::StringAttr field; + llvm::SmallVector members; + bool closed = true; + }; + + // (interface, vtable position) -> the functions every vtable of the interface holds there + std::map, InterfaceFamily> interfaceFamilies; + + // The interfaces mentioned by a type as values: the interface itself, a union or optional, a + // tuple's fields, an array's elements, what a reference points to. Not a class's or an object's + // fields (a reference to a block made elsewhere), nor a function's parameters and result. + static void valueInterfaces(mlir::Type type, llvm::StringSet<> &found, int depth = 0) + { + if (!type || depth > 16) + { + return; + } + + if (auto interfaceType = mlir::dyn_cast(type)) + { + found.insert(interfaceType.getName().getValue()); + return; + } + + auto fields = [&](llvm::ArrayRef infos) { + for (auto &info : infos) + { + valueInterfaces(info.type, found, depth + 1); + } + }; + + if (auto tupleType = mlir::dyn_cast(type)) + { + fields(tupleType.getFields()); + } + else if (auto constTupleType = mlir::dyn_cast(type)) + { + fields(constTupleType.getFields()); + } + else if (auto unionType = mlir::dyn_cast(type)) + { + for (auto member : unionType.getTypes()) + { + valueInterfaces(member, found, depth + 1); + } + } + else if (auto optionalType = mlir::dyn_cast(type)) + { + valueInterfaces(optionalType.getElementType(), found, depth + 1); + } + else if (auto arrayType = mlir::dyn_cast(type)) + { + valueInterfaces(arrayType.getElementType(), found, depth + 1); + } + else if (auto constArrayType = mlir::dyn_cast(type)) + { + valueInterfaces(constArrayType.getElementType(), found, depth + 1); + } + else if (auto refType = mlir::dyn_cast(type)) + { + valueInterfaces(refType.getElementType(), found, depth + 1); + } + else if (auto valueRefType = mlir::dyn_cast(type)) + { + valueInterfaces(valueRefType.getElementType(), found, depth + 1); + } + else if (auto boundRefType = mlir::dyn_cast(type)) + { + valueInterfaces(boundRefType.getElementType(), found, depth + 1); + } + else if (auto sharedType = mlir::dyn_cast(type)) + { + valueInterfaces(sharedType.getElementType(), found, depth + 1); + } + } + + // Every interface a type mentions anywhere, a class's fields and a function's parameters + // included: what another module handed a value of this type could reach. + static void anyInterfaces(mlir::Type type, llvm::StringSet<> &found, llvm::DenseSet &seen) + { + if (!type || !seen.insert(type).second) + { + return; + } + + if (auto interfaceType = mlir::dyn_cast(type)) + { + found.insert(interfaceType.getName().getValue()); + return; + } + + auto fields = [&](llvm::ArrayRef infos) { + for (auto &info : infos) + { + anyInterfaces(info.type, found, seen); + } + }; + + // the types whose fields are not parameters of the type + if (auto classStorageType = mlir::dyn_cast(type)) + { + fields(classStorageType.getFields()); + return; + } + + if (auto objectStorageType = mlir::dyn_cast(type)) + { + fields(objectStorageType.getFields()); + return; + } + + if (auto tupleType = mlir::dyn_cast(type)) + { + fields(tupleType.getFields()); + return; + } + + if (auto constTupleType = mlir::dyn_cast(type)) + { + fields(constTupleType.getFields()); + return; + } + + type.walkImmediateSubElements([](mlir::Attribute) {}, [&](mlir::Type sub) { anyInterfaces(sub, found, seen); }); + } + + static bool isNullish(mlir::Type type) + { + return mlir::isa(type); + } + + // May a cast from `in` to `out` make an interface value out of something that was not that + // interface's value already? A union or optional narrowed to its interface member (whose other + // members are null or undefined) or widened from it, null given a nullable interface type, a + // class or object seen as another (the same block, its fields as they are) and a function seen as + // another are not; a cast from another interface, `any`, an opaque pointer, or a tuple or an + // array whose element is not the same interface is. + static bool castMakesInterface(mlir::Type in, mlir::Type out, int depth = 0) + { + if (in == out || depth > 16) + { + return false; + } + + llvm::StringSet<> made; + valueInterfaces(out, made); + if (made.empty()) + { + return false; + } + + if (mlir::isa(out)) + { + return !holdsAsIs(in, out, /*onlyNullable=*/true); + } + + if (mlir::isa(out)) + { + if (isNullish(in) || holdsAsIs(out, in)) + { + return false; + } + + llvm::SmallVector members; + if (auto unionType = mlir::dyn_cast(in)) + { + members.append(unionType.getTypes().begin(), unionType.getTypes().end()); + } + else if (auto optionalType = mlir::dyn_cast(in)) + { + members.push_back(optionalType.getElementType()); + } + else + { + return true; + } + + return !llvm::all_of(members, [&](mlir::Type member) { return isNullish(member) || holdsAsIs(out, member); }); + } + + auto tupleFields = [](mlir::Type type) -> std::optional> { + if (auto tupleType = mlir::dyn_cast(type)) + { + return tupleType.getFields(); + } + + if (auto constTupleType = mlir::dyn_cast(type)) + { + return constTupleType.getFields(); + } + + return std::nullopt; + }; + + if (auto outFields = tupleFields(out)) + { + auto inFields = tupleFields(in); + if (!inFields || inFields->size() != outFields->size()) + { + return true; + } + + for (auto [inField, outField] : llvm::zip(*inFields, *outFields)) + { + if (castMakesInterface(inField.type, outField.type, depth + 1)) + { + return true; + } + } + + return false; + } + + auto elementOf = [](mlir::Type type) -> mlir::Type { + if (auto arrayType = mlir::dyn_cast(type)) + { + return arrayType.getElementType(); + } + + if (auto constArrayType = mlir::dyn_cast(type)) + { + return constArrayType.getElementType(); + } + + return {}; + }; + + if (auto outElement = elementOf(out)) + { + auto inElement = elementOf(in); + return !inElement || castMakesInterface(inElement, outElement, depth + 1); + } + + // a reference reinterpreted: what is read through it was never checked + return true; + } + + // The function a vtable slot holds: `ts.SymbolRef`, seen through casts. Null for anything else. + static mlir::StringAttr slotFunction(mlir::Value value) + { + while (auto castOp = value ? value.getDefiningOp() : mlir_ts::CastOp()) + { + value = castOp.getIn(); + } + + auto symbolRefOp = value ? value.getDefiningOp() : mlir_ts::SymbolRefOp(); + return symbolRefOp ? symbolRefOp.getIdentifierAttr().getAttr() : mlir::StringAttr(); + } + + static mlir_ts::AddressOfOp addressOfThroughCasts(mlir::Value value) + { + while (auto castOp = value ? value.getDefiningOp() : mlir_ts::CastOp()) + { + value = castOp.getIn(); + } + + return value ? value.getDefiningOp() : mlir_ts::AddressOfOp(); + } + + // The words of the module's exported declarations (`__decls*` globals): what another module + // can name. + static llvm::StringSet<> exportedWords(mlir::ModuleOp module) + { + llvm::StringSet<> words; + module.walk([&](mlir_ts::GlobalOp globalOp) { + if (!globalOp.getSymName().starts_with("__decls")) + { + return; + } + + globalOp.getInitializerRegion().walk([&](mlir_ts::ConstantOp constantOp) { + auto text = mlir::dyn_cast(constantOp.getValue()); + if (!text) + { + return; + } + + llvm::StringRef rest = text.getValue(); + while (!rest.empty()) + { + auto isWordChar = [](char c) { return llvm::isAlnum(c) || c == '_' || c == '$'; }; + rest = rest.drop_while([&](char c) { return !isWordChar(c); }); + auto word = rest.take_while(isWordChar); + if (!word.empty()) + { + words.insert(word); + } + + rest = rest.drop_front(word.size()); + } + }); + }); + + return words; + } + + void collectInterfaceFamilies(mlir::ModuleOp module) + { + llvm::StringSet<> open; + auto openAll = [&](const llvm::StringSet<> &names) { + for (auto &entry : names) + { + open.insert(entry.getKey()); + } + }; + + // the vtables: an interface's (`ts.InsertProperty` of each slot, slot 0 `.instanceOf` of a + // class that is not the vtable's owner, or `.instanceOf.none` of an object literal), and the + // slots of a class's own vtable that hold the address of one of its interface vtables + struct InterfaceVTable + { + mlir_ts::TupleType type; + llvm::DenseMap slots; + bool written = false; + }; + + llvm::StringMap vtables; + // interface name -> the interface vtables class vtables hold for it, read through an + // object's class at run time (`ts.VTableOffsetRef` of the object's vtable) + llvm::StringMap> classHeld; + module.walk([&](mlir_ts::GlobalOp globalOp) { + auto name = globalOp.getSymName(); + if (!name.ends_with("..vtbl")) + { + return; + } + + auto owner = name.drop_back(llvm::StringRef("..vtbl").size()); + llvm::DenseMap values; + globalOp.getInitializerRegion().walk([&](mlir_ts::InsertPropertyOp insertOp) { + if (insertOp.getPosition().size() == 1) + { + values[insertOp.getPosition()[0]] = insertOp.getValue(); + } + }); + + auto header = slotFunction(values.lookup(0)); + auto tupleType = mlir::dyn_cast(globalOp.getType()); + if (!header || !tupleType) + { + return; + } + + if (header.getValue() == (owner + "..instanceOf").str()) + { + auto exposed = !globalOp.isPrivate() || globalOp->hasAttr("export") || globalOp->hasAttr(DLL_EXPORT); + for (auto &[position, value] : values) + { + auto addressOfOp = addressOfThroughCasts(value); + auto id = position < static_cast(tupleType.size()) + ? mlir::dyn_cast_or_null(tupleType.getFieldInfo(position).id) + : mlir::StringAttr(); + if (!addressOfOp || !id) + { + continue; + } + + classHeld[id.getValue()].insert(addressOfOp.getGlobalName()); + if (exposed) + { + open.insert(id.getValue()); // another module may extend the class + } + } + + return; + } + + if (!header.getValue().ends_with("..instanceOf") && header.getValue() != ".instanceOf.none") + { + return; + } + + auto &vtable = vtables[name]; + vtable.type = tupleType; + for (auto &[position, value] : values) + { + if (position > 0) + { + vtable.slots[position] = slotFunction(value); + } + } + }); + + // interface name -> the vtables its values are made with + llvm::StringMap> madeWith; + auto vtableOf = [&](mlir::Value value) -> llvm::StringRef { + // the global itself + if (auto addressOfOp = addressOfThroughCasts(value)) + { + return vtables.count(addressOfOp.getGlobalName()) ? addressOfOp.getGlobalName() : llvm::StringRef(); + } + + // a copy of it on the heap, its absent members' slots patched + while (auto castOp = value.getDefiningOp()) + { + value = castOp.getIn(); + } + + if (auto newOp = value.getDefiningOp()) + { + llvm::StringRef copied; + for (auto *user : newOp->getUsers()) + { + auto storeOp = mlir::dyn_cast(user); + auto loadOp = storeOp && storeOp.getReference() == newOp.getResult() + ? storeOp.getValue().getDefiningOp() + : mlir_ts::LoadOp(); + auto addressOfOp = loadOp ? addressOfThroughCasts(loadOp.getReference()) : mlir_ts::AddressOfOp(); + if (addressOfOp && vtables.count(addressOfOp.getGlobalName())) + { + if (!copied.empty()) + { + return {}; + } + + copied = addressOfOp.getGlobalName(); + } + } + + return copied; + } + + return {}; + }; + + // an object's class vtable read at run time: `ts.VTableOffsetRef(ts.Load(ts.PropertyRef(object, 0)), k)` + auto readFromClass = [](mlir::Value value) { + auto offsetRefOp = value.getDefiningOp(); + auto loadOp = offsetRefOp ? offsetRefOp.getVtable().getDefiningOp() : mlir_ts::LoadOp(); + auto propertyRefOp = loadOp ? loadOp.getReference().getDefiningOp() : mlir_ts::PropertyRefOp(); + return propertyRefOp && propertyRefOp.getPosition() == 0 && + mlir::isa(propertyRefOp.getObjectRef().getType()); + }; + + // what reads a vtable's address: making an interface value, a class vtable's slot, the copy + std::function onlyMakesValues = [&](mlir::Value address) { + return llvm::all_of(address.getUses(), [&](mlir::OpOperand &use) { + auto *user = use.getOwner(); + if (auto newInterfaceOp = mlir::dyn_cast(user)) + { + return newInterfaceOp.getInterfaceVTable() == use.get(); + } + + if (mlir::isa(user)) + { + return onlyMakesValues(user->getResult(0)); + } + + if (mlir::isa(user)) + { + return use.getOperandNumber() == 0 && user->getParentOfType() != nullptr; + } + + // the copy: read whole, and stored into a block `ts.New` made + if (auto loadOp = mlir::dyn_cast(user)) + { + return llvm::all_of(loadOp->getUsers(), [&](mlir::Operation *loadUser) { + auto storeOp = mlir::dyn_cast(loadUser); + return storeOp && storeOp.getValue() == loadOp.getResult() && + storeOp.getReference().getDefiningOp(); + }); + } + + return false; + }); + }; + + // An interface value made out of another one reaches the other's members: both are open. One + // made out of `any` or an opaque pointer may be any interface value put into one: every + // interface so boxed is open with it, and every interface at all when it is untraceable. + auto allOpen = false; + llvm::StringSet<> boxed; + llvm::StringSet<> unboxedInto; + auto isOpaque = [](mlir::Type type) { return mlir::isa(type); }; + module.walk([&](mlir::Operation *op) { + if (auto newInterfaceOp = mlir::dyn_cast(op)) + { + auto name = mlir::cast(newInterfaceOp.getType()).getName().getValue(); + auto vtable = newInterfaceOp.getInterfaceVTable(); + if (auto found = vtableOf(vtable); !found.empty()) + { + madeWith[name].insert(found); + } + else if (readFromClass(vtable)) + { + // any class's vtable for the interface (classHeld, all in its families below) + if (!classHeld.count(name)) + { + open.insert(name); + } + } + else if (auto extractOp = vtable.getDefiningOp()) + { + open.insert(name); + valueInterfaces(extractOp.getInterfaceVal().getType(), open); + } + else + { + allOpen = true; + } + + return; + } + + if (auto castOp = mlir::dyn_cast(op)) + { + auto in = castOp.getIn().getType(); + auto out = castOp.getType(); + if (isOpaque(out)) + { + valueInterfaces(in, boxed); + } + + if (castMakesInterface(in, out)) + { + valueInterfaces(out, open); + valueInterfaces(in, open); + if (isOpaque(in)) + { + valueInterfaces(out, unboxedInto); + } + } + + return; + } + + if (auto interfaceRefOp = mlir::dyn_cast(op)) + { + if (!interfaceRefOp->use_empty()) + { + open.insert(interfaceRefOp.getIdentifier()); + } + + return; + } + + if (auto addressOfOp = mlir::dyn_cast(op)) + { + if (auto found = vtables.find(addressOfOp.getGlobalName()); found != vtables.end() && !onlyMakesValues(addressOfOp.getResult())) + { + found->second.written = true; + } + } + }); + + // seen from another module: a word of its exported declarations, or in a reachable type + auto words = exportedWords(module); + llvm::StringSet<> exposed; + llvm::DenseSet seen; + for (auto &entry : functions) + { + if (entry.second.isDeclaration() || !entry.second.isPrivate()) + { + anyInterfaces(entry.second.getFunctionType(), exposed, seen); + } + } + + for (auto &entry : globals) + { + auto globalOp = entry.second; + if (!globalOp.isPrivate() || globalOp->hasAttr("export") || globalOp->hasAttr("import") || + globalOp->hasAttr(DLL_EXPORT) || globalOp->hasAttr(DLL_IMPORT)) + { + anyInterfaces(globalOp.getType(), exposed, seen); + } + } + + openAll(exposed); + if (!unboxedInto.empty()) + { + openAll(boxed); + } + + auto isOpen = [&](llvm::StringRef name) { + // a namespaced interface is declared by its last name + auto last = name.rsplit('.').second; + return allOpen || open.contains(name) || words.contains(name) || (!last.empty() && words.contains(last)); + }; + + // a class's vtables for its interfaces are its members' families whether or not this module + // makes an interface value of the class: a method called directly then keeps its facts + for (auto &[name, held] : classHeld) + { + madeWith[name].insert(held.begin(), held.end()); + } + + // the families + llvm::DenseSet tied; + for (auto &[name, made] : madeWith) + { + // a class vtable's slot holding a vtable this pass did not read (another module's) + auto closed = !isOpen(name) && llvm::all_of(made, [&](llvm::StringRef vtableName) { return vtables.count(vtableName); }); + for (auto vtableName : made) + { + if (!vtables.count(vtableName)) + { + continue; + } + + tied.insert(vtableName); + auto &vtable = vtables[vtableName]; + for (auto [position, field] : llvm::enumerate(vtable.type.getFields())) + { + if (position == 0) + { + continue; + } + + auto &family = interfaceFamilies[{name.str(), static_cast(position)}]; + auto id = mlir::dyn_cast_or_null(field.id); + auto member = vtable.slots.lookup(position); + auto funcOp = member ? functions.lookup(member.getValue()) : mlir_ts::FuncOp(); + if (!family.field) + { + family.field = id; + } + + // a member that is not private (a `public` method) is open (findOpen): it has no + // facts to give, and gives the family none + family.closed = family.closed && closed && !vtable.written && id && family.field == id && funcOp && + !funcOp.isDeclaration() && !declaresCallerFacts(funcOp); + if (member && !llvm::is_contained(family.members, member)) + { + family.members.push_back(member); + } + } + } + } + + // what an open family holds, and a vtable no interface value is made with, is reached + // through calls that are not resolved + auto reached = [&](mlir::StringAttr member) { + if (auto funcOp = member ? functions.lookup(member.getValue()) : mlir_ts::FuncOp()) + { + interfaceReached.insert(funcOp); + } + }; + + for (auto &[key, family] : interfaceFamilies) + { + if (!family.closed) + { + llvm::for_each(family.members, reached); + } + } + + for (auto &entry : vtables) + { + if (!tied.contains(entry.getKey())) + { + for (auto &slot : entry.second.slots) + { + reached(slot.second); + } + } + } + } static constexpr const char *INTERFACE_REACHED_WHY = "it is reached through an interface, whose calls are not given its facts"; @@ -1366,50 +2056,7 @@ class OwnershipSignaturePass : public mlir::PassWrapper...vtbl`, where a class's own is `..vtbl`. - void collectInterfaceReached(mlir::ModuleOp module) - { - module.walk([&](mlir_ts::GlobalOp globalOp) { - auto name = globalOp.getSymName(); - if (!name.ends_with("..vtbl")) - { - return; - } - - auto owner = name.drop_back(llvm::StringRef("..vtbl").size()); - llvm::SmallVector methods; - auto ofClass = false; - auto ownClass = false; - globalOp.getInitializerRegion().walk([&](mlir_ts::SymbolRefOp symbolRefOp) { - auto symbol = symbolRefOp.getIdentifierAttr().getAttr(); - if (symbol.getValue().ends_with("..instanceOf")) - { - ofClass = true; - ownClass = ownClass || symbol.getValue().drop_back(llvm::StringRef("..instanceOf").size()) == owner; - return; - } - - methods.push_back(symbol); - }); - - if (!ofClass || ownClass) - { - return; - } - - for (auto method : methods) - { - if (auto funcOp = functions.lookup(method.getValue())) - { - interfaceReached.insert(funcOp); - } - } - }); - } - - // Does a method reached through an interface keep a parameter or return a borrow + // Does a method reached through an open interface keep a parameter or return a borrow // (interfaceReached)? Then its callers through the interface cannot be told, and it has no facts // at all. bool factsThroughInterface(mlir::Operation *funcOp, const Facts &facts) @@ -1580,6 +2227,12 @@ class OwnershipSignaturePass : public mlir::PassWrapper(op)) { resolveValue(calleeValue(op), callees); + // an iterator's `next()` is fresh whoever implements it (isIteratorNext), which its + // family's facts must not contradict + if (callees.viaInterface && isIteratorNext(op)) + { + callees = Callees(); + } } if (!callees.known) @@ -1659,6 +2312,11 @@ class OwnershipSignaturePass : public mlir::PassWrapper(def)) + { + resolveInterfaceSlot(interfaceRefOp, callees); + return; + } if (!identifier) { @@ -1676,6 +2334,34 @@ class OwnershipSignaturePass : public mlir::PassWrapper(refOp.getInterfaceVal().getType()); + auto identifier = mlir::dyn_cast(refOp.getIdentifier()); + if (!interfaceType || !identifier) + { + return; + } + + auto position = static_cast(refOp.getIndex()) + INTERFACE_VTABLE_HEADER_SLOTS; + auto found = interfaceFamilies.find({interfaceType.getName().getValue().str(), position}); + if (found == interfaceFamilies.end() || !found->second.closed || found->second.field != identifier || + found->second.members.empty()) + { + return; + } + + callees.named.assign(found->second.members.begin(), found->second.members.end()); + callees.viaInterface = true; + callees.known = true; + for (auto member : found->second.members) + { + callees.known = addDefined(member, callees) && callees.known; + } + } + // A class another module can see may be extended there, and its override is a candidate this // module never sees: every member has to be private and defined here. void resolveFamily(int64_t index, mlir::StringAttr identifier, Callees &callees) @@ -2087,30 +2773,47 @@ class OwnershipSignaturePass : public mlir::PassWrapper members) { + llvm::SmallVector seen; + for (auto member : members) + { + auto funcOp = functions.lookup(member.getValue()); + seen.push_back(funcOp ? factsEffective.lookup(funcOp) : Facts()); + } + + auto changed = false; + if (llvm::all_equal(seen)) + { + return changed; + } + + for (auto member : members) + { + if (auto funcOp = functions.lookup(member.getValue()); + funcOp && !declaresCallerFacts(funcOp) && factsEffective.erase(funcOp)) + { + changed = true; + } + } + + return changed; + }; + for (auto changed = true; changed;) { changed = false; for (auto &[key, members] : families) { - llvm::SmallVector seen; - for (auto member : members) - { - auto funcOp = functions.lookup(member.getValue()); - seen.push_back(funcOp ? factsEffective.lookup(funcOp) : Facts()); - } - - if (llvm::all_equal(seen)) - { - continue; - } + changed = demote(members) || changed; + } - for (auto member : members) + for (auto &[key, family] : interfaceFamilies) + { + if (family.closed) { - if (auto funcOp = functions.lookup(member.getValue()); - funcOp && !declaresCallerFacts(funcOp) && factsEffective.erase(funcOp)) - { - changed = true; - } + changed = demote(family.members) || changed; } } } diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index cf060e6e9..145a49e1e 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3481,7 +3481,8 @@ foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local o own_param_markers own_generator_locals own_string_param_assigned own_lends own_immortal_global own_immortal_global_region own_literal_moves own_record_literal_given - own_field_chain_let own_read_after_move own_conditional_move) + own_field_chain_let own_read_after_move own_conditional_move + own_interface_facts) 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. @@ -3538,6 +3539,13 @@ foreach(conditional_move_mm rc gc none) tslang_add_test(NAME test-compile-${conditional_move_mm}-own_conditional_move COMMAND test-runner -mm=${conditional_move_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_conditional_move.ts") endforeach() +# A call through an interface closed in its module gets its implementations' facts (spec 31); the +# same program under every other model. +foreach(interface_facts_mm rc gc none) + tslang_add_test(NAME test-jit-${interface_facts_mm}-own_interface_facts COMMAND test-runner -jit -mm=${interface_facts_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_interface_facts.ts") + tslang_add_test(NAME test-compile-${interface_facts_mm}-own_interface_facts COMMAND test-runner -mm=${interface_facts_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_interface_facts.ts") +endforeach() + # A global declared in top-level code owns what it is given, under every model that counts. foreach(global_mm rc none gc) tslang_add_test(NAME test-jit-${global_mm}-own-global-script COMMAND test-runner -jit -mm=${global_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_global_script.ts") @@ -3768,6 +3776,11 @@ set(own_error_cases "own_err_interface_borrow_param|is a parameter, returned here, but -mm=own cannot tell the callers of 'G.get' that the result borrows it" "own_err_interface_borrow_element|borrows an element and cannot be stored, returned or captured" "own_err_interface_borrow_field|borrows a field and cannot be stored, returned or captured" + "own_err_interface_exported_impl|borrows .* but is used here after it may be released or overwritten" + "own_err_interface_family_disagrees|is a parameter, returned here, but -mm=own cannot tell the callers of 'GA.get' that the result borrows it" + "own_err_interface_member_drops|borrows a field but is used here after it may be released or overwritten" + "own_err_interface_from_interface|is a parameter, returned here, but -mm=own cannot tell the callers of 'G.get' that the result borrows it" + "own_err_interface_result_outlives|borrows a call's result but is used here after it may be released or overwritten" "own_err_string_kept_param_known|is used here after its value was moved" "own_err_keeps_mismatch|'@keeps' of 'Pair.constructor' names 'a', but its body keeps 'b'" "own_err_borrows_not_generator|'@borrows' of 'copyOf' names 'w', but it neither makes a generator whose state borrows its arguments nor returns one" diff --git a/tslang/test/tester/own/own_err_interface_borrow_element.ts b/tslang/test/tester/own/own_err_interface_borrow_element.ts index 8cc77e53e..f319b6394 100644 --- a/tslang/test/tester/own/own_err_interface_borrow_element.ts +++ b/tslang/test/tester/own/own_err_interface_borrow_element.ts @@ -1,11 +1,11 @@ -// -mm=own rejects: a method whose result borrows an element of `this`, reached through an -// interface. The call through the interface is not given the method's facts, so the caller would -// release the borrow as its own (a double free). Until facts reach interface calls (G9), the -// method's body is the error. +// -mm=own rejects: a method whose result borrows an element of `this`, reached through an exported +// interface. Another module may call through the interface without the method's facts, and would +// release the borrow as its own (a double free), so the call is open and the method's body is the +// error. The same program with the interface not exported compiles (own_interface_facts, spec 31). class V { constructor(public x: number) {} } -interface First { first(): V; } +export interface First { first(): V; } class F implements First { items: V[] = [new V(3)]; first(): V { return this.items[0]; } } function main() { const f = new F(); diff --git a/tslang/test/tester/own/own_err_interface_borrow_field.ts b/tslang/test/tester/own/own_err_interface_borrow_field.ts index 2e0098ea5..5853e5711 100644 --- a/tslang/test/tester/own/own_err_interface_borrow_field.ts +++ b/tslang/test/tester/own/own_err_interface_borrow_field.ts @@ -1,11 +1,11 @@ -// -mm=own rejects: a method whose result borrows a field of `this`, reached through an interface. -// The call through the interface is not given the method's facts, so the caller would release the -// borrow as its own (a double free). Until facts reach interface calls (G9), the method's body is -// the error. +// -mm=own rejects: a method whose result borrows a field of `this`, reached through an exported +// interface. Another module may call through the interface without the method's facts, and would +// release the borrow as its own (a double free), so the call is open and the method's body is the +// error. The same program with the interface not exported compiles (own_interface_facts, spec 31). class V { constructor(public x: number) {} } -interface Holder { hold(): V; } +export interface Holder { hold(): V; } class HH implements Holder { v: V = new V(4); hold(): V { return this.v; } } function use(h: Holder) { const x = h.hold(); return x.x; } function main() { diff --git a/tslang/test/tester/own/own_err_interface_borrow_param.ts b/tslang/test/tester/own/own_err_interface_borrow_param.ts index 981a682a0..edde63ece 100644 --- a/tslang/test/tester/own/own_err_interface_borrow_param.ts +++ b/tslang/test/tester/own/own_err_interface_borrow_param.ts @@ -1,11 +1,11 @@ -// -mm=own rejects: a method whose result borrows its argument, reached through an interface. The -// call through the interface is not given the method's facts, so the caller would release the -// borrow as its own (a double free). Until facts reach interface calls (G9), the method's body is -// the error. +// -mm=own rejects: a method whose result borrows its argument, reached through an exported +// interface. Another module may call through the interface without the method's facts, and would +// release the borrow as its own (a double free), so the call is open and the method's body is the +// error. The same program with the interface not exported compiles (own_interface_facts, spec 31). class V { constructor(public x: number) {} } -interface Getter { get(v: V): V; } +export interface Getter { get(v: V): V; } class G implements Getter { get(v: V): V { return v; } } function main() { const g: Getter = new G(); diff --git a/tslang/test/tester/own/own_err_interface_exported_impl.ts b/tslang/test/tester/own/own_err_interface_exported_impl.ts new file mode 100644 index 000000000..c536f4655 --- /dev/null +++ b/tslang/test/tester/own/own_err_interface_exported_impl.ts @@ -0,0 +1,18 @@ +// -mm=own rejects: the plan's closed-interface program (own_interface_facts) with one implementation +// exported. Another module may extend `B` or implement `Th` and hand this module the value, so a call +// through `Th` may reach a function this module never sees: it gets no facts, may destroy anything, +// and the borrow of `sc.ts` it is made through ends at it. +class V { + constructor(public x: number) {} + static plus(a: V, b: V) { return new V(a.x + b.x); } +} +interface Th { area(v: V): number; } +class A implements Th { area(v: V) { return v.x; } } +export class B implements Th { area(v: V) { return v.x * 2; } } +class Sc { ts: Th[] = [new A(), new B()]; } +function tot(sc: Sc, r: V) { + let best: number = 0; + for (let i in sc.ts) { let a = sc.ts[i].area(r); if (a > best) best = a; } + return best + r.x; +} +function main() { print(tot(new Sc(), new V(3))); print("done."); } diff --git a/tslang/test/tester/own/own_err_interface_family_disagrees.ts b/tslang/test/tester/own/own_err_interface_family_disagrees.ts new file mode 100644 index 000000000..06f22bc7c --- /dev/null +++ b/tslang/test/tester/own/own_err_interface_family_disagrees.ts @@ -0,0 +1,16 @@ +// -mm=own rejects: two implementations of a closed interface that disagree - one returns a borrow of +// its argument, the other a value of its own. A call through the interface cannot be told which, so +// it is given neither, and the one that borrows has to say so to its callers (spec 31). +class V { + constructor(public x: number) {} +} +interface Getter { get(v: V): V; } +class GA implements Getter { get(v: V): V { return v; } } +class GB implements Getter { get(v: V): V { return new V(v.x + 1); } } +function main() { + const gs: Getter[] = [new GA(), new GB()]; + const a = new V(5); + let s: number = 0; + for (const g of gs) { const b = g.get(a); s += b.x; } + print(s, a.x); +} diff --git a/tslang/test/tester/own/own_err_interface_from_interface.ts b/tslang/test/tester/own/own_err_interface_from_interface.ts new file mode 100644 index 000000000..0444c38d5 --- /dev/null +++ b/tslang/test/tester/own/own_err_interface_from_interface.ts @@ -0,0 +1,15 @@ +// -mm=own rejects: a `Th` value made out of a `J` value reaches `J`'s implementations through `Th`'s +// slots, which this module does not tie to `Th`. Both interfaces are open, and `G.get`, which returns +// a borrow of its argument, cannot tell the callers through either (spec 31). +class V { + constructor(public x: number) {} +} +interface Th { get(v: V): V; } +interface J extends Th { n(): number; } +class G implements J { n(): number { return 1; } get(v: V): V { return v; } } +function via(t: Th, a: V) { const b = t.get(a); return b.x; } +function main() { + const j: J = new G(); + const a = new V(5); + print(via(j, a), a.x); +} diff --git a/tslang/test/tester/own/own_err_interface_keeps_literal.ts b/tslang/test/tester/own/own_err_interface_keeps_literal.ts index 8134c55a2..e7c6aacbf 100644 --- a/tslang/test/tester/own/own_err_interface_keeps_literal.ts +++ b/tslang/test/tester/own/own_err_interface_keeps_literal.ts @@ -1,12 +1,12 @@ -// -mm=own rejects: a method that keeps its argument, reached through an interface. The call -// through the interface is not given the method's facts, so the caller would release the literal -// the method keeps (a double free). Until facts reach interface calls (G9), the method's body is -// the error. +// -mm=own rejects: a method that keeps its argument, reached through an exported interface. Another +// module may call through the interface without the method's facts, and would release the literal +// the method keeps (a double free), so the call is open and the method's body is the error. The same +// program with the interface not exported compiles (own_interface_facts, spec 31). class V { constructor(public x: number) {} } interface Ray { start: V; dir: V; } -interface Sink { take(r: Ray): void; } +export interface Sink { take(r: Ray): void; } class KS implements Sink { rays: Ray[] = []; take(r: Ray) { this.rays.push(r); } } function main() { const ks = new KS(); diff --git a/tslang/test/tester/own/own_err_interface_keeps_param.ts b/tslang/test/tester/own/own_err_interface_keeps_param.ts index 3b933ddda..95f8e481b 100644 --- a/tslang/test/tester/own/own_err_interface_keeps_param.ts +++ b/tslang/test/tester/own/own_err_interface_keeps_param.ts @@ -1,11 +1,11 @@ -// -mm=own rejects: a method that keeps its argument, reached through an interface. The call -// through the interface is not given the method's facts, so the caller would release the value -// the method keeps (a double free). Until facts reach interface calls (G9), the method's body is -// the error. +// -mm=own rejects: a method that keeps its argument, reached through an exported interface. Another +// module may call through the interface without the method's facts, and would release the value the +// method keeps (a double free), so the call is open and the method's body is the error. The same +// program with the interface not exported compiles (own_interface_facts, spec 31). class V { constructor(public x: number) {} } -interface SinkV { take(v: V): void; } +export interface SinkV { take(v: V): void; } class KV implements SinkV { vs: V[] = []; take(v: V) { this.vs.push(v); } } function main() { const ks = new KV(); diff --git a/tslang/test/tester/own/own_err_interface_member_drops.ts b/tslang/test/tester/own/own_err_interface_member_drops.ts new file mode 100644 index 000000000..070c1be17 --- /dev/null +++ b/tslang/test/tester/own/own_err_interface_member_drops.ts @@ -0,0 +1,18 @@ +// -mm=own rejects: one implementation of a closed interface overwrites a field of its argument, so a +// call through the interface may destroy what `c` borrows (spec 31). +class V { + constructor(public x: number) {} +} +class H { c: V = new V(5); } +interface Op { run(h: H): number; } +class OA implements Op { run(h: H): number { return h.c.x; } } +class OB implements Op { run(h: H): number { h.c = new V(9); return 0; } } +function f(o: Op, h: H) { + const c = h.c; + o.run(h); + return c.x; +} +function main() { + const h = new H(); + print(f(new OA(), h), f(new OB(), h)); +} diff --git a/tslang/test/tester/own/own_err_interface_result_outlives.ts b/tslang/test/tester/own/own_err_interface_result_outlives.ts new file mode 100644 index 000000000..b666e942e --- /dev/null +++ b/tslang/test/tester/own/own_err_interface_result_outlives.ts @@ -0,0 +1,13 @@ +// -mm=own rejects: the result of a call through a closed interface borrows an element of the object +// behind the interface value, and the local holding that value is given a new one before the read. +class V { + constructor(public x: number) {} +} +interface First { first(): V; } +class F implements First { items: V[] = [new V(3)]; first(): V { return this.items[0]; } } +function main() { + let i: First = new F(); + const x = i.first(); + i = new F(); + print(x.x); +} diff --git a/tslang/test/tester/own/own_interface_facts.ts b/tslang/test/tester/own/own_interface_facts.ts new file mode 100644 index 000000000..4a5c53fe7 --- /dev/null +++ b/tslang/test/tester/own/own_interface_facts.ts @@ -0,0 +1,125 @@ +// -mm=own: a call through an interface that only this module can see (closed: not exported, every +// implementation defined here) gets the facts its implementations agree on, as a call of a private +// class family does (spec 31). The call destroys nothing when no implementation does, so a borrow +// of the argument's field or of an element of the container the receiver came from lives across +// it; a method that keeps its argument is given it (the caller moves it in), and a result that +// borrows an argument or `this` is the caller's to read, not to release. A direct call of such a +// method keeps its facts too. +class V { + constructor(public x: number) {} + static plus(a: V, b: V) { return new V(a.x + b.x); } +} + +function churn() { + let s: number = 0; + for (let i = 0; i < 2000; i++) { + const t = new V(77777); + s += t.x; + } + return s; +} + +// the plan's shape: the loop reads an element of `sc.ts` and calls through it with `r` +interface Th { area(v: V): number; } +class A implements Th { area(v: V) { return v.x; } } +class B implements Th { area(v: V) { return v.x * 2; } } +class Sc { ts: Th[] = [new A(), new B()]; } +function tot(sc: Sc, r: V) { + let best: number = 0; + for (let i in sc.ts) { let a = sc.ts[i].area(r); if (a > best) best = a; } + return best + r.x; +} + +// a borrow of a field held across a call through an interface whose members destroy nothing +class H { c: V = new V(5); } +interface Op { run(h: H): number; } +class OA implements Op { run(h: H): number { return h.c.x; } } +class OB implements Op { run(h: H): number { return V.plus(h.c, h.c).x; } } +function across(o: Op, h: H) { + const c = h.c; + const n = o.run(h); + churn(); + return c.x + n; +} + +// an object literal implementing the interface is a member like a class +function literalMember(h: H) { + const ops: Op[] = [new OA(), { run(h: H): number { return h.c.x * 3; } }]; + let s: number = 0; + for (const o of ops) s += across(o, h); + return s; +} + +// a method that keeps its argument: a literal, a const, a fresh value +interface Ray { start: V; dir: V; } +interface Sink { take(r: Ray): void; } +class KS implements Sink { rays: Ray[] = []; take(r: Ray) { this.rays.push(r); } } +interface SinkV { take(v: V): void; } +class KV implements SinkV { vs: V[] = []; take(v: V) { this.vs.push(v); } } + +// a result that borrows its argument, an element of `this`, a field of `this` +interface Getter { get(v: V): V; } +class G implements Getter { get(v: V): V { return v; } } +interface First { first(): V; } +class F implements First { items: V[] = [new V(3)]; first(): V { return this.items[0]; } } +interface Holder { hold(): V; } +class HH implements Holder { v: V = new V(4); hold(): V { return this.v; } } +function use(h: Holder) { const x = h.hold(); return x.x; } + +function main() { + print(tot(new Sc(), new V(3))); + assert(tot(new Sc(), new V(3)) == 9, "the interface call's facts"); + + const h = new H(); + assert(across(new OA(), h) == 10, "a field borrowed across the call"); + assert(across(new OB(), h) == 15, "a field borrowed across the other member"); + assert(literalMember(h) == 30, "a literal member"); + + const ks = new KS(); + const s: Sink = ks; + s.take({ start: new V(1), dir: new V(2) }); + const r: Ray = { start: new V(3), dir: new V(4) }; + s.take(r); + const kv = new KV(); + const sv: SinkV = kv; + sv.take(new V(5)); + const direct = new KV(); + direct.take(new V(6)); + churn(); + assert(ks.rays[0].start.x == 1 && ks.rays[0].dir.x == 2, "a kept literal"); + assert(ks.rays[1].start.x == 3 && ks.rays[1].dir.x == 4, "a kept const"); + assert(kv.vs[0].x == 5, "a kept value"); + assert(direct.vs[0].x == 6, "a kept value, called directly"); + + const g: Getter = new G(); + const a = new V(5); + { + const b = g.get(a); + assert(b.x == 5, "a borrowed argument"); + } + const gd = new G(); + { + const b = gd.get(a); + assert(b.x == 5, "a borrowed argument, called directly"); + } + + const f = new F(); + const i: First = f; + { + const x = i.first(); + assert(x.x == 3, "a borrowed element"); + const y = f.first(); + assert(y.x == 3, "a borrowed element, called directly"); + } + + const hh = new HH(); + assert(use(hh) == 4, "a borrowed field"); + assert(hh.hold().x == 4, "a borrowed field, called directly"); + + churn(); + const c = new V(9); + assert(a.x == 5 && f.items[0].x == 3 && hh.v.x == 4 && c.x == 9, "what the borrows read is still there"); + print(a.x, f.items[0].x, hh.v.x, c.x); + + print("done."); +} From 15e95345e3bbd6b4b5049e205033bbcb9600d483 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 12:08:08 +0100 Subject: [PATCH 21/57] own-raytrace T6: -mm=own: a call through a closed interface's function field gets its functions' facts `p.s.f(3)`, `g.value.f(3)` with `f: (x: number) => number` an interface field: the slot of each of the interface's vtables holds the offset of an object literal's field, and the call goes to whatever function that field holds. When the interface is closed and every function ever stored into those fields is one this module defines - the literal's own (its ts.Constant), or one stored later through the object or through the interface - the call is resolved to them and gets their facts (__own_no_drops when none destroys anything). A store of anything else, the field reached other than to be read or stored, an object of the literal's type made other than by the literal, a function-typed store through an open interface, or a class implementer's thunk cell keeps the call open. Exposure through a type is now read from exported and declared functions only: a `public` method of a class no other module can name does not expose the interfaces its class's fields mention. own_interface_fn_field (gc, rc, none, own; through a global and a local Shared of a literal and through a parameter, each after churn). Negatives: a field function that overwrites a field of its object, one of unknown origin (a parameter), an exported interface. Co-Authored-By: Claude Opus 5.5 --- .../lib/TypeScript/OwnershipSignaturePass.cpp | 329 +++++++++++++++++- tslang/test/tester/CMakeLists.txt | 18 + .../own/own_err_interface_fn_field_drops.ts | 16 + .../own_err_interface_fn_field_exported.ts | 9 + .../own/own_err_interface_fn_field_unknown.ts | 11 + .../test/tester/own/own_interface_fn_field.ts | 55 +++ 6 files changed, 435 insertions(+), 3 deletions(-) create mode 100644 tslang/test/tester/own/own_err_interface_fn_field_drops.ts create mode 100644 tslang/test/tester/own/own_err_interface_fn_field_exported.ts create mode 100644 tslang/test/tester/own/own_err_interface_fn_field_unknown.ts create mode 100644 tslang/test/tester/own/own_interface_fn_field.ts diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index 116fd535f..c43e61446 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -657,8 +657,8 @@ class OwnershipSignaturePass : public mlir::PassWrapper` - // in the type of a function or global another module can reach - a public or declared - // function, an exported or imported global; + // in the type of a function or global another module can reach - an exported or declared + // function, a global that is not private, or is exported or imported; // - an I value made other than by `ts.NewInterface` over a vtable this pass reads (the global, a // heap copy of it, or the one an object's class vtable holds for I): a cast from another // interface (`J` to `I`, which opens `J` too: its members are reached through `I`), from @@ -682,6 +682,253 @@ class OwnershipSignaturePass : public mlir::PassWrapper the functions every vtable of the interface holds there std::map, InterfaceFamily> interfaceFamilies; + // A field of function type (`f: (x: number) => number`), called through the interface: the slot + // holds the offset of a field of each implementing object literal, and the call goes to whatever + // function that field holds. When every vtable's slot is such an offset and every function ever + // stored into those fields is one this module defines - the literal's own (`{ f: function ... }`, + // in its `ts.Constant`), or one stored into the field later, through the object or through the + // interface - the call is resolved to them (spec 31). A store of anything else (a parameter, a + // call's result, a closure), a field reached other than to be read or stored, or an object of + // the literal's type made other than by the literal opens the slot. A class implementing the + // field holds a thunk's cell, which this does not follow: open. + struct InterfaceFieldFamily + { + mlir::StringAttr field; + // (object literal's storage tuple, field index) of each vtable + llvm::SmallVector, 2> targets; + llvm::SmallVector members; + bool closed = true; + }; + + std::map, InterfaceFieldFamily> interfaceFieldFamilies; + + // `ts.PropertyRef(ts.Cast(null), i)`, seen through casts: the offset of field `i` of an object of + // that type, as an object literal's interface vtable holds it. Null for anything else. + std::pair slotField(mlir::Value value) + { + while (auto castOp = value ? value.getDefiningOp() : mlir_ts::CastOp()) + { + value = castOp.getIn(); + } + + auto propertyRefOp = value ? value.getDefiningOp() : mlir_ts::PropertyRefOp(); + auto nullObject = propertyRefOp ? propertyRefOp.getObjectRef().getDefiningOp() : mlir_ts::CastOp(); + if (!nullObject || !nullObject.getIn().getDefiningOp() || + !mlir::isa(nullObject.getType())) + { + return {}; + } + + auto tupleType = storageTupleOf(nullObject.getType()); + if (!tupleType) + { + return {}; + } + + return {tupleType, static_cast(propertyRefOp.getPosition())}; + } + + // What every object-literal field an interface's field slot may be holds (InterfaceFieldFamily). + void collectFieldFunctions(mlir::ModuleOp module) + { + llvm::DenseMap, llvm::SetVector> stored; + llvm::DenseSet> unknownFields; + llvm::DenseSet unknownTypes; + // a function of unknown origin stored through an interface this pass cannot follow + auto unknownAnywhere = false; + + auto store = [&](std::pair key, mlir::Value value) { + auto symbol = slotFunction(value); + auto funcOp = symbol ? functions.lookup(symbol.getValue()) : mlir_ts::FuncOp(); + if (funcOp && !funcOp.isDeclaration()) + { + stored[key].insert(symbol); + } + else + { + unknownFields.insert(key); + } + }; + + auto isFunctionType = [](mlir::Type type) { + return mlir::isa(type); + }; + + module.walk([&](mlir::Operation *op) { + // the literal's own values + if (auto constantOp = mlir::dyn_cast(op)) + { + auto constTupleType = mlir::dyn_cast(constantOp.getType()); + auto values = mlir::dyn_cast(constantOp.getValue()); + if (!constTupleType || !values) + { + return; + } + + mlir::Type tupleType = mlir_ts::TupleType::get(&getContext(), constTupleType.getFields()); + for (auto [index, value] : llvm::enumerate(values)) + { + std::pair key{tupleType, static_cast(index)}; + if (auto symbol = mlir::dyn_cast(value)) + { + auto funcOp = functions.lookup(symbol.getValue()); + if (funcOp && !funcOp.isDeclaration()) + { + stored[key].insert(symbol.getAttr()); + } + else + { + unknownFields.insert(key); + } + } + else if (!mlir::isa(value) && index < constTupleType.getFields().size() && + isFunctionType(constTupleType.getFields()[index].type)) + { + unknownFields.insert(key); + } + } + + return; + } + + // a store into the field through the object + if (auto propertyRefOp = mlir::dyn_cast(op)) + { + auto tupleType = storageTupleOf(propertyRefOp.getObjectRef().getType()); + if (!tupleType) + { + return; + } + + std::pair key{tupleType, static_cast(propertyRefOp.getPosition())}; + for (auto &use : propertyRefOp->getUses()) + { + auto *user = use.getOwner(); + if (mlir::isa(user)) + { + continue; + } + + auto storeOp = mlir::dyn_cast(user); + if (storeOp && storeOp.getReference() == propertyRefOp.getResult() && storeOp.getValue() != propertyRefOp.getResult()) + { + store(key, storeOp.getValue()); + continue; + } + + // an interface vtable's offset of the field (slotField) reads nothing + if (user->getParentOfType() && mlir::isa(user)) + { + continue; + } + + unknownFields.insert(key); + } + + return; + } + + // a store into the field through an interface: into each literal its vtables reach + if (auto interfaceRefOp = mlir::dyn_cast(op)) + { + auto refType = mlir::dyn_cast(interfaceRefOp.getType()); + if (!refType || !isFunctionType(refType.getElementType())) + { + return; + } + + auto interfaceType = mlir::dyn_cast(interfaceRefOp.getInterfaceVal().getType()); + auto found = interfaceType ? interfaceFieldFamilies.find({interfaceType.getName().getValue().str(), + static_cast(interfaceRefOp.getIndex()) + + INTERFACE_VTABLE_HEADER_SLOTS}) + : interfaceFieldFamilies.end(); + for (auto &use : interfaceRefOp->getUses()) + { + auto *user = use.getOwner(); + if (mlir::isa(user)) + { + continue; + } + + auto storeOp = mlir::dyn_cast(user); + if (storeOp && storeOp.getReference() == interfaceRefOp.getResult() && found != interfaceFieldFamilies.end() && + found->second.closed) + { + for (auto target : found->second.targets) + { + store(target, storeOp.getValue()); + } + + continue; + } + + unknownAnywhere = true; + } + + return; + } + + // an object of the literal's type made other than by the literal: its fields may hold + // anything. A cast keeps the type's own value (the literal's box seen as the object, an + // object, a reference to its storage) or makes the null object a vtable's offsets are + // taken from; from anything else it reinterprets. + if (auto castOp = mlir::dyn_cast(op)) + { + auto tupleType = storageTupleOf(castOp.getType()); + if (tupleType && !mlir::isa(castOp.getIn().getType()) && + storageTupleOf(castOp.getIn().getType()) != tupleType) + { + unknownTypes.insert(tupleType); + } + + return; + } + + if (mlir::isa(op) || isCall(op) || isView(op) || mlir::isa(op)) + { + return; + } + + for (auto result : op->getResults()) + { + if (mlir::isa(result.getType())) + { + if (auto tupleType = storageTupleOf(result.getType())) + { + unknownTypes.insert(tupleType); + } + } + } + }); + + for (auto &[key, family] : interfaceFieldFamilies) + { + if (!family.closed) + { + continue; + } + + for (auto target : family.targets) + { + if (unknownAnywhere || unknownTypes.contains(target.first) || unknownFields.contains(target)) + { + family.closed = false; + break; + } + + for (auto member : stored.lookup(target)) + { + if (!llvm::is_contained(family.members, member)) + { + family.members.push_back(member); + } + } + } + + family.closed = family.closed && !family.members.empty(); + } + } + // The interfaces mentioned by a type as values: the interface itself, a union or optional, a // tuple's fields, an array's elements, what a reference points to. Not a class's or an object's // fields (a reference to a block made elsewhere), nor a function's parameters and result. @@ -987,6 +1234,8 @@ class OwnershipSignaturePass : public mlir::PassWrapper slots; + // a field's slot: the object literal's field it is the offset of + llvm::DenseMap> fields; bool written = false; }; @@ -1053,6 +1302,10 @@ class OwnershipSignaturePass : public mlir::PassWrapper 0) { vtable.slots[position] = slotFunction(value); + if (auto field = slotField(value); field.first) + { + vtable.fields[position] = field; + } } } }); @@ -1225,7 +1478,9 @@ class OwnershipSignaturePass : public mlir::PassWrapper seen; for (auto &entry : functions) { - if (entry.second.isDeclaration() || !entry.second.isPrivate()) + // a `public` method of a class no other module can name is not one: only a declaration + // re-parsed from `__decls` names a function of another module + if (entry.second.isDeclaration() || entry.second->hasAttr("export") || entry.second->hasAttr(DLL_EXPORT)) { anyInterfaces(entry.second.getFunctionType(), exposed, seen); } @@ -1299,10 +1554,28 @@ class OwnershipSignaturePass : public mlir::PassWrapper(position)}]; + auto target = vtable.fields.lookup(position); + auto refType = mlir::dyn_cast(field.type); + if (!fieldFamily.field) + { + fieldFamily.field = id; + } + + fieldFamily.closed = fieldFamily.closed && closed && !vtable.written && id && fieldFamily.field == id && + target.first && refType && mlir::isa(refType.getElementType()); + if (target.first && !llvm::is_contained(fieldFamily.targets, target)) + { + fieldFamily.targets.push_back(target); + } } } } + collectFieldFunctions(module); + // what an open family holds, and a vtable no interface value is made with, is reached // through calls that are not resolved auto reached = [&](mlir::StringAttr member) { @@ -2317,6 +2590,20 @@ class OwnershipSignaturePass : public mlir::PassWrapper(def)) + { + // a function-typed field of an interface, called: `ts.CreateBoundFunction( + // ts.ExtractInterfaceThis(i), ts.Load(ts.InterfaceSymbolRef(i, f)))` + auto loadOp = boundOp.getFunc().getDefiningOp(); + auto refOp = loadOp ? loadOp.getReference().getDefiningOp() : mlir_ts::InterfaceSymbolRefOp(); + auto thisOp = boundOp.getThisVal().getDefiningOp(); + if (refOp && isInterfaceField(refOp.getResult()) && thisOp && thisOp.getInterfaceVal() == refOp.getInterfaceVal()) + { + resolveInterfaceField(refOp, callees); + } + + return; + } if (!identifier) { @@ -2362,6 +2649,34 @@ class OwnershipSignaturePass : public mlir::PassWrapper(refOp.getInterfaceVal().getType()); + auto identifier = mlir::dyn_cast(refOp.getIdentifier()); + if (!interfaceType || !identifier) + { + return; + } + + auto position = static_cast(refOp.getIndex()) + INTERFACE_VTABLE_HEADER_SLOTS; + auto found = interfaceFieldFamilies.find({interfaceType.getName().getValue().str(), position}); + if (found == interfaceFieldFamilies.end() || !found->second.closed || found->second.field != identifier || + found->second.members.empty()) + { + return; + } + + callees.named.assign(found->second.members.begin(), found->second.members.end()); + callees.viaInterface = true; + callees.known = true; + for (auto member : found->second.members) + { + callees.known = addDefined(member, callees) && callees.known; + } + } + // A class another module can see may be extended there, and its override is a candidate this // module never sees: every member has to be private and defined here. void resolveFamily(int64_t index, mlir::StringAttr identifier, Callees &callees) @@ -2816,6 +3131,14 @@ class OwnershipSignaturePass : public mlir::PassWrapper of a literal and through a parameter (spec 31). Counted like every Shared test, so not in +# own_test (its no-counting check). +foreach(fn_field_mm gc none rc own) + tslang_add_test(NAME test-jit-${fn_field_mm}-own_interface_fn_field COMMAND test-runner -jit -mm=${fn_field_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_interface_fn_field.ts") + tslang_add_test(NAME test-compile-${fn_field_mm}-own_interface_fn_field COMMAND test-runner -mm=${fn_field_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_interface_fn_field.ts") +endforeach() +foreach(fn_field_verify_mm own rc) + add_test(NAME test-${fn_field_verify_mm}-verify-ownership-own_interface_fn_field + COMMAND $ --emit=mlir-affine --no-default-lib -mm=${fn_field_verify_mm} --verify-ownership + "${PROJECT_SOURCE_DIR}/test/tester/own/own_interface_fn_field.ts") + set_tests_properties(test-${fn_field_verify_mm}-verify-ownership-own_interface_fn_field + PROPERTIES FAIL_REGULAR_EXPRESSION "error|Stack dump") +endforeach() + # Across a library boundary: the library exports its functions' `__own_no_drops`, and a borrow held # across a call to one survives. Run as a -shared pair (the library loaded at run time) and, on # Windows, checked against a library that lists nothing (own-shared-no-drops.cmake). @@ -3781,6 +3796,9 @@ set(own_error_cases "own_err_interface_member_drops|borrows a field but is used here after it may be released or overwritten" "own_err_interface_from_interface|is a parameter, returned here, but -mm=own cannot tell the callers of 'G.get' that the result borrows it" "own_err_interface_result_outlives|borrows a call's result but is used here after it may be released or overwritten" + "own_err_interface_fn_field_drops|borrows a field but is used here after it may be released or overwritten" + "own_err_interface_fn_field_unknown|borrows a field but is used here after it may be released or overwritten" + "own_err_interface_fn_field_exported|borrows a field but is used here after it may be released or overwritten" "own_err_string_kept_param_known|is used here after its value was moved" "own_err_keeps_mismatch|'@keeps' of 'Pair.constructor' names 'a', but its body keeps 'b'" "own_err_borrows_not_generator|'@borrows' of 'copyOf' names 'w', but it neither makes a generator whose state borrows its arguments nor returns one" diff --git a/tslang/test/tester/own/own_err_interface_fn_field_drops.ts b/tslang/test/tester/own/own_err_interface_fn_field_drops.ts new file mode 100644 index 000000000..cb86988f9 --- /dev/null +++ b/tslang/test/tester/own/own_err_interface_fn_field_drops.ts @@ -0,0 +1,16 @@ +// -mm=own rejects: a function-typed field of a closed interface whose function overwrites a field of +// the object it is called on. The call through the interface gets that function's facts (spec 31), +// and they say it may destroy what `v` borrows. +class V { + constructor(public x: number) {} +} +interface S { f: (x: number) => number; v: V; } +class P { s: S = { f: function (x: number) { this.v = new V(9); return x; }, v: new V(5) }; } +function use(p: P) { + const v = p.s.v; + p.s.f(1); + return v.x; +} +function main() { + print(use(new P())); +} diff --git a/tslang/test/tester/own/own_err_interface_fn_field_exported.ts b/tslang/test/tester/own/own_err_interface_fn_field_exported.ts new file mode 100644 index 000000000..ffbdb9d35 --- /dev/null +++ b/tslang/test/tester/own/own_err_interface_fn_field_exported.ts @@ -0,0 +1,9 @@ +// -mm=own rejects: a function-typed field of an exported interface. Another module may implement +// it with a function this module never sees, so the call through the field cannot be resolved and +// the borrow of `p.s` it is made through ends at it (spec 31). +export interface S { f: (x: number) => number; r: number; } +class P { s: S = { f: function (x: number) { return x + 1; }, r: 3 }; } +function h(p: P) { return p.s.f(3); } +function main() { + print(h(new P())); +} diff --git a/tslang/test/tester/own/own_err_interface_fn_field_unknown.ts b/tslang/test/tester/own/own_err_interface_fn_field_unknown.ts new file mode 100644 index 000000000..7903fb05f --- /dev/null +++ b/tslang/test/tester/own/own_err_interface_fn_field_unknown.ts @@ -0,0 +1,11 @@ +// -mm=own rejects: a function-typed field of an interface given a function of unknown origin (a +// parameter). The call through the field cannot be resolved, so it may destroy anything, and the +// borrow of `p.s` it is made through ends at it (spec 31). +interface S { f: (x: number) => number; r: number; } +function ten(x: number) { return x * 10; } +function mkS(fn: (x: number) => number): S { return { f: fn, r: 3 }; } +class P { s: S = mkS(ten); } +function h(p: P) { return p.s.f(3); } +function main() { + print(h(new P())); +} diff --git a/tslang/test/tester/own/own_interface_fn_field.ts b/tslang/test/tester/own/own_interface_fn_field.ts new file mode 100644 index 000000000..dcda172ef --- /dev/null +++ b/tslang/test/tester/own/own_interface_fn_field.ts @@ -0,0 +1,55 @@ +// -mm=own: a function-typed field of an interface closed in its module (`f: (x: number) => number`), +// called through the interface: every function ever stored into the fields its implementing object +// literals give it is known (spec 31), so the call gets their facts - here, that it destroys +// nothing - and the borrow of the object it is called on lives across it. Through a global +// `Shared` of a literal (the ray tracer's surfaces), a local one, and a parameter with no handle, +// each after churn() reuses freed memory: a literal freed while it is borrowed reads reused memory. +interface S { f: (x: number) => number; roughness: number; } +let g = new Shared({ f: function (x: number) { return x * 2; }, roughness: 2 }); + +class Junk { + a = -1; + b = -2; +} + +function churn() { + let keep: Junk[] = []; + for (let i = 0; i < 1000; i++) { + const j = new Junk(); + j.a = -i; + keep.push(j); + } + + return keep.length; +} + +class P { + s: S = { f: function (x: number) { return x + this.roughness; }, roughness: 3 }; +} + +function viaParam(p: P) { + return p.s.f(3); +} + +function viaHandle(h: Shared) { + return h.value.f(4) + h.value.roughness; +} + +function main() { + let l = new Shared({ f: function (x: number) { return x + 1; }, roughness: 3 }); + const p = new P(); + + assert(churn() == 1000); + assert(g.value.f(3) == 6, "through the global's handle"); + assert(churn() == 1000); + assert(l.value.f(3) == 4, "through the local's handle"); + assert(churn() == 1000); + assert(viaParam(p) == 6, "through a parameter"); + assert(churn() == 1000); + assert(viaHandle(g) == 10 && viaHandle(l) == 8, "through a handle given as a parameter"); + assert(churn() == 1000); + assert(g.value.roughness == 2 && l.value.roughness == 3 && p.s.roughness == 3, "the literals are alive"); + + print(g.value.f(3), l.value.f(3), viaParam(p), viaHandle(g), viaHandle(l)); // 6 4 6 10 8 + print("done."); +} From f487ef462d39d50ef54effcc0f72600b2c943cc2 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 12:15:50 +0100 Subject: [PATCH 22/57] own-raytrace T6: spec 31, facts through a closed interface A vtable named for an interface that this pass could not read, or that no value of any interface is made with, opens the interface (a member outside its families would get their facts). A cast between references is judged by what they point to, so `bound_ref` seen as `ref` no longer counts as a reinterpretation. Spec 31: what a closed interface is, the families and their meet (agreement, a sound narrowing of the plan's union of kept parameters), function-typed fields, tests, teeth, measurements, the corpus, and why the ray tracer's errors stay at 31 (`sqrt`/`floor` declared without `@nodrops`, `Surface` exported by `export let` in `module Surfaces`). Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 100 ++++++++++++++++++ .../lib/TypeScript/OwnershipSignaturePass.cpp | 62 ++++++++++- 2 files changed, 161 insertions(+), 1 deletion(-) diff --git a/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md b/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md index 10ca68d76..68f89bd4f 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 @@ -3266,3 +3266,103 @@ are gone - `intersections` (`closestInter = inter` on some iterations) and `shad are G9's, G5's and G2's (§28): 21 `borrows a field`, 5 object-literal moves, 2 `borrows an element`, 1 mixed return, 1 kept parameter (the `reduce`), 1 `borrows a field and cannot be stored`. + +## 31. Facts through a closed interface (G9 of the ray tracer plan), 2026-10-22 + +A call through an interface (`ts.InterfaceSymbolRef`, then an indirect call) was not resolved: it +had no facts, so it might destroy anything, and since §27 every method held in an interface vtable +lost its keep and borrow facts, called directly or not. `sc.ts[i].area(r)` ended the borrow of +`sc.ts` it was made through (`borrows an element but is used here after it may be released or +overwritten`), and `s.take(x)` / `g.get(a)` / `i.first()` / `h.hold()` were the §27 errors in the +method's body. Now a call through an interface only this module can see is resolved like a call +of a private virtual family. + +- **Closed.** An interface is open when another module can see it - its name is a word of the + module's exported declarations (`__decls`), or `!ts.iface<@I>` is in the type of an exported or + declared function or of a global that is not private or is exported or imported (a class's + fields included) - or when a value of it may be made other than by `ts.NewInterface` over a + vtable the signature pass reads: the global, a heap copy of it, or the one an object's class + vtable holds for it. A cast into the interface from another interface opens both (the other's + members are reached through it); one from `any` or an opaque pointer opens every interface boxed + into one; a `ts.InterfaceRef` used as a value, a vtable read other than to make a value, an + exported class whose vtable holds one of its vtables, and a vtable named for it that no value is + made with all open it. An untraceable `ts.NewInterface` opens every interface. +- **Families.** Each slot of a closed interface (its vtable position) has the functions its + vtables hold there - a class's method, an object literal's method, never a declaration, never one + that says `@keeps` or `@lends`. A call through the slot gets `__own_no_drops` when every member + destroys nothing, and the kept parameters and borrowed result its members agree on: `meetFacts` + demotes a family that disagrees, as it does a class family, so the borrowing or keeping member's + body is the error it was (`own_err_interface_family_disagrees`). That is a sound narrowing of the + plan's "a parameter kept by any member is kept by the call": a member that does not keep a moved-in + argument has no release for it, which would leak. A `public` method is open (§15) and gives its + family no facts; an object literal's method is open too (its symbol is a value in the literal). +- **Members keep their facts.** §27's stripping (`interfaceReached`) applies only to the members of + open families and to vtables no value is made with, so a direct call of a method of a closed + interface keeps its facts (`ks.take(x)`, `f.first()`). +- **A result that borrows `this`** through the interface is bounded by the interface value: + `boundThis` sees `ts.GetThis` of an interface-bound method as the object the interface value is a + view of (`own_err_interface_result_outlives`: the local holding it is given a new value before the + read). +- **Function-typed fields** (`f: (x: number) => number`, the ray tracer's `Surface`): the slot holds + the offset of an object literal's field, and the call goes to whatever function the field holds. + When the interface is closed and every function ever stored into those fields is one this module + defines - the literal's own (`{ f: function ... }`, in its `ts.Constant`, a closure over locals + included: its captures are a field of the object), or one stored later through the object or + through the interface - the call gets their facts. A store of anything else, the field reached + other than to be read or stored, an object of the literal's type made other than by the literal, + a function-typed store through an open interface, or a class implementer (its slot is a thunk's + cell) keeps the call open. In the language as it is, a function stored into such a field is + always the literal's: `s.f = fn` through the interface is `saving to constant object`, and a + function without `this` (a parameter, a named function) is held through a thunk, which is open + (`own_err_interface_fn_field_unknown`). + +`own_interface_facts` runs the plan's loop over `sc.ts` (prints 9), a field borrowed across a call +through two class members and an object-literal member, the closed forms of the five §27 negatives +(a kept literal, a kept `const`, a kept value, through the interface and directly; a borrowed +argument, element and field, through the interface and directly), each read after churn, under own, +rc, gc and none, AOT and JIT, and on Linux; own emits no counting. `own_interface_fn_field` calls a +function field through a global and a local `Shared` of a literal, through a parameter with no +handle, and through a handle given as a parameter, each after churn, under the four models (counted, +so not in `own_test`), and `--verify-ownership` is quiet under own and rc. The five §27 negatives +now export their interface (the open form, same texts). New negatives: `own_err_interface_exported_impl` +(the plan's program with `export class B`), `own_err_interface_family_disagrees`, +`own_err_interface_member_drops`, `own_err_interface_from_interface`, +`own_err_interface_result_outlives`, `own_err_interface_fn_field_drops`, +`own_err_interface_fn_field_unknown`, `own_err_interface_fn_field_exported`. + +Teeth: with the call's `__own_params` dropped for a call through a closed interface, and with its +`__own_result_borrows` dropped, the AOT and JIT runs of `own_interface_facts` fail with `0xC0000374` +(heap corruption: the caller releases what `KS.take` kept, and what `G.get` lent). With +`__own_no_drops` given to a call through a family one of whose members overwrites a field of its +argument, `own_err_interface_member_drops` (with same-size allocations before the read) compiles and +prints 77777 instead of 5 (gc prints 5); the same for a function field whose function overwrites +`this.v` (`own_err_interface_fn_field_drops`): 77777 instead of 5. The store-of-unknown-origin +condition has no program that reaches it (above), so it has no tooth. + +Measured: 2 million iterations of a kept argument, a borrowed result and a function-field call +through closed interfaces peak at 4.4 MB under own, as under rc (gc 6.4 MB). + +Corpus (`test/tester/tests/*.ts` under `-mm=own --no-default-lib`, `--emit=obj`, Windows): 528 of +693 before this section and 528 after, the same files. + +The ray tracer (`rtbench_own.ts`) has 31 errors, as at §30: none of them is cleared, for two reasons +outside this rule. `Sphere.normal` and `Sphere.intersect` call `Math.sqrt`, whose `sqrt` is declared +without `@nodrops`, so they may destroy anything and the `Thing` families get no `__own_no_drops`; +`checkerboard`'s functions call `Math.floor` the same way. And `Surface` is exported: +`export let shiny` in `module Surfaces` puts the globals and `interface Surface` into `__decls`, so +another module may store its own function into a surface. Measured on copies: with the four C +declarations `@nodrops`, 22 errors before this section and 18 after (cleared: both errors at +`scene.things[i].intersect(ray)` in `intersections`, `isect.thing.normal(pos)` and the +`Vector.minus(d, ...)` after it in `shade`); with the `export`s dropped too, 22 and 14 (also +`thing.surface.value.reflect(pos)`, `diffuse(pos)`, `specular(pos)` and the `roughness` read); with +only the `export`s dropped, 31 and 30 (`specular(pos)`). Neither is one of the plan's two source +edits. + +Not here: +- **Kept parameters do not take the union** across members (above): a family whose members + disagree is demoted, as a class family is. +- **Class families group by (position, name)** across every vtable that holds an `.instanceOf`, + interface vtables included (unchanged): two closed interfaces with a same-named method at the same + position demote each other when they disagree. Too strict, but sound. +- A class implementing a function-typed field (`f = (x) => ...`) is held through a thunk's cell and + stays open. diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index c43e61446..66f21c66a 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -1154,7 +1154,33 @@ class OwnershipSignaturePass : public mlir::PassWrapper` as `!ts.ref`): what it points to + auto pointee = [](mlir::Type type) -> mlir::Type { + if (auto refType = mlir::dyn_cast(type)) + { + return refType.getElementType(); + } + + if (auto valueRefType = mlir::dyn_cast(type)) + { + return valueRefType.getElementType(); + } + + if (auto boundRefType = mlir::dyn_cast(type)) + { + return boundRefType.getElementType(); + } + + return {}; + }; + + if (auto outPointee = pointee(out)) + { + auto inPointee = pointee(in); + return !inPointee || castMakesInterface(inPointee, outPointee, depth + 1); + } + + // anything else reinterpreted: what is read out of it was never checked return true; } @@ -1240,6 +1266,8 @@ class OwnershipSignaturePass : public mlir::PassWrapper vtables; + // `..vtbl` globals that are neither (no `.instanceOf` this reads in slot 0) + llvm::SmallVector unread; // interface name -> the interface vtables class vtables hold for it, read through an // object's class at run time (`ts.VTableOffsetRef` of the object's vtable) llvm::StringMap> classHeld; @@ -1263,6 +1291,7 @@ class OwnershipSignaturePass : public mlir::PassWrapper(globalOp.getType()); if (!header || !tupleType) { + unread.push_back(name); return; } @@ -1292,6 +1321,7 @@ class OwnershipSignaturePass : public mlir::PassWrapper...vtbl`, `...vtbl`): the interface may have an + // implementation outside its families. Belt and braces - every way to make an interface + // value is traced above - but a missed member would get its family's facts. + llvm::DenseSet made; + for (auto &entry : madeWith) + { + made.insert(entry.second.begin(), entry.second.end()); + } + + llvm::SmallVector loose(unread.begin(), unread.end()); + for (auto &entry : vtables) + { + if (!made.contains(entry.getKey())) + { + loose.push_back(entry.getKey()); + } + } + + for (auto &entry : madeWith) + { + auto name = entry.getKey().str(); + if (llvm::any_of(loose, [&](llvm::StringRef vtableName) { + return vtableName.ends_with("." + name + "..vtbl") || vtableName.starts_with(name + "."); + })) + { + open.insert(entry.getKey()); + } + } + // the families llvm::DenseSet tied; for (auto &[name, made] : madeWith) From 8a94fc2cff1cd215eed114fe25e1f79a12b593c5 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 12:23:05 +0100 Subject: [PATCH 23/57] own-raytrace T6: spec 31, public members and namespace exports A `public` method is open (findOpen), so a family it is in agrees only on no facts; `export` inside a namespace exports the declaration from the module, so such an interface is open. Co-Authored-By: Claude Opus 5.5 --- .../specs/2026-09-24-own-memory-model-design.md | 8 ++++++++ 1 file changed, 8 insertions(+) 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 68f89bd4f..064c32bb4 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 @@ -3366,3 +3366,11 @@ Not here: position demote each other when they disagree. Too strict, but sound. - A class implementing a function-typed field (`f = (x) => ...`) is held through a thunk's cell and stays open. +- **A `public` method** is open (§15: a function that is not private may be called from another + module), so its effective facts are empty, and a family it is in can agree only on none: a + `public get(v) { return v; }` beside a private one returning the same borrow demotes both (the + ray tracer's `Plane.normal` and `Plane.intersect` are `public`). For type exposure this section + reads only exported and declared functions; `findOpen` does not, yet. +- **`export` inside a namespace** exports the declaration from the module (`__decls` lists `namespace + NS { interface Th ... }`), so an interface declared `export` in a namespace for use outside it is + open; one used only inside its namespace is closed. From 41dc9d5cade1356bfba95688a24c0d0c29f24613 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 13:24:10 +0100 Subject: [PATCH 24/57] own-raytrace T6: -mm=own: closed interfaces after review - reinterpreted functions, C scalars, public methods, executables Review fix round 1 of Task 6 (G9): - A function value reinterpreted - out of `any`/`unknown`, or as a function of another signature (`f2 as any as (o: Op, a: V) => number`, `(() => Op)` as `(() => Op2)`) - carries the interface values of its parameters and results across unseen: every interface its signatures mention is open, and those of a function boxed into `any` open with any unboxing (castReinterpretsFunction). A bound function's `this` and an opaque `this` are not part of the comparison. Negatives own_err_interface_fn_any_param / _result / _drops (review6 x1c, x1r, x1a). - (ruling) A body-less C declaration with no export or import marker whose parameters and result are numbers, booleans, characters or enums of them destroys nothing (scalarDeclaration): `sqrt`, `floor`, `pow`, `fabs`. Positive own_declared_scalar_no_drops. - (ruling) A `public` method of a class no other module can name - not exported, not a word of `__decls`, not in the type of an exported or declared function or an exported or imported global - is not callable from another module (callableFromOutside, used by findOpen and by class families). own_err_generator_method_this now exports its class to keep its meaning. Negative own_err_interface_public_exported; the public form is in own_interface_facts. - (USER decision) An executable's exports are seen by no other module: the pass takes `executable` (isExecutable or isJit; false when unknown), and then `__decls` words, exported functions and globals, and exported class vtables do not open an interface. Imports still do (a declared function, an imported global, a global with no initializer). own_interface_exe_exports runs under the JIT and as `--emit=exe` (own-exe-exports.cmake), and stays an error as an object file and a DLL. - (ruling) An iterator `next()` through a closed interface gets the family's facts like any other call; no fresh-result assumption for it. - own_interface_facts' churn keeps its allocations alive. Co-Authored-By: Claude Opus 5.5 --- tslang/include/TypeScript/Passes.h | 6 +- .../lib/TypeScript/OwnershipSignaturePass.cpp | 367 ++++++++++++++++-- tslang/test/tester/CMakeLists.txt | 25 +- tslang/test/tester/own-exe-exports.cmake | 61 +++ .../own/own_declared_scalar_no_drops.ts | 70 ++++ .../own/own_err_generator_method_this.ts | 5 +- .../own/own_err_interface_fn_any_drops.ts | 17 + .../own/own_err_interface_fn_any_param.ts | 18 + .../own/own_err_interface_fn_any_result.ts | 21 + .../own/own_err_interface_public_exported.ts | 15 + .../tester/own/own_interface_exe_exports.ts | 37 ++ tslang/test/tester/own/own_interface_facts.ts | 20 +- tslang/tslang/transform.cpp | 4 +- 13 files changed, 626 insertions(+), 40 deletions(-) create mode 100644 tslang/test/tester/own-exe-exports.cmake create mode 100644 tslang/test/tester/own/own_declared_scalar_no_drops.ts create mode 100644 tslang/test/tester/own/own_err_interface_fn_any_drops.ts create mode 100644 tslang/test/tester/own/own_err_interface_fn_any_param.ts create mode 100644 tslang/test/tester/own/own_err_interface_fn_any_result.ts create mode 100644 tslang/test/tester/own/own_err_interface_public_exported.ts create mode 100644 tslang/test/tester/own/own_interface_exe_exports.ts diff --git a/tslang/include/TypeScript/Passes.h b/tslang/include/TypeScript/Passes.h index c41180cb5..890ba6ce3 100644 --- a/tslang/include/TypeScript/Passes.h +++ b/tslang/include/TypeScript/Passes.h @@ -37,8 +37,10 @@ std::unique_ptr createOwnershipInferencePass(); /// The callee facts -mm=own infers across the module - a parameter the callee keeps, a result that /// borrows an argument, a callee that destroys nothing its caller can reach - pinned on each call it -/// resolves. Runs just before OwnershipInferencePass, under own only. -std::unique_ptr createOwnershipSignaturePass(); +/// resolves. Runs just before OwnershipInferencePass, under own only. `executable`: the module is +/// built into a program (an executable, or the JIT's entry module), whose exports no other module +/// sees; false when unknown. +std::unique_ptr createOwnershipSignaturePass(bool executable = false); /// 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 diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index 66f21c66a..a7a06a2b1 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -51,6 +51,14 @@ class OwnershipSignaturePass : public mlir::PassWrappersetAttr(OWN_FRESH_RESULT_ATTR_NAME, mlir::UnitAttr::get(&getContext())); } @@ -682,6 +691,13 @@ class OwnershipSignaturePass : public mlir::PassWrapper the functions every vtable of the interface holds there std::map, InterfaceFamily> interfaceFamilies; + // The words of the module's exported declarations (none in an executable), the classes a class + // vtable is the own vtable of, and the classes another module can name: exported, or in the + // type of an exported or declared function or of an exported or imported global (findOpen). + llvm::StringSet<> exportWords; + llvm::StringSet<> ownClasses; + llvm::StringSet<> nameableClasses; + // A field of function type (`f: (x: number) => number`), called through the interface: the slot // holds the offset of a field of each implementing object literal, and the call goes to whatever // function that field holds. When every vtable's slot is such an offset and every function ever @@ -998,8 +1014,10 @@ class OwnershipSignaturePass : public mlir::PassWrapper &found, llvm::DenseSet &seen) + // included: what another module handed a value of this type could reach. With `classes`, the + // classes it mentions too (a base class is its derived class's first field). + static void anyInterfaces(mlir::Type type, llvm::StringSet<> &found, llvm::DenseSet &seen, + llvm::StringSet<> *classes = nullptr) { if (!type || !seen.insert(type).second) { @@ -1015,13 +1033,18 @@ class OwnershipSignaturePass : public mlir::PassWrapper infos) { for (auto &info : infos) { - anyInterfaces(info.type, found, seen); + anyInterfaces(info.type, found, seen, classes); } }; // the types whose fields are not parameters of the type if (auto classStorageType = mlir::dyn_cast(type)) { + if (classes) + { + classes->insert(classStorageType.getName().getValue()); + } + fields(classStorageType.getFields()); return; } @@ -1044,7 +1067,188 @@ class OwnershipSignaturePass : public mlir::PassWrapper &found, int depth = 0) + { + if (!type || depth > 16) + { + return; + } + + if (mlir::isa(type)) + { + found.push_back(type); + return; + } + + auto fields = [&](llvm::ArrayRef infos) { + for (auto &info : infos) + { + functionValues(info.type, found, depth + 1); + } + }; + + if (auto tupleType = mlir::dyn_cast(type)) + { + fields(tupleType.getFields()); + } + else if (auto constTupleType = mlir::dyn_cast(type)) + { + fields(constTupleType.getFields()); + } + else if (auto unionType = mlir::dyn_cast(type)) + { + for (auto member : unionType.getTypes()) + { + functionValues(member, found, depth + 1); + } + } + else if (auto optionalType = mlir::dyn_cast(type)) + { + functionValues(optionalType.getElementType(), found, depth + 1); + } + else if (auto arrayType = mlir::dyn_cast(type)) + { + functionValues(arrayType.getElementType(), found, depth + 1); + } + else if (auto constArrayType = mlir::dyn_cast(type)) + { + functionValues(constArrayType.getElementType(), found, depth + 1); + } + else if (auto sharedType = mlir::dyn_cast(type)) + { + functionValues(sharedType.getElementType(), found, depth + 1); + } + } + + // A function type's parameters and results, whatever kind of function it is. + static std::pair, llvm::SmallVector> signatureOf(mlir::Type type) + { + llvm::SmallVector inputs; + llvm::SmallVector results; + if (auto functionType = mlir::dyn_cast(type)) + { + inputs.append(functionType.getInputs().begin(), functionType.getInputs().end()); + results.append(functionType.getResults().begin(), functionType.getResults().end()); + } + else if (auto boundType = mlir::dyn_cast(type)) + { + inputs.append(boundType.getInputs().begin(), boundType.getInputs().end()); + results.append(boundType.getResults().begin(), boundType.getResults().end()); + } + else if (auto hybridType = mlir::dyn_cast(type)) + { + inputs.append(hybridType.getInputs().begin(), hybridType.getInputs().end()); + results.append(hybridType.getResults().begin(), hybridType.getResults().end()); + } + else + { + // a kind this does not read: never the same signature as another + inputs.push_back(type); + } + + return {inputs, results}; + } + + // Does a cast reinterpret a function value - from `any` or `unknown`, or as a function of another + // signature? Then an interface value its parameters or + // results hold crosses unseen (a `(o: Op2) => ...` called as `(o: Op) => ...`): `interfaces` + // gets every interface either side's signatures mention, and `boxed` the input's when it is put + // into `any`. + static bool castReinterpretsFunction(mlir::Type in, mlir::Type out, llvm::StringSet<> &interfaces, bool &boxing) + { + // `!ts.opaque` is the compiler's own (a vtable slot read as the `.instanceOf` it holds), not + // a value a program can put a function into + auto isOpaque = [](mlir::Type type) { return mlir::isa(type); }; + llvm::SmallVector inFunctions; + llvm::SmallVector outFunctions; + functionValues(in, inFunctions); + functionValues(out, outFunctions); + boxing = false; + if ((inFunctions.empty() && outFunctions.empty()) || in == out) + { + return false; + } + + auto mention = [&](llvm::ArrayRef types) { + for (auto type : types) + { + llvm::DenseSet seen; + anyInterfaces(type, interfaces, seen); + } + }; + + if (isOpaque(out)) + { + boxing = true; + mention(inFunctions); + return false; + } + + // the same signatures, but for `this` seen as an opaque pointer on one side, or bound on one + // side only (a closure's box, a bound function's object: the caller supplies neither) + auto sameSignature = [](mlir::Type a, mlir::Type b) { + auto [aInputs, aResults] = signatureOf(a); + auto [bInputs, bResults] = signatureOf(b); + auto dropBoundThis = [](mlir::Type type, llvm::SmallVector &inputs) { + if (!inputs.empty() && (mlir::isa(type) || mlir::isa(inputs.front()))) + { + inputs.erase(inputs.begin()); + } + }; + + if (aInputs.size() == bInputs.size() + 1) + { + dropBoundThis(a, aInputs); + } + else if (bInputs.size() == aInputs.size() + 1) + { + dropBoundThis(b, bInputs); + } + + if (aResults != bResults || aInputs.size() != bInputs.size()) + { + return false; + } + + for (auto [index, pair] : llvm::enumerate(llvm::zip(aInputs, bInputs))) + { + auto [aInput, bInput] = pair; + if (aInput != bInput && + !(index == 0 && (mlir::isa(aInput) || mlir::isa(bInput)))) + { + return false; + } + } + + return true; + }; + + // a function out of `any`, or one function seen as another; a function seen as something + // that is no function (a vtable slot's reference to it) is the compiler's own + auto reinterprets = isOpaque(in) && !outFunctions.empty(); + if (!inFunctions.empty() && !outFunctions.empty()) + { + reinterprets = reinterprets || inFunctions.size() != outFunctions.size(); + for (auto [inFunction, outFunction] : llvm::zip(inFunctions, outFunctions)) + { + reinterprets = reinterprets || !sameSignature(inFunction, outFunction); + } + } + + if (reinterprets) + { + mention(inFunctions); + mention(outFunctions); + } + + return reinterprets; } static bool isNullish(mlir::Type type) @@ -1297,7 +1501,14 @@ class OwnershipSignaturePass : public mlir::PassWrapperhasAttr("export") || globalOp->hasAttr(DLL_EXPORT); + // an exported class (not in an executable, whose exports no module sees) + auto exposed = !executable && (!globalOp.isPrivate() || globalOp->hasAttr("export") || globalOp->hasAttr(DLL_EXPORT)); + ownClasses.insert(owner); + if (exposed) + { + nameableClasses.insert(owner); + } + for (auto &[position, value] : values) { auto addressOfOp = addressOfThroughCasts(value); @@ -1430,7 +1641,8 @@ class OwnershipSignaturePass : public mlir::PassWrapper boxed; llvm::StringSet<> unboxedInto; - auto isOpaque = [](mlir::Type type) { return mlir::isa(type); }; + auto unboxedFunction = false; + auto isOpaque =[](mlir::Type type) { return mlir::isa(type); }; module.walk([&](mlir::Operation *op) { if (auto newInterfaceOp = mlir::dyn_cast(op)) { @@ -1480,6 +1692,25 @@ class OwnershipSignaturePass : public mlir::PassWrapper signatures; + auto boxing = false; + if (castReinterpretsFunction(in, out, signatures, boxing)) + { openAll(signatures); + if (isOpaque(in)) + { + unboxedFunction = true; + } + } + else if (boxing) + { + for (auto &entry : signatures) + { + boxed.insert(entry.getKey()); + } + } + return; } @@ -1502,32 +1733,44 @@ class OwnershipSignaturePass : public mlir::PassWrapper exposed; llvm::DenseSet seen; for (auto &entry : functions) { // a `public` method of a class no other module can name is not one: only a declaration // re-parsed from `__decls` names a function of another module - if (entry.second.isDeclaration() || entry.second->hasAttr("export") || entry.second->hasAttr(DLL_EXPORT)) + auto exported = !executable && (entry.second->hasAttr("export") || entry.second->hasAttr(DLL_EXPORT)); + if (entry.second.isDeclaration() || exported) { - anyInterfaces(entry.second.getFunctionType(), exposed, seen); + anyInterfaces(entry.second.getFunctionType(), exposed, seen, &nameableClasses); } } for (auto &entry : globals) { auto globalOp = entry.second; - if (!globalOp.isPrivate() || globalOp->hasAttr("export") || globalOp->hasAttr("import") || - globalOp->hasAttr(DLL_EXPORT) || globalOp->hasAttr(DLL_IMPORT)) + // another module's global, declared here with no initializer (an import of a .ts module + // carries `export`) + auto imported = globalOp->hasAttr("import") || globalOp->hasAttr(DLL_IMPORT) || + globalOp.getInitializerRegion().empty() || globalOp.getInitializerRegion().front().empty(); + auto exported = !globalOp.isPrivate() || globalOp->hasAttr("export") || globalOp->hasAttr(DLL_EXPORT); + if (imported || (exported && !executable)) { - anyInterfaces(globalOp.getType(), exposed, seen); + anyInterfaces(globalOp.getType(), exposed, seen, &nameableClasses); } } openAll(exposed); - if (!unboxedInto.empty()) + if (!unboxedInto.empty() || unboxedFunction) { openAll(boxed); } @@ -2491,12 +2734,14 @@ class OwnershipSignaturePass : public mlir::PassWrapperhasAttr(OWN_DECLARED_NO_DROPS_ATTR_NAME))) + !(importedNoDrops.contains(symbol.getValue()) || funcOp->hasAttr(OWN_DECLARED_NO_DROPS_ATTR_NAME) || + scalarDeclaration(funcOp))) { return false; } @@ -2506,6 +2751,40 @@ class OwnershipSignaturePass : public mlir::PassWrapperhasAttr("export") || funcOp->hasAttr("import") || + funcOp->hasAttr(DLL_EXPORT) || funcOp->hasAttr(DLL_IMPORT) || funcOp.getFunctionType().isVarArg()) + { + return false; + } + + std::function scalar = [&](mlir::Type type) -> bool { + if (auto literalType = mlir::dyn_cast(type)) + { + return scalar(literalType.getElementType()); + } + + if (auto enumType = mlir::dyn_cast(type)) + { + return scalar(enumType.getElementType()); + } + + return mlir::isa(type); + }; + + auto functionType = funcOp.getFunctionType(); + return llvm::all_of(functionType.getInputs(), scalar) && + llvm::all_of(functionType.getResults(), [&](mlir::Type type) { return mlir::isa(type) || scalar(type); }); + } + // `ts.Load(ts.AddressOf @f)`, where the global `f` holds the address of a function exported // by a library loaded at run time, looked up by name: that function, if the library exported // its `__own_no_drops`. @@ -2560,12 +2839,6 @@ class OwnershipSignaturePass : public mlir::PassWrapper(op)) { resolveValue(calleeValue(op), callees); - // an iterator's `next()` is fresh whoever implements it (isIteratorNext), which its - // family's facts must not contradict - if (callees.viaInterface && isIteratorNext(op)) - { - callees = Callees(); - } } if (!callees.known) @@ -2737,6 +3010,44 @@ class OwnershipSignaturePass : public mlir::PassWrapperhasAttr("export") || funcOp->hasAttr(DLL_EXPORT)) + { + return true; + } + + // its class: the longest own class name that prefixes its symbol + auto symbol = funcOp.getSymName(); + llvm::StringRef owner; + for (auto &entry : ownClasses) + { + auto name = entry.getKey(); + if (name.size() > owner.size() && symbol.size() > name.size() && symbol.starts_with(name) && symbol[name.size()] == '.') + { + owner = name; + } + } + + if (owner.empty()) + { + return true; + } + + auto last = owner.rsplit('.').second; + return nameableClasses.contains(owner) || exportWords.contains(owner) || (!last.empty() && exportWords.contains(last)); + } + // A class another module can see may be extended there, and its override is a candidate this // module never sees: every member has to be private and defined here. void resolveFamily(int64_t index, mlir::StringAttr identifier, Callees &callees) @@ -2747,7 +3058,7 @@ class OwnershipSignaturePass : public mlir::PassWrapper mlir_ts::createOwnershipSignaturePass() +std::unique_ptr mlir_ts::createOwnershipSignaturePass(bool executable) { - return std::make_unique(); + return std::make_unique(executable); } diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index de1abc4c3..3a513e999 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3482,7 +3482,7 @@ foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local o own_immortal_global own_immortal_global_region own_literal_moves own_record_literal_given own_field_chain_let own_read_after_move own_conditional_move - own_interface_facts) + own_interface_facts own_declared_scalar_no_drops) 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. @@ -3542,10 +3542,25 @@ endforeach() # A call through an interface closed in its module gets its implementations' facts (spec 31); the # same program under every other model. foreach(interface_facts_mm rc gc none) - tslang_add_test(NAME test-jit-${interface_facts_mm}-own_interface_facts COMMAND test-runner -jit -mm=${interface_facts_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_interface_facts.ts") - tslang_add_test(NAME test-compile-${interface_facts_mm}-own_interface_facts COMMAND test-runner -mm=${interface_facts_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_interface_facts.ts") + foreach(interface_facts_test own_interface_facts own_declared_scalar_no_drops own_interface_exe_exports) + tslang_add_test(NAME test-jit-${interface_facts_mm}-${interface_facts_test} COMMAND test-runner -jit -mm=${interface_facts_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${interface_facts_test}.ts") + tslang_add_test(NAME test-compile-${interface_facts_mm}-${interface_facts_test} COMMAND test-runner -mm=${interface_facts_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${interface_facts_test}.ts") + endforeach() endforeach() +# An executable's exports leave its interfaces closed (spec 31, USER decision): under own the JIT's +# entry module and `tslang --emit=exe` compile own_interface_exe_exports; an object file and a DLL +# do not (own-exe-exports.cmake). test-runner's own AOT build is an object file, so it is not run. +tslang_add_test(NAME test-jit-own-own_interface_exe_exports COMMAND test-runner -jit -mm=own "${PROJECT_SOURCE_DIR}/test/tester/own/own_interface_exe_exports.ts") +add_test(NAME test-own-exe-exports + COMMAND ${CMAKE_COMMAND} + "-DTSLANG=$" + "-DSOURCE=${PROJECT_SOURCE_DIR}/test/tester/own/own_interface_exe_exports.ts" + "-DWORK_DIR=${CMAKE_CURRENT_BINARY_DIR}/own-exe-exports" + "-DLLVM_LIB=${LLVM_LIBRARY_DIR}" + "-DTSLANG_LIB=${CMAKE_BINARY_DIR}/lib" + -P "${CMAKE_CURRENT_SOURCE_DIR}/own-exe-exports.cmake") + # A global declared in top-level code owns what it is given, under every model that counts. foreach(global_mm rc none gc) tslang_add_test(NAME test-jit-${global_mm}-own-global-script COMMAND test-runner -jit -mm=${global_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_global_script.ts") @@ -3799,6 +3814,10 @@ set(own_error_cases "own_err_interface_fn_field_drops|borrows a field but is used here after it may be released or overwritten" "own_err_interface_fn_field_unknown|borrows a field but is used here after it may be released or overwritten" "own_err_interface_fn_field_exported|borrows a field but is used here after it may be released or overwritten" + "own_err_interface_fn_any_param|is a parameter, returned here, but -mm=own cannot tell the callers of 'G.take' that the result borrows it" + "own_err_interface_fn_any_result|is a parameter, returned here, but -mm=own cannot tell the callers of 'G.take' that the result borrows it" + "own_err_interface_fn_any_drops|borrows a field but is used here after it may be released or overwritten" + "own_err_interface_public_exported|is a parameter, returned here, but -mm=own cannot tell the callers of 'PA.pick' that the result borrows it" "own_err_string_kept_param_known|is used here after its value was moved" "own_err_keeps_mismatch|'@keeps' of 'Pair.constructor' names 'a', but its body keeps 'b'" "own_err_borrows_not_generator|'@borrows' of 'copyOf' names 'w', but it neither makes a generator whose state borrows its arguments nor returns one" diff --git a/tslang/test/tester/own-exe-exports.cmake b/tslang/test/tester/own-exe-exports.cmake new file mode 100644 index 000000000..80905c78d --- /dev/null +++ b/tslang/test/tester/own-exe-exports.cmake @@ -0,0 +1,61 @@ +# An executable's exports are seen by no other module (own spec 31, USER decision): built with +# `tslang --emit=exe`, own_interface_exe_exports.ts - an exported interface and an exported global +# holding a value of it - compiles under -mm=own and runs; built as an object file or a DLL, which +# another module may import, the interface is open and `G.get` stays an error. test-runner builds an +# object file with --entry-point and links it itself, so it cannot show the first half. + +cmake_minimum_required(VERSION 3.17.3) + +foreach(var TSLANG SOURCE WORK_DIR LLVM_LIB TSLANG_LIB) + if(NOT DEFINED ${var}) + message(FATAL_ERROR "${var} is required") + endif() +endforeach() + +file(REMOVE_RECURSE "${WORK_DIR}") +file(MAKE_DIRECTORY "${WORK_DIR}") + +set(failures "") + +execute_process( + COMMAND "${TSLANG}" --emit=exe -mm=own --no-default-lib --opt --opt_level=3 + "--llvm-lib-path=${LLVM_LIB}" "--tslang-lib-path=${TSLANG_LIB}" + "${SOURCE}" -o=program.exe + WORKING_DIRECTORY "${WORK_DIR}" + OUTPUT_VARIABLE out + ERROR_VARIABLE err + RESULT_VARIABLE status) +if(NOT status EQUAL 0) + list(APPEND failures " --emit=exe: exit ${status}: ${out}${err}") +else() + execute_process( + COMMAND "${WORK_DIR}/program.exe" + WORKING_DIRECTORY "${WORK_DIR}" + OUTPUT_VARIABLE out + ERROR_VARIABLE err + RESULT_VARIABLE status) + if(NOT status EQUAL 0 OR NOT out MATCHES "5\n.*done\.") + list(APPEND failures " the program: exit ${status}: ${out}${err}") + endif() +endif() + +foreach(emit obj dll) + execute_process( + COMMAND "${TSLANG}" --emit=${emit} -mm=own --no-default-lib + "--llvm-lib-path=${LLVM_LIB}" "--tslang-lib-path=${TSLANG_LIB}" + "${SOURCE}" -o=library.${emit} + WORKING_DIRECTORY "${WORK_DIR}" + OUTPUT_VARIABLE out + ERROR_VARIABLE err + RESULT_VARIABLE status) + if(status EQUAL 0 OR NOT "${out}${err}" MATCHES "cannot tell the callers of 'G.get' that the result borrows it") + list(APPEND failures " --emit=${emit}: exit ${status}, expected the error on 'G.get': ${out}${err}") + endif() +endforeach() + +if(failures) + string(REPLACE ";" "\n" report "${failures}") + message(FATAL_ERROR "an executable's exports under -mm=own:\n${report}") +endif() + +message(STATUS "an executable's exports leave its interfaces closed; an object's and a DLL's do not") diff --git a/tslang/test/tester/own/own_declared_scalar_no_drops.ts b/tslang/test/tester/own/own_declared_scalar_no_drops.ts new file mode 100644 index 000000000..dee312ffd --- /dev/null +++ b/tslang/test/tester/own/own_declared_scalar_no_drops.ts @@ -0,0 +1,70 @@ +// -mm=own: a C function declared without `@nodrops` whose parameters and result are numbers - the +// ray tracer's `sqrt` and `floor` - is given no block of this module and destroys none (spec 31), so +// a borrow of a field survives a call that reaches it: directly, through a function, and through a +// method called through an interface closed in this module. A declaration that takes a block still +// may destroy anything (own_err_declared_drops). +declare function sqrt(v: number): number; +declare function floor(v: number): number; + +class V { + constructor(public x: number) {} +} + +class H { + c: V = new V(16); +} + +class M { + static root(v: number): number { + return floor(sqrt(v)); + } +} + +interface Shape { + size(h: H): number; +} + +class Sq implements Shape { + size(h: H): number { + return M.root(h.c.x); + } +} + +function churn() { + const keep: V[] = []; + for (let i = 0; i < 2000; i++) { + keep.push(new V(77777)); + } + + return keep.length; +} + +function direct(h: H) { + const c = h.c; + const r = sqrt(c.x); + churn(); + return r + c.x; +} + +function viaMethod(h: H) { + const c = h.c; + const r = M.root(c.x + 9); + churn(); + return r + c.x; +} + +function viaInterface(s: Shape, h: H) { + const c = h.c; + const r = s.size(h); + churn(); + return r + c.x; +} + +function main() { + const h = new H(); + assert(direct(h) == 20, "across sqrt"); + assert(viaMethod(h) == 21, "across a function that calls sqrt and floor"); + assert(viaInterface(new Sq(), h) == 20, "across a call through an interface"); + print(direct(h), viaMethod(h), viaInterface(new Sq(), h)); // 20 21 20 + print("done."); +} diff --git a/tslang/test/tester/own/own_err_generator_method_this.ts b/tslang/test/tester/own/own_err_generator_method_this.ts index f4cd36b3f..2be1d7aa2 100644 --- a/tslang/test/tester/own/own_err_generator_method_this.ts +++ b/tslang/test/tester/own/own_err_generator_method_this.ts @@ -1,7 +1,8 @@ // -mm=own rejects: a generator method of a class another module may call keeps `this` in its state, // and its callers keep owning it. It compiled, and the state's release freed the caller's object -// (the program then freed it again); `@borrows("this")` says what it does. -class S { +// (the program then freed it again); `@borrows("this")` says what it does. The class is exported: a +// `public` method of a class no other module can name is not one another module may call (spec 31). +export class S { constructor(public value: string) {} public *chars() { diff --git a/tslang/test/tester/own/own_err_interface_fn_any_drops.ts b/tslang/test/tester/own/own_err_interface_fn_any_drops.ts new file mode 100644 index 000000000..a85d18d96 --- /dev/null +++ b/tslang/test/tester/own/own_err_interface_fn_any_drops.ts @@ -0,0 +1,17 @@ +// -mm=own rejects: a function taking an `Op2` reinterpreted through `any` as one taking an `Op`: the +// call through `Op2` inside it may reach `OB.run`, which overwrites `h.c`, so it may destroy what `c` +// borrows (spec 31). +class V { constructor(public x: number) {} } +class H { c: V = new V(5); } +function churn() { let keep: V[] = []; for (let i = 0; i < 2000; i++) { keep.push(new V(77777)); } return keep.length; } +interface Op { run(h: H): number; } +interface Op2 { run(h: H): number; } +class OA2 implements Op2 { run(h: H): number { return h.c.x; } } +class OB implements Op { run(h: H): number { h.c = new V(9); return 0; } } +function f2(o: Op2, h: H) { const c = h.c; o.run(h); churn(); return c.x; } +function main() { + const h = new H(); + const fn = f2 as any as ((o: Op, h: H) => number); + print(fn(new OB(), h)); + print(f2(new OA2(), h)); +} diff --git a/tslang/test/tester/own/own_err_interface_fn_any_param.ts b/tslang/test/tester/own/own_err_interface_fn_any_param.ts new file mode 100644 index 000000000..cc5958d18 --- /dev/null +++ b/tslang/test/tester/own/own_err_interface_fn_any_param.ts @@ -0,0 +1,18 @@ +// -mm=own rejects: a function taking an `Op2` reinterpreted through `any` as one taking an `Op`, +// so an `Op` value reaches `Op2`'s calls unseen. Both interfaces are open, and `G.take`, which returns a +// borrow of its argument, cannot tell the callers through either (spec 31). +class V { constructor(public x: number) {} } +class H { c: V = new V(5); } +function churn() { let keep: V[] = []; for (let i = 0; i < 2000; i++) { keep.push(new V(77777)); } return keep.length; } +interface Op { take(v: V): V; } +interface Op2 { get(v: V): V; } +class G implements Op { take(v: V): V { return v; } } +class G2 implements Op2 { get(v: V): V { return new V(v.x + 1); } } +function f2(o: Op2, a: V) { const b = o.get(a); return b.x; } +function main() { + const a = new V(5); + const fn = f2 as any as ((o: Op, a: V) => number); + print(fn(new G(), a)); + churn(); + print(a.x, f2(new G2(), a)); +} diff --git a/tslang/test/tester/own/own_err_interface_fn_any_result.ts b/tslang/test/tester/own/own_err_interface_fn_any_result.ts new file mode 100644 index 000000000..c36275985 --- /dev/null +++ b/tslang/test/tester/own/own_err_interface_fn_any_result.ts @@ -0,0 +1,21 @@ +// -mm=own rejects: a function returning an `Op` reinterpreted through `any` as one returning an +// `Op2`, so an `Op` value reaches `Op2`'s calls unseen. Both interfaces are open, and `G.take`, which +// returns a borrow of its argument, cannot tell the callers through either (spec 31). +class V { constructor(public x: number) {} } +class H { c: V = new V(5); } +function churn() { let keep: V[] = []; for (let i = 0; i < 2000; i++) { keep.push(new V(77777)); } return keep.length; } +interface Op { take(v: V): V; } +interface Op2 { get(v: V): V; } +class G implements Op { take(v: V): V { return v; } } +class G2 implements Op2 { get(v: V): V { return new V(v.x + 1); } } +function main() { + const a = new V(5); + const o2: Op2 = new G2(); + { const b0 = o2.get(a); print(b0.x); } + const mk = (): Op => new G(); + const mk2 = mk as any as (() => Op2); + const o = mk2(); + { const b = o.get(a); print(b.x); } + churn(); + print(a.x); +} diff --git a/tslang/test/tester/own/own_err_interface_public_exported.ts b/tslang/test/tester/own/own_err_interface_public_exported.ts new file mode 100644 index 000000000..3f3846992 --- /dev/null +++ b/tslang/test/tester/own/own_err_interface_public_exported.ts @@ -0,0 +1,15 @@ +// -mm=own rejects: a `public` method of an exported class reached through an interface. Another +// module can name the class and call the method without its facts, so it may not return a borrow of +// its argument; a `public` method of a class no other module can name may (own_interface_facts, +// spec 31). +class V { + constructor(public x: number) {} +} +interface Pick { pick(v: V): V; } +export class PA implements Pick { public pick(v: V): V { return v; } } +function main() { + const p: Pick = new PA(); + const a = new V(5); + const b = p.pick(a); + print(b.x, a.x); +} diff --git a/tslang/test/tester/own/own_interface_exe_exports.ts b/tslang/test/tester/own/own_interface_exe_exports.ts new file mode 100644 index 000000000..f84d8d6a5 --- /dev/null +++ b/tslang/test/tester/own/own_interface_exe_exports.ts @@ -0,0 +1,37 @@ +// -mm=own: an executable's exports are seen by no other module (USER decision, spec 31), so an +// exported interface, and an exported global that holds a value of it, leave the interface closed +// when the module is built into a program (`--emit=exe`, or the JIT's entry module): `Hold.g.get(a)` +// gets `G.get`'s facts and its result borrows `a`. Built as an object file or a library the same +// module is open and `G.get` is the error it was (own-exe-exports.cmake). +class V { + constructor(public x: number) {} +} + +export interface Getter { get(v: V): V; } +class G implements Getter { get(v: V): V { return v; } } + +module Hold { + export let g: Getter = new G(); +} + +function churn() { + const keep: V[] = []; + for (let i = 0; i < 2000; i++) { + keep.push(new V(77777)); + } + + return keep.length; +} + +function main() { + const a = new V(5); + { + const b = Hold.g.get(a); + assert(b.x == 5, "the borrowed argument"); + } + + churn(); + assert(a.x == 5, "the argument is still there"); + print(a.x); + print("done."); +} diff --git a/tslang/test/tester/own/own_interface_facts.ts b/tslang/test/tester/own/own_interface_facts.ts index 4a5c53fe7..032330d8c 100644 --- a/tslang/test/tester/own/own_interface_facts.ts +++ b/tslang/test/tester/own/own_interface_facts.ts @@ -4,18 +4,29 @@ // of the argument's field or of an element of the container the receiver came from lives across // it; a method that keeps its argument is given it (the caller moves it in), and a result that // borrows an argument or `this` is the caller's to read, not to release. A direct call of such a -// method keeps its facts too. +// method keeps its facts too, and so does a `public` method of a class no other module can name. +// churn() keeps what it allocates alive, so a block freed too early is reused before it is read. class V { constructor(public x: number) {} static plus(a: V, b: V) { return new V(a.x + b.x); } } function churn() { - let s: number = 0; + const keep: V[] = []; for (let i = 0; i < 2000; i++) { - const t = new V(77777); - s += t.x; + keep.push(new V(77777)); } + return keep.length; +} + +// `public` members of classes no other module can name +interface Pick { pick(v: V): V; } +class PA implements Pick { public pick(v: V): V { return v; } } +class PB implements Pick { public pick(v: V): V { return v; } } +function picked(a: V) { + const ps: Pick[] = [new PA(), new PB()]; + let s: number = 0; + for (const p of ps) { const b = p.pick(a); s += b.x; } return s; } @@ -114,6 +125,7 @@ function main() { const hh = new HH(); assert(use(hh) == 4, "a borrowed field"); + assert(picked(a) == 10, "a borrowed argument through public members"); assert(hh.hold().x == 4, "a borrowed field, called directly"); churn(); diff --git a/tslang/tslang/transform.cpp b/tslang/tslang/transform.cpp index cceb0ee47..58d71398d 100644 --- a/tslang/tslang/transform.cpp +++ b/tslang/tslang/transform.cpp @@ -149,7 +149,9 @@ int runMLIRPasses(mlir::MLIRContext &context, llvm::SourceMgr &sourceMgr, mlir:: // either erases a retain or reports it, and a program it reports stops here. if (compileOptions.memoryModel == MemoryModelOwn && !ownSkipInference) { - pm.addPass(mlir::typescript::createOwnershipSignaturePass()); + // a program's exports are seen by no other module (own spec 31): an executable, or the + // module the JIT enters (a module the JIT cache compiles beside it has isJit off) + pm.addPass(mlir::typescript::createOwnershipSignaturePass(compileOptions.isExecutable || compileOptions.isJit)); pm.nest().addPass(mlir::typescript::createOwnershipInferencePass()); // an `await`'s continuation and a `for await` body, outlined by the async lowering pm.nest().addPass(mlir::typescript::createOwnershipInferencePass()); From 1a8ad11711b4ff7e185b951988bd5734444b9d3f Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 13:31:00 +0100 Subject: [PATCH 25/57] own-raytrace T6: spec 31 after review, and a public method reachable through an exported function Negative own_err_interface_public_reachable: a `public` method of a class an exported function returns keeps its error. Spec 31: reinterpreted functions, C scalar declarations, public methods of classes no other module can name, executables' exports, iterator next(), teeth, corpus, the ray tracer built as a program (31 -> 15 errors), and the rulings on declared markers and abstract classes. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 74 +++++++++++++++++++ tslang/test/tester/CMakeLists.txt | 1 + .../own/own_err_interface_public_reachable.ts | 15 ++++ 3 files changed, 90 insertions(+) create mode 100644 tslang/test/tester/own/own_err_interface_public_reachable.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 064c32bb4..c3c9d6543 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 @@ -3374,3 +3374,77 @@ Not here: - **`export` inside a namespace** exports the declaration from the module (`__decls` lists `namespace NS { interface Th ... }`), so an interface declared `export` in a namespace for use outside it is open; one used only inside its namespace is closed. + +**After review** (2026-10-22). The two "not here" items above on `public` methods and namespace +exports are now handled as follows, and `own_interface_facts`' churn now keeps 2000 blocks alive, so +a block freed too early is reused before it is read. + +- **A function value reinterpreted** carried an interface value into a closed family unseen (found + in review, a new acceptance that crashed under own): `f2 as any as (o: Op, a: V) => number` with + `f2` taking an `Op2` let an `Op` value reach `Op2`'s calls, and so did `(() => Op)` cast through + `any` to `(() => Op2)`. Now a cast that makes a function out of `any` or `unknown`, or sees one + function as a function of another signature, opens every interface either signature mentions (a + class's fields included). A function boxed into `any` puts its signature's interfaces among the + boxed ones, which any unboxing opens. A bound function's `this` and an opaque `this` are not part + of the comparison: a closure seen as a plain function of the same parameters is the same. + Negatives `own_err_interface_fn_any_param`, `_result`, `_drops`. +- **C declarations of numbers** (controller ruling): a body-less `declare function` with no `export` + or import marker (another TypeScript module's declarations carry `export`), not variadic, whose + parameters and result are numbers, booleans, characters, integers or enums of them, destroys + nothing: it is given no block of this module. `sqrt`, `floor`, `pow` and `fabs` in the ray tracer. + The risk, as for `@nodrops`, is a C function calling back into the module through something it was + not given (an exported function reached by name). `own_declared_scalar_no_drops` holds a field's + borrow across `sqrt`, across a function that calls `sqrt` and `floor`, and across a call through a + closed interface whose member does; `own_err_declared_drops` (`strlen(s: string)`) is unchanged. +- **A `public` method of a class no other module can name** (controller ruling) is not callable from + another module: the class is not exported (its vtable is private), not a word of `__decls`, and not + in the type of an exported or declared function or of an exported or imported global (a derived + class's storage holds its base, so a nameable class makes its bases nameable). `findOpen` and class + families use the same test (`callableFromOutside`). `own_err_interface_public_exported` (an exported + class) and `own_err_interface_public_reachable` (a class an exported function returns) keep the + error; `own_interface_facts` calls `public` methods that return a borrow through a closed interface. + `own_err_generator_method_this` exports its class to keep what it tests. +- **An executable's exports** (USER decision): a module built into a program - `--emit=exe`, or the + module the JIT enters (`isExecutable || isJit`; a module the JIT cache compiles beside it has + `isJit` off) - is imported by no other module, so its `__decls`, exported functions and globals, and + exported classes open no interface and make no class nameable. What it imports still does: a + declared function, an imported global, a global with no initializer (an import of a `.ts` module). + An object file (test-runner's AOT build is `--emit=obj --entry-point`), a DLL and a `-shared` + library stay as they were; the pass takes the kind as an option, a library when not told. + `own_interface_exe_exports` (an exported interface and an exported global of it) runs under the + JIT and as `tslang --emit=exe`, and is the error on `G.get` as `--emit=obj` and `--emit=dll` + (`own-exe-exports.cmake`). +- **An iterator `next()` through a closed interface** (ruling) gets the family's facts like any other + call; it is no longer assumed fresh. + +Teeth: with function reinterpretations not opening anything, `own_err_interface_fn_any_param` and +`_result` compile and their AOT runs fail with `0xC0000374` (with and without `--opt`), and `_drops` +prints 77777 instead of 5 (gc 5). With every declaration without `export` taken as destroying +nothing, `own_err_declared_drops` compiles, and so does a program that lends a field and passes the +field's object to a declared `free(p: V)`; its AOT run fails with `0xC0000374`. With a `public` +method's class never nameable, `own_err_interface_public_reachable` compiles; with every module taken +for an executable, `own-exe-exports.cmake` fails (the object file and the DLL compile), and so do +`own_err_interface_exported_impl` and `own_err_interface_keeps_param`. The last two rules protect +callers in another module, which no single-module run can show. + +Corpus (`--emit=obj`, not an executable): 528 of 693 before this section and after, the same files. + +The ray tracer built as a program, `tslang --emit=exe --opt --opt_level=3 --no-default-lib -mm=own +rtbench_own.ts`: 31 errors before this section, 15 after. Cleared: `Camera`'s constructor (three, +`Vector.norm` reaching `sqrt`), `Sphere.normal`, `this` in `Sphere.intersect`, both errors at +`scene.things[i].intersect(ray)`, `isect.thing.normal(pos)` and the `Vector.minus(d, ...)` after it +in `shade`, `thing.surface.value.reflect(pos)`, `.diffuse(pos)`, `.specular(pos)` and the +`roughness` read, the three `camera.forward/right/up` reads in `getPoint`, and one of the two at +`traceRay({ start: scene.camera.pos, ... })`. New in their place: `scene.things[i].intersect(ray)` +is `moved inside a loop but was made outside it` - the call now has the family's facts, and +`Sphere.intersect` and `Plane.intersect` keep `this` and `ray` in the record they return; that is +G2's borrowing record. As an object file (`--emit=obj`) it has 19: the `Surface` calls stay open. + +Not here, also: +- A member that says `@keeps` or `@lends` opens its family (above): a sound narrowing of the plan, + which carried declared facts through closed interfaces too (controller ruling: accepted). Such an + implementer through an interface stays the error it was. +- An abstract class's interface vtable is always open: too strict, but sound. +- A class value reinterpreted through `any` (`new C() as any as C2`, `C2`'s field an `Op2` where + `C`'s is an `Op`) is not followed; the program stops at run time under every model, before any + call through the field. diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 3a513e999..2d30cbe91 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3818,6 +3818,7 @@ set(own_error_cases "own_err_interface_fn_any_result|is a parameter, returned here, but -mm=own cannot tell the callers of 'G.take' that the result borrows it" "own_err_interface_fn_any_drops|borrows a field but is used here after it may be released or overwritten" "own_err_interface_public_exported|is a parameter, returned here, but -mm=own cannot tell the callers of 'PA.pick' that the result borrows it" + "own_err_interface_public_reachable|is a parameter, returned here, but -mm=own cannot tell the callers of 'PA.pick' that the result borrows it" "own_err_string_kept_param_known|is used here after its value was moved" "own_err_keeps_mismatch|'@keeps' of 'Pair.constructor' names 'a', but its body keeps 'b'" "own_err_borrows_not_generator|'@borrows' of 'copyOf' names 'w', but it neither makes a generator whose state borrows its arguments nor returns one" diff --git a/tslang/test/tester/own/own_err_interface_public_reachable.ts b/tslang/test/tester/own/own_err_interface_public_reachable.ts new file mode 100644 index 000000000..ae5259756 --- /dev/null +++ b/tslang/test/tester/own/own_err_interface_public_reachable.ts @@ -0,0 +1,15 @@ +// -mm=own rejects: a `public` method of a class that is not exported but that an exported function +// returns. Another module gets a value of the class from it and may call the method without its +// facts, so it may not return a borrow of its argument (spec 31). Unreachable from another module, +// the same method keeps its facts (own_interface_facts). +class V { + constructor(public x: number) {} +} +class PA { public pick(v: V): V { return v; } } +export function make(): PA { return new PA(); } +function main() { + const p = make(); + const a = new V(5); + const b = p.pick(a); + print(b.x, a.x); +} From 608d7761f1383de70150e9bbcfb93dbbc68dca3a Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 14:31:32 +0100 Subject: [PATCH 26/57] own-raytrace T6: -mm=own: closed interfaces, re-review 1 - member types, overrides of outside bases, C callbacks - An interface value made out of another interface, or out of `any`, opens every interface either side's members mention (vtable slot types and what is read out of its values), recursively: `HasF2 { f: (o: Op2, ...) }` seen as `HasF { f: (o: Op, ...) }` handed an `Op` to a call through `Op2` (review6r1 v5, v11f). null seen as an interface makes nothing. Negatives own_err_interface_member_types, own_err_interface_member_methods. - A method of a class whose base chain holds a class another module can name, or one it defines (no own vtable here), is callable from outside, private or not: that module calls it through the base's vtable (review6r1 p5, p6, p7; the private form was accepted at BASE too). Negatives own_err_override_nameable_base (library side), own_err_override_imported_base and own_err_override_imported_class (importer side); the pairs run under rc, gc and none. - The C-scalar no-drops rule is off in a module whose functions escape to C: cast to something that is no function, or given to a declared function (review6r1 c3, `signal` then `raise`). Negative own_err_declared_callback. - Spec 31: after re-review. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 44 ++- .../lib/TypeScript/OwnershipSignaturePass.cpp | 268 ++++++++++++++++-- tslang/test/tester/CMakeLists.txt | 16 ++ .../tester/own/own_err_declared_callback.ts | 13 + .../own/own_err_interface_member_methods.ts | 15 + .../own/own_err_interface_member_types.ts | 15 + .../own/own_err_override_imported_base.ts | 7 + .../own/own_err_override_imported_class.ts | 7 + .../own/own_err_override_nameable_base.ts | 9 + .../test/tester/own/own_override_base_lib.ts | 7 + .../tester/own/own_override_nameable_main.ts | 7 + 11 files changed, 383 insertions(+), 25 deletions(-) create mode 100644 tslang/test/tester/own/own_err_declared_callback.ts create mode 100644 tslang/test/tester/own/own_err_interface_member_methods.ts create mode 100644 tslang/test/tester/own/own_err_interface_member_types.ts create mode 100644 tslang/test/tester/own/own_err_override_imported_base.ts create mode 100644 tslang/test/tester/own/own_err_override_imported_class.ts create mode 100644 tslang/test/tester/own/own_err_override_nameable_base.ts create mode 100644 tslang/test/tester/own/own_override_base_lib.ts create mode 100644 tslang/test/tester/own/own_override_nameable_main.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 c3c9d6543..c5dcda99d 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 @@ -3392,8 +3392,10 @@ a block freed too early is reused before it is read. or import marker (another TypeScript module's declarations carry `export`), not variadic, whose parameters and result are numbers, booleans, characters, integers or enums of them, destroys nothing: it is given no block of this module. `sqrt`, `floor`, `pow` and `fabs` in the ray tracer. - The risk, as for `@nodrops`, is a C function calling back into the module through something it was - not given (an exported function reached by name). `own_declared_scalar_no_drops` holds a field's + The risk, as for `@nodrops`, is a C function calling back into the module: one registered through + another declaration (`signal(15, onSig as Opaque)`, then `raise(15)`; a window procedure and + `SendMessage`), or an exported function reached by name. The first is closed below (re-review); + the second remains. `own_declared_scalar_no_drops` holds a field's borrow across `sqrt`, across a function that calls `sqrt` and `floor`, and across a call through a closed interface whose member does; `own_err_declared_drops` (`strlen(s: string)`) is unchanged. - **A `public` method of a class no other module can name** (controller ruling) is not callable from @@ -3448,3 +3450,41 @@ Not here, also: - A class value reinterpreted through `any` (`new C() as any as C2`, `C2`'s field an `Op2` where `C`'s is an `Op`) is not followed; the program stops at run time under every model, before any call through the field. + +**After re-review** (2026-10-22): + +- **An interface made out of another one hands over its members' arguments.** A `HasF2` + (`f: (o: Op2, h: H) => number`) seen as a `HasF` (`f: (o: Op, h: H) => number`) - by + assignment, through `any`, as a parameter - lets a caller hand an `Op` to a function that calls + through `Op2`; so does a `Runner2` (`go(o: Op2, ...)`) seen as a `Runner`. Now a cast that makes an + interface value out of another interface or out of `any` opens, with the two interfaces, every + interface their members mention - the types of their vtables' slots and of what is read out of + their values, function signatures and class fields included - and theirs, recursively. Null seen + as an interface makes nothing (it has no vtable), so `return null` from a method typed `Intersection` + opens nothing. Negatives `own_err_interface_member_types`, `own_err_interface_member_methods`. +- **An override of a method of a class another module can name, or defines,** is called from that + module through the base's vtable without its facts, whatever the override's own class is: a + `public keep` of an unexported `Der extends Base`, with `Base` exported and handed out by + `export function mk(): Base`, kept its argument while an importer released it; and an importer's + `Der extends Base` of an imported `Base` (abstract or with a body) was called by the library the + same way. Now a method of a class whose base chain (the first field of a derived class's storage) + holds a nameable class, or one with no own vtable here, is callable from outside, private or not - + the private form was accepted before this section too. Negatives + `own_err_override_nameable_base` (library side), `own_err_override_imported_base` and + `own_err_override_imported_class` (importer side, the latter a private override of a concrete + base); the library-and-importer pairs run under rc, gc and none (`test-runner -shared`). +- **C callbacks.** The C-scalar rule is off in a module that lets a function of its own escape to C: + its value (through function casts and bindings) cast to something that is no function (`any`, an + opaque pointer, an integer), or given to a declared function. Negative + `own_err_declared_callback` (`signal(15, onSig as Opaque)`; `onSig` overwrites `gh.c`, which a + caller of `raise(15)` borrows). + +Teeth: with the member closure skipped, `own_err_interface_member_types` and `_methods` compile and +print `5 77777` (gc `5 5`). With the C rule kept on despite an escaping function, +`own_err_declared_callback` prints `77777 9` (gc `5 9`). With a base chain never making a method +callable from outside, every override negative compiles, and the pairs under `-mm=own` fail their +assert (`kept value still there`): the library side AOT, the importer side AOT and under the JIT +(gc passes all of them). + +The ray tracer built as a program stays at 15 errors (the same ones); as an object file, 19. +Corpus: 528 before and after, the same files. diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index a7a06a2b1..a31b0ebb4 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -389,6 +389,8 @@ class OwnershipSignaturePass : public mlir::PassWrapper &found) + { + llvm::DenseSet seen; + anyInterfaces(type, found, seen); + } + static bool isNullish(mlir::Type type) { return mlir::isa(type); @@ -1264,7 +1274,8 @@ class OwnershipSignaturePass : public mlir::PassWrapper 16) + // null seen as an interface value has no vtable: a call through it reaches nothing + if (in == out || depth > 16 || isNullish(in)) { return false; } @@ -1642,7 +1653,14 @@ class OwnershipSignaturePass : public mlir::PassWrapper boxed; llvm::StringSet<> unboxedInto; auto unboxedFunction = false; - auto isOpaque =[](mlir::Type type) { return mlir::isa(type); }; + // the interfaces a cast makes a value of out of another interface's or an opaque one, and + // the ones it came from: closed over their members' types below + llvm::StringSet<> castOpened; + // interface -> the interfaces its members' types mention: its vtables' slots and what is + // read out of its values (`ts.InterfaceSymbolRef`), function signatures and class fields + // included + llvm::StringMap> memberInterfaces; + auto isOpaque = [](mlir::Type type) { return mlir::isa(type); }; module.walk([&](mlir::Operation *op) { if (auto newInterfaceOp = mlir::dyn_cast(op)) { @@ -1662,8 +1680,8 @@ class OwnershipSignaturePass : public mlir::PassWrapper()) { - open.insert(name); - valueInterfaces(extractOp.getInterfaceVal().getType(), open); + castOpened.insert(name); + valueInterfaces(extractOp.getInterfaceVal().getType(), castOpened); } else { @@ -1684,8 +1702,8 @@ class OwnershipSignaturePass : public mlir::PassWrapper signatures; auto boxing = false; if (castReinterpretsFunction(in, out, signatures, boxing)) - { openAll(signatures); + { + openAll(signatures); if (isOpaque(in)) { unboxedFunction = true; @@ -1724,6 +1743,16 @@ class OwnershipSignaturePass : public mlir::PassWrapper(op)) + { + if (auto interfaceType = mlir::dyn_cast(symbolRefOp.getInterfaceVal().getType())) + { + memberTypeInterfaces(symbolRefOp.getType(), memberInterfaces[interfaceType.getName().getValue()]); + } + + return; + } + if (auto addressOfOp = mlir::dyn_cast(op)) { if (auto found = vtables.find(addressOfOp.getGlobalName()); found != vtables.end() && !onlyMakesValues(addressOfOp.getResult())) @@ -1773,6 +1802,53 @@ class OwnershipSignaturePass : public mlir::PassWrappersecond.type) + { + for (auto [position, field] : llvm::enumerate(found->second.type.getFields())) + { + if (position > 0) + { + memberTypeInterfaces(field.type, memberInterfaces[name]); + } + } + } + } + } + + llvm::SmallVector work; + for (auto &entry : castOpened) + { + work.push_back(entry.getKey().str()); + } + + llvm::StringSet<> expanded; + while (!work.empty()) + { + auto name = work.pop_back_val(); + if (!expanded.insert(name).second) + { + continue; + } + + open.insert(name); + for (auto &member : memberInterfaces[name]) + { + work.push_back(member.getKey().str()); + } } auto isOpen = [&](llvm::StringRef name) { @@ -2741,7 +2817,7 @@ class OwnershipSignaturePass : public mlir::PassWrapperhasAttr(OWN_DECLARED_NO_DROPS_ATTR_NAME) || - scalarDeclaration(funcOp))) + (scalarDeclaration(funcOp) && !functionsEscape))) { return false; } @@ -2755,8 +2831,10 @@ class OwnershipSignaturePass : public mlir::PassWrapperhasAttr("export") || funcOp->hasAttr("import") || @@ -3010,19 +3088,16 @@ class OwnershipSignaturePass : public mlir::PassWrapperhasAttr("export") || funcOp->hasAttr(DLL_EXPORT)) + if (!funcOp.isPrivate() && (funcOp.isDeclaration() || funcOp->hasAttr("export") || funcOp->hasAttr(DLL_EXPORT))) { return true; } @@ -3040,12 +3115,159 @@ class OwnershipSignaturePass : public mlir::PassWrapper(fields.front().type); + if (!base) + { + return false; + } + + auto name = base.getName().getValue(); + if (nameable(name) || !ownClasses.contains(name)) + { + return true; + } + + storage = base; + } + + return true; + } + + // Does a function of this module escape where C could call it: its value (through function + // casts and bindings) cast to something that is no function (`any`, an opaque pointer, an + // integer), or given to a declared function as an argument? Then a C declaration of numbers + // may still call back into the module (scalarDeclaration). + bool functionsEscape = false; + + void findEscapingFunctions(mlir::ModuleOp module) + { + auto isFunction = [](mlir::Type type) { + return mlir::isa(type); + }; + + llvm::SmallVector work; + module.walk([&](mlir_ts::SymbolRefOp symbolRefOp) { + auto funcOp = functions.lookup(symbolRefOp.getIdentifier()); + if (funcOp && !funcOp.isDeclaration() && !symbolRefOp->getParentOfType()) + { + work.push_back(symbolRefOp.getResult()); + } + }); + + llvm::DenseSet seen; + while (!work.empty() && !functionsEscape) + { + auto value = work.pop_back_val(); + if (!seen.insert(value).second) + { + continue; + } + + for (auto &use : value.getUses()) + { + auto *user = use.getOwner(); + if (auto castOp = mlir::dyn_cast(user)) + { + if (isFunction(castOp.getType())) + { + work.push_back(castOp.getResult()); + } + else + { + functionsEscape = true; + } + + continue; + } + + if (mlir::isa(user)) + { + work.push_back(user->getResult(0)); + continue; + } + + if (isCall(user) && calleeValue(user) != use.get()) + { + auto callee = directCallee(user); + auto funcOp = callee ? functions.lookup(callee.getValue()) : mlir_ts::FuncOp(); + if (funcOp && funcOp.isDeclaration()) + { + functionsEscape = true; + } + } + } + } + } + + // class name -> its storage, from every class type a function's signature or a global mentions + llvm::StringMap classStorages; + + void collectClassStorages(mlir::ModuleOp module) + { + llvm::DenseSet seen; + std::function visit = [&](mlir::Type type) { + if (!type || !seen.insert(type).second) + { + return; + } + + if (auto storage = mlir::dyn_cast(type)) + { + classStorages[storage.getName().getValue()] = storage; + for (auto &field : storage.getFields()) + { + visit(field.type); + } + + return; + } + + type.walkImmediateSubElements([](mlir::Attribute) {}, visit); + }; + + for (auto &entry : functions) + { + visit(entry.second.getFunctionType()); + } + + module.walk([&](mlir::Operation *op) { + for (auto result : op->getResults()) + { + if (mlir::isa(result.getType())) + { + visit(result.getType()); + } + } + }); } // A class another module can see may be extended there, and its override is a candidate this diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 2d30cbe91..78716aa7b 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3696,6 +3696,16 @@ if (WIN32) -P "${CMAKE_CURRENT_SOURCE_DIR}/own-shared-no-drops.cmake") endif() +# An override of a method of a class another module can name, or defines, is called from that module +# through the base's vtable (own spec 31): under own each library or importer below is an error +# (own_error_cases); the pairs are ordinary TypeScript under every other model. +foreach(override_mm rc none gc) + tslang_add_test(NAME test-compile-${override_mm}-shared-override-nameable-base COMMAND test-runner -shared -mm=${override_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_override_nameable_main.ts" "${PROJECT_SOURCE_DIR}/test/tester/own/own_err_override_nameable_base.ts") + tslang_add_test(NAME test-jit-${override_mm}-shared-override-imported-base COMMAND test-runner -jit -shared -mm=${override_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_err_override_imported_base.ts" "${PROJECT_SOURCE_DIR}/test/tester/own/own_override_base_lib.ts") + tslang_add_test(NAME test-compile-${override_mm}-shared-override-imported-base COMMAND test-runner -shared -mm=${override_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_err_override_imported_base.ts" "${PROJECT_SOURCE_DIR}/test/tester/own/own_override_base_lib.ts") + tslang_add_test(NAME test-compile-${override_mm}-shared-override-imported-class COMMAND test-runner -shared -mm=${override_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_err_override_imported_class.ts" "${PROJECT_SOURCE_DIR}/test/tester/own/own_override_base_lib.ts") +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 @@ -3819,6 +3829,12 @@ set(own_error_cases "own_err_interface_fn_any_drops|borrows a field but is used here after it may be released or overwritten" "own_err_interface_public_exported|is a parameter, returned here, but -mm=own cannot tell the callers of 'PA.pick' that the result borrows it" "own_err_interface_public_reachable|is a parameter, returned here, but -mm=own cannot tell the callers of 'PA.pick' that the result borrows it" + "own_err_interface_member_types|borrows a field but is used here after it may be released or overwritten" + "own_err_interface_member_methods|borrows a field but is used here after it may be released or overwritten" + "own_err_declared_callback|borrows 'gh.c' but is used here after it may be released or overwritten" + "own_err_override_nameable_base|is a parameter, kept here, but -mm=own cannot make the callers of 'Der.keep' give it up" + "own_err_override_imported_base|is a parameter, kept here, but -mm=own cannot make the callers of 'Der.keep' give it up" + "own_err_override_imported_class|is a parameter, kept here, but -mm=own cannot make the callers of 'Der.keep' give it up" "own_err_string_kept_param_known|is used here after its value was moved" "own_err_keeps_mismatch|'@keeps' of 'Pair.constructor' names 'a', but its body keeps 'b'" "own_err_borrows_not_generator|'@borrows' of 'copyOf' names 'w', but it neither makes a generator whose state borrows its arguments nor returns one" diff --git a/tslang/test/tester/own/own_err_declared_callback.ts b/tslang/test/tester/own/own_err_declared_callback.ts new file mode 100644 index 000000000..8dbc65487 --- /dev/null +++ b/tslang/test/tester/own/own_err_declared_callback.ts @@ -0,0 +1,13 @@ +// -mm=own rejects: a C function of numbers (`raise`) whose module gave another C function a +// function of its own (`signal(15, onSig as Opaque)`): `raise` may call it back, and it overwrites +// `gh.c`, which `c` borrows. A C declaration of numbers destroys nothing only in a module none of +// whose functions escape so (spec 31). +class V { constructor(public x: number) {} } +class H { c: V = new V(5); } +function churn() { let keep: V[] = []; for (let i = 0; i < 2000; i++) { keep.push(new V(77777)); } return keep.length; } +declare function signal(sig: s32, handler: Opaque): Opaque; +declare function raise(sig: s32): s32; +let gh = new H(); +function onSig(sig: s32) { gh.c = new V(9); } +function use() { const c = gh.c; raise(15); churn(); return c.x; } +function main() { signal(15, onSig as Opaque); print(use()); print(gh.c.x); } diff --git a/tslang/test/tester/own/own_err_interface_member_methods.ts b/tslang/test/tester/own/own_err_interface_member_methods.ts new file mode 100644 index 000000000..f7b50b5e4 --- /dev/null +++ b/tslang/test/tester/own/own_err_interface_member_methods.ts @@ -0,0 +1,15 @@ +// -mm=own rejects: a `Runner2` (`go(o: Op2, ...)`) seen as a `Runner` (`go(o: Op, ...)`) hands an +// `Op` to `R.go`, whose call through `Op2` then reaches `OB.run`, which overwrites `h.c`. Every +// interface the two sides' members mention is open (spec 31). +class V { constructor(public x: number) {} } +class H { c: V = new V(5); } +function churn() { let keep: V[] = []; for (let i = 0; i < 2000; i++) { keep.push(new V(77777)); } return keep.length; } +interface Op { run(h: H): number; } +interface Op2 { run(h: H): number; } +class OA2 implements Op2 { run(h: H): number { return h.c.x; } } +class OB implements Op { run(h: H): number { h.c = new V(9); return 0; } } +interface Runner { go(o: Op, h: H): number; } +interface Runner2 { go(o: Op2, h: H): number; } +class R implements Runner2 { go(o: Op2, h: H) { const c = h.c; o.run(h); churn(); return c.x; } } +function call(r2: Runner2) { const r: Runner = r2; return r.go(new OB(), new H()); } +function main() { const r2: Runner2 = new R(); print(r2.go(new OA2(), new H())); print(call(r2)); } diff --git a/tslang/test/tester/own/own_err_interface_member_types.ts b/tslang/test/tester/own/own_err_interface_member_types.ts new file mode 100644 index 000000000..a15e0f41c --- /dev/null +++ b/tslang/test/tester/own/own_err_interface_member_types.ts @@ -0,0 +1,15 @@ +// -mm=own rejects: an interface value made out of another one whose members take different +// interfaces of the same shape: a `HasF2` (`f: (o: Op2, ...)`) seen as a `HasF` (`f: (o: Op, ...)`) +// hands an `Op` to `f2`, whose call through `Op2` then reaches `OB.run`, which overwrites `h.c`. +// Every interface the two sides' members mention is open (spec 31). +class V { constructor(public x: number) {} } +class H { c: V = new V(5); } +function churn() { let keep: V[] = []; for (let i = 0; i < 2000; i++) { keep.push(new V(77777)); } return keep.length; } +interface Op { run(h: H): number; } +interface Op2 { run(h: H): number; } +class OA2 implements Op2 { run(h: H): number { return h.c.x; } } +class OB implements Op { run(h: H): number { h.c = new V(9); return 0; } } +function f2(o: Op2, h: H) { const c = h.c; o.run(h); churn(); return c.x; } +interface HasF2 { f: (o: Op2, h: H) => number; } +interface HasF { f: (o: Op, h: H) => number; } +function main() { const x: HasF2 = { f: f2 }; print(x.f(new OA2(), new H())); const y: HasF = x; print(y.f(new OB(), new H())); } diff --git a/tslang/test/tester/own/own_err_override_imported_base.ts b/tslang/test/tester/own/own_err_override_imported_base.ts new file mode 100644 index 000000000..f4cc2471e --- /dev/null +++ b/tslang/test/tester/own/own_err_override_imported_base.ts @@ -0,0 +1,7 @@ +// -mm=own rejects: a `public` override of an imported class's abstract method. The library calls it +// through `Base`'s vtable without its facts and releases what it keeps, so it may not keep its +// argument (spec 31) - in an executable or the JIT's entry module too. +import './own_override_base_lib' +class Der extends Base { saved: V[] = []; public keep(v: V) { this.saved.push(v); } } +function churn() { const keep: V[] = []; for (let i = 0; i < 2000; i++) keep.push(new V(77777)); return keep.length; } +function main() { const d = new Der(); run(d); churn(); assert(d.saved[0].x == 5, "kept value still there"); print("done."); } diff --git a/tslang/test/tester/own/own_err_override_imported_class.ts b/tslang/test/tester/own/own_err_override_imported_class.ts new file mode 100644 index 000000000..c9beb7f98 --- /dev/null +++ b/tslang/test/tester/own/own_err_override_imported_class.ts @@ -0,0 +1,7 @@ +// -mm=own rejects: an override (not `public`) of a method of an imported class with a body. The +// library calls it through the class's vtable without its facts, so it may not keep its argument +// (spec 31). +import './own_override_base_lib' +class Der extends Concrete { saved: V[] = []; keep(v: V) { this.saved.push(v); } } +function churn() { const keep: V[] = []; for (let i = 0; i < 2000; i++) keep.push(new V(77777)); return keep.length; } +function main() { const d = new Der(); runConcrete(d); churn(); assert(d.saved[0].x == 5, "kept value still there"); print("done."); } diff --git a/tslang/test/tester/own/own_err_override_nameable_base.ts b/tslang/test/tester/own/own_err_override_nameable_base.ts new file mode 100644 index 000000000..9f4a60d6a --- /dev/null +++ b/tslang/test/tester/own/own_err_override_nameable_base.ts @@ -0,0 +1,9 @@ +// -mm=own rejects: a `public` method of a class no other module can name, that overrides a method of +// an exported class. Another module gets a `Base` from `mk()` and calls `keep` through `Base`'s +// vtable, without the method's facts, so the method may not keep its argument (spec 31). +export class V { constructor(public x: number) {} } +export abstract class Base { abstract keep(v: V): void; } +class Der extends Base { saved: V[] = []; public keep(v: V) { this.saved.push(v); } public first() { return this.saved[0].x; } } +let d = new Der(); +export function mk(): Base { return d; } +export function first() { return d.first(); } diff --git a/tslang/test/tester/own/own_override_base_lib.ts b/tslang/test/tester/own/own_override_base_lib.ts new file mode 100644 index 000000000..b0b2ad1d5 --- /dev/null +++ b/tslang/test/tester/own/own_override_base_lib.ts @@ -0,0 +1,7 @@ +// The library own_err_override_imported_base and own_err_override_imported_class import: an abstract +// and a concrete base class whose `keep` it calls itself. +export class V { constructor(public x: number) {} } +export abstract class Base { abstract keep(v: V): void; } +export class Concrete { keep(v: V): void { } } +export function run(b: Base) { const a = new V(5); b.keep(a); } +export function runConcrete(b: Concrete) { const a = new V(5); b.keep(a); } diff --git a/tslang/test/tester/own/own_override_nameable_main.ts b/tslang/test/tester/own/own_override_nameable_main.ts new file mode 100644 index 000000000..8095b5acc --- /dev/null +++ b/tslang/test/tester/own/own_override_nameable_main.ts @@ -0,0 +1,7 @@ +// The importer of own_err_override_nameable_base: it calls `keep` through `Base`'s vtable, which the +// library's `Der.keep` fills, and reads the kept value after a churn. Correct under every model; under +// own the library is the error (spec 31). +import './own_err_override_nameable_base' +function churn() { const keep: V[] = []; for (let i = 0; i < 2000; i++) keep.push(new V(77777)); return keep.length; } +function put(b: Base) { const a = new V(5); b.keep(a); } +function main() { put(mk()); churn(); assert(first() == 5, "kept value still there"); print("done."); } From cbf640b7ce3c8cc8dc2111ff98b43172e7a08be2 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 14:43:23 +0100 Subject: [PATCH 27/57] own-raytrace T6: -mm=own: closed interfaces, re-review 2 - class-made members, C callbacks by type - The member-type closure of interface-from-interface casts reads the class vtables too: they joined the interface's vtables only after it, so an interface whose values are all made from class objects (`const r2: Runner2 = rr`) had no member types (review6r2 d1-d3). Negatives own_err_interface_member_class, own_err_interface_member_any. - The C-scalar rule's escape test is by type (controller ruling): off in a module with a cast from a type that holds a function to one that holds none (a vtable's address is the compiler's own; a class or object output counts its fields), or a call of a declared function with an argument whose type holds a function anywhere. The value-following walk missed a global initialized with the function, a wrapper, a returned function, a method reference and a tuple (review6r2 k1-k10). Negatives own_err_declared_callback_global, _method, _tuple. - Spec 31: the wording of the C rule, fix round 3. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 29 +++- .../lib/TypeScript/OwnershipSignaturePass.cpp | 147 +++++++++++------- tslang/test/tester/CMakeLists.txt | 5 + .../own/own_err_declared_callback_global.ts | 13 ++ .../own/own_err_declared_callback_method.ts | 12 ++ .../own/own_err_declared_callback_tuple.ts | 13 ++ .../own/own_err_interface_member_any.ts | 15 ++ .../own/own_err_interface_member_class.ts | 17 ++ 8 files changed, 191 insertions(+), 60 deletions(-) create mode 100644 tslang/test/tester/own/own_err_declared_callback_global.ts create mode 100644 tslang/test/tester/own/own_err_declared_callback_method.ts create mode 100644 tslang/test/tester/own/own_err_declared_callback_tuple.ts create mode 100644 tslang/test/tester/own/own_err_interface_member_any.ts create mode 100644 tslang/test/tester/own/own_err_interface_member_class.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 c5dcda99d..1cae55248 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 @@ -3394,8 +3394,10 @@ a block freed too early is reused before it is read. nothing: it is given no block of this module. `sqrt`, `floor`, `pow` and `fabs` in the ray tracer. The risk, as for `@nodrops`, is a C function calling back into the module: one registered through another declaration (`signal(15, onSig as Opaque)`, then `raise(15)`; a window procedure and - `SendMessage`), or an exported function reached by name. The first is closed below (re-review); - the second remains. `own_declared_scalar_no_drops` holds a field's + `SendMessage`), or an exported function reached by name. The first is closed by type below (the + rule is off in a module that casts a function-holding value to a type that holds none, or gives a + declared function an argument whose type holds a function); the second remains. + `own_declared_scalar_no_drops` holds a field's borrow across `sqrt`, across a function that calls `sqrt` and `floor`, and across a call through a closed interface whose member does; `own_err_declared_drops` (`strlen(s: string)`) is unchanged. - **A `public` method of a class no other module can name** (controller ruling) is not callable from @@ -3473,11 +3475,17 @@ Not here, also: `own_err_override_nameable_base` (library side), `own_err_override_imported_base` and `own_err_override_imported_class` (importer side, the latter a private override of a concrete base); the library-and-importer pairs run under rc, gc and none (`test-runner -shared`). -- **C callbacks.** The C-scalar rule is off in a module that lets a function of its own escape to C: - its value (through function casts and bindings) cast to something that is no function (`any`, an - opaque pointer, an integer), or given to a declared function. Negative +- **C callbacks.** The C-scalar rule is off in a module that contains a `ts.Cast` from a type that + holds a function (the type itself, a union's or optional's member, a tuple's field, an array's + element, a reference's pointee) to a type that holds none (`any`, `unknown`, an opaque pointer, an + integer, anything else; a class or object output counts its fields, a vtable's address is the + compiler's own), or a call of a declared (body-less) function with an argument whose type holds a + function anywhere, class and object fields included. Decided by types, so no route of the value + matters (fix round 3: a first version followed values and missed a global initialized with the + function, a wrapper, a returned function, a method reference, a tuple). Negatives `own_err_declared_callback` (`signal(15, onSig as Opaque)`; `onSig` overwrites `gh.c`, which a - caller of `raise(15)` borrows). + caller of `raise(15)` borrows), `_global` (`let cb = onSig`), `_method` (`k.m as Opaque`) and + `_tuple` (`reg([onSig, 1])`, compiled only). Teeth: with the member closure skipped, `own_err_interface_member_types` and `_methods` compile and print `5 77777` (gc `5 5`). With the C rule kept on despite an escaping function, @@ -3488,3 +3496,12 @@ assert (`kept value still there`): the library side AOT, the importer side AOT a The ray tracer built as a program stays at 15 errors (the same ones); as an object file, 19. Corpus: 528 before and after, the same files. + +**Fix round 3** (2026-10-22). The member types of an interface whose values are all made from class +objects (`const r2: Runner2 = rr`, the vtable read through the object's class) were not read: the +class vtables joined the interface's vtables only after the member closure. They join before it now +(negatives `own_err_interface_member_class`, `own_err_interface_member_any`). The C-callback test is +by type (the bullet above). Teeth: with the class vtables left out of the closure, both new member +negatives compile and print `5 77777` (gc `5 5`); with the type test off, `_global` and `_method` +print `77777 9` (gc `5 9`). The ray tracer built as a program stays at 15 errors; corpus 528, the +same files. diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index a31b0ebb4..c4776a32c 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -1812,6 +1812,16 @@ class OwnershipSignaturePass : public mlir::PassWrapper...vtbl`, `...vtbl`): the interface may have an // implementation outside its families. Belt and braces - every way to make an interface @@ -3161,71 +3164,107 @@ class OwnershipSignaturePass : public mlir::PassWrapper &seen) { - auto isFunction = [](mlir::Type type) { - return mlir::isa(type); + if (!type || !seen.insert(type).second) + { + return false; + } + + if (mlir::isa(type)) + { + return true; + } + + auto any = [&](llvm::ArrayRef infos) { + return llvm::any_of(infos, [&](const mlir_ts::FieldInfo &info) { return holdsFunction(info.type, fields, seen); }); }; - llvm::SmallVector work; - module.walk([&](mlir_ts::SymbolRefOp symbolRefOp) { - auto funcOp = functions.lookup(symbolRefOp.getIdentifier()); - if (funcOp && !funcOp.isDeclaration() && !symbolRefOp->getParentOfType()) - { - work.push_back(symbolRefOp.getResult()); - } - }); + if (auto tupleType = mlir::dyn_cast(type)) + { + return any(tupleType.getFields()); + } - llvm::DenseSet seen; - while (!work.empty() && !functionsEscape) + if (auto constTupleType = mlir::dyn_cast(type)) { - auto value = work.pop_back_val(); - if (!seen.insert(value).second) + return any(constTupleType.getFields()); + } + + if (auto classStorageType = mlir::dyn_cast(type)) + { + return fields && any(classStorageType.getFields()); + } + + if (auto objectStorageType = mlir::dyn_cast(type)) + { + return fields && any(objectStorageType.getFields()); + } + + if (mlir::isa(type) && !fields) + { + return false; + } + + auto found = false; + type.walkImmediateSubElements([](mlir::Attribute) {}, [&](mlir::Type sub) { found = found || holdsFunction(sub, fields, seen); }); + return found; + } + + void findEscapingFunctions(mlir::ModuleOp module) + { + module.walk([&](mlir::Operation *op) { + if (functionsEscape) { - continue; + return; } - for (auto &use : value.getUses()) + // a function seen as something that holds none (the compiler's own seeing of an object + // as an opaque pointer - a class, an object - holds no function at this level) + if (auto castOp = mlir::dyn_cast(op)) { - auto *user = use.getOwner(); - if (auto castOp = mlir::dyn_cast(user)) + // a vtable's address (a class's, an interface's, a thunk's cell) seen as an opaque + // pointer or an integer: the compiler's own, inside a vtable or storing an object's + // vtable, never a value a program gives C + auto vtableOp = op->getParentOfType(); + auto addressOfOp = addressOfThroughCasts(castOp.getIn()); + auto isVTable = [](llvm::StringRef name) { return name.ends_with("..vtbl") || name.contains("..vtbl."); }; + if ((vtableOp && isVTable(vtableOp.getSymName())) || (addressOfOp && isVTable(addressOfOp.getGlobalName()))) { - if (isFunction(castOp.getType())) - { - work.push_back(castOp.getResult()); - } - else - { - functionsEscape = true; - } - - continue; + return; } - if (mlir::isa(user)) - { - work.push_back(user->getResult(0)); - continue; - } + llvm::DenseSet seenIn; + llvm::DenseSet seenOut; + functionsEscape = holdsFunction(castOp.getIn().getType(), false, seenIn) && + !holdsFunction(castOp.getType(), /*fields=*/true, seenOut); return; + } - if (isCall(user) && calleeValue(user) != use.get()) + // a declared function given anything that holds a function + if (isCall(op)) + { + auto callee = directCallee(op); + auto funcOp = callee ? functions.lookup(callee.getValue()) : mlir_ts::FuncOp(); + if (funcOp && funcOp.isDeclaration()) { - auto callee = directCallee(user); - auto funcOp = callee ? functions.lookup(callee.getValue()) : mlir_ts::FuncOp(); - if (funcOp && funcOp.isDeclaration()) - { - functionsEscape = true; - } + functionsEscape = llvm::any_of(callArgs(op), [&](mlir::Value arg) { + llvm::DenseSet seen; + return holdsFunction(arg.getType(), true, seen); + }); } } - } + }); } // class name -> its storage, from every class type a function's signature or a global mentions diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 78716aa7b..92bdd1a5b 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3832,6 +3832,11 @@ set(own_error_cases "own_err_interface_member_types|borrows a field but is used here after it may be released or overwritten" "own_err_interface_member_methods|borrows a field but is used here after it may be released or overwritten" "own_err_declared_callback|borrows 'gh.c' but is used here after it may be released or overwritten" + "own_err_declared_callback_global|borrows 'gh.c' but is used here after it may be released or overwritten" + "own_err_declared_callback_method|borrows 'gh.c' but is used here after it may be released or overwritten" + "own_err_declared_callback_tuple|borrows 'gh.c' but is used here after it may be released or overwritten" + "own_err_interface_member_class|borrows a field but is used here after it may be released or overwritten" + "own_err_interface_member_any|borrows a field but is used here after it may be released or overwritten" "own_err_override_nameable_base|is a parameter, kept here, but -mm=own cannot make the callers of 'Der.keep' give it up" "own_err_override_imported_base|is a parameter, kept here, but -mm=own cannot make the callers of 'Der.keep' give it up" "own_err_override_imported_class|is a parameter, kept here, but -mm=own cannot make the callers of 'Der.keep' give it up" diff --git a/tslang/test/tester/own/own_err_declared_callback_global.ts b/tslang/test/tester/own/own_err_declared_callback_global.ts new file mode 100644 index 000000000..7bb5d2a76 --- /dev/null +++ b/tslang/test/tester/own/own_err_declared_callback_global.ts @@ -0,0 +1,13 @@ +// -mm=own rejects: a C function of numbers (`raise`) in a module that casts a function to an opaque +// pointer - here one a global was initialized with: `raise` may call it back, and it overwrites `gh.c`, +// which `c` borrows (spec 31). +class V { constructor(public x: number) {} } +class H { c: V = new V(5); } +function churn() { let keep: V[] = []; for (let i = 0; i < 2000; i++) { keep.push(new V(77777)); } return keep.length; } +declare function signal(sig: s32, handler: Opaque): Opaque; +declare function raise(sig: s32): s32; +let gh = new H(); +function onSig(sig: s32) { gh.c = new V(9); } +function use() { const c = gh.c; raise(15); churn(); return c.x; } +let cb = onSig; +function main() { signal(15, cb as Opaque); print(use()); print(gh.c.x); } diff --git a/tslang/test/tester/own/own_err_declared_callback_method.ts b/tslang/test/tester/own/own_err_declared_callback_method.ts new file mode 100644 index 000000000..66cac8a5a --- /dev/null +++ b/tslang/test/tester/own/own_err_declared_callback_method.ts @@ -0,0 +1,12 @@ +// -mm=own rejects: a C function of numbers (`raise`) in a module that casts a method reference to an +// opaque pointer: `raise` may call it back, and it overwrites `gh.c`, which `c` borrows (spec 31). +class V { constructor(public x: number) {} } +class H { c: V = new V(5); } +function churn() { let keep: V[] = []; for (let i = 0; i < 2000; i++) { keep.push(new V(77777)); } return keep.length; } +declare function signal(sig: s32, handler: Opaque): Opaque; +declare function raise(sig: s32): s32; +let gh = new H(); +function onSig(sig: s32) { gh.c = new V(9); } +function use() { const c = gh.c; raise(15); churn(); return c.x; } +class K { m(s: s32) { gh.c = new V(9); } } +function main() { const k = new K(); signal(15, k.m as Opaque); print(use()); print(gh.c.x); } diff --git a/tslang/test/tester/own/own_err_declared_callback_tuple.ts b/tslang/test/tester/own/own_err_declared_callback_tuple.ts new file mode 100644 index 000000000..90e3a23c2 --- /dev/null +++ b/tslang/test/tester/own/own_err_declared_callback_tuple.ts @@ -0,0 +1,13 @@ +// -mm=own rejects: a C function of numbers (`raise`) in a module that gives a declared function a +// tuple holding a function: `raise` may call it back, and it overwrites `gh.c`, which `c` borrows +// (spec 31). Compiled only: there is no C `reg` to link. +class V { constructor(public x: number) {} } +class H { c: V = new V(5); } +function churn() { let keep: V[] = []; for (let i = 0; i < 2000; i++) { keep.push(new V(77777)); } return keep.length; } +declare function signal(sig: s32, handler: Opaque): Opaque; +declare function raise(sig: s32): s32; +let gh = new H(); +function onSig(sig: s32) { gh.c = new V(9); } +function use() { const c = gh.c; raise(15); churn(); return c.x; } +declare function reg(t: [(s: s32) => void, s32]): void; +function main() { reg([onSig, 1]); print(use()); print(gh.c.x); } diff --git a/tslang/test/tester/own/own_err_interface_member_any.ts b/tslang/test/tester/own/own_err_interface_member_any.ts new file mode 100644 index 000000000..c8db216af --- /dev/null +++ b/tslang/test/tester/own/own_err_interface_member_any.ts @@ -0,0 +1,15 @@ +// -mm=own rejects: own_err_interface_member_class through `any`: a `Runner2` made from a class +// object, put into `any` and read back as a `Runner` (spec 31). +class V { constructor(public x: number) {} } +class H { c: V = new V(5); } +function churn() { let keep: V[] = []; for (let i = 0; i < 2000; i++) { keep.push(new V(77777)); } return keep.length; } +interface Op { run(h: H): number; } +interface Op2 { run(h: H): number; } +class OA2 implements Op2 { run(h: H): number { return h.c.x; } } +class OB implements Op { run(h: H): number { h.c = new V(9); return 0; } } +function f2(o: Op2, h: H) { const c = h.c; o.run(h); churn(); return c.x; } +interface Runner { go(o: Op, h: H): number; } +interface Runner2 { go(o: Op2, h: H): number; } +class R implements Runner2 { go(o: Op2, h: H) { const c = h.c; o.run(h); churn(); return c.x; } } +function call(a: any) { const r = a as Runner; return r.go(new OB(), new H()); } +function main() { const rr = new R(); print(rr.go(new OA2(), new H())); const r2: Runner2 = rr; print(call(r2 as any)); } diff --git a/tslang/test/tester/own/own_err_interface_member_class.ts b/tslang/test/tester/own/own_err_interface_member_class.ts new file mode 100644 index 000000000..6ca3871e6 --- /dev/null +++ b/tslang/test/tester/own/own_err_interface_member_class.ts @@ -0,0 +1,17 @@ +// -mm=own rejects: v11f's shape whose `Runner2` values are made only from a class object (the first +// call goes to the class directly): a `Runner2` seen as a `Runner` hands an `Op` to `R.go`, whose call +// through `Op2` reaches `OB.run`, which overwrites `h.c`. The class's vtable for `Runner2` gives its +// members' types (spec 31). +class V { constructor(public x: number) {} } +class H { c: V = new V(5); } +function churn() { let keep: V[] = []; for (let i = 0; i < 2000; i++) { keep.push(new V(77777)); } return keep.length; } +interface Op { run(h: H): number; } +interface Op2 { run(h: H): number; } +class OA2 implements Op2 { run(h: H): number { return h.c.x; } } +class OB implements Op { run(h: H): number { h.c = new V(9); return 0; } } +function f2(o: Op2, h: H) { const c = h.c; o.run(h); churn(); return c.x; } +interface Runner { go(o: Op, h: H): number; } +interface Runner2 { go(o: Op2, h: H): number; } +class R implements Runner2 { go(o: Op2, h: H) { const c = h.c; o.run(h); churn(); return c.x; } } +function call(r2: Runner2) { const r: Runner = r2; return r.go(new OB(), new H()); } +function main() { const rr = new R(); print(rr.go(new OA2(), new H())); const r2: Runner2 = rr; print(call(r2)); } From cc17980804a4cfebb1406b43af310b794c1f929f Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 15:27:43 +0100 Subject: [PATCH 28/57] own-raytrace T6: -mm=own: closed interfaces, re-review 3 - C callbacks through indirect calls - The C-scalar rule's escape test judged a call by its direct callee only, so a declared function called through a value (`let sg = signal; sg(15, onSig)`, a global holding it, a wrapper's parameter, a C-returned pointer cast to a function), a `declare class` or `@cpp` method and a foreign interface's member left the rule on (review6r3 n7b/n7d/n7e: own `77777 9`, gc `5 9`; n7g, n8b, n8c, n9). A call now counts when `resolve` does not tie it to functions defined here (a private class family, a closed interface's slot or field are this module's), so the test runs after the interface families are collected. - A type holds a function through an interface when a slot of a vtable its values are made with, past the header, holds a method or a function-typed field, or a field's type holds one, recursively; an interface no vtable here makes values with holds one (controller ruling (b): every interface counting turned the rule off in the ray tracer through `reduce`'s helper, whose callback parameter takes a `Light`). A class or object cast to `any`, `unknown` or an opaque pointer takes its fields along (n2b). - Negatives own_err_declared_callback_indirect, _wrapper, _class_method, _interface_field. - Spec 31: the C-callback bullet states the test; the CreateTuple/CreateArray retyping under "Not here"; fix round 4. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 70 +++++++++--- .../lib/TypeScript/OwnershipSignaturePass.cpp | 102 +++++++++++++----- tslang/test/tester/CMakeLists.txt | 4 + .../own_err_declared_callback_class_method.ts | 13 +++ .../own/own_err_declared_callback_indirect.ts | 12 +++ ...n_err_declared_callback_interface_field.ts | 16 +++ .../own/own_err_declared_callback_wrapper.ts | 13 +++ 7 files changed, 189 insertions(+), 41 deletions(-) create mode 100644 tslang/test/tester/own/own_err_declared_callback_class_method.ts create mode 100644 tslang/test/tester/own/own_err_declared_callback_indirect.ts create mode 100644 tslang/test/tester/own/own_err_declared_callback_interface_field.ts create mode 100644 tslang/test/tester/own/own_err_declared_callback_wrapper.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 1cae55248..4506ab4eb 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 @@ -3394,9 +3394,10 @@ a block freed too early is reused before it is read. nothing: it is given no block of this module. `sqrt`, `floor`, `pow` and `fabs` in the ray tracer. The risk, as for `@nodrops`, is a C function calling back into the module: one registered through another declaration (`signal(15, onSig as Opaque)`, then `raise(15)`; a window procedure and - `SendMessage`), or an exported function reached by name. The first is closed by type below (the - rule is off in a module that casts a function-holding value to a type that holds none, or gives a - declared function an argument whose type holds a function); the second remains. + `SendMessage`), or an exported function reached by name. The first is answered by type below (the + rule is off in a module that casts a function-holding value to a type that holds none, or has a + call that is not resolved to functions of this module with an argument whose type holds a + function); the second remains. `own_declared_scalar_no_drops` holds a field's borrow across `sqrt`, across a function that calls `sqrt` and `floor`, and across a call through a closed interface whose member does; `own_err_declared_drops` (`strlen(s: string)`) is unchanged. @@ -3452,6 +3453,10 @@ Not here, also: - A class value reinterpreted through `any` (`new C() as any as C2`, `C2`'s field an `Op2` where `C`'s is an `Op`) is not followed; the program stops at run time under every model, before any call through the field. +- A function value retyped inside a `ts.CreateTuple` or `ts.CreateArray` (a `HasF2` element put + into a `HasF` field) is not seen as a cast, by the member closure or by the C-callback test; no + program that compiles today reaches it (every such program is stopped by another ownership + error first). **After re-review** (2026-10-22): @@ -3475,17 +3480,40 @@ Not here, also: `own_err_override_nameable_base` (library side), `own_err_override_imported_base` and `own_err_override_imported_class` (importer side, the latter a private override of a concrete base); the library-and-importer pairs run under rc, gc and none (`test-runner -shared`). -- **C callbacks.** The C-scalar rule is off in a module that contains a `ts.Cast` from a type that - holds a function (the type itself, a union's or optional's member, a tuple's field, an array's - element, a reference's pointee) to a type that holds none (`any`, `unknown`, an opaque pointer, an - integer, anything else; a class or object output counts its fields, a vtable's address is the - compiler's own), or a call of a declared (body-less) function with an argument whose type holds a - function anywhere, class and object fields included. Decided by types, so no route of the value +- **C callbacks.** The C-scalar rule is off in a module that has any of: + - a call that is not resolved to functions defined (with a body) in this module - an indirect + call through a function value (a local or global holding a declared function, a function + parameter, a pointer C returned cast to a function type), a call of a declared function, of a + `declare class` method or an `@cpp` `@slot` method, a call through an interface whose + implementations are not all this module's (an open interface) - with an argument whose type + holds a function; + - a `ts.Cast` from a type that holds a function to a type that holds none (`any`, `unknown`, an + opaque pointer, an integer, anything else; a class or object output counts its fields; a + vtable's address is the compiler's own and does not count); + - a `ts.Cast` of a class, object or interface value to `any`, `unknown` or an opaque pointer when + its type holds a function (`reg(new K())` into `reg(p: any)`, `K` with a function field). + + A type holds a function when it is one, or a union's or optional's member, a tuple's field, an + array's element or a reference's pointee does, recursively; a class or object does when a field + does (the cast of a class or object to a type other than `any`, `unknown` or opaque - a boolean, + a base - does not look at its fields); an interface does when a slot of a vtable its values are + made with, past the header, holds a method or a function-typed field, or a field's type holds one + (recursively: an interface field whose interface has methods, a class field with a function + field, an array of them) - the header `.instanceOf`, the compiler's own, does not count - and an + interface no vtable of this module makes values with (one a declared function returns) holds one. + A call is resolved to this module's functions when it names one, is a virtual call of a private + class family, or goes through a closed interface's slot or function-typed field (`resolve`); the + test runs after the interface families are known. Decided by types, so no route of the value matters (fix round 3: a first version followed values and missed a global initialized with the - function, a wrapper, a returned function, a method reference, a tuple). Negatives + function, a wrapper, a returned function, a method reference, a tuple; fix round 4: a second + looked only at direct calls of declarations and missed every indirect call into C). Negatives `own_err_declared_callback` (`signal(15, onSig as Opaque)`; `onSig` overwrites `gh.c`, which a - caller of `raise(15)` borrows), `_global` (`let cb = onSig`), `_method` (`k.m as Opaque`) and - `_tuple` (`reg([onSig, 1])`, compiled only). + caller of `raise(15)` borrows), `_global` (`let cb = onSig`), `_method` (`k.m as Opaque`), + `_tuple` (`reg([onSig, 1])`, compiled only), `_indirect` (`let sg = signal; sg(15, onSig)`), + `_wrapper` (`reg(signal)`, `reg` calling its parameter with `onSig`), `_class_method` + (`w.reg(onSig)` on a `declare class`, compiled only) and `_interface_field` (a data-only `Box` + whose field is a `CB` with a method, given to a declared function through a function value, + compiled only). Teeth: with the member closure skipped, `own_err_interface_member_types` and `_methods` compile and print `5 77777` (gc `5 5`). With the C rule kept on despite an escaping function, @@ -3505,3 +3533,21 @@ by type (the bullet above). Teeth: with the class vtables left out of the closur negatives compile and print `5 77777` (gc `5 5`); with the type test off, `_global` and `_method` print `77777 9` (gc `5 9`). The ray tracer built as a program stays at 15 errors; corpus 528, the same files. + +**Fix round 4** (2026-10-22). The C-callback test looked only at direct calls of declarations, so a +declared function called through a value (`let sg = signal; sg(15, onSig)`, a global holding it, a +wrapper's parameter, a C-returned pointer cast to a function), a `declare class` or `@cpp` method +and a foreign interface's member left the rule on (own printed `77777 9`, gc `5 9`). A call is now +judged by what it resolves to (the bullet above), interfaces count by what their vtables hold, and +a class or object cast to `any`/`unknown`/opaque takes its fields along. "An interface holds a +function" was first taken as every interface (controller ruling, option (a)): that turned the rule +off in the ray tracer through `reduce`'s helper, `___array_reduce`, whose +`f(r, v)` calls its function parameter with a `Light` - `{ pos; color }`, no method, no function +field - and the ray tracer went to 31 errors, the 16 cleared by the C-scalar rule back. Option (b), +by the vtable's slots past the header, recursively through field types, keeps it at the same 15 and +rejects every probe of the review (n7b, n7d, n7e, n7g, n8b, n8c, n9, n1b, n11, n2b, and the earlier +k1-k10, c3, n6, n10, n13, n16, n17, n18). Negatives `_indirect`, `_wrapper`, `_class_method`, +`_interface_field`. Teeth: with the call test back to direct calls of declarations, `_indirect` and +`_wrapper` compile and print `77777 9` (gc `5 9`), and `_class_method` and `_interface_field` +compile. The ray tracer built as a program stays at 15 errors (the same ones); as an object file, +19. Corpus 528, the same files. diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index c4776a32c..8e441beab 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -390,9 +390,10 @@ class OwnershipSignaturePass : public mlir::PassWrapper, InterfaceFieldFamily> interfaceFieldFamilies; + // interface name -> the tuple types of the vtables its values are made with, when every one of + // them is read here (holdsFunction: what a value of the interface carries) + llvm::StringMap> interfaceVTableTypes; + // `ts.PropertyRef(ts.Cast(null), i)`, seen through casts: the offset of field `i` of an object of // that type, as an object literal's interface vtable holds it. Null for anything else. std::pair slotField(mlir::Value value) @@ -1902,7 +1907,8 @@ class OwnershipSignaturePass : public mlir::PassWrapper>; + // Does a value of this type hold a function: the type itself, a union's or an optional's - // member, a tuple's field, an array's element, what a reference points to, and with `fields` - // a class's or an object's fields. - static bool holdsFunction(mlir::Type type, bool fields, llvm::DenseSet &seen) + // member, a tuple's field, an array's element, what a reference points to, an interface whose + // vtable holds one past its header - a method, a function-typed field, or a field whose type + // holds one, recursively (an interface with methods, a class with a function field, an array of + // them) - or whose vtable this module does not make values with (a foreign one: `declare + // function makeW(): W`), and with `fields` a class's or an object's fields. The header slot + // (`.instanceOf`, the compiler's own, which destroys nothing) does not count. + bool holdsFunction(mlir::Type type, bool fields, SeenTypes &seen) { - if (!type || !seen.insert(type).second) + if (!type || !seen.insert({type, fields}).second) { return false; } @@ -3188,6 +3212,20 @@ class OwnershipSignaturePass : public mlir::PassWrapper(type)) + { + auto found = interfaceVTableTypes.find(interfaceType.getName().getValue()); + if (found == interfaceVTableTypes.end()) + { + return true; + } + + return llvm::any_of(found->second, [&](mlir_ts::TupleType vtableType) { + return llvm::any_of(llvm::drop_begin(vtableType.getFields(), 1), + [&](const mlir_ts::FieldInfo &info) { return holdsFunction(info.type, /*fields=*/true, seen); }); + }); + } + auto any = [&](llvm::ArrayRef infos) { return llvm::any_of(infos, [&](const mlir_ts::FieldInfo &info) { return holdsFunction(info.type, fields, seen); }); }; @@ -3222,6 +3260,16 @@ class OwnershipSignaturePass : public mlir::PassWrapper(op)) { // a vtable's address (a class's, an interface's, a thunk's cell) seen as an opaque @@ -3245,24 +3292,21 @@ class OwnershipSignaturePass : public mlir::PassWrapper seenIn; - llvm::DenseSet seenOut; - functionsEscape = holdsFunction(castOp.getIn().getType(), false, seenIn) && - !holdsFunction(castOp.getType(), /*fields=*/true, seenOut); return; + // a class or object seen as `any`, `unknown` or an opaque pointer takes its fields + // along; seen as anything else (a boolean, its base) it does not + auto toOpaque = mlir::isa(castOp.getType()); + SeenTypes seenIn; + SeenTypes seenOut; + functionsEscape = holdsFunction(castOp.getIn().getType(), /*fields=*/toOpaque, seenIn) && + !holdsFunction(castOp.getType(), /*fields=*/true, seenOut); } - - // a declared function given anything that holds a function - if (isCall(op)) + // a call that may reach foreign code, given anything that holds a function + else if (isCall(op) && !callsThisModule(op)) { - auto callee = directCallee(op); - auto funcOp = callee ? functions.lookup(callee.getValue()) : mlir_ts::FuncOp(); - if (funcOp && funcOp.isDeclaration()) - { - functionsEscape = llvm::any_of(callArgs(op), [&](mlir::Value arg) { - llvm::DenseSet seen; - return holdsFunction(arg.getType(), true, seen); - }); - } + functionsEscape = llvm::any_of(callArgs(op), [&](mlir::Value arg) { + SeenTypes seen; + return holdsFunction(arg.getType(), /*fields=*/true, seen); + }); } }); } diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 92bdd1a5b..f6bca3714 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3835,6 +3835,10 @@ set(own_error_cases "own_err_declared_callback_global|borrows 'gh.c' but is used here after it may be released or overwritten" "own_err_declared_callback_method|borrows 'gh.c' but is used here after it may be released or overwritten" "own_err_declared_callback_tuple|borrows 'gh.c' but is used here after it may be released or overwritten" + "own_err_declared_callback_indirect|borrows 'gh.c' but is used here after it may be released or overwritten" + "own_err_declared_callback_wrapper|borrows 'gh.c' but is used here after it may be released or overwritten" + "own_err_declared_callback_class_method|borrows 'gh.c' but is used here after it may be released or overwritten" + "own_err_declared_callback_interface_field|borrows 'gh.c' but is used here after it may be released or overwritten" "own_err_interface_member_class|borrows a field but is used here after it may be released or overwritten" "own_err_interface_member_any|borrows a field but is used here after it may be released or overwritten" "own_err_override_nameable_base|is a parameter, kept here, but -mm=own cannot make the callers of 'Der.keep' give it up" diff --git a/tslang/test/tester/own/own_err_declared_callback_class_method.ts b/tslang/test/tester/own/own_err_declared_callback_class_method.ts new file mode 100644 index 000000000..f787c3117 --- /dev/null +++ b/tslang/test/tester/own/own_err_declared_callback_class_method.ts @@ -0,0 +1,13 @@ +// -mm=own rejects: a C function of numbers (`raise`) in a module that calls a `declare class` +// method (`w.reg(onSig)`) with a function: `raise` may call it back, and it overwrites `gh.c`, which +// `c` borrows (spec 31). Compiled only: there is no C `W` to link. +class V { constructor(public x: number) {} } +class H { c: V = new V(5); } +function churn() { let keep: V[] = []; for (let i = 0; i < 2000; i++) { keep.push(new V(77777)); } return keep.length; } +declare function raise(sig: s32): s32; +let gh = new H(); +function onSig(sig: s32) { gh.c = new V(9); } +function use() { const c = gh.c; raise(15); churn(); return c.x; } +declare class W { reg(h: (s: s32) => void): void; } +declare function makeW(): W; +function main() { const w = makeW(); w.reg(onSig); print(use()); print(gh.c.x); } diff --git a/tslang/test/tester/own/own_err_declared_callback_indirect.ts b/tslang/test/tester/own/own_err_declared_callback_indirect.ts new file mode 100644 index 000000000..564b6ef5f --- /dev/null +++ b/tslang/test/tester/own/own_err_declared_callback_indirect.ts @@ -0,0 +1,12 @@ +// -mm=own rejects: a C function of numbers (`raise`) in a module that calls a declared function +// through a function value (`sg(15, onSig)`, `sg` a local holding `signal`) with a function: `raise` may +// call it back, and it overwrites `gh.c`, which `c` borrows (spec 31). +class V { constructor(public x: number) {} } +class H { c: V = new V(5); } +function churn() { let keep: V[] = []; for (let i = 0; i < 2000; i++) { keep.push(new V(77777)); } return keep.length; } +declare function signal(sig: s32, handler: (s: s32) => void): Opaque; +declare function raise(sig: s32): s32; +let gh = new H(); +function onSig(sig: s32) { gh.c = new V(9); } +function use() { const c = gh.c; raise(15); churn(); return c.x; } +function main() { let sg = signal; sg(15, onSig); print(use()); print(gh.c.x); } diff --git a/tslang/test/tester/own/own_err_declared_callback_interface_field.ts b/tslang/test/tester/own/own_err_declared_callback_interface_field.ts new file mode 100644 index 000000000..314b5a0e9 --- /dev/null +++ b/tslang/test/tester/own/own_err_declared_callback_interface_field.ts @@ -0,0 +1,16 @@ +// -mm=own rejects: a C function of numbers (`raise`) in a module that calls a declared function +// through a function value (`r(box)`, `r` a local holding `creg`) with an interface that holds a +// function through its field: `Box` has only the field `cb`, whose interface `CB` has a method. +// `raise` may call it back, and it overwrites `gh.c`, which `c` borrows (spec 31). Compiled only: +// there is no C `creg` to link. +class V { constructor(public x: number) {} } +class H { c: V = new V(5); } +function churn() { let keep: V[] = []; for (let i = 0; i < 2000; i++) { keep.push(new V(77777)); } return keep.length; } +declare function raise(sig: s32): s32; +let gh = new H(); +interface CB { call(s: s32): void; } +class CBI implements CB { call(s: s32) { gh.c = new V(9); } } +interface Box { cb: CB; } +declare function creg(b: Box): void; +function use() { const c = gh.c; raise(15); churn(); return c.x; } +function main() { const box: Box = { cb: new CBI() }; let r = creg; r(box); print(use()); print(gh.c.x); } diff --git a/tslang/test/tester/own/own_err_declared_callback_wrapper.ts b/tslang/test/tester/own/own_err_declared_callback_wrapper.ts new file mode 100644 index 000000000..c1c319db6 --- /dev/null +++ b/tslang/test/tester/own/own_err_declared_callback_wrapper.ts @@ -0,0 +1,13 @@ +// -mm=own rejects: a C function of numbers (`raise`) in a module whose wrapper calls its function +// parameter (`reg(signal)`, `f(15, onSig)` in `reg`) with a function: `raise` may call it back, and +// it overwrites `gh.c`, which `c` borrows (spec 31). +class V { constructor(public x: number) {} } +class H { c: V = new V(5); } +function churn() { let keep: V[] = []; for (let i = 0; i < 2000; i++) { keep.push(new V(77777)); } return keep.length; } +declare function signal(sig: s32, handler: (s: s32) => void): Opaque; +declare function raise(sig: s32): s32; +let gh = new H(); +function onSig(sig: s32) { gh.c = new V(9); } +function use() { const c = gh.c; raise(15); churn(); return c.x; } +function reg(f: (sig: s32, h: (s: s32) => void) => Opaque) { f(15, onSig); } +function main() { reg(signal); print(use()); print(gh.c.x); } From c18c14a0f3425fc6826f01ed6f4aba8f1ad79835 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 16:04:18 +0100 Subject: [PATCH 29/57] own-raytrace T6: -mm=own: closed interfaces, re-review 4 - C callbacks by a closed-world surface test The C-scalar no-drops rule (a body-less declared function of numbers destroys nothing) was guarded by a list of the routes a function value takes out of the module - a cast, a call into foreign code - and re-review 4 found two more: a function stored into memory C reads (a `declare var` of a function type, a `Reference` a C function returned, an `Opaque` seen as a class with a function field, a `declare class` with one) and a `@cpp` object handed to C++, whose C++ vtable slots are this module's methods. Inverted to a closed-world whitelist (controller ruling, fix round 5): the rule holds only in a module whose entire foreign surface is scalar - every body-less function (C's, imported, a `declare class`'s, a `@cpp` slot), every declared or imported global, every `@cpp` class's fields and bases with no method of its own, every function found in a library at run time - and that turns no function into data: no cast from a type holding a function to one holding none (a boolean excepted: it cannot carry one), no cast making an opaque pointer or a reference out of a number. The call-resolving escape walk and the argument-type tests are removed. Negatives own_err_declared_callback_stored_global, _stored_reference, _cpp_class (compiled only). Teeth: with the surface test skipped, own_err_declared_callback prints 77777 9 under own (gc 5 9) and the new negatives compile. Every probe of rounds 1-4 stays rejected; casts to boolean keep the rule. Ray tracer as a program: 15 errors (the same). Corpus 528, the same files. Spec 31 rewritten to state the whitelist, with a "Fix round 5" paragraph. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 102 ++++++-- .../lib/TypeScript/OwnershipSignaturePass.cpp | 242 +++++++++++++----- tslang/test/tester/CMakeLists.txt | 3 + .../own_err_declared_callback_cpp_class.ts | 22 ++ ...own_err_declared_callback_stored_global.ts | 14 + ..._err_declared_callback_stored_reference.ts | 14 + 6 files changed, 302 insertions(+), 95 deletions(-) create mode 100644 tslang/test/tester/own/own_err_declared_callback_cpp_class.ts create mode 100644 tslang/test/tester/own/own_err_declared_callback_stored_global.ts create mode 100644 tslang/test/tester/own/own_err_declared_callback_stored_reference.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 4506ab4eb..11854087b 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 @@ -3480,40 +3480,66 @@ Not here, also: `own_err_override_nameable_base` (library side), `own_err_override_imported_base` and `own_err_override_imported_class` (importer side, the latter a private override of a concrete base); the library-and-importer pairs run under rc, gc and none (`test-runner -shared`). -- **C callbacks.** The C-scalar rule is off in a module that has any of: - - a call that is not resolved to functions defined (with a body) in this module - an indirect - call through a function value (a local or global holding a declared function, a function - parameter, a pointer C returned cast to a function type), a call of a declared function, of a - `declare class` method or an `@cpp` `@slot` method, a call through an interface whose - implementations are not all this module's (an open interface) - with an argument whose type - holds a function; +- **C callbacks.** A C function the module declares may call a function of the module back during + the call, and that function may destroy what a caller borrows (`signal(15, onSig as Opaque)`, + then `const c = gh.c; raise(15); ... c.x` with `onSig` overwriting `gh.c`). Foreign code can only + learn of a function of this module through the module's foreign surface, or through a function + turned into data; so the C-scalar rule holds only in a module whose **entire foreign surface is + scalar** and that **turns no function into data** (controller ruling, fix round 5; a closed-world + test, in place of the three earlier rounds' list of the routes a function value takes out, which + each review extended). A type is scalar when it is a number, a boolean, a character, an integer or + float kind, or an enum or a literal of one. Not scalar: an opaque pointer, a reference, a string, + a class, an interface, an object, a tuple, an array, a union with such a member, a function type, + `any`, `unknown`, a rest parameter. The foreign surface, every item of which must be scalar: + - every body-less function - each parameter and the result (void is fine): a C declaration, + another module's function (declared here with `import`, or a DLL's), a `declare class`'s + method, a `@cpp` class's `@slot` declaration, and the compiler's own members of a `declare + class` (`..instanceOf`, `..new`), which take the class; + - every global declared with no initializer or imported: a `declare var`, a `declare class`'s + vtable and type-name string (so a `declare class` always turns the rule off: a sound narrowing + of the ruling's "any member"); + - every `@cpp` class's fields and its bases' (a base is a `@cpp` class of its own), with no method + of its own with a body: C++ calls one through the C++ vtable; + - every function found in a library at run time (`SearchForAddressOfSymbol`): the function type + its address is cast to. + + The casts that turn a function into data, or make an address of foreign memory: - a `ts.Cast` from a type that holds a function to a type that holds none (`any`, `unknown`, an - opaque pointer, an integer, anything else; a class or object output counts its fields; a - vtable's address is the compiler's own and does not count); - - a `ts.Cast` of a class, object or interface value to `any`, `unknown` or an opaque pointer when - its type holds a function (`reg(new K())` into `reg(p: any)`, `K` with a function field). + opaque pointer, an integer, anything else but a boolean, which cannot carry one; a class or + object input counts its fields when the output is `any`, `unknown` or opaque, and not when it + is anything else - a boolean, a base; a vtable's address is the compiler's own and does not + count); + - a `ts.Cast` to an opaque pointer or a reference from a type that is not itself an address of + memory this module made (`0x1234`, then `Deref(>p) = onSig`): a number, + `any`, a union. A type holds a function when it is one, or a union's or optional's member, a tuple's field, an array's element or a reference's pointee does, recursively; a class or object does when a field - does (the cast of a class or object to a type other than `any`, `unknown` or opaque - a boolean, - a base - does not look at its fields); an interface does when a slot of a vtable its values are - made with, past the header, holds a method or a function-typed field, or a field's type holds one - (recursively: an interface field whose interface has methods, a class field with a function - field, an array of them) - the header `.instanceOf`, the compiler's own, does not count - and an - interface no vtable of this module makes values with (one a declared function returns) holds one. - A call is resolved to this module's functions when it names one, is a virtual call of a private - class family, or goes through a closed interface's slot or function-typed field (`resolve`); the - test runs after the interface families are known. Decided by types, so no route of the value - matters (fix round 3: a first version followed values and missed a global initialized with the - function, a wrapper, a returned function, a method reference, a tuple; fix round 4: a second - looked only at direct calls of declarations and missed every indirect call into C). Negatives + does; an interface does when a slot of a vtable its values are made with, past the header, holds + a method or a function-typed field, or a field's type holds one (recursively: an interface field + whose interface has methods, a class field with a function field, an array of them) - the header + `.instanceOf`, the compiler's own, does not count - and an interface no vtable of this module + makes values with (one a declared function returns) holds one. The test runs after the class + storages and the interface families are known. No call is examined: a function value given to a + call can only reach foreign code through a declared symbol, which the surface test already + judged by its type. Not here: a function reached by name - an exported function of a library + module, which another module may hand to C - stays accepted, as it was with `@nodrops` (an + executable's exports are visible to no module, above). Negatives `own_err_declared_callback` (`signal(15, onSig as Opaque)`; `onSig` overwrites `gh.c`, which a caller of `raise(15)` borrows), `_global` (`let cb = onSig`), `_method` (`k.m as Opaque`), `_tuple` (`reg([onSig, 1])`, compiled only), `_indirect` (`let sg = signal; sg(15, onSig)`), `_wrapper` (`reg(signal)`, `reg` calling its parameter with `onSig`), `_class_method` - (`w.reg(onSig)` on a `declare class`, compiled only) and `_interface_field` (a data-only `Box` + (`w.reg(onSig)` on a `declare class`, compiled only), `_interface_field` (a data-only `Box` whose field is a `CB` with a method, given to a declared function through a function value, - compiled only). + compiled only), `_stored_global` (`declare var hook: (s: s32) => void; hook = onSig`, compiled + only), `_stored_reference` (`Deref(getSlot()) = onSig` through a declared + `getSlot(): Reference<(s: s32) => void>`, compiled only) and `_cpp_class` (`addListener(new + MyL())`, `MyL` a `@cpp` class whose `on` has a body, compiled only). Cost: a module with any + non-scalar declaration - a C function taking a string, a struct or a pointer, a `declare class`, + a `@cpp` class with methods, an imported function - loses the rule for its scalar declarations + too (explicit `@nodrops` still works); a data-only interface handed to a declared function + (review6r4 b9) is such a declaration. The ray tracer declares four scalar functions and nothing + else. Teeth: with the member closure skipped, `own_err_interface_member_types` and `_methods` compile and print `5 77777` (gc `5 5`). With the C rule kept on despite an escaping function, @@ -3551,3 +3577,29 @@ k1-k10, c3, n6, n10, n13, n16, n17, n18). Negatives `_indirect`, `_wrapper`, `_c `_wrapper` compile and print `77777 9` (gc `5 9`), and `_class_method` and `_interface_field` compile. The ray tracer built as a program stays at 15 errors (the same ones); as an object file, 19. Corpus 528, the same files. + +**Fix round 5** (2026-10-22). Round 4's test still listed the routes a function takes out of the +module - a cast, a call into foreign code - and the re-review found two it did not list: a +function stored into memory C reads (`declare var hook: fn; hook = onSig`; `Deref(getSlot()) = +onSig` through a declared `Reference`; an `Opaque` a C function returned seen as a class with +a function field, then a store; a `declare class` with a function field) and a `@cpp` object handed +to C++, whose C++ vtable slots are this module's methods (`addListener(new MyL())`, also as +`Opaque`, also through a C++ method). The test is inverted (the bullet above): instead of naming +what takes a function out, the rule holds only where nothing can - every declared, imported, +`@cpp` or run-time-found symbol is scalar, and no cast turns a function into data or makes an +address out of a number. The enumeration it replaces is gone: no call is resolved for it, no +argument's type is walked. Also from the re-review: a cast to a boolean (`if (s)` on an interface +with methods, `if (cb)` on an optional callback, `cb?.(1)`) no longer turns the rule off; the +earlier text's "no route of the value matters" was false, and the `.instanceOf` slot and a field +holding only defined functions are no longer part of any call test. Negatives `_stored_global`, +`_stored_reference`, `_cpp_class`. Teeth: with the surface test skipped, `own_err_declared_callback` +(`signal(15, onSig as Opaque)`) compiles and prints `77777 9` (gc `5 9`), and the three new +negatives, review6r4 s8 and the `@cpp`-through-an-integer probe (`addListener((new MyL() as +Opaque))`, rejected only by the `@cpp` clause) compile. Every probe of rounds 1-4 is still +rejected (review6r1 c3, review6r2 k1-k10, review6r3 n1b n2b n6 n7b n7d n7e n7g n8b n8c n9 n10 n11 +n13 n16 n17 n18, review6r4 a1-a8, b2-b8, e1-e3, s1 s3 s5 s6 s8 s9, c1 c2 c3); d1, d3, d4, d5, d6, d6c +keep the rule; so does a program declaring only scalar functions. Newly rejected by the +whitelist, as its cost: review6r3 n0 (an unused `declare function signal(sig: s32, handler: +Opaque)`), review6r4 b1, b1b, b5 and b9 (a class or a data-only interface given to a declared +function). The ray tracer built as a program stays at 15 errors (the same ones). Corpus 528, the +same files. diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index 8e441beab..0d141f631 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -392,7 +392,7 @@ class OwnershipSignaturePass : public mlir::PassWrapperhasAttr("export") || funcOp->hasAttr("import") || - funcOp->hasAttr(DLL_EXPORT) || funcOp->hasAttr(DLL_IMPORT) || funcOp.getFunctionType().isVarArg()) + if (auto literalType = mlir::dyn_cast(type)) { - return false; + return scalarType(literalType.getElementType()); } - std::function scalar = [&](mlir::Type type) -> bool { - if (auto literalType = mlir::dyn_cast(type)) - { - return scalar(literalType.getElementType()); - } + if (auto enumType = mlir::dyn_cast(type)) + { + return scalarType(enumType.getElementType()); + } - if (auto enumType = mlir::dyn_cast(type)) - { - return scalar(enumType.getElementType()); - } + return mlir::isa(type); + } - return mlir::isa(type); - }; + // Every parameter and result scalar (a void result too), and no rest parameter. + static bool scalarSignature(mlir_ts::FunctionType functionType) + { + return !functionType.isVarArg() && llvm::all_of(functionType.getInputs(), scalarType) && + llvm::all_of(functionType.getResults(), + [](mlir::Type type) { return mlir::isa(type) || scalarType(type); }); + } - auto functionType = funcOp.getFunctionType(); - return llvm::all_of(functionType.getInputs(), scalar) && - llvm::all_of(functionType.getResults(), [&](mlir::Type type) { return mlir::isa(type) || scalar(type); }); + // A body-less `declare function` of C (no `export` or import marker: not another TypeScript + // module's, whose declarations carry `export`) whose parameters and result are scalar + // (scalarType): given no block of this module, it can destroy none (controller ruling, spec 31: + // `sqrt`, `floor`, `pow`, `fabs`). The risk is a C function that calls back into the module + // (`signal(15, f as Opaque)` then `raise(15)`, review6r1 c3), which it can only do with a + // function it was given - so the rule is off in a module whose foreign surface could hand it + // one (functionsEscape) - or an exported function reached by name, which a `@nodrops` + // declaration takes on as well. + static bool scalarDeclaration(mlir_ts::FuncOp funcOp) + { + return funcOp.isDeclaration() && !funcOp->hasAttr("export") && !funcOp->hasAttr("import") && + !funcOp->hasAttr(DLL_EXPORT) && !funcOp->hasAttr(DLL_IMPORT) && scalarSignature(funcOp.getFunctionType()); } // `ts.Load(ts.AddressOf @f)`, where the global `f` holds the address of a function exported @@ -3175,18 +3181,23 @@ class OwnershipSignaturePass : public mlir::PassWrapper0x1234`, then a + // store through it reaches foreign memory); + // - no cast makes a value of a type that holds no function (`any`, `unknown`, an opaque + // pointer, an integer; a boolean cannot carry one and does not count) out of one that holds + // one (`onSig as Opaque`, `k.m as u64`; a class or object cast to `any`, `unknown` or opaque + // takes its fields along). bool functionsEscape = false; // The types a holdsFunction walk has been through, with or without their fields. @@ -3260,54 +3271,145 @@ class OwnershipSignaturePass : public mlir::PassWrapper(inputs.front()); + auto storage = classType ? mlir::dyn_cast(classType.getStorageType()) : mlir_ts::ClassStorageType(); + if (!storage || !storage.isCpp()) + { + return {}; + } + + auto name = storage.getName().getValue(); + auto symbol = funcOp.getSymName(); + return symbol.size() > name.size() && symbol.starts_with(name) && symbol[name.size()] == '.' ? storage : mlir_ts::ClassStorageType(); + } + + // Does a value of this type carry an address of memory this module made - so a cast of it to + // an opaque pointer or a reference points into this module, not into foreign memory? + static bool addressType(mlir::Type type) + { + return mlir::isa(type); } void findEscapingFunctions(mlir::ModuleOp module) { - module.walk([&](mlir::Operation *op) { - if (functionsEscape) + // every body-less function: C's, another module's, a `declare class`'s (its methods, its + // `..instanceOf`, its `..new`), a `@cpp` class's slot + for (auto &entry : functions) + { + auto funcOp = entry.second; + if (funcOp.isDeclaration() && !scalarSignature(funcOp.getFunctionType())) { + functionsEscape = true; return; } - // a function seen as something that holds none - if (auto castOp = mlir::dyn_cast(op)) + // a method of a `@cpp` class: C++ calls it through the class's C++ vtable + if (cppClassOfMethod(funcOp)) { - // a vtable's address (a class's, an interface's, a thunk's cell) seen as an opaque - // pointer or an integer: the compiler's own, inside a vtable or storing an object's - // vtable, never a value a program gives C - auto vtableOp = op->getParentOfType(); - auto addressOfOp = addressOfThroughCasts(castOp.getIn()); - auto isVTable = [](llvm::StringRef name) { return name.ends_with("..vtbl") || name.contains("..vtbl."); }; - if ((vtableOp && isVTable(vtableOp.getSymName())) || (addressOfOp && isVTable(addressOfOp.getGlobalName()))) + functionsEscape = true; + return; + } + } + + // every global declared with no initializer or imported: a `declare var`, a `declare + // class`'s vtable (functions) and type name (a string) + for (auto &entry : globals) + { + auto globalOp = entry.second; + auto foreign = globalOp->hasAttr("import") || globalOp->hasAttr(DLL_IMPORT) || globalOp.getInitializerRegion().empty() || + globalOp.getInitializerRegion().front().empty(); + if (foreign && !scalarType(globalOp.getType())) + { + functionsEscape = true; + return; + } + } + + // every `@cpp` class's fields; a base (the first field, a storage) is a `@cpp` class of its + // own, judged on its own + for (auto &entry : classStorages) + { + auto storage = entry.second; + if (!storage.isCpp()) + { + continue; + } + + for (auto [index, field] : llvm::enumerate(storage.getFields())) + { + auto base = index == 0 ? mlir::dyn_cast(field.type) : mlir_ts::ClassStorageType(); + if (base ? !base.isCpp() : !scalarType(field.type)) { + functionsEscape = true; return; } + } + } - // a class or object seen as `any`, `unknown` or an opaque pointer takes its fields - // along; seen as anything else (a boolean, its base) it does not - auto toOpaque = mlir::isa(castOp.getType()); - SeenTypes seenIn; - SeenTypes seenOut; - functionsEscape = holdsFunction(castOp.getIn().getType(), /*fields=*/toOpaque, seenIn) && - !holdsFunction(castOp.getType(), /*fields=*/true, seenOut); + module.walk([&](mlir::Operation *op) { + if (functionsEscape) + { + return; } - // a call that may reach foreign code, given anything that holds a function - else if (isCall(op) && !callsThisModule(op)) + + // a function found in a library at run time: scalar through every cast of its address + if (mlir::isa(op)) { - functionsEscape = llvm::any_of(callArgs(op), [&](mlir::Value arg) { - SeenTypes seen; - return holdsFunction(arg.getType(), /*fields=*/true, seen); + functionsEscape = !llvm::all_of(op->getResult(0).getUsers(), [](mlir::Operation *user) { + auto castOp = mlir::dyn_cast(user); + auto functionType = castOp ? mlir::dyn_cast(castOp.getType()) : mlir_ts::FunctionType(); + return functionType && scalarSignature(functionType); }); + return; + } + + auto castOp = mlir::dyn_cast(op); + if (!castOp) + { + return; + } + + // a vtable's address (a class's, an interface's, a thunk's cell) seen as an opaque + // pointer or an integer: the compiler's own, inside a vtable or storing an object's + // vtable, never a value a program gives C + auto vtableOp = op->getParentOfType(); + auto addressOfOp = addressOfThroughCasts(castOp.getIn()); + auto isVTable = [](llvm::StringRef name) { return name.ends_with("..vtbl") || name.contains("..vtbl."); }; + if ((vtableOp && isVTable(vtableOp.getSymName())) || (addressOfOp && isVTable(addressOfOp.getGlobalName()))) + { + return; } + + // an opaque pointer or a reference made out of a number: an address of foreign memory + if (mlir::isa(castOp.getType()) && !addressType(castOp.getIn().getType())) + { + functionsEscape = true; + return; + } + + // a function seen as something that holds none; a boolean cannot carry one. A class or + // object seen as `any`, `unknown` or an opaque pointer takes its fields along; seen as + // anything else (its base) it does not + if (mlir::isa(castOp.getType())) + { + return; + } + + auto toOpaque = mlir::isa(castOp.getType()); + SeenTypes seenIn; + SeenTypes seenOut; + functionsEscape = holdsFunction(castOp.getIn().getType(), /*fields=*/toOpaque, seenIn) && + !holdsFunction(castOp.getType(), /*fields=*/true, seenOut); }); } diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index f6bca3714..13644fa66 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3839,6 +3839,9 @@ set(own_error_cases "own_err_declared_callback_wrapper|borrows 'gh.c' but is used here after it may be released or overwritten" "own_err_declared_callback_class_method|borrows 'gh.c' but is used here after it may be released or overwritten" "own_err_declared_callback_interface_field|borrows 'gh.c' but is used here after it may be released or overwritten" + "own_err_declared_callback_stored_global|borrows 'gh.c' but is used here after it may be released or overwritten" + "own_err_declared_callback_stored_reference|borrows 'gh.c' but is used here after it may be released or overwritten" + "own_err_declared_callback_cpp_class|borrows 'gh.c' but is used here after it may be released or overwritten" "own_err_interface_member_class|borrows a field but is used here after it may be released or overwritten" "own_err_interface_member_any|borrows a field but is used here after it may be released or overwritten" "own_err_override_nameable_base|is a parameter, kept here, but -mm=own cannot make the callers of 'Der.keep' give it up" diff --git a/tslang/test/tester/own/own_err_declared_callback_cpp_class.ts b/tslang/test/tester/own/own_err_declared_callback_cpp_class.ts new file mode 100644 index 000000000..097906ba2 --- /dev/null +++ b/tslang/test/tester/own/own_err_declared_callback_cpp_class.ts @@ -0,0 +1,22 @@ +// -mm=own rejects: a C function of numbers (`raise`) in a module with a `@cpp` class whose method +// has a body (`MyL.on`): a `MyL` given to C++ (`addListener(new MyL())`) is called through its C++ +// vtable, so `raise` may call `on` back, and it overwrites `gh.c`, which `c` borrows (spec 31: a +// `@cpp` class with a method of its own is not scalar). Compiled only: there is no C++ +// `addListener` to link. +class V { constructor(public x: number) {} } +class H { c: V = new V(5); } +function churn() { let keep: V[] = []; for (let i = 0; i < 2000; i++) { keep.push(new V(77777)); } return keep.length; } +declare function raise(sig: s32): s32; +let gh = new H(); +function onSig(sig: s32) { gh.c = new V(9); } +function use() { const c = gh.c; raise(15); churn(); return c.x; } +@cpp +class Listener { + @slot(0) on(s: s32): void; +} +@cpp +class MyL extends Listener { + on(s: s32): void { gh.c = new V(9); } +} +declare function addListener(l: Listener): void; +function main() { addListener(new MyL()); print(use()); print(gh.c.x); } diff --git a/tslang/test/tester/own/own_err_declared_callback_stored_global.ts b/tslang/test/tester/own/own_err_declared_callback_stored_global.ts new file mode 100644 index 000000000..180b494d6 --- /dev/null +++ b/tslang/test/tester/own/own_err_declared_callback_stored_global.ts @@ -0,0 +1,14 @@ +// -mm=own rejects: a C function of numbers (`raise`) in a module that declares a C global of a +// function type (`declare var hook`): a function stored into it (`hook = onSig`) is C's to call, so +// `raise` may call it back, and it overwrites `gh.c`, which `c` borrows (spec 31: the rule holds +// only in a module whose every declared symbol is scalar). Compiled only: there is no C `hook` to +// link. +class V { constructor(public x: number) {} } +class H { c: V = new V(5); } +function churn() { let keep: V[] = []; for (let i = 0; i < 2000; i++) { keep.push(new V(77777)); } return keep.length; } +declare function raise(sig: s32): s32; +let gh = new H(); +function onSig(sig: s32) { gh.c = new V(9); } +function use() { const c = gh.c; raise(15); churn(); return c.x; } +declare var hook: (s: s32) => void; +function main() { hook = onSig; print(use()); print(gh.c.x); } diff --git a/tslang/test/tester/own/own_err_declared_callback_stored_reference.ts b/tslang/test/tester/own/own_err_declared_callback_stored_reference.ts new file mode 100644 index 000000000..99bbfd3d3 --- /dev/null +++ b/tslang/test/tester/own/own_err_declared_callback_stored_reference.ts @@ -0,0 +1,14 @@ +// -mm=own rejects: a C function of numbers (`raise`) in a module that declares a C function +// returning a reference to a function slot (`getSlot(): Reference<(s: s32) => void>`): a function +// stored through it (`Deref(getSlot()) = onSig`) is C's to call, so `raise` may call it back, and it +// overwrites `gh.c`, which `c` borrows (spec 31: a reference or an opaque pointer in a declared +// signature is not scalar). Compiled only: there is no C `getSlot` to link. +class V { constructor(public x: number) {} } +class H { c: V = new V(5); } +function churn() { let keep: V[] = []; for (let i = 0; i < 2000; i++) { keep.push(new V(77777)); } return keep.length; } +declare function raise(sig: s32): s32; +let gh = new H(); +function onSig(sig: s32) { gh.c = new V(9); } +function use() { const c = gh.c; raise(15); churn(); return c.x; } +declare function getSlot(): Reference<(s: s32) => void>; +function main() { const p = getSlot(); Deref(p) = onSig; print(use()); print(gh.c.x); } From eac453047c5ef58ecd2f03bc1280e0063b5723f9 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 18:53:27 +0100 Subject: [PATCH 30/57] own-raytrace T6: -mm=own: closed interfaces, final patch - fields into every cast, inline_asm and call_intrinsic Two ways a function became data that the C-scalar whitelist's cast test did not see (re-review 5): a class or array with a function field cast to an integer or a reference (`>k`, then the field loaded as an integer into an all-scalar `signal(s32, u64)`) - the fields of a class or object input were counted only into any/unknown/opaque outputs - and `inline_asm(...)` / `call_intrinsic(...)`, which take any operand with no cast. Now a cast counts the input's fields for every output but a class (its base) or an interface (a view), and an InlineAsm or CallIntrinsic operand whose type holds a function turns the rule off. Negatives own_err_declared_callback_field_reference (g2) and _intrinsic (h2), runnable. Teeth, one switch per fix: each negative prints 77777 9 under own with its fix off while the other stays rejected. Ray tracer as a program: 15 errors (the same). Corpus 528, the same files. Spec 31: the three overstated sentences corrected, a "Final patch" paragraph. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 56 ++++++++++++++----- .../lib/TypeScript/OwnershipSignaturePass.cpp | 27 ++++++--- tslang/test/tester/CMakeLists.txt | 2 + ...n_err_declared_callback_field_reference.ts | 20 +++++++ .../own_err_declared_callback_intrinsic.ts | 18 ++++++ 5 files changed, 102 insertions(+), 21 deletions(-) create mode 100644 tslang/test/tester/own/own_err_declared_callback_field_reference.ts create mode 100644 tslang/test/tester/own/own_err_declared_callback_intrinsic.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 11854087b..65ab7677e 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 @@ -3485,9 +3485,10 @@ Not here, also: then `const c = gh.c; raise(15); ... c.x` with `onSig` overwriting `gh.c`). Foreign code can only learn of a function of this module through the module's foreign surface, or through a function turned into data; so the C-scalar rule holds only in a module whose **entire foreign surface is - scalar** and that **turns no function into data** (controller ruling, fix round 5; a closed-world - test, in place of the three earlier rounds' list of the routes a function value takes out, which - each review extended). A type is scalar when it is a number, a boolean, a character, an integer or + scalar** and that **turns no function into data by the means the pass sees**: a `ts.Cast`, an + `inline_asm`, a `call_intrinsic` (controller ruling, fix round 5; a closed-world test, in place + of the three earlier rounds' list of the routes a function value takes out, which each review + extended). A type is scalar when it is a number, a boolean, a character, an integer or float kind, or an enum or a literal of one. Not scalar: an opaque pointer, a reference, a string, a class, an interface, an object, a tuple, an array, a union with such a member, a function type, `any`, `unknown`, a rest parameter. The foreign surface, every item of which must be scalar: @@ -3503,15 +3504,20 @@ Not here, also: - every function found in a library at run time (`SearchForAddressOfSymbol`): the function type its address is cast to. - The casts that turn a function into data, or make an address of foreign memory: + What turns a function into data, or makes an address of foreign memory: - a `ts.Cast` from a type that holds a function to a type that holds none (`any`, `unknown`, an - opaque pointer, an integer, anything else but a boolean, which cannot carry one; a class or - object input counts its fields when the output is `any`, `unknown` or opaque, and not when it - is anything else - a boolean, a base; a vtable's address is the compiler's own and does not - count); + opaque pointer, an integer, a reference, anything else but a boolean, which cannot carry one); + a class or object input counts its fields for every output but a class (its base) or an + interface (a view of it, read through its vtable only) - `>k` with `K { f: + (s: s32) => void }`, then a load of the field, is counted (final patch; before it only + `any`, `unknown` and opaque outputs counted the fields, review6r5 g1-g4, f7); a vtable's + address is the compiler's own and does not count; - a `ts.Cast` to an opaque pointer or a reference from a type that is not itself an address of memory this module made (`0x1234`, then `Deref(>p) = onSig`): a number, - `any`, a union. + `any`, a union; + - an `inline_asm(...)` or `call_intrinsic(...)` (`ts.InlineAsm`, `ts.CallIntrinsicOp`) + given an operand whose type holds a function, fields counted (final patch; review6r5 h1, h2: + `inline_asm("mov $1, $0", "=r,r", onSig)`, an identity intrinsic on `onSig`). A type holds a function when it is one, or a union's or optional's member, a tuple's field, an array's element or a reference's pointee does, recursively; a class or object does when a field @@ -3520,9 +3526,11 @@ Not here, also: whose interface has methods, a class field with a function field, an array of them) - the header `.instanceOf`, the compiler's own, does not count - and an interface no vtable of this module makes values with (one a declared function returns) holds one. The test runs after the class - storages and the interface families are known. No call is examined: a function value given to a - call can only reach foreign code through a declared symbol, which the surface test already - judged by its type. Not here: a function reached by name - an exported function of a library + storages and the interface families are known. No ordinary call is examined: a function value + given to one can only reach foreign code through a declared symbol, which the surface test + already judged by its type; the two builtins that are calls of no declared symbol, `inline_asm` + and `call_intrinsic`, are examined by their operands (above). Not here: a function reached by + name - an exported function of a library module, which another module may hand to C - stays accepted, as it was with `@nodrops` (an executable's exports are visible to no module, above). Negatives `own_err_declared_callback` (`signal(15, onSig as Opaque)`; `onSig` overwrites `gh.c`, which a @@ -3533,8 +3541,10 @@ Not here, also: whose field is a `CB` with a method, given to a declared function through a function value, compiled only), `_stored_global` (`declare var hook: (s: s32) => void; hook = onSig`, compiled only), `_stored_reference` (`Deref(getSlot()) = onSig` through a declared - `getSlot(): Reference<(s: s32) => void>`, compiled only) and `_cpp_class` (`addListener(new - MyL())`, `MyL` a `@cpp` class whose `on` has a body, compiled only). Cost: a module with any + `getSlot(): Reference<(s: s32) => void>`, compiled only), `_cpp_class` (`addListener(new + MyL())`, `MyL` a `@cpp` class whose `on` has a body, compiled only), `_field_reference` + (`>k`, the field loaded as an integer into `signal(s32, u64)`) and `_intrinsic` + (`call_intrinsic("llvm.launder.invariant.group.p0", onSig)`, then ``). Cost: a module with any non-scalar declaration - a C function taking a string, a struct or a pointer, a `declare class`, a `@cpp` class with methods, an imported function - loses the rule for its scalar declarations too (explicit `@nodrops` still works); a data-only interface handed to a declared function @@ -3603,3 +3613,21 @@ whitelist, as its cost: review6r3 n0 (an unused `declare function signal(sig: s3 Opaque)`), review6r4 b1, b1b, b5 and b9 (a class or a data-only interface given to a declared function). The ray tracer built as a program stays at 15 errors (the same ones). Corpus 528, the same files. + +**Final patch** (2026-10-22, user decision: keep the implicit C-scalar rule with these two fixes, +then stop). Re-review 5 found two ways a function became data that the cast test did not see: a +class or array with a function field cast to an integer or a reference (`>k`, +`>>>ks`, `ks`; the field then loaded as an integer into +the all-scalar `signal(s32, u64)`; own printed `77777 9`, gc `5 9`) - the fields of a class or +object input were counted only into `any`, `unknown` and opaque outputs, a narrowing left from +round 3 - and `inline_asm(...)` / `call_intrinsic(...)`, calls of no declared symbol that +take any operand (`inline_asm("mov $1, $0", "=r,r", onSig)`; an identity intrinsic on +`onSig`). Now a cast counts the input's fields for every output but a class or an interface, and +an `inline_asm` or `call_intrinsic` with an operand whose type holds a function (fields counted) +turns the rule off. Negatives `own_err_declared_callback_field_reference` (review6r5 g2) and +`_intrinsic` (h2), both runnable. Teeth, one switch per fix: with the fields counted only into +`any`/`unknown`/opaque again, `_field_reference` compiles and prints `77777 9` under own (gc `5 +9`) while `_intrinsic` stays rejected; with the `inline_asm`/`call_intrinsic` clause off, +`_intrinsic` prints `77777 9` while `_field_reference` stays rejected. g1-g5, f7, f9, f10, h1, h2 +rejected; d1-d9 and the scalar-only control keep the rule. The ray tracer built as a program stays +at 15 errors (the same ones); as an object file, 19. Corpus 528, the same files. diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index 0d141f631..414bc349b 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -3195,9 +3195,10 @@ class OwnershipSignaturePass : public mlir::PassWrapper0x1234`, then a // store through it reaches foreign memory); // - no cast makes a value of a type that holds no function (`any`, `unknown`, an opaque - // pointer, an integer; a boolean cannot carry one and does not count) out of one that holds - // one (`onSig as Opaque`, `k.m as u64`; a class or object cast to `any`, `unknown` or opaque - // takes its fields along). + // pointer, an integer, a reference; a boolean cannot carry one and does not count) out of one + // that holds one (`onSig as Opaque`, `k.m as u64`, `>k`; a class or object + // takes its fields along unless seen as a class or an interface); + // - no `inline_asm` or `call_intrinsic` is given a value whose type holds a function. bool functionsEscape = false; // The types a holdsFunction walk has been through, with or without their fields. @@ -3373,6 +3374,17 @@ class OwnershipSignaturePass : public mlir::PassWrapper(...)` and `call_intrinsic(...)`: a function given to either is data + // of no type (review6r5 h1, h2: `inline_asm("mov $1, $0", "=r,r", onSig)`) + if (mlir::isa(op)) + { + functionsEscape = llvm::any_of(op->getOperands(), [&](mlir::Value operand) { + SeenTypes seen; + return holdsFunction(operand.getType(), /*fields=*/true, seen); + }); + return; + } + auto castOp = mlir::dyn_cast(op); if (!castOp) { @@ -3398,17 +3410,18 @@ class OwnershipSignaturePass : public mlir::PassWrapper>k`, then a load of the field, review6r5 g2) but a class + // (its base) or an interface (a view of it, read through its vtable only) if (mlir::isa(castOp.getType())) { return; } - auto toOpaque = mlir::isa(castOp.getType()); + auto fields = !mlir::isa(castOp.getType()); SeenTypes seenIn; SeenTypes seenOut; - functionsEscape = holdsFunction(castOp.getIn().getType(), /*fields=*/toOpaque, seenIn) && + functionsEscape = holdsFunction(castOp.getIn().getType(), fields, seenIn) && !holdsFunction(castOp.getType(), /*fields=*/true, seenOut); }); } diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 13644fa66..a085691a9 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3842,6 +3842,8 @@ set(own_error_cases "own_err_declared_callback_stored_global|borrows 'gh.c' but is used here after it may be released or overwritten" "own_err_declared_callback_stored_reference|borrows 'gh.c' but is used here after it may be released or overwritten" "own_err_declared_callback_cpp_class|borrows 'gh.c' but is used here after it may be released or overwritten" + "own_err_declared_callback_field_reference|borrows 'gh.c' but is used here after it may be released or overwritten" + "own_err_declared_callback_intrinsic|borrows 'gh.c' but is used here after it may be released or overwritten" "own_err_interface_member_class|borrows a field but is used here after it may be released or overwritten" "own_err_interface_member_any|borrows a field but is used here after it may be released or overwritten" "own_err_override_nameable_base|is a parameter, kept here, but -mm=own cannot make the callers of 'Der.keep' give it up" diff --git a/tslang/test/tester/own/own_err_declared_callback_field_reference.ts b/tslang/test/tester/own/own_err_declared_callback_field_reference.ts new file mode 100644 index 000000000..29ab8ef55 --- /dev/null +++ b/tslang/test/tester/own/own_err_declared_callback_field_reference.ts @@ -0,0 +1,20 @@ +// -mm=own rejects: a C function of numbers (`raise`) in a module that sees a class with a function +// field as a reference to integers (`>k`, then a load of the field), and gives the +// integer to a C function of numbers (`signal(15, ...)`): a function turned into data is C's to +// call, so `raise` may call it back, and it overwrites `gh.c`, which `c` borrows (spec 31: a class +// or object cast to anything but a class or an interface takes its fields along). +class V { constructor(public x: number) {} } +class H { c: V = new V(5); } +function churn() { let keep: V[] = []; for (let i = 0; i < 2000; i++) { keep.push(new V(77777)); } return keep.length; } +declare function signal(sig: s32, handler: u64): u64; +declare function raise(sig: s32): s32; +let gh = new H(); +function onSig(sig: s32) { gh.c = new V(9); } +function use() { const c = gh.c; raise(15); churn(); return c.x; } +class K { f: (s: s32) => void = onSig; } +let k = new K(); +function main() { + const obj = >k; + signal(15, LoadReference(obj[1])); + print(use()); print(gh.c.x); +} diff --git a/tslang/test/tester/own/own_err_declared_callback_intrinsic.ts b/tslang/test/tester/own/own_err_declared_callback_intrinsic.ts new file mode 100644 index 000000000..c099d0eda --- /dev/null +++ b/tslang/test/tester/own/own_err_declared_callback_intrinsic.ts @@ -0,0 +1,18 @@ +// -mm=own rejects: a C function of numbers (`raise`) in a module that gives a function to +// `call_intrinsic` (an identity intrinsic on a pointer, so an opaque pointer to `onSig` comes +// back), then its address to a C function of numbers (`signal(15, p)`): a function turned +// into data is C's to call, so `raise` may call it back, and it overwrites `gh.c`, which `c` +// borrows (spec 31: `inline_asm` and `call_intrinsic` given a function turn the rule off). +class V { constructor(public x: number) {} } +class H { c: V = new V(5); } +function churn() { let keep: V[] = []; for (let i = 0; i < 2000; i++) { keep.push(new V(77777)); } return keep.length; } +declare function signal(sig: s32, handler: u64): u64; +declare function raise(sig: s32): s32; +let gh = new H(); +function onSig(sig: s32) { gh.c = new V(9); } +function use() { const c = gh.c; raise(15); churn(); return c.x; } +function main() { + const p = call_intrinsic("llvm.launder.invariant.group.p0", onSig); + signal(15, p); + print(use()); print(gh.c.x); +} From 199d74236997b5ae1de696038928831f5e536673 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 19:38:32 +0100 Subject: [PATCH 31/57] own-raytrace T7: -mm=own: a borrowing record's box carries its own release routine; @lends names several A borrowing record (own spec 32, G2) is an object literal boxed as an interface that holds values it does not own beside fresh ones. Its box is released through the interface's tag, so the routine travels with the value: a `ts.NewInterface` marked with the fields the record borrows (OWN_RECORD_BORROWED_FIELDS_ATTR_NAME, set by the signature pass in the next commit) gets a descriptor of its own whose release routine (`tsrelrec__`) frees the box and the fields it owns and leaves the borrowed ones. Records from different creation sites can then meet in one local. Precedent: a non-escaping closure's box (`__own_borrows_captures`, descriptor variant "b"). Stack promotion refuses such a box: its ts.ReleaseContents would give back every field. `@lends` may name several parameters: a borrowing record holds a borrow of each. MethodInfo keeps them as a mask (a vector pushed the vtable's SmallVector past its inline-size limit), and the declaration printer prints them all. Co-Authored-By: Claude Opus 5.5 --- tslang/include/TypeScript/Defines.h | 12 +++- .../LowerToLLVM/OwnershipRoutineLogic.h | 61 ++++++++++++++++++- .../TypeScript/MLIRLogic/MLIRGenStore.h | 6 +- tslang/lib/TypeScript/DeclarationPrinter.cpp | 13 +++- tslang/lib/TypeScript/LowerToLLVM.cpp | 20 +++++- tslang/lib/TypeScript/MLIRGenImpl.h | 18 +++--- tslang/lib/TypeScript/StackPromotionPass.cpp | 7 +++ 7 files changed, 117 insertions(+), 20 deletions(-) diff --git a/tslang/include/TypeScript/Defines.h b/tslang/include/TypeScript/Defines.h index e4d933fcc..13ed65d7c 100644 --- a/tslang/include/TypeScript/Defines.h +++ b/tslang/include/TypeScript/Defines.h @@ -128,6 +128,13 @@ // groups`). A yield moves what such a field holds to the caller, so the field never owns it: an // overwrite gives nothing back, and the state's release routine skips it. #define OWN_YIELDED_FIELDS_ATTR_NAME "ts.own_yielded_fields" +// -mm=own only, set by OwnershipSignaturePass for the inference and the lowering: on the `ts.New` +// that boxes an object literal, and on every `ts.NewInterface` made of that box, the fields of a +// borrowing record (own spec 32) - those holding a value the record does not own (`this`, a +// parameter, a field read, a local's value). The interface's tag then names a release routine of +// its own, which frees the box and the fields it owns and leaves these to their owners: the routine +// travels with the value, so records made at different sites may meet in one local. +#define OWN_RECORD_BORROWED_FIELDS_ATTR_NAME "__own_record_borrowed_fields" #define RETURN_VARIABLE_NAME ".return" #define CAPTURED_NAME ".captured" #define LABEL_ATTR_NAME "label" @@ -256,8 +263,9 @@ // knows them - one in another module too, which reads them from the declaration it re-parses. // `@keeps` names the parameters the function keeps: its callers move them in. `@borrows` names the // arguments a generator's state object borrows: the caller owns the result, but it may not outlive -// them. `@lends` names the one argument the result is a borrow of (OWN_RESULT_BORROWS_ATTR_NAME): -// the caller owns nothing, and the result may not outlive it. -mm=own checks a body against what +// them. `@lends` names the arguments the result is a borrow of (OWN_RESULT_BORROWS_ATTR_NAME): one +// for a plain borrow, whose caller owns nothing, or several for a borrowing record (own spec 32), +// whose caller owns the box; the result may not outlive any of them. -mm=own checks a body against what // it says; the other memory models ignore all three. Each is a FuncOp attribute of argument indices. #define KEEPS_DECORATOR "keeps" #define BORROWS_DECORATOR "borrows" diff --git a/tslang/include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h b/tslang/include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h index 92316d9ee..2a89bae6a 100644 --- a/tslang/include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h +++ b/tslang/include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h @@ -158,6 +158,56 @@ class OwnershipRoutineLogic return name; } + // -mm=own: the release routine of one borrowing record's box (own spec 32), an object whose + // `borrowed` fields hold values the record does not own. Like the object's own routine, but it + // leaves those fields to their owners. Made for the interface tag of each box marked so where it + // is made (OWN_RECORD_BORROWED_FIELDS_ATTR_NAME), so the routine travels with the value. + std::string getOrCreateRecordReleaseRoutine(mlir::Type type, ArrayRef borrowed) + { + auto objectType = dyn_cast(type); + if (!objectType || borrowed.empty() || !ownsHeapMemory(type)) + { + return getOrCreateReleaseRoutine(type); + } + + std::stringstream nameStream; + nameStream << "tsrelrec_" << (size_t)hash_value(type); + for (auto index : borrowed) + { + nameStream << "_" << index; + } + + auto name = nameStream.str(); + auto parentModule = op->getParentOfType(); + if (parentModule.lookupSymbol(name)) + { + return name; + } + + TypeHelper th(rewriter); + auto loc = op->getLoc(); + + OpBuilder::InsertionGuard insertGuard(rewriter); + rewriter.setInsertionPointToStart(parentModule.getBody()); + + auto funcOp = rewriter.create( + loc, name, th.getFunctionType(th.getVoidType(), {th.getPtrType()}), LLVM::Linkage::Internal); + + auto *entryBlock = funcOp.addEntryBlock(rewriter); + rewriter.setInsertionPointToStart(entryBlock); + + auto instanceValue = rewriter.create(loc, th.getPtrType(), entryBlock->getArgument(0)); + emitIfLastReference(instanceValue, [&]() { + releaseFields(objectType.getStorageType(), instanceValue, /*nullReleased=*/false, borrowed); + emitFreeBlock(instanceValue); + }); + + rewriter.create(loc, ValueRange{}); + + dropDebugLocations(funcOp); + return name; + } + // Symbol name of the retain routine for `type`, generating it if needed. Empty when the // type owns no heap memory, in which case the descriptor's retain slot stays null. // @@ -753,8 +803,10 @@ class OwnershipRoutineLogic } // Releases each field a record-shaped value owns. `basePtr` addresses the record itself. With - // `nullReleased`, each field released is then zeroed. - void releaseFields(mlir::Type recordType, mlir::Value basePtr, bool nullReleased = false) + // `nullReleased`, each field released is then zeroed. `left` names fields a borrowing record's box + // does not own (own spec 32): their values are someone else's. + void releaseFields(mlir::Type recordType, mlir::Value basePtr, bool nullReleased = false, + ArrayRef left = {}) { TypeHelper th(rewriter); TypeConverterHelper tch(typeConverter); @@ -766,6 +818,11 @@ class OwnershipRoutineLogic for (auto [index, fieldType] : llvm::enumerate(getFieldTypes(recordType))) { + if (llvm::is_contained(left, static_cast(index))) + { + continue; + } + // a generator's local that only yields: what it held went to the caller if (llvm::is_contained(yielded, static_cast(index))) { diff --git a/tslang/include/TypeScript/MLIRLogic/MLIRGenStore.h b/tslang/include/TypeScript/MLIRLogic/MLIRGenStore.h index d272f1588..c13644b33 100644 --- a/tslang/include/TypeScript/MLIRLogic/MLIRGenStore.h +++ b/tslang/include/TypeScript/MLIRLogic/MLIRGenStore.h @@ -164,10 +164,12 @@ struct MethodInfo // a slot of a @cpp class's vtable that no method names (ClassInfo::getVirtualTable) bool isCppSlotGap = false; // `@keeps`, `@borrows`, `@lends` (OWN_DECLARED_KEEPS_ATTR_NAME): argument indices, `this` 0, for - // the declaration an importer re-parses; `@lends` names one, or none (-1) + // the declaration an importer re-parses; `@lends` names one, or several for a borrowing record + // (own spec 32), as a mask of argument indices (a vector would push the vtable's SmallVector + // past its inline-size limit) std::vector ownKeeps; std::vector ownBorrows; - int32_t ownLends = -1; + uint64_t ownLendsMask = 0; }; struct GenericMethodInfo diff --git a/tslang/lib/TypeScript/DeclarationPrinter.cpp b/tslang/lib/TypeScript/DeclarationPrinter.cpp index eacdda372..06c452c53 100644 --- a/tslang/lib/TypeScript/DeclarationPrinter.cpp +++ b/tslang/lib/TypeScript/DeclarationPrinter.cpp @@ -700,9 +700,16 @@ namespace typescript newline(); } - printOwnMarkers(method.ownKeeps, method.ownBorrows, - method.ownLends >= 0 ? ArrayRef(method.ownLends) : ArrayRef(), - /*hasThis=*/!method.isStatic, 4); + SmallVector lends; + for (int32_t index = 0; index < 64; ++index) + { + if (method.ownLendsMask & (uint64_t(1) << index)) + { + lends.push_back(index); + } + } + + printOwnMarkers(method.ownKeeps, method.ownBorrows, lends, /*hasThis=*/!method.isStatic, 4); // the slot of a @cpp class's virtual method: without it the importer takes the method // for a non-virtual one diff --git a/tslang/lib/TypeScript/LowerToLLVM.cpp b/tslang/lib/TypeScript/LowerToLLVM.cpp index 835aaf16d..7b3bed944 100644 --- a/tslang/lib/TypeScript/LowerToLLVM.cpp +++ b/tslang/lib/TypeScript/LowerToLLVM.cpp @@ -6687,15 +6687,31 @@ 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); + // -mm=own: a borrowing record's box (own spec 32) has a routine, and so a descriptor, of its + // own, which leaves the fields it borrows to their owners + llvm::ArrayRef borrowed; + if (auto attr = newInterfaceOp->getAttrOfType(OWN_RECORD_BORROWED_FIELDS_ATTR_NAME); + attr && tsLlvmContext->compileOptions.memoryModel == MemoryModelOwn) + { + borrowed = attr.asArrayRef(); + } + + auto releaseRoutineName = borrowed.empty() ? orl.getOrCreateReleaseRoutine(thisType) + : orl.getOrCreateRecordReleaseRoutine(thisType, borrowed); // under own nothing retains: see TypeDescriptorOpLowering auto retainRoutineName = tsLlvmContext->compileOptions.memoryModel == MemoryModelOwn ? std::string() : orl.getOrCreateRetainRoutine(thisType); + std::string variant; + for (auto index : borrowed) + { + variant += "r" + std::to_string(index); + } + LLVMCodeHelper ch(newInterfaceOp, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); return ch.getOrCreateTypeDescriptorName(thisType, name, TypeOfOpHelper::typeKindFromName(name), - releaseRoutineName, retainRoutineName); + releaseRoutineName, retainRoutineName, variant); } LogicalResult matchAndRewrite(mlir_ts::NewInterfaceOp newInterfaceOp, Adaptor transformed, diff --git a/tslang/lib/TypeScript/MLIRGenImpl.h b/tslang/lib/TypeScript/MLIRGenImpl.h index eb65db6a8..2977c8f59 100644 --- a/tslang/lib/TypeScript/MLIRGenImpl.h +++ b/tslang/lib/TypeScript/MLIRGenImpl.h @@ -3194,14 +3194,7 @@ class MLIRGenImpl return; } - // a result is a borrow of one argument - if (name == LENDS_DECORATOR && args.size() > 1) - { - emitError(location) << "'@" << name << "' names one parameter, the one the result is a borrow of"; - result = mlir::failure(); - return; - } - + // `@lends` may name several: a borrowing record holds a borrow of each (own spec 32) auto first = funcType && funcType.getInputs().size() == params.size() + 1 ? 1 : 0; SmallVector indices; for (auto arg : args) @@ -12623,7 +12616,14 @@ class MLIRGenImpl if (auto lends = funcOp->getAttrOfType(OWN_DECLARED_LENDS_ATTR_NAME)) { - methodInfo.ownLends = lends.asArrayRef().empty() ? -1 : lends.asArrayRef().front(); + methodInfo.ownLendsMask = 0; + for (auto index : lends.asArrayRef()) + { + if (index >= 0 && index < 64) + { + methodInfo.ownLendsMask |= uint64_t(1) << index; + } + } } } diff --git a/tslang/lib/TypeScript/StackPromotionPass.cpp b/tslang/lib/TypeScript/StackPromotionPass.cpp index 91ec43dcd..0ff38592c 100644 --- a/tslang/lib/TypeScript/StackPromotionPass.cpp +++ b/tslang/lib/TypeScript/StackPromotionPass.cpp @@ -376,6 +376,13 @@ class StackPromotionPass : public mlir::PassWrapperhasAttr(OWN_RECORD_BORROWED_FIELDS_ATTR_NAME)) + { + return "borrowing-record"; + } + // A promoted object gives back what it owns when its function returns, later than the heap // object it replaces would have been destroyed. Only a Shared handle's count can see // that, so under the models that count, a module using Shared keeps every object that From 08327daa1d65360824c12626276dd8985945e009 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 19:38:45 +0100 Subject: [PATCH 32/57] own-raytrace T7: -mm=own: result facts are sets, and a returned borrowing record lends its sources `__own_result_borrows` is now a DenseI32ArrayAttr of argument indices (the receiver is 0 for a method): one for a plain borrow, any number for a borrowing record, which also carries `__own_result_record` - the caller owns the box, bounded by those arguments. own_facts::resultBorrowsAll() reads the set; resultBorrows() stays as the plain-borrow index (-1 for none or for a record) for the callers that need one; borrowedParam() and the plain borrow take every index. isFresh() is true for a record result, whatever the call goes through. The signature pass classifies, in each round of the facts fixpoint, every object literal box seen only as interfaces (classifyRecordBoxes): each field that holds a block is owned (a fresh value, which moves in) or borrowed (`this`, a parameter, a read out of a place, an owning local's value, a captured variable's, an immortal), never both, and never a string (strings are copied, spec 22). A box with a borrowed field is marked, with its interfaces, for the lowering and the inference, and a store into a borrowed field keeps nothing (`keeps`). A function's result is a record when every heap value it returns is one - a box made here, or a record call's result - through views, merges and the owning locals that hold it, and every field it borrows is a parameter's or a read out of one: its facts are those parameters. A record on one path and anything else on another gives no facts. Families meet by agreement, as before: members whose sets or record-ness differ are demoted. `@lends` facts are sets; a declaration's `@lends` is a plain borrow to its callers (a record's box then leaks, nothing is freed twice). No iterator `next()` or field function whose result is a record is marked fresh. Co-Authored-By: Claude Opus 5.5 --- tslang/lib/TypeScript/OwnershipFacts.h | 204 +++++++- .../lib/TypeScript/OwnershipSignaturePass.cpp | 444 +++++++++++++++++- 2 files changed, 616 insertions(+), 32 deletions(-) diff --git a/tslang/lib/TypeScript/OwnershipFacts.h b/tslang/lib/TypeScript/OwnershipFacts.h index 727b0e323..badba9060 100644 --- a/tslang/lib/TypeScript/OwnershipFacts.h +++ b/tslang/lib/TypeScript/OwnershipFacts.h @@ -29,8 +29,15 @@ namespace mlir_ts = mlir::typescript; // // The parameters the callee keeps: the caller moves each such argument into the call. #define OWN_PARAMS_ATTR_NAME "__own_params" -// The argument the result is a borrow of: the caller owns nothing it gets back. +// The arguments the result is a borrow of, as argument indices (the receiver is 0 for a method): the +// result may not outlive any of them. Alone, the caller owns nothing it gets back, and it names one +// argument. With OWN_RESULT_RECORD_ATTR_NAME it may name several (spec 32). #define OWN_RESULT_BORROWS_ATTR_NAME "__own_result_borrows" +// Beside OWN_RESULT_BORROWS_ATTR_NAME: the result is a borrowing record (spec 32) - an object literal +// box the callee made, holding borrows of those arguments. The caller owns the box and releases it; +// the box's release (its interface's tag, OWN_RECORD_BORROWED_FIELDS_ATTR_NAME) frees only the +// fields the record owns. +#define OWN_RESULT_RECORD_ATTR_NAME "__own_result_record" // The callee destroys nothing its caller can reach: no overwrite of a place it did not make, no // assignment of a global, no call that may. #define OWN_NO_DROPS_ATTR_NAME "__own_no_drops" @@ -139,15 +146,37 @@ inline llvm::ArrayRef ownedParams(mlir::Operation *op) return {}; } -// The argument a call's result borrows, or -1. +// Every argument a call's (or a function's) result borrows: one for a plain borrow, any number for a +// borrowing record (spec 32). Empty for none. +inline llvm::SmallVector resultBorrowsAll(mlir::Operation *op) +{ + llvm::SmallVector indices; + if (auto attr = op->getAttrOfType(OWN_RESULT_BORROWS_ATTR_NAME)) + { + indices.append(attr.asArrayRef().begin(), attr.asArrayRef().end()); + } + + return indices; +} + +// A result that is a borrowing record (OWN_RESULT_RECORD_ATTR_NAME): a box the caller owns, bounded by +// the arguments it borrows. +inline bool isRecordResult(mlir::Operation *op) +{ + return op && op->hasAttr(OWN_RESULT_RECORD_ATTR_NAME) && op->hasAttr(OWN_RESULT_BORROWS_ATTR_NAME); +} + +// The argument a call's result is a plain borrow of, or -1: none, or a borrowing record, which the +// caller owns (isRecordResult). Kept beside resultBorrowsAll for the plain borrow, which names one. inline int resultBorrows(mlir::Operation *op) { - if (auto attr = op->getAttrOfType(OWN_RESULT_BORROWS_ATTR_NAME)) + if (isRecordResult(op)) { - return static_cast(attr.getInt()); + return -1; } - return -1; + auto indices = resultBorrowsAll(op); + return indices.empty() ? -1 : indices.front(); } // Is `narrow` a member of the union or optional `wide` as it is, so that a cast between them keeps @@ -308,6 +337,13 @@ inline bool isFresh(mlir::Value value) return false; } + // a borrowing record (spec 32): the box is the caller's, whatever the call goes through; what it + // borrows bounds it (OwnershipInferencePass's checkRecordResult) + if (isRecordResult(def)) + { + return true; + } + // a view is the block it shows: the object literal's interface cast is marked as arriving with // a reference, but `let raw = {...}; raw` is `raw`'s block if (isView(def)) @@ -537,6 +573,13 @@ inline bool holdsNoBlock(mlir::Value value) return false; } +// An argument that holds no block (holdsNoBlock) - but not an opaque pointer, by which a call through +// an interface or a bound method is given its receiver (`ts.GetThis`), whose type owns nothing. +inline bool argumentHoldsNoBlock(mlir::Value arg) +{ + return !mlir::isa(arg.getType()) && holdsNoBlock(arg); +} + // A value -mm=own copies rather than give a second owner (spec 22.1): a string, a string literal, // a union of strings and null that needs no tag (one pointer), or an optional of one of those // (`{ptr, i1}`). @@ -966,15 +1009,160 @@ inline int borrowedParam(mlir::Value value, int depth = 0) return isParameterSlot(varOp, index) ? index : -1; } - if (def && isCall(def) && resultBorrows(def) >= 0 && - static_cast(resultBorrows(def)) < callArgs(def).size()) + // a plain borrow of its arguments (a declared `@lends` may name several): all of one parameter + if (def && isCall(def) && resultBorrows(def) >= 0) { - return borrowedParam(callArgs(def)[resultBorrows(def)], depth + 1); + auto args = callArgs(def); + auto found = -1; + for (auto index : resultBorrowsAll(def)) + { + auto param = static_cast(index) < args.size() ? borrowedParam(args[index], depth + 1) : -1; + if (param < 0 || (found >= 0 && param != found)) + { + return -1; + } + + found = param; + } + + return found; } return -1; } +// ---- Borrowing records (spec 32) ---- +// +// An object literal boxed as an interface whose fields hold values it does not own - `this`, a +// parameter, a field read, an owning `let`'s value, a captured variable's - beside fresh values it +// owns. OwnershipSignaturePass classifies each box where it is made and marks it (and every interface +// made of it) with the fields it borrows (OWN_RECORD_BORROWED_FIELDS_ATTR_NAME): the box's release, +// through the interface's tag, frees the fields it owns and leaves the others to their owners. +// OwnershipInferencePass then bounds every use of the record by what each borrowed field is read from. + +// Where a field of a record gets a value that holds a block, for the record's release and its bound. +enum class RecordFieldSource +{ + NoBlock, // a number, null: nothing to own + Fresh, // made here (moves into the record, which owns it) + Immortal, // an immortal global or string literal: nobody frees it + Param, // a parameter or `this`, seen through views: the caller's for the whole call + Place, // a read out of a field or an element, or a call's result that borrows an argument + Slot, // the value of an owning local, which keeps it + Cell, // a captured variable's value, read through its cell + Unknown +}; + +inline RecordFieldSource recordFieldSource(mlir::Value value) +{ + if (holdsNoBlock(value)) + { + return RecordFieldSource::NoBlock; + } + + if (isImmortalGlobalRead(value)) + { + return RecordFieldSource::Immortal; + } + + auto root = rootOf(value); + if (auto castOp = root.getDefiningOp(); + castOp && mlir::isa(castOp.getType()) && castOp.getIn().getDefiningOp()) + { + return RecordFieldSource::Immortal; + } + + if (isFresh(root)) + { + return RecordFieldSource::Fresh; + } + + if (auto loadOp = root.getDefiningOp()) + { + if (isPlace(loadOp.getReference())) + { + return RecordFieldSource::Place; + } + + if (isLoadedCell(loadOp.getReference())) + { + return RecordFieldSource::Cell; + } + } + + if (auto *def = root.getDefiningOp(); def && isCall(def) && resultBorrows(def) >= 0) + { + return RecordFieldSource::Place; + } + + if (borrowedParam(root) >= 0) + { + return RecordFieldSource::Param; + } + + if (slotLoadOf(value)) + { + return RecordFieldSource::Slot; + } + + return RecordFieldSource::Unknown; +} + +// The box of the object literal built in `varOp`: its one read, copied into a block `ts.New` made +// (recordBoxOf). Null for anything else. +inline mlir_ts::NewOp recordBoxOfLocal(mlir_ts::VariableOp varOp) +{ + if (!varOp || !isConstantRecordLocal(varOp)) + { + return {}; + } + + for (auto *user : varOp->getUsers()) + { + auto loadOp = mlir::dyn_cast(user); + if (!loadOp) + { + continue; + } + + for (auto *readUser : loadOp->getUsers()) + { + auto storeOp = mlir::dyn_cast(readUser); + if (storeOp && storeOp.getValue() == loadOp.getResult()) + { + if (auto boxOp = recordBoxOf(storeOp.getReference()); boxOp && boxOp.getResult() == storeOp.getReference()) + { + return boxOp; + } + } + } + } + + return {}; +} + +// The fields a borrowing record's box (or an interface made of it) leaves to their owners; empty for +// any other value. +inline llvm::ArrayRef recordBorrowedFields(mlir::Operation *op) +{ + if (auto attr = op ? op->getAttrOfType(OWN_RECORD_BORROWED_FIELDS_ATTR_NAME) : nullptr) + { + return attr.asArrayRef(); + } + + return {}; +} + +// A store into a field of a borrowing record that the record borrows: it takes nothing. +inline bool isBorrowedRecordFieldStore(mlir::Operation *op) +{ + auto storeOp = mlir::dyn_cast(op); + auto propertyRefOp = storeOp ? storeOp.getReference().getDefiningOp() : mlir_ts::PropertyRefOp(); + auto varOp = propertyRefOp ? propertyRefOp.getObjectRef().getDefiningOp() : mlir_ts::VariableOp(); + auto boxOp = varOp ? recordBoxOfLocal(varOp) : mlir_ts::NewOp(); + return boxOp && llvm::is_contained(recordBorrowedFields(boxOp), static_cast(propertyRefOp.getPosition())); +} + // The tuple an object, a reference or a tuple value of this type stores its fields in; null for // any other type. A method sees its object as `!ts.object>`, whose // fields are the tuple's. diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index 414bc349b..a93ffc366 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -426,8 +426,10 @@ class OwnershipSignaturePass : public mlir::PassWrappersetAttr(OWN_FRESH_RESULT_ATTR_NAME, mlir::UnitAttr::get(&getContext())); } @@ -453,7 +455,7 @@ class OwnershipSignaturePass : public mlir::PassWrappergetNumResults() == 1) + if (call.callees.fieldFunction && call.op->getNumResults() == 1 && !isRecordResult(call.op)) { call.op->setAttr(OWN_FRESH_RESULT_ATTR_NAME, mlir::UnitAttr::get(&getContext())); } @@ -2000,21 +2002,23 @@ class OwnershipSignaturePass : public mlir::PassWrapper borrows; llvm::SmallVector owned; + bool record = false; bool empty() const { - return borrows < 0 && owned.empty(); + return borrows.empty() && owned.empty(); } bool operator==(const Facts &other) const { - return borrows == other.borrows && owned == other.owned; + return borrows == other.borrows && owned == other.owned && record == other.record; } bool operator!=(const Facts &other) const @@ -2056,10 +2060,15 @@ class OwnershipSignaturePass : public mlir::PassWrapperhasAttr(OWN_DECLARED_KEEPS_ATTR_NAME) || funcOp->hasAttr(OWN_DECLARED_LENDS_ATTR_NAME); } - static int declaredLends(mlir::Operation *funcOp) + // The parameters `@lends` names: one for a plain borrow, any number for a borrowing record + // (spec 32). + static llvm::SmallVector declaredLends(mlir::Operation *funcOp) { auto lends = declared(funcOp, OWN_DECLARED_LENDS_ATTR_NAME); - return lends.empty() ? -1 : lends.front(); + llvm::SmallVector sorted(lends.begin(), lends.end()); + llvm::sort(sorted); + sorted.erase(std::unique(sorted.begin(), sorted.end()), sorted.end()); + return sorted; } // What every function a call names declares under `name`, when they all declare the same; @@ -2593,13 +2602,13 @@ class OwnershipSignaturePass : public mlir::PassWrapper borrows; + bool record = false; + }; + + // What the function's result borrows (spec 2.4, 32): the one parameter every heap value it returns + // is a borrow of, or, when every such value is a borrowing record, the parameters the records + // borrow. A result that holds no block (`null`, a number) agrees with any; a record on one path + // and anything else on another is nothing a caller can be told. + ResultBorrows localResultBorrows(mlir_ts::FuncOp funcOp) { auto found = -1; + auto record = false; + llvm::SmallVector params; for (auto value : returnedValues(funcOp)) { if (holdsNoBlock(value)) @@ -3678,19 +3697,366 @@ class OwnershipSignaturePass : public mlir::PassWrapper= 0 && index != found)) { - return -1; + return {}; } found = index; } - return found; + if (record && found >= 0) + { + return {}; + } + + if (record) + { + llvm::sort(params); + params.erase(std::unique(params.begin(), params.end()), params.end()); + return {params, true}; + } + + if (found >= 0) + { + return {{found}, false}; + } + + return {}; + } + + // ---- Borrowing records (spec 32) ---- + // + // `{ thing: this, ray: ray, dist: d }` given as an interface: an object literal's box holding values + // it does not own beside values it does. Rust's `struct Hit<'a> { thing: &'a Th, .. }`: the box is + // its owner's - the caller's, when it is returned - and it may not outlive what it borrows. Each box + // is classified where it is made: the fields it borrows (OWN_RECORD_BORROWED_FIELDS_ATTR_NAME, on the + // box and on every interface made of it, whose tag then names a release routine that leaves them), + // and, when every one of them is a parameter, `this`, or a read out of one, those parameters - what + // a function returning it lends (OWN_RESULT_RECORD_ATTR_NAME). The inference bounds every use. + + struct RecordBox + { + llvm::SmallVector params; + // every borrowed field is a parameter's, `this`'s, a read out of one, or immortal + bool returnable = true; + }; + + llvm::DenseMap recordBoxes; + + void classifyRecordBoxes() + { + recordBoxes.clear(); + getOperation().walk([&](mlir_ts::NewOp newOp) { + newOp->removeAttr(OWN_RECORD_BORROWED_FIELDS_ATTR_NAME); + for (auto *user : newOp->getUsers()) + { + for (auto *viewUser : user->getUsers()) + { + if (mlir::isa(viewUser)) + { + viewUser->removeAttr(OWN_RECORD_BORROWED_FIELDS_ATTR_NAME); + } + } + } + + classifyRecordBox(newOp); + }); + } + + void classifyRecordBox(mlir_ts::NewOp newOp) + { + auto boxOp = recordBoxOf(newOp.getResult()); + auto varOp = boxOp == newOp ? recordLocalOf(newOp.getResult()) : mlir_ts::VariableOp(); + if (!varOp) + { + return; + } + + // the record read once, and only into its box (rc's retain of it beside) + mlir_ts::LoadOp read; + for (auto *user : varOp->getUsers()) + { + if (auto loadOp = mlir::dyn_cast(user)) + { + if (read) + { + return; + } + + read = loadOp; + } + } + + for (auto *user : read->getUsers()) + { + auto storeOp = mlir::dyn_cast(user); + if (!mlir::isa(user) && !(storeOp && storeOp.getReference() == newOp.getResult())) + { + return; + } + } + + // the box seen only as interfaces: every release of it reads their tag + llvm::SmallVector interfaces; + for (auto &use : newOp->getUses()) + { + auto *user = use.getOwner(); + if (auto storeOp = mlir::dyn_cast(user); storeOp && storeOp.getReference() == newOp.getResult()) + { + continue; + } + + auto castOp = mlir::dyn_cast(user); + if (!castOp || !isView(castOp)) + { + return; + } + + for (auto *castUser : castOp->getUsers()) + { + auto newInterfaceOp = mlir::dyn_cast(castUser); + if (!newInterfaceOp || newInterfaceOp.getThisVal() != castOp.getResult()) + { + return; + } + + interfaces.push_back(castUser); + } + } + + if (interfaces.empty()) + { + return; + } + + // each field that holds a block: owned (made here, or nothing) or borrowed, never both + RecordBox info; + llvm::SmallVector borrowed; + MLIRTypeHelper mth(&getContext(), CompileOptions{}); + for (auto *user : varOp->getUsers()) + { + auto propertyRefOp = mlir::dyn_cast(user); + if (!propertyRefOp) + { + continue; + } + + auto field = propertyRefOp.getResult(); + auto refType = mlir::dyn_cast(field.getType()); + if (!refType || !mth.ownsHeapMemory(field.getLoc(), refType.getElementType())) + { + continue; + } + + auto owns = false; + auto borrows = false; + for (auto *fieldUser : field.getUsers()) + { + if (mlir::isa(fieldUser)) + { + continue; + } + + auto storeOp = mlir::dyn_cast(fieldUser); + if (!storeOp || storeOp.getReference() != field) + { + return; + } + + auto value = storeOp.getValue(); + switch (recordFieldSource(value)) + { + case RecordFieldSource::NoBlock: + continue; + case RecordFieldSource::Fresh: + owns = true; + continue; + case RecordFieldSource::Unknown: + return; + case RecordFieldSource::Immortal: + borrows = true; + continue; + case RecordFieldSource::Param: + case RecordFieldSource::Place: + if (auto param = borrowedParam(value); param >= 0) + { + info.params.push_back(param); + } + else + { + info.returnable = false; + } + + borrows = true; + continue; + case RecordFieldSource::Slot: + case RecordFieldSource::Cell: + info.returnable = false; + borrows = true; + continue; + } + } + + // a string is copied, not borrowed (spec 22) + if ((owns && borrows) || (borrows && isCopyableString(refType.getElementType()))) + { + return; + } + + if (borrows) + { + borrowed.push_back(propertyRefOp.getPosition()); + } + } + + if (borrowed.empty()) + { + return; + } + + llvm::sort(borrowed); + borrowed.erase(std::unique(borrowed.begin(), borrowed.end()), borrowed.end()); + llvm::sort(info.params); + info.params.erase(std::unique(info.params.begin(), info.params.end()), info.params.end()); + + auto attr = mlir::DenseI32ArrayAttr::get(&getContext(), borrowed); + newOp->setAttr(OWN_RECORD_BORROWED_FIELDS_ATTR_NAME, attr); + for (auto *newInterfaceOp : interfaces) + { + newInterfaceOp->setAttr(OWN_RECORD_BORROWED_FIELDS_ATTR_NAME, attr); + } + + recordBoxes[newOp.getOperation()] = std::move(info); + } + + enum class RecordFlow + { + None, // no borrowing record + Record, // borrowing records only (or values that hold no block) + Bad // a record that borrows what is not a parameter, or one beside anything else + }; + + // Whether `value` - through views, merges and the owning locals that hold it - is a borrowing + // record, and the parameters it borrows: a box made here, or a call's result that is one, whose + // arguments are this function's parameters or reads out of them. + RecordFlow recordParamsOf(mlir::Value value, llvm::SmallVectorImpl ¶ms) + { + llvm::SmallVector work{value}; + llvm::SmallPtrSet seen; + auto record = false; + auto other = false; + while (!work.empty()) + { + // what holds no block, by its own type (a box's `value_ref` owns nothing by type, but + // the object its view shows does) + auto next = work.pop_back_val(); + auto current = rootOf(next); + if (!seen.insert(current).second || holdsNoBlock(next)) + { + continue; + } + + auto *def = current.getDefiningOp(); + if (auto found = def ? recordBoxes.find(def) : recordBoxes.end(); found != recordBoxes.end()) + { + if (!found->second.returnable) + { + return RecordFlow::Bad; + } + + params.append(found->second.params.begin(), found->second.params.end()); + record = true; + continue; + } + + if (def && isCall(def) && isRecordResult(def)) + { + auto args = callArgs(def); + for (auto index : resultBorrowsAll(def)) + { + if (static_cast(index) >= args.size()) + { + return RecordFlow::Bad; + } + + auto arg = args[index]; + if (argumentHoldsNoBlock(arg) || isImmortalGlobalRead(arg)) + { + continue; + } + + auto param = borrowedParam(arg); + if (param < 0) + { + return RecordFlow::Bad; + } + + params.push_back(param); + } + + record = true; + continue; + } + + if (auto argument = mlir::dyn_cast(current)) + { + llvm::SmallVector merged; + if (mergedInto(argument, merged)) + { + work.append(merged.begin(), merged.end()); + continue; + } + + other = true; + continue; + } + + // what an owning local holds: its initializer and every value stored into it + auto loadOp = mlir::dyn_cast_or_null(def); + auto varOp = loadOp ? loadOp.getReference().getDefiningOp() : mlir_ts::VariableOp(); + if (varOp && isOwningVariable(varOp) && !varOp.getCaptured().value_or(false)) + { + if (varOp.getInitializer()) + { + work.push_back(varOp.getInitializer()); + } + + for (auto *user : varOp->getUsers()) + { + if (auto storeOp = mlir::dyn_cast(user); storeOp && storeOp.getReference() == varOp.getResult()) + { + work.push_back(storeOp.getValue()); + } + } + + continue; + } + + other = true; + } + + if (!record) + { + return RecordFlow::None; + } + + return other ? RecordFlow::Bad : RecordFlow::Record; } // ---- Parameters the callee keeps ---- @@ -3709,6 +4075,12 @@ class OwnershipSignaturePass : public mlir::PassWrapper(user)) { + // a field a borrowing record borrows (spec 32): the record keeps nothing + if (isBorrowedRecordFieldStore(user)) + { + return false; + } + auto ref = storeOp.getReference(); return storeOp.getValue() == used && (isPlace(ref) || ref.getDefiningOp()); } @@ -3806,11 +4178,20 @@ class OwnershipSignaturePass : public mlir::PassWrapper pinned; for (auto round = 0; round < 16; ++round) { + // which object literals borrow depends on what the calls stored into them return, and a + // function's facts on which literals borrow (spec 32) + classifyRecordBoxes(); + for (auto &entry : functions) { if (!entry.second.isDeclaration()) { - factsLocal[entry.second] = {localResultBorrows(entry.second), localOwnedParams(entry.second)}; + auto result = localResultBorrows(entry.second); + Facts facts; + facts.borrows = std::move(result.borrows); + facts.record = result.record; + facts.owned = localOwnedParams(entry.second); + factsLocal[entry.second] = std::move(facts); } } @@ -3823,11 +4204,16 @@ class OwnershipSignaturePass : public mlir::PassWrapper(keeps.begin(), keeps.end())}; + Facts said; + said.borrows = declaredLends(entry.second); + said.owned.assign(keeps.begin(), keeps.end()); + // a body here that returns a borrowing record of what `@lends` names: callers in this + // module own the box (spec 32); checkDeclared holds the body to the parameters + auto local = factsLocal.lookup(entry.second); + said.record = !said.borrows.empty() && local.record && local.borrows == said.borrows; if (factsThroughInterface(entry.second, said)) { continue; @@ -3951,11 +4343,15 @@ class OwnershipSignaturePass : public mlir::PassWrappergetContext(); op->removeAttr(OWN_RESULT_BORROWS_ATTR_NAME); + op->removeAttr(OWN_RESULT_RECORD_ATTR_NAME); op->removeAttr(OWN_PARAMS_ATTR_NAME); - if (facts.borrows >= 0) + if (!facts.borrows.empty()) { - op->setAttr(OWN_RESULT_BORROWS_ATTR_NAME, - mlir::IntegerAttr::get(mlir::IntegerType::get(context, 32), facts.borrows)); + op->setAttr(OWN_RESULT_BORROWS_ATTR_NAME, mlir::DenseI32ArrayAttr::get(context, facts.borrows)); + if (facts.record) + { + op->setAttr(OWN_RESULT_RECORD_ATTR_NAME, mlir::UnitAttr::get(context)); + } } if (!facts.owned.empty()) From d72c4f3ba912c251b59de5fa1a3d864e6b9e37b7 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 19:39:04 +0100 Subject: [PATCH 33/57] own-raytrace T7: -mm=own: borrowing records, bounded where they are made and where they are returned The inference half of own spec 32 (G2). A borrowing record's box (marked by the signature pass) has one owner like any fresh block - the box's own check - and what it borrows bounds it: - where it is made (classifyRecords, checkBorrowingRecords): every use of the record and of what is read out of it, through its views, merges and the locals that hold it (walkRecord), is a use of each borrowed field's source: a read out of a place has them as its own uses (walkBorrowed, through the field's store, now a borrow), an owning local's value is checked against the local's ends, a captured variable's against whatever may assign it, a parameter this function keeps against its moves; `this` and other parameters are the caller's for the whole call. rc's retain of the record read goes (holdsConstantsOrMoves), its fresh fields moving in as an owning record's do. Stored anywhere but a local, given to a callee that keeps it, captured, or written through, it is an error; returned, every field it borrows must be a parameter's that the function's facts lend. - where a call returns one (checkRecordResults): the result is fresh, so its moves and releases are the ordinary ones (G10's edge releases and G7's reads through the new owner compose with it: the release is the per-site routine). It may hold a borrow of each argument the facts name, so the chain checkPlaceRead builds for a borrowing call, over all of them, bounds every use of it; kept, it is `borrows the result of ... and cannot be stored`; returned, the arguments must be parameters this function lends. A call's result given a value it records is followed by derivedFrom and by spec 30's taint. A method reached through an interface is named `.` in errors (own_err_interface_result_outlives now reads `the result of 'First.first'`). Tests: own_borrowing_record (the brief's `closest`, through a closed interface), and own_borrowing_record_arg (call-argument temporaries with a parameter, a field read, an owning local, a captured parameter beside fresh values; `const` and `let` locals; a returned record) under own, rc, gc and none, AOT and JIT, own with no counting; own_lends adds `@lends("a", "b")`. Negatives own_err_borrowing_record_stored, _outlives, _kept_arg, _let_outlives, _returned_local, _field_written, _cell, _kept_param. own_err_record_literal_let is a correct program now (the record borrows the `let`'s value) and is gone; its shape is in own_borrowing_record_arg. own_err_lends_two_params now reads the body/marker mismatch. Co-Authored-By: Claude Opus 5.5 --- .../lib/TypeScript/OwnershipInferencePass.cpp | 606 +++++++++++++++++- tslang/test/tester/CMakeLists.txt | 27 +- .../test/tester/own/own_borrowing_record.ts | 57 ++ .../tester/own/own_borrowing_record_arg.ts | 110 ++++ .../own/own_err_borrowing_record_cell.ts | 32 + .../own_err_borrowing_record_field_written.ts | 23 + .../own/own_err_borrowing_record_kept_arg.ts | 34 + .../own_err_borrowing_record_kept_param.ts | 36 ++ .../own_err_borrowing_record_let_outlives.ts | 25 + .../own/own_err_borrowing_record_outlives.ts | 27 + ...own_err_borrowing_record_returned_local.ts | 26 + .../own/own_err_borrowing_record_stored.ts | 34 + .../tester/own/own_err_record_literal_let.ts | 12 - tslang/test/tester/own/own_lends.ts | 22 + 14 files changed, 1043 insertions(+), 28 deletions(-) create mode 100644 tslang/test/tester/own/own_borrowing_record.ts create mode 100644 tslang/test/tester/own/own_borrowing_record_arg.ts create mode 100644 tslang/test/tester/own/own_err_borrowing_record_cell.ts create mode 100644 tslang/test/tester/own/own_err_borrowing_record_field_written.ts create mode 100644 tslang/test/tester/own/own_err_borrowing_record_kept_arg.ts create mode 100644 tslang/test/tester/own/own_err_borrowing_record_kept_param.ts create mode 100644 tslang/test/tester/own/own_err_borrowing_record_let_outlives.ts create mode 100644 tslang/test/tester/own/own_err_borrowing_record_outlives.ts create mode 100644 tslang/test/tester/own/own_err_borrowing_record_returned_local.ts create mode 100644 tslang/test/tester/own/own_err_borrowing_record_stored.ts delete mode 100644 tslang/test/tester/own/own_err_record_literal_let.ts diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index f3d75c861..e79e9b28b 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -134,11 +134,16 @@ class OwnershipInferencePass captureRetains.clear(); returnedParams.clear(); returnsBorrowOf = resultBorrows(f); + returnsRecordOf = isRecordResult(f) ? resultBorrowsAll(f) : llvm::SmallVector(); + records.clear(); + recordStoreUses.clear(); // first: whether a closure borrows what it captures changes what its box's stores are classifyClosures(); classifyStateBoxes(); classifyBorrowingFields(); + // ...and whether an object literal borrows what its fields hold (spec 32) + classifyRecords(); // A view of a block is that block: the candidate is always the root. f.walk([&](mlir::Operation *op) { @@ -431,6 +436,8 @@ class OwnershipInferencePass decideCells(); checkBorrowedReads(); checkBoundedResults(); + checkBorrowingRecords(); + checkRecordResults(); toErase.insert(fieldBorrowReleases.begin(), fieldBorrowReleases.end()); if (dryRun) @@ -449,8 +456,9 @@ class OwnershipInferencePass // the facts were for this pass only returnedParams.clear(); f.walk([](mlir::Operation *op) { - for (auto *name : {OWN_PARAMS_ATTR_NAME, OWN_RESULT_BORROWS_ATTR_NAME, OWN_NO_DROPS_ATTR_NAME, - OWN_FACTS_LOST_ATTR_NAME, OWN_FRESH_RESULT_ATTR_NAME, OWN_ASSIGNS_CAPTURES_ATTR_NAME, + for (auto *name : {OWN_PARAMS_ATTR_NAME, OWN_RESULT_BORROWS_ATTR_NAME, OWN_RESULT_RECORD_ATTR_NAME, + OWN_NO_DROPS_ATTR_NAME, OWN_FACTS_LOST_ATTR_NAME, OWN_FRESH_RESULT_ATTR_NAME, + OWN_ASSIGNS_CAPTURES_ATTR_NAME, OWN_RESULT_BOUNDED_ATTR_NAME, OWN_CAPTURE_BORROW_ATTR_NAME}) { @@ -629,6 +637,10 @@ class OwnershipInferencePass int returnsBorrowOf = -1; llvm::DenseSet returnedParams; + // The parameters this function's result is a borrowing record of (OWN_RESULT_RECORD_ATTR_NAME, + // spec 32); empty for none. + llvm::SmallVector returnsRecordOf; + // ---- Closures (spec 2.5, phase 5) ---- // // A closure is `ts.CreateBoundFunction(box, @f) {__owns_capture}`; its box holds a cell for each @@ -2378,6 +2390,16 @@ class OwnershipInferencePass } } + // stored into a field a borrowing record borrows (spec 32): whatever uses the record + // reads it + if (auto found = recordStoreUses.find(user); found != recordStoreUses.end()) + { + for (auto &recordUse : found->second->walk.uses) + { + uses.push_back({recordUse.op, pending.kills, recordUse.callee}); + } + } + if (pending.borrowed && keeping.returns && mlir::isa(user)) { continue; @@ -2485,6 +2507,46 @@ class OwnershipInferencePass mlir::isa(methodField.getReference().getType()); } + // ---- Borrowing records (spec 32) ---- + // + // An object literal boxed as an interface that holds values it does not own - `this`, a parameter, + // a field read, an owning `let`'s value, a captured variable's - beside fresh values, which move + // in. The signature pass marked its box (OWN_RECORD_BORROWED_FIELDS_ATTR_NAME): its release frees + // the box and the fields it owns, and leaves the others to their owners. So the box has one owner + // like any fresh block - the box's own check - and what it borrows bounds every use of it: a read + // out of a place is checked with the record's uses as its own (walkBorrowed), a local's value + // against the local's ends, a captured variable's against whatever may assign it. Returned, the + // record is the caller's to own and to bound (checkRecordResults), which the function's facts + // must say. Stored anywhere else that may outlive what it borrows, it is an error. + struct RecordWalk + { + llvm::SmallVector uses; + // the first use that would keep the record itself: a store into a place, a callee that keeps + // it, a capture + mlir::Operation *kept = nullptr; + llvm::SmallVector returns; + }; + + struct BorrowedField + { + mlir_ts::StoreOp store; + RecordFieldSource source; + }; + + struct BorrowingRecord + { + mlir_ts::NewOp box; + mlir_ts::VariableOp local; + llvm::SmallVector interfaces; + llvm::SmallVector borrowed; + RecordWalk walk; + }; + + llvm::SmallVector> records; + // a store of a place read into a borrowing record's field -> the record's uses, which are the + // read's uses too (walkBorrowed) + llvm::DenseMap recordStoreUses; + // Can a value of this type point into a heap block - a reference into one, a block of its own, // or a method bound to one? A number or a boolean read out of a borrowed block cannot. bool mayPointInto(mlir::Value value) @@ -2495,6 +2557,480 @@ class OwnershipInferencePass ownsHeap(value); } + // The borrowing record built in `varOp` (classifyRecords), or null. + BorrowingRecord *borrowingRecordOf(mlir_ts::VariableOp varOp) + { + for (auto &record : records) + { + if (record->local == varOp) + { + return record.get(); + } + } + + return nullptr; + } + + // The boxes the signature pass marked as borrowing records, where every field still holds what it + // was marked for: a borrowed field a value this pass bounds (recordFieldSource), any other one a + // fresh value or nothing. A store into a borrowed field takes nothing, and whatever uses the record + // reads what it stored (walkBorrowed, through recordStoreUses). A box that is not what it was + // marked for is left to the record's retain, which is then an error. + void classifyRecords() + { + getFunction().walk([&](mlir_ts::NewOp newOp) { + auto fields = recordBorrowedFields(newOp); + auto varOp = fields.empty() ? mlir_ts::VariableOp() : recordLocalOf(newOp.getResult()); + if (!varOp) + { + return; + } + + auto record = std::make_shared(); + record->box = newOp; + record->local = varOp; + for (auto *user : varOp->getUsers()) + { + auto propertyRefOp = mlir::dyn_cast(user); + if (!propertyRefOp) + { + continue; + } + + auto borrows = llvm::is_contained(fields, static_cast(propertyRefOp.getPosition())); + for (auto *fieldUser : propertyRefOp->getUsers()) + { + auto storeOp = mlir::dyn_cast(fieldUser); + if (!storeOp || storeOp.getReference() != propertyRefOp.getResult()) + { + continue; + } + + auto source = recordFieldSource(storeOp.getValue()); + if (source == RecordFieldSource::NoBlock) + { + continue; + } + + if (source == RecordFieldSource::Unknown || borrows != (source != RecordFieldSource::Fresh)) + { + return; + } + + if (borrows) + { + record->borrowed.push_back({storeOp, source}); + } + } + } + + for (auto *user : newOp->getUsers()) + { + if (auto castOp = mlir::dyn_cast(user)) + { + for (auto *castUser : castOp->getUsers()) + { + if (auto newInterfaceOp = mlir::dyn_cast(castUser)) + { + record->interfaces.push_back(newInterfaceOp.getResult()); + } + } + } + } + + // a path back to a use through the record's making is another record + llvm::SmallVector kills{newOp.getOperation(), varOp.getOperation()}; + walkRecord(record->interfaces, kills, record->walk); + for (auto &field : record->borrowed) + { + setAttrTracked(field.store, OWN_CAPTURE_BORROW_ATTR_NAME); + recordStoreUses[field.store.getOperation()] = record.get(); + } + + records.push_back(std::move(record)); + }); + } + + // Every use of a borrowing record (spec 32), and of what is read out of it - each may read what + // the record borrows - through its views, merges, and the locals that hold it, made again at + // `kills`. Its own retains and releases are the box's, which its own check decides. The record + // itself is `kept` when it is stored anywhere but a local, given to a callee that keeps it, or + // captured; returned, it is in `returns`. A call given it hands back its own block, unless that + // is a borrowing record that holds it, whose uses are then this one's as well. + void walkRecord(llvm::ArrayRef starts, llvm::ArrayRef kills, RecordWalk &walk) + { + struct Pending + { + mlir::Value value; + bool record; + }; + + llvm::SmallVector work; + llvm::SmallPtrSet seen; + auto push = [&](mlir::Value value, bool record) { + if ((record || mayPointInto(value)) && seen.insert(value).second) + { + work.push_back({value, record}); + } + }; + + auto followSlot = [&](mlir_ts::VariableOp varOp, bool record) { + for (auto *slotUser : varOp->getUsers()) + { + // its releases and its other values are the slot's own + if (auto loadOp = mlir::dyn_cast(slotUser)) + { + push(loadOp.getResult(), record); + } + } + }; + + auto keep = [&](mlir::Operation *user) { walk.kept = walk.kept ? walk.kept : user; }; + + for (auto start : starts) + { + push(start, true); + } + + while (!work.empty()) + { + auto pending = work.pop_back_val(); + auto value = pending.value; + for (auto &use : value.getUses()) + { + auto *user = use.getOwner(); + if (mlir::isa(user) || + (pending.record && mlir::isa(user))) + { + continue; + } + + // the same value: a view, a cast, a merge + if ((isView(user) && use.getOperandNumber() == 0) || mlir::isa(user)) + { + push(user->getResult(0), pending.record); + continue; + } + + if (auto branchOp = mlir::dyn_cast(user)) + { + if (auto argument = branchOp.getSuccessorBlockArgument(use.getOperandNumber())) + { + push(*argument, pending.record); + } + + continue; + } + + // a local that holds it: its reads + auto varOp = mlir::dyn_cast(user); + auto storeOp = mlir::dyn_cast(user); + if (varOp || (storeOp && storeOp.getValue() == value)) + { + auto slotOp = varOp ? varOp : storeOp.getReference().getDefiningOp(); + if (slotOp && !slotOp.getCaptured().value_or(false)) + { + followSlot(slotOp, pending.record); + continue; + } + + // a place, a global, a captured variable: what is read out of the record is checked + // where it is read (checkPlaceRead) + walk.uses.push_back({user, {kills.begin(), kills.end()}}); + if (pending.record) + { + keep(user); + } + + continue; + } + + walk.uses.push_back({user, {kills.begin(), kills.end()}, isCall(user) && isCalleeOperand(use)}); + if (mlir::isa(user)) + { + if (pending.record) + { + walk.returns.push_back(user); + } + + continue; + } + + if (isCall(user)) + { + if (pending.record && isKeptArgument(user, value)) + { + keep(user); + continue; + } + + auto args = callArgs(user); + if (isRecordResult(user) && llvm::any_of(resultBorrowsAll(user), [&](int index) { + return static_cast(index) < args.size() && args[index] == value; + })) + { + push(user->getResult(0), true); + } + + continue; + } + + if (pending.record && !isBorrow(user, value)) + { + keep(user); + continue; + } + + // a field written through the record: its old value may be one the record borrows, + // which the overwrite would release, and the field may be one its release leaves + if (pending.record && mlir::isa(user)) + { + for (auto *fieldUser : user->getResult(0).getUsers()) + { + auto fieldStore = mlir::dyn_cast(fieldUser); + if (mlir::isa(fieldUser) || + (fieldStore && fieldStore.getReference() == user->getResult(0))) + { + keep(fieldUser); + } + } + } + + // what it produces - a field's reference, a value read out of it, a bound method - may + // still point into what the record borrows + for (auto result : user->getResults()) + { + push(result, false); + } + } + } + } + + // The name the record goes by: the local that holds it, or a folded `const`'s. + llvm::StringRef recordName(mlir::Value value) + { + llvm::StringRef name = ownerName(value); + forEachUse(value, [&](mlir::Operation *user, mlir::Value used) { + if (auto varOp = mlir::dyn_cast(user); varOp && varOp.getInitializer() == used) + { + name = varName(varOp); + } + }); + + return name; + } + + // What a borrowed field holds, for an error: `'ray'`, `'this.pos'`, `'livec'`, ... + std::string describeRecordSource(BorrowedField field) + { + auto value = field.store.getValue(); + auto quoted = [](llvm::StringRef name) { return "'" + name.str() + "'"; }; + switch (field.source) + { + case RecordFieldSource::Param: + return quoted(paramName(borrowedParam(value))); + case RecordFieldSource::Place: + if (auto loadOp = rootOf(value).getDefiningOp(); loadOp && isPlace(loadOp.getReference())) + { + return describePlace(loadOp.getReference()); + } + + return describeCall(rootOf(value).getDefiningOp()); + case RecordFieldSource::Slot: + return quoted(varName(slotLoadOf(value).getReference().getDefiningOp())); + case RecordFieldSource::Cell: + return "a captured variable"; + default: + return "a value it does not own"; + } + } + + // A record this function makes (spec 32): kept, it is an error; returned, each field it borrows + // must be a parameter's that this function's facts lend (`__own_result_record`), so that its + // callers bound it; and no use of it may come after what a field borrows may be released or + // overwritten - an owning local's ends, and whatever may assign a captured variable. A parameter + // and `this` are the caller's for the whole call; a read out of a place has the record's uses as + // its own (checkPlaceRead). + void checkBorrowingRecords() + { + for (auto &record : records) + { + auto name = recordName(record->interfaces.empty() ? record->box.getResult() : record->interfaces.front()); + if (record->walk.kept) + { + reportPlaceBorrowEscapes(record->walk.kept, name, + record->borrowed.empty() ? "a value it does not own" + : describeRecordSource(record->borrowed.front())); + continue; + } + + auto ok = true; + for (auto *returned : record->walk.returns) + { + for (auto &field : record->borrowed) + { + if (!ok || field.source == RecordFieldSource::Immortal) + { + continue; + } + + auto param = field.source == RecordFieldSource::Param || field.source == RecordFieldSource::Place + ? borrowedParam(field.store.getValue()) + : -1; + if (param < 0 || !llvm::is_contained(returnsRecordOf, param)) + { + reportPlaceBorrowEscapes(returned, name, describeRecordSource(field)); + ok = false; + } + } + } + + for (auto &field : record->borrowed) + { + if (!ok) + { + break; + } + + auto value = field.store.getValue(); + Chain chain; + if (field.source == RecordFieldSource::Slot) + { + chain.roots.push_back({slotLoadOf(value).getReference(), Chain::Slot}); + } + else if (auto load = field.source == RecordFieldSource::Param ? keptParamLoadOf(value) : mlir_ts::LoadOp()) + { + // a parameter this function keeps is its own to move away, as a local's value is + chain.roots.push_back({load.getReference(), Chain::Slot}); + } + else if (field.source == RecordFieldSource::Cell) + { + chain.roots.push_back({rootOf(value), Chain::NotOwned}); + } + else + { + continue; + } + + auto ends = rootEnds(chain); + if (field.source == RecordFieldSource::Cell) + { + for (auto *drop : drops) + { + if (dropsChain(drop, chain, nullptr, value)) + { + ends.push_back(drop); + } + } + } + + for (auto *end : ends) + { + for (auto &use : record->walk.uses) + { + if (ok && (use.op != end || !use.callee) && reachableAfter(end, use.op, use.kills)) + { + if (field.source == RecordFieldSource::Slot) + { + reportBorrowOutlives(use.op, end, name, varName(chain.roots.front().first.getDefiningOp())); + } + else + { + reportPlaceBorrowOutlives(use.op, end, name, describeRecordSource(field)); + } + + ok = false; + } + } + } + } + } + } + + // A call whose result is a borrowing record (OWN_RESULT_RECORD_ATTR_NAME, spec 32): the box is + // this function's to own - it is fresh, and its own check decides its moves and releases - but + // it may hold a borrow of each argument the facts name, as a call's result that borrows them + // does (checkPlaceRead): somewhere under each. So no use of it, through the locals it moves + // into, may come after one of those arguments may be released or overwritten, and it may not be + // kept. Returned, the arguments must be parameters this function's own facts lend. Its releases + // are not uses: they free only what the record owns. + void checkRecordResults() + { + getFunction().walk([&](mlir::Operation *call) { + if (!isCall(call) || !isRecordResult(call) || call->getNumResults() != 1) + { + return; + } + + auto result = call->getResult(0); + auto place = describeCall(call); + auto name = recordName(result); + RecordWalk walk; + llvm::SmallVector kills{call}; + walkRecord({result}, kills, walk); + if (walk.kept) + { + reportPlaceBorrowEscapes(walk.kept, name, place); + return; + } + + auto args = callArgs(call); + for (auto *returned : walk.returns) + { + for (auto index : resultBorrowsAll(call)) + { + if (static_cast(index) >= args.size() || argumentHoldsNoBlock(args[index]) || + isImmortalLiteral(args[index])) + { + continue; + } + + auto param = borrowedParam(args[index]); + if (param < 0 || !llvm::is_contained(returnsRecordOf, param)) + { + reportPlaceBorrowEscapes(returned, name, place); + return; + } + } + } + + auto chain = chainOf(call); + // rc's retain of a fresh argument right before the call is no end: the box's own check + // decides it, and the use that takes it is an end of its own + llvm::SmallVector ends; + for (auto *end : rootEnds(chain)) + { + auto ownedRetain = mlir::isa(end) && llvm::any_of(chain.roots, [&](auto &root) { + return root.second == Chain::Owned && rootOf(end->getOperand(0)) == root.first; + }); + if (!ownedRetain) + { + ends.push_back(end); + } + } + + for (auto *drop : drops) + { + if (drop != call && dropsChain(drop, chain, call)) + { + ends.push_back(drop); + } + } + + for (auto *end : ends) + { + for (auto &use : walk.uses) + { + if ((use.op != end || !use.callee) && reachableAfter(end, use.op, use.kills)) + { + reportPlaceBorrowOutlives(use.op, end, name, place); + return; + } + } + } + }); + } + // ---- Reads out of containers (spec 2.3, phase 3) ---- // // `h.c` and `arr[i]` read a value the container owns. Such a read is a borrow: it may not be @@ -2780,12 +3316,19 @@ class OwnershipInferencePass return retainOp && retainOp.getReference() == storeOp.getValue() ? retainOp.getOperation() : nullptr; } - // The call a value is the result of, when that result borrows an argument. + // The call a value is the result of, when that result is a plain borrow of an argument. A borrowing + // record (spec 32) is not one: the caller owns the box (checkRecordResults). static mlir::Operation *borrowingCall(mlir::Value value) { auto *def = value.getDefiningOp(); - auto index = def && isCall(def) ? resultBorrows(def) : -1; - return index >= 0 && static_cast(index) < callArgs(def).size() ? def : nullptr; + if (!def || !isCall(def) || resultBorrows(def) < 0) + { + return nullptr; + } + + auto args = callArgs(def); + auto indices = resultBorrowsAll(def); + return llvm::all_of(indices, [&](int index) { return static_cast(index) < args.size(); }) ? def : nullptr; } // The places from a read up to its roots, and the roots: the values the function reached the @@ -2836,8 +3379,18 @@ class OwnershipInferencePass } else { + // a call's result that borrows its arguments - a plain borrow, or a borrowing record's box + // (spec 32), which may hold a borrow of each - is somewhere under each of them chain.anyPlace = true; - first.push_back(rootOf(callArgs(read)[resultBorrows(read)])); + auto args = callArgs(read); + for (auto index : resultBorrowsAll(read)) + { + if (static_cast(index) < args.size() && !argumentHoldsNoBlock(args[index]) && + !isImmortalLiteral(args[index])) + { + first.push_back(rootOf(args[index])); + } + } } llvm::SmallPtrSet seen; @@ -2934,7 +3487,15 @@ class OwnershipInferencePass if (auto *call = borrowingCall(base)) { chain.anyPlace = true; - bases.push_back(rootOf(callArgs(call)[resultBorrows(call)])); + auto args = callArgs(call); + for (auto index : resultBorrowsAll(call)) + { + if (static_cast(index) < args.size()) + { + bases.push_back(rootOf(args[index])); + } + } + continue; } @@ -3108,6 +3669,15 @@ class OwnershipInferencePass auto *user = use.getOwner(); if (isCall(user)) { + // ...but a borrowing record given this value holds it (spec 32) + auto args = callArgs(user); + if (isRecordResult(user) && llvm::any_of(resultBorrowsAll(user), [&](int index) { + return static_cast(index) < args.size() && args[index] == value; + })) + { + work.push_back(user->getResult(0)); + } + continue; } @@ -3390,6 +3960,16 @@ class OwnershipInferencePass { callee = refOp.getIdentifierAttr().getAttr(); } + else if (auto refOp = mlir::dyn_cast_or_null(calleeOp)) + { + // a method reached through an interface (spec 31): `.` + auto method = mlir::dyn_cast(refOp.getIdentifier()); + auto interfaceType = mlir::dyn_cast(refOp.getInterfaceVal().getType()); + if (method && interfaceType) + { + return (interfaceType.getName().getValue() + "." + method.getValue()).str(); + } + } } return callee ? callee.getValue().str() : std::string(); @@ -3614,7 +4194,9 @@ class OwnershipInferencePass { auto loadOp = value.getDefiningOp(); auto varOp = loadOp ? loadOp.getReference().getDefiningOp() : mlir_ts::VariableOp(); - if (!varOp || !isConstantRecordLocal(varOp) || !isOwningRecord(value)) + // a borrowing record (spec 32) takes nothing for what it borrows, and its fresh values move in + // as an owning record's do + if (!varOp || !isConstantRecordLocal(varOp) || !(isOwningRecord(value) || borrowingRecordOf(varOp))) { return false; } @@ -5020,13 +5602,15 @@ class OwnershipInferencePass } } - // a call's result is its own block, unless its facts say it borrows this argument + // a call's result is its own block, unless its facts say it borrows this argument - a + // plain borrow, or a borrowing record that holds it (spec 32) auto borrows = true; if (isCall(user)) { - auto index = resultBorrows(user); auto args = callArgs(user); - borrows = index >= 0 && static_cast(index) < args.size() && args[index] == value; + borrows = llvm::any_of(resultBorrowsAll(user), [&](int index) { + return static_cast(index) < args.size() && args[index] == value; + }); } // a read of the slot is what its uses came from diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index a085691a9..f8035efcd 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3482,7 +3482,8 @@ foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local o own_immortal_global own_immortal_global_region own_literal_moves own_record_literal_given own_field_chain_let own_read_after_move own_conditional_move - own_interface_facts own_declared_scalar_no_drops) + own_interface_facts own_declared_scalar_no_drops + own_borrowing_record own_borrowing_record_arg) 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. @@ -3548,6 +3549,15 @@ foreach(interface_facts_mm rc gc none) endforeach() endforeach() +# An object literal that borrows some of what it holds - a borrowing record, returned or a temporary - +# under own (spec 32); the same programs under every other model. +foreach(borrowing_record_mm rc gc none) + foreach(borrowing_record_test own_borrowing_record own_borrowing_record_arg) + tslang_add_test(NAME test-jit-${borrowing_record_mm}-${borrowing_record_test} COMMAND test-runner -jit -mm=${borrowing_record_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${borrowing_record_test}.ts") + tslang_add_test(NAME test-compile-${borrowing_record_mm}-${borrowing_record_test} COMMAND test-runner -mm=${borrowing_record_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${borrowing_record_test}.ts") + endforeach() +endforeach() + # An executable's exports leave its interfaces closed (spec 31, USER decision): under own the JIT's # entry module and `tslang --emit=exe` compile own_interface_exe_exports; an object file and a DLL # do not (own-exe-exports.cmake). test-runner's own AOT build is an object file, so it is not run. @@ -3810,7 +3820,6 @@ set(own_error_cases "own_err_global_let_call|borrows the global 'names', but -mm=own cannot prove the global keeps its value" "own_err_captured_returned|borrows a captured variable and cannot be stored, returned or captured" "own_err_literal_moved_twice|is used here after its value was moved" - "own_err_record_literal_let|an object literal that holds a value it owns is moved only into a local or returned as it is" "own_err_interface_keeps_literal|is a parameter, kept here, but -mm=own cannot make the callers of 'KS.take' give it up" "own_err_interface_keeps_param|is a parameter, kept here, but -mm=own cannot make the callers of 'KV.take' give it up" "own_err_interface_borrow_param|is a parameter, returned here, but -mm=own cannot tell the callers of 'G.get' that the result borrows it" @@ -3820,7 +3829,7 @@ set(own_error_cases "own_err_interface_family_disagrees|is a parameter, returned here, but -mm=own cannot tell the callers of 'GA.get' that the result borrows it" "own_err_interface_member_drops|borrows a field but is used here after it may be released or overwritten" "own_err_interface_from_interface|is a parameter, returned here, but -mm=own cannot tell the callers of 'G.get' that the result borrows it" - "own_err_interface_result_outlives|borrows a call's result but is used here after it may be released or overwritten" + "own_err_interface_result_outlives|borrows the result of 'First.first' but is used here after it may be released or overwritten" "own_err_interface_fn_field_drops|borrows a field but is used here after it may be released or overwritten" "own_err_interface_fn_field_unknown|borrows a field but is used here after it may be released or overwritten" "own_err_interface_fn_field_exported|borrows a field but is used here after it may be released or overwritten" @@ -3859,7 +3868,7 @@ set(own_error_cases "own_err_lends_mismatch|'@lends' of 'pick' names 'a', but its body returns a borrow of 'b'" "own_err_lends_not_borrow|'@lends' of 'made' names 'l', but its body does not return a borrow of it" "own_err_lends_and_borrows|'firstOf' cannot say both '@lends' and '@borrows'" - "own_err_lends_two_params|'@lends' names one parameter, the one the result is a borrow of" + "own_err_lends_two_params|'@lends' of 'pick' names 'a', 'b', but its body returns a borrow of 'a'" "own_err_lends_outlives|borrows the result of 'firstOf' but is used here after it may be released or overwritten" "own_err_generator_method_this|'this value' is a parameter, kept here, but -mm=own cannot make the callers of 'S.chars' give it up" "own_err_string_call_keeps_twice|borrows a field and cannot be stored, returned or captured" @@ -3895,7 +3904,15 @@ set(own_error_cases "own_err_conditional_move_catch|is moved here on some paths only" "own_err_conditional_move_bound_method|is moved here on some paths only" "own_err_conditional_move_view|is moved here on some paths only" - "own_err_conditional_move_old_view|is moved here on some paths only") + "own_err_conditional_move_old_view|is moved here on some paths only" + "own_err_borrowing_record_stored|borrows the result of .* and cannot be stored, returned or captured" + "own_err_borrowing_record_outlives|borrows the result of .* but is used here after it may be released or overwritten" + "own_err_borrowing_record_kept_arg|borrows a field and cannot be stored, returned or captured" + "own_err_borrowing_record_let_outlives|borrows '.*' but is used here after '.*' is released or overwritten" + "own_err_borrowing_record_returned_local|borrows '.*' and cannot be stored, returned or captured" + "own_err_borrowing_record_field_written|borrows a field and cannot be stored, returned or captured" + "own_err_borrowing_record_cell|borrows a captured variable but is used here after it may be released or overwritten" + "own_err_borrowing_record_kept_param|borrows '.*' but is used here after it may be released or overwritten") 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_borrowing_record.ts b/tslang/test/tester/own/own_borrowing_record.ts new file mode 100644 index 000000000..9a99f2aeb --- /dev/null +++ b/tslang/test/tester/own/own_borrowing_record.ts @@ -0,0 +1,57 @@ +// @strict-null false +// -mm=own: a method that returns an object literal holding `this` and its parameter returns a +// borrowing record (spec 32): the caller owns the box, which may not outlive the receiver and the +// argument, and whose release frees only what the record owns - here nothing but itself. A call +// through a closed interface gets the record's facts; `closest` keeps the best record in a local +// (moved there on some paths only) and returns it, so its own result borrows its `ray` and `sc`. +// churn() keeps what it allocates alive, so a block freed too early is reused before it is read. +class V { + constructor(public x: number) {} + static plus(a: V, b: V) { return new V(a.x + b.x); } +} + +function churn() { + const keep: V[] = []; + for (let i = 0; i < 200; i++) { + keep.push(new V(77777)); + } + return keep.length; +} + +interface Ray { start: V; dir: V; } +interface Hit { thing: Th; ray: Ray; dist: number; } +interface Th { hit(ray: Ray): Hit; k: number; } +class A implements Th { k: number = 1; hit(ray: Ray): Hit { return { thing: this, ray: ray, dist: ray.dir.x }; } } +class B implements Th { + k: number = 2; + hit(ray: Ray): Hit { + if (ray.dir.x > 5) return null; + return { thing: this, ray: ray, dist: ray.dir.x * 2 }; + } +} +class Sc { ts: Th[] = [new A(), new B()]; } + +function closest(ray: Ray, sc: Sc) { + let best = +Infinity; + let bestHit: Hit = undefined; + for (let i in sc.ts) { + let h = sc.ts[i].hit(ray); + if (h != null && h.dist < best) { bestHit = h; best = h.dist; } + } + return bestHit; +} + +function main() { + const sc = new Sc(); + let s: number = 0; + for (let i = 0; i < 1000; i++) { + const h = closest({ start: new V(0), dir: new V(i % 10) }, sc); + churn(); + s += h.thing.k + h.dist + h.ray.start.x; + assert(h.ray.dir.x == i % 10); + } + + print(s); // 5500: A always wins (dist d against B's 2d), k 1 + assert(s == 5500); + print("done."); +} diff --git a/tslang/test/tester/own/own_borrowing_record_arg.ts b/tslang/test/tester/own/own_borrowing_record_arg.ts new file mode 100644 index 000000000..709dd0ede --- /dev/null +++ b/tslang/test/tester/own/own_borrowing_record_arg.ts @@ -0,0 +1,110 @@ +// @strict-null false +// -mm=own: object literals that borrow some fields and own others (spec 32) - a parameter, a field +// read, an owning local's value, a captured parameter, beside fresh values - as call arguments, as +// `const` and `let` locals, and returned from a method. The box is a temporary or a local's like any +// fresh block; its release frees the fresh fields and leaves the borrowed ones to their owners, which +// are read again after it is gone. churn() keeps what it allocates alive, so a block freed too early +// (or freed twice) is reused before it is read. +class V { + constructor(public x: number) {} + static plus(a: V, b: V) { return new V(a.x + b.x); } +} + +function churn() { + const keep: V[] = []; + for (let i = 0; i < 200; i++) { + keep.push(new V(77777)); + } + return keep.length; +} + +interface Ray { start: V; dir: V; } + +function len(r: Ray): number { return r.start.x + r.dir.x; } + +// two parameters: the ray tracer's `this.traceRay({ start: pos, dir: rd }, scene, depth + 1)` +function viaParams(pos: V, rd: V) { return len({ start: pos, dir: rd }); } + +// a parameter and a fresh value +function viaParamFresh(pos: V) { return len({ start: pos, dir: new V(1) }); } + +class Cam { + pos: V = new V(3); + + // a field read and a fresh value + go() { return len({ start: this.pos, dir: new V(1) }); } + + // a `const` holding the literal, read while its sources live + held() { + const r: Ray = { start: this.pos, dir: new V(2) }; + churn(); + return len(r) + r.start.x; + } + + // a `let` holding it + heldLet() { + let r: Ray = { start: this.pos, dir: new V(4) }; + churn(); + const n = len(r); + return n + r.dir.x; + } + + // returned: the result borrows `this` + mk(): Ray { return { start: this.pos, dir: new V(5) }; } +} + +// an owning local's value and a captured parameter: the ray tracer's addLight +function natural(pos: V) { + const f = (k: number) => { + let d = V.plus(pos, new V(k)); + const n = len({ start: pos, dir: d }); + churn(); + return n + d.x; + }; + return f(1) + f(2); +} + +function main() { + const p = new V(10); + const q = new V(20); + let s: number = 0; + for (let i = 0; i < 1000; i++) { + s += viaParams(p, q); + s += viaParamFresh(p); + churn(); + } + + assert(s == 41000); + + const c = new Cam(); + for (let i = 0; i < 1000; i++) { + assert(c.go() == 4); + assert(c.held() == 8); + assert(c.heldLet() == 11); + const r = c.mk(); + churn(); + assert(r.start.x == 3 && r.dir.x == 5); + } + + assert(c.pos.x == 3); + + let t: number = 0; + for (let i = 0; i < 1000; i++) { + t += natural(p); + } + + assert(t == 66000); + + // a `let` read into a field (once own_err_record_literal_let): the record borrows its value, and + // the `let` keeps its own release + let lp = new V(1); + for (let i = 0; i < 1000; i++) { + assert(len({ start: lp, dir: new V(2) }) == 3); + churn(); + } + + assert(lp.x == 1); + assert(p.x == 10 && q.x == 20); + print(s, t); + print("done."); +} diff --git a/tslang/test/tester/own/own_err_borrowing_record_cell.ts b/tslang/test/tester/own/own_err_borrowing_record_cell.ts new file mode 100644 index 000000000..3d49aff38 --- /dev/null +++ b/tslang/test/tester/own/own_err_borrowing_record_cell.ts @@ -0,0 +1,32 @@ +// -mm=own rejects: the literal borrows the value of a captured variable (spec 32), and the closure +// gives the variable a new value - its old one freed - before the literal is read. +class V { + constructor(public x: number) {} +} + +function churn() { + const keep: V[] = []; + for (let i = 0; i < 200; i++) { + keep.push(new V(77777)); + } + return keep.length; +} + +interface Ray { start: V; dir: V; } + +function len(r: Ray) { return r.start.x + r.dir.x; } + +function run() { + let v = new V(1); + const g = () => { + const r: Ray = { start: v, dir: new V(2) }; + v = new V(5); + churn(); + return len(r); + }; + return g(); +} + +function main() { + print(run()); +} diff --git a/tslang/test/tester/own/own_err_borrowing_record_field_written.ts b/tslang/test/tester/own/own_err_borrowing_record_field_written.ts new file mode 100644 index 000000000..a3215e64a --- /dev/null +++ b/tslang/test/tester/own/own_err_borrowing_record_field_written.ts @@ -0,0 +1,23 @@ +// -mm=own rejects: a field written through a borrowing record (spec 32) would release the value the +// record borrows, and the record's release would leave the one written. +class V { + constructor(public x: number) {} +} + +interface Ray { start: V; dir: V; } + +function len(r: Ray) { return r.start.x + r.dir.x; } + +class Cam { + pos: V = new V(3); + go() { + const r: Ray = { start: this.pos, dir: new V(1) }; + r.start = new V(7); + return len(r); + } +} + +function main() { + const c = new Cam(); + print(c.go(), c.pos.x); +} diff --git a/tslang/test/tester/own/own_err_borrowing_record_kept_arg.ts b/tslang/test/tester/own/own_err_borrowing_record_kept_arg.ts new file mode 100644 index 000000000..013f44e9c --- /dev/null +++ b/tslang/test/tester/own/own_err_borrowing_record_kept_arg.ts @@ -0,0 +1,34 @@ +// -mm=own rejects: the literal borrows `this.pos` (spec 32), and `take` keeps its argument, where +// it would outlive the field it borrows. +class V { + constructor(public x: number) {} +} + +function churn() { + const keep: V[] = []; + for (let i = 0; i < 200; i++) { + keep.push(new V(77777)); + } + return keep.length; +} + +interface Ray { start: V; dir: V; } + +class Sink { + rays: Ray[] = []; + take(r: Ray) { this.rays.push(r); } +} + +class Cam { + pos: V = new V(3); + send(s: Sink) { s.take({ start: this.pos, dir: new V(1) }); } +} + +function main() { + const s = new Sink(); + let c = new Cam(); + c.send(s); + c = new Cam(); + churn(); + print(s.rays[0].start.x); +} diff --git a/tslang/test/tester/own/own_err_borrowing_record_kept_param.ts b/tslang/test/tester/own/own_err_borrowing_record_kept_param.ts new file mode 100644 index 000000000..a7cb76b37 --- /dev/null +++ b/tslang/test/tester/own/own_err_borrowing_record_kept_param.ts @@ -0,0 +1,36 @@ +// -mm=own rejects: the literal borrows the parameter `p` (spec 32), which this function keeps and +// moves into the sink before the literal is read; the sink may free it. +class V { + constructor(public x: number) {} +} + +function churn() { + const keep: V[] = []; + for (let i = 0; i < 200; i++) { + keep.push(new V(77777)); + } + return keep.length; +} + +interface Ray { start: V; dir: V; } + +function len(r: Ray) { return r.start.x + r.dir.x; } + +class Sink { + vs: V[] = []; + take(v: V) { this.vs.push(v); } + clear() { this.vs.pop(); } +} + +function give(s: Sink, p: V) { + const r: Ray = { start: p, dir: new V(2) }; + s.take(p); + s.clear(); + churn(); + return len(r); +} + +function main() { + const s = new Sink(); + print(give(s, new V(1))); +} diff --git a/tslang/test/tester/own/own_err_borrowing_record_let_outlives.ts b/tslang/test/tester/own/own_err_borrowing_record_let_outlives.ts new file mode 100644 index 000000000..a45f486aa --- /dev/null +++ b/tslang/test/tester/own/own_err_borrowing_record_let_outlives.ts @@ -0,0 +1,25 @@ +// -mm=own rejects: the literal borrows the value `d` holds (spec 32), and `d` is given a new value +// - its old one freed - before the literal is read. +class V { + constructor(public x: number) {} +} + +function churn() { + const keep: V[] = []; + for (let i = 0; i < 200; i++) { + keep.push(new V(77777)); + } + return keep.length; +} + +interface Ray { start: V; dir: V; } + +function len(r: Ray) { return r.start.x + r.dir.x; } + +function main() { + let d = new V(1); + const r: Ray = { start: d, dir: new V(2) }; + d = new V(3); + churn(); + print(len(r)); +} diff --git a/tslang/test/tester/own/own_err_borrowing_record_outlives.ts b/tslang/test/tester/own/own_err_borrowing_record_outlives.ts new file mode 100644 index 000000000..be5657ad7 --- /dev/null +++ b/tslang/test/tester/own/own_err_borrowing_record_outlives.ts @@ -0,0 +1,27 @@ +// -mm=own rejects: `h` is a borrowing record (spec 32) that holds `a`, and `a` is given a new value +// - its old one freed - before `h` is read. +class V { + constructor(public x: number) {} +} + +function churn() { + const keep: V[] = []; + for (let i = 0; i < 200; i++) { + keep.push(new V(77777)); + } + return keep.length; +} + +interface Ray { start: V; dir: V; } +interface Hit { thing: Th; ray: Ray; dist: number; } +interface Th { hit(ray: Ray): Hit; k: number; } +class A implements Th { k: number = 1; hit(ray: Ray): Hit { return { thing: this, ray: ray, dist: ray.dir.x }; } } + +function main() { + let a = new A(); + const ray: Ray = { start: new V(1), dir: new V(2) }; + let h = a.hit(ray); + a = new A(); + churn(); + print(h.thing.k); +} diff --git a/tslang/test/tester/own/own_err_borrowing_record_returned_local.ts b/tslang/test/tester/own/own_err_borrowing_record_returned_local.ts new file mode 100644 index 000000000..f2ddc079d --- /dev/null +++ b/tslang/test/tester/own/own_err_borrowing_record_returned_local.ts @@ -0,0 +1,26 @@ +// -mm=own rejects: the returned literal borrows the value of a local (spec 32), which the function +// frees as it returns; only what a parameter or `this` holds may be lent to the caller. +class V { + constructor(public x: number) {} +} + +function churn() { + const keep: V[] = []; + for (let i = 0; i < 200; i++) { + keep.push(new V(77777)); + } + return keep.length; +} + +interface Ray { start: V; dir: V; } + +function mk(): Ray { + let d = new V(1); + return { start: d, dir: new V(2) }; +} + +function main() { + const r = mk(); + churn(); + print(r.start.x); +} diff --git a/tslang/test/tester/own/own_err_borrowing_record_stored.ts b/tslang/test/tester/own/own_err_borrowing_record_stored.ts new file mode 100644 index 000000000..33057ff15 --- /dev/null +++ b/tslang/test/tester/own/own_err_borrowing_record_stored.ts @@ -0,0 +1,34 @@ +// @strict-null false +// -mm=own rejects: `sc.ts[0].hit(ray)` is a borrowing record (spec 32) that holds `sc.ts[0]` and +// `ray`; stored into a global's field it would outlive both. +class V { + constructor(public x: number) {} +} + +function churn() { + const keep: V[] = []; + for (let i = 0; i < 200; i++) { + keep.push(new V(77777)); + } + return keep.length; +} + +interface Ray { start: V; dir: V; } +interface Hit { thing: Th; ray: Ray; dist: number; } +interface Th { hit(ray: Ray): Hit; k: number; } +class A implements Th { k: number = 1; hit(ray: Ray): Hit { return { thing: this, ray: ray, dist: ray.dir.x }; } } +class Sc { ts: Th[] = [new A()]; } +class Keep { h: Hit = null; } + +const keep = new Keep(); + +function store(ray: Ray, sc: Sc) { + keep.h = sc.ts[0].hit(ray); +} + +function main() { + const sc = new Sc(); + store({ start: new V(1), dir: new V(2) }, sc); + churn(); + print(keep.h.ray.start.x); +} diff --git a/tslang/test/tester/own/own_err_record_literal_let.ts b/tslang/test/tester/own/own_err_record_literal_let.ts deleted file mode 100644 index 2e8481142..000000000 --- a/tslang/test/tester/own/own_err_record_literal_let.ts +++ /dev/null @@ -1,12 +0,0 @@ -// -mm=own rejects: a `let` read into an object literal's field. Its slot keeps its own release -// at the end of its scope, which nothing cancels, and the record's would free the block a second -// time; only a fresh value, or a `const` folded into the value it holds, moves in (spec 27). -class V { - constructor(public x: number) {} -} -interface Ray { start: V; dir: V; } -function len(r: Ray) { return r.start.x + r.dir.x; } -function main() { - let p = new V(1); - print(len({ start: p, dir: new V(2) })); -} diff --git a/tslang/test/tester/own/own_lends.ts b/tslang/test/tester/own/own_lends.ts index b6cfd836e..9434662bf 100644 --- a/tslang/test/tester/own/own_lends.ts +++ b/tslang/test/tester/own/own_lends.ts @@ -36,6 +36,18 @@ export function swapFirst(n: Node, l: List): Node { return l.first; } +// a borrowing record of two arguments (spec 32): the caller owns the box, which may not outlive +// either, and whose release leaves both to their owners +export interface Pair { + a: Node; + b: Node; +} + +@lends("a", "b") +export function pairOf(a: Node, b: Node): Pair { + return { a: a, b: b }; +} + class Vec { x: number; @@ -63,5 +75,15 @@ function main() { churn(); assert(kept.v == 1 && l.second.v == 3 && l.first.v == 1); + const n1 = new Node(5); + const n2 = new Node(6); + for (let i = 0; i < 100; i++) { + const pr = pairOf(n1, n2); + churn(); + assert(pr.a.v == 5 && pr.b.v == 6); + } + + assert(n1.v == 5 && n2.v == 6); + print("done."); } From 9c7558437f59e83b1b5a3e6ecbde793f325b5332 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 19:46:22 +0100 Subject: [PATCH 34/57] own-raytrace T7: borrowing record negatives that read freed memory with their rule off own_err_borrowing_record_stored now borrows a holder's field that is overwritten before the read, and _kept_arg a camera that a box's field drops (a stack-promoted local kept its fields alive to the end of main, so neither showed a freed read). New: _place_overwritten (a field the record borrows, overwritten before the record is read: the place read's check, with the record's uses as its own) and _open (an exported function returning a record of its parameter without `@lends`). Each prints a freed block's value, or crashes, when its rule is switched off (spec 32). Co-Authored-By: Claude Opus 5.5 --- tslang/test/tester/CMakeLists.txt | 4 ++- .../own/own_err_borrowing_record_kept_arg.ts | 11 +++++-- .../own/own_err_borrowing_record_open.ts | 25 ++++++++++++++++ ..._err_borrowing_record_place_overwritten.ts | 29 +++++++++++++++++++ .../own/own_err_borrowing_record_stored.ts | 5 +++- 5 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 tslang/test/tester/own/own_err_borrowing_record_open.ts create mode 100644 tslang/test/tester/own/own_err_borrowing_record_place_overwritten.ts diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index f8035efcd..058233cdf 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3912,7 +3912,9 @@ set(own_error_cases "own_err_borrowing_record_returned_local|borrows '.*' and cannot be stored, returned or captured" "own_err_borrowing_record_field_written|borrows a field and cannot be stored, returned or captured" "own_err_borrowing_record_cell|borrows a captured variable but is used here after it may be released or overwritten" - "own_err_borrowing_record_kept_param|borrows '.*' but is used here after it may be released or overwritten") + "own_err_borrowing_record_kept_param|borrows '.*' but is used here after it may be released or overwritten" + "own_err_borrowing_record_place_overwritten|borrows a field but is used here after it may be released or overwritten" + "own_err_borrowing_record_open|borrows '.*' and cannot be stored, returned or captured") 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_borrowing_record_kept_arg.ts b/tslang/test/tester/own/own_err_borrowing_record_kept_arg.ts index 013f44e9c..14077b267 100644 --- a/tslang/test/tester/own/own_err_borrowing_record_kept_arg.ts +++ b/tslang/test/tester/own/own_err_borrowing_record_kept_arg.ts @@ -24,11 +24,16 @@ class Cam { send(s: Sink) { s.take({ start: this.pos, dir: new V(1) }); } } +class Box { + c: Cam = new Cam(); +} + function main() { const s = new Sink(); - let c = new Cam(); - c.send(s); - c = new Cam(); + const b = new Box(); + b.c.send(s); + b.c = new Cam(); + b.c.pos.x = 9; churn(); print(s.rays[0].start.x); } diff --git a/tslang/test/tester/own/own_err_borrowing_record_open.ts b/tslang/test/tester/own/own_err_borrowing_record_open.ts new file mode 100644 index 000000000..760cb2408 --- /dev/null +++ b/tslang/test/tester/own/own_err_borrowing_record_open.ts @@ -0,0 +1,25 @@ +// -mm=own rejects: `mk` is exported, so a caller in another module, which cannot see that its result +// borrows `p` (spec 32), would not bound it by `p`; it has to say so with `@lends`. +export class V { + constructor(public x: number) {} +} + +function churn() { + const keep: V[] = []; + for (let i = 0; i < 200; i++) { + keep.push(new V(77777)); + } + return keep.length; +} + +export interface Ray { start: V; dir: V; } + +export function mk(p: V): Ray { + return { start: p, dir: new V(2) }; +} + +function main() { + const r = mk(new V(1)); + churn(); + print(r.start.x); +} diff --git a/tslang/test/tester/own/own_err_borrowing_record_place_overwritten.ts b/tslang/test/tester/own/own_err_borrowing_record_place_overwritten.ts new file mode 100644 index 000000000..836880029 --- /dev/null +++ b/tslang/test/tester/own/own_err_borrowing_record_place_overwritten.ts @@ -0,0 +1,29 @@ +// -mm=own rejects: the literal borrows the field `c.pos` (spec 32), which is given a new value - its +// old one freed - before the literal is read. +class V { + constructor(public x: number) {} +} + +function churn() { + const keep: V[] = []; + for (let i = 0; i < 200; i++) { + keep.push(new V(77777)); + } + return keep.length; +} + +interface Ray { start: V; dir: V; } + +function len(r: Ray) { return r.start.x + r.dir.x; } + +class Cam { + pos: V = new V(3); +} + +function main() { + const c = new Cam(); + const r: Ray = { start: c.pos, dir: new V(1) }; + c.pos = new V(9); + churn(); + print(len(r)); +} diff --git a/tslang/test/tester/own/own_err_borrowing_record_stored.ts b/tslang/test/tester/own/own_err_borrowing_record_stored.ts index 33057ff15..c905b9fda 100644 --- a/tslang/test/tester/own/own_err_borrowing_record_stored.ts +++ b/tslang/test/tester/own/own_err_borrowing_record_stored.ts @@ -19,6 +19,7 @@ interface Th { hit(ray: Ray): Hit; k: number; } class A implements Th { k: number = 1; hit(ray: Ray): Hit { return { thing: this, ray: ray, dist: ray.dir.x }; } } class Sc { ts: Th[] = [new A()]; } class Keep { h: Hit = null; } +class Holder { r: Ray = { start: new V(1), dir: new V(2) }; } const keep = new Keep(); @@ -28,7 +29,9 @@ function store(ray: Ray, sc: Sc) { function main() { const sc = new Sc(); - store({ start: new V(1), dir: new V(2) }, sc); + const h = new Holder(); + store(h.r, sc); + h.r = { start: new V(5), dir: new V(6) }; churn(); print(keep.h.ray.start.x); } From 237424d1216a3ead2c8fa7ed2d901c31c1572673 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 19:54:27 +0100 Subject: [PATCH 35/57] own-raytrace T7: -mm=own: a borrow of a borrowing record is a use of what the record borrows Found in self-review: walkRecord followed a call's result only when it was a borrowing record that held the record, so `const h2 = id(h)` (a plain borrow of `h`) was bounded by `h`'s own ends and not by what `h` borrows: `a = new A()` before `h2.thing.k` compiled and printed 77777 (gc 1). A call's result that borrows the record, or anything read out of it - a plain borrow, a record that holds it, a generator's state that borrows it - is now followed, so its uses are the record's. Negative own_err_borrowing_record_borrowed_again. Co-Authored-By: Claude Opus 5.5 --- .../lib/TypeScript/OwnershipInferencePass.cpp | 20 +++++++++++-- tslang/test/tester/CMakeLists.txt | 3 +- ...own_err_borrowing_record_borrowed_again.ts | 30 +++++++++++++++++++ 3 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 tslang/test/tester/own/own_err_borrowing_record_borrowed_again.ts diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index e79e9b28b..4ef7a43a5 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -2764,12 +2764,26 @@ class OwnershipInferencePass continue; } + // a result that borrows it - a plain borrow (`id(r)`), or a borrowing record that + // holds it - points where it points auto args = callArgs(user); - if (isRecordResult(user) && llvm::any_of(resultBorrowsAll(user), [&](int index) { + auto given = [&](llvm::ArrayRef indices) { + return llvm::any_of(indices, [&](int index) { return static_cast(index) < args.size() && args[index] == value; - })) + }); + }; + + if (user->getNumResults() == 1 && given(resultBorrowsAll(user))) + { + push(user->getResult(0), pending.record || isRecordResult(user)); + } + + // ...and a generator's state that borrows it (OWN_RESULT_BOUNDED_ATTR_NAME) + if (auto bounded = user->getAttrOfType(OWN_RESULT_BOUNDED_ATTR_NAME); + bounded && user->getNumResults() == 1 && + given(llvm::SmallVector(bounded.asArrayRef().begin(), bounded.asArrayRef().end()))) { - push(user->getResult(0), true); + push(user->getResult(0), false); } continue; diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 058233cdf..60c9b8987 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3914,7 +3914,8 @@ set(own_error_cases "own_err_borrowing_record_cell|borrows a captured variable but is used here after it may be released or overwritten" "own_err_borrowing_record_kept_param|borrows '.*' but is used here after it may be released or overwritten" "own_err_borrowing_record_place_overwritten|borrows a field but is used here after it may be released or overwritten" - "own_err_borrowing_record_open|borrows '.*' and cannot be stored, returned or captured") + "own_err_borrowing_record_open|borrows '.*' and cannot be stored, returned or captured" + "own_err_borrowing_record_borrowed_again|borrows the result of .* but is used here after it may be released or overwritten") 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_borrowing_record_borrowed_again.ts b/tslang/test/tester/own/own_err_borrowing_record_borrowed_again.ts new file mode 100644 index 000000000..93661710c --- /dev/null +++ b/tslang/test/tester/own/own_err_borrowing_record_borrowed_again.ts @@ -0,0 +1,30 @@ +// -mm=own rejects: `id(h)` is a borrow of the record `h` (spec 32), so it may not be read after what +// `h` borrows - `a` - is given a new value, its old one freed. +class V { + constructor(public x: number) {} +} + +function churn() { + const keep: V[] = []; + for (let i = 0; i < 200; i++) { + keep.push(new V(77777)); + } + return keep.length; +} + +interface Ray { start: V; dir: V; } +interface Hit { thing: Th; ray: Ray; dist: number; } +interface Th { hit(ray: Ray): Hit; k: number; } +class A implements Th { k: number = 1; hit(ray: Ray): Hit { return { thing: this, ray: ray, dist: ray.dir.x }; } } + +function id(r: Hit): Hit { return r; } + +function main() { + let a = new A(); + const ray: Ray = { start: new V(1), dir: new V(2) }; + let h = a.hit(ray); + const h2 = id(h); + a = new A(); + churn(); + print(h2.thing.k); +} From 337e8e504d2dff93f585e4f2323d3c1c6dc785f7 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 20:01:28 +0100 Subject: [PATCH 36/57] own-raytrace T7: spec 32, borrowing records What is accepted (an object literal that borrows `this`, parameters, field reads, a local's or a captured variable's value beside fresh values; the box owned like any fresh block, released by a routine of its own that leaves the borrowed fields, and bounded by what it borrows where it is made and where a call returns it), the facts (`__own_result_borrows` as a set, `__own_result_record`, `@lends` of several), tests, teeth, measured (own 4.2 MB as rc over 2 million iterations), corpus (528 before and after, the same files), the ray tracer as a program (15 errors to 10; the rest G5's and one G8 read), and what is not here. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) 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 65ab7677e..1d9a1def1 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 @@ -3631,3 +3631,151 @@ turns the rule off. Negatives `own_err_declared_callback_field_reference` (revie `_intrinsic` prints `77777 9` while `_field_reference` stays rejected. g1-g5, f7, f9, f10, h1, h2 rejected; d1-d9 and the scalar-only control keep the rule. The ray tracer built as a program stays at 15 errors (the same ones); as an object file, 19. Corpus 528, the same files. + +## 32. Borrowing records (G2 of the ray tracer plan), 2026-10-22 + +`{ thing: this, ray: ray, dist: d }` returned as an `Intersection` was `an object literal that holds +a value it owns is moved only into a local or returned as it is` (§27): the box's release would free +`this` and `ray`, which are the caller's. So were the ray tracer's call-argument temporaries `{ +start: pos, dir: rd }`, `{ start: pos, dir: livec }` and `{ start: scene.camera.pos, dir: +getPoint(...) }`, and `const r: Ray = { start: this.pos, dir: new V(1) }`. Such a literal is now a +**borrowing record** (USER decision): Rust's `struct Hit<'a> { thing: &'a Th, ray: &'a Ray, dist }`. +Its box is owned like any fresh block - by the local that holds it, the temporary, or, returned, the +caller - and its release frees the box and the fields it owns and leaves the borrowed ones to their +owners. What it borrows bounds every use of it. (The plan calls the result "a borrow" of its sources; +a caller that owned nothing would leave every box and its fresh fields to nobody, and the plan's +own interplay - a record moved on some paths only, read through its new owner, released by a +routine that frees only its fresh fields - is an owner's. The bound is a borrow's: the same chain +and the same errors as a call's result that borrows its arguments.) + +- **Which literals.** The signature pass classifies each object literal box seen only as interfaces + (`classifyRecordBoxes`, in every round of the facts fixpoint, since a field's call may turn out to + return a borrow): each field that holds a block is *owned* - a fresh value, which moves in as in + §27 - or *borrowed* (`recordFieldSource`): `this` or a parameter, seen through views; a read out + of a place, or a call's result that borrows an argument; an owning local's value (`let d`); a + captured variable's value (a closure body's read of a cell); an immortal global or literal. A + field is never both, and never a string (strings are copied, §22). A box with a borrowed field is + marked, and so is every interface made of it (`__own_record_borrowed_fields`, the fields' tuple + positions); a store into a borrowed field keeps nothing (`keeps`, so `this` and `ray` are not + kept parameters of `hit` any more). +- **The release travels with the value** (the design's builder check). The interface's tag names a + descriptor of its own (variant `r...`) whose release routine (`tsrelrec__`) frees + the box and the fields it owns and skips the marked ones; a non-escaping closure's box + (`__own_borrows_captures`) is the precedent. Every release of the record - a temporary's, a + local's at the end of its scope, §30's edge release, an overwrite of a local, the caller's - reads + the tag, so records made at + different sites (`Sphere.intersect`, `Plane.intersect`) may meet in one local. A box that is seen + as anything but interfaces is not classified (its static object type's routine would free every + field), and stack promotion refuses a marked box (its `ts.ReleaseContents` would give back every + field); today's escape analysis refuses every box seen as an interface anyway ("converted"). +- **The facts.** `__own_result_borrows` is a set (`DenseI32ArrayAttr` of argument indices, the + receiver 0 for a method; `own_facts::resultBorrowsAll`). Alone it is a plain borrow and names one + argument, as before (`resultBorrows` keeps that index; a declared `@lends` of several is a plain + borrow of each). With `__own_result_record` the result is a borrowing record: the caller owns the + box, and it may hold a borrow of each argument named. A function's result is a record when every + heap value it returns is one - a box made there, or a record call's result - through views, + merges and the owning locals that hold it (`recordParamsOf`), and every field it borrows is a + parameter's, `this`'s, or a read out of one: those parameters are the set. A record on one path + and anything else on another (a plain borrow, a fresh object) gives no facts. `closest` returns + `bestHit`, which holds `sc.ts[i].hit(ray)`'s records: its result borrows `ray` and `sc`. + `@lends` may name several parameters: `@lends("a", "b") pairOf(a, b): Pair { return { a: a, b: b } + }` (`own_lends`). A declaration's `@lends`, seen from another module without its body, is a plain + borrow: a record's box then leaks at that caller, and nothing is freed twice. +- **Families** meet by agreement, as before (`meetFacts`): members whose sets or record-ness differ + are demoted. The union of the sets would be sound for records (the caller would bound the result by + more arguments), but not across a record and a plain borrow, whose callers release different + things; agreement is what the ray tracer needs (`Sphere.intersect` and `Plane.intersect` both lend + `this` and `ray`). +- **Where the record is made** (`classifyRecords`, `checkBorrowingRecords`): every use of it and of + what is read out of it, through its views, merges and the locals that hold it (`walkRecord`), is a + use of each borrowed field's source. A read out of a place has them as its own uses + (`walkBorrowed`, through the field's store, now a borrow), so a drop of the place before a use is + `borrows a field but is used here after ...`. An owning local's value is bounded by the local's + ends (`borrows 'd' but is used here after 'd' is released or overwritten`), a captured variable's + by whatever may assign it (an assignment through a cell, a call that may drop), a parameter the + function keeps by its moves. `this` and other parameters are the caller's for the whole call. + rc's retain of the record read goes (`holdsConstantsOrMoves`); its fresh fields move in against it + as an owning record's do. The record stored anywhere but a local (a field, an element, a global, a + capture), given to a callee that keeps it, or written through (`r.start = ...`: the overwrite would + release a borrowed value, and the release would leave the one written) is `... and cannot be + stored, returned or captured`. Returned, every field it borrows must be a parameter's that the + function's facts lend; a local's value, or a function whose facts are lost (an exported one + without `@lends`), is the same error. +- **Where a call returns one** (`checkRecordResults`): the result is fresh (`isFresh`, whatever the + call goes through), so its moves and releases are the ordinary ones - `let h = ...`, §30's move + into `bestHit` on some paths with its release on the other edges, §29's reads through the new + owner - and each release is the per-site routine. It may hold a borrow of each named argument, + somewhere under it, so the chain `checkPlaceRead` builds for a borrowing call, now over all of + them (`chainOf`), bounds every use: `borrows the result of 'A.hit' but is used here after it may + be released or overwritten`. Kept, it is `borrows the result of ... and cannot be stored`; + returned, its arguments must be parameters this function lends. rc's retain of a fresh argument + right before the call is no end there: the box's own check decides it. A call's result that + records a value is followed by `derivedFrom` and by §30's taint. A method reached through an + interface is named `.` in these errors (`own_err_interface_result_outlives` + reads `the result of 'First.first'`). +- **Iterators** (§31): no `next()` or field function whose result is a record is marked + `__own_fresh_result`; a record's bound applies. + +`own_borrowing_record` is the brief's program: `A.hit` and `B.hit` (`null` on some paths) return +records through the closed interface `Th`, and `closest` keeps the best in a local and returns it, +1000 times with churn between, under own, rc, gc and none, AOT and JIT, and on Linux; own emits no +counting. `own_borrowing_record_arg` covers the addendum: call-argument temporaries with two +parameters, a parameter and a fresh value, a field read and a fresh value, an owning local's value +and a captured parameter (the ray tracer's `addLight`), a `let` read into a field, `const` and `let` +locals holding a record, and a method returning one; the borrowed values are read again after the +records are gone. The brief's program as written does not compile in any model (`k = 1` is an +`s32`, which does not match the interface's `number`, and `cond ? null : {...}` cannot be cast to +`Hit`), so the test declares `k: number` and returns `null` from an `if`. + +Negatives: `own_err_borrowing_record_stored` (`keep.h = sc.ts[0].hit(ray)`, a global's field), +`_outlives` (`a = new A()` before `h.thing.k`), `_kept_arg` (a temporary given to a callee that keeps +it), `_let_outlives`, `_cell`, `_kept_param`, `_place_overwritten`, `_returned_local`, `_open`, +`_field_written`, `_borrowed_again`. `own_err_record_literal_let` (§27: a `let` read into a field) is +a correct program now - the record borrows the `let`'s value, and the `let` keeps its release - and +is gone; its shape is in `own_borrowing_record_arg`. `own_err_lends_two_params` now reads `'@lends' +of 'pick' names 'a', 'b', but its body returns a borrow of 'a'`. + +Teeth (temporary `OWN_TEETH` switches, removed): with the per-site routine dropped (every record's +interface given its type's descriptor), the AOT runs of `own_borrowing_record` and +`own_borrowing_record_arg` fail with `0xC0000005` and `0xC0000374`; with only the boxes of records +that are not returned (the call-argument temporaries) given their type's routine, +`own_borrowing_record_arg` fails with `0xC0000374` and `own_borrowing_record` passes (its only +temporary owns both fields). The rules each have a negative that reads a freed block with the rule +off: the caller's bound skipped, `_outlives` prints 77777 +and `_stored` 6.95159e-310 (gc 1 and 1); a local's, a captured variable's and a kept parameter's +ends skipped, `_let_outlives`, `_cell` and `_kept_param` print 77779 (gc 3); the return check and +the local's ends skipped, `_returned_local` prints 77777 (gc 1), and `_open` compiles; the kept +check skipped, `_kept_arg` prints 77777 (gc 3), and with the place read's hook skipped too, +`_field_written` exits `0xC0000374` (gc `8 3`); the hook alone skipped, `_place_overwritten` +prints 77778 (gc 4). `_borrowed_again` is the hole review of this rule found: `const h2 = id(h)` +was bounded by `h`'s ends, not by what `h` borrows; before the fix it compiled and printed 77777 +(gc 1). With stack promotion's refusal skipped nothing changes: its escape analysis refuses every +box seen as an interface already ("converted"). + +Measured: 2 million iterations of the brief's loop and a call-argument temporary (about 7 blocks +each) peak at 4.2 MB under own, as under rc (gc 6.2 MB), AOT with `--opt`. + +Corpus (`test/tester/tests/*.ts` under `-mm=own --no-default-lib`, `--emit=obj`, Windows): 528 of +693 before this section and 528 after, the same files. + +The ray tracer built as a program (`--emit=exe`): 15 errors before this section, 10 after. +Cleared: the records `Sphere.intersect` and `Plane.intersect` return (148, 163); `moved inside a +loop` at `scene.things[i].intersect(ray)` (203: `ray` and the receiver are borrowed now, not kept); +`getReflectionColor`'s `{ start: pos, dir: rd }` (243) and `addLight`'s `{ start: pos, dir: livec }` +(250); `scene.camera.pos` stored into `render`'s literal (279:43). That literal (279:29) is now +`borrows a field but is used here after it may be released or overwritten`: the record holds +`scene.camera.pos` across `traceRay`, which may drop because `getNaturalColor` calls `reduce` with +a closure. New for the same reason: `traceRay`'s `this.shade(isect, ...)`, where `isect` borrows +the result of `intersections` (227). The rest are G5's (`reduce`: 237-239 and 266 across +`getNaturalColor`, the generated `___array_reduce`, `addLight` returning its parameter on one path) +and the `getPoint` read (279:75), present before this section (G8). With `reduce` replaced by a +loop, 2 errors remain: `addLight`'s mixed return and the `getPoint` read. + +Not here: +- A record whose box is seen as an object rather than an interface (`const o = { a: this.p }` with + no interface type) is not classified: its static type's routine would free the borrowed field. + §27's error stands. +- A field written through a borrowing record is an error even when it is a field the record owns. +- A record's borrowed field may not hold a string. +- Plain borrows are still of one argument (`c ? a : b` is no borrow), and do not flow through an + owning local assigned in a loop; only records do. From d730a631d7a1507555dd938270f2a1210e073fa9 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 21:05:42 +0100 Subject: [PATCH 37/57] own-raytrace T8: -mm=own: reduce takes its accumulator, and a parameter returned on some paths is kept G5 of the ray tracer plan. A function that returns a parameter itself on some paths and a value it owns on the others (`return v.x > 2 ? acc : V.plus(acc, v)`) keeps that parameter (`__own_params`): its callers move the argument in, the path that returns it moves it out, and every other path releases it. The signature pass marks such a parameter `__own_params_released`; the inference puts a release of its slot before each return and each throw that leaves the function, and the move's own check erases those it reaches and places the release on the edges into the join that did not move it (spec 30's static placement: no run-time flag). A caller that still uses the argument is "used here after its value was moved" (own_err_mixed_return, rewritten). A parameter read into an owning `let` that is given another value is kept too (`let r = init; ... r = f(r, v)` in the generated `___array_reduce`); not a string, which the local copies. A call through a function-typed parameter of a closed function, which is only ever called and to which every caller passes a function of this module (its symbol, or a closure over a capture box), reaches exactly those functions: the parameter's family. The members are closed when their symbols go nowhere else, and the call gets the facts they agree on, shifted by one for a closure's body (its box comes first), and `__own_no_drops` when none drops. A member that only borrows a parameter another member keeps is given it to keep and releases it on every path; any other disagreement demotes them all ("a function passed in its place disagrees"). own_reduce (the brief's program, an immortal start value returned as it is, early return, return from a loop and from a `try` with a `finally`, a throw); negatives own_err_mixed_return_join_use, own_err_callback_escapes, _disagrees, _promoted_used. own_err_borrowed_result_escapes now passes its function through a `let`: passed straight to `apply`, the call gets its facts and the program is correct. Co-Authored-By: Claude Opus 5.5 --- tslang/lib/TypeScript/OwnershipFacts.h | 4 + .../lib/TypeScript/OwnershipInferencePass.cpp | 146 ++++- .../lib/TypeScript/OwnershipSignaturePass.cpp | 618 +++++++++++++++++- tslang/test/tester/CMakeLists.txt | 16 +- .../own/own_err_borrowed_result_escapes.ts | 7 +- .../tester/own/own_err_callback_disagrees.ts | 25 + .../tester/own/own_err_callback_escapes.ts | 22 + .../own/own_err_callback_promoted_used.ts | 26 + .../test/tester/own/own_err_mixed_return.ts | 8 +- .../own/own_err_mixed_return_join_use.ts | 21 + tslang/test/tester/own/own_reduce.ts | 114 ++++ 11 files changed, 993 insertions(+), 14 deletions(-) create mode 100644 tslang/test/tester/own/own_err_callback_disagrees.ts create mode 100644 tslang/test/tester/own/own_err_callback_escapes.ts create mode 100644 tslang/test/tester/own/own_err_callback_promoted_used.ts create mode 100644 tslang/test/tester/own/own_err_mixed_return_join_use.ts create mode 100644 tslang/test/tester/own/own_reduce.ts diff --git a/tslang/lib/TypeScript/OwnershipFacts.h b/tslang/lib/TypeScript/OwnershipFacts.h index badba9060..41de60cfe 100644 --- a/tslang/lib/TypeScript/OwnershipFacts.h +++ b/tslang/lib/TypeScript/OwnershipFacts.h @@ -29,6 +29,10 @@ namespace mlir_ts = mlir::typescript; // // The parameters the callee keeps: the caller moves each such argument into the call. #define OWN_PARAMS_ATTR_NAME "__own_params" +// On a function: the parameters it keeps (OWN_PARAMS_ATTR_NAME) that its body does not take into +// anything on every path - one it returns on some paths only, one a callback family gives it to keep +// (spec 33). The inference releases each on the paths that do not move it, statically. +#define OWN_PARAMS_RELEASED_ATTR_NAME "__own_params_released" // The arguments the result is a borrow of, as argument indices (the receiver is 0 for a method): the // result may not outlive any of them. Alone, the caller owns nothing it gets back, and it names one // argument. With OWN_RESULT_RECORD_ATTR_NAME it may name several (spec 32). diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index 4ef7a43a5..05356088d 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -144,6 +144,21 @@ class OwnershipInferencePass classifyBorrowingFields(); // ...and whether an object literal borrows what its fields hold (spec 32) classifyRecords(); + // a kept parameter the body may not move on every path is released at its exits (spec 33) + releaseKeptParamsAtExits(); + auto exitReleasesGone = llvm::make_scope_exit([&]() { + // the dry run changes nothing: what it put in goes again + if (dryRun) + { + for (auto *release : exitReleases) + { + toErase.remove(release); + release->erase(); + } + } + + exitReleases.clear(); + }); // A view of a block is that block: the candidate is always the root. f.walk([&](mlir::Operation *op) { @@ -456,7 +471,8 @@ class OwnershipInferencePass // the facts were for this pass only returnedParams.clear(); f.walk([](mlir::Operation *op) { - for (auto *name : {OWN_PARAMS_ATTR_NAME, OWN_RESULT_BORROWS_ATTR_NAME, OWN_RESULT_RECORD_ATTR_NAME, + for (auto *name : {OWN_PARAMS_ATTR_NAME, OWN_PARAMS_RELEASED_ATTR_NAME, OWN_RESULT_BORROWS_ATTR_NAME, + OWN_RESULT_RECORD_ATTR_NAME, OWN_NO_DROPS_ATTR_NAME, OWN_FACTS_LOST_ATTR_NAME, OWN_FRESH_RESULT_ATTR_NAME, OWN_ASSIGNS_CAPTURES_ATTR_NAME, OWN_RESULT_BOUNDED_ATTR_NAME, @@ -1978,6 +1994,114 @@ class OwnershipInferencePass return true; } + // ---- A kept parameter released where it is not moved (spec 33) ---- + // + // `return v.x > 2 ? acc : V.plus(acc, v)`: `acc` is kept (OwnershipSignaturePass), so the caller + // moved it in; the path that returns it moves it out, and the others must release it. So must a + // callback that only borrows the accumulator its family gives it to keep. MLIRGen made no owner + // of a parameter, so its releases are put in here, before each way out of the body - a return, + // a throw that leaves the function - as MLIRGen puts an owning local's. The move's own check then + // erases those it reaches, and places the release on the edges into the join that did not move + // it (spec 30), statically: no run-time flag. Unwinding out of the function (a call that throws + // outside a `try`, a `finally` passed on the way out) gives nothing back: it leaks, as an owning + // local does there. + + // the releases put in for this run + llvm::SmallVector exitReleases; + + // The slot of parameter `index` (isParameterSlot), or null. + mlir_ts::VariableOp parameterSlot(int32_t index) + { + auto &body = getFunction().getFunctionBody(); + if (body.empty() || static_cast(index) >= body.front().getNumArguments()) + { + return {}; + } + + for (auto *user : body.front().getArgument(index).getUsers()) + { + auto varOp = mlir::dyn_cast(user); + auto position = -1; + if (varOp && isParameterSlot(varOp, position) && position == index) + { + return varOp; + } + } + + return {}; + } + + // Is `slot` a parameter this function keeps and releases where it does not move it? + bool isReleasedParam(mlir::Value slot) + { + auto f = getFunction(); + auto attr = f->getAttrOfType(OWN_PARAMS_RELEASED_ATTR_NAME); + auto varOp = slot.getDefiningOp(); + auto index = -1; + return attr && varOp && isParameterSlot(varOp, index) && llvm::is_contained(attr.asArrayRef(), index) && + llvm::is_contained(ownedParams(f), index); + } + + // Does `value` come from a read of `slot`: computed from one, through any op? + static bool readsSlot(mlir::Value value, mlir::Value slot, int depth = 0) + { + auto *def = value.getDefiningOp(); + if (!def) + { + return false; + } + + // too deep to tell: as if it did + if (depth > 16) + { + return true; + } + + if (auto loadOp = mlir::dyn_cast(def); loadOp && loadOp.getReference() == slot) + { + return true; + } + + return llvm::any_of(def->getOperands(), [&](mlir::Value operand) { return readsSlot(operand, slot, depth + 1); }); + } + + void releaseKeptParamsAtExits() + { + exitReleases.clear(); + auto f = getFunction(); + auto attr = f->getAttrOfType(OWN_PARAMS_RELEASED_ATTR_NAME); + if (!attr) + { + return; + } + + for (auto index : attr.asArrayRef()) + { + auto varOp = parameterSlot(index); + if (!varOp || !llvm::is_contained(ownedParams(f), index)) + { + continue; + } + + for (auto &block : f.getFunctionBody()) + { + auto *exit = block.empty() ? nullptr : &block.back(); + // a thrown value made of the parameter takes it with it: no release there (a leak, + // if it does not) + auto leaves = mlir::isa_and_nonnull(exit) || + (mlir::isa_and_nonnull(exit) && + !readsSlot(exit->getOperand(0), varOp.getResult())); + if (!leaves) + { + continue; + } + + mlir::OpBuilder builder(exit); + exitReleases.push_back(builder.create(exit->getLoc(), varOp.getResult())); + } + } + } + // A parameter this function keeps (`__own_params`): its caller moved the argument in, and this // function has no release for it, so exactly one taker must move it on, on every way out of // the function, and nothing may read it after that. @@ -1994,11 +2118,25 @@ class OwnershipInferencePass return; } - if (!checkSlotMove(receiver.retain, receiver.value, receiver.load, toErase)) + // one the body releases on the paths that do not move it (spec 33): the move's own check + // erases the releases at the exits it reaches, and places one on each edge into where the + // paths join that did not move it (spec 30) + auto released = isReleasedParam(slot); + if (!checkSlotMove(receiver.retain, receiver.value, receiver.load, toErase, released)) { return; } + if (released) + { + if (!isCall(receiver.retain)) + { + toErase.insert(receiver.retain); + } + + return; + } + auto &body = getFunction().getFunctionBody(); auto *moved = body.findAncestorOpInRegion(*taker); auto everyPath = true; @@ -2011,7 +2149,9 @@ class OwnershipInferencePass } }); - if (everyPath && mlir::isa(receiver.retain)) + // rc's retain goes with the move - a `ts.RetainSlot` too, for an owning local the parameter + // moves into (`let r = init`, spec 33); a call that keeps it is the move itself + if (everyPath && !isCall(receiver.retain)) { toErase.insert(receiver.retain); } diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index a93ffc366..fb355669f 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -394,6 +394,8 @@ class OwnershipSignaturePass : public mlir::PassWrappersetAttr(OWN_FRESH_RESULT_ATTR_NAME, mlir::UnitAttr::get(&getContext())); } - // a generator whose state object borrows from its arguments - if (call.callees.funcs.size() == 1 && !call.callees.imported) + // a generator whose state object borrows from its arguments (not through a callback + // family, whose member's parameters may be shifted) + if (call.callees.funcs.size() == 1 && !call.callees.imported && !call.callees.viaCallback) { if (auto bounded = call.callees.funcs.front()->getAttrOfType(OWN_RESULT_BOUNDED_ATTR_NAME)) { @@ -608,6 +611,11 @@ class OwnershipSignaturePass : public mlir::PassWrapper shifts; // the name the imported function is exported under llvm::StringRef importedName; llvm::SmallVector funcs; @@ -2031,6 +2039,10 @@ class OwnershipSignaturePass : public mlir::PassWrapper factsLocal; llvm::DenseMap factsEffective; + // The parameters each body takes into something - a field, an element, a global, a keeping call, + // an owning local it assigns (localOwnedParams). A kept parameter that is not one of them is + // released on the paths that do not move it (OWN_PARAMS_RELEASED_ATTR_NAME, spec 33). + llvm::DenseMap> takenParams; // ---- Facts a signature says (`@keeps`, `@borrows`, `@lends`) ---- // @@ -2537,7 +2549,7 @@ class OwnershipSignaturePass : public mlir::PassWrapper bounded = declaredByCallees(*known->second, OWN_DECLARED_BORROWS_ATTR_NAME); - if (bounded.empty() && known->second->funcs.size() == 1) + if (bounded.empty() && known->second->funcs.size() == 1 && !known->second->viaCallback) { auto made = declared(known->second->funcs.front(), OWN_RESULT_BOUNDED_ATTR_NAME); bounded.assign(made.begin(), made.end()); @@ -3030,6 +3042,11 @@ class OwnershipSignaturePass : public mlir::PassWrapper(def)) + { + resolveCallback(loadOp, callees); + return; + } else if (auto boundOp = mlir::dyn_cast_or_null(def)) { // a function-typed field of an interface, called: `ts.CreateBoundFunction( @@ -3592,6 +3609,12 @@ class OwnershipSignaturePass : public mlir::PassWrapper(user)) { @@ -3640,6 +3663,471 @@ class OwnershipSignaturePass : public mlir::PassWrapper(arr, f, init)` calls its parameter `f`: an indirect call, which reaches + // whatever function its callers pass. When the function is closed - private, and every use of its + // symbol a direct call - the parameter is only ever called, and every caller passes there a + // function this module defines, by its symbol or as a closure over a capture box made right + // there, a call through the parameter reaches exactly those functions: the parameter's family. + // Its members are closed too, when their symbols are used only to be called directly or passed + // so; then the call gets the facts the members agree on (callFacts), and `__own_no_drops` when + // none of them destroys anything. A closure's body takes its capture box first: its parameter k + // is the call's argument k - 1 (CallbackMember::shift). + // + // A kept parameter is agreed on the other way round from a class family's (meetCallbacks): a + // member that only borrows a parameter another member keeps is given it to keep too, and releases + // it on every path (OWN_PARAMS_RELEASED_ATTR_NAME) - `reduce` with a callback that returns its + // accumulator on some paths and one that never does, in one `___array_reduce`. Anything + // else the members disagree on demotes them all, as a class family's members are. + + struct CallbackMember + { + mlir::StringAttr symbol; + int32_t shift = 0; + }; + + struct CallbackFamily + { + llvm::SmallVector members; + // the ops that name a member's symbol to pass it here + llvm::SmallVector passing; + bool closed = true; + }; + + // (function, parameter index) -> the functions its callers pass there + std::map, CallbackFamily> callbackFamilies; + // the members whose families disagreed (meetFacts), for the error in their bodies + llvm::DenseSet demotedCallbacks; + // uses of a member's symbol that only pass it to a closed family: no escape (findOpen) + llvm::DenseSet callbackPassing; + + static bool isFunctionValueType(mlir::Type type) + { + return mlir::isa(type); + } + + // Is every use of this parameter - through its slot, never assigned or captured - the function + // a call goes through? + static bool onlyCalledParam(mlir::BlockArgument argument) + { + for (auto *user : argument.getUsers()) + { + auto varOp = mlir::dyn_cast(user); + auto index = -1; + if (!varOp || !isParameterSlot(varOp, index)) + { + return false; + } + + for (auto *slotUser : varOp->getUsers()) + { + if (mlir::isa(slotUser)) + { + continue; + } + + auto loadOp = mlir::dyn_cast(slotUser); + if (!loadOp) + { + return false; + } + + for (auto &use : loadOp->getUses()) + { + auto *call = use.getOwner(); + if (!isCall(call) || calleeValue(call) != use.get() || + llvm::is_contained(callArgs(call), use.get())) + { + return false; + } + } + } + } + + return true; + } + + // The function a caller passes as `value`: its symbol, seen as a function value through casts + // between function types, or a closure over a capture box built of it. Null for anything else. + static mlir_ts::SymbolRefOp passedFunction(mlir::Value value, int32_t &shift) + { + while (auto castOp = value.getDefiningOp()) + { + if (!isFunctionValueType(castOp.getIn().getType()) || !isFunctionValueType(castOp.getType())) + { + return {}; + } + + value = castOp.getIn(); + } + + if (auto refOp = value.getDefiningOp()) + { + shift = 0; + return refOp; + } + + auto boundOp = value.getDefiningOp(); + if (!boundOp || closureOfBox(boundOp.getThisVal()) != boundOp) + { + return {}; + } + + shift = 1; + return boundOp.getFunc().getDefiningOp(); + } + + // Does the function value `value` go only where `passes` allows - as an argument of a call - and + // through casts between function types, a closure built of it, and that closure's own retains + // and releases? + static bool passedOnly(mlir::Value value, llvm::function_ref passes, int depth = 0) + { + if (depth > 8) + { + return false; + } + + for (auto &use : value.getUses()) + { + auto *user = use.getOwner(); + if (mlir::isa(user) || passes(use)) + { + continue; + } + + auto castOp = mlir::dyn_cast(user); + auto boundOp = mlir::dyn_cast(user); + if (castOp && isFunctionValueType(castOp.getType()) && passedOnly(castOp.getResult(), passes, depth + 1)) + { + continue; + } + + if (boundOp && boundOp.getFunc() == use.get() && closureOfBox(boundOp.getThisVal()) == boundOp && + passedOnly(boundOp.getResult(), passes, depth + 1)) + { + continue; + } + + return false; + } + + return true; + } + + void collectCallbackFamilies(mlir::ModuleOp module) + { + auto uses = mlir::SymbolTable::getSymbolUses(&module.getBodyRegion()); + if (!uses) + { + return; + } + + llvm::DenseMap> usersOf; + for (auto &use : *uses) + { + if (auto funcOp = functions.lookup(use.getSymbolRef().getRootReference().getValue())) + { + usersOf[funcOp].push_back(use.getUser()); + } + } + + auto callsOnly = [&](mlir_ts::FuncOp funcOp) { + auto &users = usersOf[funcOp]; + return !users.empty() && llvm::all_of(users, [&](mlir::Operation *user) { + auto callee = directCallee(user); + return callee && callee.getValue() == funcOp.getSymName(); + }); + }; + + for (auto &entry : functions) + { + auto funcOp = entry.second; + if (funcOp.isDeclaration() || callableFromOutside(funcOp) || declaresFacts(funcOp) || !callsOnly(funcOp)) + { + continue; + } + + for (auto argument : funcOp.getBody().front().getArguments()) + { + if (!isFunctionValueType(argument.getType()) || !onlyCalledParam(argument)) + { + continue; + } + + auto index = static_cast(argument.getArgNumber()); + CallbackFamily family; + for (auto *user : usersOf[funcOp]) + { + auto args = callArgs(user); + auto shift = 0; + auto refOp = static_cast(index) < args.size() ? passedFunction(args[index], shift) + : mlir_ts::SymbolRefOp(); + auto member = refOp ? functions.lookup(refOp.getIdentifier()) : mlir_ts::FuncOp(); + if (!member || member.isDeclaration() || callableFromOutside(member) || declaresFacts(member)) + { + family.closed = false; + break; + } + + auto symbol = member.getSymNameAttr(); + auto same = llvm::find_if(family.members, [&](const CallbackMember &known) { return known.symbol == symbol; }); + if (same != family.members.end() && same->shift != shift) + { + family.closed = false; + break; + } + + if (same == family.members.end()) + { + family.members.push_back({symbol, shift}); + } + + family.passing.push_back(refOp); + } + + callbackFamilies[{funcOp, index}] = std::move(family); + } + } + + // A member's symbol used anywhere else - a call through another value, a field, a vtable, a + // global - reaches callers this cannot see; so does one passed to a family that is not closed. + // Each family that has such a member is open, until nothing changes. + auto passesToClosed = [&](mlir::OpOperand &use) { + auto *call = use.getOwner(); + auto callee = directCallee(call); + auto calleeOp = callee ? functions.lookup(callee.getValue()) : mlir_ts::FuncOp(); + if (!calleeOp) + { + return false; + } + + // the argument this use is, by its operand's position + auto args = callArgs(call); + if (args.empty() || use.getOperandNumber() < args.getBeginOperandIndex() || + use.getOperandNumber() >= args.getBeginOperandIndex() + args.size()) + { + return false; + } + + auto index = static_cast(use.getOperandNumber() - args.getBeginOperandIndex()); + auto found = callbackFamilies.find({calleeOp, index}); + return found != callbackFamilies.end() && found->second.closed; + }; + + for (auto changed = true; changed;) + { + changed = false; + for (auto &[key, family] : callbackFamilies) + { + if (!family.closed) + { + continue; + } + + for (auto &member : family.members) + { + auto memberOp = functions.lookup(member.symbol.getValue()); + auto escapes = llvm::any_of(usersOf[memberOp], [&](mlir::Operation *user) { + if (auto callee = directCallee(user); callee && callee.getValue() == member.symbol.getValue()) + { + return false; + } + + auto refOp = mlir::dyn_cast(user); + return !refOp || !user->getParentOfType() || + !passedOnly(refOp.getResult(), passesToClosed); + }); + + if (escapes) + { + family.closed = false; + changed = true; + break; + } + } + } + } + + for (auto &[key, family] : callbackFamilies) + { + if (family.closed) + { + callbackPassing.insert(family.passing.begin(), family.passing.end()); + } + } + } + + // A call through a parameter whose family is closed: its members. + void resolveCallback(mlir_ts::LoadOp loadOp, Callees &callees) + { + auto varOp = loadOp.getReference().getDefiningOp(); + auto funcOp = loadOp->getParentOfType(); + auto index = -1; + if (!varOp || !funcOp || !isParameterSlot(varOp, index)) + { + return; + } + + auto found = callbackFamilies.find({funcOp.getOperation(), index}); + if (found == callbackFamilies.end() || !found->second.closed || found->second.members.empty()) + { + return; + } + + callees.viaCallback = true; + callees.known = true; + for (auto &member : found->second.members) + { + callees.named.push_back(member.symbol); + callees.known = addDefined(member.symbol, callees) && callees.known; + callees.shifts.push_back(member.shift); + } + + callees.known = callees.known && callees.shifts.size() == callees.funcs.size(); + } + + // A member's facts as a call through its family sees them: each parameter k as argument + // k - shift. False when a fact is about the capture box itself, which the call does not pass. + static bool shiftFacts(const Facts &facts, int32_t shift, Facts &shifted) + { + shifted = Facts(); + shifted.record = facts.record; + for (auto [from, to] : {std::make_pair(&facts.owned, &shifted.owned), std::make_pair(&facts.borrows, &shifted.borrows)}) + { + for (auto index : *from) + { + if (index < shift) + { + return false; + } + + to->push_back(index - shift); + } + } + + return true; + } + + // Can the body of `funcOp` be given parameter `index` to keep and release it where it does not + // move it: a plain slot (isParameterSlot), a block, not a handle, not a string? + bool canReleaseParam(mlir_ts::FuncOp funcOp, int32_t index) + { + if (funcOp.getBody().empty() || static_cast(index) >= funcOp.getNumArguments()) + { + return false; + } + + auto argument = funcOp.getBody().front().getArgument(index); + if (isHandle(argument) || isCopyableString(argument.getType()) || holdsNoBlock(argument)) + { + return false; + } + + return llvm::any_of(argument.getUsers(), [&](mlir::Operation *user) { + auto varOp = mlir::dyn_cast(user); + auto position = -1; + return varOp && isParameterSlot(varOp, position) && position == index; + }); + } + + // The members of each closed callback family agree (the section above), by promotion of a kept + // parameter or by demotion. True when any member's facts changed. + bool meetCallbacks(llvm::DenseSet &demoted) + { + auto changed = false; + for (auto &[key, family] : callbackFamilies) + { + if (!family.closed) + { + continue; + } + + llvm::SmallVector members; + llvm::SmallVector seen; + auto shiftable = true; + for (auto &member : family.members) + { + auto funcOp = functions.lookup(member.symbol.getValue()); + Facts shifted; + shiftable = shiftable && funcOp && shiftFacts(factsEffective.lookup(funcOp), member.shift, shifted); + members.push_back(funcOp); + seen.push_back(shifted); + } + + if (shiftable && llvm::all_equal(seen)) + { + continue; + } + + // a parameter some member keeps, and each other one only borrows: kept by all + llvm::SmallVector kept; + for (auto &facts : seen) + { + kept.append(facts.owned.begin(), facts.owned.end()); + } + + llvm::sort(kept); + kept.erase(std::unique(kept.begin(), kept.end()), kept.end()); + auto promotable = shiftable && llvm::all_of(seen, [&](const Facts &facts) { + return facts.borrows == seen.front().borrows && facts.record == seen.front().record && + llvm::none_of(kept, [&](int32_t index) { return llvm::is_contained(facts.borrows, index); }); + }); + + for (auto [position, member] : llvm::enumerate(family.members)) + { + auto funcOp = members[position]; + // a member another caller reaches without the facts is given none (findOpen) + for (auto index : kept) + { + promotable = promotable && !demoted.contains(funcOp) && !open.contains(funcOp) && + !interfaceReached.contains(funcOp) && + (llvm::is_contained(seen[position].owned, index) || + canReleaseParam(funcOp, index + member.shift)); + } + } + + if (promotable) + { + for (auto [position, member] : llvm::enumerate(family.members)) + { + auto funcOp = members[position]; + auto facts = factsEffective.lookup(funcOp); + for (auto index : kept) + { + auto param = index + member.shift; + if (!llvm::is_contained(facts.owned, param)) + { + facts.owned.insert(llvm::upper_bound(facts.owned, param), param); + } + } + + if (facts != factsEffective.lookup(funcOp)) + { + factsEffective[funcOp] = facts; + changed = true; + } + } + + continue; + } + + for (auto funcOp : members) + { + if (funcOp && !declaresCallerFacts(funcOp)) + { + demoted.insert(funcOp); + if (factsEffective.erase(funcOp)) + { + changed = true; + } + } + } + } + + return changed; + } + // ---- Results that borrow an argument ---- // The values a function returns: what is stored into the local its returns read (MLIRGen's @@ -3740,6 +4228,69 @@ class OwnershipSignaturePass : public mlir::PassWrapper 2 ? acc : V.plus(acc, v)`. Such a + // parameter is kept: the callers move the argument in, the path that returns it moves it out, + // and every other path releases it. -1 for none - a parameter returned on every path is a + // borrowed result; two parameters returned, or a part of one (`acc.next`), or a borrowing + // record, have no such answer; a string is copied instead (spec 22). + int32_t mixedReturnParam(mlir_ts::FuncOp funcOp) + { + auto param = -1; + auto other = false; + auto returned = returnedValues(funcOp); + llvm::SmallVector work(returned.begin(), returned.end()); + llvm::DenseSet seen; + while (!work.empty()) + { + auto value = work.pop_back_val(); + if (!seen.insert(value).second || holdsNoBlock(value)) + { + continue; + } + + if (isHandle(value) || isCopyableString(value.getType())) + { + return -1; + } + + auto root = rootOf(value); + if (auto argument = mlir::dyn_cast(root); argument && !argument.getOwner()->isEntryBlock()) + { + llvm::SmallVector merged; + if (!mergedInto(argument, merged)) + { + return -1; + } + + work.append(merged.begin(), merged.end()); + continue; + } + + int32_t index = -1; + if (readsParameterSlot(root, funcOp, index)) + { + if ((param >= 0 && param != index) || isHandle(root) || isCopyableString(root.getType())) + { + return -1; + } + + param = index; + continue; + } + + llvm::SmallVector recordParams; + if (borrowedParam(root) >= 0 || recordParamsOf(root, recordParams) != RecordFlow::None) + { + return -1; + } + + other = true; + } + + return other ? param : -1; + } + // ---- Borrowing records (spec 32) ---- // // `{ thing: this, ray: ray, dist: d }` given as an interface: an object literal's box holding values @@ -4114,6 +4665,16 @@ class OwnershipSignaturePass : public mlir::PassWrapper(user)) + { + return varOp.getInitializer() == used && isOwningVariable(varOp) && !varOp.getCaptured().value_or(false) && + isAssigned(varOp) && !isCopyableString(used.getType()); + } + return false; } @@ -4191,6 +4752,14 @@ class OwnershipSignaturePass : public mlir::PassWrapper= 0 && !llvm::is_contained(facts.owned, mixed)) + { + facts.owned.insert(llvm::upper_bound(facts.owned, mixed), mixed); + } + factsLocal[entry.second] = std::move(facts); } } @@ -4299,6 +4868,9 @@ class OwnershipSignaturePass : public mlir::PassWrapper agreed; + for (auto [funcOp, shift] : llvm::zip(callees.funcs, callees.shifts)) + { + Facts shifted; + if (!shiftFacts(factsEffective.lookup(funcOp), shift, shifted) || (agreed && *agreed != shifted)) + { + return {}; + } + + agreed = shifted; + } + + return agreed.value_or(Facts()); + } + auto facts = factsEffective.lookup(callees.funcs.front()); auto agree = llvm::all_of(callees.funcs, [&](mlir_ts::FuncOp callee) { return factsEffective.lookup(callee) == facts; @@ -4374,10 +4966,30 @@ class OwnershipSignaturePass : public mlir::PassWrapperremoveAttr(OWN_FACTS_LOST_ATTR_NAME); + funcOp->removeAttr(OWN_PARAMS_RELEASED_ATTR_NAME); + // a signature's `@keeps` is checked against the body as it is (checkDeclared) + llvm::SmallVector released; + for (auto index : effective.owned) + { + if (!declaresCallerFacts(funcOp) && !llvm::is_contained(takenParams.lookup(funcOp), index)) + { + released.push_back(index); + } + } + + if (!released.empty()) + { + funcOp->setAttr(OWN_PARAMS_RELEASED_ATTR_NAME, mlir::DenseI32ArrayAttr::get(&getContext(), released)); + } + if (effective.empty() && !local.empty()) { // through an interface first: a marker would not help there (interfaceReached) auto *why = factsThroughInterface(funcOp, local) ? INTERFACE_REACHED_WHY : open.lookup(funcOp); + if (!why && demotedCallbacks.contains(funcOp)) + { + why = "a function passed in its place disagrees"; + } funcOp->setAttr(OWN_FACTS_LOST_ATTR_NAME, mlir::StringAttr::get(&getContext(), why ? why : "an override in its class family disagrees")); diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 60c9b8987..fcc67e24c 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3483,7 +3483,8 @@ foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local o own_literal_moves own_record_literal_given own_field_chain_let own_read_after_move own_conditional_move own_interface_facts own_declared_scalar_no_drops - own_borrowing_record own_borrowing_record_arg) + own_borrowing_record own_borrowing_record_arg + own_reduce) 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. @@ -3558,6 +3559,13 @@ foreach(borrowing_record_mm rc gc none) endforeach() endforeach() +# `reduce` takes its accumulator, and a parameter returned on some paths only is kept (spec 33); +# the same program under every other model. +foreach(reduce_mm rc gc none) + tslang_add_test(NAME test-jit-${reduce_mm}-own_reduce COMMAND test-runner -jit -mm=${reduce_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_reduce.ts") + tslang_add_test(NAME test-compile-${reduce_mm}-own_reduce COMMAND test-runner -mm=${reduce_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_reduce.ts") +endforeach() + # An executable's exports leave its interfaces closed (spec 31, USER decision): under own the JIT's # entry module and `tslang --emit=exe` compile own_interface_exe_exports; an object file and a DLL # do not (own-exe-exports.cmake). test-runner's own AOT build is an object file, so it is not run. @@ -3806,7 +3814,11 @@ set(own_error_cases "own_err_param_kept_loop|is moved inside a loop" "own_err_param_kept_not_owned|is given to 'keep', which keeps it, but -mm=own cannot move it here" "own_err_delete|'delete' is not supported by -mm=own yet" - "own_err_mixed_return|'pick' returns a borrow of its argument on some paths and another value on others" + "own_err_mixed_return|is used here after its value was moved" + "own_err_mixed_return_join_use|is moved here on some paths only" + "own_err_callback_escapes|cannot make the callers of 'pick' give it up" + "own_err_callback_disagrees|a function passed in its place disagrees" + "own_err_callback_promoted_used|is used here after its value was moved" "own_err_param_returned_exported|is a parameter, returned here, but -mm=own cannot tell the callers of 'identity' that the result borrows it" "own_err_param_kept_exported|is a parameter, kept here, but -mm=own cannot make the callers of 'Animal.constructor' give it up" "own_err_param_assigned|is a parameter and cannot be assigned under -mm=own yet" diff --git a/tslang/test/tester/own/own_err_borrowed_result_escapes.ts b/tslang/test/tester/own/own_err_borrowed_result_escapes.ts index 00c0a6fe2..083ee4d9f 100644 --- a/tslang/test/tester/own/own_err_borrowed_result_escapes.ts +++ b/tslang/test/tester/own/own_err_borrowed_result_escapes.ts @@ -1,6 +1,8 @@ // -mm=own, phase 4 rejects: `firstOf` is passed as a value, so `apply` calls it where -mm=own // cannot see which function it is, and would take the result as its own and release it. -// `firstOf`'s body keeps rc's convention and fails, saying why. +// `firstOf`'s body keeps rc's convention and fails, saying why. (Passed straight to `apply`, the +// call through `g` reaches only `firstOf` and gets its facts, spec 33; a `let` holding it is a value +// -mm=own does not follow.) class C { constructor(public x: number) {} } @@ -19,5 +21,6 @@ function apply(g: (h: H) => C, h: H) { function main() { const h = new H(); - print(apply(firstOf, h)); + let g = firstOf; + print(apply(g, h)); } diff --git a/tslang/test/tester/own/own_err_callback_disagrees.ts b/tslang/test/tester/own/own_err_callback_disagrees.ts new file mode 100644 index 000000000..7f34f11f5 --- /dev/null +++ b/tslang/test/tester/own/own_err_callback_disagrees.ts @@ -0,0 +1,25 @@ +// -mm=own rejects: both callbacks reach the one `___array_reduce`, and its call through the +// callback gets what they agree on (spec 33). `pick` keeps its accumulator (it returns it on some +// paths only), `first` returns a borrow of it on every path: no caller can be told both, so both +// lose their facts, and their bodies fail, saying why. +class V { + constructor(public x: number) {} + static plus(a: V, b: V) { + return new V(a.x + b.x); + } +} + +function pick(acc: V, v: V) { + return v.x > 1 ? acc : V.plus(acc, v); +} + +function first(acc: V, v: V) { + return acc; +} + +function main() { + const a = [new V(1), new V(2)]; + const q = a.reduce(pick, new V(0)); + const r = a.reduce(first, new V(4)); + print(q.x, r.x); +} diff --git a/tslang/test/tester/own/own_err_callback_escapes.ts b/tslang/test/tester/own/own_err_callback_escapes.ts new file mode 100644 index 000000000..0cfca2c8f --- /dev/null +++ b/tslang/test/tester/own/own_err_callback_escapes.ts @@ -0,0 +1,22 @@ +// -mm=own rejects: `pick` is passed to `reduce`, whose calls through its callback get the facts +// of the functions its callers pass there (spec 33), but `pick` is also held in a `let` and called +// through it, where -mm=own cannot see which function runs: those callers cannot be told that it +// keeps `acc`, and `pick`'s body fails, saying why. +class V { + constructor(public x: number) {} + static plus(a: V, b: V) { + return new V(a.x + b.x); + } +} + +function pick(acc: V, v: V) { + return v.x > 2 ? acc : V.plus(acc, v); +} + +function main() { + const a = [new V(1), new V(3)]; + const q = a.reduce(pick, new V(0)); + let g = pick; + const r = g(new V(1), new V(5)); + print(q.x, r.x); +} diff --git a/tslang/test/tester/own/own_err_callback_promoted_used.ts b/tslang/test/tester/own/own_err_callback_promoted_used.ts new file mode 100644 index 000000000..dc3d3f40d --- /dev/null +++ b/tslang/test/tester/own/own_err_callback_promoted_used.ts @@ -0,0 +1,26 @@ +// -mm=own rejects: `add` only borrows its accumulator, but `pick`, passed to the same +// `___array_reduce`, keeps it, so `add` is given it to keep too and releases it (spec 33). +// A direct call of `add` then moves `x` in, and `x` is read after it. +class V { + constructor(public x: number) {} + static plus(a: V, b: V) { + return new V(a.x + b.x); + } +} + +function add(acc: V, v: V) { + return V.plus(acc, v); +} + +function pick(acc: V, v: V) { + return v.x > 1 ? acc : V.plus(acc, v); +} + +function main() { + const a = [new V(1), new V(2)]; + const r = a.reduce(add, new V(0)); + const q = a.reduce(pick, new V(0)); + const x = new V(5); + const y = add(x, new V(1)); + print(r.x, q.x, y.x, x.x); +} diff --git a/tslang/test/tester/own/own_err_mixed_return.ts b/tslang/test/tester/own/own_err_mixed_return.ts index 3f67733f4..6e0d87134 100644 --- a/tslang/test/tester/own/own_err_mixed_return.ts +++ b/tslang/test/tester/own/own_err_mixed_return.ts @@ -1,6 +1,6 @@ -// -mm=own rejects: `pick` returns its argument on one path and a new object on the other, so -// its callers cannot be told whether they own the result. (A string there is copied instead, -// spec 22.) +// -mm=own rejects: `pick` returns its argument on one path and a new object on the other, so it +// keeps that parameter (spec 33) - the caller moves `c` in - and the caller still reads `c` after +// the call, which gave it up. (A string there is copied instead, spec 22.) class C { constructor(public n: string) {} } @@ -10,5 +10,5 @@ function pick(c: C, other: boolean) { } function main() { const c = new C("a"); - print(pick(c, true).n); + print(pick(c, true).n, c.n); } diff --git a/tslang/test/tester/own/own_err_mixed_return_join_use.ts b/tslang/test/tester/own/own_err_mixed_return_join_use.ts new file mode 100644 index 000000000..47e8dd0ba --- /dev/null +++ b/tslang/test/tester/own/own_err_mixed_return_join_use.ts @@ -0,0 +1,21 @@ +// -mm=own rejects: `g` returns `n` on one path and a new object on the other, so it keeps `n` and +// releases it where the paths join on the path that did not return it (spec 33). `v`, read out of +// `n` before the join, is used after it: on that path its block was released. +class V { + constructor(public x: number) {} +} + +class N { + constructor(public v: V) {} +} + +function g(n: N, k: number) { + const v = n.v; + const r = k > 1 ? n : new N(new V(1)); + print(v.x); + return r; +} + +function main() { + print(g(new N(new V(3)), 0).v.x); +} diff --git a/tslang/test/tester/own/own_reduce.ts b/tslang/test/tester/own/own_reduce.ts new file mode 100644 index 000000000..7875f8be0 --- /dev/null +++ b/tslang/test/tester/own/own_reduce.ts @@ -0,0 +1,114 @@ +// `reduce` takes its accumulator (spec 33): the initial value moves into the generated +// `___array_reduce`, each callback is given the accumulator to keep, and its result is the next +// one. A function that returns a parameter on some paths and a fresh value on others keeps that +// parameter: the path that returns it moves it out, every other path releases it exactly once - +// an early return, a `break` out of a loop, a return from inside a loop or a `try` with a +// `finally`, a `throw`. A callback that only borrows the accumulator releases it. +class V { + constructor(public x: number) {} + static plus(a: V, b: V) { + return new V(a.x + b.x); + } +} + +class K { + static zero = new V(0); +} + +function churn() { + const junk: V[] = []; + for (let i = 0; i < 64; i++) { + junk.push(new V(999)); + } +} + +// returns acc on one path +function pick(acc: V, v: V) { + return v.x > 2 ? acc : V.plus(acc, v); +} + +function early(a: V, n: number) { + if (n > 5) { + return a; + } + + for (let i = 0; i < n; i++) { + if (i == 3) { + break; + } + } + + return new V(a.x + n); +} + +function fromLoop(a: V, n: number) { + for (let i = 0; i < n; i++) { + if (i == 2) { + return a; + } + } + + return new V(a.x * 10); +} + +let finallies: number = 0; + +function guarded(a: V, n: number) { + try { + if (n > 1) { + return a; + } + } finally { + finallies++; + } + + return new V(a.x + 100); +} + +function thrower(a: V, n: number) { + if (n < 0) { + throw "negative"; + } + + if (n == 0) { + return a; + } + + return new V(a.x + 1); +} + +function main() { + const a = [new V(1), new V(2), new V(3)]; + const r = a.reduce((acc: V, v: V) => V.plus(acc, v), new V(0)); + const q = a.reduce(pick, K.zero); + churn(); + print(r.x, q.x); // 6 3 + + // every element above 2: pick returns the immortal start value itself, which no release frees + const z = [new V(5), new V(7)].reduce(pick, K.zero); + churn(); + print(z.x, K.zero.x); // 0 0 + + let total: number = 0; + let caught = 0; + for (let i = 0; i < 20000; i++) { + const n = i % 9; + const e = early(new V(i % 10), n); + const f = fromLoop(new V(1), n); + const g = guarded(new V(2), n); + let t: number = 0; + try { + const h = thrower(new V(3), n - 1); + t = h.x; + } catch (err) { + caught++; + } + + churn(); + total += e.x + f.x + g.x + t; + } + + print(total, caught, finallies); + assert(r.x == 6 && q.x == 3 && z.x == 0 && K.zero.x == 0); + print("done."); +} From 26fe86ce8189909ec987ffe5589f7458e672c4af Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 21:05:55 +0100 Subject: [PATCH 38/57] own-raytrace T8: spec 33, reduce takes its accumulator What is accepted (a parameter returned as it is on some paths and an owned value on the others is kept, its releases placed statically on the paths that do not return it; a parameter read into an owning `let` that is assigned is kept; a call through a callback parameter of a closed function gets the facts of the functions passed there, with a closure's box shifted out, and a member that only borrows a parameter another keeps is given it to keep), tests, teeth, measured (own 4.9 MB as rc over 2 million iterations), corpus (528 before and after, the same files), the ray tracer as a program (10 errors to 1: the G8 `getPoint` read), and what is not here. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) 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 1d9a1def1..9f7052fdf 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 @@ -3779,3 +3779,114 @@ Not here: - A record's borrowed field may not hold a string. - Plain borrows are still of one argument (`c ? a : b` is no borrow), and do not flow through an owning local assigned in a loop; only records do. + +## 33. `reduce` takes its accumulator (G5 of the ray tracer plan), 2026-10-22 + +`a.reduce((acc: V, v: V) => V.plus(acc, v), new V(0))` was `'init' is a parameter, kept here, but +-mm=own cannot make the callers of '___array_reduce<...>' give it up` (the generated helper's `let r = +init`, then `r = f(r, v)`), and `borrows an element but is used here after it may be released`: the +call through `f` went to an unknown function, which may drop anything. A callback that returns its +accumulator on some paths (the ray tracer's `addLight`, `return col` when in shadow) was `returns a +borrow of its argument on some paths and another value on others`. Now the initial value moves in, +each callback is given the accumulator to keep, and its result is the next one, like Rust's +`fold(init, |acc, x| ...)`, whose closure takes `acc` by value. + +- **A parameter returned on some paths only is kept** (`mixedReturnParam`, OwnershipSignaturePass). + When every value a function returns that holds a block is either one parameter itself (its slot's + read, through views and merges) or one the function owns, and both occur, that parameter is kept + (`__own_params`): the callers move the argument in, the path that returns it moves it out, and + every other path releases it. Not when the parameter is returned on every path (a borrowed result, + as before), two parameters are returned, a part of one is (`acc.next`), a borrowing record is, or + the value is a string (copied, spec 22) or a handle. A caller that still uses the argument after + the call is `is used here after its value was moved` (`own_err_mixed_return`, rewritten: + `print(pick(c, true).n, c.n)`). A function whose facts are lost (an exported one, one used as a + value) keeps today's error in its body. +- **Its releases are placed statically** (OwnershipInferencePass, `releaseKeptParamsAtExits`). Such a + parameter is named in `__own_params_released`: a kept parameter its body does not take into a + field, an element, a global, a keeping call or an owning local on every path. MLIRGen makes no + owner of a parameter, so the inference puts a release of its slot before each `ts.ReturnInternal` + and each `ts.ThrowCall` that leaves the function (not one whose thrown value is read out of the + parameter), as MLIRGen puts an owning local's. The move's own check (`checkSlotMove` with §30's + placement) then erases every release the moved value reaches and places one on each edge into the + join from a path that did not move it; nothing may use the value where the paths have joined (§30's + safety condition, followed through what was read out of it). `pick`'s `V.plus(acc, v)` arm releases + `acc` before the arms join; an early return, a `break` out of a loop, a return from inside a loop or + from a `try` with a `finally`, a `throw`, each release it once. No run-time flag, no pointer + comparison. A parameter kept this way and moved by two receivers (`if (n > 5) return a; if (n < 0) + return a;`) is still `used here after its value was moved`. +- **A parameter read into an owning `let` that is given another value is kept** (`keeps`): `let r = + init; ... r = f(r, v)` cannot borrow `init`, since the assignment releases what `r` holds, so + `init` moves into `r` (the move dominates every return, as for any kept parameter, and rc's + `ts.RetainSlot` goes with it). Not a string: the local copies it. `___array_reduce`'s source is + unchanged; this rule makes its accumulator a moved local. +- **Callbacks** (`collectCallbackFamilies`). A function-typed parameter of a closed function (private, + every use of its symbol a direct call) that is only ever called, and to which every caller passes a + function this module defines - its symbol, through casts between function types, or a closure over + a capture box built of it - reaches exactly those functions: the parameter's family. Its members + are closed when every other use of their symbols is a direct call (`callbackPassing` in `findOpen`), + and a call through the parameter is resolved to them (`resolveCallback`): it gets the facts they + agree on, and `__own_no_drops` when none of them drops. A closure's body takes its capture box + first, so its parameter k is the call's argument k - 1 (`shiftFacts`); a fact about the box itself + agrees with nothing. `__own_params = [0]` on `___array_reduce`'s `f(r, v)` moves `r` in; the + assignment's release of the old `r` goes with the move (#528), and `r` owns the result. +- **Agreement by promotion** (`meetCallbacks`). `own_reduce` passes `(acc, v) => V.plus(acc, v)`, + which only borrows `acc`, and `pick`, which keeps it, to the one `___array_reduce`. A kept + parameter of some member that every other member only borrows - a plain slot, not a string or a + handle, not lent by the result - is given to all of them to keep; the others release it on every + path (`__own_params_released`). Any other disagreement demotes every member, as a class family's + are, and their bodies fail with the note `a function passed in its place disagrees` + (`own_err_callback_disagrees`: `pick` beside `(acc, v) => acc`). A member a callback family demoted + is never promoted again, so the meet settles. Class and interface families still agree or demote + (addendum): promotion is only for callbacks. A promoted member called directly moves its argument + in too (`own_err_callback_promoted_used`). +- **The immortal start value.** `a.reduce(pick, K.zero)` moves the immortal `K.zero` (§25) in: its + retains are erased, and the releases `pick` and `main` make of it are skipped by its header. + `own_reduce` reduces `[new V(5), new V(7)]` with `pick` from `K.zero`, so `pick` returns `K.zero` + itself and `main` releases it at its end; after churn `K.zero.x` reads 0, and is asserted. No path + writes it: `V.plus` makes a new object. +- `___array_reduce` has no foreign surface, and the C-scalar rule (§31) does not read it: nothing + changed there. + +`own_reduce` runs the brief's program (`r.x, q.x` is `6 3`), a reduce that returns the immortal +start value, and 20,000 iterations of an early return, a return from inside a loop, a return from a +`try` with a `finally` and a `throw` that the caller catches, each read after churn, under own, rc, +gc and none, AOT and JIT, and on Linux; own emits no counting. Negatives: `own_err_mixed_return` +(rewritten), `own_err_mixed_return_join_use` (a field read out of the kept parameter before the join +and used after it: `is moved here on some paths only`), `own_err_callback_escapes` (`pick` also +called through a `let`: `cannot make the callers of 'pick' give it up`), `own_err_callback_disagrees`, +`own_err_callback_promoted_used`. `own_err_borrowed_result_escapes` passed `firstOf` straight to +`apply`; the call through `g` now reaches only `firstOf` and gets its borrowed result, so the program +is correct (it prints what gc prints). It now passes `firstOf` through a `let`, and fails as before. + +Teeth (temporary `OWN_TEETH` switches, removed): with the exit releases kept on the paths that +return the parameter (the returning path releases it too), the AOT run of `own_reduce` exits +`0xC0000374` (heap corruption), and so do the brief's two reduces alone and the early-return, loop +and `try` returns alone; with the call through the callback given no kept parameter +(`___array_reduce` releases the accumulator it handed to a callback that keeps it), the AOT run of +`own_reduce` and of the brief's reduces alone exit `0xC0000374`, and the direct returns alone pass. + +Measured: 2 million iterations of both reduces and an early return (about 9 blocks each) peak at +4.9 MB under own, as under rc (gc 6.5 MB), AOT. + +Corpus (`test/tester/tests/*.ts` under `-mm=own --no-default-lib`, `--emit=obj`, Windows): 528 of +693 before this section and 528 after, the same files. + +The ray tracer built as a program (`--emit=exe`): 10 errors before this section, 1 after. Cleared: +`addLight`'s mixed return (253) and the two in the generated `___array_reduce` (its kept `init`, and +the element its call through `f` borrows); and, since the call through `f` now has the closure's +facts and destroys nothing, so does `getNaturalColor`, and the reads held across it or across what +calls it: `scene.lights` given to `reduce` (266), `shade`'s three (237-239), `traceRay`'s +`this.shade(isect, ...)` (227) and `scene.camera.pos` in `render`'s literal across `traceRay` +(279:29). Left: the `scene.camera` argument of `getPoint` in `render` (279:75, G8), now reported +against `Color.toDrawingColor(color)` (280:25), which builds and calls its `legalize` closure and so +may drop. It was there before §32. It does not reproduce in three smaller programs (a closure over +`camera` called in a loop beside a call that may drop, inside a record argument, in a method), and +a method in place of the closure fails the same way, so it is not the closure's call. + +Not here: +- Unwinding out of the function - a call that throws outside a `try` - before a kept parameter is + moved leaks it, as it leaks an owning local; so does a `throw` of a value read out of it. +- A callback passed through a `let`, a field or a global, a member whose symbol is used anywhere + else (`a.reduce(V.plus, ...)`: a static method is a vtable entry), a parameter that is passed on + rather than called, an exported or `@keeps`/`@lends` function: no family, as before. +- The default library's `reduce` (design G5: a follow-up). From 767500f8b909325267803ff819d280d6123c1049 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 21:12:43 +0100 Subject: [PATCH 39/57] own-raytrace T8: own_reduce runs a promoted callback, called directly too; spec 33 measured own_reduce passes a named `add`, which only borrows its accumulator, beside `pick` to the one `___array_reduce` (promoted: it releases the accumulator), and calls it directly 20,000 times with a fresh argument (the caller moves it in, `add` frees it once); it prints the same under own, rc, gc and none. Spec 33 records it, the interface-family probe (two mixed implementations agree, one beside a plain borrow demotes both), `--verify-ownership` quiet under own and rc, and the ray tracer run end to end on a scratch copy with `legalize` made a module-level function: checksum 38837369, exit 0, peak 4.9 MB as rc on Windows and 4.3 MB (rc 4.4 MB) on Linux. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 19 ++++++++++++++----- tslang/test/tester/own/own_reduce.ts | 17 ++++++++++++++--- 2 files changed, 28 insertions(+), 8 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 9f7052fdf..e446dedf8 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 @@ -3837,8 +3837,10 @@ each callback is given the accumulator to keep, and its result is the next one, are, and their bodies fail with the note `a function passed in its place disagrees` (`own_err_callback_disagrees`: `pick` beside `(acc, v) => acc`). A member a callback family demoted is never promoted again, so the meet settles. Class and interface families still agree or demote - (addendum): promotion is only for callbacks. A promoted member called directly moves its argument - in too (`own_err_callback_promoted_used`). + (addendum): promotion is only for callbacks; a closed interface whose implementations both return + their accumulator on some paths agrees and keeps it, one beside a plain borrow demotes both. A + promoted member called directly moves its argument in too (`own_reduce`'s `add`; + `own_err_callback_promoted_used` reads the argument after). - **The immortal start value.** `a.reduce(pick, K.zero)` moves the immortal `K.zero` (§25) in: its retains are erased, and the releases `pick` and `main` make of it are skipped by its header. `own_reduce` reduces `[new V(5), new V(7)]` with `pick` from `K.zero`, so `pick` returns `K.zero` @@ -3848,9 +3850,11 @@ each callback is given the accumulator to keep, and its result is the next one, changed there. `own_reduce` runs the brief's program (`r.x, q.x` is `6 3`), a reduce that returns the immortal -start value, and 20,000 iterations of an early return, a return from inside a loop, a return from a -`try` with a `finally` and a `throw` that the caller catches, each read after churn, under own, rc, -gc and none, AOT and JIT, and on Linux; own emits no counting. Negatives: `own_err_mixed_return` +start value, a named callback `add` that only borrows its accumulator beside `pick` (promoted), and +20,000 iterations of an early return, a return from inside a loop, a return from a `try` with a +`finally`, a `throw` that the caller catches, and a direct call of `add`, each read after churn, +under own, rc, gc and none, AOT and JIT, and on Linux; own emits no counting, and +`--verify-ownership` is quiet on it under own and rc. Negatives: `own_err_mixed_return` (rewritten), `own_err_mixed_return_join_use` (a field read out of the kept parameter before the join and used after it: `is moved here on some paths only`), `own_err_callback_escapes` (`pick` also called through a `let`: `cannot make the callers of 'pick' give it up`), `own_err_callback_disagrees`, @@ -3883,6 +3887,11 @@ may drop. It was there before §32. It does not reproduce in three smaller progr `camera` called in a loop beside a call that may drop, inside a record argument, in a method), and a method in place of the closure fails the same way, so it is not the closure's call. +On a scratch copy whose only other change is `legalize` made a module-level function (so +`toDrawingColor` destroys nothing; not one of the plan's source edits), the program compiles under +own as an executable and runs: checksum 38837369 as under rc, exit 0, peak 4.9 MB as rc's 4.9 MB on +Windows, and 4.3 MB (rc 4.4 MB) on Linux, where glibc reports no bad free. + Not here: - Unwinding out of the function - a call that throws outside a `try` - before a kept parameter is moved leaks it, as it leaks an owning local; so does a `throw` of a value read out of it. diff --git a/tslang/test/tester/own/own_reduce.ts b/tslang/test/tester/own/own_reduce.ts index 7875f8be0..3911957c0 100644 --- a/tslang/test/tester/own/own_reduce.ts +++ b/tslang/test/tester/own/own_reduce.ts @@ -3,7 +3,8 @@ // one. A function that returns a parameter on some paths and a fresh value on others keeps that // parameter: the path that returns it moves it out, every other path releases it exactly once - // an early return, a `break` out of a loop, a return from inside a loop or a `try` with a -// `finally`, a `throw`. A callback that only borrows the accumulator releases it. +// `finally`, a `throw`. A callback that only borrows the accumulator releases it, and so does a +// direct call of one (`add`), which moves its argument in. class V { constructor(public x: number) {} static plus(a: V, b: V) { @@ -27,6 +28,11 @@ function pick(acc: V, v: V) { return v.x > 2 ? acc : V.plus(acc, v); } +// only borrows acc, but is passed beside `pick`: given acc to keep, it releases it +function add(acc: V, v: V) { + return V.plus(acc, v); +} + function early(a: V, n: number) { if (n > 5) { return a; @@ -89,6 +95,10 @@ function main() { churn(); print(z.x, K.zero.x); // 0 0 + const s = a.reduce(add, new V(10)); + churn(); + print(s.x); // 16 + let total: number = 0; let caught = 0; for (let i = 0; i < 20000; i++) { @@ -96,6 +106,7 @@ function main() { const e = early(new V(i % 10), n); const f = fromLoop(new V(1), n); const g = guarded(new V(2), n); + const d = add(new V(n), new V(1)); let t: number = 0; try { const h = thrower(new V(3), n - 1); @@ -105,10 +116,10 @@ function main() { } churn(); - total += e.x + f.x + g.x + t; + total += e.x + f.x + g.x + t + d.x; } print(total, caught, finallies); - assert(r.x == 6 && q.x == 3 && z.x == 0 && K.zero.x == 0); + assert(r.x == 6 && q.x == 3 && z.x == 0 && K.zero.x == 0 && s.x == 16); print("done."); } From 6b98411583d03b1127965d431e7f7ec10008d9ad Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 21:16:44 +0100 Subject: [PATCH 40/57] own-raytrace T8: say which kept parameters are released where they are not moved `__own_params_released` names a kept parameter the body never takes into a field, an element, a global, a keeping call or an assigned owning local; one it does take keeps the old rule (the move must reach every return). The comment and spec 33 said "on every path", which was not what the code does. Wording only. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 28 ++++++++++--------- tslang/lib/TypeScript/OwnershipFacts.h | 7 +++-- 2 files changed, 19 insertions(+), 16 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 e446dedf8..abc1ddb0b 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 @@ -3801,19 +3801,21 @@ each callback is given the accumulator to keep, and its result is the next one, the call is `is used here after its value was moved` (`own_err_mixed_return`, rewritten: `print(pick(c, true).n, c.n)`). A function whose facts are lost (an exported one, one used as a value) keeps today's error in its body. -- **Its releases are placed statically** (OwnershipInferencePass, `releaseKeptParamsAtExits`). Such a - parameter is named in `__own_params_released`: a kept parameter its body does not take into a - field, an element, a global, a keeping call or an owning local on every path. MLIRGen makes no - owner of a parameter, so the inference puts a release of its slot before each `ts.ReturnInternal` - and each `ts.ThrowCall` that leaves the function (not one whose thrown value is read out of the - parameter), as MLIRGen puts an owning local's. The move's own check (`checkSlotMove` with §30's - placement) then erases every release the moved value reaches and places one on each edge into the - join from a path that did not move it; nothing may use the value where the paths have joined (§30's - safety condition, followed through what was read out of it). `pick`'s `V.plus(acc, v)` arm releases - `acc` before the arms join; an early return, a `break` out of a loop, a return from inside a loop or - from a `try` with a `finally`, a `throw`, each release it once. No run-time flag, no pointer - comparison. A parameter kept this way and moved by two receivers (`if (n > 5) return a; if (n < 0) - return a;`) is still `used here after its value was moved`. +- **Its releases are placed statically** (OwnershipInferencePass, `releaseKeptParamsAtExits`). Such + a parameter is named in `__own_params_released`: a kept parameter its body never takes into a + field, an element, a global, a keeping call or an assigned owning local (one it does take there + keeps the old rule, the move must reach every return: `own_err_param_kept_some_paths`). MLIRGen + makes no owner of a parameter, so the inference puts a release of its slot before each + `ts.ReturnInternal` and each `ts.ThrowCall` that leaves the function (not one whose thrown value + is read out of the parameter), as MLIRGen puts an owning local's. The move's own check + (`checkSlotMove` with §30's placement) then erases every release the moved value reaches and + places one on each edge into the join from a path that did not move it; nothing may use the value + where the paths have joined (§30's safety condition, followed through what was read out of it). + `pick`'s `V.plus(acc, v)` arm releases `acc` before the arms join; an early return, a `break` out + of a loop, a return from inside a loop or from a `try` with a `finally`, a `throw`, each release + it once. No run-time flag, no pointer comparison. A parameter kept this way and moved by two + receivers (`if (n > 5) return a; if (n < 0) return a;`) is still `used here after its value was + moved`. - **A parameter read into an owning `let` that is given another value is kept** (`keeps`): `let r = init; ... r = f(r, v)` cannot borrow `init`, since the assignment releases what `r` holds, so `init` moves into `r` (the move dominates every return, as for any kept parameter, and rc's diff --git a/tslang/lib/TypeScript/OwnershipFacts.h b/tslang/lib/TypeScript/OwnershipFacts.h index 41de60cfe..51c81f969 100644 --- a/tslang/lib/TypeScript/OwnershipFacts.h +++ b/tslang/lib/TypeScript/OwnershipFacts.h @@ -29,9 +29,10 @@ namespace mlir_ts = mlir::typescript; // // The parameters the callee keeps: the caller moves each such argument into the call. #define OWN_PARAMS_ATTR_NAME "__own_params" -// On a function: the parameters it keeps (OWN_PARAMS_ATTR_NAME) that its body does not take into -// anything on every path - one it returns on some paths only, one a callback family gives it to keep -// (spec 33). The inference releases each on the paths that do not move it, statically. +// On a function: the parameters it keeps (OWN_PARAMS_ATTR_NAME) that its body never takes into a +// field, an element, a global, a keeping call or an assigned owning local - one it returns on some +// paths only, one a callback family gives it to keep (spec 33). The inference releases each on the +// paths that do not move it, statically. #define OWN_PARAMS_RELEASED_ATTR_NAME "__own_params_released" // The arguments the result is a borrow of, as argument indices (the receiver is 0 for a method): the // result may not outlive any of them. Alone, the caller owns nothing it gets back, and it names one From 4579830a3192c741181fe6fe3d0d84b8eceb8997 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 21:41:52 +0100 Subject: [PATCH 41/57] own-raytrace T8: -mm=own: a borrow read out of a kept parameter is bounded by its new owner Found in review: the `let r = p; r = new N(...)` keep rule moved `p` into `r`, and the assignment freed `p` and `p.v` while `const inner = p.v`, read before the move, was still used (own printed 779000, gc 501500; BASE rejected the program). The same with a field as the new owner (`h.n = p; h.n = new N(...)`) was wrong before this task too: a borrow read out of a parameter had no ends, and a kept one moves away. Now such a borrow is bounded by the new owner from the move on, as a read through the owner would be (spec 29's destination chain): the owner's ends and the drops of its place after the move end it; a move into anything else (a keeping call, a return) ends it there, and so do the parameter slot's releases. Reading it while the new owner still holds it stays accepted. Negatives own_err_kept_param_let_overwritten and own_err_kept_param_field_overwritten, built through a @noinline factory (an argument made at the call site can be stack-promoted, and its release frees nothing). Tooth: with the bound skipped both compile and print 779000 instead of 501500. Spec 33 records it, and in "Not here" the dangling slot after `r = f(r, v)` if `f` throws, and the release skipped for any thrown value computed from a kept parameter. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 28 +++++- .../lib/TypeScript/OwnershipInferencePass.cpp | 94 +++++++++++++++++++ tslang/test/tester/CMakeLists.txt | 2 + .../own_err_kept_param_field_overwritten.ts | 46 +++++++++ .../own/own_err_kept_param_let_overwritten.ts | 41 ++++++++ 5 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 tslang/test/tester/own/own_err_kept_param_field_overwritten.ts create mode 100644 tslang/test/tester/own/own_err_kept_param_let_overwritten.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 abc1ddb0b..8669d97f4 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 @@ -3894,9 +3894,35 @@ On a scratch copy whose only other change is `legalize` made a module-level func own as an executable and runs: checksum 38837369 as under rc, exit 0, peak 4.9 MB as rc's 4.9 MB on Windows, and 4.3 MB (rc 4.4 MB) on Linux, where glibc reports no bad free. +**After review** (2026-10-22). The `let` rule above accepted a read of freed memory (found in review): +`const inner = p.v; let r = p; r = new N(new V(2)); ... inner.x` moved `p` into `r`, and the +assignment freed `p` and `p.v` while `inner` still read them (own printed 779000, gc 501500; BASE +rejected the program, since `p` was not kept). The same shape with a field as the new owner, `const +inner = p.v; h.n = p; h.n = new N(...); inner.x`, was wrong before this section too (BASE and HEAD +both printed 779000): a borrow read out of a kept parameter had no ends at all - the parameter is the +caller's for the whole call, unless the function keeps it. Now a borrow read out of a parameter the +function keeps, before the parameter moves, is bounded by the new owner from the move on, as a read +through that owner would be (§29's destination chain, `keptParamOutlives` in `checkPlaceRead`): the +owner's ends and the drops of its place after the move end it. A move into anything that is not such +a place (a call that keeps it, a return, a push) ends it at the move, and so do the parameter slot's +releases (the exit releases above). A borrow read before the move and used while the new owner +still holds it is accepted (`h.n = p; churn(); inner.x`, and `let r = p; inner.x + r.v.x` before `r` +is given another value). Negatives `own_err_kept_param_let_overwritten` and +`own_err_kept_param_field_overwritten` (each built through a `@noinline` factory, with churn: an +argument made at the call site can be stack-promoted, and its release then frees nothing): `borrows +a field but is used here after it may be released or overwritten`. Tooth: with the bound skipped, both +compile, print 779000 instead of 501500 (gc), and fail their assertion. `own_reduce`, the ray tracer +(1 error, the same) and the corpus (528, the same files) are unchanged. + Not here: - Unwinding out of the function - a call that throws outside a `try` - before a kept parameter is - moved leaks it, as it leaks an owning local; so does a `throw` of a value read out of it. + moved leaks it, as it leaks an owning local; so does a `throw` of any value computed from it, even + a number (`throw acc.x`): the release before a leaving throw is skipped when the thrown value is + computed from the parameter at all, where only one that can hold its block needs it. +- After `r = f(r, v)` moves `r` into the call, `r`'s slot still holds the moved pointer until the + call's result is stored into it. If `f` throws, the slot dangles. That is benign only because + owning locals are not released on unwind; a change that releases them there must not release a + slot whose value a call took. - A callback passed through a `let`, a field or a global, a member whose symbol is used anywhere else (`a.reduce(V.plus, ...)`: a static method is a vtable entry), a parameter that is passed on rather than called, an exported or `@keeps`/`@lends` function: no family, as before. diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index 05356088d..7f8db59d4 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -4044,6 +4044,11 @@ class OwnershipInferencePass } } + if (keptParamOutlives(chain, outlives)) + { + return false; + } + for (auto *drop : drops) { if (countedStoreReleases.contains(drop)) @@ -4061,6 +4066,95 @@ class OwnershipInferencePass return true; } + // A borrow read out of a parameter this function keeps, taken before the parameter moves into a + // new owner (spec 33, review fix): from the move on it is bounded by that owner, as a read through + // the owner would be (spec 29) - by the owner's ends and the drops of its place after the move. + // `const inner = p.v; let r = p; r = new N(...); inner.x` reads what the assignment freed. A move + // into anything that is not such a place (a call that keeps it, a return, a push) ends the borrow + // there. So do the releases of the parameter's slot (spec 33's exit releases). True when some use + // of the borrow comes after one of these (`outlives` reported it). + bool keptParamOutlives(const Chain &chain, llvm::function_ref outlives) + { + for (auto &root : chain.roots) + { + auto load = keptParamLoadOf(root.first); + if (!load) + { + continue; + } + + // the moves of the parameter: what takes a read of its slot + llvm::SmallVector> takers; + for (auto *user : load.getReference().getUsers()) + { + if (mlir::isa(user)) + { + if (outlives(user)) + { + return true; + } + + continue; + } + + auto readOp = mlir::dyn_cast(user); + if (!readOp) + { + continue; + } + + forEachUse(readOp.getResult(), [&](mlir::Operation *use, mlir::Value used) { + auto varOp = mlir::dyn_cast(use); + if (mlir::isa(use)) + { + auto *taker = takerOf(use, used); + takers.push_back({taker ? taker : use, used}); + } + else if ((varOp && isOwningVariable(varOp)) || (isCall(use) && isKeptArgument(use, used))) + { + takers.push_back({use, used}); + } + }); + } + + for (auto [taker, moved] : takers) + { + Chain owner; + if (!destinationChain(taker, owner)) + { + if (outlives(taker)) + { + return true; + } + + continue; + } + + auto afterMove = [&](mlir::Operation *end) { + return end != taker && reachableAfter(taker, end, llvm::ArrayRef{}) && outlives(end); + }; + + for (auto *end : rootEnds(owner)) + { + if (afterMove(end)) + { + return true; + } + } + + for (auto *drop : drops) + { + if (dropsChain(drop, owner, nullptr, moved) && afterMove(drop)) + { + return true; + } + } + } + } + + return false; + } + // A parameter, or a view of one (`u`, the payload `ts.Unbox` reads out of an `any`), that // this function returns as a borrow (`__own_result_borrows`). rc retained it for the caller, // who now takes no reference: the retain goes, and nothing may keep it. The caller's own diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index fcc67e24c..cc844824e 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3819,6 +3819,8 @@ set(own_error_cases "own_err_callback_escapes|cannot make the callers of 'pick' give it up" "own_err_callback_disagrees|a function passed in its place disagrees" "own_err_callback_promoted_used|is used here after its value was moved" + "own_err_kept_param_let_overwritten|borrows a field but is used here after it may be released or overwritten" + "own_err_kept_param_field_overwritten|borrows a field but is used here after it may be released or overwritten" "own_err_param_returned_exported|is a parameter, returned here, but -mm=own cannot tell the callers of 'identity' that the result borrows it" "own_err_param_kept_exported|is a parameter, kept here, but -mm=own cannot make the callers of 'Animal.constructor' give it up" "own_err_param_assigned|is a parameter and cannot be assigned under -mm=own yet" diff --git a/tslang/test/tester/own/own_err_kept_param_field_overwritten.ts b/tslang/test/tester/own/own_err_kept_param_field_overwritten.ts new file mode 100644 index 000000000..0a50730a7 --- /dev/null +++ b/tslang/test/tester/own/own_err_kept_param_field_overwritten.ts @@ -0,0 +1,46 @@ +// -mm=own rejects: `p` is kept (stored into `h.n`), and `inner`, read out of `p` before the move, is +// bounded by `h.n` from then on (spec 33); `h.n = new N(...)` frees `p` and `p.v`, and `inner` is +// read after it. +class V { + constructor(public x: number) {} +} + +class N { + constructor(public v: V) {} +} + +class H { + n: N = new N(new V(0)); +} + +@noinline +function mk(i: number) { + return new N(new V(i)); +} + +function churn() { + const hold: V[] = []; + for (let i = 0; i < 64; i++) { + hold.push(new V(777)); + } +} + +@noinline +function f(h: H, p: N) { + const inner = p.v; + h.n = p; + h.n = new N(new V(2)); + churn(); + return inner.x + h.n.v.x; +} + +function main() { + const h = new H(); + let s: number = 0; + for (let i = 0; i < 1000; i++) { + s += f(h, mk(i)); + } + + print(s); + assert(s == 501500); +} diff --git a/tslang/test/tester/own/own_err_kept_param_let_overwritten.ts b/tslang/test/tester/own/own_err_kept_param_let_overwritten.ts new file mode 100644 index 000000000..97d994227 --- /dev/null +++ b/tslang/test/tester/own/own_err_kept_param_let_overwritten.ts @@ -0,0 +1,41 @@ +// -mm=own rejects: `p` moves into the `let r` (spec 33: a parameter read into an owning local that +// is given another value is kept), and `inner`, read out of `p` before the move, is bounded by `r` +// from then on; `r = new N(...)` frees `p` and `p.v`, and `inner` is read after it. +class V { + constructor(public x: number) {} +} + +class N { + constructor(public v: V) {} +} + +@noinline +function mk(i: number) { + return new N(new V(i)); +} + +function churn() { + const hold: V[] = []; + for (let i = 0; i < 64; i++) { + hold.push(new V(777)); + } +} + +@noinline +function f(p: N) { + const inner = p.v; + let r = p; + r = new N(new V(2)); + churn(); + return inner.x + r.v.x; +} + +function main() { + let s: number = 0; + for (let i = 0; i < 1000; i++) { + s += f(mk(i)); + } + + print(s); + assert(s == 501500); +} From afb37dda0eefde7f56fe595a5e613364cc2cb4ec Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 22:03:18 +0100 Subject: [PATCH 42/57] own-raytrace T8: -mm=own: a call that keeps an argument and is given it again uses it after the move Found in re-review: `f(p, q)` keeping `p` (spec 33's `let` rule, or a field store) and borrowing `q`, called as `f(n, n)`, freed `n` under the callee's own borrow (631370 and 639140 where gc prints 501500, and a crash; the field-store form was wrong before this task too). The caller's use-after-move checks skipped the taking call itself as a use - movesOf for a fresh or folded value, checkSlotMove's reads for an owning slot. Now (callGivenAgain) a call that keeps an argument is a use after the move when another of its operands may point into the moved block: the value again, a view of it, something read out of it, another read of the same slot, its receiver. Negatives own_err_kept_and_borrowed_arg, _slot, _field (a @noinline factory, churn, an assert). Tooth: with the check skipped they print 779000 instead of 501500 (two) and crash (one). Spec 33 records it, and the `finally`-after-`return p` narrowing of the previous fix. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 22 ++++++- .../lib/TypeScript/OwnershipInferencePass.cpp | 63 +++++++++++++++++++ tslang/test/tester/CMakeLists.txt | 3 + .../own/own_err_kept_and_borrowed_arg.ts | 46 ++++++++++++++ .../own_err_kept_and_borrowed_arg_field.ts | 46 ++++++++++++++ .../own/own_err_kept_and_borrowed_arg_slot.ts | 49 +++++++++++++++ 6 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 tslang/test/tester/own/own_err_kept_and_borrowed_arg.ts create mode 100644 tslang/test/tester/own/own_err_kept_and_borrowed_arg_field.ts create mode 100644 tslang/test/tester/own/own_err_kept_and_borrowed_arg_slot.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 8669d97f4..e59cbb087 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 @@ -3912,7 +3912,27 @@ is given another value). Negatives `own_err_kept_param_let_overwritten` and argument made at the call site can be stack-promoted, and its release then frees nothing): `borrows a field but is used here after it may be released or overwritten`. Tooth: with the bound skipped, both compile, print 779000 instead of 501500 (gc), and fail their assertion. `own_reduce`, the ray tracer -(1 error, the same) and the corpus (528, the same files) are unchanged. +(1 error, the same) and the corpus (528, the same files) are unchanged. It narrows one shape that +was correct: a borrow read out of `p` and used in a `finally` after `return p` (the return ends it at +the move, though the caller then holds the value) is now rejected; no suite test or corpus file has +it. + +**After re-review** (2026-10-22). A call that keeps one argument and is given the same value again +through another operand let the callee free what it still borrowed: `f(p, q) { const inner = q.v; +let r = p; r = new N(...); inner.x }` called as `f(n, n)` printed 631370 (gc 501500), a variant +crashed, and the same with `n` an owning `let` printed 639140 - BASE rejected them, since `p` was not +kept, and the `let` rule above made them reachable. The same through a field keep (`f(h, n, n)` with +`h.n = p; h.n = new N(...)`, then `q.v`) was wrong before this section too. The caller's +use-after-move checks skipped the taker itself as a use: `movesOf` for a fresh or folded value, +`checkSlotMove`'s reads for an owning slot. Now (`callGivenAgain`) a call that keeps an argument is a +use after the move when another of its operands - the value again, a view of it, something read out +of it (`f(h, n, n.v)`), another read of the same slot, its receiver - may point into the moved block: +`is used here after its value was moved`. `___array_reduce`'s `f(r, v)` passes the accumulator once. +Negatives `own_err_kept_and_borrowed_arg` (a folded `const`), `_slot` (an owning `let`) and `_field` +(the field keep), each through a `@noinline` factory with churn and an assert. Tooth: with the check +skipped, the first two print 779000 instead of 501500 and fail their assertion, and the third crashes +(0xC0000005). `own_reduce`, the ray tracer (1 error, the same) and the corpus (528, the same files) +are unchanged. Not here: - Unwinding out of the function - a call that throws outside a `try` - before a kept parameter is diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index 7f8db59d4..b1f42310a 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -5027,6 +5027,13 @@ class OwnershipInferencePass made.push_back(definition); } + // the call that keeps it, given it again through another operand + if (auto *again = callGivenAgain(taker, value, {value})) + { + reportUseAfterMove(again, taker, name); + return false; + } + for (auto *use : llvm::concat(takers, reads)) { if (use != taker && reachableAfter(taker, use, definition)) @@ -5076,6 +5083,46 @@ class OwnershipInferencePass return true; } + // A call that keeps an argument and is given the same value again through another operand - the + // value itself, a view of it, something read out of it, another read of its slot (`f(n, n)`, + // `f(h, n, n.v)` with only the first `n` kept): the callee takes the value while it borrows it + // through the other operand, and may free it under that borrow, so the other operand is a use + // after the move (spec 33, review). `sources` are what the moved value is: the value, or every + // read of the owning slot it came from. The call itself, or null. + mlir::Operation *callGivenAgain(mlir::Operation *taker, mlir::Value moved, llvm::ArrayRef sources) + { + if (!isCall(taker) || ownedParams(taker).empty()) + { + return nullptr; + } + + auto args = callArgs(taker); + auto kept = ownedParams(taker); + auto movedRoot = rootOf(moved); + for (auto &operand : taker->getOpOperands()) + { + // the moved argument itself, where the callee keeps it + auto number = operand.getOperandNumber(); + if (!args.empty() && number >= args.getBeginOperandIndex() && + number < args.getBeginOperandIndex() + args.size() && + llvm::is_contained(kept, static_cast(number - args.getBeginOperandIndex())) && + rootOf(operand.get()) == movedRoot) + { + continue; + } + + for (auto source : sources) + { + if (derivedFrom(source).contains(operand.get())) + { + return taker; + } + } + } + + return nullptr; + } + // Does control come back round to `taker` without making the value again? Only then is it a // move on every iteration of a loop. bool takerLoops(mlir::Operation *taker, mlir::Operation *definition) @@ -5340,6 +5387,22 @@ class OwnershipInferencePass return false; } + // the call that keeps it, given the slot's value again through another operand + llvm::SmallVector slotReads; + for (auto *user : slot.getUsers()) + { + if (auto readOp = mlir::dyn_cast(user)) + { + slotReads.push_back(readOp.getResult()); + } + } + + if (auto *again = callGivenAgain(taker, value, slotReads)) + { + reportUseAfterMove(again, taker, name); + return false; + } + // Every read of the slot is a use of `a`, and so is every use of what a read returned: a // `const b = a` is folded into its load, so `b.x` after the move reads the moved value // through a load that came before it. A read through the new owner, while it holds the diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index cc844824e..3260ea064 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3821,6 +3821,9 @@ set(own_error_cases "own_err_callback_promoted_used|is used here after its value was moved" "own_err_kept_param_let_overwritten|borrows a field but is used here after it may be released or overwritten" "own_err_kept_param_field_overwritten|borrows a field but is used here after it may be released or overwritten" + "own_err_kept_and_borrowed_arg|is used here after its value was moved" + "own_err_kept_and_borrowed_arg_slot|is used here after its value was moved" + "own_err_kept_and_borrowed_arg_field|is used here after its value was moved" "own_err_param_returned_exported|is a parameter, returned here, but -mm=own cannot tell the callers of 'identity' that the result borrows it" "own_err_param_kept_exported|is a parameter, kept here, but -mm=own cannot make the callers of 'Animal.constructor' give it up" "own_err_param_assigned|is a parameter and cannot be assigned under -mm=own yet" diff --git a/tslang/test/tester/own/own_err_kept_and_borrowed_arg.ts b/tslang/test/tester/own/own_err_kept_and_borrowed_arg.ts new file mode 100644 index 000000000..714df6888 --- /dev/null +++ b/tslang/test/tester/own/own_err_kept_and_borrowed_arg.ts @@ -0,0 +1,46 @@ +// -mm=own rejects: `f` keeps `p` (spec 33: it moves into a `let` that is given another value) and +// borrows `q`; `f(n, n)` gives it the same object as both, so the callee frees it under its own +// borrow. The second `n` is a use after the move into the first. +class V { + constructor(public x: number) {} +} + +class N { + constructor(public v: V) {} +} + +class H { + n: N = new N(new V(0)); +} + +@noinline +function mk(i: number) { + return new N(new V(i)); +} + +function churn() { + const hold: V[] = []; + for (let i = 0; i < 64; i++) { + hold.push(new V(777)); + } +} + +@noinline +function f(p: N, q: N) { + const inner = q.v; + let r = p; + r = new N(new V(2)); + churn(); + return inner.x + r.v.x; +} + +function main() { + let s: number = 0; + for (let i = 0; i < 1000; i++) { + const n = mk(i); + s += f(n, n); + } + + print(s); + assert(s == 501500); +} diff --git a/tslang/test/tester/own/own_err_kept_and_borrowed_arg_field.ts b/tslang/test/tester/own/own_err_kept_and_borrowed_arg_field.ts new file mode 100644 index 000000000..3e76c2a88 --- /dev/null +++ b/tslang/test/tester/own/own_err_kept_and_borrowed_arg_field.ts @@ -0,0 +1,46 @@ +// -mm=own rejects: `f` keeps `p` (stored into `h.n`) and borrows `q`; `f(h, n, n)` gives it the same +// object as both, and `h.n = new N(...)` frees it while `q` is still read. The second `n` is a use +// after the move into the first. +class V { + constructor(public x: number) {} +} + +class N { + constructor(public v: V) {} +} + +class H { + n: N = new N(new V(0)); +} + +@noinline +function mk(i: number) { + return new N(new V(i)); +} + +function churn() { + const hold: V[] = []; + for (let i = 0; i < 64; i++) { + hold.push(new V(777)); + } +} + +@noinline +function f(h: H, p: N, q: N) { + h.n = p; + h.n = new N(new V(2)); + churn(); + return q.v.x + h.n.v.x; +} + +function main() { + const h = new H(); + let s: number = 0; + for (let i = 0; i < 1000; i++) { + const n = mk(i); + s += f(h, n, n); + } + + print(s); + assert(s == 501500); +} diff --git a/tslang/test/tester/own/own_err_kept_and_borrowed_arg_slot.ts b/tslang/test/tester/own/own_err_kept_and_borrowed_arg_slot.ts new file mode 100644 index 000000000..ef904b798 --- /dev/null +++ b/tslang/test/tester/own/own_err_kept_and_borrowed_arg_slot.ts @@ -0,0 +1,49 @@ +// -mm=own rejects: as own_err_kept_and_borrowed_arg, with `n` an owning `let`: both arguments of +// `f(n, n)` are reads of its slot, and the one `f` borrows is a use after the move into the other. +class V { + constructor(public x: number) {} +} + +class N { + constructor(public v: V) {} +} + +class H { + n: N = new N(new V(0)); +} + +@noinline +function mk(i: number) { + return new N(new V(i)); +} + +function churn() { + const hold: V[] = []; + for (let i = 0; i < 64; i++) { + hold.push(new V(777)); + } +} + +@noinline +function f(p: N, q: N) { + const inner = q.v; + let r = p; + r = new N(new V(2)); + churn(); + return inner.x + r.v.x; +} + +function main() { + let s: number = 0; + for (let i = 0; i < 1000; i++) { + let n = mk(i); + if (i > 2000) { + n = mk(0); + } + + s += f(n, n); + } + + print(s); + assert(s == 501500); +} From 0bf9ed7ffefadac6e6be4a0be2d8cc80880f8859 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 22:29:37 +0100 Subject: [PATCH 43/57] own-raytrace T8: -mm=own: one value at two kept positions of a call is a use after the move Found in the second re-review: `f(n, n)` with both parameters kept (two `let`s given other values, a field and a `let`, two fields, two pushes) made two owners of one block. callGivenAgain passed over every kept operand rooted at the moved value; now it passes over one, the move itself, and a second is a use after it. The `let` forms were reachable only through spec 33's `let` rule; two fields and two pushes were wrong at BASE too. Also, for an owning slot, only the reads that reach the call holding the same value count (`const arr = [n]; n = mk(5); f(n, arr)` is accepted again). Negatives own_err_kept_twice, _field, _push (a @noinline factory, churn, an assert). Tooth: with every kept position passed over again they exit 0xC0000374, 0xC0000005, 0xC0000374. Spec 33 updated. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 14 ++++++ .../lib/TypeScript/OwnershipInferencePass.cpp | 12 +++-- tslang/test/tester/CMakeLists.txt | 3 ++ tslang/test/tester/own/own_err_kept_twice.ts | 43 +++++++++++++++++ .../tester/own/own_err_kept_twice_field.ts | 47 +++++++++++++++++++ .../tester/own/own_err_kept_twice_push.ts | 43 +++++++++++++++++ 6 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 tslang/test/tester/own/own_err_kept_twice.ts create mode 100644 tslang/test/tester/own/own_err_kept_twice_field.ts create mode 100644 tslang/test/tester/own/own_err_kept_twice_push.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 e59cbb087..5946866f9 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 @@ -3934,6 +3934,20 @@ skipped, the first two print 779000 instead of 501500 and fail their assertion, (0xC0000005). `own_reduce`, the ray tracer (1 error, the same) and the corpus (528, the same files) are unchanged. +**After the second re-review** (2026-10-22). One value at two kept positions of one call (`f(n, n)` +with both `p` and `q` kept: two `let`s given other values, a field and a `let`, two fields, two +pushes) is two owners of one block, each of which frees it; `callGivenAgain` passed over every kept +operand rooted at the moved value. Now it passes over one - the move itself - and a second is a use +after it (`is used here after its value was moved`). The `let` forms printed wrong or crashed only +since this section's `let` rule (BASE rejected them); two fields and two pushes were wrong at BASE +too. Negatives `own_err_kept_twice` (two `let`s), `_field` (a field and a `let`), `_push` (two +pushes), each through a `@noinline` factory with churn and an assert. Tooth: with every kept position +passed over again, they exit `0xC0000374`, `0xC0000005` and `0xC0000374`. Distinct objects (`f(n, +m)`, `f(h, n, m)`) stay accepted. Also, for an owning slot the other reads counted are only those +that reach the call holding the same value (not crossing an assignment of the slot): +`const arr = [n]; n = mk(5); f(n, arr)`, which reads the old value into `arr`, is accepted again, as +before the first re-review. + Not here: - Unwinding out of the function - a call that throws outside a `try` - before a kept parameter is moved leaks it, as it leaks an owning local; so does a `throw` of any value computed from it, even diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index b1f42310a..f2827afe0 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -5099,15 +5099,19 @@ class OwnershipInferencePass auto args = callArgs(taker); auto kept = ownedParams(taker); auto movedRoot = rootOf(moved); + // one kept position is the move itself; the value at a second one (`f(n, n)` with both + // kept) is a second owner, a use after the first move like any other operand + auto skipped = false; for (auto &operand : taker->getOpOperands()) { // the moved argument itself, where the callee keeps it auto number = operand.getOperandNumber(); - if (!args.empty() && number >= args.getBeginOperandIndex() && + if (!skipped && !args.empty() && number >= args.getBeginOperandIndex() && number < args.getBeginOperandIndex() + args.size() && llvm::is_contained(kept, static_cast(number - args.getBeginOperandIndex())) && rootOf(operand.get()) == movedRoot) { + skipped = true; continue; } @@ -5387,11 +5391,13 @@ class OwnershipInferencePass return false; } - // the call that keeps it, given the slot's value again through another operand + // the call that keeps it, given the slot's value again through another operand: a read of + // the slot that reaches the call holding the same value (not one made before an assignment + // gave the slot its current value, `const arr = [n]; n = mk(5); f(n, arr)`) llvm::SmallVector slotReads; for (auto *user : slot.getUsers()) { - if (auto readOp = mlir::dyn_cast(user)) + if (auto readOp = mlir::dyn_cast(user); readOp && reachableAfter(readOp, taker, kills)) { slotReads.push_back(readOp.getResult()); } diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 3260ea064..6aa8bd497 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3824,6 +3824,9 @@ set(own_error_cases "own_err_kept_and_borrowed_arg|is used here after its value was moved" "own_err_kept_and_borrowed_arg_slot|is used here after its value was moved" "own_err_kept_and_borrowed_arg_field|is used here after its value was moved" + "own_err_kept_twice|is used here after its value was moved" + "own_err_kept_twice_field|is used here after its value was moved" + "own_err_kept_twice_push|is used here after its value was moved" "own_err_param_returned_exported|is a parameter, returned here, but -mm=own cannot tell the callers of 'identity' that the result borrows it" "own_err_param_kept_exported|is a parameter, kept here, but -mm=own cannot make the callers of 'Animal.constructor' give it up" "own_err_param_assigned|is a parameter and cannot be assigned under -mm=own yet" diff --git a/tslang/test/tester/own/own_err_kept_twice.ts b/tslang/test/tester/own/own_err_kept_twice.ts new file mode 100644 index 000000000..5af845ab1 --- /dev/null +++ b/tslang/test/tester/own/own_err_kept_twice.ts @@ -0,0 +1,43 @@ +// -mm=own rejects: `f` keeps both `p` and `q` (each moves into a `let` that is given another +// value, spec 33), and `f(n, n)` gives it the same object twice: two owners, each of which frees it. +// The second `n` is a use after the move into the first. +class V { + constructor(public x: number) {} +} + +class N { + constructor(public v: V) {} +} + +@noinline +function mk(i: number) { + return new N(new V(i)); +} + +function churn() { + const hold: V[] = []; + for (let i = 0; i < 64; i++) { + hold.push(new V(777)); + } +} + +@noinline +function f(p: N, q: N) { + let r = p; + let t = q; + r = new N(new V(2)); + t = new N(new V(0)); + churn(); + return r.v.x + t.v.x - 2; +} + +function main() { + let s: number = 0; + for (let i = 0; i < 1000; i++) { + const n = mk(i); + s += f(n, n) + i + 2; + } + + print(s); + assert(s == 501500); +} diff --git a/tslang/test/tester/own/own_err_kept_twice_field.ts b/tslang/test/tester/own/own_err_kept_twice_field.ts new file mode 100644 index 000000000..7bd132b57 --- /dev/null +++ b/tslang/test/tester/own/own_err_kept_twice_field.ts @@ -0,0 +1,47 @@ +// -mm=own rejects: `f` keeps `p` (stored into `h.n`) and `q` (moved into a `let` given another +// value), and `f(h, n, n)` gives it the same object twice. The second `n` is a use after the move +// into the first. +class V { + constructor(public x: number) {} +} + +class N { + constructor(public v: V) {} +} + +@noinline +function mk(i: number) { + return new N(new V(i)); +} + +function churn() { + const hold: V[] = []; + for (let i = 0; i < 64; i++) { + hold.push(new V(777)); + } +} + +class H { + n: N = new N(new V(0)); +} + +@noinline +function f(h: H, p: N, q: N) { + h.n = p; + let t = q; + t = new N(new V(0)); + churn(); + return h.n.v.x + t.v.x + 2; +} + +function main() { + const h = new H(); + let s: number = 0; + for (let i = 0; i < 1000; i++) { + const n = mk(i); + s += f(h, n, n); + } + + print(s); + assert(s == 501500); +} diff --git a/tslang/test/tester/own/own_err_kept_twice_push.ts b/tslang/test/tester/own/own_err_kept_twice_push.ts new file mode 100644 index 000000000..ee9d07baa --- /dev/null +++ b/tslang/test/tester/own/own_err_kept_twice_push.ts @@ -0,0 +1,43 @@ +// -mm=own rejects: `f` pushes both `p` and `q` into `arr`, so it keeps both, and `f(arr, n, n)` gives +// it the same object twice: the array holds two owners of one block. The second `n` is a use after +// the move into the first. +class V { + constructor(public x: number) {} +} + +class N { + constructor(public v: V) {} +} + +@noinline +function mk(i: number) { + return new N(new V(i)); +} + +function churn() { + const hold: V[] = []; + for (let i = 0; i < 64; i++) { + hold.push(new V(777)); + } +} + +@noinline +function f(arr: N[], p: N, q: N) { + arr.push(p); + arr.push(q); + return 0; +} + +function main() { + let s: number = 0; + for (let i = 0; i < 1000; i++) { + const arr: N[] = []; + const n = mk(i); + s += f(arr, n, n) + i + 2; + churn(); + s += arr[0].v.x - i; + } + + print(s); + assert(s == 501500); +} From 6a091197be2cb0efabee5cbf13ed3762aab64f33 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 22:55:42 +0100 Subject: [PATCH 44/57] own-raytrace T8: -mm=own: a slot assigned between a move's read and the call that keeps it is a use after the move The round-3 read filter in checkSlotMove (a slot read counts for callGivenAgain only when it reaches the taker without crossing an assignment of the slot) dropped every read of the old value when the slot was assigned inside the call's own argument list, the move's read included: `let n = mk(i); f(n, n, (n = mk(5), 0))` with `p` kept and `q` borrowed was accepted and crashed (0xC0000005). Underneath, `f(n, (n = mk(7), 0))` with `p` kept is wrong with no second operand at all: the assignment's release frees the old `n` before the callee takes it (the `let` form was rejected at BASE, where `p` was not kept; the field-keep form was wrong at BASE too). checkSlotMove now rejects a move whose own read does not reach the taker without crossing an assignment of the slot (`!reachableAfter(load, taker, kills)`), reporting the assignment between them with the existing text `is used here after its value was moved` (note: the call, "value moved here"). With the move's read held to that, the round-3 filter is exact: every other read that passes it holds the moved value. `f(n, (n = mk(i), 0), n)` stays rejected; `const arr = [n]; n = mk(5); f(n, arr)` stays accepted. Negatives own_err_kept_arg_assigned_in_call and own_err_kept_and_borrowed_arg_assigned_in_call (gc and none print 501500; rc crashes on these shapes, a pre-existing rc codegen issue noted in spec 33 "Not here"). Tooth: with the check skipped both compile and exit 0xC0000005. own_reduce, the ray tracer (1 error, 279:75) and the corpus (528, the same files) are unchanged. Spec 33: the read-filter sentence now states what is checked; "After the third re-review" added. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 29 ++++++++++-- .../lib/TypeScript/OwnershipInferencePass.cpp | 30 ++++++++++++- tslang/test/tester/CMakeLists.txt | 2 + ..._kept_and_borrowed_arg_assigned_in_call.ts | 42 ++++++++++++++++++ .../own/own_err_kept_arg_assigned_in_call.ts | 44 +++++++++++++++++++ 5 files changed, 142 insertions(+), 5 deletions(-) create mode 100644 tslang/test/tester/own/own_err_kept_and_borrowed_arg_assigned_in_call.ts create mode 100644 tslang/test/tester/own/own_err_kept_arg_assigned_in_call.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 5946866f9..c2343f6b3 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 @@ -3944,11 +3944,34 @@ too. Negatives `own_err_kept_twice` (two `let`s), `_field` (a field and a `let`) pushes), each through a `@noinline` factory with churn and an assert. Tooth: with every kept position passed over again, they exit `0xC0000374`, `0xC0000005` and `0xC0000374`. Distinct objects (`f(n, m)`, `f(h, n, m)`) stay accepted. Also, for an owning slot the other reads counted are only those -that reach the call holding the same value (not crossing an assignment of the slot): -`const arr = [n]; n = mk(5); f(n, arr)`, which reads the old value into `arr`, is accepted again, as -before the first re-review. +that reach the call without crossing an assignment of the slot (or its declaration): `const arr = +[n]; n = mk(5); f(n, arr)`, which reads the old value into `arr`, is accepted again, as before the +first re-review. That is not "holds the moved value" by itself; it is, once the move's own read is +held to the same condition (the third re-review, below). + +**After the third re-review** (2026-10-22). The read filter above dropped every read of the old value +when the slot was assigned inside the call's own argument list, the move's read among them: +`let n = mk(i); f(n, n, (n = mk(5), 0))` with `p` kept and `q` borrowed was accepted and crashed +(0xC0000005; the previous round rejected it). Underneath, the assignment alone is wrong, with no +second operand: `f(n, (n = mk(7), 0))` with `p` kept in a `let` (BASE rejected it, since `p` was not +kept) or in a field (wrong at BASE too) frees the old `n` at the assignment's release and then hands +it to `f`. Now `checkSlotMove` rejects a move whose own read does not reach the taker without +crossing an assignment of the slot (`!reachableAfter(load, taker, kills)`), reporting the assignment +between them as `is used here after its value was moved` (the call is "value moved here"). With the +move's read held to that, every other read that passes the filter holds the moved value, so the +filter is exact. `f(n, (n = mk(i), 0), n)` (the old `n` kept, the new one borrowed) stays rejected, +by this check. Negatives `own_err_kept_arg_assigned_in_call` (`f(n, (n = mk(7), 0))`, `p` kept in a +`let`) and `own_err_kept_and_borrowed_arg_assigned_in_call` (`f(n, n, (n = mk(5), 0))`, `q` +borrowed), each through a `@noinline` factory with churn and an assert; gc and none print 501500. +Tooth: with the check skipped, both, and the field-keep form, compile and exit `0xC0000005` with no +output. `const arr = [n]; n = mk(5); f(n, arr)`, distinct objects at two kept positions, `own_reduce`, +the ray tracer (1 error, the same) and the corpus (528, the same files) are unchanged. Not here: +- A slot assigned inside the argument list of a call it was passed to (`f(n, (n = mk(7), 0))`, + with or without a second operand) crashes under rc as well (exit 139): rc releases the slot's old + value before the store, ahead of the callee that keeps it. That is rc's codegen, out of scope here; + own rejects the shapes. - Unwinding out of the function - a call that throws outside a `try` - before a kept parameter is moved leaks it, as it leaks an owning local; so does a `throw` of any value computed from it, even a number (`throw acc.x`): the release before a leaving throw is skipped when the thrown value is diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index f2827afe0..385309a39 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -5391,9 +5391,35 @@ class OwnershipInferencePass return false; } + // the slot given another value between the move's read and its taker - inside the call's own + // argument list, `f(n, (n = mk(7), 0))`: the assignment's release frees what the read holds + // before the callee takes it, so the assignment is a use of the slot after the move + if (!reachableAfter(load, taker, kills)) + { + mlir::Operation *between = taker; + llvm::SmallVector readAgain{load.getOperation()}; + if (varOp) + { + readAgain.push_back(varOp); + } + + for (auto *assignment : assignments) + { + if (reachableAfter(load, assignment, varOp) && reachableAfter(assignment, taker, readAgain)) + { + between = assignment; + break; + } + } + + reportUseAfterMove(between, taker, name); + return false; + } + // the call that keeps it, given the slot's value again through another operand: a read of - // the slot that reaches the call holding the same value (not one made before an assignment - // gave the slot its current value, `const arr = [n]; n = mk(5); f(n, arr)`) + // the slot that reaches the call without crossing an assignment of it (not one made before + // an assignment gave the slot its current value, `const arr = [n]; n = mk(5); f(n, arr)`); + // since the move's own read reaches the call so too, such a read holds the moved value llvm::SmallVector slotReads; for (auto *user : slot.getUsers()) { diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 6aa8bd497..9eab873d3 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3827,6 +3827,8 @@ set(own_error_cases "own_err_kept_twice|is used here after its value was moved" "own_err_kept_twice_field|is used here after its value was moved" "own_err_kept_twice_push|is used here after its value was moved" + "own_err_kept_arg_assigned_in_call|is used here after its value was moved" + "own_err_kept_and_borrowed_arg_assigned_in_call|is used here after its value was moved" "own_err_param_returned_exported|is a parameter, returned here, but -mm=own cannot tell the callers of 'identity' that the result borrows it" "own_err_param_kept_exported|is a parameter, kept here, but -mm=own cannot make the callers of 'Animal.constructor' give it up" "own_err_param_assigned|is a parameter and cannot be assigned under -mm=own yet" diff --git a/tslang/test/tester/own/own_err_kept_and_borrowed_arg_assigned_in_call.ts b/tslang/test/tester/own/own_err_kept_and_borrowed_arg_assigned_in_call.ts new file mode 100644 index 000000000..65b299357 --- /dev/null +++ b/tslang/test/tester/own/own_err_kept_and_borrowed_arg_assigned_in_call.ts @@ -0,0 +1,42 @@ +// -mm=own rejects: `f` keeps `p` (it moves into a `let` that is given another value, spec 33) and +// borrows `q`, and the call's own argument list gives `n` another value after both reads of it: +// `f(n, n, (n = mk(5), 0))`. The assignment releases the old object before `f` takes it through +// `p` and reads it through `q`, so the assignment is a use of `n` after the move. +class V { + constructor(public x: number) {} +} + +class N { + constructor(public v: V) {} +} + +@noinline +function mk(i: number) { + return new N(new V(i)); +} + +function churn() { + const hold: V[] = []; + for (let i = 0; i < 64; i++) { + hold.push(new V(777)); + } +} + +@noinline +function f(p: N, q: N, z: number) { + let r = p; + r = new N(new V(2)); + churn(); + return q.v.x + r.v.x - 2 + z; +} + +function main() { + let s: number = 0; + for (let i = 0; i < 1000; i++) { + let n = mk(i); + s += f(n, n, (n = mk(5), 0)) + 2; + } + + print(s); + assert(s == 501500); +} diff --git a/tslang/test/tester/own/own_err_kept_arg_assigned_in_call.ts b/tslang/test/tester/own/own_err_kept_arg_assigned_in_call.ts new file mode 100644 index 000000000..d8017cb32 --- /dev/null +++ b/tslang/test/tester/own/own_err_kept_arg_assigned_in_call.ts @@ -0,0 +1,44 @@ +// -mm=own rejects: `f` keeps `p` (it moves into a `let` that is given another value, spec 33), and +// the call's own argument list gives `n` another value after `n` was read for `p`: +// `f(n, (n = mk(7), 0))`. The assignment releases the old object before `f` takes it, so the +// assignment is a use of `n` after the move. +class V { + constructor(public x: number) {} +} + +class N { + constructor(public v: V) {} +} + +@noinline +function mk(i: number) { + return new N(new V(i)); +} + +function churn() { + const hold: V[] = []; + for (let i = 0; i < 64; i++) { + hold.push(new V(777)); + } +} + +@noinline +function f(p: N, z: number) { + const k = p.v.x; + churn(); + let r = p; + r = new N(new V(2)); + churn(); + return r.v.x - 2 + z + k; +} + +function main() { + let s: number = 0; + for (let i = 0; i < 1000; i++) { + let n = mk(i); + s += f(n, (n = mk(7), 0)) + 2; + } + + print(s); + assert(s == 501500); +} From ca14abf8f30b7ab50b704b85fe647a9d58730117 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 23:03:24 +0100 Subject: [PATCH 45/57] own-raytrace T8: -mm=own: a self-assignment is the move's destination, not an assignment between The check added by the previous commit (a slot assigned between a move's read and its taker) used the slot's assignments as kills, and reachableAfter answers false when the taker is one of them: `n = n` (00owned_locals.ts, corpus 528 -> 527) was rejected as a use after the move. The taker may be the slot's own assignment, the move's destination; it is left out of the kills for this check. `n = n` and `n = (n = mk(5), n)` print as under gc; the probes, the negatives, own_reduce and the ray tracer (1 error) are unchanged; corpus 528 again, the same files. Co-Authored-By: Claude Opus 5.5 --- .../specs/2026-09-24-own-memory-model-design.md | 5 +++-- tslang/lib/TypeScript/OwnershipInferencePass.cpp | 7 +++++-- 2 files changed, 8 insertions(+), 4 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 c2343f6b3..a9a7772fa 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 @@ -3956,8 +3956,9 @@ when the slot was assigned inside the call's own argument list, the move's read second operand: `f(n, (n = mk(7), 0))` with `p` kept in a `let` (BASE rejected it, since `p` was not kept) or in a field (wrong at BASE too) frees the old `n` at the assignment's release and then hands it to `f`. Now `checkSlotMove` rejects a move whose own read does not reach the taker without -crossing an assignment of the slot (`!reachableAfter(load, taker, kills)`), reporting the assignment -between them as `is used here after its value was moved` (the call is "value moved here"). With the +crossing an assignment of the slot (`!reachableAfter(load, taker, kills)`; the taker itself may be +the slot's assignment, `n = n`, which is the destination, not an assignment between), reporting the +assignment between them as `is used here after its value was moved` (the call is "value moved here"). With the move's read held to that, every other read that passes the filter holds the moved value, so the filter is exact. `f(n, (n = mk(i), 0), n)` (the old `n` kept, the new one borrowed) stays rejected, by this check. Negatives `own_err_kept_arg_assigned_in_call` (`f(n, (n = mk(7), 0))`, `p` kept in a diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index 385309a39..c60a25d51 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -5393,8 +5393,11 @@ class OwnershipInferencePass // the slot given another value between the move's read and its taker - inside the call's own // argument list, `f(n, (n = mk(7), 0))`: the assignment's release frees what the read holds - // before the callee takes it, so the assignment is a use of the slot after the move - if (!reachableAfter(load, taker, kills)) + // before the callee takes it, so the assignment is a use of the slot after the move. The + // taker may be an assignment of the slot itself (`n = n`): it is the destination, not between. + llvm::SmallVector killsBetween(kills); + llvm::erase(killsBetween, taker); + if (!reachableAfter(load, taker, killsBetween)) { mlir::Operation *between = taker; llvm::SmallVector readAgain{load.getOperation()}; From 6dbe22421df17a17afad78abde6b03dd0bac9f81 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 23:31:30 +0100 Subject: [PATCH 46/57] own-raytrace T8: -mm=own: a slot assigned on some path between a move's read and its taker is a use after the move The check from round 4 (`!reachableAfter(load, taker, kills)`) asked whether every path from the move's read to the call that keeps it is assigned. With the assignment on one arm of a conditional in the argument list - `f(n, i % 2 == 0 ? (n = mk(7), 0) : 0)`, the same through && or ||, or with an await on the other arm - the other arm is a clean path, the move was accepted, and on the assigning arm the old n was freed before f kept it (exit 139; BASE rejected the let forms; the field-keep form was wrong at BASE too). The test is now existential: the move is rejected when some assignment of the slot other than the taker lies on a path from the read to the taker (`reachableAfter(load, a, varOp) && reachableAfter(a, taker, {load, varOp})`), reported at that assignment with the existing `is used here after its value was moved`; the all-paths test stays as a fallback, and a read and taker that reachableAfter cannot order (orderedInBody) refuse instead of passing. The stale reachableAfter comment (async bodies and lambdas are functions of their own by this pass) is corrected. Negatives own_err_kept_arg_assigned_on_arm (c1a) and own_err_kept_arg_assigned_in_and (c2); gc and none print 501500, rc crashes (pre-existing, spec 33 "Not here"). Tooth: with the check skipped, both and the field-keep form compile and exit 0xC0000005. Still accepted and equal to gc: o12/o12b, ok1-ok5, a16d, a16fd, o13/o13r/o13p, own_reduce, l5g, sa, sa1, sa1b, sa4, sa6-sa8; 00owned_locals clean; ray tracer 1 error (279:75); corpus 528, the same files. Spec 33: the two false sentences corrected, "After the fourth re-review" added. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 36 +++++++++++--- .../lib/TypeScript/OwnershipInferencePass.cpp | 48 ++++++++++++------- tslang/test/tester/CMakeLists.txt | 2 + .../own/own_err_kept_arg_assigned_in_and.ts | 45 +++++++++++++++++ .../own/own_err_kept_arg_assigned_on_arm.ts | 45 +++++++++++++++++ 5 files changed, 154 insertions(+), 22 deletions(-) create mode 100644 tslang/test/tester/own/own_err_kept_arg_assigned_in_and.ts create mode 100644 tslang/test/tester/own/own_err_kept_arg_assigned_on_arm.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 a9a7772fa..8af67bf32 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 @@ -3958,9 +3958,11 @@ kept) or in a field (wrong at BASE too) frees the old `n` at the assignment's re it to `f`. Now `checkSlotMove` rejects a move whose own read does not reach the taker without crossing an assignment of the slot (`!reachableAfter(load, taker, kills)`; the taker itself may be the slot's assignment, `n = n`, which is the destination, not an assignment between), reporting the -assignment between them as `is used here after its value was moved` (the call is "value moved here"). With the -move's read held to that, every other read that passes the filter holds the moved value, so the -filter is exact. `f(n, (n = mk(i), 0), n)` (the old `n` kept, the new one borrowed) stays rejected, +assignment between them as `is used here after its value was moved` (the call is "value moved here"). That +test asks whether every path from the read to the call is assigned; it misses an assignment on one +arm (the fourth re-review, below), and the read filter is not made exact by it: a read made after +the assignment on the assigning arm holds a new value and still passes (an over-rejection, harmless). +`f(n, (n = mk(i), 0), n)` (the old `n` kept, the new one borrowed) stays rejected, by this check. Negatives `own_err_kept_arg_assigned_in_call` (`f(n, (n = mk(7), 0))`, `p` kept in a `let`) and `own_err_kept_and_borrowed_arg_assigned_in_call` (`f(n, n, (n = mk(5), 0))`, `q` borrowed), each through a `@noinline` factory with churn and an assert; gc and none print 501500. @@ -3968,11 +3970,33 @@ Tooth: with the check skipped, both, and the field-keep form, compile and exit ` output. `const arr = [n]; n = mk(5); f(n, arr)`, distinct objects at two kept positions, `own_reduce`, the ray tracer (1 error, the same) and the corpus (528, the same files) are unchanged. +**After the fourth re-review** (2026-10-22). The check above was an all-paths test: with the +assignment on one arm of a conditional in the argument list - `f(n, i % 2 == 0 ? (n = mk(7), 0) : +0)`, the same through `&&` or `||`, or with an `await` on the other arm - the other arm is a path +from the read to the call that crosses no assignment, so the move was accepted, and on the assigning +arm the old `n` was freed before `f` kept it (exit 139; BASE rejected the `let` forms, and the +field-keep form was wrong at BASE too). Now the test is existential: the move is rejected when some +assignment of the slot other than the taker lies on a path from the move's read to the taker +(`reachableAfter(load, a, varOp) && reachableAfter(a, taker, {load, varOp})`), reported at that +assignment with the same text; the all-paths test stays as a fallback (reported at the taker), and a +read and taker `reachableAfter` cannot order (one statement kept as a region, or outside the body; +unreachable today, since the moved-in-loop check takes those first and no op of a body holds a +region by this pass) now refuse instead of passing. Shapes that stay accepted, and why the predicate +leaves them alone: an assignment before the read, with the read between it and the call (`n = mk(i); +f(n, 0)` in a loop over a slot declared outside it, `f((n = mk(i), n), 0)`, `const arr = [n]; n = +mk(5); f(n, arr)`); an assignment after the call, conditional or not (the way back to the taker +crosses the read, or the declaration); `n = n`, `n = (n = mk(5), n)`, `n = acc(n, i)`. Negatives +`own_err_kept_arg_assigned_on_arm` (`i >= 0 ? (n = mk(7), 0) : 0`) and `own_err_kept_arg_assigned_in_and` +(`(i % 2 == 0 && (n = mk(7), true)) ? 0 : 0`), each through a `@noinline` factory with churn and an +assert; gc and none print 501500. Tooth: with the check skipped, both, and the field-keep form with +the conditional assignment, compile and exit `0xC0000005` with no output. `own_reduce`, the ray +tracer (1 error, the same) and the corpus (528, the same files) are unchanged. + Not here: - A slot assigned inside the argument list of a call it was passed to (`f(n, (n = mk(7), 0))`, - with or without a second operand) crashes under rc as well (exit 139): rc releases the slot's old - value before the store, ahead of the callee that keeps it. That is rc's codegen, out of scope here; - own rejects the shapes. + with or without a second operand, unconditionally or on one arm) crashes under rc as well (exit + 139): rc releases the slot's old value before the store, ahead of the callee that keeps it. That is + rc's codegen, out of scope here; own rejects these shapes since the third and fourth re-reviews. - Unwinding out of the function - a call that throws outside a `try` - before a kept parameter is moved leaks it, as it leaks an owning local; so does a `throw` of any value computed from it, even a number (`throw acc.x`): the release before a leaving throw is skipped when the thrown value is diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index c60a25d51..3038bd76e 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -5172,14 +5172,26 @@ class OwnershipInferencePass // path through it is a new value, not this one. Straight-line order within a block, then the // CFG from the block's successors; a path back into `from`'s own block reaches every op in it. // - // Ops nested in a region that is not the function body (an `async.execute` body, a lambda) - // are compared by their ancestors in the body. That answers "reachable" whenever the two - // share an ancestor, which can only turn a program into an error. + // Ops nested in a region that is not the function body are compared by their ancestors in the + // body. That answers "reachable" whenever the two share an ancestor, which can only turn a + // program into an error. (By this pass async bodies and lambdas are functions of their own; + // after the affine lowering no op of a body holds a region, so the case is kept for safety.) bool reachableAfter(mlir::Operation *from, mlir::Operation *to, mlir::Operation *kill) { return reachableAfter(from, to, kill ? llvm::ArrayRef(kill) : llvm::ArrayRef()); } + // Whether reachableAfter can order `from` and `to` at all: both inside the function body and not + // one op of it (nested in the same statement). A check whose safe answer is "not reachable" must + // refuse what it cannot order, where reachableAfter would answer "reachable". + bool orderedInBody(mlir::Operation *from, mlir::Operation *to) + { + auto &body = getFunction().getFunctionBody(); + from = body.findAncestorOpInRegion(*from); + to = body.findAncestorOpInRegion(*to); + return from && to && from != to; + } + // The same, where a path through any of `kills` is a new value. A use that is itself a kill // runs after the value is made again. bool reachableAfter(mlir::Operation *from, mlir::Operation *to, llvm::ArrayRef kills) @@ -5391,15 +5403,13 @@ class OwnershipInferencePass return false; } - // the slot given another value between the move's read and its taker - inside the call's own - // argument list, `f(n, (n = mk(7), 0))`: the assignment's release frees what the read holds - // before the callee takes it, so the assignment is a use of the slot after the move. The - // taker may be an assignment of the slot itself (`n = n`): it is the destination, not between. - llvm::SmallVector killsBetween(kills); - llvm::erase(killsBetween, taker); - if (!reachableAfter(load, taker, killsBetween)) + // the slot given another value on some path between the move's read and its taker - inside + // the call's own argument list, `f(n, (n = mk(7), 0))`, on one arm of it, `f(n, c ? (n = + // mk(7), 0) : 0)`: the assignment's release frees what the read holds before the callee takes + // it, so the assignment is a use of the slot after the move. The taker may be an assignment + // of the slot itself (`n = n`): it is the destination, not between. A read and a taker + // reachableAfter cannot order (one statement kept as a region, or outside the body) refuse. { - mlir::Operation *between = taker; llvm::SmallVector readAgain{load.getOperation()}; if (varOp) { @@ -5408,15 +5418,21 @@ class OwnershipInferencePass for (auto *assignment : assignments) { - if (reachableAfter(load, assignment, varOp) && reachableAfter(assignment, taker, readAgain)) + if (assignment != taker && reachableAfter(load, assignment, varOp) && + reachableAfter(assignment, taker, readAgain)) { - between = assignment; - break; + reportUseAfterMove(assignment, taker, name); + return false; } } - reportUseAfterMove(between, taker, name); - return false; + llvm::SmallVector killsBetween(kills); + llvm::erase(killsBetween, taker); + if (!orderedInBody(load, taker) || !reachableAfter(load, taker, killsBetween)) + { + reportUseAfterMove(taker, taker, name); + return false; + } } // the call that keeps it, given the slot's value again through another operand: a read of diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 9eab873d3..761a6c0e6 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3829,6 +3829,8 @@ set(own_error_cases "own_err_kept_twice_push|is used here after its value was moved" "own_err_kept_arg_assigned_in_call|is used here after its value was moved" "own_err_kept_and_borrowed_arg_assigned_in_call|is used here after its value was moved" + "own_err_kept_arg_assigned_on_arm|is used here after its value was moved" + "own_err_kept_arg_assigned_in_and|is used here after its value was moved" "own_err_param_returned_exported|is a parameter, returned here, but -mm=own cannot tell the callers of 'identity' that the result borrows it" "own_err_param_kept_exported|is a parameter, kept here, but -mm=own cannot make the callers of 'Animal.constructor' give it up" "own_err_param_assigned|is a parameter and cannot be assigned under -mm=own yet" diff --git a/tslang/test/tester/own/own_err_kept_arg_assigned_in_and.ts b/tslang/test/tester/own/own_err_kept_arg_assigned_in_and.ts new file mode 100644 index 000000000..d6ce9c165 --- /dev/null +++ b/tslang/test/tester/own/own_err_kept_arg_assigned_in_and.ts @@ -0,0 +1,45 @@ +// -mm=own rejects: `f` keeps `p` (it moves into a `let` that is given another value, spec 33), and +// the right side of an `&&` in the call's own argument list gives `n` another value after `n` was +// read for `p`: `f(n, (i % 2 == 0 && (n = mk(7), true)) ? 0 : 0)`. When the left side holds, the +// assignment releases the old object before `f` takes it, so the assignment is a use of `n` after +// the move, though the other path leaves `n` alone. +class V { + constructor(public x: number) {} +} + +class N { + constructor(public v: V) {} +} + +@noinline +function mk(i: number) { + return new N(new V(i)); +} + +function churn() { + const hold: V[] = []; + for (let i = 0; i < 64; i++) { + hold.push(new V(777)); + } +} + +@noinline +function f(p: N, z: number) { + const k = p.v.x; + churn(); + let r = p; + r = new N(new V(2)); + churn(); + return r.v.x - 2 + z + k; +} + +function main() { + let s: number = 0; + for (let i = 0; i < 1000; i++) { + let n = mk(i); + s += f(n, (i % 2 == 0 && (n = mk(7), true)) ? 0 : 0) + 2; + } + + print(s); + assert(s == 501500); +} diff --git a/tslang/test/tester/own/own_err_kept_arg_assigned_on_arm.ts b/tslang/test/tester/own/own_err_kept_arg_assigned_on_arm.ts new file mode 100644 index 000000000..05e58e737 --- /dev/null +++ b/tslang/test/tester/own/own_err_kept_arg_assigned_on_arm.ts @@ -0,0 +1,45 @@ +// -mm=own rejects: `f` keeps `p` (it moves into a `let` that is given another value, spec 33), and +// one arm of a conditional in the call's own argument list gives `n` another value after `n` was +// read for `p`: `f(n, i >= 0 ? (n = mk(7), 0) : 0)`. On that arm the assignment releases the old +// object before `f` takes it, so the assignment is a use of `n` after the move, though the other +// arm leaves `n` alone. +class V { + constructor(public x: number) {} +} + +class N { + constructor(public v: V) {} +} + +@noinline +function mk(i: number) { + return new N(new V(i)); +} + +function churn() { + const hold: V[] = []; + for (let i = 0; i < 64; i++) { + hold.push(new V(777)); + } +} + +@noinline +function f(p: N, z: number) { + const k = p.v.x; + churn(); + let r = p; + r = new N(new V(2)); + churn(); + return r.v.x - 2 + z + k; +} + +function main() { + let s: number = 0; + for (let i = 0; i < 1000; i++) { + let n = mk(i); + s += f(n, i >= 0 ? (n = mk(7), 0) : 0) + 2; + } + + print(s); + assert(s == 501500); +} From 004c3dcf6accbdbc75aeed6bc3710eddecd0b65a Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 23:52:51 +0100 Subject: [PATCH 47/57] own-raytrace merge: a global initializer's call borrows when its facts name any argument Main's ownGlobalInitializerCalls (#638) tested Facts::borrows >= 0, a single index on main; this branch made the result facts a set (T7, a borrowing record lends several sources), so the merged tree did not compile. Any named argument is a borrow; a borrowing record always names its sources, so it is covered too. Co-Authored-By: Claude Opus 5.5 --- tslang/lib/TypeScript/OwnershipSignaturePass.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index 2d5b44121..7c18ce0ab 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -5574,7 +5574,7 @@ class OwnershipSignaturePass : public mlir::PassWrapper= 0) + if (!facts.borrows.empty()) { op->emitError("the initializer of global '") << globalOp.getSymName() << "' holds the result of '" << calleeName From c6073351ff287e42744a7fc2979a001a9c49bdc4 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Thu, 22 Oct 2026 23:58:21 +0100 Subject: [PATCH 48/57] own-raytrace merge: a global may be given an immortal global's value Main's rejectBorrowedGlobalInitializers (#636) rejects a global initialized with another global's value: under own it would be a second owner. An immortal global's value (spec 25, T1) is owned by no one - its block is marked and no release frees it - so `let e = c` with `c` immortal is accepted again, as own_immortal_global_region expects (own, rc, gc, none; no counting). own_err_global_from_global still fails: its source global is reassigned, so it is not immortal, and a field read (own_err_global_from_field) never is. Co-Authored-By: Claude Opus 5.5 --- tslang/lib/TypeScript/OwnershipSignaturePass.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index 7c18ce0ab..e1a5404ad 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -5431,8 +5431,10 @@ class OwnershipSignaturePass : public mlir::PassWrapper(); auto *place = loadOp ? loadOp.getReference().getDefiningOp() : nullptr; + // an immortal global's value (spec 25) is owned by no one: any number of globals hold it if (!place || !mlir::isa(place) || - isHandle(value) || isConstantData(value) || !ownsHeap(value.getLoc(), value.getType())) + isHandle(value) || isConstantData(value) || isImmortalGlobalRead(value) || + !ownsHeap(value.getLoc(), value.getType())) { return; } From d691710e48ab6f1179c79b0e7671ffc0a3f06d04 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Fri, 23 Oct 2026 00:01:39 +0100 Subject: [PATCH 49/57] own-raytrace merge: negatives that main's own rules now reject first Main's own checks run in OwnershipSignaturePass before this branch's rules can report, and reject the same programs: - own_err_immortal_region_call / _borrow (T1, spec 25): a global region holding a call's borrowing result is rejected by #638 (ownGlobalInitializerCalls) first. Same programs; the regexes now pin #638's message. findImmortalGlobals still runs first and still leaves those globals mortal. - own_err_read_after_move_global / own_err_field_let_global_overwritten (T5, spec 29): `run(G)` with `run` assigning G is rejected at the call by #639 (rejectGlobalArgumentsDropped). The rule they pin is the callee's, so main() now gives each callee a fresh value (run(mkH()), new K().run(), runCallee(mkH())); every original message still fires, at lines 42/58/66 and 42. - own_err_param_kept_not_owned (phase 4): `keep` stored into h.c, which may drop, so #639 fired first; it now pushes into an array, which frees nothing, and phase 4's "is given to 'keep', which keeps it" fires again. Spec 25 and 29 note the merge; 29's "still open" `run(G)` reading a parameter is closed under own by #639. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 18 +++++++++++++++--- tslang/test/tester/CMakeLists.txt | 4 ++-- .../own_err_field_let_global_overwritten.ts | 4 +++- .../own/own_err_immortal_region_borrow.ts | 2 +- .../tester/own/own_err_immortal_region_call.ts | 2 +- .../tester/own/own_err_param_kept_not_owned.ts | 16 +++++++--------- .../own/own_err_read_after_move_global.ts | 8 +++++--- 7 files changed, 34 insertions(+), 20 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 8af67bf32..3744c7176 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 @@ -2821,7 +2821,11 @@ of `K.w` does nothing). `own_err_immortal_region_borrow`), as does a borrowed initializer (`static first = K.list[0]`, `own_err_immortal_borrowed_init`). Marking a block someone else owns immortal would turn its owner's free into a silent leak; marking a temporary the region frees would write the header - into a freed block, and the global would hold it. + into a freed block, and the global would hold it. Since main's #638 (merged into this branch + after T8) a global's region calls get their callees' facts (`ownGlobalInitializerCalls`), and a + region that holds a call's borrowing result is rejected there first ("the initializer of global + 'c' holds the result of 'id', which borrows its argument"); the two negatives now pin that + message. They stay mortal all the same: `findImmortalGlobals` runs first and still refuses them. - **Only the global's own block.** `K.n.c = x` still destroys the old `c`, and a borrow of `K.n.c` stays bounded (`own_err_immortal_field_overwritten`). - **Inference.** `own_facts::isImmortalGlobalRead` is a `ts.Load` of a `ts.AddressOf` the pass @@ -2846,7 +2850,9 @@ function, and the call's facts are not pinned there. merged with a fresh value, in a loop of 100000) and `own_immortal_global_region` the module-level ones (`const c = new V(1)`, `let e = c`), under own, rc, gc and none; own emits no counting. `own_err_global_returned` and `own_err_param_kept_not_owned` now also give the global another -value, which keeps them errors: their programs as they were are accepted. The brief's text for +value, which keeps them errors: their programs as they were are accepted. (Since the merge of main, +`own_err_param_kept_not_owned`'s `keep` pushes instead of storing into a field: a keeper that +drops is rejected at the call by #639 before phase 4's message.) The brief's text for `own_err_immortal_borrowed_init` (`the initializer of global 'K.first' takes a second reference`) is moveIntoGlobals', which only a region initializer reaches; a static's borrowed initializer is reported by inference in its static constructor, as before (`borrows 'K.list[]' and cannot be @@ -3119,7 +3125,13 @@ global's `ts.ReleaseSlot` dropping nothing, both compile but the callee's case; garbage (`9.88131e-324`) for the parameter and 999 for `this`, the second `9.88131e-324`, instead of 5. Still open, every model (rc too): a parameter itself read after the global it came from is assigned (`function run(h: C) { G = mk(); print(h.x); }` called as `run(G)`): no place is read, -so there is no chain to end. +so there is no chain to end. Under own main's #639 (merged into this branch after T8) closes that +one at the caller: a value read out of a global something assigns, lent to a call that may drop, +is an error ("a value read out of the global 'G' is lent to a call that may release or overwrite +it"). That error comes first for `run(G)`, `GK.run()` and `runCallee(G)` too, so since the merge +both negatives give their callees fresh values (`run(mkH())`, `new K().run()`) and pin this +section's callee-side rule alone; the teeth above were measured with the global passed, before +the merge. Teeth: with the kill walk skipped (`readsThroughOwner` accepting every read), all seven negatives compile, and AOT runs with same-size allocations before the read print a freed block's value: diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 00021d201..212488b6e 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3883,8 +3883,8 @@ set(own_error_cases "own_err_global_returned|borrows the global 'V.def' and cannot be stored, returned or captured" "own_err_immortal_borrowed_init|borrows 'K.list.*' and cannot be stored, returned or captured" "own_err_immortal_field_overwritten|borrows .* but is used here after .* released or overwritten" - "own_err_immortal_region_borrow|borrows the global 'c' and cannot be stored, returned or captured" - "own_err_immortal_region_call|borrows the global 'c' and cannot be stored, returned or captured" + "own_err_immortal_region_borrow|the initializer of global 'c' holds the result of 'firstOf', which borrows its argument" + "own_err_immortal_region_call|the initializer of global 'c' holds the result of 'id', which borrows its argument" "own_err_global_argument_assigned|a value read out of the global 'G' is lent to a call that may release or overwrite it" "own_err_global_method_assigns|a value read out of the global 'G' is lent to a call that may release or overwrite it" "own_err_global_let_call|borrows the global 'names', but -mm=own cannot prove the global keeps its value" diff --git a/tslang/test/tester/own/own_err_field_let_global_overwritten.ts b/tslang/test/tester/own/own_err_field_let_global_overwritten.ts index 3225f294d..2e7239953 100644 --- a/tslang/test/tester/own/own_err_field_let_global_overwritten.ts +++ b/tslang/test/tester/own/own_err_field_let_global_overwritten.ts @@ -42,6 +42,8 @@ function run(h: H) { print(x.x); } +// The rule is the callee's: it is given a fresh value here, since a written global's value lent to +// a call that may drop is rejected at the call already (#639). function main() { - run(G); + run(mkH()); } diff --git a/tslang/test/tester/own/own_err_immortal_region_borrow.ts b/tslang/test/tester/own/own_err_immortal_region_borrow.ts index 9d6bba01c..59f0815f4 100644 --- a/tslang/test/tester/own/own_err_immortal_region_borrow.ts +++ b/tslang/test/tester/own/own_err_immortal_region_borrow.ts @@ -1,6 +1,6 @@ // -mm=own rejects: `c` is never assigned, but its initializer is a call's result, a borrow of an // element `list` owns. Marking that block immortal would make the array's free of it a silent -// leak, so `c` stays mortal, and returning it is the error a global's read always was. +// leak, so `c` stays mortal. A global cannot hold a borrow: the initializer is the error (#638). class V { constructor(public x: number) {} } diff --git a/tslang/test/tester/own/own_err_immortal_region_call.ts b/tslang/test/tester/own/own_err_immortal_region_call.ts index 714c8321a..d0eb2476b 100644 --- a/tslang/test/tester/own/own_err_immortal_region_call.ts +++ b/tslang/test/tester/own/own_err_immortal_region_call.ts @@ -1,6 +1,6 @@ // -mm=own rejects: `c` is never assigned, but its initializer is a call's result, a borrow of a // temporary its region frees. Such a global is not immortal: no call's result is taken as the -// global's own, so returning it is the error a global's read always was. +// global's own. A global cannot hold a borrow: the initializer is the error (#638). class V { constructor(public x: number) {} } diff --git a/tslang/test/tester/own/own_err_param_kept_not_owned.ts b/tslang/test/tester/own/own_err_param_kept_not_owned.ts index 9e97327a9..1920bd12e 100644 --- a/tslang/test/tester/own/own_err_param_kept_not_owned.ts +++ b/tslang/test/tester/own/own_err_param_kept_not_owned.ts @@ -1,17 +1,15 @@ // -mm=own, phase 4 rejects: `keep` keeps its parameter, but `main` passes a global, which it does // not own and cannot move. `reset` gives the global another value, so it is not immortal (spec 25). +// `keep` pushes, which frees nothing: a keeper that may drop (`h.c = c` frees the old `h.c`) is +// rejected at the call already, as a written global lent to it (#639). class C { constructor(public x: number) {} } -class H { - c: C | null = null; -} - let g = new C(1); -function keep(h: H, c: C) { - h.c = c; +function keep(a: C[], c: C) { + a.push(c); } function reset() { @@ -19,8 +17,8 @@ function reset() { } function main() { - const h = new H(); - keep(h, g); - print(h.c!.x); + const a: C[] = []; + keep(a, g); + print(a[0].x); reset(); } diff --git a/tslang/test/tester/own/own_err_read_after_move_global.ts b/tslang/test/tester/own/own_err_read_after_move_global.ts index 054c58ce3..073880924 100644 --- a/tslang/test/tester/own/own_err_read_after_move_global.ts +++ b/tslang/test/tester/own/own_err_read_after_move_global.ts @@ -66,8 +66,10 @@ function runCallee(h: H) { print(c.x); } +// The rule is the callees': each is given a fresh value here, since a written global's value lent +// to a call that may drop is rejected at the call already (#639). function main() { - run(G); - GK.run(); - runCallee(G); + run(mkH()); + new K().run(); + runCallee(mkH()); } From 7484488d4420e25f30ab29ff0620fae171688ccc Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Fri, 23 Oct 2026 00:21:00 +0100 Subject: [PATCH 50/57] own-raytrace merge: an immortal static may be lent to a call that may drop Main's rejectGlobalArgumentsDropped (#639) rejects a value read out of a global something writes, lent to a call that may drop. writtenGlobals holds an immortal static's one store in its static constructor (spec 25), so `lend(b, K.w)` - lend freeing b's old field - was rejected after the merge, although no release frees K.w's block and nothing assigns it again. An immortal global's read now steps aside, as it does in #636's check. A module-level immortal global was not affected (its initializer is its region, not a store). own_immortal_global gains the call (own, rc, gc, none; no counting); spec 25 notes both of main's global checks. Co-Authored-By: Claude Opus 5.5 --- .../superpowers/specs/2026-09-24-own-memory-model-design.md | 4 ++++ tslang/lib/TypeScript/OwnershipSignaturePass.cpp | 5 ++++- tslang/test/tester/own/own_immortal_global.ts | 6 ++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md b/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md index 3744c7176..50bbe1107 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 @@ -2826,6 +2826,10 @@ of `K.w` does nothing). region that holds a call's borrowing result is rejected there first ("the initializer of global 'c' holds the result of 'id', which borrows its argument"); the two negatives now pin that message. They stay mortal all the same: `findImmortalGlobals` runs first and still refuses them. + Main's two other global checks step aside for an immortal global's read: a global may be given + one (#636, `let e = c`), and one may be lent to a call that may drop (#639; a static's one store + in its static constructor is in `writtenGlobals`, so `lend(b, K.w)` was rejected after the merge + - `own_immortal_global` now runs it). - **Only the global's own block.** `K.n.c = x` still destroys the old `c`, and a borrow of `K.n.c` stays bounded (`own_err_immortal_field_overwritten`). - **Inference.** `own_facts::isImmortalGlobalRead` is a `ts.Load` of a `ts.AddressOf` the pass diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index e1a5404ad..2e0e84c02 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -5530,7 +5530,10 @@ class OwnershipSignaturePass : public mlir::PassWrappergetLoc(), argument.getType())) + // an immortal global (spec 25) is written once, by its initializer (a static's is a + // store in its static constructor, which writtenGlobals counts), and no release + // frees its block + if (isHandle(argument) || isImmortalGlobalRead(argument) || !ownsHeap(call.op->getLoc(), argument.getType())) { continue; } diff --git a/tslang/test/tester/own/own_immortal_global.ts b/tslang/test/tester/own/own_immortal_global.ts index b8a21a560..37de9769a 100644 --- a/tslang/test/tester/own/own_immortal_global.ts +++ b/tslang/test/tester/own/own_immortal_global.ts @@ -17,9 +17,15 @@ class H { v: V; constructor() { this.v = K.w; } } // stored into a field function g(n: number) { return n > 1 ? K.w : new V(n); } // immortal or fresh +class Box { v: V = new V(0); } +function lend(b: Box, v: V) { b.v = new V(v.x + 1); return v.x; } // may drop: frees b's old v + function main() { const h = new H(); print(K.f().x, K.d.x, h.v.x, g(2).x, g(0).x); // 1 1 1 1 0 + const b = new Box(); + print(lend(b, K.w), b.v.x); // lent to a call that drops: 1 2 + assert(K.w.x == 1 && b.v.x == 2); let s: number = 0; for (let i = 0; i < 100000; i++) { s += g(i % 3).x; } // fresh ones are freed print(s); // 66666 From 765b5932c5d07476087a59df3bfe4d53c49de4b3 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Fri, 23 Oct 2026 01:15:53 +0100 Subject: [PATCH 51/57] own-raytrace T9: a direct call's result is not a use of a borrow given to it The ray tracer's last error under -mm=own (G8): render's `getPoint(x, y, scene.camera)` makes a fresh vector, but walkBorrowed took every call result as pointing into the call's arguments, so the vector's later uses were uses of the borrow `scene.camera`, and `Color.toDrawingColor` (which builds and calls a closure, so may drop) ended that borrow first. A direct call of a function this module defines (isFresh) now ends the walk at the call, as a call with `__own_fresh_result` already did: its result points into nothing it was given but what its facts name (a borrow of an argument, a borrowing record, a generator's state that borrows its arguments - each still walked), and a body whose callers cannot know such a fact is an error. Tests: own_fresh_call_result (own_test + rc/gc/none); own_raytrace, the own copy of the benchmark at 64x64 (checksum 606318 under gc, none, rc and own; under own through the JIT entry and `--emit=exe`, own-exe-run.cmake, since its exports open its interfaces in an object file); negatives own_err_call_result_bounded / _record. Tooth: with the gate dropped the bounded negative compiles and crashes (139). Co-Authored-By: Claude Opus 5.5 --- .../lib/TypeScript/OwnershipInferencePass.cpp | 12 + tslang/test/tester/CMakeLists.txt | 30 +- tslang/test/tester/own-exe-run.cmake | 39 +++ .../tester/own/own_err_call_result_bounded.ts | 56 +++ .../tester/own/own_err_call_result_record.ts | 56 +++ .../test/tester/own/own_fresh_call_result.ts | 72 ++++ tslang/test/tester/own/own_raytrace.ts | 318 ++++++++++++++++++ 7 files changed, 582 insertions(+), 1 deletion(-) create mode 100644 tslang/test/tester/own-exe-run.cmake create mode 100644 tslang/test/tester/own/own_err_call_result_bounded.ts create mode 100644 tslang/test/tester/own/own_err_call_result_record.ts create mode 100644 tslang/test/tester/own/own_fresh_call_result.ts create mode 100644 tslang/test/tester/own/own_raytrace.ts diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index 3038bd76e..52da4a209 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -2607,6 +2607,18 @@ class OwnershipInferencePass continue; } + // So does a direct call of a function this module defines (isFresh): its result points + // into nothing it was given but what its facts name - a borrow of an argument + // (`__own_result_borrows`, never fresh), a borrowing record, a generator's state that + // borrows its arguments - and a body whose callers cannot all know such a fact is an + // error. `getPoint(x, scene.camera)` makes a new vector: a call after it that may + // destroy `scene.camera` does not end that vector (spec 34). + if (isCall(user) && user->getNumResults() == 1 && !user->hasAttr(OWN_RESULT_BOUNDED_ATTR_NAME) && + !isRecordResult(user) && isFresh(user->getResult(0))) + { + continue; + } + // A result that borrows what it was given borrows this too, and is walked as it. for (auto result : user->getResults()) { diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 212488b6e..5cd8ada54 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3511,7 +3511,7 @@ foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local o own_field_chain_let own_read_after_move own_conditional_move own_interface_facts own_declared_scalar_no_drops own_borrowing_record own_borrowing_record_arg - own_reduce) + own_reduce own_fresh_call_result) 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. @@ -3593,6 +3593,32 @@ foreach(reduce_mm rc gc none) tslang_add_test(NAME test-compile-${reduce_mm}-own_reduce COMMAND test-runner -mm=${reduce_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_reduce.ts") endforeach() +# A direct call's result is the caller's own, not a use of a borrow given to it (spec 34); the same +# program under every other model. +foreach(fresh_call_mm rc gc none) + tslang_add_test(NAME test-jit-${fresh_call_mm}-own_fresh_call_result COMMAND test-runner -jit -mm=${fresh_call_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_fresh_call_result.ts") + tslang_add_test(NAME test-compile-${fresh_call_mm}-own_fresh_call_result COMMAND test-runner -mm=${fresh_call_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_fresh_call_result.ts") +endforeach() + +# The ray tracer, own's copy of the benchmark at 64x64 (spec 34), under every model. Its surfaces are +# Shared handles, which count, so it is not in own_test (its no-counting check). It exports its +# `Surfaces` globals, which an object file opens to other modules (spec 31), so under own the JIT's +# entry module and `tslang --emit=exe` build it (own-exe-run.cmake), not test-runner's object file. +foreach(raytrace_mm gc none rc) + tslang_add_test(NAME test-jit-${raytrace_mm}-own_raytrace COMMAND test-runner -jit -mm=${raytrace_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_raytrace.ts") + tslang_add_test(NAME test-compile-${raytrace_mm}-own_raytrace COMMAND test-runner -mm=${raytrace_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_raytrace.ts") +endforeach() +tslang_add_test(NAME test-jit-own-own_raytrace COMMAND test-runner -jit -mm=own "${PROJECT_SOURCE_DIR}/test/tester/own/own_raytrace.ts") +add_test(NAME test-compile-own-own_raytrace + COMMAND ${CMAKE_COMMAND} + "-DTSLANG=$" + "-DSOURCE=${PROJECT_SOURCE_DIR}/test/tester/own/own_raytrace.ts" + "-DEXPECT=checksum: 606318" + "-DWORK_DIR=${CMAKE_CURRENT_BINARY_DIR}/own-raytrace" + "-DLLVM_LIB=${LLVM_LIBRARY_DIR}" + "-DTSLANG_LIB=${CMAKE_BINARY_DIR}/lib" + -P "${CMAKE_CURRENT_SOURCE_DIR}/own-exe-run.cmake") + # An executable's exports leave its interfaces closed (spec 31, USER decision): under own the JIT's # entry module and `tslang --emit=exe` compile own_interface_exe_exports; an object file and a DLL # do not (own-exe-exports.cmake). test-runner's own AOT build is an object file, so it is not run. @@ -3872,6 +3898,8 @@ set(own_error_cases "own_err_kept_and_borrowed_arg_assigned_in_call|is used here after its value was moved" "own_err_kept_arg_assigned_on_arm|is used here after its value was moved" "own_err_kept_arg_assigned_in_and|is used here after its value was moved" + "own_err_call_result_bounded|borrows a field but is used here after it may be released or overwritten" + "own_err_call_result_record|borrows a field but is used here after it may be released or overwritten" "own_err_param_returned_exported|is a parameter, returned here, but -mm=own cannot tell the callers of 'identity' that the result borrows it" "own_err_param_kept_exported|is a parameter, kept here, but -mm=own cannot make the callers of 'Animal.constructor' give it up" "own_err_param_assigned|is a parameter and cannot be assigned under -mm=own yet" diff --git a/tslang/test/tester/own-exe-run.cmake b/tslang/test/tester/own-exe-run.cmake new file mode 100644 index 000000000..9b28f57c7 --- /dev/null +++ b/tslang/test/tester/own-exe-run.cmake @@ -0,0 +1,39 @@ +# Builds a program under -mm=own with `tslang --emit=exe` and runs it: the output must match EXPECT +# and end with "done.". For a program that exports from its root, whose exports only an executable +# keeps from other modules (own spec 31, USER decision): test-runner builds an object file with +# --entry-point and links it itself, which another module could import, so it cannot build one. + +cmake_minimum_required(VERSION 3.17.3) + +foreach(var TSLANG SOURCE EXPECT WORK_DIR LLVM_LIB TSLANG_LIB) + if(NOT DEFINED ${var}) + message(FATAL_ERROR "${var} is required") + endif() +endforeach() + +file(REMOVE_RECURSE "${WORK_DIR}") +file(MAKE_DIRECTORY "${WORK_DIR}") + +execute_process( + COMMAND "${TSLANG}" --emit=exe -mm=own --no-default-lib --opt --opt_level=3 + "--llvm-lib-path=${LLVM_LIB}" "--tslang-lib-path=${TSLANG_LIB}" + "${SOURCE}" -o=program.exe + WORKING_DIRECTORY "${WORK_DIR}" + OUTPUT_VARIABLE out + ERROR_VARIABLE err + RESULT_VARIABLE status) +if(NOT status EQUAL 0) + message(FATAL_ERROR "--emit=exe -mm=own: exit ${status}: ${out}${err}") +endif() + +execute_process( + COMMAND "${WORK_DIR}/program.exe" + WORKING_DIRECTORY "${WORK_DIR}" + OUTPUT_VARIABLE out + ERROR_VARIABLE err + RESULT_VARIABLE status) +if(NOT status EQUAL 0 OR NOT out MATCHES "${EXPECT}" OR NOT out MATCHES "done\\.") + message(FATAL_ERROR "the program: exit ${status}, expected '${EXPECT}' and 'done.': ${out}${err}") +endif() + +message(STATUS "${out}") diff --git a/tslang/test/tester/own/own_err_call_result_bounded.ts b/tslang/test/tester/own/own_err_call_result_bounded.ts new file mode 100644 index 000000000..e7ae1fa33 --- /dev/null +++ b/tslang/test/tester/own/own_err_call_result_bounded.ts @@ -0,0 +1,56 @@ +// A generator's state borrows what it was made from (spec 34): unlike the result of another call of +// a function this module defines, it points into its argument, so iterating it is a use of the +// borrow `s.cam`, and `replace` may free that block first. +class V { + constructor(public x: number) {} +} + +class Cam { + constructor(public f: V) {} +} + +class Scene { + constructor(public cam: Cam) {} +} + +@noinline +function makeScene(n: number) { + return new Scene(new Cam(new V(n))); +} + +function replace(s: Scene) { + s.cam = new Cam(new V(2)); +} + +function churn() { + const junk: V[] = []; + for (let i = 0; i < 50; i++) { + junk.push(new V(999)); + } + + return junk.length; +} + +function* xs(c: Cam) { + yield c.f.x; +} + +function total(s: Scene) { + const it = xs(s.cam); + replace(s); + churn(); + let t: number = 0; + for (const v of it) { + t += v; + } + + return t; +} + +function main() { + const s = makeScene(5); + const t = total(s); + print(t); + assert(t == 5); + print("done."); +} diff --git a/tslang/test/tester/own/own_err_call_result_record.ts b/tslang/test/tester/own/own_err_call_result_record.ts new file mode 100644 index 000000000..98800e214 --- /dev/null +++ b/tslang/test/tester/own/own_err_call_result_record.ts @@ -0,0 +1,56 @@ +// A borrowing record (spec 32) returned by a call points into the arguments it was made from +// (spec 34): unlike the result of another call of a function this module defines, a use of it is a +// use of the borrow `s.cam`, and `replace` may free that block first. +class V { + constructor(public x: number) {} +} + +class Cam { + constructor(public f: V) {} +} + +class Scene { + constructor(public cam: Cam) {} +} + +interface Hit { + v: V; + n: number; +} + +@noinline +function makeScene(n: number) { + return new Scene(new Cam(new V(n))); +} + +function replace(s: Scene) { + s.cam = new Cam(new V(2)); +} + +function churn() { + const junk: V[] = []; + for (let i = 0; i < 50; i++) { + junk.push(new V(999)); + } + + return junk.length; +} + +function hit(c: Cam): Hit { + return { v: c.f, n: 1 }; +} + +function total(s: Scene) { + const h = hit(s.cam); + replace(s); + churn(); + return h.v.x + h.n; +} + +function main() { + const s = makeScene(5); + const t = total(s); + print(t); + assert(t == 6); + print("done."); +} diff --git a/tslang/test/tester/own/own_fresh_call_result.ts b/tslang/test/tester/own/own_fresh_call_result.ts new file mode 100644 index 000000000..bc2ce011c --- /dev/null +++ b/tslang/test/tester/own/own_fresh_call_result.ts @@ -0,0 +1,72 @@ +// A call of a function this module defines hands back a block of the caller's own (spec 34): its +// result points into nothing it was given but what its facts name. So the vector `getPoint(x, +// scene.cam)` makes - a closure's call, a function's, one inside a record given to a call - is not +// a use of the borrow `scene.cam`, and `toDrawing`, which may destroy anything (it builds and calls a +// closure), may come between the call and the vector's last use. The ray tracer's `render` (G8). +class V { + constructor(public x: number) {} +} + +class Cam { + constructor(public f: V, public r: V) {} +} + +interface Scene { + cam: Cam; +} + +interface Ray { + start: V; + dir: V; +} + +function toDrawing(c: V) { + let legalize = (d: number) => d > 100 ? 100 : d; + return { r: legalize(c.x) }; +} + +function churn() { + const junk: V[] = []; + for (let i = 0; i < 50; i++) { + junk.push(new V(999)); + } + + return junk.length; +} + +function direct(cam: Cam, k: number) { + return new V(cam.f.x + k); +} + +class Tracer { + private trace(ray: Ray) { + return new V(ray.start.x + ray.dir.x); + } + + render(scene: Scene, n: number) { + let sum: number = 0; + const getPoint = (x: number, cam: Cam) => { + const recenter = (x: number) => x - n / 2; + return new V(cam.f.x + cam.r.x * recenter(x)); + }; + + for (let x = 0; x < n; x++) { + const p = getPoint(x, scene.cam); + const q = direct(scene.cam, x); + const t = this.trace({ start: scene.cam.f, dir: getPoint(x, scene.cam) }); + const c = toDrawing(p); + churn(); + sum += c.r + p.x + q.x + t.x; + } + + return sum; + } +} + +function main() { + const tracer = new Tracer(); + const sum = tracer.render({ cam: new Cam(new V(3), new V(2)) }, 8); + print("sum:", sum); + assert(sum == 124); + print("done."); +} diff --git a/tslang/test/tester/own/own_raytrace.ts b/tslang/test/tester/own/own_raytrace.ts new file mode 100644 index 000000000..200899484 --- /dev/null +++ b/tslang/test/tester/own/own_raytrace.ts @@ -0,0 +1,318 @@ +// @strict-null false +// The ray tracer under -mm=own (spec 34): benchmarks/raytrace/rtbench_own.ts - rtbench.ts with its +// two source edits for own (the surfaces are `Shared` handles; `Plane.normal` returns a +// fresh vector instead of its own field) - rendered at 64x64. Every model prints the same checksum. +declare function sqrt(v: number): number; +declare function floor(v: number): number; +declare function pow(v: number, s: number): number; +declare function fabs(v: number): number; + +class Math { + static sqrt(v: number): number { + return sqrt(v); + } + + static floor(v: number): number { + return floor(v); + } + + static pow(v: number, s: number): number { + return pow(v, s); + } + + static abs(v: number): number { + return fabs(v); + } + + static sign(v: number): number { + if (v > 0) return 1; + if (v < 0) return -1; + return 0; + } +} + +class Vector { + constructor(public x: number, + public y: number, + public z: number) { + } + static times(k: number, v: Vector) { return new Vector(k * v.x, k * v.y, k * v.z); } + static minus(v1: Vector, v2: Vector) { return new Vector(v1.x - v2.x, v1.y - v2.y, v1.z - v2.z); } + static plus(v1: Vector, v2: Vector) { return new Vector(v1.x + v2.x, v1.y + v2.y, v1.z + v2.z); } + static dot(v1: Vector, v2: Vector) { return v1.x * v2.x + v1.y * v2.y + v1.z * v2.z; } + static mag(v: Vector) { return Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z); } + static norm(v: Vector) { + let mag = Vector.mag(v); + let div = (mag === 0) ? Infinity : 1.0 / mag; + return Vector.times(div, v); + } + static cross(v1: Vector, v2: Vector) { + return new Vector(v1.y * v2.z - v1.z * v2.y, + v1.z * v2.x - v1.x * v2.z, + v1.x * v2.y - v1.y * v2.x); + } +} + +class Color { + constructor(public r: number, + public g: number, + public b: number) { + } + static scale(k: number, v: Color) { return new Color(k * v.r, k * v.g, k * v.b); } + static plus(v1: Color, v2: Color) { return new Color(v1.r + v2.r, v1.g + v2.g, v1.b + v2.b); } + static times(v1: Color, v2: Color) { return new Color(v1.r * v2.r, v1.g * v2.g, v1.b * v2.b); } + static white = new Color(1.0, 1.0, 1.0); + static grey = new Color(0.5, 0.5, 0.5); + static black = new Color(0.0, 0.0, 0.0); + static background = Color.black; + static defaultColor = Color.black; + static toDrawingColor(c: Color) { + let legalize = (d: number) => d > 1 ? 1 : d; + return { + r: Math.floor(legalize(c.r) * 255), + g: Math.floor(legalize(c.g) * 255), + b: Math.floor(legalize(c.b) * 255) + } + } +} + +class Camera { + public forward: Vector; + public right: Vector; + public up: Vector; + + constructor(public pos: Vector, lookAt: Vector) { + let down = new Vector(0.0, -1.0, 0.0); + this.forward = Vector.norm(Vector.minus(lookAt, this.pos)); + this.right = Vector.times(1.5, Vector.norm(Vector.cross(this.forward, down))); + this.up = Vector.times(1.5, Vector.norm(Vector.cross(this.forward, this.right))); + } +} + +interface Ray { + start: Vector; + dir: Vector; +} + +interface Intersection { + thing: Thing; + ray: Ray; + dist: number; +} + +interface Surface { + diffuse: (pos: Vector) => Color; + specular: (pos: Vector) => Color; + reflect: (pos: Vector) => number; + roughness: number; +} + +interface Thing { + intersect(ray: Ray): Intersection; + normal(pos: Vector): Vector; + surface: Shared; +} + +interface Light { + pos: Vector; + color: Color; +} + +interface Scene { + things: Thing[]; + lights: Light[]; + camera: Camera; +} + +class Sphere implements Thing { + public radius2: number; + + constructor(public center: Vector, radius: number, public surface: Shared) { + this.radius2 = radius * radius; + } + normal(pos: Vector): Vector { return Vector.norm(Vector.minus(pos, this.center)); } + intersect(ray: Ray): Intersection { + let eo = Vector.minus(this.center, ray.start); + let v = Vector.dot(eo, ray.dir); + let dist = 0.0; + if (v >= 0) { + let disc = this.radius2 - (Vector.dot(eo, eo) - v * v); + if (disc >= 0) { + dist = v - Math.sqrt(disc); + } + } + if (dist === 0) { + return null; + } else { + return { thing: this, ray: ray, dist: dist }; + } + } +} + +class Plane implements Thing { + constructor(public norm: Vector, public offset: number, public surface: Shared) { + } + public normal(pos: Vector): Vector { return new Vector(this.norm.x, this.norm.y, this.norm.z); } + public intersect(ray: Ray): Intersection { + let denom = Vector.dot(this.norm, ray.dir); + if (denom > 0) { + return null; + } else { + let dist = (Vector.dot(this.norm, ray.start) + this.offset) / (-denom); + return { thing: this, ray: ray, dist: dist }; + } + } +} + +module Surfaces { + export let shiny = new Shared({ + diffuse: function (pos: Vector) { return Color.white; }, + specular: function (pos: Vector) { return Color.grey; }, + reflect: function (pos: Vector) { return 0.7; }, + roughness: 250.0 + }); + export let checkerboard = new Shared({ + diffuse: function (pos: Vector) { + if ((Math.floor(pos.z) + Math.floor(pos.x)) % 2 !== 0) { + return Color.white; + } else { + return Color.black; + } + }, + specular: function (pos: Vector) { return Color.white; }, + reflect: function (pos: Vector) { + if ((Math.floor(pos.z) + Math.floor(pos.x)) % 2 !== 0) { + return 0.1; + } else { + return 0.7; + } + }, + roughness: 150.0 + }); +} + + +class RayTracer { + private maxDepth = 5; + + private intersections(ray: Ray, scene: Scene) { + let closest = +Infinity; + let closestInter: Intersection = undefined; + for (let i in scene.things) { + let inter = scene.things[i].intersect(ray); + if (inter != null && inter.dist < closest) { + closestInter = inter; + closest = inter.dist; + } + } + + return closestInter; + } + + private testRay(ray: Ray, scene: Scene) { + let isect = this.intersections(ray, scene); + if (isect != null) { + return isect.dist; + } else { + return undefined; + } + } + + private traceRay(ray: Ray, scene: Scene, depth: number): Color { + let isect = this.intersections(ray, scene); + if (isect === undefined) { + return Color.background; + } else { + return this.shade(isect, scene, depth); + } + } + + private shade(isect: Intersection, scene: Scene, depth: number) { + let d = isect.ray.dir; + let pos = Vector.plus(Vector.times(isect.dist, d), isect.ray.start); + let normal = isect.thing.normal(pos); + let reflectDir = Vector.minus(d, Vector.times(2, Vector.times(Vector.dot(normal, d), normal))); + let naturalColor = Color.plus(Color.background, + this.getNaturalColor(isect.thing, pos, normal, reflectDir, scene)); + let reflectedColor = (depth >= this.maxDepth) ? Color.grey : this.getReflectionColor(isect.thing, pos, normal, reflectDir, scene, depth); + return Color.plus(naturalColor, reflectedColor); + } + + private getReflectionColor(thing: Thing, pos: Vector, normal: Vector, rd: Vector, scene: Scene, depth: number) { + return Color.scale(thing.surface.value.reflect(pos), this.traceRay({ start: pos, dir: rd }, scene, depth + 1)); + } + + private getNaturalColor(thing: Thing, pos: Vector, norm: Vector, rd: Vector, scene: Scene) { + const addLight = (col: Color, light: Light) => { + let ldis = Vector.minus(light.pos, pos); + let livec = Vector.norm(ldis); + let neatIsect = this.testRay({ start: pos, dir: livec }, scene); + let isInShadow = (neatIsect === undefined) ? false : (neatIsect <= Vector.mag(ldis)); + if (isInShadow) { + return col; + } else { + let illum = Vector.dot(livec, norm); + let lcolor = (illum > 0) ? Color.scale(illum, light.color) + : Color.defaultColor; + let specular = Vector.dot(livec, Vector.norm(rd)); + let scolor = (specular > 0) ? Color.scale(Math.pow(specular, thing.surface.value.roughness), light.color) + : Color.defaultColor; + return Color.plus(col, Color.plus(Color.times(thing.surface.value.diffuse(pos), lcolor), + Color.times(thing.surface.value.specular(pos), scolor))); + } + } + + return scene.lights.reduce(addLight, Color.defaultColor); + } + + render(scene: Scene, screenWidth: number, screenHeight: number) { + let sum: number = 0; + const getPoint = (x: number, y: number, camera: Camera) => { + const recenterX = (x: number) => (x - (screenWidth / 2.0)) / 2.0 / screenWidth; + const recenterY = (y: number) => - (y - (screenHeight / 2.0)) / 2.0 / screenHeight; + return Vector.norm(Vector.plus(camera.forward, Vector.plus(Vector.times(recenterX(x), camera.right), Vector.times(recenterY(y), camera.up)))); + } + + for (let y = 0; y < screenHeight; y++) { + for (let x = 0; x < screenWidth; x++) { + let color = this.traceRay({ start: scene.camera.pos, dir: getPoint(x, y, scene.camera) }, scene, 0); + let c = Color.toDrawingColor(color); + sum += c.r + c.g + c.b; + //ctx.fillStyle = "rgb(" + String(c.r) + ", " + String(c.g) + ", " + String(c.b) + ")"; + //ctx.fillRect(x, y, x + 1, y + 1); + } + } + return sum; + } +} + + +function defaultScene(): Scene { + return { + things: [new Plane(new Vector(0.0, 1.0, 0.0), 0.0, Surfaces.checkerboard), + new Sphere(new Vector(0.0, 1.0, -0.25), 1.0, Surfaces.shiny), + new Sphere(new Vector(-6, 3.0, -5.0), 1.0, Surfaces.shiny), + // new Sphere(new Vector(-6, 8.0, 15.0), 5.0, Surfaces.shiny), + new Sphere(new Vector(-1.0, 0.5, 1.5), 0.5, Surfaces.shiny)], + + lights: [{ pos: new Vector(-2.0, 2.5, 0.0), color: new Color(0.49, 0.07, 0.07) }, + { pos: new Vector(1.5, 2.5, 1.5), color: new Color(0.07, 0.07, 0.49) }, + { pos: new Vector(1.5, 2.5, -1.5), color: new Color(0.07, 0.49, 0.071) }, + { pos: new Vector(0.0, 3.5, 0.0), color: new Color(0.21, 0.21, 0.35) }], + camera: new Camera(new Vector(3.0, 2.0, 4.0), new Vector(-1.0, 0.5, 0.0)) + }; +} + +function exec() { + let rayTracer = new RayTracer(); + let sum = rayTracer.render(defaultScene(), 64, 64); + print("checksum:", sum); + assert(sum == 606318); +} + + +function main() { + print("start..."); + exec(); + print("done."); +} From 2dfc296f92c216c0b9a7fc48ece9d2443da01f30 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Fri, 23 Oct 2026 01:16:03 +0100 Subject: [PATCH 52/57] own-raytrace T9: the ray tracer benchmark under own rtbench_own.ts is rtbench.ts with the plan's two source edits (the surfaces are Shared handles; Plane.normal returns a fresh vector), at 512x512; bench.ps1 builds it for -mm=own, with and without stack promotion. The "does not compile" rows of both READMEs become 628 ms / 4.5 MB (627 ms without stack promotion; rc 638 ms / 4.5 MB in the same session, Windows, median of 7, two runs of bench.ps1). Spec section 34 records G8's cause and fix, the tests, the tooth, the measurements and the corpus (525 -> 526). Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +- tslang/benchmarks/raytrace/README.md | 18 +- tslang/benchmarks/raytrace/bench.ps1 | 9 +- tslang/benchmarks/raytrace/rtbench_own.ts | 318 ++++++++++++++++++ .../2026-09-24-own-memory-model-design.md | 97 ++++++ 5 files changed, 439 insertions(+), 9 deletions(-) create mode 100644 tslang/benchmarks/raytrace/rtbench_own.ts diff --git a/README.md b/README.md index 7b881a956..e98b0ab15 100644 --- a/README.md +++ b/README.md @@ -768,7 +768,7 @@ reference-counted on the heap the way tslang allocates them: | `-mm=gc` | 1298 ms | 6 MB | 26x slower | | `-mm=rc` | 599 ms | 4 MB | 12x slower | | `-mm=none` | 703 ms | 845 MB | 14x slower | -| `-mm=own` | does not compile yet | | | +| `-mm=own` | 628 ms | 4.5 MB | 12x slower | | C++, values (clang / MSVC) | 51 / 69 ms | | | | Rust, values (`Copy` vectors and colors) | 55 ms | 4 MB | 1.1x slower | | Rust, every object in an `Rc` | 492 ms | 4 MB | 9.7x slower | @@ -779,6 +779,10 @@ Rust that allocates the same way is only 1.2x faster than `-mm=rc`, and C++ with (atomic counts) is 1.4x slower than it. `-mm=none` is slower than `-mm=rc` because it never frees and keeps touching fresh pages. Stack promotion makes no measurable difference here yet: the hot allocations are returned from functions or captured by closures. +`-mm=own` builds the benchmark's copy with two source edits (the shared surfaces are `Shared` +handles, and one method returns a fresh vector instead of its own field). It runs as fast as +`-mm=rc` in as little memory, counting only those two handles (its row measured 2026-10-23, when +`-mm=rc` took 638 ms and peaked at 4.5 MB). ### `-mm=own` diff --git a/tslang/benchmarks/raytrace/README.md b/tslang/benchmarks/raytrace/README.md index 8fe964426..f2e4d5aed 100644 --- a/tslang/benchmarks/raytrace/README.md +++ b/tslang/benchmarks/raytrace/README.md @@ -1,6 +1,9 @@ # Ray tracer: memory models vs Rust and C++ `rtbench.ts` is `test/tester/tests/raytrace.ts` at 512x512 with a pixel checksum (see its header). +`rtbench_own.ts` is the same program for `-mm=own`, with two source edits: the surfaces, which every +thing shares, are `Shared` handles, and `Plane.normal` returns a fresh vector instead of +its own field. `rust/` and `cpp/` each hold two ports of the same algorithm: - `rust/src/bin/idiomatic.rs`, `cpp/idiomatic.cpp`: `Vector`/`Color` are plain values, things are @@ -19,7 +22,7 @@ pwsh tslang/benchmarks/raytrace/bench.ps1 ``` - tslang: `--emit=exe --opt --opt_level=3 --no-default-lib --mm=` (`--no-default-lib` - because the file declares its own `sqrt`/`floor`/`pow`/`fabs`) + because the file declares its own `sqrt`/`floor`/`pow`/`fabs`); `-mm=own` builds `rtbench_own.ts` - Rust: `opt-level=3`, `lto="fat"`, `codegen-units=1`, `panic="abort"` - C++: `clang++ -std=c++20 -O3`, and `cl /std:c++20 /O2 /EHsc /MT` @@ -36,7 +39,8 @@ median of 7 runs after a warm-up. Every build prints the same checksum. | tslang rc, `--no-stack-promotion` | 600 ms | | | | tslang none | 703 ms | 845 MB | 14x | | tslang none, `--no-stack-promotion` | 727 ms | | | -| tslang own | does not compile | | | +| tslang own | 628 ms | 4.5 MB | 12x | +| tslang own, `--no-stack-promotion` | 627 ms | | | | Rust idiomatic | 55 ms | 4 MB | 1.1x | | Rust Rc-boxed | 492 ms | 4 MB | 9.7x | | C++ idiomatic (clang) | 51 ms | | 1x | @@ -52,5 +56,11 @@ median of 7 runs after a warm-up. Every build prints the same checksum. under rc keeps every hot-path allocation on the heap: `Vector.*`/`Color.*` results are "returned", the `Ray`/`Intersection` literals are "converted" (to the interface) or "recursive", and the `addLight`/`getPoint` closures are "counted-capture". -- `-mm=own` reports about 44 errors: the program shares the `Surfaces` globals, keeps owned values - in object literals, and aliases `Color.black` from two statics. +- The own rows were measured on 2026-10-23 (branch own-raytrace, the same machine, two runs of + `bench.ps1`, median of 7 each; both gave 627.5 ms), the rest of the table on 2026-10-21. In the + same session rc took 638-640 ms and peaked at 4.5 MB as well, so own runs as fast as rc and frees + as promptly, with no counts: only the two `Shared` handles count. Peak working set is the + process's `PeakWorkingSet64`, polled while it runs. +- `-mm=own` needs `rtbench_own.ts`'s two edits: the surfaces are shared by every thing, and + `Plane.normal` returned a borrow of its own field where `Sphere.normal` returns a fresh vector, so + a caller through the `Thing` interface could not know whether it owns the result. diff --git a/tslang/benchmarks/raytrace/bench.ps1 b/tslang/benchmarks/raytrace/bench.ps1 index 931408bcb..88c4da472 100644 --- a/tslang/benchmarks/raytrace/bench.ps1 +++ b/tslang/benchmarks/raytrace/bench.ps1 @@ -1,6 +1,6 @@ -# Builds rtbench.ts under every memory model (with and without stack promotion) and the two Rust -# ports, checks that they all print the same checksum, then times each one: a warm-up run and -# $Runs timed runs, reporting min/median/max wall time. +# Builds rtbench.ts under every memory model (with and without stack promotion; -mm=own builds its +# copy rtbench_own.ts) and the Rust and C++ ports, checks that they all print the same checksum, +# then times each one: a warm-up run and $Runs timed runs, reporting min/median/max wall time. # # pwsh tslang/benchmarks/raytrace/bench.ps1 [-Tslang ] [-Runs 7] param( @@ -19,7 +19,8 @@ foreach ($mm in "gc", "rc", "none", "own") { $exe = Join-Path $out ("rtb_$mm" + $(if ($sp) { "_nsp" } else { "" }) + ".exe") $flags = @("--emit=exe", "--opt", "--opt_level=3", "--no-default-lib", "--mm=$mm") if ($sp) { $flags += "--no-stack-promotion" } - & $Tslang @flags "$PSScriptRoot\rtbench.ts" -o $exe 2>&1 | Out-Null + $src = if ($mm -eq "own") { "$PSScriptRoot\rtbench_own.ts" } else { "$PSScriptRoot\rtbench.ts" } + & $Tslang @flags $src -o $exe 2>&1 | Out-Null if ($LASTEXITCODE -ne 0) { "{0,-22} does not compile (exit {1})" -f $name, $LASTEXITCODE; continue } $bins += @{ n = $name; p = $exe } } diff --git a/tslang/benchmarks/raytrace/rtbench_own.ts b/tslang/benchmarks/raytrace/rtbench_own.ts new file mode 100644 index 000000000..e22da49d7 --- /dev/null +++ b/tslang/benchmarks/raytrace/rtbench_own.ts @@ -0,0 +1,318 @@ +// @strict-null false +// rtbench.ts for -mm=own, with its two source edits: the surfaces, which every thing shares, are +// `Shared` handles, and `Plane.normal` returns a fresh vector, as `Sphere.normal` does, +// instead of its own field. Otherwise the same program and the same checksum (38837369). +// print() shows the sum with 6 significant digits, so the low digits are printed separately. +declare function sqrt(v: number): number; +declare function floor(v: number): number; +declare function pow(v: number, s: number): number; +declare function fabs(v: number): number; + +class Math { + static sqrt(v: number): number { + return sqrt(v); + } + + static floor(v: number): number { + return floor(v); + } + + static pow(v: number, s: number): number { + return pow(v, s); + } + + static abs(v: number): number { + return fabs(v); + } + + static sign(v: number): number { + if (v > 0) return 1; + if (v < 0) return -1; + return 0; + } +} + +class Vector { + constructor(public x: number, + public y: number, + public z: number) { + } + static times(k: number, v: Vector) { return new Vector(k * v.x, k * v.y, k * v.z); } + static minus(v1: Vector, v2: Vector) { return new Vector(v1.x - v2.x, v1.y - v2.y, v1.z - v2.z); } + static plus(v1: Vector, v2: Vector) { return new Vector(v1.x + v2.x, v1.y + v2.y, v1.z + v2.z); } + static dot(v1: Vector, v2: Vector) { return v1.x * v2.x + v1.y * v2.y + v1.z * v2.z; } + static mag(v: Vector) { return Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z); } + static norm(v: Vector) { + let mag = Vector.mag(v); + let div = (mag === 0) ? Infinity : 1.0 / mag; + return Vector.times(div, v); + } + static cross(v1: Vector, v2: Vector) { + return new Vector(v1.y * v2.z - v1.z * v2.y, + v1.z * v2.x - v1.x * v2.z, + v1.x * v2.y - v1.y * v2.x); + } +} + +class Color { + constructor(public r: number, + public g: number, + public b: number) { + } + static scale(k: number, v: Color) { return new Color(k * v.r, k * v.g, k * v.b); } + static plus(v1: Color, v2: Color) { return new Color(v1.r + v2.r, v1.g + v2.g, v1.b + v2.b); } + static times(v1: Color, v2: Color) { return new Color(v1.r * v2.r, v1.g * v2.g, v1.b * v2.b); } + static white = new Color(1.0, 1.0, 1.0); + static grey = new Color(0.5, 0.5, 0.5); + static black = new Color(0.0, 0.0, 0.0); + static background = Color.black; + static defaultColor = Color.black; + static toDrawingColor(c: Color) { + let legalize = (d: number) => d > 1 ? 1 : d; + return { + r: Math.floor(legalize(c.r) * 255), + g: Math.floor(legalize(c.g) * 255), + b: Math.floor(legalize(c.b) * 255) + } + } +} + +class Camera { + public forward: Vector; + public right: Vector; + public up: Vector; + + constructor(public pos: Vector, lookAt: Vector) { + let down = new Vector(0.0, -1.0, 0.0); + this.forward = Vector.norm(Vector.minus(lookAt, this.pos)); + this.right = Vector.times(1.5, Vector.norm(Vector.cross(this.forward, down))); + this.up = Vector.times(1.5, Vector.norm(Vector.cross(this.forward, this.right))); + } +} + +interface Ray { + start: Vector; + dir: Vector; +} + +interface Intersection { + thing: Thing; + ray: Ray; + dist: number; +} + +interface Surface { + diffuse: (pos: Vector) => Color; + specular: (pos: Vector) => Color; + reflect: (pos: Vector) => number; + roughness: number; +} + +interface Thing { + intersect(ray: Ray): Intersection; + normal(pos: Vector): Vector; + surface: Shared; +} + +interface Light { + pos: Vector; + color: Color; +} + +interface Scene { + things: Thing[]; + lights: Light[]; + camera: Camera; +} + +class Sphere implements Thing { + public radius2: number; + + constructor(public center: Vector, radius: number, public surface: Shared) { + this.radius2 = radius * radius; + } + normal(pos: Vector): Vector { return Vector.norm(Vector.minus(pos, this.center)); } + intersect(ray: Ray): Intersection { + let eo = Vector.minus(this.center, ray.start); + let v = Vector.dot(eo, ray.dir); + let dist = 0.0; + if (v >= 0) { + let disc = this.radius2 - (Vector.dot(eo, eo) - v * v); + if (disc >= 0) { + dist = v - Math.sqrt(disc); + } + } + if (dist === 0) { + return null; + } else { + return { thing: this, ray: ray, dist: dist }; + } + } +} + +class Plane implements Thing { + constructor(public norm: Vector, public offset: number, public surface: Shared) { + } + public normal(pos: Vector): Vector { return new Vector(this.norm.x, this.norm.y, this.norm.z); } + public intersect(ray: Ray): Intersection { + let denom = Vector.dot(this.norm, ray.dir); + if (denom > 0) { + return null; + } else { + let dist = (Vector.dot(this.norm, ray.start) + this.offset) / (-denom); + return { thing: this, ray: ray, dist: dist }; + } + } +} + +module Surfaces { + export let shiny = new Shared({ + diffuse: function (pos: Vector) { return Color.white; }, + specular: function (pos: Vector) { return Color.grey; }, + reflect: function (pos: Vector) { return 0.7; }, + roughness: 250.0 + }); + export let checkerboard = new Shared({ + diffuse: function (pos: Vector) { + if ((Math.floor(pos.z) + Math.floor(pos.x)) % 2 !== 0) { + return Color.white; + } else { + return Color.black; + } + }, + specular: function (pos: Vector) { return Color.white; }, + reflect: function (pos: Vector) { + if ((Math.floor(pos.z) + Math.floor(pos.x)) % 2 !== 0) { + return 0.1; + } else { + return 0.7; + } + }, + roughness: 150.0 + }); +} + + +class RayTracer { + private maxDepth = 5; + + private intersections(ray: Ray, scene: Scene) { + let closest = +Infinity; + let closestInter: Intersection = undefined; + for (let i in scene.things) { + let inter = scene.things[i].intersect(ray); + if (inter != null && inter.dist < closest) { + closestInter = inter; + closest = inter.dist; + } + } + + return closestInter; + } + + private testRay(ray: Ray, scene: Scene) { + let isect = this.intersections(ray, scene); + if (isect != null) { + return isect.dist; + } else { + return undefined; + } + } + + private traceRay(ray: Ray, scene: Scene, depth: number): Color { + let isect = this.intersections(ray, scene); + if (isect === undefined) { + return Color.background; + } else { + return this.shade(isect, scene, depth); + } + } + + private shade(isect: Intersection, scene: Scene, depth: number) { + let d = isect.ray.dir; + let pos = Vector.plus(Vector.times(isect.dist, d), isect.ray.start); + let normal = isect.thing.normal(pos); + let reflectDir = Vector.minus(d, Vector.times(2, Vector.times(Vector.dot(normal, d), normal))); + let naturalColor = Color.plus(Color.background, + this.getNaturalColor(isect.thing, pos, normal, reflectDir, scene)); + let reflectedColor = (depth >= this.maxDepth) ? Color.grey : this.getReflectionColor(isect.thing, pos, normal, reflectDir, scene, depth); + return Color.plus(naturalColor, reflectedColor); + } + + private getReflectionColor(thing: Thing, pos: Vector, normal: Vector, rd: Vector, scene: Scene, depth: number) { + return Color.scale(thing.surface.value.reflect(pos), this.traceRay({ start: pos, dir: rd }, scene, depth + 1)); + } + + private getNaturalColor(thing: Thing, pos: Vector, norm: Vector, rd: Vector, scene: Scene) { + const addLight = (col: Color, light: Light) => { + let ldis = Vector.minus(light.pos, pos); + let livec = Vector.norm(ldis); + let neatIsect = this.testRay({ start: pos, dir: livec }, scene); + let isInShadow = (neatIsect === undefined) ? false : (neatIsect <= Vector.mag(ldis)); + if (isInShadow) { + return col; + } else { + let illum = Vector.dot(livec, norm); + let lcolor = (illum > 0) ? Color.scale(illum, light.color) + : Color.defaultColor; + let specular = Vector.dot(livec, Vector.norm(rd)); + let scolor = (specular > 0) ? Color.scale(Math.pow(specular, thing.surface.value.roughness), light.color) + : Color.defaultColor; + return Color.plus(col, Color.plus(Color.times(thing.surface.value.diffuse(pos), lcolor), + Color.times(thing.surface.value.specular(pos), scolor))); + } + } + + return scene.lights.reduce(addLight, Color.defaultColor); + } + + render(scene: Scene, screenWidth: number, screenHeight: number) { + let sum: number = 0; + const getPoint = (x: number, y: number, camera: Camera) => { + const recenterX = (x: number) => (x - (screenWidth / 2.0)) / 2.0 / screenWidth; + const recenterY = (y: number) => - (y - (screenHeight / 2.0)) / 2.0 / screenHeight; + return Vector.norm(Vector.plus(camera.forward, Vector.plus(Vector.times(recenterX(x), camera.right), Vector.times(recenterY(y), camera.up)))); + } + + for (let y = 0; y < screenHeight; y++) { + for (let x = 0; x < screenWidth; x++) { + let color = this.traceRay({ start: scene.camera.pos, dir: getPoint(x, y, scene.camera) }, scene, 0); + let c = Color.toDrawingColor(color); + sum += c.r + c.g + c.b; + //ctx.fillStyle = "rgb(" + String(c.r) + ", " + String(c.g) + ", " + String(c.b) + ")"; + //ctx.fillRect(x, y, x + 1, y + 1); + } + } + return sum; + } +} + + +function defaultScene(): Scene { + return { + things: [new Plane(new Vector(0.0, 1.0, 0.0), 0.0, Surfaces.checkerboard), + new Sphere(new Vector(0.0, 1.0, -0.25), 1.0, Surfaces.shiny), + new Sphere(new Vector(-6, 3.0, -5.0), 1.0, Surfaces.shiny), + // new Sphere(new Vector(-6, 8.0, 15.0), 5.0, Surfaces.shiny), + new Sphere(new Vector(-1.0, 0.5, 1.5), 0.5, Surfaces.shiny)], + + lights: [{ pos: new Vector(-2.0, 2.5, 0.0), color: new Color(0.49, 0.07, 0.07) }, + { pos: new Vector(1.5, 2.5, 1.5), color: new Color(0.07, 0.07, 0.49) }, + { pos: new Vector(1.5, 2.5, -1.5), color: new Color(0.07, 0.49, 0.071) }, + { pos: new Vector(0.0, 3.5, 0.0), color: new Color(0.21, 0.21, 0.35) }], + camera: new Camera(new Vector(3.0, 2.0, 4.0), new Vector(-1.0, 0.5, 0.0)) + }; +} + +function exec() { + let rayTracer = new RayTracer(); + let sum = rayTracer.render(defaultScene(), 512, 512); + print("checksum:", sum, "low digits:", sum - 38837000); +} + + +function main() { + print("start..."); + exec(); + print("done."); +} 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 50bbe1107..5317d81e6 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 @@ -4025,3 +4025,100 @@ Not here: else (`a.reduce(V.plus, ...)`: a static method is a vtable entry), a parameter that is passed on rather than called, an exported or `@keeps`/`@lends` function: no family, as before. - The default library's `reduce` (design G5: a follow-up). + +## 34. A direct call's result, and the ray tracer (G8 of the ray tracer plan), 2026-10-23 + +After §25-§33 and the merge of main, the ray tracer's own copy +(`benchmarks/raytrace/rtbench_own.ts`: `rtbench.ts` with the plan's two source edits, +`Shared` surfaces and a fresh vector from `Plane.normal`) had one error left, built as a +program: `'this value' borrows a field but is used here after it may be released or overwritten` at +`render`'s `getPoint(x, y, scene.camera)`, with `Color.toDrawingColor(color)` on the next line as +the place it may be released. G8's other items (`Camera`'s constructor, `Sphere.normal`, `shade`'s +ternary, `let d = isect.ray.dir`) were cleared by §28-§33. + +The cause was not the closure. `walkBorrowed` follows a borrow through every value made from it, and +took the result of any call it is given to as one of them, since a result may point into an +argument. So the vector `getPoint` makes was walked as part of the borrow `scene.camera`; its uses +(through the record given to `traceRay`) were uses of that borrow; and `toDrawingColor` - which +builds and calls its `legalize` closure, so it has no `__own_no_drops` - may destroy anything +reachable from a parameter, `scene.camera` among it. The minimal program is `const p = gp(x, +sc.cam); const c = drop(p); use(p)` with any function `gp` that returns a new object and any `drop` +that may destroy. It compiles when nothing after the result may destroy: with `const legalize` in +place of the ray tracer's `let legalize`, `toDrawingColor` has `__own_no_drops`. + +- **A direct call of a function this module defines ends the walk at the call** (`walkBorrowed`, + OwnershipInferencePass), as a call marked `__own_fresh_result` already did (an iterator's + `next()`, another own module's `..new`). The test is `isFresh` of its one result - a + `ts.SymbolCallInternal` or `ts.Invoke` naming a `ts.Func` with a body, with no + `__own_result_borrows` - and the call has neither `__own_result_bounded` (a generator's state that + borrows its arguments) nor `__own_result_record` (a borrowing record, §32): those are still + walked. The call itself is still a use of the borrow for as long as it runs. Sound because such a + body hands back nothing that points into what it was given but what its facts name: returning a + borrow of a parameter, of a part of one, or a closure, bound method, array or record over one is + either a fact its callers see or an error in the body (`borrows a field and cannot be stored, + returned or captured`, or, when its callers cannot all know the fact, §31's body errors). A string + result is a copy (spec 22). +- **Not widened:** a call through a value, through an interface, through a callback family or to a + declared function is still walked (isFresh is false), and so is a method call: a class method is + called through `ts.ThisVirtualSymbolRef`, so `sc.cam.copy()` and `this.point(x, sc.cam)` keep the + error. That is a sound over-rejection: the signature pass resolves those calls, but the inference + does not learn that a resolved call's result is owned (a follow-up). The ray tracer's `getPoint` + is a closure, whose call is a direct call of its lifted function. + +`own_fresh_call_result` reads `scene.cam` into a closure's call, a function's call and a record +given to a method (`{ start: scene.cam.f, dir: getPoint(x, scene.cam) }`), then calls a `toDrawing` +that may destroy, churns, and uses all three results, in a loop; it prints 124 under own, rc, gc and +none, AOT and JIT, with no counting under own. Before this section it had three errors (54:26, +54:32, 51:61). Negatives `own_err_call_result_bounded` (a generator's state made from `s.cam`, +iterated after `replace(s)`) and `own_err_call_result_record` (a borrowing record `{ v: c.f, n: 1 }` +made from `s.cam`, read after `replace(s)`), each through a `@noinline` factory with churn and an +assert; gc, rc and none print 5 and 6. Tooth (a temporary `OWN_TEETH` switch, removed): with every +call's result ending the walk, `own_err_call_result_bounded` compiles and exits 139 (gc 5); +`own_err_call_result_record` stays an error through the record's own bound (`borrows the result of +'hit'`). The positive cannot fail with the rule widened: its results really are fresh. + +Probes (own as an executable with `--opt`, before the rule / after / with the tooth): a lifted +closure returning `sc.cam.f` or a captured borrowing `let`, an exported function returning `c.f`, +one returning a mortal global's field, a callback family with a member returning its argument, a +method returning `this` or `this.f`, a virtual override returning `c.f`, `id(c).f`, a `V | null` of +`c.f`, an `any` holding `c.f`, an element of an array parameter, a function that makes an unrelated +object and returns `c.f`, a `Shared` parameter's `s.value`, an async function returning `c.f` +or, after an `await`, `new V(c.f.x)`: rejected before and after. An async function that reads +`c.f.x` after an `await`, awaited after the drop: accepted before and after, printing what gc +prints (async is outside this rule either way). A generator over `c.f.x`: rejected before and +after; with the tooth it compiles and crashes. Newly accepted, and printing what gc prints: a +function returning `new V(c.f.x)` or `[c.f.x]`, the same called inside a `try` (a `ts.Invoke`), and +a closure called with `sc.cam` in a loop across a call that may destroy. + +**The ray tracer** (`test/tester/own/own_raytrace.ts`: `rtbench_own.ts` at 64x64, ending +`print("checksum:", sum); assert(sum == 606318)`) compiles under own with no errors and prints +checksum 606318, as under gc, rc and none. Built as a program only: it exports `Surfaces.shiny` and +`Surfaces.checkerboard`, which an object file or a DLL opens to other modules (§31, USER decision), +so test-runner's AOT build (an object file it links itself) cannot build it under own. Registered as +`test-jit-own-own_raytrace` (the JIT's entry module counts as a program) and +`test-compile-own-own_raytrace` (`own-exe-run.cmake`: `tslang --emit=exe`, run, match the checksum), +and under gc, none and rc through test-runner, JIT and AOT. Not in `own_test`: its `Shared` +handles count (`__tslang_inc_ref`/`__tslang_dec_ref` appear only in the handle's retain and release +routines), as for `own_shared_literal` (§26); for the same reason, and because an +`--emit=mlir-affine` build is not a program, no `--verify-ownership` run. + +Measured (`bench.ps1`, 512x512, Windows 11 x64, the machine otherwise idle; two runs of the script, +median of 7 runs each after a warm-up): own 627.5 ms both times (`--no-stack-promotion` 626.4 and +626.8 ms), rc 640.0 and 637.9 ms in the same runs (gc 1295-1299, none 662-666, Rust idiomatic 58, +Rust `Rc` 515, C++ idiomatic clang 54 ms). Peak working set (`PeakWorkingSet64` polled while the +process runs, three runs each): own 4.5 MB, own without stack promotion 4.5 MB, rc 4.5-4.9 MB, gc +6.5 MB, none 840-846 MB. Linux (WSL, ninja Release): own 0.13 s and 4.5 MB maximum resident set, rc +0.15 s and 4.3 MB, checksum ...369, exit 0, three runs each. Errors left in the own copy: 0. + +Corpus (`test/tester/tests/*.ts` under `-mm=own --no-default-lib`, `--emit=obj`, Windows): 525 of +702 before this section, 526 after: `00owned_any_boxing` gained (`(boxed[0]).length`: the +generated `___unbox`'s result, a copy, was walked as a borrow of the array's element, which +the array's release at the return ends; built under own it prints what gc prints). None lost. + +Not here: +- A method call's result (`o.m(borrow)`, through `ts.ThisVirtualSymbolRef`) is still walked as a + borrow of its arguments, as is an indirect call's. +- The benchmark keeps its two source edits; the unedited `rtbench.ts` does not compile under own (a + non-goal of the plan). +- This section is 34, not 35 as the Task 9 addendum expected: the merge of main left notes in §25 + and §29 and no section of its own. From 50c7112d64d7bc51a236c21fda3ec8afc37c3be6 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Fri, 23 Oct 2026 04:50:58 +0100 Subject: [PATCH 53/57] own-raytrace T1: a global's one store that may run again leaves it mortal findImmortalGlobals accepted a global whose single initializing store sits in a loop - of a static block, or of top-level script code, which is a function a ts.GlobalConstructor runs. "Written once" counted store ops, not how often they run: every block the store was given got the immortal header, and the ReleaseSlot before each store skipped the old one. A million V's of 64 numbers peaked at 589-602 MB under own, 4.4 MB under rc. A leak, not a use-after-free. The store must now run once (mayRunAgain: no enclosing loop op, and its block on no cycle of its region's CFG - after the affine lowering every loop is such a cycle). Such a global is mortal: each store releases the old value, and the probes peak at 4.4 MB. A store in an `if` arm is still accepted. own_global_loop_store (static block loop, top-level while, `if` arm) runs under own/rc/gc/none; test-own-global-loop-store-mortal checks only the `if` arm's global is marked. own_err_global_loop_store_static and _script pin that the loop-written global is mortal: they compiled before. Spec 25: the top-level script sentence corrected, and a fix-up note. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 34 ++++++++++- .../lib/TypeScript/OwnershipSignaturePass.cpp | 57 +++++++++++++++++-- tslang/test/tester/CMakeLists.txt | 15 ++++- .../own/own_err_global_loop_store_script.ts | 20 +++++++ .../own/own_err_global_loop_store_static.ts | 23 ++++++++ .../test/tester/own/own_global_loop_store.ts | 53 +++++++++++++++++ 6 files changed, 192 insertions(+), 10 deletions(-) create mode 100644 tslang/test/tester/own/own_err_global_loop_store_script.ts create mode 100644 tslang/test/tester/own/own_err_global_loop_store_static.ts create mode 100644 tslang/test/tester/own/own_global_loop_store.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 5317d81e6..80fed158a 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 @@ -2810,8 +2810,12 @@ of `K.w` does nothing). the global is checked (`SymbolTable::getSymbolUses`), not only `ts.AddressOf`: a static is also reached through its class's vtable, and `k.w = x` with `k: typeof K` writes it through a `ts.VirtualSymbolRef` that `writtenGlobals` never sees. When any vtable reference is written - through, no static is immortal. A module-level `export let`, an exported class's static, a global - top-level script code assigns, and a global assigned anywhere else stay mortal. + through, no static is immortal. A module-level `export let`, an exported class's static, and a + global assigned anywhere else stay mortal. Top-level script code is itself a function a + `ts.GlobalConstructor` runs, so its one straight-line store into a global (`let c: V;` then + `c = new V(1)` at the top level, or `K.w = new V(1)`) is accepted like a static constructor's. + The store must run once (fix-up of 2026-10-23, below): one in a loop op, or in a block on a cycle + of its function's CFG, leaves the global mortal. - **Which values.** The initializer gives it an object made right there (`ts.New`, an owning literal; none it gives back with a `ts.Release`), nothing, or another immortal global's value (found to a fixpoint). A call's result never qualifies, whatever its callee: the pass decides @@ -2865,6 +2869,32 @@ fail with `0xC0000374` (heap corruption: the first release frees `K.w`, and late releases use the freed block). With the loop at 30 million (20 million fresh `V`s freed), own peaks at 4.3 MB, as rc does. +**Fix-up, 2026-10-23: the store must run once.** "Written once" counted store ops, not how often +they run. A static's one store in a loop of its static block (`static { for (1e6) K.w = new +V(i + 1); }`), or a global's in a loop of top-level script code, marked every block it was given +immortal, and the `ts.ReleaseSlot` before each store skipped the old one: own peaked at 589 MB for +a million `V`s of 64 numbers (static block), 602 MB (`let c: V;` written in a top-level loop) and +600 MB (`K.w` written in a top-level loop), rc at 4.4-4.5 MB. A leak, not a use-after-free: no block +was freed early. `findImmortalGlobals` now refuses a store that may run again (`mayRunAgain`: an +enclosing loop op, kept for safety, or a block on a cycle of its region's CFG, which is what the +affine lowering leaves of every loop). Such a global is mortal: each store releases the value it +replaces, and the three programs peak at 4.4 MB under own. A store in an `if` still runs once and +is still accepted. What only compiled because the global was immortal is now an error, as for any +mortal global: `let e = c` after the top-level loop ("borrows the global 'c' and cannot be stored, +returned or captured", #636), and `let e = K.w` after the static block loop ("the initializer of +global 'e' reads a value another owner keeps"). + +`own_global_loop_store` writes `K.w` in a loop of its static block, `S.w` in a `while` of +top-level code and `T.w` once in an `if` of its static block (returned from `t()`, which only an +immortal global allows), 10000 rounds, under own, rc, gc and none, AOT and JIT; own emits no +counting. `test-own-global-loop-store-mortal` checks its `--emit=mlir-affine`: `T.w`'s +`ts.AddressOf` is marked `__own_immortal`, `K.w`'s and `S.w`'s are not. The module-level form +`let c: V;` is not in the test: a `let` with no initializer warns. Negatives +`own_err_global_loop_store_static` (`return K.w` after a static block loop) and +`own_err_global_loop_store_script` (`let e = K.w` after a top-level loop), "borrows the global +'K.w' and cannot be stored, returned or captured". Teeth (a temporary `OWN_TEETH` switch skipping +`mayRunAgain`, removed): the mortal check fails (`K.w`/`S.w` marked) and both negatives compile. + ## 26. `Shared` of an object literal (G4 of the ray tracer plan), 2026-10-21 The ray tracer's surfaces are module-level `new Shared({ ...function fields... })`. The diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index 2e0e84c02..717076d1e 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -131,11 +131,55 @@ class OwnershipSignaturePass : public mlir::PassWrapper(at); at = at->getParentOp()) + { + if (mlir::isa(at)) + { + return true; + } + + auto *block = at->getBlock(); + if (!block) + { + return true; // not in a function: nothing is known of how often it runs + } + + llvm::SmallPtrSet seen; + llvm::SmallVector work(block->getSuccessors().begin(), block->getSuccessors().end()); + while (!work.empty()) + { + auto *next = work.pop_back_val(); + if (next == block) + { + return true; + } + + if (seen.insert(next).second) + { + work.append(next->getSuccessors().begin(), next->getSuccessors().end()); + } + } + } + + return false; + } + // The immortal globals (OWN_IMMORTAL_GLOBAL_ATTR_NAME, spec 25). A global of a class type, - // not exported, is immortal when it is written once, by its initializer, and given a fresh + // not exported, is immortal when only its initializer writes it, once, and gives it a fresh // object, nothing, or another immortal global's value. Its initializer is either its own region // (`const c = new C()`) or, for a class static, the one store into it in the class's static // constructor, which a `ts.GlobalConstructor` runs (the region then yields only the default). + // Top-level script code is such a constructor too, so a module-level `let c: C;` it stores + // into once is one as well. "Once" is how often the store runs, not how many stores there are: + // a store in a loop (`static { for (...) K.w = new V(i); }`) would mark every block it is given + // immortal, and each old one would leak, so a store that may run again leaves the global mortal, + // and each store releases the old value, as for any other global. // // The store - the region's, which the lowering makes - marks the block HEAP_BLOCK_IMMORTAL, so // every block the global ever holds is one no release frees. The value test makes sure the block @@ -232,11 +276,12 @@ class OwnershipSignaturePass : public mlir::PassWrapper(addressUser); auto funcOp = storeOp ? storeOp->getParentOfType() : mlir_ts::FuncOp(); if (initStore || !storeOp || storeOp.getReference() != addressOfOp.getResult() || - storeOp.getValue() == addressOfOp.getResult() || !funcOp || !constructors.contains(funcOp.getSymName())) + storeOp.getValue() == addressOfOp.getResult() || !funcOp || !constructors.contains(funcOp.getSymName()) || + mayRunAgain(storeOp)) { return; } @@ -5530,9 +5575,9 @@ class OwnershipSignaturePass : public mlir::PassWrappergetLoc(), argument.getType())) { continue; diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 5cd8ada54..606f092bd 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3506,7 +3506,7 @@ foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local o own_declared_no_drops own_return_paths own_string_concat_element own_final_fresh_result own_final_field_lend own_null_narrowing_di own_param_markers own_generator_locals own_string_param_assigned own_lends - own_immortal_global own_immortal_global_region + own_immortal_global own_immortal_global_region own_global_loop_store own_literal_moves own_record_literal_given own_field_chain_let own_read_after_move own_conditional_move own_interface_facts own_declared_scalar_no_drops @@ -3525,12 +3525,21 @@ endforeach() # A global never reassigned is immortal under own (spec 25); the same program under every other model. foreach(immortal_mm rc gc none) - foreach(immortal_test own_immortal_global own_immortal_global_region) + foreach(immortal_test own_immortal_global own_immortal_global_region own_global_loop_store) tslang_add_test(NAME test-jit-${immortal_mm}-${immortal_test} COMMAND test-runner -jit -mm=${immortal_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${immortal_test}.ts") tslang_add_test(NAME test-compile-${immortal_mm}-${immortal_test} COMMAND test-runner -mm=${immortal_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${immortal_test}.ts") endforeach() endforeach() +# A global whose one store runs in a loop is mortal (spec 25): its stores are not marked, so each +# releases the value before it, which an immortal header would leak. The `if` arm's store runs once. +add_test(NAME test-own-global-loop-store-mortal + COMMAND $ --emit=mlir-affine --no-default-lib -mm=own + "${PROJECT_SOURCE_DIR}/test/tester/own/own_global_loop_store.ts") +set_tests_properties(test-own-global-loop-store-mortal + PROPERTIES PASS_REGULAR_EXPRESSION "global_name = @T[.]w}> {__own_immortal}" + FAIL_REGULAR_EXPRESSION "global_name = @[KS][.]w}> {__own_immortal}|error|Stack dump") + # An object literal that owns its fields moves anywhere a fresh object does under own (spec 27); # the same programs under every other model. foreach(owning_record_mm rc gc none) @@ -3913,6 +3922,8 @@ set(own_error_cases "own_err_immortal_field_overwritten|borrows .* but is used here after .* released or overwritten" "own_err_immortal_region_borrow|the initializer of global 'c' holds the result of 'firstOf', which borrows its argument" "own_err_immortal_region_call|the initializer of global 'c' holds the result of 'id', which borrows its argument" + "own_err_global_loop_store_static|borrows the global 'K[.]w' and cannot be stored, returned or captured" + "own_err_global_loop_store_script|borrows the global 'K[.]w' and cannot be stored, returned or captured" "own_err_global_argument_assigned|a value read out of the global 'G' is lent to a call that may release or overwrite it" "own_err_global_method_assigns|a value read out of the global 'G' is lent to a call that may release or overwrite it" "own_err_global_let_call|borrows the global 'names', but -mm=own cannot prove the global keeps its value" diff --git a/tslang/test/tester/own/own_err_global_loop_store_script.ts b/tslang/test/tester/own/own_err_global_loop_store_script.ts new file mode 100644 index 000000000..86293136a --- /dev/null +++ b/tslang/test/tester/own/own_err_global_loop_store_script.ts @@ -0,0 +1,20 @@ +// -mm=own rejects: `K.w`'s one store is in a loop of top-level script code, which runs it once per +// round, so `K.w` is not immortal (spec 25): each store frees the value before it. `e` cannot be +// given the global's value, which `K.w` keeps. +class V { + constructor(public x: number) {} +} + +class K { + static w: V; +} + +for (let i = 0; i < 10; i++) { + K.w = new V(i + 1); +} + +let e = K.w; + +function main() { + print(e.x); +} diff --git a/tslang/test/tester/own/own_err_global_loop_store_static.ts b/tslang/test/tester/own/own_err_global_loop_store_static.ts new file mode 100644 index 000000000..cb0e5987a --- /dev/null +++ b/tslang/test/tester/own/own_err_global_loop_store_static.ts @@ -0,0 +1,23 @@ +// -mm=own rejects: `K.w`'s one store is in a loop of its static block, so it runs once per round and +// `K.w` is not immortal (spec 25): each store frees the value before it. `f` returns a borrow of +// the global. +class V { + constructor(public x: number) {} +} + +class K { + static w: V; + static { + for (let i = 0; i < 10; i++) { + K.w = new V(i + 1); + } + } +} + +function f() { + return K.w; +} + +function main() { + print(f().x); +} diff --git a/tslang/test/tester/own/own_global_loop_store.ts b/tslang/test/tester/own/own_global_loop_store.ts new file mode 100644 index 000000000..a07cd3864 --- /dev/null +++ b/tslang/test/tester/own/own_global_loop_store.ts @@ -0,0 +1,53 @@ +// A global's one store that runs more than once - in a loop of a static block, or of top-level +// script code - leaves the global mortal under -mm=own (spec 25): each store releases the value it +// replaces, as for any other global. Taken as immortal, every value it was ever given would leak. +// A store that runs once, in an `if` of a static block, still makes its global immortal. +class V { + a: number[] = []; + constructor(public x: number) { + for (let i = 0; i < 8; i++) { + this.a.push(i); + } + } +} + +@noinline +function on() { + return true; +} + +class K { + static w: V; + static { + for (let i = 0; i < 10000; i++) { + K.w = new V(i + 1); + } + } +} + +class S { + static w: V; +} + +let i = 0; +while (i < 10000) { + S.w = new V(i + 2); + i++; +} + +class T { + static w: V; + static { + if (on()) { + T.w = new V(3); + } + } +} + +function t() { return T.w; } // immortal: returned + +function main() { + print(K.w.x, K.w.a.length, S.w.x, t().x); // 10000 8 10001 3 + assert(K.w.x == 10000 && S.w.x == 10001 && t().x == 3); + print("done."); +} From 0fd5b9b36c927540a0a652ffa1f7d20a53ae2f3f Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Fri, 23 Oct 2026 04:51:06 +0100 Subject: [PATCH 54/57] own-raytrace merge: negatives for #639's shapes and the region-call refusal d691710e gave the callee-side negatives fresh values, so the original programs' shapes were pinned by no negative. Each is now a #639 negative of its own ("a value read out of the global '...' is lent to a call that may release or overwrite it"): - own_err_global_argument_transitive: run(G), run calls reset, which assigns G - own_err_global_method_transitive: G.m(), m calls reset - own_err_global_this_field_move: GK.run() moves into this.f, then assigns GK - own_err_global_argument_field_let: run(G), const x = h.c, then G assigned - own_err_global_kept_field: keep(h, g) stores into h.c, g assigned by a reset elsewhere own_err_immortal_region_borrow now calls through a function value (`const c = pick()(list)`), whose callee #638 cannot know, so it again shows that findImmortalGlobals keeps a call's result mortal: "borrows the global 'c' and cannot be stored, returned or captured". own_err_immortal_region_call keeps #638's message. Spec 25 and 29 notes. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 17 +++++++++--- tslang/test/tester/CMakeLists.txt | 7 ++++- .../own/own_err_global_argument_field_let.ts | 22 +++++++++++++++ .../own/own_err_global_argument_transitive.ts | 24 +++++++++++++++++ .../tester/own/own_err_global_kept_field.ts | 27 +++++++++++++++++++ .../own/own_err_global_method_transitive.ts | 20 ++++++++++++++ .../own/own_err_global_this_field_move.ts | 23 ++++++++++++++++ .../own/own_err_immortal_region_borrow.ts | 10 +++++-- 8 files changed, 144 insertions(+), 6 deletions(-) create mode 100644 tslang/test/tester/own/own_err_global_argument_field_let.ts create mode 100644 tslang/test/tester/own/own_err_global_argument_transitive.ts create mode 100644 tslang/test/tester/own/own_err_global_kept_field.ts create mode 100644 tslang/test/tester/own/own_err_global_method_transitive.ts create mode 100644 tslang/test/tester/own/own_err_global_this_field_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 80fed158a..518281f18 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 @@ -2828,8 +2828,12 @@ of `K.w` does nothing). into a freed block, and the global would hold it. Since main's #638 (merged into this branch after T8) a global's region calls get their callees' facts (`ownGlobalInitializerCalls`), and a region that holds a call's borrowing result is rejected there first ("the initializer of global - 'c' holds the result of 'id', which borrows its argument"); the two negatives now pin that - message. They stay mortal all the same: `findImmortalGlobals` runs first and still refuses them. + 'c' holds the result of 'id', which borrows its argument"); `own_err_immortal_region_call` now + pins that message. They stay mortal all the same: `findImmortalGlobals` runs first and still + refuses them. `own_err_immortal_region_borrow` (since the fix-up of 2026-10-23) calls through a + function value instead, `const c = pick()(list)`, whose callee #638 cannot know, so it again shows + this refusal: `f`'s `return c` is "borrows the global 'c' and cannot be stored, returned or + captured". Main's two other global checks step aside for an immortal global's read: a global may be given one (#636, `let e = c`), and one may be lent to a call that may drop (#639; a static's one store in its static constructor is in `writtenGlobals`, so `lend(b, K.w)` was rejected after the merge @@ -3165,7 +3169,14 @@ is an error ("a value read out of the global 'G' is lent to a call that may rele it"). That error comes first for `run(G)`, `GK.run()` and `runCallee(G)` too, so since the merge both negatives give their callees fresh values (`run(mkH())`, `new K().run()`) and pin this section's callee-side rule alone; the teeth above were measured with the global passed, before -the merge. +the merge. The original shapes are #639 negatives of their own (fix-up of 2026-10-23), so a +narrower #639 would still meet them: `own_err_global_argument_transitive` (`run(G)`, `run` calls +`reset`, which assigns `G`), `own_err_global_method_transitive` (`G.m()`, `m` calls `reset`), +`own_err_global_this_field_move` (`GK.run()` moves into `this.f`, then assigns `GK`), +`own_err_global_argument_field_let` (`run(G)`, `const x = h.c`, then `G` assigned) and +`own_err_global_kept_field` (`keep(h, g)` stores `c` into `h.c`, `g` assigned by a `reset` +elsewhere), each "a value read out of the global '...' is lent to a call that may release or +overwrite it". Teeth: with the kill walk skipped (`readsThroughOwner` accepting every read), all seven negatives compile, and AOT runs with same-size allocations before the read print a freed block's value: diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 606f092bd..1f4fa2406 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3920,12 +3920,17 @@ set(own_error_cases "own_err_global_returned|borrows the global 'V.def' and cannot be stored, returned or captured" "own_err_immortal_borrowed_init|borrows 'K.list.*' and cannot be stored, returned or captured" "own_err_immortal_field_overwritten|borrows .* but is used here after .* released or overwritten" - "own_err_immortal_region_borrow|the initializer of global 'c' holds the result of 'firstOf', which borrows its argument" + "own_err_immortal_region_borrow|borrows the global 'c' and cannot be stored, returned or captured" "own_err_immortal_region_call|the initializer of global 'c' holds the result of 'id', which borrows its argument" "own_err_global_loop_store_static|borrows the global 'K[.]w' and cannot be stored, returned or captured" "own_err_global_loop_store_script|borrows the global 'K[.]w' and cannot be stored, returned or captured" "own_err_global_argument_assigned|a value read out of the global 'G' is lent to a call that may release or overwrite it" "own_err_global_method_assigns|a value read out of the global 'G' is lent to a call that may release or overwrite it" + "own_err_global_argument_transitive|a value read out of the global 'G' is lent to a call that may release or overwrite it" + "own_err_global_method_transitive|a value read out of the global 'G' is lent to a call that may release or overwrite it" + "own_err_global_this_field_move|a value read out of the global 'GK' is lent to a call that may release or overwrite it" + "own_err_global_argument_field_let|a value read out of the global 'G' is lent to a call that may release or overwrite it" + "own_err_global_kept_field|a value read out of the global 'g' is lent to a call that may release or overwrite it" "own_err_global_let_call|borrows the global 'names', but -mm=own cannot prove the global keeps its value" "own_err_global_from_global|the initializer of global 'k' reads a value another owner keeps" "own_err_global_from_field|the initializer of global 'k' reads a value another owner keeps" diff --git a/tslang/test/tester/own/own_err_global_argument_field_let.ts b/tslang/test/tester/own/own_err_global_argument_field_let.ts new file mode 100644 index 000000000..27fff7eea --- /dev/null +++ b/tslang/test/tester/own/own_err_global_argument_field_let.ts @@ -0,0 +1,22 @@ +// -mm=own rejects: `x` borrows the field `h.c` of a parameter that is `G`'s object, and `run` gives +// `G` a new value before `x` is read, which frees the object and its field with it. +class C { + x = 5; +} + +class H { + c: C | null = null; +} + +let G = new H(); + +function run(h: H) { + h.c = new C(); + const x = h.c; + G = new H(); + print(x.x); +} + +function main() { + run(G); +} diff --git a/tslang/test/tester/own/own_err_global_argument_transitive.ts b/tslang/test/tester/own/own_err_global_argument_transitive.ts new file mode 100644 index 000000000..a6de56bdb --- /dev/null +++ b/tslang/test/tester/own/own_err_global_argument_transitive.ts @@ -0,0 +1,24 @@ +// -mm=own rejects: `run` borrows `G`'s object as `h` and calls `reset`, which gives `G` a new value: +// that frees the object while `h` still reads it. The callee drops it through another call. +class C { + x = 5; +} + +function mk(): C { + return new C(); +} + +let G: C = mk(); + +function reset() { + G = mk(); +} + +function run(h: C) { + reset(); + print(h.x); +} + +function main() { + run(G); +} diff --git a/tslang/test/tester/own/own_err_global_kept_field.ts b/tslang/test/tester/own/own_err_global_kept_field.ts new file mode 100644 index 000000000..d52380ecb --- /dev/null +++ b/tslang/test/tester/own/own_err_global_kept_field.ts @@ -0,0 +1,27 @@ +// -mm=own rejects: `keep` stores its parameter `c` into a field, and `main` lends it the global `g`, +// which `reset` gives another value elsewhere: `g` is not immortal (spec 25), and a call that may +// drop cannot be lent a value read out of it. +class C { + constructor(public x: number) {} +} + +class H { + c: C | null = null; +} + +let g = new C(1); + +function keep(h: H, c: C) { + h.c = c; +} + +function reset() { + g = new C(2); +} + +function main() { + const h = new H(); + keep(h, g); + print(h.c!.x); + reset(); +} diff --git a/tslang/test/tester/own/own_err_global_method_transitive.ts b/tslang/test/tester/own/own_err_global_method_transitive.ts new file mode 100644 index 000000000..bac5f079d --- /dev/null +++ b/tslang/test/tester/own/own_err_global_method_transitive.ts @@ -0,0 +1,20 @@ +// -mm=own rejects: `G.m()` runs with `this` borrowed from `G`, and `m` calls `reset`, which gives +// `G` a new value: that frees the object `this` still is. +class C { + x = 5; + + m() { + reset(); + return this.x; + } +} + +let G = new C(); + +function reset() { + G = new C(); +} + +function main() { + print(G.m()); +} diff --git a/tslang/test/tester/own/own_err_global_this_field_move.ts b/tslang/test/tester/own/own_err_global_this_field_move.ts new file mode 100644 index 000000000..6be643b96 --- /dev/null +++ b/tslang/test/tester/own/own_err_global_this_field_move.ts @@ -0,0 +1,23 @@ +// -mm=own rejects: `GK.run()` runs with `this` borrowed from `GK`; `run` moves `c` into a field of +// `this`, then gives `GK` a new value, which frees the old object and `c` with it before `c.x` is +// read. +class C { + x = 5; +} + +class K { + f: C | null = null; + + run() { + const c = new C(); + this.f = c; + GK = new K(); + print(c.x); + } +} + +let GK = new K(); + +function main() { + GK.run(); +} diff --git a/tslang/test/tester/own/own_err_immortal_region_borrow.ts b/tslang/test/tester/own/own_err_immortal_region_borrow.ts index 59f0815f4..8e3fbb7fc 100644 --- a/tslang/test/tester/own/own_err_immortal_region_borrow.ts +++ b/tslang/test/tester/own/own_err_immortal_region_borrow.ts @@ -1,6 +1,9 @@ // -mm=own rejects: `c` is never assigned, but its initializer is a call's result, a borrow of an // element `list` owns. Marking that block immortal would make the array's free of it a silent -// leak, so `c` stays mortal. A global cannot hold a borrow: the initializer is the error (#638). +// leak, so `c` stays mortal, and `f` returns a borrow of it. The call goes through a function value +// `pick` returns, whose facts the global's region does not know (#638 cannot see it), so this is +// findImmortalGlobals' refusal of any call's result, not the region check. (`firstOf`, returned as +// a value, is an error of its own.) class V { constructor(public x: number) {} } @@ -8,7 +11,10 @@ const list = [new V(1)]; function firstOf(a: V[]) { return a[0]; } -const c = firstOf(list); +function pick(): (a: V[]) => V { + return firstOf; +} +const c = pick()(list); function f() { return c; } From f7846c9005b5e2f540041b70358e92f3c1dc07c4 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Fri, 23 Oct 2026 04:51:08 +0100 Subject: [PATCH 55/57] own-raytrace T9: one helper for a call whose result is the caller's own walkBorrowed's two clauses that end a walk at a call - the __own_fresh_result one and T9's direct call of a defined function - are one helper, callResultIsOwn, so the two exemptions read as one rule. No change in behaviour. own-exe-run.cmake matches EXPECT as a whole line and "done." as the last line (`checksum: 606318` no longer matches `checksum: 6063180`). Spec 34: a string read out of a parameter and returned is a borrow fact like any other (`nm(c) { return c.name; }` gets __own_result_borrows), not a copy. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 6 ++- .../lib/TypeScript/OwnershipInferencePass.cpp | 44 +++++++++++-------- tslang/test/tester/own-exe-run.cmake | 9 ++-- 3 files changed, 36 insertions(+), 23 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 518281f18..f98fdc68b 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 @@ -4098,7 +4098,11 @@ place of the ray tracer's `let legalize`, `toDrawingColor` has `__own_no_drops`. borrow of a parameter, of a part of one, or a closure, bound method, array or record over one is either a fact its callers see or an error in the body (`borrows a field and cannot be stored, returned or captured`, or, when its callers cannot all know the fact, §31's body errors). A string - result is a copy (spec 22). + is no exception: one read out of a parameter and returned is not copied but is a borrow fact like + any other (`function nm(c: Cam) { return c.name; }` gets `__own_result_borrows`, so `nm(sc.cam)` + is still walked, and its result read after `drop(sc)` is "borrows the result of 'nm' but is used + here after it may be released or overwritten"). Since the fix-up of 2026-10-23 this test and the + `__own_fresh_result` one are one helper, `callResultIsOwn`. - **Not widened:** a call through a value, through an interface, through a callback family or to a declared function is still walked (isFresh is false), and so is a method call: a class method is called through `ts.ThisVirtualSymbolRef`, so `sc.cam.copy()` and `this.point(x, sc.cam)` keep the diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index 52da4a209..69e018600 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -2597,24 +2597,8 @@ class OwnershipInferencePass continue; } - // A call known to make its result (`__own_fresh_result`: an iterator's `next()`, a field - // that only ever holds one function, another own module's `..new`) hands back a block of - // the caller's own - the `{value, done}` record of `it.next()` points nowhere into `it`, - // and what it does after its next call cannot be a use of the method read out of `it` - - // unless it is a generator's state that borrows its arguments (`__own_result_bounded`). - if (isCall(user) && user->hasAttr(OWN_FRESH_RESULT_ATTR_NAME) && !user->hasAttr(OWN_RESULT_BOUNDED_ATTR_NAME)) - { - continue; - } - - // So does a direct call of a function this module defines (isFresh): its result points - // into nothing it was given but what its facts name - a borrow of an argument - // (`__own_result_borrows`, never fresh), a borrowing record, a generator's state that - // borrows its arguments - and a body whose callers cannot all know such a fact is an - // error. `getPoint(x, scene.camera)` makes a new vector: a call after it that may - // destroy `scene.camera` does not end that vector (spec 34). - if (isCall(user) && user->getNumResults() == 1 && !user->hasAttr(OWN_RESULT_BOUNDED_ATTR_NAME) && - !isRecordResult(user) && isFresh(user->getResult(0))) + // a call whose result is the caller's own (callResultIsOwn) ends the walk at the call + if (callResultIsOwn(user)) { continue; } @@ -2633,6 +2617,30 @@ class OwnershipInferencePass return nullptr; } + // Whether a call hands back a block of the caller's own, which points into nothing it was given: + // walkBorrowed then ends a borrow's walk at the call (the call itself is still a use for as long + // as it runs). Never a generator's state that borrows its arguments (`__own_result_bounded`). + // - A call known to make its result (`__own_fresh_result`: an iterator's `next()`, a field that + // only ever holds one function, another own module's `..new`) - the `{value, done}` record of + // `it.next()` points nowhere into `it`, and what it does after its next call cannot be a use of + // the method read out of `it`. + // - A direct call of a function this module defines (isFresh): its result points into nothing it + // was given but what its facts name - a borrow of an argument (`__own_result_borrows`, never + // fresh), a borrowing record (isFresh, but bounded by what it borrows), a generator's state + // that borrows its arguments - and a body whose callers cannot all know such a fact is an + // error. `getPoint(x, scene.camera)` makes a new vector: a call after it that may destroy + // `scene.camera` does not end that vector (spec 34). + static bool callResultIsOwn(mlir::Operation *call) + { + if (!isCall(call) || call->hasAttr(OWN_RESULT_BOUNDED_ATTR_NAME)) + { + return false; + } + + return call->hasAttr(OWN_FRESH_RESULT_ATTR_NAME) || + (call->getNumResults() == 1 && !isRecordResult(call) && isFresh(call->getResult(0))); + } + // Is this what an indirect call goes through: its callee value, or, for a method read out of // a field of an object (`o.m()`, `m` a field holding a function), the object it is bound to, // which is the container of what was read, not what was borrowed. A method of a class bound diff --git a/tslang/test/tester/own-exe-run.cmake b/tslang/test/tester/own-exe-run.cmake index 9b28f57c7..9e54b84a7 100644 --- a/tslang/test/tester/own-exe-run.cmake +++ b/tslang/test/tester/own-exe-run.cmake @@ -1,5 +1,5 @@ -# Builds a program under -mm=own with `tslang --emit=exe` and runs it: the output must match EXPECT -# and end with "done.". For a program that exports from its root, whose exports only an executable +# Builds a program under -mm=own with `tslang --emit=exe` and runs it: one line of the output must +# be EXPECT (a regex), and the last "done.". For a program that exports from its root, whose exports only an executable # keeps from other modules (own spec 31, USER decision): test-runner builds an object file with # --entry-point and links it itself, which another module could import, so it cannot build one. @@ -32,8 +32,9 @@ execute_process( OUTPUT_VARIABLE out ERROR_VARIABLE err RESULT_VARIABLE status) -if(NOT status EQUAL 0 OR NOT out MATCHES "${EXPECT}" OR NOT out MATCHES "done\\.") - message(FATAL_ERROR "the program: exit ${status}, expected '${EXPECT}' and 'done.': ${out}${err}") +# EXPECT is a whole line of the output, and "done." its last line +if(NOT status EQUAL 0 OR NOT out MATCHES "(^|\n)${EXPECT}\r?\n" OR NOT out MATCHES "(^|\n)done\\.\r?\n?$") + message(FATAL_ERROR "the program: exit ${status}, expected a line '${EXPECT}' and a last line 'done.': ${out}${err}") endif() message(STATUS "${out}") From e33af8ff7526be911edf5fc9d0c766d8ef8ce0e4 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Fri, 23 Oct 2026 07:00:23 +0100 Subject: [PATCH 56/57] own-raytrace T1: a read of a mortal global ends at its assignment Making loop-written globals mortal exposed an older hole (on main too): `const c = G` names the global's read itself - a ts.Load of its ts.AddressOf with no retain - and no check bounded it. `let c = G` is a borrowing local, rejected; the const was neither a place read nor a local. So `const c = K.w; K.w = new V(i + 1); print(c.x)` in a static block's or top-level loop compiled and read the freed block (exit 139 on Windows and Linux), and so did the same in any function. OwnershipInferencePass checks each read of a mortal global (isGlobalRead / checkGlobalRead): no use of it, nor of what it produces (walkBorrowed, made again at the read), may come after a ts.Store or ts.ReleaseSlot of that global in the same function. Where that walk meets a keep, the read is followed into what holds it or rejected, never let go: - followed (borrowHolderOf): a local declared from it, assigned it, or given it in a field of its value tuple - an object literal built in place, a `let` taken out of one, a destructuring. Its uses under the field path that holds the borrow are uses of the read (a sibling field is not), made again only where the holder is given a new value (holderRenewals: its declaration when it has an initializer, a store into that path or a prefix of it) - never at the read, which renews `c` and not `o.v`: a holder declared before the loop keeps the old borrow across the back edge (`const o = { v: Z.z }; for (...) { const c = G; print(o.v.x); o.v = c; G = new V(i + 1); }` read the freed block), and so does `let l!: V` declared inside the loop, whose slot stores nothing and keeps the last round's value (walkBorrowed's renewal of a non-owning local at its declaration says the same now). A value loaded out of it is made again only by a path back through that load; a ts.DeconstructTuple is followed field by field; a value made from the borrow that declares or is stored into another local makes that a holder too; - rejected: any other keep ("the value read from 'G' is kept where its end at an assignment of 'G' cannot be checked"). A string read is copied where it is kept (spec 22). A call that assigns the global stays #639's at its arguments. Negatives own_err_global_const_store_static, _script, own_err_global_const_store, own_err_global_record_static, own_err_global_record, own_err_global_let_holder_static, own_err_global_let_destructure, own_err_global_let_holder, own_err_global_outer_holder, own_err_global_outer_holder_static, own_err_global_uninit_let and own_err_global_uninit_let_static; with each rule skipped, its negatives compile and exit 139 under MALLOC_PERTURB_ on Linux. Corpus unchanged (526); the ray tracer has no error. Also: own_err_immortal_region_borrow's firstOf says `@lends`, so the file has no error but the refusal it pins; own-exe-run.cmake's header rewrapped; spec 25 states the rule, the message as printed, and what is not closed (a call that assigns the global, `g(n, n = mk(2))` with a local); spec 34 records the limit of the T9 teeth. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 136 ++++++++ .../lib/TypeScript/OwnershipInferencePass.cpp | 300 +++++++++++++++++- tslang/test/tester/CMakeLists.txt | 12 + tslang/test/tester/own-exe-run.cmake | 7 +- .../tester/own/own_err_global_const_store.ts | 30 ++ .../own/own_err_global_const_store_script.ts | 29 ++ .../own/own_err_global_const_store_static.ts | 31 ++ .../own/own_err_global_let_destructure.ts | 37 +++ .../tester/own/own_err_global_let_holder.ts | 37 +++ .../own/own_err_global_let_holder_static.ts | 39 +++ .../tester/own/own_err_global_outer_holder.ts | 43 +++ .../own/own_err_global_outer_holder_static.ts | 47 +++ .../test/tester/own/own_err_global_record.ts | 36 +++ .../own/own_err_global_record_static.ts | 39 +++ .../tester/own/own_err_global_uninit_let.ts | 43 +++ .../own/own_err_global_uninit_let_static.ts | 43 +++ .../own/own_err_immortal_region_borrow.ts | 7 +- 17 files changed, 908 insertions(+), 8 deletions(-) create mode 100644 tslang/test/tester/own/own_err_global_const_store.ts create mode 100644 tslang/test/tester/own/own_err_global_const_store_script.ts create mode 100644 tslang/test/tester/own/own_err_global_const_store_static.ts create mode 100644 tslang/test/tester/own/own_err_global_let_destructure.ts create mode 100644 tslang/test/tester/own/own_err_global_let_holder.ts create mode 100644 tslang/test/tester/own/own_err_global_let_holder_static.ts create mode 100644 tslang/test/tester/own/own_err_global_outer_holder.ts create mode 100644 tslang/test/tester/own/own_err_global_outer_holder_static.ts create mode 100644 tslang/test/tester/own/own_err_global_record.ts create mode 100644 tslang/test/tester/own/own_err_global_record_static.ts create mode 100644 tslang/test/tester/own/own_err_global_uninit_let.ts create mode 100644 tslang/test/tester/own/own_err_global_uninit_let_static.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 f98fdc68b..a49ad119a 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 @@ -2899,6 +2899,136 @@ counting. `test-own-global-loop-store-mortal` checks its `--emit=mlir-affine`: ` 'K.w' and cannot be stored, returned or captured". Teeth (a temporary `OWN_TEETH` switch skipping `mayRunAgain`, removed): the mortal check fails (`K.w`/`S.w` marked) and both negatives compile. +**A `const` read of a mortal global ends at its assignment (fix-up review C1, 2026-10-23).** Making +those globals mortal exposed an older hole, on main too: `const c = G` names the read itself (a +`ts.Load` of the global's `ts.AddressOf`, `__owned_result_named`, with no retain), which no check +bounded - `let c = G` is a borrowing local, rejected, but the `const` was neither a place read nor +a local. So `const c = K.w; K.w = new V(i + 1); print(c.x)` in a static block's or top-level code's +loop compiled and, now that the store frees the old block, read freed memory (exit 139 on Windows +and Linux); before the fix-up it leaked instead. The same in any function (`const c = G; G = new +V(1); print(c.x)`) read freed memory on main. OwnershipInferencePass now checks each read of a +mortal global (`isGlobalRead`: not immortal, owns a heap block, not a handle; `checkGlobalRead`): +its uses, and the uses of what it produces (walkBorrowed, made again at the read), must not come +after a `ts.Store` or `ts.ReleaseSlot` of that global in the same function ("'this value' borrows +'G' but is used here after 'G' is released or overwritten": the name is the read's, as in the +other own diagnostics, and `ownerName` finds a variable's name only with `--di`). Where the walk +meets a keep, the read is followed into what holds it or rejected (both below). Not here: a call that assigns the global (`const c = G; reset(); print(c.x)`, still accepted; #639 +covers such a call only when the read is its argument), and the same with a local +(`g(n, n = mk(2))`, still accepted, and wrong at run time under own before and after). A `const` +of an owning local (`const c = n; n = mk(5); print(c.x)`) is a move, which is sound. Tests: +`own_err_global_const_store_static`, `own_err_global_const_store_script` and +`own_err_global_const_store` (runnable programs; gc prints `1 64 ... 5`). Tooth (a temporary +`OWN_TEETH` switch skipping the check, removed): the three compile, and the built programs exit +139 under `MALLOC_PERTURB_` on Linux. Newly rejected: only programs of this shape; the corpus +keeps its 526 files. + +**An object literal that holds such a read is bounded by the global too (fix-up re-review N1, +2026-10-23).** An object literal built in a local (`const o = { v: c }`, `{ v: K.w }`, +`{ p: { v: K.w } }`, `const { v } = { v: G }`) takes the read with no retain: a `ts.Store` into a +`ts.PropertyRef` of the local's value tuple, or the local's initializer. walkBorrowed took that +store for a keep, and checkGlobalRead ended there, so `o.v.x` after `K.w = new V(i + 1)` read the +freed block (exit 139 on Windows and Linux; BASE leaked instead, and the module-global form +`const o = { v: G }; G = new V(1)` read freed memory on main). The literal holds the borrow, not +a second owner, so checkGlobalRead now follows it (`recordLocalHolding`): every use of the local - +its fields' reads, a copy (`const o = b`), a spread (`{ ...b }`, through `ts.DeconstructTuple`), +a destructuring - and what those lead to are uses of the read, made again where the local is +declared. A value made from the borrow and stored into another such local makes that local a +holder too. Tests: +`own_err_global_record_static` (the static block's loop) and `own_err_global_record` (a module +global in a function), runnable, gc printing `1 64 ... 5` and `0 64 ... 5`. Tooth (a temporary +`OWN_TEETH` switch ending the follow at once, removed): both compile, and the built programs exit +139 on Windows and under `MALLOC_PERTURB_` on Linux. The ray tracer is unchanged: the globals its +records read (`Color.*`) are immortal and its `Surfaces.*` are handles, which this check skips. + +**Every keep is followed or rejected (fix-up re-review N2, 2026-10-23).** A `let` that takes the +value back out of such a literal (`const o = { v: K.w }; let l = o.v`, `let l: V = o.v`, +`let l = o["v"]`, `let l = o.v`, an interface-typed `let`, `const p = o.v; let l = p`, +`let { v } = o`, `let { v } = { v: K.w }`) was still a keep that ended the walk in silence, and the +`let` itself passes as a borrowing `let` of `o`'s field, bounded by `o`, not by the global: `l.x` +after `K.w = new V(i + 1)` read the freed block (exit 139 on Windows and Linux; the module-global +form on main too). So checkGlobalRead no longer lets a keep go. Each keep the walk meets is +either: +- followed as a holder (`borrowHolderOf`): a local - a `let`, a `const`, an object literal built in + place, a destructured local - declared from the read, assigned it, or given it in a field of its + value tuple at any depth. Its uses under the field path that holds the borrow, and what they lead + to, are uses of the read, made again only where the local is given a new value (its declaration + when it has an initializer, and a store into that path or a prefix of it; the paragraphs + below); its own retain and releases + are its bookkeeping, which the owner checks decide. A sibling field never given the borrow + (`{ v: K.w, z: Z.z }` with only `o.z` read later) is not followed. A tuple taken apart + (`ts.DeconstructTuple`) is followed field by field, and a value made from the borrow that + declares or is stored into another local makes it a holder too. rc's temporary release of a + call's result that borrows an argument goes with that result, as in checkPlaceRead; +- or rejected: "the value read from 'G' is kept where its end at an assignment of 'G' cannot be + checked; -mm=own cannot prove it outlives the global's value" - a field of an object, an array, + a global, a callee that keeps it, a capture, a return. A string read is copied where it is kept + (§22), so its keeps are not this check's. + +Tests: `own_err_global_let_holder_static` (a static block's loop), `own_err_global_let_destructure` +(`let { v } = { v: K.w }` in a top-level loop) and `own_err_global_let_holder` (a module global), +runnable, gc printing `1 64 ... 5` and `0 64 ... 5`. Tooth (a temporary `OWN_TEETH` switch that +lets a keep go and follows no plain local, removed): the three compile, and the built programs +exit 139 on Windows and under `MALLOC_PERTURB_` on Linux. Newly rejected beside them: the unsafe +shapes, and one correct one - a spread of a holder (`const o = { v: K.w, z: Z.z }; const q = +{ ...o }`, then `q.z` read after the store): `ts.DeconstructTuple` is followed field by field, so +every field of the copy holds the borrow and the copy loses the field path. That is a sound +over-rejection, kept. The corpus keeps 526 files, the ray tracer has no error, and the sibling +field of round 2's over-rejection is accepted again. + +**A holder is renewed where it is stored, never at the read (fix-up re-review N3, 2026-10-23).** +The follow above made a holder's uses again at the read as well as at the local's declaration +(the kills the walk reached it with). For a holder declared before the loop that is wrong: `const +o = { v: Z.z }; for (...) { const c = G; print(o.v.x); o.v = c; G = new V(i + 1); churn(); }` +reads the read's block in the next round - the path back from the store goes through +`const c = G`, which renews `c`, not `o.v` - and own accepted it (exit 139 on Windows and Linux; +the module-global form on main too, the static-block and top-level forms since the loop-store +fix-up). So a holder's uses are now made again only where the holder is given a new value +(`holderRenewals`): the local's declaration, and every store into the field path that holds the +borrow or into a prefix of it (the whole local, `o.p` for `o.p.v`); a store into a sibling field +or below the path is not one. Those depend on the holder alone, not on the way the walk reached +it, so a holder is followed once. A value loaded out of the holder is made again only by a path +back through that load, which is itself a use bounded by the renewals; and a store that is a +renewal is also a use of the value it stores, bounded by that value's own renewals, so a store of +a stale borrow into the path is rejected at the store. Reachability goes around the back edge: a +store in one round reaches a use in the next. Accepted by this: the field stored before each +round's use (`o.v = c; print(o.v.x); G = ...`), a second read re-stored after the store +(`G = ...; const d = G; o.v = d; print(o.v.x)`), a fresh value stored into the path or a prefix +of it after the store (`o.v = Z.z`, `o.p = { v: Z.z }`), and a sibling read. Rejected: the use +before the store in the next round (`for`, `while`, `do`, `for-of`, `continue`, a labelled +`continue`, nested loops, a holder in an enclosing block or an outer loop, nested paths, two +globals in one holder), a store on one arm only, the holder used after the loop, and a stale value +stored back (`const t = o.v; G = ...; o.v = t`). Tests: `own_err_global_outer_holder` (a module +global, the shape above) and `own_err_global_outer_holder_static` (a static block's loop, the +store in one round and the use in the later ones), runnable, gc printing `42 64 0 64 1 64 2 64 4` +and `1 64 1 64 1 64 5`. Tooth (a temporary `OWN_TEETH` switch making the read and the +declaration the renewals again, removed): both compile, and the built programs exit 139 on +Windows and under `MALLOC_PERTURB_` on Linux. Newly rejected beside them: the unsafe shapes, and +two correct ones, both sound over-rejections - a holder stored on one arm of an inner loop before +every use in practice (`for (j ...) { if (j == 0) { o.v = c; } print(o.v.x); }`), which the CFG +cannot order, and a holder copied into another holder, renewed and copied again +(`h.v = c; outer.h = h; G = ...; h.v = Z.z; outer.h = h; print(outer.h.v.x)`); the corpus keeps +526 files and the ray tracer has no error. + +**A declaration without an initializer renews nothing (fix-up re-review N4, 2026-10-23).** `let +l!: V` (or `let l: V`, `let o: { v: V }`) declared inside the loop body stores nothing: tslang +does not reset the slot each round, so it still holds the last round's value. The renewal rule +above counted every declaration, so `for (...) { let l!: V; if (i > 1) { print(l.x); } const c = +K.w; if (i == 1) { l = c; } K.w = new V(i + 1); churn(); }` was accepted and read the freed block +in round 2 (exit 139; the module-global form on main too, the static-block and top-level forms +since the loop-store fix-up). Now a declaration is a renewal only when it has an initializer +(`holderRenewals`, and walkBorrowed's `passesOn` says the same for the non-owning local it hands a +value to - a narrowing for every borrow check, which can only reject more: a place read into such +a `let` in a loop was accepted the same way, `const c = p.v; if (i == 1) { l = c; } p.v = new +V(i + 1)`, and is rejected now). `let l: V | undefined` has an initializer and is a renewal. Still +accepted: the same `let` assigned before each round's use, a `const o = { v: Z.z }` declared in the +loop (its initializer re-stores every round), and `for (x of arr)` into an outer `let x!: V` +(each round's element is stored first). Tests: `own_err_global_uninit_let_static` (the shape +above, in a static block whose only store of `K.w` is the loop's) and `own_err_global_uninit_let` +(a module global in a function), runnable, gc printing `1 64 1 64 4`. Tooth (a temporary +`OWN_TEETH` switch counting every declaration again, removed): both compile, and the built +programs exit 139 on Windows and under `MALLOC_PERTURB_` on Linux. Newly rejected beside them: +only the unsafe shapes; the corpus keeps 526 files and the ray tracer has no error. + ## 26. `Shared` of an object literal (G4 of the ray tracer plan), 2026-10-21 The ray tracer's surfaces are module-level `new Shared({ ...function fields... })`. The @@ -4163,6 +4293,12 @@ the array's release at the return ends; built under own it prints what gc prints Not here: - A method call's result (`o.m(borrow)`, through `ts.ThisVirtualSymbolRef`) is still walked as a borrow of its arguments, as is an indirect call's. +- Known limit of the teeth (fix-up, 2026-10-23): with the `isFresh` test alone removed, + `own_err_field_function_reassigned` (a conservative negative) compiles and prints gc's output; + with the borrowing-record exclusion alone removed, `own_err_call_result_record` stays an error + through the record's own bound (`borrows the result of 'hit'`). No probe (review9's 95, T9's, + the merge review's, every `own_err_*`) was accepted unsafely with either removed, so no negative + shows a use-after-free that only those two tests prevent. - The benchmark keeps its two source edits; the unedited `rtbench.ts` does not compile under own (a non-goal of the plan). - This section is 34, not 35 as the Task 9 addendum expected: the merge of main left notes in §25 diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index 69e018600..1dacfa314 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -20,6 +20,11 @@ #include "llvm/ADT/SmallPtrSet.h" #include "llvm/ADT/SmallVector.h" #include "llvm/ADT/STLFunctionalExtras.h" +#include "llvm/ADT/StringSet.h" +#include "llvm/Support/raw_ostream.h" + +#include +#include #define DEBUG_TYPE "own" @@ -120,6 +125,8 @@ class OwnershipInferencePass // reads out of fields and elements, and results that borrow an argument, and what may // destroy what they read (spec 2.3, 2.4) llvm::SmallVector placeReads; + // reads of what a mortal global holds (isGlobalRead) + llvm::SmallVector globalReads; // calls whose callee keeps an argument: each such argument moves into the call llvm::SmallVector keepingCalls; drops.clear(); @@ -220,6 +227,10 @@ class OwnershipInferencePass { placeReads.push_back(loadOp); } + else if (isGlobalRead(loadOp)) + { + globalReads.push_back(loadOp); + } } else if (auto releaseSlotOp = mlir::dyn_cast(op); releaseSlotOp && isYieldedField(releaseSlotOp.getSlot())) { @@ -287,6 +298,11 @@ class OwnershipInferencePass checkPlaceRead(read, toErase); } + for (auto read : globalReads) + { + checkGlobalRead(read); + } + // 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. @@ -2402,7 +2418,8 @@ class OwnershipInferencePass // that owns nothing and is assigned it (`for (x of arr)` into an outer `let x`). Answers // whether the use is one of those, even where nothing comes out of it. Where the value lands // somewhere that is replaced - a block argument, a local - `renewedAt` gets what replaces it: - // the block's entry, the local's declaration and assignments. + // the block's entry, the local's declaration (when it has an initializer: `let l!: V` stores + // nothing, and in a loop the slot keeps the last round's value) and assignments. static bool passesOn(mlir::OpOperand &use, llvm::SmallVectorImpl &values, llvm::SmallVectorImpl *renewedAt = nullptr) { @@ -2434,7 +2451,7 @@ class OwnershipInferencePass if (auto varOp = storeOp.getReference().getDefiningOp(); varOp && !isOwningVariable(varOp) && !varOp.getCaptured().value_or(false)) { - if (renewedAt) + if (renewedAt && varOp.getInitializer()) { renewedAt->push_back(varOp); } @@ -4086,6 +4103,285 @@ class OwnershipInferencePass return true; } + // A local that holds a borrow through `user`, and the field path under it that holds it (empty: + // the local's value itself): a store of it into the local, or into a field of the local's value + // tuple at any depth (an object literal built in place), or the local declared from it. + struct BorrowHolder + { + mlir_ts::VariableOp local; + llvm::SmallVector path; + }; + + static std::optional borrowHolderOf(mlir::Operation *user) + { + BorrowHolder holder; + mlir::Value ref; + if (auto storeOp = mlir::dyn_cast(user)) + { + ref = storeOp.getReference(); + while (auto propertyRefOp = ref.getDefiningOp()) + { + holder.path.insert(holder.path.begin(), propertyRefOp.getPosition()); + ref = propertyRefOp.getObjectRef(); + } + } + else if (auto varOp = mlir::dyn_cast(user)) + { + ref = varOp.getResult(); + } + + auto varOp = ref ? ref.getDefiningOp() : mlir_ts::VariableOp(); + if (!varOp || varOp.getCaptured().value_or(false) || isCellVariable(varOp.getResult())) + { + return std::nullopt; + } + + holder.local = varOp; + return holder; + } + + // Where the holder is given a new value: the local's declaration when it has an initializer + // (`let l!: V` stores nothing, and in a loop the slot keeps the last round's value: fix-up + // re-review N4), and every store into the field path that holds the borrow or into a prefix of + // it (the whole local, `o.p` for `o.p.v`). A store into a sibling field, or below the path, + // gives the path no new value. + static llvm::SmallVector holderRenewals(BorrowHolder holder) + { + llvm::SmallVector renewals; + if (holder.local.getInitializer()) + { + renewals.push_back(holder.local); + } + + llvm::SmallVector> refs{{holder.local.getResult(), 0}}; + while (!refs.empty()) + { + auto [ref, depth] = refs.pop_back_val(); + for (auto *refUser : ref.getUsers()) + { + if (auto propertyRefOp = mlir::dyn_cast(refUser)) + { + if (depth < holder.path.size() && propertyRefOp.getPosition() == holder.path[depth]) + { + refs.push_back({propertyRefOp.getResult(), depth + 1}); + } + } + else if (auto storeOp = mlir::dyn_cast(refUser); storeOp && storeOp.getReference() == ref) + { + renewals.push_back(storeOp); + } + } + } + + return renewals; + } + + // Whether one field path is on the way to the other: a read of either may reach the other's value. + static bool pathsMeet(llvm::ArrayRef a, llvm::ArrayRef b) + { + auto common = std::min(a.size(), b.size()); + return a.take_front(common) == b.take_front(common); + } + + // A read of what a mortal global holds (spec 25: an immortal one's one store is its + // initializer): a heap value it owns, not a handle (spec 23.3 counts those). + bool isGlobalRead(mlir_ts::LoadOp loadOp) + { + auto addressOfOp = loadOp.getReference().getDefiningOp(); + auto result = loadOp.getResult(); + return addressOfOp && !addressOfOp->hasAttr(OWN_IMMORTAL_GLOBAL_ATTR_NAME) && ownsHeap(result) && !isHandle(result); + } + + // Such a read is a borrow of the value the global owns - `const c = K.w` names the read itself, + // with no retain - and an assignment of the global releases it. So no use of the read, nor of + // what it produces (walkBorrowed), may run after one in this function. A path back to a use + // through the read itself is a new read. A call that assigns the global is #639's at its + // arguments. (C1 of the fix-up review: `const c = K.w; K.w = new V(i + 1); print(c.x)` in a loop + // read the block the assignment freed.) + // + // Where the walk meets a keep, the read is either followed into what holds it or rejected - + // never let go unchecked (fix-up re-review N2: a kept read is not always another check's). A + // local holds it (borrowHolderOf): an object literal built in place (`const o = { v: c }`, + // `{ p: { v: K.w } }`), a `let` or a destructured local declared or assigned from it + // (`let l = o.v`, `let { v } = o`). Every use of that local under the path that holds the borrow + // - its reads, a copy, a spread (`ts.DeconstructTuple`), what those lead to - is a use of the + // read, made again only where the local is given a new value (holderRenewals): never at the + // read, which renews `c` and not `o.v` - a holder declared before a loop keeps the old borrow + // across the back edge until it is stored again (fix-up re-review N3). The local's own retain + // and releases are its bookkeeping, which the owner checks decide. A sibling field never given + // the borrow is not followed. A value made from the borrow and stored into another local makes + // that one a holder too. Any other keep - a field of an object, an array, a global, a callee + // that keeps it, a capture, a return - cannot be bounded here and is an error. A string read is + // copied where it is kept (spec 22), so its keeps are not this check's. + void checkGlobalRead(mlir_ts::LoadOp read) + { + // each access of a global is a `ts.AddressOf` of its own + auto global = read.getReference().getDefiningOp().getGlobalName(); + llvm::SmallVector uses; + llvm::StringSet<> followed; + mlir::Operation *keptAt = nullptr; + // rc's temporary release of a call's result that borrows an argument goes with that result + // (walkBorrowed's `keeping`); the owner checks decide it + llvm::SmallVector bookkeeping; + Keeping keeping{&bookkeeping, false}; + std::function)> intoLocal; + + // the uses of what `holder` holds, made again at its renewals: those depend on the holder + // alone, not on the way the walk reached it, so a holder is followed once. A value loaded + // out of it is made again only by a path back through that load (the load itself is a use, + // bounded by the renewals): a store into the path after the load gives the loaded value + // nothing new. + auto followHolder = [&](BorrowHolder holder) { + std::string key; + llvm::raw_string_ostream keyOut(key); + keyOut << holder.local.getOperation(); + for (auto position : holder.path) + { + keyOut << "." << position; + } + + if (!followed.insert(keyOut.str()).second) + { + return true; + } + + auto localKills = holderRenewals(holder); + llvm::SmallVector>> refs{{holder.local.getResult(), {}}}; + while (!refs.empty()) + { + auto [ref, path] = refs.pop_back_val(); + for (auto *refUser : ref.getUsers()) + { + if (auto propertyRefOp = mlir::dyn_cast(refUser)) + { + auto fieldPath = path; + fieldPath.push_back(propertyRefOp.getPosition()); + if (pathsMeet(fieldPath, holder.path)) + { + refs.push_back({propertyRefOp.getResult(), fieldPath}); + } + + continue; + } + + if (ref == holder.local.getResult() && + mlir::isa(refUser)) + { + continue; + } + + uses.push_back({refUser, {localKills.begin(), localKills.end()}}); + if (auto loadOp = mlir::dyn_cast(refUser)) + { + if (auto *kept = walkBorrowed(loadOp.getResult(), loadOp.getOperation(), uses, intoLocal, keeping)) + { + keptAt = keptAt ? keptAt : kept; + return false; + } + } + } + } + + return true; + }; + + intoLocal = [&](mlir::Operation *user, llvm::ArrayRef kills) { + // a tuple taken apart (`{ ...b }`, a destructuring): each field is the borrow again + if (mlir::isa(user)) + { + for (auto result : user->getResults()) + { + if (!mayPointInto(result)) + { + continue; + } + + if (auto *kept = walkBorrowed(result, kills, uses, intoLocal, keeping)) + { + keptAt = keptAt ? keptAt : kept; + return false; + } + } + + return true; + } + + auto holder = borrowHolderOf(user); + return holder && followHolder(*holder); + }; + + auto *kept = walkBorrowed(read.getResult(), read.getOperation(), uses, intoLocal, keeping); + + // a value made from the borrow (a field of a tuple read out of a holder, `{ ...b }`) stored + // into another local, or declaring one: that local holds the borrow too + for (size_t index = 0; !kept && !keptAt && index < uses.size(); index++) + { + auto *op = uses[index].op; + auto storeOp = mlir::dyn_cast(op); + auto varOp = mlir::dyn_cast(op); + auto value = storeOp ? storeOp.getValue() : varOp ? varOp.getInitializer() : mlir::Value(); + if (!value || !mayPointInto(value)) + { + continue; + } + + if (auto holder = borrowHolderOf(op)) + { + followHolder(*holder); + } + } + + if (kept || keptAt) + { + if (mlir::isa(read.getType())) + { + return; + } + + if (reporting()) + { + auto *at = keptAt ? keptAt : kept; + at->emitError("the value read from '") + << global << "' is kept where its end at an assignment of '" << global + << "' cannot be checked; -mm=own cannot prove it outlives the global's value"; + signalPassFailure(); + } + + return; + } + + llvm::SmallVector ends; + getFunction().walk([&](mlir::Operation *op) { + mlir::Value slot; + if (auto releaseSlotOp = mlir::dyn_cast(op)) + { + slot = releaseSlotOp.getSlot(); + } + else if (auto storeOp = mlir::dyn_cast(op)) + { + slot = storeOp.getReference(); + } + + auto other = slot ? slot.getDefiningOp() : mlir_ts::AddressOfOp(); + if (other && other.getGlobalName() == global) + { + ends.push_back(op); + } + }); + + for (auto *end : ends) + { + for (auto &use : uses) + { + if (use.op != end && reachableAfter(end, use.op, use.kills)) + { + reportBorrowOutlives(use.op, end, ownerName(read.getResult()), global); + return; + } + } + } + } + // A borrow read out of a parameter this function keeps, taken before the parameter moves into a // new owner (spec 33, review fix): from the move on it is bounded by that owner, as a read through // the owner would be (spec 29) - by the owner's ends and the drops of its place after the move. diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 1f4fa2406..490ad79a7 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3924,6 +3924,18 @@ set(own_error_cases "own_err_immortal_region_call|the initializer of global 'c' holds the result of 'id', which borrows its argument" "own_err_global_loop_store_static|borrows the global 'K[.]w' and cannot be stored, returned or captured" "own_err_global_loop_store_script|borrows the global 'K[.]w' and cannot be stored, returned or captured" + "own_err_global_const_store_static|borrows 'K[.]w' but is used here after 'K[.]w' is released or overwritten" + "own_err_global_const_store_script|borrows 'K[.]w' but is used here after 'K[.]w' is released or overwritten" + "own_err_global_const_store|borrows 'G' but is used here after 'G' is released or overwritten" + "own_err_global_record_static|borrows 'K[.]w' but is used here after 'K[.]w' is released or overwritten" + "own_err_global_record|borrows 'G' but is used here after 'G' is released or overwritten" + "own_err_global_let_holder_static|borrows 'K[.]w' but is used here after 'K[.]w' is released or overwritten" + "own_err_global_let_destructure|borrows 'K[.]w' but is used here after 'K[.]w' is released or overwritten" + "own_err_global_let_holder|borrows 'G' but is used here after 'G' is released or overwritten" + "own_err_global_outer_holder|borrows 'G' but is used here after 'G' is released or overwritten" + "own_err_global_outer_holder_static|borrows 'K[.]w' but is used here after 'K[.]w' is released or overwritten" + "own_err_global_uninit_let|borrows 'G' but is used here after 'G' is released or overwritten" + "own_err_global_uninit_let_static|borrows 'K[.]w' but is used here after 'K[.]w' is released or overwritten" "own_err_global_argument_assigned|a value read out of the global 'G' is lent to a call that may release or overwrite it" "own_err_global_method_assigns|a value read out of the global 'G' is lent to a call that may release or overwrite it" "own_err_global_argument_transitive|a value read out of the global 'G' is lent to a call that may release or overwrite it" diff --git a/tslang/test/tester/own-exe-run.cmake b/tslang/test/tester/own-exe-run.cmake index 9e54b84a7..9b3ad5746 100644 --- a/tslang/test/tester/own-exe-run.cmake +++ b/tslang/test/tester/own-exe-run.cmake @@ -1,7 +1,8 @@ # Builds a program under -mm=own with `tslang --emit=exe` and runs it: one line of the output must -# be EXPECT (a regex), and the last "done.". For a program that exports from its root, whose exports only an executable -# keeps from other modules (own spec 31, USER decision): test-runner builds an object file with -# --entry-point and links it itself, which another module could import, so it cannot build one. +# be EXPECT (a regex), and the last "done.". For a program that exports from its root, whose +# exports only an executable keeps from other modules (own spec 31, USER decision): test-runner +# builds an object file with --entry-point and links it itself, which another module could +# import, so it cannot build one. cmake_minimum_required(VERSION 3.17.3) diff --git a/tslang/test/tester/own/own_err_global_const_store.ts b/tslang/test/tester/own/own_err_global_const_store.ts new file mode 100644 index 000000000..bbf28569d --- /dev/null +++ b/tslang/test/tester/own/own_err_global_const_store.ts @@ -0,0 +1,30 @@ +// -mm=own rejects: `c` names the read of `G` itself, a borrow of the block the global owns, and `f` +// gives `G` a new value before `c` is read, which frees that block. (`let c = G` is rejected as a +// borrowing local; a `const` names the read and was not.) +class V { + a: number[] = []; + constructor(public x: number) { + for (let i = 0; i < 64; i++) { + this.a.push(i); + } + } +} + +let G = new V(0); + +function f(i: number) { + const c = G; + G = new V(i + 1); + const z = new V(999); + print(c.x, c.a.length); + z.x = 1000 + i; +} + +function main() { + for (let i = 0; i < 5; i++) { + f(i); + } + + print(G.x); + print("done."); +} diff --git a/tslang/test/tester/own/own_err_global_const_store_script.ts b/tslang/test/tester/own/own_err_global_const_store_script.ts new file mode 100644 index 000000000..2ebadb140 --- /dev/null +++ b/tslang/test/tester/own/own_err_global_const_store_script.ts @@ -0,0 +1,29 @@ +// -mm=own rejects: `c` names the read of `K.w` itself, a borrow of the block the static owns. The +// top-level loop gives `K.w` a new value before `c` is read, which frees that block. +class V { + a: number[] = []; + constructor(public x: number) { + for (let i = 0; i < 64; i++) { + this.a.push(i); + } + } +} + +class K { + static w: V; +} + +for (let i = 0; i < 5; i++) { + const c = K.w; + K.w = new V(i + 1); + const z = new V(999); + if (i > 0) { + print(c.x, c.a.length); + } + z.x = 1000 + i; +} + +function main() { + print(K.w.x); + print("done."); +} diff --git a/tslang/test/tester/own/own_err_global_const_store_static.ts b/tslang/test/tester/own/own_err_global_const_store_static.ts new file mode 100644 index 000000000..6820d3380 --- /dev/null +++ b/tslang/test/tester/own/own_err_global_const_store_static.ts @@ -0,0 +1,31 @@ +// -mm=own rejects: `c` names the read of `K.w` itself, a borrow of the block the static owns. The +// static block's loop gives `K.w` a new value before `c` is read, which frees that block: `K.w` is +// not immortal, its store runs once per round (spec 25). +class V { + a: number[] = []; + constructor(public x: number) { + for (let i = 0; i < 64; i++) { + this.a.push(i); + } + } +} + +class K { + static w: V; + static { + for (let i = 0; i < 5; i++) { + const c = K.w; + K.w = new V(i + 1); + const z = new V(999); + if (i > 0) { + print(c.x, c.a.length); + } + z.x = 1000 + i; + } + } +} + +function main() { + print(K.w.x); + print("done."); +} diff --git a/tslang/test/tester/own/own_err_global_let_destructure.ts b/tslang/test/tester/own/own_err_global_let_destructure.ts new file mode 100644 index 000000000..6f0959f5f --- /dev/null +++ b/tslang/test/tester/own/own_err_global_let_destructure.ts @@ -0,0 +1,37 @@ +// -mm=own rejects: `let { v }` takes the read of `K.w` out of the literal - a borrow of the block +// the static owns - and the top-level loop gives `K.w` a new value before `v` is read, which frees +// that block. +class V { + a: number[] = []; + constructor(public x: number) { + for (let i = 0; i < 64; i++) { + this.a.push(i); + } + } +} + +@noinline +function churn() { + const junk: V[] = []; + for (let i = 0; i < 64; i++) { + junk.push(new V(999)); + } +} + +class K { + static w: V; +} + +for (let i = 0; i < 5; i++) { + let { v } = { v: K.w }; + K.w = new V(i + 1); + churn(); + if (i > 0) { + print(v.x, v.a.length); + } +} + +function main() { + print(K.w.x); + print("done."); +} diff --git a/tslang/test/tester/own/own_err_global_let_holder.ts b/tslang/test/tester/own/own_err_global_let_holder.ts new file mode 100644 index 000000000..46cdd02cf --- /dev/null +++ b/tslang/test/tester/own/own_err_global_let_holder.ts @@ -0,0 +1,37 @@ +// -mm=own rejects: the `let` `l` takes the read of `G` out of the object literal `o`, a borrow of +// the block the global owns, and `f` gives `G` a new value before `l` is read, which frees it. +class V { + a: number[] = []; + constructor(public x: number) { + for (let i = 0; i < 64; i++) { + this.a.push(i); + } + } +} + +@noinline +function churn() { + const junk: V[] = []; + for (let i = 0; i < 64; i++) { + junk.push(new V(999)); + } +} + +let G = new V(0); + +function f(i: number) { + const o = { v: G }; + let l = o.v; + G = new V(i + 1); + churn(); + print(l.x, l.a.length); +} + +function main() { + for (let i = 0; i < 5; i++) { + f(i); + } + + print(G.x); + print("done."); +} diff --git a/tslang/test/tester/own/own_err_global_let_holder_static.ts b/tslang/test/tester/own/own_err_global_let_holder_static.ts new file mode 100644 index 000000000..7a5658202 --- /dev/null +++ b/tslang/test/tester/own/own_err_global_let_holder_static.ts @@ -0,0 +1,39 @@ +// -mm=own rejects: the object literal `o` holds the read of `K.w`, and the `let` `l` takes that +// value out of it - a borrow of the block the static owns, still. The static block's loop gives +// `K.w` a new value before `l` is read, which frees that block (spec 25). +class V { + a: number[] = []; + constructor(public x: number) { + for (let i = 0; i < 64; i++) { + this.a.push(i); + } + } +} + +@noinline +function churn() { + const junk: V[] = []; + for (let i = 0; i < 64; i++) { + junk.push(new V(999)); + } +} + +class K { + static w: V; + static { + for (let i = 0; i < 5; i++) { + const o = { v: K.w }; + let l = o.v; + K.w = new V(i + 1); + churn(); + if (i > 0) { + print(l.x, l.a.length); + } + } + } +} + +function main() { + print(K.w.x); + print("done."); +} diff --git a/tslang/test/tester/own/own_err_global_outer_holder.ts b/tslang/test/tester/own/own_err_global_outer_holder.ts new file mode 100644 index 000000000..721b2158c --- /dev/null +++ b/tslang/test/tester/own/own_err_global_outer_holder.ts @@ -0,0 +1,43 @@ +// -mm=own rejects: the object literal `o` is declared before the loop, and each round reads `o.v` +// before it stores the read of `G` there. The next round's `const c = G` is a new read, but `o.v` +// still holds the one stored before `G = new V(i + 1)` freed its block: a holder is given a new +// value only where it is stored, never at the read. +class V { + a: number[] = []; + constructor(public x: number) { + for (let i = 0; i < 64; i++) { + this.a.push(i); + } + } +} + +@noinline +function churn() { + const junk: V[] = []; + for (let i = 0; i < 64; i++) { + junk.push(new V(999)); + } +} + +class Z { + static z = new V(42); +} + +let G = new V(0); + +function f() { + const o = { v: Z.z }; + for (let i = 0; i < 4; i++) { + const c = G; + print(o.v.x, o.v.a.length); + o.v = c; + G = new V(i + 1); + churn(); + } +} + +function main() { + f(); + print(G.x); + print("done."); +} diff --git a/tslang/test/tester/own/own_err_global_outer_holder_static.ts b/tslang/test/tester/own/own_err_global_outer_holder_static.ts new file mode 100644 index 000000000..1b0e0b65b --- /dev/null +++ b/tslang/test/tester/own/own_err_global_outer_holder_static.ts @@ -0,0 +1,47 @@ +// -mm=own rejects: the object literal `o` is declared before the static block's loop, which stores +// the read of `K.w` into `o.v` in one round and reads `o.v` in the later ones, after `K.w = new +// V(i + 1)` freed that block. The read `const c = K.w` is made again each round; `o.v` is not: a +// holder is given a new value only where it is stored (spec 25). +class V { + a: number[] = []; + constructor(public x: number) { + for (let i = 0; i < 64; i++) { + this.a.push(i); + } + } +} + +@noinline +function churn() { + const junk: V[] = []; + for (let i = 0; i < 64; i++) { + junk.push(new V(999)); + } +} + +class Z { + static z = new V(42); +} + +class K { + static w: V; + static { + const o = { v: Z.z }; + for (let i = 0; i < 5; i++) { + const c = K.w; + if (i >= 2) { + print(o.v.x, o.v.a.length); + } + if (i == 1) { + o.v = c; + } + K.w = new V(i + 1); + churn(); + } + } +} + +function main() { + print(K.w.x); + print("done."); +} diff --git a/tslang/test/tester/own/own_err_global_record.ts b/tslang/test/tester/own/own_err_global_record.ts new file mode 100644 index 000000000..1f9b429de --- /dev/null +++ b/tslang/test/tester/own/own_err_global_record.ts @@ -0,0 +1,36 @@ +// -mm=own rejects: the object literal `o` holds the read of `G` - a borrow of the block the global +// owns - and `f` gives `G` a new value before `o.v` is read, which frees that block. +class V { + a: number[] = []; + constructor(public x: number) { + for (let i = 0; i < 64; i++) { + this.a.push(i); + } + } +} + +@noinline +function churn() { + const junk: V[] = []; + for (let i = 0; i < 64; i++) { + junk.push(new V(999)); + } +} + +let G = new V(0); + +function f(i: number) { + const o = { v: G }; + G = new V(i + 1); + churn(); + print(o.v.x, o.v.a.length); +} + +function main() { + for (let i = 0; i < 5; i++) { + f(i); + } + + print(G.x); + print("done."); +} diff --git a/tslang/test/tester/own/own_err_global_record_static.ts b/tslang/test/tester/own/own_err_global_record_static.ts new file mode 100644 index 000000000..508677379 --- /dev/null +++ b/tslang/test/tester/own/own_err_global_record_static.ts @@ -0,0 +1,39 @@ +// -mm=own rejects: the object literal `o` holds the read of `K.w` - a borrow of the block the static +// owns, not a second owner - and the static block's loop gives `K.w` a new value before `o.v` is +// read, which frees that block: `K.w` is not immortal, its store runs once per round (spec 25). +class V { + a: number[] = []; + constructor(public x: number) { + for (let i = 0; i < 64; i++) { + this.a.push(i); + } + } +} + +@noinline +function churn() { + const junk: V[] = []; + for (let i = 0; i < 64; i++) { + junk.push(new V(999)); + } +} + +class K { + static w: V; + static { + for (let i = 0; i < 5; i++) { + const c = K.w; + const o = { v: c }; + K.w = new V(i + 1); + churn(); + if (i > 0) { + print(o.v.x, o.v.a.length); + } + } + } +} + +function main() { + print(K.w.x); + print("done."); +} diff --git a/tslang/test/tester/own/own_err_global_uninit_let.ts b/tslang/test/tester/own/own_err_global_uninit_let.ts new file mode 100644 index 000000000..e95a5c639 --- /dev/null +++ b/tslang/test/tester/own/own_err_global_uninit_let.ts @@ -0,0 +1,43 @@ +// -mm=own rejects: `let l!: V` inside `f`'s loop stores nothing, so the slot keeps the value of +// the round before - the read of `G` stored in round 1 - and the later rounds read it after +// `G = new V(i + 1)` freed that block. A declaration without an initializer gives a holder no new +// value. +class V { + a: number[] = []; + constructor(public x: number) { + for (let i = 0; i < 64; i++) { + this.a.push(i); + } + } +} + +@noinline +function churn() { + const junk: V[] = []; + for (let i = 0; i < 64; i++) { + junk.push(new V(999)); + } +} + +let G = new V(0); + +function f() { + for (let i = 0; i < 4; i++) { + let l!: V; + if (i > 1) { + print(l.x, l.a.length); + } + const c = G; + if (i == 1) { + l = c; + } + G = new V(i + 1); + churn(); + } +} + +function main() { + f(); + print(G.x); + print("done."); +} diff --git a/tslang/test/tester/own/own_err_global_uninit_let_static.ts b/tslang/test/tester/own/own_err_global_uninit_let_static.ts new file mode 100644 index 000000000..1a49497ac --- /dev/null +++ b/tslang/test/tester/own/own_err_global_uninit_let_static.ts @@ -0,0 +1,43 @@ +// -mm=own rejects: `let l!: V` inside the static block's loop stores nothing, so the slot keeps +// the value of the round before - the read of `K.w` stored in round 1 - and the later rounds read +// it after `K.w = new V(i + 1)` freed that block. A declaration without an initializer gives a +// holder no new value (spec 25). +class V { + a: number[] = []; + constructor(public x: number) { + for (let i = 0; i < 64; i++) { + this.a.push(i); + } + } +} + +@noinline +function churn() { + const junk: V[] = []; + for (let i = 0; i < 64; i++) { + junk.push(new V(999)); + } +} + +class K { + static w: V; + static { + for (let i = 0; i < 4; i++) { + let l!: V; + if (i > 1) { + print(l.x, l.a.length); + } + const c = K.w; + if (i == 1) { + l = c; + } + K.w = new V(i + 1); + churn(); + } + } +} + +function main() { + print(K.w.x); + print("done."); +} diff --git a/tslang/test/tester/own/own_err_immortal_region_borrow.ts b/tslang/test/tester/own/own_err_immortal_region_borrow.ts index 8e3fbb7fc..cf231c7a9 100644 --- a/tslang/test/tester/own/own_err_immortal_region_borrow.ts +++ b/tslang/test/tester/own/own_err_immortal_region_borrow.ts @@ -2,13 +2,14 @@ // element `list` owns. Marking that block immortal would make the array's free of it a silent // leak, so `c` stays mortal, and `f` returns a borrow of it. The call goes through a function value // `pick` returns, whose facts the global's region does not know (#638 cannot see it), so this is -// findImmortalGlobals' refusal of any call's result, not the region check. (`firstOf`, returned as -// a value, is an error of its own.) +// findImmortalGlobals' refusal of any call's result, not the region check. (`firstOf` says it +// lends its argument, so it is no error of its own.) class V { constructor(public x: number) {} } const list = [new V(1)]; -function firstOf(a: V[]) { +@lends("a") +function firstOf(a: V[]): V { return a[0]; } function pick(): (a: V[]) => V { From ce3371b580b76f64b8f23a758add5642673aa18d Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Fri, 23 Oct 2026 07:00:35 +0100 Subject: [PATCH 57/57] own-raytrace final: write-guard negatives, shared helpers, spec fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whole-branch review's fix list (final-review.md I2-I4, M3, M4, M6, M7). No change to what -mm=own accepts, except a method's @lends past argument 63, now an error under every memory model (MLIRGen). I4 - the immortal-global write guards each get a program and an IR check (test-own-global-mortal-*, --emit=mlir-affine: the guarded global's ts.AddressOf is not marked __own_immortal, the control's is): an exported `let` and an exported class's static, a static written through `k: typeof M` (the vtableWritten route), two stores in one static block, and `this.w =` in a static method as the static's only store. Teeth: one OWN_TEETH switch per guard (removed) fails exactly its own check. I2 (behaviour-preserving): - resolveInterfaceSlot / resolveInterfaceField were one body over two maps: one template taking the family map. - "the initializer region yields only the default" is one helper, own_facts::regionYieldsDefault, used by findImmortalGlobals and by GlobalOpLowering; they can no longer disagree on the op list. - own_facts::globalReadOf is the load-of-AddressOf walk that isImmortalGlobalRead and findImmortalGlobals' readsImmortal share (the latter still tests the set being found, not the attribute). M4 - a method's @lends is kept as a 64-bit mask for the class's declaration; an index past 63 was dropped silently, a borrow an importer never learned of. Now an error, own_err_lends_index_64. Spec (own-model): the §21.2 row no longer names the deleted own_err_record_literal_let and says what §32 accepts; §25's holder renewal says "when it has an initializer" as N4 does; §33's rc note says "a call that keeps it"; §31 says the exe rule does not cover functions (callableFromOutside: a sound over-rejection); §34 loses its process note; §25 and §32 record the new tests and teeth. Design doc G1 no longer lists a fresh call result. Both READMEs date the own row 2026-10-23 in the table. Co-Authored-By: Claude Opus 5.5 --- README.md | 5 +- tslang/benchmarks/raytrace/README.md | 6 +- .../2026-09-24-own-memory-model-design.md | 43 +++++++++++---- .../specs/2026-10-21-own-raytrace-design.md | 3 +- tslang/lib/TypeScript/DeclarationPrinter.cpp | 1 + tslang/lib/TypeScript/LowerToLLVM.cpp | 12 ++-- tslang/lib/TypeScript/MLIRGenImpl.h | 11 +++- tslang/lib/TypeScript/OwnershipFacts.h | 22 +++++++- .../lib/TypeScript/OwnershipSignaturePass.cpp | 55 +++++-------------- tslang/test/tester/CMakeLists.txt | 14 +++++ .../test/tester/own/own_err_lends_index_64.ts | 25 +++++++++ .../tester/own/own_global_mortal_export.ts | 19 +++++++ .../own/own_global_mortal_static_method.ts | 26 +++++++++ .../own/own_global_mortal_static_twice.ts | 25 +++++++++ .../tester/own/own_global_mortal_typeof.ts | 23 ++++++++ 15 files changed, 223 insertions(+), 67 deletions(-) create mode 100644 tslang/test/tester/own/own_err_lends_index_64.ts create mode 100644 tslang/test/tester/own/own_global_mortal_export.ts create mode 100644 tslang/test/tester/own/own_global_mortal_static_method.ts create mode 100644 tslang/test/tester/own/own_global_mortal_static_twice.ts create mode 100644 tslang/test/tester/own/own_global_mortal_typeof.ts diff --git a/README.md b/README.md index e98b0ab15..fd469227f 100644 --- a/README.md +++ b/README.md @@ -759,7 +759,8 @@ what to do about it. ### Performance The ray tracer benchmark in [tslang/benchmarks/raytrace](tslang/benchmarks/raytrace/README.md) -(`raytrace.ts` at 512x512, median of 7 runs, 2026-10-21) against Rust and C++ ports of the same +(`raytrace.ts` at 512x512, median of 7 runs; the `-mm=own` row measured 2026-10-23, the other rows +2026-10-21) against Rust and C++ ports of the same code, each written twice: with plain value vectors and colors, and with every object reference-counted on the heap the way tslang allocates them: @@ -768,7 +769,7 @@ reference-counted on the heap the way tslang allocates them: | `-mm=gc` | 1298 ms | 6 MB | 26x slower | | `-mm=rc` | 599 ms | 4 MB | 12x slower | | `-mm=none` | 703 ms | 845 MB | 14x slower | -| `-mm=own` | 628 ms | 4.5 MB | 12x slower | +| `-mm=own` (2026-10-23) | 628 ms | 4.5 MB | 12x slower | | C++, values (clang / MSVC) | 51 / 69 ms | | | | Rust, values (`Copy` vectors and colors) | 55 ms | 4 MB | 1.1x slower | | Rust, every object in an `Rc` | 492 ms | 4 MB | 9.7x slower | diff --git a/tslang/benchmarks/raytrace/README.md b/tslang/benchmarks/raytrace/README.md index f2e4d5aed..2d5699881 100644 --- a/tslang/benchmarks/raytrace/README.md +++ b/tslang/benchmarks/raytrace/README.md @@ -26,7 +26,7 @@ pwsh tslang/benchmarks/raytrace/bench.ps1 - Rust: `opt-level=3`, `lto="fat"`, `codegen-units=1`, `panic="abort"` - C++: `clang++ -std=c++20 -O3`, and `cl /std:c++20 /O2 /EHsc /MT` -## Results 2026-10-21 +## Results 2026-10-21 (own rows 2026-10-23) main 49b5f6bb, Release, Windows 11 x64; Rust 1.99.0, clang 21.1.4, MSVC from Visual Studio 18; median of 7 runs after a warm-up. Every build prints the same checksum. @@ -39,8 +39,8 @@ median of 7 runs after a warm-up. Every build prints the same checksum. | tslang rc, `--no-stack-promotion` | 600 ms | | | | tslang none | 703 ms | 845 MB | 14x | | tslang none, `--no-stack-promotion` | 727 ms | | | -| tslang own | 628 ms | 4.5 MB | 12x | -| tslang own, `--no-stack-promotion` | 627 ms | | | +| tslang own (2026-10-23) | 628 ms | 4.5 MB | 12x | +| tslang own, `--no-stack-promotion` (2026-10-23) | 627 ms | | | | Rust idiomatic | 55 ms | 4 MB | 1.1x | | Rust Rc-boxed | 492 ms | 4 MB | 9.7x | | C++ idiomatic (clang) | 51 ms | | 1x | 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 a49ad119a..a3aa8f239 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 @@ -1626,7 +1626,7 @@ place, so all four places that report it get the same names. Each message has a | a global's value, stored, returned or boxed | `'x' borrows the global 'g' and cannot be stored, returned or captured` | `own_err_global_returned` | | a global's value read into a local | `'x' borrows the global 'g', but -mm=own cannot prove the global keeps its value while 'x' is used: a call or an assignment may overwrite it` | `own_err_global_let_call` | | a captured variable's value, through its cell | `'x' borrows a captured variable and cannot be stored, returned or captured` | `own_err_captured_returned` | -| an object literal holding a value it does not own outright: a `let` read into a field, a borrowed field (a parameter, `this.pos`), or a literal nested by value (since §27; before, any literal given anywhere but an owning local or the result) | `an object literal that holds a value it owns is moved only into a local or returned as it is; -mm=own cannot move it here yet` | `own_err_record_literal_let` (§27; was `own_err_record_literal_given`) | +| an object literal holding a value it does not own outright: a literal nested by value, or one holding a borrowed or `let` field when it is not seen as an interface (§32; since §27, before which any literal given anywhere but an owning local or the result was one) | `an object literal that holds a value it owns is moved only into a local or returned as it is; -mm=own cannot move it here yet` | the interface-typed shapes are borrowing records (§32): `own_err_borrowing_record_*`, `own_literal_moves`, `own_borrowing_record_arg`; `own_err_record_literal_let` (§27) is gone | The mixed-return message is the largest bucket (17 files). Most are two synthesized helpers: - `___unbox` (`` of an `any`) returns the box's own string when it holds one, and a @@ -2899,6 +2899,22 @@ counting. `test-own-global-loop-store-mortal` checks its `--emit=mlir-affine`: ` 'K.w' and cannot be stored, returned or captured". Teeth (a temporary `OWN_TEETH` switch skipping `mayRunAgain`, removed): the mortal check fails (`K.w`/`S.w` marked) and both negatives compile. +**The other write guards (final review I4, 2026-10-23).** A global wrongly marked immortal and +later written frees what its reads were taken to own nothing of - a use-after-free, not a leak - +so each guard of "written once, by its initializer" has a program of its own, checked the same +way (`test-own-global-mortal-*`, `--emit=mlir-affine`: `M`'s `ts.AddressOf` is not marked +`__own_immortal`, `I`'s is): `own_global_mortal_export` (`export let M` and an exported class's +static `MK.m`), `own_global_mortal_typeof` (`k.w = I` with `k: typeof M`: the vtable-write guard, +the only one that reads a `ts.VirtualSymbolRef`'s users; a write through `typeof K` is that route, +not another), `own_global_mortal_static_twice` (two stores in one static block) and +`own_global_mortal_static_method` (`this.w = new V(3)` in a static method, the static's only +store: a function no `ts.GlobalConstructor` runs). Each program has one guard between `M` and +immortality. Teeth (one temporary `OWN_TEETH` switch per guard, removed): skipping the `export` +test, `vtableWritten`, the second-store test (`initStore`) or the static-constructor test each +makes its own check fail (`M`, `MK.m` / `M.w` marked) and leaves the other three passing. The +typeof write of a fresh value (`k.w = new V(3)`) is not used: it is an error today (`'this value' +takes a second reference`), and `k.w = M.w` reaches checkGlobalRead's keep backstop. + **A `const` read of a mortal global ends at its assignment (fix-up review C1, 2026-10-23).** Making those globals mortal exposed an older hole, on main too: `const c = G` names the read itself (a `ts.Load` of the global's `ts.AddressOf`, `__owned_result_named`, with no retain), which no check @@ -2983,10 +2999,10 @@ reads the read's block in the next round - the path back from the store goes thr `const c = G`, which renews `c`, not `o.v` - and own accepted it (exit 139 on Windows and Linux; the module-global form on main too, the static-block and top-level forms since the loop-store fix-up). So a holder's uses are now made again only where the holder is given a new value -(`holderRenewals`): the local's declaration, and every store into the field path that holds the -borrow or into a prefix of it (the whole local, `o.p` for `o.p.v`); a store into a sibling field -or below the path is not one. Those depend on the holder alone, not on the way the walk reached -it, so a holder is followed once. A value loaded out of the holder is made again only by a path +(`holderRenewals`): the local's declaration when it has an initializer (N4, below), and every +store into the field path that holds the borrow or into a prefix of it (the whole local, `o.p` for +`o.p.v`); a store into a sibling field or below the path is not one. Those depend on the holder +alone, not on the way the walk reached it, so a holder is followed once. A value loaded out of the holder is made again only by a path back through that load, which is itself a use bounded by the renewals; and a store that is a renewal is also a use of the value it stores, bounded by that value's own renewals, so a store of a stale borrow into the path is rejected at the store. Reachability goes around the back edge: a @@ -3605,7 +3621,11 @@ a block freed too early is reused before it is read. library stay as they were; the pass takes the kind as an option, a library when not told. `own_interface_exe_exports` (an exported interface and an exported global of it) runs under the JIT and as `tslang --emit=exe`, and is the error on `G.get` as `--emit=obj` and `--emit=dll` - (`own-exe-exports.cmake`). + (`own-exe-exports.cmake`). The rule covers interfaces and classes, not functions: + `callableFromOutside` ignores the kind, so a program's `export function` is still taken as one + another module calls - never a closed function, a callback-family member or a family member with + facts of its own. A sound over-rejection (final review M3): the function is treated as a + library's exported function is. - **An iterator `next()` through a closed interface** (ruling) gets the family's facts like any other call; it is no longer assumed fresh. @@ -3920,7 +3940,12 @@ it), `_let_outlives`, `_cell`, `_kept_param`, `_place_overwritten`, `_returned_l `_field_written`, `_borrowed_again`. `own_err_record_literal_let` (§27: a `let` read into a field) is a correct program now - the record borrows the `let`'s value, and the `let` keeps its release - and is gone; its shape is in `own_borrowing_record_arg`. `own_err_lends_two_params` now reads `'@lends' -of 'pick' names 'a', 'b', but its body returns a borrow of 'a'`. +of 'pick' names 'a', 'b', but its body returns a borrow of 'a'`. A method's `@lends` reaches its +class's declaration as a 64-bit mask, so since the final review (M4, 2026-10-23) an index past 63 is +an error rather than a borrow an importer silently never learns of: `own_err_lends_index_64` (a +method of 64 parameters lending its last, argument 64), "'@lends' of 'K.pick' names its argument +64, but a method's '@lends' can name only its arguments 0 to 63". The compiler before it accepted +the program; the 63rd parameter (argument 63) is still accepted, under own, rc and gc. Teeth (temporary `OWN_TEETH` switches, removed): with the per-site routine dropped (every record's interface given its type's descriptor), the AOT runs of `own_borrowing_record` and @@ -4180,7 +4205,7 @@ the conditional assignment, compile and exit `0xC0000005` with no output. `own_r tracer (1 error, the same) and the corpus (528, the same files) are unchanged. Not here: -- A slot assigned inside the argument list of a call it was passed to (`f(n, (n = mk(7), 0))`, +- A slot assigned inside the argument list of a call that keeps it (`f(n, (n = mk(7), 0))`, with or without a second operand, unconditionally or on one arm) crashes under rc as well (exit 139): rc releases the slot's old value before the store, ahead of the callee that keeps it. That is rc's codegen, out of scope here; own rejects these shapes since the third and fourth re-reviews. @@ -4301,5 +4326,3 @@ Not here: shows a use-after-free that only those two tests prevent. - The benchmark keeps its two source edits; the unedited `rtbench.ts` does not compile under own (a non-goal of the plan). -- This section is 34, not 35 as the Task 9 addendum expected: the merge of main left notes in §25 - and §29 and no section of its own. diff --git a/tslang/docs/superpowers/specs/2026-10-21-own-raytrace-design.md b/tslang/docs/superpowers/specs/2026-10-21-own-raytrace-design.md index 12c6aeb4d..a179be49b 100644 --- a/tslang/docs/superpowers/specs/2026-10-21-own-raytrace-design.md +++ b/tslang/docs/superpowers/specs/2026-10-21-own-raytrace-design.md @@ -90,7 +90,8 @@ Today: `'this value' borrows the global 'K.w' and cannot be stored, returned or module is the program's entry and nothing imports it - other modules may assign it. (In the ray tracer, `Color.*` statics are not exported; `Surfaces.*` are `Shared` anyway.) - **Which values** (builder check, my call): only a global whose initializer is a fresh value - (`new C()`, a fresh call result, an owning literal) or another immortal global. A borrowed + (`new C()`, an owning literal) or another immortal global; no call's result, fresh or not (as + built: own-model spec §25, which supersedes this list). A borrowed initializer (`static x = arr[0]`, a call that returns a borrow) stays mortal and keeps today's error: marking a block someone else owns immortal would turn its later free into a silent leak. - **Mechanism:** the existing `HEAP_BLOCK_IMMORTAL` header (OwnershipRoutineLogic.h): the global's diff --git a/tslang/lib/TypeScript/DeclarationPrinter.cpp b/tslang/lib/TypeScript/DeclarationPrinter.cpp index 06c452c53..57bf0d2da 100644 --- a/tslang/lib/TypeScript/DeclarationPrinter.cpp +++ b/tslang/lib/TypeScript/DeclarationPrinter.cpp @@ -700,6 +700,7 @@ namespace typescript newline(); } + // every index `@lends` names: MLIRGen refuses one past 63, which the mask cannot hold SmallVector lends; for (int32_t index = 0; index < 64; ++index) { diff --git a/tslang/lib/TypeScript/LowerToLLVM.cpp b/tslang/lib/TypeScript/LowerToLLVM.cpp index 498a14efe..2854a8ca3 100644 --- a/tslang/lib/TypeScript/LowerToLLVM.cpp +++ b/tslang/lib/TypeScript/LowerToLLVM.cpp @@ -40,7 +40,7 @@ #include "TypeScript/LowerToLLVM/LLVMDebugInfoFixer.h" #include "TypeScript/LowerToLLVM/ArrayLayout.h" -// OWN_IMMORTAL_GLOBAL_ATTR_NAME +// OWN_IMMORTAL_GLOBAL_ATTR_NAME, own_facts::regionYieldsDefault #include "OwnershipFacts.h" #include "scanner_enums.h" @@ -5017,14 +5017,10 @@ struct GlobalOpLowering : public TsLlvmPattern // an immortal global (own spec 25) whose region gives it its value gets it from a store, // which marks the block. A class static's region yields only the default: the store in the // static constructor gives it its value, and a constructor here would run after that one. - if (globalOp->hasAttr(OWN_IMMORTAL_GLOBAL_ATTR_NAME) && globalOp.getInitializerBlock()) + if (globalOp->hasAttr(OWN_IMMORTAL_GLOBAL_ATTR_NAME) && globalOp.getInitializerBlock() && + !own_facts::regionYieldsDefault(globalOp)) { - auto *terminator = globalOp.getInitializerBlock()->getTerminator(); - auto *valueOp = terminator && terminator->getNumOperands() == 1 ? terminator->getOperand(0).getDefiningOp() : nullptr; - if (!mlir::isa_and_nonnull(valueOp)) - { - createAsGlobalConstructor = true; - } + createAsGlobalConstructor = true; } auto linkage = globalOp.getLinkage(); diff --git a/tslang/lib/TypeScript/MLIRGenImpl.h b/tslang/lib/TypeScript/MLIRGenImpl.h index 8fc48148a..6f197a9d1 100644 --- a/tslang/lib/TypeScript/MLIRGenImpl.h +++ b/tslang/lib/TypeScript/MLIRGenImpl.h @@ -12686,7 +12686,16 @@ class MLIRGenImpl methodInfo.ownLendsMask = 0; for (auto index : lends.asArrayRef()) { - if (index >= 0 && index < 64) + // the mask the class's declaration is printed from (DeclarationPrinter) has + // 64 bits: an index past it would be a borrow an importer never learns of + if (index >= 64) + { + emitError(location) << "'@lends' of '" << funcOp.getSymName() << "' names its argument " << index + << ", but a method's '@lends' can name only its arguments 0 to 63"; + return false; + } + + if (index >= 0) { methodInfo.ownLendsMask |= uint64_t(1) << index; } diff --git a/tslang/lib/TypeScript/OwnershipFacts.h b/tslang/lib/TypeScript/OwnershipFacts.h index 51c81f969..9cfb98266 100644 --- a/tslang/lib/TypeScript/OwnershipFacts.h +++ b/tslang/lib/TypeScript/OwnershipFacts.h @@ -269,16 +269,34 @@ inline mlir::Value rootOf(mlir::Value value) return value; } +// The `ts.AddressOf` of the global a value is read out of, seen through its views, or null. +inline mlir_ts::AddressOfOp globalReadOf(mlir::Value value) +{ + auto loadOp = rootOf(value).getDefiningOp(); + return loadOp ? loadOp.getReference().getDefiningOp() : mlir_ts::AddressOfOp(); +} + // A read of an immortal global (OWN_IMMORTAL_GLOBAL_ATTR_NAME), seen through its views: the // global's own block, which no release frees. Its fields are not immortal: a read out of one is a // place read like any other. inline bool isImmortalGlobalRead(mlir::Value value) { - auto loadOp = rootOf(value).getDefiningOp(); - auto addressOfOp = loadOp ? loadOp.getReference().getDefiningOp() : mlir_ts::AddressOfOp(); + auto addressOfOp = globalReadOf(value); return addressOfOp && addressOfOp->hasAttr(OWN_IMMORTAL_GLOBAL_ATTR_NAME); } +// Does a global's initializer region yield only the default (`ts.Default`, `ts.Null`, +// `ts.Undef`)? A class static's does: the one store in its static constructor gives it its value +// (spec 25). OwnershipSignaturePass marks such a global immortal only with that store, and the +// lowering then makes no `__cctor` of the region, which would put the default back after it. +inline bool regionYieldsDefault(mlir_ts::GlobalOp globalOp) +{ + auto *block = globalOp.getInitializerBlock(); + auto *terminator = block ? block->getTerminator() : nullptr; + auto *valueOp = terminator && terminator->getNumOperands() == 1 ? terminator->getOperand(0).getDefiningOp() : nullptr; + return mlir::isa_and_nonnull(valueOp); +} + // Every user of `root` and of the views of it, with the value it uses. A view itself is not a // use. inline void forEachUse(mlir::Value root, llvm::function_ref each) diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index 717076d1e..59496e0c8 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -297,13 +297,12 @@ class OwnershipSignaturePass : public mlir::PassWrappergetOperand(0); - if (initStore && !mlir::isa_and_nonnull(regionValue.getDefiningOp())) + if (initStore && !regionYieldsDefault(globalOp)) { return; } - candidates[globalOp] = {initStore ? initStore.getValue() : regionValue, initStore}; + candidates[globalOp] = {initStore ? initStore.getValue() : resultOp->getOperand(0), initStore}; }); // given a fresh object, nothing, or another immortal global's value: in module order, until @@ -321,9 +320,9 @@ class OwnershipSignaturePass : public mlir::PassWrapper(user); }); return !released; }; + // isImmortalGlobalRead, of the globals found so far: none is marked yet auto readsImmortal = [&](mlir::Value value) { - auto loadOp = rootOf(value).getDefiningOp(); - auto addressOfOp = loadOp ? loadOp.getReference().getDefiningOp() : mlir_ts::AddressOfOp(); + auto addressOfOp = globalReadOf(value); return addressOfOp && immortal.contains(addressOfOp.getGlobalName()); }; @@ -3087,7 +3086,7 @@ class OwnershipSignaturePass : public mlir::PassWrapper(def)) { - resolveInterfaceSlot(interfaceRefOp, callees); + resolveInterfaceSlot(interfaceRefOp, interfaceFamilies, callees); return; } else if (auto loadOp = mlir::dyn_cast_or_null(def)) @@ -3104,7 +3103,7 @@ class OwnershipSignaturePass : public mlir::PassWrapper(); if (refOp && isInterfaceField(refOp.getResult()) && thisOp && thisOp.getInterfaceVal() == refOp.getInterfaceVal()) { - resolveInterfaceField(refOp, callees); + resolveInterfaceSlot(refOp, interfaceFieldFamilies, callees); } return; @@ -3126,9 +3125,13 @@ class OwnershipSignaturePass : public mlir::PassWrapper + void resolveInterfaceSlot(mlir_ts::InterfaceSymbolRefOp refOp, const std::map, Family> &families, + Callees &callees) { auto interfaceType = mlir::dyn_cast(refOp.getInterfaceVal().getType()); auto identifier = mlir::dyn_cast(refOp.getIdentifier()); @@ -3138,36 +3141,8 @@ class OwnershipSignaturePass : public mlir::PassWrapper(refOp.getIndex()) + INTERFACE_VTABLE_HEADER_SLOTS; - auto found = interfaceFamilies.find({interfaceType.getName().getValue().str(), position}); - if (found == interfaceFamilies.end() || !found->second.closed || found->second.field != identifier || - found->second.members.empty()) - { - return; - } - - callees.named.assign(found->second.members.begin(), found->second.members.end()); - callees.viaInterface = true; - callees.known = true; - for (auto member : found->second.members) - { - callees.known = addDefined(member, callees) && callees.known; - } - } - - // A function-typed field of a closed interface (interfaceFieldFamilies), read and called: every - // function its implementing literals' fields are ever given. - void resolveInterfaceField(mlir_ts::InterfaceSymbolRefOp refOp, Callees &callees) - { - auto interfaceType = mlir::dyn_cast(refOp.getInterfaceVal().getType()); - auto identifier = mlir::dyn_cast(refOp.getIdentifier()); - if (!interfaceType || !identifier) - { - return; - } - - auto position = static_cast(refOp.getIndex()) + INTERFACE_VTABLE_HEADER_SLOTS; - auto found = interfaceFieldFamilies.find({interfaceType.getName().getValue().str(), position}); - if (found == interfaceFieldFamilies.end() || !found->second.closed || found->second.field != identifier || + auto found = families.find({interfaceType.getName().getValue().str(), position}); + if (found == families.end() || !found->second.closed || found->second.field != identifier || found->second.members.empty()) { return; diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 490ad79a7..b4c72b716 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -3540,6 +3540,19 @@ set_tests_properties(test-own-global-loop-store-mortal PROPERTIES PASS_REGULAR_EXPRESSION "global_name = @T[.]w}> {__own_immortal}" FAIL_REGULAR_EXPRESSION "global_name = @[KS][.]w}> {__own_immortal}|error|Stack dump") +# The other write guards of an immortal global (spec 25), one program each: an exported global, a +# static written through its class's vtable (`k: typeof M`), a second store in a static block, and a +# store in a static method leave `M` (`M.w`, `MK.m`) mortal; `I` (`I.w`) is immortal. +foreach(mortal_test export typeof static_twice static_method) + string(REPLACE "_" "-" mortal_name "${mortal_test}") + add_test(NAME test-own-global-mortal-${mortal_name} + COMMAND $ --emit=mlir-affine --no-default-lib -mm=own + "${PROJECT_SOURCE_DIR}/test/tester/own/own_global_mortal_${mortal_test}.ts") + set_tests_properties(test-own-global-mortal-${mortal_name} + PROPERTIES PASS_REGULAR_EXPRESSION "global_name = @I([.]w)?}> {__own_immortal}" + FAIL_REGULAR_EXPRESSION "global_name = @M[^}]*}> {__own_immortal}|error|Stack dump") +endforeach() + # An object literal that owns its fields moves anywhere a fresh object does under own (spec 27); # the same programs under every other model. foreach(owning_record_mm rc gc none) @@ -3997,6 +4010,7 @@ set(own_error_cases "own_err_lends_not_borrow|'@lends' of 'made' names 'l', but its body does not return a borrow of it" "own_err_lends_and_borrows|'firstOf' cannot say both '@lends' and '@borrows'" "own_err_lends_two_params|'@lends' of 'pick' names 'a', 'b', but its body returns a borrow of 'a'" + "own_err_lends_index_64|'@lends' of 'K[.]pick' names its argument 64, but a method's '@lends' can name only its arguments 0 to 63" "own_err_lends_outlives|borrows the result of 'firstOf' but is used here after it may be released or overwritten" "own_err_generator_method_this|'this value' is a parameter, kept here, but -mm=own cannot make the callers of 'S.chars' give it up" "own_err_string_call_keeps_twice|borrows a field and cannot be stored, returned or captured" diff --git a/tslang/test/tester/own/own_err_lends_index_64.ts b/tslang/test/tester/own/own_err_lends_index_64.ts new file mode 100644 index 000000000..790ee96cc --- /dev/null +++ b/tslang/test/tester/own/own_err_lends_index_64.ts @@ -0,0 +1,25 @@ +// -mm=own rejects: a method's '@lends' is kept as a mask of 64 bits for the class's declaration, +// so it can name only the method's arguments 0 to 63 (the receiver is 0); 'p63' is argument 64. +class V { + constructor(public x: number) {} +} + +class K { + @lends("p63") + pick( + p0: V, p1: V, p2: V, p3: V, p4: V, p5: V, p6: V, p7: V, + p8: V, p9: V, p10: V, p11: V, p12: V, p13: V, p14: V, p15: V, + p16: V, p17: V, p18: V, p19: V, p20: V, p21: V, p22: V, p23: V, + p24: V, p25: V, p26: V, p27: V, p28: V, p29: V, p30: V, p31: V, + p32: V, p33: V, p34: V, p35: V, p36: V, p37: V, p38: V, p39: V, + p40: V, p41: V, p42: V, p43: V, p44: V, p45: V, p46: V, p47: V, + p48: V, p49: V, p50: V, p51: V, p52: V, p53: V, p54: V, p55: V, + p56: V, p57: V, p58: V, p59: V, p60: V, p61: V, p62: V, p63: V + ): V { + return p63; + } +} + +function main() { + print("done."); +} diff --git a/tslang/test/tester/own/own_global_mortal_export.ts b/tslang/test/tester/own/own_global_mortal_export.ts new file mode 100644 index 000000000..679405ba3 --- /dev/null +++ b/tslang/test/tester/own/own_global_mortal_export.ts @@ -0,0 +1,19 @@ +// An exported global is mortal under -mm=own (spec 25), even when this module never writes it: +// another module may, and its store would free the block a read here was taken to own nothing of. +// `M` (a module-level `export let`) and `MK.m` (a static of an exported class) stay mortal; `I`, the +// same `let` not exported, is immortal (test-own-global-mortal-export reads the IR). +class V { + constructor(public x: number) {} +} + +export let M = new V(2); + +export class MK { + static m = new V(3); +} + +let I = new V(1); + +function main() { + print(I.x, M.x, MK.m.x); // 1 2 3 +} diff --git a/tslang/test/tester/own/own_global_mortal_static_method.ts b/tslang/test/tester/own/own_global_mortal_static_method.ts new file mode 100644 index 000000000..5d3498755 --- /dev/null +++ b/tslang/test/tester/own/own_global_mortal_static_method.ts @@ -0,0 +1,26 @@ +// A static method that stores into its class's static (`this.w = x`) is no static constructor: it +// may run any number of times, each store freeing the value before it, so `M.w` stays mortal under +// -mm=own (spec 25) though it is its only store. `I.w`, stored once in its static block, is immortal +// (test-own-global-mortal-static-method reads the IR). +class V { + constructor(public x: number) {} +} + +class M { + static w: V; + static set() { + this.w = new V(3); + } +} + +class I { + static w: V; + static { + I.w = new V(1); + } +} + +function main() { + M.set(); + print(I.w.x, M.w.x); // 1 3 +} diff --git a/tslang/test/tester/own/own_global_mortal_static_twice.ts b/tslang/test/tester/own/own_global_mortal_static_twice.ts new file mode 100644 index 000000000..db145ed83 --- /dev/null +++ b/tslang/test/tester/own/own_global_mortal_static_twice.ts @@ -0,0 +1,25 @@ +// A static block that stores into a static twice leaves it mortal under -mm=own (spec 25): a read of +// the first value, taken for the immortal block, would outlive the second store, which frees it. +// `I.w`, stored once, is immortal (test-own-global-mortal-static-twice reads the IR). +class V { + constructor(public x: number) {} +} + +class M { + static w: V; + static { + M.w = new V(2); + M.w = new V(3); + } +} + +class I { + static w: V; + static { + I.w = new V(1); + } +} + +function main() { + print(I.w.x, M.w.x); // 1 3 +} diff --git a/tslang/test/tester/own/own_global_mortal_typeof.ts b/tslang/test/tester/own/own_global_mortal_typeof.ts new file mode 100644 index 000000000..0193f5b63 --- /dev/null +++ b/tslang/test/tester/own/own_global_mortal_typeof.ts @@ -0,0 +1,23 @@ +// A static written through its class's vtable - `k.w = x` with `k: typeof M`, a `ts.VirtualSymbolRef` +// stored through - may be any class's static the slot holds, so no static is immortal under -mm=own +// (spec 25): `M.w` stays mortal, though its static block stores it once. `I`, a module-level global +// in no vtable, is immortal (test-own-global-mortal-typeof reads the IR). +class V { + constructor(public x: number) {} +} + +class M { + static w = new V(2); +} + +const I = new V(1); + +class D { + set(k: typeof M) { + k.w = I; + } +} + +function main() { + print(I.x, M.w.x); // 1 2 +}