Skip to content
Merged
11 changes: 11 additions & 0 deletions changelog.d/11764-class-relink-method-visibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
After `Object.setPrototypeOf(C.prototype, X)`, methods, getters and setters
declared in C's old parent class are no longer visible on C's instances.
Property reads, `in`, calls and `super.m()` follow the live prototype chain, so
the new chain's members resolve. A wide dispatch tower also stops running a
parent method's old body after `Object.defineProperty` replaces it on the
parent prototype.

Callable proxies returned by a getter on the replacement super chain use the rooted proxy-call helper. Object-target proxies remain non-callable, and nested/revoked proxy calls and getter exceptions retain their normal behavior.

Include the immutable thread-global IR suite in scoped test selection after
integrating current main, so the complete suite map accepts this branch.
70 changes: 68 additions & 2 deletions crates/perry-codegen/src/expr/super_method.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,67 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
.ok_or_else(|| anyhow!("super.{}() outside any method body", method))?;
let this_box = ctx.block().load(DOUBLE, &this_slot);
let mut lowered: Vec<String> = Vec::with_capacity(args.len() + 1);
lowered.push(this_box);
lowered.push(this_box.clone());
let mut user_vals: Vec<String> = Vec::with_capacity(args.len());
for a in args {
user_vals.push(lower_expr(ctx, a)?);
}
// The body above was resolved along the declared `extends` chain.
// `super` is the home object's CURRENT `[[Prototype]]`, so a
// relinked class prototype (or other prototype surgery on this
// name) sends the call to the runtime, which reads that chain.
let home_cid = ctx.class_ids.get(&current_class_name).copied().unwrap_or(0);
let guarded_merge = if home_cid != 0 {
let key_idx = ctx.strings.intern(method);
let slot = (ctx.strings.entry(key_idx).dispatch_hash & 0xffff).to_string();
let direct_idx = ctx.new_block("super_m.direct");
let dynamic_idx = ctx.new_block("super_m.dynamic");
let merge_idx = ctx.new_block("super_m.merge");
let direct_label = ctx.block_label(direct_idx);
let dynamic_label = ctx.block_label(dynamic_idx);
let merge_label = ctx.block_label(merge_idx);
let ok = crate::lower_call::method_override::emit_prototype_method_guard_ok(
ctx.block(),
&slot,
);
ctx.block().cond_br(&ok, &direct_label, &dynamic_label);
ctx.current_block = dynamic_idx;
let (args_ptr, args_len) = if user_vals.is_empty() {
("null".to_string(), "0".to_string())
} else {
let n = user_vals.len();
let buf = ctx.func.alloca_entry_array(DOUBLE, n);
for (i, v) in user_vals.iter().enumerate() {
let slot = ctx.block().gep(DOUBLE, &buf, &[(I64, &i.to_string())]);
ctx.block().store(DOUBLE, v, &slot);
}
let ptr_reg = ctx.block().next_reg();
ctx.block().emit_raw(format!(
"{} = getelementptr [{} x double], ptr {}, i64 0, i64 0",
ptr_reg, n, buf
));
(ptr_reg, n.to_string())
};
let name_global = emit_string_literal_global(ctx, method);
let dynamic_value = ctx.block().call(
DOUBLE,
"js_super_method_call_dynamic",
&[
(I32, &home_cid.to_string()),
(PTR, &name_global),
(I64, &method.len().to_string()),
(DOUBLE, &this_box),
(PTR, &args_ptr),
(I64, &args_len),
],
);
let dynamic_end = ctx.block().label.clone();
ctx.block().br(&merge_label);
ctx.current_block = direct_idx;
Some((merge_idx, merge_label, dynamic_value, dynamic_end))
} else {
None
};
// #8040: this arm passed every argument POSITIONALLY, which is wrong
// whenever the resolved parent method ends in an array-shaped slot.
// A body reading `arguments` gets one synthesized trailing parameter
Expand Down Expand Up @@ -185,7 +241,17 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
}
let arg_slices: Vec<(crate::types::LlvmType, &str)> =
lowered.iter().map(|s| (DOUBLE, s.as_str())).collect();
Ok(ctx.block().call(DOUBLE, &fn_name, &arg_slices))
let direct_value = ctx.block().call(DOUBLE, &fn_name, &arg_slices);
let Some((merge_idx, merge_label, dynamic_value, dynamic_end)) = guarded_merge else {
return Ok(direct_value);
};
let direct_end = ctx.block().label.clone();
ctx.block().br(&merge_label);
ctx.current_block = merge_idx;
Ok(ctx.block().phi(
DOUBLE,
&[(&direct_value, &direct_end), (&dynamic_value, &dynamic_end)],
))
}

// -------- super.method(...spread) --------
Expand Down
27 changes: 27 additions & 0 deletions crates/perry-codegen/src/lower_call/method_override.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,33 @@ fn method_inline_probe_enabled() -> bool {
})
}

/// `i1`: no prototype surgery has retired direct-method arms for the name
/// whose guard slot is `method_guard_slot` (the low 16 bits of its dispatch
/// hash) — neither the all-names byte nor that name's byte is set.
///
/// A compiler-resolved method body for an INHERITED name (the dispatch tower,
/// `super.m()`) assumes the declared `extends` chain is the instance chain.
/// Assigning, deleting or redefining a prototype member, or relinking a class
/// prototype (`Object.setPrototypeOf(C.prototype, X)`), sets these bytes
/// (`perry-runtime` `class_registry/prototype_methods.rs`); a set byte sends
/// the site to its runtime dispatch.
pub(crate) fn emit_prototype_method_guard_ok(
blk: &mut crate::block::LlBlock,
method_guard_slot: &str,
) -> String {
let invalidated =
blk.load_atomic_acquire(I8, "@PERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED", 1);
let all_methods_ok = blk.icmp_eq(I8, &invalidated, "0");
let method_slot_ptr = blk.gep(
I8,
"@PERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED_BY_METHOD",
&[(I64, method_guard_slot)],
);
let method_invalidated = blk.load_atomic_acquire(I8, &method_slot_ptr, 1);
let method_ok = blk.icmp_eq(I8, &method_invalidated, "0");
blk.and(I1, &all_methods_ok, &method_ok)
}

pub(crate) fn emit_inline_direct_method_shape_guard(
ctx: &mut FnCtx<'_>,
recv_box: &str,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -585,8 +585,22 @@ pub(crate) fn try_lower_instance_method_call(
// fallback instead of re-entering this hard-coded tower.
probed_cid
} else {
ctx.block()
.call(I32, "js_object_get_class_id", &[(I64, &recv_handle)])
// A tower too wide for the shape probe still hard-codes the
// body each class id inherits along the declared `extends`
// chain. Prototype surgery on that name (an assignment,
// delete or redefinition, or a relinked class prototype)
// retires the arms: class id 0 matches no case and takes the
// runtime default, as the shape probe's miss does.
let raw_cid =
ctx.block()
.call(I32, "js_object_get_class_id", &[(I64, &recv_handle)]);
let blk = ctx.block();
let prototype_ok =
crate::lower_call::method_override::emit_prototype_method_guard_ok(
blk,
&method_guard_slot_str,
);
blk.select(I1, &prototype_ok, I32, &raw_cid, "0")
};

for (i, (case_cid, _)) in implementors.iter().enumerate() {
Expand Down
118 changes: 118 additions & 0 deletions crates/perry-runtime/src/object/class_constructors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -949,6 +949,22 @@ pub unsafe extern "C" fn js_super_method_call_dynamic(
return result;
}
}
// `super` is the home object's CURRENT `[[Prototype]]`. Once a user
// relinked the home class's prototype (`Object.setPrototypeOf(C.prototype,
// X)`), that is the recorded link: the declared parent's members are off
// the chain, and a name the link does not carry is not callable.
if super::class_registry::class_decl_prototype_relinked(child_class_id) {
return super_call_on_relinked_chain(
name,
this_value,
args_ptr,
args_len,
|key, receiver| {
super::class_registry::relinked_class_prototype_read(child_class_id, key, receiver)
.flatten()
},
);
}
// `lookup_class_method_in_chain` resolves under the registry read lock and
// DROPS it before returning — the invoked method body may take the registry
// write lock (a lazy `require()` registering a module class), so we must not
Expand Down Expand Up @@ -984,6 +1000,22 @@ pub unsafe extern "C" fn js_super_method_call_dynamic(
args_len,
);
}
// An ANCESTOR's prototype was relinked: the declared-member walks above
// stop there, and the rest of the chain is its recorded link, which the
// generic class-chain read follows.
if declared_chain_has_relinked_prototype(parent_cid) {
return super_call_on_relinked_chain(
name,
this_value,
args_ptr,
args_len,
|key, receiver| {
super::class_registry::resolve_proto_chain_field_with_receiver(
parent_cid, key, receiver,
)
},
);
}
// #6316: the parent chain is real (an intermediate user class) but bottoms
// out in a NATIVE base whose surface perry stamps onto the instance —
// `class Logged extends Bus`, `class Bus extends EventEmitter`. Neither the
Expand All @@ -992,6 +1024,92 @@ pub unsafe extern "C" fn js_super_method_call_dynamic(
call_displaced_native_base_method(this_value, name, args_ptr, args_len, undef)
}

/// Is the prototype of `cid` or of one of its declared ancestors relinked by a
/// user operation? Asked only after the declared-member lookups missed.
fn declared_chain_has_relinked_prototype(cid: u32) -> bool {
let mut cur = cid;
for _ in 0..32 {
if cur == 0 {
return false;
}
if super::class_registry::class_decl_prototype_relinked(cur) {
return true;
}
match crate::object::get_parent_class_id(cur) {
Some(p) if p != cur => cur = p,
_ => return false,
}
}
false
}

/// `super.name(...args)` resolved by `read` on a relinked chain: call the value
/// with `this_value` as receiver, or throw the TypeError a call of a
/// non-callable `super.name` throws.
///
/// # Safety
/// `args_ptr` must point to `args_len` valid `f64`s (or be null when
/// `args_len == 0`).
unsafe fn super_call_on_relinked_chain(
name: &str,
this_value: f64,
args_ptr: *const f64,
args_len: usize,
read: impl FnOnce(*const crate::StringHeader, f64) -> Option<crate::value::JSValue>,
) -> f64 {
// The key allocation and the read (a getter on the new chain) can collect;
// the receiver and the arguments ride across them in handles.
let scope = crate::gc::RuntimeHandleScope::new();
let this_handle = scope.root_nanbox_f64(this_value);
let args: Vec<f64> = 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 = scope.root_nanbox_f64_slice(&args);
let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32);
let value = if key.is_null() {
None
} else {
read(
key as *const crate::StringHeader,
this_handle.get_nanbox_f64(),
)
};
let callable = value.filter(|v| {
let boxed = f64::from_bits(v.bits());
v.is_pointer()
&& ((crate::proxy::js_proxy_is_proxy(boxed) == 1
&& crate::proxy::proxy_wraps_callable(boxed))
|| crate::closure::is_closure_ptr(
crate::value::js_nanbox_get_pointer(boxed) as usize
))
});
let Some(method) = callable else {
crate::error::js_throw_type_error_not_a_function(
std::ptr::null(),
0,
name.as_ptr(),
name.len(),
)
};
let method_handle = scope.root_nanbox_f64(f64::from_bits(method.bits()));
let args = crate::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(&arg_handles);
if crate::proxy::js_proxy_is_proxy(method_handle.get_nanbox_f64()) == 1 {
return crate::proxy::call_proxy_value_with_this(
method_handle.get_nanbox_f64(),
this_handle.get_nanbox_f64(),
&args,
);
}
crate::closure::native_call_value_this(
method_handle.get_nanbox_f64(),
crate::closure::JsThis::from_f64(this_handle.get_nanbox_f64()),
args.as_ptr(),
args.len(),
)
}

