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
2 changes: 1 addition & 1 deletion crates/perry-runtime/src/array/indexing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ static ARRAY_PROTO_HAS_INDEX: AtomicBool = AtomicBool::new(false);
static OBJECT_PROTO_ADDR: AtomicUsize = AtomicUsize::new(usize::MAX);
static OBJECT_PROTO_HAS_INDEX: AtomicBool = AtomicBool::new(false);

fn object_prototype_addr() -> usize {
pub(crate) fn object_prototype_addr() -> usize {
let cached = OBJECT_PROTO_ADDR.load(Ordering::Relaxed);
if cached != usize::MAX {
return cached;
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-runtime/src/array/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ pub use self::immutable::{
pub(crate) use self::indexing::{
array_has_own_index, array_iteration_is_exotic, array_proto_iterator_modified,
array_prototype_addr, array_prototype_has_index_flag, array_spec_get, array_spec_has_index,
note_array_proto_iterator_write, note_object_prototype_index_write,
note_array_proto_iterator_write, note_object_prototype_index_write, object_prototype_addr,
object_prototype_addr_matches, object_prototype_has_index_flag,
};
pub use self::indexing::{
Expand Down
24 changes: 24 additions & 0 deletions crates/perry-runtime/src/object/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,30 @@ pub(crate) fn object_proto_descriptors_in_use() -> bool {
OBJECT_PROTO_DESCRIPTORS.load(Ordering::Relaxed)
}

/// True when a write of `key` to a plain object whose prototype is the canonical
/// `Object.prototype` might be intercepted there (inherited setter / non-writable
/// data) and must therefore take the slow [[Set]] walk.
///
/// `OBJECT_PROTO_DESCRIPTORS` only records that *some* descriptor exists on
/// `Object.prototype`; using it directly forced EVERY dynamic write onto the
/// O(own-key-count) slow path, so a single userland `Object.prototype` accessor
/// made any wide-object build O(n²) (a 20k-property build went 16ms → 42s). The
/// fast plain-data write actually only needs the slow path when `Object.prototype`
/// has an own property for THIS key; an absent key cannot be intercepted, so the
/// fast path stays safe even while unrelated descriptors exist on the prototype.
pub(crate) fn object_proto_may_intercept_key(key: f64) -> bool {
if !object_proto_descriptors_in_use() {
return false;
}
let proto_addr = crate::array::object_prototype_addr();
if proto_addr == 0 {
return false;
}
let proto_value =
f64::from_bits(crate::value::JSValue::pointer(proto_addr as *const u8).bits());
reflect_support::obj_value_has_own_key(proto_value, key)
}

/// #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
5 changes: 4 additions & 1 deletion crates/perry-runtime/src/proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1249,7 +1249,10 @@ 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)
&& !crate::object::object_proto_descriptors_in_use()
// 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 Down
91 changes: 91 additions & 0 deletions crates/perry/tests/issue_object_proto_descriptor_fast_path.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//! A descriptor on `Object.prototype` must not force every dynamic write onto
//! the slow `[[Set]]` walk. The `#5054` fast path was gated process-wide by
//! `object_proto_descriptors_in_use()`, so a single userland accessor on
//! `Object.prototype` made every wide-object build O(n²) (a 20k-property build
//! went from ~16ms to ~42s) — which hung real startup paths that build wide
//! config/registry objects while a polyfill had touched `Object.prototype`.
//!
//! Fix: gate the fast path per-key (`object_proto_may_intercept_key`). An absent
//! key on `Object.prototype` cannot be intercepted, so it stays on the fast path;
//! keys `Object.prototype` actually owns still take the slow walk. This test
//! checks correctness is preserved (the inherited setter / non-writable still
//! intercept) and that a wide build completes (would effectively hang if O(n²)).

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

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

fn compile_and_run(dir: &std::path::Path, entry: &std::path::Path) -> (bool, String) {
let output = dir.join("main_bin");
let compile = Command::new(perry_bin())
.current_dir(dir)
.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");
(
run.status.success(),
String::from_utf8_lossy(&run.stdout).to_string(),
)
}

#[test]
fn object_proto_descriptor_keeps_fast_path_for_absent_keys() {
let dir = tempfile::tempdir().expect("tempdir");
let entry = dir.path().join("main.ts");
std::fs::write(
&entry,
r#"
// A userland accessor + a non-writable data prop on Object.prototype.
Object.defineProperty(Object.prototype, "intercepted", {
set(v: any) { (this as any)._got = v; },
get() { return (this as any)._got; },
configurable: true,
})
Object.defineProperty(Object.prototype, "ro", { value: 7, writable: false, configurable: true })

const o: any = {}
o.fresh = 1 // absent on Object.prototype → fast own-data write
o.intercepted = 42 // inherited accessor → setter must run, NO own prop
;(o as any).ro = 9 // inherited non-writable data → write blocked, NO own prop

console.log("fresh:", o.fresh, "own:", Object.prototype.hasOwnProperty.call(o, "fresh"))
console.log("setter-ran:", o._got, "intercepted-own:", Object.prototype.hasOwnProperty.call(o, "intercepted"))
console.log("ro-own:", Object.prototype.hasOwnProperty.call(o, "ro"))

// Wide build with a descriptor present must complete (was O(n²) → hang).
const w: any = {}
for (let i = 0; i < 5000; i++) w["k" + i] = i
console.log("wide:", Object.keys(w).length, w.k0, w.k4999)
console.log("DONE")
"#,
)
.expect("write entry");

let (ok, stdout) = compile_and_run(dir.path(), &entry);
assert!(ok, "binary failed\nstdout:\n{stdout}");
for needle in [
"fresh: 1 own: true",
"setter-ran: 42 intercepted-own: false",
"ro-own: false",
"wide: 5000 0 4999",
"DONE",
] {
assert!(
stdout.contains(needle),
"expected `{needle}` in output:\n{stdout}"
);
}
}
Loading