Skip to content

chore: merge train 247 (v0.5.1626) - #10853

Merged
proggeramlug merged 7 commits into
mainfrom
train247r
Sep 21, 2026
Merged

proggeramlug merged 7 commits into
mainfrom
train247r

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Merge train 247 — the object-model invariants the single-compare property read depends on, plus the two gate fixes needed to land them.

Contents

source Change
#10824 object model: enforce the three shape rules the single read path needs
#10828 object model: close rule 3 — no non-object cell can hold a live ShapeId at payload +4
— refactor: split descriptor-owner lifecycle out of descriptor_state.rs (2000-line cap)
— refactor: root the rule-3 array-growth probe through with_mut_ptr

#10828 is stacked on #10824 — its first two commits are that PR. Carrying both is the dependency, not a choice.

The property being established

For any pointer-tagged value that passes the tag test, the u32 at payload +4 equals a live object ShapeId only if the cell is a GC_TYPE_OBJECT carrying that shape.

A read site that compares *(receiver + 4) against its cached ShapeId and matches has then already proved the receiver is a shaped ordinary object — so the GC_TYPE_OBJECT load comes off the emitted read path. Three invariants have to hold for that. Two did not.

Rule 1 — every descriptor change changes an ordinary object's ShapeId. clear_object_descriptors made no shape call at all, so a cache primed while the object was frozen kept serving the frozen answer. Seven raw remove() calls on the descriptor tables lived outside descriptor_state.rs and skipped both the transition and the owner index, so accessor_descriptor_keys_for_obj kept reporting deleted accessors.

Rule 2 — nothing but the shape allocator mints into the ShapeId range. SYNTHETIC_CLASS_ID_BASE was 0x8000_0000 — the ShapeId floor — so synthetic class id #1 is shape #1. class X extends someFunction wrote that id into +4, where is_shape_id accepted it and shape_record_by_id answered with an unrelated live shape's ordered keys. Synthetic ids move to [0xC000_0000, 0xFFFF_0000), minted through one bounded allocator, with a const _: () = assert!(…) making the disjointness a compile error to undo.

The existing pin armed_ids_cannot_alias_a_class_id asserted this for hand-written constants only, never for the live allocator — so it could not have failed on this.

Rule 3 — no pointer-tagged non-object cell holds a ShapeId-range value at +4. new Int8Array(2**31) wrote capacity = 0x8000_0000, the first ShapeId the process ever mints. And a Promise's +4 is struct padding — neither gc_malloc nor the arena zeroes reused memory and Rust does not guarantee writing implicit padding, so a Promise born at a dead shaped object's address inherited its live ShapeId.

#10824 left six kinds open and pinned them in a table; #10828 closes all six, in release, at each kind's allocation and growth funnel. Rule3Word::RangeReachable is kept but never constructed, and the test that pinned the fence-keeping set now asserts it is empty — so the test fails the moment a new kind needs it again.

Release enforcement, because a debug_assert! is free in release

#10828 is also the answer to a review comment on #10824 asking for "a release-enforced bound or representation" before considering fence removal. That was right, and it is the repo's own recurring lesson: a guard evidenced only by a debug assertion is unenforced in what ships. Five of the six kinds are counts, and a count has a bound; each is now rejected before any memory is reserved, with the error that kind already raises one byte further out.

A behaviour change is taken deliberately and recorded rather than hidden: new Int8Array(n) / Int16Array / … for n in [2**31, 2**32) now throws RangeError: Array buffer allocation failed instead of attempting a ≥2 GiB allocation. That matches Uint8Array, ArrayBuffer and Buffer.alloc, which already stop at i32::MAX. The alternative is a capacity word the read path cannot distinguish from a live shape.

The two gate fixes, and why neither is a ratchet bump

descriptor_state.rs was 2042 lines against the 2000 cap. Split at "what happens to a descriptor entry when its owner address changes or dies" — the prune pair, remove_descriptor_owner_entries, clear_object_descriptors, transfer_descriptor_owner, rewrite_descriptor_owner — into object/descriptor_state/owner_lifecycle.rs, following the existing gc_scan.rs / young.rs idiom. 264 lines moved, not the minimum 42: a 42-line shave leaves the file at exactly 2000 with zero headroom, which is a cap failure deferred by one commit. The boundary was also chosen to leave the file's tracked handle-floor and gcheader-cast sites in place, so no allowlist entry was needed.

shape_rule3.rs had one raw-handle site in a module with no ceiling. Converted through with_mut_ptr, not ceiling'd — and a ceiling would not have worked anyway: the total was already 907 against a baseline of 906, so the total rule fires the moment the per-module rule stops. Now 906, unchanged.

Attribution trailers stripped, trees asserted identical

Two source commits carried session-attribution trailers, which the house rule forbids. Stripped with a message filter that is self-tested in both directions before use (it removes the trailer; it does not eat ordinary body text), and the rewrite was then proved message-only: head tree 46525ece96 before and after, byte-identical.

Validation

Rebased onto v0.5.1625; seven gates green on the rebased, filtered tree — check_file_size, raw_handle_debt, gc_runtime_root_holders, addr_class_inventory, shape_descriptor_census, native_result_ledger, and cargo fmt --all -- --check. Scanner outputs were diffed against a pre-edit baseline and are identical except addr_class's file count 1548 → 1549, which is the new file.

The first cycle aborted at lint with two real failures, both fallout from the split rather than the PRs' logic, and both fixed in this train:

  • string_payload_access_inventory 350 → 351 — shape_rule3.rs's thrown_message test helper open-coded the StringHeader payload offset. Routed through the runtime's own crate::string::string_data. Confirmed the +1 was this train's: origin/main is green on that gate.
  • gc_rekeyed_key_tables reported rewrite_descriptor_owner as both an unclassified site at its new path and a stale entry at the old one — the gate refusing to let a fix leave its own exemption behind. Re-pointed rather than re-classified: the moved body is byte-identical apart from pub(super), checked with a diff, so the custody argument carries over unchanged. Its why line reference moved with it.

Neither gate is in the driver's ten cheap preflight gates; lint runs 83. That gap is now closed by a preflight derived from run_lint_gates.sh --list, which was proved to discriminate — run against this train before the two fixes it reports exactly those two gates, and after it reports only the known-red public baseline.

