|
| 1 | +# `@keeps` and `@borrows`: ownership facts in a function's signature |
| 2 | + |
| 3 | +Date: 2026-10-19. Status: implemented (branch `own-param-markers`), with the own generator fixes |
| 4 | +`matchAll` needed. |
| 5 | + |
| 6 | +## Why |
| 7 | + |
| 8 | +After `@nodrops` and `@final` (PR #611), the default library still did not build under `-mm=own`. |
| 9 | +The remaining errors had one cause, "decision B": an exported function keeps or borrows a |
| 10 | +parameter. OwnershipSignaturePass computes these facts from the body (`__own_params`, |
| 11 | +`__own_result_bounded`). It drops them for any function a caller outside the module may reach (it |
| 12 | +is "open"), because a caller that does not know them borrows an argument the callee keeps, or |
| 13 | +releases a result the callee still points into. Either way that frees twice. The sites were |
| 14 | +`Response.constructor` (keeps `headers`), `___String.matchAll` and `String[Symbol.iterator]` |
| 15 | +(generators whose state borrows `this` and `expr`), and the `String.matchAll` wrapper. |
| 16 | + |
| 17 | +The user chose markers: the facts become part of the function's declared signature, so every |
| 18 | +caller knows them, including one in another module, which re-parses the declaration. |
| 19 | + |
| 20 | +## The markers |
| 21 | + |
| 22 | +Both go on a function, method or constructor, and name its parameters. `"this"` names the receiver. |
| 23 | + |
| 24 | +- `@keeps("headers", "body")`: the function keeps these arguments. Every caller moves them in. |
| 25 | +- `@borrows("this", "expr")`: the caller owns the result, but the result may not outlive these |
| 26 | + arguments. The result is either the state of a generator over them, or what a call with such a |
| 27 | + bounded result returns (`return this.value.matchAll(expr)`, a "forwarder"). |
| 28 | + |
| 29 | +Under gc, rc and none they mean nothing. MLIRGen still records them, and the LLVM lowering drops |
| 30 | +the attributes. |
| 31 | + |
| 32 | +## How it works |
| 33 | + |
| 34 | +- **MLIRGen** (`processOwnParamMarkers`) turns the names into argument indices (FuncOp attributes |
| 35 | + `ts.own_declared_keeps` and `ts.own_declared_borrows`). It also records the argument names |
| 36 | + (`ts.own_declared_param_names`) for the errors. A name that is not a parameter is an error. The |
| 37 | + generator wrapper copies the method's decorators, so a marked generator's maker carries them. |
| 38 | +- **OwnershipSignaturePass:** |
| 39 | + - A marked function's `@keeps` facts stand even when it is open (`meetFacts`). A family does not |
| 40 | + erase them. |
| 41 | + - A call gets what its callees' signatures say, whether or not it is resolved |
| 42 | + (`declaredByCallees`, over `Callees::named`). The named callees are the direct callee, every |
| 43 | + member of a virtual slot's family, or a dynamic import's address global. |
| 44 | + - A call whose callees say `@borrows` gets `__own_result_bounded` and `__own_fresh_result`: the |
| 45 | + caller owns the state. |
| 46 | + - `findBorrowingStates` accepts a marked maker even though it is public, and a method reached |
| 47 | + through its vtable. It also accepts `this` held by value (`.this`, read from the parameter's |
| 48 | + slot). |
| 49 | + - `findBoundedForwarders` gives a marked non-generator what its returned bounded calls borrow, |
| 50 | + mapped back to its own parameters (directly, or a field read of one). |
| 51 | + - `checkDeclared` holds each body to its signature, exactly. It also checks that the overrides in |
| 52 | + a marked method's family say the same. |
| 53 | +- **OwnershipInferencePass:** a marked function may return a bounded result (`boundedUses`). |
| 54 | +- **Across modules:** |
| 55 | + - `__decls` prints the markers with the printed parameter names (`p1`, and `this` for a |
| 56 | + receiver). `MethodInfo::ownKeeps` and `ownBorrows` carry them for methods; |
| 57 | + `addFunctionDeclarationToExport` gets them from the FuncOp. |
| 58 | + - A dynamic import's function is a global holding its address, so |
| 59 | + `mlirGenFunctionLikeDeclarationDynamicImport` copies the declaration's markers onto that global. |
| 60 | + - DefaultLib's `lib.d.ts` says the same as `lib.ts`. |
| 61 | + |
| 62 | +## Found on the way and fixed here |
| 63 | + |
| 64 | +- **A silent miscompile.** A public generator method over `this` with no marker compiled under own |
| 65 | + (release), and the state's release freed the caller's object. The caller then freed it a second |
| 66 | + time, and the program exited 127 with no output. `classifyStateBoxes` let a non-borrowing box |
| 67 | + take rc's reference for a parameter the function does not own. It now leaves that reference |
| 68 | + alone, so the existing "is a parameter, kept here" error fires (`own_err_generator_method_this`). |
| 69 | + `--di` already errored there, by accident: its `ts.DebugVariable` use broke the one-use check |
| 70 | + that paired the reference. |
| 71 | +- **`--di`.** `retainBefore` and `twinReadAfter` now ignore `ts.DebugVariable` uses (`soleUser`), |
| 72 | + so a marked generator over `this` builds with debug info too. |
| 73 | + |
| 74 | +## Generator locals under own |
| 75 | + |
| 76 | +`matchAll`'s body needed three fixes to own generators that markers could not give. Each is |
| 77 | +covered by `own_generator_locals.ts`, under gc, rc and own. |
| 78 | + |
| 79 | +- **Narrowing.** A generator's locals are fields of its state object, so narrowing one |
| 80 | + (`if (result == null) break;`) made a second field, filled from the first: a second reference. |
| 81 | + `addSafeCastStatement` now declares a generator's narrowed local as a view of the local's own |
| 82 | + storage (`VariableDeclarationDOM::narrowedType`). Each read loads the local and casts it, so a |
| 83 | + read after a resume sees the current value. An SSA copy would not: it read stale values after a |
| 84 | + yield, and gc printed 3 where 9 is right. |
| 85 | +- **A spread in a generator.** `[...a]` is a `for...of` that MLIRGen writes, and its `.a` alias of |
| 86 | + the source array became a field holding a second reference. The loops of the spread, `forEach`, |
| 87 | + `every` and `some` hold no yield, so their locals stay in the frame (`allocateVarsInContextThis` |
| 88 | + and `allocateVarsOutsideOfOperation` off). `isBuiltArrayReturned` also accepts the built array |
| 89 | + moved into a field, `h.c = [...a]` or a generator's local. |
| 90 | +- **A yielded local.** `yield groups` read the field into the `{value, done}` record, a borrow of |
| 91 | + the state. OwnershipSignaturePass now finds the state fields that are read for nothing but |
| 92 | + yields, where each stored value is yielded later in its block (`findYieldedFields`). The yield's |
| 93 | + read is fresh (the value moves to the caller), an overwrite gives nothing back, and the state's |
| 94 | + release routine skips the field (`OWN_YIELDED_FIELDS_ATTR_NAME`). `matchAll` computes its |
| 95 | + empty-match skip from `result` before the yield, so `groups` is only yielded. |
| 96 | +- **The owned-return pass took a generator's `next` for a class method.** OwnedReturnConsumptionPass |
| 97 | + matches a virtual call's candidates by member name. A class's `next()` was grouped with every |
| 98 | + generator's `next`, whose record does not classify as owned, so `src.next()` lost |
| 99 | + `__owned_result` and own reported a second reference. Only class methods (a class first) are |
| 100 | + candidates now. |
| 101 | + |
| 102 | +With these, `lib.ts` compiles under gc, rc, none and own, release and `--di`. DefaultLib's |
| 103 | +`build.bat` and `build.sh` build own as a fourth model. |
| 104 | + |
| 105 | +## Not done (follow-ups) |
| 106 | + |
| 107 | +- **Iterating another module's generator under own** works now. The user ruled that what an |
| 108 | + iterator's `next()` returns is the caller's, through the `Iterator` interface or into another |
| 109 | + module, and only that, not every unresolved call. OwnershipSignaturePass marks such calls fresh |
| 110 | + (`isIteratorNext`: a callee named `next` returning `{value, done}`), and so is a call that |
| 111 | + returns a generator's state object (`makesGeneratorState`), which a generator function always |
| 112 | + makes. `isFresh` reads that mark before it rejects a declared callee, and `walkBorrowed` does not |
| 113 | + follow a fresh result that is not bounded. Another module's generator state is released here |
| 114 | + without its fields (`findImportedStates`): only the box's cells, and their values unless the |
| 115 | + maker says `@borrows`. What its locals still hold when it is given up early leaks. A release |
| 116 | + made from the shape freed the borrowed argument a second time (exit 127). `import_final_class` |
| 117 | + and `import_own_markers` now run under own too. `own_iterator_next.ts` covers the |
| 118 | + interface-shaped call in one module, which already worked. |
| 119 | +- **An own DefaultLib returns corrupted regex strings.** This is on main, and it is not this |
| 120 | + branch's. `let buffer = "".clone().resize(len)` (`MatchResults.get`) borrows a temporary that is |
| 121 | + freed after the statement, and own does not reject it. It needs its own fix. |
| 122 | +- **A generator local's previous value leaks** under rc and own (on main too): a `const` declared in |
| 123 | + a loop inside a generator is stored into its state field again each iteration, and nothing gives |
| 124 | + back what the last iteration stored. Releasing it there fixes rc, but under own it makes `next` |
| 125 | + a call that may drop, and a caller's `for...of` over such a generator is then rejected (its |
| 126 | + loop variable borrows the record). The fix wants the caller-side `for...of` move as well. |
| 127 | +- **A generator local's type from a function declared later** is `none` in the state object, and |
| 128 | + the lowering fails ("failed to legalize operation 'ts.Cast'") under every model. `matchAll` |
| 129 | + annotates `const more: boolean`. |
| 130 | +- **`@borrows` for an ordinary function whose result points into an argument** |
| 131 | + (`__own_result_borrows`, where the caller owns nothing) is not expressible yet. Such a function |
| 132 | + is still rejected when it is open. |
| 133 | +- **A ternary that picks an array literal or `null`** (`return i < 3 ? ["a"] : null`) fails MLIR |
| 134 | + verification ("operand #0 does not dominate this use") under rc and own. This is unrelated. |
| 135 | +- **Android** still builds gc, rc and none only. |
0 commit comments