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/2] 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/2] 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); } } }