add credit based mux window

This commit is contained in:
iceBear67
2026-07-15 14:59:32 +00:00
parent ada07e0e36
commit bbe4efbe16
15 changed files with 651 additions and 139 deletions
+2 -1
View File
@@ -2,5 +2,6 @@
"listen": "0.0.0.0:25565",
"psk": "change-me-to-a-long-random-passphrase",
"timestampWindowMs": 30000,
"pendingTimeoutMs": 10000
"pendingTimeoutMs": 10000,
"streamWindowBytes": 262144
}
@@ -11,7 +11,8 @@ public record Config(
int port,
String psk,
long timestampWindowMs,
long pendingTimeoutMs
long pendingTimeoutMs,
int streamWindowBytes
) {
public static Config load(Path file) throws Exception {
JsonObject json = new JsonObject(Files.readString(file));
@@ -25,11 +26,15 @@ public record Config(
String psk = json.getString("psk");
if (psk == null || psk.isEmpty()) throw new IllegalArgumentException("psk is required");
int window = json.getInteger("streamWindowBytes", Protocol.DEFAULT_STREAM_WINDOW);
window = Math.max(Protocol.MIN_STREAM_WINDOW, Math.min(Protocol.MAX_STREAM_WINDOW, window));
return new Config(
host,
port,
psk,
json.getLong("timestampWindowMs", 30_000L),
json.getLong("pendingTimeoutMs", 10_000L));
json.getLong("pendingTimeoutMs", 10_000L),
window);
}
}
@@ -4,6 +4,7 @@ import io.icybear.redapricot.crypto.Crypto;
import io.icybear.redapricot.net.EncryptedFrames;
import io.icybear.redapricot.util.Hex;
import io.icybear.redapricot.util.ProtoReader;
import io.icybear.redapricot.util.ProtoWriter;
import io.icybear.redapricot.util.VarInt;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.net.NetSocket;
@@ -145,6 +146,26 @@ public final class HubConnection {
return;
}
// Mandatory trailing feature flags: the client must offer per-stream flow
// control and advertise its receive window. Anything else is an
// unsupported (pre-flow-control) peer and is rejected.
int flags;
int peerWindow;
try {
flags = r.readVarInt();
peerWindow = r.readVarInt();
} catch (RuntimeException e) {
LOG.warn("{} rekey without feature flags (unsupported client version); closing", id);
frames.close();
return;
}
if ((flags & Protocol.FLAG_STREAM_FC) == 0 || peerWindow <= 0) {
LOG.warn("{} client did not offer per-stream flow control; closing", id);
frames.close();
return;
}
peerWindow = Math.min(peerWindow, Protocol.MAX_STREAM_WINDOW);
// REKEY = Rand || Timestamp(I64 big-endian). Magic is excluded.
byte[] rekey = new byte[randLen + 8];
System.arraycopy(rand, 0, rekey, 0, randLen);
@@ -157,7 +178,12 @@ public final class HubConnection {
frames.switchCiphers(
Crypto.decryptCipher(rekey, Crypto.DIR_C2S),
Crypto.encryptCipher(rekey, Crypto.DIR_S2C));
frames.send(new byte[]{(byte) Protocol.CTL_SESSION_READY});
// Echo the accepted flags plus our own receive window.
frames.send(new ProtoWriter()
.u8(Protocol.CTL_SESSION_READY)
.varInt(Protocol.FLAG_STREAM_FC)
.varInt(hub.config.streamWindowBytes())
.toBytes());
if (magic == Protocol.MAGIC_CONTROL) {
ControlSession session = new ControlSession(hub, frames, id);
@@ -165,10 +191,10 @@ public final class HubConnection {
closeCleanup = session::onClose;
LOG.info("{} control session established", id);
} else if (magic == Protocol.MAGIC_WORKER) {
WorkerConn worker = new WorkerConn(hub, frames, id);
WorkerConn worker = new WorkerConn(hub, frames, id, peerWindow, hub.config.streamWindowBytes());
frames.setHandler(worker::onFrame);
closeCleanup = worker::onClose;
LOG.info("{} worker conn established", id);
LOG.info("{} worker conn established (peer window {})", id, peerWindow);
} else {
LOG.warn("{} bad magic {}; closing", id, magic);
frames.close();
@@ -31,6 +31,16 @@ public final class Protocol {
public static final int MUX_DATA = 0x01;
public static final int MUX_FIN = 0x02;
public static final int MUX_RST = 0x03;
public static final int MUX_WND = 0x04; // per-stream flow-control credit grant
// Session-establishment feature flags (trailing VarInt on the Rekey message,
// echoed after the SessionReady type byte when accepted).
public static final int FLAG_STREAM_FC = 0x01;
// Per-stream flow-control window bounds (bytes).
public static final int DEFAULT_STREAM_WINDOW = 256 * 1024;
public static final int MIN_STREAM_WINDOW = 32 * 1024;
public static final int MAX_STREAM_WINDOW = 8 << 20;
// Any redapricot connection
public static final int FRAME_ERROR = 0x7F;
@@ -17,20 +17,44 @@ import java.util.Set;
/**
* An authenticated worker connection (Magic 0x02). Multiplexes many player
* streams; the client opens streams via SYN(CID) to take over pending players.
*
* <p>Every stream has a credit window in both directions (PROTOCOL.md §7.3),
* so one slow player only ever stalls its own stream — the shared worker
* socket is never paused because of a single stream.
*/
@RequiredArgsConstructor
public final class WorkerConn {
private static final Logger LOG = LogManager.getLogger("redapricot.worker");
/** Cap on a single DATA frame so no stream monopolizes the shared link for long. */
private static final int CHUNK = 32 * 1024;
private final Hub hub;
private final EncryptedFrames frames;
private final String id;
private final Map<Integer, NetSocket> streams = new HashMap<>();
private final int sendWndInit; // client's advertised per-stream receive window (our send budget)
private final int recvWndInit; // our advertised per-stream receive window (basis for credit grants)
// Backpressure for the single shared worker socket, arbitrated across all streams.
private final Set<NetSocket> upstreamPaused = new HashSet<>(); // players parked until the worker write queue drains
private final Set<Integer> downstreamBlocked = new HashSet<>(); // sids whose player write queue is full; non-empty => worker read paused
private boolean workerDrainArmed = false; // whether the worker socket's single drainHandler is set
private final Map<Integer, StreamState> streams = new HashMap<>();
// Aggregate backpressure for the single shared worker socket: players parked
// until its write queue drains. Per-stream fairness is the credit windows'
// job; this only reacts to the whole pipe being congested.
private final Set<StreamState> upstreamPaused = new HashSet<>();
private boolean workerDrainArmed = false; // whether the worker socket's single drainHandler is set
/** Per-stream flow-control bookkeeping. */
private final class StreamState {
final NetSocket player;
int sendWnd = sendWndInit; // budget for player -> client DATA
Buffer pendingUp; // player bytes awaiting send window (player is paused meanwhile)
boolean pausedForWindow;
int credited; // client -> player bytes flushed but not yet granted back
StreamState(NetSocket player) {
this.player = player;
}
}
public void onFrame(byte[] payload) {
ProtoReader r = new ProtoReader(payload);
@@ -39,6 +63,7 @@ public final class WorkerConn {
switch (type) {
case Protocol.MUX_SYN -> handleSyn(sid, r.readBytes(Protocol.CID_LEN));
case Protocol.MUX_DATA -> handleData(sid, r.readBuffer(r.remaining()));
case Protocol.MUX_WND -> handleWnd(sid, r.readVarInt());
case Protocol.MUX_FIN, Protocol.MUX_RST -> closeStream(sid);
case Protocol.FRAME_ERROR -> LOG.warn("worker {} error frame", id);
default -> LOG.warn("worker {} unknown mux type {}", id, type);
@@ -53,72 +78,126 @@ public final class WorkerConn {
return;
}
NetSocket player = p.getSocket();
streams.put(sid, player);
StreamState st = new StreamState(player);
streams.put(sid, st);
// From now on the player socket belongs to this stream.
player.handler(buf -> {
sendData(sid, buf.getBytes());
if (frames.writeQueueFull()) {
player.pause();
upstreamPaused.add(player);
armWorkerDrain();
}
sendUpstream(sid, st, buf);
checkAggregate(st);
});
player.closeHandler(v -> onPlayerGone(sid, player));
player.exceptionHandler(t -> onPlayerGone(sid, player));
player.closeHandler(v -> onPlayerGone(sid, st));
player.exceptionHandler(t -> onPlayerGone(sid, st));
// Forward the buffered handshake (and any pipelined bytes), then resume.
sendData(sid, p.getBuffered().getBytes());
player.resume();
sendUpstream(sid, st, p.getBuffered());
if (!st.pausedForWindow) player.resume();
checkAggregate(st);
LOG.info("worker {} stream {} bound to {}", id, sid, p.getPattern());
}
private void handleData(int sid, Buffer data) {
NetSocket player = streams.get(sid);
if (player == null) return;
player.write(data);
// A slow player pauses the shared worker read side; the block set lets us resume only
// once every blocked player has drained (and release it if a player disconnects meanwhile).
if (player.writeQueueFull() && downstreamBlocked.add(sid)) {
if (downstreamBlocked.size() == 1) frames.socket().pause();
player.drainHandler(v -> unblockDownstream(sid));
/**
* Send player bytes to the client, chunked and clipped to the stream window;
* the overflow is parked in {@code pendingUp} and the player socket paused
* until the client grants more credit.
*/
private void sendUpstream(int sid, StreamState st, Buffer buf) {
if (st.pendingUp != null) { // still waiting for window; keep ordering
st.pendingUp.appendBuffer(buf);
return;
}
int off = drainUpstream(sid, st, buf, 0);
if (off < buf.length()) {
st.pendingUp = buf.getBuffer(off, buf.length());
if (!st.pausedForWindow) {
st.pausedForWindow = true;
st.player.pause();
}
}
}
/** Send from {@code buf[off..]} within the stream window, chunked; returns the new offset. */
private int drainUpstream(int sid, StreamState st, Buffer buf, int off) {
while (off < buf.length() && st.sendWnd > 0) {
int n = Math.min(Math.min(CHUNK, st.sendWnd), buf.length() - off);
sendData(sid, buf.getBytes(off, off + n));
st.sendWnd -= n;
off += n;
}
return off;
}
/** The client granted {@code delta} more bytes of credit on a stream. */
private void handleWnd(int sid, int delta) {
StreamState st = streams.get(sid);
if (st == null || delta <= 0) return;
st.sendWnd += delta;
if (st.pendingUp != null) {
Buffer pending = st.pendingUp;
int off = drainUpstream(sid, st, pending, 0);
st.pendingUp = off >= pending.length() ? null : pending.getBuffer(off, pending.length());
}
if (st.pendingUp == null && st.pausedForWindow) {
st.pausedForWindow = false;
if (!upstreamPaused.contains(st)) st.player.resume();
}
checkAggregate(st);
}
private void handleData(int sid, Buffer data) {
StreamState st = streams.get(sid);
if (st == null) return;
// Never pause the shared socket: the client bounds what it sends per
// stream to our advertised window, so a slow player only piles up a
// bounded amount in its own write queue; credit is granted back as the
// write completes (i.e. the bytes reached the player socket).
int len = data.length();
st.player.write(data).onComplete(ar -> {
if (ar.failed() || frames.isClosed() || streams.get(sid) != st) return;
st.credited += len;
if (st.credited * 2 >= recvWndInit) {
int delta = st.credited;
st.credited = 0;
sendWnd(sid, delta);
}
});
}
private void closeStream(int sid) {
NetSocket player = streams.remove(sid);
unblockDownstream(sid);
if (player != null) {
upstreamPaused.remove(player);
player.close();
StreamState st = streams.remove(sid);
if (st != null) {
upstreamPaused.remove(st);
st.player.close();
}
}
/** Register (once) the shared worker socket's single drain handler; on drain, wake every parked player. */
/** Park the player if the shared worker socket's write queue is congested. */
private void checkAggregate(StreamState st) {
if (frames.writeQueueFull() && upstreamPaused.add(st)) {
st.player.pause();
armWorkerDrain();
}
}
/** Register (once) the shared worker socket's single drain handler; on drain, wake parked players. */
private void armWorkerDrain() {
if (workerDrainArmed) return;
workerDrainArmed = true;
frames.socket().drainHandler(v -> {
workerDrainArmed = false;
if (upstreamPaused.isEmpty()) return;
NetSocket[] parked = upstreamPaused.toArray(new NetSocket[0]);
StreamState[] parked = upstreamPaused.toArray(new StreamState[0]);
upstreamPaused.clear();
for (NetSocket pl : parked) pl.resume();
for (StreamState st : parked) {
if (!st.pausedForWindow) st.player.resume();
}
});
}
/** A blocked player drained or vanished: drop its downstream block, resuming the shared worker read side when none remain. */
private void unblockDownstream(int sid) {
if (downstreamBlocked.remove(sid) && downstreamBlocked.isEmpty()) {
frames.socket().resume();
}
}
/** The player side of a stream vanished: drop it from every table, release any backpressure it held, and FIN the peer if still live. */
private void onPlayerGone(int sid, NetSocket player) {
boolean wasLive = streams.remove(sid) != null;
upstreamPaused.remove(player);
unblockDownstream(sid);
private void onPlayerGone(int sid, StreamState st) {
boolean wasLive = streams.remove(sid) == st;
upstreamPaused.remove(st);
if (wasLive) sendFin(sid);
}
@@ -134,10 +213,13 @@ public final class WorkerConn {
frames.send(new ProtoWriter().u8(Protocol.MUX_RST).varInt(sid).toBytes());
}
private void sendWnd(int sid, int delta) {
frames.send(new ProtoWriter().u8(Protocol.MUX_WND).varInt(sid).varInt(delta).toBytes());
}
public void onClose() {
for (NetSocket player : streams.values()) player.close();
for (StreamState st : streams.values()) st.player.close();
streams.clear();
downstreamBlocked.clear();
upstreamPaused.clear();
LOG.info("worker {} closed", id);
}
@@ -77,7 +77,7 @@ class CryptoCodecTest {
/** A Hub whose event loop is never touched (register/match/normalize use no Vert.x state). */
private static Hub testHub() {
return new Hub(null, new Config("0.0.0.0", 25565, "test-psk", 30_000L, 10_000L));
return new Hub(null, new Config("0.0.0.0", 25565, "test-psk", 30_000L, 10_000L, Protocol.DEFAULT_STREAM_WINDOW));
}
private static ControlSession testSession(Hub hub, String id) {