Skip to content

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

Closed

Description

@proggeramlug

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_constant asserts the emitted IR contains the exact packed word with the intact bit and does not contain call void @js_gc_declare_typed_shape_layout(.
  • a_pointer_bearing_shape_keeps_the_runtime_declare is 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_slot falls through to its generic
//! pointer-mask branch, which mints a per-object mask and flips the state
//! to SIDE_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

  1. gc/layout.rs:326-329 set_layout_state masks !(GC_LAYOUT_STATE_MASK | GC_LAYOUT_ALL_POINTERS) = !(0xC000 | 0x2000). GC_OBJ_TYPED_LAYOUT_INTACT is 0x1000. It cannot clear the intact bit.
  2. The generic pointer-mask branch (gc/layout.rs ~700-760) reaches set_layout_state(header, GC_LAYOUT_SIDE_MASK) and set_layout_state(header, GC_LAYOUT_UNKNOWN) bare — not through layout_mark_unknown / layout_set_typed_unknown, the only two callers of header_clear_typed_layout_intact (layout.rs:474, :521). The bit survives.
  3. perry-codegen/src/expr/class_field_inline_guard.rs:303 and :543 gate the raw-f64 arm on _reserved & 4096 != 0 alone — no layout-state test. An object left SIDE_MASK | INTACT passes.

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

  1. Decide the residual. Either clear GC_OBJ_TYPED_LAYOUT_INTACT on the generic pointer-mask downgrade path (making SIDE_MASK | INTACT unreachable 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.
  2. Fix docs/engine-plan.md item 2 either way. It currently forbids something main does. A ⛔ that main contradicts 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.

Refs #7834, #7566, #7510, #7512, #8047.

Activity

  1. proggeramlug commented on Aug 15, 2026

    @proggeramlug
    ContributorAuthor

    Re-verified on current main (fb87d8923). The residual is real and still live.

    You verified the mechanism on 12f758a22; main has moved since, so I re-checked the three load-bearing claims rather than take them forward on trust. All three hold.

    1. set_layout_state cannot clear the intact bit — gc/layout.rs:326:

    (*header)._reserved = ((*header)._reserved & !(GC_LAYOUT_STATE_MASK | GC_LAYOUT_ALL_POINTERS))
        | (state & GC_LAYOUT_STATE_MASK);

    with GC_LAYOUT_STATE_MASK = 0xC000 (:19), GC_LAYOUT_ALL_POINTERS = 0x2000 (:55), GC_OBJ_TYPED_LAYOUT_INTACT = 0x1000 (:80). 0x1000 is 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.rs has exactly two guard sites, :303 and :543, both:

    let intact = blk.and(I16, &reserved, TYPED_LAYOUT_INTACT_BIT);   // "4096"

    and a grep of that whole file for STATE_MASK, 0xC000, 49152, SIDE_MASK, 16384, 32768 returns 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 | INTACT with 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-number slot — which Perry permits by design, and layout.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:242 documents a guard that legitimately requires exactly SIDE_MASK | INTACT — so the guard cannot simply reject SIDE_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) and set_layout_state(header, GC_LAYOUT_UNKNOWN) bare, while header_clear_typed_layout_intact has only two callers — layout_mark_unknown (:474) and layout_set_typed_unknown (:521). Clearing the intact bit on the descriptor-less transition makes SIDE_MASK | INTACT mean what helpers.rs:242 already 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 | INTACT case 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 | INTACT without 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.md item 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 forbids main teaches 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.

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

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