Repository navigation
gc: #7834 ships the inline-new intact bake that engine-plan's ⛔ item 2 forbids — settle the stale INTACT bit and fix the doc #8115
Description
Summary
docs/engine-plan.md item 2 under "⛔ Two remedies that look obvious and are wrong. Do not rebuild them." forbids OR-ing GC_OBJ_TYPED_LAYOUT_INTACT into the inline new's header word, calling it "a use-after-free factory". #7834 (d04cc3f29) shipped exactly that four days later and never updated the doc.
I re-checked the ⛔'s mechanism line by line on 12f758a22. Its mechanical claim is true, #7834's published soundness argument answers a different question, and the defence one might reach for is provably inert. Two things are needed: a decision on the residual, and a doc that stops forbidding shipped code.
The bake is real and provably reaches emitted IR
lower_call/new_alloc.rs:412,438 define GC_OBJ_TYPED_LAYOUT_INTACT = 0x1000 and OR it into the single packed i64 GcHeader store, gated on typed_shape_init.rs:79 layout_pointer_free_at_allocation = pointer_mask_words.is_empty() && slot_count == field_count.
This needs no new probe — an earlier investigation's probe was vacuous (its IR showed a declare, not the bake). The tree already contains a non-vacuous IR-census test with a negative control, crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs:
a_pointer_free_shape_bakes_its_layout_into_the_header_constantasserts the emitted IR contains the exact packed word with the intact bit and does not containcall void @js_gc_declare_typed_shape_layout(.a_pointer_bearing_shape_keeps_the_runtime_declareis the control: same program, one field's declared type changed; asserts the word without the bit and the declare present.
The two documents disagree about the same code path
typed_shape_bake_tests.rs:29-37 carries #7834's argument:
//! And a later pointer store still downgrades: with no descriptor to
//! classify against,layout_note_slotfalls through to its generic
//! pointer-mask branch, which mints a per-object mask and flips the state
//! toSIDE_MASK. That branch needs no descriptor at all.
That is the very path the ⛔ names as the bug. Both descriptions are accurate; they differ on whether leaving the intact bit set matters. #7834 establishes that the collector's view is correct. It never addresses the ⛔'s claim, which is about the inline guard's view.
The ⛔'s mechanism, verified on 12f758a22
gc/layout.rs:326-329 set_layout_statemasks!(GC_LAYOUT_STATE_MASK | GC_LAYOUT_ALL_POINTERS)=!(0xC000 | 0x2000).GC_OBJ_TYPED_LAYOUT_INTACTis0x1000. It cannot clear the intact bit.- The generic pointer-mask branch (
gc/layout.rs~700-760) reachesset_layout_state(header, GC_LAYOUT_SIDE_MASK)andset_layout_state(header, GC_LAYOUT_UNKNOWN)bare — not throughlayout_mark_unknown/layout_set_typed_unknown, the only two callers ofheader_clear_typed_layout_intact(layout.rs:474,:521). The bit survives. perry-codegen/src/expr/class_field_inline_guard.rs:303and:543gate the raw-f64 arm on_reserved & 4096 != 0alone — no layout-state test. An object leftSIDE_MASK | INTACTpasses.
Residual: an object that is SIDE_MASK | INTACT with no typed descriptor — reachable only via the #7834 bake — carries a per-object mask claiming slot k is a pointer while the inline guard licenses an unnoted raw-f64 write to k. The collector then reads a raw double through a mask entry that says "pointer".
SIDE_MASK | INTACT is otherwise legitimate — perry-codegen/src/expr/helpers.rs:242 documents a guard requiring exactly STATE_MASK | TYPED_LAYOUT_INTACT == SIDE_MASK | INTACT. What is new after #7834 is reaching that state without a descriptor behind it.
Getting there needs a pointer stored into a statically-all-number class slot, i.e. a runtime type violation — which Perry permits by design ("No runtime type validation", CLAUDE.md). Narrow, but layout.rs:614-617 notes that "some aligned low words are both valid f64 bit patterns and conservatively pointer-bearing", so the window is not empty.
The defence one would reach for does not hold
object/mod.rs:1962 mark_object_dynamic_shape_unknown:
let state = (*header)._reserved & crate::gc::GC_LAYOUT_STATE_MASK;
if state != crate::gc::GC_LAYOUT_SIDE_MASK
&& !crate::gc::layout_has_typed_descriptor(obj as usize)
{
return; // <-- early return
}
crate::gc::layout_mark_unknown(obj as *mut u8);A freshly baked object is GC_LAYOUT_POINTER_FREE (0x4000) with no typed descriptor — that is the definition of the bake. Both conjuncts hold, so it returns without doing anything. It is structurally inert on exactly the objects it would need to protect. (Post-downgrade it would fire, and layout_mark_unknown does clear the bit at layout.rs:474 — but only after the fact, and only if a by-name store happens.)
Asks
- Decide the residual. Either clear
GC_OBJ_TYPED_LAYOUT_INTACTon the generic pointer-mask downgrade path (makingSIDE_MASK | INTACTunreachable without a descriptor), or state the invariant that makes it safe and add a test that can fail. A regression test should construct a baked class, force a pointer into a slot, then take the inline guard's raw-f64 path and collect. - Fix
docs/engine-plan.mditem 2 either way. It currently forbids somethingmaindoes. A ⛔ thatmaincontradicts teaches the next reader that the document is unreliable — which is worse than either answer.
Found while auditing header consumers for #8047; the mechanism lives entirely in GcHeader._reserved, so it neither blocks nor is worsened by that shrink.
Activity
proggeramlug commentedon Aug 15, 2026
Re-verified on current
main(fb87d8923). The residual is real and still live.You verified the mechanism on
12f758a22;mainhas moved since, so I re-checked the three load-bearing claims rather than take them forward on trust. All three hold.1.
set_layout_statecannot clear the intact bit —gc/layout.rs:326:with
GC_LAYOUT_STATE_MASK = 0xC000(:19),GC_LAYOUT_ALL_POINTERS = 0x2000(:55),GC_OBJ_TYPED_LAYOUT_INTACT = 0x1000(:80).0x1000is in neither mask. Confirmed.3. The inline guard tests the intact bit and nothing else — this is the one I most expected to have drifted, and it has not.
class_field_inline_guard.rshas exactly two guard sites,:303and:543, both:and a grep of that whole file for
STATE_MASK,0xC000,49152,SIDE_MASK,16384,32768returns nothing. There is no layout-state test in the file at all. Confirmed.So the residual stands as you describe it: an object in
SIDE_MASK | INTACTwith no typed descriptor carries a per-object mask claiming slot k is a pointer, while the inline guard licenses an unnoted raw-f64 write to k, and the collector then reads a raw double through a mask entry that says "pointer".I agree with your severity framing. It needs a runtime type violation to reach — a pointer stored into a statically-all-
numberslot — which Perry permits by design, andlayout.rs:614-617's note that some aligned low words are simultaneously valid f64 patterns and conservatively pointer-bearing means the window is not empty. Narrow, not closed.On which end to fix
Two candidates, and I think the choice matters more than it looks:
(a) Add a state test to the inline guard. Straightforward, but
helpers.rs:242documents a guard that legitimately requires exactlySIDE_MASK | INTACT— so the guard cannot simply rejectSIDE_MASK, and you would be encoding "which states have a descriptor behind them" into a codegen site that currently does not know about states at all.(b) Stop producing the bad state. The generic pointer-mask branch reaches
set_layout_state(header, GC_LAYOUT_SIDE_MASK)andset_layout_state(header, GC_LAYOUT_UNKNOWN)bare, whileheader_clear_typed_layout_intacthas only two callers —layout_mark_unknown(:474) andlayout_set_typed_unknown(:521). Clearing the intact bit on the descriptor-less transition makesSIDE_MASK | INTACTmean whathelpers.rs:242already assumes it means: there is a descriptor behind it.I favour (b). It repairs the invariant at the one place that violates it instead of teaching every consumer to distrust the bit, and it leaves the legitimate
SIDE_MASK | INTACTcase working. It also keeps the property that the intact bit is a claim about descriptor existence rather than a claim some readers must re-qualify.Whichever way it goes, the acceptance test has to be an object that actually reaches
SIDE_MASK | INTACTwithout a descriptor and then takes the inline raw-f64 arm — asserting the state transition alone would pass without fixing anything.The doc half is independent and should not wait
docs/engine-plan.mditem 2 currently forbids code that has shipped and is covered by a non-vacuous IR-census test with a negative control (typed_shape_bake_tests.rs). That is worth separating from the soundness decision: a ⛔ that forbidsmainteaches the next reader that the tree is wrong, and the honest version is "this is shipped, here is the residual it carries, here is the invariant that makes it sound." Leaving the two coupled means the doc stays wrong for as long as the fix takes.Not fixing this myself right now — the box is disk-constrained with three builds in flight — but the mechanism is confirmed on
main, so this should not be closed as stale.docs(changelog): add the #8115 intact-bit fragment
fix(gc): retire the typed-layout intact claim when no descriptor back…
perf(gc): relocation moves only address-keyed layout records (#10362)