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. 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..ff117f7141 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, @@ -286,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; } @@ -306,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 02c6cc928c..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 @@ -60,6 +60,85 @@ pub(crate) unsafe fn js_object_default_value_of(receiver: f64) -> f64 { receiver } +unsafe fn invoke_receiver_to_string(receiver: f64) -> f64 { + // `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; + 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()) + { + // 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 + // 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 +194,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 +284,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]" + ); + } +}