From 97ad3a7dec1648b99b28077af4d1cfe56127cee1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 16:17:12 +0200 Subject: [PATCH 1/2] fix(regex): stop capping replace and split output lists at the scratch limit (#10164) `String.prototype.replace`, `replaceAll` and `split` threw "Regular expression memory limit exceeded" once an operation's list reached 8,388,608 entries: `List::push` refused past `SCRATCH_BYTES / 8`. A replacement stores three entries per output piece and a callback replace appends two pieces per match, so `'ab12 cd345;'.repeat(n).replace(/[0-9]+/g, cb)` threw from exactly 699,050 records, where Node finishes in about 235 ms. That limit is for native scratch buffers; these lists are ordinary GC arrays whose length follows the subject. The cap is removed. The lists are bounded by what can be allocated, and output by the existing maximum-string-length check on piece units. The work budget has been unlimited since #10176 for the same reason. Test: a `replaceAll` over one match more than the former cap allows now produces the complete output; with the cap restored the same test aborts on the thrown error. Claude-Session: https://claude.ai/code/session_01RJkA4Fhqz9J5F5fzDk5HWv --- changelog.d/regex-list-cap.md | 6 +++++ .../gc/tests/runtime_roots/perex_replace.rs | 24 +++++++++++++++++++ .../src/regex/perex_replace_storage.rs | 8 ++++--- 3 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 changelog.d/regex-list-cap.md diff --git a/changelog.d/regex-list-cap.md b/changelog.d/regex-list-cap.md new file mode 100644 index 0000000000..7ecbeb28e4 --- /dev/null +++ b/changelog.d/regex-list-cap.md @@ -0,0 +1,6 @@ +Removed the fixed entry cap on the regex runtime's operation lists, which made +`String.prototype.replace`, `replaceAll` and `split` throw "Regular expression +memory limit exceeded" once their output reached 8,388,608 list entries — a +callback `replace` over about 700,000 short records. Those lists are ordinary GC +arrays and are now bounded by allocation and the maximum string length, like +the rest of the runtime. diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_replace.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_replace.rs index 3faa803d6a..b17ba58705 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_replace.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_replace.rs @@ -492,3 +492,27 @@ fn perex_replace_primitive_search_skips_prototype_hook_and_coerces_receiver() { ); } } + +#[test] +fn perex_replace_output_is_not_capped_by_the_scratch_limit() { + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + let scope = RuntimeHandleScope::new(); + // Each match after the first appends the preceding "b" and the "x" that + // replaces it: two pieces of three list entries each. The scratch limit + // divided by eight was the list's former entry cap, so this is one match + // past what could be written before. + let former_cap = api::SCRATCH_BYTES / 8; + let matches = former_cap / 6 + 1; + let input = text(&scope, &b"ab".repeat(matches)); + let search = text(&scope, b"a"); + let replacement = text(&scope, b"x"); + let result = crate::regex::js_string_replace_all_js( + input.get_nanbox_f64(), + search.get_nanbox_f64(), + replacement.get_nanbox_f64(), + ); + let output = bytes(result); + assert_eq!(output.len(), matches * 2); + assert!(output.as_chunks::<2>().0.iter().all(|pair| pair == b"xb")); +} diff --git a/crates/perry-runtime/src/regex/perex_replace_storage.rs b/crates/perry-runtime/src/regex/perex_replace_storage.rs index d0c752f62f..95f5d2ee42 100644 --- a/crates/perry-runtime/src/regex/perex_replace_storage.rs +++ b/crates/perry-runtime/src/regex/perex_replace_storage.rs @@ -52,11 +52,13 @@ impl<'a> List<'a> { self.root .with_const_ptr(|array| crate::array::js_array_get_f64(array, index as u32)) } + /// Append one value. The list is an ordinary GC array, so it is bounded + /// by what can be allocated, not by a count: the scratch limit is for + /// native buffers, and a list's length follows the subject. A replacement + /// producing more pieces than that limit's entries once threw a memory + /// error on subjects Node replaces in a fraction of a second (#10164). pub(super) fn push(&mut self, value: f64, budget: &mut Budget) -> Result<(), EngineError> { host::charge(budget, 1)?; - if self.count >= api::SCRATCH_BYTES / 8 { - return Err(StorageError::Limit.into()); - } let scope = RuntimeHandleScope::new(); let value = scope.root_nanbox_f64(value); let array = api::caught(|| { From 7b10d913b6cc053f13ca1705f3c158e117596b43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 16:25:09 +0200 Subject: [PATCH 2/2] changelog: name the fragment for #10207 Claude-Session: https://claude.ai/code/session_01RJkA4Fhqz9J5F5fzDk5HWv --- changelog.d/{regex-list-cap.md => 10207-regex-list-cap.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{regex-list-cap.md => 10207-regex-list-cap.md} (100%) diff --git a/changelog.d/regex-list-cap.md b/changelog.d/10207-regex-list-cap.md similarity index 100% rename from changelog.d/regex-list-cap.md rename to changelog.d/10207-regex-list-cap.md