From 6176e289560c6adbb96553fa873797bc1745e707 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 08:17:51 +0000 Subject: [PATCH 1/2] fix(regex): do not cap RegExp operations by work (#10164) Every RegExp operation ran under one fixed Budget of 100,000,000 work units. Valid programs Node completes threw `RangeError: Regular expression work limit exceeded`: a 32,000-unit non-ASCII split and a 60,000-unit global replace (from the per-search seek charge), and after #10165's fixes even linear splits and replaces of 11-15 million units. No finite allowance separates valid programs from pathological ones. Perex charges per subject unit an amount set by the program, not the subject: about 1 for `/x/`, 9 for `/\w+/g`, 60 for `/([a-z]+)([0-9]+)/g`, over 200 for a 32-unit lookahead. Any cap therefore throws on some large linear input, while a quadratic pattern on a short subject never reaches it. JavaScript engines never abort matching for work. WORK becomes usize::MAX. Searches still run in QUANTUM slices with a GC poll between them, so collection and cancellation keep working, and the scratch, program and output memory limits are unchanged. A catastrophic pattern now runs as long as it does in Node instead of throwing. The existing tests that exercise ExecError::WorkLimit all pass their own small budgets, so they still cover the error mapping and accounting. Test: gc::tests::runtime_roots::perex_work_policy runs one valid, linear search that charges about 1.2e8 units (a failing 32-unit lookahead at every position, with no required literal that admission could reject up front) and asserts it completes and charges more than the former limit. It fails when the old 100,000,000 cap is restored. Claude-Session: https://claude.ai/code/session_01Da12JXeG5XuVBma5yWp5C9 --- .../src/gc/tests/runtime_roots.rs | 2 + .../tests/runtime_roots/perex_work_policy.rs | 62 +++++++++++++++++++ crates/perry-runtime/src/regex/perex_api.rs | 13 +++- 3 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 crates/perry-runtime/src/gc/tests/runtime_roots/perex_work_policy.rs diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots.rs b/crates/perry-runtime/src/gc/tests/runtime_roots.rs index d21d61fe5c..a5e4bf025f 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots.rs @@ -42,6 +42,8 @@ mod perex_reuse; mod perex_split; #[cfg(feature = "regex-engine")] mod perex_strings; +#[cfg(feature = "regex-engine")] +mod perex_work_policy; mod prototype_addr_cache; mod regexp_last_index; mod segment_record_keys; diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_work_policy.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_work_policy.rs new file mode 100644 index 0000000000..dabc3f28c1 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_work_policy.rs @@ -0,0 +1,62 @@ +//! RegExp operations are not capped by work (#10164): a valid program whose +//! matching charges more than the former 100,000,000-unit allowance completes. +use super::*; +use crate::regex::perex_api as api; +use crate::regex::perex_memory::MemoryBudget; +use crate::regex::RegExpHeader; +use crate::string::StringHeader; +use perex::Budget; + +const FORMER_WORK_LIMIT: usize = 100_000_000; + +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, + )) +} + +#[test] +fn perex_operation_allowance_admits_linear_work_beyond_the_former_limit() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + let scope = RuntimeHandleScope::new(); + // One search that is linear but heavy: at every position the lookahead + // reads 32 units and then fails on a character class the subject never + // contains, so it matches nothing and allocates no results. The pattern has + // no required literal on purpose; a literal lets admission reject the whole + // search in one pass without doing the per-position work. Measured at about + // 220 work units per subject unit, so 540,000 units charge about 1.2e8. + let pattern = text(&scope, br"\w(?=[\w,; ]{32}[^\w,; ])"); + let flags = text(&scope, b""); + let receiver = scope.root_raw_mut_ptr(pattern.with_const_ptr::(|pattern| { + flags.with_const_ptr::(|flags| crate::regex::js_regexp_new(pattern, flags)) + })); + let input = text(&scope, "ab12,cd345;ef6 ".repeat(36_000).as_bytes()); + let memory = MemoryBudget::new(api::SCRATCH_BYTES); + let mut budget = Budget::new(api::WORK); + let found = receiver.with_mut_ptr::(|receiver| { + input.with_const_ptr::(|input| { + api::execute_with_resources( + receiver, + input, + false, + &mut budget, + &memory, + &mut || Ok(()), + None, + ) + }) + }); + let charged = api::WORK - budget.remaining(); + assert!( + matches!(found, Ok(None)), + "a valid search must complete without a work-limit error" + ); + assert!( + charged > FORMER_WORK_LIMIT, + "the witness must charge more than the former limit to prove anything; \ + charged {charged}, so a Perex change may have made this pattern cheaper" + ); +} diff --git a/crates/perry-runtime/src/regex/perex_api.rs b/crates/perry-runtime/src/regex/perex_api.rs index 900e603280..2e23d56d6d 100644 --- a/crates/perry-runtime/src/regex/perex_api.rs +++ b/crates/perry-runtime/src/regex/perex_api.rs @@ -12,7 +12,18 @@ use perex::executor::ExecError; use perex::{span::Span, Budget}; // One explicit host policy; no retained scratch cache or alternate engine. -pub(crate) const WORK: usize = 100_000_000; +/// A RegExp operation's work allowance: effectively unlimited (#10164). +/// +/// JavaScript engines never abort regex matching for doing too much work, and +/// no finite allowance separates valid programs from pathological ones: Perex's +/// charge per subject unit depends on the program (about 1 for `/x/`, 60 for +/// `/([a-z]+)([0-9]+)/g`, over 200 for a 32-unit lookahead), so any cap throws +/// on some large linear input Node completes. The former 100,000,000 did, as +/// `RangeError: Regular expression work limit exceeded`. Searches still run in +/// `QUANTUM` slices with a GC poll between them, so collection and cancellation +/// keep working; a catastrophic pattern runs as long as it does in Node. The +/// memory limits below are unchanged. +pub(crate) const WORK: usize = usize::MAX; pub(crate) const SCRATCH_BYTES: usize = 64 * 1024 * 1024; pub(crate) const PROGRAM_BYTES: usize = 32 * 1024 * 1024; pub(crate) const QUANTUM: usize = 4096; From b895f32e511418e5bc8ae5c9adb054c5d57ecac2 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 2/2] changelog: add fragment for #10176 Claude-Session: https://claude.ai/code/session_01Da12JXeG5XuVBma5yWp5C9 --- changelog.d/10176-regex-no-work-cap.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/10176-regex-no-work-cap.md diff --git a/changelog.d/10176-regex-no-work-cap.md b/changelog.d/10176-regex-no-work-cap.md new file mode 100644 index 0000000000..3c10963c86 --- /dev/null +++ b/changelog.d/10176-regex-no-work-cap.md @@ -0,0 +1,3 @@ +### Fixed + +- **RegExp operations are no longer capped by work** (#10164). Valid programs that Node completes threw `RangeError: Regular expression work limit exceeded`: a 32,000-unit non-ASCII `split`, a 60,000-unit global `replace`, and linear splits and replaces of 11–15 million units. No finite allowance separates valid programs from pathological ones, so a catastrophic pattern now runs as long as it does in Node. Matching still yields to the collector and to cancellation, and the memory limits are unchanged.