fix: harden resume/shutdown paths, tighten Intent-18 and PSK handshake handling
Client (Go) — resume correctness - C1: completeResume now re-parks the stream when replay fails mid-conn-loss. parked was cleared before the replay loop, so the dying conn's teardown would start a second resumeLoop and the two loops could strand the stream with neither alive. Resume stats are counted only after the replay lands. - C2: RST(ALREADY_BOUND) is retryable instead of terminating the loop. With C1 fixed there is never a genuine second attempt, so "already bound" means the hub still holds the stream on a half-open conn; the retry waits out that bind (bounded by the grace deadline, teardown on expiry) instead of returning and leaving the destination socket hung forever. Client (Go) — shutdown semantics - C3: Close() sets a closing flag and cancels an internal context; dialSession takes a ctx (DialContext + AfterFunc so shutdown aborts in-flight handshakes); the worker pool refuses new conns after closeAll (Allocate, background growth, cond waiters); serveControl's reconnect loop is gated by closing so Close works even when the caller's Start context is not cancelled; conn-loss teardown closes streams outright during shutdown instead of parking them for a reattach that will never come. Client (Go) — hygiene - C4: pingInterval() clamps at the single point a duration is derived, so a hand-built Config with PingIntervalMs <= 0 can no longer panic time.NewTicker (added DefaultPingIntervalMs). - E6: shaperStall is sampled right after shaper.Acquire, before the socket write, so a hub that is not reading is no longer charged to the bandwidth cap in the stats. - E7: stream log lines now carry conn%d/sid%d (leg.String()), making streams traceable across reattaches. - P5: mirror constants IntentReserved/RegisterOk/RegisterErrPattern added; RegisterAck dispatch logs rejection reasons via the named codes. Hub (Java) + PROTOCOL.md - P3: Intent 18 replies with a Minecraft status-response packet ([Len: VarInt][0x00][JSON: String]) and closes (socket.end, so the write always lands) instead of closing silently; documented in PROTOCOL.md §2. - P4: PSK address check is strict equality with the lowercase hex address; an uppercase/case-folded variant is now rejected per PROTOCOL.md §2. - P7: PROTOCOL.md §5 SessionReady row lists its real fields (Flags/RecvWindow/ResumeGraceMs) instead of "(none)". Verified: go vet, go test -race ./client/..., gradle test, full e2e suite (twice), resume e2e 3x, plus live probes of the hub with the real client codec (Intent-18 status reply, strict-lowercase PSK acceptance/rejection).%
This commit is contained in:
@@ -103,17 +103,45 @@ public final class HubConnection {
|
||||
if (intent == Protocol.INTENT_REDAPRICOT) {
|
||||
beginRedapricot(address, afterHandshake);
|
||||
} else if (intent == Protocol.INTENT_RESERVED) {
|
||||
LOG.info("{} reserved intent 18; closing", id);
|
||||
socket.close();
|
||||
LOG.info("{} reserved intent 18; replying with a status line and closing", id);
|
||||
sendStatusLine();
|
||||
} else {
|
||||
handlePlayer(address);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Intent 18 (reserved: management/status) ----
|
||||
|
||||
/**
|
||||
* Reply to an Intent-18 probe with a Minecraft status-response packet
|
||||
* ({@code [Len: VarInt][0x00][JSON: String]}), the same shape a player gets
|
||||
* for a status query (Intent 1), then close. This lets an operator probe
|
||||
* the public port with ordinary tooling without joining the protocol, and
|
||||
* it is the one reply the hub sends in plaintext — Intent 18 never
|
||||
* negotiates encryption.
|
||||
*/
|
||||
private void sendStatusLine() {
|
||||
String json = "{\"description\":{\"text\":\"redapricot hub\"},"
|
||||
+ "\"version\":{\"name\":\"redapricot\",\"protocol\":767},"
|
||||
+ "\"players\":{\"max\":0,\"online\":0}}";
|
||||
ProtoWriter body = new ProtoWriter().u8(0x00).string(json);
|
||||
byte[] bodyBytes = body.toBytes();
|
||||
ProtoWriter pkt = new ProtoWriter().varInt(bodyBytes.length).bytes(bodyBytes);
|
||||
// end() writes the reply and closes after it lands, so the packet is
|
||||
// never cut short by the close racing the flush.
|
||||
socket.end(Buffer.buffer(pkt.toBytes()));
|
||||
}
|
||||
|
||||
// ---- redapricot session (Intent 17) ----
|
||||
|
||||
private void beginRedapricot(String address, Buffer afterHandshake) {
|
||||
if (!address.equalsIgnoreCase(hub.pskAddress)) {
|
||||
// Strict equality, per PROTOCOL.md §2: the contract is exactly
|
||||
// lowercase_hex(SHA3-224(PSK)), and the client always sends that. An
|
||||
// uppercase or otherwise case-folded variant is not a valid session —
|
||||
// accepting it would widen the acceptance surface beyond what the spec
|
||||
// promises (and what the client ever produces), which is exactly the
|
||||
// kind of "close enough" check that hides a wrong-PSK probe.
|
||||
if (!address.equals(hub.pskAddress)) {
|
||||
LOG.warn("{} bad PSK address; closing", id);
|
||||
socket.close();
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user