Commit graph

2 commits

Author SHA1 Message Date
Erik
cf25330458 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)
2026-07-24 11:49:40 +02:00
Erik
0cb30aa0c8 feat(net): live ACE handshake verified — ConnectRequest received and parsed (Phase 4.6a/b/c/d)
Reaches the first major milestone of Phase 4: acdream's codec is proven
byte-compatible with a live ACE server. LiveHandshakeTests drives a real
UDP exchange against 127.0.0.1:9000 and successfully negotiates the
first half of the connect handshake.

Added:
  - Packets/PacketHeaderOptional.cs: new ConnectRequest flag branch.
    ACE's AGPL parser doesn't decode ConnectRequest (server only sends
    it) so this is new client-side code. Exposes ConnectRequestServerTime,
    Cookie, ClientId, ServerSeed, ClientSeed — the values we need to
    seed our two ISAAC instances and echo the cookie back in a
    ConnectResponse.
  - NetClient.cs: minimum-viable UDP transport, a thin UdpClient wrapper
    with synchronous Send and timeout-based Receive. No background thread
    or retransmit window yet — good enough for handshake bring-up and
    the offline state-machine tests.
  - LiveHandshakeTests.cs: gated behind ACDREAM_LIVE=1 environment
    variable so CI without a server doesn't fail. Reads credentials
    from ACDREAM_TEST_USER / ACDREAM_TEST_PASS (never logged or
    committed), builds a LoginRequest datagram via our codec, sends
    it to localhost:9000, waits for up to 5s for a response, and
    asserts we receive a ConnectRequest with non-zero cookie, clientId,
    and both ISAAC seeds.

Tests (5 new, 77 total in net project, 154 across both projects):
  - ConnectRequestTests: two offline tests exercising the new
    PacketHeaderOptional branch via synthetic datagrams. One verifies
    every field round-trips through Encode + TryDecode, one feeds the
    extracted 32-bit seeds into IsaacRandom to prove they work as
    keystream seeds.
  - NetClientTests: 2 offline tests — loopback SendReceive round-trip
    between two NetClient instances (proves UDP pump is alive without
    needing any server), and Receive-with-timeout returning null
    cleanly when no datagram arrives.
  - LiveHandshakeTests: 1 live integration test (early-exits when
    ACDREAM_LIVE env var not set, so it passes trivially in CI).

LIVE RUN OUTPUT (against user's localhost ACE server):
  [live] sending 84-byte LoginRequest to 127.0.0.1:9000 (user.len=11, pass.len=12)
  [live] received 52-byte datagram from 127.0.0.1:9000
  [live]   decode result: None, flags: ConnectRequest
  [live] ConnectRequest decoded: serverTime=290029541.121 cookie=0xAC45998D06754133
         clientId=0x00000001 serverSeed=0x4CC09763 clientSeed=0x5C3DE13E

Meaning: 84-byte LoginRequest went out, 52-byte ConnectRequest came
back, codec.TryDecode returned None error, every field parsed to a
sensible value. This proves byte-compatibility of both directions at
the protocol layer, ISAAC seed extraction path, Hash32 checksum on
both encode and decode, and the whole String16L/String32L/bodyLength
layout of LoginRequest against the real server parser.

Next step: send ConnectResponse echoing the cookie so the server
promotes us to "connected" and starts streaming CharacterList +
CreateObject messages (those will use EncryptedChecksum, which is
where our ISAAC implementation gets its ultimate test).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 14:46:19 +02:00