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)
- A side table holds a raw heap address whose object has died.
- The arena recycles that address for an unrelated allocation.
- 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.
rewrite_raw_addr accepts it, because its gate is classify_heap_space(addr) != Unknown, which is true for any arena address, live or dead.
- 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).
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.
Summary
CopyingNurseryCollector::rewrite_raw_addrwill 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)
GcHeader. Itsgc_flagsbyte happens to carryGC_FLAG_FORWARDED— observed0x86,obj_type=104,in_census=false.rewrite_raw_addraccepts it, because its gate isclassify_heap_space(addr) != Unknown, which is true for any arena address, live or dead.0x7FFF…); the next hop classifiedUnknownand the walk stopped (stop_reason=2, hops=1).visit_metadata_nanbox_keythen 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 ofclassify_heap_space— is precisely whatcrates/perry-runtime/src/gc/copying.rs:292-317documents as having caused the opposite bug forshapes.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_ownerprunes ~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
GC_FLAG_FORWARDED).dead_owneriterates, so adding a table without a prune fails a gate instead of corrupting a later cycle.Refs #8040, #8168.