-
-
Notifications
You must be signed in to change notification settings - Fork 155
fix(gc): run the globalThis bootstrap in a no-move window (#7217) #7249
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
| }); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 50369
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 50370
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 415
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 50369
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 50370
Remove the 1-MB bootstrap footprint precondition.
global_this_bootstrap_runs_in_a_no_move_windowrequires the single one-shot bootstrap to exceedarena_before + 1 MB, but the feature-gated global namespace installers can reduce that allocation. Apply the pre-arm filler pattern fromgenerator_intrinsic_tower_runs_in_a_no_move_window, so the subject’s own allocation can crossgc_check_trigger()even when the bootstrap is small.🤖 Prompt for AI Agents