Skip to content

Remove up to 50 ms of outbound packet latency - #341

Merged
PetteriM1 merged 3 commits into
CloudburstMC:3.0from
SendableMetatype:on-demand-flush
Aug 10, 2026
Merged

Remove up to 50 ms of outbound packet latency#341
PetteriM1 merged 3 commits into
CloudburstMC:3.0from
SendableMetatype:on-demand-flush

Conversation

@SendableMetatype

Copy link
Copy Markdown
Contributor

Outbound packets wait for a fixed 50 ms flush tick, which adds 0 to 50 ms to every batch when the packet producer is not a local game loop (proxies, standalone tools). Packets are now flushed 1 ms after enqueueing, so the wait is bounded by ~1 ms plus timer granularity, while batching, compression, and reliability behavior stay as they are.

Notes for review:

  • With RAK_AUTO_FLUSH on (the default), the RakNet layer ignores explicit flushes: datagram cadence stays on the existing 10 ms session tick, and the worst case wait still drops from ~60 ms to ~10 ms. Setting RAK_AUTO_FLUSH=false gives the full on demand path.
  • For a tick aligned server, sends spread over more than 1 ms within a tick compress as a few smaller batches instead of one; datagram counts are unchanged under auto flush. sendPacketImmediately() is unchanged.
  • Checked Cloudburst Server, ProxyPass, BedrockConnect, and Geyser: none set RAK_AUTO_FLUSH or override the affected code.
  • Per packet flushing (no coalescing window) was considered and decided against, since it costs 3.4x the bandwidth for under 1 ms of further gain.

Results: in my own testing on Velocity with Geyser (RAK_AUTO_FLUSH=false), backend measured player ping dropped from 50-100 ms to 20-45 ms. Combined with separate Geyser side changes that make its ping measurement more accurate, essentially every user who tested the release reported noticeably better ping.

@HashimTheArab

Copy link
Copy Markdown
Contributor

Nice, I implemented this yesterday oomph-ac/oomph#147

The fixed 50ms tick dates back to the 2019 common refactor and assumes
the packet producer runs a 20 TPS game loop, which holds for a game
server but not for a proxy translating a remote server's traffic, where
it added 0 to 50ms to every outbound batch. Enqueueing now schedules a
single flush 1ms out, so a tick burst still coalesces into one batch
and compression and framing behavior are unchanged, while the tick
latency is gone. Reliability is untouched; RakNet keeps its own
session tick.

The closing state keeps its existing semantics: once a disconnect is
requested nothing more is written, and onClose() frees whatever
remains queued.
The closing and closed door gates on the send paths are check then act:
a producer can pass the gate just as teardown begins, enqueueing a
wrapper that nothing would ever release since the queue stops being
drained. The closed branches of flushPacketQueue() and
schedulePacketFlush() now drain it, handed to the event loop since the
MPSC queue permits one consumer, with free() synchronized because a
rejected execute means the loop is shut down or saturated and may
still be running tasks. A rejected flush schedule no longer rethrows:
the wrapper stays owned by the queue, and a deduplicated retry on
GlobalEventExecutor, which never rejects, re-enters the scheduling
path until the loop accepts or the peer closes, so a live peer with
no further sends cannot strand a packet and saturation cannot fan out
into an unbounded global executor backlog. onClose() iterates a
snapshot of the session map, since sessions remove themselves during
teardown and mutating the open addressing map mid iteration corrupts
it (reproducible with ids 0, 1, 33, 66, reachable from the wire via
inbound target client ids); the drain now sits in a finally so a
throwing session can no longer skip it forever. An off loop close()
retries off the global executor when the loop rejects it, so the
disconnect reason cannot be silently lost while the channel stays
open.
The 1 ms coalescing window costs nothing for producers that hand a burst
over together, but one that emits a burst over a longer stretch splits it
into batches that each compress on their own. Deployments that would
rather trade latency for compression efficiency can set the window with
the org.cloudburstmc.protocol.bedrock.batchFlushDelayMillis system
property, alongside the existing disconnectTimeout property.

The default stays at 1 ms and values outside 0 to 50 fall back to it.
@PetteriM1
PetteriM1 merged commit 9b3c9c2 into CloudburstMC:3.0 Aug 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants