Skip to content

runtime: js_regexp_exec keeps the subject borrow (and its Captures) live across the result-array, capture-string and groups allocations #8449

Description

@proggeramlug

Follow-up to #8428 (fixed in #8446), same borrowed-heap-slice class as #8423.

Problem

#8446 closed the live window in js_regexp_exec — the one whose collection
point is user JS (ToLength(Get(R, "lastIndex")) running a coercible
lastIndex's valueOf). What it deliberately did not close is the
allocation-point window that follows.

After the match, crates/perry-runtime/src/regex/exec.rs keeps using str_data
(the &str into the subject's inline WTF-8 payload) and the regex::Captures /
fancy_regex::Captures borrowing it, across every one of these allocations:

  • crate::array::js_array_alloc(caps.len()) — the result array
  • js_string_from_str(m.as_str()) per capture — so capture N+1's as_str()
    reads a borrow that capture N's allocation could have invalidated
  • crate::object::js_object_alloc(0, 0) + js_object_set_field_by_name — the
    named-capture groups object
  • set_exec_array_metadata(arr, str_data, …) in the fancy arm — allocates the
    "index" key string, then copies input out of the borrow
  • set_exec_array_indices / set_exec_array_indices_fancy
    (regex/exec_array.rs) — these allocate an array and a [start, end] pair per
    capture, and call byte_index_to_utf16_index(str_data, …) between the
    allocations

crates/perry-runtime/src/regex/match_string.rs (js_string_match, the
non-global fancy arm and the standard non-global arm) has the identical shape.

Why this is latent rather than live

The only collection points in this window are the allocations themselves, and
gc_check_trigger's nursery-churn arm calls force_full_scan() unconditionally
since #7682 — a conservative stack scan makes the copying minor ineligible, so
nothing moves. That is the same "closed by accident" property string/alloc.rs
documents for #7213 and #8423's write-up spells out, and it is exactly the
property the moving-GC roadmap keeps eroding. These sites should not depend on it.

Suggested fix

The pattern already exists in this module: regex/match_all.rs's
materialize_match_all_results runs a Phase 1 (borrowing, no JS allocation)
snapshot into owned Rust data, then a Phase 2 that allocates only from the
snapshot (see OwnedMatchAllData and its doc comment, which names this exact
hazard from the 2026-07-09 audit wave 1). Give exec and js_string_match the
same two-phase shape.

Two sub-decisions worth measuring rather than guessing, which is why this is not
folded into #8446:

  1. Owned String per capture vs. byte ranges. match_all pays a
    to_string() per capture. exec is hotter (tokenizers, routers, ajv), so the
    cheaper shape is to snapshot Vec<Option<(byte_start, byte_len, utf16_len, flags)>>
    in phase 1 and build each capture with string::string_copy_range — which
    roots its source and re-reads after the allocation — for zero extra copies.
    Needs compute_utf16_len_wtf8 / bytes_have_lone_surrogate on the phase-1
    side so the WTF-8 flags stay exact.
  2. set_exec_array_indices{,_fancy} can be fixed in place without touching
    exec's structure: precompute every byte_index_to_utf16_index into a
    Vec<Option<(f64, f64)>> before the first js_array_alloc, then allocate
    from that. Self-contained and worth doing first.

Also fold in: the fancy arm's .input (set_exec_array_metadata) copies the
subject out of the borrow after allocating the "index" key; the standard arm
already re-boxes the rooted subject via set_exec_array_metadata_groups_fresh.

Validation

  • The witness shape is gc::tests::runtime_roots::regexp_last_index (added in
    fix(runtime): coerce lastIndex before borrowing the regex subject #8446): plant the collection, assert the subject was live and moved, then
    assert the captures. For this issue the collection has to be injected at an
    allocation rather than in a callback — force_next_general_arena_alloc_slow
    • GcTriggerThresholdTestGuard::make_arena_trigger_due (see
      runtime_roots/string_slice.rs) with the alloc-point scan pinned off
      (ConservativeScanDisabledGuard), otherwise the minor is non-moving and the
      test is vacuous.
  • Sabotage-check it: the test must fail against the pre-fix code, or it is
    proving nothing.
  • Watch for a regression on regex-heavy workloads if option 1 lands as owned
    Strings.

Workflow

PR = code + tests + changelog.d/<PR>-<slug>.md; no version bump.

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