Skip to content

feat(object/shape): make ShapeId authoritative for keys, live slots, and class-object kind before #8047 #8067

Description

@proggeramlug

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_field widens field_count before a pointer store so object::gc_field_slot_range traces 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, and object_ops/keys_array.rs;
  • object/delete_rest.rs::js_object_delete_field compacts and shrinks it;
  • gc/layout.rs::{gc_child_slots,init_typed_shape_layout,with_shape_shared_descriptor} and gc/heap_snapshot.rs consume 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.rs
    • emit_class_field_loop_preheader_check
    • emit_proven_shape_recheck
    • emit_class_field_inline_precheck
  • perry-codegen/src/expr/element_shape_guard.rs::emit_element_shape_field_load
  • perry-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_shape retains a class_id == 0 gate and returns keys_array for class instances;
  • typed_feedback/guards.rs::{method_direct_call_contract,class_field_get_contract,class_field_fast_contract,class_field_set_contract} compare shape_addr / (*obj).keys_array with expected_keys and bound slots with (*obj).field_count;
  • js_method_direct_shape_guard exports 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_class writes it;
  • is_class_object_ptr, field_set_by_name/write_helpers.rs, and field_get_set/get_field_by_name_tail.rs read 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' crates

Counts 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

  1. Make a ShapeId resolve 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.
  2. 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.
  3. Preserve realm/thread isolation: ids are process-global today, tables are per-thread, class keys also cross module and worker boundaries.
  4. Resolve ShapeId exhaustion without a removed-header pointer fallback.
  5. 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.
  6. Replace OBJECT_TYPE_CLASS and 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.
  7. 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_array or .field_count.
  • The class-expression typeof/new/instanceof behavior survives without ObjectHeader.object_type.
  • No emitted IR uses header offsets 0/12/16 for 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions