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.Buffers.Binary;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
|
using System.Threading.Channels;
|
||||||
using AcDream.Core.Net.Cryptography;
|
using AcDream.Core.Net.Cryptography;
|
||||||
using AcDream.Core.Net.Messages;
|
using AcDream.Core.Net.Messages;
|
||||||
using AcDream.Core.Net.Packets;
|
using AcDream.Core.Net.Packets;
|
||||||
|
|
@ -108,6 +109,15 @@ public sealed class WorldSession : IDisposable
|
||||||
private uint _clientPacketSequence;
|
private uint _clientPacketSequence;
|
||||||
private uint _fragmentSequence = 1;
|
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>
|
/// <summary>
|
||||||
/// Phase 4.10 latch — true after we've sent the LoginComplete game
|
/// Phase 4.10 latch — true after we've sent the LoginComplete game
|
||||||
/// action in response to PlayerCreate. Prevents re-sending if the
|
/// 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
|
// ready and the server ignores it. The actual trigger lives in
|
||||||
// ProcessDatagram.
|
// ProcessDatagram.
|
||||||
Transition(State.InWorld);
|
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>
|
/// <summary>
|
||||||
/// Non-blocking pump. Drains any datagrams currently in the kernel
|
/// Non-blocking pump. Drains any datagrams buffered by the background
|
||||||
/// buffer, parses them, and fires events. Call once per game-loop
|
/// net thread (Phase A.3), decodes them, and fires events. Call once
|
||||||
/// frame. Returns the number of datagrams processed.
|
/// per game-loop frame. Returns the number of datagrams processed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int Tick()
|
public int Tick()
|
||||||
{
|
{
|
||||||
int processed = 0;
|
int processed = 0;
|
||||||
while (true)
|
while (_inboundQueue.Reader.TryRead(out var bytes))
|
||||||
{
|
{
|
||||||
var bytes = _net.TryReceive(out _);
|
|
||||||
if (bytes is null) break;
|
|
||||||
ProcessDatagram(bytes);
|
ProcessDatagram(bytes);
|
||||||
processed++;
|
processed++;
|
||||||
}
|
}
|
||||||
return 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>
|
/// <summary>
|
||||||
/// Blocking single-datagram pump used during Connect/EnterWorld.
|
/// Blocking single-datagram pump used during Connect/EnterWorld.
|
||||||
/// Returns true if a datagram was processed.
|
/// 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();
|
_net.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue