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..e0f295e5b --- /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. + +- [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 +// 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); +} +``` + +- [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`. + +- [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 +// 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. + +- [x] **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. + +- [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: + +``` +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." + +- [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. + +- [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 +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. + +- [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: + +```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 +``` + +- [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 + // 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())); + } +``` + +- [x] **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. + +- [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). + +- [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: + ```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); + } + ``` + +- [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 + 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. + +- [x] **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. + +- [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`: + +```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."); +} +``` + +- [x] **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. + +- [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; + - 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. + +- [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 + // 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. + +- [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 + 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 `. + +- [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 + 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). + +- [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 + 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%. + +- [x] **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). + +- [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 +// 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. + +- [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 + 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`). + +- [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 + `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. + +- [x] **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. + +- [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; + - the teeth (Task 3, Step 6); + - the measure rows; + - the IR-unchanged check; + - every shape found outside the rule. + + Tick this plan's boxes. + +- [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). + +- [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/ +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" +``` 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..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 @@ -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` | @@ -1721,3 +1721,355 @@ 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. 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, + 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`. 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`. +- 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 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. + + 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_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; + - 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`. + +### 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. + 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)`; + - 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, 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; + - `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_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_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 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 | +|---|---| +| `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 | +| (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 + `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, 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 +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. 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 + +- **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 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 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. +- **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. +- **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. 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..d3c0273e0 100644 --- a/tslang/lib/TypeScript/LowerToLLVM.cpp +++ b/tslang/lib/TypeScript/LowerToLLVM.cpp @@ -884,6 +884,71 @@ 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 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)); + 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 +8028,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..7d1c7c8ea 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,44 @@ 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)) + { + 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); +} + // Some view of `value` is a `ts.NewInterface`, which boxes what it shows into a block. inline bool boxedByView(mlir::Value value) { diff --git a/tslang/lib/TypeScript/OwnershipInferencePass.cpp b/tslang/lib/TypeScript/OwnershipInferencePass.cpp index 4476dc0b0..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" @@ -71,10 +72,44 @@ 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; + // 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(); + } + + 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); 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; @@ -93,6 +128,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 +161,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 +169,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 +220,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 +379,11 @@ class OwnershipInferencePass checkBoundedResults(); toErase.insert(fieldBorrowReleases.begin(), fieldBorrowReleases.end()); + if (dryRun) + { + return failures == 0; + } + for (auto *op : toErase) { op->erase(); @@ -357,6 +400,8 @@ class OwnershipInferencePass op->removeAttr(name); } }); + + return true; } private: @@ -373,6 +418,155 @@ 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())); + } + + // 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. + void 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); + } + }); + + for (auto *retain : retains) + { + copyForRetain(retain); + } + } + + // 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); + } + + // 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 + 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; + } + + // 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(); + 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; + } + + 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; @@ -525,14 +719,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 +790,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 +1186,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 +1728,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; } @@ -2053,6 +2247,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()) { @@ -3067,7 +3267,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; } @@ -3777,7 +3977,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 +4198,7 @@ class OwnershipInferencePass void reportKeptOnSomePaths(mlir::Operation *move, mlir::Operation *exit, llvm::StringRef name) { - if (quiet) + if (!reporting()) { return; } @@ -4012,7 +4212,7 @@ class OwnershipInferencePass void reportGivenNotOwned(mlir::Operation *call, mlir::Value arg) { - if (quiet) + if (!reporting()) { return; } @@ -4036,7 +4236,7 @@ class OwnershipInferencePass void reportUseAfterMove(mlir::Operation *use, mlir::Operation *move, llvm::StringRef name) { - if (quiet) + if (!reporting()) { return; } @@ -4048,7 +4248,7 @@ class OwnershipInferencePass void reportMovedInLoop(mlir::Operation *move, llvm::StringRef name) { - if (quiet) + if (!reporting()) { return; } @@ -4061,7 +4261,7 @@ class OwnershipInferencePass void reportBorrowOutlives(mlir::Operation *use, mlir::Operation *end, llvm::StringRef name, llvm::StringRef owner) { - if (quiet) + if (!reporting()) { return; } @@ -4075,7 +4275,7 @@ class OwnershipInferencePass void reportPlaceBorrowOutlives(mlir::Operation *use, mlir::Operation *drop, llvm::StringRef name, llvm::StringRef place) { - if (quiet) + if (!reporting()) { return; } @@ -4088,7 +4288,7 @@ class OwnershipInferencePass void reportPlaceBorrowerAssigned(mlir::Operation *op, llvm::StringRef name, llvm::StringRef place) { - if (quiet) + if (!reporting()) { return; } @@ -4099,7 +4299,7 @@ class OwnershipInferencePass void reportPlaceBorrowEscapes(mlir::Operation *op, llvm::StringRef name, llvm::StringRef place) { - if (quiet) + if (!reporting()) { return; } @@ -4111,7 +4311,7 @@ class OwnershipInferencePass void reportMovedWhileBorrowed(mlir::Operation *op, llvm::StringRef owner, llvm::StringRef borrower) { - if (quiet) + if (!reporting()) { return; } @@ -4123,7 +4323,7 @@ class OwnershipInferencePass void reportBorrowEscapes(mlir::Operation *op, llvm::StringRef name, llvm::StringRef owner) { - if (quiet) + if (!reporting()) { return; } @@ -4135,7 +4335,7 @@ class OwnershipInferencePass void reportBorrowerAssigned(mlir::Operation *op, llvm::StringRef name, llvm::StringRef owner) { - if (quiet) + if (!reporting()) { return; } @@ -4146,6 +4346,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 +4360,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 +4373,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 +4386,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 +4410,7 @@ class OwnershipInferencePass void reportMovedOnSomePaths(mlir::Operation *move, mlir::Operation *release, llvm::StringRef name) { - if (quiet) + if (!reporting()) { return; } diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index e1b4ab240..70954639c 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -2572,7 +2572,10 @@ 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 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. @@ -2590,6 +2593,22 @@ 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 + 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() +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_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 # 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 +2719,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 +2728,10 @@ 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" + "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-string-copy-block.cmake b/tslang/test/tester/own-string-copy-block.cmake new file mode 100644 index 000000000..c735053bf --- /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_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) +# 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() 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_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..50ee9dcea --- /dev/null +++ b/tslang/test/tester/own/own_err_string_call_keeps_twice.ts @@ -0,0 +1,21 @@ +// -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 = ""; +} + +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_string_pushed_twice.ts b/tslang/test/tester/own/own_err_string_pushed_twice.ts new file mode 100644 index 000000000..e17ee4407 --- /dev/null +++ b/tslang/test/tester/own/own_err_string_pushed_twice.ts @@ -0,0 +1,10 @@ +// 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[] = []; + list.push(s, s); + print(list[0], 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..b2a65c1af --- /dev/null +++ b/tslang/test/tester/own/own_string_copy_after_move.ts @@ -0,0 +1,72 @@ +// 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 = ""; +} + +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 storedInLoop() { + const h = new H(); + const s = "loop" + 4; + for (let i = 0; i < 3; i++) { + h.text = s; + } + print(s); + 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; + h.text = kept; + print(kept); + h.text = "other" + 2; + 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"); + + const closed = captured(); + assert(churn() == 1000); + assert(closed.text == "cap7"); + + print("done."); +} 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."); +} 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_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."); +} 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."); +}