|
| 1 | +# Generator (bound-method-typed) function parameters: pass-by-reference design |
| 2 | + |
| 3 | +Status: **REJECTED — superseded by `docs/generator-object-wrapper-design.md`.** |
| 4 | +The RefType-parameter approach documented below was analyzed and dropped: the |
| 5 | +user proposed the better alternative of making the generator wrapper itself a |
| 6 | +reference type (`ObjectType`) instead of a value tuple, which fixes parameter |
| 7 | +aliasing, const storage, and closure capture at the root with none of the |
| 8 | +function-type-equality blast radius described in §3 below. This document is |
| 9 | +kept as the record of why the RefType path was not taken. |
| 10 | + |
| 11 | +Branch: `generator-param-by-ref`. See `docs/const-let-storage-design.md` §3b |
| 12 | +for the bug this addresses and the [[generator-param-value-semantics-bug]] |
| 13 | +memory for the original repro. |
| 14 | + |
| 15 | +## 1. The bug, restated precisely |
| 16 | + |
| 17 | +`const-let-storage-rework` (merged, PR #244) gave `const` bindings whose type |
| 18 | +has a bound-method field (currently: generator wrapper objects) real storage |
| 19 | +at declaration time — fixing state loss for `.next()` calls made *within the |
| 20 | +same function*. It explicitly did not fix the case where such a value is |
| 21 | +passed as a function **parameter**: `.next()` calls made inside the callee |
| 22 | +still don't advance the caller's binding, because the value is copied at the |
| 23 | +call boundary, not aliased. |
| 24 | + |
| 25 | +## 2. Why there is no way to avoid touching the function's ABI type |
| 26 | + |
| 27 | +Confirmed by tracing the pipeline (agent research, not yet re-verified line |
| 28 | +by line at implementation time — recheck before coding): |
| 29 | + |
| 30 | +- `mlirGenFunctionParams` (`MLIRGenFunctions.cpp:1026-1070`) wraps every |
| 31 | + incoming block argument in `mlir_ts::ParamOp`, unconditionally built as |
| 32 | + `RefType::get(param->getType())` seeded from `arguments[index]` |
| 33 | + (`MLIRGenFunctions.cpp:1057-1058`). |
| 34 | +- `ParamOpLowering` (`LowerToAffineLoops.cpp:183-193`) lowers `ParamOp` to a |
| 35 | + **fresh** `mlir_ts::VariableOp` (a new alloca), always — it does not matter |
| 36 | + what `arguments[index]`'s type is; a new box is minted and the incoming |
| 37 | + value is stored into it as the initializer. |
| 38 | +- `arguments` (the entry block's arguments) are not built independently — |
| 39 | + they come from `FunctionOpInterface::addEntryBlock()`, which derives one |
| 40 | + block argument per input type in the `FuncOp`'s **current `FunctionType`** |
| 41 | + (`MLIRGenFunctions.cpp:1208`, `:1317`). Confirmed no separate/parallel |
| 42 | + argument-list construction exists. |
| 43 | + |
| 44 | +Consequence: **the only way a genuine pointer can arrive in the callee's |
| 45 | +block argument is for the function's declared `FunctionType` to say the |
| 46 | +parameter's type is `RefType<T>`, not `T`.** There is no codegen-only trick |
| 47 | +(no "pass the ref through some side channel") that bypasses this — the ABI is |
| 48 | +fixed by the type the `FuncOp` was built with. This is different from the |
| 49 | +const-storage fix, which only had to change a *local declaration's* storage |
| 50 | +decision and never touched a cross-function-boundary type. |
| 51 | + |
| 52 | +## 3. The blast radius this creates |
| 53 | + |
| 54 | +Once a parameter's registered type becomes `RefType<T>` instead of `T` |
| 55 | +(for `T` = a `TupleType`/`ConstTupleType` with a bound-method field, per the |
| 56 | +existing `MLIRTypeHelper::hasBoundMethodField` predicate from the merged |
| 57 | +const-storage fix), every place that compares function *types* structurally |
| 58 | +for equality now sees a mismatch against "the same" function's other |
| 59 | +appearances (e.g. a `let` variable of function type, a generic instantiation, |
| 60 | +a `ReturnType<typeof gen>` query, an assignability check at a call site |
| 61 | +against a differently-sourced signature of the same shape). Found so far: |
| 62 | + |
| 63 | +- `MLIRTypeHelper::TestFunctionTypesMatch` (`MLIRTypeHelper.h:905-934`): raw |
| 64 | + `inInputs[i] != resInputs[i]` per-parameter equality (line 922). Callers at |
| 65 | + `MLIRTypeHelper.h:1136, 1368, 1411, 1474, 1723` — assignability/overload/ |
| 66 | + generic-instantiation matching all funnel through this one function, so a |
| 67 | + fix localized here (e.g. treat `T` and `RefType<T>` as equal specifically |
| 68 | + when `hasBoundMethodField(T)`) would cover all of these callers uniformly. |
| 69 | +- A **second**, separate comparator exists nearby (`MLIRTypeHelper.h` around |
| 70 | + line 1209-1216) using a recursive `canMatch(location, ...)` instead of raw |
| 71 | + `!=` for a different function-type-matching path (different call sites, |
| 72 | + different `startParam`/opaque-`this`-skipping logic). Not yet confirmed |
| 73 | + whether `canMatch` already tolerates a `RefType` wrapper or would also need |
| 74 | + a fix. **This must be checked before implementation** — if `canMatch` |
| 75 | + already unwraps refs generically (plausible, since it's used for broader |
| 76 | + structural compatibility, e.g. object/unknown per its inline comment), |
| 77 | + this path may already be fine; if not, it needs the same treatment as |
| 78 | + `TestFunctionTypesMatch`. |
| 79 | +- Not yet audited: generic type-parameter inference/matching (`MLIRGenGenerics.cpp`, |
| 80 | + `tryInferTupleFields`-family in `MLIRTypeHelper.h`), and whatever backs |
| 81 | + `ReturnType<typeof gen>`-style type queries — these may do their own |
| 82 | + independent structural comparison rather than funneling through either |
| 83 | + matcher above. Must be enumerated before implementation, not discovered |
| 84 | + reactively via ctest failures. |
| 85 | + |
| 86 | +This is a materially bigger blast radius than the const-storage fix, which |
| 87 | +touched exactly 3 files and ~70 lines. The precedent that this *kind* of |
| 88 | +special-casing is tractable exists — `OptionalType` is special-cased at |
| 89 | +similar density throughout this file (e.g. `MLIRGenImpl.h:1690`) — but the |
| 90 | +touch points here are scattered across a type-matching subsystem that has no |
| 91 | +single choke point guaranteeing full coverage the way `registerVariable` was |
| 92 | +a single choke point for the storage decision. |
| 93 | + |
| 94 | +## 4. Proposed approach (once implementation starts) |
| 95 | + |
| 96 | +1. **Enumerate every structural function-type comparison site first**, |
| 97 | + exhaustively, before writing the parameter-type change — grep for |
| 98 | + `FunctionType` type-equality comparisons (`!=`, `==`) and every |
| 99 | + `canMatch`/`TestFunctionTypesMatch` caller, not just the ones surfaced by |
| 100 | + one research pass. Build a checklist; don't rely on ctest to find the |
| 101 | + rest, since the original const-storage fix already took 3 rounds of full |
| 102 | + suite runs to surface all issues on a *much smaller* change. |
| 103 | +2. Add a single normalization helper, e.g. |
| 104 | + `MLIRTypeHelper::stripIdentityRef(mlir::Type t)` — returns `t`'s element |
| 105 | + type if `t` is `RefType<U>` and `hasBoundMethodField(U)`, else `t` |
| 106 | + unchanged. Use it to normalize both sides immediately before every |
| 107 | + structural comparison found in step 1, rather than special-casing each |
| 108 | + comparator's internals differently. |
| 109 | +3. Change parameter type registration (`mlirGenFunctionSignaturePrototype`, |
| 110 | + `MLIRGenImpl.h:1660-1701`, feeding `getFunctionType` via |
| 111 | + `mlirGenFunctionPrototype`, `MLIRGenFunctions.cpp:249-330`) to wrap a |
| 112 | + bound-method-bearing parameter's type in `RefType` when building `argTypes` |
| 113 | + for the `FuncOp`'s `FunctionType` — mirroring how optional params are |
| 114 | + special-cased at `MLIRGenImpl.h:1690-1693`. |
| 115 | +4. Change `mlirGenFunctionParams` (`MLIRGenFunctions.cpp:1026-1070`): when |
| 116 | + `param->getType()` is already the caller-visible `RefType` (i.e. this |
| 117 | + parameter took the new path), do NOT wrap it in another `ParamOp`→fresh |
| 118 | + `VariableOp`; bind the variable directly to the incoming block argument |
| 119 | + (which is already the caller's storage pointer) instead of allocating and |
| 120 | + copying. |
| 121 | +5. Change call-site operand building |
| 122 | + (`mlirGenAdjustOperandTypes`, `MLIRGenImpl.h:6394-6455`, specifically the |
| 123 | + `value.getType() != argTypeDestFuncType` branch around line 6445): when the |
| 124 | + destination type is `RefType<T>` for a `hasBoundMethodField` `T`, obtain |
| 125 | + the operand's *reference* instead of loading — `MLIRCodeLogic::GetReferenceFromValue` |
| 126 | + (`MLIRCodeLogic.h:122-155`) already unwraps a `LoadOp` back to its |
| 127 | + `.getReference()`, and `resolveIdentifierAsVariable` |
| 128 | + (`MLIRGenVariables.cpp:919`) already emits that `LoadOp`, so the ref is |
| 129 | + recoverable at this point without restructuring identifier resolution. |
| 130 | +6. Test incrementally, per the const-storage fix's proven workflow: target |
| 131 | + repro first (`00generator_manual_next2.ts`'s `drainTwo` case, extended to |
| 132 | + continue driving `it` after the call — this is exactly the case that file |
| 133 | + currently deliberately avoids exercising), then the same previously-fragile |
| 134 | + set (`00disposable`/`01disposable`/`02disposable`, `00spread`, `01symbol`, |
| 135 | + any test exercising function-type assignability or generics with |
| 136 | + function-typed values), then full suite. Expect multiple rounds. |
| 137 | + |
| 138 | +## 5. Open questions to resolve before coding starts |
| 139 | + |
| 140 | +- Does `canMatch` (§3, second comparator) already tolerate `RefType` |
| 141 | + wrappers generically? If yes, step 1's checklist shrinks by one entry. |
| 142 | +- Are there other constructs beyond generators that already have |
| 143 | + `hasBoundMethodField(T) == true` today, or could soon (e.g. an object |
| 144 | + literal with a bound method, not just the generator wrapper)? If so, this |
| 145 | + fix benefits them for free, but the checklist in step 1 must consider |
| 146 | + those call shapes too, not just `function* gen(){}`. |
| 147 | +- Is there a case where a bound-method-bearing value is passed *by value on |
| 148 | + purpose* (e.g. intentionally snapshotting a generator's current state into |
| 149 | + a helper that must not mutate the caller's copy)? TypeScript itself has no |
| 150 | + such distinction (objects are always reference semantics at the language |
| 151 | + level) — but confirm no existing test relies on the current (arguably |
| 152 | + accidental) copy-by-value behavior as if it were a feature. |
| 153 | + |
| 154 | +## 6. Non-goals (unchanged from the merged fix) |
| 155 | + |
| 156 | +- No change to `const`/`let` reassignment semantics. |
| 157 | +- No storage changes for classes/arrays/plain objects — still unaffected, |
| 158 | + still already pointer-like. |
| 159 | +- No `DominanceInfo` introduction. |
0 commit comments