From 40db9fbdd4df05508e9a43b434d96623362a17c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 10:26:44 +0000 Subject: [PATCH 1/2] perf(runtime): delete REGEX_SOURCE_TABLE; identify RegExps by header `REGEX_SOURCE_TABLE` was an address-keyed thread-local map whose only payload was `registered_owner: bool`. Every construction inserted into it, every copying minor rekeyed it, and every collection walked it twice (the copied-minor from-space finalizer and the sweep-entry dead-regexp subphase) only to clear dead RegExps' expandos, which the dead-owner fan-out (`prune_dead_exotic_expando_owners`) already did in the same windows. - `is_regex_pointer` / `is_valid_regex_ptr` / `is_registered_regex` answer from the header (GC_TYPE_REGEXP + size + REGEXP_MAGIC), which they already checked first. Every `registered_owner` reader was a membership check, so no other semantics are lost. - GC_TYPE_REGEXP uses the shared ExoticExpandoOwner move hook and no finalize hook. The RegExpSideTables hook kinds, the copied-minor regex finalizer, the sweep's dead_regexps list, the REGEX_EVER_REGISTERED latch, and the now-unused prefetch_gc_owner_headers / exotic_expando_owner_clear_dead helpers are removed. - Gates: drop the REGEX_SOURCE_TABLE entry from gc_runtime_root_holders.json; shape_descriptor_census.py now pins RegExp to ExoticExpandoOwner + GcFinalizeHookKind::None. Tests cover header-only identity, the hook wiring, expando pruning for a dead RegExp on a full GC and on a copying minor (asserting the minor ran), and expando migration for a live RegExp that moves. Closes #11503 --- crates/perry-runtime/src/gc/copying_phase.rs | 10 +- crates/perry-runtime/src/gc/dead_owner.rs | 4 +- crates/perry-runtime/src/gc/oldgen.rs | 7 - crates/perry-runtime/src/gc/prefetch.rs | 29 --- .../gc/tests/copying/survival_and_malloc.rs | 78 +++++- .../src/gc/tests/dead_owner_side_tables.rs | 2 + .../dead_owner_side_tables/regexp_expandos.rs | 82 ++++++ .../gc/tests/runtime_roots/perex_lifecycle.rs | 2 +- .../perry-runtime/src/gc/trace/block_skip.rs | 4 +- crates/perry-runtime/src/gc/types.rs | 27 +- crates/perry-runtime/src/hot_diag.rs | 13 +- crates/perry-runtime/src/json_tape_store.rs | 8 +- .../src/object/exotic_expando.rs | 19 +- crates/perry-runtime/src/regex.rs | 236 ++---------------- .../src/regex/perex_construct.rs | 11 +- crates/perry-runtime/src/regex/tests.rs | 45 ++-- scripts/gc_runtime_root_holders.json | 7 - scripts/shape_descriptor_census.py | 12 +- 18 files changed, 231 insertions(+), 365 deletions(-) create mode 100644 crates/perry-runtime/src/gc/tests/dead_owner_side_tables/regexp_expandos.rs diff --git a/crates/perry-runtime/src/gc/copying_phase.rs b/crates/perry-runtime/src/gc/copying_phase.rs index 9bfbc6b5e2..8ca5090580 100644 --- a/crates/perry-runtime/src/gc/copying_phase.rs +++ b/crates/perry-runtime/src/gc/copying_phase.rs @@ -99,7 +99,7 @@ impl CopyingMinorPhaseDiag { let mut out = String::new(); write!( out, - "root_scan={}/{} copy_evacuation={}/{}/{} remembered_set_young_logs={}/{}/{} promotion={}/{}/{} dead_owner_side_table_pruning={}{} from_space_finalization={}/map:{}/{}+set:{}/{}+errors:{}/{}+regex:{}/{}+lazytape:{}/{} forwarding_fixups={} block_reset_flip={} other={} phase_sum_us={}", + "root_scan={}/{} copy_evacuation={}/{}/{} remembered_set_young_logs={}/{}/{} promotion={}/{}/{} dead_owner_side_table_pruning={}{} from_space_finalization={}/map:{}/{}+set:{}/{}+errors:{}/{}+lazytape:{}/{} forwarding_fixups={} block_reset_flip={} other={} phase_sum_us={}", self.root_scan_ns / 1000, scan_us, self.copy_evacuation_ns / 1000, @@ -120,8 +120,6 @@ impl CopyingMinorPhaseDiag { finalization.sets, finalization.errors_ns / 1000, finalization.errors, - finalization.regex_ns / 1000, - finalization.regexps, finalization.lazy_tape_ns / 1000, finalization.lazy_tapes, self.forwarding_fixups_ns / 1000, @@ -143,8 +141,6 @@ pub(super) struct CopiedMinorFinalizationDiag { pub(super) sets: usize, pub(super) errors_ns: u64, pub(super) errors: usize, - pub(super) regex_ns: u64, - pub(super) regexps: usize, pub(super) dead_owner_ns: u64, pub(super) dead_owner_detail: String, pub(super) lazy_tapes: usize, @@ -175,10 +171,6 @@ pub(super) fn finalize_dead_copied_minor_from_space_side_allocations() -> Copied crate::node_submodules::diagnostics_gc::finalize_dead_copied_minor_from_space_errors(); out.errors_ns = start.map_or(0, |start| start.elapsed().as_nanos() as u64); - let start = diag.then(Instant::now); - out.regexps = crate::regex::finalize_dead_copied_minor_from_space_regexps(); - out.regex_ns = start.map_or(0, |start| start.elapsed().as_nanos() as u64); - let start = diag.then(Instant::now); out.lazy_tapes = crate::json_tape_store::finalize_dead_copied_minor_from_space_lazy_tapes(); out.lazy_tape_ns = start.map_or(0, |start| start.elapsed().as_nanos() as u64); diff --git a/crates/perry-runtime/src/gc/dead_owner.rs b/crates/perry-runtime/src/gc/dead_owner.rs index f67239cd8c..d3dc0ad89a 100644 --- a/crates/perry-runtime/src/gc/dead_owner.rs +++ b/crates/perry-runtime/src/gc/dead_owner.rs @@ -170,8 +170,8 @@ impl PostTraceProbe { /// 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? +/// own dead from-space instances after a copied minor (`json_tape_store`): 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)) } diff --git a/crates/perry-runtime/src/gc/oldgen.rs b/crates/perry-runtime/src/gc/oldgen.rs index 339c9b5ced..15f84faf08 100644 --- a/crates/perry-runtime/src/gc/oldgen.rs +++ b/crates/perry-runtime/src/gc/oldgen.rs @@ -1154,7 +1154,6 @@ enum SweepCycleSubphase { pub(super) struct IncrementalSweepState { subphase: SweepCycleSubphase, dead_sets: Vec, - dead_regexps: Vec, dead_buffers: Vec, dead_typed_arrays: Vec, dead_lazy_arrays: Vec, @@ -1177,7 +1176,6 @@ impl IncrementalSweepState { Self { subphase: SweepCycleSubphase::Malloc, dead_sets: Vec::new(), - dead_regexps: Vec::new(), dead_buffers: Vec::new(), dead_typed_arrays: Vec::new(), dead_lazy_arrays: Vec::new(), @@ -1215,7 +1213,6 @@ impl IncrementalSweepState { synchronous_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,7 +1224,6 @@ impl IncrementalSweepState { registered_lazy_array_is_dead_post_trace(addr, full_trace) }); if !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() @@ -1252,8 +1248,6 @@ impl IncrementalSweepState { while spent < budget { 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() { @@ -1267,7 +1261,6 @@ impl IncrementalSweepState { spent += 1; } if 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/prefetch.rs b/crates/perry-runtime/src/gc/prefetch.rs index 6ba0c27f35..eb431c7a05 100644 --- a/crates/perry-runtime/src/gc/prefetch.rs +++ b/crates/perry-runtime/src/gc/prefetch.rs @@ -78,32 +78,3 @@ pub(super) fn prefetch_boxed_child(bits: u64) { prefetch_read(addr.saturating_sub(super::GC_HEADER_SIZE)); } } - -/// Pipeline header reads for an existing stable ownership walk. -/// -/// The cloned iterator only supplies upcoming addresses to the prefetch -/// instruction. The original iterator still yields every owner exactly once, -/// in its original order. No address vector or registry is created here. -/// Callers must keep the iterator's source unchanged until the walk finishes. -/// The lookahead is shared with the collector's existing header walks. -pub(crate) fn prefetch_gc_owner_headers(owners: I) -> impl Iterator -where - I: Iterator + Clone, -{ - #[cfg(any(target_arch = "aarch64", target_arch = "x86_64"))] - { - let mut ahead = owners.clone(); - for addr in ahead.by_ref().take(PREFETCH_DISTANCE) { - prefetch_read(addr.saturating_sub(super::GC_HEADER_SIZE)); - } - owners.inspect(move |_| { - if let Some(addr) = ahead.next() { - prefetch_read(addr.saturating_sub(super::GC_HEADER_SIZE)); - } - }) - } - #[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))] - { - owners - } -} 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 7157340e65..cd3ed537c2 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 @@ -929,7 +929,8 @@ fn test_movable_regexp_evacuation_migrates_all_address_owned_state() { let re = crate::regex::test_alloc_nursery_regexp_for_move("move/source", "gi"); let old_addr = re as usize; assert!(crate::arena::pointer_in_nursery(old_addr)); - assert!(crate::regex::test_regex_pointer_entry_exists(old_addr)); + // Identity is the header: the fixture registers the address nowhere. + assert!(crate::regex::is_registered_regex(old_addr)); crate::object::exotic_expando::test_seed_exotic_expando_entry( old_addr, @@ -938,15 +939,20 @@ fn test_movable_regexp_evacuation_migrates_all_address_owned_state() { ); js_shadow_slot_set(0, ptr_bits(old_addr)); + let cycles = crate::gc::copying_minor_cycles(); let _ = gc_collect_minor(); + assert!( + crate::gc::copying_minor_cycles() > cycles, + "test premise: a copying minor ran" + ); let new_addr = (js_shadow_slot_get(0) & POINTER_MASK) as usize; assert_ne!(new_addr, 0, "rooted RegExp must survive the copied minor"); assert_ne!(new_addr, old_addr, "the RegExp must be evacuated"); assert!(crate::regex::regex_header_has_magic(new_addr as *const _)); + assert!(crate::regex::is_registered_regex(new_addr)); - assert!(crate::regex::test_regex_pointer_entry_exists(new_addr)); - assert!(!crate::regex::test_regex_pointer_entry_exists(old_addr)); + // The expando owner key moves with the header (`ExoticExpandoOwner`). assert!(crate::object::exotic_expando::test_exotic_expando_entry_exists(new_addr)); assert!(!crate::object::exotic_expando::test_exotic_expando_entry_exists(old_addr)); @@ -1023,12 +1029,40 @@ fn test_copied_minor_promotable_census_filtered_walk_matches_unfiltered() { ); } +/// Set a user property on a RegExp through the production `[[Set]]` path, so +/// the entry is the one a program's `re.tag = v` would create. +fn set_regexp_expando(addr: usize, key: &str, value: crate::value::JSValue) { + assert!( + matches!( + crate::object::exotic_expando::exotic_expando_kind(addr), + Some(crate::object::exotic_expando::ExoticKind::RegExp) + ), + "test premise: the header classifies as a RegExp exotic" + ); + let receiver = f64::from_bits(ptr_bits(addr)); + let stored = unsafe { + crate::object::exotic_expando::exotic_set_property( + addr, + crate::object::exotic_expando::ExoticKind::RegExp, + key, + f64::from_bits(value.bits()), + receiver, + ) + }; + assert!(stored, "test premise: the RegExp accepted the expando"); + assert!(crate::object::exotic_expando::test_exotic_expando_entry_exists(addr)); +} + /// #9819 follow-up: `js_regexp_new` allocates the header in the NURSERY. A -/// header that dies young must lose its registry entries and its GC program -/// must be reclaimed by the copied minor — because the from-space -/// flip runs no per-object finalize hooks. Without -/// `finalize_dead_copied_minor_from_space_regexps` the dead address stays in -/// the owner registry and dead programs would remain reachable. +/// header that dies young must lose its address-keyed state and its GC program +/// must be reclaimed by the copied minor — even though the from-space flip runs +/// no per-object finalize hooks. +/// +/// #11503: RegExp has no registry or death hook of its own any more. Its only +/// address-keyed state is the user's expandos, and the dead-owner fan-out +/// (`prune_dead_exotic_expando_owners`) is what must drop a dead header's +/// entry. If it did not, a fresh cell recycled at the dead header's address +/// would read the dead RegExp's properties as its own. #[test] fn nursery_regexp_that_dies_young_is_finalized_by_the_copied_minor() { let _guard = CopyingNurseryTestGuard::new(1); @@ -1041,8 +1075,8 @@ fn nursery_regexp_that_dies_young_is_finalized_by_the_copied_minor() { 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)); + set_regexp_expando(dead_addr, "tag", crate::value::JSValue::int32(7)); + set_regexp_expando(live_addr, "tag", crate::value::JSValue::int32(42)); fn programs() -> usize { let mut cursor = crate::arena::ArenaObjectCursor::new(crate::arena::ArenaWalkOrder::Address); @@ -1069,17 +1103,35 @@ fn nursery_regexp_that_dies_young_is_finalized_by_the_copied_minor() { // Only `live` is rooted; `dead` is garbage. js_shadow_slot_set(0, ptr_bits(live_addr)); + let cycles = crate::gc::copying_minor_cycles(); let _ = gc_collect_minor(); + assert!( + crate::gc::copying_minor_cycles() > cycles, + "test premise: a copying minor ran" + ); 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::is_registered_regex(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" + !crate::object::exotic_expando::test_exotic_expando_entry_exists(dead_addr), + "a nursery RegExp that died must lose its expando entry in the copied minor" + ); + assert!( + crate::object::exotic_expando::test_exotic_expando_entry_exists(live_new), + "the surviving RegExp's expando must follow it to its new address" + ); + assert_eq!( + crate::object::exotic_expando::value_lookup( + crate::object::exotic_expando::ExoticKind::RegExp, + live_new, + "tag", + ), + Some(crate::value::JSValue::int32(42).bits()), + "the surviving RegExp keeps its own value, not the dead one's" ); assert_eq!( programs(), diff --git a/crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs b/crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs index e99922c012..5fc787d149 100644 --- a/crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs +++ b/crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs @@ -649,6 +649,8 @@ fn test_dom_exception_set_cleared_with_error_side_tables() { } mod meta_and_shape_records; +#[cfg(feature = "regex-engine")] +mod regexp_expandos; // ── FUNCTION_CLASS_IDS (#8040) ────────────────────────────────────────────── // diff --git a/crates/perry-runtime/src/gc/tests/dead_owner_side_tables/regexp_expandos.rs b/crates/perry-runtime/src/gc/tests/dead_owner_side_tables/regexp_expandos.rs new file mode 100644 index 0000000000..e499c6d171 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/dead_owner_side_tables/regexp_expandos.rs @@ -0,0 +1,82 @@ +//! #11503: a RegExp's user properties (`re.tag = v`) are its ONLY +//! address-keyed state. `REGEX_SOURCE_TABLE` and the per-type death walks that +//! enumerated it are gone, so the shared dead-owner fan-out +//! (`prune_dead_exotic_expando_owners`) is now the one thing that drops a dead +//! RegExp's expando entry on the non-copying cycle kinds. The copied-minor +//! counterpart is `nursery_regexp_that_dies_young_is_finalized_by_the_copied_minor`. +//! +//! A stale entry is not only a leak: `expando_clear_on_alloc` covers a RegExp +//! or Date recycled at the address, but any other exotic kind born there reads +//! the dead RegExp's properties as its own. + +use super::*; + +/// A production-constructed RegExp, unrooted once the caller's scope ends. +fn construct_regexp(pattern: &str) -> usize { + let scope = RuntimeHandleScope::new(); + let source = scope.root_string_ptr(crate::string::js_string_from_bytes( + pattern.as_ptr(), + pattern.len() as u32, + )); + let flags = scope.root_string_ptr(crate::string::js_string_from_bytes(b"g".as_ptr(), 1)); + let re = source.with_const_ptr(|source| { + flags.with_const_ptr(|flags| crate::regex::js_regexp_new(source, flags)) + }); + assert!( + crate::regex::is_registered_regex(re as usize), + "test premise: the header identifies as a RegExp" + ); + re as usize +} + +#[test] +fn test_dead_regexp_expando_pruned_on_full_gc() { + let _guard = GcTestIsolationGuard::with_realm_bootstrapped(); + let addr = construct_regexp("dies-before-the-full-trace"); + crate::object::exotic_expando::test_seed_exotic_expando_entry( + addr, + "tag", + crate::value::JSValue::int32(7).bits(), + ); + assert!(crate::object::exotic_expando::test_exotic_expando_entry_exists(addr)); + // The construction cache holds the compiled program, not the header, but + // evict it anyway so nothing the fixture made is reachable. + crate::regex::perex_cache::clear_for_tests(); + + // No roots: the RegExp is dead at the full trace. + full_gc_with_no_block_persistence(); + + assert!( + !crate::object::exotic_expando::test_exotic_expando_entry_exists(addr), + "a dead RegExp's EXOTIC_EXPANDO entry must be pruned by the full \ + collection's dead-owner fan-out" + ); +} + +#[test] +fn test_live_regexp_expando_survives_full_gc() { + let _guard = CopyingNurseryTestGuard::new(1); + let addr = construct_regexp("stays-live-across-the-full-trace"); + crate::object::exotic_expando::test_seed_exotic_expando_entry( + addr, + "tag", + crate::value::JSValue::int32(42).bits(), + ); + js_shadow_slot_set(0, ptr_bits(addr)); + + full_gc(); + + // Full mark-sweep is non-moving: the rooted RegExp keeps its address. + assert_eq!((js_shadow_slot_get(0) & POINTER_MASK) as usize, addr); + assert!(crate::regex::is_registered_regex(addr)); + assert_eq!( + crate::object::exotic_expando::value_lookup( + crate::object::exotic_expando::ExoticKind::RegExp, + addr, + "tag", + ), + Some(crate::value::JSValue::int32(42).bits()), + "a live RegExp's expando must survive a full GC" + ); + js_shadow_slot_set(0, 0); +} diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_lifecycle.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_lifecycle.rs index a196ad9674..ba2d28330e 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_lifecycle.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_lifecycle.rs @@ -96,7 +96,7 @@ fn perex_lifecycle_reclaims_evicted_programs_when_their_only_receivers_die() { old ); for (header, program) in dead_addresses { - assert!(!crate::regex::test_regex_pointer_entry_exists(header)); + assert!(!build_valid_pointer_set().contains(&header)); assert!(!build_valid_pointer_set().contains(&program)); } assert!(matches(&survivor, "pre77")); diff --git a/crates/perry-runtime/src/gc/trace/block_skip.rs b/crates/perry-runtime/src/gc/trace/block_skip.rs index e5507001b1..57f28145c4 100644 --- a/crates/perry-runtime/src/gc/trace/block_skip.rs +++ b/crates/perry-runtime/src/gc/trace/block_skip.rs @@ -413,9 +413,7 @@ pub(crate) fn type_needs_per_object_sweep(obj_type: u8, object_side_tables_live: GcMoveHookKind::ObjectOverflowFields => object_side_tables_live, // Pruned post-trace by `closure::prune_dead_closure_side_table_owners`. GcMoveHookKind::ClosureDynamicProps => false, - GcMoveHookKind::ErrorSideTables - | GcMoveHookKind::RegExpSideTables - | GcMoveHookKind::LazyArrayTape => true, + GcMoveHookKind::ErrorSideTables | GcMoveHookKind::LazyArrayTape => true, GcMoveHookKind::None | GcMoveHookKind::MapForeachStack | GcMoveHookKind::SetSideTables diff --git a/crates/perry-runtime/src/gc/types.rs b/crates/perry-runtime/src/gc/types.rs index 833ee81bba..136f60bd7a 100644 --- a/crates/perry-runtime/src/gc/types.rs +++ b/crates/perry-runtime/src/gc/types.rs @@ -336,7 +336,10 @@ pub(crate) enum GcMoveHookKind { SetSideTables, /// Rekey a movable exotic cell's address-keyed expando side table after a /// move. Used by `GC_TYPE_PROMISE`, whose `status`/`value` expandos - /// (#5142) live in `object::exotic_expando` keyed by the promise address. + /// (#5142) live in `object::exotic_expando` keyed by the promise address, + /// and by `GC_TYPE_REGEXP`, whose user-assigned properties live there too. + /// A dead owner's entry is dropped by the `gc::dead_owner` fan-out + /// (`prune_dead_exotic_expando_owners`), not by a per-type hook. ExoticExpandoOwner, /// Rekey the Node diagnostic record keyed by the ErrorHeader address after /// a move. Errors are movable; without this a moved error loses its @@ -344,10 +347,6 @@ pub(crate) enum GcMoveHookKind { /// live on the Error's traced `ObjectMeta` edge and need no side-table /// rekeying. ErrorSideTables, - /// Rekey the RegExp identity registry plus its exotic expando owner entry. - /// `GC_TYPE_REGEXP` is movable, and both tables use the payload address as - /// their key. - RegExpSideTables, /// Rekey a lazy JSON array's tape registration. `json_tape_store` keys a /// tape by its owner's address, which is precisely what kept /// `GC_TYPE_LAZY_ARRAY` immovable and old-gen until this existed. @@ -392,11 +391,6 @@ pub(crate) enum GcFinalizeHookKind { /// #7539: free a dead lazy JSON array's tape bytes, which /// `json_tape_store` owns outside the GC heap. LazyArrayTape, - /// Release a dead RegExp cell's header-owned compiled programs and drop - /// its entries from every payload-address-keyed registry. Moved arena - /// stubs use only the move-hook dead-owner fan-out so ownership transfers - /// to the relocated header instead of being released with the old copy. - RegExpSideTables, } #[allow(dead_code)] @@ -812,9 +806,9 @@ pub(super) static GC_TYPE_INFO_BY_ID: [Option; MALLOC_KIND_BUCKET_CO GcExternalBytePolicy::InlinePayload, GcLargeObjectPolicy::MallocTracked, false, - GcMoveHookKind::RegExpSideTables, + GcMoveHookKind::ExoticExpandoOwner, GcRewriteHookKind::None, - GcFinalizeHookKind::RegExpSideTables, + GcFinalizeHookKind::None, )), Some(gc_type_info_entry( GC_TYPE_REGEX_PROGRAM, @@ -937,9 +931,6 @@ pub(crate) fn gc_type_after_payload_move(obj_type: u8, old_user: usize, new_user old_user, new_user, ); } - GcMoveHookKind::RegExpSideTables => { - crate::regex::regex_header_moved_for_gc(old_user, new_user); - } GcMoveHookKind::LazyArrayTape => { crate::json_tape_store::owner_moved(old_user, new_user); } @@ -973,9 +964,6 @@ pub(crate) fn gc_type_clear_dead_payload_side_tables(obj_type: u8, user_ptr: usi // a third time here would be sound (the release is idempotent) but // would hide which pass actually owns the reclaim. } - GcMoveHookKind::RegExpSideTables => { - crate::regex::regex_header_clear_dead_for_gc(user_ptr); - } GcMoveHookKind::None | GcMoveHookKind::MapForeachStack | GcMoveHookKind::SetSideTables @@ -1031,9 +1019,6 @@ pub(crate) unsafe fn gc_type_finalize_unmarked_payload(obj_type: u8, user_ptr: * GcFinalizeHookKind::LazyArrayTape => { crate::json_tape_store::release(user_ptr as usize); } - GcFinalizeHookKind::RegExpSideTables => { - crate::regex::regex_header_finalize_for_gc(user_ptr as *mut crate::regex::RegExpHeader); - } } } diff --git a/crates/perry-runtime/src/hot_diag.rs b/crates/perry-runtime/src/hot_diag.rs index eac6a032b7..5845f10d68 100644 --- a/crates/perry-runtime/src/hot_diag.rs +++ b/crates/perry-runtime/src/hot_diag.rs @@ -252,18 +252,17 @@ pub struct RegexDiag { pub new_site_verify_bytes: u64, /// Address-keyed side-table inserts performed per construction. This was /// two (`REGEX_POINTERS` plus the source table) before the header's string - /// slots became traced edges; only `REGEX_POINTERS` remains. + /// slots became traced edges, then one (`REGEX_SOURCE_TABLE`), and is zero + /// since #11503 made RegExp identity the header's own GC kind and magic. pub new_side_table_inserts: u64, - /// Split of the above by table. The source counters are retained as zeroed - /// before/after controls for the #9908 measurement; `REGEX_POINTERS` is - /// still the registry the copied-minor finaliser enumerates. + /// Split of the above by table, plus the death and evacuation sides. All + /// are retained as zeroed after-controls for the #9908 measurement: no + /// RegExp construction, death or move touches an address-keyed owner + /// table any more. pub pointer_table_inserts: u64, pub source_table_inserts: u64, - /// The death side. `source_table_removals` is the zeroed after-control; - /// `regex_header_clear_dead_for_gc` now removes only `REGEX_POINTERS`. pub pointer_table_removals: u64, pub source_table_removals: u64, - /// Evacuation rekeys of the remaining pointer registry. pub side_table_rekeys: u64, /// Constructions answered from the LITERAL-SITE table — identity by the /// compiler-emitted site global's address, so neither the pattern's diff --git a/crates/perry-runtime/src/json_tape_store.rs b/crates/perry-runtime/src/json_tape_store.rs index 9ce8285fef..0dade84c43 100644 --- a/crates/perry-runtime/src/json_tape_store.rs +++ b/crates/perry-runtime/src/json_tape_store.rs @@ -275,9 +275,9 @@ pub(crate) fn registry_is_empty() -> bool { /// `GcMoveHookKind::LazyArrayTape`. The registry is keyed by the owner's /// address, which was the reason `GC_TYPE_LAZY_ARRAY` had to be immovable and /// old-gen: a moved header silently orphaned its tape, and the tape then -/// outlived every path that could free it. RegExp solved the same problem the -/// same way (`GcMoveHookKind::RegExpSideTables`), so this is that precedent -/// rather than a new mechanism. +/// outlived every path that could free it. The exotic-expando owners solve the +/// same problem the same way (`GcMoveHookKind::ExoticExpandoOwner`), so this is +/// that precedent rather than a new mechanism. pub(crate) fn owner_moved(old_addr: usize, new_addr: usize) { if registry_is_empty() || old_addr == new_addr { return; @@ -299,7 +299,7 @@ pub(crate) fn owner_moved(old_addr: usize, new_addr: usize) { /// The copying minor's flip runs no per-object finalize hooks, so without this /// a lazy header that dies young leaks its tape — which is the other half of /// what kept the type pinned in the old generation. Twin of the sweep-entry -/// [`collect_owners`] pass, mirroring Map/Set/Error/RegExp. +/// [`collect_owners`] pass, mirroring Map/Set/Error. /// /// Cost: O(registry), i.e. proportional to live-plus-recently-allocated lazy /// arrays, not to program history. diff --git a/crates/perry-runtime/src/object/exotic_expando.rs b/crates/perry-runtime/src/object/exotic_expando.rs index 548cf4161b..309ddcaaea 100644 --- a/crates/perry-runtime/src/object/exotic_expando.rs +++ b/crates/perry-runtime/src/object/exotic_expando.rs @@ -17,9 +17,11 @@ //! `ACCESSOR_DESCRIPTORS`), which are already keyed by raw address. //! //! GC: address keys are migrated by each movable owner's registered move -//! hook. Stored values are kept alive via a mutable root scanner. Address -//! reuse after a sweep is handled by clearing the table slot at allocation -//! time (`expando_clear_on_alloc`). +//! hook. Stored values are kept alive via a mutable root scanner. A dead +//! owner's entry is dropped by the `gc::dead_owner` fan-out +//! (`prune_dead_exotic_expando_owners`), and Date/RegExp allocation also +//! clears the table slot (`expando_clear_on_alloc`) as a backstop for an owner +//! that died pinned. use std::cell::{Cell, RefCell}; @@ -264,12 +266,6 @@ pub(crate) fn expando_clear_on_alloc(addr: usize) { tables.entries.borrow_mut().remove(&addr); } -/// Drop an expando entry when its owner is finalized directly rather than -/// discovered by the shared dead-owner pruning pass. -pub(crate) fn exotic_expando_owner_clear_dead(addr: usize) { - expando_clear_on_alloc(addr); -} - /// Death pruning (2026-07-09 GC audit wave 2): the root scanner /// (`scan_exotic_expando_roots_mut`) strongly roots EVERY owner's values, /// dead owners included, so a dead Date/RegExp/Promise/Map/Set's expando @@ -315,9 +311,8 @@ pub(crate) fn test_exotic_expando_entry_exists(addr: usize) -> bool { /// `old_addr` to `new_addr`. Without this, a surviving owner would lose its /// user-defined properties after a move. Stored expando *values* are already /// rewritten by `scan_exotic_expando_roots_mut`; this migrates the owner -/// *key*. Most users wire this directly via -/// `GcMoveHookKind::ExoticExpandoOwner`; RegExp calls it from its combined -/// side-table move hook. +/// *key*. Every user wires this directly via +/// `GcMoveHookKind::ExoticExpandoOwner`. pub(crate) fn exotic_expando_owner_moved(old_addr: usize, new_addr: usize) { let tables = &crate::state::state().exotic_expando; if !tables.in_use.get() || old_addr == new_addr { diff --git a/crates/perry-runtime/src/regex.rs b/crates/perry-runtime/src/regex.rs index 2f8b599a4c..a7d2b185ba 100644 --- a/crates/perry-runtime/src/regex.rs +++ b/crates/perry-runtime/src/regex.rs @@ -123,188 +123,27 @@ pub use match_string::{ js_string_match_js, js_string_match_value, js_string_search_js, js_string_search_value, }; -/// Local owner registration. Source and flags live only in the header's -/// traced string edges; metadata never keeps native copies of either string. -struct RegexMetadata { - registered_owner: bool, -} - crate::perry_thread_local! { #[cfg(feature = "regex-engine")] static LAST_EXEC_INDEX: RefCell = const { RefCell::new(0.0) }; static LAST_EXEC_GROUPS: RefCell<*mut ObjectHeader> = const { RefCell::new(ptr::null_mut()) }; - - /// Headers constructed in this runtime participate in collector owner - /// walks. The historical table name remains while legacy callers migrate. - static REGEX_SOURCE_TABLE: RefCell> = RefCell::new(crate::fast_hash::new_ptr_hash_map()); } -/// Check whether `ptr` is a RegExpHeader pointer that was allocated in -/// this thread. Called by `js_string_split` to detect the `s.split(re)` -/// case without a separate runtime FFI entry point. +/// Check whether `ptr` is a RegExpHeader pointer. Called by `js_string_split` +/// to detect the `s.split(re)` case without a separate runtime FFI entry point. +/// +/// Identity is the header alone: a `GC_TYPE_REGEXP` GcHeader carrying the +/// `RegExpHeader.magic` sentinel (see [`regex_header_has_magic`]). There is no +/// address-keyed owner registry to consult — #11503 deleted +/// `REGEX_SOURCE_TABLE`, whose only payload was a `registered_owner: bool` that +/// every live header's own GcHeader already answers, and which cost an insert +/// per construction, a rekey per evacuation and a walk per collection. pub(crate) fn is_regex_pointer(ptr: *const u8) -> bool { if ptr.is_null() || (ptr as usize) < 0x1000 { return false; } - // Wall 18: check the header-resident magic FIRST so identity survives a - // duplicate-runtime thread-local split (see `RegExpHeader.magic`). A - // RegExp is a GC-tracked `GC_TYPE_REGEXP` allocation, so it always carries - // a preceding GcHeader; only read the magic field when the GC header says - // this is an object of sufficient size to actually contain it. - if regex_header_has_magic(ptr as *const RegExpHeader) { - return true; - } - regex_pointers_contains(ptr as usize) -} - -/// Monotone "this process has ever constructed a `RegExp`" latch. -/// -/// The three owner-registration probes all reach the thread-local table only -/// *after* the header-magic check misses — which is the common case, since they -/// are asked about ordinary objects on the generic property-dispatch path -/// (`object::exotic_expando::exotic_expando_kind`) and from `String.prototype` -/// dispatch. A program with no regex answers from one atomic load. -/// See `crate::registry_latch` for the ordering rule. -static REGEX_EVER_REGISTERED: crate::registry_latch::RegistryLatch = - crate::registry_latch::RegistryLatch::new(); - -#[inline] -fn regex_pointers_contains(addr: usize) -> bool { - if REGEX_EVER_REGISTERED.is_idle() { - return false; - } - REGEX_SOURCE_TABLE.with(|table| { - table - .borrow() - .get(&addr) - .is_some_and(|entry| entry.registered_owner) - }) -} - -/// Rekey every address-owned RegExp table after payload evacuation. Header -/// child slots are rewritten separately by the RegExp GC descriptor; this -/// hook handles the owner keys that a slot visitor cannot see. -pub(crate) fn regex_header_moved_for_gc(old_addr: usize, new_addr: usize) { - if old_addr == new_addr { - return; - } - REGEX_SOURCE_TABLE.with(|table| { - let mut table = table.borrow_mut(); - if let Some(mut metadata) = table.remove(&old_addr) { - match table.entry(new_addr) { - std::collections::hash_map::Entry::Occupied(mut entry) => { - // The former set retained destination registration too; - // the source metadata still comes from the moved owner. - metadata.registered_owner |= entry.get().registered_owner; - entry.insert(metadata); - } - std::collections::hash_map::Entry::Vacant(entry) => { - entry.insert(metadata); - } - } - } - }); - crate::object::exotic_expando::exotic_expando_owner_moved(old_addr, new_addr); -} - -/// Remove address-owned RegExp metadata when the cell is proven dead. -pub(crate) fn regex_header_clear_dead_for_gc(addr: usize) { - REGEX_SOURCE_TABLE.with(|table| { - table.borrow_mut().remove(&addr); - }); - crate::object::exotic_expando::exotic_expando_owner_clear_dead(addr); -} - -/// Remove a dead header's address-owned metadata. Its program and strings are -/// ordinary traced GC children and are reclaimed by the collector. -pub(crate) unsafe fn regex_header_finalize_for_gc(re: *mut RegExpHeader) { - if !re.is_null() { - 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 source/registration metadata and 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_SOURCE_TABLE.with(|table| { - let table = table.borrow(); - let owners = table - .iter() - .filter_map(|(&addr, entry)| entry.registered_owner.then_some(addr)); - crate::gc::prefetch::prefetch_gc_owner_headers(owners) - .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 crate::gc::prefetch::prefetch_gc_owner_headers(dead.iter().copied()) { - 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_SOURCE_TABLE.with(|table| { - table - .borrow() - .iter() - .filter_map(|(&addr, entry)| entry.registered_owner.then_some(addr)) - .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 - ) + regex_header_has_magic(ptr as *const RegExpHeader) } /// Test support: construct a RegExp through the PRODUCTION path @@ -347,26 +186,10 @@ pub(crate) fn test_regexp_program_address(re: *const RegExpHeader) -> usize { unsafe { (*re).perex_program as usize } } -#[cfg(test)] -pub(crate) fn test_regex_pointer_entry_exists(addr: usize) -> bool { - REGEX_SOURCE_TABLE.with(|table| { - table - .borrow() - .get(&addr) - .is_some_and(|entry| entry.registered_owner) - }) -} - -#[cfg(test)] -pub(crate) fn test_regex_source_entry_exists(addr: usize) -> bool { - REGEX_SOURCE_TABLE.with(|table| table.borrow().contains_key(&addr)) -} - /// Build a minimal nursery-resident RegExp payload for the copying collector's -/// relocation contract test. Production construction currently chooses the -/// malloc-backed arm of `ArenaOrMalloc`; this exercises the same registered GC -/// type through its arena arm so future allocator routing cannot silently -/// strand the address-owned tables. +/// relocation contract tests, without compiling a program. Identity is the +/// header's own `GC_TYPE_REGEXP` kind plus [`REGEXP_MAGIC`], exactly as for a +/// production header, so nothing beyond the allocation needs registering. #[cfg(all(test, feature = "regex-engine"))] pub(crate) fn test_alloc_nursery_regexp_for_move(source: &str, flags: &str) -> *mut RegExpHeader { let scope = crate::gc::RuntimeHandleScope::new(); @@ -394,16 +217,6 @@ pub(crate) fn test_alloc_nursery_regexp_for_move(source: &str, flags: &str) -> * (*ptr).has_indices = flags.contains('d'); (*ptr).last_index = crate::value::JSValue::number(0.0).bits(); (*ptr).magic = REGEXP_MAGIC; - - REGEX_EVER_REGISTERED.arm(); - REGEX_SOURCE_TABLE.with(|table| { - table.borrow_mut().insert( - ptr as usize, - RegexMetadata { - registered_owner: true, - }, - ); - }); ptr } } @@ -581,8 +394,8 @@ pub(crate) fn is_valid_ptr(p: *const T) -> bool { } /// Check if a RegExpHeader pointer is legitimate — it must point to a -/// header we allocated via `js_regexp_new` (recorded as a registered owner). -/// The LLVM backend's `new RegExp(pat, flags)` currently falls through +/// header we allocated via `js_regexp_new` (a `GC_TYPE_REGEXP` cell carrying +/// [`REGEXP_MAGIC`]). The LLVM backend's `new RegExp(pat, flags)` currently falls through /// to the generic `lower_new` path which allocates an empty object and /// NaN-boxes it as a regex; subsequent `.exec()` / `.test()` calls would /// read garbage from that object if we didn't gate them on this check. @@ -590,14 +403,7 @@ pub(crate) fn is_valid_ptr(p: *const T) -> bool { pub(crate) fn is_valid_regex_ptr(p: *const RegExpHeader) -> bool { #[cfg(test)] REGEX_PTR_VALIDATION_CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - if !is_valid_ptr(p) { - return false; - } - // Wall 18: header magic first (duplicate-runtime thread-local resilient). - if regex_header_has_magic(p) { - return true; - } - regex_pointers_contains(p as usize) + is_valid_ptr(p) && regex_header_has_magic(p) } #[cfg(test)] @@ -614,14 +420,10 @@ pub(crate) fn test_regex_ptr_validation_calls() -> u64 { /// Public: is `addr` a RegExpHeader we allocated via `js_regexp_new`? /// Used by the console/`util.inspect` formatter to print regex literals /// as `/source/flags` instead of `{}` (they're GC_TYPE_REGEXP allocations -/// with no enumerable string keys). Registry-gated so a generic object -/// is never mis-read as a RegExpHeader. +/// with no enumerable string keys). Header-gated (GC kind + size + magic) so a +/// generic object is never mis-read as a RegExpHeader. pub fn is_registered_regex(addr: usize) -> bool { - // Wall 18: header magic first (duplicate-runtime thread-local resilient). - if regex_header_has_magic(addr as *const RegExpHeader) { - return true; - } - regex_pointers_contains(addr) + regex_header_has_magic(addr as *const RegExpHeader) } /// Internal helper: Get string data from StringHeader diff --git a/crates/perry-runtime/src/regex/perex_construct.rs b/crates/perry-runtime/src/regex/perex_construct.rs index 5713a137ff..a2e15e204c 100644 --- a/crates/perry-runtime/src/regex/perex_construct.rs +++ b/crates/perry-runtime/src/regex/perex_construct.rs @@ -5,7 +5,7 @@ use super::perex_api::{self as api, PROGRAM_BYTES, SCRATCH_BYTES, WORK}; use super::perex_memory::MemoryBudget; use super::perex_owner::{GcProgram, HeapSubject}; use super::perex_runtime::{self as host, EngineError}; -use super::{RegExpHeader, RegexMetadata, REGEXP_MAGIC, REGEX_EVER_REGISTERED, REGEX_SOURCE_TABLE}; +use super::{RegExpHeader, REGEXP_MAGIC}; use crate::gc::{RuntimeHandle, RuntimeHandleScope}; use crate::string::StringHeader; use crate::value::{js_nanbox_pointer, js_nanbox_string, JSValue}; @@ -180,15 +180,6 @@ pub(super) fn new( unsafe { publish(&receiver, &source, &flags, canonical, &program); } - REGEX_EVER_REGISTERED.arm(); - REGEX_SOURCE_TABLE.with(|t| { - t.borrow_mut().insert( - receiver.with_mut_ptr::(|re| re as usize), - RegexMetadata { - registered_owner: true, - }, - ); - }); Ok(receiver.with_mut_ptr::(|re| re)) } diff --git a/crates/perry-runtime/src/regex/tests.rs b/crates/perry-runtime/src/regex/tests.rs index 5abf8edf85..2cbcd05dc6 100644 --- a/crates/perry-runtime/src/regex/tests.rs +++ b/crates/perry-runtime/src/regex/tests.rs @@ -42,30 +42,37 @@ fn regexp_header_is_one_56_byte_per_object_record() { ); } +/// #11503: identity is the header (`GC_TYPE_REGEXP` + size + magic), not an +/// address registry. The fixture header is registered NOWHERE, so every probe +/// answering "yes" proves no registry is consulted, and clearing the magic +/// proves the GC kind alone is not taken as proof either. #[test] -fn malloc_finalize_clears_regexp_address_owned_state() { +fn regexp_identity_is_the_header_not_an_address_registry() { let _lock = crate::gc::global_side_table_test_lock(); - let scope = crate::gc::RuntimeHandleScope::new(); - let pattern = scope.root_string_ptr(make_string("finalize")); - let flags = scope.root_string_ptr(make_string("g")); - let re = pattern.with_mut_ptr::(|pattern| { - flags.with_mut_ptr::(|flags| js_regexp_new(pattern, flags)) - }); + let re = test_alloc_nursery_regexp_for_move("identity", "g"); let addr = re as usize; - assert!(test_regex_pointer_entry_exists(addr)); - crate::object::exotic_expando::test_seed_exotic_expando_entry( - addr, - "owned", - crate::value::TAG_TRUE, - ); - assert!(crate::object::exotic_expando::test_exotic_expando_entry_exists(addr)); + assert!(is_registered_regex(addr)); + assert!(is_valid_regex_ptr(re)); + assert!(is_regex_pointer(re as *const u8)); - unsafe { - crate::gc::gc_type_finalize_unmarked_payload(crate::gc::GC_TYPE_REGEXP, re.cast::()); - } + unsafe { (*re).magic = 0 }; + assert!(!is_registered_regex(addr)); + assert!(!is_valid_regex_ptr(re)); + assert!(!is_regex_pointer(re as *const u8)); + unsafe { (*re).magic = REGEXP_MAGIC }; +} - assert!(!test_regex_pointer_entry_exists(addr)); - assert!(!crate::object::exotic_expando::test_exotic_expando_entry_exists(addr)); +/// A RegExp's only address-keyed state is its expando entry. Death is handled +/// by the dead-owner fan-out, so the type needs no finalize hook and uses the +/// shared expando-owner move hook rather than a RegExp-specific one. +#[test] +fn regexp_gc_type_needs_no_bespoke_side_table_hooks() { + let info = crate::gc::gc_type_info(crate::gc::GC_TYPE_REGEXP).expect("RegExp GC type"); + assert_eq!( + info.move_hook_kind, + crate::gc::GcMoveHookKind::ExoticExpandoOwner + ); + assert_eq!(info.finalize_hook_kind, crate::gc::GcFinalizeHookKind::None); } // Program lifetime and compilation-churn reclamation are exercised with the diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index d5da890e3f..3319b254e5 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -1054,13 +1054,6 @@ "verdict": "not_a_gc_pointer", "why": "Atomic count of live PTY handles that currently keep the event loop active. It stores only a scalar count; PTY JS values live in PTY_LIVE and are visited by pty_reactor_scan_roots_mut." }, - { - "file": "crates/perry-runtime/src/regex.rs", - "name": "REGEX_SOURCE_TABLE", - "verdict": "covered_elsewhere", - "scanner": "regex::regex_header_moved_for_gc (called from gc/types.rs on relocation), regex::regex_header_finalize_for_gc (gc/types.rs per-object finalize), regex::finalize_dead_copied_minor_from_space_regexps (gc/copying_phase.rs) and regex::collect_dead_registered_regexps_post_trace / finalize_collected_dead_regexp (gc/oldgen.rs)", - "why": "Address-KEYED owner set, not a root, and the successor of REGEX_POINTERS under the single engine: its map is `usize` header address -> `RegexMetadata { registered_owner: bool }`, so the VALUE holds no heap address at all (source and flags live only in the header's traced string edges, and the compiled program is a traced GC child of the header). The key is rekeyed by `regex_header_moved_for_gc` when a RegExpHeader moves and removed on death by the finalize hook, the copying-minor from-space walk and the full-cycle post-trace walk; it never keeps a header alive. Reached from those GC hooks rather than a registered scanner, so the walk misses it." - }, { "file": "crates/perry-runtime/src/regex/perex_dispatch.rs", "name": "EXEC_LOOKUPS", diff --git a/scripts/shape_descriptor_census.py b/scripts/shape_descriptor_census.py index e3399e3a05..358777c05a 100644 --- a/scripts/shape_descriptor_census.py +++ b/scripts/shape_descriptor_census.py @@ -941,15 +941,19 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: if not regexp_info_match: raise CensusError("shape descriptor authority surface missing: RegExp type metadata") regexp_info = regexp_info_match.group(0) + # #11503: a RegExp's only address-keyed state is its exotic expando entry, + # rekeyed by the shared expando-owner move hook and dropped on death by the + # dead-owner fan-out. Identity is the GcHeader kind plus header magic, so no + # RegExp-specific registry may be reintroduced behind a bespoke hook. require_code( regexp_info, - r"GcMoveHookKind::RegExpSideTables", - "RegExp address-owned relocation hook", + r"GcMoveHookKind::ExoticExpandoOwner", + "RegExp expando-owner relocation hook", ) require_code( regexp_info, - r"GcFinalizeHookKind::RegExpSideTables", - "RegExp malloc-finalize side-table hook", + r"GcFinalizeHookKind::None", + "RegExp needs no per-object finalize hook", ) if "OBJ_FLAG_CLASS_OBJECT" in gc_types + class_guard + element_guard + write_pics: raise CensusError("class kind reintroduced a GcHeader layout-bit alias") From 440ccf068804905aa2f4c10fbda7ba22bbc98111 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 10:27:27 +0000 Subject: [PATCH 2/2] docs: changelog fragment for PR 11518 --- .../11518-delete-regex-source-table.md | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 changelog.d/11518-delete-regex-source-table.md diff --git a/changelog.d/11518-delete-regex-source-table.md b/changelog.d/11518-delete-regex-source-table.md new file mode 100644 index 0000000000..1ff34597cf --- /dev/null +++ b/changelog.d/11518-delete-regex-source-table.md @@ -0,0 +1,39 @@ +perf(runtime): deleted `REGEX_SOURCE_TABLE`; a RegExp is now identified by its +own GC header (#11503). The table was an address-keyed thread-local +`PtrHashMap` whose only payload was +`registered_owner: bool`, yet every RegExp construction inserted into it, every +copying minor rekeyed it, and every collection walked it (once from the +copied-minor from-space pass, once from the sweep-entry +`collect_dead_registered_regexps_post_trace` subphase) just to find dead +RegExps and clear their expandos — work the shared dead-owner fan-out +(`prune_dead_exotic_expando_owners`) already did in the same windows. + +- `is_regex_pointer` / `is_valid_regex_ptr` / `is_registered_regex` answer from + the header alone (`GC_TYPE_REGEXP` + size + `REGEXP_MAGIC`), which they + already checked first; the table fallback only ever changed the answer for a + stale entry. Every reader of `registered_owner` was an "is this a regex we + allocated" membership check — none carried other semantics. +- `GC_TYPE_REGEXP` now uses the shared `GcMoveHookKind::ExoticExpandoOwner` + move hook and no finalize hook. `GcMoveHookKind::RegExpSideTables`, + `GcFinalizeHookKind::RegExpSideTables`, the copied-minor regex finalizer, the + sweep's `dead_regexps` list, the `REGEX_EVER_REGISTERED` latch and the + now-unused `prefetch_gc_owner_headers` / `exotic_expando_owner_clear_dead` + helpers are gone. A dead RegExp's expando entry is dropped by the dead-owner + fan-out; `expando_clear_on_alloc` at construction remains the backstop for an + owner that died pinned. Block-skip may now reclaim whole dead blocks holding + RegExps without visiting them. +- Gates: the `REGEX_SOURCE_TABLE` entry is deleted from + `scripts/gc_runtime_root_holders.json`; `scripts/shape_descriptor_census.py` + now requires RegExp's type metadata to carry `ExoticExpandoOwner` and + `GcFinalizeHookKind::None`. (The table was rekeyed by a move hook, not a + `visit_metadata_*` site, so `gc_rekeyed_key_tables.json` and + `DEAD_KEY_PRUNES` had no entry for it.) + +Tests: `regexp_identity_is_the_header_not_an_address_registry`, +`regexp_gc_type_needs_no_bespoke_side_table_hooks`, +`test_dead_regexp_expando_pruned_on_full_gc`, +`test_live_regexp_expando_survives_full_gc`; the copied-minor +`nursery_regexp_that_dies_young_is_finalized_by_the_copied_minor` and +`test_movable_regexp_evacuation_migrates_all_address_owned_state` now assert +the expando is dropped / migrated (and that a copying minor ran) instead of +reading the deleted table.