From d55b835df87384c4bc8215a320fa92322374e0d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 15 Aug 2026 03:43:39 +0200 Subject: [PATCH 01/12] perf(object): remove the derivable object_type and field_count header words (56 B -> 48 B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ObjectHeader` becomes `{class_id @0, parent_class_id @4, keys_array @8, meta @16}` — 24 bytes on LP64, 16 on ILP32. A two-slot object goes from 56 to 48 bytes and the eight-slot case from 104 to 96. Removing either word alone saves nothing (the struct re-pads), so this is one indivisible change. Both words were derivable: * the receiver KIND is `GcHeader.obj_type` plus the immutable ShapeId descriptor's `object_kind`; * the live inline-slot bound is that descriptor's `live_inline_slot_count`. Nine sites read raw offset 0 to answer "is this an Error?" — two more than previously catalogued (`promise/rejection.rs` x2). Since `OBJECT_TYPE_ERROR` is 2 and class ids are handed out from 1 in declaration order, leaving any of them would have reclassified every instance of the second class a program declares as an `ErrorHeader`. They now go through `error::ptr_is_native_error()`. Publication is mint-then-stamp throughout: the descriptor is the only record of the live slot bound, so a stamp-cleared window is a window in which the collector traces zero payload slots. Refs #8113, #8047. --- .github/workflows/test.yml | 20 + TYPE_LOWERING.md | 2 +- crates/perry-codegen/src/expr/array_push.rs | 5 +- .../src/expr/class_field_inline_guard.rs | 22 +- .../src/expr/element_shape_guard.rs | 7 +- crates/perry-codegen/src/expr/property_get.rs | 13 +- .../src/expr/property_get/generic_dispatch.rs | 9 +- crates/perry-codegen/src/expr/property_set.rs | 8 +- .../perry-codegen/src/expr/proxy_reflect.rs | 29 +- .../src/expr/static_field_meta.rs | 2 +- .../src/lower_call/ctor_prologue_stores.rs | 4 +- crates/perry-codegen/src/lower_call/new.rs | 4 +- .../perry-codegen/src/lower_call/new_alloc.rs | 64 +- .../src/runtime_decls/objects.rs | 2 +- crates/perry-codegen/src/stmt/loops.rs | 19 +- crates/perry-codegen/src/target_layout.rs | 80 ++- crates/perry-ext-ws/src/lib.rs | 4 +- crates/perry-ffi/src/jsvalue.rs | 8 + crates/perry-ffi/src/lib.rs | 4 +- crates/perry-ffi/src/types.rs | 84 ++- crates/perry-runtime/src/array/flat_clone.rs | 6 +- crates/perry-runtime/src/array/generic.rs | 3 +- crates/perry-runtime/src/array/header.rs | 8 +- crates/perry-runtime/src/array/push_pop.rs | 4 +- crates/perry-runtime/src/array/subclass.rs | 10 +- .../perry-runtime/src/array/subclass_tests.rs | 51 +- .../src/builtins/formatting/util_format.rs | 2 +- crates/perry-runtime/src/builtins/globals.rs | 4 +- .../src/child_process/v8_serde.rs | 2 +- .../src/collection_iter_object.rs | 4 +- crates/perry-runtime/src/dyn_eval/env.rs | 5 +- crates/perry-runtime/src/error.rs | 55 +- crates/perry-runtime/src/exception.rs | 12 +- crates/perry-runtime/src/gc/heap_snapshot.rs | 2 +- crates/perry-runtime/src/gc/layout.rs | 6 +- .../perry-runtime/src/gc/layout_slot_visit.rs | 2 +- .../src/gc/roots/runtime_handles.rs | 4 +- .../src/gc/tests/clone_keys_array_init.rs | 2 +- .../gc/tests/copying/pointer_publish_7154.rs | 4 +- .../perry-runtime/src/gc/tests/cycle_state.rs | 15 +- .../src/gc/tests/dead_owner_side_tables.rs | 4 +- .../src/gc/tests/layout_trace/typed_shape.rs | 2 +- .../gc/tests/shape_descriptor_authority.rs | 7 +- crates/perry-runtime/src/gc/tests/support.rs | 33 +- crates/perry-runtime/src/gc/types.rs | 4 +- crates/perry-runtime/src/intl/install.rs | 5 +- crates/perry-runtime/src/json/mod.rs | 8 +- crates/perry-runtime/src/json/replacer.rs | 6 +- crates/perry-runtime/src/json/stringify.rs | 6 +- .../src/json/stringify_shape_template.rs | 5 +- crates/perry-runtime/src/json_tape_tests.rs | 6 +- crates/perry-runtime/src/lib.rs | 1 + crates/perry-runtime/src/map.rs | 3 +- crates/perry-runtime/src/object/alloc.rs | 75 +-- crates/perry-runtime/src/object/arguments.rs | 12 +- .../object/class_registry/parent_static.rs | 63 +- .../perry-runtime/src/object/delete_rest.rs | 31 +- .../src/object/field_get_set/accessors.rs | 10 +- .../src/object/field_get_set/enumeration.rs | 4 +- .../src/object/field_get_set/field_ops.rs | 4 +- .../object/field_get_set/get_field_by_name.rs | 2 +- .../field_get_set/get_field_by_name_tail.rs | 12 +- .../src/object/field_get_set/ic_miss.rs | 7 +- .../src/object/field_set_by_name.rs | 4 +- .../object/field_set_by_name/fast_paths.rs | 16 +- .../src/object/field_set_by_name/tail.rs | 27 +- .../object/field_set_by_name/write_helpers.rs | 2 +- crates/perry-runtime/src/object/gc_slots.rs | 13 +- crates/perry-runtime/src/object/live_slots.rs | 89 +++ .../src/object/map_set_subclass.rs | 42 +- crates/perry-runtime/src/object/mod.rs | 166 ++--- .../src/object/native_call_method.rs | 10 +- .../perry-runtime/src/object/native_module.rs | 2 +- crates/perry-runtime/src/object/null_stub.rs | 72 +++ .../src/object/object_ops/accessors.rs | 6 +- .../src/object/object_ops/keys_array.rs | 10 +- crates/perry-runtime/src/object/shapes.rs | 273 ++++++-- crates/perry-runtime/src/object/spill.rs | 6 +- crates/perry-runtime/src/object/tests.rs | 179 +++++- crates/perry-runtime/src/promise/rejection.rs | 13 +- crates/perry-runtime/src/proxy.rs | 15 +- crates/perry-runtime/src/symbol.rs | 6 +- crates/perry-runtime/src/thread.rs | 2 +- crates/perry-runtime/src/typed_feedback.rs | 23 +- .../perry-runtime/src/typed_feedback/tests.rs | 7 +- crates/perry-runtime/src/url/url_class.rs | 4 +- .../perry-runtime/src/value/dynamic_object.rs | 31 +- crates/perry-runtime/src/weakref.rs | 7 +- crates/perry-stdlib/src/fetch/mod.rs | 2 +- crates/perry-stdlib/src/worker_threads.rs | 7 +- crates/perry-ui-android/src/json.rs | 606 ------------------ crates/perry-ui-android/src/lib.rs | 1 - docs/object-write-matrix.md | 7 +- docs/src/platforms/watchos.md | 7 +- scripts/addr_class_ratchet_baseline.txt | 1 - scripts/shape_descriptor_census.py | 215 ++++++- scripts/shape_descriptor_census_baseline.json | 194 +----- 97 files changed, 1568 insertions(+), 1398 deletions(-) create mode 100644 crates/perry-runtime/src/object/live_slots.rs create mode 100644 crates/perry-runtime/src/object/null_stub.rs delete mode 100644 crates/perry-ui-android/src/json.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index be1cfb4131..4ca350c34c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -964,6 +964,26 @@ jobs: done fi + # #8113 — the perry-ffi <-> perry-runtime ABI mirror, which had NEVER + # EXECUTED. `perry_ffi::types::layout_tests` is + # `#[cfg(all(test, feature = "runtime-link"))]`, `runtime-link` was + # enabled nowhere in `.github/`, and the scope loop above runs + # `cargo test -p perry-ffi` with DEFAULT features — so the module never + # even compiled. Deleting a mirrored field still went red (an `offset_of!` + # on a missing field stops compiling), but a SIZE or PADDING divergence + # between the two structs was invisible, which is precisely the failure + # mode of a header-layout change. + # + # Unconditional, not scope-gated: perry-ffi's optional dependency on + # perry-runtime means a runtime-only diff need not pull perry-ffi into + # scope, and this is the one check that says the published ABI mirror + # still matches the runtime it mirrors. + - name: perry-ffi ABI mirror matches the runtime (#8113) + env: + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS: "-C linker-features=-lld" + CARGO_PROFILE_TEST_DEBUG: "0" + run: cargo test -p perry-ffi --features runtime-link --lib + # --------------------------------------------------------------------------- # Scoped e2e: run the integration suites NAMED BY THE DIFF (#5960) # diff --git a/TYPE_LOWERING.md b/TYPE_LOWERING.md index 508b3cc630..23840a7105 100644 --- a/TYPE_LOWERING.md +++ b/TYPE_LOWERING.md @@ -614,7 +614,7 @@ can bypass part of the generic NaN-boxing overhead: ### `ObjectHeader` Layout -Every heap object has: `object_type` (u32), `class_id` (u32), `field_count` (u32), `keys_array` pointer. Inline property slots follow immediately in memory. +Every heap object has: `class_id` (u32), `parent_class_id` (u32, which carries the runtime `ShapeId` once stamped), `keys_array` pointer, `meta` pointer — 24 bytes on LP64, 16 on ILP32. Inline property slots follow immediately in memory. (#8113 removed the `object_type` and `field_count` words: the receiver kind comes from `GcHeader.obj_type` plus the ShapeId descriptor's `object_kind`, and the live inline-slot bound from the same descriptor's `live_inline_slot_count`.) - **Shape caching**: Objects with the same key set share a `keys_array` pointer. - **`KEYS_INDEX`**: FNV-1a hash map built when `keys_array.length > 32` for O(1) lookup. diff --git a/crates/perry-codegen/src/expr/array_push.rs b/crates/perry-codegen/src/expr/array_push.rs index 1613868ca5..6fd8f1a0bc 100644 --- a/crates/perry-codegen/src/expr/array_push.rs +++ b/crates/perry-codegen/src/expr/array_push.rs @@ -827,11 +827,12 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, value_discarded: bool) -> // instance, which perry models as a plain `ObjectHeader` — // reached the inline store below. `ObjectHeader` overlays // `ArrayHeader` field for field, so `length` read - // `object_type` (= 1) and `capacity` read `class_id` (large): + // `class_id` and `capacity` read the ShapeId word (#8113; it + // was `object_type` (= 1) and `class_id` before): // `1 < class_id` passed the in-bounds test and the value was // stored at `handle + 8 + 1*8` — i.e. over `ObjectHeader // .keys_array`, a live GC child edge — while `length + 1` - // overwrote `object_type`. The SECOND push then SIGSEGVed + // overwrote the first header word. The SECOND push then SIGSEGVed // (exit 139) dereferencing `keys_array`, whose bytes were now // the double `1.0` (fault address `0x3ff0000000000000`). // diff --git a/crates/perry-codegen/src/expr/class_field_inline_guard.rs b/crates/perry-codegen/src/expr/class_field_inline_guard.rs index e3499005e9..c7ab68d307 100644 --- a/crates/perry-codegen/src/expr/class_field_inline_guard.rs +++ b/crates/perry-codegen/src/expr/class_field_inline_guard.rs @@ -274,13 +274,15 @@ pub(crate) fn emit_class_field_loop_preheader_check( let res_ptr = blk.gep(I8, &obj_ptr, &[(I64, "-6")]); let reserved = blk.load(I16, &res_ptr); - // ObjectHeader: class_id @4 and authoritative ShapeId @8. Matching - // the immutable descriptor proves the live-slot bound and key order. - let cid_ptr = blk.gep(I8, &obj_ptr, &[(I64, "4")]); + // ObjectHeader: class_id @0 and authoritative ShapeId @4 (#8113 — the + // two leading offsets moved down 4 when `object_type` was deleted). + // Matching the immutable descriptor proves the live-slot bound and key + // order. + let cid_ptr = blk.gep(I8, &obj_ptr, &[(I64, "0")]); let class_id = blk.load(I32, &cid_ptr); let cid_ok = blk.icmp_eq(I32, &class_id, expected_class_id); - let sid_ptr = blk.gep(I8, &obj_ptr, &[(I64, "8")]); + let sid_ptr = blk.gep(I8, &obj_ptr, &[(I64, "4")]); let shape_id = blk.load(I32, &sid_ptr); let shape_ok = blk.icmp_eq(I32, &shape_id, expected_shape_id); @@ -395,9 +397,9 @@ pub(crate) fn emit_proven_shape_recheck( let latched = blk.and(I16, &reserved, OBJ_FLAG_FROZEN_OR_DESCRIPTORS); let unlatched = blk.icmp_eq(I16, &latched, "0"); - // `class_id` @4 was already matched by the tower. ShapeId @8 proves the - // exact immutable layout and receiver-kind descriptor. - let sid_ptr = blk.gep(I8, &obj_ptr, &[(I64, "8")]); + // `class_id` @0 was already matched by the tower. ShapeId @4 proves the + // exact immutable layout and receiver-kind descriptor (#8113 offsets). + let sid_ptr = blk.gep(I8, &obj_ptr, &[(I64, "4")]); let shape_id = blk.load(I32, &sid_ptr); let shape_ok = blk.icmp_eq(I32, &shape_id, expected_shape_id); @@ -491,12 +493,12 @@ pub(crate) fn emit_class_field_inline_precheck( let res_ptr = blk.gep(I8, &obj_ptr, &[(I64, "-6")]); let reserved = blk.load(I16, &res_ptr); - // ObjectHeader: class_id @4, authoritative ShapeId @8. - let cid_ptr = blk.gep(I8, &obj_ptr, &[(I64, "4")]); + // ObjectHeader: class_id @0, authoritative ShapeId @4 (#8113). + let cid_ptr = blk.gep(I8, &obj_ptr, &[(I64, "0")]); let class_id = blk.load(I32, &cid_ptr); let cid_ok = blk.icmp_eq(I32, &class_id, expected_class_id); - let sid_ptr = blk.gep(I8, &obj_ptr, &[(I64, "8")]); + let sid_ptr = blk.gep(I8, &obj_ptr, &[(I64, "4")]); let shape_id = blk.load(I32, &sid_ptr); let sid_ok = blk.icmp_eq(I32, &shape_id, expected_shape_id); diff --git a/crates/perry-codegen/src/expr/element_shape_guard.rs b/crates/perry-codegen/src/expr/element_shape_guard.rs index 79805bf8ba..62afc2f9f5 100644 --- a/crates/perry-codegen/src/expr/element_shape_guard.rs +++ b/crates/perry-codegen/src/expr/element_shape_guard.rs @@ -163,8 +163,8 @@ pub(crate) fn emit_element_shape_loop_preheader_check( // (2) SUBCLASS BRAND (#7573/#7603). `class X extends Array` instances are // plain `ObjectHeader`s that overlay `ArrayHeader` field for field, so - // `length`/`capacity`/`elements[0]` would read `object_type`/`class_id`/ - // `parent_class_id‖field_count`. The runtime's `array_gc_header` makes the + // `length`/`capacity`/`elements[0]` would read `class_id`/`parent_class_id` + // (the ShapeId)/`keys_array` (#8113). The runtime's `array_gc_header` makes the // same test, but it is repeated here so the raw pointer handed across the // call below is already branded, and so the emitted IR carries the brand // where a reviewer (and the IR census) can see it. @@ -356,7 +356,8 @@ pub(crate) fn emit_element_shape_field_load( let hdr_masked = blk.and(I32, &hdr, ELEM_HEADER_MASK); let hdr_ok = blk.icmp_eq(I32, &hdr_masked, ELEM_HEADER_EXPECT); - let sid_ptr = blk.gep(I8, &elem_ptr, &[(I64, "8")]); + // #8113: the ShapeId moved from header offset 8 to 4. + let sid_ptr = blk.gep(I8, &elem_ptr, &[(I64, "4")]); let shape_id = blk.load(I32, &sid_ptr); let shape_ok = blk.icmp_eq(I32, &shape_id, &fact.expected_shape_id); diff --git a/crates/perry-codegen/src/expr/property_get.rs b/crates/perry-codegen/src/expr/property_get.rs index d0b2dcf4ba..bde8f4e8e3 100644 --- a/crates/perry-codegen/src/expr/property_get.rs +++ b/crates/perry-codegen/src/expr/property_get.rs @@ -1448,11 +1448,12 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // `js_object_get_field_by_name_f64` runtime helper which // hashes the property name + walks the keys array. The // ObjectHeader layout (`#[repr(C)]` in - // `crates/perry-runtime/src/object.rs:591`) is 24 bytes - // followed by the inline field array of f64-sized slots: + // `crates/perry-runtime/src/object/mod.rs`) is 24 bytes on + // LP64 / 16 on ILP32 (#8113) followed by the inline field + // array of f64-sized slots: // - // offset 0..24: ObjectHeader (object_type, class_id, - // parent_class_id, field_count, keys_array) + // offset 0..24: ObjectHeader (class_id, parent_class_id + // [= ShapeId], keys_array, meta) // offset 24..32: field 0 // offset 32..40: field 1 // ... @@ -1734,8 +1735,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { ctx.current_block = fast_idx; // arm64_32 watchOS: the object fields region begins at // `size_of::()` past the user pointer — 24 on - // 64-bit, 20 on ILP32 (the trailing `keys_array` pointer is 4 - // bytes there). A hardcoded 24 reads every class field 4 bytes + // 64-bit, 16 on ILP32 since #8113 (both trailing pointers are + // 4 bytes there). A hardcoded 24 reads every class field 8 bytes // off on a 32-bit watch, so this inline class-field load // disagreed with the generic-PIC load / runtime setter (both // target-aware) and typed-object string fields came back as diff --git a/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs b/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs index 613081fc7a..f6d5ca6ddc 100644 --- a/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs +++ b/crates/perry-codegen/src/expr/property_get/generic_dispatch.rs @@ -386,7 +386,8 @@ pub(crate) fn lower_generic_property_get( // The receiver token is derived solely from its authoritative ShapeId. // Invalid/unstamped payloads produce zero and miss closed. - let pcid_addr = ctx.block().add(I64, &obj_handle, "8"); + // #8113: the ShapeId word moved from header offset 8 to 4. + let pcid_addr = ctx.block().add(I64, &obj_handle, "4"); let pcid_ptr = ctx.block().inttoptr(I64, &pcid_addr); let pcid = ctx.block().load(I32, &pcid_ptr); // In-range test via wrapping add + ult: (pcid - 0x8000_0000) < 0x4000_0000. @@ -432,9 +433,9 @@ pub(crate) fn lower_generic_property_get( ); let offset = ctx.block().shl(I64, &slot, "3"); // arm64_32 watchOS: the object fields region begins at - // `size_of::()` past the user pointer — 24 on 64-bit, 20 on - // ILP32 (the trailing `keys_array` pointer is 4 bytes there). A hardcoded - // 24 would read every cached property 4 bytes off on a 32-bit watch. Derive + // `size_of::()` past the user pointer — 24 on 64-bit, 16 on + // ILP32 since #8113 (both trailing pointers are 4 bytes there). A hardcoded + // 24 would read every cached property 8 bytes off on a 32-bit watch. Derive // it from the target triple (no-op on 64-bit; see `target_layout`). let obj_header_size = crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string(); diff --git a/crates/perry-codegen/src/expr/property_set.rs b/crates/perry-codegen/src/expr/property_set.rs index a8a651cca4..80c92a7227 100644 --- a/crates/perry-codegen/src/expr/property_set.rs +++ b/crates/perry-codegen/src/expr/property_set.rs @@ -360,8 +360,8 @@ pub(crate) fn try_lower_sloppy_class_field_store( ctx.current_block = fast_idx; { // arm64_32 watchOS: the fields region starts at `size_of::()` - // past the user pointer (24 on 64-bit, 20 on ILP32) — same derivation as - // the strict arm and the runtime setter. + // past the user pointer (24 on 64-bit, 16 on ILP32 since #8113) — + // same derivation as the strict arm and the runtime setter. let header_skip = crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string(); let blk = ctx.block(); @@ -1441,8 +1441,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let raw_stored_value = { // arm64_32 watchOS: the object fields region begins at // `size_of::()` past the user pointer — 24 on - // 64-bit, 20 on ILP32 (the trailing `keys_array` pointer is - // 4 bytes there). A hardcoded 24 writes every class field 4 + // 64-bit, 16 on ILP32 since #8113 (both trailing pointers are + // 4 bytes there). A hardcoded 24 writes every class field 8 // bytes off on a 32-bit watch; the paired inline read // (`property_get`) and the runtime setter must agree, so // derive it from the target triple (no-op on 64-bit; see diff --git a/crates/perry-codegen/src/expr/proxy_reflect.rs b/crates/perry-codegen/src/expr/proxy_reflect.rs index 737d6f7232..7e067d8764 100644 --- a/crates/perry-codegen/src/expr/proxy_reflect.rs +++ b/crates/perry-codegen/src/expr/proxy_reflect.rs @@ -556,7 +556,8 @@ fn lower_put_value_static_write_ic( .and(I16, &reserved, &WRITE_PIC_BLOCKING_FLAGS.to_string()); let flags_clear = ctx.block().icmp_eq(I16, &blocked, "0"); - let class_addr = ctx.block().add(I64, &safe_target, "4"); + // #8113: `class_id` moved from header offset 4 to 0. + let class_addr = ctx.block().add(I64, &safe_target, "0"); let class_ptr = ctx.block().inttoptr(I64, &class_addr); let class_id = ctx.block().load(I32, &class_ptr); let has_class = ctx.block().icmp_ne(I32, &class_id, "0"); @@ -572,7 +573,8 @@ fn lower_put_value_static_write_ic( 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"); + // #8113: the ShapeId word moved from header offset 8 to 4. + let shape_id_addr = ctx.block().add(I64, &safe_target, "4"); let shape_id_ptr = ctx.block().inttoptr(I64, &shape_id_addr); let raw_shape_id = ctx.block().load(I32, &shape_id_ptr); let shape_id_rel = ctx.block().add(I32, &raw_shape_id, "-2147483648"); @@ -890,7 +892,8 @@ fn lower_put_value_dyn_ic_inline( .block() .and(I16, &reserved, &WRITE_PIC_BLOCKING_FLAGS.to_string()); let flags_clear = ctx.block().icmp_eq(I16, &blocked, "0"); - let class_addr = ctx.block().add(I64, &t_handle, "4"); + // #8113: `class_id` moved from header offset 4 to 0. + let class_addr = ctx.block().add(I64, &t_handle, "0"); let class_ptr = ctx.block().inttoptr(I64, &class_addr); let class_id = ctx.block().load(I32, &class_ptr); let has_class = ctx.block().icmp_ne(I32, &class_id, "0"); @@ -901,7 +904,8 @@ fn lower_put_value_dyn_ic_inline( .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"); + // #8113: the ShapeId word moved from header offset 8 to 4. + let shape_id_addr = ctx.block().add(I64, &t_handle, "4"); let shape_id_ptr = ctx.block().inttoptr(I64, &shape_id_addr); let raw_shape_id = ctx.block().load(I32, &shape_id_ptr); let shape_id_rel = ctx.block().add(I32, &raw_shape_id, "-2147483648"); @@ -950,18 +954,21 @@ fn lower_put_value_dyn_ic_inline( I64, &[(&s0, &ways_label), (&s1, &way1_label), (&s2, &way2_label)], ); - let header_bytes = crate::target_layout::object_header_size_bytes(ctx.target_triple); - let header_words = (header_bytes / 8).to_string(); - let slot_word = ctx.block().add(I64, &slot, &header_words); + // #8113: address the slot in BYTES rather than dividing the header size by + // 8 to get a word index. The quotient is exact today (24/8 and 16/8), but + // #8047's ILP32 header is 12 bytes and `12 / 8 == 1` truncates silently — + // the same class of bug as the stale header-size comments this rung fixed. + let header_bytes = + crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string(); + let slot_bytes = ctx.block().shl(I64, &slot, "3"); + let slot_off = ctx.block().add(I64, &slot_bytes, &header_bytes); let obj_ptr = ctx.block().inttoptr(I64, &t_handle); - let slot_ptr = ctx - .block() - .gep_inbounds(I64, &obj_ptr, &[(I64, &slot_word)]); + let slot_ptr = ctx.block().gep_inbounds(I8, &obj_ptr, &[(I64, &slot_off)]); ctx.block() .cond_br(&v_scalar, &store_scalar_label, &store_ref_label); ctx.current_block = store_scalar_idx; - // GC_STORE_AUDIT(POINTER_FREE): the tag test above proved the value is + // GC_STORE_AUDIT(POINTER_FREE): the entry tag test proved the value is // not pointer/string/bigint — non-reference bits need no barrier. ctx.block().store(DOUBLE, v, &slot_ptr); ctx.block().br(&merge_label); diff --git a/crates/perry-codegen/src/expr/static_field_meta.rs b/crates/perry-codegen/src/expr/static_field_meta.rs index 8638ce0f72..94b1860f30 100644 --- a/crates/perry-codegen/src/expr/static_field_meta.rs +++ b/crates/perry-codegen/src/expr/static_field_meta.rs @@ -436,7 +436,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let obj = ctx.block() .call(I64, "js_object_alloc", &[(I32, &tcid_str), (I32, &nfields)]); - // #1789: mark it as a class object (object_type = OBJECT_TYPE_CLASS) + // #1789: mark it as a class object (ShapeObjectKind::Class) // so `typeof` reports "function" and `new`/`instanceof` read the // class_id from this object rather than treating it as an instance. ctx.block() diff --git a/crates/perry-codegen/src/lower_call/ctor_prologue_stores.rs b/crates/perry-codegen/src/lower_call/ctor_prologue_stores.rs index 1dc43445f2..4ed51f98d1 100644 --- a/crates/perry-codegen/src/lower_call/ctor_prologue_stores.rs +++ b/crates/perry-codegen/src/lower_call/ctor_prologue_stores.rs @@ -39,9 +39,9 @@ //! |---|---| //! | `GcHeader.obj_type == GC_TYPE_OBJECT` | low byte of the packed `gc_packed` constant | //! | not forwarded | `gc_flags` is exactly `GC_FLAG_ARENA` | -//! | `object_type == OBJECT_TYPE_REGULAR` | first `ObjectHeader` word constant | +//! | receiver is an ordinary object | the emitted precheck reads `class_id` @0 and the ShapeId @4; #8113 deleted the `object_type` word this row used to name | //! | `class_id == ` | same word, `cid` is this site's class | -//! | `field_count > slot` | `field_count` is the class's own field count, and every slot in the plan indexes a declared field | +//! | live-slot bound > slot | the bound is the class's own field count (the ShapeId descriptor's `live_inline_slot_count` since #8113), and every slot in the plan indexes a declared field | //! | `keys_array == @perry_class_keys_` | the header store loads the same global the precheck compares against | //! | no per-object descriptors | `_reserved` is the constant `GC_LAYOUT_POINTER_FREE \| INTACT` | //! | not frozen | same constant | diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index ea12ccde4e..5b5337d702 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -691,8 +691,8 @@ fn lower_new_impl_inner<'a>( { // arm64_32 watchOS: the fields region starts at // `size_of::()` past the user pointer (24 on - // 64-bit, 20 on ILP32) — same derivation as every other packed - // slot access. + // 64-bit, 16 on ILP32 since #8113) — same derivation as every + // other packed slot access. let header_skip = crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string(); let blk = ctx.block(); diff --git a/crates/perry-codegen/src/lower_call/new_alloc.rs b/crates/perry-codegen/src/lower_call/new_alloc.rs index 34e9bec7e8..40ce3bd340 100644 --- a/crates/perry-codegen/src/lower_call/new_alloc.rs +++ b/crates/perry-codegen/src/lower_call/new_alloc.rs @@ -246,20 +246,29 @@ fn emit_instance_alloc_inner( // store offset (1) // load data + gep (2) // write GcHeader (1) — packed i64 store - // write ObjectHeader×2 (2) — packed i64 stores + // write ObjectHeader (1) — one packed i64 store (#8113) // write keys_ptr (1) - // total: ~13 cycles vs ~140 cycles for the function-call path. + // total: ~12 cycles vs ~140 cycles for the function-call path. // // Layout assumption: GcHeader is 8 bytes // {obj_type:u8, gc_flags:u8, _reserved:u16, size:u32} - // and ObjectHeader is 24 bytes - // {object_type:u32, class_id:u32, parent_class_id:u32, - // field_count:u32, keys_array:*ptr} - // followed by `max(field_count, 8)` 8-byte field slots. The user - // pointer the rest of the codegen sees is `raw + 8` (i.e. the - // ObjectHeader address) — same as what + // and ObjectHeader is 24 bytes on LP64 / 16 on ILP32 (#8113) + // {class_id:u32, parent_class_id:u32, keys_array:*ptr, meta:*ptr} + // followed by `max(field_count, INLINE_SLOT_FLOOR)` 8-byte field + // slots. The user pointer the rest of the codegen sees is `raw + 8` + // (i.e. the ObjectHeader address) — same as what // `js_object_alloc_class_inline_keys` returns. // + // #8113 note on the SHAPE WORD: `parent_class_id` carries the + // module-init ShapeId, and that descriptor is now the ONLY record of + // the object's live inline-slot bound. The `descriptor_facts_exact` + // gate below is therefore load-bearing, not an optimization: an + // inline allocation whose slot bound differs from the id's descriptor + // would publish an object the runtime bounds-checks against the WRONG + // number. Mismatches take the outlined + // `js_object_alloc_class_inline_keys_stamped` entry point, which + // installs an exact local descriptor. + // // Layout constants are duplicated here from the runtime; if // `GcHeader` or `ObjectHeader` ever change in // `crates/perry-runtime/src/{gc,object}.rs`, update both sides. @@ -377,8 +386,8 @@ fn emit_instance_alloc_inner( } else { // Compile-time layout constants. const GC_HEADER_SIZE: u64 = 8; - // arm64_32 watchOS: `size_of::()` is 24 on 64-bit but - // 20 on ILP32 (4-byte `keys_array` pointer). Derive from the target + // arm64_32 watchOS: `size_of::()` is 24 on 64-bit + // but 16 on ILP32 (two 4-byte pointers). Derive from the target // triple so the inline alloc size and field-region base match the // target-compiled runtime (no-op on 64-bit; see `target_layout`). let object_header_size: u64 = @@ -410,7 +419,6 @@ fn emit_instance_alloc_inner( /// a raw-f64 slot directly. Runtime-side name: /// `gc::layout::GC_OBJ_TYPED_LAYOUT_INTACT`. const GC_OBJ_TYPED_LAYOUT_INTACT: u64 = 0x1000; - const OBJECT_TYPE_REGULAR: u64 = 1; // #7834: when this class's canonical layout is declarable at // allocation AND its pointer mask is statically empty, the state @@ -559,28 +567,24 @@ fn emit_instance_alloc_inner( // GC_STORE_AUDIT(INIT): inline headers initialize freshly allocated unpublished object storage. blk.store(I64, &gc_packed.to_string(), &raw); - // Write ObjectHeader at raw + 8. - // First 8 bytes: object_type (u32, low) | class_id (u32, high) - let oh_addr_1 = blk.gep(I8, &raw, &[(I64, "8")]); - let oh_word_1: u64 = OBJECT_TYPE_REGULAR | ((cid as u64) << 32); - blk.store(I64, &oh_word_1.to_string(), &oh_addr_1); - - // Second 8 bytes: ShapeId (u32, low) | field_count (u32, high). + // Write ObjectHeader at raw + 8. #8113 collapsed the two packed + // words into one: `class_id` (u32, low) | ShapeId (u32, high). // The module-init runtime call either publishes a usable ShapeId // or fail-stops on exhaustion; there is no pointer-token fallback. - let oh_addr_2 = blk.gep(I8, &raw, &[(I64, "16")]); + // The deleted `object_type` was a constant and the deleted + // `field_count` is now the ShapeId descriptor's + // `live_inline_slot_count`, which the `descriptor_facts_exact` + // gate above proved equals this site's `field_count`. + let oh_addr_1 = blk.gep(I8, &raw, &[(I64, "8")]); let shape_word64 = blk.zext(I32, &shape_id, I64); - let oh_word_2 = blk.or( - I64, - &shape_word64, - &((field_count as u64) << 32).to_string(), - ); - blk.store(I64, &oh_word_2, &oh_addr_2); + let oh_shifted = blk.shl(I64, &shape_word64, "32"); + let oh_word_1 = blk.or(I64, &oh_shifted, &(cid as u64).to_string()); + blk.store(I64, &oh_word_1, &oh_addr_1); - // Third 8 bytes: keys_array pointer. The keys_ptr we loaded + // Second 8 bytes: keys_array pointer. The keys_ptr we loaded // above is an i64 (carries the ArrayHeader address); store as // i64 since the underlying memory is 8 bytes either way. - let oh_addr_3 = blk.gep(I8, &raw, &[(I64, "24")]); + let oh_addr_3 = blk.gep(I8, &raw, &[(I64, "16")]); // GC_STORE_AUDIT(INIT): keys_array edge is installed before publishing the new object. blk.store(I64, &keys_ptr, &oh_addr_3); @@ -603,8 +607,10 @@ fn emit_instance_alloc_inner( // read-before-write — or a GC that scans the still-constructing instance — // observed stale arena bytes. When those bytes were a previously-freed // `undefined`/pointer (e.g. `marked`'s `this.defaults`), the constructor - // crashed with "Cannot read properties of undefined". Slots start at - // raw + GcHeader(8) + ObjectHeader(24) = raw + 32. + // crashed with "Cannot read properties of undefined". Slots start + // at raw + GcHeader(8) + ObjectHeader(24) = raw + 32 on LP64 + // (#8113; it was raw + 40 while the header carried the two deleted + // words). for i in 0..alloc_field_count { let slot_off = GC_HEADER_SIZE + object_header_size + i * FIELD_SLOT_SIZE; let slot_ptr = blk.gep(I8, &raw, &[(I64, &slot_off.to_string())]); diff --git a/crates/perry-codegen/src/runtime_decls/objects.rs b/crates/perry-codegen/src/runtime_decls/objects.rs index 1589430d7f..cf1d6faf7e 100644 --- a/crates/perry-codegen/src/runtime_decls/objects.rs +++ b/crates/perry-codegen/src/runtime_decls/objects.rs @@ -59,7 +59,7 @@ pub fn declare_phase_b_objects(module: &mut LlModule) { // JSValue (DOUBLE): nullish/primitive -> fresh {}, object passes through. module.declare_function("js_object_coerce", DOUBLE, &[DOUBLE]); // #1789: stamp a class-expression's heap object as a class object - // (object_type = OBJECT_TYPE_CLASS) so typeof → "function" and + // (ShapeObjectKind::Class) so typeof → "function" and // new/instanceof read class_id from it. module.declare_function("js_object_mark_class", VOID, &[I64]); // #6438: pin a per-evaluation class object's own parent edge. diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index e07d2c7e99..792450d1fe 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -3204,11 +3204,14 @@ fn lower_object_array_write_versioned_for( plans }; let object_header_size = crate::target_layout::object_header_size_bytes(ctx.target_triple); - let header_words = (object_header_size / 8).to_string(); + // #8113: address inline slots in BYTES rather than dividing the header size + // by 8 to get a word index. The quotient is exact today (24/8 and 16/8), but + // #8047's ILP32 header is 12 bytes and `12 / 8 == 1` truncates silently. + let header_bytes = object_header_size.to_string(); // `meta` is the LAST ObjectHeader field (a documented invariant of the // header layout): a POINTER-WIDTH field at byte offset - // (header_size - pointer_size). On ILP32 (arm64_32) the header is 24 - // bytes with a 4-byte `meta` at offset 20 — neither 8-byte-word-indexable + // (header_size - pointer_size). On ILP32 (arm64_32) the header is 16 + // bytes with a 4-byte `meta` at offset 12 — neither 8-byte-word-indexable // nor i64-loadable — so the spill path addresses it by BYTE offset and // loads pointer-width, mirroring the `new.rs` allocator's meta store. let meta_ptr_size: u64 = if crate::target_layout::target_is_ilp32(ctx.target_triple) { @@ -3258,8 +3261,9 @@ fn lower_object_array_write_versioned_for( ctx.current_block = inline_idx; let field_ptr = { let blk = ctx.block(); - let field_word = blk.add(I64, &slot, &header_words); - blk.gep_inbounds(I64, &object_ptr, &[(I64, &field_word)]) + let slot_bytes = blk.shl(I64, &slot, "3"); + let field_off = blk.add(I64, &slot_bytes, &header_bytes); + blk.gep_inbounds(I8, &object_ptr, &[(I64, &field_off)]) }; // GC_STORE_AUDIT(POINTER_FREE): finite numeric values only, proven // by the entry guard's range analysis. @@ -3327,8 +3331,9 @@ fn lower_object_array_write_versioned_for( ctx.current_block = inline_idx; let field_ptr = { let blk = ctx.block(); - let field_word = blk.add(I64, slot, &header_words); - blk.gep_inbounds(I64, &object_ptr, &[(I64, &field_word)]) + let slot_bytes = blk.shl(I64, slot, "3"); + let field_off = blk.add(I64, &slot_bytes, &header_bytes); + blk.gep_inbounds(I8, &object_ptr, &[(I64, &field_off)]) }; // GC_STORE_AUDIT(POINTER_FREE): the versioned loop emits only numeric // values into fields proven numeric by the entry guard. diff --git a/crates/perry-codegen/src/target_layout.rs b/crates/perry-codegen/src/target_layout.rs index 136957188d..cc31467a33 100644 --- a/crates/perry-codegen/src/target_layout.rs +++ b/crates/perry-codegen/src/target_layout.rs @@ -27,22 +27,33 @@ pub fn target_is_ilp32(target_triple: &str) -> bool { /// `std::mem::size_of::()` for the target. /// -/// `ObjectHeader` is four `u32`s (`object_type`, `class_id`, `parent_class_id`, -/// `field_count` = 16 bytes) followed by two pointers (`keys_array`, and the -/// #6759 Phase B `meta` record pointer): 16 bytes → 32 on 64-bit; 8 bytes → 24 -/// on ILP32. Inline object allocation, header init, and the property -/// inline-cache fast path all use this as the field-region base +/// #8113: `ObjectHeader` is two `u32`s (`class_id` @0, `parent_class_id` @4 — +/// the latter carrying the runtime ShapeId after stamping) followed by two +/// pointers (`keys_array`, and the #6759 Phase B `meta` record pointer): +/// 8 bytes of words → **24 on 64-bit**; → **16 on ILP32**. It was 32/24 while +/// the header also carried `object_type` @0 and `field_count` @12; both were +/// derivable (`GcHeader.obj_type` + the ShapeId descriptor's `object_kind`, and +/// the descriptor's `live_inline_slot_count`) and removing either ALONE saved +/// nothing because the struct re-padded. +/// +/// Inline object allocation, header init, and the property inline-cache fast +/// path all use this as the field-region base /// (`fields = obj + object_header_size_bytes`). It MUST equal the runtime's /// `size_of::()`, or inline-constructed objects and runtime-FFI /// field access diverge and every property read/write is corrupt. (The closure /// header `type_tag` offset has the analogous problem; that one is handled /// runtime-side via `perry_runtime::closure::CLOSURE_TYPE_TAG_OFFSET` / /// `offset_of!`.) +/// +/// Both values stay 8-BYTE MULTIPLES, which the f64 field region after the +/// header depends on: the ILP32 struct is `{u32, u32, *4, *4}` = 16 with align +/// 4, and allocations are 8-aligned, so slot 0 lands 8-aligned and the arm64_32 +/// `i64:64` ABI hazard `lower_call/new_alloc.rs` warns about does not arise. pub fn object_header_size_bytes(target_triple: &str) -> u64 { if target_is_ilp32(target_triple) { - 24 + 16 } else { - 32 + 24 } } @@ -62,12 +73,16 @@ pub fn object_header_size_bytes(target_triple: &str) -> u64 { /// `max(field_count, INLINE_SLOT_FLOOR)` slots. A value SMALLER than the /// runtime's makes the runtime's bound checks admit slots the emitted /// allocation never reserved → writes into the neighbouring arena object. -/// - **the emitted property bounds checks** (`expr/property_get`, -/// `expr/proxy_reflect`) gate a raw inline slot load/store on -/// `slot < max(field_count, INLINE_SLOT_FLOOR)`. A value LARGER than the -/// runtime's widens those raw accesses past the allocation. +/// - **the runtime's by-index bounds checks** (`object/field_get_set`, +/// `object/field_set_by_name`) gate every slot write on +/// `slot < max(live_inline_slot_count, INLINE_SLOT_FLOOR)`. A codegen value +/// LARGER than the runtime's would under-allocate for those admitted slots. /// -/// So codegen must be exactly equal, not conservatively either way. +/// So codegen must be exactly equal, not conservatively either way. (Emitted IR +/// no longer materializes this bound itself: #8067 moved the PIC hit path onto +/// an exact ShapeId match, and `expr/property_get/tests.rs`'s +/// `cached_slot_bound_comes_from_the_shape_descriptor_match` asserts it stays +/// off. #8113 then deleted the `field_count` word it used to reload.) pub const INLINE_SLOT_FLOOR: u64 = 2; /// `INLINE_SLOT_FLOOR` as the string literal the IR emitters splice in. @@ -104,15 +119,38 @@ mod tests { #[test] fn object_header_size_matches_pointer_width() { - // 64-bit targets: 4×u32 + two 8-byte-aligned pointers (keys_array + - // #6759 meta) = 32. - assert_eq!(object_header_size_bytes("aarch64-apple-darwin"), 32); - assert_eq!(object_header_size_bytes("aarch64-apple-watchos"), 32); - assert_eq!(object_header_size_bytes("aarch64-apple-watchos-sim"), 32); - assert_eq!(object_header_size_bytes("x86_64-unknown-linux-gnu"), 32); - // arm64_32 watchOS (Series 4–8 / SE): 4×u32 + two 4-byte pointers = 24. - assert_eq!(object_header_size_bytes("x86_64-unknown-linux-gnux32"), 24); - assert_eq!(object_header_size_bytes("arm64_32-apple-watchos"), 24); + // #8113 — 64-bit targets: 2×u32 + two 8-byte-aligned pointers + // (keys_array + #6759 meta) = 24. + assert_eq!(object_header_size_bytes("aarch64-apple-darwin"), 24); + assert_eq!(object_header_size_bytes("aarch64-apple-watchos"), 24); + assert_eq!(object_header_size_bytes("aarch64-apple-watchos-sim"), 24); + assert_eq!(object_header_size_bytes("x86_64-unknown-linux-gnu"), 24); + // arm64_32 watchOS (Series 4–8 / SE): 2×u32 + two 4-byte pointers = 16. + assert_eq!(object_header_size_bytes("x86_64-unknown-linux-gnux32"), 16); + assert_eq!(object_header_size_bytes("arm64_32-apple-watchos"), 16); + } + + /// #8113: two emitters divide the header size by 8 to get a WORD index + /// (`expr/proxy_reflect.rs`, `stmt/loops.rs`). That is only sound while the + /// size is a multiple of 8 on every target — 24/8 and 16/8 are exact, but + /// #8047's 16/12 pair would make the ILP32 division silently truncate. + /// Pin the divisibility rather than the quotient. + #[test] + fn object_header_size_is_a_whole_number_of_heap_words() { + for triple in [ + "aarch64-apple-darwin", + "x86_64-unknown-linux-gnu", + "arm64_32-apple-watchos", + "x86_64-unknown-linux-gnux32", + ] { + assert_eq!( + object_header_size_bytes(triple) % 8, + 0, + "{triple}: header size must be a whole number of 8-byte heap \ + words — `object_header_size_bytes(..) / 8` is used as a word \ + index and truncates silently otherwise" + ); + } } #[test] diff --git a/crates/perry-ext-ws/src/lib.rs b/crates/perry-ext-ws/src/lib.rs index b026b98563..aaf8e376fb 100644 --- a/crates/perry-ext-ws/src/lib.rs +++ b/crates/perry-ext-ws/src/lib.rs @@ -844,7 +844,9 @@ fn extract_no_server(opts_f64: f64) -> bool { return false; } unsafe { - let n = (*ptr).field_count; + // #8113: the header's `field_count` word is gone; the live inline-slot + // bound comes from the runtime accessor. + let n = perry_ffi::js_object_live_slot_count(ptr); let mut saw_true = false; let mut saw_positive_port = false; for i in 0..n { diff --git a/crates/perry-ffi/src/jsvalue.rs b/crates/perry-ffi/src/jsvalue.rs index aa6c317e21..a0ae4e1303 100644 --- a/crates/perry-ffi/src/jsvalue.rs +++ b/crates/perry-ffi/src/jsvalue.rs @@ -326,6 +326,14 @@ extern "C" { /// shape declared them). pub fn js_object_get_field(obj: *const ObjectHeader, field_index: u32) -> JsValue; + /// Number of LIVE inline field slots on `obj` — the exclusive upper bound + /// for [`js_object_get_field`]. + /// + /// #8113: this used to be readable as `(*obj).field_count`. The word is + /// gone (the authoritative bound is the object's ShapeId descriptor), so + /// ask the runtime instead of reading the header. + pub fn js_object_live_slot_count(obj: *const ObjectHeader) -> u32; + /// Write the field at `field_index`. pub fn js_object_set_field(obj: *mut ObjectHeader, field_index: u32, value: JsValue); diff --git a/crates/perry-ffi/src/lib.rs b/crates/perry-ffi/src/lib.rs index 678b4807a8..75f75417e0 100644 --- a/crates/perry-ffi/src/lib.rs +++ b/crates/perry-ffi/src/lib.rs @@ -56,7 +56,7 @@ pub use async_runtime::{ mod types; pub use types::{ ArrayHeader, BigIntHeader, BufferHeader, ClosureHeader, NativeAsyncCompletion, ObjectHeader, - Promise, StringHeader, BIGINT_LIMBS, + Promise, StringHeader, BIGINT_LIMBS, OBJECT_HEADER_ABI_REVISION, }; mod handle; @@ -74,7 +74,7 @@ mod jsvalue; pub use jsvalue::{ alloc_null_proto_object, alloc_object, build_object_shape, js_array_alloc, js_array_get, js_array_length, js_array_push, js_array_set, js_object_alloc_with_shape, js_object_get_field, - js_object_set_field, object_field_by_name, JsValue, + js_object_live_slot_count, js_object_set_field, object_field_by_name, JsValue, }; mod closure; diff --git a/crates/perry-ffi/src/types.rs b/crates/perry-ffi/src/types.rs index 6748756bad..3cd931c503 100644 --- a/crates/perry-ffi/src/types.rs +++ b/crates/perry-ffi/src/types.rs @@ -8,6 +8,26 @@ /// Length of the fixed BigInt limb array. pub const BIGINT_LIMBS: usize = 16; +/// Revision of the [`ObjectHeader`] ABI this crate mirrors. +/// +/// Bump on ANY change to `ObjectHeader`'s size, field set, or field offsets, +/// and bump `perry_runtime::perry_object_header_abi_revision()` in the same +/// commit — `object_header_abi_revision_matches_the_pinned_layout` fails +/// otherwise. +/// +/// It exists because `perry-ffi` is **published to crates.io**: a wrapper built +/// against an older mirror and linked by `perry compile` against a newer +/// runtime reads the wrong offsets with no diagnostic at all. An out-of-tree +/// wrapper should assert +/// `perry_ffi::OBJECT_HEADER_ABI_REVISION == perry_object_header_abi_revision()` +/// (declared `extern "C" fn() -> u32`) once at startup and refuse to run on a +/// mismatch. +/// +/// * 1 — `{object_type, class_id, parent_class_id, field_count, keys_array, meta}`, +/// 32 bytes on LP64. +/// * 2 — `{class_id, parent_class_id, keys_array, meta}`, 24 bytes on LP64 (#8113). +pub const OBJECT_HEADER_ABI_REVISION: u32 = 2; + /// Header for a runtime-allocated JS string. #[repr(C)] pub struct StringHeader { @@ -33,16 +53,29 @@ pub struct ArrayHeader { } /// Header for a runtime-allocated JS object. +/// +/// # ABI revision 2 (#8113) — BREAKING for out-of-tree mirrors +/// +/// Revision 1 opened with `object_type: u32` and carried `field_count: u32`. +/// Both were derivable and both are gone; `class_id` moved from offset 4 to 0, +/// the shape word from 8 to 4, and the struct shrank from 32 to 24 bytes on +/// LP64 (16 on ILP32). +/// +/// A wrapper compiled against the revision-1 mirror and linked against a +/// revision-2 runtime reads `class_id` out of the deleted `object_type` slot +/// with **no compile error**. That cannot be detected retroactively — nothing in +/// revision 1 references a version symbol — so revision 1 consumers must +/// recompile. From this revision on, [`OBJECT_HEADER_ABI_REVISION`] gives the +/// tripwire: assert it against the runtime's +/// `perry_object_header_abi_revision()` at startup, and a future layout change +/// is caught instead of silently misread. #[repr(C)] pub struct ObjectHeader { - /// Runtime object type discriminator. - pub object_type: u32, - /// Runtime class identifier. + /// Runtime class identifier. Offset 0 since ABI revision 2 (#8113). pub class_id: u32, - /// Runtime parent class identifier, or zero when absent. + /// Runtime parent class identifier during allocation, then the runtime + /// ShapeId after shape stamping. Never authoritative parent data. pub parent_class_id: u32, - /// Number of inline fields. - pub field_count: u32, /// Runtime array of object keys, or null for class instances. pub keys_array: *mut ArrayHeader, /// Per-object metadata record (#6759 Phase B), or null when the object @@ -147,13 +180,16 @@ mod layout_tests { ); } + /// #8113: this test — and the whole `layout_tests` module — had **never + /// executed**. `runtime-link` is enabled nowhere in `.github/`, and + /// `cargo-test` is a per-package loop, so a size or padding divergence + /// between the mirror and the runtime was invisible; only outright field + /// DELETION went red, via `offset_of!` failing to compile. `test.yml`'s + /// `cargo-test` job now runs + /// `cargo test -p perry-ffi --features runtime-link` unconditionally. #[test] fn object_header_matches_runtime() { assert_layout!(ObjectHeader, perry_runtime::ObjectHeader); - assert_eq!( - offset_of!(ObjectHeader, object_type), - offset_of!(perry_runtime::ObjectHeader, object_type) - ); assert_eq!( offset_of!(ObjectHeader, class_id), offset_of!(perry_runtime::ObjectHeader, class_id) @@ -162,10 +198,6 @@ mod layout_tests { offset_of!(ObjectHeader, parent_class_id), offset_of!(perry_runtime::ObjectHeader, parent_class_id) ); - assert_eq!( - offset_of!(ObjectHeader, field_count), - offset_of!(perry_runtime::ObjectHeader, field_count) - ); assert_eq!( offset_of!(ObjectHeader, keys_array), offset_of!(perry_runtime::ObjectHeader, keys_array) @@ -176,6 +208,30 @@ mod layout_tests { ); } + /// The size/padding half of the mirror contract, spelled separately so a + /// failure names the actual problem. `assert_layout!` above already covers + /// it, but this pins the ABSOLUTE numbers too: a mirror that tracks the + /// runtime while BOTH drift is still an ABI break for every published + /// consumer, and that is the case `object_header_matches_runtime` cannot + /// see. + #[test] + fn object_header_abi_revision_matches_the_pinned_layout() { + assert_eq!(OBJECT_HEADER_ABI_REVISION, 2); + assert_eq!( + OBJECT_HEADER_ABI_REVISION, + perry_runtime::perry_object_header_abi_revision(), + "the runtime and the published mirror disagree about the header ABI \ + revision — bump BOTH, in the same commit, and say so in the \ + changelog: perry-ffi is published to crates.io" + ); + #[cfg(target_pointer_width = "64")] + assert_eq!(size_of::(), 24); + #[cfg(target_pointer_width = "32")] + assert_eq!(size_of::(), 16); + assert_eq!(offset_of!(ObjectHeader, class_id), 0); + assert_eq!(offset_of!(ObjectHeader, parent_class_id), 4); + } + #[test] fn buffer_header_matches_runtime() { assert_layout!(BufferHeader, perry_runtime::BufferHeader); diff --git a/crates/perry-runtime/src/array/flat_clone.rs b/crates/perry-runtime/src/array/flat_clone.rs index bc4ecc2ce4..2ebb9f8818 100644 --- a/crates/perry-runtime/src/array/flat_clone.rs +++ b/crates/perry-runtime/src/array/flat_clone.rs @@ -428,9 +428,9 @@ pub extern "C" fn js_array_clone(src: *const ArrayHeader) -> *mut ArrayHeader { // `Array.from({length: N, 0: ..., 1: ...})` (array-like object) per // ECMA-262 §23.1.2.1 step 8: read `.length`, then for each index // 0..length read `obj[i]` (missing slots → undefined). Pre-fix this - // fell through to the array-memcpy path which read ObjectHeader's - // `field_count` u32 as `length` and the inline f64 slots as elements - // — garbage. Detect via `GC_TYPE_OBJECT`. + // fell through to the array-memcpy path which read an `ObjectHeader` word + // as `length` (`class_id` since #8113) and the inline f64 slots as + // elements — garbage. Detect via `GC_TYPE_OBJECT`. if raw_addr >= crate::gc::GC_HEADER_SIZE + 0x1000 { let obj_type = unsafe { let hdr = (raw_addr as *const u8).sub(crate::gc::GC_HEADER_SIZE) diff --git a/crates/perry-runtime/src/array/generic.rs b/crates/perry-runtime/src/array/generic.rs index 9aade46014..d6fe826b76 100644 --- a/crates/perry-runtime/src/array/generic.rs +++ b/crates/perry-runtime/src/array/generic.rs @@ -147,7 +147,8 @@ fn to_length(v: f64) -> i64 { /// not `clean_arr_ptr` alone — must gate the array fast path: `clean_arr_ptr` /// accepts an object pointer whose leading `ObjectHeader` words happen to pass /// its `length <= capacity` bound, then `(*arr).length` / the element buffer -/// read `field_count` / inline slots as garbage (see `normalize_array_receiver`). +/// read `ObjectHeader`'s words / inline slots as garbage (see +/// `normalize_array_receiver`). #[inline] pub(super) fn as_real_array(recv: f64) -> *mut ArrayHeader { let b = recv.to_bits(); diff --git a/crates/perry-runtime/src/array/header.rs b/crates/perry-runtime/src/array/header.rs index 9e7f7ef4bf..b0b5270af3 100644 --- a/crates/perry-runtime/src/array/header.rs +++ b/crates/perry-runtime/src/array/header.rs @@ -847,9 +847,15 @@ pub(crate) fn array_ptr_as_proxy(arr: *const ArrayHeader) -> Option { /// were a real ArrayHeader (reading `(*arr).length` + the inline element /// buffer). When the receiver is a plain object, `clean_arr_ptr` either nulls /// it (TypeError downstream) or — if the object's first u32s happen to pass the -/// length<=capacity sanity bound — reads the `ObjectHeader` field_count / inline +/// length<=capacity sanity bound — reads the `ObjectHeader`'s words / inline /// f64 slots as garbage elements (e.g. `8.48e-314`). /// +/// #8113 made that sanity bound WEAKER, not stronger: with `object_type` gone, +/// `length` aliases `class_id` and `capacity` aliases the ShapeId word, so a +/// plain object literal (`class_id == 0`) trivially satisfies +/// `length <= capacity`. The bound was never the defense — the GC-header +/// `obj_type` test below is — but do not reintroduce a caller that leans on it. +/// /// This helper detects the array-like case via the GC header `obj_type` /// (`GC_TYPE_OBJECT` == plain object) and materializes it into a real array via /// `js_array_from_arraylike` (which ToLength-coerces `length` and reads indexed diff --git a/crates/perry-runtime/src/array/push_pop.rs b/crates/perry-runtime/src/array/push_pop.rs index 3de073abfb..98207555b8 100644 --- a/crates/perry-runtime/src/array/push_pop.rs +++ b/crates/perry-runtime/src/array/push_pop.rs @@ -835,7 +835,9 @@ pub extern "C" fn js_array_set_length_strict(arr: *mut ArrayHeader, new_length: if cleaned.is_null() { // #7574: `a.length = n` on a `class X extends Array` instance reached // here through the `is_array_expr`-keyed `property_set` lowering and - // wrote `ObjectHeader.object_type`. Perform the Array-exotic + // wrote the first `ObjectHeader` word (`class_id` since #8113 — i.e. + // the write corrupts class identity, not an inert tag). Perform the + // Array-exotic // `Set(O, "length", n, true)` on the object instead. if let Some(recv) = crate::array::subclass::array_object_receiver(arr) { crate::array::subclass::array_object_set_length(recv, new_length); diff --git a/crates/perry-runtime/src/array/subclass.rs b/crates/perry-runtime/src/array/subclass.rs index 60105bd952..5c0db32c74 100644 --- a/crates/perry-runtime/src/array/subclass.rs +++ b/crates/perry-runtime/src/array/subclass.rs @@ -132,11 +132,11 @@ pub fn array_subclass_has_iterator_override(value: f64) -> bool { // perry models as a plain `ObjectHeader`. The two headers overlay field for // field: // -// ArrayHeader.length (u32 @0) <- ObjectHeader.object_type (= 1) -// ArrayHeader.capacity (u32 @4) <- ObjectHeader.class_id -// elements[0] (@8) <- parent_class_id || field_count -// elements[1] (@16) <- keys_array (a *mut ArrayHeader) -// elements[2] (@24) <- meta (a *mut ObjectMeta) +// ArrayHeader.length (u32 @0) <- ObjectHeader.class_id (#8113) +// ArrayHeader.capacity (u32 @4) <- ObjectHeader.parent_class_id (ShapeId) +// elements[0] (@8) <- keys_array (a *mut ArrayHeader) +// elements[1] (@16) <- meta (a *mut ObjectMeta) +// elements[2] (@24) <- inline field slot 0 // // so element WRITES overwrite two live GC child edges with arbitrary doubles — // the collector then traces whatever the mutator stored. `a.push(1); a.push(2)` diff --git a/crates/perry-runtime/src/array/subclass_tests.rs b/crates/perry-runtime/src/array/subclass_tests.rs index ec2c4b8037..815411e0b9 100644 --- a/crates/perry-runtime/src/array/subclass_tests.rs +++ b/crates/perry-runtime/src/array/subclass_tests.rs @@ -3,11 +3,19 @@ //! //! Every test here is **sabotage-shaped**: it first asserts that the bytes the //! pre-fix code misread are *still sitting there* — an `ObjectHeader` read as -//! an `ArrayHeader` yields `length == object_type == 1` and -//! `capacity == class_id`, both of which sail through `clean_arr_ptr`'s -//! length/capacity sanity check — and only then that the entry point refuses -//! or resolves it. A green run therefore proves the brand check FIRED, not that -//! the receiver happened to look invalid for some unrelated reason. +//! an `ArrayHeader` yields a `(length, capacity)` pair that sails through +//! `clean_arr_ptr`'s length/capacity sanity check — and only then that the +//! entry point refuses or resolves it. A green run therefore proves the brand +//! check FIRED, not that the receiver happened to look invalid for some +//! unrelated reason. +//! +//! #8113 MOVED the overlay. `ObjectHeader::object_type` is gone, so +//! `ArrayHeader.length` now aliases `class_id` and `capacity` aliases the shape +//! word. That makes the class ids used here load-bearing: the pre-fix sanity +//! check is `length <= capacity && length <= 100M`, and `length` is the class +//! id, so every fixture below uses an id under 100,000,000. A larger id would +//! fail that check for an unrelated reason and silently turn these tests +//! vacuous — which is exactly the failure mode the module is written to avoid. use super::subclass::{ array_object_receiver, is_array_subclass_class_id, raw_receiver_is_heap_object, @@ -23,24 +31,29 @@ fn as_array_header(obj: *mut ObjectHeader) -> *const ArrayHeader { } /// The overlay that makes this bug possible, pinned. If `ObjectHeader` ever -/// stops starting with `object_type: u32, class_id: u32`, the misread this +/// stops starting with `class_id: u32, parent_class_id: u32`, the misread this /// whole family defends against changes shape and these tests must be revisited. #[test] fn object_header_still_overlays_array_header_length_and_capacity() { - let class_id = 0x7574_0001; + let class_id = 0x0074_0001; let obj = js_object_alloc(class_id, 2); assert!(!obj.is_null()); let hdr = as_array_header(obj); unsafe { assert_eq!( (*hdr).length, - 1, - "ArrayHeader.length must still alias ObjectHeader.object_type (= 1)" + class_id, + "#8113: ArrayHeader.length must alias ObjectHeader.class_id" ); assert_eq!( (*hdr).capacity, - class_id, - "ArrayHeader.capacity must still alias ObjectHeader.class_id" + (*obj).parent_class_id, + "#8113: ArrayHeader.capacity must alias the ObjectHeader shape word" + ); + assert!( + crate::object::shapes::is_shape_id((*obj).parent_class_id), + "test premise: a birth-stamped object carries a ShapeId in word 1, \ + which is what keeps the forged capacity above the forged length" ); // The sanity check `clean_arr_ptr` applied BEFORE the fix: `length <= // capacity && length <= 100M`. Both hold, which is precisely why the @@ -52,7 +65,7 @@ fn object_header_still_overlays_array_header_length_and_capacity() { #[test] fn clean_arr_ptr_refuses_a_plain_object_receiver() { - let obj = js_object_alloc(0x7574_0002, 2); + let obj = js_object_alloc(0x0074_0002, 2); let hdr = as_array_header(obj); unsafe { // Sabotage precondition: the forged (length, capacity) pair is still @@ -88,7 +101,7 @@ fn a_genuine_array_takes_the_fast_path_and_is_never_redirected() { #[test] fn array_object_receiver_admits_an_array_subclass_instance() { - let class_id = 0x7574_0003; + let class_id = 0x0074_0003; crate::object::js_register_class_parent(class_id, CLASS_ID_ARRAY); assert!( is_array_subclass_class_id(class_id), @@ -97,9 +110,11 @@ fn array_object_receiver_admits_an_array_subclass_instance() { let obj = js_object_alloc(class_id, 2); let hdr = as_array_header(obj); unsafe { - // Sabotage precondition: the misread is still available. - assert_eq!((*hdr).length, 1); - assert_eq!((*hdr).capacity, class_id); + // Sabotage precondition: the misread is still available (#8113 overlay). + assert_eq!((*hdr).length, class_id); + assert_eq!((*hdr).capacity, (*obj).parent_class_id); + assert!((*hdr).length <= (*hdr).capacity); + assert!((*hdr).length <= 100_000_000); } assert!( raw_receiver_is_heap_object(hdr), @@ -118,8 +133,8 @@ fn array_object_receiver_admits_an_array_subclass_instance() { #[test] fn array_object_receiver_rejects_an_ordinary_class_instance() { - let class_id = 0x7574_0004; - crate::object::js_register_class_parent(class_id, 0x7574_0005); + let class_id = 0x0074_0004; + crate::object::js_register_class_parent(class_id, 0x0074_0005); assert!(!is_array_subclass_class_id(class_id)); let obj = js_object_alloc(class_id, 2); assert!( diff --git a/crates/perry-runtime/src/builtins/formatting/util_format.rs b/crates/perry-runtime/src/builtins/formatting/util_format.rs index cb37ff5023..0df8d59c8b 100644 --- a/crates/perry-runtime/src/builtins/formatting/util_format.rs +++ b/crates/perry-runtime/src/builtins/formatting/util_format.rs @@ -93,7 +93,7 @@ unsafe fn util_format_json_object_has_cycle(ptr: *const u8, stack: &mut Vec()) as *const f64; let alloc_limit = std::cmp::max(num_fields, crate::object::INLINE_SLOT_FLOOR as u32); (0..keys_len).any(|f| { diff --git a/crates/perry-runtime/src/builtins/globals.rs b/crates/perry-runtime/src/builtins/globals.rs index 7d100276ba..364b61d474 100644 --- a/crates/perry-runtime/src/builtins/globals.rs +++ b/crates/perry-runtime/src/builtins/globals.rs @@ -813,7 +813,7 @@ fn js_structured_clone_inner(value: f64, depth: usize) -> f64 { } else { 0 }; - if key_count > (*src_obj).field_count as usize { + if key_count > crate::object::object_live_slot_count(src_obj) as usize { let scope = crate::gc::RuntimeHandleScope::new(); let src_handle = scope.root_raw_const_ptr(src_obj); let new_obj = crate::object::js_object_alloc(0, key_count as u32); @@ -856,7 +856,7 @@ fn js_structured_clone_inner(value: f64, depth: usize) -> f64 { let cloned_obj = crate::object::js_object_clone_with_extra(value, 0, std::ptr::null(), 0); if !cloned_obj.is_null() && (cloned_obj as usize) > 0x10000 { - let field_count = (*cloned_obj).field_count; + let field_count = crate::object::object_live_slot_count(cloned_obj); let fields = (cloned_obj as *mut u8) .add(std::mem::size_of::()) as *mut f64; diff --git a/crates/perry-runtime/src/child_process/v8_serde.rs b/crates/perry-runtime/src/child_process/v8_serde.rs index d57f1c5e4b..6b8823f522 100644 --- a/crates/perry-runtime/src/child_process/v8_serde.rs +++ b/crates/perry-runtime/src/child_process/v8_serde.rs @@ -589,7 +589,7 @@ impl Serializer { return 0; } let keys_len = (*keys_arr).length; - let num_fields = (*obj).field_count; + let num_fields = crate::object::object_live_slot_count(obj); let alloc_limit = std::cmp::max(num_fields, crate::object::INLINE_SLOT_FLOOR as u32); let fields_ptr = (obj as *const u8).add(std::mem::size_of::()) as *const f64; let mut count = 0u64; diff --git a/crates/perry-runtime/src/collection_iter_object.rs b/crates/perry-runtime/src/collection_iter_object.rs index 27d000378d..a90e58489a 100644 --- a/crates/perry-runtime/src/collection_iter_object.rs +++ b/crates/perry-runtime/src/collection_iter_object.rs @@ -86,8 +86,8 @@ unsafe fn map_iter_obj_raw(map: *const MapHeader, kind: i32) -> i64 { // #7570: these entries are reached from the DECLARED-type lowering of // `m.entries()`/`.keys()`/`.values()`, so `map` can be a `class X extends // Map` instance (a plain ObjectHeader) rather than a `MapHeader`. Every - // `next()` would then read `parent_class_id ‖ field_count` as the entries - // pointer. Resolve onto the hidden backing before the iterator captures it. + // `next()` would then read `keys_array` as the entries pointer (#8113 moved + // the confusable word; the hazard is unchanged). Resolve onto the hidden backing before the iterator captures it. // Unlike the `js_map_*` entries this is not a `clean_map_ptr` caller — it // stores the raw pointer into the iterator object, so the redirect has to // happen here. diff --git a/crates/perry-runtime/src/dyn_eval/env.rs b/crates/perry-runtime/src/dyn_eval/env.rs index 7c857f6929..beb6547e52 100644 --- a/crates/perry-runtime/src/dyn_eval/env.rs +++ b/crates/perry-runtime/src/dyn_eval/env.rs @@ -179,7 +179,10 @@ fn scope_probe(env: f64, key: *const crate::string::StringHeader) -> ScopeProbe if keys.is_null() { return ScopeProbe::Bail; } - let alloc_limit = std::cmp::max((*o).field_count, crate::object::INLINE_SLOT_FLOOR as u32); + let alloc_limit = std::cmp::max( + crate::object::object_live_slot_count(o), + crate::object::INLINE_SLOT_FLOOR as u32, + ); if let Some(idx) = crate::object::prop_plan::read_plan_lookup(keys as usize, key as usize) { if idx < alloc_limit { let v = crate::object::js_object_get_field(o, idx); diff --git a/crates/perry-runtime/src/error.rs b/crates/perry-runtime/src/error.rs index 923a9edb09..2b9fd52152 100644 --- a/crates/perry-runtime/src/error.rs +++ b/crates/perry-runtime/src/error.rs @@ -4,17 +4,36 @@ use crate::string::{js_string_from_bytes, StringHeader}; -/// Object type tag for runtime type discrimination -pub const OBJECT_TYPE_REGULAR: u32 = 1; +/// `ErrorHeader`'s own leading discriminator. #8113 removed the punned +/// `ObjectHeader::object_type` word, so this is no longer an ObjectHeader tag — +/// it is only `ErrorHeader`'s first field, and NOTHING may read it off an +/// untyped pointer. Use [`ptr_is_native_error`] to ask the question. pub const OBJECT_TYPE_ERROR: u32 = 2; -/// #1789: a heap "class object" — the value a class EXPRESSION evaluates to -/// (a regular object stamped with the compile-time template's `class_id`, -/// carrying per-evaluation static fields as own properties). Marks the value -/// as the CLASS itself (vs an instance) so `typeof` is "function", and -/// `new`/`instanceof` read `class_id` from the object. Own-field get/set -/// treat it like OBJECT_TYPE_REGULAR (the get/set paths are gated on -/// `gc_type`/`class_id`, not on this tag). -pub const OBJECT_TYPE_CLASS: u32 = 3; + +/// The authoritative "is this a native `ErrorHeader`?" test. +/// +/// # Why this exists (#8113) +/// +/// `ObjectHeader` used to open with an `object_type: u32` prefix-punned against +/// `ErrorHeader`'s, so seven sites answered this question with a raw +/// `*(ptr as *const u32) == OBJECT_TYPE_ERROR` on an untyped pointer. Deleting +/// that word makes offset 0 `class_id`, and `OBJECT_TYPE_ERROR` is **2** — an +/// ordinary user class id. The raw read would therefore reclassify the second +/// class a program declares as an `ErrorHeader` and read `message` / `name` / +/// `stack` / `errors` out of its field slots: a silent type confusion of +/// exactly the #8100 shape. +/// +/// Every `ErrorHeader` is arena-allocated with `GC_TYPE_ERROR` (`alloc_error`, +/// the only allocation site), and no other cell uses that kind, so the GC +/// header discriminates exactly. This is the same move #8086 made for +/// `object_is_regular`. +#[inline] +pub(crate) unsafe fn ptr_is_native_error(addr: usize) -> bool { + crate::value::addr_class::try_read_gc_header(addr).is_some_and(|header| { + header.obj_type == crate::gc::GC_TYPE_ERROR + && header.gc_flags & crate::gc::GC_FLAG_FORWARDED == 0 + }) +} /// Error subclass discriminator (stored in `error_kind`). /// Used by `instanceof TypeError` etc. to check kind without name string compare. @@ -747,8 +766,7 @@ pub extern "C" fn js_error_is_error(value: f64) -> f64 { if ptr.is_null() || !crate::object::is_valid_obj_ptr(ptr) { return f64::from_bits(crate::value::TAG_FALSE); } - let object_type = std::ptr::read(ptr as *const u32); - if object_type == OBJECT_TYPE_ERROR { + if ptr_is_native_error(ptr as usize) { return f64::from_bits(crate::value::TAG_TRUE); } } @@ -1512,8 +1530,8 @@ pub extern "C" fn js_error_get_cause(error: *mut ErrorHeader) -> f64 { /// re-applies `POINTER_TAG` to the result. That is only sound when `obj` is a /// genuine native error: the `errors` field lives at a fixed byte offset in /// `ErrorHeader`, so applying it to a *regular* user object reads an unrelated -/// property slot. Observed in the wild: a plain object (`object_type == -/// OBJECT_TYPE_REGULAR`) whose `+48` slot held NaN-boxed `undefined` +/// property slot. Observed in the wild: a plain object whose `+48` slot held +/// NaN-boxed `undefined` /// (`0x7FFC_0000_0000_0001`); codegen OR-ed `POINTER_TAG` onto it to produce /// `0x7FFD_0000_0000_0001` — a handle-band id (`raw = 1`), not a heap array — /// which `for…of` then mis-iterated ("Iterator result is not an object"). @@ -1536,11 +1554,10 @@ pub extern "C" fn js_error_get_errors(error: *mut ErrorHeader) -> *mut crate::ar if !crate::value::addr_class::is_plausible_heap_addr(addr) { return std::ptr::null_mut(); } - // Native error objects carry `object_type == OBJECT_TYPE_ERROR` in - // their first u32; only those have the `errors` field at a fixed - // offset. (Matches the validation in `js_error_is_error`.) - let object_type = std::ptr::read(error as *const u32); - if object_type == OBJECT_TYPE_ERROR { + // Native error objects are the `GC_TYPE_ERROR` cells `alloc_error` + // makes; only those have the `errors` field at a fixed offset. + // (Matches the validation in `js_error_is_error`.) + if ptr_is_native_error(error as usize) { return (*error).errors; } // Not a native error — resolve `.errors` as an ordinary own property diff --git a/crates/perry-runtime/src/exception.rs b/crates/perry-runtime/src/exception.rs index ef184327c1..6c9c0017ae 100644 --- a/crates/perry-runtime/src/exception.rs +++ b/crates/perry-runtime/src/exception.rs @@ -503,8 +503,12 @@ pub(crate) fn print_uncaught(value: f64) { if top16 == 0x7FFD { let ptr = (bits & 0x0000_FFFF_FFFF_FFFF) as usize; if ptr >= 0x10000 { - let object_type = unsafe { *(ptr as *const u32) }; - if object_type == crate::error::OBJECT_TYPE_ERROR { + // #8113: both discriminators come from the GC header / ShapeId + // descriptor now. Offset 0 is `class_id`, so the old raw + // `*(ptr as *const u32)` read would classify the second class a + // program declares (`class_id == 2 == OBJECT_TYPE_ERROR`) as an + // Error and print `name`/`message`/`stack` out of its field slots. + if unsafe { crate::error::ptr_is_native_error(ptr) } { // ErrorHeader: object_type, error_kind, message, name, stack, cause, errors let eh = ptr as *const crate::error::ErrorHeader; let name_str = unsafe { string_header_to_string((*eh).name) }; @@ -543,7 +547,9 @@ pub(crate) fn print_uncaught(value: f64) { } return; } - if object_type == crate::error::OBJECT_TYPE_REGULAR { + if unsafe { + crate::object::object_is_regular(ptr as *const crate::object::ObjectHeader) + } { // Probe for `.message` and `.stack` properties the way // Node does for thrown non-Error objects. Users commonly // throw custom error shapes like `{ message, stack }` or diff --git a/crates/perry-runtime/src/gc/heap_snapshot.rs b/crates/perry-runtime/src/gc/heap_snapshot.rs index a662247855..3690dd6373 100644 --- a/crates/perry-runtime/src/gc/heap_snapshot.rs +++ b/crates/perry-runtime/src/gc/heap_snapshot.rs @@ -311,7 +311,7 @@ pub fn gc_build_v8_heap_snapshot_json() -> String { let fc = unsafe { crate::object::shapes::object_shape_descriptor(obj) .map(|descriptor| descriptor.live_inline_slot_count as usize) - .unwrap_or((*obj).field_count as usize) + .unwrap_or(crate::object::object_live_slot_count(obj) as usize) }; if fc <= 10_000 { ( diff --git a/crates/perry-runtime/src/gc/layout.rs b/crates/perry-runtime/src/gc/layout.rs index a081d9999e..c5b7d65820 100644 --- a/crates/perry-runtime/src/gc/layout.rs +++ b/crates/perry-runtime/src/gc/layout.rs @@ -218,7 +218,7 @@ unsafe fn with_shape_shared_descriptor( let object = user_ptr as *const crate::object::ObjectHeader; let field_count = crate::object::shapes::object_shape_descriptor(object) .map(|descriptor| descriptor.live_inline_slot_count as usize) - .unwrap_or((*object).field_count as usize); + .unwrap_or(crate::object::object_live_slot_count(object) as usize); let map = hot_shape_layouts().borrow(); let desc = map.get(&keys)?.as_ref()?; if desc.slot_count != field_count { @@ -690,7 +690,7 @@ pub(crate) fn layout_note_slot(parent_user: usize, slot_index: usize, value_bits let object = parent_user as *const crate::object::ObjectHeader; let live_slots = crate::object::shapes::object_shape_descriptor(object) .map(|descriptor| descriptor.live_inline_slot_count as usize) - .unwrap_or((*object).field_count as usize); + .unwrap_or(crate::object::object_live_slot_count(object) as usize); if slot_index < live_slots { return; } @@ -1030,7 +1030,7 @@ unsafe fn init_typed_shape_layout( let shape_descriptor = crate::object::shapes::object_shape_descriptor(obj_header); let object_slot_count = shape_descriptor .map(|descriptor| descriptor.live_inline_slot_count as usize) - .unwrap_or((*obj_header).field_count as usize); + .unwrap_or(crate::object::object_live_slot_count(obj_header) as usize); if object_slot_count != slot_count { layout_set_typed_unknown(header, user_ptr); return; diff --git a/crates/perry-runtime/src/gc/layout_slot_visit.rs b/crates/perry-runtime/src/gc/layout_slot_visit.rs index fa917ca842..83b9b8f5fe 100644 --- a/crates/perry-runtime/src/gc/layout_slot_visit.rs +++ b/crates/perry-runtime/src/gc/layout_slot_visit.rs @@ -27,7 +27,7 @@ pub(super) unsafe fn visit_gc_layout_slot_descriptors( .unwrap_or((*obj).keys_array); let live_inline_slot_count = descriptor .map(|facts| facts.live_inline_slot_count) - .unwrap_or((*obj).field_count); + .unwrap_or(crate::object::object_live_slot_count(obj)); if old_keys.is_null() { Some((obj, 0, 0, live_inline_slot_count)) } else if crate::value::addr_class::try_read_tracked_gc_header(old_keys as usize) diff --git a/crates/perry-runtime/src/gc/roots/runtime_handles.rs b/crates/perry-runtime/src/gc/roots/runtime_handles.rs index 9d535a6166..b1fc83658f 100644 --- a/crates/perry-runtime/src/gc/roots/runtime_handles.rs +++ b/crates/perry-runtime/src/gc/roots/runtime_handles.rs @@ -246,7 +246,7 @@ impl<'scope> RuntimeHandle<'scope> { /// ```ignore /// let obj = obj_h.get_raw_mut_ptr::(); /// let found = class_instance_has_member(class_id, "size"); // ALLOCATES - /// (*obj).field_count // from-space + /// crate::object::object_live_slot_count(obj) // from-space /// ``` /// /// The defect is not a missing root. It is that `obj` is still *nameable* @@ -257,7 +257,7 @@ impl<'scope> RuntimeHandle<'scope> { /// let (found, obj) = obj_h.across_mut::( /// || class_instance_has_member(class_id, "size"), /// ); - /// (*obj).field_count // post-collection + /// crate::object::object_live_slot_count(obj) // post-collection /// ``` /// /// # What it does NOT do diff --git a/crates/perry-runtime/src/gc/tests/clone_keys_array_init.rs b/crates/perry-runtime/src/gc/tests/clone_keys_array_init.rs index 584b59f37f..0961254d5e 100644 --- a/crates/perry-runtime/src/gc/tests/clone_keys_array_init.rs +++ b/crates/perry-runtime/src/gc/tests/clone_keys_array_init.rs @@ -3,7 +3,7 @@ //! //! # The hazard //! -//! Both branches set `object_type`, `class_id`, `parent_class_id`, +//! Both branches set `class_id`, `parent_class_id`, //! `field_count` and `meta` immediately after allocation, then set //! `keys_array` only at the END, via `set_object_keys_array`. In between sits //! `crate::array::js_array_alloc`. diff --git a/crates/perry-runtime/src/gc/tests/copying/pointer_publish_7154.rs b/crates/perry-runtime/src/gc/tests/copying/pointer_publish_7154.rs index 78454322ad..cd2b46e64e 100644 --- a/crates/perry-runtime/src/gc/tests/copying/pointer_publish_7154.rs +++ b/crates/perry-runtime/src/gc/tests/copying/pointer_publish_7154.rs @@ -291,7 +291,7 @@ fn test_ffi_index_field_set_widens_field_count_7164() { // Mirrors `perry_ffi::alloc_object()` exactly: class_id=0, field_count=0. let obj = crate::object::js_object_alloc(0, 0); assert_eq!( - unsafe { (*obj).field_count }, + unsafe { crate::object::object_live_slot_count(obj) }, 0, "test setup: alloc_object()'s field_count starts at 0" ); @@ -324,7 +324,7 @@ fn test_ffi_index_field_set_widens_field_count_7164() { the whole payload range empty, so the mask is never consulted)" ); assert_eq!( - unsafe { (*obj).field_count }, + unsafe { crate::object::object_live_slot_count(obj) }, 1, "#7164: js_object_set_field must widen field_count to cover the \ written index, mirroring field_set_by_name/tail.rs's publication order" diff --git a/crates/perry-runtime/src/gc/tests/cycle_state.rs b/crates/perry-runtime/src/gc/tests/cycle_state.rs index 34150afa7b..d4e66e2a76 100644 --- a/crates/perry-runtime/src/gc/tests/cycle_state.rs +++ b/crates/perry-runtime/src/gc/tests/cycle_state.rs @@ -103,10 +103,8 @@ fn alloc_tracked_test_object() -> *mut crate::object::ObjectHeader { let child = gc_malloc(header_size + fields_size, GC_TYPE_OBJECT) as *mut crate::object::ObjectHeader; unsafe { - (*child).object_type = crate::error::OBJECT_TYPE_REGULAR; (*child).class_id = 0; (*child).parent_class_id = 0; - (*child).field_count = 0; (*child).keys_array = std::ptr::null_mut(); (*child).meta = std::ptr::null_mut(); let fields_ptr = (child as *mut u8).add(header_size) as *mut crate::JSValue; @@ -932,11 +930,20 @@ fn gap_born_child_stored_between_finalize_and_sweep_survives() { ); unsafe { let obj = child as *mut crate::object::ObjectHeader; + // #8113: `object_type == 1` (the old canary at offset 0) is gone. The + // shape word replaces it and is a STRONGER canary: the overflow store + // above published a ShapeId into it, so it holds a value in a narrow + // 2^30-wide range that arbitrary recycled bytes would not land in. assert_eq!( - (*obj).object_type, - 1, + (*obj).class_id, + 0, "gap-born child payload clobbered after sweep" ); + assert!( + crate::object::shapes::is_shape_id((*obj).parent_class_id), + "gap-born child payload clobbered after sweep: shape word is {:#x}", + (*obj).parent_class_id + ); } crate::object::test_clear_overflow_fields_root(); } diff --git a/crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs b/crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs index efc33edead..6a1d1b8a20 100644 --- a/crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs +++ b/crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs @@ -80,10 +80,10 @@ unsafe fn alloc_malloc_test_object() -> *mut crate::object::ObjectHeader { std::mem::size_of::(), GC_TYPE_OBJECT, ) as *mut crate::object::ObjectHeader; - (*obj).object_type = 1; (*obj).class_id = 0; + // #8113: zero live slots, so no descriptor is needed — the derived bound + // for an unstamped receiver is 0, which is the right answer here. (*obj).parent_class_id = 0; - (*obj).field_count = 0; (*obj).keys_array = std::ptr::null_mut(); (*obj).meta = std::ptr::null_mut(); obj diff --git a/crates/perry-runtime/src/gc/tests/layout_trace/typed_shape.rs b/crates/perry-runtime/src/gc/tests/layout_trace/typed_shape.rs index d7840aff82..82b80dbd6c 100644 --- a/crates/perry-runtime/src/gc/tests/layout_trace/typed_shape.rs +++ b/crates/perry-runtime/src/gc/tests/layout_trace/typed_shape.rs @@ -462,7 +462,7 @@ fn test_typed_shape_descriptor_growing_new_field_falls_back() { crate::object::js_object_set_field_by_name(obj, extra_key, 42.0); unsafe { - assert_eq!((*obj).field_count, 2); + assert_eq!(crate::object::object_live_slot_count(obj), 2); } assert_eq!(test_layout_pointer_slot_count(obj as usize, 2), None); diff --git a/crates/perry-runtime/src/gc/tests/shape_descriptor_authority.rs b/crates/perry-runtime/src/gc/tests/shape_descriptor_authority.rs index 5b11c4746e..ffacd8fd46 100644 --- a/crates/perry-runtime/src/gc/tests/shape_descriptor_authority.rs +++ b/crates/perry-runtime/src/gc/tests/shape_descriptor_authority.rs @@ -35,11 +35,12 @@ fn gc_recovers_keys_and_live_slots_from_shape_id_after_header_sabotage() { assert_eq!(descriptor.logical_key_count, 2); assert_eq!(descriptor.live_inline_slot_count, 2); - // These are ABI mirrors until #8047. Corrupt both to prove the GC walk - // derives its strong keys edge and exact payload range from ShapeId. + // `keys_array` is the last ABI mirror (#8047 removes it; #8113 + // already removed `field_count`). Corrupt it to prove the GC walk + // derives its strong keys edge — and, since the payload range now + // has NO header mirror at all, its exact slot count — from ShapeId. // GC_STORE_AUDIT(POINTER_FREE): test sabotage removes the compatibility edge by storing null. (*obj).keys_array = std::ptr::null_mut(); - (*obj).field_count = 0; let slots = super::support::test_heap_child_slots_for_user(obj as *mut u8); assert_eq!((*obj).keys_array as u64, descriptor.keys); diff --git a/crates/perry-runtime/src/gc/tests/support.rs b/crates/perry-runtime/src/gc/tests/support.rs index 38bb9da2e5..d3e961b835 100644 --- a/crates/perry-runtime/src/gc/tests/support.rs +++ b/crates/perry-runtime/src/gc/tests/support.rs @@ -810,13 +810,25 @@ pub(super) fn tracked_malloc_headers_matching(headers: &[usize]) -> usize { pub(super) unsafe fn alloc_old_test_object( field_count: u32, ) -> (*mut crate::object::ObjectHeader, *mut u64) { + // #8113: the live inline-slot bound lives ONLY in the ShapeId descriptor, + // so a raw fixture has to publish one or the collector traces zero slots. + // Mint the id BEFORE the object exists: minting inserts into the shape + // table and can therefore collect, and this fixture holds no handle on the + // fresh header. + // A zero-slot fixture needs no descriptor at all — the derived bound is 0 + // either way — and minting one would perturb the descriptor-count + // accounting that sibling tests assert on. + let shape_id = if field_count == 0 { + 0 + } else { + crate::object::shapes::shape_descriptor_ensure(std::ptr::null(), 0, field_count) + .expect("shape id range exhausted in a test fixture") + }; let payload = std::mem::size_of::() + field_count as usize * 8; let obj = crate::arena::arena_alloc_gc_old(payload, 8, GC_TYPE_OBJECT) as *mut crate::object::ObjectHeader; - (*obj).object_type = 1; (*obj).class_id = 0; - (*obj).parent_class_id = 0; - (*obj).field_count = field_count; + (*obj).parent_class_id = shape_id; (*obj).keys_array = std::ptr::null_mut(); (*obj).meta = std::ptr::null_mut(); let fields = @@ -830,13 +842,22 @@ pub(super) unsafe fn alloc_old_test_object( pub(super) unsafe fn alloc_nursery_test_object( field_count: u32, ) -> (*mut crate::object::ObjectHeader, *mut u64) { + // #8113: see `alloc_old_test_object` — mint the descriptor first, then + // stamp the fresh header with a plain store. + // A zero-slot fixture needs no descriptor at all — the derived bound is 0 + // either way — and minting one would perturb the descriptor-count + // accounting that sibling tests assert on. + let shape_id = if field_count == 0 { + 0 + } else { + crate::object::shapes::shape_descriptor_ensure(std::ptr::null(), 0, field_count) + .expect("shape id range exhausted in a test fixture") + }; let payload = std::mem::size_of::() + field_count as usize * 8; let obj = crate::arena::arena_alloc_gc(payload, 8, GC_TYPE_OBJECT) as *mut crate::object::ObjectHeader; - (*obj).object_type = 1; (*obj).class_id = 0; - (*obj).parent_class_id = 0; - (*obj).field_count = field_count; + (*obj).parent_class_id = shape_id; (*obj).keys_array = std::ptr::null_mut(); (*obj).meta = std::ptr::null_mut(); let fields = diff --git a/crates/perry-runtime/src/gc/types.rs b/crates/perry-runtime/src/gc/types.rs index b20cc30127..4af28ec24d 100644 --- a/crates/perry-runtime/src/gc/types.rs +++ b/crates/perry-runtime/src/gc/types.rs @@ -1091,7 +1091,9 @@ pub const OBJ_FLAG_HAS_DESCRIPTORS: u16 = 0x800; /// Heap class-expression value (`class C {}`), as distinct from an ordinary /// instance carrying the same `GC_TYPE_OBJECT` allocation tag. This is the /// authoritative replacement for `ObjectHeader::object_type == -/// OBJECT_TYPE_CLASS`; the legacy payload word remains an ABI mirror until +/// OBJECT_TYPE_CLASS`; #8113 deleted that legacy payload word — the note below +/// is history, kept because it explains why the kind lives in the descriptor +/// rather than in /// #8047 removes it. Bit 13 is preserved by survival-age and layout-state /// updates and is otherwise unused for `GC_TYPE_OBJECT`. // #2145: this object is a per-kind `.prototype` whose diff --git a/crates/perry-runtime/src/intl/install.rs b/crates/perry-runtime/src/intl/install.rs index 9d0309bf19..356796248b 100644 --- a/crates/perry-runtime/src/intl/install.rs +++ b/crates/perry-runtime/src/intl/install.rs @@ -43,8 +43,9 @@ pub(super) fn install_constructor( let ctor_value = js_nanbox_pointer(ctor as i64); // Generous inline capacity so installing methods plus an accessor getter and - // the toStringTag symbol never bumps `field_count` past the physical slot - // count (which would expose an overflow slot — keys_array.rs #4099). + // the toStringTag symbol never bumps the live inline-slot count past the + // physical slot count (which would expose an overflow slot — + // keys_array.rs #4099). let proto = js_object_alloc(0, 16); set_field(proto, "constructor", ctor_value); set_builtin_attrs(proto, "constructor", PropertyAttrs::new(true, false, true)); diff --git a/crates/perry-runtime/src/json/mod.rs b/crates/perry-runtime/src/json/mod.rs index c64d190f89..a543e7bce8 100644 --- a/crates/perry-runtime/src/json/mod.rs +++ b/crates/perry-runtime/src/json/mod.rs @@ -1364,7 +1364,11 @@ mod tests { let arr = (value.bits() & POINTER_MASK) as *mut crate::ArrayHeader; let elem0 = crate::array::js_array_get(arr, 0); let obj = (elem0.bits() & POINTER_MASK) as *const crate::ObjectHeader; - (value, (*obj).field_count, (*(*obj).keys_array).length) + ( + value, + crate::object::object_live_slot_count(obj), + (*(*obj).keys_array).length, + ) } #[test] @@ -1497,7 +1501,7 @@ mod tests { let key = js_string_from_bytes(name.as_ptr(), name.len() as u32); crate::object::js_object_set_field_by_name(obj, key, base + i as f64); } - assert!((*obj).field_count >= (*(*obj).keys_array).length); + assert!(crate::object::object_live_slot_count(obj) >= (*(*obj).keys_array).length); arr = crate::array::js_array_push(arr, JSValue::object_ptr(obj as *mut u8)); } let boxed = crate::value::js_nanbox_pointer(arr as i64); diff --git a/crates/perry-runtime/src/json/replacer.rs b/crates/perry-runtime/src/json/replacer.rs index bd707c3a4e..7db5087c08 100644 --- a/crates/perry-runtime/src/json/replacer.rs +++ b/crates/perry-runtime/src/json/replacer.rs @@ -378,7 +378,7 @@ pub(crate) unsafe fn stringify_object_with_replacer_pretty( let obj_root = gc_scope.root_raw_const_ptr(ptr); let replacer_root = gc_scope.root_raw_const_ptr(replacer); let obj = ptr as *const crate::ObjectHeader; - let num_fields = (*obj).field_count; + let num_fields = crate::object::object_live_slot_count(obj); let Some(keys_arr) = super::stringify::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 @@ -958,7 +958,7 @@ pub(crate) unsafe fn stringify_object_pretty( } let obj = ptr as *const crate::ObjectHeader; - let num_fields = (*obj).field_count; + let num_fields = crate::object::object_live_slot_count(obj); let Some(keys_arr) = super::stringify::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 @@ -1148,7 +1148,7 @@ pub(crate) unsafe fn stringify_object_with_array_replacer( STRINGIFY_STACK.with(|s| s.borrow_mut().push(ptr as usize)); let obj = ptr as *const crate::ObjectHeader; - let num_fields = (*obj).field_count; + let num_fields = crate::object::object_live_slot_count(obj); let Some(keys_arr) = super::stringify::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 diff --git a/crates/perry-runtime/src/json/stringify.rs b/crates/perry-runtime/src/json/stringify.rs index e37c3f1650..8ce59f23f7 100644 --- a/crates/perry-runtime/src/json/stringify.rs +++ b/crates/perry-runtime/src/json/stringify.rs @@ -98,7 +98,7 @@ pub(crate) unsafe fn is_object_pointer(ptr: *const u8) -> bool { let keys_arr = (*obj).keys_array; let keys_len = (*keys_arr).length; let keys_cap = (*keys_arr).capacity; - let field_count = (*obj).field_count; + let field_count = crate::object::object_live_slot_count(obj); // keys_len is authoritative — the logical property count. field_count // can be EITHER less than keys_len (parser-built objects with ≥9 // fields cap field_count at the inline alloc_limit; closes #307; @@ -972,7 +972,7 @@ pub(crate) unsafe fn stringify_object_inner(ptr: *const u8, buf: &mut String, de } let obj = ptr as *const crate::ObjectHeader; - let num_fields = (*obj).field_count; + 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 @@ -1661,7 +1661,7 @@ pub(crate) unsafe fn estimate_json_size(value: f64, type_hint: u32) -> usize { } if type_hint == TYPE_OBJECT || is_object_pointer(ptr) { let obj = ptr as *const crate::ObjectHeader; - let fields = (*obj).field_count as usize; + let fields = crate::object::object_live_slot_count(obj) as usize; return (fields * 200).max(256); } } diff --git a/crates/perry-runtime/src/json/stringify_shape_template.rs b/crates/perry-runtime/src/json/stringify_shape_template.rs index 3f5e26b567..087fbc29f2 100644 --- a/crates/perry-runtime/src/json/stringify_shape_template.rs +++ b/crates/perry-runtime/src/json/stringify_shape_template.rs @@ -241,7 +241,10 @@ pub(crate) unsafe fn build_shape_prefix_template(first_elem_bits: u64) -> Option /// above it live in overflow storage, not in the inline region. #[inline] unsafe fn object_alloc_limit(obj: *const crate::ObjectHeader) -> u32 { - std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32) + std::cmp::max( + crate::object::object_live_slot_count(obj), + crate::object::INLINE_SLOT_FLOOR as u32, + ) } /// Read shape-template field slot `f` of `obj`: inline when it fits in the diff --git a/crates/perry-runtime/src/json_tape_tests.rs b/crates/perry-runtime/src/json_tape_tests.rs index d5f335e01f..deab72b966 100644 --- a/crates/perry-runtime/src/json_tape_tests.rs +++ b/crates/perry-runtime/src/json_tape_tests.rs @@ -129,7 +129,7 @@ fn recursive_materializer_reserves_exact_spill_per_object_depth() { unsafe { assert_eq!( - (*object).field_count, + crate::object::object_live_slot_count(object), crate::object::INLINE_SLOT_FLOOR as u32, "known width must not enlarge the primary object" ); @@ -140,7 +140,7 @@ fn recursive_materializer_reserves_exact_spill_per_object_depth() { assert_eq!((*spill).length, 3); assert_eq!( - (*nested).field_count, + crate::object::object_live_slot_count(nested), crate::object::INLINE_SLOT_FLOOR as u32 ); let nested_spill = @@ -187,7 +187,7 @@ fn iterative_materializer_reserves_exact_spill_without_widening_object() { let object = (value.bits() & crate::value::POINTER_MASK) as *const crate::ObjectHeader; unsafe { assert_eq!( - (*object).field_count, + crate::object::object_live_slot_count(object), crate::object::INLINE_SLOT_FLOOR as u32 ); let spill = diff --git a/crates/perry-runtime/src/lib.rs b/crates/perry-runtime/src/lib.rs index 863e19337d..c598efcadd 100644 --- a/crates/perry-runtime/src/lib.rs +++ b/crates/perry-runtime/src/lib.rs @@ -279,6 +279,7 @@ pub use buffer::BufferHeader; pub use closure::ClosureHeader; pub use map::MapHeader; pub use object::ObjectHeader; +pub use object::{object_live_slot_count, perry_object_header_abi_revision}; pub use promise::Promise; pub use regex::RegExpHeader; pub use set::SetHeader; diff --git a/crates/perry-runtime/src/map.rs b/crates/perry-runtime/src/map.rs index 65555e5141..856915bf0a 100644 --- a/crates/perry-runtime/src/map.rs +++ b/crates/perry-runtime/src/map.rs @@ -782,7 +782,8 @@ fn map_receiver_identity(map: *const MapHeader) -> *const MapHeader { /// redirected onto that backing (#7570); /// * a plain object that was merely *annotated* `Map` — resolved to /// null, so every entry point degrades through its existing null branch -/// instead of reading `parent_class_id ‖ field_count` as `entries`. +/// instead of reading `ObjectHeader.keys_array` as `entries` (#8113 moved +/// which word lands there; the hazard is unchanged). /// /// Anything with no readable `GcHeader` (handle-band ids, tag remnants, /// non-pointer garbage) is passed through unchanged: that is exactly the diff --git a/crates/perry-runtime/src/object/alloc.rs b/crates/perry-runtime/src/object/alloc.rs index 397b3b0456..cd09978894 100644 --- a/crates/perry-runtime/src/object/alloc.rs +++ b/crates/perry-runtime/src/object/alloc.rs @@ -167,10 +167,8 @@ pub extern "C" fn js_object_alloc_with_parent( unsafe { // Initialize header - (*ptr).object_type = crate::error::OBJECT_TYPE_REGULAR; (*ptr).class_id = class_id; (*ptr).parent_class_id = parent_class_id; - (*ptr).field_count = field_count; // GC_STORE_AUDIT(INIT): fresh object starts with no per-object meta record (#6759 B). (*ptr).meta = ptr::null_mut(); // GC_STORE_AUDIT(INIT): freshly allocated object starts with no keys-array edge. @@ -185,7 +183,8 @@ pub extern "C" fn js_object_alloc_with_parent( ptr::write(fields_ptr.add(i), JSValue::undefined()); } crate::gc::layout_init_pointer_free(ptr as *mut u8); - crate::object::shapes::synchronize_object_shape_descriptor(ptr); + // #8113: the birth live-slot bound is published here and nowhere else. + crate::object::shapes::birth_publish_object_shape(ptr, field_count); ptr } @@ -205,16 +204,15 @@ pub extern "C" fn js_object_alloc_fast(class_id: u32, field_count: u32) -> *mut unsafe { // Initialize header only - fields left uninitialized for constructor to fill - (*ptr).object_type = crate::error::OBJECT_TYPE_REGULAR; (*ptr).class_id = class_id; (*ptr).parent_class_id = 0; - (*ptr).field_count = field_count; // GC_STORE_AUDIT(INIT): fresh object starts with no per-object meta record (#6759 B). (*ptr).meta = ptr::null_mut(); // GC_STORE_AUDIT(INIT): freshly allocated object starts with no keys-array edge. (*ptr).keys_array = ptr::null_mut(); crate::gc::layout_init_pointer_free(ptr as *mut u8); - crate::object::shapes::synchronize_object_shape_descriptor(ptr); + // #8113: the birth live-slot bound is published here and nowhere else. + crate::object::shapes::birth_publish_object_shape(ptr, field_count); } ptr @@ -240,16 +238,15 @@ pub extern "C" fn js_object_alloc_fast_with_parent( let ptr = arena_alloc_gc(total_size, 8, crate::gc::GC_TYPE_OBJECT) as *mut ObjectHeader; unsafe { - (*ptr).object_type = crate::error::OBJECT_TYPE_REGULAR; (*ptr).class_id = class_id; (*ptr).parent_class_id = parent_class_id; - (*ptr).field_count = field_count; // GC_STORE_AUDIT(INIT): fresh object starts with no per-object meta record (#6759 B). (*ptr).meta = ptr::null_mut(); // GC_STORE_AUDIT(INIT): freshly allocated object starts with no keys-array edge. (*ptr).keys_array = ptr::null_mut(); crate::gc::layout_init_pointer_free(ptr as *mut u8); - crate::object::shapes::synchronize_object_shape_descriptor(ptr); + // #8113: the birth live-slot bound is published here and nowhere else. + crate::object::shapes::birth_publish_object_shape(ptr, field_count); } ptr @@ -270,12 +267,15 @@ pub extern "C" fn js_object_alloc_fast_with_parent( /// the `arena_alloc_gc` call — into the user's `new ClassName()` /// site, eliminating function-call overhead from the hot loop. #[inline] +/// Returns the header plus the BIRTH live inline-slot bound the allocation was +/// sized for. #8113: the header no longer carries a `field_count` word, so the +/// widened bound this computes has to travel back to the caller that stamps it. fn object_alloc_class_inline_keys_impl( class_id: u32, parent_class_id: u32, field_count: u32, keys_array: *mut ArrayHeader, -) -> *mut ObjectHeader { +) -> (*mut ObjectHeader, u32) { if parent_class_id != 0 { register_class(class_id, parent_class_id); } @@ -297,13 +297,13 @@ fn object_alloc_class_inline_keys_impl( let ptr = arena_alloc_gc(total_size, 8, crate::gc::GC_TYPE_OBJECT) as *mut ObjectHeader; unsafe { - (*ptr).object_type = crate::error::OBJECT_TYPE_REGULAR; (*ptr).class_id = class_id; (*ptr).parent_class_id = parent_class_id; - (*ptr).field_count = logical_field_count as u32; // GC_STORE_AUDIT(INIT): fresh object starts with no per-object meta record (#6759 B). (*ptr).meta = ptr::null_mut(); - set_object_keys_array(ptr, keys_array); + // #8113: the birth live-slot bound is a PARAMETER now — it used to be + // read back out of the `(*ptr).field_count` store that stood here. + set_object_keys_array_with_live(ptr, keys_array, logical_field_count as u32); // PerryTS/perry#4717: initialize ALL `max(field_count, 8)` field slots to // `undefined`, mirroring `js_object_alloc_with_parent`. The arena hands back @@ -321,7 +321,7 @@ fn object_alloc_class_inline_keys_impl( } crate::gc::layout_init_pointer_free(ptr as *mut u8); } - ptr + (ptr, logical_field_count as u32) } /// Compatibility entry point for runtime callers that do not have a @@ -341,7 +341,7 @@ pub extern "C" fn js_object_alloc_class_inline_keys( field_count: u32, keys_array: *mut ArrayHeader, ) -> *mut ObjectHeader { - let ptr = + let (ptr, birth_slots) = object_alloc_class_inline_keys_impl(class_id, parent_class_id, field_count, keys_array); unsafe { let key_count = if keys_array.is_null() { @@ -353,7 +353,7 @@ pub extern "C" fn js_object_alloc_class_inline_keys( keys_array as *const ArrayHeader, key_count, ); - crate::object::shapes::birth_stamp_object_shape(ptr, id); + crate::object::shapes::birth_stamp_object_shape(ptr, id, birth_slots); } ptr } @@ -374,10 +374,10 @@ pub extern "C" fn js_object_alloc_class_inline_keys_stamped( keys_array: *mut ArrayHeader, shape_id: u32, ) -> *mut ObjectHeader { - let ptr = + let (ptr, birth_slots) = object_alloc_class_inline_keys_impl(class_id, parent_class_id, field_count, keys_array); unsafe { - crate::object::shapes::birth_stamp_object_shape(ptr, shape_id); + crate::object::shapes::birth_stamp_object_shape(ptr, shape_id, birth_slots); } ptr } @@ -494,10 +494,8 @@ pub extern "C" fn js_object_alloc_class_with_keys( let ptr = arena_alloc_gc(total_size, 8, crate::gc::GC_TYPE_OBJECT) as *mut ObjectHeader; unsafe { - (*ptr).object_type = crate::error::OBJECT_TYPE_REGULAR; (*ptr).class_id = class_id; (*ptr).parent_class_id = parent_class_id; - (*ptr).field_count = field_count; // GC_STORE_AUDIT(INIT): fresh object starts with no per-object meta record (#6759 B). (*ptr).meta = ptr::null_mut(); crate::gc::layout_init_pointer_free(ptr as *mut u8); @@ -544,14 +542,14 @@ pub extern "C" fn js_object_alloc_class_with_keys( }; unsafe { - set_object_keys_array(ptr, keys_arr); + set_object_keys_array_with_live(ptr, keys_arr, field_count); // #6759 C3 rung 2, completed: birth-stamp here too. #8009 stamped the // COMPILED entry point (`js_object_alloc_class_inline_keys_stamped`) // and left this one lazily self-healing, which is a SPLIT population // for every class that lands here — and a split population is a // permanent PIC miss, not a slow start. See // `shapes::birth_stamp_object_shape`. - crate::object::shapes::birth_stamp_object_shape(ptr, runtime_shape_id); + crate::object::shapes::birth_stamp_object_shape(ptr, runtime_shape_id, field_count); } remember_class_keys_array(class_id, field_count, keys_arr); ptr @@ -663,10 +661,8 @@ pub extern "C" fn js_object_alloc_class_dynamic_parent( let total_size = header_size + fields_size; let ptr = arena_alloc_gc(total_size, 8, crate::gc::GC_TYPE_OBJECT) as *mut ObjectHeader; unsafe { - (*ptr).object_type = crate::error::OBJECT_TYPE_REGULAR; (*ptr).class_id = class_id; (*ptr).parent_class_id = parent_cid; - (*ptr).field_count = field_count; // GC_STORE_AUDIT(INIT): fresh object starts with no per-object meta record (#6759 B). (*ptr).meta = ptr::null_mut(); let fields_ptr = (ptr as *mut u8).add(header_size) as *mut JSValue; @@ -674,11 +670,11 @@ pub extern "C" fn js_object_alloc_class_dynamic_parent( // GC_STORE_AUDIT(INIT): freshly allocated object field slot is initialized pointer-free. ptr::write(fields_ptr.add(i), JSValue::undefined()); } - set_object_keys_array(ptr, merged_arr); + set_object_keys_array_with_live(ptr, merged_arr, field_count); crate::gc::layout_init_pointer_free(ptr as *mut u8); // The dynamically-parented subclass shape needs the same birth stamp // as every other class instance, or its sites split the same way. - crate::object::shapes::birth_stamp_object_shape(ptr, runtime_shape_id); + crate::object::shapes::birth_stamp_object_shape(ptr, runtime_shape_id, field_count); } remember_class_keys_array(class_id, field_count, merged_arr); ptr @@ -715,12 +711,8 @@ pub extern "C" fn js_object_alloc_with_shape( let obj_ptr = arena_alloc_gc(total_size, 8, crate::gc::GC_TYPE_OBJECT) as *mut ObjectHeader; unsafe { - (*obj_ptr).object_type = crate::error::OBJECT_TYPE_REGULAR; (*obj_ptr).class_id = 0; (*obj_ptr).parent_class_id = 0; - // field_count tracks the logical number of fields; extra allocated slots - // are available for dynamic property growth via js_object_set_field_by_name - (*obj_ptr).field_count = field_count; // GC_STORE_AUDIT(INIT): fresh object starts with no per-object meta record (#6759 B). (*obj_ptr).meta = ptr::null_mut(); @@ -780,12 +772,14 @@ pub extern "C" fn js_object_alloc_with_shape( unsafe { let obj_ptr = obj_handle.get_raw_mut_ptr::(); - set_object_keys_array(obj_ptr, keys_arr); + set_object_keys_array_with_live(obj_ptr, keys_arr, field_count); // #6804: birth-stamp the runtime ShapeId (see `ShapeCacheEntry`) — // newborn literals carry their stable identity immediately, so // typed_feedback tokens and the id-keyed FIELD_CACHE never see a // pre-stamp window for shape-cached objects. - crate::object::shapes::birth_stamp_object_shape(obj_ptr, runtime_shape_id); + // #8113: `field_count` is the LOGICAL live-slot bound; the extra + // physical slots above it stay available for dynamic growth. + crate::object::shapes::birth_stamp_object_shape(obj_ptr, runtime_shape_id, field_count); } obj_handle.get_raw_mut_ptr::() @@ -845,10 +839,8 @@ pub unsafe extern "C" fn js_object_clone_with_extra( let phys_slots = std::cmp::max(extra_count, crate::object::INLINE_SLOT_FLOOR as u32); let total_size = header_size + phys_slots as usize * 8; let new_ptr = arena_alloc_gc(total_size, 8, crate::gc::GC_TYPE_OBJECT) as *mut ObjectHeader; - (*new_ptr).object_type = crate::error::OBJECT_TYPE_REGULAR; (*new_ptr).class_id = 0; (*new_ptr).parent_class_id = 0; - (*new_ptr).field_count = 0; // GC_STORE_AUDIT(INIT): fresh object starts with no per-object meta record (#6759 B). (*new_ptr).meta = ptr::null_mut(); // GC_STORE_AUDIT(INIT): freshly allocated clone starts with no keys-array @@ -874,7 +866,7 @@ pub unsafe extern "C" fn js_object_clone_with_extra( } let src_ptr = src_raw as *const ObjectHeader; - let src_field_count = (*src_ptr).field_count; + let src_field_count = crate::object::object_live_slot_count(src_ptr); // Physical slot capacity: src_field_count + extra_count, but at least max(fc, 8) to match // js_object_set_field's alloc_limit check. Extra slots are scratch space for subsequent @@ -885,12 +877,8 @@ pub unsafe extern "C" fn js_object_clone_with_extra( ); let total_size = header_size + phys_slots as usize * 8; let new_ptr = arena_alloc_gc(total_size, 8, crate::gc::GC_TYPE_OBJECT) as *mut ObjectHeader; - (*new_ptr).object_type = crate::error::OBJECT_TYPE_REGULAR; (*new_ptr).class_id = 0; (*new_ptr).parent_class_id = 0; - // Logical field count starts at src's count. js_object_set_field_by_name bumps it when - // appending new keys. - (*new_ptr).field_count = src_field_count; // GC_STORE_AUDIT(INIT): fresh object starts with no per-object meta record (#6759 B). (*new_ptr).meta = ptr::null_mut(); // GC_STORE_AUDIT(INIT): freshly allocated clone starts with no keys-array @@ -929,6 +917,13 @@ pub unsafe extern "C" fn js_object_clone_with_extra( } rebuild_object_field_layout(new_ptr, src_field_count as usize); + // #8113: publish the clone's live inline-slot bound BEFORE the first + // allocation below. `gc_field_slot_range` reads the bound from the ShapeId + // descriptor now, and everything from the arena allocation above to here is + // allocation-free, so this closes the window in which the copied + // pointer-bearing slots would be invisible to tracing (#7154/#7164). + crate::object::shapes::birth_publish_object_shape(new_ptr, src_field_count); + // Build keys array: copy ONLY src keys. Static keys are NOT added here — codegen uses // js_object_set_field_by_name for each static prop, which appends new keys via // js_array_push. Pre-size the keys capacity to avoid immediate reallocation on append. @@ -1025,7 +1020,7 @@ pub unsafe extern "C" fn js_object_copy_own_fields(dst_i64: i64, src_f64: f64) { return; } let key_count = crate::array::js_array_length(src_keys) as usize; - let src_field_count = (*src).field_count as usize; + let src_field_count = crate::object::object_live_slot_count(src) as usize; let alloc_limit = std::cmp::max(src_field_count, crate::object::INLINE_SLOT_FLOOR); let header_size = std::mem::size_of::(); let src_fields = (src as *const u8).add(header_size) as *const u64; diff --git a/crates/perry-runtime/src/object/arguments.rs b/crates/perry-runtime/src/object/arguments.rs index 3704d43449..786ab8976c 100644 --- a/crates/perry-runtime/src/object/arguments.rs +++ b/crates/perry-runtime/src/object/arguments.rs @@ -632,8 +632,10 @@ unsafe fn read_ordinary_own_value( ) -> JSValue { let keys = (*obj).keys_array; let key_count = crate::array::js_array_length(keys) as usize; - let alloc_limit = - std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32) as usize; + let alloc_limit = std::cmp::max( + crate::object::object_live_slot_count(obj), + crate::object::INLINE_SLOT_FLOOR as u32, + ) as usize; for i in 0..key_count { let key_val = crate::array::js_array_get(keys, i as u32); if crate::string::js_string_key_matches(key_val, key) { @@ -655,8 +657,10 @@ unsafe fn write_ordinary_own_value( ) { let keys = (*obj).keys_array; let key_count = crate::array::js_array_length(keys) as usize; - let alloc_limit = - std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32) as usize; + let alloc_limit = std::cmp::max( + crate::object::object_live_slot_count(obj), + crate::object::INLINE_SLOT_FLOOR as u32, + ) as usize; for i in 0..key_count { let key_val = crate::array::js_array_get(keys, i as u32); if crate::string::js_string_key_matches(key_val, key) { diff --git a/crates/perry-runtime/src/object/class_registry/parent_static.rs b/crates/perry-runtime/src/object/class_registry/parent_static.rs index 7d2e136432..d5ab57adbf 100644 --- a/crates/perry-runtime/src/object/class_registry/parent_static.rs +++ b/crates/perry-runtime/src/object/class_registry/parent_static.rs @@ -419,9 +419,10 @@ pub extern "C" fn js_get_dynamic_parent_value(class_id: u32) -> f64 { /// #1789: stamp a freshly-allocated object as a heap "class object" (the /// value a class EXPRESSION evaluates to). Transitions the authoritative -/// ShapeId descriptor kind and updates `object_type` only as a compatibility -/// mirror. Called by codegen right after `js_object_alloc` in the -/// `ClassExprFresh` lowering. +/// ShapeId descriptor kind. #8113 deleted the `object_type` compatibility +/// mirror this also used to write; the descriptor kind is the only record. +/// Called by codegen right after `js_object_alloc` in the `ClassExprFresh` +/// lowering. #[no_mangle] pub extern "C" fn js_object_mark_class(obj: i64) { if obj != 0 { @@ -434,9 +435,6 @@ pub extern "C" fn js_object_mark_class(obj: i64) { { return; } - // Compatibility mirror only; all semantic reads use the ShapeId - // descriptor kind so #8047 can remove this payload word atomically. - (*(obj as *mut ObjectHeader)).object_type = crate::error::OBJECT_TYPE_CLASS; // Becoming a class object changes dispatch semantics even though // the rooted keys and slot layout stay the same. crate::object::shapes::transition_object_shape_to_class(obj as *mut ObjectHeader); @@ -1731,8 +1729,21 @@ mod shape_authority_tests_8067 { super::js_object_mark_class(1); } + /// #8113 replaces #8067's "saved lineage beats an interim self-heal" test. + /// + /// The self-heal it modelled is GONE: `typed_feedback::object_shape` used to + /// mint a lineage-free descriptor for an unstamped receiver, which under + /// #8113 would also publish a live inline-slot bound of ZERO — a read-only + /// observation path silently truncating the object's payload. The property + /// worth pinning is now the stronger one: an unstamped receiver MISSES, and + /// observing it publishes nothing at all. + /// + /// The clear here is manufactured with a test-only helper. No production + /// path clears a stamp any more (`shapes::clear_object_shape_stamp` is + /// `#[cfg(test)]`), which is what makes the window this used to model + /// unreachable rather than merely narrow. #[test] - fn saved_class_lineage_beats_an_interim_shape_self_heal() { + fn an_unstamped_receiver_misses_instead_of_being_self_healed() { let _lock = crate::gc::global_side_table_test_lock(); unsafe { const CID: u32 = 0x8068; @@ -1749,30 +1760,36 @@ mod shape_authority_tests_8067 { predecessor.object_kind, crate::object::shapes::ShapeObjectKind::Class ); + assert_eq!(predecessor.live_inline_slot_count, 1); - // Model a re-entrant shape observer in the narrow mutation window: - // the structural mutator has saved its predecessor and cleared the - // stamp, then typed feedback defensively self-heals the object. assert!(crate::object::shapes::clear_object_shape_stamp(obj)); let (interim, obj) = obj_handle.across_mut::(|| { crate::typed_feedback::test_object_shape_token(obj as usize) }); assert_eq!( - crate::object::shapes::shape_descriptor_by_id(interim as u32) - .expect("interim descriptor") - .object_kind, - crate::object::shapes::ShapeObjectKind::Ordinary, - "test premise: a lineage-free self-heal is ordinary" + interim, 0, + "an unstamped receiver must MISS; minting a lineage-free \ + descriptor for it would publish a zero live-slot bound" + ); + assert!( + crate::object::shapes::object_shape_descriptor(obj).is_none(), + "observing an unstamped receiver must not publish a descriptor" ); - crate::object::shapes::synchronize_object_shape_descriptor_from(obj, Some(predecessor)); + // The mutator's saved lineage still restores both facts exactly. + crate::object::shapes::synchronize_object_shape_descriptor_from( + obj, + Some(predecessor), + predecessor.live_inline_slot_count, + ); + let restored = + crate::object::shapes::object_shape_descriptor(obj).expect("restored descriptor"); assert_eq!( - crate::object::shapes::object_shape_descriptor(obj) - .expect("restored descriptor") - .object_kind, + restored.object_kind, crate::object::shapes::ShapeObjectKind::Class, - "the mutator's saved semantic lineage must outrank an interim self-heal" + "the mutator's saved semantic lineage must survive the window" ); + assert_eq!(restored.live_inline_slot_count, 1); } } @@ -1803,9 +1820,9 @@ mod shape_authority_tests_8067 { crate::object::shapes::ShapeObjectKind::Class ); - // Sabotage the compatibility mirror. Classification must remain - // driven by the ShapeId descriptor transition above. - (*obj).object_type = crate::error::OBJECT_TYPE_REGULAR; + // #8113 removed the `object_type` compatibility mirror this used to + // sabotage. Classification is driven by the ShapeId descriptor + // transition above and by nothing else, so assert that directly. assert!(super::is_class_object_ptr(obj.cast())); assert!(!crate::object::object_is_regular(obj)); diff --git a/crates/perry-runtime/src/object/delete_rest.rs b/crates/perry-runtime/src/object/delete_rest.rs index 2f8443f4d9..ddc7941daf 100644 --- a/crates/perry-runtime/src/object/delete_rest.rs +++ b/crates/perry-runtime/src/object/delete_rest.rs @@ -294,7 +294,7 @@ pub extern "C" fn js_object_delete_field( // `Object.entries`, `for-in` etc. all still saw the deleted // property. Bun and Node remove the property entirely; we // match that. - let field_count = (*obj).field_count; + let field_count = crate::object::object_live_slot_count(obj); let alloc_limit = std::cmp::max(field_count as usize, crate::object::INLINE_SLOT_FLOOR); let new_count = key_count - 1; @@ -411,8 +411,23 @@ pub extern "C" fn js_object_delete_field( // IN PLACE (which is what the comment above describes and what // `shape_slot_lookup`'s shrink check already anticipates), so // deleting it would silently make that path wrong. - crate::object::shapes::clear_object_shape_stamp(obj); - crate::object::shapes::synchronize_object_shape_descriptor_from(obj, predecessor); + // #8113: no `clear_object_shape_stamp` here any more. The stamp is now + // the ONLY record of the live inline-slot bound, so clearing it — even + // for the two statements it used to be cleared across — makes the + // object's payload untraceable if a collection lands in between (the + // publication below inserts into the shape table and can allocate). The + // republication is mint-then-stamp, which subsumes what the clear was + // for: the successor descriptor is minted while the predecessor is + // still installed, and the receiver's shape changes at the single + // `parent_class_id` store. + // The bound is the POST-compaction one published in step 3 above; + // `predecessor` contributes only semantic lineage (class kind / + // generation), never structural facts. + crate::object::shapes::synchronize_object_shape_descriptor_from( + obj, + predecessor, + crate::object::object_live_slot_count(obj), + ); 1 } @@ -692,7 +707,10 @@ mod shape_transition_tests_6759 { .expect("delete must publish a by-id descriptor"); assert_eq!(descriptor.keys, (*obj).keys_array as u64); assert_eq!(descriptor.logical_key_count, 2); - assert_eq!(descriptor.live_inline_slot_count, (*obj).field_count); + assert_eq!( + descriptor.live_inline_slot_count, + crate::object::object_live_slot_count(obj) + ); } } @@ -769,7 +787,10 @@ mod shape_transition_tests_6759 { .expect("class delete must publish a by-id descriptor"); assert_eq!(descriptor.keys, (*obj).keys_array as u64); assert_eq!(descriptor.logical_key_count, 2); - assert_eq!(descriptor.live_inline_slot_count, (*obj).field_count); + assert_eq!( + descriptor.live_inline_slot_count, + crate::object::object_live_slot_count(obj) + ); // Still true, and still what the guard compares until rung 3. assert_ne!( diff --git a/crates/perry-runtime/src/object/field_get_set/accessors.rs b/crates/perry-runtime/src/object/field_get_set/accessors.rs index 110bfe7e00..7a7b6e3837 100644 --- a/crates/perry-runtime/src/object/field_get_set/accessors.rs +++ b/crates/perry-runtime/src/object/field_get_set/accessors.rs @@ -36,7 +36,7 @@ pub extern "C" fn js_object_get_field(obj: *const ObjectHeader, field_index: u32 } unsafe { // Bounds check: check inline fields first, then overflow map - let fc = (*obj).field_count; + let fc = crate::object::object_live_slot_count(obj); if field_index >= fc { // Check overflow map for fields that didn't fit in inline storage return match overflow_get(obj as usize, field_index as usize) { @@ -58,7 +58,7 @@ pub extern "C" fn js_object_get_field(obj: *const ObjectHeader, field_index: u32 obj, field_index, (*obj).class_id, - (*obj).field_count + crate::object::object_live_slot_count(obj) ); return JSValue::undefined(); } @@ -94,8 +94,10 @@ pub(crate) unsafe fn own_data_field_by_name( if key_count > 65536 { return None; } - let alloc_limit = - std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32) as usize; + let alloc_limit = std::cmp::max( + crate::object::object_live_slot_count(obj), + crate::object::INLINE_SLOT_FLOOR as u32, + ) as usize; for i in 0..key_count { let key_val = crate::array::js_array_get(keys, i as u32); // #1781: accept inline SSO short keys — `is_string()` is diff --git a/crates/perry-runtime/src/object/field_get_set/enumeration.rs b/crates/perry-runtime/src/object/field_get_set/enumeration.rs index fb91554dc6..5d73a44a14 100644 --- a/crates/perry-runtime/src/object/field_get_set/enumeration.rs +++ b/crates/perry-runtime/src/object/field_get_set/enumeration.rs @@ -1389,7 +1389,7 @@ pub extern "C" fn js_object_values(obj: *const ObjectHeader) -> *mut ArrayHeader let count = if !keys.is_null() { crate::array::js_array_length(keys) as usize } else { - (*obj).field_count as usize + crate::object::object_live_slot_count(obj) as usize }; let result = crate::array::js_array_alloc(count as u32); @@ -1579,7 +1579,7 @@ pub extern "C" fn js_object_entries(obj: *const ObjectHeader) -> *mut ArrayHeade let count = if !keys.is_null() { crate::array::js_array_length(keys) as usize } else { - (*obj).field_count as usize + crate::object::object_live_slot_count(obj) as usize }; let result = crate::array::js_array_alloc(count as u32); diff --git a/crates/perry-runtime/src/object/field_get_set/field_ops.rs b/crates/perry-runtime/src/object/field_get_set/field_ops.rs index e82c912915..1b4da41048 100644 --- a/crates/perry-runtime/src/object/field_get_set/field_ops.rs +++ b/crates/perry-runtime/src/object/field_get_set/field_ops.rs @@ -124,7 +124,7 @@ pub extern "C" fn js_object_set_field(obj: *mut ObjectHeader, field_index: u32, // js_object_alloc_class_with_keys use exactly field_count slots. // We use a generous limit of max(field_count, 8) to avoid false positives from // js_object_alloc_with_shape's extra padding while still catching real overflows. - let stored_field_count = (*obj).field_count; + let stored_field_count = crate::object::object_live_slot_count(obj); let alloc_limit = std::cmp::max(stored_field_count, crate::object::INLINE_SLOT_FLOOR as u32); if field_index >= alloc_limit { @@ -162,7 +162,7 @@ pub extern "C" fn js_object_set_field(obj: *mut ObjectHeader, field_index: u32, // is undefined-initialized at allocation (`object/alloc.rs`), so // widening here can only ever expose non-pointer sentinels ahead of // the store that is about to fill this one in. - if field_index >= (*obj).field_count { + if field_index >= crate::object::object_live_slot_count(obj) { set_object_live_slot_count(obj, field_index + 1); } crate::gc::runtime_store_jsvalue_slot( diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs index 98c1b10265..9152420c31 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs @@ -152,7 +152,7 @@ pub extern "C" fn js_object_get_field_by_name( && crate::value::addr_class::is_above_handle_band(keys as usize) { let alloc_limit = std::cmp::max( - (*o).field_count, + crate::object::object_live_slot_count(o), crate::object::INLINE_SLOT_FLOOR as u32, ) as usize; if let Some(idx) = super::super::prop_plan::read_plan_lookup( diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs index a16fe7f09a..36acc0b52c 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs @@ -1590,10 +1590,12 @@ pub(crate) fn get_field_by_name_object_tail( } // Slow path: linear scan through keys array - let _field_count = (*obj).field_count as usize; + let _field_count = crate::object::object_live_slot_count(obj) as usize; - let alloc_limit = - std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32) as usize; + let alloc_limit = std::cmp::max( + crate::object::object_live_slot_count(obj), + crate::object::INLINE_SLOT_FLOOR as u32, + ) as usize; // #5054: wide objects get a validated key→index map so per-key reads // stay O(1) instead of O(key_count). A `None` falls through to the @@ -1640,10 +1642,14 @@ pub(crate) fn get_field_by_name_object_tail( // grow-reallocs and GC moves that retire `keys_id`. // #6759 C3 rung 1: class instances are stamped here too. { + // #8113: the live inline-slot bound is a parameter now. + // This is a READ path — it must not change the bound, so it + // republishes exactly what the receiver already carries. let id = super::super::shapes::stamp_object_shape( obj as *mut ObjectHeader, keys, key_count as u32, + crate::object::object_live_slot_count(obj as *const ObjectHeader), ); let store_key = if id != 0 { id as usize } else { keys_id }; let store_idx = diff --git a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs index bca9274a3b..48f1f25f33 100644 --- a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs +++ b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs @@ -621,9 +621,10 @@ pub extern "C" fn js_object_get_field_ic_miss( unsafe { // Issue #72: validate this really is a GC_TYPE_OBJECT before reading // (*obj).keys_array — otherwise an Array/String/Buffer/etc. receiver - // (whose `object_type` byte at offset 0 happens to be 1, matching - // OBJECT_TYPE_REGULAR for a length-1 array) would be treated as - // cacheable and seed the per-site PIC with garbage from element[1]. + // (whose word at offset 0 collides with a real `class_id` — since + // #8113 that is an array's `length`, so ANY length-N array impersonates + // class N) would be treated as cacheable and seed the per-site PIC with + // garbage from element[1]. // The codegen guard funnels non-OBJECT receivers here too, so this // belt-and-braces check keeps the cache from being primed with // values that would survive into the inline hot path. diff --git a/crates/perry-runtime/src/object/field_set_by_name.rs b/crates/perry-runtime/src/object/field_set_by_name.rs index bae615a342..c4ffe7c349 100644 --- a/crates/perry-runtime/src/object/field_set_by_name.rs +++ b/crates/perry-runtime/src/object/field_set_by_name.rs @@ -161,7 +161,7 @@ pub extern "C" fn js_object_set_field_by_name( set_object_keys_array(o, next_keys as *mut ArrayHeader); super::mark_object_dynamic_shape_unknown(o); let alloc_limit = std::cmp::max( - (*o).field_count, + crate::object::object_live_slot_count(o), crate::object::INLINE_SLOT_FLOOR as u32, ) as usize; if (slot_idx as usize) < alloc_limit { @@ -169,7 +169,7 @@ pub extern "C" fn js_object_set_field_by_name( .add(std::mem::size_of::()) as *mut JSValue; let slot = fields_ptr.add(slot_idx as usize); - if slot_idx >= (*o).field_count { + if slot_idx >= crate::object::object_live_slot_count(o) { set_object_live_slot_count(o, slot_idx + 1); } crate::gc::runtime_store_jsvalue_slot( diff --git a/crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs b/crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs index 53162e8adf..6cc2c33785 100644 --- a/crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs +++ b/crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs @@ -99,10 +99,12 @@ pub(crate) unsafe fn try_existing_own_data_overwrite( vbits }; super::mark_object_dynamic_shape_unknown(obj); - let alloc_limit = - std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32) as usize; + let alloc_limit = std::cmp::max( + crate::object::object_live_slot_count(obj), + crate::object::INLINE_SLOT_FLOOR as u32, + ) as usize; if (idx as usize) < alloc_limit { - if idx >= (*obj).field_count { + if idx >= crate::object::object_live_slot_count(obj) { set_object_live_slot_count(obj, idx + 1); } store_object_field_slot(obj, idx as usize, vbits); @@ -258,8 +260,10 @@ pub extern "C" fn js_object_set_field_by_name_transition_fast( set_object_keys_array(obj, next_keys as *mut ArrayHeader); super::mark_object_dynamic_shape_unknown(obj); - let alloc_limit = - std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32) as usize; + let alloc_limit = std::cmp::max( + crate::object::object_live_slot_count(obj), + crate::object::INLINE_SLOT_FLOOR as u32, + ) as usize; let slot_usize = slot_idx as usize; let vbits = value.to_bits(); let vbits = if (vbits >> 48) == 0x7FFD && (vbits & 0x0000_FFFF_FFFF_FFFF) == 0 { @@ -269,7 +273,7 @@ pub extern "C" fn js_object_set_field_by_name_transition_fast( }; if slot_usize < alloc_limit { - if slot_idx >= (*obj).field_count { + if slot_idx >= crate::object::object_live_slot_count(obj) { set_object_live_slot_count(obj, slot_idx + 1); } store_object_field_slot(obj, slot_usize, vbits); diff --git a/crates/perry-runtime/src/object/field_set_by_name/tail.rs b/crates/perry-runtime/src/object/field_set_by_name/tail.rs index e0bec0aa48..8e717383e6 100644 --- a/crates/perry-runtime/src/object/field_set_by_name/tail.rs +++ b/crates/perry-runtime/src/object/field_set_by_name/tail.rs @@ -50,7 +50,7 @@ pub(super) fn set_field_by_name_object_tail( // Safety: obj is a valid heap pointer (> 0x10000) at this point unsafe { // Validate this is an ObjectHeader, not some other heap type. Every - // shaped object has a tracked GcHeader; payload `object_type` is only + // shaped object has a tracked GcHeader; the payload's first word is only // a compatibility mirror and is never a kind fallback. // Guard: ensure we can safely read GC_HEADER_SIZE bytes before obj if (obj as usize) < crate::gc::GC_HEADER_SIZE + 0x1000 { @@ -157,7 +157,7 @@ pub(super) fn set_field_by_name_object_tail( // A RECOGNIZED non-object heap type (Map/Set/Buffer/TypedArray/…) // must never fall through to the plain-object write below: their // layouts alias ObjectHeader fields. A Map with EXACTLY one entry - // had MapHeader.size aliasing object_type == OBJECT_TYPE_REGULAR, + // had MapHeader.size aliasing the first ObjectHeader word, // so `m.customProp = 5` walked the Map's bytes as object fields — // deterministic heap corruption (2026-07-02 audit P1). The return; @@ -454,9 +454,10 @@ pub(super) fn set_field_by_name_object_tail( }; set_object_keys_array(obj, next_keys as *mut ArrayHeader); super::mark_object_dynamic_shape_unknown(obj); - let alloc_limit = - std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32) - as usize; + let alloc_limit = std::cmp::max( + crate::object::object_live_slot_count(obj), + crate::object::INLINE_SLOT_FLOOR as u32, + ) as usize; if (slot_idx as usize) < alloc_limit { // Inline the field write — `obj` has already been // validated (GC header read, type check, closure @@ -468,7 +469,7 @@ pub(super) fn set_field_by_name_object_tail( let slot = fields_ptr.add(slot_idx as usize); // Publish the expanded traced range and its exact // descriptor before the pointer-bearing slot value. - if slot_idx >= (*obj).field_count { + if slot_idx >= crate::object::object_live_slot_count(obj) { set_object_live_slot_count(obj, slot_idx + 1); } crate::gc::runtime_store_jsvalue_slot( @@ -522,7 +523,7 @@ pub(super) fn set_field_by_name_object_tail( // slot is undefined-initialized at allocation, so the widened range // can only expose non-pointer sentinels — then publish the value. // Bump field_count so Object.keys()/values()/entries() see the new property. - if (*obj).field_count == 0 { + if crate::object::object_live_slot_count(obj) == 0 { set_object_live_slot_count(obj, 1); } js_object_set_field(obj, 0, JSValue::from_bits(value.to_bits())); @@ -538,7 +539,7 @@ pub(super) fn set_field_by_name_object_tail( // #6759 C3 rung 1: no `class_id == 0` gate — a keyless class // instance gaining its first by-name property is stamped like // any other receiver. - super::shapes::stamp_object_shape(obj, new_keys, 1); + super::shapes::stamp_object_shape(obj, new_keys, 1, 1); return; } @@ -605,8 +606,10 @@ pub(super) fn set_field_by_name_object_tail( // Search through the keys array for a match let key_count = crate::array::js_array_length(keys) as usize; - let alloc_limit = - std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32) as usize; + let alloc_limit = std::cmp::max( + crate::object::object_live_slot_count(obj), + crate::object::INLINE_SLOT_FLOOR as u32, + ) as usize; // Sidecar O(1) lookup when keys_array has grown past the // linear-scan break-even. Without this, the build-then-fill @@ -744,7 +747,7 @@ pub(super) fn set_field_by_name_object_tail( // and evacuation rewriting. Widen the count FIRST — every physical // slot is undefined-initialized at allocation, so the widened range // can only expose non-pointer sentinels — then publish the value. - if new_index as u32 >= (*obj).field_count { + if new_index as u32 >= crate::object::object_live_slot_count(obj) { set_object_live_slot_count(obj, new_index as u32 + 1); } js_object_set_field(obj, new_index as u32, JSValue::from_bits(value.to_bits())); @@ -961,7 +964,7 @@ pub(super) fn set_field_by_name_object_tail( // slot is undefined-initialized at allocation, so the widened range // can only expose non-pointer sentinels — then publish the value. // Bump field_count to reflect the newly added property - if new_index as u32 >= (*obj).field_count { + if new_index as u32 >= crate::object::object_live_slot_count(obj) { set_object_live_slot_count(obj, new_index as u32 + 1); } js_object_set_field(obj, new_index as u32, JSValue::from_bits(value.to_bits())); diff --git a/crates/perry-runtime/src/object/field_set_by_name/write_helpers.rs b/crates/perry-runtime/src/object/field_set_by_name/write_helpers.rs index db63cdffee..88a5c88f1d 100644 --- a/crates/perry-runtime/src/object/field_set_by_name/write_helpers.rs +++ b/crates/perry-runtime/src/object/field_set_by_name/write_helpers.rs @@ -47,7 +47,7 @@ pub(super) unsafe fn string_key_eq(key: *const crate::StringHeader, expected: &[ /// round-trips via `closure_set_via_function_prototype_descriptor` before /// falling back to a plain own-property write. /// #6530: mirror a SUCCESSFUL own-data write on a per-evaluation CLASS OBJECT -/// (`object_type == OBJECT_TYPE_CLASS` — what a capture-carrying class +/// (`ShapeObjectKind::Class` — what a capture-carrying class /// statement materializes as) into the class_id-keyed `CLASS_DYNAMIC_PROPS` /// side table. Compiled method bodies reference sibling classes as INT32 /// ClassRefs (bundled zod's `ZodOptional.create(this, this._def)` inside diff --git a/crates/perry-runtime/src/object/gc_slots.rs b/crates/perry-runtime/src/object/gc_slots.rs index d3f1971971..ce038c1f82 100644 --- a/crates/perry-runtime/src/object/gc_slots.rs +++ b/crates/perry-runtime/src/object/gc_slots.rs @@ -31,11 +31,18 @@ pub(crate) unsafe fn gc_field_slot_range( if obj.is_null() { return None; } + // #8113: the descriptor is now the SOLE record of the live inline-slot + // bound — there is no header word left to fall back to. An unstamped + // receiver therefore traces zero payload slots, which is the fail-closed + // answer for the only population that can be unstamped: synthetic/raw test + // fixtures that bypass every runtime allocator, and which hold no heap + // edges. Every runtime allocator publishes a descriptor before its header + // escapes (`object/alloc.rs`), and every bound change is mint-then-stamp + // (`shapes::publish_object_live_slot_count`), so a live object is never + // observed here without one. let field_count = shapes::object_shape_descriptor(obj) .map(|descriptor| descriptor.live_inline_slot_count as usize) - // Compatibility only for synthetic/raw test fixtures that bypass all - // runtime allocators. Published runtime objects are always stamped. - .unwrap_or((*obj).field_count as usize); + .unwrap_or(0); if field_count > 1_000_000 { return None; } diff --git a/crates/perry-runtime/src/object/live_slots.rs b/crates/perry-runtime/src/object/live_slots.rs new file mode 100644 index 0000000000..e4ac594096 --- /dev/null +++ b/crates/perry-runtime/src/object/live_slots.rs @@ -0,0 +1,89 @@ +//! #8113: the live inline-slot bound, and the `ObjectHeader` ABI revision. +//! +//! `ObjectHeader` used to carry a `field_count: u32` word. It was derivable +//! from the object's immutable ShapeId descriptor, and removing it together +//! with the equally derivable `object_type` word took the header from 32 bytes +//! to 24 (a two-slot object from 56 to 48). These four items are what took its +//! place; they live in their own module because `object/mod.rs` is at the +//! repository's 2000-line cap. + +use super::shapes; +use super::ObjectHeader; +use super::INLINE_SLOT_FLOOR; + +/// Revision of the [`ObjectHeader`] ABI, paired with +/// `perry_ffi::OBJECT_HEADER_ABI_REVISION`. +/// +/// `perry-ffi` is published to crates.io, and a wrapper compiled against an old +/// mirror linked against a new runtime reads the wrong header offsets with no +/// compile error. Bump this and the perry-ffi constant together on ANY change +/// to the header's size, field set, or field offsets; perry-ffi's +/// `object_header_abi_revision_matches_the_pinned_layout` (now actually run in +/// CI, see `test.yml`) fails otherwise. +/// +/// * 1 — `{object_type, class_id, parent_class_id, field_count, keys_array, meta}`. +/// * 2 — `{class_id, parent_class_id, keys_array, meta}` (#8113). +#[no_mangle] +pub extern "C" fn perry_object_header_abi_revision() -> u32 { + 2 +} + +/// The authoritative live inline-slot bound (#8113: the replacement for the +/// deleted `ObjectHeader::field_count` word). +/// +/// Zero for a receiver with no published descriptor. That is deliberately +/// fail-CLOSED: a bound of 0 rejects field writes instead of admitting an +/// unbounded one, and every runtime allocator publishes a descriptor before its +/// header escapes, so the zero case is a raw/synthetic fixture, not a live +/// object. +#[inline] +pub unsafe fn object_live_slot_count(obj: *const ObjectHeader) -> u32 { + shapes::object_shape_descriptor(obj) + .map(|descriptor| descriptor.live_inline_slot_count) + .unwrap_or(0) +} + +/// C-ABI accessor for [`object_live_slot_count`], for out-of-runtime consumers +/// (`perry-ext-*`) that mirror `ObjectHeader` through `perry-ffi` and used to +/// read the deleted `field_count` word directly (#8113). +/// +/// # Safety +/// `obj` must be a live `GC_TYPE_OBJECT` allocation or null. +#[no_mangle] +pub unsafe extern "C" fn js_object_live_slot_count(obj: *const ObjectHeader) -> u32 { + if obj.is_null() { + return 0; + } + object_live_slot_count(obj) +} + +/// The OOB bound every by-index field write is checked against: +/// `max(live_inline_slot_count, INLINE_SLOT_FLOOR)`. Every allocator reserves +/// at least `INLINE_SLOT_FLOOR` physical slots (`object/alloc.rs`), and +/// `live_inline_slot_count` is a fixed point of the same expression — the +/// by-name append path only ever bumps it for a slot it placed inline — so this +/// can never exceed the physical slot count. +#[inline] +pub unsafe fn object_inline_alloc_limit(obj: *const ObjectHeader) -> u32 { + std::cmp::max(object_live_slot_count(obj), INLINE_SLOT_FLOOR as u32) +} + +/// Publish a new authoritative live-inline-slot bound. +/// +/// #8113 MINT-THEN-STAMP. There is no longer a header word to fall back on, so +/// this must never leave the receiver without a descriptor, not even +/// transiently: `shape_descriptor_ensure_*` inserts into a `HashMap` and can +/// therefore collect, and a collection landing in a stamp-cleared window would +/// see a live bound of 0 and stop tracing the object's payload entirely. +/// +/// The successor descriptor is minted while the PREDECESSOR is still stamped +/// (so a collection during the mint sees the old, still-correct bound — the +/// newly exposed slot has not been written yet), and publication is the single +/// `parent_class_id` store, which cannot collect. +/// +/// Callers growing the traced range must invoke this before publishing the +/// pointer-bearing field value (#7154): mint → stamp → value-slot store. +#[inline] +pub(crate) unsafe fn set_object_live_slot_count(obj: *mut ObjectHeader, field_count: u32) { + shapes::publish_object_live_slot_count(obj, field_count); +} diff --git a/crates/perry-runtime/src/object/map_set_subclass.rs b/crates/perry-runtime/src/object/map_set_subclass.rs index aa937c931e..44f5f79ad8 100644 --- a/crates/perry-runtime/src/object/map_set_subclass.rs +++ b/crates/perry-runtime/src/object/map_set_subclass.rs @@ -383,14 +383,18 @@ pub extern "C" fn js_map_set_subclass_init(this: f64, kind: i32, iterable: f64) /// entry points. /// /// These are *sabotage* tests, not smoke tests: each one first asserts that the -/// header byte the pre-fix code would have misread is still sitting there -/// (`object_type == 1` at `MapHeader.size`'s offset), and only then that the -/// entry point returns the resolved answer instead. A green run therefore -/// proves the redirect fired, not merely that nothing crashed. +/// header word the pre-fix code would have misread is still sitting there at +/// `MapHeader.size`'s offset, and only then that the entry point returns the +/// resolved answer instead. A green run therefore proves the redirect fired, +/// not merely that nothing crashed. +/// +/// #8113 moved which word that is: `ObjectHeader::object_type` is gone, so +/// offset 0 — `MapHeader.size` / `SetHeader.size` — is now `class_id`. The +/// misread value changed from a constant 1 to the receiver's class id; the +/// hazard, and therefore the sabotage, is identical. #[cfg(test)] mod tests { use super::*; - use crate::error::OBJECT_TYPE_REGULAR; use crate::object::js_object_alloc; fn boxed(obj: *mut ObjectHeader) -> f64 { @@ -456,14 +460,14 @@ mod tests { assert_ne!(backing as usize, obj as usize); // The pre-fix hazard, still present in the bytes: `MapHeader.size` - // overlays `ObjectHeader.object_type`, so `js_map_size` used to report - // 1 for an EMPTY subclass instance and `MapHeader.entries` was - // `parent_class_id ‖ field_count`. - assert_eq!(unsafe { (*obj).object_type }, OBJECT_TYPE_REGULAR); + // overlays `ObjectHeader.class_id` (#8113), so `js_map_size` used to + // report the class id for an EMPTY subclass instance and + // `MapHeader.entries` was the shape word. + assert_eq!(unsafe { (*obj).class_id }, 9001); assert_eq!( js_map_size_of(obj), 0, - "an empty Map subclass instance must report size 0, not object_type" + "an empty Map subclass instance must report size 0, not class_id" ); // Writes land in the backing; the receiver is what comes back. @@ -480,8 +484,8 @@ mod tests { ); // The instance header is untouched — no forged-pointer store landed in // it, and it is still an ordinary object. - assert_eq!(unsafe { (*obj).object_type }, OBJECT_TYPE_REGULAR); assert_eq!(unsafe { (*obj).class_id }, 9001); + assert!(unsafe { crate::object::object_is_regular(obj) }); } #[test] @@ -492,11 +496,11 @@ mod tests { _ => panic!("super() should have installed a Set backing"), }; assert_ne!(backing as usize, obj as usize); - assert_eq!(unsafe { (*obj).object_type }, OBJECT_TYPE_REGULAR); + assert_eq!(unsafe { (*obj).class_id }, 9002); assert_eq!( crate::set::js_set_size(obj as *const crate::set::SetHeader), 0, - "an empty Set subclass instance must report size 0, not object_type" + "an empty Set subclass instance must report size 0, not class_id" ); let returned = crate::set::js_set_add(obj as *mut crate::set::SetHeader, 7.0); @@ -534,9 +538,9 @@ mod tests { ); // Pre-fix these read the ObjectHeader as a MapHeader: `size` was - // `object_type` (= 1) and the very next `.set()` stored through - // `parent_class_id ‖ field_count`. - assert_eq!(unsafe { (*obj).object_type }, OBJECT_TYPE_REGULAR); + // `class_id` (#8113; `object_type` before that) and the very next + // `.set()` stored through the shape word. + assert_eq!(unsafe { (*obj).class_id }, 9003); assert_eq!(js_map_size_of(obj), 0); assert_eq!( crate::map::js_map_get(obj as *const crate::map::MapHeader, 1.0).to_bits(), @@ -555,9 +559,11 @@ mod tests { crate::set::js_set_clear(obj as *mut crate::set::SetHeader); // Nothing wrote into the object's header. - assert_eq!(unsafe { (*obj).object_type }, OBJECT_TYPE_REGULAR); assert_eq!(unsafe { (*obj).class_id }, 9003); - assert_eq!(unsafe { (*obj).field_count }, 3); + assert!(crate::object::shapes::is_shape_id(unsafe { + (*obj).parent_class_id + })); + assert_eq!(unsafe { crate::object::object_live_slot_count(obj) }, 3); } fn js_map_size_of(obj: *mut ObjectHeader) -> u32 { diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 4564e26448..f1ccf78011 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -93,6 +93,15 @@ mod global_this_tables; mod groupby; pub(crate) mod has_own_helpers; mod instanceof; +mod live_slots; +mod null_stub; +pub(crate) use live_slots::set_object_live_slot_count; +pub use live_slots::{ + js_object_live_slot_count, object_inline_alloc_limit, object_live_slot_count, + perry_object_header_abi_revision, +}; +pub use null_stub::{js_unresolved_default_call, js_unresolved_namespace_stub}; +pub(crate) use null_stub::{NullObjectBytes, NULL_OBJECT_BYTES}; pub(crate) mod iterator_prototypes; pub(crate) mod map_set_subclass; mod namespace_create; @@ -585,70 +594,6 @@ pub(crate) fn call_method_depth_restore(depth: u32) { CALL_METHOD_DEPTH.with(|d| d.set(depth)); } -/// Static "null object" used as a safe return value when the depth guard triggers. -/// Instead of returning undefined (which callers may dereference as a null pointer), -/// we return a pointer to this valid-but-empty object so downstream code doesn't crash. -/// -/// Uses a raw byte array with matching layout to avoid Sync issues with raw pointers. -#[repr(C, align(8))] -struct NullObjectBytes { - object_type: u32, // 1 = OBJECT_TYPE_REGULAR - class_id: u32, // 0 - parent_class_id: u32, // 0 - field_count: u32, // 0 - keys_array: u64, // 0 (null pointer as u64) -} -// Safety: this is a read-only zero-initialized struct with no interior mutability -unsafe impl Sync for NullObjectBytes {} - -/// Issue #629: namespace imports for unresolved modules -/// (`import * as fsp from "node:fs/promises"` when the module isn't -/// implemented) used to fall back to `TAG_TRUE` at the codegen -/// catch-all, which made `typeof fsp === "boolean"` and every -/// `fsp.method` access return undefined silently — confusing because -/// the user sees `(boolean).method is not a function`. Returning a -/// stable empty-object stub makes `typeof === "object"` (matches -/// Node's module-namespace shape) and property access cleanly returns -/// undefined via the existing object-field path. -#[no_mangle] -pub extern "C" fn js_unresolved_namespace_stub() -> f64 { - let null_obj_ptr = &NULL_OBJECT_BYTES as *const NullObjectBytes as *mut u8; - f64::from_bits(crate::JSValue::pointer(null_obj_ptr).bits()) -} - -/// Issue #692: default-import calls against unresolved modules -/// (`import jwt from "jsonwebtoken"; jwt.sign(...)` when no perry-stdlib -/// binding matched the method, or `import sanitizeHtml from -/// "sanitize-html"; sanitizeHtml(x)` when sanitize-html doesn't resolve -/// to a NativeCompiled module) used to lower to an LLVM extern named -/// literally `default`, which the system linker can't resolve — -/// surfaced as `undefined reference to 'default'`. Route those calls -/// here so the binary links; the runtime stub prints a one-shot -/// diagnostic and returns NaN-boxed undefined. The user gets a clear -/// signal at first call rather than a cryptic link error. -#[no_mangle] -pub extern "C" fn js_unresolved_default_call() -> f64 { - use std::sync::atomic::{AtomicBool, Ordering}; - static WARNED: AtomicBool = AtomicBool::new(false); - if !WARNED.swap(true, Ordering::Relaxed) { - eprintln!( - "perry: called a default-imported binding from an unresolved module \ - (returns undefined). The module's default export was not found in \ - perry-stdlib or perry.compilePackages — run `perry --print-api-manifest` \ - to see what's supported." - ); - } - f64::from_bits(0x7FFC_0000_0000_0001) // TAG_UNDEFINED -} - -static NULL_OBJECT_BYTES: NullObjectBytes = NullObjectBytes { - object_type: 1, - class_id: 0, - parent_class_id: 0, - field_count: 0, - keys_array: 0, -}; - /// Fast direct-mapped inline cache for class shape keys arrays. /// Indexed by `shape_id mod CACHE_SIZE`. Each slot stores /// `(shape_id, keys_array_ptr)`. A 256-entry direct-mapped cache costs @@ -1682,19 +1627,27 @@ pub fn overflow_fields_is_empty() -> bool { pub(crate) use crate::value::addr_class::is_valid_obj_ptr; /// Object header - precedes the fields in memory +/// +/// # #8113: two derivable words are gone +/// +/// The header used to open with `object_type: u32` (an ABI mirror of +/// `error::ErrorHeader`'s first word) and carry `field_count: u32` (the live +/// inline-slot bound). Both were derivable and neither alone saved a byte — the +/// struct re-padded — so they went together: 32 bytes to 24, and a two-slot +/// object from 56 to 48. The kind now comes from `GcHeader.obj_type` plus +/// [`shapes::ShapeObjectKind`] ([`object_is_regular`], +/// [`crate::error::ptr_is_native_error`]); the bound from +/// [`object_live_slot_count`]. See `object/live_slots.rs` for the consequence +/// every allocator has to honour. #[repr(C)] pub struct ObjectHeader { - /// Type tag to distinguish from Error objects (must be first field!) - /// Uses OBJECT_TYPE_REGULAR (1) for regular objects - pub object_type: u32, - /// Class ID for this object (used for instanceof, vtable lookup) + /// Class ID for this object (used for instanceof, vtable lookup). + /// MUST stay first: codegen guards load it at header offset 0. pub class_id: u32, /// Compatibility word: the parent class ID during allocation, then the /// runtime `ShapeId` after shape stamping. Parent lookup must use the class /// registry; direct reads of this word are not authoritative parent data. pub parent_class_id: u32, - /// Number of fields in this object - pub field_count: u32, /// Pointer to array of key strings (for Object.keys() support). /// /// A class instance HAS one: `object_alloc_class_inline_keys_impl` installs @@ -1707,7 +1660,7 @@ pub struct ObjectHeader { pub keys_array: *mut ArrayHeader, /// #6759 Phase B: per-object metadata record — null for ordinary /// objects (the common case). MUST stay the LAST field: codegen reads - /// the earlier header fields at fixed offsets (0/4/8/12/16), and the + /// the earlier header fields at fixed offsets (0/4/8), and the /// field-slot region begins at `size_of::()`, mirrored /// by `perry-codegen/src/target_layout.rs::object_header_size_bytes`. /// See [`ObjectMeta`]. @@ -1778,8 +1731,10 @@ pub(crate) const OBJECT_META_FLAG_PROTO_OVERRIDE: u64 = 1; /// Authoritative ordinary-object discriminator. RegExp has its own GC kind, /// and heap class-expression values carry their kind in the immutable ShapeId -/// descriptor. The legacy `ObjectHeader::object_type` word is only an ABI -/// mirror pending #8047. +/// descriptor. #8113 deleted the legacy `ObjectHeader::object_type` ABI mirror, +/// so this is the ONLY spelling of "is an ordinary object" — note it is FALSE +/// for a class object (`ShapeObjectKind::Class`), which is exactly what the +/// retired `object_type == OBJECT_TYPE_REGULAR` test meant (#6595). #[inline] pub(crate) unsafe fn object_is_regular(obj: *const ObjectHeader) -> bool { if obj.is_null() { @@ -1870,16 +1825,40 @@ pub(crate) unsafe fn gc_object_meta_slot(user_ptr: usize) -> Option<*mut u64> { #[inline] unsafe fn set_object_keys_array(obj: *mut ObjectHeader, keys_array: *mut ArrayHeader) { + let live = object_live_slot_count(obj); + set_object_keys_array_with_live(obj, keys_array, live); +} + +/// `set_object_keys_array` for a receiver whose live inline-slot bound is not +/// yet published — i.e. the allocators, which used to write +/// `(*ptr).field_count` before installing the keys edge (#8113). Passing the +/// birth count here keeps the published descriptor identical to the pre-#8113 +/// one; deriving it from the (absent) predecessor instead would mint a +/// spurious `live = 0` intermediate for every allocation. +#[inline] +unsafe fn set_object_keys_array_with_live( + obj: *mut ObjectHeader, + keys_array: *mut ArrayHeader, + live_inline_slot_count: u32, +) { // #6759 C3c: a stamped shape id (carried in the `parent_class_id` word) - // described the OLD keys array — clear it on a pointer CHANGE so no stale - // id is visible while the authoritative header changes. A same-pointer - // append is versioned by `synchronize_object_shape_descriptor` below; an - // immutable old descriptor is never silently changed in place. + // describes the OLD keys array on a pointer CHANGE. A same-pointer append is + // versioned inside the publication helper; an immutable old descriptor is + // never silently changed in place. + // + // #8113 MINT-THEN-STAMP — this used to CLEAR the stamp here and re-mint + // after the header store. That is no longer legal: the descriptor is the + // only record of the live inline-slot bound, so an unstamped window is a + // window in which the collector traces ZERO payload slots, and the window + // contains both a write barrier and a `HashMap` insert. Instead the + // successor descriptor for the NEW edge is published FIRST (the predecessor + // still describes the header's current edge across every allocation inside), + // and the header store follows with nothing allocating in between. // // #6759 C3 rung 1: no `class_id == 0` gate. The word is a ShapeId iff - // `is_shape_id` says so, for class instances too — and `clear_object_shape_stamp` - // tests exactly that, so an instance still carrying its allocation-time - // `parent_class_id` (never in the ShapeId range) is left alone. + // `is_shape_id` says so, for class instances too, so an instance still + // carrying its allocation-time `parent_class_id` (never in the ShapeId + // range) is left alone. let predecessor = shapes::object_shape_descriptor(obj); let keys_changed = (*obj).keys_array != keys_array; if keys_changed { @@ -1895,8 +1874,11 @@ unsafe fn set_object_keys_array(obj: *mut ObjectHeader, keys_array: *mut ArrayHe // lookup publish an Ordinary descriptor for a class object; the // structural synchronization below then inherited the wrong kind. mark_object_dynamic_shape_unknown(obj); - shapes::clear_object_shape_stamp(obj); } + // #8067/#8113: every visible ShapeId resolves to the exact rooted + // ordered-keys/live-slot descriptor. Same-pointer appends are versioned + // inside the helper. + shapes::publish_object_shape_from(obj, predecessor, keys_array, live_inline_slot_count); // GC_STORE_AUDIT(BARRIERED): keys_array pointer field is followed by an object-slot barrier. (*obj).keys_array = keys_array; crate::gc::runtime_write_barrier_slot( @@ -1904,28 +1886,6 @@ unsafe fn set_object_keys_array(obj: *mut ObjectHeader, keys_array: *mut ArrayHe &(*obj).keys_array as *const _ as usize, keys_array as u64, ); - // #8067: the old header edge remains authoritative, but every visible - // ShapeId must now resolve to the exact rooted ordered-keys/live-slot - // descriptor. Same-pointer appends are versioned inside the helper. - shapes::synchronize_object_shape_descriptor_from(obj, predecessor); -} - -/// Publish a new authoritative live-inline-slot bound without ever exposing a -/// ShapeId whose descriptor disagrees with `ObjectHeader.field_count`. -/// -/// Callers growing the traced range must invoke this before publishing the -/// pointer-bearing field value (#7154): old stamp clear → header count write → -/// complete descriptor install → new stamp → value-slot store. -#[inline] -pub(super) unsafe fn set_object_live_slot_count(obj: *mut ObjectHeader, field_count: u32) { - if (*obj).field_count != field_count { - let predecessor = shapes::object_shape_descriptor(obj); - shapes::clear_object_shape_stamp(obj); - (*obj).field_count = field_count; - shapes::synchronize_object_shape_descriptor_from(obj, predecessor); - } else { - shapes::debug_assert_object_shape_parity(obj); - } } #[inline] diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index 4c84a6ff41..0560c8eaf5 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -1682,9 +1682,13 @@ pub unsafe extern "C" fn js_native_call_method( if jsval().is_pointer() { let obj = jsval().as_pointer::(); - // Validate this is an ObjectHeader, not some other heap type. - // Check GcHeader first (reliable for heap objects), then fallback to ObjectHeader.object_type - // for static/const objects that don't have GcHeaders. + // Validate this is an ObjectHeader, not some other heap type, from the + // GcHeader. (The comment here used to promise an `ObjectHeader.object_type` + // fallback "for static/const objects that don't have GcHeaders". No such + // fallback was ever written — the read below is unconditional — and + // #8113 deleted the word it named. `NULL_OBJECT_BYTES`, the one + // GcHeader-less receiver, therefore classifies from whatever precedes it + // in `.data`; that was already true before this change.) // Guard: ensure we can safely read GC_HEADER_SIZE bytes before obj if (obj as usize) < crate::gc::GC_HEADER_SIZE + 0x1000 { return 0.0; diff --git a/crates/perry-runtime/src/object/native_module.rs b/crates/perry-runtime/src/object/native_module.rs index b7da3d0ba4..2f1c7a7350 100644 --- a/crates/perry-runtime/src/object/native_module.rs +++ b/crates/perry-runtime/src/object/native_module.rs @@ -1485,7 +1485,7 @@ pub(super) fn class_id_from_method_receiver(instance: f64) -> Option { } // #7563: the closure guard above fixed ONE instance of that type // confusion; a bare `(*obj).class_id` read has it for every other - // non-object allocation too. `ObjectHeader` is `{ object_type: u32, + // non-object allocation too. `ObjectHeader` is `{ class_id: u32, // class_id: u32, … }` while `ArrayHeader` is `{ length: u32, // capacity: u32 }`, so the `class_id` slot of an ARRAY overlays its // **capacity** — an N-capacity array literal was read back as diff --git a/crates/perry-runtime/src/object/null_stub.rs b/crates/perry-runtime/src/object/null_stub.rs new file mode 100644 index 0000000000..87413c2ace --- /dev/null +++ b/crates/perry-runtime/src/object/null_stub.rs @@ -0,0 +1,72 @@ +//! The unresolved-module namespace stub — a static, GcHeader-less "empty +//! object" handed to user code when a module import or a method dispatch has +//! nowhere to go. +//! +//! Split out of `object/mod.rs` (2000-line cap) by #8113, which also gave the +//! mirror its missing `meta` word. + +/// Static "null object" used as a safe return value when the depth guard triggers. +/// Instead of returning undefined (which callers may dereference as a null pointer), +/// we return a pointer to this valid-but-empty object so downstream code doesn't crash. +/// +/// Uses a raw byte array with matching layout to avoid Sync issues with raw pointers. +/// +/// #8113: mirrors the post-shrink `ObjectHeader` word for word, including the +/// trailing `meta` slot the pre-#8113 spelling omitted (a `(*obj).meta` read on +/// the stub used to run off the end of the static). +#[repr(C, align(8))] +pub(crate) struct NullObjectBytes { + class_id: u32, // 0 + parent_class_id: u32, // 0 (never a ShapeId: the stub has no descriptor) + keys_array: u64, // 0 (null pointer as u64) + meta: u64, // 0 (null pointer as u64) +} +// Safety: this is a read-only zero-initialized struct with no interior mutability +unsafe impl Sync for NullObjectBytes {} + +/// Issue #629: namespace imports for unresolved modules +/// (`import * as fsp from "node:fs/promises"` when the module isn't +/// implemented) used to fall back to `TAG_TRUE` at the codegen +/// catch-all, which made `typeof fsp === "boolean"` and every +/// `fsp.method` access return undefined silently — confusing because +/// the user sees `(boolean).method is not a function`. Returning a +/// stable empty-object stub makes `typeof === "object"` (matches +/// Node's module-namespace shape) and property access cleanly returns +/// undefined via the existing object-field path. +#[no_mangle] +pub extern "C" fn js_unresolved_namespace_stub() -> f64 { + let null_obj_ptr = &NULL_OBJECT_BYTES as *const NullObjectBytes as *mut u8; + f64::from_bits(crate::JSValue::pointer(null_obj_ptr).bits()) +} + +/// Issue #692: default-import calls against unresolved modules +/// (`import jwt from "jsonwebtoken"; jwt.sign(...)` when no perry-stdlib +/// binding matched the method, or `import sanitizeHtml from +/// "sanitize-html"; sanitizeHtml(x)` when sanitize-html doesn't resolve +/// to a NativeCompiled module) used to lower to an LLVM extern named +/// literally `default`, which the system linker can't resolve — +/// surfaced as `undefined reference to 'default'`. Route those calls +/// here so the binary links; the runtime stub prints a one-shot +/// diagnostic and returns NaN-boxed undefined. The user gets a clear +/// signal at first call rather than a cryptic link error. +#[no_mangle] +pub extern "C" fn js_unresolved_default_call() -> f64 { + use std::sync::atomic::{AtomicBool, Ordering}; + static WARNED: AtomicBool = AtomicBool::new(false); + if !WARNED.swap(true, Ordering::Relaxed) { + eprintln!( + "perry: called a default-imported binding from an unresolved module \ + (returns undefined). The module's default export was not found in \ + perry-stdlib or perry.compilePackages — run `perry --print-api-manifest` \ + to see what's supported." + ); + } + f64::from_bits(0x7FFC_0000_0000_0001) // TAG_UNDEFINED +} + +pub(crate) static NULL_OBJECT_BYTES: NullObjectBytes = NullObjectBytes { + class_id: 0, + parent_class_id: 0, + keys_array: 0, + meta: 0, +}; diff --git a/crates/perry-runtime/src/object/object_ops/accessors.rs b/crates/perry-runtime/src/object/object_ops/accessors.rs index 8a462de49e..342346ecf2 100644 --- a/crates/perry-runtime/src/object/object_ops/accessors.rs +++ b/crates/perry-runtime/src/object/object_ops/accessors.rs @@ -158,8 +158,10 @@ pub extern "C" fn js_object_get_own_field_or_undef( if key_count > 65536 { return f64::from_bits(TAG_UNDEF); } - let alloc_limit = - std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32) as usize; + let alloc_limit = std::cmp::max( + crate::object::object_live_slot_count(obj), + crate::object::INLINE_SLOT_FLOOR as u32, + ) as usize; for i in 0..key_count { let key_val = crate::array::js_array_get(keys, i as u32); // #1781: SSO-aware match by byte slice — the diff --git a/crates/perry-runtime/src/object/object_ops/keys_array.rs b/crates/perry-runtime/src/object/object_ops/keys_array.rs index 1a8c416e07..52a52c1136 100644 --- a/crates/perry-runtime/src/object/object_ops/keys_array.rs +++ b/crates/perry-runtime/src/object/object_ops/keys_array.rs @@ -33,7 +33,7 @@ pub(crate) unsafe fn ensure_key_in_keys_array( let new_keys = crate::array::js_array_push(new_keys, JSValue::string_ptr(key as *mut _)); refresh_define_property_roots!(); set_object_keys_array(obj, new_keys); - if (*obj).field_count == 0 { + if crate::object::object_live_slot_count(obj) == 0 { set_object_live_slot_count(obj, 1); } return; @@ -147,9 +147,11 @@ pub(crate) unsafe fn ensure_key_in_keys_array( // getter here bumped field_count from 8 (the proto's physical capacity) to // 11, exposing the overflowed `values` slot and corrupting the boundary. let new_index = key_count as u32; - let inline_capacity = - std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32); - if new_index < inline_capacity && new_index >= (*obj).field_count { + let inline_capacity = std::cmp::max( + crate::object::object_live_slot_count(obj), + crate::object::INLINE_SLOT_FLOOR as u32, + ); + if new_index < inline_capacity && new_index >= crate::object::object_live_slot_count(obj) { set_object_live_slot_count(obj, new_index + 1); } } diff --git a/crates/perry-runtime/src/object/shapes.rs b/crates/perry-runtime/src/object/shapes.rs index a57d1d543a..296d11d7a0 100644 --- a/crates/perry-runtime/src/object/shapes.rs +++ b/crates/perry-runtime/src/object/shapes.rs @@ -16,9 +16,17 @@ //! ordered-keys edge plus the exact logical-key and live-inline-slot bounds. //! The descriptor table is agent-local while ids are process-global. A live //! object's ShapeId is authoritative for its ordered keys, logical-key count, -//! live inline-slot bound, and semantic generation. The legacy -//! `ObjectHeader::{keys_array,field_count}` words remain ABI mirrors until -//! #8047 removes them; guards and GC must not use their values as shape facts. +//! live inline-slot bound, and semantic generation. +//! +//! #8113 removed `ObjectHeader::field_count`, so the descriptor's +//! `live_inline_slot_count` is no longer a mirror of a header word — it is the +//! ONLY record of the bound. Every publication below is therefore +//! MINT-THEN-STAMP: the successor descriptor is fully installed while the +//! predecessor stamp is still readable, and the `parent_class_id` store is the +//! single, allocation-free publication point. A stamp-cleared window would be a +//! window in which the collector sees a live bound of 0 (#7154/#7164). +//! `ObjectHeader::keys_array` remains an ABI mirror until #8047 removes it; +//! guards and GC must not use its value as a shape fact. use crate::array::ArrayHeader; use std::cell::RefCell; @@ -469,12 +477,13 @@ pub(crate) unsafe fn stamp_object_shape( obj: *mut crate::object::ObjectHeader, keys: *const ArrayHeader, key_count: u32, + live_inline_slot_count: u32, ) -> u32 { if !shape_word_is_writable(obj) { return 0; } let Some(lineage) = object_shape_descriptor(obj) else { - let id = shape_descriptor_ensure(keys, key_count, (*obj).field_count) + let id = shape_descriptor_ensure(keys, key_count, live_inline_slot_count) .unwrap_or_else(|error| shape_descriptor_error_abort(error)); (*obj).parent_class_id = id; debug_assert_object_shape_parity(obj); @@ -502,54 +511,138 @@ pub(crate) unsafe fn stamp_object_shape( /// `ObjectHeader` must call this so all runtime and emitted guards observe the /// same descriptor identity from birth. /// -/// No `shape_word_is_writable` check: the callers have just written -/// `object_type`/`class_id` into a header they allocated, so the receiver is a -/// genuine `ObjectHeader` and never the `RegExpHeader` alias. +/// `live_inline_slot_count` is the birth bound the allocator sized the object +/// with. #8113: it is a parameter rather than a `(*obj).field_count` read +/// because the header no longer carries the word — the descriptor this +/// publishes is the only record of it. +/// +/// No `shape_word_is_writable` check beyond the null test: the callers have just +/// written `class_id` into a header they allocated, so the receiver is a genuine +/// `ObjectHeader` and never the `RegExpHeader` alias. #[inline] pub(crate) unsafe fn birth_stamp_object_shape( obj: *mut crate::object::ObjectHeader, runtime_shape_id: u32, + live_inline_slot_count: u32, ) { if obj.is_null() || !shape_word_is_writable(obj) { return; } let current = object_shape_descriptor(obj).unwrap_or_else(|| { - synchronize_object_shape_descriptor(obj); + birth_publish_object_shape(obj, live_inline_slot_count); object_shape_descriptor(obj).expect("shape synchronization must publish a descriptor") }); let keys = current.keys as usize as *mut ArrayHeader; let key_count = current.logical_key_count; - let supplied_id_is_local = descriptor_matches_object(runtime_shape_id, obj) - || install_external_shape_id(runtime_shape_id, keys, key_count, (*obj).field_count); + let supplied_id_is_local = + descriptor_matches_object(runtime_shape_id, obj, live_inline_slot_count) + || install_external_shape_id(runtime_shape_id, keys, key_count, live_inline_slot_count); if supplied_id_is_local { (*obj).parent_class_id = runtime_shape_id; debug_assert_object_shape_parity(obj); } else { - synchronize_object_shape_descriptor(obj); + birth_publish_object_shape(obj, live_inline_slot_count); + } +} + +/// Publish the exact descriptor for a FRESHLY ALLOCATED header. #8113: the +/// birth live-slot bound must be supplied because no header word carries it. +/// +/// Mint-then-stamp: `shape_descriptor_ensure_with_generation` can collect, and +/// at that point the object is still unstamped, which is sound only because it +/// is also still unpublished — the allocator has not returned it and no live +/// edge reaches it. Every LATER bound change goes through +/// [`publish_object_live_slot_count`], which keeps a valid predecessor stamp +/// across the mint. +#[inline] +pub(crate) unsafe fn birth_publish_object_shape( + obj: *mut crate::object::ObjectHeader, + live_inline_slot_count: u32, +) -> u32 { + synchronize_object_shape_descriptor_from(obj, None, live_inline_slot_count) +} + +/// Publish a new live inline-slot bound for an ALREADY PUBLISHED object. +/// +/// This is the #8113 replacement for `(*obj).field_count = n`. The successor +/// descriptor is minted while the predecessor stamp is still installed, so a +/// collection inside the mint observes the OLD bound — correct, because the +/// slot the caller is about to expose has not been written yet — and the new +/// bound becomes visible at the single `parent_class_id` store, which cannot +/// allocate and therefore cannot collect. +pub(crate) unsafe fn publish_object_live_slot_count( + obj: *mut crate::object::ObjectHeader, + live_inline_slot_count: u32, +) -> u32 { + if obj.is_null() || !shape_word_is_writable(obj) { + return 0; + } + let predecessor = object_shape_descriptor(obj); + if let Some(current) = predecessor { + if current.live_inline_slot_count == live_inline_slot_count { + debug_assert_object_shape_parity(obj); + return object_shape_stamp(obj); + } } + synchronize_object_shape_descriptor_from(obj, predecessor, live_inline_slot_count) } -/// Install the exact descriptor for the object's current authoritative header -/// facts. This is the only structural shape publication operation used by -/// mutations. Keyless objects receive a descriptor too. +/// Install the exact descriptor for the object's current authoritative keys +/// edge, preserving the live inline-slot bound the receiver already carries. +/// This is the only structural shape publication operation used by mutations. +/// Keyless objects receive a descriptor too. +/// +/// #8113: an UNSTAMPED receiver has no recorded bound anywhere, so this +/// publishes 0 for it rather than inventing one. Callers that know the bound +/// (allocators, the by-name append path) must use +/// [`birth_publish_object_shape`] / [`publish_object_live_slot_count`]. pub(crate) unsafe fn synchronize_object_shape_descriptor( obj: *mut crate::object::ObjectHeader, ) -> u32 { let predecessor = object_shape_descriptor(obj); - synchronize_object_shape_descriptor_from(obj, predecessor) + let live = predecessor + .map(|descriptor| descriptor.live_inline_slot_count) + .unwrap_or(0); + synchronize_object_shape_descriptor_from(obj, predecessor, live) } -/// Structural synchronization after a caller has temporarily cleared the -/// stamp. `predecessor` carries semantic lineage (including class kind) across -/// the pointer/count mutation without exposing stale structural facts. +/// Structural synchronization across a keys-edge or slot-bound mutation. +/// `predecessor` carries semantic lineage (including class kind) across the +/// mutation without exposing stale structural facts. +/// +/// MINT-THEN-STAMP (#8113): every allocation below happens with the +/// predecessor stamp still installed; the receiver's published shape changes at +/// the final `parent_class_id` store and nowhere else. pub(crate) unsafe fn synchronize_object_shape_descriptor_from( obj: *mut crate::object::ObjectHeader, predecessor: Option, + live_inline_slot_count: u32, +) -> u32 { + if obj.is_null() { + return 0; + } + publish_object_shape_from(obj, predecessor, (*obj).keys_array, live_inline_slot_count) +} + +/// Publish the exact descriptor for an EXPLICIT keys edge — which may not be +/// the one the header currently holds. +/// +/// This is what makes the keys-edge mutation mint-then-stamp (#8113). The +/// caller stamps the successor here, with the predecessor still describing the +/// header's current edge throughout every allocation inside, and only then +/// stores the header word. The gap between the stamp store and the header store +/// is allocation-free, and `object::gc_keys_array_slot` materializes +/// `descriptor.keys` into the header slot anyway, so a collection inside it +/// still sees exactly one authoritative edge. +pub(crate) unsafe fn publish_object_shape_from( + obj: *mut crate::object::ObjectHeader, + predecessor: Option, + keys: *mut ArrayHeader, + live_inline_slot_count: u32, ) -> u32 { if obj.is_null() || !shape_word_is_writable(obj) { return 0; } - let keys = (*obj).keys_array; let key_count = if keys.is_null() { 0 } else { @@ -562,14 +655,17 @@ pub(crate) unsafe fn synchronize_object_shape_descriptor_from( let old_id = object_shape_stamp(obj); if let Some(old) = shape_descriptor_by_id(old_id) { if old.keys == keys as u64 && old.logical_key_count != key_count { + // #8113: these three arms are unreachable-by-construction defenses + // (`debug_assert!` below). They deliberately leave the receiver + // STAMPED with its predecessor rather than clearing: an unstamped + // object now has no live-slot bound at all, so clearing would turn + // a shape-identity fault into heap-payload loss. let Some(gc) = crate::value::addr_class::try_read_tracked_gc_header(keys as usize) else { - clear_object_shape_stamp(obj); - return 0; + return old_id; }; if (*gc.as_ptr()).obj_type != crate::gc::GC_TYPE_ARRAY { - clear_object_shape_stamp(obj); - return 0; + return old_id; } let shared = (*gc.as_ptr()).gc_flags & crate::gc::GC_FLAG_SHAPE_SHARED != 0; debug_assert!( @@ -577,8 +673,7 @@ pub(crate) unsafe fn synchronize_object_shape_descriptor_from( "shared keys array mutated in place under an immutable ShapeId" ); if shared { - clear_object_shape_stamp(obj); - return 0; + return old_id; } retain_key_count_versions(keys as u64); } @@ -599,12 +694,12 @@ pub(crate) unsafe fn synchronize_object_shape_descriptor_from( let id = publish_shape_result(shape_descriptor_ensure_with_generation( keys, key_count, - (*obj).field_count, + live_inline_slot_count, semantic_generation, object_kind, )); (*obj).parent_class_id = id; - debug_assert_object_shape_parity(obj); + debug_assert_object_shape_parity_for_keys(obj, keys); id } @@ -720,29 +815,63 @@ fn retain_key_count_versions(keys: u64) { } } -fn descriptor_matches_object(shape_id: u32, obj: *const crate::object::ObjectHeader) -> bool { +/// Exact-facts test for a candidate id against the receiver's authoritative +/// header facts. #8113: the live bound is a PARAMETER — the header no longer +/// mirrors it, so the caller supplies the bound it is claiming. +fn descriptor_matches_object( + shape_id: u32, + obj: *const crate::object::ObjectHeader, + live_inline_slot_count: u32, +) -> bool { let Some(d) = shape_descriptor_by_id(shape_id) else { return false; }; unsafe { - let keys = (*obj).keys_array; - let key_count = if keys.is_null() { - 0 - } else { - crate::array::keys_array_len_capped_to_capacity(keys) as u32 - }; - d.keys == keys as u64 - && d.logical_key_count == key_count - && d.live_inline_slot_count == (*obj).field_count + d.keys == (*obj).keys_array as u64 + && d.logical_key_count == object_header_key_count(obj) + && d.live_inline_slot_count == live_inline_slot_count } } +#[inline] +unsafe fn object_header_key_count(obj: *const crate::object::ObjectHeader) -> u32 { + let keys = (*obj).keys_array; + if keys.is_null() { + 0 + } else { + crate::array::keys_array_len_capped_to_capacity(keys) as u32 + } +} + +/// #8113: the live-slot bound is no longer independently observable, so parity +/// is now exactly "the stamp resolves, and its structural keys facts match the +/// keys edge the receiver is about to carry". The bound cannot disagree with +/// itself. #[inline] pub(crate) unsafe fn debug_assert_object_shape_parity(obj: *const crate::object::ObjectHeader) { + debug_assert_object_shape_parity_for_keys(obj, (*obj).keys_array); +} + +/// Parity against an EXPLICIT keys edge. +/// +/// `publish_object_shape_from` stamps the successor before the header store +/// (that is what makes the keys mutation mint-then-stamp), so for that one +/// window the authoritative edge is the caller's argument, not the header word. +#[inline] +pub(crate) unsafe fn debug_assert_object_shape_parity_for_keys( + obj: *const crate::object::ObjectHeader, + keys: *mut ArrayHeader, +) { let id = object_shape_stamp(obj); if id != 0 { + let key_count = if keys.is_null() { + 0 + } else { + crate::array::keys_array_len_capped_to_capacity(keys) as u32 + }; debug_assert!( - descriptor_matches_object(id, obj), + shape_descriptor_by_id(id) + .is_some_and(|d| { d.keys == keys as u64 && d.logical_key_count == key_count }), "published ShapeId disagrees with authoritative ObjectHeader facts" ); } @@ -814,9 +943,16 @@ pub(crate) unsafe fn synchronize_live_object_shape_descriptor_after_header_visit /// Drop the stamp iff the word currently holds one, leaving a real /// `parent_class_id` untouched. Returns true when a stamp was cleared. /// -/// Ids are never reused, so clearing makes every stale id-keyed cache entry a -/// permanent miss; the next resolve re-stamps from whatever record the live -/// keys array has then. +/// # TEST-ONLY since #8113 +/// +/// Production code must never clear a stamp. The descriptor is now the sole +/// record of the live inline-slot bound, so an unstamped receiver reports a +/// bound of ZERO — its payload stops being traced, rewritten, and writable. +/// Every mutation that used to clear-then-re-mint is mint-then-stamp instead +/// (`publish_object_live_slot_count`, `publish_object_shape_from`), which has no +/// window at all. This survives only so tests can MANUFACTURE the unstamped +/// state and assert what the runtime does with it. +#[cfg(test)] #[inline] pub(crate) unsafe fn clear_object_shape_stamp(obj: *mut crate::object::ObjectHeader) -> bool { if is_shape_id((*obj).parent_class_id) { @@ -1194,7 +1330,10 @@ mod c3c_tests { descriptor.logical_key_count, crate::array::js_array_length((*obj).keys_array) ); - assert_eq!(descriptor.live_inline_slot_count, (*obj).field_count); + assert_eq!( + descriptor.live_inline_slot_count, + crate::object::object_live_slot_count(obj) + ); debug_assert_object_shape_parity(obj); } @@ -1246,11 +1385,19 @@ mod c6804_tests { } } - /// #6804: `object_shape()` self-heals — an unstamped plain object gets - /// stamped at first observation, and the token equals the id every - /// sibling already carries (no pre/post-stamp token split). + /// #6804 wanted "no pre/post-stamp token split", and got it with a + /// self-heal inside `object_shape()`. #8113 removes the self-heal and keeps + /// the property, by a stronger route: **the split population is empty**, + /// because every allocator birth-stamps. + /// + /// The self-heal had to go because it derived the live inline-slot bound + /// from `ObjectHeader::field_count`. With that word deleted, healing an + /// unstamped receiver would publish a descriptor claiming a bound of ZERO — + /// a read-only observation silently truncating the object's traced and + /// writable payload. Missing closed costs a PIC miss; healing wrongly loses + /// fields. #[test] - fn object_shape_token_self_heals_to_shared_id() { + fn object_shape_token_is_birth_stamped_and_an_unstamped_one_misses_closed() { let _lock = crate::gc::global_side_table_test_lock(); unsafe { let packed = b"m6804_x\0m6804_y"; @@ -1261,20 +1408,38 @@ mod c6804_tests { packed.len() as u32, ); let birth_stamp = (*obj).parent_class_id; - assert!(is_shape_id(birth_stamp), "test premise: birth-stamped"); + assert!(is_shape_id(birth_stamp), "every literal is birth-stamped"); + assert_eq!( + crate::typed_feedback::test_object_shape_token(obj as usize), + birth_stamp as usize, + "the observed token is the birth stamp — no split to heal" + ); + assert_eq!( + shape_descriptor_by_id(birth_stamp) + .expect("birth descriptor") + .live_inline_slot_count, + 2 + ); - // Simulate a pre-#6804 / cleared-stamp object of the same shape. + // Manufacture the pre-#6804 unstamped state and prove observing it + // is INERT: no token, no descriptor, and — the part that matters — + // no rewritten live-slot bound. (*obj).parent_class_id = 0; - let token = crate::typed_feedback::test_object_shape_token(obj as usize); assert_eq!( - token, birth_stamp as usize, - "self-healed token must equal the shape's canonical id" + crate::typed_feedback::test_object_shape_token(obj as usize), + 0, + "an unstamped receiver must miss closed, not be re-stamped" ); assert_eq!( (*obj).parent_class_id, - birth_stamp, - "observation must re-stamp the object" + 0, + "observation must not publish a descriptor for an unstamped receiver" ); + + // Restoring the birth stamp restores the exact bound, which is the + // proof that nothing was lost by refusing to heal. + (*obj).parent_class_id = birth_stamp; + assert_eq!(crate::object::object_live_slot_count(obj), 2); } } @@ -1523,10 +1688,8 @@ mod descriptor_tests_8067 { let id = shape_descriptor_ensure(keys as *const ArrayHeader, 3, 2) .expect("shape range unexpectedly exhausted"); let obj = crate::object::ObjectHeader { - object_type: 1, class_id: 0, parent_class_id: id, - field_count: 2, keys_array: keys as *mut ArrayHeader, meta: std::ptr::null_mut(), }; diff --git a/crates/perry-runtime/src/object/spill.rs b/crates/perry-runtime/src/object/spill.rs index 1a0e327764..b991c2fbf4 100644 --- a/crates/perry-runtime/src/object/spill.rs +++ b/crates/perry-runtime/src/object/spill.rs @@ -166,8 +166,10 @@ pub(crate) fn reserve_object_spill(obj_ptr: usize, field_count: u32) { unsafe { let obj = obj_ptr as *mut ObjectHeader; - let inline_capacity = - std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32); + let inline_capacity = std::cmp::max( + crate::object::object_live_slot_count(obj), + crate::object::INLINE_SLOT_FLOOR as u32, + ); if field_count <= inline_capacity { return; } diff --git a/crates/perry-runtime/src/object/tests.rs b/crates/perry-runtime/src/object/tests.rs index 576cfb82ca..3dcf1f40e8 100644 --- a/crates/perry-runtime/src/object/tests.rs +++ b/crates/perry-runtime/src/object/tests.rs @@ -618,14 +618,15 @@ fn symbol_keys_keep_creation_order_across_accessor_redefine() { } } -/// #7916: the per-object footprint accounting this issue is about, pinned as an -/// executable fact rather than a comment. +/// #7916 / #8113: the per-object footprint accounting this issue is about, +/// pinned as an executable fact rather than a comment. /// -/// A two-field object literal is `GcHeader (8) + ObjectHeader (32) + 8 * -/// max(field_count, INLINE_SLOT_FLOOR)`. At `INLINE_SLOT_FLOOR = 4` that is -/// **72 bytes to store 16 bytes of payload** and `gc-handoff/bench/retain.ts` -/// writes 216 MB to hold 48 MB of doubles. Lowering the floor to 2 removes the -/// two unusable slots. +/// A two-field object literal is `GcHeader (8) + ObjectHeader (24) + 8 * +/// max(live_inline_slot_count, INLINE_SLOT_FLOOR)`. It was 72 bytes at floor 4 +/// (#7916 took it to 56 by lowering the floor to 2) and #8113 took it to **48** +/// by deleting the header's two derivable words. 48 bytes to store 16 bytes of +/// payload; `gc-handoff/bench/retain.ts` writes 3x its data volume, down from +/// 4.5x. /// /// This reads the size the ALLOCATOR recorded (`GcHeader::size`), not a /// recomputation of the same formula, so it fails if any allocation path @@ -634,8 +635,10 @@ fn symbol_keys_keep_creation_order_across_accessor_redefine() { fn two_field_literal_footprint_is_exactly_accounted() { assert_eq!( std::mem::size_of::(), - 32, - "the ObjectHeader half of the accounting: 4 u32 + 2 pointers" + 24, + "the ObjectHeader half of the accounting: 2 u32 + 2 pointers (#8113 \ + removed `object_type` and `field_count`; either alone saved nothing \ + because the struct re-padded, both together saved 8 bytes)" ); assert_eq!(crate::gc::GC_HEADER_SIZE, 8); @@ -660,10 +663,11 @@ fn two_field_literal_footprint_is_exactly_accounted() { "a 2-field literal must occupy exactly {expected} bytes" ); assert_eq!( - recorded, 56, - "#7916: the 2-field literal footprint is 56 bytes (was 72 at floor 4). \ - Raising INLINE_SLOT_FLOOR back to 4 re-adds 16 bytes of unusable slots \ - to every small object" + recorded, 48, + "#8113: the 2-field literal footprint is 48 bytes (56 before the header \ + shrink, 72 at floor 4). Raising INLINE_SLOT_FLOOR back to 4 re-adds 16 \ + bytes of unusable slots to every small object; re-adding a header word \ + re-adds 8 to every object regardless of width" ); } @@ -671,11 +675,11 @@ fn two_field_literal_footprint_is_exactly_accounted() { /// `perry-codegen/src/target_layout.rs` (#7916). /// /// perry-codegen cannot depend on perry-runtime, so it carries its own copy of -/// this constant and uses it BOTH to size the inline-`new` bump allocation and -/// to emit `slot < max(field_count, FLOOR)` bounds checks around raw inline -/// slot loads/stores. The two failure modes point in opposite directions -/// (codegen too small under-allocates; codegen too large over-reads), so the -/// values must be exactly equal — pin the number on both sides. +/// this constant and uses it to size the inline-`new` bump allocation, which +/// must match the floor every runtime bounds check applies. The two failure +/// modes point in opposite directions (codegen too small under-allocates; +/// codegen too large over-reads), so the values must be exactly equal — pin the +/// number on both sides. #[test] fn inline_slot_floor_matches_codegen() { assert_eq!( @@ -1476,9 +1480,12 @@ fn stale_pre_grow_array_pointer_reads_the_real_length_in_object_ops() { /// #7563: an ARRAY receiver must never be read back as a class instance. /// -/// `ObjectHeader` is `{ object_type: u32, class_id: u32, … }` and `ArrayHeader` -/// is `{ length: u32, capacity: u32 }`, so the two u32s at offset 4 alias — an -/// array read as an `ObjectHeader` reports its **capacity** as a `class_id`. +/// `ObjectHeader` is `{ class_id: u32, parent_class_id: u32, … }` and +/// `ArrayHeader` is `{ length: u32, capacity: u32 }`, so the two u32s at offset +/// 0 alias — an array read as an `ObjectHeader` reports its **length** as a +/// `class_id`. (#8113 moved this from offset 4 / `capacity` when it deleted the +/// leading `object_type` word. Note that makes the collision DENSER, not +/// sparser: array lengths are small and consecutive, and so are class ids.) /// /// That mattered because `arr[Symbol.iterator]` resolves through /// `js_class_method_bind(arr, "values")`, whose receiver→class step used a bare @@ -1492,11 +1499,13 @@ fn stale_pre_grow_array_pointer_reads_the_real_length_in_object_ops() { fn array_receiver_is_never_read_as_a_class_id() { let arr = crate::array::js_array_alloc(3); assert!(!arr.is_null()); + crate::array::js_array_push(arr, crate::JSValue::from_bits(1.0f64.to_bits())); // Impersonate exactly the class id this array's bytes would have yielded. - let impersonated = unsafe { (*arr).capacity }; + // #8113: that is `length`, at offset 0, not `capacity`. + let impersonated = unsafe { (*arr).length }; assert_ne!( impersonated, 0, - "the test is vacuous unless the capacity is a non-zero (i.e. lookup-able) class id" + "the test is vacuous unless the length is a non-zero (i.e. lookup-able) class id" ); let arr_value = crate::value::js_nanbox_pointer(arr as i64); @@ -1638,3 +1647,127 @@ fn buffer_own_key_comes_from_the_expando_table_not_the_object_walk() { "an unknown key is not an own key" ); } +// --------------------------------------------------------------------------- +// #8113 — the trap this header shrink had to disarm. +// +// `ObjectHeader` used to open with `object_type: u32`, prefix-punned against +// `error::ErrorHeader`'s first word, and NINE sites read raw offset 0 to answer +// "is this an Error?". Deleting the word makes offset 0 `class_id` — and +// `OBJECT_TYPE_ERROR` is **2**, while class ids are handed out from 1, densely, +// in source-declaration order. So a surviving raw read reclassifies every +// instance of the SECOND class a program declares as an `ErrorHeader` and reads +// `message`/`name`/`stack`/`errors` out of its field slots: a silent wrong +// answer of exactly the #8100 shape. +// +// These tests are SABOTAGE-SHAPED. Each first asserts that the confusable value +// really is sitting at offset 0 — so a green run proves the GcHeader-kind test +// fired, not that the fixture happened to look harmless. +// --------------------------------------------------------------------------- + +/// The premise: an ordinary object CAN carry `class_id == OBJECT_TYPE_ERROR`, +/// and that value really is the first word of its header. +#[test] +fn an_ordinary_object_can_carry_the_error_type_tag_as_its_class_id() { + let obj = js_object_alloc(crate::error::OBJECT_TYPE_ERROR, 2); + assert!(!obj.is_null()); + unsafe { + assert_eq!((*obj).class_id, crate::error::OBJECT_TYPE_ERROR); + // Offset 0, read the way the retired discriminators read it. + let raw_word_0 = std::ptr::read(obj as *const u32); + assert_eq!( + raw_word_0, + crate::error::OBJECT_TYPE_ERROR, + "test premise: the pre-#8113 raw offset-0 read now yields \ + OBJECT_TYPE_ERROR for an ordinary object" + ); + } +} + +/// `Error.isError()` must not be fooled by it. (`error.rs:750`.) +#[test] +fn error_is_error_rejects_an_object_whose_class_id_equals_the_error_tag() { + let obj = js_object_alloc(crate::error::OBJECT_TYPE_ERROR, 2); + let value = crate::value::js_nanbox_pointer(obj as i64); + assert_eq!( + crate::error::js_error_is_error(value).to_bits(), + crate::value::TAG_FALSE, + "class_id == OBJECT_TYPE_ERROR must not read as a native Error" + ); + + // Not over-narrowed: a real Error still answers true. + let real = crate::error::js_error_new_with_message(crate::string::js_string_from_bytes( + b"boom".as_ptr(), + 4, + )); + let real_value = crate::value::js_nanbox_pointer(real as i64); + assert_eq!( + crate::error::js_error_is_error(real_value).to_bits(), + crate::value::TAG_TRUE, + "a genuine ErrorHeader must still classify as an Error" + ); +} + +/// `js_error_get_errors` must resolve `.errors` GENERICALLY for it rather than +/// returning the fixed `ErrorHeader.errors` slot. (`error.rs:1542`; the doc +/// there records the for-of corruption the fixed-slot read caused.) +#[test] +fn error_get_errors_does_not_read_a_fixed_slot_off_a_colliding_class_id() { + let obj = js_object_alloc(crate::error::OBJECT_TYPE_ERROR, 2); + unsafe { + assert_eq!((*obj).class_id, crate::error::OBJECT_TYPE_ERROR); + // Poison the slot the ErrorHeader layout would call `errors`. + let key = crate::string::js_string_from_bytes(b"errors".as_ptr(), 6); + let arr = crate::array::js_array_alloc(1); + crate::object::js_object_set_field_by_name( + obj, + key, + f64::from_bits(crate::value::js_nanbox_pointer(arr as i64).to_bits()), + ); + let got = crate::error::js_error_get_errors(obj as *mut crate::error::ErrorHeader); + assert_eq!( + got as usize, arr as usize, + "`.errors` on a class_id == 2 object must resolve as an ordinary \ + own property, not as ErrorHeader's fixed slot" + ); + } +} + +/// `js_dynamic_object_keys` must return the object's real keys, not the Error +/// triple. (`value/dynamic_object.rs:728`.) +#[test] +fn dynamic_object_keys_are_not_the_error_triple_for_a_colliding_class_id() { + let obj = js_object_alloc(crate::error::OBJECT_TYPE_ERROR, 2); + unsafe { + let key = crate::string::js_string_from_bytes(b"kk8113".as_ptr(), 6); + crate::object::js_object_set_field_by_name(obj, key, 1.0); + let keys = crate::value::js_dynamic_object_keys(obj as i64); + assert_eq!( + crate::array::js_array_length(keys), + 1, + "a class_id == 2 object must enumerate its OWN keys, not \ + [message, name, stack]" + ); + } +} + +/// The #6595 half: the store-plan gate must stay FALSE for a heap class object. +/// `object_is_regular` is the replacement for the deleted +/// `object_type == OBJECT_TYPE_REGULAR` read at `proxy.rs:1523`, and it is only +/// a valid one because it means `descriptor.object_kind == Ordinary` — not the +/// weaker "is an ObjectHeader". +#[test] +fn object_is_regular_excludes_a_heap_class_object() { + let obj = js_object_alloc(0x8113_0001, 1); + unsafe { + assert!( + crate::object::object_is_regular(obj), + "a fresh ordinary object is regular" + ); + crate::object::class_registry::js_object_mark_class(obj as i64); + assert!( + !crate::object::object_is_regular(obj), + "#6595: a heap class object must NOT be 'regular' — the store-plan \ + gate at proxy.rs keys off exactly this" + ); + } +} diff --git a/crates/perry-runtime/src/promise/rejection.rs b/crates/perry-runtime/src/promise/rejection.rs index 7a1bdb7090..dc3aaf6f19 100644 --- a/crates/perry-runtime/src/promise/rejection.rs +++ b/crates/perry-runtime/src/promise/rejection.rs @@ -177,9 +177,10 @@ fn describe_rejection_reason(v: f64) -> String { } if jv.is_pointer() { let ptr = jv.as_pointer::() as usize; - if crate::value::addr_class::is_plausible_heap_addr(ptr) - && unsafe { *(ptr as *const u32) } == crate::error::OBJECT_TYPE_ERROR - { + // #8113: `GcHeader.obj_type == GC_TYPE_ERROR`, not a raw offset-0 read. + // Offset 0 is `class_id` now, and `OBJECT_TYPE_ERROR` is 2 — an + // ordinary user class id. + if unsafe { crate::error::ptr_is_native_error(ptr) } { let eh = ptr as *const crate::error::ErrorHeader; let stack = unsafe { crate::exception::string_header_to_string((*eh).stack) }; return format!("error(0x{ptr:x}) stack={stack:?}"); @@ -460,9 +461,9 @@ fn print_unhandled_diagnostic(reason: f64) { // band — `fetch().then(r => { throw r })` uncaught) — the old bare // `>= 0x10000` deref'd the id as memory instead of printing the // fallback line. - if crate::value::addr_class::is_plausible_heap_addr(ptr) - && unsafe { *(ptr as *const u32) } == crate::error::OBJECT_TYPE_ERROR - { + // #8113: `GcHeader.obj_type == GC_TYPE_ERROR` (which subsumes the + // band+plausibility gate above), not a raw offset-0 read. + if unsafe { crate::error::ptr_is_native_error(ptr) } { let eh = ptr as *const crate::error::ErrorHeader; let stack_str = unsafe { crate::exception::string_header_to_string((*eh).stack) }; if !stack_str.is_empty() { diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index e23161f1fe..aa02b905f2 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -1513,7 +1513,7 @@ fn ordinary_set_with_receiver(target: f64, key: f64, value: f64, receiver: f64) let interned = crate::object::interned_key_ptr(key_ptr); // #6595: a per-evaluation CLASS OBJECT (what a // capture-carrying class materializes as, - // `object_type == OBJECT_TYPE_CLASS`) shares its + // `ShapeObjectKind::Class`) shares its // template cid with its instances, and its own-data // writes must reach the #6530 // `mirror_class_object_static_write` hook in @@ -1530,8 +1530,17 @@ fn ordinary_set_with_receiver(target: f64, key: f64, value: f64, receiver: f64) addr, ) && class_id != crate::object::NATIVE_MODULE_CLASS_ID - && (*(addr as *const crate::ObjectHeader)).object_type - == crate::error::OBJECT_TYPE_REGULAR + // #8113: this asks for ORDINARY specifically — + // it must stay FALSE for a class object or + // #6595 reopens. `object_is_regular` is exactly + // `descriptor.object_kind == Ordinary` since + // #8086, so it is the same predicate the + // deleted `object_type == OBJECT_TYPE_REGULAR` + // word expressed, not the weaker + // "is an ObjectHeader" test. + && crate::object::object_is_regular( + addr as *const crate::ObjectHeader, + ) && interned != 0; let verdict = if plan_eligible && crate::object::prop_plan::store_plan_check(class_id, interned) diff --git a/crates/perry-runtime/src/symbol.rs b/crates/perry-runtime/src/symbol.rs index b16060a522..997f7ccae9 100644 --- a/crates/perry-runtime/src/symbol.rs +++ b/crates/perry-runtime/src/symbol.rs @@ -370,8 +370,10 @@ static SYMBOL_EVER_REGISTERED: crate::registry_latch::RegistryLatch = /// **`false` is exact** — no symbol reads `false` — while `true` is merely /// "ask the registry". A non-symbol whose first word happens to equal /// `SYMBOL_MAGIC` (a `StringHeader` would need `utf16_len == 0x5359_4D42`, i.e. -/// a 2.8 GB string; an `ObjectHeader`'s `object_type` is a small tag) simply -/// pays the old probe and gets the old, correct answer. +/// a 2.8 GB string; an `ObjectHeader`'s first word is `class_id`, and ids are +/// handed out from 1 — #8113 deleted the `object_type` tag that used to sit +/// there, which does not change this argument) simply pays the old probe and +/// gets the old, correct answer. /// /// # Safety /// `ptr` must be readable for 4 bytes. Every caller is one that already diff --git a/crates/perry-runtime/src/thread.rs b/crates/perry-runtime/src/thread.rs index ac48468410..61952e90d5 100644 --- a/crates/perry-runtime/src/thread.rs +++ b/crates/perry-runtime/src/thread.rs @@ -631,7 +631,7 @@ unsafe fn serialize_object(obj: *const crate::object::ObjectHeader) -> Serialize } else { 0 }; - let field_count = (*obj).field_count as usize; + let field_count = crate::object::object_live_slot_count(obj) as usize; // Serialize field values let fields_ptr = diff --git a/crates/perry-runtime/src/typed_feedback.rs b/crates/perry-runtime/src/typed_feedback.rs index 8a3500dee1..71557fc166 100644 --- a/crates/perry-runtime/src/typed_feedback.rs +++ b/crates/perry-runtime/src/typed_feedback.rs @@ -766,16 +766,19 @@ fn object_shape(addr: usize) -> (usize, u32, u16) { } let class_id = (*ptr).class_id; // #8067 rung 3: every genuine ObjectHeader uses one token domain. - // Runtime allocators birth-stamp objects; the synchronization call is - // a defensive self-heal for old/synthetic callers and never falls back - // to a keys pointer. - let mut shape = crate::object::shapes::object_shape_id(ptr); - if shape == 0 { - shape = crate::object::shapes::synchronize_object_shape_descriptor( - ptr as *mut ObjectHeader, - ); - } - let shape = shape as usize; + // + // #8113 REMOVED the defensive self-heal that used to run here. It + // called `synchronize_object_shape_descriptor`, which derived the live + // inline-slot bound from the header's `field_count` word. That word is + // gone, so a self-heal on an UNSTAMPED receiver would now publish a + // descriptor claiming a bound of ZERO — silently truncating the + // object's traced and writable payload from a read-only observation + // path. Missing closed costs a PIC miss; healing wrongly loses fields. + // + // Nothing is expected to reach here unstamped: every allocator in + // `object/alloc.rs` birth-publishes, and the inline-`new` path stamps a + // module-init ShapeId. + let shape = crate::object::shapes::object_shape_id(ptr) as usize; (shape, class_id, gc_type) } } diff --git a/crates/perry-runtime/src/typed_feedback/tests.rs b/crates/perry-runtime/src/typed_feedback/tests.rs index 6941ce4f40..9ff9029fb7 100644 --- a/crates/perry-runtime/src/typed_feedback/tests.rs +++ b/crates/perry-runtime/src/typed_feedback/tests.rs @@ -1691,14 +1691,12 @@ fn typed_feedback_class_field_guard_ignores_object_header_shape_mirrors() { let class_id = 0x7EED_8067; let (obj, original_keys, key_x, receiver) = class_instance(class_id, b"x"); let expected_shape_id = shape_id(obj); - let original_field_count = unsafe { (*obj).field_count }; unsafe { - // These are ABI mirrors retained until the later header-shrink issue. - // An authoritative guard must not consult either one. + // `keys_array` is the last ABI mirror (#8113 deleted `field_count`; + // #8047 removes this one). An authoritative guard must not consult it. // GC_STORE_AUDIT(POINTER_FREE): test sabotage removes the compatibility edge by storing null. (*obj).keys_array = std::ptr::null_mut(); - (*obj).field_count = 0; } let passed = js_typed_feedback_class_field_get_guard( 8067, @@ -1717,7 +1715,6 @@ fn typed_feedback_class_field_guard_ignores_object_header_shape_mirrors() { &(*obj).keys_array as *const _ as usize, original_keys as u64, ); - (*obj).field_count = original_field_count; } assert_eq!(passed, 1, "guard must consume ShapeDescriptor facts"); diff --git a/crates/perry-runtime/src/url/url_class.rs b/crates/perry-runtime/src/url/url_class.rs index 952bd94061..c0e1082528 100644 --- a/crates/perry-runtime/src/url/url_class.rs +++ b/crates/perry-runtime/src/url/url_class.rs @@ -447,7 +447,9 @@ pub(crate) fn is_url_object_shape(url: *mut ObjectHeader) -> bool { return false; } unsafe { - if !is_gc_object_header(url) || (*url).class_id != 0 || (*url).field_count < URL_FIELD_COUNT + if !is_gc_object_header(url) + || (*url).class_id != 0 + || crate::object::object_live_slot_count(url) < URL_FIELD_COUNT { return false; } diff --git a/crates/perry-runtime/src/value/dynamic_object.rs b/crates/perry-runtime/src/value/dynamic_object.rs index c8872c9eab..7c1356f0bf 100644 --- a/crates/perry-runtime/src/value/dynamic_object.rs +++ b/crates/perry-runtime/src/value/dynamic_object.rs @@ -408,11 +408,14 @@ pub unsafe extern "C" fn js_dynamic_object_get_property( }; // #7930: TypedArrayHeader starts with `length: u32`, at the same payload - // offset where ObjectHeader stores its object-type word. Classify the - // receiver through the authoritative side table before any header-shaped - // dispatch below: a two-element typed array otherwise reads as - // `OBJECT_TYPE_ERROR == 2`, so `.length` / `.byteLength` enter the Error - // branch and return `undefined` even though construction was correct. + // offset where ObjectHeader used to store its object-type word. Classify + // the receiver through the authoritative side table before any + // header-shaped dispatch below: a two-element typed array otherwise read as + // `OBJECT_TYPE_ERROR == 2`, so `.length` / `.byteLength` entered the Error + // branch and returned `undefined` even though construction was correct. + // (#8113 replaced that raw read with a `GcHeader` kind test, which no + // longer confuses the two — but the side-table classification below is + // still what gives the typed array its property semantics.) // // Delegate to the normal by-name typed-array path rather than duplicating // its property semantics here. It gives an own expando/accessor precedence @@ -434,7 +437,7 @@ pub unsafe extern "C" fn js_dynamic_object_get_property( // Check if this is a ClosureHeader (CLOSURE_MAGIC at offset 12). // ClosureHeader layout: func_ptr (8B), capture_count u32 (4B), type_tag u32 (4B), captures at 16+ - // ObjectHeader layout: object_type u32 (4B), class_id u32 (4B), parent_class_id u32 (4B), field_count u32 (4B), keys_array (8B), ... + // ObjectHeader layout (#8113): class_id u32 (4B), parent_class_id u32 (4B), keys_array (8B), meta (8B) // Without this check, the closure's capture[0] at offset 16 would be read as keys_array → crash. if crate::closure::is_closure_ptr(ptr as usize) { return crate::closure::closure_get_dynamic_prop(ptr as usize, property_name); @@ -537,8 +540,12 @@ pub unsafe extern "C" fn js_dynamic_object_get_property( } } - // Check the object type tag (first u32 field of both ObjectHeader and ErrorHeader) - let object_type = *(ptr as *const u32); + // #8113: `GcHeader.obj_type == GC_TYPE_ERROR`. This used to read the punned + // `object_type` word at offset 0; offset 0 is `class_id` now, so the raw + // read would classify every object whose class id happens to be + // `OBJECT_TYPE_ERROR` (= 2) as an Error and hand its field slots to + // `ErrorHeader`'s accessors. + let is_native_error = crate::error::ptr_is_native_error(ptr as usize); // Handle native module namespace objects (e.g., `const fn = fs.lstatSync`) // Create a bound method closure so the method reference can be called @@ -555,7 +562,7 @@ pub unsafe extern "C" fn js_dynamic_object_get_property( } // Handle Error objects specially - if object_type == crate::error::OBJECT_TYPE_ERROR { + if is_native_error { // An own expando / accessor property (installed via defineProperty, or a // reassigned `message`/`stack`) lives in the exotic side tables and wins // over the builtin slot. The compiled member-get path consults these, @@ -734,11 +741,9 @@ pub unsafe extern "C" fn js_dynamic_object_keys(ptr: i64) -> *mut crate::array:: return crate::array::js_array_alloc(0); } - // Check the object type tag (first u32 field of both ObjectHeader and ErrorHeader) - let object_type = *(ptr as *const u32); - + // #8113: `GcHeader.obj_type == GC_TYPE_ERROR` — see `js_dynamic_get_property`. // Handle Error objects specially - they have fixed keys - if object_type == crate::error::OBJECT_TYPE_ERROR { + if crate::error::ptr_is_native_error(ptr as usize) { // Error objects have keys: "message", "name", "stack" let keys = crate::array::js_array_alloc(3); diff --git a/crates/perry-runtime/src/weakref.rs b/crates/perry-runtime/src/weakref.rs index 4a4b5f19ae..0dba392992 100644 --- a/crates/perry-runtime/src/weakref.rs +++ b/crates/perry-runtime/src/weakref.rs @@ -376,7 +376,7 @@ pub(crate) unsafe fn is_weak_target_trace_slot( // Field 0 is the weak target for both: WeakRef's referent and a // WeakMap/WeakSet entry's key. CLASS_ID_WEAKREF | CLASS_ID_WEAK_ENTRY => { - (*obj).field_count > 0 && slot == object_field_slot(obj, 0) + crate::object::object_live_slot_count(obj) > 0 && slot == object_field_slot(obj, 0) } // A finalization record's target (field 0) AND its unregister token // (field 1) are both weak. The spec's [[UnregisterToken]] is an @@ -384,8 +384,9 @@ pub(crate) unsafe fn is_weak_target_trace_slot( // `registry.register(obj, held, obj)` pin the target immortal // (2026-07-09 GC audit). CLASS_ID_FINALIZATION_RECORD => { - ((*obj).field_count > 0 && slot == object_field_slot(obj, 0)) - || ((*obj).field_count > 1 && slot == object_field_slot(obj, 1)) + (crate::object::object_live_slot_count(obj) > 0 && slot == object_field_slot(obj, 0)) + || (crate::object::object_live_slot_count(obj) > 1 + && slot == object_field_slot(obj, 1)) } _ => false, } diff --git a/crates/perry-stdlib/src/fetch/mod.rs b/crates/perry-stdlib/src/fetch/mod.rs index a882ebef41..839ea4ce76 100644 --- a/crates/perry-stdlib/src/fetch/mod.rs +++ b/crates/perry-stdlib/src/fetch/mod.rs @@ -356,7 +356,7 @@ pub extern "C" fn js_fetch_response_count() -> i64 { /// rejection. Pre-fix (#236) every fetch error site NaN-boxed a bare /// `*StringHeader` with `POINTER_TAG` (0x7FFD), which the uncaught-exception /// printer in `perry-runtime/src/exception.rs` then read as an -/// `*ObjectHeader.object_type` u32 — `byte_len` of the message string is +/// the first `ObjectHeader` u32 (`class_id` since #8113) — `byte_len` of the message string is /// neither `OBJECT_TYPE_ERROR` (2) nor `OBJECT_TYPE_REGULAR` (1), so the /// printer fell through to the generic stringifier which printed /// `Uncaught exception: [object Object]`. Allocating a real diff --git a/crates/perry-stdlib/src/worker_threads.rs b/crates/perry-stdlib/src/worker_threads.rs index f69e9f9289..d82efc80fa 100644 --- a/crates/perry-stdlib/src/worker_threads.rs +++ b/crates/perry-stdlib/src/worker_threads.rs @@ -673,9 +673,14 @@ fn message_value_is_uncloneable(value: f64, visited: &mut HashSet) -> boo let Some(object) = object_ptr_from_value(value) else { return false; }; + // #8113: the header no longer carries `field_count`; the authoritative live + // inline-slot bound is the ShapeId descriptor's, exposed as + // `object_live_slot_count`. The `keys_array.is_null()` arm is deliberate — + // class instances have no keys array, and `js_object_keys` filters private + // `#x` fields, so it is NOT the same set. let field_count = unsafe { if (*object).keys_array.is_null() { - (*object).field_count + perry_runtime::object_live_slot_count(object) } else { perry_runtime::array::js_array_length((*object).keys_array) } diff --git a/crates/perry-ui-android/src/json.rs b/crates/perry-ui-android/src/json.rs deleted file mode 100644 index b69183ed83..0000000000 --- a/crates/perry-ui-android/src/json.rs +++ /dev/null @@ -1,606 +0,0 @@ -//! JSON handling for Android — copied from perry-stdlib/src/framework/json.rs -//! -//! perry-stdlib can't cross-compile for Android (OpenSSL dependency), so we -//! include the essential JSON functions directly. These replace the no-op stubs -//! in stdlib_stubs.rs. - -use perry_runtime::{ - js_array_alloc, js_array_push, js_object_alloc, js_object_set_field, js_object_set_keys, - js_string_from_bytes, JSValue, StringHeader, -}; -use std::fmt::Write as FmtWrite; - -// ─── Zero-copy string access ────────────────────────────────────────────────── - -#[inline] -unsafe fn str_from_header<'a>(ptr: *const StringHeader) -> Option<&'a str> { - if ptr.is_null() { - return None; - } - let len = (*ptr).byte_len as usize; - let data_ptr = (ptr as *const u8).add(std::mem::size_of::()); - let bytes = std::slice::from_raw_parts(data_ptr, len); - Some(std::str::from_utf8_unchecked(bytes)) -} - -unsafe fn string_from_header(ptr: *const StringHeader) -> Option { - str_from_header(ptr).map(|s| s.to_string()) -} - -// ─── Direct JSON parser ──────────────────────────────────────────────────────── - -struct DirectParser<'a> { - input: &'a [u8], - pos: usize, -} - -impl<'a> DirectParser<'a> { - fn new(input: &'a [u8]) -> Self { - Self { input, pos: 0 } - } - - #[inline] - fn peek(&self) -> Option { - self.input.get(self.pos).copied() - } - - #[inline] - fn advance(&mut self) { - self.pos += 1; - } - - #[inline] - fn skip_whitespace(&mut self) { - while self.pos < self.input.len() { - match self.input[self.pos] { - b' ' | b'\t' | b'\n' | b'\r' => self.pos += 1, - _ => break, - } - } - } - - #[inline] - fn expect(&mut self, ch: u8) -> bool { - self.skip_whitespace(); - if self.peek() == Some(ch) { - self.advance(); - true - } else { - false - } - } - - unsafe fn parse_value(&mut self) -> JSValue { - self.skip_whitespace(); - match self.peek() { - Some(b'"') => self.parse_string_value(), - Some(b'{') => self.parse_object(), - Some(b'[') => self.parse_array(), - Some(b't') => self.parse_true(), - Some(b'f') => self.parse_false(), - Some(b'n') => self.parse_null(), - Some(c) if c == b'-' || c.is_ascii_digit() => self.parse_number(), - _ => JSValue::null(), - } - } - - unsafe fn parse_string_value(&mut self) -> JSValue { - if let Some(s) = self.parse_string_bytes() { - let ptr = js_string_from_bytes(s.as_ptr(), s.len() as u32); - JSValue::string_ptr(ptr) - } else { - JSValue::null() - } - } - - fn parse_string_bytes(&mut self) -> Option> { - if self.peek() != Some(b'"') { - return None; - } - self.advance(); - - let mut result = Vec::new(); - loop { - if self.pos >= self.input.len() { - return None; - } - let ch = self.input[self.pos]; - self.pos += 1; - match ch { - b'"' => return Some(result), - b'\\' => { - if self.pos >= self.input.len() { - return None; - } - let esc = self.input[self.pos]; - self.pos += 1; - match esc { - b'"' => result.push(b'"'), - b'\\' => result.push(b'\\'), - b'/' => result.push(b'/'), - b'n' => result.push(b'\n'), - b'r' => result.push(b'\r'), - b't' => result.push(b'\t'), - b'b' => result.push(0x08), - b'f' => result.push(0x0C), - b'u' => { - if self.pos + 4 > self.input.len() { - return None; - } - let hex = - std::str::from_utf8(&self.input[self.pos..self.pos + 4]).ok()?; - let code = u16::from_str_radix(hex, 16).ok()?; - self.pos += 4; - if (0xD800..=0xDBFF).contains(&code) { - if self.pos + 6 <= self.input.len() - && self.input[self.pos] == b'\\' - && self.input[self.pos + 1] == b'u' - { - let hex2 = std::str::from_utf8( - &self.input[self.pos + 2..self.pos + 6], - ) - .ok()?; - let low = u16::from_str_radix(hex2, 16).ok()?; - self.pos += 6; - let codepoint = 0x10000 - + ((code as u32 - 0xD800) << 10) - + (low as u32 - 0xDC00); - if let Some(c) = char::from_u32(codepoint) { - let mut buf = [0u8; 4]; - let s = c.encode_utf8(&mut buf); - result.extend_from_slice(s.as_bytes()); - } - } - } else { - if let Some(c) = char::from_u32(code as u32) { - let mut buf = [0u8; 4]; - let s = c.encode_utf8(&mut buf); - result.extend_from_slice(s.as_bytes()); - } - } - } - _ => result.push(esc), - } - } - _ => result.push(ch), - } - } - } - - unsafe fn parse_object(&mut self) -> JSValue { - self.advance(); - self.skip_whitespace(); - - let mut pairs: Vec<(Vec, JSValue)> = Vec::new(); - - if self.peek() == Some(b'}') { - self.advance(); - let js_obj = js_object_alloc(0, 0); - let keys_arr = js_array_alloc(0); - js_object_set_keys(js_obj, keys_arr); - return JSValue::object_ptr(js_obj as *mut u8); - } - - loop { - self.skip_whitespace(); - let key = match self.parse_string_bytes() { - Some(k) => k, - None => break, - }; - - if !self.expect(b':') { - break; - } - - let value = self.parse_value(); - pairs.push((key, value)); - - self.skip_whitespace(); - if self.peek() == Some(b',') { - self.advance(); - } else { - break; - } - } - self.expect(b'}'); - - let count = pairs.len(); - let js_obj = js_object_alloc(0, count as u32); - let keys_arr = js_array_alloc(count as u32); - - for (idx, (key, value)) in pairs.into_iter().enumerate() { - let key_ptr = js_string_from_bytes(key.as_ptr(), key.len() as u32); - js_array_push(keys_arr, JSValue::string_ptr(key_ptr)); - js_object_set_field(js_obj, idx as u32, value); - } - js_object_set_keys(js_obj, keys_arr); - JSValue::object_ptr(js_obj as *mut u8) - } - - unsafe fn parse_array(&mut self) -> JSValue { - self.advance(); - self.skip_whitespace(); - - let js_arr = js_array_alloc(16); - - if self.peek() == Some(b']') { - self.advance(); - return JSValue::object_ptr(js_arr as *mut u8); - } - - loop { - let value = self.parse_value(); - js_array_push(js_arr, value); - - self.skip_whitespace(); - if self.peek() == Some(b',') { - self.advance(); - } else { - break; - } - } - self.expect(b']'); - JSValue::object_ptr(js_arr as *mut u8) - } - - unsafe fn parse_number(&mut self) -> JSValue { - let start = self.pos; - if self.peek() == Some(b'-') { - self.advance(); - } - while self.pos < self.input.len() && self.input[self.pos].is_ascii_digit() { - self.pos += 1; - } - if self.pos < self.input.len() && self.input[self.pos] == b'.' { - self.pos += 1; - while self.pos < self.input.len() && self.input[self.pos].is_ascii_digit() { - self.pos += 1; - } - } - if self.pos < self.input.len() - && (self.input[self.pos] == b'e' || self.input[self.pos] == b'E') - { - self.pos += 1; - if self.pos < self.input.len() - && (self.input[self.pos] == b'+' || self.input[self.pos] == b'-') - { - self.pos += 1; - } - while self.pos < self.input.len() && self.input[self.pos].is_ascii_digit() { - self.pos += 1; - } - } - - let num_str = std::str::from_utf8_unchecked(&self.input[start..self.pos]); - let value: f64 = num_str.parse().unwrap_or(0.0); - JSValue::number(value) - } - - unsafe fn parse_true(&mut self) -> JSValue { - if self.pos + 4 <= self.input.len() && &self.input[self.pos..self.pos + 4] == b"true" { - self.pos += 4; - JSValue::bool(true) - } else { - JSValue::null() - } - } - - unsafe fn parse_false(&mut self) -> JSValue { - if self.pos + 5 <= self.input.len() && &self.input[self.pos..self.pos + 5] == b"false" { - self.pos += 5; - JSValue::bool(false) - } else { - JSValue::null() - } - } - - unsafe fn parse_null(&mut self) -> JSValue { - if self.pos + 4 <= self.input.len() && &self.input[self.pos..self.pos + 4] == b"null" { - self.pos += 4; - } - JSValue::null() - } -} - -// ─── NaN-boxing constants ───────────────────────────────────────────────────── - -const TAG_NULL: u64 = 0x7FFC_0000_0000_0002; -const TAG_FALSE: u64 = 0x7FFC_0000_0000_0003; -const TAG_TRUE: u64 = 0x7FFC_0000_0000_0004; -const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; -const STRING_TAG: u64 = 0x7FFF_0000_0000_0000; -const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; - -const TYPE_UNKNOWN: u32 = 0; -const TYPE_OBJECT: u32 = 1; -const TYPE_ARRAY: u32 = 2; - -/// #7448: an UNTAGGED heap pointer that reached a type-erased JSON walk. -/// -/// This used to be a hand-rolled bit test: -/// -/// ```ignore -/// exponent == 0 && mantissa != 0 && sign == 0 -/// ``` -/// -/// which is bit-for-bit the IEEE-754 POSITIVE-SUBNORMAL predicate, so every -/// positive denormal `Number` was classified as a pointer and dereferenced. In -/// the main runtime the identical code SIGSEGV'd on `JSON.stringify(1e-317)` -/// and returned a silent `null` for `5e-324`, both reachable from untrusted -/// input through `JSON.stringify(JSON.parse(text))` (#7447). -/// -/// No bit test can fix it: a raw pointer and a positive subnormal occupy the -/// same bit patterns by construction, which is why the runtime's version went -/// through two failed narrowings (`top16 < 0x7FF8`, then `top16 == 0`) before -/// landing on allocation membership. So this asks the runtime instead of -/// keeping a third divergent copy — `ptr_is_tracked_heap_object` decides from -/// the page map and the malloc registry, both dereference-free, so a forged or -/// unmapped address is rejected before any field is read. -#[inline] -unsafe fn extract_pointer(bits: u64) -> Option<*const u8> { - let tag = bits & 0xFFFF_0000_0000_0000; - if tag == POINTER_TAG { - Some((bits & POINTER_MASK) as *const u8) - } else if perry_runtime::json::ptr_is_tracked_heap_object(bits as *const u8) { - Some(bits as *const u8) - } else { - None - } -} - -#[inline] -unsafe fn is_object_pointer(ptr: *const u8) -> bool { - let obj = ptr as *const perry_runtime::ObjectHeader; - let potential_keys_ptr = (*obj).keys_array as u64; - let top_16_bits = potential_keys_ptr >> 48; - let is_likely_heap_pointer = top_16_bits == 0 || top_16_bits == 1; - let looks_like_valid_pointer = - is_likely_heap_pointer && potential_keys_ptr > 0x10000 && (potential_keys_ptr & 0x7) == 0; - - if looks_like_valid_pointer { - let keys_arr = (*obj).keys_array; - let keys_len = (*keys_arr).length; - let keys_cap = (*keys_arr).capacity; - let field_count = (*obj).field_count; - keys_len <= keys_cap - && keys_len > 0 - && keys_cap < 1000 - && field_count == keys_len - && field_count < 1000 - } else { - false - } -} - -#[inline] -unsafe fn write_number(buf: &mut String, value: f64) { - if value.is_nan() || value.is_infinite() { - buf.push_str("null"); - } else if value.fract() == 0.0 && value.abs() < (i64::MAX as f64) { - let mut itoa_buf = itoa::Buffer::new(); - buf.push_str(itoa_buf.format(value as i64)); - } else { - let mut ryu_buf = ryu::Buffer::new(); - buf.push_str(ryu_buf.format(value)); - } -} - -#[inline] -unsafe fn write_escaped_string(buf: &mut String, s: &str) { - buf.push('"'); - let bytes = s.as_bytes(); - let mut start = 0; - for (i, &b) in bytes.iter().enumerate() { - let escape = match b { - b'"' => Some("\\\""), - b'\\' => Some("\\\\"), - b'\n' => Some("\\n"), - b'\r' => Some("\\r"), - b'\t' => Some("\\t"), - 0..=0x1f => { - if start < i { - buf.push_str(&s[start..i]); - } - let _ = write!(buf, "\\u{:04x}", b); - start = i + 1; - continue; - } - _ => None, - }; - if let Some(esc) = escape { - if start < i { - buf.push_str(&s[start..i]); - } - buf.push_str(esc); - start = i + 1; - } - } - if start < bytes.len() { - buf.push_str(&s[start..]); - } - buf.push('"'); -} - -unsafe fn stringify_value(value: f64, type_hint: u32, buf: &mut String) { - let bits: u64 = value.to_bits(); - - if bits == TAG_NULL { - buf.push_str("null"); - return; - } - if bits == TAG_TRUE { - buf.push_str("true"); - return; - } - if bits == TAG_FALSE { - buf.push_str("false"); - return; - } - - let tag = bits & 0xFFFF_0000_0000_0000; - if tag == STRING_TAG { - let str_ptr = (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"); - } - return; - } - - if let Some(ptr) = extract_pointer(bits) { - if type_hint == TYPE_OBJECT { - stringify_object(ptr, buf); - return; - } - if type_hint == TYPE_ARRAY { - stringify_array(ptr, buf); - return; - } - if is_object_pointer(ptr) { - stringify_object(ptr, buf); - } else { - let arr = ptr as *const perry_runtime::ArrayHeader; - if !arr.is_null() { - let len = (*arr).length; - let cap = (*arr).capacity; - if len <= cap && cap > 0 && cap < 10000 { - stringify_array(ptr, buf); - return; - } - } - let str_ptr = ptr as *const StringHeader; - if let Some(s) = str_from_header(str_ptr) { - write_escaped_string(buf, s); - } else { - buf.push_str("null"); - } - } - return; - } - - write_number(buf, value); -} - -unsafe fn stringify_object(ptr: *const u8, buf: &mut String) { - let obj = ptr as *const perry_runtime::ObjectHeader; - let num_fields = (*obj).field_count; - buf.push('{'); - - let keys_arr = (*obj).keys_array; - let keys_len = (*keys_arr).length; - let keys_elements = (keys_arr as *const u8) - .add(std::mem::size_of::()) - as *const f64; - let fields_ptr = - (ptr as *const u8).add(std::mem::size_of::()) as *const f64; - - for f in 0..num_fields { - if f > 0 { - buf.push(','); - } - if (f as u32) < keys_len { - let key_f64 = *keys_elements.add(f as usize); - let key_bits = key_f64.to_bits(); - let key_tag = key_bits & 0xFFFF_0000_0000_0000; - let key_ptr = if key_tag == STRING_TAG || key_tag == POINTER_TAG { - (key_bits & POINTER_MASK) as *const StringHeader - } else { - key_bits as *const StringHeader - }; - if let Some(key_str) = str_from_header(key_ptr) { - buf.push('"'); - buf.push_str(key_str); - buf.push_str("\":"); - } else { - let _ = write!(buf, "\"field{}\":", f); - } - } else { - let _ = write!(buf, "\"field{}\":", f); - } - let field_val = *fields_ptr.add(f as usize); - stringify_value(field_val, TYPE_UNKNOWN, buf); - } - buf.push('}'); -} - -unsafe fn stringify_array(ptr: *const u8, buf: &mut String) { - let arr = ptr as *const perry_runtime::ArrayHeader; - let len = (*arr).length; - let elements = - (ptr as *const u8).add(std::mem::size_of::()) as *const f64; - - buf.push('['); - for i in 0..len { - if i > 0 { - buf.push(','); - } - let elem = *elements.add(i as usize); - let elem_bits = elem.to_bits(); - let elem_tag = elem_bits & 0xFFFF_0000_0000_0000; - - if elem_tag == STRING_TAG { - let str_ptr = (elem_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 elem_bits == TAG_NULL { - buf.push_str("null"); - } else if elem_bits == TAG_TRUE { - buf.push_str("true"); - } else if elem_bits == TAG_FALSE { - buf.push_str("false"); - // #7448 converted the object path to `extract_pointer` but left this - // array-element path calling the `is_raw_pointer` it deleted, so - // perry-ui-android stopped compiling for any Android target. Routing - // it through the same helper also gives array elements the fix the - // object path already had: the old bit test was the IEEE-754 - // positive-subnormal predicate, so every positive denormal element was - // classified as a pointer and dereferenced (#7447). - } else if let Some(elem_ptr) = extract_pointer(elem_bits) { - if is_object_pointer(elem_ptr) { - stringify_object(elem_ptr, buf); - } else { - let arr_elem = elem_ptr as *const perry_runtime::ArrayHeader; - let arr_len = (*arr_elem).length; - let arr_cap = (*arr_elem).capacity; - if arr_len <= arr_cap && arr_cap > 0 && arr_cap < 10000 { - stringify_array(elem_ptr, buf); - } else { - let str_ptr = elem_ptr as *const StringHeader; - if let Some(s) = str_from_header(str_ptr) { - write_escaped_string(buf, s); - } else { - buf.push_str("null"); - } - } - } - } else { - write_number(buf, elem); - } - } - buf.push(']'); -} - -#[inline] -unsafe fn estimate_json_size(value: f64, type_hint: u32) -> usize { - let bits = value.to_bits(); - if let Some(ptr) = extract_pointer(bits) { - if type_hint == TYPE_ARRAY || (!is_object_pointer(ptr) && type_hint != TYPE_OBJECT) { - let arr = ptr as *const perry_runtime::ArrayHeader; - let len = (*arr).length as usize; - return (len * 300).max(256); - } - if type_hint == TYPE_OBJECT || is_object_pointer(ptr) { - let obj = ptr as *const perry_runtime::ObjectHeader; - let fields = (*obj).field_count as usize; - return (fields * 200).max(256); - } - } - 4096 -} - -// ─── Exported FFI functions ─────────────────────────────────────────────────── -// js_json_* functions are now provided by perry-runtime/json.rs diff --git a/crates/perry-ui-android/src/lib.rs b/crates/perry-ui-android/src/lib.rs index f3f0b140f5..2cf555b3a4 100644 --- a/crates/perry-ui-android/src/lib.rs +++ b/crates/perry-ui-android/src/lib.rs @@ -24,7 +24,6 @@ pub mod geisterhand_style; pub mod geolocation; pub mod image_picker; pub mod jni_bridge; -pub mod json; pub mod keyboard; pub mod keychain; pub mod location; diff --git a/docs/object-write-matrix.md b/docs/object-write-matrix.md index a7e43410c6..38333ee51a 100644 --- a/docs/object-write-matrix.md +++ b/docs/object-write-matrix.md @@ -22,8 +22,11 @@ lack of benefit. `proxy/put_value.rs::js_put_value_set_ic_miss`): static (interned/const) key, target ≡ receiver expression, safepoint-free RHS, heap object, non-forwarded, blocking flags clear (frozen/sealed/no-extend/descriptors/ - typed-intact), `object_type == REGULAR`, **`class_id != 0`**, shape-token - match (id-or-keys discriminated), slot in bounds. + typed-intact), **`class_id != 0`** OR the runtime's plain-ordinary birth flag + (#8098), shape-token match on the ShapeId, slot in bounds. (The + `object_type == REGULAR` term this list used to carry was already stale — the + emitted precheck reads `class_id` and the ShapeId, never offset 0 — and #8113 + deleted the word.) 3. **Runtime fast path** (header-first classification + existing-own-data overwrite routing in `js_object_set_field_by_name` / `put_value_set`): everything else that is still an ordinary data write. diff --git a/docs/src/platforms/watchos.md b/docs/src/platforms/watchos.md index fef41f7e5e..11202ee324 100644 --- a/docs/src/platforms/watchos.md +++ b/docs/src/platforms/watchos.md @@ -104,11 +104,14 @@ them into a fat binary — see [Publishing to the App Store](watchos-app-store.m > whose layout includes a pointer shifts on arm64_32 — e.g. `ClosureHeader`'s > `type_tag` sits at +12 after an 8-byte `func_ptr` on 64-bit but at +8 after a > 4-byte one on ILP32, and `ObjectHeader`'s field region starts at +24 on 64-bit -> but +20 on ILP32 (the trailing `keys_array` pointer is 4 bytes). NEVER hardcode +> but +16 on ILP32 (both trailing pointers — `keys_array` and `meta` — are 4 +> bytes there). Those two numbers were +32/+24 until #8113 deleted the header's +> `object_type` and `field_count` words; that is exactly why they must be +> derived, not written down. NEVER hardcode > such an offset: in `perry-runtime` use `std::mem::offset_of!` / `size_of` > (these track the target); in `perry-codegen` (which runs on the host but emits > for the target) derive it from the target triple via `crate::target_layout`. -> Hardcoded `12` (closure magic) and `24` (`ObjectHeader` size) were the original +> Hardcoded `12` (closure magic) and a hardcoded `ObjectHeader` size were the original > arm64_32 startup-crash root causes — a real getter failed its `CLOSURE_MAGIC` > probe, was judged non-callable, and the resulting `TypeError` value-coercion > dereferenced the closure as an object. diff --git a/scripts/addr_class_ratchet_baseline.txt b/scripts/addr_class_ratchet_baseline.txt index 27c8cc7288..b0156607e1 100644 --- a/scripts/addr_class_ratchet_baseline.txt +++ b/scripts/addr_class_ratchet_baseline.txt @@ -239,7 +239,6 @@ lone-valid-obj-ptr | crates/perry-runtime/src/object/field_get_set/get_field_by_ lone-valid-obj-ptr | crates/perry-runtime/src/object/field_get_set/has_property.rs | 1 lone-valid-obj-ptr | crates/perry-runtime/src/object/field_get_set/ic_miss.rs | 1 lone-valid-obj-ptr | crates/perry-runtime/src/object/field_set_by_name/attr_variants.rs | 2 -lone-valid-obj-ptr | crates/perry-runtime/src/object/field_set_by_name/tail.rs | 1 lone-valid-obj-ptr | crates/perry-runtime/src/object/global_this/array_error.rs | 1 lone-valid-obj-ptr | crates/perry-runtime/src/object/global_this/fetch_globals.rs | 1 lone-valid-obj-ptr | crates/perry-runtime/src/object/global_this/typed_array.rs | 1 diff --git a/scripts/shape_descriptor_census.py b/scripts/shape_descriptor_census.py index ebb69b1ff3..982d7fc827 100644 --- a/scripts/shape_descriptor_census.py +++ b/scripts/shape_descriptor_census.py @@ -1,5 +1,12 @@ #!/usr/bin/env python3 -"""#8067 exact shape-header census plus authority-order sabotage tests.""" +"""#8067/#8113 exact shape-header census plus authority-order sabotage tests. + +#8113 deleted `ObjectHeader::object_type` and `::field_count`, so `keys_array` +is the last compatibility mirror and the only field this census tracks. The +deleted pair is now guarded structurally instead: `assert_header_fields` pins +the exact declared field list, so re-adding a word is red rather than merely +un-baselined. +""" from __future__ import annotations @@ -13,7 +20,11 @@ ROOT = Path(__file__).resolve().parents[1] BASELINE_PATH = ROOT / "scripts" / "shape_descriptor_census_baseline.json" -FIELDS = ("object_type", "field_count", "keys_array") +FIELDS = ("keys_array",) +# The exact `ObjectHeader` field list, in order. #8113 took it from six fields +# (32 bytes LP64) to four (24). Changing it is an ABI change with a published +# crates.io mirror (`perry-ffi`), so it must be a deliberate edit here too. +OBJECT_HEADER_FIELDS = ("class_id", "parent_class_id", "keys_array", "meta") RAW_STRING_START = re.compile(r'(?:br|r)(?P#{0,255})"') RUST_SPECIAL = re.compile( r"//|/\*|(?:b)?'(?:\\(?:x[0-9A-Fa-f]{2}|u\{[0-9A-Fa-f_]+\}|.)|[^'\\\n])'|(?:br|r)#{0,255}\"|(?:b|c)?\"" @@ -214,6 +225,7 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: authority_paths = ( "crates/perry-runtime/src/object/shapes.rs", "crates/perry-runtime/src/object/mod.rs", + "crates/perry-runtime/src/object/live_slots.rs", "crates/perry-codegen/src/lower_call/new_alloc.rs", "crates/perry-runtime/src/gc/layout_slot_visit.rs", "crates/perry-runtime/src/object/field_set_by_name/tail.rs", @@ -238,6 +250,7 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: clean = stripped_sources({path: sources[path] for path in authority_paths}) shapes = clean["crates/perry-runtime/src/object/shapes.rs"] object_mod = clean["crates/perry-runtime/src/object/mod.rs"] + live_slots = clean["crates/perry-runtime/src/object/live_slots.rs"] codegen_alloc = clean["crates/perry-codegen/src/lower_call/new_alloc.rs"] layout_visit = clean["crates/perry-runtime/src/gc/layout_slot_visit.rs"] transition_tail = clean[ @@ -341,13 +354,37 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: "inner.ids_by_facts.entry", "by-id descriptor before reverse accelerator", ) - sync = function_body(shapes, "synchronize_object_shape_descriptor_from") + sync = function_body(shapes, "publish_object_shape_from") assert_before( sync, "shape_descriptor_ensure", "(*obj).parent_class_id = id", "descriptor before ObjectHeader ShapeId", ) + # #8113 MINT-THEN-STAMP. With `field_count` deleted, the descriptor is the + # only record of the live inline-slot bound, so a stamp-cleared window is a + # window in which the collector traces ZERO payload slots. No publication + # path may clear, and the only surviving `clear_object_shape_stamp` must be + # test-only. + for name in ( + "publish_object_shape_from", + "publish_object_live_slot_count", + "birth_publish_object_shape", + "stamp_object_shape", + "birth_stamp_object_shape", + ): + if "clear_object_shape_stamp" in function_body(shapes, name): + raise CensusError(f"{name} clears the shape stamp: the live-slot bound has no mirror") + if "clear_object_shape_stamp" in function_body(object_mod, "set_object_keys_array_with_live"): + raise CensusError( + "set_object_keys_array_with_live clears the shape stamp: " + "the live-slot bound has no mirror" + ) + if not re.search( + r"#\[cfg\(test\)\]\s*\n\s*#\[inline\]\s*\n\s*pub\(crate\) unsafe fn clear_object_shape_stamp", + shapes, + ): + raise CensusError("clear_object_shape_stamp escaped its #[cfg(test)] gate") retirement = function_body(shapes, "retain_key_count_versions") require_code( retirement, @@ -362,7 +399,27 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: if "descriptors.remove" in function_body(shapes, name): raise CensusError(f"{name} eagerly deletes a sibling descriptor") - require_code(object_mod, r"\bfn\s+set_object_live_slot_count\b", "central live-slot publication helper") + require_code( + live_slots, + r"\bfn\s+set_object_live_slot_count\b", + "central live-slot publication helper", + ) + # #8113: that helper must delegate to the mint-then-stamp primitive, not + # write a header word of its own (there is no longer one to write). + require_code( + function_body(live_slots, "set_object_live_slot_count"), + r"shapes::publish_object_live_slot_count\s*\(", + "live-slot publication goes through mint-then-stamp", + ) + # #8113: the derived bound has no header mirror, so it must come from the + # descriptor and fail CLOSED (0) when there is none. + live_body = function_body(live_slots, "object_live_slot_count") + require_code( + live_body, + r"live_inline_slot_count", + "live-slot bound derived from the ShapeId descriptor", + ) + require_code(live_body, r"unwrap_or\s*\(\s*0\s*\)", "live-slot bound fails closed") alloc_body = function_body(codegen_alloc, "emit_instance_alloc_inner") require_code(alloc_body, r"\bdescriptor_facts_exact\b", "raw-inline exact-facts admission gate") @@ -455,7 +512,17 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: if re.search(r"else\s*\{\s*(?:keys|\(\s*\*\s*obj\s*\)\.keys_array)\s+as\s+u64", body): raise CensusError(f"{label} reintroduced a keys-pointer token") - # Emitted guards must not read the three payload offsets #8047 will remove. + # Emitted guards may read exactly two header offsets: `class_id` @0 and the + # ShapeId @4. Everything at or past 8 is a mirror (`keys_array` @8, `meta` + # @16) that #8047 removes, and reading one as a shape fact is the bug this + # census exists to catch. + # + # #8113 also fixed this arm's VACUITY. It used to match only + # `add(..., "N")`, while all four functions below emit + # `gep(I8, &p, &[(I64, "N")])` — so planting a keys-offset read left it + # green. Both spellings are matched now, and each function must be shown to + # read the ShapeId at all, so a guard that stops reading the header + # entirely cannot pass by emitting nothing. for source, names in ( (raw_class_guard, ( "emit_class_field_loop_preheader_check", @@ -466,22 +533,32 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: ): for name in names: body = function_body(source, name) - # Match BOTH emission forms. These four guards build their header - # address with `blk.gep(I8, &p, &[(I64, "N")])`, not `add(..)`, so - # an `add`-only pattern was vacuous for every function in this - # list -- planting `gep(I8, &elem_ptr, &[(I64, "16")])` in - # `emit_element_shape_field_load` left the census green. - if re.search( - r"expected_keys" - r"|add\s*\([^\n]*\"(?:0|12|16)\"" - r"|gep\s*\([^\n]*\(\s*I64\s*,\s*\"(?:0|12|16)\"\s*\)", - body, - ): + # #8110 hardened this arm because an `add`-only pattern was + # vacuous: these four guards build their header address with + # `blk.gep(I8, &p, &[(I64, "N")])`. That coverage now lives in + # `forbidden_header_offsets()` below, which matches BOTH spellings + # and carries the post-#8113 offsets (keys_array @8, meta @16); + # the old 0/12/16 list described a layout this commit deletes -- + # offset 0 is now `class_id` and legitimately readable. The + # `require_code` on offset 4 keeps "emits nothing" from passing. + if re.search(r"expected_keys", body): raise CensusError(f"{name} emits a removed ObjectHeader fact") + if forbidden_header_offsets(body): + raise CensusError(f"{name} emits a removed ObjectHeader fact") + require_code( + body, + r"\(\s*I64\s*,\s*\"4\"\s*\)", + f"{name} reads the authoritative ShapeId at header offset 4", + ) generic_body = function_body(raw_generic_pic, "lower_generic_property_get") - if re.search(r"add\s*\(\s*I64\s*,\s*&obj_handle\s*,\s*\"(?:12|16)\"", generic_body): + if re.search(r"add\s*\(\s*I64\s*,\s*&obj_handle\s*,\s*\"(?:8|16)\"", generic_body): raise CensusError("generic read PIC emits a removed ObjectHeader fact") + require_code( + generic_body, + r"add\s*\(\s*I64\s*,\s*&obj_handle\s*,\s*\"4\"\s*\)", + "generic read PIC reads the authoritative ShapeId at header offset 4", + ) require_code( generic_body, r"select\s*\(\s*I1\s*,\s*&is_stamp\s*,\s*I64\s*,\s*&id_token\s*,\s*\"0\"\s*\)", @@ -489,8 +566,13 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: ) for name in ("lower_put_value_static_write_ic", "lower_put_value_dyn_ic_inline"): body = function_body(raw_write_pics, name) - if re.search(r"add\s*\(\s*I64\s*,\s*&(safe_target|t_handle)\s*,\s*\"(?:12|16)\"", body): + if re.search(r"add\s*\(\s*I64\s*,\s*&(safe_target|t_handle)\s*,\s*\"(?:8|16)\"", body): raise CensusError(f"{name} emits a removed ObjectHeader fact") + require_code( + body, + r"add\s*\(\s*I64\s*,\s*&(?:safe_target|t_handle)\s*,\s*\"4\"\s*\)", + f"{name} reads the authoritative ShapeId at header offset 4", + ) require_code(gc_types, r"GC_TYPE_REGEXP\s*:\s*u8", "RegExp external discriminator") regexp_info_match = re.search( @@ -512,6 +594,7 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: ) if "OBJ_FLAG_CLASS_OBJECT" in gc_types + class_guard + element_guard + write_pics: raise CensusError("class kind reintroduced a GcHeader layout-bit alias") + assert_header_fields(object_mod) class_probe = function_body(object_mod, "object_is_regular") require_code( class_probe, @@ -520,6 +603,47 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: ) +def forbidden_header_offsets(body: str) -> list[str]: + """Positive `ObjectHeader` byte offsets an emitted guard must not address. + + Matches both emitter spellings: a gep index tuple `(I64, "N")` and an + `add(I64, &base, "N")`. `sub(...)` is deliberately NOT matched — it is how + the GcHeader bytes at -8/-7/-6 are reached — and neither is a negative + literal. + """ + forbidden = {"8", "16"} + gep = re.findall(r'\(\s*I64\s*,\s*"(-?\d+)"\s*\)', body) + add = re.findall(r'\.add\s*\(\s*I64\s*,\s*&\w[\w.]*\s*,\s*"(-?\d+)"\s*\)', body) + return sorted({off for off in gep + add if off in forbidden}) + + +def assert_header_fields(object_mod: str) -> None: + """Pin `ObjectHeader`'s exact declared field list (#8113). + + The multiset census only sees fields named in `FIELDS`, so re-adding a + `field_count` word would slip past it entirely. This does not: the header is + an ABI with a published crates.io mirror (`perry-ffi::ObjectHeader`) and a + runtime revision constant (`perry_object_header_abi_revision`), and a change + here has to be made on purpose in all three places. + """ + match = re.search( + r"pub struct ObjectHeader\s*\{(?P[^}]*)\}", + object_mod, + ) + if not match: + raise CensusError("shape descriptor authority surface missing: ObjectHeader declaration") + fields = tuple(re.findall(r"pub\s+(\w+)\s*:", match.group("body"))) + if fields != OBJECT_HEADER_FIELDS: + raise CensusError( + "ObjectHeader field list changed: " + f"{fields} != {OBJECT_HEADER_FIELDS}. This is an ABI change — update " + "OBJECT_HEADER_FIELDS here, perry-ffi's mirror + " + "OBJECT_HEADER_ABI_REVISION, perry_object_header_abi_revision(), " + "target_layout::object_header_size_bytes, and the emitted header " + "offsets, in one commit." + ) + + def swap_once(source: str, left: str, right: str) -> str: left_at = source.find(left) right_at = source.find(right) @@ -593,7 +717,7 @@ def run_sabotage_selftests(sources: dict[str, str], baseline: dict[str, object]) inverted_publication = dict(sources) path = "crates/perry-runtime/src/object/shapes.rs" publication_body = function_body( - inverted_publication[path], "synchronize_object_shape_descriptor_from" + inverted_publication[path], "publish_object_shape_from" ) inverted_body = swap_once( publication_body, @@ -632,7 +756,7 @@ def run_sabotage_selftests(sources: dict[str, str], baseline: dict[str, object]) legacy_ir = dict(sources) path = "crates/perry-codegen/src/expr/property_get/generic_dispatch.rs" legacy_body, substitutions = re.subn( - r'add\(I64, &obj_handle, "8"\)', + r'add\(I64, &obj_handle, "4"\)', 'add(I64, &obj_handle, "16")', legacy_ir[path], count=1, @@ -645,6 +769,57 @@ def run_sabotage_selftests(sources: dict[str, str], baseline: dict[str, object]) lambda: assert_authority_surfaces(legacy_ir), ) + # #8113: the gep-spelled emitted guards. This arm was VACUOUS before — + # it matched only `add(..., "N")` — so plant a keys-offset gep and prove + # it is caught now. + gep_ir = dict(sources) + path = "crates/perry-codegen/src/expr/class_field_inline_guard.rs" + gep_body, substitutions = re.subn( + r'gep\(I8, &obj_ptr, &\[\(I64, "4"\)\]\)', + 'gep(I8, &obj_ptr, &[(I64, "8")])', + gep_ir[path], + count=1, + ) + if substitutions != 1: + raise CensusError("gep emitted-offset sabotage fixture missing") + gep_ir[path] = gep_body + expect_rejected( + "keys-array header offset in a gep-spelled emitted guard", + lambda: assert_authority_surfaces(gep_ir), + ) + + # #8113: re-adding a deleted header word must be red, not merely + # un-baselined (the multiset census cannot see a field it does not track). + readded_field = dict(sources) + path = "crates/perry-runtime/src/object/mod.rs" + readded_field[path] = readded_field[path].replace( + " pub keys_array: *mut ArrayHeader,", + " pub field_count: u32,\n pub keys_array: *mut ArrayHeader,", + 1, + ) + expect_rejected( + "re-added ObjectHeader payload word", + lambda: assert_authority_surfaces(readded_field), + ) + + # #8113: a re-introduced clear-then-remint window. + cleared_publication = dict(sources) + path = "crates/perry-runtime/src/object/shapes.rs" + cleared_body = function_body(cleared_publication[path], "publish_object_live_slot_count") + cleared_publication[path] = cleared_publication[path].replace( + cleared_body, + cleared_body.replace( + "let predecessor = object_shape_descriptor(obj);", + "let predecessor = object_shape_descriptor(obj);\n clear_object_shape_stamp(obj);", + 1, + ), + 1, + ) + expect_rejected( + "clear-then-remint window in the live-slot publication", + lambda: assert_authority_surfaces(cleared_publication), + ) + stale_summary = json.loads(json.dumps(baseline)) stale_summary["summary"]["raw_member_files"] += 1 expect_rejected( diff --git a/scripts/shape_descriptor_census_baseline.json b/scripts/shape_descriptor_census_baseline.json index a3f7e73134..ebf9fb2968 100644 --- a/scripts/shape_descriptor_census_baseline.json +++ b/scripts/shape_descriptor_census_baseline.json @@ -10,69 +10,38 @@ "crates/perry-codegen/src/expr/property_set.rs|crate::target_layout::object_header_size_bytes(": 3, "crates/perry-codegen/src/expr/property_set.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple)": 1, "crates/perry-codegen/src/expr/property_set.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 2, - "crates/perry-codegen/src/expr/proxy_reflect.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 1, - "crates/perry-codegen/src/expr/proxy_reflect.rs|let header_bytes = crate::target_layout::object_header_size_bytes(ctx.target_triple);": 1, + "crates/perry-codegen/src/expr/proxy_reflect.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 2, "crates/perry-codegen/src/lower_call/new.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 1, "crates/perry-codegen/src/lower_call/new_alloc.rs|crate::target_layout::object_header_size_bytes(ctx.target_triple);": 1, "crates/perry-codegen/src/lower_call/scalar_method.rs|let header_skip = crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string();": 1, "crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs|8 + crate::target_layout::object_header_size_bytes( ) + 8 * slots;": 1, "crates/perry-codegen/src/stmt/loops.rs|let object_header_size = crate::target_layout::object_header_size_bytes(ctx.target_triple);": 1, - "crates/perry-codegen/src/target_layout.rs|assert_eq!(object_header_size_bytes( ), 24);": 2, - "crates/perry-codegen/src/target_layout.rs|assert_eq!(object_header_size_bytes( ), 32);": 4, + "crates/perry-codegen/src/target_layout.rs|assert_eq!(object_header_size_bytes( ), 16);": 2, + "crates/perry-codegen/src/target_layout.rs|assert_eq!(object_header_size_bytes( ), 24);": 4, "crates/perry-codegen/src/target_layout.rs|let total = 8 + object_header_size_bytes(triple) + 8 * INLINE_SLOT_FLOOR;": 1, + "crates/perry-codegen/src/target_layout.rs|object_header_size_bytes(triple) % 8,": 1, "crates/perry-codegen/src/target_layout.rs|pub fn object_header_size_bytes(target_triple: &str) -> u64 {": 1 }, "raw_member_callsite_multiset": { - "crates/perry-codegen/src/lower_call/typed_shape_init.rs|field_count|declaration|field_count: u32,": 1, - "crates/perry-codegen/tests/native_proof_regressions.rs|field_count|declaration|let loop_body = |field_count: usize| {": 1, - "crates/perry-ext-events/src/lib.rs|field_count|declaration|fn js_object_alloc(class_id: u32, field_count: u32) -> *mut ObjectHeader;": 1, - "crates/perry-ext-ws/src/lib.rs|field_count|access|let n = (*ptr).field_count;": 1, - "crates/perry-ffi/src/jsvalue.rs|field_count|declaration|field_count: u32,": 1, - "crates/perry-ffi/src/jsvalue.rs|field_count|declaration|fn js_object_alloc(class_id: u32, field_count: u32) -> *mut ObjectHeader;": 1, - "crates/perry-ffi/src/jsvalue.rs|field_count|declaration|fn js_object_alloc_null_proto(class_id: u32, field_count: u32) -> *mut ObjectHeader;": 1, "crates/perry-ffi/src/jsvalue.rs|keys_array|declaration|fn js_object_set_keys(obj: *mut ObjectHeader, keys_array: *mut ArrayHeader);": 1, - "crates/perry-ffi/src/types.rs|field_count|declaration|pub field_count: u32,": 1, "crates/perry-ffi/src/types.rs|keys_array|declaration|pub keys_array: *mut ArrayHeader,": 1, - "crates/perry-ffi/src/types.rs|object_type|declaration|pub object_type: u32,": 1, "crates/perry-runtime/src/builtins/console.rs|keys_array|access|let keys_array = (*obj_ptr).keys_array;": 2, "crates/perry-runtime/src/builtins/formatting.rs|keys_array|access|let _keys_array = (*obj_ptr).keys_array;": 1, "crates/perry-runtime/src/builtins/formatting.rs|keys_array|access|let keys_array = (*obj_ptr).keys_array;": 2, - "crates/perry-runtime/src/builtins/formatting/util_format.rs|field_count|access|let num_fields = (*obj).field_count;": 1, "crates/perry-runtime/src/builtins/formatting/util_format.rs|keys_array|access|let keys_arr = (*obj).keys_array;": 1, - "crates/perry-runtime/src/builtins/globals.rs|field_count|access|for i in 0..field_count as usize {": 1, - "crates/perry-runtime/src/builtins/globals.rs|field_count|access|if key_count > (*src_obj).field_count as usize {": 1, - "crates/perry-runtime/src/builtins/globals.rs|field_count|access|let field_count = (*cloned_obj).field_count;": 1, "crates/perry-runtime/src/builtins/globals.rs|keys_array|access|let keys_now = (*src_now).keys_array;": 1, "crates/perry-runtime/src/builtins/globals.rs|keys_array|access|let src_keys = (*src_obj).keys_array;": 1, "crates/perry-runtime/src/builtins/table.rs|keys_array|access|let keys_array = (*obj_ptr).keys_array;": 1, - "crates/perry-runtime/src/child_process/v8_serde.rs|field_count|access|let num_fields = (*obj).field_count;": 1, "crates/perry-runtime/src/child_process/v8_serde.rs|keys_array|access|let keys_arr = (*obj).keys_array;": 1, - "crates/perry-runtime/src/cluster.rs|field_count|declaration|fn alloc_object_value(field_count: u32) -> f64 {": 1, - "crates/perry-runtime/src/dyn_eval/env.rs|field_count|access|let alloc_limit = std::cmp::max((*o).field_count, crate::object::INLINE_SLOT_FLOOR as u32);": 1, "crates/perry-runtime/src/dyn_eval/env.rs|keys_array|access|let keys = (*o).keys_array;": 1, - "crates/perry-runtime/src/error.rs|object_type|access|(*ptr).object_type = OBJECT_TYPE_ERROR;": 1, - "crates/perry-runtime/src/error.rs|object_type|declaration|pub object_type: u32,": 1, "crates/perry-runtime/src/fs/dirent.rs|keys_array|access|let keys = (*obj_ptr).keys_array;": 1, - "crates/perry-runtime/src/gc/heap_snapshot.rs|field_count|access|.unwrap_or((*obj).field_count as usize)": 1, "crates/perry-runtime/src/gc/heap_snapshot.rs|keys_array|access|.unwrap_or((*obj).keys_array as u64);": 1, - "crates/perry-runtime/src/gc/layout.rs|field_count|access|.unwrap_or((*obj_header).field_count as usize);": 1, - "crates/perry-runtime/src/gc/layout.rs|field_count|access|.unwrap_or((*object).field_count as usize);": 2, "crates/perry-runtime/src/gc/layout.rs|keys_array|access|.unwrap_or((*obj_header).keys_array as usize);": 1, "crates/perry-runtime/src/gc/layout.rs|keys_array|access|.unwrap_or((*object).keys_array as usize)": 1, - "crates/perry-runtime/src/gc/layout_slot_visit.rs|field_count|access|.unwrap_or((*obj).field_count);": 1, "crates/perry-runtime/src/gc/layout_slot_visit.rs|keys_array|access|.unwrap_or((*obj).keys_array);": 1, "crates/perry-runtime/src/gc/layout_slot_visit.rs|keys_array|access|let new_keys = (*obj).keys_array as u64;": 1, - "crates/perry-runtime/src/gc/tests/alloc.rs|object_type|declaration|object_type: crate::error::OBJECT_TYPE_ERROR,": 1, - "crates/perry-runtime/src/gc/tests/barrier.rs|field_count|access|for i in 0..field_count as usize {": 2, - "crates/perry-runtime/src/gc/tests/barrier.rs|field_count|declaration|unsafe fn field_index_not_on_last_page(fields: *mut u64, field_count: u32) -> usize {": 1, - "crates/perry-runtime/src/gc/tests/barrier.rs|field_count|declaration|unsafe fn field_indices_on_distinct_pages(fields: *mut u64, field_count: u32) -> (usize, usize) {": 1, "crates/perry-runtime/src/gc/tests/copying.rs|keys_array|access|let keys = (*obj_after).keys_array;": 1, - "crates/perry-runtime/src/gc/tests/copying/pointer_publish_7154.rs|field_count|access|unsafe { (*obj).field_count },": 2, - "crates/perry-runtime/src/gc/tests/cycle_state.rs|field_count|access|(*child).field_count = 0;": 1, "crates/perry-runtime/src/gc/tests/cycle_state.rs|keys_array|access|(*child).keys_array = std::ptr::null_mut();": 1, - "crates/perry-runtime/src/gc/tests/cycle_state.rs|object_type|access|(*child).object_type = crate::error::OBJECT_TYPE_REGULAR;": 1, - "crates/perry-runtime/src/gc/tests/cycle_state.rs|object_type|access|(*obj).object_type,": 1, - "crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs|field_count|access|(*obj).field_count = 0;": 1, "crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs|keys_array|access|(*a).keys_array = keys;": 1, "crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs|keys_array|access|(*b).keys_array = keys;": 1, "crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs|keys_array|access|(*obj).keys_array = std::ptr::null_mut();": 1, @@ -83,26 +52,16 @@ "crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs|keys_array|access|assert_eq!(descriptor.keys, (*a_after).keys_array as u64);": 1, "crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs|keys_array|access|assert_ne!((*a_after).keys_array as usize, old_keys);": 1, "crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs|keys_array|access|let header_keys_slot = unsafe { std::ptr::addr_of_mut!((*owner).keys_array) as *mut u64 };": 2, - "crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs|object_type|access|(*obj).object_type = 1;": 1, - "crates/perry-runtime/src/gc/tests/layout_trace/typed_shape.rs|field_count|access|assert_eq!((*obj).field_count, 2);": 1, "crates/perry-runtime/src/gc/tests/layout_trace/typed_shape.rs|keys_array|access|(*first).keys_array,": 1, "crates/perry-runtime/src/gc/tests/layout_trace/typed_shape.rs|keys_array|access|(*second).keys_array,": 1, "crates/perry-runtime/src/gc/tests/runtime_roots/json_shape_template.rs|keys_array|access|let keys = (*obj).keys_array;": 1, "crates/perry-runtime/src/gc/tests/runtime_roots/transient_handles.rs|keys_array|access|assert!(!(*obj_after).keys_array.is_null());": 1, "crates/perry-runtime/src/gc/tests/runtime_roots/transient_handles.rs|keys_array|access|let key_value = crate::array::js_array_get((*obj_after).keys_array, 0).bits();": 1, - "crates/perry-runtime/src/gc/tests/shape_descriptor_authority.rs|field_count|access|(*obj).field_count = 0;": 1, "crates/perry-runtime/src/gc/tests/shape_descriptor_authority.rs|keys_array|access|(*obj).keys_array = std::ptr::null_mut();": 1, "crates/perry-runtime/src/gc/tests/shape_descriptor_authority.rs|keys_array|access|assert_eq!((*obj).keys_array as u64, descriptor.keys);": 1, - "crates/perry-runtime/src/gc/tests/support.rs|field_count|access|(*obj).field_count = field_count;": 2, - "crates/perry-runtime/src/gc/tests/support.rs|field_count|access|for i in 0..field_count as usize {": 2, - "crates/perry-runtime/src/gc/tests/support.rs|field_count|declaration|field_count: u32,": 2, "crates/perry-runtime/src/gc/tests/support.rs|keys_array|access|(*obj).keys_array = std::ptr::null_mut();": 2, - "crates/perry-runtime/src/gc/tests/support.rs|object_type|access|(*obj).object_type = 1;": 2, - "crates/perry-runtime/src/gc/tests/support.rs|object_type|declaration|object_type: crate::error::OBJECT_TYPE_ERROR,": 1, - "crates/perry-runtime/src/json/mod.rs|field_count|access|(value, (*obj).field_count, (*(*obj).keys_array).length)": 1, - "crates/perry-runtime/src/json/mod.rs|field_count|access|assert!((*obj).field_count >= (*(*obj).keys_array).length);": 1, - "crates/perry-runtime/src/json/mod.rs|keys_array|access|(value, (*obj).field_count, (*(*obj).keys_array).length)": 1, - "crates/perry-runtime/src/json/mod.rs|keys_array|access|assert!((*obj).field_count >= (*(*obj).keys_array).length);": 1, + "crates/perry-runtime/src/json/mod.rs|keys_array|access|(value, crate::object::object_live_slot_count(obj), (*(*obj).keys_array).length)": 1, + "crates/perry-runtime/src/json/mod.rs|keys_array|access|assert!(crate::object::object_live_slot_count(obj) >= (*(*obj).keys_array).length);": 1, "crates/perry-runtime/src/json/mod.rs|keys_array|access|assert!(unsafe { (*empty).keys_array.is_null() });": 1, "crates/perry-runtime/src/json/mod.rs|keys_array|access|assert_eq!((*(*nested).keys_array).length, 1);": 1, "crates/perry-runtime/src/json/mod.rs|keys_array|access|assert_eq!((*(*obj).keys_array).length, 2);": 1, @@ -110,60 +69,28 @@ "crates/perry-runtime/src/json/mod.rs|keys_array|access|visitor.visit_raw_mut_ptr_slot(&mut entry.keys_array);": 1, "crates/perry-runtime/src/json/mod.rs|keys_array|declaration|keys_array: arr,": 1, "crates/perry-runtime/src/json/mod.rs|keys_array|declaration|pub(crate) keys_array: *mut crate::ArrayHeader,": 1, - "crates/perry-runtime/src/json/parse_api.rs|field_count|declaration|field_count: u32,": 2, - "crates/perry-runtime/src/json/parser.rs|field_count|access|shape.field_count,": 1, - "crates/perry-runtime/src/json/parser.rs|field_count|access|std::cmp::max(shape.field_count as usize, crate::object::INLINE_SLOT_FLOOR);": 1, - "crates/perry-runtime/src/json/parser.rs|field_count|declaration|pub(crate) field_count: u32,": 1, "crates/perry-runtime/src/json/parser.rs|keys_array|access|shape.keys_array,": 1, "crates/perry-runtime/src/json/parser.rs|keys_array|declaration|pub(crate) keys_array: *mut crate::array::ArrayHeader,": 1, - "crates/perry-runtime/src/json/replacer.rs|field_count|access|let num_fields = (*obj).field_count;": 3, - "crates/perry-runtime/src/json/stringify.rs|field_count|access|let field_count = (*obj).field_count;": 1, - "crates/perry-runtime/src/json/stringify.rs|field_count|access|let fields = (*obj).field_count as usize;": 1, - "crates/perry-runtime/src/json/stringify.rs|field_count|access|let num_fields = (*obj).field_count;": 1, "crates/perry-runtime/src/json/stringify.rs|keys_array|access|if (*(ptr as *const crate::ObjectHeader)).keys_array.is_null() {": 1, "crates/perry-runtime/src/json/stringify.rs|keys_array|access|let keys = (*(ptr as *const crate::ObjectHeader)).keys_array;": 1, "crates/perry-runtime/src/json/stringify.rs|keys_array|access|let keys = (*obj).keys_array as *const crate::ArrayHeader;": 1, "crates/perry-runtime/src/json/stringify.rs|keys_array|access|let keys_arr = (*cur_obj()).keys_array;": 1, "crates/perry-runtime/src/json/stringify.rs|keys_array|access|let keys_arr = (*obj).keys_array;": 2, "crates/perry-runtime/src/json/stringify.rs|keys_array|access|let potential_keys_ptr = (*obj).keys_array as u64;": 1, - "crates/perry-runtime/src/json/stringify_shape_template.rs|field_count|access|std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32)": 1, "crates/perry-runtime/src/json/stringify_shape_template.rs|keys_array|access|if (*obj).keys_array != template.keys_arr.get() {": 1, "crates/perry-runtime/src/json/stringify_shape_template.rs|keys_array|access|let keys_arr = (*obj).keys_array;": 2, "crates/perry-runtime/src/json/stringify_tojson_probe.rs|keys_array|access|let keys = (*obj).keys_array;": 1, "crates/perry-runtime/src/json/stringify_tojson_probe.rs|keys_array|access|let keys = (*proto).keys_array;": 1, - "crates/perry-runtime/src/json_tape_tests.rs|field_count|access|(*nested).field_count,": 1, - "crates/perry-runtime/src/json_tape_tests.rs|field_count|access|(*object).field_count,": 2, - "crates/perry-runtime/src/navigator.rs|field_count|declaration|let field_count: u32 = 6;": 1, "crates/perry-runtime/src/node_stream_json.rs|keys_array|access|let keys = (*obj).keys_array;": 1, "crates/perry-runtime/src/node_stream_readwrite.rs|keys_array|access|let keys = (*obj).keys_array;": 1, - "crates/perry-runtime/src/object/alloc.rs|field_count|access|(*new_ptr).field_count = 0;": 1, - "crates/perry-runtime/src/object/alloc.rs|field_count|access|(*new_ptr).field_count = src_field_count;": 1, - "crates/perry-runtime/src/object/alloc.rs|field_count|access|(*obj_ptr).field_count = field_count;": 1, - "crates/perry-runtime/src/object/alloc.rs|field_count|access|(*ptr).field_count = field_count;": 5, - "crates/perry-runtime/src/object/alloc.rs|field_count|access|(*ptr).field_count = logical_field_count as u32;": 1, - "crates/perry-runtime/src/object/alloc.rs|field_count|access|let src_field_count = (*src).field_count as usize;": 1, - "crates/perry-runtime/src/object/alloc.rs|field_count|access|let src_field_count = (*src_ptr).field_count;": 1, - "crates/perry-runtime/src/object/alloc.rs|field_count|declaration|field_count: u32,": 8, - "crates/perry-runtime/src/object/alloc.rs|field_count|declaration|fn remember_class_keys_array(class_id: u32, field_count: u32, keys_array: *mut ArrayHeader) {": 1, - "crates/perry-runtime/src/object/alloc.rs|field_count|declaration|pub extern fn js_object_alloc(class_id: u32, field_count: u32) -> *mut ObjectHeader {": 1, - "crates/perry-runtime/src/object/alloc.rs|field_count|declaration|pub extern fn js_object_alloc_fast(class_id: u32, field_count: u32) -> *mut ObjectHeader {": 1, - "crates/perry-runtime/src/object/alloc.rs|field_count|declaration|pub extern fn js_object_alloc_null_proto(class_id: u32, field_count: u32) -> *mut ObjectHeader {": 1, "crates/perry-runtime/src/object/alloc.rs|keys_array|access|(*new_ptr).keys_array = ptr::null_mut();": 2, "crates/perry-runtime/src/object/alloc.rs|keys_array|access|(*ptr).keys_array = ptr::null_mut();": 3, "crates/perry-runtime/src/object/alloc.rs|keys_array|access|let src_keys = (*src).keys_array;": 2, "crates/perry-runtime/src/object/alloc.rs|keys_array|access|let src_keys_arr = (*src_ptr).keys_array;": 1, "crates/perry-runtime/src/object/alloc.rs|keys_array|declaration|fn remember_class_keys_array(class_id: u32, field_count: u32, keys_array: *mut ArrayHeader) {": 1, "crates/perry-runtime/src/object/alloc.rs|keys_array|declaration|keys_array: *mut ArrayHeader,": 3, - "crates/perry-runtime/src/object/alloc.rs|object_type|access|(*new_ptr).object_type = crate::error::OBJECT_TYPE_REGULAR;": 2, - "crates/perry-runtime/src/object/alloc.rs|object_type|access|(*obj_ptr).object_type = crate::error::OBJECT_TYPE_REGULAR;": 1, - "crates/perry-runtime/src/object/alloc.rs|object_type|access|(*ptr).object_type = crate::error::OBJECT_TYPE_REGULAR;": 6, - "crates/perry-runtime/src/object/arguments.rs|field_count|access|std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32) as usize;": 2, "crates/perry-runtime/src/object/arguments.rs|keys_array|access|let keys = (*obj).keys_array;": 2, "crates/perry-runtime/src/object/class_registry/parent_static.rs|keys_array|access|let keys = (*obj).keys_array;": 1, - "crates/perry-runtime/src/object/class_registry/parent_static.rs|object_type|access|(*(obj as *mut ObjectHeader)).object_type = crate::error::OBJECT_TYPE_CLASS;": 1, - "crates/perry-runtime/src/object/class_registry/parent_static.rs|object_type|access|(*obj).object_type = crate::error::OBJECT_TYPE_REGULAR;": 1, - "crates/perry-runtime/src/object/delete_rest.rs|field_count|access|assert_eq!(descriptor.live_inline_slot_count, (*obj).field_count);": 2, - "crates/perry-runtime/src/object/delete_rest.rs|field_count|access|let field_count = (*obj).field_count;": 1, "crates/perry-runtime/src/object/delete_rest.rs|keys_array|access|(*obj).keys_array,": 1, "crates/perry-runtime/src/object/delete_rest.rs|keys_array|access|assert_eq!(descriptor.keys, (*obj).keys_array as u64);": 2, "crates/perry-runtime/src/object/delete_rest.rs|keys_array|access|crate::object::shapes::shape_drop((*obj).keys_array);": 1, @@ -172,48 +99,20 @@ "crates/perry-runtime/src/object/delete_rest.rs|keys_array|access|let keys_before = (*obj).keys_array;": 1, "crates/perry-runtime/src/object/descriptor_state.rs|keys_array|access|let keys = (*obj).keys_array;": 1, "crates/perry-runtime/src/object/descriptors.rs|keys_array|access|let keys = (*obj).keys_array;": 2, - "crates/perry-runtime/src/object/field_get_set/accessors.rs|field_count|access|(*obj).field_count": 1, - "crates/perry-runtime/src/object/field_get_set/accessors.rs|field_count|access|let fc = (*obj).field_count;": 1, - "crates/perry-runtime/src/object/field_get_set/accessors.rs|field_count|access|std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32) as usize;": 1, "crates/perry-runtime/src/object/field_get_set/accessors.rs|keys_array|access|let keys = (*obj).keys_array;": 1, - "crates/perry-runtime/src/object/field_get_set/enumeration.rs|field_count|access|(*obj).field_count as usize": 2, "crates/perry-runtime/src/object/field_get_set/enumeration.rs|keys_array|access|let keys = (*obj).keys_array;": 3, - "crates/perry-runtime/src/object/field_get_set/field_ops.rs|field_count|access|if field_index >= (*obj).field_count {": 1, - "crates/perry-runtime/src/object/field_get_set/field_ops.rs|field_count|access|let stored_field_count = (*obj).field_count;": 1, "crates/perry-runtime/src/object/field_get_set/field_ops.rs|keys_array|declaration|pub extern fn js_object_set_keys(obj: *mut ObjectHeader, keys_array: *mut ArrayHeader) {": 1, - "crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs|field_count|access|(*o).field_count,": 1, "crates/perry-runtime/src/object/field_get_set/get_field_by_name.rs|keys_array|access|let keys = (*o).keys_array;": 1, - "crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs|field_count|access|let _field_count = (*obj).field_count as usize;": 1, - "crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs|field_count|access|std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32) as usize;": 1, "crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs|keys_array|access|let keys = (*obj).keys_array;": 1, "crates/perry-runtime/src/object/field_get_set/has_property.rs|keys_array|access|let keys = (*obj).keys_array;": 1, "crates/perry-runtime/src/object/field_get_set/ic_miss.rs|keys_array|access|let keys = (*obj).keys_array;": 1, - "crates/perry-runtime/src/object/field_set_by_name.rs|field_count|access|(*o).field_count,": 1, - "crates/perry-runtime/src/object/field_set_by_name.rs|field_count|access|if slot_idx >= (*o).field_count {": 1, "crates/perry-runtime/src/object/field_set_by_name.rs|keys_array|access|let keys = (*o).keys_array;": 1, - "crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs|field_count|access|if idx >= (*obj).field_count {": 1, - "crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs|field_count|access|if slot_idx >= (*obj).field_count {": 1, - "crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs|field_count|access|std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32) as usize;": 2, "crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs|keys_array|access|let keys = (*obj).keys_array;": 2, - "crates/perry-runtime/src/object/field_set_by_name/tail.rs|field_count|access|if (*obj).field_count == 0 {": 1, - "crates/perry-runtime/src/object/field_set_by_name/tail.rs|field_count|access|if new_index as u32 >= (*obj).field_count {": 2, - "crates/perry-runtime/src/object/field_set_by_name/tail.rs|field_count|access|if slot_idx >= (*obj).field_count {": 1, - "crates/perry-runtime/src/object/field_set_by_name/tail.rs|field_count|access|std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32)": 1, - "crates/perry-runtime/src/object/field_set_by_name/tail.rs|field_count|access|std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32) as usize;": 1, "crates/perry-runtime/src/object/field_set_by_name/tail.rs|keys_array|access|(*obj).keys_array,": 2, "crates/perry-runtime/src/object/field_set_by_name/tail.rs|keys_array|access|let keys = (*obj).keys_array;": 3, - "crates/perry-runtime/src/object/gc_slots.rs|field_count|access|.unwrap_or((*obj).field_count as usize);": 1, "crates/perry-runtime/src/object/gc_slots.rs|keys_array|access|(*obj).keys_array = descriptor.keys as usize as *mut ArrayHeader;": 1, "crates/perry-runtime/src/object/gc_slots.rs|keys_array|access|Some(&mut (*obj).keys_array as *mut _ as *mut u64)": 1, "crates/perry-runtime/src/object/gc_slots.rs|keys_array|access|if (*obj).keys_array.is_null() {": 1, - "crates/perry-runtime/src/object/map_set_subclass.rs|field_count|access|assert_eq!(unsafe { (*obj).field_count }, 3);": 1, - "crates/perry-runtime/src/object/map_set_subclass.rs|object_type|access|assert_eq!(unsafe { (*obj).object_type }, OBJECT_TYPE_REGULAR);": 5, - "crates/perry-runtime/src/object/mod.rs|field_count|access|(*obj).field_count = field_count;": 1, - "crates/perry-runtime/src/object/mod.rs|field_count|access|if (*obj).field_count != field_count {": 1, - "crates/perry-runtime/src/object/mod.rs|field_count|declaration|field_count: 0,": 1, - "crates/perry-runtime/src/object/mod.rs|field_count|declaration|field_count: u32,": 1, - "crates/perry-runtime/src/object/mod.rs|field_count|declaration|pub field_count: u32,": 1, - "crates/perry-runtime/src/object/mod.rs|field_count|declaration|pub(super) unsafe fn set_object_live_slot_count(obj: *mut ObjectHeader, field_count: u32) {": 1, "crates/perry-runtime/src/object/mod.rs|keys_array|access|&(*obj).keys_array as *const _ as usize,": 1, "crates/perry-runtime/src/object/mod.rs|keys_array|access|(*obj).keys_array = keys_array;": 1, "crates/perry-runtime/src/object/mod.rs|keys_array|access|crate::gc::runtime_store_root_raw_mut_ptr_slot(&mut entry.keys_array, keys_array);": 2, @@ -222,27 +121,20 @@ "crates/perry-runtime/src/object/mod.rs|keys_array|access|return (entry.keys_array, entry.runtime_shape_id);": 1, "crates/perry-runtime/src/object/mod.rs|keys_array|access|visitor.visit_raw_mut_ptr_slot(&mut entry.keys_array);": 1, "crates/perry-runtime/src/object/mod.rs|keys_array|declaration|fn shape_cache_insert(shape_id: u32, keys_array: *mut ArrayHeader) {": 1, - "crates/perry-runtime/src/object/mod.rs|keys_array|declaration|keys_array: *mut ArrayHeader,": 1, - "crates/perry-runtime/src/object/mod.rs|keys_array|declaration|keys_array: 0,": 1, + "crates/perry-runtime/src/object/mod.rs|keys_array|declaration|keys_array: *mut ArrayHeader,": 2, "crates/perry-runtime/src/object/mod.rs|keys_array|declaration|keys_array: std::ptr::null_mut(),": 1, - "crates/perry-runtime/src/object/mod.rs|keys_array|declaration|keys_array: u64,": 1, "crates/perry-runtime/src/object/mod.rs|keys_array|declaration|pub keys_array: *mut ArrayHeader,": 1, "crates/perry-runtime/src/object/mod.rs|keys_array|declaration|pub(crate) fn test_seed_shape_cache_root(shape_id: u32, keys_array: *mut ArrayHeader) {": 1, "crates/perry-runtime/src/object/mod.rs|keys_array|declaration|unsafe fn set_object_keys_array(obj: *mut ObjectHeader, keys_array: *mut ArrayHeader) {": 1, - "crates/perry-runtime/src/object/mod.rs|object_type|declaration|object_type: 1,": 1, - "crates/perry-runtime/src/object/mod.rs|object_type|declaration|object_type: u32,": 1, - "crates/perry-runtime/src/object/mod.rs|object_type|declaration|pub object_type: u32,": 1, "crates/perry-runtime/src/object/namespace_create.rs|keys_array|access|(*obj).keys_array = 0x2800_0203usize as *mut _;": 1, "crates/perry-runtime/src/object/native_call_method/collection_methods.rs|keys_array|access|let keys = (*obj).keys_array;": 1, "crates/perry-runtime/src/object/native_call_method/handle_methods.rs|keys_array|access|let keys = (*obj).keys_array;": 1, + "crates/perry-runtime/src/object/null_stub.rs|keys_array|declaration|keys_array: 0,": 1, + "crates/perry-runtime/src/object/null_stub.rs|keys_array|declaration|keys_array: u64,": 1, "crates/perry-runtime/src/object/object_ops.rs|keys_array|declaration|pub(crate) use keys_array::{": 1, - "crates/perry-runtime/src/object/object_ops/accessors.rs|field_count|access|std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32) as usize;": 1, "crates/perry-runtime/src/object/object_ops/accessors.rs|keys_array|access|let keys = (*obj).keys_array;": 1, "crates/perry-runtime/src/object/object_ops/descriptor_helpers.rs|keys_array|access|let keys = (*(ptr as *const ObjectHeader)).keys_array;": 1, "crates/perry-runtime/src/object/object_ops/descriptor_helpers.rs|keys_array|access|let keys = (*obj).keys_array;": 1, - "crates/perry-runtime/src/object/object_ops/keys_array.rs|field_count|access|if (*obj).field_count == 0 {": 1, - "crates/perry-runtime/src/object/object_ops/keys_array.rs|field_count|access|if new_index < inline_capacity && new_index >= (*obj).field_count {": 1, - "crates/perry-runtime/src/object/object_ops/keys_array.rs|field_count|access|std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32);": 1, "crates/perry-runtime/src/object/object_ops/keys_array.rs|keys_array|access|(*obj).keys_array,": 1, "crates/perry-runtime/src/object/object_ops/keys_array.rs|keys_array|access|assert_eq!((*first).keys_array, (*sibling).keys_array);": 2, "crates/perry-runtime/src/object/object_ops/keys_array.rs|keys_array|access|assert_eq!(first_descriptor.keys, (*first).keys_array as u64);": 1, @@ -251,12 +143,6 @@ "crates/perry-runtime/src/object/object_ops/keys_array.rs|keys_array|access|let keys = (*obj).keys_array;": 4, "crates/perry-runtime/src/object/object_ops_frozen.rs|keys_array|access|let keys = (*obj).keys_array;": 1, "crates/perry-runtime/src/object/reflect_support.rs|keys_array|access|let keys_handle = scope.root_raw_mut_ptr((*obj).keys_array);": 1, - "crates/perry-runtime/src/object/shapes.rs|field_count|access|&& d.live_inline_slot_count == (*obj).field_count": 1, - "crates/perry-runtime/src/object/shapes.rs|field_count|access|(*obj).field_count,": 1, - "crates/perry-runtime/src/object/shapes.rs|field_count|access|assert_eq!(descriptor.live_inline_slot_count, (*obj).field_count);": 1, - "crates/perry-runtime/src/object/shapes.rs|field_count|access|let id = shape_descriptor_ensure(keys, key_count, (*obj).field_count)": 1, - "crates/perry-runtime/src/object/shapes.rs|field_count|access||| install_external_shape_id(runtime_shape_id, keys, key_count, (*obj).field_count);": 1, - "crates/perry-runtime/src/object/shapes.rs|field_count|declaration|field_count: 2,": 1, "crates/perry-runtime/src/object/shapes.rs|keys_array|access|(*a).keys_array,": 1, "crates/perry-runtime/src/object/shapes.rs|keys_array|access|(*b).keys_array,": 1, "crates/perry-runtime/src/object/shapes.rs|keys_array|access|assert_eq!((*b).keys_array, shared_keys);": 1, @@ -265,37 +151,19 @@ "crates/perry-runtime/src/object/shapes.rs|keys_array|access|assert_eq!(transitioned.keys, (*a).keys_array as u64);": 1, "crates/perry-runtime/src/object/shapes.rs|keys_array|access|assert_ne!((*a).keys_array, shared_keys);": 1, "crates/perry-runtime/src/object/shapes.rs|keys_array|access|crate::array::js_array_length((*obj).keys_array)": 1, + "crates/perry-runtime/src/object/shapes.rs|keys_array|access|d.keys == (*obj).keys_array as u64": 1, + "crates/perry-runtime/src/object/shapes.rs|keys_array|access|debug_assert_object_shape_parity_for_keys(obj, (*obj).keys_array);": 1, "crates/perry-runtime/src/object/shapes.rs|keys_array|access|let keys = (*obj).keys_array as usize;": 1, - "crates/perry-runtime/src/object/shapes.rs|keys_array|access|let keys = (*obj).keys_array;": 2, + "crates/perry-runtime/src/object/shapes.rs|keys_array|access|let keys = (*obj).keys_array;": 1, "crates/perry-runtime/src/object/shapes.rs|keys_array|access|let shared_keys = (*a).keys_array;": 1, + "crates/perry-runtime/src/object/shapes.rs|keys_array|access|publish_object_shape_from(obj, predecessor, (*obj).keys_array, live_inline_slot_count)": 1, "crates/perry-runtime/src/object/shapes.rs|keys_array|access|unsafe { (*obj).keys_array },": 1, "crates/perry-runtime/src/object/shapes.rs|keys_array|declaration|keys_array: keys as *mut ArrayHeader,": 1, - "crates/perry-runtime/src/object/shapes.rs|object_type|declaration|object_type: 1,": 1, - "crates/perry-runtime/src/object/spill.rs|field_count|access|std::cmp::max((*obj).field_count, crate::object::INLINE_SLOT_FLOOR as u32);": 1, - "crates/perry-runtime/src/object/spill.rs|field_count|declaration|pub(crate) fn reserve_object_spill(obj_ptr: usize, field_count: u32) {": 1, - "crates/perry-runtime/src/os.rs|field_count|declaration|let field_count: u32 = 14;": 1, - "crates/perry-runtime/src/param_type_guard.rs|field_count|access|for _ in 0..field_count {": 1, - "crates/perry-runtime/src/param_type_guard.rs|field_count|access|let inline_fields = ((*object).field_count as usize).max(crate::object::INLINE_SLOT_FLOOR);": 1, - "crates/perry-runtime/src/param_type_guard.rs|field_count|access||| (*object).field_count as usize > MAX_CONTAINER_LEN": 1, - "crates/perry-runtime/src/param_type_guard.rs|keys_array|access|let keys = (*object).keys_array;": 1, - "crates/perry-runtime/src/param_type_guard.rs|object_type|access|if (*object).object_type != crate::error::OBJECT_TYPE_REGULAR": 1, "crates/perry-runtime/src/perf_hooks.rs|keys_array|access|let keys_ptr = (*obj).keys_array as usize;": 1, "crates/perry-runtime/src/perf_hooks.rs|keys_array|access|recorded != 0 && (*obj).keys_array as usize == recorded": 1, - "crates/perry-runtime/src/pointer_event.rs|field_count|declaration|let field_count: u32 = 4;": 1, - "crates/perry-runtime/src/process/node_module/source_map.rs|keys_array|access|(*obj).keys_array = std::ptr::null_mut();": 1, "crates/perry-runtime/src/promise/then_probe.rs|keys_array|access|let keys = (*obj).keys_array;": 2, - "crates/perry-runtime/src/proxy.rs|object_type|access|&& (*(addr as *const crate::ObjectHeader)).object_type": 1, - "crates/perry-runtime/src/proxy/put_value.rs|field_count|access|object_array_numeric_write_slots(array, &keys[..field_count as usize], receiver_count)": 1, - "crates/perry-runtime/src/proxy/put_value.rs|field_count|access|slots[..field_count as usize]": 1, - "crates/perry-runtime/src/proxy/put_value.rs|field_count|declaration|field_count: u32,": 1, - "crates/perry-runtime/src/safe_area.rs|field_count|declaration|let field_count: u32 = 4;": 1, - "crates/perry-runtime/src/thread.rs|field_count|access|for i in 0..field_count {": 1, - "crates/perry-runtime/src/thread.rs|field_count|access|let field_count = (*obj).field_count as usize;": 1, "crates/perry-runtime/src/thread.rs|keys_array|access|let keys = if !(*obj).keys_array.is_null() {": 1, "crates/perry-runtime/src/thread.rs|keys_array|access|let keys_arr = (*obj).keys_array;": 1, - "crates/perry-runtime/src/typed_feedback/tests.rs|field_count|access|(*obj).field_count = 0;": 1, - "crates/perry-runtime/src/typed_feedback/tests.rs|field_count|access|(*obj).field_count = original_field_count;": 1, - "crates/perry-runtime/src/typed_feedback/tests.rs|field_count|access|let original_field_count = unsafe { (*obj).field_count };": 1, "crates/perry-runtime/src/typed_feedback/tests.rs|keys_array|access|&(*obj).keys_array as *const _ as usize,": 1, "crates/perry-runtime/src/typed_feedback/tests.rs|keys_array|access|(*obj).keys_array = original_keys;": 1, "crates/perry-runtime/src/typed_feedback/tests.rs|keys_array|access|(*obj).keys_array = std::ptr::null_mut();": 1, @@ -303,42 +171,14 @@ "crates/perry-runtime/src/typed_feedback/tests.rs|keys_array|access|let keys = unsafe { (*obj).keys_array };": 1, "crates/perry-runtime/src/url/search_params.rs|keys_array|access|let keys_arr = (*obj).keys_array;": 2, "crates/perry-runtime/src/url/search_params.rs|keys_array|access|let keys_arr = (*params).keys_array;": 1, - "crates/perry-runtime/src/url/url_class.rs|field_count|access|if !is_gc_object_header(url) || (*url).class_id != 0 || (*url).field_count < URL_FIELD_COUNT": 1, - "crates/perry-runtime/src/weakref.rs|field_count|access|((*obj).field_count > 0 && slot == object_field_slot(obj, 0))": 1, - "crates/perry-runtime/src/weakref.rs|field_count|access|(*obj).field_count > 0 && slot == object_field_slot(obj, 0)": 1, - "crates/perry-runtime/src/weakref.rs|field_count|access||| ((*obj).field_count > 1 && slot == object_field_slot(obj, 1))": 1, - "crates/perry-stdlib/src/streams.rs|field_count|declaration|fn js_object_alloc(class_id: u32, field_count: u32) -> *mut ObjectHeader {": 1, - "crates/perry-stdlib/src/streams.rs|field_count|declaration|fn provider_js_object_alloc(class_id: u32, field_count: u32) -> *mut ObjectHeader;": 1, - "crates/perry-stdlib/src/worker_threads.rs|field_count|access|(*object).field_count": 1, - "crates/perry-stdlib/src/worker_threads.rs|field_count|access|(0..field_count).any(|index| {": 1, "crates/perry-stdlib/src/worker_threads.rs|keys_array|access|if (*object).keys_array.is_null() {": 1, - "crates/perry-stdlib/src/worker_threads.rs|keys_array|access|perry_runtime::array::js_array_length((*object).keys_array)": 1, - "crates/perry-ui-android/src/drag_drop.rs|field_count|declaration|fn js_object_alloc(class_id: u32, field_count: u32) -> *mut c_void;": 1, - "crates/perry-ui-android/src/json.rs|field_count|access|let field_count = (*obj).field_count;": 1, - "crates/perry-ui-android/src/json.rs|field_count|access|let fields = (*obj).field_count as usize;": 1, - "crates/perry-ui-android/src/json.rs|field_count|access|let num_fields = (*obj).field_count;": 1, - "crates/perry-ui-android/src/json.rs|keys_array|access|let keys_arr = (*obj).keys_array;": 2, - "crates/perry-ui-android/src/json.rs|keys_array|access|let potential_keys_ptr = (*obj).keys_array as u64;": 1, - "crates/perry-ui-android/src/widgets/canvas.rs|field_count|declaration|fn js_object_alloc(class_id: u32, field_count: u32) -> *mut c_void;": 1, - "crates/perry-ui-gtk4/src/drag_drop.rs|field_count|declaration|fn js_object_alloc(class_id: u32, field_count: u32) -> *mut c_void;": 1, - "crates/perry-ui-gtk4/src/widgets/canvas.rs|field_count|declaration|fn js_object_alloc(class_id: u32, field_count: u32) -> *mut c_void;": 1, - "crates/perry-ui-ios/src/drag_drop.rs|field_count|declaration|fn js_object_alloc(class_id: u32, field_count: u32) -> *mut c_void;": 1, - "crates/perry-ui-ios/src/widgets/canvas.rs|field_count|declaration|fn js_object_alloc(class_id: u32, field_count: u32) -> *mut c_void;": 1, - "crates/perry-ui-macos/src/drag_drop.rs|field_count|declaration|fn js_object_alloc(class_id: u32, field_count: u32) -> *mut c_void;": 1, - "crates/perry-ui-macos/src/widgets/canvas.rs|field_count|declaration|fn js_object_alloc(class_id: u32, field_count: u32) -> *mut c_void;": 1, - "crates/perry-ui-tvos/src/widgets/canvas.rs|field_count|declaration|fn js_object_alloc(class_id: u32, field_count: u32) -> *mut c_void;": 1, - "crates/perry-ui-visionos/src/drag_drop.rs|field_count|declaration|fn js_object_alloc(class_id: u32, field_count: u32) -> *mut c_void;": 1, - "crates/perry-ui-visionos/src/widgets/canvas.rs|field_count|declaration|fn js_object_alloc(class_id: u32, field_count: u32) -> *mut c_void;": 1, - "crates/perry-ui-windows/src/drag_drop.rs|field_count|declaration|fn js_object_alloc(class_id: u32, field_count: u32)": 1, - "crates/perry-ui-windows/src/widgets/canvas.rs|field_count|declaration|fn js_object_alloc(class_id: u32, field_count: u32) -> *mut c_void;": 1 + "crates/perry-stdlib/src/worker_threads.rs|keys_array|access|perry_runtime::array::js_array_length((*object).keys_array)": 1 }, "summary": { - "codegen_object_header_size_sites": 32, - "raw_member_files": 101, + "codegen_object_header_size_sites": 33, + "raw_member_files": 63, "raw_member_sites": { - "field_count": 162, - "keys_array": 183, - "object_type": 32 + "keys_array": 181 } } } From 7305cafd8cbe13be18072b9536c108bf6c3c4551 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 15 Aug 2026 03:50:04 +0200 Subject: [PATCH 02/12] test(object): pin the 48/96-byte footprint and the exact header offsets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the wide-case (8-slot) footprint assertion — 96 bytes, isolating the header term from the INLINE_SLOT_FLOOR padding term — and an offsets test that names the field that moved rather than only the total. Plus the changelog fragment. Refs #8113. --- .../8122-object-header-shrink-56-to-48.md | 129 ++++++++++++++++++ crates/perry-runtime/src/object/tests.rs | 38 ++++++ 2 files changed, 167 insertions(+) create mode 100644 changelog.d/8122-object-header-shrink-56-to-48.md diff --git a/changelog.d/8122-object-header-shrink-56-to-48.md b/changelog.d/8122-object-header-shrink-56-to-48.md new file mode 100644 index 0000000000..106fc4f6f0 --- /dev/null +++ b/changelog.d/8122-object-header-shrink-56-to-48.md @@ -0,0 +1,129 @@ +### perf(object): remove the derivable `object_type` and `field_count` header words — 56 B → 48 B + +`ObjectHeader` is now `{class_id @0, parent_class_id @4, keys_array @8, meta @16}` +— **24 bytes on LP64, 16 on ILP32**, down from 32/24. A two-field object literal +costs **48 bytes instead of 56** (`GcHeader 8 + header 24 + 2 slots`), and the +eight-slot case 96 instead of 104. Measured with `rustc -O` on the exact +`#[repr(C)]` shapes: removing **either** word alone saves **zero** — the struct +re-pads — so the two had to go together. This is half of #8047's prize and needs +none of its GC descriptor-rooting work (#8112). + +Both words were derivable from facts the object already carries: + +* the receiver **kind** — ordinary / class / native error — from + `GcHeader.obj_type` plus the immutable ShapeId descriptor's `object_kind`; +* the **live inline-slot bound** from that descriptor's `live_inline_slot_count`. + +#### The offset-0 type confusion this had to disarm + +`ObjectHeader::object_type` was prefix-punned against `error::ErrorHeader`'s +first word, and **nine** sites read raw offset 0 to decide Error-vs-ordinary — +two more than previously catalogued (`promise/rejection.rs:181` and `:464`). +Deleting the word makes offset 0 `class_id`, and `OBJECT_TYPE_ERROR` is **2** +while class ids are handed out from 1, densely, in source-declaration order +(`run_pipeline.rs`: `let mut next_class_id = 1`). Left alone, those reads would +have reclassified **every instance of the second class a program declares** as an +`ErrorHeader` and served `message`/`name`/`stack`/`errors` out of its field +slots — a silent wrong answer of exactly the #8100 shape. Plain object literals +are `class_id == 0`, so the `OBJECT_TYPE_REGULAR` arms would have inverted in +both directions at once. + +All nine now go through `error::ptr_is_native_error()` (`GcHeader.obj_type == +GC_TYPE_ERROR`, the only kind `alloc_error` uses). Five sabotage-shaped +acceptance tests in `object/tests.rs` pin it: each first asserts the confusable +value really is sitting at offset 0, then asserts the answer. Reverting the +discriminator to the raw read turns three of them red. + +`proxy.rs`'s #6595 store-plan gate moves to `object_is_regular()`. #8047's census +warned that this substitution re-opens #6595 — that warning was **stale**: #8086 +rewrote `object_is_regular` to mean exactly `descriptor.object_kind == Ordinary`, +so it is still false for a heap class object. A test pins that too. + +#### Mint-then-stamp + +With `field_count` gone, the ShapeId descriptor is the **only** record of a live +object's slot bound, so a stamp-cleared window is a window in which the collector +traces zero payload slots — a fresh #7154/#7164. Every clear-then-remint sequence +is restructured: `shapes::publish_object_shape_from` mints the successor while +the predecessor stamp is still installed, and the single `parent_class_id` store +(which cannot allocate, hence cannot collect) is the publication point. +`set_object_keys_array`, `set_object_live_slot_count` and +`js_object_delete_field` no longer clear; `shapes::clear_object_shape_stamp` is +now `#[cfg(test)]`, surviving only so tests can manufacture the unstamped state. + +`typed_feedback::object_shape`'s defensive self-heal is **deleted**. It called +`synchronize_object_shape_descriptor`, which derived the bound from the header +word; without that word it would publish `live = 0` for an unstamped receiver — +a read-only observation path silently truncating the object's traced and writable +payload. It misses closed instead. #6804's "no pre/post-stamp token split" +property survives by the stronger route: every allocator birth-publishes, so the +population needing a heal is empty. + +#### Codegen + +`object_header_size_bytes` 32 → 24 (LP64) and 24 → 16 (ILP32). The inline-`new` +path's two packed header stores collapse to one (`class_id ‖ ShapeId`). Eleven +hard-coded IR offsets renumber `class_id @+4` → `@+0` and ShapeId `@+8` → `@+4`; +GcHeader-relative offsets (`-8`/`-7`/`-6`) are untouched. Emitted IR never read +`field_count` — #8067 moved the PIC hit path onto an exact ShapeId match — so +codegen only ever wrote it. + +The two `object_header_size_bytes(..) / 8` word-index sites (`expr/proxy_reflect.rs`, +`stmt/loops.rs`) become byte geps. Both quotients are exact today (24/8, 16/8), +but #8047's ILP32 header is 12 bytes and `12 / 8 == 1` truncates silently; a new +`object_header_size_is_a_whole_number_of_heap_words` test pins the divisibility +rather than the quotient. + +Four codegen doc comments claiming "24 on 64-bit, 20 on ILP32" — wrong since +`meta` landed in #6759 — are corrected, along with `docs/src/platforms/watchos.md` +(the only user-facing statement of the pair), `docs/object-write-matrix.md`, and +`TYPE_LOWERING.md`. + +#### Two gates that could not have caught this + +**`perry-ffi`'s ABI mirror had never executed.** `object_header_matches_runtime` +is `#[cfg(all(test, feature = "runtime-link"))]`, `runtime-link` was enabled +nowhere in `.github/`, and `cargo-test` is a per-package loop with default +features — so the module never compiled. Field *deletion* still went red (an +`offset_of!` on a missing field stops compiling), but a **size or padding +divergence was invisible**, which is precisely this change's failure mode. +`cargo-test` now runs `cargo test -p perry-ffi --features runtime-link --lib` +unconditionally. It earned its keep on the first run: it caught a real bug in +this change — the parity `debug_assert` inside the new mint-then-stamp +publication compared the freshly stamped descriptor against a header keys word +the new ordering has not written yet. + +**`perry-ffi` is published to crates.io.** This is a **breaking ABI change** for +out-of-tree wrappers: one compiled against the old mirror and linked against the +new runtime reads `class_id` out of the deleted `object_type` slot with no +compile error. That cannot be guarded retroactively — the old mirror references +no version symbol, so there is nothing the runtime can withhold. Recorded as a +deliberate break, with a tripwire introduced for the *next* one: +`perry_ffi::OBJECT_HEADER_ABI_REVISION` (= 2) paired with the runtime's +`extern "C" perry_object_header_abi_revision()`, asserted equal by the +now-running mirror test. + +#### Gate updates + +`scripts/shape_descriptor_census.py` narrows to `keys_array` (the last mirror) +and gains three rules the deletion needs: the exact `ObjectHeader` field list, so +re-adding a word is red rather than merely un-baselined; a ban on any publication +path clearing the stamp, plus a check that `clear_object_shape_stamp` stays +`#[cfg(test)]`; and a fixed emitted-guard offset rule. That last one was +**vacuous** — it matched only `add(..., "N")` while all four functions it names +emit `gep(I8, &p, &[(I64, "N")])` — so it now matches both spellings and requires +each guard to be shown reading the ShapeId at all. Three new sabotage self-tests +cover the new rules. + +#### Also + +* `perry-ui-android/src/json.rs` deleted — 606 lines, every function private with + no callers, its own trailing comment saying `js_json_*` now lives in + `perry-runtime/json.rs`. It read `field_count` in three places and is invisible + to CI three ways (`#![cfg(target_os = "android")]`, outside the host-compatible + workspace scope, and the only Android job is `continue-on-error`). +* `NullObjectBytes` gains the `meta` word it has been missing since #6759 — a + `(*obj).meta` read on the unresolved-namespace stub was running 8 bytes past + the end of the static. +* `object/mod.rs` reached the 2000-line cap, so `live_slots.rs` (the bound plus + the ABI revision) and `null_stub.rs` split out. diff --git a/crates/perry-runtime/src/object/tests.rs b/crates/perry-runtime/src/object/tests.rs index 3dcf1f40e8..5f9375e29d 100644 --- a/crates/perry-runtime/src/object/tests.rs +++ b/crates/perry-runtime/src/object/tests.rs @@ -669,6 +669,44 @@ fn two_field_literal_footprint_is_exactly_accounted() { bytes of unusable slots to every small object; re-adding a header word \ re-adds 8 to every object regardless of width" ); + + // #8113 acceptance: the WIDE case too. The floor does not apply at 8 + // fields, so this isolates the header term from the padding term — it is + // the number that says the saving is per-OBJECT, not per-small-object. + let wide_keys = b"a\0b\0c\0d\0e\0f\0g\0h\0"; + let wide = + js_object_alloc_with_shape(0x8113_0008, 8, wide_keys.as_ptr(), wide_keys.len() as u32); + assert!(!wide.is_null()); + let wide_recorded = unsafe { + crate::value::addr_class::try_read_gc_header(wide as usize) + .expect("a freshly allocated object must carry a readable GcHeader") + .size as usize + }; + assert_eq!( + wide_recorded, 96, + "#8113: the 8-slot footprint is 96 bytes (104 before the header shrink)" + ); +} + +/// #8113 acceptance, spelled as offsets rather than a total so a failure names +/// the field that moved. `GcHeader` staying 8 bytes is part of the contract: +/// the whole 8-byte saving is the header's, not a GcHeader change. +#[test] +fn object_header_is_two_words_plus_two_pointers() { + use std::mem::{align_of, offset_of, size_of}; + assert_eq!(crate::gc::GC_HEADER_SIZE, 8); + assert_eq!(size_of::(), 8); + assert_eq!(align_of::(), size_of::<*const u8>()); + assert_eq!(offset_of!(ObjectHeader, class_id), 0); + assert_eq!(offset_of!(ObjectHeader, parent_class_id), 4); + assert_eq!(offset_of!(ObjectHeader, keys_array), size_of::<*const u8>()); + assert_eq!(offset_of!(ObjectHeader, meta), 2 * size_of::<*const u8>()); + assert_eq!(size_of::(), 3 * size_of::<*const u8>()); + // The emitted-IR offsets in perry-codegen are literals; these two are the + // ones `class_field_inline_guard` / `proxy_reflect` / `generic_dispatch` + // splice in, and `stmt/loops.rs` + `expr/proxy_reflect.rs` used to divide + // the size by 8 for a word index. + assert_eq!(size_of::() % 8, 0); } /// Paired with `inline_slot_floor_matches_runtime` in From a2ce17b38bfffb1955883eb8a679150d813f81f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 15 Aug 2026 04:53:49 +0200 Subject: [PATCH 03/12] perf(object): stop paying two shape-table probes per bound read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on the 19-program corpus: the first cut of #8113 regressed instructions retired by up to +30% (deeplist +30.5%, cycles +28.4%, tree +25.4%) while delivering the RSS win. The cause was mechanical, not inherent. * Five GC-side sites already read the bound descriptor-first and used the header word as an `unwrap_or` fallback. `unwrap_or` is EAGER, so the substitution made every call do TWO shape-table probes — and one of them, `gc/layout.rs`'s `layout_note_slot`, runs on every object field store. With the word gone the fallback could only return 0, so they now do. * `weakref::is_weak_target_trace_slot` (per traced slot) went from three probes to one. * Six write paths read the bound twice — once for `alloc_limit`, once for the widen test. They read it once. * `object_live_slot_count` gains a 64-way direct-mapped ShapeId -> count memo. It needs no invalidation: ids are never reused and the bound is part of the exact facts an id is minted for. The two test helpers that DO break that premise (`test_clear_shape_table`, `test_drop_shape_descriptors`) clear it. Refs #8113. --- crates/perry-runtime/src/gc/heap_snapshot.rs | 3 +- crates/perry-runtime/src/gc/layout.rs | 13 +- .../perry-runtime/src/gc/layout_slot_visit.rs | 6 +- .../src/object/field_get_set/accessors.rs | 2 +- .../src/object/field_get_set/field_ops.rs | 7 +- .../src/object/field_set_by_name.rs | 6 +- .../object/field_set_by_name/fast_paths.rs | 19 +- .../src/object/field_set_by_name/tail.rs | 10 +- crates/perry-runtime/src/object/live_slots.rs | 166 +++++++++++++++++- crates/perry-runtime/src/object/mod.rs | 2 + .../src/object/object_ops/keys_array.rs | 9 +- crates/perry-runtime/src/object/shapes.rs | 7 + crates/perry-runtime/src/weakref.rs | 20 ++- scripts/shape_descriptor_census_baseline.json | 2 +- 14 files changed, 234 insertions(+), 38 deletions(-) diff --git a/crates/perry-runtime/src/gc/heap_snapshot.rs b/crates/perry-runtime/src/gc/heap_snapshot.rs index 3690dd6373..93c89c8405 100644 --- a/crates/perry-runtime/src/gc/heap_snapshot.rs +++ b/crates/perry-runtime/src/gc/heap_snapshot.rs @@ -311,7 +311,8 @@ pub fn gc_build_v8_heap_snapshot_json() -> String { let fc = unsafe { crate::object::shapes::object_shape_descriptor(obj) .map(|descriptor| descriptor.live_inline_slot_count as usize) - .unwrap_or(crate::object::object_live_slot_count(obj) as usize) + // #8113: 0, not a second (eager) descriptor probe. + .unwrap_or(0) }; if fc <= 10_000 { ( diff --git a/crates/perry-runtime/src/gc/layout.rs b/crates/perry-runtime/src/gc/layout.rs index c5b7d65820..d836834708 100644 --- a/crates/perry-runtime/src/gc/layout.rs +++ b/crates/perry-runtime/src/gc/layout.rs @@ -216,9 +216,13 @@ unsafe fn with_shape_shared_descriptor( // Defense-in-depth: both descriptor families must agree on the exact live // bound. The ObjectHeader count is only an ABI mirror pending #8047. let object = user_ptr as *const crate::object::ObjectHeader; + // #8113: 0, not a second descriptor probe. `unwrap_or` is EAGER, so + // re-deriving the bound cost a whole extra shape-table lookup on every + // call — and the bound has no other source now, so the fallback could only + // ever have returned 0 anyway. let field_count = crate::object::shapes::object_shape_descriptor(object) .map(|descriptor| descriptor.live_inline_slot_count as usize) - .unwrap_or(crate::object::object_live_slot_count(object) as usize); + .unwrap_or(0); let map = hot_shape_layouts().borrow(); let desc = map.get(&keys)?.as_ref()?; if desc.slot_count != field_count { @@ -688,9 +692,11 @@ pub(crate) fn layout_note_slot(parent_user: usize, slot_index: usize, value_bits && (*header).obj_type == GC_TYPE_OBJECT { let object = parent_user as *const crate::object::ObjectHeader; + // #8113: 0, not a second (eager) descriptor probe. This is + // `layout_note_slot`, i.e. every object field store. let live_slots = crate::object::shapes::object_shape_descriptor(object) .map(|descriptor| descriptor.live_inline_slot_count as usize) - .unwrap_or(crate::object::object_live_slot_count(object) as usize); + .unwrap_or(0); if slot_index < live_slots { return; } @@ -1028,9 +1034,10 @@ unsafe fn init_typed_shape_layout( } let obj_header = user_ptr as *const crate::object::ObjectHeader; let shape_descriptor = crate::object::shapes::object_shape_descriptor(obj_header); + // #8113: 0, not a second (eager) descriptor probe. let object_slot_count = shape_descriptor .map(|descriptor| descriptor.live_inline_slot_count as usize) - .unwrap_or(crate::object::object_live_slot_count(obj_header) as usize); + .unwrap_or(0); if object_slot_count != slot_count { layout_set_typed_unknown(header, user_ptr); return; diff --git a/crates/perry-runtime/src/gc/layout_slot_visit.rs b/crates/perry-runtime/src/gc/layout_slot_visit.rs index 83b9b8f5fe..0d7088aba1 100644 --- a/crates/perry-runtime/src/gc/layout_slot_visit.rs +++ b/crates/perry-runtime/src/gc/layout_slot_visit.rs @@ -27,7 +27,11 @@ pub(super) unsafe fn visit_gc_layout_slot_descriptors( .unwrap_or((*obj).keys_array); let live_inline_slot_count = descriptor .map(|facts| facts.live_inline_slot_count) - .unwrap_or(crate::object::object_live_slot_count(obj)); + // #8113: 0, not a second descriptor probe. `unwrap_or` is EAGER, + // so re-deriving the bound here cost a whole extra shape-table + // lookup on every call — and the bound has no other source now, so + // the fallback could only ever have returned 0 anyway. + .unwrap_or(0); if old_keys.is_null() { Some((obj, 0, 0, live_inline_slot_count)) } else if crate::value::addr_class::try_read_tracked_gc_header(old_keys as usize) diff --git a/crates/perry-runtime/src/object/field_get_set/accessors.rs b/crates/perry-runtime/src/object/field_get_set/accessors.rs index 7a7b6e3837..4241017ce9 100644 --- a/crates/perry-runtime/src/object/field_get_set/accessors.rs +++ b/crates/perry-runtime/src/object/field_get_set/accessors.rs @@ -58,7 +58,7 @@ pub extern "C" fn js_object_get_field(obj: *const ObjectHeader, field_index: u32 obj, field_index, (*obj).class_id, - crate::object::object_live_slot_count(obj) + fc ); return JSValue::undefined(); } diff --git a/crates/perry-runtime/src/object/field_get_set/field_ops.rs b/crates/perry-runtime/src/object/field_get_set/field_ops.rs index 1b4da41048..7bd16e2d59 100644 --- a/crates/perry-runtime/src/object/field_get_set/field_ops.rs +++ b/crates/perry-runtime/src/object/field_get_set/field_ops.rs @@ -162,7 +162,12 @@ pub extern "C" fn js_object_set_field(obj: *mut ObjectHeader, field_index: u32, // is undefined-initialized at allocation (`object/alloc.rs`), so // widening here can only ever expose non-pointer sentinels ahead of // the store that is about to fill this one in. - if field_index >= crate::object::object_live_slot_count(obj) { + // + // #8113: `stored_field_count` is reused rather than re-read. The bound + // is a shape-table probe now, not a header word, and nothing between + // the read above and here can change it (the null-pointer guard only + // substitutes the VALUE). + if field_index >= stored_field_count { set_object_live_slot_count(obj, field_index + 1); } crate::gc::runtime_store_jsvalue_slot( diff --git a/crates/perry-runtime/src/object/field_set_by_name.rs b/crates/perry-runtime/src/object/field_set_by_name.rs index c4ffe7c349..e9aee248fa 100644 --- a/crates/perry-runtime/src/object/field_set_by_name.rs +++ b/crates/perry-runtime/src/object/field_set_by_name.rs @@ -160,8 +160,10 @@ pub extern "C" fn js_object_set_field_by_name( }; set_object_keys_array(o, next_keys as *mut ArrayHeader); super::mark_object_dynamic_shape_unknown(o); + // #8113: one bound probe, reused. + let live_slots = crate::object::object_live_slot_count(o); let alloc_limit = std::cmp::max( - crate::object::object_live_slot_count(o), + live_slots, crate::object::INLINE_SLOT_FLOOR as u32, ) as usize; if (slot_idx as usize) < alloc_limit { @@ -169,7 +171,7 @@ pub extern "C" fn js_object_set_field_by_name( .add(std::mem::size_of::()) as *mut JSValue; let slot = fields_ptr.add(slot_idx as usize); - if slot_idx >= crate::object::object_live_slot_count(o) { + if slot_idx >= live_slots { set_object_live_slot_count(o, slot_idx + 1); } crate::gc::runtime_store_jsvalue_slot( diff --git a/crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs b/crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs index 6cc2c33785..f51141343b 100644 --- a/crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs +++ b/crates/perry-runtime/src/object/field_set_by_name/fast_paths.rs @@ -99,12 +99,11 @@ pub(crate) unsafe fn try_existing_own_data_overwrite( vbits }; super::mark_object_dynamic_shape_unknown(obj); - let alloc_limit = std::cmp::max( - crate::object::object_live_slot_count(obj), - crate::object::INLINE_SLOT_FLOOR as u32, - ) as usize; + // #8113: one bound probe, reused. It is a shape-table lookup now. + let live_slots = crate::object::object_live_slot_count(obj); + let alloc_limit = std::cmp::max(live_slots, crate::object::INLINE_SLOT_FLOOR as u32) as usize; if (idx as usize) < alloc_limit { - if idx >= crate::object::object_live_slot_count(obj) { + if idx >= live_slots { set_object_live_slot_count(obj, idx + 1); } store_object_field_slot(obj, idx as usize, vbits); @@ -260,10 +259,10 @@ pub extern "C" fn js_object_set_field_by_name_transition_fast( set_object_keys_array(obj, next_keys as *mut ArrayHeader); super::mark_object_dynamic_shape_unknown(obj); - let alloc_limit = std::cmp::max( - crate::object::object_live_slot_count(obj), - crate::object::INLINE_SLOT_FLOOR as u32, - ) as usize; + // #8113: one bound probe, reused. + let live_slots = crate::object::object_live_slot_count(obj); + let alloc_limit = + std::cmp::max(live_slots, crate::object::INLINE_SLOT_FLOOR as u32) as usize; let slot_usize = slot_idx as usize; let vbits = value.to_bits(); let vbits = if (vbits >> 48) == 0x7FFD && (vbits & 0x0000_FFFF_FFFF_FFFF) == 0 { @@ -273,7 +272,7 @@ pub extern "C" fn js_object_set_field_by_name_transition_fast( }; if slot_usize < alloc_limit { - if slot_idx >= crate::object::object_live_slot_count(obj) { + if slot_idx >= live_slots { set_object_live_slot_count(obj, slot_idx + 1); } store_object_field_slot(obj, slot_usize, vbits); diff --git a/crates/perry-runtime/src/object/field_set_by_name/tail.rs b/crates/perry-runtime/src/object/field_set_by_name/tail.rs index 8e717383e6..ceac43c09a 100644 --- a/crates/perry-runtime/src/object/field_set_by_name/tail.rs +++ b/crates/perry-runtime/src/object/field_set_by_name/tail.rs @@ -454,10 +454,10 @@ pub(super) fn set_field_by_name_object_tail( }; set_object_keys_array(obj, next_keys as *mut ArrayHeader); super::mark_object_dynamic_shape_unknown(obj); - let alloc_limit = std::cmp::max( - crate::object::object_live_slot_count(obj), - crate::object::INLINE_SLOT_FLOOR as u32, - ) as usize; + // #8113: one bound probe, reused. + let live_slots = crate::object::object_live_slot_count(obj); + let alloc_limit = + std::cmp::max(live_slots, crate::object::INLINE_SLOT_FLOOR as u32) as usize; if (slot_idx as usize) < alloc_limit { // Inline the field write — `obj` has already been // validated (GC header read, type check, closure @@ -469,7 +469,7 @@ pub(super) fn set_field_by_name_object_tail( let slot = fields_ptr.add(slot_idx as usize); // Publish the expanded traced range and its exact // descriptor before the pointer-bearing slot value. - if slot_idx >= crate::object::object_live_slot_count(obj) { + if slot_idx >= live_slots { set_object_live_slot_count(obj, slot_idx + 1); } crate::gc::runtime_store_jsvalue_slot( diff --git a/crates/perry-runtime/src/object/live_slots.rs b/crates/perry-runtime/src/object/live_slots.rs index e4ac594096..7bdc8a50ba 100644 --- a/crates/perry-runtime/src/object/live_slots.rs +++ b/crates/perry-runtime/src/object/live_slots.rs @@ -28,6 +28,42 @@ pub extern "C" fn perry_object_header_abi_revision() -> u32 { 2 } +/// Direct-mapped `ShapeId -> live_inline_slot_count` memo. +/// +/// The bound used to be a single `u32` load off the header. It is now a +/// shape-table probe — a TLS resolution, a `RefCell` borrow, a SipHash and a +/// bucket walk — on a path that includes every by-index field write. This memo +/// puts a plain array index in front of that. +/// +/// # Why it needs no invalidation +/// +/// Two facts, both load-bearing: +/// +/// * **ShapeIds are never reused.** `shapes::SHAPE_ID_NEXT` is a monotonic +/// process-global counter and exhaustion fail-STOPS (`shape_id_exhausted_abort`), +/// so an id names one fact set for the life of the process. +/// * **`live_inline_slot_count` is part of the exact facts an id is minted +/// for.** `shape_descriptor_ensure_with_generation` dedupes on those facts, so +/// two different bounds get two different ids. The only field ever mutated in +/// place on a published descriptor is `keys` (rewritten by the evacuator), and +/// this memo does not hold it. +/// +/// `prune_dead_shape_keys` can REMOVE an id, which would leave a stale entry — +/// but its documented contract is that "a descriptor removed here cannot be +/// named by a live object", so a stale entry is only reachable through a dead +/// receiver. (Keyless descriptors, whose `keys` is 0, are never pruned: the +/// dead-owner predicate classifies address 0 as not-in-any-heap-space.) +/// +/// The memo is per-thread because descriptor tables are per-agent: a +/// process-global id can name different local facts in two agents +/// (`install_external_shape_id`). +const LIVE_SLOT_MEMO_WAYS: usize = 64; + +thread_local! { + static LIVE_SLOT_MEMO: [std::cell::Cell<(u32, u32)>; LIVE_SLOT_MEMO_WAYS] = + [const { std::cell::Cell::new((0, 0)) }; LIVE_SLOT_MEMO_WAYS]; +} + /// The authoritative live inline-slot bound (#8113: the replacement for the /// deleted `ObjectHeader::field_count` word). /// @@ -38,9 +74,40 @@ pub extern "C" fn perry_object_header_abi_revision() -> u32 { /// object. #[inline] pub unsafe fn object_live_slot_count(obj: *const ObjectHeader) -> u32 { - shapes::object_shape_descriptor(obj) - .map(|descriptor| descriptor.live_inline_slot_count) - .unwrap_or(0) + let shape_id = shapes::object_shape_stamp(obj); + if shape_id == 0 { + return 0; + } + let way = (shape_id as usize) & (LIVE_SLOT_MEMO_WAYS - 1); + LIVE_SLOT_MEMO.with(|memo| { + let entry = &memo[way]; + let (cached_id, cached_count) = entry.get(); + if cached_id == shape_id { + return cached_count; + } + let count = shapes::shape_descriptor_by_id(shape_id) + .map(|descriptor| descriptor.live_inline_slot_count) + .unwrap_or(0); + // A missing descriptor is NOT cached: it is the fail-closed answer for + // a stale/foreign id, and caching it would make a later legitimate + // install of that id invisible. + if count != 0 { + entry.set((shape_id, count)); + } + count + }) +} + +/// Test hook: drop every memo entry. The memo needs no invalidation in +/// production (see [`LIVE_SLOT_MEMO`]), but a test that plants a synthetic id, +/// drops its descriptor and re-mints under the same id must be able to say so. +#[cfg(test)] +pub(crate) fn test_clear_live_slot_memo() { + LIVE_SLOT_MEMO.with(|memo| { + for entry in memo.iter() { + entry.set((0, 0)); + } + }); } /// C-ABI accessor for [`object_live_slot_count`], for out-of-runtime consumers @@ -87,3 +154,96 @@ pub unsafe fn object_inline_alloc_limit(obj: *const ObjectHeader) -> u32 { pub(crate) unsafe fn set_object_live_slot_count(obj: *mut ObjectHeader, field_count: u32) { shapes::publish_object_live_slot_count(obj, field_count); } + +#[cfg(test)] +mod tests { + use super::*; + + /// #8113: the memo must be keyed by ShapeId, and an entry must be REPLACED + /// when a different id maps to the same way. + /// + /// Sabotage-shaped, and the premise is the load-bearing part: two arbitrary + /// shapes get consecutive ids and therefore different ways, so alternating + /// between them proves nothing. This mints enough shapes to FIND a pair + /// that collides, asserts it found one, and only then alternates. Replacing + /// the `cached_id == shape_id` test with `cached_id != 0` turns it red. + #[test] + fn the_live_slot_memo_is_keyed_by_shape_id_not_by_way() { + let _lock = crate::gc::global_side_table_test_lock(); + unsafe { + // Distinct widths so a mixed-up answer is observable, and enough + // shapes that two of them must share one of the 64 ways. + let mut minted: Vec<(*mut ObjectHeader, u32, u32)> = Vec::new(); + for width in 1u32..=(LIVE_SLOT_MEMO_WAYS as u32 + 8) { + let mut packed = Vec::new(); + for i in 0..width { + packed.extend_from_slice(format!("m8113w{width}_{i}").as_bytes()); + packed.push(0); + } + let obj = crate::object::js_object_alloc_with_shape( + 0x8113_2000 + width, + width, + packed.as_ptr(), + packed.len() as u32, + ); + let id = (*obj).parent_class_id; + assert!(shapes::is_shape_id(id)); + minted.push((obj, id, width)); + } + + let mut collision: Option<((*mut ObjectHeader, u32), (*mut ObjectHeader, u32))> = None; + 'outer: for i in 0..minted.len() { + for j in (i + 1)..minted.len() { + let (a, ida, wa) = minted[i]; + let (b, idb, wb) = minted[j]; + if ida != idb + && wa != wb + && (ida as usize) & (LIVE_SLOT_MEMO_WAYS - 1) + == (idb as usize) & (LIVE_SLOT_MEMO_WAYS - 1) + { + collision = Some(((a, wa), (b, wb))); + break 'outer; + } + } + } + let ((a, wa), (b, wb)) = collision.expect( + "test premise: two distinct shapes with different widths must share a memo way", + ); + + // Alternate. A memo that returns whatever is in the way, without + // checking the id, hands one object the other's bound. + for _ in 0..4 { + assert_eq!(object_live_slot_count(a), wa); + assert_eq!(object_live_slot_count(b), wb); + } + } + } + + /// The bound must FOLLOW a re-stamp: growing an object past its birth width + /// mints a successor ShapeId, and the memo is keyed by that id, so the new + /// bound must be visible immediately. + #[test] + fn the_live_slot_memo_follows_a_reshape() { + let _lock = crate::gc::global_side_table_test_lock(); + unsafe { + let obj = crate::object::js_object_alloc(0, 1); + let before_id = (*obj).parent_class_id; + assert_eq!(object_live_slot_count(obj), 1); + + let key = crate::string::js_string_from_bytes(b"m8113_grow".as_ptr(), 10); + crate::object::js_object_set_field_by_name(obj, key, 7.0); + let after_id = (*obj).parent_class_id; + assert_ne!( + before_id, after_id, + "test premise: the append re-stamps the receiver" + ); + assert_eq!( + object_live_slot_count(obj), + shapes::shape_descriptor_by_id(after_id) + .expect("successor descriptor") + .live_inline_slot_count, + "the memo must follow the successor ShapeId, not hold the birth bound" + ); + } + } +} diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index f1ccf78011..bec320190c 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -96,6 +96,8 @@ mod instanceof; mod live_slots; mod null_stub; pub(crate) use live_slots::set_object_live_slot_count; +#[cfg(test)] +pub(crate) use live_slots::test_clear_live_slot_memo; pub use live_slots::{ js_object_live_slot_count, object_inline_alloc_limit, object_live_slot_count, perry_object_header_abi_revision, diff --git a/crates/perry-runtime/src/object/object_ops/keys_array.rs b/crates/perry-runtime/src/object/object_ops/keys_array.rs index 52a52c1136..90d11de5df 100644 --- a/crates/perry-runtime/src/object/object_ops/keys_array.rs +++ b/crates/perry-runtime/src/object/object_ops/keys_array.rs @@ -147,11 +147,10 @@ pub(crate) unsafe fn ensure_key_in_keys_array( // getter here bumped field_count from 8 (the proto's physical capacity) to // 11, exposing the overflowed `values` slot and corrupting the boundary. let new_index = key_count as u32; - let inline_capacity = std::cmp::max( - crate::object::object_live_slot_count(obj), - crate::object::INLINE_SLOT_FLOOR as u32, - ); - if new_index < inline_capacity && new_index >= crate::object::object_live_slot_count(obj) { + // #8113: one bound probe, reused. + let live_slots = crate::object::object_live_slot_count(obj); + let inline_capacity = std::cmp::max(live_slots, crate::object::INLINE_SLOT_FLOOR as u32); + if new_index < inline_capacity && new_index >= live_slots { set_object_live_slot_count(obj, new_index + 1); } } diff --git a/crates/perry-runtime/src/object/shapes.rs b/crates/perry-runtime/src/object/shapes.rs index 296d11d7a0..00e52a0cda 100644 --- a/crates/perry-runtime/src/object/shapes.rs +++ b/crates/perry-runtime/src/object/shapes.rs @@ -1184,6 +1184,11 @@ pub(crate) fn test_shape_descriptor_count() -> usize { #[cfg(test)] pub(crate) fn test_clear_shape_table() { + // #8113: the live-slot memo is keyed by ShapeId and needs no invalidation + // in production (ids are never reused). A test that wipes the table and + // re-mints from the same id space is exactly the case that breaks that + // premise, so drop it here. + crate::object::test_clear_live_slot_memo(); let mut inner = crate::state::state().shapes.inner.borrow_mut(); inner.indices.clear(); inner.descriptors.clear(); @@ -1193,6 +1198,8 @@ pub(crate) fn test_clear_shape_table() { #[cfg(test)] pub(crate) fn test_drop_shape_descriptors(keys_id: usize) { + // #8113: see `test_clear_shape_table`. + crate::object::test_clear_live_slot_memo(); let mut inner = crate::state::state().shapes.inner.borrow_mut(); let stale = inner .ids_by_keys diff --git a/crates/perry-runtime/src/weakref.rs b/crates/perry-runtime/src/weakref.rs index 0dba392992..53ff9ac38b 100644 --- a/crates/perry-runtime/src/weakref.rs +++ b/crates/perry-runtime/src/weakref.rs @@ -372,11 +372,22 @@ pub(crate) unsafe fn is_weak_target_trace_slot( return false; } let obj = (header as *mut u8).add(crate::gc::GC_HEADER_SIZE) as *mut ObjectHeader; - match (*obj).class_id { + let class_id = (*obj).class_id; + if !matches!( + class_id, + CLASS_ID_WEAKREF | CLASS_ID_WEAK_ENTRY | CLASS_ID_FINALIZATION_RECORD + ) { + return false; + } + // #8113: ONE bound lookup. This runs per traced slot, and the bound is a + // shape-table probe now rather than a header word, so the three separate + // reads the arms below used to make were three probes. + let live_slots = crate::object::object_live_slot_count(obj); + match class_id { // Field 0 is the weak target for both: WeakRef's referent and a // WeakMap/WeakSet entry's key. CLASS_ID_WEAKREF | CLASS_ID_WEAK_ENTRY => { - crate::object::object_live_slot_count(obj) > 0 && slot == object_field_slot(obj, 0) + live_slots > 0 && slot == object_field_slot(obj, 0) } // A finalization record's target (field 0) AND its unregister token // (field 1) are both weak. The spec's [[UnregisterToken]] is an @@ -384,9 +395,8 @@ pub(crate) unsafe fn is_weak_target_trace_slot( // `registry.register(obj, held, obj)` pin the target immortal // (2026-07-09 GC audit). CLASS_ID_FINALIZATION_RECORD => { - (crate::object::object_live_slot_count(obj) > 0 && slot == object_field_slot(obj, 0)) - || (crate::object::object_live_slot_count(obj) > 1 - && slot == object_field_slot(obj, 1)) + (live_slots > 0 && slot == object_field_slot(obj, 0)) + || (live_slots > 1 && slot == object_field_slot(obj, 1)) } _ => false, } diff --git a/scripts/shape_descriptor_census_baseline.json b/scripts/shape_descriptor_census_baseline.json index ebf9fb2968..538b86e2ac 100644 --- a/scripts/shape_descriptor_census_baseline.json +++ b/scripts/shape_descriptor_census_baseline.json @@ -60,7 +60,7 @@ "crates/perry-runtime/src/gc/tests/shape_descriptor_authority.rs|keys_array|access|(*obj).keys_array = std::ptr::null_mut();": 1, "crates/perry-runtime/src/gc/tests/shape_descriptor_authority.rs|keys_array|access|assert_eq!((*obj).keys_array as u64, descriptor.keys);": 1, "crates/perry-runtime/src/gc/tests/support.rs|keys_array|access|(*obj).keys_array = std::ptr::null_mut();": 2, - "crates/perry-runtime/src/json/mod.rs|keys_array|access|(value, crate::object::object_live_slot_count(obj), (*(*obj).keys_array).length)": 1, + "crates/perry-runtime/src/json/mod.rs|keys_array|access|(*(*obj).keys_array).length,": 1, "crates/perry-runtime/src/json/mod.rs|keys_array|access|assert!(crate::object::object_live_slot_count(obj) >= (*(*obj).keys_array).length);": 1, "crates/perry-runtime/src/json/mod.rs|keys_array|access|assert!(unsafe { (*empty).keys_array.is_null() });": 1, "crates/perry-runtime/src/json/mod.rs|keys_array|access|assert_eq!((*(*nested).keys_array).length, 1);": 1, From 3f2b7f6d0e063f8e367503646ea2d9776f0ec6ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 15 Aug 2026 05:02:05 +0200 Subject: [PATCH 04/12] =?UTF-8?q?perf(object):=20delete=20the=20ShapeId->c?= =?UTF-8?q?ount=20memo=20=E2=80=94=20measured=20null?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Built, sabotage-tested (the way-collision test goes red when the id check is removed) and measured on the 19-program corpus against the same baseline: row with memo without retain +4.26% +3.26% retain_wide +4.46% +2.89% retain_wide1 +4.18% +2.61% deeplist +8.69% +8.20% shapes +1.85% +4.96% Worse on four of the five rows that pay the bound at all, better on one. The memo pays its own TLS resolution and a closure, which is most of what `state()` plus a small `HashMap` probe costs. Deleted rather than left in as an unmeasured configuration; the measurement is kept as a doc comment so the next person does not rebuild it. Refs #8113. --- crates/perry-runtime/src/object/live_slots.rs | 179 ++---------------- crates/perry-runtime/src/object/mod.rs | 2 - crates/perry-runtime/src/object/shapes.rs | 7 - 3 files changed, 16 insertions(+), 172 deletions(-) diff --git a/crates/perry-runtime/src/object/live_slots.rs b/crates/perry-runtime/src/object/live_slots.rs index 7bdc8a50ba..225206088f 100644 --- a/crates/perry-runtime/src/object/live_slots.rs +++ b/crates/perry-runtime/src/object/live_slots.rs @@ -28,42 +28,6 @@ pub extern "C" fn perry_object_header_abi_revision() -> u32 { 2 } -/// Direct-mapped `ShapeId -> live_inline_slot_count` memo. -/// -/// The bound used to be a single `u32` load off the header. It is now a -/// shape-table probe — a TLS resolution, a `RefCell` borrow, a SipHash and a -/// bucket walk — on a path that includes every by-index field write. This memo -/// puts a plain array index in front of that. -/// -/// # Why it needs no invalidation -/// -/// Two facts, both load-bearing: -/// -/// * **ShapeIds are never reused.** `shapes::SHAPE_ID_NEXT` is a monotonic -/// process-global counter and exhaustion fail-STOPS (`shape_id_exhausted_abort`), -/// so an id names one fact set for the life of the process. -/// * **`live_inline_slot_count` is part of the exact facts an id is minted -/// for.** `shape_descriptor_ensure_with_generation` dedupes on those facts, so -/// two different bounds get two different ids. The only field ever mutated in -/// place on a published descriptor is `keys` (rewritten by the evacuator), and -/// this memo does not hold it. -/// -/// `prune_dead_shape_keys` can REMOVE an id, which would leave a stale entry — -/// but its documented contract is that "a descriptor removed here cannot be -/// named by a live object", so a stale entry is only reachable through a dead -/// receiver. (Keyless descriptors, whose `keys` is 0, are never pruned: the -/// dead-owner predicate classifies address 0 as not-in-any-heap-space.) -/// -/// The memo is per-thread because descriptor tables are per-agent: a -/// process-global id can name different local facts in two agents -/// (`install_external_shape_id`). -const LIVE_SLOT_MEMO_WAYS: usize = 64; - -thread_local! { - static LIVE_SLOT_MEMO: [std::cell::Cell<(u32, u32)>; LIVE_SLOT_MEMO_WAYS] = - [const { std::cell::Cell::new((0, 0)) }; LIVE_SLOT_MEMO_WAYS]; -} - /// The authoritative live inline-slot bound (#8113: the replacement for the /// deleted `ObjectHeader::field_count` word). /// @@ -72,42 +36,24 @@ thread_local! { /// unbounded one, and every runtime allocator publishes a descriptor before its /// header escapes, so the zero case is a raw/synthetic fixture, not a live /// object. +/// +/// # A ShapeId -> count memo in front of this measured NULL (#8113) +/// +/// The bound used to be one `u32` load off the header and is now a shape-table +/// probe, so a 64-way direct-mapped `ShapeId -> count` cache looked like the +/// obvious recovery. It was built, sabotage-tested, and measured on the +/// 19-program corpus against the same baseline: `retain` +4.26% vs +3.26% +/// WITHOUT it, `retain_wide` +4.46% vs +2.89%, `retain_wide1` +4.18% vs +2.61%, +/// `deeplist` +8.69% vs +8.20% — worse on four of the five rows that pay the +/// bound at all, better only on `shapes`. The memo pays its own TLS resolution +/// and a closure, which is most of what `state()` + a small `HashMap` +/// probe costs. It was deleted rather than left in as an unmeasured +/// configuration. #[inline] pub unsafe fn object_live_slot_count(obj: *const ObjectHeader) -> u32 { - let shape_id = shapes::object_shape_stamp(obj); - if shape_id == 0 { - return 0; - } - let way = (shape_id as usize) & (LIVE_SLOT_MEMO_WAYS - 1); - LIVE_SLOT_MEMO.with(|memo| { - let entry = &memo[way]; - let (cached_id, cached_count) = entry.get(); - if cached_id == shape_id { - return cached_count; - } - let count = shapes::shape_descriptor_by_id(shape_id) - .map(|descriptor| descriptor.live_inline_slot_count) - .unwrap_or(0); - // A missing descriptor is NOT cached: it is the fail-closed answer for - // a stale/foreign id, and caching it would make a later legitimate - // install of that id invisible. - if count != 0 { - entry.set((shape_id, count)); - } - count - }) -} - -/// Test hook: drop every memo entry. The memo needs no invalidation in -/// production (see [`LIVE_SLOT_MEMO`]), but a test that plants a synthetic id, -/// drops its descriptor and re-mints under the same id must be able to say so. -#[cfg(test)] -pub(crate) fn test_clear_live_slot_memo() { - LIVE_SLOT_MEMO.with(|memo| { - for entry in memo.iter() { - entry.set((0, 0)); - } - }); + shapes::object_shape_descriptor(obj) + .map(|descriptor| descriptor.live_inline_slot_count) + .unwrap_or(0) } /// C-ABI accessor for [`object_live_slot_count`], for out-of-runtime consumers @@ -154,96 +100,3 @@ pub unsafe fn object_inline_alloc_limit(obj: *const ObjectHeader) -> u32 { pub(crate) unsafe fn set_object_live_slot_count(obj: *mut ObjectHeader, field_count: u32) { shapes::publish_object_live_slot_count(obj, field_count); } - -#[cfg(test)] -mod tests { - use super::*; - - /// #8113: the memo must be keyed by ShapeId, and an entry must be REPLACED - /// when a different id maps to the same way. - /// - /// Sabotage-shaped, and the premise is the load-bearing part: two arbitrary - /// shapes get consecutive ids and therefore different ways, so alternating - /// between them proves nothing. This mints enough shapes to FIND a pair - /// that collides, asserts it found one, and only then alternates. Replacing - /// the `cached_id == shape_id` test with `cached_id != 0` turns it red. - #[test] - fn the_live_slot_memo_is_keyed_by_shape_id_not_by_way() { - let _lock = crate::gc::global_side_table_test_lock(); - unsafe { - // Distinct widths so a mixed-up answer is observable, and enough - // shapes that two of them must share one of the 64 ways. - let mut minted: Vec<(*mut ObjectHeader, u32, u32)> = Vec::new(); - for width in 1u32..=(LIVE_SLOT_MEMO_WAYS as u32 + 8) { - let mut packed = Vec::new(); - for i in 0..width { - packed.extend_from_slice(format!("m8113w{width}_{i}").as_bytes()); - packed.push(0); - } - let obj = crate::object::js_object_alloc_with_shape( - 0x8113_2000 + width, - width, - packed.as_ptr(), - packed.len() as u32, - ); - let id = (*obj).parent_class_id; - assert!(shapes::is_shape_id(id)); - minted.push((obj, id, width)); - } - - let mut collision: Option<((*mut ObjectHeader, u32), (*mut ObjectHeader, u32))> = None; - 'outer: for i in 0..minted.len() { - for j in (i + 1)..minted.len() { - let (a, ida, wa) = minted[i]; - let (b, idb, wb) = minted[j]; - if ida != idb - && wa != wb - && (ida as usize) & (LIVE_SLOT_MEMO_WAYS - 1) - == (idb as usize) & (LIVE_SLOT_MEMO_WAYS - 1) - { - collision = Some(((a, wa), (b, wb))); - break 'outer; - } - } - } - let ((a, wa), (b, wb)) = collision.expect( - "test premise: two distinct shapes with different widths must share a memo way", - ); - - // Alternate. A memo that returns whatever is in the way, without - // checking the id, hands one object the other's bound. - for _ in 0..4 { - assert_eq!(object_live_slot_count(a), wa); - assert_eq!(object_live_slot_count(b), wb); - } - } - } - - /// The bound must FOLLOW a re-stamp: growing an object past its birth width - /// mints a successor ShapeId, and the memo is keyed by that id, so the new - /// bound must be visible immediately. - #[test] - fn the_live_slot_memo_follows_a_reshape() { - let _lock = crate::gc::global_side_table_test_lock(); - unsafe { - let obj = crate::object::js_object_alloc(0, 1); - let before_id = (*obj).parent_class_id; - assert_eq!(object_live_slot_count(obj), 1); - - let key = crate::string::js_string_from_bytes(b"m8113_grow".as_ptr(), 10); - crate::object::js_object_set_field_by_name(obj, key, 7.0); - let after_id = (*obj).parent_class_id; - assert_ne!( - before_id, after_id, - "test premise: the append re-stamps the receiver" - ); - assert_eq!( - object_live_slot_count(obj), - shapes::shape_descriptor_by_id(after_id) - .expect("successor descriptor") - .live_inline_slot_count, - "the memo must follow the successor ShapeId, not hold the birth bound" - ); - } - } -} diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index bec320190c..f1ccf78011 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -96,8 +96,6 @@ mod instanceof; mod live_slots; mod null_stub; pub(crate) use live_slots::set_object_live_slot_count; -#[cfg(test)] -pub(crate) use live_slots::test_clear_live_slot_memo; pub use live_slots::{ js_object_live_slot_count, object_inline_alloc_limit, object_live_slot_count, perry_object_header_abi_revision, diff --git a/crates/perry-runtime/src/object/shapes.rs b/crates/perry-runtime/src/object/shapes.rs index 00e52a0cda..296d11d7a0 100644 --- a/crates/perry-runtime/src/object/shapes.rs +++ b/crates/perry-runtime/src/object/shapes.rs @@ -1184,11 +1184,6 @@ pub(crate) fn test_shape_descriptor_count() -> usize { #[cfg(test)] pub(crate) fn test_clear_shape_table() { - // #8113: the live-slot memo is keyed by ShapeId and needs no invalidation - // in production (ids are never reused). A test that wipes the table and - // re-mints from the same id space is exactly the case that breaks that - // premise, so drop it here. - crate::object::test_clear_live_slot_memo(); let mut inner = crate::state::state().shapes.inner.borrow_mut(); inner.indices.clear(); inner.descriptors.clear(); @@ -1198,8 +1193,6 @@ pub(crate) fn test_clear_shape_table() { #[cfg(test)] pub(crate) fn test_drop_shape_descriptors(keys_id: usize) { - // #8113: see `test_clear_shape_table`. - crate::object::test_clear_live_slot_memo(); let mut inner = crate::state::state().shapes.inner.borrow_mut(); let stale = inner .ids_by_keys From 6cbebfc767a3ff92debdd15c8281f2b06afcc15f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 15 Aug 2026 05:12:08 +0200 Subject: [PATCH 05/12] chore(object): drop the unused object_inline_alloc_limit helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was added with the rest of #8113's live-slot API and never called: every alloc_limit site computes max(bound, INLINE_SLOT_FLOOR) from a bound it already has in hand after the CSE pass. Removing an uncalled function cannot change the generated code — verified: libperry_runtime.a stays byte-identical to the artifact the corpus numbers were measured on. Refs #8113. --- crates/perry-runtime/src/object/live_slots.rs | 12 ------------ crates/perry-runtime/src/object/mod.rs | 3 +-- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/crates/perry-runtime/src/object/live_slots.rs b/crates/perry-runtime/src/object/live_slots.rs index 225206088f..3aa6423ab5 100644 --- a/crates/perry-runtime/src/object/live_slots.rs +++ b/crates/perry-runtime/src/object/live_slots.rs @@ -9,7 +9,6 @@ use super::shapes; use super::ObjectHeader; -use super::INLINE_SLOT_FLOOR; /// Revision of the [`ObjectHeader`] ABI, paired with /// `perry_ffi::OBJECT_HEADER_ABI_REVISION`. @@ -70,17 +69,6 @@ pub unsafe extern "C" fn js_object_live_slot_count(obj: *const ObjectHeader) -> object_live_slot_count(obj) } -/// The OOB bound every by-index field write is checked against: -/// `max(live_inline_slot_count, INLINE_SLOT_FLOOR)`. Every allocator reserves -/// at least `INLINE_SLOT_FLOOR` physical slots (`object/alloc.rs`), and -/// `live_inline_slot_count` is a fixed point of the same expression — the -/// by-name append path only ever bumps it for a slot it placed inline — so this -/// can never exceed the physical slot count. -#[inline] -pub unsafe fn object_inline_alloc_limit(obj: *const ObjectHeader) -> u32 { - std::cmp::max(object_live_slot_count(obj), INLINE_SLOT_FLOOR as u32) -} - /// Publish a new authoritative live-inline-slot bound. /// /// #8113 MINT-THEN-STAMP. There is no longer a header word to fall back on, so diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index f1ccf78011..58b7f10858 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -97,8 +97,7 @@ mod live_slots; mod null_stub; pub(crate) use live_slots::set_object_live_slot_count; pub use live_slots::{ - js_object_live_slot_count, object_inline_alloc_limit, object_live_slot_count, - perry_object_header_abi_revision, + js_object_live_slot_count, object_live_slot_count, perry_object_header_abi_revision, }; pub use null_stub::{js_unresolved_default_call, js_unresolved_namespace_stub}; pub(crate) use null_stub::{NullObjectBytes, NULL_OBJECT_BYTES}; From 67730ef0fa749f1567f6ec7bc66473489049c456 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 15 Aug 2026 05:18:44 +0200 Subject: [PATCH 06/12] docs(changelog): record the measured corpus result for #8113 --- .../8122-object-header-shrink-56-to-48.md | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/changelog.d/8122-object-header-shrink-56-to-48.md b/changelog.d/8122-object-header-shrink-56-to-48.md index 106fc4f6f0..5c0d2692ee 100644 --- a/changelog.d/8122-object-header-shrink-56-to-48.md +++ b/changelog.d/8122-object-header-shrink-56-to-48.md @@ -115,6 +115,53 @@ emit `gep(I8, &p, &[(I64, "N")])` — so it now matches both spellings and requi each guard to be shown reading the ShapeId at all. Three new sabotage self-tests cover the new rules. +#### Measured + +19-program corpus, both arms built from one worktree with `-p perry +-p perry-runtime-static -p perry-stdlib-static`, `PERRY_RUNTIME_DIR` pinned per +arm, the two `libperry_runtime.a` files `cmp`-verified to differ, all 19 stdout +byte-compared against `expected/` and exit-checked in both arms: + +| prog | Δ instructions | Δ peak RSS | +|---|---:|---:| +| `retain` | +3.26% | **−9.10%** | +| `retain1` | +7.99% | −5.29% | +| `retain_wide` | +2.89% | −5.45% | +| `retain_wide1` | +2.61% | −6.04% | +| `tree` | +0.54% | **−12.79%** | +| `tree_wide` | +0.44% | −6.30% | +| `deeplist` | +8.20% | −4.19% | +| `shapes` | +4.96% | −0.61% | +| `churn_alloc` / `push_cls` | +4.3% | ~0 | +| `fib40` / `push_num` / `churn_read` | ~0 | ~0 | + +The rows with no object population move by ~0 — that is the control. The +instruction cost is the price of the change: the bound is a shape-table probe +where it used to be a `u32` load. + +Two findings worth carrying forward, both from measuring rather than assuming: + +1. The first cut regressed instructions by up to **+30%** (`deeplist` +30.5%, + `cycles` +28.4%, `tree` +25.4%). Five GC-side sites already read the bound + descriptor-first with the header word as an `unwrap_or` fallback — and + **`unwrap_or` is eager**, so the substitution made each do *two* shape-table + probes, one of them (`gc/layout.rs`'s `layout_note_slot`) on every object + field store. Fixed, along with `weakref::is_weak_target_trace_slot` (three + probes per traced slot → one) and six write paths that read the bound twice. +2. A 64-way direct-mapped `ShapeId → count` memo — sound without invalidation, + and the obvious recovery — **measured null and was deleted**: `retain` +4.26% + with it vs +3.26% without, `retain_wide` +4.46% vs +2.89%, better only on + `shapes`. Its first sabotage test was *vacuous* (two arbitrary shapes get + consecutive ids and so never share a memo way) and the sabotage run caught + that. The numbers survive as a doc comment so it is not rebuilt. + +GC canaries (`retain`/`tree`/`churn`/`shapes` × plain / `FORCE_EVACUATE` + +`VERIFY_EVACUATION` / `FORCE_EVACUATE` + `PROTECT_FROMSPACE DEPTH=32`, all under +`PERRY_GC_DIAG=1`): all exit 0 and byte-exact, with `copied_objects > 0` or +`promoted_objects > 0` on every row (`retain` copies 368,635 and promotes 2.1 M), +and the protect arm printing 8 `[gc-fromspace-protect]` lines against +`copying_minors=8` — so no arm is vacuous. + #### Also * `perry-ui-android/src/json.rs` deleted — 606 lines, every function private with From 72d04f49d7b4362c29afe38d90aadcc68d672624 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 15 Aug 2026 06:21:22 +0200 Subject: [PATCH 07/12] perf(proxy): stop re-deriving a GcHeader the store-plan gate already holds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A per-callsite counter (#[track_caller] + libc::atexit, on tls_hot.rs's pattern) found `object_is_regular` firing EXACTLY ONCE PER ALLOCATED OBJECT from proxy.rs's #6595 store-plan gate: 3,000,000 calls on retain, 20,000,002 on churn, and still 1.00 per object on retain_wide's 8-field literals — the per-object, flat-in-width signature the corpus showed. That gate used to be `(*obj).object_type == OBJECT_TYPE_REGULAR`, a free u32 compare on the word this rung deleted. The call site has already read the very same GcHeader for its blocking-flags test, so `object_is_regular_with_header` takes it instead of re-deriving it through `try_read_gc_header` (handle-band check, heap-range check, small-buffer-slab check, reload). The predicate is character-for-character unchanged, so #6595 stays closed. `interned != 0` — a free compare that sat AFTER the probe in the && chain — moves ahead of it. The remaining shape-table probe is NOT removed here: every cheap substitute (the narrow PLAIN_ORDINARY_OBJ_FLAG birth marker, a global has-class-objects short-circuit) changes the answer for some receiver class, and that is a #6595-adjacent design call rather than a mechanical fix. The census follows the predicate to its new home and gains a sabotage test that the two spellings cannot drift. Refs #8113. --- crates/perry-runtime/src/object/mod.rs | 28 ++++++++++++++++++++++++++ crates/perry-runtime/src/proxy.rs | 18 ++++++++++++++--- scripts/shape_descriptor_census.py | 26 ++++++++++++++++++++++-- 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 58b7f10858..50e647e1c5 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -1742,6 +1742,34 @@ pub(crate) unsafe fn object_is_regular(obj: *const ObjectHeader) -> bool { let Some(header) = crate::value::addr_class::try_read_gc_header(obj as usize) else { return false; }; + object_is_regular_with_header(header, obj) +} + +/// [`object_is_regular`] for a caller that has ALREADY read the receiver's +/// `GcHeader`. The predicate is character-for-character the same — this only +/// moves where the header comes from. +/// +/// # Why it exists (#8113) +/// +/// `proxy.rs`'s #6595 store-plan gate used to be +/// `(*obj).object_type == OBJECT_TYPE_REGULAR`, a free `u32` compare on a word +/// this rung deleted. Its replacement, `object_is_regular`, is the correct +/// predicate — but it is a `try_read_gc_header` (band check, heap-range check, +/// small-buffer-slab check, then the load) plus a shape-table probe, and a +/// per-callsite counter measured it firing **exactly once per allocated +/// object**: 3,000,000 on `retain`, 20,000,002 on `churn`, and still 1.00 per +/// object on `retain_wide`'s 8-field literals. That gate is the single largest +/// piece of this rung's instruction cost. +/// +/// The caller there has already read the very same `GcHeader` for its +/// blocking-flags test, so passing it in deletes the whole re-derivation at +/// zero semantic cost. The remaining shape-table probe is a real design +/// question (#6595 forbids weakening the predicate) and is tracked separately. +#[inline] +pub(crate) unsafe fn object_is_regular_with_header( + header: &crate::gc::GcHeader, + obj: *const ObjectHeader, +) -> bool { header.obj_type == crate::gc::GC_TYPE_OBJECT && header.gc_flags & crate::gc::GC_FLAG_FORWARDED == 0 && shapes::object_shape_descriptor(obj) diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index aa02b905f2..38b0dc3ab4 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -1530,6 +1530,12 @@ fn ordinary_set_with_receiver(target: f64, key: f64, value: f64, receiver: f64) addr, ) && class_id != crate::object::NATIVE_MODULE_CLASS_ID + // #8113: `interned != 0` is a free compare and + // moves AHEAD of the descriptor probe below — + // an un-interned key can never be plan-eligible, + // so there is no reason to pay for the receiver + // test first. + && interned != 0 // #8113: this asks for ORDINARY specifically — // it must stay FALSE for a class object or // #6595 reopens. `object_is_regular` is exactly @@ -1538,10 +1544,16 @@ fn ordinary_set_with_receiver(target: f64, key: f64, value: f64, receiver: f64) // deleted `object_type == OBJECT_TYPE_REGULAR` // word expressed, not the weaker // "is an ObjectHeader" test. - && crate::object::object_is_regular( + // + // `_with_header` because `header` above IS this + // receiver's `GcHeader`: re-deriving it here + // cost a band/heap-range/slab classification + // plus a reload, once per allocated object, + // measured by a per-callsite counter. + && crate::object::object_is_regular_with_header( + header, addr as *const crate::ObjectHeader, - ) - && interned != 0; + ); let verdict = if plan_eligible && crate::object::prop_plan::store_plan_check(class_id, interned) { diff --git a/scripts/shape_descriptor_census.py b/scripts/shape_descriptor_census.py index 982d7fc827..27576edb97 100644 --- a/scripts/shape_descriptor_census.py +++ b/scripts/shape_descriptor_census.py @@ -473,7 +473,16 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: # RegExp identity lives in the GcHeader kind. No ObjectHeader payload word # or registry/magic conjunction may decide these ordinary-object forks. - for name in ("object_is_regular", "object_is_shaped"): + # #8113: `object_is_regular` delegates its predicate to + # `object_is_regular_with_header` so `proxy.rs`'s store-plan gate can pass a + # `GcHeader` it has already read. The two must not drift, so the delegation + # itself is asserted and the predicate is checked where it now lives. + require_code( + function_body(object_mod, "object_is_regular"), + r"object_is_regular_with_header\s*\(", + "object_is_regular delegates to the header-taking form", + ) + for name in ("object_is_regular_with_header", "object_is_shaped"): body = function_body(object_mod, name) require_code(body, r"obj_type\s*==\s*crate::gc::GC_TYPE_OBJECT", f"{name} GC kind") if re.search(r"regex_header_has_magic|object_type", body): @@ -595,7 +604,7 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: if "OBJ_FLAG_CLASS_OBJECT" in gc_types + class_guard + element_guard + write_pics: raise CensusError("class kind reintroduced a GcHeader layout-bit alias") assert_header_fields(object_mod) - class_probe = function_body(object_mod, "object_is_regular") + class_probe = function_body(object_mod, "object_is_regular_with_header") require_code( class_probe, r"ShapeObjectKind::Ordinary", @@ -820,6 +829,19 @@ def run_sabotage_selftests(sources: dict[str, str], baseline: dict[str, object]) lambda: assert_authority_surfaces(cleared_publication), ) + # #8113: the two spellings of the ordinary-object predicate must not drift. + undelegated = dict(sources) + path = "crates/perry-runtime/src/object/mod.rs" + undelegated[path] = undelegated[path].replace( + " object_is_regular_with_header(header, obj)\n", + " header.obj_type == crate::gc::GC_TYPE_OBJECT\n", + 1, + ) + expect_rejected( + "object_is_regular stopped delegating to the header-taking form", + lambda: assert_authority_surfaces(undelegated), + ) + stale_summary = json.loads(json.dumps(baseline)) stale_summary["summary"]["raw_member_files"] += 1 expect_rejected( From f778f12f79a270d0eb3da94c83ba186b47c2cc67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 15 Aug 2026 06:34:14 +0200 Subject: [PATCH 08/12] Revert "perf(proxy): stop re-deriving a GcHeader the store-plan gate already holds" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts 599fe97ee. The change was argued to be semantically free — same predicate, strictly less work — and it MEASURED as a reproducible regression: row pre-fix post-fix (3-run best-of, quiet host) interp +0.29% +9.59% pipeline +0.34% +4.43% retain +3.26% +3.04% deeplist +8.20% +9.31% It did not help the rows the per-callsite counter said it would (retain moved 3.26 -> 3.04, inside noise) and it cost ~1.25 BILLION instructions on interp. The predicate is provably unchanged (same `&&` chain over pure operands, and the removed `try_read_gc_header` had already been performed by the caller), so the mechanism is a codegen/inlining effect, not semantics — plausibly the inlined shape probe bloating proxy.rs's hot path for interpreter-shaped workloads. That is a hypothesis, not a finding. Reverting rather than shipping an unexplained regression under a 'free' label. The underlying cost is real and localised; it belongs in the follow-up issue with the other two candidates, where it can be measured on its own. Refs #8113. --- crates/perry-runtime/src/object/mod.rs | 28 -------------------------- crates/perry-runtime/src/proxy.rs | 18 +++-------------- scripts/shape_descriptor_census.py | 26 ++---------------------- 3 files changed, 5 insertions(+), 67 deletions(-) diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 50e647e1c5..58b7f10858 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -1742,34 +1742,6 @@ pub(crate) unsafe fn object_is_regular(obj: *const ObjectHeader) -> bool { let Some(header) = crate::value::addr_class::try_read_gc_header(obj as usize) else { return false; }; - object_is_regular_with_header(header, obj) -} - -/// [`object_is_regular`] for a caller that has ALREADY read the receiver's -/// `GcHeader`. The predicate is character-for-character the same — this only -/// moves where the header comes from. -/// -/// # Why it exists (#8113) -/// -/// `proxy.rs`'s #6595 store-plan gate used to be -/// `(*obj).object_type == OBJECT_TYPE_REGULAR`, a free `u32` compare on a word -/// this rung deleted. Its replacement, `object_is_regular`, is the correct -/// predicate — but it is a `try_read_gc_header` (band check, heap-range check, -/// small-buffer-slab check, then the load) plus a shape-table probe, and a -/// per-callsite counter measured it firing **exactly once per allocated -/// object**: 3,000,000 on `retain`, 20,000,002 on `churn`, and still 1.00 per -/// object on `retain_wide`'s 8-field literals. That gate is the single largest -/// piece of this rung's instruction cost. -/// -/// The caller there has already read the very same `GcHeader` for its -/// blocking-flags test, so passing it in deletes the whole re-derivation at -/// zero semantic cost. The remaining shape-table probe is a real design -/// question (#6595 forbids weakening the predicate) and is tracked separately. -#[inline] -pub(crate) unsafe fn object_is_regular_with_header( - header: &crate::gc::GcHeader, - obj: *const ObjectHeader, -) -> bool { header.obj_type == crate::gc::GC_TYPE_OBJECT && header.gc_flags & crate::gc::GC_FLAG_FORWARDED == 0 && shapes::object_shape_descriptor(obj) diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index 38b0dc3ab4..aa02b905f2 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -1530,12 +1530,6 @@ fn ordinary_set_with_receiver(target: f64, key: f64, value: f64, receiver: f64) addr, ) && class_id != crate::object::NATIVE_MODULE_CLASS_ID - // #8113: `interned != 0` is a free compare and - // moves AHEAD of the descriptor probe below — - // an un-interned key can never be plan-eligible, - // so there is no reason to pay for the receiver - // test first. - && interned != 0 // #8113: this asks for ORDINARY specifically — // it must stay FALSE for a class object or // #6595 reopens. `object_is_regular` is exactly @@ -1544,16 +1538,10 @@ fn ordinary_set_with_receiver(target: f64, key: f64, value: f64, receiver: f64) // deleted `object_type == OBJECT_TYPE_REGULAR` // word expressed, not the weaker // "is an ObjectHeader" test. - // - // `_with_header` because `header` above IS this - // receiver's `GcHeader`: re-deriving it here - // cost a band/heap-range/slab classification - // plus a reload, once per allocated object, - // measured by a per-callsite counter. - && crate::object::object_is_regular_with_header( - header, + && crate::object::object_is_regular( addr as *const crate::ObjectHeader, - ); + ) + && interned != 0; let verdict = if plan_eligible && crate::object::prop_plan::store_plan_check(class_id, interned) { diff --git a/scripts/shape_descriptor_census.py b/scripts/shape_descriptor_census.py index 27576edb97..982d7fc827 100644 --- a/scripts/shape_descriptor_census.py +++ b/scripts/shape_descriptor_census.py @@ -473,16 +473,7 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: # RegExp identity lives in the GcHeader kind. No ObjectHeader payload word # or registry/magic conjunction may decide these ordinary-object forks. - # #8113: `object_is_regular` delegates its predicate to - # `object_is_regular_with_header` so `proxy.rs`'s store-plan gate can pass a - # `GcHeader` it has already read. The two must not drift, so the delegation - # itself is asserted and the predicate is checked where it now lives. - require_code( - function_body(object_mod, "object_is_regular"), - r"object_is_regular_with_header\s*\(", - "object_is_regular delegates to the header-taking form", - ) - for name in ("object_is_regular_with_header", "object_is_shaped"): + for name in ("object_is_regular", "object_is_shaped"): body = function_body(object_mod, name) require_code(body, r"obj_type\s*==\s*crate::gc::GC_TYPE_OBJECT", f"{name} GC kind") if re.search(r"regex_header_has_magic|object_type", body): @@ -604,7 +595,7 @@ def assert_authority_surfaces(sources: dict[str, str]) -> None: if "OBJ_FLAG_CLASS_OBJECT" in gc_types + class_guard + element_guard + write_pics: raise CensusError("class kind reintroduced a GcHeader layout-bit alias") assert_header_fields(object_mod) - class_probe = function_body(object_mod, "object_is_regular_with_header") + class_probe = function_body(object_mod, "object_is_regular") require_code( class_probe, r"ShapeObjectKind::Ordinary", @@ -829,19 +820,6 @@ def run_sabotage_selftests(sources: dict[str, str], baseline: dict[str, object]) lambda: assert_authority_surfaces(cleared_publication), ) - # #8113: the two spellings of the ordinary-object predicate must not drift. - undelegated = dict(sources) - path = "crates/perry-runtime/src/object/mod.rs" - undelegated[path] = undelegated[path].replace( - " object_is_regular_with_header(header, obj)\n", - " header.obj_type == crate::gc::GC_TYPE_OBJECT\n", - 1, - ) - expect_rejected( - "object_is_regular stopped delegating to the header-taking form", - lambda: assert_authority_surfaces(undelegated), - ) - stale_summary = json.loads(json.dumps(baseline)) stale_summary["summary"]["raw_member_files"] += 1 expect_rejected( From c344835028036585b7cc56ddd559d8e038680877 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 15 Aug 2026 06:37:10 +0200 Subject: [PATCH 09/12] docs(changelog): record where the residual instruction cost is, and the zero Adds the per-callsite counter result to the fragment: the residual is one site (proxy.rs's #6595 store-plan gate, one probe per allocated object, flat in width), `object_live_slot_count` is called ZERO times on every hot row so a memo in front of it is structurally pointless, and the 'free' repair for the site measured as an interp +9.59% regression and was reverted. Refs #8113, #8125. --- .../8122-object-header-shrink-56-to-48.md | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/changelog.d/8122-object-header-shrink-56-to-48.md b/changelog.d/8122-object-header-shrink-56-to-48.md index 5c0d2692ee..f30ac23786 100644 --- a/changelog.d/8122-object-header-shrink-56-to-48.md +++ b/changelog.d/8122-object-header-shrink-56-to-48.md @@ -139,7 +139,26 @@ The rows with no object population move by ~0 — that is the control. The instruction cost is the price of the change: the bound is a shape-table probe where it used to be a `u32` load. -Two findings worth carrying forward, both from measuring rather than assuming: +**Where the residual actually is.** A per-callsite counter (`#[track_caller]` + +`libc::atexit`, on `tls_hot.rs::maybe_install_stats_hook`'s pattern) over every +shape-table entry point found it is **one site**: `proxy.rs`'s #6595 store-plan +gate, which this rung changed from `object_type == OBJECT_TYPE_REGULAR` (a free +`u32` compare) to `object_is_regular` — a `GcHeader` re-derivation plus a +shape-table probe, firing **exactly once per allocated object** (3,000,000 on +`retain`, 20,000,002 on `churn`, and still 1.00 per object on `retain_wide`'s +8-field literals, which is the per-object/flat-in-width signature the corpus +showed). Reducing it means weakening a predicate #6595 constrains, so it is +tracked separately with the counts attached; the obvious "free" repair was tried +here and reverted (see below). + +The same counter established something that matters more for anyone optimising +this later: **`object_live_slot_count` — the bound derivation this rung +introduces — is called ZERO times on every hot row.** Not once, across all nine +programs measured. Two separate memo attempts in front of it measured null +because they were caching a function that never runs on the measured path. Check +the call count before reaching for a memo there. + +Three findings worth carrying forward, all from measuring rather than assuming: 1. The first cut regressed instructions by up to **+30%** (`deeplist` +30.5%, `cycles` +28.4%, `tree` +25.4%). Five GC-side sites already read the bound @@ -155,6 +174,16 @@ Two findings worth carrying forward, both from measuring rather than assuming: consecutive ids and so never share a memo way) and the sabotage run caught that. The numbers survive as a doc comment so it is not rebuilt. +3. The counter-guided repair for the site above — pass the `GcHeader` the caller + already holds, hoist a free compare ahead of the probe — is semantically + identical and strictly less work, and **measured as a reproducible + regression**: `interp` +0.29% -> **+9.59%**, `pipeline` +0.34% -> +4.43%, + while doing nothing for `retain` (+3.26% -> +3.04%, noise). Implemented, + measured, reverted. The mechanism is codegen/inlining rather than semantics + and is not established. "Semantically identical and strictly less work" is an + argument about the source; only the corpus can make it a claim about the + binary. + GC canaries (`retain`/`tree`/`churn`/`shapes` × plain / `FORCE_EVACUATE` + `VERIFY_EVACUATION` / `FORCE_EVACUATE` + `PROTECT_FROMSPACE DEPTH=32`, all under `PERRY_GC_DIAG=1`): all exit 0 and byte-exact, with `copied_objects > 0` or From c18b620cc267baca3670a415844ee14309aa3df1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 16 Aug 2026 07:22:07 +0200 Subject: [PATCH 10/12] fix(runtime): param_type_guard reads the receiver kind and live bound through the #8113 accessors --- crates/perry-runtime/src/param_type_guard.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/perry-runtime/src/param_type_guard.rs b/crates/perry-runtime/src/param_type_guard.rs index a8123c36e9..c60fa52710 100644 --- a/crates/perry-runtime/src/param_type_guard.rs +++ b/crates/perry-runtime/src/param_type_guard.rs @@ -187,12 +187,17 @@ impl GuardState<'_> { return None; } let object = address as *const ObjectHeader; - if (*object).object_type != crate::error::OBJECT_TYPE_REGULAR - || (*object).field_count as usize > MAX_CONTAINER_LEN - { + // #8113: the header no longer carries `object_type` / `field_count`; + // the receiver kind comes from the ShapeId descriptor and the live + // inline-slot bound from `object_live_slot_count`. + if !crate::object::object_is_regular(object) { + return None; + } + let live_slots = crate::object::object_live_slot_count(object) as usize; + if live_slots > MAX_CONTAINER_LEN { return None; } - let inline_fields = ((*object).field_count as usize).max(crate::object::INLINE_SLOT_FLOOR); + let inline_fields = live_slots.max(crate::object::INLINE_SLOT_FLOOR); let required = crate::gc::GC_HEADER_SIZE .checked_add(std::mem::size_of::())? .checked_add(inline_fields.checked_mul(std::mem::size_of::())?)?; From 617540fb9761cb808286c95236a470b4ae05542c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 16 Aug 2026 08:10:24 +0200 Subject: [PATCH 11/12] chore(census): refresh the shape census baseline for main's two new keys_array sites The rebase onto #8110 (census as a real gate) and #8157 (PtrHashMap shape probes) brought two new `keys_array` callsites that postdate this branch's baseline: `param_type_guard.rs` (#8094) and `process/node_module/source_map.rs` (#7312). Both are ordinary uses of a field this change keeps; neither reintroduces `object_type` or `field_count`. The addr-class ratchet baseline is restored to main's verbatim: this branch produces the same verdict main does (542 held sites, the same two pre-existing stale entries), so the branch-local regeneration only served to drop #7272's provenance comment. Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj --- scripts/addr_class_ratchet_baseline.txt | 1 + scripts/shape_descriptor_census_baseline.json | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/addr_class_ratchet_baseline.txt b/scripts/addr_class_ratchet_baseline.txt index b0156607e1..27c8cc7288 100644 --- a/scripts/addr_class_ratchet_baseline.txt +++ b/scripts/addr_class_ratchet_baseline.txt @@ -239,6 +239,7 @@ lone-valid-obj-ptr | crates/perry-runtime/src/object/field_get_set/get_field_by_ lone-valid-obj-ptr | crates/perry-runtime/src/object/field_get_set/has_property.rs | 1 lone-valid-obj-ptr | crates/perry-runtime/src/object/field_get_set/ic_miss.rs | 1 lone-valid-obj-ptr | crates/perry-runtime/src/object/field_set_by_name/attr_variants.rs | 2 +lone-valid-obj-ptr | crates/perry-runtime/src/object/field_set_by_name/tail.rs | 1 lone-valid-obj-ptr | crates/perry-runtime/src/object/global_this/array_error.rs | 1 lone-valid-obj-ptr | crates/perry-runtime/src/object/global_this/fetch_globals.rs | 1 lone-valid-obj-ptr | crates/perry-runtime/src/object/global_this/typed_array.rs | 1 diff --git a/scripts/shape_descriptor_census_baseline.json b/scripts/shape_descriptor_census_baseline.json index 538b86e2ac..5f1a027a14 100644 --- a/scripts/shape_descriptor_census_baseline.json +++ b/scripts/shape_descriptor_census_baseline.json @@ -159,8 +159,10 @@ "crates/perry-runtime/src/object/shapes.rs|keys_array|access|publish_object_shape_from(obj, predecessor, (*obj).keys_array, live_inline_slot_count)": 1, "crates/perry-runtime/src/object/shapes.rs|keys_array|access|unsafe { (*obj).keys_array },": 1, "crates/perry-runtime/src/object/shapes.rs|keys_array|declaration|keys_array: keys as *mut ArrayHeader,": 1, + "crates/perry-runtime/src/param_type_guard.rs|keys_array|access|let keys = (*object).keys_array;": 1, "crates/perry-runtime/src/perf_hooks.rs|keys_array|access|let keys_ptr = (*obj).keys_array as usize;": 1, "crates/perry-runtime/src/perf_hooks.rs|keys_array|access|recorded != 0 && (*obj).keys_array as usize == recorded": 1, + "crates/perry-runtime/src/process/node_module/source_map.rs|keys_array|access|(*obj).keys_array = std::ptr::null_mut();": 1, "crates/perry-runtime/src/promise/then_probe.rs|keys_array|access|let keys = (*obj).keys_array;": 2, "crates/perry-runtime/src/thread.rs|keys_array|access|let keys = if !(*obj).keys_array.is_null() {": 1, "crates/perry-runtime/src/thread.rs|keys_array|access|let keys_arr = (*obj).keys_array;": 1, @@ -176,9 +178,9 @@ }, "summary": { "codegen_object_header_size_sites": 33, - "raw_member_files": 63, + "raw_member_files": 65, "raw_member_sites": { - "keys_array": 181 + "keys_array": 183 } } } From 8f3e32016652be083331e250bbaa4e98286535f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 16 Aug 2026 08:48:56 +0200 Subject: [PATCH 12/12] docs(changelog): re-measure on post-#8157 main; retract the proxy.rs attribution Re-measured against `3be2016c1` (after #8157's PtrHashMap shape probes and #8110's census gate), quiet M1 mini, best-of-5, instructions AND peak RSS. The #8157 hypothesis is REFUTED: 0 of 19 rows are faster and 12 pay more than 1%. The regression is slightly worse than the pre-#8157 table on most rows (deeplist +8.20 -> +9.03, retain1 +7.99 -> +8.24, churn +1.76 -> +3.08); only shapes improves. That is what the arm-C partition predicts -- the dominant rows are footprint-coupled, and a cheaper probe cannot recover a cost that is not probing. New: #8094 landed after this branch's original base and read both deleted words in param_type_guard::plain_object, so the rebase converts two free u32 loads into two descriptor probes on a path that is the #2 self-time symbol on interp. interp/iso_miss/pipeline go from ~+0.3% to +3.35/+2.86/+4.33%. asyncpipe's +2.90% peak RSS is arena block quantization, not a footprint regression: it is exactly one 1 MB block, it flips sign with the nursery cap, and the shrunk arm holds strictly less live data. The proxy.rs:1523 attribution carried by the previous revision is withdrawn. Claude-Session: https://claude.ai/code/session_01AHvBYz7E6wWKv8kmvLLGpj --- .../8122-object-header-shrink-56-to-48.md | 138 +++++++++++++----- 1 file changed, 101 insertions(+), 37 deletions(-) diff --git a/changelog.d/8122-object-header-shrink-56-to-48.md b/changelog.d/8122-object-header-shrink-56-to-48.md index f30ac23786..5530b2d1e1 100644 --- a/changelog.d/8122-object-header-shrink-56-to-48.md +++ b/changelog.d/8122-object-header-shrink-56-to-48.md @@ -117,46 +117,110 @@ cover the new rules. #### Measured -19-program corpus, both arms built from one worktree with `-p perry --p perry-runtime-static -p perry-stdlib-static`, `PERRY_RUNTIME_DIR` pinned per -arm, the two `libperry_runtime.a` files `cmp`-verified to differ, all 19 stdout -byte-compared against `expected/` and exit-checked in both arms: +19-program corpus, quiet M1 mini, **best-of-5**, `instructions retired` and +`peak memory footprint` reported together. Both arms built from one worktree +with `-p perry -p perry-runtime-static -p perry-stdlib-static`, per-arm +`PERRY_RUNTIME_DIR` **and** `PERRY_CACHE_DIR`, `PERRY_NO_AUTO_OPTIMIZE=1`; all +three archives `cmp`-verified to differ, all 19 compiled corpus binaries +`cmp`-verified to differ (no row measures nothing), all 19 stdout byte-equal +between arms with `rc=0` on every run. + +Base `3be2016c1` — i.e. **after** #8157 (PtrHashMap shape probes) and #8110 +(census gate). | prog | Δ instructions | Δ peak RSS | |---|---:|---:| -| `retain` | +3.26% | **−9.10%** | -| `retain1` | +7.99% | −5.29% | -| `retain_wide` | +2.89% | −5.45% | -| `retain_wide1` | +2.61% | −6.04% | -| `tree` | +0.54% | **−12.79%** | -| `tree_wide` | +0.44% | −6.30% | -| `deeplist` | +8.20% | −4.19% | -| `shapes` | +4.96% | −0.61% | -| `churn_alloc` / `push_cls` | +4.3% | ~0 | -| `fib40` / `push_num` / `churn_read` | ~0 | ~0 | - -The rows with no object population move by ~0 — that is the control. The -instruction cost is the price of the change: the bound is a shape-table probe -where it used to be a `u32` load. - -**Where the residual actually is.** A per-callsite counter (`#[track_caller]` + -`libc::atexit`, on `tls_hot.rs::maybe_install_stats_hook`'s pattern) over every -shape-table entry point found it is **one site**: `proxy.rs`'s #6595 store-plan -gate, which this rung changed from `object_type == OBJECT_TYPE_REGULAR` (a free -`u32` compare) to `object_is_regular` — a `GcHeader` re-derivation plus a -shape-table probe, firing **exactly once per allocated object** (3,000,000 on -`retain`, 20,000,002 on `churn`, and still 1.00 per object on `retain_wide`'s -8-field literals, which is the per-object/flat-in-width signature the corpus -showed). Reducing it means weakening a predicate #6595 constrains, so it is -tracked separately with the counts attached; the obvious "free" repair was tried -here and reverted (see below). - -The same counter established something that matters more for anyone optimising -this later: **`object_live_slot_count` — the bound derivation this rung -introduces — is called ZERO times on every hot row.** Not once, across all nine -programs measured. Two separate memo attempts in front of it measured null -because they were caching a function that never runs on the measured path. Check -the call count before reaching for a memo there. +| `deeplist` | **+9.03%** | −4.06% | +| `retain1` | +8.24% | −3.89% | +| `churn_alloc` | +5.34% | −0.08% | +| `push_cls` | +5.29% | +0.23% | +| `pipeline` | +4.33% | +0.00% | +| `interp` | +3.35% | +0.40% | +| `retain` | +3.34% | **−9.30%** | +| `retain_wide` | +3.30% | −5.46% | +| `shapes` | +3.29% | −0.17% | +| `churn` | +3.08% | +0.08% | +| `retain_wide1` | +2.97% | −6.06% | +| `iso_miss` | +2.86% | −0.06% | +| `cycles` | +0.81% | −0.08% | +| `tree` | +0.52% | **−12.81%** | +| `tree_wide` | +0.46% | −6.35% | +| `asyncpipe` | +0.45% | +2.90% | +| `push_num` | +0.02% | −0.15% | +| `churn_read` | +0.01% | −0.57% | +| `fib40` | +0.00% | +0.00% | + +**0 of 19 rows are faster; 12 of 19 pay more than 1%.** The RSS win is intact +and lands where predicted (`tree` −12.81%, `retain` −9.30%, the object-literal +retain family −3.9…−6.1%), and the rows with no object population (`fib40`, +`push_num`, `churn_read`) move by ~0 — that is the control. + +**`asyncpipe`'s +2.90% peak RSS is not a footprint regression.** It is exactly +1024 KB — one arena block — and it is *arena block quantization*: sweeping +`PERRY_GC_SCAVENGE_NURSERY_MB` moves it and **flips its sign** (−288 KB at cap 4, ++80 KB at 8, +944 KB at 12, +992 KB at the default 16, +624 KB at 24/32). Under +`PERRY_GC_DIAG=1` the two arms run the same single copying minor with the same +6767 copied objects, and the shrunk arm holds strictly *less* live data +(`copied_bytes` 449,480 vs 452,896; `post_in_use` 450,160 vs 453,576). With 48 B +objects the allocation stream lands differently against the 1 MB block +granularity, so the peak straddles one extra block at some caps and one fewer at +others. + +#### Where the residual is — the earlier attribution is RETRACTED + +An earlier revision of this fragment named `proxy.rs`'s #6595 store-plan gate as +the site. **That is wrong and is withdrawn.** Direct instrumentation at the gate +counts `total=1` on `interp`, `2` on `shapes`, `1` on `iso_miss`, and the counter +never arms at all on `retain`/`retain_wide`/`retain1`/`deeplist`/`churn`/ +`pipeline`/`push_cls`/`tree`/`churn_read`. The counter had been reading a +`#[inline]`, non-`#[track_caller]` frame and swallowing its callers. With +`#[track_caller]` on `object_is_regular` itself the true caller is +`array/element_shape.rs:258` — the element-shape check on array push — which is +**pre-existing on main**, byte-identical between arms. `object_live_slot_count`, +the derivation this rung actually introduces, is called **zero** times on every +hot row. + +Two components, separated by an arm-C probe (deletion *without* the shrink: the +same code with 8 B of inert padding, which validates as a control at ~0.00% RSS +on every row): + +* **Footprint-coupled** — `deeplist` +8.28 SIZE / −0.07 CODE, `retain1` +6.75 + SIZE. Smaller objects genuinely cost instructions here, opposite in sign to + #8047's pad probe on a neighbouring benchmark. The cache-line count is not the + mechanism; it is unexplained. +* **Code** — +1.0…+2.5% on every allocation-heavy row. + +#### What #8157 did and did not recover + +#8122 was held on #8125 in the expectation that #8157 — which made every ShapeId +probe 15–25% cheaper and is worth `deeplist` −17.2% / `churn` −25.2% **on main +alone** — would absorb this rung's cost, since that cost is extra descriptor +probing. **Re-measured on post-#8157 main, it does not.** The regression is +slightly *worse* than the pre-#8157 table on most rows (`deeplist` +8.20 → +9.03, +`retain1` +7.99 → +8.24, `churn` +1.76 → +3.08); only `shapes` improves (+4.96 → ++3.29). This is consistent with the arm-C partition: the dominant rows are +footprint-coupled, and a cheaper probe cannot recover a cost that is not probing. + +**A new consumer arrived in the meantime.** #8094 (guarded ordinary-parameter +specialization) landed 2026-08-15 18:00, *after* this branch's original base +(`83b6b8c69`, 02:35), and `param_type_guard::GuardState::plain_object` read both +deleted words directly. The rebase necessarily converts those two free `u32` +loads into `object_is_regular` + `object_live_slot_count`. `js_param_type_guard` +is the **#2 self-time symbol on `interp` in both arms**, and the three rows that +newly regressed are exactly the app-shaped ones: `interp` +0.29 → **+3.35**, +`iso_miss` +0.30 → **+2.86**, `pipeline` +0.34 → **+4.33**. A differential symbol +profile on `interp` (`PERRY_DEBUG_SYMBOLS=1`, three repeats per arm) shows the +shift is in the guard's *callees*, not its own body: + +| self-time samples, `interp` | arm A (base) | arm B (shrunk) | +|---|---:|---:| +| `shapes::shape_descriptor_by_id` | 19 / 23 / 17 | **46 / 38 / 28** | +| `gc::layout::init_typed_shape_layout` | absent | **27 / 18 / 20** | +| `js_param_type_guard` (own body) | 110 / 96 / 101 | 98 / 86 / 96 | + +Direction stable across all three pairs. Caveat: this row family has a documented +sensitivity to codegen/inlining perturbation (finding 3 below), so "probe cost" +versus "inlining perturbation" is supported but not fully separated. Three findings worth carrying forward, all from measuring rather than assuming: