Skip to content

fix(runtime): in on a class instance walks the declared-class prototype (unblocks nightly)#6347

Merged
proggeramlug merged 1 commit into
mainfrom
fix/5024-prototype-enumeration-regression
Jul 13, 2026
Merged

fix(runtime): in on a class instance walks the declared-class prototype (unblocks nightly)#6347
proggeramlug merged 1 commit into
mainfrom
fix/5024-prototype-enumeration-regression

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Unblocks the nightly

The nightly cargo-test job has been red since d51b057f4"fix(runtime,lower): in-operator walks Object.create protos; for-in skips deleted keys (#6147)", 2026-07-08 — on:

cargo test -p perry --test issue_5024_class_prototype_assignment_enumeration
  assignment_registered_prototype_methods_are_enumerable ... FAILED

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…in over an instance:

   forin: int,optional      correct
   inst:                    <-- expected "int,optional"
   keys: ["int"]            correct
   in: true                 correct
   hasOwn: true             correct

Bisect

commit result
7724c039e (parent of #6147) GOOD
d51b057f4 (#6147) BAD — same signature as main
735e89480 (main) BAD

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 endpoint 7724c039e sits 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 with key in obj so a key deleted mid-iteration is not visited (EnumerateObjectProperties). js_for_in_keys_value collected the inherited keys correctly — and the new guard then filtered them straight back out, because "m" in inst was false.

So in was the bug. In ordinary_has_property, a receiver with no recorded static [[Prototype]] hops 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 a method added by assignment (C.prototype.m = fn, which lives in CLASS_PROTOTYPE_METHODS and is mirrored onto the decl-proto object).

Meanwhile js_object_get_prototype_of does resolve the decl-proto — which is why Object.getPrototypeOf(inst) === C.prototype held all along. The two prototype-resolution paths disagreed about the very same chain, and #6147 made for…in depend on the one that was wrong.

Fix

One hop, in the None (no recorded static prototype) arm of ordinary_has_property: resolve the decl-proto too, through the same materializing accessor js_object_get_prototype_of uses, so in and getPrototypeOf agree.

Using the materializing accessor keeps the walk order-independent — C.prototype.m = fn runs at module init, long before any reflective C.prototype read materializes the object. The decl-proto carries the class's own class_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 true in Node and were false here:

class C {}
(C as any).prototype.m = function () {};
const c: any = new C();
"m" in c            // was false -> now true
"constructor" in c  // was false -> now true

Validation

  • cargo test -p perry --test issue_5024_class_prototype_assignment_enumeration2 passed, 0 failed.
  • Test program byte-identical to node --experimental-strip-types, as are three additional hand-written probes covering instance/prototype for…in, Object.keys, getOwnPropertyNames, in, and hasOwnProperty.
  • Full test-files/test_gap_* suite (296 tests) A/B'd against a pristine origin/main build: zero regressions.

The A/B used two fully independent toolchains — each with its own perry and its own libperry_runtime.a / libperry_stdlib.a, with PERRY_RUNTIME_DIR pinned to its own directory (verified by distinct archive SHA-256s, and by the baseline still reproducing the bug). perry-runtime / perry-stdlib are crate-type = ["rlib"], so building them does not refresh the archives that perry compile links — the .a files come from the perry-runtime-static / perry-stdlib-static wrapper 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.yml runs cargo test -p perry (the tests/*.rs integration 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

  • Bug Fixes
    • Fixed prototype-chain traversal for declared class instances.
    • Corrected in checks and getPrototypeOf behavior for members added to class prototypes.
    • Preserved accurate prototype lookup after prototype replacements.

…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.
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

ordinary_has_property now traverses materialized declared class instance prototypes before synthetic prototypes when no recorded [[Prototype]] exists, preserving prototype-chain behavior for assigned class prototype members.

Changes

Declared prototype traversal

Layer / File(s) Summary
Prototype-walk fallback
crates/perry-runtime/src/object/field_get_set/has_property.rs
ordinary_has_property resolves declared class instance prototypes, prevents self-referential hops, and otherwise retains the synthetic-prototype fallback.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

  • PerryTS/perry#5478: Adds assignment-registered members to declared prototype objects, which this change traverses.
  • PerryTS/perry#5861: Modifies the same ordinary_has_property prototype traversal path.
  • PerryTS/perry#6147: Updates related fallback behavior in the same runtime prototype-walk logic.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description has strong content, but it does not follow the required template headings and omits the checklist/related-issue structure. Reformat the PR text to the required template with Summary, Changes, Related issue, Test plan, Screenshots/output, and Checklist sections filled in.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and accurately summarizes the main runtime fix for class-instance prototype lookup.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/5024-prototype-enumeration-regression

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
crates/perry-runtime/src/object/field_get_set/has_property.rs (1)

878-923: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider 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 flat if 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

📥 Commits

Reviewing files that changed from the base of the PR and between be5ed2b and 3680654.

📒 Files selected for processing (1)
  • crates/perry-runtime/src/object/field_get_set/has_property.rs

@proggeramlug proggeramlug merged commit 108bdf2 into main Jul 13, 2026
25 checks passed
@proggeramlug proggeramlug deleted the fix/5024-prototype-enumeration-regression branch July 13, 2026 11:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant