From 0ee25cc24e12e74c1bb3561ef19353196c257925 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Sun, 27 Sep 2026 02:20:58 +0200 Subject: [PATCH 01/10] build(vcpkg): Build GameNetworkingSockets with WebRTC and native ICE from the fork's registry --- ports/gamenetworkingsockets/portfile.cmake | 56 ---------------------- ports/gamenetworkingsockets/vcpkg.json | 28 ----------- vcpkg.json | 12 ++++- 3 files changed, 10 insertions(+), 86 deletions(-) delete mode 100644 ports/gamenetworkingsockets/portfile.cmake delete mode 100644 ports/gamenetworkingsockets/vcpkg.json diff --git a/ports/gamenetworkingsockets/portfile.cmake b/ports/gamenetworkingsockets/portfile.cmake deleted file mode 100644 index 4060fe0c781..00000000000 --- a/ports/gamenetworkingsockets/portfile.cmake +++ /dev/null @@ -1,56 +0,0 @@ -# Upstream port, pointed at our GNS fork until the ICE fixes are merged upstream. -vcpkg_from_github( - OUT_SOURCE_PATH SOURCE_PATH - REPO tintinhamans/GNS - REF "641dfe7252b85d5827789304e494b1d135f58824" # gameclient-20260925 - SHA512 c41c929ec667e4be0c3982a72c33eeef3888922a9449dde3adc00b5f4b471c3ce334b1bc0dea7277b012ae28705e02f828e119111dfb184afae0217bb13a3140 - HEAD_REF gameclient -) - -vcpkg_check_features( - OUT_FEATURE_OPTIONS FEATURE_OPTIONS - FEATURES - ice ENABLE_ICE -) - -# Select static vs dynamic based on the triplet. -if("${VCPKG_LIBRARY_LINKAGE}" STREQUAL "dynamic") - set(BUILD_SHARED_LIB ON) - set(BUILD_STATIC_LIB OFF) -else() - set(BUILD_SHARED_LIB OFF) - set(BUILD_STATIC_LIB ON) -endif() - -# Link the MSVC CRT statically when the CRT linkage is static. -# Not used on non-MSVC platforms; listed in MAYBE_UNUSED_VARIABLES accordingly. -if("${VCPKG_CRT_LINKAGE}" STREQUAL "static") - set(MSVC_CRT_STATIC ON) -else() - set(MSVC_CRT_STATIC OFF) -endif() - -vcpkg_cmake_configure( - SOURCE_PATH "${SOURCE_PATH}" - OPTIONS - -DUSE_CRYPTO=OpenSSL - -DBUILD_STATIC_LIB=${BUILD_STATIC_LIB} - -DBUILD_SHARED_LIB=${BUILD_SHARED_LIB} - -DMSVC_CRT_STATIC=${MSVC_CRT_STATIC} - -DBUILD_TESTS=OFF - -DBUILD_EXAMPLES=OFF - -DBUILD_TOOLS=OFF - ${FEATURE_OPTIONS} - MAYBE_UNUSED_VARIABLES - MSVC_CRT_STATIC -) - -vcpkg_cmake_install() -vcpkg_cmake_config_fixup(CONFIG_PATH "lib/cmake/GameNetworkingSockets") -vcpkg_fixup_pkgconfig() - -file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/debug/share") -file(REMOVE_RECURSE "${CURRENT_PACKAGES_DIR}/debug/include") - -vcpkg_copy_pdbs() -vcpkg_install_copyright(FILE_LIST "${SOURCE_PATH}/LICENSE") diff --git a/ports/gamenetworkingsockets/vcpkg.json b/ports/gamenetworkingsockets/vcpkg.json deleted file mode 100644 index 5361f80798f..00000000000 --- a/ports/gamenetworkingsockets/vcpkg.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "gamenetworkingsockets", - "version": "1.6.0", - "description": "GameNetworkingSockets is a basic transport layer for games.", - "homepage": "https://github.com/ValveSoftware/GameNetworkingSockets", - "license": "BSD-3-Clause", - "supports": "!uwp & !(arm64 & windows)", - "dependencies": [ - "openssl", - "protobuf", - { - "name": "vcpkg-cmake", - "host": true - }, - { - "name": "vcpkg-cmake-config", - "host": true - } - ], - "default-features": [ - "ice" - ], - "features": { - "ice": { - "description": "Build support for P2P connections using the native ICE client (STUN and TURN)." - } - } -} diff --git a/vcpkg.json b/vcpkg.json index cf35cbc253e..6003937b674 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -2,7 +2,15 @@ "$schema": "https://raw.githubusercontent.com/microsoft/vcpkg-tool/main/docs/vcpkg.schema.json", "builtin-baseline": "b61d33065508ba0d5e393206abb3cb2366299699", "vcpkg-configuration": { - "overlay-ports": [ "./ports" ] + "registries": [ + { + "kind": "git", + "repository": "https://github.com/tintinhamans/GNS", + "reference": "vcpkg-registry", + "baseline": "e62d241df714253c66aaf900fed05f30c3fd433a", + "packages": [ "gamenetworkingsockets" ] + } + ] }, "dependencies": [ "zlib", @@ -17,7 +25,7 @@ "name": "gamenetworkingsockets", "version>=": "1.6.0", "default-features": false, - "features": [ "ice" ] + "features": [ "ice", "webrtc" ] }, { "name": "sentry-native", From dd1ef1c6207651d116b0624fe43a451d8f5bdf8a Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Sun, 27 Sep 2026 02:57:41 +0200 Subject: [PATCH 02/10] feat(network): Choose the ICE client from the service config --- .../GameNetwork/GeneralsOnline/OnlineServices_Init.h | 8 +++++--- .../Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp | 10 ++++++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h index 7f7472ee34a..8376ec5bbed 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h @@ -316,12 +316,14 @@ struct ServiceConfig int screenshot_width = 557; int screenshot_height = 333; - - NLOHMANN_DEFINE_TYPE_INTRUSIVE(ServiceConfig, retry_signalling, use_mapped_port, min_run_ahead_frames, ra_update_frequency_frames, relay_all_traffic, + // ICE client used for P2P NAT traversal: 0 = library default, 1 = native, 2 = WebRTC. + int ice_implementation = 2; + + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ServiceConfig, retry_signalling, use_mapped_port, min_run_ahead_frames, ra_update_frequency_frames, relay_all_traffic, ra_slack_percent, frame_grouping_frames, enable_host_migration, network_do_immediate_flush_per_frame, network_send_flags, network_latency_logic_model, use_default_config, ra_slack_override_percent_in_default, do_probes, do_replay_upload, network_mesh_histogram_duration, ibra_ra_tweaks, ibra_minslack_default, ibra_maxslack_default, ibra_minslack_greaterthan300ms, ibra_maxslack_greaterthan300ms, ibra_minslack_greaterthan200ms, ibra_maxslack_greaterthan200ms, - screenshot_width, screenshot_height) + screenshot_width, screenshot_height, ice_implementation) }; class NGMP_OnlineServicesManager diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp index c2a7549f2a4..0722e20e3e7 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp @@ -694,8 +694,14 @@ NetworkMesh::NetworkMesh() SteamNetworkingUtils()->SetGlobalConfigValueInt32(k_ESteamNetworkingConfig_P2P_Transport_ICE_Enable, k_nSteamNetworkingConfig_P2P_Transport_ICE_Enable_All); } - // The vcpkg GNS build only has the native ICE client (no WebRTC), so select it explicitly. - SteamNetworkingUtils()->SetGlobalConfigValueInt32(k_ESteamNetworkingConfig_P2P_Transport_ICE_Implementation, 1); + // Let the service config pick the ICE client: 0 = library default, 1 = native, 2 = WebRTC. + int iceImplementation = serviceConf.ice_implementation; + if (iceImplementation < 0 || iceImplementation > 2) + { + iceImplementation = 2; + } + NetworkLog(ELogVerbosity::LOG_RELEASE, "NetworkMesh: using ICE implementation %d (0=default, 1=native, 2=WebRTC)", iceImplementation); + SteamNetworkingUtils()->SetGlobalConfigValueInt32(k_ESteamNetworkingConfig_P2P_Transport_ICE_Implementation, iceImplementation); m_hListenSock = k_HSteamListenSocket_Invalid; From 2e04467af44d4e61ed554ee15691ff56dbe72341 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Sun, 27 Sep 2026 02:21:13 +0200 Subject: [PATCH 03/10] fix(mesh): Defer the cannot-connect handler until the mesh has finished ticking --- .../OnlineServices_LobbyInterface.h | 7 +++++++ .../GameNetwork/GeneralsOnline/NetworkMesh.cpp | 9 +++------ .../OnlineServices_LobbyInterface.cpp | 15 +++++++++++++++ 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h index 863e670ad5b..edb5d5c5490 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h @@ -397,6 +397,13 @@ class NGMP_OnlineServices_LobbyInterface m_OnCannotConnectToLobbyCallback = nullptr; } + // raised from inside the mesh's GNS callbacks, dispatched by Tick once the mesh is done with them + bool m_bCannotConnectToLobbyPending = false; + void QueueCannotConnectToLobby() + { + m_bCannotConnectToLobbyPending = true; + } + std::function m_OnChatCallback = nullptr; void RegisterForChatCallback(std::function cb) { diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp index 0722e20e3e7..c72b45aa024 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp @@ -167,12 +167,9 @@ void OnSteamNetConnectionStatusChanged(SteamNetConnectionStatusChangedCallback_t if (pLobbyInterface != nullptr) { NetworkLog(ELogVerbosity::LOG_RELEASE, "[STEAM NETWORKING][DISCONNECT HANDLER] Performing local removal for user %lld from lobby due to failure to connect\n", plrConnection.m_userID); - // Local copy to avoid TOCTOU race: check-then-use window - auto callbackCopy = pLobbyInterface->m_OnCannotConnectToLobbyCallback; - if (callbackCopy != nullptr) - { - callbackCopy(); - } + + // deferred: the handler leaves the lobby, which deletes this mesh while we're still inside its RunCallbacks + pLobbyInterface->QueueCannotConnectToLobby(); } } } diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp index a3593c06918..e7b9351ec10 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp @@ -730,6 +730,17 @@ void NGMP_OnlineServices_LobbyInterface::Tick() m_pLobbyMesh->Tick(); } + if (m_bCannotConnectToLobbyPending) + { + m_bCannotConnectToLobbyPending = false; + + auto callbackCopy = m_OnCannotConnectToLobbyCallback; + if (callbackCopy != nullptr) + { + callbackCopy(); + } + } + // TODO_NGMP: Do we still need this safety measure? if (IsInLobby()) { @@ -1271,6 +1282,8 @@ void NGMP_OnlineServices_LobbyInterface::JoinLobby(LobbyEntry lobbyInfo, std::st void NGMP_OnlineServices_LobbyInterface::LeaveCurrentLobby() { + m_bCannotConnectToLobbyPending = false; + // reset host migration flags ResetHostMigrationFlags(); @@ -1310,6 +1323,8 @@ void NGMP_OnlineServices_LobbyInterface::LeaveCurrentLobby() void NGMP_OnlineServices_LobbyInterface::ResetForMatchmakingRequeue() { + m_bCannotConnectToLobbyPending = false; + // The service has already removed us from the failed temporary lobby. Tear down only // local state here; sending the normal DELETE would cancel the server-side requeue. ++m_LobbyJoinGeneration; From d60a850c99b467fff59d4a6f389a4fd124d595d0 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Sun, 27 Sep 2026 02:21:13 +0200 Subject: [PATCH 04/10] fix(mesh): Only the later joiner gives up on a connection it can't make --- .../OnlineServices_LobbyInterface.h | 32 +++++++++++++ .../GeneralsOnline/NetworkMesh.cpp | 19 ++++++-- .../OnlineServices_LobbyInterface.cpp | 47 +++++++++++++++++++ 3 files changed, 95 insertions(+), 3 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h index edb5d5c5490..98974b8391a 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h @@ -9,6 +9,7 @@ #include "GameClient/InGameUI.h" #include "GameLogic/VictoryConditions.h" #include +#include extern NGMPGame* TheNGMPGame; @@ -373,6 +374,14 @@ class NGMP_OnlineServices_LobbyInterface bool IsHost(); +private: + std::set m_setMembersBeforeUs; + bool m_bJoinOrderKnown = false; + void ResetJoinOrder(); + void RecordJoinOrder(const std::vector& members); + +public: + void UpdateRoomDataCache(std::function fnCallback = nullptr); std::function m_cbPlayerDoesntHaveMap = nullptr; @@ -397,6 +406,29 @@ class NGMP_OnlineServices_LobbyInterface m_OnCannotConnectToLobbyCallback = nullptr; } + // true if userID was already in the lobby when we joined, i.e. we are the later joiner of that pair + bool JoinedAfter(int64_t userID) const + { + return m_bJoinOrderKnown && m_setMembersBeforeUs.contains(userID); + } + + bool IsJoinOrderKnown() const + { + return m_bJoinOrderKnown; + } + + bool IsLobbyMember(int64_t userID) const + { + for (const LobbyMemberEntry& member : m_CurrentLobby.members) + { + if (member.user_id == userID) + { + return true; + } + } + return false; + } + // raised from inside the mesh's GNS callbacks, dispatched by Tick once the mesh is done with them bool m_bCannotConnectToLobbyPending = false; void QueueCannotConnectToLobby() diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp index c72b45aa024..99f2a1f8879 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp @@ -111,8 +111,13 @@ void OnSteamNetConnectionStatusChanged(SteamNetConnectionStatusChangedCallback_t NetworkLog(ELogVerbosity::LOG_RELEASE, "[DC] Closing connection %lld", plrConnection.m_userID); ServiceConfig& serviceConf = NGMP_OnlineServicesManager::GetInstance()->GetServiceConfig(); - const int numSignallingAttempts = 3; - bool bShouldRetry = plrConnection.m_SignallingAttempts < numSignallingAttempts && serviceConf.retry_signalling; + const int numSignallingAttempts = 2; + + // only the later joiner of a pair gives up; unknown join order caps both sides, a departed peer is capped without leaving + NGMP_OnlineServices_LobbyInterface* pJoinOrderLobby = NGMP_OnlineServicesManager::GetInterface(); + const bool bWeJoinedLater = pJoinOrderLobby == nullptr || !pJoinOrderLobby->IsJoinOrderKnown() || pJoinOrderLobby->JoinedAfter(plrConnection.m_userID); + const bool bPeerLeft = pJoinOrderLobby != nullptr && pJoinOrderLobby->IsJoinOrderKnown() && !pJoinOrderLobby->IsLobbyMember(plrConnection.m_userID); + bool bShouldRetry = serviceConf.retry_signalling && ((!bWeJoinedLater && !bPeerLeft) || plrConnection.m_SignallingAttempts < numSignallingAttempts); bool bWasError = pInfo->m_info.m_eState == k_ESteamNetworkingConnectionState_ProblemDetectedLocally || pInfo->m_info.m_eEndReason != k_ESteamNetConnectionEnd_App_Generic; plrConnection.SetDisconnected(bWasError, pMesh, bShouldRetry && bWasError); @@ -159,7 +164,15 @@ void OnSteamNetConnectionStatusChanged(SteamNetConnectionStatusChangedCallback_t } } - if (!bShouldRetry) + if (!bShouldRetry && bPeerLeft) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[STEAM NETWORKING][DISCONNECT HANDLER] Not retrying, user %lld is no longer in the lobby", plrConnection.m_userID); + } + else if (!bShouldRetry && !bWeJoinedLater) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[STEAM NETWORKING][DISCONNECT HANDLER] Not retrying, user %lld joined after us and will leave", plrConnection.m_userID); + } + else if (!bShouldRetry) { NetworkLog(ELogVerbosity::LOG_RELEASE, "[STEAM NETWORKING][DISCONNECT HANDLER] Not retrying, handling disconnect as failure..."); diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp index e7b9351ec10..e66c1d0e844 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp @@ -669,6 +669,47 @@ void NGMP_OnlineServices_LobbyInterface::SearchForLobbies(std::function }); } +void NGMP_OnlineServices_LobbyInterface::ResetJoinOrder() +{ + m_setMembersBeforeUs.clear(); + m_bJoinOrderKnown = false; +} + +// the first member list after joining holds everyone who was there before us +void NGMP_OnlineServices_LobbyInterface::RecordJoinOrder(const std::vector& members) +{ + if (m_bJoinOrderKnown) + { + return; + } + + NGMP_OnlineServices_AuthInterface* pAuthInterface = NGMP_OnlineServicesManager::GetInterface(); + int64_t myUserID = pAuthInterface == nullptr ? -1 : pAuthInterface->GetUserID(); + + // only a list from after our join counts + std::set setOthers; + bool bContainsUs = false; + for (const LobbyMemberEntry& member : members) + { + if (member.user_id == myUserID) + { + bContainsUs = true; + } + else + { + setOthers.insert(member.user_id); + } + } + + if (!bContainsUs) + { + return; + } + + m_setMembersBeforeUs = std::move(setOthers); + m_bJoinOrderKnown = true; +} + bool NGMP_OnlineServices_LobbyInterface::IsHost() { if (IsInLobby()) @@ -1034,6 +1075,7 @@ void NGMP_OnlineServices_LobbyInterface::UpdateRoomDataCache(std::functionGetAndParseServiceConfig([=]() { @@ -1283,6 +1326,7 @@ void NGMP_OnlineServices_LobbyInterface::JoinLobby(LobbyEntry lobbyInfo, std::st void NGMP_OnlineServices_LobbyInterface::LeaveCurrentLobby() { m_bCannotConnectToLobbyPending = false; + ResetJoinOrder(); // reset host migration flags ResetHostMigrationFlags(); @@ -1324,6 +1368,7 @@ void NGMP_OnlineServices_LobbyInterface::LeaveCurrentLobby() void NGMP_OnlineServices_LobbyInterface::ResetForMatchmakingRequeue() { m_bCannotConnectToLobbyPending = false; + ResetJoinOrder(); // The service has already removed us from the failed temporary lobby. Tear down only // local state here; sending the normal DELETE would cancel the server-side requeue. @@ -1393,6 +1438,8 @@ void NGMP_OnlineServices_LobbyInterface::CreateLobby(UnicodeString strLobbyName, NGMP_OnlineServicesManager::GetInstance()->GetAndParseServiceConfig([=]() { m_CurrentLobby = LobbyEntry(); + ResetJoinOrder(); + m_bJoinOrderKnown = true; std::string strURI = NGMP_OnlineServicesManager::GetAPIEndpoint("Lobbies"); std::map mapHeaders; From 8cbfd5f8972127285b4858e593d5fa03a3f8b48c Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Sun, 27 Sep 2026 02:21:13 +0200 Subject: [PATCH 05/10] fix(mesh): Fail cleanly on mesh init errors and disconnects --- .../GameNetwork/GeneralsOnline/NetworkMesh.h | 5 ++ .../GeneralsOnline/NetworkMesh.cpp | 50 +++++++++++++------ .../OnlineServices_LobbyInterface.cpp | 32 +++++++++++- 3 files changed, 70 insertions(+), 17 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h index 7fecf72ef56..402b90b4ebd 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h @@ -203,6 +203,9 @@ class NetworkMesh void Tick(); + // false if construction failed; callers must not use a mesh that failed to initialize. + bool IsInitialized() const { return m_bInitialized; } + HSteamListenSocket GetListenSocketHandle() const { return m_hListenSock; } std::map& GetAllConnections() @@ -234,6 +237,8 @@ class NetworkMesh private: + bool m_bInitialized = false; + std::map m_mapConnections; mutable std::recursive_mutex m_mapConnectionsMutex; // Synchronizes access to m_mapConnections diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp index 99f2a1f8879..914ba8b25c4 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp @@ -44,12 +44,13 @@ static void CleanupPendingConnSignalingDeletions() } } -// Called when a connection undergoes a state transition +// Called on connection state transitions. Always runs on the main thread via RunCallbacks(), so +// m_mapConnections is accessed here without m_mapConnectionsMutex. void OnSteamNetConnectionStatusChanged(SteamNetConnectionStatusChangedCallback_t* pInfo) { // Clean up any pending ConnectionSignaling deletions from previous callbacks CleanupPendingConnSignalingDeletions(); - + // Early exit if NetworkMesh is being destroyed to prevent use-after-free if (g_bNetworkMeshDestroying.load()) { @@ -103,31 +104,36 @@ void OnSteamNetConnectionStatusChanged(SteamNetConnectionStatusChangedCallback_t { PlayerConnection& plrConnection = connections[connectionID]; + // Capture before SetDisconnected(), which can erase this entry via UpdateState(). + const int64_t userID = plrConnection.m_userID; + const int signallingAttemptsBeforeDisconnect = plrConnection.m_SignallingAttempts; + if (TheNetwork != nullptr) { - TheNetwork->GetConnectionManager()->disconnectPlayer(plrConnection.m_userID); + TheNetwork->GetConnectionManager()->disconnectPlayer(userID); } - NetworkLog(ELogVerbosity::LOG_RELEASE, "[DC] Closing connection %lld", plrConnection.m_userID); + NetworkLog(ELogVerbosity::LOG_RELEASE, "[DC] Closing connection %lld", userID); ServiceConfig& serviceConf = NGMP_OnlineServicesManager::GetInstance()->GetServiceConfig(); const int numSignallingAttempts = 2; // only the later joiner of a pair gives up; unknown join order caps both sides, a departed peer is capped without leaving NGMP_OnlineServices_LobbyInterface* pJoinOrderLobby = NGMP_OnlineServicesManager::GetInterface(); - const bool bWeJoinedLater = pJoinOrderLobby == nullptr || !pJoinOrderLobby->IsJoinOrderKnown() || pJoinOrderLobby->JoinedAfter(plrConnection.m_userID); - const bool bPeerLeft = pJoinOrderLobby != nullptr && pJoinOrderLobby->IsJoinOrderKnown() && !pJoinOrderLobby->IsLobbyMember(plrConnection.m_userID); - bool bShouldRetry = serviceConf.retry_signalling && ((!bWeJoinedLater && !bPeerLeft) || plrConnection.m_SignallingAttempts < numSignallingAttempts); + const bool bWeJoinedLater = pJoinOrderLobby == nullptr || !pJoinOrderLobby->IsJoinOrderKnown() || pJoinOrderLobby->JoinedAfter(userID); + const bool bPeerLeft = pJoinOrderLobby != nullptr && pJoinOrderLobby->IsJoinOrderKnown() && !pJoinOrderLobby->IsLobbyMember(userID); + bool bShouldRetry = serviceConf.retry_signalling && ((!bWeJoinedLater && !bPeerLeft) || signallingAttemptsBeforeDisconnect < numSignallingAttempts); bool bWasError = pInfo->m_info.m_eState == k_ESteamNetworkingConnectionState_ProblemDetectedLocally || pInfo->m_info.m_eEndReason != k_ESteamNetConnectionEnd_App_Generic; plrConnection.SetDisconnected(bWasError, pMesh, bShouldRetry && bWasError); - + // plrConnection may be dangling past this point; use the captured locals. + // the highest slot player, should leave. In most cases, this is the most recently joined player, but this may not be 100% accurate due to backfills. // TODO_NGMP: In the future, we should pick the most recently joined by timestamp if (bWasError) // only if it wasn't a clean disconnect (e.g. lobby leave) { - NetworkLog(ELogVerbosity::LOG_RELEASE, "[STEAM NETWORKING][DISCONNECT HANDLER] Determined we didn't connect due to an error, Retrying: %d (currently at %d/%d attempts)", bShouldRetry, plrConnection.m_SignallingAttempts, numSignallingAttempts); - + NetworkLog(ELogVerbosity::LOG_RELEASE, "[STEAM NETWORKING][DISCONNECT HANDLER] Determined we didn't connect due to an error, Retrying: %d (currently at %d/%d attempts)", bShouldRetry, signallingAttemptsBeforeDisconnect, numSignallingAttempts); + // should we retry signaling? if (bShouldRetry) { @@ -144,11 +150,11 @@ void OnSteamNetConnectionStatusChanged(SteamNetConnectionStatusChangedCallback_t // Behavior: // disconnected slot userID is higher than ours, do nothing, they will signal // disconnected slot userID is lower than ours, we signal - if ((myUserID > plrConnection.m_userID)) + if ((myUserID > userID)) { NetworkLog(ELogVerbosity::LOG_RELEASE, "[STEAM NETWORKING][DISCONNECT HANDLER] Send signal start request..."); - pWS->SendData_RequestSignalling(plrConnection.m_userID); + pWS->SendData_RequestSignalling(userID); } else { @@ -166,11 +172,11 @@ void OnSteamNetConnectionStatusChanged(SteamNetConnectionStatusChangedCallback_t if (!bShouldRetry && bPeerLeft) { - NetworkLog(ELogVerbosity::LOG_RELEASE, "[STEAM NETWORKING][DISCONNECT HANDLER] Not retrying, user %lld is no longer in the lobby", plrConnection.m_userID); + NetworkLog(ELogVerbosity::LOG_RELEASE, "[STEAM NETWORKING][DISCONNECT HANDLER] Not retrying, user %lld is no longer in the lobby", userID); } else if (!bShouldRetry && !bWeJoinedLater) { - NetworkLog(ELogVerbosity::LOG_RELEASE, "[STEAM NETWORKING][DISCONNECT HANDLER] Not retrying, user %lld joined after us and will leave", plrConnection.m_userID); + NetworkLog(ELogVerbosity::LOG_RELEASE, "[STEAM NETWORKING][DISCONNECT HANDLER] Not retrying, user %lld joined after us and will leave", userID); } else if (!bShouldRetry) { @@ -179,7 +185,7 @@ void OnSteamNetConnectionStatusChanged(SteamNetConnectionStatusChangedCallback_t NGMP_OnlineServices_LobbyInterface* pLobbyInterface = NGMP_OnlineServicesManager::GetInterface(); if (pLobbyInterface != nullptr) { - NetworkLog(ELogVerbosity::LOG_RELEASE, "[STEAM NETWORKING][DISCONNECT HANDLER] Performing local removal for user %lld from lobby due to failure to connect\n", plrConnection.m_userID); + NetworkLog(ELogVerbosity::LOG_RELEASE, "[STEAM NETWORKING][DISCONNECT HANDLER] Performing local removal for user %lld from lobby due to failure to connect\n", userID); // deferred: the handler leaves the lobby, which deletes this mesh while we're still inside its RunCallbacks pLobbyInterface->QueueCannotConnectToLobby(); @@ -628,6 +634,7 @@ NetworkMesh::NetworkMesh() if (pOnlineServicesMgr == nullptr) { NetworkLog(ELogVerbosity::LOG_RELEASE, "pOnlineServicesMgr is invalid"); + g_bNetworkMeshDestroying.store(false); return; } @@ -635,6 +642,7 @@ NetworkMesh::NetworkMesh() if (pAuthInterface == nullptr) { NetworkLog(ELogVerbosity::LOG_RELEASE, "pAuthInterface is invalid"); + g_bNetworkMeshDestroying.store(false); return; } @@ -642,6 +650,7 @@ NetworkMesh::NetworkMesh() if (pLobbyInterface == nullptr) { NetworkLog(ELogVerbosity::LOG_RELEASE, "pLobbyInterface is invalid"); + g_bNetworkMeshDestroying.store(false); return; } @@ -656,6 +665,7 @@ NetworkMesh::NetworkMesh() if (identityLocal.IsInvalid()) { NetworkLog(ELogVerbosity::LOG_RELEASE, "SteamNetworkingIdentity is invalid"); + g_bNetworkMeshDestroying.store(false); return; } @@ -664,6 +674,7 @@ NetworkMesh::NetworkMesh() if (!GameNetworkingSockets_Init(&identityLocal, errMsg)) { NetworkLog(ELogVerbosity::LOG_RELEASE, "GameNetworkingSockets_Init failed. %s", errMsg); + g_bNetworkMeshDestroying.store(false); return; } @@ -720,6 +731,7 @@ NetworkMesh::NetworkMesh() if (m_pSignaling == nullptr) { NetworkLog(ELogVerbosity::LOG_RELEASE, "CreateTrivialSignalingClient failed. %s", errMsg); + g_bNetworkMeshDestroying.store(false); return; } @@ -730,6 +742,8 @@ NetworkMesh::NetworkMesh() // it is now safe to allow OnSteamNetConnectionStatusChanged to run. g_bNetworkMeshDestroying.store(false); + m_bInitialized = true; + ESteamNetworkingSocketsDebugOutputType logType = #if defined(_DEBUG) ESteamNetworkingSocketsDebugOutputType::k_ESteamNetworkingSocketsDebugOutputType_Debug @@ -950,6 +964,12 @@ void NetworkMesh::StartConnectionSignalling(const char* szMiddlewareID, int64_t NetworkLog(ELogVerbosity::LOG_DEBUG, "Connecting to '%s' in symmetric mode, virtual port %d, from local virtual port %d.\n", SteamNetworkingIdentityRender(identityRemote).c_str(), g_nVirtualPortRemote, g_nLocalPort); + if (m_pSignaling == nullptr) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "NetworkMesh::StartConnectionSignalling - Signalling client is null (mesh failed to initialize)"); + return; + } + // create a signaling object for this connection SteamNetworkingErrMsg errMsg; ISteamNetworkingConnectionSignaling* pConnSignaling = m_pSignaling->CreateSignalingForConnection(identityRemote, errMsg); diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp index e66c1d0e844..689f43a61b2 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp @@ -1173,7 +1173,20 @@ void NGMP_OnlineServices_LobbyInterface::JoinLobby(LobbyEntry lobbyInfo, std::st // create our mesh if (m_pLobbyMesh == nullptr) { - m_pLobbyMesh = new NetworkMesh(); + NetworkMesh* pNewMesh = new NetworkMesh(); + if (!pNewMesh->IsInitialized()) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] Failed to initialize network mesh, aborting lobby join"); + delete pNewMesh; + ResetLobbyTryingToJoin(); + m_bAttemptingToJoinLobby = false; + if (m_callbackJoinedLobby != nullptr) + { + m_callbackJoinedLobby(EJoinLobbyResult::JoinLobbyResult_JoinFailed); + } + return; + } + m_pLobbyMesh = pNewMesh; } // convert @@ -1593,7 +1606,22 @@ void NGMP_OnlineServices_LobbyInterface::OnJoinedOrCreatedLobby(bool bAlreadyUpd // join the network mesh too if (m_pLobbyMesh == nullptr) { - m_pLobbyMesh = new NetworkMesh(); + NetworkMesh* pNewMesh = new NetworkMesh(); + if (!pNewMesh->IsInitialized()) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] Failed to initialize network mesh for lobby"); + delete pNewMesh; + m_bAttemptingToJoinLobby = false; + + QueueCannotConnectToLobby(); + + if (fnCallback != nullptr) + { + fnCallback(false); + } + return; + } + m_pLobbyMesh = pNewMesh; } m_bMarkedGameAsFinished = false; From 2558f82dc1a13d1211e913ad2cbffc970c0b5f7a Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Sun, 27 Sep 2026 02:21:13 +0200 Subject: [PATCH 06/10] fix(websocket): Stop losing messages and session state around reconnects --- .../GeneralsOnline/OnlineServices_Init.h | 50 ++++-- .../GUICallbacks/Menus/WOLGameSetupMenu.cpp | 16 +- .../GeneralsOnline/NetworkMesh.cpp | 58 +++---- .../GeneralsOnline/OnlineServices_Init.cpp | 15 +- .../OnlineServices_LobbyInterface.cpp | 8 + .../OnlineServices_RoomsInterface.cpp | 155 ++++++++++++------ 6 files changed, 207 insertions(+), 95 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h index 8376ec5bbed..796f0faffe2 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h @@ -151,8 +151,6 @@ class WebSocket std::vector m_vecWSPartialBuffer; - std::vector m_vecQueuedOutboungMsgs; - std::function m_fnWebsocketConnectedCallback = nullptr; void Shutdown(); @@ -180,26 +178,49 @@ class WebSocket void SendData_CountdownStarted(); - std::function>)> m_cbOnConnectivityCheckComplete = nullptr; - void SendData_StartFullMeshConnectivityCheck(std::function>)> cbOnConnectivityCheckComplete); + // params: fully connected, missing links, reason ("" on success, else e.g. "missing_connections", + // "timeout", "member_left", "check_superseded") + std::function>, std::string)> m_cbOnConnectivityCheckComplete = nullptr; + + // lobby ID the check was started for; a stale reply (different lobby) is ignored + int64_t m_connectivityCheckLobbyID = -1; + + void SendData_StartFullMeshConnectivityCheck(std::function>, std::string)> cbOnConnectivityCheckComplete); + + void ClearConnectivityCheckCallback() + { + m_cbOnConnectivityCheckComplete = nullptr; + m_connectivityCheckLobbyID = -1; + } void Tick(); int Ping(); + // Queues the message; Tick() flushes it on the main thread. Thread-safe. void Send(const char* message); - // TODO_STEAM: clear this on connect - std::queue> m_pendingSignals; - - bool AcquireLock() + // Thread-safe queue of inbound P2P signal payloads; drained by CSignalingClient::Poll(). + void PushPendingSignal(std::vector payload) { - return m_mutex.try_lock_for(std::chrono::milliseconds(1)); + std::scoped_lock lock(m_pendingSignalsMutex); + + static constexpr size_t kMaxPendingSignals = 256; + if (m_pendingSignals.size() >= kMaxPendingSignals) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[WebSocket] Pending signal queue full (%zu), discarding oldest signal", m_pendingSignals.size()); + m_pendingSignals.pop(); + } + + m_pendingSignals.push(std::move(payload)); } - void ReleaseLock() + std::queue> DrainPendingSignals() { - m_mutex.unlock(); + std::scoped_lock lock(m_pendingSignalsMutex); + std::queue> drained; + drained.swap(m_pendingSignals); + return drained; } private: @@ -236,7 +257,12 @@ class WebSocket std::atomic m_bShuttingDown = false; - std::recursive_timed_mutex m_mutex; + // Outbound messages queued for Tick() to flush. + std::mutex m_outboundQueueMutex; + std::vector m_vecQueuedOutboungMsgs; + + std::mutex m_pendingSignalsMutex; + std::queue> m_pendingSignals; }; enum class ERoomFlags : int diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp index 35df8db3636..8b3afa44afe 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp @@ -1140,7 +1140,7 @@ static void StartPressed() buttonStart->winEnable(FALSE); } - pWS->SendData_StartFullMeshConnectivityCheck([=](bool bMeshFullyConnected, std::list> missingConnections) + pWS->SendData_StartFullMeshConnectivityCheck([=](bool bMeshFullyConnected, std::list> missingConnections, std::string strFailureReason) { if (bMeshFullyConnected) { @@ -1190,6 +1190,13 @@ static void StartPressed() UnicodeString strInform = UnicodeString(L"Connections: The player network is not ready. Try again shortly."); GadgetListBoxAddEntryText(listboxGameSetupChat, strInform, GameMakeColor(255, 194, 15, 255), -1, -1); + if (!strFailureReason.empty()) + { + UnicodeString strReasonLine; + strReasonLine.format(L"Connections: Reason: %s", from_utf8(strFailureReason).c_str()); + GadgetListBoxAddEntryText(listboxGameSetupChat, strReasonLine, GameMakeColor(255, 194, 15, 255), -1, -1); + } + NGMP_OnlineServices_LobbyInterface* pLobbyInterface = NGMP_OnlineServicesManager::GetInterface(); // who is missing who? @@ -2377,6 +2384,13 @@ void WOLGameSetupMenuShutdown( WindowLayout *layout, void *userData ) pMesh->DeregisterForConnectionEvents(); } + // drop any in-flight mesh connectivity check so a late reply never fires into this now-dead menu + std::shared_ptr pWS = NGMP_OnlineServicesManager::GetWebSocket(); + if (pWS != nullptr) + { + pWS->ClearConnectivityCheckCallback(); + } + //TheGameSpyInfo->unregisterTextWindow(listboxGameSetupChat); if( WOLMapSelectLayout ) diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp index 914ba8b25c4..23e29e8a343 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp @@ -423,10 +423,14 @@ class CSignalingClient : public ISignalingClient std::vector vecPayload; }; ISteamNetworkingSockets* const m_pSteamNetworkingSockets; + + // Guards m_queueSend; SendSignal() may run on any thread, Poll() drains on the main thread. + std::mutex m_sendQueueMutex; std::deque m_queueSend; void CloseSocket() { + std::scoped_lock lock(m_sendQueueMutex); m_queueSend.clear(); } @@ -450,34 +454,22 @@ class CSignalingClient : public ISignalingClient } - // Send the signal. + // May be called from any thread; always queues, Poll() flushes on the main thread. void Send(int64_t target_user_id, std::vector& vecPayload) { - std::shared_ptr pWS = NGMP_OnlineServicesManager::GetWebSocket(); - if (pWS) - { - if (!pWS->AcquireLock()) - { - return; - } + std::scoped_lock lock(m_sendQueueMutex); - // If we're getting backed up, delete the oldest entries. Remember, - // we are only required to do best-effort delivery. And old signals are the - // most likely to be out of date (either old data, or the client has already - // timed them out and queued a retry). - while (m_queueSend.size() > 128) - { - NetworkLog(ELogVerbosity::LOG_RELEASE, "Signaling send queue is backed up. Discarding oldest signals\n"); - m_queueSend.pop_front(); - } - - QueuedSend newEntry = QueuedSend(); - newEntry.target_user_id = target_user_id; - newEntry.vecPayload = vecPayload; - m_queueSend.push_back(newEntry); - - pWS->ReleaseLock(); + // Best-effort delivery; drop oldest on backlog. + while (m_queueSend.size() > 128) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "Signaling send queue is backed up. Discarding oldest signals\n"); + m_queueSend.pop_front(); } + + QueuedSend newEntry = QueuedSend(); + newEntry.target_user_id = target_user_id; + newEntry.vecPayload = vecPayload; + m_queueSend.push_back(newEntry); } ISteamNetworkingConnectionSignaling* CreateSignalingForConnection( @@ -512,25 +504,21 @@ class CSignalingClient : public ISignalingClient std::shared_ptr pWS = NGMP_OnlineServicesManager::GetWebSocket(); if (pWS) { - if (!pWS->AcquireLock()) + std::deque sendBatch; { - return; + std::scoped_lock lock(m_sendQueueMutex); + sendBatch.swap(m_queueSend); } - // Drain the socket - // Flush send queue - while (!m_queueSend.empty()) + while (!sendBatch.empty()) { - QueuedSend sendData = m_queueSend.front(); + QueuedSend sendData = sendBatch.front(); pWS->SendData_Signalling(sendData.target_user_id, sendData.vecPayload); - m_queueSend.pop_front(); + sendBatch.pop_front(); } - // TODO_NGMP: Avoid copy - std::queue> pendingSignals = pWS->m_pendingSignals; - pWS->m_pendingSignals = std::queue>(); - pWS->ReleaseLock(); + std::queue> pendingSignals = pWS->DrainPendingSignals(); // Now dispatch any buffered signals if (!pendingSignals.empty()) diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp index 075ca49f972..31c1525a578 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp @@ -825,6 +825,16 @@ void NGMP_OnlineServicesManager::OnLogin(ELoginResult loginResult, const char* s { if (loginResult == ELoginResult::Success) { + // Tear down any previous session's websocket before replacing it. + if (m_pWebSocket != nullptr) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] OnLogin: shutting down previous websocket before re-login"); + m_pWebSocket->Shutdown(); + m_pWebSocket->m_fnWebsocketConnectedCallback = nullptr; + m_pWebSocket->ClearConnectivityCheckCallback(); + m_pWebSocket.reset(); + } + // connect to WS m_pWebSocket = std::make_shared(); @@ -1290,10 +1300,13 @@ void WebSocket::SendData_CountdownStarted() } -void WebSocket::SendData_StartFullMeshConnectivityCheck(std::function>)> cbOnConnectivityCheckComplete) +void WebSocket::SendData_StartFullMeshConnectivityCheck(std::function>, std::string)> cbOnConnectivityCheckComplete) { m_cbOnConnectivityCheckComplete = cbOnConnectivityCheckComplete; + NGMP_OnlineServices_LobbyInterface* pLobbyInterface = NGMP_OnlineServicesManager::GetInterface(); + m_connectivityCheckLobbyID = pLobbyInterface != nullptr ? pLobbyInterface->GetCurrentLobby().lobbyID : -1; + nlohmann::json j; j["msg_id"] = EWebSocketMessageID::FULL_MESH_CONNECTIVITY_CHECK_HOST_REQUESTS_BEGIN; std::string strBody = j.dump(); diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp index 689f43a61b2..1c2a269a58d 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp @@ -1341,6 +1341,14 @@ void NGMP_OnlineServices_LobbyInterface::LeaveCurrentLobby() m_bCannotConnectToLobbyPending = false; ResetJoinOrder(); + // a mesh connectivity check started for this lobby is meaningless once we've left it; drop it so a + // late reply never fires into whatever lobby/menu we end up in next + std::shared_ptr pWS = NGMP_OnlineServicesManager::GetWebSocket(); + if (pWS != nullptr) + { + pWS->ClearConnectivityCheckCallback(); + } + // reset host migration flags ResetHostMigrationFlags(); diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp index 365791eb711..99ef02b8882 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp @@ -282,6 +282,23 @@ void WebSocket::Disconnect() if (m_pCurlWS != nullptr) { + // best-effort flush of anything queued since the last Tick() before closing + std::vector outboundBatch; + { + std::scoped_lock lock(m_outboundQueueMutex); + outboundBatch.swap(m_vecQueuedOutboungMsgs); + } + + for (std::string& strPayload : outboundBatch) + { + size_t sentPayload; + CURLcode sendResult = curl_ws_send(m_pCurlWS, strPayload.c_str(), strPayload.length(), &sentPayload, 0, CURLWS_BINARY); + if (sendResult != CURLE_OK) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[WebSocket] Disconnect: failed to flush queued message: %s", curl_easy_strerror(sendResult)); + } + } + // send close size_t sent; (void)curl_ws_send(m_pCurlWS, "", 0, &sent, 0, CURLWS_CLOSE); @@ -312,29 +329,17 @@ void WebSocket::Disconnect() void WebSocket::Send(const char* send_payload) { - if (!AcquireLock()) - { - return; - } + // Thread-safe; always queues. Tick() flushes on the main thread. + std::scoped_lock lock(m_outboundQueueMutex); - if (!m_bConnected) + static constexpr size_t kMaxQueuedOutboundMsgs = 256; + if (m_vecQueuedOutboungMsgs.size() >= kMaxQueuedOutboundMsgs) { - // just queue it instead - m_vecQueuedOutboungMsgs.push_back(std::string(send_payload)); - - ReleaseLock(); - return; + NetworkLog(ELogVerbosity::LOG_RELEASE, "[WebSocket] Outbound queue full (%zu), discarding oldest message", m_vecQueuedOutboungMsgs.size()); + m_vecQueuedOutboungMsgs.erase(m_vecQueuedOutboungMsgs.begin()); } - size_t sent; - CURLcode result = curl_ws_send(m_pCurlWS, send_payload, strlen(send_payload), &sent, 0, CURLWS_BINARY); - - if (result != CURLE_OK) - { - NetworkLog(ELogVerbosity::LOG_RELEASE, "curl_ws_send() failed: %s\n", curl_easy_strerror(result)); - } - - ReleaseLock(); + m_vecQueuedOutboungMsgs.push_back(std::string(send_payload)); } class WebSocketMessageBase @@ -636,12 +641,55 @@ static void RestoreSessionState() pSocial->RegisterForRealtimeServiceUpdates(); } - // the new session starts outside any network room, so rejoin the one the lobby menu still shows + // resolve the previous room by stable ID against a freshly fetched room list, not by index NGMP_OnlineServices_RoomsInterface* pRooms = NGMP_OnlineServicesManager::GetInterface(); - if (pRooms != nullptr && pRooms->GetCurrentRoomIndex() >= 0) + if (pRooms == nullptr) + { + return; + } + + const std::vector& roomsBeforeRefresh = pRooms->GetGroupRooms(); + const int previousRoomIndex = pRooms->GetCurrentRoomIndex(); + std::optional previousRoomID; + UnicodeString strPreviousRoomName; + if (previousRoomIndex >= 0 && previousRoomIndex < (int)roomsBeforeRefresh.size()) { - pRooms->JoinRoom(pRooms->GetCurrentRoomIndex()); + previousRoomID = roomsBeforeRefresh[previousRoomIndex].GetRoomID(); + strPreviousRoomName = roomsBeforeRefresh[previousRoomIndex].GetRoomDisplayName(); } + + pRooms->GetRoomList([pRooms, previousRoomID, strPreviousRoomName](bool bSuccess) + { + const std::vector& rooms = pRooms->GetGroupRooms(); + if (!bSuccess || rooms.empty()) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] RestoreSessionState: failed to fetch room list, cannot rejoin a room"); + return; + } + + // fall back to the default room (index 0) if the room we were in no longer exists + int roomIndexToJoin = 0; + bool bFoundPreviousRoom = false; + if (previousRoomID.has_value()) + { + for (size_t i = 0; i < rooms.size(); ++i) + { + if (rooms[i].GetRoomID() == *previousRoomID) + { + roomIndexToJoin = (int)i; + bFoundPreviousRoom = true; + break; + } + } + } + + if (previousRoomID.has_value() && !bFoundPreviousRoom) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] RestoreSessionState: room '%s' (id %d) no longer exists, joining the default room instead", to_utf8(strPreviousRoomName.str()).c_str(), *previousRoomID); + } + + pRooms->JoinRoom(roomIndexToJoin); + }); } void WebSocket::UpdateReconnect() @@ -700,11 +748,7 @@ void WebSocket::UpdateReconnect() void WebSocket::Tick() { - if (!AcquireLock()) - { - return; - } - + // Main thread only; m_pCurlWS/m_vecWSPartialBuffer/m_bConnected are unlocked here. UpdateReconnect(); @@ -820,12 +864,17 @@ void WebSocket::Tick() if (!m_bConnected) { - ReleaseLock(); return; } - // send anything we have buffered (e.g. things that were queued while not connected) - for (std::string& strPayload : m_vecQueuedOutboungMsgs) + // send anything we have queued (things sent while not connected, or from any other thread) + std::vector outboundBatch; + { + std::scoped_lock lock(m_outboundQueueMutex); + outboundBatch.swap(m_vecQueuedOutboungMsgs); + } + + for (std::string& strPayload : outboundBatch) { size_t sent; CURLcode result = curl_ws_send(m_pCurlWS, strPayload.c_str(), strPayload.length(), &sent, 0, CURLWS_BINARY); @@ -835,7 +884,6 @@ void WebSocket::Tick() NetworkLog(ELogVerbosity::LOG_RELEASE, "curl_ws_send() failed: %s\n", curl_easy_strerror(result)); } } - m_vecQueuedOutboungMsgs.clear(); // do recv size_t rlen = 0; @@ -1181,16 +1229,31 @@ void WebSocket::Tick() { // all checks are done, process start for host + // stale reply for a lobby we've since left/changed + NGMP_OnlineServices_LobbyInterface* pLobbyInterface = NGMP_OnlineServicesManager::GetInterface(); + int64_t currentLobbyID = pLobbyInterface != nullptr ? pLobbyInterface->GetCurrentLobby().lobbyID : -1; + if (currentLobbyID != m_connectivityCheckLobbyID) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[FULL_MESH_CONNECTIVITY_CHECK_RESPONSE_COMPLETE_TO_HOST] Ignoring stale reply for a previous lobby"); + break; + } + bool bMeshComplete = false; + std::string strReason; + std::list> missingConnections; try { jsonObject["mesh_complete"].get_to(bMeshComplete); - std::list> missingConnections; + if (jsonObject.contains("reason")) + { + jsonObject["reason"].get_to(strReason); + } + if (!bMeshComplete) { - NetworkLog(ELogVerbosity::LOG_RELEASE, "[FULL_MESH_CONNECTIVITY_CHECK_RESPONSE_COMPLETE_TO_HOST] Mesh is not complete for someone"); + NetworkLog(ELogVerbosity::LOG_RELEASE, "[FULL_MESH_CONNECTIVITY_CHECK_RESPONSE_COMPLETE_TO_HOST] Mesh is not complete for someone, reason: %s", strReason.c_str()); for (const auto& missingConnectionEntryIter : jsonObject["missing_connections"]) { int64_t source_user_id = -1; @@ -1206,21 +1269,23 @@ void WebSocket::Tick() { NetworkLog(ELogVerbosity::LOG_RELEASE, "[FULL_MESH_CONNECTIVITY_CHECK_RESPONSE_COMPLETE_TO_HOST] Mesh is fully complete"); } - - // invoke callback - if (m_cbOnConnectivityCheckComplete != nullptr) - { - m_cbOnConnectivityCheckComplete(bMeshComplete, missingConnections); - } - - m_cbOnConnectivityCheckComplete = NULL; } catch (...) { - NetworkLog(ELogVerbosity::LOG_RELEASE, "[FULL_MESH_CONNECTIVITY_CHECK_RESPONSE_COMPLETE_TO_HOST] Error processing response"); - break; + NetworkLog(ELogVerbosity::LOG_RELEASE, "[FULL_MESH_CONNECTIVITY_CHECK_RESPONSE_COMPLETE_TO_HOST] Error processing response, resolving as failure"); + bMeshComplete = false; + missingConnections.clear(); + strReason = "parse_error"; + } + + // invoke callback + if (m_cbOnConnectivityCheckComplete != nullptr) + { + m_cbOnConnectivityCheckComplete(bMeshComplete, missingConnections, strReason); } + ClearConnectivityCheckCallback(); + break; } @@ -1349,7 +1414,7 @@ void WebSocket::Tick() { NetworkLog(ELogVerbosity::LOG_RELEASE, "[SIGNAL] Signal User: %lld!", signalData.target_user_id); NetworkLog(ELogVerbosity::LOG_RELEASE, "[SIGNAL] Signal Payload Size: %d!", (int)signalData.payload.size()); - m_pendingSignals.push(signalData.payload); + PushPendingSignal(std::move(signalData.payload)); } } break; @@ -1734,8 +1799,6 @@ void WebSocket::Tick() BeginReconnect(); m_vecWSPartialBuffer.clear(); }; - - ReleaseLock(); } NGMP_OnlineServices_RoomsInterface::NGMP_OnlineServices_RoomsInterface() From b32f0f289914dc7c4e9c6f5687a8fbe81dc57697 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Sun, 27 Sep 2026 02:21:13 +0200 Subject: [PATCH 07/10] refactor(mesh): Initialize GameNetworkingSockets once per login instead of per lobby --- .../GameNetwork/GeneralsOnline/NetworkMesh.h | 25 ++ .../GeneralsOnline/NetworkMesh.cpp | 276 ++++++++---------- .../GeneralsOnline/OnlineServices_Init.cpp | 15 +- 3 files changed, 164 insertions(+), 152 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h index 402b90b4ebd..9813803b88a 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h @@ -128,6 +128,26 @@ struct QueuedGamePacket int64_t m_userID = -1; }; +// Owns the process-wide GameNetworkingSockets library lifetime. +class NetworkMeshLibrary +{ +public: + // Inits once with userID's identity; stays up until Shutdown (a new login always follows a full teardown). + // Returns false on failure; caller must not create a listen socket/connection in that case. + static bool EnsureInitialized(int64_t userID); + + // Tears the library down. Safe to call when not initialized. + static void Shutdown(); + + static bool IsInitialized() { return s_bInitialized; } + + // Drives SteamNetworkingSockets()->RunCallbacks(), independent of NetworkMesh lifetime. + static void Tick(); + +private: + static bool s_bInitialized; +}; + class NetworkMesh { public: @@ -252,4 +272,9 @@ class NetworkMesh std::string m_strTurnToken; std::string m_strTurnUsernameString; std::string m_strTurnTokenString; + std::string m_strTurnServerList; + + // k_nSteamNetworkingConfig_P2P_Transport_ICE_Enable_* for this lobby's connections. + int m_iceEnable = 0; + int m_iceImplementation = 2; }; diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp index 23e29e8a343..999a1799c78 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp @@ -19,11 +19,8 @@ bool g_bForceRelay = false; UnsignedInt m_exeCRCOriginal = 0; -// Static flag to track if NetworkMesh is being destroyed to prevent callback re-entry -static std::atomic g_bNetworkMeshDestroying = false; - -// SECURITY FIX: Thread-safe pool for deferred deletion of ConnectionSignaling objects -// to prevent "delete this" races during async Steam callbacks +// Pool for deferred deletion of ConnectionSignaling objects; avoids "delete this" races during +// async Steam callbacks. static std::mutex g_pendingDeletionMutex; static std::vector g_pendingConnSignalingDeletions; @@ -48,15 +45,8 @@ static void CleanupPendingConnSignalingDeletions() // m_mapConnections is accessed here without m_mapConnectionsMutex. void OnSteamNetConnectionStatusChanged(SteamNetConnectionStatusChangedCallback_t* pInfo) { - // Clean up any pending ConnectionSignaling deletions from previous callbacks CleanupPendingConnSignalingDeletions(); - // Early exit if NetworkMesh is being destroyed to prevent use-after-free - if (g_bNetworkMeshDestroying.load()) - { - return; - } - NetworkMesh* pMesh = NGMP_OnlineServicesManager::GetNetworkMesh(); if (pMesh == nullptr) @@ -601,28 +591,91 @@ class CSignalingClient : public ISignalingClient }; -NetworkMesh::NetworkMesh() +bool NetworkMeshLibrary::s_bInitialized = false; + +bool NetworkMeshLibrary::EnsureInitialized(int64_t userID) +{ + // lives until the online services shut down; a new login always follows a full teardown + if (s_bInitialized) + { + return true; + } + + SteamNetworkingIdentity identityLocal; + identityLocal.Clear(); + std::string userIDStr = std::to_string(userID); + identityLocal.SetGenericString(userIDStr.c_str()); + + if (identityLocal.IsInvalid()) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "NetworkMeshLibrary::EnsureInitialized: SteamNetworkingIdentity is invalid"); + return false; + } + + SteamDatagramErrMsg errMsg; + if (!GameNetworkingSockets_Init(&identityLocal, errMsg)) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "NetworkMeshLibrary::EnsureInitialized: GameNetworkingSockets_Init failed. %s", errMsg); + return false; + } + + s_bInitialized = true; + + // Every STUN entry must resolve to a distinct address, or the native ICE client retries + // duplicates forever. + SteamNetworkingUtils()->SetGlobalConfigValueString(k_ESteamNetworkingConfig_P2P_STUN_ServerList, "stun:stun.playgenerals.online:53,stun:stun.playgenerals.online:3478,stun:stun.l.google.com:19302"); + + + ESteamNetworkingSocketsDebugOutputType logType = +#if defined(_DEBUG) + ESteamNetworkingSocketsDebugOutputType::k_ESteamNetworkingSocketsDebugOutputType_Debug; +#else + NGMP_OnlineServicesManager::Settings.Debug_VerboseLogging() ? ESteamNetworkingSocketsDebugOutputType::k_ESteamNetworkingSocketsDebugOutputType_Debug : ESteamNetworkingSocketsDebugOutputType::k_ESteamNetworkingSocketsDebugOutputType_Msg; +#endif + + SteamNetworkingUtils()->SetGlobalConfigValueInt32(k_ESteamNetworkingConfig_LogLevel_P2PRendezvous, logType); + SteamNetworkingUtils()->SetDebugOutputFunction(logType, [](ESteamNetworkingSocketsDebugOutputType nType, const char* pszMsg) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[STEAM NETWORKING LOGFUNC] %s", pszMsg); + }); + + SteamNetworkingUtils()->SetGlobalCallback_SteamNetConnectionStatusChanged(OnSteamNetConnectionStatusChanged); + + return true; +} + +void NetworkMeshLibrary::Shutdown() { - SteamNetworkingUtils()->SetGlobalConfigValueInt32(k_ESteamNetworkingConfig_LogLevel_P2PRendezvous, k_ESteamNetworkingSocketsDebugOutputType_Error); - - // Block the status-changed callback from firing while the library is - // torn down and re-initialized. Without this guard the callback can - // be dispatched (e.g. from a previous Tick's RunCallbacks queue) after - // GameNetworkingSockets_Kill() has freed its internal mutexes but - // before GameNetworkingSockets_Init() has rebuilt them, resulting in - // an EXCEPTION_ACCESS_VIOLATION_READ on a null mutex pointer inside - // mtx_do_lock. - g_bNetworkMeshDestroying.store(true); - - // try a shutdown - g_bNetworkMeshDestroying.store(true); + if (!s_bInitialized) + { + return; + } + + SteamNetworkingUtils()->SetGlobalCallback_SteamNetConnectionStatusChanged(nullptr); GameNetworkingSockets_Kill(); + s_bInitialized = false; +} + +void NetworkMeshLibrary::Tick() +{ + if (!s_bInitialized || AnticheatPlugInterface::DoesACPluginProvideSecureGameTransport()) + { + return; + } + + if (SteamNetworkingSockets()) + { + SteamNetworkingSockets()->RunCallbacks(); + } +} + +NetworkMesh::NetworkMesh() +{ NGMP_OnlineServicesManager* pOnlineServicesMgr = NGMP_OnlineServicesManager::GetInstance(); if (pOnlineServicesMgr == nullptr) { NetworkLog(ELogVerbosity::LOG_RELEASE, "pOnlineServicesMgr is invalid"); - g_bNetworkMeshDestroying.store(false); return; } @@ -630,7 +683,6 @@ NetworkMesh::NetworkMesh() if (pAuthInterface == nullptr) { NetworkLog(ELogVerbosity::LOG_RELEASE, "pAuthInterface is invalid"); - g_bNetworkMeshDestroying.store(false); return; } @@ -638,125 +690,65 @@ NetworkMesh::NetworkMesh() if (pLobbyInterface == nullptr) { NetworkLog(ELogVerbosity::LOG_RELEASE, "pLobbyInterface is invalid"); - g_bNetworkMeshDestroying.store(false); return; } - - int64_t localUserID = pAuthInterface->GetUserID(); - - SteamNetworkingIdentity identityLocal; - identityLocal.Clear(); - std::string localUserIDStr = std::to_string(localUserID); - identityLocal.SetGenericString(localUserIDStr.c_str()); - - if (identityLocal.IsInvalid()) - { - NetworkLog(ELogVerbosity::LOG_RELEASE, "SteamNetworkingIdentity is invalid"); - g_bNetworkMeshDestroying.store(false); - return; - } - - // initialize Steam Sockets - SteamDatagramErrMsg errMsg; - if (!GameNetworkingSockets_Init(&identityLocal, errMsg)) + if (!NetworkMeshLibrary::EnsureInitialized(pAuthInterface->GetUserID())) { - NetworkLog(ELogVerbosity::LOG_RELEASE, "GameNetworkingSockets_Init failed. %s", errMsg); - g_bNetworkMeshDestroying.store(false); + NetworkLog(ELogVerbosity::LOG_RELEASE, "NetworkMeshLibrary::EnsureInitialized failed"); return; } - // TODO_STEAM: Dont hardcode, get everything from service - // Every entry must resolve to distinct addresses. stun1-4.l.google.com resolve to the same IPs as - // stun.l.google.com, and duplicate addresses make the native ICE client retry STUN servers forever. - SteamNetworkingUtils()->SetGlobalConfigValueString(k_ESteamNetworkingConfig_P2P_STUN_ServerList, "stun:stun.playgenerals.online:53,stun:stun.playgenerals.online:3478,stun:stun.l.google.com:19302"); - - // comma seperated setting lists - // No "?transport=udp" suffix: the native ICE client passes everything after the host as the port. - const char* turnList = "turn:turn.playgenerals.online:53,turn:turn.playgenerals.online:3478"; + // comma-separated; no "?transport=udp" suffix, the native ICE client takes everything after + // the host as the port + m_strTurnServerList = "turn:turn.playgenerals.online:53,turn:turn.playgenerals.online:3478"; m_strTurnUsername = pLobbyInterface->GetLobbyTurnUsername(); m_strTurnToken = pLobbyInterface->GetLobbyTurnToken(); - - //const char* szUsername = "g04024f26713bae6e055295b6887b7007533f6c236534b725734b37e26ec15cd,g04024f26713bae6e055295b6887b7007533f6c236534b725734b37e26ec15cd"; - //const char* szToken = "9ea6a5e60216c09a1fa7512987b2ce0514e3204f863f04f70fa870a100db740f,9ea6a5e60216c09a1fa7512987b2ce0514e3204f863f04f70fa870a100db740f"; - - //strUsername = "g04024f26713bae6e055295b6887b7007533f6c236534b725734b37e26ec15cd"; - //strToken = "9ea6a5e60216c09a1fa7512987b2ce0514e3204f863f04f70fa870a100db740f"; - m_strTurnUsernameString = std::format("{},{}", m_strTurnUsername.c_str(), m_strTurnUsername.c_str()); m_strTurnTokenString = std::format("{},{}", m_strTurnToken.c_str(), m_strTurnToken.c_str()); - SteamNetworkingUtils()->SetGlobalConfigValueString(k_ESteamNetworkingConfig_P2P_TURN_ServerList, turnList); - SteamNetworkingUtils()->SetGlobalConfigValueString(k_ESteamNetworkingConfig_P2P_TURN_UserList, m_strTurnUsernameString.c_str()); - SteamNetworkingUtils()->SetGlobalConfigValueString(k_ESteamNetworkingConfig_P2P_TURN_PassList, m_strTurnTokenString.c_str()); - ServiceConfig& serviceConf = pOnlineServicesMgr->GetServiceConfig(); + m_iceEnable = (g_bForceRelay || serviceConf.relay_all_traffic) + ? k_nSteamNetworkingConfig_P2P_Transport_ICE_Enable_Relay + : k_nSteamNetworkingConfig_P2P_Transport_ICE_Enable_All; - // Allow sharing of any kind of ICE address. - if (g_bForceRelay || serviceConf.relay_all_traffic) - { - SteamNetworkingUtils()->SetGlobalConfigValueInt32(k_ESteamNetworkingConfig_P2P_Transport_ICE_Enable, k_nSteamNetworkingConfig_P2P_Transport_ICE_Enable_Relay); - } - else - { - SteamNetworkingUtils()->SetGlobalConfigValueInt32(k_ESteamNetworkingConfig_P2P_Transport_ICE_Enable, k_nSteamNetworkingConfig_P2P_Transport_ICE_Enable_All); - } - - // Let the service config pick the ICE client: 0 = library default, 1 = native, 2 = WebRTC. - int iceImplementation = serviceConf.ice_implementation; - if (iceImplementation < 0 || iceImplementation > 2) - { - iceImplementation = 2; - } - NetworkLog(ELogVerbosity::LOG_RELEASE, "NetworkMesh: using ICE implementation %d (0=default, 1=native, 2=WebRTC)", iceImplementation); - SteamNetworkingUtils()->SetGlobalConfigValueInt32(k_ESteamNetworkingConfig_P2P_Transport_ICE_Implementation, iceImplementation); + // 0 = library default, 1 = native, 2 = WebRTC + m_iceImplementation = (serviceConf.ice_implementation >= 0 && serviceConf.ice_implementation <= 2) ? serviceConf.ice_implementation : 2; + NetworkLog(ELogVerbosity::LOG_RELEASE, "NetworkMesh: using ICE implementation %d (0=default, 1=native, 2=WebRTC)", m_iceImplementation); - m_hListenSock = k_HSteamListenSocket_Invalid; - - // create signalling service m_pSignaling = new CSignalingClient(SteamNetworkingSockets()); if (m_pSignaling == nullptr) { - NetworkLog(ELogVerbosity::LOG_RELEASE, "CreateTrivialSignalingClient failed. %s", errMsg); - g_bNetworkMeshDestroying.store(false); + NetworkLog(ELogVerbosity::LOG_RELEASE, "CreateTrivialSignalingClient failed"); return; } - SteamNetworkingUtils()->SetGlobalCallback_SteamNetConnectionStatusChanged(OnSteamNetConnectionStatusChanged); - g_bNetworkMeshDestroying.store(false); - - // Library is fully re-initialized and the callback is registered; - // it is now safe to allow OnSteamNetConnectionStatusChanged to run. - g_bNetworkMeshDestroying.store(false); - - m_bInitialized = true; - - ESteamNetworkingSocketsDebugOutputType logType = -#if defined(_DEBUG) - ESteamNetworkingSocketsDebugOutputType::k_ESteamNetworkingSocketsDebugOutputType_Debug -#else - NGMP_OnlineServicesManager::Settings.Debug_VerboseLogging() ? ESteamNetworkingSocketsDebugOutputType::k_ESteamNetworkingSocketsDebugOutputType_Debug : ESteamNetworkingSocketsDebugOutputType::k_ESteamNetworkingSocketsDebugOutputType_Msg -#endif; - ; - - SteamNetworkingUtils()->SetGlobalConfigValueInt32(k_ESteamNetworkingConfig_LogLevel_P2PRendezvous, logType); - SteamNetworkingUtils()->SetDebugOutputFunction(logType, [](ESteamNetworkingSocketsDebugOutputType nType, const char* pszMsg) - { - NetworkLog(ELogVerbosity::LOG_RELEASE, "[STEAM NETWORKING LOGFUNC] %s", pszMsg); - }); + std::vector vecListenOpts; + SteamNetworkingConfigValue_t opt; + opt.SetInt32(k_ESteamNetworkingConfig_SymmetricConnect, 1); + vecListenOpts.push_back(opt); + opt.SetInt32(k_ESteamNetworkingConfig_P2P_Transport_ICE_Enable, m_iceEnable); + vecListenOpts.push_back(opt); + opt.SetInt32(k_ESteamNetworkingConfig_P2P_Transport_ICE_Implementation, m_iceImplementation); + vecListenOpts.push_back(opt); + opt.SetString(k_ESteamNetworkingConfig_P2P_TURN_ServerList, m_strTurnServerList.c_str()); + vecListenOpts.push_back(opt); + opt.SetString(k_ESteamNetworkingConfig_P2P_TURN_UserList, m_strTurnUsernameString.c_str()); + vecListenOpts.push_back(opt); + opt.SetString(k_ESteamNetworkingConfig_P2P_TURN_PassList, m_strTurnTokenString.c_str()); + vecListenOpts.push_back(opt); int localPort = 0; - - // create sockets - SteamNetworkingConfigValue_t opt; - opt.SetInt32(k_ESteamNetworkingConfig_SymmetricConnect, 1); // << Note we set symmetric mode on the listen socket - m_hListenSock = SteamNetworkingSockets()->CreateListenSocketP2P(localPort, 1, &opt); + m_hListenSock = SteamNetworkingSockets()->CreateListenSocketP2P(localPort, (int)vecListenOpts.size(), vecListenOpts.data()); if (m_hListenSock == k_HSteamListenSocket_Invalid) { NetworkLog(ELogVerbosity::LOG_RELEASE, "CreateListenSocketP2P failed. Sock was invalid"); + return; } + + m_bInitialized = true; } @@ -949,6 +941,18 @@ void NetworkMesh::StartConnectionSignalling(const char* szMiddlewareID, int64_t SteamNetworkingConfigValue_t opt; opt.SetInt32(k_ESteamNetworkingConfig_SymmetricConnect, 1); vecOpts.push_back(opt); + + opt.SetInt32(k_ESteamNetworkingConfig_P2P_Transport_ICE_Enable, m_iceEnable); + vecOpts.push_back(opt); + opt.SetInt32(k_ESteamNetworkingConfig_P2P_Transport_ICE_Implementation, m_iceImplementation); + vecOpts.push_back(opt); + opt.SetString(k_ESteamNetworkingConfig_P2P_TURN_ServerList, m_strTurnServerList.c_str()); + vecOpts.push_back(opt); + opt.SetString(k_ESteamNetworkingConfig_P2P_TURN_UserList, m_strTurnUsernameString.c_str()); + vecOpts.push_back(opt); + opt.SetString(k_ESteamNetworkingConfig_P2P_TURN_PassList, m_strTurnTokenString.c_str()); + vecOpts.push_back(opt); + NetworkLog(ELogVerbosity::LOG_DEBUG, "Connecting to '%s' in symmetric mode, virtual port %d, from local virtual port %d.\n", SteamNetworkingIdentityRender(identityRemote).c_str(), g_nVirtualPortRemote, g_nLocalPort); @@ -1057,16 +1061,11 @@ void NetworkMesh::Disconnect() m_bDisconnected = true; - // Set flag to prevent callbacks from executing during teardown - g_bNetworkMeshDestroying.store(true); - - // close every connection for (auto& connectionData : m_mapConnections) { connectionData.second.Close(); } - // clear map m_mapConnections.clear(); if (AnticheatPlugInterface::DoesACPluginProvideSecureGameTransport()) @@ -1075,43 +1074,20 @@ void NetworkMesh::Disconnect() } else { - // Unregister the global callback to prevent new callbacks from being queued - if (SteamNetworkingUtils()) - { - SteamNetworkingUtils()->SetGlobalCallback_SteamNetConnectionStatusChanged(nullptr); - } - - if (SteamNetworkingSockets()) + if (SteamNetworkingSockets() && m_hListenSock != k_HSteamListenSocket_Invalid) { SteamNetworkingSockets()->CloseListenSocket(m_hListenSock); } - // invalidate socket - m_hListenSock = k_HSteamNetConnection_Invalid; - - // tear down steam sockets - GameNetworkingSockets_Kill(); + m_hListenSock = k_HSteamListenSocket_Invalid; } - - // Reset flag after teardown is complete - g_bNetworkMeshDestroying.store(false); } void NetworkMesh::Tick() { - if (!AnticheatPlugInterface::DoesACPluginProvideSecureGameTransport()) + if (!AnticheatPlugInterface::DoesACPluginProvideSecureGameTransport() && m_pSignaling != nullptr) { - // Check for incoming signals, and dispatch them - if (m_pSignaling != nullptr) - { - m_pSignaling->Poll(); - } - - // Check callbacks - if (SteamNetworkingSockets()) - { - SteamNetworkingSockets()->RunCallbacks(); - } + m_pSignaling->Poll(); } // update connection histograms diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp index 31c1525a578..b23632f803e 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp @@ -1,4 +1,5 @@ #include "GameNetwork/GeneralsOnline/NGMP_interfaces.h" +#include "GameNetwork/GeneralsOnline/NetworkMesh.h" #include "GameNetwork/GeneralsOnline/HTTP/HTTPManager.h" #include "../json.hpp" #include "GameClient/MessageBox.h" @@ -346,11 +347,13 @@ void NGMP_OnlineServicesManager::WaitForScreenshotThreads() void NGMP_OnlineServicesManager::Shutdown() { NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] OnlineServicesManager shutdown initiated"); - + // First, wait for all screenshot threads to complete // This prevents race conditions where threads might still be using resources WaitForScreenshotThreads(); - + + NetworkMeshLibrary::Shutdown(); + // Shutdown and completely destroy WebSocket BEFORE cleaning up HTTPManager // This is critical because WebSocket has curl handles that must be freed // before curl_global_cleanup() is called by HTTPManager @@ -825,6 +828,12 @@ void NGMP_OnlineServicesManager::OnLogin(ELoginResult loginResult, const char* s { if (loginResult == ELoginResult::Success) { + NGMP_OnlineServices_AuthInterface* pAuthInterface = NGMP_OnlineServicesManager::GetInterface(); + if (pAuthInterface != nullptr) + { + NetworkMeshLibrary::EnsureInitialized(pAuthInterface->GetUserID()); + } + // Tear down any previous session's websocket before replacing it. if (m_pWebSocket != nullptr) { @@ -1038,6 +1047,8 @@ void NGMP_OnlineServicesManager::Tick() { m_pLobbyInterface->Tick(); } + + NetworkMeshLibrary::Tick(); } void NGMP_OnlineServicesManager::InitSentry() From d97cfd43231b55589a6ef4d3d2aaf264e73df9ac Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Sun, 27 Sep 2026 02:50:49 +0200 Subject: [PATCH 08/10] fix(lobby): Harden the lobby flow against stale replies and teardown races --- .../Include/GameNetwork/GameSpy/LobbyUtils.h | 4 ++ .../Source/GameNetwork/GameSpy/LobbyUtils.cpp | 31 +++++++++++--- .../GUICallbacks/Menus/WOLGameSetupMenu.cpp | 28 ++++++++++++- .../GUI/GUICallbacks/Menus/WOLLobbyMenu.cpp | 4 +- .../GameNetwork/GeneralsOnline/NGMPGame.cpp | 16 +++++++- .../OnlineServices_LobbyInterface.cpp | 41 +++++++++++++++++-- 6 files changed, 108 insertions(+), 16 deletions(-) diff --git a/Core/GameEngine/Include/GameNetwork/GameSpy/LobbyUtils.h b/Core/GameEngine/Include/GameNetwork/GameSpy/LobbyUtils.h index 01dca008afe..9c7ac0340be 100644 --- a/Core/GameEngine/Include/GameNetwork/GameSpy/LobbyUtils.h +++ b/Core/GameEngine/Include/GameNetwork/GameSpy/LobbyUtils.h @@ -40,6 +40,10 @@ void RefreshGameInfoListBox( GameWindow *mainWin, GameWindow *win ); void RefreshGameListBoxes(); void ToggleGameListType(); +// The game listbox's item data is a row index (32-bit safe), not the 64-bit lobby ID itself. +// Resolve the real ID with this before calling anything that needs it (e.g. GetLobbyFromID). +int64_t ResolveGameListLobbyID(Int rowItemData); + void playerTemplateComboBoxTooltip(GameWindow *wndComboBox, WinInstanceData *instData, UnsignedInt mouse); void playerTemplateListBoxTooltip(GameWindow *wndListBox, WinInstanceData *instData, UnsignedInt mouse); diff --git a/Core/GameEngine/Source/GameNetwork/GameSpy/LobbyUtils.cpp b/Core/GameEngine/Source/GameNetwork/GameSpy/LobbyUtils.cpp index 78857343241..93892fe49f4 100644 --- a/Core/GameEngine/Source/GameNetwork/GameSpy/LobbyUtils.cpp +++ b/Core/GameEngine/Source/GameNetwork/GameSpy/LobbyUtils.cpp @@ -273,7 +273,7 @@ static void gameTooltip(GameWindow* window, return; } - LobbyEntry lobbyEntry = pLobbyInterface->GetLobbyFromID(gameID); + LobbyEntry lobbyEntry = pLobbyInterface->GetLobbyFromID(ResolveGameListLobbyID(gameID)); if (lobbyEntry.lobbyID == -1) { return; @@ -614,6 +614,20 @@ static Bool lobbyHasBuddy(GameSpyStagingRoom *room) } #endif +// Lobby IDs are 64-bit, but GadgetListBox item data is a 32-bit void* on this platform, so we +// can't stash the ID itself there. Instead we store the row index and keep the real ID here, +// rebuilt every time the game listbox is repopulated (see RefreshGameListBox/insertGame). +static std::vector s_lobbyRowIDs; + +int64_t ResolveGameListLobbyID(Int rowItemData) +{ + if (rowItemData < 0 || (size_t)rowItemData >= s_lobbyRowIDs.size()) + { + return -1; + } + return s_lobbyRowIDs[rowItemData]; +} + #if defined(GENERALS_ONLINE) static void populateBuddyGames(std::vector& vecLobbies) #else @@ -841,7 +855,6 @@ static Int insertGame(GameWindow* win, LobbyEntry& lobbyInfo, Bool showMap) AsciiString lobbyMapName = AsciiString(lobbyInfo.map_name.c_str()); AsciiString ladder = AsciiString("TODO_NGMP"); USHORT ladderPort = 1; - int gameID = lobbyInfo.lobbyID; // TODO_NGMP: Downcast. We should use int64 everywhere, although its unlikely we actually need int64 for lobby since its reset regularly. bool bHasPassword = lobbyInfo.passworded; @@ -888,7 +901,12 @@ static Int insertGame(GameWindow* win, LobbyEntry& lobbyInfo, Bool showMap) gameColor = GameMakeColor(191, 198, 201, 255); } Int index = GadgetListBoxAddEntryText(win, gameName, gameColor, -1, COLUMN_NAME); - GadgetListBoxSetItemData(win, (void*)gameID, index); + if ((size_t)index >= s_lobbyRowIDs.size()) + { + s_lobbyRowIDs.resize(index + 1); + } + s_lobbyRowIDs[index] = lobbyInfo.lobbyID; + GadgetListBoxSetItemData(win, (void*)(intptr_t)index, index); UnicodeString s; @@ -1177,11 +1195,11 @@ void RefreshGameListBox(GameWindow* win, Bool showMap) // save off selection Int selectedIndex = -1; - Int selectedID = 0; + int64_t selectedID = -1; GadgetListBoxGetSelected(win, &selectedIndex); if (selectedIndex != -1) { - selectedID = (Int)GadgetListBoxGetItemData(win, selectedIndex); + selectedID = ResolveGameListLobbyID((Int)GadgetListBoxGetItemData(win, selectedIndex)); } int prevPos = GadgetListBoxGetTopVisibleEntry(win); @@ -1199,6 +1217,7 @@ void RefreshGameListBox(GameWindow* win, Bool showMap) { // empty listbox GadgetListBoxReset(win); + s_lobbyRowIDs.clear(); size_t numResults = vecLobbies.size(); @@ -1270,7 +1289,7 @@ void RefreshGameListBox(GameWindow* win, Bool showMap) // if(prevPos > 10) GadgetListBoxSetTopVisibleEntry(win, prevPos);//+ 1 - if (indexToSelect < 0 && selectedID) + if (indexToSelect < 0 && selectedID >= 0) { TheWindowManager->winSetLoneWindow(NULL); } diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp index 8b3afa44afe..0f4a9c7edce 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp @@ -975,6 +975,11 @@ static void StartPressed() return; NetworkMesh* pMesh = NGMP_OnlineServicesManager::GetNetworkMesh(); + if (pMesh == nullptr) + { + return; + } + int numHumanPlayers = 0; for(LobbyMemberEntry & member : pLobbyInterface->GetCurrentLobby().members) { @@ -2079,6 +2084,10 @@ void WOLGameSetupMenuInit( WindowLayout *layout, void *userData ) // TODO_NGMP NGMPGame* game = pLobbyInterface == nullptr ? nullptr : pLobbyInterface->GetCurrentGame(); + if (game == nullptr) + { + return; + } NGMPGameSlot* hostSlot = game->getGameSpySlot(0); hostSlot->setAccept(); @@ -4130,6 +4139,11 @@ WindowMsgHandledType WOLGameSetupMenuSystem( GameWindow *window, UnsignedInt msg { //I'm the Client... send an accept message to the host. auto game = pLobbyInterface->GetCurrentGame(); + if (game == nullptr) + { + break; + } + GameSlot *localSlot = game->getSlot(game->getLocalSlotNum()); if (localSlot) { @@ -4167,11 +4181,16 @@ WindowMsgHandledType WOLGameSetupMenuSystem( GameWindow *window, UnsignedInt msg break; } + NGMPGame* game = pLobbyInterface->GetCurrentGame(); + if (game == nullptr) + { + break; + } + for (Int i = 0; i < MAX_SLOTS; i++) { if (controlID == buttonMapStartPositionID[i]) { - NGMPGame* game = pLobbyInterface->GetCurrentGame(); Int playerIdxInPos = -1; for (Int j=0; jGetCurrentGame(); + if (game == nullptr) + { + break; + } + for (Int i = 0; i < MAX_SLOTS; i++) { if (controlID == buttonMapStartPositionID[i]) { - NGMPGame* game = pLobbyInterface->GetCurrentGame(); Int playerIdxInPos = -1; for (Int j=0; j= 0 ? (Int)GadgetListBoxGetItemData(control, rowSelected, 0) : 0; + int64_t lobbyID = rowSelected >= 0 ? ResolveGameListLobbyID((Int)GadgetListBoxGetItemData(control, rowSelected, 0)) : 0; if( lobbyID >= 0 ) { buttonJoin->winEnable(TRUE); @@ -2455,7 +2455,7 @@ WindowMsgHandledType WOLLobbyMenuSystem( GameWindow *window, UnsignedInt msg, GadgetListBoxGetSelected(GetGameListBox(), &selected); if (selected >= 0) { - Int selectedID = (Int)GadgetListBoxGetItemData(GetGameListBox(), selected); + int64_t selectedID = ResolveGameListLobbyID((Int)GadgetListBoxGetItemData(GetGameListBox(), selected)); if (selectedID >= 0) { auto Lobby = pLobbyInterface->GetLobbyFromID(selectedID); diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp index 388e95997c3..4890e4d6f53 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp @@ -149,7 +149,6 @@ void NGMPGame::UpdateSlotsFromCurrentLobby() for (Int i = 0; i < MAX_SLOTS; ++i) { - // this list is provided by the service, ordered by slot index, so we dont need to look up / use the slot index from the member LobbyMemberEntry pLobbyMember = pLobbyInterface->GetRoomMemberFromIndex(i); // TODO_NGMP: Support spectators @@ -218,7 +217,20 @@ void NGMPGame::UpdateSlotsFromCurrentLobby() else { // handle open/closed - NGMPGameSlot* slot = (NGMPGameSlot*)getSlot(i); + if (pLobbyMember.m_SlotIndex >= MAX_SLOTS) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] UpdateSlotsFromCurrentLobby: bad slot index %u for user %lld, skipping", pLobbyMember.m_SlotIndex, pLobbyMember.user_id); + continue; + } + + NGMPGameSlot* slot = (NGMPGameSlot*)getSlot(pLobbyMember.m_SlotIndex); + + if (slot == nullptr) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] UpdateSlotsFromCurrentLobby: getSlot(%u) returned null, skipping", pLobbyMember.m_SlotIndex); + continue; + } + slot->setState((SlotState)pLobbyMember.m_SlotState); } diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp index 1c2a269a58d..664f69f1a86 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp @@ -1195,6 +1195,12 @@ void NGMP_OnlineServices_LobbyInterface::JoinLobby(LobbyEntry lobbyInfo, std::st NGMP_OnlineServices_LobbyInterface* pLobbyInterface = NGMP_OnlineServicesManager::GetInterface(); if (pLobbyInterface == nullptr || pLobbyInterface != this || pLobbyInterface->m_LobbyJoinGeneration.load() != lobbyJoinGeneration) { + // stale join response landed after we already left/moved on; leave again if it succeeded + if (pLobbyInterface == this && statusCode == 200 && bSuccess && m_CurrentLobby.lobbyID != lobbyInfo.lobbyID) + { + std::map mapLeaveHeaders; + NGMP_OnlineServicesManager::GetInstance()->GetHTTPManager()->SendDELETERequest(strURI.c_str(), EIPProtocolVersion::DONT_CARE, mapLeaveHeaders, "", nullptr); + } return; } @@ -1341,6 +1347,11 @@ void NGMP_OnlineServices_LobbyInterface::LeaveCurrentLobby() m_bCannotConnectToLobbyPending = false; ResetJoinOrder(); + // invalidate any join/create still in flight + ++m_LobbyJoinGeneration; + m_bAttemptingToJoinLobby = false; + ResetLobbyTryingToJoin(); + // a mesh connectivity check started for this lobby is meaningless once we've left it; drop it so a // late reply never fires into whatever lobby/menu we end up in next std::shared_ptr pWS = NGMP_OnlineServicesManager::GetWebSocket(); @@ -1456,6 +1467,9 @@ void NGMP_OnlineServices_LobbyInterface::CreateLobby(UnicodeString strLobbyName, { AnticheatPlugInterface::EndSession(); + // shares the join generation counter with JoinLobby + const uint64_t lobbyCreateGeneration = ++m_LobbyJoinGeneration; + NGMP_OnlineServicesManager::GetInstance()->GetAndParseServiceConfig([=]() { m_CurrentLobby = LobbyEntry(); @@ -1515,6 +1529,19 @@ void NGMP_OnlineServices_LobbyInterface::CreateLobby(UnicodeString strLobbyName, nlohmann::json jsonObject = nlohmann::json::parse(strBody); CreateLobbyResponse resp = jsonObject.get(); + if (pLobbyInterface != this || pLobbyInterface->m_LobbyJoinGeneration.load() != lobbyCreateGeneration) + { + // stale response; delete the orphaned lobby if the server created one + NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] Ignoring stale CreateLobby response (generation changed)"); + if (resp.result == ECreateLobbyResponseResult::SUCCEEDED) + { + std::string strLeaveURI = std::format("{}/{}", NGMP_OnlineServicesManager::GetAPIEndpoint("Lobby"), resp.lobby_id); + std::map mapLeaveHeaders; + NGMP_OnlineServicesManager::GetInstance()->GetHTTPManager()->SendDELETERequest(strLeaveURI.c_str(), EIPProtocolVersion::DONT_CARE, mapLeaveHeaders, "", nullptr); + } + return; + } + m_strTURNUsername = resp.turn_username; m_strTURNToken = resp.turn_token; NetworkLog(ELogVerbosity::LOG_DEBUG, "Got TURN username: %s, token: %s", m_strTURNUsername.c_str(), m_strTURNToken.c_str()); @@ -1578,11 +1605,17 @@ void NGMP_OnlineServices_LobbyInterface::CreateLobby(UnicodeString strLobbyName, // we always need to get the enc key etc pLobbyInterface->OnJoinedOrCreatedLobby(false, [=](bool bSuccess) { - // TODO_NGMP: Impl - pLobbyInterface->InvokeCreateLobbyCallback(resp.result == ECreateLobbyResponseResult::SUCCEEDED); + if (pLobbyInterface->m_LobbyJoinGeneration.load() != lobbyCreateGeneration) + { + return; + } + + pLobbyInterface->InvokeCreateLobbyCallback(bSuccess); - // Set our properties - pLobbyInterface->ApplyLocalUserPropertiesToCurrentNetworkRoom(); + if (bSuccess) + { + pLobbyInterface->ApplyLocalUserPropertiesToCurrentNetworkRoom(); + } }); } else From 5d61575034d70b0f794abd980d8e68e30d199f74 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Sun, 27 Sep 2026 02:54:42 +0200 Subject: [PATCH 09/10] fix(quickmatch): Harden the quick match flow --- .../GUICallbacks/Menus/WOLQuickMatchMenu.cpp | 44 +++++++++++++++++-- .../GameNetwork/GeneralsOnline/NGMPGame.cpp | 3 ++ .../OnlineServices_RoomsInterface.cpp | 14 ++++++ 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLQuickMatchMenu.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLQuickMatchMenu.cpp index 6ebd35767a8..1d7f70642df 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLQuickMatchMenu.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLQuickMatchMenu.cpp @@ -115,6 +115,9 @@ static NameKeyType comboBoxSideID = NAMEKEY_INVALID; static NameKeyType comboBoxColorID = NAMEKEY_INVALID; +// Bumped on Init/Shutdown; async callbacks bail if this changed before they fire. +static uint64_t s_quickMatchMenuGeneration = 0; + // Window Pointers ------------------------------------------------------------------------ static GameWindow *parentWOLQuickMatch = nullptr; static GameWindow *buttonBack = nullptr; @@ -887,6 +890,8 @@ static void saveQuickMatchOptions() //------------------------------------------------------------------------------------------------- void WOLQuickMatchMenuInit( WindowLayout *layout, void *userData ) { + ++s_quickMatchMenuGeneration; + isInInit = TRUE; if (TheGameSpyGame && TheGameSpyGame->isGameInProgress()) { @@ -1158,8 +1163,14 @@ void WOLQuickMatchMenuInit( WindowLayout *layout, void *userData ) NGMP_OnlineServices_StatsInterface* pStatsInterface = NGMP_OnlineServicesManager::GetInterface(); if (pAuthInterface != nullptr && pStatsInterface != nullptr) { + const uint64_t generationForStats = s_quickMatchMenuGeneration; pStatsInterface->findPlayerStatsByID(pAuthInterface->GetUserID(), [=](bool bSuccess, PSPlayerStats stats) { + if (generationForStats != s_quickMatchMenuGeneration) + { + return; + } + if (bSuccess) { UnicodeString eloStr; @@ -1180,7 +1191,15 @@ void WOLQuickMatchMenuInit( WindowLayout *layout, void *userData ) pLobbyInterface->RegisterForCannotConnectToLobbyCallback([](void) { - // TODO_QUICKMATCH: Show error message + stop matchmaking + enable buttons again + Int index = GadgetListBoxAddEntryText(quickmatchTextWindow, UnicodeString(L"Could not connect to a player, waiting for the matchmaker..."), GameSpyColor[GSCOLOR_DEFAULT], -1, -1); + GadgetListBoxSetItemData(quickmatchTextWindow, (void*)-1, index); + + // don't cancel: racing a server-issued requeue could unregister us from its bucket + matchFoundTimeoutStart = 0; + matchStartCountdownLastSecond = 0; + + buttonBack->winEnable(TRUE); + buttonStop->winEnable(TRUE); }); } @@ -1189,8 +1208,14 @@ void WOLQuickMatchMenuInit( WindowLayout *layout, void *userData ) NGMP_OnlineServices_MatchmakingInterface* pMatchmakingInterface = NGMP_OnlineServicesManager::GetInterface(); if (pMatchmakingInterface != nullptr) { - pMatchmakingInterface->RetrievePlaylists([](std::vector vecPlaylists) + const uint64_t generationForPlaylists = s_quickMatchMenuGeneration; + pMatchmakingInterface->RetrievePlaylists([generationForPlaylists](std::vector vecPlaylists) { + if (generationForPlaylists != s_quickMatchMenuGeneration) + { + return; + } + // add playlists UnicodeString s; @@ -1466,6 +1491,8 @@ static void shutdownComplete( WindowLayout *layout ) //------------------------------------------------------------------------------------------------- void WOLQuickMatchMenuShutdown( WindowLayout *layout, void *userData ) { + ++s_quickMatchMenuGeneration; + #if !defined(GENERALS_ONLINE) TheGameSpyInfo->unregisterTextWindow(quickmatchTextWindow); #endif @@ -1507,6 +1534,10 @@ void WOLQuickMatchMenuShutdown( WindowLayout *layout, void *userData ) parentWOLQuickMatch = nullptr; buttonBack = nullptr; + buttonStart = nullptr; + buttonStop = nullptr; + buttonWiden = nullptr; + comboBoxNumPlayers = nullptr; quickmatchTextWindow = nullptr; selectedImage = unselectedImage = nullptr; matchFoundTimeoutStart = 0; @@ -2369,8 +2400,15 @@ WindowMsgHandledType WOLQuickMatchMenuSystem( GameWindow *window, UnsignedInt ms NGMP_OnlineServices_MatchmakingInterface* pMatchmakingInterface = NGMP_OnlineServicesManager::GetInterface(); if (pMatchmakingInterface != nullptr) { - pMatchmakingInterface->StartMatchmaking(playlistID, vecSelectedMapIndexes, [](bool bSuccess) + const uint64_t generationForStart = s_quickMatchMenuGeneration; + pMatchmakingInterface->StartMatchmaking(playlistID, vecSelectedMapIndexes, [generationForStart](bool bSuccess) { + if (generationForStart != s_quickMatchMenuGeneration) + { + // menu closed; static window pointers may be stale + return; + } + // TODO_QUICKMATCH: Chat has a sound effect in TheGameSpyInfo, re-eanble it if (bSuccess) { diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp index 4890e4d6f53..834bd86851a 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp @@ -30,6 +30,8 @@ NGMPGameSlot::NGMPGameSlot() NGMPGame::NGMPGame() { + m_isQM = FALSE; + NGMP_OnlineServices_LobbyInterface* pLobbyInterface = NGMP_OnlineServicesManager::GetInterface(); if (pLobbyInterface == nullptr) { @@ -579,6 +581,7 @@ void NGMPGame::launchGame(void) void NGMPGame::reset(void) { GameInfo::reset(); + m_isQM = FALSE; } void NGMPGame::StartCountdown() diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp index 99ef02b8882..07e13867223 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp @@ -1616,10 +1616,24 @@ void WebSocket::Tick() case EWebSocketMessageID::MATCHMAKING_ACTION_SETUP_PROGRESS: { + static constexpr int kMinMatchSetupTimeoutMs = 1000; + static constexpr int kMaxMatchSetupTimeoutMs = 120000; + int timeoutMs = 0; if (jsonObject.contains("timeout_ms") && jsonObject["timeout_ms"].is_number_integer()) { timeoutMs = jsonObject["timeout_ms"].get(); + if (timeoutMs > 0) + { + if (timeoutMs < kMinMatchSetupTimeoutMs) + { + timeoutMs = kMinMatchSetupTimeoutMs; + } + else if (timeoutMs > kMaxMatchSetupTimeoutMs) + { + timeoutMs = kMaxMatchSetupTimeoutMs; + } + } } // -1 = older service that doesn't say, the menu infers it From 2ef4f5233006885a06b7adc15cde4d1259a2fd3c Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Sun, 27 Sep 2026 05:51:53 +0200 Subject: [PATCH 10/10] fix(mesh): Apply anticheat connection state updates on the main thread --- .../GameNetwork/GeneralsOnline/NetworkMesh.h | 14 ++++++-------- .../GameNetwork/GeneralsOnline/NetworkMesh.cpp | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h index 9813803b88a..1081d2e1486 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h @@ -233,16 +233,11 @@ class NetworkMesh return m_mapConnections; } - // Thread-safe: may be invoked from anticheat plugin threads + // Thread-safe: may be invoked from anticheat plugin threads; applied on the main thread in Tick void UpdateConnectionStateForUser(int64_t userID, EConnectionState newState) { - std::lock_guard lock(m_mapConnectionsMutex); - - auto it = m_mapConnections.find(userID); - if (it != m_mapConnections.end()) - { - it->second.UpdateState(newState, this); - } + std::lock_guard lock(m_pendingStateUpdatesMutex); + m_vecPendingStateUpdates.emplace_back(userID, newState); } PlayerConnection* GetConnectionForUser(int64_t user_id) @@ -262,6 +257,9 @@ class NetworkMesh std::map m_mapConnections; mutable std::recursive_mutex m_mapConnectionsMutex; // Synchronizes access to m_mapConnections + std::mutex m_pendingStateUpdatesMutex; + std::vector> m_vecPendingStateUpdates; + ISignalingClient* m_pSignaling = nullptr; HSteamListenSocket m_hListenSock = k_HSteamListenSocket_Invalid; diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp index 999a1799c78..d267e5364d9 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp @@ -1085,6 +1085,22 @@ void NetworkMesh::Disconnect() void NetworkMesh::Tick() { + // state reported from anticheat plugin threads; UpdateState reaches UI callbacks, so apply it here + std::vector> vecStateUpdates; + { + std::lock_guard lock(m_pendingStateUpdatesMutex); + vecStateUpdates.swap(m_vecPendingStateUpdates); + } + for (const auto& update : vecStateUpdates) + { + std::lock_guard lock(m_mapConnectionsMutex); + auto it = m_mapConnections.find(update.first); + if (it != m_mapConnections.end()) + { + it->second.UpdateState(update.second, this); + } + } + if (!AnticheatPlugInterface::DoesACPluginProvideSecureGameTransport() && m_pSignaling != nullptr) { m_pSignaling->Poll();