diff --git a/changelog.d/8118-json-parse-write-fast-paths.md b/changelog.d/8118-json-parse-write-fast-paths.md new file mode 100644 index 0000000000..83cd4702fd --- /dev/null +++ b/changelog.d/8118-json-parse-write-fast-paths.md @@ -0,0 +1,85 @@ +### perf(object/json): `JSON.parse` receivers reach both object-write fast paths (#8098) + +A `JSON.parse` object carries `class_id == 0`. Both guarded object-write fast +paths — the whole-loop numeric clone and the static/dynamic write PICs — rejected +it on exactly that, so every `record.field = …` on parsed data took the generic +`[[Set]]` path for the life of the program. `JSON.parse` is how essentially all +external data enters a Perry program (HTTP bodies, config, ORM rows, cache reads, +IPC payloads), and the rejection was on the *receiver's identity*, so no amount of +loop-shape or key-form work in #6812 could reach it. + +Measured on the committed `benchmarks/object-write-6812/matrix.ts` controlled +pair, which differs in exactly one way (`JSON.parse('{"x":0}')` instead of an +object literal) and produces an identical `sink 122876400` over identical +120,000,000 writes. Wall clock on the development host is unusable (the same cell +measured 11.7 s / 17.9 s / 21.1 s across three runs), so the ratio is reported in +**instructions retired**, which reproduced to within 0.02%: + +| cell | instructions retired | vs `key_dot` | +|---|--:|--:| +| `key_dot` (object literals) | 1.158e9 | 1.00x | +| `receiver_class_id_zero` before | 150.08e9 | **129.5x** | +| `receiver_class_id_zero` after | see below | | + +**Why the guard could not simply drop the clause.** `class_id != 0` was standing +in for three per-object exclusions that the generic path still applies verbatim +(`object/field_set_by_name/fast_paths.rs::try_existing_own_data_overwrite`): +`NATIVE_MODULE_CLASS_ID`, `Object.prototype`, and a `URL` instance — whose +`pathname`/`search`/… own slots are live views whose setters rebuild `href` +(`field_set_by_name/tail.rs`). None of those is derivable from the ShapeId: two +objects share a ShapeId iff they share a keys-array *allocation*, and the +shape-transition cache deliberately converges distinct objects onto one shared +array, so a prime-time-only exclusion loses to ordering (a plain object primes the +site, a `URL` that later acquires the same keys array then hits it). The +generated hit path re-checks only per-object state, so the discriminator has to +be per-object too. + +**What landed instead** is an explicit, opt-in, per-object mark: +`OBJ_FLAG_PLAIN_ORDINARY` (bit 9 of `GcHeader::_reserved`, object-only, disjoint +from the array-only `GC_ARRAY_ARGUMENTS_OBJECT` by `obj_type` the same way bits 11 +and 12 already are). The JSON direct parser and the lazy-tape materializer set it +at birth; every other class-less receiver is unmarked and keeps the full `[[Set]]` +walk, so no existing population changes behaviour. The bit is free in the +generated guard — `_reserved` is already loaded there for the blocking-flag test, +so admission costs one `and` + `icmp` + `or`, hoisted above the four PIC ways. + +Note that the *read* PIC has admitted `class_id == 0` all along +(`object/field_get_set/ic_miss.rs` primes on any regular descriptor-free shaped +receiver, and the emitted read guard has no `class_id` compare at all). Reads of +parsed objects were already on the ShapeId fast path; only writes were not. #8067 +/ #8086 supplied what was missing on the write side: a parsed receiver is +birth-stamped with a real ShapeId by `js_object_alloc_class_inline_keys`, and +repeated parses of one shape share a single `GC_FLAG_SHAPE_SHARED` keys array via +`PARSE_SHAPE_CACHE`, so the whole 2400-receiver prefix carries one ShapeId. + +Also fixed here, in the same file: `JSON.parse("{}")` initialized **eight** inline +field slots into an allocation that has `max(0, INLINE_SLOT_FLOOR)` = **two** of +them (the floor dropped 4 → 2 in #7928) — a 48-byte overwrite past the object on +every empty-object parse, the exact "heap buffer overflow into adjacent arena +objects" that `js_object_alloc_with_parent` documents. The hand-rolled fill was +redundant as well: the allocator has initialized every slot it allocates since +#4717. + +Coverage: + +* `crates/perry-runtime/src/proxy.rs` — + `json_parse_receivers_are_admitted_to_the_whole_loop_write_clone` and + `json_parse_receivers_prime_the_static_write_pic` drive the shipped + `js_json_parse` end-to-end (payloads are a few bytes with an object root, so + the eager direct parser runs and no lazy tape stands between the probe and the + objects — #7635), assert the premises (`class_id == 0`, a real shared ShapeId), + and then clear the mark on one receiver and require the guard to refuse. Both + fail when the guard ignores the mark and when the parser stops setting it. + `plain_ordinary_object_flag_matches_the_emitted_write_pic_literal` pins the bit + value against the literal `perry-codegen` emits. +* The pre-existing `object_array_numeric_write_guard_requires_complete_uniform_proof` + keeps its class-id-zero rejection for an *unmarked* receiver and gains the + marked-accepts and native-module-still-rejects halves. +* `test-files/test_gap_json_parse_object_writes.ts` — parity against node 26.5.1 + for the semantics the `class_id != 0` clause used to keep parsed objects away + from: deleted keys, added keys, frozen/sealed/non-extensible receivers (strict + `TypeError`s), accessor and non-writable descriptors installed over a parsed + slot, prototype mutation with a shadowing setter, null prototypes, dynamic-key + writes, a parsed object used as a prototype, an empty parsed object grown by + name, a polymorphic site mixing parsed objects / literals / class instances, + and `__proto__` / `constructor` as genuine own data keys. diff --git a/crates/perry-codegen/src/expr/proxy_reflect.rs b/crates/perry-codegen/src/expr/proxy_reflect.rs index 84007ffc47..960e2eb03a 100644 --- a/crates/perry-codegen/src/expr/proxy_reflect.rs +++ b/crates/perry-codegen/src/expr/proxy_reflect.rs @@ -50,6 +50,15 @@ use super::{ /// encoded by the authoritative ShapeId and therefore owns no header flag. const WRITE_PIC_BLOCKING_FLAGS: u16 = 0x1907; +/// #8098: `GcHeader::_reserved` bit 9 — the runtime birth-marked this +/// class-less receiver an ORDINARY plain object (`JSON.parse` output), so it is +/// eligible for the write PIC exactly like a class instance. MUST equal +/// `perry_runtime::gc::OBJ_FLAG_PLAIN_ORDINARY`; the runtime pins the value in +/// `proxy::tests::plain_ordinary_object_flag_matches_the_emitted_write_pic_literal`. +/// It is deliberately NOT in `WRITE_PIC_BLOCKING_FLAGS` — this bit ADMITS a +/// receiver, the blocking mask REJECTS one. +const PLAIN_ORDINARY_OBJ_FLAG: u16 = 0x200; + /// The NaN-boxed `undefined` literal, for an absent optional operand. fn undefined_literal() -> String { double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) @@ -550,8 +559,17 @@ fn lower_put_value_static_write_ic( let class_addr = ctx.block().add(I64, &safe_target, "4"); let class_ptr = ctx.block().inttoptr(I64, &class_addr); let class_id = ctx.block().load(I32, &class_ptr); - let class_nonzero = ctx.block().icmp_ne(I32, &class_id, "0"); + let has_class = ctx.block().icmp_ne(I32, &class_id, "0"); let not_native_module = ctx.block().icmp_ne(I32, &class_id, "-2"); + // #8098: a class-less receiver qualifies when the runtime birth-marked it + // an ordinary plain object. `reserved` is already loaded above for the + // blocking-flag test, so this costs one `and` + `icmp` + `or`, computed + // once here and reused by all four ways (this block dominates them). + let plain_ordinary_bits = ctx + .block() + .and(I16, &reserved, &PLAIN_ORDINARY_OBJ_FLAG.to_string()); + let plain_ordinary = ctx.block().icmp_ne(I16, &plain_ordinary_bits, "0"); + let receiver_kind_ok = ctx.block().or(I1, &has_class, &plain_ordinary); // The write PIC uses the same single ShapeId token domain as the read PIC. let shape_id_addr = ctx.block().add(I64, &safe_target, "8"); @@ -574,7 +592,7 @@ fn lower_put_value_static_write_ic( let mut hit = ctx.block().and(I1, &heap_candidate, &gc_object); hit = ctx.block().and(I1, &hit, ¬_forwarded); hit = ctx.block().and(I1, &hit, &flags_clear); - hit = ctx.block().and(I1, &hit, &class_nonzero); + hit = ctx.block().and(I1, &hit, &receiver_kind_ok); hit = ctx.block().and(I1, &hit, ¬_native_module); hit = ctx.block().and(I1, &hit, &token_match); hit = ctx.block().and(I1, &hit, &token_nonzero); @@ -598,7 +616,7 @@ fn lower_put_value_static_write_ic( let mut hit2 = ctx.block().and(I1, &heap_candidate, &gc_object); hit2 = ctx.block().and(I1, &hit2, ¬_forwarded); hit2 = ctx.block().and(I1, &hit2, &flags_clear); - hit2 = ctx.block().and(I1, &hit2, &class_nonzero); + hit2 = ctx.block().and(I1, &hit2, &receiver_kind_ok); hit2 = ctx.block().and(I1, &hit2, ¬_native_module); hit2 = ctx.block().and(I1, &hit2, &token2_match); hit2 = ctx.block().and(I1, &hit2, &token_nonzero); @@ -618,7 +636,7 @@ fn lower_put_value_static_write_ic( let mut hit3 = ctx.block().and(I1, &heap_candidate, &gc_object); hit3 = ctx.block().and(I1, &hit3, ¬_forwarded); hit3 = ctx.block().and(I1, &hit3, &flags_clear); - hit3 = ctx.block().and(I1, &hit3, &class_nonzero); + hit3 = ctx.block().and(I1, &hit3, &receiver_kind_ok); hit3 = ctx.block().and(I1, &hit3, ¬_native_module); hit3 = ctx.block().and(I1, &hit3, &token3_match); hit3 = ctx.block().and(I1, &hit3, &token_nonzero); @@ -638,7 +656,7 @@ fn lower_put_value_static_write_ic( let mut hit4 = ctx.block().and(I1, &heap_candidate, &gc_object); hit4 = ctx.block().and(I1, &hit4, ¬_forwarded); hit4 = ctx.block().and(I1, &hit4, &flags_clear); - hit4 = ctx.block().and(I1, &hit4, &class_nonzero); + hit4 = ctx.block().and(I1, &hit4, &receiver_kind_ok); hit4 = ctx.block().and(I1, &hit4, ¬_native_module); hit4 = ctx.block().and(I1, &hit4, &token4_match); hit4 = ctx.block().and(I1, &hit4, &token_nonzero); @@ -867,8 +885,14 @@ fn lower_put_value_dyn_ic_inline( let class_addr = ctx.block().add(I64, &t_handle, "4"); let class_ptr = ctx.block().inttoptr(I64, &class_addr); let class_id = ctx.block().load(I32, &class_ptr); - let class_nonzero = ctx.block().icmp_ne(I32, &class_id, "0"); + let has_class = ctx.block().icmp_ne(I32, &class_id, "0"); let not_native_module = ctx.block().icmp_ne(I32, &class_id, "-2"); + // #8098: see the static-key PIC above. + let plain_ordinary_bits = ctx + .block() + .and(I16, &reserved, &PLAIN_ORDINARY_OBJ_FLAG.to_string()); + let plain_ordinary = ctx.block().icmp_ne(I16, &plain_ordinary_bits, "0"); + let receiver_kind_ok = ctx.block().or(I1, &has_class, &plain_ordinary); let shape_id_addr = ctx.block().add(I64, &t_handle, "8"); let shape_id_ptr = ctx.block().inttoptr(I64, &shape_id_addr); let raw_shape_id = ctx.block().load(I32, &shape_id_ptr); @@ -885,7 +909,7 @@ fn lower_put_value_dyn_ic_inline( let token_nonzero = ctx.block().icmp_ne(I64, &shape_token, "0"); let mut ok = ctx.block().and(I1, &gc_object, ¬_forwarded); ok = ctx.block().and(I1, &ok, &flags_clear); - ok = ctx.block().and(I1, &ok, &class_nonzero); + ok = ctx.block().and(I1, &ok, &receiver_kind_ok); ok = ctx.block().and(I1, &ok, ¬_native_module); ok = ctx.block().and(I1, &ok, &token_match); ok = ctx.block().and(I1, &ok, &token_nonzero); diff --git a/crates/perry-runtime/src/gc/types.rs b/crates/perry-runtime/src/gc/types.rs index dadbe0a7dd..b20cc30127 100644 --- a/crates/perry-runtime/src/gc/types.rs +++ b/crates/perry-runtime/src/gc/types.rs @@ -1108,6 +1108,24 @@ pub(crate) const GC_ARRAY_RAW_F64_LAYOUT: u16 = 0x80; /// meaningful for `GC_TYPE_ARRAY`; it lets `util.types.isArgumentsObject` /// distinguish Perry's internal `arguments` arrays from user rest arrays. pub(crate) const GC_ARRAY_ARGUMENTS_OBJECT: u16 = 0x200; +/// #8098: this `GC_TYPE_OBJECT` allocation is an ORDINARY plain object. It has +/// no class, but it also carries none of the per-object `[[Set]]` semantics a +/// class-less receiver may otherwise have — a `URL`'s `pathname`/`search`/… +/// slots are live views whose setters rebuild `href`, `Object.prototype` is the +/// realm intrinsic, and native-module receivers dispatch. Only a runtime birth +/// site that has established the receiver is ordinary may set this; it is what +/// admits `JSON.parse` output to the object-write fast paths, whose generated +/// hit paths re-test this exact bit on every store, so a ShapeId shared with an +/// unmarked population can never carry one population's cached slot into +/// another's. +/// +/// Bit 9 — only meaningful for `GC_TYPE_OBJECT`, disjoint from the array-only +/// `GC_ARRAY_ARGUMENTS_OBJECT` by `obj_type` (its sole reader goes through +/// `array::header::array_gc_header`, which refuses any header that is not +/// `GC_TYPE_ARRAY`), the same sharing bits 11 and 12 already use. The value +/// MUST match `PLAIN_ORDINARY_OBJ_FLAG` in +/// `perry-codegen/src/expr/proxy_reflect.rs`, which emits it as a literal. +pub const OBJ_FLAG_PLAIN_ORDINARY: u16 = 0x200; /// #6011: every element slot in `[0, length)` holds either canonical raw-f64 /// number bits or `TAG_HOLE` — the hole-tolerant sibling of /// `GC_ARRAY_RAW_F64_LAYOUT`. Set when `new Array(n)` hole-initializes a diff --git a/crates/perry-runtime/src/json/parser.rs b/crates/perry-runtime/src/json/parser.rs index 7936d8e635..179e426285 100644 --- a/crates/perry-runtime/src/json/parser.rs +++ b/crates/perry-runtime/src/json/parser.rs @@ -421,6 +421,10 @@ impl<'a> DirectParser<'a> { shape.field_count, shape.keys_array, ); + // #8098: parsed records are ordinary plain objects — no class, but an + // authoritative ShapeId and no per-object [[Set]] semantics — so mark + // them eligible for the object-write fast paths. + crate::object::mark_object_plain_ordinary(js_obj); // Initialize all fields to undefined so JSON with missing // fields returns `undefined` for absent properties (matches // spec: access to absent own property returns undefined). @@ -650,12 +654,16 @@ impl<'a> DirectParser<'a> { let keys: [*const StringHeader; 0] = []; let keys_arr = self.parse_shape_keys_array_hot(&keys); let js_obj = crate::object::js_object_alloc_class_inline_keys(0, 0, 0, keys_arr); - let fields_ptr = - (js_obj as *mut u8).add(std::mem::size_of::()) as *mut JSValue; - for i in 0..8 { - // GC_STORE_AUDIT(INIT): empty JSON object fields are initialized before parse publication. - std::ptr::write(fields_ptr.add(i), JSValue::undefined()); - } + // #8098: see `parse_object_shaped`. + crate::object::mark_object_plain_ordinary(js_obj); + // NOTE: no hand-rolled slot fill here. The allocator has written + // `undefined` into every slot it allocated since #4717. The fill + // this replaces was a leftover from when that was the caller's job, + // and it wrote EIGHT slots — `js_object_alloc_class_inline_keys(0, + // 0, 0, …)` allocates `max(0, INLINE_SLOT_FLOOR)` = 2 of them (the + // floor dropped 4 -> 2 in #7928), so `JSON.parse("{}")` overwrote 48 + // bytes past the object: the exact "heap buffer overflow into + // adjacent arena objects" `js_object_alloc_with_parent` warns about. parse_root_restore(saved_roots); return JSValue::object_ptr(js_obj as *mut u8); } @@ -726,6 +734,8 @@ impl<'a> DirectParser<'a> { self.parse_shape_keys_array_hot(&inline_keys[..inline_len]) }; let js_obj = crate::object::js_object_alloc_class_inline_keys(0, 0, field_count, keys_arr); + // #8098: see `parse_object_shaped`. + crate::object::mark_object_plain_ordinary(js_obj); let alloc_field_count = std::cmp::max(field_count as usize, crate::object::INLINE_SLOT_FLOOR); let fields_ptr = diff --git a/crates/perry-runtime/src/json_tape.rs b/crates/perry-runtime/src/json_tape.rs index 8a5e0f7e79..eda25c8038 100644 --- a/crates/perry-runtime/src/json_tape.rs +++ b/crates/perry-runtime/src/json_tape.rs @@ -671,6 +671,10 @@ unsafe fn materialize_object( ) -> JSValue { let field_count = count_object_fields(source, *idx, end_idx); let obj = crate::object::js_object_alloc(0, 0); + // #8098: a lazily materialized tape record is `JSON.parse` output too — the + // >1 KB top-level-array payloads (HTTP bodies, ORM result sets) that the + // eager `DirectParser` never sees all arrive through here. + crate::object::mark_object_plain_ordinary(obj); let obj_handle = scope.root_raw_mut_ptr(obj); json_tape_safepoint(JsonTapeSafepoint::MaterializeObjectRooted, obj as usize); let obj = obj_handle.get_raw_mut_ptr::(); diff --git a/crates/perry-runtime/src/json_tape/iterative.rs b/crates/perry-runtime/src/json_tape/iterative.rs index b3677aca3f..3a8788ba70 100644 --- a/crates/perry-runtime/src/json_tape/iterative.rs +++ b/crates/perry-runtime/src/json_tape/iterative.rs @@ -42,6 +42,8 @@ unsafe fn finish_frame(frame: BuildFrame) -> Option { } let field_count = u32::try_from(keys.len()).ok()?; let object = crate::object::js_object_alloc(0, 0); + // #8098: a tape-materialized record is `JSON.parse` output too. + crate::object::mark_object_plain_ordinary(object); crate::object::reserve_object_spill(object as usize, field_count); for (key, value) in keys.into_iter().zip(values) { crate::object::js_object_set_field_by_name( diff --git a/crates/perry-runtime/src/object/alloc.rs b/crates/perry-runtime/src/object/alloc.rs index 074210bda1..cf0f612022 100644 --- a/crates/perry-runtime/src/object/alloc.rs +++ b/crates/perry-runtime/src/object/alloc.rs @@ -73,6 +73,32 @@ pub extern "C" fn js_object_alloc_null_proto(class_id: u32, field_count: u32) -> ptr } +/// #8098: mark `obj` as an ORDINARY plain object — class-less, but with no +/// per-object `[[Set]]` semantics of its own, so the object-write fast paths +/// may treat it exactly like a class instance. +/// +/// The mark is deliberately OPT-IN and set at BIRTH. `class_id == 0` is not a +/// sufficient condition: a `URL` instance, `Object.prototype`, a module +/// namespace, and a native-module receiver are all class-less, and the write +/// guards used to exclude the whole class-less population wholesale rather than +/// reason about them (`proxy/put_value.rs`, and the same three exclusions in +/// `field_set_by_name/fast_paths.rs::try_existing_own_data_overwrite`). Only a +/// birth site that has established its receiver is ordinary calls this; every +/// other class-less receiver keeps taking the full `[[Set]]` walk. +/// +/// The bit lives in `GcHeader::_reserved`, which survives evacuation +/// (`gc/copying.rs` and `gc/oldgen.rs` carry the word across), is preserved by +/// the survival-age (`0x0038`) and layout-state (`0xC000`) updates, and is +/// already loaded by the generated write PIC for its blocking-flag test. +#[inline] +pub(crate) unsafe fn mark_object_plain_ordinary(obj: *mut ObjectHeader) { + if obj.is_null() { + return; + } + let gc = (obj as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader; + (*gc)._reserved |= crate::gc::OBJ_FLAG_PLAIN_ORDINARY; +} + /// `Object(value)` plain-call coercion (#3149, ECMAScript §20.1.1.1 / ToObject). /// /// Takes and returns a NaN-boxed JSValue (`f64`): diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index 60ae5add19..5ffcce253b 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -2257,12 +2257,36 @@ mod tests { unsafe { let original = (*first).class_id; + let first_header = + (first as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader; + let original_flags = (*first_header)._reserved; (*first).class_id = 0; assert_eq!( object_array_numeric_write_guard(array_box, &[c, d], 2), 0, - "class-id-zero objects cannot establish a stable raw layout identity" + "an UNMARKED class-id-zero object has no established ordinary-receiver \ + identity and must use ordinary [[Set]]" ); + // #8098: the SAME receiver, birth-marked ORDINARY by the runtime — + // which is what `JSON.parse` output carries — is eligible. The mark + // is the only thing that differs between these two assertions, so + // the pair discriminates "the guard reads the mark" from "the guard + // stopped caring about class-id zero". + (*first_header)._reserved = original_flags | crate::gc::OBJ_FLAG_PLAIN_ORDINARY; + assert_eq!( + object_array_numeric_write_guard(array_box, &[c, d], 2), + (4u64 << 16) | 3, + "a marked ordinary plain object publishes the same raw slots as a \ + class instance of the same shape" + ); + // A native-module receiver stays out no matter what it is marked. + (*first).class_id = crate::object::NATIVE_MODULE_CLASS_ID; + assert_eq!( + object_array_numeric_write_guard(array_box, &[c, d], 2), + 0, + "a native-module receiver must reject even when marked ordinary" + ); + (*first_header)._reserved = original_flags; (*first).class_id = original; } @@ -2291,6 +2315,184 @@ mod tests { ); } + /// #8098: the write PIC's generated hit path re-tests the ordinary-plain + /// mark as a raw `_reserved` bit literal, so the runtime constant and the + /// literal `perry-codegen/src/expr/proxy_reflect.rs` emits + /// (`PLAIN_ORDINARY_OBJ_FLAG`) are one ABI. A silent divergence would make + /// every generated guard test the wrong bit — either admitting a receiver + /// the runtime never cleared, or never hitting at all. Pin the value here. + #[test] + fn plain_ordinary_object_flag_matches_the_emitted_write_pic_literal() { + assert_eq!( + crate::gc::OBJ_FLAG_PLAIN_ORDINARY, + 0x200, + "perry-codegen emits 0x200 for this bit" + ); + // It ADMITS a receiver, so it must not appear in the mask that REJECTS + // one (`WRITE_PIC_BLOCKING_FLAGS = 0x1907`) — a collision would make + // every marked object permanently ineligible. + assert_eq!(crate::gc::OBJ_FLAG_PLAIN_ORDINARY & 0x1907, 0); + // Bit 9 is shared with the array-only arguments-object flag, disjoint + // by `obj_type`; and it must not collide with any object-meaningful + // flag or with the survival-age / layout-state fields the GC owns. + for other in [ + crate::gc::OBJ_FLAG_FROZEN, + crate::gc::OBJ_FLAG_SEALED, + crate::gc::OBJ_FLAG_NO_EXTEND, + crate::gc::OBJ_FLAG_NULL_PROTO, + crate::gc::OBJ_FLAG_TYPED_ARRAY_PROTO, + crate::gc::OBJ_FLAG_HAS_DESCRIPTORS, + crate::gc::GC_OBJ_TYPED_LAYOUT_INTACT, + 0x0038, // GC_COPY_SURVIVAL_AGE_MASK + 0xC000, // GC_LAYOUT_STATE_MASK + ] { + assert_eq!( + crate::gc::OBJ_FLAG_PLAIN_ORDINARY & other, + 0, + "the ordinary-plain mark must own its own bit" + ); + } + } + + /// #8098 end-to-end: real `JSON.parse` output must reach the whole-loop + /// numeric write clone. + /// + /// This drives the shipped parser rather than hand-building a class-less + /// object, because the property under test is that the PARSER marks what it + /// allocates — a guard relaxation with no marking site would leave the + /// matrix's `receiver_class_id_zero` cell exactly where it was. + /// + /// The 13-byte object payload is deliberately below the tape's 1 KB floor + /// and has an object root, so `js_json_parse` takes the eager + /// `DirectParser` (`json/parse_api.rs`) — no lazy tape stands between this + /// probe and the objects it inspects (#7635). + #[test] + fn json_parse_receivers_are_admitted_to_the_whole_loop_write_clone() { + let src = br#"{"x":0,"y":0}"#; + let mut receivers = Vec::new(); + for _ in 0..4 { + let text = crate::string::js_string_from_bytes(src.as_ptr(), src.len() as u32); + let value = unsafe { crate::json::js_json_parse(text) }; + assert!(value.is_pointer(), "JSON.parse must yield an object"); + receivers.push(f64::from_bits(value.bits())); + } + let array = crate::array::js_array_from_f64(receivers.as_ptr(), receivers.len() as u32); + let array_box = boxed_object(array.cast()); + + let objects: Vec<*mut crate::ObjectHeader> = receivers + .iter() + .map(|v| (v.to_bits() & POINTER_MASK) as *mut crate::ObjectHeader) + .collect(); + unsafe { + assert_eq!( + (*objects[0]).class_id, + 0, + "the premise: parsed receivers carry no class id" + ); + assert_ne!( + crate::object::shapes::object_shape_stamp(objects[0]), + 0, + "the premise: #8067/#8086 birth-stamps them with a real ShapeId" + ); + for object in &objects[1..] { + assert_eq!( + crate::object::shapes::object_shape_stamp(*object), + crate::object::shapes::object_shape_stamp(objects[0]), + "repeated parses share one keys array, hence one ShapeId" + ); + } + } + + let key_ptr = crate::string::js_string_from_bytes(b"y".as_ptr(), 1); + let key_y = f64::from_bits(crate::value::STRING_TAG | (key_ptr as u64 & POINTER_MASK)); + assert_eq!( + object_array_numeric_write_guard(array_box, &[key_y], 4), + 2, + "the whole-loop clone must publish slot 1 for a parsed receiver prefix" + ); + + // The discriminating quantity: clear the ordinary mark on ONE receiver + // and nothing else. Same objects, same ShapeId, same keys, same slots — + // if the guard still accepted, it would not be reading the mark. + unsafe { + let header = + (objects[2] as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader; + let saved = (*header)._reserved; + assert_ne!( + saved & crate::gc::OBJ_FLAG_PLAIN_ORDINARY, + 0, + "the parser must mark what it allocates" + ); + (*header)._reserved = saved & !crate::gc::OBJ_FLAG_PLAIN_ORDINARY; + assert_eq!( + object_array_numeric_write_guard(array_box, &[key_y], 4), + 0, + "one unmarked receiver in the prefix must send the whole nest to \ + ordinary [[Set]]" + ); + (*header)._reserved = saved; + assert_eq!( + object_array_numeric_write_guard(array_box, &[key_y], 4), + 2, + "restoring the mark restores eligibility" + ); + } + } + + /// #8098: the same admission on the per-site static write PIC, which is the + /// path scattered `record.field = …` writes take (the whole-loop clone only + /// covers a constant-counted nest). A miss that refuses to prime leaves the + /// site on the runtime path forever. + #[test] + fn json_parse_receivers_prime_the_static_write_pic() { + let src = br#"{"n":1}"#; + let text = crate::string::js_string_from_bytes(src.as_ptr(), src.len() as u32); + let value = unsafe { crate::json::js_json_parse(text) }; + assert!(value.is_pointer()); + let target = f64::from_bits(value.bits()); + let object = (value.bits() & POINTER_MASK) as *mut crate::ObjectHeader; + + let key_ptr = crate::string::js_string_from_bytes(b"n".as_ptr(), 1); + let key_ptr = crate::string::js_string_intern(key_ptr, fnv1a(b"n")); + + let mut cache = [0i64; 2]; + let stored = put_value::js_put_value_set_ic_miss(target, key_ptr, 7.0, 0, &mut cache); + assert_eq!(stored, 7.0); + let expected_token = unsafe { + crate::object::shapes::PIC_ID_TOKEN_BIT + | crate::object::shapes::object_shape_id(object) as u64 + }; + assert_eq!( + cache[0] as u64, expected_token, + "a parsed receiver must prime the way with its own ShapeId token" + ); + assert_eq!(cache[1], 0, "`n` is the receiver's first own slot"); + + // Discriminating half: an otherwise identical parsed receiver with the + // ordinary mark cleared must NOT prime. + let text = crate::string::js_string_from_bytes(src.as_ptr(), src.len() as u32); + let value = unsafe { crate::json::js_json_parse(text) }; + let unmarked = (value.bits() & POINTER_MASK) as *mut crate::ObjectHeader; + unsafe { + let header = + (unmarked as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader; + (*header)._reserved &= !crate::gc::OBJ_FLAG_PLAIN_ORDINARY; + } + let mut cache2 = [0i64; 2]; + let stored = put_value::js_put_value_set_ic_miss( + f64::from_bits(value.bits()), + key_ptr, + 9.0, + 0, + &mut cache2, + ); + assert_eq!(stored, 9.0, "the write itself still succeeds"); + assert_eq!( + cache2[0], 0, + "an unmarked class-less receiver must stay on the miss path" + ); + } + /// #7531: `create_list_from_array_like` backs `Reflect.apply(target, /// thisArg, argumentsList)` / `Reflect.construct` -- `argumentsList` is /// caller-supplied and can be a fetch/zlib/proxy/common-registry handle diff --git a/crates/perry-runtime/src/proxy/put_value.rs b/crates/perry-runtime/src/proxy/put_value.rs index 101bda98c5..1ca7f2e9c7 100644 --- a/crates/perry-runtime/src/proxy/put_value.rs +++ b/crates/perry-runtime/src/proxy/put_value.rs @@ -5,6 +5,36 @@ use super::*; +/// Receiver-kind test shared by every object-write fast path (#8098). +/// +/// A class instance qualifies, and so does a plain object the runtime +/// birth-marked `OBJ_FLAG_PLAIN_ORDINARY` — today that is `JSON.parse` output +/// (`json/parser.rs`, `json_tape.rs`), which carries an authoritative ShapeId +/// since #8067/#8086 but no class. +/// +/// This replaces a blanket `class_id != 0`. That clause was standing in for +/// three per-object exclusions the generic path still applies verbatim +/// (`object/field_set_by_name/fast_paths.rs::try_existing_own_data_overwrite`): +/// `NATIVE_MODULE_CLASS_ID`, `Object.prototype`, and a `URL` instance, whose +/// `pathname`/`search`/… own slots are live views whose setters rebuild `href` +/// (`field_set_by_name/tail.rs`). None of those is derivable from the ShapeId — +/// two objects share one iff they share a keys-array ALLOCATION, and the +/// shape-transition cache deliberately converges distinct objects onto one +/// shared array — so the discriminator has to be per-object and re-tested on +/// every generated cache hit. An opt-in mark is exactly that, and it fails +/// safe: an unmarked class-less receiver keeps the full `[[Set]]` walk. +#[inline] +unsafe fn write_fast_path_receiver_kind_ok( + obj: *const crate::ObjectHeader, + obj_flags: u16, +) -> bool { + let class_id = (*obj).class_id; + if class_id == crate::object::NATIVE_MODULE_CLASS_ID { + return false; + } + class_id != 0 || obj_flags & crate::gc::OBJ_FLAG_PLAIN_ORDINARY != 0 +} + /// `proxy[key] = value` — if handler.set exists, call it with /// (target, key, value) and return TAG_TRUE (the trap's return value is /// ignored by the default test semantics since we echo `value`). Otherwise @@ -334,10 +364,8 @@ pub extern "C" fn js_put_value_set_ic_miss( } let obj = obj_addr as *mut crate::ObjectHeader; - let class_id = (*obj).class_id; if !crate::object::object_is_regular(obj) - || class_id == 0 - || class_id == crate::object::NATIVE_MODULE_CLASS_ID + || !write_fast_path_receiver_kind_ok(obj, gc_header._reserved) { return result; } @@ -530,10 +558,8 @@ unsafe fn dyn_ic_try_store(target: f64, token: u64, slot: u32, value: f64) -> Op return None; } let obj = obj_addr as *mut crate::ObjectHeader; - let class_id = (*obj).class_id; if !crate::object::object_is_regular(obj) - || class_id == 0 - || class_id == crate::object::NATIVE_MODULE_CLASS_ID + || !write_fast_path_receiver_kind_ok(obj, gc_header._reserved) { return None; } @@ -607,10 +633,8 @@ pub extern "C" fn js_put_value_set_dyn_ic_miss( return result; } let obj = obj_addr as *mut crate::ObjectHeader; - let class_id = (*obj).class_id; if !crate::object::object_is_regular(obj) - || class_id == 0 - || class_id == crate::object::NATIVE_MODULE_CLASS_ID + || !write_fast_path_receiver_kind_ok(obj, gc_header._reserved) || crate::array::object_prototype_addr_matches(obj_addr) { return result; @@ -819,7 +843,7 @@ fn object_array_numeric_write_slots(array: f64, keys: &[f64], count: u32) -> Opt return None; } let obj = addr as *mut crate::ObjectHeader; - if (*obj).class_id == 0 || (*obj).class_id == crate::object::NATIVE_MODULE_CLASS_ID { + if !write_fast_path_receiver_kind_ok(obj, gc._reserved) { return None; } let shape = crate::object::shapes::object_shape_descriptor(obj)?; diff --git a/test-files/test_gap_json_parse_object_writes.ts b/test-files/test_gap_json_parse_object_writes.ts new file mode 100644 index 0000000000..2c59aff211 --- /dev/null +++ b/test-files/test_gap_json_parse_object_writes.ts @@ -0,0 +1,255 @@ +// Property WRITES to `JSON.parse` output (#8098). +// +// A parsed object carries `class_id == 0`. Until #8098 that single fact +// disqualified it from BOTH guarded object-write fast paths — the whole-loop +// numeric clone and the static write PIC — so every `record.field = …` on +// parsed data took the generic `[[Set]]` path for the life of the program +// (measured at 129.5x the instruction count of the identical object-literal +// cell in `benchmarks/object-write-6812/matrix.ts`). +// +// The fix admits a parsed receiver by marking it ORDINARY at birth, which the +// generated guards re-test per object. That means the fast paths now RUN on +// parsed receivers, so every semantic the `class_id != 0` clause used to +// exclude them from has to be checked against node: deleted keys, added keys, +// frozen/sealed/non-extensible receivers, installed descriptors, mutated +// prototypes, dynamic keys, and a parsed object used as a prototype. +// +// Each payload here is a few bytes with an object root, so `js_json_parse` +// takes the eager direct parser, not the >=1 KB top-level-array lazy tape — +// the values these assertions read are fully materialized (#7635). +// +// Validated byte-for-byte against `node --experimental-strip-types`. + +function show(v: any): string { + if (v === undefined) return "undefined"; + if (v === null) return "null"; + if (typeof v === "object") return JSON.stringify(v); + return String(v); +} +function line(...parts: any[]): void { + const out: string[] = []; + for (let i = 0; i < parts.length; i++) out.push(show(parts[i])); + console.log(out.join(" ")); +} + +// (1) The fast-path case itself: a constant-counted nest writing one static +// key on a uniform prefix of parsed receivers. This is the shape the +// whole-loop clone matches, and the shape the matrix cell measures. +{ + const objects: any[] = []; + for (let i = 0; i < 64; i++) objects.push(JSON.parse('{"x":0,"y":1}')); + for (let r = 0; r < 8; r++) { + for (let i = 0; i < 64; i++) { + const object: any = objects[i]; + object.x = r + i; + } + } + let sink = 0; + for (let i = 0; i < 64; i++) sink += objects[i].x + objects[i].y; + line("clone", sink, JSON.stringify(objects[0]), JSON.stringify(objects[63])); +} + +// (2) Delete an own key, then keep writing the survivors. The delete forks the +// shape, so a cache trained before it must not keep storing to the old +// slot. +{ + const o: any = JSON.parse('{"a":1,"b":2,"c":3}'); + o.b = 20; + delete o.a; + o.b = 21; + o.c = 30; + line("delete", JSON.stringify(o), Object.keys(o).join(","), o.a, "a" in o); +} + +// (3) Add a key past the parsed shape, then write both. The add transitions +// the shape; the pre-transition slot must not be reused for the new key. +{ + const o: any = JSON.parse('{"a":1}'); + o.a = 2; + o.b = 3; + o.a = 4; + o.b = 5; + line("add", JSON.stringify(o), Object.keys(o).join(",")); +} + +// (4) freeze / seal / preventExtensions AFTER the site has been trained on a +// writable receiver of the same shape. A module is strict, so the rejected +// writes THROW — which is exactly the semantic a fast path that stored +// unconditionally would lose. +{ + function attempt(fn: () => void): string { + try { + fn(); + return "ok"; + } catch (e: any) { + return e instanceof TypeError ? "TypeError" : "other"; + } + } + const warm: any = JSON.parse('{"v":1}'); + warm.v = 2; + const f: any = JSON.parse('{"v":1}'); + f.v = 2; + Object.freeze(f); + const frozenWrite = attempt(() => { + f.v = 3; + }); + const se: any = JSON.parse('{"v":1,"w":0}'); + se.v = 2; + Object.seal(se); + const sealedWrite = attempt(() => { + se.v = 3; + }); + const sealedAdd = attempt(() => { + se.q = 9; + }); + const pe: any = JSON.parse('{"v":1}'); + Object.preventExtensions(pe); + const noextWrite = attempt(() => { + pe.v = 7; + }); + const noextAdd = attempt(() => { + pe.w = 8; + }); + line("frozen", f.v, Object.isFrozen(f), frozenWrite); + line("sealed", se.v, se.w, Object.isSealed(se), sealedWrite, sealedAdd); + line("noextend", pe.v, pe.w, Object.isExtensible(pe), noextWrite, noextAdd); + line("warm", warm.v); +} + +// (5) An accessor descriptor installed over a parsed own data slot must take +// over the write; a sibling key on the same object must keep working. +{ + const o: any = JSON.parse('{"p":1,"q":2}'); + o.p = 5; + let seen = -1; + Object.defineProperty(o, "p", { + get() { + return 42; + }, + set(v: any) { + seen = v; + }, + configurable: true, + }); + o.p = 99; + o.q = 7; + line("accessor", o.p, seen, o.q); +} + +// (6) A non-writable data descriptor must reject the write (TypeError under +// the module's strict mode). +{ + const o: any = JSON.parse('{"n":1,"m":2}'); + o.m = 3; + Object.defineProperty(o, "n", { value: 1, writable: false, configurable: true }); + let threw = "ok"; + try { + o.n = 2; + } catch (e: any) { + threw = e instanceof TypeError ? "TypeError" : "other"; + } + o.m = 4; + line("nonwritable", o.n, o.m, threw); +} + +// (7) Prototype mutation. An existing OWN data property wins over a setter on +// the new prototype; a key with no own slot must reach that setter. +{ + let captured = -1; + const proto = { + set z(v: number) { + captured = v; + }, + get z() { + return 123; + }, + set own(v: number) { + captured = 1000 + v; + }, + }; + const o: any = JSON.parse('{"own":1}'); + o.own = 2; + Object.setPrototypeOf(o, proto); + o.own = 3; + o.z = 4; + line("proto", o.own, o.z, captured); +} + +// (8) A null-prototype parsed object still takes ordinary writes. +{ + const o: any = JSON.parse('{"k":1}'); + Object.setPrototypeOf(o, null); + o.k = 2; + line("nullproto", o.k, Object.getPrototypeOf(o)); +} + +// (9) Dynamic-key writes on parsed receivers (the 3-way dynamic-key write IC, +// which carries the same receiver-kind guard). +{ + const objects: any[] = []; + for (let i = 0; i < 4; i++) objects.push(JSON.parse('{"a":0,"b":0}')); + const keys = ["a", "b"]; + for (let r = 0; r < 6; r++) { + for (let i = 0; i < 4; i++) objects[i][keys[r % 2]] = r * 10 + i; + } + line("dynkey", JSON.stringify(objects)); +} + +// (10) A parsed object used as a prototype: writing through the child must +// create an own property on the child, not overwrite the parent's slot. +{ + const base: any = JSON.parse('{"m":1}'); + const child: any = Object.create(base); + base.m = 5; + child.m = 6; + line("asproto", base.m, child.m, Object.getPrototypeOf(child) === base); +} + +// (11) A parsed EMPTY object. Its allocation is the inline-slot floor, so the +// parse must not initialize more slots than it owns, and growing it by +// name afterwards must behave. +{ + const o: any = JSON.parse("{}"); + line("empty", JSON.stringify(o), Object.keys(o).length); + o.a = 1; + o.b = 2; + o.c = 3; + o.a = 4; + line("empty-grown", JSON.stringify(o)); +} + +// (12) A polymorphic write site: parsed receivers, object literals and class +// instances all flowing through one `o.x = …`. +{ + class Cell { + x = 0; + } + const mixed: any[] = [ + JSON.parse('{"x":0}'), + { x: 0 }, + new Cell(), + JSON.parse('{"x":0,"extra":1}'), + ]; + for (let r = 0; r < 4; r++) { + for (let i = 0; i < mixed.length; i++) mixed[i].x = r * 10 + i; + } + const out: string[] = []; + for (let i = 0; i < mixed.length; i++) out.push(String(mixed[i].x)); + line("poly", out.join(","), JSON.stringify(mixed[3])); +} + +// (13) `JSON.parse` creates `__proto__` as a genuine OWN data property, which +// shadows `Object.prototype`'s accessor — so `o.__proto__ = v` is an +// ordinary data write, exactly the case the fast path now takes. A fast +// path that instead reached the accessor would silently reparent the +// object. `constructor` is the same shape of trap on the read side. +{ + const o: any = JSON.parse('{"__proto__":1,"b":2}'); + line("protokey-own", Object.getOwnPropertyNames(o).join(","), JSON.stringify(o)); + o.__proto__ = 5; + o.b = 3; + line("protokey-after", JSON.stringify(o), typeof Object.getPrototypeOf(o), o.b); + const p: any = JSON.parse('{"constructor":1}'); + p.constructor = 2; + line("ctorkey", p.constructor, JSON.stringify(p)); +}