fix desync & try to fix delay
All checks were successful
Server Build / publish (push) Successful in 29s
Voice Build / publish (push) Successful in 25s

This commit is contained in:
olcxja 2026-08-26 12:17:10 +02:00
commit dc893baf94
5 changed files with 78 additions and 3 deletions

View file

@ -145,6 +145,15 @@ public class Utils
public static ConcurrentDictionary<string, List<System.Net.WebSockets.WebSocket>> ActiveWebSockets = new();
public static ConcurrentDictionary<string, List<string>> 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<byte>(buffer), CancellationToken.None);
using var idleCts = new System.Threading.CancellationTokenSource(AppWsIdleTimeout);
try
{
result = await webSocket.ReceiveAsync(new ArraySegment<byte>(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;

View file

@ -14,9 +14,44 @@ public class Program
// Entries are pruned in the 1s loop below.
public static ConcurrentDictionary<string, (int count, long resetAt)> 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<ulong, Session>(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();
}
});

View file

@ -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)
{

View file

@ -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;
}
}
public void Touch() => Interlocked.Exchange(ref _lastSeenUtcTicks, DateTime.UtcNow.Ticks);
}

View file

@ -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 = "";