From 5e3447021b278c59107a52c7fac6294fe5397195 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 07:43:31 +0000 Subject: [PATCH 1/9] perf(regex): bind subject and program once per split/replace/match (#10165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit String split, replace and global match run a search at every position or match of one string with one matcher, but execute_with_resources bound both afresh for each search. Binding a subject decodes the entire string (perex Input::wtf8, uncharged) and binding a program revalidates every word, so each of those operations did O(n) binding work per search and O(n²) overall. On ASCII input this is why `str.split(/[,; ]+/)` took 8.1 s for a 150,000-unit string. The three loops already held a BoundSubject over their input. A new perex_api::Reuse carries it, plus a program binding taken from the receiver before the loop, into execute_with_resources. Perex's binding contract allows a binding to outlive allocation, collection and JS callbacks: Perry's owners hold registered roots and reacquire their base on every view. A search uses a reused binding only while it is provably the same object (same string; same receiver still holding the same program cell); otherwise it binds afresh exactly as before, which covers an exec override, RegExp.prototype.compile and a different string. Reuse is built before each loop because runtime handle scopes are a stack. matchAll's next(), JS-level exec/test and search are one search per JS call and are unchanged; cross-call reuse is a separate contract. The non-ASCII seek charge behind the work-limit RangeError (#10164) is on the Perex side and needs its search-from-position API; this change does not affect it. Tests: gc::tests::runtime_roots::perex_reuse covers a whole global loop with a minor collection at every poll under forced evacuation (subject and program cell both relocate; fresh work equals reused work plus six program validations, proving reuse engages), a recompile between searches, and a different string. Each test fails when its guard is sabotaged. Claude-Session: https://claude.ai/code/session_01Da12JXeG5XuVBma5yWp5C9 --- .../src/gc/tests/runtime_roots.rs | 2 + .../gc/tests/runtime_roots/perex_dispatch.rs | 7 +- .../src/gc/tests/runtime_roots/perex_reuse.rs | 226 ++++++++++++++++++ crates/perry-runtime/src/regex/match_all.rs | 1 + crates/perry-runtime/src/regex/perex_api.rs | 118 ++++++++- .../perry-runtime/src/regex/perex_dispatch.rs | 5 +- .../src/regex/perex_match_search.rs | 15 +- .../perry-runtime/src/regex/perex_replace.rs | 2 + crates/perry-runtime/src/regex/perex_split.rs | 3 + 9 files changed, 362 insertions(+), 17 deletions(-) create mode 100644 crates/perry-runtime/src/gc/tests/runtime_roots/perex_reuse.rs diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots.rs b/crates/perry-runtime/src/gc/tests/runtime_roots.rs index 58feac5ab8..d21d61fe5c 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots.rs @@ -37,6 +37,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_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..9050c8c439 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_reuse.rs @@ -0,0 +1,226 @@ +//! 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) { + 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 || { + 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); + // Six searches (five matches and the final miss). Binding per search charges + // program validation six times; reuse charged it once, in `setup`. + let validation = api::WORK - setup.remaining(); + assert!(validation > 0); + assert_eq!(fresh_work, reused_work + 6 * 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"); +} 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..900e603280 100644 --- a/crates/perry-runtime/src/regex/perex_api.rs +++ b/crates/perry-runtime/src/regex/perex_api.rs @@ -97,6 +97,86 @@ pub(crate) fn program<'s>( BoundProgram::new(owner, budget).map_err(|e| EngineError::Program(e.error)) } +/// 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. +pub(crate) struct Reuse<'b, 's> { + input: RuntimeHandle<'s>, + subject: &'b BoundSubject>, + program: Option>, +} + +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 = BoundProgram::new(owner, budget).ok()?; + Some(ReusedProgram { + receiver, + cell, + bound, + }) + }) + .flatten(); + Self { + input, + subject, + program, + } + } + + 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 { pub(crate) full: Span, pub(crate) array: *mut crate::array::ArrayHeader, @@ -186,12 +266,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 +280,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,15 +299,29 @@ 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 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 subject = match reuse.and_then(|reuse| reuse.subject_for(&input)) { + Some(subject) => subject, + None => { + fresh_subject = + BoundSubject::new(unsafe { HeapSubject::new(input) }.map_err(|e| { + EngineError::Subject(perex::binding::SubjectError::Resource(e)) + })?) + .map_err(|e| EngineError::Subject(e.error))?; + &fresh_subject + } + }; let found = host::find( - &program, - &subject, + program, + subject, start, if materialize { CaptureMode::All @@ -252,8 +348,8 @@ pub(crate) fn execute_with_resources( caught(|| { super::perex_results::materialize( &input, - &subject, - &program, + subject, + program, &found, has_indices, budget, 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 { 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) 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 Result Result Result Date: Sun, 13 Sep 2026 08:30:18 +0000 Subject: [PATCH 2/9] perf(regex): split searches forward instead of trying every position (#10165) RegExp.prototype[@@split] tries a sticky match at every position q. Each attempt starts a whole search, so split pays a search's fixed setup per subject unit: about 27.6 work units per unit for `/[,; ]+/`, against about 9 for a global exec loop over the same subject. 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. The attempts at q..s-1 can therefore be skipped without changing any piece or capture, and empty matches and Unicode advancement line up. Skipping them is unobservable only when nothing can see a RegExpExec: - the species is absent or the intrinsic RegExp (recognised by its call thunk), so the splitter is a fresh object no user code holds and its skipped lastIndex writes cannot be seen; a user species could return a real RegExp and read lastIndex afterwards; - the splitter's exec resolves, without running a getter, to the builtin data property (regexp_view_uses_builtin), so the skipped Get(exec) calls cannot be seen either. When both hold, split compiles a program from the splitter's own internal source and canonical flags without `y` (never from the receiver, whose program a limit valueOf could replace via RegExp.prototype.compile after the splitter was built) and searches forward with it, reusing the operation's subject binding. Anything else runs the unchanged per-position sticky loop. Tests (gc::tests::runtime_roots::perex_split): - forward search matches ten results derived by hand from the sticky algorithm (repeated and unmatched captures, empty matches, `$` at the end, limits inside captures, Unicode empty-match advancement, the non-ASCII #10164 record), each asserted to take the forward path; - a user species returning a real RegExp keeps the sticky loop and leaves the splitter's lastIndex at 2, as the specification requires; - the existing species-factory and custom-exec tests now also assert the sticky loop ran. Sabotage: admitting any species fails the user-species test; trying the end of the input or dropping captures fails the forward-search test. Claude-Session: https://claude.ai/code/session_01Da12JXeG5XuVBma5yWp5C9 --- .../src/gc/tests/runtime_roots/perex_split.rs | 146 ++++++++++++++++++ .../src/object/regex_proto_thunks.rs | 11 ++ .../src/regex/perex_construct.rs | 24 +++ crates/perry-runtime/src/regex/perex_split.rs | 116 +++++++++++++- 4 files changed, 295 insertions(+), 2 deletions(-) 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..142cec6c40 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,137 @@ 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); +} 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/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_split.rs b/crates/perry-runtime/src/regex/perex_split.rs index f3ccb13e08..56dc16542a 100644 --- a/crates/perry-runtime/src/regex/perex_split.rs +++ b/crates/perry-runtime/src/regex/perex_split.rs @@ -4,14 +4,15 @@ 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::Budget; /// Literal String operations also accept Perry's raw Buffer/FFI payloads. @@ -56,6 +57,51 @@ fn advance( Ok(index + 1) } +// Counts forward splits taken, so tests can tell which path ran. +#[cfg(test)] +thread_local! { + pub(crate) static FORWARD_SPLITS: 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<'_, '_>, @@ -141,6 +187,72 @@ pub(crate) fn regexp(receiver: f64, argument: f64, limit_value: f64) -> Result= 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 { + 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, + 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 { + return Ok(output.value()); + } + } + } + q = p; + host::poll()?; + } + push_span(&mut output, &mut copies, p, size, &mut budget)?; + return Ok(output.value()); + } while q < size { let local = RuntimeHandleScope::new(); dispatch::set_last_index(&splitter, q as f64)?; From f8301013b5862a8ba6c49a2e4c9df63b6370b4c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 09:57:30 +0000 Subject: [PATCH 3/9] changelog: add fragment for #10174 Claude-Session: https://claude.ai/code/session_01Da12JXeG5XuVBma5yWp5C9 --- changelog.d/10174-regex-bind-once-forward-split.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/10174-regex-bind-once-forward-split.md 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×. From d61fe859139d23504953fdc5faaa9eae78f58aa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 10:46:33 +0000 Subject: [PATCH 4/9] gc: record the forward-split test counter's holder verdict (#10165) gc_runtime_root_holders.py flags the new #[cfg(test)] FORWARD_SPLITS Cell under rule B. It is a test-only count, never an address. Claude-Session: https://claude.ai/code/session_01Da12JXeG5XuVBma5yWp5C9 --- scripts/gc_runtime_root_holders.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 93387d5fa6..46d9d4c191 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -805,6 +805,12 @@ "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/site_test.rs", "name": "DIRECT_G", From 050ecb71ba4ccbca6ef655dc65f21b7b3e532416 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 08:31:17 +0000 Subject: [PATCH 5/9] perf(regex): resume searches and capture reads from the previous position (#10164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On non-ASCII (byte) storage a Perex search seeks to its start from the nearer end of the subject, up to half its length. A global replace or match starts a search per match, and split one per match or position, so the seeks summed to about n²/4: at 32,000 units that exceeded the former 100,000,000-unit allowance and threw, and without a cap it is quadratic time. Materializing captures seeked from an end the same way. Perex 0.1.2 adds a search-from-position API: Search::new_near and Search::position (the match end, or the start of the last attempt), and BoundSpan::new_near. perex_runtime::find_near and perex_strings::copy_span_near take an optional position; find and copy_span delegate to them with none. - perex_api::Reuse carries the last position of the reused subject, set only from and used only with that binding, because a position from another string with the same layout cannot be detected. execute_with_resources seeds each search and its capture materialization from it; global match copies each result from its search's end. - Split's forward search seeds each search from the previous one and materializes captures from the search's position. Tests: - perex_reuse: a non-ASCII global loop's work roughly doubles when the subject doubles (3.71x without positions); the #10164 reduction, a 32,000-unit split (6,001 pieces) and a 60,000-unit global replace (76,000 units), completes. - perex_split: a non-ASCII forward split's work roughly doubles when the input doubles (3.97x when it never resumes). Sabotage: each of those fails when positions are not used. Requires perex 0.1.2, published 2026-09-13 from PerryTS/perex d9f395d88931f0cfee1fe89ddf455cda606fd9e1 (crates.io checksum 21df239ee18f99de6abff50953f6f15be1b5ebd11e6ae9661acdd93026e983db). It is inside the workspace's 7-day min-publish-age window, so Cargo.lock was resolved once with CARGO_RESOLVER_INCOMPATIBLE_PUBLISH_AGE=allow, as for perex 0.1.0, with the maintainer's approval. Ordinary --locked builds use the locked version without the override. Claude-Session: https://claude.ai/code/session_01Da12JXeG5XuVBma5yWp5C9 --- Cargo.lock | 4 +- Cargo.toml | 2 +- .../src/gc/tests/runtime_roots/perex_reuse.rs | 104 +++++++++++++++++- .../src/gc/tests/runtime_roots/perex_split.rs | 28 +++++ crates/perry-runtime/src/regex/perex_api.rs | 27 ++++- .../src/regex/perex_match_search.rs | 4 +- .../perry-runtime/src/regex/perex_results.rs | 5 +- .../perry-runtime/src/regex/perex_runtime.rs | 36 +++++- crates/perry-runtime/src/regex/perex_split.rs | 26 ++++- .../perry-runtime/src/regex/perex_strings.rs | 29 ++++- 10 files changed, 243 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 554bbe3def..6e9faa0212 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5684,9 +5684,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perex" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b2094dda4d997bf73a372cb660d02e9abdb0a13cbf834ddd2b2f8847bffe2cf" +checksum = "21df239ee18f99de6abff50953f6f15be1b5ebd11e6ae9661acdd93026e983db" [[package]] name = "perry" diff --git a/Cargo.toml b/Cargo.toml index c12fd3abd3..24d94eb23b 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.2" hex = "0.4" tempfile = "3" itoa = "1.0" 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 index 9050c8c439..e6fdd12f94 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_reuse.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_reuse.rs @@ -48,6 +48,15 @@ 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); @@ -65,7 +74,9 @@ fn global_loop( &mut budget, &memory, &mut || { - gc_collect_minor(); + if collect { + gc_collect_minor(); + } Ok(()) }, reuse, @@ -130,10 +141,15 @@ fn perex_reuse_serves_a_whole_global_loop_across_moving_collections() { let (fresh_matches, fresh_work) = global_loop(&fresh, &fresh_input, None); assert_eq!(fresh_matches, expected); // Six searches (five matches and the final miss). Binding per search charges - // program validation six times; reuse charged it once, in `setup`. + // program validation six times; reuse charged it once, in `setup`. Reuse + // also resumes each search from the previous one instead of seeking from + // an end of this non-ASCII subject, so it saves at least the validations. let validation = api::WORK - setup.remaining(); assert!(validation > 0); - assert_eq!(fresh_work, reused_work + 6 * validation); + assert!( + fresh_work >= reused_work + 6 * validation, + "reuse must save at least six program validations: fresh {fresh_work}, reused {reused_work}, one validation {validation}" + ); } #[test] @@ -224,3 +240,85 @@ fn perex_reuse_binds_a_different_string_afresh() { .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 142cec6c40..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 @@ -1287,3 +1287,31 @@ fn perex_split_user_species_regexp_keeps_the_observable_sticky_loop() { 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/regex/perex_api.rs b/crates/perry-runtime/src/regex/perex_api.rs index 900e603280..24105efc28 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. @@ -112,10 +113,15 @@ pub(crate) fn program<'s>( /// 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> { @@ -157,9 +163,15 @@ impl<'b, 's> Reuse<'b, 's> { 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); @@ -308,7 +320,12 @@ pub(crate) fn execute_with_resources( } }; let fresh_subject; - let subject = match reuse.and_then(|reuse| reuse.subject_for(&input)) { + 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 = @@ -319,10 +336,11 @@ pub(crate) fn execute_with_resources( &fresh_subject } }; - let found = host::find( + let (found, position) = host::find_near( program, subject, start, + near, if materialize { CaptureMode::All } else { @@ -333,6 +351,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(|| { @@ -351,6 +372,8 @@ pub(crate) fn execute_with_resources( 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_match_search.rs b/crates/perry-runtime/src/regex/perex_match_search.rs index 0ef7af35ef..c09a371025 100644 --- a/crates/perry-runtime/src/regex/perex_match_search.rs +++ b/crates/perry-runtime/src/regex/perex_match_search.rs @@ -223,9 +223,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_results.rs b/crates/perry-runtime/src/regex/perex_results.rs index 142aceecbc..4590b95318 100644 --- a/crates/perry-runtime/src/regex/perex_results.rs +++ b/crates/perry-runtime/src/regex/perex_results.rs @@ -3,7 +3,7 @@ use super::perex_api::{OUTPUT_BYTES, QUANTUM}; use super::perex_owner::{GcProgram, HeapSubject}; use super::perex_runtime::{self as host, EngineError, Match}; -use super::perex_strings::{copy_name, copy_span}; +use super::perex_strings::{copy_name, copy_span_near}; use crate::array::ArrayHeader; use crate::gc::{RuntimeHandle, RuntimeHandleScope}; use crate::object::ObjectHeader; @@ -21,6 +21,7 @@ pub(super) fn materialize( subject: &BoundSubject>, 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 56dc16542a..7dfd71645e 100644 --- a/crates/perry-runtime/src/regex/perex_split.rs +++ b/crates/perry-runtime/src/regex/perex_split.rs @@ -13,6 +13,7 @@ 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::{BoundProgram, BoundSubject, SubjectError}; +use perex::input::Position; use perex::Budget; /// Literal String operations also accept Perry's raw Buffer/FFI payloads. @@ -57,10 +58,12 @@ fn advance( Ok(index + 1) } -// Counts forward splits taken, so tests can tell which path ran. +// 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). @@ -190,19 +193,29 @@ pub(crate) fn regexp(receiver: f64, argument: f64, limit_value: f64) -> Result = None; while q < size { let local = RuntimeHandleScope::new(); - let Some(found) = host::find( + let (found, position) = host::find_near( &forward, &bound, q, + near, CaptureMode::All, &mut budget, &memory, api::QUANTUM, &mut host::poll, - )? - else { + )?; + near = Some(position); + let Some(found) = found else { break; }; let start = found.full.start(); @@ -220,6 +233,7 @@ pub(crate) fn regexp(receiver: f64, argument: f64, limit_value: f64) -> Result Result Result 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, From dab9993471310e255c040f3333acf92512b43121 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 09:57:30 +0000 Subject: [PATCH 6/9] changelog: add fragment for #10181 Claude-Session: https://claude.ai/code/session_01Da12JXeG5XuVBma5yWp5C9 --- changelog.d/10181-regex-resume-from-position.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/10181-regex-resume-from-position.md 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. From d5d19743245390093b97737969a436394eb9d36c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 10:46:41 +0000 Subject: [PATCH 7/9] gc: record the forward-split work counter's holder verdict (#10164) gc_runtime_root_holders.py flags the new #[cfg(test)] LAST_FORWARD_WORK Cell under rule B. It is a test-only work count, never an address. Claude-Session: https://claude.ai/code/session_01Da12JXeG5XuVBma5yWp5C9 --- scripts/gc_runtime_root_holders.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 46d9d4c191..997ca6399a 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -811,6 +811,12 @@ "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", From 594063573558c90064674b9137016d0c0f746eec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 10:06:02 +0000 Subject: [PATCH 8/9] perf(regex): bind a RegExp's program and subject in constant work across calls (#10166) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A search per JavaScript call (an exec or test loop, matchAll's next(), search) bound its subject and program from scratch every time: binding a subject decodes the entire string and binding a program revalidates every word. A loop over one string therefore did O(n) binding work per call and O(n²) overall, and `.test()` paid a program validation per call. Perex 0.1.3 adds constant-work rebinding for what the host already validated: BoundSubject::new_counted(storage, utf16_len) and a ProgramWitness from BoundProgram::witness() that BoundProgram::new_witnessed checks against the program's length and header. Perry now keeps both, as plain data, with no allocation and nothing new traced: - Program: ProgramCell gains `witness: Option` in its pointer-free prefix, beside the words it describes. The first validating bind records it; later binds use new_witnessed, falling back to validation (recording a fresh witness) if it does not match. A recompile emits a new cell that starts with none, so a witness can never describe other words. RegExpHeader stays 56 bytes. Debug builds assert no witness is written while a view of the cell's words is live. - Subject: StringHeader gains STRING_FLAG_WTF8_VALIDATED. Perry strings are not all valid WTF-8 (raw Buffer/FFI payloads reach regex operations), so nothing is assumed: the first bind decodes, and only if that succeeds and the decoded UTF-16 length equals the header's is the header marked; later binds use new_counted. A validated header is already shared, so its payload is never mutated in place. init_string_header strips the bit from every constructed string, and the in-place writers (js_string_append, js_string_append_chain) clear it, so it never reaches another string; concat's memo check ignores it. Deliberately excluded: a cross-call lastIndex position hint for non-ASCII exec loops. Recognising the same string across calls without a traced reference would need a heap generation counter bumped on every free and move path, and one missed path gives silent wrong answers. The remaining cost is one seek from the nearer end of the subject per call on non-ASCII subjects; it is charged but uncapped (#10176), so those loops finish, but are not linear. Tests: - string::tests_validated_flag: slice, substring, trim, concat, repeat, padStart, toUpperCase, join, string_copy_range and js_string_from_bytes_known_utf16 (passed the source's whole flags word) never inherit the bit; both in-place append paths clear it. - gc::tests::runtime_roots::perex_cross_call: a program cell records its witness on first bind and a recompiled program's new cell has none; a foreign equal-header witness (x(b) on x(a)) still answers with the cell's own words; a mismatched witness falls back and is replaced; a subject is marked only with an exact length, never with a corrupted utf16_len or malformed bytes; marks survive moving collections; a witness write under a live view is caught. - perex_reuse's accounting now expects one validation per program. Fault injection, each confirmed to fail its test: marking without the length check; never recording a witness; a mismatch without fallback; removing the view guard; keeping the whole flags word in init_string_header; keeping the bit in the in-place writers. Requires perex 0.1.3 (crates.io checksum 060b4682849d20ebcba05d68f9584a1bba20af4b7838c688cfb37af572f562f4), resolved once with CARGO_RESOLVER_INCOMPATIBLE_PUBLISH_AGE=allow with the maintainer's approval; ordinary --locked builds use it without the override. Claude-Session: https://claude.ai/code/session_01Da12JXeG5XuVBma5yWp5C9 --- Cargo.lock | 4 +- Cargo.toml | 2 +- .../src/gc/tests/runtime_roots.rs | 2 + .../tests/runtime_roots/perex_cross_call.rs | 227 ++++++++++++++++++ .../src/gc/tests/runtime_roots/perex_reuse.rs | 14 +- crates/perry-runtime/src/regex/perex_api.rs | 75 +++++- .../src/regex/perex_match_search.rs | 6 +- crates/perry-runtime/src/regex/perex_owner.rs | 85 ++++++- crates/perry-runtime/src/string/append.rs | 2 +- crates/perry-runtime/src/string/concat.rs | 5 +- crates/perry-runtime/src/string/mod.rs | 17 +- .../src/string/tests_validated_flag.rs | 101 ++++++++ 12 files changed, 514 insertions(+), 26 deletions(-) create mode 100644 crates/perry-runtime/src/gc/tests/runtime_roots/perex_cross_call.rs create mode 100644 crates/perry-runtime/src/string/tests_validated_flag.rs diff --git a/Cargo.lock b/Cargo.lock index 6e9faa0212..e217c7c9d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5684,9 +5684,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perex" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21df239ee18f99de6abff50953f6f15be1b5ebd11e6ae9661acdd93026e983db" +checksum = "060b4682849d20ebcba05d68f9584a1bba20af4b7838c688cfb37af572f562f4" [[package]] name = "perry" diff --git a/Cargo.toml b/Cargo.toml index 24d94eb23b..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.2" +perex = "0.1.3" hex = "0.4" tempfile = "3" itoa = "1.0" diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots.rs b/crates/perry-runtime/src/gc/tests/runtime_roots.rs index d21d61fe5c..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; 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_reuse.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_reuse.rs index e6fdd12f94..3eddd46247 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_reuse.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_reuse.rs @@ -140,15 +140,17 @@ fn perex_reuse_serves_a_whole_global_loop_across_moving_collections() { let fresh = regex(&scope, PATTERN, "gu"); let (fresh_matches, fresh_work) = global_loop(&fresh, &fresh_input, None); assert_eq!(fresh_matches, expected); - // Six searches (five matches and the final miss). Binding per search charges - // program validation six times; reuse charged it once, in `setup`. Reuse - // also resumes each search from the previous one instead of seeking from - // an end of this non-ASCII subject, so it saves at least the validations. + // 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 + 6 * validation, - "reuse must save at least six program validations: fresh {fresh_work}, reused {reused_work}, one validation {validation}" + fresh_work > reused_work + validation, + "reuse must save its seeks as well as the validation: fresh {fresh_work}, reused {reused_work}, one validation {validation}" ); } diff --git a/crates/perry-runtime/src/regex/perex_api.rs b/crates/perry-runtime/src/regex/perex_api.rs index 24105efc28..bc5585965b 100644 --- a/crates/perry-runtime/src/regex/perex_api.rs +++ b/crates/perry-runtime/src/regex/perex_api.rs @@ -95,7 +95,72 @@ 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). @@ -151,7 +216,7 @@ impl<'b, 's> Reuse<'b, 's> { 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 = BoundProgram::new(owner, budget).ok()?; + let bound = bind_program(owner, budget).ok()?; Some(ReusedProgram { receiver, cell, @@ -328,11 +393,7 @@ pub(crate) fn execute_with_resources( let subject = match reused_subject { Some(subject) => subject, None => { - fresh_subject = - BoundSubject::new(unsafe { HeapSubject::new(input) }.map_err(|e| { - EngineError::Subject(perex::binding::SubjectError::Resource(e)) - })?) - .map_err(|e| EngineError::Subject(e.error))?; + fresh_subject = bind_heap_subject(input)?; &fresh_subject } }; diff --git a/crates/perry-runtime/src/regex/perex_match_search.rs b/crates/perry-runtime/src/regex/perex_match_search.rs index c09a371025..3222675382 100644 --- a/crates/perry-runtime/src/regex/perex_match_search.rs +++ b/crates/perry-runtime/src/regex/perex_match_search.rs @@ -53,11 +53,7 @@ pub(crate) fn flags(receiver: f64) -> 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( 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/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" + ); +} From cf63ad155d5d17697bf637c96d78dcab72680d20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 10:18:57 +0000 Subject: [PATCH 9/9] changelog: add fragment for #10183 Claude-Session: https://claude.ai/code/session_01Da12JXeG5XuVBma5yWp5C9 --- changelog.d/10183-regex-cross-call-rebinding.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/10183-regex-cross-call-rebinding.md 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.