From 58981abf8c13fc1ff67f8c0b4b60d14a206da4d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:19:41 +0000 Subject: [PATCH 1/2] perf(json): walk plain object members directly, one shape probe per object (#10696) JSON.stringify paid ~2,680 instructions per nested object after #10717 and #11397. Callgrind put the bulk in three places: - stringify_object_inner re-derived the receiver's keys through the shape table on every key access: ~5 shape_descriptor_by_id probes per object at 86 Ir each. The walk now resolves keys and the live slot bound once (object_keys_and_live_slot_count) and re-derives them only after a call that can run user code or collect. - every object member went through member_to_json's and stringify_value_depth's guard chains, both of which end at the same walk. plain_object_member now admits an ordinary object with a plain-record class, no meta record, no toJSON-ish own key and a clean Object.prototype verdict, and hands its shape facts and array-index-key answer (one fused key scan) to the member's walk. - the Object.prototype signature was revalidated per object. The stringify-wide proof is now threaded through the walk with the shape template's data_record_global_proof contract, cleared before anything that can run user code. The same admission serves a compact root. The root no longer builds a single-use shape template, and the general root path no longer allocates a heap "" for its toJSON key. Array records publish their index key only where the element's own toJSON can read it, formatted with itoa, and write_number drops a libm trunc call. The object walk moves to json/stringify_object.rs for the file-size gate. No new side table, no version bump. {a:{b:{c:{d:{e:1}}}}}: 15,748 -> 7,338 Ir/op. Per nested object ~2,680 -> ~960. Output identical to Node 26.5.1 on every benchmark shape and on the new test_gap_json_plain_member_walk.ts, which mutates Object.prototype.toJSON mid-walk from every kind of callback. --- changelog.d/11531-json-plain-member-walk.md | 36 + .../runtime_roots/json_shape_template.rs | 19 + crates/perry-runtime/src/json/mod.rs | 1 + crates/perry-runtime/src/json/replacer.rs | 11 +- crates/perry-runtime/src/json/stringify.rs | 571 +------------- .../src/json/stringify_object.rs | 708 ++++++++++++++++++ .../src/json/stringify_scalars.rs | 7 +- .../src/json/stringify_shape_template.rs | 18 +- .../src/json/stringify_tojson_probe.rs | 155 +++- .../src/json/stringify_tojson_probe_tests.rs | 187 +++++ crates/perry-runtime/src/object/mod.rs | 18 +- test-files/test_gap_json_plain_member_walk.ts | 108 +++ 12 files changed, 1256 insertions(+), 583 deletions(-) create mode 100644 changelog.d/11531-json-plain-member-walk.md create mode 100644 crates/perry-runtime/src/json/stringify_object.rs create mode 100644 test-files/test_gap_json_plain_member_walk.ts diff --git a/changelog.d/11531-json-plain-member-walk.md b/changelog.d/11531-json-plain-member-walk.md new file mode 100644 index 0000000000..5d7282c912 --- /dev/null +++ b/changelog.d/11531-json-plain-member-walk.md @@ -0,0 +1,36 @@ +### Performance + +- `JSON.stringify` visits a nested object for about a third of what it did + (#10696): the per-object cost on nested literals drops from ~2,680 to ~960 + instructions, and `{a:{b:{c:{d:{e:1}}}}}` from 15,748 to 7,338 per call. + No new side table and no new cache. + - **One shape probe per object.** The object walk re-derived the receiver's + keys through the shape table on every key access, about five + `shape_descriptor_by_id` probes per object. It now resolves keys and the + live slot bound once (`object_keys_and_live_slot_count`) and re-derives + them only after a call that can run user code or collect. + - **Plain object members are walked directly.** A member that is an + ordinary object with a plain-record class, no meta record, no `toJSON`-ish + own key and a clean `Object.prototype` verdict skips `member_to_json` and + `stringify_value_depth`'s dispatch chains, which both ended at the same + walk. The admission's shape probe and key scan (fused with the + array-index ordering scan) are handed to the member's walk. + - **One `Object.prototype` verdict per stretch of callback-free members.** + The stringify-wide half of the proof is threaded through the walk with + the shape template's existing `data_record_global_proof` contract and + cleared before anything that can run user code, instead of revalidating + the prototype's signature for every object. + - The same admission serves a compact root object, skipping the root + `toJSON` lookup, `stringify_value`'s dispatch and `is_object_pointer`. The + root no longer builds a single-use shape template, and the general root + path no longer allocates a heap `""` to carry its `toJSON` key. + - Arrays of records publish an element's index key only when the element's + own `toJSON` can read it, and format it with `itoa`; `write_number` tests + integers without a libm `trunc` call. + +### Fixed + +- A two-or-more-key object literal with a key spelled like a runtime-internal + field (`__perry_collection_backing__`, …) no longer drops that key from + `JSON.stringify`: only declared classes can carry those fields, so plain + records are no longer filtered (a one-key literal already kept it). diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/json_shape_template.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/json_shape_template.rs index e7ea98396c..a18c17e935 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/json_shape_template.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/json_shape_template.rs @@ -86,6 +86,25 @@ fn assert_initial_prototype_lookup_survives(empty: bool) { }); let output_scope = RuntimeHandleScope::new(); let output = output_scope.root_nanbox_u64(output as u64); + if empty { + // #10696: the one collection point this case ever reached was the root + // `toJSON` key the general fallback allocated — a heap `""` that was + // only ever read back as bytes. That allocation is gone, so the empty + // root's fallback reaches no collection point at all: with one armed + // for the very next arena allocation, it must not fire. + assert_eq!( + gc_collection_count(), + before, + "the empty root's fallback must stay allocation-free" + ); + assert_eq!( + input.with_mut_ptr(|input: *mut crate::ObjectHeader| input as usize), + before_address + ); + // ...and that verdict is not vacuous: the armed collection is live, + // and the next arena allocation takes it. + let _ = crate::string::js_string_from_bytes(b"armed".as_ptr(), 5); + } drain_scheduled_minor_gc(before, "initial JSON prototype lookup"); assert_ne!( input.with_mut_ptr(|input: *mut crate::ObjectHeader| input as usize), diff --git a/crates/perry-runtime/src/json/mod.rs b/crates/perry-runtime/src/json/mod.rs index 5e38f6ce07..06db4f2073 100644 --- a/crates/perry-runtime/src/json/mod.rs +++ b/crates/perry-runtime/src/json/mod.rs @@ -45,6 +45,7 @@ mod stringify_escaped_output; mod stringify_flat; pub(crate) use stringify_flat::note_completed_malloc_json_output; mod stringify_nested_records; +mod stringify_object; mod stringify_primitive_array; mod stringify_primitive_object; mod stringify_record_output; diff --git a/crates/perry-runtime/src/json/replacer.rs b/crates/perry-runtime/src/json/replacer.rs index 798df231aa..df6c11a894 100644 --- a/crates/perry-runtime/src/json/replacer.rs +++ b/crates/perry-runtime/src/json/replacer.rs @@ -1867,6 +1867,10 @@ pub unsafe extern "C" fn js_json_stringify_full( ); }); } + } else if !use_pretty + && super::stringify_object::try_stringify_plain_root(value.to_bits(), &mut buf) + { + // No replacer, compact, and a root no `toJSON` can reach (#10696). } else { // No replacer. Pre-resolve the ROOT value's own `toJSON` here (same // `apply_to_json_keyed` the function-replacer branch above uses) so a @@ -1877,9 +1881,10 @@ pub unsafe extern "C" fn js_json_stringify_full( // value-tojson-result's `arr.toJSON = () => {}` case). Arm the // one-shot suppression guard so the walk below doesn't re-invoke // `toJSON` on the same (already-resolved) root value. - let empty_str = js_string_from_bytes(b"".as_ptr(), 0); - let empty_key_f64 = nanbox_string_f64(empty_str); - let value_after_to_json = apply_to_json_keyed(value, empty_key_f64); + // The root's `toJSON` key is the empty String; it is only ever read + // back as bytes, so no string needs to be allocated to carry it. + reset_to_json_key(); + let value_after_to_json = apply_to_json(value); let after_bits = value_after_to_json.to_bits(); if after_bits == TAG_UNDEFINED || is_closure_value(after_bits) diff --git a/crates/perry-runtime/src/json/stringify.rs b/crates/perry-runtime/src/json/stringify.rs index 1bcb678906..ead205473a 100644 --- a/crates/perry-runtime/src/json/stringify.rs +++ b/crates/perry-runtime/src/json/stringify.rs @@ -7,7 +7,6 @@ use super::*; use crate::{js_string_from_bytes, JSValue, StringHeader}; -use std::fmt::Write as FmtWrite; pub(crate) use super::stringify_scalars::{ bigint_apply_to_json, serialize_bigint, throw_bigint_serialize, write_escaped_string, @@ -15,9 +14,10 @@ pub(crate) use super::stringify_scalars::{ }; // The homogeneous-array shape template lives in a sibling (file-size gate); // both the object and the array emitter below drive it. -use super::stringify_shape_template::{ - build_shape_prefix_template, shape_template_for, try_emit_shape_element, -}; +use super::stringify_shape_template::{build_shape_prefix_template, try_emit_shape_element}; +// The object walk lives in a sibling (file-size gate); every dispatch here +// reaches it. +pub(crate) use super::stringify_object::stringify_object_inner; // ─── JSON.stringify ─────────────────────────────────────────────────────────── @@ -174,7 +174,13 @@ pub(crate) unsafe fn object_has_no_own_keys(ptr: *const u8) -> bool { pub(super) unsafe fn object_keys_array_checked( obj: *const crate::ObjectHeader, ) -> Option { - let view = crate::object::object_keys(obj); + tracked_keys(crate::object::object_keys(obj)) +} + +/// [`object_keys_array_checked`] for keys the caller already derived. +pub(super) unsafe fn tracked_keys( + view: crate::object::ObjectKeys, +) -> Option { let keys = view.arr() as *const crate::ArrayHeader; if keys.is_null() || !ptr_is_tracked_heap_object(keys as *const u8) { return None; @@ -949,561 +955,6 @@ pub(crate) unsafe fn write_url_href_json(url: *mut crate::ObjectHeader, buf: &mu stringify_value(href, TYPE_UNKNOWN, buf); } -/// SerializeJSONProperty step 2 (`toJSON`) for a heap-valued object member, -/// applied by the object walk BEFORE the member's key is written so a member -/// whose `toJSON` returns `undefined` can be OMITTED per spec (test262 -/// JSON/stringify/value-tojson-arguments) instead of emitting `"k":null` — the -/// key used to be written first, with `toJSON` running only in the value -/// recursion below. -/// -/// Returns `Some(result)` ONLY when a callable `toJSON` actually ran (the value -/// is a plain object/array carrying one); `result` is what it returned. The -/// caller then omits the member if `result` is `undefined`/function/Symbol, or -/// serializes `result` with the one-shot `SUPPRESS_NEXT_TO_JSON` guard armed so -/// its own walk doesn't re-invoke `toJSON`. Returns `None` when no `toJSON` -/// applies — a plain object/array without one, or any value that isn't a plain -/// object/array — so the caller serializes the ORIGINAL value through the -/// normal dispatch (never arming the guard: arming it for a value that then -/// doesn't self-probe would leak the one-shot into the next member's `toJSON`). -/// Because `None` means "serialize normally", a plain data object member keeps -/// the #6009 fast path (its own walk skips the `toJSON` probe when -/// `class_id == 0`), so this adds no probe there. -/// -/// Guards, in an order safe for the `gc_obj_type` read below: handle ids and -/// buffers/typed arrays carry no `GcHeader`; RegExp shares the -/// `GC_TYPE_OBJECT` tag but is not an `ObjectHeader`; a boxed primitive is a -/// real object but must serialize as its primitive (see `stringify_value_depth`) -/// so it is left to the normal dispatch. Date/Temporal cells carry their own -/// `GC_TYPE_*` tags, so the `gc_obj_type` match's `_` arm already skips them. -/// The pending `toJSON` key must already be recorded. -unsafe fn member_to_json(value: f64) -> Option { - let bits = value.to_bits(); - let ptr = extract_pointer(bits)?; - if crate::value::addr_class::is_handle_band(ptr as usize) { - return None; - } - if crate::buffer::is_registered_buffer(ptr as usize) { - return None; - } - if crate::typedarray::lookup_typed_array_kind(ptr as usize).is_some() { - return None; - } - if crate::regex::regex_header_has_magic(ptr as *const crate::regex::RegExpHeader) { - return None; - } - if crate::builtins::boxed_primitive_json_value(value).is_some() { - return None; - } - match gc_obj_type(ptr) { - crate::gc::GC_TYPE_ARRAY => array_get_to_json(ptr as *const crate::ArrayHeader), - crate::gc::GC_TYPE_OBJECT => { - let resolved = object_get_to_json(ptr); - if resolved.is_none() { - // Hand the verdict to the member's own walk (#10696); the - // member loop clears it once the dispatch returns. - TO_JSON_RESOLVED_FOR.with(|c| c.set(ptr as usize)); - } - resolved - } - _ => None, - } -} - -pub(crate) unsafe fn stringify_object_inner(ptr: *const u8, buf: &mut String, depth: u32) { - // Taken unconditionally so a verdict for this object can never reach a - // later walk (see `TO_JSON_RESOLVED_FOR`). An armed one-shot suppression - // means this object IS a `toJSON` result, which also settles the question - // for it — and it must be consumed here: a walk that never probes (a plain - // record) would otherwise leak it into its first child's `toJSON`. - let resolved_by_parent = TO_JSON_RESOLVED_FOR.with(|c| c.replace(0)) == ptr as usize; - let is_to_json_result = SUPPRESS_NEXT_TO_JSON.with(|c| c.replace(false)); - let to_json_resolved = resolved_by_parent || is_to_json_result; - check_stringify_nesting_depth(depth as usize); - // #6519: a WHATWG `URL` instance is a plain `GC_TYPE_OBJECT` (class_id 0) - // whose `searchParams` field points back at the URL — walking its fields - // trips the circular-structure detector. Node serializes a URL via - // `URL.prototype.toJSON()`, i.e. its `href` string. Top-level - // `JSON.stringify(url)` is intercepted at HIR-lowering time - // (`UrlInstanceToJSON`, module_static.rs), but a URL *nested* inside another - // object/array is invisible to that interception and only reaches this - // runtime walker — so detect the URL shape here and emit its href. This is - // the single chokepoint every object walk funnels through (the direct - // dispatch arms, the array slow loop, and per-field descent all land here); - // `is_url_object_shape` validates the GC header before reading any field, so - // it is safe to call on the non-object pointers that reach the `TYPE_OBJECT` - // hint / catch-all callers. - if crate::url::is_url_object_shape(ptr as *mut crate::ObjectHeader) { - write_url_href_json(ptr as *mut crate::ObjectHeader, buf); - return; - } - // #1704: an object with a null `keys_array` has no own enumerable - // properties — empty objects come out of `js_object_alloc` with - // `keys_array == null` and only get one once a field is set. This is the - // shape of `Object.fromEntries([])`, `Object.fromEntries(emptyURLSearchParams)`, - // and a never-mutated `{}` literal. Recursion into a nested empty object - // reaches here directly (the `GC_TYPE_OBJECT` arm in `stringify_value_depth` - // skips `is_object_pointer`), so the `(*keys_arr).length` read below would - // dereference null and segfault (the `Object.fromEntries(URL.searchParams)` - // crash inside a `@hono/perry-server` handler). Emit "{}" and return — an - // empty object has no children, so it can't be part of a cycle and the - // circular-reference tracking below is unnecessary. - if crate::object::object_keys(ptr as *const crate::ObjectHeader).is_null() { - // A null `keys_array` means no own enumerable properties — but a class - // instance with no instance fields (only methods, e.g. a `class { - // toJSON() {…} }`) still has a `toJSON` on its prototype/vtable that - // must be honoured before falling back to "{}". A plain empty object - // literal / `Object.fromEntries([])` carries `class_id == 0`, so the - // probe is skipped for them. (#321) - if (*(ptr as *const crate::ObjectHeader)).class_id != 0 && !to_json_resolved { - if let Some(to_json_val) = object_get_to_json(ptr) { - arm_to_json_result_guard(to_json_val); - // Thread depth so a `toJSON` returning a cycle trips the - // circular-detection push instead of overflowing the stack — - // see the matching note in the keyed-object branch below. - stringify_value_depth(to_json_val, TYPE_UNKNOWN, buf, depth + 1); - SUPPRESS_NEXT_TO_JSON.with(|c| c.set(false)); - return; - } - } - buf.push_str("{}"); - return; - } - if depth > MAX_FAST_DEPTH { - // Deep nesting — switch to full circular detection - if STRINGIFY_STACK.with(|s| s.borrow().contains(&(ptr as usize))) { - let msg = "Converting circular structure to JSON"; - let msg_ptr = js_string_from_bytes(msg.as_ptr(), msg.len() as u32); - let err_ptr = crate::error::js_typeerror_new(msg_ptr); - crate::exception::js_throw(f64::from_bits( - POINTER_TAG | (err_ptr as u64 & POINTER_MASK), - )); - } - STRINGIFY_STACK.with(|s| s.borrow_mut().push(ptr as usize)); - } - - let obj = ptr as *const crate::ObjectHeader; - let num_fields = crate::object::object_live_slot_count(obj); - - // Templated fast path (#64 follow-up): if this object's shape has been - // seen before in this stringify call, emit via the cached prefix table - // and skip per-object `has_pointer_fields` / `object_get_to_json` / - // key-lookup work. `try_emit_shape_element` rolls back the buffer and - // returns false on any element-specific mismatch (different shape, - // stray UNDEFINED, closure), at which point we fall through to the - // slow path below. - // - // Guard (issue #67): skip the template machinery for small objects. - // `shape_template_for` allocates a Box + Vec - // + one String per field on miss (~4-5 heap allocs), and the cache - // is wiped at every top-level call exit — so for a one-shot small - // top-level stringify the build is pure overhead vs. the inline slow - // path below. The arrayof-objects fast path (stringify_array_depth) - // uses a separate build_shape_prefix_template that's unaffected. - // Skip the shape-template fast path when the object has overflow fields - // (keys_len > num_fields — see object.rs:32 OVERFLOW_FIELDS, ≥9 stored - // fields per #307). The template's per-field key prefix array is built - // from `min(keys_len, field_count)`, so an overflow object would only - // emit its first 8 fields. Falling through to the slow path below uses - // `read_field_bits` which routes overflow reads through - // `js_object_get_field`'s overflow_get fallback. - // Whether a `toJSON` could come from anywhere but an own closure field. - // The raw emitters below read own fields only, so they run only when it - // cannot; the general walk probes when it can. When the parent already - // performed this object's `toJSON` lookup (`TO_JSON_RESOLVED_FOR`) there - // is nothing left to ask (#10696). - let inherited_to_json_possible = !to_json_resolved - && super::stringify_tojson_probe::inherited_to_json_possible_without_gc(ptr); - let has_overflow_fields = unsafe { - let keys_arr_view = crate::object::object_keys(obj); - let keys_arr = keys_arr_view.arr(); - !keys_arr.is_null() && keys_arr_view.count() > num_fields - }; - // The shape-template fast path emits every key in the shape; it can't - // honor per-key `enumerable: false`, so fall through to the slow path - // (which filters) whenever any descriptor exists on this thread. - // Class instances (class_id != 0) route through the slow path: it honours a - // prototype/own `toJSON` and filters private (`#x`) elements, neither of - // which the shape-template fast path handles. Plain data objects (class_id - // == 0 — the common JSON shape) keep the fast path. - if num_fields >= 5 - && !has_overflow_fields - && !crate::object::descriptors_in_use() - && !inherited_to_json_possible - { - if let Some(tmpl_ptr) = shape_template_for(ptr) { - let mut data_record_global_proof = false; - if try_emit_shape_element( - make_pointer_bits(ptr), - &*tmpl_ptr, - buf, - depth, - None, - &mut data_record_global_proof, - ) { - if depth > MAX_FAST_DEPTH { - STRINGIFY_STACK.with(|s| s.borrow_mut().pop()); - } - return; - } - } - } - let Some(keys_view) = object_keys_array_checked(obj) else { - // Not an ObjectHeader after all (a Promise / WeakMap / ArrayBuffer that - // reached here via a static TYPE_OBJECT hint). Node serializes those as - // `{}`; walking the slot as an ArrayHeader would fault. - buf.push_str("{}"); - return; - }; - let keys_len = keys_view.count(); - // Root the object for the enumeration below and re-derive the keys/field - // buffers from the CURRENT header on every access: a user getter - // (`json_object_getter_value`), a `toJSON` somewhere inside a nested - // value, or any allocation in the recursive `stringify_value_depth` call - // can trigger a GC that sweeps or moves this object — bare Rust locals - // are invisible to the collector (production runs no conservative stack - // scan), and alloc-point minors can be MOVING under the evacuation - // policy. The keys array is re-derived THROUGH the object header (its - // `keys_array` field is rewritten by the collector when it moves). - let scope = crate::gc::RuntimeHandleScope::new(); - let obj_handle = scope.root_raw_const_ptr(obj); - let key_at = |f: u32| -> f64 { - obj_handle.with_const_ptr(|obj: *const crate::ObjectHeader| { - let keys_arr_view = crate::object::object_keys(obj); - let keys_arr = keys_arr_view.arr(); - let keys_elements = - crate::array::array_elements_ptr(keys_arr as *const crate::ArrayHeader) - as *const f64; - *keys_elements.add(f as usize) - }) - }; - // Closes #307: iterate up to keys_len, not min(num_fields, keys_len). - // Parser-built objects with ≥9 fields cap field_count at the inline - // alloc_limit (max(field_count, 8) physical slots) and store the overflow - // values in OVERFLOW_FIELDS (object.rs:32) — so num_fields can be smaller - // than keys_len. For inline slots (f < alloc_limit) we still read directly - // off fields_ptr; for overflow slots we route through `js_object_get_field` - // which checks field_count and falls through to `overflow_get`. Pre-fix - // (`std::cmp::min(num_fields, keys_len)`) silently dropped the overflow - // fields and `is_object_pointer`'s `keys_len <= field_count` guard - // returned false, so `JSON.stringify` emitted the literal string "null" - // for any parsed object with ≥9 fields. - let alloc_limit = std::cmp::max(num_fields, crate::object::INLINE_SLOT_FLOOR as u32); - let read_field_bits = |f: u32| -> u64 { - obj_handle.with_const_ptr(|obj: *const crate::ObjectHeader| { - if f < alloc_limit { - let fields_ptr = (obj as *const u8).add(std::mem::size_of::()) - as *const f64; - (*fields_ptr.add(f as usize)).to_bits() - } else { - crate::object::js_object_get_field(obj, f).bits() - } - }) - }; - let actual_fields = keys_len; - - // Nested one-field leaves and wide inline objects can prove primitive - // fields while emitting them in one raw walk, avoiding both the generic - // closure scan and the separate ordinary-key ordering scan. An array-index - // key or complex value rolls the native buffer back before the general - // path computes the required ordering. No pointer/BigInt field, descriptor - // or class can reach the borrowed emit interval. - if (actual_fields == 1 || actual_fields > 32) - && !has_overflow_fields - && !inherited_to_json_possible - && !crate::object::object_has_descriptors(ptr as usize) - && super::stringify_primitive_object::try_emit(obj, keys_view, buf) - { - if depth > MAX_FAST_DEPTH { - STRINGIFY_STACK.with(|s| s.borrow_mut().pop()); - } - return; - } - - // #2438: enumerate own keys in ECMA-262 OrdinaryOwnPropertyKeys order — - // array-index keys first (ascending numeric), then string keys in - // insertion order. `None` means no array-index keys, so insertion order - // already matches spec and the loop walks `0..actual_fields` directly. - let key_order = crate::object::ecma_own_key_order(keys_view); - - // Deferred toJSON + closure checks (issue #67 tightening): scan fields - // once to detect if any field is actually a closure. For data-only - // objects with nested arrays/objects (e.g. `{a:1, b:"", c:[...]}`) the - // earlier has_pointer_fields heuristic false-positived because any - // POINTER_TAG field triggered the `object_get_to_json` key walk — even - // though a toJSON method requires the *value* at the "toJSON" key to - // be a closure. Reading offset 12 (CLOSURE_MAGIC) per pointer field is - // cheaper (~3ns/field) than walking the keys array looking for a - // "toJSON" string that almost never exists (~15ns). - let has_closure_field = { - let mut found = false; - for f in 0..actual_fields { - let bits = read_field_bits(f); - let tag = bits & 0xFFFF_0000_0000_0000; - let ptr_candidate = if tag == POINTER_TAG { - (bits & POINTER_MASK) as *const u8 - } else if is_raw_pointer(bits) { - bits as *const u8 - } else { - std::ptr::null() - }; - // #2154 — a POINTER_TAG field can be a native *handle id* (a small - // integer, e.g. an `http.Agent` in an object literal, a fetch/zlib/ - // stream handle, or a revocable-Proxy id), not a real heap pointer. - // Reading the CLOSURE_MAGIC tag at offset 12 of such a value - // segfaults. Skip the whole small-handle band `[0, 0x100000)` — not - // just the `< 0x1000` low guard (#4904/#1843 — a Proxy id at 0xF000D - // in a Next.js render object crashed exactly here). Real closures - // live far above the band. - if crate::value::addr_class::is_above_handle_band(ptr_candidate as usize) { - let type_tag = - *(ptr_candidate.add(crate::closure::CLOSURE_TYPE_TAG_OFFSET) as *const u32); - // A Symbol-valued field must also be dropped (test262 - // JSON/stringify/value-symbol): a Symbol is POINTER_TAG'd but - // not a closure, so it needs its own probe alongside the - // CLOSURE_MAGIC check. - if type_tag == crate::closure::CLOSURE_MAGIC - || crate::symbol::is_registered_symbol(ptr_candidate as usize) - { - found = true; - break; - } - } - } - found - }; - - // A `toJSON` can live as an OWN closure-typed field (a plain object - // literal `{ toJSON() {…} }`) OR on the object's prototype / class-method - // chain — a `class { toJSON() {…} }` instance stores `toJSON` on the class - // vtable, and an `Object.create(proto)` result inherits it from `proto`. - // Neither of those carries an own closure field, so the cheap - // `has_closure_field` scan misses them; `inherited_to_json_possible` - // covers them (and `Object.setPrototypeOf` / `Object.prototype.toJSON`), - // so probe `object_get_to_json` (which resolves own+prototype via - // `js_object_get_field_by_name`) in that case too. This is what lets - // `JSON.stringify` honour a prototype `toJSON` (#321 — Effect - // `Inspectable`). Object literals are anonymous shape classes, so - // `class_id != 0` alone no longer decides it (#10529). - let has_prototype_chain = (*obj).class_id != 0; - // An own ACCESSOR `toJSON` (`{ get toJSON() {…} }`) is neither a closure - // field nor inherited; every accessor sets the descriptor header flag. - let has_own_accessors = crate::object::object_has_descriptors(ptr as usize); - if (has_closure_field || inherited_to_json_possible || has_own_accessors) && !to_json_resolved { - if let Some(to_json_val) = object_get_to_json(ptr) { - if depth > MAX_FAST_DEPTH { - STRINGIFY_STACK.with(|s| s.borrow_mut().pop()); - } - arm_to_json_result_guard(to_json_val); - // Thread the current depth into the toJSON-result walk (do NOT - // reset to the depth-0 `stringify_value` entry). A `toJSON` that - // returns a structure re-entering an object still open higher in - // the walk (`obj.toJSON = () => circular; circular.prop = obj`) - // would otherwise recurse forever: each re-entry restarted at - // depth 0, so the `depth > MAX_FAST_DEPTH` circular-detection push - // never engaged and the stack overflowed (SIGSEGV). Accumulating - // depth makes the detection fire and throw the spec TypeError - // (test262 JSON/stringify/value-tojson-object-circular). - stringify_value_depth(to_json_val, TYPE_UNKNOWN, buf, depth + 1); - SUPPRESS_NEXT_TO_JSON.with(|c| c.set(false)); - return; - } - } - - // 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::object_has_descriptors(ptr as usize) - && (crate::object::descriptors_in_use() - || crate::object::key_attrs::object_summary(ptr as *const crate::ObjectHeader) - & crate::object::key_attrs::SUMMARY_KEY_BITS - != 0); - buf.push('{'); - let mut first = true; - // `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 { - match &key_order { - Some(ord) => ord[j as usize], - None => j, - } - }; - for j in 0..actual_fields { - let f = pos(j); - // Tombstoned slot from an O(1) delete: not a key, not serialized. - if key_at(f).to_bits() == crate::value::TAG_HOLE { - continue; - } - // Private elements (`#x`) live in a class instance's keys_array but are - // not serializable own properties. (`has_prototype_chain` == class_id != 0.) - if has_prototype_chain - && obj_handle.with_const_ptr(|obj: *const crate::ObjectHeader| { - crate::object::instance_private_key_hidden( - obj, - JSValue::from_bits(key_at(f).to_bits()), - ) - }) - { - continue; - } - // Skip non-enumerable own keys (e.g. `Object.defineProperty(o, k, - // { enumerable: false })`) before touching the value. - if filter_non_enum - && obj_handle.with_const_ptr(|obj: *const crate::ObjectHeader| { - json_key_non_enumerable(obj, key_at(f)) - }) - { - continue; - } - let mut field_bits = read_field_bits(f); - // Own accessor properties: serialize the getter's return value (Node - // invokes the getter), not the raw slot — which holds the getter - // closure (object-literal `get x() {}`) or an empty placeholder - // (`Object.defineProperty(o, k, { get })`). Gated on the descriptor flag. - // The getter is USER CODE: every pointer below is re-derived from the - // rooted handle after it returns. - if filter_non_enum { - if let Some(gv) = obj_handle.with_const_ptr(|obj: *const crate::ObjectHeader| { - crate::object::json_object_getter_value(obj, key_at(f)) - }) { - field_bits = gv.to_bits(); - } - } - let mut field_val = f64::from_bits(field_bits); - // Skip undefined per JSON spec (incl. a getter that returned undefined). - if field_bits == TAG_UNDEFINED { - continue; - } - // Skip closures and Symbols per JSON spec (only possible for - // pointer-tagged values). Guarded by has_closure_field: if no field - // is a closure/Symbol, the in-loop check is skipped entirely for - // every field. - if has_closure_field && (is_closure_value(field_bits) || is_symbol_value(field_bits)) { - continue; - } - - // Resolve the member key up front — needed both to record the `toJSON` - // key (#5909) and to write the property name below. - let key_f64 = key_at(f); - let key_bits = key_f64.to_bits(); - - // SerializeJSONProperty step 2 (#5909): apply a heap-valued member's - // `toJSON` HERE, before the comma/key are written, so a member whose - // `toJSON` returns `undefined` (or a function/Symbol) is OMITTED per - // spec — the value recursion below runs `toJSON` only AFTER the key, so - // such a member wrongly emitted `"k":null`. A member's `toJSON` key is - // its own property name; the synthetic `field{f}` fallback name is - // unreadable, so pass "" as it did before. - let mut member_probed = false; - if (field_bits & 0xFFFF_0000_0000_0000) == POINTER_TAG || is_raw_pointer(field_bits) { - let mut key_sso = [0u8; crate::value::SHORT_STRING_MAX_LEN]; - set_to_json_key_str(object_key_str(key_bits, &mut key_sso).unwrap_or("")); - if let Some(resolved) = member_to_json(field_val) { - let rb = resolved.to_bits(); - if rb == TAG_UNDEFINED || is_closure_value(rb) || is_symbol_value(rb) { - continue; - } - field_bits = rb; - field_val = resolved; - // `toJSON` already ran; arm the one-shot guard so the resolved - // value's own serialization doesn't invoke `toJSON` a second - // time (SerializeJSONProperty applies it once). Disarmed after - // the pointer dispatch below, gated on `member_probed`. - arm_to_json_result_guard(resolved); - member_probed = true; - } - } - - if !first { - buf.push(','); - } - first = false; - - // `member_to_json` may have collected and moved a heap key. Re-read - // the slot through the rooted object after that call; SSO keys remain - // self-contained and use the same decoder. - let current_key_bits = if member_probed { - key_at(f).to_bits() - } else { - key_bits - }; - let mut key_sso = [0u8; crate::value::SHORT_STRING_MAX_LEN]; - if let Some(key_str) = object_key_str(current_key_bits, &mut key_sso) { - // A key can itself contain `"`/`\`/control characters (e.g. a - // `Symbol`-adjacent computed key or `Object.defineProperty` - // literal name) — must go through the same escaper as string - // values, not a raw `push_str` (test262 - // JSON/stringify/value-string-escape-ascii, where the property - // name embeds all 32 ASCII control characters). - write_escaped_string(buf, key_str); - buf.push(':'); - } else if crate::string::js_string_key_bytes( - JSValue::from_bits(current_key_bits), - &mut key_sso, - ) - .is_some_and(|bytes| super::stringify_scalars::write_wtf8_key(buf, bytes)) - { - buf.push(':'); - } else { - let _ = write!(buf, "\"field{}\":", f); - } - - // Inline value dispatch for common types to avoid function call - // overhead. `field_bits`/`field_val` are the post-`toJSON` value when a - // `toJSON` ran above. - let val_tag = field_bits & 0xFFFF_0000_0000_0000; - if field_bits == TAG_NULL { - buf.push_str("null"); - } else if field_bits == TAG_TRUE { - buf.push_str("true"); - } else if field_bits == TAG_FALSE { - buf.push_str("false"); - } else if val_tag == STRING_TAG { - let str_ptr = (field_bits & POINTER_MASK) as *const StringHeader; - if let Some(s) = str_from_header(str_ptr) { - write_escaped_string(buf, s); - } else { - buf.push_str("null"); - } - } else if val_tag == crate::value::SHORT_STRING_TAG { - // v0.5.213 SSO — decode inline 5-byte string and emit. - let jsval = JSValue::from_bits(field_bits); - let mut scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN]; - let n = jsval.short_string_to_buf(&mut scratch); - if let Ok(s) = std::str::from_utf8(&scratch[..n]) { - write_escaped_string(buf, s); - } else { - buf.push_str("null"); - } - } else if val_tag == POINTER_TAG || is_raw_pointer(field_bits) { - // Nested object/array (or the object/array `toJSON` returned). The - // `toJSON` key was recorded above; `member_probed` armed the guard. - stringify_value_depth(field_val, TYPE_UNKNOWN, buf, depth + 1); - if member_probed { - SUPPRESS_NEXT_TO_JSON.with(|c| c.set(false)); - } - // `member_to_json`'s verdict is only for this member's own walk. - TO_JSON_RESOLVED_FOR.with(|c| c.set(0)); - } else { - // Number (most common for data objects) — or Date, handled - // centrally by `write_number` via DATE_REGISTRY lookup. A BigInt - // member funnels through `write_number` to `serialize_bigint` / - // `bigint_apply_to_json`, which reads the pending `toJSON` key, so - // record this member's key first (#5909). - if val_tag == BIGINT_TAG { - set_to_json_key_str(object_key_str(current_key_bits, &mut key_sso).unwrap_or("")); - } - write_number(buf, field_val); - } - } - buf.push('}'); - if depth > MAX_FAST_DEPTH { - STRINGIFY_STACK.with(|s| s.borrow_mut().pop()); - } -} - pub(crate) unsafe fn stringify_array(ptr: *const u8, buf: &mut String) { stringify_array_depth(ptr, buf, 0) } diff --git a/crates/perry-runtime/src/json/stringify_object.rs b/crates/perry-runtime/src/json/stringify_object.rs new file mode 100644 index 0000000000..eb226ceada --- /dev/null +++ b/crates/perry-runtime/src/json/stringify_object.rs @@ -0,0 +1,708 @@ +//! The `JSON.stringify` object walk: an object's own enumerable members, the +//! `toJSON` probe that precedes them, and the direct walk of plain members +//! (#10696). Split out of `stringify.rs` for the 2000-line file gate; the +//! value dispatch that reaches it stays there. + +use super::stringify::{ + arm_to_json_result_guard, array_get_to_json, check_stringify_nesting_depth, is_closure_value, + is_symbol_value, json_key_non_enumerable, object_get_to_json, object_key_str, + stringify_value_depth, tracked_keys, write_escaped_string, write_number, write_short_string, + write_url_href_json, +}; +use super::stringify_shape_template::{shape_template_for, try_emit_shape_element}; +use super::*; +use crate::{js_string_from_bytes, JSValue, StringHeader}; +use std::fmt::Write as FmtWrite; + +/// SerializeJSONProperty step 2 (`toJSON`) for a heap-valued object member, +/// applied by the object walk BEFORE the member's key is written so a member +/// whose `toJSON` returns `undefined` can be OMITTED per spec (test262 +/// JSON/stringify/value-tojson-arguments) instead of emitting `"k":null` — the +/// key used to be written first, with `toJSON` running only in the value +/// recursion below. +/// +/// Returns `Some(result)` ONLY when a callable `toJSON` actually ran (the value +/// is a plain object/array carrying one); `result` is what it returned. The +/// caller then omits the member if `result` is `undefined`/function/Symbol, or +/// serializes `result` with the one-shot `SUPPRESS_NEXT_TO_JSON` guard armed so +/// its own walk doesn't re-invoke `toJSON`. Returns `None` when no `toJSON` +/// applies — a plain object/array without one, or any value that isn't a plain +/// object/array — so the caller serializes the ORIGINAL value through the +/// normal dispatch (never arming the guard: arming it for a value that then +/// doesn't self-probe would leak the one-shot into the next member's `toJSON`). +/// Because `None` means "serialize normally", a plain data object member keeps +/// the #6009 fast path (its own walk skips the `toJSON` probe when +/// `class_id == 0`), so this adds no probe there. +/// +/// Guards, in an order safe for the `gc_obj_type` read below: handle ids and +/// buffers/typed arrays carry no `GcHeader`; RegExp shares the +/// `GC_TYPE_OBJECT` tag but is not an `ObjectHeader`; a boxed primitive is a +/// real object but must serialize as its primitive (see `stringify_value_depth`) +/// so it is left to the normal dispatch. Date/Temporal cells carry their own +/// `GC_TYPE_*` tags, so the `gc_obj_type` match's `_` arm already skips them. +/// The pending `toJSON` key must already be recorded. +unsafe fn member_to_json(value: f64) -> Option { + let bits = value.to_bits(); + let ptr = extract_pointer(bits)?; + if crate::value::addr_class::is_handle_band(ptr as usize) { + return None; + } + if crate::buffer::is_registered_buffer(ptr as usize) { + return None; + } + if crate::typedarray::lookup_typed_array_kind(ptr as usize).is_some() { + return None; + } + if crate::regex::regex_header_has_magic(ptr as *const crate::regex::RegExpHeader) { + return None; + } + if crate::builtins::boxed_primitive_json_value(value).is_some() { + return None; + } + match gc_obj_type(ptr) { + crate::gc::GC_TYPE_ARRAY => array_get_to_json(ptr as *const crate::ArrayHeader), + crate::gc::GC_TYPE_OBJECT => { + let resolved = object_get_to_json(ptr); + if resolved.is_none() { + // Hand the verdict to the member's own walk (#10696); the + // member loop clears it once the dispatch returns. + TO_JSON_RESOLVED_FOR.with(|c| c.set(ptr as usize)); + } + resolved + } + _ => None, + } +} + +pub(crate) unsafe fn stringify_object_inner(ptr: *const u8, buf: &mut String, depth: u32) { + // Taken unconditionally so a verdict for this object can never reach a + // later walk (see `TO_JSON_RESOLVED_FOR`). An armed one-shot suppression + // means this object IS a `toJSON` result, which also settles the question + // for it — and it must be consumed here: a walk that never probes (a plain + // record) would otherwise leak it into its first child's `toJSON`. + let resolved_by_parent = TO_JSON_RESOLVED_FOR.with(|c| c.replace(0)) == ptr as usize; + let is_to_json_result = SUPPRESS_NEXT_TO_JSON.with(|c| c.replace(false)); + let mut global_proof = false; + stringify_object_walk( + ptr, + buf, + depth, + resolved_by_parent || is_to_json_result, + None, + &mut global_proof, + ); +} + +/// Compact `JSON.stringify(value)` of a root that +/// `stringify_tojson_probe::plain_object_member` admits: an ordinary object no +/// `toJSON` can reach, walked directly with the facts that proof resolved +/// (#10696). The generic root route reaches the same walk through the root +/// `toJSON` lookup (and its empty-key allocation), `stringify_value`'s +/// dispatch chain and `is_object_pointer`; only the node-stream probe that +/// dispatch applies to a root object is kept. Returns false, having written +/// nothing and called nothing, for any other value. +pub(super) unsafe fn try_stringify_plain_root(value_bits: u64, buf: &mut String) -> bool { + let mut global_proof = false; + let Some((ptr, member)) = + super::stringify_tojson_probe::plain_object_member(value_bits, &mut global_proof) + else { + return false; + }; + if !crate::node_stream::try_stringify_node_stream_json(ptr, buf) { + stringify_object_walk(ptr, buf, 0, true, Some(member), &mut global_proof); + } + true +} + +/// Write `"key":` for the member in slot `f` of an object walk. +/// +/// A key can itself contain `"`/`\`/control characters (e.g. a +/// `Symbol`-adjacent computed key or `Object.defineProperty` literal name) — +/// it must go through the same escaper as string values, not a raw +/// `push_str` (test262 JSON/stringify/value-string-escape-ascii, where the +/// property name embeds all 32 ASCII control characters). +#[inline] +unsafe fn write_member_key(buf: &mut String, key_bits: u64, f: u32) { + let mut key_sso = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + // An inline short key — most property names — is quoted straight from its + // payload: plain ASCII needs neither UTF-8 validation nor an escape scan, + // and anything else declines before writing and takes the general decode. + if key_bits & crate::value::TAG_MASK == crate::value::SHORT_STRING_TAG { + let len = JSValue::from_bits(key_bits).short_string_to_buf(&mut key_sso); + if write_short_string(buf, &key_sso[..len]) { + buf.push(':'); + return; + } + } + if let Some(key_str) = object_key_str(key_bits, &mut key_sso) { + write_escaped_string(buf, key_str); + buf.push(':'); + } else if crate::string::js_string_key_bytes(JSValue::from_bits(key_bits), &mut key_sso) + .is_some_and(|bytes| super::stringify_scalars::write_wtf8_key(buf, bytes)) + { + buf.push(':'); + } else { + let _ = write!(buf, "\"field{}\":", f); + } +} + +/// The object walk behind [`stringify_object_inner`]. +/// +/// `to_json_resolved` says this object's own `toJSON` question is already +/// settled. `plain_member` is `Some` when the caller admitted this object +/// through `stringify_tojson_probe::plain_object_member`, and carries what +/// that proof resolved, with nothing that allocates or calls user code in +/// between — so the walk reuses it instead of probing again (#10696). +/// `global_proof` is the stringify-wide `toJSON` proof that function +/// documents; the walk clears it before anything that can run user code, and +/// hands it to the plain members it walks. +unsafe fn stringify_object_walk( + ptr: *const u8, + buf: &mut String, + depth: u32, + to_json_resolved: bool, + plain_member: Option, + global_proof: &mut bool, +) { + check_stringify_nesting_depth(depth as usize); + let obj = ptr as *const crate::ObjectHeader; + // #6519: a WHATWG `URL` instance is a plain `GC_TYPE_OBJECT` (class_id 0) + // whose `searchParams` field points back at the URL — walking its fields + // trips the circular-structure detector. Node serializes a URL via + // `URL.prototype.toJSON()`, i.e. its `href` string. Top-level + // `JSON.stringify(url)` is intercepted at HIR-lowering time + // (`UrlInstanceToJSON`, module_static.rs), but a URL *nested* inside another + // object/array is invisible to that interception and only reaches this + // runtime walker — so detect the URL shape here and emit its href. This is + // the single chokepoint every object walk funnels through (the direct + // dispatch arms, the array slow loop, and per-field descent all land here). + // `is_url_object_shape` declines every class id but 0, so only those pay + // for it; reading the class id is no more of a dereference than the shape + // read just below, which every caller already relied on. + if (*obj).class_id == 0 && crate::url::is_url_object_shape(ptr as *mut crate::ObjectHeader) { + *global_proof = false; + write_url_href_json(ptr as *mut crate::ObjectHeader, buf); + return; + } + // One shape-table probe answers both the keys and the live inline-slot + // bound; the member loop below re-derives the keys only after a call that + // can run user code or collect. Re-deriving them at every key access cost + // five probes per object (#10696). + let (mut keys_view, mut num_fields) = match plain_member { + Some(member) => (member.keys, member.live_slots), + None => crate::object::object_keys_and_live_slot_count(obj), + }; + // Whether the admission's answer about array-index keys still describes + // `keys_view` (it stops doing so if the facts are re-resolved below). + let mut index_keys_known = plain_member.map(|member| member.has_index_key); + // #1704: an object with a null `keys_array` has no own enumerable + // properties — empty objects come out of `js_object_alloc` with + // `keys_array == null` and only get one once a field is set. This is the + // shape of `Object.fromEntries([])`, `Object.fromEntries(emptyURLSearchParams)`, + // and a never-mutated `{}` literal. Recursion into a nested empty object + // reaches here directly (the `GC_TYPE_OBJECT` arm in `stringify_value_depth` + // skips `is_object_pointer`), so the `(*keys_arr).length` read below would + // dereference null and segfault (the `Object.fromEntries(URL.searchParams)` + // crash inside a `@hono/perry-server` handler). Emit "{}" and return — an + // empty object has no children, so it can't be part of a cycle and the + // circular-reference tracking below is unnecessary. + if keys_view.is_null() { + // A null `keys_array` means no own enumerable properties — but a class + // instance with no instance fields (only methods, e.g. a `class { + // toJSON() {…} }`) still has a `toJSON` on its prototype/vtable that + // must be honoured before falling back to "{}". A plain empty object + // literal / `Object.fromEntries([])` carries `class_id == 0`, so the + // probe is skipped for them. (#321) + if (*obj).class_id != 0 && !to_json_resolved { + *global_proof = false; + if let Some(to_json_val) = object_get_to_json(ptr) { + arm_to_json_result_guard(to_json_val); + // Thread depth so a `toJSON` returning a cycle trips the + // circular-detection push instead of overflowing the stack — + // see the matching note in the keyed-object branch below. + stringify_value_depth(to_json_val, TYPE_UNKNOWN, buf, depth + 1); + SUPPRESS_NEXT_TO_JSON.with(|c| c.set(false)); + return; + } + } + buf.push_str("{}"); + return; + } + if depth > MAX_FAST_DEPTH { + // Deep nesting — switch to full circular detection + if STRINGIFY_STACK.with(|s| s.borrow().contains(&(ptr as usize))) { + let msg = "Converting circular structure to JSON"; + let msg_ptr = js_string_from_bytes(msg.as_ptr(), msg.len() as u32); + let err_ptr = crate::error::js_typeerror_new(msg_ptr); + crate::exception::js_throw(f64::from_bits( + POINTER_TAG | (err_ptr as u64 & POINTER_MASK), + )); + } + STRINGIFY_STACK.with(|s| s.borrow_mut().push(ptr as usize)); + } + + // Templated fast path (#64 follow-up): if this object's shape has been + // seen before in this stringify call, emit via the cached prefix table + // and skip per-object `has_pointer_fields` / `object_get_to_json` / + // key-lookup work. `try_emit_shape_element` rolls back the buffer and + // returns false on any element-specific mismatch (different shape, + // stray UNDEFINED, closure), at which point we fall through to the + // slow path below. + // + // Guard (issue #67): skip the template machinery for small objects. + // `shape_template_for` allocates a Box + Vec + // + one String per field on miss (~4-5 heap allocs), and the cache + // is wiped at every top-level call exit — so for a one-shot small + // top-level stringify the build is pure overhead vs. the inline slow + // path below. The arrayof-objects fast path (stringify_array_depth) + // uses a separate build_shape_prefix_template that's unaffected. + // Skip the shape-template fast path when the object has overflow fields + // (keys_len > num_fields — see object.rs:32 OVERFLOW_FIELDS, ≥9 stored + // fields per #307). The template's per-field key prefix array is built + // from `min(keys_len, field_count)`, so an overflow object would only + // emit its first 8 fields. Falling through to the slow path below uses + // `read_field_bits` which routes overflow reads through + // `js_object_get_field`'s overflow_get fallback. + // Whether the class can contribute anything a raw own-field emitter + // would miss: a `toJSON` on its chain, or private/runtime-internal keys. + // A plain member's admission already answered it. + let class_plain_record = plain_member.is_some() + || super::stringify_tojson_probe::class_is_plain_record((*obj).class_id); + // Whether a `toJSON` could come from anywhere but an own closure field. + // The raw emitters below read own fields only, so they run only when it + // cannot; the general walk probes when it can. When the parent already + // performed this object's `toJSON` lookup (`TO_JSON_RESOLVED_FOR`) there + // is nothing left to ask (#10696). + let inherited_to_json_possible = !to_json_resolved + && super::stringify_tojson_probe::inherited_to_json_possible_without_gc( + ptr, + class_plain_record, + ); + let has_overflow_fields = keys_view.count() > num_fields; + // The shape-template fast path emits every key in the shape; it can't + // honor per-key `enumerable: false`, so fall through to the slow path + // (which filters) whenever any descriptor exists on this thread. + // Class instances (class_id != 0) route through the slow path: it honours a + // prototype/own `toJSON` and filters private (`#x`) elements, neither of + // which the shape-template fast path handles. Plain data objects (class_id + // == 0 — the common JSON shape) keep the fast path. The root is walked + // directly: a call visits it once, so its template could never be reused + // and building one cost more than the walk (#10696). + if depth > 0 + && num_fields >= 5 + && !has_overflow_fields + && !crate::object::descriptors_in_use() + && !inherited_to_json_possible + { + if let Some(tmpl_ptr) = shape_template_for(ptr) { + // Same proof, same contract: the template establishes it lazily + // and clears it before any path that can call user code. + if try_emit_shape_element( + make_pointer_bits(ptr), + &*tmpl_ptr, + buf, + depth, + None, + global_proof, + ) { + if depth > MAX_FAST_DEPTH { + STRINGIFY_STACK.with(|s| s.borrow_mut().pop()); + } + return; + } + // A declined attempt can have walked a member (and run its + // `toJSON`) before rolling back, so re-resolve the facts. + (keys_view, num_fields) = crate::object::object_keys_and_live_slot_count(obj); + index_keys_known = None; + } + } + // Not an ObjectHeader after all (a Promise / WeakMap / ArrayBuffer that + // reached here via a static TYPE_OBJECT hint). Node serializes those as + // `{}`; walking the slot as an ArrayHeader would fault. A plain member's + // admission validated its GC header and its keys array, which came from + // the shape table, whose keys word the collector maintains — while those + // facts are still the current ones. + let keys_view = if index_keys_known.is_some() { + keys_view + } else if let Some(keys_view) = tracked_keys(keys_view) { + keys_view + } else { + buf.push_str("{}"); + return; + }; + let keys_len = keys_view.count(); + // Root the object for the enumeration below and re-derive the keys/field + // buffers from the CURRENT header after anything that can run user code: + // a user getter (`json_object_getter_value`), a `toJSON` somewhere inside + // a nested value, or any allocation in the recursive + // `stringify_value_depth` call can trigger a GC that sweeps or moves this + // object — bare Rust locals are invisible to the collector (production + // runs no conservative stack scan), and alloc-point minors can be MOVING + // under the evacuation policy. The keys array is re-derived THROUGH the + // object header (its shape's keys word is rewritten by the collector when + // it moves, and user code can move the object to another shape). + let scope = crate::gc::RuntimeHandleScope::new(); + let obj_handle = scope.root_raw_const_ptr(obj); + let current_key_slots = || -> *const f64 { + obj_handle.with_const_ptr(|obj: *const crate::ObjectHeader| { + let keys_arr = crate::object::object_keys(obj).arr(); + crate::array::array_elements_ptr(keys_arr as *const crate::ArrayHeader) as *const f64 + }) + }; + // Closes #307: iterate up to keys_len, not min(num_fields, keys_len). + // Parser-built objects with ≥9 fields cap field_count at the inline + // alloc_limit (max(field_count, 8) physical slots) and store the overflow + // values in OVERFLOW_FIELDS (object.rs:32) — so num_fields can be smaller + // than keys_len. For inline slots (f < alloc_limit) we still read directly + // off fields_ptr; for overflow slots we route through `js_object_get_field` + // which checks field_count and falls through to `overflow_get`. Pre-fix + // (`std::cmp::min(num_fields, keys_len)`) silently dropped the overflow + // fields and `is_object_pointer`'s `keys_len <= field_count` guard + // returned false, so `JSON.stringify` emitted the literal string "null" + // for any parsed object with ≥9 fields. + let alloc_limit = std::cmp::max(num_fields, crate::object::INLINE_SLOT_FLOOR as u32); + let read_field_bits = |f: u32| -> u64 { + obj_handle.with_const_ptr(|obj: *const crate::ObjectHeader| { + if f < alloc_limit { + let fields_ptr = (obj as *const u8).add(std::mem::size_of::()) + as *const f64; + (*fields_ptr.add(f as usize)).to_bits() + } else { + crate::object::js_object_get_field(obj, f).bits() + } + }) + }; + let actual_fields = keys_len; + + // Nested one-field leaves and wide inline objects can prove primitive + // fields while emitting them in one raw walk, avoiding both the generic + // closure scan and the separate ordinary-key ordering scan. An array-index + // key or complex value rolls the native buffer back before the general + // path computes the required ordering. No pointer/BigInt field, descriptor + // or class can reach the borrowed emit interval. + // A one-field object whose field is itself a container (a nesting level) + // would only be written and rolled back, so it is not attempted. + if (actual_fields > 32 + || (actual_fields == 1 + && super::stringify_primitive_object::field_is_primitive(read_field_bits(0)))) + && !has_overflow_fields + && !inherited_to_json_possible + && !crate::object::object_has_descriptors(ptr as usize) + && super::stringify_primitive_object::try_emit(obj, keys_view, buf) + { + if depth > MAX_FAST_DEPTH { + STRINGIFY_STACK.with(|s| s.borrow_mut().pop()); + } + return; + } + + // #2438: enumerate own keys in ECMA-262 OrdinaryOwnPropertyKeys order — + // array-index keys first (ascending numeric), then string keys in + // insertion order. `None` means no array-index keys, so insertion order + // already matches spec and the loop walks `0..actual_fields` directly. + // A plain member's admission already scanned its keys for this. + let key_order = match index_keys_known { + Some(false) => None, + _ => crate::object::ecma_own_key_order(keys_view), + }; + + // Private elements (`#x`) and runtime-internal keys live only in declared + // classes' instances, never in a plain record's keys (the same fact the + // raw emitters above rely on), so only other classes filter them. + let hide_private_keys = !class_plain_record; + + // Deferred toJSON + closure checks (issue #67 tightening): scan fields + // once to detect if any field is actually a closure. For data-only + // objects with nested arrays/objects (e.g. `{a:1, b:"", c:[...]}`) the + // earlier has_pointer_fields heuristic false-positived because any + // POINTER_TAG field triggered the `object_get_to_json` key walk — even + // though a toJSON method requires the *value* at the "toJSON" key to + // be a closure. Reading offset 12 (CLOSURE_MAGIC) per pointer field is + // cheaper (~3ns/field) than walking the keys array looking for a + // "toJSON" string that almost never exists (~15ns). + let has_closure_field = { + let mut found = false; + for f in 0..actual_fields { + let bits = read_field_bits(f); + let tag = bits & 0xFFFF_0000_0000_0000; + let ptr_candidate = if tag == POINTER_TAG { + (bits & POINTER_MASK) as *const u8 + } else if is_raw_pointer(bits) { + bits as *const u8 + } else { + std::ptr::null() + }; + // #2154 — a POINTER_TAG field can be a native *handle id* (a small + // integer, e.g. an `http.Agent` in an object literal, a fetch/zlib/ + // stream handle, or a revocable-Proxy id), not a real heap pointer. + // Reading the CLOSURE_MAGIC tag at offset 12 of such a value + // segfaults. Skip the whole small-handle band `[0, 0x100000)` — not + // just the `< 0x1000` low guard (#4904/#1843 — a Proxy id at 0xF000D + // in a Next.js render object crashed exactly here). Real closures + // live far above the band. + if crate::value::addr_class::is_above_handle_band(ptr_candidate as usize) { + let type_tag = + *(ptr_candidate.add(crate::closure::CLOSURE_TYPE_TAG_OFFSET) as *const u32); + // A Symbol-valued field must also be dropped (test262 + // JSON/stringify/value-symbol): a Symbol is POINTER_TAG'd but + // not a closure, so it needs its own probe alongside the + // CLOSURE_MAGIC check. + if type_tag == crate::closure::CLOSURE_MAGIC + || crate::symbol::is_registered_symbol(ptr_candidate as usize) + { + found = true; + break; + } + } + } + found + }; + + // A `toJSON` can live as an OWN closure-typed field (a plain object + // literal `{ toJSON() {…} }`) OR on the object's prototype / class-method + // chain — a `class { toJSON() {…} }` instance stores `toJSON` on the class + // vtable, and an `Object.create(proto)` result inherits it from `proto`. + // Neither of those carries an own closure field, so the cheap + // `has_closure_field` scan misses them; `inherited_to_json_possible` + // covers them (and `Object.setPrototypeOf` / `Object.prototype.toJSON`), + // so probe `object_get_to_json` (which resolves own+prototype via + // `js_object_get_field_by_name`) in that case too. This is what lets + // `JSON.stringify` honour a prototype `toJSON` (#321 — Effect + // `Inspectable`). Object literals are anonymous shape classes, so + // `class_id != 0` alone no longer decides it (#10529). + // An own ACCESSOR `toJSON` (`{ get toJSON() {…} }`) is neither a closure + // field nor inherited; every accessor sets the descriptor header flag. + let has_own_accessors = crate::object::object_has_descriptors(ptr as usize); + // Whether a call below may have run user code or collected since the key + // slots were last derived. The probe can do both even when it finds no + // `toJSON` (a getter-valued `toJSON`, the first `Object.prototype` lookup). + let mut keys_stale = false; + if (has_closure_field || inherited_to_json_possible || has_own_accessors) && !to_json_resolved { + *global_proof = false; + if let Some(to_json_val) = object_get_to_json(ptr) { + if depth > MAX_FAST_DEPTH { + STRINGIFY_STACK.with(|s| s.borrow_mut().pop()); + } + arm_to_json_result_guard(to_json_val); + // Thread the current depth into the toJSON-result walk (do NOT + // reset to the depth-0 `stringify_value` entry). A `toJSON` that + // returns a structure re-entering an object still open higher in + // the walk (`obj.toJSON = () => circular; circular.prop = obj`) + // would otherwise recurse forever: each re-entry restarted at + // depth 0, so the `depth > MAX_FAST_DEPTH` circular-detection push + // never engaged and the stack overflowed (SIGSEGV). Accumulating + // depth makes the detection fire and throw the spec TypeError + // (test262 JSON/stringify/value-tojson-object-circular). + stringify_value_depth(to_json_val, TYPE_UNKNOWN, buf, depth + 1); + SUPPRESS_NEXT_TO_JSON.with(|c| c.set(false)); + return; + } + keys_stale = true; + } + // Only dereferenced once the loop has refreshed it, if `keys_stale`. + let mut key_slots = + crate::array::array_elements_ptr(keys_view.arr() as *const crate::ArrayHeader) + as *const f64; + + // 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::object_has_descriptors(ptr as usize) + && (crate::object::descriptors_in_use() + || crate::object::key_attrs::object_summary(ptr as *const crate::ObjectHeader) + & crate::object::key_attrs::SUMMARY_KEY_BITS + != 0); + buf.push('{'); + let mut first = true; + // `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 { + match &key_order { + Some(ord) => ord[j as usize], + None => j, + } + }; + for j in 0..actual_fields { + let f = pos(j); + if keys_stale { + key_slots = current_key_slots(); + keys_stale = false; + } + let mut key_bits = (*key_slots.add(f as usize)).to_bits(); + // Tombstoned slot from an O(1) delete: not a key, not serialized. + if key_bits == crate::value::TAG_HOLE { + continue; + } + // Private elements (`#x`) live in a class instance's keys_array but are + // not serializable own properties. + if hide_private_keys + && obj_handle.with_const_ptr(|obj: *const crate::ObjectHeader| { + crate::object::instance_private_key_hidden(obj, JSValue::from_bits(key_bits)) + }) + { + continue; + } + // Skip non-enumerable own keys (e.g. `Object.defineProperty(o, k, + // { enumerable: false })`) before touching the value. + if filter_non_enum + && obj_handle.with_const_ptr(|obj: *const crate::ObjectHeader| { + json_key_non_enumerable(obj, f64::from_bits(key_bits)) + }) + { + continue; + } + let mut field_bits = read_field_bits(f); + // Own accessor properties: serialize the getter's return value (Node + // invokes the getter), not the raw slot — which holds the getter + // closure (object-literal `get x() {}`) or an empty placeholder + // (`Object.defineProperty(o, k, { get })`). Gated on the descriptor flag. + // The getter is USER CODE: every pointer below is re-derived from the + // rooted handle after it returns. + if filter_non_enum { + *global_proof = false; + let getter_value = obj_handle.with_const_ptr(|obj: *const crate::ObjectHeader| { + crate::object::json_object_getter_value(obj, f64::from_bits(key_bits)) + }); + key_slots = current_key_slots(); + key_bits = (*key_slots.add(f as usize)).to_bits(); + if let Some(gv) = getter_value { + field_bits = gv.to_bits(); + } + } + let mut field_val = f64::from_bits(field_bits); + // Skip undefined per JSON spec (incl. a getter that returned undefined). + if field_bits == TAG_UNDEFINED { + continue; + } + // Skip closures and Symbols per JSON spec (only possible for + // pointer-tagged values). Guarded by has_closure_field: if no field + // is a closure/Symbol, the in-loop check is skipped entirely for + // every field. + if has_closure_field && (is_closure_value(field_bits) || is_symbol_value(field_bits)) { + continue; + } + + // An ordinary object member that no `toJSON` can reach is walked + // directly, reusing the shape facts that admitted it: SerializeJSONProperty + // step 2 has nothing to call, and every other `stringify_value_depth` + // dispatch arm is excluded by the same proof (#10696). The member's + // `toJSON` key is not published either — only a `toJSON` call reads it, + // and each of the member's own members publishes its own first. + if let Some((member_ptr, member)) = + super::stringify_tojson_probe::plain_object_member(field_bits, global_proof) + { + if !first { + buf.push(','); + } + first = false; + write_member_key(buf, key_bits, f); + stringify_object_walk(member_ptr, buf, depth + 1, true, Some(member), global_proof); + keys_stale = true; + continue; + } + + // SerializeJSONProperty step 2 (#5909): apply a heap-valued member's + // `toJSON` HERE, before the comma/key are written, so a member whose + // `toJSON` returns `undefined` (or a function/Symbol) is OMITTED per + // spec — the value recursion below runs `toJSON` only AFTER the key, so + // such a member wrongly emitted `"k":null`. A member's `toJSON` key is + // its own property name; the synthetic `field{f}` fallback name is + // unreadable, so pass "" as it did before. + let mut member_probed = false; + if (field_bits & 0xFFFF_0000_0000_0000) == POINTER_TAG || is_raw_pointer(field_bits) { + // Everything from here to the member's dispatch can run user code. + *global_proof = false; + let mut key_sso = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + set_to_json_key_str(object_key_str(key_bits, &mut key_sso).unwrap_or("")); + if let Some(resolved) = member_to_json(field_val) { + let rb = resolved.to_bits(); + if rb == TAG_UNDEFINED || is_closure_value(rb) || is_symbol_value(rb) { + keys_stale = true; + continue; + } + field_bits = rb; + field_val = resolved; + // `toJSON` already ran; arm the one-shot guard so the resolved + // value's own serialization doesn't invoke `toJSON` a second + // time (SerializeJSONProperty applies it once). Disarmed after + // the pointer dispatch below, gated on `member_probed`. + arm_to_json_result_guard(resolved); + member_probed = true; + } + } + + if !first { + buf.push(','); + } + first = false; + + // `member_to_json` may have collected and moved a heap key. Re-read + // the slot through the rooted object after that call; SSO keys remain + // self-contained and use the same decoder. + if member_probed { + key_slots = current_key_slots(); + key_bits = (*key_slots.add(f as usize)).to_bits(); + } + write_member_key(buf, key_bits, f); + + // Inline value dispatch for common types to avoid function call + // overhead. `field_bits`/`field_val` are the post-`toJSON` value when a + // `toJSON` ran above. + let val_tag = field_bits & 0xFFFF_0000_0000_0000; + if field_bits == TAG_NULL { + buf.push_str("null"); + } else if field_bits == TAG_TRUE { + buf.push_str("true"); + } else if field_bits == TAG_FALSE { + buf.push_str("false"); + } else if val_tag == STRING_TAG { + let str_ptr = (field_bits & POINTER_MASK) as *const StringHeader; + if let Some(s) = str_from_header(str_ptr) { + write_escaped_string(buf, s); + } else { + buf.push_str("null"); + } + } else if val_tag == crate::value::SHORT_STRING_TAG { + // v0.5.213 SSO — decode inline 5-byte string and emit. + let jsval = JSValue::from_bits(field_bits); + let mut scratch = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + let n = jsval.short_string_to_buf(&mut scratch); + if let Ok(s) = std::str::from_utf8(&scratch[..n]) { + write_escaped_string(buf, s); + } else { + buf.push_str("null"); + } + } else if val_tag == POINTER_TAG || is_raw_pointer(field_bits) { + // Nested object/array (or the object/array `toJSON` returned). The + // `toJSON` key was recorded above; `member_probed` armed the guard. + stringify_value_depth(field_val, TYPE_UNKNOWN, buf, depth + 1); + if member_probed { + SUPPRESS_NEXT_TO_JSON.with(|c| c.set(false)); + } + // `member_to_json`'s verdict is only for this member's own walk. + TO_JSON_RESOLVED_FOR.with(|c| c.set(0)); + keys_stale = true; + } else { + // Number (most common for data objects) — or Date, handled + // centrally by `write_number` via DATE_REGISTRY lookup. A BigInt + // member funnels through `write_number` to `serialize_bigint` / + // `bigint_apply_to_json`, which reads the pending `toJSON` key, so + // record this member's key first (#5909). + if val_tag == BIGINT_TAG { + let mut key_sso = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + set_to_json_key_str(object_key_str(key_bits, &mut key_sso).unwrap_or("")); + // A `BigInt.prototype.toJSON` is user code. + keys_stale = true; + *global_proof = false; + } + write_number(buf, field_val); + } + } + buf.push('}'); + if depth > MAX_FAST_DEPTH { + STRINGIFY_STACK.with(|s| s.borrow_mut().pop()); + } +} diff --git a/crates/perry-runtime/src/json/stringify_scalars.rs b/crates/perry-runtime/src/json/stringify_scalars.rs index 36b2df294a..7e2cd2fbde 100644 --- a/crates/perry-runtime/src/json/stringify_scalars.rs +++ b/crates/perry-runtime/src/json/stringify_scalars.rs @@ -35,11 +35,16 @@ pub(crate) unsafe fn write_number(buf: &mut String, value: f64) { if value.is_nan() || value.is_infinite() { // JSON has no NaN/Infinity literal; the spec serializes them as null. buf.push_str("null"); - } else if value.fract() == 0.0 && value.abs() < crate::builtins::INT_EXACT_FASTPATH_LIMIT { + } else if value.abs() < crate::builtins::INT_EXACT_FASTPATH_LIMIT + && (value as i64) as f64 == value + { // Fast path for in-range integers (the overwhelming majority of JSON // numbers); identical to ECMAScript NumberToString below 2^53. Above it // the exact integer can carry more digits than the shortest round-trip // (`2**58`), so those use shortest-round-trip formatting below (#6127). + // The integer test is the conversion round trip rather than `fract()`, + // which is a libm `trunc` call on the baseline x86-64 target; both + // spell either zero as "0". let mut itoa_buf = itoa::Buffer::new(); super::stringify_copy::push_str(buf, itoa_buf.format(value as i64)); } else if write_compact_decimal(buf, value) { diff --git a/crates/perry-runtime/src/json/stringify_shape_template.rs b/crates/perry-runtime/src/json/stringify_shape_template.rs index 193492f40a..d2064df67d 100644 --- a/crates/perry-runtime/src/json/stringify_shape_template.rs +++ b/crates/perry-runtime/src/json/stringify_shape_template.rs @@ -388,13 +388,6 @@ pub(crate) unsafe fn try_emit_shape_element( return false; } - // The callback-free record path above proves that neither the element nor - // any child can observe a `toJSON` key. Only publish the array index once a - // path that may invoke user code remains. - if let Some(index) = array_index_key { - set_to_json_key_index(index); - } - // ★ #7268: ROOT THE ELEMENT. The emit loops below call // `stringify_value_depth` for every pointer-valued field, and that can run // a user `toJSON` — allocating, reaching a safepoint, and taking an @@ -441,7 +434,9 @@ pub(crate) unsafe fn try_emit_shape_element( // `shape_fields <= alloc_limit` and the branch is never taken. Hoisting it // stays sound across a collection because it is a COUNT, not an address: // `field_count` is copied verbatim when the object moves. - let alloc_limit = object_alloc_limit(obj); + // The shape probe above resolved this bound for the same receiver, and + // nothing between it and here can allocate or change the shape. + let alloc_limit = std::cmp::max(live_inline_slots, crate::object::INLINE_SLOT_FLOOR as u32); let field_bits_at = |f: usize| -> u64 { // Re-derived per access, deliberately. The pre-#7268 code hoisted // `fields_ptr` above the loop, which is exactly the address a `toJSON` @@ -547,6 +542,13 @@ pub(crate) unsafe fn try_emit_shape_element( } } if has_pointer_fields { + // The element's own `toJSON` is the one reader of its array-index key: + // every field below publishes its own key before its value can call + // user code, and a declined element is republished by the caller. So + // the index is formatted only here, not for every templated element. + if let Some(index) = array_index_key { + set_to_json_key_index(index); + } // Through the handle: the pre-scan above is allocation-free today, but // `elem_ptr` is the pre-collection address and there is no reason for a // second name for this object to exist (#7268). diff --git a/crates/perry-runtime/src/json/stringify_tojson_probe.rs b/crates/perry-runtime/src/json/stringify_tojson_probe.rs index 48243bde69..a20133dc85 100644 --- a/crates/perry-runtime/src/json/stringify_tojson_probe.rs +++ b/crates/perry-runtime/src/json/stringify_tojson_probe.rs @@ -89,6 +89,55 @@ unsafe fn keys_array_may_carry_to_json(keys_view: crate::object::ObjectKeys) -> false } +/// [`keys_array_may_carry_to_json`] fused with the other question a member's +/// walk asks of the same keys — does any key need ECMA-262 index ordering +/// (`ecma_own_key_order`) — so the array is scanned once, not twice (#10696). +/// `None` when a key may carry a `toJSON` or the array is not a well-formed +/// keys array (the caller then takes the general path); otherwise whether +/// any key is a canonical array index. +unsafe fn member_keys_scan(keys_view: crate::object::ObjectKeys) -> Option { + let keys = keys_view.arr(); + if keys.is_null() { + return Some(false); + } + let keys_addr = keys as usize; + if keys_addr & 0x7 != 0 { + return None; + } + let keys_gc = crate::value::addr_class::try_read_gc_header(keys_addr)?; + let key_count = keys_view.count() as usize; + if keys_gc.obj_type != crate::gc::GC_TYPE_ARRAY + || key_count > (*keys).capacity as usize + || key_count > (*keys).length as usize + || key_count > 4096 + { + return None; + } + let elements = + crate::array::array_elements_ptr(keys as *const crate::ArrayHeader) as *const f64; + let mut has_index_key = false; + let mut inline = [0u8; crate::value::SHORT_STRING_MAX_LEN]; + for i in 0..key_count { + let stored = JSValue::from_bits((*elements.add(i)).to_bits()); + let Some(bytes) = crate::string::js_string_key_bytes(stored, &mut inline) else { + continue; + }; + match bytes.first() { + // Every marker is longer than an inline string, so testing inline + // keys too answers exactly what `key_may_carry_to_json` does. + Some(b't' | b'_') if key_bytes_may_carry_to_json(bytes) => return None, + Some(first) if first.is_ascii_digit() && !has_index_key => { + has_index_key = std::str::from_utf8(bytes) + .ok() + .and_then(crate::object::canonical_array_index) + .is_some(); + } + _ => {} + } + } + Some(has_index_key) +} + // All forwarding markers are longer than the inline-string representation. // Keep the short-string and leading-byte exclusions tied to their constants. const _: () = { @@ -690,15 +739,109 @@ pub(super) unsafe fn to_json_definitely_absent_without_gc(ptr: *const u8) -> boo /// populate globalThis) it answers `true`, sending the caller to the rooted /// probe. Once that cache is warm, even a dirty verdict is recomputed with /// direct reads only (see `to_json_definitely_absent_without_gc`). +/// +/// `class_plain_record` is [`class_is_plain_record`] of `ptr`'s class, which +/// the caller also needs for itself. #[inline] -pub(super) unsafe fn inherited_to_json_possible_without_gc(ptr: *const u8) -> bool { - !class_is_plain_record((*(ptr as *const crate::ObjectHeader)).class_id) +pub(super) unsafe fn inherited_to_json_possible_without_gc( + ptr: *const u8, + class_plain_record: bool, +) -> bool { + !class_plain_record || crate::object::prototype_chain::object_static_prototype(ptr as usize).is_some() || (OBJECT_PROTO_TOJSON_STATE.with(|c| c.get()) == PROTO_TOJSON_DIRTY && CACHED_OBJECT_PROTO_BITS.with(|c| c.get()) == 0) || object_proto_may_have_to_json() } +/// An object-valued member the walk may serialize by walking it directly, +/// with the shape facts that walk needs: an ordinary object that no `toJSON` +/// can reach (#10696). +/// +/// Otherwise every nested object pays two dispatch chains that both end at +/// that same walk: `member_to_json` (buffer, typed array, RegExp, boxed +/// primitive, GC type, then the `toJSON` proof) and `stringify_value_depth` +/// (handle band, boxed primitive, RegExp, Date, Temporal, raw JSON, buffer, +/// typed array, Symbol, Array-subclass backing, GC type). Each exclusion is +/// decided here instead: +/// +/// - buffers and small typed arrays carry no `GcHeader`, so their registries +/// rule them out BEFORE the header read, in `member_to_json`'s order; +/// - a validated, unforwarded `GC_TYPE_OBJECT` header excludes the handle +/// band and the RegExp, Date, Temporal and Symbol cells, which carry their +/// own GC types; +/// - [`class_is_plain_record`] excludes the reserved boxed-primitive and +/// raw-JSON class ids, native module namespaces and every declared class, +/// and implies no class chain can produce a `toJSON`; +/// - a null meta record means no recorded prototype (a shaped object answers +/// `object_static_prototype` from its meta record alone) and no +/// Array-subclass elements backing (`subclass_elements::elements_of`); +/// - no own key is `toJSON` or a native-forwarding marker, and +/// `Object.prototype` has no `toJSON` — the stringify-wide half, which also +/// declines while a `toJSON` result's one-shot suppression is armed. +/// +/// Never allocates, collects or calls user code (the `Object.prototype` +/// verdict is consulted only once its cache is warm), so the facts are still +/// valid when the caller starts the member's walk. Any doubt returns `None` +/// and the caller takes the general member path. +/// +/// `global_proof` is the walk's copy of the stringify-wide half, with the +/// shape template's `data_record_global_proof` contract: established here on +/// first use, and cleared by the walk before anything that can run user code. +/// Only user code can give `Object.prototype` a `toJSON`, so between those +/// points one verdict serves every member instead of revalidating the +/// prototype's signature per object. +pub(super) unsafe fn plain_object_member( + bits: u64, + global_proof: &mut bool, +) -> Option<(*const u8, PlainMember)> { + if bits & crate::value::TAG_MASK != POINTER_TAG { + return None; + } + let addr = (bits & POINTER_MASK) as usize; + if crate::buffer::is_registered_buffer(addr) + || crate::typedarray::lookup_typed_array_kind(addr).is_some() + { + return None; + } + let header = crate::value::addr_class::try_read_gc_header(addr)?; + if header.obj_type != crate::gc::GC_TYPE_OBJECT + || header.gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 + { + return None; + } + let obj = addr as *const crate::ObjectHeader; + if !(*obj).meta.is_null() || !class_is_plain_record((*obj).class_id) { + return None; + } + let (keys, live_slots) = crate::object::object_keys_and_live_slot_count(obj); + let has_index_key = member_keys_scan(keys)?; + if !*global_proof { + *global_proof = data_record_global_to_json_absent_without_gc(); + if !*global_proof { + return None; + } + } + Some(( + obj.cast(), + PlainMember { + keys, + live_slots, + has_index_key, + }, + )) +} + +/// What [`plain_object_member`] resolved about a member it admitted, for that +/// member's walk to reuse: its keys and live inline-slot bound (one shape +/// probe), and whether any key needs ECMA-262 index ordering. +#[derive(Clone, Copy)] +pub(super) struct PlainMember { + pub(super) keys: crate::object::ObjectKeys, + pub(super) live_slots: u32, + pub(super) has_index_key: bool, +} + /// Establish the stringify-wide part of the plain-data record proof. /// /// A successful data-record emission cannot invoke user code or managed @@ -746,12 +889,8 @@ pub(crate) fn set_to_json_key_str(key: &str) { /// for the element about to be serialized. #[inline] pub(crate) fn set_to_json_key_index(index: usize) { - use std::fmt::Write; - TO_JSON_KEY.with(|c| { - let mut s = c.borrow_mut(); - s.clear(); - let _ = write!(s, "{index}"); - }); + let mut digits = itoa::Buffer::new(); + set_to_json_key_str(digits.format(index)); } /// Record a NaN-boxed JS string value as the pending `toJSON` key. The diff --git a/crates/perry-runtime/src/json/stringify_tojson_probe_tests.rs b/crates/perry-runtime/src/json/stringify_tojson_probe_tests.rs index 0eab3717c1..dbd5ef2d55 100644 --- a/crates/perry-runtime/src/json/stringify_tojson_probe_tests.rs +++ b/crates/perry-runtime/src/json/stringify_tojson_probe_tests.rs @@ -565,3 +565,190 @@ fn object_literal_shapes_reach_the_flat_emitter() { assert!(super::super::stringify_record_output::try_object(bits).is_none()); } } + +// ─── #10696: the plain-member admission behind the direct object walk ──────── + +/// Parse `text` and keep the result rooted in `scope`. +unsafe fn parsed<'s>( + scope: &'s crate::gc::RuntimeHandleScope, + text: &str, +) -> crate::gc::RuntimeHandle<'s> { + let source = crate::js_string_from_bytes(text.as_ptr(), text.len() as u32); + scope.root_nanbox_u64(super::super::test_json_parse_direct(source).bits()) +} + +unsafe fn keys_of(value: &crate::gc::RuntimeHandle<'_>) -> crate::object::ObjectKeys { + let obj = (value.get_nanbox_f64().to_bits() & POINTER_MASK) as *const crate::ObjectHeader; + crate::object::object_keys(obj) +} + +#[test] +fn member_keys_scan_answers_both_questions_it_fuses() { + unsafe { + let scope = crate::gc::RuntimeHandleScope::new(); + for text in [ + r#"{"a":1}"#, + r#"{"a":1,"b":2,"c":3}"#, + r#"{"1":1,"b":2}"#, + r#"{"b":2,"10":1}"#, + r#"{"01":1,"b":2}"#, + r#"{"4294967295":1}"#, + r#"{"toJSON":1}"#, + r#"{"a":1,"toJSON":2,"3":3}"#, + r#"{"tojson":1,"_x":2,"toJSONx":3}"#, + r#"{"__module__":1}"#, + r#"{"-1":1,"1.5":2," 1":3}"#, + r#"{}"#, + ] { + let value = parsed(&scope, text); + let keys = keys_of(&value); + let expected = if keys_array_may_carry_to_json(keys) { + None + } else { + Some(crate::object::keys_contain_array_index(keys)) + }; + assert_eq!(super::member_keys_scan(keys), expected, "{text}"); + } + // A malformed keys array declines, as `keys_array_may_carry_to_json` does. + let value = parsed(&scope, r#"{"a":1,"b":2}"#); + let keys = keys_of(&value); + assert_eq!( + super::member_keys_scan(crate::object::ObjectKeys::new( + keys.arr(), + keys.count() + 64 + )), + None + ); + assert_eq!( + super::member_keys_scan(crate::object::ObjectKeys::new( + (keys.arr() as *mut u8).add(1).cast(), + keys.count() + )), + None + ); + } +} + +#[test] +fn plain_object_member_admits_only_what_the_member_dispatch_would_walk() { + unsafe { + let scope = crate::gc::RuntimeHandleScope::new(); + let plain = parsed(&scope, r#"{"a":1,"b":{"c":2}}"#); + let obj = |v: &crate::gc::RuntimeHandle<'_>| { + (v.get_nanbox_f64().to_bits() & POINTER_MASK) as *mut crate::ObjectHeader + }; + // Warm the default-prototype lookup through the rooted general probe; + // the admission declines the allocating first lookup by design. + assert!(super::to_json_definitely_absent(obj(&plain) as *const u8)); + + let mut proof = false; + let bits = plain.get_nanbox_f64().to_bits(); + let (ptr, member) = super::plain_object_member(bits, &mut proof) + .expect("a parsed record with no toJSON anywhere is a plain member"); + assert_eq!(ptr, obj(&plain) as *const u8); + assert!(proof, "the stringify-wide half is established on first use"); + assert_eq!(member.keys, crate::object::object_keys(obj(&plain))); + assert_eq!( + member.live_slots, + crate::object::object_live_slot_count(obj(&plain)) + ); + assert!(!member.has_index_key); + + let indexed = parsed(&scope, r#"{"b":1,"2":2}"#); + let (_, member) = + super::plain_object_member(indexed.get_nanbox_f64().to_bits(), &mut proof).unwrap(); + assert!(member.has_index_key); + + // An anon-shape literal qualifies; a declared class does not. + let anon = probe_test_class_id(0xB1); + crate::object::js_register_anon_shape_class_id(anon); + (*obj(&plain)).class_id = anon; + assert!(super::plain_object_member(bits, &mut proof).is_some()); + (*obj(&plain)).class_id = probe_test_class_id(0xB2); + assert!(super::plain_object_member(bits, &mut proof).is_none()); + (*obj(&plain)).class_id = 0; + + // Not objects, or objects whose own keys can carry a `toJSON`. + let array = parsed(&scope, "[1,2]"); + let with_to_json = parsed(&scope, r#"{"toJSON":1}"#); + for declined in [ + array.get_nanbox_f64().to_bits(), + with_to_json.get_nanbox_f64().to_bits(), + JSValue::number(1.0).bits(), + JSValue::try_short_string(b"ab").unwrap().bits(), + crate::value::POINTER_TAG | 0x40, + ] { + assert!(super::plain_object_member(declined, &mut proof).is_none()); + } + + // A recorded prototype lives in the meta record: declined. + let inherits = parsed(&scope, r#"{"a":1}"#); + let proto = crate::object::js_object_alloc(0, 0); + crate::object::prototype_chain::object_set_user_prototype( + obj(&inherits) as usize, + crate::value::js_nanbox_pointer(proto as i64).to_bits(), + ); + assert!( + !(*obj(&inherits)).meta.is_null(), + "fixture must record a prototype" + ); + assert!( + super::plain_object_member(inherits.get_nanbox_f64().to_bits(), &mut proof).is_none() + ); + + // The stringify-wide half is re-asked only once the walk cleared it, + // and an armed one-shot suppression then declines. + proof = false; + SUPPRESS_NEXT_TO_JSON.with(|c| c.set(true)); + assert!(super::plain_object_member(bits, &mut proof).is_none()); + assert!(!proof); + SUPPRESS_NEXT_TO_JSON.with(|c| c.set(false)); + assert!(super::plain_object_member(bits, &mut proof).is_some()); + assert!(proof); + } +} + +#[test] +fn object_keys_and_live_slot_count_is_both_probes_at_once() { + unsafe { + let scope = crate::gc::RuntimeHandleScope::new(); + for text in [ + r#"{"a":1}"#, + r#"{"a":1,"b":2,"c":3,"d":4,"e":5,"f":6,"g":7,"h":8,"i":9}"#, + "{}", + ] { + let value = parsed(&scope, text); + let obj = + (value.get_nanbox_f64().to_bits() & POINTER_MASK) as *const crate::ObjectHeader; + assert_eq!( + crate::object::object_keys_and_live_slot_count(obj), + ( + crate::object::object_keys(obj), + crate::object::object_live_slot_count(obj) + ), + "{text}" + ); + } + let empty = crate::object::js_object_alloc(0, 0); + assert_eq!( + crate::object::object_keys_and_live_slot_count(empty), + ( + crate::object::object_keys(empty), + crate::object::object_live_slot_count(empty) + ) + ); + } +} + +#[test] +fn to_json_index_key_is_the_decimal_index() { + for index in [0usize, 7, 10, 1_234_567, usize::MAX] { + super::set_to_json_key_index(index); + let key = unsafe { super::current_to_json_key_arg() }; + let mut scratch = [0; crate::value::SHORT_STRING_MAX_LEN]; + let (ptr, len) = crate::string::str_bytes_from_jsvalue(key, &mut scratch).unwrap(); + let bytes = unsafe { std::slice::from_raw_parts(ptr, len as usize) }; + assert_eq!(bytes, index.to_string().as_bytes()); + } + super::reset_to_json_key(); +} diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 9cf868796d..a246e451e1 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -1664,14 +1664,26 @@ pub struct ObjectHeader { /// that need the keys rather than the complete descriptor. #[inline] pub(crate) unsafe fn object_keys(obj: *const ObjectHeader) -> ObjectKeys { + object_keys_and_live_slot_count(obj).0 +} + +/// [`object_keys`] and [`object_live_slot_count`] together, from ONE shape +/// table probe. A walk that needs both — `JSON.stringify` visits every object +/// this way — otherwise pays the probe twice (#10696). +#[inline] +pub(crate) unsafe fn object_keys_and_live_slot_count( + obj: *const ObjectHeader, +) -> (ObjectKeys, u32) { let Some(descriptor) = shapes::object_shape_descriptor(obj) else { - return ObjectKeys::NONE; + return (ObjectKeys::NONE, 0); }; + let live_slots = descriptor.live_inline_slot_count; if descriptor.keys != 0 { - return ObjectKeys::new( + let keys = ObjectKeys::new( descriptor.keys as usize as *mut ArrayHeader, descriptor.logical_key_count, ); + return (keys, live_slots); } // The shape publishes no keys. Either the receiver genuinely has none, or // it is in DICTIONARY MODE and carries its own ordered list (#10868 step @@ -1683,7 +1695,7 @@ pub(crate) unsafe fn object_keys(obj: *const ObjectHeader) -> ObjectKeys { // line — the nonzero `keys` word returns above — so the branch costs // nothing on the path that matters. A dictionary list is the receiver's // own, so its header length is its count. - ObjectKeys::owned(dictionary::keys_array(obj)) + (ObjectKeys::owned(dictionary::keys_array(obj)), live_slots) } /// Return the two shape facts needed together by callback-free serializers. diff --git a/test-files/test_gap_json_plain_member_walk.ts b/test-files/test_gap_json_plain_member_walk.ts new file mode 100644 index 0000000000..57210d64a6 --- /dev/null +++ b/test-files/test_gap_json_plain_member_walk.ts @@ -0,0 +1,108 @@ +// #10696: an object-valued member that no `toJSON` can reach is walked +// directly, and one `Object.prototype` verdict serves every such member until +// something runs user code. Every route that runs user code mid-walk must +// still make later members see a `toJSON` installed by it, and every exotic +// member kind must keep its own serialization. +const log = (label: string, x: any) => console.log(label, JSON.stringify(x)); +const OP = Object.prototype as any; +const install = () => { + OP.toJSON = function () { return "OP"; }; +}; + +// Nested plain records: literals, parsed trees, wide and 1-field levels. +log("nest", { a: { b: { c: { d: { e: 1 } } } } }); +log("parsed", JSON.parse('{"a":{"b":{"c":1}},"d":{"e":[1,{"f":2}]}}')); +log("wide", { a: { p: 1, q: "two", r: true, s: null, t: 5.5, u: { v: 1 } }, b: 2 }); +log("siblings", { a: { x: 1 }, b: { y: 2 }, c: { z: { w: 3 } } }); +log("empty", { a: {}, b: { c: {} } }); +log("undef", { a: { b: undefined, c: 1 }, d: { e: undefined } }); +log("fn", { a: { b() { return 1; }, c: 2 }, d: { e: Symbol("s"), f: 3 } }); +log("index keys", { a: { b: 1, 2: "two", 1: "one" } }); +log("escaped keys", { a: { 'q"uote': 1, "back\\slash": 2, "nl\n": 3, "é": 4, "😀": 5 } }); +const holey: any = { p: 1, q: 2, r: 3 }; +delete holey.q; +log("holes", { a: holey, b: { c: holey } }); +log("internal-looking keys", { a: { __perry_collection_backing__: 1, b: 2 } }); +const shared = { s: 1 }; +log("shared", { a: shared, b: shared, c: [shared, { d: shared }] }); +log("root wide", { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6, g: { h: 7 } }); + +// toJSON on a member, on a grandchild, returning a plain tree, and its key. +log("member toJSON", { a: { toJSON(k: string) { return "k=" + k; } }, b: { c: 1 } }); +log("grandchild toJSON", { a: { b: { toJSON() { return [1, { c: 2 }]; } } }, d: { e: 3 } }); +log("toJSON result tree", { a: { toJSON() { return { b: { c: { toJSON() { return "inner"; } } } }; } } }); +log("toJSON undefined", { a: { toJSON() { return undefined; } }, b: { c: 1 } }); + +// Object.prototype.toJSON installed mid-walk, from each kind of callback. +log("by member toJSON", { a: { toJSON() { install(); return 1; } }, b: { c: 1 }, d: { e: { f: 1 } } }); +delete OP.toJSON; +log("by grandchild toJSON", { a: { b: { toJSON() { install(); return 1; } }, c: { d: 1 } }, e: { f: 1 } }); +delete OP.toJSON; +log("by getter", { get a() { install(); return 1; }, b: { c: 1 } }); +delete OP.toJSON; +log("by nested getter", { a: { get b() { install(); return 1; } }, c: { d: 1 } }); +delete OP.toJSON; +log("by array element", { a: [{ toJSON() { install(); return 1; } }], b: { c: 1 } }); +delete OP.toJSON; +(BigInt.prototype as any).toJSON = function () { install(); return "big"; }; +log("by BigInt toJSON", { a: { n: 1n }, b: { c: 1 } }); +delete (BigInt.prototype as any).toJSON; +delete OP.toJSON; +log("by nested stringify", { a: { toJSON() { return JSON.stringify({ x: { y: 1 } }); } }, b: { c: 1 } }); + +// Object.prototype.toJSON present for a whole call, then removed. +install(); +log("installed", { a: { b: 1 } }); +log("installed parsed", JSON.parse('{"a":{"b":1}}')); +delete OP.toJSON; +log("removed", { a: { b: 1 } }); + +// Members that inherit a toJSON, or carry one as an accessor. +const proto = { toJSON() { return "P"; } }; +const viaSet: any = { v: 1 }; +Object.setPrototypeOf(viaSet, proto); +const viaCreate = Object.create(proto); +viaCreate.v = 2; +class WithToJSON { v = 3; toJSON() { return "C"; } } +class Plain { v = 4; w = { x: 5 }; } +log("inherited", { a: viaSet, b: viaCreate, c: new WithToJSON(), d: new Plain() }); +log("accessor toJSON", { a: { get toJSON() { return () => "acc"; } }, b: { c: 1 } }); +log("null proto", { a: Object.assign(Object.create(null), { b: 1 }) }); + +// Exotic members keep their own serialization. +class MyArray extends Array {} +const sub = new MyArray(); +sub.push(1, 2); +const nonEnum: any = { visible: 1 }; +Object.defineProperty(nonEnum, "hidden", { value: 2, enumerable: false }); +log("exotic", { + date: new Date(0), + map: new Map([[1, 2]]), + set: new Set([1]), + re: /x/g, + num: new Number(3), + str: new String("s"), + bool: new Boolean(false), + u8: new Uint8Array([1, 2]), + sub, + err: Object.assign(new Error("e"), { code: 7 }), + url: new URL("https://example.com/p?q=1"), + nonEnum, + nested: { date: new Date(0), deeper: { num: new Number(4) } }, +}); + +// Deep plain chains, and a cycle through them past the fast depth. +const head: any = {}; +let cur = head; +for (let i = 0; i < 200; i++) { + cur.next = { i }; + cur = cur.next; +} +console.log("deep ok", JSON.stringify(head).length); +cur.next = head; +try { + JSON.stringify(head); + console.log("cycle: no throw"); +} catch (e) { + console.log("cycle:", (e as Error).constructor.name); +} From 397f7404ab056191d8b9a3210c8d6351bd9aa7c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:20:25 +0000 Subject: [PATCH 2/2] changelog: key the JSON plain-member walk fragment to PR 11534 --- ...-json-plain-member-walk.md => 11534-json-plain-member-walk.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{11531-json-plain-member-walk.md => 11534-json-plain-member-walk.md} (100%) diff --git a/changelog.d/11531-json-plain-member-walk.md b/changelog.d/11534-json-plain-member-walk.md similarity index 100% rename from changelog.d/11531-json-plain-member-walk.md rename to changelog.d/11534-json-plain-member-walk.md