From 0cd3dc23515e8eb9d990d0172a796ec05b6d4734 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Fri, 2 Oct 2026 22:28:38 +0100 Subject: [PATCH 01/16] -mm=own: spec section 22, strings as values (design) A second reference to a string that own cannot prove a move or a borrow becomes a copy (ts.StringCopy): silent, made only in a function the analysis would reject, and only for the string retains it could not erase. A dry run decides where; the real run verifies. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 103 ++++++++++++++++++ 1 file changed, 103 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 510832239..afdf0a7ce 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 @@ -1721,3 +1721,106 @@ Both buckets were read with `--di`. - **`instanceof` of a class value held in a union or a nullable local** is false under every model, even narrowed (`let a: C | null = new C(); a instanceof C`). It is a frontend or lowering bug outside this phase. `own_declared_constant` tests the value with `typeof`. + +## 22. Strings as values (design, 2026-10-02) + +A design to build after phase 6, ahead of phase 8 (`Shared`). §21.5 found that most of the +corpus files rejected with "borrows a field and cannot be stored, returned or captured" are a +string given a second owner (`testrec.str = testrec.str2`, `return held.text`, `caught = e.m`). So +are the 17 files rejected with "returns a borrow of its argument on some paths and another value +on others" (`___unbox`, `___cast`, `return name` beside +`return "Sorry, " + name`). A string is immutable in TypeScript, so a copy cannot be told apart +from sharing. MLIRGen already treats it as a value: `s.length = n` makes a new string rather than +resizing in place. + +### 22.1 The rule + +Under `-mm=own`, a second reference to a string that own cannot prove to be a move or a borrow +becomes a copy. + +- **A string** is a value whose type is `string`, a string literal type, or a union or an optional + of `string` with only `null` or `undefined` beside it. Those are one pointer, the same as + `string` (the nullable views of §21.3). A union with other members (`string | number`), and an + object or array holding strings, are not covered. +- **Silent.** A copy makes no diagnostic. +- **Only where the program would fail.** Copies are made only in a function the analysis rejects, + and only for the string retains it could not erase. A function that compiles today compiles to + the same IR. +- **A write in place.** tslang accepts `t[0] = 65` on a string, which writes into its bytes; + standard TypeScript rejects it. After `let t = s`, if `t` is a copy, the write changes `t` only. + Under gc both names see it. The difference is accepted and tested. + +### 22.2 The copy + +`ts.StringCopy %s : !ts.string -> !ts.string`, its result a new allocation (`MemAlloc`). It lowers +to: null stays null; otherwise the length plus one byte, through the allocator `ts.StringConcat` +uses, and a `memcpy`. A nullable string is copied through its pointer. + +- Only the own inference pass creates it, but it lowers under every model. +- `isFresh` counts it, beside `ts.StringConcat`. +- A string literal is never copied: it is constant data (§20.2), whose retains are already erased. + +### 22.3 Where the copies go + +The inference pass decides each function in up to two runs. + +1. **A dry run.** The whole analysis runs, but it reports nothing and changes nothing: + - nothing is erased; + - the signature facts are not stripped; + - the attributes the analysis sets are noted, not set; + - every error, including those that do not go through the `quiet` reporters today (the + `RetainCell` and `delete` errors), is counted as a failure, not emitted. + + Without a failure, the function is decided by the real run alone, exactly as today. +2. **The copies.** After a failing dry run, each string retain the dry run did not erase is + rewritten where its use can be found: + - `ts.Retain(v)` for one use, the one `takerOf` names (a store, a push, a call that keeps it, a + store into the result slot): `c = ts.StringCopy(v)` is inserted before that use, the use + takes `c` in place of `v`, and the retain is erased. `v` keeps its owner, which still + releases it; the use owns `c`. + - `ts.RetainSlot` of `let x = v`: the local starts from `ts.StringCopy(v)` instead, and the + retain is erased. `x`'s release at scope exit frees the copy. +3. **The real run.** The analysis runs on the rewritten function as it does today. A copy is fresh, + and moves into its use by the existing rules. Anything still unproven is reported. + +### 22.4 No copy + +The error stays where a copy would be wrong: + +- **No single use.** The retain's use cannot be pinned to one op, or the use took the value + without a retain (rc consumed a fresh value). Copying there would leave the original with no + owner: a leak. +- **A fact the callers rely on.** The value is a parameter, or read out of one, that the signature + pass told this function's callers it keeps (`__own_params`) or returns a borrow of + (`__own_result_borrows`). Facts lost (§15, an exported function) are not such facts. There the + callers keep their own, so a copy is right. +- **Not a string** in the sense of §22.1. + +### 22.5 What it should accept + +- A field's string stored into another field, returned, pushed, or read out of a caught object + (`caught = e.m`). +- A string moved into a field and used after (`h.text = kept; print(kept)`). +- A function that returns a borrow of its argument on some paths and something else on others: + the borrowed return is copied, so the result is always fresh. That includes + `___unbox` and `___cast`. +- An exported constructor storing its string parameter. + +### 22.6 Tests and measures + +- **Positive** (test-runner under own, AOT and JIT; each also under rc, none and gc, where no copy + is made): `own_string_copy_field`, `own_string_copy_after_move`, + `own_string_copy_mixed_return`, `own_string_copy_nullable`, `own_string_copy_exported`. +- **Own only:** `own_string_copy_written`, `let t = s; t[0] = 65`, which changes `t` only. +- **Negative:** + - a kept parameter whose callers know the fact; + - a second reference to a value that is not a string; + - a taker that consumed without a retain. +- **Teeth:** with the copies turned off, every positive fails. With the fact guard turned off, its + negative compiles. +- **Measured:** a loop that copies a field's string 200,000 times stays flat under own, as under + rc (`measure.ps1`). +- **Unchanged where it compiles:** `--emit=mlir-affine` with the copies on and off is the same for + every corpus file that compiles on main. +- **Corpus:** none lost, plain and with `--opt`. Each gained file passes test-runner under own, + AOT and JIT, with the default flags and with `-noopt`. From a79fa9b8248ea4cc7f177cf9adba0fdc65b9b35d Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Fri, 2 Oct 2026 22:35:17 +0100 Subject: [PATCH 02/16] -mm=own: the strings-as-values implementation plan (spec section 22) Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-02-own-strings-as-values.md | 1051 +++++++++++++++++ 1 file changed, 1051 insertions(+) create mode 100644 tslang/docs/superpowers/plans/2026-10-02-own-strings-as-values.md diff --git a/tslang/docs/superpowers/plans/2026-10-02-own-strings-as-values.md b/tslang/docs/superpowers/plans/2026-10-02-own-strings-as-values.md new file mode 100644 index 000000000..960e85122 --- /dev/null +++ b/tslang/docs/superpowers/plans/2026-10-02-own-strings-as-values.md @@ -0,0 +1,1051 @@ +# `-mm=own` Strings as Values 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:** Under `-mm=own`, a second reference to a string that own cannot prove a move or a borrow +becomes a copy (`ts.StringCopy`), so string-heavy programs compile instead of failing. + +**Architecture:** The ownership inference pass decides each function in up to two runs. +- A dry run of today's analysis reports nothing and changes nothing. +- If the dry run fails, each string retain it could not erase is rewritten: the one use rc made + that retain for takes a fresh `ts.StringCopy` of the value, and the retain goes. +- The real run then verifies the rewritten function as it does today. + +A function that compiles today takes exactly today's path. + +**Tech Stack:** C++17, MLIR/LLVM (tablegen ops, conversion patterns), the tslang test runner, +CMake/CTest. + +**Spec:** `tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md`, section 22. +Sections 15 (signature facts), 20.2 (constant data) and 21 (named messages, nullable views) are +background. + +## Global Constraints + +- Branch `own-strings-as-values` (spec §22 is commit 0cd3dc23, on main 9d92dc84). +- Copies are silent: no diagnostic, no flag (§22.1). +- Copies only in a function the dry run rejects, and only for string retains the dry run did not + erase. A function that compiles today must compile to the same IR (§22.1, §22.6). +- "A string" (§22.1, as corrected in Task 1): + - `!ts.string`; + - a string literal type; + - a `!ts.union` of string, string literals and `!ts.null` only, which needs no tag (one pointer); + - an `!ts.optional` of one of those (`string | undefined`, an optional parameter), which is + `{ptr, i1}`. +- No copy (§22.4) when: + - the retain's use is not exactly one op, or there is no retain (rc consumed the value); + - the value borrows a parameter the function's callers know it keeps (`__own_params` on the + function) or returns a borrow of (`__own_result_borrows`); + - the retain is a closure capture's (`captureRetains`) or a borrowing field's + (`fieldBorrowRetains`). +- `ts.StringCopy`: null stays null and allocates nothing; otherwise `strlen + 1` bytes through + `ch.MemoryAlloc` (the allocator `ts.StringConcat` uses) and a `memcpy`. It lowers under every + model. +- Never branch on the memory model in MLIRGen (§4.3). This plan does not touch MLIRGen. +- 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`. + For tests: `ctest -C Release` (without `-C`, every test is "Not Run"). + - Editing `TypeScriptOps.td` regenerates the dialect. If MSVC fails with C1060, rebuild with + `--parallel 3`. +- 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 `. + +## Review Focus + +1. **A copy that is not made, while its retain is erased.** The use then shares the original, and + the original's owner frees it, which is a use after free. The tests must free the original and + allocate over it before reading the copy (the `churn` pattern), or a missing copy passes on + stale memory. Task 3 owns this. +2. **The dry run emitting or changing anything.** A diagnostic printed twice, an attribute left + set, or a retain erased by the dry run. Task 2 checks the error count and that the IR is + unchanged. +3. **An optional string whose flag says "no value".** Its pointer may be garbage, and copying it + would crash. The lowering must test the flag before the pointer, and Task 3's + `own_string_copy_nullable` passes an `undefined` through a copy. +4. **A parameter the callers were told is kept.** Copying it leaks, because the caller gave its + reference up. Task 3's negative `own_err_string_kept_param_known` pins the guard. +5. **The same retain chosen twice, or a use that takes the value twice** (`f(s, s)`, where one + call keeps both arguments). `takerOf` returns null for two takers, which means no copy. Task 3 + tests that a call keeping two arguments still errors rather than copying once. + +--- + +### Task 1: the `ts.StringCopy` op, its lowering, and the facts + +**Files:** +- Modify: `tslang/include/TypeScript/TypeScriptOps.td` (after `TypeScript_StringConcatOp`, about + line 2374) +- Modify: `tslang/lib/TypeScript/LowerToLLVM.cpp`: + - a new pattern after `StringConcatOpLowering` (about line 880); + - its registration in the pattern list (about line 7966, beside `StringConcatOpLowering`). +- Modify: `tslang/lib/TypeScript/LowerToAffineLoops.cpp`: the legal-op list, about line 2660, + beside `mlir_ts::StringConcatOp`. +- Modify: `tslang/lib/TypeScript/OwnershipFacts.h`: + - `isFresh`, about line 297; + - a new `isCopyableString` after `holdsNoBlock`. +- Modify: `tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md`, §22.1 and §22.2: + correct the representation of the optional. + +**Interfaces:** +- Produces: + - `mlir_ts::StringCopyOp`, built as `builder.create(loc, value.getType(), value)`, + accessor `getIn()`, result type equal to the operand type; + - `inline bool isCopyableString(mlir::Type type)` in `OwnershipFacts.h`; + - `isFresh(value)` true for a `StringCopyOp` result. + +- [ ] **Step 1: Add the op.** In `TypeScriptOps.td`, after `TypeScript_StringConcatOp`: + +```tablegen +// A copy of a string: a new block holding the same text, null when `in` is null (or, for an +// optional, when it holds no value). Made by -mm=own's inference pass only, where a second +// reference to a string cannot be proven a move or a borrow (spec 22); it lowers under every +// model. +def TypeScript_StringCopyOp : TypeScript_Op<"StringCopy", [AllTypesMatch<["in", "result"]>]> { + let arguments = (ins AnyType:$in); + let results = (outs Res:$result); +} +``` + +- [ ] **Step 2: Mark it legal for the affine lowering.** In `LowerToAffineLoops.cpp`, in the + `target.addLegalOp<...>` list, change `mlir_ts::StringConcatOp,mlir_ts::StringCompareOp` to + `mlir_ts::StringConcatOp, mlir_ts::StringCopyOp, mlir_ts::StringCompareOp`. + +- [ ] **Step 3: Write the LLVM lowering.** In `LowerToLLVM.cpp`, after `StringConcatOpLowering`: + +```cpp +// A copy of a string: `strlen + 1` bytes, from the allocator `ts.StringConcat` uses. A null string +// stays null and allocates nothing. An optional `{ptr, i1}` is copied only when it holds a value: +// a pointer whose flag says "none" may be anything. +class StringCopyOpLowering : public TsLlvmPattern +{ + public: + using TsLlvmPattern::TsLlvmPattern; + + LogicalResult matchAndRewrite(mlir_ts::StringCopyOp op, Adaptor transformed, + ConversionPatternRewriter &rewriter) const final + { + TypeHelper th(rewriter); + LLVMCodeHelper ch(op, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); + TypeConverterHelper tch(getTypeConverter()); + CodeLogicHelper clh(op, rewriter); + + auto loc = op->getLoc(); + auto i8PtrTy = th.getPtrType(); + auto llvmIndexType = tch.convertType(th.getIndexType()); + auto llvmBoolType = th.getLLVMBoolType(); + auto strlenFuncOp = ch.getOrInsertFunction("strlen", th.getFunctionType(llvmIndexType, {i8PtrTy})); + + auto copyOf = [&](mlir::Value source, mlir::Value copyIt) { + return clh.conditionalExpressionLowering( + loc, i8PtrTy, copyIt, + [&](OpBuilder &, Location) -> mlir::Value { + mlir::Value bytes = rewriter.create(loc, strlenFuncOp, ValueRange{source}).getResult(); + bytes = rewriter.create( + loc, llvmIndexType, + ValueRange{bytes, rewriter.create(loc, llvmIndexType, rewriter.getIntegerAttr(llvmIndexType, 1))}); + auto copy = ch.MemoryAlloc(bytes); + rewriter.create(loc, copy, source, bytes, /*isVolatile=*/false); + return copy; + }, + [&](OpBuilder &, Location) -> mlir::Value { return source; }); + }; + + mlir::Value in = transformed.getIn(); + auto isSet = [&](mlir::Value pointer) -> mlir::Value { + return rewriter.create(loc, LLVM::ICmpPredicate::ne, pointer, rewriter.create(loc, i8PtrTy)); + }; + + if (mlir::isa(in.getType())) + { + rewriter.replaceOp(op, ValueRange{copyOf(in, isSet(in))}); + return success(); + } + + // an optional: `{ptr, i1}` + auto pointer = rewriter.create(loc, in, ArrayRef{0}); + auto hasValue = rewriter.create(loc, in, ArrayRef{1}); + auto copyIt = rewriter.create(loc, llvmBoolType, hasValue, isSet(pointer)); + auto copied = copyOf(pointer, copyIt); + rewriter.replaceOp(op, ValueRange{rewriter.create(loc, in, copied, ArrayRef{0})}); + return success(); + } +}; +``` + + Check the helper names against `StringResizeOpLowering`/`StringConcatOpLowering` in the same + file (`th.getLLVMBoolType`, `LLVM::ZeroOp`, `ch.MemoryAlloc`). If `getLLVMBoolType` is not on + `TypeHelper`, use `rewriter.getI1Type()`. + + Register it: in the pattern list (the line with `StringConcatOpLowering,`), add + `StringCopyOpLowering,` after it. + +- [ ] **Step 4: Teach the facts.** In `OwnershipFacts.h`, `isFresh`: add `mlir_ts::StringCopyOp` + to the `mlir::isa` + list. After `holdsNoBlock`, add: + +```cpp +// A value -mm=own copies rather than give a second owner (spec 22.1): a string, a string literal, +// a union of strings and null that needs no tag (one pointer), or an optional of one of those +// (`{ptr, i1}`). +inline bool isCopyableString(mlir::Type type) +{ + auto isText = [](mlir::Type member) { + if (auto literalType = mlir::dyn_cast(member)) + { + return mlir::isa(literalType.getElementType()); + } + + return mlir::isa(member); + }; + + if (auto optionalType = mlir::dyn_cast(type)) + { + return !mlir::isa(optionalType.getElementType()) && + isCopyableString(optionalType.getElementType()); + } + + if (auto unionType = mlir::dyn_cast(type)) + { + return llvm::any_of(unionType.getTypes(), isText) && + llvm::all_of(unionType.getTypes(), [&](mlir::Type member) { + return isText(member) || mlir::isa(member); + }); + } + + return isText(type); +} +``` + + A union of strings and null must need no tag. Confirm on a `string | null` local that + `MLIRTypeHelper::isUnionTypeNeedsTag` answers false (the LLVM type is `ptr`; see the `@n` and + `@w` functions in §21's checks). If some string union does need a tag, add that condition here. + +- [ ] **Step 5: Correct the spec.** In §22.1, change "Those are one pointer, the same as `string` + (the nullable views of §21.3)" to: + +``` +A union with `null` is one pointer, the same as `string`; an optional (`string | undefined`, an +optional parameter) is `{ptr, i1}`, the pointer meaningful only when the flag is set. +``` + + In §22.2, change "A nullable string is copied through its pointer." to "An optional is copied + only when it holds a value." + +- [ ] **Step 6: Build, and confirm nothing else changed.** Build (Global Constraints). Run + `ctest -C Release -R "own" -j 8 --timeout 300` and expect 100% passed. Nothing creates the op + yet, so this is a no-op change for every program. + +- [ ] **Step 7: Commit.** + +```bash +git add tslang/include/TypeScript/TypeScriptOps.td tslang/lib/TypeScript/LowerToLLVM.cpp tslang/lib/TypeScript/LowerToAffineLoops.cpp tslang/lib/TypeScript/OwnershipFacts.h tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md +git commit -m "-mm=own strings: ts.StringCopy and the facts that know it (spec 22.2)" +``` + +--- + +### Task 2: the dry run (no behaviour change) + +**Files:** +- Modify: `tslang/lib/TypeScript/OwnershipInferencePass.cpp` + +**Interfaces:** +- Consumes: nothing from Task 1. +- Produces, members of `OwnershipInferencePass`: + - `bool dryRun = false;` + - `unsigned failures = 0;` + - `bool reporting();` + - `void setAttrTracked(mlir::Operation *op, llvm::StringRef name);` + - `bool analyze(llvm::SetVector &toErase);`, which returns true when the + function passed. In a dry run it erases nothing and strips no facts; otherwise it does both, + as `runOnFunction` does today. + +- [ ] **Step 1: Record the baseline.** Build, then, in the scratchpad, save the first error of + every corpus file (Appendix A, `run.sh`) as `base.tsv`, plain and with + `OPTS="--opt --opt_level=3"` as `baseopt.tsv`. Also save the full stderr of three negative tests: + +```bash +T=__build/tslang/windows-msbuild-2026-release/bin/tslang.exe +for f in own_err_two_owners own_err_closure_escape_twice own_err_delete; do $T --emit=obj --no-default-lib -mm=own tslang/test/tester/own/$f.ts -o NUL > $SCRATCH/$f.before.txt 2>&1; done +``` + +- [ ] **Step 2: Add the reporting switch.** Add the members beside `unsigned quiet = 0;`: + +```cpp + // The dry run (spec 22.3): the whole analysis, but nothing reported and nothing changed. A + // report it would make is counted in `failures`. + bool dryRun = false; + unsigned failures = 0; + // attributes the dry run set on ops that did not have them, taken off again after it + llvm::SmallVector> dryRunAttrs; + + // Whether a report is to be emitted: not under `quietly`'s trial, and not in the dry run, which + // only counts it. + bool reporting() + { + if (quiet) + { + return false; + } + + if (dryRun) + { + ++failures; + return false; + } + + return true; + } + + // `op->setAttr(name, unit)`, undone after the dry run when the op did not have it + void setAttrTracked(mlir::Operation *op, llvm::StringRef name) + { + auto attrName = mlir::StringAttr::get(&getContext(), name); + if (dryRun && !op->hasAttr(attrName)) + { + dryRunAttrs.push_back({op, attrName}); + } + + op->setAttr(attrName, mlir::UnitAttr::get(&getContext())); + } +``` + +- [ ] **Step 3: Route every report through it.** + - In every report function (`grep -n "if (quiet)" OwnershipInferencePass.cpp`, about 12 of + them), replace + ```cpp + if (quiet) + { + return; + } + ``` + with + ```cpp + if (!reporting()) + { + return; + } + ``` + - Then find every `emitError` that is not inside such a function. Today: the `RetainCell` and + `DeleteOp` errors in the walk at the top of `runOnFunction`, and any others + `grep -n "emitError" ... | grep -v report` shows. Wrap each with its `signalPassFailure()`: + ```cpp + if (reporting()) + { + op->emitError("..."); + signalPassFailure(); + } + ``` + - Every `signalPassFailure()` must now sit behind a `reporting()` that returned true. Confirm with + `grep -n "signalPassFailure" OwnershipInferencePass.cpp` and read each one. + +- [ ] **Step 4: Track the attributes.** Replace each `x->setAttr(NAME, mlir::UnitAttr::get(&getContext()))` + in the pass with `setAttrTracked(x, NAME)` (`grep -n "setAttr(" OwnershipInferencePass.cpp`: the + `OWN_CAPTURE_BORROW_ATTR_NAME`, `OWN_BORROWS_CAPTURES_ATTR_NAME` and + `OWN_CELL_BORROWS_ATTR_NAME` sites). + +- [ ] **Step 5: Split `runOnFunction` into `analyze`.** + - Move the body of `runOnFunction` into `bool analyze(llvm::SetVector &toErase)`, + with `toErase` declared by the caller, not inside. + - Its last part becomes: + ```cpp + if (dryRun) + { + return failures == 0; + } + + for (auto *op : toErase) + { + op->erase(); + } + + // the facts were for this pass only + returnedParams.clear(); + f.walk([](mlir::Operation *op) { /* the existing removeAttr loop, unchanged */ }); + return true; + ``` + - Every member the body uses must start each run clean. `analyze` already clears `drops`, + `derivedCache`, `closures`, `closureOf`, `stateBoxes`, `fieldBorrowRetains`, + `fieldBorrowReleases`, `captureStores` and `captureRetains`, and resets `returnsBorrowOf`. Also + clear `returnedParams` at its start. Read the private members once and clear any other + per-function state the same way. + - `runOnFunction` becomes: + ```cpp + void runOnFunction() + { + // the dry run (spec 22.3): does today's analysis pass as it is? + { + llvm::SetVector toErase; + dryRun = true; + failures = 0; + auto passed = analyze(toErase); + dryRun = false; + for (auto &[op, name] : dryRunAttrs) + { + op->removeAttr(name); + } + + dryRunAttrs.clear(); + (void)passed; // Task 3 makes the copies here when it did not + } + + llvm::SetVector toErase; + analyze(toErase); + } + ``` + +- [ ] **Step 6: Confirm nothing changed.** + - Build. Run `ctest -C Release -R "own|mlirgen-matches" -j 8 --timeout 300`: expect 100%. + - Rerun the corpus (`run.sh`) plain and `--opt`, and `diff` against `base.tsv`/`baseopt.tsv`, + ignoring the generated `FH` hashes (`sed -E 's/FH[0-9]+/FH/g'` on both). Expect no + differences. + - Rerun Step 1's three negatives and `diff` their stderr against the `.before.txt` files. They + must be identical: each error once, with the same notes. + +- [ ] **Step 7: Commit.** + +```bash +git add tslang/lib/TypeScript/OwnershipInferencePass.cpp +git commit -m "-mm=own strings: the inference pass's dry run (spec 22.3), no change yet" +``` + +--- + +### Task 3: the copies, their guards, and the tests + +**Files:** +- Modify: `tslang/lib/TypeScript/OwnershipInferencePass.cpp` +- Create (positive): in `tslang/test/tester/own/`, `own_string_copy_field.ts`, + `own_string_copy_after_move.ts`, `own_string_copy_nullable.ts`, `own_string_copy_exported.ts`, + `own_string_copy_written.ts` +- Create (negative): in `tslang/test/tester/own/`, `own_err_string_kept_param_known.ts`, + `own_err_string_call_keeps_twice.ts`, `own_err_string_consumed_twice.ts` +- Modify: `tslang/test/tester/CMakeLists.txt`: + - the `foreach(own_test ...)` list; + - `own_error_cases`; + - a foreach running the positives under rc, none and gc; + - fix up the existing string negatives (Step 7). + +**Interfaces:** +- Consumes: `mlir_ts::StringCopyOp`, `isCopyableString` (Task 1); `dryRun`, `analyze`, + `captureRetains`, `fieldBorrowRetains` (Task 2). +- Produces: `bool makeStringCopies(const llvm::SetVector &erased)`, which returns + whether it rewrote anything. + +- [ ] **Step 1: Write the positive tests.** Each frees the original, or overwrites its owner, and + allocates over it with `churn()` before reading the copy. A missing copy then reads freed memory. + +`own_string_copy_field.ts`: + +```ts +// A field's string given a second owner is copied (spec 22): stored into another field, returned, +// pushed, and read out of a caught object. Each original is then overwritten and its memory reused +// before the copy is read. +class Rec { + str = ""; + str2 = ""; +} + +class E { + constructor(public m: string) {} +} + +function churn() { + let keep: string[] = []; + for (let i = 0; i < 1000; i++) keep.push("k" + i); + return keep.length; +} + +function textOf(r: Rec) { + return r.str2; +} + +function main() { + const r = new Rec(); + r.str2 = "two" + 2; + r.str = r.str2; + r.str2 = "other" + 3; + assert(churn() == 1000); + assert(r.str == "two2" && r.str2 == "other3"); + + const got = textOf(r); + r.str2 = "again" + 4; + assert(churn() == 1000); + assert(got == "other3"); + + const list: string[] = []; + list.push(r.str2); + r.str2 = "last" + 5; + assert(churn() == 1000); + assert(list[0] == "again4"); + + let caught = ""; + try { + throw new E("thrown" + 6); + } catch (e) { + caught = (e).m; + } + + assert(churn() == 1000); + assert(caught == "thrown6"); + + print("done."); +} +``` + +`own_string_copy_after_move.ts`: + +```ts +// A string moved into a field and used after: the field takes a copy, the local keeps its own. +class H { + text = ""; +} + +function churn() { + let keep: string[] = []; + for (let i = 0; i < 1000; i++) keep.push("k" + i); + return keep.length; +} + +function main() { + const h = new H(); + let kept = "kept" + 1; + h.text = kept; + print(kept); + h.text = "other" + 2; + assert(churn() == 1000); + assert(kept == "kept1" && h.text == "other2"); + + print("done."); +} +``` + +`own_string_copy_nullable.ts`: + +```ts +// Nullable strings copied: a `string | null` holding a string and holding null, and an optional +// (`string | undefined`, an optional parameter) holding a string and holding nothing - whose +// pointer must not be read. +class Box { + s: string | null = null; + o: string | undefined = undefined; +} + +function churn() { + let keep: string[] = []; + for (let i = 0; i < 1000; i++) keep.push("k" + i); + return keep.length; +} + +function keepOpt(b: Box, v?: string) { + b.o = v; +} + +function main() { + const a = new Box(); + const b = new Box(); + a.s = "nn" + 1; + b.s = a.s; + a.s = null; + assert(churn() == 1000); + assert(b.s == "nn1"); + + b.s = a.s; + assert(b.s == null); + + a.o = "opt" + 2; + b.o = a.o; + a.o = undefined; + assert(churn() == 1000); + assert(b.o == "opt2"); + + b.o = a.o; + assert(b.o === undefined); + + keepOpt(a, b.o); + keepOpt(a); + assert(a.o === undefined); + + print("done."); +} +``` + +`own_string_copy_exported.ts`: + +```ts +// An exported class's constructor keeps its string parameter. Its callers cannot be told so (it can +// be called from another module), so they keep their own, and the field takes a copy. +export class Animal { + name: string; + constructor(name: string) { + this.name = name; + } +} + +function churn() { + let keep: string[] = []; + for (let i = 0; i < 1000; i++) keep.push("k" + i); + return keep.length; +} + +function main() { + let n = "cat" + 1; + const a = new Animal(n); + n = "dog" + 2; + assert(churn() == 1000); + assert(a.name == "cat1" && n == "dog2"); + + print("done."); +} +``` + +`own_string_copy_written.ts`, run under own only (gc shares the block, so `s` would change too): + +```ts +// A write into a copy's bytes changes the copy only (spec 22.1): strings are values under own. +class R { + s = ""; + t = ""; +} + +function main() { + const r = new R(); + r.s = "abc" + ""; + r.t = r.s; + r.t[0] = 65; + assert(r.t == "Abc", "the copy was written"); + assert(r.s == "abc", "the original was not"); + + print("done."); +} +``` + +- [ ] **Step 2: Write the negative tests.** + +`own_err_string_kept_param_known.ts`: + +```ts +// -mm=own rejects, and does not copy: `keep` is private and called only directly, so the +// signature pass tells its callers it keeps its parameter (`__own_params`) and they give theirs up. +// A copy in `keep` would leak the one the caller gave. `main` passes a field's string, which it +// does not own. +class H { + s = ""; +} + +const holder = new H(); + +function keep(v: string) { + holder.s = v; +} + +function main() { + const h = new H(); + h.s = "x" + 1; + keep(h.s); + print(holder.s); +} +``` + + Expected message: `argument .* is given to 'keep', which keeps it, but -mm=own cannot move it here`. + If the actual first error differs, keep a regex that names the kept-parameter rule, and say so in + the commit message. + +`own_err_string_call_keeps_twice.ts`: + +```ts +// -mm=own rejects, and does not copy: one string given twice to a call that keeps both arguments. +// The retains have no single use each, so no copy is made. +class P { + a = ""; + b = ""; +} + +const p = new P(); + +function both(x: string, y: string) { + p.a = x; + p.b = y; +} + +function main() { + const h = new P(); + h.a = "s" + 1; + both(h.a, h.a); + print(p.a, p.b); +} +``` + + Expected message: whatever the current build reports at `both(h.a, h.a)`. Record it as the regex. + It must not be "compiles". + +`own_err_string_consumed_twice.ts`: + +```ts +// -mm=own rejects, and does not copy: rc hands the result of an indirect call to two consuming +// declarations with no retain at all, so there is no retain to turn into a copy (spec 22.4). +function main() { + const make = (n: number) => "s" + n; + const a = make(1); + let b = a; + let c = a; + print(b, c); +} +``` + + Expected message: the current build's first error for it, recorded as the regex. If this program + compiles before Task 3 (rc retained after all), find another value rc consumes twice without a + retain (spec §11, phase 0's notes), or record in §22.7 that no such source shape was found, and + drop the test. + + The negative for a value that is not a string is the existing class negatives + (`own_err_two_owners`, `own_err_push_let`, ...). Step 5's full `own` run keeps them passing. + +- [ ] **Step 3: Register the tests and watch them fail.** In `CMakeLists.txt`: + - add `own_string_copy_field own_string_copy_after_move own_string_copy_nullable own_string_copy_exported own_string_copy_written` + to the `foreach(own_test ...)` list; + - add the three negatives to `own_error_cases` with the regexes from Step 2; + - after the `own_global_script` foreach, add: + +```cmake +# Strings as values (spec 22): no model but own makes a copy, and each program means the same +# under every model. +foreach(strings_mm rc none gc) + foreach(strings_test own_string_copy_field own_string_copy_after_move own_string_copy_nullable own_string_copy_exported) + tslang_add_test(NAME test-jit-${strings_mm}-${strings_test} COMMAND test-runner -jit -mm=${strings_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${strings_test}.ts") + tslang_add_test(NAME test-compile-${strings_mm}-${strings_test} COMMAND test-runner -mm=${strings_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${strings_test}.ts") + endforeach() +endforeach() +``` + + Build. Run `ctest -C Release -R "own_string_copy|own_err_string" -j 8`. Expected: every + `-own-` positive fails (own rejects them today), the rc/none/gc ones pass, and the negatives + pass. If an rc/none/gc run fails, that is a bug outside this phase. Stop and report it. + +- [ ] **Step 4: Write the rewrite.** In `OwnershipInferencePass.cpp`, add: + +```cpp + // Strings as values (spec 22.3): each string retain the dry run did not erase is rewritten, where + // its one use can be found, into a copy for that use. True when anything was rewritten. + bool makeStringCopies(const llvm::SetVector &erased) + { + llvm::SmallVector retains; + getFunction()->walk([&](mlir::Operation *op) { + if (mlir::isa(op) && !erased.contains(op) && + !captureRetains.contains(op) && !fieldBorrowRetains.contains(op)) + { + retains.push_back(op); + } + }); + + auto copied = false; + for (auto *retain : retains) + { + copied = copyForRetain(retain) || copied; + } + + return copied; + } + + // The value a parameter of this function is, when its callers were told this function keeps it + // or returns a borrow of it (spec 15): copying it would leak what the caller gave up. + bool borrowsKnownParam(mlir::Value value) + { + auto param = borrowedParam(value); + return param >= 0 && (llvm::is_contained(ownedParams(getFunction()), param) || param == returnsBorrowOf); + } + + bool copyForRetain(mlir::Operation *retain) + { + // `let x = v`: the local starts from a copy + if (auto retainSlotOp = mlir::dyn_cast(retain)) + { + auto varOp = retainSlotOp.getSlot().getDefiningOp(); + auto init = varOp ? varOp.getInitializer() : mlir::Value(); + if (!init || !isCopyableString(init.getType()) || borrowsKnownParam(init) || isCellVariable(varOp.getResult())) + { + return false; + } + + mlir::OpBuilder builder(varOp); + auto copy = builder.create(varOp.getLoc(), init.getType(), init); + varOp->setOperand(0, copy); + retain->erase(); + return true; + } + + auto retainOp = mlir::cast(retain); + auto value = retainOp.getReference(); + if (!isCopyableString(value.getType()) || borrowsKnownParam(value)) + { + return false; + } + + // the one use rc retained it for + mlir::OpOperand *taking = nullptr; + auto several = false; + forEachUse(value, [&](mlir::Operation *user, mlir::Value used) { + if (mlir::isa(user) || isBorrow(user, used)) + { + return; + } + + for (auto &operand : user->getOpOperands()) + { + if (operand.get() == used) + { + several = several || taking; + taking = &operand; + } + } + }); + + if (!taking || several || !isCopyableString(taking->get().getType())) + { + return false; + } + + mlir::OpBuilder builder(taking->getOwner()); + auto copy = builder.create(retain->getLoc(), taking->get().getType(), taking->get()); + taking->set(copy); + retain->erase(); + return true; + } +``` + + Check whether `VariableOp`'s initializer is operand 0 (`ODS`: `Optional:$initializer` + is its only operand). If there is a generated `getInitializerMutable()`, use + `varOp.getInitializerMutable().assign(copy)` instead. + + In `runOnFunction`, replace `(void)passed; // Task 3 ...` with: + +```cpp + if (!passed) + { + makeStringCopies(toErase); + } +``` + + This needs `captureRetains` and `fieldBorrowRetains` as the dry run left them. They are members + and are cleared only at the start of `analyze`, so they still hold the dry run's sets here. + +- [ ] **Step 5: Run the tests.** Build. Run + `ctest -C Release -R "own_string_copy|own_err_string|own" -j 8 --timeout 300`. Expected: all pass. + - If a positive still fails, read its error: + - one named in §22.4 means that shape is outside the rule; adjust the test only if the spec + says so; + - otherwise look for why `copyForRetain` returned false (add a temporary `llvm::errs()` line, + never committed). + - Also run each positive under own with the runner's `-noopt` (`--di --opt_level=0`): + `test-runner -noopt -mm=own ` and `test-runner -noopt -jit -mm=own `. + +- [ ] **Step 6: Teeth.** Each change is temporary, made after this task's code is committed or + backed up. + - (a) `makeStringCopies` returns false at its top: every `own_string_copy_*` positive must fail. + - (b) `borrowsKnownParam` returns false: `own_err_string_kept_param_known` must compile, or + fail at run time under AOT. If it still errors the same way, the guard is a second guard there; + record that in §22.7. + - Revert both. Clear `tslang/test/tester/own/__jit` and the build's `own/__jit` between JIT runs + (the JIT cache does not key on such a switch). + +- [ ] **Step 7: The string negatives that now compile.** + - Run `ctest -C Release -R "test-own-err" -j 8`. Phase 6's named-message negatives used strings + in some cases: `own_err_mixed_return`, `own_err_unbox_string`, + `own_err_param_returned_exported`, `own_err_param_kept_exported`, `own_err_merged_result`, and + perhaps older ones. + - For each that now fails because the program compiles, rewrite it with a class in place of the + string, so that it still exercises its message. For example, in `own_err_mixed_return.ts`: + ```ts + class C { constructor(public n: string) {} } + function pick(c: C, other: boolean) { + if (!other) return c; + return new C("new"); + } + function main() { + const c = new C("a"); + print(pick(c, true).n); + } + ``` + Update its regex's function name (`'pick' returns a borrow ...`). + - `own_err_unbox_string` cannot become a class: `anyValue` does not take the string path. + Delete it from `own_error_cases` and from the tree. Step 1's `own_string_copy_*` tests and + Task 4's mixed-return test cover the copy now. + - Rerun until `test-own-err` is 100%. + +- [ ] **Step 8: Commit.** + +```bash +git add tslang/lib/TypeScript/OwnershipInferencePass.cpp tslang/test/tester/CMakeLists.txt tslang/test/tester/own/ +git commit -m "-mm=own strings: a second reference to a string own cannot prove becomes a copy (spec 22.3, 22.4)" +``` + +--- + +### Task 4: mixed returns, and the corpus + +**Files:** +- Create: `tslang/test/tester/own/own_string_copy_mixed_return.ts` +- Modify: `tslang/test/tester/CMakeLists.txt` (the `own_test` list and the rc/none/gc foreach) +- Modify: `tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md`: a new §22.7 + "Results" +- Modify: `tslang/docs/superpowers/plans/2026-10-02-own-strings-as-values.md`: tick the boxes + +**Interfaces:** +- Consumes: Task 3's rewrite (no new code is expected here; if the mixed-return shape needs one, it + goes into `copyForRetain` with its own test). + +- [ ] **Step 1: Write the test.** `own_string_copy_mixed_return.ts`: + +```ts +// A function that returns a borrow of its argument on one path and a new string on another: the +// borrowed return is copied, so the result is always the caller's (spec 22.5). Also the +// synthesized `` of an `any`, and a union cast to a string. +function greet(name: string) { + if (name === "Honda") return name; + return "Sorry, " + name; +} + +function churn() { + let keep: string[] = []; + for (let i = 0; i < 1000; i++) keep.push("k" + i); + return keep.length; +} + +function main() { + let who = "Hon" + "da"; + const g = greet(who); + who = "x" + 1; + assert(churn() == 1000); + assert(g == "Honda"); + assert(greet("Bob") == "Sorry, Bob"); + + const a = ("boxed" + 1); + const back = a; + assert(back == "boxed1"); + + let u: number | string = "un" + 1; + const s = u; + u = 3; + assert(churn() == 1000); + assert(s == "un1"); + + print("done."); +} +``` + + Add it to the `own_test` list and to the rc/none/gc foreach. Build, and run its six runner + variants plus `-noopt -mm=own`. Expected: all pass. If own still rejects it, read which retain + had no single use (Task 3, Step 5), fix `copyForRetain` with a test-first change, and record the + shape in §22.7. + +- [ ] **Step 2: Corpus.** Rerun `run.sh` plain and `--opt` (Appendix A). Then: + - **None lost:** `join -t $'\t' base.tsv new.tsv | awk -F'\t' '$2=="ok" && $3!="ok"'` prints + nothing, plain and `--opt`. + - **Gate every gained file:** list the files ok under `--opt` now and not in `baseopt.tsv`, then + run `gate1.sh` on each, with and without `RFLAGS=-noopt` (Appendix A). Every line must read + `aot=ok jit=ok`. A failure is a bug: stop and fix it, with a test, before going on. + - Record the first-error histogram before and after + (`cut -f2 | sed -E "s/'[^']*'/'X'/g" | sort | uniq -c | sort -rn`). + +- [ ] **Step 3: Unchanged where it compiles.** Before building this branch's final binary, copy + main's `tslang.exe` aside (build main once, or reuse a saved one: `tslang-main.exe`). + - For every file ok in `base.tsv`, compare + `--emit=llvm -mm=own --no-default-lib -o .ll` from both binaries, with + `FH` hashes normalised. + - Expect no difference. Any difference means a function that compiled on main took the copy + path, or the dry run changed something. Find it and fix it. + +- [ ] **Step 4: Measure.** Write `$SCRATCH/copy_loop.ts`: + +```ts +class R { + s = ""; + t = ""; +} + +function main() { + const r = new R(); + for (let i = 0; i < 200000; i++) { + r.s = "v" + i; + r.t = r.s; + } + + print(r.t); +} +``` + + Run `pwsh tslang/test/tester/tools/measure.ps1 -Source $SCRATCH/copy_loop.ts` (once after a plain + test-runner run, so `compile.bat` exists). Then run it again at 20000 iterations. Expected: + - own's peak is the same at both counts, within 0.5 MB, so every copy is freed; + - rc is flat as well; + - none grows. + + Record both rows in §22.7. + +- [ ] **Step 5: Spec results.** Append §22.7 "Results" to the spec, in the style of §20/§21: + - what was built; + - the corpus table, before and after, plain and `--opt`; + - the gated files; + - the teeth (Task 3, Step 6); + - the measure rows; + - the IR-unchanged check; + - every shape found outside the rule. + + Tick this plan's boxes. + +- [ ] **Step 6: Linux.** Build the branch with GCC in WSL and run + `ctest -R "own-err|own-no-counting|own-verify"` there (recipe: the WSL clone at + `~/ts/TypeScriptCompiler`, build dir `__build/tslang/ninja/release`, fetch this branch from the + Windows repo). + +- [ ] **Step 7: Commit, and open the PR.** + +```bash +git add tslang/test/tester/own/own_string_copy_mixed_return.ts tslang/test/tester/CMakeLists.txt tslang/docs/superpowers/ +git commit -m "-mm=own strings: mixed returns, corpus and results (spec 22.7)" +gh auth switch --user ASDAlexander77 +git push -u origin own-strings-as-values +``` + + The PR body carries the §22.7 corpus table, and ends with + `🤖 Generated with [Claude Code](https://claude.com/claude-code)`. + +--- + +## Appendix A: the corpus tooling (scratchpad only, never committed) + +`one.sh`, the first error of one corpus file: + +```sh +#!/bin/sh +f="$1"; 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" +``` + +`gate1.sh`, test-runner under own for one file, AOT and JIT (`RFLAGS=-noopt` for the `--di` build): + +```sh +#!/bin/sh +f="$1"; R=/i/TypeScriptCompiler/__build/tslang/windows-msbuild-2026-release/test/tester/Release/test-runner.exe +cd /i/TypeScriptCompiler/__build/tslang/windows-msbuild-2026-release/test/tester || exit 1 +src=/i/TypeScriptCompiler/tslang/test/tester/tests/$f +a=$(timeout 300 "$R" $RFLAGS -mm=own "$src" >/dev/null 2>&1 && echo ok || echo FAIL) +j=$(timeout 300 "$R" $RFLAGS -jit -mm=own "$src" >/dev/null 2>&1 && echo ok || echo FAIL) +echo "$f aot=$a jit=$j" +``` From d07f6205a35e0dbf1e7ea7885a42a3ae5f394904 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Fri, 2 Oct 2026 22:40:49 +0100 Subject: [PATCH 03/16] -mm=own strings: ts.StringCopy and the facts that know it (spec 22.2) Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 8 ++- tslang/include/TypeScript/TypeScriptOps.td | 9 +++ tslang/lib/TypeScript/LowerToAffineLoops.cpp | 2 +- tslang/lib/TypeScript/LowerToLLVM.cpp | 60 ++++++++++++++++++- tslang/lib/TypeScript/OwnershipFacts.h | 33 +++++++++- 5 files changed, 106 insertions(+), 6 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 afdf0a7ce..4b41a0649 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 @@ -1739,8 +1739,9 @@ Under `-mm=own`, a second reference to a string that own cannot prove to be a mo becomes a copy. - **A string** is a value whose type is `string`, a string literal type, or a union or an optional - of `string` with only `null` or `undefined` beside it. Those are one pointer, the same as - `string` (the nullable views of §21.3). A union with other members (`string | number`), and an + of `string` with only `null` or `undefined` beside it. A union with `null` is one pointer, the + same as `string`; an optional (`string | undefined`, an optional parameter) is `{ptr, i1}`, the + pointer meaningful only when the flag is set. A union with other members (`string | number`), and an object or array holding strings, are not covered. - **Silent.** A copy makes no diagnostic. - **Only where the program would fail.** Copies are made only in a function the analysis rejects, @@ -1754,7 +1755,8 @@ becomes a copy. `ts.StringCopy %s : !ts.string -> !ts.string`, its result a new allocation (`MemAlloc`). It lowers to: null stays null; otherwise the length plus one byte, through the allocator `ts.StringConcat` -uses, and a `memcpy`. A nullable string is copied through its pointer. +uses, and a `memcpy`. An optional is copied +only when it holds a value. - Only the own inference pass creates it, but it lowers under every model. - `isFresh` counts it, beside `ts.StringConcat`. diff --git a/tslang/include/TypeScript/TypeScriptOps.td b/tslang/include/TypeScript/TypeScriptOps.td index b33e11a79..0da880b31 100644 --- a/tslang/include/TypeScript/TypeScriptOps.td +++ b/tslang/include/TypeScript/TypeScriptOps.td @@ -2373,6 +2373,15 @@ def TypeScript_StringConcatOp : TypeScript_Op<"StringConcat"> { let results = (outs Res:$result); } +// A copy of a string: a new block holding the same text, null when `in` is null (or, for an +// optional, when it holds no value). Made by -mm=own's inference pass only, where a second +// reference to a string cannot be proven a move or a borrow (spec 22); it lowers under every +// model. +def TypeScript_StringCopyOp : TypeScript_Op<"StringCopy", [AllTypesMatch<["in", "result"]>]> { + let arguments = (ins AnyType:$in); + let results = (outs Res:$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/lib/TypeScript/LowerToAffineLoops.cpp b/tslang/lib/TypeScript/LowerToAffineLoops.cpp index e08535693..227dcf7de 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::StringCompareOp, mlir_ts::AnyCompareOp, + mlir_ts::StringLengthOp, mlir_ts::StringResizeOp, mlir_ts::StringConcatOp, mlir_ts::StringCopyOp, 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 07da8cf6f..faac5dbea 100644 --- a/tslang/lib/TypeScript/LowerToLLVM.cpp +++ b/tslang/lib/TypeScript/LowerToLLVM.cpp @@ -884,6 +884,64 @@ class StringConcatOpLowering : public TsLlvmPattern } }; +// A copy of a string: `strlen + 1` bytes, from the allocator `ts.StringConcat` uses. A null string +// stays null and allocates nothing. An optional `{ptr, i1}` is copied only when it holds a value: +// a pointer whose flag says "none" may be anything. +class StringCopyOpLowering : public TsLlvmPattern +{ + public: + using TsLlvmPattern::TsLlvmPattern; + + LogicalResult matchAndRewrite(mlir_ts::StringCopyOp op, Adaptor transformed, + ConversionPatternRewriter &rewriter) const final + { + TypeHelper th(rewriter); + LLVMCodeHelper ch(op, rewriter, getTypeConverter(), tsLlvmContext->compileOptions); + TypeConverterHelper tch(getTypeConverter()); + CodeLogicHelper clh(op, rewriter); + + auto loc = op->getLoc(); + auto i8PtrTy = th.getPtrType(); + auto llvmIndexType = tch.convertType(th.getIndexType()); + auto llvmBoolType = th.getLLVMBoolType(); + auto strlenFuncOp = ch.getOrInsertFunction("strlen", th.getFunctionType(llvmIndexType, {i8PtrTy})); + + auto copyOf = [&](mlir::Value source, mlir::Value copyIt) { + return clh.conditionalExpressionLowering( + loc, i8PtrTy, copyIt, + [&](OpBuilder &, Location) -> mlir::Value { + mlir::Value bytes = rewriter.create(loc, strlenFuncOp, ValueRange{source}).getResult(); + bytes = rewriter.create( + loc, llvmIndexType, + ValueRange{bytes, rewriter.create(loc, llvmIndexType, rewriter.getIntegerAttr(llvmIndexType, 1))}); + auto copy = ch.MemoryAlloc(bytes); + rewriter.create(loc, copy, source, bytes, /*isVolatile=*/false); + return copy; + }, + [&](OpBuilder &, Location) -> mlir::Value { return source; }); + }; + + mlir::Value in = transformed.getIn(); + auto isSet = [&](mlir::Value pointer) -> mlir::Value { + return rewriter.create(loc, LLVM::ICmpPredicate::ne, pointer, rewriter.create(loc, i8PtrTy)); + }; + + if (mlir::isa(in.getType())) + { + rewriter.replaceOp(op, ValueRange{copyOf(in, isSet(in))}); + return success(); + } + + // an optional: `{ptr, i1}` + auto pointer = rewriter.create(loc, in, ArrayRef{0}); + auto hasValue = rewriter.create(loc, in, ArrayRef{1}); + auto copyIt = rewriter.create(loc, llvmBoolType, hasValue, isSet(pointer)); + auto copied = copyOf(pointer, copyIt); + rewriter.replaceOp(op, ValueRange{rewriter.create(loc, in, copied, ArrayRef{0})}); + return success(); + } +}; + class StringCompareOpLowering : public TsLlvmPattern { public: @@ -7963,7 +8021,7 @@ void TypeScriptToLLVMLoweringPass::runOnOperation() DeconstructTupleOpLowering, CreateArrayOpLowering, NewEmptyArrayOpLowering, NewArrayOpLowering, ArrayPushOpLowering, 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, + InsertPropertyOpLowering, LengthOfOpLowering, SetLengthOfOpLowering, StringLengthOpLowering, StringResizeOpLowering, StringConcatOpLowering, StringCopyOpLowering, StringCompareOpLowering, AnyCompareOpLowering, CharToStringOpLowering, UndefOpLowering, CopyStructOpLowering, MemoryCopyOpLowering, MemoryMoveOpLowering, LoadSaveValueLowering, ThrowUnwindOpLowering, ThrowCallOpLowering, VariableOpLowering, DebugVariableOpLowering, AllocaOpLowering, InvokeOpLowering, InvokeHybridOpLowering, VirtualSymbolRefOpLowering, ThisVirtualSymbolRefOpLowering, InterfaceSymbolRefOpLowering, diff --git a/tslang/lib/TypeScript/OwnershipFacts.h b/tslang/lib/TypeScript/OwnershipFacts.h index d05e0438f..198aeaf4c 100644 --- a/tslang/lib/TypeScript/OwnershipFacts.h +++ b/tslang/lib/TypeScript/OwnershipFacts.h @@ -295,7 +295,7 @@ inline bool isFresh(mlir::Value value) } if (mlir::isa(def)) + mlir_ts::StringResizeOp, mlir_ts::StringCopyOp, mlir_ts::CharToStringOp>(def)) { return true; } @@ -433,6 +433,37 @@ inline bool holdsNoBlock(mlir::Value value) return false; } +// A value -mm=own copies rather than give a second owner (spec 22.1): a string, a string literal, +// a union of strings and null that needs no tag (one pointer), or an optional of one of those +// (`{ptr, i1}`). +inline bool isCopyableString(mlir::Type type) +{ + auto isText = [](mlir::Type member) { + if (auto literalType = mlir::dyn_cast(member)) + { + return mlir::isa(literalType.getElementType()); + } + + return mlir::isa(member); + }; + + if (auto optionalType = mlir::dyn_cast(type)) + { + return !mlir::isa(optionalType.getElementType()) && + isCopyableString(optionalType.getElementType()); + } + + if (auto unionType = mlir::dyn_cast(type)) + { + return llvm::any_of(unionType.getTypes(), isText) && + llvm::all_of(unionType.getTypes(), [&](mlir::Type member) { + return isText(member) || mlir::isa(member); + }); + } + + return isText(type); +} + // Some view of `value` is a `ts.NewInterface`, which boxes what it shows into a block. inline bool boxedByView(mlir::Value value) { From 308c2a138e765fce6652fe4174d15edea5db9a56 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Fri, 2 Oct 2026 22:43:28 +0100 Subject: [PATCH 04/16] -mm=own strings: isCopyableString needs a tag-free union; StringCopy checks its operand type Co-Authored-By: Claude Opus 5.5 --- tslang/lib/TypeScript/LowerToLLVM.cpp | 7 +++++++ tslang/lib/TypeScript/OwnershipFacts.h | 15 +++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/tslang/lib/TypeScript/LowerToLLVM.cpp b/tslang/lib/TypeScript/LowerToLLVM.cpp index faac5dbea..d3c0273e0 100644 --- a/tslang/lib/TypeScript/LowerToLLVM.cpp +++ b/tslang/lib/TypeScript/LowerToLLVM.cpp @@ -933,6 +933,13 @@ class StringCopyOpLowering : public TsLlvmPattern } // an optional: `{ptr, i1}` + auto optionalType = mlir::dyn_cast(in.getType()); + if (!optionalType || optionalType.getBody().size() != 2 || + !mlir::isa(optionalType.getBody()[0]) || !optionalType.getBody()[1].isInteger(1)) + { + return rewriter.notifyMatchFailure(op, "ts.StringCopy takes a pointer or a { ptr, i1 } optional"); + } + auto pointer = rewriter.create(loc, in, ArrayRef{0}); auto hasValue = rewriter.create(loc, in, ArrayRef{1}); auto copyIt = rewriter.create(loc, llvmBoolType, hasValue, isSet(pointer)); diff --git a/tslang/lib/TypeScript/OwnershipFacts.h b/tslang/lib/TypeScript/OwnershipFacts.h index 198aeaf4c..7d1c7c8ea 100644 --- a/tslang/lib/TypeScript/OwnershipFacts.h +++ b/tslang/lib/TypeScript/OwnershipFacts.h @@ -455,10 +455,17 @@ inline bool isCopyableString(mlir::Type type) if (auto unionType = mlir::dyn_cast(type)) { - return llvm::any_of(unionType.getTypes(), isText) && - llvm::all_of(unionType.getTypes(), [&](mlir::Type member) { - return isText(member) || mlir::isa(member); - }); + if (!llvm::any_of(unionType.getTypes(), isText) || + !llvm::all_of(unionType.getTypes(), [&](mlir::Type member) { + return isText(member) || mlir::isa(member); + })) + { + return false; + } + + // only a union that lowers to one pointer: a tagged union is a struct + MLIRTypeHelper mth(type.getContext(), CompileOptions{}); + return !mth.isUnionTypeNeedsTag(mlir::UnknownLoc::get(type.getContext()), unionType); } return isText(type); From 9d0ec2dc63e20fafe1943264b7ff10154bdec589 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Fri, 2 Oct 2026 22:47:21 +0100 Subject: [PATCH 05/16] -mm=own strings: the inference pass's dry run (spec 22.3), no change yet Co-Authored-By: Claude Opus 5.5 --- .../lib/TypeScript/OwnershipInferencePass.cpp | 140 +++++++++++++++--- 1 file changed, 118 insertions(+), 22 deletions(-) diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index 4476dc0b0..0c5b13ee6 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -71,6 +71,30 @@ class OwnershipInferencePass } void runOnFunction() + { + // the dry run (spec 22.3): does today's analysis pass as it is? + { + llvm::SetVector toErase; + dryRun = true; + failures = 0; + auto passed = analyze(toErase); + dryRun = false; + for (auto &[op, name] : dryRunAttrs) + { + op->removeAttr(name); + } + + dryRunAttrs.clear(); + (void)passed; // Task 3 makes the copies here when it did not + } + + llvm::SetVector toErase; + analyze(toErase); + } + + // The analysis of one function; true when it passed. In a dry run it erases nothing and strips + // no facts. + bool analyze(llvm::SetVector &toErase) { auto f = getFunction(); mlir::DominanceInfo dominanceInfo(f); @@ -93,6 +117,7 @@ class OwnershipInferencePass fieldBorrowReleases.clear(); captureStores.clear(); captureRetains.clear(); + returnedParams.clear(); returnsBorrowOf = resultBorrows(f); // first: whether a closure borrows what it captures changes what its box's stores are @@ -125,7 +150,7 @@ class OwnershipInferencePass else if (mlir::isa(op)) { // a box taking a cell: a closure that borrows it (decideClosures) claimed it - if (!captureRetains.contains(op)) + if (!captureRetains.contains(op) && reporting()) { op->emitError("closures that capture a variable are not supported by -mm=own here yet"); signalPassFailure(); @@ -133,8 +158,11 @@ class OwnershipInferencePass } else if (mlir::isa(op)) { - op->emitError("'delete' is not supported by -mm=own yet"); - signalPassFailure(); + if (reporting()) + { + op->emitError("'delete' is not supported by -mm=own yet"); + signalPassFailure(); + } } else if (auto loadOp = mlir::dyn_cast(op)) { @@ -181,7 +209,6 @@ class OwnershipInferencePass // two consuming declarations without a single retain (the result of an indirect call // taken by `let b = a; let c = a;`). Only a fresh value's retains can be erased. llvm::DenseSet proven; - llvm::SetVector toErase; for (auto value : candidates) { if (checkValueMoves(value, toErase) && isFresh(value)) @@ -341,6 +368,11 @@ class OwnershipInferencePass checkBoundedResults(); toErase.insert(fieldBorrowReleases.begin(), fieldBorrowReleases.end()); + if (dryRun) + { + return failures == 0; + } + for (auto *op : toErase) { op->erase(); @@ -357,6 +389,8 @@ class OwnershipInferencePass op->removeAttr(name); } }); + + return true; } private: @@ -373,6 +407,43 @@ class OwnershipInferencePass // While non-zero, verdicts are tried without reporting: the next verdict gets its turn first. unsigned quiet = 0; + // The dry run (spec 22.3): the whole analysis, but nothing reported and nothing changed. A + // report it would make is counted in `failures`. + bool dryRun = false; + unsigned failures = 0; + // attributes the dry run set on ops that did not have them, taken off again after it + llvm::SmallVector> dryRunAttrs; + + // Whether a report is to be emitted: not under `quietly`'s trial, and not in the dry run, which + // only counts it. + bool reporting() + { + if (quiet) + { + return false; + } + + if (dryRun) + { + ++failures; + return false; + } + + return true; + } + + // `op->setAttr(name, unit)`, undone after the dry run when the op did not have it + void setAttrTracked(mlir::Operation *op, llvm::StringRef name) + { + auto attrName = mlir::StringAttr::get(&getContext(), name); + if (dryRun && !op->hasAttr(attrName)) + { + dryRunAttrs.push_back({op, attrName}); + } + + op->setAttr(attrName, mlir::UnitAttr::get(&getContext())); + } + // The parameter this function's result borrows (`__own_result_borrows`), or -1; and the // parameters (or views of them) whose rc reference for the caller was already decided. int returnsBorrowOf = -1; @@ -525,14 +596,14 @@ class OwnershipInferencePass if (!closure->escape && !isCell) { - storeOp->setAttr(OWN_CAPTURE_BORROW_ATTR_NAME, mlir::UnitAttr::get(&getContext())); + setAttrTracked(storeOp, OWN_CAPTURE_BORROW_ATTR_NAME); captureStores[storeOp] = closure.get(); } } if (!closure->escape) { - boundOp->setAttr(OWN_BORROWS_CAPTURES_ATTR_NAME, mlir::UnitAttr::get(&getContext())); + setAttrTracked(boundOp, OWN_BORROWS_CAPTURES_ATTR_NAME); } closureOf[boundOp.getOperation()] = closure.get(); @@ -596,7 +667,7 @@ class OwnershipInferencePass getFunction().walk([&](mlir::Operation *op) { if (auto storeOp = mlir::dyn_cast(op); storeOp && isBorrowingField(storeOp.getReference())) { - storeOp->setAttr(OWN_CAPTURE_BORROW_ATTR_NAME, mlir::UnitAttr::get(&getContext())); + setAttrTracked(storeOp, OWN_CAPTURE_BORROW_ATTR_NAME); if (auto *retain = retainBefore(storeOp.getValue(), storeOp)) { fieldBorrowRetains.insert(retain); @@ -992,7 +1063,7 @@ class OwnershipInferencePass { if (!mlir::isa(fill.second.getValue().getType())) { - fill.second->setAttr(OWN_CAPTURE_BORROW_ATTR_NAME, mlir::UnitAttr::get(&getContext())); + setAttrTracked(fill.second, OWN_CAPTURE_BORROW_ATTR_NAME); } } } @@ -1534,7 +1605,7 @@ class OwnershipInferencePass { if (mlir::isa(user)) { - user->setAttr(OWN_CELL_BORROWS_ATTR_NAME, mlir::UnitAttr::get(&getContext())); + setAttrTracked(user, OWN_CELL_BORROWS_ATTR_NAME); continue; } @@ -3777,7 +3848,7 @@ class OwnershipInferencePass // 21.2); anything else, the catch-all. void reportSecondReference(mlir::Operation *op, mlir::Value value = {}) { - if (quiet) + if (!reporting()) { return; } @@ -3998,7 +4069,7 @@ class OwnershipInferencePass void reportKeptOnSomePaths(mlir::Operation *move, mlir::Operation *exit, llvm::StringRef name) { - if (quiet) + if (!reporting()) { return; } @@ -4012,7 +4083,7 @@ class OwnershipInferencePass void reportGivenNotOwned(mlir::Operation *call, mlir::Value arg) { - if (quiet) + if (!reporting()) { return; } @@ -4036,7 +4107,7 @@ class OwnershipInferencePass void reportUseAfterMove(mlir::Operation *use, mlir::Operation *move, llvm::StringRef name) { - if (quiet) + if (!reporting()) { return; } @@ -4048,7 +4119,7 @@ class OwnershipInferencePass void reportMovedInLoop(mlir::Operation *move, llvm::StringRef name) { - if (quiet) + if (!reporting()) { return; } @@ -4061,7 +4132,7 @@ class OwnershipInferencePass void reportBorrowOutlives(mlir::Operation *use, mlir::Operation *end, llvm::StringRef name, llvm::StringRef owner) { - if (quiet) + if (!reporting()) { return; } @@ -4075,7 +4146,7 @@ class OwnershipInferencePass void reportPlaceBorrowOutlives(mlir::Operation *use, mlir::Operation *drop, llvm::StringRef name, llvm::StringRef place) { - if (quiet) + if (!reporting()) { return; } @@ -4088,7 +4159,7 @@ class OwnershipInferencePass void reportPlaceBorrowerAssigned(mlir::Operation *op, llvm::StringRef name, llvm::StringRef place) { - if (quiet) + if (!reporting()) { return; } @@ -4099,7 +4170,7 @@ class OwnershipInferencePass void reportPlaceBorrowEscapes(mlir::Operation *op, llvm::StringRef name, llvm::StringRef place) { - if (quiet) + if (!reporting()) { return; } @@ -4111,7 +4182,7 @@ class OwnershipInferencePass void reportMovedWhileBorrowed(mlir::Operation *op, llvm::StringRef owner, llvm::StringRef borrower) { - if (quiet) + if (!reporting()) { return; } @@ -4123,7 +4194,7 @@ class OwnershipInferencePass void reportBorrowEscapes(mlir::Operation *op, llvm::StringRef name, llvm::StringRef owner) { - if (quiet) + if (!reporting()) { return; } @@ -4135,7 +4206,7 @@ class OwnershipInferencePass void reportBorrowerAssigned(mlir::Operation *op, llvm::StringRef name, llvm::StringRef owner) { - if (quiet) + if (!reporting()) { return; } @@ -4146,6 +4217,11 @@ class OwnershipInferencePass void reportEscapingCellNotOwned(mlir::Operation *capture, mlir::Operation *escape, llvm::StringRef why) { + if (!reporting()) + { + return; + } + auto diag = capture->emitError("a closure that escapes owns what it captures, but -mm=own cannot move this " "variable into it: ") << why; @@ -4155,6 +4231,11 @@ class OwnershipInferencePass void reportUsedAfterEscapingCapture(mlir::Operation *use, mlir::Operation *capture, llvm::StringRef name) { + if (!reporting()) + { + return; + } + auto diag = use->emitError("'") << name << "' is captured by a closure that escapes, and is still used here"; diag.attachNote(capture->getLoc()) << "the closure takes it here"; signalPassFailure(); @@ -4163,6 +4244,11 @@ class OwnershipInferencePass void reportClosureOutlives(mlir::Operation *use, mlir::Operation *end, llvm::StringRef closure, llvm::StringRef captured) { + if (!reporting()) + { + return; + } + auto diag = use->emitError("'") << closure << "' borrows '" << captured << "', which it captures, but runs here after '" << captured << "' is released"; diag.attachNote(end->getLoc()) << "'" << captured << "' is released here"; @@ -4171,12 +4257,22 @@ class OwnershipInferencePass void reportCaptureNotOwned(mlir::Operation *op, llvm::StringRef captured) { + if (!reporting()) + { + return; + } + op->emitError("'") << captured << "' is captured by a closure, but -mm=own cannot tell who owns its value here"; signalPassFailure(); } void reportCapturedParamAssigned(mlir::Operation *op, llvm::StringRef name) { + if (!reporting()) + { + return; + } + op->emitError("'") << name << "' is a captured parameter and cannot be assigned under -mm=own: its value is the " "caller's"; @@ -4185,7 +4281,7 @@ class OwnershipInferencePass void reportMovedOnSomePaths(mlir::Operation *move, mlir::Operation *release, llvm::StringRef name) { - if (quiet) + if (!reporting()) { return; } From 4ee1180796033cc01c4842bb18701b705d17cff9 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Fri, 2 Oct 2026 23:09:43 +0100 Subject: [PATCH 06/16] -mm=own strings: a second reference to a string own cannot prove becomes a copy (spec 22.3, 22.4) After a failing dry run, each string retain it did not erase is rewritten into a ts.StringCopy for its one use (a `let` starts from a copy), unless the value is a parameter whose callers were told this function keeps it or returns a borrow of it. The real run then decides the function. Beyond the plan's code: - ts.StringCopy is a borrow of its operand (isBorrow), and walkBorrowed does not follow its result: the copy is a block of its own. - The copy is made at rc's retain when the use's operand is already there, not in front of the use: a store releases what it overwrites first, which may be the very string (`b.s = b.s`) or one the place check cannot tell from it (`b.s = a.s`, same field of another object). Tests that differ from the plan: - own_string_copy_field: no caught object (catch variables are not tested); `textOf` returns a field of an object it makes, since a field of its parameter is a borrow its callers rely on (`__own_result_borrows`, spec 22.4). - own_string_copy_nullable: no `keepOpt`: passing a field's string to a callee known to keep it is the kept-parameter negative's shape (spec 22.4, 22.6). - own_err_string_kept_param_known: `keep` uses its kept parameter after storing it, so the retain survives the dry run and the guard decides it; message "is used here after its value was moved". The plan's version (main passing a field's string) has no retain to copy at all. - own_err_string_call_keeps_twice: "borrows a field and cannot be stored, returned or captured". - own_err_string_consumed_twice dropped: it compiles before this change, and every indirect-call, push and store variant tried had rc's retains. - own_err_mixed_return, own_err_param_returned_exported, own_err_param_kept_exported and own_err_merged_result use a class now; own_err_unbox_string is deleted. Co-Authored-By: Claude Opus 5.5 --- .../lib/TypeScript/OwnershipInferencePass.cpp | 109 +++++++++++++++++- tslang/test/tester/CMakeLists.txt | 20 +++- .../test/tester/own/own_err_merged_result.ts | 11 +- .../test/tester/own/own_err_mixed_return.ts | 17 ++- .../tester/own/own_err_param_kept_exported.ts | 16 ++- .../own/own_err_param_returned_exported.ts | 8 +- .../own/own_err_string_call_keeps_twice.ts | 20 ++++ .../own/own_err_string_kept_param_known.ts | 18 +++ .../test/tester/own/own_err_unbox_string.ts | 9 -- .../tester/own/own_string_copy_after_move.ts | 22 ++++ .../tester/own/own_string_copy_exported.ts | 24 ++++ .../test/tester/own/own_string_copy_field.ts | 41 +++++++ .../tester/own/own_string_copy_nullable.ts | 36 ++++++ .../tester/own/own_string_copy_written.ts | 16 +++ 14 files changed, 334 insertions(+), 33 deletions(-) create mode 100644 tslang/test/tester/own/own_err_string_call_keeps_twice.ts create mode 100644 tslang/test/tester/own/own_err_string_kept_param_known.ts delete mode 100644 tslang/test/tester/own/own_err_unbox_string.ts create mode 100644 tslang/test/tester/own/own_string_copy_after_move.ts create mode 100644 tslang/test/tester/own/own_string_copy_exported.ts create mode 100644 tslang/test/tester/own/own_string_copy_field.ts create mode 100644 tslang/test/tester/own/own_string_copy_nullable.ts create mode 100644 tslang/test/tester/own/own_string_copy_written.ts diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index 0c5b13ee6..5d34b26c5 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -85,7 +85,10 @@ class OwnershipInferencePass } dryRunAttrs.clear(); - (void)passed; // Task 3 makes the copies here when it did not + if (!passed) + { + makeStringCopies(toErase); + } } llvm::SetVector toErase; @@ -444,6 +447,102 @@ class OwnershipInferencePass op->setAttr(attrName, mlir::UnitAttr::get(&getContext())); } + // Strings as values (spec 22.3): each string retain the dry run did not erase is rewritten, where + // its one use can be found, into a copy for that use. True when anything was rewritten. + bool makeStringCopies(const llvm::SetVector &erased) + { + llvm::SmallVector retains; + getFunction()->walk([&](mlir::Operation *op) { + if (mlir::isa(op) && !erased.contains(op) && + !captureRetains.contains(op) && !fieldBorrowRetains.contains(op)) + { + retains.push_back(op); + } + }); + + auto copied = false; + for (auto *retain : retains) + { + copied = copyForRetain(retain) || copied; + } + + return copied; + } + + // The value a parameter of this function is, when its callers were told this function keeps it + // or returns a borrow of it (spec 15): copying it would leak what the caller gave up. + bool borrowsKnownParam(mlir::Value value) + { + auto param = borrowedParam(value); + return param >= 0 && (llvm::is_contained(ownedParams(getFunction()), param) || param == returnsBorrowOf); + } + + bool copyForRetain(mlir::Operation *retain) + { + // `let x = v`: the local starts from a copy + if (auto retainSlotOp = mlir::dyn_cast(retain)) + { + auto varOp = retainSlotOp.getSlot().getDefiningOp(); + auto init = varOp ? varOp.getInitializer() : mlir::Value(); + if (!init || !isCopyableString(init.getType()) || borrowsKnownParam(init) || isCellVariable(varOp.getResult())) + { + return false; + } + + mlir::OpBuilder builder(varOp); + auto copy = builder.create(varOp.getLoc(), init.getType(), init); + varOp.getInitializerMutable().assign(copy); + retain->erase(); + return true; + } + + auto retainOp = mlir::cast(retain); + auto value = retainOp.getReference(); + if (!isCopyableString(value.getType()) || borrowsKnownParam(value)) + { + return false; + } + + // the one use rc retained it for + mlir::OpOperand *taking = nullptr; + auto several = false; + forEachUse(value, [&](mlir::Operation *user, mlir::Value used) { + if (mlir::isa(user) || isBorrow(user, used)) + { + return; + } + + for (auto &operand : user->getOpOperands()) + { + if (operand.get() == used) + { + several = several || taking; + taking = &operand; + } + } + }); + + if (!taking || several || !isCopyableString(taking->get().getType())) + { + return false; + } + + // made where rc took its reference: a store releases what it overwrites first, and that may + // be the very string (`b.s = b.s`), or one the analysis cannot tell from it (`b.s = a.s`) + auto *at = taking->getOwner(); + auto *def = taking->get().getDefiningOp(); + if (taking->get() == value || (def && def->getBlock() == retain->getBlock() && def->isBeforeInBlock(retain))) + { + at = retain; + } + + mlir::OpBuilder builder(at); + auto copy = builder.create(retain->getLoc(), taking->get().getType(), taking->get()); + taking->set(copy); + retain->erase(); + return true; + } + // The parameter this function's result borrows (`__own_result_borrows`), or -1; and the // parameters (or views of them) whose rc reference for the caller was already decided. int returnsBorrowOf = -1; @@ -2124,6 +2223,12 @@ class OwnershipInferencePass } } + // a copy (spec 22.2) reads the borrow into a block of its own, which points into nothing + if (mlir::isa(user)) + { + continue; + } + // A result that borrows what it was given borrows this too, and is walked as it. for (auto result : user->getResults()) { @@ -3138,7 +3243,7 @@ class OwnershipInferencePass mlir_ts::ThisSymbolRefOp, mlir_ts::VirtualSymbolRefOp, mlir_ts::ThisVirtualSymbolRefOp, mlir_ts::InterfaceSymbolRefOp, mlir_ts::GetThisOp, mlir_ts::GetMethodOp, mlir_ts::ArithmeticBinaryOp, mlir_ts::LogicalBinaryOp, mlir_ts::StringConcatOp, - mlir_ts::StringResizeOp, mlir_ts::StringLengthOp>(user)) + mlir_ts::StringResizeOp, mlir_ts::StringLengthOp, mlir_ts::StringCopyOp>(user)) { return true; } diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index e1b4ab240..2c886b84e 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2572,7 +2572,9 @@ foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local o own_closure_borrow own_closure_escape own_generator own_interface_view own_async own_record_return own_generator_borrow own_global_script own_global_init own_constant_data own_record_local - own_nullable_view own_declared_constant own_spread_build own_interface_field_call) + own_nullable_view own_declared_constant own_spread_build own_interface_field_call + own_string_copy_field own_string_copy_after_move own_string_copy_nullable + own_string_copy_exported own_string_copy_written) tslang_add_test(NAME test-jit-own-${own_test} COMMAND test-runner -jit -mm=own "${PROJECT_SOURCE_DIR}/test/tester/own/${own_test}.ts") tslang_add_test(NAME test-compile-own-${own_test} COMMAND test-runner -mm=own "${PROJECT_SOURCE_DIR}/test/tester/own/${own_test}.ts") # The promise: no counting at all in what own emits. @@ -2590,6 +2592,15 @@ foreach(global_mm rc none gc) tslang_add_test(NAME test-compile-${global_mm}-own-global-script COMMAND test-runner -mm=${global_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/own_global_script.ts") endforeach() +# Strings as values (spec 22): no model but own makes a copy, and each program means the same +# under every model. +foreach(strings_mm rc none gc) + foreach(strings_test own_string_copy_field own_string_copy_after_move own_string_copy_nullable own_string_copy_exported) + tslang_add_test(NAME test-jit-${strings_mm}-${strings_test} COMMAND test-runner -jit -mm=${strings_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${strings_test}.ts") + tslang_add_test(NAME test-compile-${strings_mm}-${strings_test} COMMAND test-runner -mm=${strings_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${strings_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). @@ -2700,8 +2711,7 @@ set(own_error_cases "own_err_param_kept_loop|is moved inside a loop" "own_err_param_kept_not_owned|is given to 'keep', which keeps it, but -mm=own cannot move it here" "own_err_delete|'delete' is not supported by -mm=own yet" - "own_err_mixed_return|'greet' returns a borrow of its argument on some paths and another value on others" - "own_err_unbox_string|'___unbox' returns a borrow of its argument on some paths and another value on others" + "own_err_mixed_return|'pick' returns a borrow of its argument on some paths and another value on others" "own_err_param_returned_exported|is a parameter, returned here, but -mm=own cannot tell the callers of 'identity' that the result borrows it" "own_err_param_kept_exported|is a parameter, kept here, but -mm=own cannot make the callers of 'Animal.constructor' give it up" "own_err_param_assigned|is a parameter and cannot be assigned under -mm=own yet" @@ -2710,7 +2720,9 @@ set(own_error_cases "own_err_global_returned|borrows the global 'V.def' and cannot be stored, returned or captured" "own_err_global_let_call|borrows the global 'names', but -mm=own cannot prove the global keeps its value" "own_err_captured_returned|borrows a captured variable and cannot be stored, returned or captured" - "own_err_record_literal_given|an object literal that holds a value it owns is moved only into a local or returned as it is") + "own_err_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") 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_merged_result.ts b/tslang/test/tester/own/own_err_merged_result.ts index 44ee10df6..dc3fb7084 100644 --- a/tslang/test/tester/own/own_err_merged_result.ts +++ b/tslang/test/tester/own/own_err_merged_result.ts @@ -1,8 +1,11 @@ // -mm=own rejects: the result of a `?:` is either branch's value, and nothing says which one -// owns it. -function max(a: string, b: string) { - return a > b ? a : b; +// owns it. (A string there is copied instead, spec 22.) +class C { + constructor(public n: string) {} +} +function max(a: C, b: C) { + return a.n > b.n ? a : b; } function main() { - print(max("a" + 1, "b" + 2)); + print(max(new C("a"), new C("b")).n); } diff --git a/tslang/test/tester/own/own_err_mixed_return.ts b/tslang/test/tester/own/own_err_mixed_return.ts index 9d78247f6..3f67733f4 100644 --- a/tslang/test/tester/own/own_err_mixed_return.ts +++ b/tslang/test/tester/own/own_err_mixed_return.ts @@ -1,9 +1,14 @@ -// -mm=own rejects: `greet` returns its argument on one path and a new string on the other, so -// its callers cannot be told whether they own the result. -function greet(name: string) { - if (name === "Honda") return name; - return "Sorry, " + name; +// -mm=own rejects: `pick` returns its argument on one path and a new object on the other, so +// its callers cannot be told whether they own the result. (A string there is copied instead, +// spec 22.) +class C { + constructor(public n: string) {} +} +function pick(c: C, other: boolean) { + if (!other) return c; + return new C("new"); } function main() { - print(greet("Honda")); + const c = new C("a"); + print(pick(c, true).n); } diff --git a/tslang/test/tester/own/own_err_param_kept_exported.ts b/tslang/test/tester/own/own_err_param_kept_exported.ts index 58f45ee49..9d71693da 100644 --- a/tslang/test/tester/own/own_err_param_kept_exported.ts +++ b/tslang/test/tester/own/own_err_param_kept_exported.ts @@ -1,12 +1,16 @@ // -mm=own rejects: the constructor keeps its parameter, which its callers would have to give up, -// but an exported class can be built from another module, which does not. +// but an exported class can be built from another module, which does not. (A string there is copied +// instead, spec 22.) +class C { + constructor(public n: string) {} +} export class Animal { - name: string; - constructor(name: string) { - this.name = name; + c: C; + constructor(c: C) { + this.c = c; } } function main() { - const a = new Animal("cat" + 1); - print(a.name); + const a = new Animal(new C("cat")); + print(a.c.n); } diff --git a/tslang/test/tester/own/own_err_param_returned_exported.ts b/tslang/test/tester/own/own_err_param_returned_exported.ts index c50e155eb..2c444ca9e 100644 --- a/tslang/test/tester/own/own_err_param_returned_exported.ts +++ b/tslang/test/tester/own/own_err_param_returned_exported.ts @@ -1,8 +1,12 @@ // -mm=own rejects: `identity` returns its parameter, a result its callers would have to know // borrows the argument, but an exported function can be called from another module, which does not. -export function identity(x: string) { +// (A string there is copied instead, spec 22.) +class C { + constructor(public n: string) {} +} +export function identity(x: C) { return x; } function main() { - print(identity("x")); + print(identity(new C("x")).n); } diff --git a/tslang/test/tester/own/own_err_string_call_keeps_twice.ts b/tslang/test/tester/own/own_err_string_call_keeps_twice.ts new file mode 100644 index 000000000..9477c1777 --- /dev/null +++ b/tslang/test/tester/own/own_err_string_call_keeps_twice.ts @@ -0,0 +1,20 @@ +// -mm=own rejects, and does not copy: one string given twice to a call that keeps both arguments. +// The retains have no single use each, so no copy is made. +class P { + a = ""; + b = ""; +} + +const p = new P(); + +function both(x: string, y: string) { + p.a = x; + p.b = y; +} + +function main() { + const h = new P(); + h.a = "s" + 1; + both(h.a, h.a); + print(p.a, p.b); +} diff --git a/tslang/test/tester/own/own_err_string_kept_param_known.ts b/tslang/test/tester/own/own_err_string_kept_param_known.ts new file mode 100644 index 000000000..27401c5b8 --- /dev/null +++ b/tslang/test/tester/own/own_err_string_kept_param_known.ts @@ -0,0 +1,18 @@ +// -mm=own rejects, and does not copy: `keep` is private and called only directly, so the +// signature pass tells its callers it keeps its parameter (`__own_params`) and they give theirs up. +// `keep` uses it after storing it; a copy for the store would leak the one the caller gave. +class H { + s = ""; +} + +const holder = new H(); + +function keep(v: string) { + holder.s = v; + print(v); +} + +function main() { + keep("x" + 1); + print(holder.s); +} diff --git a/tslang/test/tester/own/own_err_unbox_string.ts b/tslang/test/tester/own/own_err_unbox_string.ts deleted file mode 100644 index 92cd97dd1..000000000 --- a/tslang/test/tester/own/own_err_unbox_string.ts +++ /dev/null @@ -1,9 +0,0 @@ -// -mm=own rejects: `` of an `any` is the box's own string when it holds one, and a new -// string made from its number, boolean or bigint otherwise - a borrow on some paths and a value of -// its own on others. -function main() { - const s = "value"; - const a = s; - const back = a; - print(back); -} diff --git a/tslang/test/tester/own/own_string_copy_after_move.ts b/tslang/test/tester/own/own_string_copy_after_move.ts new file mode 100644 index 000000000..287c9f33c --- /dev/null +++ b/tslang/test/tester/own/own_string_copy_after_move.ts @@ -0,0 +1,22 @@ +// A string moved into a field and used after: the field takes a copy, the local keeps its own. +class H { + text = ""; +} + +function churn() { + let keep: string[] = []; + for (let i = 0; i < 1000; i++) keep.push("k" + i); + return keep.length; +} + +function main() { + const h = new H(); + let kept = "kept" + 1; + h.text = kept; + print(kept); + h.text = "other" + 2; + assert(churn() == 1000); + assert(kept == "kept1" && h.text == "other2"); + + print("done."); +} diff --git a/tslang/test/tester/own/own_string_copy_exported.ts b/tslang/test/tester/own/own_string_copy_exported.ts new file mode 100644 index 000000000..3794587b8 --- /dev/null +++ b/tslang/test/tester/own/own_string_copy_exported.ts @@ -0,0 +1,24 @@ +// An exported class's constructor keeps its string parameter. Its callers cannot be told so (it can +// be called from another module), so they keep their own, and the field takes a copy. +export class Animal { + name: string; + constructor(name: string) { + this.name = name; + } +} + +function churn() { + let keep: string[] = []; + for (let i = 0; i < 1000; i++) keep.push("k" + i); + return keep.length; +} + +function main() { + let n = "cat" + 1; + const a = new Animal(n); + n = "dog" + 2; + assert(churn() == 1000); + assert(a.name == "cat1" && n == "dog2"); + + print("done."); +} diff --git a/tslang/test/tester/own/own_string_copy_field.ts b/tslang/test/tester/own/own_string_copy_field.ts new file mode 100644 index 000000000..13bb7aa0e --- /dev/null +++ b/tslang/test/tester/own/own_string_copy_field.ts @@ -0,0 +1,41 @@ +// A field's string given a second owner is copied (spec 22): stored into another field, returned, +// and pushed. Each original is then overwritten, or its owner released, and its memory reused before +// the copy is read. +class Rec { + str = ""; + str2 = ""; +} + +function churn() { + let keep: string[] = []; + for (let i = 0; i < 1000; i++) keep.push("k" + i); + return keep.length; +} + +// returns a field of an object it releases: not a borrow of a parameter (spec 22.4) +function textOf(n: number) { + const made = new Rec(); + made.str2 = "made" + n; + return made.str2; +} + +function main() { + const r = new Rec(); + r.str2 = "two" + 2; + r.str = r.str2; + r.str2 = "other" + 3; + assert(churn() == 1000); + assert(r.str == "two2" && r.str2 == "other3"); + + const got = textOf(4); + assert(churn() == 1000); + assert(got == "made4"); + + const list: string[] = []; + list.push(r.str2); + r.str2 = "last" + 5; + assert(churn() == 1000); + assert(list[0] == "other3"); + + print("done."); +} diff --git a/tslang/test/tester/own/own_string_copy_nullable.ts b/tslang/test/tester/own/own_string_copy_nullable.ts new file mode 100644 index 000000000..9d1741aa0 --- /dev/null +++ b/tslang/test/tester/own/own_string_copy_nullable.ts @@ -0,0 +1,36 @@ +// Nullable strings copied: a `string | null` holding a string and holding null, and an optional +// (`string | undefined`) holding a string and holding nothing - whose pointer must not be read. +class Box { + s: string | null = null; + o: string | undefined = undefined; +} + +function churn() { + let keep: string[] = []; + for (let i = 0; i < 1000; i++) keep.push("k" + i); + return keep.length; +} + +function main() { + const a = new Box(); + const b = new Box(); + a.s = "nn" + 1; + b.s = a.s; + a.s = null; + assert(churn() == 1000); + assert(b.s == "nn1"); + + b.s = a.s; + assert(b.s == null); + + a.o = "opt" + 2; + b.o = a.o; + a.o = undefined; + assert(churn() == 1000); + assert(b.o == "opt2"); + + b.o = a.o; + assert(b.o === undefined); + + print("done."); +} diff --git a/tslang/test/tester/own/own_string_copy_written.ts b/tslang/test/tester/own/own_string_copy_written.ts new file mode 100644 index 000000000..14428213f --- /dev/null +++ b/tslang/test/tester/own/own_string_copy_written.ts @@ -0,0 +1,16 @@ +// A write into a copy's bytes changes the copy only (spec 22.1): strings are values under own. +class R { + s = ""; + t = ""; +} + +function main() { + const r = new R(); + r.s = "abc" + ""; + r.t = r.s; + r.t[0] = 65; + assert(r.t == "Abc", "the copy was written"); + assert(r.s == "abc", "the original was not"); + + print("done."); +} From edcee2ab305614fe9e98a3a6836ec03fc477c2c2 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Fri, 2 Oct 2026 23:32:47 +0100 Subject: [PATCH 07/16] -mm=own strings: a copy only for a retain in its use's block; the pushed-twice negative A copy made at rc's birth retain, right after the producer, had no owner on a path that does not reach its use in another block (`if (c) h.a = s;`): a leak. A retain is now copied only when its use is in the same block; rc's retain for the store beside it makes the copy there, and the birth retain goes in the real run once the value is proven. A store in a loop of a string made outside it now copies on each pass, the old copy released by the store. - own_string_copy_after_move: a store on one branch only, and one in a loop. - own_err_field_loop: a class now; the string there is copied. - own_err_string_pushed_twice: `list.push(s, s)`, two takers for one retain: no copy. - own_err_string_call_keeps_twice: the header says why there is no copy (no retain in `main`). - makeStringCopies returns nothing; copyForRetain says how its walk differs from takerOf's. Co-Authored-By: Claude Opus 5.5 --- .../lib/TypeScript/OwnershipInferencePass.cpp | 22 ++++++++++---- tslang/test/tester/CMakeLists.txt | 3 +- tslang/test/tester/own/own_err_field_loop.ts | 12 ++++---- .../own/own_err_string_call_keeps_twice.ts | 5 ++-- .../tester/own/own_err_string_pushed_twice.ts | 8 +++++ .../tester/own/own_string_copy_after_move.ts | 29 ++++++++++++++++++- 6 files changed, 64 insertions(+), 15 deletions(-) create mode 100644 tslang/test/tester/own/own_err_string_pushed_twice.ts diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index 5d34b26c5..d60123382 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -448,8 +448,8 @@ class OwnershipInferencePass } // Strings as values (spec 22.3): each string retain the dry run did not erase is rewritten, where - // its one use can be found, into a copy for that use. True when anything was rewritten. - bool makeStringCopies(const llvm::SetVector &erased) + // its one use can be found, into a copy for that use. + void makeStringCopies(const llvm::SetVector &erased) { llvm::SmallVector retains; getFunction()->walk([&](mlir::Operation *op) { @@ -460,13 +460,10 @@ class OwnershipInferencePass } }); - auto copied = false; for (auto *retain : retains) { - copied = copyForRetain(retain) || copied; + copyForRetain(retain); } - - return copied; } // The value a parameter of this function is, when its callers were told this function keeps it @@ -477,6 +474,11 @@ class OwnershipInferencePass return param >= 0 && (llvm::is_contained(ownedParams(getFunction()), param) || param == returnsBorrowOf); } + // Rewrites one string retain into a copy for the use it was made for (spec 22.3), and erases it; + // false, leaving it for the real run to report, where there is no single such use in its block, + // or the value is not a string or is a known parameter's (spec 22.4). Its use walk is takerOf's + // without the twinReadAfter fallback: a retain whose value has no use of its own is not copied, + // so its error stays. bool copyForRetain(mlir::Operation *retain) { // `let x = v`: the local starts from a copy @@ -527,6 +529,14 @@ class OwnershipInferencePass return false; } + // only a retain in the use's block: a copy made elsewhere (rc's birth retain, after the + // producer) has no owner on a path that does not reach the use; rc retains for the use + // beside it too + if (taking->getOwner()->getBlock() != retain->getBlock()) + { + return false; + } + // made where rc took its reference: a store releases what it overwrites first, and that may // be the very string (`b.s = b.s`), or one the analysis cannot tell from it (`b.s = a.s`) auto *at = taking->getOwner(); diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 2c886b84e..2a322a2ee 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2722,7 +2722,8 @@ set(own_error_cases "own_err_captured_returned|borrows a captured variable and cannot be stored, returned or captured" "own_err_record_literal_given|an object literal that holds a value it owns is moved only into a local or returned as it is" "own_err_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_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") foreach(own_error_case ${own_error_cases}) string(FIND "${own_error_case}" "|" own_error_sep) string(SUBSTRING "${own_error_case}" 0 ${own_error_sep} own_error_name) diff --git a/tslang/test/tester/own/own_err_field_loop.ts b/tslang/test/tester/own/own_err_field_loop.ts index e89053ce5..7bb5960ca 100644 --- a/tslang/test/tester/own/own_err_field_loop.ts +++ b/tslang/test/tester/own/own_err_field_loop.ts @@ -1,13 +1,15 @@ -// -mm=own, phase 0 rejects: a field store inside a loop of a string made outside it. -class N { v: number[] = []; s: string = ""; } +// -mm=own, phase 0 rejects: a field store inside a loop of an object made outside it. (A string +// there is copied instead, spec 22.) +class C { constructor(public x: number) {} } +class N { v: number[] = []; c: C | null = null; } function main() { let k: number = 3; const n = new N(); - const s = "a" + k; + const c = new C(k); for (let i = 0; i < 3; i++) { - n.s = s; + n.c = c; const filler = "zzzzzzzz" + i; - print(n.s, filler); + print(n.c!.x, filler); } print("done."); } diff --git a/tslang/test/tester/own/own_err_string_call_keeps_twice.ts b/tslang/test/tester/own/own_err_string_call_keeps_twice.ts index 9477c1777..50ee9dcea 100644 --- a/tslang/test/tester/own/own_err_string_call_keeps_twice.ts +++ b/tslang/test/tester/own/own_err_string_call_keeps_twice.ts @@ -1,5 +1,6 @@ -// -mm=own rejects, and does not copy: one string given twice to a call that keeps both arguments. -// The retains have no single use each, so no copy is made. +// -mm=own rejects, and does not copy: a field's string given twice to a call that keeps both +// arguments. A keeping call's retains are in the callee, so `main` has no retain to turn into a +// copy: the call takes the value without one (spec 22.4). class P { a = ""; b = ""; diff --git a/tslang/test/tester/own/own_err_string_pushed_twice.ts b/tslang/test/tester/own/own_err_string_pushed_twice.ts new file mode 100644 index 000000000..fdb855b3a --- /dev/null +++ b/tslang/test/tester/own/own_err_string_pushed_twice.ts @@ -0,0 +1,8 @@ +// -mm=own rejects, and does not copy: one string pushed twice in one push. rc's retains for it +// have no single use each, so no copy is made. +function main() { + const s = "s" + 1; + const list: string[] = []; + list.push(s, s); + print(list[0], s); +} diff --git a/tslang/test/tester/own/own_string_copy_after_move.ts b/tslang/test/tester/own/own_string_copy_after_move.ts index 287c9f33c..5d9e527e1 100644 --- a/tslang/test/tester/own/own_string_copy_after_move.ts +++ b/tslang/test/tester/own/own_string_copy_after_move.ts @@ -1,4 +1,5 @@ -// A string moved into a field and used after: the field takes a copy, the local keeps its own. +// A string moved into a field and used after: the field takes a copy, the local keeps its own - +// also where the store is on one branch only, or in a loop: the copy is made beside the store. class H { text = ""; } @@ -9,6 +10,26 @@ function churn() { return keep.length; } +function stored(c: boolean) { + const h = new H(); + const s = "some" + 3; + if (c) { + h.text = s; + } + print(s); + return h; +} + +function storedInLoop() { + const h = new H(); + const s = "loop" + 4; + for (let i = 0; i < 3; i++) { + h.text = s; + } + print(s); + return h; +} + function main() { const h = new H(); let kept = "kept" + 1; @@ -18,5 +39,11 @@ function main() { assert(churn() == 1000); assert(kept == "kept1" && h.text == "other2"); + const both = stored(true); + const neither = stored(false); + const looped = storedInLoop(); + assert(churn() == 1000); + assert(both.text == "some3" && neither.text == "" && looped.text == "loop4"); + print("done."); } From efd252b1515a58e6d55e7884be90b2beabfa8da7 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 3 Oct 2026 00:01:05 +0100 Subject: [PATCH 08/16] -mm=own strings: mixed returns, corpus and results (spec 22.7) own_string_copy_mixed_return: a function that returns its argument on one path and a new string on another, `` of an `any` and of a `number | string`. Its eight borrowed returns are copied; main rejects it with eight errors. No new code was needed. It runs under own, rc, none and gc. Corpus, every file of test/tester/tests under own: 417 -> 453 of 593 plain, 416 -> 451 with --opt, none lost. No error is new: each error the branch reports, plain, --opt and --di, main reports too. Of the 35 gained under --opt, 30 pass test-runner -mm=own AOT and JIT, default and -noopt; the other 5 are halves of four import/export pairs, three of which pass as the suite runs them. import_type_alias is still rejected by an error main has too (an object literal re-made into its annotated type, with no retain to copy). The 417 files that compile on main give the same LLVM IR from both binaries, up to names that change between runs. A loop copying a field's string stays at 4.4 MB at 20000 and 200000 iterations under own, as under rc. - spec 22.7: what was built, where the copy goes, the corpus, gates, teeth, measures, the IR check, and the shapes outside the rule; the 21.2 table notes own_err_unbox_string's deletion. - own_err_string_pushed_twice: its header says it pins a conservative rule. - plan: boxes ticked, but Linux and the PR. Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-02-own-strings-as-values.md | 54 ++--- .../2026-09-24-own-memory-model-design.md | 206 +++++++++++++++++- tslang/test/tester/CMakeLists.txt | 5 +- .../tester/own/own_err_string_pushed_twice.ts | 6 +- .../own/own_string_copy_mixed_return.ts | 34 +++ 5 files changed, 273 insertions(+), 32 deletions(-) create mode 100644 tslang/test/tester/own/own_string_copy_mixed_return.ts diff --git a/tslang/docs/superpowers/plans/2026-10-02-own-strings-as-values.md b/tslang/docs/superpowers/plans/2026-10-02-own-strings-as-values.md index 960e85122..143eed6c7 100644 --- a/tslang/docs/superpowers/plans/2026-10-02-own-strings-as-values.md +++ b/tslang/docs/superpowers/plans/2026-10-02-own-strings-as-values.md @@ -97,7 +97,7 @@ background. - `inline bool isCopyableString(mlir::Type type)` in `OwnershipFacts.h`; - `isFresh(value)` true for a `StringCopyOp` result. -- [ ] **Step 1: Add the op.** In `TypeScriptOps.td`, after `TypeScript_StringConcatOp`: +- [x] **Step 1: Add the op.** In `TypeScriptOps.td`, after `TypeScript_StringConcatOp`: ```tablegen // A copy of a string: a new block holding the same text, null when `in` is null (or, for an @@ -110,11 +110,11 @@ def TypeScript_StringCopyOp : TypeScript_Op<"StringCopy", [AllTypesMatch<["in", } ``` -- [ ] **Step 2: Mark it legal for the affine lowering.** In `LowerToAffineLoops.cpp`, in the +- [x] **Step 2: Mark it legal for the affine lowering.** In `LowerToAffineLoops.cpp`, in the `target.addLegalOp<...>` list, change `mlir_ts::StringConcatOp,mlir_ts::StringCompareOp` to `mlir_ts::StringConcatOp, mlir_ts::StringCopyOp, mlir_ts::StringCompareOp`. -- [ ] **Step 3: Write the LLVM lowering.** In `LowerToLLVM.cpp`, after `StringConcatOpLowering`: +- [x] **Step 3: Write the LLVM lowering.** In `LowerToLLVM.cpp`, after `StringConcatOpLowering`: ```cpp // A copy of a string: `strlen + 1` bytes, from the allocator `ts.StringConcat` uses. A null string @@ -183,7 +183,7 @@ class StringCopyOpLowering : public TsLlvmPattern Register it: in the pattern list (the line with `StringConcatOpLowering,`), add `StringCopyOpLowering,` after it. -- [ ] **Step 4: Teach the facts.** In `OwnershipFacts.h`, `isFresh`: add `mlir_ts::StringCopyOp` +- [x] **Step 4: Teach the facts.** In `OwnershipFacts.h`, `isFresh`: add `mlir_ts::StringCopyOp` to the `mlir::isa` list. After `holdsNoBlock`, add: @@ -224,7 +224,7 @@ inline bool isCopyableString(mlir::Type type) `MLIRTypeHelper::isUnionTypeNeedsTag` answers false (the LLVM type is `ptr`; see the `@n` and `@w` functions in §21's checks). If some string union does need a tag, add that condition here. -- [ ] **Step 5: Correct the spec.** In §22.1, change "Those are one pointer, the same as `string` +- [x] **Step 5: Correct the spec.** In §22.1, change "Those are one pointer, the same as `string` (the nullable views of §21.3)" to: ``` @@ -235,11 +235,11 @@ optional parameter) is `{ptr, i1}`, the pointer meaningful only when the flag is In §22.2, change "A nullable string is copied through its pointer." to "An optional is copied only when it holds a value." -- [ ] **Step 6: Build, and confirm nothing else changed.** Build (Global Constraints). Run +- [x] **Step 6: Build, and confirm nothing else changed.** Build (Global Constraints). Run `ctest -C Release -R "own" -j 8 --timeout 300` and expect 100% passed. Nothing creates the op yet, so this is a no-op change for every program. -- [ ] **Step 7: Commit.** +- [x] **Step 7: Commit.** ```bash git add tslang/include/TypeScript/TypeScriptOps.td tslang/lib/TypeScript/LowerToLLVM.cpp tslang/lib/TypeScript/LowerToAffineLoops.cpp tslang/lib/TypeScript/OwnershipFacts.h tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md @@ -264,7 +264,7 @@ git commit -m "-mm=own strings: ts.StringCopy and the facts that know it (spec 2 function passed. In a dry run it erases nothing and strips no facts; otherwise it does both, as `runOnFunction` does today. -- [ ] **Step 1: Record the baseline.** Build, then, in the scratchpad, save the first error of +- [x] **Step 1: Record the baseline.** Build, then, in the scratchpad, save the first error of every corpus file (Appendix A, `run.sh`) as `base.tsv`, plain and with `OPTS="--opt --opt_level=3"` as `baseopt.tsv`. Also save the full stderr of three negative tests: @@ -273,7 +273,7 @@ T=__build/tslang/windows-msbuild-2026-release/bin/tslang.exe for f in own_err_two_owners own_err_closure_escape_twice own_err_delete; do $T --emit=obj --no-default-lib -mm=own tslang/test/tester/own/$f.ts -o NUL > $SCRATCH/$f.before.txt 2>&1; done ``` -- [ ] **Step 2: Add the reporting switch.** Add the members beside `unsigned quiet = 0;`: +- [x] **Step 2: Add the reporting switch.** Add the members beside `unsigned quiet = 0;`: ```cpp // The dry run (spec 22.3): the whole analysis, but nothing reported and nothing changed. A @@ -314,7 +314,7 @@ for f in own_err_two_owners own_err_closure_escape_twice own_err_delete; do $T - } ``` -- [ ] **Step 3: Route every report through it.** +- [x] **Step 3: Route every report through it.** - In every report function (`grep -n "if (quiet)" OwnershipInferencePass.cpp`, about 12 of them), replace ```cpp @@ -343,12 +343,12 @@ for f in own_err_two_owners own_err_closure_escape_twice own_err_delete; do $T - - Every `signalPassFailure()` must now sit behind a `reporting()` that returned true. Confirm with `grep -n "signalPassFailure" OwnershipInferencePass.cpp` and read each one. -- [ ] **Step 4: Track the attributes.** Replace each `x->setAttr(NAME, mlir::UnitAttr::get(&getContext()))` +- [x] **Step 4: Track the attributes.** Replace each `x->setAttr(NAME, mlir::UnitAttr::get(&getContext()))` in the pass with `setAttrTracked(x, NAME)` (`grep -n "setAttr(" OwnershipInferencePass.cpp`: the `OWN_CAPTURE_BORROW_ATTR_NAME`, `OWN_BORROWS_CAPTURES_ATTR_NAME` and `OWN_CELL_BORROWS_ATTR_NAME` sites). -- [ ] **Step 5: Split `runOnFunction` into `analyze`.** +- [x] **Step 5: Split `runOnFunction` into `analyze`.** - Move the body of `runOnFunction` into `bool analyze(llvm::SetVector &toErase)`, with `toErase` declared by the caller, not inside. - Its last part becomes: @@ -398,7 +398,7 @@ for f in own_err_two_owners own_err_closure_escape_twice own_err_delete; do $T - } ``` -- [ ] **Step 6: Confirm nothing changed.** +- [x] **Step 6: Confirm nothing changed.** - Build. Run `ctest -C Release -R "own|mlirgen-matches" -j 8 --timeout 300`: expect 100%. - Rerun the corpus (`run.sh`) plain and `--opt`, and `diff` against `base.tsv`/`baseopt.tsv`, ignoring the generated `FH` hashes (`sed -E 's/FH[0-9]+/FH/g'` on both). Expect no @@ -406,7 +406,7 @@ for f in own_err_two_owners own_err_closure_escape_twice own_err_delete; do $T - - Rerun Step 1's three negatives and `diff` their stderr against the `.before.txt` files. They must be identical: each error once, with the same notes. -- [ ] **Step 7: Commit.** +- [x] **Step 7: Commit.** ```bash git add tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -436,7 +436,7 @@ git commit -m "-mm=own strings: the inference pass's dry run (spec 22.3), no cha - Produces: `bool makeStringCopies(const llvm::SetVector &erased)`, which returns whether it rewrote anything. -- [ ] **Step 1: Write the positive tests.** Each frees the original, or overwrites its owner, and +- [x] **Step 1: Write the positive tests.** Each frees the original, or overwrites its owner, and allocates over it with `churn()` before reading the copy. A missing copy then reads freed memory. `own_string_copy_field.ts`: @@ -624,7 +624,7 @@ function main() { } ``` -- [ ] **Step 2: Write the negative tests.** +- [x] **Step 2: Write the negative tests.** `own_err_string_kept_param_known.ts`: @@ -705,7 +705,7 @@ function main() { The negative for a value that is not a string is the existing class negatives (`own_err_two_owners`, `own_err_push_let`, ...). Step 5's full `own` run keeps them passing. -- [ ] **Step 3: Register the tests and watch them fail.** In `CMakeLists.txt`: +- [x] **Step 3: Register the tests and watch them fail.** In `CMakeLists.txt`: - add `own_string_copy_field own_string_copy_after_move own_string_copy_nullable own_string_copy_exported own_string_copy_written` to the `foreach(own_test ...)` list; - add the three negatives to `own_error_cases` with the regexes from Step 2; @@ -726,7 +726,7 @@ endforeach() `-own-` positive fails (own rejects them today), the rc/none/gc ones pass, and the negatives pass. If an rc/none/gc run fails, that is a bug outside this phase. Stop and report it. -- [ ] **Step 4: Write the rewrite.** In `OwnershipInferencePass.cpp`, add: +- [x] **Step 4: Write the rewrite.** In `OwnershipInferencePass.cpp`, add: ```cpp // Strings as values (spec 22.3): each string retain the dry run did not erase is rewritten, where @@ -833,7 +833,7 @@ endforeach() This needs `captureRetains` and `fieldBorrowRetains` as the dry run left them. They are members and are cleared only at the start of `analyze`, so they still hold the dry run's sets here. -- [ ] **Step 5: Run the tests.** Build. Run +- [x] **Step 5: Run the tests.** Build. Run `ctest -C Release -R "own_string_copy|own_err_string|own" -j 8 --timeout 300`. Expected: all pass. - If a positive still fails, read its error: - one named in §22.4 means that shape is outside the rule; adjust the test only if the spec @@ -843,7 +843,7 @@ endforeach() - Also run each positive under own with the runner's `-noopt` (`--di --opt_level=0`): `test-runner -noopt -mm=own ` and `test-runner -noopt -jit -mm=own `. -- [ ] **Step 6: Teeth.** Each change is temporary, made after this task's code is committed or +- [x] **Step 6: Teeth.** Each change is temporary, made after this task's code is committed or backed up. - (a) `makeStringCopies` returns false at its top: every `own_string_copy_*` positive must fail. - (b) `borrowsKnownParam` returns false: `own_err_string_kept_param_known` must compile, or @@ -852,7 +852,7 @@ endforeach() - Revert both. Clear `tslang/test/tester/own/__jit` and the build's `own/__jit` between JIT runs (the JIT cache does not key on such a switch). -- [ ] **Step 7: The string negatives that now compile.** +- [x] **Step 7: The string negatives that now compile.** - Run `ctest -C Release -R "test-own-err" -j 8`. Phase 6's named-message negatives used strings in some cases: `own_err_mixed_return`, `own_err_unbox_string`, `own_err_param_returned_exported`, `own_err_param_kept_exported`, `own_err_merged_result`, and @@ -876,7 +876,7 @@ endforeach() Task 4's mixed-return test cover the copy now. - Rerun until `test-own-err` is 100%. -- [ ] **Step 8: Commit.** +- [x] **Step 8: Commit.** ```bash git add tslang/lib/TypeScript/OwnershipInferencePass.cpp tslang/test/tester/CMakeLists.txt tslang/test/tester/own/ @@ -898,7 +898,7 @@ git commit -m "-mm=own strings: a second reference to a string own cannot prove - Consumes: Task 3's rewrite (no new code is expected here; if the mixed-return shape needs one, it goes into `copyForRetain` with its own test). -- [ ] **Step 1: Write the test.** `own_string_copy_mixed_return.ts`: +- [x] **Step 1: Write the test.** `own_string_copy_mixed_return.ts`: ```ts // A function that returns a borrow of its argument on one path and a new string on another: the @@ -942,7 +942,7 @@ function main() { had no single use (Task 3, Step 5), fix `copyForRetain` with a test-first change, and record the shape in §22.7. -- [ ] **Step 2: Corpus.** Rerun `run.sh` plain and `--opt` (Appendix A). Then: +- [x] **Step 2: Corpus.** Rerun `run.sh` plain and `--opt` (Appendix A). Then: - **None lost:** `join -t $'\t' base.tsv new.tsv | awk -F'\t' '$2=="ok" && $3!="ok"'` prints nothing, plain and `--opt`. - **Gate every gained file:** list the files ok under `--opt` now and not in `baseopt.tsv`, then @@ -951,7 +951,7 @@ function main() { - Record the first-error histogram before and after (`cut -f2 | sed -E "s/'[^']*'/'X'/g" | sort | uniq -c | sort -rn`). -- [ ] **Step 3: Unchanged where it compiles.** Before building this branch's final binary, copy +- [x] **Step 3: Unchanged where it compiles.** Before building this branch's final binary, copy main's `tslang.exe` aside (build main once, or reuse a saved one: `tslang-main.exe`). - For every file ok in `base.tsv`, compare `--emit=llvm -mm=own --no-default-lib -o .ll` from both binaries, with @@ -959,7 +959,7 @@ function main() { - Expect no difference. Any difference means a function that compiled on main took the copy path, or the dry run changed something. Find it and fix it. -- [ ] **Step 4: Measure.** Write `$SCRATCH/copy_loop.ts`: +- [x] **Step 4: Measure.** Write `$SCRATCH/copy_loop.ts`: ```ts class R { @@ -986,7 +986,7 @@ function main() { Record both rows in §22.7. -- [ ] **Step 5: Spec results.** Append §22.7 "Results" to the spec, in the style of §20/§21: +- [x] **Step 5: Spec results.** Append §22.7 "Results" to the spec, in the style of §20/§21: - what was built; - the corpus table, before and after, plain and `--opt`; - the gated files; 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 4b41a0649..bd297a682 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 @@ -1612,7 +1612,7 @@ place, so all four places that report it get the same names. Each message has a | shape | message | test | |---|---|---| -| a function returns a borrow of a parameter on one path and something else on another | `'f' returns a borrow of its argument on some paths and another value on others; -mm=own cannot tell its callers whether they own the result`, with a note at the other return | `own_err_mixed_return`, `own_err_unbox_string` | +| a function returns a borrow of a parameter on one path and something else on another | `'f' returns a borrow of its argument on some paths and another value on others; -mm=own cannot tell its callers whether they own the result`, with a note at the other return | `own_err_mixed_return`; `own_err_unbox_string` until §22, which copies the string and deleted it | | a parameter's value, returned, where the callers cannot learn the fact (§15) | `'x' is a parameter, returned here, but -mm=own cannot tell the callers of 'f' that the result borrows it`, with §15's facts-lost note | `own_err_param_returned_exported` | | a parameter's value, kept (stored, pushed, given away) | `'x' is a parameter, kept here, but -mm=own cannot make the callers of 'f' give it up`, with the note | `own_err_param_kept_exported` | | a store into a parameter's slot | `'x' is a parameter and cannot be assigned under -mm=own yet` | `own_err_param_assigned` | @@ -1826,3 +1826,207 @@ The error stays where a copy would be wrong: every corpus file that compiles on main. - **Corpus:** none lost, plain and with `--opt`. Each gained file passes test-runner under own, AOT and JIT, with the default flags and with `-noopt`. + +### 22.7 Results, 2026-10-02 + +Plan: `docs/superpowers/plans/2026-10-02-own-strings-as-values.md`. Changed: `TypeScriptOps.td`, +`LowerToAffineLoops.cpp`, `LowerToLLVM.cpp` (the op, lowered under every model), `OwnershipFacts.h`, +`OwnershipInferencePass.cpp`. MLIRGen and the signature pass do not change, so rc, gc and none +emit what they did. + +#### What was built + +- **`ts.StringCopy`** (§22.2) and its lowering: + - null stays null, and an optional is copied only when its flag is set; + - otherwise the length plus one byte, from the allocator `ts.StringConcat` uses, and a `memcpy`; + - an operand that is not a pointer must be exactly `{ptr, i1}`, or the pattern does not match. +- **`isCopyableString`** (`OwnershipFacts.h`) is §22.1's string: `string`, a string literal type, an + optional of one, or a union of one with only `null` or `undefined` beside it that needs no tag. + Measured under gc: `string | null` and `"a" | "b" | null` are a `ptr`, `string | undefined` is + `{ptr, i1}`, and `string | number` is tagged and not covered. +- **The dry run** (§22.3). `analyze` runs once quietly. Every report goes through `reporting()`, + which counts a failure instead of emitting it, the `RetainCell` and `delete` errors included. The + attributes are noted, not set, and nothing is erased or stripped. On the corpus it changed + nothing, plain and with `--opt`. +- **The copies** (`makeStringCopies`, `copyForRetain`, `borrowsKnownParam`). After a failing dry + run, each string retain it did not erase is rewritten: + - a `ts.Retain` whose one use is in the retain's block: the use takes `ts.StringCopy(v)`; + - a `ts.RetainSlot`: the local starts from the copy; + - no copy for: no single use in the retain's block, one operand taken twice (`several`), not a + string, or a parameter whose callers were told it is kept or borrowed. +- **Two facts about the copy**, both needed by the positives: + - It is a borrow of its operand (`isBorrow`, beside `StringConcat` and `StringResize`). Without + it, the place check saw a copy of a field as keeping the field. + - The borrow walk (`walkBorrowed`) stops at it: its result points into nothing. Without it, in + `b.s = a.s` the store of the copy counted as a use of `a.s` after `ReleaseSlot(b.s)`. The two + fields have the same position and type, so `dropsChain` cannot tell them apart. + +#### Where the copy goes (§22.3 as built) + +- **At rc's retain, not in front of the use,** when the use's operand is the retained value, or is + defined before the retain in its block. + - A store releases what it overwrites before it stores: in `b.s = b.s`, a copy in front of the + store would read freed memory. + - A return comes after rc releases the locals: in `return made.str2`, the copy would read the + field of a released object. + - With the copy in front of the use, the place check rejects both rather than miscompiling + (teeth (c)). +- **Only for a use in the retain's block.** rc's birth retain sits right after the producer, which + may be in another block. Copied there, the copy has no owner on a path that does not reach the + use (`if (c) { h.a = s; }`): a leak. rc's retain beside the store makes the copy in the store's + block instead, and the real run erases the birth retain once the value is proven. +- **A loop makes a copy per pass.** A string made outside a loop and stored inside it compiles with + a copy in the loop body. Each store's `ReleaseSlot` frees the previous copy, the field owns the + last one, and the original is released after the loop. `own_err_field_loop`, that shape with a + string, compiles and runs now; it uses a class instead, and its message is unchanged. + +#### Corpus + +`--emit=llvm -mm=own --no-default-lib`, every file of `test/tester/tests`, against main, plain and +with the runner's `--opt --opt_level=3`: + +| | main | this branch | gained | lost | +|---|---|---|---|---| +| plain | 417 / 593 | 453 / 593 | 36 | 0 | +| `--opt` | 416 / 593 | 451 / 593 | 35 | 0 | + +Two of the files gained compile plain only: +- `00interface_object3`, §21.6; +- `05strings`: under `--opt`, CSE merges the two `"X" + true` (one printed, one compared) into one + fresh string taken twice, and the catch-all reports it. A three-line program of that shape fails + the same way on main. + +The first errors, plain (`--opt` in brackets where it differs): + +| first error | main | this branch | +|---|---|---| +| `takes a second reference` (the catch-all) | 25 (27) | 19 (22) | +| used after its value was moved | 23 (25) | 24 (25) | +| borrows a field and cannot be stored, returned or captured | 20 | 11 | +| returns a borrow of its argument on some paths and another value on others | 17 | 1 | +| borrows an element and cannot be stored, returned or captured | 10 | 3 | +| borrows a field but is used here after it may be released or overwritten | 6 | 13 | +| borrows an element but is used here after it may be released or overwritten | 0 | 2 | +| merged from several branches | 8 (7) | 5 (4) | +| borrows the global and cannot be stored, returned or captured | 8 | 7 | +| the rest | 59 (57) | 55 (54) | + +- **The mixed-return bucket, 17 → 1.** The one left is `conditionalTypes2`'s + `___cast, hybrid_func>`, which returns a function value, not a string + (§22.4). +- **The use-after-release buckets, 6 → 15,** are files whose earlier error is gone. +- **No error is new.** For every corpus file, plain, with `--opt` and with `--di`, each error this + branch reports is one main reports too. The whole error list was compared, not only the first. + +#### Gated + +The 35 files gained under `--opt`: +- **30 run alone.** Each passes test-runner `-mm=own`, AOT and JIT, with the default flags and with + `-noopt`. +- **5 are halves of four import/export pairs:** `export_class_extends`, `export_class_indexer`, + `export_owned_returns` with `import_owned_returns`, and `export_type_alias`. Each pair was run + the way the suite runs it, under own: AOT, `-shared` AOT, and `-shared` JIT. A JIT pair without + `-shared` fails under gc too. + - `class_extends`, `class_indexer`, `owned_returns`: pass, with the default flags and `-noopt`. + - `type_alias`: the export half compiles, but the importer is rejected at + `M.labelOf({ label: "l" })` with "borrows a field and cannot be stored, returned or captured". + Main reports the same error behind a `___cast` mixed return. The literal is re-made into + `M.Options` by a `ts.CreateTuple` of its field's read, with no retain, so there is nothing to + copy (§22.4). + - The export half's own copy (`return o.label ?? "none"` in an exported function, whose facts + are lost) was gated with a one-module program holding the same function: it passes under + own, AOT and JIT, with the default flags and with `-noopt`. + +#### Tests and teeth + +- **Positive.** Each runs under own (AOT, JIT, and no counting) and also under rc, none and gc, AOT + and JIT; each passes under own with `-noopt`: + - `own_string_copy_field`: a field into a field, a field returned, a field pushed; + - `own_string_copy_after_move`: a string moved into a field and used after, stored on one branch + only, and stored in a loop; + - `own_string_copy_nullable`: four copies (`string | null` holding a pointer and null, an optional + holding a value and none), so the lowering's three paths run; + - `own_string_copy_exported`: an exported constructor storing its string parameter; + - `own_string_copy_mixed_return`: `greet` returning `name` on one path and `"Sorry, " + name` on + the other, `` of an `any` (`___unbox`), and `` of a `number | string` + (`___cast`). Its own IR has eight copies; main rejects it with eight errors. It needed no new + code. +- **Own only:** `own_string_copy_written`, `let t = s; t[0] = 65` changes `t` only. +- **Negative:** + +| test | message | +|---|---| +| `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 | + +- **Teeth**, each a temporary switch, with the JIT cache cleared: + +| switch | result | +|---|---| +| (a) `makeStringCopies` returns at once | the 15 own runs of the five positives then written fail | +| (b) `borrowsKnownParam` returns false | `own_err_string_kept_param_known` compiles, and `keep` never releases the `v` its callers gave up: the leak the guard is for | +| (c) the copy always in front of the use | `own_string_copy_field` and `own_string_copy_nullable` fail (6 tests); `r.s = r.s` is rejected by the place check | +| (d) the `several` guard off | `own_err_string_pushed_twice` compiles, and is correct: each retain copies a different operand | + +- **Step 7 of the plan.** Five string negatives compiled once strings were copied: + - `own_err_mixed_return`, `own_err_param_returned_exported`, `own_err_param_kept_exported` and + `own_err_merged_result` use a class now, with their messages unchanged (`pick` for the first); + - `own_err_unbox_string` was deleted (§21.2's table says so). + +#### Measured + +AOT, `measure.ps1`, `r.s = "v" + i; r.t = r.s` in a loop, in MB: + +| iterations | gc | rc | none | own | +|---|---|---|---|---| +| 20000 | 5.9 | 4.4 | 6.6 | 4.4 | +| 200000 | 5.9 | 4.4 | 25.9 | 4.4 | + +Main rejects the program under own (`r.t = r.s`, "borrows a field"). + +#### Unchanged where it compiles + +The 417 files that compile on main were compiled with main's binary and this branch's, +`--emit=llvm -mm=own --no-default-lib`. §22.6 named `--emit=mlir-affine`; the plan's step and this +check use the LLVM IR, which is what runs. The output is identical once the names that change from +run to run are normalised. Main against main needed the same normalisations: +- the `FH` hashes; +- pointer-derived suffixes (`@a_`, `@td__number`, `@tsrel_`, `...vtbl`); +- the sizes of the strings that hold an `FH` name. + +One file, `00union_null_undefined_nonstrict`, can differ in the order of a union's members in a +synthesized `___cast>` name. The order flips between runs of either binary (main gave +one order in 9 runs of 12, this branch in 10 of 12). With the same order, the IR is identical. + +#### Outside the rule + +- **A returned field of a parameter.** `function textOf(r) { return r.str2; }` stays a borrowed + return (`__own_result_borrows`), and a caller that uses the result after overwriting `r.str2` is + still rejected (§22.4). The accepted returned field is one of an object the function owns, of a + global, or of an exported function's parameter (facts lost). +- **The guard's negative.** `borrowsKnownParam` matters only when a known parameter's retain + survives the dry run, as in `keep(v) { holder.s = v; print(v); }`. The plan's version, `main` + passing `h.s` to `keep`, never reaches the guard: `main` has no retain to copy. +- **A field's string given to a known keeper** (`keep(h.s)`) is that shape: no retain in the caller, + so it stays an error. +- **No consumed-twice shape.** The plan's `own_err_string_consumed_twice` compiles: the const lambda + becomes a direct call, and the two locals are views of the const. A parameter called, a field + holding a function, and an indirect result pushed twice and stored twice all had rc's retains. + The test was dropped; `own_err_string_call_keeps_twice` covers a taker with no retain in the + caller. +- **An omitted optional argument** is a `ts.Undef : !ts.optional`. Given to a callee + that keeps its parameter, it is reported "not owned", because `holdsNoBlock` does not cover it. + This predates the phase. +- **One string taken twice by one op** (`list.push(s, s)`) is rejected, by the `several` guard. With + the guard off it compiles and is correct, so the rule is conservative there; widened, the + negative becomes a positive. +- **A use whose operand is a view defined after the retain** gets its copy in front of the use. A + same-position, same-type `ReleaseSlot` in between would make the place check reject the program: + a false error, not a miscompile. No test has it. +- **An object literal re-made into its annotated type** (a field missing from the literal) reads + the literal's fields into a `ts.CreateTuple` with no retain, so a string there is not copied + (`import_type_alias`). +- **A fresh string merged by CSE** under `--opt` (`05strings`): the two `"X" + true` become one + value with two retains and two releases, for a print and a comparison. Neither retain has a taker + to copy for, so the catch-all reports it, on main too. diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 2a322a2ee..85bd2e624 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2574,7 +2574,7 @@ foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local o own_global_init own_constant_data own_record_local own_nullable_view own_declared_constant own_spread_build own_interface_field_call own_string_copy_field own_string_copy_after_move own_string_copy_nullable - own_string_copy_exported own_string_copy_written) + own_string_copy_exported own_string_copy_written own_string_copy_mixed_return) tslang_add_test(NAME test-jit-own-${own_test} COMMAND test-runner -jit -mm=own "${PROJECT_SOURCE_DIR}/test/tester/own/${own_test}.ts") tslang_add_test(NAME test-compile-own-${own_test} COMMAND test-runner -mm=own "${PROJECT_SOURCE_DIR}/test/tester/own/${own_test}.ts") # The promise: no counting at all in what own emits. @@ -2595,7 +2595,8 @@ endforeach() # Strings as values (spec 22): no model but own makes a copy, and each program means the same # under every model. foreach(strings_mm rc none gc) - foreach(strings_test own_string_copy_field own_string_copy_after_move own_string_copy_nullable own_string_copy_exported) + foreach(strings_test own_string_copy_field own_string_copy_after_move own_string_copy_nullable own_string_copy_exported + own_string_copy_mixed_return) tslang_add_test(NAME test-jit-${strings_mm}-${strings_test} COMMAND test-runner -jit -mm=${strings_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${strings_test}.ts") tslang_add_test(NAME test-compile-${strings_mm}-${strings_test} COMMAND test-runner -mm=${strings_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${strings_test}.ts") endforeach() diff --git a/tslang/test/tester/own/own_err_string_pushed_twice.ts b/tslang/test/tester/own/own_err_string_pushed_twice.ts index fdb855b3a..e17ee4407 100644 --- a/tslang/test/tester/own/own_err_string_pushed_twice.ts +++ b/tslang/test/tester/own/own_err_string_pushed_twice.ts @@ -1,5 +1,7 @@ -// -mm=own rejects, and does not copy: one string pushed twice in one push. rc's retains for it -// have no single use each, so no copy is made. +// Pins a conservative rule: one string pushed twice in one push is rejected under -mm=own, and +// not copied, because rc's two retains for it have no single use each (the `several` guard of +// copyForRetain). With the guard off, the double push compiles and is correct: each retain +// copies a different operand. If the rule is widened, this should become a positive. function main() { const s = "s" + 1; const list: string[] = []; diff --git a/tslang/test/tester/own/own_string_copy_mixed_return.ts b/tslang/test/tester/own/own_string_copy_mixed_return.ts new file mode 100644 index 000000000..087fad852 --- /dev/null +++ b/tslang/test/tester/own/own_string_copy_mixed_return.ts @@ -0,0 +1,34 @@ +// A function that returns a borrow of its argument on one path and a new string on another: the +// borrowed return is copied, so the result is always the caller's (spec 22.5). Also the +// synthesized `` of an `any`, and a union cast to a string. +function greet(name: string) { + if (name === "Honda") return name; + return "Sorry, " + name; +} + +function churn() { + let keep: string[] = []; + for (let i = 0; i < 1000; i++) keep.push("k" + i); + return keep.length; +} + +function main() { + let who = "Hon" + "da"; + const g = greet(who); + who = "x" + 1; + assert(churn() == 1000); + assert(g == "Honda"); + assert(greet("Bob") == "Sorry, Bob"); + + const a = ("boxed" + 1); + const back = a; + assert(back == "boxed1"); + + let u: number | string = "un" + 1; + const s = u; + u = 3; + assert(churn() == 1000); + assert(s == "un1"); + + print("done."); +} From 09c87471890779edcdf3d9078b757e2b0ef63774 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 3 Oct 2026 00:04:11 +0100 Subject: [PATCH 09/16] -mm=own strings: the Linux run (spec 22.7) GCC in WSL, at efd252b1: a clean build; own-err, own-no-counting and own-verify 140/140, and the string-copy positives 48/48. The plan's last two boxes are ticked; the push and the PR are left to the user. Co-Authored-By: Claude Opus 5.5 --- .../superpowers/plans/2026-10-02-own-strings-as-values.md | 4 ++-- .../superpowers/specs/2026-09-24-own-memory-model-design.md | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tslang/docs/superpowers/plans/2026-10-02-own-strings-as-values.md b/tslang/docs/superpowers/plans/2026-10-02-own-strings-as-values.md index 143eed6c7..6c2adf52c 100644 --- a/tslang/docs/superpowers/plans/2026-10-02-own-strings-as-values.md +++ b/tslang/docs/superpowers/plans/2026-10-02-own-strings-as-values.md @@ -997,12 +997,12 @@ function main() { Tick this plan's boxes. -- [ ] **Step 6: Linux.** Build the branch with GCC in WSL and run +- [x] **Step 6: Linux.** Build the branch with GCC in WSL and run `ctest -R "own-err|own-no-counting|own-verify"` there (recipe: the WSL clone at `~/ts/TypeScriptCompiler`, build dir `__build/tslang/ninja/release`, fetch this branch from the Windows repo). -- [ ] **Step 7: Commit, and open the PR.** +- [x] **Step 7: Commit, and open the PR.** ```bash git add tslang/test/tester/own/own_string_copy_mixed_return.ts tslang/test/tester/CMakeLists.txt tslang/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 bd297a682..b8e5747ea 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 @@ -1999,6 +1999,12 @@ One file, `00union_null_undefined_nonstrict`, can differ in the order of a union synthesized `___cast>` name. The order flips between runs of either binary (main gave one order in 9 runs of 12, this branch in 10 of 12). With the same order, the IR is identical. +#### Linux + +GCC, in WSL, the branch at `efd252b1`: the build is clean, and +`ctest -R "own-err|own-no-counting|own-verify"` passes 140 of 140. `ctest -R own_string_copy` (the +positives under own, rc, none and gc, AOT and JIT) passes 48 of 48. + #### Outside the rule - **A returned field of a parameter.** `function textOf(r) { return r.str2; }` stays a borrowed From a5fc16251dc8e6aace6d731c4341687b815b9218 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 3 Oct 2026 00:39:57 +0100 Subject: [PATCH 10/16] -mm=own strings: the copies see the dry run's borrow attributes The dry run's attributes were taken off before makeStringCopies, so a store into a borrowing closure's box (or a borrowing field) lost its __own_capture_borrow and counted as a second taker: no copy, and a false "used here after its value was moved". They now come off after the copies, before the real run. The copies erase only retains, which carry none of them. own_string_copy_after_move gains the shape: a closure that captures a let (so borrows all it captures) and a string, the string then stored into a field and used after. Spec 22.3 and 22.7 now say the attributes are set, tracked and taken off after the copies; 22.7 also records why a closure that owns its copies plus a field store stays an error. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 21 +++++++++++++---- .../lib/TypeScript/OwnershipInferencePass.cpp | 13 +++++++---- .../tester/own/own_string_copy_after_move.ts | 23 +++++++++++++++++++ 3 files changed, 48 insertions(+), 9 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 b8e5747ea..341641324 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 @@ -1769,7 +1769,8 @@ The inference pass decides each function in up to two runs. 1. **A dry run.** The whole analysis runs, but it reports nothing and changes nothing: - nothing is erased; - the signature facts are not stripped; - - the attributes the analysis sets are noted, not set; + - the attributes the analysis sets are set and tracked, and taken off again after the copies + (step 2), before the real run; - every error, including those that do not go through the `quiet` reporters today (the `RetainCell` and `delete` errors), is counted as a failure, not emitted. @@ -1845,9 +1846,12 @@ emit what they did. Measured under gc: `string | null` and `"a" | "b" | null` are a `ptr`, `string | undefined` is `{ptr, i1}`, and `string | number` is tagged and not covered. - **The dry run** (§22.3). `analyze` runs once quietly. Every report goes through `reporting()`, - which counts a failure instead of emitting it, the `RetainCell` and `delete` errors included. The - attributes are noted, not set, and nothing is erased or stripped. On the corpus it changed - nothing, plain and with `--opt`. + which counts a failure instead of emitting it, the `RetainCell` and `delete` errors included. + Nothing is erased or stripped. The attributes it sets (`setAttrTracked`) are tracked and taken + off after the copies, before the real run: a store into a borrowing closure's box or into a + borrowing field is a borrow only by its `__own_capture_borrow`, and the copies must see it. The + copies erase only retains, which carry none of them. On the corpus the dry run changed nothing, + plain and with `--opt`. - **The copies** (`makeStringCopies`, `copyForRetain`, `borrowsKnownParam`). After a failing dry run, each string retain it did not erase is rewritten: - a `ts.Retain` whose one use is in the retain's block: the use takes `ts.StringCopy(v)`; @@ -1943,7 +1947,10 @@ The 35 files gained under `--opt`: and JIT; each passes under own with `-noopt`: - `own_string_copy_field`: a field into a field, a field returned, a field pushed; - `own_string_copy_after_move`: a string moved into a field and used after, stored on one branch - only, and stored in a loop; + only, stored in a loop, and stored beside a closure that borrows it (the closure also captures + a `let`, so it borrows all it captures). Before the dry run's attributes were kept for the + copies, the store into the closure's box counted as a second taker, and that last shape was + rejected; - `own_string_copy_nullable`: four copies (`string | null` holding a pointer and null, an optional holding a value and none), so the lowering's three paths run; - `own_string_copy_exported`: an exported constructor storing its string parameter; @@ -2027,6 +2034,10 @@ positives under own, rc, none and gc, AOT and JIT) passes 48 of 48. - **One string taken twice by one op** (`list.push(s, s)`) is rejected, by the `several` guard. With the guard off it compiles and is correct, so the rule is conservative there; widened, the negative becomes a positive. +- **A string captured by a closure that owns its copies, and also stored** (`const f = () => + s.length; r.t = s; print(s)`). A closure that captures only copies, and is not given to a maker + whose result is bounded, owns them (phase 4): the store into its box is a taker, so the string + has two, and the `several` guard keeps the error. - **A use whose operand is a view defined after the retain** gets its copy in front of the use. A same-position, same-type `ReleaseSlot` in between would make the place check reject the program: a false error, not a miscompile. No test has it. diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index d60123382..dc9726a30 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -79,16 +79,21 @@ class OwnershipInferencePass failures = 0; auto passed = analyze(toErase); dryRun = false; + // The copies are judged with the dry run's facts still set: a store into a borrowing + // closure's box, or a borrowing field's, is a borrow only by its + // `__own_capture_borrow`. They are taken off after, before the real run sets its own. + // makeStringCopies erases only retains, which the dry run sets no attribute on. + if (!passed) + { + makeStringCopies(toErase); + } + for (auto &[op, name] : dryRunAttrs) { op->removeAttr(name); } dryRunAttrs.clear(); - if (!passed) - { - makeStringCopies(toErase); - } } llvm::SetVector toErase; diff --git a/tslang/test/tester/own/own_string_copy_after_move.ts b/tslang/test/tester/own/own_string_copy_after_move.ts index 5d9e527e1..b2a65c1af 100644 --- a/tslang/test/tester/own/own_string_copy_after_move.ts +++ b/tslang/test/tester/own/own_string_copy_after_move.ts @@ -30,6 +30,25 @@ function storedInLoop() { return h; } +// A closure borrows the string and a field takes it. The closure captures a variable too, so it +// borrows all it captures: the store that fills its box borrows, and the field is the one taker, +// which gets the copy. +function captured() { + const h = new H(); + let n = 1; + const s = "cap" + 7; + const f = () => { + return s.length + n; + }; + n = 2; + assert(f() == 6); + h.text = s; + print(s); + assert(churn() == 1000); + assert(s == "cap7" && h.text == "cap7"); + return h; +} + function main() { const h = new H(); let kept = "kept" + 1; @@ -45,5 +64,9 @@ function main() { assert(churn() == 1000); assert(both.text == "some3" && neither.text == "" && looped.text == "loop4"); + const closed = captured(); + assert(churn() == 1000); + assert(closed.text == "cap7"); + print("done."); } From 43d0cb4700fe72facc27b541351f50abd33560b2 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 3 Oct 2026 00:41:01 +0100 Subject: [PATCH 11/16] -mm=own strings: a test that the copy is made in the store's block The same-block guard in copyForRetain had no test: without it, `stored(c)` gets its copy at the entry block's birth retain, and the copy leaks on the `c == false` path, which test-runner cannot see. test-own-string-copy-in-store-block reads the own IR of own_string_copy_after_move.ts (own-string-copy-block.cmake) and requires `stored`'s one ts.StringCopy after the entry block's cf.cond_br. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 4 +++ tslang/test/tester/CMakeLists.txt | 6 ++++ .../test/tester/own-string-copy-block.cmake | 34 +++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 tslang/test/tester/own-string-copy-block.cmake 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 341641324..f51335771 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 @@ -1959,6 +1959,10 @@ The 35 files gained under `--opt`: (`___cast`). Its own IR has eight copies; main rejects it with eight errors. It needed no new code. - **Own only:** `own_string_copy_written`, `let t = s; t[0] = 65` changes `t` only. +- **The copy's block** (`test-own-string-copy-in-store-block`, `own-string-copy-block.cmake`): the + own IR of `own_string_copy_after_move.ts` has one `ts.StringCopy` in `stored`, after the entry + block's `cf.cond_br`. Copied at the birth retain instead, it would leak on the path that skips + the store, which no run can see, so the IR is read. - **Negative:** | test | message | diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 85bd2e624..a758a074a 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2601,6 +2601,12 @@ foreach(strings_mm rc none gc) tslang_add_test(NAME test-compile-${strings_mm}-${strings_test} COMMAND test-runner -mm=${strings_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${strings_test}.ts") endforeach() endforeach() +# A string stored on one branch only is copied in the store's block, not at rc's birth retain, where +# the copy would leak on the path that skips the store; no run can see that leak, so the IR is read. +add_test(NAME test-own-string-copy-in-store-block + COMMAND ${CMAKE_COMMAND} -DTSLANG=$ + "-DFILE=${PROJECT_SOURCE_DIR}/test/tester/own/own_string_copy_after_move.ts" + -P "${CMAKE_CURRENT_SOURCE_DIR}/own-string-copy-block.cmake") # 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-string-copy-block.cmake b/tslang/test/tester/own-string-copy-block.cmake new file mode 100644 index 000000000..5750aba56 --- /dev/null +++ b/tslang/test/tester/own-string-copy-block.cmake @@ -0,0 +1,34 @@ +# Under own a string stored on one branch only gets its copy in the store's block, never at rc's +# birth retain after the producer: copied there, the copy has no owner on the path that skips the +# store, a leak no run can see (spec 22.7, "only for a use in the retain's block"). +# Checked on `stored` of own_string_copy_after_move.ts: its one `ts.StringCopy` comes after the +# entry block's `cf.cond_br`. +cmake_minimum_required(VERSION 3.17.3) +execute_process(COMMAND "${TSLANG}" --emit=mlir-affine --no-default-lib -mm=own "${FILE}" + OUTPUT_VARIABLE out ERROR_VARIABLE err RESULT_VARIABLE result) +# the IR is printed to stderr +string(APPEND out "${err}") +if(NOT result EQUAL 0) + message(FATAL_ERROR "tslang failed (${result}):\n${out}") +endif() +string(FIND "${out}" "ts.Func @stored " begin) +if(begin EQUAL -1) + message(FATAL_ERROR "no function `stored` in:\n${out}") +endif() +string(SUBSTRING "${out}" ${begin} -1 body) +string(SUBSTRING "${body}" 1 -1 rest) +string(FIND "${rest}" "ts.Func @" end) +if(NOT end EQUAL -1) + math(EXPR end "${end} + 1") + string(SUBSTRING "${body}" 0 ${end} body) +endif() +string(REGEX MATCHALL "\"ts[.]StringCopy\"" copies "${body}") +list(LENGTH copies count) +if(NOT count EQUAL 1) + message(FATAL_ERROR "`stored` has ${count} ts.StringCopy, not 1:\n${body}") +endif() +string(FIND "${body}" "cf.cond_br" branch) +string(FIND "${body}" "\"ts.StringCopy\"" copy) +if(branch EQUAL -1 OR copy LESS branch) + message(FATAL_ERROR "`stored`'s copy is not in the store's block:\n${body}") +endif() From 39f5b7465abf3e4d40ef3a49da006c6f26d397df Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 3 Oct 2026 00:43:04 +0100 Subject: [PATCH 12/16] -mm=own strings: the copy's-block check on a file of its own With the same-block rule off, own_string_copy_after_move is rejected first by its storedInLoop (a copy at the birth retain is then moved inside the loop), so the IR check failed on the compile, not on where the copy goes. own_string_copy_branch.ts holds `stored` alone: with the rule off it compiles, and the check fails because the copy is in the entry block. It is also a positive under own, rc, none and gc. Spec 22.7: the test, and teeth (e). Co-Authored-By: Claude Opus 5.5 --- .../2026-09-24-own-memory-model-design.md | 12 +++++-- tslang/test/tester/CMakeLists.txt | 7 +++-- .../test/tester/own-string-copy-block.cmake | 4 +-- .../test/tester/own/own_string_copy_branch.ts | 31 +++++++++++++++++++ 4 files changed, 46 insertions(+), 8 deletions(-) create mode 100644 tslang/test/tester/own/own_string_copy_branch.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 f51335771..9185b931d 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 @@ -1957,12 +1957,17 @@ The 35 files gained under `--opt`: - `own_string_copy_mixed_return`: `greet` returning `name` on one path and `"Sorry, " + name` on the other, `` of an `any` (`___unbox`), and `` of a `number | string` (`___cast`). Its own IR has eight copies; main rejects it with eight errors. It needed no new - code. + code; + - `own_string_copy_branch`: a string stored into a field on one branch only and used after, + alone, for the IR check below. - **Own only:** `own_string_copy_written`, `let t = s; t[0] = 65` changes `t` only. - **The copy's block** (`test-own-string-copy-in-store-block`, `own-string-copy-block.cmake`): the - own IR of `own_string_copy_after_move.ts` has one `ts.StringCopy` in `stored`, after the entry + own IR of `own_string_copy_branch.ts` has one `ts.StringCopy` in `stored`, after the entry block's `cf.cond_br`. Copied at the birth retain instead, it would leak on the path that skips - the store, which no run can see, so the IR is read. + the store, which no run can see, so the IR is read. The file is also a positive, under every + model; it has no loop, so with the same-block rule off it still compiles, and only this check + fails (teeth (e)). `own_string_copy_after_move` has the same `stored`, but with the rule off its + `storedInLoop` is rejected first. - **Negative:** | test | message | @@ -1979,6 +1984,7 @@ The 35 files gained under `--opt`: | (b) `borrowsKnownParam` returns false | `own_err_string_kept_param_known` compiles, and `keep` never releases the `v` its callers gave up: the leak the guard is for | | (c) the copy always in front of the use | `own_string_copy_field` and `own_string_copy_nullable` fail (6 tests); `r.s = r.s` is rejected by the place check | | (d) the `several` guard off | `own_err_string_pushed_twice` compiles, and is correct: each retain copies a different operand | +| (e) the same-block rule off | `test-own-string-copy-in-store-block` fails: `stored`'s copy is in the entry block | - **Step 7 of the plan.** Five string negatives compiled once strings were copied: - `own_err_mixed_return`, `own_err_param_returned_exported`, `own_err_param_kept_exported` and diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index a758a074a..70954639c 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2574,7 +2574,8 @@ foreach(own_test own_fresh_string own_fresh_array own_return_new own_try_local o own_global_init own_constant_data own_record_local own_nullable_view own_declared_constant own_spread_build own_interface_field_call own_string_copy_field own_string_copy_after_move own_string_copy_nullable - own_string_copy_exported own_string_copy_written own_string_copy_mixed_return) + own_string_copy_exported own_string_copy_written own_string_copy_mixed_return + own_string_copy_branch) tslang_add_test(NAME test-jit-own-${own_test} COMMAND test-runner -jit -mm=own "${PROJECT_SOURCE_DIR}/test/tester/own/${own_test}.ts") tslang_add_test(NAME test-compile-own-${own_test} COMMAND test-runner -mm=own "${PROJECT_SOURCE_DIR}/test/tester/own/${own_test}.ts") # The promise: no counting at all in what own emits. @@ -2596,7 +2597,7 @@ endforeach() # under every model. foreach(strings_mm rc none gc) foreach(strings_test own_string_copy_field own_string_copy_after_move own_string_copy_nullable own_string_copy_exported - own_string_copy_mixed_return) + own_string_copy_mixed_return own_string_copy_branch) tslang_add_test(NAME test-jit-${strings_mm}-${strings_test} COMMAND test-runner -jit -mm=${strings_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${strings_test}.ts") tslang_add_test(NAME test-compile-${strings_mm}-${strings_test} COMMAND test-runner -mm=${strings_mm} "${PROJECT_SOURCE_DIR}/test/tester/own/${strings_test}.ts") endforeach() @@ -2605,7 +2606,7 @@ endforeach() # the copy would leak on the path that skips the store; no run can see that leak, so the IR is read. add_test(NAME test-own-string-copy-in-store-block COMMAND ${CMAKE_COMMAND} -DTSLANG=$ - "-DFILE=${PROJECT_SOURCE_DIR}/test/tester/own/own_string_copy_after_move.ts" + "-DFILE=${PROJECT_SOURCE_DIR}/test/tester/own/own_string_copy_branch.ts" -P "${CMAKE_CURRENT_SOURCE_DIR}/own-string-copy-block.cmake") # Across a library boundary: the library exports its functions' `__own_no_drops`, and a borrow held diff --git a/tslang/test/tester/own-string-copy-block.cmake b/tslang/test/tester/own-string-copy-block.cmake index 5750aba56..c735053bf 100644 --- a/tslang/test/tester/own-string-copy-block.cmake +++ b/tslang/test/tester/own-string-copy-block.cmake @@ -1,8 +1,8 @@ # Under own a string stored on one branch only gets its copy in the store's block, never at rc's # birth retain after the producer: copied there, the copy has no owner on the path that skips the # store, a leak no run can see (spec 22.7, "only for a use in the retain's block"). -# Checked on `stored` of own_string_copy_after_move.ts: its one `ts.StringCopy` comes after the -# entry block's `cf.cond_br`. +# Checked on `stored` of own_string_copy_branch.ts: its one `ts.StringCopy` comes after the entry +# block's `cf.cond_br`. That file has no loop, so without the rule it still compiles. cmake_minimum_required(VERSION 3.17.3) execute_process(COMMAND "${TSLANG}" --emit=mlir-affine --no-default-lib -mm=own "${FILE}" OUTPUT_VARIABLE out ERROR_VARIABLE err RESULT_VARIABLE result) diff --git a/tslang/test/tester/own/own_string_copy_branch.ts b/tslang/test/tester/own/own_string_copy_branch.ts new file mode 100644 index 000000000..305dac9f7 --- /dev/null +++ b/tslang/test/tester/own/own_string_copy_branch.ts @@ -0,0 +1,31 @@ +// A string stored into a field on one branch only, and used after: the copy is made in the store's +// block. Made at rc's birth retain after the producer instead, it would have no owner on the path +// that skips the store, a leak no run sees; test-own-string-copy-in-store-block reads the IR. +class H { + text = ""; +} + +function churn() { + let keep: string[] = []; + for (let i = 0; i < 1000; i++) keep.push("k" + i); + return keep.length; +} + +function stored(c: boolean) { + const h = new H(); + const s = "some" + 3; + if (c) { + h.text = s; + } + print(s); + return h; +} + +function main() { + const both = stored(true); + const neither = stored(false); + assert(churn() == 1000); + assert(both.text == "some3" && neither.text == ""); + + print("done."); +} From de6e8f21ccae00ba9ed7712fffdf6123d00c2612 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 3 Oct 2026 00:45:01 +0100 Subject: [PATCH 13/16] -mm=own strings: no dangling dominance; a copy at the retain only before its use `dominance` pointed at analyze's local DominanceInfo after it returned; a scope exit now clears it, so nothing between or after the runs can read a dead one. copyForRetain, building the copy at the retain, now requires the retain to come before the use that reads the copy, and leaves the error otherwise. Co-Authored-By: Claude Opus 5.5 --- tslang/lib/TypeScript/OwnershipInferencePass.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index dc9726a30..c7d320809 100644 --- a/tslang/lib/TypeScript/OwnershipInferencePass.cpp +++ b/tslang/lib/TypeScript/OwnershipInferencePass.cpp @@ -14,6 +14,7 @@ #include "OwnershipFacts.h" #include "llvm/ADT/MapVector.h" +#include "llvm/ADT/ScopeExit.h" #include "llvm/ADT/SetVector.h" #include "llvm/ADT/SmallPtrSet.h" #include "llvm/ADT/SmallVector.h" @@ -107,6 +108,8 @@ class OwnershipInferencePass auto f = getFunction(); mlir::DominanceInfo dominanceInfo(f); dominance = &dominanceInfo; + // it is this call's: nothing after it (the copies, the next run) may read it + auto dominanceGone = llvm::make_scope_exit([&]() { dominance = nullptr; }); // every value some ownership operation names, in the order the walk meets them llvm::SetVector candidates; @@ -548,6 +551,12 @@ class OwnershipInferencePass auto *def = taking->get().getDefiningOp(); if (taking->get() == value || (def && def->getBlock() == retain->getBlock() && def->isBeforeInBlock(retain))) { + // the use reads the copy, so the retain must come before it + if (!retain->isBeforeInBlock(taking->getOwner())) + { + return false; + } + at = retain; } From 4515a6835565aacbfaee5fa67347c5d42ad34b7d Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 3 Oct 2026 00:48:49 +0100 Subject: [PATCH 14/16] -mm=own strings: the final review's doc points (spec 22.1, 22.2, 22.7, plan) Spec 22.7: the IR check's normalisations as their exact sed patterns; the Linux run names what came after efd252b1; outside the rule, a copy at a birth retain before a call that may throw (leaks on the throw, as the original already does), and why isCopyableString's default CompileOptions is safe against the lowering's real ones. 22.1 and 22.2 reflowed. Plan: Task 4 Step 7 says the push and PR are left to the user. Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-02-own-strings-as-values.md | 2 +- .../2026-09-24-own-memory-model-design.md | 32 +++++++++++++------ 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/tslang/docs/superpowers/plans/2026-10-02-own-strings-as-values.md b/tslang/docs/superpowers/plans/2026-10-02-own-strings-as-values.md index 6c2adf52c..e0f295e5b 100644 --- a/tslang/docs/superpowers/plans/2026-10-02-own-strings-as-values.md +++ b/tslang/docs/superpowers/plans/2026-10-02-own-strings-as-values.md @@ -1002,7 +1002,7 @@ function main() { `~/ts/TypeScriptCompiler`, build dir `__build/tslang/ninja/release`, fetch this branch from the Windows repo). -- [x] **Step 7: Commit, and open the PR.** +- [x] **Step 7: Commit, and open the PR.** Committed; push and PR left to the user. ```bash git add tslang/test/tester/own/own_string_copy_mixed_return.ts tslang/test/tester/CMakeLists.txt tslang/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 9185b931d..6565519ba 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 @@ -1741,8 +1741,8 @@ becomes a copy. - **A string** is a value whose type is `string`, a string literal type, or a union or an optional of `string` with only `null` or `undefined` beside it. A union with `null` is one pointer, the same as `string`; an optional (`string | undefined`, an optional parameter) is `{ptr, i1}`, the - pointer meaningful only when the flag is set. A union with other members (`string | number`), and an - object or array holding strings, are not covered. + pointer meaningful only when the flag is set. A union with other members (`string | number`), + and an object or array holding strings, are not covered. - **Silent.** A copy makes no diagnostic. - **Only where the program would fail.** Copies are made only in a function the analysis rejects, and only for the string retains it could not erase. A function that compiles today compiles to @@ -1755,8 +1755,7 @@ becomes a copy. `ts.StringCopy %s : !ts.string -> !ts.string`, its result a new allocation (`MemAlloc`). It lowers to: null stays null; otherwise the length plus one byte, through the allocator `ts.StringConcat` -uses, and a `memcpy`. An optional is copied -only when it holds a value. +uses, and a `memcpy`. An optional is copied only when it holds a value. - Only the own inference pass creates it, but it lowers under every model. - `isFresh` counts it, beside `ts.StringConcat`. @@ -2007,10 +2006,14 @@ Main rejects the program under own (`r.t = r.s`, "borrows a field"). The 417 files that compile on main were compiled with main's binary and this branch's, `--emit=llvm -mm=own --no-default-lib`. §22.6 named `--emit=mlir-affine`; the plan's step and this check use the LLVM IR, which is what runs. The output is identical once the names that change from -run to run are normalised. Main against main needed the same normalisations: -- the `FH` hashes; -- pointer-derived suffixes (`@a_`, `@td__number`, `@tsrel_`, `...vtbl`); -- the sizes of the strings that hold an `FH` name. +run to run are normalised. Main against main needed the same normalisations, as `sed -E` +substitutions: +- the hashes, `s/FH[0-9]+/FH/g`; +- pointer-derived suffixes (`@a_`, `@td__number`, `@tsrel_`), + `s/([A-Za-z])_[0-9]{4,}/\1_N/g`; +- class numbers (`...vtbl`), `s/\.[0-9]{6,}\./.N./g`; +- the size of a string constant that holds an `FH` name, on its line only, + `s/\[[0-9]+ x i8\]( c".*FH)/[K x i8]\1/`. One file, `00union_null_undefined_nonstrict`, can differ in the order of a union's members in a synthesized `___cast>` name. The order flips between runs of either binary (main gave @@ -2020,7 +2023,10 @@ one order in 9 runs of 12, this branch in 10 of 12). With the same order, the IR GCC, in WSL, the branch at `efd252b1`: the build is clean, and `ctest -R "own-err|own-no-counting|own-verify"` passes 140 of 140. `ctest -R own_string_copy` (the -positives under own, rc, none and gc, AOT and JIT) passes 48 of 48. +positives under own, rc, none and gc, AOT and JIT) passes 48 of 48. The commits after `efd252b1` +were not run there: the Linux paragraph is docs only, and the final review's fixes (the borrow +attributes kept for the copies, the retain-before-use check, `dominance` cleared, and +`own_string_copy_branch` with its IR check) were run on Windows only. #### Outside the rule @@ -2057,3 +2063,11 @@ positives under own, rc, none and gc, AOT and JIT) passes 48 of 48. - **A fresh string merged by CSE** under `--opt` (`05strings`): the two `"X" + true` become one value with two retains and two releases, for a print and a comparison. Neither retain has a taker to copy for, so the catch-all reports it, on main too. +- **A copy made at a birth retain, before a call that may throw** (`const t = "t" + n; boom(n); + h.text = t`): the copy is made right after `t`, before `boom`, and leaks when `boom` throws. + `t` itself leaks the same way already, since its release is on the normal path only. +- **The tag of a union.** `isCopyableString` asks `isUnionTypeNeedsTag` with a default + `CompileOptions`, and the lowering asks with the real ones (`strictNullChecks` can change the + 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. From 2345904d3db928d5dcfa819840f0aade371ddd73 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 3 Oct 2026 00:50:57 +0100 Subject: [PATCH 15/16] -mm=own strings: when a closure owns its copies, as the code decides it (spec 22.6, 22.7) The outside-the-rule bullet now names all three conditions under which a closure with copies only borrows them (no escape, a bounded maker, no fresh copy); 22.6 lists own_string_copy_branch. Co-Authored-By: Claude Opus 5.5 --- .../specs/2026-09-24-own-memory-model-design.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md b/tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md index 6565519ba..c96756f5e 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 @@ -1812,7 +1812,8 @@ The error stays where a copy would be wrong: - **Positive** (test-runner under own, AOT and JIT; each also under rc, none and gc, where no copy is made): `own_string_copy_field`, `own_string_copy_after_move`, - `own_string_copy_mixed_return`, `own_string_copy_nullable`, `own_string_copy_exported`. + `own_string_copy_mixed_return`, `own_string_copy_nullable`, `own_string_copy_exported`, + `own_string_copy_branch` (added after the final review). - **Own only:** `own_string_copy_written`, `let t = s; t[0] = 65`, which changes `t` only. - **Negative:** - a kept parameter whose callers know the fact; @@ -2051,9 +2052,10 @@ attributes kept for the copies, the retain-before-use check, `dominance` cleared the guard off it compiles and is correct, so the rule is conservative there; widened, the negative becomes a positive. - **A string captured by a closure that owns its copies, and also stored** (`const f = () => - s.length; r.t = s; print(s)`). A closure that captures only copies, and is not given to a maker - whose result is bounded, owns them (phase 4): the store into its box is a taker, so the string - has two, and the `several` guard keeps the error. + s.length; r.t = s; print(s)`). A closure that captures only copies owns them (phase 4) unless it + does not escape, is given to a maker whose result is bounded, and none of its copies is fresh. + Here the string is fresh, so the store into the box is a taker, the string has two, and the + `several` guard keeps the error. - **A use whose operand is a view defined after the retain** gets its copy in front of the use. A same-position, same-type `ReleaseSlot` in between would make the place check reject the program: a false error, not a miscompile. No test has it. From 67d53839ebc3f03e05c4ab8d0e9365ac8b0d53f5 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 3 Oct 2026 00:52:45 +0100 Subject: [PATCH 16/16] -mm=own strings: the Linux run after the final review's fixes (spec 22.7) Co-Authored-By: Claude Opus 5.5 --- .../specs/2026-09-24-own-memory-model-design.md | 8 ++++---- 1 file changed, 4 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 c96756f5e..4e13ead95 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 @@ -2024,10 +2024,10 @@ one order in 9 runs of 12, this branch in 10 of 12). With the same order, the IR GCC, in WSL, the branch at `efd252b1`: the build is clean, and `ctest -R "own-err|own-no-counting|own-verify"` passes 140 of 140. `ctest -R own_string_copy` (the -positives under own, rc, none and gc, AOT and JIT) passes 48 of 48. The commits after `efd252b1` -were not run there: the Linux paragraph is docs only, and the final review's fixes (the borrow -attributes kept for the copies, the retain-before-use check, `dominance` cleared, and -`own_string_copy_branch` with its IR check) were run on Windows only. +positives under own, rc, none and gc, AOT and JIT) passes 48 of 48. After the final review's fixes +(the borrow attributes kept for the copies, the retain-before-use check, `dominance` cleared, and +`own_string_copy_branch`), the branch at `2345904d` builds clean there, and +`ctest -R "own-err|own-no-counting|own-verify|own_string_copy|own_err_string"` passes 191 of 191. #### Outside the rule