Repository navigation
gc: sliced weak processing exposes post-remark weak-to-strong read race #7900
Description
Summary
Budgeted GC performs its one-time FinalRootRemark, then permits mutator windows while weak processing is still incomplete. During such a window, WeakRef.deref() / WeakMap.get() can turn an unmarked weak target into a strong compiled-code local through a read. The incremental write barrier and allocate-black births do not observe that transition. A later weak-processing slice can therefore tombstone the target and sweep it while generated code still holds the returned pointer.
This is a correctness problem, not only a pause-quality tradeoff.
Interleaving
With two registered weak holders and a one-holder assist budget:
- A budgeted minor/full completes marking and runs
FinalRootRemarkonce. WeakProcessingsnapshots the holder registry and processes holder A. Because the subphase is classified as sliced, it returns to the mutator with holder B still pending.- B's target
Tis white: weak edges are deliberately excluded from the strong trace. - The mutator executes
B.deref()(orweakMap.get(key)) and receivesTinto a generated stack/register local. This is a read, so neither the heap-store barrier nor allocate-black birth accounting shadesT. - The next assist processes B, sees
Tunmarked, clears the weak slot, and later sweep reclaimsT. - Compiled code still has the strong local returned in step 4.
The native/statepoint lowering makes the local discoverable when roots are scanned, but no root scan occurs after this mutator window: FinalRootRemark has already completed.
The full path also permits a sliced remembered-set rebuild between FinalRootRemark and weak decisions, so the underlying contract needs to cover every post-remark mutator window, not just the newly sliced holder loop.
Evidence
FinalRootRemarkperforms an unbounded one-time root scan and drain, then advances onward.WeakProcessingis explicitly classified as sliced and may return to the mutator.- The holder state consumes at most
budgetholders and leaves the remaining cursor for later. - Weak targets are cleared solely from the mark-set liveness decision.
- The comment justifying slicing says the mark barrier and allocate-black births make it safe; neither mechanism covers a weak-to-strong read into a compiled local.
Required contract / acceptance criteria
Choose and document one sound design, for example:
- keep the interval from the final root remark through completed weak decisions and the sweep snapshot atomic; or
- add a weak-read barrier/epoch that shades a returned target while weak processing is pending, drain those shades, and make already-issued weak decisions consistent; or
- move/repeat the final root remark after the last mutator window, then complete weak decisions through sweep without exposing the mutator.
And add coverage:
- Deterministic state-machine test with at least two weak holders and a one-unit assist budget: pause after one holder, strongly acquire the other target through the public weak read, resume, and prove the target remains valid.
- Exercise both budgeted minor and full ordering (the full path has sliced remembered-set rebuild before weak processing).
- Exercise the native/statepoint lowering, where the newly strong value can exist only in a generated local; do not make the witness rely solely on a root-store barrier that would mask the bug.
- Add an invariant/telemetry assertion that no mutator window exists after the last root observation unless weak reads participate in marking.
Audit context
Found while re-auditing the GC/performance delta through v0.5.1481. #7892 correctly removed the O(total-heap) weak-holder search, but its resumable post-remark execution changes the liveness synchronization contract.
Activity
fix(gc): bound the atomic work inside a time-budgeted incremental step (
fix(gc): bound the atomic work inside a time-budgeted incremental step (
fix(gc): close the post-remark weak-to-strong read race with a weak-r…