Skip to content

NetherNet transport improvements from production use#7

Open
SendableMetatype wants to merge 7 commits into
Kas-tle:masterfrom
SendableMetatype:nethernet-improvements
Open

NetherNet transport improvements from production use#7
SendableMetatype wants to merge 7 commits into
Kas-tle:masterfrom
SendableMetatype:nethernet-improvements

Conversation

@SendableMetatype

Copy link
Copy Markdown

A set of improvements from running the NetherNet transport on a production Geyser deployment, where frequent signaling drops at higher player counts motivated most of this.

  • Stabilize NetherNet channels for long running servers: guarded single fire channelActive, signaling stability fixes, a frame aggregator on the signaling pipeline, reassembly buffer release, an outbound fragmentation bound, and unique placeholder remote addresses per connection (see the note below).
  • Make signaling WebSockets reconnectable in place: reconnect(freshToken) swaps only the socket while handlers, the server channel, factories, and live peer connections survive. The signaling channels also gain a pollable liveness API: isChannelAlive with a max silence window and getMillisSinceLastMessage, which consumers can watchdog against; WebSocket protocol pings guarantee inbound pongs on a healthy socket, so the silence check stays reliable even when no signaling traffic is flowing and catches silently half open TCP that channel.isActive misses. Per channel scheduled tasks are cancelled on channel inactive, TURN credential refreshes are applied for the lifetime of the socket, and pending JSON RPC requests fail fast on disconnect.
  • Optimize the data channel path: cached open state instead of a JNI call per write, one reusable outbound scratch buffer with sendAsync, and single copy inbound with buffer handoff.
  • Offload connection setup from the signaling thread, whose keepalives must never starve behind blocking native calls; optional PeerConnectionFactory pooling to spread DTLS and SCTP load across several native network threads; real remote addresses from the ICE selected candidate pair callback.

One deliberate tradeoff to flag for your judgment: child channels previously all shared new InetSocketAddress(0), which is clearly recognizable as not a real address but breaks anything keyed on the remote address (per IP connection limits treat every NetherNet player as one host). This PR gives each connection a unique random 10.x.x.x placeholder instead, which keeps address keyed logic functional but is less obviously fictional in logs. With the candidate pair callback in this PR the placeholder only covers the window before ICE nomination and the rare case where the callback never fires, so if you prefer the recognizable 0.0.0.0 or another marker there, I am happy to adjust.

Note: the last two commits use RTCDataChannel.sendAsync and PeerConnectionObserver.onSelectedCandidatePairChanged from Kas-tle/webrtc-java#3, so this compiles once a webrtc-java release contains that change. Tested in production for signaling drop recovery without player disconnects, throughput at higher player counts, and correct remote addresses.

Collected fixes from running the transport on production servers:
unique placeholder remote addresses per connection instead of a shared
0.0.0.0 (keeps per IP limits and throttling functional), a guarded
single fire channelActive, signaling stability fixes for long uptimes,
a WebSocket frame aggregator on the signaling pipeline, reassembly
buffer release on close, an outbound fragmentation bound, and notes on
the reliable only channel design.
reconnect(freshToken) replaces only the socket to the signaling service.
The signaling instance, its handlers, and everything built on it (server
channel, WebRTC factories, live peer connections) survive, so a
signaling drop no longer requires tearing the transport down.

Liveness is now detectable on idle servers: a WebSocket protocol ping
every 15 seconds guarantees inbound pongs on a healthy socket, so
isChannelAlive(maxSilence) also catches silently half open TCP.

Per channel scheduled tasks are tracked and cancelled on channel
inactive, so reconnects no longer leak ping loops. TURN credential
pushes are applied for the lifetime of the socket instead of only
during connect, and the JSON RPC endpoint refreshes credentials every
30 minutes, so late joining peers no longer receive expired relay
credentials. Pending RPC requests fail fast when the socket dies. The
frame aggregator limit is raised to 128 KB for batched RPC frames.
isActive() reads an observer maintained flag instead of a blocking JNI
call per write. Outbound fragmentation reuses one scratch direct buffer
per channel and sends with sendAsync, which no longer stalls the event
loop on the native network thread. Inbound packets are copied once
instead of twice: complete single segment messages take a fast path and
assembled multi segment buffers are handed off instead of re copied.
Connection setup hops from the signaling I/O thread to the server
channel event loop: the signaling thread only registers the signal
handler (so candidates arriving behind an offer are never dropped) and
its keepalives can no longer be starved by blocking native WebRTC
calls. Candidates arriving before the peer connection exists are queued
in order.

The server channel can be backed by a pool of PeerConnectionFactory
instances with round robin assignment, spreading DTLS and SCTP load
across several native network threads instead of serializing every
player on one.

Channels now receive their real remote address from the ICE selected
candidate pair callback, which fires before DTLS and the data channels
open, replacing the random placeholder before activation. Relayed
connections record the TURN relay address. The old commented out
getStats approach is removed.
Each in flight JSON RPC request now records the WebSocket channel it
was written to. When a socket dies, onChannelInactive fails only the
requests that were sent on that socket: during a reconnect the old
channel's inactive event can no longer fail requests already written
to the replacement socket, which previously left those callers with a
spurious ClosedChannelException while the reply was still on its way.
Every retry bumps a generation counter that async engine callbacks
capture: offer creation, description observers, ICE candidate
emission, connection state changes, and the data channel open handler
all bail once a retry has moved past them. Without this a delayed
callback from an abandoned attempt could send stale SDP or candidates
under the new connection id, trigger a spurious extra retry, or mark
the handshake complete with data channels belonging to a closed peer
connection.

The signal id check also runs again inside the event loop task, since
a retry can cycle the connection id between the check on the signaling
thread and the task running, which previously let an old attempt's
answer reach the new attempt's peer connection.
The native addIceCandidate rejects candidates while the peer
connection has no remote description, and setRemoteDescription
completes asynchronously, so a candidate arriving right behind the
CONNECT_RESPONSE could be dropped. Candidates now queue until the
remote description observer reports success and are then applied in
arrival order. A handshake retry clears the queue along with the rest
of the attempt state.
@Kas-tle

Kas-tle commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Thank you for the PR. I will review this weekend.

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.

2 participants