diff --git a/changelog.d/10230-wide-json-field-reads.md b/changelog.d/10230-wide-json-field-reads.md new file mode 100644 index 0000000000..a2c446af69 --- /dev/null +++ b/changelog.d/10230-wide-json-field-reads.md @@ -0,0 +1,4 @@ +Fix computed property reads and `Object.entries` / `Object.values` for parsed +objects with more than 10,000 inline fields. Indexed reads now use the +object's published live-slot bound without rejecting otherwise valid wide +objects. Out-of-range indices still follow the existing overflow lookup. diff --git a/crates/perry-runtime/src/object/field_get_set/accessors.rs b/crates/perry-runtime/src/object/field_get_set/accessors.rs index fbc65b3cb4..cdaf2e0b5b 100644 --- a/crates/perry-runtime/src/object/field_get_set/accessors.rs +++ b/crates/perry-runtime/src/object/field_get_set/accessors.rs @@ -63,10 +63,9 @@ pub(crate) unsafe fn object_field_at_with_live( None => JSValue::undefined(), }; } - // Guard: corrupted objects with unreasonably large field_count - if live > 10000 { - return JSValue::undefined(); - } + // The published shape bound already proves this slot is inline. JSON + // objects can legitimately have more than 10,000 slots (#10175); their + // total field count is not a reason to reject an in-bounds read. let fields_ptr = (obj as *const u8).add(std::mem::size_of::()) as *const JSValue; let val = *fields_ptr.add(field_index as usize); // Guard: null POINTER_TAG (0x7FFD_0000_0000_0000) is never legitimate — replace with undefined diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 2782b1c4fc..e15899d4e5 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -1828,6 +1828,8 @@ mod tombstone_tests; #[cfg(test)] mod transition_ic_tests; #[cfg(test)] +mod wide_field_read_tests; +#[cfg(test)] mod wide_object_membership_tests; /// The named-property bag for a cell that has no inline slot layout of its own, diff --git a/crates/perry-runtime/src/object/wide_field_read_tests.rs b/crates/perry-runtime/src/object/wide_field_read_tests.rs new file mode 100644 index 0000000000..0855543544 --- /dev/null +++ b/crates/perry-runtime/src/object/wide_field_read_tests.rs @@ -0,0 +1,78 @@ +//! #10175: valid inline storage has no arbitrary 10,000-field read cutoff. + +use super::*; + +#[test] +fn wide_inline_reads_use_the_published_slot_bound() { + let scope = crate::gc::RuntimeHandleScope::new(); + for count in [9_999, 10_000, 10_001, 60_000] { + let object = scope.root_raw_mut_ptr(js_object_alloc(0, count)); + assert_eq!( + unsafe { object_live_slot_count(object.get_raw_const_ptr()) }, + count + ); + for index in [0, 5, count / 2, count - 1] { + js_object_set_field( + object.get_raw_mut_ptr(), + index, + JSValue::number(index as f64), + ); + assert_eq!( + js_object_get_field(object.get_raw_const_ptr(), index).as_number(), + index as f64, + "count={count}, index={index}" + ); + } + for index in [count, count + 1, u32::MAX] { + assert!( + js_object_get_field(object.get_raw_const_ptr(), index).is_undefined(), + "out-of-bounds count={count}, index={index}" + ); + } + } +} + +#[test] +fn parsed_wide_objects_keep_computed_reads_and_entries() { + for count in [10_000, 10_001] { + let mut input = String::from("{"); + for index in 0..count { + if index != 0 { + input.push(','); + } + input.push_str(&format!("\"k{index}\":{index}")); + } + input.push('}'); + let text = crate::string::js_string_from_bytes(input.as_ptr(), input.len() as u32); + let parsed = unsafe { crate::json::js_json_parse(text) }; + assert!(parsed.is_pointer()); + let scope = crate::gc::RuntimeHandleScope::new(); + let object = scope.root_raw_const_ptr(parsed.as_pointer::()); + let raw = || object.get_raw_const_ptr::(); + assert_eq!( + unsafe { object_live_slot_count(raw()) }, + count, + "the parser must exercise the wide inline representation" + ); + for index in [0, 5, count / 2, count - 1] { + let name = format!("k{index}"); + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + assert_eq!( + js_object_get_field_by_name(raw(), key).as_number(), + index as f64 + ); + } + let entries = scope.root_raw_mut_ptr(js_object_entries(raw())); + assert_eq!( + crate::array::js_array_length(entries.get_raw_const_ptr()), + count + ); + for index in [0, 5, count / 2, count - 1] { + let pair = crate::array::js_array_get(entries.get_raw_const_ptr(), index); + assert_eq!( + crate::array::js_array_get(pair.as_pointer(), 1).as_number(), + index as f64 + ); + } + } +} diff --git a/test-files/test_gap_10175_wide_json_field_reads.ts b/test-files/test_gap_10175_wide_json_field_reads.ts new file mode 100644 index 0000000000..53c4b6baa8 --- /dev/null +++ b/test-files/test_gap_10175_wide_json_field_reads.ts @@ -0,0 +1,29 @@ +// #10175: computed reads and enumeration must agree across the old 10k cutoff. +function parseWide(count: number, records: boolean): any { + const parts: string[] = []; + for (let i = 0; i < count; i++) { + const value = records ? '{"id":' + i + ',"name":"v' + i + '"}' : String(i); + parts.push('"k' + i + '":' + value); + } + return JSON.parse("{" + parts.join(",") + "}"); +} + +for (const count of [9999, 10000, 10001, 12000]) { + for (const records of [false, true]) { + const value = parseWide(count, records); + const entries = Object.entries(value); + const values = Object.values(value); + console.log("wide", count, records, Object.keys(value).length, entries.length, values.length); + for (const index of [0, 5, Math.floor(count / 2), count - 1]) { + const key = "k" + index; + console.log(key, JSON.stringify(value[key]), JSON.stringify(entries[index]), + JSON.stringify(values[index]), key in value, Object.hasOwn(value, key)); + } + console.log("literal", JSON.stringify(value["k5"]), "missing", value["k" + count]); + const last = "k" + (count - 1); + delete value[last]; + value[last] = 123; + value["extra"] = 456; + console.log("mutated", value[last], value["extra"], Object.keys(value).length); + } +}