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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions crates/perry-codegen/src/expr/logical_collections.rs
Original file line number Diff line number Diff line change
Expand Up @@ -884,13 +884,17 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
object,
} => {
let obj = lower_expr(ctx, object)?;
// The rooting window between these two operands is empty:
// lowering `this` only reads the current binding and cannot GC.
let brand_owner = lower_expr(ctx, &Expr::This)?;
let class_id = ctx.class_ids.get(class_name).copied().unwrap_or(0);
let key_label = emit_string_literal_global(ctx, field_name);
Ok(ctx.block().call(
DOUBLE,
"js_private_brand_check",
&[
(DOUBLE, &obj),
(DOUBLE, &brand_owner),
(I32, &class_id.to_string()),
(PTR, &key_label),
(I32, &field_name.len().to_string()),
Expand All @@ -909,6 +913,9 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
// unchanged (or throw TypeError). The enclosing PropertyGet /
// PropertySet / method-call lowering then operates on the result.
let obj = lower_expr(ctx, object)?;
// The rooting window between these two operands is empty:
// lowering `this` only reads the current binding and cannot GC.
let brand_owner = lower_expr(ctx, &Expr::This)?;
// Prefer the declaring class's unique HIR id carried on the node.
// Resolving `class_name` through `class_ids` is ambiguous: that map
// is keyed by name (last-writer-wins), so a minified bundle that
Expand All @@ -926,6 +933,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
"js_private_guard",
&[
(DOUBLE, &obj),
(DOUBLE, &brand_owner),
(I32, &class_id.to_string()),
(PTR, &key_label),
(I32, &field_name.len().to_string()),
Expand Down
8 changes: 6 additions & 2 deletions crates/perry-codegen/src/runtime_decls/strings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -413,11 +413,15 @@ pub fn declare_phase_b_strings(module: &mut LlModule) {
module.declare_function("js_map_from_iterable", I64, &[DOUBLE]);
module.declare_function("js_object_has_property", DOUBLE, &[DOUBLE, DOUBLE]);
module.declare_function("js_in_operator", DOUBLE, &[DOUBLE, DOUBLE]);
module.declare_function("js_private_brand_check", DOUBLE, &[DOUBLE, I32, PTR, I32]);
module.declare_function(
"js_private_brand_check",
DOUBLE,
&[DOUBLE, DOUBLE, I32, PTR, I32],
);
module.declare_function(
"js_private_guard",
DOUBLE,
&[DOUBLE, I32, PTR, I32, I32, I32],
&[DOUBLE, DOUBLE, I32, PTR, I32, I32, I32],
);
module.declare_function("js_fs_to_unix_timestamp", DOUBLE, &[DOUBLE]);
module.declare_function("js_fs_write_file_sync", I32, &[DOUBLE, DOUBLE]);
Expand Down
19 changes: 19 additions & 0 deletions crates/perry-hir/src/ir/decl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,25 @@ pub struct Class {
pub specialized_from: Option<String>,
}

impl Class {
/// Whether evaluating this class creates any private names whose brands
/// must be distinct from every other evaluation of the same HIR template.
pub fn has_private_elements(&self) -> bool {
self.fields.iter().any(|field| field.is_private)
|| self.static_fields.iter().any(|field| field.is_private)
|| self
.methods
.iter()
.any(|method| method.name.starts_with('#'))
|| self
.static_methods
.iter()
.any(|method| method.name.starts_with('#'))
|| self.getters.iter().any(|(name, _)| name.starts_with('#'))
|| self.setters.iter().any(|(name, _)| name.starts_with('#'))
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClassComputedMemberKind {
Method,
Expand Down
26 changes: 21 additions & 5 deletions crates/perry-hir/src/lower/lower_expr/arm_class.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub(crate) fn lower_class_expr(
ctx: &mut LoweringContext,
class_expr: &ast::ClassExpr,
) -> Result<Expr> {
let assignment_name = ctx.assignment_inferred_name.clone();
let ident_name = class_expr.ident.as_ref().map(|i| i.sym.to_string());
// A NAMED class EXPRESSION used as a VALUE whose name collides
// with an existing module-scope class — a TOP-LEVEL `class X`
Expand Down Expand Up @@ -94,6 +95,7 @@ pub(crate) fn lower_class_expr(
// guard (assigning to it inside the body throws a TypeError).
ctx.pending_class_inner_name = class_expr.ident.as_ref().map(|i| i.sym.to_string());
let class = lower_class_from_ast(ctx, &class_expr.class, &synthetic_name, false)?;
let has_private_elements = class.has_private_elements();
if let Some(display) = display_override {
ctx.class_display_names.insert(class.id, display);
}
Expand Down Expand Up @@ -165,12 +167,13 @@ pub(crate) fn lower_class_expr(
.map(|m| m.name.clone())
.collect();
ctx.pending_classes.push(class);
// #1772: a class EXPRESSION that carries per-evaluation static
// fields and is NOT a mixin (`class extends <expr>`) lowers to a
// #1772/#5893: a class EXPRESSION that carries per-evaluation static
// fields, captures, or private elements lowers to a
// fresh heap class object per evaluation (`ClassExprFresh`), so
// `make(a) !== make(b)` and each holds its own statics as own
// properties. Mixins and class expressions without statics/captures
// keep the historical (shared-template) path.
// properties. Private elements need the same path because every class
// evaluation creates a distinct private brand, even though Perry keeps a
// shared compile-time template for method dispatch.
// A class expression evaluated at module top level runs exactly
// once, so it needs no per-evaluation freshness — route it through
// the shared-template `ClassRef` path (identical to a class
Expand All @@ -180,6 +183,18 @@ pub(crate) fn lower_class_expr(
// expressions inside a function body (factories like effect's
// `make()`), which produce a distinct class object per call.
let at_module_top = ctx.scope_depth == 0 && ctx.inside_block_scope == 0;
if !at_module_top && has_private_elements {
// `const C = class { #x }` normally records C as an inferred static
// class alias, which makes `new C()` bypass the local class VALUE.
// A private class evaluated in a function must construct through its
// fresh heap class object so the instance receives this evaluation's
// brand token. Keep the static alias optimization for all other class
// expressions and for module-top expressions (which evaluate once).
ctx.inferred_class_bindings.remove(&synthetic_name);
if let Some(name) = assignment_name.as_ref() {
ctx.inferred_class_bindings.remove(name);
}
}
// #6604/#6654: register this capturing class EXPRESSION with the enclosing
// body's end-of-body capture-refresh machinery (#6037/#6052), which
// previously scanned class DECLARATION statements only. Without the
Expand Down Expand Up @@ -216,7 +231,8 @@ pub(crate) fn lower_class_expr(
if !at_module_top
&& (!named_statics.is_empty()
|| !static_symbol_registrations.is_empty()
|| !captured_args.is_empty())
|| !captured_args.is_empty()
|| has_private_elements)
{
// #6438: a class expression WITH heritage (`class extends <expr>`) used
// to be excluded here and fell back to the shared-template `ClassRef`
Expand Down
72 changes: 51 additions & 21 deletions crates/perry-hir/src/lower_decl/body_stmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -320,18 +320,48 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result<Ve
// `catchTag`-style dispatch. Bind the declared name to a
// `ClassExprFresh` value (the #1772/#1787 machinery class
// EXPRESSIONS already use) so each evaluation carries its own
// captured environment on its own heap class object. Scoped
// to capture-only classes: one with static state keeps the
// shared-template path, whose interleaved static-init
// statements below target the template by name. Prototype
// captured environment on its own heap class object. Capturing
// classes with public static state retain the shared-template
// path for now, but private elements always require a fresh
// evaluation — including private static state. Prototype
// identity is still shared per template — the remaining gap
// tracked on #6465.
let has_static_state = !class.static_fields.is_empty()
|| class
.static_methods
.iter()
.any(|m| m.name.starts_with("__perry_static_init_"));
let fresh_binding = !captured_exprs.is_empty() && !has_static_state;
let has_private_elements = class.has_private_elements();
let fresh_binding =
has_private_elements || (!captured_exprs.is_empty() && !has_static_state);
let named_statics: Vec<(String, Expr)> = if fresh_binding {
class
.static_fields
.iter()
.filter_map(
|field| match (field.key_expr.as_ref(), field.init.as_ref()) {
(None, Some(value)) => Some((field.name.clone(), value.clone())),
_ => None,
},
)
.collect()
} else {
Vec::new()
};
let symbol_statics: Vec<(Expr, Expr)> = if fresh_binding {
class
.static_fields
.iter()
.filter_map(
|field| match (field.key_expr.as_ref(), field.init.as_ref()) {
(Some(key), Some(value)) => Some((key.clone(), value.clone())),
_ => None,
},
)
.collect()
} else {
Vec::new()
};
// Static field initializers + static blocks for a
// function-nested class. The module-level path
// (`lower/stmt.rs`) emits these into `module.init`; here they
Expand All @@ -342,22 +372,22 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result<Ve
// classes initialized. Interleaved in source order (see
// `build_interleaved_static_init_stmts`), with lexical `this`
// in field initializers bound to the class ref.
result.extend(crate::lower_decl::build_interleaved_static_init_stmts(
&class_decl.class.body,
&class.name,
&class.static_fields,
&class.static_methods,
));
if !fresh_binding {
result.extend(crate::lower_decl::build_interleaved_static_init_stmts(
&class_decl.class.body,
&class.name,
&class.static_fields,
&class.static_methods,
));
}
let template_name = class.name.clone();
ctx.pending_classes.push(class);
// #6465 (see `fresh_binding` above): bind the declared name to
// a per-evaluation heap class object. The local shadows the
// class-registry fallback for every in-scope read — including
// the factory's `return C` — so the escaped value carries THIS
// evaluation's captures instead of the registry's last-wins
// snapshot. `new C()` sites that statically resolve the
// template still pass the live captures as trailing args, so
// in-factory construction is per-evaluation on both paths.
// #6465/#5893 (see `fresh_binding` above): bind the declared
// name to a per-evaluation heap class object. Capturing classes
// carry this invocation's environment; private classes use the
// object's identity as their fresh brand. Because this is a
// real local (not an inferred static class alias), `new C()`
// constructs through the evaluated class VALUE.
if fresh_binding {
let class_local = ctx.define_local(class_name.clone(), Type::Any);
ctx.record_local_source_span(class_local, class_decl.ident.span);
Expand All @@ -367,8 +397,8 @@ pub fn lower_body_stmt(ctx: &mut LoweringContext, stmt: &ast::Stmt) -> Result<Ve
ty: Type::Any,
init: Some(Expr::ClassExprFresh {
template: template_name,
named_statics: Vec::new(),
symbol_statics: Vec::new(),
named_statics,
symbol_statics,
captured_args: captured_exprs,
}),
mutable: false,
Expand Down
2 changes: 2 additions & 0 deletions crates/perry-runtime/src/closure/dispatch/bound.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,13 +170,15 @@ unsafe fn dispatch_symbol_bound_method(
// the Function.prototype call/apply arms for a static bound-method
// value) still wins in the static-method prologue.
let prev_this = crate::object::js_implicit_this_set(receiver);
crate::object::static_private_owner_push(receiver);
let result = crate::object::call_registered_static_method(
func_ptr,
args.as_ptr(),
args.len(),
param_count,
has_rest,
);
crate::object::static_private_owner_pop();
crate::object::js_implicit_this_set(prev_this);
result
} else {
Expand Down
8 changes: 8 additions & 0 deletions crates/perry-runtime/src/exception.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,10 @@ struct ExceptionState {
/// `resolve_inherited_field` is recursively walking; longjmp skips its
/// guard drops, so restore the stack to this try-entry savepoint.
prototype_resolution_depths: Box<[usize]>,
/// Static private-environment dispatch depth at each handler. A throw can
/// bypass a static method/accessor's normal pop, so catch entry restores
/// the stack to its handler-entry state.
static_private_owner_depths: Box<[usize]>,
/// #6559: dyn-eval interpreter state (rooted-stack length + interpreter
/// call depth, packed) captured when each `try` was pushed. A throw
/// `longjmp`s past interpreter Rust frames without running their
Expand All @@ -143,6 +147,7 @@ impl ExceptionState {
runtime_handle_savepoints: vec![0usize; MAX_TRY_DEPTH].into_boxed_slice(),
call_method_depths: vec![0u32; MAX_TRY_DEPTH].into_boxed_slice(),
prototype_resolution_depths: vec![0usize; MAX_TRY_DEPTH].into_boxed_slice(),
static_private_owner_depths: vec![0usize; MAX_TRY_DEPTH].into_boxed_slice(),
#[cfg(feature = "dyn-eval")]
dyn_eval_savepoints: vec![0u64; MAX_TRY_DEPTH].into_boxed_slice(),
try_depth: 0,
Expand Down Expand Up @@ -202,6 +207,8 @@ fn try_push_with_kind(kind: HandlerKind) -> *mut i32 {
(*s).call_method_depths[depth] = crate::object::call_method_depth_savepoint();
(*s).prototype_resolution_depths[depth] =
crate::object::prototype_chain::resolution_stack_savepoint();
(*s).static_private_owner_depths[depth] =
crate::object::static_private_owner_stack_savepoint();
// #6559: capture the dyn-eval interpreter's rooted-stack length +
// call depth, so a caught throw restores interpreter state exactly
// like the shadow stack.
Expand Down Expand Up @@ -322,6 +329,7 @@ pub extern "C-unwind" fn js_throw(value: f64) -> ! {
crate::object::prototype_chain::resolution_stack_restore(
(*s).prototype_resolution_depths[depth],
);
crate::object::static_private_owner_stack_restore((*s).static_private_owner_depths[depth]);
// #6559: restore the dyn-eval interpreter's rooted stack + call depth
// (interpreter Rust frames unwound by this longjmp never run their
// truncate/decrement epilogues).
Expand Down
7 changes: 3 additions & 4 deletions crates/perry-runtime/src/gc/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1641,12 +1641,11 @@ pub(super) unsafe fn gc_child_slots(header: *mut GcHeader) -> HeapChildSlotItera
)
}
GcLayoutSlotKind::ObjectMeta => {
// #6812: prototype (NaN-boxed / raw / sentinel) as the prefix
// slot, the raw spill-buffer pointer as a 1-slot range. Mirrors
// the rewrite descriptor arm — marking must see the same edges.
// Prototype is the prefix slot; spill and the private-evaluation
// brand are the two contiguous child slots that follow it.
let meta = user_ptr as *mut crate::object::ObjectMeta;
let proto_slot = Some(&mut (*meta).prototype as *mut u64);
let range = HeapSlotRange::new(&mut (*meta).spill as *mut u64, 1);
let range = HeapSlotRange::new(&mut (*meta).spill as *mut u64, 2);
HeapChildSlotIterator::new(header, proto_slot, range)
}
GcLayoutSlotKind::ClosureCaptures => {
Expand Down
5 changes: 5 additions & 0 deletions crates/perry-runtime/src/gc/layout_slot_visit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,11 @@ pub(super) unsafe fn visit_gc_rewrite_slot_descriptors(
// #6812: the object-owned overflow buffer is a raw-pointer child
// edge (0 = none), traced and rewritten exactly like `prototype`.
visit(fixed_slot(&mut (*meta).spill as *mut u64));
// A fresh class object stored as an instance's private evaluation
// brand is a NaN-boxed child edge and moves with the meta record.
visit(fixed_slot(
&mut (*meta).private_evaluation_brand as *mut u64,
));
}
GcRewriteDescriptorKind::Leaf => {}
}
Expand Down
6 changes: 2 additions & 4 deletions crates/perry-runtime/src/gc/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -935,10 +935,8 @@ pub(crate) fn validate_gc_type_info(info: &GcTypeInfo) -> Result<(), &'static st
}
}
GcRewriteDescriptorKind::ObjectMeta => {
// #6812: meta records expose their two child edges (prototype,
// spill buffer) to MARKING via GcLayoutSlotKind::ObjectMeta —
// the spill buffer is reachable through meta alone, so a
// rewrite-only descriptor would leave it invisible to liveness.
// Meta records expose every child edge (prototype, spill buffer,
// and private-evaluation brand) to marking and rewriting.
if info.layout_slot_kind != GcLayoutSlotKind::ObjectMeta {
return Err("object-meta descriptor must expose its child edges to marking");
}
Expand Down
Loading
Loading