From e076f77933b37f94403cc5c262c971711ce0c88f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 06:42:41 +0200 Subject: [PATCH] perf(regex): allocate the RegExp header in the nursery, not the malloc arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `js_regexp_new` allocated every `RegExpHeader` with `gc_malloc`. On the claude-code TUI that is 199,873 of 199,926 malloc-tracked GC allocations per 400-character reply — 100.0 % of the malloc arm — at 80 bytes each, 99.2 % of them freed, with the registry swinging 101,929 -> 1,689 across one minor (`PERRY_GC_TRACE`). Each one costs a mimalloc allocation, a `MALLOC_STATE` push, a malloc-registry `PtrHashSet` insert that rehashes as it grows, and at death a sweep visit and a free. `GC_TYPE_REGEXP` has been `ArenaOrMalloc` and movable all along: the move hook rekeys `REGEX_POINTERS` / `REGEX_SOURCE_TABLE` / the expando owner, the layout kind traces `pattern_ptr` / `flags_ptr` / `meta`, and `test_movable_regexp_evacuation_migrates_all_address_owned_state` has exercised the arena arm through a test-only allocator. What blocked production was young death: the copied minor's from-space flip runs no per-object finalize hooks, so a nursery header dying young would leak its `Arc` programs and registry entries. Handled now the way `Map`/`Set`/`Error` handle theirs: * `finalize_dead_copied_minor_from_space_regexps` after a copied minor, * `collect_dead_registered_regexps_post_trace` at sweep entry for the non-copying cycle kinds, * the existing `gc_type_finalize_unmarked_payload` for a tenured header. Deadness reuses the audited `owner_is_dead_copied_minor_from_space` predicate (now exposed per-type), which requires `GC_FLAG_ARENA` set and `MARKED|FORWARDED` clear — so an evacuated header and a malloc'd one are both skipped. Every regex program cache keys on pattern/flags CONTENT, not on the header address, so nothing else needs rekeying. This changes the collection schedule, deliberately: the `MallocCount` trigger loses essentially all of its input while ~16 MB a reply moves into the nursery. Schedule numbers are reported with the change, not assumed. Claude-Session: https://claude.ai/code/session_014UZWia6L37DpA93VLtNK9m --- changelog.d/9840-regexp-header-nursery.md | 35 ++++ crates/perry-runtime/src/gc/copying.rs | 1 + crates/perry-runtime/src/gc/dead_owner.rs | 7 + crates/perry-runtime/src/gc/mod.rs | 1 + crates/perry-runtime/src/gc/oldgen.rs | 7 + .../gc/tests/copying/survival_and_malloc.rs | 45 ++++ crates/perry-runtime/src/regex.rs | 193 +++++++++++++++--- crates/perry-runtime/src/regex/lazy.rs | 4 +- 8 files changed, 266 insertions(+), 27 deletions(-) create mode 100644 changelog.d/9840-regexp-header-nursery.md diff --git a/changelog.d/9840-regexp-header-nursery.md b/changelog.d/9840-regexp-header-nursery.md new file mode 100644 index 0000000000..f556d23f71 --- /dev/null +++ b/changelog.d/9840-regexp-header-nursery.md @@ -0,0 +1,35 @@ +### Performance + +- **A `RegExp` header is allocated in the nursery instead of the malloc arm.** + A JS regex literal evaluates to a fresh `RegExp` every time it is reached, and + `js_regexp_new` allocated each header with `gc_malloc`. On the claude-code TUI + that is, per 400-character reply (`PERRY_GC_TRACE`), **199,873 of 199,926 + malloc-tracked GC allocations — 100.0 %**, 80 bytes each, 99.2 % of them + freed, with the malloc registry swinging **101,929 entries down to 1,689** + across a single minor. Every one of those paid a mimalloc allocation, a push + onto `MALLOC_STATE.objects`, an insert into the malloc-registry `PtrHashSet` + (which rehashes as it grows), and at death a malloc-sweep visit and a free — + old-generation prices for an object that overwhelmingly dies young. + + Nothing required the malloc arm. `GC_TYPE_REGEXP` is already declared + `ArenaOrMalloc` and movable; `GcMoveHookKind::RegExpSideTables` already rekeys + `REGEX_POINTERS`, `REGEX_SOURCE_TABLE` and the expando owner after evacuation, + and `GcLayoutSlotKind::RegExpFields` already traces the header's two string + edges and its `meta` record. What kept production on `gc_malloc` was + finalization: the copying minor's from-space flip runs no per-object finalize + hooks, so a nursery header that died young would leak its `Arc` programs and + its registry entries. That is now handled exactly as `Map`, `Set` and `Error` + handle theirs — `finalize_dead_copied_minor_from_space_regexps` after a copied + minor, `collect_dead_registered_regexps_post_trace` at sweep entry for the + non-copying cycle kinds, and the ordinary old-generation sweep for a header + that has been promoted. + + Every regex program cache (`REGEX_CACHE`, `FANCY_CACHE`, + `REPEAT_MATCHER_CACHE`, `VALIDATED_PATTERNS`, the site cache) keys on pattern + and flags CONTENT, not on the header address, so a moving header costs them + nothing. + + Note that this **changes the collection schedule** rather than only removing + work: the `MallocCount` trigger loses essentially all of its input on this + workload, while ~16 MB per reply moves into the nursery. The schedule is + reported with the change rather than assumed unchanged. diff --git a/crates/perry-runtime/src/gc/copying.rs b/crates/perry-runtime/src/gc/copying.rs index d42daabea7..5012f5e1e8 100644 --- a/crates/perry-runtime/src/gc/copying.rs +++ b/crates/perry-runtime/src/gc/copying.rs @@ -1908,6 +1908,7 @@ fn finalize_dead_copied_minor_from_space_side_allocations() { crate::map::finalize_dead_copied_minor_from_space_maps(); crate::set::finalize_dead_copied_minor_from_space_sets(); crate::node_submodules::diagnostics_gc::finalize_dead_copied_minor_from_space_errors(); + crate::regex::finalize_dead_copied_minor_from_space_regexps(); // 2026-07-09 GC audit wave 2: the from-space flip runs no per-object // finalize hooks, so entries keyed by dead from-space owners in the // object-address-keyed side tables are pruned here (headers still intact). diff --git a/crates/perry-runtime/src/gc/dead_owner.rs b/crates/perry-runtime/src/gc/dead_owner.rs index 563e404c93..33e2884256 100644 --- a/crates/perry-runtime/src/gc/dead_owner.rs +++ b/crates/perry-runtime/src/gc/dead_owner.rs @@ -170,6 +170,13 @@ impl PostTraceProbe { /// from-space (eden or the active survivor half) and was neither marked nor /// forwarded — every live from-space object was evacuated (FORWARDED) or is /// pinned-and-marked by this point. Mirrors `is_dead_copied_minor_from_space_map`. +/// Crate-visible form for the per-type registry walkers that finalize their +/// own dead from-space instances after a copied minor (`regex`): is `addr` a +/// from-space `obj_type` cell that was neither evacuated nor pinned? +pub(crate) fn owner_is_dead_copied_minor_from_space_of_type(addr: usize, obj_type: u8) -> bool { + owner_is_dead_copied_minor_from_space(addr, Some(obj_type)) +} + fn owner_is_dead_copied_minor_from_space(addr: usize, expected_obj_type: Option) -> bool { let space = crate::arena::classify_heap_space(addr); if !matches!(space, crate::arena::HeapSpace::NurseryEden) diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index 844be2e5e9..050d46b8dd 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -185,6 +185,7 @@ pub(crate) use copying_pointer_set::CopyingPointerSet; #[cfg(test)] pub(crate) use copying::MAX_YOUNG_MOVE_BYTES; mod dead_owner; +pub(crate) use dead_owner::owner_is_dead_copied_minor_from_space_of_type; mod old_free; use old_free::*; pub(crate) use old_free::{old_free_bytes, old_free_filter_range, old_free_take_exact}; diff --git a/crates/perry-runtime/src/gc/oldgen.rs b/crates/perry-runtime/src/gc/oldgen.rs index 2a331eaa89..0c0ef47b5d 100644 --- a/crates/perry-runtime/src/gc/oldgen.rs +++ b/crates/perry-runtime/src/gc/oldgen.rs @@ -1154,6 +1154,7 @@ pub(super) struct IncrementalSweepState { subphase: SweepCycleSubphase, dead_maps: Vec, dead_sets: Vec, + dead_regexps: Vec, dead_buffers: Vec, dead_typed_arrays: Vec, dead_lazy_arrays: Vec, @@ -1177,6 +1178,7 @@ impl IncrementalSweepState { subphase: SweepCycleSubphase::Malloc, dead_maps: Vec::new(), dead_sets: Vec::new(), + dead_regexps: Vec::new(), dead_buffers: Vec::new(), dead_typed_arrays: Vec::new(), dead_lazy_arrays: Vec::new(), @@ -1215,6 +1217,7 @@ impl IncrementalSweepState { ); self.dead_maps = crate::map::collect_dead_registered_maps_post_trace(full_trace); self.dead_sets = crate::set::collect_dead_registered_sets_post_trace(full_trace); + self.dead_regexps = crate::regex::collect_dead_registered_regexps_post_trace(full_trace); self.dead_buffers = crate::buffer::collect_dead_registered_buffers_post_trace(full_trace); self.dead_typed_arrays = crate::typedarray::collect_dead_registered_typed_arrays_post_trace(full_trace); @@ -1227,6 +1230,7 @@ impl IncrementalSweepState { }); if !self.dead_maps.is_empty() || !self.dead_sets.is_empty() + || !self.dead_regexps.is_empty() || !self.dead_buffers.is_empty() || !self.dead_typed_arrays.is_empty() || !self.dead_lazy_arrays.is_empty() @@ -1245,6 +1249,8 @@ impl IncrementalSweepState { crate::map::finalize_collected_dead_map(addr); } else if let Some(addr) = self.dead_sets.pop() { crate::set::finalize_collected_dead_set(addr); + } else if let Some(addr) = self.dead_regexps.pop() { + crate::regex::finalize_collected_dead_regexp(addr); } else if let Some(addr) = self.dead_buffers.pop() { crate::buffer::finalize_collected_dead_buffer(addr); } else if let Some(addr) = self.dead_typed_arrays.pop() { @@ -1259,6 +1265,7 @@ impl IncrementalSweepState { } if self.dead_maps.is_empty() && self.dead_sets.is_empty() + && self.dead_regexps.is_empty() && self.dead_buffers.is_empty() && self.dead_typed_arrays.is_empty() && self.dead_lazy_arrays.is_empty() diff --git a/crates/perry-runtime/src/gc/tests/copying/survival_and_malloc.rs b/crates/perry-runtime/src/gc/tests/copying/survival_and_malloc.rs index 83130cf246..eb234b3cf7 100644 --- a/crates/perry-runtime/src/gc/tests/copying/survival_and_malloc.rs +++ b/crates/perry-runtime/src/gc/tests/copying/survival_and_malloc.rs @@ -891,3 +891,48 @@ fn test_copied_minor_promotable_census_filtered_walk_matches_unfiltered() { the equivalence assert above must not be vacuously 0 == 0" ); } + +/// #9819 follow-up: `js_regexp_new` allocates the header in the NURSERY. A +/// header that dies young must be finalized by the copied minor — its `Arc` +/// program released and its registry entries removed — because the from-space +/// flip runs no per-object finalize hooks. Without +/// `finalize_dead_copied_minor_from_space_regexps` the dead address stays in +/// `REGEX_POINTERS` and the program's strong count never comes back down. +#[test] +fn nursery_regexp_that_dies_young_is_finalized_by_the_copied_minor() { + let _guard = CopyingNurseryTestGuard::new(1); + let dead = crate::regex::test_construct_regexp_and_exec_once("b(?:c)+d-die-young", "g"); + let live = crate::regex::test_construct_regexp_and_exec_once("b(?:c)+d-die-young", "g"); + let dead_addr = dead as usize; + let live_addr = live as usize; + // Premise: production construction is nursery-allocated now. + assert!(crate::arena::pointer_in_nursery(dead_addr), "the header must be nursery-allocated"); + assert!(crate::regex::test_regex_pointer_entry_exists(dead_addr)); + assert!(crate::regex::test_regex_source_entry_exists(dead_addr)); + // Both headers share one program through the site cache. + let count_before = crate::regex::test_regexp_std_program_strong_count(live); + assert!(count_before >= 2); + + // Only `live` is rooted; `dead` is garbage. + js_shadow_slot_set(0, ptr_bits(live_addr)); + let _ = gc_collect_minor(); + + let live_new = (js_shadow_slot_get(0) & POINTER_MASK) as usize; + assert_ne!(live_new, 0, "the rooted RegExp must survive"); + assert_ne!(live_new, live_addr, "the rooted RegExp must be evacuated"); + assert!(crate::regex::regex_header_has_magic(live_new as *const _)); + assert!(crate::regex::test_regex_pointer_entry_exists(live_new)); + assert!(crate::regex::test_regex_source_entry_exists(live_new)); + + assert!( + !crate::regex::test_regex_pointer_entry_exists(dead_addr), + "a nursery RegExp that died must be removed from REGEX_POINTERS by the copied minor" + ); + assert!(!crate::regex::test_regex_source_entry_exists(dead_addr)); + assert_eq!( + crate::regex::test_regexp_std_program_strong_count(live_new as *const _), + count_before - 1, + "the dead header's Arc clone of the shared program must have been dropped" + ); + js_shadow_slot_set(0, 0); +} diff --git a/crates/perry-runtime/src/regex.rs b/crates/perry-runtime/src/regex.rs index 683af8750a..fd561495e6 100644 --- a/crates/perry-runtime/src/regex.rs +++ b/crates/perry-runtime/src/regex.rs @@ -262,6 +262,115 @@ pub(crate) unsafe fn regex_header_finalize_for_gc(re: *mut RegExpHeader) { regex_header_clear_dead_for_gc(re as usize); } +/// Finalize the RegExp headers that died in from-space during a copied minor. +/// +/// The copying minor's from-space flip runs no per-object finalize hooks, so +/// a nursery header that was neither evacuated nor pinned would otherwise keep +/// its `Arc` programs and its `REGEX_POINTERS` / `REGEX_SOURCE_TABLE` / expando +/// entries forever. Same shape as `map::finalize_dead_copied_minor_from_space_maps`: +/// walk the registry after the flip, collect the provably-dead addresses, then +/// finalize each (the finalizer removes its own registry entries, which is why +/// the walk and the removal are two passes). +/// +/// Cost: O(registry) = O(live headers + headers allocated since the last +/// minor) — the same order as the malloc sweep this replaces, and +/// proportional to allocation, not to program history. +pub(crate) fn finalize_dead_copied_minor_from_space_regexps() -> usize { + let dead: Vec = REGEX_POINTERS.with(|table| { + table + .borrow() + .iter() + .copied() + .filter(|&addr| crate::gc::owner_is_dead_copied_minor_from_space_of_type(addr, crate::gc::GC_TYPE_REGEXP)) + .collect() + }); + let count = dead.len(); + for addr in dead { + unsafe { regex_header_finalize_for_gc(addr as *mut RegExpHeader) }; + } + count +} + +/// Sweep-entry twin of the above for the non-copying cycle kinds (fallback +/// minor / full mark-sweep): a dead header in the ACTIVE nursery allocation +/// block is never object-walked by any sweeper, so it is collected from the +/// registry right after trace instead (#6010, mirroring Map/Set/Buffer). +/// Deadness: unmarked ∧ not pinned ∧ not forwarded, and for a minor trace also +/// not tenured and physically in the nursery. +pub(crate) fn collect_dead_registered_regexps_post_trace(full_trace: bool) -> Vec { + REGEX_POINTERS.with(|table| { + table + .borrow() + .iter() + .copied() + .filter(|&addr| unsafe { registered_regexp_is_dead_post_trace(addr, full_trace) }) + .collect() + }) +} + +/// Finalize one collected-dead RegExp (budget-chunked by the sweep state). +pub(crate) fn finalize_collected_dead_regexp(addr: usize) { + unsafe { regex_header_finalize_for_gc(addr as *mut RegExpHeader) }; +} + +unsafe fn registered_regexp_is_dead_post_trace(addr: usize, full_trace: bool) -> bool { + let Some(header) = crate::value::addr_class::try_read_gc_header(addr) else { + return false; + }; + if header.obj_type != crate::gc::GC_TYPE_REGEXP { + return false; + } + let flags = header.gc_flags; + if flags + & (crate::gc::GC_FLAG_MARKED | crate::gc::GC_FLAG_PINNED | crate::gc::GC_FLAG_FORWARDED) + != 0 + { + return false; + } + if full_trace { + return true; + } + if flags & crate::gc::GC_FLAG_TENURED != 0 { + return false; + } + matches!( + crate::arena::classify_heap_generation(addr), + crate::arena::HeapGeneration::Nursery + ) +} + +/// Test support: construct a RegExp through the PRODUCTION path +/// (`js_regexp_new`), run one `test()` so the compiled programs are installed +/// on the header, and hand the header back unrooted. +#[cfg(all(test, feature = "regex-engine"))] +pub(crate) fn test_construct_regexp_and_exec_once(pattern: &str, flags: &str) -> *mut RegExpHeader { + let scope = crate::gc::RuntimeHandleScope::new(); + let p = scope.root_string_ptr(js_string_from_str(pattern)); + let f = scope.root_string_ptr(js_string_from_str(flags)); + let re = p.with_mut_ptr::(|p| { + f.with_mut_ptr::(|f| js_regexp_new(p, f)) + }); + let subject = scope.root_string_ptr(js_string_from_str("abc")); + subject.with_const_ptr::(|s| { + let _ = js_regexp_test(re, s); + }); + re +} + +/// Test support: strong count of the standard program a header holds (the +/// observer clone taken here is released before returning). +#[cfg(all(test, feature = "regex-engine"))] +pub(crate) fn test_regexp_std_program_strong_count(re: *const RegExpHeader) -> usize { + unsafe { + let raw = (*re).regex_ptr as *const Regex; + assert!(!raw.is_null(), "program must be installed"); + let arc = Arc::from_raw(raw); + let n = Arc::strong_count(&arc); + std::mem::forget(arc); + n + } +} + #[cfg(test)] pub(crate) fn test_regex_pointer_entry_exists(addr: usize) -> bool { REGEX_POINTERS.with(|table| table.borrow().contains(&addr)) @@ -846,7 +955,7 @@ pub extern "C" fn js_regexp_new( ) -> *mut RegExpHeader { // ★ `pattern` is a raw `StringHeader*` in a Rust local, and this function // allocates twice below (`js_string_from_str` for the canonical flags, then - // `gc_malloc` for the header). Either can drive an evacuating minor that + // `arena_alloc_gc` for the header). Either can drive an evacuating minor that // relocates the pattern string, after which this argument names retired // from-space — and it is then *stored into the header* as `pattern_ptr`, // so the damage is permanent rather than transient. @@ -1055,10 +1164,35 @@ pub extern "C" fn js_regexp_new( #[allow(unused_variables)] let pattern_str: () = (); - // Allocate the header via gc_malloc so it's tracked by the GC and gets - // freed when no longer referenced. Previously this used raw alloc() and - // leaked every header, which was a 64-byte-per-call leak on top of the - // (now-fixed) regex object leak. + // ★ The header is NURSERY-allocated, like an ordinary object. + // + // It used to be `gc_malloc`'d: raw `alloc()` at first (a 64-byte leak per + // construction), then the tracked malloc arm so the sweep could free it. + // That arm costs, PER CONSTRUCTION, a mimalloc allocation, a push onto + // `MALLOC_STATE.objects`, an insert into the malloc-registry `PtrHashSet` + // (which rehashes as it grows), two old→young remembered-set entries for + // `pattern_ptr`/`flags_ptr`, and — at death — a malloc-sweep visit, the + // finalizer and a free. A JS regex literal constructs a fresh object every + // time it is evaluated, so on the claude-code TUI `PERRY_GC_TRACE` counted + // **199,873 RegExp headers malloc'd per 400-character reply — 100.0 % of + // all malloc allocations** — with the registry swinging 26,690 → 1,689 + // across one minor: ~94 % of them die young and were paying + // old-generation prices to do it. + // + // `GC_TYPE_REGEXP` has been movable (`GcMoveHookKind::RegExpSideTables` + // rekeys `REGEX_POINTERS`, `REGEX_SOURCE_TABLE` and the expando owner + // after evacuation; `GcLayoutSlotKind::RegExpFields` traces the two string + // edges and `meta`) since the copying collector landed, and + // `test_movable_regexp_evacuation_migrates_all_address_owned_state` has + // exercised the arena arm all along. What kept production on malloc was + // finalization: the copying minor's from-space flip runs no per-object + // finalize hooks (`gc::copying`), so a nursery header that dies young + // would leak its three `Arc` programs and its registry entries. That is + // now handled the way Map/Set/Error handle theirs — + // `finalize_dead_copied_minor_from_space_regexps` after a copied minor and + // `collect_dead_registered_regexps_post_trace` at sweep entry for the + // non-copying cycle kinds — and a tenured header is finalized by the + // old-generation sweep's ordinary `gc_type_finalize_unmarked_payload`. let header_size = std::mem::size_of::(); // `flags_ptr` must hold the CANONICAL form, so that `flags_ptr`-keyed // lookups (FANCY_CACHE, lookup_fancy_regex) and the GC-survivable source @@ -1075,12 +1209,12 @@ pub extern "C" fn js_regexp_new( scope.root_string_ptr(js_string_from_str(flags_str)) } }; - // ★ #7341: root the canonical flags string too. The `gc_malloc` below is an - // allocation and therefore a collection point, exactly as the comment above - // `pattern_root` says — but only the PATTERN was rooted and re-read. The - // flags string is created here and stored into the header AFTER that - // allocation, so an evacuating minor in `gc_malloc` moved it and the header - // kept the pre-collection address. `flags_ptr` is then permanently stale in + // ★ #7341: root the canonical flags string too. The header allocation below + // is an allocation and therefore a collection point, exactly as the comment + // above `pattern_root` says — but only the PATTERN was rooted and re-read. + // The flags string is created here and stored into the header AFTER that + // allocation, so an evacuating minor in the header allocation moved it and + // the header kept the pre-collection address. `flags_ptr` is then permanently stale in // a live header: `lookup_fancy_regex` reads it through `string_as_str` and // faults on retired from-space, which is 5 of the 31 catches in #7341 // (four different callers, all reaching that one read). @@ -1089,7 +1223,11 @@ pub extern "C" fn js_regexp_new( // missing is that the value written had to survive the allocation first. unsafe { - let raw = crate::gc::gc_malloc(header_size, crate::gc::GC_TYPE_REGEXP); + let raw = crate::arena::arena_alloc_gc( + header_size, + std::mem::align_of::(), + crate::gc::GC_TYPE_REGEXP, + ); if raw.is_null() { // #5067 — catchable RangeError instead of aborting on OOM. crate::error::throw_allocation_failed(); @@ -1114,19 +1252,24 @@ pub extern "C" fn js_regexp_new( (*ptr).pattern_ptr = pattern; (*ptr).flags_ptr = canonical_flags_ptr; // `pattern_ptr` / `flags_ptr` are GC-managed StringHeaders — the GC scans - // this 2-slot payload range via the magic-tagged RegExp layout, and - // `canonical_flags_ptr` (js_string_from_str above) is a freshly-allocated - // YOUNG string. They are stored into this malloc'd (old-generation) header - // by raw writes; without a write barrier the old→young edge is never - // remembered, so a copying minor GC sweeps the string while the retained - // RegExp still points at it. The evacuation verifier reports this as an - // uncovered object→string edge, and it crashes for real when the freed - // slot is later scanned/read (a heavy regex workload — e.g. ANSI/emoji - // parsing in a terminal UI — hits it within seconds). Remember both edges, - // mirroring every other native-header pointer store (closure captures, - // object prototype slots, array headers). `runtime_write_barrier_gc_slot` - // detects the malloc parent and only remembers genuinely-young children, - // so an already-old/interned `pattern` is a harmless no-op. + // this 2-slot payload range via the magic-tagged RegExp layout. + // + // The header is young now, so for a young child the barrier records + // nothing; it still has to run, because a header born while a budgeted + // cycle is marking is allocated black, and because a header that has + // been promoted and then reassigned (`RegExp.prototype.compile`) is a + // genuine old→young store. Historically the header was malloc'd, i.e. + // old, and this store was THE old→young edge a copying minor would + // otherwise miss: the evacuation verifier reported it as an uncovered + // object→string edge, and it crashed for real when the freed slot was + // later scanned/read (a heavy regex workload — e.g. ANSI/emoji parsing + // in a terminal UI — hit it within seconds). + // + // Remember both edges, mirroring every other native-header pointer + // store (closure captures, object prototype slots, array headers). + // `runtime_write_barrier_gc_slot` classifies the parent and only + // remembers genuinely-young children, so an already-old/interned + // `pattern` is a harmless no-op. let regexp_parent_addr = ptr as usize; if !pattern.is_null() { crate::gc::runtime_write_barrier_gc_slot( diff --git a/crates/perry-runtime/src/regex/lazy.rs b/crates/perry-runtime/src/regex/lazy.rs index a982a34875..f5492c317f 100644 --- a/crates/perry-runtime/src/regex/lazy.rs +++ b/crates/perry-runtime/src/regex/lazy.rs @@ -38,8 +38,8 @@ //! engines refuse); //! * `.source` / `.flags` / `.global` / `.sticky` / `lastIndex` are header //! and side-table reads that never touched the compiled program; -//! * identity is untouched — `js_regexp_new` still `gc_malloc`s a fresh -//! header per evaluation. +//! * identity is untouched — `js_regexp_new` still allocates a fresh header +//! per evaluation. //! //! The build itself happens on the first operation that needs a matcher, //! through [`ensure_regex_compiled`], and installs exactly the pointers