From 85862b2a32849d84bb180e23bcc367c24543497b Mon Sep 17 00:00:00 2001 From: Liparakis Date: Fri, 10 Apr 2026 22:45:57 +0300 Subject: [PATCH 1/8] feat: MMSClient WebSocket implementation and refactor + UI Hookup. --- SSMP/Game/Server/ModServerManager.cs | 18 +- .../Matchmaking/Host/MmsHostSessionService.cs | 459 ++++++++++ .../Matchmaking/Host/MmsWebSocketHandler.cs | 279 ++++++ .../Matchmaking/Join/MmsJoinCoordinator.cs | 303 +++++++ .../Matchmaking/Join/UdpDiscoveryService.cs | 114 +++ SSMP/Networking/Matchmaking/MmsClient.cs | 801 +++--------------- .../Matchmaking/Parsing/MmsJsonParser.cs | 225 +++++ .../Matchmaking/Parsing/MmsResponseParser.cs | 250 ++++++ .../Matchmaking/Protocol/MmsActions.cs | 27 + .../Matchmaking/Protocol/MmsFields.cs | 97 +++ .../Matchmaking/Protocol/MmsModels.cs | 104 +++ .../Matchmaking/Protocol/MmsQueryKeys.cs | 14 + .../Matchmaking/Protocol/MmsRoutes.cs | 45 + .../Matchmaking/Query/MmsLobbyQueryService.cs | 196 +++++ .../Matchmaking/Transport/MmsHttpClient.cs | 131 +++ .../Matchmaking/Utilities/MmsUtilities.cs | 183 ++++ .../HolePunchEncryptedTransportServer.cs | 95 ++- SSMP/Ui/Component/LobbyBrowserPanel.cs | 2 +- SSMP/Ui/Component/LobbyConfigPanel.cs | 47 +- SSMP/Ui/Component/TextComponent.cs | 21 +- SSMP/Ui/ConnectInterface.cs | 301 ++++++- 21 files changed, 2901 insertions(+), 811 deletions(-) create mode 100644 SSMP/Networking/Matchmaking/Host/MmsHostSessionService.cs create mode 100644 SSMP/Networking/Matchmaking/Host/MmsWebSocketHandler.cs create mode 100644 SSMP/Networking/Matchmaking/Join/MmsJoinCoordinator.cs create mode 100644 SSMP/Networking/Matchmaking/Join/UdpDiscoveryService.cs create mode 100644 SSMP/Networking/Matchmaking/Parsing/MmsJsonParser.cs create mode 100644 SSMP/Networking/Matchmaking/Parsing/MmsResponseParser.cs create mode 100644 SSMP/Networking/Matchmaking/Protocol/MmsActions.cs create mode 100644 SSMP/Networking/Matchmaking/Protocol/MmsFields.cs create mode 100644 SSMP/Networking/Matchmaking/Protocol/MmsModels.cs create mode 100644 SSMP/Networking/Matchmaking/Protocol/MmsQueryKeys.cs create mode 100644 SSMP/Networking/Matchmaking/Protocol/MmsRoutes.cs create mode 100644 SSMP/Networking/Matchmaking/Query/MmsLobbyQueryService.cs create mode 100644 SSMP/Networking/Matchmaking/Transport/MmsHttpClient.cs create mode 100644 SSMP/Networking/Matchmaking/Utilities/MmsUtilities.cs diff --git a/SSMP/Game/Server/ModServerManager.cs b/SSMP/Game/Server/ModServerManager.cs index cc1c244..63c9c42 100644 --- a/SSMP/Game/Server/ModServerManager.cs +++ b/SSMP/Game/Server/ModServerManager.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using SSMP.Game.Command.Server; using SSMP.Game.Settings; using SSMP.Networking.Packet; @@ -58,9 +59,12 @@ public override void Initialize() { AddonManager.LoadAddons(); // Register handlers for UI events - _uiManager.RequestServerStartHostEvent += (_, port, _, transportType, _) => + _uiManager.RequestServerStartHostEvent += (_, port, _, transportType, _) => OnRequestServerStartHost(port, _modSettings.FullSynchronisation, transportType); _uiManager.RequestServerStopHostEvent += Stop; + PlayerConnectEvent += _ => UpdateMatchmakingRemotePlayerCount(); + PlayerDisconnectEvent += _ => UpdateMatchmakingRemotePlayerCount(); + ServerShutdownEvent += () => _uiManager.ConnectInterface.MmsClient.SetConnectedPlayers(0); // Register application quit handler // ModHooks.ApplicationQuitHook += Stop; @@ -97,6 +101,7 @@ private void OnRequestServerStartHost(int port, bool fullSynchronisation, Transp }; Start(port, fullSynchronisation, transportServer); + UpdateMatchmakingRemotePlayerCount(); } /// @@ -119,4 +124,15 @@ protected override void DeregisterCommands() { CommandManager.DeregisterCommand(_settingsCommand); } + + /// + /// Pushes the current remote-player count to MMS heartbeat state. + /// + private void UpdateMatchmakingRemotePlayerCount() { + var hostAuthKey = _modSettings.AuthKey; + var remotePlayerCount = hostAuthKey == null + ? 0 + : Players.Count(player => player.AuthKey != hostAuthKey); + _uiManager.ConnectInterface.MmsClient.SetConnectedPlayers(remotePlayerCount); + } } diff --git a/SSMP/Networking/Matchmaking/Host/MmsHostSessionService.cs b/SSMP/Networking/Matchmaking/Host/MmsHostSessionService.cs new file mode 100644 index 0000000..881840c --- /dev/null +++ b/SSMP/Networking/Matchmaking/Host/MmsHostSessionService.cs @@ -0,0 +1,459 @@ +using System; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using SSMP.Logging; +using SSMP.Networking.Matchmaking.Join; +using SSMP.Networking.Matchmaking.Parsing; +using SSMP.Networking.Matchmaking.Protocol; +using SSMP.Networking.Matchmaking.Transport; +using SSMP.Networking.Matchmaking.Utilities; + +namespace SSMP.Networking.Matchmaking.Host; + +/// +/// Manages the full lifecycle of the local host's MMS lobby session, including +/// lobby creation, heartbeat keep-alive, UDP discovery refresh, and clean teardown. +/// +internal sealed class MmsHostSessionService { + /// The base HTTP URL of the MMS server (e.g. https://mms.example.com). + private readonly string _baseUrl; + + /// + /// Hostname used for UDP NAT hole-punch discovery, or null if discovery + /// is disabled for this session. + /// + private readonly string? _discoveryHost; + + private readonly object _sessionLock = new(); + + /// WebSocket handler that receives real-time MMS server events. + private readonly MmsWebSocketHandler _webSocket; + + /// + /// Bearer token issued by MMS when the lobby was created. Used to authenticate + /// heartbeat and delete requests. null when no lobby is active. + /// + private string? _hostToken; + + /// + /// The MMS lobby ID of the currently active session, or null when no + /// lobby is active. + /// + private string? _currentLobbyId; + + /// + /// Timer that fires at regular intervals to keep + /// the MMS lobby alive. null when no lobby is active. + /// + private Timer? _heartbeatTimer; + + /// The number of players currently connected to this host's session. + private int _connectedPlayers; + + /// Count of consecutive heartbeat send failures observed by the timer callback. + private int _heartbeatFailureCount; + + /// + /// Cancellation source that controls the background UDP discovery refresh task. + /// null when no refresh is running. + /// + private CancellationTokenSource? _hostDiscoveryRefreshCts; + + /// + /// Initializes a new . + /// + /// Base HTTP URL of the MMS server. + /// + /// Hostname of the MMS UDP discovery endpoint, or null to disable + /// NAT hole-punch discovery. + /// + /// WebSocket handler for real-time MMS events. + public MmsHostSessionService( + string baseUrl, + string? discoveryHost, + MmsWebSocketHandler webSocket + ) { + _baseUrl = baseUrl; + _discoveryHost = discoveryHost; + _webSocket = webSocket; + } + + /// + /// Raised when MMS requests a host-mapping refresh. + /// Provides the discovery token, peer address, and a correlation timestamp. + /// Forwarded directly from . + /// + public event Action? RefreshHostMappingRequested { + add => _webSocket.RefreshHostMappingRequested += value; + remove => _webSocket.RefreshHostMappingRequested -= value; + } + + /// + /// Raised when MMS confirms that a host mapping has been received and recorded. + /// Forwarded directly from . + /// + public event Action? HostMappingReceived { + add => _webSocket.HostMappingReceived += value; + remove => _webSocket.HostMappingReceived -= value; + } + + /// + /// Raised when MMS instructs this host to begin NAT hole-punching toward a client. + /// Provides the peer token, peer address, port, punch ID, and a correlation timestamp. + /// Forwarded directly from . + /// + public event Action? StartPunchRequested { + add => _webSocket.StartPunchRequested += value; + remove => _webSocket.StartPunchRequested -= value; + } + + /// + /// Updates the number of players connected to this host and immediately sends + /// a heartbeat to MMS if the count has changed and a lobby is active. + /// Negative values are clamped to zero. + /// + /// New connected-player count. + public void SetConnectedPlayers(int count) { + var normalized = System.Math.Max(0, count); + var previous = Interlocked.Exchange(ref _connectedPlayers, normalized); + if (previous == normalized) return; + + if (_hostToken != null) SendHeartbeat(state: null); + } + + /// + /// Creates a new UDP lobby on MMS and activates the local session on success. + /// + /// UDP port this host is listening on. + /// Whether the lobby should appear in public listings. + /// Game version string used for matchmaking compatibility checks. + /// Lobby subtype (e.g. casual, ranked). + /// + /// A tuple of (lobbyCode, lobbyName, hostDiscoveryToken) on success, + /// or (null, null, null) if the request failed or the response was invalid. + /// + public async + Task<((string? lobbyCode, string? lobbyName, string? hostDiscoveryToken) result, MatchmakingError error)> + CreateLobbyAsync( + int hostPort, + bool isPublic, + string gameVersion, + PublicLobbyType lobbyType + ) { + var (buffer, length) = MmsJsonParser.FormatCreateLobbyJson( + hostPort, isPublic, gameVersion, lobbyType, MmsUtilities.GetLocalIpAddress() + ); + try { + var response = await MmsHttpClient.PostJsonAsync( + $"{_baseUrl}{MmsRoutes.Lobby}", + new string(buffer, 0, length) + ); + if (!response.Success || response.Body == null) + return ((null, null, null), response.Error); + + return TryActivateLobby( + response.Body, + "CreateLobby", + out var lobbyName, + out var lobbyCode, + out var hostDiscoveryToken + ) + ? ((lobbyCode, lobbyName, hostDiscoveryToken), MatchmakingError.None) + : ((null, null, null), MatchmakingError.NetworkFailure); + } finally { + MmsJsonParser.ReturnBuffer(buffer); + } + } + + /// + /// Registers an existing Steam lobby with MMS, creating a corresponding MMS lobby entry. + /// + /// Steam lobby identifier to associate. + /// Whether the lobby should appear in public MMS listings. + /// Game version string for matchmaking compatibility. + /// + /// The MMS lobby code on success, or null if the request failed or the + /// response was invalid. + /// + public async Task<(string? lobbyCode, MatchmakingError error)> RegisterSteamLobbyAsync( + string steamLobbyId, + bool isPublic, + string gameVersion + ) { + var response = await MmsHttpClient.PostJsonAsync( + $"{_baseUrl}{MmsRoutes.Lobby}", + BuildSteamLobbyJson(steamLobbyId, isPublic, gameVersion) + ); + if (!response.Success || response.Body == null) + return (null, response.Error); + + if (!TryActivateLobby(response.Body, "RegisterSteamLobby", out _, out var lobbyCode, out _)) + return (null, MatchmakingError.NetworkFailure); + + Logger.Info($"MmsHostSessionService: registered Steam lobby {steamLobbyId} as MMS lobby {lobbyCode}"); + return (lobbyCode, MatchmakingError.None); + } + + /// + /// Tears down the active lobby: stops the heartbeat timer, cancels UDP discovery, + /// closes the WebSocket connection, and sends a DELETE to MMS in the background. + /// Does nothing if no lobby is currently active. + /// + public void CloseLobby() { + (string token, string? lobbyId)? snapshot; + lock (_sessionLock) { + if (_hostToken == null) return; + snapshot = SnapshotAndClearSessionUnsafe(); + } + + StopHeartbeat(); + StopHostDiscoveryRefresh(); + _webSocket.Stop(); + + var (tokenSnapshot, lobbyIdSnapshot) = snapshot.Value; + _ = SafeDeleteLobbyAsync(tokenSnapshot, lobbyIdSnapshot); + } + + /// + /// Starts the WebSocket connection that receives pending-client and punch events + /// from MMS. Requires an active lobby ( must have + /// succeeded first). + /// + public void StartPendingClientPolling() { + if (_hostToken == null) { + Logger.Error("MmsHostSessionService: cannot start WebSocket without a host token"); + return; + } + + _webSocket.Start(_hostToken); + } + + /// + /// Starts a background task that sends periodic UDP discovery packets to MMS + /// for the duration of , + /// enabling MMS to learn this host's external IP and port for NAT hole-punching. + /// Any previously running refresh is stopped first. + /// Does nothing if is null. + /// + /// Session token sent inside each UDP packet. + /// + /// Callback that writes raw bytes through the caller's UDP socket to the given endpoint. + /// + public void StartHostDiscoveryRefresh(string hostDiscoveryToken, Action sendRawAction) { + if (_discoveryHost == null) return; + + StopHostDiscoveryRefresh(); + + _hostDiscoveryRefreshCts = + new CancellationTokenSource(TimeSpan.FromSeconds(MmsProtocol.DiscoveryDurationSeconds)); + var cts = _hostDiscoveryRefreshCts; + + MmsUtilities.RunBackground( + RunHostDiscoveryRefreshAsync(hostDiscoveryToken, sendRawAction, cts), + nameof(MmsHostSessionService), + "host UDP discovery" + ); + } + + /// + /// Cancels the active UDP discovery refresh task, if any. + /// Safe to call when no refresh is running. + /// + public void StopHostDiscoveryRefresh() { + _hostDiscoveryRefreshCts?.Cancel(); + _hostDiscoveryRefreshCts = null; + } + + /// + /// Builds the JSON request body for a Steam lobby registration. + /// + /// Steam lobby ID sent as ConnectionData. + /// Public-visibility flag. + /// Game version string for compatibility filtering. + /// A JSON string ready to POST to the MMS lobby endpoint. + private static string BuildSteamLobbyJson(string steamLobbyId, bool isPublic, string gameVersion) => + $"{{\"{MmsFields.ConnectionDataRequest}\":\"{MmsUtilities.EscapeJsonString(steamLobbyId)}\"," + + $"\"{MmsFields.IsPublicRequest}\":{MmsUtilities.BoolToJson(isPublic)}," + + $"\"{MmsFields.GameVersionRequest}\":\"{MmsUtilities.EscapeJsonString(gameVersion)}\"," + + $"\"{MmsFields.LobbyTypeRequest}\":\"steam\"}}"; + + /// + /// Records the active lobby ID and host token, then starts the heartbeat timer. + /// + /// MMS lobby identifier. + /// Bearer token for authenticating subsequent MMS requests. + private void ActivateLobby(string lobbyId, string hostToken) { + lock (_sessionLock) { + _hostToken = hostToken; + _currentLobbyId = lobbyId; + } + + StartHeartbeat(); + } + + /// + /// Captures the current session token and lobby ID, then clears both fields. + /// Called during to ensure the delete request uses + /// the correct values even if state is mutated concurrently. + /// + /// + /// A tuple of (hostToken, lobbyId) holding the values that were active + /// at the moment of the snapshot. + /// + private (string token, string? lobbyId) SnapshotAndClearSessionUnsafe() { + var snapshot = (_hostToken!, _currentLobbyId); + _hostToken = null; + _currentLobbyId = null; + return snapshot; + } + + /// + /// Parses an MMS lobby-activation response and, on success, calls + /// and logs the result. + /// + /// Raw JSON response body from MMS. + /// Human-readable operation name used in log messages. + /// Receives the lobby display name, or null on failure. + /// Receives the short lobby join code, or null on failure. + /// Receives the UDP discovery token, or null on failure. + /// true if parsing and activation succeeded; false otherwise. + private bool TryActivateLobby( + string response, + string operation, + out string? lobbyName, + out string? lobbyCode, + out string? hostDiscoveryToken + ) { + if (!MmsResponseParser.TryParseLobbyActivation( + response, + out var lobbyId, + out var hostToken, + out lobbyName, + out lobbyCode, + out hostDiscoveryToken + )) { + Logger.Error($"MmsHostSessionService: invalid {operation} response (length={response.Length})"); + return false; + } + + ActivateLobby(lobbyId!, hostToken!); + Logger.Info($"MmsHostSessionService: {operation} succeeded for lobby {lobbyCode}"); + return true; + } + + /// + /// Stops any existing heartbeat timer and starts a new one that fires + /// every . + /// + private void StartHeartbeat() { + StopHeartbeat(); + _heartbeatTimer = new Timer( + SendHeartbeat, null, MmsProtocol.HeartbeatIntervalMs, MmsProtocol.HeartbeatIntervalMs + ); + } + + /// + /// Disposes the heartbeat timer. Safe to call when no timer is active. + /// + private void StopHeartbeat() { + _heartbeatTimer?.Dispose(); + _heartbeatTimer = null; + } + + /// + /// Timer callback that POSTs the current connected-player count to the MMS + /// heartbeat endpoint. Fire-and-forget; failures are silently dropped. + /// + /// Unused timer state; always null. + private void SendHeartbeat(object? state) { + string? token; + lock (_sessionLock) { + token = _hostToken; + } + + if (token == null) return; + + var heartbeatTask = MmsHttpClient.PostJsonAsync( + $"{_baseUrl}{MmsRoutes.LobbyHeartbeat(token)}", + BuildHeartbeatJson(_connectedPlayers) + ); + heartbeatTask.ContinueWith( + task => { + if (task.IsFaulted) { + var failures = Interlocked.Increment(ref _heartbeatFailureCount); + Logger.Debug($"MmsHostSessionService: heartbeat send faulted ({failures} consecutive failures)"); + return; + } + + if (task.Result.Success) { + Interlocked.Exchange(ref _heartbeatFailureCount, 0); + return; + } + + var rejectedFailures = Interlocked.Increment(ref _heartbeatFailureCount); + Logger.Debug( + $"MmsHostSessionService: heartbeat rejected or failed ({rejectedFailures} consecutive failures)" + ); + }, + TaskScheduler.Default + ); + } + + /// + /// Builds the JSON body for a heartbeat POST. + /// + /// Current connected-player count to report to MMS. + /// A JSON string ready to POST to the heartbeat endpoint. + private static string BuildHeartbeatJson(int connectedPlayers) => + $"{{\"ConnectedPlayers\":{connectedPlayers}}}"; + + /// + /// Backing task for . Runs + /// and disposes + /// when it completes, regardless of outcome. + /// + /// Token forwarded to . + /// UDP send callback forwarded to . + /// + /// The that governs this refresh's lifetime. + /// Disposed here after the task ends. + /// + private async Task RunHostDiscoveryRefreshAsync( + string hostDiscoveryToken, + Action sendRawAction, + CancellationTokenSource cts + ) { + try { + if (_discoveryHost == null) return; + + await UdpDiscoveryService.SendUntilCancelledAsync( + _discoveryHost, + hostDiscoveryToken, + sendRawAction, + cts.Token + ); + } finally { + cts.Dispose(); + if (ReferenceEquals(_hostDiscoveryRefreshCts, cts)) + _hostDiscoveryRefreshCts = null; + } + } + + /// + /// Sends a DELETE to the MMS lobby endpoint. Logs success or warns on failure. + /// Intended to be called fire-and-forget after has + /// already cleared the local session state. + /// + /// Bearer token identifying the lobby to delete. + /// Lobby ID used only for logging. + private async Task SafeDeleteLobbyAsync(string hostToken, string? lobbyId) { + var response = await MmsHttpClient.DeleteAsync($"{_baseUrl}{MmsRoutes.LobbyDelete(hostToken)}"); + if (response.Success) { + Logger.Info($"MmsHostSessionService: closed lobby {lobbyId}"); + return; + } + + Logger.Warn($"MmsHostSessionService: CloseLobby DELETE failed for lobby {lobbyId}"); + } +} diff --git a/SSMP/Networking/Matchmaking/Host/MmsWebSocketHandler.cs b/SSMP/Networking/Matchmaking/Host/MmsWebSocketHandler.cs new file mode 100644 index 0000000..58bdbce --- /dev/null +++ b/SSMP/Networking/Matchmaking/Host/MmsWebSocketHandler.cs @@ -0,0 +1,279 @@ +using System; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; +using SSMP.Logging; +using SSMP.Networking.Matchmaking.Parsing; +using SSMP.Networking.Matchmaking.Protocol; +using SSMP.Networking.Matchmaking.Utilities; + +namespace SSMP.Networking.Matchmaking.Host; + +/// +/// Manages the persistent WebSocket connection between a lobby host and MMS. +/// MMS pushes control events over this channel to coordinate matchmaking flow. +/// +internal sealed class MmsWebSocketHandler : IDisposable { + /// The base WebSocket URL of the MMS service. + private readonly string _wsBaseUrl; + + /// Synchronizes swaps of the active socket/CTS pair across overlapping start-stop cycles. + private readonly object _stateGate = new(); + + /// The underlying WebSocket client. + private ClientWebSocket? _socket; + + /// Cancellation source for the background listening loop. + private CancellationTokenSource? _cts; + + /// Monotonic version used to invalidate older background runs. + private int _runVersion; + + /// + /// Raised when MMS asks the host to refresh its NAT mapping. + /// Arguments: joinId, hostDiscoveryToken, serverTimeMs. + /// + public event Action? RefreshHostMappingRequested; + + /// + /// Raised when MMS signals both sides to start simultaneous hole-punch. + /// Arguments: joinId, clientIp, clientPort, hostPort, startTimeMs. + /// + public event Action? StartPunchRequested; + + /// + /// Raised when MMS confirms the host mapping has been learned and refresh + /// packets can stop. + /// + public event Action? HostMappingReceived; + + /// + /// Initialises a new . + /// + /// Base WebSocket URL of the MMS service (e.g. wss://mms.example.com). + public MmsWebSocketHandler(string wsBaseUrl) { + _wsBaseUrl = wsBaseUrl; + } + + /// + /// Opens the WebSocket connection and begins listening for host push events. + /// Any previously active connection is stopped first. + /// Runs on a background thread; returns immediately. + /// + /// Bearer token used to authenticate the WebSocket URL. + public void Start(string hostToken) { + var runVersion = InvalidateActiveRun(); + MmsUtilities.RunBackground( + RunAsync(hostToken, runVersion), + nameof(MmsWebSocketHandler), + "host WebSocket listener" + ); + } + + /// + /// Cancels the listening loop and disposes the WebSocket connection. + /// Safe to call when no connection is active. + /// + public void Stop() { + InvalidateActiveRun(); + } + + /// + public void Dispose() => Stop(); + + /// + /// Entry point for the background task. Connects the socket, runs the + /// receive loop, then tears down and logs the disconnection. + /// + /// Bearer token used to build the WebSocket URL. + /// Generation number captured when this run was started. + private async Task RunAsync(string hostToken, int runVersion) { + var cts = new CancellationTokenSource(); + var socket = new ClientWebSocket(); + + if (!TryRegisterRun(runVersion, socket, cts)) { + cts.Dispose(); + socket.Dispose(); + return; + } + + try { + await ConnectAsync(socket, hostToken, cts.Token); + await ReceiveLoopAsync(socket, cts.Token); + } catch (Exception ex) when (ex is not OperationCanceledException) { + Logger.Error($"MmsWebSocketHandler: error - {ex.Message}"); + } finally { + TearDownSocket(runVersion, socket, cts); + } + } + + /// + /// Connects to the host WebSocket endpoint. + /// + /// The WebSocket instance owned by the current run. + /// Token appended to the WebSocket URL path. + /// Cancellation token for the connection attempt. + private async Task ConnectAsync(ClientWebSocket socket, string hostToken, CancellationToken cancellationToken) { + var uri = new Uri($"{_wsBaseUrl}{MmsRoutes.HostWebSocket(hostToken)}"); + await socket.ConnectAsync(uri, cancellationToken); + Logger.Info("MmsWebSocketHandler: connected"); + } + + /// + /// Reads messages from until the connection closes or + /// cancellation is requested. Each text frame is forwarded to + /// . + /// + /// The WebSocket instance owned by the current run. + /// Cancellation token that ends the receive loop. + private async Task ReceiveLoopAsync(ClientWebSocket socket, CancellationToken cancellationToken) { + while (socket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested) { + var (messageType, message) = await MmsUtilities.ReceiveTextMessageAsync(socket, cancellationToken); + if (messageType == WebSocketMessageType.Close) break; + if (messageType != WebSocketMessageType.Text || string.IsNullOrEmpty(message)) continue; + + HandleMessage(message); + } + } + + /// + /// Disposes and nulls the reference, then logs the + /// disconnection. Called from the finally block of . + /// + /// Generation number for the run being torn down. + /// The socket owned by that run. + /// The cancellation source owned by that run. + private void TearDownSocket(int runVersion, ClientWebSocket socket, CancellationTokenSource cts) { + lock (_stateGate) { + if (_runVersion == runVersion) { + if (ReferenceEquals(_socket, socket)) + _socket = null; + + if (ReferenceEquals(_cts, cts)) + _cts = null; + } + } + + cts.Dispose(); + socket.Dispose(); + Logger.Info("MmsWebSocketHandler: disconnected"); + } + + /// + /// Cancels any active run and returns the next valid version number. + /// + /// The generation number that should be used by the next background run. + private int InvalidateActiveRun() { + CancellationTokenSource? previousCts; + int nextVersion; + + lock (_stateGate) { + previousCts = _cts; + _cts = null; + _socket = null; + nextVersion = unchecked(++_runVersion); + } + + previousCts?.Cancel(); + return nextVersion; + } + + /// + /// Registers the run-local socket and cancellation source if the run is still current. + /// + /// Generation number captured when the run was started. + /// Socket allocated for the run. + /// Cancellation source allocated for the run. + /// if the run is still current and has become active. + private bool TryRegisterRun(int runVersion, ClientWebSocket socket, CancellationTokenSource cts) { + lock (_stateGate) { + if (_runVersion != runVersion) + return false; + + _socket = socket; + _cts = cts; + return true; + } + } + + /// + /// Extracts the action field from and + /// routes it to the appropriate handler method. + /// Unrecognised actions are silently ignored. + /// + /// Decoded UTF-8 text frame received from MMS. + private void HandleMessage(string message) { + var span = message.AsSpan(); + var action = MmsJsonParser.ExtractValue(span, MmsFields.Action); + + switch (action) { + case MmsActions.RefreshHostMapping: HandleRefreshHostMapping(span); break; + case MmsActions.StartPunch: HandleStartPunch(span); break; + case MmsActions.HostMappingReceived: HandleHostMappingReceived(); break; + case MmsActions.JoinFailed: HandleJoinFailed(message); break; + } + } + + /// + /// Handles a refresh_host_mapping message by extracting the join ID, + /// discovery token, and server timestamp, then raising + /// . Silently ignored if any required + /// field is missing or unparseable. + /// + /// Span over the raw message text. + private void HandleRefreshHostMapping(ReadOnlySpan span) { + var joinId = MmsJsonParser.ExtractValue(span, MmsFields.JoinId); + var token = MmsJsonParser.ExtractValue(span, MmsFields.HostDiscoveryToken); + var timeStr = MmsJsonParser.ExtractValue(span, MmsFields.ServerTimeMs); + + if (joinId == null || token == null || !long.TryParse(timeStr, out var time)) + return; + + Logger.Info($"MmsWebSocketHandler: {MmsActions.RefreshHostMapping} for join {joinId}"); + RefreshHostMappingRequested?.Invoke(joinId, token, time); + } + + /// + /// Handles a start_punch message by extracting the join ID, client + /// endpoint, host port, and start timestamp, then raising + /// . Silently ignored if any required field + /// is missing or unparseable. + /// + /// Span over the raw message text. + private void HandleStartPunch(ReadOnlySpan span) { + var joinId = MmsJsonParser.ExtractValue(span, MmsFields.JoinId); + var clientIp = MmsJsonParser.ExtractValue(span, MmsFields.ClientIp); + var clientPortStr = MmsJsonParser.ExtractValue(span, MmsFields.ClientPort); + var hostPortStr = MmsJsonParser.ExtractValue(span, MmsFields.HostPort); + var startTimeStr = MmsJsonParser.ExtractValue(span, MmsFields.StartTimeMs); + + if (joinId == null || + clientIp == null || + !int.TryParse(clientPortStr, out var clientPort) || + !int.TryParse(hostPortStr, out var hostPort) || + !long.TryParse(startTimeStr, out var startTimeMs)) + return; + + Logger.Info($"MmsWebSocketHandler: {MmsActions.StartPunch} for join {joinId} -> {clientIp}:{clientPort}"); + StartPunchRequested?.Invoke(joinId, clientIp, clientPort, hostPort, startTimeMs); + } + + /// + /// Handles a host_mapping_received message by logging and raising + /// . + /// + private void HandleHostMappingReceived() { + Logger.Info($"MmsWebSocketHandler: {MmsActions.HostMappingReceived}"); + HostMappingReceived?.Invoke(); + } + + /// + /// Handles a join_failed message by logging the full message body + /// as a warning. No event is raised because the host has no corrective action + /// beyond surfacing the diagnostic. + /// + /// Full raw message text, logged verbatim for diagnostics. + private static void HandleJoinFailed(string message) { + Logger.Warn($"MmsWebSocketHandler: {MmsActions.JoinFailed} - {message}"); + } +} diff --git a/SSMP/Networking/Matchmaking/Join/MmsJoinCoordinator.cs b/SSMP/Networking/Matchmaking/Join/MmsJoinCoordinator.cs new file mode 100644 index 0000000..e162e59 --- /dev/null +++ b/SSMP/Networking/Matchmaking/Join/MmsJoinCoordinator.cs @@ -0,0 +1,303 @@ +using System; +using System.Net; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; +using SSMP.Logging; +using SSMP.Networking.Matchmaking.Parsing; +using SSMP.Networking.Matchmaking.Protocol; +using SSMP.Networking.Matchmaking.Utilities; + +namespace SSMP.Networking.Matchmaking.Join; + +/// +/// Coordinates the client-side MMS matchmaking flow over a WebSocket connection. +/// Drives UDP mapping refresh when instructed by the server and returns the +/// synchronized punch-start data needed to begin NAT hole-punching. +/// +internal sealed class MmsJoinCoordinator { + /// Base HTTP URL of the MMS server (e.g. https://mms.example.com). + private readonly string _baseUrl; + + /// + /// Hostname used for UDP NAT hole-punch discovery, or null if discovery + /// is unavailable. When null, begin_client_mapping messages are + /// silently skipped. + /// + private readonly string? _discoveryHost; + + /// + /// Initialises a new . + /// + /// Base HTTP URL of the MMS server. + /// + /// Hostname of the MMS UDP discovery endpoint, or null to skip + /// NAT hole-punch discovery. + /// + public MmsJoinCoordinator(string baseUrl, string? discoveryHost) { + _baseUrl = baseUrl; + _discoveryHost = discoveryHost; + } + + /// + /// Mutable holder for the active UDP discovery , + /// allowing handler methods to update it without ref parameters. + /// + private sealed class DiscoverySession : IDisposable { + /// + /// The CTS governing the currently running discovery task, or null + /// if no discovery is active. + /// + public CancellationTokenSource? Cts; + + /// Cancels and nulls if it is set. + public void Cancel() { + Dispose(); + } + + /// Cancels and disposes if it is set. + public void Dispose() { + Cts?.Cancel(); + Cts?.Dispose(); + Cts = null; + } + } + + /// + /// Connects to the MMS join WebSocket for , processes + /// server-driven UDP mapping messages, and returns the punch-start payload once + /// the server signals it is time to begin hole-punching. + /// + /// The method drives the following server message sequence: + /// + /// begin_client_mappingStarts (or restarts) UDP discovery with the supplied client token. + /// client_mapping_receivedStops the active UDP discovery task. + /// start_punchStops discovery, parses the punch payload, waits until the scheduled start time, then returns. + /// join_failedInvokes with the server reason and returns null. + /// + /// + /// + /// Unique identifier for this join attempt, used to build the WebSocket URL. + /// + /// Callback that writes raw bytes through the caller's UDP socket to the given endpoint. + /// Forwarded to during the mapping phase. + /// + /// + /// Invoked with a human-readable reason string whenever the join attempt fails + /// (timeout, server rejection, or WebSocket error). Never invoked on success. + /// + /// + /// A containing peer address and timing + /// information, or null if the attempt failed or timed out. + /// + public async Task CoordinateAsync( + string joinId, + Action sendRawAction, + Action onJoinFailed + ) { + if (_discoveryHost == null) + Logger.Warn("MmsJoinCoordinator: discovery host unknown; UDP mapping will be skipped"); + + using var socket = new ClientWebSocket(); + using var timeoutCts = + new CancellationTokenSource(TimeSpan.FromMilliseconds(MmsProtocol.MatchmakingWebSocketTimeoutMs)); + var discovery = new DiscoverySession(); + + try { + await ConnectAsync(socket, joinId, timeoutCts.Token); + return await RunMessageLoopAsync(socket, timeoutCts, sendRawAction, discovery, onJoinFailed); + } catch (OperationCanceledException) { + onJoinFailed("timeout"); + } catch (WebSocketException ex) { + onJoinFailed(ex.Message); + Logger.Error($"MmsJoinCoordinator: matchmaking WebSocket error: {ex.Message}"); + } catch (Exception ex) { + onJoinFailed(ex.Message); + Logger.Error($"MmsJoinCoordinator: CoordinateAsync failed: {ex.Message}"); + } finally { + discovery.Dispose(); + } + + return null; + } + + /// + /// Connects to the MMS join WebSocket URL for + /// . + /// + /// The WebSocket client to connect. + /// Join session ID appended to the WebSocket path. + /// Cancellation token; typically the session timeout. + private async Task ConnectAsync(ClientWebSocket socket, string joinId, CancellationToken ct) { + var wsUrl = + $"{MmsUtilities.ToWebSocketUrl(_baseUrl)}{MmsRoutes.JoinWebSocket(joinId)}" + + $"?{MmsQueryKeys.MatchmakingVersion}={MmsProtocol.CurrentVersion}"; + + await socket.ConnectAsync(new Uri(wsUrl), ct); + } + + /// + /// Reads text frames from and dispatches each to + /// until the connection closes, the timeout fires, + /// or a terminal action (start_punch or join_failed) is received. + /// + /// The connected WebSocket client. + /// Cancellation source governing the overall session timeout. + /// UDP send callback forwarded to discovery tasks. + /// Mutable holder for the active discovery CTS. + /// Failure callback invoked when the server sends a terminal rejection reason. + /// + /// A if start_punch was received + /// and parsed successfully; null if the loop ended without a result. + /// + private async Task RunMessageLoopAsync( + ClientWebSocket socket, + CancellationTokenSource timeoutCts, + Action sendRaw, + DiscoverySession discovery, + Action onJoinFailed + ) { + while (socket.State == WebSocketState.Open && !timeoutCts.Token.IsCancellationRequested) { + var (messageType, message) = await MmsUtilities.ReceiveTextMessageAsync(socket, timeoutCts.Token); + if (messageType == WebSocketMessageType.Close) break; + if (messageType != WebSocketMessageType.Text || string.IsNullOrEmpty(message)) continue; + + var outcome = await HandleMessage(message, timeoutCts, sendRaw, discovery, onJoinFailed); + if (outcome.hasResult) return outcome.result; + } + + return null; + } + + /// + /// Extracts the action field from and routes + /// it to the appropriate handler. Returns a result tuple indicating whether + /// the loop should terminate. + /// + /// Decoded UTF-8 text frame from MMS. + /// Session timeout source, passed through to start_punch handling. + /// UDP send callback, passed through to begin_client_mapping handling. + /// Mutable holder for the active discovery CTS. + /// Failure callback invoked when the message encodes a terminal join failure. + /// + /// (true, result) when the loop should exit and return the parsed result; + /// (false, null) to continue reading. + /// + private async Task<(bool hasResult, MatchmakingJoinStartResult? result)> HandleMessage( + string message, + CancellationTokenSource timeoutCts, + Action sendRaw, + DiscoverySession discovery, + Action onJoinFailed + ) { + var action = MmsJsonParser.ExtractValue(message.AsSpan(), MmsFields.Action); + + switch (action) { + case MmsActions.BeginClientMapping: + RestartDiscovery(message, sendRaw, discovery); + break; + + case MmsActions.StartPunch: + var joinStart = await HandleStartPunchAsync(message, timeoutCts, discovery); + return (true, joinStart); + + case MmsActions.ClientMappingReceived: + discovery.Cancel(); + break; + + case MmsActions.JoinFailed: + HandleJoinFailed(message, onJoinFailed); + return (true, null); + } + + return (false, null); + } + + /// + /// Handles a begin_client_mapping message by extracting the client + /// discovery token and restarting the UDP discovery task. + /// + /// Raw message text containing the clientDiscoveryToken field. + /// UDP send callback forwarded to the new discovery task. + /// Updated with the new discovery CTS. + private void RestartDiscovery( + string message, + Action sendRaw, + DiscoverySession discovery + ) { + var token = MmsJsonParser.ExtractValue(message.AsSpan(), MmsFields.ClientDiscoveryToken); + discovery.Cancel(); + discovery.Cts = StartDiscovery(token, sendRaw); + } + + /// + /// Handles a start_punch message by cancelling discovery, parsing the + /// punch payload, and waiting until the scheduled start time. + /// + /// Raw message text containing the punch payload fields. + /// Used as the cancellation token for the start-time delay. + /// Cancelled immediately on entry. + /// + /// The parsed , or null if the + /// payload could not be parsed. + /// + private static async Task HandleStartPunchAsync( + string message, + CancellationTokenSource timeoutCts, + DiscoverySession discovery + ) { + discovery.Cancel(); + + var joinStart = MmsResponseParser.ParseStartPunch(message.AsSpan()); + if (joinStart == null) return null; + + await DelayUntilAsync(joinStart.StartTimeMs, timeoutCts.Token); + return joinStart; + } + + /// + /// Starts a new UDP discovery task for . + /// Returns null without starting anything if + /// is null or empty, or if is null. + /// + /// UDP discovery token from the begin_client_mapping message. + /// UDP send callback forwarded to . + /// + /// A new governing the started discovery + /// task, or null if discovery was not started. + /// + private CancellationTokenSource? StartDiscovery(string? token, Action sendRaw) { + if (string.IsNullOrEmpty(token) || _discoveryHost == null) + return null; + + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(MmsProtocol.DiscoveryDurationSeconds)); + MmsUtilities.RunBackground( + UdpDiscoveryService.SendUntilCancelledAsync(_discoveryHost, token, sendRaw, cts.Token), + nameof(MmsJoinCoordinator), + "client UDP discovery" + ); + return cts; + } + + /// + /// Waits until the specified Unix timestamp (in milliseconds) before returning. + /// Returns immediately if the target time is already in the past. + /// + /// Target time expressed as milliseconds since the Unix epoch (UTC). + /// Cancellation token that can abort the wait early. + private static async Task DelayUntilAsync(long targetUnixMs, CancellationToken ct) { + var delayMs = targetUnixMs - DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + if (delayMs > 0) await Task.Delay(TimeSpan.FromMilliseconds(delayMs), ct); + } + + /// + /// Extracts the server-supplied failure reason from a join_failed message, + /// forwards it to the caller, and records the payload for diagnostics. + /// + /// Raw JSON WebSocket message from MMS. + /// Callback that updates higher-level matchmaking state. + private static void HandleJoinFailed(string message, Action onJoinFailed) { + onJoinFailed(MmsJsonParser.ExtractValue(message.AsSpan(), MmsFields.Reason) ?? "join_failed"); + Logger.Warn($"MmsJoinCoordinator: {MmsActions.JoinFailed} - {message}"); + } +} diff --git a/SSMP/Networking/Matchmaking/Join/UdpDiscoveryService.cs b/SSMP/Networking/Matchmaking/Join/UdpDiscoveryService.cs new file mode 100644 index 0000000..b05441a --- /dev/null +++ b/SSMP/Networking/Matchmaking/Join/UdpDiscoveryService.cs @@ -0,0 +1,114 @@ +using System; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using SSMP.Logging; +using SSMP.Networking.Matchmaking.Protocol; + +namespace SSMP.Networking.Matchmaking.Join; + +/// +/// Sends periodic UDP packets carrying a discovery token to the MMS discovery port. +/// MMS uses the incoming packets to learn the sender's external IP and port, +/// which it then shares with the peer to enable NAT hole-punching. +/// +internal static class UdpDiscoveryService { + private const int ExpectedTokenByteLength = 32; + + /// + /// Resolves the MMS discovery endpoint and sends token bytes every + /// until cancellation. + /// + public static async Task SendUntilCancelledAsync( + string discoveryHost, + string token, + Action sendRaw, + CancellationToken cancellationToken + ) { + var endpoint = await ResolveEndpointAsync(discoveryHost); + if (endpoint is null) return; + + var tokenBytes = EncodeToken(token); + if (tokenBytes.Length != ExpectedTokenByteLength) + throw new InvalidOperationException( + $"UdpDiscoveryService: discovery token encoded to {tokenBytes.Length} bytes; expected {ExpectedTokenByteLength}." + ); + + await RunDiscoveryLoopAsync(sendRaw, tokenBytes, endpoint, cancellationToken); + } + + /// + /// Resolves to an on + /// . Returns null and logs an + /// error if DNS resolution yields no addresses. + /// + private static async Task ResolveEndpointAsync(string host) { + try { + var addresses = await Dns.GetHostAddressesAsync(host).ConfigureAwait(false); + + if (addresses is { Length: > 0 }) + return new IPEndPoint(addresses[0], MmsProtocol.DiscoveryPort); + + Logger.Error($"UdpDiscoveryService: could not resolve host '{host}'"); + return null; + } catch (Exception ex) when (ex is SocketException or OperationCanceledException) { + Logger.Warn($"UdpDiscoveryService: DNS resolution failed for '{host}': {ex.Message}"); + return null; + } + } + + /// Encodes to a UTF-8 byte array. + private static byte[] EncodeToken(string token) => + Encoding.UTF8.GetBytes(token); + + /// + /// Loops, sending to + /// every until + /// fires or a send error occurs. + /// + private static async Task RunDiscoveryLoopAsync( + Action sendRaw, + byte[] tokenBytes, + IPEndPoint endpoint, + CancellationToken cancellationToken + ) { + while (!cancellationToken.IsCancellationRequested) { + if (!TrySend(sendRaw, tokenBytes, endpoint)) return; + + if (!await TryDelayAsync(cancellationToken).ConfigureAwait(false)) return; + } + } + + /// + /// Attempts a single send. Returns false (and logs a warning) on failure. + /// + private static bool TrySend( + Action sendRaw, + byte[] tokenBytes, + IPEndPoint endpoint + ) { + try { + sendRaw(tokenBytes, endpoint); + return true; + } catch (Exception ex) when (ex is not OperationCanceledException) { + Logger.Warn($"UdpDiscoveryService: send error, aborting – {ex}"); + return false; + } + } + + /// + /// Waits for one discovery interval. Returns false when the + /// cancellation token fires (normal shutdown), true otherwise. + /// + private static async Task TryDelayAsync(CancellationToken cancellationToken) { + try { + await Task.Delay(MmsProtocol.DiscoveryIntervalMs, cancellationToken) + .ConfigureAwait(false); + return true; + } catch (OperationCanceledException) { + return false; + } + } +} diff --git a/SSMP/Networking/Matchmaking/MmsClient.cs b/SSMP/Networking/Matchmaking/MmsClient.cs index 9b11641..973df4c 100644 --- a/SSMP/Networking/Matchmaking/MmsClient.cs +++ b/SSMP/Networking/Matchmaking/MmsClient.cs @@ -1,751 +1,198 @@ using System; -using System.Buffers; using System.Collections.Generic; -using System.Net.Http; -using System.Net.WebSockets; -using System.Text; -using System.Threading; +using System.Net; using System.Threading.Tasks; using SSMP.Logging; -using System.Net.Sockets; -using System.Net; +using SSMP.Networking.Matchmaking.Host; +using SSMP.Networking.Matchmaking.Join; +using SSMP.Networking.Matchmaking.Protocol; +using SSMP.Networking.Matchmaking.Query; +using SSMP.Networking.Matchmaking.Utilities; namespace SSMP.Networking.Matchmaking; /// -/// High-performance client for the MatchMaking Service (MMS) API. -/// Handles lobby creation, lookup, heartbeat, and NAT hole-punching coordination. +/// High-level facade for MatchMaking Service (MMS) operations. +/// Keeps the existing public surface stable while delegating work to focused collaborators. +/// +/// Core collaborators: +/// +/// for host lifecycle +/// for query/read operations +/// for client join rendezvous +/// /// internal class MmsClient { - /// - /// Base URL of the MMS server (e.g., "http://localhost:5000") - /// - private readonly string _baseUrl; - - /// - /// MMS UDP discovery host derived from the configured base URL. - /// - private readonly string? _discoveryHost; - - /// - /// Authentication token for host operations (heartbeat, close, pending clients). - /// Set when a lobby is created, cleared when closed. - /// - private string? _hostToken; - - /// - /// The currently active lobby ID, if this client is hosting a lobby. - /// - private string? CurrentLobbyId { get; set; } + private readonly MmsHostSessionService _hostSession; + private readonly MmsLobbyQueryService _queries; + private readonly MmsJoinCoordinator _joinCoordinator; + private MatchmakingError _lastHttpError = MatchmakingError.None; - /// - /// Timer that sends periodic heartbeats to keep the lobby alive on the MMS. - /// Fires every 30 seconds while a lobby is active. - /// - private Timer? _heartbeatTimer; + /// The last matchmaking error from the most recent operation. + public MatchmakingError LastMatchmakingError => + _localError != MatchmakingError.None ? _localError : _lastHttpError; - /// - /// Interval between heartbeat requests (30 seconds). - /// Keeps the lobby registered and prevents timeout on the MMS. - /// - private const int HeartbeatIntervalMs = 30000; + /// Internal error state for non-HTTP failures. + private MatchmakingError _localError = MatchmakingError.None; - /// - /// HTTP request timeout in milliseconds (5 seconds). - /// Prevents hanging on unresponsive server. - /// - private const int HttpTimeoutMs = 5000; - - /// - /// WebSocket connection for receiving push notifications from MMS. - /// - private ClientWebSocket? _hostWebSocket; - - /// - /// Cancellation token source for WebSocket connection. - /// - private CancellationTokenSource? _webSocketCts; - - /// - /// Reusable empty JSON object bytes for heartbeat requests. - /// Eliminates allocations since heartbeats send no data. - /// - private static readonly byte[] EmptyJsonBytes = "{}"u8.ToArray(); - - /// - /// Shared character array pool for zero-allocation JSON string building. - /// Reuses buffers across all JSON formatting operations. - /// - private static readonly ArrayPool CharPool = ArrayPool.Shared; - - /// - /// Shared HttpClient instance for connection pooling and reuse across all MmsClient instances. - /// This provides 3-5x performance improvement over creating new connections per request. - /// Configured for optimal performance with disabled cookies, proxies, and redirects. - /// - private static readonly HttpClient HttpClient = CreateHttpClient(); - - /// The port used for UDP discovery broadcasts. - private const int DiscoveryPort = 5001; - - /// An empty JSON object body used for POST requests that require no payload. - private const string EmptyJsonBody = "{}"; - - /// - /// Creates and configures the shared HttpClient with optimal performance settings. - /// - /// Configured HttpClient instance for MMS API calls - private static HttpClient CreateHttpClient() { - // Configure handler for maximum performance - var handler = new HttpClientHandler { - // Skip proxy detection for faster connections - UseProxy = false, - // MMS doesn't use cookies - UseCookies = false, - // MMS doesn't redirect - AllowAutoRedirect = false - }; - - // Configure ServicePointManager for connection pooling (works in Unity Mono) - ServicePointManager.DefaultConnectionLimit = 10; - // Disable Nagle for lower latency - ServicePointManager.UseNagleAlgorithm = false; - // Skip 100-Continue handshake - ServicePointManager.Expect100Continue = false; + /// + public event Action? RefreshHostMappingRequested { + add => _hostSession.RefreshHostMappingRequested += value; + remove => _hostSession.RefreshHostMappingRequested -= value; + } - return new HttpClient(handler) { - Timeout = TimeSpan.FromMilliseconds(HttpTimeoutMs) - }; + /// + public event Action? HostMappingReceived { + add => _hostSession.HostMappingReceived += value; + remove => _hostSession.HostMappingReceived -= value; } - /// - /// Static constructor to hook process exit and dispose the shared HttpClient. - /// Ensures that OS-level resources are released when the host process shuts down. - /// - static MmsClient() { - AppDomain.CurrentDomain.ProcessExit += (_, _) => { HttpClient.Dispose(); }; + /// + public event Action? StartPunchRequested { + add => _hostSession.StartPunchRequested += value; + remove => _hostSession.StartPunchRequested -= value; } - /// - /// Initializes a new instance of the MmsClient. - /// - /// Base URL of the MMS server (default: "http://localhost:5000") - public MmsClient(string baseUrl = "http://localhost:5000") { - _baseUrl = baseUrl.TrimEnd('/'); + public MmsClient( + string baseUrl, + MmsHostSessionService? hostSession = null, + MmsLobbyQueryService? queries = null, + MmsJoinCoordinator? joinCoordinator = null + ) { + var normalizedBaseUrl = baseUrl.TrimEnd('/'); + string? discoveryHost = null; + if (Uri.TryCreate(normalizedBaseUrl, UriKind.Absolute, out var uri)) + discoveryHost = uri.Host; - if (Uri.TryCreate(_baseUrl, UriKind.Absolute, out var baseUri)) { - _discoveryHost = baseUri.Host; - } + _hostSession = hostSession ?? + new MmsHostSessionService( + normalizedBaseUrl, + discoveryHost, + new MmsWebSocketHandler(MmsUtilities.ToWebSocketUrl(normalizedBaseUrl)) + ); + _queries = queries ?? new MmsLobbyQueryService(normalizedBaseUrl); + _joinCoordinator = joinCoordinator ?? new MmsJoinCoordinator(normalizedBaseUrl, discoveryHost); } + /// + /// Updates the number of connected remote players. + /// Immediately sends a heartbeat if the count changed and a lobby is active, + /// so MMS can clear stale host mappings as soon as the last player disconnects. + /// + public void SetConnectedPlayers(int count) => _hostSession.SetConnectedPlayers(count); /// - /// Creates a new lobby asynchronously with configuration options. - /// Non-blocking - runs STUN discovery and HTTP request on background thread. + /// Creates a new lobby on MMS and starts the heartbeat and host WebSocket. /// - /// Local port the host is listening on. - /// Whether to list in public browser. - /// Game version for compatibility. - /// Type of lobby. - /// Task containing the lobby code, lobby name, and host discovery token if successful. + /// Lobby code, lobby name, and host discovery token; all null on failure. public async Task<(string? lobbyCode, string? lobbyName, string? hostDiscoveryToken)> CreateLobbyAsync( int hostPort, bool isPublic = true, string gameVersion = "unknown", PublicLobbyType lobbyType = PublicLobbyType.Matchmaking ) { - try { - var buffer = CharPool.Rent(512); - try { - var localIp = GetLocalIpAddress(); - var length = FormatCreateLobbyJsonPortOnly( - buffer, hostPort, isPublic, gameVersion, lobbyType, localIp - ); - Logger.Info($"MmsClient: Creating lobby on port {hostPort}, Local IP: {localIp}"); - - var json = new string(buffer, 0, length); - var response = await PostJsonAsync($"{_baseUrl}/lobby", json); - if (response == null) return (null, null, null); - - var lobbyId = ExtractJsonValueSpan(response.AsSpan(), "connectionData"); - var hostToken = ExtractJsonValueSpan(response.AsSpan(), "hostToken"); - var lobbyName = ExtractJsonValueSpan(response.AsSpan(), "lobbyName"); - var lobbyCode = ExtractJsonValueSpan(response.AsSpan(), "lobbyCode"); - var hostDiscoveryToken = ExtractJsonValueSpan(response.AsSpan(), "hostDiscoveryToken"); - - if (lobbyId == null || hostToken == null || lobbyName == null || lobbyCode == null) { - Logger.Error($"MmsClient: Invalid response from CreateLobby: {response}"); - return (null, null, null); - } - - _hostToken = hostToken; - CurrentLobbyId = lobbyId; - StartHeartbeat(); - - Logger.Info($"MmsClient: Created lobby {lobbyCode}, token {hostDiscoveryToken}"); - return (lobbyCode, lobbyName, hostDiscoveryToken); - } finally { - CharPool.Return(buffer); - } - } catch (Exception ex) { - Logger.Error($"MmsClient: Failed to create lobby: {ex.Message}"); - return (null, null, null); - } + ClearErrors(); + var result = await _hostSession.CreateLobbyAsync(hostPort, isPublic, gameVersion, lobbyType); + _lastHttpError = result.error; + return result.result; } /// - /// Registers a Steam lobby with MMS for discovery. - /// Called after creating a Steam lobby via SteamMatchmaking.CreateLobby(). + /// Registers an existing Steam lobby with MMS for discovery. /// - /// The Steam lobby ID (CSteamID as string) - /// Whether to list in public browser - /// Game version for compatibility - /// Task containing the MMS lobby ID if successful, null on failure + /// MMS lobby code, or null on failure. public async Task RegisterSteamLobbyAsync( string steamLobbyId, bool isPublic = true, string gameVersion = "unknown" ) { - try { - var json = - $"{{\"ConnectionData\":\"{steamLobbyId}\",\"IsPublic\":{(isPublic ? "true" : "false")},\"GameVersion\":\"{gameVersion}\",\"LobbyType\":\"steam\"}}"; - - var response = await PostJsonAsync($"{_baseUrl}/lobby", json); - if (response == null) return null; - - var lobbyId = ExtractJsonValueSpan(response.AsSpan(), "connectionData"); - var hostToken = ExtractJsonValueSpan(response.AsSpan(), "hostToken"); - var lobbyName = ExtractJsonValueSpan(response.AsSpan(), "lobbyName"); - var lobbyCode = ExtractJsonValueSpan(response.AsSpan(), "lobbyCode"); - - if (lobbyId == null || hostToken == null || lobbyName == null || lobbyCode == null) { - Logger.Error($"MmsClient: Invalid response from RegisterSteamLobby: {response}"); - return null; - } - - _hostToken = hostToken; - CurrentLobbyId = lobbyId; - - StartHeartbeat(); - Logger.Info($"MmsClient: Registered Steam lobby {steamLobbyId} as MMS lobby {lobbyCode}"); - return lobbyCode; - } catch (TaskCanceledException) { - Logger.Warn("MmsClient: Steam lobby registration was canceled"); - return null; - } catch (Exception ex) { - Logger.Warn($"MmsClient: Failed to register Steam lobby: {ex.Message}"); - return null; - } - } - - /// - /// Gets the list of public lobbies asynchronously. - /// Non-blocking - runs HTTP request on background thread. - /// - /// Optional: filter by Steam or Matchmaking. - /// Task containing list of public lobby info, or null on failure. - public async Task?> GetPublicLobbiesAsync(PublicLobbyType? lobbyType = null) { - try { - var url = $"{_baseUrl}/lobbies"; - if (lobbyType != null) { - url += $"?type={lobbyType.ToString().ToLower()}"; - } - - var response = await GetJsonAsync(url); - if (response == null) return null; - - var result = new List(); - var span = response.AsSpan(); - var idx = 0; - - while (idx < span.Length) { - var connStart = span[idx..].IndexOf("\"connectionData\":"); - if (connStart == -1) break; - - connStart += idx; - var connectionData = ExtractJsonValueSpan(span[connStart..], "connectionData"); - var name = ExtractJsonValueSpan(span[connStart..], "name"); - var typeString = ExtractJsonValueSpan(span[connStart..], "lobbyType"); - var code = ExtractJsonValueSpan(span[connStart..], "lobbyCode"); - - PublicLobbyType? type = null; - if (typeString != null) { - Enum.TryParse(typeString, true, out PublicLobbyType parsedType); - type = parsedType; - } - - if (connectionData != null && name != null) { - result.Add( - new PublicLobbyInfo( - connectionData, name, type ?? PublicLobbyType.Matchmaking, code ?? "" - ) - ); - } - - idx = connStart + 1; - } - - return result; - } catch (Exception ex) { - Logger.Error($"MmsClient: Failed to get public lobbies: {ex.Message}"); - return null; - } + ClearErrors(); + var result = await _hostSession.RegisterSteamLobbyAsync(steamLobbyId, isPublic, gameVersion); + _lastHttpError = result.error; + return result.lobbyCode; } + /// Closes the active lobby and deregisters it from MMS. + public void CloseLobby() => _hostSession.CloseLobby(); - /// - /// Closes the currently hosted lobby and unregisters it from the MMS. - /// Stops heartbeat and WebSocket connection. - /// - public void CloseLobby() { - if (_hostToken == null) return; - - // Stop all connections before closing - StopHeartbeat(); - StopWebSocket(); - - try { - // Send DELETE request to remove lobby from MMS (run on background thread) - Task.Run(async () => await DeleteRequestAsync($"{_baseUrl}/lobby/{_hostToken}")).Wait(HttpTimeoutMs); - Logger.Info($"MmsClient: Closed lobby {CurrentLobbyId}"); - } catch (Exception ex) { - Logger.Warn($"MmsClient: Failed to close lobby: {ex.Message}"); - } - - // Clear state - _hostToken = null; - CurrentLobbyId = null; + /// Looks up lobby join details from MMS. + public async Task JoinLobbyAsync(string lobbyId, int clientPort) { + ClearErrors(); + var result = await _queries.JoinLobbyAsync(lobbyId, clientPort); + _lastHttpError = result.error; + return result.result; } /// - /// Joins a lobby, performs NAT hole-punching, and returns host connection details. + /// Drives the client-side matchmaking WebSocket handshake. + /// Sends UDP discovery packets to establish the NAT mapping, then waits + /// for MMS to signal when both sides should begin simultaneous hole-punch. /// - /// The ID of the lobby to join - /// The local port the client is listening on - /// Host connection details (connectionData, lobbyType, and optionally lanConnectionData) or null on - /// failure - public async Task<(string connectionData, PublicLobbyType lobbyType, string? lanConnectionData, string? - clientDiscoveryToken)?> JoinLobbyAsync( - string lobbyId, - int clientPort + public async Task CoordinateMatchmakingJoinAsync( + string joinId, + Action sendRawAction ) { - try { - var jsonRequest = $"{{\"ClientIp\":null,\"ClientPort\":{clientPort}}}"; - var response = await PostJsonAsync($"{_baseUrl}/lobby/{lobbyId}/join", jsonRequest); - - if (response == null) return null; - - var buffer = CharPool.Rent(response.Length); - try { - response.CopyTo(0, buffer, 0, response.Length); - var span = buffer.AsSpan(0, response.Length); - - var connectionData = ExtractJsonValueSpan(span, "connectionData"); - var lobbyTypeString = ExtractJsonValueSpan(span, "lobbyType"); - var lanConnectionData = ExtractJsonValueSpan(span, "lanConnectionData"); - var clientDiscoveryToken = ExtractJsonValueSpan(span, "clientDiscoveryToken"); - - if (connectionData == null || lobbyTypeString == null) { - Logger.Error($"MmsClient: Invalid response from JoinLobby: {response}"); - return null; - } - - if (!Enum.TryParse(lobbyTypeString, true, out PublicLobbyType lobbyType)) { - Logger.Error($"MmsClient: Invalid lobby type from JoinLobby: {lobbyTypeString}"); - return null; - } - - Logger.Info( - $"MmsClient: Joined lobby {lobbyId}, type: {lobbyType}, connection: {connectionData}, lan: {lanConnectionData}" - ); - return (connectionData, lobbyType, lanConnectionData, clientDiscoveryToken); - } finally { - CharPool.Return(buffer); - } - } catch (Exception ex) { - Logger.Error($"MmsClient: Failed to join lobby: {ex.Message}"); - return null; - } - } - - /// - /// Event raised when a pending client needs NAT hole-punching. - /// Subscribers should send packets to the specified endpoint to punch through NAT. - /// - public static event Action? PunchClientRequested; - - /// - /// Starts WebSocket connection to MMS for receiving push notifications. - /// Should be called after creating a lobby to enable instant client notifications. - /// - public void StartPendingClientPolling() { - if (_hostToken == null) { - Logger.Error("MmsClient: Cannot start WebSocket without host token"); - return; - } - - // Run WebSocket connection on background thread - Task.Run(ConnectWebSocketAsync); - } - - /// - /// Connects to MMS WebSocket and listens for pending client notifications. - /// - private async Task ConnectWebSocketAsync() { - StopWebSocket(); // Ensure no duplicate connections - - _webSocketCts = new CancellationTokenSource(); - _hostWebSocket = new ClientWebSocket(); - - try { - // Convert http:// to ws:// - var wsUrl = _baseUrl.Replace("http://", "ws://").Replace("https://", "wss://"); - var uri = new Uri($"{wsUrl}/ws/{_hostToken}"); - - await _hostWebSocket.ConnectAsync(uri, _webSocketCts.Token); - Logger.Info($"MmsClient: WebSocket connected to MMS"); - - // Listen for messages - var buffer = new byte[1024]; - while (_hostWebSocket.State == WebSocketState.Open && !_webSocketCts.Token.IsCancellationRequested) { - var result = await _hostWebSocket.ReceiveAsync(buffer, _webSocketCts.Token); - if (result.MessageType == WebSocketMessageType.Close) break; - - if (result is { MessageType: WebSocketMessageType.Text, Count: > 0 }) { - var message = Encoding.UTF8.GetString(buffer, 0, result.Count); - HandleWebSocketMessage(message); - } - } - } catch (Exception ex) when (ex is not OperationCanceledException) { - Logger.Error($"MmsClient: WebSocket error: {ex.Message}"); - } finally { - _hostWebSocket?.Dispose(); - _hostWebSocket = null; - Logger.Info("MmsClient: WebSocket disconnected"); - } - } - - /// - /// Handles incoming WebSocket message containing pending client info. - /// - private void HandleWebSocketMessage(string message) { - // Parse JSON: {"clientIp":"x.x.x.x","clientPort":12345} - var ip = ExtractJsonValueSpan(message.AsSpan(), "clientIp"); - var portStr = ExtractJsonValueSpan(message.AsSpan(), "clientPort"); - - if (ip != null && int.TryParse(portStr, out var port)) { - Logger.Info($"MmsClient: WebSocket received pending client {ip}:{port}"); - PunchClientRequested?.Invoke(ip, port); - } - } - - /// - /// Stops WebSocket connection. - /// - private void StopWebSocket() { - _webSocketCts?.Cancel(); - _webSocketCts?.Dispose(); - _webSocketCts = null; - _hostWebSocket?.Dispose(); - _hostWebSocket = null; - } - - /// - /// Starts the heartbeat timer to keep the lobby alive on the MMS. - /// Lobbies without heartbeats expire after a timeout period. - /// - private void StartHeartbeat() { - StopHeartbeat(); // Ensure no duplicate timers - _heartbeatTimer = new Timer(SendHeartbeat, null, HeartbeatIntervalMs, HeartbeatIntervalMs); - } - - /// - /// Stops the heartbeat timer. - /// Called when lobby is closed. - /// - private void StopHeartbeat() { - _heartbeatTimer?.Dispose(); - _heartbeatTimer = null; - } - - /// - /// Timer callback that sends a heartbeat to the MMS. - /// Uses empty JSON body and reusable byte array to minimize allocations. - /// - /// Unused timer state parameter - private void SendHeartbeat(object? state) { - if (_hostToken == null) return; - - try { - // Send empty JSON body - just need to hit the endpoint (run on background thread) - Task.Run(async () => await PostJsonBytesAsync($"{_baseUrl}/lobby/heartbeat/{_hostToken}", EmptyJsonBytes)) - .Wait(HttpTimeoutMs); - } catch (Exception ex) { - Logger.Warn($"MmsClient: Heartbeat failed: {ex.Message}"); - } - } - - #region HTTP Helpers (Async with HttpClient) - - /// - /// Performs an HTTP GET request and returns the response body as a string. - /// Uses ResponseHeadersRead for efficient streaming. - /// - /// The URL to GET - /// Response body as string, or null if request failed - private static async Task GetJsonAsync(string url) { - try { - // ResponseHeadersRead allows streaming without buffering entire response - var response = await HttpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); - if (!response.IsSuccessStatusCode) return null; - - return await response.Content.ReadAsStringAsync(); - } catch (HttpRequestException) { - // Network error or invalid response - return null; - } catch (TaskCanceledException) { - // Timeout exceeded - return null; - } + ClearErrors(); + return await _joinCoordinator.CoordinateAsync(joinId, sendRawAction, SetJoinFailed); } /// - /// Performs an HTTP POST request with JSON content. + /// Fetches a list of public lobbies from MMS. /// - /// The URL to POST to - /// JSON string to send as request body - /// Response body as string - private static async Task PostJsonAsync(string url, string json) { - // StringContent handles UTF-8 encoding and sets Content-Type header - using var content = new StringContent(json, Encoding.UTF8, "application/json"); - using var response = await HttpClient.PostAsync(url, content); - return await response.Content.ReadAsStringAsync(); + public async Task?> GetPublicLobbiesAsync(PublicLobbyType? lobbyType = null) { + ClearErrors(); + var result = await _queries.GetPublicLobbiesAsync(lobbyType); + _lastHttpError = result.error; + return result.lobbies; } /// - /// Performs an HTTP POST request with pre-encoded JSON bytes. - /// More efficient than string-based version for reusable content like heartbeats. + /// Contacts MMS and verifies that its advertised matchmaking protocol version + /// matches the client's expected version. /// - /// The URL to POST to - /// JSON bytes to send as request body - /// Response body as string - private static async Task PostJsonBytesAsync(string url, byte[] jsonBytes) { - using var content = new ByteArrayContent(jsonBytes); - content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); - using var response = await HttpClient.PostAsync(url, content); - return await response.Content.ReadAsStringAsync(); + /// + /// when MMS is reachable and compatible, + /// when MMS is reachable but requires an update, + /// or when MMS could not be contacted. + /// + public async Task ProbeMatchmakingCompatibilityAsync() { + ClearErrors(); + var (isCompatible, error) = await _queries.ProbeMatchmakingCompatibilityAsync(); + _localError = error; + return isCompatible; } /// - /// Performs an HTTP DELETE request. - /// Used to close lobbies on the MMS. + /// Starts the WebSocket listener for host push events (pending clients / start-punch). + /// Must be called after creating a lobby. /// - /// The URL to DELETE - private static async Task DeleteRequestAsync(string url) { - await HttpClient.DeleteAsync(url); - } - - #endregion - - #region Zero-Allocation JSON Helpers + public void StartPendingClientPolling() => _hostSession.StartPendingClientPolling(); /// - /// Formats JSON for CreateLobby request with port only. - /// MMS will use the HTTP connection's source IP as the host address. + /// Fires off a background UDP discovery refresh for the given host token. + /// Runs for up to seconds. /// - private static int FormatCreateLobbyJsonPortOnly( - Span buffer, - int port, - bool isPublic, - string gameVersion, - PublicLobbyType lobbyType, - string? hostLanIp - ) { - var lanIpPart = hostLanIp != null ? $",\"HostLanIp\":\"{hostLanIp}:{port}\"" : ""; - var json = - $"{{\"HostPort\":{port},\"IsPublic\":{(isPublic ? "true" : "false")},\"GameVersion\":\"{gameVersion}\",\"LobbyType\":\"{lobbyType.ToString().ToLower()}\"{lanIpPart}}}"; - json.AsSpan().CopyTo(buffer); - return json.Length; - } + public void StartHostDiscoveryRefresh(string hostDiscoveryToken, Action sendRawAction) => + _hostSession.StartHostDiscoveryRefresh(hostDiscoveryToken, sendRawAction); /// - /// Extracts a JSON value by key from a JSON string using zero allocations. - /// Supports both string values (quoted) and numeric values (unquoted). + /// Stops the active host discovery refresh loop, if one is running. /// - /// JSON string to search - /// Key to find (without quotes) - /// The value as a string, or null if not found - /// - /// This is a simple parser suitable for MMS responses. It assumes well-formed JSON. - /// Searches for "key": pattern and extracts the following value. - /// - private static string? ExtractJsonValueSpan(ReadOnlySpan json, string key) { - // Build search pattern: "key": - Span searchKey = stackalloc char[key.Length + 3]; - searchKey[0] = '"'; - key.AsSpan().CopyTo(searchKey[1..]); - searchKey[key.Length + 1] = '"'; - searchKey[key.Length + 2] = ':'; - - // Find the key in JSON - var idx = json.IndexOf(searchKey, StringComparison.Ordinal); - if (idx == -1) return null; - - var valueStart = idx + searchKey.Length; - - // Skip any whitespace after the colon - while (valueStart < json.Length && char.IsWhiteSpace(json[valueStart])) - valueStart++; - - if (valueStart >= json.Length) return null; - - // Determine if value is quoted (string) or unquoted (number) - if (json[valueStart] == '"') { - // String value - find closing quote - var valueEnd = json[(valueStart + 1)..].IndexOf('"'); - return valueEnd == -1 ? null : json.Slice(valueStart + 1, valueEnd).ToString(); - } else { - // Numeric value - read until non-digit character - var valueEnd = valueStart; - while (valueEnd < json.Length && - (char.IsDigit(json[valueEnd]) || json[valueEnd] == '.' || json[valueEnd] == '-')) - valueEnd++; - return json.Slice(valueStart, valueEnd - valueStart).ToString(); - } - } - - #endregion - - /// - /// Gets the local IP address of the machine. - /// Uses a UDP socket to determine the routing to the internet to pick the correct interface. - /// Will not actually establish a connection, so used IP and port (8.8.8.8:65530) are irrelevant. - /// - private static string? GetLocalIpAddress() { - try { - using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, 0); - socket.Connect("8.8.8.8", 65530); - return (socket.LocalEndPoint as IPEndPoint)?.Address.ToString(); - } catch { - return null; - } - } + public void StopHostDiscoveryRefresh() => _hostSession.StopHostDiscoveryRefresh(); /// - /// Performs UDP port discovery by sending packets and polling the TCP verification endpoint. - /// Returns the discovered external port, or null if discovery times out. + /// Signals a join failure with a specific reason. /// - /// Discovery token issued by MMS for this host or client session. - /// Callback that sends the raw token bytes through the caller's pre-bound UDP socket. - /// The discovered external port, or null if discovery fails or times out. - public async Task PerformDiscoveryAsync( - string token, - Action sendRawAction - ) { - if (_discoveryHost is null) { - Logger.Error("MmsClient: MMS URL must use a direct IP address for UDP discovery"); - return null; - } - - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); - - var tokenBytes = Encoding.UTF8.GetBytes(token); - var encodedToken = Uri.EscapeDataString(token); - - // Resolve the host address for the MMS discovery service - var hostAddresses = await Dns.GetHostAddressesAsync(_discoveryHost); - if (hostAddresses == null || hostAddresses.Length == 0) { - Logger.Error($"MmsClient: Could not resolve discovery host ({_discoveryHost}) to IP address"); - return null; - } - - var ipEndpoint = new IPEndPoint(hostAddresses[0], DiscoveryPort); - - // Send UDP packets in background until discovery succeeds or times out - var udpTask = SendDiscoveryPacketsAsync(tokenBytes, ipEndpoint, sendRawAction, cts.Token); - - try { - while (!cts.Token.IsCancellationRequested) { - var response = await PostJsonAsync( - $"{_baseUrl}/lobby/discovery/verify/{encodedToken}", - EmptyJsonBody - ); - - if (response is not null) { - var portStr = ExtractJsonValueSpan(response.AsSpan(), "externalPort"); - - if (int.TryParse(portStr, out var port)) { - cts.Cancel(); - return port; - } - } - - await Task.Delay(1000, cts.Token); - } - } catch (OperationCanceledException) { - // Either timed out (cts expired) or canceled after successful discovery above - } finally { - // Ensure the UDP loop has exited before returning - await udpTask; - } - - return null; + private void SetJoinFailed(string reason) { + Logger.Warn($"MmsClient: matchmaking join failed – {reason}"); + _localError = MatchmakingError.JoinFailed; } /// - /// Sends discovery packets asynchronously in a loop until canceled. + /// Clears the internal and HTTP error states. /// - /// The bytes of the discovery token to send. - /// The target IP endpoint (MMS discovery port). - /// Action to send raw UDP data. - /// Token to cancel the sending loop. - /// A task representing the asynchronous operation. - private static async Task SendDiscoveryPacketsAsync( - byte[] tokenBytes, - IPEndPoint endpoint, - Action sendRawAction, - CancellationToken cancellationToken - ) { - while (!cancellationToken.IsCancellationRequested) { - try { - sendRawAction(tokenBytes, endpoint); - } catch (SocketException ex) { - Logger.Debug($"Transient socket error during UDP discovery send - Exception: {ex}"); - } catch (Exception ex) { - Logger.Warn($"Unexpected error during UDP discovery send; aborting loop - Exception: {ex}"); - return; - } - - try { - await Task.Delay(500, cancellationToken); - } catch (OperationCanceledException) { - break; - } - } + private void ClearErrors() { + _localError = MatchmakingError.None; + _lastHttpError = MatchmakingError.None; } } - -/// -/// Public lobby information for the lobby browser. -/// -public record PublicLobbyInfo( - // IP:Port for Matchmaking, Steam lobby ID for Steam - string ConnectionData, - string Name, - PublicLobbyType LobbyType, - string LobbyCode -); - -/// -/// Enum for public lobby types. -/// -public enum PublicLobbyType { - /// - /// Standalone matchmaking through MMS. - /// - Matchmaking, - /// - /// Steam matchmaking through MMS. - /// - Steam -} diff --git a/SSMP/Networking/Matchmaking/Parsing/MmsJsonParser.cs b/SSMP/Networking/Matchmaking/Parsing/MmsJsonParser.cs new file mode 100644 index 0000000..b8fc52f --- /dev/null +++ b/SSMP/Networking/Matchmaking/Parsing/MmsJsonParser.cs @@ -0,0 +1,225 @@ +using System; +using System.Buffers; +using System.Globalization; +using System.Text; +using SSMP.Networking.Matchmaking.Protocol; +using SSMP.Networking.Matchmaking.Utilities; + +namespace SSMP.Networking.Matchmaking.Parsing; + +/// +/// Lightweight JSON helpers for reading and writing MMS API payloads. +/// These avoid a full JSON library dependency and are intentionally simple: +/// they assume well-formed server responses and handle only the small set +/// of value types (quoted strings, integers) that MMS actually returns. +/// +internal static class MmsJsonParser { + /// Shared pool for minimizing character buffer allocations. + private static readonly ArrayPool CharPool = ArrayPool.Shared; + + /// + /// Finds "key":value in and returns the value as a string. + /// Supports both quoted string values and unquoted numeric values. + /// Returns null when the key is absent. + /// + /// The JSON span to search. + /// The key to find. + /// The extracted value or null. + public static string? ExtractValue(ReadOnlySpan json, string key) { + Span searchKey = stackalloc char[key.Length + 2]; + searchKey[0] = '"'; + key.AsSpan().CopyTo(searchKey[1..]); + searchKey[key.Length + 1] = '"'; + + var searchStart = 0; + while (searchStart < json.Length) { + var relative = json[searchStart..].IndexOf(searchKey, StringComparison.Ordinal); + if (relative == -1) return null; + + var keyEnd = searchStart + relative + searchKey.Length; + var valueStart = MmsUtilities.SkipWhitespace(json, keyEnd); + if (valueStart >= json.Length || json[valueStart] != ':') { + searchStart = searchStart + relative + 1; + continue; + } + + valueStart = MmsUtilities.SkipWhitespace(json, valueStart + 1); + if (valueStart >= json.Length) return null; + + return json[valueStart] == '"' + ? ExtractStringValue(json, valueStart) + : ExtractNumericValue(json, valueStart); + } + + return null; + } + + /// + /// Writes the CreateLobby JSON payload into a rented char buffer. + /// Returns the number of characters written. + /// The caller must return the buffer to after use. + /// + /// The host port. + /// Whether the lobby is public. + /// The game version string. + /// The type of the lobby. + /// Optional local IP address. + /// A tuple containing the buffer and the number of characters written. + public static (char[] buffer, int length) FormatCreateLobbyJson( + int port, + bool isPublic, + string gameVersion, + PublicLobbyType lobbyType, + string? hostLanIp + ) { + var escapedGameVersion = MmsUtilities.EscapeJsonString(gameVersion); + var escapedHostLanIp = hostLanIp == null ? null : MmsUtilities.EscapeJsonString(hostLanIp); + var lobbyTypeValue = lobbyType == PublicLobbyType.Matchmaking ? "matchmaking" : "steam"; + var estimatedLength = + 96 + + escapedGameVersion.Length + + lobbyTypeValue.Length + + (escapedHostLanIp?.Length ?? 0) + + (hostLanIp != null ? 16 : 0) + + (lobbyType == PublicLobbyType.Matchmaking ? 24 : 0); + + var buffer = CharPool.Rent(estimatedLength); + var span = buffer.AsSpan(); + var written = 0; + + Write(span, ref written, "{\""); + Write(span, ref written, MmsFields.HostPortRequest); + Write(span, ref written, "\":"); + Write(span, ref written, port); + Write(span, ref written, ",\""); + Write(span, ref written, MmsFields.IsPublicRequest); + Write(span, ref written, "\":"); + Write(span, ref written, MmsUtilities.BoolToJson(isPublic)); + Write(span, ref written, ",\""); + Write(span, ref written, MmsFields.GameVersionRequest); + Write(span, ref written, "\":\""); + Write(span, ref written, escapedGameVersion); + Write(span, ref written, "\",\""); + Write(span, ref written, MmsFields.LobbyTypeRequest); + Write(span, ref written, "\":\""); + Write(span, ref written, lobbyTypeValue); + Write(span, ref written, "\""); + + if (hostLanIp != null) { + Write(span, ref written, ",\""); + Write(span, ref written, MmsFields.HostLanIpRequest); + Write(span, ref written, "\":\""); + Write(span, ref written, escapedHostLanIp!); + Write(span, ref written, ":"); + Write(span, ref written, port); + Write(span, ref written, "\""); + } + + if (lobbyType == PublicLobbyType.Matchmaking) { + Write(span, ref written, ",\""); + Write(span, ref written, MmsFields.MatchmakingVersionRequest); + Write(span, ref written, "\":"); + Write(span, ref written, MmsProtocol.CurrentVersion); + } + + Write(span, ref written, "}"); + return (buffer, written); + } + + /// + /// Returns a char buffer to the shared array pool. + /// + public static void ReturnBuffer(char[] buffer) => CharPool.Return(buffer); + + /// + /// Extracts a quoted string value starting at the opening ". + /// Returns null if the closing quote is missing. + /// + private static string? ExtractStringValue(ReadOnlySpan json, int openQuoteIndex) { + var segmentStart = openQuoteIndex + 1; + StringBuilder? builder = null; + + for (var i = segmentStart; i < json.Length; i++) { + if (json[i] == '\\') { + if (i + 1 >= json.Length) return null; + if (builder == null) builder = new StringBuilder(json.Slice(segmentStart, i - segmentStart).ToString()); + else builder.Append(json.Slice(segmentStart, i - segmentStart)); + var escape = json[++i]; + switch (escape) { + case '"': builder.Append('"'); break; + case '\\': builder.Append('\\'); break; + case '/': builder.Append('/'); break; + case 'b': builder.Append('\b'); break; + case 'f': builder.Append('\f'); break; + case 'n': builder.Append('\n'); break; + case 'r': builder.Append('\r'); break; + case 't': builder.Append('\t'); break; + case 'u': + if (i + 4 >= json.Length) return null; + + var hex = json.Slice(i + 1, 4); + if (!ushort.TryParse(hex, NumberStyles.HexNumber, null, out var codePoint)) + return null; + + builder.Append((char) codePoint); + i += 4; + break; + default: + throw new FormatException($"Invalid JSON escape sequence \\{escape} at index {i}."); + } + + segmentStart = i + 1; + continue; + } + + if (json[i] != '"') continue; + + if (builder == null) + return json.Slice(openQuoteIndex + 1, i - openQuoteIndex - 1).ToString(); + + builder.Append(json.Slice(segmentStart, i - segmentStart)); + return builder.ToString(); + } + + return null; + } + + /// + /// Extracts an unquoted numeric value (digits, ., -) starting at + /// . Returns an empty string if no numeric characters are found. + /// + private static string ExtractNumericValue(ReadOnlySpan json, int start) { + var end = start; + while (end < json.Length && + (char.IsDigit(json[end]) || json[end] == '.' || json[end] == '-')) { + end++; + } + + return json.Slice(start, end - start).ToString(); + } + + /// + /// Copies a string value into the destination buffer at the current write position. + /// + /// The character buffer to write into. + /// The current write position; incremented by the length of . + /// The string value to copy. + private static void Write(Span destination, ref int written, ReadOnlySpan value) { + value.CopyTo(destination[written..]); + written += value.Length; + } + + /// + /// Formats an integer value into the destination buffer at the current write position. + /// + /// The character buffer to write into. + /// The current write position; incremented by the number of characters written. + /// The integer value to format. + /// Thrown when the integer cannot be formatted into the remaining buffer space. + private static void Write(Span destination, ref int written, int value) { + if (!value.TryFormat(destination[written..], out var charsWritten)) + throw new InvalidOperationException("Could not format MMS JSON integer."); + + written += charsWritten; + } +} diff --git a/SSMP/Networking/Matchmaking/Parsing/MmsResponseParser.cs b/SSMP/Networking/Matchmaking/Parsing/MmsResponseParser.cs new file mode 100644 index 0000000..942d1da --- /dev/null +++ b/SSMP/Networking/Matchmaking/Parsing/MmsResponseParser.cs @@ -0,0 +1,250 @@ +using System; +using System.Collections.Generic; +using SSMP.Networking.Matchmaking.Protocol; + +namespace SSMP.Networking.Matchmaking.Parsing; + +/// +/// Parses raw MMS JSON response bodies into typed matchmaking models. +/// All methods are allocation-minimal where possible, operating on +/// slices via . +/// +internal static class MmsResponseParser { + private const string ConnectionDataKey = $"\"{MmsFields.ConnectionData}\":"; + + /// + /// Parses a lobby creation or Steam-lobby registration response into the + /// fields required to activate a host session. + /// + /// Raw JSON response body from the MMS /lobby endpoint. + /// + /// Receives the lobby's connection data string (used internally as the lobby ID), + /// or null if absent. + /// + /// + /// Receives the bearer token used to authenticate subsequent heartbeat and delete + /// requests, or null if absent. + /// + /// + /// Receives the human-readable lobby display name, or null if absent. + /// + /// + /// Receives the short alphanumeric join code shown to players, or null if absent. + /// + /// + /// Receives the UDP discovery token sent during NAT hole-punch mapping, + /// or null if the server did not include one (e.g. for Steam lobbies). + /// + /// + /// true if all required fields (, + /// , , and + /// ) were present; false otherwise. + /// Note that being null does not + /// cause a failure. + /// + public static bool TryParseLobbyActivation( + string response, + out string? lobbyId, + out string? hostToken, + out string? lobbyName, + out string? lobbyCode, + out string? hostDiscoveryToken + ) { + var span = response.AsSpan(); + lobbyId = MmsJsonParser.ExtractValue(span, MmsFields.ConnectionData); + hostToken = MmsJsonParser.ExtractValue(span, MmsFields.HostToken); + lobbyName = MmsJsonParser.ExtractValue(span, MmsFields.LobbyName); + lobbyCode = MmsJsonParser.ExtractValue(span, MmsFields.LobbyCode); + hostDiscoveryToken = MmsJsonParser.ExtractValue(span, MmsFields.HostDiscoveryToken); + return lobbyId != null && hostToken != null && lobbyName != null && lobbyCode != null; + } + + /// + /// Parses a /lobby/{id}/join response into a . + /// + /// Raw JSON response body from the MMS join endpoint. + /// + /// A populated on success, or null if + /// connectionData or lobbyType are missing, or if lobbyType + /// cannot be mapped to a known value. + /// + public static JoinLobbyResult? ParseJoinLobbyResult(string response) { + var span = response.AsSpan(); + + return !TryExtractJoinRequiredFields(span, out var connectionData, out var lobbyType) + ? null + : BuildJoinLobbyResult(span, connectionData!, lobbyType); + } + + /// + /// Parses the public lobby list response from the MMS /lobbies endpoint. + /// Entries are extracted by scanning for successive "connectionData" keys, + /// treating each occurrence as the start of a new lobby object. + /// Entries missing connectionData or name are silently skipped. + /// An unrecognised lobbyType defaults to . + /// + /// Raw JSON response body containing a JSON array of lobby objects. + /// + /// A list of entries. Returns an empty list if the + /// response contains no parseable lobbies. + /// + public static List ParsePublicLobbies(string response) { + var result = new List(); + var span = response.AsSpan(); + var idx = 0; + + while (TryFindNextLobbySlice(span, ref idx, out var slice)) { + var entry = TryParsePublicLobbyEntry(slice); + if (entry != null) result.Add(entry); + } + + return result; + } + + /// + /// Parses the start_punch WebSocket message received during the join + /// rendezvous phase, extracting the host endpoint and the scheduled punch start time. + /// + /// + /// A over the raw UTF-8 decoded message text. + /// + /// + /// A containing the host IP, port, and + /// Unix-millisecond start timestamp, or null if any required field + /// (hostIp, hostPort, startTimeMs) is missing or unparseable. + /// + public static MatchmakingJoinStartResult? ParseStartPunch(ReadOnlySpan span) { + return !TryExtractPunchFields(span, out var hostIp, out var hostPort, out var startTimeMs) + ? null + : new MatchmakingJoinStartResult { HostIp = hostIp!, HostPort = hostPort, StartTimeMs = startTimeMs }; + } + + /// + /// Extracts and validates the two required fields for a join response: + /// connectionData and a parseable lobbyType. + /// + /// Span over the raw response text. + /// Receives the connection string, or null on failure. + /// Receives the parsed on success. + /// true if both fields were present and valid; false otherwise. + private static bool TryExtractJoinRequiredFields( + ReadOnlySpan span, + out string? connectionData, + out PublicLobbyType lobbyType + ) { + connectionData = MmsJsonParser.ExtractValue(span, MmsFields.ConnectionData); + var lobbyTypeString = MmsJsonParser.ExtractValue(span, MmsFields.LobbyType); + + if (connectionData == null || lobbyTypeString == null) { + lobbyType = default; + return false; + } + + return Enum.TryParse(lobbyTypeString, true, out lobbyType); + } + + /// + /// Constructs a from a validated span, populating + /// all optional fields alongside the required ones. + /// + /// Span over the raw response text. + /// Pre-validated connection string. + /// Pre-validated lobby type. + private static JoinLobbyResult BuildJoinLobbyResult( + ReadOnlySpan span, + string connectionData, + PublicLobbyType lobbyType + ) => new() { + ConnectionData = connectionData, + LobbyType = lobbyType, + LanConnectionData = MmsJsonParser.ExtractValue(span, MmsFields.LanConnectionData), + JoinId = MmsJsonParser.ExtractValue(span, MmsFields.JoinId) + }; + + /// + /// Advances to the next "connectionData": key in + /// and returns the sub-span starting at that key. + /// + /// The full response span being scanned. + /// + /// Current scan position. Updated to one character past the found key so the + /// next call advances past the current entry. + /// + /// + /// Receives a sub-span beginning at the found key, suitable for field extraction. + /// Empty when the method returns false. + /// + /// true if another entry was found; false when the scan is exhausted. + private static bool TryFindNextLobbySlice( + ReadOnlySpan span, + ref int idx, + out ReadOnlySpan slice + ) { + var relative = span[idx..].IndexOf(ConnectionDataKey, StringComparison.Ordinal); + if (relative == -1) { + slice = default; + return false; + } + + var start = idx + relative; + var nextRelative = span[(start + ConnectionDataKey.Length)..] + .IndexOf(ConnectionDataKey, StringComparison.Ordinal); + var end = nextRelative == -1 ? span.Length : start + ConnectionDataKey.Length + nextRelative; + slice = span[start..end]; + idx = end; + return true; + } + + /// + /// Parses a single lobby object from a span that starts at its + /// "connectionData": key. Returns null if either + /// connectionData or name are absent. + /// An unrecognised lobbyType defaults to . + /// + /// Sub-span starting at the lobby object's connectionData key. + /// A , or null if required fields are missing. + private static PublicLobbyInfo? TryParsePublicLobbyEntry(ReadOnlySpan slice) { + var connectionData = MmsJsonParser.ExtractValue(slice, MmsFields.ConnectionData); + var name = MmsJsonParser.ExtractValue(slice, MmsFields.Name); + + if (connectionData == null || name == null) return null; + + var typeString = MmsJsonParser.ExtractValue(slice, MmsFields.LobbyType); + var code = MmsJsonParser.ExtractValue(slice, MmsFields.LobbyCode); + + var type = PublicLobbyType.Matchmaking; + if (typeString != null) Enum.TryParse(typeString, true, out type); + + return new PublicLobbyInfo(connectionData, name, type, code ?? ""); + } + + /// + /// Extracts and validates all three required fields from a start_punch + /// message: hostIp, hostPort, and startTimeMs. + /// + /// Span over the raw message text. + /// Receives the host IP string, or null on failure. + /// Receives the parsed host port on success; 0 on failure. + /// Receives the parsed start timestamp on success; 0 on failure. + /// true if all three fields were present and parseable; false otherwise. + private static bool TryExtractPunchFields( + ReadOnlySpan span, + out string? hostIp, + out int hostPort, + out long startTimeMs + ) { + hostIp = MmsJsonParser.ExtractValue(span, MmsFields.HostIp); + var hostPortStr = MmsJsonParser.ExtractValue(span, MmsFields.HostPort); + var startTimeStr = MmsJsonParser.ExtractValue(span, MmsFields.StartTimeMs); + + if (hostIp == null || + !int.TryParse(hostPortStr, out hostPort) || + !long.TryParse(startTimeStr, out startTimeMs)) { + hostPort = 0; + startTimeMs = 0; + return false; + } + + return true; + } +} diff --git a/SSMP/Networking/Matchmaking/Protocol/MmsActions.cs b/SSMP/Networking/Matchmaking/Protocol/MmsActions.cs new file mode 100644 index 0000000..119debf --- /dev/null +++ b/SSMP/Networking/Matchmaking/Protocol/MmsActions.cs @@ -0,0 +1,27 @@ +namespace SSMP.Networking.Matchmaking.Protocol; + +/// +/// WebSocket action field values exchanged between the MMS server +/// and both host and client connections. +/// +internal static class MmsActions +{ + /// Sent by the server to a joining client to initiate its NAT mapping process. + public const string BeginClientMapping = "begin_client_mapping"; + + /// Sent by the server to a host to request it to start hole punching towards a client. + public const string StartPunch = "start_punch"; + + /// Sent by the client to the server once its NAT mapping has been successfully determined. + public const string ClientMappingReceived = "client_mapping_received"; + + /// Sent by the server to a client when a join request has failed. + public const string JoinFailed = "join_failed"; + + /// Sent by the server to a host to request a refresh of its NAT mapping. + public const string RefreshHostMapping = "refresh_host_mapping"; + + /// Sent by the host to the server once its NAT mapping has been successfully refreshed or determined. + public const string HostMappingReceived = "host_mapping_received"; + +} diff --git a/SSMP/Networking/Matchmaking/Protocol/MmsFields.cs b/SSMP/Networking/Matchmaking/Protocol/MmsFields.cs new file mode 100644 index 0000000..eac7fff --- /dev/null +++ b/SSMP/Networking/Matchmaking/Protocol/MmsFields.cs @@ -0,0 +1,97 @@ +namespace SSMP.Networking.Matchmaking.Protocol; + +/// +/// JSON property names used in MMS request and response bodies. +/// +internal static class MmsFields +{ + /// The action being performed in a WebSocket message. + public const string Action = "action"; + + /// The error code returned in a failed response. + public const string ErrorCode = "errorCode"; + + /// A machine-readable failure reason returned in an error or control message. + public const string Reason = "reason"; + + /// The version of the protocol or application. + public const string Version = "version"; + + /// A general-purpose name field (e.g., player name). + public const string Name = "name"; + + /// The current server time in milliseconds. + public const string ServerTimeMs = "serverTimeMs"; + + /// The start time of an event in milliseconds. + public const string StartTimeMs = "startTimeMs"; + + /// Opaque connection data used for NAT traversal. + public const string ConnectionData = "connectionData"; + + /// A unique token identifying a host session. + public const string HostToken = "hostToken"; + + /// The display name of the lobby. + public const string LobbyName = "lobbyName"; + + /// A short code used to join a specific lobby. + public const string LobbyCode = "lobbyCode"; + + /// A token used by the host for discovery purposes. + public const string HostDiscoveryToken = "hostDiscoveryToken"; + + /// The type of lobby (e.g., Public, Private). + public const string LobbyType = "lobbyType"; + + /// Connection data specifically for LAN connections. + public const string LanConnectionData = "lanConnectionData"; + + /// A token used by the client for discovery purposes. + public const string ClientDiscoveryToken = "clientDiscoveryToken"; + + /// A unique identifier for a join attempt. + public const string JoinId = "joinId"; + + /// The public IP address of the host. + public const string HostIp = "hostIp"; + + /// The public port of the host. + public const string HostPort = "hostPort"; + + /// The public IP address of the client. + public const string ClientIp = "clientIp"; + + /// The public port of the client. + public const string ClientPort = "clientPort"; + + // Request-specific fields (often used in HTTP POST bodies) + + /// The port the host is listening on (Request field). + public const string HostPortRequest = "HostPort"; + + /// Whether the lobby is public (Request field). + public const string IsPublicRequest = "IsPublic"; + + /// The version of the game (Request field). + public const string GameVersionRequest = "GameVersion"; + + /// The type of lobby (Request field). + public const string LobbyTypeRequest = "LobbyType"; + + /// The LAN IP address of the host (Request field). + public const string HostLanIpRequest = "HostLanIp"; + + /// Opaque connection data (Request field). + public const string ConnectionDataRequest = "ConnectionData"; + + /// The IP address of the client (Request field). + public const string ClientIpRequest = "ClientIp"; + + /// The port of the client (Request field). + public const string ClientPortRequest = "ClientPort"; + + /// The version of the matchmaking system (Request field). + public const string MatchmakingVersionRequest = "MatchmakingVersion"; + +} diff --git a/SSMP/Networking/Matchmaking/Protocol/MmsModels.cs b/SSMP/Networking/Matchmaking/Protocol/MmsModels.cs new file mode 100644 index 0000000..10efb3e --- /dev/null +++ b/SSMP/Networking/Matchmaking/Protocol/MmsModels.cs @@ -0,0 +1,104 @@ +namespace SSMP.Networking.Matchmaking.Protocol; + +/// +/// Constants and configuration for the MatchMaking Service (MMS) protocol. +/// +internal static class MmsProtocol { + /// The current version of the matchmaking protocol. + public const int CurrentVersion = 1; + + /// Error code returned by MMS when the client version is too old. + public const string UpdateRequiredErrorCode = "update_required"; + + /// Interval for sending heartbeats to the server. + public const int HeartbeatIntervalMs = 30_000; + + /// Timeout for HTTP requests to the MMS API. + public const int HttpTimeoutMs = 5_000; + + /// The UDP port used for NAT discovery. + public const int DiscoveryPort = 6001; + + /// Timeout for the client-side matchmaking WebSocket handshake. + public const int MatchmakingWebSocketTimeoutMs = 20_000; + + /// Duration for which UDP discovery packets are sent. + public const int DiscoveryDurationSeconds = 15; + + /// Interval between individual UDP discovery packets. + public const int DiscoveryIntervalMs = 500; +} + +/// +/// Represents errors that can occur during matchmaking operations. +/// +internal enum MatchmakingError { + /// No error. + None, + + /// The client matchmaking version is outdated. + UpdateRequired, + + /// The join operation failed (e.g. timeout or invalid ID). + JoinFailed, + + /// A generic network error occurred. + NetworkFailure +} + +/// +/// Defines the types of lobbies supported by MMS. +/// +public enum PublicLobbyType { + /// Standalone matchmaking through MMS. + Matchmaking, + + /// Steam matchmaking through MMS. + Steam +} + +/// +/// Result of a successful lobby join request. +/// +internal sealed class JoinLobbyResult { + /// The connection string for the lobby (e.g. "IP:Port" or Steam ID). + public required string ConnectionData { get; init; } + + /// The type of the lobby. + public required PublicLobbyType LobbyType { get; init; } + + /// Optional LAN connection string for local play. + public string? LanConnectionData { get; init; } + + /// Unique ID for the join session. + public string? JoinId { get; init; } +} + +/// +/// Result of a matchmaking join coordination. +/// +internal sealed class MatchmakingJoinStartResult { + /// The resolved public IP of the host. + public required string HostIp { get; init; } + + /// The resolved public port of the host. + public required int HostPort { get; init; } + + /// The Unix timestamp (ms) when both sides should start punching. + public required long StartTimeMs { get; init; } +} + +/// +/// Public lobby information for the lobby browser. +/// +/// The connection string for the lobby (e.g. "IP:Port" or Steam ID). +/// The display name of the lobby. +/// The type of the lobby (e.g. Matchmaking or Steam). +/// A short alphanumeric code used to join the lobby. +public record PublicLobbyInfo( + string ConnectionData, + string Name, + PublicLobbyType LobbyType, + string LobbyCode +); + diff --git a/SSMP/Networking/Matchmaking/Protocol/MmsQueryKeys.cs b/SSMP/Networking/Matchmaking/Protocol/MmsQueryKeys.cs new file mode 100644 index 0000000..e5270e8 --- /dev/null +++ b/SSMP/Networking/Matchmaking/Protocol/MmsQueryKeys.cs @@ -0,0 +1,14 @@ +namespace SSMP.Networking.Matchmaking.Protocol; + +/// +/// Query-string parameter keys appended to MMS request URLs. +/// +internal static class MmsQueryKeys +{ + /// The type of lobby (e.g. Matchmaking or Steam). + public const string Type = "type"; + + /// The version of the matchmaking protocol being used by the client. + public const string MatchmakingVersion = "matchmakingVersion"; + +} diff --git a/SSMP/Networking/Matchmaking/Protocol/MmsRoutes.cs b/SSMP/Networking/Matchmaking/Protocol/MmsRoutes.cs new file mode 100644 index 0000000..3fcece1 --- /dev/null +++ b/SSMP/Networking/Matchmaking/Protocol/MmsRoutes.cs @@ -0,0 +1,45 @@ +namespace SSMP.Networking.Matchmaking.Protocol; + +/// +/// MMS REST and WebSocket route segments. Use the static helper methods to +/// build parameterised paths; use the constants directly for fixed routes. +/// +internal static class MmsRoutes { + /// MMS health-check and version endpoint. + public const string Root = "/health"; + + /// Base path for all lobby operations. + public const string Lobby = "/lobby"; + + /// Public lobby listing endpoint. + public const string Lobbies = "/lobbies"; + + /// Base path for all WebSocket connections. + private const string WebSocketBase = "/ws"; + + /// Builds the URL path for a client to join a specific lobby. + /// The unique identifier or short code of the lobby. + /// The formatted join route. + public static string LobbyJoin(string lobbyId) => $"{Lobby}/{lobbyId}/join"; + + /// Builds the URL path for a host to send a heartbeat for its lobby. + /// The token identifying the host session. + /// The formatted heartbeat route. + public static string LobbyHeartbeat(string hostToken) => $"{Lobby}/heartbeat/{hostToken}"; + + /// Builds the URL path for a host to delete its lobby. + /// The token identifying the host session. + /// The formatted delete route. + public static string LobbyDelete(string hostToken) => $"{Lobby}/{hostToken}"; + + /// Builds the WebSocket path for a client to connect for matchmaking coordination. + /// The unique join attempt identifier. + /// The formatted WebSocket join route. + public static string JoinWebSocket(string joinId) => $"{WebSocketBase}/join/{joinId}"; + + /// Builds the WebSocket path for a host to connect for matchmaking coordination. + /// The token identifying the host session. + /// The formatted WebSocket host route. + public static string HostWebSocket(string hostToken) => $"{WebSocketBase}/{hostToken}"; +} + diff --git a/SSMP/Networking/Matchmaking/Query/MmsLobbyQueryService.cs b/SSMP/Networking/Matchmaking/Query/MmsLobbyQueryService.cs new file mode 100644 index 0000000..595452d --- /dev/null +++ b/SSMP/Networking/Matchmaking/Query/MmsLobbyQueryService.cs @@ -0,0 +1,196 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using SSMP.Logging; +using SSMP.Networking.Matchmaking.Parsing; +using SSMP.Networking.Matchmaking.Protocol; +using SSMP.Networking.Matchmaking.Transport; + +namespace SSMP.Networking.Matchmaking.Query; + +/// +/// Handles non-host MMS queries: joining an existing lobby, browsing public lobbies, +/// and probing server compatibility before attempting matchmaking. +/// +internal sealed class MmsLobbyQueryService { + /// Base HTTP URL of the MMS server (e.g. https://mms.example.com). + private readonly string _baseUrl; + + /// + /// Initializes a new . + /// + /// Base HTTP URL of the MMS server. + public MmsLobbyQueryService(string baseUrl) { + _baseUrl = baseUrl; + } + + /// + /// Sends a join request for to MMS, advertising + /// the client's local UDP port so MMS can facilitate NAT hole-punching. + /// + /// The MMS lobby identifier to join. + /// The local UDP port this client is listening on. + /// + /// A containing the lobby type, connection data, + /// and join ID needed for the subsequent WebSocket rendezvous, or null + /// if the request failed or the response could not be parsed. + /// + public async Task<(JoinLobbyResult? result, MatchmakingError error)> + JoinLobbyAsync(string lobbyId, int clientPort) { + var response = await MmsHttpClient.PostJsonAsync( + $"{_baseUrl}{MmsRoutes.LobbyJoin(lobbyId)}", + BuildJoinRequestJson(clientPort) + ); + if (!response.Success || response.Body == null) + return (null, response.Error); + + return (ParseAndLogJoinResult(lobbyId, response.Body), MatchmakingError.None); + } + + /// + /// Retrieves the list of currently open public lobbies from MMS, optionally + /// filtered by lobby type. + /// + /// + /// When non-null, restricts results to lobbies of the specified + /// . When null, all public lobbies are returned. + /// Matchmaking lobbies are additionally filtered by . + /// + /// + /// A list of entries, or null if the + /// request failed or the response could not be parsed. + /// + public async Task<(List? lobbies, MatchmakingError error)> GetPublicLobbiesAsync( + PublicLobbyType? lobbyType = null + ) { + var url = BuildPublicLobbiesUrl(lobbyType); + var response = await MmsHttpClient.GetAsync(url); + return !response.Success || response.Body == null + ? (null, response.Error) + : (MmsResponseParser.ParsePublicLobbies(response.Body), MatchmakingError.None); + } + + /// + /// Probes the MMS server's health endpoint to verify that the client and server + /// are running a compatible protocol version. + /// + /// + /// A tuple of: + /// + /// + /// isCompatible + /// + /// true if the server version matches ; + /// false if a version mismatch was detected; + /// null if the server could not be reached or returned an unparseable response. + /// + /// + /// + /// error + /// + /// on success or unreachable server; + /// if the response lacked a valid version field; + /// if the protocol versions differ. + /// + /// + /// + /// + public async Task<(bool? isCompatible, MatchmakingError error)> ProbeMatchmakingCompatibilityAsync() { + var response = await MmsHttpClient.GetAsync($"{_baseUrl}{MmsRoutes.Root}"); + if (!response.Success || response.Body == null) + return (null, response.Error); + + if (!TryParseServerVersion(response.Body, out var serverVersion)) + return (null, MatchmakingError.NetworkFailure); + + return CheckVersionCompatibility(serverVersion); + } + + /// + /// Builds the JSON request body for a lobby join, advertising the client's + /// local port and protocol version. ClientIp is sent as null; + /// MMS infers the external IP from the incoming socket address. + /// + /// Local UDP port the client is listening on. + /// A JSON string ready to POST to the join endpoint. + private static string BuildJoinRequestJson(int clientPort) => + $"{{\"{MmsFields.ClientIpRequest}\":null," + + $"\"{MmsFields.ClientPortRequest}\":{clientPort}," + + $"\"{MmsFields.MatchmakingVersionRequest}\":{MmsProtocol.CurrentVersion}}}"; + + /// + /// Parses the join response and logs the outcome. Returns the result on + /// success or logs an error and returns null on parse failure. + /// + /// Lobby ID, used only for log messages. + /// Raw JSON response body from the join endpoint. + /// A populated , or null if parsing failed. + private static JoinLobbyResult? ParseAndLogJoinResult(string lobbyId, string response) { + var joinResult = MmsResponseParser.ParseJoinLobbyResult(response); + if (joinResult == null) { + Logger.Error( + $"MmsLobbyQueryService: invalid JoinLobby response (length={response.Length}, hasJoinId={response.Contains(MmsFields.JoinId)}, hasConnectionData={response.Contains(MmsFields.ConnectionData)})" + ); + return null; + } + + Logger.Info($"MmsLobbyQueryService: joined lobby {lobbyId}, type={joinResult.LobbyType}"); + return joinResult; + } + + /// + /// Extracts and parses the version field from an MMS health response. + /// Logs a warning and returns false if the field is absent or non-numeric. + /// + /// Raw JSON health response body. + /// Receives the parsed version number on success; 0 on failure. + /// true if a valid integer version was found; false otherwise. + private static bool TryParseServerVersion(string response, out int serverVersion) { + var versionString = MmsJsonParser.ExtractValue(response.AsSpan(), MmsFields.Version); + if (int.TryParse(versionString, out serverVersion)) return true; + + Logger.Warn("MmsLobbyQueryService: MMS health response did not include a valid protocol version"); + return false; + } + + /// + /// Compares against + /// and returns the appropriate + /// compatibility result. Logs a warning on mismatch. + /// + /// Protocol version reported by the MMS health endpoint. + /// + /// (true, None) if versions match; + /// (false, UpdateRequired) if they differ. + /// + private static (bool? isCompatible, MatchmakingError error) CheckVersionCompatibility(int serverVersion) { + if (serverVersion == MmsProtocol.CurrentVersion) + return (true, MatchmakingError.None); + + Logger.Warn( + $"MmsLobbyQueryService: MMS protocol mismatch " + + $"(client={MmsProtocol.CurrentVersion}, server={serverVersion})" + ); + return (false, MatchmakingError.UpdateRequired); + } + + /// + /// Builds the public lobbies query URL, appending a type filter when + /// is specified and a matchmakingVersion + /// parameter when the type is . + /// + /// + /// Optional lobby type filter. null returns the unfiltered lobbies URL. + /// + /// The fully constructed URL string ready for an HTTP GET request. + private string BuildPublicLobbiesUrl(PublicLobbyType? lobbyType) { + var url = $"{_baseUrl}{MmsRoutes.Lobbies}"; + if (lobbyType == null) return url; + + url += $"?{MmsQueryKeys.Type}={lobbyType.ToString()!.ToLowerInvariant()}"; + if (lobbyType == PublicLobbyType.Matchmaking) + url += $"&{MmsQueryKeys.MatchmakingVersion}={MmsProtocol.CurrentVersion}"; + + return url; + } +} diff --git a/SSMP/Networking/Matchmaking/Transport/MmsHttpClient.cs b/SSMP/Networking/Matchmaking/Transport/MmsHttpClient.cs new file mode 100644 index 0000000..cf270fe --- /dev/null +++ b/SSMP/Networking/Matchmaking/Transport/MmsHttpClient.cs @@ -0,0 +1,131 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using SSMP.Networking.Matchmaking.Parsing; +using SSMP.Networking.Matchmaking.Protocol; + +namespace SSMP.Networking.Matchmaking.Transport; + +/// +/// Thin HTTP transport layer for MMS API calls. +/// Owns a single shared instance for connection-pool reuse +/// and surfaces typed success/error results to callers. +/// +internal static class MmsHttpClient { + /// Shared HTTP client instance for connection pooling. + private static readonly HttpClient Http = CreateHttpClient(); + + static MmsHttpClient() { + AppDomain.CurrentDomain.ProcessExit += (_, _) => Http.Dispose(); + } + + /// + /// Performs a GET request to the specified URL. + /// + public static async Task GetAsync(string url) { + try { + using var response = await Http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); + var body = await response.Content.ReadAsStringAsync(); + return new MmsHttpResponse( + response.IsSuccessStatusCode, + body, + InspectErrorBody(response.StatusCode, body) + ); + } catch (Exception ex) when (IsTransient(ex)) { + return new MmsHttpResponse(false, null, MatchmakingError.NetworkFailure); + } + } + + /// + /// Performs a POST request with a JSON body to the specified URL. + /// + public static async Task PostJsonAsync(string url, string json) { + try { + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + using var response = await Http.PostAsync(url, content); + var body = await response.Content.ReadAsStringAsync(); + return new MmsHttpResponse( + response.IsSuccessStatusCode, + body, + InspectErrorBody(response.StatusCode, body) + ); + } catch (Exception ex) when (IsTransient(ex)) { + return new MmsHttpResponse(false, null, MatchmakingError.NetworkFailure); + } + } + + /// + /// Performs a DELETE request. + /// + /// + /// Returns per-call HTTP metadata and matchmaking error classification for both + /// successful and failed requests. Transport failures are reported as + /// without throwing. + /// + public static async Task DeleteAsync(string url) { + try { + using var response = await Http.DeleteAsync(url); + var body = await response.Content.ReadAsStringAsync(); + return new MmsHttpResponse( + response.IsSuccessStatusCode, + body, + InspectErrorBody(response.StatusCode, body) + ); + } catch (Exception ex) when (IsTransient(ex)) { + return new MmsHttpResponse(false, null, MatchmakingError.NetworkFailure); + } + } + + /// + /// Checks the response body for MMS-specific error codes. + /// + /// The HTTP status code. + /// The response body. + private static MatchmakingError InspectErrorBody(HttpStatusCode status, string? body) { + if ((int) status < 400 || body == null) return MatchmakingError.None; + + try { + var errorCode = MmsJsonParser.ExtractValue(body.AsSpan(), MmsFields.ErrorCode); + return errorCode == MmsProtocol.UpdateRequiredErrorCode + ? MatchmakingError.UpdateRequired + : MatchmakingError.NetworkFailure; + } catch (FormatException) { + return MatchmakingError.NetworkFailure; + } + } + + /// + /// Determines if an exception represents a transient network issue. + /// + /// The exception to check. + /// true if transient; otherwise, false. + private static bool IsTransient(Exception ex) => + ex is HttpRequestException or TaskCanceledException; + + /// + /// Configures and returns an optimized instance. + /// + /// A new instance. + private static HttpClient CreateHttpClient() { + var handler = new HttpClientHandler { + UseProxy = false, + UseCookies = false, + AllowAutoRedirect = false, + MaxConnectionsPerServer = 10 + }; + + var client = new HttpClient(handler) { + Timeout = TimeSpan.FromMilliseconds(MmsProtocol.HttpTimeoutMs) + }; + client.DefaultRequestHeaders.ExpectContinue = false; + return client; + } +} + +internal readonly record struct MmsHttpResponse( + bool Success, + string? Body, + MatchmakingError Error +); diff --git a/SSMP/Networking/Matchmaking/Utilities/MmsUtilities.cs b/SSMP/Networking/Matchmaking/Utilities/MmsUtilities.cs new file mode 100644 index 0000000..82c799a --- /dev/null +++ b/SSMP/Networking/Matchmaking/Utilities/MmsUtilities.cs @@ -0,0 +1,183 @@ +using System; +using System.Buffers; +using System.Net; +using System.Net.Sockets; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using SSMP.Logging; + +namespace SSMP.Networking.Matchmaking.Utilities; + +/// +/// General-purpose utility helpers shared across MMS components. +/// All methods are stateless and free of side-effects. +/// +internal static class MmsUtilities { + /// + /// Converts an HTTP or HTTPS URL to its WebSocket equivalent. + /// http:// -> ws:// and https:// -> wss://. + /// + public static string ToWebSocketUrl(string httpUrl) { + if (!Uri.TryCreate(httpUrl, UriKind.Absolute, out var uri)) + throw new ArgumentException("Matchmaking URL must be an absolute URI.", nameof(httpUrl)); + + var scheme = uri.Scheme switch { + "http" => "ws", + "https" => "wss", + _ => throw new ArgumentException("Matchmaking URL must use http or https.", nameof(httpUrl)) + }; + + var builder = new UriBuilder(uri) { Scheme = scheme }; + return builder.Uri.AbsoluteUri.TrimEnd('/'); + } + + /// + /// Returns the JSON literal for a boolean value: "true" or "false". + /// + public static string BoolToJson(bool value) => value ? "true" : "false"; + + /// + /// Observes a fire-and-forget task and logs unexpected failures. + /// + /// The task to monitor. + /// Component name included in failure logs. + /// Human-readable operation label for diagnostics. + public static void RunBackground(Task task, string owner, string operationName) => + _ = ObserveAsync(task, owner, operationName); + + /// + /// Reads one complete text message from a , assembling fragmented frames. + /// + /// The connected client WebSocket to read from. + /// Cancellation token for the receive loop. + /// Maximum allowed payload size before the read fails. + /// + /// A tuple containing the terminal frame type and the decoded text payload. + /// Non-text messages and close frames return as the payload. + /// + public static async Task<(WebSocketMessageType messageType, string? message)> ReceiveTextMessageAsync( + ClientWebSocket socket, + CancellationToken cancellationToken, + int maxMessageBytes = 16 * 1024 + ) { + const int chunkSize = 1024; + + var buffer = new byte[chunkSize]; + var writer = new ArrayBufferWriter(); + + while (true) { + var frame = await socket.ReceiveAsync(new ArraySegment(buffer), cancellationToken); + if (frame.MessageType == WebSocketMessageType.Close) + return (frame.MessageType, null); + + AppendFrame(writer, buffer, frame.Count, maxMessageBytes); + + if (!frame.EndOfMessage) + continue; + + return frame.MessageType != WebSocketMessageType.Text + ? (frame.MessageType, null) + : (frame.MessageType, + writer.WrittenCount == 0 ? string.Empty : Encoding.UTF8.GetString(writer.WrittenSpan)); + } + } + + /// + /// Determines the local machine's outbound IPv4 address by connecting a + /// disposable UDP socket to a known external address. Does not transmit any data. + /// + /// + /// The local IP address as a string, or null if the address could not + /// be determined (e.g. no network interface available). + /// + public static string? GetLocalIpAddress() { + try { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, 0); + socket.Connect("8.8.8.8", 65530); + return (socket.LocalEndPoint as IPEndPoint)?.Address.ToString(); + } catch { + return null; + } + } + + /// + /// Awaits a background task and suppresses expected cancellation while logging unexpected failures. + /// + /// The task being observed. + /// Component name included in failure logs. + /// Human-readable operation label for diagnostics. + private static async Task ObserveAsync(Task task, string owner, string operationName) { + try { + await task.ConfigureAwait(false); + } catch (OperationCanceledException) { + /*ignored*/ + } catch (Exception ex) { + Logger.Warn($"{owner}: {operationName} failed: {ex.Message}"); + } + } + + /// + /// Appends a single received frame into the accumulating message buffer while enforcing the message size cap. + /// + /// The destination buffer holding the message assembled so far. + /// Scratch receive buffer containing the latest frame bytes. + /// Number of valid bytes currently in . + /// Maximum total message size allowed. + private static void AppendFrame(ArrayBufferWriter writer, byte[] buffer, int count, int maxMessageBytes) { + if (count <= 0) + return; + + var nextLength = writer.WrittenCount + count; + if (nextLength > maxMessageBytes) + throw new InvalidOperationException("Matchmaking WebSocket message exceeded the maximum size."); + + buffer.AsSpan(0, count).CopyTo(writer.GetSpan(count)); + writer.Advance(count); + } + + + /// + /// Advances an index past any whitespace characters in a JSON span. + /// + /// The JSON character span being parsed. + /// The position to start skipping from. + /// The index of the first non-whitespace character, or .Length if the rest of the span is whitespace. + public static int SkipWhitespace(ReadOnlySpan json, int index) { + while (index < json.Length && char.IsWhiteSpace(json[index])) + index++; + + return index; + } + + /// + /// Escapes a string for safe embedding in JSON, encoding special characters + /// and non-printable control characters as their JSON escape sequences. + /// + /// The raw string to escape. + /// A JSON-safe escaped string, without surrounding quotes. + public static string EscapeJsonString(string value) { + var builder = new StringBuilder(value.Length); + foreach (var ch in value) { + switch (ch) { + case '"': builder.Append("\\\""); break; + case '\\': builder.Append(@"\\"); break; + case '/': builder.Append("\\/"); break; + case '\b': builder.Append("\\b"); break; + case '\f': builder.Append("\\f"); break; + case '\n': builder.Append("\\n"); break; + case '\r': builder.Append("\\r"); break; + case '\t': builder.Append("\\t"); break; + default: + if (char.IsControl(ch)) + builder.AppendFormat("\\u{0:X4}", (int) ch); + else + builder.Append(ch); + break; + } + } + + return builder.ToString(); + } +} diff --git a/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransportServer.cs b/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransportServer.cs index 62dd9d7..a99127c 100644 --- a/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransportServer.cs +++ b/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransportServer.cs @@ -2,7 +2,6 @@ using System.Collections.Concurrent; using System.Net; using System.Net.Sockets; -using System.Threading; using System.Threading.Tasks; using SSMP.Logging; using SSMP.Networking.Matchmaking; @@ -67,27 +66,33 @@ public HolePunchEncryptedTransportServer(MmsClient? mmsClient = null) { /// public void Start(int port) { Logger.Info($"HolePunch Server: Starting on port {port}"); - - // Subscribe to punch coordination - MmsClient.PunchClientRequested += OnPunchClientRequested; + + if (_mmsClient != null) { + _mmsClient.RefreshHostMappingRequested += OnHostMappingRefreshRequested; + _mmsClient.HostMappingReceived += OnHostMappingReceived; + _mmsClient.StartPunchRequested += OnStartPunchRequested; + } var socket = PreBoundSocket; PreBoundSocket = null; _dtlsServer.Start(port, socket); + + _mmsClient?.StartPendingClientPolling(); } /// public void Stop() { Logger.Info("HolePunch Server: Stopping"); - - // Close MMS lobby if we have an MMS client - _mmsClient?.CloseLobby(); - _mmsClient = null; - - // Unsubscribe from punch coordination - MmsClient.PunchClientRequested -= OnPunchClientRequested; - + + if (_mmsClient != null) { + _mmsClient.RefreshHostMappingRequested -= OnHostMappingRefreshRequested; + _mmsClient.HostMappingReceived -= OnHostMappingReceived; + _mmsClient.StartPunchRequested -= OnStartPunchRequested; + _mmsClient.CloseLobby(); + _mmsClient = null; + } + _dtlsServer.Stop(); _clients.Clear(); } @@ -95,13 +100,14 @@ public void Stop() { /// /// Called when MMS notifies us of a client needing punch-back. /// - private void OnPunchClientRequested(string clientIp, int clientPort) { + private void OnStartPunchRequested(string joinId, string clientIp, int clientPort, int hostPort, long startTimeMs) { + _mmsClient?.StopHostDiscoveryRefresh(); if (!IPAddress.TryParse(clientIp, out var ip)) { Logger.Warn($"HolePunch Server: Invalid client IP: {clientIp}"); return; } - - PunchToClient(new IPEndPoint(ip, clientPort)); + + _ = PunchToClientAsync(new IPEndPoint(ip, clientPort), startTimeMs); } /// @@ -113,25 +119,6 @@ public void DisconnectClient(IEncryptedTransportClient client) { _clients.TryRemove(hpClient.EndPoint, out _); } - /// - /// Initiates hole punch to a client that wants to connect. - /// Uses the DTLS server's socket so the punch comes from the correct port. - /// - /// The client's public endpoint. - private void PunchToClient(IPEndPoint clientEndpoint) { - // Run on background thread to avoid blocking the calling thread for 5 seconds - Task.Run(() => { - Logger.Debug($"HolePunch Server: Punching to client at {clientEndpoint}"); - - for (var i = 0; i < PunchPacketCount; i++) { - _dtlsServer.SendRaw(PunchPacket, clientEndpoint); - Thread.Sleep(PunchPacketDelayMs); - } - - Logger.Info($"HolePunch Server: Punch packets sent to {clientEndpoint}"); - }); - } - /// /// Callback method for when data is received from a server client. /// @@ -144,4 +131,44 @@ private void OnClientDataReceived(DtlsServerClient dtlsClient, byte[] data, int client.RaiseDataReceived(data, length); } + + /// + /// Called when MMS asks the host to refresh its matchmaking mapping on the live UDP server socket. + /// + private void OnHostMappingRefreshRequested(string joinId, string hostDiscoveryToken, long serverTimeMs) { + Logger.Info($"HolePunch Server: Refreshing host mapping for join {joinId}"); + _mmsClient?.StartHostDiscoveryRefresh(hostDiscoveryToken, (data, endpoint) => _dtlsServer.SendRaw(data, endpoint)); + } + + /// + /// Called when MMS confirms it has learned the host's current external mapping. + /// + private void OnHostMappingReceived() { + Logger.Info("HolePunch Server: Host mapping learned by MMS, stopping refresh"); + _mmsClient?.StopHostDiscoveryRefresh(); + } + + /// + /// Sends UDP packets to , + /// spaced ms apart, starting at . + /// Exceptions are caught and logged rather than propagated, since this runs fire-and-forget. + /// + private async Task PunchToClientAsync(IPEndPoint clientEndpoint, long startTimeMs) + { + try + { + Logger.Debug($"HolePunch Server: Punching to client at {clientEndpoint}"); + var delay = startTimeMs - DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + if (delay > 0) await Task.Delay(TimeSpan.FromMilliseconds(delay)); + + for (var i = 0; i < PunchPacketCount; i++) + { + _dtlsServer.SendRaw(PunchPacket, clientEndpoint); + await Task.Delay(PunchPacketDelayMs); + } + + Logger.Info($"HolePunch Server: Punch complete to {clientEndpoint}"); + } + catch (Exception ex) { Logger.Error($"HolePunch Server: Punch to {clientEndpoint} failed – {ex.Message}"); } + } } diff --git a/SSMP/Ui/Component/LobbyBrowserPanel.cs b/SSMP/Ui/Component/LobbyBrowserPanel.cs index 8addc21..8e95ede 100644 --- a/SSMP/Ui/Component/LobbyBrowserPanel.cs +++ b/SSMP/Ui/Component/LobbyBrowserPanel.cs @@ -1,6 +1,6 @@ using System; using System.Collections.Generic; -using SSMP.Networking.Matchmaking; +using SSMP.Networking.Matchmaking.Protocol; using UnityEngine; using UnityEngine.UI; using Object = UnityEngine.Object; diff --git a/SSMP/Ui/Component/LobbyConfigPanel.cs b/SSMP/Ui/Component/LobbyConfigPanel.cs index bc00f05..41246f3 100644 --- a/SSMP/Ui/Component/LobbyConfigPanel.cs +++ b/SSMP/Ui/Component/LobbyConfigPanel.cs @@ -1,5 +1,5 @@ using System; -using SSMP.Networking.Matchmaking; +using SSMP.Networking.Matchmaking.Protocol; using UnityEngine; using UnityEngine.UI; using Object = UnityEngine.Object; @@ -293,51 +293,6 @@ private static GameObject CreateText( return go; } - private static GameObject CreateInputField(Vector2 pos, float width, float height, string placeholder) { - var go = new GameObject("InputField"); - var rect = go.AddComponent(); - rect.anchorMin = rect.anchorMax = new Vector2(0.5f, 1f); - rect.pivot = new Vector2(0.5f, 0.5f); - rect.anchoredPosition = new Vector2(pos.x, pos.y - height / 2f); - rect.sizeDelta = new Vector2(width, height); - - var bg = go.AddComponent(); - bg.color = new Color(0.15f, 0.15f, 0.18f, 1f); - - var textGo = new GameObject("Text"); - var textRect = textGo.AddComponent(); - textRect.anchorMin = Vector2.zero; - textRect.anchorMax = Vector2.one; - textRect.offsetMin = new Vector2(10f, 5f); - textRect.offsetMax = new Vector2(-10f, -5f); - var text = textGo.AddComponent(); - text.font = Resources.FontManager.UIFontRegular; - text.fontSize = 14; - text.color = Color.white; - text.alignment = TextAnchor.MiddleLeft; - textGo.transform.SetParent(go.transform, false); - - var placeholderGo = new GameObject("Placeholder"); - var phRect = placeholderGo.AddComponent(); - phRect.anchorMin = Vector2.zero; - phRect.anchorMax = Vector2.one; - phRect.offsetMin = new Vector2(10f, 5f); - phRect.offsetMax = new Vector2(-10f, -5f); - var phText = placeholderGo.AddComponent(); - phText.font = Resources.FontManager.UIFontRegular; - phText.fontSize = 14; - phText.color = new Color(0.5f, 0.5f, 0.5f, 1f); - phText.alignment = TextAnchor.MiddleLeft; - phText.text = placeholder; - placeholderGo.transform.SetParent(go.transform, false); - - var input = go.AddComponent(); - input.textComponent = text; - input.placeholder = phText; - - return go; - } - private static GameObject CreateButton( string text, Vector2 pos, diff --git a/SSMP/Ui/Component/TextComponent.cs b/SSMP/Ui/Component/TextComponent.cs index fd3b2f3..24935f5 100644 --- a/SSMP/Ui/Component/TextComponent.cs +++ b/SSMP/Ui/Component/TextComponent.cs @@ -17,8 +17,9 @@ public TextComponent( string text, int fontSize, FontStyle fontStyle = FontStyle.Normal, - TextAnchor alignment = TextAnchor.MiddleCenter - ) : this(componentGroup, position, size, new Vector2(0.5f, 0.5f), text, fontSize, fontStyle, alignment) { + TextAnchor alignment = TextAnchor.MiddleCenter, + bool wrap = false + ) : this(componentGroup, position, size, new Vector2(0.5f, 0.5f), text, fontSize, fontStyle, alignment, wrap) { } public TextComponent( @@ -29,9 +30,10 @@ public TextComponent( string text, int fontSize, FontStyle fontStyle = FontStyle.Normal, - TextAnchor alignment = TextAnchor.MiddleCenter + TextAnchor alignment = TextAnchor.MiddleCenter, + bool wrap = false ) : base(componentGroup, position, size) { - _textObject = CreateTextObject(text, fontSize, fontStyle, alignment, pivot); + _textObject = CreateTextObject(text, fontSize, fontStyle, alignment, pivot, wrap); AddSizeFitter(); AddOutline(); } @@ -85,7 +87,14 @@ public float GetPreferredWidth() { /// Create the Unity Text object with all the parameters. /// /// The Unity object. - private Text CreateTextObject(string text, int fontSize, FontStyle fontStyle, TextAnchor alignment, Vector2 pivot) { + private Text CreateTextObject( + string text, + int fontSize, + FontStyle fontStyle, + TextAnchor alignment, + Vector2 pivot, + bool wrap + ) { var textObj = GameObject.AddComponent(); textObj.supportRichText = true; @@ -94,7 +103,7 @@ private Text CreateTextObject(string text, int fontSize, FontStyle fontStyle, Te textObj.fontSize = fontSize; textObj.fontStyle = fontStyle; textObj.alignment = alignment; - textObj.horizontalOverflow = HorizontalWrapMode.Overflow; + textObj.horizontalOverflow = wrap ? HorizontalWrapMode.Wrap : HorizontalWrapMode.Overflow; textObj.verticalOverflow = VerticalWrapMode.Overflow; textObj.rectTransform.pivot = pivot; textObj.raycastTarget = false; // do not block input diff --git a/SSMP/Ui/ConnectInterface.cs b/SSMP/Ui/ConnectInterface.cs index e9e450b..eaf9f73 100644 --- a/SSMP/Ui/ConnectInterface.cs +++ b/SSMP/Ui/ConnectInterface.cs @@ -6,6 +6,7 @@ using SSMP.Game.Settings; using SSMP.Networking.Client; using SSMP.Networking.Matchmaking; +using SSMP.Networking.Matchmaking.Protocol; using SSMP.Ui.Component; using Steamworks; using SSMP.Networking.Transport.Common; @@ -158,6 +159,11 @@ internal class ConnectInterface { /// private const float FeedbackTextOffset = 310f; + /// + /// Height of the bottom feedback area so longer matchmaking messages can wrap cleanly. + /// + private const float FeedbackTextHeight = 72f; + #endregion #region UI Text Constants @@ -329,6 +335,22 @@ internal class ConnectInterface { /// private const string ErrorUnknown = "Failed to connect:\nUnknown reason"; + /// + /// Large blocking message shown when the client must update before using matchmaking. + /// + private const string MatchmakingUpdateRequiredText = + "Please update to the latest version in order to use matchmaking!"; + + /// + /// Temporary message shown while the client verifies matchmaking compatibility. + /// + private const string MatchmakingCheckingText = "Checking matchmaking compatibility..."; + + /// + /// Blocking message shown when MMS cannot be reached, so matchmaking stays unavailable. + /// + private const string MatchmakingUnavailableText = "Unable to contact matchmaking server right now."; + #endregion #region Fields @@ -473,6 +495,31 @@ internal class ConnectInterface { /// private Coroutine? _feedbackHideCoroutine; + /// + /// Whether the current bottom feedback message is being driven by matchmaking status UI. + /// + private bool _isMatchmakingFeedbackActive; + + /// + /// Whether MMS has reported that this client is too old for matchmaking. + /// + private bool _isMatchmakingVersionBlocked; + + /// + /// Whether the UI is currently verifying matchmaking compatibility with MMS. + /// + private bool _isCheckingMatchmakingVersion; + + /// + /// Whether MMS has been reached successfully and the matchmaking version check passed. + /// + private bool _isMatchmakingReady; + + /// + /// Tracks the currently selected tab so async UI refreshes preserve the active view. + /// + private Tab _activeTab = Tab.Matchmaking; + /// /// Public accessor for the MMS client. /// Used by server manager to pass to HolePunch transport for lobby cleanup. @@ -650,6 +697,7 @@ public ConnectInterface(ModSettings modSettings, ComponentGroup connectGroup) { FinalizeLayout(); + BeginMatchmakingVersionCheck(); SwitchTab(Tab.Matchmaking); } @@ -1028,11 +1076,12 @@ private ITextComponent CreateFeedbackText(float contentY) { var feedback = new TextComponent( _backgroundGroup, new Vector2(InitialX, contentY), - new Vector2(ContentWidth, LabelHeight), + new Vector2(ContentWidth, FeedbackTextHeight), new Vector2(0.5f, 1f), "", UiManager.SubTextFontSize, - alignment: TextAnchor.UpperCenter + alignment: TextAnchor.UpperCenter, + wrap: true ); feedback.SetActive(false); @@ -1082,6 +1131,8 @@ _directConnectButton is Component.Component connectComp && /// /// The tab to activate. private void SwitchTab(Tab tab) { + _activeTab = tab; + // Hide lobby browsers and config panels if visible _lobbyBrowserPanel.Hide(); _steamLobbyBrowserPanel?.Hide(); @@ -1093,9 +1144,15 @@ private void SwitchTab(Tab tab) { _directIpTab.SetTabActive(tab == Tab.DirectIp); // Show only the active tab's content - _matchmakingGroup.SetActive(tab == Tab.Matchmaking); + _matchmakingGroup.SetActive( + tab == Tab.Matchmaking && + _isMatchmakingReady && + !_isMatchmakingVersionBlocked && + !_isCheckingMatchmakingVersion + ); _steamGroup?.SetActive(tab == Tab.Steam); _directIpGroup.SetActive(tab == Tab.DirectIp); + RefreshMatchmakingStatusFeedback(); } /// @@ -1105,6 +1162,10 @@ private void SwitchTab(Tab tab) { public void SetMenuActive(bool active) { _backgroundPanel.SetActive(active); _glowingNotch.SetActive(active); + + if (active && !_isMatchmakingVersionBlocked && !_isCheckingMatchmakingVersion) { + BeginMatchmakingVersionCheck(); + } } #endregion @@ -1116,6 +1177,10 @@ public void SetMenuActive(bool active) { /// Looks up lobby via MMS and connects to the host. /// private void OnLobbyConnectButtonPressed() { + if (IsMatchmakingBlocked()) { + return; + } + if (!ValidateUsername(out var username)) { return; } @@ -1144,27 +1209,76 @@ private IEnumerator JoinLobbyCoroutine(string lobbyId, string username) { var task = MmsClient.JoinLobbyAsync(lobbyId, clientPort); yield return new WaitUntil(() => task.IsCompleted); + if (!task.IsCompletedSuccessfully) { + CleanupHolePunchSocket(holePunchSocket); + Logger.Error( + $"ConnectInterface: JoinLobbyAsync failed: {task.Exception?.GetBaseException().Message ?? "cancelled"}" + ); + ShowFeedback(Color.red, "Lobby not found, offline, or join failed"); + yield break; + } + var lobbyInfo = task.Result; if (lobbyInfo == null) { CleanupHolePunchSocket(holePunchSocket); + + if (MmsClient.LastMatchmakingError == MatchmakingError.UpdateRequired) { + ActivateMatchmakingVersionBlock(); + yield break; + } + ShowFeedback(Color.red, "Lobby not found, offline, or join failed"); yield break; } - var (connectionData, lobbyType, lanConnectionData, clientDiscoveryToken) = lobbyInfo.Value; - - yield return RunDiscoveryCoroutine( - holePunchSocket, - clientDiscoveryToken, - "MmsClient: UDP discovery timed out, falling back to local port" - ); + var connectionData = lobbyInfo.ConnectionData; + var lobbyType = lobbyInfo.LobbyType; + var lanConnectionData = lobbyInfo.LanConnectionData; + var joinId = lobbyInfo.JoinId; // Handle connection based on lobby type if (lobbyType == PublicLobbyType.Steam) { CleanupHolePunchSocket(holePunchSocket); ConnectToSteamLobby(connectionData, username); } else { - ConnectToMatchmakingLobby(connectionData, lanConnectionData, username, holePunchSocket); + if (string.IsNullOrEmpty(joinId)) { + CleanupHolePunchSocket(holePunchSocket); + ShowFeedback(Color.red, "Lobby not found, offline, or join failed"); + yield break; + } + + var joinTask = MmsClient.CoordinateMatchmakingJoinAsync( + joinId, + (data, endpoint) => { holePunchSocket.SendTo(data, endpoint); } + ); + + yield return new WaitUntil(() => joinTask.IsCompleted); + + if (!joinTask.IsCompletedSuccessfully) { + CleanupHolePunchSocket(holePunchSocket); + Logger.Error( + $"ConnectInterface: CoordinateMatchmakingJoinAsync failed: {joinTask.Exception?.GetBaseException().Message ?? "cancelled"}" + ); + ShowFeedback(Color.red, "Lobby not found, offline, or join failed"); + yield break; + } + + var joinStart = joinTask.Result; + if (joinStart == null) { + CleanupHolePunchSocket(holePunchSocket); + + if (MmsClient.LastMatchmakingError == MatchmakingError.UpdateRequired) { + ActivateMatchmakingVersionBlock(); + yield break; + } + + ShowFeedback(Color.red, "Lobby not found, offline, or join failed"); + yield break; + } + + ConnectToMatchmakingLobby( + $"{joinStart.HostIp}:{joinStart.HostPort}", lanConnectionData, username, holePunchSocket + ); } } @@ -1173,6 +1287,10 @@ private IEnumerator JoinLobbyCoroutine(string lobbyId, string username) { /// Shows the lobby configuration panel. /// private void OnHostLobbyButtonPressed() { + if (IsMatchmakingBlocked()) { + return; + } + if (!ValidateUsername(out _)) { return; } @@ -1295,21 +1413,27 @@ string username yield return new WaitUntil(() => task.IsCompleted); - var (lobbyId, lobbyName, hostDiscoveryToken) = task.Result; - if (lobbyId == null || lobbyName == null) { - ShowFeedback(Color.red, "Failed to create lobby. Is MMS running?"); + if (!task.IsCompletedSuccessfully) { CleanupHolePunchSocket(holePunchSocket); + Logger.Error( + $"ConnectInterface: CreateLobbyAsync failed: {task.Exception?.GetBaseException().Message ?? "cancelled"}" + ); + ShowFeedback(Color.red, "Failed to create lobby. Is MMS running?"); yield break; } - yield return RunDiscoveryCoroutine( - holePunchSocket, - hostDiscoveryToken, - "MmsClient: UDP discovery timed out, falling back to mapping-less hosting" - ); + var (lobbyId, lobbyName, _) = task.Result; + if (lobbyId == null || lobbyName == null) { + if (MmsClient.LastMatchmakingError == MatchmakingError.UpdateRequired) { + CleanupHolePunchSocket(holePunchSocket); + ActivateMatchmakingVersionBlock(); + yield break; + } - // Start polling for pending clients to punch back - MmsClient.StartPendingClientPolling(); + ShowFeedback(Color.red, "Failed to create lobby. Is MMS running?"); + CleanupHolePunchSocket(holePunchSocket); + yield break; + } // For private lobbies, show invite code in ChatBox so it's easily shareable if (visibility == LobbyVisibility.Private) { @@ -1335,6 +1459,10 @@ string username /// Fetches and displays public lobbies from the MMS. /// private void OnBrowseMatchmakingLobbiesPressed() { + if (IsMatchmakingBlocked()) { + return; + } + // Hide matchmaking content and show lobby browser _matchmakingGroup.SetActive(false); _lobbyBrowserPanel.Show(); @@ -1354,6 +1482,12 @@ private IEnumerator FetchLobbiesCoroutine() { var lobbies = task.Result; if (lobbies == null) { + if (MmsClient.LastMatchmakingError == MatchmakingError.UpdateRequired) { + _lobbyBrowserPanel.Hide(); + ActivateMatchmakingVersionBlock(); + yield break; + } + ShowFeedback(Color.red, "Failed to fetch lobbies. Is MMS running?"); yield break; } @@ -1691,6 +1825,7 @@ private void SaveConnectionSettings(string address, int port, string username) { /// The color of the feedback text. /// The message to display. private void ShowFeedback(Color color, string message) { + _isMatchmakingFeedbackActive = false; _feedbackHideCoroutine = ConnectInterfaceHelpers.SetFeedbackText( _feedbackText, color, @@ -1699,6 +1834,105 @@ private void ShowFeedback(Color color, string message) { ); } + /// + /// Activates the persistent matchmaking version block UI. + /// + private void ActivateMatchmakingVersionBlock() { + _isCheckingMatchmakingVersion = false; + _isMatchmakingReady = false; + _isMatchmakingVersionBlocked = true; + _lobbyBrowserPanel.Hide(); + _lobbyConfigPanel.Hide(); + SwitchTab(Tab.Matchmaking); + } + + /// + /// Starts an immediate matchmaking compatibility probe so the UI can block before showing actions. + /// + private void BeginMatchmakingVersionCheck() { + if (_isCheckingMatchmakingVersion || _isMatchmakingVersionBlocked) { + return; + } + + _isMatchmakingReady = false; + _isCheckingMatchmakingVersion = true; + SwitchTab(_activeTab); + MonoBehaviourUtil.Instance.StartCoroutine(CheckMatchmakingVersionCoroutine()); + } + + /// + /// Contacts MMS first and only enables matchmaking when the server is both reachable and compatible. + /// + private IEnumerator CheckMatchmakingVersionCoroutine() { + var task = MmsClient.ProbeMatchmakingCompatibilityAsync(); + yield return new WaitUntil(() => task.IsCompleted); + + if (MmsClient.LastMatchmakingError == MatchmakingError.UpdateRequired) { + ActivateMatchmakingVersionBlock(); + yield break; + } + + _isCheckingMatchmakingVersion = false; + _isMatchmakingReady = task.Result == true; + SwitchTab(_activeTab); + } + + /// + /// Keeps matchmaking status messages aligned with the standard bottom feedback area. + /// + private void RefreshMatchmakingStatusFeedback() { + if (_activeTab != Tab.Matchmaking) { + if (_isMatchmakingFeedbackActive) { + _feedbackText.SetActive(false); + _isMatchmakingFeedbackActive = false; + } + + return; + } + + if (_isCheckingMatchmakingVersion) { + SetMatchmakingStatusFeedback(MatchmakingCheckingText, Color.yellow); + return; + } + + if (_isMatchmakingVersionBlocked) { + SetMatchmakingStatusFeedback(MatchmakingUpdateRequiredText, Color.red); + return; + } + + if (!_isMatchmakingReady) { + SetMatchmakingStatusFeedback(MatchmakingUnavailableText, Color.red); + return; + } + + if (_isMatchmakingFeedbackActive) { + _feedbackText.SetActive(false); + _isMatchmakingFeedbackActive = false; + } + } + + /// + /// Returns whether matchmaking is blocked for this client version. + /// + private bool IsMatchmakingBlocked() { + return _isMatchmakingVersionBlocked; + } + + /// + /// Displays matchmaking-owned status feedback without clobbering feedback from other tabs. + /// + private void SetMatchmakingStatusFeedback(string message, Color color) { + if (_feedbackHideCoroutine != null) { + MonoBehaviourUtil.Instance.StopCoroutine(_feedbackHideCoroutine); + _feedbackHideCoroutine = null; + } + + _isMatchmakingFeedbackActive = true; + _feedbackText.SetText(message); + _feedbackText.SetColor(color); + _feedbackText.SetActive(true); + } + /// /// Resets the connection buttons to their default state after a connection attempt. /// @@ -1749,31 +1983,6 @@ private static int GetSocketPort(Socket socket) { return ((IPEndPoint) socket.LocalEndPoint!).Port; } - /// - /// Performs MMS-backed UDP discovery for a pre-bound hole-punch socket. - /// - private IEnumerator RunDiscoveryCoroutine(Socket holePunchSocket, string? discoveryToken, string timeoutMessage) { - if (string.IsNullOrEmpty(discoveryToken)) { - yield break; - } - - ShowFeedback(Color.yellow, "Mapping external port..."); - - var discoveryTask = MmsClient.PerformDiscoveryAsync( - discoveryToken, - (data, endpoint) => { holePunchSocket.SendTo(data, endpoint); } - ); - - yield return new WaitUntil(() => discoveryTask.IsCompleted); - - if (discoveryTask.Result == null) { - Logger.Warn(timeoutMessage); - yield break; - } - - Logger.Info($"MmsClient: Discovered external port {discoveryTask.Result}"); - } - /// /// Handles connection to a matchmaking lobby with LAN/public fallback. /// From a026d1765807a3fe7fa5e3ebdeba1bfee9491b04 Mon Sep 17 00:00:00 2001 From: Liparakis Date: Fri, 10 Apr 2026 23:22:04 +0300 Subject: [PATCH 2/8] feat: Improved comments and bug fix. --- .../Matchmaking/Host/MmsHostSessionService.cs | 14 ++++++++++---- .../Matchmaking/Host/MmsWebSocketHandler.cs | 10 +++++++++- SSMP/Networking/Matchmaking/MmsClient.cs | 2 +- .../Matchmaking/Transport/MmsHttpClient.cs | 3 +-- .../HolePunch/HolePunchEncryptedTransportServer.cs | 2 +- 5 files changed, 22 insertions(+), 9 deletions(-) diff --git a/SSMP/Networking/Matchmaking/Host/MmsHostSessionService.cs b/SSMP/Networking/Matchmaking/Host/MmsHostSessionService.cs index 881840c..e403bf5 100644 --- a/SSMP/Networking/Matchmaking/Host/MmsHostSessionService.cs +++ b/SSMP/Networking/Matchmaking/Host/MmsHostSessionService.cs @@ -25,6 +25,7 @@ internal sealed class MmsHostSessionService { /// private readonly string? _discoveryHost; + /// Synchronization lock for thread-safe access to session state (tokens, lobby IDs). private readonly object _sessionLock = new(); /// WebSocket handler that receives real-time MMS server events. @@ -81,7 +82,7 @@ MmsWebSocketHandler webSocket /// /// Raised when MMS requests a host-mapping refresh. - /// Provides the discovery token, peer address, and a correlation timestamp. + /// Provides the join ID, host discovery token, and a server correlation timestamp. /// Forwarded directly from . /// public event Action? RefreshHostMappingRequested { @@ -100,7 +101,7 @@ public event Action? HostMappingReceived { /// /// Raised when MMS instructs this host to begin NAT hole-punching toward a client. - /// Provides the peer token, peer address, port, punch ID, and a correlation timestamp. + /// Provides the join ID, client IP, client port, host port, and a startTimeMs correlation timestamp. /// Forwarded directly from . /// public event Action? StartPunchRequested { @@ -119,6 +120,8 @@ public void SetConnectedPlayers(int count) { var previous = Interlocked.Exchange(ref _connectedPlayers, normalized); if (previous == normalized) return; + // Note: _hostToken can be nulled concurrently after this check, + // but SendHeartbeat safely re-checks it under _sessionLock. if (_hostToken != null) SendHeartbeat(state: null); } @@ -191,7 +194,6 @@ string gameVersion if (!TryActivateLobby(response.Body, "RegisterSteamLobby", out _, out var lobbyCode, out _)) return (null, MatchmakingError.NetworkFailure); - Logger.Info($"MmsHostSessionService: registered Steam lobby {steamLobbyId} as MMS lobby {lobbyCode}"); return (lobbyCode, MatchmakingError.None); } @@ -220,7 +222,7 @@ public void CloseLobby() { /// from MMS. Requires an active lobby ( must have /// succeeded first). /// - public void StartPendingClientPolling() { + public void StartWebSocketConnection() { if (_hostToken == null) { Logger.Error("MmsHostSessionService: cannot start WebSocket without a host token"); return; @@ -259,6 +261,8 @@ public void StartHostDiscoveryRefresh(string hostDiscoveryToken, Action /// Cancels the active UDP discovery refresh task, if any. /// Safe to call when no refresh is running. + /// Note: The CancellationTokenSource is not disposed here; disposal is deferred + /// to the finally block of RunHostDiscoveryRefreshAsync. /// public void StopHostDiscoveryRefresh() { _hostDiscoveryRefreshCts?.Cancel(); @@ -296,6 +300,7 @@ private void ActivateLobby(string lobbyId, string hostToken) { /// Captures the current session token and lobby ID, then clears both fields. /// Called during to ensure the delete request uses /// the correct values even if state is mutated concurrently. + /// IMPORTANT: Must be called while holding _sessionLock, and with _hostToken non-null. /// /// /// A tuple of (hostToken, lobbyId) holding the values that were active @@ -425,6 +430,7 @@ private async Task RunHostDiscoveryRefreshAsync( CancellationTokenSource cts ) { try { + // Defensive check; normal flow is already guarded in StartHostDiscoveryRefresh if (_discoveryHost == null) return; await UdpDiscoveryService.SendUntilCancelledAsync( diff --git a/SSMP/Networking/Matchmaking/Host/MmsWebSocketHandler.cs b/SSMP/Networking/Matchmaking/Host/MmsWebSocketHandler.cs index 58bdbce..dac310a 100644 --- a/SSMP/Networking/Matchmaking/Host/MmsWebSocketHandler.cs +++ b/SSMP/Networking/Matchmaking/Host/MmsWebSocketHandler.cs @@ -12,6 +12,8 @@ namespace SSMP.Networking.Matchmaking.Host; /// /// Manages the persistent WebSocket connection between a lobby host and MMS. /// MMS pushes control events over this channel to coordinate matchmaking flow. +/// Note: There is no automatic reconnection logic. If the connection drops mid-session, +/// it must be restarted explicitly by the caller via . /// internal sealed class MmsWebSocketHandler : IDisposable { /// The base WebSocket URL of the MMS service. @@ -26,7 +28,10 @@ internal sealed class MmsWebSocketHandler : IDisposable { /// Cancellation source for the background listening loop. private CancellationTokenSource? _cts; - /// Monotonic version used to invalidate older background runs. + /// + /// Generation counter used to invalidate older background runs. + /// Wraps around on overflow; collision probability is negligible in practice. + /// private int _runVersion; /// @@ -211,6 +216,9 @@ private void HandleMessage(string message) { case MmsActions.StartPunch: HandleStartPunch(span); break; case MmsActions.HostMappingReceived: HandleHostMappingReceived(); break; case MmsActions.JoinFailed: HandleJoinFailed(message); break; + default: + Logger.Debug($"MmsWebSocketHandler: unknown action '{new string(action)}' mapped to message dropping"); + break; } } diff --git a/SSMP/Networking/Matchmaking/MmsClient.cs b/SSMP/Networking/Matchmaking/MmsClient.cs index 973df4c..4a19514 100644 --- a/SSMP/Networking/Matchmaking/MmsClient.cs +++ b/SSMP/Networking/Matchmaking/MmsClient.cs @@ -166,7 +166,7 @@ Action sendRawAction /// Starts the WebSocket listener for host push events (pending clients / start-punch). /// Must be called after creating a lobby. /// - public void StartPendingClientPolling() => _hostSession.StartPendingClientPolling(); + public void StartWebSocketConnection() => _hostSession.StartWebSocketConnection(); /// /// Fires off a background UDP discovery refresh for the given host token. diff --git a/SSMP/Networking/Matchmaking/Transport/MmsHttpClient.cs b/SSMP/Networking/Matchmaking/Transport/MmsHttpClient.cs index cf270fe..8e3b03a 100644 --- a/SSMP/Networking/Matchmaking/Transport/MmsHttpClient.cs +++ b/SSMP/Networking/Matchmaking/Transport/MmsHttpClient.cs @@ -112,8 +112,7 @@ private static HttpClient CreateHttpClient() { var handler = new HttpClientHandler { UseProxy = false, UseCookies = false, - AllowAutoRedirect = false, - MaxConnectionsPerServer = 10 + AllowAutoRedirect = false }; var client = new HttpClient(handler) { diff --git a/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransportServer.cs b/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransportServer.cs index a99127c..ea128fc 100644 --- a/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransportServer.cs +++ b/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransportServer.cs @@ -78,7 +78,7 @@ public void Start(int port) { _dtlsServer.Start(port, socket); - _mmsClient?.StartPendingClientPolling(); + _mmsClient?.StartWebSocketConnection(); } /// From f0732cee67350a5775019939b1cdc3582a345afd Mon Sep 17 00:00:00 2001 From: Liparakis Date: Fri, 10 Apr 2026 23:34:34 +0300 Subject: [PATCH 3/8] fix: improve reliability and docs in UdpDiscoveryService and MmsJoinCoordinator --- .../Matchmaking/Join/MmsJoinCoordinator.cs | 44 ++++++++++++++----- .../Matchmaking/Join/UdpDiscoveryService.cs | 32 +++++++++----- SSMP/Networking/Matchmaking/MmsClient.cs | 6 ++- 3 files changed, 59 insertions(+), 23 deletions(-) diff --git a/SSMP/Networking/Matchmaking/Join/MmsJoinCoordinator.cs b/SSMP/Networking/Matchmaking/Join/MmsJoinCoordinator.cs index e162e59..5ba6655 100644 --- a/SSMP/Networking/Matchmaking/Join/MmsJoinCoordinator.cs +++ b/SSMP/Networking/Matchmaking/Join/MmsJoinCoordinator.cs @@ -50,9 +50,9 @@ private sealed class DiscoverySession : IDisposable { /// public CancellationTokenSource? Cts; - /// Cancels and nulls if it is set. + /// Cancels without disposing it. public void Cancel() { - Dispose(); + Cts?.Cancel(); } /// Cancels and disposes if it is set. @@ -86,6 +86,7 @@ public void Dispose() { /// Invoked with a human-readable reason string whenever the join attempt fails /// (timeout, server rejection, or WebSocket error). Never invoked on success. /// + /// Cancellation token that allows backing out of matchmaking mid-flow. /// /// A containing peer address and timing /// information, or null if the attempt failed or timed out. @@ -93,21 +94,23 @@ public void Dispose() { public async Task CoordinateAsync( string joinId, Action sendRawAction, - Action onJoinFailed + Action onJoinFailed, + CancellationToken cancellationToken ) { if (_discoveryHost == null) Logger.Warn("MmsJoinCoordinator: discovery host unknown; UDP mapping will be skipped"); using var socket = new ClientWebSocket(); - using var timeoutCts = + using var sessionCts = new CancellationTokenSource(TimeSpan.FromMilliseconds(MmsProtocol.MatchmakingWebSocketTimeoutMs)); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(sessionCts.Token, cancellationToken); var discovery = new DiscoverySession(); try { await ConnectAsync(socket, joinId, timeoutCts.Token); return await RunMessageLoopAsync(socket, timeoutCts, sendRawAction, discovery, onJoinFailed); } catch (OperationCanceledException) { - onJoinFailed("timeout"); + onJoinFailed("Timeout."); } catch (WebSocketException ex) { onJoinFailed(ex.Message); Logger.Error($"MmsJoinCoordinator: matchmaking WebSocket error: {ex.Message}"); @@ -159,7 +162,10 @@ Action onJoinFailed ) { while (socket.State == WebSocketState.Open && !timeoutCts.Token.IsCancellationRequested) { var (messageType, message) = await MmsUtilities.ReceiveTextMessageAsync(socket, timeoutCts.Token); - if (messageType == WebSocketMessageType.Close) break; + if (messageType == WebSocketMessageType.Close) { + onJoinFailed("Connection closed prematurely by server."); + break; + } if (messageType != WebSocketMessageType.Text || string.IsNullOrEmpty(message)) continue; var outcome = await HandleMessage(message, timeoutCts, sendRaw, discovery, onJoinFailed); @@ -198,7 +204,7 @@ Action onJoinFailed break; case MmsActions.StartPunch: - var joinStart = await HandleStartPunchAsync(message, timeoutCts, discovery); + var joinStart = await HandleStartPunchAsync(message, timeoutCts, discovery, onJoinFailed); return (true, joinStart); case MmsActions.ClientMappingReceived: @@ -208,6 +214,10 @@ Action onJoinFailed case MmsActions.JoinFailed: HandleJoinFailed(message, onJoinFailed); return (true, null); + + default: + Logger.Debug($"MmsJoinCoordinator: Unknown action '{new string(action)}' mapped to message dropping"); + break; } return (false, null); @@ -237,6 +247,7 @@ DiscoverySession discovery /// Raw message text containing the punch payload fields. /// Used as the cancellation token for the start-time delay. /// Cancelled immediately on entry. + /// Invoked if payload parsing fails. /// /// The parsed , or null if the /// payload could not be parsed. @@ -244,12 +255,17 @@ DiscoverySession discovery private static async Task HandleStartPunchAsync( string message, CancellationTokenSource timeoutCts, - DiscoverySession discovery + DiscoverySession discovery, + Action onJoinFailed ) { discovery.Cancel(); var joinStart = MmsResponseParser.ParseStartPunch(message.AsSpan()); - if (joinStart == null) return null; + if (joinStart == null) { + Logger.Warn($"MmsJoinCoordinator: Failed to parse start punch payload: {message}"); + onJoinFailed("Invalid start_punch payload received from server."); + return null; + } await DelayUntilAsync(joinStart.StartTimeMs, timeoutCts.Token); return joinStart; @@ -267,7 +283,12 @@ DiscoverySession discovery /// task, or null if discovery was not started. /// private CancellationTokenSource? StartDiscovery(string? token, Action sendRaw) { - if (string.IsNullOrEmpty(token) || _discoveryHost == null) + if (string.IsNullOrEmpty(token)) { + Logger.Warn("MmsJoinCoordinator: begin_client_mapping missing token"); + return null; + } + + if (_discoveryHost == null) return null; var cts = new CancellationTokenSource(TimeSpan.FromSeconds(MmsProtocol.DiscoveryDurationSeconds)); @@ -281,7 +302,8 @@ DiscoverySession discovery /// /// Waits until the specified Unix timestamp (in milliseconds) before returning. - /// Returns immediately if the target time is already in the past. + /// Returns immediately if the target time is already in the past. If the target time + /// is far in the future, the delay will simply block until fires. /// /// Target time expressed as milliseconds since the Unix epoch (UTC). /// Cancellation token that can abort the wait early. diff --git a/SSMP/Networking/Matchmaking/Join/UdpDiscoveryService.cs b/SSMP/Networking/Matchmaking/Join/UdpDiscoveryService.cs index b05441a..b0b7a1d 100644 --- a/SSMP/Networking/Matchmaking/Join/UdpDiscoveryService.cs +++ b/SSMP/Networking/Matchmaking/Join/UdpDiscoveryService.cs @@ -15,6 +15,10 @@ namespace SSMP.Networking.Matchmaking.Join; /// which it then shares with the peer to enable NAT hole-punching. /// internal static class UdpDiscoveryService { + /// + /// The expected length of the discovery token in bytes. + /// This corresponds to a 32-character hexadecimal UUID. + /// private const int ExpectedTokenByteLength = 32; /// @@ -31,12 +35,14 @@ CancellationToken cancellationToken if (endpoint is null) return; var tokenBytes = EncodeToken(token); - if (tokenBytes.Length != ExpectedTokenByteLength) - throw new InvalidOperationException( - $"UdpDiscoveryService: discovery token encoded to {tokenBytes.Length} bytes; expected {ExpectedTokenByteLength}." + if (tokenBytes.Length != ExpectedTokenByteLength) { + Logger.Error( + $"UdpDiscoveryService: discovery token encoded to {tokenBytes.Length} bytes; expected {ExpectedTokenByteLength}. Aborting discovery." ); + return; + } - await RunDiscoveryLoopAsync(sendRaw, tokenBytes, endpoint, cancellationToken); + await RunDiscoveryLoopAsync(sendRaw, tokenBytes, endpoint, cancellationToken).ConfigureAwait(false); } /// @@ -48,8 +54,16 @@ CancellationToken cancellationToken try { var addresses = await Dns.GetHostAddressesAsync(host).ConfigureAwait(false); - if (addresses is { Length: > 0 }) - return new IPEndPoint(addresses[0], MmsProtocol.DiscoveryPort); + if (addresses is { Length: > 0 }) { + var address = addresses[0]; + foreach (var a in addresses) { + if (a.AddressFamily != AddressFamily.InterNetwork) + continue; + address = a; + break; + } + return new IPEndPoint(address, MmsProtocol.DiscoveryPort); + } Logger.Error($"UdpDiscoveryService: could not resolve host '{host}'"); return null; @@ -75,7 +89,7 @@ private static async Task RunDiscoveryLoopAsync( CancellationToken cancellationToken ) { while (!cancellationToken.IsCancellationRequested) { - if (!TrySend(sendRaw, tokenBytes, endpoint)) return; + TrySend(sendRaw, tokenBytes, endpoint); if (!await TryDelayAsync(cancellationToken).ConfigureAwait(false)) return; } @@ -84,17 +98,15 @@ CancellationToken cancellationToken /// /// Attempts a single send. Returns false (and logs a warning) on failure. /// - private static bool TrySend( + private static void TrySend( Action sendRaw, byte[] tokenBytes, IPEndPoint endpoint ) { try { sendRaw(tokenBytes, endpoint); - return true; } catch (Exception ex) when (ex is not OperationCanceledException) { Logger.Warn($"UdpDiscoveryService: send error, aborting – {ex}"); - return false; } } diff --git a/SSMP/Networking/Matchmaking/MmsClient.cs b/SSMP/Networking/Matchmaking/MmsClient.cs index 4a19514..6ecad73 100644 --- a/SSMP/Networking/Matchmaking/MmsClient.cs +++ b/SSMP/Networking/Matchmaking/MmsClient.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Net; +using System.Threading; using System.Threading.Tasks; using SSMP.Logging; using SSMP.Networking.Matchmaking.Host; @@ -130,10 +131,11 @@ public MmsClient( /// public async Task CoordinateMatchmakingJoinAsync( string joinId, - Action sendRawAction + Action sendRawAction, + CancellationToken cancellationToken = default ) { ClearErrors(); - return await _joinCoordinator.CoordinateAsync(joinId, sendRawAction, SetJoinFailed); + return await _joinCoordinator.CoordinateAsync(joinId, sendRawAction, SetJoinFailed, cancellationToken); } /// From 35ed7dcf61597e8f3a45005f99aba77b9c761c79 Mon Sep 17 00:00:00 2001 From: Liparakis Date: Sat, 11 Apr 2026 00:27:53 +0300 Subject: [PATCH 4/8] Fix: Hardening Matchmaking Networking Logic - Refactored MmsJsonParser with defensive boundary checks and null-returns for graceful handling. - Unified lobby codes to be nullable in the public UI models and improved connection data fallbacks. - Mapped join parse failures to NetworkFailure and explicitly validated array structures in Lobby Queries. - Dropped volatile enum mappings for URL arguments in favor of strict string constants. - Cleaned up fragmented error tracking into a single _lastError state in MmsClient. - Replaced tight-loop buffering allocations with ArrayPool on WebSocket receives. - Forced oversized WebSocket frames to degrade gracefully instead of crashing parent tasks. - Fixed un-declared variable and silent IP extraction failures across matchmaking components. --- .../Matchmaking/Host/MmsWebSocketHandler.cs | 10 +++++- .../Matchmaking/Join/MmsJoinCoordinator.cs | 10 +++++- SSMP/Networking/Matchmaking/MmsClient.cs | 33 ++++++++--------- .../Matchmaking/Parsing/MmsJsonParser.cs | 35 +++++++++++++++--- .../Matchmaking/Parsing/MmsResponseParser.cs | 21 +++++++++-- .../Matchmaking/Query/MmsLobbyQueryService.cs | 36 +++++++++++++------ .../Matchmaking/Transport/MmsHttpClient.cs | 16 ++++----- .../Matchmaking/Utilities/MmsUtilities.cs | 33 +++++++++-------- 8 files changed, 134 insertions(+), 60 deletions(-) diff --git a/SSMP/Networking/Matchmaking/Host/MmsWebSocketHandler.cs b/SSMP/Networking/Matchmaking/Host/MmsWebSocketHandler.cs index dac310a..aef5faa 100644 --- a/SSMP/Networking/Matchmaking/Host/MmsWebSocketHandler.cs +++ b/SSMP/Networking/Matchmaking/Host/MmsWebSocketHandler.cs @@ -133,7 +133,15 @@ private async Task ConnectAsync(ClientWebSocket socket, string hostToken, Cancel /// Cancellation token that ends the receive loop. private async Task ReceiveLoopAsync(ClientWebSocket socket, CancellationToken cancellationToken) { while (socket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested) { - var (messageType, message) = await MmsUtilities.ReceiveTextMessageAsync(socket, cancellationToken); + WebSocketMessageType messageType; + string? message; + try { + (messageType, message) = await MmsUtilities.ReceiveTextMessageAsync(socket, cancellationToken); + } catch (InvalidOperationException ex) { + Logger.Error($"MmsWebSocketHandler: disconnecting - {ex.Message}"); + break; + } + if (messageType == WebSocketMessageType.Close) break; if (messageType != WebSocketMessageType.Text || string.IsNullOrEmpty(message)) continue; diff --git a/SSMP/Networking/Matchmaking/Join/MmsJoinCoordinator.cs b/SSMP/Networking/Matchmaking/Join/MmsJoinCoordinator.cs index 5ba6655..6f01353 100644 --- a/SSMP/Networking/Matchmaking/Join/MmsJoinCoordinator.cs +++ b/SSMP/Networking/Matchmaking/Join/MmsJoinCoordinator.cs @@ -161,7 +161,15 @@ private async Task ConnectAsync(ClientWebSocket socket, string joinId, Cancellat Action onJoinFailed ) { while (socket.State == WebSocketState.Open && !timeoutCts.Token.IsCancellationRequested) { - var (messageType, message) = await MmsUtilities.ReceiveTextMessageAsync(socket, timeoutCts.Token); + WebSocketMessageType messageType; + string? message; + try { + (messageType, message) = await MmsUtilities.ReceiveTextMessageAsync(socket, timeoutCts.Token); + } catch (InvalidOperationException ex) { + onJoinFailed($"Matchmaking error: {ex.Message}"); + break; + } + if (messageType == WebSocketMessageType.Close) { onJoinFailed("Connection closed prematurely by server."); break; diff --git a/SSMP/Networking/Matchmaking/MmsClient.cs b/SSMP/Networking/Matchmaking/MmsClient.cs index 6ecad73..51302a8 100644 --- a/SSMP/Networking/Matchmaking/MmsClient.cs +++ b/SSMP/Networking/Matchmaking/MmsClient.cs @@ -23,18 +23,14 @@ namespace SSMP.Networking.Matchmaking; /// for client join rendezvous /// /// -internal class MmsClient { +internal sealed class MmsClient { private readonly MmsHostSessionService _hostSession; private readonly MmsLobbyQueryService _queries; private readonly MmsJoinCoordinator _joinCoordinator; - private MatchmakingError _lastHttpError = MatchmakingError.None; + private MatchmakingError _lastError = MatchmakingError.None; /// The last matchmaking error from the most recent operation. - public MatchmakingError LastMatchmakingError => - _localError != MatchmakingError.None ? _localError : _lastHttpError; - - /// Internal error state for non-HTTP failures. - private MatchmakingError _localError = MatchmakingError.None; + public MatchmakingError LastMatchmakingError => _lastError; /// public event Action? RefreshHostMappingRequested { @@ -61,9 +57,11 @@ public MmsClient( MmsJoinCoordinator? joinCoordinator = null ) { var normalizedBaseUrl = baseUrl.TrimEnd('/'); - string? discoveryHost = null; - if (Uri.TryCreate(normalizedBaseUrl, UriKind.Absolute, out var uri)) - discoveryHost = uri.Host; + if (!Uri.TryCreate(normalizedBaseUrl, UriKind.Absolute, out var uri)) { + throw new ArgumentException($"Invalid base URL: {baseUrl}. NAT discovery will fail.", nameof(baseUrl)); + } + + var discoveryHost = uri.Host; _hostSession = hostSession ?? new MmsHostSessionService( @@ -94,7 +92,7 @@ public MmsClient( ) { ClearErrors(); var result = await _hostSession.CreateLobbyAsync(hostPort, isPublic, gameVersion, lobbyType); - _lastHttpError = result.error; + _lastError = result.error; return result.result; } @@ -109,7 +107,7 @@ public MmsClient( ) { ClearErrors(); var result = await _hostSession.RegisterSteamLobbyAsync(steamLobbyId, isPublic, gameVersion); - _lastHttpError = result.error; + _lastError = result.error; return result.lobbyCode; } @@ -120,7 +118,7 @@ public MmsClient( public async Task JoinLobbyAsync(string lobbyId, int clientPort) { ClearErrors(); var result = await _queries.JoinLobbyAsync(lobbyId, clientPort); - _lastHttpError = result.error; + _lastError = result.error; return result.result; } @@ -144,7 +142,7 @@ public MmsClient( public async Task?> GetPublicLobbiesAsync(PublicLobbyType? lobbyType = null) { ClearErrors(); var result = await _queries.GetPublicLobbiesAsync(lobbyType); - _lastHttpError = result.error; + _lastError = result.error; return result.lobbies; } @@ -160,7 +158,7 @@ public MmsClient( public async Task ProbeMatchmakingCompatibilityAsync() { ClearErrors(); var (isCompatible, error) = await _queries.ProbeMatchmakingCompatibilityAsync(); - _localError = error; + _lastError = error; return isCompatible; } @@ -187,14 +185,13 @@ public void StartHostDiscoveryRefresh(string hostDiscoveryToken, Action private void SetJoinFailed(string reason) { Logger.Warn($"MmsClient: matchmaking join failed – {reason}"); - _localError = MatchmakingError.JoinFailed; + _lastError = MatchmakingError.JoinFailed; } /// /// Clears the internal and HTTP error states. /// private void ClearErrors() { - _localError = MatchmakingError.None; - _lastHttpError = MatchmakingError.None; + _lastError = MatchmakingError.None; } } diff --git a/SSMP/Networking/Matchmaking/Parsing/MmsJsonParser.cs b/SSMP/Networking/Matchmaking/Parsing/MmsJsonParser.cs index b8fc52f..28234e0 100644 --- a/SSMP/Networking/Matchmaking/Parsing/MmsJsonParser.cs +++ b/SSMP/Networking/Matchmaking/Parsing/MmsJsonParser.cs @@ -36,10 +36,20 @@ internal static class MmsJsonParser { var relative = json[searchStart..].IndexOf(searchKey, StringComparison.Ordinal); if (relative == -1) return null; - var keyEnd = searchStart + relative + searchKey.Length; + var matchPos = searchStart + relative; + + // Verify this is a full key match at a JSON field boundary: + // the character before the opening quote must not be alphanumeric + // (guards against "1gameVersion" matching a search for "gameVersion"). + if (matchPos > 0 && char.IsLetterOrDigit(json[matchPos - 1])) { + searchStart = matchPos + 1; + continue; + } + + var keyEnd = matchPos + searchKey.Length; var valueStart = MmsUtilities.SkipWhitespace(json, keyEnd); if (valueStart >= json.Length || json[valueStart] != ':') { - searchStart = searchStart + relative + 1; + searchStart = matchPos + 1; continue; } @@ -65,6 +75,10 @@ internal static class MmsJsonParser { /// The type of the lobby. /// Optional local IP address. /// A tuple containing the buffer and the number of characters written. + /// + /// The HostLanIp field is serialized as "ip:port" (a socket address) + /// because the MMS server expects both values combined in a single string field. + /// public static (char[] buffer, int length) FormatCreateLobbyJson( int port, bool isPublic, @@ -75,13 +89,18 @@ public static (char[] buffer, int length) FormatCreateLobbyJson( var escapedGameVersion = MmsUtilities.EscapeJsonString(gameVersion); var escapedHostLanIp = hostLanIp == null ? null : MmsUtilities.EscapeJsonString(hostLanIp); var lobbyTypeValue = lobbyType == PublicLobbyType.Matchmaking ? "matchmaking" : "steam"; + // 96: fixed JSON structure overhead (braces, key names, quotes, colons, commas) + // 16: HostLanIp wrapper (key + colon + port digits) + // 24: MatchmakingVersion wrapper (key + colon + version digits) + // 32: safety margin for edge cases var estimatedLength = 96 + escapedGameVersion.Length + lobbyTypeValue.Length + (escapedHostLanIp?.Length ?? 0) + (hostLanIp != null ? 16 : 0) + - (lobbyType == PublicLobbyType.Matchmaking ? 24 : 0); + (lobbyType == PublicLobbyType.Matchmaking ? 24 : 0) + + 32; var buffer = CharPool.Rent(estimatedLength); var span = buffer.AsSpan(); @@ -165,7 +184,8 @@ public static (char[] buffer, int length) FormatCreateLobbyJson( i += 4; break; default: - throw new FormatException($"Invalid JSON escape sequence \\{escape} at index {i}."); + // Unknown escape sequence; treat as unparseable + return null; } segmentStart = i + 1; @@ -190,8 +210,13 @@ public static (char[] buffer, int length) FormatCreateLobbyJson( /// private static string ExtractNumericValue(ReadOnlySpan json, int start) { var end = start; + + // A leading minus sign is valid only at the first character position. + if (end < json.Length && json[end] == '-') + end++; + while (end < json.Length && - (char.IsDigit(json[end]) || json[end] == '.' || json[end] == '-')) { + (char.IsDigit(json[end]) || json[end] == '.')) { end++; } diff --git a/SSMP/Networking/Matchmaking/Parsing/MmsResponseParser.cs b/SSMP/Networking/Matchmaking/Parsing/MmsResponseParser.cs index 942d1da..9b5d6d8 100644 --- a/SSMP/Networking/Matchmaking/Parsing/MmsResponseParser.cs +++ b/SSMP/Networking/Matchmaking/Parsing/MmsResponseParser.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using SSMP.Logging; using SSMP.Networking.Matchmaking.Protocol; namespace SSMP.Networking.Matchmaking.Parsing; @@ -10,6 +11,9 @@ namespace SSMP.Networking.Matchmaking.Parsing; /// slices via . /// internal static class MmsResponseParser { + /// + /// Lookup key for scanning lobby object boundaries. + /// private const string ConnectionDataKey = $"\"{MmsFields.ConnectionData}\":"; /// @@ -88,6 +92,11 @@ out string? hostDiscoveryToken /// A list of entries. Returns an empty list if the /// response contains no parseable lobbies. /// + /// + /// Lobby objects are limited by scanning for successive "connectionData": keys. + /// This assumes connectionData is the first field in each lobby object and that + /// no other field values contain the literal string "connectionData":. + /// public static List ParsePublicLobbies(string response) { var result = new List(); var span = response.AsSpan(); @@ -95,7 +104,11 @@ public static List ParsePublicLobbies(string response) { while (TryFindNextLobbySlice(span, ref idx, out var slice)) { var entry = TryParsePublicLobbyEntry(slice); - if (entry != null) result.Add(entry); + if (entry != null) { + result.Add(entry); + } else { + Logger.Debug($"MmsResponseParser: Skipped unparseable lobby entry at index {idx}."); + } } return result; @@ -140,7 +153,11 @@ out PublicLobbyType lobbyType return false; } - return Enum.TryParse(lobbyTypeString, true, out lobbyType); + // Default to Matchmaking for unknown lobby types, consistent with TryParsePublicLobbyEntry + if (!Enum.TryParse(lobbyTypeString, true, out lobbyType)) + lobbyType = PublicLobbyType.Matchmaking; + + return true; } /// diff --git a/SSMP/Networking/Matchmaking/Query/MmsLobbyQueryService.cs b/SSMP/Networking/Matchmaking/Query/MmsLobbyQueryService.cs index 595452d..a511748 100644 --- a/SSMP/Networking/Matchmaking/Query/MmsLobbyQueryService.cs +++ b/SSMP/Networking/Matchmaking/Query/MmsLobbyQueryService.cs @@ -5,6 +5,7 @@ using SSMP.Networking.Matchmaking.Parsing; using SSMP.Networking.Matchmaking.Protocol; using SSMP.Networking.Matchmaking.Transport; +using SSMP.Networking.Matchmaking.Utilities; namespace SSMP.Networking.Matchmaking.Query; @@ -44,7 +45,10 @@ public MmsLobbyQueryService(string baseUrl) { if (!response.Success || response.Body == null) return (null, response.Error); - return (ParseAndLogJoinResult(lobbyId, response.Body), MatchmakingError.None); + var result = ParseAndLogJoinResult(lobbyId, response.Body); + return result == null + ? (null, MatchmakingError.NetworkFailure) + : (result, MatchmakingError.None); } /// @@ -65,9 +69,17 @@ public MmsLobbyQueryService(string baseUrl) { ) { var url = BuildPublicLobbiesUrl(lobbyType); var response = await MmsHttpClient.GetAsync(url); - return !response.Success || response.Body == null - ? (null, response.Error) - : (MmsResponseParser.ParsePublicLobbies(response.Body), MatchmakingError.None); + if (!response.Success || response.Body == null) { + return (null, response.Error); + } + + var startIdx = MmsUtilities.SkipWhitespace(response.Body.AsSpan(), 0); + if (startIdx >= response.Body.Length || response.Body[startIdx] != '[') { + Logger.Warn($"MmsLobbyQueryService: GetPublicLobbiesAsync received malformed non-array response (length={response.Body.Length})."); + return (null, MatchmakingError.NetworkFailure); + } + + return (MmsResponseParser.ParsePublicLobbies(response.Body), MatchmakingError.None); } /// @@ -88,7 +100,8 @@ public MmsLobbyQueryService(string baseUrl) { /// /// error /// - /// on success or unreachable server; + /// on success; + /// Passes through the underlying if the server could not be reached; /// if the response lacked a valid version field; /// if the protocol versions differ. /// @@ -128,13 +141,15 @@ private static string BuildJoinRequestJson(int clientPort) => private static JoinLobbyResult? ParseAndLogJoinResult(string lobbyId, string response) { var joinResult = MmsResponseParser.ParseJoinLobbyResult(response); if (joinResult == null) { + var hasJoinId = MmsJsonParser.ExtractValue(response.AsSpan(), MmsFields.JoinId) != null; + var hasConnectionData = MmsJsonParser.ExtractValue(response.AsSpan(), MmsFields.ConnectionData) != null; Logger.Error( - $"MmsLobbyQueryService: invalid JoinLobby response (length={response.Length}, hasJoinId={response.Contains(MmsFields.JoinId)}, hasConnectionData={response.Contains(MmsFields.ConnectionData)})" + $"MmsLobbyQueryService: invalid JoinLobby response (length={response.Length}, hasJoinId={hasJoinId}, hasConnectionData={hasConnectionData})." ); return null; } - Logger.Info($"MmsLobbyQueryService: joined lobby {lobbyId}, type={joinResult.LobbyType}"); + Logger.Info($"MmsLobbyQueryService: joined lobby {lobbyId}, type={joinResult.LobbyType}."); return joinResult; } @@ -149,7 +164,7 @@ private static bool TryParseServerVersion(string response, out int serverVersion var versionString = MmsJsonParser.ExtractValue(response.AsSpan(), MmsFields.Version); if (int.TryParse(versionString, out serverVersion)) return true; - Logger.Warn("MmsLobbyQueryService: MMS health response did not include a valid protocol version"); + Logger.Warn("MmsLobbyQueryService: MMS health response did not include a valid protocol version."); return false; } @@ -169,7 +184,7 @@ private static (bool? isCompatible, MatchmakingError error) CheckVersionCompatib Logger.Warn( $"MmsLobbyQueryService: MMS protocol mismatch " + - $"(client={MmsProtocol.CurrentVersion}, server={serverVersion})" + $"(client={MmsProtocol.CurrentVersion}, server={serverVersion})." ); return (false, MatchmakingError.UpdateRequired); } @@ -187,7 +202,8 @@ private string BuildPublicLobbiesUrl(PublicLobbyType? lobbyType) { var url = $"{_baseUrl}{MmsRoutes.Lobbies}"; if (lobbyType == null) return url; - url += $"?{MmsQueryKeys.Type}={lobbyType.ToString()!.ToLowerInvariant()}"; + var typeString = lobbyType == PublicLobbyType.Matchmaking ? "matchmaking" : "steam"; + url += $"?{MmsQueryKeys.Type}={typeString}"; if (lobbyType == PublicLobbyType.Matchmaking) url += $"&{MmsQueryKeys.MatchmakingVersion}={MmsProtocol.CurrentVersion}"; diff --git a/SSMP/Networking/Matchmaking/Transport/MmsHttpClient.cs b/SSMP/Networking/Matchmaking/Transport/MmsHttpClient.cs index 8e3b03a..fb665ad 100644 --- a/SSMP/Networking/Matchmaking/Transport/MmsHttpClient.cs +++ b/SSMP/Networking/Matchmaking/Transport/MmsHttpClient.cs @@ -18,6 +18,8 @@ internal static class MmsHttpClient { private static readonly HttpClient Http = CreateHttpClient(); static MmsHttpClient() { + // Note: ProcessExit only fires on graceful shutdown. + // Hard crashes will bypass this, meaning the OS will clean up the socket. AppDomain.CurrentDomain.ProcessExit += (_, _) => Http.Dispose(); } @@ -26,7 +28,7 @@ static MmsHttpClient() { /// public static async Task GetAsync(string url) { try { - using var response = await Http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); + using var response = await Http.GetAsync(url); var body = await response.Content.ReadAsStringAsync(); return new MmsHttpResponse( response.IsSuccessStatusCode, @@ -86,14 +88,10 @@ public static async Task DeleteAsync(string url) { private static MatchmakingError InspectErrorBody(HttpStatusCode status, string? body) { if ((int) status < 400 || body == null) return MatchmakingError.None; - try { - var errorCode = MmsJsonParser.ExtractValue(body.AsSpan(), MmsFields.ErrorCode); - return errorCode == MmsProtocol.UpdateRequiredErrorCode - ? MatchmakingError.UpdateRequired - : MatchmakingError.NetworkFailure; - } catch (FormatException) { - return MatchmakingError.NetworkFailure; - } + var errorCode = MmsJsonParser.ExtractValue(body.AsSpan(), MmsFields.ErrorCode); + return errorCode == MmsProtocol.UpdateRequiredErrorCode + ? MatchmakingError.UpdateRequired + : MatchmakingError.NetworkFailure; } /// diff --git a/SSMP/Networking/Matchmaking/Utilities/MmsUtilities.cs b/SSMP/Networking/Matchmaking/Utilities/MmsUtilities.cs index 82c799a..d00fe3f 100644 --- a/SSMP/Networking/Matchmaking/Utilities/MmsUtilities.cs +++ b/SSMP/Networking/Matchmaking/Utilities/MmsUtilities.cs @@ -57,30 +57,34 @@ public static void RunBackground(Task task, string owner, string operationName) /// A tuple containing the terminal frame type and the decoded text payload. /// Non-text messages and close frames return as the payload. /// + /// Thrown if the assembled message exceeds . public static async Task<(WebSocketMessageType messageType, string? message)> ReceiveTextMessageAsync( ClientWebSocket socket, CancellationToken cancellationToken, int maxMessageBytes = 16 * 1024 ) { const int chunkSize = 1024; - - var buffer = new byte[chunkSize]; + var buffer = ArrayPool.Shared.Rent(chunkSize); var writer = new ArrayBufferWriter(); - while (true) { - var frame = await socket.ReceiveAsync(new ArraySegment(buffer), cancellationToken); - if (frame.MessageType == WebSocketMessageType.Close) - return (frame.MessageType, null); + try { + while (true) { + var frame = await socket.ReceiveAsync(new ArraySegment(buffer), cancellationToken); + if (frame.MessageType == WebSocketMessageType.Close) + return (frame.MessageType, null); - AppendFrame(writer, buffer, frame.Count, maxMessageBytes); + AppendFrame(writer, buffer, frame.Count, maxMessageBytes); - if (!frame.EndOfMessage) - continue; + if (!frame.EndOfMessage) + continue; - return frame.MessageType != WebSocketMessageType.Text - ? (frame.MessageType, null) - : (frame.MessageType, - writer.WrittenCount == 0 ? string.Empty : Encoding.UTF8.GetString(writer.WrittenSpan)); + return frame.MessageType != WebSocketMessageType.Text + ? (frame.MessageType, null) + : (frame.MessageType, + writer.WrittenCount == 0 ? string.Empty : Encoding.UTF8.GetString(writer.WrittenSpan)); + } + } finally { + ArrayPool.Shared.Return(buffer); } } @@ -97,7 +101,8 @@ public static void RunBackground(Task task, string owner, string operationName) using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, 0); socket.Connect("8.8.8.8", 65530); return (socket.LocalEndPoint as IPEndPoint)?.Address.ToString(); - } catch { + } catch (Exception ex) { + Logger.Debug($"MmsUtilities: GetLocalIpAddress failed: {ex.Message}"); return null; } } From f22cb6d719296bb4be67436d2a94c4655d758dee Mon Sep 17 00:00:00 2001 From: Liparakis Date: Sat, 11 Apr 2026 16:27:37 +0300 Subject: [PATCH 5/8] refactor(matchmaking): harden concurrency and minimize documentation - Resolved Unity main-thread violations in MmsWebSocketHandler via SynchronizationContext. - Fixed race conditions and lifecycle leaks in lobby session management. - Hardened NAT hole-punching logic and Steam ID validation. - Aggressively minimized XML documentation across the matchmaking namespace. --- .../Matchmaking/Host/MmsHostSessionService.cs | 303 ++++++++++-------- .../Matchmaking/Host/MmsWebSocketHandler.cs | 229 +++++++++---- .../Matchmaking/Join/MmsJoinCoordinator.cs | 94 +----- .../Matchmaking/Join/UdpDiscoveryService.cs | 16 +- SSMP/Networking/Matchmaking/MmsClient.cs | 83 ++--- .../Matchmaking/Parsing/MmsJsonParser.cs | 32 +- .../Matchmaking/Parsing/MmsResponseParser.cs | 68 +--- .../Matchmaking/Protocol/MmsModels.cs | 24 +- .../Matchmaking/Query/MmsLobbyQueryService.cs | 65 +--- .../Matchmaking/Transport/MmsHttpClient.cs | 15 +- .../Matchmaking/Utilities/MmsUtilities.cs | 43 +-- .../SteamP2P/SteamEncryptedTransport.cs | 7 +- 12 files changed, 413 insertions(+), 566 deletions(-) diff --git a/SSMP/Networking/Matchmaking/Host/MmsHostSessionService.cs b/SSMP/Networking/Matchmaking/Host/MmsHostSessionService.cs index e403bf5..ca26161 100644 --- a/SSMP/Networking/Matchmaking/Host/MmsHostSessionService.cs +++ b/SSMP/Networking/Matchmaking/Host/MmsHostSessionService.cs @@ -11,31 +11,28 @@ namespace SSMP.Networking.Matchmaking.Host; -/// -/// Manages the full lifecycle of the local host's MMS lobby session, including -/// lobby creation, heartbeat keep-alive, UDP discovery refresh, and clean teardown. -/// -internal sealed class MmsHostSessionService { +/// Host lobby lifecycle: creation, heartbeat, UDP discovery, and teardown. +internal sealed class MmsHostSessionService : IDisposable { /// The base HTTP URL of the MMS server (e.g. https://mms.example.com). private readonly string _baseUrl; - /// - /// Hostname used for UDP NAT hole-punch discovery, or null if discovery - /// is disabled for this session. - /// + /// NAT discovery hostname; null if disabled. private readonly string? _discoveryHost; /// Synchronization lock for thread-safe access to session state (tokens, lobby IDs). private readonly object _sessionLock = new(); + /// Prevents concurrent lobby creation. + private int _creationLock; + + /// Whether this service instance has been disposed. + private volatile bool _disposed; + /// WebSocket handler that receives real-time MMS server events. private readonly MmsWebSocketHandler _webSocket; - /// - /// Bearer token issued by MMS when the lobby was created. Used to authenticate - /// heartbeat and delete requests. null when no lobby is active. - /// - private string? _hostToken; + /// MMS session bearer token; null if no active lobby. + private volatile string? _hostToken; /// /// The MMS lobby ID of the currently active session, or null when no @@ -43,11 +40,13 @@ internal sealed class MmsHostSessionService { /// private string? _currentLobbyId; + /// MMS lobby keep-alive timer. + private Timer? _heartbeatTimer; + /// - /// Timer that fires at regular intervals to keep - /// the MMS lobby alive. null when no lobby is active. + /// Cancellation source to suppress in-flight heartbeat continuations after the lobby is closed. /// - private Timer? _heartbeatTimer; + private CancellationTokenSource? _heartbeatCts; /// The number of players currently connected to this host's session. private int _connectedPlayers; @@ -109,33 +108,20 @@ public event Action? StartPunchRequested { remove => _webSocket.StartPunchRequested -= value; } - /// - /// Updates the number of players connected to this host and immediately sends - /// a heartbeat to MMS if the count has changed and a lobby is active. - /// Negative values are clamped to zero. - /// - /// New connected-player count. + /// Updates player count; triggers immediate heartbeat if changed. public void SetConnectedPlayers(int count) { - var normalized = System.Math.Max(0, count); - var previous = Interlocked.Exchange(ref _connectedPlayers, normalized); - if (previous == normalized) return; + if (_disposed) throw new ObjectDisposedException(nameof(MmsHostSessionService)); + + if (count < 0) + throw new ArgumentOutOfRangeException(nameof(count), count, "Connected player count cannot be negative."); + + var previous = Interlocked.Exchange(ref _connectedPlayers, count); + if (previous == count) return; - // Note: _hostToken can be nulled concurrently after this check, - // but SendHeartbeat safely re-checks it under _sessionLock. if (_hostToken != null) SendHeartbeat(state: null); } - /// - /// Creates a new UDP lobby on MMS and activates the local session on success. - /// - /// UDP port this host is listening on. - /// Whether the lobby should appear in public listings. - /// Game version string used for matchmaking compatibility checks. - /// Lobby subtype (e.g. casual, ranked). - /// - /// A tuple of (lobbyCode, lobbyName, hostDiscoveryToken) on success, - /// or (null, null, null) if the request failed or the response was invalid. - /// + /// Creates lobby on MMS and activates session. public async Task<((string? lobbyCode, string? lobbyName, string? hostDiscoveryToken) result, MatchmakingError error)> CreateLobbyAsync( @@ -144,28 +130,42 @@ public async string gameVersion, PublicLobbyType lobbyType ) { - var (buffer, length) = MmsJsonParser.FormatCreateLobbyJson( - hostPort, isPublic, gameVersion, lobbyType, MmsUtilities.GetLocalIpAddress() - ); + if (_disposed) throw new ObjectDisposedException(nameof(MmsHostSessionService)); + + if (Interlocked.CompareExchange(ref _creationLock, 1, 0) != 0) + return ((null, null, null), MatchmakingError.NetworkFailure); + try { - var response = await MmsHttpClient.PostJsonAsync( - $"{_baseUrl}{MmsRoutes.Lobby}", - new string(buffer, 0, length) + lock (_sessionLock) { + if (_disposed) throw new ObjectDisposedException(nameof(MmsHostSessionService)); + if (_hostToken != null) return ((null, null, null), MatchmakingError.NetworkFailure); + } + + var (buffer, length) = MmsJsonParser.FormatCreateLobbyJson( + hostPort, isPublic, gameVersion, lobbyType, MmsUtilities.GetLocalIpAddress() ); - if (!response.Success || response.Body == null) - return ((null, null, null), response.Error); - - return TryActivateLobby( - response.Body, - "CreateLobby", - out var lobbyName, - out var lobbyCode, - out var hostDiscoveryToken - ) - ? ((lobbyCode, lobbyName, hostDiscoveryToken), MatchmakingError.None) - : ((null, null, null), MatchmakingError.NetworkFailure); + try { + var response = await MmsHttpClient.PostJsonAsync( + $"{_baseUrl}{MmsRoutes.Lobby}", + new string(buffer, 0, length) + ); + if (!response.Success || response.Body == null) + return ((null, null, null), response.Error); + + return TryActivateLobby( + response.Body, + "CreateLobby", + out var lobbyName, + out var lobbyCode, + out var hostDiscoveryToken + ) + ? ((lobbyCode, lobbyName, hostDiscoveryToken), MatchmakingError.None) + : ((null, null, null), MatchmakingError.NetworkFailure); + } finally { + MmsJsonParser.ReturnBuffer(buffer); + } } finally { - MmsJsonParser.ReturnBuffer(buffer); + Interlocked.Exchange(ref _creationLock, 0); } } @@ -176,40 +176,48 @@ out var hostDiscoveryToken /// Whether the lobby should appear in public MMS listings. /// Game version string for matchmaking compatibility. /// - /// The MMS lobby code on success, or null if the request failed or the - /// response was invalid. + /// Returns the MMS lobby code on success; otherwise returns null along with a MatchmakingError describing the failure. /// public async Task<(string? lobbyCode, MatchmakingError error)> RegisterSteamLobbyAsync( string steamLobbyId, bool isPublic, string gameVersion ) { - var response = await MmsHttpClient.PostJsonAsync( - $"{_baseUrl}{MmsRoutes.Lobby}", - BuildSteamLobbyJson(steamLobbyId, isPublic, gameVersion) - ); - if (!response.Success || response.Body == null) - return (null, response.Error); + if (_disposed) throw new ObjectDisposedException(nameof(MmsHostSessionService)); - if (!TryActivateLobby(response.Body, "RegisterSteamLobby", out _, out var lobbyCode, out _)) + if (Interlocked.CompareExchange(ref _creationLock, 1, 0) != 0) return (null, MatchmakingError.NetworkFailure); - return (lobbyCode, MatchmakingError.None); + try { + lock (_sessionLock) { + if (_disposed) throw new ObjectDisposedException(nameof(MmsHostSessionService)); + if (_hostToken != null) return (null, MatchmakingError.NetworkFailure); + } + + var response = await MmsHttpClient.PostJsonAsync( + $"{_baseUrl}{MmsRoutes.Lobby}", + BuildSteamLobbyJson(steamLobbyId, isPublic, gameVersion) + ); + if (!response.Success || response.Body == null) + return (null, response.Error); + + return !TryActivateLobby(response.Body, "RegisterSteamLobby", out _, out var lobbyCode, out _) + ? (null, MatchmakingError.NetworkFailure) + : (lobbyCode, MatchmakingError.None); + } finally { + Interlocked.Exchange(ref _creationLock, 0); + } } - /// - /// Tears down the active lobby: stops the heartbeat timer, cancels UDP discovery, - /// closes the WebSocket connection, and sends a DELETE to MMS in the background. - /// Does nothing if no lobby is currently active. - /// + /// Stops session: heartbeat, discovery, and socket. Deletes lobby on MMS. public void CloseLobby() { (string token, string? lobbyId)? snapshot; lock (_sessionLock) { if (_hostToken == null) return; snapshot = SnapshotAndClearSessionUnsafe(); + StopHeartbeat(); } - StopHeartbeat(); StopHostDiscoveryRefresh(); _webSocket.Stop(); @@ -219,37 +227,41 @@ public void CloseLobby() { /// /// Starts the WebSocket connection that receives pending-client and punch events - /// from MMS. Requires an active lobby ( must have - /// succeeded first). + /// from MMS. Requires an active lobby; logs an error and returns if no host token is available. /// public void StartWebSocketConnection() { + if (_disposed) throw new ObjectDisposedException(nameof(MmsHostSessionService)); + if (_hostToken == null) { - Logger.Error("MmsHostSessionService: cannot start WebSocket without a host token"); + Logger.Error("MmsHostSessionService: cannot start WebSocket without a host token."); return; } _webSocket.Start(_hostToken); } - /// - /// Starts a background task that sends periodic UDP discovery packets to MMS - /// for the duration of , - /// enabling MMS to learn this host's external IP and port for NAT hole-punching. - /// Any previously running refresh is stopped first. - /// Does nothing if is null. - /// + /// Starts periodic background UDP discovery for external IP learning. /// Session token sent inside each UDP packet. /// /// Callback that writes raw bytes through the caller's UDP socket to the given endpoint. /// public void StartHostDiscoveryRefresh(string hostDiscoveryToken, Action sendRawAction) { + if (_disposed) throw new ObjectDisposedException(nameof(MmsHostSessionService)); + if (_discoveryHost == null) return; - StopHostDiscoveryRefresh(); + var cts = new CancellationTokenSource(TimeSpan.FromSeconds(MmsProtocol.DiscoveryDurationSeconds)); - _hostDiscoveryRefreshCts = - new CancellationTokenSource(TimeSpan.FromSeconds(MmsProtocol.DiscoveryDurationSeconds)); - var cts = _hostDiscoveryRefreshCts; + var oldCts = Interlocked.Exchange(ref _hostDiscoveryRefreshCts, cts); + if (oldCts != null) { + try { + oldCts.Cancel(); + } catch (ObjectDisposedException) { + /*ignored*/ + } + + oldCts.Dispose(); + } MmsUtilities.RunBackground( RunHostDiscoveryRefreshAsync(hostDiscoveryToken, sendRawAction, cts), @@ -261,12 +273,18 @@ public void StartHostDiscoveryRefresh(string hostDiscoveryToken, Action /// Cancels the active UDP discovery refresh task, if any. /// Safe to call when no refresh is running. - /// Note: The CancellationTokenSource is not disposed here; disposal is deferred - /// to the finally block of RunHostDiscoveryRefreshAsync. /// public void StopHostDiscoveryRefresh() { - _hostDiscoveryRefreshCts?.Cancel(); - _hostDiscoveryRefreshCts = null; + var cts = Interlocked.Exchange(ref _hostDiscoveryRefreshCts, null); + if (cts == null) + return; + try { + cts.Cancel(); + } catch (ObjectDisposedException) { + /*ignored*/ + } + + cts.Dispose(); } /// @@ -282,19 +300,6 @@ private static string BuildSteamLobbyJson(string steamLobbyId, bool isPublic, st $"\"{MmsFields.GameVersionRequest}\":\"{MmsUtilities.EscapeJsonString(gameVersion)}\"," + $"\"{MmsFields.LobbyTypeRequest}\":\"steam\"}}"; - /// - /// Records the active lobby ID and host token, then starts the heartbeat timer. - /// - /// MMS lobby identifier. - /// Bearer token for authenticating subsequent MMS requests. - private void ActivateLobby(string lobbyId, string hostToken) { - lock (_sessionLock) { - _hostToken = hostToken; - _currentLobbyId = lobbyId; - } - - StartHeartbeat(); - } /// /// Captures the current session token and lobby ID, then clears both fields. @@ -307,16 +312,14 @@ private void ActivateLobby(string lobbyId, string hostToken) { /// at the moment of the snapshot. /// private (string token, string? lobbyId) SnapshotAndClearSessionUnsafe() { + System.Diagnostics.Debug.Assert(Monitor.IsEntered(_sessionLock)); var snapshot = (_hostToken!, _currentLobbyId); _hostToken = null; _currentLobbyId = null; return snapshot; } - /// - /// Parses an MMS lobby-activation response and, on success, calls - /// and logs the result. - /// + /// Validates and records lobby activation. Deletes if disposed mid-flight. /// Raw JSON response body from MMS. /// Human-readable operation name used in log messages. /// Receives the lobby display name, or null on failure. @@ -338,21 +341,34 @@ out string? hostDiscoveryToken out lobbyCode, out hostDiscoveryToken )) { - Logger.Error($"MmsHostSessionService: invalid {operation} response (length={response.Length})"); + Logger.Error($"MmsHostSessionService: Invalid {operation} response (length={response.Length})."); return false; } - ActivateLobby(lobbyId!, hostToken!); - Logger.Info($"MmsHostSessionService: {operation} succeeded for lobby {lobbyCode}"); + lock (_sessionLock) { + if (_disposed) { + _ = SafeDeleteLobbyAsync(hostToken!, lobbyId); + return false; + } + + _hostToken = hostToken; + _currentLobbyId = lobbyId; + _heartbeatFailureCount = 0; + StartHeartbeat(); + } + + Logger.Info($"MmsHostSessionService: {operation} succeeded for lobby {lobbyCode}."); return true; } /// /// Stops any existing heartbeat timer and starts a new one that fires /// every . + /// IMPORTANT: Caller must hold _sessionLock. /// private void StartHeartbeat() { StopHeartbeat(); + _heartbeatCts = new CancellationTokenSource(); _heartbeatTimer = new Timer( SendHeartbeat, null, MmsProtocol.HeartbeatIntervalMs, MmsProtocol.HeartbeatIntervalMs ); @@ -360,34 +376,51 @@ private void StartHeartbeat() { /// /// Disposes the heartbeat timer. Safe to call when no timer is active. + /// IMPORTANT: Caller must hold _sessionLock. /// private void StopHeartbeat() { _heartbeatTimer?.Dispose(); _heartbeatTimer = null; + if (_heartbeatCts == null) + return; + try { + _heartbeatCts.Cancel(); + } catch (ObjectDisposedException) { + /*ignored*/ + } + + _heartbeatCts.Dispose(); + _heartbeatCts = null; } /// /// Timer callback that POSTs the current connected-player count to the MMS - /// heartbeat endpoint. Fire-and-forget; failures are silently dropped. + /// heartbeat endpoint. Fire-and-forget with a continuation that tracks and logs consecutive failures. + /// Failures are not retried but are logged and tracked via a consecutive failure counter. /// /// Unused timer state; always null. private void SendHeartbeat(object? state) { string? token; + CancellationToken cancellationToken; lock (_sessionLock) { token = _hostToken; + if (token == null || _heartbeatCts == null) + return; + cancellationToken = _heartbeatCts.Token; } - if (token == null) return; - + var players = Interlocked.CompareExchange(ref _connectedPlayers, 0, 0); var heartbeatTask = MmsHttpClient.PostJsonAsync( $"{_baseUrl}{MmsRoutes.LobbyHeartbeat(token)}", - BuildHeartbeatJson(_connectedPlayers) + BuildHeartbeatJson(players) ); heartbeatTask.ContinueWith( task => { + if (cancellationToken.IsCancellationRequested) return; + if (task.IsFaulted) { var failures = Interlocked.Increment(ref _heartbeatFailureCount); - Logger.Debug($"MmsHostSessionService: heartbeat send faulted ({failures} consecutive failures)"); + Logger.Debug($"MmsHostSessionService: heartbeat send faulted ({failures} consecutive failures)."); return; } @@ -398,7 +431,7 @@ private void SendHeartbeat(object? state) { var rejectedFailures = Interlocked.Increment(ref _heartbeatFailureCount); Logger.Debug( - $"MmsHostSessionService: heartbeat rejected or failed ({rejectedFailures} consecutive failures)" + $"MmsHostSessionService: heartbeat rejected or failed ({rejectedFailures} consecutive failures)." ); }, TaskScheduler.Default @@ -415,15 +448,11 @@ private static string BuildHeartbeatJson(int connectedPlayers) => /// /// Backing task for . Runs - /// and disposes - /// when it completes, regardless of outcome. + /// . /// /// Token forwarded to . /// UDP send callback forwarded to . - /// - /// The that governs this refresh's lifetime. - /// Disposed here after the task ends. - /// + /// The active cancellation token source. private async Task RunHostDiscoveryRefreshAsync( string hostDiscoveryToken, Action sendRawAction, @@ -440,9 +469,13 @@ await UdpDiscoveryService.SendUntilCancelledAsync( cts.Token ); } finally { - cts.Dispose(); - if (ReferenceEquals(_hostDiscoveryRefreshCts, cts)) - _hostDiscoveryRefreshCts = null; + // If StopHostDiscoveryRefresh or a new Start request was called concurrently, + // they will have already swapped out _hostDiscoveryRefreshCts and disposed this cts. + // CompareExchange checks if we still own it; if so, we clear the field and dispose it ourselves. + var currentCts = Interlocked.CompareExchange(ref _hostDiscoveryRefreshCts, null, cts); + if (ReferenceEquals(currentCts, cts)) { + cts.Dispose(); + } } } @@ -456,10 +489,22 @@ await UdpDiscoveryService.SendUntilCancelledAsync( private async Task SafeDeleteLobbyAsync(string hostToken, string? lobbyId) { var response = await MmsHttpClient.DeleteAsync($"{_baseUrl}{MmsRoutes.LobbyDelete(hostToken)}"); if (response.Success) { - Logger.Info($"MmsHostSessionService: closed lobby {lobbyId}"); + Logger.Info($"MmsHostSessionService: closed lobby {lobbyId}."); return; } - Logger.Warn($"MmsHostSessionService: CloseLobby DELETE failed for lobby {lobbyId}"); + Logger.Warn($"MmsHostSessionService: CloseLobby DELETE failed for lobby {lobbyId}."); + } + + /// + /// Marks the service as disposed, prevents further lobby creation, and closes the active lobby if present. + /// + public void Dispose() { + Interlocked.Exchange(ref _creationLock, 1); + lock (_sessionLock) { + _disposed = true; + } + + CloseLobby(); } } diff --git a/SSMP/Networking/Matchmaking/Host/MmsWebSocketHandler.cs b/SSMP/Networking/Matchmaking/Host/MmsWebSocketHandler.cs index aef5faa..63ef27f 100644 --- a/SSMP/Networking/Matchmaking/Host/MmsWebSocketHandler.cs +++ b/SSMP/Networking/Matchmaking/Host/MmsWebSocketHandler.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Net.WebSockets; using System.Threading; using System.Threading.Tasks; @@ -9,16 +10,14 @@ namespace SSMP.Networking.Matchmaking.Host; -/// -/// Manages the persistent WebSocket connection between a lobby host and MMS. -/// MMS pushes control events over this channel to coordinate matchmaking flow. -/// Note: There is no automatic reconnection logic. If the connection drops mid-session, -/// it must be restarted explicitly by the caller via . -/// -internal sealed class MmsWebSocketHandler : IDisposable { +/// Host-MMS WebSocket manager. No auto-reconnect; manual required on drop. +internal sealed class MmsWebSocketHandler : IDisposable, IAsyncDisposable { /// The base WebSocket URL of the MMS service. private readonly string _wsBaseUrl; + /// Captures context (usually Unity main thread) for event marshaling. + private readonly SynchronizationContext? _mainThreadContext; + /// Synchronizes swaps of the active socket/CTS pair across overlapping start-stop cycles. private readonly object _stateGate = new(); @@ -28,47 +27,55 @@ internal sealed class MmsWebSocketHandler : IDisposable { /// Cancellation source for the background listening loop. private CancellationTokenSource? _cts; - /// - /// Generation counter used to invalidate older background runs. - /// Wraps around on overflow; collision probability is negligible in practice. - /// + /// Generation counter to invalidate stale background runs. private int _runVersion; + /// Awaited by to ensure clean exit. + private Task _runTask = Task.CompletedTask; + /// - /// Raised when MMS asks the host to refresh its NAT mapping. - /// Arguments: joinId, hostDiscoveryToken, serverTimeMs. + /// Maximum time to wait for a graceful WebSocket close handshake before + /// abandoning and disposing the socket. /// + private static readonly TimeSpan CloseHandshakeTimeout = TimeSpan.FromSeconds(2); + + /// Raised on NAT refresh request. Marshaled to construction thread. public event Action? RefreshHostMappingRequested; /// /// Raised when MMS signals both sides to start simultaneous hole-punch. /// Arguments: joinId, clientIp, clientPort, hostPort, startTimeMs. + /// + /// Handlers are always invoked on the that was + /// active when this handler was constructed (typically the Unity main thread). + /// Calling or from within a handler is safe. + /// Calling and awaiting it inline from within a handler + /// will deadlock; schedule the await on a separate task instead. + /// + /// + /// Ordering: Events are posted to the main-thread queue in arrival order, + /// but Unity processes posted callbacks at its own schedule (typically the next + /// frame). Two events received in rapid succession are guaranteed to execute in + /// order, but may execute across different frames. + /// /// public event Action? StartPunchRequested; - /// - /// Raised when MMS confirms the host mapping has been learned and refresh - /// packets can stop. - /// + /// Raised on mapping confirmation. Marshaled to construction thread. public event Action? HostMappingReceived; - /// - /// Initialises a new . - /// + /// Initializes handler. Capture current for marshaling. /// Base WebSocket URL of the MMS service (e.g. wss://mms.example.com). public MmsWebSocketHandler(string wsBaseUrl) { _wsBaseUrl = wsBaseUrl; + _mainThreadContext = SynchronizationContext.Current; } - /// - /// Opens the WebSocket connection and begins listening for host push events. - /// Any previously active connection is stopped first. - /// Runs on a background thread; returns immediately. - /// + /// Opens connection and starts listener. Stops existing connection first. /// Bearer token used to authenticate the WebSocket URL. public void Start(string hostToken) { var runVersion = InvalidateActiveRun(); - MmsUtilities.RunBackground( + _runTask = MmsUtilities.RunBackground( RunAsync(hostToken, runVersion), nameof(MmsWebSocketHandler), "host WebSocket listener" @@ -84,31 +91,57 @@ public void Stop() { } /// + /// + /// + /// This overload calls but does not await the + /// background task. If the background loop is still running when this returns, any + /// in-flight I/O may complete after the caller has moved on. + /// Prefer in async contexts to guarantee the background + /// task has fully exited before disposal completes. + /// + /// public void Dispose() => Stop(); + /// Stops and awaits background task. Prefer over in async contexts. + public async ValueTask DisposeAsync() { + Stop(); + await _runTask.ConfigureAwait(false); + } + /// - /// Entry point for the background task. Connects the socket, runs the - /// receive loop, then tears down and logs the disconnection. + /// Entry point for the background task. Connects the socket, runs the receive + /// loop, then drains any remaining queued events before tearing down. + /// Each run creates its own isolated event queue so that overlapping start-stop + /// cycles cannot steal or drop events across runs. /// /// Bearer token used to build the WebSocket URL. /// Generation number captured when this run was started. private async Task RunAsync(string hostToken, int runVersion) { var cts = new CancellationTokenSource(); var socket = new ClientWebSocket(); + var eq = new EventQueue(); if (!TryRegisterRun(runVersion, socket, cts)) { cts.Dispose(); socket.Dispose(); + eq.Dispose(); return; } try { await ConnectAsync(socket, hostToken, cts.Token); - await ReceiveLoopAsync(socket, cts.Token); + await ReceiveLoopAsync(socket, cts.Token, eq); } catch (Exception ex) when (ex is not OperationCanceledException) { Logger.Error($"MmsWebSocketHandler: error - {ex.Message}"); } finally { - TearDownSocket(runVersion, socket, cts); + // Enqueue a null sentinel so DrainEventQueueAsync exits its wait loop. + eq.Enqueue(null); + // Runs unconditionally to the null sentinel; + // no cancellation so queued events + // are never dropped on shutdown. + await DrainEventQueueAsync(eq); + eq.Dispose(); + await TearDownSocket(runVersion, socket, cts); } } @@ -125,13 +158,14 @@ private async Task ConnectAsync(ClientWebSocket socket, string hostToken, Cancel } /// - /// Reads messages from until the connection closes or + /// Reads messages from the supplied until the connection closes or /// cancellation is requested. Each text frame is forwarded to - /// . + /// . Events are enqueued rather than raised directly. /// /// The WebSocket instance owned by the current run. /// Cancellation token that ends the receive loop. - private async Task ReceiveLoopAsync(ClientWebSocket socket, CancellationToken cancellationToken) { + /// Run-local event queue for marshaling event invocations. + private async Task ReceiveLoopAsync(ClientWebSocket socket, CancellationToken cancellationToken, EventQueue eq) { while (socket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested) { WebSocketMessageType messageType; string? message; @@ -145,18 +179,62 @@ private async Task ReceiveLoopAsync(ClientWebSocket socket, CancellationToken ca if (messageType == WebSocketMessageType.Close) break; if (messageType != WebSocketMessageType.Text || string.IsNullOrEmpty(message)) continue; - HandleMessage(message); + HandleMessage(message, eq); + } + } + + /// + /// Waits for items in the run-local and dispatches them + /// sequentially until a sentinel is dequeued, indicating + /// shutdown. Runs unconditionally to completion so that no already-queued events + /// are dropped on shutdown. + /// + /// Each action is posted through when one was + /// captured at construction, ensuring all public events are raised on the Unity + /// main thread. When no context is available the action is invoked directly. + /// + /// + /// Ordering vs. same-frame execution: + /// is fire-and-forget -- this loop does not await completion of the posted + /// callback before dequeuing the next item. Consequently: + /// + /// Events are enqueued on the main-thread context in strict arrival + /// order. + /// Unity drains its posted-callback queue at its own cadence (typically once + /// per frame), so two rapidly arriving events are ordered but may execute across + /// different frames. + /// Using SynchronizationContext.Send instead would enforce same-frame + /// execution but risks deadlocking if a handler calls + /// inline, so Post is the correct choice here. + /// See the Unity + /// Asynchronous Programming docs for background on UnitySynchronizationContext. + /// + /// + /// + /// Run-local event queue to drain. + private async Task DrainEventQueueAsync(EventQueue eq) { + while (true) { + await eq.WaitAsync(); + + if (!eq.TryDequeue(out var action) || action == null) + break; + + if (_mainThreadContext != null) + _mainThreadContext.Post(static s => ((Action)s!)(), action); + else + action(); } } /// - /// Disposes and nulls the reference, then logs the - /// disconnection. Called from the finally block of . + /// Attempts a graceful WebSocket close handshake, clears shared references + /// if this run still owns them, then disposes the socket and cancellation source. + /// Called from the finally block of . /// /// Generation number for the run being torn down. /// The socket owned by that run. /// The cancellation source owned by that run. - private void TearDownSocket(int runVersion, ClientWebSocket socket, CancellationTokenSource cts) { + private async Task TearDownSocket(int runVersion, ClientWebSocket socket, CancellationTokenSource cts) { lock (_stateGate) { if (_runVersion == runVersion) { if (ReferenceEquals(_socket, socket)) @@ -167,6 +245,15 @@ private void TearDownSocket(int runVersion, ClientWebSocket socket, Cancellation } } + if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived) { + using var closeCts = new CancellationTokenSource(CloseHandshakeTimeout); + try { + await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "stopping", closeCts.Token); + } catch { + // best-effort only - swallow all exceptions + } + } + cts.Dispose(); socket.Dispose(); Logger.Info("MmsWebSocketHandler: disconnected"); @@ -174,20 +261,21 @@ private void TearDownSocket(int runVersion, ClientWebSocket socket, Cancellation /// /// Cancels any active run and returns the next valid version number. + /// The cancel is performed inside the lock to prevent a race where + /// disposes the CTS on the background thread + /// between the lock release and the Cancel() call on the calling thread. /// /// The generation number that should be used by the next background run. private int InvalidateActiveRun() { - CancellationTokenSource? previousCts; int nextVersion; lock (_stateGate) { - previousCts = _cts; + _cts?.Cancel(); _cts = null; _socket = null; nextVersion = unchecked(++_runVersion); } - previousCts?.Cancel(); return nextVersion; } @@ -215,14 +303,15 @@ private bool TryRegisterRun(int runVersion, ClientWebSocket socket, Cancellation /// Unrecognised actions are silently ignored. /// /// Decoded UTF-8 text frame received from MMS. - private void HandleMessage(string message) { + /// Run-local event queue for marshalling event invocations. + private void HandleMessage(string message, EventQueue eq) { var span = message.AsSpan(); var action = MmsJsonParser.ExtractValue(span, MmsFields.Action); switch (action) { - case MmsActions.RefreshHostMapping: HandleRefreshHostMapping(span); break; - case MmsActions.StartPunch: HandleStartPunch(span); break; - case MmsActions.HostMappingReceived: HandleHostMappingReceived(); break; + case MmsActions.RefreshHostMapping: HandleRefreshHostMapping(span, eq); break; + case MmsActions.StartPunch: HandleStartPunch(span, eq); break; + case MmsActions.HostMappingReceived: HandleHostMappingReceived(eq); break; case MmsActions.JoinFailed: HandleJoinFailed(message); break; default: Logger.Debug($"MmsWebSocketHandler: unknown action '{new string(action)}' mapped to message dropping"); @@ -232,12 +321,13 @@ private void HandleMessage(string message) { /// /// Handles a refresh_host_mapping message by extracting the join ID, - /// discovery token, and server timestamp, then raising + /// discovery token, and server timestamp, then enqueuing a raise of /// . Silently ignored if any required /// field is missing or unparseable. /// /// Span over the raw message text. - private void HandleRefreshHostMapping(ReadOnlySpan span) { + /// Run-local event queue for marshalling event invocations. + private void HandleRefreshHostMapping(ReadOnlySpan span, EventQueue eq) { var joinId = MmsJsonParser.ExtractValue(span, MmsFields.JoinId); var token = MmsJsonParser.ExtractValue(span, MmsFields.HostDiscoveryToken); var timeStr = MmsJsonParser.ExtractValue(span, MmsFields.ServerTimeMs); @@ -246,17 +336,18 @@ private void HandleRefreshHostMapping(ReadOnlySpan span) { return; Logger.Info($"MmsWebSocketHandler: {MmsActions.RefreshHostMapping} for join {joinId}"); - RefreshHostMappingRequested?.Invoke(joinId, token, time); + eq.Enqueue(() => RefreshHostMappingRequested?.Invoke(joinId, token, time)); } /// /// Handles a start_punch message by extracting the join ID, client - /// endpoint, host port, and start timestamp, then raising + /// endpoint, host port, and start timestamp, then enqueuing a raise of /// . Silently ignored if any required field /// is missing or unparseable. /// /// Span over the raw message text. - private void HandleStartPunch(ReadOnlySpan span) { + /// Run-local event queue for marshalling event invocations. + private void HandleStartPunch(ReadOnlySpan span, EventQueue eq) { var joinId = MmsJsonParser.ExtractValue(span, MmsFields.JoinId); var clientIp = MmsJsonParser.ExtractValue(span, MmsFields.ClientIp); var clientPortStr = MmsJsonParser.ExtractValue(span, MmsFields.ClientPort); @@ -271,16 +362,17 @@ private void HandleStartPunch(ReadOnlySpan span) { return; Logger.Info($"MmsWebSocketHandler: {MmsActions.StartPunch} for join {joinId} -> {clientIp}:{clientPort}"); - StartPunchRequested?.Invoke(joinId, clientIp, clientPort, hostPort, startTimeMs); + eq.Enqueue(() => StartPunchRequested?.Invoke(joinId, clientIp, clientPort, hostPort, startTimeMs)); } /// - /// Handles a host_mapping_received message by logging and raising - /// . + /// Handles a host_mapping_received message by logging and enqueuing + /// a raise of . /// - private void HandleHostMappingReceived() { + /// Run-local event queue for marshalling event invocations. + private void HandleHostMappingReceived(EventQueue eq) { Logger.Info($"MmsWebSocketHandler: {MmsActions.HostMappingReceived}"); - HostMappingReceived?.Invoke(); + eq.Enqueue(() => HostMappingReceived?.Invoke()); } /// @@ -292,4 +384,33 @@ private void HandleHostMappingReceived() { private static void HandleJoinFailed(string message) { Logger.Warn($"MmsWebSocketHandler: {MmsActions.JoinFailed} - {message}"); } + + /// + /// Run-local event queue that pairs a with a + /// to provide an async-wait, single-consumer dispatch + /// channel. Each creates its own instance so that + /// overlapping start-stop cycles cannot steal or drop events across runs. + /// + private sealed class EventQueue : IDisposable { + private readonly ConcurrentQueue _queue = new(); + private readonly SemaphoreSlim _semaphore = new(0); + + /// + /// Enqueues an action (or sentinel) and releases the + /// semaphore so the drain loop wakes. + /// + public void Enqueue(Action? action) { + _queue.Enqueue(action); + _semaphore.Release(); + } + + /// Waits until an item is available. + public Task WaitAsync() => _semaphore.WaitAsync(); + + /// Attempts to dequeue the next item. + public bool TryDequeue(out Action? action) => _queue.TryDequeue(out action); + + /// + public void Dispose() => _semaphore.Dispose(); + } } diff --git a/SSMP/Networking/Matchmaking/Join/MmsJoinCoordinator.cs b/SSMP/Networking/Matchmaking/Join/MmsJoinCoordinator.cs index 6f01353..976ccf8 100644 --- a/SSMP/Networking/Matchmaking/Join/MmsJoinCoordinator.cs +++ b/SSMP/Networking/Matchmaking/Join/MmsJoinCoordinator.cs @@ -10,11 +10,7 @@ namespace SSMP.Networking.Matchmaking.Join; -/// -/// Coordinates the client-side MMS matchmaking flow over a WebSocket connection. -/// Drives UDP mapping refresh when instructed by the server and returns the -/// synchronized punch-start data needed to begin NAT hole-punching. -/// +/// Client-side matchmaking coordinator. Drives UDP mapping and awaits hole-punch signals. internal sealed class MmsJoinCoordinator { /// Base HTTP URL of the MMS server (e.g. https://mms.example.com). private readonly string _baseUrl; @@ -63,34 +59,7 @@ public void Dispose() { } } - /// - /// Connects to the MMS join WebSocket for , processes - /// server-driven UDP mapping messages, and returns the punch-start payload once - /// the server signals it is time to begin hole-punching. - /// - /// The method drives the following server message sequence: - /// - /// begin_client_mappingStarts (or restarts) UDP discovery with the supplied client token. - /// client_mapping_receivedStops the active UDP discovery task. - /// start_punchStops discovery, parses the punch payload, waits until the scheduled start time, then returns. - /// join_failedInvokes with the server reason and returns null. - /// - /// - /// - /// Unique identifier for this join attempt, used to build the WebSocket URL. - /// - /// Callback that writes raw bytes through the caller's UDP socket to the given endpoint. - /// Forwarded to during the mapping phase. - /// - /// - /// Invoked with a human-readable reason string whenever the join attempt fails - /// (timeout, server rejection, or WebSocket error). Never invoked on success. - /// - /// Cancellation token that allows backing out of matchmaking mid-flow. - /// - /// A containing peer address and timing - /// information, or null if the attempt failed or timed out. - /// + /// Connects to join WebSocket and drives server-directed UDP mapping flow. public async Task CoordinateAsync( string joinId, Action sendRawAction, @@ -124,13 +93,7 @@ CancellationToken cancellationToken return null; } - /// - /// Connects to the MMS join WebSocket URL for - /// . - /// - /// The WebSocket client to connect. - /// Join session ID appended to the WebSocket path. - /// Cancellation token; typically the session timeout. + /// Connects to the MMS join WebSocket URL. private async Task ConnectAsync(ClientWebSocket socket, string joinId, CancellationToken ct) { var wsUrl = $"{MmsUtilities.ToWebSocketUrl(_baseUrl)}{MmsRoutes.JoinWebSocket(joinId)}" + @@ -139,20 +102,7 @@ private async Task ConnectAsync(ClientWebSocket socket, string joinId, Cancellat await socket.ConnectAsync(new Uri(wsUrl), ct); } - /// - /// Reads text frames from and dispatches each to - /// until the connection closes, the timeout fires, - /// or a terminal action (start_punch or join_failed) is received. - /// - /// The connected WebSocket client. - /// Cancellation source governing the overall session timeout. - /// UDP send callback forwarded to discovery tasks. - /// Mutable holder for the active discovery CTS. - /// Failure callback invoked when the server sends a terminal rejection reason. - /// - /// A if start_punch was received - /// and parsed successfully; null if the loop ended without a result. - /// + /// Reads WebSocket frames until terminal signal or timeout. private async Task RunMessageLoopAsync( ClientWebSocket socket, CancellationTokenSource timeoutCts, @@ -183,20 +133,7 @@ Action onJoinFailed return null; } - /// - /// Extracts the action field from and routes - /// it to the appropriate handler. Returns a result tuple indicating whether - /// the loop should terminate. - /// - /// Decoded UTF-8 text frame from MMS. - /// Session timeout source, passed through to start_punch handling. - /// UDP send callback, passed through to begin_client_mapping handling. - /// Mutable holder for the active discovery CTS. - /// Failure callback invoked when the message encodes a terminal join failure. - /// - /// (true, result) when the loop should exit and return the parsed result; - /// (false, null) to continue reading. - /// + /// Routes message actions to handlers. private async Task<(bool hasResult, MatchmakingJoinStartResult? result)> HandleMessage( string message, CancellationTokenSource timeoutCts, @@ -231,13 +168,7 @@ Action onJoinFailed return (false, null); } - /// - /// Handles a begin_client_mapping message by extracting the client - /// discovery token and restarting the UDP discovery task. - /// - /// Raw message text containing the clientDiscoveryToken field. - /// UDP send callback forwarded to the new discovery task. - /// Updated with the new discovery CTS. + /// Restarts UDP discovery with new token. private void RestartDiscovery( string message, Action sendRaw, @@ -248,18 +179,7 @@ DiscoverySession discovery discovery.Cts = StartDiscovery(token, sendRaw); } - /// - /// Handles a start_punch message by cancelling discovery, parsing the - /// punch payload, and waiting until the scheduled start time. - /// - /// Raw message text containing the punch payload fields. - /// Used as the cancellation token for the start-time delay. - /// Cancelled immediately on entry. - /// Invoked if payload parsing fails. - /// - /// The parsed , or null if the - /// payload could not be parsed. - /// + /// Stops discovery, parses payload, and delays until start time. private static async Task HandleStartPunchAsync( string message, CancellationTokenSource timeoutCts, diff --git a/SSMP/Networking/Matchmaking/Join/UdpDiscoveryService.cs b/SSMP/Networking/Matchmaking/Join/UdpDiscoveryService.cs index b0b7a1d..a862e68 100644 --- a/SSMP/Networking/Matchmaking/Join/UdpDiscoveryService.cs +++ b/SSMP/Networking/Matchmaking/Join/UdpDiscoveryService.cs @@ -9,22 +9,12 @@ namespace SSMP.Networking.Matchmaking.Join; -/// -/// Sends periodic UDP packets carrying a discovery token to the MMS discovery port. -/// MMS uses the incoming packets to learn the sender's external IP and port, -/// which it then shares with the peer to enable NAT hole-punching. -/// +/// Sends UDP discovery pulses to learn external IP/port for NAT hole-punching. internal static class UdpDiscoveryService { - /// - /// The expected length of the discovery token in bytes. - /// This corresponds to a 32-character hexadecimal UUID. - /// + /// Expected discovery token length in bytes. private const int ExpectedTokenByteLength = 32; - /// - /// Resolves the MMS discovery endpoint and sends token bytes every - /// until cancellation. - /// + /// Resolves endpoint and sends token pulses until cancellation. public static async Task SendUntilCancelledAsync( string discoveryHost, string token, diff --git a/SSMP/Networking/Matchmaking/MmsClient.cs b/SSMP/Networking/Matchmaking/MmsClient.cs index 51302a8..15ece9b 100644 --- a/SSMP/Networking/Matchmaking/MmsClient.cs +++ b/SSMP/Networking/Matchmaking/MmsClient.cs @@ -12,25 +12,14 @@ namespace SSMP.Networking.Matchmaking; -/// -/// High-level facade for MatchMaking Service (MMS) operations. -/// Keeps the existing public surface stable while delegating work to focused collaborators. -/// -/// Core collaborators: -/// -/// for host lifecycle -/// for query/read operations -/// for client join rendezvous -/// -/// +/// Public entry point for MMS coordination. Delegates to specialized services. internal sealed class MmsClient { private readonly MmsHostSessionService _hostSession; private readonly MmsLobbyQueryService _queries; private readonly MmsJoinCoordinator _joinCoordinator; - private MatchmakingError _lastError = MatchmakingError.None; - /// The last matchmaking error from the most recent operation. - public MatchmakingError LastMatchmakingError => _lastError; + /// Last error from most recent operation. + public MatchmakingError LastMatchmakingError { get; private set; } = MatchmakingError.None; /// public event Action? RefreshHostMappingRequested { @@ -73,16 +62,10 @@ public MmsClient( _joinCoordinator = joinCoordinator ?? new MmsJoinCoordinator(normalizedBaseUrl, discoveryHost); } - /// - /// Updates the number of connected remote players. - /// Immediately sends a heartbeat if the count changed and a lobby is active, - /// so MMS can clear stale host mappings as soon as the last player disconnects. - /// + /// Updates connected players; triggers heartbeat if count changes. public void SetConnectedPlayers(int count) => _hostSession.SetConnectedPlayers(count); - /// - /// Creates a new lobby on MMS and starts the heartbeat and host WebSocket. - /// + /// Creates lobby and starts host services. /// Lobby code, lobby name, and host discovery token; all null on failure. public async Task<(string? lobbyCode, string? lobbyName, string? hostDiscoveryToken)> CreateLobbyAsync( int hostPort, @@ -92,13 +75,11 @@ public MmsClient( ) { ClearErrors(); var result = await _hostSession.CreateLobbyAsync(hostPort, isPublic, gameVersion, lobbyType); - _lastError = result.error; + LastMatchmakingError = result.error; return result.result; } - /// - /// Registers an existing Steam lobby with MMS for discovery. - /// + /// Registers existing Steam lobby for discovery. /// MMS lobby code, or null on failure. public async Task RegisterSteamLobbyAsync( string steamLobbyId, @@ -107,26 +88,22 @@ public MmsClient( ) { ClearErrors(); var result = await _hostSession.RegisterSteamLobbyAsync(steamLobbyId, isPublic, gameVersion); - _lastError = result.error; + LastMatchmakingError = result.error; return result.lobbyCode; } - /// Closes the active lobby and deregisters it from MMS. + /// Closes active lobby and deregisters from MMS. public void CloseLobby() => _hostSession.CloseLobby(); - /// Looks up lobby join details from MMS. + /// Retrieves join details from MMS. public async Task JoinLobbyAsync(string lobbyId, int clientPort) { ClearErrors(); var result = await _queries.JoinLobbyAsync(lobbyId, clientPort); - _lastError = result.error; + LastMatchmakingError = result.error; return result.result; } - /// - /// Drives the client-side matchmaking WebSocket handshake. - /// Sends UDP discovery packets to establish the NAT mapping, then waits - /// for MMS to signal when both sides should begin simultaneous hole-punch. - /// + /// Drives UDP discovery and WebSocket hole-punch signal wait. public async Task CoordinateMatchmakingJoinAsync( string joinId, Action sendRawAction, @@ -136,48 +113,30 @@ public MmsClient( return await _joinCoordinator.CoordinateAsync(joinId, sendRawAction, SetJoinFailed, cancellationToken); } - /// - /// Fetches a list of public lobbies from MMS. - /// + /// Fetches public lobbies from MMS. public async Task?> GetPublicLobbiesAsync(PublicLobbyType? lobbyType = null) { ClearErrors(); var result = await _queries.GetPublicLobbiesAsync(lobbyType); - _lastError = result.error; + LastMatchmakingError = result.error; return result.lobbies; } - /// - /// Contacts MMS and verifies that its advertised matchmaking protocol version - /// matches the client's expected version. - /// - /// - /// when MMS is reachable and compatible, - /// when MMS is reachable but requires an update, - /// or when MMS could not be contacted. - /// + /// Checks server reaching and version compatibility. public async Task ProbeMatchmakingCompatibilityAsync() { ClearErrors(); var (isCompatible, error) = await _queries.ProbeMatchmakingCompatibilityAsync(); - _lastError = error; + LastMatchmakingError = error; return isCompatible; } - /// - /// Starts the WebSocket listener for host push events (pending clients / start-punch). - /// Must be called after creating a lobby. - /// + /// Starts host push event listener. Call after creating lobby. public void StartWebSocketConnection() => _hostSession.StartWebSocketConnection(); - /// - /// Fires off a background UDP discovery refresh for the given host token. - /// Runs for up to seconds. - /// + /// Triggers background UDP discovery refresh for given token. public void StartHostDiscoveryRefresh(string hostDiscoveryToken, Action sendRawAction) => _hostSession.StartHostDiscoveryRefresh(hostDiscoveryToken, sendRawAction); - /// - /// Stops the active host discovery refresh loop, if one is running. - /// + /// Stops active host discovery refresh loop. public void StopHostDiscoveryRefresh() => _hostSession.StopHostDiscoveryRefresh(); /// @@ -185,13 +144,13 @@ public void StartHostDiscoveryRefresh(string hostDiscoveryToken, Action private void SetJoinFailed(string reason) { Logger.Warn($"MmsClient: matchmaking join failed – {reason}"); - _lastError = MatchmakingError.JoinFailed; + LastMatchmakingError = MatchmakingError.JoinFailed; } /// /// Clears the internal and HTTP error states. /// private void ClearErrors() { - _lastError = MatchmakingError.None; + LastMatchmakingError = MatchmakingError.None; } } diff --git a/SSMP/Networking/Matchmaking/Parsing/MmsJsonParser.cs b/SSMP/Networking/Matchmaking/Parsing/MmsJsonParser.cs index 28234e0..2e9cfaa 100644 --- a/SSMP/Networking/Matchmaking/Parsing/MmsJsonParser.cs +++ b/SSMP/Networking/Matchmaking/Parsing/MmsJsonParser.cs @@ -7,24 +7,12 @@ namespace SSMP.Networking.Matchmaking.Parsing; -/// -/// Lightweight JSON helpers for reading and writing MMS API payloads. -/// These avoid a full JSON library dependency and are intentionally simple: -/// they assume well-formed server responses and handle only the small set -/// of value types (quoted strings, integers) that MMS actually returns. -/// +/// Minimal JSON reader/writer for MMS payloads. Assumes well-formed input; supports strings and numbers. internal static class MmsJsonParser { /// Shared pool for minimizing character buffer allocations. private static readonly ArrayPool CharPool = ArrayPool.Shared; - /// - /// Finds "key":value in and returns the value as a string. - /// Supports both quoted string values and unquoted numeric values. - /// Returns null when the key is absent. - /// - /// The JSON span to search. - /// The key to find. - /// The extracted value or null. + /// Extracts quoted string or unquoted numeric value for . public static string? ExtractValue(ReadOnlySpan json, string key) { Span searchKey = stackalloc char[key.Length + 2]; searchKey[0] = '"'; @@ -64,21 +52,7 @@ internal static class MmsJsonParser { return null; } - /// - /// Writes the CreateLobby JSON payload into a rented char buffer. - /// Returns the number of characters written. - /// The caller must return the buffer to after use. - /// - /// The host port. - /// Whether the lobby is public. - /// The game version string. - /// The type of the lobby. - /// Optional local IP address. - /// A tuple containing the buffer and the number of characters written. - /// - /// The HostLanIp field is serialized as "ip:port" (a socket address) - /// because the MMS server expects both values combined in a single string field. - /// + /// Writes CreateLobby JSON to rented buffer. Caller MUST return buffer to pool. public static (char[] buffer, int length) FormatCreateLobbyJson( int port, bool isPublic, diff --git a/SSMP/Networking/Matchmaking/Parsing/MmsResponseParser.cs b/SSMP/Networking/Matchmaking/Parsing/MmsResponseParser.cs index 9b5d6d8..5f44161 100644 --- a/SSMP/Networking/Matchmaking/Parsing/MmsResponseParser.cs +++ b/SSMP/Networking/Matchmaking/Parsing/MmsResponseParser.cs @@ -5,47 +5,14 @@ namespace SSMP.Networking.Matchmaking.Parsing; -/// -/// Parses raw MMS JSON response bodies into typed matchmaking models. -/// All methods are allocation-minimal where possible, operating on -/// slices via . -/// +/// Typed models parser for MMS JSON. Minimal allocations via . internal static class MmsResponseParser { /// /// Lookup key for scanning lobby object boundaries. /// private const string ConnectionDataKey = $"\"{MmsFields.ConnectionData}\":"; - /// - /// Parses a lobby creation or Steam-lobby registration response into the - /// fields required to activate a host session. - /// - /// Raw JSON response body from the MMS /lobby endpoint. - /// - /// Receives the lobby's connection data string (used internally as the lobby ID), - /// or null if absent. - /// - /// - /// Receives the bearer token used to authenticate subsequent heartbeat and delete - /// requests, or null if absent. - /// - /// - /// Receives the human-readable lobby display name, or null if absent. - /// - /// - /// Receives the short alphanumeric join code shown to players, or null if absent. - /// - /// - /// Receives the UDP discovery token sent during NAT hole-punch mapping, - /// or null if the server did not include one (e.g. for Steam lobbies). - /// - /// - /// true if all required fields (, - /// , , and - /// ) were present; false otherwise. - /// Note that being null does not - /// cause a failure. - /// + /// Parses lobby activation fields. Returns true if core fields present. public static bool TryParseLobbyActivation( string response, out string? lobbyId, @@ -80,23 +47,7 @@ out string? hostDiscoveryToken : BuildJoinLobbyResult(span, connectionData!, lobbyType); } - /// - /// Parses the public lobby list response from the MMS /lobbies endpoint. - /// Entries are extracted by scanning for successive "connectionData" keys, - /// treating each occurrence as the start of a new lobby object. - /// Entries missing connectionData or name are silently skipped. - /// An unrecognised lobbyType defaults to . - /// - /// Raw JSON response body containing a JSON array of lobby objects. - /// - /// A list of entries. Returns an empty list if the - /// response contains no parseable lobbies. - /// - /// - /// Lobby objects are limited by scanning for successive "connectionData": keys. - /// This assumes connectionData is the first field in each lobby object and that - /// no other field values contain the literal string "connectionData":. - /// + /// Scans JSON array for lobby objects by "connectionData" key boundaries. public static List ParsePublicLobbies(string response) { var result = new List(); var span = response.AsSpan(); @@ -114,18 +65,7 @@ public static List ParsePublicLobbies(string response) { return result; } - /// - /// Parses the start_punch WebSocket message received during the join - /// rendezvous phase, extracting the host endpoint and the scheduled punch start time. - /// - /// - /// A over the raw UTF-8 decoded message text. - /// - /// - /// A containing the host IP, port, and - /// Unix-millisecond start timestamp, or null if any required field - /// (hostIp, hostPort, startTimeMs) is missing or unparseable. - /// + /// Parses punch start payload. Returns null if required fields missing. public static MatchmakingJoinStartResult? ParseStartPunch(ReadOnlySpan span) { return !TryExtractPunchFields(span, out var hostIp, out var hostPort, out var startTimeMs) ? null diff --git a/SSMP/Networking/Matchmaking/Protocol/MmsModels.cs b/SSMP/Networking/Matchmaking/Protocol/MmsModels.cs index 10efb3e..5d066d2 100644 --- a/SSMP/Networking/Matchmaking/Protocol/MmsModels.cs +++ b/SSMP/Networking/Matchmaking/Protocol/MmsModels.cs @@ -1,8 +1,6 @@ namespace SSMP.Networking.Matchmaking.Protocol; -/// -/// Constants and configuration for the MatchMaking Service (MMS) protocol. -/// +/// MatchMaking Service (MMS) protocol constants. internal static class MmsProtocol { /// The current version of the matchmaking protocol. public const int CurrentVersion = 1; @@ -29,9 +27,7 @@ internal static class MmsProtocol { public const int DiscoveryIntervalMs = 500; } -/// -/// Represents errors that can occur during matchmaking operations. -/// +/// Matchmaking error classification. internal enum MatchmakingError { /// No error. None, @@ -46,9 +42,7 @@ internal enum MatchmakingError { NetworkFailure } -/// -/// Defines the types of lobbies supported by MMS. -/// +/// Supported MMS lobby subtypes. public enum PublicLobbyType { /// Standalone matchmaking through MMS. Matchmaking, @@ -57,9 +51,7 @@ public enum PublicLobbyType { Steam } -/// -/// Result of a successful lobby join request. -/// +/// Lobby join request metadata. internal sealed class JoinLobbyResult { /// The connection string for the lobby (e.g. "IP:Port" or Steam ID). public required string ConnectionData { get; init; } @@ -74,9 +66,7 @@ internal sealed class JoinLobbyResult { public string? JoinId { get; init; } } -/// -/// Result of a matchmaking join coordination. -/// +/// NAT hole-punch peer data and synchronized start time. internal sealed class MatchmakingJoinStartResult { /// The resolved public IP of the host. public required string HostIp { get; init; } @@ -88,9 +78,7 @@ internal sealed class MatchmakingJoinStartResult { public required long StartTimeMs { get; init; } } -/// -/// Public lobby information for the lobby browser. -/// +/// Registry entry for the lobby browser. /// The connection string for the lobby (e.g. "IP:Port" or Steam ID). /// The display name of the lobby. /// The type of the lobby (e.g. Matchmaking or Steam). diff --git a/SSMP/Networking/Matchmaking/Query/MmsLobbyQueryService.cs b/SSMP/Networking/Matchmaking/Query/MmsLobbyQueryService.cs index a511748..3b5ea04 100644 --- a/SSMP/Networking/Matchmaking/Query/MmsLobbyQueryService.cs +++ b/SSMP/Networking/Matchmaking/Query/MmsLobbyQueryService.cs @@ -9,33 +9,17 @@ namespace SSMP.Networking.Matchmaking.Query; -/// -/// Handles non-host MMS queries: joining an existing lobby, browsing public lobbies, -/// and probing server compatibility before attempting matchmaking. -/// +/// Non-host queries: joining, browsing public lobbies, and health/version probes. internal sealed class MmsLobbyQueryService { /// Base HTTP URL of the MMS server (e.g. https://mms.example.com). private readonly string _baseUrl; - - /// - /// Initializes a new . - /// - /// Base HTTP URL of the MMS server. + + /// Initializes a new . public MmsLobbyQueryService(string baseUrl) { _baseUrl = baseUrl; } - /// - /// Sends a join request for to MMS, advertising - /// the client's local UDP port so MMS can facilitate NAT hole-punching. - /// - /// The MMS lobby identifier to join. - /// The local UDP port this client is listening on. - /// - /// A containing the lobby type, connection data, - /// and join ID needed for the subsequent WebSocket rendezvous, or null - /// if the request failed or the response could not be parsed. - /// + /// Sends join request with local UDP port for NAT mapping. public async Task<(JoinLobbyResult? result, MatchmakingError error)> JoinLobbyAsync(string lobbyId, int clientPort) { var response = await MmsHttpClient.PostJsonAsync( @@ -51,19 +35,7 @@ public MmsLobbyQueryService(string baseUrl) { : (result, MatchmakingError.None); } - /// - /// Retrieves the list of currently open public lobbies from MMS, optionally - /// filtered by lobby type. - /// - /// - /// When non-null, restricts results to lobbies of the specified - /// . When null, all public lobbies are returned. - /// Matchmaking lobbies are additionally filtered by . - /// - /// - /// A list of entries, or null if the - /// request failed or the response could not be parsed. - /// + /// Retrieves public lobbies, filtered by type and protocol version. public async Task<(List? lobbies, MatchmakingError error)> GetPublicLobbiesAsync( PublicLobbyType? lobbyType = null ) { @@ -82,32 +54,7 @@ public MmsLobbyQueryService(string baseUrl) { return (MmsResponseParser.ParsePublicLobbies(response.Body), MatchmakingError.None); } - /// - /// Probes the MMS server's health endpoint to verify that the client and server - /// are running a compatible protocol version. - /// - /// - /// A tuple of: - /// - /// - /// isCompatible - /// - /// true if the server version matches ; - /// false if a version mismatch was detected; - /// null if the server could not be reached or returned an unparseable response. - /// - /// - /// - /// error - /// - /// on success; - /// Passes through the underlying if the server could not be reached; - /// if the response lacked a valid version field; - /// if the protocol versions differ. - /// - /// - /// - /// + /// Probes server health and protocol compatibility. public async Task<(bool? isCompatible, MatchmakingError error)> ProbeMatchmakingCompatibilityAsync() { var response = await MmsHttpClient.GetAsync($"{_baseUrl}{MmsRoutes.Root}"); if (!response.Success || response.Body == null) diff --git a/SSMP/Networking/Matchmaking/Transport/MmsHttpClient.cs b/SSMP/Networking/Matchmaking/Transport/MmsHttpClient.cs index fb665ad..9122f2b 100644 --- a/SSMP/Networking/Matchmaking/Transport/MmsHttpClient.cs +++ b/SSMP/Networking/Matchmaking/Transport/MmsHttpClient.cs @@ -8,11 +8,7 @@ namespace SSMP.Networking.Matchmaking.Transport; -/// -/// Thin HTTP transport layer for MMS API calls. -/// Owns a single shared instance for connection-pool reuse -/// and surfaces typed success/error results to callers. -/// +/// HTTP transport for MMS; shared for pool reuse. internal static class MmsHttpClient { /// Shared HTTP client instance for connection pooling. private static readonly HttpClient Http = CreateHttpClient(); @@ -58,14 +54,7 @@ public static async Task PostJsonAsync(string url, string json) } } - /// - /// Performs a DELETE request. - /// - /// - /// Returns per-call HTTP metadata and matchmaking error classification for both - /// successful and failed requests. Transport failures are reported as - /// without throwing. - /// + /// Returns HTTP metadata and classified matchmaking errors without throwing. public static async Task DeleteAsync(string url) { try { using var response = await Http.DeleteAsync(url); diff --git a/SSMP/Networking/Matchmaking/Utilities/MmsUtilities.cs b/SSMP/Networking/Matchmaking/Utilities/MmsUtilities.cs index d00fe3f..c0978e5 100644 --- a/SSMP/Networking/Matchmaking/Utilities/MmsUtilities.cs +++ b/SSMP/Networking/Matchmaking/Utilities/MmsUtilities.cs @@ -10,15 +10,9 @@ namespace SSMP.Networking.Matchmaking.Utilities; -/// -/// General-purpose utility helpers shared across MMS components. -/// All methods are stateless and free of side-effects. -/// +/// Stateless helpers for MMS protocol handling. internal static class MmsUtilities { - /// - /// Converts an HTTP or HTTPS URL to its WebSocket equivalent. - /// http:// -> ws:// and https:// -> wss://. - /// + /// Converts http(s) to ws(s). Throws if not absolute. public static string ToWebSocketUrl(string httpUrl) { if (!Uri.TryCreate(httpUrl, UriKind.Absolute, out var uri)) throw new ArgumentException("Matchmaking URL must be an absolute URI.", nameof(httpUrl)); @@ -33,31 +27,17 @@ public static string ToWebSocketUrl(string httpUrl) { return builder.Uri.AbsoluteUri.TrimEnd('/'); } - /// - /// Returns the JSON literal for a boolean value: "true" or "false". - /// + /// Returns JSON literal for boolean. public static string BoolToJson(bool value) => value ? "true" : "false"; - /// - /// Observes a fire-and-forget task and logs unexpected failures. - /// + /// Logs unexpected failures in fire-and-forget task. /// The task to monitor. /// Component name included in failure logs. /// Human-readable operation label for diagnostics. - public static void RunBackground(Task task, string owner, string operationName) => - _ = ObserveAsync(task, owner, operationName); + public static Task RunBackground(Task task, string owner, string operationName) => + ObserveAsync(task, owner, operationName); - /// - /// Reads one complete text message from a , assembling fragmented frames. - /// - /// The connected client WebSocket to read from. - /// Cancellation token for the receive loop. - /// Maximum allowed payload size before the read fails. - /// - /// A tuple containing the terminal frame type and the decoded text payload. - /// Non-text messages and close frames return as the payload. - /// - /// Thrown if the assembled message exceeds . + /// Assembles text frames from WebSocket. Returns null payload for non-text/close. public static async Task<(WebSocketMessageType messageType, string? message)> ReceiveTextMessageAsync( ClientWebSocket socket, CancellationToken cancellationToken, @@ -88,14 +68,7 @@ public static void RunBackground(Task task, string owner, string operationName) } } - /// - /// Determines the local machine's outbound IPv4 address by connecting a - /// disposable UDP socket to a known external address. Does not transmit any data. - /// - /// - /// The local IP address as a string, or null if the address could not - /// be determined (e.g. no network interface available). - /// + /// Deterministically identifies outbound IPv4 via dummy UDP connect. public static string? GetLocalIpAddress() { try { using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, 0); diff --git a/SSMP/Networking/Transport/SteamP2P/SteamEncryptedTransport.cs b/SSMP/Networking/Transport/SteamP2P/SteamEncryptedTransport.cs index e9ed6d7..634be6c 100644 --- a/SSMP/Networking/Transport/SteamP2P/SteamEncryptedTransport.cs +++ b/SSMP/Networking/Transport/SteamP2P/SteamEncryptedTransport.cs @@ -241,7 +241,7 @@ private void ReceivePackets() { public void Disconnect() { if (!_isConnected) return; - // Signal shutdown first + // Signal shutdown first so the receive loop exits at the top of its next iteration. _isConnected = false; _receiveTokenSource?.Cancel(); @@ -257,8 +257,6 @@ public void Disconnect() { SteamNetworking.CloseP2PSessionWithUser(_remoteSteamId); } - _remoteSteamId = NilSteamId; - if (_receiveThread != null) { if (!_receiveThread.Join(5000)) { Logger.Warn("Steam P2P: Receive thread did not terminate within 5 seconds"); @@ -267,6 +265,9 @@ public void Disconnect() { _receiveThread = null; } + // Safe to zero connection state only after the thread has exited. + _remoteSteamId = NilSteamId; + _receiveTokenSource?.Dispose(); _receiveTokenSource = null; } From b344df1584f914460487c9949d574a377eabc4dd Mon Sep 17 00:00:00 2001 From: Liparakis Date: Sat, 11 Apr 2026 19:49:09 +0300 Subject: [PATCH 6/8] fix: Host matchmaking event dispatch and start DTLS earlier during hole punching. --- .../Matchmaking/Host/MmsWebSocketHandler.cs | 10 ++-- .../HolePunch/HolePunchEncryptedTransport.cs | 46 +++++++++++++++---- 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/SSMP/Networking/Matchmaking/Host/MmsWebSocketHandler.cs b/SSMP/Networking/Matchmaking/Host/MmsWebSocketHandler.cs index 63ef27f..e92bfde 100644 --- a/SSMP/Networking/Matchmaking/Host/MmsWebSocketHandler.cs +++ b/SSMP/Networking/Matchmaking/Host/MmsWebSocketHandler.cs @@ -130,16 +130,14 @@ private async Task RunAsync(string hostToken, int runVersion) { try { await ConnectAsync(socket, hostToken, cts.Token); + var drainTask = DrainEventQueueAsync(eq); await ReceiveLoopAsync(socket, cts.Token, eq); + // Signal the dispatcher to stop after all queued callbacks are posted. + eq.Enqueue(null); + await drainTask; } catch (Exception ex) when (ex is not OperationCanceledException) { Logger.Error($"MmsWebSocketHandler: error - {ex.Message}"); } finally { - // Enqueue a null sentinel so DrainEventQueueAsync exits its wait loop. - eq.Enqueue(null); - // Runs unconditionally to the null sentinel; - // no cancellation so queued events - // are never dropped on shutdown. - await DrainEventQueueAsync(eq); eq.Dispose(); await TearDownSocket(runVersion, socket, cts); } diff --git a/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransport.cs b/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransport.cs index a6891c4..34365f6 100644 --- a/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransport.cs +++ b/SSMP/Networking/Transport/HolePunch/HolePunchEncryptedTransport.cs @@ -1,6 +1,7 @@ using System; using System.Net; using System.Net.Sockets; +using System.Threading.Tasks; using System.Threading; using SSMP.Logging; using SSMP.Networking.Client; @@ -56,6 +57,11 @@ internal class HolePunchEncryptedTransport : IEncryptedTransport { /// private const int PunchPacketDelayMs = 50; + /// + /// Small synchronous burst sent before DTLS starts so port-restricted NATs see the peer endpoint immediately. + /// + private const int InitialPunchPacketCount = 5; + /// /// The IP address used for self-connecting (host connecting to own server). /// Localhost connections bypass hole-punching as no NAT traversal is needed. @@ -235,9 +241,10 @@ public void Disconnect() { /// Hole-punching sequence: /// 1. Reuse pre-bound socket from STUN discovery (or create new one) /// 2. Configure socket to ignore ICMP Port Unreachable errors - /// 3. Send 100 "PUNCH" packets over 5 seconds to open NAT mapping + /// 3. Send a short priming burst to open the NAT mapping /// 4. Connect socket to peer endpoint - /// 5. Return socket for DTLS handshake + /// 5. Continue punching in the background while DTLS handshakes + /// 6. Return socket for DTLS handshake /// private static Socket PerformHolePunch(string address, int port) { // Attempt to reuse the socket passed from ConnectInterface @@ -266,14 +273,11 @@ private static Socket PerformHolePunch(string address, int port) { // Parse target endpoint var endpoint = new IPEndPoint(IPAddress.Parse(address), port); - Logger.Debug($"HolePunch: Sending {PunchPacketCount} punch packets to {endpoint}"); + Logger.Debug($"HolePunch: Sending initial punch burst ({InitialPunchPacketCount} packets) to {endpoint}"); - // Send punch packets to create/maintain NAT mapping - // Each packet refreshes the NAT timer and increases chance of success - for (var i = 0; i < PunchPacketCount; i++) { + // Prime the NAT mapping immediately before DTLS begins. + for (var i = 0; i < InitialPunchPacketCount; i++) { socket.SendTo(PunchPacket, endpoint); - - // Wait between packets to spread them over time Thread.Sleep(PunchPacketDelayMs); } @@ -281,6 +285,7 @@ private static Socket PerformHolePunch(string address, int port) { // This is important for DTLS which expects point-to-point communication socket.Connect(endpoint); + StartBackgroundPunchBurst(socket, endpoint); Logger.Info($"HolePunch: NAT traversal complete, socket connected to {endpoint}"); return socket; } catch (Exception ex) { @@ -290,6 +295,31 @@ private static Socket PerformHolePunch(string address, int port) { } } + /// + /// Continues sending punch packets while DTLS handshakes so stricter NATs keep the mapping alive. + /// + private static void StartBackgroundPunchBurst(Socket socket, IPEndPoint endpoint) { + _ = Task.Run(() => { + try { + Logger.Debug( + $"HolePunch: Continuing background punch burst ({PunchPacketCount - InitialPunchPacketCount} packets) to {endpoint}" + ); + for (var i = InitialPunchPacketCount; i < PunchPacketCount; i++) { + socket.Send(PunchPacket); + Thread.Sleep(PunchPacketDelayMs); + } + + Logger.Debug($"HolePunch: Background punch burst complete to {endpoint}"); + } catch (ObjectDisposedException) { + // Socket closed during disconnect or failed handshake. + } catch (SocketException ex) { + Logger.Debug($"HolePunch: Background punch burst stopped for {endpoint}: {ex.Message}"); + } catch (Exception ex) { + Logger.Warn($"HolePunch: Background punch burst failed for {endpoint}: {ex.Message}"); + } + }); + } + /// /// Handles data received from the DTLS client. /// Forwards decrypted data to subscribers of . From 850b4700bac1906bd05eec9e7d628d63d5626e91 Mon Sep 17 00:00:00 2001 From: Liparakis Date: Sat, 11 Apr 2026 21:34:27 +0300 Subject: [PATCH 7/8] feat: Swapped manual JSON parsing with NewtonSoft. --- .../Matchmaking/Parsing/MmsJsonParser.cs | 275 ++++++------------ .../Matchmaking/Parsing/MmsResponseParser.cs | 266 ++++++++--------- 2 files changed, 205 insertions(+), 336 deletions(-) diff --git a/SSMP/Networking/Matchmaking/Parsing/MmsJsonParser.cs b/SSMP/Networking/Matchmaking/Parsing/MmsJsonParser.cs index 2e9cfaa..786888f 100644 --- a/SSMP/Networking/Matchmaking/Parsing/MmsJsonParser.cs +++ b/SSMP/Networking/Matchmaking/Parsing/MmsJsonParser.cs @@ -1,58 +1,42 @@ using System; using System.Buffers; using System.Globalization; -using System.Text; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using SSMP.Networking.Matchmaking.Protocol; -using SSMP.Networking.Matchmaking.Utilities; namespace SSMP.Networking.Matchmaking.Parsing; -/// Minimal JSON reader/writer for MMS payloads. Assumes well-formed input; supports strings and numbers. +/// Reads and writes the small JSON payloads used by MMS. internal static class MmsJsonParser { - /// Shared pool for minimizing character buffer allocations. + /// Reuses temporary char buffers while serializing request payloads. private static readonly ArrayPool CharPool = ArrayPool.Shared; - /// Extracts quoted string or unquoted numeric value for . - public static string? ExtractValue(ReadOnlySpan json, string key) { - Span searchKey = stackalloc char[key.Length + 2]; - searchKey[0] = '"'; - key.AsSpan().CopyTo(searchKey[1..]); - searchKey[key.Length + 1] = '"'; - - var searchStart = 0; - while (searchStart < json.Length) { - var relative = json[searchStart..].IndexOf(searchKey, StringComparison.Ordinal); - if (relative == -1) return null; - - var matchPos = searchStart + relative; - - // Verify this is a full key match at a JSON field boundary: - // the character before the opening quote must not be alphanumeric - // (guards against "1gameVersion" matching a search for "gameVersion"). - if (matchPos > 0 && char.IsLetterOrDigit(json[matchPos - 1])) { - searchStart = matchPos + 1; - continue; - } - - var keyEnd = matchPos + searchKey.Length; - var valueStart = MmsUtilities.SkipWhitespace(json, keyEnd); - if (valueStart >= json.Length || json[valueStart] != ':') { - searchStart = matchPos + 1; - continue; - } - - valueStart = MmsUtilities.SkipWhitespace(json, valueStart + 1); - if (valueStart >= json.Length) return null; - - return json[valueStart] == '"' - ? ExtractStringValue(json, valueStart) - : ExtractNumericValue(json, valueStart); + /// + /// Parses a JSON string and returns the first property with the requested key. + /// Returns null when the payload is invalid or the key is missing. + /// + private static string? ExtractValue(string json, string key) { + try { + var token = JToken.Parse(json); + var property = FindPropertyRecursive(token, key); + return property == null ? null : ConvertTokenToString(property.Value); + } catch (JsonReaderException) { + return null; } - - return null; } - /// Writes CreateLobby JSON to rented buffer. Caller MUST return buffer to pool. + /// + /// Span-based wrapper for callers that already have a message buffer. + /// Converts once, then reuses the string overload. + /// + public static string? ExtractValue(ReadOnlySpan json, string key) => ExtractValue(json.ToString(), key); + + /// + /// Serializes the create-lobby payload into a rented char buffer. + /// The caller must return that buffer with . + /// public static (char[] buffer, int length) FormatCreateLobbyJson( int port, bool isPublic, @@ -60,165 +44,88 @@ public static (char[] buffer, int length) FormatCreateLobbyJson( PublicLobbyType lobbyType, string? hostLanIp ) { - var escapedGameVersion = MmsUtilities.EscapeJsonString(gameVersion); - var escapedHostLanIp = hostLanIp == null ? null : MmsUtilities.EscapeJsonString(hostLanIp); - var lobbyTypeValue = lobbyType == PublicLobbyType.Matchmaking ? "matchmaking" : "steam"; - // 96: fixed JSON structure overhead (braces, key names, quotes, colons, commas) - // 16: HostLanIp wrapper (key + colon + port digits) - // 24: MatchmakingVersion wrapper (key + colon + version digits) - // 32: safety margin for edge cases - var estimatedLength = - 96 + - escapedGameVersion.Length + - lobbyTypeValue.Length + - (escapedHostLanIp?.Length ?? 0) + - (hostLanIp != null ? 16 : 0) + - (lobbyType == PublicLobbyType.Matchmaking ? 24 : 0) + - 32; - - var buffer = CharPool.Rent(estimatedLength); - var span = buffer.AsSpan(); - var written = 0; - - Write(span, ref written, "{\""); - Write(span, ref written, MmsFields.HostPortRequest); - Write(span, ref written, "\":"); - Write(span, ref written, port); - Write(span, ref written, ",\""); - Write(span, ref written, MmsFields.IsPublicRequest); - Write(span, ref written, "\":"); - Write(span, ref written, MmsUtilities.BoolToJson(isPublic)); - Write(span, ref written, ",\""); - Write(span, ref written, MmsFields.GameVersionRequest); - Write(span, ref written, "\":\""); - Write(span, ref written, escapedGameVersion); - Write(span, ref written, "\",\""); - Write(span, ref written, MmsFields.LobbyTypeRequest); - Write(span, ref written, "\":\""); - Write(span, ref written, lobbyTypeValue); - Write(span, ref written, "\""); + var payload = new JObject { + [MmsFields.HostPortRequest] = port, + [MmsFields.IsPublicRequest] = isPublic, + [MmsFields.GameVersionRequest] = gameVersion, + [MmsFields.LobbyTypeRequest] = SerializeLobbyType(lobbyType) + }; if (hostLanIp != null) { - Write(span, ref written, ",\""); - Write(span, ref written, MmsFields.HostLanIpRequest); - Write(span, ref written, "\":\""); - Write(span, ref written, escapedHostLanIp!); - Write(span, ref written, ":"); - Write(span, ref written, port); - Write(span, ref written, "\""); + payload[MmsFields.HostLanIpRequest] = $"{hostLanIp}:{port}"; } + // Matchmaking lobbies carry a protocol version so MMS can reject stale clients. if (lobbyType == PublicLobbyType.Matchmaking) { - Write(span, ref written, ",\""); - Write(span, ref written, MmsFields.MatchmakingVersionRequest); - Write(span, ref written, "\":"); - Write(span, ref written, MmsProtocol.CurrentVersion); + payload[MmsFields.MatchmakingVersionRequest] = MmsProtocol.CurrentVersion; } - Write(span, ref written, "}"); - return (buffer, written); + var json = payload.ToString(Formatting.None); + var buffer = CharPool.Rent(json.Length); + json.AsSpan().CopyTo(buffer); + return (buffer, json.Length); } - /// - /// Returns a char buffer to the shared array pool. - /// + /// Returns a previously rented char buffer back to the shared pool. public static void ReturnBuffer(char[] buffer) => CharPool.Return(buffer); - /// - /// Extracts a quoted string value starting at the opening ". - /// Returns null if the closing quote is missing. - /// - private static string? ExtractStringValue(ReadOnlySpan json, int openQuoteIndex) { - var segmentStart = openQuoteIndex + 1; - StringBuilder? builder = null; - - for (var i = segmentStart; i < json.Length; i++) { - if (json[i] == '\\') { - if (i + 1 >= json.Length) return null; - if (builder == null) builder = new StringBuilder(json.Slice(segmentStart, i - segmentStart).ToString()); - else builder.Append(json.Slice(segmentStart, i - segmentStart)); - var escape = json[++i]; - switch (escape) { - case '"': builder.Append('"'); break; - case '\\': builder.Append('\\'); break; - case '/': builder.Append('/'); break; - case 'b': builder.Append('\b'); break; - case 'f': builder.Append('\f'); break; - case 'n': builder.Append('\n'); break; - case 'r': builder.Append('\r'); break; - case 't': builder.Append('\t'); break; - case 'u': - if (i + 4 >= json.Length) return null; - - var hex = json.Slice(i + 1, 4); - if (!ushort.TryParse(hex, NumberStyles.HexNumber, null, out var codePoint)) - return null; - - builder.Append((char) codePoint); - i += 4; - break; - default: - // Unknown escape sequence; treat as unparseable - return null; + /// Walks nested objects and arrays until it finds a matching property name. + private static JProperty? FindPropertyRecursive(JToken token, string key) { + switch (token.Type) { + case JTokenType.Object: { + foreach (var property in token.Children()) { + if (string.Equals(property.Name, key, StringComparison.Ordinal)) { + return property; + } + + var nestedMatch = FindPropertyRecursive(property.Value, key); + if (nestedMatch != null) { + return nestedMatch; + } } - segmentStart = i + 1; - continue; + break; } - - if (json[i] != '"') continue; - - if (builder == null) - return json.Slice(openQuoteIndex + 1, i - openQuoteIndex - 1).ToString(); - - builder.Append(json.Slice(segmentStart, i - segmentStart)); - return builder.ToString(); + case JTokenType.Array: + return token.Children().Select(child => FindPropertyRecursive(child, key)).OfType() + .FirstOrDefault(); + case JTokenType.None: + case JTokenType.Constructor: + case JTokenType.Property: + case JTokenType.Comment: + case JTokenType.Integer: + case JTokenType.Float: + case JTokenType.String: + case JTokenType.Boolean: + case JTokenType.Null: + case JTokenType.Undefined: + case JTokenType.Date: + case JTokenType.Raw: + case JTokenType.Bytes: + case JTokenType.Guid: + case JTokenType.Uri: + case JTokenType.TimeSpan: + default: + throw new ArgumentOutOfRangeException(); } return null; } - /// - /// Extracts an unquoted numeric value (digits, ., -) starting at - /// . Returns an empty string if no numeric characters are found. - /// - private static string ExtractNumericValue(ReadOnlySpan json, int start) { - var end = start; - - // A leading minus sign is valid only at the first character position. - if (end < json.Length && json[end] == '-') - end++; - - while (end < json.Length && - (char.IsDigit(json[end]) || json[end] == '.')) { - end++; - } - - return json.Slice(start, end - start).ToString(); - } - - /// - /// Copies a string value into the destination buffer at the current write position. - /// - /// The character buffer to write into. - /// The current write position; incremented by the length of . - /// The string value to copy. - private static void Write(Span destination, ref int written, ReadOnlySpan value) { - value.CopyTo(destination[written..]); - written += value.Length; - } - - /// - /// Formats an integer value into the destination buffer at the current write position. - /// - /// The character buffer to write into. - /// The current write position; incremented by the number of characters written. - /// The integer value to format. - /// Thrown when the integer cannot be formatted into the remaining buffer space. - private static void Write(Span destination, ref int written, int value) { - if (!value.TryFormat(destination[written..], out var charsWritten)) - throw new InvalidOperationException("Could not format MMS JSON integer."); - - written += charsWritten; - } + /// Maps the local enum to the lowercase lobby type string MMS expects. + private static string SerializeLobbyType(PublicLobbyType lobbyType) => lobbyType switch { + PublicLobbyType.Matchmaking => "matchmaking", + PublicLobbyType.Steam => "steam", + _ => "matchmaking" + }; + + /// Converts a JSON token into the string shape expected by existing callers. + private static string? ConvertTokenToString(JToken token) => token.Type switch { + JTokenType.Null => null, + JTokenType.String => token.Value(), + JTokenType.Integer => token.Value().ToString(CultureInfo.InvariantCulture), + JTokenType.Float => token.Value().ToString(CultureInfo.InvariantCulture), + JTokenType.Boolean => token.Value() ? "true" : "false", + _ => token.ToString(Formatting.None) + }; } diff --git a/SSMP/Networking/Matchmaking/Parsing/MmsResponseParser.cs b/SSMP/Networking/Matchmaking/Parsing/MmsResponseParser.cs index 5f44161..4781b68 100644 --- a/SSMP/Networking/Matchmaking/Parsing/MmsResponseParser.cs +++ b/SSMP/Networking/Matchmaking/Parsing/MmsResponseParser.cs @@ -1,18 +1,18 @@ using System; using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using SSMP.Logging; using SSMP.Networking.Matchmaking.Protocol; namespace SSMP.Networking.Matchmaking.Parsing; -/// Typed models parser for MMS JSON. Minimal allocations via . +/// Builds Matchmaking models from raw MMS response payloads. internal static class MmsResponseParser { /// - /// Lookup key for scanning lobby object boundaries. + /// Reads the create-lobby response fields needed to start a host session. + /// Returns false when any required field is missing. /// - private const string ConnectionDataKey = $"\"{MmsFields.ConnectionData}\":"; - - /// Parses lobby activation fields. Returns true if core fields present. public static bool TryParseLobbyActivation( string response, out string? lobbyId, @@ -21,187 +21,149 @@ public static bool TryParseLobbyActivation( out string? lobbyCode, out string? hostDiscoveryToken ) { - var span = response.AsSpan(); - lobbyId = MmsJsonParser.ExtractValue(span, MmsFields.ConnectionData); - hostToken = MmsJsonParser.ExtractValue(span, MmsFields.HostToken); - lobbyName = MmsJsonParser.ExtractValue(span, MmsFields.LobbyName); - lobbyCode = MmsJsonParser.ExtractValue(span, MmsFields.LobbyCode); - hostDiscoveryToken = MmsJsonParser.ExtractValue(span, MmsFields.HostDiscoveryToken); + var root = ParseJsonObject(response); + lobbyId = root?.Value(MmsFields.ConnectionData); + hostToken = root?.Value(MmsFields.HostToken); + lobbyName = root?.Value(MmsFields.LobbyName); + lobbyCode = root?.Value(MmsFields.LobbyCode); + hostDiscoveryToken = root?.Value(MmsFields.HostDiscoveryToken); + return lobbyId != null && hostToken != null && lobbyName != null && lobbyCode != null; } /// - /// Parses a /lobby/{id}/join response into a . + /// Parses the join response into the local result model. + /// Returns null when MMS omits the required connection fields. /// - /// Raw JSON response body from the MMS join endpoint. - /// - /// A populated on success, or null if - /// connectionData or lobbyType are missing, or if lobbyType - /// cannot be mapped to a known value. - /// public static JoinLobbyResult? ParseJoinLobbyResult(string response) { - var span = response.AsSpan(); + var root = ParseJsonObject(response); + if (root == null) { + return null; + } - return !TryExtractJoinRequiredFields(span, out var connectionData, out var lobbyType) - ? null - : BuildJoinLobbyResult(span, connectionData!, lobbyType); - } + var connectionData = root.Value(MmsFields.ConnectionData); + var lobbyTypeString = root.Value(MmsFields.LobbyType); - /// Scans JSON array for lobby objects by "connectionData" key boundaries. - public static List ParsePublicLobbies(string response) { - var result = new List(); - var span = response.AsSpan(); - var idx = 0; - - while (TryFindNextLobbySlice(span, ref idx, out var slice)) { - var entry = TryParsePublicLobbyEntry(slice); - if (entry != null) { - result.Add(entry); - } else { - Logger.Debug($"MmsResponseParser: Skipped unparseable lobby entry at index {idx}."); - } + if (connectionData == null || lobbyTypeString == null) { + return null; } - return result; + return new JoinLobbyResult { + ConnectionData = connectionData, + LobbyType = ParseLobbyType(lobbyTypeString), + LanConnectionData = root.Value(MmsFields.LanConnectionData), + JoinId = root.Value(MmsFields.JoinId) + }; } - /// Parses punch start payload. Returns null if required fields missing. - public static MatchmakingJoinStartResult? ParseStartPunch(ReadOnlySpan span) { - return !TryExtractPunchFields(span, out var hostIp, out var hostPort, out var startTimeMs) - ? null - : new MatchmakingJoinStartResult { HostIp = hostIp!, HostPort = hostPort, StartTimeMs = startTimeMs }; + /// + /// Parses the public lobby listing returned by MMS. + /// Returns an empty list when the payload is malformed. + /// + public static List ParsePublicLobbies(string response) { + try { + var lobbies = ParseLobbiesAsArray(response); + return ExtractValidLobbies(lobbies); + } catch (JsonReaderException) { + Logger.Debug("MmsResponseParser: Failed to parse public lobbies JSON."); + return []; + } } /// - /// Extracts and validates the two required fields for a join response: - /// connectionData and a parseable lobbyType. + /// Parses the start-punch message sent before synchronized hole punching. + /// Returns null when the host endpoint or timestamp is missing. /// - /// Span over the raw response text. - /// Receives the connection string, or null on failure. - /// Receives the parsed on success. - /// true if both fields were present and valid; false otherwise. - private static bool TryExtractJoinRequiredFields( - ReadOnlySpan span, - out string? connectionData, - out PublicLobbyType lobbyType - ) { - connectionData = MmsJsonParser.ExtractValue(span, MmsFields.ConnectionData); - var lobbyTypeString = MmsJsonParser.ExtractValue(span, MmsFields.LobbyType); + private static MatchmakingJoinStartResult? ParseStartPunch(string json) { + var root = ParseJsonObject(json); + if (root == null) { + return null; + } - if (connectionData == null || lobbyTypeString == null) { - lobbyType = default; - return false; + var hostIp = root.Value(MmsFields.HostIp); + var hostPort = root.Value(MmsFields.HostPort); + var startTime = root.Value(MmsFields.StartTimeMs); + + if (hostIp == null || hostPort == null || startTime == null) { + return null; } - // Default to Matchmaking for unknown lobby types, consistent with TryParsePublicLobbyEntry - if (!Enum.TryParse(lobbyTypeString, true, out lobbyType)) - lobbyType = PublicLobbyType.Matchmaking; + return new MatchmakingJoinStartResult { + HostIp = hostIp, + HostPort = hostPort.Value, + StartTimeMs = startTime.Value + }; + } - return true; + /// Span-based wrapper for callers that already work with message spans. + public static MatchmakingJoinStartResult? ParseStartPunch(ReadOnlySpan span) => + ParseStartPunch(span.ToString()); + + /// Normalizes lobby-list payloads so callers can always iterate a JSON array. + private static JArray ParseLobbiesAsArray(string response) { + return JToken.Parse(response) switch { + JArray array => array, + JObject obj => [obj], + _ => [] + }; } - /// - /// Constructs a from a validated span, populating - /// all optional fields alongside the required ones. - /// - /// Span over the raw response text. - /// Pre-validated connection string. - /// Pre-validated lobby type. - private static JoinLobbyResult BuildJoinLobbyResult( - ReadOnlySpan span, - string connectionData, - PublicLobbyType lobbyType - ) => new() { - ConnectionData = connectionData, - LobbyType = lobbyType, - LanConnectionData = MmsJsonParser.ExtractValue(span, MmsFields.LanConnectionData), - JoinId = MmsJsonParser.ExtractValue(span, MmsFields.JoinId) - }; + /// Filters malformed lobby entries and converts the valid ones to models. + private static List ExtractValidLobbies(JArray lobbies) { + var result = new List(lobbies.Count); - /// - /// Advances to the next "connectionData": key in - /// and returns the sub-span starting at that key. - /// - /// The full response span being scanned. - /// - /// Current scan position. Updated to one character past the found key so the - /// next call advances past the current entry. - /// - /// - /// Receives a sub-span beginning at the found key, suitable for field extraction. - /// Empty when the method returns false. - /// - /// true if another entry was found; false when the scan is exhausted. - private static bool TryFindNextLobbySlice( - ReadOnlySpan span, - ref int idx, - out ReadOnlySpan slice - ) { - var relative = span[idx..].IndexOf(ConnectionDataKey, StringComparison.Ordinal); - if (relative == -1) { - slice = default; - return false; + foreach (var token in lobbies) { + if (token is not JObject lobbyObject) { + Logger.Debug("MmsResponseParser: Skipped non-object lobby entry."); + continue; + } + + var lobby = TryParseLobbyEntry(lobbyObject); + if (lobby != null) { + result.Add(lobby); + } else { + Logger.Debug("MmsResponseParser: Skipped unparseable lobby entry."); + } } - var start = idx + relative; - var nextRelative = span[(start + ConnectionDataKey.Length)..] - .IndexOf(ConnectionDataKey, StringComparison.Ordinal); - var end = nextRelative == -1 ? span.Length : start + ConnectionDataKey.Length + nextRelative; - slice = span[start..end]; - idx = end; - return true; + return result; } - /// - /// Parses a single lobby object from a span that starts at its - /// "connectionData": key. Returns null if either - /// connectionData or name are absent. - /// An unrecognised lobbyType defaults to . - /// - /// Sub-span starting at the lobby object's connectionData key. - /// A , or null if required fields are missing. - private static PublicLobbyInfo? TryParsePublicLobbyEntry(ReadOnlySpan slice) { - var connectionData = MmsJsonParser.ExtractValue(slice, MmsFields.ConnectionData); - var name = MmsJsonParser.ExtractValue(slice, MmsFields.Name); + /// Parses one lobby entry from the public lobby list. + private static PublicLobbyInfo? TryParseLobbyEntry(JObject lobby) { + var connectionData = lobby.Value(MmsFields.ConnectionData); + var name = lobby.Value(MmsFields.Name); - if (connectionData == null || name == null) return null; + if (connectionData == null || name == null) { + return null; + } - var typeString = MmsJsonParser.ExtractValue(slice, MmsFields.LobbyType); - var code = MmsJsonParser.ExtractValue(slice, MmsFields.LobbyCode); + var lobbyTypeString = lobby.Value(MmsFields.LobbyType); + var lobbyType = lobbyTypeString != null + ? ParseLobbyType(lobbyTypeString) + : PublicLobbyType.Matchmaking; - var type = PublicLobbyType.Matchmaking; - if (typeString != null) Enum.TryParse(typeString, true, out type); + var lobbyCode = lobby.Value(MmsFields.LobbyCode) ?? string.Empty; - return new PublicLobbyInfo(connectionData, name, type, code ?? ""); + return new PublicLobbyInfo(connectionData, name, lobbyType, lobbyCode); } - /// - /// Extracts and validates all three required fields from a start_punch - /// message: hostIp, hostPort, and startTimeMs. - /// - /// Span over the raw message text. - /// Receives the host IP string, or null on failure. - /// Receives the parsed host port on success; 0 on failure. - /// Receives the parsed start timestamp on success; 0 on failure. - /// true if all three fields were present and parseable; false otherwise. - private static bool TryExtractPunchFields( - ReadOnlySpan span, - out string? hostIp, - out int hostPort, - out long startTimeMs - ) { - hostIp = MmsJsonParser.ExtractValue(span, MmsFields.HostIp); - var hostPortStr = MmsJsonParser.ExtractValue(span, MmsFields.HostPort); - var startTimeStr = MmsJsonParser.ExtractValue(span, MmsFields.StartTimeMs); - - if (hostIp == null || - !int.TryParse(hostPortStr, out hostPort) || - !long.TryParse(startTimeStr, out startTimeMs)) { - hostPort = 0; - startTimeMs = 0; - return false; + /// Parses a lobby type string and defaults unknown values to Matchmaking. + private static PublicLobbyType ParseLobbyType(string lobbyTypeString) { + if (Enum.TryParse(lobbyTypeString, ignoreCase: true, out PublicLobbyType lobbyType)) { + return lobbyType; } - return true; + Logger.Debug($"MmsResponseParser: Unknown lobby type '{lobbyTypeString}', defaulting to Matchmaking."); + return PublicLobbyType.Matchmaking; + } + + /// Parses a JSON object root and returns null when the payload is invalid. + private static JObject? ParseJsonObject(string json) { + try { + return JObject.Parse(json); + } catch (JsonReaderException) { + return null; + } } } From 267a6aae5331c0e67cb88b276d335f3903905b0d Mon Sep 17 00:00:00 2001 From: Liparakis Date: Sat, 11 Apr 2026 21:48:37 +0300 Subject: [PATCH 8/8] fix(mms-parsing): edge case fixes in json parser and response parser --- .../Matchmaking/Host/MmsHostSessionService.cs | 40 ++++----- .../Matchmaking/Parsing/MmsJsonParser.cs | 87 +++++++++++++------ .../Matchmaking/Parsing/MmsResponseParser.cs | 51 ++++++----- 3 files changed, 111 insertions(+), 67 deletions(-) diff --git a/SSMP/Networking/Matchmaking/Host/MmsHostSessionService.cs b/SSMP/Networking/Matchmaking/Host/MmsHostSessionService.cs index ca26161..45b2410 100644 --- a/SSMP/Networking/Matchmaking/Host/MmsHostSessionService.cs +++ b/SSMP/Networking/Matchmaking/Host/MmsHostSessionService.cs @@ -141,29 +141,27 @@ PublicLobbyType lobbyType if (_hostToken != null) return ((null, null, null), MatchmakingError.NetworkFailure); } - var (buffer, length) = MmsJsonParser.FormatCreateLobbyJson( + using var lease = MmsJsonParser.FormatCreateLobbyJson( hostPort, isPublic, gameVersion, lobbyType, MmsUtilities.GetLocalIpAddress() ); - try { - var response = await MmsHttpClient.PostJsonAsync( - $"{_baseUrl}{MmsRoutes.Lobby}", - new string(buffer, 0, length) - ); - if (!response.Success || response.Body == null) - return ((null, null, null), response.Error); - - return TryActivateLobby( - response.Body, - "CreateLobby", - out var lobbyName, - out var lobbyCode, - out var hostDiscoveryToken - ) - ? ((lobbyCode, lobbyName, hostDiscoveryToken), MatchmakingError.None) - : ((null, null, null), MatchmakingError.NetworkFailure); - } finally { - MmsJsonParser.ReturnBuffer(buffer); - } + + var response = await MmsHttpClient.PostJsonAsync( + $"{_baseUrl}{MmsRoutes.Lobby}", + new string(lease.Span) + ); + + if (!response.Success || response.Body == null) + return ((null, null, null), response.Error); + + return TryActivateLobby( + response.Body, + "CreateLobby", + out var lobbyName, + out var lobbyCode, + out var hostDiscoveryToken + ) + ? ((lobbyCode, lobbyName, hostDiscoveryToken), MatchmakingError.None) + : ((null, null, null), MatchmakingError.NetworkFailure); } finally { Interlocked.Exchange(ref _creationLock, 0); } diff --git a/SSMP/Networking/Matchmaking/Parsing/MmsJsonParser.cs b/SSMP/Networking/Matchmaking/Parsing/MmsJsonParser.cs index 786888f..031ed34 100644 --- a/SSMP/Networking/Matchmaking/Parsing/MmsJsonParser.cs +++ b/SSMP/Networking/Matchmaking/Parsing/MmsJsonParser.cs @@ -10,8 +10,38 @@ namespace SSMP.Networking.Matchmaking.Parsing; /// Reads and writes the small JSON payloads used by MMS. internal static class MmsJsonParser { - /// Reuses temporary char buffers while serializing request payloads. - private static readonly ArrayPool CharPool = ArrayPool.Shared; + /// + /// A scoped rental of a pooled char buffer. Disposing returns the buffer to the shared + /// pool, making double-return safe and ensuring the caller cannot forget to release. + /// + internal sealed class CharLease : IDisposable { + private static readonly ArrayPool Pool = ArrayPool.Shared; + private char[]? _buffer; + + /// The number of valid characters in . + private int Length { get; } + + /// The serialized JSON content, valid only while this lease is undisposed. + public ReadOnlySpan Span => _buffer != null + ? _buffer.AsSpan(0, Length) + : ReadOnlySpan.Empty; + + internal CharLease(char[] buffer, int length) { + _buffer = buffer; + Length = length; + } + + /// Returns the rented buffer to the pool. Safe to call more than once. + public void Dispose() { + var buf = _buffer; + if (buf == null) { + return; + } + + _buffer = null; + Pool.Return(buf); + } + } /// /// Parses a JSON string and returns the first property with the requested key. @@ -34,10 +64,10 @@ internal static class MmsJsonParser { public static string? ExtractValue(ReadOnlySpan json, string key) => ExtractValue(json.ToString(), key); /// - /// Serializes the create-lobby payload into a rented char buffer. - /// The caller must return that buffer with . + /// Serializes the create-lobby payload into a scoped . + /// Dispose the returned lease (e.g. with using) to return the buffer to the pool. /// - public static (char[] buffer, int length) FormatCreateLobbyJson( + public static CharLease FormatCreateLobbyJson( int port, bool isPublic, string gameVersion, @@ -45,10 +75,10 @@ public static (char[] buffer, int length) FormatCreateLobbyJson( string? hostLanIp ) { var payload = new JObject { - [MmsFields.HostPortRequest] = port, - [MmsFields.IsPublicRequest] = isPublic, + [MmsFields.HostPortRequest] = port, + [MmsFields.IsPublicRequest] = isPublic, [MmsFields.GameVersionRequest] = gameVersion, - [MmsFields.LobbyTypeRequest] = SerializeLobbyType(lobbyType) + [MmsFields.LobbyTypeRequest] = SerializeLobbyType(lobbyType) }; if (hostLanIp != null) { @@ -60,15 +90,12 @@ public static (char[] buffer, int length) FormatCreateLobbyJson( payload[MmsFields.MatchmakingVersionRequest] = MmsProtocol.CurrentVersion; } - var json = payload.ToString(Formatting.None); - var buffer = CharPool.Rent(json.Length); + var json = payload.ToString(Formatting.None); + var buffer = ArrayPool.Shared.Rent(json.Length); json.AsSpan().CopyTo(buffer); - return (buffer, json.Length); + return new CharLease(buffer, json.Length); } - /// Returns a previously rented char buffer back to the shared pool. - public static void ReturnBuffer(char[] buffer) => CharPool.Return(buffer); - /// Walks nested objects and arrays until it finds a matching property name. private static JProperty? FindPropertyRecursive(JToken token, string key) { switch (token.Type) { @@ -84,11 +111,16 @@ public static (char[] buffer, int length) FormatCreateLobbyJson( } } - break; + return null; } + case JTokenType.Array: - return token.Children().Select(child => FindPropertyRecursive(child, key)).OfType() + return token.Children() + .Select(child => FindPropertyRecursive(child, key)) + .OfType() .FirstOrDefault(); + + // Scalar and leaf token types contain no child properties to search. case JTokenType.None: case JTokenType.Constructor: case JTokenType.Property: @@ -106,26 +138,29 @@ public static (char[] buffer, int length) FormatCreateLobbyJson( case JTokenType.Uri: case JTokenType.TimeSpan: default: - throw new ArgumentOutOfRangeException(); + return null; } - - return null; } - /// Maps the local enum to the lowercase lobby type string MMS expects. + /// + /// Maps the local enum to the lowercase lobby type string MMS expects. + /// Throws for unrecognized values so that + /// new enum members are caught at development time rather than silently misrouted. + /// private static string SerializeLobbyType(PublicLobbyType lobbyType) => lobbyType switch { PublicLobbyType.Matchmaking => "matchmaking", - PublicLobbyType.Steam => "steam", - _ => "matchmaking" + PublicLobbyType.Steam => "steam", + _ => throw new ArgumentOutOfRangeException(nameof(lobbyType), lobbyType, + $"No MMS name defined for lobby type '{lobbyType}'.") }; /// Converts a JSON token into the string shape expected by existing callers. private static string? ConvertTokenToString(JToken token) => token.Type switch { - JTokenType.Null => null, - JTokenType.String => token.Value(), + JTokenType.Null => null, + JTokenType.String => token.Value(), JTokenType.Integer => token.Value().ToString(CultureInfo.InvariantCulture), - JTokenType.Float => token.Value().ToString(CultureInfo.InvariantCulture), + JTokenType.Float => token.Value().ToString(CultureInfo.InvariantCulture), JTokenType.Boolean => token.Value() ? "true" : "false", - _ => token.ToString(Formatting.None) + _ => token.ToString(Formatting.None) }; } diff --git a/SSMP/Networking/Matchmaking/Parsing/MmsResponseParser.cs b/SSMP/Networking/Matchmaking/Parsing/MmsResponseParser.cs index 4781b68..dfff463 100644 --- a/SSMP/Networking/Matchmaking/Parsing/MmsResponseParser.cs +++ b/SSMP/Networking/Matchmaking/Parsing/MmsResponseParser.cs @@ -13,6 +13,11 @@ internal static class MmsResponseParser { /// Reads the create-lobby response fields needed to start a host session. /// Returns false when any required field is missing. /// + /// + /// is intentionally excluded from the required-field + /// check: MMS omits it for non-matchmaking lobbies, so null is a valid server response and + /// callers must guard against it rather than treating it as a parse failure. + /// public static bool TryParseLobbyActivation( string response, out string? lobbyId, @@ -22,11 +27,11 @@ public static bool TryParseLobbyActivation( out string? hostDiscoveryToken ) { var root = ParseJsonObject(response); - lobbyId = root?.Value(MmsFields.ConnectionData); - hostToken = root?.Value(MmsFields.HostToken); - lobbyName = root?.Value(MmsFields.LobbyName); - lobbyCode = root?.Value(MmsFields.LobbyCode); - hostDiscoveryToken = root?.Value(MmsFields.HostDiscoveryToken); + lobbyId = root?.Value(MmsFields.ConnectionData); + hostToken = root?.Value(MmsFields.HostToken); + lobbyName = root?.Value(MmsFields.LobbyName); + lobbyCode = root?.Value(MmsFields.LobbyCode); + hostDiscoveryToken = root?.Value(MmsFields.HostDiscoveryToken); return lobbyId != null && hostToken != null && lobbyName != null && lobbyCode != null; } @@ -41,18 +46,18 @@ out string? hostDiscoveryToken return null; } - var connectionData = root.Value(MmsFields.ConnectionData); - var lobbyTypeString = root.Value(MmsFields.LobbyType); + var connectionData = root.Value(MmsFields.ConnectionData); + var lobbyTypeString = root.Value(MmsFields.LobbyType); if (connectionData == null || lobbyTypeString == null) { return null; } return new JoinLobbyResult { - ConnectionData = connectionData, - LobbyType = ParseLobbyType(lobbyTypeString), + ConnectionData = connectionData, + LobbyType = ParseLobbyType(lobbyTypeString), LanConnectionData = root.Value(MmsFields.LanConnectionData), - JoinId = root.Value(MmsFields.JoinId) + JoinId = root.Value(MmsFields.JoinId) }; } @@ -80,7 +85,7 @@ public static List ParsePublicLobbies(string response) { return null; } - var hostIp = root.Value(MmsFields.HostIp); + var hostIp = root.Value(MmsFields.HostIp); var hostPort = root.Value(MmsFields.HostPort); var startTime = root.Value(MmsFields.StartTimeMs); @@ -89,8 +94,8 @@ public static List ParsePublicLobbies(string response) { } return new MatchmakingJoinStartResult { - HostIp = hostIp, - HostPort = hostPort.Value, + HostIp = hostIp, + HostPort = hostPort.Value, StartTimeMs = startTime.Value }; } @@ -102,10 +107,15 @@ public static List ParsePublicLobbies(string response) { /// Normalizes lobby-list payloads so callers can always iterate a JSON array. private static JArray ParseLobbiesAsArray(string response) { return JToken.Parse(response) switch { - JArray array => array, - JObject obj => [obj], - _ => [] + JArray array => array, + JObject obj => [obj], + var other => LogAndReturnEmpty(other) }; + + static JArray LogAndReturnEmpty(JToken other) { + Logger.Debug($"MmsResponseParser: Unexpected lobby payload token type '{other.Type}', expected array or object."); + return []; + } } /// Filters malformed lobby entries and converts the valid ones to models. @@ -132,9 +142,12 @@ private static List ExtractValidLobbies(JArray lobbies) { /// Parses one lobby entry from the public lobby list. private static PublicLobbyInfo? TryParseLobbyEntry(JObject lobby) { var connectionData = lobby.Value(MmsFields.ConnectionData); - var name = lobby.Value(MmsFields.Name); + var name = lobby.Value(MmsFields.Name); + var lobbyCode = lobby.Value(MmsFields.LobbyCode); - if (connectionData == null || name == null) { + // All three fields are required: a missing lobby code would break client-side + // code entry and is not a valid state a well-formed MMS response can produce. + if (connectionData == null || name == null || lobbyCode == null) { return null; } @@ -143,8 +156,6 @@ private static List ExtractValidLobbies(JArray lobbies) { ? ParseLobbyType(lobbyTypeString) : PublicLobbyType.Matchmaking; - var lobbyCode = lobby.Value(MmsFields.LobbyCode) ?? string.Empty; - return new PublicLobbyInfo(connectionData, name, lobbyType, lobbyCode); }