diff --git a/changelog.d/7249-realm-bootstrap-no-move-window.md b/changelog.d/7249-realm-bootstrap-no-move-window.md new file mode 100644 index 0000000000..56070d524a --- /dev/null +++ b/changelog.d/7249-realm-bootstrap-no-move-window.md @@ -0,0 +1,139 @@ +### Fixed + +- **GC: the lazy `globalThis` bootstrap now runs in a no-move window (#7217).** + `test_gap_gc_spread_accessor_rooting` — #7207's reproducer for #7200 — + SIGSEGV'd 10/10 deterministically under + `PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off` + (the allocation-point route) months after three separate rooting fixes had + been verified green on the safepoint route. **The failing collection was not + in the code any of those fixes touched.** + + `js_get_global_this()` builds the whole realm on first use, and it is reached + *lazily* — in this program from `js_object_set_field_by_name` → + `object_prototype_addr_matches` → `js_get_global_this_builtin_value`, i.e. + from an ordinary property write several hundred loop iterations in, after + ~8 MB of churn. The bootstrap then allocates ~1.15 MB of its own, so under an + 8 MB heap limit **minor #0 lands in the middle of it**. #6982 rooted the + `globalThis` singleton — one pointer. The bootstrap builds a *graph*: + `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, + across a dozen files. Every one is a slot the collector does not rewrite. + + `PERRY_GC_PROTECT_FROMSPACE=1` (#7196) named it without inference: + `set_builtin_property_attrs` ← `intl::install_function` ← + `install_constructor` ← `install_intl_namespace` ← + `populate_global_this_builtins` ← `js_get_global_this`, on an address with + `retired_by_minor=#0`. Confirmed before any code changed: adding + `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 with 6 copying minors and 4 613–5 797 objects + copied each. + + **Why the safepoint route could not see it, which is the general finding.** A + loop back-edge poll fires only while user JS is running, and the bootstrap + runs no user JS, so none of those locals is ever live across a collection + there. On the allocation-point route the bootstrap's *own* allocations are the + collection points, so the entire graph is exposed at once. The two routes are + not two chances to catch the same bug: `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 invariant: a bootstrap that builds an IMMORTAL object graph through raw + pointers held across its own allocations must run in a NO-MOVE WINDOW.** + Rooting each holder individually is unbounded (hundreds of sites) and + ungateable — `scripts/gc_root_dominance_check.py` reads emitted LLVM IR and is + structurally blind to all of them. The window is one line and provably enough, + and it costs nothing a collection would have recovered: every object born in + it is reachable from `globalThis` for the life of the thread. + + The fix is one line: `crate::gc::GcSuppressScope` (the existing nesting-safe + RAII no-move window, already used by `descriptor_state.rs`) at the top of + `populate_global_this_builtins`. `GC_FLAG_SUPPRESSED` gates + `gc_check_trigger`, the budgeted stepper **and** `gc_safepoint_moving_minor`, + so the window is comprehensive rather than allocation-point-only. No + installer's rooting was touched — adding a `RuntimeHandleScope` to one of + fifty installers would imply the other forty-nine are fine. No env knob is + added and no collector behaviour changes anywhere else. + + Measured on the allocation-point arm, same host, idle, one target dir, 10 runs + per cell. The base arm was produced by reverting the source change and + rebuilding, and came back **bit-identical** to the pre-change build + (`perry` md5 `6142b49f…` both times) against `068af604…` for the fix, so the + two arms are demonstrably different binaries. Every row was then re-run + against the final shipped tree (`f1f002f8…`, after the two #7251 windows were + dropped) and is unchanged: + + | witness | base `8b024958f` | fixed | + |---|---|---| + | `test_gap_gc_spread_accessor_rooting` | **exit=139, no output, 10/10** | **`bad plain 0 hot 0 tail 0` 10/10** | + | `test_gap_gc_static_block_this_rooting` | `bad 1` 10/10 | **`bad 0` 10/10** | + | `test_gap_gc_inline_ctor_this_rooting` | green 10/10 | green 10/10 | + + `loop_polls` (compiled **and** run with `PERRY_GC_MOVING_LOOP_POLLS=1` plus + `PERRY_GC_FORCE_EVACUATE=1`): all five `test_gap_gc_*_rooting` witnesses green + 5/5, and all five still relocate (1–5 cycles, 224–26 063 objects copied), so + none went inert. Shipped default: clean 3/3, byte-exact against + `node --experimental-strip-types`. `PERRY_GC_PROTECT_FROMSPACE=1` on the + reproducer now reports **no fault at all**. The static + `gc_root_dominance_check.py` reports 0 violations before *and* after, in both + its default and `--unrooted-allocas` modes — it reads emitted LLVM IR, so it + is structurally blind to a bug that lives in the runtime's Rust locals, which + is worth recording as a limit of that gate rather than as a clean bill. + + **The window defers a collection, it does not add one.** On + `console.log("hi", typeof globalThis.Intl)`, peak RSS drops from + 10 878 976 / 10 895 360 / 10 878 976 bytes to + 10 649 600 / 10 649 600 / 10 633 216, and GC cycles at `HEAP_LIMIT=8` go from + 2 to 1 — the collection that used to run mid-bootstrap copied the bootstrap's + own live set and then had all of it survive anyway. + +### Testing + +- **`crates/perry-runtime/src/gc/tests/global_bootstrap.rs`** — a `--lib` unit + test, so it runs in the per-PR `cargo-test` gate rather than in a + nightly-only `tests/*.rs` suite. It arms **one** pending collection, runs + the bootstrap, and asserts it was not serviced, that the request is + **deferred rather than dropped**, that the window spans at least one arena + block (so `arena_alloc_gc` genuinely reached `gc_check_trigger` inside it), + and that the window closed. Each then runs **the control**: the *same* armed + request, on the *same* thread, must be serviced by ordinary allocation once + the window is over. Without that second half the test would pass on a tree + where nothing was ever due — CLAUDE.md's fourth way a gate cannot fail. + Sabotage-checked in the failing direction: removing the `GcSuppressScope` + reddens it with `left: 1, right: 0` and the message naming the installer + locals. + +### Known issues + +- Two of the five `test_gap_gc_*_rooting` witnesses remain red on the + allocation-point route for **unrelated** reasons, and their twenty + `test-parity/gc_repsel_triage.txt` entries are retargeted rather than deleted + — one of the two triage texts was asserting a cause now known to be wrong. + Both are green on `loop_polls`, which `gc-moving-witnesses.yml` gates. + - **#7247** — `test_gap_gc_regexp_receiver_rooting`, unchanged (exit=139 10/10 + on both arms). `js_regexp_new` holds `string_as_str(pattern)` / + `string_as_str(flags)` — `&str` borrows into a movable `StringHeader` + payload — across its whole body. The #7215 borrow shape. + - **#7248** — `test_gap_gc_assign_string_source_rooting`, improved from + `bad char 3 count 3` to `bad char 1 count 1` (10/10 each). The residual + failure is a stale `js_eq` left operand in the test's own + `got !== ALPHA[i % 26]` assertion — a register loaded above the allocating + `js_string_index_get_boxed` sibling and never re-read — not anything in + `js_object_assign_one`. The #7206/#7214 operand family. +- **#7251** — the same defect shape exists in `ensure_generator_intrinsics` and + `ensure_typed_array_intrinsic`, which build the same kind of immortal tower + through the same kind of raw locals and are *also* reachable lazily ahead of + the bootstrap. Windows for them were written and then **deliberately dropped + from this PR**: a tower is three orders of magnitude smaller than the + bootstrap, fits inside one arena block's tail, and so may reach no + `gc_check_trigger` at all — three successive versions of a gate for them + passed with the window deleted. Shipping a GC-trigger change with no test that + can fail without it is the thing CLAUDE.md's knob-kill policy exists to stop, + so the exposure is tracked instead, with the two candidate gate designs and an + unexplained observation (something may already be suppressing across part of + the tower build) written up in the issue. +- **#7154's `sfw-registry --help` symptom was not run** (the workload is not + present on this machine) and is **not** claimed resolved. What is measured is + that the five witnesses are green in the configuration a #7161 revert would + ship. diff --git a/crates/perry-runtime/src/gc/tests/global_bootstrap.rs b/crates/perry-runtime/src/gc/tests/global_bootstrap.rs new file mode 100644 index 0000000000..1817fff427 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/global_bootstrap.rs @@ -0,0 +1,157 @@ +//! #7217: the lazy `globalThis` bootstrap must run in a NO-MOVE WINDOW. +//! +//! `populate_global_this_builtins` constructs an IMMORTAL object graph — +//! everything it allocates is reachable from `globalThis` for the life of the +//! thread. It constructs it by threading raw `*mut ObjectHeader` / +//! `*mut ClosureHeader` locals through several hundred installs in a dozen +//! installer modules, each of which allocates. Those locals are slots the +//! collector does not rewrite, so a *relocating* collection inside the window +//! leaves the rest of the bootstrap writing into from-space. +//! +//! This was invisible on the safepoint route (`PERRY_GC_MOVING_LOOP_POLLS=1`): +//! a back-edge poll only fires while user JS runs, and the bootstrap runs no +//! user JS. It is reachable on the allocation-point route, where the +//! bootstrap's own block allocations are the collection points — which is why +//! `test_gap_gc_spread_accessor_rooting` still SIGSEGV'd 10/10 under +//! `PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 +//! PERRY_CONSERVATIVE_STACK_SCAN=off` after three rooting fixes that were all +//! green at safepoints. +//! +//! ***BOTH HALVES ARE ASSERTED*** (CLAUDE.md's fourth way a gate cannot fail). +//! A test that only checked "no collection ran during the bootstrap" would pass +//! on a tree where nothing was ever due. The test below therefore arms ONE +//! pending collection, shows the bootstrap did not service it, and then shows +//! that the SAME armed request is serviced by ordinary allocation once the +//! window has closed. Same thread, same lever, same magnitude — the only +//! variable is whether the allocations happened inside the window. +//! +//! SCOPE. Only `populate_global_this_builtins` is covered, and deliberately so. +//! `ensure_generator_intrinsics` / `ensure_typed_array_intrinsic` build the same +//! shape of immortal graph through the same kind of raw locals and are ALSO +//! reachable lazily, ahead of the bootstrap — but a tower is three orders of +//! magnitude smaller than the bootstrap, fits inside one arena block's tail, and +//! so may reach no `gc_check_trigger` at all. A version of this file that armed a +//! collection around them PASSED with their windows deleted. Rather than ship a +//! GC-trigger change with no test that can fail without it — the exact thing +//! CLAUDE.md's knob-kill policy exists to stop — those two windows were dropped +//! and the exposure is tracked separately. + +use super::super::*; +use super::support::*; + +/// Run `body` on a thread that has never touched `globalThis`, so the lazy +/// bootstrap really runs instead of returning the per-thread cache hit. +/// `THREAD_GLOBAL_THIS`, the arena, `GC_STATS` and `GC_OLD_RECLAIM_PENDING` are +/// all thread-local, so the arming and the measurement stay on this thread. +fn on_a_fresh_thread(body: impl FnOnce() + Send + 'static) { + std::thread::Builder::new() + .stack_size(16 << 20) + .spawn(body) + .expect("spawn bootstrap test thread") + .join() + .expect("bootstrap test thread panicked"); +} + +/// Make one collection due at the very next `gc_check_trigger()` — which +/// `arena_alloc_gc` calls every time the current block fills. This is the same +/// lever `scan_fallback.rs` uses, and it completes synchronously on the +/// allocation-point arm rather than deferring to a safepoint. +fn arm_one_pending_collection() { + GC_OLD_RECLAIM_PENDING.with(|pending| pending.set(true)); +} + +fn pending_collection_still_owed() -> bool { + GC_OLD_RECLAIM_PENDING.with(std::cell::Cell::get) +} + +fn clear_pending_collection() { + GC_OLD_RECLAIM_PENDING.with(|pending| pending.set(false)); + GC_SAFEPOINT_PENDING.with(|pending| pending.set(false)); + let old_in_use = crate::arena::old_gen_in_use_bytes(); + GC_LAST_OLD_RECLAIM_IN_USE_BYTES.with(|bytes| bytes.set(old_in_use)); +} + +/// THE CONTROL. Allocate ordinary young objects — nothing rooted, nothing +/// exotic — until either the armed collection is serviced or two arena blocks +/// have been consumed without it. Returns whether it was serviced. +/// +/// Two blocks is deliberately more than the bootstrap window spans (measured: +/// ~1.15 MB, i.e. just over one 1 MB block), so a `false` here means the +/// arming is inert on this thread and the subject assertion above it proved +/// nothing. +fn ordinary_allocation_services_the_armed_collection(collections_before: u64) -> bool { + let arena_before = crate::arena::arena_total_bytes(); + for _ in 0..500_000 { + let _ = young_leaf(); + if gc_collection_count() > collections_before { + return true; + } + if crate::arena::arena_total_bytes() >= arena_before + (2 << 20) { + return false; + } + } + false +} + +#[test] +fn global_this_bootstrap_runs_in_a_no_move_window() { + on_a_fresh_thread(|| { + // Pin the shipped pacing (#7161 flipped `PERRY_GC_MOVING_LOOP_POLLS` + // off) so this asserts against a declared mode rather than whatever the + // process-wide OnceLock happened to resolve to. + let _pacing = crate::gc::policy::force_legacy_gc_pacing(); + crate::gc::ensure_gc_initialized(); + clear_pending_collection(); + + arm_one_pending_collection(); + let arena_before = crate::arena::arena_total_bytes(); + let collections_before = gc_collection_count(); + + // THE SUBJECT: the one-shot realm bootstrap. + let global = crate::object::js_get_global_this(); + assert!( + crate::value::JSValue::from_bits(global.to_bits()).is_pointer(), + "js_get_global_this must return a real singleton, else the \ + bootstrap never ran and this test measured nothing" + ); + + let arena_after = crate::arena::arena_total_bytes(); + let collections_after = gc_collection_count(); + + // LIVE SUBJECT, half 1: the window really did span a block boundary, so + // `arena_alloc_gc` really did reach `gc_check_trigger()` inside it. + assert!( + arena_after >= arena_before + (1 << 20), + "the bootstrap must consume at least one arena block for this test \ + to say anything (before={arena_before} after={arena_after})" + ); + // THE INVARIANT: nothing collected, and therefore nothing moved, while + // the installers held raw pointers. + assert_eq!( + collections_after, collections_before, + "a collection ran inside the globalThis bootstrap — every installer \ + local (`ctor`, `proto`, `ns_obj`, …) is now a from-space address" + ); + assert!( + pending_collection_still_owed(), + "the window must DEFER the request, not drop it: leaving it \ + unserviced-and-unset would disable the trigger for the rest of \ + the thread" + ); + assert!( + !crate::gc::gc_is_suppressed(), + "the no-move window must close when the bootstrap returns" + ); + + // LIVE SUBJECT, half 2 — THE CONTROL. Same thread, same armed request, + // ordinary allocation. If this does not collect, the arming was inert + // and the assertion above is vacuous. + assert!( + ordinary_allocation_services_the_armed_collection(collections_after), + "the armed collection was never serviceable on this thread, so \ + 'the bootstrap did not collect' proved nothing" + ); + + clear_pending_collection(); + }); +} diff --git a/crates/perry-runtime/src/gc/tests/mod.rs b/crates/perry-runtime/src/gc/tests/mod.rs index 6da4238116..df97420c65 100644 --- a/crates/perry-runtime/src/gc/tests/mod.rs +++ b/crates/perry-runtime/src/gc/tests/mod.rs @@ -13,6 +13,7 @@ mod error_side_tables; mod evacuation; mod fromspace_protect; mod fromspace_scan; +mod global_bootstrap; mod helper_stores; mod host_safepoints; mod incremental_sweep_reclaim; diff --git a/crates/perry-runtime/src/object/global_this/populate.rs b/crates/perry-runtime/src/object/global_this/populate.rs index f615cd9a19..b2184b5c4a 100644 --- a/crates/perry-runtime/src/object/global_this/populate.rs +++ b/crates/perry-runtime/src/object/global_this/populate.rs @@ -40,6 +40,43 @@ pub(crate) fn populate_global_this_builtins(singleton_at_entry: *mut ObjectHeade // Only reachable when the conservative native-stack scan is off, which is // production's `Auto -> SkipDisabled` resolution; the scan was masking this // by pinning the argument register. + // + // #7217: rooting the singleton is necessary but NOT sufficient, and the + // difference is the whole point of the allocation-point route. + // + // The singleton is one pointer. The bootstrap it drives is a *graph*: + // `install_intl_namespace` -> `install_constructor` -> `install_function` + // holds `ctor`, `proto` and `ns_obj` as raw `*mut ObjectHeader` locals + // across dozens of allocating installs, and so do the error, typed-array, + // generator, Reflect, Atomics, WebAssembly, … installers, in a dozen files + // and several hundred call sites. Every one of those is a slot the + // collector does not rewrite. + // + // On the SAFEPOINT route none of them can be exposed: a collection reached + // from a loop back-edge poll only happens while user JS is running, and the + // bootstrap runs no user JS. On the ALLOCATION-POINT route every one of the + // bootstrap's own ~1.15 MB of allocations is a collection point, so the + // whole graph is exposed at once. That is why three separate rooting fixes + // verified green on `PERRY_GC_MOVING_LOOP_POLLS=1` were still red under + // `PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 + // PERRY_CONSERVATIVE_STACK_SCAN=off`: the collection they were failing on + // was not in the code they had fixed, it was minor #0 landing inside this + // bootstrap. `PERRY_GC_PROTECT_FROMSPACE=1` names it exactly — + // `set_builtin_property_attrs` <- `intl::install_function` <- + // `install_constructor` <- `install_intl_namespace` <- here, on an address + // `retired_by_minor=#0`. + // + // THE INVARIANT: a bootstrap that builds an IMMORTAL object graph through + // raw pointers held across its own allocations must run in a NO-MOVE + // WINDOW. Rooting each holder individually is unbounded (hundreds of sites + // across a dozen installer modules) and ungateable (no checker can prove + // the set complete), while the window is one line and provably enough. It + // costs nothing a collection would have recovered: every object born here + // is reachable from `globalThis` for the life of the process, so a + // collection inside the window frees nothing. Measured footprint of the + // whole window: ~1.15 MB allocated, ~410 KB of it live afterwards, once per + // thread. + let _no_move = crate::gc::GcSuppressScope::new(); let scope = crate::gc::RuntimeHandleScope::new(); let singleton_handle = scope.root_raw_mut_ptr(singleton_at_entry); let singleton = || singleton_handle.get_raw_mut_ptr::(); diff --git a/test-parity/gc_repsel_corpus.txt b/test-parity/gc_repsel_corpus.txt index 5c469558bd..1277c630ca 100644 --- a/test-parity/gc_repsel_corpus.txt +++ b/test-parity/gc_repsel_corpus.txt @@ -197,6 +197,19 @@ test_gap_repsel_gc_stress # inline_ctor_this_rooting green — see its own header; it is a STATIC # gate, pinned by the codegen test and by # `gc_root_dominance_check.py --unrooted-allocas` +# +# #7217 UPDATE: `spread_accessor_rooting` and `static_block_this_rooting` are now +# ALSO green on the ALLOCATION-POINT route, which they were not when #7207 +# landed. That route's failure was never in the code #7207 fixed: it was minor #0 +# landing inside the lazy `globalThis` bootstrap, which threads raw +# `*mut ObjectHeader` installer locals across its own ~1.15 MB of allocations. +# The bootstrap now runs in a no-move window. Measured on `origin/main` +# (8b024958f) vs this branch, same profile, same host, idle, 10 runs each, at +# `PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off` +# with NO compile-time GC env: +# spread_accessor_rooting exit=139 10/10 -> `plain 0 hot 0 tail 0` 10/10 +# static_block_this_rooting `bad 1` 10/10 -> `bad 0` 10/10 +# inline_ctor_this_rooting green 10/10 -> green 10/10 test_gap_gc_spread_accessor_rooting test_gap_gc_static_block_this_rooting test_gap_gc_inline_ctor_this_rooting @@ -292,6 +305,13 @@ test_gap_gc_closure_call_argument_rooting # Measured on this commit (7d1dc9ca2), `--arms loop_polls --filter test_gap_gc_`: # both PASS with copy-minor > 0, i.e. they relocate and they are byte-exact. # Also measured on the `gc-stress` PR arm set: PASS/UNVER only, no new red. +# +# #7217 UPDATE for `assign_string_source_rooting`: partly improved by #7217's +# no-move bootstrap window (`bad char 3 count 3` -> `bad char 1 count 1`, 10/10 +# each) but NOT green. The residual iteration is a stale `js_eq` left operand in +# the test's own `got !== ALPHA[i % 26]` assertion, not anything in +# `js_object_assign_one` — tracked as #7248 with the emitted IR; the triage +# entries are retargeted there. test_gap_gc_new_instance_rooting test_gap_gc_assign_string_source_rooting @@ -347,7 +367,13 @@ test_gap_gc_closure_call_prev_this_rooting # PASS on `loop_polls` 8/8 (`bad 0`, matching the oracle), and exit=139 on all # TEN allocation-point arms, also deterministic. See gc_repsel_triage.txt: the # fix in this PR is verified on the safepoint route and is NOT claimed on the -# allocation-point route, which is #7217's open defect class at a second site. +# allocation-point route. +# +# #7217 UPDATE: that class turned out to be TWO unrelated sites, not one. #7217 +# itself (the lazy `globalThis` bootstrap) is fixed and this file is UNCHANGED by +# it — exit=139 10/10 both before and after. Its own site is `js_regexp_new` +# holding `&str` borrows into a movable `StringHeader` across its whole body, +# tracked as #7247; the triage entries are retargeted there. test_gap_gc_regexp_receiver_rooting # --- #7210: argument staging buffers filled interleaved with lowering ------- # `setTimeout(cb, 0, {…}, churn())` kept the CALLBACK closure in a bare SSA diff --git a/test-parity/gc_repsel_triage.txt b/test-parity/gc_repsel_triage.txt index c7d2574114..4fdcc3ac8a 100644 --- a/test-parity/gc_repsel_triage.txt +++ b/test-parity/gc_repsel_triage.txt @@ -16,88 +16,83 @@ test_gap_repsel_ptr_shape_locals | rep_ptr_shape_off | #6976 -- REGRESSION IN THE REPRESENTATION'S OWN OFF-SWITCH, not a defect in this PR. Bisected: passes at 8327ced52, fails at 1a533a3a8 (#6925, repsel Phase 5a proven `this`). With PERRY_PTR_SHAPE_LOCALS=0 the program dies partway with `TypeError: Cannot read properties of undefined (reading 'area')`, losing its last five output lines. It was invisible until now because #6925 also left test_gap_repsel_proven_this_frozen.ts unregistered, which makes this script exit 3 before it runs anything -- the gate was dark, not green. REMOVE THIS ENTRY when #6976 is fixed; an OFF arm is supposed to be the safest cell in the matrix. -# --- #7216's Object.assign witness on the ALLOCATION-POINT arms (#7217) ------ -# `test_gap_gc_assign_string_source_rooting` shipped with #7216 but was never -# registered in the corpus, so it had never run anywhere. Registering it (see -# gc_repsel_corpus.txt) surfaced a pre-existing red on every arm that forces the -# collection at the register-imprecise ALLOCATION point (`%E%` without the -# compile-time `PERRY_GC_MOVING_LOOP_POLLS=1`). -# -# This is #7217, not a new defect and not a representation defect. #7217 already -# says the mechanism in words -- "an allocation-point collection inside the -# helper ... 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" -- and -# this file is that sentence's reproducer, and a sharper one than the -# `spread_accessor_rooting` case #7217 names -- that one is load-dependent and -# passed idle here, while this one reproduces on demand at the same env. -# -# Measured on 7d1dc9ca2, release, idle host, oracle node 26.5.1 -# (`bad char 0 count 0`): -# PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off -# -> `bad char 2 count 2`, 5/5 -# shipped default -> `bad char 0 count 0`, 3/3 -# compiled+run PERRY_GC_MOVING_LOOP_POLLS=1 (`loop_polls`) -> PASS, copy-minor > 0 -# -# EVERY %E% ARM IS LISTED, INCLUDING ONE THAT WAS GREEN WHEN FIRST SAMPLED. -# `rep_str_off` PASSED on the first `--arms all` sweep and FAILED on the third -- -# same binary, same idle host. The first read ("PERRY_CANONICAL_STR_LOCALS=0 -# makes it pass, so the borrow is in a canonical Str local") was an artefact of a -# single sample. The window is timing-sensitive within the allocation point and -# the repsel knobs are not discriminators, so the entries cover the arm class -# rather than the arms that happened to be red on one run. Anyone bisecting -# #7217 with this file should repeat each candidate before believing a green. -# -# THE FILE IS NOT DARK WHILE THESE ENTRIES EXIST. It is a hard gate on +# --- #7216's Object.assign witness on the ALLOCATION-POINT arms (#7248) ------ +# RETARGETED FROM #7217, AND ITS STATED CAUSE WAS WRONG. +# +# #7217 is fixed: the allocation-point failure it names was minor #0 landing +# inside the lazy `globalThis` bootstrap, whose installers thread raw +# `*mut ObjectHeader` locals across their own allocations. That bootstrap now +# runs in a no-move window. It fixed `spread_accessor_rooting` (exit=139 -> +# clean, 10/10) and `static_block_this_rooting` (`bad 1` -> `bad 0`, 10/10). +# +# It did NOT fix this file, and it moved the number rather than the verdict: +# +# merge-base 8b024958f, perry-dev, idle -> `bad char 3 count 3`, 10/10 +# with the #7217 no-move window -> `bad char 1 count 1`, 10/10 +# loop_polls (safepoint route) -> PASS, copy-minor > 0 +# shipped default -> `bad char 0 count 0` +# +# Two of the three bad iterations were the bootstrap. The residual one is NOT in +# `js_object_assign_one` / `object_assign_string_source` at all -- the old +# triage text asserting "allocation-point relocation inside js_object_assign_one +# ... interning and keys-array growth" is not what the quarantine shows. Under +# `PERRY_GC_PROTECT_FROMSPACE=1` the fault is in the test's OWN assertion: +# +# js_jsvalue_equals + 332 <- js_eq + 20 <- main obj_type=3 (string) +# retired_by_minor=#2 (not #0 -- which is why the bootstrap fix missed it) +# +# and the emitted IR names it exactly: `got` is loaded into a register above the +# allocating `js_string_index_get_boxed` lowering of `ALPHA[i % 26]` and handed +# to `js_eq` below it, never re-read. That is the #7206/#7214 codegen operand +# family, filed as #7248 with the IR. +# +# THE FILE IS NOT DARK WHILE THESE ENTRIES EXIST -- it is a hard gate on # `loop_polls`, which .github/workflows/gc-moving-witnesses.yml runs on every -# collector-touching PR and which requires it to relocate before it may pass. -# That arm was green 8/8 across these sweeps. -# -# DELETE ALL TEN when #7217 is fixed. An entry that matches nothing fails -# nothing here, but a stale triage is a gate quietly narrowed. -test_gap_gc_assign_string_source_rooting | evac_minor | #7217 -- allocation-point relocation inside js_object_assign_one / object_assign_string_source, pre-existing on main. Clean on the safepoint route (loop_polls) and on the shipped default. -test_gap_gc_assign_string_source_rooting | force_evac | #7217 -- same allocation-point window, with force-evacuate on top. -test_gap_gc_assign_string_source_rooting | force_verify | #7217 -- same allocation-point window, force + verify. -test_gap_gc_assign_string_source_rooting | rep_i32_off | #7217 -- %E% allocation-point window; the repsel knob is not the discriminator. -test_gap_gc_assign_string_source_rooting | rep_str_off | #7217 -- %E% allocation-point window. Green on the first sweep and red on the third, same binary and idle host: sampled, not fixed. -test_gap_gc_assign_string_source_rooting | rep_str_static_off | #7217 -- %E% allocation-point window; see rep_i32_off. -test_gap_gc_assign_string_source_rooting | rep_ptr_shape_off | #7217 -- %E% allocation-point window; see rep_i32_off. -test_gap_gc_assign_string_source_rooting | rep_ptr_numarray_off | #7217 -- %E% allocation-point window; see rep_i32_off. -test_gap_gc_assign_string_source_rooting | rep_spec_abi_off | #7217 -- %E% allocation-point window; see rep_i32_off. -test_gap_gc_assign_string_source_rooting | rep_int_valued_off | #7217 -- %E% allocation-point window; see rep_i32_off. -# --- The regexp receiver on the ALLOCATION-POINT arms (#7217 class) ---------- -# `test_gap_gc_regexp_receiver_rooting` is a HARD GATE on `loop_polls`, where the -# fix it ships with is claimed and verified: `bad 0`, 8/8, byte-exact against the -# oracle, with the copying minor live. These ten entries cover the arms where no -# fix is claimed -- the ones that force the collection at the register-imprecise -# ALLOCATION point (`%E%` without the compile-time PERRY_GC_MOVING_LOOP_POLLS=1) -# rather than at a loop safepoint. -# -# Measured on this branch, release, oracle node 26.5.1 (`bad 0`): -# PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off -# -> exit=139, no output, 8/8 deterministic -# compiled+run PERRY_GC_MOVING_LOOP_POLLS=1 -> `bad 0`, 8/8 +# collector-touching PR. +# +# DELETE ALL TEN when #7248 is fixed. +test_gap_gc_assign_string_source_rooting | evac_minor | #7248 -- stale `js_eq` left operand in the test's own assertion (was mis-attributed to #7217/js_object_assign_one). `bad char 1 count 1`, 10/10. Clean on the safepoint route (loop_polls) and on the shipped default. +test_gap_gc_assign_string_source_rooting | force_evac | #7248 -- same allocation-point window, with force-evacuate on top. +test_gap_gc_assign_string_source_rooting | force_verify | #7248 -- same allocation-point window, force + verify. +test_gap_gc_assign_string_source_rooting | rep_i32_off | #7248 -- %E% allocation-point window; the repsel knob is not the discriminator. +test_gap_gc_assign_string_source_rooting | rep_str_off | #7248 -- %E% allocation-point window. Green on the first sweep and red on the third, same binary and idle host: sampled, not fixed. +test_gap_gc_assign_string_source_rooting | rep_str_static_off | #7248 -- %E% allocation-point window; see rep_i32_off. +test_gap_gc_assign_string_source_rooting | rep_ptr_shape_off | #7248 -- %E% allocation-point window; see rep_i32_off. +test_gap_gc_assign_string_source_rooting | rep_ptr_numarray_off | #7248 -- %E% allocation-point window; see rep_i32_off. +test_gap_gc_assign_string_source_rooting | rep_spec_abi_off | #7248 -- %E% allocation-point window; see rep_i32_off. +test_gap_gc_assign_string_source_rooting | rep_int_valued_off | #7248 -- %E% allocation-point window; see rep_i32_off. +# --- The regexp receiver on the ALLOCATION-POINT arms (#7247) ---------------- +# RETARGETED FROM #7217. #7217 is fixed (the lazy `globalThis` bootstrap now runs +# in a no-move window) and this file is UNCHANGED by it: +# +# merge-base 8b024958f, perry-dev, idle -> exit=139, no output, 10/10 +# with the #7217 no-move window -> exit=139, no output, 10/10 +# compiled+run PERRY_GC_MOVING_LOOP_POLLS=1 -> `bad 0` # shipped default -> `bad 0` -# `--arms all` reports the SAME `exit=139 cycles=1 scavenged=14` on all ten, and -# PASS on `loop_polls`. The split is the route, not the arm's other knobs. -# -# THIS IS THE SECOND FILE WITH EXACTLY THIS SIGNATURE. #7216's -# `test_gap_gc_assign_string_source_rooting` behaves the same way and is triaged -# to #7217 for the same reason. Two independent sites where a rooting fix that -# holds at a safepoint does not hold when the collection is forced inside the -# allocating helper is a statement about the route, not about either fix -- and -# it is what #7217 says in words about `object_assign_set_string_key`'s interning -# and keys-array growth. Whoever closes #7217 should check this file too; if it -# turns out to need its own fix, split these entries onto their own issue. -# -# DELETE ALL TEN when the allocation-point route is fixed. -test_gap_gc_regexp_receiver_rooting | evac_minor | #7217 -- allocation-point relocation; the fix is verified on the safepoint route (loop_polls, 8/8) and not claimed here. exit=139, deterministic. -test_gap_gc_regexp_receiver_rooting | force_evac | #7217 -- same allocation-point window, with force-evacuate on top. -test_gap_gc_regexp_receiver_rooting | force_verify | #7217 -- same allocation-point window, force + verify. -test_gap_gc_regexp_receiver_rooting | rep_i32_off | #7217 -- %E% allocation-point window; the repsel knob is not the discriminator (identical evidence on all ten). -test_gap_gc_regexp_receiver_rooting | rep_str_off | #7217 -- %E% allocation-point window; see rep_i32_off. -test_gap_gc_regexp_receiver_rooting | rep_str_static_off | #7217 -- %E% allocation-point window; see rep_i32_off. -test_gap_gc_regexp_receiver_rooting | rep_ptr_shape_off | #7217 -- %E% allocation-point window; see rep_i32_off. -test_gap_gc_regexp_receiver_rooting | rep_ptr_numarray_off | #7217 -- %E% allocation-point window; see rep_i32_off. -test_gap_gc_regexp_receiver_rooting | rep_spec_abi_off | #7217 -- %E% allocation-point window; see rep_i32_off. -test_gap_gc_regexp_receiver_rooting | rep_int_valued_off | #7217 -- %E% allocation-point window; see rep_i32_off. +# +# It is a separate site, and #7196's quarantine names it: +# +# LocalKey>>>::with +# <- js_regexp_new + 1396 obj_type=3 (string) retired_by_minor=#0 +# +# `js_regexp_new` (`regex.rs:616`) opens with `string_as_str(pattern)` / +# `string_as_str(flags)` -- `&str` borrows INTO the movable `StringHeader` +# payload -- and holds them across the whole body, including the `REGEX_CACHE` +# probe's `.to_string()` calls and the `RegExpHeader` allocation. That is the +# #7215 borrow shape, the one #7216 fixed for `object_assign_string_source` with +# one owned copy. Filed as #7247. +# +# THIS FILE IS A HARD GATE ON `loop_polls` and stays one: PASS 8/8 there, which +# .github/workflows/gc-moving-witnesses.yml runs on every collector-touching PR. +# +# DELETE ALL TEN when #7247 is fixed. +test_gap_gc_regexp_receiver_rooting | evac_minor | #7247 -- `&str` borrowed into a movable StringHeader across js_regexp_new's body. The #7227 fix is verified on the safepoint route (loop_polls, 8/8) and not claimed here. exit=139, deterministic 10/10. +test_gap_gc_regexp_receiver_rooting | force_evac | #7247 -- same allocation-point window, with force-evacuate on top. +test_gap_gc_regexp_receiver_rooting | force_verify | #7247 -- same allocation-point window, force + verify. +test_gap_gc_regexp_receiver_rooting | rep_i32_off | #7247 -- %E% allocation-point window; the repsel knob is not the discriminator (identical evidence on all ten). +test_gap_gc_regexp_receiver_rooting | rep_str_off | #7247 -- %E% allocation-point window; see rep_i32_off. +test_gap_gc_regexp_receiver_rooting | rep_str_static_off | #7247 -- %E% allocation-point window; see rep_i32_off. +test_gap_gc_regexp_receiver_rooting | rep_ptr_shape_off | #7247 -- %E% allocation-point window; see rep_i32_off. +test_gap_gc_regexp_receiver_rooting | rep_ptr_numarray_off | #7247 -- %E% allocation-point window; see rep_i32_off. +test_gap_gc_regexp_receiver_rooting | rep_spec_abi_off | #7247 -- %E% allocation-point window; see rep_i32_off. +test_gap_gc_regexp_receiver_rooting | rep_int_valued_off | #7247 -- %E% allocation-point window; see rep_i32_off.