feat(net): Phase A.3 — background net receive thread

Moves the UDP receive onto a dedicated daemon thread that
continuously pulls raw datagrams from the kernel buffer and posts
them to a Channel<byte[]>. 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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-12 09:20:56 +02:00
parent e8c4ac25ba
commit ae43531866

View file

@ -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<byte[]> _inboundQueue =
Channel.CreateUnbounded<byte[]>(
new UnboundedChannelOptions
{ SingleReader = true, SingleWriter = true });
private Thread? _netThread;
private readonly CancellationTokenSource _netCancel = new();
/// <summary>
/// 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();
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Phase A.3: background receive loop. Runs on a dedicated daemon
/// thread started at the end of <see cref="EnterWorld"/>. Continuously
/// pulls raw UDP datagrams from the kernel buffer via
/// <see cref="NetClient.Receive"/> and writes them into
/// <see cref="_inboundQueue"/> for the render thread to drain in
/// <see cref="Tick"/>. Does NOT decode, reassemble, or dispatch —
/// all of that stays on the render thread to avoid ISAAC/assembler
/// thread-safety issues.
///
/// <para>
/// 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, <see cref="Dispose"/> cancels the token
/// and joins the thread.
/// </para>
/// </summary>
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();
}
}
/// <summary>
/// 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();
}
}