Second cycle green throughout: lint 6-of-6 with lint_unexpected_failures=[], 6 unit suites with an empty failing set, both compiler-output suites at failed_workloads=[] with no transient excused, repsel_census rc=0 wasted_promotion=False, security_audit showing only the tracked RUSTSEC-2026-0285 (#10791), and all five pinned artifacts byte-identical before and after the gap sweep.

Gap sweep at PERRY_RUN_TIMEOUT=30 over the descriptor/shape surface these rules govern, plus class and object as broad controls. 185 fixtures, every area asserted live, zero unexplained regressions:

class 84   object 40   shape 23   typed_ 12   define 10   accessor 9   descriptor 7

One area from the first attempt is worth recording: freeze selected zero fixtures. The driver asserts area liveness only after the build, so that would have been a vacuous green discovered ~35 minutes in; the seconds-long preflight caught it and it was replaced with define, which is the surface rule 1 is actually about — #10824's seven raw remove() calls were in the defineProperty paths.

perry-bot and others added 7 commits September 21, 2026 02:58
Lane 1 of the single-fast-path property-access work. The emitted read path
wants to prove everything it needs from ONE compare of the ShapeId word at
receiver+4. Three invariants have to hold for that, and two of them did not.

RULE 1 - every descriptor change changes an ordinary object's ShapeId.

* `clear_object_descriptors` made no shape call at all. It is a BULK removal
  (every key goes back to writable/enumerable/configurable), so a cache
  primed while the object was frozen kept serving the frozen answer. It now
  transitions, and removes through the owner index instead of two O(table)
  `retain` scans - which also retires the `HANDLE_HAS_DESCRIPTORS` latch that
  existed only to skip those scans, and with it the silent no-op the function
  performed for a heap owner while no handle had ever taken a descriptor.
* Seven raw `remove()` calls on the descriptor tables lived OUTSIDE
  descriptor_state.rs (array index redefine x2, defineProperty x3, handle
  expando x2). They skipped the shape transition and left the owner index -
  the authoritative answer to "which keys does this owner have" - naming keys
  the tables no longer held, so `accessor_descriptor_keys_for_obj` kept
  reporting deleted accessors. All seven now go through
  `clear_accessor_descriptor` / `clear_property_attrs`.
* `transfer_descriptor_owner` is correctly shape-free: it re-keys ONE object
  whose allocation moved (array growth), and a transition there would retire
  caches for a shape the object still has. Documented, and made safe for any
  caller: if the replacement cell does not already carry the source's
  descriptor flag, the new owner is routed through the funnel rather than
  silently acquiring descriptors behind an unchanged header.
* The funnel's scope is now stated where it is implemented: typed arrays
  return early, and every non-GC_TYPE_OBJECT kind fails the obj_type test, so
  for those receivers the descriptor flag is never set AND the shape is never
  transitioned.

RULE 2 - ShapeIds are never reused, and nothing else mints into their range.
The design note listed this as holding, with "class ids stay below
0x8000_0000 - TO CONFIRM". They do not: `SYNTHETIC_CLASS_ID_BASE` was
0x8000_0000, the ShapeId floor, and both counters started there - so
synthetic class id #1 IS shape #1. A `class X extends someFunction` resolves
its parent through `dynamic_value_class_id` -> `function_class_id` to that
synthetic id, and `js_object_alloc_class_dynamic_parent` writes it into +4,
where `is_shape_id` accepts it and `shape_record_by_id` answers with an
unrelated live shape's ordered keys and live-inline-slot bound. Synthetic
ids move to [0xC000_0000, 0xFFFF_0000) - a band no other producer uses - and
are minted through one bounded allocator that parks at the end instead of
wrapping. A `const _: () = assert!(...)` makes the disjointness a compile
error to undo.

RULE 3 - no pointer-tagged non-object cell holds a ShapeId-range value at +4.
* `new Int8Array(2**31)` wrote `capacity = 0x8000_0000` - the first ShapeId
  the process ever mints. The element cap moves from u32::MAX to i32::MAX,
  matching `new Uint8Array(n)`, `new ArrayBuffer(n)` and `Buffer.alloc(n)`,
  which already stop there.
* A Promise's +4 is struct padding, and neither `gc_malloc` nor the arena
  zeroes reused memory: a Promise born at a dead shaped object's address
  inherited its LIVE ShapeId. The padding is now a named zeroed field.
* Debug assertions at the capacity writes for Array (alloc + grow), Map, Set,
  Buffer, TypedArray and NativeTypedView, and a table in the new
  `object/shape_rule3.rs` classifying all 21 GC kinds, walked by a test that
  fails when a new kind arrives unclassified.

Counters: the property-get IC already counts misses by 17 causes under
PERRY_IC_DIAG, but nothing counted hits (they never reach the runtime).
`ic_note_hit` / `js_ic_diag_note_hit` add the hit side behind the same
arming, so a lane removing a guard can tell "reads now hit" from "reads
still fall to the miss handler and are merely correct".

Tests: crates/perry-runtime/src/object/shape_rules_tests.rs (rules 1-2, one
per mutator) and the tests module of object/shape_rule3.rs (rule 3).
`re_registering_the_same_edge_flushes_nothing` snapshots the PROCESS-global
prop-plan epoch, runs eight no-op re-registrations, and asserts the epoch did
not move. Any other libtest thread installing a descriptor or registering a
class inside that window moves it, so the test failed for reasons unrelated
to re-registration (observed: off by exactly one, and it passes under
`--test-threads=1`). It now takes `global_side_table_test_lock`, which every
descriptor-mutating test already holds.
Second lane-1 change, stacked on #10824. That PR left rule 3 -- "no
pointer-tagged non-object cell holds a ShapeId-range value at payload +4" --
open for six kinds, and its own table pinned them: ArrayHeader, BufferHeader,
DateCell, MapHeader, NativeTypedViewHeader, SetHeader. While any of them can
produce a +4 word inside [0x8000_0000, 0xC000_0000), a successful shape
compare does not prove the receiver is an ordinary object, so the emitted read
path has to keep loading the GC header first. This closes all six.

Five are COUNTS, and a count has a bound. Each is now rejected in RELEASE at
its allocation and growth funnel, before any memory is reserved, with the
error that kind already raises one byte further out:

* ArrayHeader::capacity -- `array_capacity_or_throw` at all seven alloc
  funnels, at `js_array_grow`, and at `pairs_alloc`; `RangeError: Invalid
  array length`. The bound also removes the `old_capacity * 2` u32 overflow
  the grow path carried, and the `as u32` truncation in `pairs_alloc`.
* MapHeader / SetHeader::capacity -- `js_map_alloc` / `js_set_alloc` and both
  `ensure_capacity` doublings; `RangeError: Map/Set maximum size exceeded`.
* BufferHeader::capacity -- `buffer_alloc` and `alloc_shared_sab` throw
  `RangeError: Array buffer allocation failed`. The two wrappers over foreign
  memory (`buffer_alloc_foreign`, `rebind_foreign_buffer`) are reached from
  `extern "C"` Node-API entry points where a throw has nowhere to land, so
  they clamp -- the policy `instance_memory_span` already applied to a wasm
  memory wider than an i32 byte count -- and every caller of them now refuses
  an over-range length itself (napi `checked_length`, `bun_ffi::memory`,
  `webassembly`).
* TypedArrayHeader::capacity -- the same bound at `typed_array_alloc`, which
  the internal callers reach without passing the constructor's check.
* NativeTypedViewHeader::capacity -- `js_native_arena_view`, checked before
  the owner is resolved so the diagnosis names the length.

The sixth is DATA, and no bound applies to it. `DateCell` held `ts: f64` at
+0, so `new Date(-1)` -- 0xBFF0_0000_0000_0000 -- put 0xBFF0_0000 at +4, a
live-looking ShapeId. The cell is relaid out instead: `meta` first, the
timestamp at +8. Size and alignment are unchanged, +4 is now the high half of
a heap pointer (zero while `meta` is null), and nothing outside date.rs reads
the cell by offset -- `object::cell_meta_slot` and `thread.rs` use the named
fields and perry-codegen has no DateCell offsets at all, only `js_date_*`
calls. `const _: () = assert!(offset_of!(DateCell, ts) == 8)` pins it.

What lane 4 may now assume: for any pointer-tagged value that passes the tag
test, the u32 at payload +4 equals a live object ShapeId only if the cell is a
GC_TYPE_OBJECT carrying that shape. The GC-kind load is removable from the
emitted read path.

User-visible maxima lowered (none reachable by a program that was working):
dense array capacity, Map entries and Set elements stop at 2^31-1 (16/32/16
GiB of live storage); `Buffer.from(arrayLike)`, `Buffer.concat` and
`buffer::copy_bytes` stop at 2 GiB-1 instead of clamping at 4 GiB-1, matching
`Buffer.alloc` / `new ArrayBuffer` / the typed arrays, which already did;
`bun:ffi` `toArrayBuffer`/`viewSource` and napi external buffers stop there
too; a wasm memory's JS-visible `.buffer` exposes at most 2 GiB-1, which
`instance_memory_span` already did for the instance path.

Tests: `object/shape_rule3.rs` -- the pinned set of fence-keeping kinds is now
empty, and eight new tests drive the real funnels at exactly SHAPE_ID_BASE and
assert the rejecting message, so "refused by rule 3" is distinguishable from
"the allocator refused". The DateCell test first asserts that the high word of
-1.0 really is inside the ShapeId range, so it cannot quietly stop testing
anything, then walks both ends of the JS time-value range plus NaN and checks
the value round-trips. Verified falsifiable: reverting the DateCell layout,
dropping the native-arena cap, or changing the array message fails exactly the
tests that cover them.

4129 perry-runtime tests pass single-threaded; a Date/Map/Set/Buffer/typed-array
program compiled by perry is byte-identical to node, including sparse
`new Array(2**31)` and `length = 2**32-1`.
…_state.rs

`crates/perry-runtime/src/object/descriptor_state.rs` reached 2042 lines and
failed `scripts/check_file_size.sh` (cap 2000).

Split boundary: everything that keys off an owner ADDRESS changing or going
away moves to the new sibling `descriptor_state/owner_lifecycle.rs` --
`prune_dead_descriptor_owner_entries`, its minor-scoped twin, the shared
`remove_descriptor_owner_entries` helper, `clear_object_descriptors`,
`transfer_descriptor_owner` and the metadata-rewrite-phase translation
`rewrite_descriptor_owner`. The install/lookup side of the same tables stays
in the parent. That is a real seam rather than a line count: the five moved
functions are the only ones in the file whose subject is the owner key itself,
they already call one another, and `gc_scan.rs` -- the existing sibling -- is
their only outside caller for `rewrite_descriptor_owner`.

Follows the `gc_scan.rs` / `young.rs` idiom already in that directory: private
`mod` plus explicit named `pub(crate) use` re-exports, so `crate::object::`'s
own re-export list in `object/mod.rs` is unchanged. `gc_scan.rs` imports
`rewrite_descriptor_owner` by name instead of relying on the parent glob, which
would not have carried it.

No function body changed. The only semantic edit is `rewrite_descriptor_owner`
going from private to `pub(super)` so the sibling scanner can still reach it.
…t_ptr

`scripts/raw_handle_debt.py` failed with
`object/shape_rule3.rs: 1 raw-handle debt site(s) in a module with no ceiling`.

The site is `rule3_array_growth_to_the_shape_id_floor_is_refused`, which rooted
the array and then read the address back out with a bare `get_raw_mut_ptr` to
hand to `js_array_grow`. That is the argument-position shape `with_mut_ptr`
exists for -- a scoped pointer passed to a runtime entry point -- so it is
converted rather than granted a ceiling. `across_*` would not fit: the call
consumes the pointer, it does not need one re-read after the call.

A ceiling would also not have been sufficient. The recorded total was already
907 against a baseline of 906, so the total rule would have fired the moment
the per-module rule stopped. Converting the site puts both back at 906.
…descriptor_state split

Both are fallout from the 2000-line-cap split and the new shape_rule3.rs, not
from the PRs' logic.

string_payload_access_inventory went 350 -> 351: shape_rule3.rs's thrown_message
test helper open-coded the StringHeader payload offset. Routed through
crate::string::string_data instead. origin/main is green on the gate, so the +1
was this train's.

gc_rekeyed_key_tables reported rewrite_descriptor_owner as both an unclassified
site at its new path and a stale entry at the old one. Re-pointed, not
re-classified: the moved body is byte-identical apart from pub(super). The why's
line reference was updated with it.
@coderabbitai

coderabbitai Bot commented Sep 21, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 48b2de62-2c52-429e-8416-ee581dd6052b

📥 Commits

Reviewing files that changed from the base of the PR and between 7bcb5a3 and a49bb2a.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (41)
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/10828-descriptor-state-owner-lifecycle-split.md
  • changelog.d/10828-shape-rule3-raw-handle.md
  • changelog.d/10828-string-payload-and-rekey-manifest.md
  • crates/perry-runtime/src/array/alloc.rs
  • crates/perry-runtime/src/array/named_props.rs
  • crates/perry-runtime/src/array/push_pop.rs
  • crates/perry-runtime/src/buffer/header.rs
  • crates/perry-runtime/src/bun_ffi/memory.rs
  • crates/perry-runtime/src/date.rs
  • crates/perry-runtime/src/gc/tests/alloc.rs
  • crates/perry-runtime/src/gc/tests/support.rs
  • crates/perry-runtime/src/hot_diag.rs
  • crates/perry-runtime/src/json/construction_array.rs
  • crates/perry-runtime/src/map.rs
  • crates/perry-runtime/src/native_arena.rs
  • crates/perry-runtime/src/node_api_host/buffers.rs
  • crates/perry-runtime/src/object/array_object_ops.rs
  • crates/perry-runtime/src/object/class_meta_registry.rs
  • crates/perry-runtime/src/object/class_registry.rs
  • crates/perry-runtime/src/object/class_registry/gc_roots.rs
  • crates/perry-runtime/src/object/class_registry/prototype_methods.rs
  • crates/perry-runtime/src/object/class_registry/prototype_objects.rs
  • crates/perry-runtime/src/object/descriptor_state.rs
  • crates/perry-runtime/src/object/descriptor_state/gc_scan.rs
  • crates/perry-runtime/src/object/descriptor_state/owner_lifecycle.rs
  • crates/perry-runtime/src/object/handle_expando.rs
  • crates/perry-runtime/src/object/mod.rs
  • crates/perry-runtime/src/object/native_get.rs
  • crates/perry-runtime/src/object/object_ops/define_property.rs
  • crates/perry-runtime/src/object/object_ops/prototype.rs
  • crates/perry-runtime/src/object/shape_rule3.rs
  • crates/perry-runtime/src/object/shape_rules_tests.rs
  • crates/perry-runtime/src/promise/mod.rs
  • crates/perry-runtime/src/set.rs
  • crates/perry-runtime/src/shared_sab.rs
  • crates/perry-runtime/src/typedarray/construct.rs
  • crates/perry-runtime/src/typedarray/mod.rs
  • crates/perry-runtime/src/webassembly.rs
  • scripts/gc_rekeyed_key_tables.json
 ___________________________________________________________________________________________________________________
< Work with a user to think like a user. It's the best way to gain insight into how the system will really be used. >
 -------------------------------------------------------------------------------------------------------------------
  \
   \   (\__/)
       (•ㅅ•)
       /   づ
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants