Fix race condition in beatmap difficulty cache invalidation flow#37178
Merged
peppy merged 2 commits intoppy:masterfrom Apr 3, 2026
Merged
Fix race condition in beatmap difficulty cache invalidation flow#37178peppy merged 2 commits intoppy:masterfrom
peppy merged 2 commits intoppy:masterfrom
Conversation
Exposed by CI failures ([example](https://github.com/ppy/osu/actions/runs/23888446400#user-content-r0s0)). The race occurs when a consumer calls `GetBindableDifficulty()` for the first time and then a cache invalidation is triggered. The sequence of events triggering the failure is as follows: 1. Consumer calls `GetBindableDifficulty()` to get a difficulty bindable for a given beatmap tracking the game-global ruleset / mods. This triggers difficulty calculation A. 2. In the meantime, another process requests a cache invalidation for the same beatmap as the one supplied by the consumer in step (1). This incurs a cache purge and triggers difficulty calculation B, but never cancels difficulty calculation A. 3. Difficulty calculation B concludes and writes the correct, latest difficulty value to the bindable. 4. Difficulty calculation A concludes and writes an incorrect, stale difficulty value to the bindable. See below for patch that reproduces this behaviour on my machine 100% reliably: ```diff diff --git a/osu.Game/Beatmaps/BeatmapDifficultyCache.cs b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs index d6b4063..c9604e0e58 100644 --- a/osu.Game/Beatmaps/BeatmapDifficultyCache.cs +++ b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs @@ -252,17 +252,17 @@ private void updateBindable(BindableStarDifficulty bindable, IRulesetInfo? rules GetDifficultyAsync(bindable.BeatmapInfo, rulesetInfo, mods, cancellationToken, computationDelay) .ContinueWith(task => { + StarDifficulty? starDifficulty = task.GetResultSafely(); + // We're on a threadpool thread, but we should exit back to the update thread so consumers can safely handle value-changed events. - Schedule(() => + Scheduler.AddDelayed(() => { if (cancellationToken.IsCancellationRequested) return; - StarDifficulty? starDifficulty = task.GetResultSafely(); - if (starDifficulty != null) bindable.Value = starDifficulty.Value; - }); + }, starDifficulty?.Stars > 0 ? 400 : 0); }, cancellationToken); } ``` The goal of the patch is to reorder the write to the bindable in order to trigger the scenario described above. Due to the invasiveness of the patch it is not suitable to add as a test, and chances are that the schedule delay may need to be tweaked if anyone else wants to check that patch. Anyway, the solution here is to use the same pattern of creating a linked cancellation token even on the first retrieval of a bindable difficulty, and registering it in the list of cancellation tokens that already existed to service the ruleset- / mod-tracking flow.
peppy
approved these changes
Apr 3, 2026
This was referenced Apr 6, 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.
Exposed by CI failures (example).
The race occurs when a consumer calls
GetBindableDifficulty()for the first time and then a cache invalidation is triggered. The sequence of events triggering the failure is as follows:GetBindableDifficulty()to get a difficulty bindable for a given beatmap tracking the game-global ruleset / mods. This triggers difficulty calculation A.See below for patch that reproduces this behaviour on my machine 100% reliably:
The goal of the patch is to reorder the write to the bindable in order to trigger the scenario described above.
Due to the invasiveness of the patch it is not suitable to add as a test, and chances are that the schedule delay may need to be tweaked if anyone else wants to check that patch.
Anyway, the solution here is to use the same pattern of creating a linked cancellation token even on the first retrieval of a bindable difficulty, and registering it in the list of cancellation tokens that already existed to service the ruleset- / mod-tracking flow.
Some extra rearranging in 9184299 is needed to ensure the linked tokens created to do this don't stay behind after they are no longer needed for anything.