You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
GC: js_regexp_new holds &str borrows into movable StringHeaders across its whole body (residual #7217 allocation-point witness) #7247
test_gap_gc_regexp_receiver_rooting still exits 139 on every allocation-point arm, 10/10 deterministic, after #7217's fix landed. #7217 turned out to be the lazy globalThis bootstrap — a different site — and closing it fixed test_gap_gc_spread_accessor_rooting and test_gap_gc_static_block_this_rooting but left this one untouched (base and fixed both exit=139, 10/10 each). So the ten regexp_receiver entries in test-parity/gc_repsel_triage.txt are retargeted here, not deleted.
Reproduction
export PERRY_RUNTIME_DIR=<build>/perry-dev PERRY_NO_AUTO_OPTIMIZE=1
perry test-files/test_gap_gc_regexp_receiver_rooting.ts -o /tmp/rx # no compile-time env
PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off /tmp/rx
# exit=139, no output, 10/10
Clean on the safepoint route (loop_polls) and on the shipped default, exactly as #7227 claimed.
PERRY_GC_PROTECT_FROMSPACE=1 faults at the stale dereference (build with PERRY_DEBUG_SYMBOLS=1 for the frames):
[gc-fromspace-protect] FAULT: signal 10 at 0x27609a90064
block=0x27609a90000 +100 retired_bytes=640 retired_by_minor=#0
last-known object: user_ptr=0x27609a90050 obj_type=3 size=40
2 std::thread::local::LocalKey<RefCell<HashMap<(String,String), Arc<fancy_regex::Regex>>>>::with::{closure}
(perry_runtime::regex::js_regexp_new)
3 js_regexp_new + 1396
4 main + 228
obj_type=3 is a string, and the frame is the REGEX_CACHE.with(...) closure inside js_regexp_new.
Why
crates/perry-runtime/src/regex.rs:616 opens with
let pattern_str = ifis_valid_ptr(pattern){string_as_str(pattern)}else{""};let raw_flags_str = ifis_valid_ptr(flags){string_as_str(flags)}else{""};
Both are &strborrows into the heap StringHeader's payload region, and they are held for the whole function — through validate_and_canonicalize_flags, the REGEX_CACHE probe (pattern_str.to_string(), flags_str.to_string()), the validation block, get_or_compile_regex, and the RegExpHeader allocation. Every one of those is an allocation point on this route, and an evacuating minor moves the pattern string out from under the borrow.
This is the #7215 borrow shape, and the same one #7216 fixed for object_assign_string_source by taking one owned copy up front (let owned: String = s.to_string();). The &str-into-a-movable-StringHeader pattern is the family, not the individual site: string_as_str / str_bytes_from_jsvalue / js_string_key_bytes all hand out a pointer into a relocatable payload, and their own safety notes say so.
Suggested shape
Take owned copies of pattern_str / raw_flags_str before the first allocation (js_regexp_new is already O(pattern length) — it does pattern.chars().collect::<Vec<char>>() on a cache miss), or root the two StringHeaders in a RuntimeHandleScope and re-derive the slices after each allocating step. The owned copy is the one #7216 chose for the sibling and it removes the dependence on where the collection lands.
Confirmed still open on main = d4ac65f21. Re-verified in the sweep #7262.
Compiled with no compile-time GC env, allocation-point arm:
PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off \
./test_gap_gc_regexp_receiver_rooting
→ exit=139, no output, 10/10 (node 26.5.1: bad 0)
Unchanged from the exit=139 10/10 this issue reports at 8b024958f and with #7217's window — so #7249, #7250, #7252, #7253 and #7256 have all landed since without touching it, as expected.
crates/perry-runtime/src/regex.rs still opens js_regexp_new with string_as_str(pattern) / string_as_str(flags) and holds both &str borrows across the whole body.
Worth linking explicitly: #7215 has now been closed (its object_assign_string_source instance is fixed by #7216's owned snapshot), so this is the last open instance of that borrow family — and the family argument in #7215 ("any caller that allocates between the decode and the last use of the view has this bug, and no static gate reaches it") is now carried by this issue alone. The suggested shape there — an owned copy up front, on a path already O(pattern length) — is the same one that worked for the sibling.
Heads-up before anyone invests here — this issue is in a class that a planned architectural change would make unrepresentable, and I don't want someone building new machinery that gets deleted.
See docs/src/internals/rfc-rooting-by-construction.md ("Where this sits in the one GC correctness plan", landed in #7292). Short version: this is layer 2 — a GC-managed pointer live in emitted machine code across a point where the collector can run. Statepoints/stack maps (#7108, #7174, enabled by #7241) would have LLVM track that liveness precisely, so the whole shape stops being expressible.
This is not "don't fix it". The architectural change is weeks away, the bug is real now, and correctness comes first — please do fix it.
It is "fix the instance, don't build new mechanism." Specifically:
A targeted root/RuntimeHandleScope/GcSuppressScope at the site: good, lands now, survives.
A new predicate, a new shadow-slot allocation scheme, or a new hand-maintained table deciding what to root: please raise it first.codegen: ClassExprFresh leaves the class object unrooted across its own js_object_set_field_by_name stores #7211 is the cautionary case — a four-clause predicate written by someone actively thinking about rooting, still wrong, because it asked about the user's expressions rather than the lowering's own emitted calls. Statepoints delete that entire category of judgement call.
Also worth knowing when you verify: PERRY_GC_MOVING_LOOP_POLLS=1 set at run time against a default-compiled binary is clean 10/10 and proves nothing — the poll codegen isn't in the binary. Compile-time and run-time env must both be set (#7280). And a green run that performed zero copying minors proves nothing either (#7255) — assert copied_objects > 0.
Re-verified on main = d1aa968e9 (v0.5.1393): this reproduces GREEN. No fix PR — it was closed by the #7341-family work that landed since, and the residual borrow is latent rather than live. Evidence and the precise reason, so the next person does not have to re-derive it.
The run
Built -p perry -p perry-runtime-static -p perry-stdlib-static (perry-dev), PERRY_RUNTIME_DIR pinned to that build, PERRY_NO_AUTO_OPTIMIZE=1, no compile-time GC env — i.e. this issue's own recipe:
PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off ./rx
→ bad 0 exit=0 10/10 (node 26.5.1: bad 0)
Previously exit=139, 10/10, at 8b024958f and again at d4ac65f21.
The arm is live, not inert (#7255): under PERRY_GC_DIAG=1 the same run reports evacuating copying minors doing real work —
[gc-copy-minor] eligible=true fallback=none preflight_skipped=true
[gc-copy-minor] ran copied_objects=6024 copied_bytes=411936 … trigger=ArenaBytes
Why it is fixed
js_regexp_new now opens with a RuntimeHandleScope and root_string_ptr(pattern), roots the canonical flags string it allocates, and re-reads both out of their handles after gc_malloc before storing them into the RegExpHeader (crates/perry-runtime/src/regex.rs, the two ★ comments). That is #7341's work, and it closed the store-a-stale-pointer half.
The &str borrows are still there, and that is worth being precise about
The function still opens with
let pattern_str = ifis_valid_ptr(pattern){string_as_str(pattern)}else{""};let raw_flags_str = ifis_valid_ptr(flags){string_as_str(flags)}else{""};
so #7215's borrow shape has not been removed. It is currently latent, not live, and the reason is exactly what the shape's family argument turns on — whether the span between the borrow and its last use contains a GC allocation point:
raw_flags_str dies at validate_and_canonicalize_flags, which is pure Rust;
pattern_str lives until let owned_pattern = pattern_str.to_string();, and everything in between — the REGEX_CACHE probe, has_invalid_repeated_quantifier, compile_and_cache_regex_checked, get_or_compile_regex — allocates only through the Rust allocator (String, Vec<char>, the regex/fancy-regex programs). None of those reaches gc_malloc or the arena, so none of them is a collection point;
throw_regexp_syntax_error does allocate a GC error object, but its format! reads pattern_strbefore the call and the call never returns;
the first genuine GC allocation, js_string_from_str(flags_str), comes after the owned copy, and the source already kills the borrow at that boundary with #[allow(unused_variables)] let pattern_str: () = ();.
So the borrow is safe by an argument about what the callees allocate through, not by construction. Inserting any GC allocation into that span reopens it silently, and nothing gates that. If it is worth hardening, the cheap version is #7216's shape — take the owned copy up front rather than at the end — on a path that is already O(pattern length).
Closing as fixed-by-#7341. Reopen (or file the narrower "harden the span" item) if that span ever gains an allocation.
Related: the ten regexp_receiver entries in test-parity/gc_repsel_triage.txt retargeted here can now be retired.
Activity
fix(gc): run the lazy realm bootstraps in a no-move window (#7217)
fix(gc): run the globalThis bootstrap in a no-move window (#7217)
fix(gc): run the globalThis bootstrap in a no-move window (#7217)
fix(gc): run the globalThis bootstrap in a no-move window (#7217)
fix(gc): run the globalThis bootstrap in a no-move window (#7217) (#7249
proggeramlug commentedon Aug 2, 2026
Confirmed still open on
main=d4ac65f21. Re-verified in the sweep #7262.Compiled with no compile-time GC env, allocation-point arm:
Unchanged from the
exit=13910/10 this issue reports at8b024958fand with #7217's window — so #7249, #7250, #7252, #7253 and #7256 have all landed since without touching it, as expected.crates/perry-runtime/src/regex.rsstill opensjs_regexp_newwithstring_as_str(pattern)/string_as_str(flags)and holds both&strborrows across the whole body.Worth linking explicitly: #7215 has now been closed (its
object_assign_string_sourceinstance is fixed by #7216's owned snapshot), so this is the last open instance of that borrow family — and the family argument in #7215 ("any caller that allocates between the decode and the last use of the view has this bug, and no static gate reaches it") is now carried by this issue alone. The suggested shape there — an owned copy up front, on a path already O(pattern length) — is the same one that worked for the sibling.proggeramlug commentedon Aug 3, 2026
Heads-up before anyone invests here — this issue is in a class that a planned architectural change would make unrepresentable, and I don't want someone building new machinery that gets deleted.
See
docs/src/internals/rfc-rooting-by-construction.md("Where this sits in the one GC correctness plan", landed in #7292). Short version: this is layer 2 — a GC-managed pointer live in emitted machine code across a point where the collector can run. Statepoints/stack maps (#7108, #7174, enabled by #7241) would have LLVM track that liveness precisely, so the whole shape stops being expressible.This is not "don't fix it". The architectural change is weeks away, the bug is real now, and correctness comes first — please do fix it.
It is "fix the instance, don't build new mechanism." Specifically:
RuntimeHandleScope/GcSuppressScopeat the site: good, lands now, survives.Also worth knowing when you verify:
PERRY_GC_MOVING_LOOP_POLLS=1set at run time against a default-compiled binary is clean 10/10 and proves nothing — the poll codegen isn't in the binary. Compile-time and run-time env must both be set (#7280). And a green run that performed zero copying minors proves nothing either (#7255) — assertcopied_objects > 0.proggeramlug commentedon Aug 9, 2026
Re-verified on
main=d1aa968e9(v0.5.1393): this reproduces GREEN. No fix PR — it was closed by the #7341-family work that landed since, and the residual borrow is latent rather than live. Evidence and the precise reason, so the next person does not have to re-derive it.The run
Built
-p perry -p perry-runtime-static -p perry-stdlib-static(perry-dev),PERRY_RUNTIME_DIRpinned to that build,PERRY_NO_AUTO_OPTIMIZE=1, no compile-time GC env — i.e. this issue's own recipe:Previously
exit=139, 10/10, at8b024958fand again atd4ac65f21.The arm is live, not inert (#7255): under
PERRY_GC_DIAG=1the same run reports evacuating copying minors doing real work —Why it is fixed
js_regexp_newnow opens with aRuntimeHandleScopeandroot_string_ptr(pattern), roots the canonical flags string it allocates, and re-reads both out of their handles aftergc_mallocbefore storing them into theRegExpHeader(crates/perry-runtime/src/regex.rs, the two ★ comments). That is #7341's work, and it closed the store-a-stale-pointer half.The
&strborrows are still there, and that is worth being precise aboutThe function still opens with
so #7215's borrow shape has not been removed. It is currently latent, not live, and the reason is exactly what the shape's family argument turns on — whether the span between the borrow and its last use contains a GC allocation point:
raw_flags_strdies atvalidate_and_canonicalize_flags, which is pure Rust;pattern_strlives untillet owned_pattern = pattern_str.to_string();, and everything in between — theREGEX_CACHEprobe,has_invalid_repeated_quantifier,compile_and_cache_regex_checked,get_or_compile_regex— allocates only through the Rust allocator (String,Vec<char>, theregex/fancy-regexprograms). None of those reachesgc_mallocor the arena, so none of them is a collection point;throw_regexp_syntax_errordoes allocate a GC error object, but itsformat!readspattern_strbefore the call and the call never returns;js_string_from_str(flags_str), comes after the owned copy, and the source already kills the borrow at that boundary with#[allow(unused_variables)] let pattern_str: () = ();.So the borrow is safe by an argument about what the callees allocate through, not by construction. Inserting any GC allocation into that span reopens it silently, and nothing gates that. If it is worth hardening, the cheap version is #7216's shape — take the owned copy up front rather than at the end — on a path that is already
O(pattern length).Closing as fixed-by-#7341. Reopen (or file the narrower "harden the span" item) if that span ever gains an allocation.
Related: the ten
regexp_receiverentries intest-parity/gc_repsel_triage.txtretargeted here can now be retired.