Skip to content

Async network packet processing via RewrittenBatchPeer + asio strand #356

Description

@wu-vincent

Summary

BDS processes inbound network packets — decryption, decompression, batch splitting, packet deserialization, and handler dispatch — synchronously on the main thread inside NetworkSystem::_sortAndPacketizeEvents, on a server. Outbound packets are async only after a client authenticates, and even then they run on a single shared worker thread. This is a major scalability bottleneck: the node with the most connections does the most expensive work single-threaded.

The threading details below were verified against the BDS 1.26.10.4 (Windows) binary. Chain composition is version-specific.

Background: what BDS actually does today

Peer chain. The chain is built in NetworkConnection's constructor (not in onNewIncomingConnection), each layer wrapping the previous:

connection->peer
  → PacketTraceNetworkPeer          ← outermost (this IS connection->peer)
    → BatchedNetworkPeer
      → CompressedNetworkPeer
        → EncryptedNetworkPeer       (only constructed when encryption is enabled)
          → RakNet…NetworkPeer       ← innermost

Send. BatchedNetworkPeer::mAsyncEnabled defaults to false. flush() sends synchronously on the main thread until it's flipped on. The only thing that flips it is NetworkSystem::enableAsyncFlush, called from exactly one site — ServerNetworkHandler::_onClientAsyncAuthorized — i.e. per-connection, only after authentication. When enabled, the send task runs on a TaskGroup bound to MinecraftWorkerPool::NETWORK, which is hardcoded to 1 worker thread, shared by every connection. So BDS's async send is off-main but serialized onto a single thread — not parallel.

Receive. _sortAndPacketizeEvents runs the whole pipeline: connection->receivePacket() (decrypt → decompress → batch-split via the inner chain) → header parse → MinecraftPackets::createPacketPacket::readNoHeader (full deserialize) → dispatch to the NetEventCallback (guarded by the mIncomingPackets mutex). It is driven by a lambda in NetworkSystem::runEvents(bool networkIsCritical) that round-robins all connections under mConnectionsMutex on one thread.

networkIsCritical is computed in Minecraft::update and is effectively "a live ServerNetworkHandler exists"always true when hosting (dedicated or integrated server) → the loop runs inline on the main thread, unbounded, every tick. (Pure clients take the other branch: posted to mReceiveTaskGroup with a ~1 ms budget — but that's still a single serial task, not per-connection parallelism, and never happens server-side.)

Net effect: on a server, decryption/decompression/deserialization for all connections happens on the main thread each tick; the only async BDS offers is post-auth send on one shared thread. This is the design that breaks down at high player counts.

Proposal

Splice an ABI-compatible AsyncBatchedNetworkPeer into the chain in place of BatchedNetworkPeer, using a Netty-style EventLoopGroup (a boost::asio io_context + a fixed pool of worker threads); each connection binds to one EventLoop (a serialized asio strand) that handles both directions. PacketTraceNetworkPeer and the inner chain (Compressed → Encrypted → RakNet) are left untouched; only the Batched layer is swapped. The inner chain runs on the connection's event loop. This is the standard Netty model (codec on event-loop workers, handlers marshalled to the main thread).

AsyncBatchedNetworkPeer derives from BatchedNetworkPeer (identical memory layout) and stays synchronous on the main thread until the connection authenticates, flipping to the strand only once BDS sets mAsyncEnabled post-auth — see Activation below. This keeps the entire handshake (encryption/compression setup) on the main thread, where it already runs, so there is no async window to race.

Corrected peer-chain topology

EventLoopGroup: boost::asio::io_context + fixed N worker threads
Per-connection: one EventLoop (asio strand), both directions

  connection->peer
    → PacketTraceNetworkPeer (BDS, retained)         [main thread: forwards + trace]
      → AsyncBatchedNetworkPeer (replaces Batched)   [main thread: enqueue/dequeue + events]
        → CompressedNetworkPeer (BDS)      [event loop: decompress/compress]
          → EncryptedNetworkPeer (BDS)     [event loop: decrypt/encrypt] (if encryption enabled)
            → BufferedRakNetPeer (new)     [event loop: pop raw queue | main thread: drain RakNet buffer]
              → RakNetNetworkPeer          [main thread only: per-connection read buffer]

PacketTraceNetworkPeer stays as the outermost connection->peer; it's a pass-through that forwards sendPacket/_receivePacket to its peer_ (now the AsyncBatchedNetworkPeer), so its tracing keeps working on the main thread. Posting at the Batch level puts the entire inner chain on the strand worker automatically. AsyncBatchedNetworkPeer wraps the same CompressedNetworkPeer the old Batched wrapped — grab it from the old Batched's peer_ (equivalently the connection->compressed_peer weak_ptr).

A second, lightweight BufferedRakNetPeer is spliced at the bottom of the chain, wrapping the RakNetNetworkPeer leaf. RakNetNetworkPeer keeps each connection's received packets in an unsynchronized per-peer buffer (mReadBufferDatas) that is filled on the main thread (RakNetConnector::runEvents → newData()), so the strand-side codec must never call into it. BufferedRakNetPeer confines that buffer to the main thread — see Receive flow and Thread-safety hazards below. It is transparent to AsyncBatchedNetworkPeer, which just sees the inner chain root.

Receive flow

  1. Main threadBufferedRakNetPeer::update(), driven by BDS's per-tick update() chain (the same thread that fills RakNet's buffer via newData()), drains RakNetNetworkPeer's read buffer into a per-connection raw SPSCQueue.
  2. Event loopAsyncBatchedNetworkPeer posts recvLoop: peer_->_receivePacket() chains Compressed → Encrypted → BufferedRakNetPeer on the event loop. BufferedRakNetPeer::_receivePacket just pops the raw queue (never touching RakNetNetworkPeer), so decrypt/decompress still run on the worker → splits the batch → enqueues decoded sub-packets to a second lock-free SPSCQueue.
  3. Main thread_sortAndPacketizeEvents is unchanged; it calls connection->peer->_receivePacket() (= PacketTrace, which forwards to AsyncBatchedNetworkPeer::_receivePacket), which simply becomes a lock-free pop from the decoded queue. Then createPacketreadNoHeaderPacketReceiveEvent → handler all run on the main thread, in arrival order.

Two SPSC queues per connection: raw (main producer = the RakNet drain, strand consumer = recvLoop) and decoded (strand producer = recvLoop, main consumer = _sortAndPacketizeEvents). The inner-chain update() therefore runs on the main thread (it forwards down to the BufferedRakNetPeer drain + RakNet telemetry), while only the codec (sendPacket / _receivePacket) is posted to the strand — mirroring BDS's own async-send model.

Send flow

  1. Main threadsendPacket() fires PacketSendEvent + packet patching, accumulates the batch.
  2. Main threadflush() posts the batch to the event loop, then runs the completion callback inline on the main thread (the batch is already handed off to the task, so "flushed to the send path" holds at enqueue, and BDS callers see the callback on the main thread).
  3. Event looppeer_->sendPacket() chains Compressed → Encrypted → RakNet.

Both flows above describe the async path. Before activation (see below) sendPacket/_receivePacket/flush simply delegate to the BatchedNetworkPeer base — i.e. BDS's normal synchronous main-thread behavior.

Activation: synchronous until post-auth

AsyncBatchedNetworkPeer derives from BatchedNetworkPeer, so it inherits the bool mAsyncEnabled field at the exact offset BDS's enableAsyncFlush writes to. We reuse that flag as the gate:

  • mAsyncEnabled == false (pre-auth: handshake, NetworkSettings/compression, encryption setup): the overridden sendPacket/_receivePacket/flush just call the BatchedNetworkPeer base — BDS's exact synchronous, main-thread behavior. No strand, no worker exists for the connection yet.
  • mAsyncEnabled == true: BDS flips this in _onClientAsyncAuthorized (post-auth), after it has already called enableEncryption in the same function (enableEncryption precedes enableAsyncFlush). On the next main-thread _receivePacket/update, we observe the flag, flush any pending base buffer once, post the first recvLoop to the connection's event loop, and switch to the async path for both directions.

Why this dissolves the handshake state-flip race: setCompressionEnabled (pre-auth) and enableEncryption (in _onClientAsyncAuthorized) both run on the main thread while we are still synchronous — there is no strand worker in existence to race them. By the time the strand activates, the cipher and compression state are fully configured and published. No barrier hooks, no separate interception of those calls are needed. (Read mAsyncEnabled as an acquire load; on the x86-64 server target a plain-bool store/load is already release/acquire, guaranteeing the encryption state set just before it is visible.)

