fix(runtime): run the owner's body when a class method value is copied onto another prototype#6398
Conversation
…d onto another prototype
A method value read off a class prototype was a closure that re-resolved the
method *by name against the receiver's class* at call time. Copy that value onto
a different class's prototype and the lookup finds the copy — itself — so it
recursed until the call-depth guard returned the null object, which user code
sees as `[object Object]`:
class Query { rsh() { return "Q.rsh"; } }
class Execute {}
Execute.prototype.rsh = Query.prototype.rsh; // cross-class method copy
new Execute().rsh(); // node: "Q.rsh" perry: [object Object]
`typeof` reported `function` and the call did not throw — it just returned an
object, so the failure surfaced far from its cause.
Per spec a method value is a FIXED function object: reading `C.prototype.m`
yields *that* function, and invoking it later must run that body with the
call-time `this`, never re-dispatch by name. The captured slot-0 prototype-ref
already names the owner class, so dispatch the owner's body through
`lookup_class_method_in_chain` — the same treatment the `#private` arm directly
above already applies. The receiver is still computed by
`canonical_bound_method_receiver`, so `const f = c.m; f()`,
`this.m = this.m.bind(this)` and friends are unchanged.
Behavior that must not regress, and does not: an ordinary `sub.m()` call still
dispatches a subclass override virtually; a method *value* invoked on a subclass
receiver now correctly runs the owner's body (`Query.prototype.m.call(sub)` —
spec, and previously the override ran); method identity (`C.prototype.m ===
C.prototype.m`) still holds.
mysql2's Command state machine copies a block of methods across classes
(`Execute.prototype.resultsetHeader = Query.prototype.resultsetHeader`) and
advances by returning method values, so its resultset parse mis-fired: the
resultset header read `fieldCount` as 0 for a 6-column SELECT, took the
INSERT/OK branch, and threw one packet later. It affects any library using this
common inheritance/mixin idiom.
Found while compiling a real Next.js + MySQL app; covered by
`test_gap_cross_class_prototype_method_copy` (direct call, state-machine value,
override, owner-body-on-subclass-receiver, identity). Byte-identical to node.
📝 WalkthroughWalkthroughThe runtime now directly resolves canonical class-method values through their captured owner class and invokes them with the call-time receiver. A new test covers cross-class prototype copying, deferred calls, subclass overrides, direct invocation, and stable function identity. ChangesFixed owner method values
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 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/closure/dispatch/bound.rs (1)
74-113: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider deduplicating method resolution logic.
The newly added public method dispatch block duplicates the UTF-8 parsing and
lookup_class_method_in_chainlogic already present for private methods (lines 36-63). You can consolidate these two blocks to reduce boilerplate and rightward drift.As per coding guidelines, remember to use
cargo check -p perryandcargo build --profile perry-dev -p perryfor normal local compiler iteration.♻️ Proposed refactor to consolidate method dispatch
You can replace both the old private method block (lines 36-63) and this new public method block with a unified dispatch flow:
let owner_proto_ref = namespace_obj; if method_name_len > 0 && !method_name_ptr.is_null() { if let Some(owner_id) = crate::object::class_prototype_ref_id(owner_proto_ref) { if let Ok(name) = std::str::from_utf8(std::slice::from_raw_parts( method_name_ptr as *const u8, method_name_len, )) { if let Some((func_ptr, param_count, has_synth_args, has_rest)) = crate::object::lookup_class_method_in_chain(owner_id, name) { let call_this = if name.starts_with('#') { crate::object::js_implicit_this_get() } else { crate::object::canonical_bound_method_receiver(owner_proto_ref) }; return crate::object::call_vtable_method( func_ptr, call_this.to_bits() as i64, args.as_ptr(), args.len(), param_count, has_synth_args, has_rest, ); } } } } namespace_obj = crate::object::canonical_bound_method_receiver(owner_proto_ref);🤖 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/closure/dispatch/bound.rs` around lines 74 - 113, Consolidate the private and public dispatch paths in the closure method-dispatch function into one shared UTF-8 parsing and lookup flow using class_prototype_ref_id and lookup_class_method_in_chain. Select js_implicit_this_get for private names and canonical_bound_method_receiver for public names, then invoke call_vtable_method with the selected receiver; preserve the final receiver assignment when lookup does not dispatch. Validate with cargo check -p perry and cargo build --profile perry-dev -p perry.Source: Coding guidelines
🤖 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/closure/dispatch/bound.rs`:
- Around line 74-113: Consolidate the private and public dispatch paths in the
closure method-dispatch function into one shared UTF-8 parsing and lookup flow
using class_prototype_ref_id and lookup_class_method_in_chain. Select
js_implicit_this_get for private names and canonical_bound_method_receiver for
public names, then invoke call_vtable_method with the selected receiver;
preserve the final receiver assignment when lookup does not dispatch. Validate
with cargo check -p perry and cargo build --profile perry-dev -p perry.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: aecca568-9a00-49fe-9358-e928ff44b01a
📒 Files selected for processing (2)
crates/perry-runtime/src/closure/dispatch/bound.rstest-files/test_gap_cross_class_prototype_method_copy.ts
|
The It races the libuv threadpool: the three callback-form fd mutators are dispatched concurrently, so their callbacks fire in completion order. The gap harness compares perry against a freshly run node oracle, and the oracle itself is nondeterministic — across 40 runs, Fix is up as #6400 (buffer the three callback results, flush in a fixed order; assertions and expected output unchanged). Once that lands, rebase or rerun and this should go green. Note |
…ol (#6400) The three callback-form fd mutators are dispatched to the libuv threadpool concurrently, so their callbacks fire in completion order, which is not deterministic. The gap harness compares perry against a freshly run node oracle -- and the *oracle itself* is unstable: across 40 runs of `node --experimental-strip-types` this test produced 4 distinct outputs, emitting `fchown closed cb` before `futimes closed cb` on roughly one run in six. Perry is deterministic here (always submission order), so the test red-lit at random on unrelated PRs -- it took out #6397, #6398 and #6399 today, and cost a full bisect before the oracle was suspected. The test is about the error surface (err.code / err.syscall) of each fd mutator, not about libuv's scheduling. Buffer the three callback results and flush them in a fixed order once all three have landed. Every assertion is preserved and the emitted 10 lines are byte-for-byte what they were; only the print order is pinned. After: node emits 1 distinct output across 60 runs, perry 1 across 40, and the two are byte-identical. Co-authored-by: Ralph Küpper <ralph3@skelpo.com>
The bug
A method value read off a class prototype was a closure that re-resolved the method by name against the receiver's class at call time. Copy that value onto a different class's prototype and the lookup finds the copy — itself — so it recurses until the call-depth guard returns the null object, which user code sees as
[object Object]:typeofreportedfunctionand the call didn't throw — it just returned an object, so the failure surfaced far from its cause. The existing comment inbound.rsalready described this exact signature for the self-shadowingthis.m = this.m.bind(this)case; the same trap applies whenever the value is copied.The fix
Per spec a method value is a fixed function object: reading
C.prototype.myields that function, and invoking it later must run that body with the call-timethis— never re-dispatch by name. The captured slot-0 prototype-ref already names the owner class, so dispatch the owner's body throughlookup_class_method_in_chain— the same treatment the#privatearm directly above already applies. The receiver is still computed bycanonical_bound_method_receiver, soconst f = c.m; f()andthis.m = this.m.bind(this)are unchanged.Behavior that must not regress, and does not:
sub.m()call still dispatches a subclass override virtually;Query.prototype.m.call(sub)— spec; previously the override ran);C.prototype.m === C.prototype.m) still holds.How it was found
Compiling a real Next.js + MySQL app. mysql2's
Commandstate machine copies a block of methods across classes (Execute.prototype.resultsetHeader = Query.prototype.resultsetHeader) and advances by returning method values, so its resultset parse mis-fired: the resultset header readfieldCountas0for a 6-columnSELECT, took the INSERT/OK branch, and threw one packet later. It affects any library using this common inheritance/mixin idiom.Test
test_gap_cross_class_prototype_method_copy— direct call through the copied slot, use as a state-machine value, subclass override, owner-body-on-subclass-receiver, and method identity. Byte-identical to node.Gap suite: no new untriaged failures (verified on a branch of
maincarrying only this and the two sibling fixes; the two the runner flags as "new" —test_gap_fs_fd_2749,test_gap_handle_band_object_ops— reproduce byte-identically on pristinemainand are pre-existing).Summary by CodeRabbit
Bug Fixes
Tests