Skip to content
Draft
45 changes: 28 additions & 17 deletions GenOnlineService/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -549,9 +549,7 @@ public static int GetNumberOfUsersOnline()
return numSessions;
}

// Pure re-check used by CheckForTimeouts right before actually clearing a snapshotted
// abandoned+expired entry: refuses unless it's still the SAME session object (a reconnect in
// between would have registered a new one) and it is STILL abandoned and expired.
// True only if currentSession is the same object as snapshotSession and still abandoned+expired.
internal static bool ShouldStillClearAbandonedSession(UserSession? currentSession, UserSession snapshotSession)
{
if (!ReferenceEquals(currentSession, snapshotSession))
Expand Down Expand Up @@ -590,10 +588,6 @@ 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))
{
Expand Down Expand Up @@ -658,6 +652,8 @@ public static async Task DeleteSession(Int64 user_id, EUserSessionType sessionTy
{
sourceData.MarkAbandoned();

MatchmakingManager.InvalidateAutoStartForLobby(sourceData.currentLobbyID);

// 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,
Expand Down Expand Up @@ -1160,8 +1156,7 @@ public SharedUserData(Int64 ownerID, UserSocialContainer socialContainer, string
}
}

// Core:reconnect_grace_period_ms in appsettings.json, read on use. Mirrors the pattern used by
// FullMeshCheckSettings in LobbyManager.cs.
// Core:reconnect_grace_period_ms in appsettings.json.
internal static class UserSessionSettings
{
internal static Int64 ReconnectGracePeriodMS => Get("reconnect_grace_period_ms", 30000);
Expand Down Expand Up @@ -1256,10 +1251,15 @@ public UserSession(Int64 ownerID, EUserSessionType sessionType, KnownClients.EKn
m_UserID = ownerID;

// store the exe CRC (this is actually the .CODE section, for AC)
if (Helpers.g_dictInitialExeCRCs.ContainsKey(ownerID))
if (Helpers.g_dictInitialExeCRCs.TryRemove(ownerID, out (string ExeCrcHash, Int64 RegisteredAtTicks) acExeCrcEntry))
{
ACExeCRC = Helpers.g_dictInitialExeCRCs[ownerID].ToUpper();
Helpers.g_dictInitialExeCRCs.Remove(ownerID, out string removedCRC);
ACExeCRC = acExeCrcEntry.ExeCrcHash.ToUpper();
}

if (Helpers.g_dictInitialGameCRCs.TryRemove(ownerID, out (UInt32 ExeCRC, UInt32 IniCRC, Int64 RegisteredAtTicks) gameCRCs))
{
ExeCRC = gameCRCs.ExeCRC;
IniCRC = gameCRCs.IniCRC;
}
}

Expand Down Expand Up @@ -1328,11 +1328,7 @@ public async Task<UserWebSocketInstance> CloseWebsocket(WebSocketCloseStatus rea

public bool NeedsCleanup()
{
// Grace period an abandoned (no live websocket) session gets before it's torn down,
// letting a brief disconnect reconnect instead of losing the slot. Configurable via
// Core:reconnect_grace_period_ms; defaults to 30 seconds, unchanged from before this
// was configurable (the old comment here said "5 minutes", which was wrong - 30000 is
// milliseconds, i.e. 30 seconds).
// Grace period before an abandoned session is torn down. Core:reconnect_grace_period_ms, default 30s.
return Environment.TickCount64 - m_timeAbandoned >= UserSessionSettings.ReconnectGracePeriodMS;
}

Expand Down Expand Up @@ -3016,17 +3012,26 @@ public class WebSocketMessage_FullMeshConnectivityCheckResponseFromUser : WebSoc

// peers the member is still negotiating with; absent from older clients
public List<Int64> connecting_map { get; set; } = new();

// user IDs this member has exhausted its connection attempts against; absent/empty from older clients
public List<Int64> gave_up_user_ids { get; set; } = new();
}

public class WebSocketMessage_Social_NewFriendRequest : WebSocketMessage
{
public string display_name { get; set; } = String.Empty;
}

// FULL_MESH_CONNECTIVITY_CHECK_RESPONSE_COMPLETE_TO_HOST. Sent at most once per check, only to
// the current owner; a superseded or stale-requester check sends nothing (see
// Lobby.CompleteFullMeshConnectivityCheckLocked).
public class WebSocketMessage_FullMeshConnectivityCheckOutcome: WebSocketMessage
{
public bool mesh_complete { get; set; }
public List<MissingConnectionEntry> missing_connections { get; set; } = new();

// "" if mesh_complete; else one of missing_connections/timeout/membership_changed/gave_up (FullMeshCheckOutcomeReason).
public string reason { get; set; } = string.Empty;
}

public class WebSocketMessage_FullMeshConnectivityCheckOutcomeForHost : WebSocketMessage
Expand Down Expand Up @@ -3197,6 +3202,12 @@ public Int64 lobby_id
{
get; set;
}

// Same shape as GET lobby's "lobby" field. Optional; older clients ignore it.
public Lobby? lobby
{
get; set;
}
}

public class WebSocketMessage_MatchmakerStartGame : WebSocketMessage
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,8 +205,7 @@ public async Task<APIResult> Post_InternalHandler(string jsonData, string ipAddr
await Database.UserDevices.RegisterUserDevice(db, user_id, hwid_0, hwid_1, hwid_2, ipAddr);
}

string exe_crc = data.ContainsKey("exe_crc") ? data["exe_crc"].ToString() : "NONE";
Helpers.RegisterInitialPlayerExeCRC(user_id, exe_crc);
Helpers.RegisterInitialPlayerCRCsFromLoginPayload(user_id, data);

var sessiontoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Session, knownClientID, sessionType, bIsAdmin);
var refreshtoken = Program.g_tokenGenerator.GenerateToken(strDisplayName, user_id, ipAddr, Program.JwtTokenGenerator.ETokenType.Refresh, knownClientID, sessionType, false, out string refreshJti);
Expand Down
88 changes: 49 additions & 39 deletions GenOnlineService/Controllers/Lobby/LobbyController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -245,19 +245,23 @@ public async Task<APIResult> Delete(Int64 lobbyID)
}
}

Console.WriteLine("[Source 1] User {0} Leave Any Lobby", user_id);
await _lobbyManager.LeaveAnyLobby(user_id);
// only the named lobby; a request for any other is a no-op
if (lobby != null && lobby.GetMemberFromUserID(user_id) != null)
{
Console.WriteLine("[Source 1] User {0} Leave Lobby {1}", user_id, lobbyID);
await _lobbyManager.LeaveSpecificLobby(user_id, lobbyID);

// cleanup TURN credentials
TURNCredentialManager.DeleteCredentialsForUser(user_id);
// cleanup TURN credentials
TURNCredentialManager.DeleteCredentialsForUser(user_id);

// clear our lobby ID
UserSession? sourceData = WebSocketManager.GetSessionFromUser(user_id, sessionType);
// 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
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;
Expand Down Expand Up @@ -467,13 +471,8 @@ public async Task<APIResult> Post(Int64 lobbyID)
}
}

// 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.
// ApplyLobbyFieldUpdateAsync only validates a kick target; PerformKickAsync
// removes them after the gate releases (RemoveMember needs the gate too).
Int64? kickedUserID = await lobby.RunExclusiveAsync(() => ApplyLobbyFieldUpdateAsync(lobby, SourceMember, field, data));

if (kickedUserID.HasValue)
Expand All @@ -495,31 +494,32 @@ public async Task<APIResult> Post(Int64 lobbyID)
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;
return Enum.IsDefined(typeof(EPlayerType), slotState);
}

// 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).
// Rejects only when the joining session knows both its own game CRCs (non-zero) and at least one differs.
internal static bool ShouldRejectJoinForCrcMismatch(UInt32 sessionExeCrc, UInt32 sessionIniCrc, UInt32 lobbyExeCrc, UInt32 lobbyIniCrc)
{
bool bSessionCrcsKnown = sessionExeCrc != 0 && sessionIniCrc != 0;
if (!bSessionCrcsKnown)
{
return false;
}

return sessionExeCrc != lobbyExeCrc || sessionIniCrc != lobbyIniCrc;
}

