From 925cedf1497668abeedaf4f233232d8d60eced08 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Tue, 29 Sep 2026 17:23:32 +0100 Subject: [PATCH 1/6] -mm=own phase 4: plan Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-09-29-own-phase-4.md | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 tslang/docs/superpowers/plans/2026-09-29-own-phase-4.md diff --git a/tslang/docs/superpowers/plans/2026-09-29-own-phase-4.md b/tslang/docs/superpowers/plans/2026-09-29-own-phase-4.md new file mode 100644 index 000000000..b165e3ceb --- /dev/null +++ b/tslang/docs/superpowers/plans/2026-09-29-own-phase-4.md @@ -0,0 +1,215 @@ +# `-mm=own` Phase 4 Implementation Plan: function signatures and `any` + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development +> (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Under `-mm=own`, a function's body tells its callers three facts (spec §2.4, §3.2), and +the inference uses them at every call it can resolve. Today: + +- `function keep(h: H, c: C) { h.c = c; }` is rejected ("takes a second reference"): nothing lets + a callee keep what it is given. +- `get c() { return this.c; }` and `function first(h: H) { return h.c; }` are rejected ("borrows + 'this.c' and cannot be stored, returned or captured"). +- `` `${this.color} area=${this.area()}` `` is rejected: any call drops a borrow of a field of + `this`, even one to a method that only reads numbers (§14.4's three corpus regressions). +- `any` is rejected, and rc leaks every `any` box that no `let` holds (§14.5: rc 66.2 MB). + +After this phase all four compile, and the unsound shapes around them are errors. + +**Architecture:** + +1. **`OwnershipSignaturePass`** (new module pass, own only, just before inference). It resolves + every call to the functions it may reach, computes each function's facts, and pins them on the + call ops, where the per-function inference reads them without looking anything up: + - `__own_params` (array of indices): the parameters the callee keeps (owned-by-callee). + - `__own_result_borrows` (index): the result is a borrow of that argument. + - `__own_no_drops` (unit): the callee destroys nothing its caller can reach. + The same attributes on a `ts.Func` are the facts its own body must honour. +2. **`OwnershipInferencePass`** learns the three facts on both sides of a call. +3. **MLIRGen**: the boxing cast into `any` carries its birth reference like every other fresh + value (`__owned_result` + `ts.Retain`), which is rc's leak fix and own's owner. + +**Tech Stack:** C++17, MLIR, CMake/ctest, `test/tester/test-runner.cpp`, +`test/tester/tools/measure.ps1`. + +**Spec:** `docs/superpowers/specs/2026-09-24-own-memory-model-design.md`: §2.4 (calls), §3.2 (the +signature pass), §14.5-14.6 (this phase's input). + +## Global Constraints + +- Branch `own-phase-4` from `origin/main` (edaf802a, with #403 and #404 merged). +- `gc`, `rc` and `none` output does not change, except Task 5's deliberate rc leak fix (the `any` + box), which changes rc's and gc's IR by one retain and one release per box. +- Never branch on the memory model in MLIRGen (§4.3). The signature pass runs only under own. +- `__tslang_inc_ref` and `__tslang_dec_ref` never appear in own output. +- Own tests run with `--no-default-lib`, under JIT and AOT, and read through a `churn()` after the + point where a wrong release would free. Expected values are checked under `gc` first. +- Build: `cmake --build __build/tslang/windows-msbuild-2026-release --config Release --target tslang test-runner` + from `I:/TypeScriptCompiler`. Test: `ctest -j 16 -C Release` from the build directory. +- Source files are CRLF. Commits are GPG-signed, each ending with the session's `Co-Authored-By:`. +- Check IR shapes with `--emit=mlir-affine --own-skip-inference` and, since CSE runs first under + `--opt`/JIT, with `--emit=jit --mlir-print-ir-after-all --mlir-disable-threading`. +- Baseline corpus on this tree: 272 of 564 compile under `-mm=own --no-default-lib`. + +## Shapes (measured on edaf802a) + +- A parameter arrives as a non-owning `ts.Variable(%argN)`; `this` is `%arg0`. rc never releases + it. A callee that keeps it reads the slot, `ts.Retain`s what it read and stores it. +- A result goes through a non-owning result slot: `ts.Store(v, %ret)`, then `ts.Load(%ret)` into + `ts.ReturnInternal`. A returned field read is `ts.Retain`ed before its store: rc returns +1. +- The caller releases a call's result as a temporary (`ts.Release`), or declares a + `__owned_consumed` `let` from it (no `ts.RetainSlot`, one `ts.ReleaseSlot`). +- Every method call goes through the vtable, even with no subclass: `ts.ThisVirtualSymbolRef` or + `ts.VirtualSymbolRef` (`identifier`, `index`) feeding `ts.CallInternal`, directly or through + `ts.GetMethod`. An override sits at the same position of its class's `..vtbl` global under the + same method name (`B.get` and `D.get` at 2). +- `___unbox` reads `ts.Unbox` of its parameter, calls the payload's `.instanceOf` through the + vtable's first slot (`ts.VTableOffsetRef` index 0, twice), casts `!ts.opaque` to `T`, retains it + and returns it. `___cast` returns its parameter narrowed on each branch, or `null`. +- The boxing cast is +0 and unmarked. A box a `let` holds gets a `ts.RetainSlot` and is freed; a + box held by a folded `const` or passed straight to a call is never released. + +## Rulings made while planning + +- **Closed world.** An owned parameter or a borrowed result is sound only if *every* call that can + reach the function knows it: a caller that does not know borrows the argument and releases it, + or releases a result it does not own - a double free either way. So a function has those facts + only if it is not public, and every reference to its symbol is a direct call + (`ts.SymbolCallInternal`), its entry in a class's `..vtbl` global, or the `identifier` of a + virtual reference used only as a call's callee (directly or through `ts.GetMethod`/`ts.GetThis`). + A function reached any other way - a method taken as a value, an interface, an export - keeps + rc's convention, and its body's retain stays an error, now saying why. `__own_no_drops` goes the + other way (an unknown call is assumed to drop), so it needs no such check. Cost if wrong: none + for soundness; exported functions and interface methods get no facts until the export/import + work. +- **Virtual families meet.** A virtual call may reach every entry at its index with its method + name, in any `..vtbl` of the module (an unrelated class that happens to match only adds a + candidate). The call has an owned parameter only if every candidate owns it, a borrowed result + only if every candidate borrows the same argument, no drops only if no candidate drops. A + function whose own facts differ from the meet of a family it is in loses them (it keeps rc's + convention, and its body errors). Cost if wrong: a spurious error where a subclass disagrees. +- **A returned parameter is a borrowed result, not an owned parameter.** §2.4 says a callee that + returns a parameter owns it. `___cast` returns its parameter narrowed, and owning it would make + every `u` move `u`. Rust's elision (`fn id(c: &C) -> &C`) is the model. Cost if wrong: + `h.c = id(new C())` is an error (a borrow cannot be stored) where the owned reading would accept + it. +- **An owned parameter is moved on every path or it is an error.** The callee has no release for + it, so a path that does not move it would leak it and one that moves it twice would free it + twice. The move must dominate every `ts.ReturnInternal`, must not be in a loop, and nothing may + read the parameter after it. A throw before the move leaks it, as rc leaks a try-less function's + locals. Cost if wrong: `if (x) h.c = c;` is rejected (drop elaboration is not in scope). +- **A parameter that is assigned has no facts.** Its reads no longer see the argument. +- **A borrowed result's places are a wildcard.** The caller does not know which place under the + argument the result came from (`h.c`, `h.c.d`), so any overwrite of any field or element, and + any array op that removes elements, drops it. Its roots are the argument's, found as a place + read's are. Cost if wrong: spurious errors, never a missed one. +- **Drops.** A function may drop if it overwrites a field or an element (`ts.ReleaseSlot` of a + place) whose root it does not own, assigns a global, pops, shifts, splices or sets the length of + an array it does not own, or makes a call that may drop (an unresolved one always may). Least + fixpoint over the call graph. A constructor's stores into its own `this` are not drops: the + object is new, and nothing outside holds a borrow into it. Cost if wrong: a constructor called + through `super` on an object whose fields someone borrows - impossible in the current MLIRGen, + where a borrow of a field of `this` inside a constructor already ends at the call. +- **A call is a use of what it is given for as long as it runs.** Phase 3 skipped a call's own + arguments when the call was the drop ("read before it runs"). That is unsound: `f(h.c)` where + `f` resets `h.c` through a global and then reads its parameter reads freed memory. A call that + drops a chain is now an error when it is also given the borrow. Cost if wrong: extra errors in + code that passes a field to a function that overwrites fields. +- **The `.instanceOf` slot.** The call through a vtable's first slot is always a class's generated + `..instanceOf` (a string compare), which drops nothing. Without this, `___unbox` has an unknown + call between its read and its return. Cost if wrong: none today; nothing else is stored there. +- **The `any` box fix is in MLIRGen and changes rc.** The boxing cast gets `__owned_result` and a + `ts.Retain`, as `markFreshStringOwned` does for a printed number. That is rc's §14.5 leak fix, + and under own it makes the box a fresh value with one owner. Cost if wrong: rc suite failures, + which the full suite shows. +- **Export and import of facts is deferred.** §3.2 has a `-shared` build export them beside the + `__tsmm_own_*` marker. Under the closed-world rule an exported function gets no facts, so the + default stays sound without it. It is its own PR. + +## Review Focus + +1. Soundness of the closed world: any reference to a function's symbol that the classifier does + not recognise must drop the function's owned/borrowed facts. Grep the IR of every new test for + `SymbolRef` uses outside `..vtbl` globals. +2. A caller passing something it does not own (a parameter, a place read, a global) to an owned + parameter is an error, never a silent move. +3. The borrowed-result caller erases *both* the temporary's `ts.Release` and a consumed `let`'s + `ts.ReleaseSlot`s, and the callee erases the retain before its result store. Each is a teeth + test. +4. The `.instanceOf` resolution matches only a callee loaded from vtable slot 0 of a vtable + loaded from slot 0 of an object (`___unbox`'s shape). + +--- + +### Task 1: the signature pass and drops + +**Files:** +- Create: `lib/TypeScript/OwnershipSignaturePass.cpp`, `lib/TypeScript/OwnershipFacts.h` +- Modify: `include/TypeScript/Passes.h`, `lib/TypeScript/CMakeLists.txt`, `tslang/transform.cpp`, + `lib/TypeScript/OwnershipInferencePass.cpp` +- Tests: `test/tester/own/own_call_no_drops.ts` (``${this.color} area=${this.area()}``, a + `churn()` between a field read and its use), `own_err_call_drops_indirectly.ts` (a field of a + parameter read across a call to a function that calls one that assigns the global owner) + +Steps: resolve calls (`resolveCallees`: direct, virtual family, `.instanceOf` slot, else unknown); +compute `__own_no_drops` to a least fixpoint and pin it on functions and resolved calls. In the +inference, `dropsChain` asks a call's `__own_no_drops` first; `isCall` drops stay otherwise. +Remove the "a call's own arguments are read before it runs" exclusion when the call drops. +Corpus: the three `export_class_abstract*` files compile again. +Commit: `-mm=own phase 4: a call drops a borrow only if its callee may drop`. + +### Task 2: borrowed-from-argument results + +**Tests:** `own_getter_borrow.ts` (getter, method and free function returning a field, used and +held by a `let`), `own_cast_borrow.ts` (a function returning its parameter), negatives +`own_err_borrowed_result_stored.ts` (stored into a field), `own_err_borrowed_result_outlives.ts` +(the argument's field overwritten, then the result used), `own_err_borrowed_result_escapes.ts` +(the getter's function reached as a value: the callee body's error says why), +`own_err_borrowed_result_family.ts` (an override that returns a new value). + +Signature: a function whose every returned heap value is, through views, opaque casts, `ts.Unbox` +and place reads, the parameter `K`'s slot (never assigned) - `null`/`undefined` and non-heap values +allowed - has `__own_result_borrows = K`, subject to the closed world and the family meet. +Inference, callee: a place read or parameter view that reaches the result slot of such a function +is not kept; its `ts.Retain` is erased. Caller: a call carrying `__own_result_borrows` is a borrow +read like a place read - wildcard places, roots through argument `K` - whose `ts.Release` and whose +consumed `let`s' `ts.ReleaseSlot`s are erased. +Commit: `-mm=own phase 4: a result that borrows an argument is a borrow at the call`. + +### Task 3: owned-by-callee parameters + +**Tests:** `own_param_kept.ts` (`keep(h, new C(i))`, `keep(h, a)` from a `let`, forwarding through +a second function, a method storing its parameter into `this`), negatives +`own_err_param_kept_used.ts` (the argument read after the call), +`own_err_param_kept_some_paths.ts`, `own_err_param_kept_loop.ts` (an argument made outside a loop), +`own_err_param_kept_not_owned.ts` (a caller passing its own parameter or a field). + +Signature: parameter `K` (never assigned) is owned if a read of it is taken - stored into a place, +inserted into an array, or passed to an owned parameter - subject to the closed world and the +meet; fixpoint for forwarding. Inference, callee: the take is a move out of the parameter's slot +(no releases to erase; the retain goes); it must dominate every return, not loop, and nothing may +read the parameter after it. Caller: the call is a taker of each owned argument +(`takerAcquires` is true: rc's retain is inside the callee); a slot argument is a slot receiver +whose acquisition is the call; an argument the caller does not own is an error: +`argument 'x' is given to 'f', which keeps it, but -mm=own cannot move it here`. +Commit: `-mm=own phase 4: a callee may keep a parameter its caller moves to it`. + +### Task 4: `any` + +**Tests:** `own_any_box.ts` (`let` and `const` boxes, a box passed to a call, unboxed and read +after a `churn()`), negative `own_err_any_unboxed_outlives.ts` (the unboxed value used after its +box is reassigned). rc: `00owned_any_boxing.ts` and the §14.5 program under `measure.ps1`. + +MLIRGen: mark the boxing cast fresh (`__owned_result` + `ts.Retain`). Inference: a boxing cast is +a taker of its payload (it already is) and a fresh value; `___unbox` gets its borrowed result from +Task 2's signature rules through `ts.Unbox`, and its `.instanceOf` call is drop-free (Task 1). +Commit: `-mm=own phase 4: any`. + +### Task 5: results + +Measure (`measure.ps1`, O3 and O1) an owned-parameter loop, a getter loop and an `any` loop under +all four models, and rc on §14.5's program. Corpus report with the histogram. Teeth: keep the +caller's release of an owned argument; keep the callee's retain of a borrowed result; keep the +caller's release of a borrowed result. Debug `ctest -R own`. Spec §15 and the status line. +Commit: `-mm=own phase 4: results`. From 320d5a3f9d3c696d025163f7955806bffb9a406f Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Tue, 29 Sep 2026 17:23:48 +0100 Subject: [PATCH 2/6] -mm=own phase 4: a call drops a borrow only if its callee may drop OwnershipSignaturePass (new, own only, before inference) resolves every call it can - direct, a virtual call whose family is all private and defined here, the .instanceOf slot - and pins __own_no_drops where no candidate may destroy what its caller can reach (least fixpoint; a constructor filling its own object is not a drop). The inference's call rule asks it first. A call given a borrow now uses it for as long as the callee runs: phase 3 skipped a call's own arguments, so f(h.c) with f resetting h.c through a global and reading its parameter compiled. The function value an indirect call goes through, and the object a field-held method is bound to, are read as the call starts. The helpers both passes need move to OwnershipFacts.h. Co-Authored-By: Claude Opus 5.5 --- tslang/include/TypeScript/Passes.h | 5 + tslang/lib/TypeScript/CMakeLists.txt | 1 + tslang/lib/TypeScript/OwnershipFacts.h | 269 ++++++++ .../lib/TypeScript/OwnershipInferencePass.cpp | 247 ++----- .../lib/TypeScript/OwnershipSignaturePass.cpp | 629 ++++++++++++++++++ tslang/test/tester/CMakeLists.txt | 7 +- tslang/test/tester/own/own_call_no_drops.ts | 50 ++ .../own/own_err_call_drops_indirectly.ts | 29 + .../tester/own/own_err_call_given_borrow.ts | 22 + .../tester/own/own_err_call_this_borrow.ts | 20 + .../own/own_err_exported_virtual_call.ts | 17 + tslang/tslang/transform.cpp | 1 + 12 files changed, 1095 insertions(+), 202 deletions(-) create mode 100644 tslang/lib/TypeScript/OwnershipFacts.h create mode 100644 tslang/lib/TypeScript/OwnershipSignaturePass.cpp create mode 100644 tslang/test/tester/own/own_call_no_drops.ts create mode 100644 tslang/test/tester/own/own_err_call_drops_indirectly.ts create mode 100644 tslang/test/tester/own/own_err_call_given_borrow.ts create mode 100644 tslang/test/tester/own/own_err_call_this_borrow.ts create mode 100644 tslang/test/tester/own/own_err_exported_virtual_call.ts diff --git a/tslang/include/TypeScript/Passes.h b/tslang/include/TypeScript/Passes.h index 396d99724..81cf46239 100644 --- a/tslang/include/TypeScript/Passes.h +++ b/tslang/include/TypeScript/Passes.h @@ -35,6 +35,11 @@ std::unique_ptr createOwnershipVerifierPass(); /// acquisition and reports every other one. Runs at the affine level under own only. 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(); + /// Lets a call take over the reference its callee returned instead of retaining a second one. /// Runs on the whole module, after MLIRGen, because deciding which callees retain their result /// needs every function to be present - see docs/reference-counting-evaluation.md section 9.27. diff --git a/tslang/lib/TypeScript/CMakeLists.txt b/tslang/lib/TypeScript/CMakeLists.txt index 3f42cc3dd..49b8be4f6 100644 --- a/tslang/lib/TypeScript/CMakeLists.txt +++ b/tslang/lib/TypeScript/CMakeLists.txt @@ -33,6 +33,7 @@ add_mlir_dialect_library(MLIRTypeScript RelocateConstantPass.cpp OwnershipVerifierPass.cpp OwnershipInferencePass.cpp + OwnershipSignaturePass.cpp OwnedReturnConsumptionPass.cpp GCPass.cpp AsyncTargetWidthPass.cpp diff --git a/tslang/lib/TypeScript/OwnershipFacts.h b/tslang/lib/TypeScript/OwnershipFacts.h new file mode 100644 index 000000000..e00c4fec3 --- /dev/null +++ b/tslang/lib/TypeScript/OwnershipFacts.h @@ -0,0 +1,269 @@ +#ifndef MLIR_TYPESCRIPT_OWNERSHIPFACTS_H +#define MLIR_TYPESCRIPT_OWNERSHIPFACTS_H + +#include "TypeScript/TypeScriptOps.h" +#include "TypeScript/Defines.h" + +#include "mlir/Interfaces/ControlFlowInterfaces.h" + +#include "llvm/ADT/SmallVector.h" +#include "llvm/ADT/STLFunctionalExtras.h" + +// What -mm=own's two passes both need to read the ownership ops: which op is a view of the block +// its operand holds, where a value was made, which ops are calls and places. See +// docs/superpowers/specs/2026-09-24-own-memory-model-design.md, sections 3.2, 3.3 and 14. + +namespace own_facts +{ + +namespace mlir_ts = mlir::typescript; + + +// The facts OwnershipSignaturePass pins on a call it resolved, and on a function whose callers all +// know them (spec 2.4, 3.2). Absent means rc's convention: every parameter borrowed, the result +// owned, and the callee may destroy anything it can reach. +// +// 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. +#define OWN_RESULT_BORROWS_ATTR_NAME "__own_result_borrows" +// 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" + +// The arguments of a call, without the callee value of an indirect one. +inline mlir::OperandRange callArgs(mlir::Operation *op) +{ + if (mlir::isa(op)) + { + return op->getOperands().drop_front(); + } + + return op->getOperands(); +} + +inline bool callNoDrops(mlir::Operation *op) +{ + return op->hasAttr(OWN_NO_DROPS_ATTR_NAME); +} + +// The argument indices a call's callee keeps. +inline llvm::ArrayRef ownedParams(mlir::Operation *op) +{ + if (auto attr = op->getAttrOfType(OWN_PARAMS_ATTR_NAME)) + { + return attr.asArrayRef(); + } + + return {}; +} + +// The argument a call's result borrows, or -1. +inline int resultBorrows(mlir::Operation *op) +{ + if (auto attr = op->getAttrOfType(OWN_RESULT_BORROWS_ATTR_NAME)) + { + return static_cast(attr.getInt()); + } + + return -1; +} + +// An op whose result is the very block its operand holds: a class widened to a union or an +// optional or narrowed back, a union made from its payload or read back from it. rc's retain +// or release of either is one of the block, so the pass looks through them. A cast that +// converts (a number printed into a string) or boxes (into `any`) makes a new block and is +// not one. +inline bool isView(mlir::Operation *op) +{ + if (mlir::isa_and_nonnull(op)) + { + return true; + } + + auto castOp = mlir::dyn_cast_or_null(op); + if (!castOp) + { + return false; + } + + auto keeps = [](mlir::Type type) { + return mlir::isa(type); + }; + return keeps(castOp.getIn().getType()) && keeps(castOp.getType()); +} + +// The block a value is a view of. +inline mlir::Value rootOf(mlir::Value value) +{ + while (auto *def = value.getDefiningOp()) + { + if (!isView(def)) + { + break; + } + + value = def->getOperand(0); + } + + return value; +} + +// 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) +{ + llvm::SmallVector values{root}; + while (!values.empty()) + { + auto value = values.pop_back_val(); + for (auto &use : value.getUses()) + { + auto *user = use.getOwner(); + if (isView(user) && use.getOperandNumber() == 0) + { + values.push_back(user->getResult(0)); + continue; + } + + each(user, value); + } + } +} + +inline bool isOwningVariable(mlir_ts::VariableOp varOp) +{ + return varOp->hasAttr(OWNED_LOCAL_ATTR_NAME) || varOp->hasAttr(OWNED_LOCAL_CONSUMED_ATTR_NAME); +} + +// A string literal or a constant array cast to its value type. The result is either the +// immortal global itself, which a release skips, or a copy nobody else holds, which a +// release destroys - so, owned once, it has one owner either way. `null` and `undefined` +// widened to a nullable type (`c: C | null = null`), and an empty optional +// (`u: C | undefined = undefined`), hold no block at all. +inline bool isLiteral(mlir::Operation *def) +{ + if (mlir::isa(def)) + { + return true; + } + + auto castOp = mlir::dyn_cast(def); + if (!castOp) + { + return false; + } + + auto *in = castOp.getIn().getDefiningOp(); + return in && mlir::isa(in); +} + +// Made here and held by nobody else: an allocation, a literal cast to its value type, an +// operation rc marks as arriving with a reference, or a direct call of a function this module +// defines (every function returns its result retained, rc 9.24; the call's own mark does not +// survive the affine lowering). A declared callee, an indirect call, a parameter, a load or a +// value merged from branches is not fresh. +inline bool isFresh(mlir::Value value) +{ + auto *def = value.getDefiningOp(); + if (!def) + { + return false; + } + + if (mlir::isa(def)) + { + return true; + } + + if (auto callOp = mlir::dyn_cast(def)) + { + auto callee = mlir::SymbolTable::lookupNearestSymbolFrom(def, callOp.getCalleeAttr()); + return callee && !callee.isDeclaration(); + } + + if (mlir::isa(def)) + { + return false; + } + + return def->hasAttr(OWNED_RESULT_ATTR_NAME) || isLiteral(def); +} + +inline bool isCall(mlir::Operation *op) +{ + return mlir::isa(op); +} + +inline bool isPlace(mlir::Value ref) +{ + return mlir::isa_and_nonnull(ref.getDefiningOp()); +} + +// The values the branches into its block pass for `argument`, each seen through its views. +// False, adding nothing, when a way in is not a branch that passes one - the entry block's +// parameters, a region's arguments. +inline bool mergedInto(mlir::BlockArgument argument, llvm::SmallVectorImpl &values) +{ + auto *block = argument.getOwner(); + if (block->isEntryBlock()) + { + return false; + } + + llvm::SmallVector found; + for (auto *predecessor : block->getPredecessors()) + { + auto branchOp = mlir::dyn_cast(predecessor->getTerminator()); + if (!branchOp) + { + return false; + } + + for (unsigned index = 0; index < branchOp->getNumSuccessors(); ++index) + { + if (branchOp->getSuccessor(index) != block) + { + continue; + } + + auto passed = branchOp.getSuccessorOperands(index)[argument.getArgNumber()]; + if (!passed) + { + return false; + } + + found.push_back(rootOf(passed)); + } + } + + values.append(found.begin(), found.end()); + return true; +} + +// The load of an owning local that `value` was read from, through its views (a class widened +// to a union with null, an upcast, a union made from it). None when the value is anything +// else - a parameter, a field, a boxing cast - which phase 1 does not move out of. +inline mlir_ts::LoadOp slotLoadOf(mlir::Value value) +{ + auto loadOp = rootOf(value).getDefiningOp(); + if (!loadOp) + { + return {}; + } + + auto varOp = loadOp.getReference().getDefiningOp(); + if (!varOp || !isOwningVariable(varOp) || varOp.getCaptured().value_or(false)) + { + return {}; + } + + return loadOp; +} + +} // namespace own_facts + +#endif // MLIR_TYPESCRIPT_OWNERSHIPFACTS_H diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index 191a4d9ab..96514b06a 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -10,6 +10,8 @@ #include "TypeScript/Defines.h" #include "TypeScript/MLIRLogic/MLIRTypeHelper.h" +#include "OwnershipFacts.h" + #include "llvm/ADT/MapVector.h" #include "llvm/ADT/SetVector.h" #include "llvm/ADT/SmallPtrSet.h" @@ -23,6 +25,8 @@ namespace mlir_ts = mlir::typescript; namespace { +using namespace own_facts; + // Ownership inference for -mm=own, phases 0 and 1. See // docs/superpowers/specs/2026-09-24-own-memory-model-design.md, sections 4.2, 10.6, 11 and 12. // @@ -361,6 +365,9 @@ class OwnershipInferencePass : public mlir::PassWrapper kills; + // the function value an indirect call goes through: read when the call starts, not while + // the callee runs + bool callee = false; }; // A borrowing `let`'s slot: each read, and everything it leads to (walkBorrowed), joins @@ -492,7 +499,7 @@ class OwnershipInferencePass : public mlir::PassWrapper same; @@ -534,6 +541,31 @@ class OwnershipInferencePass : public mlir::PassWrapper(user)) + { + return false; + } + + if (use.getOperandNumber() == 0) + { + return true; + } + + auto getThisOp = use.get().getDefiningOp(); + auto getMethodOp = user->getOperand(0).getDefiningOp(); + auto methodField = getThisOp ? getThisOp.getOperand().getDefiningOp() : mlir_ts::LoadOp(); + return use.getOperandNumber() == 1 && getMethodOp && methodField && + getThisOp.getOperand() == getMethodOp.getBoundFunc() && + mlir::isa(methodField.getReference().getType()); + } + // 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) @@ -552,17 +584,6 @@ class OwnershipInferencePass : public mlir::PassWrapper(op); - } - - static bool isPlace(mlir::Value ref) - { - return mlir::isa_and_nonnull(ref.getDefiningOp()); - } - // A read of a heap value out of a field or an element, seen through its views. None for // anything else. mlir_ts::LoadOp placeReadOf(mlir::Value value) @@ -663,47 +684,6 @@ class OwnershipInferencePass : public mlir::PassWrapper &values) - { - auto *block = argument.getOwner(); - if (block->isEntryBlock()) - { - return false; - } - - llvm::SmallVector found; - for (auto *predecessor : block->getPredecessors()) - { - auto branchOp = mlir::dyn_cast(predecessor->getTerminator()); - if (!branchOp) - { - return false; - } - - for (unsigned index = 0; index < branchOp->getNumSuccessors(); ++index) - { - if (branchOp->getSuccessor(index) != block) - { - continue; - } - - auto passed = branchOp.getSuccessorOperands(index)[argument.getArgNumber()]; - if (!passed) - { - return false; - } - - found.push_back(rootOf(passed)); - } - } - - values.append(found.begin(), found.end()); - return true; - } - // Where the roots stop holding what the chain reads: a local's releases and assignments and // every declaration or retain that may move it away; a made value's releases and every use // that takes it; a global's assignments. @@ -844,9 +824,10 @@ class OwnershipInferencePass : public mlir::PassWrapper(drop)) @@ -865,6 +846,11 @@ class OwnershipInferencePass : public mlir::PassWrapper(op)) - { - return true; - } - - auto castOp = mlir::dyn_cast_or_null(op); - if (!castOp) - { - return false; - } - - auto keeps = [](mlir::Type type) { - return mlir::isa(type); - }; - return keeps(castOp.getIn().getType()) && keeps(castOp.getType()); - } - - // The block a value is a view of. - static mlir::Value rootOf(mlir::Value value) - { - while (auto *def = value.getDefiningOp()) - { - if (!isView(def)) - { - break; - } - - value = def->getOperand(0); - } - - return value; - } - - // Every user of `root` and of the views of it, with the value it uses. A view itself is not a - // use. - static void forEachUse(mlir::Value root, llvm::function_ref each) - { - llvm::SmallVector values{root}; - while (!values.empty()) - { - auto value = values.pop_back_val(); - for (auto &use : value.getUses()) - { - auto *user = use.getOwner(); - if (isView(user) && use.getOperandNumber() == 0) - { - values.push_back(user->getResult(0)); - continue; - } - - each(user, value); - } - } - } - // The value a retain acquires: a Retain's operand, or a RetainSlot's variable's initializer, // seen through its views. None for a variable with no initializer - its storage was hoisted // in front of a try and its value arrives by a store this phase does not follow. @@ -1180,44 +1105,6 @@ class OwnershipInferencePass : public mlir::PassWrapperhasAttr(OWNED_LOCAL_ATTR_NAME) || varOp->hasAttr(OWNED_LOCAL_CONSUMED_ATTR_NAME); - } - - // Made here and held by nobody else: an allocation, a literal cast to its value type, an - // operation rc marks as arriving with a reference, or a direct call of a function this module - // defines (every function returns its result retained, rc 9.24; the call's own mark does not - // survive the affine lowering). A declared callee, an indirect call, a parameter, a load or a - // value merged from branches is not fresh. - static bool isFresh(mlir::Value value) - { - auto *def = value.getDefiningOp(); - if (!def) - { - return false; - } - - if (mlir::isa(def)) - { - return true; - } - - if (auto callOp = mlir::dyn_cast(def)) - { - auto callee = mlir::SymbolTable::lookupNearestSymbolFrom(def, callOp.getCalleeAttr()); - return callee && !callee.isDeclaration(); - } - - if (mlir::isa(def)) - { - return false; - } - - return def->hasAttr(OWNED_RESULT_ATTR_NAME) || isLiteral(def); - } - // Does a value of this type own a block that a release would destroy? bool ownsHeap(mlir::Value value) { @@ -1233,28 +1120,6 @@ class OwnershipInferencePass : public mlir::PassWrapper(); } - // A string literal or a constant array cast to its value type. The result is either the - // immortal global itself, which a release skips, or a copy nobody else holds, which a - // release destroys - so, owned once, it has one owner either way. `null` and `undefined` - // widened to a nullable type (`c: C | null = null`), and an empty optional - // (`u: C | undefined = undefined`), hold no block at all. - static bool isLiteral(mlir::Operation *def) - { - if (mlir::isa(def)) - { - return true; - } - - auto castOp = mlir::dyn_cast(def); - if (!castOp) - { - return false; - } - - auto *in = castOp.getIn().getDefiningOp(); - return in && mlir::isa(in); - } - // A use that reads the value without keeping it. Kept deliberately short: anything not listed // is treated as taking the value, which can only turn a program into an error. static bool isBorrow(mlir::Operation *user, mlir::Value value) @@ -1592,26 +1457,6 @@ class OwnershipInferencePass : public mlir::PassWrapper(); - if (!loadOp) - { - return {}; - } - - auto varOp = loadOp.getReference().getDefiningOp(); - if (!varOp || !isOwningVariable(varOp) || varOp.getCaptured().value_or(false)) - { - return {}; - } - - return loadOp; - } - // The use that takes `value` for this retain: the declaration a `ts.RetainSlot` belongs to, // or the one use of the value, or of a view of it, after a `ts.Retain` that is not a read. static mlir::Operation *takerOf(mlir::Operation *retain, mlir::Value value) diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp new file mode 100644 index 000000000..a951d64a4 --- /dev/null +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -0,0 +1,629 @@ +#include "mlir/Pass/Pass.h" +#include "mlir/IR/BuiltinOps.h" +#include "mlir/IR/SymbolTable.h" + +#include "TypeScript/TypeScriptDialect.h" +#include "TypeScript/TypeScriptOps.h" +#include "TypeScript/Passes.h" +#include "TypeScript/Defines.h" + +#include "OwnershipFacts.h" + +#include "llvm/ADT/DenseMap.h" +#include "llvm/ADT/DenseSet.h" +#include "llvm/ADT/SmallVector.h" +#include "llvm/ADT/StringMap.h" +#include "llvm/ADT/StringSet.h" + +#include + +#define DEBUG_TYPE "own" + +namespace mlir_ts = mlir::typescript; + +namespace +{ + +using namespace own_facts; + +// The callee facts of -mm=own (spec 2.4, 3.2), computed over the whole module before the +// per-function inference runs. Each call the pass can resolve - a direct call, a virtual call +// whose candidates are all defined here, the `.instanceOf` slot - gets the facts its candidates +// agree on, pinned on the call op, so the inference reads them without looking anything up. +// +// `__own_no_drops` goes one way: a call with no facts may destroy anything, which can only turn a +// program into an error. The facts a callee's own body relies on - a parameter it keeps, a result +// that borrows an argument - go the other way: a caller that does not know them borrows the +// argument and releases it, or releases a result it does not own, a double free either way. So a +// function has those only when every call that can reach it is one this pass resolved (the closed +// world, see `open`). +class OwnershipSignaturePass : public mlir::PassWrapper> +{ + public: + MLIR_DEFINE_EXPLICIT_INTERNAL_INLINE_TYPE_ID(OwnershipSignaturePass) + + void runOnOperation() override + { + auto module = getOperation(); + module.walk([&](mlir_ts::FuncOp funcOp) { functions[funcOp.getSymName()] = funcOp; }); + collectClassVTables(module); + + module.walk([&](mlir::Operation *op) { + if (isCall(op) && op->getParentOfType()) + { + calls.push_back({op, resolve(op)}); + } + }); + + findOpen(module); + computeDrops(); + + for (auto &call : calls) + { + if (!call.callees.known) + { + continue; + } + + if (call.callees.instanceOf || + llvm::all_of(call.callees.funcs, [&](mlir_ts::FuncOp callee) { return noDrops.contains(callee); })) + { + call.op->setAttr(OWN_NO_DROPS_ATTR_NAME, mlir::UnitAttr::get(&getContext())); + } + } + } + + private: + // What a call may reach: known, when every candidate is a function defined in this module (or + // the call goes through the `.instanceOf` slot); else anything. + struct Callees + { + bool known = false; + bool instanceOf = false; + llvm::SmallVector funcs; + }; + + struct Call + { + mlir::Operation *op; + Callees callees; + }; + + llvm::StringMap functions; + llvm::SmallVector calls; + + // Class names, from their vtables, to split a method's symbol into class and method name. + llvm::StringSet<> classNames; + // (vtable position, method name) -> the functions at that position under that name, in every + // class vtable of the module: what a virtual call through that slot may reach. + std::map, llvm::SmallVector> families; + + // Functions whose callers are not all known: a caller outside the module, or a reference to + // the symbol that is not a call this pass resolves. + llvm::DenseSet open; + + // Functions that destroy nothing their caller can reach. + llvm::DenseSet noDrops; + + // A class's vtable is a global named `..vtbl` that holds its `..instanceOf`. An + // interface's vtable for a class (`B.I..vtbl`) holds only the methods and is not one: a call + // through an interface does not name what it reaches. + void collectClassVTables(mlir::ModuleOp module) + { + llvm::SmallVector>>> vtables; + module.walk([&](mlir_ts::GlobalOp globalOp) { + auto entries = classVTableEntries(globalOp); + if (!entries.empty()) + { + auto name = globalOp.getSymName(); + classNames.insert(name.drop_back(llvm::StringRef("..vtbl").size())); + vtables.push_back({name, std::move(entries)}); + } + }); + + for (auto &[name, entries] : vtables) + { + for (auto [position, symbol] : entries) + { + families[{position, methodName(symbol.getValue())}].push_back(symbol); + } + } + } + + // The functions a class vtable holds, by position; empty for any other global. + static llvm::SmallVector> classVTableEntries(mlir_ts::GlobalOp globalOp) + { + llvm::SmallVector> entries; + if (!globalOp.getSymName().ends_with("..vtbl")) + { + return entries; + } + + auto isClass = false; + globalOp.getInitializerRegion().walk([&](mlir_ts::InsertPropertyOp insertOp) { + auto symbolRefOp = insertOp.getValue().getDefiningOp(); + if (!symbolRefOp || insertOp.getPosition().size() != 1) + { + return; + } + + auto symbol = symbolRefOp.getIdentifierAttr().getAttr(); + isClass = isClass || symbol.getValue().ends_with("..instanceOf"); + entries.push_back({insertOp.getPosition()[0], symbol}); + }); + + if (!isClass) + { + entries.clear(); + } + + return entries; + } + + static bool isClassVTableEntry(mlir::Operation *op) + { + auto globalOp = op->getParentOfType(); + return globalOp && !classVTableEntries(globalOp).empty(); + } + + // `B.get` -> `get`: the symbol with the longest class name that prefixes it taken off. An + // override keeps its base's method name, which is what puts both in one family. + std::string methodName(llvm::StringRef symbol) + { + size_t best = 0; + for (auto &entry : classNames) + { + auto name = entry.getKey(); + if (name.size() > best && symbol.size() > name.size() && symbol.starts_with(name) && + symbol[name.size()] == '.') + { + best = name.size() + 1; + } + } + + return symbol.drop_front(best).str(); + } + + llvm::SmallVector familyOf(int64_t index, mlir::StringAttr identifier) + { + llvm::SmallVector members; + if (auto found = families.find({index, methodName(identifier.getValue())}); found != families.end()) + { + members = found->second; + } + + if (!llvm::is_contained(members, identifier)) + { + members.push_back(identifier); + } + + return members; + } + + // Adds `symbol`'s function to `callees`; false when it is not defined in this module. + bool addDefined(mlir::StringAttr symbol, Callees &callees) + { + auto funcOp = functions.lookup(symbol.getValue()); + if (!funcOp || funcOp.isDeclaration()) + { + return false; + } + + if (!llvm::is_contained(callees.funcs, funcOp)) + { + callees.funcs.push_back(funcOp); + } + + return true; + } + + Callees resolve(mlir::Operation *op) + { + Callees callees; + if (auto callOp = mlir::dyn_cast(op)) + { + callees.known = addDefined(callOp.getCalleeAttr().getAttr(), callees); + } + else if (auto callOp = mlir::dyn_cast(op)) + { + callees.known = addDefined(callOp.getCalleeAttr().getAttr(), callees); + } + else if (mlir::isa(op)) + { + resolveValue(op->getOperand(0), callees); + } + + if (!callees.known) + { + callees.funcs.clear(); + } + + return callees; + } + + // The function value a call goes through: a method of a class, virtual or not, a symbol, or + // the `.instanceOf` slot. Anything else - a closure, a function read from a field, an + // interface's method - is unknown. + void resolveValue(mlir::Value callee, Callees &callees) + { + if (auto getMethodOp = callee.getDefiningOp()) + { + callee = getMethodOp.getBoundFunc(); + } + + auto *def = callee.getDefiningOp(); + mlir::StringAttr identifier; + int64_t index = -1; + if (auto refOp = mlir::dyn_cast_or_null(def)) + { + identifier = refOp.getIdentifierAttr().getAttr(); + index = refOp.getIndex(); + } + else if (auto refOp = mlir::dyn_cast_or_null(def)) + { + identifier = refOp.getIdentifierAttr().getAttr(); + index = refOp.getIndex(); + } + else if (auto refOp = mlir::dyn_cast_or_null(def)) + { + identifier = refOp.getIdentifierAttr().getAttr(); + } + else if (auto refOp = mlir::dyn_cast_or_null(def)) + { + identifier = refOp.getIdentifierAttr().getAttr(); + } + else if (isInstanceOfSlot(def)) + { + callees.known = true; + callees.instanceOf = true; + return; + } + + if (!identifier) + { + return; + } + + if (index < 0) + { + callees.known = addDefined(identifier, callees); + return; + } + + // A class another module can see may be extended there, and its override is a candidate + // this module never sees. + callees.known = true; + for (auto member : familyOf(index, identifier)) + { + callees.known = addDefined(member, callees) && callees.known; + } + + callees.known = callees.known && llvm::all_of(callees.funcs, [](mlir_ts::FuncOp funcOp) { return funcOp.isPrivate(); }); + } + + // `ts.Cast(ts.VTableOffsetRef(ts.VTableOffsetRef(object, 0), 0))`: the first slot of the + // vtable an object's first word points to. Every class vtable keeps its generated + // `..instanceOf` there, a string compare that destroys nothing. `___unbox` asks it. + static bool isInstanceOfSlot(mlir::Operation *def) + { + auto castOp = mlir::dyn_cast_or_null(def); + auto slot = castOp ? castOp.getIn().getDefiningOp() : mlir_ts::VTableOffsetRefOp(); + auto vtable = slot ? slot.getVtable().getDefiningOp() : mlir_ts::VTableOffsetRefOp(); + return vtable && slot.getIndex() == 0 && vtable.getIndex() == 0; + } + + // Is every use of this function value the callee of a call - through `ts.GetMethod`, with + // `ts.GetThis` reading the object beside it? + static bool onlyCalled(mlir::Value value) + { + for (auto &use : value.getUses()) + { + auto *user = use.getOwner(); + if (mlir::isa(user) && use.getOperandNumber() == 0) + { + continue; + } + + if (mlir::isa(user)) + { + continue; + } + + if (mlir::isa(user) && onlyCalled(user->getResult(0))) + { + continue; + } + + return false; + } + + return true; + } + + // The closed world. A function is open when a caller this pass cannot see may reach it: it is + // not private (another module may call it), or its symbol is used other than by a direct call, + // a class vtable's entry, or a method reference used only as a callee. A virtual reference + // that escapes opens its whole family. + void findOpen(mlir::ModuleOp module) + { + for (auto &entry : functions) + { + if (!entry.second.isPrivate()) + { + open.insert(entry.second); + } + } + + auto uses = mlir::SymbolTable::getSymbolUses(module.getOperation()); + if (!uses) + { + // an op this pass cannot read symbols through: nothing is closed + for (auto &entry : functions) + { + open.insert(entry.second); + } + + return; + } + + for (auto &use : *uses) + { + auto symbol = use.getSymbolRef().getRootReference(); + auto funcOp = functions.lookup(symbol.getValue()); + if (!funcOp) + { + continue; + } + + auto *user = use.getUser(); + if (mlir::isa(user)) + { + continue; + } + + if (mlir::isa(user) && isClassVTableEntry(user)) + { + continue; + } + + auto inFunction = !!user->getParentOfType(); + if (inFunction && mlir::isa(user) && + onlyCalled(user->getResult(0))) + { + continue; + } + + open.insert(funcOp); + int64_t index = -1; + if (auto refOp = mlir::dyn_cast(user)) + { + index = refOp.getIndex(); + } + else if (auto refOp = mlir::dyn_cast(user)) + { + index = refOp.getIndex(); + } + + if (index >= 0) + { + for (auto member : familyOf(index, symbol)) + { + if (auto memberOp = functions.lookup(member.getValue())) + { + open.insert(memberOp); + } + } + } + } + } + + // ---- Drops ---- + + // Least fixpoint: a function drops nothing until its body or a call it makes shows it may. + void computeDrops() + { + llvm::DenseMap> callsIn; + for (auto &call : calls) + { + callsIn[call.op->getParentOfType()].push_back(&call); + } + + for (auto &entry : functions) + { + auto funcOp = entry.second; + if (!funcOp.isDeclaration() && !dropsInBody(funcOp)) + { + noDrops.insert(funcOp); + } + } + + for (auto changed = true; changed;) + { + changed = false; + for (auto &entry : functions) + { + auto funcOp = entry.second; + if (!noDrops.contains(funcOp)) + { + continue; + } + + for (auto *call : callsIn[funcOp]) + { + if (!callMayDrop(call->callees)) + { + continue; + } + + noDrops.erase(funcOp); + changed = true; + break; + } + } + } + } + + bool callMayDrop(const Callees &callees) + { + if (!callees.known) + { + return true; + } + + return !callees.instanceOf && + llvm::any_of(callees.funcs, [&](mlir_ts::FuncOp callee) { return !noDrops.contains(callee); }); + } + + // Does the body itself destroy something a caller may reach: overwrite a field or an element + // of a block it did not make, assign a global, remove elements from an array it did not make, + // or `delete`? A local's own releases are not drops, nor is the constructor's filling of the + // object it is building. + bool dropsInBody(mlir_ts::FuncOp funcOp) + { + auto drops = false; + funcOp.walk([&](mlir::Operation *op) { + if (drops) + { + return; + } + + if (auto releaseSlotOp = mlir::dyn_cast(op)) + { + auto slot = releaseSlotOp.getSlot(); + drops = slot.getDefiningOp() || (isPlace(slot) && reachesOutside(slot, funcOp)); + } + else if (mlir::isa(op)) + { + drops = reachesOutside(op->getOperand(0), funcOp); + } + else if (mlir::isa(op)) + { + drops = true; + } + }); + + return drops; + } + + // Can the block this reference points into be one the function's caller can reach? Walks up + // from a place to what holds it. Not when every way up ends in a block the function made (an + // allocation, a call's result it owns) or in an owning local that only ever held such blocks, + // or, in a constructor, in the object being built. + bool reachesOutside(mlir::Value ref, mlir_ts::FuncOp funcOp) + { + auto isConstructor = funcOp.getSymName().ends_with(".constructor"); + llvm::SmallVector work{ref}; + llvm::DenseSet seen; + while (!work.empty()) + { + auto value = rootOf(work.pop_back_val()); + if (!seen.insert(value).second) + { + continue; + } + + if (auto propertyRefOp = value.getDefiningOp()) + { + work.push_back(propertyRefOp.getObjectRef()); + continue; + } + + if (auto elementRefOp = value.getDefiningOp()) + { + work.push_back(elementRefOp.getArray()); + continue; + } + + if (auto argument = mlir::dyn_cast(value)) + { + llvm::SmallVector merged; + if (mergedInto(argument, merged)) + { + work.append(merged.begin(), merged.end()); + continue; + } + + if (isConstructor && argument.getOwner()->isEntryBlock() && argument.getArgNumber() == 0) + { + continue; // the object being built + } + + return true; + } + + if (auto varOp = value.getDefiningOp()) + { + if (holdsOnlyFresh(varOp)) + { + continue; + } + + // a parameter's slot: what the caller passed + if (auto init = varOp.getInitializer()) + { + if (auto argument = mlir::dyn_cast(init); + argument && isConstructor && argument.getOwner()->isEntryBlock() && argument.getArgNumber() == 0 && + !isAssigned(varOp)) + { + continue; + } + } + + return true; + } + + if (auto loadOp = value.getDefiningOp()) + { + work.push_back(loadOp.getReference()); + continue; + } + + if (isFresh(value)) + { + continue; + } + + return true; + } + + return false; + } + + static bool isAssigned(mlir_ts::VariableOp varOp) + { + return llvm::any_of(varOp.getResult().getUsers(), [&](mlir::Operation *user) { + auto storeOp = mlir::dyn_cast(user); + return storeOp && storeOp.getReference() == varOp.getResult(); + }); + } + + // An owning, uncaptured local whose every value - its initializer and each assignment - is one + // the function made. + static bool holdsOnlyFresh(mlir_ts::VariableOp varOp) + { + if (!isOwningVariable(varOp) || varOp.getCaptured().value_or(false)) + { + return false; + } + + if (auto init = varOp.getInitializer(); init && !isFresh(rootOf(init))) + { + return false; + } + + return llvm::all_of(varOp.getResult().getUsers(), [&](mlir::Operation *user) { + auto storeOp = mlir::dyn_cast(user); + return !storeOp || storeOp.getReference() != varOp.getResult() || isFresh(rootOf(storeOp.getValue())); + }); + } +}; + +} // end anonymous namespace + +#undef DEBUG_TYPE + +std::unique_ptr mlir_ts::createOwnershipSignaturePass() +{ + return std::make_unique(); +} diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index f108399aa..490399d11 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2408,7 +2408,8 @@ foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local o own_union_nullable own_union_tagged own_union_optional own_field_borrow own_element_borrow own_container_loop own_optional_access own_borrow_merge - own_field_let_borrow own_element_let_borrow own_borrow_merge_after_call) + own_field_let_borrow own_element_let_borrow own_borrow_merge_after_call + own_call_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. @@ -2473,6 +2474,10 @@ set(own_error_cases "own_err_container_read_stored|borrows .* and cannot be stored, returned or captured" "own_err_field_let_overwritten|borrows .* but is used here after it may be released or overwritten" "own_err_field_let_assigned|borrows .* and cannot be assigned" + "own_err_call_drops_indirectly|borrows .* but is used here after it may be released or overwritten" + "own_err_call_given_borrow|borrows .* but is used here after it may be released or overwritten" + "own_err_call_this_borrow|borrows .* but is used here after it may be released or overwritten" + "own_err_exported_virtual_call|borrows .* but is used here after it may be released or overwritten" "own_err_delete|'delete' is not supported by -mm=own yet") foreach(own_error_case ${own_error_cases}) string(FIND "${own_error_case}" "|" own_error_sep) diff --git a/tslang/test/tester/own/own_call_no_drops.ts b/tslang/test/tester/own/own_call_no_drops.ts new file mode 100644 index 000000000..23ca37555 --- /dev/null +++ b/tslang/test/tester/own/own_call_no_drops.ts @@ -0,0 +1,50 @@ +// -mm=own, phase 4: a borrow of a field of `this` or of a parameter survives a call to a function +// that destroys nothing its caller can reach (`__own_no_drops`): a method that reads numbers, a +// helper that builds and fills its own objects. Phase 3 dropped it at any call. +class C { + v: number[] = []; + constructor(public x: number) {} +} + +function churn() { + const junk: C[] = []; + for (let i = 0; i < 64; i++) { + const c = new C(999); + c.v.push(99); + junk.push(c); + } +} + +class S { + c: C = new C(0); + w: number = 2; + area() { + return this.w * 2; + } + + total() { + const c = this.c; + const a = this.area(); + churn(); + return c.x + c.v.length + a; + } +} + +function sum(s: S) { + const c = s.c; + churn(); + return c.x + s.area(); +} + +function main() { + let t: number = 0; + for (let i = 0; i < 100000; i++) { + const s = new S(); + s.c = new C(i % 10); + s.c.v.push(1); + t += s.total() + sum(s); + } + + assert(t == 1800000); + print("done."); +} diff --git a/tslang/test/tester/own/own_err_call_drops_indirectly.ts b/tslang/test/tester/own/own_err_call_drops_indirectly.ts new file mode 100644 index 000000000..68a71bd83 --- /dev/null +++ b/tslang/test/tester/own/own_err_call_drops_indirectly.ts @@ -0,0 +1,29 @@ +// -mm=own, phase 4 rejects: `reset` destroys nothing itself, but it calls `clear`, which assigns +// the global that owns what `c` borrows. The fact goes up the call graph. +class C { + constructor(public x: number) {} +} + +class H { + c: C = new C(0); +} + +let g = new H(); + +function clear() { + g.c = new C(9); +} + +function reset() { + clear(); +} + +function read(h: H) { + const c = h.c; + reset(); + print(c.x); +} + +function main() { + read(g); +} diff --git a/tslang/test/tester/own/own_err_call_given_borrow.ts b/tslang/test/tester/own/own_err_call_given_borrow.ts new file mode 100644 index 000000000..a4c4df4da --- /dev/null +++ b/tslang/test/tester/own/own_err_call_given_borrow.ts @@ -0,0 +1,22 @@ +// -mm=own, phase 4 rejects: `use` is given `h.c`, then assigns the global that owns it and reads +// its parameter. A call given a borrow uses it for as long as the callee runs, so a callee that +// may destroy it is a use after the destroy. +class C { + constructor(public x: number) {} +} + +class H { + c: C = new C(0); +} + +let g = new H(); + +function use(c: C) { + g.c = new C(9); + print(c.x); +} + +function main() { + const h = g; + use(h.c); +} diff --git a/tslang/test/tester/own/own_err_call_this_borrow.ts b/tslang/test/tester/own/own_err_call_this_borrow.ts new file mode 100644 index 000000000..67765cec6 --- /dev/null +++ b/tslang/test/tester/own/own_err_call_this_borrow.ts @@ -0,0 +1,20 @@ +// -mm=own, phase 4 rejects: `g.c.m()` calls `m` on the borrow of `g.c` itself. `m` assigns +// `g.c`, which destroys its own `this`, and then reads `this`. +class C { + constructor(public x: number) {} + m() { + g.c = new C(9); + print(this.x); + } +} + +class H { + c: C = new C(0); +} + +let g = new H(); + +function main() { + const h = g; + h.c.m(); +} diff --git a/tslang/test/tester/own/own_err_exported_virtual_call.ts b/tslang/test/tester/own/own_err_exported_virtual_call.ts new file mode 100644 index 000000000..9a937d6b6 --- /dev/null +++ b/tslang/test/tester/own/own_err_exported_virtual_call.ts @@ -0,0 +1,17 @@ +// -mm=own, phase 4 rejects: `S` is exported, so another module may extend it and override +// `area` with one that overwrites `color`. The virtual call has candidates this module cannot see. +export class S { + color: string = "red"; + w: number = 2; + area() { + return this.w * 2; + } + + describe() { + return `${this.color} area=${this.area()}`; + } +} + +function main() { + print(new S().describe()); +} diff --git a/tslang/tslang/transform.cpp b/tslang/tslang/transform.cpp index 006699905..9fd3edbf7 100644 --- a/tslang/tslang/transform.cpp +++ b/tslang/tslang/transform.cpp @@ -144,6 +144,7 @@ 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()); pm.nest().addPass(mlir::typescript::createOwnershipInferencePass()); } From e0fbac5eae455e76ab0340170c62b13187ac503e Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Tue, 29 Sep 2026 17:44:13 +0100 Subject: [PATCH 3/6] -mm=own phase 4: a result that borrows an argument is a borrow at the call OwnershipSignaturePass gives a function __own_result_borrows = K when every heap value it returns is, through views, opaque casts, ts.Unbox, field and element reads, the object a method is called on and results that borrow, parameter K (never assigned) - and only in a closed world: the function is private, every use of its symbol is a call this pass resolves or a class vtable entry, and every class family it is in agrees. Otherwise it keeps rc's convention, and the error in its body says why (__own_facts_lost). A fixpoint lets a method that returns another's borrow borrow too. The inference treats such a call as a borrow read (wildcard places, roots through the argument): its temporary release and its consuming let's releases go, and a function returning a borrow drops the retain rc made for its caller. Found on the way: getSymbolUses(module) does not look inside the module (it is a symbol table), so the closed world saw no uses at all. Co-Authored-By: Claude Opus 5.5 --- tslang/lib/TypeScript/OwnershipFacts.h | 171 ++++++++++- .../lib/TypeScript/OwnershipInferencePass.cpp | 278 +++++++++++++++--- .../lib/TypeScript/OwnershipSignaturePass.cpp | 256 ++++++++++++++-- tslang/test/tester/CMakeLists.txt | 7 +- .../own/own_err_borrowed_result_escapes.ts | 23 ++ .../own/own_err_borrowed_result_family.ts | 23 ++ .../own_err_borrowed_result_let_outlives.ts | 19 ++ .../own/own_err_borrowed_result_outlives.ts | 20 ++ .../own/own_err_borrowed_result_stored.ts | 20 ++ tslang/test/tester/own/own_getter_borrow.ts | 78 +++++ 10 files changed, 832 insertions(+), 63 deletions(-) create mode 100644 tslang/test/tester/own/own_err_borrowed_result_escapes.ts create mode 100644 tslang/test/tester/own/own_err_borrowed_result_family.ts create mode 100644 tslang/test/tester/own/own_err_borrowed_result_let_outlives.ts create mode 100644 tslang/test/tester/own/own_err_borrowed_result_outlives.ts create mode 100644 tslang/test/tester/own/own_err_borrowed_result_stored.ts create mode 100644 tslang/test/tester/own/own_getter_borrow.ts diff --git a/tslang/lib/TypeScript/OwnershipFacts.h b/tslang/lib/TypeScript/OwnershipFacts.h index e00c4fec3..00ee0cde1 100644 --- a/tslang/lib/TypeScript/OwnershipFacts.h +++ b/tslang/lib/TypeScript/OwnershipFacts.h @@ -3,6 +3,7 @@ #include "TypeScript/TypeScriptOps.h" #include "TypeScript/Defines.h" +#include "TypeScript/MLIRLogic/MLIRTypeHelper.h" #include "mlir/Interfaces/ControlFlowInterfaces.h" @@ -30,6 +31,9 @@ namespace mlir_ts = mlir::typescript; // 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" +// On a function whose body relies on a fact its callers cannot all know (a parameter it keeps, a +// result that borrows): why, for the error the body gets instead. +#define OWN_FACTS_LOST_ATTR_NAME "__own_facts_lost" // The arguments of a call, without the callee value of an indirect one. inline mlir::OperandRange callArgs(mlir::Operation *op) @@ -162,12 +166,12 @@ inline bool isLiteral(mlir::Operation *def) // Made here and held by nobody else: an allocation, a literal cast to its value type, an // operation rc marks as arriving with a reference, or a direct call of a function this module // defines (every function returns its result retained, rc 9.24; the call's own mark does not -// survive the affine lowering). A declared callee, an indirect call, a parameter, a load or a -// value merged from branches is not fresh. +// survive the affine lowering). A declared callee, an indirect call, a parameter, a load, a +// value merged from branches, or a result that borrows an argument is not fresh. inline bool isFresh(mlir::Value value) { auto *def = value.getDefiningOp(); - if (!def) + if (!def || resultBorrows(def) >= 0) { return false; } @@ -264,6 +268,167 @@ inline mlir_ts::LoadOp slotLoadOf(mlir::Value value) return loadOp; } +// Does a value of this type own a block that a release would destroy? +inline bool ownsHeap(mlir::Value value) +{ + MLIRTypeHelper mth(value.getContext(), CompileOptions{}); + return mth.ownsHeapMemory(value.getLoc(), value.getType()); +} + +// Holds no block: a number, `null` or `undefined` widened, an empty optional, a string literal. +inline bool holdsNoBlock(mlir::Value value) +{ + auto *def = rootOf(value).getDefiningOp(); + return !ownsHeap(value) || (def && isLiteral(def)); +} + +// Is this the slot of parameter `argument` - a local declared from it that owns nothing, is not +// captured, and is never assigned, so every read of it is the argument? +inline bool isParameterSlot(mlir_ts::VariableOp varOp, int &index) +{ + auto init = varOp.getInitializer(); + auto argument = init ? mlir::dyn_cast(init) : mlir::BlockArgument(); + if (!argument || !argument.getOwner()->isEntryBlock() || + !mlir::isa(argument.getOwner()->getParentOp()) || isOwningVariable(varOp) || + varOp.getCaptured().value_or(false)) + { + return false; + } + + auto assigned = llvm::any_of(varOp.getResult().getUsers(), [&](mlir::Operation *user) { + auto storeOp = mlir::dyn_cast(user); + return storeOp && storeOp.getReference() == varOp.getResult(); + }); + if (assigned) + { + return false; + } + + index = argument.getArgNumber(); + return true; +} + +// The object a method call is made on: `ts.GetThis` of a method bound to it. Null otherwise. +inline mlir::Value boundThis(mlir::Value value) +{ + auto getThisOp = value.getDefiningOp(); + auto *bound = getThisOp ? getThisOp.getOperand().getDefiningOp() : nullptr; + if (auto refOp = mlir::dyn_cast_or_null(bound)) + { + return refOp.getThisVal(); + } + + if (auto refOp = mlir::dyn_cast_or_null(bound)) + { + return refOp.getThisVal(); + } + + return {}; +} + +// The parameter whose argument `value` is a borrow of, or -1 (spec 2.4, borrowed-from-argument). +// Seen through views, casts out of `!ts.opaque`, `ts.Unbox` (the payload an `any` box holds), the +// object a method is called on, +// reads out of fields and elements (the argument's block holds them), and results that borrow an +// argument, down to a read of a parameter's slot or the parameter itself. Merged values must all +// agree; one that holds no block (`null`) agrees with any. A value the function made, a local, a +// global: -1. +inline int borrowedParam(mlir::Value value, int depth = 0) +{ + if (depth > 32) + { + return -1; + } + + value = rootOf(value); + if (auto argument = mlir::dyn_cast(value)) + { + if (argument.getOwner()->isEntryBlock()) + { + return mlir::isa(argument.getOwner()->getParentOp()) ? argument.getArgNumber() : -1; + } + + llvm::SmallVector merged; + if (!mergedInto(argument, merged)) + { + return -1; + } + + auto found = -1; + for (auto passed : merged) + { + if (holdsNoBlock(passed)) + { + continue; + } + + auto index = borrowedParam(passed, depth + 1); + if (index < 0 || (found >= 0 && index != found)) + { + return -1; + } + + found = index; + } + + return found; + } + + if (auto object = boundThis(value)) + { + return borrowedParam(object, depth + 1); + } + + auto *def = value.getDefiningOp(); + if (auto castOp = mlir::dyn_cast_or_null(def); + castOp && mlir::isa(castOp.getIn().getType())) + { + return borrowedParam(castOp.getIn(), depth + 1); + } + + if (auto unboxOp = mlir::dyn_cast_or_null(def)) + { + return borrowedParam(unboxOp.getIn(), depth + 1); + } + + if (auto propertyRefOp = mlir::dyn_cast_or_null(def)) + { + return borrowedParam(propertyRefOp.getObjectRef(), depth + 1); + } + + if (auto elementRefOp = mlir::dyn_cast_or_null(def)) + { + return borrowedParam(elementRefOp.getArray(), depth + 1); + } + + if (auto loadOp = mlir::dyn_cast_or_null(def)) + { + auto ref = loadOp.getReference(); + if (isPlace(ref)) + { + return borrowedParam(ref, depth + 1); + } + + auto index = -1; + auto varOp = ref.getDefiningOp(); + return varOp && isParameterSlot(varOp, index) ? index : -1; + } + + if (auto varOp = mlir::dyn_cast_or_null(def)) + { + auto index = -1; + return isParameterSlot(varOp, index) ? index : -1; + } + + if (def && isCall(def) && resultBorrows(def) >= 0 && + static_cast(resultBorrows(def)) < callArgs(def).size()) + { + return borrowedParam(callArgs(def)[resultBorrows(def)], depth + 1); + } + + return -1; +} + } // namespace own_facts #endif // MLIR_TYPESCRIPT_OWNERSHIPFACTS_H diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index 96514b06a..21227206f 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -57,10 +57,12 @@ class OwnershipInferencePass : public mlir::PassWrapper candidates; llvm::SmallVector retains; - // reads out of fields and elements, and what may destroy what they read (spec 2.3) - llvm::SmallVector placeReads; + // 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; drops.clear(); derivedCache.clear(); + returnsBorrowOf = resultBorrows(f); // A view of a block is that block: the candidate is always the root. f.walk([&](mlir::Operation *op) { @@ -114,6 +116,10 @@ class OwnershipInferencePass : public mlir::PassWrappergetNumResults() == 1 && placeReadOf(op->getResult(0)) == op) + { + placeReads.push_back(op); + } } for (auto result : op->getResults()) @@ -163,6 +169,17 @@ class OwnershipInferencePass : public mlir::PassWrapper= 0 && borrowedParam(value) == returnsBorrowOf) + { + if (returnedParams.insert(value).second) + { + checkReturnedParam(value, toErase); + } + + continue; + } + if (auto load = value ? slotLoadOf(value) : mlir_ts::LoadOp()) { slotReceivers[load.getReference()].push_back({op, value, load}); @@ -184,6 +201,16 @@ class OwnershipInferencePass : public mlir::PassWrappererase(); } + + // 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}) + { + op->removeAttr(name); + } + }); } private: @@ -200,6 +227,11 @@ class OwnershipInferencePass : public mlir::PassWrapper returnedParams; + struct SlotReceiver { mlir::Operation *retain; @@ -274,8 +306,10 @@ class OwnershipInferencePass : public mlir::PassWrapper(acquisition)) @@ -287,12 +321,16 @@ class OwnershipInferencePass : public mlir::PassWrapper(acquisition); } - if (!varOp || !varOp.getInitializer() || !isOwningVariable(varOp) || - varOp->hasAttr(OWNED_LOCAL_CONSUMED_ATTR_NAME) || varOp.getCaptured().value_or(false)) + if (!varOp || !varOp.getInitializer() || !isOwningVariable(varOp) || varOp.getCaptured().value_or(false)) { return {}; } + if (varOp->hasAttr(OWNED_LOCAL_CONSUMED_ATTR_NAME)) + { + return consumed ? varOp : mlir_ts::VariableOp(); + } + auto retains = llvm::any_of(varOp.getResult().getUsers(), [](mlir::Operation *user) { return mlir::isa(user); }); return retains ? varOp : mlir_ts::VariableOp(); @@ -479,9 +517,22 @@ class OwnershipInferencePass : public mlir::PassWrapper *bookkeeping = nullptr; + bool returns = false; + }; + mlir::Operation *walkBorrowed( mlir::Value start, llvm::ArrayRef kills, llvm::SmallVectorImpl &uses, - llvm::function_ref)> isBorrower = nullptr) + llvm::function_ref)> isBorrower = nullptr, + Keeping keeping = {}) { struct Pending { @@ -499,7 +550,20 @@ class OwnershipInferencePass : public mlir::PassWrapper(user) && borrowingCall(rootOf(pending.value))) || + (keeping.returns && mlir::isa(user)))) + { + keeping.bookkeeping->push_back(user); + continue; + } + uses.push_back({user, pending.kills, isCall(user) && isCalleeOperand(use)}); + if (pending.borrowed && keeping.returns && mlir::isa(user)) + { + continue; + } + if (pending.borrowed) { llvm::SmallVector same; @@ -528,11 +592,12 @@ class OwnershipInferencePass : public mlir::PassWrappergetResults()) { if (mayPointInto(result) && seen.insert(result).second) { - values.push_back({result, false, pending.kills}); + values.push_back({result, !!borrowingCall(result), pending.kills}); } } } @@ -584,17 +649,31 @@ class OwnershipInferencePass : public mlir::PassWrapper(); - if (!loadOp || !isPlace(loadOp.getReference()) || !ownsHeap(loadOp.getResult())) + auto root = rootOf(value); + auto *def = root.getDefiningOp(); + if (!def || !ownsHeap(root)) { - return {}; + return nullptr; + } + + if (auto loadOp = mlir::dyn_cast(def); loadOp && isPlace(loadOp.getReference())) + { + return def; } - return loadOp; + return borrowingCall(root); + } + + // The call a value is the result of, when that result borrows an argument. + 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; } // The places from a read up to its roots, and the roots: the values the function reached the @@ -612,6 +691,9 @@ class OwnershipInferencePass : public mlir::PassWrapper> places; llvm::SmallVector> roots; + // Somewhere under a root, but where is not known: a result that borrows an argument may + // be any field or element under it. Every overwrite and every removal drops it. + bool anyPlace = false; bool owned() const { @@ -619,31 +701,51 @@ class OwnershipInferencePass : public mlir::PassWrapper refs{read.getReference()}; + llvm::SmallVector refs; + llvm::SmallVector first; + if (auto loadOp = mlir::dyn_cast(read)) + { + refs.push_back(loadOp.getReference()); + } + else + { + chain.anyPlace = true; + first.push_back(rootOf(callArgs(read)[resultBorrows(read)])); + } + llvm::SmallPtrSet seen; - while (!refs.empty()) + while (!refs.empty() || !first.empty()) { - auto ref = refs.pop_back_val(); - mlir::Value base; - if (auto propertyRefOp = ref.getDefiningOp()) + llvm::SmallVector bases; + if (!first.empty()) { - chain.places.push_back({propertyRefOp.getPosition(), propertyRefOp.getType()}); - base = propertyRefOp.getObjectRef(); + bases.swap(first); } else { - auto elementRefOp = ref.getDefiningOp(); - chain.places.push_back({-1, elementRefOp.getType()}); - base = elementRefOp.getArray(); + auto ref = refs.pop_back_val(); + mlir::Value base; + if (auto propertyRefOp = ref.getDefiningOp()) + { + chain.places.push_back({propertyRefOp.getPosition(), propertyRefOp.getType()}); + base = propertyRefOp.getObjectRef(); + } + else + { + auto elementRefOp = ref.getDefiningOp(); + chain.places.push_back({-1, elementRefOp.getType()}); + base = elementRefOp.getArray(); + } + + bases.push_back(rootOf(base)); } - llvm::SmallVector bases{rootOf(base)}; while (!bases.empty()) { - base = bases.pop_back_val(); + auto base = bases.pop_back_val(); if (!seen.insert(base).second) { continue; @@ -661,6 +763,21 @@ class OwnershipInferencePass : public mlir::PassWrapper(base); argument && mergedInto(argument, bases)) { continue; @@ -828,8 +945,18 @@ class OwnershipInferencePass : public mlir::PassWrapper(drop)) { auto slot = releaseSlotOp.getSlot(); @@ -856,7 +983,7 @@ class OwnershipInferencePass : public mlir::PassWrappergetResult(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) { @@ -883,18 +1010,22 @@ class OwnershipInferencePass : public mlir::PassWrapper &toErase) + bool checkPlaceRead(mlir::Operation *read, llvm::SetVector &toErase) { - auto place = describePlace(read.getReference()); - llvm::StringRef name = ownerName(read.getResult()); + auto loadOp = mlir::dyn_cast(read); + auto place = loadOp ? describePlace(loadOp.getReference()) : describeCall(read); + auto result = read->getResult(0); + llvm::StringRef name = ownerName(result); llvm::SmallVector uses; llvm::SmallVector bookkeeping; mlir_ts::VariableOp letKeeps; mlir::Operation *letKept = nullptr; - mlir::Operation *readOp = read; - auto *kept = walkBorrowed(read.getResult(), readOp, uses, [&](mlir::Operation *user, - llvm::ArrayRef kills) { - auto borrower = borrowerOf(user); + // A result that borrows arrives with rc's temporary release, or rc's consuming `let`, and + // in a function returning a borrow of the same parameter it may go out with rc's retain. + Keeping keeping{&bookkeeping, returnsBorrowOf >= 0 && borrowedParam(result) == returnsBorrowOf}; + auto *kept = walkBorrowed(result, read, uses, [&](mlir::Operation *user, + llvm::ArrayRef kills) { + auto borrower = borrowerOf(user, /*consumed=*/true); if (!borrower || borrower.getOperation() != user) { return false; @@ -908,7 +1039,7 @@ class OwnershipInferencePass : public mlir::PassWrapperu`, 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 + // borrow of the argument bounds it; nothing here can end that but a call, and a call that may + // is the caller's use of what it passed (spec 2.4, phase 4). + void checkReturnedParam(mlir::Value value, llvm::SetVector &toErase) + { + llvm::SmallVector uses; + llvm::SmallVector bookkeeping; + if (auto *kept = walkBorrowed(value, {}, uses, nullptr, Keeping{&bookkeeping, true})) + { + reportBorrowEscapes(kept, ownerName(value), "a parameter"); + return; + } + + toErase.insert(bookkeeping.begin(), bookkeeping.end()); + } + + // `'first'`'s result, for a call whose result borrows an argument. + static std::string describeCall(mlir::Operation *call) + { + mlir::StringAttr callee; + if (auto callOp = mlir::dyn_cast(call)) + { + callee = callOp.getCalleeAttr().getAttr(); + } + else if (auto calleeOp = call->getOperand(0).getDefiningOp()) + { + if (auto getMethodOp = mlir::dyn_cast(calleeOp)) + { + calleeOp = getMethodOp.getBoundFunc().getDefiningOp(); + } + + if (auto refOp = mlir::dyn_cast_or_null(calleeOp)) + { + callee = refOp.getIdentifierAttr().getAttr(); + } + else if (auto refOp = mlir::dyn_cast_or_null(calleeOp)) + { + callee = refOp.getIdentifierAttr().getAttr(); + } + else if (auto refOp = mlir::dyn_cast_or_null(calleeOp)) + { + callee = refOp.getIdentifierAttr().getAttr(); + } + } + + return callee ? "the result of '" + callee.getValue().str() + "'" : "a call's result"; + } + // `'h.c'`, `'arr[]'`, `'h.c.v'` - or `a field`, `an element` when the root has no name (a // temporary, or no --di). std::string describePlace(mlir::Value ref) @@ -1690,10 +1871,23 @@ class OwnershipInferencePass : public mlir::PassWrapperemitError("'") << name << "' takes a second reference; -mm=own cannot prove a move or a borrow here yet"; + auto diag = op->emitError("'") << name << "' takes a second reference; -mm=own cannot prove a move or a borrow here yet"; + noteLostFacts(diag); signalPassFailure(); } + // Where the body would have been fine had its callers known a fact about it, say why they + // cannot. + void noteLostFacts(mlir::InFlightDiagnostic &diag) + { + auto f = getFunction(); + if (auto why = f->getAttrOfType(OWN_FACTS_LOST_ATTR_NAME)) + { + diag.attachNote(f.getLoc()) << "'" << f.getSymName() << "' could return a borrow or keep a parameter, but " + << why.getValue(); + } + } + void reportUseAfterMove(mlir::Operation *use, mlir::Operation *move, llvm::StringRef name) { if (quiet) @@ -1764,7 +1958,8 @@ class OwnershipInferencePass : public mlir::PassWrapperemitError("'") << name << "' borrows " << place << " and cannot be stored, returned or captured"; + auto diag = op->emitError("'") << name << "' borrows " << place << " and cannot be stored, returned or captured"; + noteLostFacts(diag); signalPassFailure(); } @@ -1787,7 +1982,8 @@ class OwnershipInferencePass : public mlir::PassWrapperemitError("'") << name << "' borrows '" << owner << "' and cannot be stored, returned or captured"; + auto diag = op->emitError("'") << name << "' borrows '" << owner << "' and cannot be stored, returned or captured"; + noteLostFacts(diag); signalPassFailure(); } diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index a951d64a4..fd6391db2 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -56,6 +56,7 @@ class OwnershipSignaturePass : public mlir::PassWrapper, llvm::SmallVector> families; - // Functions whose callers are not all known: a caller outside the module, or a reference to - // the symbol that is not a call this pass resolves. - llvm::DenseSet open; + // Functions whose callers are not all known - a caller outside the module, or a reference to + // the symbol that is not a call this pass resolves - and why, for the error in their body. + llvm::DenseMap open; + + // The parameter each function's result borrows, from its body (local) and as its callers see + // it (effective: closed, and agreed by every family it is in). + llvm::DenseMap borrowsLocal; + llvm::DenseMap borrowsEffective; // Functions that destroy nothing their caller can reach. llvm::DenseSet noDrops; @@ -290,8 +296,13 @@ class OwnershipSignaturePass : public mlir::PassWrapper(user)) + { + index = refOp.getIndex(); + } + else if (auto refOp = mlir::dyn_cast(user)) + { + index = refOp.getIndex(); + } + auto inFunction = !!user->getParentOfType(); if (inFunction && mlir::isa(user) && onlyCalled(user->getResult(0))) { + // a virtual call this pass cannot resolve reaches its family without the facts + if (index >= 0) + { + Callees callees; + resolveFamily(index, symbol, callees); + if (!callees.known) + { + openFamily(index, symbol, "an override may be defined in another module"); + } + } + continue; } - open.insert(funcOp); - int64_t index = -1; - if (auto refOp = mlir::dyn_cast(user)) + open.try_emplace(funcOp, "it is used other than by a call"); + if (index >= 0) { - index = refOp.getIndex(); + openFamily(index, symbol, "it is used other than by a call"); } - else if (auto refOp = mlir::dyn_cast(user)) + } + } + + void openFamily(int64_t index, mlir::StringAttr symbol, const char *why) + { + for (auto member : familyOf(index, symbol)) + { + if (auto memberOp = functions.lookup(member.getValue())) { - index = refOp.getIndex(); + open.try_emplace(memberOp, why); } + } + } - if (index >= 0) + // ---- Results that borrow an argument ---- + + // The values a function returns: what is stored into the local its returns read (MLIRGen's + // result slot), or what a return hands back directly. + static llvm::SmallVector returnedValues(mlir_ts::FuncOp funcOp) + { + llvm::SmallVector values; + funcOp.walk([&](mlir_ts::ReturnInternalOp returnOp) { + for (auto operand : returnOp.getRetOperands()) { - for (auto member : familyOf(index, symbol)) + auto loadOp = operand.getDefiningOp(); + auto slot = loadOp ? loadOp.getReference().getDefiningOp() : mlir_ts::VariableOp(); + if (!slot || slot.getInitializer() || isOwningVariable(slot)) + { + values.push_back(operand); + continue; + } + + for (auto *user : slot.getResult().getUsers()) { - if (auto memberOp = functions.lookup(member.getValue())) + auto storeOp = mlir::dyn_cast(user); + if (storeOp && storeOp.getReference() == slot.getResult()) { - open.insert(memberOp); + values.push_back(storeOp.getValue()); } } } + }); + + return values; + } + + // The parameter every heap value the function returns borrows, or -1 (spec 2.4). A result + // that holds no block (`null`, a number) agrees with any. + static int localResultBorrows(mlir_ts::FuncOp funcOp) + { + auto found = -1; + for (auto value : returnedValues(funcOp)) + { + if (holdsNoBlock(value)) + { + continue; + } + + auto index = borrowedParam(value); + if (index < 0 || (found >= 0 && index != found)) + { + return -1; + } + + found = index; + } + + return found; + } + + // A result that borrows an argument only holds where every caller knows it, so the fact + // survives only on a closed function whose every family agrees, and families are demoted + // until they do: a member that loses it can make another family disagree. A caller that + // returns what such a call returns borrows too, so the facts are recomputed with the calls + // pinned until nothing changes; if that does not settle, nobody gets one. + void computeResultBorrows() + { + llvm::DenseMap pinned; + for (auto round = 0; round < 16; ++round) + { + for (auto &entry : functions) + { + if (!entry.second.isDeclaration()) + { + borrowsLocal[entry.second] = localResultBorrows(entry.second); + } + } + + meetResultBorrows(); + + llvm::DenseMap next; + for (auto &call : calls) + { + if (auto index = callResultBorrows(call.callees); index >= 0) + { + next[call.op] = index; + } + } + + auto settled = next == pinned; + pinned = std::move(next); + pinResultBorrows(pinned); + if (settled) + { + return; + } + } + + borrowsEffective.clear(); + pinResultBorrows(llvm::DenseMap()); + } + + void meetResultBorrows() + { + borrowsEffective.clear(); + for (auto &[funcOp, index] : borrowsLocal) + { + if (index >= 0 && !open.contains(funcOp)) + { + borrowsEffective[funcOp] = index; + } + } + + 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 ? borrowsEffective.lookup_or(funcOp, -1) : -1); + } + + if (llvm::all_equal(seen)) + { + continue; + } + + for (auto member : members) + { + if (auto funcOp = functions.lookup(member.getValue()); funcOp && borrowsEffective.erase(funcOp)) + { + changed = true; + } + } + } + } + } + + int callResultBorrows(const Callees &callees) + { + if (!callees.known || callees.instanceOf || callees.funcs.empty()) + { + return -1; + } + + auto index = borrowsEffective.lookup_or(callees.funcs.front(), -1); + auto agree = llvm::all_of(callees.funcs, [&](mlir_ts::FuncOp callee) { + return borrowsEffective.lookup_or(callee, -1) == index; + }); + return agree ? index : -1; + } + + // The calls get the facts their callees agree on; each function gets its own, or the reason it + // lost one its body has, for the error there. + void pinResultBorrows(const llvm::DenseMap &pinned) + { + auto *context = &getContext(); + auto i32 = mlir::IntegerType::get(context, 32); + for (auto &call : calls) + { + if (auto found = pinned.find(call.op); found != pinned.end()) + { + call.op->setAttr(OWN_RESULT_BORROWS_ATTR_NAME, mlir::IntegerAttr::get(i32, found->second)); + } + else + { + call.op->removeAttr(OWN_RESULT_BORROWS_ATTR_NAME); + } + } + + for (auto &[funcOp, local] : borrowsLocal) + { + funcOp->removeAttr(OWN_RESULT_BORROWS_ATTR_NAME); + funcOp->removeAttr(OWN_FACTS_LOST_ATTR_NAME); + if (auto index = borrowsEffective.lookup_or(funcOp, -1); index >= 0) + { + funcOp->setAttr(OWN_RESULT_BORROWS_ATTR_NAME, mlir::IntegerAttr::get(i32, index)); + } + else if (local >= 0) + { + auto *why = open.lookup(funcOp); + funcOp->setAttr(OWN_FACTS_LOST_ATTR_NAME, + mlir::StringAttr::get(context, why ? why : "an override in its class family disagrees")); + } } } @@ -523,6 +737,12 @@ class OwnershipSignaturePass : public mlir::PassWrapper()) { work.push_back(propertyRefOp.getObjectRef()); diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 490399d11..a1a1ad3a6 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2409,7 +2409,7 @@ foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local o own_field_borrow own_element_borrow own_container_loop own_optional_access own_borrow_merge own_field_let_borrow own_element_let_borrow own_borrow_merge_after_call - own_call_no_drops) + own_call_no_drops own_getter_borrow) 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. @@ -2478,6 +2478,11 @@ set(own_error_cases "own_err_call_given_borrow|borrows .* but is used here after it may be released or overwritten" "own_err_call_this_borrow|borrows .* but is used here after it may be released or overwritten" "own_err_exported_virtual_call|borrows .* but is used here after it may be released or overwritten" + "own_err_borrowed_result_stored|borrows the result of 'firstOf' and cannot be stored, returned or captured" + "own_err_borrowed_result_outlives|borrows the result of 'firstOf' but is used here after it may be released or overwritten" + "own_err_borrowed_result_let_outlives|borrows the result of 'H.first' but is used here after it may be released or overwritten" + "own_err_borrowed_result_escapes|could return a borrow or keep a parameter, but it is used other than by a call" + "own_err_borrowed_result_family|an override in its class family disagrees" "own_err_delete|'delete' is not supported by -mm=own yet") foreach(own_error_case ${own_error_cases}) string(FIND "${own_error_case}" "|" own_error_sep) diff --git a/tslang/test/tester/own/own_err_borrowed_result_escapes.ts b/tslang/test/tester/own/own_err_borrowed_result_escapes.ts new file mode 100644 index 000000000..00c0a6fe2 --- /dev/null +++ b/tslang/test/tester/own/own_err_borrowed_result_escapes.ts @@ -0,0 +1,23 @@ +// -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. +class C { + constructor(public x: number) {} +} + +class H { + c: C = new C(0); +} + +function firstOf(h: H) { + return h.c; +} + +function apply(g: (h: H) => C, h: H) { + return g(h).x; +} + +function main() { + const h = new H(); + print(apply(firstOf, h)); +} diff --git a/tslang/test/tester/own/own_err_borrowed_result_family.ts b/tslang/test/tester/own/own_err_borrowed_result_family.ts new file mode 100644 index 000000000..bf084adda --- /dev/null +++ b/tslang/test/tester/own/own_err_borrowed_result_family.ts @@ -0,0 +1,23 @@ +// -mm=own, phase 4 rejects: `B.get` returns a field, but its override `D.get` returns a new +// object, so a call through `B` cannot know whether it owns the result. +class C { + constructor(public x: number) {} +} + +class B { + c: C = new C(1); + get() { + return this.c; + } +} + +class D extends B { + get() { + return new C(2); + } +} + +function main() { + const b: B = new D(); + print(b.get().x); +} diff --git a/tslang/test/tester/own/own_err_borrowed_result_let_outlives.ts b/tslang/test/tester/own/own_err_borrowed_result_let_outlives.ts new file mode 100644 index 000000000..ba1c9023a --- /dev/null +++ b/tslang/test/tester/own/own_err_borrowed_result_let_outlives.ts @@ -0,0 +1,19 @@ +// -mm=own, phase 4 rejects: `b` is declared from a method's borrowed result, then the owner of +// the field it borrows is assigned. +class C { + constructor(public x: number) {} +} + +class H { + c: C = new C(0); + first() { + return this.c; + } +} + +function main() { + let h = new H(); + let b = h.first(); + h = new H(); + print(b.x); +} diff --git a/tslang/test/tester/own/own_err_borrowed_result_outlives.ts b/tslang/test/tester/own/own_err_borrowed_result_outlives.ts new file mode 100644 index 000000000..234f2690a --- /dev/null +++ b/tslang/test/tester/own/own_err_borrowed_result_outlives.ts @@ -0,0 +1,20 @@ +// -mm=own, phase 4 rejects: `c` borrows what `firstOf(h)` returned, a field under `h`; the +// overwrite of `h.c` destroys it before `c.x` reads it. +class C { + constructor(public x: number) {} +} + +class H { + c: C = new C(0); +} + +function firstOf(h: H) { + return h.c; +} + +function main() { + const h = new H(); + const c = firstOf(h); + h.c = new C(9); + print(c.x); +} diff --git a/tslang/test/tester/own/own_err_borrowed_result_stored.ts b/tslang/test/tester/own/own_err_borrowed_result_stored.ts new file mode 100644 index 000000000..f3e30d3fc --- /dev/null +++ b/tslang/test/tester/own/own_err_borrowed_result_stored.ts @@ -0,0 +1,20 @@ +// -mm=own, phase 4 rejects: `firstOf(h)` borrows `h`, so it cannot be stored into another +// object, which would outlive `h`'s hold on it. +class C { + constructor(public x: number) {} +} + +class H { + c: C = new C(0); +} + +function firstOf(h: H) { + return h.c; +} + +function main() { + const h = new H(); + const h2 = new H(); + h2.c = firstOf(h); + print(h2.c.x); +} diff --git a/tslang/test/tester/own/own_getter_borrow.ts b/tslang/test/tester/own/own_getter_borrow.ts new file mode 100644 index 000000000..02d2abc7e --- /dev/null +++ b/tslang/test/tester/own/own_getter_borrow.ts @@ -0,0 +1,78 @@ +// -mm=own, phase 4: a result that borrows an argument (`__own_result_borrows`). A getter, a method +// and a function returning a field of their argument, a method forwarding another's borrow, a +// field or `null`, and a function returning its parameter: the callee gives no reference, the +// caller takes none, and the result is bounded by the argument, read after a `churn()`. +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); + } +} + +class H { + c: C = new C(0); + get cc() { + return this.c; + } + + first() { + return this.c; + } + + firstOrNull(k: number): C | null { + return k > 0 ? this.c : null; + } +} + +class W { + h: H = new H(); + inner() { + return this.h.first(); + } +} + +function firstOf(h: H) { + return h.c; +} + +function same(c: C) { + return c; +} + +function main() { + let t: number = 0; + for (let i = 0; i < 100000; i++) { + const h = new H(); + h.c = new C(i % 10); + h.c.v.push(1); + const a = firstOf(h); + let b = h.first(); + churn(); + t += a.x + b.x + h.cc.x + b.v.length; + + const w = new W(); + w.h.c = new C(1); + const d = w.inner(); + churn(); + t += d.x; + + const n = h.firstOrNull(i % 2); + if (n) { + t += n.x; + } + + let s = same(new C(2)); + churn(); + t += s.x + same(h.c).x; + } + + assert(t == 2450000); + print("done."); +} From 2c5e0aa1581506e2cf7a21f3737da2081d125fdc Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Tue, 29 Sep 2026 18:55:28 +0100 Subject: [PATCH 4/6] -mm=own phase 4: a callee may keep a parameter its caller moves to it; any Owned-by-callee parameters (__own_params): a parameter (never assigned) that the body stores into a field, element or global, pushes, or passes to a kept parameter, under the same closed world and family meet as borrowed results. The callee's retain goes, and the move must reach every return, not loop, and be the last use. The caller's call is a taker of each kept argument: out of a fresh value, an owning let or a kept parameter; anything else is an error. Found by own_param_kept: a move whose acquisition is the call erased the call. any: the boxing cast carries its birth reference (__owned_result + retain), as a printed number does. That fixes rc's leak of every box no let holds, and under own makes the box a fresh value with one owner. The catch copy thunk's store takes that reference over (the catch slot is runtime memory), and a thunk's read out of the exception object is a move: nothing gives it back. ___unbox gets a borrowed result through ts.Unbox, and its .instanceOf call is drop-free, so a borrows the payload for as long as the box lives. Co-Authored-By: Claude Opus 5.5 --- tslang/lib/TypeScript/MLIRGenCast.cpp | 12 +- tslang/lib/TypeScript/MLIRGenImpl.h | 16 +- tslang/lib/TypeScript/MLIRGenStatements.cpp | 7 + .../lib/TypeScript/OwnershipInferencePass.cpp | 214 ++++++++++++++++- .../lib/TypeScript/OwnershipSignaturePass.cpp | 225 +++++++++++++----- tslang/test/tester/CMakeLists.txt | 7 +- tslang/test/tester/own/own_any_box.ts | 35 +++ .../own/own_err_any_unboxed_outlives.ts | 12 + .../tester/own/own_err_param_kept_loop.ts | 23 ++ .../own/own_err_param_kept_not_owned.ts | 21 ++ .../own/own_err_param_kept_some_paths.ts | 21 ++ .../tester/own/own_err_param_kept_used.ts | 20 ++ tslang/test/tester/own/own_param_kept.ts | 66 +++++ 13 files changed, 606 insertions(+), 73 deletions(-) create mode 100644 tslang/test/tester/own/own_any_box.ts create mode 100644 tslang/test/tester/own/own_err_any_unboxed_outlives.ts create mode 100644 tslang/test/tester/own/own_err_param_kept_loop.ts create mode 100644 tslang/test/tester/own/own_err_param_kept_not_owned.ts create mode 100644 tslang/test/tester/own/own_err_param_kept_some_paths.ts create mode 100644 tslang/test/tester/own/own_err_param_kept_used.ts create mode 100644 tslang/test/tester/own/own_param_kept.ts diff --git a/tslang/lib/TypeScript/MLIRGenCast.cpp b/tslang/lib/TypeScript/MLIRGenCast.cpp index 81928e493..7c7c7c8e4 100644 --- a/tslang/lib/TypeScript/MLIRGenCast.cpp +++ b/tslang/lib/TypeScript/MLIRGenCast.cpp @@ -677,7 +677,17 @@ namespace mlirgen // time (§9.37). if (isa(type) && castToStringAllocates(valueType)) { - markFreshStringOwned(location, castResult); + markFreshBlockOwned(location, castResult); + } + + // Boxing into `any` allocates the box, and the cast handed it back with no reference at + // all: a box a `let` holds was retained by the `let` and freed, but one held by a folded + // `const`, or passed straight to a call, was never released (1M `const a: any = new C()`: + // rc 66 MB, gc 6 MB). The box takes the payload's reference above; this is the box's own. + // An `any` cast to `any` is the same box, and allocates nothing. + if (isa(type) && !isa(valueType)) + { + markFreshBlockOwned(location, castResult); } return V(castResult); diff --git a/tslang/lib/TypeScript/MLIRGenImpl.h b/tslang/lib/TypeScript/MLIRGenImpl.h index 30124a2d1..49362abd8 100644 --- a/tslang/lib/TypeScript/MLIRGenImpl.h +++ b/tslang/lib/TypeScript/MLIRGenImpl.h @@ -1070,17 +1070,17 @@ class MLIRGenImpl return isa(valueType) || valueType.isIntOrIndex(); } - // Gives a freshly built string the same standing as every other producer of a new heap - // value: the retain makes the reference real, and the mark says a receiver may take it over - // rather than adding one of its own - which is also what lets §9.30 give it back where - // nothing receives it at all. + // Gives a freshly built string, or a freshly made `any` box, the same standing as every other + // producer of a new heap value: the retain makes the reference real, and the mark says a + // receiver may take it over rather than adding one of its own - which is also what lets §9.30 + // give it back where nothing receives it at all. // // Both halves are needed together, and the retain is what makes this safe to be generous // with: a value wrongly counted as fresh gains a reference and a release for it, which is // balanced, where a mark on its own would hand a receiver a reference nobody took. - void markFreshStringOwned(mlir::Location location, mlir::Value value) + void markFreshBlockOwned(mlir::Location location, mlir::Value value) { - if (!value || !isa(value.getType())) + if (!value || !isa(value.getType())) { return; } @@ -6212,9 +6212,9 @@ class MLIRGenImpl // `ts.StringConcat`, which builds a new string. A receiver takes that reference // over; `("a" + b).length`, where there is no receiver, gives it back at the end of // the block instead of leaking (§9.37). - if (opCode == SyntaxKind::PlusToken) + if (opCode == SyntaxKind::PlusToken && isa(result.getType())) { - markFreshStringOwned(location, result); + markFreshBlockOwned(location, result); } break; diff --git a/tslang/lib/TypeScript/MLIRGenStatements.cpp b/tslang/lib/TypeScript/MLIRGenStatements.cpp index 105b419ee..12c59d678 100644 --- a/tslang/lib/TypeScript/MLIRGenStatements.cpp +++ b/tslang/lib/TypeScript/MLIRGenStatements.cpp @@ -1158,6 +1158,13 @@ namespace mlirgen return mlir::failure(); } + // The catch object the runtime copies into holds what it is given: a box made here carries + // a reference of its own, which goes with it rather than back at the end of the thunk. + if (producesOwnedReference(V(result))) + { + consumeOwnedReference(V(result)); + } + builder.create(location, V(result), arguments[0]); builder.create(location, mlir::Value()); diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index 21227206f..1d89bef1a 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -60,6 +60,8 @@ class OwnershipInferencePass : public mlir::PassWrapper placeReads; + // calls whose callee keeps an argument: each such argument moves into the call + llvm::SmallVector keepingCalls; drops.clear(); derivedCache.clear(); returnsBorrowOf = resultBorrows(f); @@ -120,6 +122,11 @@ class OwnershipInferencePass : public mlir::PassWrappergetResults()) @@ -155,6 +162,7 @@ class OwnershipInferencePass : public mlir::PassWrapper> slotReceivers; + llvm::MapVector> paramReceivers; for (auto *op : retains) { auto value = retainedValue(op); @@ -169,6 +177,13 @@ class OwnershipInferencePass : public mlir::PassWrapper= 0 && borrowedParam(value) == returnsBorrowOf) { @@ -186,17 +201,60 @@ class OwnershipInferencePass : public mlir::PassWrapper(index) >= args.size() || holdsNoBlock(args[index]) || + isImmortalLiteral(args[index])) + { + continue; + } + + auto arg = args[index]; + auto root = rootOf(arg); + if (auto load = slotLoadOf(arg)) + { + slotReceivers[load.getReference()].push_back({call, root, load}); + } + else if (auto load = keptParamLoadOf(arg)) + { + paramReceivers[load.getReference()].push_back({call, root, load}); + } + else if (!isFresh(root) && !placeReadOf(root)) + { + reportGivenNotOwned(call, arg); + } + } + } + for (auto &entry : slotReceivers) { decideSlot(entry.first, entry.second, toErase); } + for (auto &entry : paramReceivers) + { + decideParam(entry.first, entry.second, toErase); + } + for (auto *op : toErase) { op->erase(); @@ -256,7 +314,12 @@ class OwnershipInferencePass : public mlir::PassWrapper receivers, llvm::SetVector &toErase) + { + auto name = varName(slot.getDefiningOp()); + auto &receiver = receivers.front(); + auto *taker = takerOf(receiver.retain, receiver.value); + if (receivers.size() > 1) + { + auto &second = receivers[1]; + auto *secondTaker = takerOf(second.retain, second.value); + reportUseAfterMove(secondTaker ? secondTaker : second.retain, taker ? taker : receiver.retain, name); + return; + } + + if (!checkSlotMove(receiver.retain, receiver.value, receiver.load, toErase)) + { + return; + } + + auto &body = getFunction().getBody(); + auto *moved = body.findAncestorOpInRegion(*taker); + auto everyPath = true; + getFunction().walk([&](mlir_ts::ReturnInternalOp returnOp) { + auto *exit = body.findAncestorOpInRegion(*returnOp); + if (everyPath && (!moved || !exit || !dominance->dominates(moved, exit))) + { + reportKeptOnSomePaths(taker, returnOp, name); + everyPath = false; + } + }); + + if (everyPath && mlir::isa(receiver.retain)) + { + toErase.insert(receiver.retain); + } + } + + // The thrown value, read out of the exception object by a catch's copy thunk (`.eh.copy.*`, + // which the C++ runtime calls to copy the exception into the catch). rc retains it for the + // copy; own moves it: the exception object never gives its reference back (a throw hands the + // thrower's reference to it, and nothing releases it - rc leaks it the same way), and each + // exception is copied into one catch, since a TypeScript rethrow is a new throw. + bool isReadOutOfException(mlir::Value value) + { + auto loadOp = rootOf(value).getDefiningOp(); + auto argument = loadOp ? mlir::dyn_cast(loadOp.getReference()) : mlir::BlockArgument(); + return argument && argument.getOwner()->isEntryBlock() && getFunction().getSymName().starts_with(".eh.copy."); + } + + // The read of a parameter this function keeps, through its views. None for anything else. + mlir_ts::LoadOp keptParamLoadOf(mlir::Value value) + { + auto loadOp = rootOf(value).getDefiningOp(); + auto varOp = loadOp ? loadOp.getReference().getDefiningOp() : mlir_ts::VariableOp(); + auto index = -1; + if (!varOp || !isParameterSlot(varOp, index) || !llvm::is_contained(ownedParams(getFunction()), index)) + { + return {}; + } + + return loadOp; + } + // More than one receiver of one owning local. Each has to borrow: a borrow pins the owner, so // none may move it. A receiver that is not a `let` reports its move error - another // receiver's reads come after it - or, if its move alone would pass, that a borrow pins it. @@ -819,9 +947,10 @@ class OwnershipInferencePass : public mlir::PassWrapper(user)) { - forEachUse(loadOp.getResult(), [&](mlir::Operation *use, mlir::Value) { + forEachUse(loadOp.getResult(), [&](mlir::Operation *use, mlir::Value used) { auto varOp = mlir::dyn_cast(use); - if (mlir::isa(use) || (varOp && isOwningVariable(varOp))) + if (mlir::isa(use) || (varOp && isOwningVariable(varOp)) || + (isCall(use) && isKeptArgument(use, used))) { ends.push_back(use); } @@ -1110,8 +1239,15 @@ class OwnershipInferencePass : public mlir::PassWrapper(call)) @@ -1139,7 +1275,7 @@ class OwnershipInferencePass : public mlir::PassWrapper(castOp.getType()) || castOp.getType().isInteger(1); } - // arguments are borrowed; a callee that keeps one retains it, and that retain is its own - // error - if (mlir::isa(user)) + // arguments are borrowed, but one the callee keeps (`__own_params`) is taken; a callee + // with no facts that keeps one retains it, and that retain is its own error + if (isCall(user)) { - return true; + return !isKeptArgument(user, value); } // the array being pushed onto is written, not taken; what is inserted is taken @@ -1364,6 +1499,15 @@ class OwnershipInferencePass : public mlir::PassWrapper(index) < args.size() && args[index] == value; + }); + } + // The moves out of a fresh or unknown SSA value. Each taking use (spec 11.1) is a move: // - nothing else may use the value after it - another taker included - and a taker that // control comes back to without the value being made again is a move on every iteration @@ -1647,6 +1791,12 @@ class OwnershipInferencePass : public mlir::PassWrapper(taker)) { return llvm::any_of(varOp.getResult().getUsers(), @@ -1888,6 +2044,44 @@ class OwnershipInferencePass : public mlir::PassWrapperemitError("'") << name + << "' is moved here on some paths only; -mm=own cannot release it " + "on the others yet"; + diag.attachNote(exit->getLoc()) << "returns here without moving it; the caller gave it up"; + signalPassFailure(); + } + + void reportGivenNotOwned(mlir::Operation *call, mlir::Value arg) + { + if (quiet) + { + return; + } + + llvm::StringRef name = ownerName(arg); + if (auto loadOp = rootOf(arg).getDefiningOp()) + { + if (auto varOp = loadOp.getReference().getDefiningOp()) + { + name = varName(varOp); + } + } + + auto callee = calleeName(call); + auto diag = call->emitError("argument '") << name << "' is given to '" << (callee.empty() ? "a function" : callee) + << "', which keeps it, but -mm=own cannot move it here: this " + "function does not own it"; + noteLostFacts(diag); + signalPassFailure(); + } + void reportUseAfterMove(mlir::Operation *use, mlir::Operation *move, llvm::StringRef name) { if (quiet) diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index fd6391db2..b50ae592c 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -56,7 +56,7 @@ class OwnershipSignaturePass : public mlir::PassWrapper open; - // The parameter each function's result borrows, from its body (local) and as its callers see - // it (effective: closed, and agreed by every family it is in). - llvm::DenseMap borrowsLocal; - llvm::DenseMap borrowsEffective; + // The parameter a function's result borrows and the parameters it keeps (spec 2.4); absent is + // rc's convention. + struct Facts + { + int borrows = -1; + llvm::SmallVector owned; + + bool empty() const + { + return borrows < 0 && owned.empty(); + } + + bool operator==(const Facts &other) const + { + return borrows == other.borrows && owned == other.owned; + } + + bool operator!=(const Facts &other) const + { + return !(*this == other); + } + }; + + // Each function's facts, from its body (local) and as its callers see them (effective: closed, + // and agreed by every family it is in). + llvm::DenseMap factsLocal; + llvm::DenseMap factsEffective; // Functions that destroy nothing their caller can reach. llvm::DenseSet noDrops; @@ -502,56 +525,147 @@ class OwnershipSignaturePass : public mlir::PassWrapper pinned; + // a number, a boolean, a string literal: nothing to keep + if (holdsNoBlock(used)) + { + return false; + } + + if (auto storeOp = mlir::dyn_cast(user)) + { + auto ref = storeOp.getReference(); + return storeOp.getValue() == used && (isPlace(ref) || ref.getDefiningOp()); + } + + if (auto pushOp = mlir::dyn_cast(user)) + { + return llvm::is_contained(pushOp.getItems(), used); + } + + if (auto unshiftOp = mlir::dyn_cast(user)) + { + return llvm::is_contained(unshiftOp.getItems(), used); + } + + if (auto spliceOp = mlir::dyn_cast(user)) + { + return llvm::is_contained(spliceOp.getItems(), used); + } + + if (isCall(user)) + { + auto args = callArgs(user); + return llvm::any_of(ownedParams(user), [&](int32_t index) { + return static_cast(index) < args.size() && args[index] == used; + }); + } + + return false; + } + + // The parameters the body keeps (spec 2.4, owned-by-callee): a read of the parameter's slot - + // never assigned - that something keeps. + static llvm::SmallVector localOwnedParams(mlir_ts::FuncOp funcOp) + { + llvm::SmallVector owned; + if (funcOp.getBody().empty()) + { + return owned; + } + + for (auto argument : funcOp.getBody().front().getArguments()) + { + auto kept = false; + for (auto *user : argument.getUsers()) + { + auto varOp = mlir::dyn_cast(user); + auto index = -1; + if (!varOp || !isParameterSlot(varOp, index)) + { + continue; + } + + for (auto *slotUser : varOp.getResult().getUsers()) + { + if (auto loadOp = mlir::dyn_cast(slotUser)) + { + forEachUse(loadOp.getResult(), [&](mlir::Operation *use, mlir::Value used) { + kept = kept || keeps(use, used); + }); + } + } + } + + if (kept) + { + owned.push_back(argument.getArgNumber()); + } + } + + return owned; + } + + // ---- The facts callers must know ---- + + // Owned parameters and a result that borrows only hold where every caller knows them, so the + // facts survive only on a closed function whose every family agrees, and families are + // demoted until they do: a member that loses its facts can make another family disagree. A + // caller that returns what such a call returns, or passes a parameter on to a kept one, gets + // the fact too, so the facts are recomputed with the calls pinned until nothing changes; if + // that does not settle, nobody gets any. + void computeFacts() + { + llvm::DenseMap pinned; for (auto round = 0; round < 16; ++round) { for (auto &entry : functions) { if (!entry.second.isDeclaration()) { - borrowsLocal[entry.second] = localResultBorrows(entry.second); + factsLocal[entry.second] = {localResultBorrows(entry.second), localOwnedParams(entry.second)}; } } - meetResultBorrows(); + meetFacts(); - llvm::DenseMap next; + llvm::DenseMap next; for (auto &call : calls) { - if (auto index = callResultBorrows(call.callees); index >= 0) + if (auto facts = callFacts(call.callees); !facts.empty()) { - next[call.op] = index; + next[call.op] = facts; } } auto settled = next == pinned; pinned = std::move(next); - pinResultBorrows(pinned); + pinFacts(pinned); if (settled) { return; } } - borrowsEffective.clear(); - pinResultBorrows(llvm::DenseMap()); + factsEffective.clear(); + pinFacts(llvm::DenseMap()); } - void meetResultBorrows() + void meetFacts() { - borrowsEffective.clear(); - for (auto &[funcOp, index] : borrowsLocal) + factsEffective.clear(); + for (auto &[funcOp, facts] : factsLocal) { - if (index >= 0 && !open.contains(funcOp)) + if (!facts.empty() && !open.contains(funcOp)) { - borrowsEffective[funcOp] = index; + factsEffective[funcOp] = facts; } } @@ -560,11 +674,11 @@ class OwnershipSignaturePass : public mlir::PassWrapper seen; + llvm::SmallVector seen; for (auto member : members) { auto funcOp = functions.lookup(member.getValue()); - seen.push_back(funcOp ? borrowsEffective.lookup_or(funcOp, -1) : -1); + seen.push_back(funcOp ? factsEffective.lookup(funcOp) : Facts()); } if (llvm::all_equal(seen)) @@ -574,7 +688,7 @@ class OwnershipSignaturePass : public mlir::PassWrappergetContext(); + op->removeAttr(OWN_RESULT_BORROWS_ATTR_NAME); + op->removeAttr(OWN_PARAMS_ATTR_NAME); + if (facts.borrows >= 0) + { + op->setAttr(OWN_RESULT_BORROWS_ATTR_NAME, + mlir::IntegerAttr::get(mlir::IntegerType::get(context, 32), facts.borrows)); + } + + if (!facts.owned.empty()) + { + op->setAttr(OWN_PARAMS_ATTR_NAME, mlir::DenseI32ArrayAttr::get(context, facts.owned)); + } } // The calls get the facts their callees agree on; each function gets its own, or the reason it - // lost one its body has, for the error there. - void pinResultBorrows(const llvm::DenseMap &pinned) + // lost those its body has, for the error there. + void pinFacts(const llvm::DenseMap &pinned) { - auto *context = &getContext(); - auto i32 = mlir::IntegerType::get(context, 32); for (auto &call : calls) { - if (auto found = pinned.find(call.op); found != pinned.end()) - { - call.op->setAttr(OWN_RESULT_BORROWS_ATTR_NAME, mlir::IntegerAttr::get(i32, found->second)); - } - else - { - call.op->removeAttr(OWN_RESULT_BORROWS_ATTR_NAME); - } + setFacts(call.op, pinned.lookup(call.op)); } - for (auto &[funcOp, local] : borrowsLocal) + for (auto &[funcOp, local] : factsLocal) { - funcOp->removeAttr(OWN_RESULT_BORROWS_ATTR_NAME); + auto effective = factsEffective.lookup(funcOp); + setFacts(funcOp, effective); funcOp->removeAttr(OWN_FACTS_LOST_ATTR_NAME); - if (auto index = borrowsEffective.lookup_or(funcOp, -1); index >= 0) - { - funcOp->setAttr(OWN_RESULT_BORROWS_ATTR_NAME, mlir::IntegerAttr::get(i32, index)); - } - else if (local >= 0) + if (effective.empty() && !local.empty()) { auto *why = open.lookup(funcOp); funcOp->setAttr(OWN_FACTS_LOST_ATTR_NAME, - mlir::StringAttr::get(context, why ? why : "an override in its class family disagrees")); + 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 a1a1ad3a6..ddbcc3fb2 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2409,7 +2409,7 @@ foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local o own_field_borrow own_element_borrow own_container_loop own_optional_access own_borrow_merge own_field_let_borrow own_element_let_borrow own_borrow_merge_after_call - own_call_no_drops own_getter_borrow) + own_call_no_drops own_getter_borrow own_param_kept own_any_box) 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. @@ -2483,6 +2483,11 @@ set(own_error_cases "own_err_borrowed_result_let_outlives|borrows the result of 'H.first' but is used here after it may be released or overwritten" "own_err_borrowed_result_escapes|could return a borrow or keep a parameter, but it is used other than by a call" "own_err_borrowed_result_family|an override in its class family disagrees" + "own_err_param_kept_used|is used here after its value was moved" + "own_err_any_unboxed_outlives|borrows the result of '___unbox<.*>' but is used here after it may be released or overwritten" + "own_err_param_kept_some_paths|is moved here on some paths only" + "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") foreach(own_error_case ${own_error_cases}) string(FIND "${own_error_case}" "|" own_error_sep) diff --git a/tslang/test/tester/own/own_any_box.ts b/tslang/test/tester/own/own_any_box.ts new file mode 100644 index 000000000..377afb4cf --- /dev/null +++ b/tslang/test/tester/own/own_any_box.ts @@ -0,0 +1,35 @@ +// -mm=own, phase 4: an `any` box owns what it holds. Boxing moves the value in and makes a box +// with one owner - a `let`, a folded `const`, or the temporary passed to a call - and unboxing +// (`___unbox`, whose result borrows its argument) borrows the payload for as long as the box +// lives. Every value is read after a `churn()`. +class C { + v: number[] = []; + constructor(public x: number) {} +} + +function churn() { + const junk: C[] = []; + for (let i = 0; i < 64; i++) { + const c = new C(999); + c.v.push(99); + junk.push(c); + } +} + +function kind(v: any) { + return typeof v == "class" ? 1 : 0; +} + +function main() { + let t: number = 0; + for (let i = 0; i < 100000; i++) { + let a: any = new C(i % 10); + const b: any = new C(1); + const ca = a; + churn(); + t += ca.x + (b).x + kind(new C(2)) + kind(a); + } + + assert(t == 750000); + print("done."); +} diff --git a/tslang/test/tester/own/own_err_any_unboxed_outlives.ts b/tslang/test/tester/own/own_err_any_unboxed_outlives.ts new file mode 100644 index 000000000..4296d3c57 --- /dev/null +++ b/tslang/test/tester/own/own_err_any_unboxed_outlives.ts @@ -0,0 +1,12 @@ +// -mm=own, phase 4 rejects: `c` is the payload of the box `a` holds, borrowed through `___unbox`; +// assigning `a` destroys the box and its payload before `c.x` reads it. +class C { + constructor(public x: number) {} +} + +function main() { + let a: any = new C(1); + const c = a; + a = new C(2); + print(c.x); +} diff --git a/tslang/test/tester/own/own_err_param_kept_loop.ts b/tslang/test/tester/own/own_err_param_kept_loop.ts new file mode 100644 index 000000000..faac8793d --- /dev/null +++ b/tslang/test/tester/own/own_err_param_kept_loop.ts @@ -0,0 +1,23 @@ +// -mm=own, phase 4 rejects: `c` is made once, outside the loop, and moved into `keep` on every +// iteration. +class C { + constructor(public x: number) {} +} + +class H { + c: C | null = null; +} + +function keep(h: H, c: C) { + h.c = c; +} + +function main() { + const h = new H(); + const c = new C(1); + for (let i = 0; i < 3; i++) { + keep(h, c); + } + + print(h.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 new file mode 100644 index 000000000..f37db9bab --- /dev/null +++ b/tslang/test/tester/own/own_err_param_kept_not_owned.ts @@ -0,0 +1,21 @@ +// -mm=own, phase 4 rejects: `keep` keeps its parameter, but `main` passes a global, which it does +// not own and cannot move. +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 main() { + const h = new H(); + keep(h, g); + print(h.c!.x); +} diff --git a/tslang/test/tester/own/own_err_param_kept_some_paths.ts b/tslang/test/tester/own/own_err_param_kept_some_paths.ts new file mode 100644 index 000000000..d4fe30b81 --- /dev/null +++ b/tslang/test/tester/own/own_err_param_kept_some_paths.ts @@ -0,0 +1,21 @@ +// -mm=own, phase 4 rejects: `keepIf` keeps its parameter only when `f` is true, and has nothing +// to release it with on the other path. +class C { + constructor(public x: number) {} +} + +class H { + c: C | null = null; +} + +function keepIf(h: H, c: C, f: boolean) { + if (f) { + h.c = c; + } +} + +function main() { + const h = new H(); + keepIf(h, new C(1), true); + print(h.c!.x); +} diff --git a/tslang/test/tester/own/own_err_param_kept_used.ts b/tslang/test/tester/own/own_err_param_kept_used.ts new file mode 100644 index 000000000..ab2b39a96 --- /dev/null +++ b/tslang/test/tester/own/own_err_param_kept_used.ts @@ -0,0 +1,20 @@ +// -mm=own, phase 4 rejects: `keep` keeps its parameter, so `a` moves into the call and cannot be +// read after it. +class C { + constructor(public x: number) {} +} + +class H { + c: C | null = null; +} + +function keep(h: H, c: C) { + h.c = c; +} + +function main() { + const h = new H(); + let a = new C(1); + keep(h, a); + print(a.x); +} diff --git a/tslang/test/tester/own/own_param_kept.ts b/tslang/test/tester/own/own_param_kept.ts new file mode 100644 index 000000000..9c4816e02 --- /dev/null +++ b/tslang/test/tester/own/own_param_kept.ts @@ -0,0 +1,66 @@ +// -mm=own, phase 4: a callee that keeps a parameter (`__own_params`) - a function storing it into +// a field, one passing it on to that function, a method storing it into `this`, one pushing it, +// a constructor's parameter property - takes it over: the caller moves the argument in, from a +// temporary or a `let`, and gives nothing back. Every value is read after a `churn()`. +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); + } +} + +class H { + c: C | null = null; + items: C[] = []; + set(c: C) { + this.c = c; + } +} + +class K { + constructor(public c: C) {} +} + +function keep(h: H, c: C) { + h.c = c; +} + +function forward(h: H, c: C) { + keep(h, c); +} + +function add(h: H, c: C) { + h.items.push(c); +} + +function main() { + let t: number = 0; + for (let i = 0; i < 100000; i++) { + const h = new H(); + keep(h, new C(i % 10)); + churn(); + t += h.c!.x; + + let a = new C(1); + a.v.push(1); + forward(h, a); + churn(); + t += h.c!.x + h.c!.v.length; + + h.set(new C(2)); + add(h, new C(3)); + const k = new K(new C(4)); + churn(); + t += h.c!.x + h.items[0].x + k.c.x; + } + + assert(t == 1550000); + print("done."); +} From b195a518f8d63cc4ee22512283d206cde74d38d6 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Tue, 29 Sep 2026 18:55:35 +0100 Subject: [PATCH 5/6] -mm=own phase 4: results Spec section 15: the signature pass and its closed world, what the inference accepts, measured numbers (own flat at 4.8 MB where none climbs to 1.6 GB), teeth, tests and the corpus (272 -> 303 of 564, none lost), and the known limits: export/import of facts deferred, parameters kept on some paths only, wildcard places for borrowed results, interface calls unresolved. Found on the way and left for their own work: rc never releases a getter's result used as a temporary (12.6 MB in own_getter_borrow), and under every model anyValue segfaults when B implements an interface, since mlirGenInstanceOfOpaque calls vtable slot 0 as .instanceOf. Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-09-29-own-phase-4.md | 11 +- .../2026-09-24-own-memory-model-design.md | 166 +++++++++++++++++- .../lib/TypeScript/OwnershipSignaturePass.cpp | 7 +- 3 files changed, 175 insertions(+), 9 deletions(-) diff --git a/tslang/docs/superpowers/plans/2026-09-29-own-phase-4.md b/tslang/docs/superpowers/plans/2026-09-29-own-phase-4.md index b165e3ceb..1af2afda7 100644 --- a/tslang/docs/superpowers/plans/2026-09-29-own-phase-4.md +++ b/tslang/docs/superpowers/plans/2026-09-29-own-phase-4.md @@ -116,9 +116,11 @@ signature pass), §14.5-14.6 (this phase's input). `f` resets `h.c` through a global and then reads its parameter reads freed memory. A call that drops a chain is now an error when it is also given the borrow. Cost if wrong: extra errors in code that passes a field to a function that overwrites fields. -- **The `.instanceOf` slot.** The call through a vtable's first slot is always a class's generated - `..instanceOf` (a string compare), which drops nothing. Without this, `___unbox` has an unknown - call between its read and its return. Cost if wrong: none today; nothing else is stored there. +- **The `.instanceOf` slot.** A call through a vtable's first slot is built only by + `mlirGenInstanceOfOpaque`, to reach a class's generated `..instanceOf` (a string compare), which + drops nothing. Without this, `___unbox` has an unknown call between its read and its return. + Found while reviewing: a class that implements an interface keeps the interface's vtable in that + slot, so the call crashes under every model (§15.7). It destroys nothing either way. - **The `any` box fix is in MLIRGen and changes rc.** The boxing cast gets `__owned_result` and a `ts.Retain`, as `markFreshStringOwned` does for a printed number. That is rc's §14.5 leak fix, and under own it makes the box a fresh value with one owner. Cost if wrong: rc suite failures, @@ -156,7 +158,8 @@ Steps: resolve calls (`resolveCallees`: direct, virtual family, `.instanceOf` sl compute `__own_no_drops` to a least fixpoint and pin it on functions and resolved calls. In the inference, `dropsChain` asks a call's `__own_no_drops` first; `isCall` drops stay otherwise. Remove the "a call's own arguments are read before it runs" exclusion when the call drops. -Corpus: the three `export_class_abstract*` files compile again. +Corpus: the three `export_class_abstract*` files were expected to compile again. They do not: +their class is exported, so `area`'s family may have an override in another module (see §15.7). Commit: `-mm=own phase 4: a call drops a borrow only if its callee may drop`. ### Task 2: borrowed-from-argument results 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 dceed5a7d..e9ba5c466 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 @@ -3,8 +3,8 @@ Date: 2026-09-24. Status: design approved in conversation; written review 2026-09-28 (§10), amendments folded in. Phase 0 merged as #399 (results §11); phase 1 (moves by reachability) merged as #401 (results §12); phase 2 (borrows for locals) merged as #402 -(results §13); phase 3 (containers and unions) on branch `own-phase-3` (results §14). Plans in -`docs/superpowers/plans/`. +(results §13); phase 3 (containers and unions) merged as #403 (results §14); phase 4 (function +signatures and `any`) on branch `own-phase-4` (results §15). Plans in `docs/superpowers/plans/`. ## 1. Purpose @@ -295,7 +295,8 @@ Each phase is a PR series leaving `main` green. 3. **Containers**: stores as moves, reads as borrows; `any`/union boxing. As built (§14): unions and reads; `any` moved to phase 4. 4. **Signature pass**: owned-by-callee parameters, borrowed-from-argument results, export beside - the marker and import on the other side. + the marker and import on the other side. As built (§15): the facts, drop-free callees and + `any`; export and import deferred. 5. **Closures**: escaping vs non-escaping, cells. 6. **Corpus report and diagnostics polish**: refine the shapes the histogram says matter. 7. **Generators and async**: the state object as owner. @@ -787,3 +788,162 @@ reference. `ts.PropertyRef` and is not bounded yet. - From §13.5: borrow chains through assigned borrowers, moves after the last use of every borrower, and a temporary taken by a `let` and consumed elsewhere. + +## 15. Phase 4 results, 2026-09-29 + +Plan: `docs/superpowers/plans/2026-09-29-own-phase-4.md`. New: `OwnershipSignaturePass.cpp` and +`OwnershipFacts.h` (the helpers both passes read the ops with). Changed: +`OwnershipInferencePass.cpp`, and two MLIRGen sites for `any` (§15.4). + +### 15.1 The signature pass + +A module pass, own only, just before inference. It resolves every call it can, computes three +facts per function, and pins on each resolved call the facts its candidates agree on: + +- `__own_no_drops`: the callee destroys nothing its caller can reach. That means no overwrite of a + field or element whose root it did not make, no global assignment, no removal from an array it + did not make, no `delete`, and no call that may drop. It is a least fixpoint. A constructor + filling its own `this` is not a drop. +- `__own_result_borrows = K`: every heap value the function returns is parameter `K`'s. That is + seen through views, `!ts.opaque` casts, `ts.Unbox`, field and element reads, the object a method + is called on, and results that borrow. `null` agrees with any. +- `__own_params`: the parameters the body keeps. A kept parameter is stored into a field, element + or global, pushed, or passed to a kept parameter. + +**Resolving a call.** The candidates are found as follows: + +- a direct call has one candidate; +- a virtual call reaches every entry at its index under its method name in any class `..vtbl`. + This is its family, and every member must be private and defined here; +- a call through the vtable's first slot is only built to reach a generated `..instanceOf`, + which drops nothing (§15.7 has the bug this found); +- anything else (a closure, a function read from a field, an interface) is unknown. + +**The closed world.** `__own_no_drops` goes one way: an unknown call may drop, which can only add +an error. The other two facts are relied on by the callee's body. A caller that does not know them +borrows the argument and releases it, or releases a result it does not own, a double free either +way. So a function has them only if all of these hold: + +- it is private; +- every use of its symbol is a direct call, a class vtable entry, or a method reference used only + as a callee; +- every family it is in agrees. + +A function that loses them keeps rc's convention. The error in its body gets a note saying why: +`'firstOf' could return a borrow or keep a parameter, but it is used other than by a call` (or +`it can be called from another module`, `an override in its class family disagrees`, +`an override may be defined in another module`). The facts are recomputed with the calls pinned +until nothing changes, so a method returning another's borrow, and a function forwarding a +parameter to one that keeps it, get the fact too. + +`mlir::SymbolTable::getSymbolUses(module)` does not look inside the module, which is a symbol +table. The first version saw no uses, so every function was closed. `own_err_borrowed_result_escapes` +compiled until the walk used the module's body region. + +### 15.2 What the inference accepts + +- **Calls that drop.** A call drops a borrow only if its callee may drop (`__own_no_drops` absent). + `` `${this.color} area=${this.area()}` `` compiles again. Phase 3 skipped a call's own arguments + ("read before it runs"). That was unsound: `f(h.c)` compiled where `f` resets `h.c` through a + global and then reads its parameter. Now a call given a borrow uses it for as long as the callee + runs. Two things are exempt, because they are read as the call starts: the function value an + indirect call goes through, and the object a field-held method is bound to (`o.m()`). +- **Results that borrow.** A call with `__own_result_borrows` is a borrow read like a field read. + Its places are a wildcard, so any overwrite of any field or element, and any removal, drops it. + Its roots are the argument's. Its temporary release and a consuming `let`'s releases are erased. + In the callee, the retain rc made for the caller goes, and the return is a use, not an escape. +- **Kept parameters.** In the callee, the take is a move out of the parameter's slot. It must be + the last use, not loop, and dominate every return. The callee has no release for the parameter, + so `if (f) h.c = c` is an error. In the caller, the call is a taker of each kept argument, and rc's + retain for it is in the callee. It can move a fresh value, an owning `let` or a kept parameter. A + parameter, a global or a field it does not own is + `argument 'x' is given to 'keep', which keeps it, but -mm=own cannot move it here`. +- **A thrown value read by a catch's copy thunk** (`.eh.copy.*`) is a move. A throw hands the + thrower's reference to the exception object, and nothing gives it back (rc leaks it the same + way). A TypeScript rethrow is a new throw, so each exception is copied into one catch. + `___unbox`'s failure path throws a string, so every unboxing needs this. + +A bug the positive tests found: a move whose acquisition is the call itself was "erased with its +retain". The call to `forward(h, a)` vanished, and the program read uninitialised memory. + +### 15.3 Measured + +AOT, `measure.ps1`, at `-O3` and `-O1` (identical within 1%), in MB: + +| program | gc | rc | none | own | +| --- | --- | --- | --- | --- | +| `own_param_kept` | 6.5 | 4.8 | 1657.7 | 4.8 | +| `own_getter_borrow` | 6.5 | 12.6 | 1653.3 | 4.8 | +| `own_any_box` | 6.5 | 4.8 | 558.9 | 4.8 | +| `own_call_no_drops` | 6.5 | 4.8 | 1100.7 | 4.8 | + +rc climbs in `own_getter_borrow`: it never releases a getter's result used as a temporary +(`h.cc.x`: the `ts.CallInternal` result has no `ts.Release`). Own has no reference to give back. +§14.5's `const a: any = new C(i)` loop reads 4.6 MB under all four models, at both levels. `none` +does not grow, so LLVM removes the allocation and that program shows nothing about reclamation. + +### 15.4 `any` + +The boxing cast now carries its birth reference (`__owned_result` + `ts.Retain`), as a printed +number does (`markFreshStringOwned` became `markFreshBlockOwned`). That is rc's §14.5 leak fix: +a box held by a folded `const`, or passed straight to a call, was never released. Under own it +makes the box a fresh value with one owner. An `any` cast to `any` allocates nothing and is not +marked. The fix broke rc's catch copy thunks at first: the thunk's plain store into the catch slot +let the box's new reference go back at the end of the thunk, so the catch read a freed box (10 +suite failures). The store now takes it over. The catch slot is runtime memory. + +`a` is `___unbox(a)`. With `___unbox`'s borrowed result (through `ts.Unbox`) and its +drop-free `.instanceOf` call, the payload is borrowed for as long as the box lives. + +### 15.5 Teeth + +- With the releases a kept argument's move erases kept, `own_param_kept` fails under JIT and AOT. +- With a borrowed result's temporary release kept, `own_getter_borrow` and `own_any_box` fail. +- Keeping the callee's retain for a borrowed return makes the same two fail at compile time: the + retain reads as an escape. So it proves nothing about the runtime. At runtime it would be a leak, + not a double free. +- `own_call_no_drops` erases nothing. Its teeth are the negatives. + +### 15.6 Tests and the corpus + +- **Positives:** `own_call_no_drops`, `own_getter_borrow` (getter, method, free function, a method + forwarding another's borrow, a field-or-`null` result, a returned parameter), `own_param_kept` + (field, forwarding, method into `this`, push, constructor parameter property), `own_any_box`. +- **Negatives:** + - calls that drop: `own_err_call_drops_indirectly`, `own_err_call_given_borrow`, + `own_err_call_this_borrow`, `own_err_exported_virtual_call`; + - borrowed results: `own_err_borrowed_result_stored`, `own_err_borrowed_result_outlives`, + `own_err_borrowed_result_let_outlives`, `own_err_borrowed_result_escapes`, + `own_err_borrowed_result_family`; + - kept parameters: `own_err_param_kept_used`, `own_err_param_kept_some_paths`, + `own_err_param_kept_loop`, `own_err_param_kept_not_owned`; + - `any`: `own_err_any_unboxed_outlives`. + +Corpus (`test/tester/tests/*.ts` under `-mm=own --no-default-lib`): 272 of 564 compiled before, +303 after, and none stopped. "Takes a second reference" fell from 189 files to 149. About half of +the new ones throw or catch (the copy thunks); several more cast out of `any`. Release suite: 3226 +of 3226. + +### 15.7 Known limits (the input to phase 5 and later) + +- Export and import of the facts (§3.2). An exported function, or a method of an exported class, + gets no owned or borrowed facts, and a virtual call on one gets no `__own_no_drops`. That is sound + and restrictive for `-shared` modules. +- A parameter kept on some paths only (needs drop elaboration: a release on the others). +- A borrowed result's places are a wildcard, so any field overwrite between the call and the use + drops it, even of an unrelated object. +- Interface calls (`ts.InterfaceSymbolRef`) are unresolved: no facts, and they drop. +- A virtual family is matched by method name and index, so an unrelated class's method at the + same index can only take facts away. +- The three corpus files §14.4 lost (`export_class_abstract*`) stay rejected. Their class is + exported, so the virtual call to `area` has candidates this module cannot see, and it may drop + (`own_err_exported_virtual_call` is the same shape). The error does not yet say why: the note + on lost facts is attached to escapes and second references, not to drops. +- rc: a getter's result used as a temporary is never released (§15.3). +- Every model: `anyValue` segfaults when `B` implements an interface. + `mlirGenInstanceOfOpaque` calls vtable slot 0 as `..instanceOf`, but such a class keeps the + interface's vtable there (`B..vtbl = {I, .instanceOf, ...}`). Fixing it changes the vtable + layout, so it is left for its own PR. +- From §14.6: a read reached through an interface, borrow chains through assigned borrowers, + moves after the last use of every borrower, and a temporary taken by a `let` and consumed + elsewhere. diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index b50ae592c..24bc9ced6 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -336,8 +336,11 @@ class OwnershipSignaturePass : public mlir::PassWrapper(def); From e90d00a2a9110886b91d000cab53d704ae6483b0 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Tue, 29 Sep 2026 19:27:30 +0100 Subject: [PATCH 6/6] -mm=own phase 4: build with GCC GCC rejects a default argument that value-initializes a nested struct with default member initializers inside the enclosing class (walkBorrowed's Keeping). The struct is a plain aggregate now, and the default is spelled out. Co-Authored-By: Claude Opus 5.5 --- tslang/lib/TypeScript/OwnershipInferencePass.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index 1d89bef1a..47ad7bb1d 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -653,14 +653,16 @@ class OwnershipInferencePass : public mlir::PassWrapper *bookkeeping = nullptr; - bool returns = false; + // no default member initializers: GCC rejects them in a default argument of the class + // that encloses the struct + llvm::SmallVectorImpl *bookkeeping; + bool returns; }; mlir::Operation *walkBorrowed( mlir::Value start, llvm::ArrayRef kills, llvm::SmallVectorImpl &uses, llvm::function_ref)> isBorrower = nullptr, - Keeping keeping = {}) + Keeping keeping = Keeping{nullptr, false}) { struct Pending {