Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Notify users of who is spectating them #259

Merged
merged 7 commits into from
Jan 23, 2025
Merged

Conversation

bdach
Copy link
Collaborator

@bdach bdach commented Jan 15, 2025

Server-side part of ppy/osu#22087.

@smoogipoo
Copy link
Contributor

Don't you need to cleanup the usage on the external EntityStore in CleanUpState()?

@bdach
Copy link
Collaborator Author

bdach commented Jan 17, 2025

You're right, there has to be cleanup, but a very annoying sort of cleanup because I would need to remove the user that disconnected from any and all spectator lists. Which would mean iterating over every single entry in the store.

Not sure how to fix this. I feel like if I write that the naive way then things will inevitably blow up...

@bdach
Copy link
Collaborator Author

bdach commented Jan 17, 2025

The only thing I can immediately think of that would fix the concern would be to make the spectators lists bidirectionally associative, something like so (very untested):

diff --git a/osu.Server.Spectator/Hubs/Spectator/SpectatorHub.cs b/osu.Server.Spectator/Hubs/Spectator/SpectatorHub.cs
index fdc5139..b40c467 100644
--- a/osu.Server.Spectator/Hubs/Spectator/SpectatorHub.cs
+++ b/osu.Server.Spectator/Hubs/Spectator/SpectatorHub.cs
@@ -221,10 +221,16 @@ namespace osu.Server.Spectator.Hubs.Spectator
                 Username = watcherUsername,
             };
 
-            using (var usage = await spectatorLists.GetForUse(userId, createOnMissing: true))
+            using (var streamerUsage = await spectatorLists.GetForUse(userId, createOnMissing: true))
             {
-                usage.Item ??= new SpectatorList();
-                usage.Item.Spectators[watcherId] = watcher;
+                streamerUsage.Item ??= new SpectatorList();
+                streamerUsage.Item.WatchedBy[watcherId] = watcher;
+            }
+
+            using (var watcherUsage = await spectatorLists.GetForUse(watcherId, createOnMissing: true))
+            {
+                watcherUsage.Item ??= new SpectatorList();
+                watcherUsage.Item.Watching.Add(userId);
             }
 
             await Clients.User(userId.ToString()).UserStartedWatching([watcher]);
@@ -241,8 +247,8 @@ namespace osu.Server.Spectator.Hubs.Spectator
                 if (usage?.Item == null)
                     return;
 
-                usage.Item.Spectators.Remove(watcherId);
-                if (usage.Item.Spectators.Count == 0)
+                usage.Item.WatchedBy.Remove(watcherId);
+                if (usage.Item.WatchedBy.Count == 0 && usage.Item.Watching.Count == 0)
                     usage.Destroy();
             }
 
@@ -261,7 +267,7 @@ namespace osu.Server.Spectator.Hubs.Spectator
             using (var usage = await spectatorLists.TryGetForUse(Context.GetUserId()))
             {
                 if (usage?.Item != null)
-                    watchers = usage.Item.Spectators.Values.ToArray();
+                    watchers = usage.Item.WatchedBy.Values.ToArray();
             }
 
             if (watchers != null)
@@ -275,6 +281,31 @@ namespace osu.Server.Spectator.Hubs.Spectator
             if (state.State != null)
                 await endPlaySession(state.UserId, state.State);
 
+            int userId = Context.GetUserId();
+            var usersWatched = new HashSet<int>();
+
+            using (var usage = await spectatorLists.TryGetForUse(userId))
+            {
+                if (usage?.Item != null)
+                {
+                    usersWatched.UnionWith(usage.Item.Watching);
+                    usage.Destroy();
+                }
+            }
+
+            foreach (int watchedUser in usersWatched)
+            {
+                using (var usage = await spectatorLists.TryGetForUse(watchedUser))
+                {
+                    if (usage?.Item != null)
+                    {
+                        usage.Item.WatchedBy.Remove(watchedUser);
+                        if (usage.Item.WatchedBy.Count == 0 && usage.Item.Watching.Count == 0)
+                            usage.Destroy();
+                    }
+                }
+            }
+
             await base.CleanUpState(state);
         }
 
diff --git a/osu.Server.Spectator/Hubs/Spectator/SpectatorList.cs b/osu.Server.Spectator/Hubs/Spectator/SpectatorList.cs
index 49540bd..952ea97 100644
--- a/osu.Server.Spectator/Hubs/Spectator/SpectatorList.cs
+++ b/osu.Server.Spectator/Hubs/Spectator/SpectatorList.cs
@@ -8,6 +8,7 @@ namespace osu.Server.Spectator.Hubs.Spectator
 {
     public class SpectatorList
     {
-        public Dictionary<int, SpectatorUser> Spectators { get; } = new Dictionary<int, SpectatorUser>();
+        public Dictionary<int, SpectatorUser> WatchedBy { get; } = new Dictionary<int, SpectatorUser>();
+        public HashSet<int> Watching { get; } = new HashSet<int>();
     }
 }

