From 2a308247a6896ddaaaec95c52d4cd1fc34d3ea55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 14 Jul 2026 09:47:06 +0200 Subject: [PATCH] fix(runtime): run the owner's body when a class method value is copied onto another prototype MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/closure/dispatch/bound.rs | 41 +++++++++++- ...t_gap_cross_class_prototype_method_copy.ts | 65 +++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 test-files/test_gap_cross_class_prototype_method_copy.ts diff --git a/crates/perry-runtime/src/closure/dispatch/bound.rs b/crates/perry-runtime/src/closure/dispatch/bound.rs index d8050137e..6ef1cad9b 100644 --- a/crates/perry-runtime/src/closure/dispatch/bound.rs +++ b/crates/perry-runtime/src/closure/dispatch/bound.rs @@ -71,7 +71,46 @@ pub unsafe fn dispatch_bound_method(closure: *const ClosureHeader, args: &[f64]) // the rebind targets the right object. Ordinary `obj.method(args)` calls do // NOT reach here (they lower straight to `js_native_call_method`), so this // only governs method-as-value invocations. - namespace_obj = crate::object::canonical_bound_method_receiver(namespace_obj); + // The captured slot-0 prototype-ref names the OWNER class. A method value is a + // FIXED function object (spec: reading `C.prototype.m` yields *that* function; + // invoking it later does not re-resolve `m` against whatever receiver it is + // called on), so dispatch the OWNER's body with the call-time `this` rather than + // re-resolving the name on the RECEIVER's class below. + // + // Re-resolution breaks as soon as the value is COPIED onto another class's + // prototype — `Execute.prototype.resultsetHeader = Query.prototype.resultsetHeader`, + // the block copy mysql2's Command state machine performs. Invoking it on an + // `Execute` then re-resolved "resultsetHeader" against Execute, found the copy + // (this very closure), and recursed until the call-depth guard returned the null + // object — user-visible as `[object Object]` instead of the method's result, + // exactly the failure the comment below describes for the self-shadowing `bind` + // case. + let owner_proto_ref = namespace_obj; + let call_receiver = crate::object::canonical_bound_method_receiver(owner_proto_ref); + 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) + { + return crate::object::call_vtable_method( + func_ptr, + call_receiver.to_bits() as i64, + args.as_ptr(), + args.len(), + param_count, + has_synth_args, + has_rest, + ); + } + } + } + } + + namespace_obj = call_receiver; // A bound-method VALUE (`const f = obj.method`) is resolved at READ time and // must always invoke that method — even if `obj.method` is later reassigned. diff --git a/test-files/test_gap_cross_class_prototype_method_copy.ts b/test-files/test_gap_cross_class_prototype_method_copy.ts new file mode 100644 index 000000000..551a3d33a --- /dev/null +++ b/test-files/test_gap_cross_class_prototype_method_copy.ts @@ -0,0 +1,65 @@ +// Copying a method value from one class's prototype onto another's — the block +// copy mysql2's Command state machine performs +// (`Execute.prototype.resultsetHeader = Query.prototype.resultsetHeader`). +// +// A method value is a FIXED function object: invoking it must run the OWNER's +// body with the call-time `this`, never re-resolve the method name against the +// receiver's own class. Re-resolving finds the copy itself and recurses until +// the call-depth guard yields the null object (`[object Object]`). + +class Query { + tag = "Query"; + resultsetHeader(this: any, n: number): any { + return "Q.rsh:" + this.tag + ":" + n; + } + done(this: any): any { + return "Q.done:" + this.tag; + } +} + +class Execute { + tag = "Execute"; + start(this: any): any { + return (Execute as any).prototype.resultsetHeader; + } + // Execute has its OWN readField; the copied Query method reads it off `this`, + // which must still resolve virtually to Execute's override. + readField(this: any): any { + return "Execute.readField"; + } +} + +(Execute as any).prototype.resultsetHeader = (Query as any).prototype.resultsetHeader; +(Execute as any).prototype.done = (Query as any).prototype.done; + +const ex: any = new Execute(); + +// direct call through the copied prototype slot +console.log("direct :", ex.resultsetHeader(6)); +console.log("copied done :", ex.done()); + +// as a state-machine value: read it, then invoke it later (mysql2's `this.next`) +const next = ex.start(); +console.log("typeof next :", typeof next); +console.log("invoke next :", next.call(ex, 6)); + +// the value read straight off the source prototype +const q: any = new Query(); +const m = (Query as any).prototype.resultsetHeader; +console.log("proto value :", typeof m, m.call(q, 1)); + +// an override on the receiver must NOT hijack the copied owner's body +class Sub extends Query { + resultsetHeader(this: any, n: number): any { + return "Sub.rsh:" + n; + } +} +const sub: any = new Sub(); +console.log("override :", sub.resultsetHeader(2)); +console.log("owner body :", (Query as any).prototype.resultsetHeader.call(sub, 3)); + +// method identity: reading a method twice yields the same function object +console.log( + "identity :", + (Query as any).prototype.done === (Query as any).prototype.done, +);