diff --git a/changelog.d/11609-class-name-length-own-data.md b/changelog.d/11609-class-name-length-own-data.md new file mode 100644 index 0000000000..8770fd2f52 --- /dev/null +++ b/changelog.d/11609-class-name-length-own-data.md @@ -0,0 +1,9 @@ +### Fixed + +A class constructor's `name` and `length` are now own data properties of its +function object, as in Node: `delete C.name` makes `C.name` read the value +inherited from its prototype (`""` from `Function.prototype`, or the parent +class's name for a subclass) instead of `undefined`, `delete C.length` reads +`0`, and `Object.getOwnPropertyNames(C)` no longer lists a deleted `name` or +`length`. Reading `C.name` or `obj.constructor.name` no longer builds a new +string on every read (about 5x fewer instructions for `C.name`). diff --git a/changelog.d/11609-class-values-are-function-objects.md b/changelog.d/11609-class-values-are-function-objects.md new file mode 100644 index 0000000000..5e7c440552 --- /dev/null +++ b/changelog.d/11609-class-values-are-function-objects.md @@ -0,0 +1,9 @@ +### Fixed + +A class used as a value is now a real function object. It used to be encoded +as the int32 number equal to its internal class id, so `1 === SomeClass` could +be true, `switch (1) { case SomeClass: }` matched, `[SomeClass, 1].indexOf(1)` +found the class, `JSON.stringify({ c: SomeClass })` printed the id and +`SomeClass instanceof Object` was `false`. Each class now has one function +object per agent, which compares, hashes, prints and reflects as it does in +Node (`[class A extends B] { statics }` in `util.inspect`). diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index 488f120ae7..cd00a6477e 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -783,9 +783,7 @@ pub(super) fn compile_closure( // closure's synthetic this slot with the enclosing ClassRef rather // than the old 0.0 sentinel so arrows in static fields retain the // class constructor as their SuperProperty receiver. - let class_ref = crate::nanbox::double_literal(f64::from_bits( - crate::nanbox::INT32_TAG | class_id as u64, - )); + let class_ref = crate::expr::emit_class_value(blk, class_id); blk.store(DOUBLE, &class_ref, &slot); } else if entry_bound_this { // A valid non-pointer until the prologue's receiver read below diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index 7ac542ae00..d25c208976 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -1549,10 +1549,8 @@ pub(super) fn emit_namespace_populator( crate::expr::nanbox_pointer_inline(blk, &handle) } NamespaceEntryKind::LocalClass { class_id } => { - // INT32-tagged class-id NaN-box: 0x7FFE_0000_0000_0000 | - // (class_id & 0xFFFFFFFF). Matches `Expr::ClassRef`. - let bits = crate::nanbox::INT32_TAG | (*class_id as u64 & 0xFFFF_FFFF); - crate::nanbox::double_literal(f64::from_bits(bits)) + // The class's function object, as `Expr::ClassRef` lowers. + crate::expr::emit_class_value_cached(ctx, *class_id) } NamespaceEntryKind::ForeignFunction { source_prefix, diff --git a/crates/perry-codegen/src/codegen/method_static.rs b/crates/perry-codegen/src/codegen/method_static.rs index b271f7beb2..a21b1ee9bb 100644 --- a/crates/perry-codegen/src/codegen/method_static.rs +++ b/crates/perry-codegen/src/codegen/method_static.rs @@ -41,12 +41,24 @@ pub(in crate::codegen) fn compile_static_method( let ic_base = llmod.ic_counter; let buffer_alias_base = llmod.buffer_alias_counter; + // The prologue's cache of this class's function object (see + // `js_static_this_resolve_class`); thread-local when workers exist, so + // each agent caches its own. + let class_value_slot = format!("@{llvm_name}__classval"); + llmod.add_raw_global(format!( + "{class_value_slot} = private {}global double 0.0, align 8", + if crate::codegen::program_has_worker() { + "thread_local " + } else { + "" + } + )); let lf = llmod.define_function(&llvm_name, DOUBLE, params); // gh #6206 / #6081: same shadow-frame emission as compile_method — static // method bodies were equally invisible to the exact-roots copying minor. // One extra slot roots the resolved receiver: static `this` is usually - // the non-pointer INT32 class-ref, but `js_static_this_resolve` returns a + // the class's pinned function object, but `js_static_this_resolve_class` returns a // REAL heap receiver for `C.m.call(x)` / `.apply(x)` / inherited `D.m()` // dynamic dispatch, and that object may be reachable only from this slot. // #10663: decided before any statement is lowered. @@ -88,10 +100,6 @@ pub(in crate::codegen) fn compile_static_method( // path. (Previously `this` fell through to `js_implicit_this_get` and // read back `undefined`.) let class_ref_cid = class_ids.get(&class.name).copied().unwrap_or(class.id); - let class_ref_lit = { - let bits = crate::nanbox::INT32_TAG | (class_ref_cid as u64 & 0xFFFF_FFFF); - crate::nanbox::double_literal(f64::from_bits(bits)) - }; let (this_slot, locals): (String, HashMap) = { let blk = lf.block_mut(0).unwrap(); let this_slot = blk.alloca(DOUBLE); @@ -103,8 +111,11 @@ pub(in crate::codegen) fn compile_static_method( // real receiver (test262 class/elements static-private-*). let resolved_this = blk.call( DOUBLE, - "js_static_this_resolve", - &[(DOUBLE, &class_ref_lit)], + "js_static_this_resolve_class", + &[ + (I32, &(class_ref_cid as i32).to_string()), + (PTR, &class_value_slot), + ], ); blk.store(DOUBLE, &resolved_this, &this_slot); if crate::codegen::helpers::precise_root_analysis_enabled() { diff --git a/crates/perry-codegen/src/codegen/static_fields.rs b/crates/perry-codegen/src/codegen/static_fields.rs index 9a79c28d05..ec187172d2 100644 --- a/crates/perry-codegen/src/codegen/static_fields.rs +++ b/crates/perry-codegen/src/codegen/static_fields.rs @@ -338,8 +338,7 @@ pub(super) fn init_static_fields_late( // class-ref NaN-box a static method binds (see // `compile_static_method`) for the init's duration. let seeded_this = ctx.class_ids.get(&c.name).copied().map(|cid| { - let bits = crate::nanbox::INT32_TAG | (cid as u64 & 0xFFFF_FFFF); - let class_ref_lit = crate::nanbox::double_literal(f64::from_bits(bits)); + let class_ref_lit = crate::expr::emit_class_value_cached(ctx, cid); let this_slot = ctx.func.alloca_entry(DOUBLE); ctx.block().store(DOUBLE, &class_ref_lit, &this_slot); ctx.this_stack.push(this_slot); diff --git a/crates/perry-codegen/src/codegen/string_pool.rs b/crates/perry-codegen/src/codegen/string_pool.rs index 5438875ef2..203c1972f7 100644 --- a/crates/perry-codegen/src/codegen/string_pool.rs +++ b/crates/perry-codegen/src/codegen/string_pool.rs @@ -968,6 +968,12 @@ pub(super) fn emit_string_pool( // #1788: static methods are emitted as `perry_static_*` (no `this` // param). Collect them for the runtime CLASS_STATIC_METHODS table. for sm in &class.static_methods { + // A `static { }` block is lowered to a synthetic static method the + // class's initializer calls directly. It is not a member: never + // registered, so no reflection (`Reflect.ownKeys(C)`) can see it. + if sm.name.starts_with("__perry_static_init_") { + continue; + } let llvm_name = scoped_static_method_name(module_prefix, cid, class_name, &sm.name); let has_rest = sm.params.last().map(|p| p.is_rest).unwrap_or(false); // Spec `.length`: leading formal params before the first default/rest diff --git a/crates/perry-codegen/src/expr/arrays_finds.rs b/crates/perry-codegen/src/expr/arrays_finds.rs index 6993c12ed7..af5b5492c3 100644 --- a/crates/perry-codegen/src/expr/arrays_finds.rs +++ b/crates/perry-codegen/src/expr/arrays_finds.rs @@ -1449,8 +1449,7 @@ pub(crate) fn lower( // class_ids (legacy callers checking truthiness). Refs #420. Expr::ClassRef(name) => { if let Some(&cid) = ctx.class_ids.get(name) { - let bits = crate::nanbox::INT32_TAG | (cid as u64 & 0xFFFF_FFFF); - Ok(double_literal(f64::from_bits(bits))) + Ok(super::emit_class_value_cached(ctx, cid)) } else { Ok(double_literal(0.0)) } diff --git a/crates/perry-codegen/src/expr/compare.rs b/crates/perry-codegen/src/expr/compare.rs index 5f2bfc6ca6..40b9eef35f 100644 --- a/crates/perry-codegen/src/expr/compare.rs +++ b/crates/perry-codegen/src/expr/compare.rs @@ -71,6 +71,12 @@ fn typeof_literal_pair<'a>( /// storage (reclaimable but non-moving), while `Symbol.for()` values are /// process-lifetime `Box` allocations. Therefore a proven Symbol can equal /// another JS value iff their NaN-boxed pointer bits are identical. +/// A declared class named as a value (`Expr::ClassRef`): its pinned function +/// object (`js_class_value`). +fn is_class_value_expr(ctx: &FnCtx<'_>, expr: &Expr) -> bool { + matches!(expr, Expr::ClassRef(name) if ctx.class_ids.contains_key(name)) +} + pub(crate) fn is_proven_symbol_expr(ctx: &FnCtx<'_>, expr: &Expr) -> bool { match expr { Expr::SymbolNew(_) | Expr::SymbolFor(_) => true, @@ -1252,7 +1258,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // STRICT only: loose equality still has coercion/throw rules. let either_proven_symbol = is_proven_symbol_expr(ctx, left) || is_proven_symbol_expr(ctx, right); - if either_proven_symbol && matches!(op, CompareOp::Eq | CompareOp::Ne) { + // A class value is its class's pinned function object — one + // per class, never moved or forwarded — so strict identity + // against one is bit identity too. + let either_class_value = + is_class_value_expr(ctx, left) || is_class_value_expr(ctx, right); + if (either_proven_symbol || either_class_value) + && matches!(op, CompareOp::Eq | CompareOp::Ne) + { let blk = ctx.block(); let l_bits = blk.bitcast_double_to_i64(&l); let r_bits = blk.bitcast_double_to_i64(&r); diff --git a/crates/perry-codegen/src/expr/dyn_extern_i18n.rs b/crates/perry-codegen/src/expr/dyn_extern_i18n.rs index 5d25349d74..6bee5f3c97 100644 --- a/crates/perry-codegen/src/expr/dyn_extern_i18n.rs +++ b/crates/perry-codegen/src/expr/dyn_extern_i18n.rs @@ -888,8 +888,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { if let Some(&cid) = ctx.class_ids.get(name).filter(|_| { !ctx.imported_vars.contains(name) && !ctx.namespace_imports.contains(name) }) { - let bits = crate::nanbox::INT32_TAG | (cid as u64 & 0xFFFF_FFFF); - return Ok(double_literal(f64::from_bits(bits))); + return Ok(super::emit_class_value_cached(ctx, cid)); } // Issue #841: named imports from Node submodules Perry recognizes // as runtime-backed values must win over the generic native-module diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 5bed81857c..5017cad7c6 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -13,6 +13,51 @@ use perry_hir::types::Type as HirType; use perry_hir::{BinaryOp, CompareOp, Expr, UnaryOp}; use crate::block::LlBlock; + +/// A class constructor as a VALUE (#11414): the class's per-agent function +/// object. `js_class_value` never collects (`gc_call_effects`) and the object +/// is pinned for the agent's life, so the result needs no root. +pub(crate) fn emit_class_value(blk: &mut LlBlock, class_id: u32) -> String { + blk.call( + DOUBLE, + "js_class_value", + &[(I32, &(class_id as i32).to_string())], + ) +} + +/// [`emit_class_value`] behind a per-site cache: a zero-initialised global +/// (thread-local when the program starts workers, so each agent caches its +/// own class object) holds the NaN-boxed value after the first use. The object +/// is pinned for the agent's life, so the cached bits never go stale and the +/// slot needs no root. +pub(crate) fn emit_class_value_cached(ctx: &mut FnCtx<'_>, class_id: u32) -> String { + let site = ctx.ic_site_counter; + ctx.ic_site_counter += 1; + let slot = format!("@{}_classval", inline_cache_global_name(ctx, site)); + let tls = if crate::codegen::program_has_worker() { + "thread_local " + } else { + "" + }; + ctx.typed_parse_rodata + .push(format!("{slot} = private {tls}global double 0.0, align 8")); + let cached = ctx.block().load(DOUBLE, &slot); + let bits = ctx.block().bitcast_double_to_i64(&cached); + let empty = ctx.block().icmp_eq(I64, &bits, "0"); + let from_l = ctx.block_label(ctx.current_block); + let miss_idx = ctx.new_block("classval.miss"); + let join_idx = ctx.new_block("classval.join"); + let miss_l = ctx.block_label(miss_idx); + let join_l = ctx.block_label(join_idx); + ctx.block().cond_br(&empty, &miss_l, &join_l); + ctx.current_block = miss_idx; + let fresh = emit_class_value(ctx.block(), class_id); + ctx.block().store(DOUBLE, &fresh, &slot); + ctx.block().br(&join_l); + ctx.current_block = join_idx; + ctx.block() + .phi(DOUBLE, &[(&cached, &from_l), (&fresh, &miss_l)]) +} use crate::codegen::AppMetadata; use crate::collectors::NativeRegionFactGraph; use crate::function::LlFunction; diff --git a/crates/perry-codegen/src/expr/property_get.rs b/crates/perry-codegen/src/expr/property_get.rs index 33fb06c932..7a50abab47 100644 --- a/crates/perry-codegen/src/expr/property_get.rs +++ b/crates/perry-codegen/src/expr/property_get.rs @@ -1144,8 +1144,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { .get(&crate::namespace_member_class_key(name, property)) .copied(); if let Some(cid) = class_cid { - let bits = crate::nanbox::INT32_TAG | (cid as u64 & 0xFFFF_FFFF); - return Ok(double_literal(f64::from_bits(bits))); + return Ok(super::emit_class_value_cached(ctx, cid)); } // Issue #680: prefer the per-namespace map so // `random.make` and `tracer.make` resolve to their diff --git a/crates/perry-codegen/src/expr/slice7_rooting_tests.rs b/crates/perry-codegen/src/expr/slice7_rooting_tests.rs index 002cd3c5a6..1e566a013c 100644 --- a/crates/perry-codegen/src/expr/slice7_rooting_tests.rs +++ b/crates/perry-codegen/src/expr/slice7_rooting_tests.rs @@ -556,3 +556,104 @@ fn proxy_function_apply_uses_argument_validation_bridge() { "Function.prototype.apply must validate and convert its argument list" ); } + +// --------------------------------------------------------------------------- +// expr/static_field_meta.rs — RefreshClassExprCaptures +// --------------------------------------------------------------------------- + +/// A class expression's capture refresh builds its capture array with one +/// push per capture. The array is live across every capture's lowering, and a +/// capture can collect, so each push must read the array from its root, not +/// from the `js_array_alloc` register (gc-root-dominance --stale-registers +/// flagged `source=alloc -> sink=js_array_push_f64` on #11609). +#[test] +fn class_expr_capture_refresh_rereads_its_capture_array_below_each_capture() { + let ir = compile_body( + "refresh_class_expr_captures", + vec![Stmt::Expr(Expr::RefreshClassExprCaptures { + class_value: Box::new(Expr::Undefined), + captures: vec![allocating("first"), allocating("second")], + env_class: None, + })], + ); + require_call_line(&ir, "js_array_alloc"); + assert_operand_survives_the_window( + &ir, + "js_array_push_f64", + 0, + "the capture array pushed after the first capture", + ); + let pushes: Vec = ir + .lines() + .enumerate() + .filter(|(_, l)| { + l.contains("@js_array_push_f64(") && !l.trim_start().starts_with("declare") + }) + .map(|(i, _)| i) + .collect(); + assert_eq!(pushes.len(), 2, "one push per capture:\n{ir}"); + let last = *pushes.last().unwrap(); + let line = ir.lines().nth(last).unwrap(); + let reg = line + .split("@js_array_push_f64(i64 ") + .nth(1) + .and_then(|rest| rest.split(',').next()) + .unwrap_or_else(|| panic!("unexpected push shape: {line}")) + .to_string(); + let def = require_definition_line(&ir, ®); + let alloc = last_alloc_before(&ir, last); + assert!( + def > alloc, + "the second push reads {reg} (line {def}) from above the second capture's allocation \ + (line {alloc}):\n{ir}" + ); +} + +fn capture_refresh(name: &str, captures: Vec) -> String { + compile_body( + name, + vec![Stmt::Expr(Expr::RefreshClassExprCaptures { + class_value: Box::new(Expr::Undefined), + captures, + env_class: None, + })], + ) +} + +/// The capture array is ONE accumulator: however many captures collect, the +/// refresh holds one rooted slot, republished by each push. A slot per push +/// grew tsc's module-scope closure (hundreds of refreshes of ~75 captures +/// each) by ~370k blocks and made its compile 3-4x slower. +#[test] +fn class_expr_capture_refresh_roots_one_slot_for_any_capture_count() { + let one = capture_refresh("refresh_one", vec![allocating("a")]); + let three = capture_refresh( + "refresh_three", + vec![allocating("a"), allocating("b"), allocating("c")], + ); + assert_eq!(call_count(&one, "js_array_push_f64"), 1, "{one}"); + assert_eq!(call_count(&three, "js_array_push_f64"), 3, "{three}"); + assert_eq!( + temp_root_slot_width(&one), + temp_root_slot_width(&three), + "three collecting captures must reuse the one capture-array slot:\n{three}" + ); +} + +/// Captures that cannot collect leave no collection point between two pushes, +/// so the refresh emits no root at all: the same slot width as a refresh with +/// no captures. +#[test] +fn class_expr_capture_refresh_over_inert_captures_emits_no_root() { + let none = capture_refresh("refresh_none", vec![]); + let inert = capture_refresh( + "refresh_inert", + vec![Expr::Number(1.0), Expr::Number(2.0), Expr::Undefined], + ); + assert_eq!(call_count(&inert, "js_array_push_f64"), 3, "{inert}"); + assert_eq!( + temp_root_slot_width(&inert), + temp_root_slot_width(&none), + "inert captures cannot collect, so the capture array needs no slot:\n{inert}" + ); +} diff --git a/crates/perry-codegen/src/expr/static_field_meta.rs b/crates/perry-codegen/src/expr/static_field_meta.rs index 1e271a8272..1a2c5bacc4 100644 --- a/crates/perry-codegen/src/expr/static_field_meta.rs +++ b/crates/perry-codegen/src/expr/static_field_meta.rs @@ -41,6 +41,90 @@ fn static_block_fns(ctx: &FnCtx<'_>, template: &str) -> Vec { .unwrap_or_default() } +/// The interned name bytes of a static field, as (`@bytes`, len). +fn static_field_name_bytes(ctx: &mut FnCtx<'_>, field_name: &str) -> (String, String) { + let idx = ctx.strings.intern(field_name); + let entry = ctx.strings.entry(idx); + ( + format!("@{}", entry.bytes_global), + entry.byte_len.to_string(), + ) +} + +/// `C.x` through its compiled alias, unless the alias is detached +/// (`TAG_HOLE` — see `class_static_alias_sync`): then the generic [[Get]]. +fn emit_detached_static_get( + ctx: &mut FnCtx<'_>, + class_id: u32, + field_name: &str, + value: &str, +) -> String { + let bits = ctx.block().bitcast_double_to_i64(value); + let detached = ctx + .block() + .icmp_eq(crate::types::I64, &bits, crate::nanbox::TAG_HOLE_I64); + let from_l = ctx.block_label(ctx.current_block); + let slow_idx = ctx.new_block("staticget.detached"); + let join_idx = ctx.new_block("staticget.join"); + let slow_l = ctx.block_label(slow_idx); + let join_l = ctx.block_label(join_idx); + ctx.block().cond_br(&detached, &slow_l, &join_l); + ctx.current_block = slow_idx; + let (bytes, len) = static_field_name_bytes(ctx, field_name); + let slow = ctx.block().call( + DOUBLE, + "js_class_static_field_get", + &[ + (crate::types::I32, &(class_id as i32).to_string()), + (PTR, &bytes), + (crate::types::I64, &len), + ], + ); + let slow_end_l = ctx.block_label(ctx.current_block); + ctx.block().br(&join_l); + ctx.current_block = join_idx; + ctx.block() + .phi(DOUBLE, &[(value, &from_l), (&slow, &slow_end_l)]) +} + +/// `C.x = v`: when the compiled alias is detached, the generic [[Set]] runs in +/// its own block and joins; the caller emits the attached store in the +/// current block and then branches to the returned join block. +fn emit_detached_static_put( + ctx: &mut FnCtx<'_>, + class_id: u32, + field_name: &str, + global_name: &str, + value: &str, +) -> usize { + let current = ctx.block().load(DOUBLE, &format!("@{global_name}")); + let bits = ctx.block().bitcast_double_to_i64(¤t); + let detached = ctx + .block() + .icmp_eq(crate::types::I64, &bits, crate::nanbox::TAG_HOLE_I64); + let slow_idx = ctx.new_block("staticset.detached"); + let fast_idx = ctx.new_block("staticset.attached"); + let join_idx = ctx.new_block("staticset.join"); + let slow_l = ctx.block_label(slow_idx); + let fast_l = ctx.block_label(fast_idx); + let join_l = ctx.block_label(join_idx); + ctx.block().cond_br(&detached, &slow_l, &fast_l); + ctx.current_block = slow_idx; + let (bytes, len) = static_field_name_bytes(ctx, field_name); + ctx.block().call_void( + "js_class_static_field_put", + &[ + (crate::types::I32, &(class_id as i32).to_string()), + (PTR, &bytes), + (crate::types::I64, &len), + (DOUBLE, value), + ], + ); + ctx.block().br(&join_l); + ctx.current_block = fast_idx; + join_idx +} + fn private_static_storage_name(class_id: u32, field_name: &str) -> String { format!("#") } @@ -54,7 +138,13 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let key = (class_name.clone(), field_name.clone()); if let Some(global_name) = ctx.static_field_globals.get(&key).cloned() { let g_ref = format!("@{}", global_name); - Ok(ctx.block().load(DOUBLE, &g_ref)) + let value = ctx.block().load(DOUBLE, &g_ref); + match ctx.class_ids.get(class_name).copied() { + Some(class_id) if !field_name.starts_with('#') => { + Ok(emit_detached_static_get(ctx, class_id, field_name, &value)) + } + _ => Ok(value), + } } else { Ok(double_literal(0.0)) } @@ -67,6 +157,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let v = lower_expr(ctx, value)?; let key = (class_name.clone(), field_name.clone()); let global_name = ctx.static_field_globals.get(&key).cloned(); + // A detached alias (`TAG_HOLE`: deleted / accessor / read-only) + // takes the generic [[Set]] instead of the direct store below. + let join_idx = match (global_name.as_ref(), ctx.class_ids.get(class_name).copied()) { + (Some(global_name), Some(class_id)) if !field_name.starts_with('#') => Some( + emit_detached_static_put(ctx, class_id, field_name, global_name, &v), + ), + _ => None, + }; if let Some(global_name) = global_name.as_ref() { let g_ref = format!("@{}", global_name); // GC_STORE_AUDIT(ROOT): static field global slot is registered as a mutable GC root @@ -105,6 +203,11 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { ], ); } + if let Some(join_idx) = join_idx { + let join_l = ctx.block_label(join_idx); + ctx.block().br(&join_l); + ctx.current_block = join_idx; + } Ok(v) } // Issue #711: dynamic parent-class registration for `class X @@ -204,25 +307,46 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { 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 (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", - &[(I64, &caps_arr), (DOUBLE, &value)], - ); - } + // The capture array is live across every capture's lowering, and a + // capture can collect (a property read through an IC miss, a + // getter): then it is an accumulator in ONE root slot, re-read by + // each push and republished with the push's result (the array may + // grow). A refresh whose captures cannot collect — the common case, + // plain local and boxed-variable reads — has no collection point + // between two pushes, so it emits no slot at all: a per-push slot + // costs seven blocks, and a module-scope closure holding several + // hundred class refreshes of ~75 captures each grew by ~370k blocks, + // which made LLVM's mem2reg quadratic (tsc compiled 3-4x slower). + let protect = crate::rooting::any_operand_may_collect(ctx, captures.iter()); + let arr = ctx.block().call(I64, "js_array_alloc", &[(I32, &cap_len)]); + let caps_arr = crate::rooting::with_rooted_accumulator( + ctx, + crate::rooting::Repr::Ptr, + &arr, + protect, + |ctx, acc| { + ctx.block().call_void("js_tdz_suppress_begin", &[]); + 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, + ); + } + acc.advance( + ctx, + "js_array_push_f64", + &[crate::rooting::Arg::Plain(DOUBLE, &value)], + ); + } + Ok(()) + }, + |_, current| Ok(current.to_string()), + )?; ctx.block().call_void("js_tdz_suppress_end", &[]); let caps_box = nanbox_pointer_inline(ctx.block(), &caps_arr); // Lower after the allocating array operations so a movable class diff --git a/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv b/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv index e9aa137e2a..a941ab3139 100644 --- a/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv +++ b/crates/perry-codegen/src/gc_effects/linux-x86_64.tsv @@ -573,6 +573,7 @@ js_class_capture_value Leaf js_class_capture_value_for_receiver Reenters js_class_capture_value_or Leaf js_class_computed_field_key Reenters +js_class_constructor_called Reenters js_class_env_current Reenters js_class_env_evaluate Reenters js_class_env_get Reenters @@ -589,8 +590,8 @@ js_class_field_set_fallback Reenters js_class_field_set_ic Reenters js_class_field_set_ic_fast Leaf js_class_field_set_ic_fast_miss Reenters -js_class_lexical_binding_get Leaf -js_class_lexical_binding_set Leaf +js_class_lexical_binding_get Reenters +js_class_lexical_binding_set Reenters js_class_method_bind Reenters js_class_method_bind_by_id Reenters js_class_method_snapshot_bind Reenters @@ -598,9 +599,12 @@ js_class_object_pin_parent Reenters js_class_object_refresh_capture_values Reenters js_class_prototype_method_value Reenters js_class_register_capture_values Leaf -js_class_register_static_field Leaf +js_class_register_static_field Reenters js_class_register_static_symbol Reenters +js_class_static_field_get Reenters +js_class_static_field_put Reenters js_class_static_method_call Reenters +js_class_value Reenters js_clear_exception Leaf js_clear_immediate_value Reenters js_clear_interval_value Reenters @@ -2141,7 +2145,7 @@ js_object_get_symbol_property_ic_miss Reenters js_object_get_symbol_then_field_ic_miss Reenters js_object_group_by Reenters js_object_has_own Reenters -js_object_has_own_symbol AllocOnly +js_object_has_own_symbol Reenters js_object_has_property Reenters js_object_is Reenters js_object_is_extensible Reenters @@ -2734,19 +2738,19 @@ js_register_class_generic_origin Leaf js_register_class_getter Reenters js_register_class_has_instance Leaf js_register_class_id Leaf -js_register_class_length Leaf +js_register_class_length Reenters js_register_class_method Leaf js_register_class_method_bind_length Leaf -js_register_class_name Leaf +js_register_class_name Reenters js_register_class_parent Leaf js_register_class_parent_dynamic Reenters js_register_class_setter Reenters js_register_class_source Leaf js_register_class_source_static Leaf -js_register_class_static_getter Leaf -js_register_class_static_method Leaf +js_register_class_static_getter Reenters +js_register_class_static_method Reenters js_register_class_static_method_bind_length Leaf -js_register_class_static_setter Leaf +js_register_class_static_setter Reenters js_register_class_string_member_order Leaf js_register_class_to_string_tag Leaf js_register_closure_arity Leaf @@ -3038,9 +3042,10 @@ js_stack_overflow Reenters js_state_get Reenters js_state_init Reenters js_state_set Reenters -js_static_this_arm_classref Leaf +js_static_this_arm_classref Reenters js_static_this_arm_value Leaf js_static_this_resolve Leaf +js_static_this_resolve_class Reenters js_stdlib_has_active_handles Reenters js_stdlib_init_dispatch Reenters js_stdlib_install_bundled_nodemailer Leaf diff --git a/crates/perry-codegen/src/gc_effects/macos-aarch64.tsv b/crates/perry-codegen/src/gc_effects/macos-aarch64.tsv index 405bf6af51..416ac68806 100644 --- a/crates/perry-codegen/src/gc_effects/macos-aarch64.tsv +++ b/crates/perry-codegen/src/gc_effects/macos-aarch64.tsv @@ -573,6 +573,7 @@ js_class_capture_value Leaf js_class_capture_value_for_receiver Reenters js_class_capture_value_or Leaf js_class_computed_field_key Reenters +js_class_constructor_called ThrowOnly js_class_env_current Reenters js_class_env_evaluate Reenters js_class_env_get Reenters @@ -589,8 +590,8 @@ js_class_field_set_fallback Reenters js_class_field_set_ic Reenters js_class_field_set_ic_fast Leaf js_class_field_set_ic_fast_miss Reenters -js_class_lexical_binding_get Leaf -js_class_lexical_binding_set Leaf +js_class_lexical_binding_get Reenters +js_class_lexical_binding_set Reenters js_class_method_bind Reenters js_class_method_bind_by_id Reenters js_class_method_snapshot_bind Reenters @@ -598,9 +599,12 @@ js_class_object_pin_parent Reenters js_class_object_refresh_capture_values Reenters js_class_prototype_method_value Reenters js_class_register_capture_values Leaf -js_class_register_static_field Leaf +js_class_register_static_field Reenters js_class_register_static_symbol Reenters +js_class_static_field_get Reenters +js_class_static_field_put Reenters js_class_static_method_call Reenters +js_class_value Reenters js_clear_exception Leaf js_clear_immediate_value Reenters js_clear_interval_value Reenters @@ -2141,7 +2145,7 @@ js_object_get_symbol_property_ic_miss Reenters js_object_get_symbol_then_field_ic_miss Reenters js_object_group_by Reenters js_object_has_own Reenters -js_object_has_own_symbol AllocOnly +js_object_has_own_symbol Reenters js_object_has_property Reenters js_object_is AllocOnly js_object_is_extensible Reenters @@ -2734,16 +2738,16 @@ js_register_class_id Reenters js_register_class_length Reenters js_register_class_method Leaf js_register_class_method_bind_length Reenters -js_register_class_name Leaf +js_register_class_name Reenters js_register_class_parent Reenters js_register_class_parent_dynamic Reenters js_register_class_setter Reenters js_register_class_source Leaf js_register_class_source_static Leaf -js_register_class_static_getter Leaf -js_register_class_static_method Leaf +js_register_class_static_getter Reenters +js_register_class_static_method Reenters js_register_class_static_method_bind_length Reenters -js_register_class_static_setter Leaf +js_register_class_static_setter Reenters js_register_class_string_member_order Leaf js_register_class_to_string_tag Reenters js_register_closure_arity Leaf @@ -3032,9 +3036,10 @@ js_sqlite_transaction Reenters js_state_get Reenters js_state_init Reenters js_state_set Reenters -js_static_this_arm_classref Leaf +js_static_this_arm_classref Reenters js_static_this_arm_value Leaf js_static_this_resolve Leaf +js_static_this_resolve_class Reenters js_stdlib_has_active_handles Reenters js_stdlib_init_dispatch Reenters js_stdlib_install_bundled_nodemailer Leaf diff --git a/crates/perry-codegen/src/gc_effects/windows-x86_64.tsv b/crates/perry-codegen/src/gc_effects/windows-x86_64.tsv index 8334ff53d5..784bc9a415 100644 --- a/crates/perry-codegen/src/gc_effects/windows-x86_64.tsv +++ b/crates/perry-codegen/src/gc_effects/windows-x86_64.tsv @@ -573,6 +573,7 @@ js_class_capture_value Leaf js_class_capture_value_for_receiver Reenters js_class_capture_value_or Leaf js_class_computed_field_key Reenters +js_class_constructor_called Reenters js_class_env_current Reenters js_class_env_evaluate Reenters js_class_env_get Reenters @@ -589,8 +590,8 @@ js_class_field_set_fallback Reenters js_class_field_set_ic Reenters js_class_field_set_ic_fast Leaf js_class_field_set_ic_fast_miss Reenters -js_class_lexical_binding_get Leaf -js_class_lexical_binding_set Leaf +js_class_lexical_binding_get Reenters +js_class_lexical_binding_set Reenters js_class_method_bind Reenters js_class_method_bind_by_id Reenters js_class_method_snapshot_bind Reenters @@ -598,9 +599,12 @@ js_class_object_pin_parent Reenters js_class_object_refresh_capture_values Reenters js_class_prototype_method_value Reenters js_class_register_capture_values Leaf -js_class_register_static_field Leaf +js_class_register_static_field Reenters js_class_register_static_symbol Reenters +js_class_static_field_get Reenters +js_class_static_field_put Reenters js_class_static_method_call Reenters +js_class_value Reenters js_clear_exception Leaf js_clear_immediate_value Reenters js_clear_interval_value Reenters @@ -2141,7 +2145,7 @@ js_object_get_symbol_property_ic_miss Reenters js_object_get_symbol_then_field_ic_miss Reenters js_object_group_by Reenters js_object_has_own Reenters -js_object_has_own_symbol AllocOnly +js_object_has_own_symbol Reenters js_object_has_property Reenters js_object_is Reenters js_object_is_extensible Reenters @@ -2731,19 +2735,19 @@ js_register_class_generic_origin Leaf js_register_class_getter Reenters js_register_class_has_instance Leaf js_register_class_id Leaf -js_register_class_length Leaf +js_register_class_length Reenters js_register_class_method Leaf js_register_class_method_bind_length Leaf -js_register_class_name Leaf +js_register_class_name Reenters js_register_class_parent Leaf js_register_class_parent_dynamic Reenters js_register_class_setter Reenters js_register_class_source Leaf js_register_class_source_static Leaf -js_register_class_static_getter Leaf -js_register_class_static_method Leaf +js_register_class_static_getter Reenters +js_register_class_static_method Reenters js_register_class_static_method_bind_length Leaf -js_register_class_static_setter Leaf +js_register_class_static_setter Reenters js_register_class_string_member_order Leaf js_register_class_to_string_tag Leaf js_register_closure_arity Leaf @@ -3032,9 +3036,10 @@ js_sqlite_transaction Reenters js_state_get Reenters js_state_init Reenters js_state_set Reenters -js_static_this_arm_classref Leaf +js_static_this_arm_classref Reenters js_static_this_arm_value Leaf js_static_this_resolve Leaf +js_static_this_resolve_class Reenters js_stdlib_has_active_handles Reenters js_stdlib_init_dispatch Reenters js_stdlib_install_bundled_nodemailer Leaf diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index 942918adde..d13eb48cc5 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -700,9 +700,7 @@ fn lower_new_impl_inner<'a>( // rewrites — so it goes in a temp root, not a bare register. let saved_new_target = if ctor_chain_uses_new_target(ctx, class) { ctx.class_ids.get(class_name).copied().map(|cid| { - let class_ref = double_literal(f64::from_bits( - crate::nanbox::INT32_TAG | (cid as u64 & 0xFFFF_FFFF), - )); + let class_ref = crate::expr::emit_class_value_cached(ctx, cid); crate::rooting::new_target_save(ctx, &class_ref) }) } else { @@ -1005,17 +1003,13 @@ fn lower_new_impl_inner<'a>( // `INT32_TAG | class_id`, the same value `Expr::ClassRef` produces, so // `new.target === C`, `new.target.name`, and `new.target.prototype` all // work. Falls back to `undefined` if the class id is somehow unresolved. - let new_target_bits = ctx - .class_ids - .get(class_name) - .map(|&cid| crate::nanbox::INT32_TAG | (cid as u64 & 0xFFFF_FFFF)) - .unwrap_or(crate::nanbox::TAG_UNDEFINED); + let new_target_value = match ctx.class_ids.get(class_name).copied() { + Some(cid) => crate::expr::emit_class_value_cached(ctx, cid), + None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), + }; let new_target_slot = ctx.func.alloca_entry(DOUBLE); - ctx.block().store( - DOUBLE, - &double_literal(f64::from_bits(new_target_bits)), - &new_target_slot, - ); + ctx.block() + .store(DOUBLE, &new_target_value, &new_target_slot); ctx.new_target_stack.push(new_target_slot); // Set up the inline-constructor return target. An explicit `return` @@ -1719,7 +1713,10 @@ fn lower_new_impl_inner<'a>( // 'type')`, or silently set `type = undefined` → the auth error // was mis-categorized and the login redirect fell back to // `?error=Configuration`. - let nt_ref = double_literal(f64::from_bits(new_target_bits)); + let nt_ref = match ctx.class_ids.get(class_name).copied() { + Some(cid) => crate::expr::emit_class_value_cached(ctx, cid), + None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), + }; let nt_save = crate::rooting::new_target_save(ctx, &nt_ref); let _ = ctx.block().call(DOUBLE, &ctor.symbol, &ctor_args); crate::rooting::new_target_restore(ctx, &nt_save); @@ -1754,7 +1751,10 @@ fn lower_new_impl_inner<'a>( // new.target cross-module: bind the runtime cell to the leaf // class ref around the imported ctor call (see the ANCESTOR arm // above for why). This is the direct `new ImportedClass()` case. - let nt_ref = double_literal(f64::from_bits(new_target_bits)); + let nt_ref = match ctx.class_ids.get(class_name).copied() { + Some(cid) => crate::expr::emit_class_value_cached(ctx, cid), + None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), + }; let nt_save = crate::rooting::new_target_save(ctx, &nt_ref); let ctor_ret = ctx.block().call(DOUBLE, &ctor.symbol, &ctor_args); crate::rooting::new_target_restore(ctx, &nt_save); diff --git a/crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs b/crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs index d49de12ce3..0adb171ff5 100644 --- a/crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs +++ b/crates/perry-codegen/src/lower_call/property_get/static_dispatch.rs @@ -134,9 +134,12 @@ pub(crate) fn try_lower_static_dispatch( Expr::LocalGet(_) => lower_expr(ctx, object)?, _ => { // Synthesize a ClassRef NaN-box from the resolved class. - let cid = ctx.class_ids.get(&cls_name).copied().unwrap_or(0); - let bits = crate::nanbox::INT32_TAG | (cid as u64 & 0xFFFF_FFFF); - crate::nanbox::double_literal(f64::from_bits(bits)) + match ctx.class_ids.get(&cls_name).copied() { + Some(cid) if cid != 0 => crate::expr::emit_class_value_cached(ctx, cid), + _ => crate::nanbox::double_literal(f64::from_bits( + crate::nanbox::TAG_UNDEFINED, + )), + } } }; // `has_rest` unconditionally allocates the synthesized array diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/language_core.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/language_core.rs index d981b3fd89..5f6e56a826 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/language_core.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/language_core.rs @@ -398,6 +398,7 @@ pub(crate) fn declare_core(module: &mut LlModule) { // armed by dynamic static dispatch / call/apply, else returns the // lexical class-ref argument. module.declare_function("js_static_this_resolve", DOUBLE, &[DOUBLE]); + module.declare_function("js_static_this_resolve_class", DOUBLE, &[I32, PTR]); module.declare_function("js_static_this_arm_classref", VOID, &[I32]); module.declare_function("js_static_this_arm_value", VOID, &[DOUBLE]); module.declare_function("js_ctor_return_override", DOUBLE, &[DOUBLE, DOUBLE, I32]); diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index 8b2265db7f..d7e7002d79 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -217,6 +217,13 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { // skipping per-evaluation closure allocation on the hot loop. See // `crates/perry-runtime/src/closure.rs::js_closure_alloc_singleton`. module.declare_function("js_closure_alloc_singleton", I64, &[PTR]); + // A class constructor as a value: the class's per-agent function object + // (`object/class_value.rs`; #11414). + module.declare_function("js_class_value", DOUBLE, &[I32]); + // A declared static whose compiled alias is detached (`TAG_HOLE`): the + // generic [[Get]] / [[Set]] on the class function object. + module.declare_function("js_class_static_field_get", DOUBLE, &[I32, PTR, I64]); + module.declare_function("js_class_static_field_put", VOID, &[I32, PTR, I64, DOUBLE]); // Singleton-cached variant for closures with captures, keyed by // `(func_ptr, capture_bits…)`. Args: (func_ptr, capture_count, // captures_ptr — pointer to `capture_count` u64 values). diff --git a/crates/perry-codegen/src/stmt/let_scalar_new.rs b/crates/perry-codegen/src/stmt/let_scalar_new.rs index b4c4c03caf..19bb935e8d 100644 --- a/crates/perry-codegen/src/stmt/let_scalar_new.rs +++ b/crates/perry-codegen/src/stmt/let_scalar_new.rs @@ -171,17 +171,15 @@ pub(super) fn try_lower_scalar_replaced_new( // (`INT32_TAG | class_id`). Without this a `new.target` read in // the ctor (notably `const t = new.target`) fell through to the // runtime cell, which this path never sets, yielding undefined. - let new_target_bits = ctx - .class_ids - .get(class_name) - .map(|&cid| crate::nanbox::INT32_TAG | (cid as u64 & 0xFFFF_FFFF)) - .unwrap_or(crate::nanbox::TAG_UNDEFINED); + let new_target_value = match ctx.class_ids.get(class_name).copied() { + Some(cid) => crate::expr::emit_class_value_cached(ctx, cid), + None => { + crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) + } + }; let new_target_slot = ctx.func.alloca_entry(DOUBLE); - ctx.block().store( - DOUBLE, - &crate::nanbox::double_literal(f64::from_bits(new_target_bits)), - &new_target_slot, - ); + ctx.block() + .store(DOUBLE, &new_target_value, &new_target_slot); ctx.new_target_stack.push(new_target_slot); // Stage field initializers around any parent body chain. diff --git a/crates/perry-codegen/src/wasm32/runtime_abi.tsv b/crates/perry-codegen/src/wasm32/runtime_abi.tsv index 5b6a571e30..6583ef4d13 100644 --- a/crates/perry-codegen/src/wasm32/runtime_abi.tsv +++ b/crates/perry-codegen/src/wasm32/runtime_abi.tsv @@ -581,6 +581,7 @@ js_class_capture_value f64 i32u,i32u js_class_capture_value_for_receiver f64 f64,i32u,i32u js_class_capture_value_or f64 i32u,i32u,f64 js_class_computed_field_key f64 f64,i32u,ptr,usize +js_class_constructor_called f64 ptr js_class_env_current f64 f64,i32u js_class_env_evaluate void i32u,f64,f64 js_class_env_get f64 f64,i32u,i32u @@ -608,7 +609,10 @@ js_class_prototype_method_value f64 f64,f64 js_class_register_capture_values void i32u,ptr,usize js_class_register_static_field void i32u,ptr,usize,f64,ptr js_class_register_static_symbol void i32u,f64,f64 +js_class_static_field_get f64 i32s,ptr,i64 +js_class_static_field_put void i32s,ptr,i64,f64 js_class_static_method_call f64 f64,ptr,usize,ptr,usize +js_class_value f64 i32s js_clear_exception void js_clear_immediate_value void f64 js_clear_interval_value void f64 @@ -3525,6 +3529,7 @@ js_state_set void f64,f64 js_static_this_arm_classref void i32u js_static_this_arm_value void f64 js_static_this_resolve f64 f64 +js_static_this_resolve_class f64 i32s,ptr js_stdlib_has_active_handles i32s js_stdlib_init_dispatch void js_stdlib_install_bundled_nodemailer void diff --git a/crates/perry-runtime/src/array/indexing_keyed.rs b/crates/perry-runtime/src/array/indexing_keyed.rs index e8894906f8..c408486d64 100644 --- a/crates/perry-runtime/src/array/indexing_keyed.rs +++ b/crates/perry-runtime/src/array/indexing_keyed.rs @@ -37,7 +37,7 @@ pub extern "C" fn js_array_set_string_key( // its high bits are set, so the `is_array` GC-header probe below would // dereference unmapped memory. Route to the by-name object setter, which // detects the class-ref tag and stores into the static-field tables. - if (arr as u64) >> 48 == 0x7FFE { + if crate::object::class_value::legacy_class_ptr_word(arr as u64).is_some() { crate::object::js_object_set_field_by_name( arr as *mut crate::object::ObjectHeader, key, diff --git a/crates/perry-runtime/src/builtins/formatting.rs b/crates/perry-runtime/src/builtins/formatting.rs index 61dd2d08d6..0e2a9db0b3 100644 --- a/crates/perry-runtime/src/builtins/formatting.rs +++ b/crates/perry-runtime/src/builtins/formatting.rs @@ -265,6 +265,9 @@ fn format_function_for_console(closure_ptr: *const crate::closure::ClosureHeader if closure_ptr.is_null() { return "[Function (anonymous)]".to_string(); } + if let Some(class_id) = crate::object::class_value::class_closure_id(closure_ptr as usize) { + return format_class_for_console(class_id, closure_ptr); + } // Snapshot user-attached own properties and filter out the built-in // function slots that Node hides from `util.inspect`. Node prints @@ -341,6 +344,52 @@ fn format_function_for_console(closure_ptr: *const crate::closure::ClosureHeader format!("{} {{ {} }}", label, parts.join(", ")) } +/// Node's `util.inspect` of a class constructor: `[class A extends B]`, then +/// its enumerable own properties (static fields, runtime-added keys) as +/// `{ k: v }` — the same decoration a plain function gets. Values are read as +/// data (`Object.keys` lists no accessor: class accessors are non-enumerable). +fn format_class_for_console( + class_id: u32, + closure_ptr: *const crate::closure::ClosureHeader, +) -> String { + let label = value_repr::class_label_for_id(class_id); + // The class function object is pinned: `closure_ptr` stays valid across + // the allocations below. + let value = f64::from_bits(crate::value::POINTER_TAG | closure_ptr as u64); + let keys = crate::object::js_object_keys_value(value); + let mut names: Vec = Vec::new(); + if !keys.is_null() { + for i in 0..crate::array::js_array_length(keys) { + if let Some(name) = jsvalue_string_content(crate::array::js_array_get_f64(keys, i)) { + names.push(name); + } + } + } + if names.is_empty() { + return label; + } + let mut parts: Vec = Vec::with_capacity(names.len()); + for name in names { + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + let v = crate::object::js_object_get_field_by_name_f64( + closure_ptr as *const crate::object::ObjectHeader, + key, + ); + let rendered = format_jsvalue(v, 1); + let rendered = if crate::value::JSValue::from_bits(v.to_bits()).is_any_string() { + format!("'{rendered}'") + } else { + rendered + }; + parts.push(format!( + "{}: {}", + format_inspect_property_key(&name), + rendered + )); + } + format!("{} {{ {} }}", label, parts.join(", ")) +} + // Per-thread override for the `showHidden` inspect option. Defaults to // `false` (Node default): `util.inspect` / `console.log` only show // enumerable properties. `console.dir(value, { showHidden: true })` diff --git a/crates/perry-runtime/src/builtins/formatting/identity_equality.rs b/crates/perry-runtime/src/builtins/formatting/identity_equality.rs index cd7c6d3073..64782ad3ae 100644 --- a/crates/perry-runtime/src/builtins/formatting/identity_equality.rs +++ b/crates/perry-runtime/src/builtins/formatting/identity_equality.rs @@ -24,5 +24,9 @@ fn is_weak_collection_value(value: f64) -> bool { #[inline] pub(super) fn is_identity_only_deep_equal_value(value: f64) -> bool { - crate::promise::js_value_is_promise(value) != 0 || is_weak_collection_value(value) + crate::promise::js_value_is_promise(value) != 0 + || is_weak_collection_value(value) + // A class is its function object: two distinct classes render alike + // (`[class Twin]`) but are never deep-equal. + || crate::object::class_value::class_value_id(value).is_some() } diff --git a/crates/perry-runtime/src/builtins/formatting/value_repr.rs b/crates/perry-runtime/src/builtins/formatting/value_repr.rs index 0df500db9f..f39a153224 100644 --- a/crates/perry-runtime/src/builtins/formatting/value_repr.rs +++ b/crates/perry-runtime/src/builtins/formatting/value_repr.rs @@ -50,7 +50,7 @@ pub(crate) fn class_ref_inspect_label(value: f64) -> Option { } /// `[class …]` for a resolved class id. -fn class_label_for_id(class_id: u32) -> String { +pub(crate) fn class_label_for_id(class_id: u32) -> String { // The name comes from whatever `class_name_for_id` reports — deliberately // NOT re-derived here. #9413 covers class `.name` leaking compiler-internal // spellings; when that lands, the corrected name flows straight through. diff --git a/crates/perry-runtime/src/closure/dispatch/bound.rs b/crates/perry-runtime/src/closure/dispatch/bound.rs index 46f33ad5ec..e459659281 100644 --- a/crates/perry-runtime/src/closure/dispatch/bound.rs +++ b/crates/perry-runtime/src/closure/dispatch/bound.rs @@ -483,6 +483,10 @@ pub(crate) fn rebind_explicit_this(target: f64, this_arg: f64) -> f64 { /// lazily here instead of at bind time is observationally identical. unsafe fn bound_target_declared_name(target_value: f64) -> String { use crate::value::JSValue; + // A class function object's declared name is its class's. + if let Some(class_id) = crate::object::class_value::class_value_id(target_value) { + return crate::object::class_name_for_id(class_id).unwrap_or_default(); + } let target_jv = JSValue::from_bits(target_value.to_bits()); if target_jv.is_pointer() { let target_closure = target_jv.as_pointer::(); @@ -493,9 +497,10 @@ unsafe fn bound_target_declared_name(target_value: f64) -> String { return String::new(); } let target_class_id = crate::object::class_ref_id(target_value).or_else(|| { - ((target_value.to_bits() >> 48) == 0x7FFE - && crate::object::class_prototype_ref_id(target_value).is_none()) - .then_some((target_value.to_bits() & 0xFFFF_FFFF) as u32) + crate::object::class_prototype_ref_id(target_value) + .is_none() + .then(|| crate::object::class_value::legacy_class_value_word(target_value.to_bits())) + .flatten() }); target_class_id .and_then(crate::object::class_name_for_id) @@ -614,11 +619,9 @@ pub unsafe extern "C" fn js_function_bind( let err = crate::error::js_typeerror_new(msg); crate::exception::js_throw(crate::value::js_nanbox_pointer(err as i64)); } - let target_class_id = crate::object::class_ref_id(target_value).or_else(|| { - ((target_value.to_bits() >> 48) == 0x7FFE - && crate::object::class_prototype_ref_id(target_value).is_none()) - .then_some((target_value.to_bits() & 0xFFFF_FFFF) as u32) - }); + // A pointer target is a closure (a class function object is one) or a + // callable native handle; only a non-pointer target can be the legacy + // INT32 class form, so only it pays the class probe. let target_is_closure = if target_jv.is_pointer() { let ptr = target_jv.as_pointer::(); if ptr.is_null() || !is_closure_ptr(ptr as usize) { @@ -627,7 +630,17 @@ pub unsafe extern "C" fn js_function_bind( return target_value; } true - } else if target_class_id.is_some() { + } else if crate::object::class_ref_id(target_value) + .or_else(|| { + crate::object::class_prototype_ref_id(target_value) + .is_none() + .then(|| { + crate::object::class_value::legacy_class_value_word(target_value.to_bits()) + }) + .flatten() + }) + .is_some() + { // ClassRefs are callable/constructable INT32-tagged values rather // than heap closures. They still need a real BoundFunction wrapper // so `new C.bind(_, ...args)()` prepends its captured arguments. diff --git a/crates/perry-runtime/src/closure/dynamic_props.rs b/crates/perry-runtime/src/closure/dynamic_props.rs index 0b64155990..76bed81da7 100644 --- a/crates/perry-runtime/src/closure/dynamic_props.rs +++ b/crates/perry-runtime/src/closure/dynamic_props.rs @@ -326,6 +326,16 @@ pub extern "C" fn js_value_is_closure(value_bits: i64) -> i32 { /// Get a dynamic property stored on a closure. /// Returns TAG_UNDEFINED if not found. pub fn closure_get_dynamic_prop(ptr: usize, prop: &str) -> f64 { + closure_get_dynamic_prop_keyed(ptr, prop, std::ptr::null()) +} + +/// [`closure_get_dynamic_prop`] with the caller's key header, when it has one +/// (`key` may be null): a class constructor's read then builds no key string. +pub(crate) fn closure_get_dynamic_prop_keyed( + ptr: usize, + prop: &str, + key: *const crate::StringHeader, +) -> f64 { if !is_closure_ptr(ptr) { return f64::from_bits(crate::value::TAG_UNDEFINED); } @@ -350,6 +360,11 @@ pub fn closure_get_dynamic_prop(ptr: usize, prop: &str) -> f64 { let on_base = unsafe { super::shape::closure_on_base_shape(ptr as *const ClosureHeader) }; if on_base { // fall through to the data lookups below + } else if super::shape::is_class_code(unsafe { (*(ptr as *const ClosureHeader)).func_ptr }) { + // A class constructor: its class lookup (statics, the parent chain, + // `name`/`length`/`prototype`, Function.prototype) — never the plain + // function fallbacks below. + return crate::object::class_value::class_static_read(ptr, prop, key); } else if let Some(acc) = crate::object::get_accessor_descriptor(ptr, prop) { if acc.get == 0 { return f64::from_bits(crate::value::TAG_UNDEFINED); diff --git a/crates/perry-runtime/src/closure/mod.rs b/crates/perry-runtime/src/closure/mod.rs index 7dac269eb0..ea3980712a 100644 --- a/crates/perry-runtime/src/closure/mod.rs +++ b/crates/perry-runtime/src/closure/mod.rs @@ -93,9 +93,10 @@ pub(crate) use dynamic_props::test_clear_closure_side_tables; pub(crate) use dynamic_props::{ clear_closure_side_tables_for_dead_ptr, clone_closure_rebind_this, closure_dynamic_props_owner_moved, closure_dynamic_side_tables_nonempty, - closure_set_via_function_prototype_descriptor, function_prototype_fallback_target, - function_prototype_inherited_get, prune_dead_closure_side_table_owners, - prune_dead_closure_side_table_owners_young, release_closure_side_table_owners_in_ranges, + closure_get_dynamic_prop_keyed, closure_set_via_function_prototype_descriptor, + function_prototype_fallback_target, function_prototype_inherited_get, + prune_dead_closure_side_table_owners, prune_dead_closure_side_table_owners_young, + release_closure_side_table_owners_in_ranges, }; pub use dynamic_props::{ closure_delete_own_dynamic_prop, closure_dynamic_props_snapshot, closure_get_dynamic_prop, diff --git a/crates/perry-runtime/src/closure/props.rs b/crates/perry-runtime/src/closure/props.rs index 8d2d7e1478..88eaacdcac 100644 --- a/crates/perry-runtime/src/closure/props.rs +++ b/crates/perry-runtime/src/closure/props.rs @@ -24,6 +24,7 @@ use crate::value::JSValue; const STATE_PROTO: &str = "p"; const DELETED_PREFIX: &str = "d:"; +const INTERNAL_PREFIX: &str = "i:"; /// The bag of the closure at `ptr` (null when it never had an own property). /// @@ -272,6 +273,48 @@ pub(crate) unsafe fn state_set_prototype(ptr: usize, proto_bits: u64) { object_own_set(state, STATE_PROTO, f64::from_bits(proto_bits)); } +/// A runtime-internal own slot of the function object — never a JS property +/// (class private statics, computed-key records, class captures): kept in the +/// state record under `"i:" + key`, so no reflection or enumeration of the +/// function can reach it. +/// +/// # Safety +/// `ptr` is a proven, live closure cell. +pub(crate) unsafe fn state_internal_get(ptr: usize, key: &str) -> Option { + let state = state_of(ptr); + if state.is_null() { + return None; + } + let mut marker = String::with_capacity(INTERNAL_PREFIX.len() + key.len()); + marker.push_str(INTERNAL_PREFIX); + marker.push_str(key); + object_own_get(state, marker.as_bytes()) +} + +/// # Safety +/// `ptr` is a proven, live closure cell. +pub(crate) unsafe fn state_internal_set(ptr: usize, key: &str, value: f64) { + let _no_move = crate::gc::GcSuppressScope::new(); + let Some(state) = state_ensure(ptr) else { + return; + }; + object_own_set(state, &format!("{INTERNAL_PREFIX}{key}"), value); +} + +/// # Safety +/// `ptr` is a proven, live closure cell. +pub(crate) unsafe fn state_internal_remove(ptr: usize, key: &str) -> bool { + if state_internal_get(ptr, key).is_none() { + return false; + } + let _no_move = crate::gc::GcSuppressScope::new(); + let state = state_of(ptr); + let marker = format!("{INTERNAL_PREFIX}{key}"); + let key_hdr = crate::string::js_string_from_bytes(marker.as_ptr(), marker.len() as u32); + crate::object::js_object_delete_field(state, key_hdr); + true +} + /// True when the closure carries internal state a base/keyed Function shape /// cannot describe (a deleted marker or a recorded prototype). /// diff --git a/crates/perry-runtime/src/closure/shape.rs b/crates/perry-runtime/src/closure/shape.rs index 6413f116b9..f2e91dbf46 100644 --- a/crates/perry-runtime/src/closure/shape.rs +++ b/crates/perry-runtime/src/closure/shape.rs @@ -32,6 +32,12 @@ pub(crate) const INTRINSIC_SERIAL_FUNCTION: u64 = 1; pub(crate) const INTRINSIC_SERIAL_ASYNC_FUNCTION: u64 = 2; pub(crate) const INTRINSIC_SERIAL_GENERATOR_FUNCTION: u64 = 3; pub(crate) const INTRINSIC_SERIAL_ASYNC_GENERATOR_FUNCTION: u64 = 4; +/// Not a prototype: the marker `proto_id` of the class-constructor ShapeId +/// ([`function_class_shape`]). Shapes are canonical per facts, so without a +/// fact of its own the class shape would BE the FunctionDictionary id. No +/// object is ever assigned this serial; a class constructor's real +/// [[Prototype]] lives on the object (dictionary kind: "ask the object"). +pub(crate) const INTRINSIC_SERIAL_CLASS_CONSTRUCTOR_MARKER: u64 = 5; /// Which intrinsic prototype a function BODY's closures inherit from. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -96,6 +102,9 @@ crate::perry_thread_local! { /// This agent's base Function ShapeIds, indexed by `FunctionProtoKind`, /// then the FunctionDictionary id (0 = not minted yet). static BASE_SHAPES: std::cell::Cell<[u32; 5]> = const { std::cell::Cell::new([0; 5]) }; + /// This agent's class-constructor ShapeId (0 = not minted yet). Its own + /// cell: the base-shape array is copied on every closure birth. + static CLASS_SHAPE: std::cell::Cell = const { std::cell::Cell::new(0) }; /// One-entry body cache: the last `func_ptr` born and its base shape. static LAST_BODY: std::cell::Cell<(usize, u32)> = const { std::cell::Cell::new((0, 0)) }; } @@ -178,6 +187,39 @@ pub(crate) fn function_dictionary_shape() -> u32 { ) } +/// The ShapeId of every class function object (`object::class_value`): its +/// kind is a shape fact. Dictionary-kind ("ask the object": statics, the +/// recorded [[Prototype]], accessors live on the object) and STICKY — no +/// own-property transition moves a class function object off it, so a site +/// that compares ShapeIds tells a class constructor from any other function +/// with that one compare. +#[inline] +pub(crate) fn function_class_shape() -> u32 { + let mut id = CLASS_SHAPE.with(std::cell::Cell::get); + if id == 0 { + id = mint( + ShapeObjectKind::FunctionDictionary, + INTRINSIC_SERIAL_CLASS_CONSTRUCTOR_MARKER, + ); + CLASS_SHAPE.with(|c| c.set(id)); + } + debug_assert_ne!( + id, + function_dictionary_shape(), + "the class shape is its own id" + ); + id +} + +/// Is `func_ptr` the class [[Call]] code — i.e. is a closure carrying it a +/// class function object? Equivalent to its ShapeId being +/// [`function_class_shape`] (both are set at mint and never change); used on +/// hot paths where the shape id would need a thread-local read. +#[inline(always)] +pub(crate) fn is_class_code(func_ptr: *const u8) -> bool { + func_ptr == crate::object::class_value::js_class_constructor_called as *const u8 +} + /// The ShapeId a fresh closure of `func_ptr` is born with. #[inline] pub(crate) fn birth_shape_for_body(func_ptr: *const u8) -> u32 { @@ -214,10 +256,14 @@ pub(crate) unsafe fn closure_on_base_shape(closure: *const ClosureHeader) -> boo let id = (*closure).shape_id; debug_assert!( id == function_dictionary_shape() + || id == function_class_shape() || shapes::shape_object_kind_by_id(id) == Some(ShapeObjectKind::Function), - "a closure carries a Function or the FunctionDictionary shape: {id:#x}" + "a closure carries a Function, FunctionDictionary or class shape: {id:#x}" ); - id != function_dictionary_shape() + // The class shape is sticky and implies the class [[Call]] code pointer, + // so the code-pointer compare (a link-time constant, no thread-local + // read) excludes it for free on this hot path. + id != function_dictionary_shape() && !is_class_code((*closure).func_ptr) } /// Record that `closure` now answers something its base shape does not: @@ -230,7 +276,9 @@ pub(crate) unsafe fn closure_on_base_shape(closure: *const ClosureHeader) -> boo #[inline] pub(crate) unsafe fn closure_become_dictionary(closure: *mut ClosureHeader) { let dict = function_dictionary_shape(); - if (*closure).shape_id != dict { + let id = (*closure).shape_id; + // A class function object keeps its (sticky, dictionary-kind) class shape. + if id != dict && !is_class_code((*closure).func_ptr) { // GC_STORE_AUDIT(POINTER_FREE): a ShapeId, never a heap reference. (*closure).shape_id = dict; } @@ -252,7 +300,7 @@ pub(crate) fn refresh_closure_shape(ptr: usize) { unsafe { let closure = ptr as *mut ClosureHeader; let dict = function_dictionary_shape(); - if (*closure).shape_id == dict { + if (*closure).shape_id == dict || is_class_code((*closure).func_ptr) { return; } if super::props::has_state(ptr) { @@ -310,7 +358,8 @@ pub(crate) fn function_shape_inherits_from_function_prototype(id: u32, key: &[u8 if id == function_dictionary_shape() { return false; } - // A keyed shape: its verdict for the three Function.prototype intrinsics + // A keyed shape (the class shape is dictionary-kind: the verdict below + // answers false for it without a compare of its own): its verdict for the three Function.prototype intrinsics // is a fact of the (immutable) ShapeId, cached per agent. let bit = match key { b"bind" => VERDICT_BIND, @@ -319,7 +368,9 @@ pub(crate) fn function_shape_inherits_from_function_prototype(id: u32, key: &[u8 _ => return keyed_shape_lacks_key(id, key), }; let slot = (id as usize).wrapping_mul(0x9E37_79B9) >> 26 & (VERDICT_CACHE_LEN - 1); - let cached = VERDICT_CACHE.with(|c| c.get()[slot]); + // Index in place: `Cell::get` would copy the whole 64-entry array. + // SAFETY: this agent's own cell; no reference to it outlives the read. + let cached = VERDICT_CACHE.with(|c| unsafe { (*c.as_ptr())[slot] }); let mask = if cached.0 == id { cached.1 } else { @@ -339,11 +390,8 @@ pub(crate) fn function_shape_inherits_from_function_prototype(id: u32, key: &[u8 } else { 0 }; - VERDICT_CACHE.with(|c| { - let mut all = c.get(); - all[slot] = (id, mask); - c.set(all); - }); + // SAFETY: as above; a single-entry store in place. + VERDICT_CACHE.with(|c| unsafe { (*c.as_ptr())[slot] = (id, mask) }); mask }; mask & bit != 0 diff --git a/crates/perry-runtime/src/dyn_eval/expr.rs b/crates/perry-runtime/src/dyn_eval/expr.rs index c2b77405b5..d1024c29dc 100644 --- a/crates/perry-runtime/src/dyn_eval/expr.rs +++ b/crates/perry-runtime/src/dyn_eval/expr.rs @@ -808,8 +808,11 @@ fn eval_call(ctx: &Ctx, c: &ast::CallExpr, env_idx: usize) -> f64 { } let callee_idx = (!ctx.strings_allowed && (crate::object::js_value_is_heap_object(root_get(obj_idx)) - || (root_get(obj_idx).to_bits() >> 48) == 0x7FFE)) - .then(|| root_push(bridge::get_member(root_get(obj_idx), &name))); + || crate::object::class_value::legacy_class_value_word( + root_get(obj_idx).to_bits(), + ) + .is_some())) + .then(|| root_push(bridge::get_member(root_get(obj_idx), &name))); let codegen_blocked = !ctx.strings_allowed && ((name == "constructor" && crate::object::value_is_callable(root_get(obj_idx))) @@ -851,10 +854,11 @@ fn eval_call(ctx: &Ctx, c: &ast::CallExpr, env_idx: usize) -> f64 { let key_idx = root_push(key); let callee_idx = (!ctx.strings_allowed && (crate::object::js_value_is_heap_object(root_get(obj_idx)) - || (root_get(obj_idx).to_bits() >> 48) == 0x7FFE)) - .then(|| { - root_push(bridge::get_index(root_get(obj_idx), root_get(key_idx))) - }); + || crate::object::class_value::legacy_class_value_word( + root_get(obj_idx).to_bits(), + ) + .is_some())) + .then(|| root_push(bridge::get_index(root_get(obj_idx), root_get(key_idx)))); let codegen_blocked = callee_idx.is_some_and(|idx| is_string_codegen_callee(ctx, root_get(idx))); let method = bridge::read_string(root_get(key_idx)).unwrap_or_default(); diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index e44e251470..7890d5dd10 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -1213,6 +1213,8 @@ pub fn gc_init() { // capture heap words, so copied-minor must rewrite them after moving // captured young values or future cache hits miss on stale addresses. reg_scanner!(crate::closure::scan_singleton_closure_roots_mut); + // The per-agent class function objects (`object::class_value`). + reg_scanner!(crate::object::class_value::scan_class_value_roots_mut); reg_scanner!(crate::closure::scan_closure_dynamic_props_roots_mut); // #8393: built-in prototype methods carry per-closure identity metadata // keyed by their raw heap address. Copying minor GC moves those closures; diff --git a/crates/perry-runtime/src/gc/tests/copying/latch.rs b/crates/perry-runtime/src/gc/tests/copying/latch.rs index 88b464e752..22d5923c8d 100644 --- a/crates/perry-runtime/src/gc/tests/copying/latch.rs +++ b/crates/perry-runtime/src/gc/tests/copying/latch.rs @@ -330,6 +330,16 @@ fn pin_object_non_young_call_sites_are_never_young() { ever young, pin_object_non_young there would be memory corruption" ); + // `object/class_value.rs` pins each class function object, which it + // allocates born-tenured in the OLD arena. + let class_fn = crate::object::class_value::class_value_ptr(0x7A11); + let cf_header = header_from_user_ptr(class_fn as *const u8) as *mut GcHeader; + assert!( + !crate::gc::pin::pin_constrains_copying_minor_for_tests(cf_header), + "a class function object is born in the old arena; if it were ever \ + young, pin_object_non_young there would be memory corruption" + ); + // Control: a plain nursery object IS young, so the predicate the two // assertions above rely on is not vacuously false for everything. let young = young_leaf(); diff --git a/crates/perry-runtime/src/gc/tests/cycle_state.rs b/crates/perry-runtime/src/gc/tests/cycle_state.rs index 5a292815b5..3251a3fdeb 100644 --- a/crates/perry-runtime/src/gc/tests/cycle_state.rs +++ b/crates/perry-runtime/src/gc/tests/cycle_state.rs @@ -564,7 +564,7 @@ fn root_scan_slices_many_registered_class_side_table_roots_with_tiny_budget() { const ROOTS: usize = 32; let children = (0..ROOTS).map(|_| young_leaf()).collect::>(); for (idx, &child) in children.iter().enumerate() { - crate::object::test_seed_class_dynamic_prop_root( + crate::object::test_seed_class_prototype_method_root( 0x5300 + idx as u32, "root", string_bits(child), diff --git a/crates/perry-runtime/src/gc/tests/global_sink_isolation.rs b/crates/perry-runtime/src/gc/tests/global_sink_isolation.rs index 654c165d10..b848987566 100644 --- a/crates/perry-runtime/src/gc/tests/global_sink_isolation.rs +++ b/crates/perry-runtime/src/gc/tests/global_sink_isolation.rs @@ -98,7 +98,7 @@ fn the_probe_catches_a_bare_process_global_sink() { // --------------------------------------------------------------------------- /// `symbol::test_clear_symbol_side_table_roots` — `SYMBOL_PROPERTIES`, -/// `SYMBOL_PROPERTY_ATTRS`, `CLASS_STATIC_SYMBOLS`, `SYMBOL_ACCESSOR_PROPERTIES` +/// `SYMBOL_PROPERTY_ATTRS`, `SYMBOL_ACCESSOR_PROPERTIES` /// and the `SYMBOL_POINTERS` rebuild. /// /// The largest reader cluster in the survey behind #7672: 14 tests populate one diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/callback_scanners.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/callback_scanners.rs index 28c71731df..03929ff367 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/callback_scanners.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/callback_scanners.rs @@ -1325,7 +1325,6 @@ fn test_gc_init_mutable_scanner_families_rewrite_runtime_slots() { ); crate::object::test_seed_transition_cache_root(fixture.nursery_addr()); crate::object::test_seed_object_cache_roots([fixture.nursery_bits; 7], fixture.nursery_i64()); - crate::object::test_seed_class_dynamic_prop_root(0x5501, "dyn", fixture.nursery_bits); crate::object::test_seed_class_prototype_method_root(0x5501, "proto", fixture.nursery_bits); crate::object::test_seed_class_prototype_method_value_root( 0x5501, @@ -1477,10 +1476,6 @@ fn test_gc_init_mutable_scanner_families_rewrite_runtime_slots() { crate::object::test_object_cache_roots(), ([fixture.old_bits; 7], fixture.old_addr() as i64) ); - assert_eq!( - crate::object::test_class_dynamic_prop_root_bits(0x5501, "dyn"), - fixture.old_bits - ); assert_eq!( crate::object::test_class_prototype_method_root_bits(0x5501, "proto"), fixture.old_bits diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/side_table_scanners.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/side_table_scanners.rs index 4e14a61002..1a1943d11f 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/side_table_scanners.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/side_table_scanners.rs @@ -64,7 +64,7 @@ fn test_implicit_this_root_scanner_marks_and_rewrites() { #[test] fn test_class_side_table_scanner_marks_values_but_not_function_keys() { let _guard = GcTestIsolationGuard::new(); - // `dynamic_value`/`prototype_value`/`cached_value`/`prototype_object` are + // `prototype_value`/`cached_value`/`prototype_object` are // all live across the two `arena_alloc_gc` calls below (`parent_closure`, // `function_key`), and `parent_closure` is live across `function_key`'s — // any of those allocations can reach the block-full slow path's @@ -74,7 +74,6 @@ fn test_class_side_table_scanner_marks_values_but_not_function_keys() { clear_mark_seeds(); crate::object::test_clear_class_side_table_roots(); - let dynamic_value = young_leaf(); let prototype_value = young_leaf(); let cached_value = young_leaf(); let prototype_object = crate::object::js_object_alloc(0, 0) as usize; @@ -93,7 +92,6 @@ fn test_class_side_table_scanner_marks_values_but_not_function_keys() { init_test_closure(function_key as *mut u8); } - crate::object::test_seed_class_dynamic_prop_root(0x5201, "dyn", string_bits(dynamic_value)); crate::object::test_seed_class_prototype_method_root( 0x5201, "proto", @@ -111,7 +109,6 @@ fn test_class_side_table_scanner_marks_values_but_not_function_keys() { let valid_ptrs = build_valid_pointer_set(); crate::object::scan_class_side_table_roots_mut(&mut RuntimeRootVisitor::for_mark(&valid_ptrs)); - assert_marked_user_ptr(dynamic_value, "dynamic class property value"); assert_marked_user_ptr(prototype_value, "prototype method value"); assert_marked_user_ptr(cached_value, "cached bound prototype method value"); assert_marked_user_ptr(prototype_object, "prototype-object side-table value"); @@ -159,7 +156,6 @@ fn test_registered_class_side_table_scanner_rewrites_values_and_function_keys() let value_old_bits = ptr_bits(value_old as usize); let key_bits = ptr_bits(key_user as usize); let key_old_bits = ptr_bits(key_old as usize); - crate::object::test_seed_class_dynamic_prop_root(0x5202, "dyn", value_bits); crate::object::test_seed_class_prototype_method_root(0x5202, "proto", value_bits); crate::object::test_seed_class_prototype_method_value_root(0x5202, "bound", value_bits); crate::object::test_seed_class_prototype_object_root(0x5202, value_user as usize); @@ -168,10 +164,6 @@ fn test_registered_class_side_table_scanner_rewrites_values_and_function_keys() rewrite_mutable_registered_roots(&valid_ptrs); - assert_eq!( - crate::object::test_class_dynamic_prop_root_bits(0x5202, "dyn"), - value_old_bits - ); assert_eq!( crate::object::test_class_prototype_method_root_bits(0x5202, "proto"), value_old_bits diff --git a/crates/perry-runtime/src/node_vm.rs b/crates/perry-runtime/src/node_vm.rs index f5d565b621..dd25c08d8f 100644 --- a/crates/perry-runtime/src/node_vm.rs +++ b/crates/perry-runtime/src/node_vm.rs @@ -243,6 +243,10 @@ pub(crate) fn compiled_function_source_for_closure(closure: usize) -> Option String { + // A class function object renders its class's retained source. + if let Some(class_id) = crate::object::class_value::class_closure_id(closure) { + return crate::object::class_ref_to_string(class_id).into_owned(); + } compiled_function_source_for_closure(closure).unwrap_or_else(|| { let closure_ptr = closure as *const ClosureHeader; let func_ptr = unsafe { diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index b7f902334e..279fb64e26 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -95,21 +95,20 @@ pub(crate) use state::{ class_prototype_method_value_cache_root_store, class_prototype_object_addr_index_contains, class_prototype_object_addr_index_rekey, class_prototype_object_root_store, class_ref_dynamic_prop_root_store, class_register_declared_static_global_slot, - class_static_defined_attrs, class_static_prototype, class_static_prototype_is_nulled, - class_static_prototype_root_clear, class_static_prototype_root_store, - class_static_set_defined_attrs, class_unmark_key_deleted, decl_prototype_identity_id, - global_object_prototype_bits, is_bound_native_constructor_closure_value, - is_non_constructable_builtin_function_value, parent_closure_in_chain, - throw_non_constructable_builtin_function, + class_static_alias_sync, class_static_clear_defined_attrs, class_static_defined_attrs, + class_static_prototype, class_static_prototype_is_nulled, class_static_prototype_root_clear, + class_static_prototype_root_store, class_static_set_defined_attrs, class_unmark_key_deleted, + decl_prototype_identity_id, global_object_prototype_bits, + is_bound_native_constructor_closure_value, is_non_constructable_builtin_function_value, + parent_closure_in_chain, throw_non_constructable_builtin_function, CLASS_OBJECT_EVER, }; pub use state::{ AccessorDecl, ClassVTable, VTableMethodEntry, CLASS_DECL_PROTOTYPE_OBJECTS, CLASS_DYNAMIC_PARENT_VALUE, CLASS_METHOD_BIND_LENGTHS, CLASS_OBJECT_VALUES, CLASS_PARENT_CLOSURES, CLASS_PROTOTYPE_METHOD_NONENUM, CLASS_PROTOTYPE_OBJECTS, CLASS_STATIC_ACCESSORS, CLASS_STATIC_METHODS, CLASS_STATIC_METHOD_BIND_LENGTHS, - CLASS_STATIC_PROTOTYPES, CLASS_STRING_MEMBER_ORDERS, CLASS_SYMBOL_ACCESSORS, - CLASS_SYMBOL_MEMBER_ORDERS, CLASS_SYMBOL_METHODS, CLASS_VTABLE_REGISTRY, FUNCTION_CLASS_IDS, - REGISTERED_CLASS_IDS, + CLASS_STRING_MEMBER_ORDERS, CLASS_SYMBOL_ACCESSORS, CLASS_SYMBOL_MEMBER_ORDERS, + CLASS_SYMBOL_METHODS, CLASS_VTABLE_REGISTRY, FUNCTION_CLASS_IDS, REGISTERED_CLASS_IDS, }; // ── prototype_objects.rs ──────────────────────────────────────────────────── diff --git a/crates/perry-runtime/src/object/class_registry/class_meta.rs b/crates/perry-runtime/src/object/class_registry/class_meta.rs index 76b9fcfe94..960939aade 100644 --- a/crates/perry-runtime/src/object/class_registry/class_meta.rs +++ b/crates/perry-runtime/src/object/class_registry/class_meta.rs @@ -52,11 +52,14 @@ pub unsafe extern "C" fn js_register_class_name(class_id: u32, name_ptr: *const Ok(s) => s.to_string(), Err(_) => return, }; - let mut guard = CLASS_NAMES.write().unwrap(); - if guard.is_none() { - *guard = Some(new_ptr_hash_map()); + { + let mut guard = CLASS_NAMES.write().unwrap(); + if guard.is_none() { + *guard = Some(new_ptr_hash_map()); + } + guard.as_mut().unwrap().insert(class_id, name); } - guard.as_mut().unwrap().insert(class_id, name); + crate::object::class_value::note_intrinsic_registration(class_id, "name"); } /// Look up the user-visible name of a registered class. Returns `None` @@ -214,11 +217,14 @@ pub extern "C" fn js_register_class_length(class_id: u32, length: u32) { if class_id == 0 { return; } - let mut guard = CLASS_LENGTHS.write().unwrap(); - if guard.is_none() { - *guard = Some(new_ptr_hash_map()); + { + let mut guard = CLASS_LENGTHS.write().unwrap(); + if guard.is_none() { + *guard = Some(new_ptr_hash_map()); + } + guard.as_mut().unwrap().insert(class_id, length); } - guard.as_mut().unwrap().insert(class_id, length); + crate::object::class_value::note_intrinsic_registration(class_id, "length"); } pub fn class_length_for_id(class_id: u32) -> Option { @@ -254,8 +260,8 @@ pub(crate) fn dispatch_diag_enabled() -> bool { fn describe_dispatch_receiver(recv: f64) -> String { let bits = recv.to_bits(); let top16 = bits >> 48; - if top16 == 0x7FFE { - let cid = (bits & 0xFFFF_FFFF) as u32; + let _ = top16; + if let Some(cid) = crate::object::class_value::legacy_class_value_word(bits) { return match class_name_for_id(cid) { Some(n) => format!("class-ref `{}` (id {})", n, cid), None => format!("class-ref (id {})", cid), diff --git a/crates/perry-runtime/src/object/class_registry/construct.rs b/crates/perry-runtime/src/object/class_registry/construct.rs index b56a78ed0f..383672d44c 100644 --- a/crates/perry-runtime/src/object/class_registry/construct.rs +++ b/crates/perry-runtime/src/object/class_registry/construct.rs @@ -275,6 +275,13 @@ pub unsafe extern "C-unwind" fn js_new_function_construct( args_ptr: *const f64, args_len: usize, ) -> f64 { + // A class value (its function object, or the legacy immediate) constructs + // its class: decided first, one closure probe, before the exotic arms. + if let Some(class_cid) = constructor_class_ref_id(func_value) { + return construct_registered_class_ref( + class_cid, class_cid, func_value, args_ptr, args_len, + ); + } // `new ()` is a TypeError — a primitive is never a constructor // (`new undefined()`, `new 5n()`, `new "s"()`, `new true()`). Checked via // the unambiguous NaN-box tags only (NOT `is_number`, whose f64 range @@ -1250,10 +1257,7 @@ pub unsafe extern "C" fn js_new_function_construct_apply(func_value: f64, args_a } fn constructor_class_ref_id(value: f64) -> Option { - if super::super::class_prototype_ref_id(value).is_some() { - return None; - } - super::super::class_ref_id(value) + super::super::class_value::class_value_id(value) } /// Spec `IsConstructor(value)` — used by `NewPromiseCapability` (the Promise diff --git a/crates/perry-runtime/src/object/class_registry/evaluation_heritage.rs b/crates/perry-runtime/src/object/class_registry/evaluation_heritage.rs index 1dc6718a87..82bfdabc25 100644 --- a/crates/perry-runtime/src/object/class_registry/evaluation_heritage.rs +++ b/crates/perry-runtime/src/object/class_registry/evaluation_heritage.rs @@ -148,8 +148,7 @@ pub(crate) fn scan_active_class_evaluations_mut(visitor: &mut crate::gc::Runtime /// class value with its own pinned heritage, and rejecting those would break the /// factory chains that lowering already models correctly. pub(crate) fn is_self_heritage_value(class_id: u32, parent_bits: u64) -> bool { - const INT32_TAG: u64 = 0x7FFE_0000_0000_0000; - parent_bits & 0xFFFF_0000_0000_0000 == INT32_TAG && parent_bits as u32 == class_id + crate::object::class_value::class_value_id_bits(parent_bits) == Some(class_id) } /// #10624: monotone "has any class object ever pinned its own heritage" diff --git a/crates/perry-runtime/src/object/class_registry/gc_roots.rs b/crates/perry-runtime/src/object/class_registry/gc_roots.rs index 2d5178493a..eff9940f46 100644 --- a/crates/perry-runtime/src/object/class_registry/gc_roots.rs +++ b/crates/perry-runtime/src/object/class_registry/gc_roots.rs @@ -2,10 +2,6 @@ use super::*; #[derive(Clone)] enum ClassSideTableRootSlot { - DynamicProp { - class_id: u32, - name: String, - }, PrototypeMethod { class_id: u32, name: String, @@ -20,9 +16,6 @@ enum ClassSideTableRootSlot { DeclPrototypeObject { class_id: u32, }, - StaticPrototype { - class_id: u32, - }, ParentClosure { class_id: u32, }, @@ -81,15 +74,6 @@ pub fn scan_class_side_table_roots(mark: &mut dyn FnMut(f64)) { } pub fn scan_class_side_table_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { - CLASS_DYNAMIC_PROPS.with(|m| { - let mut m = m.borrow_mut(); - for props in m.values_mut() { - for value in props.values_mut() { - visitor.visit_nanbox_f64_slot(value); - } - } - }); - CLASS_PROTOTYPE_METHODS.with(|table| { if let Ok(mut guard) = table.write() { if let Some(map) = guard.as_mut() { @@ -131,16 +115,6 @@ pub fn scan_class_side_table_roots_mut(visitor: &mut crate::gc::RuntimeRootVisit } }); - CLASS_STATIC_PROTOTYPES.with(|table| { - if let Ok(mut guard) = table.write() { - if let Some(map) = guard.as_mut() { - for proto_addr in map.values_mut() { - visitor.visit_usize_slot(proto_addr); - } - } - } - }); - CLASS_PARENT_CLOSURES.with(|table| { if let Ok(mut guard) = table.write() { if let Some(map) = guard.as_mut() { @@ -235,18 +209,6 @@ fn scan_class_symbol_member_keys_mut(visitor: &mut crate::gc::RuntimeRootVisitor fn class_side_table_root_snapshot() -> Vec { let mut slots = Vec::new(); - CLASS_DYNAMIC_PROPS.with(|m| { - let m = m.borrow(); - for (&class_id, props) in m.iter() { - for name in props.keys() { - slots.push(ClassSideTableRootSlot::DynamicProp { - class_id, - name: name.clone(), - }); - } - } - }); - CLASS_PROTOTYPE_METHODS.with(|table| { if let Ok(guard) = table.read() { if let Some(map) = guard.as_ref() { @@ -292,16 +254,6 @@ fn class_side_table_root_snapshot() -> Vec { } }); - CLASS_STATIC_PROTOTYPES.with(|table| { - if let Ok(guard) = table.read() { - if let Some(map) = guard.as_ref() { - for &class_id in map.keys() { - slots.push(ClassSideTableRootSlot::StaticPrototype { class_id }); - } - } - } - }); - CLASS_PARENT_CLOSURES.with(|table| { if let Ok(guard) = table.read() { if let Some(map) = guard.as_ref() { @@ -385,17 +337,6 @@ fn scan_class_side_table_root_slot( slot: &ClassSideTableRootSlot, ) { match slot { - ClassSideTableRootSlot::DynamicProp { class_id, name } => { - CLASS_DYNAMIC_PROPS.with(|m| { - if let Some(value) = m - .borrow_mut() - .get_mut(class_id) - .and_then(|props| props.get_mut(name)) - { - visitor.visit_nanbox_f64_slot(value); - } - }); - } ClassSideTableRootSlot::PrototypeMethod { class_id, name } => { CLASS_PROTOTYPE_METHODS.with(|table| { if let Ok(mut guard) = table.write() { @@ -438,15 +379,6 @@ fn scan_class_side_table_root_slot( } }); } - ClassSideTableRootSlot::StaticPrototype { class_id } => { - CLASS_STATIC_PROTOTYPES.with(|table| { - if let Ok(mut guard) = table.write() { - if let Some(proto_addr) = guard.as_mut().and_then(|map| map.get_mut(class_id)) { - visitor.visit_usize_slot(proto_addr); - } - } - }); - } ClassSideTableRootSlot::ParentClosure { class_id } => { CLASS_PARENT_CLOSURES.with(|table| { if let Ok(mut guard) = table.write() { @@ -657,9 +589,7 @@ pub(crate) fn test_clear_class_side_table_roots() { // Disambiguate: CLASS_DELETED_KEYS is reachable via both `use super::*` // and `use crate::object::*`; name the canonical definition explicitly. use super::state::CLASS_DELETED_KEYS; - CLASS_DYNAMIC_PROPS.with(|m| m.borrow_mut().clear()); super::state::CLASS_DECLARED_STATIC_GLOBAL_SLOTS.with(|m| m.borrow_mut().clear()); - crate::object::CLASS_DYNAMIC_PROP_ORDER.with(|order| order.borrow_mut().clear()); CLASS_DELETED_KEYS.with(|m| m.borrow_mut().clear()); CLASS_PROTOTYPE_METHOD_VALUES.with(|cache| cache.borrow_mut().clear()); CLASS_PROTOTYPE_METHODS.with(|table| { @@ -688,11 +618,6 @@ pub(crate) fn test_clear_class_side_table_roots() { *guard = None; } }); - CLASS_STATIC_PROTOTYPES.with(|table| { - if let Ok(mut guard) = table.write() { - *guard = None; - } - }); CLASS_PARENT_CLOSURES.with(|table| { if let Ok(mut guard) = table.write() { *guard = None; @@ -731,13 +656,9 @@ pub(crate) fn test_seed_class_dynamic_prop_root(class_id: u32, name: &str, value #[cfg(test)] pub(crate) fn test_class_dynamic_prop_root_bits(class_id: u32, name: &str) -> u64 { - CLASS_DYNAMIC_PROPS.with(|m| { - m.borrow() - .get(&class_id) - .and_then(|props| props.get(name)) - .map(|value| value.to_bits()) - .unwrap_or(0) - }) + crate::object::class_value::class_static_get(class_id, name) + .map(f64::to_bits) + .unwrap_or(0) } #[cfg(test)] diff --git a/crates/perry-runtime/src/object/class_registry/parent_static.rs b/crates/perry-runtime/src/object/class_registry/parent_static.rs index 73ade58983..47e1fc3b30 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static.rs @@ -91,6 +91,9 @@ pub(crate) fn dynamic_value_class_id(value: f64) -> u32 { _ => 0, } } + } else if let Some(class_id) = crate::object::class_value::class_value_id_bits(bits) { + // A class function object names its class. + class_id } else if tag == POINTER_TAG { // Object instance: read class_id from the ObjectHeader. let ptr = crate::value::js_nanbox_get_pointer(value) as *const ObjectHeader; @@ -500,7 +503,6 @@ pub extern "C" fn js_get_dynamic_parent_value(class_id: u32) -> f64 { /// the constructor currently running inherits. pub(crate) fn template_dynamic_parent_value(class_id: u32) -> f64 { const TAG_UNDEFINED: u64 = 0x7FFC_0000_0000_0001; - const INT32_TAG: u64 = 0x7FFE_0000_0000_0000; if class_id == 0 { return f64::from_bits(TAG_UNDEFINED); } @@ -524,7 +526,7 @@ pub(crate) fn template_dynamic_parent_value(class_id: u32) -> f64 { // snapshot caps by the signature split. if let Some(parent_cid) = crate::object::get_parent_class_id(class_id) { if parent_cid != 0 { - return f64::from_bits(INT32_TAG | parent_cid as u64); + return crate::object::class_value::class_value(parent_cid); } } f64::from_bits(TAG_UNDEFINED) @@ -608,16 +610,22 @@ pub unsafe extern "C" fn js_register_class_static_method( Ok(s) => s.to_string(), Err(_) => return, }; - let mut guard = CLASS_STATIC_METHODS.write().unwrap(); - if guard.is_none() { - *guard = Some(crate::fast_hash::new_ptr_hash_map()); + { + let mut guard = CLASS_STATIC_METHODS.write().unwrap(); + if guard.is_none() { + *guard = Some(crate::fast_hash::new_ptr_hash_map()); + } + guard + .as_mut() + .unwrap() + .entry(class_id as u32) + .or_default() + .insert( + name.clone(), + (func_ptr as usize, param_count as u32, has_rest != 0), + ); } - guard - .as_mut() - .unwrap() - .entry(class_id as u32) - .or_default() - .insert(name, (func_ptr as usize, param_count as u32, has_rest != 0)); + crate::object::class_value::note_intrinsic_registration(class_id as u32, &name); } fn property_key_string(key: f64) -> Option { @@ -744,16 +752,17 @@ pub unsafe extern "C" fn js_register_class_computed_method( throw_object_type_error(b"Classes may not have a static property named 'prototype'"); } if is_static != 0 { - let mut guard = CLASS_STATIC_METHODS.write().unwrap(); - if guard.is_none() { - *guard = Some(crate::fast_hash::new_ptr_hash_map()); + { + let mut guard = CLASS_STATIC_METHODS.write().unwrap(); + if guard.is_none() { + *guard = Some(crate::fast_hash::new_ptr_hash_map()); + } + guard.as_mut().unwrap().entry(class_id).or_default().insert( + name.clone(), + (func_ptr as usize, param_count as u32, has_rest != 0), + ); } - guard - .as_mut() - .unwrap() - .entry(class_id) - .or_default() - .insert(name, (func_ptr as usize, param_count as u32, has_rest != 0)); + crate::object::class_value::note_intrinsic_registration(class_id, &name); } else { let mut registry = CLASS_VTABLE_REGISTRY.write().unwrap(); if registry.is_none() { @@ -848,23 +857,26 @@ pub unsafe extern "C" fn js_register_class_computed_accessor( drop(registry); super::decl_accessors::note_instance_accessor_registered(class_id, &name); } else { - let mut guard = CLASS_STATIC_ACCESSORS.write().unwrap(); - if guard.is_none() { - *guard = Some(crate::fast_hash::new_ptr_hash_map()); - } - let entry = guard - .as_mut() - .unwrap() - .entry(class_id) - .or_default() - .entry(name) - .or_insert((0, 0)); - if getter_ptr != 0 { - entry.0 = getter_ptr as usize; - } - if setter_ptr != 0 { - entry.1 = setter_ptr as usize; + { + let mut guard = CLASS_STATIC_ACCESSORS.write().unwrap(); + if guard.is_none() { + *guard = Some(crate::fast_hash::new_ptr_hash_map()); + } + let entry = guard + .as_mut() + .unwrap() + .entry(class_id) + .or_default() + .entry(name.clone()) + .or_insert((0, 0)); + if getter_ptr != 0 { + entry.0 = getter_ptr as usize; + } + if setter_ptr != 0 { + entry.1 = setter_ptr as usize; + } } + crate::object::class_value::note_intrinsic_registration(class_id, &name); } } VTABLE_GEN.fetch_add(1, Ordering::Release); @@ -1611,8 +1623,9 @@ pub unsafe extern "C" fn js_class_static_method_call( // class_id stamped on a POINTER class object's ObjectHeader. let bits = receiver.to_bits(); let top16 = bits >> 48; - let class_id = if top16 == 0x7FFE { - (bits & 0xFFFF_FFFF) as u32 + let _ = top16; + let class_id = if let Some(cid) = crate::object::class_value::legacy_class_value_word(bits) { + cid } else if is_class_object_value(receiver) { let obj = crate::value::JSValue::from_bits(bits).as_pointer::(); js_object_get_class_id(obj) @@ -1686,8 +1699,7 @@ pub unsafe extern "C" fn js_class_static_method_call( let mut cid = class_id; let mut depth = 0u32; while cid != 0 && depth < 64 { - let field_val = CLASS_DYNAMIC_PROPS - .with(|m| m.borrow().get(&cid).and_then(|f| f.get(name).copied())); + let field_val = crate::object::class_value::class_static_get(cid, name); if let Some(v) = field_val { let fv = crate::value::JSValue::from_bits(v.to_bits()); if !fv.is_undefined() && !fv.is_null() { diff --git a/crates/perry-runtime/src/object/class_registry/parent_static/private_and_dynamic.rs b/crates/perry-runtime/src/object/class_registry/parent_static/private_and_dynamic.rs index 41a481238e..33bb296b6e 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static/private_and_dynamic.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static/private_and_dynamic.rs @@ -115,6 +115,11 @@ pub(crate) fn register_class_dynamic_static_accessor( return; } let key = dynamic_static_accessor_storage_key(owner, name); + // A property is data OR accessor: redefining an own static data property + // as an accessor removes the data slot from the class function object. + if !is_class_object_ptr(owner as *const u8) { + crate::object::class_value::class_static_remove(class_id, name); + } let existing = crate::object::get_accessor_descriptor(owner, &key).unwrap_or_default(); crate::object::set_accessor_descriptor( owner, @@ -150,6 +155,7 @@ pub(crate) fn register_class_dynamic_static_accessor( } else { class_static_set_defined_attrs(class_id, name, false, enumerable, configurable); } + crate::object::class_registry::class_static_alias_sync(class_id, name); } pub(crate) fn class_dynamic_static_accessor_descriptor( diff --git a/crates/perry-runtime/src/object/class_registry/prototype_methods.rs b/crates/perry-runtime/src/object/class_registry/prototype_methods.rs index 8667bb0c8e..3e62dd6f8c 100644 --- a/crates/perry-runtime/src/object/class_registry/prototype_methods.rs +++ b/crates/perry-runtime/src/object/class_registry/prototype_methods.rs @@ -47,6 +47,10 @@ pub unsafe extern "C" fn js_class_register_static_field( }; class_register_declared_static_global_slot(class_id, name, global_slot); class_dynamic_prop_root_store(class_id, name, value); + // DefineField: a static field is an ordinary writable, enumerable, + // configurable own property — also when it replaces the class's + // intrinsic `name` / `length`. + crate::object::class_value::note_static_field_defined(class_id, name); } /// Read a computed instance-field key resolved at ClassDefinitionEvaluation. diff --git a/crates/perry-runtime/src/object/class_registry/registration.rs b/crates/perry-runtime/src/object/class_registry/registration.rs index 41685eed3a..a835e38fba 100644 --- a/crates/perry-runtime/src/object/class_registry/registration.rs +++ b/crates/perry-runtime/src/object/class_registry/registration.rs @@ -499,21 +499,24 @@ unsafe fn register_class_static_accessor_half( Err(_) => return, } }; - let mut guard = CLASS_STATIC_ACCESSORS.write().unwrap(); - if guard.is_none() { - *guard = Some(crate::fast_hash::new_ptr_hash_map()); - } - let entry = guard - .as_mut() - .unwrap() - .entry(class_id as u32) - .or_default() - .entry(name) - .or_insert((0, 0)); - if is_getter { - entry.0 = func_ptr as usize; - } else { - entry.1 = func_ptr as usize; + { + let mut guard = CLASS_STATIC_ACCESSORS.write().unwrap(); + if guard.is_none() { + *guard = Some(crate::fast_hash::new_ptr_hash_map()); + } + let entry = guard + .as_mut() + .unwrap() + .entry(class_id as u32) + .or_default() + .entry(name.clone()) + .or_insert((0, 0)); + if is_getter { + entry.0 = func_ptr as usize; + } else { + entry.1 = func_ptr as usize; + } } VTABLE_GEN.fetch_add(1, Ordering::Release); + crate::object::class_value::note_intrinsic_registration(class_id as u32, &name); } diff --git a/crates/perry-runtime/src/object/class_registry/state.rs b/crates/perry-runtime/src/object/class_registry/state.rs index c9a7a07b54..f55344c3a5 100644 --- a/crates/perry-runtime/src/object/class_registry/state.rs +++ b/crates/perry-runtime/src/object/class_registry/state.rs @@ -91,55 +91,58 @@ pub(crate) fn class_unmark_key_deleted(class_id: u32, key: &str) { /// (`class C { m() {} static m = 1 }` — both land under one class_id) keeps /// whatever behaviour it had. pub(crate) fn class_dynamic_prop_root_store(class_id: u32, name: &str, value: f64) { - let nothing_deleted = CLASS_DELETED_KEYS.with(|m| m.borrow().is_empty()); - if nothing_deleted { - let updated = CLASS_DYNAMIC_PROPS.with(|m| { - match m - .borrow_mut() - .get_mut(&class_id) - .and_then(|props| props.get_mut(name)) - { - Some(slot) => { - *slot = value; - true - } - None => false, - } - }); - if updated { - crate::gc::runtime_write_barrier_root_nanbox(value.to_bits()); - return; - } - } else { - // Un-marking re-exposes a previously `delete`d prototype key to - // `class_instance_has_member` / `lookup_prototype_method` — the one - // direction a cached "this chain resolves nothing" verdict must not - // survive (#10696). - CLASS_DELETED_KEYS.with(|m| { - if let Some(keys) = m.borrow_mut().get_mut(&class_id) { - keys.remove(name); - } - }); + // Un-marking re-exposes a previously `delete`d prototype key to + // `class_instance_has_member` / `lookup_prototype_method` — the one + // direction a cached "this chain resolves nothing" verdict must not + // survive (#10696). + let was_deleted = CLASS_DELETED_KEYS.with(|m| { + m.borrow_mut() + .get_mut(&class_id) + .is_some_and(|keys| keys.remove(name)) + }); + if was_deleted { super::class_lookup_surface_gen_bump(); } - CLASS_DYNAMIC_PROPS.with(|m| { - let created = m - .borrow_mut() - .entry(class_id) - .or_default() - .insert(name.to_string(), value) - .is_none(); - if created { - crate::object::CLASS_DYNAMIC_PROP_ORDER.with(|order| { - order - .borrow_mut() - .entry(class_id) - .or_default() - .push(name.to_string()); - }); - } - }); - crate::gc::runtime_write_barrier_root_nanbox(value.to_bits()); + // The class function object's own-property bag (barriered, traced). + crate::object::class_value::class_static_set(class_id, name, value); + class_static_alias_sync(class_id, name); +} + +/// Keep a declared static field's compiled alias (its `@perry_static_*` +/// global, which statically lowered `C.x` reads and writes) coherent with the +/// class function object's own property: the global holds the value while +/// `name` is a plain writable own data property, and `TAG_HOLE` otherwise — +/// deleted, an accessor, or read-only — which sends compiled reads and writes +/// to the generic [[Get]] / [[Set]] (`js_class_static_field_get` / `_put`). +/// Called after every mutation of a class static. +pub(crate) fn class_static_alias_sync(class_id: u32, name: &str) { + let Some(slot) = CLASS_DECLARED_STATIC_GLOBAL_SLOTS.with(|slots| { + slots + .borrow() + .get(&class_id) + .and_then(|f| f.get(name)) + .copied() + }) else { + return; + }; + let plain = !class_is_key_deleted(class_id, name) + && class_static_defined_attrs(class_id, name).is_none_or(|(writable, _, _)| writable) + && class_own_static_accessor_ptrs(class_id, name).is_none() + && super::class_dynamic_static_accessor_descriptor( + class_id, + name, + crate::object::class_value::class_value(class_id), + ) + .is_none(); + let value = plain + .then(|| crate::object::class_value::class_static_get(class_id, name)) + .flatten() + .unwrap_or(f64::from_bits(crate::value::TAG_HOLE)); + // SAFETY: codegen only registers addresses of process-lifetime LLVM + // globals, and those slots are mutable GC roots. + unsafe { + crate::gc::runtime_store_root_nanbox_f64_raw_slot(slot as *mut f64, value); + } } /// Associate a declared static field's runtime-table entry with the LLVM @@ -168,20 +171,7 @@ pub(crate) fn class_register_declared_static_global_slot( /// static, through its compiled backing cell as well. This is the terminal /// write used by `C.x`, `C["x"]`, and `C[key]` runtime assignment paths. pub(crate) fn class_ref_dynamic_prop_root_store(class_id: u32, name: &str, value: f64) { - let global_slot = CLASS_DECLARED_STATIC_GLOBAL_SLOTS.with(|slots| { - slots - .borrow() - .get(&class_id) - .and_then(|fields| fields.get(name)) - .copied() - }); - if let Some(global_slot) = global_slot { - // SAFETY: codegen only registers addresses of process-lifetime LLVM - // globals, and those slots are mutable GC roots. - unsafe { - crate::gc::runtime_store_root_nanbox_f64_raw_slot(global_slot as *mut f64, value); - } - } + // The store re-syncs the declared static's compiled alias. class_dynamic_prop_root_store(class_id, name, value); } @@ -191,11 +181,7 @@ pub(crate) fn class_ref_dynamic_prop_root_store(class_id: u32, name: &str, value /// constructor ref so `verifyProperty(C, "field", …)` sees a real data /// descriptor (test262 class/elements static-field-declaration & friends). pub(crate) fn class_own_static_field_value(class_id: u32, name: &str) -> Option { - CLASS_DYNAMIC_PROPS.with(|m| { - m.borrow() - .get(&class_id) - .and_then(|props| props.get(name).copied()) - }) + crate::object::class_value::class_static_get(class_id, name) } /// Enumerable own string keys of a class constructor: the static fields (and @@ -216,27 +202,10 @@ pub(crate) fn class_own_enumerable_field_names(class_id: u32) -> Vec { } pub(crate) fn class_own_dynamic_prop_names(class_id: u32) -> Vec { - let mut names = crate::object::CLASS_DYNAMIC_PROP_ORDER - .with(|order| order.borrow().get(&class_id).cloned().unwrap_or_default()); - CLASS_DYNAMIC_PROPS.with(|props| { - let props = props.borrow(); - let Some(props) = props.get(&class_id) else { - names.clear(); - return; - }; - names.retain(|name| props.contains_key(name)); - // Registries populated by older/native paths may predate the order - // side table. Keep those visible with a deterministic fallback. - let mut missing: Vec = props - .keys() - .filter(|name| !names.contains(name)) - .cloned() - .collect(); - missing.sort(); - names.extend(missing); - }); - names.retain(|key| !crate::object::is_internal_runtime_key(key)); - names + crate::object::class_value::class_static_entries(class_id) + .into_iter() + .map(|(name, _)| name) + .collect() } /// #7190: record a `defineProperty`-installed static key's attributes. Called @@ -256,6 +225,24 @@ pub(crate) fn class_static_set_defined_attrs( .or_default() .insert(name.to_string(), (writable, enumerable, configurable)); }); + class_static_alias_sync(class_id, name); +} + +/// Forget the recorded attributes of static `name` (it becomes an ordinary +/// writable, enumerable, configurable data property again) and re-sync its +/// compiled alias. A static FIELD definition does this: DefineField creates +/// the property with CreateDataPropertyOrThrow, replacing e.g. the class's +/// own intrinsic `name`. +pub(crate) fn class_static_clear_defined_attrs(class_id: u32, name: &str) { + let removed = crate::object::CLASS_STATIC_DEFINED_ATTRS.with(|m| { + m.borrow_mut() + .get_mut(&class_id) + .and_then(|k| k.remove(name)) + .is_some() + }); + if removed { + class_static_alias_sync(class_id, name); + } } /// `(writable, enumerable)` if this static key was installed by @@ -274,25 +261,12 @@ pub(crate) fn class_static_key_is_non_enumerable(class_id: u32, name: &str) -> b /// only — does not read the value, so it never invokes a static getter. Used by /// the `in` operator on a class ref (#6149). pub(crate) fn class_has_own_dynamic_prop(class_id: u32, name: &str) -> bool { - CLASS_DYNAMIC_PROPS.with(|m| { - m.borrow() - .get(&class_id) - .map(|props| props.contains_key(name)) - .unwrap_or(false) - }) + crate::object::class_value::class_static_get(class_id, name).is_some() } pub(crate) fn class_delete_own_dynamic_prop(class_id: u32, name: &str) { - CLASS_DYNAMIC_PROPS.with(|m| { - if let Some(props) = m.borrow_mut().get_mut(&class_id) { - props.remove(name); - } - }); - crate::object::CLASS_DYNAMIC_PROP_ORDER.with(|order| { - if let Some(names) = order.borrow_mut().get_mut(&class_id) { - names.retain(|existing| existing != name); - } - }); + crate::object::class_value::class_static_remove(class_id, name); + class_static_alias_sync(class_id, name); } pub(crate) fn class_prototype_method_value_cache_root_store( @@ -518,37 +492,7 @@ pub(crate) fn class_prototype_object_addr_index_rekey(old: usize, new: usize) { }); } -crate::perry_thread_local! { - /// The CONSTRUCTOR's `[[Prototype]]`, set by `Object.setPrototypeOf(Ctor, obj)` - /// on a declared class (perry represents those as INT32 ClassRefs, not heap - /// Function objects, so they have no closure prototype slot to write). - /// - /// Deliberately its own table. `CLASS_PROTOTYPE_OBJECTS` means "the object - /// INSTANCES of this class inherit from", and the method-dispatch and - /// field-read walks read it for exactly that purpose — parking a - /// constructor-side link there makes `new Ctor()` inherit the constructor's - /// statics and makes prototype-method mirroring write into the user's - /// object. Only the static-side lookups consult this table: - /// `js_object_get_field_by_name`'s ClassRef arm, the generic `in` presence - /// walk, static method dispatch, and `Object.getPrototypeOf`. - /// - /// Effect's `Schema.Opaque` is the motivating shape (`Schema.ts:1887`, - /// `:5874`): `class Opaque {}; Object.setPrototypeOf(Opaque, schema)`, then - /// `class Partial extends Opaque {}` reads `Partial.ast` through the chain. - /// - /// Stored as `usize` for the same Send + Sync reason as the tables above. - pub static CLASS_STATIC_PROTOTYPES: RwLock>> = RwLock::new(None); -} - -crate::perry_thread_local! { - /// Class ids whose constructor `[[Prototype]]` was explicitly set to `null` - /// (`Object.setPrototypeOf(Ctor, null)`). Absence from CLASS_STATIC_PROTOTYPES - /// alone cannot express this: "never linked" must still report the default - /// `Function.prototype`, while an explicit null must report `null`. Holds - /// class ids only, so the collector has nothing to trace here. - pub static CLASS_STATIC_PROTOTYPE_NULLED: RwLock>> = - RwLock::new(None); -} +crate::perry_thread_local! {} crate::perry_thread_local! { /// Lazily materialized `Class.prototype` objects for declared ES classes. @@ -652,12 +596,21 @@ crate::perry_thread_local! { pub static CLASS_OBJECT_VALUES: RwLock>> = RwLock::new(None); } +/// Monotone: has any per-evaluation class object (`ClassExprFresh`) been +/// recorded in this process? While clear, `class_object_value_for_cid` is +/// `None` for every class, so a read of a class function object's own data +/// property answers from its own-property object without consulting the +/// per-evaluation table first. +pub(crate) static CLASS_OBJECT_EVER: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + /// Store the marked class object for its template class id (see /// `CLASS_OBJECT_VALUES`). pub(crate) fn class_object_value_root_store(class_id: u32, obj_ptr: *mut ObjectHeader) { if class_id == 0 || obj_ptr.is_null() { return; } + CLASS_OBJECT_EVER.store(true, std::sync::atomic::Ordering::Relaxed); let bits = crate::value::js_nanbox_pointer(obj_ptr as i64).to_bits(); CLASS_OBJECT_VALUES.with(|table| { let mut guard = table.write().unwrap(); @@ -728,77 +681,54 @@ pub(crate) fn class_prototype_object_root_store(class_id: u32, proto_ptr: *mut O super::class_lookup_surface_gen_bump(); } +/// `Object.setPrototypeOf(Ctor, proto)`: the class function object's +/// recorded `[[Prototype]]` (its state record, a traced edge). pub(crate) fn class_static_prototype_root_store(class_id: u32, proto_ptr: *mut ObjectHeader) { if class_id == 0 || proto_ptr.is_null() { return; } - CLASS_STATIC_PROTOTYPES.with(|table| { - let mut guard = table.write().unwrap(); - if guard.is_none() { - *guard = Some(HashMap::new()); - } - guard.as_mut().unwrap().insert(class_id, proto_ptr as usize); - }); - CLASS_STATIC_PROTOTYPE_NULLED.with(|table| { - if let Ok(mut guard) = table.write() { - if let Some(set) = guard.as_mut() { - set.remove(&class_id); - } - } - }); - crate::gc::runtime_write_barrier_root_raw_ptr(proto_ptr); + let bits = crate::value::js_nanbox_pointer(proto_ptr as i64).to_bits(); + crate::closure::closure_set_static_prototype( + crate::object::class_value::class_value_ptr(class_id) as usize, + bits, + ); } +/// `Object.setPrototypeOf(Ctor, null)`. pub(crate) fn class_static_prototype_root_clear(class_id: u32) { if class_id == 0 { return; } - CLASS_STATIC_PROTOTYPES.with(|table| { - if let Ok(mut guard) = table.write() { - if let Some(map) = guard.as_mut() { - map.remove(&class_id); - } - } - }); - CLASS_STATIC_PROTOTYPE_NULLED.with(|table| { - let mut guard = table.write().unwrap(); - if guard.is_none() { - *guard = Some(std::collections::HashSet::new()); - } - guard.as_mut().unwrap().insert(class_id); - }); + crate::closure::closure_set_static_prototype( + crate::object::class_value::class_value_ptr(class_id) as usize, + crate::value::TAG_NULL, + ); } -/// True when `Object.setPrototypeOf(Ctor, null)` explicitly severed the -/// constructor's prototype chain, as opposed to never having linked one. -pub(crate) fn class_static_prototype_is_nulled(class_id: u32) -> bool { +fn class_recorded_prototype_bits(class_id: u32) -> Option { if class_id == 0 { - return false; + return None; } let class_id = crate::object::class_generic_origin(class_id).unwrap_or(class_id); - CLASS_STATIC_PROTOTYPE_NULLED.with(|table| { - table - .read() - .ok() - .and_then(|guard| guard.as_ref().map(|set| set.contains(&class_id))) - .unwrap_or(false) - }) + crate::closure::closure_static_prototype( + crate::object::class_value::class_value_ptr(class_id) as usize + ) +} + +/// True when `Object.setPrototypeOf(Ctor, null)` explicitly severed the +/// constructor's prototype chain, as opposed to never having linked one. +pub(crate) fn class_static_prototype_is_nulled(class_id: u32) -> bool { + class_recorded_prototype_bits(class_id) == Some(crate::value::TAG_NULL) } /// The constructor-side `[[Prototype]]` recorded for `class_id`, or null. pub(crate) fn class_static_prototype(class_id: u32) -> *mut ObjectHeader { - if class_id == 0 { - return std::ptr::null_mut(); - } - let class_id = crate::object::class_generic_origin(class_id).unwrap_or(class_id); - CLASS_STATIC_PROTOTYPES.with(|table| { - if let Ok(read) = table.read() { - if let Some(map) = read.as_ref() { - return map.get(&class_id).copied().unwrap_or(0) as *mut ObjectHeader; - } + match class_recorded_prototype_bits(class_id) { + Some(bits) if bits & crate::value::TAG_MASK == crate::value::POINTER_TAG => { + (bits & crate::value::POINTER_MASK) as *mut ObjectHeader } - std::ptr::null_mut() - }) + _ => std::ptr::null_mut(), + } } pub(crate) fn class_decl_prototype_object_root_store(class_id: u32, proto_ptr: *mut ObjectHeader) { diff --git a/crates/perry-runtime/src/object/class_value.rs b/crates/perry-runtime/src/object/class_value.rs new file mode 100644 index 0000000000..c7e2984549 --- /dev/null +++ b/crates/perry-runtime/src/object/class_value.rs @@ -0,0 +1,790 @@ +//! A class CONSTRUCTOR used as a value (#11414; the every-receiver-shape +//! lane's class-constructor stage). +//! +//! Two forms name a class constructor while the migration runs: +//! +//! * the legacy INT32 immediate `0x7FFE_0000_0000_0000 | class_id` with bit 32 +//! clear (bit 32 set is the `C.prototype` half — [`super::class_prototype_ref_id`]). +//! It is bit-identical to the int32 number equal to the class id, which is +//! #11414; the lane deletes it; +//! * a class FUNCTION OBJECT: a `GC_TYPE_CLOSURE` cell whose code pointer is +//! [`js_class_constructor_called`] (its [[Call]], which throws) and whose +//! capture slot 0 holds the class id as an INT32 value. +//! +//! Every decoder asks [`class_value_id`] (or [`class_value_id_bits`] / +//! [`class_closure_id`] for the raw-word and raw-pointer spellings). Nothing +//! else may test the INT32 tag or the code pointer to decide "is this a class". +use crate::closure::ClosureHeader; + +/// The class a constructor VALUE names — either form — or `None`. A +/// `C.prototype` reference is not a constructor and answers `None`. +#[inline] +pub(crate) fn class_value_id(value: f64) -> Option { + class_value_id_bits(value.to_bits()) +} + +/// [`class_value_id`] on the NaN-boxed word. +#[inline] +pub(crate) fn class_value_id_bits(bits: u64) -> Option { + match bits >> 48 { + 0x7FFE => { + if bits & super::native_module::CLASS_PROTOTYPE_REF_FLAG != 0 { + return None; + } + let class_id = (bits & 0xFFFF_FFFF) as u32; + (class_id != 0 && super::is_class_id_registered(class_id)).then_some(class_id) + } + 0x7FFD => class_closure_id((bits & crate::value::POINTER_MASK) as usize), + _ => None, + } +} + +/// The class id of a class function object at raw address `ptr`, or `None` +/// for any other word. Ownership is proven (`is_closure_ptr`) before a header +/// byte is trusted, so arbitrary addresses are fine. +/// +/// Callers include hot generic paths (bind, method values), so an ordinary +/// function is rejected before the ownership proof: once the address is a +/// plausible, aligned heap address whose ShapeId word is in the exotic band +/// (the same pre-checks `is_closure_ptr` makes before its first load), the +/// code-pointer word at +8 — inside every exotic cell's header — must be this +/// module's thunk. Only then is the cell proven. +#[inline] +pub fn class_closure_id(ptr: usize) -> Option { + if !crate::value::addr_class::is_plausible_heap_addr(ptr) + || !ptr.is_multiple_of(std::mem::align_of::()) + { + return None; + } + // SAFETY: a plausible, aligned heap address (the contract of the + // `is_closure_ptr` pre-checks this mirrors). + let shape = + unsafe { *((ptr as *const u8).add(crate::closure::CLOSURE_SHAPE_OFFSET) as *const u32) }; + if !crate::object::shapes::is_exotic_shape_id(shape) { + return None; + } + // SAFETY: an exotic-band ShapeId word means a closure-or-exotic header, + // at least 16 bytes; +8 is the code pointer of a closure. + let code = unsafe { *((ptr as *const u8).add(8) as *const *const u8) }; + if code != js_class_constructor_called as *const u8 { + return None; + } + class_closure_id_exotic(ptr) +} + +/// [`class_closure_id`] past its inline pre-filter (a plausible, aligned heap +/// address whose ShapeId word is in the exotic band): out of line, so the +/// many gates that inline the pre-filter stay small. +#[inline(never)] +fn class_closure_id_exotic(ptr: usize) -> Option { + if !crate::closure::is_closure_ptr(ptr) { + return None; + } + // SAFETY: `is_closure_ptr` proved a live, non-forwarded closure cell. + unsafe { class_closure_id_unchecked(ptr as *const ClosureHeader) } +} + +/// [`class_closure_id`] for a cell already proven to be a live closure. +/// +/// # Safety +/// `closure` is a live, non-forwarded `GC_TYPE_CLOSURE` cell. +#[inline] +pub(crate) unsafe fn class_closure_id_unchecked(closure: *const ClosureHeader) -> Option { + if (*closure).func_ptr != js_class_constructor_called as *const u8 { + return None; + } + let slot0 = *((closure as *const u8).add(std::mem::size_of::()) as *const u64); + Some((slot0 & 0xFFFF_FFFF) as u32) +} + +/// [[Call]] of a class constructor: ES2015 9.2.1 step 2 — a class constructor +/// called without `new` throws a TypeError. It is the code pointer of every +/// class function object (and how one is recognized); [[Construct]] never +/// reaches it — `new` decodes the class id and runs the class's constructor. +#[no_mangle] +pub unsafe extern "C" fn js_class_constructor_called(closure: *const ClosureHeader) -> f64 { + let name = unsafe { class_closure_id_unchecked(closure) } + .and_then(super::class_registry::class_name_for_id) + .unwrap_or_default(); + let message = format!("Class constructor {name} cannot be invoked without 'new'"); + crate::node_submodules::diagnostics::throw_type_error_no_code(message.as_bytes()) +} + +/// The PRE-MIGRATION gate spelling, kept exact for the legacy form while it +/// also admits the function-object form: any INT32 word (registered or not, +/// either half — those gates accepted every `0x7FFE` word, which is #11414) +/// or a class function object. `bits` is a NaN-boxed VALUE word. Every caller +/// is narrowed to [`class_value_id_bits`] when the INT32 form is deleted. +#[inline] +pub(crate) fn legacy_class_value_word(bits: u64) -> Option { + match bits >> 48 { + 0x7FFE => Some((bits & 0xFFFF_FFFF) as u32), + 0x7FFD => class_closure_id((bits & crate::value::POINTER_MASK) as usize), + _ => None, + } +} + +/// [`legacy_class_value_word`] for a word that arrived through a POINTER-typed +/// parameter (`obj as u64`), which may be a raw untagged heap address. +#[inline] +pub(crate) fn legacy_class_ptr_word(bits: u64) -> Option { + match bits >> 48 { + 0 => class_closure_id(bits as usize), + _ => legacy_class_value_word(bits), + } +} + +/// The NaN-boxed VALUE for a word [`legacy_class_ptr_word`] admitted: a raw +/// heap address gets its POINTER tag, anything else is already a value. +#[inline] +pub(crate) fn boxed_class_word(bits: u64) -> f64 { + if bits >> 48 == 0 { + f64::from_bits(crate::value::POINTER_TAG | bits) + } else { + f64::from_bits(bits) + } +} + +// --------------------------------------------------------------------------- +// The function object for each class: one per agent per class id. +// --------------------------------------------------------------------------- + +/// Class ids per table page (log2). Class ids are dense per program plus a few +/// high reserved ids for built-in classes, so a two-level table keeps the +/// lookup a pair of indexed loads without a large flat array. +const CLASS_VALUE_PAGE_SHIFT: u32 = 8; +const CLASS_VALUE_PAGE_LEN: usize = 1 << CLASS_VALUE_PAGE_SHIFT; + +type ClassValuePage = [*mut ClosureHeader; CLASS_VALUE_PAGE_LEN]; + +crate::perry_thread_local! { + /// This agent's class function objects, indexed by class id: a page + /// directory (`pages`, `len` pages) whose pages are leaked for the agent's + /// life. Read without a borrow flag — the hot path is a TLS read, a bounds + /// check and two loads. A GC root (rewritten on a move) via + /// [`scan_class_value_roots_mut`]. + static CLASS_VALUES: std::cell::Cell<(*mut *mut ClassValuePage, usize)> = + const { std::cell::Cell::new((std::ptr::null_mut(), 0)) }; +} + +#[inline] +fn class_value_cached(class_id: u32) -> Option<*mut ClosureHeader> { + let page = (class_id >> CLASS_VALUE_PAGE_SHIFT) as usize; + let index = class_id as usize & (CLASS_VALUE_PAGE_LEN - 1); + let (pages, len) = CLASS_VALUES.with(std::cell::Cell::get); + if page >= len { + return None; + } + // SAFETY: `pages` holds `len` page pointers (null or a live leaked page). + unsafe { + let p = *pages.add(page); + if p.is_null() { + return None; + } + let c = (*p)[index]; + (!c.is_null()).then_some(c) + } +} + +/// The table slot for `class_id`, growing the directory / minting the page. +fn class_value_slot(class_id: u32) -> *mut *mut ClosureHeader { + let page = (class_id >> CLASS_VALUE_PAGE_SHIFT) as usize; + let index = class_id as usize & (CLASS_VALUE_PAGE_LEN - 1); + let (mut pages, mut len) = CLASS_VALUES.with(std::cell::Cell::get); + if page >= len { + let new_len = (page + 1).next_power_of_two().max(4); + let mut dir: Vec<*mut ClassValuePage> = vec![std::ptr::null_mut(); new_len]; + if !pages.is_null() { + // SAFETY: the old directory holds `len` entries. + unsafe { dir[..len].copy_from_slice(std::slice::from_raw_parts(pages, len)) }; + // The old directory is leaked: a concurrent reader on this agent + // cannot exist (single-threaded agent), but the few bytes are not + // worth a free/reuse protocol. + } + pages = Box::leak(dir.into_boxed_slice()).as_mut_ptr(); + len = new_len; + CLASS_VALUES.with(|c| c.set((pages, len))); + } + // SAFETY: `page < len`. + unsafe { + let slot = pages.add(page); + if (*slot).is_null() { + *slot = Box::leak(Box::new([std::ptr::null_mut(); CLASS_VALUE_PAGE_LEN])); + } + (**slot).as_mut_ptr().add(index) + } +} + +/// Allocate the class function object for `class_id`: a closure born in the +/// old generation and pinned (it lives as long as the agent and never moves), +/// code pointer +/// [`js_class_constructor_called`], capture slot 0 = the class id as INT32. +/// +/// Never collects: callers hold raw receiver pointers across the lookup, so +/// the old-arena allocation runs under a [`crate::gc::GcSuppressScope`]. +#[cold] +#[inline(never)] +fn class_value_mint(class_id: u32) -> *mut ClosureHeader { + let _no_collect = crate::gc::GcSuppressScope::new(); + let payload = crate::closure::closure_payload_size(1); + let ptr = crate::arena::arena_alloc_gc_old_born_tenured( + payload, + std::mem::align_of::(), + crate::gc::GC_TYPE_CLOSURE, + ) as *mut ClosureHeader; + unsafe { + // GC_STORE_AUDIT(INIT): fresh class function object; the one capture + // is an INT32 class id and the props edge is null — pointer-free. + (*ptr).capture_count = 1; + (*ptr).shape_id = crate::closure::shape::function_class_shape(); + (*ptr).func_ptr = js_class_constructor_called as *const u8; + (*ptr).props = std::ptr::null_mut(); + std::ptr::write( + crate::closure::closure_capture_slots_mut(ptr), + crate::value::INT32_TAG | class_id as u64, + ); + crate::gc::layout_init_pointer_free(ptr as *mut u8); + // Born old AND pinned: the address is the class's identity for the + // agent's life (compiled code keeps it in registers and allocas, the + // metadata and weak tables compare it), so no collector may move it. + crate::gc::pin_user_ptr_non_young(ptr as *mut u8); + } + // SAFETY: the slot is this agent's table entry for `class_id`. + unsafe { *class_value_slot(class_id) = ptr }; + crate::gc::runtime_write_barrier_root_heap_word(ptr as u64); + // Still inside the no-collect scope: the own-property object and its + // keys allocate. + for key in INTRINSIC_OWN_DATA_KEYS { + install_intrinsic_own_data(class_id, key); + } + ptr +} + +/// A class constructor's `length` and `name`, in creation order +/// (ClassDefinitionEvaluation: SetFunctionLength, then SetFunctionName). +const INTRINSIC_OWN_DATA_KEYS: [&str; 2] = ["length", "name"]; + +/// The attributes of a function's own `length` / `name`. +const INTRINSIC_ATTRS: (bool, bool, bool) = (false, false, true); + +/// The value of intrinsic own data property `key` of class `class_id`, if +/// the class registered one. +fn intrinsic_own_data_value(class_id: u32, key: &str) -> Option { + match key { + "length" => super::class_registry::class_length_for_id(class_id).map(f64::from), + "name" => super::class_registry::class_name_for_id(class_id).map(|name| { + let s = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + f64::from_bits(crate::value::JSValue::string_ptr(s).bits()) + }), + _ => None, + } +} + +/// Does a static method or accessor of class `class_id` own `key`? Then it, +/// not the intrinsic data property, is the class's own `key`. +fn static_member_owns(class_id: u32, key: &str) -> bool { + super::class_registry::class_has_own_static_method(class_id, key) + || super::class_registry::class_own_static_accessor_ptrs(class_id, key).is_some() +} + +/// Is own `key` of class `class_id` still the intrinsic data property (not +/// replaced by a static field, a `defineProperty`, or deleted)? +fn holds_intrinsic(class_id: u32, key: &str) -> bool { + class_static_get(class_id, key).is_some() + && super::class_registry::class_static_defined_attrs(class_id, key) == Some(INTRINSIC_ATTRS) +} + +/// ClassDefinitionEvaluation's SetFunctionLength / SetFunctionName: `length` +/// and `name` are own DATA properties of the class's function object, +/// `{ writable: false, enumerable: false, configurable: true }`, kept in its +/// own-property object with every other own data property — so `C.name` and +/// `x.constructor.name` are one lookup in that object's shape. A static +/// method or accessor of the same name is the class's own property instead, +/// and a static field or `defineProperty` of that name replaces it. +fn install_intrinsic_own_data(class_id: u32, key: &str) { + if static_member_owns(class_id, key) { + return; + } + let Some(value) = intrinsic_own_data_value(class_id, key) else { + return; + }; + class_static_set(class_id, key, value); + let (writable, enumerable, configurable) = INTRINSIC_ATTRS; + super::class_registry::class_static_set_defined_attrs( + class_id, + key, + writable, + enumerable, + configurable, + ); +} + +/// A static field `key` was defined on class `class_id`: if it replaced the +/// intrinsic `name` / `length`, the property keeps the field's (ordinary) +/// attributes, not the intrinsic's. +pub(crate) fn note_static_field_defined(class_id: u32, key: &str) { + if INTRINSIC_OWN_DATA_KEYS.contains(&key) + && super::class_registry::class_static_defined_attrs(class_id, key) == Some(INTRINSIC_ATTRS) + { + super::class_registry::class_static_clear_defined_attrs(class_id, key); + } +} + +/// The registry changed what class `class_id`'s intrinsic `key` is (its +/// name or length registered, or a static method / accessor of that name +/// registered) after this agent minted its function object: bring the own +/// property in line. A key the program already redefined or deleted is left +/// alone. +pub(crate) fn note_intrinsic_registration(class_id: u32, key: &str) { + if !INTRINSIC_OWN_DATA_KEYS.contains(&key) || class_value_cached(class_id).is_none() { + return; + } + let _no_collect = crate::gc::GcSuppressScope::new(); + if holds_intrinsic(class_id, key) { + if static_member_owns(class_id, key) { + class_static_remove(class_id, key); + super::class_registry::class_static_clear_defined_attrs(class_id, key); + } else if let Some(value) = intrinsic_own_data_value(class_id, key) { + class_static_set(class_id, key, value); + } + } else if class_static_get(class_id, key).is_none() + && !super::class_registry::class_is_key_deleted(class_id, key) + { + install_intrinsic_own_data(class_id, key); + } +} + +/// The class function object for `class_id` on this agent (minted on first +/// use). `class_id` must be a registered class. +#[inline] +pub(crate) fn class_value_ptr(class_id: u32) -> *mut ClosureHeader { + match class_value_cached(class_id) { + Some(c) => c, + None => class_value_mint(class_id), + } +} + +/// The VALUE of class `class_id`'s constructor: its function object, NaN-boxed. +#[inline] +pub(crate) fn class_value(class_id: u32) -> f64 { + f64::from_bits(crate::value::POINTER_TAG | (class_value_ptr(class_id) as u64)) +} + +/// Emitted for every `Expr::ClassRef` and every place compiled code names a +/// class as a value (static `this`, `new.target`, `ns.C`): the class's +/// function object. A per-agent indexed load; never allocates after the +/// first use and never collects. +#[no_mangle] +pub extern "C" fn js_class_value(class_id: i32) -> f64 { + class_value(class_id as u32) +} + +/// GC root scan for [`CLASS_VALUES`]; registered in `gc::mod`'s runtime +/// scanner list. +/// +/// A class function object is PINNED, and marking never queues a pinned +/// header (`try_mark_*`: "pinned objects are always live"), so no collector +/// enumerates its child slots from a root. Its one heap edge, the own-property +/// bag (`props`, the statics), is therefore visited here as a root slot of its +/// own: a full trace marks and traces the bag (and notes the shape it carries, +/// which post-trace descriptor retirement reads), and a moving collection +/// rewrites the edge. A minor also reaches the edge through the remembered set +/// the `bag_ensure` store barrier dirtied; the second visit of a rewritten +/// slot sees the forwarded address and is a no-op. +pub(crate) fn scan_class_value_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { + let (pages, len) = CLASS_VALUES.with(std::cell::Cell::get); + for i in 0..len { + // SAFETY: `pages` holds `len` page pointers (null or a live page). + let page = unsafe { *pages.add(i) }; + if page.is_null() { + continue; + } + // SAFETY: a live leaked page of this agent. + for slot in unsafe { (*page).iter_mut() } { + if slot.is_null() { + continue; + } + visitor.visit_raw_mut_ptr_slot(slot); + // SAFETY: a live class function object of this agent. + let props = unsafe { &mut (**slot).props }; + if !props.is_null() { + visitor.visit_raw_mut_ptr_slot(props); + } + } + } +} + +/// `C[prop]` for a class function object at `ptr` (routed by +/// `closure_get_dynamic_prop` on the class ShapeId): the class lookup. +#[cold] +#[inline(never)] +pub(crate) fn class_static_read(ptr: usize, prop: &str, key: *const crate::StringHeader) -> f64 { + // SAFETY: the caller proved a live class closure (its ShapeId). + let Some(class_id) = (unsafe { class_closure_id_unchecked(ptr as *const ClosureHeader) }) + else { + return f64::from_bits(crate::value::TAG_UNDEFINED); + }; + // The class object is pinned: `ptr` survives the key allocation. + let key = if key.is_null() { + crate::string::js_string_from_bytes(prop.as_ptr(), prop.len() as u32) + } else { + key as *mut crate::StringHeader + }; + let value = crate::object::field_get_set::class_value_get_field( + ptr as *const crate::object::ObjectHeader, + key, + ptr as u64, + class_id, + ); + f64::from_bits(value.bits()) +} + +/// A statically lowered `C.x` whose compiled alias is detached (`TAG_HOLE`: +/// the static was deleted, redefined as an accessor or made read-only): the +/// generic [[Get]] on the class function object. +/// +/// # Safety +/// `name_ptr` points at `name_len` bytes of UTF-8 (codegen rodata). +#[no_mangle] +pub unsafe extern "C" fn js_class_static_field_get( + class_id: i32, + name_ptr: *const u8, + name_len: i64, +) -> f64 { + let key = crate::string::js_string_from_bytes(name_ptr, name_len as u32); + let receiver = class_value_ptr(class_id as u32) as *const crate::object::ObjectHeader; + f64::from_bits(crate::object::js_object_get_field_by_name(receiver, key).bits()) +} + +/// A statically lowered `C.x = v` whose compiled alias is detached: the +/// generic [[Set]] on the class function object (a setter, a read-only +/// refusal, or re-creating a deleted static — which re-attaches the alias). +/// +/// # Safety +/// As [`js_class_static_field_get`]. +#[no_mangle] +pub unsafe extern "C" fn js_class_static_field_put( + class_id: i32, + name_ptr: *const u8, + name_len: i64, + value: f64, +) { + let key = crate::string::js_string_from_bytes(name_ptr, name_len as u32); + let receiver = class_value_ptr(class_id as u32) as *mut crate::object::ObjectHeader; + crate::object::js_object_set_field_by_name(receiver, key, value); +} + +/// [[Get]] of `key` on class `class_id`'s [[Prototype]], `receiver` as the +/// receiver: the continuation of a read of a key the class does not own +/// (e.g. its own `name` was deleted — `Sub.name` then reads `Base.name`, a +/// base class reads `Function.prototype.name`). The [[Prototype]] is the +/// recorded one (`Object.setPrototypeOf(C, p)`), else the parent class's +/// function object, else the parent function (`extends `), else +/// %Function.prototype%. +pub(crate) fn class_prototype_get( + class_id: u32, + key: *const crate::StringHeader, + receiver: f64, +) -> crate::value::JSValue { + use crate::value::JSValue; + if super::class_registry::class_static_prototype_is_nulled(class_id) { + return JSValue::undefined(); + } + let proto = super::class_registry::class_static_prototype(class_id) as usize; + let proto = if proto != 0 { + proto + } else if let Some(parent) = super::get_parent_class_id(class_id) + .filter(|&p| p != 0 && p != class_id && super::is_class_id_registered(p)) + { + class_value_ptr(parent) as usize + } else if let Some(parent) = super::class_registry::class_parent_closure(class_id) { + parent + } else { + crate::closure::shape::FUNCTION_PROTOTYPE_PTR.load(std::sync::atomic::Ordering::Acquire) + as usize + }; + if proto == 0 { + return JSValue::undefined(); + } + let prev = super::field_get_set::accessor_receiver_override_begin(receiver); + let value = super::js_object_get_field_by_name(proto as *const super::ObjectHeader, key); + super::field_get_set::accessor_receiver_override_end(prev); + value +} + +// --------------------------------------------------------------------------- +// Statics: the class function object's OWN properties. +// --------------------------------------------------------------------------- + +/// A runtime-internal static key (private statics, computed-key records, +/// class captures): stored in the function object's internal state record, +/// never as a property. +#[inline] +fn is_internal_static_key(name: &str) -> bool { + crate::object::is_internal_runtime_key(name) +} + +/// Class `class_id`'s own static data property `name` (a declared static +/// field or a runtime `C.x = v`): a slot of its function object's own-property +/// bag. +pub(crate) fn class_static_get(class_id: u32, name: &str) -> Option { + let ptr = class_value_ptr(class_id) as usize; + // SAFETY: `class_value_ptr` returns this agent's live class closure. + unsafe { + if is_internal_static_key(name) { + crate::closure::props::state_internal_get(ptr, name) + } else { + crate::closure::props::bag_get(ptr, name.as_bytes()) + } + } +} + +/// Define/overwrite class `class_id`'s own static data property `name`. +pub(crate) fn class_static_set(class_id: u32, name: &str, value: f64) { + let ptr = class_value_ptr(class_id) as usize; + // SAFETY: as above; the bag writers run under a GcSuppressScope. + unsafe { + if is_internal_static_key(name) { + crate::closure::props::state_internal_set(ptr, name, value); + } else { + crate::closure::props::bag_set(ptr, name, value); + } + } +} + +/// Remove class `class_id`'s own static data property `name`; true when it +/// existed. +pub(crate) fn class_static_remove(class_id: u32, name: &str) -> bool { + let ptr = class_value_ptr(class_id) as usize; + // SAFETY: as above. + unsafe { + if is_internal_static_key(name) { + crate::closure::props::state_internal_remove(ptr, name) + } else { + crate::closure::props::bag_remove(ptr, name) + } + } +} + +/// Class `class_id`'s own static data properties in own-key order (integer +/// keys ascending, then creation order). Internal keys are not properties and +/// never appear. +pub(crate) fn class_static_entries(class_id: u32) -> Vec<(String, f64)> { + let ptr = class_value_ptr(class_id) as usize; + // SAFETY: as above. + unsafe { crate::closure::props::bag_snapshot(ptr) } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn register(cid: u32) { + let mut guard = crate::object::REGISTERED_CLASS_IDS.write().unwrap(); + guard + .get_or_insert_with(crate::fast_hash::new_ptr_hash_set) + .insert(cid); + } + + /// #11414: a class value is ONE function object per class — never an + /// INT32 word a number can equal — pinned and old, so its address is its + /// identity across collections. + #[test] + fn class_value_is_one_pinned_function_object_per_class() { + let cid = 0x6A01; + register(cid); + let a = class_value(cid); + let b = class_value(cid); + assert_eq!(a.to_bits(), b.to_bits(), "one function object per class"); + let v = crate::value::JSValue::from_bits(a.to_bits()); + assert!( + !v.is_int32() && !v.is_number(), + "a class value is not a number" + ); + assert!(v.is_pointer()); + let ptr = (a.to_bits() & crate::value::POINTER_MASK) as usize; + assert!( + crate::closure::is_closure_ptr(ptr), + "a GC_TYPE_CLOSURE cell" + ); + assert_eq!(class_value_id(a), Some(cid)); + assert_eq!(class_closure_id(ptr), Some(cid)); + // The number equal to the class id is not the class. + assert_ne!( + f64::from_bits(crate::value::INT32_TAG | cid as u64).to_bits(), + a.to_bits() + ); + let header = unsafe { crate::value::addr_class::try_read_gc_header(ptr) }.expect("header"); + assert_ne!(header.gc_flags & crate::gc::GC_FLAG_PINNED, 0, "pinned"); + assert_ne!(header.gc_flags & crate::gc::GC_FLAG_TENURED, 0, "born old"); + crate::gc::js_gc_collect(); + assert_eq!(class_value(cid).to_bits(), a.to_bits(), "never moves"); + assert_eq!(class_value_id(a), Some(cid), "survives a full collection"); + let other = class_value(0x6A02); + assert_ne!(other.to_bits(), a.to_bits()); + assert_eq!(class_value_id(other), Some(0x6A02)); + } + + /// The table is a root: the scan visits every minted class value. + #[test] + fn class_value_table_is_scanned() { + let cid = 0x6B01; + register(cid); + let ptr = class_value_ptr(cid) as usize; + let mut seen = false; + scan_class_value_roots_mut(&mut crate::gc::RuntimeRootVisitor::for_copy( + &mut |v: f64| { + let bits = v.to_bits(); + if bits as usize == ptr || (bits & crate::value::POINTER_MASK) as usize == ptr { + seen = true; + } + }, + )); + assert!(seen, "the class-value table must be a GC root"); + } + + /// #11609: the class function object is pinned, and marking never queues a + /// pinned header, so its own-property bag (the statics) is reached only + /// because the class-value root scan visits the `props` edge itself. + /// Without that, a full trace never visits the bag: the shape the bag + /// carries is never noted as carried, post-trace descriptor retirement + /// drops it, and every static reads back as absent. + #[test] + fn a_full_collection_keeps_the_class_statics_bag() { + let cid = 0x6B02; + register(cid); + // A unit-test thread may not have run `gc_init`'s scanner list. + crate::gc::gc_register_mutable_root_scanner(scan_class_value_roots_mut); + let ptr = class_value_ptr(cid) as usize; + let text = "static-payload-11609"; + let s = crate::string::js_string_from_bytes(text.as_ptr(), text.len() as u32); + class_static_set( + cid, + "k11609", + f64::from_bits(crate::value::JSValue::string_ptr(s).bits()), + ); + let mut saw_bag = false; + let bag = unsafe { crate::closure::props::bag_of(ptr) } as usize; + assert_ne!(bag, 0, "the static installed a bag"); + scan_class_value_roots_mut(&mut crate::gc::RuntimeRootVisitor::for_copy( + &mut |v: f64| { + let bits = v.to_bits(); + if bits as usize == bag || (bits & crate::value::POINTER_MASK) as usize == bag { + saw_bag = true; + } + }, + )); + assert!( + saw_bag, + "the root scan must visit the pinned class's bag edge" + ); + crate::gc::js_gc_collect(); + crate::gc::js_gc_collect(); + let got = class_static_get(cid, "k11609").expect("the static survives a full collection"); + let got = crate::value::JSValue::from_bits(got.to_bits()); + let hdr = got.as_string_ptr(); + assert!(!hdr.is_null()); + let bytes = unsafe { crate::string::OwnedStringBytes::copy_from_header(hdr) }; + assert_eq!(bytes.as_bytes(), text.as_bytes()); + let keys: Vec = class_static_entries(cid) + .into_iter() + .map(|(k, _)| k) + .collect(); + assert!(keys.iter().any(|k| k == "k11609"), "own keys: {keys:?}"); + } + + /// The kind is a shape fact: class function objects carry their own + /// ShapeId, distinct from FunctionDictionary (shapes are canonical per + /// facts — without its marker fact the class shape WOULD be the dictionary + /// id and every dictionary function would route as a class), and it is + /// sticky across own-property installs. + #[test] + fn class_function_objects_have_their_own_sticky_shape() { + let cid = 0x6C01; + register(cid); + let class_shape = crate::closure::shape::function_class_shape(); + assert_ne!( + class_shape, + crate::closure::shape::function_dictionary_shape() + ); + let ptr = class_value_ptr(cid); + assert_eq!(unsafe { (*ptr).shape_id }, class_shape); + class_static_set(cid, "s", 1.0); + crate::closure::shape::note_function_own_state_changed(ptr as usize); + assert_eq!(unsafe { (*ptr).shape_id }, class_shape, "sticky"); + extern "C" fn body() {} + let f = crate::closure::js_closure_alloc(body as *const u8, 0); + crate::closure::shape::note_function_own_state_changed(f as usize); + assert_ne!( + unsafe { (*f).shape_id }, + class_shape, + "a dictionary function is not a class" + ); + } + + /// A class constructor's `length` and `name` are own data properties of + /// its function object (in its own-property object, intrinsic + /// attributes); a static method of that name owns the key instead. + #[test] + fn name_and_length_are_own_data_of_the_function_object() { + let cid = 0x6D01; + register(cid); + unsafe { crate::object::js_register_class_name(cid, b"Zed".as_ptr(), 3) }; + crate::object::js_register_class_length(cid, 2); + let ptr = class_value_ptr(cid) as usize; + assert_eq!( + unsafe { crate::closure::props::bag_get(ptr, b"length") }, + Some(2.0), + "own length" + ); + let name = unsafe { crate::closure::props::bag_get(ptr, b"name") }.expect("own name"); + let mut scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + // SAFETY: a live string value just read from the object. + let bytes = unsafe { + crate::string::js_string_key_bytes( + crate::value::JSValue::from_bits(name.to_bits()), + &mut scratch, + ) + } + .expect("a string"); + assert_eq!(bytes, b"Zed"); + for key in ["length", "name"] { + assert_eq!( + crate::object::class_registry::class_static_defined_attrs(cid, key), + Some(INTRINSIC_ATTRS), + "{key}: non-writable, non-enumerable, configurable" + ); + } + extern "C" fn static_name() -> f64 { + 0.0 + } + unsafe { + crate::object::class_registry::js_register_class_static_method( + cid as i64, + b"name".as_ptr(), + 4, + static_name as *const () as usize as i64, + 0, + 0, + ) + }; + assert_eq!( + unsafe { crate::closure::props::bag_get(ptr, b"name") }, + None, + "a static method named `name` is the class's own `name`" + ); + } + + /// Only the function object's own code pointer names a class. + #[test] + fn ordinary_closures_and_numbers_are_not_class_values() { + extern "C" fn body() {} + let c = crate::closure::js_closure_alloc(body as *const u8, 0); + assert_eq!(class_closure_id(c as usize), None); + assert_eq!(class_value_id(42.0), None); + assert_eq!( + class_value_id(f64::from_bits(crate::value::TAG_UNDEFINED)), + None + ); + } +} diff --git a/crates/perry-runtime/src/object/delete_rest.rs b/crates/perry-runtime/src/object/delete_rest.rs index b08382ccd0..58c073f261 100644 --- a/crates/perry-runtime/src/object/delete_rest.rs +++ b/crates/perry-runtime/src/object/delete_rest.rs @@ -870,6 +870,15 @@ pub extern "C" fn js_object_delete_dynamic_value(obj_value: f64, key: f64) -> i3 } // Class-ref receiver (`delete C["m"]`): see `js_object_delete_field_value`. if let Some(class_id) = super::native_module::class_ref_id(obj_value) { + // A symbol key is an own symbol property of the class function object. + if unsafe { crate::symbol::js_is_symbol(key) } != 0 { + return unsafe { + crate::symbol::js_object_delete_symbol_property( + super::class_value::class_value(class_id), + key, + ) + }; + } return js_object_delete_dynamic(class_id as usize as *mut ObjectHeader, key); } if !delete_receiver_is_pointer(obj_value) { diff --git a/crates/perry-runtime/src/object/descriptors.rs b/crates/perry-runtime/src/object/descriptors.rs index 1c7a0409ed..d408b2f536 100644 --- a/crates/perry-runtime/src/object/descriptors.rs +++ b/crates/perry-runtime/src/object/descriptors.rs @@ -1190,7 +1190,13 @@ fn js_object_get_own_property_names_shape(obj_value: f64) -> f64 { push_unique_name(&mut names, name); } } - names.retain(|n| !super::field_get_set::is_internal_runtime_key(n)); + names.retain(|n| { + !super::field_get_set::is_internal_runtime_key(n) + // A deleted `length` / `name` is no longer own. + && !(!is_prototype_ref + && matches!(n.as_str(), "length" | "name") + && super::class_registry::class_is_key_deleted(class_id, n)) + }); sort_property_names_ecma(&mut names); let result = crate::array::js_array_alloc(names.len() as u32); for name in names { diff --git a/crates/perry-runtime/src/object/field_get_set.rs b/crates/perry-runtime/src/object/field_get_set.rs index eba8ac46d8..f15c61bd97 100644 --- a/crates/perry-runtime/src/object/field_get_set.rs +++ b/crates/perry-runtime/src/object/field_get_set.rs @@ -291,6 +291,7 @@ pub use field_ops::{ js_value_to_object, }; pub use for_in_stable::js_for_in_keys_stable_value; +pub(crate) use get_field_by_name::class_value_get_field; pub(crate) use get_field_by_name::get_field_by_name_past_inherited_cache; pub use get_field_by_name::js_object_get_field_by_name; pub(crate) use get_field_by_name_async::async_resource_property; diff --git a/crates/perry-runtime/src/object/field_get_set/class_object_props.rs b/crates/perry-runtime/src/object/field_get_set/class_object_props.rs index 9cf0a88e46..f913510351 100644 --- a/crates/perry-runtime/src/object/field_get_set/class_object_props.rs +++ b/crates/perry-runtime/src/object/field_get_set/class_object_props.rs @@ -383,8 +383,9 @@ pub(super) unsafe fn instance_constructor_value( ); } if class_id != 0 && is_class_id_registered(class_id) { - let bits = 0x7FFE_0000_0000_0000u64 | (class_id as u64); - return Some(JSValue::from_bits(bits)); + return Some(JSValue::from_bits( + crate::object::class_value::class_value(class_id).to_bits(), + )); } // class_id == 0 fallback: plain ObjectHeader allocated // without an HIR shape (Object.create(null) hybrids, raw diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs index caf9a0ea7b..28d2e02863 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs @@ -100,6 +100,421 @@ pub extern "C" fn js_object_get_field_by_name( get_field_by_name_past_data_probe(obj, key) } +/// `C.key` for a class constructor value (its function object, or the legacy +/// INT32 immediate / `C.prototype` reference): the class-static lookup. Split +/// out so a class function object is routed here BEFORE the closure arms of +/// the generic read (`get_field_by_name_past_inherited_cache`), which it +/// would otherwise walk end to end first. +#[inline(never)] +pub(crate) fn class_value_get_field( + obj: *const ObjectHeader, + key: *const crate::StringHeader, + bits: u64, + class_id: u32, +) -> JSValue { + // A class function object's own data property (a static field, a runtime + // `C.x = v`): its bag answers first — own data wins [[Get]] — unless a + // per-evaluation class object exists for some class (the redirect below + // then decides). + if bits >> 48 != 0x7FFE + && !super::super::class_registry::CLASS_OBJECT_EVER + .load(std::sync::atomic::Ordering::Relaxed) + { + let ptr = (bits & crate::value::POINTER_MASK) as usize; + // SAFETY: a class function object (the caller decoded `class_id` from + // it); `key` is a live string header. + unsafe { + let bytes = std::slice::from_raw_parts( + crate::string::string_data(key), + (*key).byte_len as usize, + ); + if let Some(v) = crate::closure::props::bag_get(ptr, bytes) { + return JSValue::from_bits(v.to_bits()); + } + } + } + let class_value = crate::object::class_value::boxed_class_word(bits); + let is_prototype_ref = super::super::class_prototype_ref_id(class_value).is_some(); + unsafe { + let name_ptr = (key as *const u8).add(std::mem::size_of::()); + let name_len = (*key).byte_len as usize; + let name = + std::str::from_utf8(std::slice::from_raw_parts(name_ptr, name_len)).unwrap_or(""); + // v0.5.752: class_ref.constructor synthesizes back to the + // same class ref so drizzle's + // `Object.getPrototypeOf(value).constructor === Class` chain + // collapses correctly (with v0.5.751's getPrototypeOf + // returning the class ref for instance receivers). Refs + // #420 / #618 followup. + if is_prototype_ref + && name == "constructor" + && class_id != 0 + && class_has_own_method(class_id, name) + { + let value = class_prototype_method_value_for_name(class_id, name); + return JSValue::from_bits(value.to_bits()); + } + if name == "constructor" + && is_prototype_ref + && class_id != 0 + && is_class_id_registered(class_id) + { + let value = if is_prototype_ref { + super::super::class_constructor_ref_value(class_id) + } else { + class_value + }; + return JSValue::from_bits(value.to_bits()); + } + if name == "prototype" + && class_id != 0 + && is_class_id_registered(class_id) + && !is_prototype_ref + { + let value = super::super::class_registry::class_decl_prototype_value(class_id); + if value.to_bits() == crate::value::TAG_UNDEFINED { + let value = super::super::class_prototype_ref_value(class_id); + return JSValue::from_bits(value.to_bits()); + } + return JSValue::from_bits(value.to_bits()); + } + // A capture-carrying class declaration is materialized as a + // heap class object (`ClassExprFresh`). References that were + // lowered before the declaration's runtime binding existed + // (the class constructor itself and earlier helper closures) + // still carry the template `ClassRef`. Runtime additions such + // as `Object.defineProperty(C, "OPEN", { value: 1 })` live on + // the materialized object, so consulting only the template + // tables makes `C.OPEN` undefined in those bodies even though + // the same expression at the declaration site reads `1`. + // + // `CLASS_OBJECT_VALUES` is already the runtime identity used + // by `instance.constructor`. Read that same current + // evaluation first, preserving its own-property and pinned + // static-parent semantics; a miss continues through the + // ordinary ClassRef registry path below. + if !is_prototype_ref { + if let Some(class_object) = + super::super::class_registry::class_object_value_for_cid(class_id) + { + let class_object = JSValue::from_bits(class_object.to_bits()); + if class_object.is_pointer() { + let class_object = class_object.as_pointer::(); + if !class_object.is_null() && class_object as usize != obj as usize { + let value = js_object_get_field_by_name(class_object, key); + if !value.is_undefined() { + return value; + } + } + } + } + } + // Instance (prototype) methods must only resolve when reading + // off the prototype ref (`C.prototype.m`), NOT off the class ref + // itself (`C.m`). In JS a class object does not expose its + // prototype methods as static members: `class C { m(){} }` has + // `C.m === undefined` (the method lives on `C.prototype`). The + // earlier unconditional lookup leaked instance methods onto the + // class ref, so `C.m` returned a (mis-bound) function. This + // broke NestJS interceptor/guard/pipe resolution: its + // `getInterceptorInstance` duck-types `!!metatype.intercept` to + // decide "is this a class or an already-built instance"; a + // truthy `Class.intercept` made it treat the CLASS as the + // instance, so `intercept()` ran with a broken receiver and + // returned `{}`, which rxjs `innerFrom` then rejected. Real + // static methods are resolved below via + // `lookup_static_method_in_chain`. + if is_prototype_ref && class_id != 0 && class_has_own_method(class_id, name) { + let value = class_prototype_method_value_for_name(class_id, name); + return JSValue::from_bits(value.to_bits()); + } + if is_prototype_ref { + // Class accessors are properties of the class prototype + // chain (charter step 3); `this` is the prototype ref. + if let Some((v, _)) = + super::super::class_registry::class_chain_getter_value(class_id, name, || { + class_value + }) + { + return v; + } + return JSValue::undefined(); + } + // Empty-string is a legal static member key (`static get ''()`); + // the `!name.is_empty()` guard below skips it, so resolve a + // static accessor named "" here (Test262 accessor-name-static + // literal-string-empty). + if name.is_empty() { + if let Some(v) = super::super::class_registry::class_static_accessor_getter_value( + class_id, + name, + class_value, + ) { + return JSValue::from_bits(v.to_bits()); + } + } + if !name.is_empty() { + if super::super::class_registry::class_is_key_deleted(class_id, name) { + // Not an own property any more: the read continues on the + // class's [[Prototype]]. + return crate::object::class_value::class_prototype_get(class_id, key, class_value); + } + let result = crate::object::class_value::class_static_get(class_id, name); + if let Some(v) = result { + return JSValue::from_bits(v.to_bits()); + } + // Static DATA fields are INHERITED by subclasses, exactly like + // static methods: `class D {}; D.kind = "x"; class G extends D {}` + // makes `G.kind === "x"` (the class-object proto chain + // `G.__proto__ === D` carries statics). The own-field read above + // only consulted `class_id`; walk the parent class_id chain here + // so an inherited static field (or runtime `Parent.x = …` + // assignment — both live in CLASS_DYNAMIC_PROPS) resolves. Static + // METHODS are handled by `lookup_static_method_in_chain` below; + // this covers the data-field case that was returning `undefined` + // (Auth.js sets `SignInError.kind = "signIn"` and reads it off a + // `CredentialsSignin` subclass to pick the sign-in vs error page). + // + // #6530: `name` is an OWN property of every constructor — a + // subclass never inherits its parent's `.name` (spec: + // ClassDefinitionEvaluation installs it per class). Skip the + // chain walk so the #2059 own-name synthesis below answers + // with THIS class's registered name instead of an ancestor's. + if !matches!(name, "name" | "length") { + // Walk the class-object proto chain for an inherited static + // DATA field. At EACH level the class's pinned + // per-evaluation parent OBJECT is consulted BEFORE the + // parent's registry props (`CLASS_DYNAMIC_PROPS`). + // + // #6552: a subclass of a class-EXPRESSION value evaluated + // more than once (`function make(a){return class{static + // ast=a}}`, then `class Number$ extends make(x) {}` / + // `class Widget$ extends make(y) {}`) records THIS + // evaluation's parent object as its static prototype + // (`class_prototype_object`, #1788), but the parent's + // `CLASS_DYNAMIC_PROPS` are keyed by the class-expression + // TEMPLATE id — shared, last-wins across every evaluation. + // Reading the registry entry for such a parent collapses + // sibling subclasses to the LAST `make(...)` (effect Schema: + // `Number$.ast`/`Widget$.ast` both read the last parent's + // `ast`). The pinned object carries this evaluation's own + // edge, so it is authoritative; the registry read remains + // the fallback for a plain declaration parent (#6443: + // Auth.js `SignInError.kind`), which has no pinned object. + let mut child = class_id; + let mut depth = 0usize; + while depth < 32 { + // The constructor's own `[[Prototype]]`, set by + // `Object.setPrototypeOf(Ctor, obj)`. Checked first: + // it is the nearest static-side link, and unlike + // `class_prototype_object` it is never on an + // instance's chain. + let static_proto = super::super::class_registry::class_static_prototype(child); + if !static_proto.is_null() { + // #10911: this walk re-enters with the PARENT as + // the object. It was written when effect's `ast` + // was a static DATA field (see above), where the + // object doesn't matter; effect now makes `ast` a + // static GETTER, and a getter found this way ran + // with `this` === the parent class. Stash the + // class the read started from so the accessor + // binds it (spec OrdinaryGet threads Receiver) -- + // the same device `resolve_proto_chain_field_inner` + // uses for instance getters. + let prev = crate::object::field_get_set::accessor_receiver_override_begin( + class_value, + ); + let v = js_object_get_field_by_name(static_proto as *const _, key); + crate::object::field_get_set::accessor_receiver_override_end(prev); + if !v.is_undefined() { + return v; + } + } + let proto = super::super::class_registry::class_prototype_object(child); + if !proto.is_null() { + let prev = crate::object::field_get_set::accessor_receiver_override_begin( + class_value, + ); + let v = js_object_get_field_by_name(proto as *const _, key); + crate::object::field_get_set::accessor_receiver_override_end(prev); + // Return a value present on the pinned object even + // when it is `null` — a static explicitly set to + // `null` on THIS evaluation is authoritative and + // must not fall through to the last-wins registry + // entry (a sibling evaluation's value). Only + // `undefined` means "absent here", which continues + // the walk to the parent's registry props / a higher + // ancestor. + if !v.is_undefined() { + return v; + } + } + let p = match get_parent_class_id(child) { + Some(p) if p != 0 && p != child => p, + _ => break, + }; + // A key deleted on THIS ancestor is not provided by it, + // but a higher ancestor may still define it — `delete + // Mid.foo` must let `Sub.foo` inherit `Base.foo`, not + // resolve to undefined. Skip the registry read for the + // deleted level and keep walking up. + if !super::super::class_registry::class_is_key_deleted(p, name) { + let inherited = crate::object::class_value::class_static_get(p, name); + if let Some(v) = inherited { + return JSValue::from_bits(v.to_bits()); + } + } + child = p; + depth += 1; + } + } + if super::super::class_registry::lookup_static_method_in_chain(class_id, name).is_some() + { + let heap_name = { + let layout = std::alloc::Layout::from_size_align(name_len.max(1), 1).unwrap(); + let ptr = std::alloc::alloc(layout); + std::ptr::copy_nonoverlapping(name_ptr, ptr, name_len); + ptr + }; + let result = js_class_method_bind(class_value, heap_name, name_len); + return JSValue::from_bits(result.to_bits()); + } + // `class X extends Promise` — a value read of an inherited + // builtin static (`X.resolve`, `X.all`, …) resolves to the + // reified Promise static (so `X.resolve.bind(X)` works). Only + // fires when no user static shadowed it above. + if super::super::promise_parent_in_chain(class_id) + && super::super::promise_static_function_spec(name).is_some() + { + let v = super::super::js_promise_static_function_value(name_ptr, name_len); + if v.to_bits() != crate::value::TAG_UNDEFINED { + return JSValue::from_bits(v.to_bits()); + } + } + if let Some(v) = super::super::class_registry::class_static_accessor_getter_value( + class_id, + name, + class_value, + ) { + return JSValue::from_bits(v.to_bits()); + } + // #1788: a subclass of a class-expression value + // (`class Sub extends make("A") {}`) inherits the parent + // class OBJECT's OWN per-evaluation static fields. The + // parent object was recorded as `class_id`'s static + // prototype at `extends` time; walk that chain (also + // covering multi-level `class Leaf extends Mid {}`). + // #6530: except `name` — an own property of every + // constructor, never inherited; without the guard a + // subclass of a per-evaluation class object reported its + // BASE's synthesized `.name` (bundled zod: + // `z.string().constructor.name` gave "ZodType"). + if !matches!(name, "name" | "length") { + if let Some(v) = + super::super::class_registry::resolve_proto_chain_field(class_id, key) + { + if !v.is_undefined() && !v.is_null() { + return v; + } + } + } + // #36 / #321: the subclass extends a FUNCTION value + // (`class Svc extends Context.Tag(id)<...>() {}`). Read the + // named static off the parent closure — its OWN props + // (`Svc.key` → "Svc") plus, via the closure getter, its + // static prototype (`Svc._op` → "Tag" on TagProto). + // #10210: the edge is keyed by the class that directly + // `extends `, which may be an ANCESTOR of this + // class (`class Flags extends ConfigTag {}` where + // `ConfigTag extends Context.Service()(id)`), so walk the + // parent chain like `super()` dispatch does. + // `name`/`length` are own properties of every constructor (#6530), + // never inherited from a function-valued parent. + if let Some(closure_ptr) = (!matches!(name, "name" | "length")) + .then(|| super::super::class_registry::parent_closure_in_chain(class_id)) + .flatten() + { + let v = crate::closure::closure_get_dynamic_prop(closure_ptr, name); + let vb = JSValue::from_bits(v.to_bits()); + if !vb.is_undefined() && !vb.is_null() { + return vb; + } + } + // #2059: the constructor's built-in `name` own property — + // the class name. Checked last so an explicit static + // `name` member (method/field, handled above) still wins. + // This is what `assert.throws` reads via + // `thrown.constructor.name` to label the thrown error. + if name == "name" + && class_id != 0 + && !super::super::class_registry::class_is_key_deleted(class_id, name) + { + if let Some(cname) = super::super::class_registry::class_name_for_id(class_id) { + let s = crate::string::js_string_from_bytes(cname.as_ptr(), cname.len() as u32); + return JSValue::from_bits(crate::js_nanbox_string(s as i64).to_bits()); + } + } + if name == "length" + && class_id != 0 + && !is_prototype_ref + && !super::super::class_registry::class_is_key_deleted(class_id, name) + { + if let Some(length) = super::super::class_registry::class_length_for_id(class_id) { + return JSValue::number(length as f64); + } + } + // A class constructor is also a Function object. Reify + // inherited Function.prototype methods for value reads + // (`const bind = C.bind`) just as the closure path does; + // the captured ClassRef is accepted by native method + // dispatch and by `js_function_bind`. + if !is_prototype_ref { + if let Some(method) = super::reified_function_method_name(name) { + let value = crate::closure::reify_function_method_value(class_value, method); + return JSValue::from_bits(value.to_bits()); + } + } + // No own static / inherited entry resolved the name. A class + // constructor is a function, so a bare read of `.caller` or + // `.arguments` hits the poison-pill %ThrowTypeError% accessor + // on `Function.prototype` — strict-mode throws (Perry only + // compiles strict code). Placed last so any own static field, + // accessor, or `defineProperty`-installed data prop of that + // name takes precedence. Prototype-refs (`C.prototype`) are + // plain objects and are excluded. + if !is_prototype_ref && matches!(name, "caller" | "arguments") { + crate::fs::validate::throw_type_error_with_code( + "Restricted function property access", + "ERR_INVALID_ARG_TYPE", + ); + } + // #11492: a constructor's chain ends at %Function.prototype%, + // so a user method or expando installed there + // (`Function.prototype.myHelper = fn`) is readable through + // `C.myHelper` exactly as through a closure. + if !is_prototype_ref { + if let Some(v) = + crate::closure::function_prototype_inherited_get(0, name, class_value) + { + return JSValue::from_bits(v.to_bits()); + } + } + } + // The built-in constructor object's `constructor` value is + // inherited from Function.prototype. It is therefore only the + // fallback after own computed fields, static methods, and + // static accessors of the same name have had a chance to win. + if name == "constructor" && class_id != 0 && is_class_id_registered(class_id) { + let constructor = super::super::js_get_global_this_builtin_value( + b"Function".as_ptr(), + b"Function".len(), + ); + return JSValue::from_bits(constructor.to_bits()); + } + } + return JSValue::undefined(); +} + #[cfg(test)] mod primitive_proto_accessor_tests_10648 { use super::*; @@ -1315,408 +1730,11 @@ fn get_field_by_name_past_data_probe( // `SQL.Aliased` lookup pattern. { let bits = obj as u64; - if (bits >> 48) == 0x7FFE && !key.is_null() { - let class_id = (bits & 0xFFFF_FFFF) as u32; - let class_value = f64::from_bits(bits); - let is_prototype_ref = super::super::class_prototype_ref_id(class_value).is_some(); - unsafe { - let name_ptr = (key as *const u8).add(std::mem::size_of::()); - let name_len = (*key).byte_len as usize; - let name = std::str::from_utf8(std::slice::from_raw_parts(name_ptr, name_len)) - .unwrap_or(""); - // v0.5.752: class_ref.constructor synthesizes back to the - // same class ref so drizzle's - // `Object.getPrototypeOf(value).constructor === Class` chain - // collapses correctly (with v0.5.751's getPrototypeOf - // returning the class ref for instance receivers). Refs - // #420 / #618 followup. - if is_prototype_ref - && name == "constructor" - && class_id != 0 - && class_has_own_method(class_id, name) - { - let value = class_prototype_method_value_for_name(class_id, name); - return JSValue::from_bits(value.to_bits()); - } - if name == "constructor" - && is_prototype_ref - && class_id != 0 - && is_class_id_registered(class_id) - { - let value = if is_prototype_ref { - super::super::class_constructor_ref_value(class_id) - } else { - class_value - }; - return JSValue::from_bits(value.to_bits()); - } - if name == "prototype" - && class_id != 0 - && is_class_id_registered(class_id) - && !is_prototype_ref - { - let value = super::super::class_registry::class_decl_prototype_value(class_id); - if value.to_bits() == crate::value::TAG_UNDEFINED { - let value = super::super::class_prototype_ref_value(class_id); - return JSValue::from_bits(value.to_bits()); - } - return JSValue::from_bits(value.to_bits()); - } - // A capture-carrying class declaration is materialized as a - // heap class object (`ClassExprFresh`). References that were - // lowered before the declaration's runtime binding existed - // (the class constructor itself and earlier helper closures) - // still carry the template `ClassRef`. Runtime additions such - // as `Object.defineProperty(C, "OPEN", { value: 1 })` live on - // the materialized object, so consulting only the template - // tables makes `C.OPEN` undefined in those bodies even though - // the same expression at the declaration site reads `1`. - // - // `CLASS_OBJECT_VALUES` is already the runtime identity used - // by `instance.constructor`. Read that same current - // evaluation first, preserving its own-property and pinned - // static-parent semantics; a miss continues through the - // ordinary ClassRef registry path below. - if !is_prototype_ref { - if let Some(class_object) = - super::super::class_registry::class_object_value_for_cid(class_id) - { - let class_object = JSValue::from_bits(class_object.to_bits()); - if class_object.is_pointer() { - let class_object = class_object.as_pointer::(); - if !class_object.is_null() && class_object as usize != obj as usize { - let value = js_object_get_field_by_name(class_object, key); - if !value.is_undefined() { - return value; - } - } - } - } - } - // Instance (prototype) methods must only resolve when reading - // off the prototype ref (`C.prototype.m`), NOT off the class ref - // itself (`C.m`). In JS a class object does not expose its - // prototype methods as static members: `class C { m(){} }` has - // `C.m === undefined` (the method lives on `C.prototype`). The - // earlier unconditional lookup leaked instance methods onto the - // class ref, so `C.m` returned a (mis-bound) function. This - // broke NestJS interceptor/guard/pipe resolution: its - // `getInterceptorInstance` duck-types `!!metatype.intercept` to - // decide "is this a class or an already-built instance"; a - // truthy `Class.intercept` made it treat the CLASS as the - // instance, so `intercept()` ran with a broken receiver and - // returned `{}`, which rxjs `innerFrom` then rejected. Real - // static methods are resolved below via - // `lookup_static_method_in_chain`. - if is_prototype_ref && class_id != 0 && class_has_own_method(class_id, name) { - let value = class_prototype_method_value_for_name(class_id, name); - return JSValue::from_bits(value.to_bits()); - } - if is_prototype_ref { - // Class accessors are properties of the class prototype - // chain (charter step 3); `this` is the prototype ref. - if let Some((v, _)) = super::super::class_registry::class_chain_getter_value( - class_id, - name, - || class_value, - ) { - return v; - } - return JSValue::undefined(); - } - // Empty-string is a legal static member key (`static get ''()`); - // the `!name.is_empty()` guard below skips it, so resolve a - // static accessor named "" here (Test262 accessor-name-static - // literal-string-empty). - if name.is_empty() { - if let Some(v) = - super::super::class_registry::class_static_accessor_getter_value( - class_id, - name, - class_value, - ) - { - return JSValue::from_bits(v.to_bits()); - } - } - if !name.is_empty() { - if super::super::class_registry::class_is_key_deleted(class_id, name) { - return JSValue::undefined(); - } - let result = CLASS_DYNAMIC_PROPS.with(|m| { - m.borrow() - .get(&class_id) - .and_then(|props| props.get(name).copied()) - }); - if let Some(v) = result { - return JSValue::from_bits(v.to_bits()); - } - // Static DATA fields are INHERITED by subclasses, exactly like - // static methods: `class D {}; D.kind = "x"; class G extends D {}` - // makes `G.kind === "x"` (the class-object proto chain - // `G.__proto__ === D` carries statics). The own-field read above - // only consulted `class_id`; walk the parent class_id chain here - // so an inherited static field (or runtime `Parent.x = …` - // assignment — both live in CLASS_DYNAMIC_PROPS) resolves. Static - // METHODS are handled by `lookup_static_method_in_chain` below; - // this covers the data-field case that was returning `undefined` - // (Auth.js sets `SignInError.kind = "signIn"` and reads it off a - // `CredentialsSignin` subclass to pick the sign-in vs error page). - // - // #6530: `name` is an OWN property of every constructor — a - // subclass never inherits its parent's `.name` (spec: - // ClassDefinitionEvaluation installs it per class). Skip the - // chain walk so the #2059 own-name synthesis below answers - // with THIS class's registered name instead of an ancestor's. - if !matches!(name, "name" | "length") { - // Walk the class-object proto chain for an inherited static - // DATA field. At EACH level the class's pinned - // per-evaluation parent OBJECT is consulted BEFORE the - // parent's registry props (`CLASS_DYNAMIC_PROPS`). - // - // #6552: a subclass of a class-EXPRESSION value evaluated - // more than once (`function make(a){return class{static - // ast=a}}`, then `class Number$ extends make(x) {}` / - // `class Widget$ extends make(y) {}`) records THIS - // evaluation's parent object as its static prototype - // (`class_prototype_object`, #1788), but the parent's - // `CLASS_DYNAMIC_PROPS` are keyed by the class-expression - // TEMPLATE id — shared, last-wins across every evaluation. - // Reading the registry entry for such a parent collapses - // sibling subclasses to the LAST `make(...)` (effect Schema: - // `Number$.ast`/`Widget$.ast` both read the last parent's - // `ast`). The pinned object carries this evaluation's own - // edge, so it is authoritative; the registry read remains - // the fallback for a plain declaration parent (#6443: - // Auth.js `SignInError.kind`), which has no pinned object. - let mut child = class_id; - let mut depth = 0usize; - while depth < 32 { - // The constructor's own `[[Prototype]]`, set by - // `Object.setPrototypeOf(Ctor, obj)`. Checked first: - // it is the nearest static-side link, and unlike - // `class_prototype_object` it is never on an - // instance's chain. - let static_proto = - super::super::class_registry::class_static_prototype(child); - if !static_proto.is_null() { - // #10911: this walk re-enters with the PARENT as - // the object. It was written when effect's `ast` - // was a static DATA field (see above), where the - // object doesn't matter; effect now makes `ast` a - // static GETTER, and a getter found this way ran - // with `this` === the parent class. Stash the - // class the read started from so the accessor - // binds it (spec OrdinaryGet threads Receiver) -- - // the same device `resolve_proto_chain_field_inner` - // uses for instance getters. - let prev = - crate::object::field_get_set::accessor_receiver_override_begin( - class_value, - ); - let v = js_object_get_field_by_name(static_proto as *const _, key); - crate::object::field_get_set::accessor_receiver_override_end(prev); - if !v.is_undefined() { - return v; - } - } - let proto = super::super::class_registry::class_prototype_object(child); - if !proto.is_null() { - let prev = - crate::object::field_get_set::accessor_receiver_override_begin( - class_value, - ); - let v = js_object_get_field_by_name(proto as *const _, key); - crate::object::field_get_set::accessor_receiver_override_end(prev); - // Return a value present on the pinned object even - // when it is `null` — a static explicitly set to - // `null` on THIS evaluation is authoritative and - // must not fall through to the last-wins registry - // entry (a sibling evaluation's value). Only - // `undefined` means "absent here", which continues - // the walk to the parent's registry props / a higher - // ancestor. - if !v.is_undefined() { - return v; - } - } - let p = match get_parent_class_id(child) { - Some(p) if p != 0 && p != child => p, - _ => break, - }; - // A key deleted on THIS ancestor is not provided by it, - // but a higher ancestor may still define it — `delete - // Mid.foo` must let `Sub.foo` inherit `Base.foo`, not - // resolve to undefined. Skip the registry read for the - // deleted level and keep walking up. - if !super::super::class_registry::class_is_key_deleted(p, name) { - let inherited = CLASS_DYNAMIC_PROPS.with(|m| { - m.borrow() - .get(&p) - .and_then(|props| props.get(name).copied()) - }); - if let Some(v) = inherited { - return JSValue::from_bits(v.to_bits()); - } - } - child = p; - depth += 1; - } - } - if super::super::class_registry::lookup_static_method_in_chain(class_id, name) - .is_some() - { - let heap_name = { - let layout = - std::alloc::Layout::from_size_align(name_len.max(1), 1).unwrap(); - let ptr = std::alloc::alloc(layout); - std::ptr::copy_nonoverlapping(name_ptr, ptr, name_len); - ptr - }; - let result = js_class_method_bind(class_value, heap_name, name_len); - return JSValue::from_bits(result.to_bits()); - } - // `class X extends Promise` — a value read of an inherited - // builtin static (`X.resolve`, `X.all`, …) resolves to the - // reified Promise static (so `X.resolve.bind(X)` works). Only - // fires when no user static shadowed it above. - if super::super::promise_parent_in_chain(class_id) - && super::super::promise_static_function_spec(name).is_some() - { - let v = super::super::js_promise_static_function_value(name_ptr, name_len); - if v.to_bits() != crate::value::TAG_UNDEFINED { - return JSValue::from_bits(v.to_bits()); - } - } - if let Some(v) = - super::super::class_registry::class_static_accessor_getter_value( - class_id, - name, - class_value, - ) - { - return JSValue::from_bits(v.to_bits()); - } - // #1788: a subclass of a class-expression value - // (`class Sub extends make("A") {}`) inherits the parent - // class OBJECT's OWN per-evaluation static fields. The - // parent object was recorded as `class_id`'s static - // prototype at `extends` time; walk that chain (also - // covering multi-level `class Leaf extends Mid {}`). - // #6530: except `name` — an own property of every - // constructor, never inherited; without the guard a - // subclass of a per-evaluation class object reported its - // BASE's synthesized `.name` (bundled zod: - // `z.string().constructor.name` gave "ZodType"). - if !matches!(name, "name" | "length") { - if let Some(v) = - super::super::class_registry::resolve_proto_chain_field(class_id, key) - { - if !v.is_undefined() && !v.is_null() { - return v; - } - } - } - // #36 / #321: the subclass extends a FUNCTION value - // (`class Svc extends Context.Tag(id)<...>() {}`). Read the - // named static off the parent closure — its OWN props - // (`Svc.key` → "Svc") plus, via the closure getter, its - // static prototype (`Svc._op` → "Tag" on TagProto). - // #10210: the edge is keyed by the class that directly - // `extends `, which may be an ANCESTOR of this - // class (`class Flags extends ConfigTag {}` where - // `ConfigTag extends Context.Service()(id)`), so walk the - // parent chain like `super()` dispatch does. - if let Some(closure_ptr) = - super::super::class_registry::parent_closure_in_chain(class_id) - { - let v = crate::closure::closure_get_dynamic_prop(closure_ptr, name); - let vb = JSValue::from_bits(v.to_bits()); - if !vb.is_undefined() && !vb.is_null() { - return vb; - } - } - // #2059: the constructor's built-in `name` own property — - // the class name. Checked last so an explicit static - // `name` member (method/field, handled above) still wins. - // This is what `assert.throws` reads via - // `thrown.constructor.name` to label the thrown error. - if name == "name" - && class_id != 0 - && !super::super::class_registry::class_is_key_deleted(class_id, name) - { - if let Some(cname) = - super::super::class_registry::class_name_for_id(class_id) - { - let s = crate::string::js_string_from_bytes( - cname.as_ptr(), - cname.len() as u32, - ); - return JSValue::from_bits(crate::js_nanbox_string(s as i64).to_bits()); - } - } - if name == "length" - && class_id != 0 - && !is_prototype_ref - && !super::super::class_registry::class_is_key_deleted(class_id, name) - { - if let Some(length) = - super::super::class_registry::class_length_for_id(class_id) - { - return JSValue::number(length as f64); - } - } - // A class constructor is also a Function object. Reify - // inherited Function.prototype methods for value reads - // (`const bind = C.bind`) just as the closure path does; - // the captured ClassRef is accepted by native method - // dispatch and by `js_function_bind`. - if !is_prototype_ref { - if let Some(method) = super::reified_function_method_name(name) { - let value = - crate::closure::reify_function_method_value(class_value, method); - return JSValue::from_bits(value.to_bits()); - } - } - // No own static / inherited entry resolved the name. A class - // constructor is a function, so a bare read of `.caller` or - // `.arguments` hits the poison-pill %ThrowTypeError% accessor - // on `Function.prototype` — strict-mode throws (Perry only - // compiles strict code). Placed last so any own static field, - // accessor, or `defineProperty`-installed data prop of that - // name takes precedence. Prototype-refs (`C.prototype`) are - // plain objects and are excluded. - if !is_prototype_ref && matches!(name, "caller" | "arguments") { - crate::fs::validate::throw_type_error_with_code( - "Restricted function property access", - "ERR_INVALID_ARG_TYPE", - ); - } - // #11492: a constructor's chain ends at %Function.prototype%, - // so a user method or expando installed there - // (`Function.prototype.myHelper = fn`) is readable through - // `C.myHelper` exactly as through a closure. - if !is_prototype_ref { - if let Some(v) = - crate::closure::function_prototype_inherited_get(0, name, class_value) - { - return JSValue::from_bits(v.to_bits()); - } - } - } - // The built-in constructor object's `constructor` value is - // inherited from Function.prototype. It is therefore only the - // fallback after own computed fields, static methods, and - // static accessors of the same name have had a chance to win. - if name == "constructor" && class_id != 0 && is_class_id_registered(class_id) { - let constructor = super::super::js_get_global_this_builtin_value( - b"Function".as_ptr(), - b"Function".len(), - ); - return JSValue::from_bits(constructor.to_bits()); - } - } - return JSValue::undefined(); + if let (Some(class_id), false) = ( + crate::object::class_value::legacy_class_ptr_word(bits), + key.is_null(), + ) { + return class_value_get_field(obj, key, bits, class_id); } } // #1545: Promise `then`/`catch`/`finally` value-reads return a bound diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs index b78abaeaf2..7cd6c780b7 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs @@ -130,6 +130,16 @@ pub(crate) fn get_field_by_name_object_tail( if key.is_null() { return JSValue::undefined(); } + // A class constructor: its class lookup with this key header. + if crate::closure::shape::is_class_code( + (*(obj as *const crate::closure::ClosureHeader)).func_ptr, + ) { + if let Some(value) = + super::has_property::class_closure_read_by_key(obj as usize, key) + { + return JSValue::from_bits(value.to_bits()); + } + } let key_ptr = (key as *const u8).add(std::mem::size_of::()); let key_len = (*key).byte_len as usize; let key_bytes = std::slice::from_raw_parts(key_ptr, key_len); diff --git a/crates/perry-runtime/src/object/field_get_set/has_property.rs b/crates/perry-runtime/src/object/field_get_set/has_property.rs index d3297da56e..eb0e59a990 100644 --- a/crates/perry-runtime/src/object/field_get_set/has_property.rs +++ b/crates/perry-runtime/src/object/field_get_set/has_property.rs @@ -6,7 +6,7 @@ use super::*; /// Presence of a Symbol-keyed STATIC member on a class ref, for `sym in Class` /// (#6160). Covers the registration schemes the generic symbol resolver /// (`js_object_get_symbol_property`, which only reads the data-valued -/// CLASS_STATIC_SYMBOLS table) skips: +/// class function object's static symbols) skips: /// * user computed-symbol methods/accessors (`static [S]() {}`, /// `static get [S]()`) → CLASS_SYMBOL_METHODS / CLASS_SYMBOL_ACCESSORS; /// * `static [Symbol.hasInstance]` → the lifted per-class has-instance hook; @@ -370,7 +370,7 @@ pub extern "C" fn js_object_has_property(obj: f64, key: f64) -> f64 { // #6160: `Symbol in Class` where the member is a Symbol-keyed STATIC member // that registers through a scheme the generic symbol resolver below // (`js_object_get_symbol_property`) does not consult — it only sees the - // data-valued CLASS_STATIC_SYMBOLS table. `class_ref_has_symbol_member` + // data-valued class static symbols. `class_ref_has_symbol_member` // presence-checks the method/accessor and well-known static registrations, // so `sym in Class` matches Node even though those members dispatch through // dedicated call paths. Presence-only: `in` is [[HasProperty]], never [[Get]]. @@ -407,14 +407,18 @@ pub extern "C" fn js_object_has_property(obj: f64, key: f64) -> f64 { } // Refs #420 / #618: `Symbol in ClassRef` — drizzle's `entityKind in cls`. - // Class refs are INT32-tagged. Check CLASS_STATIC_SYMBOLS for symbol + // Class refs are INT32-tagged. Check the class's static symbols for symbol // keys and CLASS_DYNAMIC_PROPS for string keys. { let bits = obj.to_bits(); - if (bits >> 48) == 0x7FFE { - let class_id = (bits & 0xFFFF_FFFF) as u32; + if let Some(class_id) = crate::object::class_value::legacy_class_value_word(bits) { // Symbol key path. - if crate::symbol::class_static_symbol_lookup(class_id, key).is_some() { + let found = if crate::object::class_prototype_ref_id(obj).is_some() { + crate::symbol::class_static_symbol_lookup(class_id, key) + } else { + crate::symbol::class_static_symbol_lookup_in_chain(class_id, key) + }; + if found.is_some() { return nanbox_true; } // #6149: string key on a class ref (`"prototype" in C`, @@ -1388,6 +1392,25 @@ pub(crate) unsafe fn prototype_value_has_property( /// Get a field by its string key name /// Returns the field value or undefined if the key is not found +/// A class function object read with the caller's own key header (no key +/// string is built): its class lookup. `None` for any other receiver — one +/// ShapeId-word pre-filter; the class shape is sticky and implies the class +/// code pointer. +#[inline] +pub(crate) unsafe fn class_closure_read_by_key( + obj: usize, + key: *const crate::StringHeader, +) -> Option { + let class_id = crate::object::class_value::class_closure_id(obj)?; + let value = super::get_field_by_name::class_value_get_field( + obj as *const crate::object::ObjectHeader, + key, + obj as u64, + class_id, + ); + Some(f64::from_bits(value.bits())) +} + pub(crate) unsafe fn closure_dynamic_prop_by_key( obj: usize, key: *const crate::StringHeader, @@ -1396,7 +1419,7 @@ pub(crate) unsafe fn closure_dynamic_prop_by_key( return None; } let name = crate::string::header_str_checked(key)?; - let val = crate::closure::closure_get_dynamic_prop(obj, name); + let val = crate::closure::closure_get_dynamic_prop_keyed(obj, name, key); // Function methods were already resolved, including a getter or own // slot returning undefined. Do not repeat that read or synthesize a // fallback method over an explicit undefined value (#11175). diff --git a/crates/perry-runtime/src/object/field_set_by_name.rs b/crates/perry-runtime/src/object/field_set_by_name.rs index f58f9664aa..2c7d3d35d8 100644 --- a/crates/perry-runtime/src/object/field_set_by_name.rs +++ b/crates/perry-runtime/src/object/field_set_by_name.rs @@ -426,8 +426,10 @@ pub extern "C" fn js_object_set_field_by_name( // so a later `SQL.Aliased` read can find it. { let bits = obj as u64; - if (bits >> 48) == 0x7FFE && !key.is_null() { - let class_id = (bits & 0xFFFF_FFFF) as u32; + if let (Some(class_id), false) = ( + crate::object::class_value::legacy_class_ptr_word(bits), + key.is_null(), + ) { unsafe { let name_ptr = (key as *const u8).add(std::mem::size_of::()); @@ -435,13 +437,15 @@ pub extern "C" fn js_object_set_field_by_name( let name = std::str::from_utf8(std::slice::from_raw_parts(name_ptr, name_len)) .unwrap_or("") .to_string(); - let recv = f64::from_bits(bits); + let recv = crate::object::class_value::boxed_class_word(bits); let is_prototype_ref = super::class_prototype_ref_id(recv).is_some(); if !is_prototype_ref && name == "name" && !super::class_registry::class_is_key_deleted(class_id, &name) && super::class_registry::lookup_static_method_in_chain(class_id, &name) .is_none() + && super::class_registry::class_static_defined_attrs(class_id, &name) + .is_none_or(|(writable, _, _)| !writable) { return; } @@ -450,11 +454,7 @@ pub extern "C" fn js_object_set_field_by_name( .is_some() || super::native_module::class_has_own_method(class_id, &name) } else { - CLASS_DYNAMIC_PROPS.with(|m| { - m.borrow() - .get(&class_id) - .is_some_and(|props| props.contains_key(&name)) - }) + crate::object::class_value::class_static_get(class_id, &name).is_some() }; // `C.prototype[key] = v` where `key` is an instance // accessor invokes the setter with `this = C.prototype`. @@ -502,6 +502,20 @@ pub extern "C" fn js_object_set_field_by_name( ); crate::typed_feedback::invalidate_method_change(class_id); } else { + // A read-only own static (defineProperty writable:false): + // strict-mode [[Set]] throws; the value stays. + if has_own_data + && super::class_registry::class_static_defined_attrs(class_id, &name) + .is_some_and(|(writable, _, _)| !writable) + { + let message = format!( + "Cannot assign to read only property '{name}' of function '{}'", + super::class_registry::class_ref_to_string(class_id) + ); + crate::node_submodules::diagnostics::throw_type_error_no_code( + message.as_bytes(), + ); + } // #9526: a declared static has two views: runtime class // property dispatch and the LLVM global used by direct // `C.name` reads. Keep both coherent for every runtime diff --git a/crates/perry-runtime/src/object/field_set_by_name/attr_variants.rs b/crates/perry-runtime/src/object/field_set_by_name/attr_variants.rs index 848bae6dc3..49f876ca4a 100644 --- a/crates/perry-runtime/src/object/field_set_by_name/attr_variants.rs +++ b/crates/perry-runtime/src/object/field_set_by_name/attr_variants.rs @@ -23,7 +23,7 @@ pub extern "C" fn js_object_set_field_by_name_nonenum( // TypedArrays, Temporal cells, etc. are handled by `set_field_by_name`'s own // routing and never reach the ordinary enumerable default, so skip them. let bits = obj as u64; - if (bits >> 48) == 0x7FFE + if crate::object::class_value::legacy_class_ptr_word(bits).is_some() || crate::value::addr_class::is_handle_band(obj as usize) || key.is_null() { @@ -64,7 +64,7 @@ pub extern "C" fn js_object_set_field_by_name_nonconfigurable( ) { js_object_set_field_by_name(obj, key, value); let bits = obj as u64; - if (bits >> 48) == 0x7FFE + if crate::object::class_value::legacy_class_ptr_word(bits).is_some() || crate::value::addr_class::is_handle_band(obj as usize) || key.is_null() { diff --git a/crates/perry-runtime/src/object/global_this/bigint_promise.rs b/crates/perry-runtime/src/object/global_this/bigint_promise.rs index d03cb60106..43441930fe 100644 --- a/crates/perry-runtime/src/object/global_this/bigint_promise.rs +++ b/crates/perry-runtime/src/object/global_this/bigint_promise.rs @@ -756,7 +756,7 @@ fn require_typed_array_from_of_constructor() { /// non-constructable builtin. fn value_is_constructor(value: f64) -> bool { let bits = value.to_bits(); - if (bits >> 48) == 0x7FFE { + if crate::object::class_value::legacy_class_value_word(bits).is_some() { return true; // class-ref constructor } if crate::proxy::js_proxy_is_proxy(value) == 1 { diff --git a/crates/perry-runtime/src/object/global_this/fetch_globals.rs b/crates/perry-runtime/src/object/global_this/fetch_globals.rs index 65ca81f8e6..7aaa9cfbb8 100644 --- a/crates/perry-runtime/src/object/global_this/fetch_globals.rs +++ b/crates/perry-runtime/src/object/global_this/fetch_globals.rs @@ -1028,7 +1028,6 @@ pub unsafe extern "C" fn js_fetch_or_value_super( const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; const TAG_MASK: u64 = 0xFFFF_0000_0000_0000; const PTR_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; - const INT32_TAG: u64 = 0x7FFE_0000_0000_0000; // A dynamic parent that resolved to a ClassRef (INT32-tagged) is a // real registered Perry class — `class X extends _mod.default` // where the default export is a user class (Next.js @@ -1038,8 +1037,7 @@ pub unsafe extern "C" fn js_fetch_or_value_super( // base constructor would never run — parent `this. = …` // writes (e.g. `this.nextConfig = opts`) would be lost. Invoke the // class constructor directly on `this` instead. - if bits & TAG_MASK == INT32_TAG { - let parent_cid = bits as u32; + if let Some(parent_cid) = crate::object::class_value::class_value_id(parent_val) { if let Some(obj) = subclass_this_object_ptr(this_box) { return super::super::class_constructors::run_class_constructor_on_this_flat( parent_cid, obj as i64, args_ptr, args_len, diff --git a/crates/perry-runtime/src/object/instanceof.rs b/crates/perry-runtime/src/object/instanceof.rs index 7b8c44d60a..4c4c13bb45 100644 --- a/crates/perry-runtime/src/object/instanceof.rs +++ b/crates/perry-runtime/src/object/instanceof.rs @@ -33,6 +33,7 @@ pub use static_dispatch::js_instanceof; /// representation: heap closures (declarations / expressions / arrows / /// methods / bound functions / built-in constructors, all carrying /// `CLOSURE_MAGIC`) and small native function handles. +#[inline] pub(crate) fn value_is_callable(value: f64) -> bool { if crate::value::is_js_handle(value) && crate::value::js_handle_is_function(value) { return true; @@ -44,13 +45,11 @@ pub(crate) fn value_is_callable(value: f64) -> bool { // user-crafted NaN payload sharing this tag band (e.g. via // `DataView.setFloat64` — a real JS number, not a class ref) is not // misclassified as callable. - if class_ref_id(value).is_some() { - return true; - } let jv = crate::JSValue::from_bits(value.to_bits()); if !jv.is_pointer() { - return false; + return class_ref_id(value).is_some(); } + // A class function object is a closure: one probe answers both. crate::closure::is_closure_ptr((jv.bits() & crate::value::POINTER_MASK) as usize) } diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index d79477935c..30e4a1660c 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -84,6 +84,7 @@ mod class_gc_roots; mod class_handles; pub mod class_image; mod class_registry; +pub(crate) mod class_value; #[cfg(test)] mod zeroed_cache_tests; pub(crate) use class_registry::async_resource_prototype_value; @@ -375,7 +376,7 @@ pub(crate) use this_binding::{ pub use this_binding::{ js_implicit_this_get, js_implicit_this_get_sloppy, js_implicit_this_set, js_new_target_get, js_new_target_set, js_static_this_arm_classref, js_static_this_arm_value, - js_static_this_resolve, ImplicitThisScope, + js_static_this_resolve, js_static_this_resolve_class, ImplicitThisScope, }; pub use to_string_tag::js_object_to_string; pub(crate) use to_string_tag::typed_array_to_string_tag_name; @@ -741,19 +742,6 @@ pub(crate) struct ShapeCacheEntry { } crate::perry_thread_local! { - /// Issue #618-followup / drizzle SQL.Aliased: dynamic properties added - /// via the IIFE pattern `((SQL2) => { SQL2.Aliased = Aliased; })(SQL)` - /// to imported classes (which Perry stores as INT32-tagged class ids). - /// Pre-fix `js_object_set_field_by_name` saw the receiver as an INT32 - /// "small handle" and silently dropped the assignment. Now route through - /// this side-table keyed by class_id. - pub(crate) static CLASS_DYNAMIC_PROPS: std::cell::RefCell>> = - std::cell::RefCell::new(std::collections::HashMap::new()); - /// Property-creation order for `CLASS_DYNAMIC_PROPS`. The value table is a - /// HashMap for hot lookup, while [[OwnPropertyKeys]] needs first-insertion - /// order (with delete + re-add moving a key to the end). - pub(crate) static CLASS_DYNAMIC_PROP_ORDER: std::cell::RefCell>> = - std::cell::RefCell::new(std::collections::HashMap::new()); /// #7190: `(writable, enumerable)` for static own keys installed by /// `Object.defineProperty(C, k, desc)`. They live in `CLASS_DYNAMIC_PROPS` /// next to `static x = …` fields, which are writable AND enumerable by diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index c4107d3262..2bb9a53831 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -714,8 +714,8 @@ pub unsafe extern "C-unwind" fn js_native_call_method_value( if sym_key != 0 { let bits = object.to_bits(); let top16 = bits >> 48; - if top16 == 0x7FFE { - let class_id = (bits & 0xFFFF_FFFF) as u32; + let _ = top16; + if let Some(class_id) = crate::object::class_value::legacy_class_value_word(bits) { let is_prototype_ref = crate::object::class_prototype_ref_id(object).is_some(); if is_prototype_ref { if let Some((func_ptr, param_count, has_rest)) = diff --git a/crates/perry-runtime/src/object/native_call_method/common_methods.rs b/crates/perry-runtime/src/object/native_call_method/common_methods.rs index f64bb78d25..3dbf6baaee 100644 --- a/crates/perry-runtime/src/object/native_call_method/common_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/common_methods.rs @@ -75,9 +75,10 @@ pub(super) unsafe fn dispatch_common( if crate::symbol::js_is_symbol(key_value) != 0 { return Some(super::object_ops::js_object_has_own(object, key_value)); } - if (object.to_bits() >> 48) == 0x7FFE { + if let Some(class_id) = + crate::object::class_value::legacy_class_value_word(object.to_bits()) + { let key_str = crate::builtins::js_string_coerce(key_value); - let class_id = (object.to_bits() & 0xFFFF_FFFF) as u32; let present = if key_str.is_null() { false } else { @@ -115,14 +116,12 @@ pub(super) unsafe fn dispatch_common( { super::class_registry::class_name_for_id(class_id).is_some() } else { - CLASS_DYNAMIC_PROPS.with(|m| { - m.borrow() - .get(&class_id) - .is_some_and(|props| props.contains_key(key)) - }) || super::class_registry::lookup_static_method_in_chain( - class_id, key, - ) - .is_some() + crate::object::class_value::class_static_get(class_id, key) + .is_some() + || super::class_registry::lookup_static_method_in_chain( + class_id, key, + ) + .is_some() } }) .unwrap_or(false); @@ -717,8 +716,10 @@ pub(crate) unsafe fn dispatch_function_proto_method( "call" => { // Class constructors have no [[Call]] slot. `C.call(...)` must // reject instead of treating the INT32-tagged ClassRef payload as - // a closure pointer in the generic Function.prototype path. - if super::class_ref_id(object).is_some() { + // a closure pointer in the generic Function.prototype path. (A + // class FUNCTION OBJECT needs no gate: its code is the throwing + // [[Call]] `js_class_constructor_called`.) + if (object.to_bits() >> 48) == 0x7FFE && super::class_ref_id(object).is_some() { throw_fn_proto_not_callable("call"); } // Proxy receiver (#3656): `p.call(thisArg, ...args)` routes through @@ -820,7 +821,7 @@ pub(crate) unsafe fn dispatch_function_proto_method( } } "apply" => { - if super::class_ref_id(object).is_some() { + if (object.to_bits() >> 48) == 0x7FFE && super::class_ref_id(object).is_some() { throw_fn_proto_not_callable("apply"); } // Proxy receiver (#3656): `p.apply(thisArg, argsArray)` routes diff --git a/crates/perry-runtime/src/object/native_call_method/function_shape.rs b/crates/perry-runtime/src/object/native_call_method/function_shape.rs index 6be6c0851f..10ac520e5f 100644 --- a/crates/perry-runtime/src/object/native_call_method/function_shape.rs +++ b/crates/perry-runtime/src/object/native_call_method/function_shape.rs @@ -60,7 +60,11 @@ pub(crate) unsafe fn try_function_shape_method_call( return dictionary_function_proto_method_call(object, addr, name, args_ptr, args_len); } if !crate::closure::shape::function_shape_inherits_from_function_prototype(word, name) { - return None; + // A class constructor (`C.m()` on a class value): the class arm — + // statics, callable static data, Function.prototype methods. Reached + // only once the ordinary function test failed, so no other receiver + // pays for it. + return function_shape_decline(object, addr, name, args_ptr, args_len); } // (2) The prototype the shape names, and its own data slot for the key. let proto = @@ -89,6 +93,24 @@ pub(crate) unsafe fn try_function_shape_method_call( /// from the receiver's ACTUAL prototype (`reify_function_method_value`, which /// reads `getPrototypeOf(fn)`). The intrinsic runs the tower's semantics; any /// other callable (`p.call`) is invoked with the function as `this`. +/// The shape arm declined an inherited Function.prototype method: a class +/// constructor goes to the class arm; anything else back to the tower. Out of +/// line so the inlined arm stays small. +#[cold] +#[inline(never)] +unsafe fn function_shape_decline( + object: f64, + addr: usize, + name: &[u8], + args_ptr: *const f64, + args_len: usize, +) -> Option { + if crate::closure::shape::is_class_code((*(addr as *const ClosureHeader)).func_ptr) { + return super::primitive_methods::class_value_method_call(object, name, args_ptr, args_len); + } + None +} + unsafe fn dictionary_function_proto_method_call( object: f64, addr: usize, diff --git a/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs b/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs index c66c4b9c01..878ba10073 100644 --- a/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs @@ -1,6 +1,174 @@ use super::typed_array::*; use super::*; +/// `C.m(args)` on a class function object (routed by the function-shape arm, +/// which proved the class ShapeId): the class arm of [`dispatch_primitive`] +/// with its own rooting. `None` falls back to the generic tower. +/// +/// # Safety +/// `args_ptr` is valid for `args_len` reads (or null with `args_len == 0`). +#[cold] +#[inline(never)] +pub(crate) unsafe fn class_value_method_call( + object: f64, + name: &[u8], + args_ptr: *const f64, + args_len: usize, +) -> Option { + let method_name = std::str::from_utf8(name).ok()?; + let root_scope = crate::gc::RuntimeHandleScope::new(); + let object_handle = root_scope.root_nanbox_f64(object); + let original_args: Vec = if args_len > 0 && !args_ptr.is_null() { + std::slice::from_raw_parts(args_ptr, args_len).to_vec() + } else { + Vec::new() + }; + let arg_handles = root_scope.root_nanbox_f64_slice(&original_args); + let class_id = crate::object::class_value::class_value_id(object)?; + if let Some(r) = class_receiver_arm( + class_id, + &root_scope, + &object_handle, + &arg_handles, + object_handle.get_nanbox_f64(), + method_name, + name.as_ptr() as *const i8, + name.len(), + args_ptr, + args_len, + ) { + return Some(r); + } + dispatch_primitive( + &root_scope, + &object_handle, + &arg_handles, + object_handle.get_nanbox_f64(), + method_name, + name.as_ptr() as *const i8, + name.len(), + args_ptr, + args_len, + ) +} + +/// The class-constructor arm of the method tower: `C.m(args)` on a class +/// value (its function object, routed here by the function-shape arm, or the +/// legacy INT32 / `C.prototype` immediate from `dispatch_primitive`). +#[allow(clippy::too_many_arguments)] +#[inline(never)] +unsafe fn class_receiver_arm( + class_id: u32, + root_scope: &crate::gc::RuntimeHandleScope, + object_handle: &crate::gc::RuntimeHandle, + arg_handles: &[crate::gc::RuntimeHandle], + object: f64, + method_name: &str, + method_name_ptr: *const i8, + method_name_len: usize, + args_ptr: *const f64, + args_len: usize, +) -> Option { + let refreshed_args = || crate::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(arg_handles); + let _ = (root_scope, &refreshed_args); + if crate::object::class_prototype_ref_id(object).is_some() { + if let Some((func_ptr, param_count, has_synthetic_arguments, has_rest)) = + crate::object::class_registry::lookup_class_method_in_chain(class_id, method_name) + { + return Some(crate::object::class_registry::call_vtable_method( + func_ptr, + object.to_bits() as i64, + args_ptr, + args_len, + param_count, + has_synthetic_arguments, + has_rest, + )); + } + } else if class_id != 0 + && crate::object::class_registry::lookup_static_method_in_chain(class_id, method_name) + .is_some() + { + let args = refreshed_args(); + return Some(crate::object::class_registry::js_class_static_method_call( + object_handle.get_nanbox_f64(), + method_name_ptr as *const u8, + method_name_len, + args.as_ptr(), + args.len(), + )); + } else if class_id != 0 + && matches!( + method_name, + "bind" | "call" | "apply" | "isPrototypeOf" | "toString" + ) + && crate::object::class_registry::class_own_static_field_value(class_id, method_name) + .is_none() + { + // These are inherited Function/Object prototype operations, not + // static data members. Let `dispatch_common` handle them. Looking + // them up as a class property here reifies a bound method whose + // dispatch re-enters this same arm indefinitely (`C.call(...)` + // exhausted the native stack instead of throwing TypeError). + return match method_name { + "bind" => Some(crate::closure::js_function_bind(object, args_ptr, args_len)), + "call" | "apply" => super::proto_dispatch::throw_fn_proto_not_callable(method_name), + _ => None, + }; + } else if class_id != 0 && !method_name_ptr.is_null() && method_name_len > 0 { + // #5437: `C.viaFn()` where `viaFn` is a static DATA property holding a + // callable (`C.viaFn = fn` / `static viaFn = fn`), NOT a registered + // static method. A class reference VALUE is an INT32-tagged class id, + // not a heap object, so the generic object field-scan below can't deref + // it; and these statics live in CLASS_DYNAMIC_PROPS, not the static- + // method vtable, so the arm above misses them. The bug surfaced as a + // method call on a class returned from / aliased through a function + // (`const D = C; D.viaFn()`), where the static analyzer couldn't prove + // the receiver is a class object and lowered it to this dynamic path. + // Resolve the property exactly as the read-then-call path does + // (`js_object_get_field_by_name` walks the class-ref static chain), + // then invoke the callable with `this` bound to the class ref — + // mirroring `const f = C.viaFn; f()`, which already worked. + let key_ptr = crate::string::js_string_from_bytes( + method_name_ptr as *const u8, + method_name_len as u32, + ); + let prop = js_object_get_field_by_name(object.to_bits() as *const ObjectHeader, key_ptr); + let prop_bits = prop.bits(); + let raw = (prop_bits & crate::value::POINTER_MASK) as usize; + if (prop_bits & crate::value::TAG_MASK) == crate::value::POINTER_TAG + && crate::closure::is_closure_ptr(raw) + { + // Rebind the closure's reserved `this` slot to the class ref, as + // the prototype/field method-dispatch arms above do. A static + // data property holding an object-literal method (`captures_this`) + // bakes `this` into a capture slot that `IMPLICIT_THIS` alone + // can't override; `clone_closure_rebind_this` is a no-op for + // closures that don't capture `this`, so plain functions and + // arrows are unaffected. + let bound = crate::closure::clone_closure_rebind_this( + prop_bits, + object_handle.get_nanbox_f64(), + ); + let prop_handle = root_scope.root_nanbox_f64(f64::from_bits(bound)); + let args = refreshed_args(); + // #8495: root the displaced receiver across the call below. + let prev_this_scope = crate::gc::RuntimeHandleScope::new(); + let prev_this_h = prev_this_scope.root_nanbox_u64( + IMPLICIT_THIS.with(|c| c.replace(object_handle.get_nanbox_f64().to_bits())), + ); + let result = crate::closure::js_native_call_value( + prop_handle.get_nanbox_f64(), + args.as_ptr(), + args.len(), + ); + IMPLICIT_THIS.with(|c| c.set(prev_this_h.get_nanbox_u64())); + return Some(result); + } + } + None +} + pub(super) unsafe fn dispatch_primitive( root_scope: &crate::gc::RuntimeHandleScope, object_handle: &crate::gc::RuntimeHandle, @@ -34,102 +202,25 @@ pub(super) unsafe fn dispatch_primitive( )); } + // A legacy class value (the INT32 immediate or `C.prototype`); class + // function objects arrive through `class_value_method_call`. if (object.to_bits() >> 48) == 0x7FFE { - let class_id = (object.to_bits() & 0xFFFF_FFFF) as u32; - if crate::object::class_prototype_ref_id(object).is_some() { - if let Some((func_ptr, param_count, has_synthetic_arguments, has_rest)) = - crate::object::class_registry::lookup_class_method_in_chain(class_id, method_name) - { - return Some(crate::object::class_registry::call_vtable_method( - func_ptr, - object.to_bits() as i64, - args_ptr, - args_len, - param_count, - has_synthetic_arguments, - has_rest, - )); - } - } else if class_id != 0 - && crate::object::class_registry::lookup_static_method_in_chain(class_id, method_name) - .is_some() + if let Some(class_id) = + crate::object::class_value::legacy_class_value_word(object.to_bits()) { - let args = refreshed_args(); - return Some(crate::object::class_registry::js_class_static_method_call( - object_handle.get_nanbox_f64(), - method_name_ptr as *const u8, - method_name_len, - args.as_ptr(), - args.len(), - )); - } else if class_id != 0 - && matches!( + if let Some(r) = class_receiver_arm( + class_id, + root_scope, + object_handle, + arg_handles, + object, method_name, - "bind" | "call" | "apply" | "isPrototypeOf" | "toString" - ) - && crate::object::class_registry::class_own_static_field_value(class_id, method_name) - .is_none() - { - // These are inherited Function/Object prototype operations, not - // static data members. Let `dispatch_common` handle them. Looking - // them up as a class property here reifies a bound method whose - // dispatch re-enters this same arm indefinitely (`C.call(...)` - // exhausted the native stack instead of throwing TypeError). - return match method_name { - "bind" => Some(crate::closure::js_function_bind(object, args_ptr, args_len)), - "call" | "apply" => super::proto_dispatch::throw_fn_proto_not_callable(method_name), - _ => None, - }; - } else if class_id != 0 && !method_name_ptr.is_null() && method_name_len > 0 { - // #5437: `C.viaFn()` where `viaFn` is a static DATA property holding a - // callable (`C.viaFn = fn` / `static viaFn = fn`), NOT a registered - // static method. A class reference VALUE is an INT32-tagged class id, - // not a heap object, so the generic object field-scan below can't deref - // it; and these statics live in CLASS_DYNAMIC_PROPS, not the static- - // method vtable, so the arm above misses them. The bug surfaced as a - // method call on a class returned from / aliased through a function - // (`const D = C; D.viaFn()`), where the static analyzer couldn't prove - // the receiver is a class object and lowered it to this dynamic path. - // Resolve the property exactly as the read-then-call path does - // (`js_object_get_field_by_name` walks the class-ref static chain), - // then invoke the callable with `this` bound to the class ref — - // mirroring `const f = C.viaFn; f()`, which already worked. - let key_ptr = crate::string::js_string_from_bytes( - method_name_ptr as *const u8, - method_name_len as u32, - ); - let prop = - js_object_get_field_by_name(object.to_bits() as *const ObjectHeader, key_ptr); - let prop_bits = prop.bits(); - let raw = (prop_bits & crate::value::POINTER_MASK) as usize; - if (prop_bits & crate::value::TAG_MASK) == crate::value::POINTER_TAG - && crate::closure::is_closure_ptr(raw) - { - // Rebind the closure's reserved `this` slot to the class ref, as - // the prototype/field method-dispatch arms above do. A static - // data property holding an object-literal method (`captures_this`) - // bakes `this` into a capture slot that `IMPLICIT_THIS` alone - // can't override; `clone_closure_rebind_this` is a no-op for - // closures that don't capture `this`, so plain functions and - // arrows are unaffected. - let bound = crate::closure::clone_closure_rebind_this( - prop_bits, - object_handle.get_nanbox_f64(), - ); - let prop_handle = root_scope.root_nanbox_f64(f64::from_bits(bound)); - let args = refreshed_args(); - // #8495: root the displaced receiver across the call below. - let prev_this_scope = crate::gc::RuntimeHandleScope::new(); - let prev_this_h = prev_this_scope.root_nanbox_u64( - IMPLICIT_THIS.with(|c| c.replace(object_handle.get_nanbox_f64().to_bits())), - ); - let result = crate::closure::js_native_call_value( - prop_handle.get_nanbox_f64(), - args.as_ptr(), - args.len(), - ); - IMPLICIT_THIS.with(|c| c.set(prev_this_h.get_nanbox_u64())); - return Some(result); + method_name_ptr, + method_name_len, + args_ptr, + args_len, + ) { + return Some(r); } } } diff --git a/crates/perry-runtime/src/object/native_module.rs b/crates/perry-runtime/src/object/native_module.rs index 174da6cdfa..821fccecbb 100644 --- a/crates/perry-runtime/src/object/native_module.rs +++ b/crates/perry-runtime/src/object/native_module.rs @@ -1247,325 +1247,15 @@ pub extern "C" fn js_native_module_bind_method( bound_native_callable_export_value(&module_name, property_name) } -/// Build a "bound method" closure for `obj.method` PropertyGet on a known class -/// instance. The captures (instance, method_name_ptr, method_name_len) drive -/// `dispatch_bound_method` (closure.rs), which calls `js_native_call_method` -/// — that resolves the method through `CLASS_VTABLE_REGISTRY` for any class -/// registered by `js_register_class_method` at module init. -/// -/// Issue #446: previously a class method reference (`let f = obj.method`, -/// `typeof obj.method`, `arr.map(obj.method)`) silently lowered to the -/// generic property-bag lookup, which doesn't store prototype methods — -/// every such read returned `undefined`, so `typeof obj.method === "undefined"` -/// and a captured method ran no body when invoked. -/// -/// Method-name pointer is expected to be stable for the closure's lifetime; -/// codegen emits it from the per-module `.str.N.bytes` rodata global. -#[no_mangle] -pub extern "C" fn js_class_method_bind( - instance: f64, - method_name_ptr: *const u8, - method_name_len: usize, -) -> f64 { - if !method_name_ptr.is_null() && method_name_len > 0 { - if let Ok(name) = unsafe { - std::str::from_utf8(std::slice::from_raw_parts(method_name_ptr, method_name_len)) - } { - if matches!( - name, - "append" - | "delete" - | "entries" - | "forEach" - | "get" - | "getSetCookie" - | "has" - | "keys" - | "set" - | "Symbol.iterator" - | "@@iterator" - | "values" - ) { - let bits = instance.to_bits(); - if (bits >> 48) == 0x7FFD { - let id = (bits & 0x0000_FFFF_FFFF_FFFF) as i64; - if crate::value::addr_class::is_small_handle(id as usize) { - if let Some(dispatch) = handle_property_dispatch() { - let value = HANDLE_PROPERTY_BIND_REENTRY.with(|guard| { - if guard.get() { - None - } else { - guard.set(true); - let value = - unsafe { dispatch(id, method_name_ptr, method_name_len) }; - guard.set(false); - Some(value) - } - }); - if let Some(value) = value { - if value.to_bits() != crate::value::TAG_UNDEFINED { - return value; - } - } - } - } - } - } - } - } - - // Method IDENTITY (test262 class/elements): a class method is a single - // shared function object, so `c.m`, `c2.m` and `C.prototype.m` must all be - // the IDENTICAL value. Route every user-class method-as-value read through - // the per-`(owner_class, name)` cached canonical built by - // `class_prototype_method_value_for_name` instead of minting a fresh - // per-receiver closure here. The canonical captures the OWNER class's - // prototype-ref (capture 0); `dispatch_bound_method` recognises that marker - // and supplies the call-site `this` (IMPLICIT_THIS) so invocations still see - // the right receiver — e.g. the `this.m = this.m.bind(this)` idiom rebinds - // correctly, and a bare `const f = c.m; f()` runs with the spec `this`. - // - // Guard against re-entry from `class_prototype_method_value_for_name` - // itself: it builds the canonical by calling `build_bound_method_closure` - // directly (NOT this function), so the cache is populated without looping. - if !method_name_ptr.is_null() && method_name_len > 0 { - if let Ok(name) = unsafe { - std::str::from_utf8(std::slice::from_raw_parts(method_name_ptr, method_name_len)) - } { - // #7689: a CONSTRUCTOR class-ref receiver (`const f = C.m`) must - // never canonicalize to the INSTANCE vtable method of the same - // name — in JS `C.m` sees only statics (`class C { static lex(){} - // lex(){} }` has `C.lex` === the static; the instance `lex` lives - // on `C.prototype`). `class_id_from_method_receiver` treats a - // class ref like an instance, so marked's `const lexer2 = - // _Lexer.lex; lexer2(src, opt)` extracted the instance `lex`, - // whose bare invocation read `this.options` off an unconstructed - // receiver. Fall through to `build_bound_method_closure`: its - // call-time dispatch (`js_native_call_method`'s 0x7FFE arm) - // resolves statics-first for constructor refs. PROTOTYPE refs - // (`C.prototype.m`) keep the canonical path — the instance method - // is exactly what they name. - let receiver_is_constructor_ref = - class_ref_id(instance).is_some() && class_prototype_ref_id(instance).is_none(); - if !receiver_is_constructor_ref && bound_native_method_length(name).is_none() { - if let Some(class_id) = class_id_from_method_receiver(instance) { - let private_owner = super::take_private_method_owner_hint(name); - if let Some(owner) = private_owner - .or_else(|| super::class_registry::method_owner_class_id(class_id, name)) - { - // [[Get]] order: an OWN data property of this name - // shadows the prototype method. The ubiquitous - // `this.m = this.m.bind(this)` idiom installs an own `m` - // (a bound function), so `obj.m` must read that own value - // back — not the shared prototype method. Skipping this - // both returned the wrong identity (`obj.m === - // C.prototype.m` where Node says false) and looped when - // the canonical re-resolved `m` by name. A class - // prototype-ref receiver has no own-property bag, so this - // check is naturally a no-op there. - let recv_jsv = JSValue::from_bits(instance.to_bits()); - if private_owner.is_none() - && recv_jsv.is_pointer() - && !super::class_registry::is_registered_class_prototype_object( - crate::value::js_nanbox_get_pointer(instance) as usize, - ) - { - let obj = recv_jsv.as_pointer::(); - if crate::value::addr_class::is_above_handle_band(obj as usize) { - let key = crate::string::js_string_from_bytes( - method_name_ptr, - method_name_len as u32, - ); - if let Some(own) = - unsafe { super::own_data_field_by_name(obj, key) } - { - if own.bits() != crate::value::TAG_UNDEFINED { - return f64::from_bits(own.bits()); - } - } - } - } - let lexical_owner = private_owner - .and_then(|owner| super::current_private_lexical_brand_value(owner)); - let canonical = lexical_owner - .or_else(|| private_evaluation_brand_value(instance)) - .map(|brand| class_evaluation_method_value_for_name(owner, name, brand)) - .unwrap_or_else(|| class_prototype_method_value_for_name(owner, name)); - if canonical.to_bits() != crate::value::TAG_UNDEFINED { - return canonical; - } - } - } - } - } - } - - build_bound_method_closure(instance, method_name_ptr, method_name_len) -} - -/// Perry's intentional `this.method` value-read contract: capture the instance -/// at read time so a later own-property replacement cannot change the method's -/// receiver or target. Ordinary `obj.method` reads still use -/// [`js_class_method_bind`] and its canonical per-class value identity. -/// -/// An own value that already exists wins at read time. This keeps constructor -/// arrow overrides (`this.m = () => ...; const f = this.m`) on the ordinary -/// property path instead of replacing them with a prototype-method snapshot. -#[no_mangle] -pub extern "C" fn js_class_method_snapshot_bind( - instance: f64, - method_name_ptr: *const u8, - method_name_len: usize, -) -> f64 { - let value = JSValue::from_bits(instance.to_bits()); - if !value.is_pointer() - || class_registry::is_class_object_value(instance) - || class_id_from_method_receiver(instance).is_none() - { - return js_class_method_bind(instance, method_name_ptr, method_name_len); - } - - let scope = crate::gc::RuntimeHandleScope::new(); - let instance_handle = scope.root_nanbox_f64(instance); - if !method_name_ptr.is_null() && method_name_len > 0 { - let key = crate::string::js_string_from_bytes(method_name_ptr, method_name_len as u32); - let key_handle = scope.root_string_ptr(key); - let current = instance_handle.get_nanbox_f64(); - let obj = JSValue::from_bits(current.to_bits()).as_pointer::(); - if crate::value::addr_class::is_above_handle_band(obj as usize) { - let own = key_handle.with_const_ptr::(|key| unsafe { - super::own_data_field_by_name(obj, key) - }); - if let Some(own) = own { - if own.bits() != crate::value::TAG_UNDEFINED { - return f64::from_bits(own.bits()); - } - } - } - } - - build_bound_method_closure( - instance_handle.get_nanbox_f64(), - method_name_ptr, - method_name_len, - ) -} - -/// By-ID sibling of `js_class_method_bind` for static-name lowering. -/// -/// Current codegen passes an immutable AOT descriptor. Legacy heap/short-string -/// ids remain accepted for ABI compatibility. -#[no_mangle] -pub extern "C" fn js_class_method_bind_by_id(instance: f64, method_id: i64) -> f64 { - let mut scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN]; - let Some(name_ref) = crate::string::perry_string_ref_from_dispatch_id(method_id, &mut scratch) - else { - return f64::from_bits(crate::value::TAG_UNDEFINED); - }; - js_class_method_bind(instance, name_ref.ptr, name_ref.len) -} - -#[cfg(feature = "keepalive-anchors")] -#[used(compiler)] -static KEEP_CLASS_METHOD_BIND_BY_ID: extern "C" fn(f64, i64) -> f64 = js_class_method_bind_by_id; - -#[cfg(test)] -thread_local! { - static TEST_COLLECT_BOUND_METHOD_AFTER_CAPTURE_INIT: std::cell::Cell = - const { std::cell::Cell::new(false) }; - static TEST_BOUND_METHOD_MOVE: std::cell::Cell<(usize, usize)> = - const { std::cell::Cell::new((0, 0)) }; -} - -#[cfg(test)] -pub(crate) fn test_collect_bound_method_after_capture_init() { - TEST_COLLECT_BOUND_METHOD_AFTER_CAPTURE_INIT.with(|armed| armed.set(true)); - TEST_BOUND_METHOD_MOVE.with(|trace| trace.set((0, 0))); -} - +mod class_method_bind; +use class_method_bind::build_bound_method_closure_with_private_brand; +pub use class_method_bind::{ + js_class_method_bind, js_class_method_bind_by_id, js_class_method_snapshot_bind, +}; #[cfg(test)] -pub(crate) fn test_take_bound_method_move() -> (usize, usize) { - TEST_BOUND_METHOD_MOVE.with(|trace| trace.replace((0, 0))) -} - -fn build_bound_method_closure_with_private_brand( - instance: f64, - method_name_ptr: *const u8, - method_name_len: usize, - private_brand: Option, -) -> f64 { - // `js_closure_alloc` can collect before it returns, so keep the receiver - // live across that allocation. The metadata installation below allocates a - // string for `.name` and can collect again; keep the newly-created closure - // in an outer handle and reload it after every such call. Without the outer - // handle, `set_bound_native_closure_name` protected the closure only inside - // its own scope and this function could return the now-forwarded from-space - // address. A caller such as Next's Reflect.get adapter observes that stale - // method value at an immediately-following `typeof` check (#8036). - let scope = crate::gc::RuntimeHandleScope::new(); - let instance_handle = scope.root_nanbox_f64(instance); - let private_brand_handle = private_brand.map(|brand| scope.root_nanbox_f64(brand)); - let closure_handle = scope.root_raw_mut_ptr(crate::closure::js_closure_alloc( - crate::closure::BOUND_METHOD_FUNC_PTR, - if private_brand_handle.is_some() { 4 } else { 3 }, - )); - // Capture-slot writes are scoped arguments to non-allocating stores, so - // the address cannot go stale inside the call. Each value is read from its - // own handle first, exactly as before. - let instance_value = instance_handle.get_nanbox_f64(); - closure_handle.with_mut_ptr::(|closure| { - crate::closure::js_closure_set_capture_f64(closure, 0, instance_value); - crate::closure::js_closure_set_capture_ptr(closure, 1, method_name_ptr as i64); - crate::closure::js_closure_set_capture_ptr(closure, 2, method_name_len as i64); - if let Some(brand) = &private_brand_handle { - crate::closure::js_closure_set_capture_f64(closure, 3, brand.get_nanbox_f64()); - } - }); - #[cfg(test)] - TEST_COLLECT_BOUND_METHOD_AFTER_CAPTURE_INIT.with(|armed| { - if armed.replace(false) { - let before = closure_handle - .with_mut_ptr::(|closure| closure as usize); - // The reload IS the subject of this hook: `across_mut` hands back - // the post-collection address without ever binding a pre-call one. - let (_, after) = closure_handle - .across_mut::(crate::gc::gc_collect_minor); - let after = after as usize; - TEST_BOUND_METHOD_MOVE.with(|trace| trace.set((before, after))); - } - }); - if !method_name_ptr.is_null() && method_name_len > 0 { - if let Ok(name) = unsafe { - std::str::from_utf8(std::slice::from_raw_parts(method_name_ptr, method_name_len)) - } { - closure_handle.with_mut_ptr::(|closure| { - set_bound_native_closure_name(closure, name) - }); - if let Some(length) = bound_native_method_length(name) { - closure_handle.with_mut_ptr::(|closure| { - set_builtin_closure_length(closure as usize, length) - }); - } else if let Some(class_id) = - class_id_from_method_receiver(instance_handle.get_nanbox_f64()) - { - // User class method bound as a value (`C.prototype.m`, `c.m`): - // stamp its spec `.length` from the registered param count so - // `C.prototype.m.length` reflects the declared arity instead of - // the closure's capture count (Test262 method `.length` tests). - if let Some(length) = - super::class_registry::class_method_bind_length(class_id, name) - { - closure_handle.with_mut_ptr::(|closure| { - set_builtin_closure_length(closure as usize, length) - }); - } - } - } - } - closure_handle.with_mut_ptr::(|closure| { - crate::value::js_nanbox_pointer(closure as i64) - }) -} +pub(crate) use class_method_bind::{ + test_collect_bound_method_after_capture_init, test_take_bound_method_move, +}; include!("native_module/class_method_values.rs"); @@ -1707,7 +1397,14 @@ pub(crate) fn canonical_bound_method_receiver(captured: f64) -> f64 { /// non-object allocation (an array, above all) must resolve to `None` rather /// than to whatever its bytes happen to hold at the `class_id` offset. pub(super) fn class_id_from_method_receiver(instance: f64) -> Option { - if let Some(cid) = class_ref_id(instance) { + class_id_from_method_receiver_known(instance, class_ref_id(instance)) +} + +/// [`class_id_from_method_receiver`] for a caller that already asked +/// `class_ref_id(instance)`. +#[inline] +fn class_id_from_method_receiver_known(instance: f64, class_ref: Option) -> Option { + if let Some(cid) = class_ref { return Some(cid); } let jsv = JSValue::from_bits(instance.to_bits()); diff --git a/crates/perry-runtime/src/object/native_module/class_method_bind.rs b/crates/perry-runtime/src/object/native_module/class_method_bind.rs new file mode 100644 index 0000000000..6b7c3a58af --- /dev/null +++ b/crates/perry-runtime/src/object/native_module/class_method_bind.rs @@ -0,0 +1,328 @@ +//! Class-method binding: the bound-method closures `obj.method` reads build +//! for class instances (`js_class_method_bind`, its snapshot and by-id +//! forms) and the private-brand-aware builder they share. + +use super::*; + +/// Build a "bound method" closure for `obj.method` PropertyGet on a known class +/// instance. The captures (instance, method_name_ptr, method_name_len) drive +/// `dispatch_bound_method` (closure.rs), which calls `js_native_call_method` +/// — that resolves the method through `CLASS_VTABLE_REGISTRY` for any class +/// registered by `js_register_class_method` at module init. +/// +/// Issue #446: previously a class method reference (`let f = obj.method`, +/// `typeof obj.method`, `arr.map(obj.method)`) silently lowered to the +/// generic property-bag lookup, which doesn't store prototype methods — +/// every such read returned `undefined`, so `typeof obj.method === "undefined"` +/// and a captured method ran no body when invoked. +/// +/// Method-name pointer is expected to be stable for the closure's lifetime; +/// codegen emits it from the per-module `.str.N.bytes` rodata global. +#[no_mangle] +pub extern "C" fn js_class_method_bind( + instance: f64, + method_name_ptr: *const u8, + method_name_len: usize, +) -> f64 { + if !method_name_ptr.is_null() && method_name_len > 0 { + if let Ok(name) = unsafe { + std::str::from_utf8(std::slice::from_raw_parts(method_name_ptr, method_name_len)) + } { + if matches!( + name, + "append" + | "delete" + | "entries" + | "forEach" + | "get" + | "getSetCookie" + | "has" + | "keys" + | "set" + | "Symbol.iterator" + | "@@iterator" + | "values" + ) { + let bits = instance.to_bits(); + if (bits >> 48) == 0x7FFD { + let id = (bits & 0x0000_FFFF_FFFF_FFFF) as i64; + if crate::value::addr_class::is_small_handle(id as usize) { + if let Some(dispatch) = handle_property_dispatch() { + let value = HANDLE_PROPERTY_BIND_REENTRY.with(|guard| { + if guard.get() { + None + } else { + guard.set(true); + let value = + unsafe { dispatch(id, method_name_ptr, method_name_len) }; + guard.set(false); + Some(value) + } + }); + if let Some(value) = value { + if value.to_bits() != crate::value::TAG_UNDEFINED { + return value; + } + } + } + } + } + } + } + } + + // Method IDENTITY (test262 class/elements): a class method is a single + // shared function object, so `c.m`, `c2.m` and `C.prototype.m` must all be + // the IDENTICAL value. Route every user-class method-as-value read through + // the per-`(owner_class, name)` cached canonical built by + // `class_prototype_method_value_for_name` instead of minting a fresh + // per-receiver closure here. The canonical captures the OWNER class's + // prototype-ref (capture 0); `dispatch_bound_method` recognises that marker + // and supplies the call-site `this` (IMPLICIT_THIS) so invocations still see + // the right receiver — e.g. the `this.m = this.m.bind(this)` idiom rebinds + // correctly, and a bare `const f = c.m; f()` runs with the spec `this`. + // + // Guard against re-entry from `class_prototype_method_value_for_name` + // itself: it builds the canonical by calling `build_bound_method_closure` + // directly (NOT this function), so the cache is populated without looping. + if !method_name_ptr.is_null() && method_name_len > 0 { + if let Ok(name) = unsafe { + std::str::from_utf8(std::slice::from_raw_parts(method_name_ptr, method_name_len)) + } { + // #7689: a CONSTRUCTOR class-ref receiver (`const f = C.m`) must + // never canonicalize to the INSTANCE vtable method of the same + // name — in JS `C.m` sees only statics (`class C { static lex(){} + // lex(){} }` has `C.lex` === the static; the instance `lex` lives + // on `C.prototype`). `class_id_from_method_receiver` treats a + // class ref like an instance, so marked's `const lexer2 = + // _Lexer.lex; lexer2(src, opt)` extracted the instance `lex`, + // whose bare invocation read `this.options` off an unconstructed + // receiver. Fall through to `build_bound_method_closure`: its + // call-time dispatch (`js_native_call_method`'s 0x7FFE arm) + // resolves statics-first for constructor refs. PROTOTYPE refs + // (`C.prototype.m`) keep the canonical path — the instance method + // is exactly what they name. + let receiver_class_ref = class_ref_id(instance); + let receiver_is_constructor_ref = + receiver_class_ref.is_some() && class_prototype_ref_id(instance).is_none(); + if !receiver_is_constructor_ref && bound_native_method_length(name).is_none() { + if let Some(class_id) = + class_id_from_method_receiver_known(instance, receiver_class_ref) + { + let private_owner = super::take_private_method_owner_hint(name); + if let Some(owner) = private_owner + .or_else(|| super::class_registry::method_owner_class_id(class_id, name)) + { + // [[Get]] order: an OWN data property of this name + // shadows the prototype method. The ubiquitous + // `this.m = this.m.bind(this)` idiom installs an own `m` + // (a bound function), so `obj.m` must read that own value + // back — not the shared prototype method. Skipping this + // both returned the wrong identity (`obj.m === + // C.prototype.m` where Node says false) and looped when + // the canonical re-resolved `m` by name. A class + // prototype-ref receiver has no own-property bag, so this + // check is naturally a no-op there. + let recv_jsv = JSValue::from_bits(instance.to_bits()); + if private_owner.is_none() + && recv_jsv.is_pointer() + && !super::class_registry::is_registered_class_prototype_object( + crate::value::js_nanbox_get_pointer(instance) as usize, + ) + { + let obj = recv_jsv.as_pointer::(); + if crate::value::addr_class::is_above_handle_band(obj as usize) { + let key = crate::string::js_string_from_bytes( + method_name_ptr, + method_name_len as u32, + ); + if let Some(own) = + unsafe { super::own_data_field_by_name(obj, key) } + { + if own.bits() != crate::value::TAG_UNDEFINED { + return f64::from_bits(own.bits()); + } + } + } + } + let lexical_owner = private_owner + .and_then(|owner| super::current_private_lexical_brand_value(owner)); + let canonical = lexical_owner + .or_else(|| private_evaluation_brand_value(instance)) + .map(|brand| class_evaluation_method_value_for_name(owner, name, brand)) + .unwrap_or_else(|| class_prototype_method_value_for_name(owner, name)); + if canonical.to_bits() != crate::value::TAG_UNDEFINED { + return canonical; + } + } + } + } + } + } + + build_bound_method_closure(instance, method_name_ptr, method_name_len) +} + +/// Perry's intentional `this.method` value-read contract: capture the instance +/// at read time so a later own-property replacement cannot change the method's +/// receiver or target. Ordinary `obj.method` reads still use +/// [`js_class_method_bind`] and its canonical per-class value identity. +/// +/// An own value that already exists wins at read time. This keeps constructor +/// arrow overrides (`this.m = () => ...; const f = this.m`) on the ordinary +/// property path instead of replacing them with a prototype-method snapshot. +#[no_mangle] +pub extern "C" fn js_class_method_snapshot_bind( + instance: f64, + method_name_ptr: *const u8, + method_name_len: usize, +) -> f64 { + let value = JSValue::from_bits(instance.to_bits()); + if !value.is_pointer() + || class_registry::is_class_object_value(instance) + || class_id_from_method_receiver(instance).is_none() + { + return js_class_method_bind(instance, method_name_ptr, method_name_len); + } + + let scope = crate::gc::RuntimeHandleScope::new(); + let instance_handle = scope.root_nanbox_f64(instance); + if !method_name_ptr.is_null() && method_name_len > 0 { + let key = crate::string::js_string_from_bytes(method_name_ptr, method_name_len as u32); + let key_handle = scope.root_string_ptr(key); + let current = instance_handle.get_nanbox_f64(); + let obj = JSValue::from_bits(current.to_bits()).as_pointer::(); + if crate::value::addr_class::is_above_handle_band(obj as usize) { + let own = key_handle.with_const_ptr::(|key| unsafe { + super::own_data_field_by_name(obj, key) + }); + if let Some(own) = own { + if own.bits() != crate::value::TAG_UNDEFINED { + return f64::from_bits(own.bits()); + } + } + } + } + + build_bound_method_closure( + instance_handle.get_nanbox_f64(), + method_name_ptr, + method_name_len, + ) +} + +/// By-ID sibling of `js_class_method_bind` for static-name lowering. +/// +/// Current codegen passes an immutable AOT descriptor. Legacy heap/short-string +/// ids remain accepted for ABI compatibility. +#[no_mangle] +pub extern "C" fn js_class_method_bind_by_id(instance: f64, method_id: i64) -> f64 { + let mut scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let Some(name_ref) = crate::string::perry_string_ref_from_dispatch_id(method_id, &mut scratch) + else { + return f64::from_bits(crate::value::TAG_UNDEFINED); + }; + js_class_method_bind(instance, name_ref.ptr, name_ref.len) +} + +#[cfg(feature = "keepalive-anchors")] +#[used(compiler)] +static KEEP_CLASS_METHOD_BIND_BY_ID: extern "C" fn(f64, i64) -> f64 = js_class_method_bind_by_id; + +#[cfg(test)] +thread_local! { + static TEST_COLLECT_BOUND_METHOD_AFTER_CAPTURE_INIT: std::cell::Cell = + const { std::cell::Cell::new(false) }; + static TEST_BOUND_METHOD_MOVE: std::cell::Cell<(usize, usize)> = + const { std::cell::Cell::new((0, 0)) }; +} + +#[cfg(test)] +pub(crate) fn test_collect_bound_method_after_capture_init() { + TEST_COLLECT_BOUND_METHOD_AFTER_CAPTURE_INIT.with(|armed| armed.set(true)); + TEST_BOUND_METHOD_MOVE.with(|trace| trace.set((0, 0))); +} + +#[cfg(test)] +pub(crate) fn test_take_bound_method_move() -> (usize, usize) { + TEST_BOUND_METHOD_MOVE.with(|trace| trace.replace((0, 0))) +} + +pub(super) fn build_bound_method_closure_with_private_brand( + instance: f64, + method_name_ptr: *const u8, + method_name_len: usize, + private_brand: Option, +) -> f64 { + // `js_closure_alloc` can collect before it returns, so keep the receiver + // live across that allocation. The metadata installation below allocates a + // string for `.name` and can collect again; keep the newly-created closure + // in an outer handle and reload it after every such call. Without the outer + // handle, `set_bound_native_closure_name` protected the closure only inside + // its own scope and this function could return the now-forwarded from-space + // address. A caller such as Next's Reflect.get adapter observes that stale + // method value at an immediately-following `typeof` check (#8036). + let scope = crate::gc::RuntimeHandleScope::new(); + let instance_handle = scope.root_nanbox_f64(instance); + let private_brand_handle = private_brand.map(|brand| scope.root_nanbox_f64(brand)); + let closure_handle = scope.root_raw_mut_ptr(crate::closure::js_closure_alloc( + crate::closure::BOUND_METHOD_FUNC_PTR, + if private_brand_handle.is_some() { 4 } else { 3 }, + )); + // Capture-slot writes are scoped arguments to non-allocating stores, so + // the address cannot go stale inside the call. Each value is read from its + // own handle first, exactly as before. + let instance_value = instance_handle.get_nanbox_f64(); + closure_handle.with_mut_ptr::(|closure| { + crate::closure::js_closure_set_capture_f64(closure, 0, instance_value); + crate::closure::js_closure_set_capture_ptr(closure, 1, method_name_ptr as i64); + crate::closure::js_closure_set_capture_ptr(closure, 2, method_name_len as i64); + if let Some(brand) = &private_brand_handle { + crate::closure::js_closure_set_capture_f64(closure, 3, brand.get_nanbox_f64()); + } + }); + #[cfg(test)] + TEST_COLLECT_BOUND_METHOD_AFTER_CAPTURE_INIT.with(|armed| { + if armed.replace(false) { + let before = closure_handle + .with_mut_ptr::(|closure| closure as usize); + // The reload IS the subject of this hook: `across_mut` hands back + // the post-collection address without ever binding a pre-call one. + let (_, after) = closure_handle + .across_mut::(crate::gc::gc_collect_minor); + let after = after as usize; + TEST_BOUND_METHOD_MOVE.with(|trace| trace.set((before, after))); + } + }); + if !method_name_ptr.is_null() && method_name_len > 0 { + if let Ok(name) = unsafe { + std::str::from_utf8(std::slice::from_raw_parts(method_name_ptr, method_name_len)) + } { + closure_handle.with_mut_ptr::(|closure| { + set_bound_native_closure_name(closure, name) + }); + if let Some(length) = bound_native_method_length(name) { + closure_handle.with_mut_ptr::(|closure| { + set_builtin_closure_length(closure as usize, length) + }); + } else if let Some(class_id) = + class_id_from_method_receiver(instance_handle.get_nanbox_f64()) + { + // User class method bound as a value (`C.prototype.m`, `c.m`): + // stamp its spec `.length` from the registered param count so + // `C.prototype.m.length` reflects the declared arity instead of + // the closure's capture count (Test262 method `.length` tests). + if let Some(length) = + super::class_registry::class_method_bind_length(class_id, name) + { + closure_handle.with_mut_ptr::(|closure| { + set_builtin_closure_length(closure as usize, length) + }); + } + } + } + } + closure_handle.with_mut_ptr::(|closure| { + crate::value::js_nanbox_pointer(closure as i64) + }) +} diff --git a/crates/perry-runtime/src/object/native_module/class_ref_values.rs b/crates/perry-runtime/src/object/native_module/class_ref_values.rs index eb02eed2d3..fab0d06f12 100644 --- a/crates/perry-runtime/src/object/native_module/class_ref_values.rs +++ b/crates/perry-runtime/src/object/native_module/class_ref_values.rs @@ -10,8 +10,16 @@ // away from the top of a module. pub(crate) const CLASS_PROTOTYPE_REF_FLAG: u64 = 1u64 << 32; +/// The VALUE of class `class_id`'s constructor: its function object. pub(crate) fn class_constructor_ref_value(class_id: u32) -> f64 { - f64::from_bits(0x7FFE_0000_0000_0000u64 | (class_id as u64 & 0xFFFF_FFFF)) + super::class_value::class_value(class_id) +} + +/// A stable, non-moving KEY for class `class_id`'s constructor, for side +/// tables that key by value bits (the legacy immediate's bits; never a value +/// handed to user code). +pub(crate) fn class_constructor_key_bits(class_id: u32) -> u64 { + 0x7FFE_0000_0000_0000u64 | (class_id as u64 & 0xFFFF_FFFF) } pub(crate) fn class_prototype_ref_value(class_id: u32) -> f64 { @@ -20,6 +28,7 @@ pub(crate) fn class_prototype_ref_value(class_id: u32) -> f64 { ) } +#[inline] pub(crate) fn class_prototype_ref_id(value: f64) -> Option { let bits = value.to_bits(); if (bits >> 48) == 0x7FFE && (bits & CLASS_PROTOTYPE_REF_FLAG) != 0 { @@ -31,15 +40,22 @@ pub(crate) fn class_prototype_ref_id(value: f64) -> Option { None } +/// A class constructor OR its `C.prototype` reference -> the class id. The +/// constructor half is [`super::class_value::class_value_id`] (both forms); +/// callers that mean only the constructor ask that directly. +#[inline] pub(crate) fn class_ref_id(value: f64) -> Option { let bits = value.to_bits(); - if (bits >> 48) == 0x7FFE { - let class_id = (bits & 0xFFFF_FFFF) as u32; - if class_id != 0 && is_class_id_registered(class_id) { - return Some(class_id); - } + match bits >> 48 { + // The legacy immediates: constructor or `C.prototype` reference. + 0x7FFE => super::class_value::class_value_id_bits(bits) + .or_else(|| class_prototype_ref_id(value)), + // A class function object (one pre-filter for any other pointer). + 0x7FFD => super::class_value::class_closure_id( + (bits & crate::value::POINTER_MASK) as usize, + ), + _ => None, } - None } pub(crate) unsafe fn metadata_key_to_string(value: f64) -> Option { diff --git a/crates/perry-runtime/src/object/object_ops/define_properties.rs b/crates/perry-runtime/src/object/object_ops/define_properties.rs index f962a07f33..74a3a8f297 100644 --- a/crates/perry-runtime/src/object/object_ops/define_properties.rs +++ b/crates/perry-runtime/src/object/object_ops/define_properties.rs @@ -360,19 +360,16 @@ pub extern "C" fn js_object_set_prototype_of(obj_value: f64, proto: f64) -> f64 } } - // Declared ES classes are represented by INT32-tagged ClassRefs rather - // than heap Function objects. Preserve Object.setPrototypeOf on a ClassRef - // as the class object's static prototype. Effect's Schema.Opaque depends - // on this exact shape: + // A class constructor's own [[Prototype]]: recorded on its function object + // (the state record every function object uses). Effect's Schema.Opaque + // depends on this exact shape: // // class Opaque {} // Object.setPrototypeOf(Opaque, schema) // class Partial extends Opaque {} // Partial.ast // - // Ordinary object and closure targets already have prototype side tables, - // but the ClassRef previously fell through as a no-op. Record it in - // CLASS_STATIC_PROTOTYPES — the CONSTRUCTOR-side table. + // It is the CONSTRUCTOR-side link. // // It must not go in CLASS_PROTOTYPE_OBJECTS: that table means "what // INSTANCES of this class inherit from", so parking a constructor link @@ -393,8 +390,9 @@ pub extern "C" fn js_object_set_prototype_of(obj_value: f64, proto: f64) -> f64 // GC root table that the collector later dereferences — a segfault // there, silently hidden on macOS (#1843/#4004/#4665/#4800/#6271). // Require a real, readable GC header instead. + // A function (including another class) is a valid [[Prototype]]: + // the link is a traced edge of the function object, not a table. if !proto_ptr.is_null() - && !crate::closure::is_closure_ptr(proto_ptr as usize) && crate::value::addr_class::is_above_handle_band(proto_ptr as usize) && unsafe { crate::value::addr_class::try_read_gc_header(proto_ptr as usize).is_some() diff --git a/crates/perry-runtime/src/object/object_ops/define_property.rs b/crates/perry-runtime/src/object/object_ops/define_property.rs index 8a2c2e24e3..2bc2a3ef52 100644 --- a/crates/perry-runtime/src/object/object_ops/define_property.rs +++ b/crates/perry-runtime/src/object/object_ops/define_property.rs @@ -796,7 +796,7 @@ pub extern "C" fn js_object_define_property( // `Object.defineProperty(C, Symbol.hasInstance, { value: fn })` (and // any symbol-keyed static define on a class): `metadata_key_to_string` // can't stringify a Symbol, so the value would be silently dropped. - // Route it into the class static-symbol table (CLASS_STATIC_SYMBOLS) — + // Route it into the class static symbols (the class function object's own symbol properties) — // the same table `static [Symbol.hasInstance]` registers into and that // `js_instanceof` consults — so `x instanceof C` honors the user hook // (zod 4 installs its brand-check `@@hasInstance` exactly this way). @@ -805,6 +805,8 @@ pub extern "C" fn js_object_define_property( // non-`undefined`: `Object.defineProperty(C, sym, { value: undefined })` // must still register an own entry. A generic redefine like // `{ enumerable: true }` (no `value`) leaves any existing entry intact. + let existed = + crate::symbol::class_static_symbol_lookup(target_cid, key_value).is_some(); if desc_has_field(descriptor_value, b"value") { let value_field = desc_read_field(descriptor_value, b"value"); crate::symbol::js_class_register_static_symbol( @@ -813,6 +815,33 @@ pub extern "C" fn js_object_define_property( f64::from_bits(value_field.bits()), ); } + // ValidateAndApplyPropertyDescriptor: omitted attributes are + // false on a new property and retained on an existing one. + let owner = crate::object::class_value::class_value_ptr(target_cid) as usize; + let sym_key = crate::symbol::sym_key_from_f64(key_value); + if crate::symbol::class_static_symbol_lookup(target_cid, key_value).is_some() { + let prior = crate::symbol::get_symbol_property_attrs(owner, sym_key) + .unwrap_or(crate::object::PropertyAttrs::new(existed, existed, existed)); + let descriptor_value = desc_handle.get_nanbox_f64(); + let pick = |field: &[u8], cur: bool| { + if desc_has_field(descriptor_value, field) { + crate::value::js_is_truthy(f64::from_bits( + desc_read_field(descriptor_value, field).bits(), + )) != 0 + } else { + cur + } + }; + crate::symbol::set_symbol_property_attrs( + owner, + sym_key, + crate::object::PropertyAttrs::new( + pick(b"writable", prior.writable()), + pick(b"enumerable", prior.enumerable()), + pick(b"configurable", prior.configurable()), + ), + ); + } return obj_value; } if let Some(name) = super::super::metadata_key_to_string(key_value) { @@ -876,7 +905,27 @@ pub extern "C" fn js_object_define_property( let value_key = crate::string::js_string_from_bytes(b"value".as_ptr(), 5); let value_field = js_object_get_field_by_name(desc_ptr as *const ObjectHeader, value_key); - if !value_field.is_undefined() { + let descriptor_value = desc_handle.get_nanbox_f64(); + let has_value = desc_has_field(descriptor_value, b"value"); + // ECMA-262 ValidateAndApplyPropertyDescriptor: an existing own + // static keeps every attribute the descriptor omits (a + // declared `static x` is writable+enumerable+configurable); + // a new key defaults them to false. + let existing_static = super::super::class_prototype_ref_id(obj_value) + .is_none() + .then(|| { + super::super::class_registry::class_own_static_field_value( + target_cid, &name, + ) + }) + .flatten() + .map(|_| { + super::super::class_registry::class_static_defined_attrs( + target_cid, &name, + ) + .unwrap_or((true, true, true)) + }); + if !value_field.is_undefined() || has_value || existing_static.is_some() { // #7190: `C` and `C.prototype` both answer // `class_ref_id` with the SAME class id — the arm this // sits in exists because `C.prototype` maps back to the @@ -899,11 +948,13 @@ pub extern "C" fn js_object_define_property( // `js_class_register_static_field` write to, so the // existing static read path finds it with no new // lookup. - super::super::class_registry::class_dynamic_prop_root_store( - target_cid, - &name, - f64::from_bits(value_field.bits()), - ); + if has_value || existing_static.is_none() { + super::super::class_registry::class_dynamic_prop_root_store( + target_cid, + &name, + f64::from_bits(value_field.bits()), + ); + } // A data descriptor is non-enumerable unless it // says otherwise; a `static x = …` field IS // enumerable, and both share CLASS_DYNAMIC_PROPS. @@ -922,17 +973,25 @@ pub extern "C" fn js_object_define_property( desc_read_field(descriptor_value, b"configurable").bits(), )) != 0 } else { - super::super::class_registry::class_static_defined_attrs( - target_cid, &name, - ) - .map(|(_, _, cfg)| cfg) - .unwrap_or(matches!(name.as_str(), "name" | "length")) + existing_static + .map(|(_, _, cfg)| cfg) + .unwrap_or(matches!(name.as_str(), "name" | "length")) + }; + let writable = if desc_has_field(descriptor_value, b"writable") { + descriptor_writable(descriptor_value) + } else { + existing_static.is_some_and(|(w, _, _)| w) + }; + let enumerable = if desc_has_field(descriptor_value, b"enumerable") { + descriptor_enumerable(descriptor_value) + } else { + existing_static.is_some_and(|(_, e, _)| e) }; super::super::class_registry::class_static_set_defined_attrs( target_cid, &name, - descriptor_writable(descriptor_value), - descriptor_enumerable(descriptor_value), + writable, + enumerable, configurable, ); return obj_value; diff --git a/crates/perry-runtime/src/object/object_ops/has_own.rs b/crates/perry-runtime/src/object/object_ops/has_own.rs index 9f6f3712f5..3d355e0f84 100644 --- a/crates/perry-runtime/src/object/object_ops/has_own.rs +++ b/crates/perry-runtime/src/object/object_ops/has_own.rs @@ -233,11 +233,8 @@ pub extern "C" fn js_object_has_own(obj_value: f64, key_value: f64) -> f64 { { super::super::class_registry::class_name_for_id(class_id).is_some() } else { - let has_public_data = CLASS_DYNAMIC_PROPS.with(|m| { - m.borrow() - .get(&class_id) - .is_some_and(|props| props.contains_key(key)) - }); + let has_public_data = + crate::object::class_value::class_static_get(class_id, key).is_some(); has_public_data || (!key.starts_with('#') && (super::super::class_registry::lookup_static_method_in_chain( @@ -540,7 +537,7 @@ pub extern "C" fn js_object_property_is_enumerable(obj_value: f64, key_value: f6 // non-enumerable. if crate::symbol::js_is_symbol(key_value) != 0 { let bits = obj_value.to_bits(); - if (bits >> 48) == 0x7FFE { + if crate::object::class_value::legacy_class_value_word(bits).is_some() { // ClassRef receivers: statics live in the class registry and // are non-enumerable like builtin statics. return f64::from_bits(TAG_FALSE); diff --git a/crates/perry-runtime/src/object/object_ops/prototype.rs b/crates/perry-runtime/src/object/object_ops/prototype.rs index 653400db9a..baf5a2f05c 100644 --- a/crates/perry-runtime/src/object/object_ops/prototype.rs +++ b/crates/perry-runtime/src/object/object_ops/prototype.rs @@ -221,8 +221,7 @@ fn get_prototype_of_resolved(obj_value: f64) -> f64 { let jv = crate::value::JSValue::from_bits(obj_value.to_bits()); // An INT32-tagged value may be a class ref (same 0x7FFE tag as small // integers) — those must keep flowing to the class resolution below. - let is_class_ref = (obj_value.to_bits() >> 48) == 0x7FFE - && super::super::class_ref_id(obj_value).is_some(); + let is_class_ref = super::super::class_ref_id(obj_value).is_some(); let wrapper = if is_class_ref { None } else if jv.is_number() { @@ -407,8 +406,7 @@ fn get_prototype_of_resolved(obj_value: f64) -> f64 { f64::from_bits(TAG_NULL) } }; - if top16 == 0x7FFE { - let class_id = (bits & 0xFFFF_FFFF) as u32; + if let Some(class_id) = crate::object::class_value::legacy_class_value_word(bits) { // An explicit `Object.setPrototypeOf(Ctor, obj)` wins over every // derived answer below — it IS the constructor's [[Prototype]]. if super::super::class_prototype_ref_id(obj_value).is_none() { @@ -444,8 +442,7 @@ fn get_prototype_of_resolved(obj_value: f64) -> f64 { // %Object.prototype%, so the synthetic class whose proto was that // namespace inherits Object.prototype too. if parent_id != 0 && parent_id != super::super::native_module::NATIVE_MODULE_CLASS_ID { - let parent_bits = 0x7FFE_0000_0000_0000u64 | (parent_id as u64); - return f64::from_bits(parent_bits); + return crate::object::class_value::class_value(parent_id); } } // Root of the class hierarchy. In JS `Object.getPrototypeOf` of a base diff --git a/crates/perry-runtime/src/object/property_key.rs b/crates/perry-runtime/src/object/property_key.rs index 1c71c8da4c..da68b8c1c3 100644 --- a/crates/perry-runtime/src/object/property_key.rs +++ b/crates/perry-runtime/src/object/property_key.rs @@ -413,9 +413,7 @@ pub unsafe extern "C" fn js_super_accessor_get( let mut cid = parent_class_id; let mut depth = 0usize; while cid != 0 && depth < 32 { - if let Some(v) = crate::object::CLASS_DYNAMIC_PROPS - .with(|m| m.borrow().get(&cid).and_then(|f| f.get(key_name)).copied()) - { + if let Some(v) = crate::object::class_value::class_static_get(cid, key_name) { return v; } match crate::object::get_parent_class_id(cid) { diff --git a/crates/perry-runtime/src/object/this_binding.rs b/crates/perry-runtime/src/object/this_binding.rs index f996dd19f4..15f47fdbf6 100644 --- a/crates/perry-runtime/src/object/this_binding.rs +++ b/crates/perry-runtime/src/object/this_binding.rs @@ -172,6 +172,42 @@ pub extern "C" fn js_static_this_resolve(default_this: f64) -> f64 { }) } +/// [`js_static_this_resolve`] for a static method of class `class_id`: the +/// armed override if any, else the class's function object, cached in the +/// method's own zero-initialised `slot` (the object is pinned, so the cached +/// bits never go stale) — no per-call class-table lookup. +// #1561-style force-keep: only generated IR calls this. +#[cfg(feature = "keepalive-anchors")] +#[used(compiler)] +static KEEP_JS_STATIC_THIS_RESOLVE_CLASS: unsafe extern "C" fn(i32, *mut f64) -> f64 = + js_static_this_resolve_class; + +/// # Safety +/// `slot` is null or the calling static method's own `double` cache global. +#[no_mangle] +pub unsafe extern "C" fn js_static_this_resolve_class(class_id: i32, slot: *mut f64) -> f64 { + let armed = STATIC_THIS_OVERRIDE.with(|c| { + let (armed, bits) = c.get(); + if armed { + c.set((false, crate::value::TAG_UNDEFINED)); + } + armed.then_some(bits) + }); + if let Some(bits) = armed { + return f64::from_bits(bits); + } + if !slot.is_null() && (*slot).to_bits() != 0 { + return *slot; + } + let value = super::class_value::class_value(class_id as u32); + if !slot.is_null() { + // GC_STORE_AUDIT(ROOT): a compiled cache slot holding a PINNED class + // function object (never moves), also rooted by the class-value table. + *slot = value; + } + value +} + /// Read the current implicit `this` (issue #519). #[no_mangle] pub extern "C" fn js_implicit_this_get() -> f64 { diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index c933074964..555e8c7f57 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -2433,7 +2433,7 @@ pub extern "C" fn js_super_put_value_set( // path looked at `Parent.prototype` and made valid static writes fail. if let Some(child_id) = crate::object::class_ref_id(receiver) { let target = if parent_class_id != 0 { - f64::from_bits(crate::value::INT32_TAG | parent_class_id as u64) + crate::object::class_value::class_value(parent_class_id) } else { crate::object::js_get_dynamic_parent_value(child_id) }; diff --git a/crates/perry-runtime/src/proxy/apply_construct.rs b/crates/perry-runtime/src/proxy/apply_construct.rs index 866aeb6604..8eea0862a2 100644 --- a/crates/perry-runtime/src/proxy/apply_construct.rs +++ b/crates/perry-runtime/src/proxy/apply_construct.rs @@ -19,7 +19,7 @@ use crate::closure::js_closure_call3; pub(crate) fn is_callable_function(value: f64) -> bool { let bits = value.to_bits(); // Class-ref constructors (INT32-tagged, top16 == 0x7FFE) are callable. - if (bits >> 48) == 0x7FFE { + if (bits >> 48) == 0x7FFE || crate::object::class_value::class_value_id(value).is_some() { return crate::object::class_ref_id(value).is_some(); } // A proxy whose target is callable is itself callable. diff --git a/crates/perry-runtime/src/proxy/metadata.rs b/crates/perry-runtime/src/proxy/metadata.rs index 3f2182416d..9dcfd43f29 100644 --- a/crates/perry-runtime/src/proxy/metadata.rs +++ b/crates/perry-runtime/src/proxy/metadata.rs @@ -120,14 +120,19 @@ pub extern "C" fn js_reflect_delete_metadata(key: f64, target: f64, property_key fn normalize_target_bits(target: f64) -> u64 { // Synthetic class-prototype ref → fold onto the class constructor key. if let Some(cid) = crate::object::class_prototype_ref_id(target) { - return crate::object::class_constructor_ref_value(cid).to_bits(); + return crate::object::class_constructor_key_bits(cid); + } + // A class constructor (either form) -> its stable key: the function object + // is a heap cell whose address is not a durable key. + if let Some(cid) = crate::object::class_value::class_value_id(target) { + return crate::object::class_constructor_key_bits(cid); } // Live decl-prototype heap object → fold onto the class constructor key. let bits = target.to_bits(); if (bits >> 48) == (POINTER_TAG >> 48) { let ptr = (bits & POINTER_MASK) as usize; if let Some(cid) = crate::object::class_id_for_decl_prototype_object(ptr) { - return crate::object::class_constructor_ref_value(cid).to_bits(); + return crate::object::class_constructor_key_bits(cid); } } bits @@ -303,13 +308,14 @@ mod tests { fn synthetic_prototype_ref_folds_onto_constructor_key() { let cid = 0x4242; register_test_class(cid); - let ctor_key = crate::object::class_constructor_ref_value(cid).to_bits(); + let ctor_key = crate::object::class_constructor_key_bits(cid); let proto_ref = crate::object::class_prototype_ref_value(cid); assert_eq!(normalize_target_bits(proto_ref), ctor_key); - // The constructor ref already IS the key — must pass through unchanged. + // The constructor VALUE (its function object) folds onto the same key. let ctor_ref = crate::object::class_constructor_ref_value(cid); + assert_ne!(ctor_ref.to_bits(), ctor_key, "the value is not the key"); assert_eq!(normalize_target_bits(ctor_ref), ctor_key); } @@ -328,7 +334,7 @@ mod tests { let target = f64::from_bits(POINTER_TAG | (fake_proto_ptr as u64 & POINTER_MASK)); assert_eq!( normalize_target_bits(target), - crate::object::class_constructor_ref_value(cid).to_bits() + crate::object::class_constructor_key_bits(cid) ); } diff --git a/crates/perry-runtime/src/symbol.rs b/crates/perry-runtime/src/symbol.rs index 66ea969030..3fb0f832bd 100644 --- a/crates/perry-runtime/src/symbol.rs +++ b/crates/perry-runtime/src/symbol.rs @@ -54,7 +54,8 @@ pub(crate) use properties::{ symbol_property_is_non_writable, symbol_property_root_bits, }; pub use properties::{ - class_static_symbol_lookup, js_class_register_static_symbol, js_object_has_own_symbol, + class_static_symbol_lookup, class_static_symbol_lookup_in_chain, + js_class_register_static_symbol, js_object_has_own_symbol, js_object_literal_infer_computed_function_name, js_object_set_method_by_name, js_object_set_symbol_method, js_object_set_symbol_property, }; @@ -959,22 +960,9 @@ pub(crate) fn release_symbol_tables_in_freed_ranges( changed |= map.len() != before; } } - // Class ids are process-global, but a member a dying thread stored holds - // its symbol and/or value. The symbol is deliberately NOT dereferenced: a - // `gc_malloc`'d symbol may already have been freed by the thread's - // `MallocState` destructor. `CLASS_STATIC_SYMBOL_ORDER` therefore keeps - // the removed member's symbol id; ids are monotonic and never reissued, - // so a stale id can only cost a few bytes, never a wrong position. - { - let mut guard = CLASS_STATIC_SYMBOLS - .lock() - .unwrap_or_else(PoisonError::into_inner); - if let Some(map) = guard.as_mut() { - let before = map.len(); - map.retain(|&(_, sym), bits| !freed.contains(sym) && !freed.holds_bits(*bits)); - changed |= map.len() != before; - } - } + // A class's static symbol members are own symbol properties of its + // function object (`SYMBOL_PROPERTIES`, owner = that object), which lives + // in the agent's heap: the owner-keyed pass above releases them. if changed { symbol_property_ic_epoch_bump(); } @@ -1037,15 +1025,11 @@ pub fn symbol_property_tables_hold_for_test(owner: usize, sym: usize) -> (bool, (props, attrs) } -/// Test probe (#11471): does class `class_id` hold a static member under the -/// symbol at `sym`? +/// Test probe (#11471): the owner key class `class_id`'s static symbol +/// members are stored under in the calling agent — its function object. #[doc(hidden)] -pub fn class_static_symbol_held_for_test(class_id: u32, sym: usize) -> bool { - CLASS_STATIC_SYMBOLS - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .as_ref() - .is_some_and(|map| map.contains_key(&(class_id, sym))) +pub fn class_static_symbol_owner_for_test(class_id: u32) -> usize { + crate::object::class_value::class_value_ptr(class_id) as usize } // Monotonic id counter for fresh symbols. Not thread-safe per-thread but @@ -1284,56 +1268,21 @@ pub(crate) fn store_object_symbol_property_root( /// Idle until a class declares a static Symbol-keyed member. /// -/// `js_instanceof` consults `CLASS_STATIC_SYMBOLS` for a `Symbol.hasInstance` +/// `js_instanceof` consults a class's static symbols for a `Symbol.hasInstance` /// override on EVERY evaluation, which meant a process-global `Mutex` plus a /// SipHash probe of an empty map for every `x instanceof C` in a program that /// never mentions a Symbol (#7769). pub(crate) static CLASS_STATIC_SYMBOLS_LATCH: crate::registry_latch::RegistryLatch = crate::registry_latch::RegistryLatch::new(); +/// A class's static Symbol-keyed data property (`static [sym] = v`, +/// `C[sym] = v`): an own symbol property of the class's function object, in +/// the same per-object store every object uses (the object is pinned, so its +/// address is a stable owner key). pub(crate) fn store_class_static_symbol_root(class_id: u32, sym_key: usize, value_bits: u64) { - note_symbol_key_installed(sym_key); CLASS_STATIC_SYMBOLS_LATCH.arm(); - let symbol_id = unsafe { (*(sym_key as *const SymbolHeader)).id }; - let created; - { - let mut guard = crate::gc::lock_gc_root_registry(&CLASS_STATIC_SYMBOLS); - if guard.is_none() { - *guard = Some(HashMap::new()); - } - created = guard - .as_mut() - .unwrap() - .insert((class_id, sym_key), value_bits) - .is_none(); - } - if created { - let mut order = CLASS_STATIC_SYMBOL_ORDER.lock().unwrap(); - if order.is_none() { - *order = Some(HashMap::new()); - } - order - .as_mut() - .unwrap() - .entry(class_id) - .or_default() - .push(symbol_id); - } - publish_symbol_side_table_root_edges(sym_key, value_bits); -} - -per_test_global! { - /// Class-id-keyed side table for static Symbol-keyed properties. - /// drizzle's `static [entityKind] = "Table"` registers - /// (class_id, sym_ptr) → value here at module init via - /// `js_class_register_static_symbol`. Consulted by `js_object_has_own` - /// when the receiver is a class identifier (NaN-boxed INT32_TAG). - /// Refs #420. - static CLASS_STATIC_SYMBOLS: Mutex>> = Mutex::new(None); - - /// Symbol-id creation order for static symbol data properties. IDs are - /// stable across moving GC, unlike the pointer keys in the value table. - static CLASS_STATIC_SYMBOL_ORDER: Mutex>>> = Mutex::new(None); + let owner = crate::object::class_value::class_value_ptr(class_id) as usize; + store_object_symbol_property_root(owner, sym_key, value_bits); } #[cfg(test)] diff --git a/crates/perry-runtime/src/symbol/gc_roots.rs b/crates/perry-runtime/src/symbol/gc_roots.rs index 8c05be3014..0366d57261 100644 --- a/crates/perry-runtime/src/symbol/gc_roots.rs +++ b/crates/perry-runtime/src/symbol/gc_roots.rs @@ -24,7 +24,6 @@ pub fn scan_symbol_side_table_roots_mut(visitor: &mut crate::gc::RuntimeRootVisi scan_symbol_property_roots_mut(visitor); scan_symbol_property_attrs_mut(visitor); accessors::scan_symbol_accessor_roots_mut(visitor); - scan_class_static_symbol_roots_mut(visitor); scan_symbol_pointer_metadata_roots_mut(visitor); } @@ -86,31 +85,6 @@ fn scan_symbol_property_attrs_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_ } } -fn scan_class_static_symbol_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { - let mut key_rewrites = Vec::new(); - let mut guard = crate::gc::lock_gc_root_registry(&CLASS_STATIC_SYMBOLS); - let Some(map) = guard.as_mut() else { - return; - }; - - for (class_id, old_sym_key) in map.keys().copied().collect::>() { - let Some(value_bits) = map.get_mut(&(class_id, old_sym_key)) else { - continue; - }; - let mut new_sym_key = old_sym_key; - if visitor.visit_usize_slot(&mut new_sym_key) && new_sym_key != old_sym_key { - key_rewrites.push(((class_id, old_sym_key), (class_id, new_sym_key))); - } - visitor.visit_nanbox_u64_slot(value_bits); - } - - for (old_key, new_key) in key_rewrites { - if let Some(value_bits) = map.remove(&old_key) { - map.insert(new_key, value_bits); - } - } -} - fn scan_symbol_pointer_metadata_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) { let mut rewrites = Vec::new(); let mut guard = crate::gc::lock_gc_root_registry(&SYMBOL_POINTERS); @@ -137,7 +111,6 @@ enum SymbolSideTableRootSlot { SymbolPropertyEntry { owner: usize, sym_key: usize }, SymbolPropertyAttrs { owner: usize, sym_key: usize }, SymbolAccessorProperty { owner: usize, sym_key: usize }, - ClassStaticSymbol { class_id: u32, sym_key: usize }, SymbolPointer { ptr: usize }, } @@ -197,15 +170,6 @@ fn symbol_side_table_root_snapshot() -> Vec { slots.push(SymbolSideTableRootSlot::SymbolAccessorProperty { owner, sym_key }); } - { - let guard = crate::gc::lock_gc_root_registry(&CLASS_STATIC_SYMBOLS); - if let Some(map) = guard.as_ref() { - for &(class_id, sym_key) in map.keys() { - slots.push(SymbolSideTableRootSlot::ClassStaticSymbol { class_id, sym_key }); - } - } - } - { let guard = crate::gc::lock_gc_root_registry(&SYMBOL_POINTERS); if let Some(set) = guard.as_ref() { @@ -257,9 +221,6 @@ fn scan_symbol_side_table_root_slot( SymbolSideTableRootSlot::SymbolPropertyAttrs { owner, sym_key } => { rewrite_symbol_property_attrs_if_forwarded(visitor, owner, sym_key); } - SymbolSideTableRootSlot::ClassStaticSymbol { class_id, sym_key } => { - rewrite_class_static_symbol_entry_if_forwarded(visitor, class_id, sym_key); - } SymbolSideTableRootSlot::SymbolPointer { ptr } => { rewrite_symbol_pointer_metadata_if_forwarded(visitor, ptr); } @@ -312,28 +273,6 @@ fn rewrite_symbol_property_attrs_if_forwarded( } } -fn rewrite_class_static_symbol_entry_if_forwarded( - visitor: &mut crate::gc::RuntimeRootVisitor<'_>, - class_id: u32, - sym_key: usize, -) { - let mut guard = crate::gc::lock_gc_root_registry(&CLASS_STATIC_SYMBOLS); - let Some(map) = guard.as_mut() else { - return; - }; - let Some(value_bits) = map.get_mut(&(class_id, sym_key)) else { - return; - }; - let mut new_sym_key = sym_key; - let moved = visitor.visit_usize_slot(&mut new_sym_key); - visitor.visit_nanbox_u64_slot(value_bits); - if moved && new_sym_key != sym_key { - if let Some(value_bits) = map.remove(&(class_id, sym_key)) { - map.insert((class_id, new_sym_key), value_bits); - } - } -} - fn rewrite_symbol_pointer_metadata_if_forwarded( visitor: &mut crate::gc::RuntimeRootVisitor<'_>, ptr: usize, @@ -355,8 +294,6 @@ fn rewrite_symbol_pointer_metadata_if_forwarded( pub(crate) fn test_clear_symbol_side_table_roots() { *crate::gc::lock_gc_root_registry(&SYMBOL_PROPERTIES) = None; *crate::gc::lock_gc_root_registry(&SYMBOL_PROPERTY_ATTRS) = None; - *crate::gc::lock_gc_root_registry(&CLASS_STATIC_SYMBOLS) = None; - *CLASS_STATIC_SYMBOL_ORDER.lock().unwrap() = None; accessors::test_clear_symbol_accessor_roots(); let mut persistent = Vec::new(); @@ -421,19 +358,20 @@ pub(crate) fn test_symbol_property_owner_exists(owner: usize) -> bool { #[cfg(test)] pub(crate) fn test_seed_class_static_symbol_root(class_id: u32, sym_key: usize, value_bits: u64) { if class_id != 0 && sym_key != 0 { - // Root-scanner tests deliberately use synthetic addresses, including - // an unaligned sentinel. Seed only the root table they exercise; - // production registration additionally reads SymbolHeader::id for - // [[OwnPropertyKeys]] ordering and therefore requires a real Symbol. + // Root-scanner tests use synthetic symbol addresses: seed the owner- + // keyed store directly (the production path also records the key's + // installation, which reads a real SymbolHeader). CLASS_STATIC_SYMBOLS_LATCH.arm(); - let mut guard = crate::gc::lock_gc_root_registry(&CLASS_STATIC_SYMBOLS); + let owner = crate::object::class_value::class_value_ptr(class_id) as usize; + let mut guard = crate::gc::lock_gc_root_registry(&SYMBOL_PROPERTIES); if guard.is_none() { - *guard = Some(HashMap::new()); + *guard = Some(new_ptr_hash_map()); + } + let entries = guard.as_mut().unwrap().entry(owner).or_default(); + match entries.iter_mut().find(|(key, _)| *key == sym_key) { + Some(entry) => entry.1 = value_bits, + None => entries.push((sym_key, value_bits)), } - guard - .as_mut() - .unwrap() - .insert((class_id, sym_key), value_bits); drop(guard); publish_symbol_side_table_root_edges(sym_key, value_bits); } @@ -441,24 +379,17 @@ pub(crate) fn test_seed_class_static_symbol_root(class_id: u32, sym_key: usize, #[cfg(test)] pub(crate) fn test_class_static_symbol_root_bits(class_id: u32, sym_key: usize) -> Option { - let guard = crate::gc::lock_gc_root_registry(&CLASS_STATIC_SYMBOLS); - guard - .as_ref() - .and_then(|map| map.get(&(class_id, sym_key)).copied()) + let owner = crate::object::class_value::class_value_ptr(class_id) as usize; + super::symbol_property_root_bits(owner, sym_key) } #[cfg(test)] pub(crate) fn test_class_static_symbol_roots_for_class(class_id: u32) -> Vec<(usize, u64)> { - let guard = crate::gc::lock_gc_root_registry(&CLASS_STATIC_SYMBOLS); + let owner = crate::object::class_value::class_value_ptr(class_id) as usize; + let guard = crate::gc::lock_gc_root_registry(&SYMBOL_PROPERTIES); guard .as_ref() - .map(|map| { - map.iter() - .filter_map(|(&(cid, sym_key), &value_bits)| { - (cid == class_id).then_some((sym_key, value_bits)) - }) - .collect() - }) + .and_then(|map| map.get(&owner).cloned()) .unwrap_or_default() } diff --git a/crates/perry-runtime/src/symbol/get.rs b/crates/perry-runtime/src/symbol/get.rs index 19bc6e9eac..629a384d86 100644 --- a/crates/perry-runtime/src/symbol/get.rs +++ b/crates/perry-runtime/src/symbol/get.rs @@ -721,11 +721,10 @@ pub(crate) unsafe fn js_object_get_symbol_property_with_receiver( } return f64::from_bits(TAG_UNDEFINED); } - // Check CLASS_STATIC_SYMBOLS first when receiver is a class ref + // Check the class's static symbols first when receiver is a class ref // (top16 == 0x7FFE, INT32_TAG). let bits = obj_f64.to_bits(); - if (bits >> 48) == 0x7FFE { - let class_id = (bits & 0xFFFF_FFFF) as u32; + if let Some(class_id) = crate::object::class_value::legacy_class_value_word(bits) { let sym_key = sym_key_from_f64(sym_f64); if sym_key != 0 { if let Some(v) = @@ -734,7 +733,12 @@ pub(crate) unsafe fn js_object_get_symbol_property_with_receiver( return v; } } - if let Some(vb) = class_static_symbol_lookup(class_id, sym_f64) { + let static_lookup = if crate::object::class_prototype_ref_id(obj_f64).is_some() { + class_static_symbol_lookup(class_id, sym_f64) + } else { + super::class_static_symbol_lookup_in_chain(class_id, sym_f64) + }; + if let Some(vb) = static_lookup { return f64::from_bits(vb); } // #9101: statically-known well-known-symbol METHODS are registered diff --git a/crates/perry-runtime/src/symbol/properties.rs b/crates/perry-runtime/src/symbol/properties.rs index 8b323a1b5e..5724c6a455 100644 --- a/crates/perry-runtime/src/symbol/properties.rs +++ b/crates/perry-runtime/src/symbol/properties.rs @@ -205,8 +205,7 @@ pub(crate) unsafe fn reflect_symbol_getter_closure_bits(obj_f64: f64, sym_f64: f pub(crate) unsafe fn js_object_has_own_symbol_property(obj_f64: f64, sym_f64: f64) -> bool { let bits = obj_f64.to_bits(); - if (bits >> 48) == 0x7FFE { - let class_id = (bits & 0xFFFF_FFFF) as u32; + if let Some(class_id) = crate::object::class_value::legacy_class_value_word(bits) { return class_static_symbol_lookup(class_id, sym_f64).is_some(); } let obj_key = obj_key_from_f64(obj_f64); @@ -393,7 +392,7 @@ unsafe fn set_symbol_property(obj_f64: f64, sym_f64: f64, value_f64: f64) -> f64 // below uses. if !has_own_data && !native_async_resource { let bits = obj_f64.to_bits(); - if (bits >> 48) != 0x7FFE { + if crate::object::class_value::legacy_class_value_word(bits).is_none() { let jsval = crate::value::JSValue::from_bits(bits); if jsval.is_pointer() { let ptr = jsval.as_pointer::(); @@ -447,8 +446,7 @@ unsafe fn set_symbol_property(obj_f64: f64, sym_f64: f64, value_f64: f64) -> f64 } if !has_own_data { let bits = obj_f64.to_bits(); - if (bits >> 48) == 0x7FFE { - let class_id = (bits & 0xFFFF_FFFF) as u32; + if let Some(class_id) = crate::object::class_value::legacy_class_value_word(bits) { if crate::object::class_symbol_setter_apply(class_id, sym_key, obj_f64, value_f64, true) { return value_f64; @@ -527,7 +525,7 @@ pub unsafe extern "C" fn js_object_set_symbol_property( // heap address — `set_symbol_property` keys the own-symbol side table by // `obj_key_from_f64`, which returns 0 for a non-pointer receiver, so the // write was silently dropped and `sym in C` / `C[sym]` came back undefined. - // Store it as a static Symbol-keyed member (CLASS_STATIC_SYMBOLS), the same + // Store it as a static Symbol-keyed member of the class function object, the same // table `static [sym] = v` uses and that the class-ref arms of // `js_object_get_symbol_property` / `js_object_has_property` already read. if let Some(class_id) = crate::object::class_ref_id(obj_f64) { @@ -631,53 +629,57 @@ pub fn class_static_symbol_lookup(class_id: u32, sym_f64: f64) -> Option { #[inline(never)] fn class_static_symbol_lookup_slow(class_id: u32, sym_f64: f64) -> Option { - unsafe { - let sym_key = sym_key_from_f64(sym_f64); - if class_id == 0 || sym_key == 0 { - return None; + let sym_key = unsafe { sym_key_from_f64(sym_f64) }; + if class_id == 0 || sym_key == 0 { + return None; + } + let owner = crate::object::class_value::class_value_ptr(class_id) as usize; + symbol_property_root_bits(owner, sym_key) +} + +/// [`class_static_symbol_lookup`] up the class's constructor chain: a +/// subclass constructor inherits its parent's static symbol properties +/// (its [[Prototype]] is the parent constructor). +pub fn class_static_symbol_lookup_in_chain(class_id: u32, sym_f64: f64) -> Option { + if super::CLASS_STATIC_SYMBOLS_LATCH.is_idle() { + return None; + } + let mut cid = class_id; + let mut depth = 0; + while cid != 0 && depth < 64 { + if let Some(bits) = class_static_symbol_lookup_slow(cid, sym_f64) { + return Some(bits); } - let guard = crate::gc::lock_gc_root_registry(&CLASS_STATIC_SYMBOLS); - guard - .as_ref() - .and_then(|m| m.get(&(class_id, sym_key)).copied()) + cid = match crate::object::get_parent_class_id(cid) { + Some(p) if p != cid => p, + _ => return None, + }; + depth += 1; } + None } +/// A class's own static symbol data keys, in creation order. pub(crate) fn class_static_symbol_keys_for_class(class_id: u32) -> Vec { - let guard = crate::gc::lock_gc_root_registry(&CLASS_STATIC_SYMBOLS); - let mut keys: Vec = guard - .as_ref() - .map(|map| { - map.keys() - .filter_map(|&(cid, sym_key)| (cid == class_id).then_some(sym_key)) - .collect() - }) - .unwrap_or_default(); - drop(guard); - let order = CLASS_STATIC_SYMBOL_ORDER.lock().unwrap(); - crate::cold_sort::sort_by_key(&mut keys, |sym_key| unsafe { - let symbol_id = (*sym_key as *const SymbolHeader) - .as_ref() - .map_or(u64::MAX, |symbol| symbol.id); - let position = order - .as_ref() - .and_then(|all| all.get(&class_id)) - .and_then(|ids| ids.iter().position(|id| *id == symbol_id)); - (position.unwrap_or(usize::MAX), symbol_id) - }); - keys + if class_id == 0 { + return Vec::new(); + } + let owner = crate::object::class_value::class_value_ptr(class_id) as usize; + clone_symbol_entries_for_obj_ptr(owner) + .into_iter() + .map(|(sym_key, _)| sym_key) + .collect() } /// `Object.prototype.hasOwnProperty.call(obj, sym)` for Symbol keys. /// Refs #420 — drizzle's `is(value, type)` checks entityKind which is a Symbol. /// /// When `obj` is an INT32-tagged class ref, also consult -/// `CLASS_STATIC_SYMBOLS` for static-Symbol-keyed declarations. +/// the class function object's static symbol properties. #[no_mangle] pub unsafe extern "C" fn js_object_has_own_symbol(obj_f64: f64, sym_f64: f64) -> bool { let bits = obj_f64.to_bits(); - if (bits >> 48) == 0x7FFE { - let class_id = (bits & 0xFFFF_FFFF) as u32; + if let Some(class_id) = crate::object::class_value::legacy_class_value_word(bits) { return class_static_symbol_lookup(class_id, sym_f64).is_some(); } let obj_key = obj_key_from_f64(obj_f64); diff --git a/crates/perry-runtime/src/typed_feedback.rs b/crates/perry-runtime/src/typed_feedback.rs index 044b10c8e5..88ccc84736 100644 --- a/crates/perry-runtime/src/typed_feedback.rs +++ b/crates/perry-runtime/src/typed_feedback.rs @@ -2751,7 +2751,7 @@ pub extern "C" fn js_typed_feedback_array_set_string_key( // Class-ref receivers (INT32 tag 0x7FFE) are not arrays; skip the array // shape observation (which would probe the GC header of a non-pointer) and // route straight to the class-ref-aware string-key setter. - if (arr as u64) >> 48 == 0x7FFE { + if crate::object::class_value::legacy_class_ptr_word(arr as u64).is_some() { return crate::array::js_array_set_string_key(arr, key, value); } observe_array(site_id, arr, u32::MAX); diff --git a/crates/perry-runtime/src/typed_feedback/tests.rs b/crates/perry-runtime/src/typed_feedback/tests.rs index 1d8e6d93c3..27611fa8f5 100644 --- a/crates/perry-runtime/src/typed_feedback/tests.rs +++ b/crates/perry-runtime/src/typed_feedback/tests.rs @@ -1231,9 +1231,9 @@ fn typed_feedback_array_loop_helpers_have_lto_keepalive_anchors() { #[test] fn representation_lowering_helpers_have_lto_keepalive_anchors() { let native_abi = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/native_abi.rs")); - let native_module = include_str!(concat!( + let class_method_bind = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), - "/src/object/native_module.rs" + "/src/object/native_module/class_method_bind.rs" )); let guards = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), @@ -1353,7 +1353,7 @@ fn representation_lowering_helpers_have_lto_keepalive_anchors() { "js_native_call_method_apply_by_id", ), ( - native_module, + class_method_bind, "KEEP_CLASS_METHOD_BIND_BY_ID", "static KEEP_CLASS_METHOD_BIND_BY_ID: extern \"C\" fn(f64, i64) -> f64", "js_class_method_bind_by_id", diff --git a/crates/perry-runtime/src/value/dyn_index.rs b/crates/perry-runtime/src/value/dyn_index.rs index 41f1b53939..3cf1ffa6a4 100644 --- a/crates/perry-runtime/src/value/dyn_index.rs +++ b/crates/perry-runtime/src/value/dyn_index.rs @@ -225,7 +225,7 @@ pub extern "C" fn js_dyn_index_get(value: f64, index: f64) -> f64 { // CLASS_DYNAMIC_PROPS tables; the computed form must do the same instead of // falling through to the not-a-pointer `undefined` path below. (test262 // class/elements propertyHelper `isWritable(C, "m")` does `C[name] = v`.) - if (bits >> 48) == 0x7FFE { + if crate::object::class_value::legacy_class_value_word(bits).is_some() { let idx_top16 = index.to_bits() >> 48; let key_ptr = if idx_top16 == 0x7FFF || idx_top16 == 0x7FF9 { js_get_string_pointer_unified(index) as *const crate::StringHeader @@ -648,7 +648,7 @@ pub extern "C" fn js_dyn_index_set_strict(obj: f64, index: f64, value: f64, stri // form (`C.key = v`). Without this the write was silently dropped, so // propertyHelper's `isWritable(C, name)` (`C[name] = v`) reported a static // method as non-writable. (Mirrors the get arm above.) - if (bits >> 48) == 0x7FFE { + if crate::object::class_value::legacy_class_value_word(bits).is_some() { let idx_top16 = index.to_bits() >> 48; if idx_top16 == 0x7FFF || idx_top16 == 0x7FF9 { let key_ptr = js_get_string_pointer_unified(index) as *const crate::StringHeader; diff --git a/crates/perry-stdlib/src/runtime_thread_exit_tests/symbols_tests.rs b/crates/perry-stdlib/src/runtime_thread_exit_tests/symbols_tests.rs index 6f17e5aa49..22d0feb648 100644 --- a/crates/perry-stdlib/src/runtime_thread_exit_tests/symbols_tests.rs +++ b/crates/perry-stdlib/src/runtime_thread_exit_tests/symbols_tests.rs @@ -52,7 +52,7 @@ fn addr_of(value: f64) -> usize { #[test] fn thread_exit_releases_the_threads_symbol_side_table_entries() { const STATIC_SYMBOL_CLASS: u32 = 0x0B11_4711; - let ((owner, sym), alive) = std::thread::spawn(|| { + let ((owner, class_owner, sym), alive) = std::thread::spawn(|| { use perry_runtime::symbol as s; let scope = RuntimeHandleScope::new(); let sym = scope.root_nanbox_f64(unsafe { s::js_symbol_new(string_value("t11471")) }); @@ -100,7 +100,8 @@ fn thread_exit_releases_the_threads_symbol_side_table_entries() { sym3.get_nanbox_f64(), js_nanbox_pointer(accessor.get_raw_mut_ptr::() as i64), ); - // static [sym] = [] on a (process-global) class id (CLASS_STATIC_SYMBOLS). + // static [sym] = [] on a class id: an own symbol property of the class's + // function object, which this thread's agent mints in its own heap. unsafe { s::js_class_register_static_symbol( STATIC_SYMBOL_CLASS, @@ -110,6 +111,7 @@ fn thread_exit_releases_the_threads_symbol_side_table_entries() { }; let owner = obj.get_raw_mut_ptr::() as usize; + let class_owner = s::class_static_symbol_owner_for_test(STATIC_SYMBOL_CLASS); let (sym, sym2, sym3) = ( addr_of(sym.get_nanbox_f64()), addr_of(sym2.get_nanbox_f64()), @@ -119,9 +121,9 @@ fn thread_exit_releases_the_threads_symbol_side_table_entries() { s::symbol_property_tables_hold_for_test(owner, sym).0, s::symbol_property_tables_hold_for_test(owner, sym2).1, s::symbol_accessor_held_for_test(owner, sym3), - s::class_static_symbol_held_for_test(STATIC_SYMBOL_CLASS, sym), + s::symbol_property_tables_hold_for_test(class_owner, sym).0, ]; - ((owner, [sym, sym2, sym3]), alive) + ((owner, class_owner, [sym, sym2, sym3]), alive) }) .join() .unwrap(); @@ -144,7 +146,7 @@ fn thread_exit_releases_the_threads_symbol_side_table_entries() { "a dead thread's symbol accessor outlived its heap" ); assert!( - !s::class_static_symbol_held_for_test(STATIC_SYMBOL_CLASS, sym[0]), + !s::symbol_property_tables_hold_for_test(class_owner, sym[0]).0, "a dead thread's class-static symbol member outlived its heap" ); } diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 30d220939a..7ed081685d 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -398,7 +398,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. 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. Re-audited 2026-09-25 after the inherited-access lane touched `gc/mod.rs`: the change is one `reg_scanner!` registration for `object::chain_store::scan_chain_store_roots_mut`, a root scanner that visits store-site chain verdicts (one interned key pointer each) during root scanning. It runs at the start of a cycle, never between mark completion and sweep entry, relocates nothing and runs no JS callback. The synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-25 for #10698 (allocation-point trigger watermark), which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains `pub(crate) mod trigger_watermark;` and a `pub(crate) use` re-export -- no control flow. In `gc/policy.rs`: (a) `gc_check_trigger` becomes an out-of-line wrapper over an inlined fast path that reads the watermark cell, the malloc registry's length through an unguarded shared borrow and the inline allocator's offset, then either returns or runs the previous body unchanged (`gc_check_trigger_evaluate`). An allocation inside the window reaches it with `GC_FLAG_IN_ALLOC` set: the fast path returns without acting, which is the outcome of the `GC_FLAG_IN_ALLOC` early return it would otherwise reach, and the slow path still takes that early return. (b) The due-trigger evaluation also returns a watermark -- integer arithmetic over values it already read -- published only on the no-action return past every entry guard. (c) The ladder's thresholds and flags are retyped `Cell` -> `TriggerInput`, whose writes add one store retiring the watermark; heap-generation advances and `Arena::set_current` retire it too. (d) `#[cfg(test)]` seams. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback; `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-25 for #10498 (class-accessor cache), which adds one `reg_scanner!` registration (`scan_class_accessor_cache_roots_mut`, marking and rewriting the cache's key strings) to `gc/mod.rs`: again a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-26 for #10572: `gc/mod.rs` gains one `#[cfg(not(feature = \"hot-diag\"))]` call to `hot_diag::refuse_knobs_without_hot_diag()` in `gc_init`, a startup-only knob check that runs before any cycle and alters no mark/sweep control flow; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-27 for #11416: `gc/mod.rs` loses the `reg_scanner!` registration for `class_accessor_cache::scan_class_accessor_cache_roots_mut` (the cache is deleted); removing a root-scanner registration alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-27 for the method-calls lane, which adds one `reg_scanner!` registration (`scan_method_site_roots_mut`, marking and rewriting the method closures inherited method-site entries hold) to `gc/mod.rs`: a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-28 (charter step 5, P0): the field-representation census adds `mod census_field_repr;` to `gc/mod.rs` and, in `census.rs`, one Rust-owned accumulator fed from `visit_object` inside `take_census`, which runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED; it reads slots and the per-object layout maps (try_borrow), allocates nothing on the JS heap and alters no mark/sweep control flow. The window is unchanged. Re-audited 2026-09-29 after #11659 added `verify::verify_array_hole_tails_at_collection()` to `gc/mod.rs` and `gc/policy.rs`. Every call sits at a collection or budgeted-cycle ENTRY, beside `roots::ensure_stack_maps_built()`, i.e. before mark begins and so before `census_pass1_if_armed` opens the window. The call is a read-only walk of array headers (debug builds, or release with PERRY_GC_VERIFY_ARRAY_HOLES): it neither allocates GC memory, relocates, runs JS, nor reads or writes PASS1_MARKED, and it panics rather than continuing on a violation. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched.", + "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. Re-audited 2026-09-25 after the inherited-access lane touched `gc/mod.rs`: the change is one `reg_scanner!` registration for `object::chain_store::scan_chain_store_roots_mut`, a root scanner that visits store-site chain verdicts (one interned key pointer each) during root scanning. It runs at the start of a cycle, never between mark completion and sweep entry, relocates nothing and runs no JS callback. The synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-25 for #10698 (allocation-point trigger watermark), which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains `pub(crate) mod trigger_watermark;` and a `pub(crate) use` re-export -- no control flow. In `gc/policy.rs`: (a) `gc_check_trigger` becomes an out-of-line wrapper over an inlined fast path that reads the watermark cell, the malloc registry's length through an unguarded shared borrow and the inline allocator's offset, then either returns or runs the previous body unchanged (`gc_check_trigger_evaluate`). An allocation inside the window reaches it with `GC_FLAG_IN_ALLOC` set: the fast path returns without acting, which is the outcome of the `GC_FLAG_IN_ALLOC` early return it would otherwise reach, and the slow path still takes that early return. (b) The due-trigger evaluation also returns a watermark -- integer arithmetic over values it already read -- published only on the no-action return past every entry guard. (c) The ladder's thresholds and flags are retyped `Cell` -> `TriggerInput`, whose writes add one store retiring the watermark; heap-generation advances and `Arena::set_current` retire it too. (d) `#[cfg(test)]` seams. Nothing added allocates a GC object, relocates anything, collects, or invokes a JS callback; `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`, so the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-25 for #10498 (class-accessor cache), which adds one `reg_scanner!` registration (`scan_class_accessor_cache_roots_mut`, marking and rewriting the cache's key strings) to `gc/mod.rs`: again a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-26 for #10572: `gc/mod.rs` gains one `#[cfg(not(feature = \"hot-diag\"))]` call to `hot_diag::refuse_knobs_without_hot_diag()` in `gc_init`, a startup-only knob check that runs before any cycle and alters no mark/sweep control flow; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-27 for #11416: `gc/mod.rs` loses the `reg_scanner!` registration for `class_accessor_cache::scan_class_accessor_cache_roots_mut` (the cache is deleted); removing a root-scanner registration alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-27 for the method-calls lane, which adds one `reg_scanner!` registration (`scan_method_site_roots_mut`, marking and rewriting the method closures inherited method-site entries hold) to `gc/mod.rs`: a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-28 (charter step 5, P0): the field-representation census adds `mod census_field_repr;` to `gc/mod.rs` and, in `census.rs`, one Rust-owned accumulator fed from `visit_object` inside `take_census`, which runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED; it reads slots and the per-object layout maps (try_borrow), allocates nothing on the JS heap and alters no mark/sweep control flow. The window is unchanged. Re-audited 2026-09-27 for #11416: `gc/mod.rs` loses the `reg_scanner!` registration for `class_accessor_cache::scan_class_accessor_cache_roots_mut` (the cache is deleted); removing a root-scanner registration alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-27 for the method-calls lane, which adds one `reg_scanner!` registration (`scan_method_site_roots_mut`, marking and rewriting the method closures inherited method-site entries hold) to `gc/mod.rs`: a root-scanner registration, altering no mark/sweep control flow and running nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-27 (class constructors as function objects, #11414): `gc/mod.rs` gains one `reg_scanner!` registration (`object::class_value::scan_class_value_roots_mut`, the per-agent class function-object table, visited and rewritten) \u2014 a root-scanner registration that alters no mark/sweep control flow and runs nothing inside the mark-complete to sweep-entry window. Re-audited 2026-09-29 after #11659 added `verify::verify_array_hole_tails_at_collection()` to `gc/mod.rs` and `gc/policy.rs`. Every call sits at a collection or budgeted-cycle ENTRY, beside `roots::ensure_stack_maps_built()`, i.e. before mark begins and so before `census_pass1_if_armed` opens the window. The call is a read-only walk of array headers (debug builds, or release with PERRY_GC_VERIFY_ARRAY_HOLES): it neither allocates GC memory, relocates, runs JS, nor reads or writes PASS1_MARKED, and it panics rather than continuing on a violation. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched.", "window": { "start": { "file": "crates/perry-runtime/src/gc/census.rs", @@ -415,7 +415,7 @@ "sources": { "crates/perry-runtime/src/gc/census.rs": "b2b60a124ea0f1131108940a8e675dae92f24192ecb482f87b6183e7641a2677", "crates/perry-runtime/src/gc/cycle.rs": "4744196ba5e9c5ac40912154cf5b45b4a618d81ddc776ab1095fbc585f27c878", - "crates/perry-runtime/src/gc/mod.rs": "86b9df5e8f4c50784a2a50b75e9c966b04096e4abfb3d42bc0bff6a98d0af450", + "crates/perry-runtime/src/gc/mod.rs": "6dbc38682e153342cabffb39ebd94d2bbeabf3847473a332dda67ffcf5636fe7", "crates/perry-runtime/src/gc/policy.rs": "84a869e0aa09e932a4d2b6601186260129ba851b4482f29579047713c60fc95e", "crates/perry-runtime/src/gc/progress.rs": "a5ad3971bbe4047229ca57325234780daa85921dbc778e1c08dff4ad07ccfb96" } @@ -891,19 +891,6 @@ "scanner": "object::scan_class_side_table_roots_mut and its budgeted step twin (class_registry/gc_roots.rs:138 and :256)", "why": "The class side tables are declared in state.rs and scanned from gc_roots.rs. Both twins visit it \u2014 #7239 diffed all eight budgeted (FULL, STEP) pairs and found no drift." }, - { - "file": "crates/perry-runtime/src/object/class_registry/state.rs", - "name": "CLASS_STATIC_PROTOTYPES", - "verdict": "covered_elsewhere", - "scanner": "object::class_registry::gc_roots::scan_class_side_table_roots_mut and its budgeted step twin (class_side_table_root_snapshot enumerates ClassSideTableRootSlot::StaticPrototype; scan_class_side_table_root_slot visits that slot)", - "why": "Constructor-side [[Prototype]] recorded by Object.setPrototypeOf(Ctor, obj) on a declared class. Holds a real heap ObjectHeader address as usize, so it is visited with visit_usize_slot in BOTH the full and budgeted class-side-table walks, exactly like the CLASS_DECL_PROTOTYPE_OBJECTS entries beside it, and class_static_prototype_root_store fires runtime_write_barrier_root_raw_ptr on the stored pointer." - }, - { - "file": "crates/perry-runtime/src/object/class_registry/state.rs", - "name": "CLASS_STATIC_PROTOTYPE_NULLED", - "verdict": "not_a_gc_pointer", - "why": "Set of class ids whose constructor [[Prototype]] was explicitly set to null, so Object.getPrototypeOf answers null rather than the default Function.prototype. Stores u32 class ids only \u2014 no heap address, nothing to trace or forward." - }, { "file": "crates/perry-runtime/src/object/class_registry/state.rs", "name": "CLASS_SYMBOL_MEMBER_ORDERS", @@ -942,12 +929,6 @@ "scanner": "gc::roots GLOBAL_ROOTS \u2014 the cell's address is registered with js_gc_register_global_root (fetch_globals.rs, js_module_top_this)", "why": "Same shape as THREAD_GLOBAL_THIS: a NaN-boxed cache slot registered as a mutable global root at first population." }, - { - "file": "crates/perry-runtime/src/object/mod.rs", - "name": "CLASS_DYNAMIC_PROP_ORDER", - "verdict": "not_a_gc_pointer", - "why": "First-insertion order for CLASS_DYNAMIC_PROPS: HashMap> of owned Rust strings, needed because the value table is a HashMap while [[OwnPropertyKeys]] needs order. Holds no JSValues; the f64 values live in CLASS_DYNAMIC_PROPS, which is already a scanned root." - }, { "file": "crates/perry-runtime/src/object/mod.rs", "name": "TRANSITION_CACHE_YOUNG", @@ -967,7 +948,7 @@ "why": "#[cfg(test)] Cell selects the unchanged slow Get for differential tests. Holds only a boolean, never a GC address or JSValue, and is absent from shipped binaries." }, { - "file": "crates/perry-runtime/src/object/native_module.rs", + "file": "crates/perry-runtime/src/object/native_module/class_method_bind.rs", "name": "TEST_BOUND_METHOD_MOVE", "verdict": "test_only", "why": "#[cfg(test)] diagnostic trace for the bound-method moving-GC regression: records the (before, after) addresses a test-forced minor produced so the test can assert the relocation happened. The addresses are compared as integers, never dereferenced, and the cell is dead in a shipped binary." @@ -2784,6 +2765,12 @@ "name": "NOT_ANON_MEMO", "verdict": "not_a_gc_pointer", "why": "#10495: per-thread direct-mapped memo of u32 CLASS IDS proved not to be an anonymous literal shape's (is_anon_shape_class_id == false). Class ids are integers from the class registry, never heap addresses." + }, + { + "file": "crates/perry-runtime/src/closure/shape.rs", + "name": "CLASS_SHAPE", + "verdict": "not_a_gc_pointer", + "why": "This agent's class-constructor ShapeId (a u32 shape-directory index minted once and pinned as an external shape carrier), never a heap reference." } ], "_FRONTIER_README": "Identity-pinned debt ratchet over new perry-ui* candidates and otherwise-unclassified core raw/Perry TLS declarations (see the census docstring, \u201cThe identity-pinned frontier\u201d). A new uncovered holder fails until it is scanned, receives a researched holders verdict, or is deliberately pinned as debt. Moving a researched false positive to holders graduates it from this list. A fixed or classified holder makes its old frontier pin stale, so the receipt must be deleted.", @@ -3835,10 +3822,6 @@ "file": "crates/perry-runtime/src/object/mod.rs", "name": "ASYNC_GENERATOR_PROTOTYPE_PTR_SLOT" }, - { - "file": "crates/perry-runtime/src/object/mod.rs", - "name": "CLASS_DYNAMIC_PROPS" - }, { "file": "crates/perry-runtime/src/object/mod.rs", "name": "CLASS_PROTOTYPE_METHOD_VALUES" @@ -3912,7 +3895,7 @@ "name": "HANDLE_PROPERTY_BIND_REENTRY" }, { - "file": "crates/perry-runtime/src/object/native_module.rs", + "file": "crates/perry-runtime/src/object/native_module/class_method_bind.rs", "name": "TEST_COLLECT_BOUND_METHOD_AFTER_CAPTURE_INIT" }, { diff --git a/scripts/registry_lifetime_allowlist.json b/scripts/registry_lifetime_allowlist.json index 6579dca063..ad6345431b 100644 --- a/scripts/registry_lifetime_allowlist.json +++ b/scripts/registry_lifetime_allowlist.json @@ -427,12 +427,6 @@ "verdict": "bounded_by_program", "why": "(class_id, method name) -> bound method closure cache, filled only for vtable-registered methods" }, - { - "file": "crates/perry-runtime/src/object/mod.rs", - "name": "CLASS_STATIC_DEFINED_ATTRS", - "verdict": "bounded_by_program", - "why": "(class_id, static key) -> attrs from Object.defineProperty(C,k): per distinct key, not per op" - }, { "file": "crates/perry-runtime/src/object/native_module.rs", "name": "NATIVE_CALLABLE_EXPORTS", @@ -500,12 +494,6 @@ "verdict": "bounded_by_constant", "why": "HashSet of &'static FFI stub symbol names for first-call warnings: the fixed set of stubs in the runtime" }, - { - "file": "crates/perry-runtime/src/symbol.rs", - "name": "CLASS_STATIC_SYMBOL_ORDER", - "verdict": "bounded_by_program", - "why": "Keyed by class_id (one per declaration site) -> symbol ids of its static [sym] members" - }, { "file": "crates/perry-runtime/src/symbol.rs", "name": "REGISTERED_SYMBOL_DESCRIPTIONS", diff --git a/scripts/thread_exit_address_globals.json b/scripts/thread_exit_address_globals.json index 05ee384d6c..7e04a409fc 100644 --- a/scripts/thread_exit_address_globals.json +++ b/scripts/thread_exit_address_globals.json @@ -3551,23 +3551,6 @@ "verdict": "no_heap_address", "why": "Monotonic u64 id counter handed out by next_id() to every new SymbolHeader." }, - { - "file": "crates/perry-runtime/src/symbol.rs", - "names": [ - "CLASS_STATIC_SYMBOLS" - ], - "verdict": "thread_exit_invalidated", - "why": "Class ids are process-global but members hold symbol addresses and JS values from the writing thread; members with a freed symbol or value are dropped at thread exit.", - "hook": "release_symbol_tables_in_freed_ranges" - }, - { - "file": "crates/perry-runtime/src/symbol.rs", - "names": [ - "CLASS_STATIC_SYMBOL_ORDER" - ], - "verdict": "no_heap_address", - "why": "class_id -> Vec of SymbolHeader::id (monotonic u64 ids, stable across moves) recording creation order (symbol.rs:1162-1172); holds no addresses." - }, { "file": "crates/perry-runtime/src/intl/segments_view.rs", "names": [ diff --git a/test-files/test_gap_class_name_length_own.ts b/test-files/test_gap_class_name_length_own.ts new file mode 100644 index 0000000000..d1bfde5d59 --- /dev/null +++ b/test-files/test_gap_class_name_length_own.ts @@ -0,0 +1,91 @@ +// A class constructor's `length` and `name` are own data properties of its +// function object: { writable: false, enumerable: false, configurable: true }, +// created before any static. Static fields, methods, accessors and +// defineProperty of the same name replace them; delete removes them. +function show(label: string, v: any): void { + console.log(label, JSON.stringify(v)); +} +function desc(o: any, k: string): string { + const d = Object.getOwnPropertyDescriptor(o, k); + if (!d) return "none"; + return `${typeof d.value === "function" ? "fn" : JSON.stringify(d.value)} w=${d.writable} e=${d.enumerable} c=${d.configurable}`; +} + +class A { + static s = 1; + constructor(a: number, b: number) {} +} +show("A.name", A.name); +show("A.length", A.length); +show("names", Object.getOwnPropertyNames(A)); +show("keys", Object.keys(A)); +show("entries", Object.entries(A)); +show("spread", { ...(A as any) }); +show("assign", Object.assign({}, A)); +console.log("desc name", desc(A, "name")); +console.log("desc length", desc(A, "length")); +console.log("own", A.hasOwnProperty("name"), A.hasOwnProperty("length"), "name" in A); +const forin: string[] = []; +for (const k in A) forin.push(k); +show("forin", forin); + +const anyA: any = A; +show("dyn name", anyA.name); +show("dyn length", anyA["length"]); +show("ctor name", new A(1, 2).constructor.name); + +class B extends A {} +show("B.name", B.name); +show("B.length", B.length); +show("B names", Object.getOwnPropertyNames(B)); + +class F { + static name = "Field"; +} +show("F.name", F.name); +console.log("F desc", desc(F, "name")); +show("F keys", Object.keys(F)); +show("F names", Object.getOwnPropertyNames(F)); + +class M { + static name() { + return "method"; + } +} +show("M.name()", M.name()); +console.log("M desc", desc(M, "name")); + +class G { + static get name() { + return "getter"; + } +} +show("G.name", G.name); + +class D {} +Object.defineProperty(D, "name", { value: "Defined" }); +show("D.name", D.name); +console.log("D desc", desc(D, "name")); +show("D keys", Object.keys(D)); + +class E {} +show("delete", delete (E as any).name); +show("E.name after delete", E.name); +show("E own", E.hasOwnProperty("name")); +show("E names", Object.getOwnPropertyNames(E)); + +class L { + constructor(a: number, b = 2, ...rest: number[]) {} +} +show("L.length", L.length); +delete (L as any).length; +show("L.length after delete", L.length); + + +const Named = class {}; +show("named expr", Named.name); + +class P { + static x = 5; +} +console.log(P); diff --git a/test-files/test_gap_class_static_symbols.ts b/test-files/test_gap_class_static_symbols.ts new file mode 100644 index 0000000000..bad911bc27 --- /dev/null +++ b/test-files/test_gap_class_static_symbols.ts @@ -0,0 +1,31 @@ +// Class static Symbol-keyed data properties are own symbol properties of the +// class function object: declared, runtime-added, defined, deleted, inherited. +const tag = Symbol("tag"); +const extra = Symbol("extra"); +const defined = Symbol("defined"); +class C { + static [tag] = "C-tag"; + static plain = 1; +} +class Sub extends C {} +console.log("declared", (C as any)[tag], tag in C, Object.prototype.hasOwnProperty.call(C, tag)); +(C as any)[extra] = "runtime"; +console.log("runtime", (C as any)[extra], extra in C); +console.log("symbols", Object.getOwnPropertySymbols(C).map(String).join(",")); +console.log("inherited", (Sub as any)[tag], tag in Sub, Object.prototype.hasOwnProperty.call(Sub, tag)); +console.log("sub symbols", Object.getOwnPropertySymbols(Sub).length); +Object.defineProperty(C, defined, { value: 42, enumerable: false }); +console.log("defined", (C as any)[defined], Object.getOwnPropertyDescriptor(C, defined)!.enumerable); +console.log("ownKeys", Reflect.ownKeys(C).map(String).sort().join(",")); +delete (C as any)[extra]; +console.log("deleted", (C as any)[extra], extra in C, Object.getOwnPropertySymbols(C).map(String).join(",")); +(Sub as any)[tag] = "Sub-tag"; +console.log("shadow", (Sub as any)[tag], (C as any)[tag], Object.getOwnPropertySymbols(Sub).map(String).join(",")); +class Even { + static [Symbol.hasInstance](v: unknown) { return typeof v === "number" && v % 2 === 0; } +} +console.log("hasInstance", (4 as any) instanceof Even, (3 as any) instanceof Even); +class Branded { static [Symbol.for("brand")] = "b"; } +console.log("registered", (Branded as any)[Symbol.for("brand")], Symbol.for("brand") in Branded); +const is = (v: any, k: any) => Object.prototype.hasOwnProperty.call(k, tag) && v instanceof k; +console.log("is", is(new C(), C), is(new Sub(), Sub)); diff --git a/test-files/test_gap_class_statics_alias.ts b/test-files/test_gap_class_statics_alias.ts new file mode 100644 index 0000000000..705453affc --- /dev/null +++ b/test-files/test_gap_class_statics_alias.ts @@ -0,0 +1,24 @@ +// A declared static field read by compiled code (`C.x`) and by reflection +// must agree after the property is deleted, redefined as an accessor, or made +// read-only; and a constructor's [[Prototype]] set at runtime is honoured. +class C { static x = 1; static y = 2; static z = 3; } +class P { static fromP = "p"; } +delete (C as any).x; +console.log("deleted", C.x, "x" in C, Object.keys(C).join(",")); +Object.defineProperty(C, "y", { get() { return 20; }, configurable: true }); +console.log("accessor", C.y, Object.getOwnPropertyDescriptor(C, "y")!.get !== undefined); +Object.defineProperty(C, "z", { writable: false }); +try { (C as any).z = 30; } catch (e) {} +console.log("readonly", C.z, Object.getOwnPropertyDescriptor(C, "z")!.writable); +class Q {} +Object.setPrototypeOf(Q, { inherited: "yes" }); +console.log("setProto", (Q as any).inherited, Object.getPrototypeOf(Q).inherited); +Object.setPrototypeOf(Q, P); +console.log("setProto class", (Q as any).fromP, Object.getPrototypeOf(Q) === P); +Object.setPrototypeOf(Q, null); +console.log("setProto null", Object.getPrototypeOf(Q), (Q as any).fromP); +class R extends P {} +(P as any).fromP = "p2"; +console.log("inherited static write", R.fromP, Object.getOwnPropertyNames(R).includes("fromP")); +(R as any).fromP = "r"; +console.log("shadow", R.fromP, P.fromP, Object.getOwnPropertyNames(R).includes("fromP")); diff --git a/test-files/test_gap_class_value_identity.ts b/test-files/test_gap_class_value_identity.ts new file mode 100644 index 0000000000..2198ac9f66 --- /dev/null +++ b/test-files/test_gap_class_value_identity.ts @@ -0,0 +1,54 @@ +// #11414: a class used as a value must be a real function object, never +// bit-identical to a number. +class A { + static s = 7; + x = 1; + static make() { return new this(); } +} +class B extends A { + static t = 9; +} +const one: any = 1; +const two: any = 2; +const vals: any[] = [0, 1, 2, 3, 4, 5, 6, 7, 8]; +let eq = 0; +for (const v of vals) { if ((v as any) === (A as any)) eq++; if ((v as any) === (B as any)) eq++; } +console.log("num===class", eq); +console.log("typeof", typeof A, typeof B, typeof one, typeof two); +const m = new Map(); +m.set(A, "A"); m.set(B, "B"); +console.log("map", m.get(1), m.get(2), m.get(A), m.get(B), m.size); +m.set(1, "one"); +console.log("map2", m.get(A), m.get(1), m.size); +const wm = new WeakMap(); +wm.set(A, "wa"); +console.log("weakmap", wm.get(A), wm.has(B)); +let threw = "no"; +try { wm.set(one, "x"); } catch (e) { threw = "yes"; } +console.log("weakmap int key throws", threw); +const s = new Set([A, B, 1, 2]); +console.log("set size", s.size); +console.log("print", String(A).startsWith("class A"), `${B}`.startsWith("class B")); +console.log("name", A.name, B.name, A.length); +console.log("instanceof", new B() instanceof A, new A() instanceof B, (A as any) instanceof Function, (A as any) instanceof Object); +console.log("static inh", (B as any).s, B.t, Object.getPrototypeOf(B) === A); +console.log("static this", (B.make() as any) instanceof B); +console.log("arith", (A as any) + 1 === 2, typeof ((A as any) + 1), Number(A as any)); +console.log("json", JSON.stringify({ a: A }), JSON.stringify([A])); +console.log("proto ne num", (A.prototype as any) === (4294967297 as any), typeof A.prototype); +class NT { t: any; constructor() { this.t = new.target; } } +class NT2 extends NT {} +const nt: any = new NT2(); +console.log("new.target", nt.t === NT2, typeof nt.t, nt.t === 0, nt.t.name); +function mk(n: number) { return class K { v = n; static tag = n; }; } +const K1 = mk(1), K2 = mk(2); +console.log("expr twice", K1 === K2, K1.tag, K2.tag, new K1().v, new K2().v, new K1() instanceof K2, typeof K1); +const mk2 = new Map(); mk2.set(K1, 1); mk2.set(K2, 2); +console.log("expr map", mk2.size, mk2.get(K1), mk2.get(K2)); +const arr: any[] = [A, 1]; +console.log("indexOf", arr.indexOf(1), arr.indexOf(A), arr.includes(A)); +console.log("obj key", Object.is(A, 1), Object.is(A, A)); +let callThrew = "no"; +try { (A as any)(); } catch (e) { callThrew = (e as any) instanceof TypeError ? "TypeError" : "other"; } +console.log("call throws", callThrew); +console.log("switch", (() => { switch (one as any) { case A: return "A"; default: return "num"; } })()); diff --git a/test-files/test_gap_class_value_misc.ts b/test-files/test_gap_class_value_misc.ts new file mode 100644 index 0000000000..f66b7c0b64 --- /dev/null +++ b/test-files/test_gap_class_value_misc.ts @@ -0,0 +1,34 @@ +// Class constructors as function objects: printing, statics blocks, private +// statics, super in statics, metadata-free reflection, collections. +class A { static #count = 0; static inc() { return ++A.#count; } static { (A as any).boot = "booted"; } } +class B extends A { static who() { return super.inc() * 10; } } +console.log(A, B, [A, 1], { k: B }); +console.log(A.inc(), B.who(), (A as any).boot, (B as any).boot); +class H { static [Symbol.hasInstance](v: any) { return v === 42; } } +console.log((42 as any) instanceof H, ({} as any) instanceof H); +const byClass = new Map(); +for (const C of [A, B, A, H]) byClass.set(C, (byClass.get(C) ?? 0) + 1); +console.log([...byClass.values()].join(","), byClass.has(A), byClass.has(1 as any)); +const ws = new WeakSet([A, B]); +console.log(ws.has(A), ws.has(H)); +const wr = new WeakRef(A); +console.log(wr.deref() === A); +const obj: Record = {}; +obj[A.name] = A; +console.log(obj.A === A, typeof obj.A); +const arr = [H, B, A]; +arr.sort((x: any, y: any) => x.name.localeCompare(y.name)); +console.log(arr.map((c) => c.name).join(",")); +console.log([A, B].indexOf(B), [1, 2, 3].indexOf(A as any), [A].lastIndexOf(A)); +function takesCtor(c: new () => object) { return new c(); } +console.log(takesCtor(B) instanceof A); +class P { v: string; constructor() { this.v = new.target.name; } } +class Q extends P {} +console.log(new P().v, new Q().v); +const bound = (B as any).who.bind(B); +console.log(bound()); +let n: any = A; +n = n === A ? "same" : "diff"; +console.log(n, (A as any) == (A as any), (A as any) === (B as any)); +console.log(Number.isInteger(A as any), Array.isArray(A), typeof (A as any).prototype); +console.log(String([A]).startsWith("class A"), `${[B]}`.includes("extends A")); diff --git a/test-files/test_gap_class_value_reflection.ts b/test-files/test_gap_class_value_reflection.ts new file mode 100644 index 0000000000..5735c24f6c --- /dev/null +++ b/test-files/test_gap_class_value_reflection.ts @@ -0,0 +1,58 @@ +// Class constructors as function objects: reflection, statics, dynamic new, +// class expressions evaluated more than once. +class A { + static s = 7; + static get g() { return "g" + this.s; } + static m() { return this.name; } + x = 1; + hi() { return "hi" + this.x; } +} +class B extends A { static t = 9; } +const a: any = A, b: any = B; +console.log("ctor", A.prototype.constructor === A, Object.getPrototypeOf(new A()) === A.prototype); +console.log("proto chain", Object.getPrototypeOf(B) === A, Object.getPrototypeOf(A) === Function.prototype, + Object.getPrototypeOf(B.prototype) === A.prototype); +console.log("own", a.hasOwnProperty("s"), b.hasOwnProperty("s"), b.hasOwnProperty("t"), "s" in b, "prototype" in a); +console.log("keys", Object.keys(A).join(","), Object.keys(B).join(",")); +console.log("names", Object.getOwnPropertyNames(A).sort().join(",")); +console.log("statics", B.m(), B.g, a.g, A.m()); +a.s = 8; +console.log("static write", A.s, b.s, B.g); +b.s = 5; +console.log("shadow", A.s, B.s, b.hasOwnProperty("s")); +a.dyn = "d"; +console.log("dyn", a.dyn, b.dyn, Object.keys(A).includes("dyn")); +delete a.dyn; +console.log("deleted", a.dyn, "dyn" in a); +Object.defineProperty(A, "ro", { value: 3, writable: false, enumerable: false }); +console.log("define", a.ro, Object.keys(A).includes("ro"), Object.getOwnPropertyDescriptor(A, "ro")!.writable); +const d = Object.getOwnPropertyDescriptor(A, "prototype")!; +console.log("proto desc", d.writable, d.enumerable, d.configurable); +function mk(c: any, ...args: any[]) { return new c(...args); } +console.log("dyn new", mk(A).hi(), mk(B) instanceof A, mk(B).x); +console.log("reflect", (Reflect.construct(A, []) as any).hi(), Reflect.construct(A, [], B) instanceof B); +const Bound: any = (A as any).bind(null); +console.log("bound", new Bound() instanceof A, typeof Bound); +let t = "no"; try { (A as any).call({}); } catch (e) { t = (e as any).constructor.name; } +console.log("call", t); +console.log("fnproto", typeof (A as any).call, typeof (A as any).apply, (A as any).call === Function.prototype.call, (A as any).bind === Function.prototype.bind); +const list: any[] = []; +for (let i = 0; i < 3; i++) { + const C = class { static n = i; v = i * 10; static who() { return this.n; } }; + list.push(C); +} +console.log("expr", list[0] === list[1], list.map((c) => c.n).join(","), list.map((c) => new c().v).join(","), + list.map((c) => c.who()).join(","), new list[1]() instanceof list[1], new list[1]() instanceof list[2]); +const s = new Set(list); +console.log("expr set", s.size, list.map((c) => typeof c).join(",")); +class Base { static create(this: any) { return new this(); } kind() { return "base"; } } +class Derived extends Base { kind() { return "derived"; } } +console.log("static this ctor", (Derived.create() as any).kind(), (Base.create() as any).kind()); +const reg = new Map([[A, "a"], [B, "b"]]); +for (const [k, v] of reg) console.log("iter", k === A ? "A" : k === B ? "B" : "?", v, typeof k); +const wm = new WeakMap([[A, 1]]); +console.log("wm", wm.get(A), wm.get(B), wm.has(A)); +console.log("eq", (A as any) == 1, (A as any) == (A as any), (A as any) !== (B as any), [A].includes(1 as any)); +console.log("num", isNaN(A as any), typeof (+(A as any)), (A as any) < 5, (A as any) > 0); +console.log("str", String(B).length > 0, Object.prototype.toString.call(A)); +console.log("obj", Object(A) === A, typeof Object(A)); diff --git a/test-files/test_gap_class_value_statics_own.ts b/test-files/test_gap_class_value_statics_own.ts new file mode 100644 index 0000000000..a7a397698a --- /dev/null +++ b/test-files/test_gap_class_value_statics_own.ts @@ -0,0 +1,11 @@ +// Class statics are the constructor's OWN properties: reflection, spread and +// Object.assign see exactly node's keys (no compiler-internal names). +class A { static s = 1; static #p = 2; static m() { return A.#p; } static { (A as any).boot = "b"; } } +class B extends A { static t = 3; } +console.log(Reflect.ownKeys(A).map(String).sort().join(",")); +console.log(Object.getOwnPropertyNames(B).sort().join(",")); +console.log(Object.keys({ ...(A as any) }).join(","), Object.keys({ ...(B as any) }).join(",")); +console.log(Object.keys(Object.assign({}, A)).join(","), JSON.stringify(Object.assign({}, B))); +console.log(Object.entries(A).map(([k, v]) => k + "=" + v).join(",")); +for (const k in B) console.log("for-in", k); +console.log(A.m());