fix(net): survive transient socket errors in the receive loop (2026-07-24 audit review)

WorldSession.NetReceiveLoop wrapped its entire while loop in a single
try/catch, so ANY non-timeout SocketException permanently killed the
background receive thread: the catch block at the loop's end was empty
(misattributed the error to "socket closed during shutdown"), and the
finally called _inboundQueue.Writer.TryComplete(), which silently and
irrecoverably stopped all inbound processing for the rest of the
session — no log line, no recovery path, and LiveSessionHost.Reconnect
has zero production callers to notice.

The realistic trigger is a well-known Windows UdpClient quirk: an ICMP
"port unreachable" reply to an EARLIER Send (e.g. against a stale ACE
session that already tore down its socket) surfaces as a
WSAECONNRESET SocketException on this socket's NEXT, completely
unrelated Receive call. NetClient.Receive already swallows the
expected SocketError.TimedOut heartbeat case; anything else reaching
WorldSession was a real, transient, per-datagram error being treated
as session-fatal.

Three changes, root-cause not a band-aid:

- NetClient's constructor now disables SIO_UDP_CONNRESET reporting on
  Windows, so a delayed ICMP error can't poison receives at all.
- NetReceiveLoop now catches SocketException PER ITERATION, logs it,
  and continues polling instead of exiting. The existing clean-shutdown
  paths (cancellation, ObjectDisposedException during Dispose) are
  unchanged — only the non-timeout-socket-error case that used to kill
  the loop is now recoverable.
- NetClient.Receive no longer calls the ReceiveTimeout setter (a
  setsockopt syscall) on every single call — only when the requested
  timeout differs from the last-applied value, cached in a new field.
  This was an unrelated but adjacent finding (4x/sec syscall churn at
  the 250ms heartbeat cadence) in the same audit.

No change to outbound wire behavior, ack cadence, heartbeat interval,
or datagram ordering — this is purely receive-loop resilience.

Tests: NetClientTests gained a SIO_UDP_CONNRESET construction smoke
test and two ReceiveTimeout-caching tests. A new
WorldSessionNetReceiveLoopResilienceTests drives the actual private
NetReceiveLoop method (via the existing internal
IWorldSessionTransport seam + reflection) with a scripted transport
that throws a non-timeout SocketException on the first call, proving
the loop survives it and keeps enqueueing subsequent datagrams — fully
deterministic, no real sockets. Full solution suite green: 3204/3206
Core, 3462/3465 App, 552/552 Core.Net (skips pre-existing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit c72ce028a927e15b8a54a86bbd0661a723dc84ca)
This commit is contained in:
Erik 2026-07-24 11:38:37 +02:00
parent c2cb83d11f
commit cf25330458
4 changed files with 215 additions and 3 deletions

View file

@ -973,6 +973,21 @@ public sealed class WorldSession : IDisposable
/// tries again. On shutdown, <see cref="Dispose"/> cancels the token
/// and joins the thread.
/// </para>
///
/// <para>
/// 2026-07-24 audit fix: a per-iteration <see cref="SocketException"/>
/// (anything other than the expected <see cref="SocketError.TimedOut"/>,
/// which <see cref="NetClient.Receive"/> already swallows) used to be
/// caught by an OUTER try/catch that wrapped the entire while loop,
/// so a single transient error — classically Windows' WSAECONNRESET,
/// delivered on this socket's next Receive after an earlier Send hit
/// an ICMP port-unreachable — exited the loop for good. The
/// <c>finally</c> then completed <see cref="_inboundQueue"/>'s writer,
/// permanently killing inbound processing for the rest of the session
/// with no log line. Non-timeout socket errors are recoverable,
/// per-datagram events, not session-fatal, so they're now caught
/// per-iteration, logged, and the loop keeps polling.
/// </para>
/// </summary>
private void NetReceiveLoop()
{
@ -980,13 +995,26 @@ public sealed class WorldSession : IDisposable
{
while (!_netCancel.Token.IsCancellationRequested)
{
var bytes = _net.Receive(TimeSpan.FromMilliseconds(250), out _);
byte[]? bytes;
try
{
bytes = _net.Receive(TimeSpan.FromMilliseconds(250), out _);
}
catch (System.Net.Sockets.SocketException ex)
{
// Transient, recoverable socket error (e.g. WSAECONNRESET
// from a stale peer's ICMP port-unreachable) — log and
// keep receiving. Does NOT tear down _inboundQueue.
Console.Error.WriteLine(
$"[net] receive error (continuing): {ex.SocketErrorCode} {ex.Message}");
continue;
}
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
{