Repository navigation
feat(object/shape): make ShapeId authoritative for keys, live slots, and class-object kind before #8047 #8067
Description
Summary
A source/IR/FFI audit of main at 08173f8b4 found that #8047's prerequisite is not complete. Rungs 1–2 made ObjectHeader.parent_class_id a uniform, birth-stamped shape word, but the planned rung 3 never landed: keys_array and field_count are still authoritative mutable object state, several guards still compare the keys pointer directly, and object_type still carries the live OBJECT_TYPE_CLASS semantic.
This issue is the missing prerequisite. It deliberately does not remove fields or change ObjectHeader size; that atomic ABI/layout payoff stays in #8047 after this issue proves the replacement facts.
Concrete blockers on current main
1. ShapeId cannot recover the fields #8047 proposes to delete
crates/perry-runtime/src/object/shapes.rs still defines:
ShapeTable.entries: PtrHashMap<usize, Shape>keyed by the keys-array address;Shape { indexed_len, shape_id, slots }, with no authoritative rooted keys edge and no by-id lookup;shape_id_for_keys_ensure(keys, key_count),shape_keys_grown(old_keys, new_keys),shape_drop(keys), and the GC prune/rekey paths all address-keyed.
docs/shape-tree-plan.md says this explicitly: keys_array remains the ordered-key artifact and the transition cache remains separate. A header containing only { class_id, shape_id, meta } therefore has no way to enumerate keys or recover the live field/slot bound.
The ShapeId allocator also deliberately returns 0 on range exhaustion. Generated PICs then fall back to keys_array pointer tokens. Removing the pointer requires a sound exhausted-id behavior rather than silently deleting the fallback.
2. field_count is independently mutable, not keys_array.length
The publication/liveness invariant is load-bearing:
object/field_get_set/field_ops.rs::js_object_set_fieldwidensfield_countbefore a pointer store soobject::gc_field_slot_rangetraces and rewrites the new slot;- the same pre-store widening exists in
object/field_set_by_name.rs,field_set_by_name/{fast_paths,tail}.rs, andobject_ops/keys_array.rs; object/delete_rest.rs::js_object_delete_fieldcompacts and shrinks it;gc/layout.rs::{gc_child_slots,init_typed_shape_layout,with_shape_shared_descriptor}andgc/heap_snapshot.rsconsume it;- by-index stores can grow it with no keys write, while class instances may carry fields with a null or separately-built keys array.
So a future shape descriptor needs an authoritative live-slot/count protocol and publication ordering. Replacing the load with keys_array.length is not equivalent.
3. Rung-3 keys-pointer consumers are still live
Generated IR still bakes the old layout and pointer identity in these families:
perry-codegen/src/expr/class_field_inline_guard.rsemit_class_field_loop_preheader_checkemit_proven_shape_recheckemit_class_field_inline_precheck
perry-codegen/src/expr/element_shape_guard.rs::emit_element_shape_field_loadperry-codegen/src/expr/property_get/generic_dispatch.rs::lower_generic_property_get- both static/dynamic write PIC emitters in
perry-codegen/src/expr/proxy_reflect.rs - packed inline allocation in
perry-codegen/src/lower_call/new_alloc.rs perry-codegen/src/target_layout.rs::object_header_size_bytes
The hard-coded current user-header offsets are object_type @0, class_id @4, shape word @8, field_count @12, keys_array @16, meta @24, fields @32 on LP64. The inline allocator additionally writes them at raw allocation offsets +8/+16/+24 after the 8-byte GcHeader.
Runtime typed-feedback still deliberately preserves the pointer contract:
typed_feedback.rs::object_shaperetains aclass_id == 0gate and returnskeys_arrayfor class instances;typed_feedback/guards.rs::{method_direct_call_contract,class_field_get_contract,class_field_fast_contract,class_field_set_contract}compareshape_addr/(*obj).keys_arraywithexpected_keysand bound slots with(*obj).field_count;js_method_direct_shape_guardexports the live keys pointer.
The retained comments in typed_feedback.rs and changelog.d/7983-6759-rung1-uniform-shape-word.md name this as the unlanded “rung 3”. The old handoff's discriminating sabotage fixture is the wide non-numeric class case: delete the first field, then read/write a middle field whose old index remains below the post-delete count.
4. object_type is not currently a derivable constant
Ordinary objects use OBJECT_TYPE_REGULAR, but class-expression values are the same ObjectHeader layout with a mutable OBJECT_TYPE_CLASS marker:
object/class_registry/parent_static.rs::js_object_mark_classwrites it;is_class_object_ptr,field_set_by_name/write_helpers.rs, andfield_get_set/get_field_by_name_tail.rsread it;native_abi.rs,promise/then_probe.rs,typed_feedback/guards.rs, object get/set tails, and generated PIC guards use the regular-object check.
GcHeader.obj_type alone is also insufficient today because RegExpHeader is deliberately allocated as GC_TYPE_OBJECT; regex_header_has_magic is the extra discriminator in the GC/object paths. #8047 needs a proven replacement for the class-object marker and ordinary-vs-RegExp classification before removing the word.
Exact census snapshot
Reproducible textual census at 08173f8b4:
# Complete textual superset (declarations, initializers, comments, tests, and accesses)
rg -n '\.(object_type|field_count|keys_array)\b|\b(object_type|field_count|keys_array)\s*:' crates
# Direct raw-pointer member accesses
for f in object_type field_count keys_array; do
rg -n --glob '*.rs' '\(\*[^)]*\)\.'"$f"'\b' crates
done
# Derived layout/offset surfaces
rg -n 'size_of::<.*ObjectHeader|object_header_size_bytes|offset_of!\([^,]*ObjectHeader' cratesCounts from those exact checks:
| field | textual superset | direct raw-pointer member sites |
|---|---|---|
object_type |
66 | 43 |
field_count |
170 | 104 |
keys_array |
201 | 151 |
The 298 raw member sites span 77 files. Separately, codegen has 34 object_header_size_bytes references; runtime/external crates have 67 size_of::<...ObjectHeader> references; perry-ffi/src/types.rs::ObjectHeader mirrors the public ABI and its runtime-link test asserts every current offset. Direct out-of-runtime layout readers include perry-ui-android/src/json.rs, perry-ext-ws/src/lib.rs, and perry-stdlib/src/worker_threads.rs.
The semantic migration should install an exact checked-in census/ratchet, because a broad textual count is a safe superset but cannot distinguish another struct's field_count from an ObjectHeader read.
Required design/implementation
- Make a
ShapeIdresolve to an authoritative descriptor containing at least the rooted/moving ordered keys artifact and the logical/live slot facts needed by enumeration, bounds, GC tracing, and typed-layout lookup. - Define add/delete/defineProperty/descriptor/prototype transitions and the by-index publication protocol. A shared descriptor must never be mutated to describe only one sibling.
- Preserve realm/thread isolation: ids are process-global today, tables are per-thread, class keys also cross module and worker boundaries.
- Resolve ShapeId exhaustion without a removed-header pointer fallback.
- Migrate every runtime/codegen guard above from keys identity/count loads to exact ShapeId/descriptor facts, including the entry-hoisted key roots and their ratchet tests.
- Replace
OBJECT_TYPE_CLASSand the regular/RegExp discriminator with a proven source outside the three words perf(object/GC): finish the common-object header shrink from 56 B to 40 B after shape-transition migration #8047 will delete. - Add adversarial delete/defineProperty/prototype/cross-module/realm tests and a checked-in source/IR census that rejects reintroduced old-offset or pointer-identity consumers.
Acceptance
- A newborn instance and every runtime allocator has a usable authoritative ShapeId before publication, or takes an explicitly tested fail-closed path.
- Adding, deleting, compacting, defining a descriptor, mutating a prototype, crossing a module boundary, and worker/realm transfer cannot make two different layouts compare equal.
- GC mark/rewrite/evacuation can recover and rewrite keys plus the exact live field range without
ObjectHeader.keys_arrayor.field_count. - The class-expression
typeof/new/instanceofbehavior survives withoutObjectHeader.object_type. - No emitted IR uses header offsets
0/12/16for the removed facts and no guard token is a keys pointer. - This issue lands without shrinking
ObjectHeader; perf(object/GC): finish the common-object header shrink from 56 B to 40 B after shape-transition migration #8047 then performs the runtime/codegen/FFI layout change atomically.
Refs #6759, #6798, #7916, #7981, #7983, #8009, #8010, #8047.
Activity
proggeramlug commentedon Aug 14, 2026
Census scope clarification: the 66 / 170 / 201 textual-superset counts include
crates scripts tests test-files, matching the audit command used. Restricting that superset to Rust files undercratesgives 64 / 169 / 197. The direct raw-pointer counts (43 / 104 / 151, 298 sites across 77 files) are Rust files undercratesas stated.ci(object): make the ObjectHeader shape-descriptor census a real gate (…
test: split the PerryTS#8067 shape-authority tests out of parent_stat…
perf(runtime): a Bloom filter for the heap-allocated registries, and #…