From a667b286c41ec2efcd7a2e12be16d6c40bae9d0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 22:14:10 +0200 Subject: [PATCH 1/3] perf(intl): collapse the RegExp test proof into one realm record --- .../perry-runtime/src/intl/segments_view.rs | 57 ++++++- crates/perry-runtime/src/object/mod.rs | 12 +- .../src/object/prototype_chain.rs | 19 +++ .../src/object/regex_proto_thunks.rs | 142 ++++++++++++------ 4 files changed, 167 insertions(+), 63 deletions(-) diff --git a/crates/perry-runtime/src/intl/segments_view.rs b/crates/perry-runtime/src/intl/segments_view.rs index fbadacf6a9..9da9d03abf 100644 --- a/crates/perry-runtime/src/intl/segments_view.rs +++ b/crates/perry-runtime/src/intl/segments_view.rs @@ -791,8 +791,7 @@ mod view_mode_tests { ); // Replace `RegExp.prototype.test` the way a program would. - let proto_ptr = - crate::object::regex_proto_thunks::REGEXP_PROTOTYPE_PTR.load(Ordering::Acquire); + let proto_ptr = crate::object::regex_proto_thunks::test_recorded_regexp_prototype(); assert!(proto_ptr != 0, "the site must have been recorded"); let proto = proto_ptr as *mut ObjectHeader; let key = crate::string::js_string_from_bytes(b"test".as_ptr(), 4); @@ -814,6 +813,60 @@ mod view_mode_tests { f64::from_bits(JSValue::bool(true).bits()) } + /// SABOTAGE-SHAPED: the constant Bloom mask must be the exact bit the + /// descriptor writer records. Defining an accessor AFTER the canonical + /// site was recorded leaves the old data slot intact; without this bit + /// check the fast proof would accept and silently bypass the getter. + #[cfg(feature = "regex-engine")] + #[test] + fn an_accessor_installed_after_recording_makes_the_next_call_decline() { + let cursor = js_segments_view_open(grapheme_segmenter(), js_string("ab")); + assert_eq!(js_segments_view_next(cursor), 1.0); + let re = crate::regex::js_regexp_construct(js_string("[a-z]"), js_string("")); + let re_v = f64::from_bits(JSValue::pointer(re as *const u8).bits()); + assert!( + !is_undefined(js_segments_view_regexp_test(cursor, re_v)), + "premise: the recorded data property is initially canonical" + ); + + let proto_ptr = crate::object::regex_proto_thunks::test_recorded_regexp_prototype(); + assert!(proto_ptr != 0, "the canonical site must have been recorded"); + let proto = proto_ptr as *mut ObjectHeader; + let before = crate::object::js_object_get_field_by_name( + proto, + crate::string::js_string_from_bytes(b"test".as_ptr(), 4), + ); + crate::object::set_accessor_descriptor( + proto as usize, + "test".to_string(), + crate::object::AccessorDescriptor::default(), + ); + assert_eq!( + crate::object::js_object_get_field(proto, unsafe { + // The test deliberately uses the recorded data-slot value as + // its witness: installing the accessor must not overwrite it. + let keys = crate::object::object_keys_array(proto); + let count = crate::array::js_array_length(keys); + (0..count) + .find(|&i| { + let key = crate::array::js_array_get_f64(keys, i); + crate::string::js_string_key_matches_bytes( + JSValue::from_bits(key.to_bits()), + b"test", + ) + }) + .expect("test key") as u32 + }) + .bits(), + before.bits(), + "premise: the accessor install leaves the canonical data slot intact" + ); + assert!( + is_undefined(js_segments_view_regexp_test(cursor, re_v)), + "the accessor Bloom bit must force an immediate decline" + ); + } + /// `_regexp_test` answers the same as the materialised call for a plain /// regex, and DECLINES (three-valued `undefined`) for a global one, whose /// `test` is stateful in `lastIndex`. diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 77a02c25a4..a2832d7e27 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -1295,23 +1295,13 @@ pub fn scan_object_cache_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<' &iterator_prototypes::STRING_ITERATOR_PROTOTYPE_PTR, &iterator_prototypes::REGEXP_STRING_ITERATOR_PROTOTYPE_PTR, &iterator_prototypes::ITERATOR_HELPER_PROTOTYPE_PTR, - // The realm's `RegExp.prototype`, recorded by `regex_proto_thunks` so - // the view mode's canonicality proof is three loads instead of a walk. - // A recorded address MUST be scanned: unscanned, it is a stale pointer - // the first time the collector moves the prototype. - #[cfg(feature = "regex-engine")] - ®ex_proto_thunks::REGEXP_PROTOTYPE_PTR, ] { slot.with_slot(|slot| { visitor.visit_atomic_i64_slot(slot, Ordering::Acquire, Ordering::Release); }); } - // The canonical `test` closure is a NaN-boxed word, not a bare address, so - // it is visited as one — the collector rewrites the pointer inside it. #[cfg(feature = "regex-engine")] - regex_proto_thunks::REGEXP_PROTOTYPE_TEST_CLOSURE.with_slot(|slot| { - visitor.visit_atomic_nanbox_u64_slot(slot, Ordering::Acquire, Ordering::Release); - }); + regex_proto_thunks::scan_canonical_test_site_roots_mut(visitor); } /// Drive the PRODUCTION shape-cache writer from a test. Deliberately nothing diff --git a/crates/perry-runtime/src/object/prototype_chain.rs b/crates/perry-runtime/src/object/prototype_chain.rs index 29a9ce8084..dfac6661a4 100644 --- a/crates/perry-runtime/src/object/prototype_chain.rs +++ b/crates/perry-runtime/src/object/prototype_chain.rs @@ -327,6 +327,25 @@ pub fn object_static_prototype(obj_ptr: usize) -> Option { .and_then(|map| map.get(&obj_ptr).copied()) } +/// Look up the residual prototype registry for a caller that has already +/// proved its receiver is not a shaped `GC_TYPE_OBJECT`. +/// +/// `RegExp` cells meet that precondition. Keeping it explicit lets their hot +/// view-mode proof read the empty latch FIRST and return without paying +/// `meta_capable_object`'s buffer/object/header classification. The ordinary +/// [`object_static_prototype`] remains the entry for unclassified receivers +/// and still checks object-owned metadata before consulting this registry. +#[inline] +pub(crate) fn object_static_prototype_known_non_meta(obj_ptr: usize) -> Option { + if !OBJECT_PROTOTYPES_NONEMPTY.load(Ordering::Acquire) { + return None; + } + get_object_prototypes() + .lock() + .ok() + .and_then(|map| map.get(&obj_ptr).copied()) +} + #[inline] fn object_has_prototype_flag(obj_ptr: usize, flag: u64) -> bool { unsafe { diff --git a/crates/perry-runtime/src/object/regex_proto_thunks.rs b/crates/perry-runtime/src/object/regex_proto_thunks.rs index 389499db5d..4f896e1566 100644 --- a/crates/perry-runtime/src/object/regex_proto_thunks.rs +++ b/crates/perry-runtime/src/object/regex_proto_thunks.rs @@ -316,27 +316,63 @@ fn regex_instance_or_throw(method: &str) -> *const crate::regex::RegExpHeader { )) } +/// The complete install-time proof record for `RegExp.prototype.test`. +/// +/// Keeping the three words behind one [`HotKey`](crate::tls_hot::HotKey) is +/// load-bearing on Darwin: a call resolves one TLS address, not three. The +/// first two fields are GC roots and are visited together by +/// [`scan_canonical_test_site_roots_mut`]. +struct CanonicalTestSite { + /// The realm's `RegExp.prototype`, as a raw heap address. + prototype: std::sync::atomic::AtomicI64, + /// The canonical `test` closure, as a NaN-boxed root word. + closure: std::sync::atomic::AtomicU64, + /// The field index occupied by the prototype's own `test`. + index: std::sync::atomic::AtomicU32, +} + +impl CanonicalTestSite { + const EMPTY: Self = Self { + prototype: std::sync::atomic::AtomicI64::new(0), + closure: std::sync::atomic::AtomicU64::new(0), + index: std::sync::atomic::AtomicU32::new(u32::MAX), + }; +} + crate::perry_thread_local! { - /// The realm's `RegExp.prototype`. A raw heap address, so it is a GC ROOT: - /// visited in `scan_object_cache_roots_mut` beside the iterator-prototype - /// towers, which both marks it and rewrites it when the collector moves the - /// object. A recorded address that is not scanned is a stale pointer the - /// first time the prototype moves — the #9539/#9445 shape. - static REGEXP_PROTOTYPE_PTR_SLOT: std::sync::atomic::AtomicI64 = - const { std::sync::atomic::AtomicI64::new(0) }; - /// The canonical `test` closure, NaN-boxed. Also a root, visited as a - /// nanbox word so the collector rewrites the pointer inside it. - static REGEXP_PROTOTYPE_TEST_CLOSURE_SLOT: std::sync::atomic::AtomicU64 = - const { std::sync::atomic::AtomicU64::new(0) }; - /// The field index its own `test` occupies. Not an address, so not a root. - static REGEXP_PROTOTYPE_TEST_INDEX_SLOT: std::sync::atomic::AtomicU32 = - const { std::sync::atomic::AtomicU32::new(u32::MAX) }; + static REGEXP_PROTOTYPE_TEST_SITE: CanonicalTestSite = const { CanonicalTestSite::EMPTY }; +} + +/// FNV-1a(`"test"`) & 63 = 37. This is the exact bit +/// `descriptor_state::note_meta_descriptor_key` sets when an accessor named +/// `test` is installed. Recording it as a constant removes the four-byte hash +/// from every view-mode regex call. +const TEST_ACCESSOR_KEY_BIT: u64 = 1u64 << 37; + +/// Visit both pointer-bearing fields in the one per-realm record. The raw +/// prototype address and the NaN-boxed closure deliberately use different +/// visitor operations so evacuation rewrites each representation correctly. +#[cfg(feature = "regex-engine")] +pub(crate) fn scan_canonical_test_site_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { + REGEXP_PROTOTYPE_TEST_SITE.with(|site| { + visitor.visit_atomic_i64_slot( + &site.prototype, + std::sync::atomic::Ordering::Acquire, + std::sync::atomic::Ordering::Release, + ); + visitor.visit_atomic_nanbox_u64_slot( + &site.closure, + std::sync::atomic::Ordering::Acquire, + std::sync::atomic::Ordering::Release, + ); + }); } -pub(crate) static REGEXP_PROTOTYPE_PTR: super::RealmAtomicI64 = - super::RealmAtomicI64::new(®EXP_PROTOTYPE_PTR_SLOT); -pub(crate) static REGEXP_PROTOTYPE_TEST_CLOSURE: super::RealmAtomicU64 = - super::RealmAtomicU64::new(®EXP_PROTOTYPE_TEST_CLOSURE_SLOT); +#[cfg(all(test, feature = "regex-engine"))] +pub(crate) fn test_recorded_regexp_prototype() -> i64 { + REGEXP_PROTOTYPE_TEST_SITE + .with(|site| site.prototype.load(std::sync::atomic::Ordering::Acquire)) +} /// How many by-name walks the canonicality proof has done in this process. /// The fast path does none: the only walk is the one-time recording below, so @@ -394,27 +430,33 @@ pub(crate) fn regexp_prototype_test_is_canonical(value: f64) -> bool { // the object recorded below. `object_static_prototype` answers from the // object's own meta record, or from an atomic "nothing was ever recorded" // latch — no mutex, no chain walk. - if super::prototype_chain::object_static_prototype(recv_addr).is_some() { - return false; - } - let proto_ptr = REGEXP_PROTOTYPE_PTR.load(std::sync::atomic::Ordering::Acquire); - let canonical = REGEXP_PROTOTYPE_TEST_CLOSURE.load(std::sync::atomic::Ordering::Acquire); - let index = REGEXP_PROTOTYPE_TEST_INDEX_SLOT - .with(|slot| slot.load(std::sync::atomic::Ordering::Acquire)); - if proto_ptr == 0 || canonical == 0 || index == u32::MAX { + if super::prototype_chain::object_static_prototype_known_non_meta(recv_addr).is_some() { return false; } - let proto_obj = proto_ptr as *mut ObjectHeader; - // Both reads below are of values the collector maintains: the prototype - // address is a scanned root, and the recorded closure is a scanned nanbox - // word, so a move rewrites both and this compare stays an identity compare. - let current = crate::object::js_object_get_field(proto_obj, index); - if current.bits() != canonical { - return false; - } - // `defineProperty(proto, "test", { get })` leaves the data slot alone and - // records the accessor, so the identity compare above cannot see it. - !super::descriptor_state::may_have_descriptor_entry(proto_obj as usize, "test", true) + REGEXP_PROTOTYPE_TEST_SITE.with(|site| { + let proto_ptr = site.prototype.load(std::sync::atomic::Ordering::Acquire); + let canonical = site.closure.load(std::sync::atomic::Ordering::Acquire); + let index = site.index.load(std::sync::atomic::Ordering::Acquire); + if proto_ptr == 0 || canonical == 0 || index == u32::MAX { + return false; + } + let proto_obj = proto_ptr as *mut ObjectHeader; + // Both reads below are of values the collector maintains: the + // prototype address is a scanned root, and the recorded closure is a + // scanned nanbox word, so a move rewrites both and this compare stays + // an identity compare. + let current = crate::object::js_object_get_field(proto_obj, index); + if current.bits() != canonical { + return false; + } + // `defineProperty(proto, "test", { get })` leaves the data slot alone + // and records the accessor. The prototype is an ObjectHeader, so its + // meta edge can be read directly: no cell classification and no key + // hash on this per-call path. A null meta proves no accessor was ever + // installed; the Bloom bit is monotonic once set. + let meta = unsafe { (*proto_obj).meta }; + meta.is_null() || unsafe { (*meta).accessor_key_bits & TEST_ACCESSOR_KEY_BIT == 0 } + }) } /// Record the prototype, the index of its own `test`, and the canonical @@ -457,24 +499,24 @@ fn record_canonical_test_site(proto_obj: *mut ObjectHeader) { if crate::object::js_object_get_field(proto_obj, index).bits() != own.to_bits() { return; } - let addr = proto_obj as i64; - REGEXP_PROTOTYPE_TEST_INDEX_SLOT - .with(|slot| slot.store(index, std::sync::atomic::Ordering::Release)); - // GC_STORE_AUDIT(ROOT): REGEXP_PROTOTYPE_TEST_CLOSURE is a mutable nanbox - // root visited by scan_object_cache_roots_mut. - REGEXP_PROTOTYPE_TEST_CLOSURE.with_slot(|slot| { + REGEXP_PROTOTYPE_TEST_SITE.with(|site| { + site.index + .store(index, std::sync::atomic::Ordering::Release); + // GC_STORE_AUDIT(ROOT): `site.closure` is a mutable nanbox root visited + // by `scan_canonical_test_site_roots_mut`. crate::gc::runtime_store_root_atomic_nanbox_u64( - slot, + &site.closure, own.to_bits(), std::sync::atomic::Ordering::Release, ); + // GC_STORE_AUDIT(ROOT): `site.prototype` is a mutable raw-address root + // visited by `scan_canonical_test_site_roots_mut`. + crate::gc::runtime_store_root_atomic_raw_i64( + &site.prototype, + proto_obj as i64, + std::sync::atomic::Ordering::Release, + ); }); - // GC_STORE_AUDIT(ROOT): REGEXP_PROTOTYPE_PTR is a mutable raw-address root - // visited by scan_object_cache_roots_mut. `RealmAtomicI64::store` routes - // through `runtime_store_root_atomic_raw_i64`, so the heap-word barrier - // runs here too — the sibling closure store spells that out only because - // it goes through `with_slot` and bypasses the wrapper. - REGEXP_PROTOTYPE_PTR.store(addr, std::sync::atomic::Ordering::Release); } /// Install the real (brand-checking) `exec`/`test`/`toString`/`compile` From b5d502f4c60976aa82ad981e86bdf72071ecf716 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 22:14:44 +0200 Subject: [PATCH 2/3] perf(intl): validate view regex pointers once --- .../perry-runtime/src/intl/segments_view.rs | 25 +++++++++++++++++++ crates/perry-runtime/src/regex.rs | 17 ++++++++++--- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/crates/perry-runtime/src/intl/segments_view.rs b/crates/perry-runtime/src/intl/segments_view.rs index 9da9d03abf..998adb37b0 100644 --- a/crates/perry-runtime/src/intl/segments_view.rs +++ b/crates/perry-runtime/src/intl/segments_view.rs @@ -897,6 +897,31 @@ mod view_mode_tests { ); } + /// SABOTAGE-SHAPED: the exported entry validates a RegExp pointer once; + /// the bounded matcher must trust that precondition instead of repeating + /// heap-space classification. Reintroducing the old check makes the delta + /// two, so this test cannot pass merely because the regex is valid. + #[cfg(feature = "regex-engine")] + #[test] + fn bounded_regex_test_does_not_repeat_entry_pointer_validation() { + let cursor = js_segments_view_open(grapheme_segmenter(), js_string("a")); + assert_eq!(js_segments_view_next(cursor), 1.0); + let re = crate::regex::js_regexp_construct(js_string("a"), js_string("")); + let re_v = f64::from_bits(JSValue::pointer(re as *const u8).bits()); + + // Warm the lazily compiled matcher before delimiting the validation + // window; a cold builder deliberately revalidates at its boundary. + assert!(!is_undefined(js_segments_view_regexp_test(cursor, re_v))); + let before = crate::regex::test_regex_ptr_validation_calls(); + assert!(!is_undefined(js_segments_view_regexp_test(cursor, re_v))); + let after = crate::regex::test_regex_ptr_validation_calls(); + assert_eq!( + after - before, + 1, + "one exported view call must validate its regex exactly once" + ); + } + /// The anchors are segment-local: `^`/`$` must bind to the segment's ends, /// not the input's. A start-offset match instead of a bounded haystack /// would make this pass for the first segment and fail for the second. diff --git a/crates/perry-runtime/src/regex.rs b/crates/perry-runtime/src/regex.rs index 2a7c5461e0..7864a06462 100644 --- a/crates/perry-runtime/src/regex.rs +++ b/crates/perry-runtime/src/regex.rs @@ -649,6 +649,8 @@ pub(crate) fn is_valid_ptr(p: *const T) -> bool { /// read garbage from that object if we didn't gate them on this check. #[inline] pub(crate) fn is_valid_regex_ptr(p: *const RegExpHeader) -> bool { + #[cfg(test)] + REGEX_PTR_VALIDATION_CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); if !is_valid_ptr(p) { return false; } @@ -659,6 +661,15 @@ pub(crate) fn is_valid_regex_ptr(p: *const RegExpHeader) -> bool { regex_pointers_contains(p as usize) } +#[cfg(test)] +static REGEX_PTR_VALIDATION_CALLS: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(0); + +#[cfg(test)] +pub(crate) fn test_regex_ptr_validation_calls() -> u64 { + REGEX_PTR_VALIDATION_CALLS.load(std::sync::atomic::Ordering::Relaxed) +} + /// Public: is `addr` a RegExpHeader we allocated via `js_regexp_new`? /// Used by the console/`util.inspect` formatter to print regex literals /// as `/source/flags` instead of `{}` (they're GC_TYPE_REGEXP allocations @@ -1459,9 +1470,9 @@ fn regexp_pattern_is_regexp_like(pattern: f64) -> bool { // caller (`js_segments_view_regexp_test`) references it only under this feature. #[cfg(feature = "regex-engine")] pub(crate) fn regexp_test_str_bounded(re: *const RegExpHeader, hay: &str) -> Option { - if !is_valid_regex_ptr(re) { - return None; - } + // The view entry point has already established `is_valid_regex_ptr(re)`. + // Repeating it here reached heap-space classification on every accepted + // call. Keep this helper crate-private and its precondition explicit. unsafe { if (*re).global || (*re).sticky { return None; From b7376973571b8672a13461be092fb19c27001af3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 22:23:53 +0200 Subject: [PATCH 3/3] perf(intl): access view cursor numbers through fixed slots --- .../perry-runtime/src/intl/segments_view.rs | 141 +++++++++++++++--- scripts/gc_runtime_root_holders.json | 6 + 2 files changed, 123 insertions(+), 24 deletions(-) diff --git a/crates/perry-runtime/src/intl/segments_view.rs b/crates/perry-runtime/src/intl/segments_view.rs index 998adb37b0..cb163364c4 100644 --- a/crates/perry-runtime/src/intl/segments_view.rs +++ b/crates/perry-runtime/src/intl/segments_view.rs @@ -93,6 +93,7 @@ fn cursor_ptr(value: f64) -> Option<*mut ObjectHeader> { Some(obj) } +#[cfg(test)] #[inline(always)] fn num_field(obj: *mut ObjectHeader, index: u32) -> usize { let bits = crate::object::js_object_get_field(obj, index); @@ -104,6 +105,49 @@ fn num_field(obj: *mut ObjectHeader, index: u32) -> usize { } } +/// Direct view of the cursor's fixed inline payload. `cursor_ptr` has already +/// established the class id, and every cursor is allocated with exactly +/// `CURSOR_FIELDS`, so these indexed reads need neither a shape-table lookup +/// nor an overflow check. +#[derive(Clone, Copy)] +struct CursorFields(*mut JSValue); + +impl CursorFields { + #[inline(always)] + unsafe fn from_cursor(cursor: *mut ObjectHeader) -> Self { + Self((cursor as *mut u8).add(std::mem::size_of::()) as *mut JSValue) + } + + #[inline(always)] + unsafe fn value(self, index: u32) -> JSValue { + *self.0.add(index as usize) + } + + #[inline(always)] + unsafe fn number(self, index: u32) -> usize { + let n = self.value(index).to_number(); + if n.is_finite() && n >= 0.0 { + n as usize + } else { + 0 + } + } + + /// Store a value whose representation is provably an IEEE number. Cursor + /// slots 1..=4 are initialized as numbers and no writer stores any other + /// kind, so there is no child edge for the write barrier to remember. + #[inline(always)] + unsafe fn set_number(self, index: u32, value: usize) { + debug_assert!((F_BYTE_START..=F_UTF16_LEN).contains(&index)); + // GC_STORE_AUDIT(NUMBER): `JSValue::number` cannot carry a heap edge; + // `cursor_position_fields_are_never_pointer_typed` pins the invariant + // across every product writer. + self.0 + .add(index as usize) + .write(JSValue::number(value as f64)); + } +} + #[inline(always)] fn set_num_field(obj: *mut ObjectHeader, index: u32, value: usize) { crate::object::js_object_set_field(obj, index, JSValue::number(value as f64)); @@ -136,8 +180,11 @@ fn set_num_field(obj: *mut ObjectHeader, index: u32, value: usize) { /// A `debug_assert` re-checks it in debug builds, which is where a future /// fourth writer to the slot would be caught. #[inline] -fn with_input(cursor: *mut ObjectHeader, f: impl FnOnce(&str) -> R) -> Option { - let value = crate::object::js_object_get_field(cursor, F_INPUT); +fn with_input(fields: CursorFields, f: impl FnOnce(&str) -> R) -> Option { + // SAFETY: `fields` was derived at entry from a branded cursor, and slot 0 + // is the traced input value. Reading it here is the §9a re-derivation; no + // address derived from it is retained beyond this call. + let value = unsafe { fields.value(F_INPUT) }; let mut sso = [0u8; crate::value::SHORT_STRING_MAX_LEN]; let bytes = unsafe { crate::string::js_string_key_bytes(JSValue::from_bits(value.bits()), &mut sso) }?; @@ -249,7 +296,7 @@ pub extern "C" fn js_segments_view_open(segmenter: f64, input: f64) -> f64 { } /// Advance to the next grapheme boundary. `1.0` if a segment is now current, -/// `0.0` at the end. **Allocation-free by contract**: three integer field +/// `0.0` at the end. **Allocation-free by contract**: four integer field /// writes and a UAX #29 boundary scan, no arena allocation, no owned `String`, /// no descriptor insert — which is also why it cannot collect. #[no_mangle] @@ -257,19 +304,23 @@ pub extern "C" fn js_segments_view_next(cursor: f64) -> f64 { let Some(c) = cursor_ptr(cursor) else { return 0.0; }; - let from = num_field(c, F_BYTE_END); - let utf16_start = num_field(c, F_UTF16_START) + num_field(c, F_UTF16_LEN); - let step = with_input(c, |text| { + // SAFETY: `cursor_ptr` proved the fixed cursor layout. + let fields = unsafe { CursorFields::from_cursor(c) }; + let from = unsafe { fields.number(F_BYTE_END) }; + let utf16_start = unsafe { fields.number(F_UTF16_START) + fields.number(F_UTF16_LEN) }; + let step = with_input(fields, |text| { next_boundary(text, from).map(|next| (next, super::segmenter::utf16_len(&text[from..next]))) }) .flatten(); let Some((next, seg_u16)) = step else { return 0.0; }; - set_num_field(c, F_BYTE_START, from); - set_num_field(c, F_UTF16_START, utf16_start); - set_num_field(c, F_BYTE_END, next); - set_num_field(c, F_UTF16_LEN, seg_u16 as usize); + unsafe { + fields.set_number(F_BYTE_START, from); + fields.set_number(F_UTF16_START, utf16_start); + fields.set_number(F_BYTE_END, next); + fields.set_number(F_UTF16_LEN, seg_u16 as usize); + } bump(&NEXTS); 1.0 } @@ -291,14 +342,16 @@ pub extern "C" fn js_segments_view_code_point_at(cursor: f64, k: f64) -> f64 { if !k.is_finite() || k < 0.0 || k.fract() != 0.0 { return undef; } + // SAFETY: `cursor_ptr` proved the fixed cursor layout. + let fields = unsafe { CursorFields::from_cursor(c) }; let k = k as usize; - if k >= num_field(c, F_UTF16_LEN) { + if k >= unsafe { fields.number(F_UTF16_LEN) } { return undef; } - let start = num_field(c, F_BYTE_START); - let end = num_field(c, F_BYTE_END); + let start = unsafe { fields.number(F_BYTE_START) }; + let end = unsafe { fields.number(F_BYTE_END) }; bump(&CODE_POINT_ATS); - with_input(c, |text| { + with_input(fields, |text| { let seg = &text[start..end]; let mut utf16_pos = 0usize; for ch in seg.chars() { @@ -329,14 +382,16 @@ pub extern "C" fn js_segments_view_segment(cursor: f64) -> f64 { let Some(c) = cursor_ptr(cursor) else { return undef; }; - let start = num_field(c, F_BYTE_START); - let end = num_field(c, F_BYTE_END); + // SAFETY: `cursor_ptr` proved the fixed cursor layout. + let fields = unsafe { CursorFields::from_cursor(c) }; + let start = unsafe { fields.number(F_BYTE_START) }; + let end = unsafe { fields.number(F_BYTE_END) }; bump(&MATERIALISE_SEGMENT); // The allocation happens INSIDE the borrow, so the borrow must not outlive // it: take the bytes out first, then allocate from a copy on the stack path // `js_string_from_bytes` performs. Nothing derived from the input survives // this call. - let made = with_input(c, |text| { + let made = with_input(fields, |text| { let seg = &text[start..end]; crate::string::js_string_from_bytes(seg.as_ptr(), seg.len() as u32) }); @@ -398,9 +453,11 @@ pub extern "C" fn js_segments_view_regexp_test(cursor: f64, regex: f64) -> f64 { bump(®EXP_TEST_DECLINED); return undef; } - let start = num_field(c, F_BYTE_START); - let end = num_field(c, F_BYTE_END); - let verdict = with_input(c, |text| { + // SAFETY: `cursor_ptr` proved the fixed cursor layout. + let fields = unsafe { CursorFields::from_cursor(c) }; + let start = unsafe { fields.number(F_BYTE_START) }; + let end = unsafe { fields.number(F_BYTE_END) }; + let verdict = with_input(fields, |text| { crate::regex::regexp_test_str_bounded(re, &text[start..end]) }) .flatten(); @@ -618,6 +675,40 @@ mod view_mode_tests { ); } + /// SABOTAGE-SHAPED: slots 1..=4 are the proof that `_next` may bypass the + /// generic JSValue store barrier. A future writer that puts a pointer in + /// any position slot makes this fail at the exact step where the invariant + /// is broken; slot 0 is intentionally excluded because it is the traced + /// input string. + #[test] + fn cursor_position_fields_are_never_pointer_typed() { + let cursor = js_segments_view_open( + grapheme_segmenter(), + js_string("a\u{301}b\u{1f469}\u{200d}\u{1f4bb}cd"), + ); + assert!(cursor != 0.0); + let c = cursor_ptr(cursor).expect("branded cursor"); + let fields = unsafe { CursorFields::from_cursor(c) }; + let mut steps = 0usize; + loop { + for index in F_BYTE_START..=F_UTF16_LEN { + let value = unsafe { fields.value(index) }; + assert!( + value.is_number() && !value.is_pointer(), + "cursor position slot {index} must be number-only before step {steps}" + ); + } + if js_segments_view_next(cursor) != 1.0 { + break; + } + steps += 1; + } + assert!( + steps >= 4, + "the invariant must be checked across real steps" + ); + } + /// The rooting obligation of §9e, exercised rather than asserted: `open` /// allocates the cursor while holding the input, so a collection landing in /// that window must not leave a dead value in the traced slot. Force a @@ -850,10 +941,12 @@ mod view_mode_tests { (0..count) .find(|&i| { let key = crate::array::js_array_get_f64(keys, i); - crate::string::js_string_key_matches_bytes( - JSValue::from_bits(key.to_bits()), - b"test", - ) + unsafe { + crate::string::js_string_key_matches_bytes( + JSValue::from_bits(key.to_bits()), + b"test", + ) + } }) .expect("test key") as u32 }) diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index f1582b60c2..9b51ae156b 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -589,6 +589,12 @@ "verdict": "test_only", "why": "#[cfg(test)] diagnostic trace for the bound-method moving-GC regression: records the (before, after) addresses a test-forced minor produced so the test can assert the relocation happened. The addresses are compared as integers, never dereferenced, and the cell is dead in a shipped binary." }, + { + "file": "crates/perry-runtime/src/object/regex_proto_thunks.rs", + "name": "REGEXP_PROTOTYPE_TEST_WALKS", + "verdict": "not_a_gc_pointer", + "why": "View-mode diagnostic tally of install-time RegExp.prototype.test walks. A plain AtomicU64 incremented once by record_canonical_test_site and read as a count by tests; it never stores an address or NaN-boxed value. The actual prototype and closure roots live together in REGEXP_PROTOTYPE_TEST_SITE and are visited by scan_canonical_test_site_roots_mut." + }, { "file": "crates/perry-runtime/src/object/read_stub.rs", "name": "READ_STUB",