This commit is contained in:
iceBear67
2026-07-25 16:33:28 +08:00
parent a41cb7965e
commit e63a34d53a
20 changed files with 1787 additions and 95 deletions
+2 -1
View File
@@ -3,5 +3,6 @@
"psk": "change-me-to-a-long-random-passphrase",
"timestampWindowMs": 30000,
"pendingTimeoutMs": 10000,
"streamWindowBytes": 262144
"streamWindowBytes": 262144,
"sessionIdleTimeoutMs": 90000
}
@@ -12,7 +12,8 @@ public record Config(
String psk,
long timestampWindowMs,
long pendingTimeoutMs,
int streamWindowBytes
int streamWindowBytes,
long sessionIdleTimeoutMs
) {
public static Config load(Path file) throws Exception {
JsonObject json = new JsonObject(Files.readString(file));
@@ -35,6 +36,9 @@ public record Config(
psk,
json.getLong("timestampWindowMs", 30_000L),
json.getLong("pendingTimeoutMs", 10_000L),
window);
window,
// Comfortably above the client's default 20s ping interval;
// 0 disables the watchdog.
json.getLong("sessionIdleTimeoutMs", 90_000L));
}
}
@@ -165,6 +165,9 @@ public final class HubConnection {
return;
}
peerWindow = Math.min(peerWindow, Protocol.MAX_STREAM_WINDOW);
// Heartbeat is optional: accept it only when the client offered it, so
// older clients keep working (they just lose silent-path detection).
boolean heartbeat = (flags & Protocol.FLAG_WORKER_HEARTBEAT) != 0;
// REKEY = Rand || Timestamp(I64 big-endian). Magic is excluded.
byte[] rekey = new byte[randLen + 8];
@@ -179,9 +182,10 @@ public final class HubConnection {
Crypto.decryptCipher(rekey, Crypto.DIR_C2S),
Crypto.encryptCipher(rekey, Crypto.DIR_S2C));
// Echo the accepted flags plus our own receive window.
int accepted = Protocol.FLAG_STREAM_FC | (heartbeat ? Protocol.FLAG_WORKER_HEARTBEAT : 0);
frames.send(new ProtoWriter()
.u8(Protocol.CTL_SESSION_READY)
.varInt(Protocol.FLAG_STREAM_FC)
.varInt(accepted)
.varInt(hub.config.streamWindowBytes())
.toBytes());
@@ -189,16 +193,48 @@ public final class HubConnection {
ControlSession session = new ControlSession(hub, frames, id);
frames.setHandler(session::onFrame);
closeCleanup = session::onClose;
LOG.info("{} control session established", id);
LOG.info("{} control session established (heartbeat {})", id, heartbeat);
} else if (magic == Protocol.MAGIC_WORKER) {
WorkerConn worker = new WorkerConn(hub, frames, id, peerWindow, hub.config.streamWindowBytes());
frames.setHandler(worker::onFrame);
closeCleanup = worker::onClose;
LOG.info("{} worker conn established (peer window {})", id, peerWindow);
LOG.info("{} worker conn established (peer window {}, heartbeat {})", id, peerWindow, heartbeat);
} else {
LOG.warn("{} bad magic {}; closing", id, magic);
frames.close();
return;
}
armIdleWatchdog();
}
/**
* Drop an established redapricot session that has gone silent. Clients ping
* both their control session and every worker conn, so silence means the
* path is dead — without this the hub would keep a zombie control session
* registered and keep routing players into it, and zombie worker conns would
* hold player sockets open forever. Player connections are never subject to
* this; only authenticated sessions are.
*/
private void armIdleWatchdog() {
long idleMs = hub.config.sessionIdleTimeoutMs();
if (idleMs <= 0) return;
long timer = hub.vertx.setPeriodic(idleMs / 2, tid -> {
if (frames.isClosed()) {
hub.vertx.cancelTimer(tid);
return;
}
long silent = System.currentTimeMillis() - frames.lastFrameAt();
if (silent > idleMs) {
LOG.warn("{} session silent for {}ms; closing", id, silent);
hub.vertx.cancelTimer(tid);
frames.close();
}
});
Runnable inner = closeCleanup;
closeCleanup = () -> {
hub.vertx.cancelTimer(timer);
inner.run();
};
}
// ---- player connection ----
@@ -24,6 +24,9 @@ public final class HubServer extends AbstractVerticle {
.setHost(config.host())
.setPort(config.port())
.setTcpNoDelay(true)
// Probe idle sockets so a peer that becomes unreachable is
// eventually detected even when no frames are in flight.
.setTcpKeepAlive(true)
.setReuseAddress(true);
NetServer server = vertx.createNetServer(opts);
@@ -32,10 +32,17 @@ public final class Protocol {
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
public static final int MUX_PING = 0x05; // liveness probe, StreamID 0
public static final int MUX_PONG = 0x06; // liveness reply, echoes the nonce
/** Reserved stream id for connection-scoped mux frames (PING/PONG). Streams start at 1. */
public static final int MUX_CTL_SID = 0;
// 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;
/** Mux-level PING/PONG on worker conns, so a silently dropped path is detected. */
public static final int FLAG_WORKER_HEARTBEAT = 0x02;
// Per-stream flow-control window bounds (bytes).
public static final int DEFAULT_STREAM_WINDOW = 256 * 1024;
@@ -65,6 +65,8 @@ public final class WorkerConn {
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.MUX_PING -> sendPong(r.readI64());
case Protocol.MUX_PONG -> { /* liveness only; arrival is what matters */ }
case Protocol.FRAME_ERROR -> LOG.warn("worker {} error frame", id);
default -> LOG.warn("worker {} unknown mux type {}", id, type);
}
@@ -217,6 +219,11 @@ public final class WorkerConn {
frames.send(new ProtoWriter().u8(Protocol.MUX_WND).varInt(sid).varInt(delta).toBytes());
}
/** Answer the client's liveness probe, echoing its nonce. */
private void sendPong(long nonce) {
frames.send(new ProtoWriter().u8(Protocol.MUX_PONG).varInt(Protocol.MUX_CTL_SID).i64(nonce).toBytes());
}
public void onClose() {
for (StreamState st : streams.values()) st.player.close();
streams.clear();
@@ -23,6 +23,7 @@ public final class EncryptedFrames {
private FrameHandler handler;
private Buffer buf = Buffer.buffer();
private boolean closed = false;
private long lastFrameAt = System.currentTimeMillis();
public EncryptedFrames(NetSocket socket, Cipher in, Cipher out, FrameHandler handler) {
this.socket = socket;
@@ -71,6 +72,7 @@ public final class EncryptedFrames {
byte[] pt = in.update(ct);
if (pt == null) pt = new byte[0];
buf = buf.getBuffer(hdr + payloadLen, buf.length());
lastFrameAt = System.currentTimeMillis();
FrameHandler h = handler;
if (h != null) {
try {
@@ -96,6 +98,9 @@ public final class EncryptedFrames {
public boolean writeQueueFull() { return socket.writeQueueFull(); }
/** Wall-clock millis when the last complete frame was decoded; basis for idle detection. */
public long lastFrameAt() { return lastFrameAt; }
public void close() {
if (closed) return;
closed = true;
@@ -77,7 +77,8 @@ 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, Protocol.DEFAULT_STREAM_WINDOW));
return new Hub(null, new Config("0.0.0.0", 25565, "test-psk", 30_000L, 10_000L,
Protocol.DEFAULT_STREAM_WINDOW, 90_000L));
}
private static ControlSession testSession(Hub hub, String id) {