Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions tslang/docs/superpowers/specs/2026-10-19-own-param-markers-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# `@keeps` and `@borrows`: ownership facts in a function's signature

Date: 2026-10-19. Status: implemented (branch `own-param-markers`), with the own generator fixes
`matchAll` needed.

## Why

After `@nodrops` and `@final` (PR #611), the default library still did not build under `-mm=own`.
The remaining errors had one cause, "decision B": an exported function keeps or borrows a
parameter. OwnershipSignaturePass computes these facts from the body (`__own_params`,
`__own_result_bounded`). It drops them for any function a caller outside the module may reach (it
is "open"), because a caller that does not know them borrows an argument the callee keeps, or
releases a result the callee still points into. Either way that frees twice. The sites were
`Response.constructor` (keeps `headers`), `___String.matchAll` and `String[Symbol.iterator]`
(generators whose state borrows `this` and `expr`), and the `String.matchAll` wrapper.

The user chose markers: the facts become part of the function's declared signature, so every
caller knows them, including one in another module, which re-parses the declaration.

## The markers

Both go on a function, method or constructor, and name its parameters. `"this"` names the receiver.

- `@keeps("headers", "body")`: the function keeps these arguments. Every caller moves them in.
- `@borrows("this", "expr")`: the caller owns the result, but the result may not outlive these
arguments. The result is either the state of a generator over them, or what a call with such a
bounded result returns (`return this.value.matchAll(expr)`, a "forwarder").

Under gc, rc and none they mean nothing. MLIRGen still records them, and the LLVM lowering drops
the attributes.

## How it works

- **MLIRGen** (`processOwnParamMarkers`) turns the names into argument indices (FuncOp attributes
`ts.own_declared_keeps` and `ts.own_declared_borrows`). It also records the argument names
(`ts.own_declared_param_names`) for the errors. A name that is not a parameter is an error. The
generator wrapper copies the method's decorators, so a marked generator's maker carries them.
- **OwnershipSignaturePass:**
- A marked function's `@keeps` facts stand even when it is open (`meetFacts`). A family does not
erase them.
- A call gets what its callees' signatures say, whether or not it is resolved
(`declaredByCallees`, over `Callees::named`). The named callees are the direct callee, every
member of a virtual slot's family, or a dynamic import's address global.
- A call whose callees say `@borrows` gets `__own_result_bounded` and `__own_fresh_result`: the
caller owns the state.
- `findBorrowingStates` accepts a marked maker even though it is public, and a method reached
through its vtable. It also accepts `this` held by value (`.this`, read from the parameter's
slot).
- `findBoundedForwarders` gives a marked non-generator what its returned bounded calls borrow,
mapped back to its own parameters (directly, or a field read of one).
- `checkDeclared` holds each body to its signature, exactly. It also checks that the overrides in
a marked method's family say the same.
- **OwnershipInferencePass:** a marked function may return a bounded result (`boundedUses`).
- **Across modules:**
- `__decls` prints the markers with the printed parameter names (`p1`, and `this` for a
receiver). `MethodInfo::ownKeeps` and `ownBorrows` carry them for methods;
`addFunctionDeclarationToExport` gets them from the FuncOp.
- A dynamic import's function is a global holding its address, so
`mlirGenFunctionLikeDeclarationDynamicImport` copies the declaration's markers onto that global.
- DefaultLib's `lib.d.ts` says the same as `lib.ts`.

## Found on the way and fixed here

- **A silent miscompile.** A public generator method over `this` with no marker compiled under own
(release), and the state's release freed the caller's object. The caller then freed it a second
time, and the program exited 127 with no output. `classifyStateBoxes` let a non-borrowing box
take rc's reference for a parameter the function does not own. It now leaves that reference
alone, so the existing "is a parameter, kept here" error fires (`own_err_generator_method_this`).
`--di` already errored there, by accident: its `ts.DebugVariable` use broke the one-use check
that paired the reference.
- **`--di`.** `retainBefore` and `twinReadAfter` now ignore `ts.DebugVariable` uses (`soleUser`),
so a marked generator over `this` builds with debug info too.

## Generator locals under own

`matchAll`'s body needed three fixes to own generators that markers could not give. Each is
covered by `own_generator_locals.ts`, under gc, rc and own.

- **Narrowing.** A generator's locals are fields of its state object, so narrowing one
(`if (result == null) break;`) made a second field, filled from the first: a second reference.
`addSafeCastStatement` now declares a generator's narrowed local as a view of the local's own
storage (`VariableDeclarationDOM::narrowedType`). Each read loads the local and casts it, so a
read after a resume sees the current value. An SSA copy would not: it read stale values after a
yield, and gc printed 3 where 9 is right.
- **A spread in a generator.** `[...a]` is a `for...of` that MLIRGen writes, and its `.a` alias of
the source array became a field holding a second reference. The loops of the spread, `forEach`,
`every` and `some` hold no yield, so their locals stay in the frame (`allocateVarsInContextThis`
and `allocateVarsOutsideOfOperation` off). `isBuiltArrayReturned` also accepts the built array
moved into a field, `h.c = [...a]` or a generator's local.
- **A yielded local.** `yield groups` read the field into the `{value, done}` record, a borrow of
the state. OwnershipSignaturePass now finds the state fields that are read for nothing but
yields, where each stored value is yielded later in its block (`findYieldedFields`). The yield's
read is fresh (the value moves to the caller), an overwrite gives nothing back, and the state's
release routine skips the field (`OWN_YIELDED_FIELDS_ATTR_NAME`). `matchAll` computes its
empty-match skip from `result` before the yield, so `groups` is only yielded.
- **The owned-return pass took a generator's `next` for a class method.** OwnedReturnConsumptionPass
matches a virtual call's candidates by member name. A class's `next()` was grouped with every
generator's `next`, whose record does not classify as owned, so `src.next()` lost
`__owned_result` and own reported a second reference. Only class methods (a class first) are
candidates now.

With these, `lib.ts` compiles under gc, rc, none and own, release and `--di`. DefaultLib's
`build.bat` and `build.sh` build own as a fourth model.

## Not done (follow-ups)

- **Iterating another module's generator under own** works now. The user ruled that what an
iterator's `next()` returns is the caller's, through the `Iterator` interface or into another
module, and only that, not every unresolved call. OwnershipSignaturePass marks such calls fresh
(`isIteratorNext`: a callee named `next` returning `{value, done}`), and so is a call that
returns a generator's state object (`makesGeneratorState`), which a generator function always
makes. `isFresh` reads that mark before it rejects a declared callee, and `walkBorrowed` does not
follow a fresh result that is not bounded. Another module's generator state is released here
without its fields (`findImportedStates`): only the box's cells, and their values unless the
maker says `@borrows`. What its locals still hold when it is given up early leaks. A release
made from the shape freed the borrowed argument a second time (exit 127). `import_final_class`
and `import_own_markers` now run under own too. `own_iterator_next.ts` covers the
interface-shaped call in one module, which already worked.
- **An own DefaultLib returns corrupted regex strings.** This is on main, and it is not this
branch's. `let buffer = "".clone().resize(len)` (`MatchResults.get`) borrows a temporary that is
freed after the statement, and own does not reject it. It needs its own fix.
- **A generator local's previous value leaks** under rc and own (on main too): a `const` declared in
a loop inside a generator is stored into its state field again each iteration, and nothing gives
back what the last iteration stored. Releasing it there fixes rc, but under own it makes `next`
a call that may drop, and a caller's `for...of` over such a generator is then rejected (its
loop variable borrows the record). The fix wants the caller-side `for...of` move as well.
- **A generator local's type from a function declared later** is `none` in the state object, and
the lowering fails ("failed to legalize operation 'ts.Cast'") under every model. `matchAll`
annotates `const more: boolean`.
- **`@borrows` for an ordinary function whose result points into an argument**
(`__own_result_borrows`, where the caller owns nothing) is not expressible yet. Such a function
is still rejected when it is open.
- **A ternary that picks an array literal or `null`** (`return i < 3 ? ["a"] : null`) fails MLIR
verification ("operand #0 does not dominate this use") under rc and own. This is unrelated.
- **Android** still builds gc, rc and none only.
12 changes: 12 additions & 0 deletions tslang/include/TypeScript/DOM.h
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ class VariableDeclarationDOM
bool ignoreCapturing;
bool _using;
mlir::Value disposeGuard;
// a generator's local narrowed by a test: a view of the local's own storage, read and cast
// to this type at each read (addSafeCastStatement)
mlir::Type narrowedType;

