Skip to content

fix(runtime): run the owner's body when a class method value is copied onto another prototype#6398

Merged
proggeramlug merged 1 commit into
mainfrom
fix/cross-class-prototype-method-copy
Jul 14, 2026
Merged

fix(runtime): run the owner's body when a class method value is copied onto another prototype#6398
proggeramlug merged 1 commit into
mainfrom
fix/cross-class-prototype-method-copy

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

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]:

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 didn't throw — it just returned an object, so the failure surfaced far from its cause. The existing comment in bound.rs already described this exact signature for the self-shadowing this.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.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() and this.m = this.m.bind(this) 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; previously the override ran);
  • method identity (C.prototype.m === C.prototype.m) still holds.

How it was found

Compiling a real Next.js + MySQL app. 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.

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 main carrying 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 pristine main and are pre-existing).

Summary by CodeRabbit

  • Bug Fixes

    • Fixed copied prototype methods so they consistently execute the original method implementation.
    • Preserved the correct receiver context when copied methods are called directly or later.
    • Prevented subclass overrides from unexpectedly replacing copied method behavior.
    • Ensured repeated access to the same copied method retains function identity.
  • Tests

    • Added coverage for cross-class prototype method copying and invocation scenarios.

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

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The 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.

Changes

Fixed owner method values

Layer / File(s) Summary
Owner-resolved method dispatch
crates/perry-runtime/src/closure/dispatch/bound.rs
dispatch_bound_method resolves the captured owner’s method through its prototype chain and invokes it with the canonical receiver.
Cross-class method value validation
test-files/test_gap_cross_class_prototype_method_copy.ts
Tests copied prototype methods, deferred invocation, direct calls, subclass overrides, and repeated method identity.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

  • PerryTS/perry#5944: Changes the same dispatch path to directly invoke the captured owner class’s implementation.
  • PerryTS/perry#6281: Changes bound class-method closure read and dispatch behavior in the same runtime file.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: fixing copied class method values to run the owner's body.
Description check ✅ Passed The description covers the bug, fix, discovery, and tests, but it does not follow the repository's template headings.
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/cross-class-prototype-method-copy

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/closure/dispatch/bound.rs (1)

74-113: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider deduplicating method resolution logic.

The newly added public method dispatch block duplicates the UTF-8 parsing and lookup_class_method_in_chain logic 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 perry and cargo build --profile perry-dev -p perry for 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

📥 Commits

Reviewing files that changed from the base of the PR and between 205473f and 2a30824.

📒 Files selected for processing (2)
  • crates/perry-runtime/src/closure/dispatch/bound.rs
  • test-files/test_gap_cross_class_prototype_method_copy.ts

@proggeramlug

Copy link
Copy Markdown
Contributor Author

The test_gap_fs_fd_2749 failure here is not caused by this PR — the test is flaky and it red-lit #6397, #6398 and #6399 today.

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, node --experimental-strip-types 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 it disagrees with the oracle whenever the oracle takes the minority path.

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 test_gap_fs_fd_2749 is the only untriaged failure on this PR — everything else it reports is already in known_failures.json.

proggeramlug added a commit that referenced this pull request Jul 14, 2026
…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>
@proggeramlug proggeramlug merged commit baefb2a into main Jul 14, 2026
48 of 50 checks passed
@proggeramlug proggeramlug deleted the fix/cross-class-prototype-method-copy branch July 14, 2026 13:58
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