Replace timeout-polled in-world UDP receives with one cancellable caller-buffered socket operation. Transfer only right-sized pooled datagrams through the FIFO, return every ownership edge deterministically, and send caller spans without a transport copy while preserving handshake pacing and ACK order.
196 lines
6.4 KiB
C#
196 lines
6.4 KiB
C#
using System.Net;
|
|
using System.Net.Sockets;
|
|
|
|
namespace AcDream.Core.Net;
|
|
|
|
/// <summary>
|
|
/// UDP transport for acdream. The live world owns one cancellable,
|
|
/// caller-buffered asynchronous receive after the synchronous handshake.
|
|
///
|
|
/// <para>
|
|
/// Sends consume caller spans directly through <see cref="Socket"/> without
|
|
/// materializing a transport-only array copy.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class NetClient : IDisposable
|
|
{
|
|
private readonly UdpClient _udp;
|
|
private readonly IPEndPoint _remote;
|
|
private readonly IPEndPoint _anyRemote =
|
|
new(IPAddress.Any, 0);
|
|
|
|
/// <summary>Last <see cref="Socket.ReceiveTimeout"/> value applied to the
|
|
/// underlying socket, so repeated <see cref="Receive"/> calls with the
|
|
/// same timeout (every 250ms heartbeat tick) skip the setsockopt syscall.</summary>
|
|
private int _lastAppliedReceiveTimeoutMs = int.MinValue;
|
|
|
|
public NetClient(IPEndPoint remote)
|
|
{
|
|
_remote = remote;
|
|
// Bind to an OS-assigned local port; server will reply to it.
|
|
_udp = new UdpClient(new IPEndPoint(IPAddress.Any, 0));
|
|
|
|
if (OperatingSystem.IsWindows())
|
|
{
|
|
// SIO_UDP_CONNRESET: Windows' UDP stack otherwise surfaces an
|
|
// ICMP "port unreachable" reply to an EARLIER Send (e.g. a stale
|
|
// server session that already tore down its socket) as a
|
|
// WSAECONNRESET SocketException on this socket's NEXT unrelated
|
|
// Receive call. An ICMP error from one send must not poison
|
|
// receiving for the rest of the session — disable that reporting.
|
|
const int SioUdpConnReset = -1744830452; // 0x9800000C
|
|
_udp.Client.IOControl((IOControlCode)SioUdpConnReset, new byte[] { 0 }, null);
|
|
}
|
|
}
|
|
|
|
/// <summary>The local endpoint the OS assigned us.</summary>
|
|
public IPEndPoint LocalEndPoint => (IPEndPoint)_udp.Client.LocalEndPoint!;
|
|
|
|
/// <summary>The remote endpoint we're talking to.</summary>
|
|
public IPEndPoint RemoteEndPoint => _remote;
|
|
|
|
/// <summary>
|
|
/// Send a datagram to the configured default remote. Blocks until the
|
|
/// OS has accepted the bytes (fast — just a kernel buffer copy on loopback).
|
|
/// </summary>
|
|
public void Send(ReadOnlySpan<byte> datagram)
|
|
{
|
|
_udp.Client.SendTo(
|
|
datagram,
|
|
SocketFlags.None,
|
|
_remote);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Send a datagram to an arbitrary remote endpoint. Needed for the AC
|
|
/// handshake because the server binds separate listeners on port 9000
|
|
/// (LoginRequest) and port 9001 (ConnectResponse), so the second
|
|
/// handshake leg targets a different port than the first.
|
|
/// </summary>
|
|
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram)
|
|
{
|
|
_udp.Client.SendTo(
|
|
datagram,
|
|
SocketFlags.None,
|
|
remote);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Receive into caller-owned storage. Returns the received byte count,
|
|
/// or <c>-1</c> when <paramref name="timeout"/> expires.
|
|
/// </summary>
|
|
public int Receive(
|
|
Span<byte> destination,
|
|
TimeSpan timeout,
|
|
out IPEndPoint? from)
|
|
{
|
|
int timeoutMs = checked((int)timeout.TotalMilliseconds);
|
|
if (timeoutMs != _lastAppliedReceiveTimeoutMs)
|
|
{
|
|
_udp.Client.ReceiveTimeout = timeoutMs;
|
|
_lastAppliedReceiveTimeoutMs = timeoutMs;
|
|
}
|
|
|
|
try
|
|
{
|
|
EndPoint remote = _anyRemote;
|
|
int length = _udp.Client.ReceiveFrom(
|
|
destination,
|
|
SocketFlags.None,
|
|
ref remote);
|
|
from = (IPEndPoint)remote;
|
|
return length;
|
|
}
|
|
catch (SocketException ex)
|
|
when (ex.SocketErrorCode == SocketError.TimedOut)
|
|
{
|
|
from = null;
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// One cancellable asynchronous receive into caller-owned storage.
|
|
/// The returned memory remains owned by the caller.
|
|
/// </summary>
|
|
public async ValueTask<NetReceiveResult> ReceiveAsync(
|
|
Memory<byte> destination,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
SocketReceiveFromResult result =
|
|
await _udp.Client.ReceiveFromAsync(
|
|
destination,
|
|
SocketFlags.None,
|
|
_anyRemote,
|
|
cancellationToken).ConfigureAwait(false);
|
|
return new NetReceiveResult(
|
|
result.ReceivedBytes,
|
|
(IPEndPoint)result.RemoteEndPoint);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Block until a datagram arrives or <paramref name="timeout"/> elapses.
|
|
/// Returns the raw bytes, or <c>null</c> on timeout. The sender's
|
|
/// endpoint is recorded in <paramref name="from"/> so the caller can
|
|
/// verify it matches the expected remote.
|
|
/// </summary>
|
|
public byte[]? Receive(TimeSpan timeout, out IPEndPoint? from)
|
|
{
|
|
int timeoutMs = checked((int)timeout.TotalMilliseconds);
|
|
if (timeoutMs != _lastAppliedReceiveTimeoutMs)
|
|
{
|
|
_udp.Client.ReceiveTimeout = timeoutMs;
|
|
_lastAppliedReceiveTimeoutMs = timeoutMs;
|
|
}
|
|
try
|
|
{
|
|
IPEndPoint any = new(IPAddress.Any, 0);
|
|
byte[] bytes = _udp.Receive(ref any);
|
|
from = any;
|
|
return bytes;
|
|
}
|
|
catch (SocketException ex)
|
|
when (ex.SocketErrorCode == SocketError.TimedOut)
|
|
{
|
|
from = null;
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Non-blocking receive: returns the next pending datagram if one is
|
|
/// already in the kernel buffer, or <c>null</c> if not. Never blocks,
|
|
/// so it's safe to call once per game-loop frame. Uses
|
|
/// <see cref="UdpClient.Available"/> under the hood.
|
|
/// </summary>
|
|
public byte[]? TryReceive(out IPEndPoint? from)
|
|
{
|
|
if (_udp.Available == 0)
|
|
{
|
|
from = null;
|
|
return null;
|
|
}
|
|
|
|
try
|
|
{
|
|
IPEndPoint any = new(IPAddress.Any, 0);
|
|
var bytes = _udp.Receive(ref any);
|
|
from = any;
|
|
return bytes;
|
|
}
|
|
catch (SocketException)
|
|
{
|
|
from = null;
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public void Dispose() => _udp.Dispose();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of receiving one UDP datagram into caller-owned memory.
|
|
/// </summary>
|
|
public readonly record struct NetReceiveResult(
|
|
int Length,
|
|
IPEndPoint RemoteEndPoint);
|