But I dunno if this is a good idea, because the CleanUpState() operation looks sketchy from a concurrency perspective... Like taking those locks fine-grained for every user to clean up looks real dodgy...

@smoogipoo
Copy link
Contributor

It seems to me like the entire complexity of this is related to the OnConnected() notification, so how about just not doing it? Is there anything wrong with requiring users to start re-spectating when a user connects again?

@peppy
Copy link
Member

peppy commented Jan 17, 2025

I think adding an id of who you are watching to SpectatorClientState and using that during cleanup to remove self should suffice? It will mean keeping a SpectatorClientState around for users which are watching rather than just users which are playing, but that already seems to be handled well enough (the state won't be sent to clients unless the internal Item.State is non-null).

@bdach
Copy link
Collaborator Author

bdach commented Jan 17, 2025

It seems to me like the entire complexity of this is related to the OnConnected() notification, so how about just not doing it? Is there anything wrong with requiring users to start re-spectating when a user connects again?

Yes, correct, it is incurred by that. Initially I didn't bother, but then realised that, say, if the streamer's connection to spectator drops out and then the streamer reconnects, they won't see their spectators anymore unless the spectators... re-spectate? Even though they are in fact actually spectating, which felt really dodgy to me.

That said if we don't care I would be very happy to drop all of that.

@peppy
Copy link
Member

peppy commented Jan 17, 2025

they won't see their spectators anymore unless the spectators... re-spectate? Even though they are in fact actually spectating, which felt really dodgy to me.

If we're going down that path, users would have to be forcefully removed from the spectator channel, with the client doing the reconnection. I agree that this will look very dodgy otherwise.

@smoogipoo
Copy link
Contributor

Sure, that sounds easier to do given there's precedence (OnConnectedAsync()). Just remove all users from the spectating group.

unless the spectators... re-spectate?

I would expect this in any case.

@bdach
Copy link
Collaborator Author

bdach commented Jan 17, 2025

If we're going down that path, users would have to be forcefully removed from the spectator channel, with the client doing the reconnection. I agree that this will look very dodgy otherwise.

Sure, that sounds easier to do given there's precedence (OnConnectedAsync()). Just remove all users from the spectating group.

My follow up question to these two remarks would be: should it be at all supported to spectate users that aren't currently online, then? Because currently that is supported, and from this angle it's basically the same scenario too. And it was explicitly intended that you should be able to do that, there are references to this in code...

Not sure typing is effective to continue this discussion forward at this point, maybe voice?

@smoogipoo
Copy link
Contributor

It should not be supported, and it's also incorrect that we support that for appear-offline users too.

@peppy
Copy link
Member

peppy commented Jan 17, 2025

Stable also doesn't support spectating offline users so I think it's fine to not support that.

Although I would hope that the client will sit at the spectating screen if the user you're spectating goes offline, and re-engages if they come back online.

@bdach
Copy link
Collaborator Author

bdach commented Jan 17, 2025

Although I would hope that the client will sit at the spectating screen if the user you're spectating goes offline, and re-engages if they come back online.

Being able to remain in a watcher group for an offline user is what facilitates that right now. And it also creates my problem in this pull of having to resend the list of spectators on connect, therefore incurring all of the tricky and annoying tracking.

The proposal to simplify this pull was to clear the spectator group on disconnect and not support watching offline users anymore, at which point how are the spectators supposed to know that the user has come back online to re-engage? I'd have to push that responsibility to the client somehow I guess? Which in turn may make #262 tricky or outright impossible to achieve?

@smoogipoo
Copy link
Contributor

We're likely going to need to track specific user states one way or another, as has been mentioned IRL and also ppy/osu#31524 (comment)

@bdach
Copy link
Collaborator Author

bdach commented Jan 17, 2025

I think we're gonna need some common agreement and direction and then implementation coordination here because all of the spectator issues & changes have become so entangled that it is no longer clear to me who should do what to progress any of these PRs.

I guess my bad for encroaching on the online systems work with this 🙇

@peppy
Copy link
Member

peppy commented Jan 17, 2025

Subscribing to all user states when at the spectator screen sounds like a valid solution which will not require too much effort. The most complex part I can think of is managing the "should we listen" state across two screens that can potentially be visible at the same time (and that can be solved).

In discussion it appears that the consensus is that the player should
not be able to spectate offline players, and as such the server is
allowed to purge the list of spectators on disconnection of the
streamer. This in turn obviates the need of doing any external tracking
of who's spectating whom in the hub.
@bdach
Copy link
Collaborator Author

bdach commented Jan 17, 2025

For now, in the interest of keeping this PR as small, self-contained, and conflict-free as possible, I've removed the problematic tracking logic, with the implicit assumption that the ability of spectating offline players will be removed separately in another PR. It's out of scope of this one either way.

If you want me to look into making that change to spectator logic then let me know.

@peppy
Copy link
Member

peppy commented Jan 21, 2025

I've removed the problematic tracking logic, with the implicit assumption that the ability of spectating offline players will be removed separately in another PR. It's out of scope of this one either way.

At very least let's make a tracking issue.

@bdach
Copy link
Collaborator Author

bdach commented Jan 21, 2025

At very least let's make a tracking issue.

Done: #263

Copy link
Contributor

@smoogipoo smoogipoo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lgtm, awaiting new osu.Game package.

@peppy
Copy link
Member

peppy commented Jan 22, 2025

One important thing here is that we will now be left with ghost spectators if a user is non-gracefully disconnected (ie. if their client doesn't send EndWatchingUser before disconnecting).

Unlike the other case discussed above, I do see this one as a blocker.

I think a good way forward may be to do tracking inverse to the original PR's code, and keep it in the existing state just to reduce the complexity of the entity store setups:

diff --git a/osu.Server.Spectator/Hubs/Spectator/SpectatorClientState.cs b/osu.Server.Spectator/Hubs/Spectator/SpectatorClientState.cs
index ce6f200..f2b1151 100644
--- a/osu.Server.Spectator/Hubs/Spectator/SpectatorClientState.cs
+++ b/osu.Server.Spectator/Hubs/Spectator/SpectatorClientState.cs
@@ -2,6 +2,7 @@
 // See the LICENCE file in the repository root for full licence text.
 
 using System;
+using System.Collections.Generic;
 using Newtonsoft.Json;
 using osu.Game.Online.Spectator;
 using osu.Game.Scoring;
@@ -26,6 +27,11 @@ namespace osu.Server.Spectator.Hubs.Spectator
         /// </summary>
         public long? ScoreToken;
 
+        /// <summary>
+        /// If currently spectating other users, this contains a list of all users being watched.
+        /// </summary>
+        public List<int>? WatchingUserIds;
+
         [JsonConstructor]
         public SpectatorClientState(in string connectionId, in int userId)
             : base(connectionId, userId)
diff --git a/osu.Server.Spectator/Hubs/Spectator/SpectatorHub.cs b/osu.Server.Spectator/Hubs/Spectator/SpectatorHub.cs
index c913e16..fe70c9f 100644
--- a/osu.Server.Spectator/Hubs/Spectator/SpectatorHub.cs
+++ b/osu.Server.Spectator/Hubs/Spectator/SpectatorHub.cs
@@ -154,7 +154,12 @@ namespace osu.Server.Spectator.Hubs.Spectator
                 }
                 finally
                 {
-                    usage.Destroy();
+                    if (usage.Item != null)
+                    {
+                        usage.Item.Score = null;
+                        usage.Item.ScoreToken = null;
+                        usage.Item.State = null;
+                    }
                 }
             }
 
@@ -186,6 +191,8 @@ namespace osu.Server.Spectator.Hubs.Spectator
         {
             Log($"Watching {userId}");
 
+            // TODO: create own spectator state and update watcher list.
+
             try
             {
                 SpectatorState? spectatorState;
@@ -227,6 +234,8 @@ namespace osu.Server.Spectator.Hubs.Spectator
 
             int watcherId = Context.GetUserId();
 
+            // TODO: remove id from own spectator state watcher list.
+
             await Clients.User(userId.ToString()).UserEndedWatching(watcherId);
         }
 
@@ -245,6 +254,12 @@ namespace osu.Server.Spectator.Hubs.Spectator
             if (state.State != null)
                 await endPlaySession(state.UserId, state.State);
 
+            if (state.WatchingUserIds != null)
+            {
+                foreach (int id in state.WatchingUserIds)
+                    await EndWatchingUser(id);
+            }
+
             await base.CleanUpState(state);
         }
 

@bdach thoughts on this one? i'm open to other proposals but using the existing state for dual-purposes does feel simpler than a separate entity store to me. maybe.

I won't take this further than the diff above unless you'd prefer I take over.

Also as touched on, restricted users are not considered at all here, meaning they will show up in the spectator list (likely a blocking concern). And I don't think blocked users are being considered either. Restricted user handling has been something we've deferred for a while, but I'd propose that it's about time we fix this before it becomes an actual community issue (there are other cases – they also they show up in online users and in friend status updates, basically none of this should exist, i'll open an issue).

You mentioned potentially wanting to take that on, so I'll leave some more details in a new issue regarding the scope of what restrictions (and silences) should do.

Copy link
Member

@peppy peppy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concerns remain

@bdach
Copy link
Collaborator Author

bdach commented Jan 22, 2025

using the existing state for dual-purposes does feel simpler than a separate entity store to me. maybe.

I tend to agree, and only did the separate store out of concerns I was going to break something - that said, on closer inspection, I think it should be mostly fine, so I applied your diff with some minor renames and the TODOs filled out.

@peppy peppy self-requested a review January 23, 2025 09:42
Copy link
Member

@peppy peppy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No remaining qualms. Let's give this a go.

@peppy peppy merged commit 239e231 into ppy:master Jan 23, 2025
2 checks passed
@bdach bdach deleted the who-is-spectating-me branch January 23, 2025 14:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants