Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions crates/perry-codegen/src/collectors/segview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>) -> Expr {
Expand Down
24 changes: 24 additions & 0 deletions crates/perry-codegen/src/collectors/segview_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
213 changes: 191 additions & 22 deletions crates/perry-runtime/src/intl/segments_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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::<ObjectHeader>()) 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));
Expand Down Expand Up @@ -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<R>(cursor: *mut ObjectHeader, f: impl FnOnce(&str) -> R) -> Option<R> {
let value = crate::object::js_object_get_field(cursor, F_INPUT);
fn with_input<R>(fields: CursorFields, f: impl FnOnce(&str) -> R) -> Option<R> {
// 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) }?;
Expand Down Expand Up @@ -249,27 +296,31 @@ 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]
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
}
Expand All @@ -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() {
Expand Down Expand Up @@ -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)
});
Expand Down Expand Up @@ -398,9 +453,11 @@ pub extern "C" fn js_segments_view_regexp_test(cursor: f64, regex: f64) -> f64 {
bump(&REGEXP_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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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`.
Expand Down Expand Up @@ -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.
Expand Down
12 changes: 1 addition & 11 deletions crates/perry-runtime/src/object/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
&regex_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
Expand Down
Loading
Loading