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:
parent
e8c4ac25ba
commit
ae43531866
1 changed files with 70 additions and 6 deletions
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue