Repository navigation
fix(barrier): handle generation counter wraparound on 32-bit targets - #413
Merged
Merged
Conversation
A `Barrier` waiter kept the generation it joined. `Drop` treated the waiter as completed once the current generation differed from that number, but `poll` only once the number was less than the current one. The counter was a `usize` incremented without wrapping, so completing generation `usize::MAX` overflowed it. A debug build panicked on that increment before waking anyone and left the counter in place, so the final arrival of every later generation panicked the same way and no waiter was released again. A release build wrapped to zero. The waiters released by the wrapping generation still held `usize::MAX`, which is never less than the current number, so polling one passed its stale token to the drained waker set: it panicked if that slot was vacant, or replaced the waker of the next generation's follower in the slot and stayed pending, so that follower was never woken. On 32-bit targets this takes 2^32 generations. Complete a waiter when the generation it joined is no longer current, in one check that `poll` and `Drop` share, the same shape `Phaser` uses for its phase number. Comparing by identity makes the wrap itself harmless, but a woken waiter left unpolled for a full cycle of the counter would match the current generation again, and that cycle is only 2^32 generations for a 32-bit `usize`. Use a 64-bit counter with a wrapping increment so the cycle is out of reach on every target. Add unit tests that start the counter at `u64::MAX`, poll or drop the woken follower after the wrap, and check that the next generation's follower is still woken. The existing drop test now asserts that wake too; it passed even with the generation check in `Drop` removed.
`Barrier::wait` took the state lock twice on a follower's first poll: once in the async fn body to count the arrival and once in `BarrierWait::poll` to register the waker. Move the arrival into the future's first poll so the count, the completion check and the waker registration happen under one acquisition, and let the future return the leader result itself. The leader can no longer complete a generation between a follower's arrival and its registration. An unset token marks a future that has not arrived yet, as the index does in the semaphore's `Acquire`. This relies on the `async fn` wrapper, which panics if polled after completion instead of letting the inner future record a second arrival. The wait future stays at 40 bytes on 64-bit targets and 24 bytes on i686, the same as with the separate arrival.
tisonkun
approved these changes
Nov 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
On 32-bit targets, a
Barrierbreaks after 2^32 completed generations. Its generation is ausizeadvanced with+= 1, andpolltreats a woken follower as done only when the generation it joined is less than the current one, whileDropchecks for any difference. I reproduced both failure modes on v0.7.4 by starting the counter atusize::MAX. Debug builds panic on the overflow, and every later generation's final arrival panics again, so no waiter is released after that. Release builds wrap to 0, and polling a follower released by the wrapping generation re-registers its stale waker token, which panics on a vacant slot or makes a next-generation follower that reused the slot miss its wakeup.The first commit makes the counter a
u64advanced withwrapping_add, andpollandDropnow share one identity check,BarrierState::is_completed, the same shapePhaseruses for its phase number. Two new unit tests start the counter atu64::MAX, poll or drop a woken follower after the wrap, and check with a counting waker that the next generation's follower is still woken. The existing drop test inbarrier_testnow asserts that wake as well. Before, it passed even with the check inDropremoved.The second commit is a separate optimization. If you'd rather land only the fix, I'll drop it. A follower's first poll currently takes the state lock twice, once in
waitto count the arrival and once inBarrierWait::pollto register the waker. Both now happen in the future's first poll under one lock, and the wait future stays at 40 bytes on 64-bit targets.Design Notes
The identity check alone makes the wrap harmless, but a woken follower left unpolled for a full cycle of the counter would match the current generation again. With a 32-bit
usizethat cycle is 2^32 generations, so the counter is 64 bits on every target, which on i686 grows the wait future from 20 to 24 bytes andBarrieritself from 40 to 44 bytes. At the roughly 60 ns per generation ofreused_generation_fanout/1below, a fullu64cycle would take about 35,000 years. I also tried removing the counter and keeping per-node completion state in aWaitList. That closes the full-cycle case, but it would replace theWakerSetstorage to cover a state that needs 2^64 generations.Benchmarks
mainiscbdcd0f. #412 landed after it but doesn't touchBarrier. Apple M5, macOS 26.4, Rust 1.96.0. These are the cases ofcargo x bench --bench primitives -- barrier, which poll the futures by hand without an executor. I ran binaries formain, the first commit and both commits in rotating order. Each cell is the median of 12 rounds, each round the median of 5 runs, in ms. Change compares both commits withmain.mainreused_generation_fanout/1reused_generation_fanout/4reused_generation_fanout/16cancel_pendingOn 64-bit targets the first commit only changes a branch condition, so its column shows noise.
Validation
cargo x test(696 passed),cargo x check(26 feature configurations),cargo x lint, and Miri on the Barrier unit tests andbarrier_test, natively and fori686-unknown-linux-gnu.