CratonVM

R6 — every `VmHeap::Zgc` arm, audited against a collector that now moves

R6 — every VmHeap::Zgc arm, audited against a collector that now moves

Written 2026-08-14. zgc-production-implementation-plan.md filed this as risk R6:

The VmHeap::Zgc neutral arms are silent. 58 arms plus a macro; the affirmative ones (true, (0,0)) assert facts that are only true for a non-moving collector, and none of them will fail loudly when they become wrong.

Compaction went default-on for ZGC on 2026-08-13, so "when they become wrong" is now. This is that audit, and it found three live defects, two of which corrupt memory.

Method

74 VmHeap::Zgc arms. 46 delegate to the heap (dispatch! or VmHeap::Zgc(h)) and are audited by the heap method they call. The 28 that ignore the heap — VmHeap::Zgc(_) => <constant> — are the ones R6 is about, because a constant cannot notice that the collector changed underneath it. Each was classified by asking one question: does this answer stay true when an object moves?


The three defects

D1 — pin_critical_region pinned nothing (memory corruption)

VmHeap::Zgc(_) => Vec::new(),

The doc above it explains why that was fine: "the copy-back staleness is reachable in practice only under G1's aggressive young/mixed evacuation." That reasoning expired the day this collector started evacuating.

The hazard is not the native pointer — this VM hands native code a copy of the array. It is the copy-back at ReleasePrimitiveArrayCritical, which re-resolves the Get-time address. Move the array and that copy-back writes a whole array's worth of bytes over whatever object now occupies the old address. The JNI site's own comment names the outcome: "data loss / write to a recycled object."

Fixed by giving ZgcRealHeap a refcounted critical_pins set and dropping any page holding a pinned object out of the relocation set — page granularity, mirroring what G1 already does with pin_region_for_addr. Object granularity would need the slide's placement probe to route around individual survivors inside a page it is otherwise compacting, and every extra rule in that probe is another chance to overwrite something live.

Tests: a_critically_pinned_object_is_never_relocated (verified red with the filter removed) and critical_pins_are_refcounted_so_a_nested_release_does_not_unpin.

D2 — the reference-processing guard tested the pre-move address (silent loss)

Not a VmHeap arm but found through one — pre_gc_addr_did_not_survive's ZGC arm is !self.is_addr_live(addr), which correctly answers "that address is not safe to write through" for a relocated object's old address. The caller then did this:

if is_stale_young(ref_addr) { continue; }             // tests the OLD address
let actual_addr = pointer_map.get(&ref_addr)...;      // writes through the NEW one

The guard and the write disagreed about which object they meant. Under a non-moving collector they are the same address and it never mattered. From 2026-08-13, every Reference the slide moved was judged dead here and silently never cleared or enqueued: no WeakReference delivery, no Cleaner action — which on netty is how a direct ByteBuf's native memory stops being freed.

Fixed by testing the relocated address in both the cleared and the enqueue loop. A no-op wherever the pointer map is empty, i.e. every non-moving cycle on every backend.

D3 — prune_dead received pre-slide addresses (memory corruption)

Also not an arm, and the worst of the three. MonitorCleanup::prune_dead documents its input as EXACT — "this address was a live allocation base before this collection and its memory is now freed, so no thread can read its mark word" — and on that licence calls Monitor::release_mark_ref, dropping the strong reference the object's own mark word owns.

Compaction breaks that precondition in the commonest way there is: survivors slide down into the space vacated by dead objects. Measured on a four-page fixture: 169 live object bases were handed to prune_dead in a single collection. Each is a released mark-word reference on a live object's monitor — a use-after-free reachable from any synchronized block, on an object chosen by wherever the slide happened to land.

First fix was wrong, and the suite caught it the same day. Re-screening dead against the post-slide registry stopped the collector freeing a live object's monitor — and left the dead object's entry sitting at that address for the survivor to inherit, because remap_after_gc RETAINS an entry absent from the pointer map. io.netty.util.ResourceLeakDetectorTest went from FAIL to CRASH on it. The screen fixed one bug and created its mirror image.

The real fix is ORDER: prune the dead first, then remap the survivors. With the prune running first, every address in dead really is a freed base — no survivor has been re-keyed onto one yet — which is precisely the precondition prune_dead documents and which licenses its release_mark_ref. The remap then moves each survivor into a slot nobody else claims.

Test: a_survivor_keeps_its_own_monitor_and_never_inherits_a_dead_objects. It models the real table (drain-and-reinsert, retain-if-absent) and asserts the end state rather than the call order. Verified red against both wrong orderings, which is what separates them:

orderingoutcome
remap → prune (original)169 survivors lost their monitor
filtered remap → prune (the bad fix)83 survivors inherited another object's monitor
prune → remap (correct)0 and 0

The lesson worth keeping is not "screen the list". It is that two operations keyed by the same address space cannot be reasoned about independently once the collector moves objects between them: remap and prune were each correct alone and collided only because compaction made a dead base and a live base the same number. Fixing the one that noticed first produced a bug in the other.


The other 25 arms

armanswerverdict
try_alloc_object_old, try_alloc_objects_old_batch, old_gen_needs_gc, old_gen_info, old_gen_lock, collect_young_to_old_roots, young_gen_stats, is_in_young_addr, young_inactive_semispace_range, dbg_first_young_small_refNone / (0,0) / false / emptySound. Generational concepts. ZGC has one space; the absence is real, not an approximation, and stays true under a moving ZGC.
g1_should_start_marking, g1_is_marking_active, g1_concurrent_mark_step, jit_card_table_infofalse / true / NoneSound. G1-specific.
refill_tlabNoneSound, and deliberately misleading if read alone — ZGC has TLABs, just not through this door. Documented at the arm.
set_jit_tlab_skip_regions, clear_jit_tlab_skip_regions{}Sound today, gated by supports_jit_tlab_skip which is false for ZGC, so no skip region is ever published. Would become wrong if that gate flipped.
young_bump_headroom, young_has_free_block, try_alloc_young_probe!needs_gc()Sound. Allocation heuristics; a wrong answer costs a spill decision, never correctness.
reclaimed_hole_atNoneSound. Diagnostic.
watched_pre_gc_addr_survivedmap-then-is_addr_liveSound under compaction — checks the pointer map first, so a moved survivor answers true. This is the sibling D2 should have been copied from.
metadata_pin_deferrable, mirror_pin_deferrabletrueSound, and worth re-reading if generational ZGC lands. "Deferrable" means the pin can wait for the next cycle; that holds while every ZGC cycle is whole-heap. A young-only cycle (plan item G1) makes deferral a way to miss a pin, exactly as the generational arms' comments describe.
flush_thread_satb{}Sound today, wrong the day concurrent marking is armed. ZGC now has a SATB ingress (satb_pre_barrier), but mark_active is never set in production, so there is nothing buffered to flush. Plan item C1 must revisit this arm; noted at C4.

What R6 was right about, and what it was not

Right: the arms are silent. Every one of the three defects above was a constant or an ordering that stayed syntactically valid and stopped being true, and not one of them failed loudly. D1 and D3 corrupt memory; D2 loses work. None produced a diagnostic naming the collector.

Not quite right: the count. R6 says "58 arms plus a macro" and implies the risk is spread across them. It is not — 46 delegate and are the heap's problem, not the enum's, and of the 28 constants, 25 are sound for a reason that has nothing to do with motion (they describe generations and regions that do not exist here). The risk concentrated in the three places where an arm, or its caller, encoded an ADDRESS assumption: pinning, survival, and death. That is the shape to look for the next time this collector gains a capability — not "which arms return a constant" but "which arms answer a question about an address".