diff --git a/benchmarks/string_receiver_boxing.cjs b/benchmarks/string_receiver_boxing.cjs new file mode 100644 index 0000000000..ac165cf5f4 --- /dev/null +++ b/benchmarks/string_receiver_boxing.cjs @@ -0,0 +1,38 @@ +// #9810: run with Node or compile with Perry. Arguments: receiver length, calls. +// .cjs keeps the receiver-binding controls in sloppy mode on both engines. +const n = Number(process.argv[3] || "20000"); +const receiver = "x".repeat(Number(process.argv[2] || "200")); +function unused(value) { return value + 1; } +function observed(value) { return this.length + value; } +function capture() { return this; } +function strict(value) { "use strict"; return value + 1; } +String.prototype.bench9810 = unused; +const funcs = { unused, observed, strict, capture }; +let sum = 0; +let start = Date.now(); +for (let i = 0; i < n; i++) sum += receiver.bench9810(i); +console.log("method", receiver.length, Date.now() - start, sum); +sum = 0; start = Date.now(); +for (let i = 0; i < n; i++) sum += funcs.unused.call(receiver, i); +console.log("call", receiver.length, Date.now() - start, sum); +sum = 0; start = Date.now(); +for (let i = 0; i < n; i++) sum += funcs.observed.call(receiver, i); +console.log("call-this", receiver.length, Date.now() - start, sum); +sum = 0; start = Date.now(); +for (let i = 0; i < n; i++) sum += funcs.unused.apply(receiver, [i]); +console.log("apply", receiver.length, Date.now() - start, sum); +sum = 0; start = Date.now(); +for (let i = 0; i < n; i++) sum += funcs.strict.call(receiver, i); +console.log("strict", receiver.length, Date.now() - start, sum); +sum = 0; start = Date.now(); +for (let i = 0; i < n; i++) sum += Object(receiver).length; +console.log("Object", receiver.length, Date.now() - start, sum); + +const first = funcs.capture.call(receiver); +const second = funcs.capture.apply(receiver, []); +if (typeof first !== "object" || first === second || first.length !== receiver.length) { + throw new Error("sloppy calls must create distinct String wrappers"); +} +first.extra = 7; +if (second.extra !== undefined) throw new Error("receiver state leaked"); +delete String.prototype.bench9810; diff --git a/changelog.d/9814-virtual-string-indices.md b/changelog.d/9814-virtual-string-indices.md new file mode 100644 index 0000000000..8a063471e8 --- /dev/null +++ b/changelog.d/9814-virtual-string-indices.md @@ -0,0 +1 @@ +Fix string-wrapper construction scaling with receiver length in non-strict method calls, `Function.prototype.call`/`apply`, and `Object(string)`. Character indices are now virtual properties, preserving UTF-16 indexing, reflection, and readonly descriptors without allocating a property and descriptor for every character (#9810). diff --git a/crates/perry-runtime/src/builtins/formatting/boxed_primitives.rs b/crates/perry-runtime/src/builtins/formatting/boxed_primitives.rs index 08c9a151c4..d0ed53f23b 100644 --- a/crates/perry-runtime/src/builtins/formatting/boxed_primitives.rs +++ b/crates/perry-runtime/src/builtins/formatting/boxed_primitives.rs @@ -44,9 +44,8 @@ pub(super) unsafe fn boxed_primitive_base_for_object( /// String, otherwise `None`. /// /// The count is in UTF-16 code units, NOT Unicode scalar values: the index -/// properties are installed over `0..js_string_length` (`utf16_len`) by -/// `install_string_wrapper_indices`, so a non-BMP char (e.g. an emoji, two -/// UTF-16 units) occupies two indices. Counting `.chars()` would under-count +/// properties span `0..js_string_length` (`utf16_len`), so a non-BMP char +/// (e.g. an emoji, two UTF-16 units) occupies two indices. Counting `.chars()` would under-count /// and leak a trailing index (e.g. `new String("a๐Ÿ˜€b")` โ†’ `{ 3: 'b' }`). pub(super) unsafe fn boxed_string_char_index_count( obj_ptr: *const crate::object::ObjectHeader, @@ -139,6 +138,8 @@ fn attach_boxed_primitive_prototype(obj: *mut crate::object::ObjectHeader, class if obj.is_null() { return; } + let scope = crate::gc::RuntimeHandleScope::new(); + let obj_h = scope.root_raw_mut_ptr(obj); let Some(name) = boxed_constructor_name(class_id) else { return; }; @@ -151,7 +152,12 @@ fn attach_boxed_primitive_prototype(obj: *mut crate::object::ObjectHeader, class let proto = crate::closure::closure_get_dynamic_prop(ctor_ptr, "prototype"); let proto_value = crate::value::JSValue::from_bits(proto.to_bits()); if proto_value.is_pointer() { - crate::object::prototype_chain::object_set_static_prototype(obj as usize, proto.to_bits()); + obj_h.with_mut_ptr(|obj: *mut crate::object::ObjectHeader| { + crate::object::prototype_chain::object_set_static_prototype( + obj as usize, + proto.to_bits(), + ) + }); } } @@ -162,46 +168,18 @@ fn install_string_wrapper_length( if obj.is_null() || string_ptr.is_null() { return; } - let key = crate::string::js_string_from_bytes(b"length".as_ptr(), 6); let len = crate::string::js_string_length(string_ptr) as f64; - crate::object::js_object_set_field_by_name(obj, key, len); - crate::object::set_builtin_property_attrs( - obj as usize, - "length".to_string(), - crate::object::PropertyAttrs::new(false, false, false), - ); -} - -/// String exotic objects (ECMA-262 ยง10.4.3) expose each UTF-16 code unit as an -/// integer-indexed own property `"0".."len-1"` with the descriptor -/// `{ value: , writable: false, enumerable: true, configurable: false }`. -/// `new String("abc")` therefore reports `getOwnPropertyDescriptor(s, "0")`, -/// `s.hasOwnProperty("0")`, and `Object.keys(s)`/enumeration over the indices. -/// Installed eagerly at construction (typical `new String` receivers are -/// short); the wrapper's `length` is installed separately and stays last. -fn install_string_wrapper_indices( - obj: *mut crate::object::ObjectHeader, - string_ptr: *const crate::string::StringHeader, -) { - if obj.is_null() || string_ptr.is_null() { - return; - } - let len = crate::string::js_string_length(string_ptr); - for i in 0..len { - let ch = crate::string::js_string_char_at(string_ptr, i as i32); - if ch.is_null() { - continue; - } - let name = i.to_string(); - let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); - let ch_value = f64::from_bits(crate::value::JSValue::string_ptr(ch).bits()); - crate::object::js_object_set_field_by_name(obj, key, ch_value); + let scope = crate::gc::RuntimeHandleScope::new(); + let obj_h = scope.root_raw_mut_ptr(obj); + let key = crate::string::js_string_from_bytes(b"length".as_ptr(), 6); + obj_h.with_mut_ptr(|obj| crate::object::js_object_set_field_by_name(obj, key, len)); + obj_h.with_mut_ptr(|obj: *mut crate::object::ObjectHeader| { crate::object::set_builtin_property_attrs( obj as usize, - name, - crate::object::PropertyAttrs::new(false, true, false), - ); - } + "length".to_string(), + crate::object::PropertyAttrs::new(false, false, false), + ) + }); } pub fn scan_boxed_primitive_payload_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { @@ -327,8 +305,8 @@ pub extern "C" fn js_boxed_string_new(value: f64, has_arg: i32) -> f64 { // empty-string case and `js_string_coerce` otherwise, the latter running a // user `toString`/`valueOf` for a POINTER_TAG value โ€” so either can collect // and EVACUATE while `obj` sits in a raw Rust local. Every use below - // (`register_boxed_primitive_payload`, the two `install_string_wrapper_*` - // calls, `attach_boxed_primitive_prototype`, and the returned NaN-box) + // (`register_boxed_primitive_payload`, the `install_string_wrapper_length` + // call, `attach_boxed_primitive_prototype`, and the returned NaN-box) // dereferences or keys on it. let scope = crate::gc::RuntimeHandleScope::new(); let obj_handle = scope.root_raw_mut_ptr(obj); @@ -348,10 +326,13 @@ pub extern "C" fn js_boxed_string_new(value: f64, has_arg: i32) -> f64 { }); let boxed = f64::from_bits(crate::value::JSValue::string_ptr(ptr).bits()); register_boxed_primitive_payload(obj, boxed); - install_string_wrapper_indices(obj, ptr); + // #9810: character indices are virtual String exotic properties. Index + // keys and values are produced on demand; boxing never walks the string. install_string_wrapper_length(obj, ptr); - attach_boxed_primitive_prototype(obj, CLASS_ID_BOXED_STRING); - crate::value::js_nanbox_pointer(obj as i64) + obj_handle.with_mut_ptr(|obj| attach_boxed_primitive_prototype(obj, CLASS_ID_BOXED_STRING)); + obj_handle.with_mut_ptr(|obj: *mut crate::object::ObjectHeader| { + crate::value::js_nanbox_pointer(obj as i64) + }) } #[no_mangle] diff --git a/crates/perry-runtime/src/object/alloc.rs b/crates/perry-runtime/src/object/alloc.rs index 0601ee7519..cd6d6e359a 100644 --- a/crates/perry-runtime/src/object/alloc.rs +++ b/crates/perry-runtime/src/object/alloc.rs @@ -915,6 +915,16 @@ pub unsafe extern "C" fn js_object_clone_with_extra( } let src_ptr = src_raw as *const ObjectHeader; + if super::string_wrapper::length(src_raw).is_some() { + let scope = crate::gc::RuntimeHandleScope::new(); + let src_h = scope.root_nanbox_f64(src_f64); + let target = js_object_alloc(0, 0); + let copied = js_object_assign_one( + crate::value::js_nanbox_pointer(target as i64), + src_h.get_nanbox_f64(), + ); + return crate::value::js_nanbox_get_pointer(copied) as *mut ObjectHeader; + } let src_field_count = crate::object::object_live_slot_count(src_ptr); // Physical slot capacity: src_field_count + extra_count, but at least max(fc, 8) to match @@ -1037,6 +1047,10 @@ pub unsafe extern "C" fn js_object_copy_own_fields(dst_i64: i64, src_f64: f64) { _ => return, } let src = src_raw as *const ObjectHeader; + if super::string_wrapper::length(src_raw).is_some() { + js_object_assign_one(crate::value::js_nanbox_pointer(dst as i64), src_f64); + return; + } // #6667: a native-module namespace (`{ ...require("crypto") }`) stores no // real fields โ€” only the internal `__module__` sentinel โ€” so the raw @@ -1811,7 +1825,14 @@ pub unsafe extern "C" fn js_object_assign_one(target_f64: f64, source_f64: f64) ); } } else if source_obj_type == crate::gc::GC_TYPE_OBJECT { - let src_keys = crate::object::object_keys_array(src); + let src_keys = if super::string_wrapper::length(src as usize).is_some() { + let names = src_h.with_const_ptr(|src: *const ObjectHeader| { + js_object_get_own_property_names(crate::value::js_nanbox_pointer(src as i64)) + }); + crate::value::js_nanbox_get_pointer(names) as *mut crate::ArrayHeader + } else { + crate::object::object_keys_array(src) + }; let keys_h = scope.root_raw_mut_ptr(src_keys); if !src_keys.is_null() && (src_keys as usize) >= 0x10000 { // Cap the key count at the keys array's capacity: a malformed keys diff --git a/crates/perry-runtime/src/object/delete_rest.rs b/crates/perry-runtime/src/object/delete_rest.rs index f8aeb554f9..cfa797bacd 100644 --- a/crates/perry-runtime/src/object/delete_rest.rs +++ b/crates/perry-runtime/src/object/delete_rest.rs @@ -227,6 +227,9 @@ pub extern "C" fn js_object_delete_field( } return 1; } + if super::string_wrapper::has_index_key(obj as usize, key) { + return 0; + } // Once #9064's stable marker is installed, this receiver has already // been proved to be an ordinary non-prototype object with no // descriptors. Avoid decoding the same dynamic key and scanning the @@ -1071,6 +1074,9 @@ pub extern "C" fn js_object_rest( return js_object_alloc(0, 0); } unsafe { + if super::string_wrapper::length(src as usize).is_some() { + return super::string_wrapper::rest(src, exclude_keys); + } let keys = crate::object::object_keys_array(src); if keys.is_null() { return js_object_alloc(0, 0); diff --git a/crates/perry-runtime/src/object/descriptor_state.rs b/crates/perry-runtime/src/object/descriptor_state.rs index 909002a9aa..96cdbaf0ef 100644 --- a/crates/perry-runtime/src/object/descriptor_state.rs +++ b/crates/perry-runtime/src/object/descriptor_state.rs @@ -528,6 +528,9 @@ pub(crate) fn note_descriptor_target(obj: usize) { /// Look up the property descriptor for (obj, key). Returns None if no entry exists, /// in which case the JS default `{ writable: true, enumerable: true, configurable: true }` applies. pub(crate) fn get_property_attrs(obj: usize, key: &str) -> Option { + if super::string_wrapper::has_index(obj, key) { + return Some(PropertyAttrs::new(false, true, false)); + } // #6759 Phase C2: the meta-record summary proves most misses without // the `String` build + table probe (and shields a fresh object at a // recycled address from a dead owner's not-yet-pruned entries). diff --git a/crates/perry-runtime/src/object/descriptors.rs b/crates/perry-runtime/src/object/descriptors.rs index f3bb2f62cc..a17edbc176 100644 --- a/crates/perry-runtime/src/object/descriptors.rs +++ b/crates/perry-runtime/src/object/descriptors.rs @@ -89,12 +89,18 @@ unsafe fn boxed_string_own_property_names(obj_value: f64, str_value: f64) -> f64 } sort_property_names_ecma(&mut names); + let scope = crate::gc::RuntimeHandleScope::new(); let result = crate::array::js_array_alloc(names.len() as u32); + let result_h = scope.root_raw_mut_ptr(result); for name in names { let str_ptr = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); - crate::array::js_array_push(result, JSValue::string_ptr(str_ptr)); + result_h.with_mut_ptr(|result| { + crate::array::js_array_push(result, JSValue::string_ptr(str_ptr)) + }); } - f64::from_bits((result as u64) | 0x7FFD_0000_0000_0000) + result_h.with_mut_ptr(|result: *mut crate::ArrayHeader| { + f64::from_bits(JSValue::array_ptr(result).bits()) + }) } /// Object.getOwnPropertyDescriptor(obj, key) โ€” returns a data descriptor @@ -1214,16 +1220,15 @@ unsafe fn string_primitive_descriptor(str_value: f64, key_value: f64) -> f64 { if let Some(index) = super::canonical_array_index(name) { if index < utf16_len { - // Materialize the single UTF-16 unit at `index` as a 1-char string. - let bytes = std::slice::from_raw_parts(sptr, sblen as usize); - let s = std::str::from_utf8(bytes).unwrap_or(""); - if let Some(ch) = s.chars().nth(index as usize) { - let mut buf = [0u8; 4]; - let cs = ch.encode_utf8(&mut buf); - let cstr = crate::string::js_string_from_bytes(cs.as_ptr(), cs.len() as u32); - let char_val = f64::from_bits(JSValue::string_ptr(cstr).bits()); - return build_data_descriptor(char_val, false, true, false); - } + // String exotic indices are UTF-16 code units, including lone + // surrogate halves. Use the same read path as s[index]; `.chars()` + // counts Unicode scalars and returned the wrong descriptors. + let string = crate::value::js_get_string_pointer_unified(f64::from_bits( + str_handle.get_heap_word_u64(), + )) as *const crate::StringHeader; + let cstr = crate::string::js_string_char_at(string, index as i32); + let char_val = f64::from_bits(JSValue::string_ptr(cstr).bits()); + return build_data_descriptor(char_val, false, true, false); } } f64::from_bits(crate::value::TAG_UNDEFINED) diff --git a/crates/perry-runtime/src/object/field_get_set/enumeration.rs b/crates/perry-runtime/src/object/field_get_set/enumeration.rs index d5a60122ab..30098dd73d 100644 --- a/crates/perry-runtime/src/object/field_get_set/enumeration.rs +++ b/crates/perry-runtime/src/object/field_get_set/enumeration.rs @@ -151,48 +151,6 @@ pub extern "C" fn js_object_keys_value(value: f64) -> *mut ArrayHeader { } return arr; } - if crate::builtins::boxed_primitive_to_string_tag(value) == Some("String") { - if let Some((_, payload)) = crate::builtins::boxed_primitive_payload(value) { - let mut scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN]; - let len = match crate::string::str_bytes_from_jsvalue(payload, &mut scratch) { - Some((ptr, blen)) if !ptr.is_null() => crate::string::compute_utf16_len(ptr, blen), - _ => 0, - }; - let arr = crate::array::js_array_alloc(len.max(1)); - for i in 0..len { - let s = i.to_string(); - let k = crate::string::js_string_from_bytes(s.as_ptr(), s.len() as u32); - crate::array::js_array_push(arr, JSValue::string_ptr(k)); - } - if jv.is_pointer() { - let ptr = jv.as_pointer::(); - let own = js_object_keys(ptr); - let own_len = crate::array::js_array_length(own); - for i in 0..own_len { - let key_val = crate::array::js_array_get(own, i); - // The wrapper's character indices are installed as REAL - // own fields at construction (install_string_wrapper_ - // indices), so they come back from `js_object_keys` too โ€” - // skip them here or `Object.keys(Object("abc"))` lists - // every index twice. Only canonical indices below the - // string length are virtual; expando keys pass through. - let key_ptr = - (key_val.bits() & crate::value::POINTER_MASK) as *const crate::StringHeader; - if let Some(name) = - unsafe { super::super::has_own_helpers::str_from_string_header(key_ptr) } - { - if let Ok(idx) = name.parse::() { - if idx.to_string() == name && (idx as usize) < len as usize { - continue; - } - } - } - crate::array::js_array_push_f64(arr, f64::from_bits(key_val.bits())); - } - } - return arr; - } - } if let Some(addr) = crate::typedarray_props::typed_array_addr_from_value(value) { return unsafe { crate::typedarray_props::typed_array_own_property_names( @@ -1249,6 +1207,12 @@ fn js_object_keys_shape(obj: *const ObjectHeader) -> *mut ArrayHeader { } } unsafe { + if let Some(result) = super::super::string_wrapper::enumerate( + obj, + super::super::string_wrapper::Enumeration::Keys, + ) { + return result; + } if (*obj).class_id == NATIVE_MODULE_CLASS_ID { // Relocated to native_module.rs::vt_own_keys_array so the // module key tables are reachable only through the vtable @@ -1547,6 +1511,12 @@ fn js_object_values_shape(obj: *const ObjectHeader) -> *mut ArrayHeader { return crate::array::js_array_alloc(0); } unsafe { + if let Some(result) = super::super::string_wrapper::enumerate( + obj, + super::super::string_wrapper::Enumeration::Values, + ) { + return result; + } if (*obj).class_id == NATIVE_MODULE_CLASS_ID { if let Some(result) = native_module_enum(obj, MapSetEnum::Values) { return result; @@ -1758,6 +1728,12 @@ fn js_object_entries_shape(obj: *const ObjectHeader) -> *mut ArrayHeader { return crate::array::js_array_alloc(0); } unsafe { + if let Some(result) = super::super::string_wrapper::enumerate( + obj, + super::super::string_wrapper::Enumeration::Entries, + ) { + return result; + } if (*obj).class_id == NATIVE_MODULE_CLASS_ID { if let Some(result) = native_module_enum(obj, MapSetEnum::Entries) { return result; diff --git a/crates/perry-runtime/src/object/field_set_by_name/tail.rs b/crates/perry-runtime/src/object/field_set_by_name/tail.rs index ee6a361400..632bccf67a 100644 --- a/crates/perry-runtime/src/object/field_set_by_name/tail.rs +++ b/crates/perry-runtime/src/object/field_set_by_name/tail.rs @@ -234,6 +234,11 @@ pub(crate) fn set_field_by_name_object_tail( } } + if super::super::string_wrapper::has_index_key(obj as usize, key) { + let name = key_to_str_for_diag(key); + crate::error::throw_immutable_write(0, &name); + } + // Resolve the interned key EARLY (hoisted from below the interception // vet): the store-plan cache and the shape-transition cache both key // on interned pointer identity. If the key is already interned diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index f598712f9b..00a0fc7db2 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -102,6 +102,7 @@ pub(crate) mod has_own_helpers; mod instanceof; mod live_slots; mod null_stub; +mod string_wrapper; pub(crate) use live_slots::set_object_live_slot_count; pub use live_slots::{ js_object_live_slot_count, object_live_slot_count, perry_object_header_abi_revision, diff --git a/crates/perry-runtime/src/object/object_ops/define_property.rs b/crates/perry-runtime/src/object/object_ops/define_property.rs index 9511262f78..e749ce2fb4 100644 --- a/crates/perry-runtime/src/object/object_ops/define_property.rs +++ b/crates/perry-runtime/src/object/object_ops/define_property.rs @@ -1474,6 +1474,11 @@ pub extern "C" fn js_object_define_property( desc_view.as_ref(), )); } + // A compatible definition of an immutable virtual index is a no-op. + // The invariant check above has already rejected every actual change. + if super::super::string_wrapper::has_index_key(obj as usize, key_str) { + return obj_value; + } super::super::mark_object_dynamic_shape_unknown(obj); // Extract descriptor object if extract_obj_ptr(descriptor_value).is_null() { diff --git a/crates/perry-runtime/src/object/object_ops/keys_array.rs b/crates/perry-runtime/src/object/object_ops/keys_array.rs index 25e32e8f70..286b78aa65 100644 --- a/crates/perry-runtime/src/object/object_ops/keys_array.rs +++ b/crates/perry-runtime/src/object/object_ops/keys_array.rs @@ -348,6 +348,9 @@ pub(crate) unsafe fn own_key_present_via_index( if (*obj).class_id == super::super::native_module::NATIVE_MODULE_CLASS_ID { return None; } + if super::super::string_wrapper::has_index_key(obj as usize, key) { + return Some(true); + } let keys = crate::object::object_keys_array(obj); match crate::value::addr_class::try_read_gc_header(keys as usize) { Some(h) if h.obj_type == crate::gc::GC_TYPE_ARRAY => {} @@ -397,6 +400,9 @@ pub(crate) unsafe fn own_key_present( Some(h) if h.obj_type == crate::gc::GC_TYPE_OBJECT => {} _ => return false, } + if super::super::string_wrapper::has_index_key(obj as usize, key) { + return true; + } let keys = crate::object::object_keys_array(obj); if keys.is_null() { return false; diff --git a/crates/perry-runtime/src/object/reflect_support.rs b/crates/perry-runtime/src/object/reflect_support.rs index fe580da06d..6d386d28e0 100644 --- a/crates/perry-runtime/src/object/reflect_support.rs +++ b/crates/perry-runtime/src/object/reflect_support.rs @@ -181,6 +181,9 @@ pub(crate) fn obj_value_has_own_key(value: f64, key: f64) -> bool { if let Some(present) = crate::process::process_env_has_field(obj, key_str) { return present; } + if super::string_wrapper::has_index_key(obj as usize, key_str) { + return true; + } // #9190 replaced the allocating per-element `js_array_get` walk with // the consult-only key index below, so no handle round-trip is needed: // there is no collection point between reading these pointers and diff --git a/crates/perry-runtime/src/object/string_wrapper.rs b/crates/perry-runtime/src/object/string_wrapper.rs new file mode 100644 index 0000000000..c827102e19 --- /dev/null +++ b/crates/perry-runtime/src/object/string_wrapper.rs @@ -0,0 +1,227 @@ +//! Virtual character indices for String exotic objects (#9810). +//! +//! Boxing stores the primitive and the fixed `length` property. Indices never +//! enter shapes or descriptor_state: a call with an unused string receiver +//! must not allocate one field, descriptor, and character per code unit. + +use super::{ObjectHeader, PropertyAttrs}; +use crate::{ArrayHeader, JSValue, StringHeader}; + +/// Consult only: no allocation, string coercion, or user code. Heap strings +/// already cache their UTF-16 length, so probes are independent of that length. +pub(super) fn length(owner: usize) -> Option { + unsafe { + let header = crate::value::addr_class::try_read_gc_header(owner)?; + if header.obj_type != crate::gc::GC_TYPE_OBJECT + || (*(owner as *const ObjectHeader)).class_id != 0xFFFF_00D1 + { + return None; + } + let (_, payload) = crate::builtins::boxed_primitive_payload( + crate::value::js_nanbox_pointer(owner as i64), + )?; + let value = JSValue::from_bits(payload.to_bits()); + if value.is_string() { + let ptr = (value.bits() & crate::value::POINTER_MASK) as *const StringHeader; + return Some(crate::string::js_string_length(ptr)); + } + let mut scratch = [0; crate::value::SHORT_STRING_MAX_LEN]; + let (ptr, len) = crate::string::str_bytes_from_jsvalue(payload, &mut scratch)?; + Some(crate::string::compute_utf16_len(ptr, len)) + } +} + +pub(super) fn has_index(owner: usize, name: &str) -> bool { + // Reject ordinary property names before probing the receiver metadata. + let Some(index) = super::canonical_array_index(name) else { + return false; + }; + length(owner).is_some_and(|len| index < len) +} + +pub(super) unsafe fn has_index_key(owner: usize, key: *const StringHeader) -> bool { + crate::string::header_str_checked(key).is_some_and(|name| has_index(owner, name)) +} + +pub(super) enum Enumeration { + Keys, + Values, + Entries, +} + +unsafe fn is_enumerable(obj: *const ObjectHeader, key: *const StringHeader) -> bool { + super::own_key_present(obj as *mut ObjectHeader, key) + && crate::string::header_str_checked(key).is_some_and(|name| { + super::get_property_attrs(obj as usize, name) + .unwrap_or(PropertyAttrs::new(true, true, true)) + .enumerable() + }) +} + +/// EnumerableOwnProperties over virtual indices and ordinary expando keys. +/// Snapshot once, then recheck ownership/enumerability before each value read: +/// an expando getter can delete or hide a later property and can trigger GC. +pub(super) unsafe fn enumerate( + obj: *const ObjectHeader, + kind: Enumeration, +) -> Option<*mut ArrayHeader> { + length(obj as usize)?; + let scope = crate::gc::RuntimeHandleScope::new(); + let obj_h = scope.root_raw_const_ptr(obj); + let names = obj_h.with_const_ptr(|obj: *const ObjectHeader| { + super::js_object_get_own_property_names(crate::value::js_nanbox_pointer(obj as i64)) + }); + let names_h = scope.root_nanbox_f64(names); + let count = crate::array::js_array_length(crate::value::js_nanbox_get_pointer( + names_h.get_nanbox_f64(), + ) as *const ArrayHeader); + let result = crate::array::js_array_alloc(count); + let result_h = scope.root_raw_mut_ptr(result); + for i in 0..count { + let iter_scope = crate::gc::RuntimeHandleScope::new(); + let key = crate::array::js_array_get( + crate::value::js_nanbox_get_pointer(names_h.get_nanbox_f64()) as *const ArrayHeader, + i, + ); + let key_h = iter_scope.root_nanbox_u64(key.bits()); + let key_ptr = crate::builtins::js_string_coerce(key_h.get_nanbox_f64()); + let key_ptr_h = iter_scope.root_string_ptr(key_ptr); + let enumerable = + obj_h.with_const_ptr(|obj| key_ptr_h.with_const_ptr(|key| is_enumerable(obj, key))); + if !enumerable { + continue; + } + let output = match kind { + Enumeration::Keys => key_h.get_nanbox_u64(), + Enumeration::Values | Enumeration::Entries => { + let value = obj_h.with_const_ptr(|obj| { + key_ptr_h.with_const_ptr(|key| super::js_object_get_field_by_name(obj, key)) + }); + if matches!(kind, Enumeration::Values) { + value.bits() + } else { + let value_h = iter_scope.root_nanbox_u64(value.bits()); + let pair = crate::array::js_array_alloc(2); + let pair_h = iter_scope.root_raw_mut_ptr(pair); + pair_h.with_mut_ptr(|pair| { + crate::array::js_array_push_f64(pair, key_h.get_nanbox_f64()) + }); + pair_h.with_mut_ptr(|pair| { + crate::array::js_array_push_f64(pair, value_h.get_nanbox_f64()) + }); + pair_h.with_mut_ptr(|pair: *mut ArrayHeader| JSValue::array_ptr(pair).bits()) + } + } + }; + result_h + .with_mut_ptr(|result| crate::array::js_array_push(result, JSValue::from_bits(output))); + } + Some(result_h.with_mut_ptr(|result| result)) +} + +/// The ordinary rest helper copies physical slots. String indices have no +/// slots, so read the included enumerable properties by name instead. +pub(super) unsafe fn rest( + source: *const ObjectHeader, + excluded: *const ArrayHeader, +) -> *mut ObjectHeader { + let scope = crate::gc::RuntimeHandleScope::new(); + let source_h = scope.root_raw_const_ptr(source); + let excluded_h = scope.root_raw_const_ptr(excluded); + let keys = enumerate(source, Enumeration::Keys).unwrap(); + let keys_h = scope.root_raw_const_ptr(keys); + let result = super::js_object_alloc(0, 0); + let result_h = scope.root_raw_mut_ptr(result); + let count = keys_h.with_const_ptr(|keys| crate::array::js_array_length(keys)); + for i in 0..count { + let iter_scope = crate::gc::RuntimeHandleScope::new(); + let key = keys_h.with_const_ptr(|keys| crate::array::js_array_get(keys, i)); + let key_h = iter_scope.root_nanbox_u64(key.bits()); + let mut scratch = [0; crate::value::SHORT_STRING_MAX_LEN]; + let bytes = crate::string::js_string_key_bytes(key, &mut scratch).unwrap(); + let skip = excluded_h.with_const_ptr(|excluded: *const ArrayHeader| { + !excluded.is_null() + && (0..crate::array::js_array_length(excluded)).any(|j| { + crate::string::js_string_key_matches_bytes( + crate::array::js_array_get(excluded, j), + bytes, + ) + }) + }); + if skip { + continue; + } + let key_ptr = crate::builtins::js_string_coerce(key_h.get_nanbox_f64()); + let key_ptr_h = iter_scope.root_string_ptr(key_ptr); + if !source_h + .with_const_ptr(|source| key_ptr_h.with_const_ptr(|key| is_enumerable(source, key))) + { + continue; + } + let value = source_h.with_const_ptr(|source| { + key_ptr_h.with_const_ptr(|key| super::js_object_get_field_by_name(source, key)) + }); + result_h.with_mut_ptr(|result| { + key_ptr_h.with_const_ptr(|key| { + super::js_object_set_field_by_name(result, key, f64::from_bits(value.bits())) + }) + }); + } + result_h.with_mut_ptr(|result| result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn boxing_and_reflection_do_not_store_character_properties() { + unsafe { + let text = "a".repeat(4096); + let string = crate::string::js_string_from_bytes(text.as_ptr(), text.len() as u32); + let boxed = crate::builtins::js_boxed_string_new( + crate::value::js_nanbox_string(string as i64), + 1, + ); + let scope = crate::gc::RuntimeHandleScope::new(); + let boxed_h = scope.root_nanbox_f64(boxed); + let check_storage = || { + let owner = crate::value::js_nanbox_get_pointer(boxed_h.get_nanbox_f64()) as usize; + assert_eq!(length(owner), Some(4096)); + let physical_keys = crate::object::object_keys_array(owner as *const ObjectHeader); + assert_eq!( + crate::array::js_array_length(physical_keys), + 1, + "only length is stored" + ); + assert_eq!( + crate::state::state() + .descriptors + .property_descriptors + .borrow() + .keys() + .filter(|(ptr, _)| *ptr == owner) + .count(), + 1, + "indices must not populate descriptor_state", + ); + assert!(has_index(owner, "4095")); + assert!(!has_index(owner, "4096")); + assert!(!has_index(owner, "01")); + let attrs = crate::object::get_property_attrs(owner, "4095").unwrap(); + assert!(!attrs.writable() && attrs.enumerable() && !attrs.configurable()); + }; + check_storage(); + let keys = crate::object::js_object_keys_value(boxed_h.get_nanbox_f64()); + assert_eq!(crate::array::js_array_length(keys), 4096); + check_storage(); + let key = crate::string::js_string_from_bytes(b"4095".as_ptr(), 4); + let descriptor = crate::object::js_object_get_own_property_descriptor( + boxed_h.get_nanbox_f64(), + crate::value::js_nanbox_string(key as i64), + ); + assert!(!JSValue::from_bits(descriptor.to_bits()).is_undefined()); + check_storage(); + } + } +} diff --git a/test-files/test_issue_9810_virtual_string_indices.ts b/test-files/test_issue_9810_virtual_string_indices.ts new file mode 100644 index 0000000000..4dca0332f6 --- /dev/null +++ b/test-files/test_issue_9810_virtual_string_indices.ts @@ -0,0 +1,119 @@ +// #9810: boxing must not eagerly allocate one property per UTF-16 code unit. +function check(ok: boolean, label: string): void { + if (!ok) throw new Error(label); +} +function equal(actual: any, expected: any, label: string): void { + check(JSON.stringify(actual) === JSON.stringify(expected), label); +} +function throws(fn: () => void, label: string): void { + let threw = false; + try { fn(); } catch (e) { threw = e instanceof TypeError; } + check(threw, label); +} + +const text = "a๐Ÿ˜€b"; +const s: any = Object(text); +check(s.length === 4 && s.valueOf() === text, "payload and UTF-16 length"); +for (let i = 0; i < 4; i++) { + check(s[i] === text[i], "index read " + i); + check(Object.hasOwn(s, String(i)) && s.hasOwnProperty(i) && i in s, "own index " + i); + check(s.propertyIsEnumerable(i), "enumerable index " + i); + equal(Object.getOwnPropertyDescriptor(s, String(i)), { + value: text[i], writable: false, enumerable: true, configurable: false, + }, "index descriptor " + i); +} +for (const key of ["-0", "-1", "01", "1.0", "1.5", "4", "NaN", "4294967295"]) { + check(!Object.hasOwn(s, key), "absent " + key); +} +const symbol = Symbol("extra"); +s[symbol] = 17; +s.extra = 9; +s[7] = "outside"; +s["01"] = "leading"; +Object.defineProperty(s, "hidden", { value: 10 }); +equal(Object.keys(s), ["0", "1", "2", "3", "7", "extra", "01"], "keys order"); +equal(Object.getOwnPropertyNames(s), ["0", "1", "2", "3", "7", "length", "extra", "01", "hidden"], "names order"); +const ownKeys = Reflect.ownKeys(s); +check(ownKeys.length === 10 && ownKeys[9] === symbol, "symbol ordering"); +equal(Object.values(s), [text[0], text[1], text[2], text[3], "outside", 9, "leading"], "values"); +equal(Object.entries(Object("ab")), [["0", "a"], ["1", "b"]], "entries"); +equal(Object.keys(Object.getOwnPropertyDescriptors(Object("ab"))), ["0", "1", "length"], "descriptors"); +const loop: string[] = []; +for (const key in s) loop.push(key); +equal(loop, Object.keys(s), "for-in"); +const assigned: any = Object.assign({}, s); +check(assigned[0] === "a" && assigned[3] === "b" && assigned.extra === 9 && assigned[symbol] === 17, "assign"); +check(!Object.hasOwn(assigned, "length") && !Object.hasOwn(assigned, "hidden"), "assign filters"); +const spread: any = { ...s, tail: 12 }; +check(spread[0] === "a" && spread[3] === "b" && spread.tail === 12, "spread"); +const { 0: first, ...rest } = s; +check(first === "a" && !Object.hasOwn(rest, "0") && rest[3] === "b", "rest"); +check(JSON.stringify(s) === JSON.stringify(text), "JSON unwraps"); + +check(!Reflect.set(s, "0", "x"), "Reflect.set rejects"); +check(!Reflect.deleteProperty(s, "0"), "Reflect.delete rejects"); +check(!Reflect.defineProperty(s, "0", { value: "x" }), "Reflect.define rejects"); +Object.defineProperty(s, "0", { value: "a" }); +Object.defineProperty(s, "0", {}); +Object.defineProperty(s, "0", { writable: false, enumerable: true, configurable: false }); +throws(() => Object.defineProperty(s, "0", { writable: true }), "cannot become writable"); +throws(() => Object.defineProperty(s, "0", { enumerable: false }), "cannot hide"); +throws(() => Object.defineProperty(s, "0", { configurable: true }), "cannot become configurable"); +throws(() => Object.defineProperty(s, "0", { get() { return "a"; } }), "cannot become accessor"); +throws(() => { "use strict"; s[0] = "x"; }, "strict assignment"); +throws(() => { "use strict"; delete s[0]; }, "strict delete"); +check(s[0] === "a", "rejected operations preserve index"); +check(delete s[7] && !Object.hasOwn(s, "7"), "delete expando"); +Object.preventExtensions(s); +Object.defineProperty(s, "0", { value: "a" }); +check(!Reflect.defineProperty(s, "8", { value: "new" }), "no new property after preventExtensions"); +for (const lock of [Object.seal, Object.freeze]) { + const locked: any = lock(Object("ab")); + check(Object.isSealed(locked) && Object.isFrozen(locked), "immutable sealed indices"); + equal(Object.keys(locked), ["0", "1"], "locked enumeration"); + check(!Reflect.deleteProperty(locked, "1"), "locked delete"); +} + +// Own virtual indices must shadow inherited numeric accessors/properties. +const proto: any = { 0: "wrong", inherited: 1 }; +const changed: any = Object("ab"); +Object.setPrototypeOf(changed, proto); +check(changed[0] === "a" && changed.inherited === 1, "custom prototype"); +Object.defineProperty(proto, "1", { get() { return "wrong"; } }); +check(changed[1] === "b", "own index shadows inherited getter"); +Object.setPrototypeOf(changed, null); +check(changed[0] === "a" && Object.hasOwn(changed, "1"), "null prototype"); + +// Wide expando objects use a separate ownership index; its miss is not proof +// that a virtual character property is absent. +const wide: any = Object("abc"); +for (let i = 0; i < 80; i++) wide["field" + i] = i; +check(Object.hasOwn(wide, "1"), "wide own index"); +Object.defineProperty(wide, "1", { value: "b" }); +throws(() => Object.defineProperty(wide, "1", { value: "x" }), "wide incompatible definition"); + +const changing: any = Object("ab"); +Object.defineProperty(changing, "first", { enumerable: true, get() { + delete changing.later; + Object.defineProperty(changing, "hiddenLater", { enumerable: false }); + return 5; +} }); +changing.later = 6; +changing.hiddenLater = 7; +equal(Object.values(changing), ["a", "b", 5], "getter changes later keys"); + +function capture() { return Object(this); } +const methods: any = { capture }; +const a: any = methods.capture.call("x".repeat(200)); +const b: any = methods.capture.apply("x".repeat(200), []); +check(typeof a === "object" && a !== b && a.length === 200 && b[199] === "x", "call/apply wrappers"); +a.extra = 4; +check(b.extra === undefined, "independent receiver state"); +function strictReceiver() { "use strict"; return typeof this; } +check(strictReceiver.call("abc") === "string", "strict primitive receiver"); +(String.prototype as any).issue9810 = capture; +const methodThis: any = ("abc" as any).issue9810(); +check(typeof methodThis === "object" && methodThis[2] === "c", "prototype method receiver"); +delete (String.prototype as any).issue9810; +equal(Object.keys(Object("")), [], "empty wrapper"); +console.log("virtual-string-indices-9810 ok");