From ecd789596d84c92dd87e09a3bb155ffb20cdd75a Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:11:44 +0200 Subject: [PATCH 1/8] fix(websocket): stop cancelled receives from aborting sockets --- .../Controllers/WebSocket/WebSocketController.cs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index 2f30ed1..6759905 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -279,15 +279,13 @@ public async Task Get([FromHeader(Name = "is-reconnect")] bool bIsReconnect) try { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); // timeout + // cancelling a pending receive aborts the socket receiveResult = await webSocket.ReceiveAsync( - new ArraySegment(buffer), cts.Token); + new ArraySegment(buffer), HttpContext.RequestAborted); } catch (OperationCanceledException) { - // No message received in 30s � send a keep-alive pong and continue waiting - await wsSess.SendPong(); - continue; + break; } catch (Exception ex) { From 81f027bd90aff39b4a0768d0d46802a8a084c453 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:12:54 +0200 Subject: [PATCH 2/8] feat(websocket): detect dead connections with protocol ping/pong --- GenOnlineService/Constants.cs | 61 ++++++++++++----------------------- GenOnlineService/Program.cs | 3 +- 2 files changed, 22 insertions(+), 42 deletions(-) diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index a860c70..00c9d6e 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -559,33 +559,14 @@ public static int GetNumberOfUsersOnline() public static async Task CheckForTimeouts() { - List lstSessionsToDestroy = new(); foreach (var sessionDataByClient in m_dictWebsockets) { foreach (var sessionData in sessionDataByClient.Value) { -#if DEBUG - const int timeoutVal = 60000 * 10; -#else - const int timeoutVal = 20000; -#endif - if (sessionData.Value.GetTimeSinceLastPing() >= timeoutVal) - { - lstSessionsToDestroy.Add(sessionData.Value); - } - else - { - await sessionData.Value.SendPong(); - } + sessionData.Value.SendKeepAlive(); } } - foreach (UserWebSocketInstance wsSess in lstSessionsToDestroy) - { - Console.WriteLine("Timing out WS session for {0}", wsSess.m_UserID); - await DeleteSession(wsSess.m_UserID, wsSess.m_SessionType, wsSess, false); - } - // do we need to clear out cache entries? List> lstCacheEntriesToDestroy = new(); foreach (var sessionDataPerClientType in m_dictUserSessions) @@ -1464,22 +1445,35 @@ public class UserWebSocketInstance public EUserSessionType m_SessionType = EUserSessionType.None; public Int64 m_UserID = -1; - public Int64 m_lastPingTime = Environment.TickCount64; // last time we pinged this user, used to detect disconnects - - + // pinged after c_KeepAliveInterval of silence, aborted if no pong within c_KeepAliveTimeout + public static readonly TimeSpan c_KeepAliveInterval = TimeSpan.FromSeconds(15); +#if DEBUG + public static readonly TimeSpan c_KeepAliveTimeout = TimeSpan.FromMinutes(10); // survive debugger breaks +#else + public static readonly TimeSpan c_KeepAliveTimeout = TimeSpan.FromSeconds(45); +#endif // TODO: Start using nullable for int values etc instead of doing 0 or -1 + // reply to legacy JSON PING; released clients only reset their timeout on it public async Task SendPong() { - OnPing(); - // send pong back WebSocketMessage_PONG outboundMsg = new WebSocketMessage_PONG(); outboundMsg.msg_id = (int)EWebSocketMessageID.PONG; byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(outboundMsg)); await SendAsync(bytesJSON, WebSocketMessageType.Text); } - + + // legacy JSON keep-alive for clients that don't send their own PING; skipped while a send is in flight + public void SendKeepAlive() + { + if (m_SendLock.CurrentCount == 0) + { + return; + } + + _ = SendPong(); + } private WebSocket? m_SockInternal = null; @@ -1494,21 +1488,6 @@ public void AttachWebsocket(WebSocket sock) m_SockInternal = sock; } - public void OnPing() - { - m_lastPingTime = Environment.TickCount64; - } - - public Int64 GetLastPingTime() - { - return m_lastPingTime; - } - - public Int64 GetTimeSinceLastPing() - { - return Environment.TickCount64 - m_lastPingTime; - } - public async Task SendAsync(byte[] buffer, WebSocketMessageType messageType, CancellationToken externalToken = default) { if (m_SockInternal != null) diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs index f872451..8c1b177 100644 --- a/GenOnlineService/Program.cs +++ b/GenOnlineService/Program.cs @@ -1551,7 +1551,8 @@ public static async Task Main(string[] args) var webSocketOptions = new WebSocketOptions { - KeepAliveInterval = TimeSpan.FromSeconds(30) + KeepAliveInterval = UserWebSocketInstance.c_KeepAliveInterval, + KeepAliveTimeout = UserWebSocketInstance.c_KeepAliveTimeout }; app.UseWebSockets(webSocketOptions); From 056c0ab91892b8a4ede5a2feb4538f21e799f05d Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:46:10 +0200 Subject: [PATCH 3/8] fix(websocket): contain close failures in the receive loop --- GenOnlineService/Constants.cs | 2 +- .../Controllers/WebSocket/WebSocketController.cs | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index 00c9d6e..4e6e4c7 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -1570,7 +1570,7 @@ public async Task CloseAsync(WebSocketCloseStatus closeStatus, string? statusDes try { // dont wait forever, certain situations can cause that in ASP.NET - var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); await m_SockInternal.CloseAsync(closeStatus, statusDescription, cts.Token); } catch diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index 6759905..3ecb377 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -297,8 +297,7 @@ public async Task Get([FromHeader(Name = "is-reconnect")] bool bIsReconnect) if (receiveResult.MessageType == WebSocketMessageType.Close) { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); // timeout - await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", cts.Token); + await wsSess.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing"); break; } @@ -314,8 +313,7 @@ public async Task Get([FromHeader(Name = "is-reconnect")] bool bIsReconnect) fragmentBuffer.Dispose(); fragmentBuffer = null; - using var ctsTooBig = new CancellationTokenSource(TimeSpan.FromSeconds(10)); - await webSocket.CloseAsync(WebSocketCloseStatus.MessageTooBig, "Message too large", ctsTooBig.Token); + await wsSess.CloseAsync(WebSocketCloseStatus.MessageTooBig, "Message too large"); break; } @@ -341,7 +339,7 @@ public async Task Get([FromHeader(Name = "is-reconnect")] bool bIsReconnect) // if we lost session data, close WS if (sourceUserData == null) { - wsSess.CloseAsync(WebSocketCloseStatus.NormalClosure, "User signed in from another point of presence [B]"); + await wsSess.CloseAsync(WebSocketCloseStatus.NormalClosure, "User signed in from another point of presence [B]"); break; } @@ -373,8 +371,7 @@ public async Task Get([FromHeader(Name = "is-reconnect")] bool bIsReconnect) } } - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); // timeout - await webSocket.CloseAsync(closeStatus, closeStatusDescription, cts.Token); + await wsSess.CloseAsync(closeStatus, closeStatusDescription); } } From 34da9e16b18baaef6f2ae56a92c09d7682949b9c Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:40:20 +0200 Subject: [PATCH 4/8] fix(websocket): release the session when the handshake fails --- .../Controllers/WebSocket/WebSocketController.cs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index 3ecb377..4856e24 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -255,7 +255,19 @@ public async Task Get([FromHeader(Name = "is-reconnect")] bool bIsReconnect) } // accept WS - using var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync(); + WebSocket acceptedSocket; + try + { + acceptedSocket = await HttpContext.WebSockets.AcceptWebSocketAsync(); + } + catch + { + // handshake failed: release the session registered for this socket, or it stays online with no connection + await WebSocketManager.DeleteSession(user_id, wsSess.m_SessionType, wsSess, false); + throw; + } + + using var webSocket = acceptedSocket; // attach wsSess.AttachWebsocket(webSocket); From 7b039e387491d2026a0e037cef3f6cb1e64906f7 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:02:40 +0200 Subject: [PATCH 5/8] fix(websocket): deliver the friends status update sent on connect --- GenOnlineService/Constants.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index 4e6e4c7..a0de908 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -526,7 +526,8 @@ public static async Task CreateSession(AppDbContext _db, outboundMsg.num_online = numOnline; outboundMsg.num_pending = numPending; byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(outboundMsg)); - await newSess.SendAsync(bytesJSON, WebSocketMessageType.Text); + // queued: the socket isn't attached until the handshake completes + userCacheData.QueueWebsocketSend(bytesJSON); } } From fb8b16fc51c6b1d2d31f66e778f023e3979fbb92 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:13:40 +0200 Subject: [PATCH 6/8] refactor(websocket): send through a per-session queue with one writer per socket --- GenOnlineService/Constants.cs | 218 +++++++++--------- .../WebSocket/WebSocketController.cs | 4 +- GenOnlineService/Program.cs | 2 +- 3 files changed, 111 insertions(+), 113 deletions(-) diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index a0de908..1dee6bf 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -30,6 +30,7 @@ using System.Text; using System.Text.Json; using System.Text.Json.Serialization; +using System.Threading.Channels; using System.Threading.Tasks; namespace GenOnlineService @@ -479,16 +480,14 @@ public static async Task CreateSession(AppDbContext _db, } // now create a websocket, we always do this whether its reconnect or not, only data is persistent - // NOTE: on the reconnect path the previous websocket may still be physically open (e.g. its receive loop - // is parked in a 30s ReceiveAsync). Overwriting the entry without closing it leaks a zombie connection - // that keeps running and sending on a socket nobody owns any more. + // close the previous socket first: it may still be open, and its send loop must stop reading the session's queue if (m_dictWebsockets[sessionType].TryRemove(ownerID, out UserWebSocketInstance? supersededSess) && supersededSess != null) { Console.WriteLine("Closing superseded websocket for {0} ({1})", ownerID, strDisplayName); await supersededSess.CloseAsync(WebSocketCloseStatus.NormalClosure, "Superseded by a newer connection"); } - UserWebSocketInstance newSess = new UserWebSocketInstance(sessionType, ownerID); + UserWebSocketInstance newSess = new UserWebSocketInstance(sessionType, ownerID, userCacheData); m_dictWebsockets[sessionType][ownerID] = newSess; // update last login and last ip @@ -534,17 +533,9 @@ public static async Task CreateSession(AppDbContext _db, return newSess; } - public static async Task Tick() + public static void Tick() { FlushLobbyListUpdates(); - - // Give the entire tick a 20 ms deadline. All users drain concurrently via - // Task.WhenAll, so a slow/stuck client cannot delay others. If the deadline - // fires, the CancellationToken propagates into each in-flight SendAsync and - // into the dequeue loop guard, so the stuck user is skipped and their unsent - // messages stay in the queue for the next tick. - using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(20)); - await Task.WhenAll(m_dictUserSessions.Values.SelectMany(inner => inner.Values).Select(sess => sess.TickWebsocket(cts.Token))); } public static int GetNumberOfUsersOnline() @@ -564,7 +555,7 @@ public static async Task CheckForTimeouts() { foreach (var sessionData in sessionDataByClient.Value) { - sessionData.Value.SendKeepAlive(); + sessionData.Value.QueueKeepAlive(); } } @@ -832,7 +823,9 @@ public static async Task DisconnectUser(Int64 userID, byte[] finalMessage) UserWebSocketInstance? oldWS = GetWebSocketForSession(userSession); if (oldWS != null) { - await oldWS.SendAsync(finalMessage, WebSocketMessageType.Text); + // short bound: the socket is torn down right after, don't hold up the caller on a dead peer + userSession.QueueWebsocketSend(finalMessage); + await userSession.FlushWebsocketSendsAsync(TimeSpan.FromSeconds(2)); } await DeleteSession(userID, userSession.GetSessionType(), oldWS, true); @@ -1257,9 +1250,23 @@ public void QueueWebsocketSend(byte[] bytesJSON) return; } - // Always enqueue; the TickWebsocket drain loop is the sole sender, - // ensuring WebSocket.SendAsync is never called concurrently. - m_lstPendingWebsocketSends.Enqueue(bytesJSON); + if (!m_outbound.Writer.TryWrite(bytesJSON)) + { + // too far behind to catch up: drop the connection so the client reconnects + if (WebSocketManager.GetWebSocketForSession(this)?.AbortIfOpen() == true) + { + Console.WriteLine("[WebSocket] Outbound queue full for {0}, connection aborted", m_UserID); + } + } + } + + public async Task FlushWebsocketSendsAsync(TimeSpan timeout) + { + long deadline = Environment.TickCount64 + (long)timeout.TotalMilliseconds; + while (m_outbound.Reader.Count > 0 && Environment.TickCount64 < deadline) + { + await Task.Delay(20); + } } public async Task CloseWebsocket(WebSocketCloseStatus reason, string strReason) @@ -1273,25 +1280,15 @@ public async Task CloseWebsocket(WebSocketCloseStatus rea return websocketForUser; } - public async Task TickWebsocket(CancellationToken tickToken = default) + // outlives the socket so queued messages reach the client after a reconnect; the attached socket's send loop is the only reader + private const int c_MaxQueuedSends = 1024; + private readonly Channel m_outbound = Channel.CreateBounded(new BoundedChannelOptions(c_MaxQueuedSends) { - // Do we have a connection to send on? - UserWebSocketInstance websocketForUser = WebSocketManager.GetWebSocketForSession(this); - if (websocketForUser != null) - { - const int maxMessagesSendPerFrame = 50; - int messagesSent = 0; - // start dequeing and sending - while (!tickToken.IsCancellationRequested && messagesSent < maxMessagesSendPerFrame && m_lstPendingWebsocketSends.TryDequeue(out byte[] packetData)) - { - await websocketForUser.SendAsync(packetData, WebSocketMessageType.Text, tickToken); - ++messagesSent; - } - } - } - - // TODO_CACHE: Size limit this? - ConcurrentQueue m_lstPendingWebsocketSends = new ConcurrentQueue(); + SingleReader = true, + FullMode = BoundedChannelFullMode.Wait + }); + + public ChannelReader OutboundWebsocketSends => m_outbound.Reader; public bool NeedsCleanup() { @@ -1454,118 +1451,116 @@ public class UserWebSocketInstance public static readonly TimeSpan c_KeepAliveTimeout = TimeSpan.FromSeconds(45); #endif + // cancelling a pending send aborts the socket, so only give up on a peer that stopped reading + private static readonly TimeSpan c_SendStallTimeout = c_KeepAliveInterval + c_KeepAliveTimeout; + // TODO: Start using nullable for int values etc instead of doing 0 or -1 // reply to legacy JSON PING; released clients only reset their timeout on it - public async Task SendPong() + public void QueuePong() { - // send pong back WebSocketMessage_PONG outboundMsg = new WebSocketMessage_PONG(); outboundMsg.msg_id = (int)EWebSocketMessageID.PONG; - byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(outboundMsg)); - await SendAsync(bytesJSON, WebSocketMessageType.Text); + m_OwnerSession.QueueWebsocketSend(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(outboundMsg))); } - // legacy JSON keep-alive for clients that don't send their own PING; skipped while a send is in flight - public void SendKeepAlive() + // legacy JSON keep-alive for clients that don't send their own PING; any queued message serves the same purpose + public void QueueKeepAlive() { - if (m_SendLock.CurrentCount == 0) + if (m_OwnerSession.OutboundWebsocketSends.Count == 0) { - return; + QueuePong(); } - - _ = SendPong(); } private WebSocket? m_SockInternal = null; + private readonly UserSession m_OwnerSession; + private readonly CancellationTokenSource m_sendLoopStop = new CancellationTokenSource(); + private Task m_sendLoop = Task.CompletedTask; - public UserWebSocketInstance(EUserSessionType sessionType, Int64 ownerID) : base() + public UserWebSocketInstance(EUserSessionType sessionType, Int64 ownerID, UserSession ownerSession) : base() { m_SessionType = sessionType; m_UserID = ownerID; + m_OwnerSession = ownerSession; } public void AttachWebsocket(WebSocket sock) { m_SockInternal = sock; + m_sendLoop = Task.Run(RunSendLoop); } - public async Task SendAsync(byte[] buffer, WebSocketMessageType messageType, CancellationToken externalToken = default) + public bool AbortIfOpen() { - if (m_SockInternal != null) + WebSocket? sock = m_SockInternal; + if (sock == null || sock.State != WebSocketState.Open) { - // WebSocket.SendAsync must never be called concurrently on the same socket or the frame stream gets - // corrupted. Several paths (tick drain, pongs, direct sends) can send at the same time, so serialize here. - await m_SendLock.WaitAsync(); - try - { - // should we chunked send? - /* - const int frameMax = 99999999; - if (buffer.Length > frameMax) - { - int bytresRemaining = buffer.Length; - int numFrames = (int)Math.Ceiling((float)buffer.Length / (float)frameMax); - - System.Diagnostics.Debug.WriteLine("[Websocket] sending {0} bytes in {1} chunks", bytresRemaining, numFrames); - - for (int i = 0; i < numFrames; ++i) - { - int bytesToSend = Math.Min(bytresRemaining, frameMax); - bool bLastFrame = i == numFrames - 1; - - - ArraySegment arrSegment = new ArraySegment(buffer, i * frameMax, bytesToSend); - System.Diagnostics.Debug.WriteLine("[Websocket] send frame {0} with {1} bytes (last: {2})", i, bytesToSend, bLastFrame); - await m_SockInternal.SendAsync(arrSegment, messageType, bLastFrame, CancellationToken.None); - - bytresRemaining -= bytesToSend; - } + return false; + } - } - else // just send whole - { - await m_SockInternal.SendAsync(buffer, messageType, true, CancellationToken.None); - } - */ + sock.Abort(); + return true; + } - CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(externalToken); - try - { - cts.CancelAfter(TimeSpan.FromMilliseconds(500)); - await m_SockInternal.SendAsync(buffer, messageType, true, cts.Token); - } - finally + // sole writer to the socket; a message leaves the queue only once sent, so a dead socket leaves it for the next connection + private async Task RunSendLoop() + { + ChannelReader outbound = m_OwnerSession.OutboundWebsocketSends; + try + { + while (await outbound.WaitToReadAsync(m_sendLoopStop.Token)) + { + while (!m_sendLoopStop.IsCancellationRequested && outbound.TryPeek(out byte[]? buffer)) { - try + // a failed send on a still-open socket is dropped rather than retried forever + if (!await SendFrameAsync(buffer) && m_SockInternal?.State != WebSocketState.Open) { - // cts is intentionally not disposed: disposing it races with the parent token's - // timer callback (ThreadPool thread), causing ObjectDisposedException. GC reclaims it. - - - + return; } - catch (ObjectDisposedException) - { - - - } - } - } - catch - { + outbound.TryRead(out _); + } } - finally - { - m_SendLock.Release(); - } + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + Console.WriteLine($"[WebSocket] Send loop failed for {m_UserID}: {ex}"); } } - private readonly SemaphoreSlim m_SendLock = new SemaphoreSlim(1, 1); + private async Task SendFrameAsync(byte[] buffer) + { + WebSocket? sock = m_SockInternal; + if (sock == null || sock.State != WebSocketState.Open) + { + return false; + } + + try + { + using var cts = new CancellationTokenSource(c_SendStallTimeout); + await sock.SendAsync(buffer, WebSocketMessageType.Text, true, cts.Token); + return true; + } + catch (OperationCanceledException) + { + Console.WriteLine("[WebSocket] Send to {0} stalled for {1}s, connection aborted", m_UserID, c_SendStallTimeout.TotalSeconds); + return false; + } + catch + { + return false; + } + } public async Task CloseAsync(WebSocketCloseStatus closeStatus, string? statusDescription) { + // stop reading the queue first so the next connection's send loop is the only reader + m_sendLoopStop.Cancel(); + if (m_SockInternal != null) { try @@ -1576,9 +1571,12 @@ public async Task CloseAsync(WebSocketCloseStatus closeStatus, string? statusDes } catch { - + // a timed-out close leaves the socket open behind a stuck send; abort to end both + m_SockInternal.Abort(); } } + + await m_sendLoop; } } diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index 4856e24..ca2c1f7 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -498,7 +498,7 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession { if (msgID == EWebSocketMessageID.PING) { - await sourceWS.SendPong(); + sourceWS.QueuePong(); } else if (msgID == EWebSocketMessageID.SOCIAL_SUBSCRIBE_REALTIME_UPDATES) { @@ -569,7 +569,7 @@ private async Task ProcessWSMessage(UserWebSocketInstance sourceWS, UserSession // send to source byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(outboundMsg)); - await sourceWS.SendAsync(bytesJSON, WebSocketMessageType.Text); + sourceUserSession.QueueWebsocketSend(bytesJSON); } } } diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs index 8c1b177..7de732d 100644 --- a/GenOnlineService/Program.cs +++ b/GenOnlineService/Program.cs @@ -1654,7 +1654,7 @@ public static async Task Main(string[] args) { var lobbyManager = ServiceLocator.Services.GetRequiredService(); await lobbyManager.Tick(); - await WebSocketManager.Tick(); + WebSocketManager.Tick(); } catch (Exception ex) { From 1e335bc58d844df37ff5a9841f5d97aa727c85bc Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:13:42 +0200 Subject: [PATCH 7/8] fix(websocket): stop reporting unclean client disconnects to sentry --- .../Controllers/WebSocket/WebSocketController.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index ca2c1f7..deced9d 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -299,6 +299,11 @@ public async Task Get([FromHeader(Name = "is-reconnect")] bool bIsReconnect) { break; } + catch (WebSocketException ex) when (ex.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely) + { + // client dropped without a close handshake + break; + } catch (Exception ex) { // Log unexpected errors From 8d08a3522f123d31509f3a46b55378993aa6d0c3 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:13:45 +0200 Subject: [PATCH 8/8] fix(websocket): correct geoip fallback coordinates --- GenOnlineService/Controllers/WebSocket/WebSocketController.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index deced9d..7abfa74 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -184,8 +184,8 @@ public async Task Get([FromHeader(Name = "is-reconnect")] bool bIsReconnect) string ipAddress = IPHelpers.NormalizeIP(HttpContext.Connection.RemoteIpAddress?.ToString()); string ipContinent = "NA"; string ipCountry = "US"; - double dLongitude = 38.8977; // the whitehouse; - double dLatitude = 77.0365f; // the whitehouse; + double dLongitude = -77.0365; // the whitehouse + double dLatitude = 38.8977; // the whitehouse try {