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:
iceBear67
2026-08-15 17:47:39 +08:00
parent 7bd84af48d
commit da17140583
7 changed files with 504 additions and 37 deletions
@@ -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;