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
+33 -7
View File
@@ -12,7 +12,12 @@ import (
// Protocol constants (mirror of the Java Protocol class; see PROTOCOL.md).
const (
IntentRedapricot = 17
ProtocolVersion = 767 // arbitrary; the hub ignores it
// IntentReserved is the handshake intent reserved for redapricot
// management/status (mirror of Protocol.INTENT_RESERVED). The hub never
// pattern-matches it; it replies with a Minecraft status line and closes,
// so an operator can probe the port without joining the protocol.
IntentReserved = 18
ProtocolVersion = 767 // arbitrary; the hub ignores it
MagicControl = 0x01
MagicWorker = 0x02
@@ -27,6 +32,10 @@ const (
CtlPing = 0x05
CtlPong = 0x06
// RegisterAck status codes (mirror of Protocol.REGISTER_OK/_ERR_PATTERN).
RegisterOk = 0x00
RegisterErrPattern = 0x01 // pattern is not a valid regular expression
MuxSyn = 0x00
MuxData = 0x01
MuxFin = 0x02
@@ -45,8 +54,8 @@ const (
// RST reason codes (optional trailing byte; absence means "unspecified").
// Distinguishing them matters for resume: an unknown stream is terminal,
// while "already bound" means a racing attempt won and this one must leave
// the stream alone rather than tear down a player the hub just rebound.
// while "already bound" means the hub has the stream on another conn — the
// reattach retries until that bind dies and the hub re-parks the stream.
RstUnspecified = 0x00
RstUnknownStream = 0x01
RstAlreadyBound = 0x02
@@ -129,9 +138,15 @@ const (
// a session is declared dead and dropped.
MissedHeartbeats = 3
// DefaultPingIntervalMs is used when a config carries no (or a
// non-positive) interval. LoadConfig applies the same default.
DefaultPingIntervalMs = 20000
// MinPingIntervalMs floors the configured ping interval so the derived
// heartbeat timeout can never be short enough to cause spurious drops.
// Applied in LoadConfig, i.e. to configs that come from disk.
// Applied wherever the interval is read, not just on the file path: a
// hand-built Config (tests) carrying 0 would otherwise panic
// time.NewTicker at the call site.
MinPingIntervalMs = 1000
// DefaultResumeGraceMs is how long a parked stream keeps trying to reattach
@@ -167,9 +182,20 @@ func (c *Config) heartbeatTimeout() time.Duration {
return c.pingInterval() * MissedHeartbeats
}
// pingInterval is the configured heartbeat period.
// pingInterval is the configured heartbeat period, clamped at the single point
// where a duration is derived. LoadConfig also clamps on the file path; this
// covers Configs built directly (tests), where a 0 or negative PingIntervalMs
// would panic time.NewTicker — a panic, not a log line, because the interval
// feeds the heartbeat timeout too.
func (c *Config) pingInterval() time.Duration {
return time.Duration(c.PingIntervalMs) * time.Millisecond
ms := c.PingIntervalMs
if ms <= 0 {
ms = DefaultPingIntervalMs
}
if ms < MinPingIntervalMs {
ms = MinPingIntervalMs
}
return time.Duration(ms) * time.Millisecond
}
// Mapping routes a registered pattern to a real destination.
@@ -291,7 +317,7 @@ func LoadConfig(path string) (*Config, error) {
c.MaxConn = 8
}
if c.PingIntervalMs <= 0 {
c.PingIntervalMs = 20000
c.PingIntervalMs = DefaultPingIntervalMs
}
if c.PingIntervalMs < MinPingIntervalMs {
c.PingIntervalMs = MinPingIntervalMs