From 460bd6fb38d7af46f584e1567cdafc7ea986c991 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 12:38:19 -0700 Subject: [PATCH 1/3] 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 --- .../src/frontend/lowering/lower-classes.ts | 10 +++- tests/corpus/dyn-generator-method-value.js | 53 +++++++++++++++++++ .../dyn-generator-method-value.js.json | 1 + 3 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 tests/corpus/dyn-generator-method-value.js create mode 100644 tests/harness/corpus-costs/dyn-generator-method-value.js.json diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 61de74d83..014bbc71a 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -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 diff --git a/tests/corpus/dyn-generator-method-value.js b/tests/corpus/dyn-generator-method-value.js new file mode 100644 index 000000000..f1565aab0 --- /dev/null +++ b/tests/corpus/dyn-generator-method-value.js @@ -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); + }); diff --git a/tests/harness/corpus-costs/dyn-generator-method-value.js.json b/tests/harness/corpus-costs/dyn-generator-method-value.js.json new file mode 100644 index 000000000..cc16fb950 --- /dev/null +++ b/tests/harness/corpus-costs/dyn-generator-method-value.js.json @@ -0,0 +1 @@ +{ "plain": 1349, "san": 2585 } From 34c39e54436875a00bb336fd24dee3cc456540de Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 16:59:25 -0700 Subject: [PATCH 2/3] Throw on empty patterns over checked-dynamic sources An empty binding pattern still performs its one observable step: RequireObjectCoercible for `{}` and GetIterator for `[]`. Over a statically non-nullish source that step is invisible, which is why the empty-pattern lowering emitted nothing; a checked-dynamic source can be null or undefined at runtime, where V8 throws. Emit the runtime null/undefined test for dyn sources in the empty-pattern branch, and let a JS-file empty object pattern take the source-spelling message dynDestrCheck already produces for non-empty ones. Test262 (static-adapted-v6, --workers 8), affected chapters before -> after: language/statements/class/dstr pass 1518 -> 1526, fail 424 -> 416; language/expressions/class/dstr pass 1518 -> 1526, fail 424 -> 416. No pass -> fail. Co-Authored-By: Claude 5.5 --- .../src/frontend/lowering/lower-stmts.ts | 35 ++++++- ...empty-pattern-requires-object-coercible.js | 96 +++++++++++++++++++ ...-pattern-requires-object-coercible.js.json | 1 + 3 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 tests/corpus/8516-empty-pattern-requires-object-coercible.js create mode 100644 tests/harness/corpus-costs/8516-empty-pattern-requires-object-coercible.js.json diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index ee991091f..3cb9ecaee 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -2617,7 +2617,7 @@ export function lowerDestructuringDecl( init.type.kind === "dyn" && ts.isObjectBindingPattern(decl.name) && isJsSourceFile(ts.getSourceFileOfNode(decl)) && - startsWithRuntimeKey(lowerer, decl.name) && + (decl.name.elements.nodes.length === 0 || startsWithRuntimeKey(lowerer, decl.name)) && destrSpellingOf(decl.initializer) !== null; if ( jsRuntimeKeyCheck || @@ -3181,6 +3181,39 @@ export function lowerBindingPattern( // every statically non-nullish kind, record or not (the assignment // path's rule); possibly-nullish sources would throw in JS and keep // a fence. + // + // A CHECKED-DYNAMIC source still owes that step at runtime: an empty + // object pattern over null/undefined throws RequireObjectCoercible, + // and an empty array pattern throws GetIterator's "is not iterable". + // Nothing else about the pattern is observable, so the fence is the + // whole lowering. + if (srcType.kind === "dyn") { + const unitLoc = locOf(pattern); + const isObject = ts.isObjectBindingPattern(pattern); + for (const unit of ["null", "undefined"] as const) { + // V8 describes an argument-derived source by its type ("object + // null"); a bare non-empty pattern names its source instead, but + // those reads throw on their own before this check could fire. + const described = unit === "null" ? "object null" : "undefined"; + const message = isObject + ? `Cannot destructure '${described}' as it is ${unit}.` + : `${described} is not iterable (cannot read property Symbol(Symbol.iterator))`; + out.push({ + kind: "if", + cond: { kind: "dynTest", test: unit, value: srcRef(), type: BOOL, loc: unitLoc }, + then: [ + { + kind: "exprStmt", + expr: nodeThrowExpr(1, "", message, VOID, unitLoc), + loc: unitLoc, + }, + ], + else_: null, + loc: unitLoc, + }); + } + return; + } if ( isUnitType(srcType) || (srcType.kind === "union" && diff --git a/tests/corpus/8516-empty-pattern-requires-object-coercible.js b/tests/corpus/8516-empty-pattern-requires-object-coercible.js new file mode 100644 index 000000000..069ec2f83 --- /dev/null +++ b/tests/corpus/8516-empty-pattern-requires-object-coercible.js @@ -0,0 +1,96 @@ +// An empty destructuring pattern still performs its one observable step: +// RequireObjectCoercible for `{}` and GetIterator for `[]`. A checked-dynamic +// source reaches that step at runtime, so null and undefined throw the same +// TypeError Node does while every other value — including primitives, which +// ToObject wraps — is accepted. The throws are printed through +// `instanceof` rather than the message, since V8 spells the source +// differently per position (an argument is "object null", a declaration +// names its expression). + +function report(label, fn) { + try { + fn(); + console.log(label, "no-throw"); + } catch (error) { + console.log(label, error instanceof TypeError ? "TypeError" : "other"); + } +} + +function acceptsWhatever(value) { + return value; +} + +report("object-null", () => { + const source = acceptsWhatever(null); + const {} = source; +}); +report("object-undefined", () => { + const source = acceptsWhatever(undefined); + const {} = source; +}); + +function objectParameter({}) { + return "bound"; +} +report("param-null", () => { + objectParameter(null); +}); +report("param-undefined", () => { + objectParameter(undefined); +}); +report("param-number", () => { + console.log("param-number returned", objectParameter(7)); +}); +report("param-string", () => { + console.log("param-string returned", objectParameter("text")); +}); + +function arrayParameter([]) { + return "bound"; +} +report("array-param-null", () => { + arrayParameter(null); +}); +report("array-param-undefined", () => { + arrayParameter(undefined); +}); + +function nestedObject({ inner: {} }) { + return "nested"; +} +report("nested-null", () => { + nestedObject({ inner: null }); +}); +report("nested-undefined", () => { + nestedObject({ inner: undefined }); +}); +report("nested-object", () => { + console.log("nested-object returned", nestedObject({ inner: {} })); +}); + +// A loop head rebinds the pattern per iteration, exactly like a declaration. +report("for-of-object", () => { + for (const {} of acceptsWhatever([null])) { + console.log("never"); + } +}); + +// Non-nullish primitives are accepted by both pattern kinds. +report("primitives", () => { + const numbers = [0, "", false, 1, "x"]; + for (const value of numbers) { + const {} = value; + console.log("destructured", JSON.stringify(value), typeof value); + } +}); + +// The assignment form keeps its own (already exact) check. +report("assignment-null", () => { + let bound; + ({} = bound); +}); +report("assignment-number", () => { + let bound = 5; + ({} = bound); + console.log("assignment-number finished"); +}); diff --git a/tests/harness/corpus-costs/8516-empty-pattern-requires-object-coercible.js.json b/tests/harness/corpus-costs/8516-empty-pattern-requires-object-coercible.js.json new file mode 100644 index 000000000..e683aaf34 --- /dev/null +++ b/tests/harness/corpus-costs/8516-empty-pattern-requires-object-coercible.js.json @@ -0,0 +1 @@ +{ "plain": 2400, "san": 24000 } From 083d8e7aa33e5996b4132fbd5a2882b56967feb0 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Wed, 28 Oct 2026 18:42:33 -0700 Subject: [PATCH 3/3] Record the ts7 baselines of the new corpus programs Co-Authored-By: Claude 5.5 --- .../8516-empty-pattern-requires-object-coercible.js.json | 6 ++++++ .../tests/corpus/dyn-generator-method-value.js.json | 6 ++++++ 2 files changed, 12 insertions(+) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8516-empty-pattern-requires-object-coercible.js.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dyn-generator-method-value.js.json diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8516-empty-pattern-requires-object-coercible.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8516-empty-pattern-requires-object-coercible.js.json new file mode 100644 index 000000000..bff29a8e0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8516-empty-pattern-requires-object-coercible.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8516-empty-pattern-requires-object-coercible.js" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dyn-generator-method-value.js.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dyn-generator-method-value.js.json new file mode 100644 index 000000000..1e51be568 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/dyn-generator-method-value.js.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/dyn-generator-method-value.js" + ], + "diags": [] +}