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 01/13] 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 02/13] 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 03/13] 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 04/13] 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 05/13] 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 06/13] 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 07/13] 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 08/13] 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; } From ee209595b0df14cb8f60288a31cde373a1711ec6 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:33:53 +0200 Subject: [PATCH 09/13] fix(matchmaking): preserve exact-size match formations --- GenOnlineService/MatchmakingManager.cs | 451 ++++++++++--------------- GenOnlineService/Program.cs | 1 + 2 files changed, 173 insertions(+), 279 deletions(-) diff --git a/GenOnlineService/MatchmakingManager.cs b/GenOnlineService/MatchmakingManager.cs index 0b24176..ea7d5f0 100644 --- a/GenOnlineService/MatchmakingManager.cs +++ b/GenOnlineService/MatchmakingManager.cs @@ -25,7 +25,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using System; -using System.Collections.Concurrent; +using System.Collections.Concurrent; +using System.Collections.Immutable; using System.Net; using System.Net.WebSockets; using System.Security.Claims; @@ -332,7 +333,7 @@ public bool IsLockedForMatchStart() { lock (m_StateLock) { - return m_bHasStartedCountdown || m_bWaitingOnLobbyJoins || m_bWaitingOnMeshConnectivityChecks || m_bPendingDeletion; + return m_bCreatingLobby || m_bHasStartedCountdown || m_bWaitingOnLobbyJoins || m_bWaitingOnMeshConnectivityChecks || m_bPendingDeletion; } } @@ -380,120 +381,100 @@ private TimeSpan TimeSinceLastEloExpansion() return null; } - public void DetermineMap(out string strMapName, out string strMapPath) + private sealed record Formation(ImmutableArray Players, PlaylistMap Map, bool UsedFallback); + private Formation? m_Formation; + private bool m_bCreatingLobby; + private static long s_NextBucketID; + private readonly long m_BucketID = Interlocked.Increment(ref s_NextBucketID); + + private Formation? TryBeginFormation(Playlist playlist, bool graceExpired) { - // If they are in this bucket, they had SOME map overlap with the bucket creator, now we need to find the common ground between everyone - - // TODO_QUICKMATCH: what if we cant find a suitable map? - - // first condense the map list doesn to a list that has mutually agreed upon maps/preferences from all participants - //var mapSetFromBucketCreator = new HashSet(lstMapIndices); - - var perPlayerMapSet = new List>(); - var finalMapSet = new HashSet(lstMapIndices.ToList()); // we need to check intersection against this, so pre-populate it with the original bucket creation list, because that's the "biggest set" in theory - - // TODO_QUICKMATCH: Optimize this, it's inefficient - - foreach (MatchmakingBucketMember member in m_lstMembers) - { - UserSession? memberSession = member.GetAssociatedSession(); - if (memberSession != null) - { - perPlayerMapSet.Add(new HashSet(memberSession.MatchmakingMapIndicies.ToList())); - } - } - - // Find shared values across all of perPlayerMapSet - if (perPlayerMapSet.Count > 0) - { - for (int i = 0; i < perPlayerMapSet.Count; i++) - { - finalMapSet.IntersectWith(perPlayerMapSet[i]); - } - } - - // remove any maps that aren't big enough (mainly applies to FFA's where maps may be 6 players but bucket could be 8 players) - // NOTE: iterate a real copy, we are mutating finalMapSet below - var copyMapSetForIter = new HashSet(finalMapSet); - foreach (int mapIndex in copyMapSetForIter) - { - if (MatchmakingManager.g_Playlists.TryGetValue(PlaylistID, out Playlist? playlist)) - { - if (mapIndex >= 0 && mapIndex < playlist.Maps.Count) - { - if (playlist.Maps[mapIndex].MaxPlayers < CurrentMemberCount()) - { - finalMapSet.Remove(mapIndex); - } - } - } - } + lock (m_StateLock) + { + if (IsLockedForMatchStart() || m_bMergedAway) return null; + var players = m_lstMembers.ToList().Select(member => member.GetAssociatedSession()).ToArray(); + if (players.Length < MinPlayers || players.Length > DesiredPlayers + || (!graceExpired && players.Length < DesiredPlayers) + || players.Any(player => player == null || player.IsRetired || !player.IsRegisteredForMatchmaking)) return null; + + var selectedPlayers = players.Cast().ToArray(); + var sharedMaps = new HashSet(lstMapIndices.ToList()); + foreach (var player in selectedPlayers) + sharedMaps.IntersectWith(player.MatchmakingMapIndicies.ToList()); + var exactMaps = playlist.Maps.Where(map => map.MaxPlayers == selectedPlayers.Length).ToArray(); + var preferredMaps = sharedMaps.Where(index => index >= 0 && index < playlist.Maps.Count) + .Select(index => playlist.Maps[index]).Where(map => map.MaxPlayers == selectedPlayers.Length).ToArray(); + var choices = preferredMaps.Length > 0 ? preferredMaps : exactMaps; + if (choices.Length == 0) return null; + + m_Formation = new Formation(selectedPlayers.ToImmutableArray(), choices[Random.Shared.Next(choices.Length)], preferredMaps.Length == 0); + m_bCreatingLobby = true; + m_bReachedMinPlayers = false; + m_timeReachedMinPlayers = -1; + return m_Formation; + } + } - // Randomly select a map from the map list - if (finalMapSet.Count > 0) + private bool FormationMatches(Lobby? lobby = null) + { + lock (m_StateLock) + { + return m_Formation != null && !m_bAutoStartInvalidated && !m_bPendingDeletion + && CurrentMemberCount() == m_Formation.Players.Length + && m_Formation.Players.All(player => !player.IsRetired && player.IsRegisteredForMatchmaking && HasPlayer(player)) + && (lobby == null || (lobby.NumCurrentPlayers == m_Formation.Players.Length + && m_Formation.Players.All(player => lobby.GetMemberFromUserID(player.m_UserID)?.GetSession() + .TryGetTarget(out var memberSession) == true && ReferenceEquals(memberSession, player)))); + } + } + + private async Task CreateFormationLobby(Playlist playlist, Formation formation) + { + var lobbyManager = ServiceLocator.Services.GetRequiredService(); + try { - // Get the playlist for this bucket - if (MatchmakingManager.g_Playlists.TryGetValue(PlaylistID, out Playlist? playlist)) + using var scope = ServiceLocator.Services.CreateScope(); + var factory = scope.ServiceProvider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + foreach (var player in formation.Players) { - var finalMapIndices = finalMapSet.ToList(); - int selectedIndex = Random.Shared.Next(finalMapIndices.Count); - int mapIndex = finalMapIndices[selectedIndex]; - - // Defensive: ensure index is valid for playlist.Maps - if (mapIndex >= 0 && mapIndex < playlist.Maps.Count) - { - strMapName = playlist.Maps[mapIndex].Name; - strMapPath = playlist.Maps[mapIndex].Path; - return; - } + await Database.Leaderboards.CreateUserEntriesIfNotExists(db, player.m_UserID); + await SendMatchmakingMessage(player, $"Creating a {formation.Players.Length}-player QuickMatch on {formation.Map.Name}." + + (formation.Players.Length < playlist.DesiredPlayers ? " Starting below the playlist maximum after the grace period." : "") + + (formation.UsedFallback ? " No shared map preference fits this size; using an exact-size playlist map." : "")); + } + var host = formation.Players[0]; + var hostData = GenOnlineService.WebSocketManager.GetSharedDataForUser(host.m_UserID); + if (hostData == null) throw new InvalidOperationException("QuickMatch host session disappeared."); + if (!FormationMatches()) throw new InvalidOperationException("QuickMatch formation changed."); + m_LobbyID = await lobbyManager.CreateLobby(db, host, hostData.m_strDisplayName, "Quickmatch Lobby", formation.Map.Name, formation.Map.Path + ".map", + true, formation.Players.Length, "", 12345, false, true, 10000, false, String.Empty, -5, false, Constants.g_DefaultCameraMaxHeight, + ExeCRC, IniCRC, ELobbyType.QuickMatch, AnticheatID); + var lobby = lobbyManager.GetLobby(m_LobbyID) ?? throw new InvalidOperationException("QuickMatch lobby creation failed."); + foreach (var player in formation.Players) lobby.MatchedUserIDs.Add(player.m_UserID); + var action = new WebSocketMessage_MatchmakerJoinLobby + { + msg_id = (int)EWebSocketMessageID.MATCHMAKING_ACTION_JOIN_PREARRANGED_LOBBY, + lobby_id = m_LobbyID + }; + var payload = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(action)); + lock (m_StateLock) + { + if (!FormationMatches()) throw new InvalidOperationException("QuickMatch formation changed."); + m_bWaitingOnLobbyJoins = true; + m_timeStartedWaitingOnLobbyJoins = Environment.TickCount64; + foreach (var player in formation.Players) player.QueueWebsocketSend(payload); } } - else + catch (Exception ex) { - // pick a sensible default (biggest map in playlist), probably not what the players asked for, but we cant play on no map - Console.WriteLine("WARNING: No mutually agreed upon map found for matchmaking bucket, falling back to largest map in playlist"); - - if (MatchmakingManager.g_Playlists.TryGetValue(PlaylistID, out Playlist? playlist)) - { - int biggestCountSeen = 0; - PlaylistMap? mapToUse = null; - foreach (var map in playlist.Maps) - { - if (map.MaxPlayers > biggestCountSeen) - { - biggestCountSeen = map.MaxPlayers; - mapToUse = map; - } - } - - // TODO_QUICKMATCH: What if it's still null? don't think we can get into that state since we must have some kind of map in the playlist - if (mapToUse != null) - { - strMapName = mapToUse.Name; - strMapPath = mapToUse.Path; - return; - } - } + Console.WriteLine($"QuickMatch formation failed: {ex}"); + await AbortQuickMatchAutoStart("QuickMatch setup failed or a player left during formation."); } - - // TODO_QUICKMATCH: What happens if you widen when already in a bucket? tell the user htey cant? you would need everyone to expand, or just expand for everyone? - - // Fallback: use first map from bucket creator's list if available - if (lstMapIndices.Count > 0 && MatchmakingManager.g_Playlists.TryGetValue(PlaylistID, out Playlist? fallbackPlaylist)) + finally { - int fallbackIndex = lstMapIndices[0]; - if (fallbackIndex >= 0 && fallbackIndex < fallbackPlaylist.Maps.Count) - { - strMapName = fallbackPlaylist.Maps[fallbackIndex].Name; - strMapPath = fallbackPlaylist.Maps[fallbackIndex].Path; - return; - } + lock (m_StateLock) m_bCreatingLobby = false; } - - // If no map found, set to empty - strMapName = string.Empty; - strMapPath = string.Empty; - } public bool DoMapSelectionsIntersect(ConcurrentList lstRhs) @@ -580,29 +561,24 @@ public bool CanMergeWithOtherBucket(MatchmakingBucket bucketToMerge) return true; } - public async Task MergeWithOtherBucket(MatchmakingBucket bucketToMerge) + public async Task MergeWithOtherBucket(MatchmakingBucket bucketToMerge) { - // copy over players (skipping anyone already present, and dead sessions) - foreach (MatchmakingBucketMember rhsMember in bucketToMerge.m_lstMembers) - { - UserSession? rhsSession = rhsMember.GetAssociatedSession(); - if (rhsSession == null || HasPlayer(rhsSession)) + if (ReferenceEquals(this, bucketToMerge)) return false; + var first = m_BucketID < bucketToMerge.m_BucketID ? this : bucketToMerge; + var second = ReferenceEquals(first, this) ? bucketToMerge : this; + lock (first.m_StateLock) + lock (second.m_StateLock) + { + if (!CanMergeWithOtherBucket(bucketToMerge)) return false; + foreach (var member in bucketToMerge.m_lstMembers) { - continue; + var session = member.GetAssociatedSession(); + if (session != null && !session.IsRetired && !HasPlayer(session)) m_lstMembers.Add(member); } - - this.m_lstMembers.Add(rhsMember); + bucketToMerge.m_bMergedAway = true; + bucketToMerge.m_lstMembers.Clear(); } - // the source bucket is now empty and flagged so it can never merge/accept players again - lock (bucketToMerge.m_StateLock) - { - bucketToMerge.m_bMergedAway = true; - } - bucketToMerge.m_lstMembers.Clear(); - - // nothing else to copy... everything else should match since we were a merge candidate - // tell all players foreach (MatchmakingBucketMember member in m_lstMembers) { @@ -612,6 +588,7 @@ public async Task MergeWithOtherBucket(MatchmakingBucket bucketToMerge) await SendMatchmakingMessage(session, String.Format("Your matchmaking bucket was merged with another bucket. Status: {0}/{1} players. ({2} required to start)", CurrentMemberCount(), DesiredPlayers, MinPlayers)); } } + return true; } public bool HasPlayer(UserSession playerSession) @@ -642,7 +619,7 @@ public bool RemovePlayer(UserSession playerSession, out bool bCancellationReject return false; } - if (m_bWaitingOnLobbyJoins || m_bHasStartedCountdown || m_bWaitingOnMeshConnectivityChecks) + if (m_bCreatingLobby || m_bWaitingOnLobbyJoins || m_bHasStartedCountdown || m_bWaitingOnMeshConnectivityChecks) { m_bAutoStartInvalidated = true; } @@ -672,7 +649,7 @@ public bool PruneDeadMembers() { if (member.GetAssociatedSession() == null) { - if (m_bWaitingOnLobbyJoins || m_bHasStartedCountdown || m_bWaitingOnMeshConnectivityChecks) + if (m_bCreatingLobby || m_bWaitingOnLobbyJoins || m_bHasStartedCountdown || m_bWaitingOnMeshConnectivityChecks) { m_bAutoStartInvalidated = true; bInvalidatedSetup = true; @@ -803,36 +780,22 @@ public int GetAvgElo() } - public async Task Join(UserSession playerSession) + public async Task Join(UserSession playerSession) { - // already in here? nothing to do (and never add a duplicate member) - if (HasPlayer(playerSession)) + lock (m_StateLock) { - return true; + if (HasPlayer(playerSession)) return true; + if (playerSession.IsRetired || IsJoiningUserBlockedByOrHasBlockedAnyBucketMember(playerSession, playerSession.m_UserID) + || !HasSpaceForUsers(1, playerSession.ExeCRC, playerSession.IniCRC, playerSession.AnticheatID)) return false; + m_lstMembers.Add(new MatchmakingBucketMember(playerSession)); } - - // cant be blocked by others in this bucket - if (!IsJoiningUserBlockedByOrHasBlockedAnyBucketMember(playerSession, playerSession.m_UserID)) - { - if (HasSpaceForUsers(1, playerSession.ExeCRC, playerSession.IniCRC, playerSession.AnticheatID)) - { - m_lstMembers.Add(new MatchmakingBucketMember(playerSession)); - - // tell everyone - foreach (MatchmakingBucketMember member in m_lstMembers) - { - UserSession? memberSession = member.GetAssociatedSession(); - if (memberSession != null) - { - await SendMatchmakingMessage(memberSession, String.Format("Status: {0}/{1} players. ({2} required to start)", CurrentMemberCount(), DesiredPlayers, MinPlayers)); - } - } - - return true; - } - } - - return false; + foreach (var member in m_lstMembers) + { + var session = member.GetAssociatedSession(); + if (session != null) + await SendMatchmakingMessage(session, $"Status: {CurrentMemberCount()}/{DesiredPlayers} players. ({MinPlayers} required to start)"); + } + return true; } public MatchmakingBucket(UInt16 playlistID, UserSession owningSession, int minPlayers, int desiredPlayers, ConcurrentList mapIndices, UInt32 exe_crc, UInt32 ini_crc, EKnownAnticheatID anticheatID) @@ -930,7 +893,7 @@ private async Task TriggerFullMeshConnectivityChecks(Lobby lobby) } } - private async Task AbortQuickMatchAutoStart(string reason) + private async Task AbortQuickMatchAutoStart(string reason, bool dropNonJoiners = false) { List sessionsToRequeue = new(); long abortedLobbyID; @@ -964,6 +927,30 @@ private async Task AbortQuickMatchAutoStart(string reason) Lobby? quickMatchLobby = lobbyManager.GetLobby(abortedLobbyID); if (quickMatchLobby != null) { + if (dropNonJoiners) + { + // Close admission before classifying late arrivals; never change a newer registration. + await quickMatchLobby.UpdateState(ELobbyState.COMPLETE); + foreach (var session in sessionsToRequeue) + { + if (quickMatchLobby.GetMemberFromUserID(session.m_UserID) != null) continue; + bool dropped = false; + await session.MatchmakingStateLock.WaitAsync(); + try + { + lock (m_StateLock) + { + if (HasPlayer(session)) + { + session.IsRegisteredForMatchmaking = false; + dropped = true; + } + } + } + finally { session.MatchmakingStateLock.Release(); } + if (dropped) await SendMatchmakingMessage(session, "You failed to join the QuickMatch lobby in time and have been removed from matchmaking."); + } + } foreach (UserSession memberSession in sessionsToRequeue) { LobbyMember? lobbyMember = quickMatchLobby.GetMemberFromUserID(memberSession.m_UserID); @@ -1007,6 +994,7 @@ public async Task Tick() bool bHasStartedCountdown; lock (m_StateLock) { + if (m_bCreatingLobby) return; bPendingDeletion = m_bPendingDeletion; bAutoStartInvalidated = m_bAutoStartInvalidated; bWaitingOnMeshConnectivityChecks = m_bWaitingOnMeshConnectivityChecks; @@ -1072,7 +1060,7 @@ public async Task Tick() else { m_bWaitingOnMeshConnectivityChecks = false; - bStartCountdown = !bInvalidatedAtDecision && lobbyDuringMeshCheck.LastFullMeshConnectivityCheckOutcome == true; + bStartCountdown = FormationMatches(lobbyDuringMeshCheck) && lobbyDuringMeshCheck.LastFullMeshConnectivityCheckOutcome == true; bAbortStart = !bStartCountdown; if (bStartCountdown) { @@ -1133,7 +1121,7 @@ public async Task Tick() { m_bHasStartedCountdown = false; m_StartTime = -1; - bStartGame = !m_bAutoStartInvalidated; + bStartGame = FormationMatches(lobbyAfterCountdown); bAbortStart = !bStartGame; if (bStartGame) { @@ -1215,88 +1203,8 @@ await SendMatchmakingMessage(memberSession, // did we hit the timer OR have enough players to start? bool bMinPlayersCountdownExpired = m_bReachedMinPlayers && (Environment.TickCount64 - m_timeReachedMinPlayers) > playlist.GracePeriodAtMinPlayersMSec; - if (bMinPlayersCountdownExpired || CurrentMemberCount() >= DesiredPlayers) - { - // reset min player countdown - m_bReachedMinPlayers = false; - m_timeReachedMinPlayers = -1; - - lock (m_StateLock) - { - m_bWaitingOnLobbyJoins = true; - m_timeStartedWaitingOnLobbyJoins = Environment.TickCount64; - } - - // tell everyone - UserSession? dummyHostUser = null; - foreach (MatchmakingBucketMember member in m_lstMembers) - { - UserSession? memberSession = member.GetAssociatedSession(); - if (memberSession != null) - { - // create lb data if necessary - using var scope = ServiceLocator.Services.CreateScope(); - var factory = scope.ServiceProvider.GetRequiredService>(); - await using var db = await factory.CreateDbContextAsync(); - - await Database.Leaderboards.CreateUserEntriesIfNotExists(db, memberSession.m_UserID); - - if (dummyHostUser == null) - { - dummyHostUser = memberSession; - } - await SendMatchmakingMessage(memberSession, "Creating a QuickMatch lobby for everyone..."); - } - } - - // should have a user by now - if (dummyHostUser != null) - { - SharedUserData? dummyHostUserData = GenOnlineService.WebSocketManager.GetSharedDataForUser(dummyHostUser.m_UserID); - - if (dummyHostUserData != null) - { - // make a lobby - DetermineMap(out string strMapName, out string strMapPath); - - using var scope = ServiceLocator.Services.CreateScope(); - var factory = scope.ServiceProvider.GetRequiredService>(); - await using var db = await factory.CreateDbContextAsync(); - - m_LobbyID = await lobbyManager.CreateLobby(db, dummyHostUser, dummyHostUserData.m_strDisplayName, "Quickmatch Lobby", strMapName, strMapPath + ".map", - true, playlist.DesiredPlayers, "", 12345, false, true, 10000, false, String.Empty, -5, false, Constants.g_DefaultCameraMaxHeight, dummyHostUser.ExeCRC, dummyHostUser.IniCRC, ELobbyType.QuickMatch, - dummyHostUser.AnticheatID); - - Lobby? quickMatchLobby = lobbyManager.GetLobby(m_LobbyID); - if (quickMatchLobby != null) - { - foreach (MatchmakingBucketMember member in m_lstMembers) - { - UserSession? memberSession = member.GetAssociatedSession(); - if (memberSession != null) - { - quickMatchLobby.MatchedUserIDs.Add(memberSession.m_UserID); - } - } - } - - // tell both to join our lobby - WebSocketMessage_MatchmakerJoinLobby joinAction = new WebSocketMessage_MatchmakerJoinLobby(); - joinAction.msg_id = (int)EWebSocketMessageID.MATCHMAKING_ACTION_JOIN_PREARRANGED_LOBBY; - joinAction.lobby_id = m_LobbyID; - byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(joinAction)); - - foreach (MatchmakingBucketMember member in m_lstMembers) - { - UserSession? memberSession = member.GetAssociatedSession(); - if (memberSession != null) - { - memberSession.QueueWebsocketSend(bytesJSON); - } - } - } - } - } + var formation = TryBeginFormation(playlist, bMinPlayersCountdownExpired); + if (formation != null) await CreateFormationLobby(playlist, formation); } else { @@ -1362,48 +1270,14 @@ await SendMatchmakingMessage(memberSession, bool bJoinTimeoutExpired = m_timeStartedWaitingOnLobbyJoins != -1 && (Environment.TickCount64 - m_timeStartedWaitingOnLobbyJoins) > c_LobbyJoinTimeoutMSec; - if (bJoinTimeoutExpired && lobby.NumCurrentPlayers != CurrentMemberCount()) + if (bJoinTimeoutExpired && !FormationMatches(lobby)) { - foreach (MatchmakingBucketMember member in m_lstMembers) - { - UserSession? memberSession = member.GetAssociatedSession(); - if (memberSession == null || lobby.GetMemberFromUserID(memberSession.m_UserID) == null) - { - if (memberSession != null) - { - Console.WriteLine("User {0} failed to join QuickMatch lobby {1} in time, dropping from bucket", memberSession.m_UserID, m_LobbyID); - memberSession.IsRegisteredForMatchmaking = false; - await SendMatchmakingMessage(memberSession, "You failed to join the QuickMatch lobby in time and have been removed from matchmaking."); - } - - m_lstMembers.Remove(member); - } - } - - // not enough players survived? tear the whole thing down and let people re-queue - if (CurrentMemberCount() < MinPlayers) - { - foreach (MatchmakingBucketMember member in m_lstMembers) - { - UserSession? memberSession = member.GetAssociatedSession(); - if (memberSession != null) - { - memberSession.IsRegisteredForMatchmaking = false; - await SendMatchmakingMessage(memberSession, "Not enough players joined the QuickMatch lobby. Please try matchmaking again."); - } - } - - m_bWaitingOnLobbyJoins = false; - m_timeStartedWaitingOnLobbyJoins = -1; - - await lobbyManager.DeleteLobby(lobby); - MatchmakingManager.DestroyBucket(this); - return; - } + await AbortQuickMatchAutoStart("Not all selected players joined the QuickMatch lobby in time.", dropNonJoiners: true); + return; } { - if (lobby.NumCurrentPlayers == CurrentMemberCount()) // everyone is in, lets start for real + if (FormationMatches(lobby)) { m_timeStartedWaitingOnLobbyJoins = -1; @@ -1451,6 +1325,26 @@ await SendMatchmakingMessage(memberSession, } // Using ConcurrentBag instead of ConcurrentList for lock-free bucket management private static ConcurrentDictionary> m_dictMatchmakingBuckets = new(); + + public static void ValidatePlaylistsAtStartup() + { + foreach (var (id, playlist) in g_Playlists.ToArray()) + { + string? error = null; + if (playlist.MinPlayers < 2 || playlist.DesiredPlayers > Lobby.maxLobbySize || playlist.MinPlayers > playlist.DesiredPlayers) + error = "player bounds exceed supported lobby capacity"; + else if (playlist.MinSelectedMaps < 1 || playlist.MinSelectedMaps > playlist.Maps.Count) + error = "minimum map selection is outside the playlist map count"; + else if (playlist.Maps.Any(map => map.MaxPlayers < playlist.MinPlayers || map.MaxPlayers > playlist.DesiredPlayers)) + error = "a map capacity is outside playlist player bounds"; + else if (Enumerable.Range(playlist.MinPlayers, playlist.DesiredPlayers - playlist.MinPlayers + 1) + .Any(size => !playlist.Maps.Any(map => map.MaxPlayers == size))) + error = "an allowed formation size has no exact-size map"; + if (error == null) continue; + Console.WriteLine($"Disabling matchmaking playlist {id} ({playlist.Name}): {error}."); + g_Playlists.Remove(id); + } + } // TODO_QUICKMATCH: Read from db or file private static Dictionary g_Playlists = new() @@ -1617,9 +1511,8 @@ public static async Task Tick() { if (mmBucket.CanMergeWithOtherBucket(mmBucketMergeCandidate)) { - await mmBucket.MergeWithOtherBucket(mmBucketMergeCandidate); - - lstBucketsMergedNeedingDeleted.Add(mmBucketMergeCandidate); + if (await mmBucket.MergeWithOtherBucket(mmBucketMergeCandidate)) + lstBucketsMergedNeedingDeleted.Add(mmBucketMergeCandidate); } } } diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs index 949720a..5eefd21 100644 --- a/GenOnlineService/Program.cs +++ b/GenOnlineService/Program.cs @@ -1169,6 +1169,7 @@ public static async Task Main(string[] args) var builder = WebApplication.CreateBuilder(args); RoomCatalog.Initialize(Path.Combine(builder.Environment.ContentRootPath, "data", "rooms.json")); + MatchmakingManager.ValidatePlaylistsAtStartup(); // Add services to the container. From c97278c25c5bd7403d9ac059d6b8acccac0395e4 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:06:41 +0200 Subject: [PATCH 10/13] refactor(matchmaking): clarify formation and timeout names --- GenOnlineService/MatchmakingManager.cs | 68 +++++++++++++------------- GenOnlineService/Program.cs | 2 +- 2 files changed, 35 insertions(+), 35 deletions(-) diff --git a/GenOnlineService/MatchmakingManager.cs b/GenOnlineService/MatchmakingManager.cs index ea7d5f0..a96f54c 100644 --- a/GenOnlineService/MatchmakingManager.cs +++ b/GenOnlineService/MatchmakingManager.cs @@ -381,7 +381,7 @@ private TimeSpan TimeSinceLastEloExpansion() return null; } - private sealed record Formation(ImmutableArray Players, PlaylistMap Map, bool UsedFallback); + private sealed record Formation(ImmutableArray Players, PlaylistMap Map, bool UsedFallbackMap); private Formation? m_Formation; private bool m_bCreatingLobby; private static long s_NextBucketID; @@ -415,7 +415,7 @@ private sealed record Formation(ImmutableArray Players, PlaylistMap } } - private bool FormationMatches(Lobby? lobby = null) + private bool IsFormationCurrent(Lobby? lobby = null) { lock (m_StateLock) { @@ -428,7 +428,7 @@ private bool FormationMatches(Lobby? lobby = null) } } - private async Task CreateFormationLobby(Playlist playlist, Formation formation) + private async Task CreateFormationLobbyAsync(Playlist playlist, Formation formation) { var lobbyManager = ServiceLocator.Services.GetRequiredService(); try @@ -441,12 +441,12 @@ private async Task CreateFormationLobby(Playlist playlist, Formation formation) await Database.Leaderboards.CreateUserEntriesIfNotExists(db, player.m_UserID); await SendMatchmakingMessage(player, $"Creating a {formation.Players.Length}-player QuickMatch on {formation.Map.Name}." + (formation.Players.Length < playlist.DesiredPlayers ? " Starting below the playlist maximum after the grace period." : "") - + (formation.UsedFallback ? " No shared map preference fits this size; using an exact-size playlist map." : "")); + + (formation.UsedFallbackMap ? " No shared map preference fits this size; using an exact-size playlist map." : "")); } var host = formation.Players[0]; var hostData = GenOnlineService.WebSocketManager.GetSharedDataForUser(host.m_UserID); if (hostData == null) throw new InvalidOperationException("QuickMatch host session disappeared."); - if (!FormationMatches()) throw new InvalidOperationException("QuickMatch formation changed."); + if (!IsFormationCurrent()) throw new InvalidOperationException("QuickMatch formation changed."); m_LobbyID = await lobbyManager.CreateLobby(db, host, hostData.m_strDisplayName, "Quickmatch Lobby", formation.Map.Name, formation.Map.Path + ".map", true, formation.Players.Length, "", 12345, false, true, 10000, false, String.Empty, -5, false, Constants.g_DefaultCameraMaxHeight, ExeCRC, IniCRC, ELobbyType.QuickMatch, AnticheatID); @@ -460,7 +460,7 @@ await SendMatchmakingMessage(player, $"Creating a {formation.Players.Length}-pla var payload = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(action)); lock (m_StateLock) { - if (!FormationMatches()) throw new InvalidOperationException("QuickMatch formation changed."); + if (!IsFormationCurrent()) throw new InvalidOperationException("QuickMatch formation changed."); m_bWaitingOnLobbyJoins = true; m_timeStartedWaitingOnLobbyJoins = Environment.TickCount64; foreach (var player in formation.Players) player.QueueWebsocketSend(payload); @@ -469,7 +469,7 @@ await SendMatchmakingMessage(player, $"Creating a {formation.Players.Length}-pla catch (Exception ex) { Console.WriteLine($"QuickMatch formation failed: {ex}"); - await AbortQuickMatchAutoStart("QuickMatch setup failed or a player left during formation."); + await AbortQuickMatchAutoStartAsync("QuickMatch setup failed or a player left during formation."); } finally { @@ -561,7 +561,7 @@ public bool CanMergeWithOtherBucket(MatchmakingBucket bucketToMerge) return true; } - public async Task MergeWithOtherBucket(MatchmakingBucket bucketToMerge) + public async Task MergeWithOtherBucketAsync(MatchmakingBucket bucketToMerge) { if (ReferenceEquals(this, bucketToMerge)) return false; var first = m_BucketID < bucketToMerge.m_BucketID ? this : bucketToMerge; @@ -780,7 +780,7 @@ public int GetAvgElo() } - public async Task Join(UserSession playerSession) + public async Task JoinAsync(UserSession playerSession) { lock (m_StateLock) { @@ -821,12 +821,12 @@ public Int64 GetLobbyID() Int64 m_timeStartedWaitingOnLobbyJoins = -1; // how long we give everyone to actually connect to the QuickMatch lobby before we give up on the stragglers - private const Int64 c_LobbyJoinTimeoutMSec = 45000; - private const int c_GameStartCountdownMSec = 5000; - private const int c_SetupClientTimeoutMarginMSec = 2000; + private const Int64 LobbyJoinTimeoutMs = 45000; + private const int GameStartCountdownMs = 5000; + private const int SetupClientTimeoutMarginMs = 2000; // older clients treat any timeout_ms below this as the start countdown - private const int c_LegacyClientCountdownThresholdMSec = 10000; + private const int LegacyClientCountdownThresholdMs = 10000; private async Task StartGameAfterSuccessfulMeshCheck(Lobby lobby) { @@ -879,7 +879,7 @@ private async Task TriggerFullMeshConnectivityChecks(Lobby lobby) } // kept at or above the legacy threshold so short tuned checks don't show a start countdown on older clients - QueueSetupProgress(Math.Max(FullMeshCheckSettings.MaxDurationMS + c_SetupClientTimeoutMarginMSec, c_LegacyClientCountdownThresholdMSec)); + QueueSetupProgress(Math.Max(FullMeshCheckSettings.MaxDurationMS + SetupClientTimeoutMarginMs, LegacyClientCountdownThresholdMs)); lobby.SendFullMeshConnectivityCheckRequestToMembers(); @@ -893,7 +893,7 @@ private async Task TriggerFullMeshConnectivityChecks(Lobby lobby) } } - private async Task AbortQuickMatchAutoStart(string reason, bool dropNonJoiners = false) + private async Task AbortQuickMatchAutoStartAsync(string reason, bool dropNonJoiners = false) { List sessionsToRequeue = new(); long abortedLobbyID; @@ -1029,7 +1029,7 @@ public async Task Tick() if (bAutoStartInvalidated) { - await AbortQuickMatchAutoStart("QuickMatch auto-start was aborted because a player left during match setup."); + await AbortQuickMatchAutoStartAsync("QuickMatch auto-start was aborted because a player left during match setup."); return; } @@ -1038,7 +1038,7 @@ public async Task Tick() Lobby? lobbyDuringMeshCheck = lobbyManager.GetLobby(m_LobbyID); if (lobbyDuringMeshCheck == null) { - await AbortQuickMatchAutoStart("QuickMatch auto-start was aborted because the temporary lobby no longer exists."); + await AbortQuickMatchAutoStartAsync("QuickMatch auto-start was aborted because the temporary lobby no longer exists."); return; } @@ -1060,11 +1060,11 @@ public async Task Tick() else { m_bWaitingOnMeshConnectivityChecks = false; - bStartCountdown = FormationMatches(lobbyDuringMeshCheck) && lobbyDuringMeshCheck.LastFullMeshConnectivityCheckOutcome == true; + bStartCountdown = IsFormationCurrent(lobbyDuringMeshCheck) && lobbyDuringMeshCheck.LastFullMeshConnectivityCheckOutcome == true; bAbortStart = !bStartCountdown; if (bStartCountdown) { - m_StartTime = Environment.TickCount64 + c_GameStartCountdownMSec; + m_StartTime = Environment.TickCount64 + GameStartCountdownMs; m_bHasStartedCountdown = true; } } @@ -1072,13 +1072,13 @@ public async Task Tick() if (bStartCountdown) { - QueueSetupProgress(c_GameStartCountdownMSec + c_SetupClientTimeoutMarginMSec, c_GameStartCountdownMSec); + QueueSetupProgress(GameStartCountdownMs + SetupClientTimeoutMarginMs, GameStartCountdownMs); foreach (MatchmakingBucketMember member in m_lstMembers) { UserSession? memberSession = member.GetAssociatedSession(); if (memberSession != null) { - await SendMatchmakingMessage(memberSession, $"Starting game in {c_GameStartCountdownMSec / 1000} seconds."); + await SendMatchmakingMessage(memberSession, $"Starting game in {GameStartCountdownMs / 1000} seconds."); } } } @@ -1087,7 +1087,7 @@ public async Task Tick() string reason = bInvalidatedAtDecision ? "QuickMatch auto-start was aborted because a player left during match setup." : "QuickMatch auto-start was aborted because not all players were fully mesh-connected."; - await AbortQuickMatchAutoStart(reason); + await AbortQuickMatchAutoStartAsync(reason); } } @@ -1104,7 +1104,7 @@ public async Task Tick() Lobby? lobbyAfterCountdown = lobbyManager.GetLobby(m_LobbyID); if (lobbyAfterCountdown == null) { - await AbortQuickMatchAutoStart("QuickMatch auto-start was aborted because the temporary lobby no longer exists."); + await AbortQuickMatchAutoStartAsync("QuickMatch auto-start was aborted because the temporary lobby no longer exists."); return; } @@ -1121,7 +1121,7 @@ public async Task Tick() { m_bHasStartedCountdown = false; m_StartTime = -1; - bStartGame = FormationMatches(lobbyAfterCountdown); + bStartGame = IsFormationCurrent(lobbyAfterCountdown); bAbortStart = !bStartGame; if (bStartGame) { @@ -1137,7 +1137,7 @@ public async Task Tick() } else if (bAbortStart) { - await AbortQuickMatchAutoStart("QuickMatch auto-start was aborted because a player left during match setup."); + await AbortQuickMatchAutoStartAsync("QuickMatch auto-start was aborted because a player left during match setup."); } return; @@ -1204,7 +1204,7 @@ await SendMatchmakingMessage(memberSession, // did we hit the timer OR have enough players to start? bool bMinPlayersCountdownExpired = m_bReachedMinPlayers && (Environment.TickCount64 - m_timeReachedMinPlayers) > playlist.GracePeriodAtMinPlayersMSec; var formation = TryBeginFormation(playlist, bMinPlayersCountdownExpired); - if (formation != null) await CreateFormationLobby(playlist, formation); + if (formation != null) await CreateFormationLobbyAsync(playlist, formation); } else { @@ -1268,16 +1268,16 @@ await SendMatchmakingMessage(memberSession, // has someone failed to connect? drop them so the rest of the players aren't stuck here forever bool bJoinTimeoutExpired = m_timeStartedWaitingOnLobbyJoins != -1 - && (Environment.TickCount64 - m_timeStartedWaitingOnLobbyJoins) > c_LobbyJoinTimeoutMSec; + && (Environment.TickCount64 - m_timeStartedWaitingOnLobbyJoins) > LobbyJoinTimeoutMs; - if (bJoinTimeoutExpired && !FormationMatches(lobby)) + if (bJoinTimeoutExpired && !IsFormationCurrent(lobby)) { - await AbortQuickMatchAutoStart("Not all selected players joined the QuickMatch lobby in time.", dropNonJoiners: true); + await AbortQuickMatchAutoStartAsync("Not all selected players joined the QuickMatch lobby in time.", dropNonJoiners: true); return; } { - if (FormationMatches(lobby)) + if (IsFormationCurrent(lobby)) { m_timeStartedWaitingOnLobbyJoins = -1; @@ -1326,12 +1326,12 @@ await SendMatchmakingMessage(memberSession, // Using ConcurrentBag instead of ConcurrentList for lock-free bucket management private static ConcurrentDictionary> m_dictMatchmakingBuckets = new(); - public static void ValidatePlaylistsAtStartup() + public static void RemoveInvalidPlaylistsAtStartup() { foreach (var (id, playlist) in g_Playlists.ToArray()) { string? error = null; - if (playlist.MinPlayers < 2 || playlist.DesiredPlayers > Lobby.maxLobbySize || playlist.MinPlayers > playlist.DesiredPlayers) + if (playlist.MinPlayers < 2 || playlist.DesiredPlayers > Lobby.MaxLobbySize || playlist.MinPlayers > playlist.DesiredPlayers) error = "player bounds exceed supported lobby capacity"; else if (playlist.MinSelectedMaps < 1 || playlist.MinSelectedMaps > playlist.Maps.Count) error = "minimum map selection is outside the playlist map count"; @@ -1511,7 +1511,7 @@ public static async Task Tick() { if (mmBucket.CanMergeWithOtherBucket(mmBucketMergeCandidate)) { - if (await mmBucket.MergeWithOtherBucket(mmBucketMergeCandidate)) + if (await mmBucket.MergeWithOtherBucketAsync(mmBucketMergeCandidate)) lstBucketsMergedNeedingDeleted.Add(mmBucketMergeCandidate); } } @@ -1633,7 +1633,7 @@ public static async Task Tick() // do the maps overlap? if so we can join if (mmBucket.DoMapSelectionsIntersect(thisSession.MatchmakingMapIndicies)) { - bool bJoined = await mmBucket.Join(thisSession); + bool bJoined = await mmBucket.JoinAsync(thisSession); if (bJoined) { diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs index 5eefd21..07f761d 100644 --- a/GenOnlineService/Program.cs +++ b/GenOnlineService/Program.cs @@ -1169,7 +1169,7 @@ public static async Task Main(string[] args) var builder = WebApplication.CreateBuilder(args); RoomCatalog.Initialize(Path.Combine(builder.Environment.ContentRootPath, "data", "rooms.json")); - MatchmakingManager.ValidatePlaylistsAtStartup(); + MatchmakingManager.RemoveInvalidPlaylistsAtStartup(); // Add services to the container. From 52b08f43173d931b5368041f13f22e0c5e066f79 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:46:20 +0200 Subject: [PATCH 11/13] fix(lobby): bind mesh checks to membership and socket lifecycle --- GenOnlineService/Constants.cs | 4 + .../WebSocket/WebSocketController.cs | 58 +--- GenOnlineService/LobbyManager.Sessions.cs | 30 +- GenOnlineService/LobbyManager.cs | 318 ++++++++---------- GenOnlineService/MatchmakingManager.cs | 13 +- 5 files changed, 185 insertions(+), 238 deletions(-) diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index 9d67959..865b0ce 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -1388,6 +1388,7 @@ public void QueueKeepAlive() private bool closed; private readonly UserSession m_OwnerSession; internal UserSession OwnerSession => m_OwnerSession; + internal bool IsOpen => m_SockInternal?.State == WebSocketState.Open; private readonly CancellationTokenSource m_sendLoopStop = new CancellationTokenSource(); private Task m_sendLoop = Task.CompletedTask; @@ -2875,6 +2876,9 @@ public class WebSocketMessage_Social_NewFriendRequest : WebSocketMessage public class WebSocketMessage_FullMeshConnectivityCheckOutcome: WebSocketMessage { + public long mesh_check_id { get; set; } + public int attempt { get; set; } + public string? reason { get; set; } public bool mesh_complete { get; set; } public List missing_connections { get; set; } = new(); } diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index ce70dfd..4d1a3c2 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -1015,55 +1015,15 @@ await lobby.RunExclusiveAsync(() => } } } - else if (msgID == EWebSocketMessageID.FULL_MESH_CONNECTIVITY_CHECK_HOST_REQUESTS_BEGIN) - { - // Host has requested this, as part of the start game flow - - // must be in a lobby - Lobby? lobbyInfo = null; - if (sourceUserSession.currentLobbyID != -1) - { - // must be lobby owner too - lobbyInfo = _lobbyManager.GetLobby(sourceUserSession.currentLobbyID); - - if (lobbyInfo == null || lobbyInfo.Owner != sourceUserSession.m_UserID) - { - return; - } - } - - if (lobbyInfo == null) - { - return; - } - - // lock slots (more people joining when we're already doing connectivity checks won't help the situation) - // Awaited separately from StartFullMeshConnectivityCheck below: each is its own - // gated operation on the lobby, so neither ever nests inside the other's gate use. - await lobbyInfo.CloseOpenSlots(); - - // mark lobby as in progress of full mesh connectivity checks - await lobbyInfo.StartFullMeshConnectivityCheck(); - - // start full mesh connectivity checks - lobbyInfo.SendFullMeshConnectivityCheckRequestToMembers(); - } - else if (msgID == EWebSocketMessageID.FULL_MESH_CONNECTIVITY_CHECK_RESPONSE) - { - // process a response from a user - WebSocketMessage_FullMeshConnectivityCheckResponseFromUser? fullMeshMsg = - JsonSerializer.Deserialize(payload, JsonOpts); - - // store response - if (fullMeshMsg != null) - { - Lobby? lobby = _lobbyManager.GetLobby(sourceUserSession.currentLobbyID); - if (lobby != null) - { - await lobby.StoreFullMeshConnectivityResponse(sourceUserSession.m_UserID, fullMeshMsg); - } - } - } + else if (msgID == EWebSocketMessageID.FULL_MESH_CONNECTIVITY_CHECK_HOST_REQUESTS_BEGIN) + { + await _lobbyManager.StartMeshCheckAsync(sourceUserSession, sourceWS); + } + else if (msgID == EWebSocketMessageID.FULL_MESH_CONNECTIVITY_CHECK_RESPONSE) + { + var response = JsonSerializer.Deserialize(payload, JsonOpts); + if (response != null) await _lobbyManager.StoreMeshResponseAsync(sourceUserSession, sourceWS, response); + } else if (msgID == EWebSocketMessageID.NETWORK_CONNECTION_CLIENT_REQUEST_SIGNALLING) { WebSocketMessage_RequestSignaling? signalingRequest = diff --git a/GenOnlineService/LobbyManager.Sessions.cs b/GenOnlineService/LobbyManager.Sessions.cs index 8f1940b..2cae90f 100644 --- a/GenOnlineService/LobbyManager.Sessions.cs +++ b/GenOnlineService/LobbyManager.Sessions.cs @@ -31,8 +31,10 @@ internal async Task OpenSessionAsync(AppDbContext db, EUs var previous = membershipConnections.GetSession(userID, sessionType); if (!reconnect && previous != null) await DestroySessionAsync(previous); if (reconnect && (previous == null || previous.IsRetired)) return null; - return await WebSocketManager.InstallSession(db, sessionType, reconnect, userID, clientID, + var installed = await WebSocketManager.InstallSession(db, sessionType, reconnect, userID, clientID, ipAddress, continent, country, latitude, longitude, admin); + if (reconnect && previous != null) await InvalidateSessionMesh(previous); + return installed; } internal async Task DisconnectSessionAsync(UserSession? session, UserWebSocketInstance? socket, bool destroySession) @@ -46,6 +48,7 @@ internal async Task DisconnectSessionAsync(UserSession? session, UserWebSocketIn else { WebSocketManager.DetachSocket(session); + await InvalidateSessionMesh(session); session.MarkAbandoned(); var lobby = GetLobby(session.currentLobbyID); if (lobby?.State == ELobbyState.INGAME) lobby.RecordPlayerIngameAbandon(session.m_UserID); @@ -79,6 +82,7 @@ private async Task DestroySessionAsync(UserSession session) { if (gameplay) { + await InvalidateSessionMesh(session); await MatchmakingManager.DetachSessionAsync(session); await LeaveMembershipsAsync(session.m_UserID); await CleanupUserLobbiesNotStartedAsync(session.m_UserID); @@ -112,6 +116,30 @@ private async Task DestroySessionAsync(UserSession session) } } + private async Task InvalidateSessionMesh(UserSession session) + { + if (!SessionHelpers.SessionTypeHasAccessTo(session.GetSessionType(), ESessionAccessType.Gameplay)) return; + var lobby = GetLobby(session.currentLobbyID); + if (lobby != null) await lobby.InvalidateMeshForSession(session); + } + + internal async Task StartMeshCheckAsync(UserSession session, UserWebSocketInstance socket) + { + using var membership = await AcquireMembershipGateAsync(session.m_UserID); + if (!IsCurrentSession(session) || session.IsRetired || !ReferenceEquals(WebSocketManager.GetWebSocketForSession(session), socket)) return; + var lobby = GetLobby(session.currentLobbyID); + if (lobby != null) await lobby.StartFullMeshConnectivityCheck(session, socket); + } + + internal async Task StoreMeshResponseAsync(UserSession session, UserWebSocketInstance socket, + WebSocketMessage_FullMeshConnectivityCheckResponseFromUser response) + { + using var membership = await AcquireMembershipGateAsync(session.m_UserID); + if (!IsCurrentSession(session) || session.IsRetired || !ReferenceEquals(WebSocketManager.GetWebSocketForSession(session), socket)) return; + var lobby = GetLobby(session.currentLobbyID); + if (lobby != null) await lobby.StoreFullMeshConnectivityResponse(session, socket, response); + } + internal async Task CancelMatchmakingAsync(UserSession session) { using var membership = await AcquireMembershipGateAsync(session.m_UserID); diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 9decb52..51c8b26 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -262,19 +262,108 @@ public async Task RegisterProbeResponse_Malformed_Type2(Int64 userID) } // End AC Probes - public async Task StartFullMeshConnectivityCheck() + private sealed record MeshParticipant(LobbyMember Member, UserSession Session, UserWebSocketInstance? Socket); + private Dictionary m_MeshParticipants = new(); + private MeshParticipant? m_MeshHost; + private long m_MembershipVersion; + private long m_MeshMembershipVersion; + [JsonIgnore] + public string? LastFullMeshCheckFailureReason { get; private set; } + + public Task StartFullMeshConnectivityCheck(UserSession? requester = null, UserWebSocketInstance? socket = null) { - await RunExclusiveAsync(() => + return RunExclusiveAsync(() => { + var host = GetMemberFromUserID(Owner); + if (State != ELobbyState.GAME_SETUP || host == null || !host.GetSession().TryGetTarget(out var hostSession) + || (requester != null && (!ReferenceEquals(hostSession, requester) + || !ReferenceEquals(WebSocketManager.GetWebSocketForSession(requester), socket)))) + return Task.FromResult(0L); + + if (requester != null) + { + foreach (var member in Members) + if (member.SlotState == EPlayerType.SLOT_OPEN) member.SetPlayerSlotState(EPlayerType.SLOT_CLOSED); + DirtyRetransmit(); + } FullMeshCheckID = Interlocked.Increment(ref s_NextFullMeshCheckID); FullMeshCheckAttempt = 1; + m_MeshMembershipVersion = m_MembershipVersion; + m_MeshParticipants = new(); + foreach (var member in Members.Where(member => member.IsHuman())) + if (member.GetSession().TryGetTarget(out var session)) + m_MeshParticipants[member.UserID] = new(member, session, WebSocketManager.GetWebSocketForSession(session)); + m_MeshHost = m_MeshParticipants.GetValueOrDefault(Owner); m_TimeToRetryFullMeshChecks = -1; LastFullMeshConnectivityCheckOutcome = null; + LastFullMeshCheckFailureReason = null; BeginFullMeshConnectivityCheckAttempt(); - return Task.CompletedTask; + if (ValidateMeshOwnership()) SendFullMeshConnectivityCheckRequestToMembers(); + return Task.FromResult(FullMeshCheckID); }); } + private bool IsCurrentMeshParticipant(MeshParticipant participant) => + ReferenceEquals(GetMemberFromUserID(participant.Session.m_UserID), participant.Member) + && participant.Member.GetSession().TryGetTarget(out var session) && ReferenceEquals(session, participant.Session) + && !session.IsRetired && participant.Socket?.IsOpen == true + && ReferenceEquals(WebSocketManager.GetWebSocketForSession(session), participant.Socket); + + private bool ValidateMeshOwnership() + { + if (FullMeshCheckID == 0) return false; + string? reason = State != ELobbyState.GAME_SETUP ? "lobby_state_changed" + : m_MembershipVersion != m_MeshMembershipVersion + || m_MeshParticipants.Count != GetNumberOfHumans() ? "membership_changed" + : m_MeshParticipants.Values.Any(participant => !IsCurrentMeshParticipant(participant)) ? "session_changed" + : m_MeshHost == null || Owner != m_MeshHost.Session.m_UserID ? "host_changed" : null; + if (reason == null) return true; + InvalidateMeshCheck(reason); + return false; + } + + private void InvalidateMeshCheck(string reason) + { + if (PendingFullMeshConnectivityChecks) CompleteMeshCheck(false, reason, new()); + else if (FullMeshCheckID != 0) + { + LastFullMeshConnectivityCheckOutcome = false; + LastFullMeshCheckFailureReason = reason; + } + } + + internal Task InvalidateMeshForSession(UserSession session) => RunExclusiveAsync(() => + { + if (m_MeshParticipants.Values.Any(participant => ReferenceEquals(participant.Session, session))) + InvalidateMeshCheck("session_changed"); + return Task.CompletedTask; + }); + + public Task GetFullMeshCheckResult(long checkID) => RunExclusiveAsync(() => + { + ValidateMeshOwnership(); + return Task.FromResult(checkID == FullMeshCheckID && checkID != 0 + ? LastFullMeshConnectivityCheckOutcome : (bool?)false); + }); + + private void CompleteMeshCheck(bool success, string? reason, List missing) + { + if (!PendingFullMeshConnectivityChecks) return; + PendingFullMeshConnectivityChecks = false; + TimeStartFullMeshChecks = -1; + m_TimeToRetryFullMeshChecks = -1; + LastFullMeshConnectivityCheckOutcome = success; + LastFullMeshCheckFailureReason = reason; + if (m_MeshHost == null || Owner != m_MeshHost.Session.m_UserID || !IsCurrentMeshParticipant(m_MeshHost)) return; + var outcome = new WebSocketMessage_FullMeshConnectivityCheckOutcome + { + msg_id = (int)EWebSocketMessageID.FULL_MESH_CONNECTIVITY_CHECK_RESPONSE_COMPLETE_TO_HOST, + mesh_check_id = FullMeshCheckID, attempt = FullMeshCheckAttempt, + mesh_complete = success, reason = reason, missing_connections = missing + }; + m_MeshHost.Session.QueueWebsocketSend(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(outcome))); + } + private void BeginFullMeshConnectivityCheckAttempt() { PendingFullMeshConnectivityChecks = true; @@ -311,7 +400,7 @@ private void LogFullMeshCheckAttempt(bool bMeshComplete, List 0 ? $" (still connecting {strConnecting})" : "")}"); } - public void SendFullMeshConnectivityCheckRequestToMembers() + private void SendFullMeshConnectivityCheckRequestToMembers() { WebSocketMessage_FullMeshConnectivityCheckRequest startCommand = new WebSocketMessage_FullMeshConnectivityCheckRequest(); startCommand.msg_id = (int)EWebSocketMessageID.FULL_MESH_CONNECTIVITY_CHECK_RESPONSE; @@ -373,31 +462,21 @@ private void SendStartSignallingToMember(LobbyMember recipient, LobbyMember peer } } - public async Task StoreFullMeshConnectivityResponse(Int64 sourceUser, WebSocketMessage_FullMeshConnectivityCheckResponseFromUser response) + public Task StoreFullMeshConnectivityResponse(UserSession source, UserWebSocketInstance socket, + WebSocketMessage_FullMeshConnectivityCheckResponseFromUser response) => RunExclusiveAsync(() => { - await RunExclusiveAsync(() => - { - bool bLegacyResponse = FullMeshCheckProtocol.IsLegacyResponse(response); - bool bMatchesCurrentAttempt = FullMeshCheckProtocol.MatchesCurrentAttempt( - response, - FullMeshCheckID, - FullMeshCheckAttempt); - - LobbyMember? sourceMember = GetMemberFromUserID(sourceUser); - if (PendingFullMeshConnectivityChecks - && m_TimeToRetryFullMeshChecks == -1 - && sourceMember?.IsHuman() == true - && bMatchesCurrentAttempt) - { - m_bCurrentAttemptHasLegacyResponse |= bLegacyResponse; - FullMeshConnectivityChecks[sourceUser] = new ConcurrentList(response.connectivity_map); - m_FullMeshConnecting[sourceUser] = new HashSet(response.connecting_map); - } - - ProcessPendingFullMeshConnectivityChecksInternal(); + // Legacy zero IDs cannot distinguish successive checks on the same socket. + if (!ValidateMeshOwnership() || !PendingFullMeshConnectivityChecks || m_TimeToRetryFullMeshChecks != -1 + || !m_MeshParticipants.TryGetValue(source.m_UserID, out var participant) + || !ReferenceEquals(participant.Session, source) || !ReferenceEquals(participant.Socket, socket) + || !FullMeshCheckProtocol.MatchesCurrentAttempt(response, FullMeshCheckID, FullMeshCheckAttempt)) return Task.CompletedTask; - }); - } + m_bCurrentAttemptHasLegacyResponse |= FullMeshCheckProtocol.IsLegacyResponse(response); + FullMeshConnectivityChecks[source.m_UserID] = new(response.connectivity_map ?? new()); + m_FullMeshConnecting[source.m_UserID] = new(response.connecting_map ?? new()); + ProcessPendingFullMeshConnectivityChecksInternal(); + return Task.CompletedTask; + }); public async Task ProcessPendingFullMeshConnectivityChecks() { @@ -411,172 +490,43 @@ await RunExclusiveAsync(() => // Must only be called while holding m_LobbyGate (via RunExclusiveAsync). private void ProcessPendingFullMeshConnectivityChecksInternal() { - if (!PendingFullMeshConnectivityChecks) - { - return; - } - - // Give re-signalled connections time to establish before starting the retry. + if (!ValidateMeshOwnership() || !PendingFullMeshConnectivityChecks) return; if (m_TimeToRetryFullMeshChecks != -1) { - if (Environment.TickCount64 < m_TimeToRetryFullMeshChecks) - { - return; - } - + if (Environment.TickCount64 < m_TimeToRetryFullMeshChecks) return; m_TimeToRetryFullMeshChecks = -1; BeginFullMeshConnectivityCheckAttempt(); SendFullMeshConnectivityCheckRequestToMembers(); return; } - { - bool bDoneChecks = false; - - // judged as soon as anyone replies: members who haven't replied yet count as missing, and gaps before - // the window closes are re-polled below, which also recovers replies lost to a reconnect - bool bWindowElapsed = (Environment.TickCount64 - TimeStartFullMeshChecks) >= FullMeshCheckSettings.AttemptWindowMS; - bDoneChecks = bWindowElapsed || !FullMeshConnectivityChecks.IsEmpty; - - List lstMissingConnections = new(); - - if (bDoneChecks) + var missing = new List(); + foreach (var source in m_MeshParticipants.Keys) + foreach (var target in m_MeshParticipants.Keys) + if (source != target && (!FullMeshConnectivityChecks.TryGetValue(source, out var connected) || !connected.Contains(target))) + missing.Add(new() { source_user_id = source, target_user_id = target }); + bool disabled = Program.g_Config?.GetSection("Core").GetValue("disable_full_mesh_check") == true; + bool allReported = m_MeshParticipants.Keys.All(FullMeshConnectivityChecks.ContainsKey); + bool complete = disabled || (allReported && missing.Count == 0); + bool elapsed = Environment.TickCount64 - TimeStartFullMeshChecks >= FullMeshCheckSettings.AttemptWindowMS; + if (!complete && !elapsed) + { + if (Environment.TickCount64 >= m_TimeNextFullMeshSnapshotRequest) { - // now verify each user has provided data for all other users - foreach (var userMap in FullMeshConnectivityChecks) - { - // a member who left mid-check no longer counts - if (GetMemberFromUserID(userMap.Key)?.IsHuman() != true) - { - continue; - } - - // foreach member in the lobby, check they are in userMap.Value - foreach (LobbyMember member in Members) - { - if (member.IsHuman()) - { - // useful for test -// if (member.UserID != userMap.Key && member.UserID == 1) -// { -// // register it -// MissingConnectionEntry missingConnectionEntry = new(); -// missingConnectionEntry.source_user_id = userMap.Key; -// missingConnectionEntry.target_user_id = member.UserID; -// lstMissingConnections.Add(missingConnectionEntry); -// } - - // wont have a connection to ourself - if (member.UserID != userMap.Key && !userMap.Value.Contains(member.UserID)) - { - // register it - MissingConnectionEntry missingConnectionEntry = new(); - missingConnectionEntry.source_user_id = userMap.Key; - missingConnectionEntry.target_user_id = member.UserID; - lstMissingConnections.Add(missingConnectionEntry); - } - } - } - } - - // A member that never reported cannot be assumed connected, so treat them as missing to everyone. - foreach (LobbyMember member in Members) - { - if (member.IsHuman() && !FullMeshConnectivityChecks.ContainsKey(member.UserID)) - { - foreach (LobbyMember otherMember in Members) - { - if (otherMember.IsHuman() && otherMember.UserID != member.UserID) - { - MissingConnectionEntry missingConnectionEntry = new(); - missingConnectionEntry.source_user_id = member.UserID; - missingConnectionEntry.target_user_id = otherMember.UserID; - lstMissingConnections.Add(missingConnectionEntry); - } - } - } - } - - bool bDisableMeshCheck = false; - if (Program.g_Config != null) - { - IConfiguration? coreSettings = Program.g_Config.GetSection("Core"); - - if (coreSettings != null) - { - bDisableMeshCheck = coreSettings.GetValue("disable_full_mesh_check"); - } - } - - - - bool bMeshComplete = bDisableMeshCheck || lstMissingConnections.Count == 0; - - // members report a snapshot, so a gap before the window closes may be a connection still forming: - // keep asking for fresh snapshots rather than re-signalling it early - if (!bMeshComplete && !bWindowElapsed) - { - if (Environment.TickCount64 >= m_TimeNextFullMeshSnapshotRequest) - { - m_TimeNextFullMeshSnapshotRequest = Environment.TickCount64 + FullMeshCheckSettings.SnapshotIntervalMS; - SendFullMeshConnectivityCheckRequestToMembers(); - } - - return; - } - - LogFullMeshCheckAttempt(bMeshComplete, lstMissingConnections); - - if (FullMeshCheckProtocol.ShouldRetry( - bMeshComplete, - m_bCurrentAttemptHasLegacyResponse, - FullMeshCheckAttempt, - FullMeshCheckSettings.MaxAttempts)) - { - ++FullMeshCheckAttempt; - - // a pair still negotiating would be torn down by a re-signal; the next attempt re-checks it - RestartSignallingForMissingConnections(lstMissingConnections - .Where(c => !IsFullMeshPairStillConnecting(c.source_user_id, c.target_user_id)) - .ToList()); - m_TimeToRetryFullMeshChecks = Environment.TickCount64 + FullMeshCheckSettings.RetryDelayMS; - return; - } - - // inform host that we are done - // start full mesh connectivity checks - WebSocketMessage_FullMeshConnectivityCheckOutcome outcome = new WebSocketMessage_FullMeshConnectivityCheckOutcome(); - outcome.msg_id = (int)EWebSocketMessageID.FULL_MESH_CONNECTIVITY_CHECK_RESPONSE_COMPLETE_TO_HOST; - - if (bDisableMeshCheck) - { - outcome.mesh_complete = true; - outcome.missing_connections = new List(); - } - else - { - outcome.mesh_complete = lstMissingConnections.Count == 0; - outcome.missing_connections = lstMissingConnections; - } - - LastFullMeshConnectivityCheckOutcome = outcome.mesh_complete; - - // TODO_EFCORE: Later, these should really use lobby list instead of getting session from ID - - // send to host - UserSession? hostSession = WebSocketManager.GetSessionFromUser(Owner, EUserSessionType.GameClient); // host should be a game client - if (hostSession != null) - { - byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(outcome)); - hostSession.QueueWebsocketSend(bytesJSON); - } - - // reset state - PendingFullMeshConnectivityChecks = false; - TimeStartFullMeshChecks = -1; - m_TimeToRetryFullMeshChecks = -1; + m_TimeNextFullMeshSnapshotRequest = Environment.TickCount64 + FullMeshCheckSettings.SnapshotIntervalMS; + SendFullMeshConnectivityCheckRequestToMembers(); } + return; } + LogFullMeshCheckAttempt(complete, missing); + if (FullMeshCheckProtocol.ShouldRetry(complete, m_bCurrentAttemptHasLegacyResponse, FullMeshCheckAttempt, FullMeshCheckSettings.MaxAttempts)) + { + ++FullMeshCheckAttempt; + RestartSignallingForMissingConnections(missing.Where(connection => !IsFullMeshPairStillConnecting(connection.source_user_id, connection.target_user_id)).ToList()); + m_TimeToRetryFullMeshChecks = Environment.TickCount64 + FullMeshCheckSettings.RetryDelayMS; + return; + } + CompleteMeshCheck(complete, complete ? null : allReported ? "missing_connections" : "timeout", disabled ? new() : missing); } public void AddPassword(string password) @@ -1229,6 +1179,8 @@ internal Task AddCreatorAsync(UserSession session, string } Members[slotIndex] = newMember; + ++m_MembershipVersion; + InvalidateMeshCheck("membership_changed"); TimeMemberLeft[playerSession.m_UserID] = DateTime.UnixEpoch; // Lobby members leave public-room presence. @@ -1375,6 +1327,8 @@ private bool RemoveMemberLocked(LobbyMember member) TimeMemberLeft[member.UserID] = DateTime.UtcNow; OnAfterPlayerLeftLocked(member.UserID); + ++m_MembershipVersion; + InvalidateMeshCheck("membership_changed"); return true; } diff --git a/GenOnlineService/MatchmakingManager.cs b/GenOnlineService/MatchmakingManager.cs index a96f54c..45f9201 100644 --- a/GenOnlineService/MatchmakingManager.cs +++ b/GenOnlineService/MatchmakingManager.cs @@ -868,9 +868,11 @@ private void QueueSetupProgress(int timeoutMSec, int countdownMSec = 0) } } + private long m_MeshCheckID; + private async Task TriggerFullMeshConnectivityChecks(Lobby lobby) { - await lobby.StartFullMeshConnectivityCheck(); + m_MeshCheckID = await lobby.StartFullMeshConnectivityCheck(); lock (m_StateLock) { // Publish the state transition before any notification work. If a send fails, the @@ -881,8 +883,6 @@ private async Task TriggerFullMeshConnectivityChecks(Lobby lobby) // kept at or above the legacy threshold so short tuned checks don't show a start countdown on older clients QueueSetupProgress(Math.Max(FullMeshCheckSettings.MaxDurationMS + SetupClientTimeoutMarginMs, LegacyClientCountdownThresholdMs)); - lobby.SendFullMeshConnectivityCheckRequestToMembers(); - foreach (MatchmakingBucketMember member in m_lstMembers) { UserSession? memberSession = member.GetAssociatedSession(); @@ -1044,7 +1044,7 @@ public async Task Tick() await lobbyDuringMeshCheck.ProcessPendingFullMeshConnectivityChecks(); - if (!lobbyDuringMeshCheck.PendingFullMeshConnectivityChecks) + if (await lobbyDuringMeshCheck.GetFullMeshCheckResult(m_MeshCheckID) is bool meshComplete) { bool bStartCountdown; bool bAbortStart; @@ -1060,7 +1060,7 @@ public async Task Tick() else { m_bWaitingOnMeshConnectivityChecks = false; - bStartCountdown = IsFormationCurrent(lobbyDuringMeshCheck) && lobbyDuringMeshCheck.LastFullMeshConnectivityCheckOutcome == true; + bStartCountdown = IsFormationCurrent(lobbyDuringMeshCheck) && meshComplete; bAbortStart = !bStartCountdown; if (bStartCountdown) { @@ -1108,6 +1108,7 @@ public async Task Tick() return; } + bool meshStillValid = await lobbyAfterCountdown.GetFullMeshCheckResult(m_MeshCheckID) == true; bool bStartGame; bool bAbortStart; lock (m_StateLock) @@ -1121,7 +1122,7 @@ public async Task Tick() { m_bHasStartedCountdown = false; m_StartTime = -1; - bStartGame = IsFormationCurrent(lobbyAfterCountdown); + bStartGame = meshStillValid && IsFormationCurrent(lobbyAfterCountdown); bAbortStart = !bStartGame; if (bStartGame) { From f820790785805ba92c155795f98492c0ffaa927b Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:07:19 +0200 Subject: [PATCH 12/13] refactor(lobby): clarify mesh check lifecycle names --- GenOnlineService/LobbyManager.Sessions.cs | 14 +++---- GenOnlineService/LobbyManager.cs | 46 +++++++++++------------ GenOnlineService/MatchmakingManager.cs | 8 ++-- 3 files changed, 34 insertions(+), 34 deletions(-) diff --git a/GenOnlineService/LobbyManager.Sessions.cs b/GenOnlineService/LobbyManager.Sessions.cs index 2cae90f..492aaa4 100644 --- a/GenOnlineService/LobbyManager.Sessions.cs +++ b/GenOnlineService/LobbyManager.Sessions.cs @@ -33,7 +33,7 @@ internal async Task OpenSessionAsync(AppDbContext db, EUs if (reconnect && (previous == null || previous.IsRetired)) return null; var installed = await WebSocketManager.InstallSession(db, sessionType, reconnect, userID, clientID, ipAddress, continent, country, latitude, longitude, admin); - if (reconnect && previous != null) await InvalidateSessionMesh(previous); + if (reconnect && previous != null) await InvalidateSessionMeshAsync(previous); return installed; } @@ -48,7 +48,7 @@ internal async Task DisconnectSessionAsync(UserSession? session, UserWebSocketIn else { WebSocketManager.DetachSocket(session); - await InvalidateSessionMesh(session); + await InvalidateSessionMeshAsync(session); session.MarkAbandoned(); var lobby = GetLobby(session.currentLobbyID); if (lobby?.State == ELobbyState.INGAME) lobby.RecordPlayerIngameAbandon(session.m_UserID); @@ -82,7 +82,7 @@ private async Task DestroySessionAsync(UserSession session) { if (gameplay) { - await InvalidateSessionMesh(session); + await InvalidateSessionMeshAsync(session); await MatchmakingManager.DetachSessionAsync(session); await LeaveMembershipsAsync(session.m_UserID); await CleanupUserLobbiesNotStartedAsync(session.m_UserID); @@ -116,11 +116,11 @@ private async Task DestroySessionAsync(UserSession session) } } - private async Task InvalidateSessionMesh(UserSession session) + private async Task InvalidateSessionMeshAsync(UserSession session) { if (!SessionHelpers.SessionTypeHasAccessTo(session.GetSessionType(), ESessionAccessType.Gameplay)) return; var lobby = GetLobby(session.currentLobbyID); - if (lobby != null) await lobby.InvalidateMeshForSession(session); + if (lobby != null) await lobby.InvalidateMeshForSessionAsync(session); } internal async Task StartMeshCheckAsync(UserSession session, UserWebSocketInstance socket) @@ -128,7 +128,7 @@ internal async Task StartMeshCheckAsync(UserSession session, UserWebSocketInstan using var membership = await AcquireMembershipGateAsync(session.m_UserID); if (!IsCurrentSession(session) || session.IsRetired || !ReferenceEquals(WebSocketManager.GetWebSocketForSession(session), socket)) return; var lobby = GetLobby(session.currentLobbyID); - if (lobby != null) await lobby.StartFullMeshConnectivityCheck(session, socket); + if (lobby != null) await lobby.StartMeshCheckAsync(session, socket); } internal async Task StoreMeshResponseAsync(UserSession session, UserWebSocketInstance socket, @@ -137,7 +137,7 @@ internal async Task StoreMeshResponseAsync(UserSession session, UserWebSocketIns using var membership = await AcquireMembershipGateAsync(session.m_UserID); if (!IsCurrentSession(session) || session.IsRetired || !ReferenceEquals(WebSocketManager.GetWebSocketForSession(session), socket)) return; var lobby = GetLobby(session.currentLobbyID); - if (lobby != null) await lobby.StoreFullMeshConnectivityResponse(session, socket, response); + if (lobby != null) await lobby.StoreMeshResponseAsync(session, socket, response); } internal async Task CancelMatchmakingAsync(UserSession session) diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 51c8b26..b16baaf 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -66,18 +66,18 @@ internal static bool ShouldRetry(bool meshComplete, bool hasLegacyResponse, int internal static class FullMeshCheckSettings { // how long an attempt waits for every connection before judging it - internal static int AttemptWindowMS => Get("full_mesh_check_attempt_window_ms", 8000); + internal static int AttemptWindowMs => Get("full_mesh_check_attempt_window_ms", 8000); // how often an incomplete attempt asks members for a fresh snapshot - internal static int SnapshotIntervalMS => Get("full_mesh_check_snapshot_interval_ms", 1000); + internal static int SnapshotIntervalMs => Get("full_mesh_check_snapshot_interval_ms", 1000); // time given to re-signalled connections before the next attempt - internal static int RetryDelayMS => Get("full_mesh_check_retry_delay_ms", 3000); + internal static int RetryDelayMs => Get("full_mesh_check_retry_delay_ms", 3000); internal static int MaxAttempts => Get("full_mesh_check_max_attempts", 2); // upper bound for a whole check, used as the clients' setup timeout - internal static int MaxDurationMS => (AttemptWindowMS * MaxAttempts) + (RetryDelayMS * (MaxAttempts - 1)); + internal static int MaxDurationMs => (AttemptWindowMs * MaxAttempts) + (RetryDelayMs * (MaxAttempts - 1)); private static int Get(string key, int defaultValue) { @@ -266,11 +266,11 @@ private sealed record MeshParticipant(LobbyMember Member, UserSession Session, U private Dictionary m_MeshParticipants = new(); private MeshParticipant? m_MeshHost; private long m_MembershipVersion; - private long m_MeshMembershipVersion; + private long m_MembershipVersionAtMeshCheckStart; [JsonIgnore] public string? LastFullMeshCheckFailureReason { get; private set; } - public Task StartFullMeshConnectivityCheck(UserSession? requester = null, UserWebSocketInstance? socket = null) + public Task StartMeshCheckAsync(UserSession? requester = null, UserWebSocketInstance? socket = null) { return RunExclusiveAsync(() => { @@ -288,7 +288,7 @@ public Task StartFullMeshConnectivityCheck(UserSession? requester = null, } FullMeshCheckID = Interlocked.Increment(ref s_NextFullMeshCheckID); FullMeshCheckAttempt = 1; - m_MeshMembershipVersion = m_MembershipVersion; + m_MembershipVersionAtMeshCheckStart = m_MembershipVersion; m_MeshParticipants = new(); foreach (var member in Members.Where(member => member.IsHuman())) if (member.GetSession().TryGetTarget(out var session)) @@ -298,7 +298,7 @@ public Task StartFullMeshConnectivityCheck(UserSession? requester = null, LastFullMeshConnectivityCheckOutcome = null; LastFullMeshCheckFailureReason = null; BeginFullMeshConnectivityCheckAttempt(); - if (ValidateMeshOwnership()) SendFullMeshConnectivityCheckRequestToMembers(); + if (EnsureMeshCheckIsCurrentLocked()) SendFullMeshConnectivityCheckRequestToMembers(); return Task.FromResult(FullMeshCheckID); }); } @@ -309,11 +309,11 @@ private bool IsCurrentMeshParticipant(MeshParticipant participant) => && !session.IsRetired && participant.Socket?.IsOpen == true && ReferenceEquals(WebSocketManager.GetWebSocketForSession(session), participant.Socket); - private bool ValidateMeshOwnership() + private bool EnsureMeshCheckIsCurrentLocked() { if (FullMeshCheckID == 0) return false; string? reason = State != ELobbyState.GAME_SETUP ? "lobby_state_changed" - : m_MembershipVersion != m_MeshMembershipVersion + : m_MembershipVersion != m_MembershipVersionAtMeshCheckStart || m_MeshParticipants.Count != GetNumberOfHumans() ? "membership_changed" : m_MeshParticipants.Values.Any(participant => !IsCurrentMeshParticipant(participant)) ? "session_changed" : m_MeshHost == null || Owner != m_MeshHost.Session.m_UserID ? "host_changed" : null; @@ -332,16 +332,16 @@ private void InvalidateMeshCheck(string reason) } } - internal Task InvalidateMeshForSession(UserSession session) => RunExclusiveAsync(() => + internal Task InvalidateMeshForSessionAsync(UserSession session) => RunExclusiveAsync(() => { if (m_MeshParticipants.Values.Any(participant => ReferenceEquals(participant.Session, session))) InvalidateMeshCheck("session_changed"); return Task.CompletedTask; }); - public Task GetFullMeshCheckResult(long checkID) => RunExclusiveAsync(() => + public Task GetValidatedMeshCheckResultAsync(long checkID) => RunExclusiveAsync(() => { - ValidateMeshOwnership(); + EnsureMeshCheckIsCurrentLocked(); return Task.FromResult(checkID == FullMeshCheckID && checkID != 0 ? LastFullMeshConnectivityCheckOutcome : (bool?)false); }); @@ -371,7 +371,7 @@ private void BeginFullMeshConnectivityCheckAttempt() m_FullMeshConnecting = new(); m_bCurrentAttemptHasLegacyResponse = false; TimeStartFullMeshChecks = Environment.TickCount64; - m_TimeNextFullMeshSnapshotRequest = TimeStartFullMeshChecks + FullMeshCheckSettings.SnapshotIntervalMS; + m_TimeNextFullMeshSnapshotRequest = TimeStartFullMeshChecks + FullMeshCheckSettings.SnapshotIntervalMs; } private bool IsFullMeshPairStillConnecting(Int64 userA, Int64 userB) @@ -462,11 +462,11 @@ private void SendStartSignallingToMember(LobbyMember recipient, LobbyMember peer } } - public Task StoreFullMeshConnectivityResponse(UserSession source, UserWebSocketInstance socket, + public Task StoreMeshResponseAsync(UserSession source, UserWebSocketInstance socket, WebSocketMessage_FullMeshConnectivityCheckResponseFromUser response) => RunExclusiveAsync(() => { // Legacy zero IDs cannot distinguish successive checks on the same socket. - if (!ValidateMeshOwnership() || !PendingFullMeshConnectivityChecks || m_TimeToRetryFullMeshChecks != -1 + if (!EnsureMeshCheckIsCurrentLocked() || !PendingFullMeshConnectivityChecks || m_TimeToRetryFullMeshChecks != -1 || !m_MeshParticipants.TryGetValue(source.m_UserID, out var participant) || !ReferenceEquals(participant.Session, source) || !ReferenceEquals(participant.Socket, socket) || !FullMeshCheckProtocol.MatchesCurrentAttempt(response, FullMeshCheckID, FullMeshCheckAttempt)) @@ -490,7 +490,7 @@ await RunExclusiveAsync(() => // Must only be called while holding m_LobbyGate (via RunExclusiveAsync). private void ProcessPendingFullMeshConnectivityChecksInternal() { - if (!ValidateMeshOwnership() || !PendingFullMeshConnectivityChecks) return; + if (!EnsureMeshCheckIsCurrentLocked() || !PendingFullMeshConnectivityChecks) return; if (m_TimeToRetryFullMeshChecks != -1) { if (Environment.TickCount64 < m_TimeToRetryFullMeshChecks) return; @@ -508,12 +508,12 @@ private void ProcessPendingFullMeshConnectivityChecksInternal() bool disabled = Program.g_Config?.GetSection("Core").GetValue("disable_full_mesh_check") == true; bool allReported = m_MeshParticipants.Keys.All(FullMeshConnectivityChecks.ContainsKey); bool complete = disabled || (allReported && missing.Count == 0); - bool elapsed = Environment.TickCount64 - TimeStartFullMeshChecks >= FullMeshCheckSettings.AttemptWindowMS; - if (!complete && !elapsed) + bool attemptTimedOut = Environment.TickCount64 - TimeStartFullMeshChecks >= FullMeshCheckSettings.AttemptWindowMs; + if (!complete && !attemptTimedOut) { if (Environment.TickCount64 >= m_TimeNextFullMeshSnapshotRequest) { - m_TimeNextFullMeshSnapshotRequest = Environment.TickCount64 + FullMeshCheckSettings.SnapshotIntervalMS; + m_TimeNextFullMeshSnapshotRequest = Environment.TickCount64 + FullMeshCheckSettings.SnapshotIntervalMs; SendFullMeshConnectivityCheckRequestToMembers(); } return; @@ -523,7 +523,7 @@ private void ProcessPendingFullMeshConnectivityChecksInternal() { ++FullMeshCheckAttempt; RestartSignallingForMissingConnections(missing.Where(connection => !IsFullMeshPairStillConnecting(connection.source_user_id, connection.target_user_id)).ToList()); - m_TimeToRetryFullMeshChecks = Environment.TickCount64 + FullMeshCheckSettings.RetryDelayMS; + m_TimeToRetryFullMeshChecks = Environment.TickCount64 + FullMeshCheckSettings.RetryDelayMs; return; } CompleteMeshCheck(complete, complete ? null : allReported ? "missing_connections" : "timeout", disabled ? new() : missing); @@ -885,8 +885,8 @@ public async Task FinalizeACChecks() // Runs under the per-lobby gate: this mutates slot state and previously ran unguarded, // 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 + // inside) StartMeshCheckAsync's own gate use - callers await this first and then + // await StartMeshCheckAsync as a separate gated operation, so the gate is never // re-entered. public async Task CloseOpenSlots() { diff --git a/GenOnlineService/MatchmakingManager.cs b/GenOnlineService/MatchmakingManager.cs index 45f9201..8344764 100644 --- a/GenOnlineService/MatchmakingManager.cs +++ b/GenOnlineService/MatchmakingManager.cs @@ -872,7 +872,7 @@ private void QueueSetupProgress(int timeoutMSec, int countdownMSec = 0) private async Task TriggerFullMeshConnectivityChecks(Lobby lobby) { - m_MeshCheckID = await lobby.StartFullMeshConnectivityCheck(); + m_MeshCheckID = await lobby.StartMeshCheckAsync(); lock (m_StateLock) { // Publish the state transition before any notification work. If a send fails, the @@ -881,7 +881,7 @@ private async Task TriggerFullMeshConnectivityChecks(Lobby lobby) } // kept at or above the legacy threshold so short tuned checks don't show a start countdown on older clients - QueueSetupProgress(Math.Max(FullMeshCheckSettings.MaxDurationMS + SetupClientTimeoutMarginMs, LegacyClientCountdownThresholdMs)); + QueueSetupProgress(Math.Max(FullMeshCheckSettings.MaxDurationMs + SetupClientTimeoutMarginMs, LegacyClientCountdownThresholdMs)); foreach (MatchmakingBucketMember member in m_lstMembers) { @@ -1044,7 +1044,7 @@ public async Task Tick() await lobbyDuringMeshCheck.ProcessPendingFullMeshConnectivityChecks(); - if (await lobbyDuringMeshCheck.GetFullMeshCheckResult(m_MeshCheckID) is bool meshComplete) + if (await lobbyDuringMeshCheck.GetValidatedMeshCheckResultAsync(m_MeshCheckID) is bool meshComplete) { bool bStartCountdown; bool bAbortStart; @@ -1108,7 +1108,7 @@ public async Task Tick() return; } - bool meshStillValid = await lobbyAfterCountdown.GetFullMeshCheckResult(m_MeshCheckID) == true; + bool meshStillValid = await lobbyAfterCountdown.GetValidatedMeshCheckResultAsync(m_MeshCheckID) == true; bool bStartGame; bool bAbortStart; lock (m_StateLock) From 47e8e7087f2675bef6f9767554152cac34cb605c Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:22:26 +0200 Subject: [PATCH 13/13] fix(lobby): preserve client mesh outcome compatibility --- GenOnlineService/Constants.cs | 2 +- GenOnlineService/LobbyManager.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index 865b0ce..816149c 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -2878,7 +2878,7 @@ public class WebSocketMessage_FullMeshConnectivityCheckOutcome: WebSocketMessage { public long mesh_check_id { get; set; } public int attempt { get; set; } - public string? reason { get; set; } + public string reason { get; set; } = string.Empty; public bool mesh_complete { get; set; } public List missing_connections { get; set; } = new(); } diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index b16baaf..ea658da 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -359,7 +359,7 @@ private void CompleteMeshCheck(bool success, string? reason, List