Skip to content

gc: rewrite_raw_addr follows a forwarding pointer out of an address that is not a live object start #8174

Description

@proggeramlug

Summary

CopyingNurseryCollector::rewrite_raw_addr will follow a "forwarding pointer" out of an address that is not a live object start, and will accept a NaN-boxed word as the forwarding target. Every rekeyed address-keyed metadata table shares this exposure.

Found while root-causing #8040's forced-GC failure (fixed in #8168), but it is a distinct defect: #8168 removes the dead key that triggered it here; this issue is that a dead or bogus address is followed at all.

Mechanism (instrumented, from the #8168 investigation)

  1. A side table holds a raw heap address whose object has died.
  2. The arena recycles that address for an unrelated allocation.
  3. The recycled bytes are read as a GcHeader. Its gc_flags byte happens to carry GC_FLAG_FORWARDED — observed 0x86, obj_type=104, in_census=false.
  4. rewrite_raw_addr accepts it, because its gate is classify_heap_space(addr) != Unknown, which is true for any arena address, live or dead.
  5. It reads the payload word as a forwarding pointer. In the observed case that word was a NaN-boxed value (0x7FFF…); the next hop classified Unknown and the walk stopped (stop_reason=2, hops=1).
  6. visit_metadata_nanbox_key then masked it to 48 bits, yielding a genuine, live survivor object that was itself being evacuated — an object entirely unrelated to the original key.

In #8040 the object behind the key was GC_TYPE_STRING + GC_FLAG_INTERNED, a shape a closure-only key can never legitimately name. That is what identified the value as stale rather than mis-rewritten (confirmed under lldb).

Why it was not fixed in #8168

The obvious tightening — gating on self.ptrs.classify() instead of classify_heap_space — is precisely what crates/perry-runtime/src/gc/copying.rs:292-317 documents as having caused the opposite bug for shapes.entries. Mid-cycle there is no census to consult, so "is this address a live object start?" has no cheap authoritative answer at that point.

#8168 addressed the root cause for its own table (a dead key should never have survived to be followed) rather than widening the fix under time pressure. That is the right call for that PR, but it leaves the general exposure in place.

Why it matters

gc::dead_owner prunes ~a dozen address-keyed side tables. Any table that is rekeyed rather than re-derived depends on every one of its keys being live at rewrite time. #8168 wires up the one table that was missing; the invariant is currently maintained by that enumeration being complete, with no structural enforcement. A future table added without a prune reintroduces exactly this failure, and it will present as a stale forwarded pointer in an unrelated scanner — expensive to trace back, as #8040 was.

Directions

  • A cheap liveness discriminator usable mid-cycle (an allocation bitmap, or a header magic checked before trusting GC_FLAG_FORWARDED).
  • Refuse a forwarding target that fails to classify as a heap object start, rather than masking a NaN-boxed word into an address.
  • Or make the prune structural: a registry of rekeyed address-keyed tables that dead_owner iterates, so adding a table without a prune fails a gate instead of corrupting a later cycle.

Refs #8040, #8168.

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