From cb00680bad0bb4b9908942a0606cc3b4bae9749b Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Thu, 20 Aug 2026 12:13:30 +0200 Subject: [PATCH 1/3] fix(runtime): stop Object locale string self-recursion Separate the Object.prototype.toLocaleString builtin body from the source-level locale dispatcher so direct and aliased builtin calls invoke toString without redispatching themselves. Add a regression for the Test262 case tracked by #5901. --- .../src/object/global_this/array_error.rs | 2 +- .../src/object/native_call_method.rs | 3 +- .../object/native_call_method/object_proto.rs | 125 ++++++++++-------- .../to_locale_string_tests.rs | 24 ++++ 4 files changed, 100 insertions(+), 54 deletions(-) diff --git a/crates/perry-runtime/src/object/global_this/array_error.rs b/crates/perry-runtime/src/object/global_this/array_error.rs index b95ce57e0f..f3c344dad6 100644 --- a/crates/perry-runtime/src/object/global_this/array_error.rs +++ b/crates/perry-runtime/src/object/global_this/array_error.rs @@ -366,7 +366,7 @@ pub(crate) extern "C" fn object_prototype_to_locale_string_thunk( _closure: *const crate::closure::ClosureHeader, ) -> f64 { let this_value = f64::from_bits(IMPLICIT_THIS.with(|c| c.get())); - unsafe { super::super::js_object_default_to_locale_string(this_value) } + unsafe { super::super::js_object_prototype_to_locale_string(this_value) } } /// Spec `CreateListFromArrayLike`'s implementation-defined cap on the diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index 37a406ead9..a2098b6876 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -31,7 +31,8 @@ use disposal::{ }; pub use object_proto::js_value_to_locale_string; pub(crate) use object_proto::{ - js_object_default_to_locale_string, js_object_default_value_of, js_object_is_prototype_of_value, + js_object_default_value_of, js_object_is_prototype_of_value, + js_object_prototype_to_locale_string, }; pub(crate) use proto_dispatch::{ try_dispatch_instance_method_value, try_dispatch_value_called_proto_method, diff --git a/crates/perry-runtime/src/object/native_call_method/object_proto.rs b/crates/perry-runtime/src/object/native_call_method/object_proto.rs index 02c6cc928c..55addd49b2 100644 --- a/crates/perry-runtime/src/object/native_call_method/object_proto.rs +++ b/crates/perry-runtime/src/object/native_call_method/object_proto.rs @@ -60,6 +60,74 @@ pub(crate) unsafe fn js_object_default_value_of(receiver: f64) -> f64 { receiver } +unsafe fn invoke_receiver_to_string(receiver: f64) -> f64 { + let jsval = JSValue::from_bits(receiver.to_bits()); + // Symbols are POINTER-tagged, so `!jsval.is_pointer()` would be false for + // them. Check the symbol registry before the pointer guard. + let is_symbol = crate::symbol::js_is_symbol(receiver) != 0; + if !jsval.is_pointer() || is_symbol { + // `Invoke(receiver, "toString")` resolves the method on a primitive's + // prototype chain and calls it with the original primitive as `this`. + // A user-patched prototype method (including an accessor result) must + // therefore win over the native fallback. + let builtin_name: &[u8] = if jsval.is_bool() { + b"Boolean" + } else if jsval.is_number() { + b"Number" + } else if jsval.is_bigint() { + b"BigInt" + } else if jsval.is_any_string() { + b"String" + } else if is_symbol { + b"Symbol" + } else { + b"" + }; + if !builtin_name.is_empty() { + if let Some(patched) = + super::builtin_proto_user_value(builtin_name, "toString", receiver) + { + if let Some(result) = + call_primitive_closure_value(receiver, patched, std::ptr::null(), 0) + { + return result; + } + // `Invoke` must call the value returned by `GetV`. A present + // non-callable property throws rather than silently selecting + // the native fallback (#5901). + throw_object_to_string_not_function(); + } + } + return js_native_call_method( + receiver, + b"toString".as_ptr() as *const i8, + "toString".len(), + std::ptr::null(), + 0, + ); + } + if let Some(result) = call_object_to_string_method(receiver) { + return result; + } + crate::object::js_object_to_string(receiver) +} + +/// The body of `%Object.prototype.toLocaleString%`. +/// +/// Keep this separate from [`js_object_default_to_locale_string`], which is +/// the source-level `value.toLocaleString()` dispatcher. Redispatching that +/// method from inside its own built-in thunk finds the same own property on +/// `Object.prototype` (or on an object that aliases the built-in) and recurses +/// until the native stack overflows. ECMA-262 requires only an invocation of +/// the receiver's `toString` here. +pub(crate) unsafe fn js_object_prototype_to_locale_string(receiver: f64) -> f64 { + let jsval = JSValue::from_bits(receiver.to_bits()); + if jsval.is_undefined() || jsval.is_null() { + throw_object_to_locale_string_nullish_receiver(); + } + invoke_receiver_to_string(receiver) +} + pub(crate) unsafe fn js_object_default_to_locale_string(receiver: f64) -> f64 { let jsval = JSValue::from_bits(receiver.to_bits()); if jsval.is_undefined() || jsval.is_null() { @@ -115,54 +183,10 @@ pub(crate) unsafe fn js_object_default_to_locale_string(receiver: f64) -> f64 { let s = crate::intl::bigint_to_locale_string(receiver, undef, undef); return f64::from_bits(JSValue::string_ptr(s).bits()); } - // Symbols are POINTER-tagged, so `!jsval.is_pointer()` would be false for - // them — check before the pointer guard so the branch is reachable. - let is_symbol = unsafe { crate::symbol::js_is_symbol(receiver) } != 0; - if !jsval.is_pointer() || is_symbol { - // Spec 20.1.3.6 Object.prototype.toLocaleString: step 1 is "Let O be - // the this value" (NOT ToObject), step 2 is "Return ? Invoke(O, - // 'toString')". Invoke resolves the method on the primitive's prototype - // chain and calls it with the original primitive as `this`. A - // user-patched Boolean/Number/BigInt/String prototype toString must be - // honoured, and a strict callee must receive the raw primitive (not a - // boxed wrapper) — call_primitive_closure_value handles both. - let builtin_name: &[u8] = if jsval.is_bool() { - b"Boolean" - } else if jsval.is_bigint() { - b"BigInt" - } else if jsval.is_any_string() { - b"String" - } else if is_symbol { - b"Symbol" - } else { - b"" - }; - if !builtin_name.is_empty() { - if let Some(patched) = - unsafe { super::builtin_proto_user_value(builtin_name, "toString", receiver) } - { - if let Some(result) = - unsafe { call_primitive_closure_value(receiver, patched, std::ptr::null(), 0) } - { - return result; - } - // `Invoke(O, "toString")` must call the value returned by - // `GetV`. A present data property such as - // `String.prototype.toString = 42`, or an accessor returning - // a non-callable, throws rather than silently selecting the - // native fallback (#5901). - throw_object_to_string_not_function(); - } - } - return unsafe { - js_native_call_method( - receiver, - b"toString".as_ptr() as *const i8, - "toString".len(), - std::ptr::null(), - 0, - ) - }; + // Primitive receivers (including pointer-tagged Symbols) inherit the + // Object method but resolve `toString` on their own prototype chain. + if !jsval.is_pointer() || crate::symbol::js_is_symbol(receiver) != 0 { + return invoke_receiver_to_string(receiver); } // #8139: an ARRAY, TYPED ARRAY or BUFFER receiver. // @@ -249,10 +273,7 @@ pub(crate) unsafe fn js_object_default_to_locale_string(receiver: f64) -> f64 { return result; } } - if let Some(result) = call_object_to_string_method(receiver) { - return result; - } - crate::object::js_object_to_string(receiver) + invoke_receiver_to_string(receiver) } /// #4546: codegen entry point for `value.toLocaleString()` when the diff --git a/crates/perry-runtime/src/object/native_call_method/to_locale_string_tests.rs b/crates/perry-runtime/src/object/native_call_method/to_locale_string_tests.rs index 604dd87ffa..c5b257c303 100644 --- a/crates/perry-runtime/src/object/native_call_method/to_locale_string_tests.rs +++ b/crates/perry-runtime/src/object/native_call_method/to_locale_string_tests.rs @@ -154,3 +154,27 @@ fn an_array_buffer_and_data_view_keep_the_object_tag() { crate::buffer::mark_as_data_view(dv); assert_ne!(locale_string(boxed(dv)), "hi"); } + +#[test] +fn object_prototype_builtin_invokes_to_string_without_self_redispatch() { + // test262 built-ins/Object/prototype/toLocaleString/S15.2.4.3_A1.js calls + // the built-in directly on Object.prototype. The thunk used to re-enter + // the source-level `toLocaleString` dispatcher, which found the same own + // thunk again and overflowed the native stack before reaching `toString`. + let receiver = crate::object::builtin_prototype_value("Object"); + let previous = crate::object::js_implicit_this_set(receiver); + let result = + crate::object::global_this::object_prototype_to_locale_string_thunk(std::ptr::null()); + crate::object::js_implicit_this_set(previous); + + let ptr = crate::value::js_get_string_pointer_unified(result) as *const crate::StringHeader; + assert!(!ptr.is_null()); + unsafe { + let len = (*ptr).byte_len as usize; + let data = (ptr as *const u8).add(std::mem::size_of::()); + assert_eq!( + String::from_utf8_lossy(std::slice::from_raw_parts(data, len)), + "[object Object]" + ); + } +} From f75748922df261bcc6dbc178696f6f16c5f6505f Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Thu, 20 Aug 2026 12:17:29 +0200 Subject: [PATCH 2/3] docs(changelog): record Object locale recursion fix --- changelog.d/8478-object-locale-self-recursion.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 changelog.d/8478-object-locale-self-recursion.md diff --git a/changelog.d/8478-object-locale-self-recursion.md b/changelog.d/8478-object-locale-self-recursion.md new file mode 100644 index 0000000000..6a8da97260 --- /dev/null +++ b/changelog.d/8478-object-locale-self-recursion.md @@ -0,0 +1,6 @@ +### Fixed + +- **`Object.prototype.toLocaleString()` no longer overflows the native stack.** + The built-in now invokes the receiver's `toString` directly instead of + redispatching itself when called on `Object.prototype` or aliased onto + another object. From 7032d412f82ac7d9ad9de1fa98a14d6ea2161fba Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Thu, 20 Aug 2026 13:03:09 +0200 Subject: [PATCH 3/3] fix(runtime): root locale string dispatch values --- .../src/object/native_call_method.rs | 25 ++++++++++++---- .../object/native_call_method/object_proto.rs | 29 +++++++++++++------ 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index a2098b6876..ff117f7141 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -287,6 +287,13 @@ unsafe fn call_primitive_closure_value( args_ptr: *const f64, args_len: usize, ) -> Option { + // Both values remain live across ToObject(this), closure cloning and the + // user call. Any of those can allocate, so derive pointer bits only from + // handles that the moving collector can rewrite. + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver_h = scope.root_nanbox_f64(receiver); + let value_h = scope.root_nanbox_u64(value.bits()); + let value = JSValue::from_bits(value_h.get_nanbox_u64()); if value.is_undefined() { return None; } @@ -307,14 +314,20 @@ unsafe fn call_primitive_closure_value( let strict_callee = !func_ptr.is_null() && crate::closure::is_registered_strict_function(func_ptr); let this_receiver = if strict_callee { - receiver + receiver_h.get_nanbox_f64() } else { - crate::object::js_object_coerce(receiver) + crate::object::js_object_coerce(receiver_h.get_nanbox_f64()) }; - let bound = crate::closure::clone_closure_rebind_this(bits, this_receiver); - let prev_this = crate::object::js_implicit_this_set(this_receiver); - let result = crate::closure::js_native_call_value(f64::from_bits(bound), args_ptr, args_len); - crate::object::js_implicit_this_set(prev_this); + let this_h = scope.root_nanbox_f64(this_receiver); + let bound = crate::closure::clone_closure_rebind_this( + value_h.get_nanbox_u64(), + this_h.get_nanbox_f64(), + ); + let bound_h = scope.root_nanbox_u64(bound); + let prev_this = crate::object::js_implicit_this_set(this_h.get_nanbox_f64()); + let prev_this_h = scope.root_nanbox_f64(prev_this); + let result = crate::closure::js_native_call_value(bound_h.get_nanbox_f64(), args_ptr, args_len); + crate::object::js_implicit_this_set(prev_this_h.get_nanbox_f64()); Some(result) } diff --git a/crates/perry-runtime/src/object/native_call_method/object_proto.rs b/crates/perry-runtime/src/object/native_call_method/object_proto.rs index 55addd49b2..0a8c562f65 100644 --- a/crates/perry-runtime/src/object/native_call_method/object_proto.rs +++ b/crates/perry-runtime/src/object/native_call_method/object_proto.rs @@ -61,10 +61,15 @@ pub(crate) unsafe fn js_object_default_value_of(receiver: f64) -> f64 { } unsafe fn invoke_receiver_to_string(receiver: f64) -> f64 { - let jsval = JSValue::from_bits(receiver.to_bits()); + // `builtin_proto_user_value` may call an accessor and collect. Keep the + // receiver rooted before that lookup and reload it for every later use. + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver_h = scope.root_nanbox_f64(receiver); + let receiver = || receiver_h.get_nanbox_f64(); + let jsval = JSValue::from_bits(receiver().to_bits()); // Symbols are POINTER-tagged, so `!jsval.is_pointer()` would be false for // them. Check the symbol registry before the pointer guard. - let is_symbol = crate::symbol::js_is_symbol(receiver) != 0; + let is_symbol = crate::symbol::js_is_symbol(receiver()) != 0; if !jsval.is_pointer() || is_symbol { // `Invoke(receiver, "toString")` resolves the method on a primitive's // prototype chain and calls it with the original primitive as `this`. @@ -85,11 +90,17 @@ unsafe fn invoke_receiver_to_string(receiver: f64) -> f64 { }; if !builtin_name.is_empty() { if let Some(patched) = - super::builtin_proto_user_value(builtin_name, "toString", receiver) + super::builtin_proto_user_value(builtin_name, "toString", receiver()) { - if let Some(result) = - call_primitive_closure_value(receiver, patched, std::ptr::null(), 0) - { + // The accessor result is live across sloppy-this coercion and + // closure cloning, both allocation points. + let patched_h = scope.root_nanbox_u64(patched.bits()); + if let Some(result) = call_primitive_closure_value( + receiver(), + JSValue::from_bits(patched_h.get_nanbox_u64()), + std::ptr::null(), + 0, + ) { return result; } // `Invoke` must call the value returned by `GetV`. A present @@ -99,17 +110,17 @@ unsafe fn invoke_receiver_to_string(receiver: f64) -> f64 { } } return js_native_call_method( - receiver, + receiver(), b"toString".as_ptr() as *const i8, "toString".len(), std::ptr::null(), 0, ); } - if let Some(result) = call_object_to_string_method(receiver) { + if let Some(result) = call_object_to_string_method(receiver()) { return result; } - crate::object::js_object_to_string(receiver) + crate::object::js_object_to_string(receiver()) } /// The body of `%Object.prototype.toLocaleString%`.