diff --git a/LarpixServer/Utils/Utils.cs b/LarpixServer/Utils/Utils.cs index ff751c9..67f0bb3 100644 --- a/LarpixServer/Utils/Utils.cs +++ b/LarpixServer/Utils/Utils.cs @@ -145,6 +145,15 @@ public class Utils public static ConcurrentDictionary> ActiveWebSockets = new(); public static ConcurrentDictionary> ActiveCalls = new(); + // Bezczynność app-WS = rozłączony klient. Klient wysyła tekstowy "ping" co + // 20 s; brak CZEGOKOLWIEK przez 75 s (3-4 przegapione pingi) oznacza martwy + // polłączony TCP (zabita aplikacja / utracony NAT mapping). Bez tego + // ReceiveAsync wisiał w nieskończoność, a cleanup ActiveCalls w finally + // nigdy nie biegł -> wskaźniki rozmów pokazywały uczestników, którzy + // dawno wyłączyli aplikację. Timeout wywołuje normalny finally (cleanup + // ActiveCalls + broadcast call_update do członków pokoju). + public static readonly TimeSpan AppWsIdleTimeout = TimeSpan.FromSeconds(75); + public static async Task WsHandler(HttpContext context, System.Net.WebSockets.WebSocket webSocket, IQueryCollection query) { var buffer = new byte[8192]; @@ -210,7 +219,17 @@ public class Utils { while (webSocket.State == System.Net.WebSockets.WebSocketState.Open) { - result = await webSocket.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None); + using var idleCts = new System.Threading.CancellationTokenSource(AppWsIdleTimeout); + try + { + result = await webSocket.ReceiveAsync(new ArraySegment(buffer), idleCts.Token); + } + catch (System.OperationCanceledException) + { + //Brak ramek przez AppWsIdleTimeout - traktujemy jak rozłączenie: + //przerywamy pętlę, finally posprząta ActiveCalls i roześle call_update + break; + } if (result.MessageType == System.Net.WebSockets.WebSocketMessageType.Close) { break; diff --git a/LarpixVoice/Program.cs b/LarpixVoice/Program.cs index 8f33851..e2d70e1 100644 --- a/LarpixVoice/Program.cs +++ b/LarpixVoice/Program.cs @@ -14,9 +14,44 @@ public class Program // Entries are pruned in the 1s loop below. public static ConcurrentDictionary UsersListLimits = new (); + // Sesja bez ODBIERANEGO ruchu dłużej niż to = martwy TCP (zabita aplikacja, + // utracony mapping NAT). Klient pinguje co 2 s - 15 s to 7+ przegapionych + // pingów, więc fałszywe pozytywy przy chwilowych zaniżeniach sieci są + // praktycznie niemożliwe, a ghost nie zaśmieca rosteru dłużej niż trzeba. + public static readonly TimeSpan SessionIdleTimeout = TimeSpan.FromSeconds(15); + public static ulong TotalBytesSent = 0; public static ulong TotalBytesReceived = 0; private static ulong CurrentBandwidthKps = 0; + + // Zamiata sesje bez odebranego ruchu dłużej niż SessionIdleTimeout. Bez + // tego zabita aplikacja (half-open TCP, brak Close frame) zostawała w + // rosterze pokoju na zawsze: /room/users zwracało ghostów -> ghost kafelki + // i "trwa rozmowa" u innych uczestników. TryRemove(KeyValuePair) jest + // warunkowe po wartości - nie da się przypadkiem usunąć NOWEJ sesji, która + // w międzyczasie nadpisała wpis tego samego userId (reconnect). + private static void SweepStaleSessions() + { + DateTime cutoff = DateTime.UtcNow - SessionIdleTimeout; + foreach (var room in Rooms) + { + foreach (var kvp in room.Value) + { + if (kvp.Value.LastSeenUtc >= cutoff) continue; + if (!room.Value.TryRemove(new KeyValuePair(kvp.Key, kvp.Value))) continue; + + //Zamknięcie kolejki kończy task-pump; Abort wywala zawieszone + //ReceiveAsync/SendAsync martwego socketu -> receive-loop wejdzie + //we swój finally i posprząta bufor (guard session==currentSession + //zapobiega podwójnemu sprzątaniu) + kvp.Value.SendQueue.Writer.TryComplete(); + try { kvp.Value.Socket.Abort(); } catch { } + Console.WriteLine($"Voice: swept idle session room={room.Key} user={kvp.Key}"); + } + + if (room.Value.IsEmpty) Rooms.TryRemove(room.Key, out _); + } + } public static void Main(string[] args) { @@ -39,6 +74,8 @@ public class Program { if (entry.Value.resetAt <= nowSec) UsersListLimits.TryRemove(entry.Key, out _); } + + SweepStaleSessions(); } }); diff --git a/LarpixVoice/Requests.cs b/LarpixVoice/Requests.cs index 3313649..6ebac0b 100644 --- a/LarpixVoice/Requests.cs +++ b/LarpixVoice/Requests.cs @@ -145,6 +145,10 @@ public class Requests } while (!result.EndOfMessage); + //Dowolna kompletna ramka = klient żyje (ping typu 5 przychodzi co 2 s + //nawet przy ciszy) - odświeża licznik dla idle-sweepera w Program.cs + currentSession.Touch(); + if (result.MessageType == WebSocketMessageType.Close) break; if (result.MessageType == WebSocketMessageType.Binary) { diff --git a/LarpixVoice/Session.cs b/LarpixVoice/Session.cs index a5fbabe..3a018d4 100644 --- a/LarpixVoice/Session.cs +++ b/LarpixVoice/Session.cs @@ -8,6 +8,16 @@ public class Session public WebSocket Socket { get; } public Channel<(byte[] Buffer, int Length)> SendQueue { get; } + // Ostatni znak życia (dowolna odebrana ramka - audio, wideo, ping typu 5). + // Klient pinguje co 2 s, więc sesja bez ruchu przez VOICE_IDLE_TIMEOUT to + // martwy polłączony TCP (zabita aplikacja, utracony NAT mapping) - taki wpis + // zamiata task w Program.cs. Bez tego ghost-sesje wiszą w rosterze wiecznie + // (/room/users zwraca nieżyjących uczestników -> ghost kafelki u innych, + // a relay marnuje bandwidth na zamknięte sockety). + private long _lastSeenUtcTicks; + + public DateTime LastSeenUtc => new(Interlocked.Read(ref _lastSeenUtcTicks), DateTimeKind.Utc); + public Session(WebSocket socket) { Socket = socket; @@ -15,5 +25,8 @@ public class Session { FullMode = BoundedChannelFullMode.Wait }); + _lastSeenUtcTicks = DateTime.UtcNow.Ticks; } -} \ No newline at end of file + + public void Touch() => Interlocked.Exchange(ref _lastSeenUtcTicks, DateTime.UtcNow.Ticks); +} diff --git a/LarpixVoice/Utils.cs b/LarpixVoice/Utils.cs index 20cbaa2..bf4dc36 100644 --- a/LarpixVoice/Utils.cs +++ b/LarpixVoice/Utils.cs @@ -8,7 +8,9 @@ public class Utils public static string MAIN_URL = ""; // Rate limits, requests per minute (fixed 60s window). - public static int ROOM_USERS_RATE_LIMIT = 120; + // 240/min: klient odpytuje roster co ~5 s przez całą rozmowę (12/min) - + // kilka osób za jednym NAT-em + początkowe retrye mieszczą się z zapasem. + public static int ROOM_USERS_RATE_LIMIT = 240; // Shared with the main server: authenticates internal validation requests. public static string VOICE_KEY = "";