diff --git a/src/Empostor.Server/Extensions/ServiceProviderExtensions.cs b/src/Empostor.Server/Extensions/ServiceProviderExtensions.cs index dff1eb1..0ad4709 100644 --- a/src/Empostor.Server/Extensions/ServiceProviderExtensions.cs +++ b/src/Empostor.Server/Extensions/ServiceProviderExtensions.cs @@ -9,7 +9,11 @@ namespace Empostor.Server { public static void AddEventPools(this IServiceCollection services) { - services.TryAddSingleton(new DefaultObjectPoolProvider()); + // Larger object-pool retention so high-frequency packet allocation + // (MessageReader, event objects) is served from the pool instead of + // hammering the GC under load (e.g. 40+ players). + services.TryAddSingleton( + new DefaultObjectPoolProvider { MaximumRetained = 512 }); services.AddSingleton(serviceProvider => { diff --git a/src/Empostor.Server/Net/Hazel/HazelConnection.cs b/src/Empostor.Server/Net/Hazel/HazelConnection.cs index f998783..19cb2fb 100644 --- a/src/Empostor.Server/Net/Hazel/HazelConnection.cs +++ b/src/Empostor.Server/Net/Hazel/HazelConnection.cs @@ -4,6 +4,7 @@ using Empostor.Api; using Empostor.Api.Config; using Empostor.Api.Net; using Next.Hazel; +using Next.Hazel.Udp; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -21,6 +22,16 @@ namespace Empostor.Server.Net.Hazel InnerConnection = innerConnection; innerConnection.DataReceived = ConnectionOnDataReceived; innerConnection.Disconnected = ConnectionOnDisconnected; + + if (innerConnection is UdpConnection udp) + { + // Tolerate UDP packet loss under load (e.g. 40+ players). A few + // dropped reliable packets or keepalive pings are retransmitted + // for longer before the connection is declared dead, so a burst + // of loss no longer disconnects players. + udp.DisconnectTimeout = 10000; + udp.MissingPingsUntilDisconnect = 12; + } } public Connection InnerConnection { get; } diff --git a/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpConnection.cs b/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpConnection.cs index 4f9b12b..a1c2675 100644 --- a/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpConnection.cs +++ b/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpConnection.cs @@ -33,10 +33,17 @@ public abstract partial class UdpConnection : NetworkConnection _readerPool = readerPool; _stoppingCts = new CancellationTokenSource(); - Pipeline = Channel.CreateUnbounded(new UnboundedChannelOptions + // Bound the inbound queue so a slow/overloaded connection cannot let + // packets accumulate without limit. Once full, the oldest queued packet + // is dropped (DropOldest) — reliable packets are retransmitted by the + // sender anyway and unreliable ones carry fresh state each tick, so this + // keeps memory flat under sustained load instead of letting it balloon + // until the GC pressure starts disconnecting everyone. + Pipeline = Channel.CreateBounded(new BoundedChannelOptions(1024) { SingleReader = true, - SingleWriter = true + SingleWriter = true, + FullMode = BoundedChannelFullMode.DropOldest, }); } @@ -46,8 +53,13 @@ public abstract partial class UdpConnection : NetworkConnection public Task StartAsync() { - // Store the task we're executing - _executingTask = Task.Factory.StartNew(ReadAsync, TaskCreationOptions.LongRunning); + // Run the read loop on the thread pool instead of a dedicated + // long-running thread per connection. ReadAsync is fully async (it + // awaits the pipeline channel), so it only occupies a thread-pool + // thread while there is actually data to process. With hundreds of + // connections this avoids hundreds of dedicated threads (1 MB stack + // each) and the resulting context-switch overhead. + _executingTask = Task.Run(ReadAsync); // If the task is completed then return it, this will bubble cancellation and failure to the caller if (_executingTask.IsCompleted) return _executingTask; diff --git a/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpConnectionListener.cs b/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpConnectionListener.cs index 47589f6..167b672 100644 --- a/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpConnectionListener.cs +++ b/src/NextFast.Hazel-New/Next.Hazel/Udp/UdpConnectionListener.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.Net; using System.Net.Sockets; using System.Threading; @@ -65,6 +66,20 @@ public class UdpConnectionListener : NetworkConnectionListener { } + try + { + // Under high load (many players / high tick rate) the OS-level UDP + // receive queue can overflow and silently drop packets; reliable + // retransmission / keepalive then misreads that as a dead connection + // and disconnects players. Use a large buffer so bursts are absorbed + // instead of dropped. + _socket.Client.ReceiveBufferSize = 4 * 1024 * 1024; + _socket.Client.SendBufferSize = 4 * 1024 * 1024; + } + catch (SocketException) + { + } + _reliablePacketTimer = new Timer(ManageReliablePackets, null, 100, Timeout.Infinite); _allConnections = new ConcurrentDictionary(); @@ -79,10 +94,25 @@ public class UdpConnectionListener : NetworkConnectionListener private async void ManageReliablePackets(object? state) { - foreach (var kvp in _allConnections) + // Resend reliably-sent packets for every connection. Run them + // concurrently instead of serially awaiting each connection: with many + // players one slow connection used to stall retransmission for everyone + // else, which made packet loss snowball into mass disconnects. + var connections = new List(_allConnections.Values); + var tasks = new Task[connections.Count]; + for (var i = 0; i < connections.Count; i++) { - var sock = kvp.Value; - await sock.ManageReliablePackets(); + var conn = connections[i]; + tasks[i] = conn.ManageReliablePackets().AsTask(); + } + + try + { + await Task.WhenAll(tasks); + } + catch + { + // ignored } try