-
-
Notifications
You must be signed in to change notification settings - Fork 159
fix(gc): retire the typed-layout intact claim when no descriptor backs it #8182
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| **GC: `GC_OBJ_TYPED_LAYOUT_INTACT` no longer outlives the descriptor it claims (#8115).** | ||
| The bit is documented to mean "a canonical `TypedLayoutDescriptor` is reachable | ||
| for this object", and four readers rely on it. #7834's at-allocation bake sets it | ||
| with no descriptor behind it, and `set_layout_state` masks | ||
| `!(GC_LAYOUT_STATE_MASK | GC_LAYOUT_ALL_POINTERS)` = `!0xE000` — it cannot clear a | ||
| `0x1000` bit — so `layout_note_slot`'s generic pointer-mask branch could publish | ||
| `GC_LAYOUT_SIDE_MASK | GC_OBJ_TYPED_LAYOUT_INTACT` **without** a descriptor. Three | ||
| codegen consumers read that state as a licence to skip a map: | ||
| `class_field_inline_guard` (which tests the bit and no layout state at all), | ||
| `element_shape_guard`'s packed `0x1800_80FF` header compare, and | ||
| `class_field_store_layout_note_is_conforming`, which elides the layout note | ||
| outright on `_reserved & 0xD000 == 0x9000`. | ||
|
|
||
| `layout_note_slot` now clears the bit at the fall-through past its descriptor | ||
| probe — the one point where the broken state is observable, reached only when | ||
| *both* maps answered `None`. A descriptor-backed object returns from the | ||
| `Some(verdict)` arm above it, so the legitimate `SIDE_MASK | INTACT` case is | ||
| untouched; pre-#7834 the descriptor existed and any contradicting store evicted | ||
| it, bit included, so this restores that outcome without paying for the descriptor. | ||
| Cost is one 16-bit store on a path a baked object only reaches for a store the | ||
| descriptor path would have downgraded on. | ||
|
|
||
| Acceptance: `perry-runtime/src/gc/tests/typed_layout_intact_residual.rs` (7 | ||
| tests). It reaches `SIDE_MASK` without a descriptor through the real | ||
| `layout_note_slot`, from the exact header the inline `new` bakes, and then | ||
| evaluates the codegen predicate over the header the runtime produced — asserting | ||
| the state transition alone passes on an unfixed tree. Premises come from the maps | ||
| (a new `#[cfg(test)] layout_descriptor_reachable`), never from the bit under test. | ||
| Sabotage-verified: with the clear disabled, exactly the two residual tests fail at | ||
| `_reserved = 0x9000` and the four controls still pass. | ||
|
|
||
| Two findings recorded rather than fixed. `mark_object_dynamic_shape_unknown` is | ||
| **not** inert on baked instances as #8115 assumed — `layout_has_typed_descriptor` | ||
| answers from the same stale bit, so the guard does not early-return and | ||
| `layout_mark_unknown` heals the object; the stale claim was its own antidote, and | ||
| a test now pins that coupling. And the lost-child use-after-free the old | ||
| `docs/engine-plan.md` ⛔ posited needs the note elision, whose precondition (the | ||
| class declares a pointer slot) is mutually exclusive with the bake's (the pointer | ||
| mask is statically empty), so the reachable consequence was a wrong-value raw-f64 | ||
| read, not a collector fault. | ||
|
|
||
| `docs/engine-plan.md` item 2 is rewritten: it forbade code that shipped four days | ||
| after it was written and is covered by an IR census with a negative control. It | ||
| now records that the bake shipped, what #8115 changed, and what the design still | ||
| rests on (`TypedShapeProof::FreshlyAllocated`). | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -65,7 +65,19 @@ pub(crate) const GC_LAYOUT_ALL_POINTERS: u16 = 0x2000; | |
| // either per-object in `TYPED_LAYOUTS` OR (the #6893 common | ||
| // case) shared by shape in `SHAPE_LAYOUTS`, keyed by the | ||
| // object's `keys_array` | ||
| // holds at all times. (Before #6893 the descriptor was always the per-object | ||
| // holds at all times. | ||
| // | ||
| // #7834 introduced ONE producer that sets the bit without installing a | ||
| // descriptor: the inline `new`'s baked header constant | ||
| // (`lower_call/new_alloc.rs`), for a class whose pointer mask is statically | ||
| // empty. That is sound at birth — the collector's view of a | ||
| // `GC_LAYOUT_POINTER_FREE` payload consults no map — but it made the invariant | ||
| // hold only until the first store the descriptor path would have downgraded on. | ||
| // #8115 closes that: `layout_note_slot` clears the bit the moment BOTH | ||
| // descriptor maps answer `None`, which is the one point where the broken state | ||
| // is observable. So the invariant above still holds *for every reader*, with | ||
| // the bake as a birth-time exception that self-heals on its first contradicting | ||
| // store. (Before #6893 the descriptor was always the per-object | ||
| // `TYPED_LAYOUTS` entry; `shape_install_shared` now sets the bit while routing | ||
| // same-shape objects through the shared map, so the bit no longer implies a | ||
| // per-object entry — only that *some* descriptor is reachable.) The descriptor's | ||
|
|
@@ -547,6 +559,27 @@ pub(crate) fn layout_has_typed_descriptor(user_ptr: usize) -> bool { | |
| layout_typed_intact_for_user(user_ptr) | ||
| } | ||
|
|
||
| /// #8115 test probe: would [`layout_note_slot`]'s descriptor probe find a | ||
| /// `TypedLayoutDescriptor` for `user_ptr` right now — asked of the two maps, | ||
| /// never of the header bit? | ||
| /// | ||
| /// [`layout_has_typed_descriptor`] above answers a similar question by reading | ||
| /// `GC_OBJ_TYPED_LAYOUT_INTACT`, which is the very claim #8115 is about. A test | ||
| /// that used it could not tell "the bit is honest" from "the bit lies", so the | ||
| /// premise of every intact-bit test has to come from here instead. | ||
| /// | ||
| /// Note this is the *shape*'s answer, not the object's licence: the shared | ||
| /// `SHAPE_LAYOUTS` entry outlives one object's divergence on purpose (see | ||
| /// `with_shape_shared_descriptor`), so after `layout_set_typed_unknown` this | ||
| /// still reports `true` while the diverged object no longer claims it. | ||
| #[cfg(test)] | ||
| pub(in crate::gc) fn layout_descriptor_reachable(user_ptr: usize) -> bool { | ||
| if with_per_object_descriptor(user_ptr, |_| ()).is_some() { | ||
| return true; | ||
| } | ||
| unsafe { with_shape_shared_descriptor(user_ptr, |_| ()).is_some() } | ||
| } | ||
|
|
||
| pub(super) unsafe fn layout_set_typed_unknown(header: *mut GcHeader, user_ptr: usize) { | ||
| set_layout_state(header, GC_LAYOUT_UNKNOWN); | ||
| header_clear_typed_layout_intact(header); | ||
|
|
@@ -629,9 +662,10 @@ pub(crate) fn layout_note_slot(parent_user: usize, slot_index: usize, value_bits | |
| // return `None` and fall through to the pointer-mask path below. | ||
| // Skipping it removes the per-write TLS touch on the common dynamic-shape | ||
| // / pointer-free object and array store path (#5094). The inner `if let` | ||
| // still tolerates a `None` defensively, so a transiently desynced bit | ||
| // can only cost an extra fall-through, never mis-track a slot. | ||
| if (*header)._reserved & GC_OBJ_TYPED_LAYOUT_INTACT != 0 { | ||
| // still tolerates a `None` defensively — see the #8115 clear below for | ||
| // what a `None` costs and why it is no longer merely a fall-through. | ||
| let claimed_intact = (*header)._reserved & GC_OBJ_TYPED_LAYOUT_INTACT != 0; | ||
| if claimed_intact { | ||
| // #5094: a plain, non-pointer-bearing double is representation- | ||
| // compatible with every in-bounds typed object slot. A raw-f64 | ||
| // slot consumes the bits directly; a boxed slot consumes the same | ||
|
|
@@ -707,6 +741,46 @@ pub(crate) fn layout_note_slot(parent_user: usize, slot_index: usize, value_bits | |
| return; | ||
| } | ||
| } | ||
| // #8115: reaching here with the bit still set means BOTH descriptor maps | ||
| // answered `None` — the probe above is exhaustive — so the object is | ||
| // INTACT and descriptor-less, and the invariant documented on | ||
| // [`GC_OBJ_TYPED_LAYOUT_INTACT`] ("intact ⟹ a canonical descriptor is | ||
| // reachable") is false for it. Restore the invariant here, at the one | ||
| // place that observes it broken. | ||
| // | ||
| // The state stores in the generic pointer-mask branch below CANNOT do | ||
| // it: `set_layout_state` masks `!(GC_LAYOUT_STATE_MASK | | ||
| // GC_LAYOUT_ALL_POINTERS)` = `!0xE000`, and this bit is `0x1000`. So | ||
| // before this clear the branch could publish `SIDE_MASK | INTACT` | ||
| // WITHOUT a descriptor — a state three separate consumers read as a | ||
| // proof they may skip a map: | ||
| // | ||
| // * `class_field_inline_guard` (codegen) tests this bit ALONE before | ||
| // reading/writing a slot as a bare `double`; | ||
| // * `element_shape_guard`'s packed `0x1800_80FF` header test folds it | ||
| // in for the same license; | ||
| // * `class_field_store_layout_note_is_conforming` (codegen | ||
| // `expr/helpers.rs`) elides the layout note outright on | ||
| // `_reserved & 0xD000 == 0x9000`, whose proof is "a descriptor built | ||
| // from this class's mask globals is reachable". | ||
| // | ||
| // #7834's at-allocation bake is what made that reachable: it stamps | ||
| // `POINTER_FREE | INTACT` into the inline `new`'s header constant with | ||
| // no descriptor behind it, deliberately, on the argument that the | ||
| // generic branch below downgrades correctly. It does — for the | ||
| // collector. The bit it leaves behind is the half that was missing: | ||
| // `docs/engine-plan.md`'s construction-cost section, item 2, named this | ||
| // mechanism exactly — it used to forbid the bake outright, and now | ||
| // records the residual and this repair. | ||
| // | ||
| // Cost: one 16-bit store, only on the fall-through, which for a baked | ||
| // object is only ever a store the descriptor path would have called | ||
| // `layout_set_typed_unknown` for. Pre-#7834 that is precisely what it | ||
| // did — every pointer-free class carried a real descriptor, and any | ||
| // non-conforming store evicted it, bit included. | ||
| if claimed_intact { | ||
| header_clear_typed_layout_intact(header); | ||
| } | ||
|
Comment on lines
+744
to
+783
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Confirm the clear helper's signature and the raw-f64 predicate's treatment of
# non-double NaN-boxed values, to check the fall-through claim above.
set -euo pipefail
fd -t f 'layout.rs' crates/perry-runtime/src/gc
ast-grep run --lang rust --pattern 'fn header_clear_typed_layout_intact($$$) { $$$ }' crates/perry-runtime/src/gc
ast-grep run --lang rust --pattern 'fn layout_raw_f64_bits($$$) { $$$ }' crates/perry-runtime/src
ast-grep run --lang rust --pattern 'fn layout_pointer_bearing_bits($$$) { $$$ }' crates/perry-runtime/srcRepository: PerryTS/perry Length of output: 252 🏁 Script executed: #!/bin/bash
set -euo pipefail
sed -n '630,825p' crates/perry-runtime/src/gc/layout.rs
printf '\n-- relevant definitions and call sites --\n'
rg -n -C 5 'header_clear_typed_layout_intact|layout_raw_f64_bits|layout_pointer_bearing_bits|claimed_intact|a_conforming_raw_f64_store_leaves_the_bake_intact|GC_LAYOUT_POINTER_FREE' crates/perry-runtime/src/gc/layout.rs crates/perry-runtime/src/gcRepository: PerryTS/perry Length of output: 50370 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '-- NaN-box tags and predicates --'
rg -n -C 3 'TAG_(MASK|POINTER|STRING|BIGINT|SHORT_STRING|UNDEFINED|TRUE|FALSE)|TRUE_VALUE|FALSE_VALUE|UNDEFINED' crates/perry-runtime/src/value.rs crates/perry-runtime/src/gc/layout.rs | head -180
printf '%s\n' '-- focused layout tests --'
rg -n -C 8 'a_conforming_raw_f64_store_leaves_the_bake_intact|bake|typed_layout_intact|layout_note_slot' crates/perry-runtime/src/gc -g '*.rs' | head -260
printf '%s\n' '-- layout_note_slot callers --'
rg -n 'layout_note_slot\(' crates/perry-runtime/src -g '*.rs'Repository: PerryTS/perry Length of output: 3451 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '-- value module files --'
fd -t f -i 'value' crates/perry-runtime/src | head -40
printf '%s\n' '-- tag definitions --'
rg -n -C 4 'SHORT_STRING_TAG|STRING_TAG|BIGINT_TAG|POINTER_TAG|TAG_MASK|TAG_UNDEFINED|TAG_TRUE|TAG_FALSE' crates/perry-runtime/src crates/perry-runtime/tests -g '*.rs' 2>/dev/null | head -240
printf '%s\n' '-- focused layout tests --'
rg -n -C 8 'a_conforming_raw_f64_store_leaves_the_bake_intact|bake|typed_layout_intact|layout_note_slot' crates/perry-runtime/src/gc -g '*.rs' | head -300
printf '%s\n' '-- layout_note_slot callers --'
rg -n 'layout_note_slot\(' crates/perry-runtime/src -g '*.rs'Repository: PerryTS/perry Length of output: 20303 🏁 Script executed: #!/bin/bash
set -u
printf '%s\n' '-- tag definitions --'
rg -n -C 6 'SHORT_STRING_TAG|STRING_TAG|BIGINT_TAG|POINTER_TAG|TAG_MASK|TAG_UNDEFINED|TAG_TRUE|TAG_FALSE' crates/perry-runtime/src/value crates/perry-runtime/src/gc/layout.rs -g '*.rs' | head -220 || true
printf '%s\n' '-- focused layout tests --'
rg -n -C 8 'a_conforming_raw_f64_store_leaves_the_bake_intact|bake|typed_layout_intact|layout_note_slot' crates/perry-runtime/src/gc -g '*.rs' | head -320 || true
printf '%s\n' '-- layout_note_slot callers --'
rg -n 'layout_note_slot\(' crates/perry-runtime/src -g '*.rs' || trueRepository: PerryTS/perry Length of output: 50369 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '-- exact NaN-box constants --'
sed -n '1,180p' crates/perry-runtime/src/value/mod.rs 2>/dev/null || true
sed -n '1,180p' crates/perry-runtime/src/value/nanbox.rs
printf '%s\n' '-- residual-layout tests --'
wc -l crates/perry-runtime/src/gc/tests/typed_layout_intact_residual.rs
cat -n crates/perry-runtime/src/gc/tests/typed_layout_intact_residual.rsRepository: PerryTS/perry Length of output: 36475 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '-- exact tag constants --'
sed -n '1,150p' crates/perry-runtime/src/value/tags.rs
printf '%s\n' '-- deterministic predicate probe --'
python3 - <<'PY'
TAG_MASK = 0xFFFF000000000000
POINTER_MASK = 0x0000FFFFFFFFFFFF
POINTER_TAG = 0x7FFD000000000000
STRING_TAG = 0x7FFF000000000000
BIGINT_TAG = 0x7FFA000000000000
SHORT_STRING_TAG = 0x7FF9000000000000
TAG_UNDEFINED = 0x7FFC000000000000
TAG_TRUE = 0x7FFC000000000001
TAG_FALSE = 0x7FFC000000000002
TAG_INT32 = 0x7FFE000000000000
def raw_f64(bits):
tag = bits & TAG_MASK
return not (SHORT_STRING_TAG <= tag <= STRING_TAG)
def pointer_bearing(bits):
tag = bits & TAG_MASK
if tag in (POINTER_TAG, STRING_TAG, BIGINT_TAG):
return (bits & POINTER_MASK) != 0
if tag >= 0x7FF8000000000000:
return False
return 0x1000 <= bits <= POINTER_MASK and bits & 7 == 0
for name, bits in {
"true": TAG_TRUE,
"undefined": TAG_UNDEFINED,
"int32": TAG_INT32 | 7,
"plain_double_3_5": __import__("struct").unpack(">Q", __import__("struct").pack(">d", 3.5))[0],
}.items():
print(name, f"0x{bits:016X}", "raw_f64=", raw_f64(bits),
"pointer_bearing=", pointer_bearing(bits),
"fast_arm=", raw_f64(bits) and not pointer_bearing(bits))
PYRepository: PerryTS/perry Length of output: 8914 Add a regression test for tagged non-pointer stores.
🤖 Prompt for AI Agents |
||
| let pointer = layout_pointer_bearing_bits(value_bits); | ||
| // A result array built by a runtime helper can declare that its live | ||
| // prefix is pointer-only once, instead of growing a HashMap-backed | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Use the full repository-relative test path in both documents.
changelog.d/8182-typed-layout-intact-residual.md#L23-L25: change the path tocrates/perry-runtime/src/gc/tests/typed_layout_intact_residual.rs.docs/engine-plan.md#L229-L231: apply the same path correction.📍 Affects 2 files
changelog.d/8182-typed-layout-intact-residual.md#L23-L25(this comment)docs/engine-plan.md#L229-L231🤖 Prompt for AI Agents