From 5b8893c405f60ecc34620ab15e99002ec1d3dd89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 24 Jun 2026 16:00:49 +0200 Subject: [PATCH] fix(runtime): bind inherited prototype-accessor methods to the receiver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a method call `inst.m()` resolves `m` to an ACCESSOR (getter) installed on a prototype via `Object.defineProperty(proto, "m", { get })`, the getter must run with `this === inst` (spec `[[Get]](P, Receiver)`), not `this === proto` (the object the accessor lives on). `dispatch_handle`'s two prototype-method resolution paths invoked the getter with the prototype as the receiver: - the `ResolvedMethod::ProtoClosure` walk called the raw, receiver-less `js_object_get_field_by_name(proto_obj, key)`; - the registry-`None` (`Object.create(objLiteral).m()`) fallback called the receiver-less `resolve_proto_chain_field`. Both now stash the receiver (`accessor_receiver_override` + `IMPLICIT_THIS` — the mechanism `resolve_inherited_field` / `resolve_proto_chain_field_with_receiver` already use), so `invoke_accessor_getter` rebinds `this` to the instance. This surfaced in a large minified CLI bundle built on a schema-validation library that lazily installs methods such as `describe`/`clone` on the shared class prototype as accessors whose getter does `Object.defineProperty(this, k, { value: fn.bind(this) })`. With `this === prototype`, the bound method baked `this = prototype` and was cached on the prototype, so the no-op `clone_closure_rebind_this` (a `.bind()`-bound function has no `CAPTURES_THIS_FLAG`) could not recover the instance. Every downstream read of an instance-only field then returned `undefined` (`Cannot read properties of undefined`). Verified byte-for-byte against Node for describe/clone/optional/nullable/array/ object parse+safeParse/meta/default/refine method chains, and for an `Object.create` inherited-accessor method. `cargo test -p perry-runtime -p perry-codegen` stays green (1079 passed, 0 failed). --- .../native_call_method/handle_methods.rs | 43 +++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/crates/perry-runtime/src/object/native_call_method/handle_methods.rs b/crates/perry-runtime/src/object/native_call_method/handle_methods.rs index 036e2b3431..1077365544 100644 --- a/crates/perry-runtime/src/object/native_call_method/handle_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/handle_methods.rs @@ -891,10 +891,39 @@ pub(super) unsafe fn dispatch_handle( method_name.as_ptr(), method_name.len() as u32, ); + // An inherited method that resolves to an ACCESSOR + // on the prototype must observe the instance as + // `this` (spec `[[Get]](P, Receiver)`), not the + // prototype object the getter lives on. Stash the + // receiver so `invoke_accessor_getter` rebinds it. + // Without this, a schema library's lazily-installed + // `describe`/`clone` accessors — `Object.define- + // Property(proto, k, { get() { const b = fn.bind(this); + // Object.defineProperty(this, k, { value: b }); return b } })` + // on the shared class prototype — run with + // `this === prototype`, bake `this = prototype` into + // the returned bound method, and cache it on the + // prototype. Every downstream read of an + // instance-only field via `this.` then + // returns `undefined` (`Cannot read properties of + // undefined`) even though the instance has it. + // Mirrors `resolve_proto_chain_field_with_receiver` + // (the winston `get transports()` fix). + let receiver_f64 = f64::from_bits(jsval.bits()); + let prev_this = + IMPLICIT_THIS.with(|c| c.replace(receiver_f64.to_bits())); + let prev_override = + super::super::field_get_set::accessor_receiver_override_begin( + receiver_f64, + ); let field_val = js_object_get_field_by_name( proto_obj as *const _, method_key as *const crate::StringHeader, ); + super::super::field_get_set::accessor_receiver_override_end( + prev_override, + ); + IMPLICIT_THIS.with(|c| c.set(prev_this)); if !field_val.is_undefined() && !field_val.is_null() { resolved_method = Some(ResolvedMethod::ProtoClosure { field_bits: field_val.bits(), @@ -966,9 +995,17 @@ pub(super) unsafe fn dispatch_handle( method_name.as_ptr(), method_name.len() as u32, ); - if let Some(field_val) = - resolve_proto_chain_field(class_id, method_key as *const crate::StringHeader) - { + // `_with_receiver` binds an inherited ACCESSOR getter's `this` + // to the instance (not the prototype it lives on), matching the + // ProtoClosure walk above and spec `[[Get]](P, Receiver)`. A + // prototype `Object.defineProperty(proto, k, { get })` reached + // through the registry-`None` (`Object.create(objLiteral)`) path + // would otherwise observe the prototype as `this`. + if let Some(field_val) = resolve_proto_chain_field_with_receiver( + class_id, + method_key as *const crate::StringHeader, + f64::from_bits(jsval.bits()), + ) { if !field_val.is_undefined() && !field_val.is_null() { // #321 (effect Context/Layer/Scope): the closure we // just resolved is an *inherited* method — by