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
21 changes: 17 additions & 4 deletions crates/perry-runtime/src/buffer/header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ use std::collections::{HashMap, HashSet};
use std::sync::{Mutex, OnceLock};

static EXTERNAL_BUFFER_REGISTRY: OnceLock<Mutex<HashSet<usize>>> = OnceLock::new();
/// Latched true by the first external-buffer registration. Lets the hot
/// `is_registered_buffer` probe — which JSON.stringify runs for every pointer
/// value it serializes (#6009) — skip the registry mutex entirely in the
/// (overwhelmingly common) processes that never register an external buffer.
static EXTERNAL_BUFFERS_NONEMPTY: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
static EXTERNAL_UINT8ARRAY_REGISTRY: OnceLock<Mutex<HashSet<usize>>> = OnceLock::new();
static EXTERNAL_CRYPTO_KEY_META_REGISTRY: OnceLock<Mutex<HashMap<usize, CryptoKeyMeta>>> =
OnceLock::new();
Expand Down Expand Up @@ -305,10 +311,11 @@ pub fn is_registered_buffer(addr: usize) -> bool {
if BUFFER_REGISTRY.with(|r| r.borrow().contains(&addr)) {
return true;
}
if external_buffers()
.lock()
.map(|r| r.contains(&addr))
.unwrap_or(false)
if EXTERNAL_BUFFERS_NONEMPTY.load(std::sync::atomic::Ordering::Acquire)
&& external_buffers()
.lock()
.map(|r| r.contains(&addr))
.unwrap_or(false)
{
return true;
}
Expand All @@ -329,6 +336,10 @@ pub fn mark_as_uint8array(addr: usize) {
#[no_mangle]
pub extern "C" fn js_buffer_register_external(addr: usize) {
register_buffer(addr as *const BufferHeader);
// Latch BEFORE the insert: a concurrent `is_registered_buffer` that
// observed the latch after the insert-but-before-the-store window would
// skip the mutex and miss an already-registered buffer.
EXTERNAL_BUFFERS_NONEMPTY.store(true, std::sync::atomic::Ordering::Release);
if let Ok(mut r) = external_buffers().lock() {
r.insert(addr);
}
Expand Down Expand Up @@ -389,6 +400,8 @@ pub extern "C" fn js_buffer_mark_as_crypto_key_external(
register_buffer(addr as *const BufferHeader);
mark_as_uint8array(addr);
mark_as_crypto_key_with_flags(addr, algo, hash, kind, extractable != 0, usages);
// Latch BEFORE the insert — see js_buffer_register_external.
EXTERNAL_BUFFERS_NONEMPTY.store(true, std::sync::atomic::Ordering::Release);
if let Ok(mut r) = external_buffers().lock() {
r.insert(addr);
}
Expand Down
50 changes: 50 additions & 0 deletions crates/perry-runtime/src/json/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ mod simd;
mod stringify;
mod stringify_api;
mod stringify_buffer;
mod stringify_tojson_probe;

// Public FFI re-exports — preserve the `crate::json::js_json_*` path used by
// the rest of perry-runtime, perry-stdlib, and code generated by perry-codegen.
Expand Down Expand Up @@ -82,6 +83,10 @@ pub(crate) use stringify_api::{
pub(crate) use stringify_buffer::{
stringify_buffer, stringify_buffer_pretty, stringify_typed_array, stringify_typed_array_pretty,
};
#[allow(unused_imports)]
pub(crate) use stringify_tojson_probe::{
invalidate_object_proto_tojson_state, to_json_definitely_absent, PROTO_TOJSON_DIRTY,
};

// ─── Circular reference detection ────────────────────────────────────────────
thread_local! {
Expand Down Expand Up @@ -157,6 +162,29 @@ thread_local! {
pub(crate) static SUPPRESS_NEXT_TO_JSON: std::cell::Cell<bool> =
const { std::cell::Cell::new(false) };

/// Cached verdict on whether the default `Object.prototype` carries a
/// `toJSON` property (#6009). One of `PROTO_TOJSON_DIRTY` /
/// `PROTO_TOJSON_ABSENT` / `PROTO_TOJSON_PRESENT`. Computed lazily by the
/// first `object_get_to_json` fast-path probe of a stringify call and
/// invalidated at every top-level stringify entry and after every user
/// callback (`toJSON` / replacer) — the only points where user code could
/// have added `Object.prototype.toJSON` since the last computation.
pub(crate) static OBJECT_PROTO_TOJSON_STATE: std::cell::Cell<u8> =
const { std::cell::Cell::new(0) };

/// One-time-resolved NaN-box bits of the default `Object.prototype`
/// object (0 = not yet resolved). Resolving it live per stringify call
/// (`default_object_prototype_bits`: globalThis generic getter + key
/// alloc + closure prop lookup) dominated the post-#6009 fast-path
/// profile. Per spec the `Object.prototype` property is non-writable /
/// non-configurable, so the resolution itself can never change — only
/// the OBJECT can move under an evacuating GC, which is why this slot is
/// registered as a GC mutable root
/// (`scan_json_object_proto_cache_root_mut`): marking visits it and
/// evacuation rewrites it.
pub(crate) static CACHED_OBJECT_PROTO_BITS: std::cell::Cell<u64> =
const { std::cell::Cell::new(0) };

/// GC roots for in-progress JSON.parse. Each entry is a JSValue bit pattern
/// (stored as f64 so the scanner can hand it to the NaN-boxed mark path).
///
Expand Down Expand Up @@ -446,6 +474,28 @@ pub fn scan_parse_roots_mut(visitor: &mut crate::gc::RuntimeRootVisitor<'_>) {
}
}
});
// #6009: the once-resolved default-`Object.prototype` cache must be
// rewritten when an evacuating GC moves the prototype object (and marked
// so the slot can never point at swept memory). Guard against a stale
// pointer whose backing arena block no longer exists (the unit-test
// harness resets arenas between tests while thread-locals persist):
// visiting it would walk freed memory, so drop the cache instead — the
// next stringify probe simply re-resolves.
CACHED_OBJECT_PROTO_BITS.with(|c| {
let mut bits = c.get();
if bits != 0 {
let addr = (bits & crate::value::POINTER_MASK) as usize;
if matches!(
crate::arena::classify_heap_generation(addr),
crate::arena::HeapGeneration::Unknown
) {
c.set(0);
} else {
visitor.visit_nanbox_u64_slot(&mut bits);
c.set(bits);
}
}
});
}

#[cfg(test)]
Expand Down
9 changes: 9 additions & 0 deletions crates/perry-runtime/src/json/replacer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ pub(crate) unsafe fn call_replacer(
let prev_this = crate::object::js_implicit_this_set(holder_f64);
let result = crate::js_closure_call2(replacer, key_f64, value_f64);
crate::object::js_implicit_this_set(prev_this);
// The user callback may have installed/removed `Object.prototype.toJSON`
// (#6009 fast-probe cache).
super::invalidate_object_proto_tojson_state();
result
}

Expand Down Expand Up @@ -533,8 +536,11 @@ pub unsafe extern "C" fn js_json_stringify_with_replacer(
});
// Defensive: clear the one-shot `toJSON` suppression guard at the outermost
// entry so a throw during a prior stringify can't leak it across calls.
// Arbitrary user code ran since the last stringify, so the cached
// `Object.prototype`-has-`toJSON` verdict must be recomputed too (#6009).
if prior_depth == 0 {
SUPPRESS_NEXT_TO_JSON.with(|c| c.set(false));
super::invalidate_object_proto_tojson_state();
}
let saved_cache = if prior_depth > 0 {
Some(take_shape_cache())
Expand Down Expand Up @@ -1370,8 +1376,11 @@ pub unsafe extern "C" fn js_json_stringify_full(
});
// Defensive: clear the one-shot `toJSON` suppression guard at the outermost
// entry so a throw during a prior stringify can't leak it across calls.
// Arbitrary user code ran since the last stringify, so the cached
// `Object.prototype`-has-`toJSON` verdict must be recomputed too (#6009).
if prior_depth == 0 {
SUPPRESS_NEXT_TO_JSON.with(|c| c.set(false));
super::invalidate_object_proto_tojson_state();
}
let saved_cache = if prior_depth > 0 {
Some(take_shape_cache())
Expand Down
25 changes: 22 additions & 3 deletions crates/perry-runtime/src/json/stringify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,8 @@ pub(crate) unsafe fn bigint_apply_to_json(value: f64) -> Option<f64> {
let prev_this = crate::object::js_implicit_this_set(recv);
let result = crate::closure::js_native_call_value(f64::from_bits(method_bits), &key_f64_arg, 1);
crate::object::js_implicit_this_set(prev_this);
// The user callback may have installed/removed `Object.prototype.toJSON`.
invalidate_object_proto_tojson_state();
Some(result)
}

Expand Down Expand Up @@ -402,6 +404,14 @@ pub(crate) unsafe fn object_get_to_json(ptr: *const u8) -> Option<f64> {
{
return None;
}
// #6009 fast path: when direct reads prove no `toJSON` can resolve
// anywhere on this object's lookup chain, skip the generic
// `js_object_get_field_by_name` dispatch (whose miss path recursively
// re-enters itself through the subclass/prototype fallbacks) and all the
// per-probe allocations below.
if to_json_definitely_absent(ptr) {
return None;
}
// `js_object_get_field_by_name` expects a raw (masked) heap pointer for the
// ordinary-object path; the receiver `this` is the same value NaN-boxed
// with POINTER_TAG.
Expand Down Expand Up @@ -448,6 +458,8 @@ pub(crate) unsafe fn object_get_to_json(ptr: *const u8) -> Option<f64> {
let prev_this = crate::object::js_implicit_this_set(recv);
let result = crate::closure::js_native_call_value(f64::from_bits(bound), &key_f64_arg, 1);
crate::object::js_implicit_this_set(prev_this);
// The user callback may have installed/removed `Object.prototype.toJSON`.
invalidate_object_proto_tojson_state();
Some(result)
}

Expand Down Expand Up @@ -483,6 +495,8 @@ pub(crate) unsafe fn array_get_to_json(arr: *const crate::ArrayHeader) -> Option
let prev_this = crate::object::js_implicit_this_set(recv_handle.get_nanbox_f64());
let result = crate::closure::js_native_call_value(f64::from_bits(method_bits), &key_f64_arg, 1);
crate::object::js_implicit_this_set(prev_this);
// The user callback may have installed/removed `Object.prototype.toJSON`.
invalidate_object_proto_tojson_state();
Some(result)
}

Expand Down Expand Up @@ -1180,9 +1194,14 @@ pub(crate) unsafe fn stringify_object_inner(ptr: *const u8, buf: &mut String, de

buf.push('{');
let mut first = true;
// Only own ENUMERABLE keys are serialized; gated so descriptor-free
// objects (the common case) pay a single relaxed atomic load.
let filter_non_enum = crate::object::descriptors_in_use();
// Only own ENUMERABLE keys are serialized; gated on the process-wide
// atomic AND the per-object `OBJ_FLAG_HAS_DESCRIPTORS` header flag
// (#6009) — the global flag flips for good the first time ANY program
// descriptor is installed, which made every later stringify pay a
// per-key thread-local HashMap probe (`json_key_non_enumerable` +
// `json_object_getter_value`) on objects that never had a descriptor.
let filter_non_enum =
crate::object::descriptors_in_use() && crate::object::object_has_descriptors(ptr as usize);
// `pos(j)` maps the j-th enumerated slot to its key/field index: spec
// order when array-index keys are present, else slot `j` (no allocation).
let pos = |j: u32| -> u32 {
Expand Down
3 changes: 3 additions & 0 deletions crates/perry-runtime/src/json/stringify_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,9 @@ pub unsafe extern "C" fn js_json_stringify(value: f64, type_hint: u32) -> *mut S
// it can't leak across top-level calls.
if prior_depth == 0 {
super::SUPPRESS_NEXT_TO_JSON.with(|c| c.set(false));
// Arbitrary user code ran since the last stringify, so the cached
// `Object.prototype`-has-`toJSON` verdict must be recomputed (#6009).
super::invalidate_object_proto_tojson_state();
// A circular-ref `TypeError` longjmps past the `STRINGIFY_STACK`
// pops (js_throw doesn't unwind Rust), so a caught throw can leave
// stale ancestor pointers behind. Clear at the outermost entry so they
Expand Down
Loading
Loading