add credit based mux window
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user