From 3c5720627f241750a582c5dbb2c14be58851fe56 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 10:53:55 +0000 Subject: [PATCH 1/2] fix(runtime): a built-in callee receives the primitive `this`, not a ToObject wrapper (#11509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ECMA-262 §10.3.1: a built-in function's [[Call]] does not run OrdinaryCallBindThis; it receives thisArg unchanged and coerces itself. call_primitive_closure_value still boxed a primitive receiver for every non-strict callee, built-ins included, minting a String wrapper with an own index property per UTF-16 code unit. Re-lands the intent of #9800's 32f150b22 without its per-instance builtin_closure_length side-table lookup: built-in-ness is a new BUILTIN kind bit on the closure BODY record (next to STRICT and NON_CONSTRUCTOR), set once per thunk by the prototype-method installers, and checked in the same single body-record lookup the call site already made for strictness. --- .../PRNUM-builtin-callee-no-toobject.md | 23 +++++++ crates/perry-runtime/src/closure/mod.rs | 5 +- crates/perry-runtime/src/closure/registry.rs | 32 +++++++++- .../src/object/global_this/install_static.rs | 2 + .../src/object/native_call_method.rs | 14 +++-- .../code_point_at_dispatch_tests.rs | 61 +++++++++++++++++++ .../src/object/primitive_proto_thunks.rs | 1 + 7 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 changelog.d/PRNUM-builtin-callee-no-toobject.md diff --git a/changelog.d/PRNUM-builtin-callee-no-toobject.md b/changelog.d/PRNUM-builtin-callee-no-toobject.md new file mode 100644 index 0000000000..ee99438624 --- /dev/null +++ b/changelog.d/PRNUM-builtin-callee-no-toobject.md @@ -0,0 +1,23 @@ +### Runtime + +- fix(runtime): a method call on a primitive whose callee is a BUILT-IN no + longer boxes the receiver (#11509, re-land of #9800's `32f150b22`). ECMA-262 + §10.3.1: a built-in function's `[[Call]]` does not run + `OrdinaryCallBindThis`. It receives `thisArg` unchanged and does its own + coercion, which every `String`/`Number`/`Boolean`/`BigInt`/`Object` + prototype thunk already does (they accept the raw primitive before looking + for a wrapper payload). `call_primitive_closure_value` boxed for them + anyway. For a string receiver, that `ToObject` wrapper materialises an own + index property per UTF-16 code unit. Only a sloppy USER callee gets the + wrapper now, which is the distinction the spec draws. + + Unlike the original commit, built-in-ness is not read from the per-instance + `builtin_closure_length` side table. It is a new `BUILTIN` kind bit on the + closure BODY record (`closure/registry.rs`, next to `STRICT` and #10521's + `NON_CONSTRUCTOR`), set once per thunk by `install_proto_method`, + `install_proto_method_rest_with_length` and + `primitive_proto_method_closure_value`. The call site checks + `STRICT | BUILTIN` in the same single body-record lookup it already made for + strictness. The bit is a positive mark, so any body not registered as a + built-in keeps the old wrapper behaviour. No new table, and no + per-closure entries for the collector to prune. diff --git a/crates/perry-runtime/src/closure/mod.rs b/crates/perry-runtime/src/closure/mod.rs index 0619d58791..7c38a8f1d8 100644 --- a/crates/perry-runtime/src/closure/mod.rs +++ b/crates/perry-runtime/src/closure/mod.rs @@ -39,6 +39,10 @@ pub(crate) fn closure_side_table_census() -> Vec bool { body_record(func_ptr).is_some_and(|record| record.has(body_flags::STRICT)) } +/// #11509: mark every closure whose body is `func_ptr` as a built-in function, +/// so a method call on a primitive hands it the raw receiver instead of a +/// `ToObject` wrapper. Only runtime-native thunks may be registered here. +pub(crate) fn register_closure_body_builtin(func_ptr: *const u8) { + if func_ptr.is_null() { + return; + } + update_body_record(func_ptr, |record| record.flags |= body_flags::BUILTIN); +} + +/// OrdinaryCallBindThis, decided by function KIND in one registry lookup: +/// a strict body or a built-in body observes the primitive `thisArg` +/// unchanged; only a sloppy user body is owed the `ToObject` wrapper. +#[inline(always)] +pub(crate) fn body_receives_primitive_this(func_ptr: *const u8) -> bool { + if func_ptr.is_null() { + return false; + } + body_record(func_ptr) + .is_some_and(|record| record.has(body_flags::STRICT) || record.has(body_flags::BUILTIN)) +} + pub fn closure_is_arrow(closure: *const ClosureHeader) -> bool { let func_ptr = get_valid_func_ptr(closure); if func_ptr.is_null() { diff --git a/crates/perry-runtime/src/object/global_this/install_static.rs b/crates/perry-runtime/src/object/global_this/install_static.rs index b45a562ce6..87fc702462 100644 --- a/crates/perry-runtime/src/object/global_this/install_static.rs +++ b/crates/perry-runtime/src/object/global_this/install_static.rs @@ -745,6 +745,7 @@ pub(crate) fn install_proto_method( return f64::from_bits(crate::value::TAG_UNDEFINED); } crate::closure::js_register_closure_arity(func_ptr, arity); + crate::closure::register_closure_body_builtin(func_ptr); super::super::native_module::set_bound_native_closure_name(closure, method_name); // #3143: record this method's spec `.length` per closure instance — all // noop-backed methods share one func_ptr, so the func-ptr arity registry @@ -835,6 +836,7 @@ pub(crate) fn install_proto_method_rest_with_length( return f64::from_bits(crate::value::TAG_UNDEFINED); } crate::closure::js_register_closure_rest(func_ptr, call_fixed_arity); + crate::closure::register_closure_body_builtin(func_ptr); super::super::native_module::set_bound_native_closure_name(closure, method_name); super::super::native_module::set_builtin_closure_length(closure as usize, spec_length); super::super::native_module::set_builtin_closure_non_constructable(closure as usize); diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index ff109b430f..a667edb227 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -340,13 +340,15 @@ unsafe fn call_primitive_closure_value( } // OrdinaryCallBindThis: a strict callee observes the raw primitive // receiver (`Number.prototype.f = function(){"use strict"; return - // typeof this}` must see `"number"` for `(5).f()`); only a sloppy - // callee gets the ToObject wrapper — boxed ONCE up front so writes - // through `this` land on the wrapper the body later observes. + // typeof this}` must see `"number"` for `(5).f()`), and so does a + // BUILT-IN (§10.3.1: its [[Call]] takes `thisArg` unchanged and coerces + // itself — every primitive prototype thunk accepts the raw primitive + // before it looks for a wrapper payload). Only a sloppy USER callee gets + // the ToObject wrapper — boxed ONCE up front so writes through `this` + // land on the wrapper the body later observes. For a string receiver + // that wrapper costs an own index property per UTF-16 code unit (#11509). let func_ptr = crate::closure::get_valid_func_ptr(ptr as *const crate::closure::ClosureHeader); - let strict_callee = - !func_ptr.is_null() && crate::closure::is_registered_strict_function(func_ptr); - let this_receiver = if strict_callee { + let this_receiver = if crate::closure::body_receives_primitive_this(func_ptr) { receiver_h.get_nanbox_f64() } else { crate::object::js_object_coerce(receiver_h.get_nanbox_f64()) diff --git a/crates/perry-runtime/src/object/native_call_method/code_point_at_dispatch_tests.rs b/crates/perry-runtime/src/object/native_call_method/code_point_at_dispatch_tests.rs index e1ce0b8cfc..76eb6d9251 100644 --- a/crates/perry-runtime/src/object/native_call_method/code_point_at_dispatch_tests.rs +++ b/crates/perry-runtime/src/object/native_call_method/code_point_at_dispatch_tests.rs @@ -78,3 +78,64 @@ fn the_wrapper_counter_moves_when_a_receiver_is_boxed() { "if this cannot move, the codePointAt assertion above is vacuous" ); } + +/// #11509: ECMA-262 §10.3.1 hands a BUILT-IN's `[[Call]]` the `thisArg` +/// unchanged, so `call_primitive_closure_value` must not `ToObject` a primitive +/// receiver for one — only a sloppy USER callee is owed that wrapper. The +/// built-in-ness is a property of the closure BODY (a registry bit set by the +/// prototype-method installer), not a per-instance side-table entry. +/// +/// Pins both directions. A method installed through `install_proto_method` +/// receives the raw string (no new wrapper, and it still answers correctly); +/// an unregistered closure body — what a sloppy user function looks like to +/// this predicate — still gets boxed. Without the negative half, a predicate +/// that answered "built-in" for everything would pass. +#[test] +fn builtin_callee_gets_the_primitive_receiver_and_a_user_callee_the_wrapper() { + unsafe { + let s = crate::string::js_string_from_bytes(b"a".as_ptr(), 1); + let recv = f64::from_bits(JSValue::string_ptr(s).bits()); + + let proto = crate::object::js_object_alloc(0, 4); + let method = crate::object::global_this::install_proto_method( + proto, + "codePointAt", + crate::object::string_proto_thunks::string_proto_code_point_at_thunk as *const u8, + 1, + ); + let before = crate::builtins::test_boxed_primitive_payload_count(); + let cp = super::call_primitive_closure_value( + recv, + JSValue::from_bits(method.to_bits()), + [0.0f64].as_ptr(), + 1, + ); + assert_eq!( + cp, + Some(97.0), + "the built-in still sees \"a\" through the raw receiver" + ); + assert_eq!( + crate::builtins::test_boxed_primitive_payload_count(), + before, + "a built-in callee must receive the primitive, not a ToObject wrapper" + ); + + extern "C" fn sloppy_user_body(_c: *const crate::closure::ClosureHeader) -> f64 { + 0.0 + } + let user = crate::closure::js_closure_alloc(sloppy_user_body as *const u8, 0); + let user_value = crate::value::js_nanbox_pointer(user as i64); + let before = crate::builtins::test_boxed_primitive_payload_count(); + let _ = super::call_primitive_closure_value( + recv, + JSValue::from_bits(user_value.to_bits()), + std::ptr::null(), + 0, + ); + assert!( + crate::builtins::test_boxed_primitive_payload_count() > before, + "a sloppy user callee is still owed the ToObject wrapper" + ); + } +} diff --git a/crates/perry-runtime/src/object/primitive_proto_thunks.rs b/crates/perry-runtime/src/object/primitive_proto_thunks.rs index 521a871458..b7ffdddcc0 100644 --- a/crates/perry-runtime/src/object/primitive_proto_thunks.rs +++ b/crates/perry-runtime/src/object/primitive_proto_thunks.rs @@ -169,6 +169,7 @@ fn primitive_proto_method_closure_value(method_name: &str, func_ptr: *const u8, return f64::from_bits(crate::value::TAG_UNDEFINED); } crate::closure::js_register_closure_arity(func_ptr, arity); + crate::closure::register_closure_body_builtin(func_ptr); super::native_module::set_bound_native_closure_name(closure, method_name); super::native_module::set_builtin_closure_length(closure as usize, arity); super::native_module::set_builtin_closure_non_constructable(closure as usize); From fe87b155628d88fa5932d4446c9014385b54b43b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 10:54:21 +0000 Subject: [PATCH 2/2] changelog: key the fragment on #11524 --- ...-callee-no-toobject.md => 11524-builtin-callee-no-toobject.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{PRNUM-builtin-callee-no-toobject.md => 11524-builtin-callee-no-toobject.md} (100%) diff --git a/changelog.d/PRNUM-builtin-callee-no-toobject.md b/changelog.d/11524-builtin-callee-no-toobject.md similarity index 100% rename from changelog.d/PRNUM-builtin-callee-no-toobject.md rename to changelog.d/11524-builtin-callee-no-toobject.md