Keep a generator method's generator ABI through the prototype-access widening

A class whose methods are reachable through `X.prototype` widens its method
signatures to checked values so a JS caller can hand anything across. The
widening replaced every non-void return with DYN, which erased a GENERATOR
method's generator type; the generator branch below then had no generator
channels to build its metadata from and refused the whole class (SC2001 in
`%init`, re-emitted as a runtime fence in a JS file). Every
`assert.sameValue(c.m, C.prototype.m)` variant of the campaign therefore
refused at runtime, 759 of them in the generator-method chapters alone.

A generator's return kind is already a checked-crossable ABI: dynFrom boxes a
generator by reference and dyn calls resume it through the dyn generator ops,
so only the arguments need the widening. Guard the return widening to
non-generator methods.

Test262, 1,110 affected variants, before/after on the candidate
(class/gen-method 52->76, class/async-gen-method 76->128,
class/scope-gen-meth 0->8); no pass->fail and no typed-path delta.

Co-Authored-By: Claude 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-28 19:51:09 -07:00
co-authored by Claude 5.5
parent 45f4f0d47e
commit 460bd6fb38
3 changed files with 63 additions and 1 deletions
@@ -3451,7 +3451,15 @@ export function collectClassShapeInner(
if (shape.bodyType) shape.bodyType = DYN;
}
ft.params = shapes.map((shape) => shape.type);
if (ft.ret.kind !== "void") ft.ret = DYN;
// A GENERATOR method's result is the suspended generator its spawn
// wrapper answers, so the generator kind IS the checked-crossable
// ABI: dynFrom boxes a generator by reference and dyn calls resume
// it through the dyn generator ops. Replacing it with plain DYN
// would leave the method no representation at all (the generator
// branch below needs the channels to build its metadata), which
// fenced every `c.m` read on a prototype-accessed class. Only the
// ARGUMENTS cross as checked values here.
if (ft.ret.kind !== "void" && member.asteriskToken === undefined) ft.ret = DYN;
}
// JS subclasses can add parameters to a method. Reserve checked
// argument slots in the base before constructing its vtable; shorter
@@ -0,0 +1,53 @@
/* A generator method VALUE crossing the checked-value boundary.
*
* A class whose methods are reachable through its prototype object answers
* its methods as checked (dynamic) values: the value read off an instance is
* the same function object the prototype carries, a checked callback can hold
* it and call it, and the generator object it produces keeps the generator
* protocol across the box. Node is the oracle. */
class Seq {
constructor(tag) {
this.tag = tag;
}
*items(n) {
for (let i = 0; i < n; i++) yield this.tag + ":" + i;
return n;
}
async *later(n) {
yield n + 1;
return n;
}
}
function sameValue(actual, expected, label) {
if (actual !== expected) throw new Error("not the same: " + label);
}
function typeofOf(value) {
return typeof value;
}
function callWith(receiver, fn, ...args) {
return fn.apply(receiver, args);
}
const seq = new Seq("a");
sameValue(seq.items, Seq.prototype.items, "instance method value");
sameValue(seq.items, seq.items, "repeated read");
sameValue(seq.later, Seq.prototype.later, "async instance method value");
console.log(typeofOf(seq.items), typeofOf(seq.later));
const gen = callWith(seq, seq.items, 2);
console.log(gen.next().value, gen.next().value);
const finished = gen.next();
console.log(finished.value, finished.done);
// The direct call answers its own generator object. (Identity and reflection
// on a generator object are separate, still-fenced operations.)
const direct = seq.items(1);
console.log(direct.next().value);
// An async generator value crosses the same boundary.
callWith(seq, seq.later, 4)
.next()
.then(function (step) {
console.log("async", step.value, step.done);
});
@@ -0,0 +1 @@
{ "plain": 1349, "san": 2585 }