diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index d717c00..9d67959 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -374,9 +374,15 @@ public enum EKnownClients static class WebSocketManager { public static int g_PeakConnectionCount = 0; - public static async Task CreateSession(AppDbContext _db, EUserSessionType sessionType, bool bIsReconnect, Int64 ownerID, KnownClients.EKnownClients client_id, string ipAddr, string strContinent, string strCountry, double dLatitude, double dLongitude, bool bIsAdmin) + public static Task CreateSession(AppDbContext _db, EUserSessionType sessionType, bool bIsReconnect, Int64 ownerID, KnownClients.EKnownClients client_id, string ipAddr, string strContinent, string strCountry, double dLatitude, double dLongitude, bool bIsAdmin) + { + return ServiceLocator.Services.GetRequiredService().OpenSessionAsync(_db, sessionType, bIsReconnect, ownerID, client_id, ipAddr, strContinent, strCountry, dLatitude, dLongitude, bIsAdmin); + } + + internal static async Task InstallSession(AppDbContext _db, EUserSessionType sessionType, bool bIsReconnect, Int64 ownerID, KnownClients.EKnownClients client_id, string ipAddr, string strContinent, string strCountry, double dLatitude, double dLongitude, bool bIsAdmin) { - string strDisplayName = await Database.Users.GetDisplayName(_db, ownerID); + string strDisplayName = await Database.Users.GetDisplayName(_db, ownerID); + await Database.Users.UpdateLastLoginData(_db, ownerID, ipAddr); // if we have cache data, that means its a reconnect, noraml connections go through login flows which reset cache data UserSession? userCacheData = WebSocketManager.GetSessionFromUser(ownerID, sessionType); @@ -430,13 +436,6 @@ public static async Task CreateSession(AppDbContext _db, // how many other sessions do they have online? bool bIsFirstSessionForUser = WebSocketManager.GetAllDataFromUser(ownerID).Count == 0; - // kill any existing sessions for this user of same session type - if (m_dictWebsockets[sessionType].TryGetValue(ownerID, out UserWebSocketInstance? existingSession)) - { - Console.WriteLine("Killing existing session for {0} ({1})", ownerID, strDisplayName); - await DeleteSession(ownerID, sessionType, existingSession, !bIsReconnect); - } - // get and cache social container UserSocialContainer socialContainer = new(); socialContainer.Friends = await Database.Social.GetFriends(_db, ownerID); @@ -490,8 +489,6 @@ public static async Task CreateSession(AppDbContext _db, UserWebSocketInstance newSess = new UserWebSocketInstance(sessionType, ownerID, userCacheData); m_dictWebsockets[sessionType][ownerID] = newSess; - // update last login and last ip - await Database.Users.UpdateLastLoginData(_db, ownerID, ipAddr); // TODO_EFCORE: Optimize this, dont iterate all the time int numSessions = WebSocketManager.GetNumberOfUsersOnline(); @@ -588,149 +585,48 @@ public static async Task CheckForTimeouts() } } - foreach (var userData in lstCacheEntriesToDestroy) - { - // A reconnect between the snapshot above and now would have replaced this user's - // session with a live one; only clear if the SAME session object is still registered - // and still abandoned+expired, so a fresh reconnect never has its live session torn - // down (kicked from its lobby, deregistered from matchmaking) by a stale sweep entry. - UserSession? currentSession = GetSessionFromUser(userData.UserID, userData.SessionType); - if (!ShouldStillClearAbandonedSession(currentSession, userData.Session)) - { - continue; - } - - await ClearDataFromUser(userData.UserID, userData.SessionType); - } - } - - public static async Task DeleteSession(Int64 user_id, EUserSessionType sessionType, UserWebSocketInstance? oldWS, bool bShouldInvalidatePlayerCacheToBlockReconnect) - { - UserSession? sourceData = WebSocketManager.GetSessionFromUser(user_id, sessionType); - SharedUserData? sourceSharedData = WebSocketManager.GetSharedDataForUser(user_id); - - bool bIsStaleSocket = false; - - if (oldWS != null) - { - // has this websocket already been superseded by a newer connection for the same user? if so it is a - // stale/zombie socket and must not be allowed to abandon or tear down the live session - if (m_dictWebsockets[sessionType].TryGetValue(user_id, out UserWebSocketInstance? currentWS)) - { - bIsStaleSocket = !ReferenceEquals(currentWS, oldWS); - } - - try - { - // dont remove by ID, user could have re-opened another websocket open via reconnection, remove by instance, if its not there, thats OK, it was already closed and the new instance is a reconnect - var item = m_dictWebsockets[sessionType].First(kvp => kvp.Value == oldWS); // safe to lookup by sessionType here since we only ever remove old WS of the same type - m_dictWebsockets[sessionType].Remove(item.Key, out UserWebSocketInstance? destroyedSess); - - if (destroyedSess != null) - { - await destroyedSess.CloseAsync(WebSocketCloseStatus.NormalClosure, "User signed in from another point of presence [A]"); - } - } - catch - { - - } - - if (bIsStaleSocket) - { - // nothing else to do - the user is still connected on the newer socket - await oldWS.CloseAsync(WebSocketCloseStatus.NormalClosure, "Superseded by a newer connection"); - return; - } - } - - if (bShouldInvalidatePlayerCacheToBlockReconnect) - { - // NOTE: ClearDataFromUser owns the shared data ref count decrement (and GC) for this path - doing it - // again here would drop the count twice for a single disconnect and tear down the shared data while - // the user still has other live sessions - await WebSocketManager.ClearDataFromUser(user_id, sessionType); - } - else - { - // mark it as abandoned for now to start the expiration timer - if (sourceData != null) - { - sourceData.MarkAbandoned(); - - // If the player was in an active game when their connection dropped, record the - // abandon time NOW (before any lobby-structure cleanup runs). This timestamp is - // the authoritative "who quit first" signal used by DetermineLobbyWinnerIfNotPresent, - // and must be captured here rather than in RemoveMember, because RemoveMember may - // execute much later (e.g. 30 s after the first abandonment) or at an unexpected - // time (e.g. when the other player reconnects with a fresh session and their old - // session is forcibly evicted, causing them to appear as "last to leave" even - // though they stayed in the game longer than the opponent who quit first). - try - { - var lobbyManager = ServiceLocator.Services.GetRequiredService(); - if (sourceData.currentLobbyID != -1) - { - Lobby? ingameLobby = lobbyManager.GetLobby(sourceData.currentLobbyID); - if (ingameLobby != null && ingameLobby.State == ELobbyState.INGAME) - { - ingameLobby.RecordPlayerIngameAbandon(user_id); - } - else - { - Console.WriteLine($"[WARN] DeleteSession(false): skipped RecordPlayerIngameAbandon for user {user_id} — lobby={sourceData.currentLobbyID} found={ingameLobby != null} state={ingameLobby?.State}"); - } - } - else - { - Console.WriteLine($"[WARN] DeleteSession(false): skipped RecordPlayerIngameAbandon for user {user_id} — currentLobbyID==-1 (session has no lobby)"); - } - } - catch (Exception ex) - { - Console.WriteLine($"[WARN] Failed to record in-game abandon for user {user_id}: {ex.Message}"); - } - } - } - - // NOTE: They only went offline if ref count became 0, otherwise they're still online somewhere else - if (sourceData != null && sourceSharedData != null && sourceSharedData.NeedsGC()) - { - // TODO_SOCIAL: Move this to a class - // inform any friends who are online that this person just came online - WebSocketMessage_Social_FriendStatusChanged friendStatusChangedEvent = new(); - friendStatusChangedEvent.msg_id = (int)EWebSocketMessageID.SOCIAL_FRIEND_ONLINE_STATUS_CHANGED; - friendStatusChangedEvent.display_name = sourceSharedData.m_strDisplayName; - friendStatusChangedEvent.online = false; - byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(friendStatusChangedEvent)); - - if (sourceData != null) - { - // friends are reciprocal so we can just iterate our friends - foreach (Int64 friendID in sourceSharedData.GetSocialContainer().Friends) - { - WebsocketHelper.SendToAllSessionsOfUser(friendID, bytesJSON); - } - } - } - - int numSessions = WebSocketManager.GetNumberOfUsersOnline(); ; - Console.Title = String.Format("GenOnline - {0} players", numSessions); - - try - { - if (oldWS != null) - { - // Close the WS directly, dont rely on session data as it may be linked to something else at this point - await oldWS.CloseAsync(WebSocketCloseStatus.NormalClosure, "Session being deleted"); - } - } - catch - { - - } - } - + foreach (var userData in lstCacheEntriesToDestroy) + await ServiceLocator.Services.GetRequiredService().ExpireSessionAsync(userData.Session); + } + + public static Task DeleteSession(Int64 userID, EUserSessionType sessionType, UserWebSocketInstance? oldWS, bool destroySession, UserSession? expectedSession = null) + { + var session = expectedSession ?? oldWS?.OwnerSession ?? GetSessionFromUser(userID, sessionType); + return ServiceLocator.Services.GetRequiredService().DisconnectSessionAsync(session, oldWS, destroySession); + } + + internal static UserWebSocketInstance? DetachSocket(UserSession session) + { + var sockets = m_dictWebsockets[session.GetSessionType()]; + if (!sockets.TryGetValue(session.m_UserID, out var socket) || !ReferenceEquals(socket.OwnerSession, session)) return null; + return sockets.TryRemove(new KeyValuePair(session.m_UserID, socket)) ? socket : null; + } + + internal static bool RemoveSessionData(UserSession session) + { + if (!m_dictUserSessions[session.GetSessionType()].TryRemove(new KeyValuePair(session.m_UserID, session))) + return false; + if (m_dictSharedUserData.TryGetValue(session.m_UserID, out var shared)) + { + shared.DecrementRefCount(); + if (shared.NeedsGC()) + { + m_dictSharedUserData.TryRemove(new KeyValuePair(session.m_UserID, shared)); + var message = new WebSocketMessage_Social_FriendStatusChanged + { + msg_id = (int)EWebSocketMessageID.SOCIAL_FRIEND_ONLINE_STATUS_CHANGED, + display_name = shared.m_strDisplayName, + online = false + }; + byte[] payload = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(message)); + foreach (long friendID in shared.GetSocialContainer().Friends) + WebsocketHelper.SendToAllSessionsOfUser(friendID, payload); + } + } + Console.Title = $"GenOnline - {GetNumberOfUsersOnline()} players"; + return true; + } + /* public static ChatSession? GetWebSocketForUser(Int64 userID) { @@ -748,7 +644,7 @@ public static async Task DeleteSession(Int64 user_id, EUserSessionType sessionTy // TODO_EFCORE: Just use a weakref to the websocket from the user session instead of lookups public static UserWebSocketInstance? GetWebSocketForSession(UserSession session) { - if (m_dictWebsockets[session.GetSessionType()].TryGetValue(session.m_UserID, out UserWebSocketInstance? retVal)) + if (m_dictWebsockets[session.GetSessionType()].TryGetValue(session.m_UserID, out UserWebSocketInstance? retVal) && ReferenceEquals(retVal.OwnerSession, session)) { return retVal; } @@ -851,7 +747,7 @@ public static async Task DisconnectUser(Int64 userID, byte[] finalMessage) await userSession.FlushWebsocketSendsAsync(TimeSpan.FromSeconds(2)); } - await DeleteSession(userID, userSession.GetSessionType(), oldWS, true); + await DeleteSession(userID, userSession.GetSessionType(), oldWS, true, userSession); } catch (Exception ex) { @@ -861,42 +757,13 @@ public static async Task DisconnectUser(Int64 userID, byte[] finalMessage) } } - public static async Task ClearDataFromUser(Int64 userID, EUserSessionType sessionType) - { - // NOTE: This is when a player is truly disconnected and we can destroy session, remove form lobby etc, websocket disconnect doesnt mean that because the clietn reconnects - try - { - UserSession? userData = null; - - if (m_dictUserSessions[sessionType].ContainsKey(userID)) - { - userData = m_dictUserSessions[sessionType][userID]; - } - await SessionHelpers.FullyDestroyPlayerSession(userID, userData, true); - - // decrement ref count on shared data - if (m_dictSharedUserData.TryGetValue(userID, out SharedUserData? sharedData)) - { - sharedData.DecrementRefCount(); - if (sharedData.NeedsGC()) // cleanup if necessary - { - m_dictSharedUserData.Remove(userID, out var removedSharedData); - } - } - else - { - Console.WriteLine("Error: Could not find shared data for user {0} when deleting session", userID); - } - } - catch - { - - } - - return m_dictUserSessions[sessionType].Remove(userID, out var itemRemoved); - } - - + public static Task ClearDataFromUser(Int64 userID, EUserSessionType sessionType) + { + var session = GetSessionFromUser(userID, sessionType); + return ServiceLocator.Services.GetRequiredService().ClearSessionAsync(session); + } + + // helpers private static readonly object g_dirtyLobbyRoomsLock = new(); private static readonly HashSet g_dirtyLobbyRooms = new(); @@ -1175,6 +1042,10 @@ private static Int64 Get(string key, Int64 defaultValue) public class UserSession { + private int retired; + internal bool IsRetired => Volatile.Read(ref retired) != 0; + internal void Retire() => Interlocked.Exchange(ref retired, 1); + public Int64 m_UserID = -1; public string m_strContinent; @@ -1513,7 +1384,10 @@ public void QueueKeepAlive() } private WebSocket? m_SockInternal = null; - private readonly UserSession m_OwnerSession; + private readonly object transportLock = new(); + private bool closed; + private readonly UserSession m_OwnerSession; + internal UserSession OwnerSession => m_OwnerSession; private readonly CancellationTokenSource m_sendLoopStop = new CancellationTokenSource(); private Task m_sendLoop = Task.CompletedTask; @@ -1524,12 +1398,20 @@ public UserWebSocketInstance(EUserSessionType sessionType, Int64 ownerID, UserSe m_OwnerSession = ownerSession; } - public void AttachWebsocket(WebSocket sock) - { - m_SockInternal = sock; - m_sendLoop = Task.Run(RunSendLoop); - } - + public void AttachWebsocket(WebSocket sock) + { + lock (transportLock) + { + if (closed) + { + sock.Abort(); + return; + } + m_SockInternal = sock; + m_sendLoop = Task.Run(RunSendLoop); + } + } + public bool AbortIfOpen() { WebSocket? sock = m_SockInternal; @@ -1598,6 +1480,7 @@ private async Task SendFrameAsync(byte[] buffer) public async Task CloseAsync(WebSocketCloseStatus closeStatus, string? statusDescription) { + lock (transportLock) closed = true; // stop reading the queue first so the next connection's send loop is the only reader m_sendLoopStop.Cancel(); @@ -1647,40 +1530,6 @@ public static bool SessionTypeHasAccessTo(EUserSessionType sessType, ESessionAcc return false; } - public static async Task FullyDestroyPlayerSession(Int64 user_id, UserSession? userData, bool bMigrateLobbyIfPresent) - { - // NOTE: Dont assume userData is valid, use user_id for user id - Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine("FullyDestroyPlayerSession for user {0}", user_id); - Console.ForegroundColor = ConsoleColor.Gray; - - // invalidate any TURN credentials - TURNCredentialManager.DeleteCredentialsForUser(user_id); - - // TODO: Implement single point of presence? gets dicey if multiple logins - // TODO: Dont destroy this, just mark inactive/offline, we use this as a saved credential system - - // session tied to this token (keep other ones attached to user_id, could be other machines) - // TODO_JWT: Remove table fully + set logged out - //await m_Inst.Query("DELETE FROM sessions WHERE user_id={0} AND session_type={1};", user_id, (int)ESessionType.Game); - - // leave any lobby - Console.WriteLine("[Source 2] User {0} Leave Any Lobby", user_id); - - var lobbyManager = ServiceLocator.Services.GetRequiredService(); - await lobbyManager.LeaveAnyLobby(user_id); - - await lobbyManager.CleanupUserLobbiesNotStarted(user_id); - - // remove from any matchmaking - if (userData != null) - { - await MatchmakingManager.DeregisterPlayer(userData); - } - - // TODO: Client needs to handle this... itll start returning 404 - } - public async static Task SetUsedLoggedIn(Int64 userID, KnownClients.EKnownClients clientID, EUserSessionType sessionType) { // TODO_EFCORE: website uses this index as 1 (60hz) to 0 (30hz), update it to use new enum + support new clients, also need to update DB to match @@ -1701,7 +1550,7 @@ public async static Task SetUsedLoggedIn(Int64 userID, KnownClients.EKnownClient Console.ForegroundColor = ConsoleColor.Gray; UserWebSocketInstance? oldWS = GenOnlineService.WebSocketManager.GetWebSocketForSession(sess); - await GenOnlineService.WebSocketManager.DeleteSession(userID, sessionType, oldWS, false); + await GenOnlineService.WebSocketManager.DeleteSession(userID, sessionType, oldWS, false, sess); } } } @@ -2795,7 +2644,8 @@ public static async Task DeleteCredentialsForUser(Int64 userID) try { string strURI = String.Format("https://rtc.live.cloudflare.com/v1/turn/keys/{0}/credentials/{1}/revoke", TurnKey, strTURNUsername); - HttpResponseMessage response = await client.PostAsync(strURI, requestContent); + using HttpResponseMessage response = await client.PostAsync(strURI, requestContent); + response.EnsureSuccessStatusCode(); } catch { diff --git a/GenOnlineService/Controllers/Lobbies/LobbiesController.cs b/GenOnlineService/Controllers/Lobbies/LobbiesController.cs index 167eb72..65f9323 100644 --- a/GenOnlineService/Controllers/Lobbies/LobbiesController.cs +++ b/GenOnlineService/Controllers/Lobbies/LobbiesController.cs @@ -328,8 +328,6 @@ public async Task Put() if (playerSession != null) { - // cleanup any zombie lobbies - await _lobbyManager.CleanupUserLobbiesNotStarted(user_id); await using var db = await _dbFactory.CreateDbContextAsync(); string strDisplayName = await Database.Users.GetDisplayName(db, user_id); @@ -342,14 +340,9 @@ public async Task Put() result.result = 1; result.lobby_id = newLobbyID; - // TODO: What if this fails? just let them proceed? just means only direct connect people will be able to play - // get some turn credentials - TURNCredentialContainer? turnCredentials = await TURNCredentialManager.CreateCredentialsForUser(user_id); - if (turnCredentials != null) - { - result.turn_username = turnCredentials.m_strUsername; - result.turn_token = turnCredentials.m_strToken; - } + var credentials = await _lobbyManager.CreateLobbyRelayCredentialsAsync(playerSession, newLobbyID); + result.turn_username = credentials.Username; + result.turn_token = credentials.Token; } else { diff --git a/GenOnlineService/Controllers/Lobby/LobbyController.cs b/GenOnlineService/Controllers/Lobby/LobbyController.cs index 336e93b..db7a085 100644 --- a/GenOnlineService/Controllers/Lobby/LobbyController.cs +++ b/GenOnlineService/Controllers/Lobby/LobbyController.cs @@ -211,65 +211,18 @@ public async Task Get(string lobby_id) [Authorize(Roles = "GameClient")] public async Task Delete(Int64 lobbyID) { - RouteHandler_DELETE_Lobby_Result result = new RouteHandler_DELETE_Lobby_Result(); - - using (var reader = new StreamReader(HttpContext.Request.Body)) + var result = new RouteHandler_DELETE_Lobby_Result(); + using var reader = new StreamReader(HttpContext.Request.Body); + await reader.ReadToEndAsync(); + try { - string jsonData = await reader.ReadToEndAsync(); - var options = new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }; - - try - { - // TODO: Dont let them join more than 1 lobby - // need a lobby ID - int leavingPersonSlot = -1; - Int64 user_id = TokenHelper.GetUserID(this); - EUserSessionType sessionType = TokenHelper.GetSessionType(this); - if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, ESessionAccessType.Gameplay)) - { - Lobby? lobby = _lobbyManager.GetLobby(lobbyID); - if (lobby != null) - { - foreach (var member in lobby.Members) - { - if (member.SlotState == EPlayerType.SLOT_PLAYER) - { - if (member.UserID == user_id) - { - leavingPersonSlot = member.SlotIndex; - } - } - } - } - - Console.WriteLine("[Source 1] User {0} Leave Any Lobby", user_id); - await _lobbyManager.LeaveAnyLobby(user_id); - - // cleanup TURN credentials - TURNCredentialManager.DeleteCredentialsForUser(user_id); - - // clear our lobby ID - UserSession? sourceData = WebSocketManager.GetSessionFromUser(user_id, sessionType); - - if (sourceData != null) - { - sourceData.UpdateSessionLobbyID(-1); - // NOTE: We dont update the match history match ID here, that is done by the match history service - } - - result.success = true; - } - - } - catch - { - return result; - } + result.success = await _lobbyManager.LeaveAsync(new LeaveLobbyCommand(lobbyID, + TokenHelper.GetUserID(this), TokenHelper.GetSessionType(this))); } - + catch + { + return result; + } return result; } @@ -410,94 +363,57 @@ enum ELobbyUpdatePermissions [Authorize(Roles = "GameClient")] public async Task Post(Int64 lobbyID) { - RouteHandler_POST_Lobby_Result result = new RouteHandler_POST_Lobby_Result(); - - using (var reader = new StreamReader(HttpContext.Request.Body)) - { - string jsonData = await reader.ReadToEndAsync(); - var options = new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }; - - try - { - // TODO: Dont let them join more than 1 lobby - - var data = JsonSerializer.Deserialize>(jsonData, options); - - if (data != null - && data.ContainsKey("field") - ) - { - // TODO_NGMP: For all things which modify lobbies, we should check if the player is really in the lobby - // get requesting user - Int64 user_id = TokenHelper.GetUserID(this); - if (user_id != -1) - { - Lobby? lobby = _lobbyManager.GetLobby(lobbyID); - - if (lobby != null) - { - // check the user is in the lobby, otherwise bail - LobbyMember? SourceMember = lobby.GetMemberFromUserID(user_id); - - if (SourceMember == null) - { - Response.StatusCode = (int)HttpStatusCode.Unauthorized; - return result; - } - - if (!TryParseLobbyUpdateField(data["field"].GetInt32(), out ELobbyUpdateField field)) - { - Response.StatusCode = (int)HttpStatusCode.BadRequest; - return result; - } - - // check permissions - ELobbyUpdatePermissions updatePerms = g_dictLobbyUpdatePermissionsTable[field]; - - if (updatePerms == ELobbyUpdatePermissions.LobbyOwner) // check owner - { - if (user_id != lobby.Owner) - { - Response.StatusCode = (int)HttpStatusCode.Unauthorized; - result.success = false; - return result; - } - } - - // Every mutation below touches this lobby's Members, slot state, or ready state, - // so the whole field-update dispatch runs as one atomic operation under the - // per-lobby gate instead of each setter mutating state unguarded. - // A kick can't finish the actual removal in here: RemoveMember acquires this - // same lobby's gate, which is not reentrant, so ApplyLobbyFieldUpdateAsync only - // validates the target and hands the user ID back for PerformKickAsync to - // process once the gate below has been released. - Int64? kickedUserID = await lobby.RunExclusiveAsync(() => ApplyLobbyFieldUpdateAsync(lobby, SourceMember, field, data)); - - if (kickedUserID.HasValue) - { - await PerformKickAsync(lobby, kickedUserID.Value); - } - } - } - - } - - } - catch - { + var result = new RouteHandler_POST_Lobby_Result(); + using var reader = new StreamReader(HttpContext.Request.Body); + string jsonData = await reader.ReadToEndAsync(); + try + { + var data = JsonSerializer.Deserialize>(jsonData); + if (data == null || !data.ContainsKey("field")) return result; + long userID = TokenHelper.GetUserID(this); + if (userID == -1) return result; + var lobby = _lobbyManager.GetLobby(lobbyID); + if (lobby == null) return result; + + if (data["field"].GetInt32() == (int)ELobbyUpdateField.HOST_ACTION_KICK_USER) + { + long? targetID = data.TryGetValue("userid", out var target) && target.ValueKind == JsonValueKind.Number + && target.TryGetInt64(out long parsedTarget) ? parsedTarget : null; + var status = await _lobbyManager.KickAsync(new KickLobbyMemberCommand(lobbyID, userID, targetID)); + if (status is LobbyMembershipStatus.ActorNotMember or LobbyMembershipStatus.ActorNotHost) + Response.StatusCode = (int)HttpStatusCode.Unauthorized; + // Legacy kick responses leave success false, even when removal succeeds. return result; } + + await lobby.RunExclusiveAsync(async () => + { + var sourceMember = lobby.GetMemberFromUserID(userID); + if (sourceMember == null) + { + Response.StatusCode = (int)HttpStatusCode.Unauthorized; + return; + } + if (!TryParseLobbyUpdateField(data["field"].GetInt32(), out ELobbyUpdateField field)) + { + Response.StatusCode = (int)HttpStatusCode.BadRequest; + return; + } + if (g_dictLobbyUpdatePermissionsTable[field] == ELobbyUpdatePermissions.LobbyOwner && userID != lobby.Owner) + { + Response.StatusCode = (int)HttpStatusCode.Unauthorized; + return; + } + await ApplyLobbyFieldUpdateAsync(lobby, sourceMember, field, data); + }); } - + catch + { + return result; + } return result; } - // Rejects any wire value that isn't a real EPlayerType member, so a malformed or - // out-of-range slot_state from HOST_ACTION_SET_SLOT_STATE can't be cast into an enum value - // the rest of the lobby code (SetPlayerSlotState, IsAI, serialization, ...) never expects. internal static bool TryParseSlotState(UInt16 rawValue, out EPlayerType slotState) { slotState = (EPlayerType)rawValue; @@ -505,23 +421,14 @@ internal static bool TryParseSlotState(UInt16 rawValue, out EPlayerType slotStat return Enum.IsDefined(typeof(EPlayerType), slotState) && slotState != EPlayerType.SLOT_PLAYER; } - // Rejects any wire value that isn't a real ELobbyUpdateField member, so an unknown/malformed - // field can't be cast into an enum value that then gets indexed straight into - // g_dictLobbyUpdatePermissionsTable (which previously threw KeyNotFoundException, silently - // swallowed by Post's outer catch, for anything out of range). internal static bool TryParseLobbyUpdateField(int rawValue, out ELobbyUpdateField field) { field = (ELobbyUpdateField)rawValue; return Enum.IsDefined(typeof(ELobbyUpdateField), field); } - // Runs the per-field lobby update dispatch. Must only be called from inside - // lobby.RunExclusiveAsync: every branch here mutates this lobby's Members, slot - // state/fields, or ready state, and previously ran completely unguarded. - // Returns the kicked user's ID when HOST_ACTION_KICK_USER validated a real target, so the - // caller can run the actual removal after releasing the gate; null for every other field - // (including a kick request with no valid target). - private async Task ApplyLobbyFieldUpdateAsync(Lobby lobby, LobbyMember SourceMember, ELobbyUpdateField field, Dictionary data) + // Caller holds the lobby gate. + private async Task ApplyLobbyFieldUpdateAsync(Lobby lobby, LobbyMember SourceMember, ELobbyUpdateField field, Dictionary data) { // reset everyones ready states when anything changes (minus dummy actions) if (field != ELobbyUpdateField.HOST_ACTION_FORCE_START @@ -547,7 +454,7 @@ internal static bool TryParseLobbyUpdateField(int rawValue, out ELobbyUpdateFiel if ((strMap != null && strMap.Length > 255) || (strMapPath != null && strMapPath.Length > 512)) { Response.StatusCode = (int)HttpStatusCode.BadRequest; - return null; + return; } if (strMap != null && strMapPath != null) @@ -630,22 +537,6 @@ internal static bool TryParseLobbyUpdateField(int rawValue, out ELobbyUpdateFiel SourceMember.UpdateHasMap(bHasMap); } } - else if (field == ELobbyUpdateField.HOST_ACTION_KICK_USER) - { - if (data.ContainsKey("userid")) - { - Int64 KickedUserID = data["userid"].GetInt64(); - - // the target must actually be in THIS lobby, otherwise a host could wipe the - // TURN credentials / lobby state of any arbitrary online player. Checked here, - // under the gate, so it can't race a concurrent leave/kick of the same target; - // the actual removal runs after the gate is released (see PerformKickAsync). - if (lobby.GetMemberFromUserID(KickedUserID) != null) - { - return KickedUserID; - } - } - } else if (field == ELobbyUpdateField.HOST_ACTION_SET_SLOT_STATE) { UInt16 slot_index = data["slot_index"].GetUInt16(); @@ -653,7 +544,7 @@ internal static bool TryParseLobbyUpdateField(int rawValue, out ELobbyUpdateFiel if (!TryParseSlotState(data["slot_state"].GetUInt16(), out EPlayerType slot_state)) { Response.StatusCode = (int)HttpStatusCode.BadRequest; - return null; + return; } // human slots are left alone; kicking is how a host removes a player @@ -796,165 +687,44 @@ internal static bool TryParseLobbyUpdateField(int rawValue, out ELobbyUpdateFiel } } - return null; - } - - // Runs the parts of a kick that RemoveMember itself performs (leaving the lobby, which - // acquires this same lobby's gate) plus TURN/session cleanup - all after - // ApplyLobbyFieldUpdateAsync's gate has been released, never from inside it. - private async Task PerformKickAsync(Lobby lobby, Int64 kickedUserID) - { - // TODO: we should communicate the kick to the user... - await _lobbyManager.LeaveSpecificLobby(kickedUserID, lobby.LobbyID); - - // cleanup TURN credentials - TURNCredentialManager.DeleteCredentialsForUser(kickedUserID); - - // clear our lobby ID - UserSession? sourceData = WebSocketManager.GetSessionFromUser(kickedUserID, EUserSessionType.GameClient); // user being kicked must be a game client - - if (sourceData != null) - { - sourceData.UpdateSessionLobbyID(-1); - // NOTE: We dont update the match history match ID here, that is done by the match history service - } - - // we have to manually send to the kicked user... they won't get the dirty lobby update anymore - await lobby.DirtyRetransmitToSingleMember(kickedUserID); + return; } [HttpPut("{lobbyID}")] [Authorize(Roles = "GameClient")] public async Task Put(Int64 lobbyID) { - RouteHandler_PUT_Lobby_Result result = new RouteHandler_PUT_Lobby_Result(); - - using (var reader = new StreamReader(HttpContext.Request.Body)) - { - string jsonData = await reader.ReadToEndAsync(); - - var options = new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }; - - try - { - // TODO: Dont let them join more than 1 lobby - - var data = JsonSerializer.Deserialize>(jsonData, options); - - if (data != null - && data.ContainsKey("preferred_port") - && data.ContainsKey("anticheat_id") - ) - { - - Lobby? lobby = _lobbyManager.GetLobby(lobbyID); - - if (lobby != null) - { - Int64 user_id = TokenHelper.GetUserID(this); - EUserSessionType sessionType = TokenHelper.GetSessionType(this); - if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, ESessionAccessType.Gameplay)) - { - if (lobby.LobbyType == ELobbyType.QuickMatch && !lobby.MatchedUserIDs.Contains(user_id)) - { - Response.StatusCode = (int)HttpStatusCode.Forbidden; - result.success = false; - return result; - } - - UInt16 userPreferredPort = data["preferred_port"].GetUInt16(); - bool bHasMap = data["has_map"].GetBoolean(); - EKnownAnticheatID anticheatID = (EKnownAnticheatID)data["anticheat_id"].GetInt32(); - - // does the lobby have a password? - bool bLobbyPassworded = lobby.IsPassworded; - bool bUserProvidedPassword = data.ContainsKey("password"); - - // we need a password - if (bLobbyPassworded) - { - if (bUserProvidedPassword) - { - // do the passwords match? - string? strUserProvidedPassword = data["password"].GetString(); - - // if it doesnt match, bail, otherwise just proceed as normal - if (strUserProvidedPassword != lobby.Password) - { - Response.StatusCode = (int)HttpStatusCode.Unauthorized; - result.success = false; - return result; - } - } - else - { - // no password, no access - Response.StatusCode = (int)HttpStatusCode.Unauthorized; - result.success = false; - return result; - } - } - - // must be running same AC - if (anticheatID != lobby.AnticheatID) - { - Response.StatusCode = (int)HttpStatusCode.ExpectationFailed; - result.success = false; - return result; - } - - UserSession? playerSession = WebSocketManager.GetSessionFromUser(user_id, sessionType); - - if (playerSession != null) - { - // leave any lobby - await _lobbyManager.LeaveAnyLobby(user_id); - - await using var db = await _dbFactory.CreateDbContextAsync(); - string strDisplayName = await Database.Users.GetDisplayName(db, user_id); - bool bJoinedSuccessfully = await _lobbyManager.JoinLobby(db, lobby, playerSession, strDisplayName, userPreferredPort, bHasMap); - - result.success = bJoinedSuccessfully; - - if (!bJoinedSuccessfully) // this basically means full, didnt find a slot in correct state - { - Response.StatusCode = (int)HttpStatusCode.NotAcceptable; - } - else - { - // TODO: What if this fails? just let them proceed? just means only direct connect people will be able to play - // get some turn credentials - TURNCredentialContainer? turnCredentials = await TURNCredentialManager.CreateCredentialsForUser(user_id); - if (turnCredentials != null) - { - result.turn_username = turnCredentials.m_strUsername; - result.turn_token = turnCredentials.m_strToken; - } - - Response.StatusCode = (int)HttpStatusCode.OK; - } - } - else - { - Response.StatusCode = (int)HttpStatusCode.Unauthorized; - } - } - - } - } - - } - catch - { - return result; - } - } - - return result; - - } + var result = new RouteHandler_PUT_Lobby_Result(); + using var reader = new StreamReader(HttpContext.Request.Body); + string jsonData = await reader.ReadToEndAsync(); + try + { + var data = JsonSerializer.Deserialize>(jsonData); + if (data == null || !data.ContainsKey("preferred_port") || !data.ContainsKey("anticheat_id")) return result; + var joined = await _lobbyManager.JoinAsync(new JoinLobbyCommand(lobbyID, + TokenHelper.GetUserID(this), TokenHelper.GetSessionType(this), + data["preferred_port"].GetUInt16(), data.TryGetValue("has_map", out var hasMap) && hasMap.GetBoolean(), + (EKnownAnticheatID)data["anticheat_id"].GetInt32(), + data.TryGetValue("password", out var password) ? password.GetString() : null)); + result.success = joined.Status == LobbyMembershipStatus.Success; + result.turn_username = joined.TurnUsername; + result.turn_token = joined.TurnToken; + Response.StatusCode = (int)MapMembershipStatus(joined.Status); + } + catch + { + return result; + } + return result; + } + + private static HttpStatusCode MapMembershipStatus(LobbyMembershipStatus status) => status switch + { + LobbyMembershipStatus.ActorNotMember or LobbyMembershipStatus.ActorNotHost or LobbyMembershipStatus.SessionUnavailable or LobbyMembershipStatus.PasswordMismatch => HttpStatusCode.Unauthorized, + LobbyMembershipStatus.NotMatched => HttpStatusCode.Forbidden, + LobbyMembershipStatus.AnticheatMismatch => HttpStatusCode.ExpectationFailed, + LobbyMembershipStatus.Success or LobbyMembershipStatus.NoChange => HttpStatusCode.OK, + _ => HttpStatusCode.NotAcceptable + }; } } diff --git a/GenOnlineService/Controllers/Matchmaking/MatchmakingController.cs b/GenOnlineService/Controllers/Matchmaking/MatchmakingController.cs index 1de844d..ee296e7 100644 --- a/GenOnlineService/Controllers/Matchmaking/MatchmakingController.cs +++ b/GenOnlineService/Controllers/Matchmaking/MatchmakingController.cs @@ -128,7 +128,7 @@ public async Task Delete() if (playerSession != null) { - await MatchmakingManager.DeregisterPlayer(playerSession); + await MatchmakingManager.DeregisterPlayerAsync(playerSession); } } } diff --git a/GenOnlineService/LobbyManager.Membership.cs b/GenOnlineService/LobbyManager.Membership.cs new file mode 100644 index 0000000..245f583 --- /dev/null +++ b/GenOnlineService/LobbyManager.Membership.cs @@ -0,0 +1,177 @@ +/* +** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour +** Copyright (C) 2025 GeneralsOnline Development Team +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU Affero General Public License as +** published by the Free Software Foundation, either version 3 of the +** License, or (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU Affero General Public License for more details. +** +** You should have received a copy of the GNU Affero General Public License +** along with this program. If not, see . +*/ + +using Database; +using Microsoft.EntityFrameworkCore; + +namespace GenOnlineService; + +public enum LobbyMembershipStatus +{ + Success, + NoChange, + ActorNotMember, + ActorNotHost, + NotMatched, + AnticheatMismatch, + SessionUnavailable, + PasswordMismatch, + NotInSetup, + AlreadyMember, + NoOpenSlot, + BlockedByOwner, + FriendshipRequired, + PreferencesUnavailable +} + +public sealed record JoinLobbyCommand(long LobbyID, long UserID, EUserSessionType SessionType, + ushort PreferredPort, bool HasMap, EKnownAnticheatID AnticheatID, string? Password); +public sealed record LeaveLobbyCommand(long LobbyID, long UserID, EUserSessionType SessionType); +public sealed record KickLobbyMemberCommand(long LobbyID, long ActorUserID, long? TargetUserID); +public sealed record JoinLobbyResult(LobbyMembershipStatus Status, string TurnUsername = "", string TurnToken = ""); + +public partial class LobbyManager +{ + // Bounded stripes serialize a user's membership transitions across lobbies. Always + // acquire this before a lobby gate; never acquire another stripe while holding one. + private readonly SemaphoreSlim[] membershipGates = Enumerable.Range(0, 256).Select(_ => new SemaphoreSlim(1, 1)).ToArray(); + + private async Task AcquireMembershipGateAsync(long userID) + { + var gate = membershipGates[(int)((ulong)userID % (uint)membershipGates.Length)]; + await gate.WaitAsync(); + return new MembershipLease(gate); + } + + private sealed class MembershipLease(SemaphoreSlim gate) : IDisposable + { + public void Dispose() => gate.Release(); + } + + private readonly IDbContextFactory membershipDbFactory; + private readonly LobbyMembershipConnections membershipConnections; + + public async Task JoinAsync(JoinLobbyCommand command) + { + using var membership = await AcquireMembershipGateAsync(command.UserID); + var lobby = GetLobby(command.LobbyID); + if (lobby == null) return new(LobbyMembershipStatus.NoChange); + if (command.UserID == -1 || !SessionHelpers.SessionTypeHasAccessTo(command.SessionType, ESessionAccessType.Gameplay)) + return new(LobbyMembershipStatus.NoChange); + + var session = membershipConnections.GetSession(command.UserID, command.SessionType); + if (session == null || session.IsRetired) return new(LobbyMembershipStatus.SessionUnavailable); + + await using var db = await membershipDbFactory.CreateDbContextAsync(); + var displayName = await Users.GetDisplayName(db, command.UserID); + var preferences = await Users.GetUserLobbyPreferences(db, command.UserID); + if (preferences == null) return new(LobbyMembershipStatus.PreferencesUnavailable); + var sources = m_dictLobbies.Values.Where(source => source.GetMemberFromUserID(command.UserID) != null).ToArray(); + var status = await Lobby.TransferMemberAsync(lobby, sources, command, session, displayName, preferences); + if (status != LobbyMembershipStatus.Success) return new(status); + + var credentials = await membershipConnections.CreateRelayCredentialsAsync(command.UserID); + return new(status, credentials.Username, credentials.Token); + } + + public async Task LeaveAsync(LeaveLobbyCommand command) + { + if (command.UserID == -1 || !SessionHelpers.SessionTypeHasAccessTo(command.SessionType, ESessionAccessType.Gameplay)) + return false; + using var membership = await AcquireMembershipGateAsync(command.UserID); + var lobby = GetLobby(command.LobbyID); + if (lobby == null) return false; + return await RemoveMembershipAsync(lobby, command.UserID) != null; + } + + public async Task KickAsync(KickLobbyMemberCommand command) + { + using var membership = await AcquireMembershipGateAsync(command.TargetUserID ?? command.ActorUserID); + var lobby = GetLobby(command.LobbyID); + if (lobby == null) return LobbyMembershipStatus.NoChange; + var status = await lobby.KickMemberAsync(command); + if (status == LobbyMembershipStatus.Success && command.TargetUserID is long targetID) + { + await ClearSessionMembershipStateAsync(targetID, EUserSessionType.GameClient); + // Removed members no longer receive ordinary dirty lobby updates. + await lobby.DirtyRetransmitToSingleMember(targetID); + } + return status; + } + + private async Task ClearSessionMembershipStateAsync(long userID, EUserSessionType sessionType) + { + try + { + await membershipConnections.DeleteRelayCredentialsAsync(userID); + } + finally + { + membershipConnections.GetSession(userID, sessionType)?.UpdateSessionLobbyID(-1); + } + // Match-history membership is owned by the match-history service. + } + + internal async Task RemoveMatchmakingMemberAsync(Lobby lobby, LobbyMember member, bool sendPeerTeardown = false) + { + using var membership = await AcquireMembershipGateAsync(member.UserID); + if (!member.GetSession().TryGetTarget(out var session) || !IsCurrentSession(session) || session.IsRetired) return; + if (sendPeerTeardown) lobby.SendPeerTeardownToDepartingMember(member); + await RemoveMembershipAsync(lobby, member.UserID, member); + } + + // Caller holds the user's membership gate through removal and session cleanup. + private async Task RemoveMembershipAsync(Lobby lobby, long userID, LobbyMember? expectedMember = null) + { + var removed = await lobby.RemoveMemberAsync(userID, expectedMember); + if (removed != null && removed.GetSession().TryGetTarget(out var session) + && IsCurrentSession(session) && !session.IsRetired && session.currentLobbyID == lobby.LobbyID) + await ClearSessionMembershipStateAsync(userID, session.GetSessionType()); + return removed; + } + + internal async Task TryRequeueMatchmakingAsync(UserSession session, long expectedLobbyID, byte[] requeueActionJSON) + { + using var membership = await AcquireMembershipGateAsync(session.m_UserID); + if (!IsCurrentSession(session) || session.IsRetired + || (session.currentLobbyID != -1 && session.currentLobbyID != expectedLobbyID)) return false; + if (session.currentLobbyID == expectedLobbyID && expectedLobbyID != -1) + await ClearSessionMembershipStateAsync(session.m_UserID, session.GetSessionType()); + return await MatchmakingManager.TryRequeueRegisteredPlayer(session, requeueActionJSON); + } + + public async Task LeaveAllLobbiesAsync(long userID) + { + using var membership = await AcquireMembershipGateAsync(userID); + await LeaveMembershipsAsync(userID); + } + + private async Task LeaveMembershipsAsync(long userID) + { + foreach (Lobby lobby in m_dictLobbies.Values) + await RemoveMembershipAsync(lobby, userID); + } + internal async Task<(string Username, string Token)> CreateLobbyRelayCredentialsAsync(UserSession session, long lobbyID) + { + using var membership = await AcquireMembershipGateAsync(session.m_UserID); + if (!IsCurrentSession(session) || session.IsRetired || session.currentLobbyID != lobbyID) + return ("", ""); + return await membershipConnections.CreateRelayCredentialsAsync(session.m_UserID); + } + +} diff --git a/GenOnlineService/LobbyManager.Sessions.cs b/GenOnlineService/LobbyManager.Sessions.cs new file mode 100644 index 0000000..8f1940b --- /dev/null +++ b/GenOnlineService/LobbyManager.Sessions.cs @@ -0,0 +1,124 @@ +/* +** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour +** Copyright (C) 2025 GeneralsOnline Development Team +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU Affero General Public License as +** published by the Free Software Foundation, either version 3 of the +** License, or (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU Affero General Public License for more details. +** +** You should have received a copy of the GNU Affero General Public License +** along with this program. If not, see . +*/ + +using Database; +using System.Net.WebSockets; + +namespace GenOnlineService; + +public partial class LobbyManager +{ + internal async Task OpenSessionAsync(AppDbContext db, EUserSessionType sessionType, bool reconnect, + long userID, KnownClients.EKnownClients clientID, string ipAddress, string continent, string country, + double latitude, double longitude, bool admin) + { + using var membership = await AcquireMembershipGateAsync(userID); + var previous = membershipConnections.GetSession(userID, sessionType); + if (!reconnect && previous != null) await DestroySessionAsync(previous); + if (reconnect && (previous == null || previous.IsRetired)) return null; + return await WebSocketManager.InstallSession(db, sessionType, reconnect, userID, clientID, + ipAddress, continent, country, latitude, longitude, admin); + } + + internal async Task DisconnectSessionAsync(UserSession? session, UserWebSocketInstance? socket, bool destroySession) + { + if (session == null) return; + using (await AcquireMembershipGateAsync(session.m_UserID)) + { + if (IsCurrentSession(session) && ReferenceEquals(WebSocketManager.GetWebSocketForSession(session), socket)) + { + if (destroySession) await DestroySessionAsync(session); + else + { + WebSocketManager.DetachSocket(session); + session.MarkAbandoned(); + var lobby = GetLobby(session.currentLobbyID); + if (lobby?.State == ELobbyState.INGAME) lobby.RecordPlayerIngameAbandon(session.m_UserID); + } + } + if (socket != null) await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Session being deleted"); + } + } + + internal Task ClearSessionAsync(UserSession? session) => RemoveSessionAsync(session, expiredOnly: false); + internal Task ExpireSessionAsync(UserSession session) => RemoveSessionAsync(session, expiredOnly: true); + + private async Task RemoveSessionAsync(UserSession? session, bool expiredOnly) + { + if (session == null) return false; + using var membership = await AcquireMembershipGateAsync(session.m_UserID); + if (!IsCurrentSession(session)) return false; + if (expiredOnly && !WebSocketManager.ShouldStillClearAbandonedSession(session, session)) return false; + await DestroySessionAsync(session); + return true; + } + + private bool IsCurrentSession(UserSession session) => + ReferenceEquals(membershipConnections.GetSession(session.m_UserID, session.GetSessionType()), session); + + private async Task DestroySessionAsync(UserSession session) + { + session.Retire(); + bool gameplay = SessionHelpers.SessionTypeHasAccessTo(session.GetSessionType(), ESessionAccessType.Gameplay); + try + { + if (gameplay) + { + await MatchmakingManager.DetachSessionAsync(session); + await LeaveMembershipsAsync(session.m_UserID); + await CleanupUserLobbiesNotStartedAsync(session.m_UserID); + } + } + catch (Exception ex) + { + SentrySdk.CaptureException(ex); + throw; + } + finally + { + try + { + if (gameplay) await membershipConnections.DeleteRelayCredentialsAsync(session.m_UserID); + } + finally + { + var socket = WebSocketManager.DetachSocket(session); + try + { + WebSocketManager.RemoveSessionData(session); + } + finally + { + // Send loops never invoke lifecycle operations. Stop the old queue reader + // before allowing a reconnect to attach another reader to the same session. + if (socket != null) await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Session replaced or expired"); + } + } + } + } + + internal async Task CancelMatchmakingAsync(UserSession session) + { + using var membership = await AcquireMembershipGateAsync(session.m_UserID); + if (!IsCurrentSession(session) || session.IsRetired) return; + var detached = await MatchmakingManager.DetachSessionAsync(session); + foreach (var entry in detached.Members) + await RemoveMembershipAsync(entry.Lobby, session.m_UserID, entry.Member); + if (!detached.CancellationRejected) await LeaveMembershipsAsync(session.m_UserID); + } +} diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 5a677d7..9decb52 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -111,7 +111,7 @@ public class Lobby // SemaphoreSlim, so there is exactly one lock to reason about and no lock-ordering hazard // between the two. It is NOT reentrant: a callback passed to RunExclusiveAsync must never // call back into RunExclusiveAsync on this same lobby, or it will deadlock - callers that are - // already inside a callback (e.g. DoHostMigration, called from RemoveMember's callback) must + // already inside a callback (e.g. DoHostMigration, called from RemoveMemberAsync's callback) must // mutate state directly instead. Sending a websocket message from inside a callback is safe: // QueueWebsocketSend only enqueues bytes onto an in-memory bounded channel (a non-blocking // TryWrite) - it never performs real network I/O itself, so it cannot block while the gate is @@ -720,8 +720,8 @@ public int MaxPlayers public ELobbyJoinability LobbyJoinability { get; private set; } = ELobbyJoinability.Public; // public by default - public const int maxLobbySize = 8; - public LobbyMember[] Members { get; private set; } = new LobbyMember[maxLobbySize]; + public const int MaxLobbySize = 8; + public LobbyMember[] Members { get; private set; } = new LobbyMember[MaxLobbySize]; public EKnownAnticheatID AnticheatID { get; private set; } = EKnownAnticheatID.NONE; @@ -847,11 +847,11 @@ public Lobby(Int64 lobby_id, UserSession owner, string name, ELobbyState state, RNGSeed = rng_seed; MaximumCameraHeight = max_cam_height; LobbyType = lobbyType; - Members = new LobbyMember[maxLobbySize]; + Members = new LobbyMember[MaxLobbySize]; AnticheatID = inAnticheatID; // create default slots - for (UInt16 i = 0; i < maxLobbySize; ++i) + for (UInt16 i = 0; i < MaxLobbySize; ++i) { LobbyMember placeholderMember = new LobbyMember(this, null, -1, String.Empty, String.Empty, 0, -1, -1, -1, i < max_players ? EPlayerType.SLOT_OPEN : EPlayerType.SLOT_CLOSED, i, true); Members[i] = placeholderMember; @@ -861,8 +861,8 @@ public Lobby(Int64 lobby_id, UserSession owner, string name, ELobbyState state, public event Action? OnLobbyNeedsDestroyed; - // Must only be called while holding m_LobbyGate (from within RemoveMember's callback): it reads - // and mutates Members/Owner and must observe RemoveMember's slot clear atomically with any + // Must only be called while holding m_LobbyGate (from within RemoveMemberAsync's callback): it reads + // and mutates Members/Owner and must observe RemoveMemberAsync's slot clear atomically with any // concurrent join/leave/migration. private void OnAfterPlayerLeftLocked(Int64 leavingUserID) { @@ -934,7 +934,7 @@ public async Task FinalizeACChecks() } // Runs under the per-lobby gate: this mutates slot state and previously ran unguarded, - // racing against AddMember/RemoveMember/other slot updates. Sequential with (never nested + // racing against AddMember/RemoveMemberAsync/other slot updates. Sequential with (never nested // inside) StartFullMeshConnectivityCheck's own gate use - callers await this first and then // await StartFullMeshConnectivityCheck as a separate gated operation, so the gate is never // re-entered. @@ -956,7 +956,7 @@ await RunExclusiveAsync(() => } // Must only be called while holding m_LobbyGate (currently only true from - // OnAfterPlayerLeftLocked, itself only called from within RemoveMember's callback). + // OnAfterPlayerLeftLocked, itself only called from within RemoveMemberAsync's callback). private void DoHostMigration() { Int64 oldOwner = Owner; @@ -972,8 +972,10 @@ private void DoHostMigration() Owner = member.UserID; member.UpdateSlotIndex(0); - Members[0] = member; - Members[oldSlot] = new LobbyMember(this, null, -1, String.Empty, String.Empty, 0, -1, -1, -1, EPlayerType.SLOT_OPEN, oldSlot, true); + var migrated = (LobbyMember[])Members.Clone(); + migrated[0] = member; + migrated[oldSlot] = new LobbyMember(this, null, -1, String.Empty, String.Empty, 0, -1, -1, -1, EPlayerType.SLOT_OPEN, oldSlot, true); + Members = migrated; member.SetReadyState(true); DirtyRetransmitLobbyList(); @@ -1084,73 +1086,81 @@ public async Task Tick() } } - public async Task AddMember(UserSession playerSession, string strDisplayName, UInt16 userPreferredPort, bool bHasMap, UserLobbyPreferences lobbyPrefs) + internal static async Task TransferMemberAsync(Lobby destination, IReadOnlyCollection sources, + JoinLobbyCommand command, UserSession session, string displayName, UserLobbyPreferences preferences) + { + var locked = sources.Append(destination).Distinct().OrderBy(lobby => lobby.LobbyID).ToArray(); + int acquired = 0; + try + { + // The caller holds the user's membership gate. All multi-lobby operations use + // ascending IDs; only synchronous state changes and queue writes follow. + foreach (var lobby in locked) + { + await lobby.m_LobbyGate.WaitAsync(); + ++acquired; + } + var admission = destination.ValidateJoin(command); + if (admission != LobbyMembershipStatus.Success) return admission; + var added = destination.AddMemberLocked(session, displayName, command.PreferredPort, command.HasMap, preferences); + if (added.Member == null) return added.Status; + // Destination admission and insertion must succeed before releasing old membership. + foreach (var source in sources) + { + if (ReferenceEquals(source, destination)) continue; + var member = source.GetMemberFromUserID(command.UserID); + if (member != null && source.RemoveMemberLocked(member)) source.PublishMemberRemoval(member); + } + destination.PublishMemberAddition(session, added.Member); + return LobbyMembershipStatus.Success; + } + finally + { + for (int index = acquired - 1; index >= 0; --index) locked[index].m_LobbyGate.Release(); + } + } + + internal Task TryMarkCompleteIfEmptyAsync(long? expectedOwner = null, bool setupOnly = false) => + RunExclusiveAsync(() => + { + if ((expectedOwner.HasValue && Owner != expectedOwner.Value) || (setupOnly && State != ELobbyState.GAME_SETUP) + || GetNumberOfHumans() != 0) return Task.FromResult(false); + State = ELobbyState.COMPLETE; + DirtyRetransmitLobbyList(); + return Task.FromResult(true); + }); + + internal Task AddCreatorAsync(UserSession session, string displayName, ushort preferredPort, UserLobbyPreferences preferences) => + RunExclusiveAsync(() => + { + var added = AddMemberLocked(session, displayName, preferredPort, true, preferences); + if (added.Member != null) PublishMemberAddition(session, added.Member); + return Task.FromResult(added.Status); + }); + + private (LobbyMembershipStatus Status, LobbyMember? Member) AddMemberLocked(UserSession playerSession, string strDisplayName, UInt16 userPreferredPort, bool bHasMap, UserLobbyPreferences lobbyPrefs) { - // NOTE: AddMember runs inside the per-lobby gate, so timing + slot determination can no - // longer result in two concurrent joins picking the same slot. - return await RunExclusiveAsync(async () => + if (State != ELobbyState.GAME_SETUP) return (LobbyMembershipStatus.NotInSetup, null); + if (GetMemberFromUserID(playerSession.m_UserID) != null) return (LobbyMembershipStatus.AlreadyMember, null); + + int openSlot = Array.FindIndex(Members, member => member.SlotState == EPlayerType.SLOT_OPEN); + if (openSlot < 0) return (LobbyMembershipStatus.NoOpenSlot, null); + ushort slotIndex = (ushort)openSlot; + + // QuickMatch applies social eligibility during matchmaking. + if (LobbyType == ELobbyType.CustomGame) { - if (State != ELobbyState.GAME_SETUP) + var ownerData = WebSocketManager.GetSharedDataForUser(Owner); + if (ownerData != null) { - return false; + var social = ownerData.GetSocialContainer(); + if (social.Blocked.Contains(playerSession.m_UserID)) return (LobbyMembershipStatus.BlockedByOwner, null); + if (LobbyJoinability == ELobbyJoinability.FriendsOnly && !social.Friends.Contains(playerSession.m_UserID)) + return (LobbyMembershipStatus.FriendshipRequired, null); } + } - // NOTE: this must be inside the lock, otherwise two concurrent joins for the same user can both pass - // the check and end up occupying two slots - LobbyMember? existingMember = GetMemberFromUserID(playerSession.m_UserID); - if (existingMember != null) // we're already in this lobby - { - return false; - } - - // find first open slot - bool bFoundSlot = false; - UInt16 slotIndex = 0; - foreach (var memberEntry in Members) - { - if (memberEntry.SlotState == EPlayerType.SLOT_OPEN) - { - // found a gap, use this slot index - bFoundSlot = true; - break; - } - ++slotIndex; - } - - if (!bFoundSlot) - { - return false; - } - - // Check social requirements (dont allow blocked in, and check friends only) - // SOCIAL: If the lobby owner has source user blocked, remove the lobby - // NOTE: Only check this for custom match, quick match checks it during matchmaking bucket stage - if (LobbyType == ELobbyType.CustomGame) - { - SharedUserData? lobbyOwnerSharedData = WebSocketManager.GetSharedDataForUser(Owner); // owner must be a game client - - if (lobbyOwnerSharedData != null) - { - // dont allow join if blocked - if (lobbyOwnerSharedData.GetSocialContainer().Blocked.Contains(playerSession.m_UserID)) - { - return false; - } - - // check joinability - if (LobbyJoinability == ELobbyJoinability.FriendsOnly) - { - // If it's friends only, return false if they aren't friends - if (!lobbyOwnerSharedData.GetSocialContainer().Friends.Contains(playerSession.m_UserID)) - { - return false; - } - } - } - } - - // de dupe names - string strOriginalDisplayName = strDisplayName; + string strOriginalDisplayName = strDisplayName; int dupesSeen = 0; string strNameLower = strDisplayName.ToLower(); foreach (var memberEntry in Members) @@ -1226,6 +1236,11 @@ public async Task AddMember(UserSession playerSession, string strDisplayNa playerSession.UpdateSessionLobbyID(LobbyID); + return (LobbyMembershipStatus.Success, newMember); + } + + private void PublishMemberAddition(UserSession playerSession, LobbyMember newMember) + { // START NETWORK SIGNALLING // send all existing player to new player and vice-versa foreach (LobbyMember memberEntry in Members) @@ -1260,7 +1275,7 @@ public async Task AddMember(UserSession playerSession, string strDisplayNa existingPlayerMsg.msg_id = (int)EWebSocketMessageID.NETWORK_CONNECTION_START_SIGNALLING; existingPlayerMsg.lobby_id = LobbyID; existingPlayerMsg.user_id = playerSession.m_UserID; - existingPlayerMsg.preferred_port = userPreferredPort; + existingPlayerMsg.preferred_port = newMember.Port; existingPlayerMsg.middleware_id = playerSession.GetMiddlewareID(); remoteSession.QueueWebsocketSend(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(existingPlayerMsg))); @@ -1279,9 +1294,8 @@ public async Task AddMember(UserSession playerSession, string strDisplayNa // Notify lobby members and room browsers. DirtyRetransmitLobbyList(); - Console.WriteLine("User {0} joined lobby {1}: {2} (Slot was {3})", playerSession.m_UserID, LobbyID, true, slotIndex); - return true; - }); + Console.WriteLine("User {0} joined lobby {1}: {2} (Slot was {3})", playerSession.m_UserID, LobbyID, true, newMember.SlotIndex); + } public void SendPeerTeardownToDepartingMember(LobbyMember departingMember) @@ -1315,36 +1329,58 @@ public void SendPeerTeardownToDepartingMember(LobbyMember departingMember) } } - public async Task RemoveMember(LobbyMember member) + internal async Task RemoveMemberAsync(long userID, LobbyMember? expectedMember = null) { - // Matchmaking cancellation and the client's explicit lobby leave can arrive concurrently. - // Claim the slot once, and run host migration in the same critical section as the slot - // clear, so teardown, host migration, and destruction callbacks stay atomic and idempotent - // (previously DoHostMigration ran after this gate was released, so it could interleave - // with a concurrent AddMember/RemoveMember and observe a half-updated Members/Owner). - bool bRemoved = await RunExclusiveAsync(() => + LobbyMember? removed = await RunExclusiveAsync(() => { - if (member.SlotIndex < 0 - || member.SlotIndex >= Members.Length - || !ReferenceEquals(Members[member.SlotIndex], member)) - { - return Task.FromResult(false); - } + var member = GetMemberFromUserID(userID); + return Task.FromResult(member != null && (expectedMember == null || ReferenceEquals(member, expectedMember)) + && RemoveMemberLocked(member) ? member : null); + }); + if (removed != null) PublishMemberRemoval(removed); + return removed; + } - LobbyMember placeholderMember = new LobbyMember(this, null, -1, String.Empty, String.Empty, 0, -1, -1, -1, EPlayerType.SLOT_OPEN, member.SlotIndex, true); - Members[member.SlotIndex] = placeholderMember; - TimeMemberLeft[member.UserID] = DateTime.UtcNow; - - OnAfterPlayerLeftLocked(member.UserID); - - return Task.FromResult(true); - }); + internal async Task KickMemberAsync(KickLobbyMemberCommand command) + { + LobbyMember? removed = null; + var status = await RunExclusiveAsync(() => + { + if (GetMemberFromUserID(command.ActorUserID) == null) + return Task.FromResult(LobbyMembershipStatus.ActorNotMember); + if (Owner != command.ActorUserID) + return Task.FromResult(LobbyMembershipStatus.ActorNotHost); + if (command.TargetUserID == Owner) + return Task.FromResult(LobbyMembershipStatus.NoChange); + var member = command.TargetUserID.HasValue ? GetMemberFromUserID(command.TargetUserID.Value) : null; + if (member != null && RemoveMemberLocked(member)) removed = member; + return Task.FromResult(removed != null ? LobbyMembershipStatus.Success : LobbyMembershipStatus.NoChange); + }); + if (removed != null) PublishMemberRemoval(removed); + return status; + } - if (!bRemoved) + // Caller holds the lobby gate; validation and host migration form one transition. + private bool RemoveMemberLocked(LobbyMember member) + { + if (member.SlotIndex < 0 + || member.SlotIndex >= Members.Length + || !ReferenceEquals(Members[member.SlotIndex], member)) { - return; + return false; } + LobbyMember placeholderMember = new LobbyMember(this, null, -1, String.Empty, String.Empty, 0, -1, -1, -1, EPlayerType.SLOT_OPEN, member.SlotIndex, true); + Members[member.SlotIndex] = placeholderMember; + TimeMemberLeft[member.UserID] = DateTime.UtcNow; + + OnAfterPlayerLeftLocked(member.UserID); + + return true; + } + + private void PublishMemberRemoval(LobbyMember member) + { // TODO_LOBBY: Optimize this Int64 UserID = member.UserID; Console.WriteLine("User {0} left lobby {1}", UserID, LobbyID); @@ -1390,6 +1426,15 @@ public async Task RemoveMember(LobbyMember member) } DirtyRetransmitLobbyList(); + } + + private LobbyMembershipStatus ValidateJoin(JoinLobbyCommand command) + { + if (LobbyType == ELobbyType.QuickMatch && !MatchedUserIDs.Contains(command.UserID)) + return LobbyMembershipStatus.NotMatched; + if (IsPassworded && command.Password != Password) + return LobbyMembershipStatus.PasswordMismatch; + return command.AnticheatID == AnticheatID ? LobbyMembershipStatus.Success : LobbyMembershipStatus.AnticheatMismatch; } public int GetNumberOfHumans() @@ -1838,7 +1883,7 @@ public enum EKnownAnticheatID } - public class LobbyManager + public partial class LobbyManager { private ConcurrentDictionary m_dictLobbies = new(); @@ -1856,9 +1901,11 @@ internal Int64 GenerateNextLobbyID() private readonly IServiceProvider _services; - public LobbyManager(IServiceProvider services) + public LobbyManager(IServiceProvider services, IDbContextFactory membershipDbFactory, LobbyMembershipConnections membershipConnections) { _services = services; + this.membershipDbFactory = membershipDbFactory; + this.membershipConnections = membershipConnections; } public async Task Cleanup() @@ -1882,7 +1929,7 @@ public async Task Cleanup() foreach (Lobby lobbyToRemove in lstLobbiesToRemove) { // remove it, also commit it + update leaderboard - await DeleteLobby(lobbyToRemove); + if (await lobbyToRemove.TryMarkCompleteIfEmptyAsync()) await DeleteLobby(lobbyToRemove); } } @@ -1907,7 +1954,7 @@ public async Task ProcessLobbiesNeedingDestroyed() try { - await DeleteLobby(lobbyToDestroy); + if (await lobbyToDestroy.TryMarkCompleteIfEmptyAsync()) await DeleteLobby(lobbyToDestroy); } catch (Exception ex) { @@ -1921,12 +1968,14 @@ public async Task CreateLobby(AppDbContext _db, UserSession owningSession UInt16 hostPreferredPort, bool bVanillaTeams, bool bTrackStats, UInt32 default_starting_cash, bool bPassworded, String strPassword, Int16 parentNetworkRoom, bool bAllowObservers, UInt16 maxCamHeight, UInt32 exe_crc, UInt32 ini_crc, ELobbyType lobbyType, EKnownAnticheatID anticheatID) { + using var membership = await AcquireMembershipGateAsync(owningSession.m_UserID); + if (!IsCurrentSession(owningSession) || owningSession.IsRetired) return -1; Console.WriteLine("Created lobby"); // cant own two lobbies at once, unless in gameplay - await CleanupUserLobbiesNotStarted(owningSession.m_UserID); + await CleanupUserLobbiesNotStartedAsync(owningSession.m_UserID); Console.WriteLine("[Source 3] User {0} Leave Any Lobby", owningSession.m_UserID); - await this.LeaveAnyLobby(owningSession.m_UserID); + await LeaveMembershipsAsync(owningSession.m_UserID); int rng_seed = new Random().Next(); @@ -1954,7 +2003,9 @@ public async Task CreateLobby(AppDbContext _db, UserSession owningSession if (lobbyType != ELobbyType.QuickMatch) // QuickMatch clients join after allocation. { - await JoinLobby(_db, newLobby, owningSession, strOwnerDisplayName, hostPreferredPort, true); + var preferences = await Database.Users.GetUserLobbyPreferences(_db, owningSession.m_UserID); + if (preferences != null) + await newLobby.AddCreatorAsync(owningSession, strOwnerDisplayName, hostPreferredPort, preferences); } // Send initial lobby state and invalidate room listings. @@ -1971,36 +2022,17 @@ public async Task Tick() } } - public async Task JoinLobby(AppDbContext _db, Lobby lobby, UserSession playerSession, string strDisplayName, UInt16 userPreferredPort, bool bHasMap) - { - UserLobbyPreferences? lobbyPrefs = await Database.Users.GetUserLobbyPreferences(_db, playerSession.m_UserID); - - if (lobbyPrefs != null) - { - bool bAdded = await lobby.AddMember(playerSession, strDisplayName, userPreferredPort, bHasMap, lobbyPrefs); - return bAdded; - } - - return false; - } - public int GetNumLobbies() { return m_dictLobbies.Count; } - public async Task CleanupUserLobbiesNotStarted(Int64 UserID) - { - List ownedLobbies = GetPlayerOwnedLobbies(UserID); - foreach (Lobby ownedLobby in ownedLobbies) - { - if (ownedLobby.State == ELobbyState.GAME_SETUP) // only those in setup, in game games can continue - { - await DeleteLobby(ownedLobby); - } - } - } - + private async Task CleanupUserLobbiesNotStartedAsync(Int64 userID) + { + foreach (var lobby in GetPlayerOwnedLobbies(userID)) + if (await lobby.TryMarkCompleteIfEmptyAsync(userID, setupOnly: true)) await DeleteLobby(lobby); + } + public List GetAllLobbies(Int16 selectedRoomID, bool bIncludePassword, bool bAllowInSetup, bool bAllowInGame, bool bAllowCompleted, bool bIncludeAllNetworkRooms) { List listLobbies = new List(); @@ -2126,33 +2158,6 @@ public List GetPlayerOwnedLobbies(Int64 userID) return lstLobbies; } - public async Task LeaveSpecificLobby(Int64 userID, Int64 lobbyID) - { - Lobby? targetLobby = GetLobby(lobbyID); - if (targetLobby != null) - { - LobbyMember? memberEntry = targetLobby.GetMemberFromUserID(userID); - if (memberEntry != null) - { - Console.WriteLine("User {0} Leave Specific Lobby", userID); - await targetLobby.RemoveMember(memberEntry); - } - } - } - - public async Task LeaveAnyLobby(Int64 userID) - { - foreach (Lobby lobbyInst in m_dictLobbies.Values) - { - LobbyMember? member = lobbyInst.GetMemberFromUserID(userID); - if (member != null) - { - Console.WriteLine("User {0} Leave Any Lobby", userID); - await lobbyInst.RemoveMember(member); - } - } - } - public async Task DeleteLobby(Lobby lobby) { try diff --git a/GenOnlineService/LobbyMembershipConnections.cs b/GenOnlineService/LobbyMembershipConnections.cs new file mode 100644 index 0000000..eeece37 --- /dev/null +++ b/GenOnlineService/LobbyMembershipConnections.cs @@ -0,0 +1,44 @@ +/* +** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour +** Copyright (C) 2025 GeneralsOnline Development Team +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU Affero General Public License as +** published by the Free Software Foundation, either version 3 of the +** License, or (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU Affero General Public License for more details. +** +** You should have received a copy of the GNU Affero General Public License +** along with this program. If not, see . +*/ + +namespace GenOnlineService; + +// Adapter for the legacy session and relay registries used by membership commands. +public sealed class LobbyMembershipConnections +{ + public UserSession? GetSession(long userID, EUserSessionType sessionType) => + WebSocketManager.GetSessionFromUser(userID, sessionType); + + public Task DeleteRelayCredentialsAsync(long userID) => + TURNCredentialManager.DeleteCredentialsForUser(userID); + + public async Task<(string Username, string Token)> CreateRelayCredentialsAsync(long userID) + { + try + { + var credentials = await TURNCredentialManager.CreateCredentialsForUser(userID); + return (credentials?.m_strUsername ?? "", credentials?.m_strToken ?? ""); + } + catch (Exception ex) + { + // Relay failure does not undo a successful lobby join. + SentrySdk.CaptureException(ex); + return ("", ""); + } + } +} diff --git a/GenOnlineService/MatchmakingManager.cs b/GenOnlineService/MatchmakingManager.cs index 95d02e3..0b24176 100644 --- a/GenOnlineService/MatchmakingManager.cs +++ b/GenOnlineService/MatchmakingManager.cs @@ -933,6 +933,7 @@ private async Task TriggerFullMeshConnectivityChecks(Lobby lobby) private async Task AbortQuickMatchAutoStart(string reason) { List sessionsToRequeue = new(); + long abortedLobbyID; lock (m_StateLock) { if (m_bAbortInProgress || m_bStartCommitted) @@ -941,6 +942,7 @@ private async Task AbortQuickMatchAutoStart(string reason) } m_bAbortInProgress = true; + abortedLobbyID = m_LobbyID; m_bPendingDeletion = true; foreach (MatchmakingBucketMember member in m_lstMembers) @@ -959,7 +961,7 @@ private async Task AbortQuickMatchAutoStart(string reason) } LobbyManager lobbyManager = ServiceLocator.Services.GetRequiredService(); - Lobby? quickMatchLobby = lobbyManager.GetLobby(m_LobbyID); + Lobby? quickMatchLobby = lobbyManager.GetLobby(abortedLobbyID); if (quickMatchLobby != null) { foreach (UserSession memberSession in sessionsToRequeue) @@ -970,8 +972,7 @@ private async Task AbortQuickMatchAutoStart(string reason) // TODO: Remove this fallback once all supported clients handle MATCHMAKING_ACTION_REQUEUE. // Legacy clients do not understand the requeue action, but they can still tear down // peer and anti-cheat connections before joining the next temporary lobby. - quickMatchLobby.SendPeerTeardownToDepartingMember(lobbyMember); - await quickMatchLobby.RemoveMember(lobbyMember); + await lobbyManager.RemoveMatchmakingMemberAsync(quickMatchLobby, lobbyMember, sendPeerTeardown: true); } } @@ -984,7 +985,7 @@ private async Task AbortQuickMatchAutoStart(string reason) foreach (UserSession memberSession in sessionsToRequeue) { - if (await TryRequeueRegisteredPlayer(memberSession, requeueActionJSON)) + if (await lobbyManager.TryRequeueMatchmakingAsync(memberSession, abortedLobbyID, requeueActionJSON)) { await SendMatchmakingMessage(memberSession, reason); await SendMatchmakingMessage(memberSession, "Re-queueing you into matchmaking..."); @@ -1659,7 +1660,7 @@ public static async Task Tick() { // A cancellation can remove the weak reference while this tick is iterating a snapshot. // Re-check registration while holding the per-session gate before assigning any bucket. - if (!thisSession.IsRegisteredForMatchmaking || !IsPendingSession(thisSession)) + if (thisSession.IsRetired || !thisSession.IsRegisteredForMatchmaking || !IsPendingSession(thisSession)) { lstDestroy.Add(wrSession); continue; @@ -1821,12 +1822,12 @@ private static void RemovePendingSession(UserSession session) } } - private static async Task TryRequeueRegisteredPlayer(UserSession session, byte[] requeueActionJSON) + internal static async Task TryRequeueRegisteredPlayer(UserSession session, byte[] requeueActionJSON) { await session.MatchmakingStateLock.WaitAsync(); try { - if (!session.IsRegisteredForMatchmaking) + if (session.IsRetired || !session.IsRegisteredForMatchmaking) { return false; } @@ -1836,7 +1837,6 @@ private static async Task TryRequeueRegisteredPlayer(UserSession session, lstSessions.Add(new WeakReference(session)); } - session.UpdateSessionLobbyID(-1); session.QueueWebsocketSend(requeueActionJSON); return true; } @@ -1874,16 +1874,16 @@ public static async Task RegisterPlayer(UserSession plr, UInt16 playlistID, List return; } - bool bCancellationRejected; + MatchmakingDetachment detached; await plr.MatchmakingStateLock.WaitAsync(); try { - // A duplicate registration must not leave the player queued twice or in two buckets. + if (plr.IsRetired) return; plr.IsRegisteredForMatchmaking = false; RemovePendingSession(plr); - bCancellationRejected = await RemovePlayerFromAllBuckets(plr); + detached = RemovePlayerFromAllBuckets(plr); - if (!bCancellationRejected) + if (!detached.CancellationRejected) { plr.MatchmakingPlaylistID = playlistID; plr.MatchmakingMapIndicies = new ConcurrentList(validatedMapIndices); @@ -1899,7 +1899,11 @@ public static async Task RegisterPlayer(UserSession plr, UInt16 playlistID, List plr.MatchmakingStateLock.Release(); } - if (bCancellationRejected) + var lobbyManager = ServiceLocator.Services.GetRequiredService(); + foreach (var entry in detached.Members) + await lobbyManager.RemoveMatchmakingMemberAsync(entry.Lobby, entry.Member); + + if (detached.CancellationRejected) { await SendMatchmakingMessage(plr, "Matchmaking cannot be restarted because your game is already starting."); return; @@ -1908,66 +1912,47 @@ public static async Task RegisterPlayer(UserSession plr, UInt16 playlistID, List await SendMatchmakingMessage(plr, "Started matchmaking... Searching for players..."); } - private static async Task RemovePlayerFromAllBuckets(UserSession plr) - { - var lobbyManager = ServiceLocator.Services.GetRequiredService(); - bool bCancellationRejected = false; - - // also remove from any bucket we are in to avoid ghost buckets - foreach (var kvPair in m_dictMatchmakingBuckets) - { - foreach (MatchmakingBucket mmBucket in kvPair.Value) - { - bool bRemoved = mmBucket.RemovePlayer(plr, out bool bBucketCancellationRejected); - bCancellationRejected |= bBucketCancellationRejected; - - if (bRemoved) - { - // remove from QM lobby too - Lobby? lobby = lobbyManager.GetLobby(mmBucket.GetLobbyID()); - if (lobby != null) - { - LobbyMember? lobbyMember = lobby.GetMemberFromUserID(plr.m_UserID); - if (lobbyMember != null) - { - Console.WriteLine("User {0} Leave MM Lobby", plr.m_UserID); - await lobby.RemoveMember(lobbyMember); - } - } - - // if we're the last player, destroy the bucket - if (mmBucket.CurrentMemberCount() == 0) - { - DestroyBucket(mmBucket); - } - } - } - } - - return bCancellationRejected; - } - - public static async Task DeregisterPlayer(UserSession plr) - { - var lobbyManager = ServiceLocator.Services.GetRequiredService(); - bool bCancellationRejected; - await plr.MatchmakingStateLock.WaitAsync(); - try - { - plr.IsRegisteredForMatchmaking = false; - RemovePendingSession(plr); - bCancellationRejected = await RemovePlayerFromAllBuckets(plr); - } - finally - { - plr.MatchmakingStateLock.Release(); - } - - // leave QM lobby too - if (!bCancellationRejected) - { - Console.WriteLine("[Source 4] User {0} Leave Any Lobby", plr.m_UserID); - await lobbyManager.LeaveAnyLobby(plr.m_UserID); - } - } + internal sealed record MatchmakingDetachment(bool CancellationRejected, List<(Lobby Lobby, LobbyMember Member)> Members); + + private static MatchmakingDetachment RemovePlayerFromAllBuckets(UserSession player) + { + var lobbies = ServiceLocator.Services.GetRequiredService(); + bool rejected = false; + List<(Lobby, LobbyMember)> members = new(); + foreach (var buckets in m_dictMatchmakingBuckets.Values) + { + foreach (var bucket in buckets) + { + bool removed = bucket.RemovePlayer(player, out bool cancellationRejected); + rejected |= cancellationRejected; + if (!removed) continue; + var lobby = lobbies.GetLobby(bucket.GetLobbyID()); + var member = lobby?.GetMemberFromUserID(player.m_UserID); + if (member != null && member.GetSession().TryGetTarget(out var memberSession) && ReferenceEquals(memberSession, player)) + members.Add((lobby!, member)); + if (bucket.CurrentMemberCount() == 0) DestroyBucket(bucket); + } + } + return new(rejected, members); + } + + // Only matchmaking state is changed here. Membership effects run after releasing + // this lock, so lifecycle operations can acquire user gate -> matchmaking lock. + internal static async Task DetachSessionAsync(UserSession player) + { + await player.MatchmakingStateLock.WaitAsync(); + try + { + player.IsRegisteredForMatchmaking = false; + RemovePendingSession(player); + return RemovePlayerFromAllBuckets(player); + } + finally + { + player.MatchmakingStateLock.Release(); + } + } + + public static Task DeregisterPlayerAsync(UserSession player) => + ServiceLocator.Services.GetRequiredService().CancelMatchmakingAsync(player); } diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs index 7de732d..949720a 100644 --- a/GenOnlineService/Program.cs +++ b/GenOnlineService/Program.cs @@ -1244,6 +1244,7 @@ public static async Task Main(string[] args) builder.Services.AddHostedService(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); var rateLimitingSettings = Program.g_Config.GetSection("RateLimiting"); bool bUseBuiltinRateLimiter = rateLimitingSettings.GetValue("use_builtin_ratelimiter"); // use built in Kestrel/dotnet rate limiting if you do not have a reverse proxy or other rate limiter in front of service