diff --git a/crates/perry-codegen/src/collectors/segview.rs b/crates/perry-codegen/src/collectors/segview.rs index 990f31b646..019b857ef2 100644 --- a/crates/perry-codegen/src/collectors/segview.rs +++ b/crates/perry-codegen/src/collectors/segview.rs @@ -958,10 +958,17 @@ fn describe(v: &SegViewVerdict) -> String { // labels correct and avoids duplicating any closure the body contains — a // duplicated `Expr::Closure` would carry a duplicate `FuncId`. -/// `PERRY_SEGVIEW=1`. **Default OFF**: the runtime's view entry points do not -/// exist yet, so an on-by-default rewrite would emit calls that fail to link. +/// **Default ON.** `PERRY_SEGVIEW=0` opts out. +/// +/// It shipped default OFF because the runtime's view entry points did not +/// exist; #9870 landed them on main, and on claude-code the tier with #9893's +/// levers is -14 % CPU and -30…-42 MB peak RSS with identical output. +/// +/// The opt-out stays: a program whose loops the tier declines pays nothing, +/// but a switch that changes emitted code needs an off position that does not +/// require a rebuild of the compiler. pub fn segview_lowering_enabled() -> bool { - matches!(std::env::var("PERRY_SEGVIEW"), Ok(v) if !v.is_empty() && v != "0") + !matches!(std::env::var("PERRY_SEGVIEW"), Ok(v) if v == "0") } fn extern_call(name: &str, args: Vec) -> Expr { diff --git a/crates/perry-codegen/src/collectors/segview_tests.rs b/crates/perry-codegen/src/collectors/segview_tests.rs index 9783faf640..da42520b79 100644 --- a/crates/perry-codegen/src/collectors/segview_tests.rs +++ b/crates/perry-codegen/src/collectors/segview_tests.rs @@ -552,3 +552,27 @@ fn the_cursor_is_cleared_at_loop_exit() { other => panic!("no cursor clear after the loop: {other:?}"), } } + +/// The default is ON, and `PERRY_SEGVIEW=0` is the opt-out. +/// +/// Asserted rather than assumed because the switch is read through the +/// environment: a typo in the predicate that made it always-false would leave +/// every test above passing (they call the rewrite directly) while the tier +/// silently never fired in a real compile — the #9824 shape. +#[test] +fn the_lowering_is_on_by_default_and_zero_opts_out() { + use super::segview::segview_lowering_enabled; + // These mutate process-wide state, so they live in one test rather than + // three; `cargo test` runs test fns concurrently and separate tests would + // race on the same variable. + std::env::remove_var("PERRY_SEGVIEW"); + assert!( + segview_lowering_enabled(), + "unset must mean ON — that is what 'default on' means" + ); + std::env::set_var("PERRY_SEGVIEW", "0"); + assert!(!segview_lowering_enabled(), "`0` is the opt-out"); + std::env::set_var("PERRY_SEGVIEW", "1"); + assert!(segview_lowering_enabled(), "`1` stays on"); + std::env::remove_var("PERRY_SEGVIEW"); +} diff --git a/crates/perry-runtime/src/intl/segments_view.rs b/crates/perry-runtime/src/intl/segments_view.rs index fbadacf6a9..72e777c8b3 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(POINTER_FREE): `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 @@ -791,8 +882,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 +904,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`. @@ -844,6 +988,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/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 4d247a88a9..2922c02563 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 f5ee37dffa..f9100deb31 100644 --- a/crates/perry-runtime/src/object/prototype_chain.rs +++ b/crates/perry-runtime/src/object/prototype_chain.rs @@ -330,6 +330,29 @@ 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] +// #9917 added this for the recorded canonical-test-site proof, whose only +// caller is regex-engine gated; without the feature it is dead in a product +// build. Same gate as the rest of that surface (#9970). +#[cfg(any(test, feature = "regex-engine"))] +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 58bbc07d59..21b3c0aeb0 100644 --- a/crates/perry-runtime/src/object/regex_proto_thunks.rs +++ b/crates/perry-runtime/src/object/regex_proto_thunks.rs @@ -316,32 +316,67 @@ fn regex_instance_or_throw(method: &str) -> *const crate::regex::RegExpHeader { )) } -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. - #[cfg(any(test, feature = "regex-engine"))] - 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. - #[cfg(any(test, feature = "regex-engine"))] - 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. - #[cfg(any(test, feature = "regex-engine"))] - static REGEXP_PROTOTYPE_TEST_INDEX_SLOT: std::sync::atomic::AtomicU32 = - const { std::sync::atomic::AtomicU32::new(u32::MAX) }; +/// 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`]. +#[cfg(any(test, feature = "regex-engine"))] +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, +} + +#[cfg(any(test, feature = "regex-engine"))] +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), + }; } #[cfg(any(test, feature = "regex-engine"))] -pub(crate) static REGEXP_PROTOTYPE_PTR: super::RealmAtomicI64 = - super::RealmAtomicI64::new(®EXP_PROTOTYPE_PTR_SLOT); +crate::perry_thread_local! { + 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. #[cfg(any(test, feature = "regex-engine"))] -pub(crate) static REGEXP_PROTOTYPE_TEST_CLOSURE: super::RealmAtomicU64 = - super::RealmAtomicU64::new(®EXP_PROTOTYPE_TEST_CLOSURE_SLOT); +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, + ); + }); +} + +#[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 @@ -400,27 +435,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 { - 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 { + if super::prototype_chain::object_static_prototype_known_non_meta(recv_addr).is_some() { 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 @@ -463,24 +504,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` diff --git a/crates/perry-runtime/src/regex.rs b/crates/perry-runtime/src/regex.rs index 14a77af6a7..7f72e0be76 100644 --- a/crates/perry-runtime/src/regex.rs +++ b/crates/perry-runtime/src/regex.rs @@ -641,6 +641,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; } @@ -651,6 +653,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 @@ -1429,9 +1440,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; diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 46329aa2af..6c9573b747 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -638,37 +638,17 @@ "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/read_stub.rs", - "name": "READ_STUB", - "verdict": "not_a_gc_pointer", - "why": "Megamorphic property-read stub cache, the read twin of WRITE_STUB: 2-way ways of (shape_token, key_bits, slot) plain u64s. The token is a shape id and the slot an index; key_bits are content-derived by construction, because read_stub_key_bits returns short_ascii_sso_bits(key) \u2014 the key's characters packed inline \u2014 and yields None for any key that would otherwise be stored under a pointer. No way holds a heap address, so nothing here keeps an object alive, and a stale entry cannot hit: receiver_shape_token returns None for a receiver with no live shape, and the token identifies the exact key set and order, so a shape change yields a different token." - }, - { - "file": "crates/perry-runtime/src/object/regex_proto_thunks.rs", - "name": "REGEXP_PROTOTYPE_PTR_SLOT", - "verdict": "covered_elsewhere", - "why": "#9893: the realm's `RegExp.prototype` address, recorded so the view mode's canonicality proof is three loads instead of a by-name walk. It IS a root and it IS scanned: `object::scan_object_cache_roots_mut` (registered via `reg_scanner!` in gc/mod.rs) visits it with `visit_atomic_i64_slot` beside the iterator-prototype towers, which marks it and rewrites it when the collector moves the prototype. The walk does not reach it because access goes through the `RealmAtomicI64` wrapper's `with_slot`, not a direct static read.", - "scanner": "object::scan_object_cache_roots_mut (crates/perry-runtime/src/object/mod.rs), registered by reg_scanner! in crates/perry-runtime/src/gc/mod.rs" - }, { "file": "crates/perry-runtime/src/object/regex_proto_thunks.rs", - "name": "REGEXP_PROTOTYPE_TEST_CLOSURE_SLOT", - "verdict": "covered_elsewhere", - "why": "#9893: the canonical `RegExp.prototype.test` closure, NaN-BOXED rather than a bare address, and visited as such \u2014 `scan_object_cache_roots_mut` uses `visit_atomic_nanbox_u64_slot` so the collector rewrites the pointer inside the word. Same wrapper indirection as `REGEXP_PROTOTYPE_PTR_SLOT` above.", - "scanner": "object::scan_object_cache_roots_mut (crates/perry-runtime/src/object/mod.rs), registered by reg_scanner! in crates/perry-runtime/src/gc/mod.rs" - }, - { - "file": "crates/perry-runtime/src/object/regex_proto_thunks.rs", - "name": "REGEXP_PROTOTYPE_TEST_INDEX_SLOT", + "name": "REGEXP_PROTOTYPE_TEST_WALKS", "verdict": "not_a_gc_pointer", - "why": "#9893: the field INDEX `test` occupies on the prototype \u2014 a `u32` ordinal, not an address, sentinel `u32::MAX`. Nothing for the collector to mark or rewrite." + "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/regex_proto_thunks.rs", - "name": "REGEXP_PROTOTYPE_TEST_WALKS", + "file": "crates/perry-runtime/src/object/read_stub.rs", + "name": "READ_STUB", "verdict": "not_a_gc_pointer", - "why": "#9893: how many by-name canonicality walks this process has done \u2014 a plain `u64` count whose whole purpose is to read 1 per realm and thereby prove the fast path is the path being taken. A NUMBER, never an address." + "why": "Megamorphic property-read stub cache, the read twin of WRITE_STUB: 2-way ways of (shape_token, key_bits, slot) plain u64s. The token is a shape id and the slot an index; key_bits are content-derived by construction, because read_stub_key_bits returns short_ascii_sso_bits(key) \u2014 the key's characters packed inline \u2014 and yields None for any key that would otherwise be stored under a pointer. No way holds a heap address, so nothing here keeps an object alive, and a stale entry cannot hit: receiver_shape_token returns None for a receiver with no live shape, and the token identifies the exact key set and order, so a shape change yields a different token." }, { "file": "crates/perry-runtime/src/object/shapes.rs",