From 66da5b82c918f275f33866ebda5398c9174b6e16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 1 Sep 2026 12:00:13 +0200 Subject: [PATCH] =?UTF-8?q?fix(codegen):=20#9369=20=E2=80=94=20a=20static?= =?UTF-8?q?=20body's=20`this`=20is=20the=20class,=20not=20an=20instance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A class carrying a generic computed member lost `this.prototype` (and every other static-`this` read, and static-`this` writes) inside all of its static methods, while the same property read through the class's own binding answered correctly — one function, two answers. `class_has_computed_runtime_members` says the class's INSTANCES have keys the packed shape does not describe, so an instance access must go by name. The by-name helper strips the receiver NaN-box to a raw `ObjectHeader*`. But `receiver_class_name` answers with the owning class for `Expr::This` in a static body just as in an instance body (both read `class_stack`), and a static body's `this` is the class CONSTRUCTOR — an INT32 class ref. Masking it handed the runtime the bare class id as a pointer, below the handle band, so every such read answered `undefined`; `this.name` survived only because `js_object_get_field_by_name_f64` already reads a small-integer receiver back as a class id for that one key. `FnCtx::in_static_member` records what the receiver-class answer cannot, and the computed-member read/store routes consult it before treating a proven class name as a claim about the receiver's layout. Static bodies fall through to the general dispatch tower, which classifies the receiver tag and has a class-ref arm — the same lowering the identical static method already got when its class carried no computed member. This is what took `cc --help` down (#9341): #9315 routed well-known-symbol computed members onto the generic path, and axios's `AxiosHeaders` pairs `[Symbol.iterator]()` with `static accessor(){ let z = this.prototype; … }`. Refs #9369, #9341. Claude-Session: https://claude.ai/code/session_014knX724SYDogwzsXybCGxp --- ...tatic-this-is-the-class-not-an-instance.md | 47 +++++++++++++++++++ crates/perry-codegen/src/codegen/closure.rs | 6 +++ crates/perry-codegen/src/codegen/entry.rs | 2 + crates/perry-codegen/src/codegen/function.rs | 1 + crates/perry-codegen/src/codegen/method.rs | 2 + crates/perry-codegen/src/expr/mod.rs | 38 +++++++++++++++ crates/perry-codegen/src/expr/property_get.rs | 24 +++++++++- crates/perry-codegen/src/expr/property_set.rs | 9 +++- ...st_gap_9369_static_this_computed_member.ts | 43 +++++++++++++++++ 9 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 changelog.d/9369-static-this-is-the-class-not-an-instance.md create mode 100644 test-files/test_gap_9369_static_this_computed_member.ts diff --git a/changelog.d/9369-static-this-is-the-class-not-an-instance.md b/changelog.d/9369-static-this-is-the-class-not-an-instance.md new file mode 100644 index 0000000000..eccbc9173d --- /dev/null +++ b/changelog.d/9369-static-this-is-the-class-not-an-instance.md @@ -0,0 +1,47 @@ +**A class with a computed-key member no longer loses `this` inside its static +methods** — `this.prototype`, `this.` and static-`this` writes +answer what node answers, which is what brought `cc --help` back (#9369, +unblocking #9341). + +The reduced case was two answers to one question inside one function: + +```js +const K = "dyn" + "Key"; +class E { + [K]() { return 1; } + static probe() { return typeof this.prototype; } // perry: "undefined" +} +typeof E.prototype // perry: "object" +``` + +`class_has_computed_runtime_members` is a statement about a class's +*instances*: their key set is not described by the packed shape, so an +instance read has to go by name. Codegen applied it to every receiver whose +proven class had computed members, and `receiver_class_name` answers with the +owning class for `Expr::This` in a static body exactly as it does in an +instance body — both read `class_stack`. But a static body's `this` is the +class CONSTRUCTOR, an INT32-tagged class ref, and the by-name helper strips +the receiver NaN-box to a raw `ObjectHeader*`. The class ref's tag was masked +away, so the runtime received the bare class id as a pointer — below the +handle band, therefore not an object, therefore `undefined`. `this.name` was +the one survivor, because `js_object_get_field_by_name_f64` already reads a +small-integer receiver back as a class id for that single key. + +`FnCtx::in_static_member` now records the distinction the receiver-class +answer cannot carry, and the computed-member routes (read and store) ask +before treating a proven class name as a claim about the receiver's layout. +Static bodies fall through to the general dispatch tower, which classifies +the receiver tag and already has a class-ref arm — so a static method of a +computed-member class now lowers exactly like the same method on a class +without one. + +#9315 is what made this reach a real workload: it stopped giving +`[Symbol.iterator]`, `[Symbol.asyncIterator]`, `[Symbol.toPrimitive]` and +`[util.inspect.custom]` special lowering, so the common well-known-symbol +members became generic computed members. axios's `AxiosHeaders` has a +non-generator `[Symbol.iterator]()` and a `static accessor()` whose first act +is `let z = this.prototype`, and `Object.defineProperty(undefined, …)` threw +on every `cc --help`. The gap fixture +`test_gap_9369_static_this_computed_member.ts` pins all five member kinds +that take the generic path, plus static-before/after-computed, an +instance-method control, and the named-binding read that used to disagree. diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index 15de3d15c7..e4554bf2d8 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -1075,6 +1075,12 @@ pub(super) fn compile_closure( this_stack, new_target_stack, class_stack, + // Closures are compiled from their own HIR node; the enclosing + // member's staticness is not carried on it, so an arrow inside a + // static body still lowers `this` as an instance receiver. Tracked + // separately from #9369, whose fixture family is the static body + // itself. + in_static_member: false, super_called_stack: Vec::new(), shared_super_scope_active: false, lexical_this_uses_derived_binding: captures_this diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 9ed2a64cd3..54908c2a3b 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -807,6 +807,7 @@ pub(super) fn compile_module_entry( inline_ctor_return: Vec::new(), new_target_stack: Vec::new(), class_stack: Vec::new(), + in_static_member: false, methods, module_globals, import_function_prefixes, @@ -1519,6 +1520,7 @@ pub(super) fn compile_module_entry( inline_ctor_return: Vec::new(), new_target_stack: Vec::new(), class_stack: Vec::new(), + in_static_member: false, methods, module_globals, import_function_prefixes, diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index 964e7aabce..e55b21841b 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -1047,6 +1047,7 @@ pub(super) fn compile_function( inline_ctor_return: Vec::new(), new_target_stack: Vec::new(), class_stack: Vec::new(), + in_static_member: false, methods, module_globals, import_function_prefixes, diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index 48f9b7000c..b5943f4c0a 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -453,6 +453,7 @@ pub(super) fn compile_method( inline_ctor_return: Vec::new(), new_target_stack: Vec::new(), class_stack: vec![class.name.clone()], + in_static_member: false, methods, module_globals, import_function_prefixes, @@ -1626,6 +1627,7 @@ pub(super) fn compile_static_method( // `super.x` in a static method resolves against the parent's static // side, mirroring instance-method setup. class_stack: vec![class.name.clone()], + in_static_member: true, methods, module_globals, import_function_prefixes, diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 65d802f026..4982db1486 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -415,6 +415,21 @@ pub(crate) struct FnCtx<'a> { /// find the parent class's constructor to inline. Same depth as /// `this_stack` (one entry per nested `new`). pub class_stack: Vec, + /// True while lowering the body of a STATIC class member (method, static + /// accessor, static computed member) — i.e. a body compiled by + /// `compile_static_method`, whose `this` slot holds the CLASS + /// CONSTRUCTOR, not an instance. + /// + /// `class_stack` names the owning class in a static body too (it is what + /// `super.x` resolves against), and `receiver_class_name(Expr::This)` + /// reads `class_stack.last()`. That answer is right for an instance + /// method and wrong here: a static body's `this` is the INT32-tagged + /// class ref `0x7FFE_0000_0000_00cc`, never a heap instance of the class. + /// Lowerings that turn a proven receiver class into an INSTANCE-shaped + /// access — anything that strips the NaN-box to a raw `ObjectHeader*`, or + /// loads a packed field slot — must consult this flag before trusting + /// `Expr::This` (#9369). + pub in_static_member: bool, /// Method registry: `(class_name, method_name) → LLVM function name`. /// Built by `compile_module` from `hir.classes[*].methods`. Used by /// `lower_call` to dispatch `obj.method(args)` to the right @@ -2424,6 +2439,29 @@ mod inline_cache_name_tests { } impl<'a> FnCtx<'a> { + /// Is `e` the `this` of a STATIC class member — i.e. a receiver that holds + /// the class CONSTRUCTOR (an INT32 class ref) rather than an instance? + /// + /// `receiver_class_name` answers `Some()` for `Expr::This` + /// in a static body just as it does in an instance body, because both read + /// `class_stack`. Callers that go on to treat that class name as a + /// statement about the receiver's LAYOUT — stripping the NaN-box to an + /// `ObjectHeader*`, indexing a packed field slot — are only entitled to do + /// so for an instance, so they ask this first (#9369). + /// + /// Sees through `Expr::PrivateGuard`, which returns its receiver + /// unchanged, mirroring `receiver_class_name`'s own arm for it. + pub(crate) fn is_static_class_this(&self, e: &perry_hir::Expr) -> bool { + if !self.in_static_member { + return false; + } + match e { + perry_hir::Expr::This => true, + perry_hir::Expr::PrivateGuard { object, .. } => self.is_static_class_this(object), + _ => false, + } + } + /// Return runtime-derived initializer evidence only when no write anywhere /// in this region can have invalidated it. /// diff --git a/crates/perry-codegen/src/expr/property_get.rs b/crates/perry-codegen/src/expr/property_get.rs index d672999b0d..cf03f43d5f 100644 --- a/crates/perry-codegen/src/expr/property_get.rs +++ b/crates/perry-codegen/src/expr/property_get.rs @@ -1350,7 +1350,29 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { { return lower_runtime_property_get_by_name(ctx, object, property); } - if class_has_computed_runtime_members(ctx, &class_name) { + // #9369: "this class has computed members" is a statement + // about its INSTANCES — their key set is not described by the + // packed shape, so an instance read must go by name. It says + // nothing about a STATIC body's `this`, which is the class + // constructor: an INT32 class ref, not a heap instance. + // `lower_runtime_property_get_by_name` strips the NaN-box to a + // raw `ObjectHeader*`, so routing a class ref through it hands + // the runtime the bare class id as a pointer — below the + // handle band, so every read answered `undefined` (except + // `name`, which `js_object_get_field_by_name_f64` already + // rescues by reading that small integer back as a class id). + // That is why `E.prototype` and `this.prototype` disagreed + // inside one class, and why axios's + // `static accessor(){ let z = this.prototype; … }` fed + // `undefined` to `Object.defineProperty` once #9315 routed + // `[Symbol.iterator]` onto the computed-member path (#9341). + // Falling through leaves the general dispatch tower below, + // which classifies the receiver tag and has a class-ref arm — + // exactly what the same static method gets when its class + // carries no computed member. + if class_has_computed_runtime_members(ctx, &class_name) + && !ctx.is_static_class_this(object) + { return lower_runtime_property_get_by_name(ctx, object, property); } let getter_key = (class_name.clone(), format!("__get_{}", property)); diff --git a/crates/perry-codegen/src/expr/property_set.rs b/crates/perry-codegen/src/expr/property_set.rs index 2fa5c9ef78..6f32c5f57b 100644 --- a/crates/perry-codegen/src/expr/property_set.rs +++ b/crates/perry-codegen/src/expr/property_set.rs @@ -898,7 +898,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { .clone() .or_else(|| guarded_declared_class_store_candidate(ctx, object)) { - if class_has_computed_runtime_members(ctx, &class_name) { + // #9369, store twin of the read gate in `property_get.rs`: + // the computed-member route strips the receiver NaN-box to a + // raw `ObjectHeader*`, which is only meaningful for an + // INSTANCE. A static body's `this` is the class ref, so the + // store landed on the bare class id and was lost. + if class_has_computed_runtime_members(ctx, &class_name) + && !ctx.is_static_class_this(object) + { return lower_runtime_property_set_by_name(ctx, object, property, value); } let setter_key = (class_name.clone(), format!("__set_{}", property)); diff --git a/test-files/test_gap_9369_static_this_computed_member.ts b/test-files/test_gap_9369_static_this_computed_member.ts new file mode 100644 index 0000000000..7cb905a103 --- /dev/null +++ b/test-files/test_gap_9369_static_this_computed_member.ts @@ -0,0 +1,43 @@ +// #9369: a class carrying a generic computed member must not lose `this` in +// its STATIC bodies. `this` there is the class CONSTRUCTOR — an INT32 class +// ref — not an instance, so codegen may not route the read through the +// instance-shaped by-name helper that strips the NaN-box to an +// `ObjectHeader*`. Doing so handed the runtime the bare class id as a +// pointer, and every static-`this` read but `name` answered `undefined` +// while the same property read through the class's own binding answered an +// object — one function, two answers. +// +// The member kinds below are the ones that reach the generic computed path: +// `[Symbol.iterator]` (generator and non-generator), `[Symbol.asyncIterator]`, +// `[Symbol.toPrimitive]`, and a plain computed key. #9315 routed the +// well-known-symbol forms onto it, which is how axios's `AxiosHeaders` +// (`[Symbol.iterator]()` plus `static accessor(){ let z = this.prototype; … }`) +// took `cc --help` down with `Object.defineProperty called on non-object` +// (#9341). + +function T(name, fn) { + try { const r = fn(); console.log(name + " => " + String(r)); } + catch (e) { console.log(name + " !! " + (e && e.message ? e.message : String(e))); } +} +class A { + static before() { return typeof this.prototype; } + toJSON() { return { a: 1 }; } + [Symbol.iterator]() { return Object.entries(this.toJSON())[Symbol.iterator](); } + static after() { return typeof this.prototype; } + method() { return typeof this; } +} +T("static-BEFORE-computed", () => A.before()); +T("static-AFTER-computed", () => A.after()); +T("instance-method-after", () => new A().method()); +T("named-binding", () => typeof A.prototype); + +// which computed keys trigger it? +class B { toJSON(){return{a:1}} [Symbol.asyncIterator]() { return null; } static after() { return typeof this.prototype; } } +T("asyncIterator-trigger", () => B.after()); +class C { toJSON(){return{a:1}} *[Symbol.iterator]() { yield 1; } static after() { return typeof this.prototype; } } +T("generator-iterator-trigger", () => C.after()); +class D { toJSON(){return{a:1}} [Symbol.toPrimitive]() { return 1; } static after() { return typeof this.prototype; } } +T("toPrimitive-trigger", () => D.after()); +const K = "dyn" + "Key"; +class E { toJSON(){return{a:1}} [K]() { return 1; } static after() { return typeof this.prototype; } } +T("plain-computed-key-trigger", () => E.after());