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, +);