Skip to content

Commit 107d40a

Browse files
author
Ralph Küpper
committed
perf(regex): preserve live literal programs on eviction
Replace whole-map overflow clears with one-entry eviction, and keep content-cache entries pinned while a recorded literal site refers to them. Only dynamic or displaced-site entries can leave the bounded table. Add a sabotage test that crosses both cache bounds, collects dead nursery headers, and proves the recorded literal does not rebuild.
1 parent 1158dff commit 107d40a

8 files changed

Lines changed: 349 additions & 130 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Keep compiled programs for recorded regular-expression literal sites across bounded cache eviction, and replace whole-cache overflow clears with one-entry eviction.

crates/perry-runtime/src/hot_diag.rs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,9 @@ pub struct RegexDiag {
126126
pub compiles_std: u64,
127127
pub compiles_fancy: u64,
128128
pub compiles_repeat: u64,
129+
/// One-entry evictions after a regex cache reaches its bound. The former
130+
/// wholesale-clear counter remains as a zeroed regression control.
131+
pub cache_evictions: u64,
129132
pub cache_clears: u64,
130133
/// `lazy::build_and_install_programs` runs (one per header that is
131134
/// executed at least once).
@@ -190,6 +193,10 @@ pub struct RegexDiag {
190193
/// CONTENT-keyed cache; a site hit never reaches it, so the two are
191194
/// disjoint and `site_key_hit + site_hit <= new`.
192195
pub new_site_key_hit: u64,
196+
#[cfg(test)]
197+
test_program_builds: u64,
198+
#[cfg(test)]
199+
test_cache_evictions: u64,
193200
per_pattern: HashMap<usize, PatStat>,
194201
}
195202

@@ -252,6 +259,33 @@ pub fn regex_with(f: impl FnOnce(&mut RegexDiag)) {
252259
});
253260
}
254261

