Skip to content

GC: #7207's spread-accessor reproducer still SIGSEGVs on main under the evac_minor arm (allocation-point relocation, not the safepoint route) #7217

Closed

Description

@proggeramlug

Summary

test-files/test_gap_gc_spread_accessor_rooting.ts — the reproducer #7207 added for #7200 — still SIGSEGVs on current main (1679e22b4) when the collection is forced at the allocation point rather than at a loop safepoint.

#7207 verified that fix on the PERRY_GC_MOVING_LOOP_POLLS=1 route, where it is clean 5/5. It is not clean on the evac_minor arm env.

Reproduction

$ export PERRY_RUNTIME_DIR=<main build>/release PERRY_NO_AUTO_OPTIMIZE=1
$ perry test-files/test_gap_gc_spread_accessor_rooting.ts -o /tmp/spread     # no compile-time env
$ PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off /tmp/spread
exit=139, no output

Compared against a build of PR #7216 on the same host under identical conditions: byte-identical, exit=139, output md5 d41d8cd9… on both. So this is main's behaviour, not #7216's, and #7216's A/B is what surfaced it.

test_gap_gc_static_block_this_rooting (the #7201 reproducer) reports bad 1 under the same env, also identically on both arms — the same story one notch quieter.

Why the two routes differ

gc_repsel_matrix.sh's own header states it: default and the polls route "relocate at a real safepoint (the JS stack has unwound, roots are precise by construction)", whereas the %E% arms "force it at the register-imprecise allocation point". #7207's fixes — the RuntimeHandleScope rooting inside js_object_assign_one and the codegen accumulator root — were reasoned about and verified against the safepoint route. An allocation-point collection inside the helper is a different window: it can fire between any two of the helper's own allocations, including ones inside object_assign_set_string_key's interning and keys-array growth, where #7207 re-reads its handles only at the top of each key iteration.

Caveat, stated because it changes what to do first

Both observations were made while a second agent's build was loading the box. In #7207's own idle --arms all --pressure 8 run this cell was PASS on evac_minor. So the failure is load-dependent, and the first step is an idle reproduction, not a fix — a load-dependent SIGSEGV that disappears when idle is still a real defect (it is the classic #7154 timing signature), but the mechanism should be pinned before anyone changes rooting code on the strength of it.

If it reproduces idle, the natural next instrument is #7196's PERRY_GC_PROTECT_FROMSPACE quarantine, which is built to fault at the stale dereference rather than one cycle later.

Why it matters

This is a residual of #7200, which #7207 reported as closed. It does not reopen the loop_polls result — that remains clean 5/5 — but it means the #7161 revert readiness statement should count the allocation-point route as unverified for the object-spread path, not merely the sites #7208/#7209/#7210 name.

Refs #7207, #7200, #7154, #7161, #7216, #7196.

Activity

  1. proggeramlug commented on Aug 2, 2026

    @proggeramlug
    ContributorAuthor

    Idle verdict: CONFIRMED, and it is not load-dependent — it is deterministic

    Mac mini perry-macos.local, idle (load 1.78/1.96/2.45, no builds, no other agent), main 7d1dc9ca2, compiled with no compile-time GC env:

    $ for i in $(seq 1 10); do PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 \
        PERRY_CONSERVATIVE_STACK_SCAN=off /tmp/spread7217; echo "rc=$?"; done
    run1..run10  rc=139  md5=d41d8cd9…  (empty output)
    

    10/10. The "observed while a second agent's build was loading the box" caveat can be retired: load has nothing to do with it.

    Which knob does it

    Bisecting the %E% triple, same binary, same host:

    env result
    HEAP_LIMIT=8 INCREMENTAL=0 CONSERVATIVE_STACK_SCAN=off exit 139
    HEAP_LIMIT=8 INCREMENTAL=0 bad plain 0 hot 0 tail 0
    CONSERVATIVE_STACK_SCAN=off clean
    HEAP_LIMIT=8 clean
    INCREMENTAL=0 clean

    PERRY_CONSERVATIVE_STACK_SCAN=off is the discriminator, and the mechanism is explicit in the source: conservative_stack_scan_mode() (gc/roots/scan_mode.rs:124-160) lets an env value beat the pinned override, so it defeats the ManualGcScanGuard::force_full_scan() that the alloc-point arm installs (gc/policy.rs:1496-1499). With the scan gone the alloc-point direct minor becomes precise-rooted, and therefore copying. That is the arm the matrix header itself calls "the register-imprecise allocation point", adding "only the first is a configuration anyone ships."

    So the two routes are not two chances to catch the same bug — they are two different contracts. Under the shipped default (and after a #7161 revert), the only moving collection points reachable from generated code are js_gc_loop_safepoint back-edge polls and the microtask-pump safepoint; a runtime helper's own allocation either defers (GC_SAFEPOINT_PENDING, policy.rs:1420-1467) or collects non-moving behind the forced scan. %E% removes that guarantee.

    Under #7196's quarantine, and with #7226's lens

    PERRY_GC_PROTECT_FROMSPACE=1 faults at the stale dereference:

    [gc-fromspace-protect] FAULT: signal 10 at 0x324a579fc60
      block=0x324a56a0000 +1047648 retired_bytes=1048536 retired_by_minor=#0
      last-known object: user_ptr=0x324a579fc68 obj_type=2 size=168
    

    retired_by_minor=#0 — and per #7226's table, 10/10 determinism plus "goes bad at collection #0" is evidence against a stale register and for a cache/table.

    Minimising the reproducer supports that. The accessor is not required:

    program evac_minor + PROTECT_FROMSPACE
    churn() alone, no spread clean clean
    { ...src, tail: 7 }, no accessor correct output (n 4800) FAULT, obj_type=2 size=72
    { ...src, tail: 7 } + accessor correct output FAULT, obj_type=4 size=24

    The plain-spread case gives the right answer while holding a retired from-space address — masked exactly as #7202's inline-ctor case was, by the forwarding record still living in the retired block. So the accessor is an amplifier, not the cause, and the search should start from what the spread path caches, not from the getter window. js_string_key_bytes in object_assign_set_string_key (object/alloc.rs:1115-1120) hands out a slice borrowed into a StringHeader — the #7215 shape — which is a candidate worth checking first.

    Suggested next step: re-run this bisection on top of #7226 before changing any rooting code, since two of its three fixes are in exactly this "runtime-side cache" family.

  2. proggeramlug commented on Aug 2, 2026

    @proggeramlug
    ContributorAuthor

    This is a route, not a site — two independent files now show the identical signature, and it deserves its own framing.

    Measured while registering previously-unregistered witnesses (#7228):

    witness allocation-point arms loop_polls (safepoint route)
    test_gap_gc_assign_string_source_rooting (#7216's) bad char 2 count 2 deterministic 5/5, all ten arms clean
    test_gap_gc_regexp_receiver_rooting (#7227's) exit=139 SIGSEGV deterministic 8/8, all ten arms clean 8/8
    test_gap_gc_spread_accessor_rooting (#7207's, this issue) SIGSEGV clean

    Three fixes, each verified green on the safepoint route, each failing when the collection is forced inside the allocating helper. That is not three coincidences — the allocation-point route has a property the safepoint route does not, and every fix verified only against loop_polls is unverified against it.

    Two consequences:

    1. Verification standard: a rooting fix is not "done" on loop_polls alone. The allocation-point arms need to be part of the acceptance criteria for this bug family, or we will keep shipping fixes that hold on one route and fail on the other.
    2. This issue should either own the route explicitly (retitle/rescope to "the allocation-point route defeats fixes verified at safepoints", with the three witnesses as its evidence) or a new issue should, with GC: #7207's spread-accessor reproducer still SIGSEGVs on main under the evac_minor arm (allocation-point relocation, not the safepoint route) #7217 reduced to the spread case. Whoever takes it: the question to answer first is what differs about a collection initiated inside the allocating helper versus at a back-edge poll — the answer probably explains all three at once.

    Note these three witnesses are deterministic (5/5, 8/8), which per #7226's heuristic is evidence against a stale-register window and for something structural about the route.

  3. proggeramlug commented on Aug 2, 2026

    @proggeramlug
    ContributorAuthor

    Answered, and the answer is not in the spread path at all

    PR #7249. The question the second comment asked —

    what differs about a collection initiated inside the allocating helper versus at a back-edge poll — the answer probably explains all three at once

    — has an answer, and it explains two of the three, not by a property of js_object_assign_one but by relocating the search.

    The failing collection was minor #0 landing inside the lazy globalThis bootstrap. js_get_global_this() builds the whole realm on first use, and here it is reached lazily from js_object_set_field_by_name → object_prototype_addr_matches → js_get_global_this_builtin_value — an ordinary property write several hundred iterations into the loop, after ~8 MB of churn. The bootstrap then allocates ~1.15 MB of its own in one go, so under PERRY_GC_HEAP_LIMIT=8 the program's very first collection happens in the middle of it. #6982 rooted the singleton — one pointer — and that is all it rooted; intl::install_constructor threads ctor, proto and ns_obj as bare *mut ObjectHeader locals across dozens of allocating installs, and so do the error, typed-array, generator, Reflect, Atomics and WebAssembly installers.

    #7196's quarantine names it without inference (build with PERRY_DEBUG_SYMBOLS=1):

    [gc-fromspace-protect] FAULT: signal 10 at 0x478b0f8fc60
      block=0x478b0e90000 +1047648 retired_bytes=1048536 retired_by_minor=#0
      last-known object: user_ptr=0x478b0f8fc68 obj_type=2 size=168
    2  descriptor_state::set_builtin_property_attrs
    3  intl::install_function + 576
    4  intl::install::install_constructor + 564
    5  intl::install_intl_namespace + 1828
    6  global_this::populate::populate_global_this_builtins + 13768
    7  js_get_global_this + 220
    8  js_get_global_this_builtin_value + 72
    9  array::indexing::object_prototype_addr_matches + 156
    10 js_object_set_field_by_name + 1004
    11 …AnonShape_…_constructor + 208
    

    Confirmed before writing any code, which is the check worth copying: adding one line — const __warm = typeof (globalThis as any).Intl; — at the top of the unmodified reproducer, so the bootstrap runs while the arena is nearly empty, makes it clean 5/5 on the allocation-point arm with 6 copying minors and 4 613–5 797 objects copied each. The spread path was never the bug.

    The route difference, stated generally

    A back-edge poll fires only while user JS is running, and the bootstrap runs no user JS, so on the safepoint route not one of those installer locals is ever live across a collection. On the allocation-point route the bootstrap's own allocations are the collection points and the whole graph is exposed at once.

    So loop_polls cannot expose an unrooted local in any runtime code that does not re-enter user JS — which is most of the runtime. The two routes are not two chances to catch the same bug, and #7207's fixes were not three flawed fixes: two of the three were failing on a collection in code they had never touched.

    Results

    Same host, idle, --profile perry-dev, 10 runs per cell, base rebuilt bit-identically for the A/B (perry md5 6142b49f… both times vs f1f002f8… fixed):

    witness base 8b024958f #7249
    test_gap_gc_spread_accessor_rooting exit=139 10/10 clean 10/10, and PERRY_GC_PROTECT_FROMSPACE=1 now reports no fault at all
    test_gap_gc_static_block_this_rooting bad 1 10/10 bad 0 10/10
    test_gap_gc_assign_string_source_rooting bad char 3 count 3 bad char 1 count 1 — residual is #7248
    test_gap_gc_regexp_receiver_rooting exit=139 unchanged — #7247

    The other two witnesses are separate sites, now localized: #7247 (js_regexp_new holds &str borrows into a movable StringHeader across its whole body — the #7215 shape) and #7248 (a stale js_eq left operand in assign_string_source's own assertion, with the IR — so that file's triage text, which blamed js_object_assign_one's interning, was wrong). Their twenty triage entries are retargeted rather than deleted. #7251 tracks the two sibling lazy intrinsic-tower builders, whose windows were written and then dropped because three successive gate designs for them passed with the window deleted.

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