fix(runtime): in on a class instance walks the declared-class prototype (unblocks nightly)#6347
Conversation
…type `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 (d51b057, 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.
📝 WalkthroughWalkthrough
ChangesDeclared prototype traversal
Estimated code review effort: 2 (Simple) | ~10 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
crates/perry-runtime/src/object/field_get_set/has_property.rs (1)
878-923: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsider extracting the declared/synthetic prototype hop into a helper.
This block (declared-proto attempt + self-edge guard + synthetic-proto fallback) is the densest branch in an already long loop, matching the flagged high-complexity range. Extracting it into a small
unsafe fn resolve_class_prototype_hop(cur, cur_class_id) -> Option<*const ObjectHeader>would let the loop body read as a flatif let Some(next) = ... { cur = next; continue; }, with no behavior change.♻️ Proposed extraction
+#[inline] +unsafe fn resolve_class_prototype_hop( + cur: *const ObjectHeader, + cur_class_id: u32, +) -> Option<*const ObjectHeader> { + 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; + if !decl_ptr.is_null() && decl_ptr != cur { + return Some(decl_ptr); + } + } + let synth_proto = crate::object::class_prototype_object(cur_class_id); + if !synth_proto.is_null() && synth_proto as *const ObjectHeader != cur { + return Some(synth_proto as *const ObjectHeader); + } + None +} + if !cur_is_array { let cur_class_id = unsafe { (*cur).class_id }; - 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; - 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; - } + if let Some(next) = resolve_class_prototype_hop(cur, cur_class_id) { + cur = next; + continue; + } } break;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/perry-runtime/src/object/field_get_set/has_property.rs` around lines 878 - 923, Extract the declared-prototype lookup, null/self-edge guard, and synthetic-prototype fallback from the loop into an unsafe helper such as resolve_class_prototype_hop(cur, cur_class_id), returning the next ObjectHeader pointer when available. Replace the inline branch with a single optional-hop check that updates cur and continues; preserve the existing ordering and behavior exactly.Source: Linters/SAST tools
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@crates/perry-runtime/src/object/field_get_set/has_property.rs`:
- Around line 878-923: Extract the declared-prototype lookup, null/self-edge
guard, and synthetic-prototype fallback from the loop into an unsafe helper such
as resolve_class_prototype_hop(cur, cur_class_id), returning the next
ObjectHeader pointer when available. Replace the inline branch with a single
optional-hop check that updates cur and continues; preserve the existing
ordering and behavior exactly.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: a9ab47a3-00e6-48d5-9f95-2c721bd4279b
📒 Files selected for processing (1)
crates/perry-runtime/src/object/field_get_set/has_property.rs
Unblocks the nightly
The nightly
cargo-testjob has been red sinced51b057f4— "fix(runtime,lower): in-operator walks Object.create protos; for-in skips deleted keys (#6147)", 2026-07-08 — on:This restores it to green. Both tests in the suite pass, and the compiled program is byte-for-byte identical to
node --experimental-strip-types.The failure
Exactly one of the five asserted lines was wrong —
for…inover an instance:Bisect
7724c039e(parent of #6147)d51b057f4(#6147)main735e89480(main)Parent good + commit bad ⇒ first bad commit. Both endpoints were built from source (compiler and both static archives); the good endpoint was verified good before being trusted.
Note this exonerates
5afa08fca(the #5874 revert, 2026-07-05), the commit the red date superficially pointed at: the verified-good endpoint7724c039esits after it.Root cause
The enumeration walk was never at fault. #6147 added
guard_for_in_body(perry-hir/src/lower/for_head.rs), which re-checks every snapshotted key withkey in objso a key deleted mid-iteration is not visited (EnumerateObjectProperties).js_for_in_keys_valuecollected the inherited keys correctly — and the new guard then filtered them straight back out, because"m" in instwasfalse.So
inwas the bug. Inordinary_has_property, a receiver with no recorded static[[Prototype]]hops throughCLASS_PROTOTYPE_OBJECTS— the synthetic table used byObject.create/ plain-function constructors. A declared-class instance's prototype lives in the separateCLASS_DECL_PROTOTYPE_OBJECTStable, 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 a method added by assignment (C.prototype.m = fn, which lives inCLASS_PROTOTYPE_METHODSand is mirrored onto the decl-proto object).Meanwhile
js_object_get_prototype_ofdoes resolve the decl-proto — which is whyObject.getPrototypeOf(inst) === C.prototypeheld all along. The two prototype-resolution paths disagreed about the very same chain, and #6147 madefor…independ on the one that was wrong.Fix
One hop, in the
None(no recorded static prototype) arm ofordinary_has_property: resolve the decl-proto too, through the same materializing accessorjs_object_get_prototype_ofuses, soinandgetPrototypeOfagree.Using the materializing accessor keeps the walk order-independent —
C.prototype.m = fnruns at module init, long before any reflectiveC.prototyperead materializes the object. The decl-proto carries the class's ownclass_id, so re-resolving it from itself returns the same pointer; the code stops at that self-edge and lets the decl-proto's recorded static[[Prototype]]carry the walk up to the parent class.This also fixes, directly, two things that are
truein Node and werefalsehere:Validation
cargo test -p perry --test issue_5024_class_prototype_assignment_enumeration— 2 passed, 0 failed.node --experimental-strip-types, as are three additional hand-written probes covering instance/prototypefor…in,Object.keys,getOwnPropertyNames,in, andhasOwnProperty.test-files/test_gap_*suite (296 tests) A/B'd against a pristineorigin/mainbuild: zero regressions.The A/B used two fully independent toolchains — each with its own
perryand its ownlibperry_runtime.a/libperry_stdlib.a, withPERRY_RUNTIME_DIRpinned to its own directory (verified by distinct archive SHA-256s, and by the baseline still reproducing the bug).perry-runtime/perry-stdlibarecrate-type = ["rlib"], so building them does not refresh the archives thatperry compilelinks — the.afiles come from theperry-runtime-static/perry-stdlib-staticwrapper crates (#5422). Building only the rlib crates leaves both sides of an A/B linking the same stale archive, which makes the comparison vacuous.Why CI missed it for 5 days
.github/workflows/test.ymlrunscargo test -p perry(thetests/*.rsintegration suite, this one included) only on the full nightly / tag path; the per-PR path deliberately skips it for runtime. So #6147 could land without CI ever executing this test.Summary by CodeRabbit
inchecks andgetPrototypeOfbehavior for members added to class prototypes.