From a41ff8d90a16704b889acb737844f68328cf4e1b Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:54:23 +0200 Subject: [PATCH 1/8] refactor(lobby): centralize membership commands and state ownership --- .../Controllers/Lobby/LobbyController.cs | 414 ++++-------------- GenOnlineService/LobbyManager.Membership.cs | 152 +++++++ GenOnlineService/LobbyManager.cs | 227 ++++------ .../LobbyMembershipConnections.cs | 44 ++ GenOnlineService/MatchmakingManager.cs | 5 +- GenOnlineService/Program.cs | 1 + 6 files changed, 386 insertions(+), 457 deletions(-) create mode 100644 GenOnlineService/LobbyManager.Membership.cs create mode 100644 GenOnlineService/LobbyMembershipConnections.cs diff --git a/GenOnlineService/Controllers/Lobby/LobbyController.cs b/GenOnlineService/Controllers/Lobby/LobbyController.cs index 336e93b..82ca130 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( + 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["has_map"].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/LobbyManager.Membership.cs b/GenOnlineService/LobbyManager.Membership.cs new file mode 100644 index 0000000..f6066ba --- /dev/null +++ b/GenOnlineService/LobbyManager.Membership.cs @@ -0,0 +1,152 @@ +/* +** 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 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 EnterMembershipAsync(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 EnterMembershipAsync(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); + + // Admission is checked again atomically with insertion. + var admission = await lobby.CheckJoinAdmission(command); + if (admission != LobbyMembershipStatus.Success) return new(admission); + var session = membershipConnections.GetSession(command.UserID, command.SessionType); + if (session == null) return new(LobbyMembershipStatus.SessionUnavailable); + + await LeaveMemberships(command.UserID); + 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 status = await lobby.JoinMember(command, session, displayName, preferences); + if (status != LobbyMembershipStatus.Success) return new(status); + + var credentials = await membershipConnections.CreateRelayCredentials(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 EnterMembershipAsync(command.UserID); + // DELETE historically leaves any lobby, irrespective of its route ID. + await LeaveMemberships(command.UserID); + await ClearMembership(command.UserID, command.SessionType); + return true; + } + + public async Task KickAsync(KickLobbyMemberCommand command) + { + using var membership = await EnterMembershipAsync(command.TargetUserID ?? command.ActorUserID); + var lobby = GetLobby(command.LobbyID); + if (lobby == null) return LobbyMembershipStatus.NoChange; + var status = await lobby.KickMember(command); + if (status == LobbyMembershipStatus.Success && command.TargetUserID is long targetID) + { + await ClearMembership(targetID, EUserSessionType.GameClient); + // Removed members no longer receive ordinary dirty lobby updates. + await lobby.DirtyRetransmitToSingleMember(targetID); + } + return status; + } + + private async Task ClearMembership(long userID, EUserSessionType sessionType) + { + try + { + await membershipConnections.DeleteRelayCredentials(userID); + } + finally + { + membershipConnections.GetSession(userID, sessionType)?.UpdateSessionLobbyID(-1); + } + // Match-history membership is owned by the match-history service. + } + + internal async Task RemoveMatchmakingMember(Lobby lobby, LobbyMember member, bool sendPeerTeardown = false) + { + using var membership = await EnterMembershipAsync(member.UserID); + if (sendPeerTeardown) lobby.SendPeerTeardownToDepartingMember(member); + await lobby.RemoveMember(member.UserID, member); + } + + public async Task LeaveAnyLobby(long userID) + { + using var membership = await EnterMembershipAsync(userID); + await LeaveMemberships(userID); + } + + private async Task LeaveMemberships(long userID) + { + foreach (Lobby lobby in m_dictLobbies.Values) + await lobby.RemoveMember(userID); + } +} diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 5a677d7..04d10b6 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -1084,73 +1084,44 @@ public async Task Tick() } } - public async Task AddMember(UserSession playerSession, string strDisplayName, UInt16 userPreferredPort, bool bHasMap, UserLobbyPreferences lobbyPrefs) + internal Task CheckJoinAdmission(JoinLobbyCommand command) => + RunExclusiveAsync(() => Task.FromResult(ValidateJoin(command))); + + internal Task JoinMember(JoinLobbyCommand command, UserSession session, string displayName, UserLobbyPreferences preferences) => + RunExclusiveAsync(() => + { + var admission = ValidateJoin(command); + return Task.FromResult(admission == LobbyMembershipStatus.Success + ? AddMemberLocked(session, displayName, command.PreferredPort, command.HasMap, preferences) + : admission); + }); + + internal Task AddCreator(UserSession session, string displayName, ushort preferredPort, UserLobbyPreferences preferences) => + RunExclusiveAsync(() => Task.FromResult(AddMemberLocked(session, displayName, preferredPort, true, preferences))); + + private LobbyMembershipStatus 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; + if (GetMemberFromUserID(playerSession.m_UserID) != null) return LobbyMembershipStatus.AlreadyMember; + + int openSlot = Array.FindIndex(Members, member => member.SlotState == EPlayerType.SLOT_OPEN); + if (openSlot < 0) return LobbyMembershipStatus.NoOpenSlot; + 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; + if (LobbyJoinability == ELobbyJoinability.FriendsOnly && !social.Friends.Contains(playerSession.m_UserID)) + return LobbyMembershipStatus.FriendshipRequired; } + } - // 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) @@ -1280,8 +1251,7 @@ public async Task AddMember(UserSession playerSession, string strDisplayNa DirtyRetransmitLobbyList(); Console.WriteLine("User {0} joined lobby {1}: {2} (Slot was {3})", playerSession.m_UserID, LobbyID, true, slotIndex); - return true; - }); + return LobbyMembershipStatus.Success; } public void SendPeerTeardownToDepartingMember(LobbyMember departingMember) @@ -1315,36 +1285,55 @@ public void SendPeerTeardownToDepartingMember(LobbyMember departingMember) } } - public async Task RemoveMember(LobbyMember member) + internal async Task RemoveMember(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); + } - 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 KickMember(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); + 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 +1379,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 +1836,7 @@ public enum EKnownAnticheatID } - public class LobbyManager + public partial class LobbyManager { private ConcurrentDictionary m_dictLobbies = new(); @@ -1856,9 +1854,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() @@ -1921,12 +1921,13 @@ 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 EnterMembershipAsync(owningSession.m_UserID); Console.WriteLine("Created lobby"); // cant own two lobbies at once, unless in gameplay await CleanupUserLobbiesNotStarted(owningSession.m_UserID); Console.WriteLine("[Source 3] User {0} Leave Any Lobby", owningSession.m_UserID); - await this.LeaveAnyLobby(owningSession.m_UserID); + await LeaveMemberships(owningSession.m_UserID); int rng_seed = new Random().Next(); @@ -1954,7 +1955,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.AddCreator(owningSession, strOwnerDisplayName, hostPreferredPort, preferences); } // Send initial lobby state and invalidate room listings. @@ -1971,25 +1974,12 @@ 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) + public async Task CleanupUserLobbiesNotStarted(Int64 UserID) { List ownedLobbies = GetPlayerOwnedLobbies(UserID); foreach (Lobby ownedLobby in ownedLobbies) @@ -2126,33 +2116,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..380336e --- /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 DeleteRelayCredentials(long userID) => + TURNCredentialManager.DeleteCredentialsForUser(userID); + + public async Task<(string Username, string Token)> CreateRelayCredentials(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..1552666 100644 --- a/GenOnlineService/MatchmakingManager.cs +++ b/GenOnlineService/MatchmakingManager.cs @@ -970,8 +970,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.RemoveMatchmakingMember(quickMatchLobby, lobbyMember, sendPeerTeardown: true); } } @@ -1931,7 +1930,7 @@ private static async Task RemovePlayerFromAllBuckets(UserSession plr) if (lobbyMember != null) { Console.WriteLine("User {0} Leave MM Lobby", plr.m_UserID); - await lobby.RemoveMember(lobbyMember); + await lobbyManager.RemoveMatchmakingMember(lobby, lobbyMember); } } 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 From 21f35859a5827907aac60d1a9816ee3df1fe8e98 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:04:52 +0200 Subject: [PATCH 2/8] refactor(lobby): align membership operation names --- GenOnlineService/Constants.cs | 6 +-- .../Controllers/Lobbies/LobbiesController.cs | 2 +- GenOnlineService/LobbyManager.Membership.cs | 44 +++++++++---------- GenOnlineService/LobbyManager.cs | 38 ++++++++-------- .../LobbyMembershipConnections.cs | 4 +- GenOnlineService/MatchmakingManager.cs | 6 +-- 6 files changed, 50 insertions(+), 50 deletions(-) diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index d717c00..559b081 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -661,7 +661,7 @@ public static async Task DeleteSession(Int64 user_id, EUserSessionType sessionTy // 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 + // and must be captured here rather than in RemoveMemberAsync, because RemoveMemberAsync 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 @@ -1668,9 +1668,9 @@ public static async Task FullyDestroyPlayerSession(Int64 user_id, UserSession? u Console.WriteLine("[Source 2] User {0} Leave Any Lobby", user_id); var lobbyManager = ServiceLocator.Services.GetRequiredService(); - await lobbyManager.LeaveAnyLobby(user_id); + await lobbyManager.LeaveAllLobbiesAsync(user_id); - await lobbyManager.CleanupUserLobbiesNotStarted(user_id); + await lobbyManager.CleanupUserLobbiesNotStartedAsync(user_id); // remove from any matchmaking if (userData != null) diff --git a/GenOnlineService/Controllers/Lobbies/LobbiesController.cs b/GenOnlineService/Controllers/Lobbies/LobbiesController.cs index 167eb72..0b4e031 100644 --- a/GenOnlineService/Controllers/Lobbies/LobbiesController.cs +++ b/GenOnlineService/Controllers/Lobbies/LobbiesController.cs @@ -329,7 +329,7 @@ public async Task Put() if (playerSession != null) { // cleanup any zombie lobbies - await _lobbyManager.CleanupUserLobbiesNotStarted(user_id); + await _lobbyManager.CleanupUserLobbiesNotStartedAsync(user_id); await using var db = await _dbFactory.CreateDbContextAsync(); string strDisplayName = await Database.Users.GetDisplayName(db, user_id); diff --git a/GenOnlineService/LobbyManager.Membership.cs b/GenOnlineService/LobbyManager.Membership.cs index f6066ba..9c8bb6b 100644 --- a/GenOnlineService/LobbyManager.Membership.cs +++ b/GenOnlineService/LobbyManager.Membership.cs @@ -51,7 +51,7 @@ public partial class LobbyManager // 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 EnterMembershipAsync(long userID) + private async Task AcquireMembershipGateAsync(long userID) { var gate = membershipGates[(int)((ulong)userID % (uint)membershipGates.Length)]; await gate.WaitAsync(); @@ -68,27 +68,27 @@ private sealed class MembershipLease(SemaphoreSlim gate) : IDisposable public async Task JoinAsync(JoinLobbyCommand command) { - using var membership = await EnterMembershipAsync(command.UserID); + 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); // Admission is checked again atomically with insertion. - var admission = await lobby.CheckJoinAdmission(command); + var admission = await lobby.CheckJoinAdmissionAsync(command); if (admission != LobbyMembershipStatus.Success) return new(admission); var session = membershipConnections.GetSession(command.UserID, command.SessionType); if (session == null) return new(LobbyMembershipStatus.SessionUnavailable); - await LeaveMemberships(command.UserID); + await LeaveMembershipsAsync(command.UserID); 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 status = await lobby.JoinMember(command, session, displayName, preferences); + var status = await lobby.JoinMemberAsync(command, session, displayName, preferences); if (status != LobbyMembershipStatus.Success) return new(status); - var credentials = await membershipConnections.CreateRelayCredentials(command.UserID); + var credentials = await membershipConnections.CreateRelayCredentialsAsync(command.UserID); return new(status, credentials.Username, credentials.Token); } @@ -96,33 +96,33 @@ public async Task LeaveAsync(LeaveLobbyCommand command) { if (command.UserID == -1 || !SessionHelpers.SessionTypeHasAccessTo(command.SessionType, ESessionAccessType.Gameplay)) return false; - using var membership = await EnterMembershipAsync(command.UserID); + using var membership = await AcquireMembershipGateAsync(command.UserID); // DELETE historically leaves any lobby, irrespective of its route ID. - await LeaveMemberships(command.UserID); - await ClearMembership(command.UserID, command.SessionType); + await LeaveMembershipsAsync(command.UserID); + await ClearSessionMembershipStateAsync(command.UserID, command.SessionType); return true; } public async Task KickAsync(KickLobbyMemberCommand command) { - using var membership = await EnterMembershipAsync(command.TargetUserID ?? command.ActorUserID); + using var membership = await AcquireMembershipGateAsync(command.TargetUserID ?? command.ActorUserID); var lobby = GetLobby(command.LobbyID); if (lobby == null) return LobbyMembershipStatus.NoChange; - var status = await lobby.KickMember(command); + var status = await lobby.KickMemberAsync(command); if (status == LobbyMembershipStatus.Success && command.TargetUserID is long targetID) { - await ClearMembership(targetID, EUserSessionType.GameClient); + await ClearSessionMembershipStateAsync(targetID, EUserSessionType.GameClient); // Removed members no longer receive ordinary dirty lobby updates. await lobby.DirtyRetransmitToSingleMember(targetID); } return status; } - private async Task ClearMembership(long userID, EUserSessionType sessionType) + private async Task ClearSessionMembershipStateAsync(long userID, EUserSessionType sessionType) { try { - await membershipConnections.DeleteRelayCredentials(userID); + await membershipConnections.DeleteRelayCredentialsAsync(userID); } finally { @@ -131,22 +131,22 @@ private async Task ClearMembership(long userID, EUserSessionType sessionType) // Match-history membership is owned by the match-history service. } - internal async Task RemoveMatchmakingMember(Lobby lobby, LobbyMember member, bool sendPeerTeardown = false) + internal async Task RemoveMatchmakingMemberAsync(Lobby lobby, LobbyMember member, bool sendPeerTeardown = false) { - using var membership = await EnterMembershipAsync(member.UserID); + using var membership = await AcquireMembershipGateAsync(member.UserID); if (sendPeerTeardown) lobby.SendPeerTeardownToDepartingMember(member); - await lobby.RemoveMember(member.UserID, member); + await lobby.RemoveMemberAsync(member.UserID, member); } - public async Task LeaveAnyLobby(long userID) + public async Task LeaveAllLobbiesAsync(long userID) { - using var membership = await EnterMembershipAsync(userID); - await LeaveMemberships(userID); + using var membership = await AcquireMembershipGateAsync(userID); + await LeaveMembershipsAsync(userID); } - private async Task LeaveMemberships(long userID) + private async Task LeaveMembershipsAsync(long userID) { foreach (Lobby lobby in m_dictLobbies.Values) - await lobby.RemoveMember(userID); + await lobby.RemoveMemberAsync(userID); } } diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 04d10b6..73325f5 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; @@ -1084,10 +1084,10 @@ public async Task Tick() } } - internal Task CheckJoinAdmission(JoinLobbyCommand command) => + internal Task CheckJoinAdmissionAsync(JoinLobbyCommand command) => RunExclusiveAsync(() => Task.FromResult(ValidateJoin(command))); - internal Task JoinMember(JoinLobbyCommand command, UserSession session, string displayName, UserLobbyPreferences preferences) => + internal Task JoinMemberAsync(JoinLobbyCommand command, UserSession session, string displayName, UserLobbyPreferences preferences) => RunExclusiveAsync(() => { var admission = ValidateJoin(command); @@ -1096,7 +1096,7 @@ internal Task JoinMember(JoinLobbyCommand command, UserSe : admission); }); - internal Task AddCreator(UserSession session, string displayName, ushort preferredPort, UserLobbyPreferences preferences) => + internal Task AddCreatorAsync(UserSession session, string displayName, ushort preferredPort, UserLobbyPreferences preferences) => RunExclusiveAsync(() => Task.FromResult(AddMemberLocked(session, displayName, preferredPort, true, preferences))); private LobbyMembershipStatus AddMemberLocked(UserSession playerSession, string strDisplayName, UInt16 userPreferredPort, bool bHasMap, UserLobbyPreferences lobbyPrefs) @@ -1285,7 +1285,7 @@ public void SendPeerTeardownToDepartingMember(LobbyMember departingMember) } } - internal async Task RemoveMember(long userID, LobbyMember? expectedMember = null) + internal async Task RemoveMemberAsync(long userID, LobbyMember? expectedMember = null) { LobbyMember? removed = await RunExclusiveAsync(() => { @@ -1296,7 +1296,7 @@ internal async Task RemoveMember(long userID, LobbyMember? expectedMember = null if (removed != null) PublishMemberRemoval(removed); } - internal async Task KickMember(KickLobbyMemberCommand command) + internal async Task KickMemberAsync(KickLobbyMemberCommand command) { LobbyMember? removed = null; var status = await RunExclusiveAsync(() => @@ -1921,13 +1921,13 @@ 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 EnterMembershipAsync(owningSession.m_UserID); + using var membership = await AcquireMembershipGateAsync(owningSession.m_UserID); 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 LeaveMemberships(owningSession.m_UserID); + await LeaveMembershipsAsync(owningSession.m_UserID); int rng_seed = new Random().Next(); @@ -1957,7 +1957,7 @@ public async Task CreateLobby(AppDbContext _db, UserSession owningSession { var preferences = await Database.Users.GetUserLobbyPreferences(_db, owningSession.m_UserID); if (preferences != null) - await newLobby.AddCreator(owningSession, strOwnerDisplayName, hostPreferredPort, preferences); + await newLobby.AddCreatorAsync(owningSession, strOwnerDisplayName, hostPreferredPort, preferences); } // Send initial lobby state and invalidate room listings. @@ -1979,7 +1979,7 @@ public int GetNumLobbies() return m_dictLobbies.Count; } - public async Task CleanupUserLobbiesNotStarted(Int64 UserID) + public async Task CleanupUserLobbiesNotStartedAsync(Int64 UserID) { List ownedLobbies = GetPlayerOwnedLobbies(UserID); foreach (Lobby ownedLobby in ownedLobbies) diff --git a/GenOnlineService/LobbyMembershipConnections.cs b/GenOnlineService/LobbyMembershipConnections.cs index 380336e..eeece37 100644 --- a/GenOnlineService/LobbyMembershipConnections.cs +++ b/GenOnlineService/LobbyMembershipConnections.cs @@ -24,10 +24,10 @@ public sealed class LobbyMembershipConnections public UserSession? GetSession(long userID, EUserSessionType sessionType) => WebSocketManager.GetSessionFromUser(userID, sessionType); - public Task DeleteRelayCredentials(long userID) => + public Task DeleteRelayCredentialsAsync(long userID) => TURNCredentialManager.DeleteCredentialsForUser(userID); - public async Task<(string Username, string Token)> CreateRelayCredentials(long userID) + public async Task<(string Username, string Token)> CreateRelayCredentialsAsync(long userID) { try { diff --git a/GenOnlineService/MatchmakingManager.cs b/GenOnlineService/MatchmakingManager.cs index 1552666..57c0ab6 100644 --- a/GenOnlineService/MatchmakingManager.cs +++ b/GenOnlineService/MatchmakingManager.cs @@ -970,7 +970,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. - await lobbyManager.RemoveMatchmakingMember(quickMatchLobby, lobbyMember, sendPeerTeardown: true); + await lobbyManager.RemoveMatchmakingMemberAsync(quickMatchLobby, lobbyMember, sendPeerTeardown: true); } } @@ -1930,7 +1930,7 @@ private static async Task RemovePlayerFromAllBuckets(UserSession plr) if (lobbyMember != null) { Console.WriteLine("User {0} Leave MM Lobby", plr.m_UserID); - await lobbyManager.RemoveMatchmakingMember(lobby, lobbyMember); + await lobbyManager.RemoveMatchmakingMemberAsync(lobby, lobbyMember); } } @@ -1966,7 +1966,7 @@ public static async Task DeregisterPlayer(UserSession plr) if (!bCancellationRejected) { Console.WriteLine("[Source 4] User {0} Leave Any Lobby", plr.m_UserID); - await lobbyManager.LeaveAnyLobby(plr.m_UserID); + await lobbyManager.LeaveAllLobbiesAsync(plr.m_UserID); } } } From f7ea2330b590b2077d9aa7ca7691bd71f36c70c6 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:03:13 +0200 Subject: [PATCH 3/8] refactor(matchmaking): separate detachment from membership effects --- GenOnlineService/MatchmakingManager.cs | 124 ++++++++++++------------- 1 file changed, 57 insertions(+), 67 deletions(-) diff --git a/GenOnlineService/MatchmakingManager.cs b/GenOnlineService/MatchmakingManager.cs index 57c0ab6..b7f0153 100644 --- a/GenOnlineService/MatchmakingManager.cs +++ b/GenOnlineService/MatchmakingManager.cs @@ -1873,16 +1873,15 @@ 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. 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); @@ -1898,7 +1897,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; @@ -1907,66 +1910,53 @@ 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 lobbyManager.RemoveMatchmakingMemberAsync(lobby, 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.LeaveAllLobbiesAsync(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 DetachSession(UserSession player) + { + await player.MatchmakingStateLock.WaitAsync(); + try + { + player.IsRegisteredForMatchmaking = false; + RemovePendingSession(player); + return RemovePlayerFromAllBuckets(player); + } + finally + { + player.MatchmakingStateLock.Release(); + } + } + + public static async Task DeregisterPlayer(UserSession player) + { + var detached = await DetachSession(player); + var lobbies = ServiceLocator.Services.GetRequiredService(); + foreach (var entry in detached.Members) + await lobbies.RemoveMatchmakingMemberAsync(entry.Lobby, entry.Member); + if (!detached.CancellationRejected) await lobbies.LeaveAllLobbiesAsync(player.m_UserID); + } } From 50d2a6803ea54098d67aa44ac6b75b14a20f7472 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:05:27 +0200 Subject: [PATCH 4/8] refactor(matchmaking): align detachment operation names --- GenOnlineService/Constants.cs | 2 +- .../Controllers/Matchmaking/MatchmakingController.cs | 2 +- GenOnlineService/MatchmakingManager.cs | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index 559b081..9da5bbb 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -1675,7 +1675,7 @@ public static async Task FullyDestroyPlayerSession(Int64 user_id, UserSession? u // remove from any matchmaking if (userData != null) { - await MatchmakingManager.DeregisterPlayer(userData); + await MatchmakingManager.DeregisterPlayerAsync(userData); } // TODO: Client needs to handle this... itll start returning 404 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/MatchmakingManager.cs b/GenOnlineService/MatchmakingManager.cs index b7f0153..1fc38fd 100644 --- a/GenOnlineService/MatchmakingManager.cs +++ b/GenOnlineService/MatchmakingManager.cs @@ -1936,7 +1936,7 @@ private static MatchmakingDetachment RemovePlayerFromAllBuckets(UserSession play // 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 DetachSession(UserSession player) + internal static async Task DetachSessionAsync(UserSession player) { await player.MatchmakingStateLock.WaitAsync(); try @@ -1951,9 +1951,9 @@ internal static async Task DetachSession(UserSession play } } - public static async Task DeregisterPlayer(UserSession player) + public static async Task DeregisterPlayerAsync(UserSession player) { - var detached = await DetachSession(player); + var detached = await DetachSessionAsync(player); var lobbies = ServiceLocator.Services.GetRequiredService(); foreach (var entry in detached.Members) await lobbies.RemoveMatchmakingMemberAsync(entry.Lobby, entry.Member); From 3aaa0085d955d7cbbe5a08db1a158f1a66ada460 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:03:36 +0200 Subject: [PATCH 5/8] fix(session): serialize lifecycle cleanup with membership changes --- GenOnlineService/Constants.cs | 320 +++++------------- .../Controllers/Lobbies/LobbiesController.cs | 13 +- GenOnlineService/LobbyManager.Membership.cs | 11 +- GenOnlineService/LobbyManager.Sessions.cs | 124 +++++++ GenOnlineService/LobbyManager.cs | 3 +- GenOnlineService/MatchmakingManager.cs | 15 +- 6 files changed, 229 insertions(+), 257 deletions(-) create mode 100644 GenOnlineService/LobbyManager.Sessions.cs diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index 9da5bbb..4c2eff5 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 RemoveMemberAsync, because RemoveMemberAsync 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 invalidateCache, UserSession? expectedSession = null) + { + var session = expectedSession ?? oldWS?.OwnerSession ?? GetSessionFromUser(userID, sessionType); + return ServiceLocator.Services.GetRequiredService().DisconnectSessionAsync(session, oldWS, invalidateCache); + } + + 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.LeaveAllLobbiesAsync(user_id); - - await lobbyManager.CleanupUserLobbiesNotStartedAsync(user_id); - - // remove from any matchmaking - if (userData != null) - { - await MatchmakingManager.DeregisterPlayerAsync(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 0b4e031..32a3d06 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.CleanupUserLobbiesNotStartedAsync(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.CreateLobbyRelayCredentials(playerSession, newLobbyID); + result.turn_username = credentials.Username; + result.turn_token = credentials.Token; } else { diff --git a/GenOnlineService/LobbyManager.Membership.cs b/GenOnlineService/LobbyManager.Membership.cs index 9c8bb6b..42d1afa 100644 --- a/GenOnlineService/LobbyManager.Membership.cs +++ b/GenOnlineService/LobbyManager.Membership.cs @@ -78,7 +78,7 @@ public async Task JoinAsync(JoinLobbyCommand command) var admission = await lobby.CheckJoinAdmissionAsync(command); if (admission != LobbyMembershipStatus.Success) return new(admission); var session = membershipConnections.GetSession(command.UserID, command.SessionType); - if (session == null) return new(LobbyMembershipStatus.SessionUnavailable); + if (session == null || session.IsRetired) return new(LobbyMembershipStatus.SessionUnavailable); await LeaveMembershipsAsync(command.UserID); await using var db = await membershipDbFactory.CreateDbContextAsync(); @@ -134,6 +134,7 @@ private async Task ClearSessionMembershipStateAsync(long userID, EUserSessionTyp 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 lobby.RemoveMemberAsync(member.UserID, member); } @@ -149,4 +150,12 @@ private async Task LeaveMembershipsAsync(long userID) foreach (Lobby lobby in m_dictLobbies.Values) await lobby.RemoveMemberAsync(userID); } + internal async Task<(string Username, string Token)> CreateLobbyRelayCredentials(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..38498b6 --- /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 DestroySession(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 invalidateCache) + { + if (session == null) return; + using (await AcquireMembershipGateAsync(session.m_UserID)) + { + if (IsCurrentSession(session) && ReferenceEquals(WebSocketManager.GetWebSocketForSession(session), socket)) + { + if (invalidateCache) await DestroySession(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 DestroySession(session); + return true; + } + + private bool IsCurrentSession(UserSession session) => + ReferenceEquals(membershipConnections.GetSession(session.m_UserID, session.GetSessionType()), session); + + private async Task DestroySession(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 entry.Lobby.RemoveMemberAsync(session.m_UserID, entry.Member); + if (!detached.CancellationRejected) await LeaveMembershipsAsync(session.m_UserID); + } +} diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 73325f5..3d7cb51 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -1922,6 +1922,7 @@ public async Task CreateLobby(AppDbContext _db, UserSession owningSession 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 CleanupUserLobbiesNotStartedAsync(owningSession.m_UserID); @@ -1979,7 +1980,7 @@ public int GetNumLobbies() return m_dictLobbies.Count; } - public async Task CleanupUserLobbiesNotStartedAsync(Int64 UserID) + private async Task CleanupUserLobbiesNotStartedAsync(Int64 UserID) { List ownedLobbies = GetPlayerOwnedLobbies(UserID); foreach (Lobby ownedLobby in ownedLobbies) diff --git a/GenOnlineService/MatchmakingManager.cs b/GenOnlineService/MatchmakingManager.cs index 1fc38fd..dd08485 100644 --- a/GenOnlineService/MatchmakingManager.cs +++ b/GenOnlineService/MatchmakingManager.cs @@ -1658,7 +1658,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; @@ -1825,7 +1825,7 @@ private static async Task TryRequeueRegisteredPlayer(UserSession session, await session.MatchmakingStateLock.WaitAsync(); try { - if (!session.IsRegisteredForMatchmaking) + if (session.IsRetired || !session.IsRegisteredForMatchmaking) { return false; } @@ -1877,6 +1877,7 @@ public static async Task RegisterPlayer(UserSession plr, UInt16 playlistID, List await plr.MatchmakingStateLock.WaitAsync(); try { + if (plr.IsRetired) return; plr.IsRegisteredForMatchmaking = false; RemovePendingSession(plr); detached = RemovePlayerFromAllBuckets(plr); @@ -1951,12 +1952,6 @@ internal static async Task DetachSessionAsync(UserSession } } - public static async Task DeregisterPlayerAsync(UserSession player) - { - var detached = await DetachSessionAsync(player); - var lobbies = ServiceLocator.Services.GetRequiredService(); - foreach (var entry in detached.Members) - await lobbies.RemoveMatchmakingMemberAsync(entry.Lobby, entry.Member); - if (!detached.CancellationRejected) await lobbies.LeaveAllLobbiesAsync(player.m_UserID); - } + public static Task DeregisterPlayerAsync(UserSession player) => + ServiceLocator.Services.GetRequiredService().CancelMatchmakingAsync(player); } From b8f680975c757ed2259b0c533bffa22912f663e0 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:23:45 +0200 Subject: [PATCH 6/8] fix(lobby): make membership transfers and cleanup race-safe --- .../Controllers/Lobby/LobbyController.cs | 4 +- GenOnlineService/LobbyManager.Membership.cs | 20 ++-- GenOnlineService/LobbyManager.cs | 109 ++++++++++++------ 3 files changed, 88 insertions(+), 45 deletions(-) diff --git a/GenOnlineService/Controllers/Lobby/LobbyController.cs b/GenOnlineService/Controllers/Lobby/LobbyController.cs index 82ca130..db7a085 100644 --- a/GenOnlineService/Controllers/Lobby/LobbyController.cs +++ b/GenOnlineService/Controllers/Lobby/LobbyController.cs @@ -216,7 +216,7 @@ public async Task Delete(Int64 lobbyID) await reader.ReadToEndAsync(); try { - result.success = await _lobbyManager.LeaveAsync(new LeaveLobbyCommand( + result.success = await _lobbyManager.LeaveAsync(new LeaveLobbyCommand(lobbyID, TokenHelper.GetUserID(this), TokenHelper.GetSessionType(this))); } catch @@ -703,7 +703,7 @@ public async Task Put(Int64 lobbyID) 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["has_map"].GetBoolean(), + 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; diff --git a/GenOnlineService/LobbyManager.Membership.cs b/GenOnlineService/LobbyManager.Membership.cs index 42d1afa..03df7dc 100644 --- a/GenOnlineService/LobbyManager.Membership.cs +++ b/GenOnlineService/LobbyManager.Membership.cs @@ -41,7 +41,7 @@ public enum LobbyMembershipStatus public sealed record JoinLobbyCommand(long LobbyID, long UserID, EUserSessionType SessionType, ushort PreferredPort, bool HasMap, EKnownAnticheatID AnticheatID, string? Password); -public sealed record LeaveLobbyCommand(long UserID, EUserSessionType SessionType); +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 = ""); @@ -74,18 +74,15 @@ public async Task JoinAsync(JoinLobbyCommand command) if (command.UserID == -1 || !SessionHelpers.SessionTypeHasAccessTo(command.SessionType, ESessionAccessType.Gameplay)) return new(LobbyMembershipStatus.NoChange); - // Admission is checked again atomically with insertion. - var admission = await lobby.CheckJoinAdmissionAsync(command); - if (admission != LobbyMembershipStatus.Success) return new(admission); var session = membershipConnections.GetSession(command.UserID, command.SessionType); if (session == null || session.IsRetired) return new(LobbyMembershipStatus.SessionUnavailable); - await LeaveMembershipsAsync(command.UserID); 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 status = await lobby.JoinMemberAsync(command, session, displayName, preferences); + var sources = m_dictLobbies.Values.Where(source => source.GetMemberFromUserID(command.UserID) != null).ToArray(); + var status = await Lobby.TransferMember(lobby, sources, command, session, displayName, preferences); if (status != LobbyMembershipStatus.Success) return new(status); var credentials = await membershipConnections.CreateRelayCredentialsAsync(command.UserID); @@ -97,9 +94,14 @@ 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); - // DELETE historically leaves any lobby, irrespective of its route ID. - await LeaveMembershipsAsync(command.UserID); - await ClearSessionMembershipStateAsync(command.UserID, command.SessionType); + var lobby = GetLobby(command.LobbyID); + if (lobby == null) return false; + var removed = await lobby.RemoveMemberAsync(command.UserID); + if (removed == null) return false; + var session = membershipConnections.GetSession(command.UserID, command.SessionType); + if (session != null && session.currentLobbyID == command.LobbyID + && removed.GetSession().TryGetTarget(out var memberSession) && ReferenceEquals(session, memberSession)) + await ClearSessionMembershipStateAsync(command.UserID, command.SessionType); return true; } diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 3d7cb51..5dc08aa 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -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,28 +1086,65 @@ public async Task Tick() } } - internal Task CheckJoinAdmissionAsync(JoinLobbyCommand command) => - RunExclusiveAsync(() => Task.FromResult(ValidateJoin(command))); + internal static async Task TransferMember(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 JoinMemberAsync(JoinLobbyCommand command, UserSession session, string displayName, UserLobbyPreferences preferences) => + internal Task CompleteIfEmpty(long? expectedOwner = null, bool setupOnly = false) => RunExclusiveAsync(() => { - var admission = ValidateJoin(command); - return Task.FromResult(admission == LobbyMembershipStatus.Success - ? AddMemberLocked(session, displayName, command.PreferredPort, command.HasMap, preferences) - : admission); + 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(() => Task.FromResult(AddMemberLocked(session, displayName, preferredPort, true, 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 AddMemberLocked(UserSession playerSession, string strDisplayName, UInt16 userPreferredPort, bool bHasMap, UserLobbyPreferences lobbyPrefs) + private (LobbyMembershipStatus Status, LobbyMember? Member) AddMemberLocked(UserSession playerSession, string strDisplayName, UInt16 userPreferredPort, bool bHasMap, UserLobbyPreferences lobbyPrefs) { - if (State != ELobbyState.GAME_SETUP) return LobbyMembershipStatus.NotInSetup; - if (GetMemberFromUserID(playerSession.m_UserID) != null) return LobbyMembershipStatus.AlreadyMember; + 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; + if (openSlot < 0) return (LobbyMembershipStatus.NoOpenSlot, null); ushort slotIndex = (ushort)openSlot; // QuickMatch applies social eligibility during matchmaking. @@ -1115,9 +1154,9 @@ private LobbyMembershipStatus AddMemberLocked(UserSession playerSession, string if (ownerData != null) { var social = ownerData.GetSocialContainer(); - if (social.Blocked.Contains(playerSession.m_UserID)) return LobbyMembershipStatus.BlockedByOwner; + if (social.Blocked.Contains(playerSession.m_UserID)) return (LobbyMembershipStatus.BlockedByOwner, null); if (LobbyJoinability == ELobbyJoinability.FriendsOnly && !social.Friends.Contains(playerSession.m_UserID)) - return LobbyMembershipStatus.FriendshipRequired; + return (LobbyMembershipStatus.FriendshipRequired, null); } } @@ -1197,6 +1236,11 @@ private LobbyMembershipStatus AddMemberLocked(UserSession playerSession, string 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) @@ -1231,7 +1275,7 @@ private LobbyMembershipStatus AddMemberLocked(UserSession playerSession, string 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))); @@ -1250,8 +1294,8 @@ private LobbyMembershipStatus AddMemberLocked(UserSession playerSession, string // 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 LobbyMembershipStatus.Success; + Console.WriteLine("User {0} joined lobby {1}: {2} (Slot was {3})", playerSession.m_UserID, LobbyID, true, newMember.SlotIndex); + } public void SendPeerTeardownToDepartingMember(LobbyMember departingMember) @@ -1285,7 +1329,7 @@ public void SendPeerTeardownToDepartingMember(LobbyMember departingMember) } } - internal async Task RemoveMemberAsync(long userID, LobbyMember? expectedMember = null) + internal async Task RemoveMemberAsync(long userID, LobbyMember? expectedMember = null) { LobbyMember? removed = await RunExclusiveAsync(() => { @@ -1294,6 +1338,7 @@ internal async Task RemoveMemberAsync(long userID, LobbyMember? expectedMember = && RemoveMemberLocked(member) ? member : null); }); if (removed != null) PublishMemberRemoval(removed); + return removed; } internal async Task KickMemberAsync(KickLobbyMemberCommand command) @@ -1305,6 +1350,8 @@ internal async Task KickMemberAsync(KickLobbyMemberComman 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); @@ -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.CompleteIfEmpty()) await DeleteLobby(lobbyToRemove); } } @@ -1907,7 +1954,7 @@ public async Task ProcessLobbiesNeedingDestroyed() try { - await DeleteLobby(lobbyToDestroy); + if (await lobbyToDestroy.CompleteIfEmpty()) await DeleteLobby(lobbyToDestroy); } catch (Exception ex) { @@ -1980,18 +2027,12 @@ public int GetNumLobbies() return m_dictLobbies.Count; } - private async Task CleanupUserLobbiesNotStartedAsync(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.CompleteIfEmpty(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(); From f0f7c7e08abffaa705a702a6d49b826ae5ab4f71 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:06:08 +0200 Subject: [PATCH 7/8] refactor(session): clarify lifecycle operation names --- GenOnlineService/Constants.cs | 4 ++-- .../Controllers/Lobbies/LobbiesController.cs | 2 +- GenOnlineService/LobbyManager.Membership.cs | 4 ++-- GenOnlineService/LobbyManager.Sessions.cs | 10 +++++----- GenOnlineService/LobbyManager.cs | 10 +++++----- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index 4c2eff5..9d67959 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -589,10 +589,10 @@ public static async Task CheckForTimeouts() await ServiceLocator.Services.GetRequiredService().ExpireSessionAsync(userData.Session); } - public static Task DeleteSession(Int64 userID, EUserSessionType sessionType, UserWebSocketInstance? oldWS, bool invalidateCache, UserSession? expectedSession = null) + 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, invalidateCache); + return ServiceLocator.Services.GetRequiredService().DisconnectSessionAsync(session, oldWS, destroySession); } internal static UserWebSocketInstance? DetachSocket(UserSession session) diff --git a/GenOnlineService/Controllers/Lobbies/LobbiesController.cs b/GenOnlineService/Controllers/Lobbies/LobbiesController.cs index 32a3d06..65f9323 100644 --- a/GenOnlineService/Controllers/Lobbies/LobbiesController.cs +++ b/GenOnlineService/Controllers/Lobbies/LobbiesController.cs @@ -340,7 +340,7 @@ public async Task Put() result.result = 1; result.lobby_id = newLobbyID; - var credentials = await _lobbyManager.CreateLobbyRelayCredentials(playerSession, newLobbyID); + var credentials = await _lobbyManager.CreateLobbyRelayCredentialsAsync(playerSession, newLobbyID); result.turn_username = credentials.Username; result.turn_token = credentials.Token; } diff --git a/GenOnlineService/LobbyManager.Membership.cs b/GenOnlineService/LobbyManager.Membership.cs index 03df7dc..3a09414 100644 --- a/GenOnlineService/LobbyManager.Membership.cs +++ b/GenOnlineService/LobbyManager.Membership.cs @@ -82,7 +82,7 @@ public async Task JoinAsync(JoinLobbyCommand command) 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.TransferMember(lobby, sources, command, session, displayName, preferences); + 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); @@ -152,7 +152,7 @@ private async Task LeaveMembershipsAsync(long userID) foreach (Lobby lobby in m_dictLobbies.Values) await lobby.RemoveMemberAsync(userID); } - internal async Task<(string Username, string Token)> CreateLobbyRelayCredentials(UserSession session, long lobbyID) + 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) diff --git a/GenOnlineService/LobbyManager.Sessions.cs b/GenOnlineService/LobbyManager.Sessions.cs index 38498b6..f38bd50 100644 --- a/GenOnlineService/LobbyManager.Sessions.cs +++ b/GenOnlineService/LobbyManager.Sessions.cs @@ -29,20 +29,20 @@ internal async Task OpenSessionAsync(AppDbContext db, EUs { using var membership = await AcquireMembershipGateAsync(userID); var previous = membershipConnections.GetSession(userID, sessionType); - if (!reconnect && previous != null) await DestroySession(previous); + 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 invalidateCache) + 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 (invalidateCache) await DestroySession(session); + if (destroySession) await DestroySessionAsync(session); else { WebSocketManager.DetachSocket(session); @@ -64,14 +64,14 @@ private async Task RemoveSessionAsync(UserSession? session, bool expiredOn using var membership = await AcquireMembershipGateAsync(session.m_UserID); if (!IsCurrentSession(session)) return false; if (expiredOnly && !WebSocketManager.ShouldStillClearAbandonedSession(session, session)) return false; - await DestroySession(session); + await DestroySessionAsync(session); return true; } private bool IsCurrentSession(UserSession session) => ReferenceEquals(membershipConnections.GetSession(session.m_UserID, session.GetSessionType()), session); - private async Task DestroySession(UserSession session) + private async Task DestroySessionAsync(UserSession session) { session.Retire(); bool gameplay = SessionHelpers.SessionTypeHasAccessTo(session.GetSessionType(), ESessionAccessType.Gameplay); diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 5dc08aa..9decb52 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -1086,7 +1086,7 @@ public async Task Tick() } } - internal static async Task TransferMember(Lobby destination, IReadOnlyCollection sources, + 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(); @@ -1120,7 +1120,7 @@ internal static async Task TransferMember(Lobby destinati } } - internal Task CompleteIfEmpty(long? expectedOwner = null, bool setupOnly = false) => + internal Task TryMarkCompleteIfEmptyAsync(long? expectedOwner = null, bool setupOnly = false) => RunExclusiveAsync(() => { if ((expectedOwner.HasValue && Owner != expectedOwner.Value) || (setupOnly && State != ELobbyState.GAME_SETUP) @@ -1929,7 +1929,7 @@ public async Task Cleanup() foreach (Lobby lobbyToRemove in lstLobbiesToRemove) { // remove it, also commit it + update leaderboard - if (await lobbyToRemove.CompleteIfEmpty()) await DeleteLobby(lobbyToRemove); + if (await lobbyToRemove.TryMarkCompleteIfEmptyAsync()) await DeleteLobby(lobbyToRemove); } } @@ -1954,7 +1954,7 @@ public async Task ProcessLobbiesNeedingDestroyed() try { - if (await lobbyToDestroy.CompleteIfEmpty()) await DeleteLobby(lobbyToDestroy); + if (await lobbyToDestroy.TryMarkCompleteIfEmptyAsync()) await DeleteLobby(lobbyToDestroy); } catch (Exception ex) { @@ -2030,7 +2030,7 @@ public int GetNumLobbies() private async Task CleanupUserLobbiesNotStartedAsync(Int64 userID) { foreach (var lobby in GetPlayerOwnedLobbies(userID)) - if (await lobby.CompleteIfEmpty(userID, setupOnly: true)) await DeleteLobby(lobby); + 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) From b448c87643275284db4afab4d4434198b0d2ee4e Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Sun, 18 Oct 2026 04:20:31 +0200 Subject: [PATCH 8/8] fix(lobby): keep matchmaking exits within membership ownership --- GenOnlineService/LobbyManager.Membership.cs | 32 +++++++++++++++------ GenOnlineService/LobbyManager.Sessions.cs | 2 +- GenOnlineService/MatchmakingManager.cs | 9 +++--- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/GenOnlineService/LobbyManager.Membership.cs b/GenOnlineService/LobbyManager.Membership.cs index 3a09414..245f583 100644 --- a/GenOnlineService/LobbyManager.Membership.cs +++ b/GenOnlineService/LobbyManager.Membership.cs @@ -96,13 +96,7 @@ public async Task LeaveAsync(LeaveLobbyCommand command) using var membership = await AcquireMembershipGateAsync(command.UserID); var lobby = GetLobby(command.LobbyID); if (lobby == null) return false; - var removed = await lobby.RemoveMemberAsync(command.UserID); - if (removed == null) return false; - var session = membershipConnections.GetSession(command.UserID, command.SessionType); - if (session != null && session.currentLobbyID == command.LobbyID - && removed.GetSession().TryGetTarget(out var memberSession) && ReferenceEquals(session, memberSession)) - await ClearSessionMembershipStateAsync(command.UserID, command.SessionType); - return true; + return await RemoveMembershipAsync(lobby, command.UserID) != null; } public async Task KickAsync(KickLobbyMemberCommand command) @@ -138,7 +132,27 @@ internal async Task RemoveMatchmakingMemberAsync(Lobby lobby, LobbyMember member 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 lobby.RemoveMemberAsync(member.UserID, 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) @@ -150,7 +164,7 @@ public async Task LeaveAllLobbiesAsync(long userID) private async Task LeaveMembershipsAsync(long userID) { foreach (Lobby lobby in m_dictLobbies.Values) - await lobby.RemoveMemberAsync(userID); + await RemoveMembershipAsync(lobby, userID); } internal async Task<(string Username, string Token)> CreateLobbyRelayCredentialsAsync(UserSession session, long lobbyID) { diff --git a/GenOnlineService/LobbyManager.Sessions.cs b/GenOnlineService/LobbyManager.Sessions.cs index f38bd50..8f1940b 100644 --- a/GenOnlineService/LobbyManager.Sessions.cs +++ b/GenOnlineService/LobbyManager.Sessions.cs @@ -118,7 +118,7 @@ internal async Task CancelMatchmakingAsync(UserSession session) if (!IsCurrentSession(session) || session.IsRetired) return; var detached = await MatchmakingManager.DetachSessionAsync(session); foreach (var entry in detached.Members) - await entry.Lobby.RemoveMemberAsync(session.m_UserID, entry.Member); + await RemoveMembershipAsync(entry.Lobby, session.m_UserID, entry.Member); if (!detached.CancellationRejected) await LeaveMembershipsAsync(session.m_UserID); } } diff --git a/GenOnlineService/MatchmakingManager.cs b/GenOnlineService/MatchmakingManager.cs index dd08485..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) @@ -983,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..."); @@ -1820,7 +1822,7 @@ 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 @@ -1835,7 +1837,6 @@ private static async Task TryRequeueRegisteredPlayer(UserSession session, lstSessions.Add(new WeakReference(session)); } - session.UpdateSessionLobbyID(-1); session.QueueWebsocketSend(requeueActionJSON); return true; }