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::createPacket → Packet::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
- Main thread —
BufferedRakNetPeer::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.
- Event loop —
AsyncBatchedNetworkPeer 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.
- 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 createPacket → readNoHeader → PacketReceiveEvent → 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
- Main thread —
sendPacket() fires PacketSendEvent + packet patching, accumulates the batch.
- Main thread —
flush() 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).
- Event loop —
peer_->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 / setCompressionEnabled — not 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.cpp — removed (logic moved into AsyncBatchedNetworkPeer)
src/endstone/runtime/bedrock_hooks/network_system.cpp — onNewIncomingConnection 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+).
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.Background: what BDS actually does today
Peer chain. The chain is built in
NetworkConnection's constructor (not inonNewIncomingConnection), each layer wrapping the previous:Send.
BatchedNetworkPeer::mAsyncEnableddefaults tofalse.flush()sends synchronously on the main thread until it's flipped on. The only thing that flips it isNetworkSystem::enableAsyncFlush, called from exactly one site —ServerNetworkHandler::_onClientAsyncAuthorized— i.e. per-connection, only after authentication. When enabled, the send task runs on aTaskGroupbound toMinecraftWorkerPool::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.
_sortAndPacketizeEventsruns the whole pipeline:connection->receivePacket()(decrypt → decompress → batch-split via the inner chain) → header parse →MinecraftPackets::createPacket→Packet::readNoHeader(full deserialize) → dispatch to theNetEventCallback(guarded by themIncomingPacketsmutex). It is driven by a lambda inNetworkSystem::runEvents(bool networkIsCritical)that round-robins all connections undermConnectionsMutexon one thread.networkIsCriticalis computed inMinecraft::updateand is effectively "a liveServerNetworkHandlerexists" → 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 tomReceiveTaskGroupwith 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
AsyncBatchedNetworkPeerinto the chain in place ofBatchedNetworkPeer, using a Netty-styleEventLoopGroup(aboost::asioio_context+ a fixed pool of worker threads); each connection binds to oneEventLoop(a serialized asio strand) that handles both directions.PacketTraceNetworkPeerand 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).AsyncBatchedNetworkPeerderives fromBatchedNetworkPeer(identical memory layout) and stays synchronous on the main thread until the connection authenticates, flipping to the strand only once BDS setsmAsyncEnabledpost-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
PacketTraceNetworkPeerstays as the outermostconnection->peer; it's a pass-through that forwardssendPacket/_receivePacketto itspeer_(now theAsyncBatchedNetworkPeer), so its tracing keeps working on the main thread. Posting at the Batch level puts the entire inner chain on the strand worker automatically.AsyncBatchedNetworkPeerwraps the sameCompressedNetworkPeerthe old Batched wrapped — grab it from the old Batched'speer_(equivalently theconnection->compressed_peerweak_ptr).A second, lightweight
BufferedRakNetPeeris spliced at the bottom of the chain, wrapping theRakNetNetworkPeerleaf.RakNetNetworkPeerkeeps 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.BufferedRakNetPeerconfines that buffer to the main thread — see Receive flow and Thread-safety hazards below. It is transparent toAsyncBatchedNetworkPeer, which just sees the inner chain root.Receive flow
BufferedRakNetPeer::update(), driven by BDS's per-tickupdate()chain (the same thread that fills RakNet's buffer vianewData()), drainsRakNetNetworkPeer's read buffer into a per-connection rawSPSCQueue.AsyncBatchedNetworkPeerpostsrecvLoop:peer_->_receivePacket()chains Compressed → Encrypted →BufferedRakNetPeeron the event loop.BufferedRakNetPeer::_receivePacketjust pops the raw queue (never touchingRakNetNetworkPeer), so decrypt/decompress still run on the worker → splits the batch → enqueues decoded sub-packets to a second lock-freeSPSCQueue._sortAndPacketizeEventsis unchanged; it callsconnection->peer->_receivePacket()(=PacketTrace, which forwards toAsyncBatchedNetworkPeer::_receivePacket), which simply becomes a lock-free pop from the decoded queue. ThencreatePacket→readNoHeader→PacketReceiveEvent→ handler all run on the main thread, in arrival order.Send flow
sendPacket()firesPacketSendEvent+ packet patching, accumulates the batch.flush()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).peer_->sendPacket()chains Compressed → Encrypted → RakNet.Activation: synchronous until post-auth
AsyncBatchedNetworkPeerderives fromBatchedNetworkPeer, so it inherits thebool mAsyncEnabledfield at the exact offset BDS'senableAsyncFlushwrites to. We reuse that flag as the gate:mAsyncEnabled == false(pre-auth: handshake,NetworkSettings/compression, encryption setup): the overriddensendPacket/_receivePacket/flushjust call theBatchedNetworkPeerbase — 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 calledenableEncryptionin the same function (enableEncryptionprecedesenableAsyncFlush). On the next main-thread_receivePacket/update, we observe the flag, flush any pending base buffer once, post the firstrecvLoopto 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) andenableEncryption(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. (ReadmAsyncEnabledas 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
EncryptedNetworkPeercarries 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.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 replacingconnection->peerwholesale. Walking to the holder (instead of assuming a fixed depth) keeps this robust against version differences in the outer layers (e.g. aLatencypeer):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;asynccopies 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_isprotected, so the walk/splice (andBufferedRakNetPeer's drain) need friend declarations innetwork_peer.h(BDS already does the equivalent forgetId()).batched_peerisweak_ptr<BatchedNetworkPeer>, and sinceAsyncBatchedNetworkPeer : public BatchedNetworkPeer, the reassignment is type-compatible andenableAsyncFlushkeeps working — it writes our inheritedmAsyncEnabledat the correct offset, which is precisely the activation signal we want (see Activation). The base ctor builds aTaskGroup/SPSCQueuewe never use in the async path (we use our own strand); that's the minor, accepted cost of layout compatibility.Eliminates the current
ENDSTONE_HOOKonBatchedNetworkPeer::sendPacket/_receivePacket— all logic moves intoAsyncBatchedNetworkPeer.Thread-safety hazards
RakNetNetworkPeer::mReadBufferDatas— the per-connection read buffer is unsynchronized, produced on the main thread (RakNetConnector::runEvents → newData()). CallingRakNetNetworkPeer::_receivePacketfrom the strand to decode would race it. Resolved byBufferedRakNetPeerat the bottom of the chain: itsupdate()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. SomReadBufferDatasis only ever accessed on the main thread (newData+ the drain), and the inner-chainupdate()runs on the main thread to carry the drain.enableEncryption/setCompressionEnabled— not 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.NetworkConnection(idle/disconnected connections are culled undermConnectionsMutex).AsyncBatchedNetworkPeerusesenable_shared_from_this; every posted task capturesshared_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 —~RakNetNetworkPeerfrees its read/send buffers (noRakPeerclose, no connector deregistration) and~EncryptedNetworkPeerfrees its cipher/HMAC state; none touchRakPeer, the connector, or a scheduler. TheEventLoopGroupis owned byEndstoneServerand outlives all connections (eachEventLoopreferences itsio_context).flush(callback)— runs inline on the main thread at enqueue, where BDS callers expect it.update()on main ‖ codecsendPacket/_receivePacketon strand — the inner-chainupdate()runs on the main thread (to carry theBufferedRakNetPeerdrain + RakNet telemetry) while the strand runs the codec. Verified safe (1.26.10.4):CompressedNetworkPeer/EncryptedNetworkPeerdon't overrideupdate()/flush(); they inheritNetworkPeer::update(), which is literallyif (mPeer) mPeer->update();— so the chainupdate()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: codecsendPacketon a worker ‖update()on main.)mAsyncEnabledread — treat as an acquire load (benign on the x86-64 server target).Why keep BDS's Compressed / Encrypted / RakNet peers
BDS accesses
compressed_peer(andencrypted_peer) weak_ptrs to callsetCompressionEnabled/enableEncryptionafter the handshake. Keep the inner chain intact — andPacketTracetoo; only theBatchedlayer is swapped.Why this beats simply enabling BDS's existing async
BDS already has post-auth async send — but on a single shared
NETWORKthread (1 worker, hardcoded), and no async receive at all on a server. A cheap interim experiment is to callenableAsyncFlushearly/unconditionally to get that 1-thread send path for free, but it does not parallelize across connections and does nothing for receive. TheEventLoopGroupdelivers 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-styleEventLoopGroup(asioio_context+ worker threads), owned byEndstoneServersrc/endstone/core/network/async_batched_network_peer.{h,cpp}— the spliced peersrc/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 virtualssrc/bedrock/network/batched_network_peer.h— full layout reconstruction (real typed members) soAsyncBatchedNetworkPeercan derive from itsrc/bedrock/core/utility/binary_stream.cpp— implementgetAndReleaseDatasrc/endstone/runtime/bedrock_hooks/batched_network_peer.cpp— removed (logic moved intoAsyncBatchedNetworkPeer)src/endstone/runtime/bedrock_hooks/network_system.cpp—onNewIncomingConnectionRVA hook + splicesrc/endstone/core/server.{h,cpp}— own theEventLoopGroupscripts/configs/{windows,linux}.toml+ regeneratedsrc/bedrock/symbols/*.h— dropBatchedNetworkPeersymbols, addonNewIncomingConnectionsrc/endstone/core/CMakeLists.txt— add new sourcesExpected impact