Skip to content

Commit 7dc8ff8

Browse files
Box the generator wrapper as a heap-allocated ObjectType instead of a value tuple (#245)
Object literals in this compiler compile to value-typed tuples by default, even though the generator wrapper ({step, next()}) has mutable identity that must be shared across aliases. This meant passing a generator to a function, capturing it in a closure, or reassigning it (const b = a) all silently copied its state instead of aliasing it. Add InternalFlags::BoxAsObject to mark the synthetic wrapper literal built by buildGeneratorWrapperDeclaration, and heap-box it (NewOp + StoreOp + CastOp, the same recipe castTupleToInterface already uses) into ObjectType instead of leaving it as a tuple. Property access on ObjectType already emits a PropertyRefOp directly on the pointer, so .next() now mutates shared storage for every alias. Supersedes the const-storage-only fix in #244 for generators specifically, and fixes the parameter-aliasing bug that fix explicitly left open. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent a8f4b14 commit 7dc8ff8

6 files changed

Lines changed: 340 additions & 15 deletions

File tree

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# Generator wrapper as a reference type (`ObjectType`): design
2+
3+
Status: **implemented and verified** — 350/350 JIT + 354/354 compile on the
4+
first full-suite round, plus new aliasing regression coverage (parameter,
5+
closure capture, plain assignment) added to
6+
`test/tester/tests/00generator_manual_next2.ts`. The chronic "losing this
7+
reference" warning on generator tests is gone, confirming the value/`this`
8+
representation mismatch theory (§3). Implementation matched the plan in §4
9+
exactly; step 4 (for...of) required no changes — iteration discovers `next`
10+
via `evaluateProperty`, which flows through the generic property-access
11+
machinery that already handles `ObjectType`.
12+
Supersedes the rejected `docs/generator-param-by-ref-design.md` (RefType
13+
parameters). Proposed by the user; verified feasible by code inspection.
14+
15+
## 1. The idea
16+
17+
Stop representing the generator wrapper (`{ step, next() {...} }`, built by
18+
`buildGeneratorWrapperDeclaration`, `MLIRGenFunctions.cpp:531-678`) as a
19+
value-typed tuple. Make it a **reference type**`mlir_ts::ObjectType`, a
20+
pointer to heap storage — the same representation class instances already
21+
have. Anything with identity in JavaScript is a reference type; the wrapper
22+
has mutable identity (`step` advanced by `next()`), so it should be one too.
23+
24+
## 2. Why this beats the RefType-parameter approach
25+
26+
| Concern | RefType params (rejected) | ObjectType wrapper (this) |
27+
| --- | --- | --- |
28+
| Parameter aliasing | fixed only at fn boundaries, via ABI change | fixed — callee copies the *pointer* |
29+
| `const g = gen(); g.next()` | needs PR #244's storage fix | works even for a bare SSA const (pointer has identity) |
30+
| Closure capture | needs by-ref capture flag | pointer copied by value is already correct |
31+
| Function-type equality (`TestFunctionTypesMatch`, `canMatch`, generics, `ReturnType<>`) | must treat `T`/`RefType<T>` as equal at every comparison site — scattered, unbounded checklist | untouched — the wrapper is *one* type everywhere |
32+
| Cost | none | one GC heap alloc per generator instantiation (semantically correct; it's what JS engines do) |
33+
34+
## 3. Verified enabling facts (file:line)
35+
36+
- **Boxing recipe already exists**: `castTupleToInterface`
37+
(`MLIRGenCast.cpp:1574-1579`) boxes a tuple with exactly
38+
`NewOp(ValueRefType<tuple>)` + `StoreOp` + `CastOp``ObjectType`. `NewOp`
39+
heap allocation is GC-managed (same as class instances) — solves the
40+
escapes-`gen()`-frame lifetime question.
41+
- **Property access on `ObjectType` is already correct**: the TypeSwitch case
42+
(`MLIRGenAccessCall.cpp:302`) → `MLIRPropertyAccessCodeLogic::Object`
43+
`RefLogic` (`MLIRCodeLogic.h:1623-1671`) emits `PropertyRefOp` **directly
44+
on the pointer** — a real field address into shared storage. No fresh
45+
alloca, no pristine-copy seeding, no `boundRefMaterializedCache`. The
46+
entire bug family lives only in the tuple access path (`Tuple`/
47+
`TupleNoError`), which `ObjectType` never enters.
48+
- **Methods already expect an `ObjectType` `this`**: object-literal codegen
49+
builds `oli.objThis = getObjectType(objectStorageType)`
50+
(`MLIRGenExpressions.cpp:1298-1301`) as the `this` type for the literal's
51+
methods (the `USE_BOUND_FUNCTION_FOR_OBJECTS` mechanism;
52+
`isBoundReference`, `MLIRTypeHelper.h:203-219`, recognizes first-param
53+
`ObjectType` as bound). Only the produced *value* is currently a tuple
54+
(`MLIRGenExpressions.cpp:1330-1342`) — the value/this representation
55+
mismatch is likely also the source of the persistent "losing this
56+
reference" warnings on every generator test.
57+
- **Marker mechanism exists**: synthetic AST already carries codegen hints in
58+
`internalFlags` (`VarsInObjectContext` set at `MLIRGenFunctions.cpp:598`,
59+
`ThisArgAlias` at `:607`; enum at `ts-new-parser/enums.h:544-560`, bits
60+
free from `1 << 11`).
61+
- **Return-type inference needs no help**: the wrapper function has no
62+
explicit return-type node; `discoverFunctionReturnTypeAndCapturedVars`
63+
takes the type of the returned value. Box the literal → the wrapper's
64+
return type *becomes* `ObjectType` automatically, consistently, everywhere
65+
(including pass-2 method-prototype registration from PR #243, which runs
66+
the same discovery).
67+
- **`ObjectType` → interface casts already exist** (`castObjectToInterface`,
68+
`MLIRGenCast.cpp:1584+`), so iterable-as-interface usage keeps working.
69+
70+
## 4. Implementation plan
71+
72+
1. Add `InternalFlags::BoxAsObject = 1 << 11` (`ts-new-parser/enums.h`).
73+
2. `buildGeneratorWrapperDeclaration` (`MLIRGenFunctions.cpp:618`): set the
74+
flag on the synthetic `generatorObject` literal — one line.
75+
3. `mlirGen(ObjectLiteralExpression)` (`MLIRGenExpressions.cpp:1270-1343`):
76+
when the flag is set, after building the (const-)tuple value: cast
77+
const-tuple → mutable tuple (`convertConstTupleTypeToTupleType``step`
78+
must be mutable at runtime), then box via `NewOp` + `StoreOp` + `CastOp`
79+
to `oli.objThis` (the *named* `ObjectType<objectStorageType>` the methods'
80+
`this` already expects — not an anonymous `ObjectType::get(tupleType)`),
81+
and return the pointer value.
82+
4. Check `for...of` / iterator-protocol lowering: find where `next` is
83+
discovered on the iterated expression's type (search `ITERATOR_NEXT`
84+
consumers); if it inspects tuple fields directly, teach it to look through
85+
`ObjectType::getStorageType()`. Same check for `MLIRTypeHelper::getFields`
86+
(`MLIRTypeHelper.h:2133-2220`) if anything getFields-based touches the
87+
wrapper.
88+
5. Build + test rounds (proven workflow): target repros first
89+
(`00generator_manual_next.ts`, `00generator_manual_next2.ts`,
90+
`00generator7.ts`, plus **extend** `00generator_manual_next2.ts`'s `main2`
91+
to finally drive `it` past `drainTwo` — the case it deliberately avoided —
92+
and a new closure-capture-mutation case), then the fragile set
93+
(`00disposable`/`01`/`02`, `00spread`, `01symbol`), then full ctest suite.
94+
6. If green: the `[value, done]` result tuple stays a value tuple
95+
(deliberately — it has no mutable identity); PR #244's
96+
`needsIdentityStorage` machinery and the `boundRefMaterializedCache`
97+
become dead for generators — leave both in place, remove in a later
98+
cleanup PR (same bisectability reasoning as before).
99+
100+
## 5. Risks / open items
101+
102+
- `for...of`/spread lowering shape (step 4) is the main unknown — not yet
103+
read.
104+
- Generator methods in classes (`class C { *gen(){} }`) and object literals
105+
(`{ *gen(){} }`) share `buildGeneratorWrapperDeclaration`, so they change
106+
uniformly — but class-method `this` interplay (`fixThisReference` path,
107+
`MLIRGenFunctions.cpp:534-546, 604-614, 628-637`) needs a test pass.
108+
- Compile-output tests that print the wrapper's type will change text.
109+
- Async generators / `for await` (`ForAwait` flag) — check whether that path
110+
builds its own wrapper or shares this one.
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
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.

tslang/lib/TypeScript/MLIRGenExpressions.cpp

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1332,14 +1332,43 @@ namespace mlirgen
13321332
auto arrayAttr = mlir::ArrayAttr::get(builder.getContext(), oli.values);
13331333
auto constantVal =
13341334
builder.create<mlir_ts::ConstantOp>(location, constTupleTypeWithReplacedThis, arrayAttr);
1335-
if (oli.fieldsToSet.empty())
1335+
1336+
auto boxAsObject =
1337+
(objectLiteral->internalFlags & InternalFlags::BoxAsObject) == InternalFlags::BoxAsObject;
1338+
1339+
if (oli.fieldsToSet.empty() && !boxAsObject)
13361340
{
13371341
return V(constantVal);
13381342
}
13391343

13401344
auto tupleType = mth.convertConstTupleTypeToTupleType(constantVal.getType());
1341-
auto tupleValue = mlirGenCreateTuple(location, tupleType, constantVal, oli.fieldsToSet, genContext);
1342-
return V(tupleValue);
1345+
1346+
mlir::Value tupleValue;
1347+
if (!oli.fieldsToSet.empty())
1348+
{
1349+
tupleValue = mlirGenCreateTuple(location, tupleType, constantVal, oli.fieldsToSet, genContext);
1350+
}
1351+
else
1352+
{
1353+
CAST_A(castedValue, location, tupleType, constantVal, genContext);
1354+
tupleValue = castedValue;
1355+
}
1356+
1357+
if (!boxAsObject)
1358+
{
1359+
return V(tupleValue);
1360+
}
1361+
1362+
// this literal has mutable identity (e.g. a generator wrapper whose `step`
1363+
// is advanced by next()) -- box it on the GC heap and hand out a
1364+
// reference-typed ObjectType so every alias (const binding, parameter,
1365+
// closure capture) shares the same state; same recipe as castTupleToInterface
1366+
auto objType = mlir_ts::ObjectType::get(tupleType);
1367+
auto valueAddr =
1368+
builder.create<mlir_ts::NewOp>(location, mlir_ts::ValueRefType::get(tupleType), builder.getBoolAttr(false));
1369+
builder.create<mlir_ts::StoreOp>(location, tupleValue, valueAddr);
1370+
auto objValue = builder.create<mlir_ts::CastOp>(location, objType, valueAddr);
1371+
return V(objValue);
13431372
}
13441373

13451374
ValueOrLogicalResult MLIRGenImpl::mlirGen(Identifier identifier, const GenContext &genContext)

tslang/lib/TypeScript/MLIRGenFunctions.cpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,10 @@ namespace mlirgen
616616
generatorObjectProperties.push_back(nextMethodDecl);
617617

618618
auto generatorObject = nf.createObjectLiteralExpression(generatorObjectProperties, false);
619+
// the generator object has mutable identity (`step` advanced by next());
620+
// it must be a reference type so aliases (params, closures, const bindings)
621+
// share state -- box it on the GC heap instead of the default value tuple
622+
generatorObject->internalFlags |= InternalFlags::BoxAsObject;
619623

620624
// copy location info, to fix issue with names of anonymous functions
621625
generatorObject->pos = functionLikeDeclarationBaseAST->pos;

0 commit comments

Comments
 (0)