Skip to content

Commit e6f874a

Browse files
author
Ralph Küpper
committed
gc: close the strhandle, derived-mask and new.target unrooted hazards (#7664)
`gc-root-dominance-statepoints`' `--max-unrooted` ratchet goes 21 -> 7. #7663 pointed the root-dominance rule at the NATIVE root lowering -- the one that ships since #7370 -- and reported 21 `unrooted` hazards. Fourteen were shapes `root_reload.rs` looked straight through, because its rule is stated over the load's own register and in both shapes the value at risk lives somewhere else. 1. The root is a GLOBAL, not an alloca (10 hits). A string literal lowers to `load double, ptr @<mod>_.str.N.handle`; the handle global is a registered root, so the string is never swept, and an evacuating cycle REWRITES the global while a register loaded beforehand keeps the pre-move address. #7240's shape, whose fix covered call operands only. 2. The stale register is DERIVED from the load (3 of 7 unmasked receivers). `this.count++` holds the unmasked receiver across the property GET; the load's only use is the bitcast ABOVE the collecting call, so the window was empty and the function took zero reloads. 3. `new.target`'s saved previous value (1 hit). `new.rs` saved `js_new_target_get()` in a bare register across the whole constructor body; the cell is a registered mutable root, so the restore publishes a pre-move address back INTO a root the collector scans. #7226's `prev_this` bug for `new.target`. The reload rule is restated over the value's derivation rather than its register: for a value read out of a collector-rewritten location -- a shadow slot or a string-handle global -- and any value derived from it by pure bit ops, every use a collection point can reach re-materialises the whole derivation. A recipe is extended only through ops that are pure functions of their operands and whose every register operand is already in the same single root's recipe, which makes it self-contained and materialisable anywhere. Each value's window is anchored at its own defining instruction, not at the root load. `new.target` gets `new_target_save`/`new_target_restore` in `crate::rooting`, structurally `implicit_this_save`/`implicit_this_restore`. Re-reading the cell would be the wrong repair: `js_new_target_set` has already overwritten it. Measured on `Counter__increment`: before, all three statepoints carried an EMPTY live set, so the receiver was marked by nothing; after, each carries a "gc-live" bundle and a `gc.relocate`, and the SET reads a mask re-derived from the relocated pointer plus a fresh load of the handle global. Remaining 7, each its own slice: 4 unmasked are phi-mediated (the reload has to go in the predecessor, on the edge); 2 `@perry_global_*` are module-level variables the program assigns, so they need rooting rather than reloading (pinned by `a_module_global_is_not_a_reload_source`); 1 capture read. #7664 stays open as the budget's referent. Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
1 parent 7bde3de commit e6f874a

6 files changed

Lines changed: 770 additions & 96 deletions

File tree

.github/workflows/gc-root-dominance.yml

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -464,17 +464,35 @@ jobs:
464464
#
465465
# BUDGETS. `unrooted` (the object is in NO live bundle at all, so
466466
# nothing marks or rewrites it) is the serious class, and it is a
467-
# RATCHET at its measured value rather than a calibrated zero: this
468-
# is a new instrument pointed at a lowering nothing has ever checked
469-
# statically, and 21 is a population under triage, not a number
470-
# anyone has adjudicated. It is a budget rather than allowlist
471-
# entries for the reason `--stale-registers` records — 21 tombstones
472-
# with no issue numbers would be worse documentation than one number
473-
# that can only go down. Lower it as sites are fixed.
467+
# RATCHET rather than a calibrated zero: this is a young instrument
468+
# pointed at a lowering nothing had ever checked statically, and the
469+
# remainder is a population under triage rather than a number anyone
470+
# has adjudicated. It is a budget rather than allowlist entries for
471+
# the reason `--stale-registers` records — tombstones with no issue
472+
# numbers would be worse documentation than one number that can only
473+
# go down. Lower it as sites are fixed.
474474
#
475-
# The 21 are enumerated by shape in #7664, which is this budget's
476-
# referent: a number with nothing behind it is the thing CLAUDE.md
477-
# warns a threshold decays into.
475+
# 21 at #7663. #7664 fixed 14 of them in `root_reload.rs` and
476+
# `lower_call/new.rs` — the whole `strhandle` population (10), the
477+
# `js_new_target_get` save/restore (1), and 3 of the 7 unmasked
478+
# receivers — so the budget is 7. What remains, and why each is its
479+
# own slice rather than a widening of the same fix:
480+
#
481+
# 4 unmasked, all PHI-MEDIATED. The stale value reaches its use
482+
# through a `phi`, and `root_reload` cannot insert above a phi;
483+
# the reload has to go in the PREDECESSOR, on the edge, which is
484+
# a different insertion model.
485+
# 2 global (`@perry_global_*`). NOT reloadable: a module-level
486+
# variable is one the program assigns, so a re-read can observe
487+
# a later assignment instead of the value the call was given
488+
# (`operand_needs_root`). That population needs ROOTING, and
489+
# `a_module_global_is_not_a_reload_source` in root_reload.rs
490+
# pins the distinction so it cannot be widened away by accident.
491+
# 1 capture, a `js_closure_get_capture_bits` read held across
492+
# `js_number_coerce`.
493+
#
494+
# #7664 stays open as this budget's referent: a number with nothing
495+
# behind it is the thing CLAUDE.md warns a threshold decays into.
478496
#
479497
# `stale` (the object survives and is relocated, but a raw copy of
480498
# its pre-move address is used below) reads 0 today and is held
@@ -494,7 +512,7 @@ jobs:
494512
--min-statepoints 15000 \
495513
--min-live-bundles 8000 \
496514
--min-relocates 20000 \
497-
--max-unrooted 21 \
515+
--max-unrooted 7 \
498516
--max-stale 0 \
499517
--allowlist scripts/gc_root_dominance_allowlist.json \
500518
--seeded-violations 40 \
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
### The reload rule now covers global roots and derived values (native lowering)
2+
3+
`gc-root-dominance-statepoints`' `--max-unrooted` ratchet goes **21 → 7**.
4+
5+
#7663 pointed the root-dominance rule at the **native** root lowering — the one
6+
that ships on every target whose frames the runtime can walk since #7370 — and
7+
reported 21 `unrooted` hazards, enumerated by shape in #7664. Fourteen of them
8+
were shapes `root_reload.rs` (#7280) looked straight through, and for one
9+
reason: its rule is stated over *the load's own register*, and in both shapes
10+
the value at risk lives somewhere else.
11+
12+
**1. The root is a global, not an alloca — 10 hits.** A string literal lowers to
13+
`load double, ptr @<mod>_.str.N.handle`. The handle global **is** a registered
14+
root (`js_gc_register_global_root`, `codegen/string_pool.rs`), so the string is
15+
never swept — and an evacuating cycle **rewrites the global** while a register
16+
loaded beforehand keeps the pre-move address. That is #7240's shape, whose fix
17+
(`OperandProtection::Reload`) covers call *operands* and never reached the ~194
18+
codegen sites that load a handle global directly.
19+
20+
**2. The stale register is DERIVED from the load — 3 of 7 unmasked receivers.**
21+
`this.count++` lowered to a slot load, a `bitcast`, an `and` mask, the property
22+
GET, and then the SET re-using the *pre-GET* mask. The load's only use is the
23+
`bitcast`, which sits **above** the collecting call, so the window was empty and
24+
the function took zero reloads; the value that actually crosses the GET is the
25+
mask. Under the native lowering the same shape reads
26+
`ptrtoint ptr addrspace(1) %s to i64` — LLVM relocates the `addrspace(1)`
27+
pointer and rewrites its uses, but it cannot touch an `i64` copy, so the unmask
28+
is where the value leaves the tracked domain for good. #7280's zod-`clone`
29+
shape and #7240's literal shape, meeting in one function.
30+
31+
**3. `new.target`'s saved previous value — 1 hit.** `lower_call/new.rs` saved
32+
`js_new_target_get()` into a bare SSA register across the **whole constructor
33+
body**. The cell is a registered mutable root
34+
(`scan_current_new_target_root_mut`), so evacuation rewrites it and the restore
35+
publishes a pre-move address back *into* a root the collector scans. The
36+
runtime's own construct paths have always rooted their `prev_new_target`
37+
(`scope.root_nanbox_f64`); generated code did not. This is #7226's `prev_this`
38+
bug for `new.target`, and it is fixed the same way — a
39+
`new_target_save`/`new_target_restore` pair in `crate::rooting`, structurally
40+
`implicit_this_save`/`implicit_this_restore`.
41+
42+
#### The restated rule
43+
44+
> For a value read out of a collector-rewritten location — a shadow slot **or a
45+
> string-handle global** — and any value **derived from it by pure bit ops**,
46+
> every use that a collection point can reach re-materialises the whole
47+
> derivation instead.
48+
49+
A derivation ("recipe") is extended only through instructions that are pure
50+
functions of their operands (`and`/`or`/`xor`, `bitcast`/`ptrtoint`/`inttoptr`/
51+
`trunc`/`zext`/`sext`) **and** whose every register operand is already in the
52+
same single root's recipe. That makes a recipe self-contained — a load plus bit
53+
ops on constants — so it materialises at any point in the function with no
54+
dominance question, and re-executing it is by construction the same function of
55+
the same root evaluated against the address the collector wrote back. A phi, a
56+
call, or an operand from a second root is not extended through. Each reloadable
57+
value's window is anchored at **its own defining instruction**, not at the root
58+
load: a derivation built *below* a collecting call is already fresh.
59+
60+
Re-reading a *handle global* is sound for the reason `operand_is_reloadable`
61+
gives: the only writer in generated code is `__perry_init_strings_*`, so a
62+
re-read cannot observe a later assignment. That function is excluded by the
63+
analysis rather than by the argument — the same store side-condition that
64+
protects a reassigned slot covers a stored-to global.
65+
66+
#### Measured
67+
68+
`test_gap_closures.ts`'s `Counter__increment`, native corpus. **Before**, all
69+
three statepoints carried an empty live set: the receiver was in no bundle at
70+
all, so nothing marked or rewrote it, and the SET read the mask computed before
71+
the GET. **After**, every statepoint carries a `"gc-live"` bundle and emits a
72+
`gc.relocate`, and the SET reads a mask re-derived from the relocated pointer
73+
plus a fresh load of the handle global. The re-derivation is what puts the
74+
receiver in the bundle: it extends the tracked pointer's live range past the
75+
safepoints, so `rewrite-statepoints-for-gc` must report and relocate it.
76+
77+
#### What remains
78+
79+
`--max-unrooted 7`, and each remainder is its own slice rather than a widening
80+
of this fix: **4 unmasked** are phi-mediated (no instruction can be inserted
81+
above a phi — the reload has to go in the predecessor, on the edge); **2
82+
global** are `@perry_global_*`, module-level variables the program assigns, so a
83+
re-read can observe a later assignment instead of the value the call was given
84+
(`operand_needs_root`) — that population needs rooting, not reloading; **1
85+
capture** is a `js_closure_get_capture_bits` read held across
86+
`js_number_coerce`. #7664 stays open as the budget's referent.
87+
88+
The `global` exclusion is pinned by `a_module_global_is_not_a_reload_source`, so
89+
widening `is_string_handle_global` to swallow it is a test failure rather than a
90+
silent decision. Five more unit tests cover the two new shapes, the
91+
`__perry_init_strings_*` store side-condition, the no-collection-point control
92+
(the derivation closure must not turn every mask in the program into three extra
93+
instructions), and the refusal to extend a derivation through a call.
94+
95+
Also corrected: #7664's shape-1 heading says nine hits; its own list has ten,
96+
and the checker reports ten. The census of the 21 is 10 `strhandle` / 7
97+
`unmasked` / 2 `global` / 1 `rootread` / 1 `capture`.

crates/perry-codegen/src/inst.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
1616
use crate::types::LlvmType;
1717

18+
#[derive(Clone)]
1819
pub enum LoadFlavor {
1920
Plain,
2021
Aligned(u32),
@@ -24,6 +25,11 @@ pub enum LoadFlavor {
2425
Invariant,
2526
}
2627

28+
/// `Clone` is what lets `root_reload.rs` carry a value's derivation RECIPE —
29+
/// the root load plus the pure bit ops above it — to the stale use and
30+
/// re-materialise it there (#7664). Cloning an instruction is cloning its
31+
/// operand tokens; nothing in a variant is an identity.
32+
#[derive(Clone)]
2733
pub enum LlInst {
2834
/// Pre-rendered instruction line, two-space indent included.
2935
Raw(String),

crates/perry-codegen/src/lower_call/new.rs

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1014,15 +1014,15 @@ fn lower_new_impl_inner(
10141014
// ponytail: a throw inside the ctor skips the restore, leaving the cell
10151015
// set — same edge case the runtime construct paths already have; fix
10161016
// holistically if it bites.
1017+
// #7664: `prev` is saved across the WHOLE constructor body, and the
1018+
// cell it comes out of is a registered mutable root that evacuation
1019+
// rewrites — so it goes in a temp root, not a bare register.
10171020
let saved_new_target = if ctor_chain_uses_new_target(ctx, class) {
1018-
ctx.class_ids.get(class_name).map(|&cid| {
1019-
let prev = ctx.block().call(DOUBLE, "js_new_target_get", &[]);
1021+
ctx.class_ids.get(class_name).copied().map(|cid| {
10201022
let class_ref = double_literal(f64::from_bits(
10211023
crate::nanbox::INT32_TAG | (cid as u64 & 0xFFFF_FFFF),
10221024
));
1023-
ctx.block()
1024-
.call(DOUBLE, "js_new_target_set", &[(DOUBLE, &class_ref)]);
1025-
prev
1025+
crate::rooting::new_target_save(ctx, &class_ref)
10261026
})
10271027
} else {
10281028
None
@@ -1034,9 +1034,8 @@ fn lower_new_impl_inner(
10341034
&lowered_args,
10351035
caps_absent_from_args,
10361036
) {
1037-
if let Some(prev) = &saved_new_target {
1038-
ctx.block()
1039-
.call(DOUBLE, "js_new_target_set", &[(DOUBLE, prev)]);
1037+
if let Some(save) = &saved_new_target {
1038+
crate::rooting::new_target_restore(ctx, save);
10401039
}
10411040
// #7154: the constructor body has run, so every register holding
10421041
// the instance is potentially pre-move. Re-read it from its root
@@ -1081,9 +1080,8 @@ fn lower_new_impl_inner(
10811080
);
10821081
return Ok(final_box);
10831082
}
1084-
if let Some(prev) = &saved_new_target {
1085-
ctx.block()
1086-
.call(DOUBLE, "js_new_target_set", &[(DOUBLE, prev)]);
1083+
if let Some(save) = &saved_new_target {
1084+
crate::rooting::new_target_restore(ctx, save);
10871085
}
10881086
// #6921: `call_local_constructor_symbol` returned `None` — this module
10891087
// has no `<Class>_constructor` entry, so no constructor ran and the
@@ -1760,13 +1758,10 @@ fn lower_new_impl_inner(
17601758
// 'type')`, or silently set `type = undefined` → the auth error
17611759
// was mis-categorized and the login redirect fell back to
17621760
// `?error=Configuration`.
1763-
let nt_prev = ctx.block().call(DOUBLE, "js_new_target_get", &[]);
17641761
let nt_ref = double_literal(f64::from_bits(new_target_bits));
1765-
ctx.block()
1766-
.call(DOUBLE, "js_new_target_set", &[(DOUBLE, &nt_ref)]);
1762+
let nt_save = crate::rooting::new_target_save(ctx, &nt_ref);
17671763
let _ = ctx.block().call(DOUBLE, &ctor.symbol, &ctor_args);
1768-
ctx.block()
1769-
.call(DOUBLE, "js_new_target_set", &[(DOUBLE, &nt_prev)]);
1764+
crate::rooting::new_target_restore(ctx, &nt_save);
17701765
} else if let Some(ctor) = ctx.imported_class_ctors.get(class_name).cloned() {
17711766
// Pad missing optional args with TAG_UNDEFINED so the constructor
17721767
// doesn't read garbage from stale registers, and pack the rest
@@ -1798,13 +1793,10 @@ fn lower_new_impl_inner(
17981793
// new.target cross-module: bind the runtime cell to the leaf
17991794
// class ref around the imported ctor call (see the ANCESTOR arm
18001795
// above for why). This is the direct `new ImportedClass()` case.
1801-
let nt_prev = ctx.block().call(DOUBLE, "js_new_target_get", &[]);
18021796
let nt_ref = double_literal(f64::from_bits(new_target_bits));
1803-
ctx.block()
1804-
.call(DOUBLE, "js_new_target_set", &[(DOUBLE, &nt_ref)]);
1797+
let nt_save = crate::rooting::new_target_save(ctx, &nt_ref);
18051798
let ctor_ret = ctx.block().call(DOUBLE, &ctor.symbol, &ctor_args);
1806-
ctx.block()
1807-
.call(DOUBLE, "js_new_target_set", &[(DOUBLE, &nt_prev)]);
1799+
crate::rooting::new_target_restore(ctx, &nt_save);
18081800
ctx.block().store(DOUBLE, &ctor_ret, &ctor_result_slot);
18091801
found_inherited_ctor = true;
18101802
}

0 commit comments

Comments
 (0)