From 10597f7e6a508de8db6aade5faf6664c22f26d46 Mon Sep 17 00:00:00 2001 From: smoogipoo Date: Thu, 20 May 2021 18:37:27 +0900 Subject: [PATCH 1/6] Remove locking from SpectatorClient --- osu.Game/Online/Spectator/SpectatorClient.cs | 66 +++++++------------- 1 file changed, 23 insertions(+), 43 deletions(-) diff --git a/osu.Game/Online/Spectator/SpectatorClient.cs b/osu.Game/Online/Spectator/SpectatorClient.cs index cb98b01bed..f930328846 100644 --- a/osu.Game/Online/Spectator/SpectatorClient.cs +++ b/osu.Game/Online/Spectator/SpectatorClient.cs @@ -38,8 +38,6 @@ namespace osu.Game.Online.Spectator private readonly List watchingUsers = new List(); - private readonly object userLock = new object(); - public IBindableList PlayingUsers => playingUsers; private readonly BindableList playingUsers = new BindableList(); @@ -81,18 +79,13 @@ namespace osu.Game.Online.Spectator [BackgroundDependencyLoader] private void load() { - IsConnected.BindValueChanged(connected => + IsConnected.BindValueChanged(connected => Schedule(() => { if (connected.NewValue) { // get all the users that were previously being watched - int[] users; - - lock (userLock) - { - users = watchingUsers.ToArray(); - watchingUsers.Clear(); - } + int[] users = watchingUsers.ToArray(); + watchingUsers.Clear(); // resubscribe to watched users. foreach (var userId in users) @@ -104,18 +97,15 @@ namespace osu.Game.Online.Spectator } else { - lock (userLock) - { - playingUsers.Clear(); - playingUserStates.Clear(); - } + playingUsers.Clear(); + playingUserStates.Clear(); } - }, true); + }), true); } Task ISpectatorClient.UserBeganPlaying(int userId, SpectatorState state) { - lock (userLock) + Schedule(() => { if (!playingUsers.Contains(userId)) playingUsers.Add(userId); @@ -125,29 +115,29 @@ namespace osu.Game.Online.Spectator // We don't want the user states to update unless the player is being watched, otherwise calling BindUserBeganPlaying() can lead to double invocations. if (watchingUsers.Contains(userId)) playingUserStates[userId] = state; - } - OnUserBeganPlaying?.Invoke(userId, state); + OnUserBeganPlaying?.Invoke(userId, state); + }); return Task.CompletedTask; } Task ISpectatorClient.UserFinishedPlaying(int userId, SpectatorState state) { - lock (userLock) + Schedule(() => { playingUsers.Remove(userId); playingUserStates.Remove(userId); - } - OnUserFinishedPlaying?.Invoke(userId, state); + OnUserFinishedPlaying?.Invoke(userId, state); + }); return Task.CompletedTask; } Task ISpectatorClient.UserSentFrames(int userId, FrameDataBundle data) { - OnNewFrames?.Invoke(userId, data); + Schedule(() => OnNewFrames?.Invoke(userId, data)); return Task.CompletedTask; } @@ -182,23 +172,17 @@ namespace osu.Game.Online.Spectator public void WatchUser(int userId) { - lock (userLock) - { - if (watchingUsers.Contains(userId)) - return; + if (watchingUsers.Contains(userId)) + return; - watchingUsers.Add(userId); - } + watchingUsers.Add(userId); WatchUserInternal(userId); } public void StopWatchingUser(int userId) { - lock (userLock) - { - watchingUsers.Remove(userId); - } + watchingUsers.Remove(userId); StopWatchingUserInternal(userId); } @@ -262,8 +246,7 @@ namespace osu.Game.Online.Spectator /// true if successful (the user is playing), false otherwise. public bool TryGetPlayingUserState(int userId, out SpectatorState state) { - lock (userLock) - return playingUserStates.TryGetValue(userId, out state); + return playingUserStates.TryGetValue(userId, out state); } /// @@ -274,16 +257,13 @@ namespace osu.Game.Online.Spectator public void BindUserBeganPlaying(Action callback, bool runOnceImmediately = false) { // The lock is taken before the event is subscribed to to prevent doubling of events. - lock (userLock) - { - OnUserBeganPlaying += callback; + OnUserBeganPlaying += callback; - if (!runOnceImmediately) - return; + if (!runOnceImmediately) + return; - foreach (var (userId, state) in playingUserStates) - callback(userId, state); - } + foreach (var (userId, state) in playingUserStates) + callback(userId, state); } } } From f74dbb9e1f2abfafca3bfe4499c5488dc6198c49 Mon Sep 17 00:00:00 2001 From: smoogipoo Date: Thu, 20 May 2021 18:52:20 +0900 Subject: [PATCH 2/6] Remove locking from SpectatorScreen --- osu.Game/Screens/Spectate/SpectatorScreen.cs | 181 ++++++++----------- 1 file changed, 78 insertions(+), 103 deletions(-) diff --git a/osu.Game/Screens/Spectate/SpectatorScreen.cs b/osu.Game/Screens/Spectate/SpectatorScreen.cs index 1cf7bc30ee..e6c9a0acd4 100644 --- a/osu.Game/Screens/Spectate/SpectatorScreen.cs +++ b/osu.Game/Screens/Spectate/SpectatorScreen.cs @@ -42,9 +42,6 @@ namespace osu.Game.Screens.Spectate [Resolved] private UserLookupCache userLookupCache { get; set; } - // A lock is used to synchronise access to spectator/gameplay states, since this class is a screen which may become non-current and stop receiving updates at any point. - private readonly object stateLock = new object(); - private readonly Dictionary userMap = new Dictionary(); private readonly Dictionary gameplayStates = new Dictionary(); @@ -63,8 +60,11 @@ namespace osu.Game.Screens.Spectate { base.LoadComplete(); - populateAllUsers().ContinueWith(_ => Schedule(() => + getAllUsers().ContinueWith(users => Schedule(() => { + foreach (var u in users.Result) + userMap[u.Id] = u; + spectatorClient.BindUserBeganPlaying(userBeganPlaying, true); spectatorClient.OnUserFinishedPlaying += userFinishedPlaying; spectatorClient.OnNewFrames += userSentFrames; @@ -72,27 +72,23 @@ namespace osu.Game.Screens.Spectate managerUpdated = beatmaps.ItemUpdated.GetBoundCopy(); managerUpdated.BindValueChanged(beatmapUpdated); - lock (stateLock) - { - foreach (var (id, _) in userMap) - spectatorClient.WatchUser(id); - } + foreach (var (id, _) in userMap) + spectatorClient.WatchUser(id); })); } - private Task populateAllUsers() + private Task getAllUsers() { - var userLookupTasks = new List(); + var userLookupTasks = new List>(); foreach (var u in userIds) { userLookupTasks.Add(userLookupCache.GetUserAsync(u).ContinueWith(task => { if (!task.IsCompletedSuccessfully) - return; + return null; - lock (stateLock) - userMap[u] = task.Result; + return task.Result; })); } @@ -104,16 +100,13 @@ namespace osu.Game.Screens.Spectate if (!e.NewValue.TryGetTarget(out var beatmapSet)) return; - lock (stateLock) + foreach (var (userId, _) in userMap) { - foreach (var (userId, _) in userMap) - { - if (!spectatorClient.TryGetPlayingUserState(userId, out var userState)) - continue; + if (!spectatorClient.TryGetPlayingUserState(userId, out var userState)) + continue; - if (beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID == userState.BeatmapID)) - updateGameplayState(userId); - } + if (beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID == userState.BeatmapID)) + updateGameplayState(userId); } } @@ -122,101 +115,89 @@ namespace osu.Game.Screens.Spectate if (state.RulesetID == null || state.BeatmapID == null) return; - lock (stateLock) - { - if (!userMap.ContainsKey(userId)) - return; + if (!userMap.ContainsKey(userId)) + return; - // The user may have stopped playing. - if (!spectatorClient.TryGetPlayingUserState(userId, out _)) - return; + // The user may have stopped playing. + if (!spectatorClient.TryGetPlayingUserState(userId, out _)) + return; - Schedule(() => OnUserStateChanged(userId, state)); + Schedule(() => OnUserStateChanged(userId, state)); - updateGameplayState(userId); - } + updateGameplayState(userId); } private void updateGameplayState(int userId) { - lock (stateLock) + Debug.Assert(userMap.ContainsKey(userId)); + + // The user may have stopped playing. + if (!spectatorClient.TryGetPlayingUserState(userId, out var spectatorState)) + return; + + var user = userMap[userId]; + + var resolvedRuleset = rulesets.AvailableRulesets.FirstOrDefault(r => r.ID == spectatorState.RulesetID)?.CreateInstance(); + if (resolvedRuleset == null) + return; + + var resolvedBeatmap = beatmaps.QueryBeatmap(b => b.OnlineBeatmapID == spectatorState.BeatmapID); + if (resolvedBeatmap == null) + return; + + var score = new Score { - Debug.Assert(userMap.ContainsKey(userId)); - - // The user may have stopped playing. - if (!spectatorClient.TryGetPlayingUserState(userId, out var spectatorState)) - return; - - var user = userMap[userId]; - - var resolvedRuleset = rulesets.AvailableRulesets.FirstOrDefault(r => r.ID == spectatorState.RulesetID)?.CreateInstance(); - if (resolvedRuleset == null) - return; - - var resolvedBeatmap = beatmaps.QueryBeatmap(b => b.OnlineBeatmapID == spectatorState.BeatmapID); - if (resolvedBeatmap == null) - return; - - var score = new Score + ScoreInfo = new ScoreInfo { - ScoreInfo = new ScoreInfo - { - Beatmap = resolvedBeatmap, - User = user, - Mods = spectatorState.Mods.Select(m => m.ToMod(resolvedRuleset)).ToArray(), - Ruleset = resolvedRuleset.RulesetInfo, - }, - Replay = new Replay { HasReceivedAllFrames = false }, - }; + Beatmap = resolvedBeatmap, + User = user, + Mods = spectatorState.Mods.Select(m => m.ToMod(resolvedRuleset)).ToArray(), + Ruleset = resolvedRuleset.RulesetInfo, + }, + Replay = new Replay { HasReceivedAllFrames = false }, + }; - var gameplayState = new GameplayState(score, resolvedRuleset, beatmaps.GetWorkingBeatmap(resolvedBeatmap)); + var gameplayState = new GameplayState(score, resolvedRuleset, beatmaps.GetWorkingBeatmap(resolvedBeatmap)); - gameplayStates[userId] = gameplayState; - Schedule(() => StartGameplay(userId, gameplayState)); - } + gameplayStates[userId] = gameplayState; + Schedule(() => StartGameplay(userId, gameplayState)); } private void userSentFrames(int userId, FrameDataBundle bundle) { - lock (stateLock) + if (!userMap.ContainsKey(userId)) + return; + + if (!gameplayStates.TryGetValue(userId, out var gameplayState)) + return; + + // The ruleset instance should be guaranteed to be in sync with the score via ScoreLock. + Debug.Assert(gameplayState.Ruleset != null && gameplayState.Ruleset.RulesetInfo.Equals(gameplayState.Score.ScoreInfo.Ruleset)); + + foreach (var frame in bundle.Frames) { - if (!userMap.ContainsKey(userId)) - return; + IConvertibleReplayFrame convertibleFrame = gameplayState.Ruleset.CreateConvertibleReplayFrame(); + convertibleFrame.FromLegacy(frame, gameplayState.Beatmap.Beatmap); - if (!gameplayStates.TryGetValue(userId, out var gameplayState)) - return; + var convertedFrame = (ReplayFrame)convertibleFrame; + convertedFrame.Time = frame.Time; - // The ruleset instance should be guaranteed to be in sync with the score via ScoreLock. - Debug.Assert(gameplayState.Ruleset != null && gameplayState.Ruleset.RulesetInfo.Equals(gameplayState.Score.ScoreInfo.Ruleset)); - - foreach (var frame in bundle.Frames) - { - IConvertibleReplayFrame convertibleFrame = gameplayState.Ruleset.CreateConvertibleReplayFrame(); - convertibleFrame.FromLegacy(frame, gameplayState.Beatmap.Beatmap); - - var convertedFrame = (ReplayFrame)convertibleFrame; - convertedFrame.Time = frame.Time; - - gameplayState.Score.Replay.Frames.Add(convertedFrame); - } + gameplayState.Score.Replay.Frames.Add(convertedFrame); } } private void userFinishedPlaying(int userId, SpectatorState state) { - lock (stateLock) - { - if (!userMap.ContainsKey(userId)) - return; + if (!userMap.ContainsKey(userId)) + return; - if (!gameplayStates.TryGetValue(userId, out var gameplayState)) - return; + if (!gameplayStates.TryGetValue(userId, out var gameplayState)) + return; - gameplayState.Score.Replay.HasReceivedAllFrames = true; + gameplayState.Score.Replay.HasReceivedAllFrames = true; - gameplayStates.Remove(userId); - Schedule(() => EndGameplay(userId)); - } + gameplayStates.Remove(userId); + Schedule(() => EndGameplay(userId)); } /// @@ -245,15 +226,12 @@ namespace osu.Game.Screens.Spectate /// The user to stop spectating. protected void RemoveUser(int userId) { - lock (stateLock) - { - userFinishedPlaying(userId, null); + userFinishedPlaying(userId, null); - userIds.Remove(userId); - userMap.Remove(userId); + userIds.Remove(userId); + userMap.Remove(userId); - spectatorClient.StopWatchingUser(userId); - } + spectatorClient.StopWatchingUser(userId); } protected override void Dispose(bool isDisposing) @@ -266,11 +244,8 @@ namespace osu.Game.Screens.Spectate spectatorClient.OnUserFinishedPlaying -= userFinishedPlaying; spectatorClient.OnNewFrames -= userSentFrames; - lock (stateLock) - { - foreach (var (userId, _) in userMap) - spectatorClient.StopWatchingUser(userId); - } + foreach (var (userId, _) in userMap) + spectatorClient.StopWatchingUser(userId); } managerUpdated?.UnbindAll(); From 5a8b8782d34e76faaad394c42cdd597289acf633 Mon Sep 17 00:00:00 2001 From: smoogipoo Date: Thu, 20 May 2021 19:44:43 +0900 Subject: [PATCH 3/6] Fix WatchUser being called asynchronously in BDL --- osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs index ed83bbf693..c3bfe19b29 100644 --- a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs +++ b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs @@ -55,8 +55,6 @@ namespace osu.Game.Screens.Play.HUD foreach (var userId in playingUsers) { - spectatorClient.WatchUser(userId); - // probably won't be required in the final implementation. var resolvedUser = userLookupCache.GetUserAsync(userId).Result; @@ -80,6 +78,8 @@ namespace osu.Game.Screens.Play.HUD // BindableList handles binding in a really bad way (Clear then AddRange) so we need to do this manually.. foreach (int userId in playingUsers) { + spectatorClient.WatchUser(userId); + if (!multiplayerClient.CurrentMatchPlayingUserIds.Contains(userId)) usersChanged(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, new[] { userId })); } From 06c99e8c7c259e8ffb712ff5320163b38e0f4aa3 Mon Sep 17 00:00:00 2001 From: smoogipoo Date: Thu, 20 May 2021 19:45:11 +0900 Subject: [PATCH 4/6] Fix race due to StopWatchingUser() being called asynchronously --- osu.Game/Online/Spectator/SpectatorClient.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/osu.Game/Online/Spectator/SpectatorClient.cs b/osu.Game/Online/Spectator/SpectatorClient.cs index f930328846..de5e57a1d0 100644 --- a/osu.Game/Online/Spectator/SpectatorClient.cs +++ b/osu.Game/Online/Spectator/SpectatorClient.cs @@ -182,9 +182,13 @@ namespace osu.Game.Online.Spectator public void StopWatchingUser(int userId) { - watchingUsers.Remove(userId); - - StopWatchingUserInternal(userId); + // This method is most commonly called via Dispose(), which is asynchronous. + // Todo: This should not be a thing, but requires framework changes. + Schedule(() => + { + watchingUsers.Remove(userId); + StopWatchingUserInternal(userId); + }); } protected abstract Task BeginPlayingInternal(SpectatorState state); From 2fdf8aa1aa49da528b1fb3d7811a957435b7be3d Mon Sep 17 00:00:00 2001 From: smoogipoo Date: Fri, 21 May 2021 15:57:31 +0900 Subject: [PATCH 5/6] Add update thread assertions --- osu.Game/Online/Spectator/SpectatorClient.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/osu.Game/Online/Spectator/SpectatorClient.cs b/osu.Game/Online/Spectator/SpectatorClient.cs index de5e57a1d0..b90fec09d6 100644 --- a/osu.Game/Online/Spectator/SpectatorClient.cs +++ b/osu.Game/Online/Spectator/SpectatorClient.cs @@ -10,6 +10,7 @@ using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Development; using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Online.API; @@ -144,6 +145,8 @@ namespace osu.Game.Online.Spectator public void BeginPlaying(GameplayBeatmap beatmap, Score score) { + Debug.Assert(ThreadSafety.IsUpdateThread); + if (IsPlaying) throw new InvalidOperationException($"Cannot invoke {nameof(BeginPlaying)} when already playing"); @@ -172,6 +175,8 @@ namespace osu.Game.Online.Spectator public void WatchUser(int userId) { + Debug.Assert(ThreadSafety.IsUpdateThread); + if (watchingUsers.Contains(userId)) return; @@ -219,6 +224,8 @@ namespace osu.Game.Online.Spectator public void HandleFrame(ReplayFrame frame) { + Debug.Assert(ThreadSafety.IsUpdateThread); + if (frame is IConvertibleReplayFrame convertible) pendingFrames.Enqueue(convertible.ToLegacy(currentBeatmap)); From 7f712a4d04b64b4f9801208048f30f2a272b0a4e Mon Sep 17 00:00:00 2001 From: smoogipoo Date: Fri, 21 May 2021 15:57:39 +0900 Subject: [PATCH 6/6] Fix EndPlaying potentially doing cross-thread mutation --- osu.Game/Online/Spectator/SpectatorClient.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/osu.Game/Online/Spectator/SpectatorClient.cs b/osu.Game/Online/Spectator/SpectatorClient.cs index b90fec09d6..48e2528528 100644 --- a/osu.Game/Online/Spectator/SpectatorClient.cs +++ b/osu.Game/Online/Spectator/SpectatorClient.cs @@ -167,10 +167,15 @@ namespace osu.Game.Online.Spectator public void EndPlaying() { - IsPlaying = false; - currentBeatmap = null; + // This method is most commonly called via Dispose(), which is asynchronous. + // Todo: This should not be a thing, but requires framework changes. + Schedule(() => + { + IsPlaying = false; + currentBeatmap = null; - EndPlayingInternal(currentState); + EndPlayingInternal(currentState); + }); } public void WatchUser(int userId)