From 674b5f3df7d1fd3e81ae5074af4105cafe6c7965 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 14:05:38 +0000 Subject: [PATCH 1/3] perf(regex): resume JS-level searches on non-ASCII strings from the previous call's position (#10164) A JavaScript exec/test/search/matchAll step binds its subject afresh on every call, so on a non-ASCII (WTF-8) string each search paid a seek from the nearer end and a loop over one string did quadratic work. A per-thread four-entry table now remembers where the last such search stopped and hands that position to the next search on the same string. "The same string" is decided without a traced edge or per-object state: the concealed address, the byte and UTF-16 lengths, and a new per-thread heap generation must all match. The generation advances on entry and exit of a HeapChange scope around every event that frees or moves heap memory (copying minor, cycle Sweep and Reclaim steps, minor-prelude evacuation with a nested compaction scope, in-place promotion, gc_realloc, the synchronous sweep). Debug builds assert at every primitive that makes object memory reusable or evacuates a young object that a scope is open. RegExpHeader is unchanged (56 bytes); ASCII strings never consult the table. Tests: one per event kind asserting the generation advanced and the kind's own scope opened; a funnel assertion that can say no; linearity of a JS-level non-ASCII loop with and without positions; a moved string; and a different same-layout string at a freed string's address. Claude-Session: https://claude.ai/code/session_01Da12JXeG5XuVBma5yWp5C9 --- crates/perry-runtime/src/arena/promote.rs | 4 + crates/perry-runtime/src/arena/reset.rs | 14 + crates/perry-runtime/src/arena/tests.rs | 21 ++ crates/perry-runtime/src/gc/copying.rs | 4 + crates/perry-runtime/src/gc/cycle.rs | 32 +- .../perry-runtime/src/gc/heap_generation.rs | 135 ++++++++ crates/perry-runtime/src/gc/malloc.rs | 3 + crates/perry-runtime/src/gc/mod.rs | 1 + crates/perry-runtime/src/gc/old_free.rs | 4 + crates/perry-runtime/src/gc/oldgen.rs | 4 + crates/perry-runtime/src/gc/tests/alloc.rs | 3 + .../perry-runtime/src/gc/tests/debt_pacer.rs | 3 + .../perry-runtime/src/gc/tests/evacuation.rs | 3 + .../src/gc/tests/heap_generation.rs | 291 ++++++++++++++++++ .../src/gc/tests/incremental_sweep_reclaim.rs | 6 + crates/perry-runtime/src/gc/tests/mod.rs | 1 + crates/perry-runtime/src/gc/tests/oldgen.rs | 21 ++ .../src/gc/tests/promote_in_place.rs | 3 + .../src/gc/tests/runtime_roots.rs | 2 + .../runtime_roots/perex_position_hint.rs | 172 +++++++++++ .../src/gc/tests/runtime_roots/perex_reuse.rs | 12 +- crates/perry-runtime/src/regex.rs | 2 + crates/perry-runtime/src/regex/perex_api.rs | 43 ++- .../src/regex/perex_position_hint.rs | 160 ++++++++++ scripts/gc_runtime_root_holders.json | 12 +- 25 files changed, 935 insertions(+), 21 deletions(-) create mode 100644 crates/perry-runtime/src/gc/heap_generation.rs create mode 100644 crates/perry-runtime/src/gc/tests/heap_generation.rs create mode 100644 crates/perry-runtime/src/gc/tests/runtime_roots/perex_position_hint.rs create mode 100644 crates/perry-runtime/src/regex/perex_position_hint.rs diff --git a/crates/perry-runtime/src/arena/promote.rs b/crates/perry-runtime/src/arena/promote.rs index 12debf1b57..fcfacf6442 100644 --- a/crates/perry-runtime/src/arena/promote.rs +++ b/crates/perry-runtime/src/arena/promote.rs @@ -303,6 +303,9 @@ pub(crate) fn finish_in_place_promotion( promotion: InPlacePromotion, liveness: PromotionLiveness, ) -> InPlacePromotionStats { + let _heap_change = crate::gc::heap_generation::HeapChange::begin( + crate::gc::heap_generation::HeapChangeKind::Promotion, + ); let mut stats = InPlacePromotionStats::default(); if promotion.blocks.is_empty() { return stats; @@ -567,6 +570,7 @@ fn stamp_and_index_block(block: &ArenaBlock, liveness: PromotionLiveness) -> (us /// `InPlacePromotion::reserved_bytes`), so the mutator maps only what it /// actually allocates. fn reset_young_after_promotion() { + crate::gc::heap_generation::debug_assert_heap_change_open(); crate::gc::ARENA_FREE_LIST.with(|fl| fl.borrow_mut().clear()); crate::gc::ARENA_FREE_LIST_NONEMPTY.with(|c| c.set(false)); diff --git a/crates/perry-runtime/src/arena/reset.rs b/crates/perry-runtime/src/arena/reset.rs index e53918b18d..935be06cce 100644 --- a/crates/perry-runtime/src/arena/reset.rs +++ b/crates/perry-runtime/src/arena/reset.rs @@ -11,6 +11,9 @@ use super::*; /// nothing escapes, GC observes that all 700k+ objects from the /// previous burst are dead and reclaims the entire arena in O(1). pub fn arena_reset_all_blocks_to_zero() { + let _heap_change = crate::gc::heap_generation::HeapChange::begin( + crate::gc::heap_generation::HeapChangeKind::Sweep, + ); // Only the general arena is reset (issue #179). The longlived arena // holds cached data that must not be reclaimed. ARENA.with(|arena| unsafe { @@ -85,6 +88,7 @@ fn poison_region_in_place(arena: &mut Arena) { } fn reset_region_to_zero(arena: &mut Arena) -> (usize, usize) { + crate::gc::heap_generation::debug_assert_heap_change_open(); let mut reset_blocks = 0usize; let mut reusable_bytes = 0usize; for block in arena.blocks.iter_mut() { @@ -171,6 +175,7 @@ pub(crate) fn active_survivor_block_index_range() -> std::ops::Range { /// has. No other reclaim path (the non-moving minor's `arena_reset_empty_blocks`, /// the full mark-sweep, old-gen defrag) is affected by that knob. pub(crate) fn copying_reset_from_spaces_and_flip() -> ArenaResetStats { + crate::gc::heap_generation::debug_assert_heap_change_open(); if protect_fromspace_enabled() { return copying_quarantine_from_spaces_and_flip(); } @@ -261,6 +266,7 @@ pub(crate) fn copying_reset_from_spaces_and_flip() -> ArenaResetStats { /// in place and the inline allocator keeps reusing the same ~8MB /// arena block forever. pub fn arena_reset_empty_blocks(block_has_live: &[bool]) -> ArenaResetStats { + crate::gc::heap_generation::debug_assert_heap_change_open(); let n_live = block_has_live.iter().filter(|&&b| b).count(); let n_total = block_has_live.len(); // Issue #179: only reset general-arena blocks. Longlived-arena blocks @@ -508,6 +514,7 @@ pub fn arena_reset_empty_blocks(block_has_live: &[bool]) -> ArenaResetStats { const GENERAL_DEALLOC_DEAD_CYCLES: u32 = 2; fn filter_free_list_ranges(ranges: &[(usize, usize)]) { + crate::gc::heap_generation::debug_assert_heap_change_open(); if ranges.is_empty() { return; } @@ -639,6 +646,7 @@ impl ArenaResetEmptyBlocksState { } fn process_reset_block(&mut self, block_idx: usize) -> Option<(usize, usize, usize)> { + crate::gc::heap_generation::debug_assert_heap_change_open(); let snapshot = self.snapshots.get(block_idx).copied().unwrap_or_default(); if snapshot.data == 0 { return None; @@ -695,6 +703,7 @@ impl ArenaResetEmptyBlocksState { &mut self, block_idx: usize, ) -> Result<(usize, usize, ArenaBlockRelease), DeallocReject> { + crate::gc::heap_generation::debug_assert_heap_change_open(); let snapshot = self.snapshots.get(block_idx).copied().unwrap_or_default(); if snapshot.data == 0 { return Err(DeallocReject::NoSnapshot); @@ -882,6 +891,7 @@ impl SurvivorArenaReclaimState { } fn process_block(&mut self, local_idx: usize) { + crate::gc::heap_generation::debug_assert_heap_change_open(); let global_idx = self.block_start + local_idx; let snapshot = self.snapshots.get(global_idx).copied().unwrap_or_default(); if snapshot.data == 0 { @@ -1184,6 +1194,7 @@ impl OldArenaReclaimDeadBlocksState { } fn process_block(&mut self, local_idx: usize) { + crate::gc::heap_generation::debug_assert_heap_change_open(); let old_block_start = longlived_end(); let block_idx = old_block_start + local_idx; if self @@ -1298,6 +1309,7 @@ impl OldArenaReclaimDeadBlocksState { } pub(crate) fn old_arena_reclaim_dead_blocks(block_has_live: &[bool]) -> ArenaResetStats { + crate::gc::heap_generation::debug_assert_heap_change_open(); let old_block_start = longlived_end(); let stats = OLD_ARENA.with(|arena| unsafe { let arena = &mut *arena.get(); @@ -1392,6 +1404,7 @@ pub(crate) fn old_arena_reclaim_selected_dead_blocks( block_has_live: &[bool], selected_old_blocks: &crate::fast_hash::PtrHashSet, ) -> ArenaResetStats { + crate::gc::heap_generation::debug_assert_heap_change_open(); if selected_old_blocks.is_empty() { return ArenaResetStats::default(); } @@ -1492,6 +1505,7 @@ fn reclaim_dead_survivor_arena_blocks( block_start: usize, block_has_live: &[bool], ) -> ArenaResetStats { + crate::gc::heap_generation::debug_assert_heap_change_open(); with_survivor_arena_mut(arena_idx, |arena| { let keep_idx = arena .blocks diff --git a/crates/perry-runtime/src/arena/tests.rs b/crates/perry-runtime/src/arena/tests.rs index ab319b8053..44c13392be 100644 --- a/crates/perry-runtime/src/arena/tests.rs +++ b/crates/perry-runtime/src/arena/tests.rs @@ -31,6 +31,9 @@ pub(super) fn run_with_fresh_arenas(test: impl FnOnce() + Send + 'static) { } fn reset_old_nursery_block(dead_cycles_before: u32) -> (usize, usize, usize, ArenaResetStats) { + let _heap_change = crate::gc::heap_generation::HeapChange::begin( + crate::gc::heap_generation::HeapChangeKind::Sweep, + ); let mut blocks = Vec::new(); for _ in 0..7 { let ptr = arena_alloc(BLOCK_SIZE, 8) as usize; @@ -69,6 +72,9 @@ fn reset_old_nursery_block(dead_cycles_before: u32) -> (usize, usize, usize, Are fn reset_single_reclaimable_nursery_block( dead_cycles_before: u32, ) -> (usize, usize, usize, usize, ArenaResetStats) { + let _heap_change = crate::gc::heap_generation::HeapChange::begin( + crate::gc::heap_generation::HeapChangeKind::Sweep, + ); let mut blocks = Vec::new(); for _ in 0..6 { let ptr = arena_alloc(BLOCK_SIZE, 8) as usize; @@ -144,6 +150,9 @@ fn object_start_bitmap_stamps_only_maps_and_clears_on_reset() { #[test] fn survivor_reclaim_resets_dead_blocks() { run_with_fresh_arenas(|| { + let _heap_change = crate::gc::heap_generation::HeapChange::begin( + crate::gc::heap_generation::HeapChangeKind::Sweep, + ); let baseline = arena_telemetry_snapshot(); let _dead = arena_alloc_gc_survivor(2 * 1024 * 1024, 8, GC_TYPE_STRING); let after_alloc = arena_telemetry_snapshot(); @@ -177,6 +186,9 @@ fn survivor_reclaim_resets_dead_blocks() { #[test] fn budgeted_survivor_reclaim_accumulates_release_stats_across_slices() { run_with_fresh_arenas(|| { + let _heap_change = crate::gc::heap_generation::HeapChange::begin( + crate::gc::heap_generation::HeapChangeKind::Sweep, + ); for _ in 0..3 { let ptr = arena_alloc_gc_survivor(BLOCK_SIZE, 8, GC_TYPE_STRING); assert!(!ptr.is_null()); @@ -838,6 +850,9 @@ fn longlived_pointer_is_disjoint_from_general_blocks() { #[test] fn test_arena_reset_reuses_dead_general_block_without_touching_live_block() { + let _heap_change = crate::gc::heap_generation::HeapChange::begin( + crate::gc::heap_generation::HeapChangeKind::Sweep, + ); let mut dead_blocks = Vec::new(); for _ in 0..6 { @@ -954,6 +969,9 @@ fn longlived_walk_yields_indices_outside_general_range() { /// is the only thing referencing them. #[test] fn reset_never_clears_longlived_blocks() { + let _heap_change = crate::gc::heap_generation::HeapChange::begin( + crate::gc::heap_generation::HeapChangeKind::Sweep, + ); let ll = arena_alloc_gc_longlived(40, 8, GC_TYPE_STRING) as usize; let ll_header_in_block = { // The header sits GC_HEADER_SIZE before the user pointer; @@ -1061,6 +1079,9 @@ fn old_gen_walk_yields_indices_after_longlived() { /// block is marked dead. Promotion implies indefinite lifetime. #[test] fn reset_never_clears_old_gen_blocks() { + let _heap_change = crate::gc::heap_generation::HeapChange::begin( + crate::gc::heap_generation::HeapChangeKind::Sweep, + ); let old_ptr = arena_alloc_gc_old(40, 8, GC_TYPE_STRING) as usize; let old_header = old_ptr - GC_HEADER_SIZE; let n_blocks = arena_block_count(); diff --git a/crates/perry-runtime/src/gc/copying.rs b/crates/perry-runtime/src/gc/copying.rs index d4b82fe7fd..6bd4e75ca9 100644 --- a/crates/perry-runtime/src/gc/copying.rs +++ b/crates/perry-runtime/src/gc/copying.rs @@ -500,6 +500,7 @@ impl CopyingNurseryCollector { } pub(super) unsafe fn move_young(&mut self, ptr: CopyingPointer) -> usize { + crate::gc::heap_generation::debug_assert_heap_change_open(); let header = ptr.header; let old_user = (header as *mut u8).add(GC_HEADER_SIZE); let flags = (*header).gc_flags; @@ -1098,6 +1099,9 @@ pub(super) fn run_copied_minor_attempt( _trigger_kind: GcTriggerKind, may_speculate: bool, ) -> CopiedMinorAttempt { + let _heap_change = crate::gc::heap_generation::HeapChange::begin( + crate::gc::heap_generation::HeapChangeKind::CopyingMinor, + ); if let Some(trace) = trace.as_mut() { trace.copying_nursery = eligibility.trace_stats(); trace.legacy_copy_only_scanner_pinned = eligibility.legacy_root_stats; diff --git a/crates/perry-runtime/src/gc/cycle.rs b/crates/perry-runtime/src/gc/cycle.rs index a8e6eee6af..db975cf290 100644 --- a/crates/perry-runtime/src/gc/cycle.rs +++ b/crates/perry-runtime/src/gc/cycle.rs @@ -772,8 +772,18 @@ impl GcCycleState { GcCyclePhase::MarkPropagation => self.step_mark_propagation(budget), GcCyclePhase::BlockPersistence => self.step_block_persistence(budget), GcCyclePhase::AtomicFinalize => self.step_atomic_finalize(budget), - GcCyclePhase::Sweep => self.step_sweep(budget), - GcCyclePhase::Reclaim => self.step_reclaim(budget), + GcCyclePhase::Sweep => { + let _heap_change = crate::gc::heap_generation::HeapChange::begin( + crate::gc::heap_generation::HeapChangeKind::Sweep, + ); + self.step_sweep(budget) + } + GcCyclePhase::Reclaim => { + let _heap_change = crate::gc::heap_generation::HeapChange::begin( + crate::gc::heap_generation::HeapChangeKind::Reclaim, + ); + self.step_reclaim(budget) + } GcCyclePhase::Complete => {} } self.active_step_start = None; @@ -1370,6 +1380,9 @@ impl GcCycleState { let mut evacuation = EvacuationTraceStats::default(); let mut evacuation_sticky = StickyRememberedSet::default(); if minor.evacuation_policy.enabled { + let _heap_change = crate::gc::heap_generation::HeapChange::begin( + crate::gc::heap_generation::HeapChangeKind::Evacuation, + ); let phase_start = trace_phase_start(&self.trace); let mut evacuated_new_headers = Vec::new(); let mut evacuated_original_headers = Vec::new(); @@ -1378,11 +1391,16 @@ impl GcCycleState { &mut evacuated_new_headers, &mut evacuated_original_headers, ); - let old_page_evacuation = evacuate_selected_old_pages_collecting( - &minor.old_page_selection.pages, - &mut evacuated_new_headers, - &mut evacuated_original_headers, - ); + let old_page_evacuation = { + let _compaction = crate::gc::heap_generation::HeapChange::begin( + crate::gc::heap_generation::HeapChangeKind::Compaction, + ); + evacuate_selected_old_pages_collecting( + &minor.old_page_selection.pages, + &mut evacuated_new_headers, + &mut evacuated_original_headers, + ) + }; evacuation.objects = evacuation .objects .saturating_add(old_page_evacuation.objects); diff --git a/crates/perry-runtime/src/gc/heap_generation.rs b/crates/perry-runtime/src/gc/heap_generation.rs new file mode 100644 index 0000000000..4f91460663 --- /dev/null +++ b/crates/perry-runtime/src/gc/heap_generation.rs @@ -0,0 +1,135 @@ +//! A per-thread heap generation that advances whenever heap memory is freed or +//! moved. +//! +//! An address observed while the generation reads `G` still names the same +//! object for as long as the generation still reads `G`: freeing that object +//! (so its address can be handed to a new allocation) or relocating it both +//! advance the generation first. RegExp's cross-call search position (#10164) +//! uses this to recognise that a string it searched on a previous call is the +//! same string, without adding a traced edge or any per-object state. +//! +//! # The funnel +//! +//! Every free or move of heap memory runs inside a [`HeapChange`] scope, which +//! advances the generation when it opens and again when it closes. Opening +//! covers observers that recorded an address before the event; closing covers +//! any observer that recorded one while the event was running (a JS callback +//! reached from inside a collection). Scopes may nest. +//! +//! The primitives that make an object's memory reusable or give it a new +//! address call [`debug_assert_heap_change_open`]: the arena region and block +//! resets, the old-generation free-list rebuild, dead-object reclaim, the +//! malloc sweep, promotion's young reset, evacuation (copying minor, tenured +//! nursery, selected old pages), forwarding-stub release and `gc_realloc`. +//! A free or move reached outside every scope panics in debug builds, so a new +//! path cannot silently bypass the generation. +//! +//! Recycling a block that is already empty (the block pool, the from-space +//! quarantine ring, an arena dropped at thread exit) needs no scope: the +//! objects that lived there were freed or moved by an event that already +//! advanced the generation, and nothing has been allocated there since. + +use std::cell::Cell; + +crate::perry_thread_local! { + static HEAP_GENERATION: Cell = const { Cell::new(0) }; + static OPEN_HEAP_CHANGES: Cell = const { Cell::new(0) }; +} + +/// What kind of event a [`HeapChange`] scope covers. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub(crate) enum HeapChangeKind { + /// A copying (evacuating) minor: from-space reset, young moves, promotion. + CopyingMinor = 0, + /// A non-moving or full sweep step: arena resets, malloc frees, dead-object + /// reclaim, free-list rebuild. + Sweep = 1, + /// An incremental reclaim step of a budgeted cycle. + Reclaim = 2, + /// Minor-prelude evacuation of tenured nursery objects and forwarding-stub + /// release. + Evacuation = 3, + /// Old-generation compaction or defragmentation. + Compaction = 4, + /// Promotion of the young generation outside a copying minor. + Promotion = 5, + /// A malloc-tracked object reallocated to a new address. + Realloc = 6, +} + +#[cfg(test)] +const HEAP_CHANGE_KINDS: usize = 7; + +#[cfg(test)] +crate::perry_thread_local! { + static HEAP_CHANGES_BY_KIND: Cell<[u64; HEAP_CHANGE_KINDS]> = + const { Cell::new([0; HEAP_CHANGE_KINDS]) }; +} + +/// This thread's current heap generation. +#[inline] +#[cfg_attr(not(feature = "regex-engine"), allow(dead_code))] +pub(crate) fn heap_generation() -> u64 { + HEAP_GENERATION.with(Cell::get) +} + +#[inline] +fn advance() { + // `try_with`: a scope can close while thread-locals are being destroyed. + let _ = HEAP_GENERATION.try_with(|g| g.set(g.get().wrapping_add(1))); +} + +/// A region of code that may free or move heap memory. See the module docs. +#[must_use = "a HeapChange covers only the code that runs while it is held"] +pub(crate) struct HeapChange { + _not_send: std::marker::PhantomData<*const ()>, +} + +impl HeapChange { + #[inline] + pub(crate) fn begin(kind: HeapChangeKind) -> Self { + advance(); + let _ = OPEN_HEAP_CHANGES.try_with(|n| n.set(n.get() + 1)); + #[cfg(test)] + let _ = HEAP_CHANGES_BY_KIND.try_with(|c| { + let mut counts = c.get(); + counts[kind as usize] += 1; + c.set(counts); + }); + #[cfg(not(test))] + let _ = kind; + Self { + _not_send: std::marker::PhantomData, + } + } +} + +impl Drop for HeapChange { + #[inline] + fn drop(&mut self) { + let _ = OPEN_HEAP_CHANGES.try_with(|n| n.set(n.get().saturating_sub(1))); + advance(); + } +} + +/// Called by every primitive that frees or moves heap memory. +#[inline] +#[track_caller] +pub(crate) fn debug_assert_heap_change_open() { + #[cfg(debug_assertions)] + { + let open = OPEN_HEAP_CHANGES.try_with(Cell::get).unwrap_or(1); + assert!( + open > 0, + "heap memory freed or moved outside a HeapChange scope; the heap generation \ + would not advance and an address-keyed observer could confuse two objects" + ); + } +} + +/// How many scopes of `kind` have opened on this thread. +#[cfg(test)] +pub(crate) fn heap_changes_of_kind(kind: HeapChangeKind) -> u64 { + HEAP_CHANGES_BY_KIND.with(|c| c.get()[kind as usize]) +} diff --git a/crates/perry-runtime/src/gc/malloc.rs b/crates/perry-runtime/src/gc/malloc.rs index df85b12362..6fc0fd6108 100644 --- a/crates/perry-runtime/src/gc/malloc.rs +++ b/crates/perry-runtime/src/gc/malloc.rs @@ -543,6 +543,9 @@ pub fn gc_realloc(old_user_ptr: *mut u8, new_payload_size: usize) -> *mut u8 { return gc_malloc(new_payload_size, GC_TYPE_STRING); } + let _heap_change = crate::gc::heap_generation::HeapChange::begin( + crate::gc::heap_generation::HeapChangeKind::Realloc, + ); let old_header = unsafe { old_user_ptr.sub(GC_HEADER_SIZE) as *mut GcHeader }; // Validate the pointer is in our tracked set before dereferencing the header. diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index 2098869ce1..cd2027c9ff 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -199,6 +199,7 @@ mod tenuring; use tenuring::*; mod oldgen; use oldgen::*; +pub(crate) mod heap_generation; mod oldgen_defrag; use oldgen_defrag::*; mod cycle; diff --git a/crates/perry-runtime/src/gc/old_free.rs b/crates/perry-runtime/src/gc/old_free.rs index de64ba30e2..48efe7daeb 100644 --- a/crates/perry-runtime/src/gc/old_free.rs +++ b/crates/perry-runtime/src/gc/old_free.rs @@ -69,6 +69,7 @@ pub(crate) fn old_free_bytes_slot_index() -> u32 { } fn old_free_push(user_ptr: usize, total_size: usize) { + crate::gc::heap_generation::debug_assert_heap_change_open(); if user_ptr == 0 || total_size < GC_HEADER_SIZE { return; } @@ -236,6 +237,9 @@ pub(crate) fn old_free_filter_pages(excluded_pages: &crate::fast_hash::PtrHashSe #[cfg(test)] pub(super) fn old_free_push_for_test(user_ptr: usize, total_size: usize) { + let _heap_change = crate::gc::heap_generation::HeapChange::begin( + crate::gc::heap_generation::HeapChangeKind::Sweep, + ); old_free_push(user_ptr, total_size); } diff --git a/crates/perry-runtime/src/gc/oldgen.rs b/crates/perry-runtime/src/gc/oldgen.rs index 0a96aea979..b0852e72ce 100644 --- a/crates/perry-runtime/src/gc/oldgen.rs +++ b/crates/perry-runtime/src/gc/oldgen.rs @@ -665,6 +665,7 @@ impl MallocSweepCycleState { layout_clear_for_ptr(user_ptr as usize); gc_type_finalize_unmarked_payload(obj_type, user_ptr); let layout = Layout::from_size_align(total_size, 8).unwrap(); + crate::gc::heap_generation::debug_assert_heap_change_open(); dealloc(header as *mut u8, layout); self.remove_tracked_header(header, obj_type, total_size as u64); } @@ -1334,6 +1335,7 @@ impl IncrementalSweepState { #[allow(dead_code)] pub(super) fn finish_unbounded(&mut self) -> SweepTraceStats { + let _heap_change = HeapChange::begin(HeapChangeKind::Sweep); while !self.step(usize::MAX) {} self.stats() } @@ -1665,6 +1667,7 @@ enum ArenaSweepCleanupSubphase { mod sweep_batch; mod sweep_cleanup; +use super::heap_generation::{HeapChange, HeapChangeKind}; use sweep_cleanup::*; fn add_reset_stats( @@ -1950,6 +1953,7 @@ pub(super) fn evacuate_selected_old_pages_collecting( pub(super) fn release_evacuated_original_forwarding_stubs( evacuated_original_headers: &[*mut GcHeader], ) -> EvacuationTraceStats { + crate::gc::heap_generation::debug_assert_heap_change_open(); let mut released = EvacuationTraceStats::default(); for &header in evacuated_original_headers { if header.is_null() { diff --git a/crates/perry-runtime/src/gc/tests/alloc.rs b/crates/perry-runtime/src/gc/tests/alloc.rs index 5d58365976..f44da8248f 100644 --- a/crates/perry-runtime/src/gc/tests/alloc.rs +++ b/crates/perry-runtime/src/gc/tests/alloc.rs @@ -904,6 +904,9 @@ fn test_thread_bigint_deserialization_uses_managed_nursery_page() { #[test] fn test_malloc_kind_telemetry_sweep_by_kind() { + let _heap_change = crate::gc::heap_generation::HeapChange::begin( + crate::gc::heap_generation::HeapChangeKind::Sweep, + ); let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); reset_malloc_kind_telemetry_for_tests(); let kinds = [ diff --git a/crates/perry-runtime/src/gc/tests/debt_pacer.rs b/crates/perry-runtime/src/gc/tests/debt_pacer.rs index dfb1361181..fbc1e03cb1 100644 --- a/crates/perry-runtime/src/gc/tests/debt_pacer.rs +++ b/crates/perry-runtime/src/gc/tests/debt_pacer.rs @@ -903,6 +903,9 @@ fn forwarded_array_stub_propagates_liveness_to_grown_array() { /// the precise pre-fix reclaim conditions. #[test] fn minor_sweep_retains_window_expired_growth_stub() { + let _heap_change = crate::gc::heap_generation::HeapChange::begin( + crate::gc::heap_generation::HeapChangeKind::Sweep, + ); // #7056: this exercises the BUDGETED/incremental stepper, which the // shipped default now bypasses — scavenge defers alloc-point // collections to a precise safepoint instead of starting a cycle here. diff --git a/crates/perry-runtime/src/gc/tests/evacuation.rs b/crates/perry-runtime/src/gc/tests/evacuation.rs index 6f591ac506..8d1636a054 100644 --- a/crates/perry-runtime/src/gc/tests/evacuation.rs +++ b/crates/perry-runtime/src/gc/tests/evacuation.rs @@ -675,6 +675,9 @@ fn test_evacuate_tenured_marks_forwarded_and_copies_payload() { #[test] fn test_release_evacuated_original_forwarding_stub_before_sweep() { + let _heap_change = crate::gc::heap_generation::HeapChange::begin( + crate::gc::heap_generation::HeapChangeKind::Evacuation, + ); CONS_PINNED.with(|s| s.borrow_mut().clear()); clear_marks(); let user = crate::arena::arena_alloc_gc(64, 8, GC_TYPE_OBJECT); diff --git a/crates/perry-runtime/src/gc/tests/heap_generation.rs b/crates/perry-runtime/src/gc/tests/heap_generation.rs new file mode 100644 index 0000000000..8596a91f3e --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/heap_generation.rs @@ -0,0 +1,291 @@ +//! The heap generation advances across every kind of event that frees or moves +//! heap memory (#10164's cross-call search position depends on it). +//! +//! Each test drives one kind through its production entry point and asserts two +//! things: the generation advanced, and a scope of that kind opened. The second +//! is what makes the test fail when that kind's `HeapChange::begin` is removed, +//! even where an enclosing scope would still advance the generation. + +use super::super::heap_generation::{heap_changes_of_kind, heap_generation, HeapChangeKind}; +use super::super::promote_in_place::InPlacePromotionTestGuard; +use super::super::*; +use super::support::*; + +fn assert_advanced(kind: HeapChangeKind, generation_before: u64, kind_before: u64) { + assert!( + heap_generation() > generation_before, + "{kind:?} must advance the heap generation" + ); + assert!( + heap_changes_of_kind(kind) > kind_before, + "{kind:?} must open its own HeapChange scope" + ); +} + +fn full_collection(kind: GcTriggerKind) { + let _ = gc_collect_full_mark_sweep_with_trigger(GcTriggerSnapshot { + kind, + steps_before: Some(GcStepSnapshot::current()), + }); +} + +#[test] +fn a_copying_minor_advances_the_heap_generation() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let child = young_leaf(); + js_shadow_slot_set(0, ptr_bits(child)); + let (generation, kind) = ( + heap_generation(), + heap_changes_of_kind(HeapChangeKind::CopyingMinor), + ); + + let trace = collect_minor_trace(GcTriggerKind::Direct); + + assert_copied_minor_trace(&trace, true, CopiedMinorFallbackReason::None, false); + assert_ne!( + (js_shadow_slot_get(0) & POINTER_MASK) as usize, + child, + "the witness must actually have moved" + ); + assert_advanced(HeapChangeKind::CopyingMinor, generation, kind); +} + +#[test] +fn an_in_place_promotion_advances_the_heap_generation() { + let _guard = CopyingNurseryTestGuard::new(4); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _promote = InPlacePromotionTestGuard::enabled(1000); + let child = young_leaf(); + js_shadow_slot_set(0, ptr_bits(child)); + let (generation, kind) = ( + heap_generation(), + heap_changes_of_kind(HeapChangeKind::Promotion), + ); + + let trace = collect_minor_trace(GcTriggerKind::Direct); + + assert!( + trace.copying_nursery.in_place_promoted_objects > 0, + "the cycle must have promoted in place" + ); + assert_advanced(HeapChangeKind::Promotion, generation, kind); +} + +#[test] +fn a_full_sweep_advances_the_heap_generation() { + let _isolation = copying_nursery_isolation_lock(); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _scan = ConservativeScanDisabledGuard::new(); + let _dead = crate::arena::arena_alloc_gc_old(64, 8, GC_TYPE_STRING); + let (generation, kind) = ( + heap_generation(), + heap_changes_of_kind(HeapChangeKind::Sweep), + ); + + full_collection(GcTriggerKind::Direct); + + assert_advanced(HeapChangeKind::Sweep, generation, kind); +} + +#[test] +fn an_emergency_reclaim_advances_the_heap_generation() { + let _isolation = copying_nursery_isolation_lock(); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _scan = ConservativeScanDisabledGuard::new(); + let _dead = crate::arena::arena_alloc_gc_old(64, 8, GC_TYPE_STRING); + let (generation, kind) = ( + heap_generation(), + heap_changes_of_kind(HeapChangeKind::Sweep), + ); + + // The emergency full is the ordinary full cycle under another trigger; it + // frees through the same Sweep scope. + full_collection(GcTriggerKind::Emergency); + + assert_advanced(HeapChangeKind::Sweep, generation, kind); +} + +#[test] +fn freeing_a_malloc_object_advances_the_heap_generation() { + let _isolation = copying_nursery_isolation_lock(); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _scan = ConservativeScanDisabledGuard::new(); + let dead = gc_malloc(256, GC_TYPE_STRING); + let header = unsafe { header_from_user_ptr(dead as *const u8) }; + assert!(gc_malloc_header_is_tracked(header)); + let (generation, kind) = ( + heap_generation(), + heap_changes_of_kind(HeapChangeKind::Sweep), + ); + + full_collection(GcTriggerKind::Direct); + + assert!( + !gc_malloc_header_is_tracked(header), + "the unreachable malloc object must have been freed" + ); + assert_advanced(HeapChangeKind::Sweep, generation, kind); +} + +#[test] +fn an_incremental_reclaim_step_advances_the_heap_generation() { + let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); + let _guard = CopyingNurseryTestGuard::new(1); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + GC_LAST_OLD_RECLAIM_IN_USE_BYTES.with(|bytes| bytes.set(crate::arena::old_gen_in_use_bytes())); + GC_OLD_RECLAIM_PENDING.with(|pending| pending.set(false)); + let live = young_leaf(); + js_shadow_slot_set(0, ptr_bits(live)); + let _dead = allocate_dead_malloc_churn_headers(8); + GC_NEXT_MALLOC_TRIGGER.with(|trigger| trigger.set(malloc_object_count().saturating_sub(1))); + gc_check_trigger(); + + let mut status = JsGcStepResult::default(); + let mut reached_reclaim = false; + for _ in 0..500_000 { + js_gc_step_work_units(1, &mut status); + if status.status == JS_GC_STEP_STATUS_ACTIVE + && status.phase == GcCyclePhase::Reclaim.ffi_code() + { + reached_reclaim = true; + break; + } + assert_eq!( + status.status, JS_GC_STEP_STATUS_ACTIVE, + "the cycle ended before Reclaim" + ); + } + assert!(reached_reclaim, "the budgeted cycle must reach Reclaim"); + let (generation, kind) = ( + heap_generation(), + heap_changes_of_kind(HeapChangeKind::Reclaim), + ); + + let completed = complete_budgeted_gc_cycle(); + + assert_eq!(completed.status, JS_GC_STEP_STATUS_COMPLETED); + assert_advanced(HeapChangeKind::Reclaim, generation, kind); +} + +fn forced_evacuating_minor() -> GcCycleTrace { + let _defrag = super::super::oldgen_defrag::OldDefragTestEnable::new(); + let (parent, fields) = unsafe { alloc_old_test_object(1) }; + let parent_header = unsafe { header_from_user_ptr(parent as *const u8) }; + let _dead = crate::arena::arena_alloc_gc_old(40, 8, GC_TYPE_STRING); + unsafe { + (*parent_header).gc_flags |= GC_FLAG_MARKED; + } + let _ = sweep_with_age_bump(false); + let _frame = js_shadow_frame_push(1); + let child = crate::arena::arena_alloc_gc(40, 8, GC_TYPE_OBJECT) as usize; + let _copy_only_root_guard = TemporaryCopyOnlyRootScanner::rust_bits(&[ptr_bits(child)]); + unsafe { + *fields = ptr_bits(child); + } + js_write_barrier_slot(ptr_bits(parent as usize), fields as u64, ptr_bits(child)); + js_shadow_slot_set(0, ptr_bits(parent as usize)); + collect_minor_trace(GcTriggerKind::Direct) +} + +struct ResetGcTestState; + +impl Drop for ResetGcTestState { + fn drop(&mut self) { + reset_shadow_stack(); + reset_global_roots(); + reset_remembered_set(); + clear_marks(); + clear_mark_seeds(); + CONS_PINNED.with(|s| s.borrow_mut().clear()); + } +} + +macro_rules! evacuation_setup { + () => { + let _reset = ResetGcTestState; + let _scan = ConservativeScanDisabledGuard::new(); + let _isolation = copying_nursery_isolation_lock(); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _force = ForcedEvacuationTestGuard::on(); + let _barrier_guard = GeneratedWriteBarrierTestGuard::active(); + reset_shadow_stack(); + reset_global_roots(); + reset_remembered_set(); + clear_marks(); + clear_mark_seeds(); + CONS_PINNED.with(|s| s.borrow_mut().clear()); + }; +} + +#[test] +fn minor_prelude_evacuation_advances_the_heap_generation() { + evacuation_setup!(); + let (generation, kind) = ( + heap_generation(), + heap_changes_of_kind(HeapChangeKind::Evacuation), + ); + + let trace = forced_evacuating_minor(); + + assert!( + trace.evacuation.objects > 0, + "the minor must have evacuated" + ); + assert_advanced(HeapChangeKind::Evacuation, generation, kind); +} + +#[test] +fn old_page_compaction_advances_the_heap_generation() { + evacuation_setup!(); + let (generation, kind) = ( + heap_generation(), + heap_changes_of_kind(HeapChangeKind::Compaction), + ); + + let trace = forced_evacuating_minor(); + + assert!( + trace.evacuation.old_page_moved_objects >= 1, + "the minor must have moved an object off a selected old page" + ); + assert_advanced(HeapChangeKind::Compaction, generation, kind); +} + +#[test] +fn a_moving_realloc_advances_the_heap_generation() { + let _isolation = copying_nursery_isolation_lock(); + let mut ptr = gc_malloc(64, GC_TYPE_STRING); + let original = unsafe { header_from_user_ptr(ptr as *const u8) }; + let (generation, kind) = ( + heap_generation(), + heap_changes_of_kind(HeapChangeKind::Realloc), + ); + + for payload in [1024 * 1024, 4 * 1024 * 1024, 16 * 1024 * 1024] { + ptr = gc_realloc(ptr, payload); + if unsafe { header_from_user_ptr(ptr as *const u8) } != original { + break; + } + } + + assert_ne!( + unsafe { header_from_user_ptr(ptr as *const u8) }, + original, + "the realloc must have moved the object" + ); + assert_advanced(HeapChangeKind::Realloc, generation, kind); +} + +#[test] +fn a_free_or_move_outside_every_scope_is_caught_in_debug_builds() { + let caught = std::panic::catch_unwind(|| { + crate::gc::heap_generation::debug_assert_heap_change_open(); + }); + assert!( + caught.is_err(), + "the funnel assertion must fire with no scope open" + ); + let _scope = crate::gc::heap_generation::HeapChange::begin(HeapChangeKind::Sweep); + crate::gc::heap_generation::debug_assert_heap_change_open(); +} diff --git a/crates/perry-runtime/src/gc/tests/incremental_sweep_reclaim.rs b/crates/perry-runtime/src/gc/tests/incremental_sweep_reclaim.rs index be9e54cd7e..53b31df041 100644 --- a/crates/perry-runtime/src/gc/tests/incremental_sweep_reclaim.rs +++ b/crates/perry-runtime/src/gc/tests/incremental_sweep_reclaim.rs @@ -96,6 +96,9 @@ fn budgeted_step_until_phase(target: GcCyclePhase) -> JsGcStepResult { } fn complete_incremental_sweep(sweep: &mut IncrementalSweepState) -> SweepTraceStats { + let _heap_change = crate::gc::heap_generation::HeapChange::begin( + crate::gc::heap_generation::HeapChangeKind::Sweep, + ); for _ in 0..500_000 { if sweep.step(1) { return sweep.stats(); @@ -123,6 +126,9 @@ fn realloc_until_header_moves(mut ptr: *mut u8) -> *mut u8 { #[test] fn malloc_sweep_pauses_mid_list_and_eventually_frees_dead_malloc() { + let _heap_change = crate::gc::heap_generation::HeapChange::begin( + crate::gc::heap_generation::HeapChangeKind::Sweep, + ); // #7056: this exercises the BUDGETED/incremental stepper, which the // shipped default now bypasses — scavenge defers alloc-point // collections to a precise safepoint instead of starting a cycle here. diff --git a/crates/perry-runtime/src/gc/tests/mod.rs b/crates/perry-runtime/src/gc/tests/mod.rs index 635f3af432..1a9ae7e54d 100644 --- a/crates/perry-runtime/src/gc/tests/mod.rs +++ b/crates/perry-runtime/src/gc/tests/mod.rs @@ -30,6 +30,7 @@ mod global_bootstrap; mod global_sink_isolation; mod handle_bound_method_name; mod heap_accounting; +mod heap_generation; mod helper_stores; mod host_safepoints; mod idle_compact; diff --git a/crates/perry-runtime/src/gc/tests/oldgen.rs b/crates/perry-runtime/src/gc/tests/oldgen.rs index 79b1453fd2..2b26f3fc7b 100644 --- a/crates/perry-runtime/src/gc/tests/oldgen.rs +++ b/crates/perry-runtime/src/gc/tests/oldgen.rs @@ -489,6 +489,9 @@ fn test_old_page_defrag_policy_selection_prefers_fragmented_unpinned_pages() { #[test] fn test_old_page_defrag_moves_every_source_block_occupant_during_a_minor() { + let _heap_change = crate::gc::heap_generation::HeapChange::begin( + crate::gc::heap_generation::HeapChangeKind::Compaction, + ); let _isolation = copying_nursery_isolation_lock(); reset_remembered_set(); clear_marks(); @@ -554,6 +557,9 @@ fn test_old_page_defrag_moves_every_source_block_occupant_during_a_minor() { #[test] fn test_old_page_defrag_copy_avoids_selected_pages_and_rebuilds_remembered_set() { + let _heap_change = crate::gc::heap_generation::HeapChange::begin( + crate::gc::heap_generation::HeapChangeKind::Compaction, + ); let _isolation = copying_nursery_isolation_lock(); let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); reset_remembered_set(); @@ -637,6 +643,9 @@ fn test_old_page_defrag_copy_avoids_selected_pages_and_rebuilds_remembered_set() #[test] fn test_old_page_defrag_skips_pinned_old_objects() { + let _heap_change = crate::gc::heap_generation::HeapChange::begin( + crate::gc::heap_generation::HeapChangeKind::Compaction, + ); let _isolation = copying_nursery_isolation_lock(); reset_remembered_set(); clear_marks(); @@ -687,6 +696,9 @@ fn test_old_page_defrag_skips_pinned_old_objects() { #[test] fn test_old_page_defrag_skips_non_movable_buffer_and_typed_array() { + let _heap_change = crate::gc::heap_generation::HeapChange::begin( + crate::gc::heap_generation::HeapChangeKind::Compaction, + ); let _isolation = copying_nursery_isolation_lock(); reset_remembered_set(); clear_marks(); @@ -947,6 +959,9 @@ fn test_old_page_defrag_target_gate_emits_trace() { #[test] fn test_old_page_defrag_mixed_size_fragmentation_converges_to_released_block() { + let _heap_change = crate::gc::heap_generation::HeapChange::begin( + crate::gc::heap_generation::HeapChangeKind::Compaction, + ); let _isolation = copying_nursery_isolation_lock(); let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); let _defrag = OldDefragTestEnable::new(); @@ -1475,6 +1490,9 @@ fn test_minor_preserves_old_to_young_edge_across_minors() { /// children that were still referenced. #[test] fn test_minor_sweep_keeps_unmarked_old_object_layout_mask() { + let _heap_change = crate::gc::heap_generation::HeapChange::begin( + crate::gc::heap_generation::HeapChangeKind::Sweep, + ); let _isolation = copying_nursery_isolation_lock(); reset_remembered_set(); clear_marks(); @@ -1519,6 +1537,9 @@ fn test_minor_sweep_keeps_unmarked_old_object_layout_mask() { /// being widened into "never reclaim the old generation". #[test] fn test_full_sweep_still_finalizes_unmarked_old_object() { + let _heap_change = crate::gc::heap_generation::HeapChange::begin( + crate::gc::heap_generation::HeapChangeKind::Sweep, + ); let _isolation = copying_nursery_isolation_lock(); reset_remembered_set(); clear_marks(); diff --git a/crates/perry-runtime/src/gc/tests/promote_in_place.rs b/crates/perry-runtime/src/gc/tests/promote_in_place.rs index d605781532..49d0f12c09 100644 --- a/crates/perry-runtime/src/gc/tests/promote_in_place.rs +++ b/crates/perry-runtime/src/gc/tests/promote_in_place.rs @@ -862,6 +862,9 @@ fn first_cycle_rollback_preserves_young_side_table_roots_for_the_retry() { /// turns this red. #[test] fn old_page_relocation_expands_a_described_run_before_it_moves_anything() { + let _heap_change = crate::gc::heap_generation::HeapChange::begin( + crate::gc::heap_generation::HeapChangeKind::Compaction, + ); let _isolation = copying_nursery_isolation_lock(); reset_remembered_set(); clear_marks(); diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots.rs b/crates/perry-runtime/src/gc/tests/runtime_roots.rs index 627516524d..dea88d140a 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots.rs @@ -35,6 +35,8 @@ mod perex_match_search; #[cfg(feature = "regex-engine")] mod perex_ownership; #[cfg(feature = "regex-engine")] +mod perex_position_hint; +#[cfg(feature = "regex-engine")] mod perex_public; #[cfg(feature = "regex-engine")] mod perex_replace; diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_position_hint.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_position_hint.rs new file mode 100644 index 0000000000..89cc198880 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_position_hint.rs @@ -0,0 +1,172 @@ +//! Cross-call search positions on non-ASCII strings (#10164): a JavaScript-level +//! loop that runs one search per call resumes from where the previous call's +//! search stopped, and never from a position that belonged to another string. +use super::perex_reuse::{global_loop_collecting, regex, text}; +use super::*; +use crate::regex::perex_api as api; +use crate::regex::perex_memory::MemoryBudget; +use crate::regex::perex_position_hint::{self as hints, DisableHintsForTest}; +use crate::regex::RegExpHeader; +use crate::string::StringHeader; +use perex::Budget; + +fn receiver_ptr(receiver: &RuntimeHandle<'_>) -> *mut RegExpHeader { + crate::value::js_nanbox_get_pointer(receiver.get_nanbox_f64()) as *mut RegExpHeader +} + +fn address(input: &RuntimeHandle<'_>) -> usize { + input.with_const_ptr::(|s| s as usize) +} + +/// Work charged by a JS-level global exec loop (one call per match, nothing +/// carried by the operation itself) over `repeats` copies of a non-ASCII unit. +fn js_loop_work(repeats: usize) -> usize { + let local = RuntimeHandleScope::new(); + let input = text(&local, "ä1 ö22 ".repeat(repeats).as_bytes()); + let receiver = regex(&local, "[a-zäö]+\\d+", "gu"); + let (matches, work) = global_loop_collecting(&receiver, &input, None, false); + assert_eq!(matches.len(), 2 * repeats); + work +} + +/// One non-materializing search from `last_index`; the match span. +fn search_from( + receiver: &RuntimeHandle<'_>, + input: &RuntimeHandle<'_>, + last_index: usize, +) -> Option<(usize, usize)> { + crate::regex::set_last_index_throwing(receiver_ptr(receiver), last_index); + let memory = MemoryBudget::new(api::SCRATCH_BYTES); + let mut budget = Budget::new(api::WORK); + input + .with_const_ptr::(|s| { + api::execute_with_resources( + receiver_ptr(receiver), + s, + false, + &mut budget, + &memory, + &mut || Ok(()), + None, + ) + }) + .unwrap() + .map(|found| (found.full.start(), found.full.end())) +} + +#[test] +fn cross_call_positions_keep_a_js_level_non_ascii_loop_linear() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + hints::clear_for_test(); + + let positioned = js_loop_work(2_000) as f64 / js_loop_work(1_000) as f64; + assert!( + hints::hint_uses() > 0, + "the loop must have resumed from recorded positions" + ); + let unpositioned = { + let _off = DisableHintsForTest::new(); + js_loop_work(2_000) as f64 / js_loop_work(1_000) as f64 + }; + assert!( + unpositioned > 3.0, + "without positions the loop must be quadratic here, got {unpositioned:.2}x" + ); + assert!( + positioned < 2.2, + "with positions the loop must be linear, got {positioned:.2}x" + ); +} + +#[test] +fn a_moved_string_does_not_reuse_its_position() { + let _guard = CopyingNurseryTestGuard::new(0); + let _scan = ConservativeScanDisabledGuard::new(); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + let scope = RuntimeHandleScope::new(); + let receiver = regex(&scope, "[a-zäö]+\\d+", "gu"); + let input = text(&scope, "ä1 ö22 ".repeat(64).as_bytes()); + hints::clear_for_test(); + + let first = search_from(&receiver, &input, 0).unwrap(); + let second = search_from(&receiver, &input, first.1).unwrap(); + assert_eq!( + hints::hint_uses(), + 1, + "an unmoved string resumes from its position" + ); + + let before = address(&input); + gc_collect_minor(); + assert_ne!( + address(&input), + before, + "the witness string must have moved" + ); + let uses = hints::hint_uses(); + let third = search_from(&receiver, &input, second.1).unwrap(); + assert_eq!( + hints::hint_uses(), + uses, + "a moved string must not reuse its position" + ); + assert_eq!((first, second, third), ((0, 2), (3, 6), (7, 9))); +} + +#[test] +fn another_string_at_the_same_address_after_a_free_does_not_use_the_position() { + let _guard = CopyingNurseryTestGuard::new(0); + let _scan = ConservativeScanDisabledGuard::new(); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + let scope = RuntimeHandleScope::new(); + let receiver = regex(&scope, "[a-zäö]+\\d+", "gu"); + hints::clear_for_test(); + // Start from an empty nursery, so the first string allocated lands at the + // same address before and after the collection that frees it. + gc_collect_minor(); + + // Same byte and UTF-16 lengths, different arrangement: the ASCII and + // non-ASCII letters trade places, so a byte position means another unit. + let a_bytes = "ä1 ab22 ".repeat(40); + let b_bytes = "ab1 ä22 ".repeat(40); + assert_eq!(a_bytes.len(), b_bytes.len()); + + let freed_address; + let a_end; + { + let a_scope = RuntimeHandleScope::new(); + let a = text(&a_scope, a_bytes.as_bytes()); + freed_address = address(&a); + let first = search_from(&receiver, &a, 0).unwrap(); + let second = search_from(&receiver, &a, first.1).unwrap(); + assert_eq!( + hints::hint_uses(), + 1, + "the first string records and uses a position" + ); + a_end = second.1; + } + gc_collect_minor(); + + let b = text(&scope, b_bytes.as_bytes()); + assert_eq!( + address(&b), + freed_address, + "precondition: the second string must occupy the freed string's address" + ); + let uses = hints::hint_uses(); + let found = search_from(&receiver, &b, a_end).unwrap(); + assert_eq!( + hints::hint_uses(), + uses, + "a position recorded on a freed string must not serve the string now at its address" + ); + // "ä1 ab22 " ends its second match at UTF-16 index 7; in "ab1 ä22 ab1 ..." + // the next match from index 7 is the second "ab1". + assert_eq!(a_end, 7); + assert_eq!(found, (8, 11)); +} diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_reuse.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_reuse.rs index 3eddd46247..618bf73ca1 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_reuse.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_reuse.rs @@ -13,7 +13,7 @@ use crate::value::{js_nanbox_pointer, js_nanbox_string}; use perex::binding::BoundSubject; use perex::Budget; -fn text<'s>(scope: &'s RuntimeHandleScope, bytes: &[u8]) -> RuntimeHandle<'s> { +pub(super) fn text<'s>(scope: &'s RuntimeHandleScope, bytes: &[u8]) -> RuntimeHandle<'s> { scope.root_string_ptr(crate::string::js_string_from_bytes( bytes.as_ptr(), bytes.len() as u32, @@ -21,7 +21,11 @@ fn text<'s>(scope: &'s RuntimeHandleScope, bytes: &[u8]) -> RuntimeHandle<'s> { } /// A NaN-boxed receiver handle, as split/replace/match root their receivers. -fn regex<'s>(scope: &'s RuntimeHandleScope, pattern: &str, flags: &str) -> RuntimeHandle<'s> { +pub(super) fn regex<'s>( + scope: &'s RuntimeHandleScope, + pattern: &str, + flags: &str, +) -> RuntimeHandle<'s> { let pattern = text(scope, pattern.as_bytes()); let flags = text(scope, flags.as_bytes()); let re = pattern.with_const_ptr::(|pattern| { @@ -52,7 +56,7 @@ fn global_loop( global_loop_collecting(receiver, input, reuse, true) } -fn global_loop_collecting( +pub(super) fn global_loop_collecting( receiver: &RuntimeHandle<'_>, input: &RuntimeHandle<'_>, reuse: Option<&Reuse<'_, '_>>, @@ -245,6 +249,8 @@ fn perex_reuse_binds_a_different_string_afresh() { /// Work a global loop over `repeats` copies of a non-ASCII record charges. fn non_ascii_loop_work(repeats: usize, reuse: bool) -> usize { + // The unpositioned control must not pick up a cross-call position either. + let _hints = (!reuse).then(crate::regex::perex_position_hint::DisableHintsForTest::new); let local = RuntimeHandleScope::new(); let input = text(&local, "ä1 ö22 ".repeat(repeats).as_bytes()); let receiver = regex(&local, "[a-zäö]+\\d+", "gu"); diff --git a/crates/perry-runtime/src/regex.rs b/crates/perry-runtime/src/regex.rs index e9631137f7..8594911285 100644 --- a/crates/perry-runtime/src/regex.rs +++ b/crates/perry-runtime/src/regex.rs @@ -46,6 +46,8 @@ pub(crate) mod perex_memory; #[cfg(feature = "regex-engine")] pub(crate) mod perex_owner; #[cfg(feature = "regex-engine")] +pub(crate) mod perex_position_hint; +#[cfg(feature = "regex-engine")] pub(crate) mod perex_replace; #[cfg(feature = "regex-engine")] mod perex_replace_storage; diff --git a/crates/perry-runtime/src/regex/perex_api.rs b/crates/perry-runtime/src/regex/perex_api.rs index 2175921db9..f003a4e640 100644 --- a/crates/perry-runtime/src/regex/perex_api.rs +++ b/crates/perry-runtime/src/regex/perex_api.rs @@ -143,18 +143,33 @@ pub(crate) fn bind_program<'s>( pub(crate) fn bind_heap_subject( input: RuntimeHandle<'_>, ) -> Result>, EngineError> { + bind_heap_subject_observed(input).map(|(bound, _)| bound) +} + +/// [`bind_heap_subject`], also returning the string's cross-call identity when +/// it is non-ASCII (#10164), read from the same header access. +pub(crate) fn bind_heap_subject_observed( + input: RuntimeHandle<'_>, +) -> Result< + ( + BoundSubject>, + Option, + ), + EngineError, +> { use crate::string::STRING_FLAG_WTF8_VALIDATED; - let (utf16_len, validated) = input.with_const_ptr::(|s| unsafe { + let (utf16_len, validated, identity) = input.with_const_ptr::(|s| unsafe { ( (*s).utf16_len as usize, (*s).flags & STRING_FLAG_WTF8_VALIDATED != 0, + super::perex_position_hint::identity_of_header(s), ) }); let owner = unsafe { HeapSubject::new(input) } .map_err(|e| EngineError::Subject(perex::binding::SubjectError::Resource(e)))?; let owner = if validated { match BoundSubject::new_counted(owner, utf16_len) { - Ok(bound) => return Ok(bound), + Ok(bound) => return Ok((bound, identity)), Err(failed) => failed.storage, } } else { @@ -171,7 +186,7 @@ pub(crate) fn bind_heap_subject( (*(s as *mut StringHeader)).flags |= STRING_FLAG_WTF8_VALIDATED; }); } - Ok(bound) + Ok((bound, identity)) } /// Bindings one compound operation reuses across its searches (#10165). @@ -397,17 +412,23 @@ pub(crate) fn execute_with_resources( }; let fresh_subject; let reused_subject = reuse.and_then(|reuse| reuse.subject_for(&input)); - // A position is valid only on the binding it came from. - let near = reuse - .filter(|_| reused_subject.is_some()) - .and_then(|reuse| reuse.near()); + // A position from this operation's own binding, or else from the previous + // call's search on this same, unchanged string (#10164). Only a non-ASCII + // string has an identity; its lengths cannot change during the search. + let mut cross_call = None; let subject = match reused_subject { Some(subject) => subject, None => { - fresh_subject = bind_heap_subject(input)?; + let (bound, identity) = bind_heap_subject_observed(input)?; + fresh_subject = bound; + cross_call = identity; &fresh_subject } }; + let near = match reused_subject { + Some(_) => reuse.and_then(|reuse| reuse.near()), + None => cross_call.and_then(super::perex_position_hint::lookup), + }; let (found, position) = host::find_near( program, subject, @@ -425,6 +446,12 @@ pub(crate) fn execute_with_resources( )?; if let (Some(reuse), Some(_)) = (reuse, reused_subject) { reuse.near.set(Some(position)); + } else if cross_call.is_some() { + // Re-read after the search: a collection during it may have moved the + // string, and the identity must be the one the next call will see. + if let Some(identity) = super::perex_position_hint::identity_of(&input) { + super::perex_position_hint::record(identity, position); + } } if stateful { let next = found.as_ref().map_or(0, |m| m.full.end()); diff --git a/crates/perry-runtime/src/regex/perex_position_hint.rs b/crates/perry-runtime/src/regex/perex_position_hint.rs new file mode 100644 index 0000000000..3214e1591c --- /dev/null +++ b/crates/perry-runtime/src/regex/perex_position_hint.rs @@ -0,0 +1,160 @@ +//! Search positions carried across JavaScript calls on non-ASCII strings +//! (#10164). +//! +//! A JavaScript `exec`, `test`, `search` or `matchAll` step runs one search per +//! call and binds its subject afresh. On a non-ASCII (WTF-8) string a search +//! that starts at `lastIndex` without a position pays a seek from the nearer end +//! of the string, so a loop over one long string does quadratic work. Within one +//! compound operation (`split`, `replace`, global `match`) `perex_api::Reuse` +//! already carries the position; this carries it from one call to the next. +//! +//! The next call must be searching the same string, and that is decided without +//! a traced edge or any per-object state: the string's address, its byte and +//! UTF-16 lengths and the thread's heap generation must all be unchanged. +//! `gc::heap_generation` advances on every event that frees or moves heap +//! memory, so an unchanged generation means the object at that address was +//! neither freed (letting another string take the address) nor moved. The +//! lengths reject an in-place append, the only way a live string's bytes change. +//! A wrong position could only give wrong answers, never unsafety (the contract +//! of `perex::input::Position`), and Perex still refuses one whose layout does +//! not match. +//! +//! The table is per thread, four entries of plain data. RegExp objects gain no +//! state (their header stays one 56-byte record) and the collector has nothing +//! new to scan: the address is kept only as a concealed identity, never read +//! back as a pointer. + +use crate::gc::RuntimeHandle; +use crate::string::StringHeader; +use perex::input::Position; +use std::cell::Cell; + +/// Which string a position belongs to, as observed at one moment. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct StringIdentity { + concealed_address: usize, + generation: u64, + byte_len: u32, + utf16_len: u32, +} + +#[derive(Clone, Copy)] +struct Hint { + identity: StringIdentity, + position: Position, +} + +const HINTS: usize = 4; + +crate::perry_thread_local! { + static HINT_TABLE: Cell<[Option; HINTS]> = const { Cell::new([None; HINTS]) }; + static HINT_NEXT: Cell = const { Cell::new(0) }; +} + +#[cfg(test)] +crate::perry_thread_local! { + static HINT_USES: Cell = const { Cell::new(0) }; + static HINTS_DISABLED: Cell = const { Cell::new(false) }; +} + +/// Not a pointer to anything: a bijection of the address, compared for equality +/// only. +#[inline] +fn conceal(address: usize) -> usize { + address.rotate_left(29) ^ 0x5a5a_5a5a_5a5a_5a5a_u64 as usize +} + +/// The identity of the string `input` currently holds, when it is non-ASCII. +/// ASCII strings seek in constant work and need no position. +#[inline] +pub(crate) fn identity_of(input: &RuntimeHandle<'_>) -> Option { + input.with_const_ptr::(|s| unsafe { identity_of_header(s) }) +} + +/// [`identity_of`] for a header the caller is already reading. +/// +/// # Safety +/// `s` must point at a live `StringHeader`. +#[inline] +pub(crate) unsafe fn identity_of_header(s: *const StringHeader) -> Option { + let (byte_len, utf16_len) = ((*s).byte_len, (*s).utf16_len); + (byte_len != utf16_len).then(|| StringIdentity { + concealed_address: conceal(s as usize), + generation: crate::gc::heap_generation::heap_generation(), + byte_len, + utf16_len, + }) +} + +/// The position the last search on this same string stopped at, if any. +#[inline] +pub(crate) fn lookup(identity: StringIdentity) -> Option { + #[cfg(test)] + if HINTS_DISABLED.with(Cell::get) { + return None; + } + let found = HINT_TABLE.with(|table| { + table + .get() + .iter() + .flatten() + .find(|hint| hint.identity == identity) + .map(|hint| hint.position) + }); + #[cfg(test)] + if found.is_some() { + HINT_USES.with(|n| n.set(n.get() + 1)); + } + found +} + +/// Remember where a search on this string stopped. +#[inline] +pub(crate) fn record(identity: StringIdentity, position: Position) { + HINT_TABLE.with(|table| { + let mut hints = table.get(); + let slot = match hints + .iter() + .position(|hint| hint.is_some_and(|hint| hint.identity == identity)) + { + Some(slot) => slot, + None => HINT_NEXT.with(|next| { + let slot = next.get() as usize % HINTS; + next.set(((slot + 1) % HINTS) as u8); + slot + }), + }; + hints[slot] = Some(Hint { identity, position }); + table.set(hints); + }); +} + +#[cfg(test)] +pub(crate) fn hint_uses() -> u64 { + HINT_USES.with(Cell::get) +} + +#[cfg(test)] +pub(crate) fn clear_for_test() { + HINT_TABLE.with(|table| table.set([None; HINTS])); + HINT_USES.with(|n| n.set(0)); +} + +/// Searches on this thread ignore recorded positions while this is held, so a +/// test can measure the unpositioned cost next to the positioned one. +#[cfg(test)] +pub(crate) struct DisableHintsForTest(bool); + +#[cfg(test)] +impl DisableHintsForTest { + pub(crate) fn new() -> Self { + Self(HINTS_DISABLED.with(|d| d.replace(true))) + } +} + +#[cfg(test)] +impl Drop for DisableHintsForTest { + fn drop(&mut self) { + HINTS_DISABLED.with(|d| d.set(self.0)); + } +} diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 6bc9927516..c3ebab9f30 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -307,7 +307,7 @@ "file": "crates/perry-runtime/src/gc/census.rs", "name": "PASS1_MARKED", "verdict": "non_moving_snapshot", - "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete → sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs — it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase — after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged — `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` — and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` → `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only — no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound — the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses — no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects — and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module — all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes. Re-audited 2026-09-13 after the #10169 fix touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains only `pub(crate) use` re-exports (`policy::note_young_leaf_born_old`, `policy::young_generation_holds_a_nursery`, `promote_in_place::{young_generation_measured_dying, young_generation_measured_retained}`, and cfg(test) survival seeders). `gc/policy.rs` gains a `Cell` thread-local (`GC_YOUNG_LEAF_BORN_OLD`, no pointer), its setter, a pure predicate over `copying_from_space_in_use_bytes` vs the base nursery cap, and a consumed-once branch at the top of `gc_budgeted_due_trigger` that may answer `YoungScavengeCap` ahead of `OldReclaim`. That branch decides WHICH collection a safepoint starts (a minor instead of a full); it runs before any cycle begins and never inside one, so the mark-complete → sweep-entry window of a synchronous full — where PASS1_MARKED is populated and consumed within one `run_to_completion` — is unchanged, and neither hunk adds an allocation, a JS callback, or a relocation to it.", + "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete → sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs — it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase — after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged — `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` — and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` → `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only — no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound — the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses — no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects — and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module — all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes. Re-audited 2026-09-13 after the #10169 fix touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains only `pub(crate) use` re-exports (`policy::note_young_leaf_born_old`, `policy::young_generation_holds_a_nursery`, `promote_in_place::{young_generation_measured_dying, young_generation_measured_retained}`, and cfg(test) survival seeders). `gc/policy.rs` gains a `Cell` thread-local (`GC_YOUNG_LEAF_BORN_OLD`, no pointer), its setter, a pure predicate over `copying_from_space_in_use_bytes` vs the base nursery cap, and a consumed-once branch at the top of `gc_budgeted_due_trigger` that may answer `YoungScavengeCap` ahead of `OldReclaim`. That branch decides WHICH collection a safepoint starts (a minor instead of a full); it runs before any cycle begins and never inside one, so the mark-complete → sweep-entry window of a synchronous full — where PASS1_MARKED is populated and consumed within one `run_to_completion` — is unchanged, and neither hunk adds an allocation, a JS callback, or a relocation to it. Re-audited 2026-09-13 for the heap generation (#10164 cross-call search positions): `gc/mod.rs` only declares `pub(crate) mod heap_generation;`. `gc/cycle.rs` wraps the `Sweep` and `Reclaim` arms of `GcCycleState::step` in a `HeapChange` scope and opens one inside `atomic_finalize_minor_prelude`'s evacuation branch (with a nested one around old-page defrag). Opening and closing a scope only increments two thread-local integer cells (`HEAP_GENERATION`, `OPEN_HEAP_CHANGES`); a first thread-local read may allocate a key through the global allocator, which neither relocates nor runs JS. The `Sweep` scope opens immediately before `step_sweep`, i.e. before `census_take_if_armed_at_full_sweep_start` takes PASS1_MARKED out of TLS, and adds no relocation, collection or JS callback to the synchronous mark-complete to sweep-entry window; the minor-prelude scope is unreachable from a full cycle, which bypasses `MinorPrelude`. Neither boundary nor the intervening control flow changed.", "window": { "start": { "file": "crates/perry-runtime/src/gc/census.rs", @@ -323,8 +323,8 @@ }, "sources": { "crates/perry-runtime/src/gc/census.rs": "3f9e6be47b4454022b70ff2357bbdf3a80ef6a84c4986e58763acbdcce9142c1", - "crates/perry-runtime/src/gc/cycle.rs": "77eadaf7c4157308c3b14be5e1ff11d7198b84b503a50ff2d244851248d3e800", - "crates/perry-runtime/src/gc/mod.rs": "5b0c6dcf8ad8b919e86458f69ef4ad24679e6c7c08eeacf6629439b4e9ff0177", + "crates/perry-runtime/src/gc/cycle.rs": "7ec51445743cf706fe99f089360513823b118ee13390505beeb6f4b3ba895090", + "crates/perry-runtime/src/gc/mod.rs": "90339683735e4d662628cd98279a1972d523c3f2ebc358130ed3bdb0c6fc2d3f", "crates/perry-runtime/src/gc/policy.rs": "aea89274a017156efea3516b4f49124f48a66527a08195b662cfbf61672ac042", "crates/perry-runtime/src/gc/progress.rs": "a5ad3971bbe4047229ca57325234780daa85921dbc778e1c08dff4ad07ccfb96" } @@ -823,6 +823,12 @@ "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_position_hint.rs", + "name": "HINT_USES", + "verdict": "test_only", + "why": "#10164: #[cfg(test)] Cell counting how many searches resumed from a recorded cross-call position, so tests can tell the position was used. A count, never an address, and absent from shipped binaries." + }, { "file": "crates/perry-runtime/src/regex/perex_split.rs", "name": "FORWARD_SPLITS", From 991f63d643d3698c865ca6e87f6401998b0e2cee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 14:06:59 +0000 Subject: [PATCH 2/3] changelog: add fragment for #10205 Claude-Session: https://claude.ai/code/session_01Da12JXeG5XuVBma5yWp5C9 --- changelog.d/10205-regex-cross-call-position.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/10205-regex-cross-call-position.md diff --git a/changelog.d/10205-regex-cross-call-position.md b/changelog.d/10205-regex-cross-call-position.md new file mode 100644 index 0000000000..7af08477cb --- /dev/null +++ b/changelog.d/10205-regex-cross-call-position.md @@ -0,0 +1,3 @@ +### Performance + +- **JavaScript regex loops over a non-ASCII string resume where the previous call stopped** (#10205). A `while (re.exec(s))` or `for (const m of s.matchAll(re))` loop used to pay a seek from an end of the string on every call, so a loop over one long string with umlauts, CJK or emoji did quadratic work. For 80,000 matches the `exec` loop drops from 28.5 s to 314 ms, and `matchAll` from 26.9 s to 463 ms. A new per-thread heap generation, advanced by every collection that frees or moves memory, is what lets a remembered position be matched to the same string safely; RegExp objects and strings gain no state. From 6eb609ed80d98e3bbd3dd40e46e52e62d27c1ce8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 14:07:34 +0000 Subject: [PATCH 3/3] chore: bump workspace version to 0.5.1555 Claude-Session: https://claude.ai/code/session_01Da12JXeG5XuVBma5yWp5C9 --- CLAUDE.md | 2 +- Cargo.lock | 162 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 83 insertions(+), 83 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 876d24e42e..a62fe15241 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1554 +**Current Version:** 0.5.1555 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 7d3f127d04..1bd6baff14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5690,7 +5690,7 @@ checksum = "1542e48011813fbdf3c075da4a4ed53ee93c816eef62e36eb5064a6fd2be10a5" [[package]] name = "perry" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "anyhow", "base64 0.22.1", @@ -5754,7 +5754,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "perry-dispatch", "serde", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "cc", "libc", @@ -5771,7 +5771,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "aho-corasick", "anyhow", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "anyhow", "perry-hir", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "anyhow", "perry-hir", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "anyhow", "perry-dispatch", @@ -5814,7 +5814,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "anyhow", "perry-hir", @@ -5822,7 +5822,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "anyhow", "base64 0.22.1", @@ -5834,7 +5834,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "anyhow", "perry-hir", @@ -5842,7 +5842,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "anyhow", "async-trait", @@ -5870,14 +5870,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "serde", "serde_json", @@ -5885,7 +5885,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1554" +version = "0.5.1555" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "anyhow", "clap", @@ -5911,7 +5911,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "block2", "objc2", @@ -5921,7 +5921,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "argon2", "perry-ffi", @@ -5930,7 +5930,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "perry-ffi", "reqwest", @@ -5939,7 +5939,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "bcrypt", "perry-ffi", @@ -5947,7 +5947,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "perry-ffi", "rusqlite", @@ -5955,7 +5955,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "perry-ffi", "scraper", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "perry-ffi", "perry-runtime", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "chrono", "cron", @@ -5981,7 +5981,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "chrono", "perry-ffi", @@ -5989,7 +5989,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "perry-ffi", "rust_decimal", @@ -5997,7 +5997,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "perry-ffi", "serde_json", @@ -6005,7 +6005,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -6013,7 +6013,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "perry-ffi", "perry-runtime", @@ -6021,14 +6021,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "bytes", "http-body-util", @@ -6046,7 +6046,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "bytes", "lazy_static", @@ -6059,7 +6059,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "base64 0.22.1", "bytes", @@ -6091,7 +6091,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "lazy_static", "perry-ffi", @@ -6101,7 +6101,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "base64 0.22.1", "jsonwebtoken", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "lru", "perry-ffi", @@ -6121,7 +6121,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "chrono", "perry-ffi", @@ -6129,7 +6129,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "bson", "futures-util", @@ -6141,7 +6141,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "chrono", "perry-ffi", @@ -6153,7 +6153,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "nanoid", "perry-ffi", @@ -6162,7 +6162,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "bytes", "perry-ffi", @@ -6177,7 +6177,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "const-oid 0.10.2", "der 0.8.1", @@ -6196,7 +6196,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "lettre", "perry-ffi", @@ -6206,7 +6206,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "notify", "perry-ffi", @@ -6218,7 +6218,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "perry-ffi", "printpdf", @@ -6226,7 +6226,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "perry-ffi", "sqlx", @@ -6235,7 +6235,7 @@ dependencies = [ [[package]] name = "perry-ext-qs" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "perry-ffi", "perry-runtime", @@ -6244,7 +6244,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "governor", "perry-ffi", @@ -6252,7 +6252,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "fast_image_resize", "image", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "lazy_static", "perry-ffi", @@ -6272,7 +6272,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "anyhow", "perry-ffi", @@ -6292,7 +6292,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "perry-ffi", "perry-runtime", @@ -6301,7 +6301,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "perry-ffi", "uuid", @@ -6309,7 +6309,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "perry-ffi", "perry-validation", @@ -6318,7 +6318,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "futures-util", "lazy_static", @@ -6331,7 +6331,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "brotli", "flate2", @@ -6341,7 +6341,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6351,7 +6351,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "anyhow", "perry-api-manifest", @@ -6372,11 +6372,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1554" +version = "0.5.1555" [[package]] name = "perry-parser" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "anyhow", "perry-diagnostics", @@ -6390,7 +6390,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "perex", "regex", @@ -6398,7 +6398,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "ahash", "anyhow", @@ -6458,14 +6458,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6560,14 +6560,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "anyhow", "perry-hir", @@ -6576,7 +6576,7 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "perry-ffi", "perry-ui-model", @@ -6584,7 +6584,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "base64 0.22.1", "itoa", @@ -6602,7 +6602,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "rand 0.10.2", "serde", @@ -6612,7 +6612,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6635,7 +6635,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "base64 0.22.1", "block2", @@ -6652,7 +6652,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "base64 0.22.1", "block2", @@ -6669,7 +6669,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1554" +version = "0.5.1555" [[package]] name = "perry-ui-test" @@ -6680,11 +6680,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1554" +version = "0.5.1555" [[package]] name = "perry-ui-tvos" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "base64 0.22.1", "block2", @@ -6701,7 +6701,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "base64 0.22.1", "block2", @@ -6718,7 +6718,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "block2", "libc", @@ -6732,7 +6732,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "base64 0.22.1", "libc", @@ -6751,7 +6751,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "base64 0.22.1", "libc", @@ -6764,7 +6764,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "anyhow", "base64 0.22.1", @@ -6780,7 +6780,7 @@ dependencies = [ [[package]] name = "perry-validation" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "idna", "regex", @@ -6790,7 +6790,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1554" +version = "0.5.1555" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index d3cbf67e15..ed938ddcb3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -338,7 +338,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1554" +version = "0.5.1555" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry"