diff --git a/changelog.d/9579-array-expando-growth.md b/changelog.d/9579-array-expando-growth.md new file mode 100644 index 0000000000..46178bc8c6 --- /dev/null +++ b/changelog.d/9579-array-expando-growth.md @@ -0,0 +1,9 @@ +Named properties attached to arrays remain visible after an indexed write grows +the array's backing allocation. + +The named-property table is keyed by the array header address, while growth +replaces that header and leaves a forwarding stub behind. Direct runtime +coverage now pins the required owner transfer to the replacement header and +asserts that the stale owner is removed. A Node differential also covers typed +and dynamic arrays through growth, including property enumeration and JSON +serialization. diff --git a/crates/perry-runtime/src/array/large_presized_tests.rs b/crates/perry-runtime/src/array/large_presized_tests.rs index a9ca9b3739..d39d346cbe 100644 --- a/crates/perry-runtime/src/array/large_presized_tests.rs +++ b/crates/perry-runtime/src/array/large_presized_tests.rs @@ -4,6 +4,45 @@ use super::*; +// #9201: named properties live in a side table keyed by the array allocation. +// A grow replaces that allocation, so both the values and the owner key must +// move before the old header becomes a forwarding stub. +#[test] +fn growth_rekeys_named_property_owner() { + let _global = crate::gc::global_side_table_test_lock(); + unsafe { + const NAME: &str = "__perry_test_9201_expando__"; + let key = crate::string::js_string_from_bytes(NAME.as_ptr(), NAME.len() as u32); + let mut arr = js_array_alloc(3); + arr = js_array_push_f64(arr, 1.0); + arr = js_array_push_f64(arr, 2.0); + arr = js_array_push_f64(arr, 3.0); + + array_named_property_set(arr, key, 42.0); + let old_owner = arr as usize; + let old_capacity = (*arr).capacity; + assert_eq!( + array_named_property_get_by_name(arr, NAME), + Some(42.0), + "the expando must exist before growth" + ); + + arr = js_array_set_f64_extend(arr, old_capacity, 99.0); + + assert_ne!(arr as usize, old_owner, "the fixture must grow the array"); + assert_eq!( + array_named_property_get_by_name(arr, NAME), + Some(42.0), + "#9201: growth must preserve the named property" + ); + assert!(test_array_named_property_owner_exists(arr as usize)); + assert!( + !test_array_named_property_owner_exists(old_owner), + "the side table must no longer be keyed by the forwarding stub" + ); + } +} + #[test] fn large_presized_array_grows_its_dense_frontier() { unsafe { diff --git a/crates/perry-runtime/src/array/push_pop.rs b/crates/perry-runtime/src/array/push_pop.rs index 7beb02cf05..ae5306a8c2 100644 --- a/crates/perry-runtime/src/array/push_pop.rs +++ b/crates/perry-runtime/src/array/push_pop.rs @@ -205,7 +205,7 @@ pub extern "C" fn js_array_grow(arr: *mut ArrayHeader, min_capacity: u32) -> *mu crate::gc::layout_transfer(arr as *mut u8, new_ptr as *mut u8); // Array expandos and sparse numeric indices live in an address-keyed // side table. Growth is not a collector move, so rekey it explicitly - // before the old address becomes a forwarding stub (#9371). + // before the old address becomes a forwarding stub (#9371, #9201). transfer_array_named_property_owner(arr as usize, new_ptr as usize); // `js_array_grow` is an allocation replacement outside the collector, // so GC's normal side-table rekey phase does not run. Preserve every diff --git a/test-files/test_gap_9201_array_named_properties.ts b/test-files/test_gap_9201_array_named_properties.ts new file mode 100644 index 0000000000..968c2d3302 --- /dev/null +++ b/test-files/test_gap_9201_array_named_properties.ts @@ -0,0 +1,19 @@ +const arr: any[] = [1, 2, 3]; +arr.foo = "bar"; +console.log("before", arr.length, arr.foo, Object.keys(arr).join(",")); + +arr[10] = "sparse"; + +console.log("after", arr.length, arr.foo, arr[10]); +console.log(Object.keys(arr).join(",")); + +const dynamic: any = [4, 5]; +dynamic.metadata = { total: 2 }; +dynamic["hasMore"] = false; +console.log("before", dynamic.length, dynamic.metadata.total, dynamic.hasMore); + +dynamic[12] = "tail"; + +console.log("after", dynamic.length, dynamic.metadata.total, dynamic.hasMore, dynamic[12]); +console.log(Object.keys(dynamic).join(",")); +console.log(JSON.stringify(dynamic));