From 85bbe13aa969f8368f91591c5d6590410e11d76c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Fri, 10 Oct 2025 08:48:18 +0200 Subject: [PATCH 1/6] Move realm refetches of beatmap in song select wedges off of update thread From local testing on release build (such that online beatmaps are accessible) with a large database it seems that maybe this'll help with recurrent complaints of 'stutters'. Co-authored-by: Dean Herbert --- .../Screens/SelectV2/BeatmapMetadataWedge.cs | 46 +++++++++++++------ .../Screens/SelectV2/BeatmapTitleWedge.cs | 46 +++++++++++++------ 2 files changed, 65 insertions(+), 27 deletions(-) diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs index 818176b3c4..4fd678407a 100644 --- a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs @@ -3,6 +3,8 @@ using System; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -402,24 +404,40 @@ namespace osu.Game.Screens.SelectV2 updateSubWedgeVisibility(); } + private CancellationTokenSource? userTagsCancellationSource; + private void updateUserTags() { - string[] tags = realm.Run(r => - { - // need to refetch because `beatmap.Value.BeatmapInfo` is not going to have the latest tags - r.Refresh(); - var refetchedBeatmap = r.Find(beatmap.Value.BeatmapInfo.ID); - return refetchedBeatmap?.Metadata.UserTags.ToArray() ?? []; - }); + userTagsCancellationSource?.Cancel(); + userTagsCancellationSource = new CancellationTokenSource(); - if (tags.Length == 0) - { - userTags.FadeOut(transition_duration, Easing.OutQuint); - return; - } + var token = userTagsCancellationSource.Token; - userTags.FadeIn(transition_duration, Easing.OutQuint); - userTags.Tags = (tags, t => songSelect?.Search($@"tag=""{t}""!")); + Task.Run(() => + { + string[] tags = realm.Run(r => + { + // need to refetch because `beatmap.Value.BeatmapInfo` is not going to have the latest tags + r.Refresh(); + var refetchedBeatmap = r.Find(beatmap.Value.BeatmapInfo.ID); + return refetchedBeatmap?.Metadata.UserTags.ToArray() ?? []; + }); + + Schedule(() => + { + if (token.IsCancellationRequested) + return; + + if (tags.Length == 0) + { + userTags.FadeOut(transition_duration, Easing.OutQuint); + return; + } + + userTags.FadeIn(transition_duration, Easing.OutQuint); + userTags.Tags = (tags, t => songSelect?.Search($@"tag=""{t}""!")); + }); + }, token); } } } diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs index 21ac04b18a..427466e366 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs @@ -278,8 +278,13 @@ namespace osu.Game.Screens.SelectV2 }, token); } + private CancellationTokenSource? onlineDisplayCancellationSource; + private void updateOnlineDisplay() { + onlineDisplayCancellationSource?.Cancel(); + onlineDisplayCancellationSource = null; + if (onlineLookupResult.Value?.Status != SongSelect.BeatmapSetLookupStatus.Completed) { playCount.Value = null; @@ -291,20 +296,35 @@ namespace osu.Game.Screens.SelectV2 playCount.Value = new StatisticPlayCount.Data(onlineBeatmap?.PlayCount ?? -1, onlineBeatmap?.UserPlayCount ?? -1); favouriteButton.SetBeatmapSet(onlineLookupResult.Value.Result); - // the online fetch may have also updated the beatmap's status. - // this needs to be checked against the *local* beatmap model rather than the online one, because it's not known here whether the status change has occurred or not - // (think scenarios like the beatmap being locally modified). - // it also has to be handled explicitly like this because the working beatmap's `BeatmapInfo` will not receive these updates due to being detached - // (and because of https://github.com/ppy/osu/blob/4b73afd1957a9161e2956fc4191c8114d9958372/osu.Game/Screens/SelectV2/SongSelect.cs#L487-L488 - // which prevents working beatmap refetches caused by changes to the realm model of perceived low importance). - var status = realm.Run(r => + onlineDisplayCancellationSource = new CancellationTokenSource(); + var token = onlineDisplayCancellationSource.Token; + + Task.Run(() => { - r.Refresh(); - var refetchedBeatmap = r.Find(working.Value.BeatmapInfo.ID); - return refetchedBeatmap?.Status; - }); - if (status != null) - statusPill.Status = status.Value; + // the online fetch may have also updated the beatmap's status. + // this needs to be checked against the *local* beatmap model rather than the online one, because it's not known here whether the status change has occurred or not + // (think scenarios like the beatmap being locally modified). + // it also has to be handled explicitly like this because the working beatmap's `BeatmapInfo` will not receive these updates due to being detached + // (and because of https://github.com/ppy/osu/blob/4b73afd1957a9161e2956fc4191c8114d9958372/osu.Game/Screens/SelectV2/SongSelect.cs#L487-L488 + // which prevents working beatmap refetches caused by changes to the realm model of perceived low importance). + var status = realm.Run(r => + { + r.Refresh(); + var refetchedBeatmap = r.Find(working.Value.BeatmapInfo.ID); + return refetchedBeatmap?.Status; + }); + + if (status != null) + { + Schedule(() => + { + if (token.IsCancellationRequested) + return; + + statusPill.Status = status.Value; + }); + } + }, token); } } } From ca4c033b7623f8cb17a21ff54d8882b59f6305b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Fri, 10 Oct 2025 09:20:23 +0200 Subject: [PATCH 2/6] Remove redundant refresh calls --- osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs | 1 - osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs | 1 - 2 files changed, 2 deletions(-) diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs index 4fd678407a..95bf907d55 100644 --- a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs @@ -418,7 +418,6 @@ namespace osu.Game.Screens.SelectV2 string[] tags = realm.Run(r => { // need to refetch because `beatmap.Value.BeatmapInfo` is not going to have the latest tags - r.Refresh(); var refetchedBeatmap = r.Find(beatmap.Value.BeatmapInfo.ID); return refetchedBeatmap?.Metadata.UserTags.ToArray() ?? []; }); diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs index 427466e366..08da89f9f3 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs @@ -309,7 +309,6 @@ namespace osu.Game.Screens.SelectV2 // which prevents working beatmap refetches caused by changes to the realm model of perceived low importance). var status = realm.Run(r => { - r.Refresh(); var refetchedBeatmap = r.Find(working.Value.BeatmapInfo.ID); return refetchedBeatmap?.Status; }); From 0389da4559ddb099e99459f660555db5384f40a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Fri, 10 Oct 2025 10:30:31 +0200 Subject: [PATCH 3/6] Attempt to abort realm accesses on disposal --- osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs | 7 +++++++ osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs index 95bf907d55..01762e3231 100644 --- a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs @@ -438,5 +438,12 @@ namespace osu.Game.Screens.SelectV2 }); }, token); } + + protected override void Dispose(bool isDisposing) + { + userTagsCancellationSource?.Cancel(); + userTagsCancellationSource = null; + base.Dispose(isDisposing); + } } } diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs index 08da89f9f3..dfe8dd84de 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs @@ -326,5 +326,12 @@ namespace osu.Game.Screens.SelectV2 }, token); } } + + protected override void Dispose(bool isDisposing) + { + onlineDisplayCancellationSource?.Dispose(); + onlineDisplayCancellationSource = null; + base.Dispose(isDisposing); + } } } From 0b00191d713e0335a0bb0af34965a082b70757f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Fri, 10 Oct 2025 12:06:57 +0200 Subject: [PATCH 4/6] Check for cancellation EVEN MORE aggressively --- osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs | 3 +++ osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs | 3 +++ 2 files changed, 6 insertions(+) diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs index 01762e3231..b4f378d21b 100644 --- a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs @@ -415,6 +415,9 @@ namespace osu.Game.Screens.SelectV2 Task.Run(() => { + if (token.IsCancellationRequested) + return; + string[] tags = realm.Run(r => { // need to refetch because `beatmap.Value.BeatmapInfo` is not going to have the latest tags diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs index dfe8dd84de..43824f7dc0 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs @@ -301,6 +301,9 @@ namespace osu.Game.Screens.SelectV2 Task.Run(() => { + if (token.IsCancellationRequested) + return; + // the online fetch may have also updated the beatmap's status. // this needs to be checked against the *local* beatmap model rather than the online one, because it's not known here whether the status change has occurred or not // (think scenarios like the beatmap being locally modified). From 97739c39e7896fdc98153872ee3978d646ffca0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 13 Oct 2025 09:27:18 +0200 Subject: [PATCH 5/6] Attempt to ensure `RealmAccess` waits for the async read before disposing itself --- osu.Game/Database/RealmAccess.cs | 42 +++++++++++++++---- .../Screens/SelectV2/BeatmapMetadataWedge.cs | 19 ++++----- .../Screens/SelectV2/BeatmapTitleWedge.cs | 28 ++++++------- 3 files changed, 54 insertions(+), 35 deletions(-) diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs index 17f4068fc4..fa54ed538a 100644 --- a/osu.Game/Database/RealmAccess.cs +++ b/osu.Game/Database/RealmAccess.cs @@ -109,6 +109,8 @@ namespace osu.Game.Database /// private readonly SemaphoreSlim realmRetrievalLock = new SemaphoreSlim(1); + private readonly CountdownEvent pendingAsyncOperations = new CountdownEvent(0); + /// /// true when the current thread has already entered the . /// @@ -467,6 +469,30 @@ namespace osu.Game.Database } } + /// + /// Run work on realm on a TPL thread, in a way that ensures that the realm isn't disposed before the work is done. + /// + public Task RunAsync(Func action, CancellationToken token = default) + { + ObjectDisposedException.ThrowIf(isDisposed, this); + + // Required to ensure the read is tracked and accounted for before disposal. + // Can potentially be avoided if we have a need to do so in the future. + if (!ThreadSafety.IsUpdateThread) + throw new InvalidOperationException($@"{nameof(RunAsync)} must be called from the update thread."); + + // CountdownEvent will fail if already at zero. + if (!pendingAsyncOperations.TryAddCount()) + pendingAsyncOperations.Reset(1); + + return Task.Run(() => + { + var result = Run(action); + pendingAsyncOperations.Signal(); + return result; + }, token); + } + /// /// Write changes to realm. /// @@ -507,8 +533,6 @@ namespace osu.Game.Database } } - private readonly CountdownEvent pendingAsyncWrites = new CountdownEvent(0); - /// /// Write changes to realm asynchronously, guaranteeing order of execution. /// @@ -523,8 +547,8 @@ namespace osu.Game.Database throw new InvalidOperationException(@$"{nameof(WriteAsync)} must be called from the update thread."); // CountdownEvent will fail if already at zero. - if (!pendingAsyncWrites.TryAddCount()) - pendingAsyncWrites.Reset(1); + if (!pendingAsyncOperations.TryAddCount()) + pendingAsyncOperations.Reset(1); // Regardless of calling Realm.GetInstance or Realm.GetInstanceAsync, there is a blocking overhead on retrieval. // Adding a forced Task.Run resolves this. @@ -539,7 +563,7 @@ namespace osu.Game.Database // ReSharper disable once AccessToDisposedClosure (WriteAsync should be marked as [InstantHandle]). await realm.WriteAsync(() => action(realm)).ConfigureAwait(false); - pendingAsyncWrites.Signal(); + pendingAsyncOperations.Signal(); }); return writeTask; @@ -559,8 +583,8 @@ namespace osu.Game.Database throw new InvalidOperationException(@$"{nameof(WriteAsync)} must be called from the update thread."); // CountdownEvent will fail if already at zero. - if (!pendingAsyncWrites.TryAddCount()) - pendingAsyncWrites.Reset(1); + if (!pendingAsyncOperations.TryAddCount()) + pendingAsyncOperations.Reset(1); // Regardless of calling Realm.GetInstance or Realm.GetInstanceAsync, there is a blocking overhead on retrieval. // Adding a forced Task.Run resolves this. @@ -576,7 +600,7 @@ namespace osu.Game.Database // ReSharper disable once AccessToDisposedClosure (WriteAsync should be marked as [InstantHandle]). result = await realm.WriteAsync(() => action(realm)).ConfigureAwait(false); - pendingAsyncWrites.Signal(); + pendingAsyncOperations.Signal(); return result; }); @@ -1494,7 +1518,7 @@ namespace osu.Game.Database public void Dispose() { - if (!pendingAsyncWrites.Wait(10000)) + if (!pendingAsyncOperations.Wait(10000)) Logger.Log("Realm took too long waiting on pending async writes", level: LogLevel.Error); updateRealm?.Dispose(); diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs index b4f378d21b..b275386b9d 100644 --- a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs @@ -4,11 +4,11 @@ using System; using System.Linq; using System.Threading; -using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Utils; @@ -413,17 +413,14 @@ namespace osu.Game.Screens.SelectV2 var token = userTagsCancellationSource.Token; - Task.Run(() => + realm.RunAsync(r => { - if (token.IsCancellationRequested) - return; - - string[] tags = realm.Run(r => - { - // need to refetch because `beatmap.Value.BeatmapInfo` is not going to have the latest tags - var refetchedBeatmap = r.Find(beatmap.Value.BeatmapInfo.ID); - return refetchedBeatmap?.Metadata.UserTags.ToArray() ?? []; - }); + // need to refetch because `beatmap.Value.BeatmapInfo` is not going to have the latest tags + var refetchedBeatmap = r.Find(beatmap.Value.BeatmapInfo.ID); + return refetchedBeatmap?.Metadata.UserTags.ToArray() ?? []; + }, token).ContinueWith(t => + { + string[] tags = t.GetResultSafely(); Schedule(() => { diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs index 43824f7dc0..530b1348dd 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs @@ -8,6 +8,7 @@ using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Localisation; @@ -299,22 +300,19 @@ namespace osu.Game.Screens.SelectV2 onlineDisplayCancellationSource = new CancellationTokenSource(); var token = onlineDisplayCancellationSource.Token; - Task.Run(() => + // the online fetch may have also updated the beatmap's status. + // this needs to be checked against the *local* beatmap model rather than the online one, because it's not known here whether the status change has occurred or not + // (think scenarios like the beatmap being locally modified). + // it also has to be handled explicitly like this because the working beatmap's `BeatmapInfo` will not receive these updates due to being detached + // (and because of https://github.com/ppy/osu/blob/4b73afd1957a9161e2956fc4191c8114d9958372/osu.Game/Screens/SelectV2/SongSelect.cs#L487-L488 + // which prevents working beatmap refetches caused by changes to the realm model of perceived low importance). + realm.RunAsync(r => { - if (token.IsCancellationRequested) - return; - - // the online fetch may have also updated the beatmap's status. - // this needs to be checked against the *local* beatmap model rather than the online one, because it's not known here whether the status change has occurred or not - // (think scenarios like the beatmap being locally modified). - // it also has to be handled explicitly like this because the working beatmap's `BeatmapInfo` will not receive these updates due to being detached - // (and because of https://github.com/ppy/osu/blob/4b73afd1957a9161e2956fc4191c8114d9958372/osu.Game/Screens/SelectV2/SongSelect.cs#L487-L488 - // which prevents working beatmap refetches caused by changes to the realm model of perceived low importance). - var status = realm.Run(r => - { - var refetchedBeatmap = r.Find(working.Value.BeatmapInfo.ID); - return refetchedBeatmap?.Status; - }); + var refetchedBeatmap = r.Find(working.Value.BeatmapInfo.ID); + return refetchedBeatmap?.Status; + }, token).ContinueWith(t => + { + var status = t.GetResultSafely(); if (status != null) { From a73c3252350153ca04c5027bee1bdb7b2c24f499 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 13 Oct 2025 10:19:19 +0200 Subject: [PATCH 6/6] Fix code quality --- osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs index b275386b9d..d516f4b846 100644 --- a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs @@ -434,7 +434,7 @@ namespace osu.Game.Screens.SelectV2 } userTags.FadeIn(transition_duration, Easing.OutQuint); - userTags.Tags = (tags, t => songSelect?.Search($@"tag=""{t}""!")); + userTags.Tags = (tags, tag => songSelect?.Search($@"tag=""{tag}""!")); }); }, token); }