fix: Fix Workers::stop() race between m_allPaused and m_runningTaskCount - #6574
Conversation
Replace the post-wait assertion in Workers::stop() with a compound condition variable predicate that waits for both m_allPaused AND m_runningTaskCount == 0. The old code asserted zero running tasks after confirming m_allPaused, but these two states use independent synchronization (mutex vs atomic), allowing a window where m_allPaused is true while a task is still finishing. Additionally, notify m_cv when m_runningTaskCount drops to zero, locking m_mut first to prevent lost wakeups against the predicate evaluation in stop()'s cv.wait(). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## develop #6574 +/- ##
=========================================
- Coverage 79.8% 79.8% -0.0%
=========================================
Files 878 878
Lines 68028 68029 +1
Branches 7556 7556
=========================================
- Hits 54308 54302 -6
- Misses 13720 13727 +7
🚀 New features to boost your workflow:
|
Keep the explicit unlock before returning from stop() to release the mutex as soon as it is no longer needed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
I don't think we can stably reproduce this issue with xrpl.core.Workers. I believe we should soak this branch in antithesis |
I just kicked off a run in Antithesis, specifically for this branch. |
Can you please share the results of the Antithesis run? |
No new findings 🙂 |
Is that a good thing or not so good? Still crashing or nothing improved? All normal, so nothing to report? |
Since the shutdown crash occurs randomly, not every Antithesis run will encounter it. The run I kicked off didn't see it, but that's no proof yet. The easiest way forward is to merge this PR once approved, so the full day fuzzing runs will start using it. If we see the problem reoccur, then we revert this PR and go back to the drawing board. However, if you are uncertain, then we can kick off several longer Antithesis runs to gain more confidence, before we do the above. |
|
We can just modify ripple-antithesis to use this branch for daily antithesis runs |
Yeah, I am fine with merging and testing on Antithesis full day runs. Since the issue is around the shutdown step, if we run same tests, but shutdown every time, we can get more surety. This then doesn't need to be done after merging. Is there a way we can automate, say 100 test runs, preferably on CI(or on aws), on this branch(using same binary, to save build times)? |
|
anyway, just fyi, if you don't see |
|
@bthomee So, in that case we can go ahead with the merge. |
| // so m_allPaused can momentarily be true while a task is still finishing. | ||
| std::unique_lock<std::mutex> lk{m_mut}; | ||
| m_cv.wait(lk, [this] { return m_allPaused; }); | ||
| m_cv.wait(lk, [this] { return m_allPaused && numberOfCurrentlyRunningTasks() == 0; }); |
There was a problem hiding this comment.
so according to the code, m_allPaused == true implies that m_activeCount is 0, which further implies that no worker is currently running the for loop at the line 184, and further implies that numberOfCurrentlyRunningTasks() is 0.
If that's true, then adding this condition here doesn't work.
There was a problem hiding this comment.
After discussion, this is not a real life bug and it only happens when we change the number of threads dynamically.
IMO, we should remove the functionality that changes the number of threads dynamically because we never need to do it and the function setNumberOfThreads is buggy. After you call setNumberOfThreads there's no guarantee that the current number of threads is the number you specified and there's no way for the user to wait until it finishes changing the number of threads. So a couple of things to do:
- Remove the test that changes the number of threads
- Remove the function
setNumberOfThreads - Add a flag to say whether we're exiting
- Simplify the logic in the worker thread entry by removing the outer do-while loop
- When we call
stop, we simply indicate that we're exiting and wait for worker threads to exit
There was a problem hiding this comment.
Sounds like a much better way to go about this.
There was a problem hiding this comment.
Yeah, I agree. It would be good to get rid of the complicated code since it is not used.
Question: How do we approach this? Shall we merge this change and then create another ticket to clean it up? Or close this PR, and directly clean the code?
There was a problem hiding this comment.
Sorry, forgot to mention that I’ve created a ticket RIPD-5582.
That change isn’t directly relating to this PR so we can do it later in a separate PR
There was a problem hiding this comment.
Pull request overview
Fixes an intermittent Workers::stop() assertion by removing the logical race between “all workers paused” (m_allPaused) and “no tasks currently executing” (m_runningTaskCount) during shutdown of the Workers thread pool.
Changes:
- Update
Workers::stop()to wait on a single CV predicate that requires bothm_allPausedandnumberOfCurrentlyRunningTasks() == 0. - Notify
m_cvwhen the running task count drops to zero, ensuringstop()can make progress when it is waiting on both conditions. - Remove an unused include after dropping the assertion.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…unt (#6574) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…unt (XRPLF#6574) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
High Level Overview of Change
Fix an intermittent assertion failure in
Workers::stop()caused by a race condition betweenm_allPaused(mutex-protected bool) andm_runningTaskCount(atomic int). These two states use independent synchronization, som_allPausedcan betruewhile a task is still finishing.Triggered by this CI failure on
debian-bookworm-gcc-12-amd64-debug.Context of Change
Workers::stop()previously assertednumberOfCurrentlyRunningTasks() == 0after confirmingm_allPausedwas true, but outside any lock. Sincem_allPausedandm_runningTaskCountare synchronized independently (mutex vs. atomic), there is a window wherem_allPausedis true while a worker still hasm_runningTaskCount > 0.This fix:
m_allPaused && numberOfCurrentlyRunningTasks() == 0m_cvwhenm_runningTaskCountdrops to zero (lockingm_mutfirst to prevent lost wakeups)This is an alternative approach to PR #6302, which used memory fences. The compound predicate is more robust — it handles the logical race (not just memory ordering) and works correctly on all architectures.
Type of Change
API Impact
None — internal threading change only.
Before / After
Before:
stop()waits form_allPaused, then assertsm_runningTaskCount == 0. Assertion can fail if the two states are transiently inconsistent.After:
stop()waits for both conditions in a single predicate. No assertion can fail;stop()simply waits until both are satisfied.Test Plan
Existing
xrpl.core.Workerstest suite covers this. The fix eliminates the intermittent crash rather than adding new test cases — the race is timing-dependent and not reliably reproducible in a unit test.Jira: RIPD-5329
Related: #6302 (RIPD-4759), #5774