262+
#[cfg(test)]
263+
pub(crate) fn test_reset_regex_builds_and_evictions() {
264+
REGEX_DIAG.with(|diag| {
265+
let mut diag = diag.borrow_mut();
266+
diag.test_program_builds = 0;
267+
diag.test_cache_evictions = 0;
268+
});
269+
}
270+
271+
#[cfg(test)]
272+
pub(crate) fn test_note_regex_program_build() {
273+
REGEX_DIAG.with(|diag| diag.borrow_mut().test_program_builds += 1);
274+
}
275+
276+
#[cfg(test)]
277+
pub(crate) fn test_note_regex_cache_eviction() {
278+
REGEX_DIAG.with(|diag| diag.borrow_mut().test_cache_evictions += 1);
279+
}
280+
281+
#[cfg(test)]
282+
pub(crate) fn test_regex_builds_and_evictions() -> (u64, u64) {
283+
REGEX_DIAG.with(|diag| {
284+
let diag = diag.borrow();
285+
(diag.test_program_builds, diag.test_cache_evictions)
286+
})
287+
}
288+
255289
impl RegexDiag {
256290
fn pat(&mut self, pattern_addr: usize, pattern: &[u8], flags: &str) -> &mut PatStat {
257291
let entry = self.per_pattern.entry(pattern_addr).or_default();
@@ -331,7 +365,7 @@ impl RegexDiag {
331365
let _ = writeln!(
332366
out,
333367
"[regex-diag] t={secs:.1}s new={} validated_hit={} site_hit={} pattern_bytes={} \
334-
compiles std={} fancy={} repeat={} cache_clears={} lazy_builds={} lazy_cache_hits={} \
368+
compiles std={} fancy={} repeat={} cache_clears={} evictions={} lazy_builds={} lazy_cache_hits={} \
335369
exec={} exec_matched={} capture_slots={} capture_bytes={} test={} test_global={} \
336370
match={} replace={} replace_matches={} split={} flags_alloc={} \
337371
desc_regexp_probes={} desc_regexp_meta_negative={} \
@@ -346,6 +380,7 @@ impl RegexDiag {
346380
self.compiles_fancy,
347381
self.compiles_repeat,
348382
self.cache_clears,
383+
self.cache_evictions,
349384
self.lazy_builds,
350385
self.lazy_cache_hits,
351386
self.exec_calls,

crates/perry-runtime/src/regex.rs

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -481,9 +481,9 @@ crate::perry_thread_local! {
481481
/// validation. Validity is a pure function of the pair, so the answer is
482482
/// worth remembering; `js_regexp_new` used to get this from a
483483
/// `REGEX_CACHE` hit, which stopped being a proxy once the compiled
484-
/// program became lazy (see `regex::lazy`). Same cap and
485-
/// clear-on-overflow policy as the program caches — the cost of a clear
486-
/// is a repeated parse, never a wrong verdict. The unit value keeps
484+
/// program became lazy (see `regex::lazy`). Same cap and one-entry
485+
/// eviction policy as the program caches — eviction can repeat one parse,
486+
/// never change a verdict. The unit value keeps
487487
/// `evict_regex_cache_if_full` shared with the three program caches.
488488
static VALIDATED_PATTERNS: RefCell<HashMap<(String, String), ()>> = RefCell::new(HashMap::new());
489489
}
@@ -569,26 +569,25 @@ pub(crate) fn build_fancy_regex(pattern: &str) -> Result<fancy_regex::Regex, fan
569569
.build()
570570
}
571571

572-
/// Entry cap for the compiled-regex caches (2026-07-09 GC audit: one entry
573-
/// per distinct `(pattern, flags)` ever compiled, no cap of any kind, entries
574-
/// up to [`REGEX_SIZE_LIMIT`] — `new RegExp(userInput)` was an attacker-driven
575-
/// OOM). When an insert would exceed the cap the whole map is cleared — the
576-
/// `PARSE_KEY_CACHE` precedent: cheap, no LRU bookkeeping, recompilation is
577-
/// the fallback. Live `RegExpHeader`s are unaffected: each header OWNS a raw
578-
/// `Arc` reference to its compiled program(s), released by its GC finalizer,
579-
/// so dropping the cache's references cannot free a program still in use.
572+
/// Entry cap for the content-keyed compiled-regex caches. An insertion at the
573+
/// cap evicts one entry rather than clearing the entire working set. Literal
574+
/// programs remain owned by `site_cache` while their literal site is recorded;
575+
/// only dynamic programs can lose their last cache reference.
580576
#[cfg(feature = "regex-engine")]
581577
const REGEX_CACHE_MAX_ENTRIES: usize = 512;
582578

583-
/// Clear-on-overflow guard shared by the compiled-program caches and the
584-
/// validated-pattern set: make room for one more entry, wiping the map when it
585-
/// is at capacity.
579+
/// Make room for one entry without invalidating the other 511 cached answers.
586580
#[cfg(feature = "regex-engine")]
587-
fn evict_regex_cache_if_full<K, V>(cache: &mut HashMap<K, V>) {
581+
fn evict_regex_cache_if_full<K: Clone + Eq + std::hash::Hash, V>(cache: &mut HashMap<K, V>) {
588582
if cache.len() >= REGEX_CACHE_MAX_ENTRIES {
589-
cache.clear();
583+
let victim = cache.keys().next().cloned();
584+
if let Some(victim) = victim {
585+
cache.remove(&victim);
586+
}
587+
#[cfg(test)]
588+
tests_cache::note_cache_eviction();
590589
if crate::hot_diag::regex_on() {
591-
crate::hot_diag::regex_with(|d| d.cache_clears += 1);
590+
crate::hot_diag::regex_counters(|d| d.cache_evictions += 1);
592591
}
593592
}
594593
}
@@ -1996,4 +1995,6 @@ pub(crate) fn test_last_exec_groups() -> usize {
19961995
#[cfg(all(test, feature = "regex-engine"))]
19971996
mod tests;
19981997
#[cfg(all(test, feature = "regex-engine"))]
1998+
mod tests_cache;
1999+
#[cfg(all(test, feature = "regex-engine"))]
19992000
mod tests_header;

crates/perry-runtime/src/regex/lazy.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,8 @@ fn build_and_install_programs(re: *const RegExpHeader) {
217217
if !is_valid_regex_ptr(re) {
218218
return;
219219
}
220+
#[cfg(test)]
221+
crate::hot_diag::test_note_regex_program_build();
220222
let (pattern, flags) = source_and_flags(re);
221223
if crate::hot_diag::regex_on() {
222224
let cache_hit = super::REGEX_CACHE.with(|cache| {
@@ -248,7 +250,7 @@ fn build_and_install_programs(re: *const RegExpHeader) {
248250
// here becomes the answer for every later construction of the same
249251
// literal. It therefore has to be complete, and the probes above cannot
250252
// guarantee that on their own: the three caches are capped independently
251-
// and each `clear()`s wholesale, while
253+
// and each can evict a different entry, while
252254
// `compile_and_cache_regex_checked` returns early whenever `REGEX_CACHE`
253255
// already holds the pattern — so it never re-runs the fancy or
254256
// repeat-matcher build for a pattern whose `REGEX_CACHE` entry survived a
@@ -306,6 +308,16 @@ fn build_and_install_programs(re: *const RegExpHeader) {
306308
}
307309
}
308310

311+
#[cfg(test)]
312+
pub(super) fn test_reset_program_builds() {
313+
crate::hot_diag::test_reset_regex_builds_and_evictions();
314+
}
315+
316+
#[cfg(test)]
317+
pub(super) fn test_program_builds() -> u64 {
318+
crate::hot_diag::test_regex_builds_and_evictions().0
319+
}
320+
309321
/// The header's standard-engine program, building it on first use.
310322
///
311323
/// Every standard-program borrow in the tree goes through here — the field is

0 commit comments

Comments
 (0)