acdream/docs/research/2026-07-25-slice-h-c1-pooled-receive-owner.md
Erik 7211bb1bf7 perf(net): own one pooled async receive
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.
2026-07-25 05:50:18 +02:00

2.9 KiB

Slice H-c1 — cancellable pooled receive owner

Result

The in-world transport now has one asynchronous socket receive owner:

one retained full-size socket buffer
    -> ReceiveFromAsync(cancellation token)
    -> copy actual datagram length into a right-sized ArrayPool bucket
    -> FIFO Channel<PooledInboundDatagram>
    -> render-thread decode/dispatch
    -> return bucket exactly once

The synchronous PumpOnce handshake path keeps its existing 250 ms pacing, but receives into a temporarily rented caller buffer rather than accepting a fresh UdpClient.Receive array. The normal in-world path no longer wakes four times per idle second and no longer uses SocketException(TimedOut) as its heartbeat.

NetClient.Send also consumes its caller's ReadOnlySpan<byte> directly through Socket.SendTo; the transport-only datagram.ToArray() copy is gone. Higher-level outbound framing allocations are intentionally left to H-c3.

Retail and protocol boundary

Behavioral invariants remain unchanged:

  • one outstanding receive;
  • kernel arrival order is FIFO channel order;
  • decode, ISAAC, fragment assembly, ACK decisions, and event dispatch remain on the existing render/update thread;
  • ACK generation remains inside ProcessDatagram, immediately after decode acceptance;
  • LinkStatusHolder::OnHeartbeat (0x004113D0) last-heard placement remains immediately after successful decode;
  • blocking PumpOnce handshake cadence is unchanged.

The initial design review caught that 484 bytes is a fragment limit, not a safe UDP receive limit. Named-retail CNetLayerPacket (type 3705 in acclient.h) contains a separate ProtoHeader plus m_Data[65484]. Accordingly the socket edge retains a full UDP buffer. Only the received byte count crosses the queue boundary, preventing small queued packets from each pinning a 64 KiB bucket.

Holtburger's receive/finalize path remains the cross-reference for ACK-per-accepted-packet ordering. The transport ownership mechanism is a modern .NET adaptation; it does not change AC wire behavior.

Deterministic evidence

  • real loopback ReceiveAsync writes a 1,500-byte datagram into caller-owned memory;
  • cancelling an idle receive terminates the one outstanding operation;
  • a scripted recoverable SocketException does not kill the receive owner;
  • three accepted packets retain arrival order and produce ACKs in the exact sequence 41, 42, 43;
  • blocking handshake timeout behavior and cached socket-timeout tests remain unchanged;
  • graceful confirmation drain releases every consumed pooled datagram, and shutdown cancels/joins the receive task before returning unread buffers.

Remaining H-c work

H-c2 removes Packet, BodyBytes, PacketHeaderOptional, List, and single-fragment payload copies from the production decode path using borrowed memory with an explicit lifetime boundary. H-c3 writes fragment and packet framing directly into caller storage.