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: #7207's spread-accessor reproducer still SIGSEGVs on main under the evac_minor arm (allocation-point relocation, not the safepoint route) #7217
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.
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.
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.
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:
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.
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.
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):
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):
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.
Activity
proggeramlug commentedon Aug 2, 2026
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),main7d1dc9ca2, compiled with no compile-time GC env: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:HEAP_LIMIT=8 INCREMENTAL=0 CONSERVATIVE_STACK_SCAN=offHEAP_LIMIT=8 INCREMENTAL=0bad plain 0 hot 0 tail 0CONSERVATIVE_STACK_SCAN=offHEAP_LIMIT=8INCREMENTAL=0PERRY_CONSERVATIVE_STACK_SCAN=offis 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 theManualGcScanGuard::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_safepointback-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=1faults at the stale dereference: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:
churn()alone, no spread{ ...src, tail: 7 }, no accessorn 4800)obj_type=2 size=72{ ...src, tail: 7 }+ accessorobj_type=4 size=24The 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_bytesinobject_assign_set_string_key(object/alloc.rs:1115-1120) hands out a slice borrowed into aStringHeader— 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.
test(gc): register the regexp witness, and triage its allocation-poin…
ci(gc): make the moving-GC stale-root witnesses able to fail (+ #7205) (
proggeramlug commentedon Aug 2, 2026
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):
loop_polls(safepoint route)test_gap_gc_assign_string_source_rooting(#7216's)bad char 2 count 2deterministic 5/5, all ten armstest_gap_gc_regexp_receiver_rooting(#7227's)test_gap_gc_spread_accessor_rooting(#7207's, this issue)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_pollsis unverified against it.Two consequences:
loop_pollsalone. 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.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.
fix(gc): root the regexp receiver across ToString, and audit ALLOC_RE…
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)
proggeramlug commentedon Aug 2, 2026
Answered, and the answer is not in the spread path at all
PR #7249. The question the second comment asked —
— has an answer, and it explains two of the three, not by a property of
js_object_assign_onebut by relocating the search.The failing collection was minor #0 landing inside the lazy
globalThisbootstrap.js_get_global_this()builds the whole realm on first use, and here it is reached lazily fromjs_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 underPERRY_GC_HEAP_LIMIT=8the 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_constructorthreadsctor,protoandns_objas bare*mut ObjectHeaderlocals 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):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_pollscannot 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 (perrymd56142b49f…both times vsf1f002f8…fixed):8b024958ftest_gap_gc_spread_accessor_rootingPERRY_GC_PROTECT_FROMSPACE=1now reports no fault at alltest_gap_gc_static_block_this_rootingbad 110/10bad 010/10test_gap_gc_assign_string_source_rootingbad char 3 count 3bad char 1 count 1— residual is #7248test_gap_gc_regexp_receiver_rootingThe other two witnesses are separate sites, now localized: #7247 (
js_regexp_newholds&strborrows into a movableStringHeaderacross its whole body — the #7215 shape) and #7248 (a stalejs_eqleft operand inassign_string_source's own assertion, with the IR — so that file's triage text, which blamedjs_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.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
gc: no-move window for the two lazy intrinsic-tower builders (#7251)
gc: no-move window for lazy intrinsic towers (#7251) + CI arm for ZEA…