Repository navigation
GC: js_regexp_new holds &str borrows into movable StringHeaders across its whole body (residual #7217 allocation-point witness) #7247
Description
Activity
- added 5 commits that reference this issue
on 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:
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=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.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/GcSuppressScopeat 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=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.- A targeted root/
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: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, at8b024958fand again atd4ac65f21.The arm is live, not inert (#7255): under
PERRY_GC_DIAG=1the 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=ArenaBytesWhy 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
let pattern_str = if is_valid_ptr(pattern) { string_as_str(pattern) } else { "" }; let raw_flags_str = if is_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_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;- 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_receiverentries intest-parity/gc_repsel_triage.txtretargeted here can now be retired.
Summary
test_gap_gc_regexp_receiver_rootingstill exits 139 on every allocation-point arm, 10/10 deterministic, after #7217's fix landed. #7217 turned out to be the lazyglobalThisbootstrap — a different site — and closing it fixedtest_gap_gc_spread_accessor_rootingandtest_gap_gc_static_block_this_rootingbut left this one untouched (base and fixed bothexit=139, 10/10 each). So the tenregexp_receiverentries intest-parity/gc_repsel_triage.txtare retargeted here, not deleted.Reproduction
Clean on the safepoint route (
loop_polls) and on the shipped default, exactly as #7227 claimed.The site, named by #7196's quarantine
PERRY_GC_PROTECT_FROMSPACE=1faults at the stale dereference (build withPERRY_DEBUG_SYMBOLS=1for the frames):obj_type=3is a string, and the frame is theREGEX_CACHE.with(...)closure insidejs_regexp_new.Why
crates/perry-runtime/src/regex.rs:616opens withBoth are
&strborrows into the heapStringHeader's payload region, and they are held for the whole function — throughvalidate_and_canonicalize_flags, theREGEX_CACHEprobe (pattern_str.to_string(),flags_str.to_string()), the validation block,get_or_compile_regex, and theRegExpHeaderallocation. 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_sourceby taking one owned copy up front (let owned: String = s.to_string();). The&str-into-a-movable-StringHeaderpattern is the family, not the individual site:string_as_str/str_bytes_from_jsvalue/js_string_key_bytesall 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_strbefore the first allocation (js_regexp_newis alreadyO(pattern length)— it doespattern.chars().collect::<Vec<char>>()on a cache miss), or root the twoStringHeaders in aRuntimeHandleScopeand 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.Refs #7217, #7227, #7216, #7215, #7196.