From e09de5bfa191233efc417092a2c9f359c5687001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 2 Aug 2026 13:05:39 +0200 Subject: [PATCH] fix(gc): run the globalThis bootstrap in a no-move window (#7217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `test_gap_gc_spread_accessor_rooting` SIGSEGV'd 10/10 on the allocation-point route long after three rooting fixes had been verified green at safepoints. The failing collection was in none of the code they touched: `js_get_global_this()` builds the whole realm lazily — here reached from an ordinary property write several hundred loop iterations in — allocates ~1.15 MB doing it, and under an 8 MB heap limit minor #0 lands in the middle of it. #6982 rooted the singleton, which is one pointer. The bootstrap builds a graph, threading `ctor`, `proto` and `ns_obj` as bare `*mut ObjectHeader` locals across dozens of allocating installs in a dozen installer modules. `PERRY_GC_PROTECT_FROMSPACE=1` names it: `set_builtin_property_attrs` <- `intl::install_function` <- `install_constructor` <- `install_intl_namespace` <- `populate_global_this_builtins`, `retired_by_minor=#0`. Confirmed before any code changed: warming `globalThis` at the top of the unmodified reproducer makes it clean 5/5 with six copying minors. A back-edge poll fires only while user JS runs and the bootstrap runs none, so the safepoint route can never expose those locals; the allocation-point route makes the bootstrap's own allocations the collection points and exposes all of them at once. 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 is unbounded and ungateable (the dominance checker reads LLVM IR and is blind to all of them); the window is one line and costs nothing a collection would have recovered, since everything born there lives for the life of the thread. Allocation-point arm, 10 runs each, base rebuilt bit-identically for the A/B: spread_accessor_rooting exit=139 10/10 -> clean 10/10, quarantine silent static_block_this_rooting `bad 1` 10/10 -> `bad 0` 10/10 loop_polls: all five witnesses green 5/5, all still relocating. RSS on a globalThis-touching hello drops ~230 KB and one GC cycle — the window defers a collection, it does not add one. Gated by a `--lib` unit test that arms one pending collection, shows the bootstrap does not service it, and then shows the same armed request IS serviced by ordinary allocation once the window closes. Sabotage-checked. Windows for the two sibling `ensure_*` intrinsic-tower builders were written and then deliberately dropped: a tower fits inside one arena block's tail and may reach no `gc_check_trigger` at all, so three successive gate designs PASSED with the window deleted. Tracked as #7251 rather than shipped ungated. Two other witnesses stay red on that route for unrelated, now-localized reasons (#7247 `js_regexp_new` `&str` borrows, #7248 stale `js_eq` left operand); their triage entries are retargeted rather than deleted. --- .../7249-realm-bootstrap-no-move-window.md | 139 +++++++++++++++ .../src/gc/tests/global_bootstrap.rs | 157 +++++++++++++++++ crates/perry-runtime/src/gc/tests/mod.rs | 1 + .../src/object/global_this/populate.rs | 37 ++++ test-parity/gc_repsel_corpus.txt | 28 ++- test-parity/gc_repsel_triage.txt | 161 +++++++++--------- 6 files changed, 439 insertions(+), 84 deletions(-) create mode 100644 changelog.d/7249-realm-bootstrap-no-move-window.md create mode 100644 crates/perry-runtime/src/gc/tests/global_bootstrap.rs 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.