public:
using TypePtr = std::shared_ptr<VariableDeclarationDOM>;
Expand Down Expand Up @@ -114,6 +117,15 @@ class VariableDeclarationDOM
{
return disposeGuard;
}

mlir::Type getNarrowedType() const
{
return narrowedType;
}
void setNarrowedType(mlir::Type value)
{
narrowedType = value;
}
void setDisposeGuard(mlir::Value value)
{
disposeGuard = value;
Expand Down
18 changes: 18 additions & 0 deletions tslang/include/TypeScript/Defines.h
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@
// Its release routine skips them, and a borrowing `.captured` box frees its cells but not what
// they hold.
#define OWN_BORROWING_FIELDS_ATTR_NAME "ts.own_borrowing_fields"
// -mm=own only, set by OwnershipSignaturePass on the module, in the same shape: the fields of a
// generator's state object - its locals - read only to be yielded (`const groups = [...r]; yield
// groups`). A yield moves what such a field holds to the caller, so the field never owns it: an
// overwrite gives nothing back, and the state's release routine skips it.
#define OWN_YIELDED_FIELDS_ATTR_NAME "ts.own_yielded_fields"
#define RETURN_VARIABLE_NAME ".return"
#define CAPTURED_NAME ".captured"
#define LABEL_ATTR_NAME "label"
Expand Down Expand Up @@ -246,6 +251,19 @@
// one re-parsed from a TypeScript library, whose functions may destroy what they are given.
#define NO_DROPS_DECORATOR "nodrops"
#define OWN_DECLARED_NO_DROPS_ATTR_NAME "ts.own_declared_no_drops"
// `@keeps("p")` and `@borrows("this", "p")`: the facts of a function's body that its callers rely
// on (OWN_PARAMS_ATTR_NAME, OWN_RESULT_BOUNDED_ATTR_NAME), said in its signature, so every caller
// knows them - one in another module too, which reads them from the declaration it re-parses.
// `@keeps` names the parameters the function keeps: its callers move them in. `@borrows` names the
// arguments a generator's state object borrows: the caller owns the result, but it may not outlive
// them. -mm=own checks a body against what it says; the other memory models ignore both. Each is a
// FuncOp attribute of argument indices.
#define KEEPS_DECORATOR "keeps"
#define BORROWS_DECORATOR "borrows"
#define OWN_DECLARED_KEEPS_ATTR_NAME "ts.own_declared_keeps"
#define OWN_DECLARED_BORROWS_ATTR_NAME "ts.own_declared_borrows"
// the names of the arguments of a function that says either, for -mm=own's errors about it
#define OWN_DECLARED_PARAM_NAMES_ATTR_NAME "ts.own_declared_param_names"

// The process-heap allocator of a Windows module (TypeScript/ProcessHeap.h), which ProcessHeapPass
// calls instead of the C runtime's. Defined by the async runtime library a program links,
Expand Down
15 changes: 14 additions & 1 deletion tslang/include/TypeScript/LowerToLLVM/OwnershipRoutineLogic.h
Original file line number Diff line number Diff line change
Expand Up @@ -743,9 +743,16 @@ class OwnershipRoutineLogic
auto loc = op->getLoc();
auto llvmRecordType = tch.convertType(recordType);
auto borrowing = borrowingFieldsOf(recordType);
auto yielded = fieldsOf(OWN_YIELDED_FIELDS_ATTR_NAME, recordType);

for (auto [index, fieldType] : llvm::enumerate(getFieldTypes(recordType)))
{
// a generator's local that only yields: what it held went to the caller
if (llvm::is_contained(yielded, static_cast<int32_t>(index)))
{
continue;
}

// a field of a generator's state object that borrows from the generator's arguments:
// nothing to give back, but for the `.captured` box itself, whose cells are the state's
auto borrows = llvm::is_contained(borrowing, static_cast<int32_t>(index));
Expand Down Expand Up @@ -956,6 +963,12 @@ class OwnershipRoutineLogic
// Under own, the fields of a generator's state object that borrow (OWN_BORROWING_FIELDS_ATTR_NAME),
// the `.captured` box's first; empty for any other record or model.
llvm::SmallVector<int32_t> borrowingFieldsOf(mlir::Type recordType)
{
return fieldsOf(OWN_BORROWING_FIELDS_ATTR_NAME, recordType);
}

// Under own, the fields a module attribute of [[type, [field...]], ...] lists for this record.
llvm::SmallVector<int32_t> fieldsOf(llvm::StringRef attrName, mlir::Type recordType)
{
llvm::SmallVector<int32_t> fields;
if (compileOptions.memoryModel != MemoryModelOwn)
Expand All @@ -969,7 +982,7 @@ class OwnershipRoutineLogic
}

auto parentModule = op->getParentOfType<ModuleOp>();
auto entries = parentModule ? parentModule->getAttrOfType<ArrayAttr>(OWN_BORROWING_FIELDS_ATTR_NAME) : ArrayAttr();
auto entries = parentModule ? parentModule->getAttrOfType<ArrayAttr>(attrName) : ArrayAttr();
if (!entries)
{
return fields;
Expand Down
4 changes: 3 additions & 1 deletion tslang/include/TypeScript/MLIRLogic/MLIRDeclarationPrinter.h
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ namespace typescript
void printTypeDeclaration(StringRef, NamespaceInfo::TypePtr, mlir::Type);
void printEnum(StringRef, NamespaceInfo::TypePtr, mlir::DictionaryAttr);
void printVariableDeclaration(StringRef, NamespaceInfo::TypePtr, mlir::Type, bool, StringRef = StringRef());
void print(StringRef, NamespaceInfo::TypePtr, mlir_ts::FunctionType, StringRef = StringRef());
void print(StringRef, NamespaceInfo::TypePtr, mlir_ts::FunctionType, StringRef = StringRef(),
ArrayRef<int32_t> ownKeeps = {}, ArrayRef<int32_t> ownBorrows = {});
void print(ClassInfo::TypePtr);
void print(InterfaceInfo::TypePtr);
void printGenericClass(NamespaceInfo::TypePtr, StringRef);
Expand All @@ -39,6 +40,7 @@ namespace typescript
bool filterName(StringRef);
bool filterField(mlir::Attribute);
void printParams(ArrayRef<mlir::Type>, mlir::Type);
void printOwnMarkers(ArrayRef<int32_t>, ArrayRef<int32_t>, bool, unsigned);
void printFunction(StringRef, ArrayRef<mlir::Type>, mlir::Type);
void printMethod(bool, StringRef, ArrayRef<mlir::Type>, mlir::Type, mlir::Type);
void printAccessor(bool, StringRef, StringRef, mlir_ts::AccessLevel, ArrayRef<mlir::Type>, mlir::Type, mlir::Type, StringRef);
Expand Down
4 changes: 4 additions & 0 deletions tslang/include/TypeScript/MLIRLogic/MLIRGenStore.h
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,10 @@ struct MethodInfo
int cppSlot = -1;
// a slot of a @cpp class's vtable that no method names (ClassInfo::getVirtualTable)
bool isCppSlotGap = false;
// `@keeps`, `@borrows` (OWN_DECLARED_KEEPS_ATTR_NAME): argument indices, `this` 0, for the
// declaration an importer re-parses
std::vector<int32_t> ownKeeps;
std::vector<int32_t> ownBorrows;
};

struct GenericMethodInfo
Expand Down
42 changes: 41 additions & 1 deletion tslang/lib/TypeScript/DeclarationPrinter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,41 @@ namespace typescript
os << ")";
}

// `@keeps("p1")`, `@borrows("this", "p1")`: by the names printParams gives the parameters, `this`
// the receiver of a method, so the importer reads the same argument indices back
void MLIRDeclarationPrinter::printOwnMarkers(ArrayRef<int32_t> keeps, ArrayRef<int32_t> borrows, bool hasThis, unsigned indent)
{
auto printMarker = [&](StringRef name, ArrayRef<int32_t> indices) {
if (indices.empty())
{
return;
}

os.indent(indent);
os << "@" << name << "(";
for (auto [position, index] : enumerate(indices))
{
os << (position ? ", " : "") << "\"";
if (index == 0 && hasThis)
{
os << "this";
}
else
{
os << "p" << index;
}

os << "\"";
}

os << ")";
newline();
};

printMarker(KEEPS_DECORATOR, keeps);
printMarker(BORROWS_DECORATOR, borrows);
}

void MLIRDeclarationPrinter::printFunction(StringRef name, ArrayRef<mlir::Type> params, mlir::Type returnType)
{
os << "function " << name;
Expand Down Expand Up @@ -402,7 +437,8 @@ namespace typescript
printNamespaceEnd(elementNamespace);
}

void MLIRDeclarationPrinter::print(StringRef name, NamespaceInfo::TypePtr elementNamespace, mlir_ts::FunctionType funcType, StringRef dllName)
void MLIRDeclarationPrinter::print(StringRef name, NamespaceInfo::TypePtr elementNamespace, mlir_ts::FunctionType funcType, StringRef dllName,
ArrayRef<int32_t> ownKeeps, ArrayRef<int32_t> ownBorrows)
{
printNamespaceBegin(elementNamespace);

Expand All @@ -414,6 +450,8 @@ namespace typescript
newline();
}

printOwnMarkers(ownKeeps, ownBorrows, /*hasThis=*/false, 0);

printFunction(
name,
funcType.getParams(),
Expand Down Expand Up @@ -660,6 +698,8 @@ namespace typescript
newline();
}

printOwnMarkers(method.ownKeeps, method.ownBorrows, /*hasThis=*/!method.isStatic, 4);

// the slot of a @cpp class's virtual method: without it the importer takes the method
// for a non-virtual one
if (method.cppSlot >= 0)
Expand Down
3 changes: 2 additions & 1 deletion tslang/lib/TypeScript/LowerToLLVM.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2050,7 +2050,8 @@ struct FuncOpLowering : public TsLlvmPattern<mlir_ts::FuncOp>
}

// read by -mm=own only
if (name == OWN_DECLARED_NO_DROPS_ATTR_NAME) {
if (name == OWN_DECLARED_NO_DROPS_ATTR_NAME || name == OWN_DECLARED_KEEPS_ATTR_NAME ||
name == OWN_DECLARED_BORROWS_ATTR_NAME || name == OWN_DECLARED_PARAM_NAMES_ATTR_NAME) {
continue;
}

Expand Down
2 changes: 1 addition & 1 deletion tslang/lib/TypeScript/MLIRGenClasses.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3459,7 +3459,7 @@ genContext);
auto location = loc(funcLikeDeclaration);
auto dllNameAttr = funcOp->getAttrOfType<mlir::StringAttr>(DLL_NAME);
if (mlir::succeeded(mlirGenFunctionLikeDeclarationDynamicImport(
location, funcOp.getName(), funcOp.getFunctionType(), dllNameAttr ? dllNameAttr.getValue() : funcOp.getName(), genContext)))
location, funcOp, funcOp.getName(), funcOp.getFunctionType(), dllNameAttr ? dllNameAttr.getValue() : funcOp.getName(), genContext)))
{
// no need to generate method in code
funcLikeDeclaration->processed = true;
Expand Down
Loading
Loading