From 394c8b8d24430d9b37c30deebdc6627cc301da0f Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 3 Oct 2026 09:48:30 +0100 Subject: [PATCH 01/21] -mm=own: spec section 23, Shared (design) Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 148 +++++++++++++++++- 1 file changed, 146 insertions(+), 2 deletions(-) diff --git a/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md b/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md index 4e13ead95..a5a0b44f8 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 @@ -7,8 +7,9 @@ amendments folded in. Phase 0 merged as #399 (results §11); phase 1 signatures and `any`) merged as #406-#410 (results §15); phase 5a (closures that do not escape) merged as #436 (results §16); phase 5b (closures that escape) merged as #437 (results §17); phase 7a (generators, async) merged as #439 (results §18); phase 7b (generators that borrow) merged as -#441 (results §19); phase 6 (the corpus report) on branch `own-phase-6`, after #444 (results -§20). Plans in `docs/superpowers/plans/`. +#441 (results §19); phase 6 (the corpus report) merged as #444, #445 and #449 (results +§20, §21); strings as values merged as #452 (§22); phase 8 (`Shared`) designed in §23. Plans +in `docs/superpowers/plans/`. ## 1. Purpose @@ -2073,3 +2074,146 @@ positives under own, rc, none and gc, AOT and JIT) passes 48 of 48. After the fi answer). Both disagreements are safe: where the pass sees a tag there is no copy, and the error stays; where only the lowering sees one, `ts.StringCopy` fails to match (`notifyMatchFailure`), and the build fails loudly. + +## 23. `Shared` (design, 2026-10-03) + +Phase 8 (§7). §2.7 deferred it: an explicit, counted generic for graphs that a tree of single +owners cannot express (a parent link, a node in two lists, a DAG), reusing rc's retain and release +routines for that type only. Everything else stays single-owner and uncounted. + +Decisions taken in the design conversation: + +| Question | Decision | +| --- | --- | +| Form | A wrapper: a handle to a block that holds one `T` and the count. Not a marker on `T` itself | +| Where it is declared | Built into the compiler, so `--no-default-lib` programs and every model have it | +| Members | `.value` read and write, `===`/`!==` on handles, `Shared.count(s)` | +| Handles under own | Own steps aside: every rc retain and release of a handle stays and is counted | +| Cycles | Leak, as under rc, and documented; `WeakRef` under own is a later phase | + +### 23.1 The type and its members + +```ts +class Node { + v = 0; + next: Shared | null = null; +} + +const a = new Shared(new Node()); +const b = a; // a second handle: the count is 2 +a.value.v = 1; +print(b.value.v); // 1 +b.value = new Node(); // the old Node is released, the new one moves in +print(Shared.count(a), a === b); +``` + +- **The type.** `Shared` is resolved by name in `MLIRGenTypes` beside `Reference` and + `BoxedObject`, to a new `!ts.shared`. A program's own declaration of a type named `Shared` + is found first and wins, as for every embedded name. +- **At run time** a handle is a pointer to a heap block: the header word is the count, and the + payload is one `T` in its storage type. +- **`new Shared(x)`** is `ts.SharedNew`: it allocates the block and stores `x` into it, which is a + store into a container (§2.3): `x` moves in. +- **`s.value`** is `ts.SharedValue`, a read out of a container: a borrow of the `T` (§23.3). +- **`s.value = x`** is `ts.SharedStore`: `x` moves in, and the old `T` is released, as a field + store does. +- **`a === b` and `a !== b`** compare the pointers. +- **`Shared.count(s)`** reads the header: the number of handles under rc and own, `-1` under gc + and none, which keep no count. +- **A program means the same under every model.** gc and none get a wrapper that is never + counted. rc counts it as it counts every block. Own counts this type and no other. +- **Other uses of the type.** A handle can be a field, an element, a union member (`Shared | + null` is one pointer), a capture, a parameter, a result, or boxed into `any`. It goes into a + module's `__decls` as `Shared`, so a module that imports it sees the same type. + +### 23.2 Handles under own: the analysis steps aside + +- **MLIRGen emits rc's ops for handles under own too.** It emits the same `ts.Retain`, + `ts.RetainSlot`, releases and `__owned_consumed` declarations for a `!ts.shared` value under rc + and own, because both track ownership (§3.1). That is unchanged. +- **The inference pass leaves every retain and release of a handle in place.** A handle is a value of + type `!ts.shared`, or a tag-free union or an optional of one with `null` or `undefined` + (one pointer, as in §22.1). The pass never erases such a retain or release, and never reports one. A handle's count is therefore exactly rc's, which rc's own + suite tests. +- **Lowering.** Under own, a surviving retain of a `!ts.shared` value no longer reports "left a + retain behind"; it lowers to `__tslang_inc_ref`, as under rc. +- **The release routine** of a `!ts.shared` value is the same in every model that tracks + ownership: `__tslang_dec_ref`, and at zero, release the payload `T` and free the block. +- **The `T` is released by its model's routine.** Under own, that is a single-owner drop: free + unless immortal, after releasing the `T`'s own fields. +- **Inside a container.** Everything outside the handle is ordinary own analysis. A handle stored + in a field, element, union or closure is a value the container owns, and the container's drop + releases it through the routine above. +- **The type descriptor.** An `any` box, a union or an interface releases what it holds through the + type's descriptor, so `!ts.shared` gets a descriptor whose release slot is its routine. +- **Not in this phase.** Eliding the count for a handle own could prove moved or borrowed. That + would need a birth convention for handles that differs from rc's (rc counts the birth with a + retain own would erase), so it is left for a measured later step. + +### 23.3 Reading through a handle + +Under own, no other path reaches a block while its owner holds it. That is why a borrow out of a +container ends only at the container's own release or overwrite. A `Shared` breaks that: two +handles can reach one block. + +```ts +const n = a.value; // a borrow of the Node +b.value = new Node(); // if b is a, this frees the Node n borrows +print(n.v); // rejected +``` + +- **The rule.** A borrow read through a handle, at any depth (`s.value`, `s.value.name`, + `s.value.items[0]`), ends at the first of: + - the handle's own release or overwrite (the existing rule); + - a write into any place reached through any `Shared`: `x.value = …`, `x.value.f = …`, an + element store, `push` or `pop` on an array reached through one; + - a call that is not known to drop nothing (`__own_no_drops`, §15). +- **A handle copied out is a counted retain, not a borrow.** `cur = cur.value.next` gives `cur` a + handle of its own, which no write ends. That is how a graph is walked, so walking is not + restricted. +- **The error** is the existing "borrows a field but is used here after it may be released or + overwritten". +- **Conservative by design.** It does not check whether two handles could be the same: any write + through any handle, and any call that may drop, ends every such borrow. The alternative is a + runtime borrow flag, Rust's `RefCell`, which costs at run time; it is not taken. +- **A string read through a handle** is a borrow too. Where one of rc's retains survives, §22 + makes it a copy, as for any field. + +### 23.4 Cycles + +`a.value.next = b; b.value.next = a` leaks both blocks, as under rc. The test +`own_shared_cycle` shows that it compiles and runs, and §23.7 (the results) records it. + +### 23.5 The verifier + +`--verify-ownership` (§9.18 of the rc document) runs before the own passes. It checks that every +slot that takes a reference gives it back on every path. Handles keep rc's pairing, so it needs no +change. It runs on every `own_shared_*` test, under own and rc. + +### 23.6 Tests and measures + +- **Positive**, under own, rc, none and gc, AOT and JIT: + - `own_shared_basic`: two handles, `.value` read and write, `===`. + - `own_shared_graph`: a doubly linked list and a node with a parent link, walked with + `cur = cur.value.next`. + - `own_shared_containers`: one node in two arrays, a `Shared | null` field, a closure + capturing a handle, a handle boxed into `any` and back, a handle returned from a function. + - `own_shared_cycle`: a cycle that is never freed, which still runs. + + Each frees every handle but one, then allocates over the freed memory (the churn pattern) + before reading through the one left. An early free then reads reused memory. +- **Under own and rc only:** `own_shared_count`, which asserts `Shared.count` after each copy and + drop. +- **Negative:** + - `own_err_shared_borrow_write`: the §23.3 example; + - `own_err_shared_borrow_call`: a borrow through a handle used after a call that may drop; + - `own_err_shared_moved_in`: a class value given to `new Shared(x)` and then used, which is a + use after move. +- **Teeth:** + - with the inference pass erasing a handle's retains as for any value, the positives fail; + - with §23.3's rule off, its negatives compile. +- **Verifier:** `--verify-ownership` is quiet on every `own_shared_*` file. +- **Measured:** a loop that builds and drops a small graph of handles 200,000 times stays flat + under own and rc (`measure.ps1`), and grows under none. +- **Unchanged:** no corpus file uses `Shared`, so the corpus (453 / 593 plain, 451 with `--opt`) + must be unchanged, and its LLVM IR identical for every file that compiles, as in §22.7. From 6cf1ed17b486a6060d93e7121c73b38567155578 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 3 Oct 2026 10:07:03 +0100 Subject: [PATCH 02/21] -mm=own: the Shared implementation plan (spec section 23) Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-03-own-shared.md | 1264 +++++++++++++++++ 1 file changed, 1264 insertions(+) create mode 100644 tslang/docs/superpowers/plans/2026-10-03-own-shared.md diff --git a/tslang/docs/superpowers/plans/2026-10-03-own-shared.md b/tslang/docs/superpowers/plans/2026-10-03-own-shared.md new file mode 100644 index 000000000..2704bf0a6 --- /dev/null +++ b/tslang/docs/superpowers/plans/2026-10-03-own-shared.md @@ -0,0 +1,1264 @@ +# `Shared` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A built-in `Shared`, a counted handle to a block that holds one `T`, which `-mm=own` +leaves to rc's counting while everything else stays single-owner (own phase 8). + +**Architecture:** A new dialect type `!ts.shared` and three ops (`ts.SharedNew`, +`ts.SharedValueRef`, `ts.SharedCount`). `s.value` is a `ts.Load` of the `ts.SharedValueRef` place +and `s.value = x` is the field-store pattern on it, so MLIRGen's existing ownership ops and own's +existing place-read machinery apply. Retains and releases of a handle lower to rc's +`__tslang_inc_ref`/`__tslang_dec_ref` in every model that tracks ownership. The own inference +pass leaves them alone, and a borrow read through a handle is ended by any write that could reach +it through another handle and by any call that may drop. + +**Tech Stack:** C++17, MLIR/LLVM (TableGen ODS), the tslang test-runner, CMake/ctest, PowerShell +(`measure.ps1`), WSL GCC for Linux. + +**Spec:** `tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md`, §23 (approved), +with §2, §15 and §22 as background. + +## Global Constraints + +- Branch `own-shared` (spec §23 is commit 394c8b8d, on main 8b9d6316). +- **Form (§23):** a wrapper; the count lives in the handle's block header and the block's payload + is one `T`. `Shared` is built in (it works with `--no-default-lib`). A program's own type named + `Shared` is found first and wins. +- **Members (§23.1):** `new Shared(x)`, `s.value` read, `s.value = x`, `===`/`!==` on handles, and + `Shared.count(s)` (the count under rc and own, `-1` under gc and none). +- **Under own (§23.2):** every retain and release of a handle stays and is counted. A handle is a + value of type `!ts.shared`, or a tag-free union or an optional of one with `null` or + `undefined`. The pass never erases or reports one. +- **The release of a handle (§23.2):** `__tslang_dec_ref`, and at zero, release the payload `T` with + `T`'s routine and free the block, in every model that tracks ownership (rc and own). +- **Reading through a handle (§23.3):** such a borrow ends at a write into a place reached through + any `Shared`, or at a call that is not `__own_no_drops`. A handle copied out is a counted retain, + not a borrow. The error is the existing "borrows a field but is used here after it may be + released or overwritten". +- **Cycles (§23.4):** they leak; there is no `WeakRef` in this phase. +- **Never branch on the memory model in MLIRGen** (§4.3). Model differences go in the lowering and + in the own passes. +- **Unchanged where `Shared` is not used:** the corpus (453 / 593 plain, 451 / 593 `--opt`) and its + LLVM IR for every file that compiles. +- **Line endings:** sources are CRLF. Do not edit them with Git Bash `sed -i`, which rewrites them + to LF. Use the Edit tool, or Python with `newline=''`. +- **Build:** `cmake --build __build/tslang/windows-msbuild-2026-release --config Release --target tslang --parallel 8`. + - Editing a `.td` file regenerates the dialect. If MSVC fails with C1060, rebuild with + `--parallel 3`. + - Run tests with `ctest -C Release` (without `-C`, every test is "Not Run") and `--timeout 300`. +- **Commits** are GPG-signed. Never pass `--no-gpg-sign`. If signing times out, stage everything and + hand the commit to the user with the message in a file. End commit messages with + `Co-Authored-By: Claude Opus 5.5 `. +- **Test rules:** never read a catch variable's value in a test. Printing a class instance needs its + own `toString`, so print numbers and strings only. Integer literals are `s32`. + +## Review Focus + +1. **`Shared | null` in a condition.** `while (cur)`, `if (h.s)` and `cur !== null` must narrow + to the handle, and `null` must compare. Expected: they behave as for a class. The union is one + pointer with no tag. Task 2's `own_shared_graph` walks such a list. +2. **A handle given to a function and returned from one.** The count across a call, and rc's + owned-return consumption of `return new Shared(...)`. Expected: no extra and no missing + reference. Task 2's `own_shared_count` asserts `Shared.count` after `make()` and `keepOne()`. +3. **A payload that is not a class:** `Shared` and `Shared`. Expected: the payload + is released by its own routine (a string), or by nothing (a number). Task 1's `own_shared_basic` + holds a string. +4. **A handle reassigned to the block it already holds** (`b = a` when `b` is `a`). Expected: the + count goes up before it goes down, so the block is not freed in between. Task 2's + `own_shared_count` does it and asserts the count. +5. **A handle created and dropped many times in a loop.** Expected: the count returns to where it + was and memory stays flat. Task 2's `own_shared_count` does it 1000 times, and Task 5 measures + 200,000. + +--- + +## File map + +| File | Change | Task | +| --- | --- | --- | +| `include/TypeScript/TypeScriptTypes.td` | `TypeScript_Shared` type | 1 | +| `include/TypeScript/TypeScriptOps.td` | `SharedNew`, `SharedValueRef`, `SharedCount` | 1 | +| `lib/TypeScript/MLIRGenTypes.cpp` | `Shared` as an embedded name, `Shared` | 1 | +| `lib/TypeScript/MLIRGenExpressions.cpp` | `new Shared(x)`, `Shared.count(s)` | 1, 2 | +| `lib/TypeScript/MLIRGenAccessCall.cpp` | `s.value` | 1 | +| `include/TypeScript/MLIRLogic/MLIRTypeCore.h` | `isNullableTypeNoUnion`, `isNullableOrOptionalType` | 1 | +| `include/TypeScript/MLIRLogic/MLIRTypeIterator.h` | recurse into `Shared` | 1 | +| `include/TypeScript/MLIRLogic/MLIRPrinter.h` | print `Shared` | 1 | +| `lib/TypeScript/LowerToLLVM.cpp` | converter, three lowerings, retain gates, descriptor retain | 1, 3, 4 | +| `lib/TypeScript/LowerToAffineLoops.cpp` | legal ops | 1 | +| `include/TypeScript/MLIRLogic/MLIRTypeHelper.h` | `ownsHeapMemory`, `isSharedHandleType` | 2 | +| `include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h` | counted routines for a handle | 2 | +| `lib/TypeScript/MLIRGenImpl.h` | `markFreshBlockOwned`, `isOwningSlot` for the payload | 2 | +| `lib/TypeScript/OwnershipFacts.h` | `isHandle`, `isPlace` | 3 | +| `lib/TypeScript/OwnershipInferencePass.cpp` | step aside; reads through a handle | 3 | +| `lib/TypeScript/OwnershipSignaturePass.cpp` | no facts on handles; drops | 3 | +| `include/TypeScript/MLIRLogic/TypeOfOpHelper.h` | a `typeof` name, so it gets a descriptor | 4 | +| `lib/TypeScript/MLIRGenCast.cpp` | `any` to `Shared` | 4 | +| `test/tester/own/own_shared_*.ts`, `own_err_shared_*.ts` | tests | 1-4 | +| `test/tester/CMakeLists.txt` | registration | 1-4 | +| spec §23 | amendments (Task 1), results §23.7 (Task 5) | 1, 5 | + +All paths below are relative to `tslang/`. Line numbers were read on 8b9d6316; find the code by the +names given, because lines move. + +--- + +### Task 1: the type, the ops, and MLIRGen (no counting yet) + +**Files:** +- Modify: `include/TypeScript/TypeScriptTypes.td`, `include/TypeScript/TypeScriptOps.td` +- Modify: `lib/TypeScript/MLIRGenTypes.cpp`, `lib/TypeScript/MLIRGenExpressions.cpp`, + `lib/TypeScript/MLIRGenAccessCall.cpp` +- Modify: `include/TypeScript/MLIRLogic/MLIRTypeCore.h`, `MLIRTypeIterator.h`, `MLIRPrinter.h` +- Modify: `lib/TypeScript/LowerToLLVM.cpp`, `lib/TypeScript/LowerToAffineLoops.cpp` +- Create: `test/tester/own/own_shared_basic.ts` +- Modify: `test/tester/CMakeLists.txt` +- Modify: the spec, §23.1 and §23.6 (Step 9) + +**Interfaces:** +- Produces: + - `mlir_ts::SharedType` with `getElementType()`, built by `mlir_ts::SharedType::get(elementType)`. + - `mlir_ts::SharedNewOp::create(builder, loc, SharedType, mlir::Value value)` with `getValue()`. + - `mlir_ts::SharedValueRefOp(loc, mlir_ts::RefType resultType, mlir::Value shared)` with + `getShared()` and `getResult()`. + - `mlir_ts::SharedCountOp(loc, NumberType, mlir::Value shared)`. + - The CMake variable `shared_models` and the foreach that registers `own_shared_*` tests per model. + +- [ ] **Step 1: Write the test.** `test/tester/own/own_shared_basic.ts`: + +```ts +// Shared (spec 23): two handles to one block, `.value` read and write, `===` on handles, and a +// payload that is a string. Each handle dropped is followed by reusing freed memory before the +// other is read. +class Node { + v = 0; + name = ""; +} + +function churn() { + let keep: string[] = []; + for (let i = 0; i < 1000; i++) keep.push("k" + i); + return keep.length; +} + +function main() { + let a = new Shared(new Node()); + a.value.name = "first" + 1; + const b = a; + assert(a === b, "two handles to one block are equal"); + a.value.v = 5; + assert(b.value.v == 5, "a write through one handle is seen through the other"); + + const c = new Shared(new Node()); + assert(a !== c, "two blocks are not equal"); + + a = c; + assert(churn() == 1000); + assert(b.value.v == 5 && b.value.name == "first1", "the other handle keeps the block"); + + const d = b; + d.value = new Node(); + d.value.v = 7; + assert(b.value.v == 7, "the value replaced through one handle is seen through the other"); + + const t = new Shared("text" + 2); + const u = t; + assert(u.value == "text2"); + t.value = "other" + 3; + assert(churn() == 1000); + assert(u.value == "other3"); + + print("done."); +} +``` + +- [ ] **Step 2: Register it under gc and none, and watch it fail.** + - In `test/tester/CMakeLists.txt`, after the `strings_mm` foreach (search + `foreach(strings_mm rc none gc)`), add: + +```cmake +# Shared (spec 23): counted handles. These are not in the own_test list, whose +# test-own-no-counting check forbids __tslang_inc_ref: a handle is counted under own by design. +set(shared_models gc none) +foreach(shared_mm ${shared_models}) + foreach(shared_test own_shared_basic) + tslang_add_test(NAME test-jit-${shared_mm}-${shared_test} COMMAND test-runner -jit -mm=${shared_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${shared_test}.ts") + tslang_add_test(NAME test-compile-${shared_mm}-${shared_test} COMMAND test-runner -mm=${shared_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${shared_test}.ts") + endforeach() +endforeach() +``` + + - Build, then run `ctest -C Release -R own_shared -j 8 --timeout 300`. + - Expected: 4 tests, all FAIL, with "can't resolve name" or similar for `Shared`. + +- [ ] **Step 3: The type.** In `include/TypeScript/TypeScriptTypes.td`, after `def TypeScript_Ref` + (the block that ends with its `builders`), add: + +```tablegen +// Shared (own spec 23): a counted handle to a block whose payload is one T. +def TypeScript_Shared : TypeScript_Type<"Shared"> { + let mnemonic = "shared"; + + let description = [{ + Syntax: + + ``` + shared-type ::= `shared` `<` type `>` + ``` + + Examples: + + ```mlir + shared> + ``` + }]; + let parameters = (ins "Type":$elementType); + + let assemblyFormat = "`<` params `>`"; + + let builders = [ + TypeBuilderWithInferredContext<(ins "Type":$elementType), [{ + return Base::get(elementType.getContext(), elementType); + }]> + ]; +} +``` + +- [ ] **Step 4: The ops.** In `include/TypeScript/TypeScriptOps.td`, after `def TypeScript_StringCopyOp`, + add: + +```tablegen +// Shared (own spec 23). `new Shared(x)`: a block of one T, which `value` moves into. +def TypeScript_SharedNewOp : TypeScript_Op<"SharedNew"> { + let arguments = (ins AnyType:$value); + let results = (outs Res:$result); +} + +// The place `s.value`: a reference to the handle's payload. It is read with ts.Load and written +// with ts.Store, as a field is. +def TypeScript_SharedValueRefOp : TypeScript_Op<"SharedValueRef"> { + let arguments = (ins TypeScript_Shared:$shared); + let results = (outs TypeScript_Ref:$result); +} + +// `Shared.count(s)`: the number of handles under rc and own, -1 under gc and none. +def TypeScript_SharedCountOp : TypeScript_Op<"SharedCount"> { + let arguments = (ins TypeScript_Shared:$shared); + let results = (outs TypeScript_Number:$result); +} +``` + + Build with `--parallel 3`. Expected: it compiles. If `TypeScript_Shared` cannot be used as an + operand constraint, wrap it the way `TypeScript_AnyClassOrValueRef` is + (`TypeScriptTypes.td`, `AnyTypeOf<[...]>`): `def TypeScript_SharedLike : AnyTypeOf<[TypeScript_Shared]>;` + +- [ ] **Step 5: MLIRGen.** Each change is small. + - **The embedded name** (`lib/TypeScript/MLIRGenTypes.cpp`): + - Add `{"Shared", true}` to the static maps in `isEmbededTypeWithBuiltins` and + `isEmbededTypeWithNoBuiltins`, next to `{"BoxedObject", true}`. + - In both `getEmbeddedTypeWithParamBuiltins` and `getEmbeddedTypeWithParamNoBuiltins`, add + `Shared` to the `EmbeddedType` enum, add `.Case("Shared", EmbeddedType::Shared)` to the + `StringSwitch`, and add this case to the `switch (kind)`: + +```cpp + case EmbeddedType::Shared: + return mlir_ts::SharedType::get(type); +``` + + - **`new Shared(x)`** (`lib/TypeScript/MLIRGenExpressions.cpp`, `mlirGen(NewExpression ...)`): + in the `if (result.failed())` / `typeExpression == SyntaxKind::Identifier` fallback, before + `findEmbeddedType`, add: + +```cpp + if (name == "Shared") + { + return mlirGenNewShared(location, newExpression, genContext); + } +``` + + Then add the method to `MLIRGenImpl` (declare it in `MLIRGenImpl.h` beside the other + `mlirGen(NewExpression ...)` helpers; define it in `MLIRGenExpressions.cpp`): + +```cpp + // `new Shared(x)` (own spec 23): T is the type argument, else the receiver's, else x's. + ValueOrLogicalResult MLIRGenImpl::mlirGenNewShared(mlir::Location location, NewExpression newExpression, + const GenContext &genContext) + { + if (!newExpression->arguments || newExpression->arguments->size() != 1) + { + emitError(location, "new Shared(value) takes one argument"); + return mlir::failure(); + } + + auto result = mlirGen(newExpression->arguments->front(), genContext); + EXIT_IF_FAILED_OR_NO_VALUE(result) + auto value = V(result); + + mlir::Type elementType; + if (newExpression->typeArguments && newExpression->typeArguments->size() == 1) + { + elementType = getType(newExpression->typeArguments->front(), genContext); + } + else if (auto receiver = dyn_cast_or_null(genContext.receiverType)) + { + elementType = receiver.getElementType(); + } + else + { + elementType = mth.wideStorageType(value.getType()); + } + + if (!elementType) + { + return mlir::failure(); + } + + CAST_A(stored, location, elementType, value, genContext); + return V(builder.create(location, mlir_ts::SharedType::get(elementType), stored)); + } +``` + + Use the casting macro this file already uses for "cast a value to a type" (search + `CAST_A(` in `MLIRGenExpressions.cpp`). If the receiver-type field has another name, use the + one `NewClassInstance` reads (search `receiverType` in `mlirGen(NewExpression`). + - **`s.value`** (`lib/TypeScript/MLIRGenAccessCall.cpp`, the `mlir::TypeSwitch` over + `actualType` in `mlirGenPropertyAccessExpressionBaseLogic`): add a case beside + `.Case`: + +```cpp + .Case([&](auto sharedType) -> mlir::Value { + if (name != "value") + { + emitError(location, "Shared has no member '") << name << "'"; + return mlir::Value(); + } + + auto elementType = sharedType.getElementType(); + auto valueRef = builder.create( + location, mlir_ts::RefType::get(elementType), objectValue); + return builder.create(location, elementType, valueRef); + }) +``` + + Use the local names that function already uses for the member name and the object value. + `s.value = x` then needs no change: `mlirGenSaveLogicOneItem` (`MLIRGenImpl.h`) finds the + `LoadOp` and stores through its reference, the `SharedValueRef`. + - **`Shared.count(s)`** (`mlirGen(CallExpression ...)` in `MLIRGenExpressions.cpp`): before the + general call path, add: + +```cpp + // `Shared.count(s)` (own spec 23.1) + if (auto access = callExpression->expression.as(); + callExpression->expression == SyntaxKind::PropertyAccessExpression && + access->expression == SyntaxKind::Identifier && + MLIRHelper::getName(access->expression.as()) == "Shared" && + MLIRHelper::getName(access->name) == "count" && !resolve("Shared", genContext)) + { + if (!callExpression->arguments || callExpression->arguments->size() != 1) + { + emitError(location, "Shared.count(handle) takes one argument"); + return mlir::failure(); + } + + auto result = mlirGen(callExpression->arguments->front(), genContext); + EXIT_IF_FAILED_OR_NO_VALUE(result) + auto handle = V(result); + if (!isa(handle.getType())) + { + emitError(location, "Shared.count takes a Shared"); + return mlir::failure(); + } + + return V(builder.create(location, getNumberType(), handle)); + } +``` + + `!resolve("Shared", genContext)` stands for "no program symbol named `Shared`". Use whatever + lookup the new-expression fallback relies on to find that `mlirGen(identifier)` failed (for + example, a `findDeclaration`/`resolveIdentifier` call; search how `mlirGen(location, name)` + decides "can't resolve name"). +- [ ] **Step 6: Type helpers.** + - **`include/TypeScript/MLIRLogic/MLIRTypeCore.h`:** add `mlir_ts::SharedType` to the lists in + `isNullableTypeNoUnion` and `isNullableOrOptionalType`, beside `ClassType`. This makes `===` + compare pointers and `null` compare and assign. + - **`include/TypeScript/MLIRLogic/MLIRTypeIterator.h`:** add a case beside the `RefType` one that + visits `getElementType()`, so a generic `T` inside `Shared` is found. + - **`include/TypeScript/MLIRLogic/MLIRPrinter.h`, `printType`:** add, beside the `RefType` case: + +```cpp + .template Case([&](auto t) { + out << "Shared<"; + printType(out, t.getElementType()); + out << ">"; + }) +``` + +- [ ] **Step 7: Lowering.** + - **`lib/TypeScript/LowerToLLVM.cpp`, `populateTypeScriptConversionPatterns`:** add beside the + `RefType` conversion: + +```cpp + converter.addConversion([&](mlir_ts::SharedType type) { + return LLVM::LLVMPointerType::get(m.getContext()); + }); +``` + + - **The lowerings**, beside `StringCopyOpLowering`: + +```cpp +// `new Shared(x)` (own spec 23): a heap block with the header every block has (a count under rc +// and own, born 0 like any block), whose payload is the value. +struct SharedNewOpLowering : public TsLlvmPattern +{ + using TsLlvmPattern::TsLlvmPattern; + + LogicalResult matchAndRewrite(mlir_ts::SharedNewOp op, Adaptor transformed, + ConversionPatternRewriter &rewriter) const final + { + LLVMCodeHelper ch(op, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); + auto elementType = cast(op.getType()).getElementType(); + auto handle = ch.MemoryAlloc(elementType, MemoryAllocSet::Zero); + rewriter.create(op->getLoc(), transformed.getValue(), handle); + rewriter.replaceOp(op, ValueRange{handle}); + return success(); + } +}; + +// `s.value`: the payload starts at the handle. +struct SharedValueRefOpLowering : public TsLlvmPattern +{ + using TsLlvmPattern::TsLlvmPattern; + + LogicalResult matchAndRewrite(mlir_ts::SharedValueRefOp op, Adaptor transformed, + ConversionPatternRewriter &rewriter) const final + { + rewriter.replaceOp(op, ValueRange{transformed.getShared()}); + return success(); + } +}; + +// `Shared.count(s)`: the header word under rc and own; gc and none never write it. +struct SharedCountOpLowering : public TsLlvmPattern +{ + using TsLlvmPattern::TsLlvmPattern; + + LogicalResult matchAndRewrite(mlir_ts::SharedCountOp op, Adaptor transformed, + ConversionPatternRewriter &rewriter) const final + { + auto loc = op->getLoc(); + TypeHelper th(rewriter); + LLVMCodeHelper ch(op, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); + auto f64Type = rewriter.getF64Type(); + if (!tsLlvmContext->compileOptions.tracksOwnership()) + { + rewriter.replaceOpWithNewOp(op, f64Type, rewriter.getF64FloatAttr(-1.0)); + return success(); + } + + auto llvmIndexType = ch.getIndexType(); + auto blockPtr = ch.getBlockPtrFromPayloadPtr(loc, transformed.getShared(), llvmIndexType); + auto count = rewriter.create(loc, llvmIndexType, blockPtr); + rewriter.replaceOpWithNewOp(op, f64Type, count); + return success(); + } +}; +``` + + Use the pattern base class, adaptor and helper names that `StringCopyOpLowering` uses in this + file, and the index-type getter `_MemoryAlloc` uses (`LLVMCodeHelperBase.h`). If `number` + lowers to another type than `f64`, use `getTypeConverter()->convertType(op.getType())`. + - Add the three patterns to the `patterns.insert<...>` list beside `StringCopyOpLowering`. + - **`lib/TypeScript/LowerToAffineLoops.cpp`, `AddTsAffineLegalOps`:** add + `mlir_ts::SharedNewOp, mlir_ts::SharedValueRefOp, mlir_ts::SharedCountOp` to the + `addLegalOp<...>` list beside `mlir_ts::StringCopyOp`. +- [ ] **Step 8: Run the test.** + - Build, then run `ctest -C Release -R own_shared -j 8 --timeout 300`. Expected: 4/4 pass. + - Also run `test-runner -mm=rc ` once by hand. It should pass too, but it leaks: rc does + not count handles until Task 2. + - Check the cross-module type: write a scratch module exporting + `export function make(): Shared { return new Shared(1); }`, compile it with + `--emit=mlir` and `--export`, the way the `export_*` corpus files are, and confirm that + `__decls` prints `Shared`. +- [ ] **Step 9: The spec.** In §23.1: + - Replace the bullets for `s.value` and `s.value = x` with one bullet: "**`s.value`** is a + `ts.Load` of the place `ts.SharedValueRef(s)`, and **`s.value = x`** stores into that place + the way a field store does (MLIRGen's retain, `ts.ReleaseSlot` and `ts.Store`), so `x` moves in + and the old `T` is released. One op instead of two lets the place-read and store machinery of + MLIRGen and own apply unchanged." + - In the decisions table and §23.6, rename the graph test's content: "`own_shared_graph`: a DAG + (one item under two parents) and a singly linked list walked with `cur = cur.value.next`". Move + "a doubly linked list and a node with a parent link" to `own_shared_cycle`, since both are + cycles and leak (§23.4). + - Keep CRLF. +- [ ] **Step 10: Commit.** + +```bash +git add include/TypeScript lib/TypeScript test/tester/own/own_shared_basic.ts test/tester/CMakeLists.txt docs/superpowers/specs/2026-09-24-own-memory-model-design.md +git commit -m "-mm=own Shared: the type, its ops and MLIRGen (spec 23.1)" +``` + +--- + +### Task 2: counting a handle (rc) + +**Files:** +- Modify: `include/TypeScript/MLIRLogic/MLIRTypeHelper.h` +- Modify: `include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h` +- Modify: `lib/TypeScript/MLIRGenImpl.h`, `lib/TypeScript/MLIRGenExpressions.cpp` +- Modify: `lib/TypeScript/LowerToLLVM.cpp` (the GC root list) +- Create: `test/tester/own/own_shared_count.ts`, `test/tester/own/own_shared_graph.ts` +- Modify: `test/tester/CMakeLists.txt` + +**Interfaces:** +- Consumes: `SharedType`, `SharedNewOp`, `SharedValueRefOp`, `SharedCountOp`, `shared_models` (Task 1). +- Produces: `static bool MLIRTypeHelper::isSharedHandleType(mlir::Type type)`. It is true for a + `SharedType`, or an `OptionalType` of one, or a `UnionType` whose members are one `SharedType` and + `NullType`/`UndefinedType` only. Tasks 3 and 4 use it. + +- [ ] **Step 1: Write the tests.** `test/tester/own/own_shared_count.ts` (rc and own only, since + gc and none keep no count): + +```ts +// Shared (spec 23.1): Shared.count after each copy and drop - a handle made, copied, given to a +// function that keeps it, returned from one, reassigned to the block it holds, and copied in a loop. +class Node { + v = 0; +} + +function keepOne(s: Shared, into: Shared[]) { + into.push(s); +} + +function dropLast(list: Shared[]) { + list.pop(); +} + +function make() { + return new Shared(new Node()); +} + +function main() { + const a = new Shared(new Node()); + assert(Shared.count(a) == 1, "one handle"); + + let b = a; + assert(Shared.count(a) == 2, "copied"); + + b = a; + assert(Shared.count(a) == 2, "reassigned to the block it holds"); + + const list: Shared[] = []; + keepOne(a, list); + assert(Shared.count(a) == 3, "kept by a callee"); + + b = new Shared(new Node()); + assert(Shared.count(a) == 2 && Shared.count(b) == 1, "one handle moved to another block"); + + dropLast(list); + assert(Shared.count(a) == 1, "the kept one dropped"); + + const m = make(); + assert(Shared.count(m) == 1, "returned from a function"); + + for (let i = 0; i < 1000; i++) { + const t = a; + assert(Shared.count(t) == 2, "copied in a loop"); + } + + assert(Shared.count(a) == 1, "every copy in the loop dropped"); + + print("done."); +} +``` + + `test/tester/own/own_shared_graph.ts`: + +```ts +// Shared (spec 23): a DAG - one item under two parents - and a singly linked list walked with +// `cur = cur.value.next`. Handles are dropped and their memory reused before the rest is read. +class Item { + v = 0; + name = ""; + next: Shared | null = null; +} + +class Parent { + child: Shared | null = null; +} + +function churn() { + let keep: string[] = []; + for (let i = 0; i < 1000; i++) keep.push("k" + i); + return keep.length; +} + +function list(n: number) { + let head: Shared | null = null; + for (let i = 0; i < n; i++) { + const item = new Shared(new Item()); + item.value.v = i; + item.value.next = head; + head = item; + } + + return head; +} + +function main() { + const child = new Shared(new Item()); + child.value.name = "child" + 1; + let p1 = new Parent(); + const p2 = new Parent(); + p1.child = child; + p2.child = child; + p1 = new Parent(); + assert(churn() == 1000); + const c = p2.child; + assert(c !== null && c.value.name == "child1", "the other parent keeps the child"); + + let sum = 0; + let cur = list(10); + while (cur) { + sum += cur.value.v; + cur = cur.value.next; + } + + assert(sum == 45, "the list walked to its end"); + assert(cur === null); + + print("done."); +} +``` + +- [ ] **Step 2: Register them and watch them fail.** In `CMakeLists.txt`: + - change `set(shared_models gc none)` to `set(shared_models gc none rc)`; + - add `own_shared_graph` to the inner `foreach(shared_test ...)` list; + - add after that foreach: + +```cmake +# Shared.count is a count only under rc and own. +foreach(shared_count_mm rc) + tslang_add_test(NAME test-jit-${shared_count_mm}-own_shared_count COMMAND test-runner -jit -mm=${shared_count_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_shared_count.ts") + tslang_add_test(NAME test-compile-${shared_count_mm}-own_shared_count COMMAND test-runner -mm=${shared_count_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_shared_count.ts") +endforeach() +``` + + Build, then run `ctest -C Release -R own_shared -j 8 --timeout 300`. Expected: + - `own_shared_count` fails under rc (the count stays 0: nothing counts a handle yet); + - `own_shared_basic` and `own_shared_graph` may pass under rc, because a leak is invisible; + - gc and none pass. + + If `own_shared_graph` fails to compile under gc or none (a `Shared | null` narrowing), + that is Review Focus 1: fix it in this task. The union must be tag-free and narrow like a class + union; check `isUnionTypeNeedsTag` and the narrowing helpers. +- [ ] **Step 3: The type predicates** (`include/TypeScript/MLIRLogic/MLIRTypeHelper.h`): + - In `ownsHeapMemory(location, type, visiting)`, add `isa(type)` to the first + "owns its own block" test, beside `ClassType`. Do not add `SharedType` to + `getOwnershipFieldTypes`: the block's payload is released by the handle's routine. + - Add: + +```cpp + // A handle (own spec 23.2): Shared, or a tag-free union or an optional of one with null or + // undefined - one pointer whose block is counted in every model that tracks ownership. + static bool isSharedHandleType(mlir::Type type) + { + if (isa(type)) + { + return true; + } + + if (auto optionalType = dyn_cast(type)) + { + return isSharedHandleType(optionalType.getElementType()); + } + + if (auto unionType = dyn_cast(type)) + { + mlir::Type shared; + for (auto member : unionType.getTypes()) + { + if (isa(member)) + { + continue; + } + + if (!isa(member) || (shared && shared != member)) + { + return false; + } + + shared = member; + } + + return !!shared; + } + + return false; + } +``` + +- [ ] **Step 4: The routines** (`include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h`): + - In `buildRetainBody`, add `isa(type)` to the "reference to a block of its + own" condition beside `ClassType`, so it is `emitIncRef` of the loaded pointer. + - In `buildBody`, after the class/object branch, add: + +```cpp + // a handle (own spec 23.2) is counted in every model that tracks ownership: the last one + // releases the payload and frees the block + if (auto sharedType = dyn_cast(type)) + { + auto handle = rewriter.create(loc, ptrTy, slotPtr); + emitIfLastReference(handle, [&]() { + releaseSlot(sharedType.getElementType(), handle); + emitFreeBlock(handle); + }, /*counted=*/true); + return; + } +``` + + - Give `emitIfLastReference` a `bool counted = false` parameter, and choose the test with it: + +```cpp + auto wasLast = compileOptions.memoryModel == MemoryModelOwn && !counted ? emitIsMortal(payloadPtr) + : emitDecRef(payloadPtr); +``` + + `releaseSlot(type, slotPtr)` is the helper that calls a type's release routine on a slot. The + handle points at the payload, which is the `T`'s slot. If the helper has another name, use the + one `releaseFields` calls per field. + - **A tag-free union `Shared | null`** is released and retained through the union branches. + Check with `--emit=llvm -mm=rc` on `own_shared_graph.ts` that the `next` field's release calls + the handle's `tsrel_` routine, not a tag dispatch. If it dispatches on a tag the union does not + have, make the tag-free union of one `SharedType` use the `SharedType` routine (the same way a + tag-free `C | null` uses the class routine; find that branch and add `SharedType` beside + `ClassType`). +- [ ] **Step 5: MLIRGen's ownership ops for a handle** (`lib/TypeScript/MLIRGenImpl.h`): + - In `markFreshBlockOwned`, widen the test to + `isa(value.getType())`. A new + handle then carries its birth reference the way a new string does (§9.25 of the rc document). + - In `mlirGenNewShared` (Task 1), give the value to the block, then mark the new handle: + +```cpp + CAST_A(stored, location, elementType, value, genContext); + // the block owns what it holds, as an array literal owns its elements + mlirGenRetainCaptured(location, mlir::ValueRange{stored}); + auto handle = builder.create(location, mlir_ts::SharedType::get(elementType), stored); + markFreshBlockOwned(location, handle); + return V(handle); +``` + + - **`isOwningSlot`:** add an `isOwnedSharedValueSlot` beside `isOwnedFieldSlot`, and include it + in `isOwningSlot`'s `||` chain: + +```cpp + // `s.value` (own spec 23): the payload is owned by the handle's block, as a field by its object + bool isOwnedSharedValueSlot(mlir::Location location, mlir::Value reference) + { + auto valueRef = reference.getDefiningOp(); + return valueRef && + mth.ownsHeapMemory(location, cast(reference.getType()).getElementType()); + } +``` + + - **`LowerToLLVM.cpp`:** in the GC root type test (the list that names `ClassType` near the + `GC_ENABLE` code in `VariableOpLowering`), add `SharedType` beside `ClassType`. +- [ ] **Step 6: Run the tests.** + - Build, then run `ctest -C Release -R own_shared -j 8 --timeout 300`. Expected: all pass (gc, + none and rc, plus `own_shared_count` under rc). + - Run `test-runner -noopt -mm=rc` and `test-runner -noopt -jit -mm=rc` on all three files. + Expected: they pass. + - Run `ctest -C Release -R "own|rc" -j 8 --timeout 300`. Expected: no new failure (nothing + existing uses `Shared`). +- [ ] **Step 7: Commit.** + +```bash +git add include/TypeScript lib/TypeScript test/tester/own/own_shared_count.ts test/tester/own/own_shared_graph.ts test/tester/CMakeLists.txt +git commit -m "-mm=own Shared: a handle is counted, under rc first (spec 23.2)" +``` + +--- + +### Task 3: own steps aside, and reads through a handle + +**Files:** +- Modify: `lib/TypeScript/OwnershipFacts.h`, `lib/TypeScript/OwnershipInferencePass.cpp`, + `lib/TypeScript/OwnershipSignaturePass.cpp` +- Modify: `lib/TypeScript/LowerToLLVM.cpp` (the retain gates) +- Create: `test/tester/own/own_err_shared_borrow_write.ts`, `own_err_shared_borrow_call.ts`, + `own_err_shared_borrow_push.ts`, `own_err_shared_moved_in.ts` +- Modify: `test/tester/CMakeLists.txt` + +**Interfaces:** +- Consumes: `MLIRTypeHelper::isSharedHandleType` (Task 2), `SharedValueRefOp`, `SharedNewOp` (Task 1). +- Produces: + - `inline bool isHandle(mlir::Value value)` in `OwnershipFacts.h`; + - `inline bool touchesHandle(mlir::Operation *op)`, true for a `Retain`/`Release` of a handle + value, or a `RetainSlot`/`ReleaseSlot` of a slot holding one. + +- [ ] **Step 1: Write the negative tests.** + + `own_err_shared_borrow_write.ts`: + +```ts +// -mm=own rejects (spec 23.3): a borrow read through one handle, used after a write through another +// handle that may reach the same block. +class Node { + v = 0; +} + +function main() { + const a = new Shared(new Node()); + const b = a; + const n = a.value; + b.value = new Node(); + print(n.v); +} +``` + + `own_err_shared_borrow_call.ts`: + +```ts +// -mm=own rejects (spec 23.3): a borrow read through a handle, used after a call that may drop - +// `touch` writes through a handle it reaches from a global. +class Node { + v = 0; +} + +let kept: Shared | null = null; + +function touch() { + const k = kept; + if (k) k.value = new Node(); +} + +function main() { + const a = new Shared(new Node()); + kept = a; + const n = a.value; + touch(); + print(n.v); +} +``` + + `own_err_shared_borrow_push.ts`: + +```ts +// -mm=own rejects (spec 23.3): an element borrowed through one handle, used after a push through +// another, which may move the elements. +class Node { + v = 0; +} + +class Bag { + items: Node[] = []; +} + +function main() { + const a = new Shared(new Bag()); + const b = a; + a.value.items.push(new Node()); + const first = a.value.items[0]; + b.value.items.push(new Node()); + print(first.v); +} +``` + + `own_err_shared_moved_in.ts`: + +```ts +// -mm=own rejects: a class value given to `new Shared(x)` moves into the block, so it cannot be used +// after (spec 23.1, 2.3). +class Node { + v = 0; +} + +function main() { + const n = new Node(); + const s = new Shared(n); + print(n.v, s.value.v); +} +``` + +- [ ] **Step 2: Register them; add own to the positives; watch them fail.** In `CMakeLists.txt`: + - add to `own_error_cases`: + +```cmake + "own_err_shared_borrow_write|but is used here after it may be released or overwritten" + "own_err_shared_borrow_call|but is used here after it may be released or overwritten" + "own_err_shared_borrow_push|but is used here after it may be released or overwritten" + "own_err_shared_moved_in|is used here after its value was moved" +``` + + - change `set(shared_models gc none rc)` to `set(shared_models gc none rc own)`, and + `foreach(shared_count_mm rc)` to `foreach(shared_count_mm rc own)`; + - add the verifier over every positive, under own and rc: + +```cmake +foreach(shared_verify_mm own rc) + foreach(shared_test own_shared_basic own_shared_graph own_shared_count) + add_test(NAME test-${shared_verify_mm}-verify-ownership-${shared_test} + COMMAND $ --emit=mlir-affine --no-default-lib -mm=${shared_verify_mm} --verify-ownership + "${PROJECT_SOURCE_DIR}/test/tester/own/${shared_test}.ts") + set_tests_properties(test-${shared_verify_mm}-verify-ownership-${shared_test} + PROPERTIES FAIL_REGULAR_EXPRESSION "error|Stack dump") + endforeach() +endforeach() +``` + + Build, then run `ctest -C Release -R "own_shared|own-err-own_err_shared|verify-ownership-own_shared" -j 8 --timeout 300`. + Expected: + - the own positives fail, with "takes a second reference" or "left a retain behind"; + - `own_err_shared_moved_in` passes (a taker already moves), and the other three negatives give + their own first errors or compile; + - the rest pass. + + Record each negative's actual first message now. +- [ ] **Step 3: The facts** (`lib/TypeScript/OwnershipFacts.h`): + +```cpp +// A handle (spec 23.2): counted in every model that tracks ownership, and left alone by own. +inline bool isHandle(mlir::Value value) +{ + return value && mlir_ts::MLIRTypeHelper::isSharedHandleType(value.getType()); +} + +inline bool touchesHandle(mlir::Operation *op) +{ + if (mlir::isa(op)) + { + return isHandle(op->getOperand(0)); + } + + if (mlir::isa(op)) + { + auto slotType = mlir::cast(op->getOperand(0).getType()); + return mlir_ts::MLIRTypeHelper::isSharedHandleType(slotType.getElementType()); + } + + return false; +} +``` + + Use the namespace `MLIRTypeHelper` is in, as the file's other uses show. In `isPlace(ref)`, add + `SharedValueRefOp` beside `PropertyRefOp`/`ElementRefOp`: `s.value` is a place. +- [ ] **Step 4: The inference pass steps aside** (`lib/TypeScript/OwnershipInferencePass.cpp`). The + goal is "never erased, never reported" for a handle. The safety net comes first: + - **Never erased.** Immediately before the loop that erases `toErase` in `analyze`, add: + +```cpp + // a handle's retains and releases are rc's, and stay (spec 23.2) + toErase.remove_if([](mlir::Operation *op) { return touchesHandle(op); }); +``` + + - **Never decided.** In the walk that collects `retains` and `candidates`, skip ops for which + `touchesHandle(op)` holds: do not push them into `retains`, and do not add their value to + `candidates`, `drops` or `slotLoadOf` lists. + - In the top-level retain loop, add `if (touchesHandle(op)) { continue; }` as the first statement. + - For a call's kept arguments (the `keepingCalls` loop that calls `reportGivenNotOwned`), skip an + argument with `isHandle(argument)`. + - In `decideClosures`/`decideEscaping`, do not erase or report a capture retain whose value is a + handle (`touchesHandle`). + - **A handle read out of a place is a counted copy, not a borrow (§23.3).** In `placeReadOf`, + return null when the loaded value `isHandle`. + - **A read through a handle (§23.3).** `s.value` is now a place (`isPlace`), so `Load(SharedValueRef)` + is a place read, and `chainOf` walks through it. Make `chainOf` treat a `SharedValueRefOp` link + as a place whose root is the handle, with root kind `NotOwned`. A handle is reachable through + aliases, so it is never "owned". Then: + - `dropsChain`'s call rule already ends a borrow with a not-owned root at any call that is not + `callNoDrops`; + - a `ReleaseSlot` of a `SharedValueRef` place (a store `x.value = …` through any handle) must + match a chain that has a `SharedValueRef` place of the same element type. Add that beside + the position-and-type match `dropsChain` does for a `PropertyRef`; + - a store into a field of a `T` through any handle (`x.value.f = …`) is a `ReleaseSlot` of a + `PropertyRef` of the same position and type, which `dropsChain` already matches by type; + - add `ArrayPushOp`, and `ArrayUnshiftOp` if it exists, to the ops the walk collects into + `drops` beside `ArrayPopOp`, but only when the array operand's `chainOf` reaches a + `SharedValueRef`. It is a drop for an element place of the same element type, as `pop` is. + - `describePlace` should name a `SharedValueRef` place `'.value'` (e.g. `'a.value'`). +- [ ] **Step 5: The signature pass** (`lib/TypeScript/OwnershipSignaturePass.cpp`): + - **No facts on handles:** in `setFacts`, leave out of `__own_params` any parameter whose type + `isSharedHandleType`, and do not set `__own_result_borrows` for a result that is a handle. + - **`keeps()`:** a `SharedNewOp` whose operand is the parameter keeps it, like a store into a + place. + - **`dropsInBody`:** + - a `ReleaseOp` of a handle value, or a `ReleaseSlotOp` of a slot holding one, is a drop. It may + be the last handle, and its payload's release can free anything the payload owns; + - a `ReleaseSlotOp` of a `SharedValueRef` place is a drop. In `reachesOutside`, treat a + `SharedValueRef` as reaching outside: any handle may be shared. +- [ ] **Step 6: The lowering gates** (`lib/TypeScript/LowerToLLVM.cpp`): + - In `RetainOpLowering` and `RetainSlotOpLowering`, compute + `auto counted = MLIRTypeHelper::isSharedHandleType()`. Use the reference's type, + and for a slot, the `RefType`'s element type. Then: + - raise "ownership inference left a retain behind" under own only when `!counted`; + - emit the retain when `isRefCounted() || (tracksOwnership() && counted)`. + - `RetainCellOpLowering` is unchanged, since a cell is not a handle. + - In the two `VariableOpLowering` sites gated on `isRefCounted()` that retain a captured slot's + initial value with `emitRetainSlot`, use the same `|| (tracksOwnership() && counted)` on the + slot's element type. +- [ ] **Step 7: Run the tests.** + - Build, then run + `ctest -C Release -R "own_shared|own-err-own_err_shared|verify-ownership-own_shared" -j 8 --timeout 300`. + Expected: all pass. That is every positive under gc, none, rc and own (AOT and JIT), the count + test under rc and own, the four negatives with their regexes, and the verifier quiet. + - If a negative's first error differs from its regex, keep a regex that names the §23.3 rule or + the move, and say so in the commit message. + - Run `test-runner -noopt -mm=own` and `-noopt -jit -mm=own` on every positive. + - Run the full `ctest -C Release -R own -j 8 --timeout 300`. Expected: every existing own test + still passes (490 before this phase). +- [ ] **Step 8: Teeth.** Commit or back up first. Each switch is temporary. Clear + `test/tester/own/__jit` and the build's `own/__jit` between JIT runs. + - (a) Remove the `toErase.remove_if(...)` line and the `touchesHandle` skips in the retain loop: + the own positives must fail (to compile, or at run time). + - (b) Make `chainOf` treat a `SharedValueRef` root as owned: `own_err_shared_borrow_call` must + compile. + - (c) Drop the `SharedValueRef` match from `dropsChain`: `own_err_shared_borrow_write` must + compile. + - (d) Drop the push rule: `own_err_shared_borrow_push` must compile. If it errors anyway, the + existing rules already end that borrow. Record which, for §23.7. + - Revert all four. +- [ ] **Step 9: Commit.** + +```bash +git add lib/TypeScript test/tester/own/own_err_shared_*.ts test/tester/CMakeLists.txt +git commit -m "-mm=own Shared: own leaves handles to the count; reads through a handle (spec 23.2, 23.3)" +``` + +--- + +### Task 4: handles in containers, `any`, closures, and cycles + +**Files:** +- Modify: `include/TypeScript/MLIRLogic/TypeOfOpHelper.h`, `lib/TypeScript/LowerToLLVM.cpp` + (`TypeDescriptorOpLowering`), `lib/TypeScript/MLIRGenCast.cpp`, and wherever the test shows a gap +- Create: `test/tester/own/own_shared_containers.ts`, `test/tester/own/own_shared_cycle.ts` +- Modify: `test/tester/CMakeLists.txt` + +**Interfaces:** +- Consumes: everything above. + +- [ ] **Step 1: Write the tests.** `own_shared_containers.ts`: + +```ts +// Shared (spec 23) in containers: one node in two arrays, a `Shared | null` field, a closure +// capturing a handle, a handle boxed into `any` and back, and a handle returned from a function. +// Each owner but one is dropped and its memory reused before the last is read. +class Node { + v = 0; + tag = ""; +} + +class Holder { + s: Shared | null = null; +} + +function churn() { + let keep: string[] = []; + for (let i = 0; i < 1000; i++) keep.push("k" + i); + return keep.length; +} + +function make(v: number) { + const s = new Shared(new Node()); + s.value.v = v; + s.value.tag = "t" + v; + return s; +} + +function main() { + const s = make(3); + + let left: Shared[] = [s]; + const right: Shared[] = [s]; + left = []; + assert(churn() == 1000); + assert(right[0].value.v == 3 && right[0].value.tag == "t3", "the other array keeps the node"); + + const h = new Holder(); + h.s = right[0]; + const read = () => s.value.v; + assert(read() == 3, "a closure reads through its handle"); + + const boxed: any = h.s; + const back = >boxed; + assert(back === s, "a handle boxed into any comes back the same"); + assert(back.value.tag == "t3"); + + h.s = null; + assert(churn() == 1000); + assert(read() == 3 && back.value.v == 3, "the closure and the unboxed handle keep the node"); + + print("done."); +} +``` + + `own_shared_cycle.ts`: + +```ts +// Shared (spec 23.4): a doubly linked pair and a child with a link to its parent are cycles, +// which are never freed (as under rc); the program still runs. +class Item { + v = 0; + next: Shared | null = null; + prev: Shared | null = null; +} + +function main() { + for (let i = 0; i < 100; i++) { + const a = new Shared(new Item()); + const b = new Shared(new Item()); + a.value.next = b; + b.value.prev = a; + a.value.v = i; + const p = b.value.prev; + assert(p !== null && p.value.v == i); + + const parent = new Shared(new Item()); + const child = new Shared(new Item()); + parent.value.next = child; + child.value.prev = parent; + } + + print("done."); +} +``` + +- [ ] **Step 2: Register them and watch them fail.** Add `own_shared_containers own_shared_cycle` + to the inner `foreach(shared_test ...)` list and to the `shared_verify_mm` list. Build, then run + `ctest -C Release -R own_shared -j 8 --timeout 300`. Expected: `own_shared_containers` fails in + some models. The `any` box has no type descriptor for a handle yet, and the `>` + cast from `any` is not supported. `own_shared_cycle` may already pass. +- [ ] **Step 3: A descriptor for a handle.** + - **`TypeOfOpHelper.h`, `typeOfAsString`:** return `"object"` for a `SharedType`. That is what + JavaScript's `typeof` would say of a wrapper object. The descriptor is keyed by the type's hash, + so `Shared` and `Shared` still get distinct descriptors. + - **`LowerToLLVM.cpp`, `TypeDescriptorOpLowering`:** under own, the retain routine name is empty + for every type. Keep it for a handle: + +```cpp + auto retainRoutineName = + tsLlvmContext->compileOptions.memoryModel == MemoryModelOwn && + !MLIRTypeHelper::isSharedHandleType(descriptorType) + ? std::string() + : orl.getOrCreateRetainRoutine(descriptorType); +``` + +- [ ] **Step 4: `any` back to `Shared`** (`lib/TypeScript/MLIRGenCast.cpp`). Find where a cast + from `any` to a class type is generated: it checks the box's type tag and unboxes. Make + `SharedType` take the same path as `ClassType`. The tag it compares against is the handle's + descriptor from Step 3. + - If that path compares the `typeof` name only ("object"), an `any` holding a plain object would + also pass the check. Record that in §23.7 as a limit. + - If the class path cannot be reused, stop and report the reason (BLOCKED). +- [ ] **Step 5: Run the tests.** + - Build, then run `ctest -C Release -R "own_shared|verify-ownership-own_shared" -j 8 --timeout 300`. + Expected: all pass under gc, none, rc and own, AOT and JIT. + - Run `-noopt` under own and rc for both files. + - If own rejects `own_shared_containers` with an error that names a non-handle value, read it: + - a rule of §23.3 means the test is wrong; rewrite the shape and say so in the commit; + - anything else is a gap in Task 3's skips. Fix it there, with this test as its proof. +- [ ] **Step 6: Commit.** + +```bash +git add include/TypeScript lib/TypeScript test/tester/own/own_shared_containers.ts test/tester/own/own_shared_cycle.ts test/tester/CMakeLists.txt +git commit -m "-mm=own Shared: handles in containers, any and closures; cycles (spec 23.1, 23.4)" +``` + +--- + +### Task 5: measure, corpus, results, Linux + +**Files:** +- Modify: the spec, a new §23.7 "Results" +- Modify: this plan (tick the boxes) + +- [ ] **Step 1: Measure.** Write `$SCRATCH/shared_loop.ts`: + +```ts +class N { + v = 0; + kids: Shared[] = []; +} + +function main() { + let total = 0; + for (let i = 0; i < 200000; i++) { + const leaf = new Shared(new N()); + const a = new Shared(new N()); + const b = new Shared(new N()); + a.value.kids.push(leaf); + b.value.kids.push(leaf); + leaf.value.v = i; + total += a.value.kids[0].value.v == b.value.kids[0].value.v ? 1 : 0; + } + + print(total); +} +``` + + - Run `pwsh test/tester/tools/measure.ps1 -Source $SCRATCH/shared_loop.ts`. It needs one plain + test-runner run first, so that `compile.bat` exists. + - Run it again at 20000 iterations. + - Expected: own and rc peaks are the same at both counts, within 0.5 MB; none grows; gc stays + bounded. + - Record both rows. +- [ ] **Step 2: Corpus.** Run the corpus scripts (Appendix A) plain and with `OPTS=--opt`, with + this branch's binary. + - Expected: 453 / 593 and 451 / 593, each with the same file set as main + (`join -t $'\t' base.tsv new.tsv | awk -F'\t' '$2 != $3'` prints nothing). + - Make `base.tsv`/`baseopt.tsv` from main's binary first: build main once, or reuse a saved + `tslang-main.exe`, before this branch's final build. +- [ ] **Step 3: Unchanged where it compiles.** For every file ok in `base.tsv`, compare + `--emit=llvm -mm=own --no-default-lib -o .ll` from main's binary and this branch's. + - Normalise the run-to-run names first: `([A-Za-z])_[0-9]{4,}` → `\1_N`, `\.[0-9]{6,}\.` → + `.N.`, and the `[N x i8]` size on lines containing `FH`. + - Expected: identical, apart from any file shown to differ between two runs of main itself. + - Also run `--emit=llvm -mm=rc` on 50 of those files. Expected: identical. rc's routines changed + only for `SharedType`. +- [ ] **Step 4: Spec results.** Append §23.7 "Results" in the style of §22.7: + - what was built, with the op list, including Task 1's `SharedValueRef` amendment; + - the tests and models; + - the negatives with their messages; + - the teeth (Task 3 Step 8), and what (d) showed; + - the measure rows; + - the corpus and IR-unchanged results; + - the limits found: the `any` check from Task 4 Step 4, if it applies; cycles; anything a task + recorded. + + Tick this plan's boxes, and annotate this step "committed; push and PR left to the user". +- [ ] **Step 5: Linux.** Build the branch with GCC in WSL and run + `ctest -R "own_shared|own-err|own-no-counting|own-verify|verify-ownership-own_shared"` there. + - The WSL clone is `~/ts/TypeScriptCompiler`, whose origin is `/mnt/i/TypeScriptCompiler`. + Fetch this branch, and do not fetch `main`. + - The build dir is `__build/tslang/ninja/release`. + - Write a `.sh` into the scratchpad and run it with `wsl bash -l ` from PowerShell. +- [ ] **Step 6: Commit.** Do not push and do not open a PR. That is left to the user. + +```bash +git add docs/superpowers/ +git commit -m "-mm=own Shared: measure, corpus and results (spec 23.7)" +``` + +--- + +## Appendix A: the corpus tooling (scratchpad only, never committed) + +`one.sh`, the first error of one corpus file (`T` is the compiler to run): + +```sh +#!/bin/sh +f="$1"; T=${T:-/i/TypeScriptCompiler/__build/tslang/windows-msbuild-2026-release/bin/tslang.exe} +out=$(cd /i/TypeScriptCompiler/tslang/test/tester/tests && timeout 120 "$T" --emit=llvm -mm=own --no-default-lib $OPTS "$(basename "$f")" -o NUL 2>&1) +rc=$? +first=$(printf '%s\n' "$out" | grep -m1 -E "error" | sed -E 's/^.*error: //' | tr -d '\r') +if [ $rc -eq 0 ]; then echo "$(basename "$f") ok"; else echo "$(basename "$f") ${first:-rc=$rc}"; fi +``` + +`run.sh`, every file into `$1`: + +```sh +#!/bin/sh +cd "$(dirname "$0")" +ls /i/TypeScriptCompiler/tslang/test/tester/tests/*.ts | xargs -P 12 -n 1 sh ./one.sh | sort > "${1:-results.tsv}" +echo "$(grep -c ' ok$' "${1:-results.tsv}") / $(wc -l < "${1:-results.tsv}") ok" +``` From 22d3f3c3ec70ef0ff2b85ccca30bc71930bd5aa3 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 3 Oct 2026 10:44:52 +0100 Subject: [PATCH 03/21] -mm=own Shared: the type, its ops and MLIRGen (spec 23.1) Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 14 +-- .../TypeScript/MLIRLogic/MLIRPrinter.h | 5 ++ .../TypeScript/MLIRLogic/MLIRTypeCore.h | 2 + .../TypeScript/MLIRLogic/MLIRTypeIterator.h | 5 ++ tslang/include/TypeScript/TypeScriptOps.td | 19 ++++ tslang/include/TypeScript/TypeScriptTypes.td | 28 ++++++ tslang/lib/TypeScript/LowerToAffineLoops.cpp | 2 +- tslang/lib/TypeScript/LowerToLLVM.cpp | 68 ++++++++++++++ tslang/lib/TypeScript/MLIRGenAccessCall.cpp | 14 +++ tslang/lib/TypeScript/MLIRGenExpressions.cpp | 89 +++++++++++++++++++ tslang/lib/TypeScript/MLIRGenImpl.h | 4 + tslang/lib/TypeScript/MLIRGenTypes.cpp | 13 ++- tslang/test/tester/CMakeLists.txt | 11 +++ tslang/test/tester/own/own_shared_basic.ts | 43 +++++++++ .../test/tester/own/own_shared_user_named.ts | 14 +++ 15 files changed, 323 insertions(+), 8 deletions(-) create mode 100644 tslang/test/tester/own/own_shared_basic.ts create mode 100644 tslang/test/tester/own/own_shared_user_named.ts diff --git a/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md b/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md index a5a0b44f8..884faaffb 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 @@ -2114,9 +2114,10 @@ print(Shared.count(a), a === b); payload is one `T` in its storage type. - **`new Shared(x)`** is `ts.SharedNew`: it allocates the block and stores `x` into it, which is a store into a container (§2.3): `x` moves in. -- **`s.value`** is `ts.SharedValue`, a read out of a container: a borrow of the `T` (§23.3). -- **`s.value = x`** is `ts.SharedStore`: `x` moves in, and the old `T` is released, as a field - store does. +- **`s.value`** is a `ts.Load` of the place `ts.SharedValueRef(s)`, and **`s.value = x`** stores + into that place the way a field store does (MLIRGen's retain, `ts.ReleaseSlot` and `ts.Store`), + so `x` moves in and the old `T` is released. One op instead of two lets the place-read and store + machinery of MLIRGen and own apply unchanged. - **`a === b` and `a !== b`** compare the pointers. - **`Shared.count(s)`** reads the header: the number of handles under rc and own, `-1` under gc and none, which keep no count. @@ -2194,11 +2195,14 @@ change. It runs on every `own_shared_*` test, under own and rc. - **Positive**, under own, rc, none and gc, AOT and JIT: - `own_shared_basic`: two handles, `.value` read and write, `===`. - - `own_shared_graph`: a doubly linked list and a node with a parent link, walked with + - `own_shared_user_named`: a program's own class named `Shared`, and its static `count`, win over + the built-in (§23.1). + - `own_shared_graph`: a DAG (one item under two parents) and a singly linked list walked with `cur = cur.value.next`. - `own_shared_containers`: one node in two arrays, a `Shared | null` field, a closure capturing a handle, a handle boxed into `any` and back, a handle returned from a function. - - `own_shared_cycle`: a cycle that is never freed, which still runs. + - `own_shared_cycle`: a cycle that is never freed, which still runs: a doubly linked list and a + node with a parent link, both cycles that leak (§23.4). Each frees every handle but one, then allocates over the freed memory (the churn pattern) before reading through the one left. An early free then reads reused memory. diff --git a/tslang/include/TypeScript/MLIRLogic/MLIRPrinter.h b/tslang/include/TypeScript/MLIRLogic/MLIRPrinter.h index 0f877d05a..029498ab2 100644 --- a/tslang/include/TypeScript/MLIRLogic/MLIRPrinter.h +++ b/tslang/include/TypeScript/MLIRLogic/MLIRPrinter.h @@ -437,6 +437,11 @@ class MLIRPrinter printType(out, t.getElementType()); out << ">"; }) + .template Case([&](auto t) { + out << "Shared<"; + printType(out, t.getElementType()); + out << ">"; + }) .template Case([&](auto t) { printTupleOrObjectType(out, t); }) diff --git a/tslang/include/TypeScript/MLIRLogic/MLIRTypeCore.h b/tslang/include/TypeScript/MLIRLogic/MLIRTypeCore.h index 75b061a13..ab84ce9f4 100644 --- a/tslang/include/TypeScript/MLIRLogic/MLIRTypeCore.h +++ b/tslang/include/TypeScript/MLIRLogic/MLIRTypeCore.h @@ -38,6 +38,7 @@ class MLIRTypeCore || isa(typeIn) || isa(typeIn) || isa(typeIn) + || isa(typeIn) || isa(typeIn) || isa(typeIn) || isa(typeIn) @@ -72,6 +73,7 @@ class MLIRTypeCore || isa(typeIn) || isa(typeIn) || isa(typeIn) + || isa(typeIn) || isa(typeIn) || isa(typeIn) || isa(typeIn) diff --git a/tslang/include/TypeScript/MLIRLogic/MLIRTypeIterator.h b/tslang/include/TypeScript/MLIRLogic/MLIRTypeIterator.h index 2bc7679ef..b90afa03a 100644 --- a/tslang/include/TypeScript/MLIRLogic/MLIRTypeIterator.h +++ b/tslang/include/TypeScript/MLIRLogic/MLIRTypeIterator.h @@ -241,6 +241,11 @@ class MLIRTypeIterator return false; return true; }) + .Case([&](auto t) { + if (!iterate(t.getElementType())) + return false; + return true; + }) .Case([&](auto t) { for (auto subType : t.getFields()) { diff --git a/tslang/include/TypeScript/TypeScriptOps.td b/tslang/include/TypeScript/TypeScriptOps.td index 0da880b31..818fdf6d5 100644 --- a/tslang/include/TypeScript/TypeScriptOps.td +++ b/tslang/include/TypeScript/TypeScriptOps.td @@ -2382,6 +2382,25 @@ def TypeScript_StringCopyOp : TypeScript_Op<"StringCopy", [AllTypesMatch<["in", let results = (outs Res:$result); } +// Shared (own spec 23). `new Shared(x)`: a block of one T, which `value` moves into. +def TypeScript_SharedNewOp : TypeScript_Op<"SharedNew"> { + let arguments = (ins AnyType:$value); + let results = (outs Res:$result); +} + +// The place `s.value`: a reference to the handle's payload. It is read with ts.Load and written +// with ts.Store, as a field is. +def TypeScript_SharedValueRefOp : TypeScript_Op<"SharedValueRef"> { + let arguments = (ins TypeScript_Shared:$shared); + let results = (outs TypeScript_Ref:$result); +} + +// `Shared.count(s)`: the number of handles under rc and own, -1 under gc and none. +def TypeScript_SharedCountOp : TypeScript_Op<"SharedCount"> { + let arguments = (ins TypeScript_Shared:$shared); + let results = (outs TypeScript_Number:$result); +} + def TypeScript_StringCompareOp : TypeScript_Op<"StringCompare"> { let arguments = (ins TypeScript_String:$op1, TypeScript_String:$op2, I32Attr:$code); let results = (outs TypeScript_Boolean:$result); diff --git a/tslang/include/TypeScript/TypeScriptTypes.td b/tslang/include/TypeScript/TypeScriptTypes.td index 512e50ddc..3120cfec0 100644 --- a/tslang/include/TypeScript/TypeScriptTypes.td +++ b/tslang/include/TypeScript/TypeScriptTypes.td @@ -104,6 +104,34 @@ def TypeScript_Ref : TypeScript_Type<"Ref"> { ]; } +// Shared (own spec 23): a counted handle to a block whose payload is one T. +def TypeScript_Shared : TypeScript_Type<"Shared"> { + let mnemonic = "shared"; + + let description = [{ + Syntax: + + ``` + shared-type ::= `shared` `<` type `>` + ``` + + Examples: + + ```mlir + shared> + ``` + }]; + let parameters = (ins "Type":$elementType); + + let assemblyFormat = "`<` params `>`"; + + let builders = [ + TypeBuilderWithInferredContext<(ins "Type":$elementType), [{ + return Base::get(elementType.getContext(), elementType); + }]> + ]; +} + def TypeScript_ValueRef : TypeScript_Type<"ValueRef"> { let mnemonic = "value_ref"; diff --git a/tslang/lib/TypeScript/LowerToAffineLoops.cpp b/tslang/lib/TypeScript/LowerToAffineLoops.cpp index 227dcf7de..6e43eb645 100644 --- a/tslang/lib/TypeScript/LowerToAffineLoops.cpp +++ b/tslang/lib/TypeScript/LowerToAffineLoops.cpp @@ -2657,7 +2657,7 @@ void AddTsAffineLegalOps(ConversionTarget &target) mlir_ts::ElementRefOp, mlir_ts::PointerOffsetRefOp, mlir_ts::FuncOp, mlir_ts::GlobalOp, mlir_ts::GlobalResultOp, mlir_ts::DefaultOp, mlir_ts::HasValueOp, mlir_ts::ValueOp, mlir_ts::ValueOrDefaultOp, mlir_ts::NullOp, mlir_ts::ParseFloatOp, mlir_ts::ParseIntOp, mlir_ts::IsNaNOp, mlir_ts::PrintOp, mlir_ts::ConvertFOp, mlir_ts::SizeOfOp, mlir_ts::TypeDescriptorOp, mlir_ts::RetainOp, mlir_ts::ReleaseOp, mlir_ts::RetainSlotOp, mlir_ts::ReleaseSlotOp, mlir_ts::RetainCellOp, mlir_ts::ReleaseCellOp, mlir_ts::StoreOp, mlir_ts::SymbolRefOp, mlir_ts::LengthOfOp, mlir_ts::SetLengthOfOp, - mlir_ts::StringLengthOp, mlir_ts::StringResizeOp, mlir_ts::StringConcatOp, mlir_ts::StringCopyOp, mlir_ts::StringCompareOp, mlir_ts::AnyCompareOp, + mlir_ts::StringLengthOp, mlir_ts::StringResizeOp, mlir_ts::StringConcatOp, mlir_ts::StringCopyOp, mlir_ts::SharedNewOp, mlir_ts::SharedValueRefOp, mlir_ts::SharedCountOp, mlir_ts::StringCompareOp, mlir_ts::AnyCompareOp, mlir_ts::LoadOp, mlir_ts::LoadSaveOp, mlir_ts::NewOp, mlir_ts::CreateTupleOp, mlir_ts::DeconstructTupleOp, mlir_ts::CreateArrayOp, mlir_ts::NewEmptyArrayOp, mlir_ts::NewArrayOp, mlir_ts::DeleteOp, mlir_ts::PropertyRefOp, mlir_ts::InsertPropertyOp, mlir_ts::ExtractPropertyOp, mlir_ts::LogicalBinaryOp, mlir_ts::UndefOp, mlir_ts::VariableOp, mlir_ts::DebugVariableOp, mlir_ts::AllocaOp, diff --git a/tslang/lib/TypeScript/LowerToLLVM.cpp b/tslang/lib/TypeScript/LowerToLLVM.cpp index d3c0273e0..9c5fee635 100644 --- a/tslang/lib/TypeScript/LowerToLLVM.cpp +++ b/tslang/lib/TypeScript/LowerToLLVM.cpp @@ -949,6 +949,68 @@ class StringCopyOpLowering : public TsLlvmPattern } }; +// `new Shared(x)` (own spec 23): a heap block with the header every block has (a count under rc +// and own, born 0 like any block), whose payload is the value. +class SharedNewOpLowering : public TsLlvmPattern +{ + public: + using TsLlvmPattern::TsLlvmPattern; + + LogicalResult matchAndRewrite(mlir_ts::SharedNewOp op, Adaptor transformed, + ConversionPatternRewriter &rewriter) const final + { + LLVMCodeHelper ch(op, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); + auto elementType = mlir::cast(op.getType()).getElementType(); + auto handle = ch.MemoryAlloc(elementType, MemoryAllocSet::Zero); + rewriter.create(op->getLoc(), transformed.getValue(), handle); + rewriter.replaceOp(op, ValueRange{handle}); + return success(); + } +}; + +// `s.value`: the payload starts at the handle. +class SharedValueRefOpLowering : public TsLlvmPattern +{ + public: + using TsLlvmPattern::TsLlvmPattern; + + LogicalResult matchAndRewrite(mlir_ts::SharedValueRefOp op, Adaptor transformed, + ConversionPatternRewriter &rewriter) const final + { + rewriter.replaceOp(op, ValueRange{transformed.getShared()}); + return success(); + } +}; + +// `Shared.count(s)`: the header word under rc and own; gc and none never write it. +class SharedCountOpLowering : public TsLlvmPattern +{ + public: + using TsLlvmPattern::TsLlvmPattern; + + LogicalResult matchAndRewrite(mlir_ts::SharedCountOp op, Adaptor transformed, + ConversionPatternRewriter &rewriter) const final + { + auto loc = op->getLoc(); + TypeHelper th(rewriter); + TypeConverterHelper tch(getTypeConverter()); + LLVMCodeHelper ch(op, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); + + auto numberType = tch.convertType(op.getType()); + if (!tsLlvmContext->compileOptions.tracksOwnership()) + { + rewriter.replaceOpWithNewOp(op, numberType, rewriter.getFloatAttr(numberType, -1.0)); + return success(); + } + + auto llvmIndexType = tch.convertType(th.getIndexType()); + auto blockPtr = ch.getBlockPtrFromPayloadPtr(loc, transformed.getShared(), llvmIndexType); + auto count = rewriter.create(loc, llvmIndexType, blockPtr); + rewriter.replaceOpWithNewOp(op, numberType, count); + return success(); + } +}; + class StringCompareOpLowering : public TsLlvmPattern { public: @@ -7138,6 +7200,11 @@ static void populateTypeScriptConversionPatterns(LLVMTypeConverter &converter, m return LLVM::LLVMPointerType::get(m.getContext()); }); + // Shared (own spec 23): a pointer to the handle's payload + converter.addConversion([&](mlir_ts::SharedType type) { + return LLVM::LLVMPointerType::get(m.getContext()); + }); + converter.addConversion([&](mlir_ts::ValueRefType type) { return LLVM::LLVMPointerType::get(m.getContext()); }); @@ -8029,6 +8096,7 @@ void TypeScriptToLLVMLoweringPass::runOnOperation() ArrayPopOpLowering, ArrayUnshiftOpLowering, ArrayShiftOpLowering, ArraySpliceOpLowering, ArrayViewOpLowering, DeleteOpLowering, ParseFloatOpLowering, ParseIntOpLowering, IsNaNOpLowering, PrintOpLowering, ConvertFOpLowering, StoreOpLowering, SizeOfOpLowering, TypeDescriptorOpLowering, RetainOpLowering, ReleaseOpLowering, RetainSlotOpLowering, ReleaseSlotOpLowering, RetainCellOpLowering, ReleaseCellOpLowering, InsertPropertyOpLowering, LengthOfOpLowering, SetLengthOfOpLowering, StringLengthOpLowering, StringResizeOpLowering, StringConcatOpLowering, StringCopyOpLowering, + SharedNewOpLowering, SharedValueRefOpLowering, SharedCountOpLowering, StringCompareOpLowering, AnyCompareOpLowering, CharToStringOpLowering, UndefOpLowering, CopyStructOpLowering, MemoryCopyOpLowering, MemoryMoveOpLowering, LoadSaveValueLowering, ThrowUnwindOpLowering, ThrowCallOpLowering, VariableOpLowering, DebugVariableOpLowering, AllocaOpLowering, InvokeOpLowering, InvokeHybridOpLowering, VirtualSymbolRefOpLowering, ThisVirtualSymbolRefOpLowering, InterfaceSymbolRefOpLowering, diff --git a/tslang/lib/TypeScript/MLIRGenAccessCall.cpp b/tslang/lib/TypeScript/MLIRGenAccessCall.cpp index 16f4c69b1..479890491 100644 --- a/tslang/lib/TypeScript/MLIRGenAccessCall.cpp +++ b/tslang/lib/TypeScript/MLIRGenAccessCall.cpp @@ -305,6 +305,20 @@ namespace mlirgen return mlir::Value(); }) .Case([&](auto refType) { return cl.Ref(refType); }) + // `s.value` (own spec 23.1): a ts.Load of the place ts.SharedValueRef(s), so `s.value = x` + // stores through that place the way a field store does (mlirGenSaveLogicOneItem) + .Case([&](auto sharedType) -> mlir::Value { + // any other member: an extension function, else "Can't resolve property" below + if (name != "value") + { + return mlir::Value(); + } + + auto elementType = sharedType.getElementType(); + auto valueRef = builder.create( + location, mlir_ts::RefType::get(elementType), objectValue); + return builder.create(location, elementType, valueRef); + }) .Case([&](auto objectType) { if (auto value = cl.Object(objectType)) { diff --git a/tslang/lib/TypeScript/MLIRGenExpressions.cpp b/tslang/lib/TypeScript/MLIRGenExpressions.cpp index dcde6624d..550b652a6 100644 --- a/tslang/lib/TypeScript/MLIRGenExpressions.cpp +++ b/tslang/lib/TypeScript/MLIRGenExpressions.cpp @@ -1000,6 +1000,20 @@ namespace mlirgen auto callExpr = callExpression->expression.as(); + // `Shared.count(s)` (own spec 23.1), unless the program declares its own `Shared`, which + // wins as it does in type position and in `new`. + if (callExpr == SyntaxKind::PropertyAccessExpression) + { + auto access = callExpr.as(); + if (access->expression == SyntaxKind::Identifier + && MLIRHelper::getName(access->expression.as()) == "Shared" + && MLIRHelper::getName(access->name) == "count" + && !resolveIdentifier(location, "Shared", genContext)) + { + return mlirGenSharedCount(location, callExpression, genContext); + } + } + auto result = mlirGen(callExpr, genContext); // in case of detecting value for recursive calls we need to ignore failed calls // last condition we need to reduce posobilities to ignore legitimate failure @@ -1071,6 +1085,28 @@ namespace mlirgen return mlirGenCallExpression(location, funcResult, callExpression->typeArguments, operands, genContext); } + // `Shared.count(s)` (own spec 23.1): the number of handles under rc and own, -1 under gc and none. + ValueOrLogicalResult MLIRGenImpl::mlirGenSharedCount(mlir::Location location, CallExpression callExpression, + const GenContext &genContext) + { + if (callExpression->arguments.size() != 1) + { + emitError(location, "Shared.count(handle) takes one argument"); + return mlir::failure(); + } + + auto result = mlirGen(callExpression->arguments.front(), genContext); + EXIT_IF_FAILED_OR_NO_VALUE(result) + auto handle = V(result); + if (!isa(handle.getType())) + { + emitError(location, "Shared.count takes a Shared, not ") << to_print(handle.getType()); + return mlir::failure(); + } + + return V(builder.create(location, getNumberType(), handle)); + } + ValueOrLogicalResult MLIRGenImpl::mlirGen(NewExpression newExpression, const GenContext &genContext) { auto location = loc(newExpression); @@ -1086,6 +1122,11 @@ namespace mlirgen { // TODO: review it, seems it should be resolved earlier auto name = MLIRHelper::getName(typeExpression.as()); + if (name == "Shared") + { + return mlirGenNewShared(location, newExpression, genContext); + } + type = findEmbeddedType(location, name, newExpression->typeArguments, genContext); if (type) { @@ -1202,6 +1243,54 @@ namespace mlirgen return NewClassInstance(location, value, newExpression->arguments, newExpression->typeArguments, suppressConstructorCall, genContext); } + // `new Shared(x)` (own spec 23): T is the type argument, else the receiver's, else x's. + ValueOrLogicalResult MLIRGenImpl::mlirGenNewShared(mlir::Location location, NewExpression newExpression, + const GenContext &genContext) + { + if (newExpression->arguments.size() != 1) + { + emitError(location, "new Shared(value) takes one argument"); + return mlir::failure(); + } + + if (newExpression->typeArguments.size() > 1) + { + emitError(location, "Shared takes one type argument"); + return mlir::failure(); + } + + mlir::Type elementType; + if (newExpression->typeArguments.size() == 1) + { + elementType = getType(newExpression->typeArguments.front(), genContext); + if (!elementType) + { + return mlir::failure(); + } + } + else if (auto receiver = mlir::dyn_cast_or_null(genContext.receiverType)) + { + elementType = receiver.getElementType(); + } + + // the value is given T as its receiver, not the handle's type + GenContext valueGenContext(genContext); + valueGenContext.clearReceiverTypes(); + valueGenContext.receiverType = elementType; + + auto result = mlirGen(newExpression->arguments.front(), valueGenContext); + EXIT_IF_FAILED_OR_NO_VALUE(result) + auto value = V(result); + + if (!elementType) + { + elementType = mth.wideStorageType(value.getType()); + } + + CAST_A(stored, location, elementType, value, genContext); + return V(builder.create(location, mlir_ts::SharedType::get(elementType), stored)); + } + mlir::LogicalResult MLIRGenImpl::mlirGen(DeleteExpression deleteExpression, const GenContext &genContext) { diff --git a/tslang/lib/TypeScript/MLIRGenImpl.h b/tslang/lib/TypeScript/MLIRGenImpl.h index 30bd02dc3..c87d7d8e1 100644 --- a/tslang/lib/TypeScript/MLIRGenImpl.h +++ b/tslang/lib/TypeScript/MLIRGenImpl.h @@ -8661,6 +8661,10 @@ class MLIRGenImpl ValueOrLogicalResult mlirGen(NewExpression newExpression, const GenContext &genContext); + ValueOrLogicalResult mlirGenNewShared(mlir::Location location, NewExpression newExpression, const GenContext &genContext); + + ValueOrLogicalResult mlirGenSharedCount(mlir::Location location, CallExpression callExpression, const GenContext &genContext); + mlir::LogicalResult mlirGen(DeleteExpression deleteExpression, const GenContext &genContext); ValueOrLogicalResult mlirGen(VoidExpression voidExpression, const GenContext &genContext); diff --git a/tslang/lib/TypeScript/MLIRGenTypes.cpp b/tslang/lib/TypeScript/MLIRGenTypes.cpp index d5febf363..e178604f3 100644 --- a/tslang/lib/TypeScript/MLIRGenTypes.cpp +++ b/tslang/lib/TypeScript/MLIRGenTypes.cpp @@ -919,6 +919,7 @@ namespace mlirgen {"Reference", true }, // to support dll import {"Ref", true }, // alias of Reference {"BoxedObject", true }, // an object held by reference, see MLIRPrinter::printBoxedObjectTypes + {"Shared", true }, // a counted handle, own spec 23 {"Readonly", true }, {"Partial", true }, {"Required", true }, @@ -1008,6 +1009,7 @@ namespace mlirgen {"Reference", true }, // to support dll import {"Ref", true }, // alias of Reference {"BoxedObject", true }, // an object held by reference, see MLIRPrinter::printBoxedObjectTypes + {"Shared", true }, // a counted handle, own spec 23 {"ThisType", true }, //{"Array", true } }; @@ -1161,7 +1163,7 @@ namespace mlirgen { enum class EmbeddedType { - None, TypeOf, Reference, BoxedObject, FirstTypeArgument, NonNullable, Array, ReadonlyArray, ReturnType, + None, TypeOf, Reference, BoxedObject, Shared, FirstTypeArgument, NonNullable, Array, ReadonlyArray, ReturnType, Parameters, ThisParameterType, OmitThisParameter, Uppercase, Lowercase, Capitalize, Uncapitalize }; @@ -1169,6 +1171,7 @@ namespace mlirgen .Case("TypeOf", EmbeddedType::TypeOf) .Cases("Reference", "Ref", EmbeddedType::Reference) .Case("BoxedObject", EmbeddedType::BoxedObject) + .Case("Shared", EmbeddedType::Shared) .Cases("Readonly", "Partial", "Required", "ThisType", EmbeddedType::FirstTypeArgument) .Case("NonNullable", EmbeddedType::NonNullable) #ifdef ARRAY_TYPE_AS_ARRAY_CLASS @@ -1204,6 +1207,8 @@ namespace mlirgen return mlir_ts::RefType::get(type); case EmbeddedType::BoxedObject: return getObjectType(type); + case EmbeddedType::Shared: + return mlir_ts::SharedType::get(type); case EmbeddedType::FirstTypeArgument: return type; case EmbeddedType::NonNullable: @@ -1260,13 +1265,14 @@ namespace mlirgen { enum class EmbeddedType { - None, TypeOf, Reference, BoxedObject, ThisType, Array + None, TypeOf, Reference, BoxedObject, Shared, ThisType, Array }; auto kind = llvm::StringSwitch(name) .Case("TypeOf", EmbeddedType::TypeOf) .Cases("Reference", "Ref", EmbeddedType::Reference) .Case("BoxedObject", EmbeddedType::BoxedObject) + .Case("Shared", EmbeddedType::Shared) .Case("ThisType", EmbeddedType::ThisType) #ifdef ARRAY_TYPE_AS_ARRAY_CLASS .Case("Array", EmbeddedType::Array) @@ -1287,6 +1293,9 @@ namespace mlirgen return mlir_ts::RefType::get(type); case EmbeddedType::BoxedObject: return getObjectType(type); + case EmbeddedType::Shared: + // the type argument is not checked above in this variant + return type ? mlir_ts::SharedType::get(type) : mlir::Type(); case EmbeddedType::ThisType: return type; #ifdef ARRAY_TYPE_AS_ARRAY_CLASS diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 70954639c..1cbea5582 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2609,6 +2609,17 @@ add_test(NAME test-own-string-copy-in-store-block "-DFILE=${PROJECT_SOURCE_DIR}/test/tester/own/own_string_copy_branch.ts" -P "${CMAKE_CURRENT_SOURCE_DIR}/own-string-copy-block.cmake") +# Shared (spec 23): counted handles. These are not in the own_test list, whose +# test-own-no-counting check forbids __tslang_inc_ref: a handle is counted under own by design. +# own_shared_user_named: a program's own class named Shared wins over the built-in (spec 23.1). +set(shared_models gc none) +foreach(shared_mm ${shared_models}) + foreach(shared_test own_shared_basic own_shared_user_named) + tslang_add_test(NAME test-jit-${shared_mm}-${shared_test} COMMAND test-runner -jit -mm=${shared_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${shared_test}.ts") + tslang_add_test(NAME test-compile-${shared_mm}-${shared_test} COMMAND test-runner -mm=${shared_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${shared_test}.ts") + endforeach() +endforeach() + # Across a library boundary: the library exports its functions' `__own_no_drops`, and a borrow held # across a call to one survives. Run as a -shared pair (the library loaded at run time) and, on # Windows, checked against a library that lists nothing (own-shared-no-drops.cmake). diff --git a/tslang/test/tester/own/own_shared_basic.ts b/tslang/test/tester/own/own_shared_basic.ts new file mode 100644 index 000000000..cefb39314 --- /dev/null +++ b/tslang/test/tester/own/own_shared_basic.ts @@ -0,0 +1,43 @@ +// Shared (spec 23): two handles to one block, `.value` read and write, `===` on handles, and a +// payload that is a string. Each handle dropped is followed by reusing freed memory before the +// other is read. +class Node { + v = 0; + name = ""; +} + +function churn() { + let keep: string[] = []; + for (let i = 0; i < 1000; i++) keep.push("k" + i); + return keep.length; +} + +function main() { + let a = new Shared(new Node()); + a.value.name = "first" + 1; + const b = a; + assert(a === b, "two handles to one block are equal"); + a.value.v = 5; + assert(b.value.v == 5, "a write through one handle is seen through the other"); + + const c = new Shared(new Node()); + assert(a !== c, "two blocks are not equal"); + + a = c; + assert(churn() == 1000); + assert(b.value.v == 5 && b.value.name == "first1", "the other handle keeps the block"); + + const d = b; + d.value = new Node(); + d.value.v = 7; + assert(b.value.v == 7, "the value replaced through one handle is seen through the other"); + + const t = new Shared("text" + 2); + const u = t; + assert(u.value == "text2"); + t.value = "other" + 3; + assert(churn() == 1000); + assert(u.value == "other3"); + + print("done."); +} diff --git a/tslang/test/tester/own/own_shared_user_named.ts b/tslang/test/tester/own/own_shared_user_named.ts new file mode 100644 index 000000000..8d4a790dd --- /dev/null +++ b/tslang/test/tester/own/own_shared_user_named.ts @@ -0,0 +1,14 @@ +// A program's own class named Shared wins over the built-in (spec 23.1). +class Shared { + constructor(public value: number) {} + static count(s: Shared) { + return 42; + } +} + +function main() { + const x = new Shared(1); + assert(x.value == 1, "the program's own Shared"); + assert(Shared.count(x) == 42, "the program's own static count"); + print("done."); +} From 41323480c75260dfbec59036fc5dd0402d13c9d6 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 3 Oct 2026 11:08:00 +0100 Subject: [PATCH 04/21] -mm=own Shared: a handle is counted, under rc first (spec 23.2) Co-Authored-By: Claude Opus 5.5 --- .../LowerToLLVM/OwnershipRoutineLogic.h | 26 +++++++-- .../TypeScript/MLIRLogic/MLIRTypeHelper.h | 43 +++++++++++++- tslang/lib/TypeScript/LowerToLLVM.cpp | 2 +- tslang/lib/TypeScript/MLIRGenExpressions.cpp | 6 +- tslang/lib/TypeScript/MLIRGenImpl.h | 27 +++++++-- tslang/test/tester/CMakeLists.txt | 9 ++- tslang/test/tester/own/own_shared_count.ts | 56 +++++++++++++++++++ tslang/test/tester/own/own_shared_graph.ts | 54 ++++++++++++++++++ 8 files changed, 206 insertions(+), 17 deletions(-) create mode 100644 tslang/test/tester/own/own_shared_count.ts create mode 100644 tslang/test/tester/own/own_shared_graph.ts diff --git a/tslang/include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h b/tslang/include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h index bc52c31e0..a40fd9c5b 100644 --- a/tslang/include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h +++ b/tslang/include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h @@ -627,8 +627,9 @@ class OwnershipRoutineLogic // Runs `thenBody` -- the destroy half: release what the value owns, then free it -- only // when `payloadPtr` is non-null and the reference being dropped was the last one. Under // own there is no count: the one owner is giving the block up, so it goes unless it is - // immortal (a literal, a `typeof` tag). - void emitIfLastReference(mlir::Value payloadPtr, llvm::function_ref thenBody) + // immortal (a literal, a `typeof` tag). A `counted` block - a Shared handle's (own spec + // 23.2) - is counted under own too. + void emitIfLastReference(mlir::Value payloadPtr, llvm::function_ref thenBody, bool counted = false) { TypeHelper th(rewriter); auto loc = op->getLoc(); @@ -643,7 +644,8 @@ class OwnershipRoutineLogic rewriter.create(loc, ValueRange{}, continuationBlock); rewriter.setInsertionPointToEnd(decBlock); - auto wasLast = compileOptions.memoryModel == MemoryModelOwn ? emitIsMortal(payloadPtr) : emitDecRef(payloadPtr); + auto wasLast = compileOptions.memoryModel == MemoryModelOwn && !counted ? emitIsMortal(payloadPtr) + : emitDecRef(payloadPtr); rewriter.create(loc, wasLast, thenBlock, continuationBlock); rewriter.setInsertionPointToEnd(currentBlock); @@ -976,6 +978,18 @@ class OwnershipRoutineLogic return; } + // a handle (own spec 23.2) is counted in every model that tracks ownership: the last one + // releases the payload and frees the block + if (auto sharedType = dyn_cast(type)) + { + auto handle = rewriter.create(loc, ptrTy, slotPtr); + emitIfLastReference(handle, [&]() { + releaseSlot(sharedType.getElementType(), handle); + emitFreeBlock(handle); + }, /*counted=*/true); + return; + } + // an "any" box owns its own block, and its payload's type is only known through the // tag if (isa(type)) @@ -1135,9 +1149,9 @@ class OwnershipRoutineLogic auto ptrTy = th.getPtrType(); // each of these is a reference to a block of its own: string, class and object - // instances, and an "any" box - if (isa(type) || isa(type) || isa(type) || - isa(type)) + // instances, a Shared handle, and an "any" box + if (isa(type) || isa(type) || isa(type) || + isa(type) || isa(type)) { emitIncRef(rewriter.create(loc, ptrTy, slotPtr)); return; diff --git a/tslang/include/TypeScript/MLIRLogic/MLIRTypeHelper.h b/tslang/include/TypeScript/MLIRLogic/MLIRTypeHelper.h index bb10fdbb4..c858e9c5c 100644 --- a/tslang/include/TypeScript/MLIRLogic/MLIRTypeHelper.h +++ b/tslang/include/TypeScript/MLIRLogic/MLIRTypeHelper.h @@ -2118,6 +2118,44 @@ class MLIRTypeHelper return ownsHeapMemory(location, type, visiting); } + // A handle (own spec 23.2): Shared, or a tag-free union or an optional of one with null or + // undefined - one pointer whose block is counted in every model that tracks ownership. + static bool isSharedHandleType(mlir::Type type) + { + if (isa(type)) + { + return true; + } + + if (auto optionalType = dyn_cast(type)) + { + return isSharedHandleType(optionalType.getElementType()); + } + + if (auto unionType = dyn_cast(type)) + { + mlir::Type shared; + for (auto member : unionType.getTypes()) + { + if (isa(member)) + { + continue; + } + + if (!isa(member) || (shared && shared != member)) + { + return false; + } + + shared = member; + } + + return !!shared; + } + + return false; + } + // Field types of a record-shaped type, empty for anything else. llvm::SmallVector getOwnershipFieldTypes(mlir::Type type) { @@ -3785,9 +3823,10 @@ class MLIRTypeHelper return false; } - // owns its own block + // owns its own block. A Shared handle (own spec 23) does too, and its block's payload + // is released by the handle's routine, so it is not a record here if (isa(type) || isa(type) || isa(type) || - isa(type) || isa(type)) + isa(type) || isa(type) || isa(type)) { return true; } diff --git a/tslang/lib/TypeScript/LowerToLLVM.cpp b/tslang/lib/TypeScript/LowerToLLVM.cpp index 9c5fee635..e5727cb81 100644 --- a/tslang/lib/TypeScript/LowerToLLVM.cpp +++ b/tslang/lib/TypeScript/LowerToLLVM.cpp @@ -2673,7 +2673,7 @@ struct VariableOpLowering : public TsLlvmPattern if (!isCaptured) { auto tsStorageType = referenceType.getElementType(); - if (isa(tsStorageType) || isa(tsStorageType) || + if (isa(tsStorageType) || isa(tsStorageType) || isa(tsStorageType) || isa(tsStorageType) || isa(tsStorageType) || isa(tsStorageType)) { diff --git a/tslang/lib/TypeScript/MLIRGenExpressions.cpp b/tslang/lib/TypeScript/MLIRGenExpressions.cpp index 550b652a6..8a0846a62 100644 --- a/tslang/lib/TypeScript/MLIRGenExpressions.cpp +++ b/tslang/lib/TypeScript/MLIRGenExpressions.cpp @@ -1288,7 +1288,11 @@ namespace mlirgen } CAST_A(stored, location, elementType, value, genContext); - return V(builder.create(location, mlir_ts::SharedType::get(elementType), stored)); + // the block owns what it holds, as an array literal owns its elements + mlirGenRetainCaptured(location, mlir::ValueRange{stored}); + auto handle = builder.create(location, mlir_ts::SharedType::get(elementType), stored); + markFreshBlockOwned(location, handle); + return V(handle); } mlir::LogicalResult MLIRGenImpl::mlirGen(DeleteExpression deleteExpression, const GenContext &genContext) diff --git a/tslang/lib/TypeScript/MLIRGenImpl.h b/tslang/lib/TypeScript/MLIRGenImpl.h index c87d7d8e1..ea77a44b7 100644 --- a/tslang/lib/TypeScript/MLIRGenImpl.h +++ b/tslang/lib/TypeScript/MLIRGenImpl.h @@ -1055,8 +1055,9 @@ class MLIRGenImpl } } - // 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 + // Gives a freshly built string, a freshly made `any` box, or a new Shared handle (own spec + // 23), 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. // @@ -1065,7 +1066,7 @@ class MLIRGenImpl // balanced, where a mark on its own would hand a receiver a reference nobody took. void markFreshBlockOwned(mlir::Location location, mlir::Value value) { - if (!value || !isa(value.getType())) + if (!value || !isa(value.getType())) { return; } @@ -1092,7 +1093,19 @@ class MLIRGenImpl return isOwnedLocalSlot(reference) || isCapturedVariableCell(reference) || isCapturedCellSlot(reference) || isOwnedGlobalSlot(location, reference) || isOwnedFieldSlot(location, reference) || isOwnedElementSlot(location, reference) || - isOwnedInterfaceFieldSlot(location, reference); + isOwnedInterfaceFieldSlot(location, reference) || isOwnedSharedValueSlot(location, reference); + } + + // `s.value` (own spec 23): the payload is owned by the handle's block, as a field by its object + bool isOwnedSharedValueSlot(mlir::Location location, mlir::Value reference) + { + if (!reference.getDefiningOp()) + { + return false; + } + + auto refType = mlir::dyn_cast(reference.getType()); + return refType && mth.ownsHeapMemory(location, refType.getElementType()); } // A field reached through an interface: it belongs to the object behind the interface, which releases @@ -1482,9 +1495,13 @@ class MLIRGenImpl // aliases the storage it was read from, so it keeps that, and a narrowed view // (addSafeCastStatement, a SafeCastOp) is the narrowed variable seen through // another type, not an array of its own. + // + // A `Shared` handle (own spec 23.1) as well: `const b = a` is a second handle, and + // counts as one. A folded name would be `a` itself, with no reference of its own, so + // `a = c` would free the block `b` still reads, and `Shared.count` would miss it. MLIRTypeHelper mth(builder.getContext(), compileOptions); needsIdentityStorage = mth.hasBoundMethodField(type) || isa(type) - || (isa(type) && !(varClass == VariableType::ConstRef) + || (isa(type) && !(varClass == VariableType::ConstRef) && !(initial && initial.getDefiningOp())); if (needsIdentityStorage) { diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 1cbea5582..a9837dac9 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2612,13 +2612,18 @@ add_test(NAME test-own-string-copy-in-store-block # Shared (spec 23): counted handles. These are not in the own_test list, whose # test-own-no-counting check forbids __tslang_inc_ref: a handle is counted under own by design. # own_shared_user_named: a program's own class named Shared wins over the built-in (spec 23.1). -set(shared_models gc none) +set(shared_models gc none rc) foreach(shared_mm ${shared_models}) - foreach(shared_test own_shared_basic own_shared_user_named) + foreach(shared_test own_shared_basic own_shared_user_named own_shared_graph) tslang_add_test(NAME test-jit-${shared_mm}-${shared_test} COMMAND test-runner -jit -mm=${shared_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${shared_test}.ts") tslang_add_test(NAME test-compile-${shared_mm}-${shared_test} COMMAND test-runner -mm=${shared_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${shared_test}.ts") endforeach() endforeach() +# Shared.count is a count only under rc and own. +foreach(shared_count_mm rc) + tslang_add_test(NAME test-jit-${shared_count_mm}-own_shared_count COMMAND test-runner -jit -mm=${shared_count_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_shared_count.ts") + tslang_add_test(NAME test-compile-${shared_count_mm}-own_shared_count COMMAND test-runner -mm=${shared_count_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_shared_count.ts") +endforeach() # Across a library boundary: the library exports its functions' `__own_no_drops`, and a borrow held # across a call to one survives. Run as a -shared pair (the library loaded at run time) and, on diff --git a/tslang/test/tester/own/own_shared_count.ts b/tslang/test/tester/own/own_shared_count.ts new file mode 100644 index 000000000..c46fa8054 --- /dev/null +++ b/tslang/test/tester/own/own_shared_count.ts @@ -0,0 +1,56 @@ +// Shared (spec 23.1): Shared.count after each copy and drop - a handle made, copied, given to a +// function that keeps it, returned from one, reassigned to the block it holds, and copied in a loop. +class Node { + v = 0; +} + +// a holder rather than a bare array parameter: an array is passed as a value, so a callee's push +// grows the callee's copy and not the caller's +class Keeper { + kept: Shared[] = []; +} + +function keepOne(s: Shared, into: Keeper) { + into.kept.push(s); +} + +function dropLast(list: Keeper) { + list.kept.pop(); +} + +function make() { + return new Shared(new Node()); +} + +function main() { + const a = new Shared(new Node()); + assert(Shared.count(a) == 1, "one handle"); + + let b = a; + assert(Shared.count(a) == 2, "copied"); + + b = a; + assert(Shared.count(a) == 2, "reassigned to the block it holds"); + + const list = new Keeper(); + keepOne(a, list); + assert(Shared.count(a) == 3, "kept by a callee"); + + b = new Shared(new Node()); + assert(Shared.count(a) == 2 && Shared.count(b) == 1, "one handle moved to another block"); + + dropLast(list); + assert(Shared.count(a) == 1, "the kept one dropped"); + + const m = make(); + assert(Shared.count(m) == 1, "returned from a function"); + + for (let i = 0; i < 1000; i++) { + const t = a; + assert(Shared.count(t) == 2, "copied in a loop"); + } + + assert(Shared.count(a) == 1, "every copy in the loop dropped"); + + print("done."); +} diff --git a/tslang/test/tester/own/own_shared_graph.ts b/tslang/test/tester/own/own_shared_graph.ts new file mode 100644 index 000000000..b4323e07d --- /dev/null +++ b/tslang/test/tester/own/own_shared_graph.ts @@ -0,0 +1,54 @@ +// Shared (spec 23): a DAG - one item under two parents - and a singly linked list walked with +// `cur = cur.value.next`. Handles are dropped and their memory reused before the rest is read. +class Item { + v = 0; + name = ""; + next: Shared | null = null; +} + +class Parent { + child: Shared | null = null; +} + +function churn() { + let keep: string[] = []; + for (let i = 0; i < 1000; i++) keep.push("k" + i); + return keep.length; +} + +function list(n: number) { + let head: Shared | null = null; + for (let i = 0; i < n; i++) { + const item = new Shared(new Item()); + item.value.v = i; + item.value.next = head; + head = item; + } + + return head; +} + +function main() { + const child = new Shared(new Item()); + child.value.name = "child" + 1; + let p1 = new Parent(); + const p2 = new Parent(); + p1.child = child; + p2.child = child; + p1 = new Parent(); + assert(churn() == 1000); + const c = p2.child; + assert(c !== null && c.value.name == "child1", "the other parent keeps the child"); + + let sum = 0; + let cur = list(10); + while (cur) { + sum += cur.value.v; + cur = cur.value.next; + } + + assert(sum == 45, "the list walked to its end"); + assert(cur === null); + + print("done."); +} From 4a816769a684ee96afe9635856b0487086e62a7f Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 3 Oct 2026 11:32:48 +0100 Subject: [PATCH 05/21] -mm=own Shared: own leaves handles to the count; reads through a handle (spec 23.2, 23.3) Inference never erases, decides or reports a retain or release of a handle (touchesHandle), and lowering counts what is left under own. The signature pass gives handles no owned or borrowed facts; a handle's release, and a store into `s.value`, make a function one that may drop. A borrow read through a handle has a root nobody owns, so any call that may drop ends it, as do `x.value = ...`, a store into a place under any handle, and push/unshift on an array reached through one. The handle's own release or overwrite (a local's ReleaseSlot/Store, a temporary's Release) ends it too; a copy of the handle does not. Also: own_shared_count asserts that a block held by a dying block releases its payload and that `s.value = x` hands the counts over; comment reflow in MLIRGenImpl.h and the GC-root condition wrapped in LowerToLLVM.cpp. Co-Authored-By: Claude Opus 5.5 --- tslang/lib/TypeScript/LowerToLLVM.cpp | 30 +++- tslang/lib/TypeScript/MLIRGenImpl.h | 5 +- tslang/lib/TypeScript/OwnershipFacts.h | 27 +++- .../lib/TypeScript/OwnershipInferencePass.cpp | 143 +++++++++++++++++- .../lib/TypeScript/OwnershipSignaturePass.cpp | 32 +++- tslang/test/tester/CMakeLists.txt | 20 ++- .../tester/own/own_err_shared_borrow_call.ts | 20 +++ .../tester/own/own_err_shared_borrow_push.ts | 18 +++ .../tester/own/own_err_shared_borrow_write.ts | 13 ++ .../tester/own/own_err_shared_moved_in.ts | 11 ++ tslang/test/tester/own/own_shared_count.ts | 17 ++- 11 files changed, 311 insertions(+), 25 deletions(-) create mode 100644 tslang/test/tester/own/own_err_shared_borrow_call.ts create mode 100644 tslang/test/tester/own/own_err_shared_borrow_push.ts create mode 100644 tslang/test/tester/own/own_err_shared_borrow_write.ts create mode 100644 tslang/test/tester/own/own_err_shared_moved_in.ts diff --git a/tslang/lib/TypeScript/LowerToLLVM.cpp b/tslang/lib/TypeScript/LowerToLLVM.cpp index e5727cb81..44154baaa 100644 --- a/tslang/lib/TypeScript/LowerToLLVM.cpp +++ b/tslang/lib/TypeScript/LowerToLLVM.cpp @@ -425,14 +425,17 @@ class RetainOpLowering : public TsLlvmPattern LogicalResult matchAndRewrite(mlir_ts::RetainOp op, Adaptor transformed, ConversionPatternRewriter &rewriter) const final { + // A handle is counted under own too, and inference leaves its retains (spec 23.2). + auto counted = MLIRTypeHelper::isSharedHandleType(op.getReference().getType()); + // Under own, ownership inference erased every retain it could prove, and reported the // rest; one that reaches here was neither, and erasing it would ship a double free. - if (tsLlvmContext->compileOptions.memoryModel == MemoryModelOwn) + if (tsLlvmContext->compileOptions.memoryModel == MemoryModelOwn && !counted) { return op.emitError("ownership inference left a retain behind"); } - if (tsLlvmContext->compileOptions.isRefCounted()) + if (tsLlvmContext->compileOptions.isRefCounted() || (tsLlvmContext->compileOptions.tracksOwnership() && counted)) { OwnershipRoutineLogic orl(op, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); orl.emitRetainValue(op.getReference().getType(), transformed.getReference()); @@ -472,14 +475,19 @@ class RetainSlotOpLowering : public TsLlvmPattern LogicalResult matchAndRewrite(mlir_ts::RetainSlotOp op, Adaptor transformed, ConversionPatternRewriter &rewriter) const final { + // A slot holding a handle is counted under own too, and inference leaves its retains + // (spec 23.2). + auto counted = + MLIRTypeHelper::isSharedHandleType(cast(op.getSlot().getType()).getElementType()); + // Under own, ownership inference erased every retain it could prove, and reported the // rest; one that reaches here was neither, and erasing it would ship a double free. - if (tsLlvmContext->compileOptions.memoryModel == MemoryModelOwn) + if (tsLlvmContext->compileOptions.memoryModel == MemoryModelOwn && !counted) { return op.emitError("ownership inference left a retain behind"); } - if (tsLlvmContext->compileOptions.isRefCounted()) + if (tsLlvmContext->compileOptions.isRefCounted() || (tsLlvmContext->compileOptions.tracksOwnership() && counted)) { OwnershipRoutineLogic orl(op, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); orl.emitRetainSlot(cast(op.getSlot().getType()).getElementType(), transformed.getSlot()); @@ -2673,9 +2681,12 @@ struct VariableOpLowering : public TsLlvmPattern if (!isCaptured) { auto tsStorageType = referenceType.getElementType(); - if (isa(tsStorageType) || isa(tsStorageType) || isa(tsStorageType) || - isa(tsStorageType) || isa(tsStorageType) || - isa(tsStorageType)) + if (isa(tsStorageType) || + isa(tsStorageType) || + isa(tsStorageType) || + isa(tsStorageType) || + isa(tsStorageType) || + isa(tsStorageType)) { TypeHelper th(rewriter); @@ -2743,7 +2754,10 @@ struct VariableOpLowering : public TsLlvmPattern // Not under own, which has no count to take: such a cell borrows the value, and its // release frees the cell alone (OWN_CELL_BORROWS_ATTR_NAME, which ownership inference // sets, or rejects the program). - if (isCaptured && tsLlvmContext->compileOptions.isRefCounted() && + // A handle is counted under own too (spec 23.2). + auto counted = MLIRTypeHelper::isSharedHandleType(referenceType.getElementType()); + if (isCaptured && + (tsLlvmContext->compileOptions.isRefCounted() || (tsLlvmContext->compileOptions.tracksOwnership() && counted)) && !varOp->hasAttr(CAPTURE_BOX_ATTR_NAME) && !varOp->hasAttr(OWNED_LOCAL_ATTR_NAME)) { OwnershipRoutineLogic orl(varOp, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); diff --git a/tslang/lib/TypeScript/MLIRGenImpl.h b/tslang/lib/TypeScript/MLIRGenImpl.h index ea77a44b7..a7dec4a54 100644 --- a/tslang/lib/TypeScript/MLIRGenImpl.h +++ b/tslang/lib/TypeScript/MLIRGenImpl.h @@ -1057,9 +1057,8 @@ class MLIRGenImpl // Gives a freshly built string, a freshly made `any` box, or a new Shared handle (own spec // 23), 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. + // 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 diff --git a/tslang/lib/TypeScript/OwnershipFacts.h b/tslang/lib/TypeScript/OwnershipFacts.h index 7d1c7c8ea..ba1220c17 100644 --- a/tslang/lib/TypeScript/OwnershipFacts.h +++ b/tslang/lib/TypeScript/OwnershipFacts.h @@ -327,9 +327,34 @@ inline bool isCall(mlir::Operation *op) mlir_ts::InvokeHybridOp>(op); } +// A field, an element, or `s.value` - the payload of a handle (spec 23.1). inline bool isPlace(mlir::Value ref) { - return mlir::isa_and_nonnull(ref.getDefiningOp()); + return mlir::isa_and_nonnull( + ref.getDefiningOp()); +} + +// A handle (spec 23.2): counted in every model that tracks ownership, and left alone by own. +inline bool isHandle(mlir::Value value) +{ + return value && MLIRTypeHelper::isSharedHandleType(value.getType()); +} + +// A retain or release of a handle, or of a slot holding one: rc's, which own leaves in place. +inline bool touchesHandle(mlir::Operation *op) +{ + if (mlir::isa(op)) + { + return isHandle(op->getOperand(0)); + } + + if (mlir::isa(op)) + { + auto slotType = mlir::dyn_cast(op->getOperand(0).getType()); + return slotType && MLIRTypeHelper::isSharedHandleType(slotType.getElementType()); + } + + return false; } // The values the branches into its block pass for `argument`, each seen through its views. diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index c7d320809..7464f309a 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -138,6 +138,14 @@ class OwnershipInferencePass // A view of a block is that block: the candidate is always the root. f.walk([&](mlir::Operation *op) { + // A handle's retains and releases are rc's, counted, and nobody's to decide (spec 23.2). + // An overwrite of a field or an element that holds one is still a drop: the old handle + // may be the last, and its payload goes with it. + if (touchesHandle(op) && !mlir::isa(op)) + { + return; + } + if (auto retainOp = mlir::dyn_cast(op)) { retains.push_back(op); @@ -153,7 +161,7 @@ class OwnershipInferencePass } else if (auto varOp = mlir::dyn_cast(op)) { - if (varOp.getInitializer() && isOwningVariable(varOp)) + if (varOp.getInitializer() && isOwningVariable(varOp) && !isHandle(varOp.getInitializer())) { candidates.insert(rootOf(varOp.getInitializer())); } @@ -191,6 +199,14 @@ class OwnershipInferencePass drops.push_back(op); } } + else if (mlir::isa(op)) + { + // through a handle, it may move the elements another handle's borrow reads (spec 23.3) + if (reachesSharedValue(op->getOperand(0))) + { + drops.push_back(op); + } + } else if (mlir::isa(op) || isCall(op)) @@ -209,7 +225,7 @@ class OwnershipInferencePass for (auto result : op->getResults()) { - if (!isView(op) && isFresh(result) && ownsHeap(result)) + if (!isView(op) && isFresh(result) && ownsHeap(result) && !isHandle(result)) { candidates.insert(result); } @@ -242,6 +258,11 @@ class OwnershipInferencePass llvm::MapVector> paramReceivers; for (auto *op : retains) { + if (touchesHandle(op)) + { + continue; + } + if (captureRetains.contains(op)) { continue; // a borrowing box's copy of a value: decideClosures decides it @@ -340,8 +361,9 @@ class OwnershipInferencePass auto args = callArgs(call); for (auto index : ownedParams(call)) { + // a handle given to a callee that keeps it is a counted copy (spec 23.2) if (static_cast(index) >= args.size() || holdsNoBlock(args[index]) || - isImmortalLiteral(args[index])) + isImmortalLiteral(args[index]) || isHandle(args[index])) { continue; } @@ -384,6 +406,8 @@ class OwnershipInferencePass return failures == 0; } + // a handle's retains and releases are rc's, and stay (spec 23.2) + toErase.remove_if([](mlir::Operation *op) { return touchesHandle(op); }); for (auto *op : toErase) { op->erase(); @@ -1506,9 +1530,10 @@ class OwnershipInferencePass auto root = rootOf(value); captured = ownerName(root); Chain chain; - if (holdsNoBlock(value) || placeReadOf(root) || borrowedParam(root) >= 0) + if (holdsNoBlock(value) || placeReadOf(root) || borrowedParam(root) >= 0 || isHandle(root)) { - // nothing to own, a read the read's check bounds, or a parameter + // nothing to own, a read the read's check bounds, a parameter, or a handle, + // whose copy in the box is counted (spec 23.2) } else if (auto slotLoad = slotLoadOf(root)) { @@ -2317,7 +2342,8 @@ class OwnershipInferencePass { auto root = rootOf(value); auto *def = root.getDefiningOp(); - if (!def || !ownsHeap(root)) + // a handle read out of a place is a counted copy, not a borrow (spec 23.3) + if (!def || !ownsHeap(root) || isHandle(root)) { return nullptr; } @@ -2357,8 +2383,10 @@ class OwnershipInferencePass NotOwned // a parameter, `this`, a global, anything else }; - // a field's position and reference type; an element is position -1 + // a field's position and reference type; an element is position -1, a handle's payload + // (`s.value`) is SharedValue llvm::SmallVector> places; + static constexpr int64_t SharedValue = -2; 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. @@ -2402,6 +2430,11 @@ class OwnershipInferencePass chain.places.push_back({propertyRefOp.getPosition(), propertyRefOp.getType()}); base = propertyRefOp.getObjectRef(); } + else if (auto sharedValueRefOp = ref.getDefiningOp()) + { + chain.places.push_back({Chain::SharedValue, sharedValueRefOp.getType()}); + base = sharedValueRefOp.getShared(); + } else { auto elementRefOp = ref.getDefiningOp(); @@ -2420,6 +2453,19 @@ class OwnershipInferencePass continue; } + // A handle (spec 23.3): other handles may reach its block, so this function owns + // nothing under it. A handle read out of a place still has that place above it. + if (isHandle(base)) + { + chain.roots.push_back({base, Chain::NotOwned}); + if (auto loadOp = base.getDefiningOp(); loadOp && isPlace(loadOp.getReference())) + { + refs.push_back(loadOp.getReference()); + } + + continue; + } + if (isPlace(base)) { refs.push_back(base); // a field of a value-type field: the same object @@ -2470,6 +2516,49 @@ class OwnershipInferencePass return chain; } + // Is this reference a place reached through a handle's payload (`s.value.items`), at any depth? + static bool reachesSharedValue(mlir::Value ref) + { + llvm::SmallVector work{ref}; + llvm::SmallPtrSet seen; + while (!work.empty()) + { + auto value = rootOf(work.pop_back_val()); + if (!seen.insert(value).second) + { + continue; + } + + if (value.getDefiningOp()) + { + return true; + } + + if (auto propertyRefOp = value.getDefiningOp()) + { + work.push_back(propertyRefOp.getObjectRef()); + } + else if (auto elementRefOp = value.getDefiningOp()) + { + work.push_back(elementRefOp.getArray()); + } + else if (auto loadOp = value.getDefiningOp(); loadOp && isPlace(loadOp.getReference())) + { + work.push_back(loadOp.getReference()); + } + else if (auto argument = mlir::dyn_cast(value)) + { + llvm::SmallVector merged; + if (mergedInto(argument, merged)) + { + work.append(merged.begin(), merged.end()); + } + } + } + + return false; + } + // 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. @@ -2478,6 +2567,33 @@ class OwnershipInferencePass llvm::SmallVector ends; for (auto [root, kind] : chain.roots) { + // A handle (spec 23.3), never owned: where this one is given back or overwritten - a + // local's release and assignments, a temporary's release; a global's assignments are + // below. A copy of it is counted, and moves nothing away. + if (isHandle(root)) + { + auto loadOp = root.getDefiningOp(); + if (auto varOp = loadOp ? loadOp.getReference().getDefiningOp() : mlir_ts::VariableOp()) + { + for (auto *user : varOp->getUsers()) + { + if (mlir::isa(user)) + { + ends.push_back(user); + } + } + } + else if (!loadOp) + { + forEachUse(root, [&](mlir::Operation *use, mlir::Value) { + if (mlir::isa(use)) + { + ends.push_back(use); + } + }); + } + } + if (kind == Chain::Slot) { for (auto *user : root.getUsers()) @@ -2642,6 +2758,12 @@ class OwnershipInferencePass { position = propertyRefOp.getPosition(); } + else if (slot.getDefiningOp()) + { + // `x.value = ...` through any handle may replace the payload another one reads + // (spec 23.3) + position = Chain::SharedValue; + } return llvm::any_of(chain.places, [&](auto &place) { return place.first == position && place.second == slot.getType(); @@ -2670,7 +2792,7 @@ class OwnershipInferencePass }); } - // pop, shift, splice, `length =` + // pop, shift, splice, `length =`; push and unshift on an array reached through a handle auto refType = mlir::dyn_cast(drop->getOperand(0).getType()); auto arrayType = refType ? mlir::dyn_cast(refType.getElementType()) : mlir_ts::ArrayType(); if (!arrayType) @@ -2861,6 +2983,11 @@ class OwnershipInferencePass base = elementRefOp.getArray(); step = "[]"; } + else if (auto sharedValueRefOp = ref.getDefiningOp()) + { + base = sharedValueRefOp.getShared(); + step = ".value"; + } else { return {}; diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index 6f1e5d119..9317c48eb 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -1010,6 +1010,12 @@ class OwnershipSignaturePass : public mlir::PassWrapper= 0 && index != found)) { @@ -1047,6 +1053,12 @@ class OwnershipSignaturePass : public mlir::PassWrapper(user)) + { + return sharedNewOp.getValue() == used; + } + if (auto unshiftOp = mlir::dyn_cast(user)) { return llvm::is_contained(unshiftOp.getItems(), used); @@ -1080,6 +1092,12 @@ class OwnershipSignaturePass : public mlir::PassWrapper(op)) + // A handle given back may be the last one, and its payload goes with it, and with that + // anything the payload owns (spec 23.3). + if (mlir::isa(op) && touchesHandle(op)) + { + drops = true; + } + else if (auto releaseSlotOp = mlir::dyn_cast(op)) { // a captured variable assigned through its cell, by a closure's body: the variable // is its declaring function's @@ -1765,6 +1789,12 @@ class OwnershipSignaturePass : public mlir::PassWrapper()) + { + return true; + } + if (auto propertyRefOp = value.getDefiningOp()) { work.push_back(propertyRefOp.getObjectRef()); diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index a9837dac9..739e13629 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2612,7 +2612,7 @@ add_test(NAME test-own-string-copy-in-store-block # Shared (spec 23): counted handles. These are not in the own_test list, whose # test-own-no-counting check forbids __tslang_inc_ref: a handle is counted under own by design. # own_shared_user_named: a program's own class named Shared wins over the built-in (spec 23.1). -set(shared_models gc none rc) +set(shared_models gc none rc own) foreach(shared_mm ${shared_models}) foreach(shared_test own_shared_basic own_shared_user_named own_shared_graph) tslang_add_test(NAME test-jit-${shared_mm}-${shared_test} COMMAND test-runner -jit -mm=${shared_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${shared_test}.ts") @@ -2620,10 +2620,20 @@ foreach(shared_mm ${shared_models}) endforeach() endforeach() # Shared.count is a count only under rc and own. -foreach(shared_count_mm rc) +foreach(shared_count_mm rc own) tslang_add_test(NAME test-jit-${shared_count_mm}-own_shared_count COMMAND test-runner -jit -mm=${shared_count_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_shared_count.ts") tslang_add_test(NAME test-compile-${shared_count_mm}-own_shared_count COMMAND test-runner -mm=${shared_count_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_shared_count.ts") endforeach() +# --verify-ownership is quiet on every positive (spec 23.5): handles keep rc's pairing. +foreach(shared_verify_mm own rc) + foreach(shared_test own_shared_basic own_shared_graph own_shared_count) + add_test(NAME test-${shared_verify_mm}-verify-ownership-${shared_test} + COMMAND $ --emit=mlir-affine --no-default-lib -mm=${shared_verify_mm} --verify-ownership + "${PROJECT_SOURCE_DIR}/test/tester/own/${shared_test}.ts") + set_tests_properties(test-${shared_verify_mm}-verify-ownership-${shared_test} + PROPERTIES FAIL_REGULAR_EXPRESSION "error|Stack dump") + endforeach() +endforeach() # Across a library boundary: the library exports its functions' `__own_no_drops`, and a borrow held # across a call to one survives. Run as a -shared pair (the library loaded at run time) and, on @@ -2747,7 +2757,11 @@ set(own_error_cases "own_err_record_literal_given|an object literal that holds a value it owns is moved only into a local or returned as it is" "own_err_string_kept_param_known|is used here after its value was moved" "own_err_string_call_keeps_twice|borrows a field and cannot be stored, returned or captured" - "own_err_string_pushed_twice|is used here after its value was moved") + "own_err_string_pushed_twice|is used here after its value was moved" + "own_err_shared_borrow_write|but is used here after it may be released or overwritten" + "own_err_shared_borrow_call|but is used here after it may be released or overwritten" + "own_err_shared_borrow_push|but is used here after it may be released or overwritten" + "own_err_shared_moved_in|is used here after its value was moved") foreach(own_error_case ${own_error_cases}) string(FIND "${own_error_case}" "|" own_error_sep) string(SUBSTRING "${own_error_case}" 0 ${own_error_sep} own_error_name) diff --git a/tslang/test/tester/own/own_err_shared_borrow_call.ts b/tslang/test/tester/own/own_err_shared_borrow_call.ts new file mode 100644 index 000000000..a0a59e90d --- /dev/null +++ b/tslang/test/tester/own/own_err_shared_borrow_call.ts @@ -0,0 +1,20 @@ +// -mm=own rejects (spec 23.3): a borrow read through a handle, used after a call that may drop - +// `touch` writes through a handle it reaches from a global. +class Node { + v = 0; +} + +let kept: Shared | null = null; + +function touch() { + const k = kept; + if (k) k.value = new Node(); +} + +function main() { + const a = new Shared(new Node()); + kept = a; + const n = a.value; + touch(); + print(n.v); +} diff --git a/tslang/test/tester/own/own_err_shared_borrow_push.ts b/tslang/test/tester/own/own_err_shared_borrow_push.ts new file mode 100644 index 000000000..9e8254876 --- /dev/null +++ b/tslang/test/tester/own/own_err_shared_borrow_push.ts @@ -0,0 +1,18 @@ +// -mm=own rejects (spec 23.3): an element borrowed through one handle, used after a push through +// another, which may move the elements. +class Node { + v = 0; +} + +class Bag { + items: Node[] = []; +} + +function main() { + const a = new Shared(new Bag()); + const b = a; + a.value.items.push(new Node()); + const first = a.value.items[0]; + b.value.items.push(new Node()); + print(first.v); +} diff --git a/tslang/test/tester/own/own_err_shared_borrow_write.ts b/tslang/test/tester/own/own_err_shared_borrow_write.ts new file mode 100644 index 000000000..6cbbed831 --- /dev/null +++ b/tslang/test/tester/own/own_err_shared_borrow_write.ts @@ -0,0 +1,13 @@ +// -mm=own rejects (spec 23.3): a borrow read through one handle, used after a write through another +// handle that may reach the same block. +class Node { + v = 0; +} + +function main() { + const a = new Shared(new Node()); + const b = a; + const n = a.value; + b.value = new Node(); + print(n.v); +} diff --git a/tslang/test/tester/own/own_err_shared_moved_in.ts b/tslang/test/tester/own/own_err_shared_moved_in.ts new file mode 100644 index 000000000..e388aacb0 --- /dev/null +++ b/tslang/test/tester/own/own_err_shared_moved_in.ts @@ -0,0 +1,11 @@ +// -mm=own rejects: a class value given to `new Shared(x)` moves into the block, so it cannot be used +// after (spec 23.1, 2.3). +class Node { + v = 0; +} + +function main() { + const n = new Node(); + const s = new Shared(n); + print(n.v, s.value.v); +} diff --git a/tslang/test/tester/own/own_shared_count.ts b/tslang/test/tester/own/own_shared_count.ts index c46fa8054..4cf7ada9d 100644 --- a/tslang/test/tester/own/own_shared_count.ts +++ b/tslang/test/tester/own/own_shared_count.ts @@ -1,5 +1,6 @@ // Shared (spec 23.1): Shared.count after each copy and drop - a handle made, copied, given to a -// function that keeps it, returned from one, reassigned to the block it holds, and copied in a loop. +// function that keeps it, returned from one, reassigned to the block it holds, copied in a loop, held +// by another block that dies, and replaced in a block by `s.value = x`. class Node { v = 0; } @@ -52,5 +53,19 @@ function main() { assert(Shared.count(a) == 1, "every copy in the loop dropped"); + // the payload is released when the outer block dies + const inner = new Shared(new Node()); + let outer = new Shared(inner); + assert(Shared.count(inner) == 2, "an outer block holds inner"); + outer = new Shared(new Shared(new Node())); + assert(Shared.count(inner) == 1, "the payload released when the outer block died"); + + // `s.value = x` releases the old payload and takes a count of the new one + const other = new Shared(new Node()); + const o2 = new Shared(inner); + assert(Shared.count(inner) == 2, "o2 holds inner"); + o2.value = other; + assert(Shared.count(inner) == 1 && Shared.count(other) == 2, "s.value = x hands over"); + print("done."); } From 7681df0b25c88cbcc15c617eced5169431a198e6 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 3 Oct 2026 11:45:10 +0100 Subject: [PATCH 06/21] -mm=own Shared: a constructor filling a handle field is not a drop (spec 23.3) dropsInBody marked every ReleaseSlot of a slot holding a handle as a drop, ahead of the place rule, so a constructor initialising a Shared | null field lost __own_no_drops and `new C()` ended unrelated borrows in its caller. A handle held by a field or an element is now judged as any place overwrite is; the handle rule applies to ts.Release and to non-place slots. Co-Authored-By: Claude Opus 5.5 --- tslang/lib/TypeScript/OwnershipSignaturePass.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index 9317c48eb..1704c9e7e 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -1740,8 +1740,10 @@ class OwnershipSignaturePass : public mlir::PassWrapper(op) && touchesHandle(op)) + // anything the payload owns (spec 23.3). One held by a field or an element is a place's + // overwrite, judged below like any other: a constructor filling its own `this` is not + // a drop. + if (mlir::isa(op) && touchesHandle(op)) { drops = true; } @@ -1751,7 +1753,7 @@ class OwnershipSignaturePass : public mlir::PassWrapper() || (isPlace(slot) && reachesOutside(slot, funcOp)) || - isLoadedCell(slot); + isLoadedCell(slot) || (!isPlace(slot) && touchesHandle(op)); } else if (mlir::isa(op)) { From eda425735df4e8e28e0f29bd8973d01f91fd0744 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 3 Oct 2026 12:39:16 +0100 Subject: [PATCH 07/21] -mm=own Shared: any write through any handle ends a borrow; handle cells counted (spec 23.3) - dropsChain: a store into `x.value` ends every borrow read through a handle, whatever the handles' static types (a Shared converts to a Shared, so matching by type compiled a use-after-free). - chainOf: the root of a `s.value` is a handle because of the op, not its type (a view may hide it); merges into it are each handles; rootEnds keys on that. - decideCells: a cell holding a handle is not a borrowing cell; its release gives back the count lowering takes for it (a captured handle parameter leaked one count per call). decideEscaping lets an escaping closure take such a cell. - dropsInBody: push/unshift on an array reached through a handle is a drop (reachesSharedValue, now in OwnershipFacts.h). - Tests: own_err_shared_borrow_other_type, own_err_shared_borrow_push_call, own_err_shared_borrow_reassigned; own_shared_count asserts the counts of a captured and an escaping-captured handle parameter. Co-Authored-By: Claude Opus 5.5 --- tslang/lib/TypeScript/OwnershipFacts.h | 44 +++++++++ .../lib/TypeScript/OwnershipInferencePass.cpp | 94 ++++++++----------- .../lib/TypeScript/OwnershipSignaturePass.cpp | 5 + tslang/test/tester/CMakeLists.txt | 5 +- .../own/own_err_shared_borrow_other_type.ts | 30 ++++++ .../own/own_err_shared_borrow_push_call.ts | 21 +++++ .../own/own_err_shared_borrow_reassigned.ts | 12 +++ tslang/test/tester/own/own_shared_count.ts | 27 +++++- 8 files changed, 181 insertions(+), 57 deletions(-) create mode 100644 tslang/test/tester/own/own_err_shared_borrow_other_type.ts create mode 100644 tslang/test/tester/own/own_err_shared_borrow_push_call.ts create mode 100644 tslang/test/tester/own/own_err_shared_borrow_reassigned.ts diff --git a/tslang/lib/TypeScript/OwnershipFacts.h b/tslang/lib/TypeScript/OwnershipFacts.h index ba1220c17..67c917cea 100644 --- a/tslang/lib/TypeScript/OwnershipFacts.h +++ b/tslang/lib/TypeScript/OwnershipFacts.h @@ -7,6 +7,7 @@ #include "mlir/Interfaces/ControlFlowInterfaces.h" +#include "llvm/ADT/SmallPtrSet.h" #include "llvm/ADT/SmallVector.h" #include "llvm/ADT/STLFunctionalExtras.h" @@ -398,6 +399,49 @@ inline bool mergedInto(mlir::BlockArgument argument, llvm::SmallVectorImpl work{ref}; + llvm::SmallPtrSet seen; + while (!work.empty()) + { + auto value = rootOf(work.pop_back_val()); + if (!seen.insert(value).second) + { + continue; + } + + if (value.getDefiningOp()) + { + return true; + } + + if (auto propertyRefOp = value.getDefiningOp()) + { + work.push_back(propertyRefOp.getObjectRef()); + } + else if (auto elementRefOp = value.getDefiningOp()) + { + work.push_back(elementRefOp.getArray()); + } + else if (auto loadOp = value.getDefiningOp(); loadOp && isPlace(loadOp.getReference())) + { + work.push_back(loadOp.getReference()); + } + else if (auto argument = mlir::dyn_cast(value)) + { + llvm::SmallVector merged; + if (mergedInto(argument, merged)) + { + work.append(merged.begin(), merged.end()); + } + } + } + + return false; +} + // 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. diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index 7464f309a..5099afb7a 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -1649,8 +1649,10 @@ class OwnershipInferencePass auto name = varName(varOp); MLIRTypeHelper mth(&getContext(), CompileOptions{}); - if (!isOwningVariable(varOp) && !closure.borrowsValues && - mth.ownsHeapMemory(varOp.getLoc(), mlir::cast(value.getType()).getElementType())) + auto elementType = mlir::cast(value.getType()).getElementType(); + // a handle's cell holds a count of its own, whoever's the value was (spec 23.2) + if (!isOwningVariable(varOp) && !closure.borrowsValues && mth.ownsHeapMemory(varOp.getLoc(), elementType) && + !MLIRTypeHelper::isSharedHandleType(elementType)) { reportEscapingCellNotOwned(move, closure.escape, "its value is the caller's"); ok = false; @@ -1734,7 +1736,15 @@ class OwnershipInferencePass } MLIRTypeHelper mth(&getContext(), CompileOptions{}); - if (!mth.ownsHeapMemory(varOp.getLoc(), mlir::cast(cell.getType()).getElementType())) + auto elementType = mlir::cast(cell.getType()).getElementType(); + if (!mth.ownsHeapMemory(varOp.getLoc(), elementType)) + { + return; + } + + // A handle's cell holds a count of its own, taken where it is made (spec 23.2): its + // release gives that back, as under rc, and an assignment is a counted copy. + if (MLIRTypeHelper::isSharedHandleType(elementType)) { return; } @@ -2388,6 +2398,9 @@ class OwnershipInferencePass llvm::SmallVector> places; static constexpr int64_t SharedValue = -2; llvm::SmallVector> roots; + // The roots that are handles - what a `s.value` was read through - each also NotOwned + // among `roots` (spec 23.3). Known from the op, not the root's type: a view may hide it. + llvm::SmallVector handles; // 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; @@ -2414,6 +2427,8 @@ class OwnershipInferencePass } llvm::SmallPtrSet seen; + // the bases a `s.value` was read through, and what merges into them + llvm::SmallPtrSet handleBases; while (!refs.empty() || !first.empty()) { llvm::SmallVector bases; @@ -2434,6 +2449,7 @@ class OwnershipInferencePass { chain.places.push_back({Chain::SharedValue, sharedValueRefOp.getType()}); base = sharedValueRefOp.getShared(); + handleBases.insert(rootOf(base)); } else { @@ -2454,10 +2470,20 @@ class OwnershipInferencePass } // A handle (spec 23.3): other handles may reach its block, so this function owns - // nothing under it. A handle read out of a place still has that place above it. - if (isHandle(base)) + // nothing under it. A handle read out of a place still has that place above it, and + // one merged from several has each of them. + if (handleBases.contains(base) || isHandle(base)) { + llvm::SmallVector merged; + if (auto argument = mlir::dyn_cast(base); argument && mergedInto(argument, merged)) + { + handleBases.insert(merged.begin(), merged.end()); + bases.append(merged.begin(), merged.end()); + continue; + } + chain.roots.push_back({base, Chain::NotOwned}); + chain.handles.push_back(base); if (auto loadOp = base.getDefiningOp(); loadOp && isPlace(loadOp.getReference())) { refs.push_back(loadOp.getReference()); @@ -2516,49 +2542,6 @@ class OwnershipInferencePass return chain; } - // Is this reference a place reached through a handle's payload (`s.value.items`), at any depth? - static bool reachesSharedValue(mlir::Value ref) - { - llvm::SmallVector work{ref}; - llvm::SmallPtrSet seen; - while (!work.empty()) - { - auto value = rootOf(work.pop_back_val()); - if (!seen.insert(value).second) - { - continue; - } - - if (value.getDefiningOp()) - { - return true; - } - - if (auto propertyRefOp = value.getDefiningOp()) - { - work.push_back(propertyRefOp.getObjectRef()); - } - else if (auto elementRefOp = value.getDefiningOp()) - { - work.push_back(elementRefOp.getArray()); - } - else if (auto loadOp = value.getDefiningOp(); loadOp && isPlace(loadOp.getReference())) - { - work.push_back(loadOp.getReference()); - } - else if (auto argument = mlir::dyn_cast(value)) - { - llvm::SmallVector merged; - if (mergedInto(argument, merged)) - { - work.append(merged.begin(), merged.end()); - } - } - } - - return false; - } - // 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. @@ -2570,7 +2553,7 @@ class OwnershipInferencePass // A handle (spec 23.3), never owned: where this one is given back or overwritten - a // local's release and assignments, a temporary's release; a global's assignments are // below. A copy of it is counted, and moves nothing away. - if (isHandle(root)) + if (llvm::is_contained(chain.handles, root)) { auto loadOp = root.getDefiningOp(); if (auto varOp = loadOp ? loadOp.getReference().getDefiningOp() : mlir_ts::VariableOp()) @@ -2753,17 +2736,18 @@ class OwnershipInferencePass return !chain.owned(); } + // `x.value = ...` through any handle, of any type, may replace the payload another + // handle reads (spec 23.3): a handle converts to one of a base class's type + if (slot.getDefiningOp()) + { + return llvm::any_of(chain.places, [&](auto &place) { return place.first == Chain::SharedValue; }); + } + int64_t position = -1; if (auto propertyRefOp = slot.getDefiningOp()) { position = propertyRefOp.getPosition(); } - else if (slot.getDefiningOp()) - { - // `x.value = ...` through any handle may replace the payload another one reads - // (spec 23.3) - position = Chain::SharedValue; - } return llvm::any_of(chain.places, [&](auto &place) { return place.first == position && place.second == slot.getType(); diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index 1704c9e7e..8abad45ad 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -1759,6 +1759,11 @@ class OwnershipSignaturePass : public mlir::PassWrappergetOperand(0), funcOp); } + else if (mlir::isa(op)) + { + // through a handle, it may move the elements another handle's borrow reads (spec 23.3) + drops = reachesSharedValue(op->getOperand(0)); + } else if (mlir::isa(op)) { drops = true; diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 739e13629..3b7a100ad 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2761,7 +2761,10 @@ set(own_error_cases "own_err_shared_borrow_write|but is used here after it may be released or overwritten" "own_err_shared_borrow_call|but is used here after it may be released or overwritten" "own_err_shared_borrow_push|but is used here after it may be released or overwritten" - "own_err_shared_moved_in|is used here after its value was moved") + "own_err_shared_moved_in|is used here after its value was moved" + "own_err_shared_borrow_other_type|but is used here after it may be released or overwritten" + "own_err_shared_borrow_push_call|but is used here after it may be released or overwritten" + "own_err_shared_borrow_reassigned|but is used here after it may be released or overwritten") foreach(own_error_case ${own_error_cases}) string(FIND "${own_error_case}" "|" own_error_sep) string(SUBSTRING "${own_error_case}" 0 ${own_error_sep} own_error_name) diff --git a/tslang/test/tester/own/own_err_shared_borrow_other_type.ts b/tslang/test/tester/own/own_err_shared_borrow_other_type.ts new file mode 100644 index 000000000..107f0c2db --- /dev/null +++ b/tslang/test/tester/own/own_err_shared_borrow_other_type.ts @@ -0,0 +1,30 @@ +// -mm=own rejects (spec 23.3): a borrow read through one handle, used after a write through a +// handle of another static type that reaches the same block - a Shared converts to a +// Shared - so any write through any handle ends it. +class Base { + v = 0; +} + +class Derived extends Base { + w = 1; +} + +class Box { + items: Derived[] = []; +} + +function main() { + const a = new Shared(new Derived()); + const b: Shared = a; + const n = a.value; + b.value = new Base(); + const box = new Box(); + for (let i = 0; i < 100; i++) { + const d = new Derived(); + d.w = 77; + d.v = 66; + box.items.push(d); + } + + print(n.v, n.w, Shared.count(a), box.items.length); +} diff --git a/tslang/test/tester/own/own_err_shared_borrow_push_call.ts b/tslang/test/tester/own/own_err_shared_borrow_push_call.ts new file mode 100644 index 000000000..d03274281 --- /dev/null +++ b/tslang/test/tester/own/own_err_shared_borrow_push_call.ts @@ -0,0 +1,21 @@ +// -mm=own rejects (spec 23.3): an element borrowed through a handle, used after a call that pushes +// through another handle to the same block, which may move the elements. +class Node { + v = 0; +} + +class Bag { + items: Node[] = []; +} + +function grow(s: Shared) { + s.value.items.push(new Node()); +} + +function main() { + const a = new Shared(new Bag()); + a.value.items.push(new Node()); + const first = a.value.items[0]; + grow(a); + print(first.v); +} diff --git a/tslang/test/tester/own/own_err_shared_borrow_reassigned.ts b/tslang/test/tester/own/own_err_shared_borrow_reassigned.ts new file mode 100644 index 000000000..ea5790926 --- /dev/null +++ b/tslang/test/tester/own/own_err_shared_borrow_reassigned.ts @@ -0,0 +1,12 @@ +// -mm=own rejects (spec 23.3): a borrow read through a handle, used after that handle is given a +// new block - the old one may have been the last handle, and its payload went with it. +class Node { + v = 0; +} + +function main() { + let a = new Shared(new Node()); + const n = a.value; + a = new Shared(new Node()); + print(n.v); +} diff --git a/tslang/test/tester/own/own_shared_count.ts b/tslang/test/tester/own/own_shared_count.ts index 4cf7ada9d..4bb441756 100644 --- a/tslang/test/tester/own/own_shared_count.ts +++ b/tslang/test/tester/own/own_shared_count.ts @@ -1,6 +1,7 @@ // Shared (spec 23.1): Shared.count after each copy and drop - a handle made, copied, given to a // function that keeps it, returned from one, reassigned to the block it holds, copied in a loop, held -// by another block that dies, and replaced in a block by `s.value = x`. +// by another block that dies, replaced in a block by `s.value = x`, and captured as a parameter by a +// closure and by one that escapes. class Node { v = 0; } @@ -23,6 +24,19 @@ function make() { return new Shared(new Node()); } +// a handle parameter captured by a closure: its cell holds a count, and gives it back +function capture(s: Shared) { + let t = 1; + const g = () => s.value.v + t; + t = 2; + return g(); +} + +// a closure that escapes with a handle parameter: its box holds the count +function reader(s: Shared) { + return () => s.value.v; +} + function main() { const a = new Shared(new Node()); assert(Shared.count(a) == 1, "one handle"); @@ -67,5 +81,16 @@ function main() { o2.value = other; assert(Shared.count(inner) == 1 && Shared.count(other) == 2, "s.value = x hands over"); + capture(a); + capture(a); + assert(Shared.count(a) == 1, "a captured parameter's count given back"); + + for (let i = 0; i < 100; i++) { + const r = reader(a); + assert(r() == 0 && Shared.count(a) == 2, "held by an escaping closure"); + } + + assert(Shared.count(a) == 1, "every escaping closure gave its count back"); + print("done."); } From 8b5b89396a98cc598be8adc099d51145a6ccad4d Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 3 Oct 2026 12:54:38 +0100 Subject: [PATCH 08/21] -mm=own Shared: handles in containers, any and closures; cycles (spec 23.1, 23.4) - A handle's typeof name is "object", so it has a type descriptor; under own the descriptor keeps its retain routine (a handle is counted). - `Shared | null` boxes into `any` under the handle's descriptor (it had no tag at all, and crashed the compiler). - `>any` goes through ___unbox like a class, checked by the typeof name only. - Shared.count accepts a `Shared | null` or optional narrowed by `if (s)`, which keeps the union's type. - Tests: own_shared_containers and own_shared_cycle under gc, none, rc and own, AOT and JIT, plus --verify-ownership; own_shared_count asserts the narrowed union and the any round trip. Co-Authored-By: Claude Opus 5.5 --- .../TypeScript/LowerToLLVM/CastLogicHelper.h | 6 +++ .../TypeScript/MLIRLogic/TypeOfOpHelper.h | 7 +++ tslang/lib/TypeScript/LowerToLLVM.cpp | 11 ++-- tslang/lib/TypeScript/MLIRGenCast.cpp | 8 ++- tslang/lib/TypeScript/MLIRGenExpressions.cpp | 25 ++++++++++ tslang/test/tester/CMakeLists.txt | 4 +- .../test/tester/own/own_shared_containers.ts | 50 +++++++++++++++++++ tslang/test/tester/own/own_shared_count.ts | 27 +++++++++- tslang/test/tester/own/own_shared_cycle.ts | 26 ++++++++++ 9 files changed, 156 insertions(+), 8 deletions(-) create mode 100644 tslang/test/tester/own/own_shared_containers.ts create mode 100644 tslang/test/tester/own/own_shared_cycle.ts diff --git a/tslang/include/TypeScript/LowerToLLVM/CastLogicHelper.h b/tslang/include/TypeScript/LowerToLLVM/CastLogicHelper.h index bb2327ede..bc9ad98e8 100644 --- a/tslang/include/TypeScript/LowerToLLVM/CastLogicHelper.h +++ b/tslang/include/TypeScript/LowerToLLVM/CastLogicHelper.h @@ -1175,6 +1175,12 @@ class CastLogicHelper LLVM_DEBUG(llvm::dbgs() << "\n!! union storage type: " << storageType << "\n";); valueForBoxing = rewriter.create(loc, storageType, in); } + else if (MLIRTypeHelper::isSharedHandleType(unionType)) + { + // `Shared | null` (own spec 23.1) is one pointer, boxed under the handle's own + // descriptor: the union has no `typeof` name, so it had no tag at all + typeOfValue = toh.typeOfLogic(loc, baseType); + } else { typeOfValue = toh.typeOfLogic(loc, inType); diff --git a/tslang/include/TypeScript/MLIRLogic/TypeOfOpHelper.h b/tslang/include/TypeScript/MLIRLogic/TypeOfOpHelper.h index 0527ac090..c93cf40b5 100644 --- a/tslang/include/TypeScript/MLIRLogic/TypeOfOpHelper.h +++ b/tslang/include/TypeScript/MLIRLogic/TypeOfOpHelper.h @@ -193,6 +193,13 @@ class TypeOfOpHelper return "object"; } + // A Shared handle (own spec 23.2) is what JavaScript's `typeof` calls a wrapper object. + // Its descriptor is keyed by the type, so Shared and Shared still get distinct ones. + if (isa(type)) + { + return "object"; + } + if (isa(type)) { return "symbol"; diff --git a/tslang/lib/TypeScript/LowerToLLVM.cpp b/tslang/lib/TypeScript/LowerToLLVM.cpp index 44154baaa..a01c87f55 100644 --- a/tslang/lib/TypeScript/LowerToLLVM.cpp +++ b/tslang/lib/TypeScript/LowerToLLVM.cpp @@ -400,10 +400,13 @@ class TypeDescriptorOpLowering : public TsLlvmPattern auto releaseRoutineName = orl.getOrCreateReleaseRoutine(descriptorType); // Under own nothing retains: the descriptor's retain slot is read only from inside // retain routines (retainViaDescriptor), none of which can run, and building one would - // bring `__tslang_inc_ref` into a model that promises it is never referenced. - auto retainRoutineName = tsLlvmContext->compileOptions.memoryModel == MemoryModelOwn - ? std::string() - : orl.getOrCreateRetainRoutine(descriptorType); + // bring `__tslang_inc_ref` into a model that promises it is never referenced. A Shared + // handle is the exception: own counts it (spec 23.2), so its retain is kept. + auto retainRoutineName = + tsLlvmContext->compileOptions.memoryModel == MemoryModelOwn && + !MLIRTypeHelper::isSharedHandleType(descriptorType) + ? std::string() + : orl.getOrCreateRetainRoutine(descriptorType); rewriter.replaceOp(op, ch.getOrCreateTypeDescriptorName(descriptorType, name, TypeOfOpHelper::typeKindFromName(name), diff --git a/tslang/lib/TypeScript/MLIRGenCast.cpp b/tslang/lib/TypeScript/MLIRGenCast.cpp index 2ecb6e0cd..2a47e6afd 100644 --- a/tslang/lib/TypeScript/MLIRGenCast.cpp +++ b/tslang/lib/TypeScript/MLIRGenCast.cpp @@ -1541,7 +1541,9 @@ namespace mlirgen || isa(type) || isa(type) || isa(type) - || isa(type)) + || isa(type) + // a Shared handle (own spec 23.1) is checked by its `typeof` name, "object" + || isa(type)) { return castPrimitiveTypeFromAny(location, type, value, genContext); } @@ -1905,6 +1907,10 @@ namespace mlirgen .Case([&](auto _) { typeOfs["function"] = true; }) .Case([&](auto classType_) { typeOfs["class"] = true; classInstances.push_back(classType_); }) .Case([&](auto _) { typeOfs["interface"] = true; }) + // A handle's tag names it "object" (TypeOfOpHelper), and there is no `instanceof` for a + // handle, so the name is all that is checked: an `any` holding a Shared passes for a + // Shared, as would any other value tagged "object" (own spec 23.7). + .Case([&](auto _) { typeOfs["object"] = true; }) // TODO: we can't use null type here and undefined otherwise code will be cycling // due to issue with TypeOf == 'null' as it should denounce UnionType into Single Type // review code to use null in "TypeGuard" diff --git a/tslang/lib/TypeScript/MLIRGenExpressions.cpp b/tslang/lib/TypeScript/MLIRGenExpressions.cpp index 8a0846a62..a5af69853 100644 --- a/tslang/lib/TypeScript/MLIRGenExpressions.cpp +++ b/tslang/lib/TypeScript/MLIRGenExpressions.cpp @@ -1098,6 +1098,31 @@ namespace mlirgen auto result = mlirGen(callExpression->arguments.front(), genContext); EXIT_IF_FAILED_OR_NO_VALUE(result) auto handle = V(result); + // `Shared | null` narrowed by `if (s)`, which keeps the union's type: unwrapped to the + // handle, as `.value` collapses a tag-free union (mlirGenPropertyAccessExpressionBaseLogic) + if (MLIRTypeHelper::isSharedHandleType(handle.getType()) && !isa(handle.getType())) + { + auto handleType = handle.getType(); + if (auto optionalType = dyn_cast(handleType)) + { + handleType = optionalType.getElementType(); + } + + mlir::Type baseType; + if (auto unionType = dyn_cast(handleType)) + { + if (!mth.isUnionTypeNeedsTag(location, unionType, baseType)) + { + handleType = baseType; + } + } + + if (isa(handleType)) + { + CAST(handle, location, handleType, handle, genContext); + } + } + if (!isa(handle.getType())) { emitError(location, "Shared.count takes a Shared, not ") << to_print(handle.getType()); diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 3b7a100ad..6feb29c66 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2614,7 +2614,7 @@ add_test(NAME test-own-string-copy-in-store-block # own_shared_user_named: a program's own class named Shared wins over the built-in (spec 23.1). set(shared_models gc none rc own) foreach(shared_mm ${shared_models}) - foreach(shared_test own_shared_basic own_shared_user_named own_shared_graph) + foreach(shared_test own_shared_basic own_shared_user_named own_shared_graph own_shared_containers own_shared_cycle) tslang_add_test(NAME test-jit-${shared_mm}-${shared_test} COMMAND test-runner -jit -mm=${shared_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${shared_test}.ts") tslang_add_test(NAME test-compile-${shared_mm}-${shared_test} COMMAND test-runner -mm=${shared_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${shared_test}.ts") endforeach() @@ -2626,7 +2626,7 @@ foreach(shared_count_mm rc own) endforeach() # --verify-ownership is quiet on every positive (spec 23.5): handles keep rc's pairing. foreach(shared_verify_mm own rc) - foreach(shared_test own_shared_basic own_shared_graph own_shared_count) + foreach(shared_test own_shared_basic own_shared_graph own_shared_count own_shared_containers own_shared_cycle) add_test(NAME test-${shared_verify_mm}-verify-ownership-${shared_test} COMMAND $ --emit=mlir-affine --no-default-lib -mm=${shared_verify_mm} --verify-ownership "${PROJECT_SOURCE_DIR}/test/tester/own/${shared_test}.ts") diff --git a/tslang/test/tester/own/own_shared_containers.ts b/tslang/test/tester/own/own_shared_containers.ts new file mode 100644 index 000000000..8c824f853 --- /dev/null +++ b/tslang/test/tester/own/own_shared_containers.ts @@ -0,0 +1,50 @@ +// Shared (spec 23) in containers: one node in two arrays, a `Shared | null` field, a closure +// capturing a handle, a handle boxed into `any` and back, and a handle returned from a function. +// Each owner but one is dropped and its memory reused before the last is read. +class Node { + v = 0; + tag = ""; +} + +class Holder { + s: Shared | null = null; +} + +function churn() { + let keep: string[] = []; + for (let i = 0; i < 1000; i++) keep.push("k" + i); + return keep.length; +} + +function make(v: number) { + const s = new Shared(new Node()); + s.value.v = v; + s.value.tag = "t" + v; + return s; +} + +function main() { + const s = make(3); + + let left: Shared[] = [s]; + const right: Shared[] = [s]; + left = []; + assert(churn() == 1000); + assert(right[0].value.v == 3 && right[0].value.tag == "t3", "the other array keeps the node"); + + const h = new Holder(); + h.s = right[0]; + const read = () => s.value.v; + assert(read() == 3, "a closure reads through its handle"); + + const boxed: any = h.s; + const back = >boxed; + assert(back === s, "a handle boxed into any comes back the same"); + assert(back.value.tag == "t3"); + + h.s = null; + assert(churn() == 1000); + assert(read() == 3 && back.value.v == 3, "the closure and the unboxed handle keep the node"); + + print("done."); +} diff --git a/tslang/test/tester/own/own_shared_count.ts b/tslang/test/tester/own/own_shared_count.ts index 4bb441756..85d8fd588 100644 --- a/tslang/test/tester/own/own_shared_count.ts +++ b/tslang/test/tester/own/own_shared_count.ts @@ -1,7 +1,8 @@ // Shared (spec 23.1): Shared.count after each copy and drop - a handle made, copied, given to a // function that keeps it, returned from one, reassigned to the block it holds, copied in a loop, held // by another block that dies, replaced in a block by `s.value = x`, and captured as a parameter by a -// closure and by one that escapes. +// closure and by one that escapes; a narrowed `Shared | null`, and a handle boxed into `any` and +// read back out. class Node { v = 0; } @@ -92,5 +93,29 @@ function main() { assert(Shared.count(a) == 1, "every escaping closure gave its count back"); + // a `Shared | null` narrowed by `if (u)` keeps the union's type, and is still a handle here + let u: Shared | null = null; + u = a; + if (u) { + assert(Shared.count(u) == 2, "a narrowed union is a handle"); + } + + if (u !== null) { + assert(Shared.count(u) == 2, "a union narrowed by !== null is a handle"); + } + + u = null; + assert(Shared.count(a) == 1, "the union's count given back"); + + // an `any` box holds a count, and a handle read back out of it holds its own + for (let i = 0; i < 100; i++) { + const boxed: any = a; + assert(Shared.count(a) == 2, "boxed into any"); + const back = >boxed; + assert(Shared.count(back) == 3, "read back out of any"); + } + + assert(Shared.count(a) == 1, "every box and every handle read out of one gave its count back"); + print("done."); } diff --git a/tslang/test/tester/own/own_shared_cycle.ts b/tslang/test/tester/own/own_shared_cycle.ts new file mode 100644 index 000000000..52a0ab6cd --- /dev/null +++ b/tslang/test/tester/own/own_shared_cycle.ts @@ -0,0 +1,26 @@ +// Shared (spec 23.4): a doubly linked pair and a child with a link to its parent are cycles, +// which are never freed (as under rc); the program still runs. +class Item { + v = 0; + next: Shared | null = null; + prev: Shared | null = null; +} + +function main() { + for (let i = 0; i < 100; i++) { + const a = new Shared(new Item()); + const b = new Shared(new Item()); + a.value.next = b; + b.value.prev = a; + a.value.v = i; + const p = b.value.prev; + assert(p !== null && p.value.v == i); + + const parent = new Shared(new Item()); + const child = new Shared(new Item()); + parent.value.next = child; + child.value.prev = parent; + } + + print("done."); +} From 85f99e2742fda95fa5874b95b9c8ea63ce544616 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 3 Oct 2026 13:12:32 +0100 Subject: [PATCH 09/21] -mm=own Shared: a union of a handle and members that own nothing is a handle (spec 23.2) `Shared | number` compiles since the handle has a type descriptor, but own took it for a single-owner value: it erased the union's count and kept the releases, so counts differed from rc's and a loop corrupted the heap. - isSharedHandleType: a union of at least one Shared whose other members own no heap memory (null, undefined, numbers, literals, other Shared) is a handle, so own leaves its retains and releases as rc's. Its routines dispatch on the tag; a member with no retain routine is skipped. - A handle put into a union with an owning member (`Shared | string`, `Shared | Node`) is rejected under own by the inference pass: "a Shared in a union with '', which -mm=own does not count, is not supported". gc, none and rc accept it. - Tests: own_shared_union (all four models, AOT and JIT, verify-ownership) and the negative own_err_shared_union_owned. Spec 23.2 and 23.6 amended. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 20 ++- .../TypeScript/MLIRLogic/MLIRTypeHelper.h | 43 +++++-- .../lib/TypeScript/OwnershipInferencePass.cpp | 74 +++++++++++ tslang/test/tester/CMakeLists.txt | 7 +- .../tester/own/own_err_shared_union_owned.ts | 12 ++ tslang/test/tester/own/own_shared_union.ts | 117 ++++++++++++++++++ 6 files changed, 259 insertions(+), 14 deletions(-) create mode 100644 tslang/test/tester/own/own_err_shared_union_owned.ts create mode 100644 tslang/test/tester/own/own_shared_union.ts diff --git a/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md b/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md index 884faaffb..87fe4b023 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 @@ -2133,9 +2133,20 @@ print(Shared.count(a), a === b); `ts.RetainSlot`, releases and `__owned_consumed` declarations for a `!ts.shared` value under rc and own, because both track ownership (§3.1). That is unchanged. - **The inference pass leaves every retain and release of a handle in place.** A handle is a value of - type `!ts.shared`, or a tag-free union or an optional of one with `null` or `undefined` - (one pointer, as in §22.1). The pass never erases such a retain or release, and never reports one. A handle's count is therefore exactly rc's, which rc's own - suite tests. + one of these types (`MLIRTypeHelper::isSharedHandleType`): + - `!ts.shared`, or an optional of a handle; + - a union with at least one `Shared` member whose other members own no heap memory: `null`, + `undefined`, a number, a boolean, a literal, another `Shared`. `Shared | null` is one + pointer (§22.1). `Shared | number` is tagged: its routines dispatch on the tag through + descriptors, a `Shared` member's counting and the others' doing nothing. + + The pass never erases such a retain or release, and never reports one. A handle's count is + therefore exactly rc's, which rc's own suite tests. +- **A `Shared` in a union with a member that owns a block** (`Shared | string`, + `Shared | Node`, `Shared[] | Shared`) is rejected under own where a handle is put into + one: `a Shared in a union with '', which -mm=own does not count, is not supported`. + Counting the union would count a single-owner block, and not counting it would lose the + handle's count. gc, none and rc accept it. - **Lowering.** Under own, a surviving retain of a `!ts.shared` value no longer reports "left a retain behind"; it lowers to `__tslang_inc_ref`, as under rc. - **The release routine** of a `!ts.shared` value is the same in every model that tracks @@ -2203,6 +2214,8 @@ change. It runs on every `own_shared_*` test, under own and rc. capturing a handle, a handle boxed into `any` and back, a handle returned from a function. - `own_shared_cycle`: a cycle that is never freed, which still runs: a doubly linked list and a node with a parent link, both cycles that leak (§23.4). + - `own_shared_union`: a handle in a `Shared | number` local, field and element, each switched + to a number and back, with rc's counts under own (§23.2). Each frees every handle but one, then allocates over the freed memory (the churn pattern) before reading through the one left. An early free then reads reused memory. @@ -2213,6 +2226,7 @@ change. It runs on every `own_shared_*` test, under own and rc. - `own_err_shared_borrow_call`: a borrow through a handle used after a call that may drop; - `own_err_shared_moved_in`: a class value given to `new Shared(x)` and then used, which is a use after move. + - `own_err_shared_union_owned`: a handle put into a `Shared | string` (§23.2). - **Teeth:** - with the inference pass erasing a handle's retains as for any value, the positives fail; - with §23.3's rule off, its negatives compile. diff --git a/tslang/include/TypeScript/MLIRLogic/MLIRTypeHelper.h b/tslang/include/TypeScript/MLIRLogic/MLIRTypeHelper.h index c858e9c5c..c60903023 100644 --- a/tslang/include/TypeScript/MLIRLogic/MLIRTypeHelper.h +++ b/tslang/include/TypeScript/MLIRLogic/MLIRTypeHelper.h @@ -2118,8 +2118,11 @@ class MLIRTypeHelper return ownsHeapMemory(location, type, visiting); } - // A handle (own spec 23.2): Shared, or a tag-free union or an optional of one with null or - // undefined - one pointer whose block is counted in every model that tracks ownership. + // A handle (own spec 23.2): a value counted in every model that tracks ownership. That is a + // Shared, an optional of a handle, or a union of at least one Shared whose other members + // own no heap memory: null, undefined, a number, a boolean, a literal, another Shared. + // `Shared | null` is one pointer; `Shared | number` is tagged, and its routines dispatch + // on the tag through descriptors, each Shared member's counting and the others' doing nothing. static bool isSharedHandleType(mlir::Type type) { if (isa(type)) @@ -2134,28 +2137,52 @@ class MLIRTypeHelper if (auto unionType = dyn_cast(type)) { - mlir::Type shared; + auto shared = false; for (auto member : unionType.getTypes()) { - if (isa(member)) + if (isa(member)) { + shared = true; continue; } - if (!isa(member) || (shared && shared != member)) + if (ownsHeapMemoryOfUnionMember(member)) { return false; } - - shared = member; } - return !!shared; + return shared; } return false; } + // A union with a Shared member that is not a handle: another member owns a block of its own + // (`Shared | string`, `Shared | Node`), which -mm=own does not count (spec 23.2). + static bool isSharedInOwningUnionType(mlir::Type type) + { + if (auto optionalType = dyn_cast(type)) + { + return isSharedInOwningUnionType(optionalType.getElementType()); + } + + auto unionType = dyn_cast(type); + return unionType && llvm::any_of(unionType.getTypes(), [](mlir::Type member) { return isa(member); }) && + !isSharedHandleType(unionType); + } + + static bool ownsHeapMemoryOfUnionMember(mlir::Type member) + { + if (isa(member)) + { + return false; + } + + MLIRTypeHelper mth(member.getContext(), CompileOptions{}); + return mth.ownsHeapMemory(mlir::UnknownLoc::get(member.getContext()), member); + } + // Field types of a record-shaped type, empty for anything else. llvm::SmallVector getOwnershipFieldTypes(mlir::Type type) { diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index 5099afb7a..edc5becf5 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -138,6 +138,11 @@ class OwnershipInferencePass // A view of a block is that block: the candidate is always the root. f.walk([&](mlir::Operation *op) { + // A handle put into a union with a member that owns a block of its own: counting the + // union would count that member's single-owner block, and not counting it would lose + // the handle's count (spec 23.2). + reportHandleInOwningUnion(op); + // A handle's retains and releases are rc's, counted, and nobody's to decide (spec 23.2). // An overwrite of a field or an element that holds one is still a drop: the old handle // may be the last, and its payload goes with it. @@ -4321,6 +4326,75 @@ class OwnershipInferencePass signalPassFailure(); } + // A handle made into a union whose other members include one that owns a block (spec 23.2): + // `Shared | string` given a Shared. gc, none and rc accept it. + void reportHandleInOwningUnion(mlir::Operation *op) + { + auto handleIn = llvm::any_of(op->getOperandTypes(), [](mlir::Type type) { + return MLIRTypeHelper::isSharedHandleType(type); + }); + if (!handleIn) + { + return; + } + + for (auto resultType : op->getResultTypes()) + { + if (!MLIRTypeHelper::isSharedInOwningUnionType(resultType)) + { + continue; + } + + if (!reporting()) + { + return; + } + + auto unionType = resultType; + if (auto optionalType = mlir::dyn_cast(unionType)) + { + unionType = optionalType.getElementType(); + } + + mlir::Type shared; + mlir::Type owning; + for (auto member : mlir::cast(unionType).getTypes()) + { + if (mlir::isa(member)) + { + shared = shared ? shared : member; + } + else if (!owning && MLIRTypeHelper::ownsHeapMemoryOfUnionMember(member)) + { + owning = member; + } + } + + auto diag = op->emitError("a ") << printed(shared) << " in a union with "; + if (owning) + { + diag << "'" << printed(owning) << "', which -mm=own does not count,"; + } + else + { + diag << "a member -mm=own does not count,"; + } + + diag << " is not supported"; + signalPassFailure(); + return; + } + } + + static std::string printed(mlir::Type type) + { + llvm::SmallString<128> text; + llvm::raw_svector_ostream out(text); + MLIRPrinter printer{}; + printer.printType(out, type); + return text.str().str(); + } + void reportGivenNotOwned(mlir::Operation *call, mlir::Value arg) { if (!reporting()) diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 6feb29c66..b816eb3ff 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2614,7 +2614,7 @@ add_test(NAME test-own-string-copy-in-store-block # own_shared_user_named: a program's own class named Shared wins over the built-in (spec 23.1). set(shared_models gc none rc own) foreach(shared_mm ${shared_models}) - foreach(shared_test own_shared_basic own_shared_user_named own_shared_graph own_shared_containers own_shared_cycle) + foreach(shared_test own_shared_basic own_shared_user_named own_shared_graph own_shared_containers own_shared_cycle own_shared_union) tslang_add_test(NAME test-jit-${shared_mm}-${shared_test} COMMAND test-runner -jit -mm=${shared_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${shared_test}.ts") tslang_add_test(NAME test-compile-${shared_mm}-${shared_test} COMMAND test-runner -mm=${shared_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${shared_test}.ts") endforeach() @@ -2626,7 +2626,7 @@ foreach(shared_count_mm rc own) endforeach() # --verify-ownership is quiet on every positive (spec 23.5): handles keep rc's pairing. foreach(shared_verify_mm own rc) - foreach(shared_test own_shared_basic own_shared_graph own_shared_count own_shared_containers own_shared_cycle) + foreach(shared_test own_shared_basic own_shared_graph own_shared_count own_shared_containers own_shared_cycle own_shared_union) add_test(NAME test-${shared_verify_mm}-verify-ownership-${shared_test} COMMAND $ --emit=mlir-affine --no-default-lib -mm=${shared_verify_mm} --verify-ownership "${PROJECT_SOURCE_DIR}/test/tester/own/${shared_test}.ts") @@ -2764,7 +2764,8 @@ set(own_error_cases "own_err_shared_moved_in|is used here after its value was moved" "own_err_shared_borrow_other_type|but is used here after it may be released or overwritten" "own_err_shared_borrow_push_call|but is used here after it may be released or overwritten" - "own_err_shared_borrow_reassigned|but is used here after it may be released or overwritten") + "own_err_shared_borrow_reassigned|but is used here after it may be released or overwritten" + "own_err_shared_union_owned|a Shared in a union with 'string', which -mm=own does not count, is not supported") foreach(own_error_case ${own_error_cases}) string(FIND "${own_error_case}" "|" own_error_sep) string(SUBSTRING "${own_error_case}" 0 ${own_error_sep} own_error_name) diff --git a/tslang/test/tester/own/own_err_shared_union_owned.ts b/tslang/test/tester/own/own_err_shared_union_owned.ts new file mode 100644 index 000000000..2bd2e1530 --- /dev/null +++ b/tslang/test/tester/own/own_err_shared_union_owned.ts @@ -0,0 +1,12 @@ +// Shared (spec 23.2) in a union with a member that owns a block of its own: -mm=own counts a +// handle and owns a string singly, so it cannot do both for one value. Rejected under own only. +class Node { + v = 0; +} + +function main() { + const a = new Shared(new Node()); + let u: Shared | string = a; + u = "text"; + print(Shared.count(a)); +} diff --git a/tslang/test/tester/own/own_shared_union.ts b/tslang/test/tester/own/own_shared_union.ts new file mode 100644 index 000000000..9c23ee24e --- /dev/null +++ b/tslang/test/tester/own/own_shared_union.ts @@ -0,0 +1,117 @@ +// Shared (spec 23.2) in a tagged union whose other members own no block: `Shared | number` +// is a handle, counted as rc counts it. A local, a field and an array element each take a handle, +// switch to a number and back; each owner but one is dropped and its memory reused before the +// last is read. Counts are checked where there is one (rc, own); gc and none report -1. +class Node { + v = 0; +} + +class H { + u: Shared | number = 0; +} + +function churn() { + let keep: string[] = []; + for (let i = 0; i < 1000; i++) keep.push("k" + i); + return keep.length; +} + +function countIs(s: Shared, expected: number) { + const c = Shared.count(s); + return c < 0 || c == expected; +} + +function valueOf(u: Shared | number) { + if (typeof u == "number") return -1; + return u.value.v; +} + +// a copy of a parameter put into a union local: the copy and the union each hold a count +function inLet(a: Shared) { + const b = a; + let u: Shared | number = b; + assert(countIs(a, 4), "a let union holds a count"); + assert(valueOf(u) == 7); +} + +function inConst(a: Shared) { + const b = a; + const u: Shared | number = b; + assert(countIs(a, 3), "a const union"); + assert(valueOf(u) == 7); +} + +function inField(a: Shared) { + const b = a; + const h = new H(); + h.u = b; + assert(countIs(a, 4), "a union field holds a count"); + assert(valueOf(h.u) == 7); +} + +// a handle whose only owner, once this returns, is the union +function lastInUnion(v: number) { + const s = new Shared(new Node()); + s.value.v = v; + const u: Shared | number = s; + return u; +} + +function main() { + const a = new Shared(new Node()); + a.value.v = 7; + const keep = a; + assert(countIs(a, 2), "start"); + + inLet(a); + assert(countIs(a, 2), "the let union gave its count back"); + inConst(a); + assert(countIs(a, 2), "the const union gave its count back"); + inField(a); + assert(countIs(a, 2), "the field gave its count back"); + + // a field switched to a number and back + for (let i = 0; i < 1000; i++) { + const b = a; + const h = new H(); + h.u = b; + h.u = i; + assert(valueOf(h.u) == -1); + h.u = b; + assert(valueOf(h.u) == 7); + } + + assert(countIs(a, 2), "every field gave its count back"); + + // a local switched to a number and back + for (let i = 0; i < 1000; i++) { + const b = a; + let u: Shared | number = b; + u = 1; + assert(countIs(a, 3), "a union switched to a number gave its count back"); + u = b; + assert(valueOf(u) == 7); + } + + assert(countIs(a, 2), "every local gave its count back"); + + // an element switched to a number and back + const list: (Shared | number)[] = [a, 1]; + assert(countIs(a, 3), "an element holds a count"); + list[0] = 2; + assert(countIs(a, 2), "the element switched to a number"); + list[1] = a; + assert(countIs(a, 3), "another element holds it"); + + // a union is the last owner: every other handle is dropped and its memory reused + const only = lastInUnion(9); + const elements: (Shared | number)[] = [lastInUnion(10), 3]; + const field = new H(); + field.u = lastInUnion(11); + assert(churn() == 1000); + assert(valueOf(only) == 9, "a union local keeps the node"); + assert(valueOf(elements[0]) == 10 && valueOf(elements[1]) == -1, "an element keeps the node"); + assert(valueOf(field.u) == 11, "a field keeps the node"); + + print("done."); +} From bbac9ba83b89abd29279550bdb5385ba0649dfd7 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 3 Oct 2026 13:22:24 +0100 Subject: [PATCH 10/21] -mm=own Shared: measure, corpus and results (spec 23.7) - measure.ps1: a leaf handle in two arrays, flat and equal under own and rc (4.4 MB at 20000 and 200000 iterations), growing under none; a two-node cycle leaks under rc and own exactly as under none - corpus: 453 / 593 plain and 451 / 593 with --opt, the same files and first errors as main - LLVM IR identical to main's for the 451 of 453 files that compile; the other two flip a union's member order between runs of main itself - spec 23.7: the ops as built, the widened handle, tests, negatives, teeth, limits and the pre-existing bugs filed (#453-#456) - the plan's boxes ticked Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-03-own-shared.md | 76 +++--- .../2026-09-24-own-memory-model-design.md | 216 ++++++++++++++++++ 2 files changed, 254 insertions(+), 38 deletions(-) diff --git a/tslang/docs/superpowers/plans/2026-10-03-own-shared.md b/tslang/docs/superpowers/plans/2026-10-03-own-shared.md index 2704bf0a6..4a2b570ae 100644 --- a/tslang/docs/superpowers/plans/2026-10-03-own-shared.md +++ b/tslang/docs/superpowers/plans/2026-10-03-own-shared.md @@ -125,7 +125,7 @@ names given, because lines move. - `mlir_ts::SharedCountOp(loc, NumberType, mlir::Value shared)`. - The CMake variable `shared_models` and the foreach that registers `own_shared_*` tests per model. -- [ ] **Step 1: Write the test.** `test/tester/own/own_shared_basic.ts`: +- [x] **Step 1: Write the test.** `test/tester/own/own_shared_basic.ts`: ```ts // Shared (spec 23): two handles to one block, `.value` read and write, `===` on handles, and a @@ -173,7 +173,7 @@ function main() { } ``` -- [ ] **Step 2: Register it under gc and none, and watch it fail.** +- [x] **Step 2: Register it under gc and none, and watch it fail.** - In `test/tester/CMakeLists.txt`, after the `strings_mm` foreach (search `foreach(strings_mm rc none gc)`), add: @@ -192,7 +192,7 @@ endforeach() - Build, then run `ctest -C Release -R own_shared -j 8 --timeout 300`. - Expected: 4 tests, all FAIL, with "can't resolve name" or similar for `Shared`. -- [ ] **Step 3: The type.** In `include/TypeScript/TypeScriptTypes.td`, after `def TypeScript_Ref` +- [x] **Step 3: The type.** In `include/TypeScript/TypeScriptTypes.td`, after `def TypeScript_Ref` (the block that ends with its `builders`), add: ```tablegen @@ -225,7 +225,7 @@ def TypeScript_Shared : TypeScript_Type<"Shared"> { } ``` -- [ ] **Step 4: The ops.** In `include/TypeScript/TypeScriptOps.td`, after `def TypeScript_StringCopyOp`, +- [x] **Step 4: The ops.** In `include/TypeScript/TypeScriptOps.td`, after `def TypeScript_StringCopyOp`, add: ```tablegen @@ -253,7 +253,7 @@ def TypeScript_SharedCountOp : TypeScript_Op<"SharedCount"> { operand constraint, wrap it the way `TypeScript_AnyClassOrValueRef` is (`TypeScriptTypes.td`, `AnyTypeOf<[...]>`): `def TypeScript_SharedLike : AnyTypeOf<[TypeScript_Shared]>;` -- [ ] **Step 5: MLIRGen.** Each change is small. +- [x] **Step 5: MLIRGen.** Each change is small. - **The embedded name** (`lib/TypeScript/MLIRGenTypes.cpp`): - Add `{"Shared", true}` to the static maps in `isEmbededTypeWithBuiltins` and `isEmbededTypeWithNoBuiltins`, next to `{"BoxedObject", true}`. @@ -378,7 +378,7 @@ def TypeScript_SharedCountOp : TypeScript_Op<"SharedCount"> { lookup the new-expression fallback relies on to find that `mlirGen(identifier)` failed (for example, a `findDeclaration`/`resolveIdentifier` call; search how `mlirGen(location, name)` decides "can't resolve name"). -- [ ] **Step 6: Type helpers.** +- [x] **Step 6: Type helpers.** - **`include/TypeScript/MLIRLogic/MLIRTypeCore.h`:** add `mlir_ts::SharedType` to the lists in `isNullableTypeNoUnion` and `isNullableOrOptionalType`, beside `ClassType`. This makes `===` compare pointers and `null` compare and assign. @@ -394,7 +394,7 @@ def TypeScript_SharedCountOp : TypeScript_Op<"SharedCount"> { }) ``` -- [ ] **Step 7: Lowering.** +- [x] **Step 7: Lowering.** - **`lib/TypeScript/LowerToLLVM.cpp`, `populateTypeScriptConversionPatterns`:** add beside the `RefType` conversion: @@ -472,7 +472,7 @@ struct SharedCountOpLowering : public TsLlvmPattern - **`lib/TypeScript/LowerToAffineLoops.cpp`, `AddTsAffineLegalOps`:** add `mlir_ts::SharedNewOp, mlir_ts::SharedValueRefOp, mlir_ts::SharedCountOp` to the `addLegalOp<...>` list beside `mlir_ts::StringCopyOp`. -- [ ] **Step 8: Run the test.** +- [x] **Step 8: Run the test.** - Build, then run `ctest -C Release -R own_shared -j 8 --timeout 300`. Expected: 4/4 pass. - Also run `test-runner -mm=rc ` once by hand. It should pass too, but it leaks: rc does not count handles until Task 2. @@ -480,7 +480,7 @@ struct SharedCountOpLowering : public TsLlvmPattern `export function make(): Shared { return new Shared(1); }`, compile it with `--emit=mlir` and `--export`, the way the `export_*` corpus files are, and confirm that `__decls` prints `Shared`. -- [ ] **Step 9: The spec.** In §23.1: +- [x] **Step 9: The spec.** In §23.1: - Replace the bullets for `s.value` and `s.value = x` with one bullet: "**`s.value`** is a `ts.Load` of the place `ts.SharedValueRef(s)`, and **`s.value = x`** stores into that place the way a field store does (MLIRGen's retain, `ts.ReleaseSlot` and `ts.Store`), so `x` moves in @@ -491,7 +491,7 @@ struct SharedCountOpLowering : public TsLlvmPattern "a doubly linked list and a node with a parent link" to `own_shared_cycle`, since both are cycles and leak (§23.4). - Keep CRLF. -- [ ] **Step 10: Commit.** +- [x] **Step 10: Commit.** ```bash git add include/TypeScript lib/TypeScript test/tester/own/own_shared_basic.ts test/tester/CMakeLists.txt docs/superpowers/specs/2026-09-24-own-memory-model-design.md @@ -516,7 +516,7 @@ git commit -m "-mm=own Shared: the type, its ops and MLIRGen (spec 23.1)" `SharedType`, or an `OptionalType` of one, or a `UnionType` whose members are one `SharedType` and `NullType`/`UndefinedType` only. Tasks 3 and 4 use it. -- [ ] **Step 1: Write the tests.** `test/tester/own/own_shared_count.ts` (rc and own only, since +- [x] **Step 1: Write the tests.** `test/tester/own/own_shared_count.ts` (rc and own only, since gc and none keep no count): ```ts @@ -631,7 +631,7 @@ function main() { } ``` -- [ ] **Step 2: Register them and watch them fail.** In `CMakeLists.txt`: +- [x] **Step 2: Register them and watch them fail.** In `CMakeLists.txt`: - change `set(shared_models gc none)` to `set(shared_models gc none rc)`; - add `own_shared_graph` to the inner `foreach(shared_test ...)` list; - add after that foreach: @@ -652,7 +652,7 @@ endforeach() If `own_shared_graph` fails to compile under gc or none (a `Shared | null` narrowing), that is Review Focus 1: fix it in this task. The union must be tag-free and narrow like a class union; check `isUnionTypeNeedsTag` and the narrowing helpers. -- [ ] **Step 3: The type predicates** (`include/TypeScript/MLIRLogic/MLIRTypeHelper.h`): +- [x] **Step 3: The type predicates** (`include/TypeScript/MLIRLogic/MLIRTypeHelper.h`): - In `ownsHeapMemory(location, type, visiting)`, add `isa(type)` to the first "owns its own block" test, beside `ClassType`. Do not add `SharedType` to `getOwnershipFieldTypes`: the block's payload is released by the handle's routine. @@ -698,7 +698,7 @@ endforeach() } ``` -- [ ] **Step 4: The routines** (`include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h`): +- [x] **Step 4: The routines** (`include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h`): - In `buildRetainBody`, add `isa(type)` to the "reference to a block of its own" condition beside `ClassType`, so it is `emitIncRef` of the loaded pointer. - In `buildBody`, after the class/object branch, add: @@ -733,7 +733,7 @@ endforeach() have, make the tag-free union of one `SharedType` use the `SharedType` routine (the same way a tag-free `C | null` uses the class routine; find that branch and add `SharedType` beside `ClassType`). -- [ ] **Step 5: MLIRGen's ownership ops for a handle** (`lib/TypeScript/MLIRGenImpl.h`): +- [x] **Step 5: MLIRGen's ownership ops for a handle** (`lib/TypeScript/MLIRGenImpl.h`): - In `markFreshBlockOwned`, widen the test to `isa(value.getType())`. A new handle then carries its birth reference the way a new string does (§9.25 of the rc document). @@ -763,14 +763,14 @@ endforeach() - **`LowerToLLVM.cpp`:** in the GC root type test (the list that names `ClassType` near the `GC_ENABLE` code in `VariableOpLowering`), add `SharedType` beside `ClassType`. -- [ ] **Step 6: Run the tests.** +- [x] **Step 6: Run the tests.** - Build, then run `ctest -C Release -R own_shared -j 8 --timeout 300`. Expected: all pass (gc, none and rc, plus `own_shared_count` under rc). - Run `test-runner -noopt -mm=rc` and `test-runner -noopt -jit -mm=rc` on all three files. Expected: they pass. - Run `ctest -C Release -R "own|rc" -j 8 --timeout 300`. Expected: no new failure (nothing existing uses `Shared`). -- [ ] **Step 7: Commit.** +- [x] **Step 7: Commit.** ```bash git add include/TypeScript lib/TypeScript test/tester/own/own_shared_count.ts test/tester/own/own_shared_graph.ts test/tester/CMakeLists.txt @@ -796,7 +796,7 @@ git commit -m "-mm=own Shared: a handle is counted, under rc first (spec 23.2 - `inline bool touchesHandle(mlir::Operation *op)`, true for a `Retain`/`Release` of a handle value, or a `RetainSlot`/`ReleaseSlot` of a slot holding one. -- [ ] **Step 1: Write the negative tests.** +- [x] **Step 1: Write the negative tests.** `own_err_shared_borrow_write.ts`: @@ -880,7 +880,7 @@ function main() { } ``` -- [ ] **Step 2: Register them; add own to the positives; watch them fail.** In `CMakeLists.txt`: +- [x] **Step 2: Register them; add own to the positives; watch them fail.** In `CMakeLists.txt`: - add to `own_error_cases`: ```cmake @@ -914,7 +914,7 @@ endforeach() - the rest pass. Record each negative's actual first message now. -- [ ] **Step 3: The facts** (`lib/TypeScript/OwnershipFacts.h`): +- [x] **Step 3: The facts** (`lib/TypeScript/OwnershipFacts.h`): ```cpp // A handle (spec 23.2): counted in every model that tracks ownership, and left alone by own. @@ -942,7 +942,7 @@ inline bool touchesHandle(mlir::Operation *op) Use the namespace `MLIRTypeHelper` is in, as the file's other uses show. In `isPlace(ref)`, add `SharedValueRefOp` beside `PropertyRefOp`/`ElementRefOp`: `s.value` is a place. -- [ ] **Step 4: The inference pass steps aside** (`lib/TypeScript/OwnershipInferencePass.cpp`). The +- [x] **Step 4: The inference pass steps aside** (`lib/TypeScript/OwnershipInferencePass.cpp`). The goal is "never erased, never reported" for a handle. The safety net comes first: - **Never erased.** Immediately before the loop that erases `toErase` in `analyze`, add: @@ -976,7 +976,7 @@ inline bool touchesHandle(mlir::Operation *op) `drops` beside `ArrayPopOp`, but only when the array operand's `chainOf` reaches a `SharedValueRef`. It is a drop for an element place of the same element type, as `pop` is. - `describePlace` should name a `SharedValueRef` place `'.value'` (e.g. `'a.value'`). -- [ ] **Step 5: The signature pass** (`lib/TypeScript/OwnershipSignaturePass.cpp`): +- [x] **Step 5: The signature pass** (`lib/TypeScript/OwnershipSignaturePass.cpp`): - **No facts on handles:** in `setFacts`, leave out of `__own_params` any parameter whose type `isSharedHandleType`, and do not set `__own_result_borrows` for a result that is a handle. - **`keeps()`:** a `SharedNewOp` whose operand is the parameter keeps it, like a store into a @@ -986,7 +986,7 @@ inline bool touchesHandle(mlir::Operation *op) be the last handle, and its payload's release can free anything the payload owns; - a `ReleaseSlotOp` of a `SharedValueRef` place is a drop. In `reachesOutside`, treat a `SharedValueRef` as reaching outside: any handle may be shared. -- [ ] **Step 6: The lowering gates** (`lib/TypeScript/LowerToLLVM.cpp`): +- [x] **Step 6: The lowering gates** (`lib/TypeScript/LowerToLLVM.cpp`): - In `RetainOpLowering` and `RetainSlotOpLowering`, compute `auto counted = MLIRTypeHelper::isSharedHandleType()`. Use the reference's type, and for a slot, the `RefType`'s element type. Then: @@ -996,7 +996,7 @@ inline bool touchesHandle(mlir::Operation *op) - In the two `VariableOpLowering` sites gated on `isRefCounted()` that retain a captured slot's initial value with `emitRetainSlot`, use the same `|| (tracksOwnership() && counted)` on the slot's element type. -- [ ] **Step 7: Run the tests.** +- [x] **Step 7: Run the tests.** - Build, then run `ctest -C Release -R "own_shared|own-err-own_err_shared|verify-ownership-own_shared" -j 8 --timeout 300`. Expected: all pass. That is every positive under gc, none, rc and own (AOT and JIT), the count @@ -1006,7 +1006,7 @@ inline bool touchesHandle(mlir::Operation *op) - Run `test-runner -noopt -mm=own` and `-noopt -jit -mm=own` on every positive. - Run the full `ctest -C Release -R own -j 8 --timeout 300`. Expected: every existing own test still passes (490 before this phase). -- [ ] **Step 8: Teeth.** Commit or back up first. Each switch is temporary. Clear +- [x] **Step 8: Teeth.** Commit or back up first. Each switch is temporary. Clear `test/tester/own/__jit` and the build's `own/__jit` between JIT runs. - (a) Remove the `toErase.remove_if(...)` line and the `touchesHandle` skips in the retain loop: the own positives must fail (to compile, or at run time). @@ -1017,7 +1017,7 @@ inline bool touchesHandle(mlir::Operation *op) - (d) Drop the push rule: `own_err_shared_borrow_push` must compile. If it errors anyway, the existing rules already end that borrow. Record which, for §23.7. - Revert all four. -- [ ] **Step 9: Commit.** +- [x] **Step 9: Commit.** ```bash git add lib/TypeScript test/tester/own/own_err_shared_*.ts test/tester/CMakeLists.txt @@ -1037,7 +1037,7 @@ git commit -m "-mm=own Shared: own leaves handles to the count; reads through **Interfaces:** - Consumes: everything above. -- [ ] **Step 1: Write the tests.** `own_shared_containers.ts`: +- [x] **Step 1: Write the tests.** `own_shared_containers.ts`: ```ts // Shared (spec 23) in containers: one node in two arrays, a `Shared | null` field, a closure @@ -1123,12 +1123,12 @@ function main() { } ``` -- [ ] **Step 2: Register them and watch them fail.** Add `own_shared_containers own_shared_cycle` +- [x] **Step 2: Register them and watch them fail.** Add `own_shared_containers own_shared_cycle` to the inner `foreach(shared_test ...)` list and to the `shared_verify_mm` list. Build, then run `ctest -C Release -R own_shared -j 8 --timeout 300`. Expected: `own_shared_containers` fails in some models. The `any` box has no type descriptor for a handle yet, and the `>` cast from `any` is not supported. `own_shared_cycle` may already pass. -- [ ] **Step 3: A descriptor for a handle.** +- [x] **Step 3: A descriptor for a handle.** - **`TypeOfOpHelper.h`, `typeOfAsString`:** return `"object"` for a `SharedType`. That is what JavaScript's `typeof` would say of a wrapper object. The descriptor is keyed by the type's hash, so `Shared` and `Shared` still get distinct descriptors. @@ -1143,21 +1143,21 @@ function main() { : orl.getOrCreateRetainRoutine(descriptorType); ``` -- [ ] **Step 4: `any` back to `Shared`** (`lib/TypeScript/MLIRGenCast.cpp`). Find where a cast +- [x] **Step 4: `any` back to `Shared`** (`lib/TypeScript/MLIRGenCast.cpp`). Find where a cast from `any` to a class type is generated: it checks the box's type tag and unboxes. Make `SharedType` take the same path as `ClassType`. The tag it compares against is the handle's descriptor from Step 3. - If that path compares the `typeof` name only ("object"), an `any` holding a plain object would also pass the check. Record that in §23.7 as a limit. - If the class path cannot be reused, stop and report the reason (BLOCKED). -- [ ] **Step 5: Run the tests.** +- [x] **Step 5: Run the tests.** - Build, then run `ctest -C Release -R "own_shared|verify-ownership-own_shared" -j 8 --timeout 300`. Expected: all pass under gc, none, rc and own, AOT and JIT. - Run `-noopt` under own and rc for both files. - If own rejects `own_shared_containers` with an error that names a non-handle value, read it: - a rule of §23.3 means the test is wrong; rewrite the shape and say so in the commit; - anything else is a gap in Task 3's skips. Fix it there, with this test as its proof. -- [ ] **Step 6: Commit.** +- [x] **Step 6: Commit.** ```bash git add include/TypeScript lib/TypeScript test/tester/own/own_shared_containers.ts test/tester/own/own_shared_cycle.ts test/tester/CMakeLists.txt @@ -1172,7 +1172,7 @@ git commit -m "-mm=own Shared: handles in containers, any and closures; cycle - Modify: the spec, a new §23.7 "Results" - Modify: this plan (tick the boxes) -- [ ] **Step 1: Measure.** Write `$SCRATCH/shared_loop.ts`: +- [x] **Step 1: Measure.** Write `$SCRATCH/shared_loop.ts`: ```ts class N { @@ -1202,20 +1202,20 @@ function main() { - Expected: own and rc peaks are the same at both counts, within 0.5 MB; none grows; gc stays bounded. - Record both rows. -- [ ] **Step 2: Corpus.** Run the corpus scripts (Appendix A) plain and with `OPTS=--opt`, with +- [x] **Step 2: Corpus.** Run the corpus scripts (Appendix A) plain and with `OPTS=--opt`, with this branch's binary. - Expected: 453 / 593 and 451 / 593, each with the same file set as main (`join -t $'\t' base.tsv new.tsv | awk -F'\t' '$2 != $3'` prints nothing). - Make `base.tsv`/`baseopt.tsv` from main's binary first: build main once, or reuse a saved `tslang-main.exe`, before this branch's final build. -- [ ] **Step 3: Unchanged where it compiles.** For every file ok in `base.tsv`, compare +- [x] **Step 3: Unchanged where it compiles.** For every file ok in `base.tsv`, compare `--emit=llvm -mm=own --no-default-lib -o .ll` from main's binary and this branch's. - Normalise the run-to-run names first: `([A-Za-z])_[0-9]{4,}` → `\1_N`, `\.[0-9]{6,}\.` → `.N.`, and the `[N x i8]` size on lines containing `FH`. - Expected: identical, apart from any file shown to differ between two runs of main itself. - Also run `--emit=llvm -mm=rc` on 50 of those files. Expected: identical. rc's routines changed only for `SharedType`. -- [ ] **Step 4: Spec results.** Append §23.7 "Results" in the style of §22.7: +- [x] **Step 4: Spec results.** Append §23.7 "Results" in the style of §22.7: - what was built, with the op list, including Task 1's `SharedValueRef` amendment; - the tests and models; - the negatives with their messages; @@ -1226,13 +1226,13 @@ function main() { recorded. Tick this plan's boxes, and annotate this step "committed; push and PR left to the user". -- [ ] **Step 5: Linux.** Build the branch with GCC in WSL and run +- [x] **Step 5: Linux.** Build the branch with GCC in WSL and run `ctest -R "own_shared|own-err|own-no-counting|own-verify|verify-ownership-own_shared"` there. - The WSL clone is `~/ts/TypeScriptCompiler`, whose origin is `/mnt/i/TypeScriptCompiler`. Fetch this branch, and do not fetch `main`. - The build dir is `__build/tslang/ninja/release`. - Write a `.sh` into the scratchpad and run it with `wsl bash -l ` from PowerShell. -- [ ] **Step 6: Commit.** Do not push and do not open a PR. That is left to the user. +- [x] **Step 6: Commit.** Do not push and do not open a PR. That is left to the user. Committed; push and PR left to the user. ```bash git add docs/superpowers/ 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 87fe4b023..f8d63b3a5 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 @@ -2235,3 +2235,219 @@ change. It runs on every `own_shared_*` test, under own and rc. under own and rc (`measure.ps1`), and grows under none. - **Unchanged:** no corpus file uses `Shared`, so the corpus (453 / 593 plain, 451 with `--opt`) must be unchanged, and its LLVM IR identical for every file that compiles, as in §22.7. + +### 23.7 Results, 2026-10-03 + +Plan: `docs/superpowers/plans/2026-10-03-own-shared.md`. Changed: `TypeScriptTypes.td`, +`TypeScriptOps.td`, `MLIRTypeCore.h`, `MLIRTypeHelper.h`, `MLIRTypeIterator.h`, `MLIRPrinter.h`, +`TypeOfOpHelper.h` (the type), `MLIRGenTypes.cpp`, `MLIRGenExpressions.cpp`, +`MLIRGenAccessCall.cpp`, `MLIRGenCast.cpp`, `MLIRGenImpl.h` (MLIRGen), `OwnershipRoutineLogic.h`, +`CastLogicHelper.h`, `LowerToAffineLoops.cpp`, `LowerToLLVM.cpp` (the routines and lowering), +`OwnershipFacts.h`, `OwnershipInferencePass.cpp`, `OwnershipSignaturePass.cpp` (own). Every new +branch keys on `!ts.shared`, a `ts.SharedValueRef`, or `isSharedHandleType`, so a program without +`Shared` compiles as before (see "Unchanged where it compiles"). + +#### What was built + +- **The type** `!ts.shared` (`mlir_ts::SharedType`), lowered to `ptr`. `Shared` is an embedded + name in `MLIRGenTypes`, so a program's own `Shared` is found first and wins + (`own_shared_user_named`). It is nullable (`===`, `null`), its `typeof` is `"object"`, and it + prints as `Shared`, in messages and in `__decls`. +- **The ops**, three, not the two of §23.1's first draft: + - `ts.SharedNew` (`MemAlloc`): allocates the block with the allocator every model's `new` uses, + and stores the value; + - `ts.SharedValueRef`: the place `!ts.ref` of the payload, the identity in lowering. + **`s.value` is a `ts.Load` of it**, and `s.value = x` stores through it (§23.1), so the place + machinery of MLIRGen and own applies unchanged; + - `ts.SharedCount`: `-1` where nothing is counted (gc, none), otherwise the header word as a + `number`. +- **MLIRGen.** + - `new Shared(x)` is reached only when `Shared` does not resolve. `T` comes from a type argument, + then a `Shared` receiver, then the value: **`new Shared(1)` is a `Shared`**, as + `let x = 1` is an `s32`. `new Shared()`, two arguments and two type arguments are errors. + - `Shared.count(s)` is intercepted at the call when `Shared` does not resolve. It takes a bare + handle, and a narrowed `Shared | null` or optional one (`if (s)` keeps the union's type), + which it unwraps as `.value` does. + - The payload goes in with `mlirGenRetainCaptured` (a move, §2.3), and `ts.SharedNew`'s result is + a fresh owned reference (`markFreshBlockOwned`). A `.value` place is an owning slot + (`isOwnedSharedValueSlot`), so `s.value = x` is rc's `Retain`, `ReleaseSlot`, `Store`. + - **A `const` of a bare `Shared` gets identity storage** (`needsIdentityStorage`, as a `const` + array does), so `const b = a` is a second, counted handle (§23.1). A `const` of a union or + optional handle (`const c = p.child`) stays folded; no defect was found with it. +- **The routines.** `ownsHeapMemory` is true for a handle. Its release routine is + `__tslang_dec_ref`, and at zero the payload's routine and `__tslang_free_block`; its retain is + `__tslang_inc_ref`. `emitIfLastReference(counted)` makes own count a handle where it tests + mortality for everything else. A tag-free `Shared | null` needs no routine of its own. +- **Under own, the analysis steps aside** (§23.2): + - the facts `isHandle` and `touchesHandle`; `isPlace` includes `ts.SharedValueRef`; + - the inference walk returns early for every op that touches a handle, except a `ReleaseSlot` of + a field or element holding one, which stays a drop (a handle overwritten there may be the last + one). A handle never becomes a candidate, by any of the four ways in, and a safety net takes + any handle op out of the erase list; + - the signature pass gives no parameter or result fact to a handle; + - the lowering emits a surviving handle retain under own (`__tslang_inc_ref`), without "left a + retain behind"; + - a cell holding a handle (a captured handle parameter) is released by `ReleaseCell`, and a box + may take it (`decideCells`, `decideEscaping`): the cell holds its own count. +- **The handle, widened** (Ruling 6, §23.2): a `Shared`, an optional of one, or a union with a + `Shared` member whose other members own no heap. `Shared | number` and + `Shared | Shared | null | number` are counted under own exactly as rc counts them. + `Shared | string`, `Shared | Node` and `Shared[] | Shared` are rejected under own + where a handle is put into one, with a named error (`reportHandleInOwningUnion`). +- **Reading through a handle** (§23.3), as built: + - `chainOf` treats a base read through by `ts.SharedValueRef` as a `NotOwned` root and records + it as a handle; a handle loaded out of a place walks on up, so `p.child.value` sees `p.child`; + - `rootEnds`: a borrow through a handle also ends at that handle's own `ReleaseSlot` or `Store` + (a local) or `ts.Release` (a temporary). A copy of the handle does not end it; + - `dropsChain`: a `ReleaseSlot` of a `.value` place ends every borrow read through any handle, + **whatever the element type** (Ruling 4); + - a `push` or `unshift` on an array reached through a handle (`reachesSharedValue`) is a drop, + inline and inside a callee (`dropsInBody`); + - a function that stores a parameter into a handle's payload keeps that parameter (`keeps()` + sees `s.value = x` as a store into a place). +- **`any`.** A handle boxes under its own descriptor, named `"object"`, whose release and retain + slots are the handle's routines, kept under own. A tag-free `Shared | null` boxes under the + handle's descriptor, so a null in it is `typeof` `"object"`. `>a` goes through + `___unbox>`, which checks the name and throws otherwise. The counts round-trip + exactly under rc and own. + +#### Tests and teeth + +- **Positive**, under gc, none, rc and own, AOT and JIT (48 runs): `own_shared_basic`, + `own_shared_user_named`, `own_shared_graph`, `own_shared_containers`, `own_shared_cycle`, + `own_shared_union`. **Under rc and own** (4 runs): `own_shared_count`, with every count after a + copy, a drop, an outer block's death, a `.value` write, a narrowed union, an `any` round trip, a + captured handle parameter (two calls) and an escaping closure over one (100 iterations). + **`--verify-ownership`**, under own and rc, is quiet on the six counted files (12 runs). +- **`-O0`**: test-runner's `-noopt` does nothing in a Release build, so each positive was also run + by hand with `--opt_level=0` under own and rc, AOT and JIT. +- **Negative** (8). Messages with `--di`; without it the borrower is `'this value'` and the place + "a field" or "an element", as for every own error: + +| test | message | +|---|---| +| `own_err_shared_borrow_write` | `'n' borrows 'a.value' but is used here after it may be released or overwritten` (the §23.3 example) | +| `own_err_shared_borrow_call` | the same, after a call that may drop | +| `own_err_shared_borrow_push` | `'first' borrows 'a.value.items[]' but is used here after …` (a push through another handle) | +| `own_err_shared_borrow_push_call` | `… borrows 'a.value' but is used here after …` (the push inside a callee) | +| `own_err_shared_borrow_other_type` | `'n' borrows 'a.value' but is used here after …` (a write through a `Shared` alias) | +| `own_err_shared_borrow_reassigned` | `'n' borrows 'a.value' but is used here after …` (the handle itself reassigned) | +| `own_err_shared_moved_in` | `is used here after its value was moved` | +| `own_err_shared_union_owned` | `a Shared in a union with 'string', which -mm=own does not count, is not supported` | + +- **Suites:** the Shared set is 72 tests, `ctest -R own` 562 and `ctest -R "own|rc"` 1516, all + passing on this branch's final build. +- **Teeth**, each a temporary switch, with the JIT cache cleared: + +| switch | result | +|---|---| +| Task 1: before the type existed (its RED) | `own_shared_basic` fails under gc and none, `can't resolve name: Shared` | +| Task 2: the payload's release taken out of the handle routine | `own_shared_count` fails under rc and own: `the payload released when the outer block died` | +| (a) the safety net and the retain loop's handle skip removed | nothing fails: the walk's early return keeps handles out first | +| (a) extended: the walk's early return removed too | the six own positive runs and `own_shared_count`'s own verifier run fail to compile | +| (b) a handle root `Owned`, not `NotOwned` | `own_err_shared_borrow_call` compiles | +| (c) the `.value` match removed from `dropsChain` | `own_err_shared_borrow_write` compiles | +| (d) the inline push rule off | `own_err_shared_borrow_push` compiles | +| the element-type comparison restored for `.value` places | `own_err_shared_borrow_other_type` compiles | +| the push rule in `dropsInBody` off | `own_err_shared_borrow_push_call` compiles | +| the `rootEnds` handle clause off | `own_err_shared_borrow_reassigned` compiles | +| the `decideCells` handle return removed | `own_shared_count` under own: `a captured parameter's count given back` (a count leaked per call) | +| the widened handle off | `own_shared_union` is rejected by the named rule | +| the widened handle and the named rejection off (a tagged union is single-owner) | `own_shared_union`: `is used here after its value was moved`; a field loop crashes; the counts drop below rc's | + +- **What (d) showed:** no existing rule ends an element borrow across a push through another + handle; the push rule is the only one. Each other §23.3 rule also has its own negative. +- **The containers test is weak as teeth:** several owners keep its node alive, so an early free + there may read live memory. The counts are asserted in `own_shared_count` and `own_shared_union`. + +#### Measured + +AOT, `measure.ps1`, in MB. The first program is the plan's: per iteration, three handles, one of +them in two arrays, every block given back at the end of the iteration. The second builds a +two-node cycle (`a.value.next = b; b.value.next = a`) per iteration. + +| program | iterations | gc | rc | none | own | +|---|---|---|---|---|---| +| a leaf in two arrays | 20000 | 5.9 | 4.4 | 10.6 | 4.4 | +| a leaf in two arrays | 200000 | 5.9 | 4.4 | 65.9 | 4.4 | +| a two-node cycle | 200000 | 5.9 | 35.2 | 35.2 | 35.2 | + +Own and rc are flat and equal; none grows. A cycle leaks under rc and own exactly as under none, +and gc collects it (§23.4). + +#### Corpus + +`--emit=llvm -mm=own --no-default-lib`, every file of `test/tester/tests`, main's binary against +this branch's, plain and with `--opt`: 453 / 593 and 451 / 593 on both, each file with the same +first error. Two messages (`00types`, `callWithSpread`) name a function by its run-to-run hash +(`FH`); with it normalised, the two lists are identical. + +#### Unchanged where it compiles + +The 453 files that compile on main were compiled with main's binary and this branch's, +`--emit=llvm -mm=own --no-default-lib`, with §22.7's normalisations; the `[n x i8]` size is +normalised on any line holding an `FH` name. 451 are identical. The other two differ only as +main differs from itself between runs, in the order of a union's members in a synthesized name, +and so in the order of the functions and constants that follow it: + +| file | main, 12 runs | this branch, 12 runs | +|---|---|---| +| `00union_null_undefined_nonstrict` (§22.7) | 11 one order, 1 the other | 10, 2 | +| `00union_bin_ops2` (`___bin_op_plus>`) | 8, 4 | 7, 5 | + +Each binary gives exactly the same two normalised outputs, byte for byte. Under `-mm=rc`, 50 of +those files (every ninth) were compared the same way: 49 are identical, and `00union_bin_ops2` +flips the same way (main 8 and 4, this branch 11 and 1, the same two outputs). + +#### Outside the rule + +- **The covariance hole.** `Shared` is assignable to `Shared`, so + `b.value = new Base()` through the `Base` view leaves a `Base` in a `Derived` block. That is + TypeScript's own unsoundness (mutable covariance) and is not fixed: the `Derived` view then + reads fields a `Base` does not have (p2c, under gc and rc). Own lets no borrow survive such a + write: any write through any handle ends every borrow read through a handle (Ruling 4). +- **Conservative rejections**, false errors rather than miscompiles: + - a function that gives back a handle (a `ts.Release`, or a `ReleaseSlot` of a local holding + one) loses `__own_no_drops`, so a call to it ends borrows its caller holds; + - a getter returning `s.value` is rejected (`borrows 's.value' and cannot be stored, returned or + captured`): `borrowedParam` does not see through `ts.SharedValueRef`; + - a write through a handle of an unrelated type ends borrows through every handle (p1d); + - a push or unshift through a handle ends element borrows, inline and in a callee alike. +- **`any` is checked by name only.** `>a` accepts any value tagged `"object"`, so a + `Shared` passes as a `Shared` and its fields are read as `A`'s. Literals, numbers and + class instances are rejected. A wrong-type unbox cannot be asserted in a test, because a `let` + in `try` whose initializer throws is not caught (#456). +- **Through null.** `Shared.count(u)` and `u.value` on an un-narrowed `Shared | null` read + through null when it is null; neither is a compile error. +- **Misused names.** `Shared.foo(s)` and a bare `Shared` value report only "failed statement", + with no cause, as `BoxedObject` and `Reference` do. A member other than `value` gets the generic + "Can't resolve property". When a program has its own `Shared`, the `Shared.count` guard leaves + one dead op behind. +- **A rejected mixed union** (`Shared | string`) also reports own's ordinary error on the same + function after the named one (`borrows … and cannot be assigned`). +- **The union check's options.** `ownsHeapMemoryOfUnionMember` builds its `MLIRTypeHelper` with a + default `CompileOptions`, as `isCopyableString` (§22.7) and `OwnershipFacts`' `ownsHeap` do. +- **rc is no oracle for borrows.** rc takes no retain for `const n = obj.f`, so it runs the + use-after-free shapes own rejects (`own_err_shared_borrow_write`, p2c, r1) and prints whatever + the freed memory holds. Own and rc differ there by design. +- **No count is elided.** Own counts every handle as rc does (§23.2's last bullet); a handle own + could prove moved or borrowed is still counted. +- **A bare `{ }` block** does not end a folded `const boxed: any = h`: §9.30 releases a fresh box + at the end of its MLIR block, and a bare block in `main` is not one. Loops and functions are + exact. +- **Not tested:** a generic `function f(s: Shared)`; nothing rebuilds a `SharedType` with a + substituted element. +- **Resolved during the phase:** an escaping closure capturing a handle parameter, rejected at + first, is accepted (its cell holds a count) and asserted in `own_shared_count`; `Shared.count` + on a narrowed `Shared | null` const, an error at first, is accepted; a tagged + `Shared | number`, single-owner at first, is counted (Ruling 6). + +#### Found, not fixed (every model) + +- #453: a push onto an array parameter is lost to the caller (gc sees the old length, rc frees the + data block the caller still holds). `own_shared_count` keeps its handles in a class for this. +- #454: under rc, `const b = a` of a `let` class value takes no reference, so `a = …` frees what + `b` reads. A `const` handle has its own storage and is not affected. +- #455: a `Node | null` cast to `any` crashes the compiler (a null tag). Fixed for + `Shared | null` only. +- #456: a `let` inside `try` whose initializer throws is not caught. From 28f3fe3cd5aacb4f5b7522a3a574ce5048ec9af5 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 3 Oct 2026 13:24:43 +0100 Subject: [PATCH 11/21] -mm=own Shared: the Linux run (spec 23.7) GCC in WSL at bbac9ba8: builds with no errors, and ctest -R "own_shared|own-err|own-no-counting|own-verify|verify-ownership-own_shared" passes 213 of 213. Co-Authored-By: Claude Opus 5.5 --- .../superpowers/specs/2026-09-24-own-memory-model-design.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md b/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md index f8d63b3a5..403f2362f 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 @@ -2399,6 +2399,12 @@ Each binary gives exactly the same two normalised outputs, byte for byte. Under those files (every ninth) were compared the same way: 49 are identical, and `00union_bin_ops2` flips the same way (main 8 and 4, this branch 11 and 1, the same two outputs). +#### Linux + +GCC, in WSL, the branch at `bbac9ba8`: it builds with no errors, and +`ctest -R "own_shared|own-err|own-no-counting|own-verify|verify-ownership-own_shared"` passes 213 +of 213, the same 213 tests the Windows build lists for that pattern. + #### Outside the rule - **The covariance hole.** `Shared` is assignable to `Shared`, so From 105d12163e006cd458c10812c8176ebfb93adb41 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 3 Oct 2026 13:26:17 +0100 Subject: [PATCH 12/21] -mm=own Shared: annotate the plan's results step; name the teeth (a) runs (spec 23.7) Co-Authored-By: Claude Opus 5.5 --- tslang/docs/superpowers/plans/2026-10-03-own-shared.md | 2 ++ .../superpowers/specs/2026-09-24-own-memory-model-design.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/tslang/docs/superpowers/plans/2026-10-03-own-shared.md b/tslang/docs/superpowers/plans/2026-10-03-own-shared.md index 4a2b570ae..0baaf893e 100644 --- a/tslang/docs/superpowers/plans/2026-10-03-own-shared.md +++ b/tslang/docs/superpowers/plans/2026-10-03-own-shared.md @@ -1226,6 +1226,8 @@ function main() { recorded. Tick this plan's boxes, and annotate this step "committed; push and PR left to the user". + + Committed; push and PR left to the user. - [x] **Step 5: Linux.** Build the branch with GCC in WSL and run `ctest -R "own_shared|own-err|own-no-counting|own-verify|verify-ownership-own_shared"` there. - The WSL clone is `~/ts/TypeScriptCompiler`, whose origin is `/mnt/i/TypeScriptCompiler`. 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 403f2362f..1ee2cd809 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 @@ -2344,7 +2344,7 @@ branch keys on `!ts.shared`, a `ts.SharedValueRef`, or `isSharedHandleType`, so | Task 1: before the type existed (its RED) | `own_shared_basic` fails under gc and none, `can't resolve name: Shared` | | Task 2: the payload's release taken out of the handle routine | `own_shared_count` fails under rc and own: `the payload released when the outer block died` | | (a) the safety net and the retain loop's handle skip removed | nothing fails: the walk's early return keeps handles out first | -| (a) extended: the walk's early return removed too | the six own positive runs and `own_shared_count`'s own verifier run fail to compile | +| (a) extended: the walk's early return removed too | `own_shared_basic`, `own_shared_graph` and `own_shared_count` under own (AOT and JIT), and `own_shared_count`'s own verifier run, fail to compile | | (b) a handle root `Owned`, not `NotOwned` | `own_err_shared_borrow_call` compiles | | (c) the `.value` match removed from `dropsChain` | `own_err_shared_borrow_write` compiles | | (d) the inline push rule off | `own_err_shared_borrow_push` compiles | From 24024547c455b3a39a4d564ffd51f0178efed5f6 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 3 Oct 2026 14:04:59 +0100 Subject: [PATCH 13/21] -mm=own Shared: the final review's fixes - global handles, const handles, handle arguments, generators (spec 23) - A global handle is born with its count: own's moveIntoGlobals leaves a handle's retain in place, and a `Shared | null` global takes over its initializer's reference through the cast to its type (it was released as a discarded temporary, under rc as well). - A const of a union or optional handle (`const c = p.child`, `const c: Shared | null = a`) gets identity storage, as a bare one does: it is a handle of its own, counted. - A handle read out of a place is a borrow of the place, checked as a field read is, unless rc gives it an owner of its own (a retain, or an owning local's slot): `f(p, p.child)` with `f` overwriting `p.child` is rejected under own. - `s.value` and `Shared.count(s)` are borrows (isBorrow); a generator over a handle parameter owns its box, whose cell holds the handle's count, so it is no longer rejected and gives the count back. - Tests: own_shared_global, own_shared_unlink (all four models, AOT and JIT, verifier), own_err_shared_place_arg; a generator in own_shared_count; graph and union counts. Co-Authored-By: Claude Opus 5.5 --- tslang/lib/TypeScript/MLIRGenImpl.h | 25 ++++- .../lib/TypeScript/OwnershipInferencePass.cpp | 57 ++++++++++- .../lib/TypeScript/OwnershipSignaturePass.cpp | 13 +++ tslang/test/tester/CMakeLists.txt | 9 +- .../tester/own/own_err_shared_place_arg.ts | 31 ++++++ tslang/test/tester/own/own_shared_count.ts | 24 ++++- tslang/test/tester/own/own_shared_global.ts | 82 +++++++++++++++ tslang/test/tester/own/own_shared_graph.ts | 3 + tslang/test/tester/own/own_shared_union.ts | 3 +- tslang/test/tester/own/own_shared_unlink.ts | 99 +++++++++++++++++++ 10 files changed, 333 insertions(+), 13 deletions(-) create mode 100644 tslang/test/tester/own/own_err_shared_place_arg.ts create mode 100644 tslang/test/tester/own/own_shared_global.ts create mode 100644 tslang/test/tester/own/own_shared_unlink.ts diff --git a/tslang/lib/TypeScript/MLIRGenImpl.h b/tslang/lib/TypeScript/MLIRGenImpl.h index a7dec4a54..ada15157b 100644 --- a/tslang/lib/TypeScript/MLIRGenImpl.h +++ b/tslang/lib/TypeScript/MLIRGenImpl.h @@ -1495,12 +1495,14 @@ class MLIRGenImpl // (addSafeCastStatement, a SafeCastOp) is the narrowed variable seen through // another type, not an array of its own. // - // A `Shared` handle (own spec 23.1) as well: `const b = a` is a second handle, and - // counts as one. A folded name would be `a` itself, with no reference of its own, so - // `a = c` would free the block `b` still reads, and `Shared.count` would miss it. + // A `Shared` handle (own spec 23.1) as well, bare or in a union or optional + // (`Shared | null`): `const b = a` is a second handle, and counts as one. A folded + // name would be `a` itself, with no reference of its own, so `a = c` would free the + // block `b` still reads, and `Shared.count` would miss it. MLIRTypeHelper mth(builder.getContext(), compileOptions); needsIdentityStorage = mth.hasBoundMethodField(type) || isa(type) - || (isa(type) && !(varClass == VariableType::ConstRef) + || ((isa(type) || MLIRTypeHelper::isSharedHandleType(type)) + && !(varClass == VariableType::ConstRef) && !(initial && initial.getDefiningOp())); if (needsIdentityStorage) { @@ -1992,10 +1994,23 @@ class MLIRGenImpl variableDeclarationInfo.initial = builder.create(location, variableDeclarationInfo.type); } + // A handle global (own spec 23.1) takes over the reference its initializer carries, through + // the cast to its type as well: `let g: Shared | null = new Shared(x)`. Left unclaimed, + // the reference is a discarded temporary (§9.30), given back at the end of the region, + // and the global is born holding a block nobody counts. + if (MLIRTypeHelper::isSharedHandleType(variableDeclarationInfo.type)) + { + if (auto castOp = variableDeclarationInfo.initial.getDefiningOp(); + castOp && producesOwnedReference(castOp.getIn())) + { + consumeOwnedReference(castOp.getIn()); + } + } + builder.create(location, mlir::ValueRange{variableDeclarationInfo.initial}); return mlir::success(); - } + } mlir::LogicalResult createGlobalVariableUndefinedInitialization(mlir::Location location, mlir_ts::GlobalOp globalOp, struct VariableDeclarationInfo &variableDeclarationInfo) { diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index edc5becf5..b590a4a11 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -2357,8 +2357,9 @@ class OwnershipInferencePass { auto root = rootOf(value); auto *def = root.getDefiningOp(); - // a handle read out of a place is a counted copy, not a borrow (spec 23.3) - if (!def || !ownsHeap(root) || isHandle(root)) + // a handle read out of a place that rc gives an owner of its own is a counted copy, not a + // borrow (spec 23.3); any other is a borrow of the place, as a field read is + if (!def || !ownsHeap(root) || (isHandle(root) && isCountedHandleRead(root))) { return nullptr; } @@ -2378,6 +2379,52 @@ class OwnershipInferencePass return borrowingCall(root); } + // A handle read out of a place that rc gives a count of its own (spec 23.3): retained, which + // own leaves in place - a store into a field, an element or a global, a push, `new Shared`, + // an `any` box, a return - or the initializer of an owning local, which takes its own count + // (`ts.RetainSlot`). `cur = cur.value.next` is one. Seen through casts, merges and locals + // that own nothing. A handle read with none of these - an argument given straight to a call + // (`f(p, p.child)`), a `.value` read through it - is a borrow of the place. + static bool isCountedHandleRead(mlir::Value value) + { + llvm::SmallVector values{value}; + llvm::SmallPtrSet seen{value}; + while (!values.empty()) + { + auto current = values.pop_back_val(); + for (auto &use : current.getUses()) + { + auto *user = use.getOwner(); + if (mlir::isa(user)) + { + return true; + } + + if (auto varOp = mlir::dyn_cast(user); varOp && isOwningVariable(varOp) && + llvm::any_of(varOp->getUsers(), [](mlir::Operation *varUser) { + return mlir::isa(varUser); + })) + { + return true; + } + + llvm::SmallVector same; + if (passesOn(use, same)) + { + for (auto next : same) + { + if (isHandle(next) && seen.insert(next).second) + { + values.push_back(next); + } + } + } + } + } + + return false; + } + // The call a value is the result of, when that result borrows an argument. static mlir::Operation *borrowingCall(mlir::Value value) { @@ -3388,6 +3435,12 @@ class OwnershipInferencePass return true; } + // `s.value`'s place and `Shared.count(s)` read the handle and keep nothing (spec 23.1) + if (mlir::isa(user)) + { + return true; + } + // `--di`'s record of a folded const's value for the debugger: names it, keeps nothing if (mlir::isa(user)) { diff --git a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp index 8abad45ad..05c34e093 100644 --- a/tslang/lib/TypeScript/OwnershipSignaturePass.cpp +++ b/tslang/lib/TypeScript/OwnershipSignaturePass.cpp @@ -122,6 +122,12 @@ class OwnershipSignaturePass : public mlir::PassWrapper(); cellOp && isParameterCell(cellOp, maker, index)) { auto cellType = mlir::cast(cellOp.getType()); + // a handle parameter's cell holds a count of its own (spec 23.2), which only + // the box's release gives back: a box holding one owns what it holds + if (MLIRTypeHelper::isSharedHandleType(cellType.getElementType())) + { + return; + } + if (ownsHeap(cellOp.getLoc(), cellType.getElementType())) { state.bounded.push_back(index); diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index b816eb3ff..38c34179c 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2614,7 +2614,8 @@ add_test(NAME test-own-string-copy-in-store-block # own_shared_user_named: a program's own class named Shared wins over the built-in (spec 23.1). set(shared_models gc none rc own) foreach(shared_mm ${shared_models}) - foreach(shared_test own_shared_basic own_shared_user_named own_shared_graph own_shared_containers own_shared_cycle own_shared_union) + foreach(shared_test own_shared_basic own_shared_user_named own_shared_graph own_shared_containers own_shared_cycle own_shared_union + own_shared_global own_shared_unlink) tslang_add_test(NAME test-jit-${shared_mm}-${shared_test} COMMAND test-runner -jit -mm=${shared_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${shared_test}.ts") tslang_add_test(NAME test-compile-${shared_mm}-${shared_test} COMMAND test-runner -mm=${shared_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${shared_test}.ts") endforeach() @@ -2626,7 +2627,8 @@ foreach(shared_count_mm rc own) endforeach() # --verify-ownership is quiet on every positive (spec 23.5): handles keep rc's pairing. foreach(shared_verify_mm own rc) - foreach(shared_test own_shared_basic own_shared_graph own_shared_count own_shared_containers own_shared_cycle own_shared_union) + foreach(shared_test own_shared_basic own_shared_graph own_shared_count own_shared_containers own_shared_cycle own_shared_union + own_shared_global own_shared_unlink) add_test(NAME test-${shared_verify_mm}-verify-ownership-${shared_test} COMMAND $ --emit=mlir-affine --no-default-lib -mm=${shared_verify_mm} --verify-ownership "${PROJECT_SOURCE_DIR}/test/tester/own/${shared_test}.ts") @@ -2765,7 +2767,8 @@ set(own_error_cases "own_err_shared_borrow_other_type|but is used here after it may be released or overwritten" "own_err_shared_borrow_push_call|but is used here after it may be released or overwritten" "own_err_shared_borrow_reassigned|but is used here after it may be released or overwritten" - "own_err_shared_union_owned|a Shared in a union with 'string', which -mm=own does not count, is not supported") + "own_err_shared_union_owned|a Shared in a union with 'string', which -mm=own does not count, is not supported" + "own_err_shared_place_arg|borrows a field but is used here after it may be released or overwritten") foreach(own_error_case ${own_error_cases}) string(FIND "${own_error_case}" "|" own_error_sep) string(SUBSTRING "${own_error_case}" 0 ${own_error_sep} own_error_name) diff --git a/tslang/test/tester/own/own_err_shared_place_arg.ts b/tslang/test/tester/own/own_err_shared_place_arg.ts new file mode 100644 index 000000000..9d333d363 --- /dev/null +++ b/tslang/test/tester/own/own_err_shared_place_arg.ts @@ -0,0 +1,31 @@ +// Shared (spec 23.3): a handle read out of a field and given straight to a call is a borrow of +// the field, not a counted copy. The callee overwrites the field, which may give back the last +// count of the block the argument points to: rejected, as for a class read out of a field. +class Node { + v = 0; + tag = ""; +} + +class P { + child: Shared = new Shared(new Node()); +} + +function churn() { + let keep: string[] = []; + for (let i = 0; i < 1000; i++) keep.push("k" + i); + return keep.length; +} + +function f(p: P, s: Shared) { + p.child = new Shared(new Node()); + churn(); + print(Shared.count(s)); + print(s.value.tag); +} + +function main() { + const p = new P(); + p.child.value.tag = "alive"; + f(p, p.child); + print("done."); +} diff --git a/tslang/test/tester/own/own_shared_count.ts b/tslang/test/tester/own/own_shared_count.ts index 85d8fd588..b5964135b 100644 --- a/tslang/test/tester/own/own_shared_count.ts +++ b/tslang/test/tester/own/own_shared_count.ts @@ -1,8 +1,8 @@ // Shared (spec 23.1): Shared.count after each copy and drop - a handle made, copied, given to a // function that keeps it, returned from one, reassigned to the block it holds, copied in a loop, held // by another block that dies, replaced in a block by `s.value = x`, and captured as a parameter by a -// closure and by one that escapes; a narrowed `Shared | null`, and a handle boxed into `any` and -// read back out. +// closure and by one that escapes; a narrowed `Shared | null`, a handle boxed into `any` and read +// back out, and a handle given to a generator. class Node { v = 0; } @@ -38,6 +38,19 @@ function reader(s: Shared) { return () => s.value.v; } +// a generator over a handle parameter: its box holds the parameter's cell, which holds a count, and +// the generator reads through it with Shared.count and .value +function* counts(s: Shared) { + yield Shared.count(s); + yield s.value.v; +} + +function walkCounts(s: Shared) { + const seen: number[] = []; + for (const c of counts(s)) seen.push(c); + return seen; +} + function main() { const a = new Shared(new Node()); assert(Shared.count(a) == 1, "one handle"); @@ -117,5 +130,12 @@ function main() { assert(Shared.count(a) == 1, "every box and every handle read out of one gave its count back"); + a.value.v = 7; + for (let i = 0; i < 100; i++) { + const seen = walkCounts(a); + assert(seen.length == 2 && seen[0] == 2 && seen[1] == 7, "a generator over a handle holds a count"); + assert(Shared.count(a) == 1, "the generator's count given back"); + } + print("done."); } diff --git a/tslang/test/tester/own/own_shared_global.ts b/tslang/test/tester/own/own_shared_global.ts new file mode 100644 index 000000000..a028c3630 --- /dev/null +++ b/tslang/test/tester/own/own_shared_global.ts @@ -0,0 +1,82 @@ +// Shared (spec 23.1) in module-level globals, bare and `Shared | null`, each initialised by +// `new Shared(...)` at module level: each global is born holding one count. Each test copies a +// global, drops the global's own handle, reuses the memory, then reads through the copy; and the +// global that keeps its handle is read after its copy is dropped. Counts are checked where there +// is one (rc, own); gc and none report -1. +class Node { + v = 0; + tag = ""; +} + +function churn() { + let keep: Node[] = []; + for (let i = 0; i < 1000; i++) { + const n = new Node(); + n.v = -i; + n.tag = "junk" + i; + keep.push(n); + } + + return keep.length; +} + +function countIs(s: Shared, expected: number) { + const c = Shared.count(s); + return c < 0 || c == expected; +} + +function mk(tag: string, v: number) { + const n = new Node(); + n.tag = tag; + n.v = v; + return n; +} + +const root = new Shared(mk("root", 1)); +let bare = new Shared(mk("bare", 2)); +let maybe: Shared | null = new Shared(mk("maybe", 3)); + +// a const global keeps its handle: a copy is dropped, and the global still reads its block +function constGlobal() { + assert(countIs(root, 1), "a const global is born with one count"); + let x = root; + assert(countIs(root, 2), "copied"); + x = new Shared(mk("other", 9)); + assert(churn() == 1000); + assert(countIs(root, 1), "the copy dropped"); + assert(root.value.v == 1 && root.value.tag == "root", "the const global's block is alive"); + assert(countIs(x, 1)); +} + +// a let global's handle replaced: the copy is the only handle left +function bareGlobal() { + assert(countIs(bare, 1), "a let global is born with one count"); + const h = bare; + assert(countIs(h, 2), "copied"); + bare = new Shared(mk("replaced", 5)); + assert(churn() == 1000); + assert(countIs(h, 1), "the copy is the only handle left"); + assert(h.value.v == 2 && h.value.tag == "bare", "the copy's block is alive"); + assert(countIs(bare, 1) && bare.value.tag == "replaced"); +} + +// a `Shared | null` global set to null: the copy is the only handle left +function nullableGlobal() { + const g = maybe; + if (g) assert(countIs(g, 2), "a nullable global is born with one count; copied"); + maybe = null; + assert(churn() == 1000); + if (g) { + assert(countIs(g, 1), "the copy is the only handle left"); + assert(g.value.v == 3 && g.value.tag == "maybe", "the copy's block is alive"); + } + + assert(g !== null && maybe === null); +} + +function main() { + constGlobal(); + bareGlobal(); + nullableGlobal(); + print("done."); +} diff --git a/tslang/test/tester/own/own_shared_graph.ts b/tslang/test/tester/own/own_shared_graph.ts index b4323e07d..6f9646090 100644 --- a/tslang/test/tester/own/own_shared_graph.ts +++ b/tslang/test/tester/own/own_shared_graph.ts @@ -39,6 +39,9 @@ function main() { assert(churn() == 1000); const c = p2.child; assert(c !== null && c.value.name == "child1", "the other parent keeps the child"); + // a const copied out of a field is a handle of its own (spec 23.1): child, p2.child and c + const n = Shared.count(child); + assert(n < 0 || n == 3, "the const counts"); let sum = 0; let cur = list(10); diff --git a/tslang/test/tester/own/own_shared_union.ts b/tslang/test/tester/own/own_shared_union.ts index 9c23ee24e..8b26fdc26 100644 --- a/tslang/test/tester/own/own_shared_union.ts +++ b/tslang/test/tester/own/own_shared_union.ts @@ -34,10 +34,11 @@ function inLet(a: Shared) { assert(valueOf(u) == 7); } +// a const union is a handle of its own too (spec 23.1), as a let is function inConst(a: Shared) { const b = a; const u: Shared | number = b; - assert(countIs(a, 3), "a const union"); + assert(countIs(a, 4), "a const union holds a count"); assert(valueOf(u) == 7); } diff --git a/tslang/test/tester/own/own_shared_unlink.ts b/tslang/test/tester/own/own_shared_unlink.ts new file mode 100644 index 000000000..659e23dab --- /dev/null +++ b/tslang/test/tester/own/own_shared_unlink.ts @@ -0,0 +1,99 @@ +// Shared (spec 23.1): a const handle copied out of a place, or out of a local, is a handle of its +// own, bare or in a union. Each test drops the only other owner, reuses its memory, then reads +// through the const: unlinking a list node (spec 23.1's Node), taking a parent's child before +// clearing the field, and a `Shared | null` const of a let that is then reassigned. Counts are +// checked where there is one (rc, own); gc and none report -1. +class Node { + v = 0; + tag = ""; + next: Shared | null = null; +} + +class Parent { + child: Shared | null = null; +} + +function churn() { + let keep: Node[] = []; + for (let i = 0; i < 1000; i++) { + const n = new Node(); + n.v = -i; + n.tag = "junk" + i; + keep.push(n); + } + + return keep.length; +} + +function countIs(s: Shared, expected: number) { + const c = Shared.count(s); + return c < 0 || c == expected; +} + +function mk(tag: string, v: number) { + const n = new Node(); + n.tag = tag; + n.v = v; + return n; +} + +function link(head: Shared) { + head.value.next = new Shared(mk("second", 2)); +} + +function fill(p: Parent) { + p.child = new Shared(mk("child", 3)); +} + +// the list node unlinked from its head: `nxt` is its only handle +function unlink() { + const head = new Shared(new Node()); + link(head); + const nxt = head.value.next; + if (nxt) assert(countIs(nxt, 2), "the field and the const"); + head.value.next = null; + assert(churn() == 1000); + if (nxt) { + assert(countIs(nxt, 1), "the const is the only handle left"); + assert(nxt.value.v == 2 && nxt.value.tag == "second", "the unlinked node is alive"); + } + + assert(nxt !== null); +} + +// a parent's child taken into a const before the field is cleared +function takeChild() { + const p = new Parent(); + fill(p); + const c = p.child; + p.child = null; + assert(churn() == 1000); + if (c) { + assert(countIs(c, 1), "the const is the only handle left"); + assert(c.value.v == 3 && c.value.tag == "child", "the child is alive"); + } + + assert(c !== null); +} + +// a `Shared | null` const of a let, which is then given another block +function constOfLet() { + let a = new Shared(mk("alive", 42)); + const c: Shared | null = a; + assert(countIs(a, 2), "the let and the const"); + a = new Shared(mk("other", 7)); + assert(churn() == 1000); + if (c) { + assert(countIs(c, 1), "the const is the only handle left"); + assert(c.value.v == 42 && c.value.tag == "alive", "the const's block is alive"); + } + + assert(countIs(a, 1) && a.value.tag == "other"); +} + +function main() { + unlink(); + takeChild(); + constOfLet(); + print("done."); +} From fad98c536a7fb2cbedb78e3c707b2f6f1dc70307 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 3 Oct 2026 14:08:59 +0100 Subject: [PATCH 14/21] -mm=own Shared: handles copied inside a generator, in own_shared_count (spec 23.2) Co-Authored-By: Claude Opus 5.5 --- tslang/test/tester/own/own_shared_count.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tslang/test/tester/own/own_shared_count.ts b/tslang/test/tester/own/own_shared_count.ts index b5964135b..9f3c9fe2a 100644 --- a/tslang/test/tester/own/own_shared_count.ts +++ b/tslang/test/tester/own/own_shared_count.ts @@ -51,6 +51,15 @@ function walkCounts(s: Shared) { return seen; } +// handles copied inside a generator live in its state object, and are read by Shared.count there +function* copies() { + const s = new Shared(new Node()); + const t = s; + yield Shared.count(t); + const u = t; + yield Shared.count(u); +} + function main() { const a = new Shared(new Node()); assert(Shared.count(a) == 1, "one handle"); @@ -137,5 +146,9 @@ function main() { assert(Shared.count(a) == 1, "the generator's count given back"); } + const copied: number[] = []; + for (const c of copies()) copied.push(c); + assert(copied.length == 2 && copied[0] == 2 && copied[1] == 3, "handles copied inside a generator"); + print("done."); } From 476c4d8f523389c4a29bcfe187aacd96e8b54a71 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 3 Oct 2026 14:22:34 +0100 Subject: [PATCH 15/21] -mm=own Shared: the final review and its fix round in the results (spec 23.7) The const-handle note corrected, the fix round's tests, teeth, rejections and limits, the corpus and Linux rerun, and what was found and not fixed. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 129 +++++++++++++++--- 1 file changed, 112 insertions(+), 17 deletions(-) diff --git a/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md b/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md index 1ee2cd809..44064d8eb 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 @@ -2271,9 +2271,18 @@ branch keys on `!ts.shared`, a `ts.SharedValueRef`, or `isSharedHandleType`, so - The payload goes in with `mlirGenRetainCaptured` (a move, §2.3), and `ts.SharedNew`'s result is a fresh owned reference (`markFreshBlockOwned`). A `.value` place is an owning slot (`isOwnedSharedValueSlot`), so `s.value = x` is rc's `Retain`, `ReleaseSlot`, `Store`. - - **A `const` of a bare `Shared` gets identity storage** (`needsIdentityStorage`, as a `const` - array does), so `const b = a` is a second, counted handle (§23.1). A `const` of a union or - optional handle (`const c = p.child`) stays folded; no defect was found with it. + - **A `const` of a handle gets identity storage** (`needsIdentityStorage`, as a `const` array + does), so `const b = a` is a second, counted handle (§23.1). That is any handle + (`isSharedHandleType`): a bare `Shared`, and a union or optional one (`const c = p.child`, + `const c: Shared | null = a`). A narrowed view (`if (c)`) stays a view. Until the final + review's fix round, only a bare `Shared` had storage: a union `const` was folded into the + place it was read from, held no count, and got no check, so unlinking a node + (`const nxt = head.value.next; head.value.next = null`) left `nxt` reading freed memory under + rc and own. + - **A handle global is born holding one count.** A `Shared | null` global takes over its + initializer's reference through the cast to its type (`createGlobalVariableInitialization`). + Left unclaimed, the reference was a discarded temporary (§9.30), given back at the end of the + initializer, so the global held a block at count 0, under rc as well. - **The routines.** `ownsHeapMemory` is true for a handle. Its release routine is `__tslang_dec_ref`, and at zero the payload's routine and `__tslang_free_block`; its retain is `__tslang_inc_ref`. `emitIfLastReference(counted)` makes own count a handle where it tests @@ -2284,7 +2293,13 @@ branch keys on `!ts.shared`, a `ts.SharedValueRef`, or `isSharedHandleType`, so a field or element holding one, which stays a drop (a handle overwritten there may be the last one). A handle never becomes a candidate, by any of the four ways in, and a safety net takes any handle op out of the erase list; - - the signature pass gives no parameter or result fact to a handle; + - the signature pass gives no parameter or result fact to a handle, and `moveIntoGlobals` leaves + a handle's retain in a global's initializer in place: it is the global's count. Before the fix + round it erased it as a move, so a global handle was born at count 0 and freed while still + held, and a `Shared | null` global was rejected as "takes a second reference"; + - a generator over a handle parameter is not a borrowing state (`findBorrowingStates`): the + parameter's cell holds a count of its own, which only the box's release gives back, so its + box owns what it holds; - the lowering emits a surviving handle retain under own (`__tslang_inc_ref`), without "left a retain behind"; - a cell holding a handle (a captured handle parameter) is released by `ReleaseCell`, and a box @@ -2304,7 +2319,17 @@ branch keys on `!ts.shared`, a `ts.SharedValueRef`, or `isSharedHandleType`, so - a `push` or `unshift` on an array reached through a handle (`reachesSharedValue`) is a drop, inline and inside a callee (`dropsInBody`); - a function that stores a parameter into a handle's payload keeps that parameter (`keeps()` - sees `s.value = x` as a store into a place). + sees `s.value = x` as a store into a place); + - **a handle read out of a place is a borrow of the place** (`placeReadOf`), checked as a field + read is (`checkPlaceRead`), unless rc gives it a count of its own (`isCountedHandleRead`, + Ruling 7). That is a `ts.Retain` of it (a store into a field, an element or a global, a push, + `new Shared`, an `any` box, a return), or an owning local's slot (`ts.RetainSlot`). + `cur = cur.value.next` is retained, so walking stays legal. `f(p, p.child)`, where `f` + overwrites `p.child`, is rejected (`own_err_shared_place_arg`). Before the fix round every + handle read was exempt, and that program read freed memory under own. It still does under rc, + which retains no argument; + - `ts.SharedValueRef` and `ts.SharedCount` read a handle and keep nothing (`isBorrow`), so + `s.value` and `Shared.count(s)` are borrows of whatever `s` borrows. - **`any`.** A handle boxes under its own descriptor, named `"object"`, whose release and retain slots are the handle's routines, kept under own. A tag-free `Shared | null` boxes under the handle's descriptor, so a null in it is `typeof` `"object"`. `>a` goes through @@ -2313,15 +2338,26 @@ branch keys on `!ts.shared`, a `ts.SharedValueRef`, or `isSharedHandleType`, so #### Tests and teeth -- **Positive**, under gc, none, rc and own, AOT and JIT (48 runs): `own_shared_basic`, +- **Positive**, under gc, none, rc and own, AOT and JIT (64 runs): `own_shared_basic`, `own_shared_user_named`, `own_shared_graph`, `own_shared_containers`, `own_shared_cycle`, - `own_shared_union`. **Under rc and own** (4 runs): `own_shared_count`, with every count after a - copy, a drop, an outer block's death, a `.value` write, a narrowed union, an `any` round trip, a - captured handle parameter (two calls) and an escaping closure over one (100 iterations). - **`--verify-ownership`**, under own and rc, is quiet on the six counted files (12 runs). + `own_shared_union`, and two from the final review's fix round: + - `own_shared_global`: a `const`, a `let` and a `Shared | null` global, each initialised by + `new Shared(...)` at module level and born with one count. Each `let` global's handle is + dropped while a copy holds the block, the memory is reused, and the copy is read; + - `own_shared_unlink`: a list node unlinked from its head (§23.1's `Node`), a parent's child + taken into a `const` before the field is cleared, and a `Shared | null` const of a `let` + that is then given another block. In each the `const` is the only handle left when it is read. + + **Under rc and own** (4 runs): `own_shared_count`, with every count after a copy, a drop, an + outer block's death, a `.value` write, a narrowed union, an `any` round trip, a captured handle + parameter (two calls), an escaping closure over one (100 iterations), a generator over a handle + parameter (100 calls) and handles copied inside a generator. The fix round also asserts the + counts of a `const` union (`own_shared_union`: 4, not 3) and of a `const` read out of a field + (`own_shared_graph`: 3, under rc and own). **`--verify-ownership`**, under own and rc, is quiet + on the eight counted files (16 runs). - **`-O0`**: test-runner's `-noopt` does nothing in a Release build, so each positive was also run by hand with `--opt_level=0` under own and rc, AOT and JIT. -- **Negative** (8). Messages with `--di`; without it the borrower is `'this value'` and the place +- **Negative** (9). Messages with `--di`; without it the borrower is `'this value'` and the place "a field" or "an element", as for every own error: | test | message | @@ -2334,9 +2370,10 @@ branch keys on `!ts.shared`, a `ts.SharedValueRef`, or `isSharedHandleType`, so | `own_err_shared_borrow_reassigned` | `'n' borrows 'a.value' but is used here after …` (the handle itself reassigned) | | `own_err_shared_moved_in` | `is used here after its value was moved` | | `own_err_shared_union_owned` | `a Shared in a union with 'string', which -mm=own does not count, is not supported` | +| `own_err_shared_place_arg` | `'this value' borrows 'p.child' but is used here after …`, at `f(p, p.child)`, where `f` overwrites `p.child` | -- **Suites:** the Shared set is 72 tests, `ctest -R own` 562 and `ctest -R "own|rc"` 1516, all - passing on this branch's final build. +- **Suites:** the Shared set is 93 tests, `ctest -R own` 583 and `ctest -R "own|rc"` 1537, all + passing on this branch's final build (72, 562 and 1516 before the final review's fix round). - **Teeth**, each a temporary switch, with the JIT cache cleared: | switch | result | @@ -2354,6 +2391,12 @@ branch keys on `!ts.shared`, a `ts.SharedValueRef`, or `isSharedHandleType`, so | the `decideCells` handle return removed | `own_shared_count` under own: `a captured parameter's count given back` (a count leaked per call) | | the widened handle off | `own_shared_union` is rejected by the named rule | | the widened handle and the named rejection off (a tagged union is single-owner) | `own_shared_union`: `is used here after its value was moved`; a field loop crashes; the counts drop below rc's | +| fix round: `moveIntoGlobals` erases a handle's retain again | `own_shared_global` under own (AOT, JIT, verifier): `the initializer of global 'maybe' takes a second reference`; the reviewer's `gl1` prints `start 0 0` and crashes | +| fix round: a union global no longer takes over its initializer's reference | `own_shared_global` under rc and own: `a nullable global is born with one count; copied` | +| fix round: identity storage for a bare `Shared` only | `own_shared_unlink` under rc: `the field and the const`; under own it is rejected (`borrows a field but is used here after …`: the place-read check now sees the folded `const`); `own_shared_graph` and `own_shared_union` fail their counts under rc and own | +| fix round: the `placeReadOf` exemption for every handle restored | `own_err_shared_place_arg` compiles | +| fix round: `ts.SharedValueRef` and `ts.SharedCount` out of `isBorrow` | under own, `own_shared_count`: `borrows a field and cannot be stored, returned or captured` (handles copied inside a generator); `own_shared_containers`: `borrows an element and cannot be stored …` (`right[0].value.v`) | +| fix round: a generator over a handle parameter a borrowing state again | `own_shared_count` under own: `the generator's count given back` (a count leaked per call) | - **What (d) showed:** no existing rule ends an element borrow across a push through another handle; the push rule is the only one. Each other §23.3 rule also has its own negative. @@ -2395,15 +2438,42 @@ and so in the order of the functions and constants that follow it: | `00union_null_undefined_nonstrict` (§22.7) | 11 one order, 1 the other | 10, 2 | | `00union_bin_ops2` (`___bin_op_plus>`) | 8, 4 | 7, 5 | -Each binary gives exactly the same two normalised outputs, byte for byte. Under `-mm=rc`, 50 of +Each binary gives exactly the same two normalised outputs, byte for byte. After the final review's +fix round, the corpus is again 453 / 593 and 451 / 593 with the same first errors, and all 453 +files compile to the same normalised IR as main's (in that run both flipping files came out in +main's recorded order). Under `-mm=rc`, 50 of those files (every ninth) were compared the same way: 49 are identical, and `00union_bin_ops2` flips the same way (main 8 and 4, this branch 11 and 1, the same two outputs). +#### The final review and its fix round + +The final review of the branch found three critical defects and one important one. Every test +then kept a second owner alive, which is how all three critical ones got through. The fix round +fixed all four, each with a test that drops the only other owner before the read, and a tooth +(above): + +- **Critical 1, a global handle born at count 0.** `moveIntoGlobals` erased a global's handle + retain as a move: the global was freed while held (gl1), and a `Shared | null` global was + rejected. Under rc too, a `Shared | null` global gave back its initializer's reference + through the cast. Both are fixed (`own_shared_global`). +- **Critical 2, a union `const` folded.** `const c = p.child` and `const c: Shared | null = a` + held no count and got no check: §23.1's own unlink read freed memory under rc and own. Every + handle `const` now has storage (`own_shared_unlink`). +- **Critical 3, a handle argument neither counted nor checked.** `f(p, p.child)`, where `f` + overwrites `p.child`, freed the block `f` was reading. A handle read out of a place is now a + borrow unless rc counts it, and that program is rejected (`own_err_shared_place_arg`). +- **Important 4, a generator over a handle falsely rejected.** `isBorrow` did not list + `ts.SharedValueRef` and `ts.SharedCount`. With them listed, such a generator compiled but leaked + a count per call under own, because its state borrowed the box that holds the handle's cell. A + handle parameter now makes the box owned (`own_shared_count`). + #### Linux GCC, in WSL, the branch at `bbac9ba8`: it builds with no errors, and `ctest -R "own_shared|own-err|own-no-counting|own-verify|verify-ownership-own_shared"` passes 213 -of 213, the same 213 tests the Windows build lists for that pattern. +of 213, the same 213 tests the Windows build lists for that pattern. After the final review's fix +round, at `fad98c53` (the later commits change only documents), it builds with no errors and the +same pattern passes 234 of 234, again the Windows build's count. #### Outside the rule @@ -2418,7 +2488,17 @@ of 213, the same 213 tests the Windows build lists for that pattern. - a getter returning `s.value` is rejected (`borrows 's.value' and cannot be stored, returned or captured`): `borrowedParam` does not see through `ts.SharedValueRef`; - a write through a handle of an unrelated type ends borrows through every handle (p1d); - - a push or unshift through a handle ends element borrows, inline and in a callee alike. + - a push or unshift through a handle ends element borrows, inline and in a callee alike; + - a handle read out of a place and given straight to a call that may drop is a borrow (Ruling + 7), so the call ends it even where the callee retains the handle first. With `p` a parameter, + `keep(k, p.child)` is rejected when `keep` stores into a field. Copying it into a local first + (`const c = p.child; keep(k, c)`) is accepted, and counts; + - a generator over a handle parameter and another parameter that holds a block + (`function* g(s: Shared, xs: number[])`) is rejected: the handle's cell makes the box own + what it holds, and the other parameter is the caller's; + - a generator function expression capturing a local handle (`const it = function* () { yield + Shared.count(a); }`) is rejected as an escaping closure that cannot move the variable, as for + any local. - **`any` is checked by name only.** `>a` accepts any value tagged `"object"`, so a `Shared` passes as a `Shared` and its fields are read as `A`'s. Literals, numbers and class instances are rejected. A wrong-type unbox cannot be asserted in a test, because a `let` @@ -2441,6 +2521,10 @@ of 213, the same 213 tests the Windows build lists for that pattern. - **A bare `{ }` block** does not end a folded `const boxed: any = h`: §9.30 releases a fresh box at the end of its MLIR block, and a bare block in `main` is not one. Loops and functions are exact. +- **A handle returned from an imported function is counted one extra**, under rc and own alike. The + importer's `ts.CallIndirect` through the DLL's global carries no `__owned_result`, so its + reference is never given back: `const a = make(3)` is at 2. A class leaks the same way through + an import, before this phase as after (final review, `xm/import_w`). There is no test. - **Not tested:** a generic `function f(s: Shared)`; nothing rebuilds a `SharedType` with a substituted element. - **Resolved during the phase:** an escaping closure capturing a handle parameter, rejected at @@ -2453,7 +2537,18 @@ of 213, the same 213 tests the Windows build lists for that pattern. - #453: a push onto an array parameter is lost to the caller (gc sees the old length, rc frees the data block the caller still holds). `own_shared_count` keeps its handles in a class for this. - #454: under rc, `const b = a` of a `let` class value takes no reference, so `a = …` frees what - `b` reads. A `const` handle has its own storage and is not affected. + `b` reads. A `const` handle has its own storage and is not affected: a bare one from the start, + a union or optional one since the final review's fix round. Before it, `const c: Shared | null + = a` was folded, and `a = …` freed what `c` read, under rc and own. - #455: a `Node | null` cast to `any` crashes the compiler (a null tag). Fixed for `Shared | null` only. - #456: a `let` inside `try` whose initializer throws is not caught. +- #457: a throw in a `try` that declares a local crashes: the final review's t1, t6 and t10 under + rc and own, and t8 and t9 under rc (own rejects those two). +- Not filed: a union global of a class (`let G: Node | null = mk()`) gives back its initializer's + reference through the cast, as a handle one did, so it is freed at once: under own `G.v` reads + 0, under rc it reads reused memory. Fixed for handles only, since a general fix changes the IR + of programs without `Shared`. +- Not filed: ` | string>a`, an `any` unboxed to a union holding a string, is a + compile error in every model (`'ts.Cast' op type ['!ts.any'] can't be stored in …`; the + final review's u1). From 81c348ed2bfe8b7323747f80599f849622ebfff3 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 3 Oct 2026 15:22:22 +0100 Subject: [PATCH 16/21] -mm=own Shared: the second fix round - handle globals from any initializer, a handle read with two uses (spec 23) - A handle global takes over its initializer's reference through any view of its type (ts.Cast, ts.OptionalValue, ts.CreateUnionInstance), from `new Shared(..)` or from a call: OwnedReturnConsumptionPass claims it (claimHandleGlobalInitializers) once a call's result is known to carry one, which MLIRGen cannot see. `Shared | undefined = new Shared(..)` and `Shared | null = mk()` were born at count 0 under rc and own. The cast-only claim in MLIRGen goes. - A handle read out of a place is a counted copy only when every use of it is one rc counts (Ruling 7): one use that reads through the place makes it a borrow, tenure-checked, while its retain and the store it feeds stay a counted copy, and that store's release of the old value is not a drop of the read. `f(p, q, q.child = p.child)`, where `f` overwrites both fields, is rejected under own; `Shared.count(q.child = p.child)` stays legal. - Tests: own_shared_global_init and own_shared_place_two_uses (all four models, AOT and JIT, verifier), own_err_shared_place_two_uses, and a generator that outlives the caller's handle in own_shared_count. Docs (spec 23.7): the second fix round in the results - the narrowed global claim (a handle global initialised from another global or a field takes no count; #446 hides it), the per-use exemption, tests and suites (114 / 604 / 1558), teeth, corpus rerun, Linux 255/255, the generator rejection only when it reads the handle, #458, #459 and the not-filed findings. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 137 ++++++++++++--- tslang/lib/TypeScript/MLIRGenImpl.h | 14 +- .../TypeScript/OwnedReturnConsumptionPass.cpp | 63 +++++++ .../lib/TypeScript/OwnershipInferencePass.cpp | 157 ++++++++++++++++-- tslang/test/tester/CMakeLists.txt | 9 +- .../own/own_err_shared_place_two_uses.ts | 34 ++++ tslang/test/tester/own/own_shared_count.ts | 32 +++- .../test/tester/own/own_shared_global_init.ts | 147 ++++++++++++++++ .../tester/own/own_shared_place_two_uses.ts | 87 ++++++++++ 9 files changed, 620 insertions(+), 60 deletions(-) create mode 100644 tslang/test/tester/own/own_err_shared_place_two_uses.ts create mode 100644 tslang/test/tester/own/own_shared_global_init.ts create mode 100644 tslang/test/tester/own/own_shared_place_two_uses.ts diff --git a/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md b/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md index 44064d8eb..a4d62768b 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 @@ -2243,7 +2243,8 @@ Plan: `docs/superpowers/plans/2026-10-03-own-shared.md`. Changed: `TypeScriptTyp `TypeOfOpHelper.h` (the type), `MLIRGenTypes.cpp`, `MLIRGenExpressions.cpp`, `MLIRGenAccessCall.cpp`, `MLIRGenCast.cpp`, `MLIRGenImpl.h` (MLIRGen), `OwnershipRoutineLogic.h`, `CastLogicHelper.h`, `LowerToAffineLoops.cpp`, `LowerToLLVM.cpp` (the routines and lowering), -`OwnershipFacts.h`, `OwnershipInferencePass.cpp`, `OwnershipSignaturePass.cpp` (own). Every new +`OwnershipFacts.h`, `OwnershipInferencePass.cpp`, `OwnershipSignaturePass.cpp` (own), +`OwnedReturnConsumptionPass.cpp` (a handle global's initializer, every model). Every new branch keys on `!ts.shared`, a `ts.SharedValueRef`, or `isSharedHandleType`, so a program without `Shared` compiles as before (see "Unchanged where it compiles"). @@ -2279,10 +2280,21 @@ branch keys on `!ts.shared`, a `ts.SharedValueRef`, or `isSharedHandleType`, so place it was read from, held no count, and got no check, so unlinking a node (`const nxt = head.value.next; head.value.next = null`) left `nxt` reading freed memory under rc and own. - - **A handle global is born holding one count.** A `Shared | null` global takes over its - initializer's reference through the cast to its type (`createGlobalVariableInitialization`). - Left unclaimed, the reference was a discarded temporary (§9.30), given back at the end of the - initializer, so the global held a block at count 0, under rc as well. + - **A handle global initialised by a value that carries a reference is born holding it.** A + value given to `ts.GlobalResult` straight keeps its reference (a terminator is no use §9.30 + releases after). One given through a view of the global's type - `ts.Cast` to + `Shared | null`, `ts.OptionalValue` to `Shared | undefined`, `ts.CreateUnionInstance` - + was a discarded temporary, given back at the end of the initializer, so the global held a block + at count 0, under rc as well. `OwnedReturnConsumptionPass` now claims it through any such view + (`claimHandleGlobalInitializers`), from `new Shared(..)` or from a call: a call's result is + marked as carrying a reference only there, once its callee is classified, which is why + MLIRGen cannot see it. Keyed on the global's type. The first fix round claimed only a cast of + `new Shared(..)`, in MLIRGen; `Shared | undefined = new Shared(..)` and + `Shared | null = mk()` stayed at count 0 until the second (`own_shared_global_init`). + **Not covered:** a handle global initialised from a value that carries no reference - another + global (`let h = root`), a field - takes no count of its own. Today a global initialised from + another global does not read that global's value in any model (#446; the review's `glob.ts` + reads null for `d` and `h`), which hides it. - **The routines.** `ownsHeapMemory` is true for a handle. Its release routine is `__tslang_dec_ref`, and at zero the payload's routine and `__tslang_free_block`; its retain is `__tslang_inc_ref`. `emitIfLastReference(counted)` makes own count a handle where it tests @@ -2321,13 +2333,29 @@ branch keys on `!ts.shared`, a `ts.SharedValueRef`, or `isSharedHandleType`, so - a function that stores a parameter into a handle's payload keeps that parameter (`keeps()` sees `s.value = x` as a store into a place); - **a handle read out of a place is a borrow of the place** (`placeReadOf`), checked as a field - read is (`checkPlaceRead`), unless rc gives it a count of its own (`isCountedHandleRead`, - Ruling 7). That is a `ts.Retain` of it (a store into a field, an element or a global, a push, - `new Shared`, an `any` box, a return), or an owning local's slot (`ts.RetainSlot`). - `cur = cur.value.next` is retained, so walking stays legal. `f(p, p.child)`, where `f` - overwrites `p.child`, is rejected (`own_err_shared_place_arg`). Before the fix round every - handle read was exempt, and that program read freed memory under own. It still does under rc, - which retains no argument; + read is (`checkPlaceRead`), unless every use of it is one rc counts (`isCountedHandleRead`, + `handleReadUses`, Ruling 7). A counted use is a `ts.Retain` of it and what takes the value it + retains for (a store into a field, an element, a global or an owning local, a push, + `new Shared`, a return; `isCountedHandleReceiver`), or an owning local's slot + (`ts.RetainSlot`); an `any` box is a view whose own retain counts it. What makes the read a + borrow is a use `isBorrow` lists; a use that is neither (an op outside that list) does not, as + in the first round. A handle argument is always such a borrow, never a kept one, since a handle + gets no parameter facts: `keep(k, q.child = p.child)`, `keep` storing it, is rejected. + `cur = cur.value.next` + is counted only, so walking stays legal. `f(p, p.child)`, where `f` overwrites `p.child`, is + rejected (`own_err_shared_place_arg`). Before the first fix round every handle read was + exempt, and that program read freed memory under own. It still does under rc, which retains + no argument; + - **the exemption is per use, not per read** (second fix round). One read with a counted use and + one that reads through the place (`f(p, q, q.child = p.child)`) is a borrow: the counted uses + are a copy of their own in the borrow walk (`walkBorrowed`'s `countedHandle`), and the other + use is tenure-checked, so that program, where `f` overwrites both fields, is rejected + (`own_err_shared_place_two_uses`). The release of the old value a counted store makes right + before storing the retained read (`ts.Retain`, `ts.ReleaseSlot`, `ts.Store`) is not a drop + of the read (`collectCountedStoreReleases`): the block holds the retain's count by then, even + when the two fields are one. So `Shared.count(q.child = p.child)` and + `look(q.child = p.child)` stay legal (`own_shared_place_two_uses`). Until this round any one + counted use exempted the whole read, and that program read freed memory under own; - `ts.SharedValueRef` and `ts.SharedCount` read a handle and keep nothing (`isBorrow`), so `s.value` and `Shared.count(s)` are borrows of whatever `s` borrows. - **`any`.** A handle boxes under its own descriptor, named `"object"`, whose release and retain @@ -2338,9 +2366,16 @@ branch keys on `!ts.shared`, a `ts.SharedValueRef`, or `isSharedHandleType`, so #### Tests and teeth -- **Positive**, under gc, none, rc and own, AOT and JIT (64 runs): `own_shared_basic`, +- **Positive**, under gc, none, rc and own, AOT and JIT (80 runs): `own_shared_basic`, `own_shared_user_named`, `own_shared_graph`, `own_shared_containers`, `own_shared_cycle`, - `own_shared_union`, and two from the final review's fix round: + `own_shared_union`, two from the final review's fix round, and two from its second: + - `own_shared_global_init`: `Shared | undefined` from `new Shared(...)`, `let` and `const` + `Shared | null` from a call, an explicit cast of a call's result, `Shared | undefined` + and `Shared | number` from a call. Each asserts the global's count of 1, copies it, drops + the global's handle (the copy's, for the `const`), reuses the memory, and reads what is left; + - `own_shared_place_two_uses`: one read of `p.child` stored into `q.child` and read in the same + expression (`Shared.count(q.child = p.child)`, `look(q.child = p.child)`), and the same field + on both sides; `p.child` is then replaced, and `q.child` is read after the memory is reused; - `own_shared_global`: a `const`, a `let` and a `Shared | null` global, each initialised by `new Shared(...)` at module level and born with one count. Each `let` global's handle is dropped while a copy holds the block, the memory is reused, and the copy is read; @@ -2351,13 +2386,15 @@ branch keys on `!ts.shared`, a `ts.SharedValueRef`, or `isSharedHandleType`, so **Under rc and own** (4 runs): `own_shared_count`, with every count after a copy, a drop, an outer block's death, a `.value` write, a narrowed union, an `any` round trip, a captured handle parameter (two calls), an escaping closure over one (100 iterations), a generator over a handle - parameter (100 calls) and handles copied inside a generator. The fix round also asserts the + parameter (100 calls), handles copied inside a generator, and (second fix round) a generator + that reads its handle again after the caller has replaced its own and the memory is reused + (`2`, `42`, then `1`). The fix round also asserts the counts of a `const` union (`own_shared_union`: 4, not 3) and of a `const` read out of a field (`own_shared_graph`: 3, under rc and own). **`--verify-ownership`**, under own and rc, is quiet - on the eight counted files (16 runs). + on the ten counted files (20 runs). - **`-O0`**: test-runner's `-noopt` does nothing in a Release build, so each positive was also run by hand with `--opt_level=0` under own and rc, AOT and JIT. -- **Negative** (9). Messages with `--di`; without it the borrower is `'this value'` and the place +- **Negative** (10). Messages with `--di`; without it the borrower is `'this value'` and the place "a field" or "an element", as for every own error: | test | message | @@ -2371,9 +2408,11 @@ branch keys on `!ts.shared`, a `ts.SharedValueRef`, or `isSharedHandleType`, so | `own_err_shared_moved_in` | `is used here after its value was moved` | | `own_err_shared_union_owned` | `a Shared in a union with 'string', which -mm=own does not count, is not supported` | | `own_err_shared_place_arg` | `'this value' borrows 'p.child' but is used here after …`, at `f(p, p.child)`, where `f` overwrites `p.child` | +| `own_err_shared_place_two_uses` | `'this value' borrows a field but is used here after …`, at `f(p, q, q.child = p.child)`, where `f` overwrites both fields; the same with `--opt` | -- **Suites:** the Shared set is 93 tests, `ctest -R own` 583 and `ctest -R "own|rc"` 1537, all - passing on this branch's final build (72, 562 and 1516 before the final review's fix round). +- **Suites:** the Shared set is 114 tests, `ctest -R own` 604 and `ctest -R "own|rc"` 1558, all + passing on this branch's final build (72, 562 and 1516 before the final review's fix round; + 93, 583 and 1537 after it). - **Teeth**, each a temporary switch, with the JIT cache cleared: | switch | result | @@ -2397,6 +2436,10 @@ branch keys on `!ts.shared`, a `ts.SharedValueRef`, or `isSharedHandleType`, so | fix round: the `placeReadOf` exemption for every handle restored | `own_err_shared_place_arg` compiles | | fix round: `ts.SharedValueRef` and `ts.SharedCount` out of `isBorrow` | under own, `own_shared_count`: `borrows a field and cannot be stored, returned or captured` (handles copied inside a generator); `own_shared_containers`: `borrows an element and cannot be stored …` (`right[0].value.v`) | | fix round: a generator over a handle parameter a borrowing state again | `own_shared_count` under own: `the generator's count given back` (a count leaked per call) | +| second fix round: the global claim back to the first round's (a cast of `new Shared` only) | `own_shared_global_init` under rc and own, JIT and AOT: `an optional global from new Shared is born with one count`; the review's `v1` and `v3` print 0, `opt_new_min` 0, `null_call_min` garbage | +| second fix round: any one counted use exempts the whole read again | `own_err_shared_place_two_uses` compiles; the review's `hole.ts` crashes under own (`count 0`) | +| second fix round: a counted store's release of the old value a drop again | `own_shared_place_two_uses` under own (JIT, AOT, verifier): `borrows a field but is used here after …` | +| second fix round: the counted uses not a copy in the borrow walk | `own_shared_place_two_uses` under own (JIT, AOT, verifier) and `own_err_shared_place_two_uses`: `borrows a field and cannot be stored, returned or captured` | - **What (d) showed:** no existing rule ends an element borrow across a push through another handle; the push rule is the only one. Each other §23.3 rule also has its own negative. @@ -2445,6 +2488,13 @@ main's recorded order). Under `-mm=rc`, 50 of those files (every ninth) were compared the same way: 49 are identical, and `00union_bin_ops2` flips the same way (main 8 and 4, this branch 11 and 1, the same two outputs). +After the second fix round the corpus is again 453 / 593 and 451 / 593 with the same first +errors. Under own, 451 of the 453 are identical. `00union_null_undefined_nonstrict` came out in +main's other order, and so did `00funcs`, a third file of the same kind that had not flipped in +the earlier runs: the order of a union's members in `___cast,…>`. +Over 12 runs, main gives 11 and 1 of two outputs and this branch 10 and 2, the same two. Under rc, +49 of the 50 are identical, and `00union_bin_ops2` is main's other output again. + #### The final review and its fix round The final review of the branch found three critical defects and one important one. Every test @@ -2467,13 +2517,31 @@ fixed all four, each with a test that drops the only other owner before the read a count per call under own, because its state borrowed the box that holds the handle's cell. A handle parameter now makes the box owned (`own_shared_count`). +The re-review found Critical 1 fixed only in part, and one new important defect. A second fix +round, approved by the user, fixed both: + +- **Critical 1, the rest.** The first round's claim covered only a cast of `new Shared(..)`. A + `Shared | undefined` global from `new Shared(..)` (through `ts.OptionalValue`), and a + `Shared | null` global, `let` or `const`, from a call or a cast of one, were still born at + count 0: own read garbage and crashed, rc printed count 0. The claim moved to + `OwnedReturnConsumptionPass`, which sees through every view and knows a call's result + (`own_shared_global_init`). +- **Important, a counted use exempted its read.** `isCountedHandleRead` exempted a read when any + one of its uses was counted, so `f(p, q, q.child = p.child)`, where `f` overwrites both fields, + compiled under own and freed the block `f` read. The exemption is now per use: a read with an + uncounted use is a borrow, and its counted uses are a copy (`own_err_shared_place_two_uses`, + `own_shared_place_two_uses`). +- Also: a generator that outlives its caller's handle is asserted (`own_shared_count`). + #### Linux GCC, in WSL, the branch at `bbac9ba8`: it builds with no errors, and `ctest -R "own_shared|own-err|own-no-counting|own-verify|verify-ownership-own_shared"` passes 213 of 213, the same 213 tests the Windows build lists for that pattern. After the final review's fix round, at `fad98c53` (the later commits change only documents), it builds with no errors and the -same pattern passes 234 of 234, again the Windows build's count. +same pattern passes 234 of 234, again the Windows build's count. After the second fix round +(its changes applied to `476c4d8f` as a patch), it builds with +no errors and the same pattern passes 255 of 255, the Windows build's count. #### Outside the rule @@ -2493,9 +2561,14 @@ same pattern passes 234 of 234, again the Windows build's count. 7), so the call ends it even where the callee retains the handle first. With `p` a parameter, `keep(k, p.child)` is rejected when `keep` stores into a field. Copying it into a local first (`const c = p.child; keep(k, c)`) is accepted, and counts; - - a generator over a handle parameter and another parameter that holds a block - (`function* g(s: Shared, xs: number[])`) is rejected: the handle's cell makes the box own - what it holds, and the other parameter is the caller's; + - so is one read of a handle with a counted use and a use given to a call that may drop: with + `k` an owning local, `f(p, k = p.child)`, where `f` overwrites `p.child`, is rejected even + though `k` keeps the block alive (the review's `hole2.ts`). `k = p.child; f(p, k)` is + accepted; + - a generator over a handle parameter and another parameter that holds a block, that reads the + handle (`function* g(s: Shared, xs: number[])`), is rejected: the handle's cell makes the + box own what it holds, and the other parameter is the caller's. With the handle unread it + compiles (the review's `gen3.ts`; `gen3b.ts` reads it); - a generator function expression capturing a local handle (`const it = function* () { yield Shared.count(a); }`) is rejected as an escaping closure that cannot move the variable, as for any local. @@ -2545,10 +2618,22 @@ same pattern passes 234 of 234, again the Windows build's count. - #456: a `let` inside `try` whose initializer throws is not caught. - #457: a throw in a `try` that declares a local crashes: the final review's t1, t6 and t10 under rc and own, and t8 and t9 under rc (own rejects those two). -- Not filed: a union global of a class (`let G: Node | null = mk()`) gives back its initializer's +- #459: a union global of a class (`let G: Node | null = mk()`) gives back its initializer's reference through the cast, as a handle one did, so it is freed at once: under own `G.v` reads - 0, under rc it reads reused memory. Fixed for handles only, since a general fix changes the IR - of programs without `Shared`. + 0, under rc it reads reused memory. Fixed for handle globals only, through any view of the + global's type (`claimHandleGlobalInitializers`), since a general fix changes the IR of programs + without `Shared`. +- #458: an unbraced `else` whose body is a `for … of` over a generator, inside a loop, fails with + "operand does not dominate this use" (the re-review's `gen2.ts`; braced, `gen2b.ts` compiles and + counts as rc does). +- Not filed: an owning local initialised through a view of its type + (`let b: Shared | undefined = new Shared(..)`, `let e: Shared | null = mk()`) holds two + counts until the end of its block, where the initializer's reference is given back as a + discarded temporary (§9.30): `Shared.count` reads one high there (`gl/loc.ts`: 2, under rc and + own alike). It is balanced, not a leak. +- Not filed: a global whose initializer is a conditional (`let x = on() ? 1 : 2`) is a compile + error in every model (`'ts.Global' op expects region #0 to have 0 or 1 blocks`), so a handle + global cannot be initialised by one. - Not filed: ` | string>a`, an `any` unboxed to a union holding a string, is a compile error in every model (`'ts.Cast' op type ['!ts.any'] can't be stored in …`; the final review's u1). diff --git a/tslang/lib/TypeScript/MLIRGenImpl.h b/tslang/lib/TypeScript/MLIRGenImpl.h index ada15157b..3d8f95dcd 100644 --- a/tslang/lib/TypeScript/MLIRGenImpl.h +++ b/tslang/lib/TypeScript/MLIRGenImpl.h @@ -1995,18 +1995,8 @@ class MLIRGenImpl } // A handle global (own spec 23.1) takes over the reference its initializer carries, through - // the cast to its type as well: `let g: Shared | null = new Shared(x)`. Left unclaimed, - // the reference is a discarded temporary (§9.30), given back at the end of the region, - // and the global is born holding a block nobody counts. - if (MLIRTypeHelper::isSharedHandleType(variableDeclarationInfo.type)) - { - if (auto castOp = variableDeclarationInfo.initial.getDefiningOp(); - castOp && producesOwnedReference(castOp.getIn())) - { - consumeOwnedReference(castOp.getIn()); - } - } - + // a view of its type as well (`let g: Shared | null = mk()`): OwnedReturnConsumptionPass + // claims it (claimHandleGlobalInitializers), once a call's result is known to carry one. builder.create(location, mlir::ValueRange{variableDeclarationInfo.initial}); return mlir::success(); diff --git a/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp b/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp index aebd26023..a3e6f1ef2 100644 --- a/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp +++ b/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp @@ -123,6 +123,7 @@ class OwnedReturnConsumptionPass // back even when no function here is classified as returning owned. if (returnsOwned.empty()) { + claimHandleGlobalInitializers(module); releaseDiscardedTemporaries(mth, module); return; } @@ -235,9 +236,71 @@ class OwnedReturnConsumptionPass consumeConditionalResults(mth, module); + claimHandleGlobalInitializers(module); + releaseDiscardedTemporaries(mth, module); } + // A handle global (own spec 23.1) is born holding the reference its initializer carries. + // + // The initializer region ends in `ts.GlobalResult`, a terminator, so a value given to it + // straight is never released as a discarded temporary (§9.30): `let g: Shared = mk()` + // keeps the call's +1. A value given to it through a view of the global's type is: `ts.Cast` + // to `Shared | null`, `ts.OptionalValue` to `Shared | undefined`, `ts.CreateUnionInstance` + // - the view is the one use the producer has, and it is releasable, so the producer's + // reference was given back at the end of the region and the global was born at count 0. + // + // The producer is anything that carries an owned reference by now: `new Shared(..)`, marked + // where MLIRGen builds it, or a call, marked above once its callee is classified - which is + // why MLIRGen alone cannot see it. Keyed on the global's type: no other global changes. + void claimHandleGlobalInitializers(mlir::ModuleOp module) + { + module.walk([&](mlir_ts::GlobalOp globalOp) { + if (!MLIRTypeHelper::isSharedHandleType(globalOp.getType())) + { + return; + } + + auto *block = globalOp.getInitializerBlock(); + if (!block || block->empty()) + { + return; + } + + auto resultOp = mlir::dyn_cast(block->getTerminator()); + if (!resultOp || resultOp->getNumOperands() != 1) + { + return; + } + + auto value = resultOp->getOperand(0); + while (auto *definingOp = value.getDefiningOp()) + { + if (auto castOp = mlir::dyn_cast(definingOp)) + { + value = castOp.getIn(); + } + else if (auto optionalValueOp = mlir::dyn_cast(definingOp)) + { + value = optionalValueOp.getIn(); + } + else if (auto createUnionInstanceOp = mlir::dyn_cast(definingOp)) + { + value = createUnionInstanceOp.getIn(); + } + else + { + break; + } + } + + if (producesOwnedResult(value)) + { + value.getDefiningOp()->setAttr(OWNED_RESULT_CONSUMED_ATTR_NAME, mlir::UnitAttr::get(&getContext())); + } + }); + } + // A conditional expression whose branches disagree about ownership. // // `cond ? a : f()` builds a `ts.If` with a result, and the two branches hand back references diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index b590a4a11..b4a8235f9 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -2211,7 +2211,7 @@ class OwnershipInferencePass mlir::Operation *walkBorrowed( mlir::Value start, llvm::ArrayRef kills, llvm::SmallVectorImpl &uses, llvm::function_ref)> isBorrower = nullptr, - Keeping keeping = Keeping{nullptr, false}) + Keeping keeping = Keeping{nullptr, false}, bool countedHandle = false) { struct Pending { @@ -2276,6 +2276,14 @@ class OwnershipInferencePass continue; } + // a handle read that rc counts (spec 23.3): its retain, and what takes the value + // it retains for, are a counted copy of their own, as a string copy is; the + // read's other uses are the borrow's (Ruling 7) + if (countedHandle && isCountedHandleReceiver(user, pending.value)) + { + continue; + } + if (isBorrower && isBorrower(user, pending.kills)) { continue; @@ -2379,14 +2387,21 @@ class OwnershipInferencePass return borrowingCall(root); } - // A handle read out of a place that rc gives a count of its own (spec 23.3): retained, which - // own leaves in place - a store into a field, an element or a global, a push, `new Shared`, - // an `any` box, a return - or the initializer of an owning local, which takes its own count - // (`ts.RetainSlot`). `cur = cur.value.next` is one. Seen through casts, merges and locals - // that own nothing. A handle read with none of these - an argument given straight to a call - // (`f(p, p.child)`), a `.value` read through it - is a borrow of the place. - static bool isCountedHandleRead(mlir::Value value) + // How a handle read out of a place is used (spec 23.3), seen through casts, merges and locals + // that own nothing: `counted` when rc gives it an owner of its own - a retain, which own leaves + // in place, for what takes it (isCountedHandleReceiver), or an owning local's own count + // (`ts.RetainSlot`); `borrowed` when a use reads through the place instead - an argument given + // to a call (`f(p, p.child)`), a `.value` read, `Shared.count`. `cur = cur.value.next` is + // counted only. + struct HandleReadUses { + bool counted; + bool borrowed; + }; + + static HandleReadUses handleReadUses(mlir::Value value) + { + HandleReadUses result{false, false}; llvm::SmallVector values{value}; llvm::SmallPtrSet seen{value}; while (!values.empty()) @@ -2395,19 +2410,94 @@ class OwnershipInferencePass for (auto &use : current.getUses()) { auto *user = use.getOwner(); - if (mlir::isa(user)) + llvm::SmallVector same; + if (passesOn(use, same)) { - return true; + for (auto next : same) + { + if (isHandle(next) && seen.insert(next).second) + { + values.push_back(next); + } + } + + continue; } - if (auto varOp = mlir::dyn_cast(user); varOp && isOwningVariable(varOp) && - llvm::any_of(varOp->getUsers(), [](mlir::Operation *varUser) { - return mlir::isa(varUser); - })) + if (isCountedHandleReceiver(user, current)) { - return true; + result.counted |= mlir::isa(user); + continue; + } + + if (isBorrow(user, current)) + { + result.borrowed = true; } + } + } + + return result; + } + + // A handle read out of a place that rc gives a count of its own is a counted copy, not a borrow + // (spec 23.3) - but only when every use of it is one rc counts (Ruling 7). A read with one use + // that reads through the place as well (`f(p, q, q.child = p.child)`) is a borrow, and that + // use is tenure-checked; the counted uses are copies (checkPlaceRead). + static bool isCountedHandleRead(mlir::Value value) + { + auto uses = handleReadUses(value); + return uses.counted && !uses.borrowed; + } + + // A use that gives a handle an owner rc counts: rc's retain, and what takes the value it + // retains for - a store into a field, an element, a global or an owning local (a local that + // owns nothing passes the value on), a push, `new Shared`, a return - or the initializer of an + // owning local, which takes a count of its own (`ts.RetainSlot`). + static bool isCountedHandleReceiver(mlir::Operation *user, mlir::Value value) + { + if (mlir::isa(user)) + { + return true; + } + + if (auto varOp = mlir::dyn_cast(user)) + { + return isOwningVariable(varOp) && llvm::any_of(varOp->getUsers(), [](mlir::Operation *varUser) { + return mlir::isa(varUser); + }); + } + + if (auto storeOp = mlir::dyn_cast(user)) + { + return storeOp.getValue() == value; + } + if (auto pushOp = mlir::dyn_cast(user)) + { + return llvm::is_contained(pushOp.getItems(), value); + } + + if (auto unshiftOp = mlir::dyn_cast(user)) + { + return llvm::is_contained(unshiftOp.getItems(), value); + } + + return mlir::isa(user); + } + + // The releases of the old value that rc's counted stores of a handle read make: `ts.Retain` of + // the value, `ts.ReleaseSlot` of the slot, `ts.Store` of the value into it, in a row. Seen + // through the read's casts, merges and locals that own nothing, as handleReadUses sees it. + static void collectCountedStoreReleases(mlir::Value value, llvm::SmallPtrSetImpl &releases) + { + llvm::SmallVector values{value}; + llvm::SmallPtrSet seen{value}; + while (!values.empty()) + { + auto current = values.pop_back_val(); + for (auto &use : current.getUses()) + { llvm::SmallVector same; if (passesOn(use, same)) { @@ -2418,11 +2508,26 @@ class OwnershipInferencePass values.push_back(next); } } + + continue; + } + + auto storeOp = mlir::dyn_cast(use.getOwner()); + if (!storeOp || storeOp.getValue() != current) + { + continue; + } + + auto releaseSlotOp = mlir::dyn_cast_or_null(storeOp->getPrevNode()); + auto retainOp = releaseSlotOp ? mlir::dyn_cast_or_null(releaseSlotOp->getPrevNode()) + : mlir_ts::RetainOp(); + if (releaseSlotOp && retainOp && releaseSlotOp.getSlot() == storeOp.getReference() && + retainOp.getReference() == current) + { + releases.insert(releaseSlotOp); } } } - - return false; } // The call a value is the result of, when that result borrows an argument. @@ -2851,6 +2956,17 @@ class OwnershipInferencePass auto loadOp = mlir::dyn_cast(read); auto place = loadOp ? describePlace(loadOp.getReference()) : describeCall(read); auto result = read->getResult(0); + // A handle read rc counts (spec 23.3): its counted uses are copies (walkBorrowed), and the + // release of the old value a counted store makes, right before it stores the retained + // read, cannot free the block read: it holds the retain's count by then + // (`Shared.count(q.child = p.child)`). + auto countedHandle = isHandle(result) && handleReadUses(result).counted; + llvm::SmallPtrSet countedStoreReleases; + if (countedHandle) + { + collectCountedStoreReleases(result, countedStoreReleases); + } + llvm::StringRef name = ownerName(result); llvm::SmallVector uses; llvm::SmallVector bookkeeping; @@ -2875,7 +2991,7 @@ class OwnershipInferencePass } return true; - }, keeping); + }, keeping, countedHandle); if (kept || letKept) { @@ -2918,6 +3034,11 @@ class OwnershipInferencePass for (auto *drop : drops) { + if (countedStoreReleases.contains(drop)) + { + continue; + } + if (dropsChain(drop, chain, read) && outlives(drop)) { return false; diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 38c34179c..8891733c9 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2615,7 +2615,8 @@ add_test(NAME test-own-string-copy-in-store-block set(shared_models gc none rc own) foreach(shared_mm ${shared_models}) foreach(shared_test own_shared_basic own_shared_user_named own_shared_graph own_shared_containers own_shared_cycle own_shared_union - own_shared_global own_shared_unlink) + own_shared_global own_shared_unlink own_shared_global_init + own_shared_place_two_uses) tslang_add_test(NAME test-jit-${shared_mm}-${shared_test} COMMAND test-runner -jit -mm=${shared_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${shared_test}.ts") tslang_add_test(NAME test-compile-${shared_mm}-${shared_test} COMMAND test-runner -mm=${shared_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${shared_test}.ts") endforeach() @@ -2628,7 +2629,8 @@ endforeach() # --verify-ownership is quiet on every positive (spec 23.5): handles keep rc's pairing. foreach(shared_verify_mm own rc) foreach(shared_test own_shared_basic own_shared_graph own_shared_count own_shared_containers own_shared_cycle own_shared_union - own_shared_global own_shared_unlink) + own_shared_global own_shared_unlink own_shared_global_init + own_shared_place_two_uses) add_test(NAME test-${shared_verify_mm}-verify-ownership-${shared_test} COMMAND $ --emit=mlir-affine --no-default-lib -mm=${shared_verify_mm} --verify-ownership "${PROJECT_SOURCE_DIR}/test/tester/own/${shared_test}.ts") @@ -2768,7 +2770,8 @@ set(own_error_cases "own_err_shared_borrow_push_call|but is used here after it may be released or overwritten" "own_err_shared_borrow_reassigned|but is used here after it may be released or overwritten" "own_err_shared_union_owned|a Shared in a union with 'string', which -mm=own does not count, is not supported" - "own_err_shared_place_arg|borrows a field but is used here after it may be released or overwritten") + "own_err_shared_place_arg|borrows a field but is used here after it may be released or overwritten" + "own_err_shared_place_two_uses|borrows a field but is used here after it may be released or overwritten") foreach(own_error_case ${own_error_cases}) string(FIND "${own_error_case}" "|" own_error_sep) string(SUBSTRING "${own_error_case}" 0 ${own_error_sep} own_error_name) diff --git a/tslang/test/tester/own/own_err_shared_place_two_uses.ts b/tslang/test/tester/own/own_err_shared_place_two_uses.ts new file mode 100644 index 000000000..722df3e3e --- /dev/null +++ b/tslang/test/tester/own/own_err_shared_place_two_uses.ts @@ -0,0 +1,34 @@ +// Shared (spec 23.3, Ruling 7): one read of a handle out of a field with two uses. The store into +// `q.child` gives that use a counted owner of its own; the argument given to `f` does not, so it +// is a borrow of `p.child`. `f` overwrites both fields, which may give back the last count of the +// block the argument points to: rejected. The counted use does not exempt the other one. +class Node { + v = 0; + tag = ""; +} + +class P { + child: Shared = new Shared(new Node()); +} + +function churn() { + let keep: string[] = []; + for (let i = 0; i < 1000; i++) keep.push("k" + i); + return keep.length; +} + +function f(p: P, q: P, s: Shared) { + p.child = new Shared(new Node()); + q.child = new Shared(new Node()); + churn(); + print(Shared.count(s)); + print(s.value.tag); +} + +function main() { + const p = new P(); + const q = new P(); + p.child.value.tag = "alive"; + f(p, q, q.child = p.child); + print("done."); +} diff --git a/tslang/test/tester/own/own_shared_count.ts b/tslang/test/tester/own/own_shared_count.ts index 9f3c9fe2a..f5b0502b8 100644 --- a/tslang/test/tester/own/own_shared_count.ts +++ b/tslang/test/tester/own/own_shared_count.ts @@ -2,7 +2,7 @@ // function that keeps it, returned from one, reassigned to the block it holds, copied in a loop, held // by another block that dies, replaced in a block by `s.value = x`, and captured as a parameter by a // closure and by one that escapes; a narrowed `Shared | null`, a handle boxed into `any` and read -// back out, and a handle given to a generator. +// back out, and a handle given to a generator, also one that outlives the caller's handle. class Node { v = 0; } @@ -51,6 +51,25 @@ function walkCounts(s: Shared) { return seen; } +// a generator that reads its handle parameter again after the caller has dropped its own handle: +// the generator's count is the block's last +function* readsLater(s: Shared) { + yield Shared.count(s); + yield s.value.v; + yield Shared.count(s); +} + +function churn() { + let keep: Node[] = []; + for (let i = 0; i < 1000; i++) { + const n = new Node(); + n.v = -i; + keep.push(n); + } + + return keep.length; +} + // handles copied inside a generator live in its state object, and are read by Shared.count there function* copies() { const s = new Shared(new Node()); @@ -150,5 +169,16 @@ function main() { for (const c of copies()) copied.push(c); assert(copied.length == 2 && copied[0] == 2 && copied[1] == 3, "handles copied inside a generator"); + // the caller's handle replaced while a generator holds the block: the generator's count keeps it + let d = new Shared(new Node()); + d.value.v = 42; + const later = readsLater(d); + assert(later.next().value == 2, "the generator holds a count"); + d = new Shared(new Node()); + assert(churn() == 1000); + assert(later.next().value == 42, "the generator's block is alive after the caller's handle is dropped"); + assert(later.next().value == 1, "the generator's count is the block's last"); + assert(Shared.count(d) == 1); + print("done."); } diff --git a/tslang/test/tester/own/own_shared_global_init.ts b/tslang/test/tester/own/own_shared_global_init.ts new file mode 100644 index 000000000..d900dae6b --- /dev/null +++ b/tslang/test/tester/own/own_shared_global_init.ts @@ -0,0 +1,147 @@ +// Shared (spec 23.1) in module-level globals whose initializer reaches the global through a +// view of its type, or comes from a call: `Shared | undefined` from `new Shared(...)`, +// `Shared | null` (`let` and `const`) from a call, an explicit cast of a call's result, and +// `Shared | number`. Each global is born holding one count. Each test checks that count, copies +// the global, drops the global's own handle (or the copy, for the const), reuses the memory, then +// reads through what is left. Counts are checked where there is one (rc, own); gc and none +// report -1. +class Node { + v = 0; + tag = ""; +} + +function churn() { + let keep: Node[] = []; + for (let i = 0; i < 1000; i++) { + const n = new Node(); + n.v = -i; + n.tag = "junk" + i; + keep.push(n); + } + + return keep.length; +} + +function countIs(s: Shared, expected: number) { + const c = Shared.count(s); + return c < 0 || c == expected; +} + +function mk(tag: string, v: number) { + const n = new Node(); + n.tag = tag; + n.v = v; + return n; +} + +function mkS(tag: string, v: number) { + return new Shared(mk(tag, v)); +} + +let optNew: Shared | undefined = new Shared(mk("optNew", 1)); +let nullCall: Shared | null = mkS("nullCall", 2); +const constCall: Shared | null = mkS("constCall", 3); +let castCall = | null>mkS("castCall", 4); +let optCall: Shared | undefined = mkS("optCall", 5); +let numCall: Shared | number = mkS("numCall", 6); + +// `Shared | undefined` from `new Shared(...)`: the initializer goes through `ts.OptionalValue` +function optNewGlobal() { + if (optNew) assert(countIs(optNew, 1), "an optional global from new Shared is born with one count"); + const g = optNew; + if (g) assert(countIs(g, 2), "copied"); + optNew = undefined; + assert(churn() == 1000); + if (g) { + assert(countIs(g, 1), "the copy is the only handle left"); + assert(g.value.v == 1 && g.value.tag == "optNew", "the copy's block is alive"); + } + + assert(g !== undefined && optNew === undefined); +} + +// a `let` `Shared | null` from a call: the call's reference goes through the cast +function nullCallGlobal() { + if (nullCall) assert(countIs(nullCall, 1), "a nullable let global from a call is born with one count"); + const g = nullCall; + if (g) assert(countIs(g, 2), "copied"); + nullCall = null; + assert(churn() == 1000); + if (g) { + assert(countIs(g, 1), "the copy is the only handle left"); + assert(g.value.v == 2 && g.value.tag == "nullCall", "the copy's block is alive"); + } + + assert(g !== null && nullCall === null); +} + +// a `const` `Shared | null` from a call: it cannot be dropped, so the copy is, and the const +// still reads its block +function constCallGlobal() { + if (constCall) assert(countIs(constCall, 1), "a nullable const global from a call is born with one count"); + let x = constCall; + if (x) assert(countIs(x, 2), "copied"); + x = null; + assert(churn() == 1000); + if (constCall) { + assert(countIs(constCall, 1), "the copy dropped"); + assert(constCall.value.v == 3 && constCall.value.tag == "constCall", "the const global's block is alive"); + } + + assert(constCall !== null && x === null); +} + +// an explicit cast of a call's result +function castCallGlobal() { + if (castCall) assert(countIs(castCall, 1), "a cast call result global is born with one count"); + const g = castCall; + if (g) assert(countIs(g, 2), "copied"); + castCall = null; + assert(churn() == 1000); + if (g) { + assert(countIs(g, 1), "the copy is the only handle left"); + assert(g.value.v == 4 && g.value.tag == "castCall", "the copy's block is alive"); + } + + assert(g !== null && castCall === null); +} + +// `Shared | undefined` from a call +function optCallGlobal() { + if (optCall) assert(countIs(optCall, 1), "an optional global from a call is born with one count"); + const g = optCall; + if (g) assert(countIs(g, 2), "copied"); + optCall = undefined; + assert(churn() == 1000); + if (g) { + assert(countIs(g, 1), "the copy is the only handle left"); + assert(g.value.v == 5 && g.value.tag == "optCall", "the copy's block is alive"); + } + + assert(g !== undefined && optCall === undefined); +} + +// `Shared | number` from a call +function numCallGlobal() { + if (typeof numCall != "number") assert(countIs(numCall, 1), "a handle-or-number global from a call is born with one count"); + const g = numCall; + if (typeof g != "number") assert(countIs(g, 2), "copied"); + numCall = 7; + assert(churn() == 1000); + if (typeof g != "number") { + assert(countIs(g, 1), "the copy is the only handle left"); + assert(g.value.v == 6 && g.value.tag == "numCall", "the copy's block is alive"); + } + + assert(typeof g != "number" && typeof numCall == "number"); +} + +function main() { + optNewGlobal(); + nullCallGlobal(); + constCallGlobal(); + castCallGlobal(); + optCallGlobal(); + numCallGlobal(); + print("done."); +} diff --git a/tslang/test/tester/own/own_shared_place_two_uses.ts b/tslang/test/tester/own/own_shared_place_two_uses.ts new file mode 100644 index 000000000..fb88a162c --- /dev/null +++ b/tslang/test/tester/own/own_shared_place_two_uses.ts @@ -0,0 +1,87 @@ +// Shared (spec 23.3, Ruling 7): one read of a handle out of a field with two uses, a store into +// another field, which rc counts, and a read through it, which borrows the field read from. With +// nothing that may drop the field in between, both are legal: the store's release of the field's +// old value cannot free the block read, which already holds the store's count. The positive twin +// of own_err_shared_place_two_uses. Each test then drops the field read from, reuses its memory, and +// reads through the field stored into. Counts are checked where there is one (rc, own); gc and none +// report -1. +class Node { + v = 0; + tag = ""; +} + +class P { + child: Shared = new Shared(new Node()); +} + +function churn() { + let keep: Node[] = []; + for (let i = 0; i < 1000; i++) { + const n = new Node(); + n.v = -i; + n.tag = "junk" + i; + keep.push(n); + } + + return keep.length; +} + +function look(s: Shared) { + return s.value.v; +} + +function mkP(tag: string, v: number) { + const p = new P(); + p.child.value.tag = tag; + p.child.value.v = v; + return p; +} + +// `Shared.count` of the assignment: the store holds one count, the field read from the other +function countOfAssignment(p: P, q: P) { + return Shared.count(q.child = p.child); +} + +// the assignment given to a call that drops nothing +function lookAtAssignment(p: P, q: P) { + return look(q.child = p.child); +} + +function storedAndCounted() { + const p = mkP("first", 1); + const q = mkP("other", 9); + const c = countOfAssignment(p, q); + assert(c < 0 || c == 2, "the store and the field each hold a count"); + p.child = new Shared(new Node()); + assert(churn() == 1000); + const n = Shared.count(q.child); + assert(n < 0 || n == 1, "the store's count is the last"); + assert(q.child.value.v == 1 && q.child.value.tag == "first", "the stored block is alive"); +} + +function storedAndRead() { + const p = mkP("second", 2); + const q = mkP("other", 9); + assert(lookAtAssignment(p, q) == 2, "read through the assignment"); + p.child = new Shared(new Node()); + assert(churn() == 1000); + const n = Shared.count(q.child); + assert(n < 0 || n == 1, "the store's count is the last"); + assert(q.child.value.v == 2 && q.child.value.tag == "second", "the stored block is alive"); +} + +// the same field on both sides: the retain comes before the release of the old value +function sameObject() { + const p = mkP("same", 3); + const c = countOfAssignment(p, p); + assert(c < 0 || c == 1, "a field assigned its own value keeps one count"); + assert(churn() == 1000); + assert(p.child.value.v == 3 && p.child.value.tag == "same", "the block is alive"); +} + +function main() { + storedAndCounted(); + storedAndRead(); + sameObject(); + print("done."); +} From 866520d1dbee5ce6659bc4ad048f0efd37f41b41 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 3 Oct 2026 16:32:55 +0100 Subject: [PATCH 17/21] -mm=own Shared: the third fix round - a store counts only when rc retains for it, handle globals from a field (spec 23) A handle read out of a place is exempt from the borrow check only when every use gives it a counted owner (Ruling 7). Any ts.Store of the read counted as one, so `{ s: (q.child = p.child) }` - one store rc retains for (q.child), one it does not (the literal's field) - compiled under own and crashed. - OwnershipInferencePass: countedStoreRetain finds rc's retain for a store (Retain(v), [ReleaseSlot(slot)], Store(v, slot)). isCountedHandleReceiver takes a store only with it, so walkBorrowed skips only such stores, and collectCountedStoreReleases shares it. handleReadUses marks a store with no retain as an uncounted use (isUncountedStoreOf), so the read is checked and the store is its keep. - OwnedReturnConsumptionPass: a handle global initialised from a field read (`holder.child`, `new P().child`, `mkP().child`) gets a ts.Retain of its own right after the read; it was born at count 0 under rc and own. Erased under gc and none; keyed on the handle type. - Tests: own_err_shared_place_literal (negative), own_shared_global_field (positive, gc/none/rc/own, JIT and AOT, verifier under own and rc). Co-Authored-By: Claude Opus 5.5 --- .../TypeScript/OwnedReturnConsumptionPass.cpp | 18 +++- .../lib/TypeScript/OwnershipInferencePass.cpp | 45 +++++++-- tslang/test/tester/CMakeLists.txt | 7 +- .../own/own_err_shared_place_literal.ts | 36 +++++++ .../tester/own/own_shared_global_field.ts | 94 +++++++++++++++++++ 5 files changed, 186 insertions(+), 14 deletions(-) create mode 100644 tslang/test/tester/own/own_err_shared_place_literal.ts create mode 100644 tslang/test/tester/own/own_shared_global_field.ts diff --git a/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp b/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp index a3e6f1ef2..db3780c60 100644 --- a/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp +++ b/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp @@ -252,7 +252,9 @@ class OwnedReturnConsumptionPass // // The producer is anything that carries an owned reference by now: `new Shared(..)`, marked // where MLIRGen builds it, or a call, marked above once its callee is classified - which is - // why MLIRGen alone cannot see it. Keyed on the global's type: no other global changes. + // why MLIRGen alone cannot see it. A handle read out of a field carries none, and gets a + // retain of its own. Keyed on the global's type: no other global changes. A read of another + // global is left alone (#446: it reads null today in every model). void claimHandleGlobalInitializers(mlir::ModuleOp module) { module.walk([&](mlir_ts::GlobalOp globalOp) { @@ -297,6 +299,20 @@ class OwnedReturnConsumptionPass if (producesOwnedResult(value)) { value.getDefiningOp()->setAttr(OWNED_RESULT_CONSUMED_ATTR_NAME, mlir::UnitAttr::get(&getContext())); + return; + } + + // A field read (`holder.child`, `new P().child`, `mkP().child`) carries no reference: + // the field's object keeps its count, and a temporary object gives it back at the end + // of the region. The global takes a count of its own, retained right where it is + // read - before the temporary's release. Erased under gc and none. + if (auto loadOp = value.getDefiningOp(); + loadOp && MLIRTypeHelper::isSharedHandleType(value.getType()) && + loadOp.getReference().getDefiningOp()) + { + mlir::OpBuilder builder(loadOp->getContext()); + builder.setInsertionPointAfter(loadOp); + builder.create(loadOp->getLoc(), value); } }); } diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index b4a8235f9..a94c0128b 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -2430,7 +2430,10 @@ class OwnershipInferencePass continue; } - if (isBorrow(user, current)) + // a store of the read that rc made no retain for (an object literal's field, + // `{ s: (q.child = p.child) }`) keeps it uncounted: the read is checked as a + // borrow, and the store is its keep + if (isBorrow(user, current) || isUncountedStoreOf(user, current)) { result.borrowed = true; } @@ -2452,8 +2455,10 @@ class OwnershipInferencePass // A use that gives a handle an owner rc counts: rc's retain, and what takes the value it // retains for - a store into a field, an element, a global or an owning local (a local that - // owns nothing passes the value on), a push, `new Shared`, a return - or the initializer of an - // owning local, which takes a count of its own (`ts.RetainSlot`). + // owns nothing passes the value on) that rc retained for (countedStoreRetain), a push, + // `new Shared`, a return - or the initializer of an owning local, which takes a count of its + // own (`ts.RetainSlot`). A store rc made no retain for is not one: it keeps the read without + // a count (`{ s: (q.child = p.child) }`'s field). static bool isCountedHandleReceiver(mlir::Operation *user, mlir::Value value) { if (mlir::isa(user)) @@ -2470,7 +2475,7 @@ class OwnershipInferencePass if (auto storeOp = mlir::dyn_cast(user)) { - return storeOp.getValue() == value; + return storeOp.getValue() == value && countedStoreRetain(storeOp); } if (auto pushOp = mlir::dyn_cast(user)) @@ -2513,16 +2518,12 @@ class OwnershipInferencePass } auto storeOp = mlir::dyn_cast(use.getOwner()); - if (!storeOp || storeOp.getValue() != current) + if (!storeOp || storeOp.getValue() != current || !countedStoreRetain(storeOp)) { continue; } - auto releaseSlotOp = mlir::dyn_cast_or_null(storeOp->getPrevNode()); - auto retainOp = releaseSlotOp ? mlir::dyn_cast_or_null(releaseSlotOp->getPrevNode()) - : mlir_ts::RetainOp(); - if (releaseSlotOp && retainOp && releaseSlotOp.getSlot() == storeOp.getReference() && - retainOp.getReference() == current) + if (auto releaseSlotOp = mlir::dyn_cast_or_null(storeOp->getPrevNode())) { releases.insert(releaseSlotOp); } @@ -2530,6 +2531,30 @@ class OwnershipInferencePass } } + // rc's retain for a store of a value, when it made one: `ts.Retain` of the value right before + // the store, or before the `ts.ReleaseSlot` of the old value right before it - + // `Retain(v)`, [`ReleaseSlot(slot)`], `Store(v, slot)`. Null for a store rc counts nothing for. + static mlir::Operation *countedStoreRetain(mlir_ts::StoreOp storeOp) + { + auto *previous = storeOp->getPrevNode(); + if (auto releaseSlotOp = mlir::dyn_cast_or_null(previous)) + { + previous = releaseSlotOp.getSlot() == storeOp.getReference() ? releaseSlotOp->getPrevNode() : nullptr; + } + + auto retainOp = mlir::dyn_cast_or_null(previous); + return retainOp && retainOp.getReference() == storeOp.getValue() ? retainOp.getOperation() : nullptr; + } + + // A store of a handle read that rc made no retain for (countedStoreRetain): it keeps the read + // with no count of its own. A store into a local that owns nothing passes the value on + // (passesOn) and is not seen here. + static bool isUncountedStoreOf(mlir::Operation *user, mlir::Value value) + { + auto storeOp = mlir::dyn_cast(user); + return storeOp && storeOp.getValue() == value && !countedStoreRetain(storeOp); + } + // The call a value is the result of, when that result borrows an argument. static mlir::Operation *borrowingCall(mlir::Value value) { diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 8891733c9..012e4a3ac 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2616,7 +2616,7 @@ set(shared_models gc none rc own) foreach(shared_mm ${shared_models}) foreach(shared_test own_shared_basic own_shared_user_named own_shared_graph own_shared_containers own_shared_cycle own_shared_union own_shared_global own_shared_unlink own_shared_global_init - own_shared_place_two_uses) + own_shared_place_two_uses own_shared_global_field) tslang_add_test(NAME test-jit-${shared_mm}-${shared_test} COMMAND test-runner -jit -mm=${shared_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${shared_test}.ts") tslang_add_test(NAME test-compile-${shared_mm}-${shared_test} COMMAND test-runner -mm=${shared_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${shared_test}.ts") endforeach() @@ -2630,7 +2630,7 @@ endforeach() foreach(shared_verify_mm own rc) foreach(shared_test own_shared_basic own_shared_graph own_shared_count own_shared_containers own_shared_cycle own_shared_union own_shared_global own_shared_unlink own_shared_global_init - own_shared_place_two_uses) + own_shared_place_two_uses own_shared_global_field) add_test(NAME test-${shared_verify_mm}-verify-ownership-${shared_test} COMMAND $ --emit=mlir-affine --no-default-lib -mm=${shared_verify_mm} --verify-ownership "${PROJECT_SOURCE_DIR}/test/tester/own/${shared_test}.ts") @@ -2771,7 +2771,8 @@ set(own_error_cases "own_err_shared_borrow_reassigned|but is used here after it may be released or overwritten" "own_err_shared_union_owned|a Shared in a union with 'string', which -mm=own does not count, is not supported" "own_err_shared_place_arg|borrows a field but is used here after it may be released or overwritten" - "own_err_shared_place_two_uses|borrows a field but is used here after it may be released or overwritten") + "own_err_shared_place_two_uses|borrows a field but is used here after it may be released or overwritten" + "own_err_shared_place_literal|borrows a field and cannot be stored, returned or captured") foreach(own_error_case ${own_error_cases}) string(FIND "${own_error_case}" "|" own_error_sep) string(SUBSTRING "${own_error_case}" 0 ${own_error_sep} own_error_name) diff --git a/tslang/test/tester/own/own_err_shared_place_literal.ts b/tslang/test/tester/own/own_err_shared_place_literal.ts new file mode 100644 index 000000000..c91ab1fcc --- /dev/null +++ b/tslang/test/tester/own/own_err_shared_place_literal.ts @@ -0,0 +1,36 @@ +// Shared (spec 23.3, Ruling 7): one read of a handle out of a field with two uses. The store into +// `q.child` gives that use a counted owner of its own (rc retains for it); the object literal's +// field does not (rc retains nothing for it), so that store keeps a borrow of `p.child`. `dropBoth` +// overwrites both fields and gives back the last count of the block the literal points to: +// rejected. A store counts only when rc retains for it; the counted store does not exempt the other. +class Node { + v = 0; + tag = ""; +} + +class P { + child: Shared = new Shared(new Node()); +} + +function churn() { + let keep: string[] = []; + for (let i = 0; i < 1000; i++) keep.push("k" + i); + return keep.length; +} + +function dropBoth(p: P, q: P) { + p.child = new Shared(new Node()); + q.child = new Shared(new Node()); + churn(); +} + +function main() { + const p = new P(); + const q = new P(); + p.child.value.tag = "alive"; + const o = { s: (q.child = p.child) }; + dropBoth(p, q); + print(Shared.count(o.s)); + print(o.s.value.tag); + print("done."); +} diff --git a/tslang/test/tester/own/own_shared_global_field.ts b/tslang/test/tester/own/own_shared_global_field.ts new file mode 100644 index 000000000..a1b0feb48 --- /dev/null +++ b/tslang/test/tester/own/own_shared_global_field.ts @@ -0,0 +1,94 @@ +// Shared (spec 23.1) in module-level globals initialised from a field read: of another global's +// object (`holder.child`), of a temporary (`new P().child`), and of a call's result +// (`mkP().child`). A field read carries no reference of its own, so the global takes one: it is +// born at count 2 when the field's object lives on, and at count 1 when the object was a +// temporary given back at the end of the initializer. Each test drops the other handle, reuses +// the memory, then reads through the global. Counts are checked where there is one (rc, own); gc +// and none report -1. +class Node { + v = 0; + tag = ""; +} + +function churn() { + let keep: Node[] = []; + for (let i = 0; i < 1000; i++) { + const n = new Node(); + n.v = -i; + n.tag = "junk" + i; + keep.push(n); + } + + return keep.length; +} + +function countIs(s: Shared, expected: number) { + const c = Shared.count(s); + return c < 0 || c == expected; +} + +function mk(tag: string, v: number) { + const n = new Node(); + n.tag = tag; + n.v = v; + return n; +} + +class P { + child: Shared = new Shared(mk("child", 9)); +} + +function mkP(tag: string, v: number) { + const p = new P(); + p.child.value.tag = tag; + p.child.value.v = v; + return p; +} + +const holder = mkP("held", 1); +let fromHeld: Shared | null = holder.child; +let fromTemp: Shared = new P().child; +let fromCall: Shared | null = mkP("called", 3).child; + +// `holder.child`: the field and the global hold one count each +function fromHeldGlobal() { + if (fromHeld) assert(countIs(fromHeld, 2), "a global from a live object's field takes a count of its own"); + holder.child = new Shared(mk("other", 0)); + assert(churn() == 1000); + if (fromHeld) { + assert(countIs(fromHeld, 1), "the global is the only handle left"); + assert(fromHeld.value.v == 1 && fromHeld.value.tag == "held", "the global's block is alive"); + } + + assert(fromHeld !== null); +} + +// `new P().child`: the temporary gave its count back; the global's is the only one +function fromTempGlobal() { + assert(countIs(fromTemp, 1), "a global from a temporary's field is born with one count"); + assert(churn() == 1000); + assert(countIs(fromTemp, 1), "still one"); + assert(fromTemp.value.v == 9 && fromTemp.value.tag == "child", "the global's block is alive"); +} + +// `mkP(..).child`: as the temporary, through a call +function fromCallGlobal() { + if (fromCall) assert(countIs(fromCall, 1), "a global from a call result's field is born with one count"); + const g = fromCall; + if (g) assert(countIs(g, 2), "copied"); + fromCall = null; + assert(churn() == 1000); + if (g) { + assert(countIs(g, 1), "the copy is the only handle left"); + assert(g.value.v == 3 && g.value.tag == "called", "the copy's block is alive"); + } + + assert(g !== null && fromCall === null); +} + +function main() { + fromHeldGlobal(); + fromTempGlobal(); + fromCallGlobal(); + print("done."); +} From 3a86e20d143525dd26b94b4da51b879304ee84ac Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 3 Oct 2026 16:39:26 +0100 Subject: [PATCH 18/21] -mm=own Shared: a handle global from an element read takes its own count too (spec 23) `let g: Shared | null = mkArr()[0]` read an element of a temporary array, which gave its count back at the end of the initializer: the global was born at count 0 under rc and own, as from a field. claimHandleGlobalInitializers retains a handle read out of an element as it does one read out of a field. own_shared_global_field asserts it. Co-Authored-By: Claude Opus 5.5 --- .../TypeScript/OwnedReturnConsumptionPass.cpp | 20 +++++++------ .../tester/own/own_shared_global_field.ts | 28 +++++++++++++++---- 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp b/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp index db3780c60..908fe00b6 100644 --- a/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp +++ b/tslang/lib/TypeScript/OwnedReturnConsumptionPass.cpp @@ -252,8 +252,8 @@ class OwnedReturnConsumptionPass // // The producer is anything that carries an owned reference by now: `new Shared(..)`, marked // where MLIRGen builds it, or a call, marked above once its callee is classified - which is - // why MLIRGen alone cannot see it. A handle read out of a field carries none, and gets a - // retain of its own. Keyed on the global's type: no other global changes. A read of another + // why MLIRGen alone cannot see it. A handle read out of a field or an element carries none, + // and gets a retain of its own. Keyed on the global's type: no other global changes. A read of another // global is left alone (#446: it reads null today in every model). void claimHandleGlobalInitializers(mlir::ModuleOp module) { @@ -302,13 +302,15 @@ class OwnedReturnConsumptionPass return; } - // A field read (`holder.child`, `new P().child`, `mkP().child`) carries no reference: - // the field's object keeps its count, and a temporary object gives it back at the end - // of the region. The global takes a count of its own, retained right where it is - // read - before the temporary's release. Erased under gc and none. - if (auto loadOp = value.getDefiningOp(); - loadOp && MLIRTypeHelper::isSharedHandleType(value.getType()) && - loadOp.getReference().getDefiningOp()) + // A field or element read (`holder.child`, `new P().child`, `mkP().child`, + // `mkArr()[0]`) carries no reference: the object or array keeps its count, and a + // temporary one gives it back at the end of the region. The global takes a count of its + // own, retained right where it is read - before the temporary's release. Erased under + // gc and none. + auto loadOp = value.getDefiningOp(); + auto *place = loadOp ? loadOp.getReference().getDefiningOp() : nullptr; + if (place && MLIRTypeHelper::isSharedHandleType(value.getType()) && + mlir::isa(place)) { mlir::OpBuilder builder(loadOp->getContext()); builder.setInsertionPointAfter(loadOp); diff --git a/tslang/test/tester/own/own_shared_global_field.ts b/tslang/test/tester/own/own_shared_global_field.ts index a1b0feb48..d68bfa4cd 100644 --- a/tslang/test/tester/own/own_shared_global_field.ts +++ b/tslang/test/tester/own/own_shared_global_field.ts @@ -1,8 +1,8 @@ -// Shared (spec 23.1) in module-level globals initialised from a field read: of another global's -// object (`holder.child`), of a temporary (`new P().child`), and of a call's result -// (`mkP().child`). A field read carries no reference of its own, so the global takes one: it is -// born at count 2 when the field's object lives on, and at count 1 when the object was a -// temporary given back at the end of the initializer. Each test drops the other handle, reuses +// Shared (spec 23.1) in module-level globals initialised from a field or an element read: of +// another global's object (`holder.child`), of a temporary (`new P().child`), of a call's result +// (`mkP().child`), and of a call's array (`mkArr()[0]`). Such a read carries no reference of its +// own, so the global takes one: it is born at count 2 when the field's object lives on, and at +// count 1 when the object or array was a temporary given back at the end of the initializer. Each test drops the other handle, reuses // the memory, then reads through the global. Counts are checked where there is one (rc, own); gc // and none report -1. class Node { @@ -45,10 +45,15 @@ function mkP(tag: string, v: number) { return p; } +function mkArr(tag: string, v: number) { + return [mkP(tag, v).child]; +} + const holder = mkP("held", 1); let fromHeld: Shared | null = holder.child; let fromTemp: Shared = new P().child; let fromCall: Shared | null = mkP("called", 3).child; +let fromElem: Shared | null = mkArr("elem", 4)[0]; // `holder.child`: the field and the global hold one count each function fromHeldGlobal() { @@ -86,9 +91,22 @@ function fromCallGlobal() { assert(g !== null && fromCall === null); } +// `mkArr(..)[0]`: an element of a temporary array +function fromElemGlobal() { + if (fromElem) assert(countIs(fromElem, 1), "a global from a temporary array's element is born with one count"); + assert(churn() == 1000); + if (fromElem) { + assert(countIs(fromElem, 1), "still one"); + assert(fromElem.value.v == 4 && fromElem.value.tag == "elem", "the global's block is alive"); + } + + assert(fromElem !== null); +} + function main() { fromHeldGlobal(); fromTempGlobal(); fromCallGlobal(); + fromElemGlobal(); print("done."); } From ceedafa580ae5d7497d2155fab21525c8704ef71 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 3 Oct 2026 16:43:26 +0100 Subject: [PATCH 19/21] -mm=own Shared: the third fix round in the results (spec 23.7) The store receiver (a store counts only when rc retains for it), the global initialised from a field or an element, and the place-read bullet's misplaced gap; tests, suites (125 / 615 / 1569), four teeth rows, the corpus spot check, the third round's paragraph, Linux 266, and three not-filed findings (a global object literal, a discarded pop, arronly). Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 89 +++++++++++++++---- 1 file changed, 74 insertions(+), 15 deletions(-) diff --git a/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md b/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md index a4d62768b..cc2f9dfe9 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 @@ -2291,10 +2291,19 @@ branch keys on `!ts.shared`, a `ts.SharedValueRef`, or `isSharedHandleType`, so MLIRGen cannot see it. Keyed on the global's type. The first fix round claimed only a cast of `new Shared(..)`, in MLIRGen; `Shared | undefined = new Shared(..)` and `Shared | null = mk()` stayed at count 0 until the second (`own_shared_global_init`). - **Not covered:** a handle global initialised from a value that carries no reference - another - global (`let h = root`), a field - takes no count of its own. Today a global initialised from - another global does not read that global's value in any model (#446; the review's `glob.ts` - reads null for `d` and `h`), which hides it. + **A handle read out of a field or an element carries no reference**, and since the third fix + round the claim gives it one: a `ts.Retain` right after the read, before the end of the + initializer gives back a temporary object or array (`own_shared_global_field`: + `holder.child`, `new P().child`, `mkP().child`, `mkArr()[0]`). Until then such a global was + born at count 0 under rc and own alike and crashed when read (the re-review's `globfield`, + `tmpfield`, `callfield`: `count 0`, then 0xC0000005). #446 did not hide that: it hides only a + global initialised from another global. The class equivalents (`let g: Node = new P().child`) + read freed memory under rc on main too (`cls_globfield`, `cls_tmpfield`); they are not changed. + **Not covered:** a handle global initialised from another global (`let h = root`) takes no + count of its own; #446 makes such an initializer read null in every model (the review's + `glob.ts` reads null for `d` and `h`), which hides it. A global object literal holding a handle + from a call (`let o = { s: mkS(4) }`) gives the call's reference back and crashes under rc and + own (`objonly`), as its class equivalent reads freed memory under rc on main (`cls_obj`). - **The routines.** `ownsHeapMemory` is true for a handle. Its release routine is `__tslang_dec_ref`, and at zero the payload's routine and `__tslang_free_block`; its retain is `__tslang_inc_ref`. `emitIfLastReference(counted)` makes own count a handle where it tests @@ -2337,10 +2346,14 @@ branch keys on `!ts.shared`, a `ts.SharedValueRef`, or `isSharedHandleType`, so `handleReadUses`, Ruling 7). A counted use is a `ts.Retain` of it and what takes the value it retains for (a store into a field, an element, a global or an owning local, a push, `new Shared`, a return; `isCountedHandleReceiver`), or an owning local's slot - (`ts.RetainSlot`); an `any` box is a view whose own retain counts it. What makes the read a - borrow is a use `isBorrow` lists; a use that is neither (an op outside that list) does not, as - in the first round. A handle argument is always such a borrow, never a kept one, since a handle - gets no parameter facts: `keep(k, q.child = p.child)`, `keep` storing it, is rejected. + (`ts.RetainSlot`); an `any` box is a view whose own retain counts it. **A store counts only + when rc retained for it** (third fix round; `countedStoreRetain`: `ts.Retain` of the value, + then the store, or the `ts.ReleaseSlot` of the old value and then the store). What makes the + read a borrow is a use `isBorrow` lists, or a store rc made no retain for + (`isUncountedStoreOf`), which the borrow walk then reports as a keep. A use in neither list + (any other op) does not, as in the first round. A handle argument is always such a borrow, + never a kept one, since a handle gets no parameter facts: `keep(k, q.child = p.child)`, `keep` + storing it, is rejected. `cur = cur.value.next` is counted only, so walking stays legal. `f(p, p.child)`, where `f` overwrites `p.child`, is rejected (`own_err_shared_place_arg`). Before the first fix round every handle read was @@ -2356,6 +2369,16 @@ branch keys on `!ts.shared`, a `ts.SharedValueRef`, or `isSharedHandleType`, so when the two fields are one. So `Shared.count(q.child = p.child)` and `look(q.child = p.child)` stay legal (`own_shared_place_two_uses`). Until this round any one counted use exempted the whole read, and that program read freed memory under own; + - **a store counts only when rc retains for it** (third fix round). `isCountedHandleReceiver` + took every `ts.Store` of the read as counted, and the borrow walk skipped it as a copy. In + `const o = { s: (q.child = p.child) }` the read has two stores: the one into `q.child`, which + rc retains for, and the one into the object literal's field, which rc makes no retain for. The + second kept the read with no count, so after `dropBoth(p, q)` own read freed memory and crashed + (the re-review's `e2_objlit`). The second round had put the remaining gap in "an op outside + `isBorrow`'s list"; this one was the store receiver. That program is now rejected with the + plain `{ s: p.child }`'s message, `borrows a field and cannot be stored, returned or captured` + (`own_err_shared_place_literal`). The array literal, return, constructor argument and `any` + shapes (`e3`, `e6`, `e7`, `e9`) are retained by rc, compile, and count as rc does; - `ts.SharedValueRef` and `ts.SharedCount` read a handle and keep nothing (`isBorrow`), so `s.value` and `Shared.count(s)` are borrows of whatever `s` borrows. - **`any`.** A handle boxes under its own descriptor, named `"object"`, whose release and retain @@ -2366,9 +2389,14 @@ branch keys on `!ts.shared`, a `ts.SharedValueRef`, or `isSharedHandleType`, so #### Tests and teeth -- **Positive**, under gc, none, rc and own, AOT and JIT (80 runs): `own_shared_basic`, +- **Positive**, under gc, none, rc and own, AOT and JIT (88 runs): `own_shared_basic`, `own_shared_user_named`, `own_shared_graph`, `own_shared_containers`, `own_shared_cycle`, - `own_shared_union`, two from the final review's fix round, and two from its second: + `own_shared_union`, two from the final review's fix round, two from its second, and one from its + third: + - `own_shared_global_field`: handle globals initialised from `holder.child` (born at 2: the + field keeps its count), `new P().child`, `mkP(..).child` and `mkArr(..)[0]` (born at 1: the + temporary gave its count back). Each drops the other handle where there is one, reuses the + memory, and reads through the global; - `own_shared_global_init`: `Shared | undefined` from `new Shared(...)`, `let` and `const` `Shared | null` from a call, an explicit cast of a call's result, `Shared | undefined` and `Shared | number` from a call. Each asserts the global's count of 1, copies it, drops @@ -2391,10 +2419,10 @@ branch keys on `!ts.shared`, a `ts.SharedValueRef`, or `isSharedHandleType`, so (`2`, `42`, then `1`). The fix round also asserts the counts of a `const` union (`own_shared_union`: 4, not 3) and of a `const` read out of a field (`own_shared_graph`: 3, under rc and own). **`--verify-ownership`**, under own and rc, is quiet - on the ten counted files (20 runs). + on the eleven counted files (22 runs). - **`-O0`**: test-runner's `-noopt` does nothing in a Release build, so each positive was also run by hand with `--opt_level=0` under own and rc, AOT and JIT. -- **Negative** (10). Messages with `--di`; without it the borrower is `'this value'` and the place +- **Negative** (11). Messages with `--di`; without it the borrower is `'this value'` and the place "a field" or "an element", as for every own error: | test | message | @@ -2409,10 +2437,11 @@ branch keys on `!ts.shared`, a `ts.SharedValueRef`, or `isSharedHandleType`, so | `own_err_shared_union_owned` | `a Shared in a union with 'string', which -mm=own does not count, is not supported` | | `own_err_shared_place_arg` | `'this value' borrows 'p.child' but is used here after …`, at `f(p, p.child)`, where `f` overwrites `p.child` | | `own_err_shared_place_two_uses` | `'this value' borrows a field but is used here after …`, at `f(p, q, q.child = p.child)`, where `f` overwrites both fields; the same with `--opt` | +| `own_err_shared_place_literal` | `'this value' borrows a field and cannot be stored, returned or captured`, at `{ s: (q.child = p.child) }` (31:15), then `dropBoth(p, q)`; the same with `--opt` | -- **Suites:** the Shared set is 114 tests, `ctest -R own` 604 and `ctest -R "own|rc"` 1558, all +- **Suites:** the Shared set is 125 tests, `ctest -R own` 615 and `ctest -R "own|rc"` 1569, all passing on this branch's final build (72, 562 and 1516 before the final review's fix round; - 93, 583 and 1537 after it). + 93, 583 and 1537 after it; 114, 604 and 1558 after the second). - **Teeth**, each a temporary switch, with the JIT cache cleared: | switch | result | @@ -2440,6 +2469,10 @@ branch keys on `!ts.shared`, a `ts.SharedValueRef`, or `isSharedHandleType`, so | second fix round: any one counted use exempts the whole read again | `own_err_shared_place_two_uses` compiles; the review's `hole.ts` crashes under own (`count 0`) | | second fix round: a counted store's release of the old value a drop again | `own_shared_place_two_uses` under own (JIT, AOT, verifier): `borrows a field but is used here after …` | | second fix round: the counted uses not a copy in the borrow walk | `own_shared_place_two_uses` under own (JIT, AOT, verifier) and `own_err_shared_place_two_uses`: `borrows a field and cannot be stored, returned or captured` | +| third fix round: any store of the read counted again (`isCountedHandleReceiver`) | `own_err_shared_place_literal` compiles (`--emit=obj` exits 0); the re-review's `e2_objlit` crashes under own (0xC0000005) | +| third fix round: a store with no retain not marked uncounted (`isUncountedStoreOf` off, the predicate kept) | the same: the store falls through both lists, the read stays exempt, `own_err_shared_place_literal` compiles and `e2_objlit` crashes | +| third fix round: the field-read retain in the global claim off | `own_shared_global_field` under rc and own, JIT and AOT: `a global from a live object's field takes a count of its own`; `globfield`, `tmpfield`, `callfield` crash again (`count 0`) | +| third fix round: the element read left out of the claim | `own_shared_global_field` under rc and own, JIT and AOT: `a global from a temporary array's element is born with one count` | - **What (d) showed:** no existing rule ends an element borrow across a push through another handle; the push rule is the only one. Each other §23.3 rule also has its own negative. @@ -2495,6 +2528,11 @@ the earlier runs: the order of a union's members in `___cast | string>a`, an `any` unboxed to a union holding a string, is a compile error in every model (`'ts.Cast' op type ['!ts.any'] can't be stored in …`; the final review's u1). +- Not filed: a global object literal initialised with a call's result (`let o = { s: mk(4) }`) + gives the call's reference back at the end of the initializer: the class version reads freed + memory under rc on main (`cls_obj`), the handle version crashes under rc and own (`objonly`). +- Not filed: a discarded `arr.pop()` does not give back the element it removes, so a handle + popped out of an array keeps its count (2 after the pop of a two-handle block, under rc and own + alike). The re-review's `arronly` (a `const` read out of a global array, then `arr = []`) + prints 2 under rc and 1 under own, as before this round. From 4486dee5c16eabea2cbad136267d018b66f2f0a4 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 3 Oct 2026 17:55:28 +0100 Subject: [PATCH 20/21] -mm=own Shared: the fourth fix round - a handle read's uses are denied by default (spec 23, Ruling 9) handleReadUses classified a use as counted, borrowed, or neither; a use that was neither (a tuple's element: `[(q.child = p.child), 1]`) let a read with a counted store stay exempt, and the tuple kept the block with no count of its own (c7, d4, d10 compiled and crashed under own). Every use of a handle read is now classified, and nothing falls through: - counted: a receiver with rc's retain for that very use - a store (countedStoreRetain, now also a return's store into the result slot after the scope exit's releases), push and unshift, `new Shared`, an array literal's element, an `any` box, `ts.ReturnVal` (retainedRightBefore: the row of retains MLIRGen puts right before the op), and an owning local's initializer; - passed on: a cast or view that is still a handle, a merge, a local that owns nothing; - borrowed: everything else - isBorrow's list, a tuple element, an object literal's field, a capture, a yield, a receiver without its retain, a cast to what is not a handle. rc's retains count for nothing alone. A return (result-slot store) and an `any` box are classified before passesOn would hand them on, in handleReadUses and in walkBorrowed's counted-copy skip alike. ReturnInternal is no receiver any more; isUncountedStoreOf is folded into the default. New negative own_err_shared_place_tuple.ts (c7's shape). Spec 23.7: Ruling 9 in the place-read bullet and a fourth-round bullet (with the retain-pairing argument), the new negative, suites 126 / 616 / 1570, two teeth rows, the corpus spot check, Linux 267, and the conservative rejections (a counted read's value in a tuple: c7c, c7d, d3). Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 85 +++++++-- .../lib/TypeScript/OwnershipInferencePass.cpp | 172 +++++++++++++----- tslang/test/tester/CMakeLists.txt | 3 +- .../tester/own/own_err_shared_place_tuple.ts | 36 ++++ 4 files changed, 238 insertions(+), 58 deletions(-) create mode 100644 tslang/test/tester/own/own_err_shared_place_tuple.ts diff --git a/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md b/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md index cc2f9dfe9..1b4149cb5 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 @@ -2343,17 +2343,23 @@ branch keys on `!ts.shared`, a `ts.SharedValueRef`, or `isSharedHandleType`, so sees `s.value = x` as a store into a place); - **a handle read out of a place is a borrow of the place** (`placeReadOf`), checked as a field read is (`checkPlaceRead`), unless every use of it is one rc counts (`isCountedHandleRead`, - `handleReadUses`, Ruling 7). A counted use is a `ts.Retain` of it and what takes the value it - retains for (a store into a field, an element, a global or an owning local, a push, - `new Shared`, a return; `isCountedHandleReceiver`), or an owning local's slot - (`ts.RetainSlot`); an `any` box is a view whose own retain counts it. **A store counts only - when rc retained for it** (third fix round; `countedStoreRetain`: `ts.Retain` of the value, - then the store, or the `ts.ReleaseSlot` of the old value and then the store). What makes the - read a borrow is a use `isBorrow` lists, or a store rc made no retain for - (`isUncountedStoreOf`), which the borrow walk then reports as a keep. A use in neither list - (any other op) does not, as in the first round. A handle argument is always such a borrow, - never a kept one, since a handle gets no parameter facts: `keep(k, q.child = p.child)`, `keep` - storing it, is rejected. + `handleReadUses`, Rulings 7 and 9). **Every use is classified, and a use not known to count + is a borrow** (fourth fix round, Ruling 9). A counted use is a receiver with rc's retain for + that very use (`isCountedHandleReceiver`): a store into a field, an element, a global or an + owning local (`countedStoreRetain`: `ts.Retain` of the value, then the store, or the + `ts.ReleaseSlot` of the old value and then the store); a return, which after the affine + lowering is a store into the result slot after the scope exit's releases (`ts.Retain`, then + `ts.Release` and `ts.ReleaseSlot` of the frame's locals, then the store); a push, an unshift, + `new Shared`, an array literal's element and an `any` box, each right after the row of + retains MLIRGen puts before what it fills (`retainedRightBefore`); or an owning local's slot + (`ts.RetainSlot`). A cast or view that is still a handle, a merge and a local that owns + nothing pass the value on, and their uses are classified in turn. Everything else is a + borrow: a use `isBorrow` lists, and every use that is neither of the above - a tuple's element, + an object literal's field, a capture, a `yield`, a cast to what is not a handle, a receiver + with no retain of its own, any op not named here; the borrow walk reports one that keeps the + value as a keep. rc's retains count for nothing on their own: each is the count of the + receiver right after it. A handle argument is always a borrow, never a kept one, since a + handle gets no parameter facts: `keep(k, q.child = p.child)`, `keep` storing it, is rejected. `cur = cur.value.next` is counted only, so walking stays legal. `f(p, p.child)`, where `f` overwrites `p.child`, is rejected (`own_err_shared_place_arg`). Before the first fix round every handle read was @@ -2379,6 +2385,26 @@ branch keys on `!ts.shared`, a `ts.SharedValueRef`, or `isSharedHandleType`, so plain `{ s: p.child }`'s message, `borrows a field and cannot be stored, returned or captured` (`own_err_shared_place_literal`). The array literal, return, constructor argument and `any` shapes (`e3`, `e6`, `e7`, `e9`) are retained by rc, compile, and count as rc does; + - **a use not known to count is a borrow** (fourth fix round, Ruling 9). `handleReadUses` still + let a use that was neither a counted receiver nor in `isBorrow`'s list fall through, so in + `const t: [Shared, number] = [(q.child = p.child), 1]` the counted store into `q.child` + exempted the read and the tuple kept it with no count: after `dropBoth(p, q)` own read freed + memory and crashed (the re-review's `c7`, and `as const` and `[Shared | null, number]`, + `d4` and `d10`). The default is now inverted: only a receiver with rc's retain for that use + counts, and every other use makes the read a borrow, which the borrow walk reports at the tuple + (`own_err_shared_place_tuple`, the plain `[p.child, 1]`'s message). Push, unshift, + `new Shared` and a return now need their retain too, where before they counted on sight; an + array literal's element is now a receiver, with its retain (before, its bare retain alone + counted the read). A return and an `any` box are classified before `passesOn` would hand + them on (`isHandOff`), in `handleReadUses` and in the borrow walk alike, and + `ts.ReturnInternal` is no receiver any more: it is reached only through the result slot. + **The pairing is exact in effect.** `countedStoreRetain` and `retainedRightBefore` match a + retain by position, and a retain has one next op, so no two receivers count the same retain. + A retain made for one receiver but sitting right before another, uncounted, store of the value + would be taken by that store; the receiver it was made for then finds no retain of its own and + makes the read a borrow, so the read is never exempt with fewer retains than counted + receivers. No MLIRGen site emits that order (each emits its retain right before its own op), + and nothing more was built for it; - `ts.SharedValueRef` and `ts.SharedCount` read a handle and keep nothing (`isBorrow`), so `s.value` and `Shared.count(s)` are borrows of whatever `s` borrows. - **`any`.** A handle boxes under its own descriptor, named `"object"`, whose release and retain @@ -2422,7 +2448,7 @@ branch keys on `!ts.shared`, a `ts.SharedValueRef`, or `isSharedHandleType`, so on the eleven counted files (22 runs). - **`-O0`**: test-runner's `-noopt` does nothing in a Release build, so each positive was also run by hand with `--opt_level=0` under own and rc, AOT and JIT. -- **Negative** (11). Messages with `--di`; without it the borrower is `'this value'` and the place +- **Negative** (12). Messages with `--di`; without it the borrower is `'this value'` and the place "a field" or "an element", as for every own error: | test | message | @@ -2438,10 +2464,12 @@ branch keys on `!ts.shared`, a `ts.SharedValueRef`, or `isSharedHandleType`, so | `own_err_shared_place_arg` | `'this value' borrows 'p.child' but is used here after …`, at `f(p, p.child)`, where `f` overwrites `p.child` | | `own_err_shared_place_two_uses` | `'this value' borrows a field but is used here after …`, at `f(p, q, q.child = p.child)`, where `f` overwrites both fields; the same with `--opt` | | `own_err_shared_place_literal` | `'this value' borrows a field and cannot be stored, returned or captured`, at `{ s: (q.child = p.child) }` (31:15), then `dropBoth(p, q)`; the same with `--opt` | +| `own_err_shared_place_tuple` | `'this value' borrows a field and cannot be stored, returned or captured`, at `[(q.child = p.child), 1]` (31:39), then `dropBoth(p, q)`; the same with `--opt` | -- **Suites:** the Shared set is 125 tests, `ctest -R own` 615 and `ctest -R "own|rc"` 1569, all +- **Suites:** the Shared set is 126 tests, `ctest -R own` 616 and `ctest -R "own|rc"` 1570, all passing on this branch's final build (72, 562 and 1516 before the final review's fix round; - 93, 583 and 1537 after it; 114, 604 and 1558 after the second). + 93, 583 and 1537 after it; 114, 604 and 1558 after the second; 125, 615 and 1569 after the + third). - **Teeth**, each a temporary switch, with the JIT cache cleared: | switch | result | @@ -2473,6 +2501,8 @@ branch keys on `!ts.shared`, a `ts.SharedValueRef`, or `isSharedHandleType`, so | third fix round: a store with no retain not marked uncounted (`isUncountedStoreOf` off, the predicate kept) | the same: the store falls through both lists, the read stays exempt, `own_err_shared_place_literal` compiles and `e2_objlit` crashes | | third fix round: the field-read retain in the global claim off | `own_shared_global_field` under rc and own, JIT and AOT: `a global from a live object's field takes a count of its own`; `globfield`, `tmpfield`, `callfield` crash again (`count 0`) | | third fix round: the element read left out of the claim | `own_shared_global_field` under rc and own, JIT and AOT: `a global from a temporary array's element is born with one count` | +| fourth fix round: the fall-through restored (a use neither counted nor in `isBorrow`'s list, nor a store, is neither) | `own_err_shared_place_tuple` fails, the only one of the 126 (`--emit=obj` exits 0); the re-review's `c7`, `d4` and `d10` crash under own (0xC0000005) | +| fourth fix round: a return and an `any` box no longer classified before `passesOn` (`isHandOff` off) | `own_shared_containers` under own (JIT, AOT, verifier) fails to compile, at its `any` box (`const boxed: any = h.s`); the re-review's `e6`, `e9`, `b9`, `c2` and this round's return and `any` probes are rejected with `borrows a field and cannot be stored, returned or captured` | - **What (d) showed:** no existing rule ends an element borrow across a push through another handle; the push rule is the only one. Each other §23.3 rule also has its own negative. @@ -2533,6 +2563,11 @@ After the third fix round, a spot check of 40 corpus files (every fifteenth, and 70 that compile are identical (40 under rc, 30 under own); the other ten fail under own on both binaries with the same first error. None of the three flippers was in the sample. +After the fourth fix round, another 40 (every fifteenth from the seventh), the same way: 66 of +the 67 that compile are identical; the other is `00union_bin_ops2`, main's other output, under +rc in one run and under own in the rerun on the final build (main gives it 1 time in 10 here +too). The 13 that fail fail on both binaries with the same first error. + #### The final review and its fix round The final review of the branch found three critical defects and one important one. Every test @@ -2583,6 +2618,15 @@ round, approved by the user, fixed both: count 0 and crashed under rc and own; #446 hides only the global-from-global case. The claim now retains a handle read out of a field or an element (`own_shared_global_field`). +The third re-review found one more important defect, of the same kind: a use outside both lists +fell through. A fourth fix round, approved by the user, inverted the default (Ruling 9): + +- **Important, a tuple's element exempted its read.** `[(q.child = p.child), 1]`, in a `const` + tuple (`as const`, or with a `Shared | null` element), kept the read with no count beside a + counted store; own crashed after both fields were replaced. Every use of a handle read is now + classified, a receiver counts only with rc's retain for that very use, and anything else is a + borrow (`own_err_shared_place_tuple`). + #### Linux GCC, in WSL, the branch at `bbac9ba8`: it builds with no errors, and @@ -2593,7 +2637,8 @@ same pattern passes 234 of 234, again the Windows build's count. After the secon (its changes applied to `476c4d8f` as a patch), it builds with no errors and the same pattern passes 255 of 255, the Windows build's count. After the third fix round, at `3a86e20d`, it builds with no errors and the same pattern passes 266 of 266, the Windows -build's count. +build's count. After the fourth (its changes applied to `ceedafa5` as a patch), it builds with no +errors and the same pattern passes 267 of 267, the Windows build's count. #### Outside the rule @@ -2613,6 +2658,16 @@ build's count. 7), so the call ends it even where the callee retains the handle first. With `p` a parameter, `keep(k, p.child)` is rejected when `keep` stores into a field. Copying it into a local first (`const c = p.child; keep(k, c)`) is accepted, and counts; + - a handle read whose count goes into a tuple, beside a counted use, is rejected whether or not + anything drops it (Ruling 9): `[(q.child = p.child), 1]` with nothing dropped after it (the + re-review's `c7c`, `c7d`), and `const [a, n] = [(q.child = p.child), 1]` (`d3`, which counted + 1 under rc and own). All three compiled before this round; the plain `[p.child, 1]` was + already rejected. `const x = p.child; const t = [x, 1]` is accepted, and counts; + - a return whose retain is not followed by only the scope exit's `ts.Release` and + `ts.ReleaseSlot` ops (in any order) before the store into the result slot would be rejected: + none was found (`return p.child` after `const` and `let` locals, after a single `let`, inside + a loop, inside `try`/`finally`, from an arrow or a method, into an optional or a union result + all compile and count as rc does); - so is one read of a handle with a counted use and a use given to a call that may drop: with `k` an owning local, `f(p, k = p.child)`, where `f` overwrites `p.child`, is rejected even though `k` keeps the block alive (the review's `hole2.ts`). `k = p.child; f(p, k)` is diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index a94c0128b..c2055a990 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -2261,6 +2261,13 @@ class OwnershipInferencePass if (pending.borrowed) { + // a handle read that rc counts (spec 23.3): a return or an `any` box rc + // retained for is a counted copy of its own; passesOn would hand them on + if (countedHandle && isCountedHandOff(user, pending.value)) + { + continue; + } + llvm::SmallVector same; llvm::SmallVector renewedAt; if (passesOn(use, same, &renewedAt)) @@ -2388,11 +2395,19 @@ class OwnershipInferencePass } // How a handle read out of a place is used (spec 23.3), seen through casts, merges and locals - // that own nothing: `counted` when rc gives it an owner of its own - a retain, which own leaves - // in place, for what takes it (isCountedHandleReceiver), or an owning local's own count - // (`ts.RetainSlot`); `borrowed` when a use reads through the place instead - an argument given - // to a call (`f(p, p.child)`), a `.value` read, `Shared.count`. `cur = cur.value.next` is - // counted only. + // that own nothing. Every use is classified, and nothing falls through (Ruling 9): + // - `counted`: a receiver that takes a count rc made for that very use + // (isCountedHandleReceiver) - a store, a push or an unshift, `new Shared`, an array + // literal's element, a return, an `any` box, or an owning local's initializer; + // - passed on: a cast or a view that is still a handle, a merge, a local that owns nothing - + // whose own uses are classified in turn; + // - `borrowed`: everything else. A use that reads through the place (`f(p, p.child)`, a + // `.value` read, `Shared.count`), and every use that is neither of the above: a tuple's + // element, an object literal's field, a capture, a `yield`, a receiver rc made no retain + // for, any op not named here. The read is then a borrow, and tenure-checked + // (checkPlaceRead). + // rc's retains count for nothing on their own: each is the count of the receiver right after + // it. `cur = cur.value.next` is counted only. struct HandleReadUses { bool counted; @@ -2410,12 +2425,30 @@ class OwnershipInferencePass for (auto &use : current.getUses()) { auto *user = use.getOwner(); + if (mlir::isa(user)) + { + continue; + } + + // a return and an `any` box take the value, though passesOn would hand them on + if (isHandOff(user, current)) + { + (isCountedHandleReceiver(user, current) ? result.counted : result.borrowed) = true; + continue; + } + llvm::SmallVector same; if (passesOn(use, same)) { for (auto next : same) { - if (isHandle(next) && seen.insert(next).second) + // what is no longer a handle is no receiver: a test (`if (p.child)`) only + // reads it + if (!isHandle(next)) + { + result.borrowed = true; + } + else if (seen.insert(next).second) { values.push_back(next); } @@ -2424,19 +2457,7 @@ class OwnershipInferencePass continue; } - if (isCountedHandleReceiver(user, current)) - { - result.counted |= mlir::isa(user); - continue; - } - - // a store of the read that rc made no retain for (an object literal's field, - // `{ s: (q.child = p.child) }`) keeps it uncounted: the read is checked as a - // borrow, and the store is its keep - if (isBorrow(user, current) || isUncountedStoreOf(user, current)) - { - result.borrowed = true; - } + (isCountedHandleReceiver(user, current) ? result.counted : result.borrowed) = true; } } @@ -2444,21 +2465,24 @@ class OwnershipInferencePass } // A handle read out of a place that rc gives a count of its own is a counted copy, not a borrow - // (spec 23.3) - but only when every use of it is one rc counts (Ruling 7). A read with one use - // that reads through the place as well (`f(p, q, q.child = p.child)`) is a borrow, and that - // use is tenure-checked; the counted uses are copies (checkPlaceRead). + // (spec 23.3) - but only when every use of it is one rc counts (Ruling 7), and a use that is + // not known to be one is not (Ruling 9). A read with one use that reads through the place as + // well (`f(p, q, q.child = p.child)`), or that keeps it with no count (`[(q.child = p.child), + // 1]`), is a borrow, and that use is tenure-checked; the counted uses are copies + // (checkPlaceRead). static bool isCountedHandleRead(mlir::Value value) { auto uses = handleReadUses(value); return uses.counted && !uses.borrowed; } - // A use that gives a handle an owner rc counts: rc's retain, and what takes the value it - // retains for - a store into a field, an element, a global or an owning local (a local that - // owns nothing passes the value on) that rc retained for (countedStoreRetain), a push, - // `new Shared`, a return - or the initializer of an owning local, which takes a count of its - // own (`ts.RetainSlot`). A store rc made no retain for is not one: it keeps the read without - // a count (`{ s: (q.child = p.child) }`'s field). + // A use that gives a handle an owner rc counts, with rc's retain for that very use: rc's + // retain itself, a store into a field, an element, a global, an owning local or the result + // slot (a return) that rc retained for (countedStoreRetain); a push or an unshift, `new + // Shared`, an array literal's element, an `any` box, a `ts.ReturnVal`, each right after rc's + // retain of what it takes (retainedRightBefore); or the initializer of an owning local, which + // takes a count of its own (`ts.RetainSlot`). Any of them without its retain is not one: it + // keeps the read without a count (`{ s: (q.child = p.child) }`'s field). static bool isCountedHandleReceiver(mlir::Operation *user, mlir::Value value) { if (mlir::isa(user)) @@ -2480,15 +2504,76 @@ class OwnershipInferencePass if (auto pushOp = mlir::dyn_cast(user)) { - return llvm::is_contained(pushOp.getItems(), value); + return retainedRightBefore(user, pushOp.getItems(), value); } if (auto unshiftOp = mlir::dyn_cast(user)) { - return llvm::is_contained(unshiftOp.getItems(), value); + return retainedRightBefore(user, unshiftOp.getItems(), value); + } + + if (auto castOp = mlir::dyn_cast(user)) + { + return mlir::isa(castOp.getType()) && retainedRightBefore(user, user->getOperands(), value); + } + + // `ts.ReturnVal` is the return before the affine lowering, which makes it a store into + // the result slot + if (mlir::isa(user)) + { + return retainedRightBefore(user, user->getOperands(), value); } - return mlir::isa(user); + return false; + } + + // A use that passesOn hands on but that takes the value: a return's store into the function's + // result slot (isResultSlot), and a cast that boxes it into `any`. + static bool isHandOff(mlir::Operation *user, mlir::Value value) + { + if (auto storeOp = mlir::dyn_cast(user)) + { + auto slot = storeOp.getReference().getDefiningOp(); + return storeOp.getValue() == value && slot && isResultSlot(slot); + } + + auto castOp = mlir::dyn_cast(user); + return castOp && castOp.getIn() == value && mlir::isa(castOp.getType()); + } + + // A return or an `any` box of a handle that rc counts (walkBorrowed). + static bool isCountedHandOff(mlir::Operation *user, mlir::Value value) + { + return isHandOff(user, value) && isCountedHandleReceiver(user, value); + } + + // rc's retains for what `op` takes: MLIRGen puts them in a row right before it + // (mlirGenRetainCaptured, retainInsertedElements), one for each operand it does not take + // over. Answers whether that row retains `value` once for each time `operands` hold it. A + // retain is right before one op only, so no other receiver can count it as well. + static bool retainedRightBefore(mlir::Operation *op, mlir::ValueRange operands, mlir::Value value) + { + auto needed = llvm::count(operands, value); + if (needed == 0) + { + return false; + } + + for (auto *previous = op->getPrevNode(); needed > 0 && previous; previous = previous->getPrevNode()) + { + auto retainOp = mlir::dyn_cast(previous); + if (!retainOp) + { + break; + } + + if (retainOp.getReference() == value) + { + --needed; + } + } + + return needed == 0; } // The releases of the old value that rc's counted stores of a handle read make: `ts.Retain` of @@ -2533,11 +2618,23 @@ class OwnershipInferencePass // rc's retain for a store of a value, when it made one: `ts.Retain` of the value right before // the store, or before the `ts.ReleaseSlot` of the old value right before it - - // `Retain(v)`, [`ReleaseSlot(slot)`], `Store(v, slot)`. Null for a store rc counts nothing for. + // `Retain(v)`, [`ReleaseSlot(slot)`], `Store(v, slot)`. A return's store into the result + // slot comes after the scope exit's releases of the frame's locals and their slots: + // `Retain(v)`, [`Release(..)`, `ReleaseSlot(..)`...], `Store(v, result)`. Null for a store rc + // counts nothing for. static mlir::Operation *countedStoreRetain(mlir_ts::StoreOp storeOp) { auto *previous = storeOp->getPrevNode(); - if (auto releaseSlotOp = mlir::dyn_cast_or_null(previous)) + if (auto slot = storeOp.getReference().getDefiningOp(); slot && isResultSlot(slot)) + { + // the scope exit's releases, of locals and of their slots, in any order + while (mlir::isa_and_nonnull(previous) && + previous->getOperand(0) != storeOp.getValue()) + { + previous = previous->getPrevNode(); + } + } + else if (auto releaseSlotOp = mlir::dyn_cast_or_null(previous)) { previous = releaseSlotOp.getSlot() == storeOp.getReference() ? releaseSlotOp->getPrevNode() : nullptr; } @@ -2546,15 +2643,6 @@ class OwnershipInferencePass return retainOp && retainOp.getReference() == storeOp.getValue() ? retainOp.getOperation() : nullptr; } - // A store of a handle read that rc made no retain for (countedStoreRetain): it keeps the read - // with no count of its own. A store into a local that owns nothing passes the value on - // (passesOn) and is not seen here. - static bool isUncountedStoreOf(mlir::Operation *user, mlir::Value value) - { - auto storeOp = mlir::dyn_cast(user); - return storeOp && storeOp.getValue() == value && !countedStoreRetain(storeOp); - } - // The call a value is the result of, when that result borrows an argument. static mlir::Operation *borrowingCall(mlir::Value value) { diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 012e4a3ac..2ba3fadfa 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2772,7 +2772,8 @@ set(own_error_cases "own_err_shared_union_owned|a Shared in a union with 'string', which -mm=own does not count, is not supported" "own_err_shared_place_arg|borrows a field but is used here after it may be released or overwritten" "own_err_shared_place_two_uses|borrows a field but is used here after it may be released or overwritten" - "own_err_shared_place_literal|borrows a field and cannot be stored, returned or captured") + "own_err_shared_place_literal|borrows a field and cannot be stored, returned or captured" + "own_err_shared_place_tuple|borrows a field and cannot be stored, returned or captured") foreach(own_error_case ${own_error_cases}) string(FIND "${own_error_case}" "|" own_error_sep) string(SUBSTRING "${own_error_case}" 0 ${own_error_sep} own_error_name) diff --git a/tslang/test/tester/own/own_err_shared_place_tuple.ts b/tslang/test/tester/own/own_err_shared_place_tuple.ts new file mode 100644 index 000000000..97c6e18ac --- /dev/null +++ b/tslang/test/tester/own/own_err_shared_place_tuple.ts @@ -0,0 +1,36 @@ +// Shared (spec 23.3, Rulings 7 and 9): one read of a handle out of a field with two uses. The +// store into `q.child` gives that use a counted owner of its own (rc retains for it); the tuple's +// element does not (rc retains nothing for it, and a tuple is no receiver rc counts), so the tuple +// keeps a borrow of `p.child`. `dropBoth` overwrites both fields and gives back the last count of +// the block the tuple points to: rejected. A use that is not known to count is a borrow (Ruling 9). +class Node { + v = 0; + tag = ""; +} + +class P { + child: Shared = new Shared(new Node()); +} + +function churn() { + let keep: string[] = []; + for (let i = 0; i < 1000; i++) keep.push("k" + i); + return keep.length; +} + +function dropBoth(p: P, q: P) { + p.child = new Shared(new Node()); + q.child = new Shared(new Node()); + churn(); +} + +function main() { + const p = new P(); + const q = new P(); + p.child.value.tag = "alive"; + const t: [Shared, number] = [(q.child = p.child), 1]; + dropBoth(p, q); + print(Shared.count(t[0])); + print(t[0].value.tag); + print("done."); +} From d636eb82099c9a23cf9d421edbb6621aa8d950d5 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 3 Oct 2026 17:57:44 +0100 Subject: [PATCH 21/21] -mm=own Shared: a using disposal before return p.child is a conservative rejection (spec 23.7) Co-Authored-By: Claude Opus 5.5 --- .../specs/2026-09-24-own-memory-model-design.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md b/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md index 1b4149cb5..2611590f1 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 @@ -2664,10 +2664,13 @@ errors and the same pattern passes 267 of 267, the Windows build's count. 1 under rc and own). All three compiled before this round; the plain `[p.child, 1]` was already rejected. `const x = p.child; const t = [x, 1]` is accepted, and counts; - a return whose retain is not followed by only the scope exit's `ts.Release` and - `ts.ReleaseSlot` ops (in any order) before the store into the result slot would be rejected: - none was found (`return p.child` after `const` and `let` locals, after a single `let`, inside - a loop, inside `try`/`finally`, from an arrow or a method, into an optional or a union result - all compile and count as rc does); + `ts.ReleaseSlot` ops (in any order) before the store into the result slot is rejected. One + such shape was found: a scope exit split across blocks, as a `using` disposal makes it + (`using r = …; return p.child`, in a function or an arrow), compiled and counted 1 before this + round and is now rejected ("borrows a field and cannot be stored, returned or captured"). + `return p.child` after `const` and `let` locals, after a single `let`, inside a loop, inside + `try`/`finally`, from an arrow or a method, into an optional or a union result all compile + and count as rc does; - so is one read of a handle with a counted use and a use given to a call that may drop: with `k` an owning local, `f(p, k = p.child)`, where `f` overwrites `p.child`, is rejected even though `k` keeps the block alive (the review's `hole2.ts`). `k = p.child; f(p, k)` is