Skip to content

GC: js_regexp_new holds &str borrows into movable StringHeaders across its whole body (residual #7217 allocation-point witness) #7247

Description

@proggeramlug

Summary

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.

The site, named by #7196's quarantine

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 = if is_valid_ptr(pattern) { string_as_str(pattern) } else { "" };
let raw_flags_str = if is_valid_ptr(flags) { string_as_str(flags) } else { "" };

Both are &str borrows 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.

Refs #7217, #7227, #7216, #7215, #7196.

Activity

  1. proggeramlug commented on Aug 2, 2026

    @proggeramlug
    ContributorAuthor

    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.

  2. proggeramlug commented on Aug 3, 2026

    @proggeramlug
    ContributorAuthor

    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.

  3. proggeramlug commented on Aug 9, 2026

    @proggeramlug
    ContributorAuthor

    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 = 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_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_str before 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions