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
34 changes: 34 additions & 0 deletions crates/perry-runtime/src/object/class_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5497,6 +5497,40 @@ pub(crate) fn class_has_instance_getter(class_id: u32, name: &str) -> bool {
false
}

/// Whether the class chain rooted at `class_id` defines an instance getter OR
/// setter named `name` (on `Class.prototype`, via `js_register_class_getter` /
/// `js_register_class_setter`). These accessors live in the per-class vtable,
/// NOT in the address-keyed descriptor tables, so a prototype-object descriptor
/// scan would miss them — the dynamic-write fast path must consult this before
/// treating `instance[name] = v` as a plain own-data store (an inherited
/// accessor must intercept instead). Walks the `extends` chain like
/// [`class_has_instance_getter`].
pub(crate) fn class_chain_has_instance_accessor(class_id: u32, name: &str) -> bool {
let Ok(guard) = CLASS_VTABLE_REGISTRY.read() else {
return false;
};
let Some(reg) = guard.as_ref() else {
return false;
};
let mut cid = class_id;
let mut depth = 0usize;
while cid != 0 && depth < 32 {
if let Some(vt) = reg.get(&cid) {
if vt.getters.contains_key(name) || vt.setters.contains_key(name) {
return true;
}
}
match get_parent_class_id(cid) {
Some(p) if p != 0 && p != cid => {
cid = p;
depth += 1;
}
_ => break,
}
}
false
}

pub(crate) unsafe fn class_instance_setter_apply(
class_id: u32,
name: &str,
Expand Down
13 changes: 12 additions & 1 deletion crates/perry-runtime/src/object/field_set_by_name.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1076,8 +1076,19 @@ pub extern "C" fn js_object_set_field_by_name(
new_keys as usize,
new_index as u32,
);
// The sidecar is keyed on the OBJECT pointer (see
// `keys_index_lookup`, which probes `obj as usize`), NOT the
// keys-array pointer — shape-sharing clones the keys array on
// every insert, so a keys-keyed entry would be orphaned each
// iteration. Previously this inline-slot append registered
// under `new_keys as usize`, so the obj-keyed lookup never
// found it and rebuilt the full O(key_count) index on every
// write — turning a wide build that stays on the inline-slot
// path (e.g. a class instance whose pre-sized inline capacity
// keeps appends below the overflow threshold) into O(n²). Use
// the object address to match the lookup + the overflow path.
keys_index_insert(
new_keys as usize,
obj as usize,
(new_index + 1) as u32,
key_hash,
new_index as u32,
Expand Down
92 changes: 92 additions & 0 deletions crates/perry-runtime/src/object/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,98 @@ pub(crate) fn object_proto_may_intercept_key(key: f64) -> bool {
reflect_support::obj_value_has_own_key(proto_value, key)
}

/// Whether a fast plain-data write of `key` to a CLASS INSTANCE (`class_id != 0`)
/// at `obj_addr` might be intercepted by its prototype chain — i.e. the slow
/// `[[Set]]` walk is required instead of a direct own-data store. Conservative:
/// any uncertainty returns `true` (take the slow path).
///
/// All interception sources are checked so the fast path stays correct:
/// 1. A class getter/setter named `key` anywhere in the `extends` chain. These
/// live in the per-class vtable, NOT the address-keyed descriptor tables, so
/// the prototype-object scan in (2) cannot see them.
/// 2. An address-keyed accessor / non-writable descriptor on any *class*
/// prototype object (`Object.defineProperty(C.prototype, …)`), detected via
/// `OBJ_FLAG_HAS_DESCRIPTORS` on that prototype object.
/// 3. `Object.prototype` at the chain tail — delegated per-key to
/// [`object_proto_may_intercept_key`].
///
/// Own-instance descriptors / frozen / sealed are excluded by the caller before
/// this is reached.
pub(crate) unsafe fn class_instance_set_may_intercept(
obj_addr: usize,
class_id: u32,
key: f64,
) -> bool {
// Decode the key once — used for both the class-chain and per-prototype
// accessor probes below.
let name = match reflect_support::key_to_rust_string(key) {
Some(n) => n,
// Non-decodable / non-string key: do not risk the fast path.
None => return true,
};
// (1) A class getter/setter for this exact key anywhere in the class chain.
if class_registry::class_chain_has_instance_accessor(class_id, &name) {
return true;
}
// (2)/(3) Walk the prototype OBJECTS from the instance's [[Prototype]].
let mut proto = js_object_get_prototype_of(crate::value::js_nanbox_pointer(obj_addr as i64));
let mut depth = 0u32;
loop {
depth += 1;
if depth > 64 {
// Pathologically deep / cyclic chain — be safe.
return true;
}
let bits = proto.to_bits();
let top16 = bits >> 48;
// Classify the prototype value before dereferencing it — mirror the
// shapes `js_object_get_prototype_of` can hand back:
// - 0x7FFD NaN-boxed pointer: a small-handle payload (e.g. a Proxy)
// is NOT an ObjectHeader and may carry a trap → be conservative.
// - top16 == 0 raw pointer: module-level object literals recorded via
// `Object.setPrototypeOf` come back as raw I64 pointers.
// - null / undefined: genuine end of chain, nothing to intercept.
// - anything else: unknown shape → do not risk the fast path.
let p = if top16 == 0x7FFD {
let p = (bits & crate::value::POINTER_MASK) as usize;
if p == 0 {
return false;
}
if crate::value::addr_class::is_small_handle(p) {
// Proxy / handle prototype — assume it may intercept the write.
return true;
}
p
} else if top16 == 0 && bits >= (crate::gc::GC_HEADER_SIZE as u64) + 0x1000 {
bits as usize
} else if bits == crate::value::TAG_NULL || bits == crate::value::TAG_UNDEFINED {
return false;
} else {
return true;
};
if crate::array::object_prototype_addr_matches(p) {
// Reached the canonical Object.prototype: per-key check, then done.
return object_proto_may_intercept_key(key);
}
// Per-KEY intercepting descriptor on this class prototype. A blanket
// `object_has_descriptors(p)` bail is too coarse — every class prototype
// carries descriptors (constructor / method install), which would defeat
// the fast path entirely. Only an inherited accessor or non-writable data
// property *named this key* actually intercepts the write.
if object_has_descriptors(p) {
if get_accessor_descriptor(p, &name).is_some() {
return true;
}
if let Some(attrs) = get_property_attrs(p, &name) {
if !attrs.writable() {
return true;
}
}
}
proto = js_object_get_prototype_of(proto);
}
}

/// #5054: record descriptor installation on the target object itself —
/// `OBJ_FLAG_HAS_DESCRIPTORS` in its GcHeader (travels with the object on
/// evacuation), plus the `Object.prototype` process-global above. Unlike
Expand Down
28 changes: 28 additions & 0 deletions crates/perry-runtime/src/object/object_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2786,6 +2786,34 @@ pub extern "C" fn js_object_get_prototype_of(obj_value: f64) -> f64 {
}
return function_prototype_or_null();
}
// Fast [[Prototype]] for a DECLARED-class instance: resolve
// directly from the class id instead of the generic
// `constructor_dynamic_prototype` probe, which reads the
// `constructor` field by name and therefore does a LINEAR scan
// over the instance's own keys (O(own-key-count)) before missing
// and continuing to the prototype. On a wide build —
// `const o = new C(); for (i) o["k"+i] = i` — that scan grows by
// one each iteration, making any reflective getPrototypeOf on the
// instance O(n²). The class-id table at line ~2810 below already
// returns this exact prototype for the same instances; hoisting it
// here is semantically identical (same declared-class prototype
// object) but O(1). Gated on a REAL declared class id only
// (`class_decl_prototype_value_for_instance_class` returns None for
// class_id 0 / anonymous-shape / unregistered ids), so synthetic
// function-ctor instances and plain objects keep the existing
// `constructor`-based resolution unchanged.
if (*gc).obj_type == crate::gc::GC_TYPE_OBJECT
&& (*obj).class_id != 0
&& !is_anon_shape_class_id((*obj).class_id)
{
if let Some(proto) =
super::class_registry::class_decl_prototype_value_for_instance_class(
(*obj).class_id,
)
{
return proto;
}
}
if let Some(proto) = constructor_dynamic_prototype(obj) {
return proto;
}
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-runtime/src/object/reflect_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ pub(crate) fn reflect_define_property(obj: f64, key: f64, descriptor: f64) -> f6
reflect_bool(true)
}

unsafe fn key_to_rust_string(value: f64) -> Option<String> {
pub(crate) unsafe fn key_to_rust_string(value: f64) -> Option<String> {
let key_str = crate::builtins::js_string_coerce(value);
if key_str.is_null() {
return None;
Expand Down
28 changes: 20 additions & 8 deletions crates/perry-runtime/src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1249,10 +1249,6 @@ fn ordinary_set_with_receiver(target: f64, key: f64, value: f64, receiver: f64)
// POINTER_TAG'd heap object, or a module-level slot's raw I64 pointer
// (top 16 bits zero).
&& (target_top16 == 0x7FFD || target_top16 == 0)
// Per-key, not the coarse process-wide flag: an unrelated descriptor on
// Object.prototype must not force every write of an *absent* key onto the
// O(n) slow walk (that made wide-object builds O(n²)).
&& !crate::object::object_proto_may_intercept_key(key)
&& unsafe { crate::symbol::js_is_symbol(key) } == 0
{
let addr = extract_pointer(target.to_bits()) as usize;
Expand All @@ -1270,11 +1266,27 @@ fn ordinary_set_with_receiver(target: f64, key: f64, value: f64, receiver: f64)
| crate::gc::OBJ_FLAG_HAS_DESCRIPTORS;
if header.obj_type == crate::gc::GC_TYPE_OBJECT
&& header._reserved & SLOW_FLAGS == 0
&& (*(addr as *const crate::ObjectHeader)).class_id == 0
&& crate::object::prototype_chain::object_static_prototype(addr).is_none()
{
target_set(target, key, value);
return true;
let class_id = (*(addr as *const crate::ObjectHeader)).class_id;
let fast_safe = if class_id == 0 {
// Plain object: prototype is exactly Object.prototype, and
// Object.prototype doesn't intercept this key (per-key, not
// the coarse process-wide descriptor flag — that made wide
// builds O(n²)).
crate::object::prototype_chain::object_static_prototype(addr).is_none()
&& !crate::object::object_proto_may_intercept_key(key)
} else {
// Class instance: the `class_id == 0` guard previously sent
// EVERY wide class-instance build down the O(own-key) slow
// walk (O(n²)). Safe to fast-path when no inherited accessor /
// non-writable anywhere in the prototype chain could intercept
// this key.
!crate::object::class_instance_set_may_intercept(addr, class_id, key)
};
if fast_safe {
target_set(target, key, value);
return true;
}
}
}
}
Expand Down
115 changes: 115 additions & 0 deletions crates/perry/tests/issue_class_instance_wide_set.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
//! Regression test: writing many fresh own properties to a CLASS INSTANCE
//! (`class C {}; const o = new C(); for (i) o["k"+i] = i`) must be O(1) per
//! insert — the same as a plain `{}` — while STILL honoring an inherited
//! setter that intercepts the write.
//!
//! Two layered O(n²) bugs previously made the class-instance wide build scale
//! quadratically (a 20k build took tens of seconds vs ~25ms for a plain
//! object):
//! * The dynamic-write sidecar key index was registered under the keys-array
//! pointer instead of the (stable) object pointer on the inline-slot append
//! path, so the obj-keyed lookup never hit and rebuilt the full index every
//! insert.
//! * `Object.getPrototypeOf` on a declared-class instance resolved its
//! `[[Prototype]]` via a `constructor`-field probe, which does a LINEAR scan
//! over the instance's own keys before missing — re-run on every set by the
//! `[[Set]]` interception check, the scan grew by one each iteration.
//!
//! This test asserts BOTH: the wide build completes (and reads back), and an
//! inherited `set` accessor still intercepts (no own data property created).

use std::path::PathBuf;
use std::process::Command;

fn perry_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_perry"))
}

fn compile_and_run(src: &str) -> String {
let dir = tempfile::tempdir().expect("tempdir");
let entry = dir.path().join("main.ts");
let output = dir.path().join("main_bin");
std::fs::write(&entry, src).expect("write entry");

let compile = Command::new(perry_bin())
.current_dir(dir.path())
.arg("compile")
.arg(&entry)
.arg("-o")
.arg(&output)
.output()
.expect("run perry compile");
assert!(
compile.status.success(),
"perry compile failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&compile.stdout),
String::from_utf8_lossy(&compile.stderr)
);

let run = Command::new(&output).output().expect("run compiled binary");
assert!(
run.status.success(),
"compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}",
run.status,
String::from_utf8_lossy(&run.stdout),
String::from_utf8_lossy(&run.stderr)
);
String::from_utf8_lossy(&run.stdout).to_string()
}

#[test]
fn class_instance_wide_set_is_fast_and_intercepts() {
// An `Object.prototype` accessor is present (the worst case that forces the
// interception check on every write). The wide build of 20_000 fresh keys
// must still complete and read back correctly. The base-class `set baz`
// must intercept (no own `baz` data property created on the instance).
let src = r#"
Object.defineProperty(Object.prototype, "__x__", { get(){ return 1; }, configurable: true });

class Base { _b: any; set baz(v: any) { this._b = "base:" + v; } }
class C extends Base { [k: string]: any; }

const o: any = new C();
const N = 20000;
for (let i = 0; i < N; i++) o["k" + i] = i;

// Inherited setter still intercepts: stored via the setter, NOT as an own prop.
o.baz = 5;

const own = (obj: any, k: string) => Object.prototype.hasOwnProperty.call(obj, k);

// Wide build completed: every key read back, count is exact.
console.log("count", Object.keys(o).length);
console.log("first", o["k0"], "mid", o["k12345"], "last", o["k19999"]);
console.log("setter", o._b, own(o, "baz"));
console.log("fresh-own", own(o, "k7"));
console.log("DONE");
"#;

let out = compile_and_run(src);
// Object.keys includes the 20_000 fresh keys (the inherited `baz` setter
// created `_b`, an own data field on the instance, but `baz` itself is not
// an own key). The exact count guards against dropped/duplicated keys.
assert!(
out.contains("count 20001"),
"wide build must keep every fresh key (+ the setter-created `_b`)\n{out}"
);
assert!(
out.contains("first 0 mid 12345 last 19999"),
"values must read back at the correct keys\n{out}"
);
// `o.baz = 5` ran the inherited setter (`_b == "base:5"`) and created NO
// own `baz` property — interception preserved despite the fast insert path.
assert!(
out.contains("setter base:5 false"),
"inherited setter must intercept (no own `baz` prop)\n{out}"
);
assert!(
out.contains("fresh-own true"),
"a fresh key is a real own data property\n{out}"
);
assert!(
out.contains("DONE"),
"program must run to completion\n{out}"
);
}
Loading