diff --git a/changelog.d/8143-rs4gc-boxed-slots-leaf-accessors.md b/changelog.d/8143-rs4gc-boxed-slots-leaf-accessors.md new file mode 100644 index 0000000000..32598216c7 --- /dev/null +++ b/changelog.d/8143-rs4gc-boxed-slots-leaf-accessors.md @@ -0,0 +1,19 @@ +**codegen/GC: stop GC-typing variable-box pointers and leaf-mark the audited capture/box accessors (#8132 direction 1)** + +RS4GC's output scales as safepoints × live GC values, and on next\@16.3.0's bundled `jsonwebtoken` one webpack module factory concentrated 95% of the unit's 1.5M `gc.relocate`s: 5,536 statepoints × a mean of 259 live `addrspace(1)` values (`perry_closure_jsonwebtoken_js__227`). Dissecting the live sets showed two populations this change removes at the source, per the issue's "not modelling every value as a GC pointer where a proof exists": + +- **Variable-box pointers are not GC pointers.** A boxed local's slot only ever holds a `js_box_alloc_bits`-family result (or the `TAG_UNDEFINED` sentinel): boxes are `std::alloc` allocations outside the GC heap, never moved, never freed (`BOX_REGISTRY` is monotonic), and the JSValue inside is traced by the registered `scan_box_roots_mut` scanner — the premise `scripts/gc_root_dominance_check.py`'s IMMOVABLE_SOURCES "box" probes already machine-check, and the one `expr/literals_vars.rs` already relies on to carry a box address across collecting calls. `emit_shadow_slot_bind_for_local` now skips boxed locals (`boxed_vars && !module_globals`, the exact test every store site uses), so their slots stay plain `alloca i64` and RS4GC never relocates them. On the fixture ~300 preallocated boxes were live across ~90% of the monolith's statepoints. + +- **The audited capture/box accessors are leaf calls.** `js_closure_get/set_capture_bits` (+ `_ptr`), `js_box_set_bits`, `js_box_alloc_bits`, and the i32/bool box helpers are raw slot reads/writes plus already-admitted barrier/layout bookkeeping, or `std::alloc` allocation that cannot arm a Perry GC trigger. They join `GcCallEffect::CannotCollect` (and the checker's NONCOLLECTING, preserving the one-way containment). They were 2,168 of the monolith's 5,537 statepoint-forming calls. `js_box_get_bits` is deliberately **not** admitted: its TDZ arm allocates a ReferenceError before unwinding, and a test pins it to `Unknown`. + +Measured on the #8132 fixture (stock `opt` 22.1.4, `function(mem2reg,sccp),rewrite-statepoints-for-gc` on unit0): + +| metric | before | after | +|---|---|---| +| fn227 statepoints | 5,536 | 3,368 (−39%) | +| fn227 gc.relocate | 1,432,110 | 477,377 (−67%) | +| fn227 mean live values/statepoint | 258.7 | 141.7 | +| unit0 gc.relocate | 1,503,308 | 522,099 (−65%) | +| unit0 post-RS4GC IR | 412 MB | 161 MB (−61%) | + +Tests: the boxed local's slot is asserted un-retyped beside an unboxed twin that still lowers `alloca ptr addrspace(1)` (discriminating in both directions), and a statepoint-rewrite probe asserts the audited accessors stay direct calls while an unaudited callee beside them is statepoint-wrapped. diff --git a/crates/perry-codegen/src/expr/shadow_slot.rs b/crates/perry-codegen/src/expr/shadow_slot.rs index 923c76a7df..50d6694bfb 100644 --- a/crates/perry-codegen/src/expr/shadow_slot.rs +++ b/crates/perry-codegen/src/expr/shadow_slot.rs @@ -236,6 +236,29 @@ pub(crate) fn emit_shadow_slot_bind_for_local(ctx: &mut FnCtx<'_>, local_id: u32 if ctx.persistent_shadow_slots.contains(&slot_idx) { return; } + // #8132: a boxed local's alloca never holds a GC-heap value, so rooting it + // protects nothing and (under the RS4GC lowering) costs a relocation of + // the box pointer at EVERY statepoint it is live across. Every store site + // routes through the same `boxed_vars && !module_globals` test + // (`stmt/mod.rs` prealloc, `let_stmt.rs`'s boxed arm, + // `codegen/arguments.rs::store_param_slot`, `lower_call/new_ctor_args.rs`), + // and each of them stores only a `js_box_alloc_bits`-family result or the + // TAG_UNDEFINED sentinel into the slot — the VALUE always goes inside the + // box. Boxes are `std::alloc` allocations outside the GC heap: no + // collector phase moves them, box.rs never frees them (`BOX_REGISTRY` is + // monotonic), and the JSValue inside is traced and rewritten by the + // registered `scan_box_roots_mut` scanner. All three premises are pinned + // by `scripts/gc_root_dominance_check.py`'s IMMOVABLE_SOURCES "box" entry, + // whose probes fail the lint if boxes ever become arena-allocated or grow + // a free path — at which point this skip must be reverted with them. + // + // On the webpack-factory monolith of #8132, ~300 preallocated boxes were + // live across ~90% of one function's 5.5k statepoints; unbinding them is + // what "not modelling every value as a GC pointer where a proof exists" + // means for this shape. + if ctx.boxed_vars.contains(&local_id) && !ctx.module_globals.contains_key(&local_id) { + return; + } let Some(local_slot) = ctx.locals.get(&local_id).cloned() else { return; }; diff --git a/crates/perry-codegen/src/function/precise_roots.rs b/crates/perry-codegen/src/function/precise_roots.rs index eebf356f5d..de442c6d47 100644 --- a/crates/perry-codegen/src/function/precise_roots.rs +++ b/crates/perry-codegen/src/function/precise_roots.rs @@ -251,6 +251,91 @@ pub(super) fn lower_precise_roots_to_native_stack( mod tests { use super::lower_precise_roots_to_native_stack; + /// #8132: the audited capture/box accessors must not become statepoints. + /// On the bundled-module-factory shape they were ~45% of one function's + /// 5.5k statepoints, each relocating every live GC value (~259 mean). + /// + /// The unknown callee beside them is the discriminating control: it MUST + /// be statepoint-wrapped, so this test fails in both directions — a leaf + /// marking that stops being applied (extra statepoints, the direct-call + /// assertions break) and an over-rotation that leafs everything (the + /// control's statepoint disappears). + #[test] + fn audited_capture_and_box_accessors_take_no_statepoint() { + let _native = crate::codegen::helpers::NativeRootsPin::native(); + let target = crate::codegen::default_target_triple(); + let mut module = crate::module::LlModule::new(target.clone()); + use crate::types::{I32, I64, PTR, VOID}; + module.declare_function("js_shadow_slot_bind", VOID, &[I32, PTR]); + module.declare_function("js_closure_get_capture_bits", I64, &[I64, I32]); + module.declare_function("js_closure_set_capture_bits", VOID, &[I64, I32, I64]); + module.declare_function("js_box_alloc_bits", I64, &[I64]); + module.declare_function("js_box_set_bits", VOID, &[I64, I64]); + module.declare_function("js_map_alloc", I64, &[I32]); + + let function = module.define_function("leaf_probe", I64, vec![]); + function.enable_shadow_frame(0); + let idx = function + .reserve_shadow_slot() + .expect("native pin reserves a precise-root slot"); + let root = function.alloca_entry(I64); + function.entry_allocas_push_store(I64, "0", &root); + function.entry_setup_call_void( + "js_shadow_slot_bind", + &[(I32, &idx.to_string()), (PTR, &root)], + ); + let entry = function.create_block("entry"); + let dynamic = entry.call(I64, "js_map_alloc", &[(I32, "0")]); + entry.store(I64, &dynamic, &root); + // The audited leaf calls, with the root live across every one. + let box_ptr = entry.call(I64, "js_box_alloc_bits", &[(I64, "0")]); + entry.call_void("js_box_set_bits", &[(I64, &box_ptr), (I64, "1")]); + let cap = entry.call( + I64, + "js_closure_get_capture_bits", + &[(I64, "0"), (I32, "0")], + ); + entry.call_void( + "js_closure_set_capture_bits", + &[(I64, "0"), (I32, "0"), (I64, &cap)], + ); + // Control: an unaudited callee stays a genuine safepoint. + let unknown = entry.call(I64, "js_map_alloc", &[(I32, "1")]); + let live = entry.load(I64, &root); + let acc = entry.xor(I64, &live, &cap); + let acc = entry.xor(I64, &acc, &box_ptr); + let acc = entry.xor(I64, &acc, &unknown); + entry.ret(I64, &acc); + + let rewritten = + crate::inprocess::statepoint_rewritten_ir(&module.to_ir(), &target, "leaf_probe") + .expect("leaf probe must survive RS4GC"); + // Exactly the two js_map_alloc calls become statepoints. + assert_eq!( + rewritten + .matches("@llvm.experimental.gc.statepoint") + .count(), + // one declare line + two wrapped call sites + 3, + "only the two unaudited js_map_alloc calls may be statepoints:\n{rewritten}" + ); + for direct in [ + "call i64 @js_box_alloc_bits(", + "call void @js_box_set_bits(", + "call i64 @js_closure_get_capture_bits(", + "call void @js_closure_set_capture_bits(", + ] { + assert!( + rewritten.contains(direct), + "audited accessor must remain a direct call ({direct}):\n{rewritten}" + ); + } + assert!( + !rewritten.contains("= call i64 @js_map_alloc("), + "the control callee must be statepoint-wrapped, not direct:\n{rewritten}" + ); + } + #[test] fn one_logical_slot_can_root_disjoint_physical_allocas() { let ir = r#"define void @f() { diff --git a/crates/perry-codegen/src/gc_call_effects.rs b/crates/perry-codegen/src/gc_call_effects.rs index 4c7dfea41b..c2b0e1f9dc 100644 --- a/crates/perry-codegen/src/gc_call_effects.rs +++ b/crates/perry-codegen/src/gc_call_effects.rs @@ -114,7 +114,49 @@ pub(crate) fn classify_direct_callee(name: &str) -> GcCallEffect { // TLS dynamic-call context only. | "js_implicit_this_set" | "js_new_target_get" - | "js_new_target_set" => GcCallEffect::CannotCollect, + | "js_new_target_set" + // Closure capture-slot accessors (#8132). `closure/alloc.rs`: + // `get` is a null check, a bounds check, and a raw slot read; + // `set` is the raw slot write plus `note_closure_capture_slot`, whose + // whole body is `layout_note_slot` + `runtime_write_barrier_gc_slot` — + // the same side-table/barrier bodies already admitted above as + // `js_gc_note_slot_layout` / `js_write_barrier_slot`. The `_ptr` + // spellings are one-line wrappers over the `_bits` pair. All four are + // in `gc_root_dominance_check.py`'s NONCOLLECTING (the audit + // authority this table must stay a subset of). On #8132's bundled + // module factory these were 1,495 of 5,537 statepoints. + | "js_closure_get_capture_bits" + | "js_closure_set_capture_bits" + | "js_closure_get_capture_ptr" + | "js_closure_set_capture_ptr" + // Variable-box accessors and allocators (#8132), `box.rs`. Boxes are + // `std::alloc::alloc` allocations OUTSIDE the GC heap — allocating + // one arms no Perry GC trigger (the malloc-count trigger counts + // `MALLOC_STATE` GC objects, not raw Rust allocations), and the + // registry insert is a TLS set. `gc_root_dominance_check.py`'s + // IMMOVABLE_SOURCES "box" probes pin exactly this: std::alloc, no + // arena allocation, no dealloc — if boxes ever become GC objects the + // lint fails and these entries must be demoted with it. The setters + // are a registry membership check, the raw cell write, and (for the + // JSValue box) `runtime_write_barrier_root_nanbox`, admitted above. + // The i32/bool getters are registry check + raw read; they have no + // TDZ path. + // + // `js_box_get_bits` is deliberately ABSENT: its TDZ arm calls + // `js_throw_reference_error_tdz`, which allocates the ReferenceError + // (string + error object) before unwinding — a genuine route into + // collection, per the `js_throw*` audit note below. The checker's + // NONCOLLECTING currently lists it anyway; this table does not + // inherit that entry, it only requires containment in the safe + // direction. + | "js_box_alloc_bits" + | "js_i32_box_alloc" + | "js_bool_box_alloc" + | "js_box_set_bits" + | "js_i32_box_set" + | "js_bool_box_set" + | "js_i32_box_get" + | "js_bool_box_get" => GcCallEffect::CannotCollect, // Audited allocate-but-never-reenter helpers (2026-07-31): each body // was checked for closure invocation, coercion (valueOf/toString), // and accessor dispatch — none present, and none takes a receiver @@ -215,6 +257,49 @@ mod tests { } } + /// #8132: capture-slot and variable-box accessors are leaf calls. On the + /// bundled-module-factory shape these were ~45% of all statepoints, each + /// paying a relocation for every live GC value. + #[test] + fn capture_and_box_accessors_cannot_collect() { + for name in [ + "js_closure_get_capture_bits", + "js_closure_set_capture_bits", + "js_closure_get_capture_ptr", + "js_closure_set_capture_ptr", + "js_box_alloc_bits", + "js_i32_box_alloc", + "js_bool_box_alloc", + "js_box_set_bits", + "js_i32_box_set", + "js_bool_box_set", + "js_i32_box_get", + "js_bool_box_get", + ] { + assert_eq!( + classify_direct_callee(name), + GcCallEffect::CannotCollect, + "{name}" + ); + } + } + + /// The discriminating negative for the family above: `js_box_get_bits` + /// reads a TDZ-seeded box's sentinel and calls + /// `js_throw_reference_error_tdz`, which ALLOCATES the ReferenceError + /// (string + error object) before unwinding. A leaf marking would leave + /// the catch handler's relocations unrecorded on the unwind edge. If a + /// future split gives the non-TDZ boxes their own entry point, THAT + /// symbol can be admitted; this one cannot. + #[test] + fn the_tdz_capable_box_getter_stays_a_safepoint() { + assert_eq!( + classify_direct_callee("js_box_get_bits"), + GcCallEffect::Unknown, + "js_box_get_bits can throw (and allocate) on the TDZ path" + ); + } + #[test] fn collection_and_unknown_calls_stay_conservative() { for name in [ diff --git a/crates/perry-codegen/src/stmt/boxed_slot_no_root_tests.rs b/crates/perry-codegen/src/stmt/boxed_slot_no_root_tests.rs new file mode 100644 index 0000000000..d2f98cabbe --- /dev/null +++ b/crates/perry-codegen/src/stmt/boxed_slot_no_root_tests.rs @@ -0,0 +1,218 @@ +//! #8132: a boxed local's slot must not be lowered as a GC root. +//! +//! A boxed local's alloca holds a `js_box_alloc_bits` result (or the +//! TAG_UNDEFINED sentinel) — never a GC-heap value. Boxes are `std::alloc` +//! allocations outside the GC heap: no collector phase moves them, box.rs +//! never frees them, and the JSValue inside is traced through the registered +//! `scan_box_roots_mut` scanner. Rooting the slot therefore protects nothing — +//! and under the RS4GC lowering it costs a relocation of the box pointer at +//! every statepoint it stays live across. On #8132's bundled webpack module +//! factory, ~300 preallocated boxes were live across ~90% of one function's +//! 5.5k statepoints: roughly a third of its 1.43M `gc.relocate`s protected +//! pointers the collector can never move. +//! +//! The fixture pairs the boxed local with an unboxed twin of the same shape +//! (`let plain = []`), so the assertions discriminate in both directions: +//! if the bind gate in `emit_shadow_slot_bind_for_local` is reverted, the box +//! pointer gets `inttoptr`-retyped into `addrspace(1)` and the negative +//! assertion fails; if binds were skipped wholesale, the twin's +//! `alloca ptr addrspace(1)` disappears and the premise assertion fails. + +use crate::{compile_module, AppMetadata, CompileOptions}; +use perry_hir::types::Type; +use perry_hir::{Expr, Function, Module, ModuleInitKind, Stmt}; + +fn ir_opts() -> CompileOptions { + CompileOptions { + target: None, + is_entry_module: true, + non_entry_module_prefixes: Vec::new(), + nextjs_path_init_modules: Vec::new(), + import_function_prefixes: std::collections::HashMap::new(), + import_function_ffi_aliases: std::collections::HashMap::new(), + import_function_origin_names: std::collections::HashMap::new(), + import_function_v8_specifiers: std::collections::HashMap::new(), + import_function_node_submodule: std::collections::HashMap::new(), + namespace_node_submodules: std::collections::HashMap::new(), + namespace_v8_specifiers: std::collections::HashMap::new(), + namespace_member_prefixes: std::collections::HashMap::new(), + namespace_member_origin_names: std::collections::HashMap::new(), + emit_ir_only: true, + verify_native_regions: false, + disable_buffer_fast_path: false, + namespace_imports: Vec::new(), + namespace_member_nested: Vec::new(), + imported_classes: Vec::new(), + imported_enums: Vec::new(), + imported_async_funcs: std::collections::HashSet::new(), + type_aliases: std::collections::HashMap::new(), + imported_func_param_counts: std::collections::HashMap::new(), + imported_func_has_rest: std::collections::HashSet::new(), + imported_func_synthetic_arguments: std::collections::HashSet::new(), + imported_func_return_types: std::collections::HashMap::new(), + imported_vars: std::collections::HashSet::new(), + output_type: "executable".to_string(), + needs_stdlib: false, + needs_ui: false, + needs_geisterhand: false, + geisterhand_port: 7676, + enabled_features: Vec::new(), + native_module_init_names: Vec::new(), + js_module_specifiers: Vec::new(), + bundled_extensions: Vec::new(), + native_library_functions: Vec::new(), + i18n_table: None, + fast_math: false, + fp_contract_mode: crate::FpContractMode::Off, + app_metadata: AppMetadata::default(), + namespace_entries: Vec::new(), + dynamic_import_path_to_prefix: std::collections::HashMap::new(), + deferred_module_prefixes: std::collections::HashSet::new(), + module_init_deps: Vec::new(), + is_dynamic_import_target: false, + debug_locations: false, + module_source: None, + debug_source_line_offset: 0, + } +} + +const ACC: u32 = 101; +const BUMP: u32 = 102; +const PLAIN: u32 = 103; + +/// `function f() { let acc = []; const bump = () => { acc = [1]; }; +/// let plain = []; bump(); return plain; }` +/// +/// `acc` is captured AND mutated by `bump`, so `collect_boxed_vars` boxes it; +/// `plain` has the identical pointer-bearing init but no capture, so it keeps +/// an ordinary rooted slot. +fn module_with_boxed_local() -> Module { + let mut m = Module::new("boxed_root.ts"); + let f = Function { + id: 1, + name: "f".to_string(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Any, + body: vec![ + Stmt::Let { + id: ACC, + name: "acc".to_string(), + ty: Type::Any, + mutable: true, + init: Some(Expr::Array(Vec::new())), + }, + Stmt::Let { + id: BUMP, + name: "bump".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::Closure { + func_id: 2, + params: Vec::new(), + return_type: Type::Any, + body: vec![Stmt::Expr(Expr::LocalSet( + ACC, + Box::new(Expr::Array(vec![Expr::Integer(1)])), + ))], + captures: vec![ACC], + mutable_captures: vec![ACC], + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: true, + is_async: false, + is_generator: false, + is_strict: false, + }), + }, + Stmt::Let { + id: PLAIN, + name: "plain".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::Array(Vec::new())), + }, + Stmt::Expr(Expr::Call { + callee: Box::new(Expr::LocalGet(BUMP)), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + }), + Stmt::Return(Some(Expr::LocalGet(PLAIN))), + ], + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }; + m.functions.push(f); + m.init_kind = ModuleInitKind::Eager; + m +} + +/// The `define` block that contains `needle` — the boxed function's body, +/// found by content rather than by name so the module-prefix naming scheme +/// cannot silently retarget the assertions. +fn define_containing<'a>(ir: &'a str, needle: &str) -> &'a str { + let mut starts: Vec = vec![0]; + let mut from = 0usize; + while let Some(i) = ir[from..].find("\ndefine ") { + starts.push(from + i + 1); + from = from + i + 1; + } + starts.push(ir.len()); + for pair in starts.windows(2) { + let block = &ir[pair[0]..pair[1]]; + if block.starts_with("define") && block.contains(needle) { + return block; + } + } + panic!("no define block contains `{needle}`"); +} + +#[test] +fn a_boxed_locals_slot_is_not_a_native_gc_root() { + let _native = crate::codegen::helpers::NativeRootsPin::native(); + let ir = String::from_utf8(compile_module(&module_with_boxed_local(), ir_opts()).unwrap()) + .expect("LLVM IR should be UTF-8"); + let f = define_containing(&ir, "@js_box_alloc_bits"); + + // Premise 1: `acc` really is boxed — the fixture would otherwise assert + // about a lowering that never ran. + let alloc_line = f + .lines() + .find(|l| l.contains("call i64 @js_box_alloc_bits")) + .expect("boxed local must allocate its box"); + let box_reg = alloc_line + .trim() + .split(" = ") + .next() + .expect("box alloc must name a result register") + .to_string(); + + // Premise 2: the unboxed twin keeps a rooted, addrspace(1)-retyped slot, + // so the box slot's missing root below is the gate's doing — not a + // collector that stopped rooting array-holding locals. + assert!( + f.contains("alloca ptr addrspace(1)"), + "the unboxed twin must still lower a precise root:\n{f}" + ); + + // The point: the box pointer is stored as a plain i64 and is never + // retyped into the GC address space, so RS4GC has nothing to relocate. + assert!( + f.contains(&format!("store i64 {box_reg},")), + "box pointer must be stored to its plain i64 slot:\n{f}" + ); + assert!( + !f.contains(&format!("inttoptr i64 {box_reg} to ptr addrspace(1)")), + "#8132: a boxed local's slot must not be lowered as an addrspace(1) \ + GC root — the box never moves and its contents are traced through \ + the box-registry scanner:\n{f}" + ); +} diff --git a/crates/perry-codegen/src/stmt/mod.rs b/crates/perry-codegen/src/stmt/mod.rs index 635ba339c1..156f675a6d 100644 --- a/crates/perry-codegen/src/stmt/mod.rs +++ b/crates/perry-codegen/src/stmt/mod.rs @@ -11,6 +11,8 @@ use crate::expr::{lower_expr, lower_expr_value, materialize_js_value, FnCtx}; use crate::native_value::{LoweredValue, MaterializationReason}; use crate::types::DOUBLE; +#[cfg(test)] +mod boxed_slot_no_root_tests; #[cfg(test)] mod class_field_loop_tests; mod counter_range; diff --git a/crates/perry-codegen/tests/shadow_slot_hygiene.rs b/crates/perry-codegen/tests/shadow_slot_hygiene.rs index 9f0196680c..ca7493e823 100644 --- a/crates/perry-codegen/tests/shadow_slot_hygiene.rs +++ b/crates/perry-codegen/tests/shadow_slot_hygiene.rs @@ -1044,9 +1044,28 @@ fn closure_body_write_to_captured_outer_local_is_visible_to_shadow_analysis() { fn_ir.contains("call ptr @js_shadow_frame_enter(i32 2)"), "captured Any local written to a pointer inside a closure must keep its outer slot" ); + // The analysis half of the contract, stated positively: the captured + // write IS visible, so `value` is boxed and its writes route through the + // box cell. assert!( - fn_ir.contains("call void @js_shadow_slot_bind(i32 0, ptr %"), - "boxed captured local should bind its outer shadow slot to the box slot" + fn_ir.contains("call i64 @js_box_alloc_bits"), + "a captured-and-mutated local must be boxed:\n{fn_ir}" + ); + // #8132: the box-pointer slot itself is deliberately NOT bound. The slot + // only ever holds a `js_box_alloc_bits` result — a `std::alloc` cell + // outside the GC heap that no collector phase moves or frees, whose + // contents the box-registry scanner traces — so binding it rooted + // nothing and (under RS4GC) cost a relocation of the box pointer at + // every statepoint it stayed live across. Slot 0 is `value`'s. + assert!( + !fn_ir.contains("call void @js_shadow_slot_bind(i32 0, ptr %"), + "#8132: a boxed local's box-pointer slot must not be bound as a GC root:\n{fn_ir}" + ); + // The discriminating control: `writer` (slot 1) holds a movable closure + // and must still bind — if binds were skipped wholesale this fails. + assert!( + fn_ir.contains("call void @js_shadow_slot_bind(i32 1, ptr %"), + "the unboxed closure local must still bind its shadow slot:\n{fn_ir}" ); } diff --git a/scripts/gc_root_dominance_check.py b/scripts/gc_root_dominance_check.py index 7ff6b8046a..e09de24320 100755 --- a/scripts/gc_root_dominance_check.py +++ b/scripts/gc_root_dominance_check.py @@ -483,6 +483,16 @@ def build_cfg(f): "js_closure_set_capture_ptr", "js_closure_get_capture_ptr", "js_box_set_bits", "js_box_get_bits", # box.rs:317 raw cell write "js_i32_box_set", "js_bool_box_set", + "js_i32_box_get", "js_bool_box_get", # registry check + raw read, no TDZ + # Box allocators (#8132): `std::alloc::alloc` + a TLS registry insert. + # A raw Rust allocation arms no Perry GC trigger (the malloc-count + # trigger counts MALLOC_STATE GC objects), so the call cannot enter the + # collector. The premise is machine-checked: IMMOVABLE_SOURCES' "box" + # probes below fail if box.rs ever arena-allocates or grows a free path, + # and these entries must be removed with them. Required here for the + # one-way containment `gc_call_effects.rs` documents (its CannotCollect + # set must stay a subset of this one). + "js_box_alloc_bits", "js_i32_box_alloc", "js_bool_box_alloc", "js_write_barrier", # gc/barrier.rs:930 "js_tdz_suppress_begin", "js_tdz_suppress_end", # box.rs:242/248 counter "js_array_note_numeric_write", # array/header.rs:1443