// Must be called from inside lobby.RunExclusiveAsync. Returns the kicked user's ID for
// HOST_ACTION_KICK_USER, else null.
private async Task<Int64?> ApplyLobbyFieldUpdateAsync(Lobby lobby, LobbyMember SourceMember, ELobbyUpdateField field, Dictionary<string, JsonElement> data)
{
// reset everyones ready states when anything changes (minus dummy actions)
Expand Down Expand Up @@ -628,11 +628,8 @@ internal static bool TryParseLobbyUpdateField(int rawValue, out ELobbyUpdateFiel
{
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)
// Target must be in this lobby and not the host, otherwise a host could wipe an arbitrary player's TURN/session state.
if (KickedUserID != SourceMember.UserID && KickedUserID != lobby.Owner && lobby.GetMemberFromUserID(KickedUserID) != null)
{
return KickedUserID;
}
Expand Down Expand Up @@ -790,9 +787,7 @@ 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.
// Must be called after the gate has released, never from inside ApplyLobbyFieldUpdateAsync.
private async Task PerformKickAsync(Lobby lobby, Int64 kickedUserID)
{
// TODO: we should communicate the kick to the user...
Expand Down Expand Up @@ -850,7 +845,7 @@ public async Task<APIResult> Put(Int64 lobbyID)
if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, ESessionAccessType.Gameplay))
{
UInt16 userPreferredPort = data["preferred_port"].GetUInt16();
bool bHasMap = data["has_map"].GetBoolean();
bool bHasMap = data.ContainsKey("has_map") && data["has_map"].GetBoolean(); // missing means no map
EKnownAnticheatID anticheatID = (EKnownAnticheatID)data["anticheat_id"].GetInt32();

// does the lobby have a password?
Expand Down Expand Up @@ -894,6 +889,21 @@ public async Task<APIResult> Put(Int64 lobbyID)

if (playerSession != null)
{
if (ShouldRejectJoinForCrcMismatch(playerSession.ExeCRC, playerSession.IniCRC, lobby.ExeCRC, lobby.IniCRC))
{
Response.StatusCode = (int)HttpStatusCode.Conflict;
result.success = false;
return result;
}

// don't strand them out of their current lobby; AddMember stays authoritative
if (lobby.GetMemberFromUserID(user_id) == null && !lobby.HasOpenSlot())
{
Response.StatusCode = (int)HttpStatusCode.NotAcceptable;
result.success = false;
return result;
}

// leave any lobby
await _lobbyManager.LeaveAnyLobby(user_id);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,7 @@ public async Task<APIResult> Post_InternalHandler(string jsonData, string ipAddr
return result;
}

string exe_crc = data.ContainsKey("exe_crc") ? data["exe_crc"].ToString() : "NONE";
Helpers.RegisterInitialPlayerExeCRC(user_id, exe_crc);
Helpers.RegisterInitialPlayerCRCsFromLoginPayload(user_id, data);

string strDisplayName = await Database.Users.GetDisplayName(db, user_id);
await SessionHelpers.SetUsedLoggedIn(user_id, clientID, sessionType);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ public MatchmakingController(ILogger<MatchmakingController> logger)

[HttpPost("Widen")]
[Authorize(Roles = "GameClient")]
public void Put_Widen()
public async Task Put_Widen()
{
// TODO_QUICKMATCH: What if a user widens after already being matched? We should probably tell them no
// widen the search
Expand All @@ -111,7 +111,7 @@ public void Put_Widen()

if (playerSession != null)
{
MatchmakingManager.PlayerWidenSearch(playerSession);
await MatchmakingManager.PlayerWidenSearch(playerSession);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,13 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.WebSockets;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;

namespace GenOnlineService.Controllers
{
Expand All @@ -44,6 +47,25 @@ public ServiceConfigController()
{
string strFileData = await System.IO.File.ReadAllTextAsync(Path.Combine("data", "serviceconfig.json"));

JsonNode? configNode = JsonNode.Parse(strFileData);
if (configNode is JsonObject configObject)
{
// 0 = library default, 1 = native ICE, 2 = WebRTC
int iceImplementation = Program.g_Config?.GetSection("Core").GetValue("ice_implementation", 2) ?? 2;

if (Int64.TryParse(this.User.FindFirst(ClaimTypes.NameIdentifier)?.Value, out Int64 userId))
{
List<Int64> lstTesterIDs = Program.g_Config?.GetSection("Core").GetSection("ice_implementation_testers").Get<List<Int64>>() ?? new List<Int64>();
if (lstTesterIDs.Contains(userId))
{
iceImplementation = Program.g_Config?.GetSection("Core").GetValue("ice_implementation_testers_value", 1) ?? 1;
}
}

configObject["ice_implementation"] = iceImplementation;
strFileData = configObject.ToJsonString();
}

Response.StatusCode = (int)HttpStatusCode.OK;
return strFileData;
}
Expand Down Expand Up @@ -86,4 +108,4 @@ public AnticheatConfigController()
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -673,8 +673,6 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession
Lobby? lobby = _lobbyManager.GetLobby(sourceUserSession.currentLobbyID);
if (lobby != null)
{
// Ready state is part of the lobby's mutable state, so it goes through the
// same per-lobby gate as slot/member mutations.
await lobby.RunExclusiveAsync(() =>
{
LobbyMember? member = lobby.GetMemberFromUserID(sourceUserSession.m_UserID);
Expand Down Expand Up @@ -1034,9 +1032,7 @@ await lobby.RunExclusiveAsync(() =>
}

// 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();
await lobbyInfo.CloseOpenSlots(true);

// mark lobby as in progress of full mesh connectivity checks
await lobbyInfo.StartFullMeshConnectivityCheck();
Expand Down
Loading
Loading