diff --git a/crates/perry-runtime/src/array/indexing.rs b/crates/perry-runtime/src/array/indexing.rs index 3d7a85a800..691234cb95 100644 --- a/crates/perry-runtime/src/array/indexing.rs +++ b/crates/perry-runtime/src/array/indexing.rs @@ -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; diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index 289e348e95..845a747231 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -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::{ diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 8cb6da2c69..bd59c890cf 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -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 diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index db58a61106..028635dec2 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -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; diff --git a/crates/perry/tests/issue_object_proto_descriptor_fast_path.rs b/crates/perry/tests/issue_object_proto_descriptor_fast_path.rs new file mode 100644 index 0000000000..d05f05c4fb --- /dev/null +++ b/crates/perry/tests/issue_object_proto_descriptor_fast_path.rs @@ -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}" + ); + } +}