From 3680654a7a9659b3beefdaab86142808ac93299f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 13 Jul 2026 08:19:00 +0200 Subject: [PATCH] fix(runtime): `in` on a class instance walks the declared-class prototype MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `for…in` over an instance of a class with an assignment-registered prototype method (`C.prototype.m = fn`) enumerated nothing, so the nightly `issue_5024_class_prototype_assignment_enumeration` suite has been red since #6147 landed (d51b057f4, 2026-07-08). Only the `inst:` line was wrong: forin: int,optional <- correct inst: <- expected "int,optional" keys: ["int"] <- correct in: true <- correct hasOwn: true <- correct Root cause is NOT in the enumeration walk, which is correct: the #6147 for-in desugar (`guard_for_in_body`) re-checks every snapshotted key with `key in obj` so that a key deleted mid-iteration is not visited (EnumerateObjectProperties). `js_for_in_keys_value` collects the inherited keys correctly, and the guard then filtered them straight back out, because `"m" in inst` was `false`. `in` was wrong. `ordinary_has_property`'s chain walk hops a receiver with no recorded static `[[Prototype]]` through `CLASS_PROTOTYPE_OBJECTS` — the synthetic table used by `Object.create` / plain-function constructors. A DECLARED class instance's prototype lives in the separate `CLASS_DECL_PROTOTYPE_OBJECTS` table, which that hop cannot see, so the walk stopped dead at the instance. The tail fallback (`class_instance_has_member`) only covers vtable methods, not methods added by assignment. Meanwhile `js_object_get_prototype_of` *does* resolve the decl-proto — so `in` and `getPrototypeOf` disagreed about the very same prototype chain. Hop the decl-proto too, via the same materializing accessor `js_object_get_prototype_of` uses, so the two agree and the walk is order-independent (`C.prototype.m = fn` runs long before any reflective `C.prototype` read materializes the object). The decl-proto carries the class's own id, so re-resolving it from itself returns the same pointer; stop at that self-edge and let the recorded static prototype carry the walk to the parent. This also fixes `"m" in inst` and `"constructor" in inst` directly, both of which are `true` in Node and were `false` here. --- .../src/object/field_get_set/has_property.rs | 42 ++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/crates/perry-runtime/src/object/field_get_set/has_property.rs b/crates/perry-runtime/src/object/field_get_set/has_property.rs index 676bdb5c9..c5107c85c 100644 --- a/crates/perry-runtime/src/object/field_get_set/has_property.rs +++ b/crates/perry-runtime/src/object/field_get_set/has_property.rs @@ -876,8 +876,46 @@ unsafe fn ordinary_has_property( // and could spuriously hop. Arrays never model a synthetic // prototype, so skip the lookup for them. if !cur_is_array { - let synth_proto = - crate::object::class_prototype_object(unsafe { (*cur).class_id }); + let cur_class_id = unsafe { (*cur).class_id }; + // A DECLARED class instance (`class C {}; new C()`) records no + // static `[[Prototype]]`: its prototype is the reflective + // `C.prototype` object in the SEPARATE `CLASS_DECL_PROTOTYPE_OBJECTS` + // table, which the synthetic-proto lookup below cannot see. + // `js_object_get_prototype_of` already resolves it (that is why + // `Object.getPrototypeOf(inst) === C.prototype` holds), so without + // the same hop here `in` and `getPrototypeOf` disagreed about the + // very same chain: `"m" in new C()` was false for any member that + // is not a vtable method — notably a method added by ASSIGNMENT + // (`C.prototype.m = fn`, stored in `CLASS_PROTOTYPE_METHODS` and + // mirrored onto the decl-proto object), which the + // `class_instance_has_member` vtable fallback below does not cover. + // That divergence silently emptied `for…in` over an instance: the + // #6147 for-in desugar re-checks every snapshotted key with + // `key in obj` (so a key deleted mid-iteration is not visited), and + // this `false` filtered the inherited keys back out again. + // + // Resolve through the materializing accessor — the same one + // `js_object_get_prototype_of` uses — so the walk is + // order-independent: a `C.prototype.m = fn` assignment registers the + // method long before any reflective `C.prototype` read materializes + // the decl-proto object. + if let Some(decl_proto) = + crate::object::class_decl_prototype_value_for_instance_class(cur_class_id) + { + let decl_ptr = (decl_proto.to_bits() & crate::value::POINTER_MASK) + as *const ObjectHeader; + // The decl-proto object is allocated WITH the class's own id + // (`js_object_alloc(class_id, 0)`), so re-resolving it from the + // proto itself yields the same pointer — hopping again would + // spin. Stop at the self-edge; the decl-proto records a real + // static `[[Prototype]]` to its parent, which the walk above + // follows on the next turn. + if !decl_ptr.is_null() && decl_ptr != cur { + cur = decl_ptr; + continue; + } + } + let synth_proto = crate::object::class_prototype_object(cur_class_id); if !synth_proto.is_null() && synth_proto as *const ObjectHeader != cur { cur = synth_proto as *const ObjectHeader; continue;