/// Invoke the native base method a subclass override displaced (#6316), with
/// `this` bound to the receiver. Falls back to `undef` when the receiver carries
/// no such method — an ordinary `super.m()` miss stays `undefined`.
Expand Down
13 changes: 7 additions & 6 deletions crates/perry-runtime/src/object/class_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ pub use state::{
pub(crate) use prototype_objects::{
class_decl_prototype_relinked, class_prototype_object, ensure_function_prototype_object,
function_class_id, function_value_for_class_id, instance_class_prototype_object,
object_proto_chain_symbol_slot, resolve_proto_chain_field,
object_proto_chain_symbol_slot, relinked_class_prototype_read, resolve_proto_chain_field,
resolve_proto_chain_field_noting_miss, resolve_proto_chain_field_with_receiver,
resolve_proto_chain_symbol, synthetic_class_prototype_object, SYNTHETIC_CLASS_ID_BASE,
};
Expand Down Expand Up @@ -152,8 +152,9 @@ pub(crate) use prototype_methods::{
pub(crate) use prototype_methods::{
class_prototype_fast_guard_invalidated_for_method, class_prototype_method_guard_slot,
class_prototype_method_root_remove, class_prototype_method_root_store,
invalidate_class_prototype_fast_guards, invalidate_class_prototype_fast_guards_for_method,
mirror_prototype_method_on_object, synthetic_class_id_for_function,
class_prototype_relinked, invalidate_class_prototype_fast_guards,
invalidate_class_prototype_fast_guards_for_method, mirror_prototype_method_on_object,
synthetic_class_id_for_function,
};
pub use prototype_methods::{
js_class_register_static_field, js_get_function_prototype_method,
Expand Down Expand Up @@ -230,9 +231,9 @@ pub(crate) use parent_static::{
class_private_instance_getter_value, class_private_instance_setter_apply,
class_static_accessor_getter_value, class_static_accessor_setter_apply,
class_symbol_getter_value, class_symbol_setter_apply, dynamic_value_class_id,
get_parent_class_id, lookup_class_symbol_method_in_chain, lookup_static_method_in_chain,
lookup_static_method_owner, register_class, register_class_dynamic_static_accessor,
static_accessor_in_chain,
get_parent_class_id, instance_chain_parent_class_id, lookup_class_symbol_method_in_chain,
lookup_static_method_in_chain, lookup_static_method_owner, register_class,
register_class_dynamic_static_accessor, static_accessor_in_chain,
};
pub use parent_static::{
is_class_object_ptr, is_class_object_value, is_registered_class_prototype_object,
Expand Down
32 changes: 26 additions & 6 deletions crates/perry-runtime/src/object/class_registry/parent_static.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1922,14 +1922,34 @@ pub fn lookup_class_method_in_chain(class_id: u32, name: &str) -> Option<(usize,
return Some(entry);
}
}
match get_parent_class_id(cur) {
Some(pid) if pid != 0 => cur = pid,
_ => return None,
match instance_chain_parent_class_id(cur) {
Some(pid) => cur = pid,
None => return None,
}
}
None
}

/// The next class on an INSTANCE chain after `cid`: the declared parent,
/// unless a user operation (`Object.setPrototypeOf(C.prototype, X)`,
/// `C.prototype.__proto__ = X`) replaced the `[[Prototype]]` of `cid`'s
/// prototype object. That prototype's recorded link is then the chain, and a
/// walk over declared class members must stop at `cid`: the parent's methods,
/// getters and setters are off the chain (the generic read continues on the
/// recorded link). The static side (`C.__proto__`) is a different object and
/// keeps the declared parent.
///
/// The relink check runs only when a declared parent exists, so a walk that
/// answers from the receiver's own class, or reaches a root class, pays
/// nothing for it.
#[inline]
pub(crate) fn instance_chain_parent_class_id(cid: u32) -> Option<u32> {
match get_parent_class_id(cid) {
Some(pid) if pid != 0 && !super::class_decl_prototype_relinked(cid) => Some(pid),
_ => None,
}
}

/// True when `ptr` is the prototype OBJECT of some registered class. Class
/// methods are installed as own fields on the prototype object, so a method-as-
/// value read whose receiver *is* the prototype must return the shared canonical
Expand Down Expand Up @@ -1959,9 +1979,9 @@ pub fn method_owner_class_id(class_id: u32, name: &str) -> Option<u32> {
return Some(cur);
}
}
match get_parent_class_id(cur) {
Some(pid) if pid != 0 => cur = pid,
_ => return None,
match instance_chain_parent_class_id(cur) {
Some(pid) => cur = pid,
None => return None,
}
}
None
Expand Down
Loading
Loading