Threading model

  • Per-connection strand is a correctness requirement, not just a convenience. Decryption is inherently serial per connection — EncryptedNetworkPeer carries chained cipher state, a per-packet receive counter, and an HMAC chain, so packet N+1 cannot be decrypted before packet N on the same connection. Therefore parallelism is across connections, never within. One strand per connection serializes a connection's ops while letting different connections run on different workers.
  • The per-connection SPSC queue (single producer = that connection's strand worker, single consumer = main thread) preserves per-connection FIFO into the main-thread drain.
  • Fixed thread pool (N workers); thread count is independent of connection count. No per-connection threads, no ThreadPoolPeer.

Injection point

Hook NetworkSystem::onNewIncomingConnection (vtable hook). Note this function does not build the chain — NetworkConnection's ctor does — so run the original first, then splice in place of the old Batched rather than replacing connection->peer wholesale. Walking to the holder (instead of assuming a fixed depth) keeps this robust against version differences in the outer layers (e.g. a Latency peer):

auto batched = connection->batched_peer.lock();                  // keeps it alive during the swap

// 1. Splice a BufferedRakNetPeer above the childless RakNet leaf, so RakNet's read buffer stays main-thread-only.
auto *leaf = &batched->peer_;                                    // walk Compressed → Encrypted → … → RakNet
while ((*leaf)->peer_) {
    leaf = &(*leaf)->peer_;
}
*leaf = std::make_shared<BufferedRakNetPeer>(*leaf);             // wrap the leaf; transparent to the async peer

// 2. Replace the BatchedNetworkPeer with the async one wherever the chain holds it.
auto *slot = &connection->peer;                                  // PacketTrace (→ Latency?) → … → batched
while (*slot && *slot != batched) {
    slot = &(*slot)->peer_;
}
auto async = std::make_shared<AsyncBatchedNetworkPeer>(
    id, batched->peer_ /* inner chain root, COPIED */, group.next() /* one EventLoop (strand) */);
*slot = async;                                                    // splice in place of old Batched
connection->batched_peer = async;                                // type-compatible (derives from BatchedNetworkPeer)

// Do NOT start the recv loop here — stay synchronous until mAsyncEnabled flips post-auth (see Activation).

A pointer-to-slot (shared_ptr<NetworkPeer>*) is used rather than a reference because a walk must reseat the cursor (leaf = &(*leaf)->peer_) without mutating the chain, then write only the final slot (*leaf = …); the same uniform walk handles both the chain head and any interior node, so there is no head-vs-interior special-casing.

Why this is safe: the lock() keeps the old Batched alive through the swap; async copies the inner-chain root shared_ptr (ctor takes it by value), so the inner chain survives the old Batched's destruction; and the whole splice runs at connection setup on the main thread before the first recv task is posted — no strand worker is touching the chain yet, so no races or UAF.

peer_ is protected, so the walk/splice (and BufferedRakNetPeer's drain) need friend declarations in network_peer.h (BDS already does the equivalent for getId()).

batched_peer is weak_ptr<BatchedNetworkPeer>, and since AsyncBatchedNetworkPeer : public BatchedNetworkPeer, the reassignment is type-compatible and enableAsyncFlush keeps working — it writes our inherited mAsyncEnabled at the correct offset, which is precisely the activation signal we want (see Activation). The base ctor builds a TaskGroup/SPSCQueue we never use in the async path (we use our own strand); that's the minor, accepted cost of layout compatibility.

Eliminates the current ENDSTONE_HOOK on BatchedNetworkPeer::sendPacket / _receivePacket — all logic moves into AsyncBatchedNetworkPeer.

Thread-safety hazards

  • RakNetNetworkPeer::mReadBufferDatas — the per-connection read buffer is unsynchronized, produced on the main thread (RakNetConnector::runEvents → newData()). Calling RakNetNetworkPeer::_receivePacket from the strand to decode would race it. Resolved by BufferedRakNetPeer at the bottom of the chain: its update() drains the buffer on the main thread into a raw SPSC queue, and its _receivePacket() (called on the strand) pops that queue instead of touching RakNet. So mReadBufferDatas is only ever accessed on the main thread (newData + the drain), and the inner-chain update() runs on the main thread to carry the drain.
  • enableEncryption / setCompressionEnablednot a hazard with the activation gate. Both run on the main thread during the handshake while we are still synchronous (mAsyncEnabled == false), so no strand worker exists to race them. The cipher/compression state is fully configured before the strand ever activates. No barrier hooks or interception needed.
  • Connection teardown — the event loop may have in-flight work when BDS removes the NetworkConnection (idle/disconnected connections are culled under mConnectionsMutex). AsyncBatchedNetworkPeer uses enable_shared_from_this; every posted task captures shared_from_this(), so the peer can't be destroyed while a task is in flight. Consequence: if teardown lands while a task is in flight, the last reference (and thus the inner BDS peer chain) is released on a worker thread when the task finishes. Verified safe (1.26.10.4): those destructors are pure memory cleanup + atomic refcount ops — ~RakNetNetworkPeer frees its read/send buffers (no RakPeer close, no connector deregistration) and ~EncryptedNetworkPeer frees its cipher/HMAC state; none touch RakPeer, the connector, or a scheduler. The EventLoopGroup is owned by EndstoneServer and outlives all connections (each EventLoop references its io_context).
  • flush(callback) — runs inline on the main thread at enqueue, where BDS callers expect it.
  • Codec update() on main ‖ codec sendPacket/_receivePacket on strand — the inner-chain update() runs on the main thread (to carry the BufferedRakNetPeer drain + RakNet telemetry) while the strand runs the codec. Verified safe (1.26.10.4): CompressedNetworkPeer/EncryptedNetworkPeer don't override update()/flush(); they inherit NetworkPeer::update(), which is literally if (mPeer) mPeer->update(); — so the chain update() does no codec work. Send and receive codec state are disjoint and each is serialized by the strand. (Same model as BDS's own async send: codec sendPacket on a worker ‖ update() on main.)
  • mAsyncEnabled read — treat as an acquire load (benign on the x86-64 server target).

Why keep BDS's Compressed / Encrypted / RakNet peers

BDS accesses compressed_peer (and encrypted_peer) weak_ptrs to call setCompressionEnabled / enableEncryption after the handshake. Keep the inner chain intact — and PacketTrace too; only the Batched layer is swapped.

Why this beats simply enabling BDS's existing async

BDS already has post-auth async send — but on a single shared NETWORK thread (1 worker, hardcoded), and no async receive at all on a server. A cheap interim experiment is to call enableAsyncFlush early/unconditionally to get that 1-thread send path for free, but it does not parallelize across connections and does nothing for receive. The EventLoopGroup delivers per-connection parallelism on both directions — and moves receive off the main thread for the first time on a server.

Files to create

  • src/endstone/core/network/event_loop_group.{h,cpp} — RAII Netty-style EventLoopGroup (asio io_context + worker threads), owned by EndstoneServer
  • src/endstone/core/network/async_batched_network_peer.{h,cpp} — the spliced peer
  • src/endstone/core/network/buffered_raknet_peer.{h,cpp} — main-thread RakNet read-buffer shim (thread-safe receive)

Files to modify

  • src/bedrock/network/network_peer.h — friend declarations for the splice + BufferedRakNetPeer; default chain-forwarding implementations for the pass-through virtuals
  • src/bedrock/network/batched_network_peer.h — full layout reconstruction (real typed members) so AsyncBatchedNetworkPeer can derive from it
  • src/bedrock/core/utility/binary_stream.cpp — implement getAndReleaseData
  • src/endstone/runtime/bedrock_hooks/batched_network_peer.cppremoved (logic moved into AsyncBatchedNetworkPeer)
  • src/endstone/runtime/bedrock_hooks/network_system.cpponNewIncomingConnection RVA hook + splice
  • src/endstone/core/server.{h,cpp} — own the EventLoopGroup
  • scripts/configs/{windows,linux}.toml + regenerated src/bedrock/symbols/*.h — drop BatchedNetworkPeer symbols, add onNewIncomingConnection
  • src/endstone/core/CMakeLists.txt — add new sources

Only the Batched layer is swapped, so packet_trace_peer / compressed_peer / encrypted_peer weak_ptrs stay valid, and batched_peer is reassigned to the new peer (which derives from BatchedNetworkPeer) — so BDS's enableAsyncFlush writes the inherited mAsyncEnabled, our activation signal.

Expected impact

  • Main-thread network processing → lock-free queue ops + event dispatch + handler only.
  • Decryption, decompression, compression, encryption, and batch splitting → all on strand workers, parallel across connections.
  • Receive moves off the main thread on a server for the first time.
  • Fixed thread count regardless of player count.
  • Significant TPS improvement under high player counts (50+).

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

Fields

No fields configured for Task.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions