diff --git a/changelog.d/11297-class-capture-environment.md b/changelog.d/11297-class-capture-environment.md new file mode 100644 index 0000000000..e13c7b69e2 --- /dev/null +++ b/changelog.d/11297-class-capture-environment.md @@ -0,0 +1,14 @@ +Classes nested in a function no longer store their captured outer variables +on every instance. A class whose definition runs once, or a class expression +evaluated to a fresh class object (every class in a CommonJS module body), +keeps its captures in a per-class environment read with one compare and one +load; a second evaluation (a re-run module body) is resolved per receiver, +so each instance still sees its own evaluation's values. TypeScript's AST +nodes lose their 3-10 hidden `__perry_cap_*` keys (25% fewer bytes per node), +`pos`/`end`/`kind` no longer shift with a class's capture count, and +`ts.transpileModule` runs about 11% fewer instructions. + +A class expression in env mode that closes over a `for (let …)` head binding +keeps #11250's expired-head rewrite: the refresh re-reads that evaluation's +own capture array, which the runtime republishes into the environment slots +only for the owning evaluation (`test_gap_11297_env_class_for_let_capture`). diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index a1553d9dab..07b8c73bb2 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -1368,6 +1368,8 @@ pub(super) fn register_module_globals_as_gc_roots( ctx.block() .call_void("js_gc_register_global_root", &[(I64, &addr)]); } + // Guarded class environments: hand the runtime their state and slots. + crate::expr::class_env::register_class_envs(ctx); } /// Issue #100: emit the IR that populates this module's diff --git a/crates/perry-codegen/src/codegen/module_globals_emit.rs b/crates/perry-codegen/src/codegen/module_globals_emit.rs index 14b4887ccd..5b0388291f 100644 --- a/crates/perry-codegen/src/codegen/module_globals_emit.rs +++ b/crates/perry-codegen/src/codegen/module_globals_emit.rs @@ -620,6 +620,48 @@ pub(crate) fn emit_module_globals( static_field_globals.insert((c.name.clone(), sf.name.clone()), name); } } + // Class capture environments (`Expr::ClassEnvGet`/`ClassEnvSet`): one + // module-state global per published slot, filed under a key no static + // field can spell so the GC-root registration and every lowering context + // pick them up with the static fields. Starts `undefined`; the class + // evaluation (`RegisterClassCaptures` / `ClassExprFresh`) and the + // constructor fill it. + for c in &hir.classes { + let (slots, guarded) = crate::expr::class_env::class_env_layout(c); + if guarded { + // 0.0 = one evaluation so far; the runtime writes 1.0 on a second. + let name = format!( + "perry_classenv_{}__{}__state", + module_prefix, + sanitize_member(&c.name), + ); + if external_globals_emitted.insert(name.clone()) { + llmod.add_module_state_global(&name, DOUBLE, "0.0"); + } + static_field_globals.insert( + (c.name.clone(), perry_hir::cap_fields::class_env_state_key()), + name, + ); + } + for index in 0..slots { + let name = format!( + "perry_classenv_{}__{}__{}", + module_prefix, + sanitize_member(&c.name), + index, + ); + if external_globals_emitted.insert(name.clone()) { + llmod.add_module_state_global(&name, DOUBLE, "0x7FFC000000000001"); + } + static_field_globals.insert( + ( + c.name.clone(), + perry_hir::cap_fields::class_env_slot_key(index), + ), + name, + ); + } + } // Register foreign static-field globals from imported classes. The source // module emits the defining external global (above); the consumer just // declares a reference and adds it to its own `static_field_globals` map diff --git a/crates/perry-codegen/src/expr/class_env.rs b/crates/perry-codegen/src/expr/class_env.rs new file mode 100644 index 0000000000..ce3a7a60a2 --- /dev/null +++ b/crates/perry-codegen/src/expr/class_env.rs @@ -0,0 +1,380 @@ +//! Class capture environment (`Expr::ClassEnvGet` / `ClassEnvSet` / +//! `ClassEnvStamp`). +//! +//! A class whose definition is evaluated at most once, or a class expression +//! evaluated to a fresh class object per evaluation, keeps its captured outer +//! values with the class rather than on every instance (see +//! `perry_hir::lower::run_once` and `synthesize_class_captures`). Each slot is +//! one module-state global — `@perry_classenv_____` — +//! registered as a mutable GC root with the static-field globals, so a read is +//! one load and a write one rooted store. +//! +//! A GUARDED class (the fresh-class-expression case) also has a state global, +//! `0.0` while the class has had a single evaluation and `1.0` after a second +//! one. Guarded reads and writes compare it against zero and take the slot +//! directly; otherwise they call into `perry-runtime`'s `class_env`, which +//! resolves the receiver's own evaluation. The evaluation and refresh of a +//! guarded class publish through the runtime, which only lets the class's +//! first evaluation reach the slots. + +use anyhow::Result; +use perry_hir::{Class, Expr, Stmt}; + +use crate::nanbox::double_literal; +use crate::types::{DOUBLE, I32, PTR}; + +use super::{emit_root_nanbox_store_for_expr, lower_expr, FnCtx}; + +/// `(slot count, guarded)` for `class`: one past the highest index its +/// constructor publishes, and whether that publish is guarded. Zero slots for +/// a class that keeps instance captures. +pub(crate) fn class_env_layout(class: &Class) -> (u32, bool) { + let mut count = 0; + let mut guarded = false; + if let Some(ctor) = class.constructor.as_ref() { + for stmt in &ctor.body { + if let Stmt::Expr(Expr::ClassEnvSet { + class_name, + index, + guarded: g, + publish: true, + .. + }) = stmt + { + if *class_name == class.name { + count = count.max(*index + 1); + guarded |= *g; + } + } + } + } + (count, guarded) +} + +/// Number of environment slots `class` uses (see [`class_env_layout`]). +pub(crate) fn class_env_slot_count(class: &Class) -> u32 { + class_env_layout(class).0 +} + +/// The global holding `class_name`'s environment slot `index`, when this +/// module defines one. +pub(crate) fn class_env_global(ctx: &FnCtx<'_>, class_name: &str, index: u32) -> Option { + ctx.static_field_globals + .get(&( + class_name.to_string(), + perry_hir::cap_fields::class_env_slot_key(index), + )) + .map(|name| format!("@{name}")) +} + +/// The state global of a guarded class, when this module defines one. +pub(crate) fn class_env_state_global(ctx: &FnCtx<'_>, class_name: &str) -> Option { + ctx.static_field_globals + .get(&( + class_name.to_string(), + perry_hir::cap_fields::class_env_state_key(), + )) + .map(|name| format!("@{name}")) +} + +/// Store an already-lowered capture value into an UNGUARDED class's slot. A +/// guarded class publishes through the runtime instead (only its first +/// evaluation may reach the slots), so this is a no-op for it. +pub(crate) fn store_class_env_slot( + ctx: &mut FnCtx<'_>, + class_name: &str, + index: u32, + value: &str, + expr: &Expr, +) { + if class_env_state_global(ctx, class_name).is_some() { + return; + } + if let Some(slot) = class_env_global(ctx, class_name, index) { + // GC_STORE_AUDIT(ROOT): environment slots are registered mutable + // roots (`register_module_globals_as_gc_roots` walks every + // static-field global, and these live in that map). + emit_root_nanbox_store_for_expr(ctx, value, &slot, expr); + } +} + +/// `js_class_env_evaluate` / `js_class_env_refresh` for a guarded class: +/// `class_value` (NaN-boxed) evaluated or refreshed with capture array +/// `caps` (NaN-boxed). No-op for an unguarded class. +pub(crate) fn publish_guarded( + ctx: &mut FnCtx<'_>, + class_name: &str, + runtime_fn: &str, + class_value: &str, + caps: &str, +) { + if class_env_state_global(ctx, class_name).is_none() { + return; + } + let Some(cid) = ctx.class_ids.get(class_name).copied() else { + return; + }; + let cid = cid.to_string(); + ctx.block().call_void( + runtime_fn, + &[(I32, &cid), (DOUBLE, class_value), (DOUBLE, caps)], + ); +} + +/// Emit `if (state == 0) { fast } else { slow }` and join the two values. +/// `fast`/`slow` run with the current block set to their branch. +fn branch_on_state( + ctx: &mut FnCtx<'_>, + state: &str, + fast: impl FnOnce(&mut FnCtx<'_>) -> String, + slow: impl FnOnce(&mut FnCtx<'_>) -> String, +) -> String { + let st = ctx.block().load(DOUBLE, state); + let single = ctx.block().fcmp("oeq", &st, "0.0"); + let fast_idx = ctx.new_block("classenv.single"); + let slow_idx = ctx.new_block("classenv.multi"); + let done_idx = ctx.new_block("classenv.done"); + let fast_label = ctx.block_label(fast_idx); + let slow_label = ctx.block_label(slow_idx); + let done_label = ctx.block_label(done_idx); + ctx.block().cond_br(&single, &fast_label, &slow_label); + + ctx.current_block = fast_idx; + let fast_v = fast(ctx); + let fast_end = ctx.block().label.clone(); + ctx.block().br(&done_label); + + ctx.current_block = slow_idx; + let slow_v = slow(ctx); + let slow_end = ctx.block().label.clone(); + ctx.block().br(&done_label); + + ctx.current_block = done_idx; + ctx.block() + .phi(DOUBLE, &[(&fast_v, &fast_end), (&slow_v, &slow_end)]) +} + +/// Store `v` into `slot` unless it is `undefined` (see the publish arm of +/// `Expr::ClassEnvSet`). +fn publish_unless_undefined(ctx: &mut FnCtx<'_>, v: &str, slot: &str, value: &Expr) { + let bits = ctx.block().bitcast_double_to_i64(v); + let undefined = format!("{}", crate::nanbox::TAG_UNDEFINED as i64); + let present = ctx.block().icmp_ne(crate::types::I64, &bits, &undefined); + let store_idx = ctx.new_block("classenv.publish"); + let done_idx = ctx.new_block("classenv.publish.done"); + let store_label = ctx.block_label(store_idx); + let done_label = ctx.block_label(done_idx); + ctx.block().cond_br(&present, &store_label, &done_label); + ctx.current_block = store_idx; + // GC_STORE_AUDIT(ROOT): registered mutable root slot. + emit_root_nanbox_store_for_expr(ctx, v, slot, value); + ctx.block().br(&done_label); + ctx.current_block = done_idx; +} + +fn receiver(ctx: &mut FnCtx<'_>) -> String { + if let Some(this_slot) = ctx.this_stack.last().cloned() { + ctx.block().load(DOUBLE, &this_slot) + } else { + double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) + } +} + +/// Module-init registration of every guarded class environment this module +/// defines, so the runtime's slow paths can reach the state and slot globals. +pub(crate) fn register_class_envs(ctx: &mut FnCtx<'_>) { + let mut classes: Vec<(String, u32)> = ctx + .classes + .values() + .map(|c| (c.name.clone(), class_env_slot_count(c))) + .filter(|(_, n)| *n > 0) + .collect(); + classes.sort(); + for (name, slots) in classes { + let Some(state) = class_env_state_global(ctx, &name) else { + continue; + }; + let Some(cid) = ctx.class_ids.get(&name).copied() else { + continue; + }; + let cid = cid.to_string(); + ctx.block() + .call_void("js_class_env_register_state", &[(I32, &cid), (PTR, &state)]); + for index in 0..slots { + if let Some(slot) = class_env_global(ctx, &name, index) { + let idx = index.to_string(); + ctx.block().call_void( + "js_class_env_register_slot", + &[(I32, &cid), (I32, &idx), (PTR, &slot)], + ); + } + } + } +} + +pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { + match expr { + Expr::ClassEnvGet { + class_name, index, .. + } => { + let Some(slot) = class_env_global(ctx, class_name, *index) else { + // No slot in this module (nothing published this index): the + // decl-site snapshot holds the same evaluation's values. + return Ok(match ctx.class_ids.get(class_name).copied() { + Some(cid) => { + let cid = cid.to_string(); + let idx = index.to_string(); + ctx.block().call( + DOUBLE, + "js_class_capture_value", + &[(I32, &cid), (I32, &idx)], + ) + } + None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), + }); + }; + let Some(state) = class_env_state_global(ctx, class_name) else { + return Ok(ctx.block().load(DOUBLE, &slot)); + }; + let cid = ctx + .class_ids + .get(class_name) + .copied() + .unwrap_or(0) + .to_string(); + let idx = index.to_string(); + Ok(branch_on_state( + ctx, + &state, + |ctx| ctx.block().load(DOUBLE, &slot), + |ctx| { + let recv = receiver(ctx); + ctx.block().call( + DOUBLE, + "js_class_env_get", + &[(DOUBLE, &recv), (I32, &cid), (I32, &idx)], + ) + }, + )) + } + Expr::ClassEnvSet { + class_name, + index, + value, + publish, + .. + } => { + let v = lower_expr(ctx, value)?; + let Some(slot) = class_env_global(ctx, class_name, *index) else { + return Ok(v); + }; + let state = class_env_state_global(ctx, class_name); + if *publish { + // The constructor's capture params are not always filled from + // the class's evaluation: `super(...args)` reaching an + // ancestor constructor through the runtime fills them from the + // decl-site snapshot, which a class expression does not have, + // so they arrive `undefined`. The evaluation itself (and every + // refresh) publishes the real values, so a publish must never + // overwrite them with a missing param. A guarded class is + // always a fresh class expression whose evaluation publishes, + // so its publish is dropped entirely. + if state.is_none() { + publish_unless_undefined(ctx, &v, &slot, value); + } + return Ok(v); + } + let Some(state) = state else { + // GC_STORE_AUDIT(ROOT): registered mutable root slot. + emit_root_nanbox_store_for_expr(ctx, &v, &slot, value); + return Ok(v); + }; + let cid = ctx + .class_ids + .get(class_name) + .copied() + .unwrap_or(0) + .to_string(); + let idx = index.to_string(); + branch_on_state( + ctx, + &state, + |ctx| { + // GC_STORE_AUDIT(ROOT): registered mutable root slot. + emit_root_nanbox_store_for_expr(ctx, &v, &slot, value); + v.clone() + }, + |ctx| { + let recv = receiver(ctx); + ctx.block().call_void( + "js_class_env_set", + &[(DOUBLE, &recv), (I32, &cid), (I32, &idx), (DOUBLE, &v)], + ); + v.clone() + }, + ); + Ok(v) + } + Expr::ClassEnvStamp { + class_name, + instance, + evaluation, + } => { + let inst = lower_expr(ctx, instance)?; + let Some(state) = class_env_state_global(ctx, class_name) else { + return Ok(inst); + }; + let cid = ctx + .class_ids + .get(class_name) + .copied() + .unwrap_or(0) + .to_string(); + let mut lowered: Result<()> = Ok(()); + let out = branch_on_state( + ctx, + &state, + |_| inst.clone(), + |ctx| match lower_expr(ctx, evaluation) { + Ok(eval) => ctx.block().call( + DOUBLE, + "js_class_env_stamp", + &[(DOUBLE, &inst), (I32, &cid), (DOUBLE, &eval)], + ), + Err(e) => { + lowered = Err(e); + inst.clone() + } + }, + ); + lowered?; + Ok(out) + } + Expr::ClassEnvCurrent { class_name } => { + let undefined = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); + let Some(state) = class_env_state_global(ctx, class_name) else { + return Ok(undefined); + }; + let cid = ctx + .class_ids + .get(class_name) + .copied() + .unwrap_or(0) + .to_string(); + Ok(branch_on_state( + ctx, + &state, + |_| undefined.clone(), + |ctx| { + let recv = receiver(ctx); + ctx.block().call( + DOUBLE, + "js_class_env_current", + &[(DOUBLE, &recv), (I32, &cid)], + ) + }, + )) + } + _ => unreachable!("class_env::lower called with {expr:?}"), + } +} diff --git a/crates/perry-codegen/src/expr/dispatch.rs b/crates/perry-codegen/src/expr/dispatch.rs index 549e5aa6c1..fa1ee917ac 100644 --- a/crates/perry-codegen/src/expr/dispatch.rs +++ b/crates/perry-codegen/src/expr/dispatch.rs @@ -538,6 +538,10 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { | Expr::StructuredClone { .. } | Expr::WeakRefNew(..) => super::env_clones::lower(ctx, expr), Expr::FsUnlinkSync(..) | Expr::Await(..) => super::fs_await::lower(ctx, expr), + Expr::ClassEnvGet { .. } + | Expr::ClassEnvSet { .. } + | Expr::ClassEnvStamp { .. } + | Expr::ClassEnvCurrent { .. } => super::class_env::lower(ctx, expr), Expr::StaticFieldGet { .. } | Expr::StaticFieldSet { .. } | Expr::RegisterClassParentDynamic { .. } diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index d98515e87e..9b49dd5744 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -3000,6 +3000,7 @@ mod boolean_number_tests; mod call_spread; pub(crate) mod calls; mod child_proc; +pub(crate) mod class_env; mod closure; mod compare; pub(crate) mod region_guard; diff --git a/crates/perry-codegen/src/expr/static_field_meta.rs b/crates/perry-codegen/src/expr/static_field_meta.rs index ca7cf2391c..1e271a8272 100644 --- a/crates/perry-codegen/src/expr/static_field_meta.rs +++ b/crates/perry-codegen/src/expr/static_field_meta.rs @@ -156,8 +156,11 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // side-effect-free capture loads — no user code runs inside it. ctx.block().call_void("js_tdz_suppress_begin", &[]); let mut lowered: Vec = Vec::with_capacity(captures.len()); - for c in captures { - lowered.push(lower_expr(ctx, c)?); + for (index, c) in captures.iter().enumerate() { + let v = lower_expr(ctx, c)?; + // A class-environment class's evaluation publishes here too. + super::class_env::store_class_env_slot(ctx, class_name, index as u32, &v, c); + lowered.push(v); } ctx.block().call_void("js_tdz_suppress_end", &[]); if let Some(&class_id) = ctx.class_ids.get(class_name) { @@ -198,12 +201,22 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { Expr::RefreshClassExprCaptures { class_value, captures, + env_class, } => { let cap_len = captures.len().to_string(); let mut caps_arr = ctx.block().call(I64, "js_array_alloc", &[(I32, &cap_len)]); ctx.block().call_void("js_tdz_suppress_begin", &[]); - for capture in captures { + for (index, capture) in captures.iter().enumerate() { let value = lower_expr(ctx, capture)?; + if let Some(env_class) = env_class { + super::class_env::store_class_env_slot( + ctx, + env_class, + index as u32, + &value, + capture, + ); + } caps_arr = ctx.block().call( I64, "js_array_push_f64", @@ -215,6 +228,15 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // Lower after the allocating array operations so a movable class // object is reloaded from its compiler-private rooted local. let owner = lower_expr(ctx, class_value)?; + if let Some(env_class) = env_class { + super::class_env::publish_guarded( + ctx, + env_class, + "js_class_env_refresh", + &owner, + &caps_box, + ); + } let key_idx = ctx.strings.intern("__perry_ctor_caps"); let key_handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global); let key_box = ctx.block().load(DOUBLE, &key_handle_global); @@ -630,8 +652,18 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { protect_caps, |ctx, acc| { ctx.block().call_void("js_tdz_suppress_begin", &[]); - for arg in captured_args { + for (index, arg) in captured_args.iter().enumerate() { let v = lower_expr(ctx, arg)?; + // The evaluation publishes a class-environment + // class's slots before any static initializer + // or member can read them. + super::class_env::store_class_env_slot( + ctx, + template, + index as u32, + &v, + arg, + ); acc.advance(ctx, "js_array_push_f64", &[Arg::Plain(DOUBLE, &v)]); } ctx.block().call_void("js_tdz_suppress_end", &[]); @@ -653,6 +685,17 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { "js_object_set_field_by_name", &[(I64, &obj), (I64, &key_raw), (DOUBLE, &caps_box)], ); + // A guarded class environment learns this evaluation; the + // first one publishes its captures into the slots. + let obj = group.reread_emitted(ctx, rooted); + let obj_box = nanbox_pointer_inline(ctx.block(), &obj); + super::class_env::publish_guarded( + ctx, + template, + "js_class_env_evaluate", + &obj_box, + &caps_box, + ); } // Static fields and blocks execute only after every computed // name has been resolved, then in their original ClassBody diff --git a/crates/perry-codegen/src/lower_call/capture_writeback.rs b/crates/perry-codegen/src/lower_call/capture_writeback.rs index 98f0f46eb9..c06202bfc5 100644 --- a/crates/perry-codegen/src/lower_call/capture_writeback.rs +++ b/crates/perry-codegen/src/lower_call/capture_writeback.rs @@ -53,6 +53,14 @@ pub(crate) fn emit_class_capture_writeback( .collect(); // The cap args occupy the last cap_params.len() slots of new_args. let cap_args_start = new_args.len().saturating_sub(cap_params.len()); + // Capture param order is environment slot order. + let (env_slots, guarded) = crate::expr::class_env::class_env_layout(class); + // A guarded environment may hold another evaluation's values; a member- + // or ctor-side write of a capture is a shared cell anyway (#5951), so the + // outer binding already sees it. + if guarded { + return; + } for (cap_idx, param) in cap_params.iter().enumerate() { let Some(name_outer_id) = perry_hir::cap_fields::cap_field_outer_id(¶m.name) else { @@ -89,18 +97,29 @@ pub(crate) fn emit_class_capture_writeback( if outer_slot.is_none() && !outer_is_canonical_i32 { continue; } - // Read the updated capture value from the instance field. - let field_name = ¶m.name; - let key_idx = ctx.strings.intern(field_name); - let key_handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global); - let key_box = ctx.block().load(DOUBLE, &key_handle_global); - let key_bits = ctx.block().bitcast_double_to_i64(&key_box); - let key_handle = ctx.block().and(I64, &key_bits, POINTER_MASK_I64); - let val = ctx.block().call( - DOUBLE, - "js_object_get_field_by_name_f64", - &[(I64, obj_handle), (I64, &key_handle)], - ); + // Read the updated capture value: from the class environment when the + // class keeps its captures there (its constructor published every + // slot), otherwise from the instance field. + let val = if env_slots > 0 { + let Some(slot) = + crate::expr::class_env::class_env_global(ctx, &class.name, cap_idx as u32) + else { + continue; + }; + ctx.block().load(DOUBLE, &slot) + } else { + let field_name = ¶m.name; + let key_idx = ctx.strings.intern(field_name); + let key_handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global); + let key_box = ctx.block().load(DOUBLE, &key_handle_global); + let key_bits = ctx.block().bitcast_double_to_i64(&key_box); + let key_handle = ctx.block().and(I64, &key_bits, POINTER_MASK_I64); + ctx.block().call( + DOUBLE, + "js_object_get_field_by_name_f64", + &[(I64, obj_handle), (I64, &key_handle)], + ) + }; // Store the updated value. Handle boxed locals (shared across multiple // closures) via js_box_set; plain locals via a direct slot store. // `outer_id` here is the current-scope id (resolved via new_args or diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index d93870859b..d6edd79a1e 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -1528,6 +1528,15 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { module.declare_function("js_tdz_suppress_end", VOID, &[]); // Static-method prologue read of one decl-site capture snapshot slot. module.declare_function("js_class_capture_value", DOUBLE, &[I32, I32]); + // Guarded class capture environments (`perry-runtime` object/class_env). + module.declare_function("js_class_env_register_state", VOID, &[I32, PTR]); + module.declare_function("js_class_env_register_slot", VOID, &[I32, I32, PTR]); + module.declare_function("js_class_env_evaluate", VOID, &[I32, DOUBLE, DOUBLE]); + module.declare_function("js_class_env_refresh", VOID, &[I32, DOUBLE, DOUBLE]); + module.declare_function("js_class_env_get", DOUBLE, &[DOUBLE, I32, I32]); + module.declare_function("js_class_env_set", VOID, &[DOUBLE, I32, I32, DOUBLE]); + module.declare_function("js_class_env_stamp", DOUBLE, &[DOUBLE, I32, DOUBLE]); + module.declare_function("js_class_env_current", DOUBLE, &[DOUBLE, I32]); module.declare_function( "js_class_capture_value_for_receiver", DOUBLE, diff --git a/crates/perry-hir/src/analysis.rs b/crates/perry-hir/src/analysis.rs index 1d9dd7af5d..6d594e2b50 100644 --- a/crates/perry-hir/src/analysis.rs +++ b/crates/perry-hir/src/analysis.rs @@ -663,10 +663,23 @@ pub fn remap_local_ids_in_stmts( /// by inspecting the original id, then runs the standard remap on the /// LocalSet/Update inside the wrap so the resulting Sequence references the /// fresh per-method id everywhere consistently. +/// Where a member's write to a captured binding is propagated. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CaptureWriteTarget { + /// The per-instance `this.` snapshot (`__perry_cap_*`). + Field(String), + /// Slot `index` of the class environment (`Expr::ClassEnvSet`). + Env { + class_name: String, + index: u32, + guarded: bool, + }, +} + pub fn remap_local_ids_in_stmts_with_field_propagation( stmts: &mut Vec, map: &std::collections::HashMap, - field_propagation: &std::collections::HashMap, + field_propagation: &std::collections::HashMap, ) { if map.is_empty() && field_propagation.is_empty() { return; @@ -679,7 +692,7 @@ pub fn remap_local_ids_in_stmts_with_field_propagation( fn remap_local_ids_in_stmt_propagating( stmt: &mut Stmt, map: &std::collections::HashMap, - fp: &std::collections::HashMap, + fp: &std::collections::HashMap, ) { match stmt { Stmt::Let { init, .. } => { @@ -778,11 +791,11 @@ fn remap_local_ids_in_stmt_propagating( fn remap_with_propagation( expr: &mut Expr, map: &std::collections::HashMap, - fp: &std::collections::HashMap, + fp: &std::collections::HashMap, ) { // Detect captured LocalSet / Update at THIS position. Use the // pre-remap (outer) id to look up the field name. - let captured_field: Option<(LocalId, String)> = match expr { + let captured_field: Option<(LocalId, CaptureWriteTarget)> = match expr { Expr::LocalSet(id, _) => fp.get(id).map(|f| (*id, f.clone())), Expr::Update { id, .. } => fp.get(id).map(|f| (*id, f.clone())), _ => None, @@ -797,14 +810,26 @@ fn remap_with_propagation( // After remap, the LocalSet/Update's id is fresh_id (or unchanged // if outer_id wasn't in `map`). let fresh_id = *map.get(&outer_id).unwrap_or(&outer_id); - *expr = Expr::Sequence(vec![ - original, - Expr::PropertySet { + let value = Box::new(Expr::LocalGet(fresh_id)); + let propagate = match field_name { + CaptureWriteTarget::Field(property) => Expr::PropertySet { object: Box::new(Expr::This), - property: field_name, - value: Box::new(Expr::LocalGet(fresh_id)), + property, + value, + }, + CaptureWriteTarget::Env { + class_name, + index, + guarded, + } => Expr::ClassEnvSet { + class_name, + index, + value, + guarded, + publish: false, }, - ]); + }; + *expr = Expr::Sequence(vec![original, propagate]); return; } // Not a captured write at this position. Recurse via the standard diff --git a/crates/perry-hir/src/cap_fields.rs b/crates/perry-hir/src/cap_fields.rs index 23f576beac..6ee37c1818 100644 --- a/crates/perry-hir/src/cap_fields.rs +++ b/crates/perry-hir/src/cap_fields.rs @@ -30,6 +30,19 @@ pub fn cap_field_name(salt: u64, id: u32) -> String { format!("{CAP_FIELD_PREFIX}{id}m{:012x}", salt & 0xFFFF_FFFF_FFFF) } +/// Codegen's `static_field_globals` key for slot `index` of a class's capture +/// environment (`Expr::ClassEnvGet`/`ClassEnvSet`). The leading `\u{1}` keeps +/// it disjoint from every real static field name. +pub fn class_env_slot_key(index: u32) -> String { + format!("\u{1}perry_env{index}") +} + +/// Codegen's `static_field_globals` key for a guarded class environment's +/// state global (`Expr::ClassEnvGet::guarded`). +pub fn class_env_state_key() -> String { + "\u{1}perry_env_state".to_string() +} + /// Parse the outer local id from a cap field/param name. Accepts both the /// salted `__perry_cap_m` form and the legacy `__perry_cap_` /// (still produced by pre-salt HIR in caches/tests). diff --git a/crates/perry-hir/src/ir/expr.rs b/crates/perry-hir/src/ir/expr.rs index b621ee7118..e0d7e45430 100644 --- a/crates/perry-hir/src/ir/expr.rs +++ b/crates/perry-hir/src/ir/expr.rs @@ -480,6 +480,69 @@ pub enum Expr { RefreshClassExprCaptures { class_value: Box, captures: Vec, + /// The template class whose members read their captures from the + /// class environment (`ClassEnvGet`), when the class takes that path. + /// The refresh then also rewrites the environment slots, so members + /// observe a captured binding initialized after the class evaluated. + env_class: Option, + }, + + /// Read slot `index` of a class's capture ENVIRONMENT: the per-class + /// storage holding the class-definition evaluation's captured outer + /// values, the way V8 keeps them in the closure context. Emitted for + /// classes whose definition is evaluated at most once (see + /// `lower::run_once`). Their instance members read captures here instead + /// of from per-instance `__perry_cap_*` fields, so instances carry no + /// hidden capture keys and the slot of every real field does not depend + /// on the class's capture count. Codegen lowers it to one load of a + /// module-state global. + ClassEnvGet { + class_name: String, + index: u32, + /// The class definition may be evaluated more than once (a CommonJS + /// module body the runtime can re-run), so the read is GUARDED: while + /// the class has had one evaluation the slot is read directly; after a + /// second one the runtime resolves the receiver's own evaluation and + /// reads that evaluation's capture array unless it is the owner. + guarded: bool, + }, + + /// Write slot `index` of a class's capture environment (see + /// `ClassEnvGet`). Emitted where a member or the constructor assigns a + /// captured binding, and where the constructor publishes its capture + /// params. Evaluates to `value`. + ClassEnvSet { + class_name: String, + index: u32, + value: Box, + /// See `ClassEnvGet::guarded`. + guarded: bool, + /// The constructor's entry publish of its capture params (as opposed + /// to a member's write of a captured binding). A guarded publish only + /// runs while the class has had a single evaluation: afterwards the + /// params may belong to a later evaluation, whose own capture array + /// already holds them. + publish: bool, + }, + + /// Construct `instance` (an `Expr::New` of a guarded class-environment + /// class, see `ClassEnvGet::guarded`) and, once the class has had more + /// than one evaluation, record `evaluation` (the class value the `new` + /// site's binding holds) as the instance's evaluation. An unrecorded + /// instance belongs to the class's first evaluation. + ClassEnvStamp { + class_name: String, + instance: Box, + evaluation: Box, + }, + + /// The class value of the evaluation the enclosing member of guarded + /// class `class_name` belongs to (the extracted method's own, else the + /// receiver's), or `undefined` while the class has had a single + /// evaluation or for its first one. Feeds `ClassEnvStamp` for a + /// `new ()` inside the class's own members. + ClassEnvCurrent { + class_name: String, }, /// Read slot `index` of a class's decl-site capture snapshot diff --git a/crates/perry-hir/src/lower/class_capture_scope.rs b/crates/perry-hir/src/lower/class_capture_scope.rs index 8647ec60cf..f9eb12fd4c 100644 --- a/crates/perry-hir/src/lower/class_capture_scope.rs +++ b/crates/perry-hir/src/lower/class_capture_scope.rs @@ -17,6 +17,19 @@ //! an expired head from the class object's own capture slot instead. The //! name-keyed `RegisterClassCaptures` snapshot has no per-evaluation slot to //! re-read and is dropped; the per-object refresh is authoritative over it. +//! +//! A class-environment refresh (`env_class: Some`) is rewritten the same way. +//! Only a fresh class expression can close over a loop-head binding in env +//! mode: a class declaration in a loop body is `Repeatable` (per-instance +//! snapshot) and a run-once definition is never inside a loop. A fresh class +//! expression is GUARDED: every evaluation, owner included, still carries its +//! own `__perry_ctor_caps` array, the refresh rebuilds that array, and +//! `js_class_env_refresh` copies it into the environment slots only for the +//! class's first (owner) evaluation. Re-reading the expired head from the +//! array therefore republishes the value the evaluation already holds. The +//! array and the owner's slots cannot disagree on a head binding: a member +//! that writes it makes it a shared-mutable capture (the loop head writes it +//! too), which `shared_mutable_capture` boxes, so both hold the same box. use std::collections::{BTreeSet, HashMap, HashSet}; @@ -120,6 +133,7 @@ impl Pruner { Expr::RefreshClassExprCaptures { class_value, captures, + .. } => { for (index, capture) in captures.iter_mut().enumerate() { if self.expired(capture) { diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index 2eae5bb63b..80d4cef5f4 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -478,6 +478,27 @@ impl LoweringContext { /// Look up the captured outer-scope LocalIds for a class. Returns `None` /// for plain (non-capturing) classes. + /// Record that `class_name` keeps its captures in the class environment. + pub(crate) fn register_class_env(&mut self, class_name: String) { + self.class_env_classes.insert(class_name); + } + + /// Whether `class_name`'s environment reads are guarded by evaluation. + pub(crate) fn is_class_env_guarded(&self, class_name: &str) -> bool { + self.class_env_guarded.contains(class_name) + } + + /// Whether `class_name` keeps its captures in the class environment. + pub(crate) fn is_class_env(&self, class_name: &str) -> bool { + self.class_env_classes.contains(class_name) + } + + /// Whether the class node spanning `span` is evaluated at most once. + pub(crate) fn class_definition_runs_once(&self, span: swc_common::Span) -> bool { + !(span.lo.0 == 0 && span.hi.0 == 0) + && self.run_once_class_spans.contains(&(span.lo.0, span.hi.0)) + } + pub(crate) fn lookup_class_captures(&self, class_name: &str) -> Option<&[LocalId]> { self.class_captures .iter() diff --git a/crates/perry-hir/src/lower/context_new.rs b/crates/perry-hir/src/lower/context_new.rs index 74f730a48d..e38345f4f4 100644 --- a/crates/perry-hir/src/lower/context_new.rs +++ b/crates/perry-hir/src/lower/context_new.rs @@ -196,6 +196,10 @@ impl LoweringContext { next_anon_shape_id: 0, class_method_return_types: Vec::new(), class_captures: Vec::new(), + run_once_class_spans: HashSet::new(), + class_env_classes: HashSet::new(), + class_env_guarded: HashSet::new(), + pending_fresh_class_expr: false, body_class_expr_captures: Vec::new(), let_class_aliases: Vec::new(), global_this_aliases: HashSet::new(), diff --git a/crates/perry-hir/src/lower/expr_function.rs b/crates/perry-hir/src/lower/expr_function.rs index 18387fab9b..cbd1be8bb4 100644 --- a/crates/perry-hir/src/lower/expr_function.rs +++ b/crates/perry-hir/src/lower/expr_function.rs @@ -1563,7 +1563,7 @@ fn compute_closure_captures( /// mutate an object retained by an earlier factory call. pub(crate) fn apply_class_expr_capture_refreshes( body: &mut Vec, - entries: Vec<(LocalId, Vec)>, + entries: Vec<(LocalId, Vec, Option)>, ) { if entries.is_empty() { return; @@ -1573,7 +1573,7 @@ pub(crate) fn apply_class_expr_capture_refreshes( let mut refreshes = Vec::new(); let mut refresh_capsets = Vec::new(); let mut seen_owners = std::collections::HashSet::new(); - for (owner, ids) in entries { + for (owner, ids, env_class) in entries { if seen_owners.insert(owner) { owner_lets.push(Stmt::Let { id: owner, @@ -1589,6 +1589,7 @@ pub(crate) fn apply_class_expr_capture_refreshes( let refresh = Stmt::Expr(Expr::RefreshClassExprCaptures { class_value: Box::new(Expr::LocalGet(owner)), captures: ids.iter().map(|id| Expr::LocalGet(*id)).collect(), + env_class, }); refresh_capsets.push((refresh.clone(), ids.iter().copied().collect())); refreshes.push(refresh); diff --git a/crates/perry-hir/src/lower/expr_new.rs b/crates/perry-hir/src/lower/expr_new.rs index 66d0a12b1d..025899d75d 100644 --- a/crates/perry-hir/src/lower/expr_new.rs +++ b/crates/perry-hir/src/lower/expr_new.rs @@ -430,6 +430,9 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R } else { None }; + // The binding a static construct resolved through (its value is the + // class evaluation; see `Expr::ClassEnvStamp` below). + let mut class_binding: Option = None; // #6233: a user-declared binding — `class Symbol extends Base {}`, // a local/param, a `function` declaration, or an imported binding — // lexically shadows the same-named global for every reference in @@ -1583,7 +1586,10 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R .inferred_class_bindings .class_key_for(local_id, &class_name) { - Some(key) => class_name = key.to_string(), + Some(key) => { + class_name = key.to_string(); + class_binding = Some(local_id); + } None => { return Ok(Expr::NewDynamic { callee: Box::new(Expr::LocalGet(local_id)), @@ -1798,13 +1804,27 @@ pub(super) fn lower_new(ctx: &mut LoweringContext, new_expr: &ast::NewExpr) -> R for cid in class_captures { args.push(Expr::LocalGet(cid)); } - Ok(Expr::New { + let construct = Expr::New { class_name, args, type_args, byte_offset: new_byte_offset, cap_args_appended, - }) + }; + // A guarded class-environment class is constructed statically + // through its binding; the binding holds this evaluation's class + // value, which is recorded on the instance once the class has had + // more than one evaluation (see `Expr::ClassEnvStamp`). + match class_binding { + Some(binding) if ctx.is_class_env_guarded(&lookup_name) => { + Ok(Expr::ClassEnvStamp { + class_name: lookup_name, + instance: Box::new(construct), + evaluation: Box::new(Expr::LocalGet(binding)), + }) + } + _ => Ok(construct), + } } // Non-identifier callee (e.g., new (condition ? A : B)() or new someVar()). _ => lower_new_non_ident(ctx, new_expr, callee_expr, new_byte_offset), diff --git a/crates/perry-hir/src/lower/lower_expr/arm_class.rs b/crates/perry-hir/src/lower/lower_expr/arm_class.rs index de38ba2318..17dc7f52d1 100644 --- a/crates/perry-hir/src/lower/lower_expr/arm_class.rs +++ b/crates/perry-hir/src/lower/lower_expr/arm_class.rs @@ -138,7 +138,12 @@ pub(crate) fn lower_class_expr( } else { None }; + // Inside a function body a capturing class expression lowers to a fresh + // class object per evaluation (`ClassExprFresh` below), which is what the + // guarded class environment keys evaluations by. + ctx.pending_fresh_class_expr = !at_module_top; let class_result = lower_class_from_ast(ctx, &class_expr.class, &synthetic_name, false); + ctx.pending_fresh_class_expr = false; if let Some(self_id) = self_binding { let (_, _, popped_id) = ctx .class_expr_self_bindings @@ -281,6 +286,9 @@ pub(crate) fn lower_class_expr( // update only the object that was actually evaluated in this invocation. // Module top is skipped — module-level ids are stripped from capture lists // by `filter_module_level_captures`, so there is nothing to refresh. + let env_class = ctx + .is_class_env(&synthetic_name) + .then(|| synthetic_name.clone()); let capture_owner = if !at_module_top && (!captured_args.is_empty() || self_binding_used) { let ids = ctx .lookup_class_captures(&synthetic_name) @@ -292,7 +300,8 @@ pub(crate) fn lower_class_expr( // initializers can read the self-binding before ClassExprFresh // returns. Empty entries materialize that local without emitting a // capture refresh. - ctx.body_class_expr_captures.push((owner, ids)); + ctx.body_class_expr_captures + .push((owner, ids, env_class.clone())); Some(owner) } else if ids.is_empty() { None @@ -301,7 +310,8 @@ pub(crate) fn lower_class_expr( format!("__perry_class_expr_capture_owner_{synthetic_name}"), crate::types::Type::Any, ); - ctx.body_class_expr_captures.push((owner, ids)); + ctx.body_class_expr_captures + .push((owner, ids, env_class.clone())); Some(owner) } } else { diff --git a/crates/perry-hir/src/lower/lower_module_fn.rs b/crates/perry-hir/src/lower/lower_module_fn.rs index db8bb8bc4e..98310129a4 100644 --- a/crates/perry-hir/src/lower/lower_module_fn.rs +++ b/crates/perry-hir/src/lower/lower_module_fn.rs @@ -956,6 +956,10 @@ pub fn lower_module_full_with_platform_globals( // unresolved-constructor guard (see `pre_scan/class_decl_names.rs`). pre_scan_class_decl_names(ast_module, &mut ctx); + // Class definitions evaluated at most once keep their captured + // environment with the class (`synthesize_class_captures`). + ctx.run_once_class_spans = super::run_once::run_once_class_spans(ast_module); + // Pre-scan for WeakRef/FinalizationRegistry variable declarations so subsequent // method-call lowering (`x.deref()`, `x.register(...)`, `x.unregister(...)`) can // route via the dedicated HIR variants without relying on type inference. diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index f8a789c779..9f6034a2ec 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -1025,6 +1025,19 @@ pub struct LoweringContext { /// here so the `Expr::New { class_name }` lowering can append /// `LocalGet(id)` for each captured id at every construction site. pub(crate) class_captures: Vec<(String, Vec)>, + /// Spans of the class definitions evaluated at most once + /// (`lower::run_once`), computed once per module before lowering. + pub(crate) run_once_class_spans: HashSet<(u32, u32)>, + /// Classes whose captures live in the class environment + /// (`Expr::ClassEnvGet`/`ClassEnvSet`) rather than on instances. + pub(crate) class_env_classes: HashSet, + /// The subset of `class_env_classes` whose environment read is guarded + /// by the receiver's evaluation (`ClassEnvGet::guarded`). + pub(crate) class_env_guarded: HashSet, + /// Set by `lower_class_expr` for a class expression lowered inside a + /// function body (it evaluates to a fresh class object per evaluation); + /// consumed by the next `lower_class_from_ast`. + pub(crate) pending_fresh_class_expr: bool, /// #6604/#6654: capturing class EXPRESSIONS lowered while the CURRENT /// function body is being lowered — /// `(per_evaluation_owner_local, captured_outer_ids)`, @@ -1042,7 +1055,9 @@ pub struct LoweringContext { /// every other body-lowering path must truncate back to its entry mark so /// entries (whose ids are only meaningful in THEIR OWN function scope) /// never leak into an enclosing body's refresh statements. - pub(crate) body_class_expr_captures: Vec<(LocalId, Vec)>, + /// The third element names the template class when it keeps its + /// captures in the class environment, so the refresh rewrites that too. + pub(crate) body_class_expr_captures: Vec<(LocalId, Vec, Option)>, /// Issue #740: `let_name → class_name` for `let/const/var = ` /// initializers. Lets `Expr::New { class_name }` (where `class_name` is /// the source-level identifier of an alias binding) resolve to the diff --git a/crates/perry-hir/src/lower/mod.rs b/crates/perry-hir/src/lower/mod.rs index 4dd23a324e..154f7402e9 100644 --- a/crates/perry-hir/src/lower/mod.rs +++ b/crates/perry-hir/src/lower/mod.rs @@ -79,6 +79,7 @@ mod const_fold_fn; mod eval_super_scan; pub(crate) mod fn_ctor_env; mod global_eval_hoist; +mod run_once; mod shared_mutable_capture; pub(crate) mod type_widening; pub(crate) use closure_analysis::*; diff --git a/crates/perry-hir/src/lower/run_once.rs b/crates/perry-hir/src/lower/run_once.rs new file mode 100644 index 0000000000..0992012e69 --- /dev/null +++ b/crates/perry-hir/src/lower/run_once.rs @@ -0,0 +1,388 @@ +//! Which class definitions are evaluated AT MOST ONCE. +//! +//! A class nested in a function captures the enclosing function's locals. +//! Each evaluation of the class definition closes over its own environment, +//! so in general the captured values must be reachable per evaluation — Perry +//! stores them on every instance as hidden `__perry_cap_*` fields. When the +//! definition can evaluate only once there is exactly one environment, and it +//! can live with the class instead (`Expr::ClassEnvGet`/`ClassEnvSet`), the way +//! V8 keeps it in the closure context. Instances then carry no hidden capture +//! keys, and identically-declared classes get identically-slotted fields. +//! +//! A position is RUN-ONCE when it executes at most once per program run: +//! +//! * module top level; +//! * inside `if`/`switch`/`try`/blocks/labels and expressions of a run-once +//! position (each executes at most once); +//! * the body of a function immediately invoked from a run-once position — +//! `(function(){…})()`, `(() => {…})()`, `(function(){…}).call(…)` — or of +//! a function DECLARATION whose name occurs exactly twice in the module: +//! its declaration and one call from a run-once position. The CJS wrapper's +//! `function __perry_cjs_factory(){…} return __perry_cjs_factory();` and +//! bundles' `((module) => {…})(…)` are both this shape. +//! +//! Not run-once: loop bodies and heads, class members (a method runs once +//! per call), every other function body, and async/generator bodies (their +//! lowering re-enters the body through a step function). A function whose +//! own `arguments` object is referenced can reach itself through +//! `arguments.callee` and is never treated as invoked once. Names are compared module-wide without scope +//! resolution, which can only make the answer more conservative. +//! +//! The result is keyed by the class node's span. A span seen twice (a +//! synthesized copy) is dropped, as are dummy spans. + +use std::collections::{HashMap, HashSet}; + +use swc_ecma_ast as ast; +use swc_ecma_visit::{Visit, VisitWith}; + +/// Spans `(lo, hi)` of every class (declaration or expression) whose +/// definition is evaluated at most once. +pub(crate) fn run_once_class_spans(module: &ast::Module) -> HashSet<(u32, u32)> { + let mut counts = IdentCounts::default(); + module.visit_with(&mut counts); + let mut candidates: HashSet = HashSet::new(); + let mut decls = FnDecls::default(); + module.visit_with(&mut decls); + for (name, eligible) in decls.eligible { + if eligible && counts.counts.get(&name).copied() == Some(2) { + candidates.insert(name); + } + } + // Fixpoint: a function invoked once from a run-once position makes its + // own body run-once, which can admit further once-called declarations. + // Monotone (the set only grows), bounded by the nesting depth. + let mut once_fns: HashSet = HashSet::new(); + for _ in 0..32 { + let mut walk = Walk::new(&once_fns, &candidates, &counts.counts); + module.visit_with(&mut walk); + let next: HashSet = std::mem::take(&mut walk.called_once) + .into_iter() + .filter(|n| candidates.contains(n)) + .collect(); + if next == once_fns { + return walk.spans(); + } + once_fns = next; + } + // Did not converge (cannot happen for a finite nesting depth): claim + // nothing rather than something unproven. + HashSet::new() +} + +#[derive(Default)] +struct IdentCounts { + counts: HashMap, +} + +impl Visit for IdentCounts { + fn visit_ident(&mut self, ident: &ast::Ident) { + *self.counts.entry(ident.sym.to_string()).or_default() += 1; + } +} + +/// Function declarations, and whether each is structurally eligible to be a +/// once-called body (plain, not async/generator, no `callee` reference). A +/// name declared twice is ineligible. +#[derive(Default)] +struct FnDecls { + eligible: HashMap, +} + +impl Visit for FnDecls { + fn visit_fn_decl(&mut self, decl: &ast::FnDecl) { + let ok = function_is_plain(&decl.function); + self.eligible + .entry(decl.ident.sym.to_string()) + .and_modify(|e| *e = false) + .or_insert(ok); + decl.visit_children_with(self); + } +} + +fn function_is_plain(function: &ast::Function) -> bool { + // Visit the parts, not the `Function` node: `ArgumentsRef` stops at + // function boundaries, which would skip this function's own body. + !function.is_async + && !function.is_generator + && !names_callee(&function.params) + && !function.body.as_ref().is_some_and(names_callee) +} + +/// Whether `node` can reach its own function object through +/// `arguments.callee`: it names `arguments` outside any nested non-arrow +/// function (those bind their own). Any such reference counts, since the +/// object can escape before `.callee` is read. +fn names_callee>(node: &N) -> bool { + let mut finder = ArgumentsRef(false); + node.visit_with(&mut finder); + finder.0 +} + +struct ArgumentsRef(bool); + +impl Visit for ArgumentsRef { + fn visit_ident(&mut self, ident: &ast::Ident) { + if &*ident.sym == "arguments" { + self.0 = true; + } + } + // Every construct below binds its own `arguments`. + fn visit_function(&mut self, _: &ast::Function) {} + fn visit_constructor(&mut self, _: &ast::Constructor) {} + fn visit_getter_prop(&mut self, _: &ast::GetterProp) {} + fn visit_setter_prop(&mut self, _: &ast::SetterProp) {} +} + +struct Walk<'a> { + run_once: bool, + once_fns: &'a HashSet, + candidates: &'a HashSet, + counts: &'a HashMap, + called_once: HashSet, + seen: HashSet<(u32, u32)>, + duplicated: HashSet<(u32, u32)>, +} + +impl<'a> Walk<'a> { + fn new( + once_fns: &'a HashSet, + candidates: &'a HashSet, + counts: &'a HashMap, + ) -> Self { + Walk { + run_once: true, + once_fns, + candidates, + counts, + called_once: HashSet::new(), + seen: HashSet::new(), + duplicated: HashSet::new(), + } + } + + fn spans(self) -> HashSet<(u32, u32)> { + let duplicated = self.duplicated; + self.seen + .into_iter() + .filter(|s| !duplicated.contains(s)) + .collect() + } + + fn with(&mut self, run_once: bool, f: F) { + let saved = self.run_once; + self.run_once = run_once; + f(self); + self.run_once = saved; + } + + /// The function an immediately-invoked callee evaluates, if the callee is + /// one: `fn`, `(fn)`, `fn.call`, `(fn).apply`. + fn iife_target<'e>(&self, callee: &'e ast::Expr) -> Option> { + let callee = strip_parens(callee); + let target = match callee { + ast::Expr::Member(member) => match &member.prop { + ast::MemberProp::Ident(prop) if &*prop.sym == "call" || &*prop.sym == "apply" => { + strip_parens(&member.obj) + } + _ => return None, + }, + other => other, + }; + match target { + ast::Expr::Fn(fn_expr) => { + let named_once = fn_expr + .ident + .as_ref() + .is_none_or(|id| self.counts.get(id.sym.as_str()).copied() == Some(1)); + (named_once && function_is_plain(&fn_expr.function)) + .then_some(Iife::Function(&fn_expr.function)) + } + ast::Expr::Arrow(arrow) => { + (!arrow.is_async && !arrow.is_generator && !names_callee(arrow.body.as_ref())) + .then_some(Iife::Arrow(arrow)) + } + _ => None, + } + } +} + +enum Iife<'e> { + Function(&'e ast::Function), + Arrow(&'e ast::ArrowExpr), +} + +fn strip_parens(mut expr: &ast::Expr) -> &ast::Expr { + while let ast::Expr::Paren(paren) = expr { + expr = &paren.expr; + } + expr +} + +impl Visit for Walk<'_> { + fn visit_class(&mut self, class: &ast::Class) { + if self.run_once && !(class.span.lo.0 == 0 && class.span.hi.0 == 0) { + let key = (class.span.lo.0, class.span.hi.0); + if !self.seen.insert(key) { + self.duplicated.insert(key); + } + } + // Heritage and computed keys evaluate with the definition, but every + // member body runs once per call: stay conservative for the lot. + self.with(false, |w| class.visit_children_with(w)); + } + + fn visit_function(&mut self, function: &ast::Function) { + self.with(false, |w| function.visit_children_with(w)); + } + + fn visit_arrow_expr(&mut self, arrow: &ast::ArrowExpr) { + self.with(false, |w| arrow.visit_children_with(w)); + } + + fn visit_getter_prop(&mut self, prop: &ast::GetterProp) { + self.with(false, |w| prop.visit_children_with(w)); + } + + fn visit_setter_prop(&mut self, prop: &ast::SetterProp) { + self.with(false, |w| prop.visit_children_with(w)); + } + + fn visit_fn_decl(&mut self, decl: &ast::FnDecl) { + let once = self.run_once && self.once_fns.contains(decl.ident.sym.as_str()); + // Bypass `visit_function` so the body keeps the run-once flag. + self.with(once, |w| decl.function.visit_children_with(w)); + } + + fn visit_call_expr(&mut self, call: &ast::CallExpr) { + let ast::Callee::Expr(callee) = &call.callee else { + call.visit_children_with(self); + return; + }; + if self.run_once { + if let ast::Expr::Ident(id) = strip_parens(callee) { + if self.candidates.contains(id.sym.as_str()) { + self.called_once.insert(id.sym.to_string()); + } + } + } + match self.iife_target(callee) { + Some(target) if self.run_once => { + match target { + Iife::Function(function) => function.visit_children_with(self), + Iife::Arrow(arrow) => arrow.visit_children_with(self), + } + // `.call`/`.apply` receivers and the arguments are ordinary + // expressions of this position. + call.args.visit_with(self); + } + _ => call.visit_children_with(self), + } + } + + fn visit_for_stmt(&mut self, node: &ast::ForStmt) { + self.with(false, |w| node.visit_children_with(w)); + } + + fn visit_for_in_stmt(&mut self, node: &ast::ForInStmt) { + self.with(false, |w| node.visit_children_with(w)); + } + + fn visit_for_of_stmt(&mut self, node: &ast::ForOfStmt) { + self.with(false, |w| node.visit_children_with(w)); + } + + fn visit_while_stmt(&mut self, node: &ast::WhileStmt) { + self.with(false, |w| node.visit_children_with(w)); + } + + fn visit_do_while_stmt(&mut self, node: &ast::DoWhileStmt) { + self.with(false, |w| node.visit_children_with(w)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn spans_of(src: &str) -> (HashSet<(u32, u32)>, Vec<(String, (u32, u32))>) { + let module = perry_parser::parse_typescript(src, "run-once.ts").expect("source parses"); + struct Names(Vec<(String, (u32, u32))>); + impl Visit for Names { + fn visit_class_decl(&mut self, d: &ast::ClassDecl) { + self.0.push(( + d.ident.sym.to_string(), + (d.class.span.lo.0, d.class.span.hi.0), + )); + d.visit_children_with(self); + } + fn visit_class_expr(&mut self, e: &ast::ClassExpr) { + let name = e + .ident + .as_ref() + .map(|i| i.sym.to_string()) + .unwrap_or_default(); + self.0.push((name, (e.class.span.lo.0, e.class.span.hi.0))); + e.visit_children_with(self); + } + } + let mut names = Names(Vec::new()); + module.visit_with(&mut names); + (run_once_class_spans(&module), names.0) + } + + fn once(src: &str) -> Vec { + let (spans, names) = spans_of(src); + let mut out: Vec = names + .into_iter() + .filter(|(_, s)| spans.contains(s)) + .map(|(n, _)| n) + .collect(); + out.sort(); + out + } + + #[test] + fn iife_and_once_called_declaration_bodies_are_run_once() { + assert_eq!( + once( + "(function(){ class A {} })(); + (() => { class B {} })(); + (function(){ class C {} }).call(this); + (function(){ const callee = 1; function g(){ arguments; } class E {} })(); + const x = (function(){ function f(){ class D {} } return f(); })();" + ), + vec!["A", "B", "C", "D", "E"] + ); + } + + #[test] + fn repeatable_positions_are_not_run_once() { + assert_eq!( + once( + "function g(){ class A {} } g(); g(); + function h(){ class B {} } const k = h; + for (;;) { (function(){ class C {} })(); } + while (1) { class D {} } + (async function(){ class E {} })(); + (function*(){ class F {} })(); + (function r(){ class G {} r; })(); + (function(){ arguments.callee; class H {} })(); + (function(){ const a = arguments; class M {} })(); + class K { m(){ (function(){ class I {} })(); } } + const o = { get p(){ class J {} return 1; } }; + function once(){ class L {} } [1].map(once);" + ), + // K itself sits at module top; only its method body repeats. + vec!["K"] + ); + } + + #[test] + fn module_top_and_nested_blocks_are_run_once() { + assert_eq!( + once("class A {} if (x) { class B {} } try { class C {} } catch { class D {} }"), + vec!["A", "B", "C", "D"] + ); + } +} diff --git a/crates/perry-hir/src/lower/shared_mutable_capture.rs b/crates/perry-hir/src/lower/shared_mutable_capture.rs index 18c3476184..b0725033a7 100644 --- a/crates/perry-hir/src/lower/shared_mutable_capture.rs +++ b/crates/perry-hir/src/lower/shared_mutable_capture.rs @@ -1365,6 +1365,19 @@ fn is_redundant_cell_propagation(items: &[Expr], index_uses: &HashSet) && matches!(value.as_ref(), Expr::LocalGet(id) if *id == written) } +/// The class-environment twin of [`is_redundant_cell_propagation`]: +/// `Sequence([LocalSet(id, _) | Update { id }, ClassEnvSet { value: LocalGet(id) }])`. +fn is_redundant_env_propagation(items: &[Expr], index_uses: &HashSet) -> bool { + let [write, Expr::ClassEnvSet { value, .. }] = items else { + return false; + }; + let written = match write { + Expr::LocalSet(id, _) | Expr::Update { id, .. } => *id, + _ => return false, + }; + index_uses.contains(&written) && matches!(value.as_ref(), Expr::LocalGet(id) if *id == written) +} + fn rewrite_stmts(stmts: &mut [Stmt], shared: &HashSet, index_uses: &HashSet) { for s in stmts.iter_mut() { rewrite_stmt(s, shared, index_uses); @@ -1512,7 +1525,10 @@ fn rewrite_expr(expr: &mut Expr, shared: &HashSet, index_uses: &HashSet // shared cell needs no propagation — the field already holds the same // cell — and keeping it makes the sequence yield the cell handle instead // of the write's value (`return n++` returned `[3]`, not 2; #10489). - Expr::Sequence(items) if is_redundant_cell_propagation(items, index_uses) => { + Expr::Sequence(items) + if is_redundant_cell_propagation(items, index_uses) + || is_redundant_env_propagation(items, index_uses) => + { let write = items.swap_remove(0); *expr = write; rewrite_expr(expr, shared, index_uses); @@ -1553,6 +1569,13 @@ fn rewrite_expr(expr: &mut Expr, shared: &HashSet, index_uses: &HashSet } if matches!(object.as_ref(), Expr::This) && property.starts_with("__perry_cap_") => { return; } + // Constructor PUBLISH `ClassEnvSet { value: LocalGet(param) }`: the + // environment holds the whole array handle, exactly like the instance + // stash above. Any other value is an ordinary expression. + Expr::ClassEnvSet { value, .. } if matches!(value.as_ref(), Expr::LocalGet(id) if index_uses.contains(id)) => + { + return; + } Expr::Update { id, op, prefix } if index_uses.contains(id) => { *expr = Expr::IndexUpdate { object: Box::new(Expr::LocalGet(*id)), @@ -1580,6 +1603,7 @@ fn rewrite_expr(expr: &mut Expr, shared: &HashSet, index_uses: &HashSet Expr::RefreshClassExprCaptures { class_value, captures, + .. } => { rewrite_expr(class_value, shared, index_uses); for capture in captures.iter_mut() { diff --git a/crates/perry-hir/src/lower/tests/capture_stash.rs b/crates/perry-hir/src/lower/tests/capture_stash.rs index 9d2cdbef96..43963f51c3 100644 --- a/crates/perry-hir/src/lower/tests/capture_stash.rs +++ b/crates/perry-hir/src/lower/tests/capture_stash.rs @@ -1,6 +1,11 @@ //! Derived-ctor capture-stash placement (#8630): the `this.__perry_cap_*` //! stash must follow `super()`, not constructor entry. Split from `tests.rs` //! for the 2000-line file cap. +//! +//! The fixtures declare their classes in a function that may run many times, +//! so the classes keep the per-instance snapshot. A class evaluated once keeps +//! its captures in the class environment instead, needs no `this`, and +//! publishes at constructor entry (the last test). /// A derived class with captured outers whose `super()` is not its own /// statement — the minifier's `super(a), this.x = b, …` comma sequence, as in @@ -11,7 +16,7 @@ #[test] fn derived_ctor_capture_stash_follows_super_inside_comma_sequence() { let source = r#" - const exported = (() => { + function exported() { const shared = { tag: "outer" }; class Base { constructor(opts) { this.definition = opts.definition; } @@ -22,7 +27,7 @@ fn derived_ctor_capture_stash_follows_super_inside_comma_sequence() { } } return Derived; - })(); + } "#; assert_capture_stash_follows_super(source, "Derived"); } @@ -32,7 +37,7 @@ fn derived_ctor_capture_stash_follows_super_inside_comma_sequence() { #[test] fn derived_ctor_capture_stash_follows_super_inside_if_test() { let source = r#" - const exported = (() => { + function exported() { const shared = { tag: "outer" }; class Base { constructor() { this.base = 1; } @@ -45,7 +50,7 @@ fn derived_ctor_capture_stash_follows_super_inside_if_test() { } } return Derived; - })(); + } "#; assert_capture_stash_follows_super(source, "Derived"); } @@ -88,3 +93,59 @@ fn assert_capture_stash_follows_super(source: &str, class_name: &str) { ctor.body ); } + +/// A class-environment class (the same fixture evaluated once, in an IIFE) +/// publishes its captures at constructor ENTRY — the environment needs no +/// `this`, so there is no `super()` to wait for — and declares no hidden +/// instance field at all. +#[test] +fn a_class_environment_ctor_publishes_before_super() { + let source = r#" + const exported = (() => { + const shared = { tag: "outer" }; + class Base { + constructor(opts) { this.definition = opts.definition; } + } + class Derived extends Base { + constructor({ definition: r, name: n }) { + super({ definition: r }), this.name = n, this.tag = shared.tag; + } + } + return Derived; + })(); + "#; + let module = perry_parser::parse_typescript(source, "t.ts").expect("source parses"); + let hir = super::lower_module(&module, "t", "t.ts").expect("source lowers"); + let class = hir + .classes + .iter() + .find(|c| c.name == "Derived") + .expect("fixture declares class Derived"); + assert!( + class + .fields + .iter() + .all(|f| !f.name.starts_with("__perry_cap_")), + "a class-environment class declares no capture field: {:?}", + class.fields.iter().map(|f| &f.name).collect::>() + ); + let ctor = class + .constructor + .as_ref() + .expect("user-written constructor"); + let position = |needle: &str| { + ctor.body.iter().position(|stmt| { + format!("{stmt:?}") + .chars() + .filter(|ch| !ch.is_whitespace()) + .collect::() + .contains(needle) + }) + }; + let super_at = position("SuperCall(").expect("fixture constructor calls super()"); + let publish_at = position("ClassEnvSet{").expect("the constructor publishes its captures"); + assert!( + publish_at < super_at, + "environment publish (stmt {publish_at}) precedes super() (stmt {super_at})" + ); +} diff --git a/crates/perry-hir/src/lower_decl/body_stmt/class_self_binding.rs b/crates/perry-hir/src/lower_decl/body_stmt/class_self_binding.rs index 2f0db32436..6e5d5c36e6 100644 --- a/crates/perry-hir/src/lower_decl/body_stmt/class_self_binding.rs +++ b/crates/perry-hir/src/lower_decl/body_stmt/class_self_binding.rs @@ -56,7 +56,8 @@ pub(super) fn decl_self_binding_owner( .lookup_class_captures(class_name) .map(<[_]>::to_vec) .unwrap_or_default(); - ctx.body_class_expr_captures.push((self_id, ids)); + let env_class = ctx.is_class_env(class_name).then(|| class_name.to_string()); + ctx.body_class_expr_captures.push((self_id, ids, env_class)); Some(self_id) } diff --git a/crates/perry-hir/src/lower_decl/class_captures.rs b/crates/perry-hir/src/lower_decl/class_captures.rs index f7c2cef0e9..d681372f1a 100644 --- a/crates/perry-hir/src/lower_decl/class_captures.rs +++ b/crates/perry-hir/src/lower_decl/class_captures.rs @@ -5,6 +5,295 @@ use crate::lower::LoweringContext; use super::class_members::collect_method_captures; +/// `PERRY_NO_CLASS_ENV=1` keeps every capturing class on the per-instance +/// `__perry_cap_*` snapshot (bisection escape hatch, like `PERRY_NO_5951`). +fn class_env_enabled() -> bool { + static ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); + *ENABLED.get_or_init(|| std::env::var_os("PERRY_NO_CLASS_ENV").is_none()) +} + +/// `PERRY_CLASS_CAPTURE_DIAG=1`: one stderr line per capturing class naming +/// where its captures live (`env` or `instance`) and how many there are. +fn class_capture_diag() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| std::env::var_os("PERRY_CLASS_CAPTURE_DIAG").is_some()) +} + +/// Guarded class-environment classes only: a `new ()` inside the +/// class's own member must record the member's evaluation, or once the class +/// has several evaluations the new instance would read the first one's +/// captures. Hoist that evaluation into a member-entry local (a state compare +/// while the class has one evaluation) and stamp every self-construction. +fn stamp_self_constructions(ctx: &mut LoweringContext, class_name: &str, body: &mut Vec) { + fn has_self_new(expr: &Expr, class_name: &str) -> bool { + if matches!(expr, Expr::ClassEnvStamp { .. }) { + return false; + } + if matches!(expr, Expr::New { class_name: cn, .. } if cn == class_name) { + return true; + } + if let Expr::Closure { body, .. } = expr { + return body.iter().any(|s| stmt_has_self_new(s, class_name)); + } + let mut found = false; + crate::walker::walk_expr_children(expr, &mut |child| { + found = found || has_self_new(child, class_name); + }); + found + } + fn stmt_has_self_new(stmt: &Stmt, class_name: &str) -> bool { + let mut found = false; + closure_free_exprs_of_stmt(stmt, &mut |e| found = found || has_self_new(e, class_name)); + found + } + if !body.iter().any(|s| stmt_has_self_new(s, class_name)) { + return; + } + let eval_id = ctx.fresh_local(); + for stmt in body.iter_mut() { + stamp_self_new_stmt(stmt, class_name, eval_id); + } + body.insert( + 0, + Stmt::Let { + id: eval_id, + name: "__perry_class_env_evaluation".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::ClassEnvCurrent { + class_name: class_name.to_string(), + }), + }, + ); +} + +/// Every expression of `stmt`, descending nested statements. +fn closure_free_exprs_of_stmt(stmt: &Stmt, f: &mut dyn FnMut(&Expr)) { + match stmt { + Stmt::Let { init, .. } => { + if let Some(e) = init { + f(e); + } + } + Stmt::Expr(e) | Stmt::Throw(e) => f(e), + Stmt::Return(e) => { + if let Some(e) = e { + f(e); + } + } + Stmt::If { + condition, + then_branch, + else_branch, + } => { + f(condition); + for s in then_branch.iter().chain(else_branch.iter().flatten()) { + closure_free_exprs_of_stmt(s, f); + } + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + f(condition); + for s in body { + closure_free_exprs_of_stmt(s, f); + } + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(i) = init { + closure_free_exprs_of_stmt(i, f); + } + for e in condition.iter().chain(update.iter()) { + f(e); + } + for s in body { + closure_free_exprs_of_stmt(s, f); + } + } + Stmt::Labeled { body, .. } => closure_free_exprs_of_stmt(body, f), + Stmt::Try { + body, + catch, + finally, + } => { + for s in body + .iter() + .chain(catch.iter().flat_map(|c| c.body.iter())) + .chain(finally.iter().flatten()) + { + closure_free_exprs_of_stmt(s, f); + } + } + Stmt::Switch { + discriminant, + cases, + } => { + f(discriminant); + for case in cases { + if let Some(t) = &case.test { + f(t); + } + for s in &case.body { + closure_free_exprs_of_stmt(s, f); + } + } + } + _ => {} + } +} + +/// Drop the leading `prologue_len` capture rebinds of a member body that the +/// rest of the body never names. Every member rebinds the class's whole +/// capture union; in the class environment a rebind is a load plus a rooted +/// slot store, so an unused one is pure per-call cost. A rebind is kept when +/// any later statement — nested closure bodies and their capture lists +/// included — refers to its id. +fn prune_unused_capture_rebinds(body: &mut Vec, prologue_len: usize) { + let mut refs: Vec = Vec::new(); + let mut visited = std::collections::HashSet::new(); + for stmt in &body[prologue_len..] { + crate::analysis::collect_local_refs_stmt(stmt, &mut refs, &mut visited); + } + let mut named: std::collections::HashSet = refs.into_iter().collect(); + for stmt in &body[prologue_len..] { + closure_capture_ids_stmt(stmt, &mut named); + } + let mut index = 0; + body.retain(|stmt| { + let keep = + index >= prologue_len || !matches!(stmt, Stmt::Let { id, .. } if !named.contains(id)); + index += 1; + keep + }); +} + +fn closure_capture_ids_expr(expr: &Expr, out: &mut std::collections::HashSet) { + if let Expr::Closure { + captures, + mutable_captures, + body, + .. + } = expr + { + out.extend(captures.iter().copied()); + out.extend(mutable_captures.iter().copied()); + for stmt in body { + closure_capture_ids_stmt(stmt, out); + } + } + crate::walker::walk_expr_children(expr, &mut |child| closure_capture_ids_expr(child, out)); +} + +fn closure_capture_ids_stmt(stmt: &Stmt, out: &mut std::collections::HashSet) { + match stmt { + Stmt::Let { init, .. } => { + if let Some(e) = init { + closure_capture_ids_expr(e, out); + } + } + Stmt::Expr(e) | Stmt::Throw(e) => closure_capture_ids_expr(e, out), + Stmt::Return(e) => { + if let Some(e) = e { + closure_capture_ids_expr(e, out); + } + } + Stmt::If { + condition, + then_branch, + else_branch, + } => { + closure_capture_ids_expr(condition, out); + for s in then_branch.iter().chain(else_branch.iter().flatten()) { + closure_capture_ids_stmt(s, out); + } + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + closure_capture_ids_expr(condition, out); + for s in body { + closure_capture_ids_stmt(s, out); + } + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(i) = init { + closure_capture_ids_stmt(i, out); + } + for e in condition.iter().chain(update.iter()) { + closure_capture_ids_expr(e, out); + } + for s in body { + closure_capture_ids_stmt(s, out); + } + } + Stmt::Labeled { body, .. } => closure_capture_ids_stmt(body, out), + Stmt::Try { + body, + catch, + finally, + } => { + for s in body + .iter() + .chain(catch.iter().flat_map(|c| c.body.iter())) + .chain(finally.iter().flatten()) + { + closure_capture_ids_stmt(s, out); + } + } + Stmt::Switch { + discriminant, + cases, + } => { + closure_capture_ids_expr(discriminant, out); + for case in cases { + if let Some(t) = &case.test { + closure_capture_ids_expr(t, out); + } + for s in &case.body { + closure_capture_ids_stmt(s, out); + } + } + } + Stmt::PreallocateBoxes(ids) | Stmt::PreallocateTdzBoxes(ids) | Stmt::ReleaseBoxes(ids) => { + out.extend(ids.iter().copied()); + } + Stmt::Break | Stmt::Continue | Stmt::LabeledBreak(_) | Stmt::LabeledContinue(_) => {} + } +} + +/// How many times a capturing class's definition can be evaluated, which +/// decides where its captured environment lives. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CaptureDefinition { + /// At most once (`lower::run_once`): one environment, read directly. + RunOnce, + /// A class expression inside a function body. Each evaluation is a fresh + /// class object carrying its own capture array, so the environment is + /// read directly while the class has had one evaluation and per receiver + /// evaluation after that (`ClassEnvGet::guarded`). + FreshExpression, + /// Anything else: captures are snapshotted on every instance. + Repeatable, +} + +impl CaptureDefinition { + pub(crate) fn classify(run_once: bool, fresh_class_expr: bool) -> Self { + if run_once { + Self::RunOnce + } else if fresh_class_expr { + Self::FreshExpression + } else { + Self::Repeatable + } + } +} + pub fn synthesize_class_captures( ctx: &mut LoweringContext, name: &str, @@ -25,6 +314,8 @@ pub fn synthesize_class_captures( // module-level global. One slot, overwritten by every evaluation of the // enclosing factory. static_accessor_fn_ids: &[crate::types::FuncId], + // How often the definition can evaluate (see `CaptureDefinition`). + definition: CaptureDefinition, ) { let cap_salt = ctx.cap_salt(); let module_level_ids = ctx.module_level_ids.clone(); @@ -120,6 +411,36 @@ pub fn synthesize_class_captures( return; } + // One environment per class definition. Only a definition evaluated at + // most once has a single environment every instance, static and + // extracted method can share; the rest keep the per-instance snapshot. + let env_mode = definition != CaptureDefinition::Repeatable && class_env_enabled(); + let guarded = env_mode && definition == CaptureDefinition::FreshExpression; + if env_mode { + ctx.register_class_env(name.to_string()); + } + if guarded { + ctx.class_env_guarded.insert(name.to_string()); + } + if class_capture_diag() { + eprintln!( + "[class-capture] module={} class={} captures={} storage={}", + ctx.source_file_path, + name, + captures_vec.len(), + match (env_mode, guarded) { + (true, false) => "env", + (true, true) => "env-guarded", + _ => "instance", + } + ); + } + let env_get = |index: usize| Expr::ClassEnvGet { + class_name: name.to_string(), + index: index as u32, + guarded, + }; + // Walk the parent chain to find which `__perry_cap_` fields // are already declared by an ancestor. Inherited fields share the // same instance slot via the runtime's by-name lookup; declaring @@ -150,9 +471,11 @@ pub fn synthesize_class_captures( }) .collect(); - // 1. Hidden fields keyed by outer id, skipping inherited. + // 1. Hidden fields keyed by outer id, skipping inherited. A class- + // environment class declares none: its instances carry only their own + // fields. for &cid in &captures_vec { - if inherited_cap_ids.contains(&cid) { + if env_mode || inherited_cap_ids.contains(&cid) { continue; } fields.push(ClassField { @@ -165,7 +488,7 @@ pub fn synthesize_class_captures( decorators: Vec::new(), }); } - if let Some(existing) = ctx.lookup_class_field_names(name) { + if let Some(existing) = ctx.lookup_class_field_names(name).filter(|_| !env_mode) { let mut updated: Vec = existing.to_vec(); for &cid in &captures_vec { let field_name = crate::cap_fields::cap_field_name(cap_salt, cid); @@ -208,10 +531,25 @@ pub fn synthesize_class_captures( // expression, return value, condition); nested captured writes // like `(stored = v).toString()` only update the local — rare // enough to defer to a follow-up. - let field_propagation: std::collections::HashMap = captures_vec - .iter() - .map(|&cid| (cid, crate::cap_fields::cap_field_name(cap_salt, cid))) - .collect(); + let field_propagation: std::collections::HashMap = + captures_vec + .iter() + .enumerate() + .map(|(index, &cid)| { + let target = if env_mode { + crate::analysis::CaptureWriteTarget::Env { + class_name: name.to_string(), + index: index as u32, + guarded, + } + } else { + crate::analysis::CaptureWriteTarget::Field(crate::cap_fields::cap_field_name( + cap_salt, cid, + )) + }; + (cid, target) + }) + .collect(); // Helper closure: build a fresh-id map for one function's body, // rewrite the body refs (with field-write propagation), and @@ -239,12 +577,10 @@ pub fn synthesize_class_captures( // `undefined` and threw at boot, #5437). When the field is still // undefined, fall back to the class's decl-site capture snapshot // (same machinery as the ctor param rebinds above). - prologue.push(Stmt::Let { - id: new_id, - name: crate::cap_fields::cap_field_name(cap_salt, outer_id), - ty, - mutable: true, - init: Some(Expr::ClassCaptureValue { + let init = if env_mode { + env_get(index) + } else { + Expr::ClassCaptureValue { class_name: name.to_string(), index: index as u32, fallback: Some(Box::new(Expr::PropertyGet { @@ -253,7 +589,14 @@ pub fn synthesize_class_captures( property: crate::cap_fields::cap_field_name(cap_salt, outer_id), })), prefer_fallback: true, - }), + } + }; + prologue.push(Stmt::Let { + id: new_id, + name: crate::cap_fields::cap_field_name(cap_salt, outer_id), + ty, + mutable: true, + init: Some(init), }); } // Rewrite first (so closure captures lists pick up the new ids @@ -299,9 +642,21 @@ pub fn synthesize_class_captures( append_self_new_args_stmt(stmt, name, &cap_args); } }; + // In the class environment an unused rebind is pure per-call cost (see + // `prune_unused_capture_rebinds`); the instance path keeps its rebinds. + let prune = |body: &mut Vec| { + if env_mode { + prune_unused_capture_rebinds(body, captures_vec.len()); + } + }; + for m in methods.iter_mut() { let id_map = rewrite_method_body(ctx, &mut m.body); append_self_sites(&mut m.body, &id_map); + prune(&mut m.body); + if guarded { + stamp_self_constructions(ctx, name, &mut m.body); + } } for (_, g) in getters .iter_mut() @@ -309,6 +664,10 @@ pub fn synthesize_class_captures( { let id_map = rewrite_method_body(ctx, &mut g.body); append_self_sites(&mut g.body, &id_map); + prune(&mut g.body); + if guarded { + stamp_self_constructions(ctx, name, &mut g.body); + } } for (_, s) in setters .iter_mut() @@ -316,6 +675,10 @@ pub fn synthesize_class_captures( { let id_map = rewrite_method_body(ctx, &mut s.body); append_self_sites(&mut s.body, &id_map); + prune(&mut s.body); + if guarded { + stamp_self_constructions(ctx, name, &mut s.body); + } } for member in computed_members .iter_mut() @@ -323,8 +686,29 @@ pub fn synthesize_class_captures( { let id_map = rewrite_method_body(ctx, &mut member.function.body); append_self_sites(&mut member.function.body, &id_map); + prune(&mut member.function.body); + if guarded { + stamp_self_constructions(ctx, name, &mut member.function.body); + } } + // Statics rebind from the decl-site snapshot and, historically, did not + // propagate their writes. In the class environment a static shares the + // one environment with every instance member, so its writes propagate + // there too. + let remap_static = + |body: &mut Vec, id_map: &std::collections::HashMap| { + if env_mode { + crate::analysis::remap_local_ids_in_stmts_with_field_propagation( + body, + id_map, + &field_propagation, + ); + } else { + crate::analysis::remap_local_ids_in_stmts(body, id_map); + } + }; + // 2b. STATIC methods: no instance carries `__perry_cap_*` fields, so // the prologue rebinds read the decl-site snapshot instead // (`ClassCaptureValue { class_name, index }` → @@ -347,18 +731,26 @@ pub fn synthesize_class_captures( .cloned() .unwrap_or(Type::Any), mutable: true, - init: Some(Expr::ClassCaptureValue { - class_name: name.to_string(), - index: index as u32, - fallback: None, - prefer_fallback: false, + init: Some(if env_mode { + env_get(index) + } else { + Expr::ClassCaptureValue { + class_name: name.to_string(), + index: index as u32, + fallback: None, + prefer_fallback: false, + } }), }); } - crate::analysis::remap_local_ids_in_stmts(&mut sm.body, &id_map); + remap_static(&mut sm.body, &id_map); prologue.append(&mut sm.body); sm.body = prologue; append_self_sites(&mut sm.body, &id_map); + prune(&mut sm.body); + if guarded { + stamp_self_constructions(ctx, name, &mut sm.body); + } } // 2b-bis (#10835). STATIC accessors get the same treatment as static @@ -386,18 +778,26 @@ pub fn synthesize_class_captures( .cloned() .unwrap_or(Type::Any), mutable: true, - init: Some(Expr::ClassCaptureValue { - class_name: name.to_string(), - index: index as u32, - fallback: None, - prefer_fallback: false, + init: Some(if env_mode { + env_get(index) + } else { + Expr::ClassCaptureValue { + class_name: name.to_string(), + index: index as u32, + fallback: None, + prefer_fallback: false, + } }), }); } - crate::analysis::remap_local_ids_in_stmts(&mut acc.body, &id_map); + remap_static(&mut acc.body, &id_map); prologue.append(&mut acc.body); acc.body = prologue; append_self_sites(&mut acc.body, &id_map); + prune(&mut acc.body); + if guarded { + stamp_self_constructions(ctx, name, &mut acc.body); + } } // 2c. STATIC computed methods (`static [k]() {}`, and the static methods @@ -429,18 +829,26 @@ pub fn synthesize_class_captures( .cloned() .unwrap_or(Type::Any), mutable: true, - init: Some(Expr::ClassCaptureValue { - class_name: name.to_string(), - index: index as u32, - fallback: None, - prefer_fallback: false, + init: Some(if env_mode { + env_get(index) + } else { + Expr::ClassCaptureValue { + class_name: name.to_string(), + index: index as u32, + fallback: None, + prefer_fallback: false, + } }), }); } - crate::analysis::remap_local_ids_in_stmts(&mut member.function.body, &id_map); + remap_static(&mut member.function.body, &id_map); prologue.append(&mut member.function.body); member.function.body = prologue; append_self_sites(&mut member.function.body, &id_map); + prune(&mut member.function.body); + if guarded { + stamp_self_constructions(ctx, name, &mut member.function.body); + } } // 3. Constructor. @@ -562,16 +970,39 @@ pub fn synthesize_class_captures( prefer_fallback: true, }), ))); - assignment_stmts.push(Stmt::Expr(Expr::PropertySet { - object: Box::new(Expr::This), - property: crate::cap_fields::cap_field_name(cap_salt, outer_id), - value: Box::new(Expr::LocalGet(fresh_param_id)), + assignment_stmts.push(Stmt::Expr(if env_mode { + Expr::ClassEnvSet { + class_name: name.to_string(), + index: index as u32, + value: Box::new(Expr::LocalGet(fresh_param_id)), + guarded, + publish: true, + } + } else { + Expr::PropertySet { + object: Box::new(Expr::This), + property: crate::cap_fields::cap_field_name(cap_salt, outer_id), + value: Box::new(Expr::LocalGet(fresh_param_id)), + } })); } // Rewrite user-written ctor body BEFORE inserting the rebind + assignment - // stmts (which already reference the fresh ids directly). - crate::analysis::remap_local_ids_in_stmts(&mut ctor.body, &ctor_id_map); + // stmts (which already reference the fresh ids directly). A class- + // environment ctor also propagates its own writes, like every member: the + // environment is shared, so a method the ctor calls must see them. + if env_mode { + crate::analysis::remap_local_ids_in_stmts_with_field_propagation( + &mut ctor.body, + &ctor_id_map, + &field_propagation, + ); + } else { + crate::analysis::remap_local_ids_in_stmts(&mut ctor.body, &ctor_id_map); + } append_self_sites(&mut ctor.body, &ctor_id_map); + if guarded { + stamp_self_constructions(ctx, name, &mut ctor.body); + } // Finding #2: the param REBINDS (`param = param-or-snapshot`) go at // FUNCTION ENTRY (index 0), BEFORE any pre-`super()` user code — a derived // ctor may read a captured outer before calling `super()`, and that read @@ -612,7 +1043,13 @@ pub fn synthesize_class_captures( // bundles fold it into a comma sequence (`super(a), this.x = b, …` — // Next's `AppRouteRouteModule`), an `if (super(), …)` test or a `try`, // all of which landed the stash at constructor entry (#8546 follow-up). - let early_insert_at = if has_heritage { + // The environment needs no `this`: a class-environment ctor publishes its + // capture params at entry, before any user statement (a base ctor that + // dispatches into this class's override reads them before `super()` + // returns). + let early_insert_at = if env_mode { + Some(rebind_count) + } else if has_heritage { // No direct `super()` anywhere in the body (a closure calls it, or a // value-bearing `return` takes the override path): there is no point // at which `this` is known to be bound, so skip the early stash. The @@ -1011,3 +1448,137 @@ pub(crate) fn append_new_args_stmt( | Stmt::ReleaseBoxes(_) => {} } } + +/// Wrap every `new (…)` in `expr` (nested closures included, +/// patching their capture lists) in an `Expr::ClassEnvStamp` recording the +/// enclosing member's evaluation, held in `eval_id`. +fn stamp_self_new_expr(expr: &mut Expr, class_name: &str, eval_id: LocalId) { + // A construct the `new`-site lowering already stamped keeps its binding. + if matches!(expr, Expr::ClassEnvStamp { .. }) { + return; + } + if matches!(expr, Expr::New { class_name: cn, .. } if cn == class_name) { + let instance = std::mem::replace(expr, Expr::Undefined); + *expr = Expr::ClassEnvStamp { + class_name: class_name.to_string(), + instance: Box::new(instance), + evaluation: Box::new(Expr::LocalGet(eval_id)), + }; + return; + } + if let Expr::Closure { body, captures, .. } = expr { + for stmt in body.iter_mut() { + stamp_self_new_stmt(stmt, class_name, eval_id); + } + let mut refs = Vec::new(); + let mut visited = std::collections::HashSet::new(); + for stmt in body.iter() { + crate::analysis::collect_local_refs_stmt(stmt, &mut refs, &mut visited); + } + if refs.contains(&eval_id) && !captures.contains(&eval_id) { + captures.push(eval_id); + } + return; + } + crate::walker::walk_expr_children_mut(expr, &mut |child| { + stamp_self_new_expr(child, class_name, eval_id) + }); +} + +/// Statement-level driver for [`stamp_self_new_expr`]. +fn stamp_self_new_stmt(stmt: &mut Stmt, class_name: &str, eval_id: LocalId) { + match stmt { + Stmt::Let { init, .. } => { + if let Some(e) = init { + stamp_self_new_expr(e, class_name, eval_id); + } + } + Stmt::Expr(e) | Stmt::Throw(e) => stamp_self_new_expr(e, class_name, eval_id), + Stmt::Return(opt) => { + if let Some(e) = opt { + stamp_self_new_expr(e, class_name, eval_id); + } + } + Stmt::If { + condition, + then_branch, + else_branch, + } => { + stamp_self_new_expr(condition, class_name, eval_id); + for s in then_branch { + stamp_self_new_stmt(s, class_name, eval_id); + } + if let Some(eb) = else_branch { + for s in eb { + stamp_self_new_stmt(s, class_name, eval_id); + } + } + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + stamp_self_new_expr(condition, class_name, eval_id); + for s in body { + stamp_self_new_stmt(s, class_name, eval_id); + } + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(s) = init { + stamp_self_new_stmt(s, class_name, eval_id); + } + if let Some(e) = condition { + stamp_self_new_expr(e, class_name, eval_id); + } + if let Some(e) = update { + stamp_self_new_expr(e, class_name, eval_id); + } + for s in body { + stamp_self_new_stmt(s, class_name, eval_id); + } + } + Stmt::Labeled { body, .. } => stamp_self_new_stmt(body, class_name, eval_id), + Stmt::Try { + body, + catch, + finally, + } => { + for s in body { + stamp_self_new_stmt(s, class_name, eval_id); + } + if let Some(c) = catch { + for s in &mut c.body { + stamp_self_new_stmt(s, class_name, eval_id); + } + } + if let Some(fb) = finally { + for s in fb { + stamp_self_new_stmt(s, class_name, eval_id); + } + } + } + Stmt::Switch { + discriminant, + cases, + } => { + stamp_self_new_expr(discriminant, class_name, eval_id); + for c in cases { + if let Some(t) = &mut c.test { + stamp_self_new_expr(t, class_name, eval_id); + } + for s in &mut c.body { + stamp_self_new_stmt(s, class_name, eval_id); + } + } + } + Stmt::Break + | Stmt::Continue + | Stmt::LabeledBreak(_) + | Stmt::LabeledContinue(_) + | Stmt::PreallocateBoxes(_) + | Stmt::PreallocateTdzBoxes(_) + | Stmt::ReleaseBoxes(_) => {} + } +} diff --git a/crates/perry-hir/src/lower_decl/class_decl.rs b/crates/perry-hir/src/lower_decl/class_decl.rs index 31eec9a1c9..094f466e97 100644 --- a/crates/perry-hir/src/lower_decl/class_decl.rs +++ b/crates/perry-hir/src/lower_decl/class_decl.rs @@ -1269,6 +1269,10 @@ pub fn lower_class_decl( &mut constructor, &mut static_methods, &static_accessor_fn_ids, + crate::lower_decl::CaptureDefinition::classify( + ctx.class_definition_runs_once(class_decl.class.span), + false, + ), ); // Phase 4.1: register each method's and getter's return type so diff --git a/crates/perry-hir/src/lower_decl/class_decl/from_ast.rs b/crates/perry-hir/src/lower_decl/class_decl/from_ast.rs index 3243b06e8e..c78736327c 100644 --- a/crates/perry-hir/src/lower_decl/class_decl/from_ast.rs +++ b/crates/perry-hir/src/lower_decl/class_decl/from_ast.rs @@ -13,6 +13,8 @@ pub(crate) fn lower_class_from_ast( name: &str, is_exported: bool, ) -> Result { + // Consume the flag before any nested class in this body is lowered. + let fresh_class_expr = std::mem::take(&mut ctx.pending_fresh_class_expr); validate_legacy_decorator_surface(class, name)?; validate_class_element_early_errors(class, name)?; let class_id = match ctx.lookup_class(name) { @@ -707,6 +709,10 @@ pub(crate) fn lower_class_from_ast( &mut constructor, &mut static_methods, &static_accessor_fn_ids, + crate::lower_decl::CaptureDefinition::classify( + ctx.class_definition_runs_once(class.span), + fresh_class_expr, + ), ); Ok(Class { diff --git a/crates/perry-hir/src/lower_decl/mod.rs b/crates/perry-hir/src/lower_decl/mod.rs index 8c6a0e4550..7f398006d5 100644 --- a/crates/perry-hir/src/lower_decl/mod.rs +++ b/crates/perry-hir/src/lower_decl/mod.rs @@ -37,7 +37,9 @@ pub(crate) use block::{ }; pub(crate) use body_stmt::gen_capture_scan::forward_referenced_nested_generators; pub(crate) use body_stmt::{find_native_return_in_stmts, lower_body_stmt}; -pub(crate) use class_captures::{append_new_args_stmt, synthesize_class_captures}; +pub(crate) use class_captures::{ + append_new_args_stmt, synthesize_class_captures, CaptureDefinition, +}; pub(crate) use class_computed::fresh_class_static_init_order; pub(crate) use class_computed::{ class_computed_member_registration_expr, prepare_ordered_class_computed_names, diff --git a/crates/perry-hir/src/stable_hash/expr.rs b/crates/perry-hir/src/stable_hash/expr.rs index 070f8bfad0..c66244d506 100644 --- a/crates/perry-hir/src/stable_hash/expr.rs +++ b/crates/perry-hir/src/stable_hash/expr.rs @@ -648,7 +648,11 @@ impl SH for Expr { Expr::TemplateRaw(e) => { tag(h, 446); e.as_ref().hash(h); } Expr::RegisterClassParentDynamic { class_name, parent_expr, } => { tag(h, 447); class_name.hash(h); parent_expr.as_ref().hash(h); } Expr::RegisterClassCaptures { class_name, captures } => { tag(h, 12241); class_name.hash(h); for c in captures { c.hash(h); } } - Expr::RefreshClassExprCaptures { class_value, captures } => { tag(h, 12243); class_value.as_ref().hash(h); for c in captures { c.hash(h); } } + Expr::RefreshClassExprCaptures { class_value, captures, env_class } => { tag(h, 12243); class_value.as_ref().hash(h); for c in captures { c.hash(h); } env_class.hash(h); } + Expr::ClassEnvGet { class_name, index, guarded } => { tag(h, 12244); class_name.hash(h); index.hash(h); guarded.hash(h); } + Expr::ClassEnvSet { class_name, index, value, guarded, publish } => { tag(h, 12245); class_name.hash(h); index.hash(h); value.as_ref().hash(h); guarded.hash(h); publish.hash(h); } + Expr::ClassEnvStamp { class_name, instance, evaluation } => { tag(h, 12246); class_name.hash(h); instance.as_ref().hash(h); evaluation.as_ref().hash(h); } + Expr::ClassEnvCurrent { class_name } => { tag(h, 12247); class_name.hash(h); } Expr::ClassCaptureValue { class_name, index, fallback, prefer_fallback } => { tag(h, 12242); class_name.hash(h); index.hash(h); fallback.hash(h); prefer_fallback.hash(h); } Expr::RegisterClassStaticSymbol { class_name, key_expr, value_expr, } => { tag(h, 12025); class_name.hash(h); key_expr.as_ref().hash(h); value_expr.as_ref().hash(h); } Expr::RegisterClassComputedMethod { class_name, key_expr, method_name, is_static, param_count, has_rest, definition_order } => { tag(h, 12233); class_name.hash(h); key_expr.as_ref().hash(h); method_name.hash(h); is_static.hash(h); param_count.hash(h); has_rest.hash(h); definition_order.hash(h); } diff --git a/crates/perry-hir/src/walker/expr_mut.rs b/crates/perry-hir/src/walker/expr_mut.rs index 2ea6df910b..57d7aec200 100644 --- a/crates/perry-hir/src/walker/expr_mut.rs +++ b/crates/perry-hir/src/walker/expr_mut.rs @@ -37,6 +37,8 @@ where | Expr::SuperPropertyGet { .. } | Expr::EnumMember { .. } | Expr::StaticFieldGet { .. } + | Expr::ClassEnvGet { .. } + | Expr::ClassEnvCurrent { .. } | Expr::Update { .. } | Expr::EnvGet(_) | Expr::ProcessEnv @@ -595,12 +597,24 @@ where Expr::RefreshClassExprCaptures { class_value, captures, + .. } => { f(class_value); for c in captures { f(c); } } + Expr::ClassEnvSet { value, .. } => { + f(value); + } + Expr::ClassEnvStamp { + instance, + evaluation, + .. + } => { + f(instance); + f(evaluation); + } Expr::ClassCaptureValue { fallback, .. } => { if let Some(fb) = fallback { f(fb); diff --git a/crates/perry-hir/src/walker/expr_ref.rs b/crates/perry-hir/src/walker/expr_ref.rs index 707dd7b1ef..af64231f78 100644 --- a/crates/perry-hir/src/walker/expr_ref.rs +++ b/crates/perry-hir/src/walker/expr_ref.rs @@ -38,6 +38,8 @@ where | Expr::SuperPropertyGet { .. } | Expr::EnumMember { .. } | Expr::StaticFieldGet { .. } + | Expr::ClassEnvGet { .. } + | Expr::ClassEnvCurrent { .. } | Expr::Update { .. } | Expr::EnvGet(_) | Expr::ProcessEnv @@ -596,12 +598,24 @@ where Expr::RefreshClassExprCaptures { class_value, captures, + .. } => { f(class_value); for c in captures { f(c); } } + Expr::ClassEnvSet { value, .. } => { + f(value); + } + Expr::ClassEnvStamp { + instance, + evaluation, + .. + } => { + f(instance); + f(evaluation); + } Expr::ClassCaptureValue { fallback, .. } => { if let Some(fb) = fallback { f(fb); diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index 8acb51c92d..54e7cc66f6 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -1211,6 +1211,7 @@ pub fn gc_init() { // them if a copying collection moves their backing allocations. reg_scanner!(crate::object::scan_native_callable_export_roots_mut); reg_scanner!(crate::object::scan_class_capture_value_roots_mut); + reg_scanner!(crate::object::scan_class_env_roots_mut); reg_scanner!(crate::node_vm::scan_vm_roots_mut); // #6559: the dyn-eval interpreter's rooted value stack (environments, // temporaries, arguments of in-flight interpreted frames). Mark + diff --git a/crates/perry-runtime/src/object/class_constructors.rs b/crates/perry-runtime/src/object/class_constructors.rs index 80c9d73fd3..2aef916811 100644 --- a/crates/perry-runtime/src/object/class_constructors.rs +++ b/crates/perry-runtime/src/object/class_constructors.rs @@ -94,7 +94,7 @@ pub extern "C" fn js_class_capture_value_for_receiver( /// heritage, the same order `instanceof`'s `class_chain_reaches_dynamic` walks. /// A ClassRef of `class_id` itself answers `None`: a declaration's captures /// live in its decl-site snapshot, not on an object. -fn capture_owner_for_template(start: f64, class_id: u32) -> Option { +pub(crate) fn capture_owner_for_template(start: f64, class_id: u32) -> Option { let mut current = start; for _ in 0..64 { let cid = if super::class_registry::is_class_object_value(current) { diff --git a/crates/perry-runtime/src/object/class_env.rs b/crates/perry-runtime/src/object/class_env.rs new file mode 100644 index 0000000000..0fd7841dd3 --- /dev/null +++ b/crates/perry-runtime/src/object/class_env.rs @@ -0,0 +1,284 @@ +//! Guarded class capture environments (`Expr::ClassEnvGet { guarded: true }`). +//! +//! A capturing class EXPRESSION inside a function body evaluates to a fresh +//! class object per evaluation, each carrying its own capture array +//! (`__perry_ctor_caps`). Its members read their captures from one set of +//! module-state slot globals owned by the class's FIRST evaluation. While the +//! class has had only that evaluation, the slots are the whole truth and +//! generated code reads them directly after one compare of the class's state +//! global against 0. A second evaluation — a CommonJS module body the runtime +//! re-runs (`module_require.rs`'s re-require of a loaded module) — sets the +//! state to 1; from then on generated code calls into this module, which +//! resolves the receiver's own evaluation and reads that evaluation's array +//! unless it is the owner. +//! +//! An instance names its evaluation through the private-evaluation brand in +//! its metadata record: dynamic construction stamps it always, and a static +//! `new` stamps it (`js_class_env_stamp`) once the class is in the multi- +//! evaluation state. An unstamped instance belongs to the first evaluation. + +use std::collections::HashMap; + +use crate::value::TAG_UNDEFINED; + +struct ClassEnv { + /// NaN-boxed class object of the first evaluation (0 = none yet). A GC + /// root: see [`scan_class_env_roots_mut`]. + owner: u64, + /// The class's state global (`0.0` = one evaluation, `1.0` = several). + state: *mut f64, + /// The slot globals, by capture index. Registered GC roots of their own. + slots: Vec<*mut f64>, +} + +crate::perry_thread_local! { + static CLASS_ENVS: std::cell::RefCell> = + std::cell::RefCell::new(HashMap::new()); +} + +fn with_env(cid: u32, f: impl FnOnce(&mut ClassEnv) -> R) -> R { + CLASS_ENVS.with(|envs| { + let mut envs = envs.borrow_mut(); + let env = envs.entry(cid).or_insert_with(|| ClassEnv { + owner: 0, + state: std::ptr::null_mut(), + slots: Vec::new(), + }); + f(env) + }) +} + +/// GC root scan for the owner class objects. +pub fn scan_class_env_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { + CLASS_ENVS.with(|envs| { + for env in envs.borrow_mut().values_mut() { + if env.owner != 0 { + visitor.visit_nanbox_u64_slot(&mut env.owner); + } + } + }); +} + +/// Codegen FFI (module init): the class's state global. +#[no_mangle] +pub unsafe extern "C" fn js_class_env_register_state(cid: u32, state: *mut f64) { + with_env(cid, |env| env.state = state); +} + +/// Codegen FFI (module init): slot global `index` of the class environment. +#[no_mangle] +pub unsafe extern "C" fn js_class_env_register_slot(cid: u32, index: u32, slot: *mut f64) { + with_env(cid, |env| { + let index = index as usize; + if env.slots.len() <= index { + env.slots.resize(index + 1, std::ptr::null_mut()); + } + env.slots[index] = slot; + }); +} + +/// Store `value` into a registered root slot, with the incremental-mark root +/// barrier generated code uses for the same slots. +unsafe fn store_slot(slot: *mut f64, value: f64) { + if slot.is_null() { + return; + } + *slot = value; + crate::gc::runtime_write_barrier_root_nanbox(value.to_bits()); +} + +/// Copy a capture array into the owner's slots. +unsafe fn publish_array(cid: u32, caps: f64) { + let caps = crate::value::JSValue::from_bits(caps.to_bits()); + if !caps.is_pointer() { + return; + } + let array = caps.as_pointer::(); + if array.is_null() { + return; + } + let len = crate::array::js_array_length(array); + let slots: Vec<*mut f64> = with_env(cid, |env| env.slots.clone()); + for (index, slot) in slots.into_iter().enumerate() { + if (index as u32) < len { + store_slot(slot, crate::array::js_array_get_f64(array, index as u32)); + } + } +} + +/// Codegen FFI: a guarded class evaluated, producing `class_value` with the +/// capture array `caps`. The first evaluation becomes the owner and publishes +/// its captures into the slots; any other one only moves the class into the +/// multi-evaluation state (its members then resolve per receiver). +#[no_mangle] +pub unsafe extern "C" fn js_class_env_evaluate(cid: u32, class_value: f64, caps: f64) { + let owner = with_env(cid, |env| { + if env.owner == 0 { + env.owner = class_value.to_bits(); + true + } else if env.owner == class_value.to_bits() { + true + } else { + if !env.state.is_null() { + *env.state = 1.0; + } + false + } + }); + if owner { + publish_array(cid, caps); + } +} + +/// Codegen FFI: `class_value`'s capture array was refreshed (a captured +/// binding was assigned after the class evaluated). Only the owner's refresh +/// reaches the slots. +#[no_mangle] +pub unsafe extern "C" fn js_class_env_refresh(cid: u32, class_value: f64, caps: f64) { + if with_env(cid, |env| env.owner == class_value.to_bits()) { + publish_array(cid, caps); + } +} + +/// The evaluation a member of class `cid` runs in. The candidates, in order, +/// are the ones `js_class_capture_value_for_receiver` uses: the method value's +/// own evaluation (extracted-method dispatch), the class object an ordinary +/// static dispatch found the member on, and the receiver. A class value (or +/// class ref) candidate is walked up its heritage to `cid`'s evaluation, so an +/// inherited static runs in its defining evaluation; an instance answers with +/// its recorded brand's ancestor for `cid`. +fn member_evaluation(receiver: f64, cid: u32) -> Option { + let candidates = [ + super::field_get_set::current_private_lexical_brand_value(cid), + super::static_private_owner_current(), + Some(receiver), + ]; + for candidate in candidates.into_iter().flatten() { + let found = if super::class_registry::is_class_object_value(candidate) + || super::class_ref_id(candidate).is_some() + { + super::capture_owner_for_template(candidate, cid) + } else { + super::field_get_set::class_evaluation_of(candidate, cid) + }; + if let Some(evaluation) = found { + return Some(evaluation.to_bits()); + } + } + None +} + +/// The capture array of a non-owner evaluation, or `None` for the owner. +fn foreign_caps(receiver: f64, cid: u32) -> Option<*mut crate::array::ArrayHeader> { + let evaluation = member_evaluation(receiver, cid)?; + if with_env(cid, |env| env.owner == evaluation) { + return None; + } + let caps = super::js_object_get_own_field_or_undef( + f64::from_bits(evaluation), + b"__perry_ctor_caps".as_ptr(), + 17, + ); + let caps = crate::value::JSValue::from_bits(caps.to_bits()); + if !caps.is_pointer() { + return None; + } + let array = caps.as_pointer::() as *mut crate::array::ArrayHeader; + (!array.is_null()).then_some(array) +} + +/// Codegen FFI: the slow read, taken once the class has several evaluations. +#[no_mangle] +pub unsafe extern "C" fn js_class_env_get(receiver: f64, cid: u32, index: u32) -> f64 { + if let Some(array) = foreign_caps(receiver, cid) { + return if index < crate::array::js_array_length(array) { + crate::array::js_array_get_f64(array, index) + } else { + f64::from_bits(TAG_UNDEFINED) + }; + } + let slot = with_env(cid, |env| { + env.slots + .get(index as usize) + .copied() + .unwrap_or(std::ptr::null_mut()) + }); + if slot.is_null() { + f64::from_bits(TAG_UNDEFINED) + } else { + *slot + } +} + +/// Codegen FFI: the slow write of a captured binding (see `js_class_env_get`). +#[no_mangle] +pub unsafe extern "C" fn js_class_env_set(receiver: f64, cid: u32, index: u32, value: f64) { + if let Some(array) = foreign_caps(receiver, cid) { + if index < crate::array::js_array_length(array) { + crate::array::js_array_set_f64(array, index, value); + } + return; + } + let slot = with_env(cid, |env| { + env.slots + .get(index as usize) + .copied() + .unwrap_or(std::ptr::null_mut()) + }); + store_slot(slot, value); +} + +/// Codegen FFI: the evaluation (class value) a member of class `cid` runs in, +/// or `undefined` when it cannot be resolved — which, like an unrecorded +/// instance, means the first evaluation. +#[no_mangle] +pub extern "C" fn js_class_env_current(receiver: f64, cid: u32) -> f64 { + member_evaluation(receiver, cid) + .map(f64::from_bits) + .unwrap_or(f64::from_bits(TAG_UNDEFINED)) +} + +/// Codegen FFI: record `evaluation` as the evaluation of `instance`, built by +/// a static `new` of class `cid` while the class has several evaluations. +/// Returns the instance (the metadata allocation may move it). +#[no_mangle] +pub unsafe extern "C" fn js_class_env_stamp(instance: f64, cid: u32, evaluation: f64) -> f64 { + if with_env(cid, |env| env.owner == evaluation.to_bits()) + || !super::class_registry::is_class_object_value(evaluation) + { + return instance; + } + let value = crate::value::JSValue::from_bits(instance.to_bits()); + if !value.is_pointer() { + return instance; + } + let scope = crate::gc::RuntimeHandleScope::new(); + let handle = scope.root_nanbox_f64(instance); + let object = value.as_pointer::() as *mut super::ObjectHeader; + if object.is_null() { + return instance; + } + super::field_get_set::stamp_private_evaluation_brand(object, evaluation); + handle.get_nanbox_f64() +} + +#[cfg(feature = "keepalive-anchors")] +mod keepalive { + #[used(compiler)] + static REGISTER_STATE: unsafe extern "C" fn(u32, *mut f64) = super::js_class_env_register_state; + #[used(compiler)] + static REGISTER_SLOT: unsafe extern "C" fn(u32, u32, *mut f64) = + super::js_class_env_register_slot; + #[used(compiler)] + static EVALUATE: unsafe extern "C" fn(u32, f64, f64) = super::js_class_env_evaluate; + #[used(compiler)] + static REFRESH: unsafe extern "C" fn(u32, f64, f64) = super::js_class_env_refresh; + #[used(compiler)] + static GET: unsafe extern "C" fn(f64, u32, u32) -> f64 = super::js_class_env_get; + #[used(compiler)] + static SET: unsafe extern "C" fn(f64, u32, u32, f64) = super::js_class_env_set; + #[used(compiler)] + static STAMP: unsafe extern "C" fn(f64, u32, f64) -> f64 = super::js_class_env_stamp; + #[used(compiler)] + static CURRENT: extern "C" fn(f64, u32) -> f64 = super::js_class_env_current; +} diff --git a/crates/perry-runtime/src/object/field_get_set.rs b/crates/perry-runtime/src/object/field_get_set.rs index 35cc8e8b38..c2530ac096 100644 --- a/crates/perry-runtime/src/object/field_get_set.rs +++ b/crates/perry-runtime/src/object/field_get_set.rs @@ -302,7 +302,7 @@ pub(crate) use has_property::{ pub use has_property::{js_in_operator, js_object_has_property}; pub use has_property_ic::js_in_operator_presence_ic; pub(crate) use ic_miss::{ - bind_primitive_proto_method_static, cannot_be_private_member_name, + bind_primitive_proto_method_static, cannot_be_private_member_name, class_evaluation_of, current_private_lexical_brand_value, is_array_method_value_name, private_evaluation_brand_value, private_lexical_brand_pop, private_lexical_brand_push, private_lexical_brand_stack_restore, private_lexical_brand_stack_savepoint, diff --git a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs index 5319dafd58..b5927b1520 100644 --- a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs +++ b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs @@ -1658,6 +1658,13 @@ fn current_private_lexical_brand(declaring_class_id: u32) -> Option { }) } +/// The class-definition evaluation `value` belongs to for members of +/// `declaring_class_id`: a class object of that template is its own, and an +/// instance's is its recorded brand's ancestor for that template. +pub(crate) fn class_evaluation_of(value: f64, declaring_class_id: u32) -> Option { + private_evaluation_brand(value, declaring_class_id).map(f64::from_bits) +} + pub(crate) fn current_private_lexical_brand_value(declaring_class_id: u32) -> Option { current_private_lexical_brand(declaring_class_id).map(f64::from_bits) } diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 4d716233c2..c0904a52f1 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -77,6 +77,7 @@ mod async_generator_queue; mod bigint_dispatch; mod buffer_dispatch; mod class_constructors; +mod class_env; mod class_gc_roots; mod class_handles; pub mod class_image; @@ -251,6 +252,7 @@ pub(crate) use async_generator_queue::is_async_generator_instance_value; pub(crate) use bigint_dispatch::*; pub use buffer_dispatch::*; pub use class_constructors::*; +pub use class_env::*; pub use class_gc_roots::scan_class_inheritance_roots_mut; #[cfg(test)] pub(crate) use class_gc_roots::{ diff --git a/crates/perry-transform/src/inline/factory_specialize.rs b/crates/perry-transform/src/inline/factory_specialize.rs index 9c3d8c2574..17500e1ca7 100644 --- a/crates/perry-transform/src/inline/factory_specialize.rs +++ b/crates/perry-transform/src/inline/factory_specialize.rs @@ -158,6 +158,18 @@ pub fn specialize_captured_class_factories(module: &mut Module) { } fn class_needs_specialization(class: &Class, param_ids: &[LocalId]) -> bool { + // A class whose captures live in the class environment was defined + // by a factory that runs at most once. That one evaluation already + // publishes the environment, and a per-call-site clone would read the + // original class's environment under a new name. + let publishes_env = class.constructor.as_ref().is_some_and(|c| { + c.body + .iter() + .any(|s| matches!(s, Stmt::Expr(Expr::ClassEnvSet { .. }))) + }); + if publishes_env { + return false; + } let param_set: HashSet = param_ids.iter().copied().collect(); let has_capture_params = class .constructor diff --git a/crates/perry/tests/class_capture_environment.rs b/crates/perry/tests/class_capture_environment.rs new file mode 100644 index 0000000000..c00caa2009 --- /dev/null +++ b/crates/perry/tests/class_capture_environment.rs @@ -0,0 +1,415 @@ +//! Class captures live with the class definition, not on instances. +//! +//! A class nested in a function captures the enclosing function's locals. +//! When the class definition is evaluated at most once (module top, an IIFE +//! body, a function declaration called exactly once — `lower::run_once`) its +//! members read and write those captures in the class ENVIRONMENT +//! (`Expr::ClassEnvGet`/`ClassEnvSet`), the way V8 keeps them in the closure +//! context. Every other capturing class keeps the per-instance `__perry_cap_*` +//! snapshot, because each of its evaluations has its own environment. +//! +//! Each test is differential: Node runs the same source, and both outputs must +//! equal the expected text. Each also asserts WHICH storage the compiler chose +//! (`PERRY_CLASS_CAPTURE_DIAG`), so a test meant for the environment path +//! cannot pass on the instance path or the reverse. + +use std::path::PathBuf; +use std::process::{Command, Output}; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn assert_success(label: &str, output: &Output) { + assert!( + output.status.success(), + "{label} failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +/// Compile `main.ts` (`src`, plus `(file name, contents)` sources beside it) +/// with perry, run it and Node on it; returns (perry stdout, node stdout, the +/// compiler's capture-storage diagnostics). +fn run_both_with(src: &str, extra: &[(&str, &str)]) -> (String, String, Vec) { + let dir = tempfile::tempdir().expect("tempdir"); + for (name, contents) in extra { + std::fs::write(dir.path().join(name), contents).expect("write extra source"); + } + let entry = dir.path().join("main.ts"); + std::fs::write(&entry, src).expect("write fixture"); + let bin = dir.path().join("main_bin"); + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .env("PERRY_CLASS_CAPTURE_DIAG", "1") + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&bin) + .arg("--no-cache") + .output() + .expect("run perry compile"); + assert_success("perry compile", &compile); + let diag: Vec = String::from_utf8_lossy(&compile.stderr) + .lines() + .filter(|l| l.starts_with("[class-capture]")) + .map(str::to_string) + .collect(); + let run = Command::new(&bin) + .current_dir(dir.path()) + .output() + .expect("run compiled fixture"); + assert_success("compiled fixture", &run); + let node = Command::new("node") + .current_dir(dir.path()) + .arg(&entry) + .output() + .expect("run Node semantic oracle"); + assert_success("Node", &node); + ( + String::from_utf8(run.stdout).expect("utf-8"), + String::from_utf8(node.stdout).expect("utf-8"), + diag, + ) +} + +/// The storage the compiler reported for the capturing class `class` (a +/// named class expression registers as `__class_expr_`). +fn storage_of<'a>(diag: &'a [String], class: &str) -> &'a str { + let exact = format!(" class={class} "); + let expr = format!(" class={class}__class_expr_"); + let line = diag + .iter() + .find(|l| l.contains(&exact) || l.contains(&expr)) + .unwrap_or_else(|| panic!("no capture diagnostic for class {class}: {diag:#?}")); + line.rsplit("storage=").next().expect("storage field") +} + +fn check(src: &str, expected: &str, storage: &[(&str, &str)]) { + check_with(src, &[], expected, storage) +} + +fn check_with(src: &str, extra: &[(&str, &str)], expected: &str, storage: &[(&str, &str)]) { + let (perry, node, diag) = run_both_with(src, extra); + assert_eq!(node, expected, "Node disagrees with the expected text"); + assert_eq!(perry, expected, "perry disagrees with Node"); + for (class, want) in storage { + assert_eq!(storage_of(&diag, class), *want, "storage of {class}"); + } +} + +#[test] +fn method_mutation_is_shared_by_instances_and_the_enclosing_scope() { + check( + "(function () { + let count = 0; + class Counter { bump() { count++; return count; } read() { return count; } } + const a = new Counter(), b = new Counter(); + a.bump(); a.bump(); b.bump(); + console.log(a.read(), b.read(), count); + count = 10; + console.log(a.read(), b.read()); + })();", + "3 3 3\n10 10\n", + &[("Counter", "env")], + ); +} + +#[test] +fn a_constructor_write_reaches_the_enclosing_scope() { + check( + "(function () { + let made = 0; + const unit = 'px'; + class Shape { size: string; constructor() { made++; this.size = this.describe(); } describe() { return 'shape'; } } + class Box extends Shape { describe() { return 'box' + unit + made; } } + const bx = new Box(); + new Shape(); + console.log(bx.size, made); + })();", + "boxpx1 2\n", + &[("Shape", "env"), ("Box", "env")], + ); +} + +#[test] +fn an_extracted_method_reads_its_class_environment_with_any_this() { + check( + "(function () { + const tag = 'T'; + class Tagged { get() { return tag + ':' + typeof this; } } + const g = Tagged.prototype.get; + console.log(g.call({}), g.call(42), new Tagged().get()); + })();", + "T:object T:number T:object\n", + &[("Tagged", "env")], + ); +} + +#[test] +fn statics_and_instances_share_one_environment() { + check( + "(function () { + let seq = 100; + const label = 'L'; + class S { + static next() { return ++seq; } + static peek() { return label + seq; } + inst() { return label + seq; } + } + S.next(); S.next(); + console.log(S.peek(), new S().inst(), seq); + })();", + "L102 L102 102\n", + &[("S", "env")], + ); +} + +#[test] +fn subclasses_and_super_read_their_own_class_environment() { + check( + "(function () { + const base = 'B'; + class Base { who() { return base; } greet() { return 'hi ' + this.who(); } } + const derived = 'D'; + class Derived extends Base { who() { return derived + '<' + super.who(); } own() { return derived; } } + class Plain extends Base {} + const d = new Derived(); + console.log(d.greet(), d.own(), new Plain().greet(), d instanceof Base); + })();", + "hi D o.t()).join(','));", + "a b suba x1,x2,x3\n", + &[("Base", "env-guarded")], + ); +} + +#[test] +fn a_class_in_a_loop_body_is_not_a_single_evaluation() { + check( + "(function () { + const made = []; + for (const v of ['p', 'q']) { + class L { get() { return v; } } + made.push(new L()); + } + console.log(made.map((o) => o.get()).join(',')); + })();", + "p,q\n", + &[("L", "instance")], + ); +} + +#[test] +fn reflection_sees_only_declared_fields() { + check( + "(function () { + const k = 1, tag = 'n'; + class Node2 { pos = 0; end = 5; kind = k; name() { return tag + this.kind; } } + const n = new Node2(); + const forIn = []; + for (const key in n) forIn.push(key); + console.log(Object.keys(n).join(','), JSON.stringify(n), forIn.join(','), + Object.getOwnPropertyNames(n).join(','), JSON.stringify({ ...n }), n.name()); + })();", + "pos,end,kind {\"pos\":0,\"end\":5,\"kind\":1} pos,end,kind pos,end,kind {\"pos\":0,\"end\":5,\"kind\":1} n1\n", + &[("Node2", "env")], + ); +} + +#[test] +fn a_statically_constructed_class_expression_keeps_its_evaluation() { + // `new C()` through the binding is a static construct; once `mk` has run + // twice the second instance must be recorded as the second evaluation's. + check( + "function mk(v) { const C = class { get() { return v; } }; return new C(); } + const one = mk(1), two = mk(2), three = mk(3); + console.log(one.get(), two.get(), three.get());", + "1 2 3\n", + &[("C", "env-guarded")], + ); +} + +const REEVAL_MODULE: &str = "globalThis.__evals = (globalThis.__evals || 0) + 1; +const tag = 'e' + globalThis.__evals; +var Box = class { + get() { return tag; } + static tagOf() { return tag; } +}; +module.exports = { make: () => new Box(), Box: Box }; +"; + +const REEVAL_DRIVER: &str = "const first = require('./m.js'); +const a = first.make(); +const a2 = new first.Box(); +const key = Object.keys(require.cache).find((k) => k.endsWith('/m.js')); +delete require.cache[key]; +const second = require('module').createRequire(__filename)(key); +const b = second.make(); +const b2 = new second.Box(); +const c = first.make(); +module.exports = [first.Box === second.Box, a.get(), a2.get(), b.get(), b2.get(), c.get(), + first.Box.tagOf(), second.Box.tagOf(), first.Box.prototype.get.call(b)].join(' '); +"; + +#[test] +fn a_re_evaluated_module_body_keeps_old_instances_on_their_evaluation() { + // Deleting the cache entry and requiring again re-runs the CommonJS module + // body (perry-runtime's module_require re-require of a loaded module), so + // `Box` has two evaluations: instances, statics and the first + // evaluation's extracted method must each see their own `tag`. + check_with( + "console.log(require('./driver.js'));", + &[("m.js", REEVAL_MODULE), ("driver.js", REEVAL_DRIVER)], + "false e1 e1 e2 e2 e1 e1 e2 e1\n", + &[("Box", "env-guarded")], + ); +} + +const REEVAL_SELF_MODULE: &str = r#"globalThis.__evals2 = (globalThis.__evals2 || 0) + 1; +const tag = "e" + globalThis.__evals2; +var Box = class Inner { + get() { return tag; } + make() { return new Inner(); } + makeLater() { return [1].map(() => new Inner())[0]; } + make2() { return new Box(); } +}; +module.exports = { make: () => new Box(), Box: Box }; +"#; + +const REEVAL_SELF_DRIVER: &str = r#"const first = require("./m2.js"); +const a = first.make(); +const key = Object.keys(require.cache).find((k) => k.endsWith("/m2.js")); +delete require.cache[key]; +const second = require("module").createRequire(__filename)(key); +const b = second.make(); +module.exports = [a.make().get(), a.makeLater().get(), a.make2().get(), + b.make().get(), b.makeLater().get(), b.make2().get(), + first.Box.prototype.make.call(b).get()].join(" "); +"#; + +#[test] +fn a_self_construction_after_re_evaluation_keeps_the_members_evaluation() { + // `new Inner()` inside a member (directly and from an arrow) must build + // an instance of the member's OWN evaluation: the second evaluation's + // instance `b` makes `e2` objects, the first's `a` makes `e1` ones, and + // the first evaluation's extracted method called on `b` makes `e1`. + check_with( + "console.log(require('./driver_self.js'));", + &[ + ("m2.js", REEVAL_SELF_MODULE), + ("driver_self.js", REEVAL_SELF_DRIVER), + ], + "e1 e1 e1 e2 e2 e2 e1\n", + &[("Inner", "env-guarded")], + ); +} + +#[test] +fn inherited_members_run_in_their_defining_evaluation() { + // A subclass constructed through `super(...args)` must not overwrite the + // base class environment with a missing capture param, an inherited + // static runs in the evaluation it was found on, and an extracted static + // keeps its own evaluation whatever `this` is. + check( + "(function () { + const baseCap = \"base-capture\"; + const Base1 = class { m() { return baseCap; } }; + const Sub1 = class extends Base1 {}; + console.log(new Base1().m(), new Sub1().m()); +})(); +function fCapM(tag: string) { return class Out { static tagv() { return tag; } }; } +class A3 extends fCapM(\"a\") {} +class B3 extends fCapM(\"b\") {} +const mk = (seed: string) => class c { static v = seed; static get() { return c.v; } }; +const P = mk(\"P\"), Q = mk(\"Q\"); +console.log((A3 as any).tagv(), (B3 as any).tagv(), P.get.call(Q)); +", + "base-capture base-capture\na b P\n", + &[("Out", "env-guarded")], + ); +} diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 47cac27de9..bd41bde2e7 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -368,7 +368,7 @@ "file": "crates/perry-runtime/src/gc/census.rs", "name": "PASS1_MARKED", "verdict": "non_moving_snapshot", - "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete \u2192 sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs \u2014 it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase \u2014 after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged \u2014 `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` \u2014 and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` \u2192 `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only \u2014 no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound \u2014 the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses \u2014 no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects \u2014 and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module \u2014 all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes. Re-audited 2026-09-13 after the #10169 fix touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains only `pub(crate) use` re-exports (`policy::note_young_leaf_born_old`, `policy::young_generation_holds_a_nursery`, `promote_in_place::{young_generation_measured_dying, young_generation_measured_retained}`, and cfg(test) survival seeders). `gc/policy.rs` gains a `Cell` thread-local (`GC_YOUNG_LEAF_BORN_OLD`, no pointer), its setter, a pure predicate over `copying_from_space_in_use_bytes` vs the base nursery cap, and a consumed-once branch at the top of `gc_budgeted_due_trigger` that may answer `YoungScavengeCap` ahead of `OldReclaim`. That branch decides WHICH collection a safepoint starts (a minor instead of a full); it runs before any cycle begins and never inside one, so the mark-complete \u2192 sweep-entry window of a synchronous full \u2014 where PASS1_MARKED is populated and consumed within one `run_to_completion` \u2014 is unchanged, and neither hunk adds an allocation, a JS callback, or a relocation to it. Re-audited 2026-09-13 for the heap generation (#10164 cross-call search positions): `gc/mod.rs` only declares `pub(crate) mod heap_generation;`. `gc/cycle.rs` wraps the `Sweep` and `Reclaim` arms of `GcCycleState::step` in a `HeapChange` scope and opens one inside `atomic_finalize_minor_prelude`'s evacuation branch (with a nested one around old-page defrag). Opening and closing a scope only increments two thread-local integer cells (`HEAP_GENERATION`, `OPEN_HEAP_CHANGES`); a first thread-local read may allocate a key through the global allocator, which neither relocates nor runs JS. The `Sweep` scope opens immediately before `step_sweep`, i.e. before `census_take_if_armed_at_full_sweep_start` takes PASS1_MARKED out of TLS, and adds no relocation, collection or JS callback to the synchronous mark-complete to sweep-entry window; the minor-prelude scope is unreachable from a full cycle, which bypasses `MinorPrelude`. Neither boundary nor the intervening control flow changed. Re-audited 2026-09-13 for #10182 block-granular reclamation, which touched `gc/cycle.rs`. Two hunks: (a) in the `RememberedSetRebuild` subphase of AtomicFinalize \u2014 INSIDE the window \u2014 the require-marked old-to-young rebuild is now constructed with `OldToYoungRememberedRebuildState::new_skipping`, whose cursor never enters blocks the census recorded as holding no reached, pinned or pre-marked object (`BlockCensus::unmarked_blocks`); computing that list reads `arena_block_snapshots()` and allocates one `Vec` through the global allocator. It visits a subset of the same objects the rebuild already walked (every skipped object would have been rejected as unmarked), and it neither allocates a GC object, relocates anything, nor runs a JS callback. (b) In `step_sweep`, `IncrementalSweepState::with_block_skip` runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED out of TLS. Neither boundary moved and the synchronous mark-complete to sweep-entry interval gains no relocation, collection or callback. Re-audited 2026-09-11 for the startup memory profile: gc/mod.rs only retains the pre-main allocator-policy constructor in js_gc_init. The constructor applies process allocation options, without invoking GC or JS. No census boundary, collector phase, or mark-complete to sweep-entry control flow changed. Re-audited 2026-09-13 for #10179: census.rs only adds a native regex cache metadata row and its unit assertion; snapshot consumption and the full-cycle window are unchanged. Re-audited 2026-09-14 for the GC due-check fast path, which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` only changes the safepoint re-exports: `gc_runtime_safepoint` becomes cfg(test) and `gc_runtime_safepoint_poll` is added. `gc/policy.rs`: the budgeted step returns a debt-free `GcStepReport` (debt is attached by the FFI and test entry points after the step returns) and moves cycle start/step into an out-of-line `gc_budgeted_start_or_step`; `gc_check_trigger` reuses a repeatable due-trigger answer through `DueTriggerMemo`, placed after its `GC_FLAG_IN_ALLOC` and suppression early returns; the young scavenge cap reuses the old-gen pressure value the due trigger already read and checks the census-seeded flag first. All of it runs from mutator safepoints, allocation-point trigger checks and the host step API, before a cycle starts or between budgeted steps. None of it is reachable between `census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` of a synchronous full: an allocation inside that window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before the changed code. No allocation, relocation, collection or JS callback is added to the window. Re-audited 2026-09-14 for the tiny-parse nursery-cap boundary, which touched `gc/policy.rs`. It adds `tiny_parse_generational_collection_due`, a pure predicate (the existing `tiny_parse_pressure_due` OR the existing `young_scavenge_cap_due` read), and calls it instead of `tiny_parse_pressure_due` from `gc_bump_malloc_trigger_inner` and `gc_collect_pending_suppressed_parse_slow` (generational branch only) and from `gc_schedule_parse_boundary_collection_if_pressure`. All three are JSON.parse mutator-side boundaries, none reachable from `step_mark_propagation` or `step_sweep`; the predicate reads counters and allocates nothing. Neither census boundary nor the synchronous mark-complete to sweep-entry interval changed. Re-audited 2026-09-13 for #10182's full-collection throughput follow-up, which touched `gc/cycle.rs` in one hunk, INSIDE the window: the `RememberedSetRebuild` subphase of a synchronous full now first asks `verify::full_remembered_rebuild_provably_empty` and, when it holds, installs `OldToYoungRememberedRebuildState::provably_empty()` (an empty sticky set, no walk) instead of the require-marked rebuild. The predicate reads `arena_block_snapshots()` (one `Vec` through the global allocator), the census's per-block reached/pre-marked facts and the malloc registry's length; the constructor bumps a `Cell` counter and prints one line under `PERRY_GC_DIAG`. None of it allocates a GC object, relocates anything, collects, or runs a JS callback, and both census boundaries stay where they were. Re-audited 2026-09-14 for #10182's pacing-full work, which touched `gc/cycle.rs`, `gc/mod.rs` and `gc/policy.rs`. `gc/cycle.rs`: `GcCycleState::new_full` no longer calls `materialize_all_promoted_page_runs`; that call ran in the constructor, before the census and far before `census_pass1_if_armed`, and removing it adds nothing to the window. `gc/mod.rs`: one `mod promoted_cohort;` declaration. `gc/policy.rs`: (a) `credit_promoted_bytes_to_old_baseline` also credits a `Cell` cohort counter (it runs after a copying minor completes); (b) `finish_full_old_reclaim_baseline` also records the verified old live bytes and resets that counter (Publish, after `step_sweep` consumed the snapshot); (c) `gc_safepoint_moving_minor` arms and disarms the promotion-census record around its nursery minor and calls `run_promoted_cohort_full_if_due`, which starts a synchronous full through the same `gc_collect_full_mark_sweep_with_trigger` entry and reads byte counters before and after it. All of it runs before a cycle starts or after it completes; none of it runs between mark completion and sweep entry, allocates a GC object, relocates anything, or calls into JS. The census the promoted-cohort full may adopt from the promotion walk is built in `BuildValidPointerSet`, before either boundary. Both boundaries are unchanged. Re-audited 2026-09-14 for the #10182 dead-stack scrub in `gc/cycle.rs`: `step_build_valid_pointer_set` now calls `scrub_dead_stack_below`, which zeroes a local array in its own frame (dead stack below the caller), right after the census finishes \u2014 in `BuildValidPointerSet`, before the root scan and far before `census_pass1_if_armed`. It writes no heap memory, allocates nothing, relocates nothing and calls no JS; both boundaries are unchanged. Re-audited 2026-09-14 for #10241 (cohort survival), which touched `gc/cycle.rs` and `gc/policy.rs`. `gc/cycle.rs`: one call, `promoted_cohort::survival::check_minor_view_at_full_sweep_start()`, in `step_sweep` immediately AFTER `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS, i.e. outside the window. It is a no-op unless a promoted-cohort full armed its survival probe; when armed it walks the old page index over the preceding minor's dirty pages (`old_arena_walk_objects_on_pages`, Rust-allocator Vecs), reads GC headers' mark flags and the slots of unmarked ones, and records one enum. It writes no heap memory, allocates no GC object, relocates nothing and calls no JS. `gc/policy.rs`: `run_promoted_cohort_full_if_due` arms the probe before `gc_collect_full_mark_sweep_with_trigger` and takes it after the full returns (feeding `note_full_measured_promotion_survival` and one diagnostic line); both run before a cycle starts or after it completes. Both boundaries are unchanged. Re-audited 2026-09-14 for #10241's in-place-only cohort: `gc/policy.rs` drops the `promoted_cohort::note_promoted` call from `credit_promoted_bytes_to_old_baseline` (the copying minor now calls `promoted_cohort::note_minor_promotion` itself, after the credit). Both run at the end of a copying minor, outside any full cycle; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-14 for the parse-boundary side-allocation band (medium-parse pacing), which touched `gc/policy.rs`. Three hunks: (a) a `Cell` thread-local (`GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES`, a byte COUNT, no pointer) plus three pure predicates over it and `external_side_live_bytes()`; (b) that predicate added as a third disjunct of `tiny_parse_generational_collection_due`, which is read only from the three JSON.parse mutator-side boundaries (`gc_bump_malloc_trigger_inner`, `gc_collect_pending_suppressed_parse_slow`, `gc_schedule_parse_boundary_collection_if_pressure`), none of them reachable from `step_mark_propagation` or `step_sweep`; and (c) one extra `Cell` store in `note_collection_finished_arena_occupancy` plus two extra reads in the `PERRY_GC_DIAG` tiny-parse line. `note_collection_finished_arena_occupancy` runs from `publish_reclaim_outcome` in the Publish subphase, i.e. AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local, exactly as #9831's store on the same line does. Nothing added allocates a GC object, relocates anything, or runs a JS callback, and neither census boundary moved. Re-audited 2026-09-14 for the drained-bytes counterweight to that band, which touched `gc/policy.rs` again. Four hunks: a second `Cell` thread-local (`GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL`, a byte COUNT); one increment of it inside `gc_note_external_side_free`; a pure read (`external_side_old_reclaim_pressure_bytes`) substituted for `external_side_live_bytes()` at the four old-reclaim pressure sites; and one `Cell` store at the top of `finish_full_old_reclaim_baseline`. None of it can run between the census boundaries. `gc_note_external_side_free` is also reached by mutator-side tape materialization, regex scratch teardown, native-addon adjustments and buffer replacement. Its added operation is only a saturating increment of a scalar Cell, with no GC allocation, relocation, collection or JS callback, so this wider caller set does not invalidate the census window. `finish_full_old_reclaim_baseline` runs from `publish_reclaim_outcome` in the Publish subphase, the same place #9831's store already sits. The pressure reads happen at trigger decisions, before a cycle starts. No allocation, relocation, collection or JS callback is added to the mark-complete -> sweep-entry window, and neither boundary moved. Re-audited 2026-09-15 for turnloop P0, which touched `gc/mod.rs` with one added call: `crate::event_pump::shutdown_wait_driver()` inside `js_gc_release_current_thread_collection_side_allocations`, the process-exit funnel. That function runs once no more JavaScript can run on the thread, never from inside a collection cycle; the added call drops the thread's turnloop wait loop (closing its kqueue/epoll descriptor) and may print a diagnostic line. It allocates no GC object, relocates nothing, starts no collection and runs no JS callback. The census boundaries and the mark-complete -> sweep-entry window are untouched.. Re-audited 2026-09-16 for the copying minor's per-parent weak-holder fact: `gc/mod.rs` gains exactly one line, `mod copying_parent_facts;`, a module declaration. The module it declares holds `weak_holder_fact` (a read of the parent's `obj_type`/`class_id` via `weakref::is_weak_holder_header`) and the copying minor's `visit_slot_with_parent`, moved verbatim out of `gc/copying.rs` for the 2000-line lint. Both run only inside a COPYING MINOR, which skips both census boundaries (`census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` are synchronous-full only). Nothing was added to any full-cycle phase, and the declaration itself executes no code. Neither boundary moved and the synchronous mark-complete to sweep-entry window gains no allocation, relocation, collection or JS callback. Re-audited 2026-09-18 for the #10532 follow-up argument-list rooting fix, which touched `gc/mod.rs`. The only change there is `mod collection_points;` plus a `pub(crate) use collection_points::collection_point;` re-export (and, under `#[cfg(test)]`, `arm_collection_point`). `collection_point` is an inline no-op outside `cfg(test)`; under test it only runs a copying minor when called from ordinary MUTATOR code (`proxy.rs`'s `Reflect.apply` rebind path and `registry.rs`'s rest-array bundler), never from inside `step_mark_propagation` or `step_sweep`. Neither `census_pass1_if_armed` nor `census_take_if_armed_at_full_sweep_start` is reachable from it, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-18 (same PR, round 2) for the added `arm_collection_point_after` re-export in `gc/mod.rs`: another pure re-export line, same as the `collection_point`/`arm_collection_point` one already covered above. `arm_collection_point_after` only changes test-only arming state in `collection_points.rs` (which named site fires and on which hit); it still runs no mark/sweep control flow. Re-audited 2026-09-19 for #10735 (require.main threading): gc/mod.rs gains exactly one line, `reg_scanner!(crate::module_require::scan_cjs_main_module_root_mut);`, registering the new CJS_MAIN_MODULE thread-local's mutable-root scanner beside the existing `scan_module_path_roots_mut` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it runs during root scanning, before mark propagation completes, and does not execute between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start`. Neither census boundary moved and the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-20 for #10834 (inherited-property read cache). `gc/mod.rs` gains exactly one line: `reg_scanner!(crate::object::inherited_read_cache::scan_inherited_read_cache_roots_mut);` in `gc_init()`. A scanner registration adds a root SOURCE for the mutable-root walks. The walk runs inside `RootScanCycleState::step_current_subphase`, i.e. entirely within the RootScan phase: `step_root_scan` only sets `self.phase = GcCyclePhase::MarkPropagation` once that loop reports done (`gc/cycle.rs:958-961`), and `census_pass1_if_armed()` fires at the END of `step_mark_propagation` (`gc/cycle.rs:982`). The scanner therefore runs strictly BEFORE the window opens and can never execute between the boundaries. Its body is a bounded walk of a fixed 512-entry thread-local array calling `visit_tagged_usize_slot` / `visit_usize_slot`; it allocates nothing, relocates nothing and runs no JS callback. Same shape as #9769, #9976/#9977, #10054, #10055 and #10735, all previously cleared. The PR also adds an `INHERITED_READ_CACHE` entry to `DEAD_KEY_PRUNES` in `gc/dead_owner.rs` (not a pinned source). That registry is consumed by `IncrementalSweepState::with_dead_collection_finalize` at `gc/cycle.rs:1548`, which is AFTER `census_take_if_armed_at_full_sweep_start` at `gc/cycle.rs:1505` has already `take()`n the snapshot out of the thread-local -- the same argument that cleared #9845's `collect_dead_registered_regexps_post_trace`. The prune reads addresses and zeroes entries; no GC allocation, relocation or callback. Both additions sit outside the window, on opposite sides of it. Neither boundary moved and the synchronous mark-complete to sweep-entry interval is unchanged. Re-audited 2026-09-22 for #10399 (per-thread module init), which touched `gc/mod.rs`. Two hunks, both init-time: a new free function `raise_default_thread_stack_floor()` and one call to it at the top of `js_gc_init`, before `enter_current_thread_image`'s successor statements. The function reads `RUST_MIN_STACK` from the environment and, only when it is unset, sets it to 32 MiB so a thread spawned against a multi-megabyte static TLS block still has usable stack (glibc carves static TLS out of the thread's stack mapping). It touches no heap object, allocates no GC object, relocates nothing and runs no JS callback. `js_gc_init` is the first runtime call of a compiled `main`, so it runs once before any cycle exists, and it is not reachable from `step_mark_propagation` or `step_sweep`. Same shape as the 2026-09-11 startup-memory-profile re-audit, which cleared the pre-main allocator-policy constructor in the same function. Neither census boundary moved and the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-23 (size/runtime-decouple, #11135) after the binary-size branch touched `gc/census.rs`, `gc/mod.rs` and `gc/policy.rs`. census.rs: `census_pass1_if_armed` / `census_take_if_armed_at_full_sweep_start` keep their bodies verbatim, moved into `_impl` functions compiled only with the new `gc-instruments` feature (without it both are empty and `census_path()` is `None`, so nothing is ever armed); the take still empties PASS1_MARKED before `take_census`. gc/mod.rs: `gc_init` gains a startup env check that aborts when an instrument knob is set without the feature, before any cycle exists. gc/policy.rs: env-knob OnceLock caches now initialize through `crate::once_init::get_or_init` (same closures, same values). No mark/sweep control flow between the two census boundaries changed; the window is unchanged. Re-audited for Fetch handle reclamation: cycle.rs only redirects the incomplete-cycle Drop cancellation hook to also cancel the Fetch trace. The full-trace finish hook removes native records and cached slots without allocating GC objects or invoking JS; it cannot relocate the census addresses before sweep entry. Re-audited 2026-09-22 for #10928 (one proportional old-reclaim rule), which touched `gc/policy.rs`. Six hunks. (a) Two new thread-locals, `GC_OLD_RECLAIM_PRE_IN_USE_BYTES` (`Cell`) and `GC_OLD_RECLAIM_BACKOFF_SHIFT` (`Cell`): both are byte/shift COUNTS, neither holds a pointer. (b) `gc_old_reclaim_growth_band_bytes` gains a `Cell` read and a left shift -- pure arithmetic over byte counts. (c) `old_reclaim_pressure_due` loses the #7937 absolute first-crossing arm, splits its pure form out as `old_reclaim_pressure_due_inner`, and calls `note_old_reclaim_cycle_started()` when the answer is true. That predicate is read at TRIGGER decisions only -- the allocation-point `gc_check_trigger` and `gc_budgeted_due_trigger` at safepoints -- i.e. before a cycle starts, never between the boundaries; an allocation inside the window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before this code, the same argument the 2026-09-14 due-check fast-path re-audit made for the same function. Even if it did run there it would be sound: `note_old_reclaim_cycle_started` stores one scalar `Cell` from `pacing_arena_in_use_bytes()` (a read of `arena_live_allocated_bytes`), which allocates no GC object, relocates nothing and runs no JS callback -- the window's contract. (d) `update_old_reclaim_backoff` is called only from `finish_full_old_reclaim_baseline`, which runs from `publish_reclaim_outcome` in the Publish subphase, AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local -- exactly where #9831's store and the medium-parse pacing store already sit. (e) `gc_old_reclaim_debt_bytes` drops the absolute arm it mirrored; it remains pure arithmetic read at debt/trigger decisions. (f) `#[cfg(test)]` seams, absent from production builds. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback between them; the change alters only WHEN a collection is scheduled, never what runs inside one. Neither boundary moved and the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-24 for #10960 (growth-aware old-reclaim backoff), which touched `gc/policy.rs` again. One new thread-local, `GC_OLD_RECLAIM_LAST_POST_IN_USE_BYTES` (`Cell`), a byte COUNT that holds no pointer. It is written only by `update_old_reclaim_backoff`, which runs from `finish_full_old_reclaim_baseline` in the Publish subphase, after `step_sweep` has already taken the snapshot out of the thread-local; the change there is pure integer arithmetic deciding whether to widen the band. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback, and neither window boundary moved.", + "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete \u2192 sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs \u2014 it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase \u2014 after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged \u2014 `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` \u2014 and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` \u2192 `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only \u2014 no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound \u2014 the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses \u2014 no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects \u2014 and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module \u2014 all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes. Re-audited 2026-09-13 after the #10169 fix touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains only `pub(crate) use` re-exports (`policy::note_young_leaf_born_old`, `policy::young_generation_holds_a_nursery`, `promote_in_place::{young_generation_measured_dying, young_generation_measured_retained}`, and cfg(test) survival seeders). `gc/policy.rs` gains a `Cell` thread-local (`GC_YOUNG_LEAF_BORN_OLD`, no pointer), its setter, a pure predicate over `copying_from_space_in_use_bytes` vs the base nursery cap, and a consumed-once branch at the top of `gc_budgeted_due_trigger` that may answer `YoungScavengeCap` ahead of `OldReclaim`. That branch decides WHICH collection a safepoint starts (a minor instead of a full); it runs before any cycle begins and never inside one, so the mark-complete \u2192 sweep-entry window of a synchronous full \u2014 where PASS1_MARKED is populated and consumed within one `run_to_completion` \u2014 is unchanged, and neither hunk adds an allocation, a JS callback, or a relocation to it. Re-audited 2026-09-13 for the heap generation (#10164 cross-call search positions): `gc/mod.rs` only declares `pub(crate) mod heap_generation;`. `gc/cycle.rs` wraps the `Sweep` and `Reclaim` arms of `GcCycleState::step` in a `HeapChange` scope and opens one inside `atomic_finalize_minor_prelude`'s evacuation branch (with a nested one around old-page defrag). Opening and closing a scope only increments two thread-local integer cells (`HEAP_GENERATION`, `OPEN_HEAP_CHANGES`); a first thread-local read may allocate a key through the global allocator, which neither relocates nor runs JS. The `Sweep` scope opens immediately before `step_sweep`, i.e. before `census_take_if_armed_at_full_sweep_start` takes PASS1_MARKED out of TLS, and adds no relocation, collection or JS callback to the synchronous mark-complete to sweep-entry window; the minor-prelude scope is unreachable from a full cycle, which bypasses `MinorPrelude`. Neither boundary nor the intervening control flow changed. Re-audited 2026-09-13 for #10182 block-granular reclamation, which touched `gc/cycle.rs`. Two hunks: (a) in the `RememberedSetRebuild` subphase of AtomicFinalize \u2014 INSIDE the window \u2014 the require-marked old-to-young rebuild is now constructed with `OldToYoungRememberedRebuildState::new_skipping`, whose cursor never enters blocks the census recorded as holding no reached, pinned or pre-marked object (`BlockCensus::unmarked_blocks`); computing that list reads `arena_block_snapshots()` and allocates one `Vec` through the global allocator. It visits a subset of the same objects the rebuild already walked (every skipped object would have been rejected as unmarked), and it neither allocates a GC object, relocates anything, nor runs a JS callback. (b) In `step_sweep`, `IncrementalSweepState::with_block_skip` runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED out of TLS. Neither boundary moved and the synchronous mark-complete to sweep-entry interval gains no relocation, collection or callback. Re-audited 2026-09-11 for the startup memory profile: gc/mod.rs only retains the pre-main allocator-policy constructor in js_gc_init. The constructor applies process allocation options, without invoking GC or JS. No census boundary, collector phase, or mark-complete to sweep-entry control flow changed. Re-audited 2026-09-13 for #10179: census.rs only adds a native regex cache metadata row and its unit assertion; snapshot consumption and the full-cycle window are unchanged. Re-audited 2026-09-14 for the GC due-check fast path, which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` only changes the safepoint re-exports: `gc_runtime_safepoint` becomes cfg(test) and `gc_runtime_safepoint_poll` is added. `gc/policy.rs`: the budgeted step returns a debt-free `GcStepReport` (debt is attached by the FFI and test entry points after the step returns) and moves cycle start/step into an out-of-line `gc_budgeted_start_or_step`; `gc_check_trigger` reuses a repeatable due-trigger answer through `DueTriggerMemo`, placed after its `GC_FLAG_IN_ALLOC` and suppression early returns; the young scavenge cap reuses the old-gen pressure value the due trigger already read and checks the census-seeded flag first. All of it runs from mutator safepoints, allocation-point trigger checks and the host step API, before a cycle starts or between budgeted steps. None of it is reachable between `census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` of a synchronous full: an allocation inside that window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before the changed code. No allocation, relocation, collection or JS callback is added to the window. Re-audited 2026-09-14 for the tiny-parse nursery-cap boundary, which touched `gc/policy.rs`. It adds `tiny_parse_generational_collection_due`, a pure predicate (the existing `tiny_parse_pressure_due` OR the existing `young_scavenge_cap_due` read), and calls it instead of `tiny_parse_pressure_due` from `gc_bump_malloc_trigger_inner` and `gc_collect_pending_suppressed_parse_slow` (generational branch only) and from `gc_schedule_parse_boundary_collection_if_pressure`. All three are JSON.parse mutator-side boundaries, none reachable from `step_mark_propagation` or `step_sweep`; the predicate reads counters and allocates nothing. Neither census boundary nor the synchronous mark-complete to sweep-entry interval changed. Re-audited 2026-09-13 for #10182's full-collection throughput follow-up, which touched `gc/cycle.rs` in one hunk, INSIDE the window: the `RememberedSetRebuild` subphase of a synchronous full now first asks `verify::full_remembered_rebuild_provably_empty` and, when it holds, installs `OldToYoungRememberedRebuildState::provably_empty()` (an empty sticky set, no walk) instead of the require-marked rebuild. The predicate reads `arena_block_snapshots()` (one `Vec` through the global allocator), the census's per-block reached/pre-marked facts and the malloc registry's length; the constructor bumps a `Cell` counter and prints one line under `PERRY_GC_DIAG`. None of it allocates a GC object, relocates anything, collects, or runs a JS callback, and both census boundaries stay where they were. Re-audited 2026-09-14 for #10182's pacing-full work, which touched `gc/cycle.rs`, `gc/mod.rs` and `gc/policy.rs`. `gc/cycle.rs`: `GcCycleState::new_full` no longer calls `materialize_all_promoted_page_runs`; that call ran in the constructor, before the census and far before `census_pass1_if_armed`, and removing it adds nothing to the window. `gc/mod.rs`: one `mod promoted_cohort;` declaration. `gc/policy.rs`: (a) `credit_promoted_bytes_to_old_baseline` also credits a `Cell` cohort counter (it runs after a copying minor completes); (b) `finish_full_old_reclaim_baseline` also records the verified old live bytes and resets that counter (Publish, after `step_sweep` consumed the snapshot); (c) `gc_safepoint_moving_minor` arms and disarms the promotion-census record around its nursery minor and calls `run_promoted_cohort_full_if_due`, which starts a synchronous full through the same `gc_collect_full_mark_sweep_with_trigger` entry and reads byte counters before and after it. All of it runs before a cycle starts or after it completes; none of it runs between mark completion and sweep entry, allocates a GC object, relocates anything, or calls into JS. The census the promoted-cohort full may adopt from the promotion walk is built in `BuildValidPointerSet`, before either boundary. Both boundaries are unchanged. Re-audited 2026-09-14 for the #10182 dead-stack scrub in `gc/cycle.rs`: `step_build_valid_pointer_set` now calls `scrub_dead_stack_below`, which zeroes a local array in its own frame (dead stack below the caller), right after the census finishes \u2014 in `BuildValidPointerSet`, before the root scan and far before `census_pass1_if_armed`. It writes no heap memory, allocates nothing, relocates nothing and calls no JS; both boundaries are unchanged. Re-audited 2026-09-14 for #10241 (cohort survival), which touched `gc/cycle.rs` and `gc/policy.rs`. `gc/cycle.rs`: one call, `promoted_cohort::survival::check_minor_view_at_full_sweep_start()`, in `step_sweep` immediately AFTER `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS, i.e. outside the window. It is a no-op unless a promoted-cohort full armed its survival probe; when armed it walks the old page index over the preceding minor's dirty pages (`old_arena_walk_objects_on_pages`, Rust-allocator Vecs), reads GC headers' mark flags and the slots of unmarked ones, and records one enum. It writes no heap memory, allocates no GC object, relocates nothing and calls no JS. `gc/policy.rs`: `run_promoted_cohort_full_if_due` arms the probe before `gc_collect_full_mark_sweep_with_trigger` and takes it after the full returns (feeding `note_full_measured_promotion_survival` and one diagnostic line); both run before a cycle starts or after it completes. Both boundaries are unchanged. Re-audited 2026-09-14 for #10241's in-place-only cohort: `gc/policy.rs` drops the `promoted_cohort::note_promoted` call from `credit_promoted_bytes_to_old_baseline` (the copying minor now calls `promoted_cohort::note_minor_promotion` itself, after the credit). Both run at the end of a copying minor, outside any full cycle; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-14 for the parse-boundary side-allocation band (medium-parse pacing), which touched `gc/policy.rs`. Three hunks: (a) a `Cell` thread-local (`GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES`, a byte COUNT, no pointer) plus three pure predicates over it and `external_side_live_bytes()`; (b) that predicate added as a third disjunct of `tiny_parse_generational_collection_due`, which is read only from the three JSON.parse mutator-side boundaries (`gc_bump_malloc_trigger_inner`, `gc_collect_pending_suppressed_parse_slow`, `gc_schedule_parse_boundary_collection_if_pressure`), none of them reachable from `step_mark_propagation` or `step_sweep`; and (c) one extra `Cell` store in `note_collection_finished_arena_occupancy` plus two extra reads in the `PERRY_GC_DIAG` tiny-parse line. `note_collection_finished_arena_occupancy` runs from `publish_reclaim_outcome` in the Publish subphase, i.e. AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local, exactly as #9831's store on the same line does. Nothing added allocates a GC object, relocates anything, or runs a JS callback, and neither census boundary moved. Re-audited 2026-09-14 for the drained-bytes counterweight to that band, which touched `gc/policy.rs` again. Four hunks: a second `Cell` thread-local (`GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL`, a byte COUNT); one increment of it inside `gc_note_external_side_free`; a pure read (`external_side_old_reclaim_pressure_bytes`) substituted for `external_side_live_bytes()` at the four old-reclaim pressure sites; and one `Cell` store at the top of `finish_full_old_reclaim_baseline`. None of it can run between the census boundaries. `gc_note_external_side_free` is also reached by mutator-side tape materialization, regex scratch teardown, native-addon adjustments and buffer replacement. Its added operation is only a saturating increment of a scalar Cell, with no GC allocation, relocation, collection or JS callback, so this wider caller set does not invalidate the census window. `finish_full_old_reclaim_baseline` runs from `publish_reclaim_outcome` in the Publish subphase, the same place #9831's store already sits. The pressure reads happen at trigger decisions, before a cycle starts. No allocation, relocation, collection or JS callback is added to the mark-complete -> sweep-entry window, and neither boundary moved. Re-audited 2026-09-15 for turnloop P0, which touched `gc/mod.rs` with one added call: `crate::event_pump::shutdown_wait_driver()` inside `js_gc_release_current_thread_collection_side_allocations`, the process-exit funnel. That function runs once no more JavaScript can run on the thread, never from inside a collection cycle; the added call drops the thread's turnloop wait loop (closing its kqueue/epoll descriptor) and may print a diagnostic line. It allocates no GC object, relocates nothing, starts no collection and runs no JS callback. The census boundaries and the mark-complete -> sweep-entry window are untouched.. Re-audited 2026-09-16 for the copying minor's per-parent weak-holder fact: `gc/mod.rs` gains exactly one line, `mod copying_parent_facts;`, a module declaration. The module it declares holds `weak_holder_fact` (a read of the parent's `obj_type`/`class_id` via `weakref::is_weak_holder_header`) and the copying minor's `visit_slot_with_parent`, moved verbatim out of `gc/copying.rs` for the 2000-line lint. Both run only inside a COPYING MINOR, which skips both census boundaries (`census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` are synchronous-full only). Nothing was added to any full-cycle phase, and the declaration itself executes no code. Neither boundary moved and the synchronous mark-complete to sweep-entry window gains no allocation, relocation, collection or JS callback. Re-audited 2026-09-18 for the #10532 follow-up argument-list rooting fix, which touched `gc/mod.rs`. The only change there is `mod collection_points;` plus a `pub(crate) use collection_points::collection_point;` re-export (and, under `#[cfg(test)]`, `arm_collection_point`). `collection_point` is an inline no-op outside `cfg(test)`; under test it only runs a copying minor when called from ordinary MUTATOR code (`proxy.rs`'s `Reflect.apply` rebind path and `registry.rs`'s rest-array bundler), never from inside `step_mark_propagation` or `step_sweep`. Neither `census_pass1_if_armed` nor `census_take_if_armed_at_full_sweep_start` is reachable from it, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-18 (same PR, round 2) for the added `arm_collection_point_after` re-export in `gc/mod.rs`: another pure re-export line, same as the `collection_point`/`arm_collection_point` one already covered above. `arm_collection_point_after` only changes test-only arming state in `collection_points.rs` (which named site fires and on which hit); it still runs no mark/sweep control flow. Re-audited 2026-09-19 for #10735 (require.main threading): gc/mod.rs gains exactly one line, `reg_scanner!(crate::module_require::scan_cjs_main_module_root_mut);`, registering the new CJS_MAIN_MODULE thread-local's mutable-root scanner beside the existing `scan_module_path_roots_mut` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it runs during root scanning, before mark propagation completes, and does not execute between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start`. Neither census boundary moved and the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-20 for #10834 (inherited-property read cache). `gc/mod.rs` gains exactly one line: `reg_scanner!(crate::object::inherited_read_cache::scan_inherited_read_cache_roots_mut);` in `gc_init()`. A scanner registration adds a root SOURCE for the mutable-root walks. The walk runs inside `RootScanCycleState::step_current_subphase`, i.e. entirely within the RootScan phase: `step_root_scan` only sets `self.phase = GcCyclePhase::MarkPropagation` once that loop reports done (`gc/cycle.rs:958-961`), and `census_pass1_if_armed()` fires at the END of `step_mark_propagation` (`gc/cycle.rs:982`). The scanner therefore runs strictly BEFORE the window opens and can never execute between the boundaries. Its body is a bounded walk of a fixed 512-entry thread-local array calling `visit_tagged_usize_slot` / `visit_usize_slot`; it allocates nothing, relocates nothing and runs no JS callback. Same shape as #9769, #9976/#9977, #10054, #10055 and #10735, all previously cleared. The PR also adds an `INHERITED_READ_CACHE` entry to `DEAD_KEY_PRUNES` in `gc/dead_owner.rs` (not a pinned source). That registry is consumed by `IncrementalSweepState::with_dead_collection_finalize` at `gc/cycle.rs:1548`, which is AFTER `census_take_if_armed_at_full_sweep_start` at `gc/cycle.rs:1505` has already `take()`n the snapshot out of the thread-local -- the same argument that cleared #9845's `collect_dead_registered_regexps_post_trace`. The prune reads addresses and zeroes entries; no GC allocation, relocation or callback. Both additions sit outside the window, on opposite sides of it. Neither boundary moved and the synchronous mark-complete to sweep-entry interval is unchanged. Re-audited 2026-09-22 for #10399 (per-thread module init), which touched `gc/mod.rs`. Two hunks, both init-time: a new free function `raise_default_thread_stack_floor()` and one call to it at the top of `js_gc_init`, before `enter_current_thread_image`'s successor statements. The function reads `RUST_MIN_STACK` from the environment and, only when it is unset, sets it to 32 MiB so a thread spawned against a multi-megabyte static TLS block still has usable stack (glibc carves static TLS out of the thread's stack mapping). It touches no heap object, allocates no GC object, relocates nothing and runs no JS callback. `js_gc_init` is the first runtime call of a compiled `main`, so it runs once before any cycle exists, and it is not reachable from `step_mark_propagation` or `step_sweep`. Same shape as the 2026-09-11 startup-memory-profile re-audit, which cleared the pre-main allocator-policy constructor in the same function. Neither census boundary moved and the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-23 (size/runtime-decouple, #11135) after the binary-size branch touched `gc/census.rs`, `gc/mod.rs` and `gc/policy.rs`. census.rs: `census_pass1_if_armed` / `census_take_if_armed_at_full_sweep_start` keep their bodies verbatim, moved into `_impl` functions compiled only with the new `gc-instruments` feature (without it both are empty and `census_path()` is `None`, so nothing is ever armed); the take still empties PASS1_MARKED before `take_census`. gc/mod.rs: `gc_init` gains a startup env check that aborts when an instrument knob is set without the feature, before any cycle exists. gc/policy.rs: env-knob OnceLock caches now initialize through `crate::once_init::get_or_init` (same closures, same values). No mark/sweep control flow between the two census boundaries changed; the window is unchanged. Re-audited for Fetch handle reclamation: cycle.rs only redirects the incomplete-cycle Drop cancellation hook to also cancel the Fetch trace. The full-trace finish hook removes native records and cached slots without allocating GC objects or invoking JS; it cannot relocate the census addresses before sweep entry. Re-audited 2026-09-22 for #10928 (one proportional old-reclaim rule), which touched `gc/policy.rs`. Six hunks. (a) Two new thread-locals, `GC_OLD_RECLAIM_PRE_IN_USE_BYTES` (`Cell`) and `GC_OLD_RECLAIM_BACKOFF_SHIFT` (`Cell`): both are byte/shift COUNTS, neither holds a pointer. (b) `gc_old_reclaim_growth_band_bytes` gains a `Cell` read and a left shift -- pure arithmetic over byte counts. (c) `old_reclaim_pressure_due` loses the #7937 absolute first-crossing arm, splits its pure form out as `old_reclaim_pressure_due_inner`, and calls `note_old_reclaim_cycle_started()` when the answer is true. That predicate is read at TRIGGER decisions only -- the allocation-point `gc_check_trigger` and `gc_budgeted_due_trigger` at safepoints -- i.e. before a cycle starts, never between the boundaries; an allocation inside the window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before this code, the same argument the 2026-09-14 due-check fast-path re-audit made for the same function. Even if it did run there it would be sound: `note_old_reclaim_cycle_started` stores one scalar `Cell` from `pacing_arena_in_use_bytes()` (a read of `arena_live_allocated_bytes`), which allocates no GC object, relocates nothing and runs no JS callback -- the window's contract. (d) `update_old_reclaim_backoff` is called only from `finish_full_old_reclaim_baseline`, which runs from `publish_reclaim_outcome` in the Publish subphase, AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local -- exactly where #9831's store and the medium-parse pacing store already sit. (e) `gc_old_reclaim_debt_bytes` drops the absolute arm it mirrored; it remains pure arithmetic read at debt/trigger decisions. (f) `#[cfg(test)]` seams, absent from production builds. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback between them; the change alters only WHEN a collection is scheduled, never what runs inside one. Neither boundary moved and the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-24 for #10960 (growth-aware old-reclaim backoff), which touched `gc/policy.rs` again. One new thread-local, `GC_OLD_RECLAIM_LAST_POST_IN_USE_BYTES` (`Cell`), a byte COUNT that holds no pointer. It is written only by `update_old_reclaim_backoff`, which runs from `finish_full_old_reclaim_baseline` in the Publish subphase, after `step_sweep` has already taken the snapshot out of the thread-local; the change there is pure integer arithmetic deciding whether to widen the band. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback, and neither window boundary moved. Re-audited 2026-09-24 after the class-capture environment added one `reg_scanner!` registration (`scan_class_env_roots_mut`, visiting each guarded class environment's owner class object) to `gc/mod.rs`: a root-scanner registration alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window.", "window": { "start": { "file": "crates/perry-runtime/src/gc/census.rs", @@ -385,7 +385,7 @@ "sources": { "crates/perry-runtime/src/gc/census.rs": "25601f25ac70aa998f8cb5c1939d11e7c43a96235d4edf39a78e261b68709471", "crates/perry-runtime/src/gc/cycle.rs": "4744196ba5e9c5ac40912154cf5b45b4a618d81ddc776ab1095fbc585f27c878", - "crates/perry-runtime/src/gc/mod.rs": "19018a885b66c842529aaab5d5bf820b9d871b57b57fb352d78f55ced712b23d", + "crates/perry-runtime/src/gc/mod.rs": "89d03c918d3f3cc3a412f72bd2f51dcf598a37dee5d189228a797a4db9fa8758", "crates/perry-runtime/src/gc/policy.rs": "b02e6867b2c883736e89b1290aa381ad5c173f0405fc1ee6a267dd439e1ab5b0", "crates/perry-runtime/src/gc/progress.rs": "a5ad3971bbe4047229ca57325234780daa85921dbc778e1c08dff4ad07ccfb96" } diff --git a/test-files/test_gap_11297_env_class_for_let_capture.ts b/test-files/test_gap_11297_env_class_for_let_capture.ts new file mode 100644 index 0000000000..7893a9a228 --- /dev/null +++ b/test-files/test_gap_11297_env_class_for_let_capture.ts @@ -0,0 +1,123 @@ +// #11297 x #11250: a class EXPRESSION in a function body keeps its captures +// in the class environment (guarded: the first evaluation owns the +// environment slots, later ones read their own capture array). When such a +// class closes over a `for (let …)` head binding, a refresh that runs outside +// the loop body (a write to another capture, the loop head, a `return`) must +// keep each evaluation's own `i` — in the environment slots as well as in the +// class object's capture array. Every function runs twice: the first call's +// first class is the environment owner, the second call's classes are all +// non-owner evaluations. + +function writeAfterLoop(): string { + const classes: Array { get(): string }> = []; + let x = 0; + for (let i = 0; i < 3; i++) { + classes.push( + class { + get(): string { + return i + ":" + x; + } + }, + ); + } + x = 5; + return classes.map((C) => new C().get()).join(","); +} +console.log("write after loop:", writeAfterLoop(), writeAfterLoop()); + +function headWritesOther(): string { + const classes: Array<{ s(): string }> = []; + let x = 0; + for (let i = 0; i < 3; i++, x++) { + classes.push( + class { + static s(): string { + return i + ":" + x; + } + }, + ); + } + return classes.map((C) => C.s()).join(","); +} +console.log("head writes other, static:", headWritesOther(), headWritesOther()); + +// A single iteration: the last class IS the environment owner, so the +// post-loop refresh republishes into the slots its members read directly. +function ownerMemberWrite(): string { + let x = 0; + let K: any; + for (let i = 0; i < 1; i++) { + K = class { + bump(): void { + i += 10; + } + get(): string { + return i + ":" + x; + } + }; + } + new K().bump(); + x = 5; + return new K().get(); +} +console.log("owner, member write:", ownerMemberWrite(), ownerMemberWrite()); + +function memberWrites(): string { + let x = 0; + const ks: any[] = []; + for (let i = 0; i < 3; i++) { + ks.push( + class { + bump(): void { + i += 10; + } + get(): string { + return i + ":" + x; + } + }, + ); + } + for (const K of ks) new K().bump(); + x = 7; + return ks.map((K) => new K().get()).join(","); +} +console.log("member writes:", memberWrites(), memberWrites()); + +// No head update: only the member writes `i`, then ends the loop. +function noUpdate(): string { + let x = 0; + let K: any; + for (let i = 0; i < 1; ) { + K = class { + bump(): void { + i += 10; + } + get(): string { + return i + ":" + x; + } + }; + new K().bump(); + } + x = 5; + return new K().get(); +} +console.log("no update:", noUpdate(), noUpdate()); + +// A `const` declared after the loop is published by the post-loop refresh, +// which must keep the last class's own `i`. (Only the most recent class +// object is refreshed, so earlier iterations are not asserted.) +function forwardCapture(): string { + const classes: Array { get(): string }> = []; + for (let i = 0; i < 3; i++) { + classes.push( + class { + get(): string { + return i + ":" + later; + } + }, + ); + } + const later = "L"; + return new classes[2]().get(); +} +console.log("forward capture, last class:", forwardCapture(), forwardCapture());