diff --git a/Cargo.lock b/Cargo.lock index 554bbe3def..e217c7c9d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5684,9 +5684,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perex" -version = "0.1.0" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b2094dda4d997bf73a372cb660d02e9abdb0a13cbf834ddd2b2f8847bffe2cf" +checksum = "060b4682849d20ebcba05d68f9584a1bba20af4b7838c688cfb37af572f562f4" [[package]] name = "perry" diff --git a/Cargo.toml b/Cargo.toml index c12fd3abd3..28dd89f1d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -399,7 +399,7 @@ chrono = "0.4" regex = "1.12" aho-corasick = "1.1" # The single regular-expression engine, through crates/perry-perex. -perex = "0.1" +perex = "0.1.3" hex = "0.4" tempfile = "3" itoa = "1.0" diff --git a/changelog.d/10174-regex-bind-once-forward-split.md b/changelog.d/10174-regex-bind-once-forward-split.md new file mode 100644 index 0000000000..d0a641b80c --- /dev/null +++ b/changelog.d/10174-regex-bind-once-forward-split.md @@ -0,0 +1,3 @@ +### Performance + +- **RegExp `split`, `replace` and global `match` bind the subject and program once per operation, and `split` searches forward** (#10165). Each search used to decode the whole string and revalidate the whole program, so these operations did quadratic work: an ASCII `split(/[,; ]+/)` of 150,000 units took 8.1 s. They are now linear. `split` also searches for the next match instead of trying a sticky match at every position, whenever nothing can observe the difference (an absent or intrinsic `RegExp` species and the builtin `exec`), which brings ASCII split to about 13× Node from about 220×. diff --git a/changelog.d/10181-regex-resume-from-position.md b/changelog.d/10181-regex-resume-from-position.md new file mode 100644 index 0000000000..30f275d619 --- /dev/null +++ b/changelog.d/10181-regex-resume-from-position.md @@ -0,0 +1,3 @@ +### Performance + +- **Non-ASCII RegExp `split`, `replace` and global `match` resume each search from where the previous one stopped** (#10164). On non-ASCII strings every search sought its start from an end of the string, which made these operations quadratic; they are now linear (log-log slope 1.02–1.05, from about 1.8). Capture strings are read from the match's position the same way. Requires `perex` 0.1.2. diff --git a/changelog.d/10183-regex-cross-call-rebinding.md b/changelog.d/10183-regex-cross-call-rebinding.md new file mode 100644 index 0000000000..ad30bdc592 --- /dev/null +++ b/changelog.d/10183-regex-cross-call-rebinding.md @@ -0,0 +1,3 @@ +### Performance + +- **A RegExp search per JavaScript call binds its program and subject in constant work** (#10166). JS-level `exec` and `test` loops, `matchAll` iteration and `search` used to decode the whole string and revalidate the whole program on every call, so a loop over one string did quadratic work and each `.test()` paid a full program validation. A compiled program now keeps a small witness of its validation, and a string remembers that its bytes validated, both as plain data with no extra garbage-collector work. Non-ASCII subjects still seek from the nearer end once per call. Requires `perex` 0.1.3. diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots.rs b/crates/perry-runtime/src/gc/tests/runtime_roots.rs index 58feac5ab8..6da1f4dec9 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots.rs @@ -19,6 +19,8 @@ mod old_defrag_contract; #[cfg(feature = "regex-engine")] mod perex_construction; #[cfg(feature = "regex-engine")] +mod perex_cross_call; +#[cfg(feature = "regex-engine")] mod perex_dispatch; #[cfg(feature = "regex-engine")] mod perex_execution; @@ -37,6 +39,8 @@ mod perex_public; #[cfg(feature = "regex-engine")] mod perex_replace; #[cfg(feature = "regex-engine")] +mod perex_reuse; +#[cfg(feature = "regex-engine")] mod perex_split; #[cfg(feature = "regex-engine")] mod perex_strings; diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_cross_call.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_cross_call.rs new file mode 100644 index 0000000000..4f8811386e --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_cross_call.rs @@ -0,0 +1,227 @@ +//! Cross-call rebinding (#10166): a program cell keeps a plain-data witness of +//! its validated words, and a string header remembers that its payload +//! validated, so a search per JavaScript call binds both in constant work. +use super::*; +use crate::regex::perex_api as api; +use crate::regex::perex_owner::{cell_witness, set_cell_witness, GcProgram}; +use crate::regex::perex_runtime::EngineError; +use crate::regex::RegExpHeader; +use crate::string::{StringHeader, STRING_FLAG_WTF8_VALIDATED}; +use crate::value::js_nanbox_string; +use perex::binding::ImmutableProgram; + +fn text<'s>(scope: &'s RuntimeHandleScope, bytes: &[u8]) -> RuntimeHandle<'s> { + scope.root_string_ptr(crate::string::js_string_from_bytes( + bytes.as_ptr(), + bytes.len() as u32, + )) +} + +fn regex<'s>(scope: &'s RuntimeHandleScope, pattern: &str) -> RuntimeHandle<'s> { + let pattern = text(scope, pattern.as_bytes()); + let flags = text(scope, b""); + scope.root_raw_mut_ptr(pattern.with_const_ptr::(|pattern| { + flags.with_const_ptr::(|flags| crate::regex::js_regexp_new(pattern, flags)) + })) +} + +/// One non-global search: the full match's UTF-16 span, or None. +fn search( + re: &RuntimeHandle<'_>, + input: &RuntimeHandle<'_>, +) -> Result, EngineError> { + re.with_mut_ptr::(|re| { + input.with_const_ptr::(|input| { + api::execute(re, input, false, &mut || Ok(())) + .map(|found| found.map(|m| (m.full.start(), m.full.end()))) + }) + }) +} + +/// The witness in the RegExp's current program cell. +fn witness(re: &RuntimeHandle<'_>) -> Option { + re.with_const_ptr::(|re| unsafe { cell_witness((*re).perex_program) }) +} + +fn set_witness(re: &RuntimeHandle<'_>, value: Option) { + re.with_const_ptr::(|re| unsafe { + set_cell_witness((*re).perex_program, value) + }); +} + +fn recompile(scope: &RuntimeHandleScope, re: &RuntimeHandle<'_>, pattern: &str) { + let pattern = text(scope, pattern.as_bytes()); + let flags = text(scope, b""); + let p = pattern.with_const_ptr::(|p| js_nanbox_string(p as i64)); + let f = flags.with_const_ptr::(|f| js_nanbox_string(f as i64)); + re.with_mut_ptr::(|re| crate::regex::js_regexp_compile_value(re, p, f)); +} + +fn validated(s: &RuntimeHandle<'_>) -> bool { + s.with_const_ptr::(|s| unsafe { (*s).flags & STRING_FLAG_WTF8_VALIDATED != 0 }) +} + +#[test] +fn perex_program_witness_lives_with_its_cell_and_a_recompile_starts_without_one() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + let scope = RuntimeHandleScope::new(); + let input = text(&scope, b"xb xa"); + + let re = regex(&scope, "x(a)"); + assert_eq!( + witness(&re), + None, + "a freshly compiled program has no witness" + ); + assert_eq!(search(&re, &input).unwrap(), Some((3, 5))); + let wa = witness(&re).expect("the first validating bind records the witness"); + + recompile(&scope, &re, "x(b)"); + assert_eq!( + witness(&re), + None, + "a recompile emits a new cell, which starts without a witness" + ); + assert_eq!(search(&re, &input).unwrap(), Some((0, 2))); + let wb = witness(&re).expect("the new cell records its own witness"); + + // Precondition for the next step: these programs share length and header. + assert_eq!( + wa, wb, + "precondition: x(a) and x(b) must share length and header" + ); + // Even a foreign witness over an equal header binds the cell's CURRENT + // words, which Perex emitted: the answer is this program's. + recompile(&scope, &re, "x(a)"); + set_witness(&re, Some(wb)); + assert_eq!(search(&re, &input).unwrap(), Some((3, 5))); +} + +#[test] +fn perex_program_witness_that_does_not_match_falls_back_to_validation() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + let scope = RuntimeHandleScope::new(); + let input = text(&scope, b"ba"); + let a = regex(&scope, "a"); + let b = regex(&scope, "b"); + assert_eq!(search(&a, &input).unwrap(), Some((1, 2))); + assert_eq!(search(&b, &input).unwrap(), Some((0, 1))); + let (wa, wb) = (witness(&a).unwrap(), witness(&b).unwrap()); + assert_ne!( + wa, wb, + "precondition: a and b must differ in length or header" + ); + + set_witness(&a, Some(wb)); + assert_eq!(search(&a, &input).unwrap(), Some((1, 2))); + assert_eq!( + witness(&a), + Some(wa), + "a mismatched witness is replaced by validation" + ); +} + +#[test] +fn perex_subject_is_marked_only_after_it_validates_with_its_exact_length() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + let scope = RuntimeHandleScope::new(); + let digits = regex(&scope, r"\d+"); + + let good = text(&scope, "ä1 b22 c333 longer than inline".as_bytes()); + assert!(!validated(&good)); + assert_eq!(search(&digits, &good).unwrap(), Some((1, 2))); + assert!( + validated(&good), + "a valid payload with an exact length is marked" + ); + assert_eq!( + search(&digits, &good).unwrap(), + Some((1, 2)), + "the counted bind answers the same" + ); + + let wrong_length = text(&scope, b"abcdef1 and longer than inline"); + wrong_length.with_const_ptr::(|s| unsafe { + (*(s as *mut StringHeader)).utf16_len = 3; + }); + let _ = search(&digits, &wrong_length); + assert!( + !validated(&wrong_length), + "a header whose length disagrees with its payload must never be trusted" + ); + + let malformed = text(&scope, b"\xff\xfe 1 malformed and longer than inline"); + let result = search(&digits, &malformed); + assert!( + result.is_err(), + "malformed WTF-8 is rejected as before: {result:?}" + ); + assert!( + !validated(&malformed), + "a payload that failed to validate is never marked" + ); +} + +#[test] +fn perex_cross_call_marks_survive_moving_collections() { + let _guard = CopyingNurseryTestGuard::new(0); + let _scan = ConservativeScanDisabledGuard::new(); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _force = ForcedEvacuationTestGuard::on(); + super::perex_public::register_host_roots(); + let scope = RuntimeHandleScope::new(); + let re = regex(&scope, r"\d+"); + let input = text( + &scope, + "ö7 and some text after it, beyond inline".as_bytes(), + ); + assert_eq!(search(&re, &input).unwrap(), Some((1, 2))); + let (w, marked) = (witness(&re), validated(&input)); + assert!(w.is_some() && marked); + let (re_before, input_before) = ( + re.with_const_ptr::(|p| p as usize), + input.with_const_ptr::(|p| p as usize), + ); + let cycles = copying_minor_cycles(); + gc_collect_minor(); + gc_collect_minor(); + assert!(copying_minor_cycles() > cycles); + assert_ne!( + input.with_const_ptr::(|p| p as usize), + input_before, + "the string must move" + ); + assert_ne!( + re.with_const_ptr::(|p| p as usize), + re_before, + "the RegExp must move" + ); + assert_eq!(witness(&re), w, "the witness moves with its RegExp"); + assert!(validated(&input), "the mark moves with its string"); + assert_eq!(search(&re, &input).unwrap(), Some((1, 2))); +} + +/// Debug builds prove a witness is never written under a live view of the +/// words it describes (#10166). +#[test] +#[cfg(debug_assertions)] +#[should_panic(expected = "must not be written while a view of its words is live")] +fn perex_program_witness_write_under_a_live_view_is_caught() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + let scope = RuntimeHandleScope::new(); + let re = regex(&scope, "q+"); + let input = text(&scope, b"qq"); + assert_eq!(search(&re, &input).unwrap(), Some((0, 2))); + let witness = witness(&re).expect("validated"); + let owner = unsafe { GcProgram::from_receiver(&scope, &re) }.unwrap(); + let root = owner.root(); + let _ = owner.with_words(|_words| GcProgram::record_witness(&root, witness)); +} diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_dispatch.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_dispatch.rs index 0c38320d46..2c4438effd 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_dispatch.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_dispatch.rs @@ -238,6 +238,7 @@ fn perex_dispatch_getter_and_callback_reacquire_original_input_after_gc() { &mut Budget::new(api::WORK), &MemoryBudget::new(api::SCRATCH_BYTES), &mut crate::regex::perex_runtime::poll, + None, )) .unwrap() .object(); @@ -349,7 +350,8 @@ fn perex_dispatch_validates_override_results_and_keeps_one_work_allowance() { false, &mut budget, &memory, - &mut crate::regex::perex_runtime::poll + &mut crate::regex::perex_runtime::poll, + None, )) .is_some()); assert_eq!(budget.remaining(), expected); @@ -361,7 +363,8 @@ fn perex_dispatch_validates_override_results_and_keeps_one_work_allowance() { false, &mut budget, &memory, - &mut crate::regex::perex_runtime::poll + &mut crate::regex::perex_runtime::poll, + None, ), Err(EngineError::Execution( perex::executor::ExecError::WorkLimit diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_reuse.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_reuse.rs new file mode 100644 index 0000000000..3eddd46247 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_reuse.rs @@ -0,0 +1,326 @@ +//! Compound-operation binding reuse (#10165): one subject and program binding +//! serves every search of an operation, across actual moving collections, and +//! is abandoned whenever the receiver's program or the string is not the one +//! it bound. +use super::*; +use crate::array::ArrayHeader; +use crate::regex::perex_api::{self as api, Reuse}; +use crate::regex::perex_memory::MemoryBudget; +use crate::regex::perex_owner::HeapSubject; +use crate::regex::RegExpHeader; +use crate::string::StringHeader; +use crate::value::{js_nanbox_pointer, js_nanbox_string}; +use perex::binding::BoundSubject; +use perex::Budget; + +fn text<'s>(scope: &'s RuntimeHandleScope, bytes: &[u8]) -> RuntimeHandle<'s> { + scope.root_string_ptr(crate::string::js_string_from_bytes( + bytes.as_ptr(), + bytes.len() as u32, + )) +} + +/// A NaN-boxed receiver handle, as split/replace/match root their receivers. +fn regex<'s>(scope: &'s RuntimeHandleScope, pattern: &str, flags: &str) -> RuntimeHandle<'s> { + let pattern = text(scope, pattern.as_bytes()); + let flags = text(scope, flags.as_bytes()); + let re = pattern.with_const_ptr::(|pattern| { + flags.with_const_ptr::(|flags| crate::regex::js_regexp_new(pattern, flags)) + }); + scope.root_nanbox_f64(js_nanbox_pointer(re as i64)) +} + +fn receiver_ptr(receiver: &RuntimeHandle<'_>) -> *mut RegExpHeader { + crate::value::js_nanbox_get_pointer(receiver.get_nanbox_f64()) as *mut RegExpHeader +} + +fn first_item(scope: &RuntimeHandleScope, array: *mut ArrayHeader) -> Vec { + let array = scope.root_raw_mut_ptr(array); + let value = array.with_const_ptr::(|a| crate::array::js_array_get_f64(a, 0)); + let mut scratch = [0; crate::value::SHORT_STRING_MAX_LEN]; + let (data, len) = crate::string::str_bytes_from_jsvalue(value, &mut scratch).unwrap(); + unsafe { std::slice::from_raw_parts(data, len as usize).to_vec() } +} + +/// Run a global exec loop to exhaustion with a collection at every poll, +/// returning each full match and the work the loop charged. +fn global_loop( + receiver: &RuntimeHandle<'_>, + input: &RuntimeHandle<'_>, + reuse: Option<&Reuse<'_, '_>>, +) -> (Vec>, usize) { + global_loop_collecting(receiver, input, reuse, true) +} + +fn global_loop_collecting( + receiver: &RuntimeHandle<'_>, + input: &RuntimeHandle<'_>, + reuse: Option<&Reuse<'_, '_>>, + collect: bool, +) -> (Vec>, usize) { + let memory = MemoryBudget::new(api::SCRATCH_BYTES); + let mut budget = Budget::new(api::WORK); + let mut matches = Vec::new(); + let roots = RuntimeHandleScope::active_len_for_tests(); + loop { + let iteration = RuntimeHandleScope::new(); + // Re-read both addresses every search: the previous one collected. + let found = input + .with_const_ptr::(|input| { + api::execute_with_resources( + receiver_ptr(receiver), + input, + true, + &mut budget, + &memory, + &mut || { + if collect { + gc_collect_minor(); + } + Ok(()) + }, + reuse, + ) + }) + .unwrap(); + let Some(found) = found else { break }; + matches.push(first_item(&iteration, found.array)); + drop(iteration); + assert_eq!(RuntimeHandleScope::active_len_for_tests(), roots); + } + (matches, api::WORK - budget.remaining()) +} + +#[test] +fn perex_reuse_serves_a_whole_global_loop_across_moving_collections() { + let _guard = CopyingNurseryTestGuard::new(0); + let _scan = ConservativeScanDisabledGuard::new(); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _force = ForcedEvacuationTestGuard::on(); + super::perex_public::register_host_roots(); + let scope = RuntimeHandleScope::new(); + // Non-ASCII storage (the byte representation, not the ASCII layout). Every + // object is allocated immediately before its loop so it is still young + // and the loop's collections must actually relocate it. + const SUBJECT: &str = "ä1 b22 c333 ä4444 é55555"; + const PATTERN: &str = "[a-zäé]+\\d+"; + let expected: Vec> = ["ä1", "b22", "c333", "ä4444", "é55555"] + .iter() + .map(|s| s.as_bytes().to_vec()) + .collect(); + + let input = text(&scope, SUBJECT.as_bytes()); + let reused = regex(&scope, PATTERN, "gu"); + let subject = BoundSubject::new(unsafe { HeapSubject::new(input) }.unwrap()).unwrap(); + let mut setup = Budget::new(api::WORK); + let reuse = Reuse::new(&scope, &reused, input, &subject, &mut setup); + let input_before = input.with_const_ptr::(|p| p as usize); + let program_before = unsafe { (*receiver_ptr(&reused)).perex_program as usize }; + let cycles = copying_minor_cycles(); + let (reused_matches, reused_work) = global_loop(&reused, &input, Some(&reuse)); + + assert_eq!(reused_matches, expected); + assert!( + copying_minor_cycles() > cycles, + "the loop must actually collect" + ); + assert_ne!( + input.with_const_ptr::(|p| p as usize), + input_before, + "the bound subject must have been relocated during the loop" + ); + assert_ne!( + unsafe { (*receiver_ptr(&reused)).perex_program as usize }, + program_before, + "the reused program must have moved, and still be recognised as the same cell" + ); + + // The same operation on identical, independent objects without reuse. + let fresh_input = text(&scope, SUBJECT.as_bytes()); + let fresh = regex(&scope, PATTERN, "gu"); + let (fresh_matches, fresh_work) = global_loop(&fresh, &fresh_input, None); + assert_eq!(fresh_matches, expected); + // Both loops validate their program once: the witness in each program cell + // lets later searches bind it without validating (#10166). The reused loop + // paid its validation in `setup`, and additionally resumes each search from + // the previous one instead of seeking from an end of this non-ASCII subject, + // so it must charge strictly less than the fresh loop minus one validation. + // Without reuse the two differ by exactly that one validation. + let validation = api::WORK - setup.remaining(); + assert!(validation > 0); + assert!( + fresh_work > reused_work + validation, + "reuse must save its seeks as well as the validation: fresh {fresh_work}, reused {reused_work}, one validation {validation}" + ); +} + +#[test] +fn perex_reuse_uses_the_receivers_current_program_after_recompile() { + let _guard = CopyingNurseryTestGuard::new(0); + let _scan = ConservativeScanDisabledGuard::new(); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _force = ForcedEvacuationTestGuard::on(); + super::perex_public::register_host_roots(); + let scope = RuntimeHandleScope::new(); + let input = text(&scope, b"aaa bbb aaa"); + let receiver = regex(&scope, "a+", "g"); + let subject = BoundSubject::new(unsafe { HeapSubject::new(input) }.unwrap()).unwrap(); + let reuse = Reuse::new( + &scope, + &receiver, + input, + &subject, + &mut Budget::new(api::WORK), + ); + let search = |scope: &RuntimeHandleScope| { + let memory = MemoryBudget::new(api::SCRATCH_BYTES); + input + .with_const_ptr::(|s| { + api::execute_with_resources( + receiver_ptr(&receiver), + s, + true, + &mut Budget::new(api::WORK), + &memory, + &mut || { + gc_collect_minor(); + Ok(()) + }, + Some(&reuse), + ) + }) + .unwrap() + .map(|found| first_item(scope, found.array)) + }; + let first = RuntimeHandleScope::new(); + assert_eq!(search(&first).as_deref(), Some(&b"aaa"[..])); + drop(first); + // RegExp.prototype.compile publishes a new program and resets lastIndex. + let pattern = text(&scope, b"b+"); + let flags = text(&scope, b"g"); + crate::regex::js_regexp_compile_value( + receiver_ptr(&receiver), + pattern.with_const_ptr::(|p| js_nanbox_string(p as i64)), + flags.with_const_ptr::(|p| js_nanbox_string(p as i64)), + ); + let second = RuntimeHandleScope::new(); + assert_eq!(search(&second).as_deref(), Some(&b"bbb"[..])); +} + +#[test] +fn perex_reuse_binds_a_different_string_afresh() { + let _guard = CopyingNurseryTestGuard::new(0); + let _scan = ConservativeScanDisabledGuard::new(); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + let scope = RuntimeHandleScope::new(); + let bound_input = text(&scope, b"x1"); + let other_input = text(&scope, b"yy22"); + let receiver = regex(&scope, "\\d+", ""); + let subject = BoundSubject::new(unsafe { HeapSubject::new(bound_input) }.unwrap()).unwrap(); + let reuse = Reuse::new( + &scope, + &receiver, + bound_input, + &subject, + &mut Budget::new(api::WORK), + ); + let memory = MemoryBudget::new(api::SCRATCH_BYTES); + let found = other_input + .with_const_ptr::(|s| { + api::execute_with_resources( + receiver_ptr(&receiver), + s, + true, + &mut Budget::new(api::WORK), + &memory, + &mut || Ok(()), + Some(&reuse), + ) + }) + .unwrap() + .unwrap(); + assert_eq!(first_item(&scope, found.array), b"22"); +} + +/// Work a global loop over `repeats` copies of a non-ASCII record charges. +fn non_ascii_loop_work(repeats: usize, reuse: bool) -> usize { + let local = RuntimeHandleScope::new(); + let input = text(&local, "ä1 ö22 ".repeat(repeats).as_bytes()); + let receiver = regex(&local, "[a-zäö]+\\d+", "gu"); + let subject = BoundSubject::new(unsafe { HeapSubject::new(input) }.unwrap()).unwrap(); + let mut setup = Budget::new(api::WORK); + let reused = Reuse::new(&local, &receiver, input, &subject, &mut setup); + let (matches, work) = + global_loop_collecting(&receiver, &input, reuse.then_some(&reused), false); + assert_eq!(matches.len(), 2 * repeats); + work +} + +#[test] +fn perex_reuse_positions_keep_a_non_ascii_global_loop_linear() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + // Doubling the subject doubles the searches. Seeking each one from an end + // of the subject makes the total roughly quadruple (#10164); resuming from + // the previous search keeps it roughly double. + let fresh = non_ascii_loop_work(2_000, false) as f64 / non_ascii_loop_work(1_000, false) as f64; + let reused = non_ascii_loop_work(2_000, true) as f64 / non_ascii_loop_work(1_000, true) as f64; + assert!( + fresh > 3.0, + "the unpositioned loop must be quadratic here, got {fresh:.2}x" + ); + assert!( + reused < 2.2, + "the positioned loop must be linear, got {reused:.2}x" + ); +} + +fn nanbox_text(scope: &RuntimeHandleScope, value: &str) -> f64 { + text(scope, value.as_bytes()).with_const_ptr::(|p| js_nanbox_string(p as i64)) +} + +fn utf16_length(value: f64) -> u32 { + let string = crate::value::js_nanbox_get_pointer(value) as *const StringHeader; + unsafe { (*string).utf16_len } +} + +#[test] +fn perex_split_and_replace_no_longer_hit_the_work_limit_on_linear_inputs() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + let scope = RuntimeHandleScope::new(); + + // The #10164 reduction: a 32,000-unit split threw RangeError("Regular + // expression work limit exceeded"); Node returns 6,001 pieces. + let subject = "ä中12,Ö漢345;ef6😀".repeat(2_000); + let input = nanbox_text(&scope, &subject); + let input = scope.root_nanbox_f64(input); + assert_eq!(utf16_length(input.get_nanbox_f64()), 32_000); + let re = regex(&scope, "[,;😀]+", "u"); + let pieces = crate::regex::perex_split::regexp( + re.get_nanbox_f64(), + input.get_nanbox_f64(), + f64::from_bits(crate::value::TAG_UNDEFINED), + ) + .expect("a 32,000-unit split must not exhaust the work allowance"); + let pieces = crate::value::js_nanbox_get_pointer(pieces) as *const ArrayHeader; + assert_eq!(unsafe { (*pieces).length }, 6_001); + + // A 60,000-unit global replace threw the same error; Node's result wraps + // each of the 8,000 matches in brackets, 76,000 units in all. + let subject = "ä中😀12 Ö漢🦊345;".repeat(4_000); + let input = scope.root_nanbox_f64(nanbox_text(&scope, &subject)); + assert_eq!(utf16_length(input.get_nanbox_f64()), 60_000); + let re = regex(&scope, "[ä中😀Ö漢🦊]+", "gu"); + let template = scope.root_nanbox_f64(nanbox_text(&scope, "[$&]")); + let replaced = crate::regex::perex_replace::regexp( + re.get_nanbox_f64(), + input.get_nanbox_f64(), + template.get_nanbox_f64(), + ) + .expect("a 60,000-unit global replace must not exhaust the work allowance"); + assert_eq!(utf16_length(replaced), 76_000); +} diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_split.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_split.rs index d66dcc84f2..8c4c192ea1 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_split.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_split.rs @@ -641,6 +641,7 @@ fn perex_split_species_order_zero_limit_and_empty_input() { let f = function(&scope, throwing as *const u8, 0); getter(&receiver, b"lastIndex", &f); getter(&matcher, b"lastIndex", &f); + let forward_before = forward_splits(); for (number, expected, count) in [(0.0, 123456, 0.0), (1.0, 1234567, 1.0)] { ORDER.with(|o| o.set(0)); put(&lim, b"number", number); @@ -653,6 +654,11 @@ fn perex_split_species_order_zero_limit_and_empty_input() { assert_eq!(get(&out, b"length"), count); assert_eq!(bytes(get(&matcher, b"seenFlags")), b"vy"); } + assert_eq!( + forward_splits(), + forward_before, + "a species factory must keep the per-position sticky loop" + ); } extern "C" fn custom_exec(c: *const crate::closure::ClosureHeader, input: f64) -> f64 { @@ -716,6 +722,7 @@ fn perex_split_custom_exec_capture_values_reentrancy_and_limit_short_circuit() { getter(&result, b"0", &throws); getter(&result, b"index", &throws); put(&capture, b"toString", throws.get_nanbox_f64()); + let forward_before = forward_splits(); let before = input.get_nanbox_f64().to_bits(); let out = scope.root_nanbox_f64(api::finish(split::regexp( re.get_nanbox_f64(), @@ -735,6 +742,11 @@ fn perex_split_custom_exec_capture_values_reentrancy_and_limit_short_circuit() { 1.0, ))); check(&out, &[Some(b"a")]); + assert_eq!( + forward_splits(), + forward_before, + "a custom exec must keep the per-position sticky loop" + ); } extern "C" fn throwing_hook(_: *const crate::closure::ClosureHeader, _: f64, _: f64) -> f64 { @@ -1141,3 +1153,165 @@ fn perex_numeric_arguments_reject_bigint_after_observable_primitive_conversion() Err(crate::regex::perex_runtime::EngineError::Type(_)) )); } + +fn forward_splits() -> usize { + split::FORWARD_SPLITS.with(Cell::get) +} + +/// Split's forward search (#10165) returns exactly the specification's +/// per-position sticky result. Each expectation below was derived by running +/// the sticky algorithm by hand, not by observing either implementation. +#[test] +fn perex_split_forward_search_matches_the_sticky_specification() { + let _guard = CopyingNurseryTestGuard::new(0); + let _scan = ConservativeScanDisabledGuard::new(); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _force = ForcedEvacuationTestGuard::on(); + super::perex_public::register_host_roots(); + type Parts = &'static [Option<&'static [u8]>]; + let cases: &[(&[u8], &str, &[u8], f64, Parts)] = &[ + // A repeated group keeps its last iteration. + ( + b"a1b22c", + r"(\d)+", + b"", + -1.0, + &[Some(b"a"), Some(b"1"), Some(b"b"), Some(b"2"), Some(b"c")], + ), + // An unmatched group is undefined, and a match at the end leaves "". + (b"ab", r"(x)?b", b"", -1.0, &[Some(b"a"), None, Some(b"")]), + // Empty matches everywhere: every position is stepped past once. + ( + b"abc", + "x*", + b"", + -1.0, + &[Some(b"a"), Some(b"b"), Some(b"c")], + ), + // Empty at 0, a real match at 1, empty again at 2. + (b"abc", "b*", b"", -1.0, &[Some(b"a"), Some(b"c")]), + (b",a,", ",", b"", -1.0, &[Some(b""), Some(b"a"), Some(b"")]), + // `$` matches only at the end, which the sticky loop never tries. + (b"ab", "$", b"", -1.0, &[Some(b"ab")]), + (b"a,b,c", ",", b"", 2.0, &[Some(b"a"), Some(b"b")]), + // The limit can fall inside a match's captures. + ( + b"a1b2c3", + r"(\d)", + b"", + 3.0, + &[Some(b"a"), Some(b"1"), Some(b"b")], + ), + // Unicode mode advances an empty match by a whole code point. + ( + "😀😀".as_bytes(), + "", + b"u", + -1.0, + &[Some(b"\xf0\x9f\x98\x80"), Some(b"\xf0\x9f\x98\x80")], + ), + ( + "ä中12,Ö漢345;ef6😀".as_bytes(), + "[,;😀]+", + b"u", + -1.0, + &[ + Some(b"\xc3\xa4\xe4\xb8\xad12"), + Some(b"\xc3\x96\xe6\xbc\xa2345"), + Some(b"ef6"), + Some(b""), + ], + ), + ]; + for (index, (subject, pattern, flags, limit, expected)) in cases.iter().enumerate() { + let local = RuntimeHandleScope::new(); + let input = text(&local, subject); + let separator = regex(&local, pattern.as_bytes(), flags); + let before = forward_splits(); + let out = run(&local, &input, &separator, *limit); + assert_eq!( + forward_splits(), + before + 1, + "case {index} must take the forward search" + ); + check(&out, expected); + } +} + +/// A user species constructor that builds a genuine RegExp and keeps it where +/// JavaScript can reach it afterwards, as any user factory could. +extern "C" fn recording_regexp_species( + _: *const crate::closure::ClosureHeader, + receiver: f64, + flags: f64, +) -> f64 { + let scope = RuntimeHandleScope::new(); + let receiver = scope.root_nanbox_f64(receiver); + let flags = scope.root_nanbox_f64(flags); + let splitter = scope.root_nanbox_f64(js_nanbox_pointer(crate::regex::js_regexp_construct( + receiver.get_nanbox_f64(), + flags.get_nanbox_f64(), + ) as i64)); + put(&receiver, b"splitter", splitter.get_nanbox_f64()); + splitter.get_nanbox_f64() +} + +/// The species condition is what keeps the forward search unobservable: a user +/// species can return a real RegExp with the builtin exec, which passes every +/// other admission check, and still hold the splitter and read what the +/// per-position loop wrote to it. +#[test] +fn perex_split_user_species_regexp_keeps_the_observable_sticky_loop() { + let _guard = CopyingNurseryTestGuard::new(0); + let _scan = ConservativeScanDisabledGuard::new(); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _force = ForcedEvacuationTestGuard::on(); + super::perex_public::register_host_roots(); + let scope = RuntimeHandleScope::new(); + let re = regex(&scope, b",", b""); + let holder = object(&scope); + let species = function(&scope, recording_regexp_species as *const u8, 2); + symbol(&holder, "species", species.get_nanbox_f64()); + put(&re, b"constructor", holder.get_nanbox_f64()); + let input = text(&scope, b"a,"); + let before = forward_splits(); + let out = run(&scope, &input, &re, -1.0); + check(&out, &[Some(b"a"), Some(b"")]); + assert_eq!( + forward_splits(), + before, + "a user species must keep the per-position sticky loop" + ); + // The sticky loop's last RegExpExec matched "," at 1 and left lastIndex at + // 2; a forward search would never have written it and left 0. + let splitter = scope.root_nanbox_f64(get(&re, b"splitter")); + assert_eq!(get(&splitter, b"lastIndex"), 2.0); +} + +/// Work one forward split of `repeats` non-ASCII records charges. +fn forward_split_work(repeats: usize) -> usize { + let scope = RuntimeHandleScope::new(); + let input = text(&scope, "ä中12,Ö漢345;ef6😀".repeat(repeats).as_bytes()); + let re = regex(&scope, "[,;😀]+".as_bytes(), b"u"); + let before = forward_splits(); + let out = run(&scope, &input, &re, -1.0); + assert_eq!(forward_splits(), before + 1, "the forward search must run"); + // Three pieces per record and the empty piece after the final emoji. + assert_eq!(get(&out, b"length"), (3 * repeats + 1) as f64); + split::LAST_FORWARD_WORK.with(Cell::get) +} + +/// On non-ASCII storage a search that seeks from an end of the subject makes a +/// loop of them quadratic (#10164). Resuming each from the previous one keeps +/// the forward split's work proportional to the input. +#[test] +fn perex_split_forward_search_resumes_each_search_on_non_ascii_input() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + let ratio = forward_split_work(2_000) as f64 / forward_split_work(1_000) as f64; + assert!( + ratio < 2.3, + "doubling the input must roughly double the work, got {ratio:.2}x" + ); +} diff --git a/crates/perry-runtime/src/object/regex_proto_thunks.rs b/crates/perry-runtime/src/object/regex_proto_thunks.rs index 9ffc0ce0c9..1d1203ba95 100644 --- a/crates/perry-runtime/src/object/regex_proto_thunks.rs +++ b/crates/perry-runtime/src/object/regex_proto_thunks.rs @@ -491,6 +491,17 @@ pub(crate) fn regexp_prototype_test_is_canonical(value: f64) -> bool { }) } +#[cfg(feature = "regex-engine")] +/// The intrinsic `RegExp` constructor, recognised the way the class registry +/// recognises it: by its dedicated call thunk. A subclass, a bound function or +/// a proxy has a different function pointer. +pub(crate) fn is_intrinsic_regexp_constructor(value: f64) -> bool { + let closure = + crate::value::js_nanbox_get_pointer(value) as *const crate::closure::ClosureHeader; + crate::closure::get_valid_func_ptr(closure) + == super::global_this::regexp_constructor_call_thunk as *const u8 +} + /// Non-observable admission for a substring view. An exec/test accessor or /// override must run once on the materialized JS argument, so never invoke /// one while deciding whether to take this optimization. diff --git a/crates/perry-runtime/src/regex/match_all.rs b/crates/perry-runtime/src/regex/match_all.rs index 65bca84ef2..5c3ff102b6 100644 --- a/crates/perry-runtime/src/regex/match_all.rs +++ b/crates/perry-runtime/src/regex/match_all.rs @@ -232,6 +232,7 @@ fn next(iter: *mut ObjectHeader) -> Result { &mut budget, &memory, &mut host::poll, + None, )?; let Some(found) = found else { complete(&iter); diff --git a/crates/perry-runtime/src/regex/perex_api.rs b/crates/perry-runtime/src/regex/perex_api.rs index c14d1cf097..bc5585965b 100644 --- a/crates/perry-runtime/src/regex/perex_api.rs +++ b/crates/perry-runtime/src/regex/perex_api.rs @@ -9,6 +9,7 @@ use crate::string::StringHeader; use perex::binding::{BoundProgram, BoundSubject}; use perex::compiler::CompileError; use perex::executor::ExecError; +use perex::input::Position; use perex::{span::Span, Budget}; // One explicit host policy; no retained scratch cache or alternate engine. @@ -94,7 +95,163 @@ pub(crate) fn program<'s>( ) -> Result>, EngineError> { let owner = unsafe { GcProgram::from_receiver(scope, receiver) } .map_err(|e| EngineError::Subject(perex::binding::SubjectError::Resource(e)))?; - BoundProgram::new(owner, budget).map_err(|e| EngineError::Program(e.error)) + bind_program(owner, budget) +} + +/// Bind a program, in constant work when a binding validated this same cell +/// before (#10166): the witness lives beside the words in the program cell, so +/// it cannot describe other words. A witness that does not match falls back to +/// validation, which records a fresh one. No allocation and nothing traced. +pub(crate) fn bind_program<'s>( + owner: GcProgram<'s>, + budget: &mut Budget, +) -> Result>, EngineError> { + let root = owner.root(); + let owner = match owner.witness() { + Some(witness) => match BoundProgram::new_witnessed(owner, witness) { + Ok(bound) => return Ok(bound), + Err(failed) => failed.storage, + }, + None => owner, + }; + let bound = BoundProgram::new(owner, budget).map_err(|e| EngineError::Program(e.error))?; + GcProgram::record_witness(&root, bound.witness()); + Ok(bound) +} + +/// Bind a whole heap string, in constant work when this header was validated +/// before (#10166). The first binding decodes it; if that succeeds and its +/// UTF-16 length matches the header, the header is marked +/// `STRING_FLAG_WTF8_VALIDATED` and later bindings use `new_counted`. +/// +/// Perry strings are not all valid WTF-8 (raw Buffer and FFI payloads reach +/// here too), so validity is never assumed: a string that fails to validate is +/// never marked and keeps failing exactly as before. `HeapSubject::new` has +/// already marked the header shared, so a marked payload is never mutated in +/// place, and the mark is never copied to another string (see the flag). +pub(crate) fn bind_heap_subject( + input: RuntimeHandle<'_>, +) -> Result>, EngineError> { + use crate::string::STRING_FLAG_WTF8_VALIDATED; + let (utf16_len, validated) = input.with_const_ptr::(|s| unsafe { + ( + (*s).utf16_len as usize, + (*s).flags & STRING_FLAG_WTF8_VALIDATED != 0, + ) + }); + let owner = unsafe { HeapSubject::new(input) } + .map_err(|e| EngineError::Subject(perex::binding::SubjectError::Resource(e)))?; + let owner = if validated { + match BoundSubject::new_counted(owner, utf16_len) { + Ok(bound) => return Ok(bound), + Err(failed) => failed.storage, + } + } else { + owner + }; + let bound = BoundSubject::new(owner).map_err(|e| EngineError::Subject(e.error))?; + let decoded = bound + .with_view(|view| view.len_utf16()) + .map_err(EngineError::Subject)?; + // An empty string has nothing to decode. `HeapSubject::new` already wrote + // this header's refcount, so it is writable. + if decoded == utf16_len && utf16_len > 0 { + input.with_const_ptr::(|s| unsafe { + (*(s as *mut StringHeader)).flags |= STRING_FLAG_WTF8_VALIDATED; + }); + } + Ok(bound) +} + +/// Bindings one compound operation reuses across its searches (#10165). +/// +/// Split, replace and global match run many searches over one string with one +/// matcher. Binding per search decodes the entire subject and revalidates the +/// entire program every time, which made those loops quadratic in the input. +/// Perex's binding contract lets a binding outlive allocation, collection and +/// JS callbacks: both owners hold registered roots and reacquire their base on +/// every view, so no search needs to rebind because the collector moved them. +/// +/// Build it before the operation's loop. Runtime handle scopes are a stack, so +/// its roots must sit below every per-iteration scope; nothing here roots +/// lazily. A search uses a binding only while it is provably the same object: +/// the same string, and the same receiver still holding the same program cell. +/// Anything else (an `exec` override, a recompiled receiver, another string) +/// binds afresh for that search exactly as before. +/// +/// `near` is where the previous search over the reused subject stood (#10164), +/// so the next search seeks from there instead of from an end of the subject. +/// It is only ever set from, and only ever used with, the reused binding. +pub(crate) struct Reuse<'b, 's> { + input: RuntimeHandle<'s>, + subject: &'b BoundSubject>, + program: Option>, + near: std::cell::Cell>, +} + +struct ReusedProgram<'s> { + receiver: RuntimeHandle<'s>, + cell: RuntimeHandle<'s>, + bound: BoundProgram>, +} + +impl<'b, 's> Reuse<'b, 's> { + /// `subject` must bind the whole of `input` (not a window), as the + /// operations' own `subject(input)` bindings do. + pub(crate) fn new( + scope: &'s RuntimeHandleScope, + receiver: &RuntimeHandle<'_>, + input: RuntimeHandle<'s>, + subject: &'b BoundSubject>, + budget: &mut Budget, + ) -> Self { + let re = + crate::value::js_nanbox_get_pointer(receiver.get_nanbox_f64()) as *const RegExpHeader; + // A receiver that is not a RegExp with a published program runs no + // builtin search here; its failure belongs to the ordinary path. + let program = (super::is_valid_regex_ptr(re) && unsafe { !(*re).perex_program.is_null() }) + .then(|| { + // Rooting pushes a handle slot and never collects, so `re` and + // its program edge are still current for every read below. + let receiver = scope.root_raw_const_ptr(re); + let cell = scope.root_raw_const_ptr(unsafe { (*re).perex_program }); + let owner = unsafe { GcProgram::from_receiver(scope, &receiver) }.ok()?; + let bound = bind_program(owner, budget).ok()?; + Some(ReusedProgram { + receiver, + cell, + bound, + }) + }) + .flatten(); + Self { + input, + subject, + program, + near: std::cell::Cell::new(None), + } + } + + /// Where the last search over the reused subject stood, if any. + pub(crate) fn near(&self) -> Option { + self.near.get() + } + + fn subject_for(&self, input: &RuntimeHandle<'_>) -> Option<&BoundSubject>> { + let current = input.with_const_ptr::(|p| p); + let bound = self.input.with_const_ptr::(|p| p); + (current == bound).then_some(self.subject) + } + + /// Both roots are live, so equal addresses name the same objects even after + /// either moved; a replaced program cannot reuse a cell this root retains. + fn program_for(&self, receiver: &RuntimeHandle<'_>) -> Option<&BoundProgram>> { + let reused = self.program.as_ref()?; + let current = receiver.with_const_ptr::(|p| p); + let bound = reused.receiver.with_const_ptr::(|p| p); + let cell = reused.cell.with_const_ptr::(|p| p); + (current == bound && unsafe { (*current).perex_program } == cell).then_some(&reused.bound) + } } pub(crate) struct ExecMatch { @@ -186,12 +343,13 @@ pub(crate) fn execute( &mut Budget::new(WORK), &MemoryBudget::new(SCRATCH_BYTES), poll, + None, ) } /// Compound String operations keep one allowance across successive matches. /// Each execution has its own root scope, so a global loop cannot retain a -/// root for every previous result. +/// root for every previous result. `reuse` carries the operation's bindings. pub(crate) fn execute_with_resources( receiver: *mut RegExpHeader, input: *const StringHeader, @@ -199,6 +357,7 @@ pub(crate) fn execute_with_resources( budget: &mut Budget, memory: &MemoryBudget, poll: &mut impl FnMut() -> Result<(), EngineError>, + reuse: Option<&Reuse<'_, '_>>, ) -> Result, EngineError> { let scope = RuntimeHandleScope::new(); let receiver = scope.root_raw_mut_ptr(receiver); @@ -217,16 +376,32 @@ pub(crate) fn execute_with_resources( } return Ok(None); } - let program = program(&scope, &receiver, budget, memory, poll)?; - let subject = BoundSubject::new( - unsafe { HeapSubject::new(input) } - .map_err(|e| EngineError::Subject(perex::binding::SubjectError::Resource(e)))?, - ) - .map_err(|e| EngineError::Subject(e.error))?; - let found = host::find( - &program, - &subject, + let fresh_program; + let program = match reuse.and_then(|reuse| reuse.program_for(&receiver)) { + Some(program) => program, + None => { + fresh_program = program(&scope, &receiver, budget, memory, poll)?; + &fresh_program + } + }; + let fresh_subject; + let reused_subject = reuse.and_then(|reuse| reuse.subject_for(&input)); + // A position is valid only on the binding it came from. + let near = reuse + .filter(|_| reused_subject.is_some()) + .and_then(|reuse| reuse.near()); + let subject = match reused_subject { + Some(subject) => subject, + None => { + fresh_subject = bind_heap_subject(input)?; + &fresh_subject + } + }; + let (found, position) = host::find_near( + program, + subject, start, + near, if materialize { CaptureMode::All } else { @@ -237,6 +412,9 @@ pub(crate) fn execute_with_resources( QUANTUM, poll, )?; + if let (Some(reuse), Some(_)) = (reuse, reused_subject) { + reuse.near.set(Some(position)); + } if stateful { let next = found.as_ref().map_or(0, |m| m.full.end()); caught(|| { @@ -252,9 +430,11 @@ pub(crate) fn execute_with_resources( caught(|| { super::perex_results::materialize( &input, - &subject, - &program, + subject, + program, &found, + // From the search that just ran over this same binding. + Some(position), has_indices, budget, poll, diff --git a/crates/perry-runtime/src/regex/perex_construct.rs b/crates/perry-runtime/src/regex/perex_construct.rs index aa7def894b..b13d326e41 100644 --- a/crates/perry-runtime/src/regex/perex_construct.rs +++ b/crates/perry-runtime/src/regex/perex_construct.rs @@ -60,6 +60,30 @@ fn compile<'s>( ) } +/// A program for `re`'s own source and canonical flags with `y` removed, +/// compiled from its internal slots, so no property of `re` is observed. +/// Split's forward search uses it in place of the sticky splitter (#10165). +pub(crate) fn nonsticky_program<'s>( + scope: &'s RuntimeHandleScope, + re: &RuntimeHandle<'_>, +) -> Result, EngineError> { + let (source, flags) = + re.with_const_ptr::(|re| unsafe { ((*re).pattern_ptr, (*re).flags_ptr) }); + if source.is_null() || flags.is_null() { + return Err(EngineError::InvalidFlags); + } + let source = scope.root_string_ptr(source); + let flags = scope.root_string_ptr(flags); + let canonical = unsafe { + flags.with_string_bytes(|bytes| { + let without: Vec = bytes.iter().copied().filter(|&b| b != b'y').collect(); + CanonicalFlags::parse(&without) + }) + } + .ok_or(EngineError::InvalidFlags)?; + compile(scope, source, canonical) +} + unsafe fn publish( receiver: &RuntimeHandle<'_>, source: &RuntimeHandle<'_>, diff --git a/crates/perry-runtime/src/regex/perex_dispatch.rs b/crates/perry-runtime/src/regex/perex_dispatch.rs index 14fc0aa6e1..e6f17c5a43 100644 --- a/crates/perry-runtime/src/regex/perex_dispatch.rs +++ b/crates/perry-runtime/src/regex/perex_dispatch.rs @@ -97,6 +97,7 @@ pub(crate) fn call_one( /// RegExpExec with operation-owned limits. Lookup happens on every iteration; /// a callback may replace exec or recompile the receiver before the next one. /// Only the known builtin may omit materialization for a boolean test. +/// `reuse` is consulted only on the builtin path, after the observable lookup. pub(crate) fn execute( receiver: &RuntimeHandle<'_>, input: &RuntimeHandle<'_>, @@ -104,6 +105,7 @@ pub(crate) fn execute( budget: &mut Budget, memory: &MemoryBudget, poll: &mut impl FnMut() -> Result<(), EngineError>, + reuse: Option<&api::Reuse<'_, '_>>, ) -> Result, EngineError> { host::charge(budget, 1)?; require_object(receiver.get_nanbox_f64())?; @@ -137,7 +139,7 @@ pub(crate) fn execute( // `execute_with_resources` roots both before it allocates. input .with_const_ptr::(|input| { - api::execute_with_resources(re, input, materialize, budget, memory, poll) + api::execute_with_resources(re, input, materialize, budget, memory, poll, reuse) }) .map(|result| result.map(ExecResult::Builtin)) } @@ -318,6 +320,7 @@ pub(crate) fn test_string(receiver: f64, input: *const StringHeader) -> Result Result<*mut StringHeader, EngineError> { pub(super) fn subject( input: RuntimeHandle<'_>, ) -> Result>, EngineError> { - BoundSubject::new( - unsafe { HeapSubject::new(input) } - .map_err(|e| EngineError::Subject(perex::binding::SubjectError::Resource(e)))?, - ) - .map_err(|e| EngineError::Subject(e.error)) + api::bind_heap_subject(input) } fn match_flags( @@ -169,7 +165,7 @@ fn search( if previous.get_nanbox_f64().to_bits() != 0 { dispatch::set_last_index(receiver, 0.0)?; } - let result = dispatch::execute(receiver, input, true, budget, memory, &mut host::poll)?; + let result = dispatch::execute(receiver, input, true, budget, memory, &mut host::poll, None)?; let result = scope.root_nanbox_f64(result.map_or(f64::from_bits(TAG_NULL), |r| r.object())); let current = scope.root_nanbox_f64(dispatch::get(receiver, b"lastIndex")?); if !dispatch::same_value(¤t, &previous)? { @@ -190,19 +186,28 @@ fn matches( ) -> Result { let (global, unicode) = match_flags(receiver, budget)?; if !global { - return dispatch::execute(receiver, input, true, budget, memory, &mut host::poll) + return dispatch::execute(receiver, input, true, budget, memory, &mut host::poll, None) .map(|result| result.map_or(f64::from_bits(TAG_NULL), |r| r.object())); } dispatch::set_last_index(receiver, 0.0)?; let scope = RuntimeHandleScope::new(); let array = scope.root_raw_mut_ptr(api::caught(|| crate::array::js_array_alloc(0))?); let subject = subject(*input)?; + let reuse = api::Reuse::new(&scope, receiver, *input, &subject, budget); let length = input.with_const_ptr::(|s| unsafe { (*s).utf16_len as usize }); let mut count = 0u32; loop { // A fresh scope per iteration bounds roots regardless of match count. let iteration = RuntimeHandleScope::new(); - let result = dispatch::execute(receiver, input, false, budget, memory, &mut host::poll)?; + let result = dispatch::execute( + receiver, + input, + false, + budget, + memory, + &mut host::poll, + Some(&reuse), + )?; let Some(result) = result else { return Ok(if count == 0 { f64::from_bits(TAG_NULL) @@ -214,9 +219,11 @@ fn matches( }; let string = match result { dispatch::ExecResult::Builtin(found) => api::caught(|| { - super::perex_strings::copy_span( + super::perex_strings::copy_span_near( &subject, found.full, + // `reuse` binds this same `subject`. + reuse.near(), budget, api::OUTPUT_BYTES, api::QUANTUM, diff --git a/crates/perry-runtime/src/regex/perex_owner.rs b/crates/perry-runtime/src/regex/perex_owner.rs index 5606175edc..05a7188fe3 100644 --- a/crates/perry-runtime/src/regex/perex_owner.rs +++ b/crates/perry-runtime/src/regex/perex_owner.rs @@ -13,6 +13,12 @@ use perex::compiler::{CompileError, Prepared}; #[repr(C)] struct ProgramCell { word_count: usize, + /// What validating these words established, so later bindings of this same + /// cell skip validation (#10166). Plain data beside the words it describes: + /// the words never change, and a recompile emits a new cell that starts + /// with none, so it cannot describe other words. Stored by the first + /// validating bind; the cell stays a pointer-free leaf. + witness: Option, // Immediately followed by word_count initialized u32 words. } @@ -74,7 +80,10 @@ impl<'scope> GcProgram<'scope> { // finalizer or a leaked external owner. No GC call occurs in this scope. unsafe { // GC_STORE_AUDIT(POINTER_FREE): the program cell is a leaf of u32 words; its prefix is a count. - cell.write(ProgramCell { word_count: words }); + cell.write(ProgramCell { + word_count: words, + witness: None, + }); let output = cell.add(1).cast::(); output.write_bytes(0, words); let output = std::slice::from_raw_parts_mut(output, words); @@ -106,6 +115,39 @@ impl<'scope> GcProgram<'scope> { }); } + /// The witness stored beside this program's words, if a binding validated + /// them before (#10166). + pub(crate) fn witness(&self) -> Option { + self.root + .with_const_ptr::(|cell| unsafe { (*cell).witness }) + } + + /// Record what validating this program established. `witness` must come + /// from a binding of this same cell. + pub(crate) fn record_witness( + root: &RuntimeHandle<'_>, + witness: perex::binding::ProgramWitness, + ) { + // The prefix lies outside the word slice, but the write still goes + // through the cell's own pointer and never under a live view of its + // words (a binding holds none between calls). + #[cfg(debug_assertions)] + debug_assert_eq!( + PROGRAM_VIEWS.with(std::cell::Cell::get), + 0, + "a program cell's witness must not be written while a view of its words is live" + ); + // A plain-data store into a pointer-free leaf: no allocation, no barrier. + root.with_const_ptr::(|cell| unsafe { + (*(cell as *mut ProgramCell)).witness = Some(witness); + }); + } + + /// This program's registered root, which survives consuming the owner. + pub(crate) fn root(&self) -> RuntimeHandle<'scope> { + self.root + } + /// Establish a separate operation root, so reentrant receiver recompilation /// cannot replace the immutable program of an already-running operation. /// @@ -127,6 +169,46 @@ impl<'scope> GcProgram<'scope> { } } +/// The witness stored in the program cell at `program` (a RegExp's +/// `perex_program`), for tests. +#[cfg(test)] +pub(crate) unsafe fn cell_witness(program: *const u8) -> Option { + unsafe { (*(program as *const ProgramCell)).witness } +} + +/// Overwrite the witness stored in the program cell at `program`, for tests. +#[cfg(test)] +pub(crate) unsafe fn set_cell_witness( + program: *const u8, + witness: Option, +) { + unsafe { (*(program as *mut ProgramCell)).witness = witness }; +} + +// How many `with_words` views of any program cell are live on this thread, so +// debug builds can prove a witness is never written under one (#10166). +#[cfg(debug_assertions)] +thread_local! { + static PROGRAM_VIEWS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +struct ProgramView; + +impl ProgramView { + fn open() -> Self { + #[cfg(debug_assertions)] + PROGRAM_VIEWS.with(|views| views.set(views.get() + 1)); + ProgramView + } +} + +impl Drop for ProgramView { + fn drop(&mut self) { + #[cfg(debug_assertions)] + PROGRAM_VIEWS.with(|views| views.set(views.get() - 1)); + } +} + impl ImmutableProgram for GcProgram<'_> { type Error = OwnerError; @@ -149,6 +231,7 @@ impl ImmutableProgram for GcProgram<'_> { // Only emit creates these cells; no mutable word access escapes. // Binding validation is separate, once per immutable owner. This // getter neither allocates nor polls and always reacquires the base. + let _view = ProgramView::open(); Ok(f(std::slice::from_raw_parts(cell.add(1).cast(), count))) }) } diff --git a/crates/perry-runtime/src/regex/perex_replace.rs b/crates/perry-runtime/src/regex/perex_replace.rs index cc07bcdf1f..303c6fa1c1 100644 --- a/crates/perry-runtime/src/regex/perex_replace.rs +++ b/crates/perry-runtime/src/regex/perex_replace.rs @@ -74,6 +74,7 @@ pub(crate) fn regexp(receiver: f64, argument: f64, replacement: f64) -> Result Result>, program: &BoundProgram>, found: &Match<'_>, + near: Option, has_indices: bool, budget: &mut Budget, poll: &mut impl FnMut() -> Result<(), EngineError>, @@ -34,7 +35,7 @@ pub(super) fn materialize( }); for (index, capture) in captures.iter().enumerate() { let value = if let Some(span) = capture { - let text = copy_span(subject, *span, budget, OUTPUT_BYTES, QUANTUM, poll)?; + let text = copy_span_near(subject, *span, near, budget, OUTPUT_BYTES, QUANTUM, poll)?; crate::value::js_nanbox_string(text as i64).to_bits() } else { crate::value::TAG_UNDEFINED diff --git a/crates/perry-runtime/src/regex/perex_runtime.rs b/crates/perry-runtime/src/regex/perex_runtime.rs index cb71897144..802b2c98b8 100644 --- a/crates/perry-runtime/src/regex/perex_runtime.rs +++ b/crates/perry-runtime/src/regex/perex_runtime.rs @@ -14,6 +14,7 @@ use perex::executor::{ ExecError, Frame, Progress, Scratch, ScratchOwner, ScratchRequirements, Search, SearchError, Undo, }; +use perex::input::Position; use perex::span::Span; use perex::Budget; @@ -164,6 +165,31 @@ pub(crate) fn find<'mem, S: ImmutableSubject>( quantum: usize, poll: &mut impl FnMut() -> Result<(), EngineError>, ) -> Result>, EngineError> { + find_near( + program, subject, start, None, mode, budget, memory, quantum, poll, + ) + .map(|(found, _)| found) +} + +/// `find`, seeking to `start` from `near` when that is closer than either end +/// of the subject, and returning where the search stood: the match's end, or +/// the start of its last attempt (#10164). On non-ASCII storage a search from +/// an end costs up to half the subject, so a loop of them is quadratic. +/// +/// `near` must come from a search or reader over this same binding. Another +/// string with an identical layout cannot be detected and would give wrong +/// answers, so callers keep a position only as long as the binding it came from. +pub(crate) fn find_near<'mem, S: ImmutableSubject>( + program: &BoundProgram>, + subject: &BoundSubject, + start: usize, + near: Option, + mode: CaptureMode, + budget: &mut Budget, + memory: &'mem MemoryBudget, + quantum: usize, + poll: &mut impl FnMut() -> Result<(), EngineError>, +) -> Result<(Option>, Position), EngineError> { if quantum == 0 { return Err(EngineError::InvalidQuantum); } @@ -178,14 +204,18 @@ pub(crate) fn find<'mem, S: ImmutableSubject>( }; poll()?; let buffers = MatchBuffers::new(memory, size)?; - let mut search = Search::new(&resources, start, buffers, *budget).map_err(search_error)?; + let mut search = match near { + Some(near) => Search::new_near(&resources, start, near, buffers, *budget), + None => Search::new(&resources, start, buffers, *budget), + } + .map_err(search_error)?; loop { let result = search.advance(quantum); // Preserve consumed work even when the following poll cancels/throws, // allocation fails, or a scratch replacement cannot fit the cap. *budget = Budget::new(search.remaining_work()); match result { - Ok(Progress::NoMatch) => return Ok(None), + Ok(Progress::NoMatch) => return Ok((None, search.position())), Ok(Progress::Matched) => { let full = search .capture(0) @@ -202,7 +232,7 @@ pub(crate) fn find<'mem, S: ImmutableSubject>( Some(output) } }; - return Ok(Some(Match { full, captures })); + return Ok((Some(Match { full, captures }), search.position())); } Ok(Progress::Pending) => poll()?, Err(SearchError::Execution(ExecError::Frames | ExecError::Undo)) => { diff --git a/crates/perry-runtime/src/regex/perex_split.rs b/crates/perry-runtime/src/regex/perex_split.rs index ced3a42139..7dfd71645e 100644 --- a/crates/perry-runtime/src/regex/perex_split.rs +++ b/crates/perry-runtime/src/regex/perex_split.rs @@ -4,14 +4,16 @@ use super::perex_api as api; use super::perex_dispatch as dispatch; use super::perex_match_search::subject; use super::perex_memory::MemoryBudget; +use super::perex_owner::GcProgram; use super::perex_owner::HeapSubject; use super::perex_replace::{callable, index_property}; use super::perex_replace_storage::{boxed, call, length, text, List, Pieces, Units}; -use super::perex_runtime::{self as host, EngineError}; +use super::perex_runtime::{self as host, CaptureMode, EngineError}; use super::perex_strings::SpanCopies; use crate::gc::{RuntimeHandle, RuntimeHandleScope}; use crate::value::{js_nanbox_pointer, js_nanbox_string, TAG_NULL, TAG_UNDEFINED}; -use perex::binding::{BoundSubject, SubjectError}; +use perex::binding::{BoundProgram, BoundSubject, SubjectError}; +use perex::input::Position; use perex::Budget; /// Literal String operations also accept Perry's raw Buffer/FFI payloads. @@ -56,6 +58,53 @@ fn advance( Ok(index + 1) } +// Counts forward splits taken, and the work the last one charged, so tests can +// tell which path ran and how its cost scales. +#[cfg(test)] +thread_local! { + pub(crate) static FORWARD_SPLITS: std::cell::Cell = const { std::cell::Cell::new(0) }; + pub(crate) static LAST_FORWARD_WORK: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The program for split's forward search, when it is admissible (#10165). +/// +/// The specification tries a sticky match at every position `q`. A non-sticky +/// search from `q` returns the leftmost position `s >= q` where the pattern +/// matches, with the same match a sticky attempt at `s` finds, so the attempts +/// at `q..s` can be skipped without changing any piece or capture, and empty +/// matches and Unicode advancement line up. The skipped attempts are +/// unobservable only when nothing can see a RegExpExec happen: +/// - the splitter came from the intrinsic `RegExp` (absent or intrinsic +/// species), so it is a fresh object no user code holds, and its skipped +/// `lastIndex` writes cannot be seen; +/// - its `exec` resolves, without running a getter, to the builtin data +/// property, so the skipped `Get(exec)` calls cannot be seen either. +/// +/// The program is compiled from the splitter's own internal source and flags +/// without `y`. Anything else keeps the per-position sticky loop. +fn forward_program<'s>( + scope: &'s RuntimeHandleScope, + constructor: Option<&RuntimeHandle<'_>>, + splitter: &RuntimeHandle<'_>, + budget: &mut Budget, +) -> Option>> { + if constructor.is_some_and(|c| { + !crate::object::regex_proto_thunks::is_intrinsic_regexp_constructor(c.get_nanbox_f64()) + }) { + return None; + } + let value = splitter.get_nanbox_f64(); + let re = crate::value::js_nanbox_get_pointer(value) as *const super::RegExpHeader; + if !super::is_valid_regex_ptr(re) + || !crate::object::regex_proto_thunks::regexp_view_uses_builtin(value) + { + return None; + } + let splitter = scope.root_raw_const_ptr(re); + let program = super::perex_construct::nonsticky_program(scope, &splitter).ok()?; + BoundProgram::new(program, budget).ok() +} + fn push_span( output: &mut List<'_>, copies: &mut SpanCopies<'_, '_>, @@ -128,6 +177,7 @@ pub(crate) fn regexp(receiver: f64, argument: f64, limit_value: f64) -> Result Result = None; + while q < size { + let local = RuntimeHandleScope::new(); + let (found, position) = host::find_near( + &forward, + &bound, + q, + near, + CaptureMode::All, + &mut budget, + &memory, + api::QUANTUM, + &mut host::poll, + )?; + near = Some(position); + let Some(found) = found else { + break; + }; + let start = found.full.start(); + // The sticky loop never tries the end of the input. + if start >= size { + break; + } + let end = found.full.end().min(size); + if end == p { + // Only an empty match at `p` itself: step past it, as the + // sticky loop does. + q = advance(&mut units, start, size, unicode, &mut budget)?; + host::poll()?; + continue; + } + push_span(&mut output, &mut copies, p, start, &mut budget)?; + if output.len() == lim { + charged(&budget); + return Ok(output.value()); + } + p = end; + let count = found.captures.as_ref().map_or(0, |captures| captures.len()); + if count > 1 { + let (array, _) = api::caught(|| { + super::perex_results::materialize( + &input, + &bound, + &forward, + &found, + // Captures lie within this match, just behind the search's end. + Some(position), + false, + &mut budget, + &mut host::poll, + ) + })??; + let array = local.root_raw_mut_ptr(array); + for capture in 1..count { + let value = array.with_const_ptr::(|array| { + crate::array::js_array_get_f64(array, capture as u32) + }); + output.push(value, &mut budget)?; + if output.len() == lim { + charged(&budget); + return Ok(output.value()); + } + } + } + q = p; + host::poll()?; + } + push_span(&mut output, &mut copies, p, size, &mut budget)?; + charged(&budget); + return Ok(output.value()); + } while q < size { let local = RuntimeHandleScope::new(); dispatch::set_last_index(&splitter, q as f64)?; @@ -149,6 +281,7 @@ pub(crate) fn regexp(receiver: f64, argument: f64, limit_value: f64) -> Result Result<(), EngineError>, ) -> Result<*mut StringHeader, EngineError> { - let mut readers = [ - BoundSpan::new(subject, span).map_err(|e| read_error(e, |never| match never {}))?, - BoundSpan::new(subject, span).map_err(|e| read_error(e, |never| match never {}))?, - ]; + copy_span_near(subject, span, None, budget, max_output_bytes, quantum, poll) +} + +/// `copy_span`, with both reader passes seeking to the span from `near` when +/// that is closer than either end. Materializing a match's captures from its +/// search's position seeks back by at most the match length (#10164). `near` +/// has the same same-binding requirement as `perex_runtime::find_near`. +pub(crate) fn copy_span_near( + subject: &BoundSubject>, + span: Span, + near: Option, + budget: &mut Budget, + max_output_bytes: usize, + quantum: usize, + poll: &mut impl FnMut() -> Result<(), EngineError>, +) -> Result<*mut StringHeader, EngineError> { + let reader = || { + match near { + Some(near) => BoundSpan::new_near(subject, span, near), + None => BoundSpan::new(subject, span), + } + .map_err(|e| read_error(e, |never| match never {})) + }; + let mut readers = [reader()?, reader()?]; copy_units( Some(span.len()), budget, diff --git a/crates/perry-runtime/src/string/append.rs b/crates/perry-runtime/src/string/append.rs index 789f3dff1b..5c51849197 100644 --- a/crates/perry-runtime/src/string/append.rs +++ b/crates/perry-runtime/src/string/append.rs @@ -127,7 +127,7 @@ pub extern "C" fn js_string_append( ); (*dest).byte_len = new_blen; (*dest).utf16_len += (*src).utf16_len; - (*dest).flags |= flag_bits; + (*dest).flags = ((*dest).flags | flag_bits) & !STRING_FLAG_WTF8_VALIDATED; return if boundary_pair { // Merge the straddling pair (usually returns a new, smaller // string; rare, so the in-place win still holds in general). diff --git a/crates/perry-runtime/src/string/concat.rs b/crates/perry-runtime/src/string/concat.rs index 800d942489..9fae0db487 100644 --- a/crates/perry-runtime/src/string/concat.rs +++ b/crates/perry-runtime/src/string/concat.rs @@ -806,7 +806,7 @@ pub extern "C" fn js_string_concat_value( let memoizable = total_blen <= CONCAT_MEMO_MAX_BYTES as usize && is_valid_string_ptr(prefix) && prefix_u16 == prefix_blen - && unsafe { (*prefix).flags == 0 } + && unsafe { (*prefix).flags & !STRING_FLAG_WTF8_VALIDATED == 0 } && bytes_all_ascii(string_data(prefix), prefix_blen) && concat_memo_should_probe(); let mut memo_buf = [0u8; CONCAT_MEMO_MAX_BYTES as usize]; @@ -1073,7 +1073,8 @@ fn append_chain_all_heap_strings( } (*dest).byte_len = total_blen; (*dest).utf16_len = total_u16; - (*dest).flags |= piece_flags; + // The destination's payload just changed; no piece's validation carries over. + (*dest).flags = ((*dest).flags | piece_flags) & !STRING_FLAG_WTF8_VALIDATED; return if piece_flags & STRING_FLAG_HAS_LONE_SURROGATES != 0 { canonicalize_surrogate_pairs(dest) } else { diff --git a/crates/perry-runtime/src/string/mod.rs b/crates/perry-runtime/src/string/mod.rs index 24fb6f64e4..fceb76cd36 100644 --- a/crates/perry-runtime/src/string/mod.rs +++ b/crates/perry-runtime/src/string/mod.rs @@ -135,6 +135,8 @@ mod trim_tests; /// end of an exact-sized payload. Unix-only (needs `mmap` + `mprotect`). #[cfg(all(test, unix))] mod tests_guard_page; +#[cfg(test)] +mod tests_validated_flag; // Explicit named re-exports — preserve the original `crate::string::*` // surface 1:1. NO glob re-exports. @@ -262,6 +264,17 @@ pub const STRING_FLAG_HAS_LONE_SURROGATES: u32 = 1; /// byte-level escape scan. String-producing mutations do not propagate this /// provenance bit unless they independently prove the resulting payload. pub(crate) const STRING_FLAG_JSON_ESCAPE_FREE: u32 = 1 << 1; +/// This exact header's payload was fully validated as generalized WTF-8 and its +/// `utf16_len` found exact, so a RegExp can bind it again in constant work +/// (`perex::binding::BoundSubject::new_counted`) instead of decoding it (#10166). +/// +/// Only the regex subject binding sets it, after a full validation succeeds. It +/// describes one payload, so it must never reach another string: +/// `init_string_header` strips it from every constructed string, and the +/// in-place writers (`js_string_append`, `js_string_append_chain`) clear it on +/// the destination they change. A validated header is already shared +/// (`js_string_addref`), so it is never itself mutated in place afterwards. +pub(crate) const STRING_FLAG_WTF8_VALIDATED: u32 = 1 << 2; /// A static empty string that can be used as a safe fallback for null pointers. /// Has utf16_len=0, byte_len=0, capacity=0, refcount=0, flags=0 (shared). @@ -812,7 +825,9 @@ pub(crate) unsafe fn init_string_header( (*ptr).byte_len = byte_len; (*ptr).capacity = capacity; (*ptr).refcount = refcount; - (*ptr).flags = flags; + // A new header never inherits its source's validation (#10166), however a + // caller computed `flags`. + (*ptr).flags = flags & !STRING_FLAG_WTF8_VALIDATED; } #[inline] diff --git a/crates/perry-runtime/src/string/tests_validated_flag.rs b/crates/perry-runtime/src/string/tests_validated_flag.rs new file mode 100644 index 0000000000..aa91667edc --- /dev/null +++ b/crates/perry-runtime/src/string/tests_validated_flag.rs @@ -0,0 +1,101 @@ +//! `STRING_FLAG_WTF8_VALIDATED` describes one header's payload (#10166). A +//! RegExp trusts it to bind in constant work without decoding, so it must never +//! reach any other string: a wrong bit gives wrong answers or a panic. +use super::*; + +fn heap(text: &str) -> *mut StringHeader { + js_string_from_bytes(text.as_ptr(), text.len() as u32) +} + +/// A shared heap string marked the way the regex binding marks one. +fn validated(text: &str) -> *mut StringHeader { + let s = heap(text); + unsafe { + (*s).refcount = 0; + (*s).flags |= STRING_FLAG_WTF8_VALIDATED; + } + s +} + +fn carries(s: *const StringHeader) -> bool { + unsafe { (*s).flags & STRING_FLAG_WTF8_VALIDATED != 0 } +} + +fn assert_not_inherited(name: &str, source: *const StringHeader, derived: *const StringHeader) { + if derived != source { + assert!( + !carries(derived), + "{name} copied the validation of its source" + ); + } +} + +#[test] +fn string_validated_flag_is_never_inherited_by_a_derived_string() { + let v = validated("abcdef"); + let w = validated("xyz"); + assert!(carries(v) && carries(w)); + assert_not_inherited("slice", v, js_string_slice(v, 1, 4)); + assert_not_inherited("substring", v, js_string_substring(v, 0, 2)); + assert_not_inherited("trim", v, js_string_trim(validated(" abc "))); + assert_not_inherited("concat", v, js_string_concat(v, w)); + assert_not_inherited("repeat", v, js_string_repeat(v, 3.0)); + assert_not_inherited("padStart", v, js_string_pad_start(v, 12.0, w)); + assert_not_inherited("toUpperCase", v, crate::string::js_string_to_upper_case(v)); + let array = crate::array::js_array_alloc(2); + let array = crate::array::js_array_push_f64(array, crate::value::js_nanbox_string(v as i64)); + let array = crate::array::js_array_push_f64(array, crate::value::js_nanbox_string(w as i64)); + assert_not_inherited( + "join", + v, + crate::array::js_array_join(array, validated(",")), + ); + // Constructors that take a caller-computed flags word, passed the source's + // whole word on purpose: the funnel must still strip the validation. + let whole = unsafe { (*v).flags }; + assert_not_inherited("string_copy_range", v, string_copy_range(v, 0, 3, 3, whole)); + assert_not_inherited( + "js_string_from_bytes_known_utf16", + v, + js_string_from_bytes_known_utf16(b"abc".as_ptr(), 3, 3, whole), + ); +} + +#[test] +fn string_in_place_append_clears_the_destinations_validation() { + // A unique destination with spare capacity is appended in place. It cannot + // normally be validated (validation happens on shared strings); mark it + // anyway to prove the writers clear the bit when the payload changes. + let dest = js_string_from_bytes_with_capacity(b"ab".as_ptr(), 2, 64); + let piece = validated("cd"); + unsafe { + (*dest).refcount = 1; + (*dest).flags |= STRING_FLAG_WTF8_VALIDATED; + } + let appended = js_string_append(dest, piece); + assert_eq!(appended, dest, "the test needs the in-place path"); + assert!( + !carries(appended), + "js_string_append kept a stale validation" + ); + + let chain_dest = js_string_from_bytes_with_capacity(b"ab".as_ptr(), 2, 64); + unsafe { + (*chain_dest).refcount = 1; + (*chain_dest).flags |= STRING_FLAG_WTF8_VALIDATED; + } + let parts = [ + crate::value::js_nanbox_string(chain_dest as i64), + crate::value::js_nanbox_string(validated("cd") as i64), + crate::value::js_nanbox_string(validated("ef") as i64), + ]; + let chained = js_string_append_chain(parts.as_ptr(), 3); + assert_eq!( + chained, chain_dest, + "the test needs the in-place chain path" + ); + assert!( + !carries(chained), + "js_string_append_chain kept a stale validation" + ); +} diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 93387d5fa6..997ca6399a 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -805,6 +805,18 @@ "scanner": "regex::regex_header_moved_for_gc (called from gc/types.rs on relocation), regex::regex_header_finalize_for_gc (gc/types.rs per-object finalize), regex::finalize_dead_copied_minor_from_space_regexps (gc/copying_phase.rs) and regex::collect_dead_registered_regexps_post_trace / finalize_collected_dead_regexp (gc/oldgen.rs)", "why": "Address-KEYED owner set, not a root, and the successor of REGEX_POINTERS under the single engine: its map is `usize` header address -> `RegexMetadata { registered_owner: bool }`, so the VALUE holds no heap address at all (source and flags live only in the header's traced string edges, and the compiled program is a traced GC child of the header). The key is rekeyed by `regex_header_moved_for_gc` when a RegExpHeader moves and removed on death by the finalize hook, the copying-minor from-space walk and the full-cycle post-trace walk; it never keeps a header alive. Reached from those GC hooks rather than a registered scanner, so the walk misses it." }, + { + "file": "crates/perry-runtime/src/regex/perex_split.rs", + "name": "FORWARD_SPLITS", + "verdict": "test_only", + "why": "#10165: #[cfg(test)] Cell counter of how many splits took the forward-search path, so tests can tell which path ran. It stores only a count and is absent from shipped binaries." + }, + { + "file": "crates/perry-runtime/src/regex/perex_split.rs", + "name": "LAST_FORWARD_WORK", + "verdict": "test_only", + "why": "#10164: #[cfg(test)] Cell holding the Perex work units the last forward split charged, so tests can assert its cost scales linearly. A quantity, never an address, and absent from shipped binaries." + }, { "file": "crates/perry-runtime/src/regex/site_test.rs", "name": "DIRECT_G",