From ae43531866fee558dba59281ab33683da1ddf7e6 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 12 Apr 2026 09:20:56 +0200 Subject: [PATCH] =?UTF-8?q?feat(net):=20Phase=20A.3=20=E2=80=94=20backgrou?= =?UTF-8?q?nd=20net=20receive=20thread?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the UDP receive onto a dedicated daemon thread that continuously pulls raw datagrams from the kernel buffer and posts them to a Channel. Tick() on the render thread drains the channel instead of calling _net.TryReceive() directly. All decode, fragment assembly, ISAAC crypto, event dispatch, and ack-sending remain on the render thread — this is the minimal change that prevents packet drops during render-thread stalls without the complexity of moving decode/dispatch off-thread. The net thread starts at the end of EnterWorld() after the handshake is complete — during Connect() and EnterWorld(), PumpOnce() still reads directly from the socket (the net thread isn't running yet). Dispose() cancels the thread via CancellationToken, joins with a 2-second timeout, then disposes the socket. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/AcDream.Core.Net/WorldSession.cs | 76 +++++++++++++++++++++++++--- 1 file changed, 70 insertions(+), 6 deletions(-) diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index f2e76d6..089ac14 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -1,5 +1,6 @@ using System.Buffers.Binary; using System.Net; +using System.Threading.Channels; using AcDream.Core.Net.Cryptography; using AcDream.Core.Net.Messages; using AcDream.Core.Net.Packets; @@ -108,6 +109,15 @@ public sealed class WorldSession : IDisposable private uint _clientPacketSequence; private uint _fragmentSequence = 1; + // Phase A.3: background receive thread buffers raw UDP datagrams into + // a channel so the render thread never blocks on socket I/O. + private readonly Channel _inboundQueue = + Channel.CreateUnbounded( + new UnboundedChannelOptions + { SingleReader = true, SingleWriter = true }); + private Thread? _netThread; + private readonly CancellationTokenSource _netCancel = new(); + /// /// Phase 4.10 latch — true after we've sent the LoginComplete game /// action in response to PlayerCreate. Prevents re-sending if the @@ -218,26 +228,73 @@ public sealed class WorldSession : IDisposable // ready and the server ignores it. The actual trigger lives in // ProcessDatagram. Transition(State.InWorld); + + // Phase A.3: start the background receive thread now that the + // handshake is complete and the session is fully established. + // During Connect() and EnterWorld(), PumpOnce() read directly + // from the socket (blocking). From here on, Tick() drains the + // channel instead. + _netThread = new Thread(NetReceiveLoop) + { + IsBackground = true, + Name = "acdream.net-recv", + }; + _netThread.Start(); } /// - /// Non-blocking pump. Drains any datagrams currently in the kernel - /// buffer, parses them, and fires events. Call once per game-loop - /// frame. Returns the number of datagrams processed. + /// Non-blocking pump. Drains any datagrams buffered by the background + /// net thread (Phase A.3), decodes them, and fires events. Call once + /// per game-loop frame. Returns the number of datagrams processed. /// public int Tick() { int processed = 0; - while (true) + while (_inboundQueue.Reader.TryRead(out var bytes)) { - var bytes = _net.TryReceive(out _); - if (bytes is null) break; ProcessDatagram(bytes); processed++; } return processed; } + /// + /// Phase A.3: background receive loop. Runs on a dedicated daemon + /// thread started at the end of . Continuously + /// pulls raw UDP datagrams from the kernel buffer via + /// and writes them into + /// for the render thread to drain in + /// . Does NOT decode, reassemble, or dispatch — + /// all of that stays on the render thread to avoid ISAAC/assembler + /// thread-safety issues. + /// + /// + /// The 250ms receive timeout is the heartbeat: if no packet arrives + /// within 250ms, the loop re-checks the cancellation token and + /// tries again. On shutdown, cancels the token + /// and joins the thread. + /// + /// + private void NetReceiveLoop() + { + try + { + while (!_netCancel.Token.IsCancellationRequested) + { + var bytes = _net.Receive(TimeSpan.FromMilliseconds(250), out _); + if (bytes is not null) + _inboundQueue.Writer.TryWrite(bytes); + } + } + catch (OperationCanceledException) { /* graceful shutdown */ } + catch (System.Net.Sockets.SocketException) { /* socket closed during shutdown */ } + catch (ObjectDisposedException) { /* NetClient disposed before thread noticed */ } + finally + { + _inboundQueue.Writer.TryComplete(); + } + } + /// /// Blocking single-datagram pump used during Connect/EnterWorld. /// Returns true if a datagram was processed. @@ -470,6 +527,13 @@ public sealed class WorldSession : IDisposable } } + // Phase A.3: shut down the background receive thread. Cancel the + // token → the 250ms receive timeout fires → loop exits → join. + _netCancel.Cancel(); + _inboundQueue.Writer.TryComplete(); + _netThread?.Join(TimeSpan.FromSeconds(2)); + _netCancel.Dispose(); + _net.Dispose(); } }