Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions changelog.d/8809-gc-root-dominance-late-roots.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Two GC root stores that did not dominate the collection points after them, and the audit failure that hid both.

`gc-root-dominance` has been red on `main` since 2026-08-15, and every scheduled run failed at the *same* step: `--audit-poll-reach`, which runs before the compiler build. None of the four gated arms below it executed for ten days, and two rooting regressions landed inside that window. Both were `MOVING: YES`-class defects of the #7192 shape (a root store emitted in-frame but after a call that can collect), and neither PR carried `run-extended-tests`, so the opt-in PR arm never ran either.

* **Object-literal method closures (`expr/object_literal.rs`).** `lower_by_name_props` lowered a `this`-capturing method closure, installed it with `js_object_set_field_by_name`, and only *then* pushed its root. `js_object_set_field_by_name` is a collection point, and the closure is reachable from the object by the time it runs, so an evacuating minor moves it — after which the queued register names from-space, the push publishes a dangling pointer into a slot the collector scans, and the deferred `this`-patch loop writes the receiver into abandoned memory (the surviving copy's method then runs with `this` unset). The install now happens *inside* the closure's rooted scope and re-reads it like any other rooted argument; `RootedAcc::as_arg` is `pub(crate)` for that one shape. When `protect` is false nothing is emitted and the IR is byte-identical, as before.

This arm was unreachable from TypeScript between #809 and #8793 — every source literal with a method went through the IIFE builder — so the ordering survived the #7192 sweep unexercised. #8793 routes static-key method literals straight to `Expr::Object`, which lands here, and the latent bug became seven live violations across three fingerprints the next morning. The test module's doc no longer claims the branch is unreachable.

* **Private-method classes (`lower_call/new.rs`).** `construction_runs_user_code` gates three things that must agree — the instance temp root, the `this`-slot bind, and whether `reload_instance` re-reads. A class whose only private elements are methods or accessors declares no fields, no constructor and no heritage, so it answered `false` — while `emit_field_inits` still emits `js_private_brand_add` for it. That helper allocates the marker key and calls `js_object_set_field_by_name`; its own body says the allocation "can evacuate both the receiver and any live value" and opens a `RuntimeHandleScope` for exactly that reason. `new WithPrivateMethod()` therefore fed a stale handle to `js_gc_init_typed_shape_layout` and published it into the caller's root slot. The predicate now includes `has_private_instance_elements()`.

* **`scripts/gc_root_dominance_check.py`.** `POLL_CAPABLE_RUNTIME` gains the three symbols `--audit-poll-reach` had been naming since 2026-08-15 (`js_builtin_subclass_construct`, `js_tls_create_secure_context`, `js_tls_secure_context_new`), which is what unblocks the gate. It also gains `js_private_brand_add` — the referent-with-no-name neither audit can ask for, because `--audit-poll-reach` only walks symbols `ALLOC_RE` matches and this one matches no alloc/new/create convention. Without it the private-method window classified `MOVING: no` and every `--moving-only` arm dropped it, the same way the emitted property-GET helpers were dropped before #7284. Measured over the curated corpus, that entry reclassifies exactly the one window this change fixes. No allowlist entry was added and no detection was narrowed; `--self-test` still reports its planted violation.
7 changes: 7 additions & 0 deletions changelog.d/8810-gc-root-dominance-corpus-no-link.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
**GC-root-dominance corpus:** two GC-callback-rooting sources were silently contributing no IR to the gate that exists to check exactly that. `scripts/gc_root_dominance_corpus.sh` linked every source, and the corpus deliberately sets `PERRY_NO_AUTO_OPTIMIZE=1` — so the only two sources importing a node builtin (`test_gap_gc_net_once_flags_rekey` → `node:net`, `test_gap_gc_http2_pending_event_callback_rooting` → `node:http2`) needed a prebuilt `libperry_ext_{net,http}.a` that the documented build command does not produce. perry auto-built each wrapper in its own cargo invocation, cargo unified features per invocation, and `compile/shared_tokio.rs` correctly refused the resulting two-tokio link (#507/#7629). perry exited non-zero **after** codegen had already written the `.ll`, and the loop's `continue` threw that IR away: 150/152 compiled, 2 skipped, exit 1 on clean `main` — with a skip message that named only the two files, so the finding read as a compiler regression rather than a link-line refusal (#8810).

The corpus now compiles with `--no-link`. `--trace llvm` is written during codegen, so the link stage was never part of this gate's subject; not linking removes the failure mode instead of tolerating it (no link line to be wrong, no ext archive to be missing, no stale `libperry_runtime.a` deciding what the corpus contains) and stops the script writing 152 executables it never reads. `scripts/compiler_output_harness/repsel_census.py` already compiles with `--no-link` for the same reason. A source that stops *linking* is still a finding — it is `./run_parity_tests.sh`'s, which compiles and runs every `test_gap_*.ts` under the shipping configuration. Codegen failures still exit non-zero and still fail the `MAX_SKIPPED=0` ratchet (verified by sabotage).

Measured on the shadow lowering, same compiler both arms: **150/152 sources, 177 `.ll` → 152/152 sources, 179 `.ll`, 0 skipped, exit 0**. The 177 shared `.ll` files are byte-identical across the two runs, so `--no-link` changed nothing about the emitted IR. The two new modules add 30 functions and 53 root stores and **zero** new violations: both arms report the same 7 violations over the same 3 fingerprints (tracked in #8809), and `--unrooted-allocas` stays at 0, now over 179 files instead of 177.

Two follow-ups in the same file: a failed compile now reports its first error line next to the source name (one skip per line), because a skip that names only a file is a finding you have to reproduce locally before you can read it. And `MIN_SOURCES` was raised 131 → 152, the measured discovery count — it had drifted the same way the old `MIN_COMPILED=90` floor did, leaving room for 21 sources to vanish before the "corpus shrank" arm could fire.
124 changes: 105 additions & 19 deletions crates/perry-codegen/src/expr/object_literal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,17 @@
//! loop". Reorder those two statements and the leak is silent.
//!
//! Nesting one `with_rooted_accumulator` per such property expresses the same
//! lifetime as a scope: each value's root spans exactly the suffix of the
//! literal that follows it, the release is owned on every path out (including a
//! `?` from a later initializer, which the flat form leaked), and the value can
//! only be read in `finish` — below the last initializer, above the release.
//! lifetime as a scope: each value's root spans its own installing
//! `js_object_set_field_by_name` and the whole suffix of the literal that
//! follows it, the release is owned on every path out (including a `?` from a
//! later initializer, which the flat form leaked), and the value can only be
//! read in `finish` — below the last initializer, above the release.
//!
//! The install is INSIDE the scope, not above it (#8809). Rooting after it is
//! the #7192 shape: the setter can run a user setter or a Proxy trap, and the
//! closure it just installed is reachable from the object, so an evacuating
//! minor moves it and the register queued above names from-space.
//!
//! No fourth combinator: the three existing `with_operands_rooted*` forms all
//! lower their own operand list up front, which would evaluate every property
//! before storing any of them and reorder observable side effects, and
Expand Down Expand Up @@ -255,26 +262,44 @@ fn lower_by_name_props<'f>(
let this_idx = auto_caps.len() as u32;

let v = lower_expr(ctx, value_expr)?;
let key_raw = emit_interned_key_raw(ctx, &key_handle_global);
obj.call_void(
ctx,
"js_object_set_field_by_name",
&[Arg::Plain(I64, &key_raw), Arg::Plain(DOUBLE, &v)],
);

// The closure value is deferred: the patch loop reads it after every
// remaining property has been lowered, so it must survive their
// allocations AND be re-read afterwards (an evacuating cycle rewrote
// the slot; the register queued above is stale). `build` owns the
// rest of the literal, `finish` is the one place the value escapes,
// and the release happens on both paths out.
//
// #8809: the root is pushed BEFORE the installing
// `js_object_set_field_by_name`, and that call re-reads it like any
// other rooted argument. It used to be pushed after, which is #7192
// exactly — `js_object_set_field_by_name` is a collection point
// (`POLL_CAPABLE_RUNTIME`: it can run a user setter or a Proxy trap,
// and the closure it just installed is reachable from the object, so
// an evacuating minor MOVES it). The register queued above was then
// stale, and pushing a stale pointer into a slot the collector scans
// is strictly worse than not rooting at all: the patch loop below
// read it back and wrote the receiver into abandoned from-space, so
// the surviving copy's method ran with `this` unset.
//
// This arm was unreachable from TypeScript between #809 and #8793 —
// every source-level literal with a method went through the IIFE
// builder — which is why the ordering survived the #7192 sweep. See
// `by_name_method_closure_tests` below, whose HIR-level fixture is
// the only coverage it had.
let mut rest: Vec<(String, u32)> = Vec::new();
let closure_value = rooting::with_rooted_accumulator(
ctx,
Repr::Boxed,
&v,
protect,
|ctx, _| {
|ctx, closure| {
let key_raw = emit_interned_key_raw(ctx, &key_handle_global);
obj.call_void(
ctx,
"js_object_set_field_by_name",
&[Arg::Plain(I64, &key_raw), closure.as_arg()],
);
rest = lower_by_name_props(ctx, obj, props, i + 1, protect)?;
Ok(())
},
Expand Down Expand Up @@ -529,14 +554,22 @@ mod by_name_method_closure_tests {
/// `lower_object_literal`'s BY-NAME path and with it the deferred
/// `this`-patch machinery this module's nested accumulators root.
///
/// Built from HIR rather than from TypeScript on purpose. Since #809 every
/// source-level object literal containing a `Prop::Method` is lowered to a
/// source-ordered IIFE over `{}` (`js_object_set_method_by_name`), so the
/// by-name path with a non-empty prop list is not reachable from
/// TypeScript: over the whole `gc_root_dominance_corpus.sh` corpus (129
/// sources, 149 modules) every emitted `js_object_alloc` is
/// `(i32 0, i32 0)`. A branch no corpus reaches is a branch no IR A/B can
/// speak for, so it gets a test of its own rather than an assumption.
/// Built from HIR rather than from TypeScript on purpose — originally
/// because the branch was unreachable from source, and now because this is
/// the only place its *shape* is asserted rather than sampled.
///
/// **That unreachability lapsed, and it cost a shipped rooting bug.** From
/// #809 to #8793 every source-level object literal containing a
/// `Prop::Method` lowered to a source-ordered IIFE over `{}`
/// (`js_object_set_method_by_name`), so this path never ran on real code:
/// over the whole `gc_root_dominance_corpus.sh` corpus every emitted
/// `js_object_alloc` was `(i32 0, i32 0)`. #8793 routes a static-key method
/// literal straight to `Expr::Object`, which lands here — and the late root
/// this arm had carried unexercised since #6951 became seven live
/// `--moving-only` violations the next morning (#8809). A branch no corpus
/// reaches is a branch that keeps whatever bug it has until something
/// reaches it, so treat "not reachable from TypeScript" as a note about
/// today's front end, never as a reason a hazard here is theoretical.
///
/// The two closures deliberately reserve DIFFERENT `this` slots — `add`
/// captures nothing so its slot index is 0, `scale` captures `base` so its
Expand Down Expand Up @@ -713,6 +746,59 @@ mod by_name_method_closure_tests {
);
}

/// #8809: each method closure's root store must DOMINATE the
/// `js_object_set_field_by_name` that installs it, not follow it.
///
/// The installer is a collection point — it can run a user setter or a
/// Proxy trap, and by the time it returns the closure is reachable from the
/// object, so an evacuating minor MOVES it. Rooting afterwards publishes
/// the pre-move register into a slot the collector scans, and the deferred
/// patch loop then writes the receiver into abandoned from-space: the
/// surviving copy's method runs with `this` unset. That was the shipped
/// state until this test existed.
///
/// Asserted positionally over the emitted lines rather than by counting
/// calls: the count is identical in both orderings, which is exactly why
/// #7192's sweep did not catch this one.
#[test]
fn a_method_closure_is_rooted_before_it_is_installed() {
let ir = build_fn(&method_literal_ir());
let lines: Vec<&str> = ir.lines().collect();
let closure_allocs: Vec<usize> = lines
.iter()
.enumerate()
.filter(|(_, l)| l.contains("@js_closure_alloc"))
.map(|(i, _)| i)
.collect();
assert_eq!(
closure_allocs.len(),
2,
"the fixture's two method closures must each be allocated here:\n{ir}"
);
for alloc in closure_allocs {
let root = lines[alloc..]
.iter()
.position(|l| {
l.starts_with("store ptr addrspace(1) %") || l.starts_with("store i64 %")
})
.unwrap_or_else(|| {
panic!("no root store below the closure allocation on line {alloc}:\n{ir}")
});
let install = lines[alloc..]
.iter()
.position(|l| l.contains("@js_object_set_field_by_name("))
.unwrap_or_else(|| {
panic!("no by-name install below the closure allocation on line {alloc}:\n{ir}")
});
assert!(
root < install,
"the closure allocated on line {alloc} is installed before its root store \
(root +{root}, install +{install}) — that is #7192's shape and it publishes \
a moved-from address:\n{ir}"
);
}
}

/// The rooting shape, read off the IR. Three GC values are live across the
/// literal — the object handle and both deferred closure values — so the
/// lowering must own three rooted slots and must give each back, innermost
Expand Down
20 changes: 20 additions & 0 deletions crates/perry-codegen/src/lower_call/new.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,26 @@ fn construction_runs_user_code(ctx: &FnCtx<'_>, class_name: &str) -> bool {
ctx.classes.get(class_name).is_some_and(|class| {
class.constructor.is_some()
|| !class.fields.is_empty()
// #8809: a class whose only private elements are METHODS or
// ACCESSORS declares no fields, no constructor and no heritage, and
// answered `false` here — while `emit_field_inits` still emits
// `js_private_brand_add` for it (#8643 added that call, keyed on
// `has_private_instance_elements`, and its `continue` guard lets a
// fieldless class through precisely so the brand can be installed).
// That helper allocates the marker key and calls
// `js_object_set_field_by_name`; its own body says "the marker-key
// allocation can evacuate both the receiver and any live value" and
// opens a `RuntimeHandleScope` for exactly that reason. So the
// window this predicate claims cannot collect does, and the
// instance was crossing it in a bare register: `new
// WithPrivateMethod()` fed a stale handle to
// `js_gc_init_typed_shape_layout` and then published it into the
// caller's root slot.
//
// One predicate, one place — the temp root, the `this`-slot bind
// and `reload_instance` all read this, which is what stops them
// disagreeing the way #7114's pair did.
|| class.has_private_instance_elements()
|| class.extends.is_some()
|| class.extends_name.is_some()
|| class.native_extends.is_some()
Expand Down
9 changes: 8 additions & 1 deletion crates/perry-codegen/src/rooting/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1185,7 +1185,14 @@ pub(crate) struct RootedAcc {

impl RootedAcc {
/// The accumulator as a call argument.
fn as_arg(&self) -> Arg<'_> {
///
/// `pub(crate)` for the one shape that needs it: a call whose argument 0 is
/// one accumulator and whose later argument is *another* (an object-literal
/// method closure being installed into the half-built object it belongs to,
/// #8809). It hands out an [`Arg`], never a register — `materialize` still
/// performs the re-read at the instant the call is emitted — so the "load
/// early, use late" sequence stays unwritable through this door too.
pub(crate) fn as_arg(&self) -> Arg<'_> {
match &self.slot {
Some(slot) => Arg::Root(slot),
None => Arg::Plain(self.repr.llvm_ty(), &self.value),
Expand Down
Loading
Loading