Skip to content

Commit 2a7aaff

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. When both content ways are pinned, let the bounded site entry own the program bundle until that site is displaced. Add a sabotage test that crosses the 512-entry boundary, collects dead nursery headers, and proves the recorded literal does not rebuild.
1 parent 1158dff commit 2a7aaff

7 files changed

Lines changed: 350 additions & 56 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

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

Lines changed: 51 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,29 @@ fn entry_matches(entry: &Entry, fp: u64, pattern: &str, flags: &str) -> bool {
126126
entry.fp == fp && &*entry.flags == flags && &*entry.pattern == pattern
127127
}
128128

129+
/// Pick one of this fingerprint's two ways without displacing a recorded
130+
/// literal site. If both are literal-owned, the caller leaves the new dynamic
131+
/// entry uncached; a new literal pins its bundle in the bounded site table.
132+
fn replacement_slot(cache: &[Option<Entry>], slot: usize, fp: u64) -> Option<usize> {
133+
if cache[slot].is_none() {
134+
return Some(slot);
135+
}
136+
if cache[slot ^ 1].is_none() {
137+
return Some(slot ^ 1);
138+
}
139+
let preferred = slot ^ ((fp >> 11) as usize & 1);
140+
[preferred, preferred ^ 1].into_iter().find(|&candidate| {
141+
let entry = cache[candidate].as_ref().expect("both ways are occupied");
142+
!super::site_key::references_content(&entry.pattern, &entry.flags)
143+
})
144+
}
145+
146+
fn note_eviction() {
147+
if crate::hot_diag::regex_on() {
148+
crate::hot_diag::regex_counters(|d| d.cache_evictions += 1);
149+
}
150+
}
151+
129152
/// Find the verified entry for `(pattern, canonical flags)`.
130153
pub(super) fn lookup(pattern: &str, flags: &str) -> Option<Hit> {
131154
if !enabled() {
@@ -184,21 +207,19 @@ pub(super) fn insert(pattern: &str, flags: &str) -> (Arc<str>, Arc<str>) {
184207
}
185208
}
186209
}
187-
let victim = if cache[slot].is_none() {
188-
slot
189-
} else if cache[slot ^ 1].is_none() {
190-
slot ^ 1
191-
} else {
192-
slot ^ ((fp >> 11) as usize & 1)
193-
};
194210
let pattern: Arc<str> = Arc::from(pattern);
195211
let flags: Arc<str> = Arc::from(flags);
196-
cache[victim] = Some(Entry {
197-
fp,
198-
pattern: pattern.clone(),
199-
flags: flags.clone(),
200-
programs: None,
201-
});
212+
if let Some(victim) = replacement_slot(&cache, slot, fp) {
213+
if cache[victim].is_some() {
214+
note_eviction();
215+
}
216+
cache[victim] = Some(Entry {
217+
fp,
218+
pattern: pattern.clone(),
219+
flags: flags.clone(),
220+
programs: None,
221+
});
222+
}
202223
(pattern, flags)
203224
})
204225
}
@@ -212,7 +233,7 @@ pub(super) fn install_programs(pattern: &str, flags: &str, programs: Arc<Program
212233
}
213234
let fp = fingerprint(pattern.as_bytes(), flags.as_bytes());
214235
let slot = slot_of(fp);
215-
SITE_CACHE.with(|cache| {
236+
let content_owned = SITE_CACHE.with(|cache| {
216237
let mut cache = cache.borrow_mut();
217238
if cache.is_empty() {
218239
cache.resize_with(SLOTS, || None);
@@ -221,26 +242,27 @@ pub(super) fn install_programs(pattern: &str, flags: &str, programs: Arc<Program
221242
if let Some(entry) = &mut cache[s] {
222243
if entry_matches(entry, fp, pattern, flags) {
223244
if entry.programs.is_none() {
224-
entry.programs = Some(programs);
245+
entry.programs = Some(programs.clone());
225246
}
226-
return;
247+
return true;
227248
}
228249
}
229250
}
230-
let victim = if cache[slot].is_none() {
231-
slot
232-
} else if cache[slot ^ 1].is_none() {
233-
slot ^ 1
234-
} else {
235-
slot ^ ((fp >> 11) as usize & 1)
251+
let Some(victim) = replacement_slot(&cache, slot, fp) else {
252+
return false;
236253
};
254+
if cache[victim].is_some() {
255+
note_eviction();
256+
}
237257
cache[victim] = Some(Entry {
238258
fp,
239259
pattern: Arc::from(pattern),
240260
flags: Arc::from(flags),
241-
programs: Some(programs),
261+
programs: Some(programs.clone()),
242262
});
263+
true
243264
});
265+
super::site_key::install_programs_for_content(pattern, flags, &programs, content_owned);
244266
}
245267

246268
#[cfg(test)]
@@ -267,3 +289,9 @@ pub(super) fn test_has_programs(pattern: &str, flags: &str) -> Option<bool> {
267289
None
268290
})
269291
}
292+
293+
#[cfg(test)]
294+
pub(super) fn test_slot_and_victim_way(pattern: &str, flags: &str) -> (usize, usize) {
295+
let fp = fingerprint(pattern.as_bytes(), flags.as_bytes());
296+
(slot_of(fp), (fp >> 11) as usize & 1)
297+
}

0 commit comments

Comments
 (0)