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:
+33
-12
@@ -24,7 +24,7 @@ import (
|
||||
|
||||
var (
|
||||
errResumeUnknown = errors.New("hub does not know this stream")
|
||||
errResumeRaced = errors.New("another reattach already bound this stream")
|
||||
errResumeRaced = errors.New("hub has this stream bound to another conn")
|
||||
errResumeRefused = errors.New("hub refused the reattach")
|
||||
errResumeTimeout = errors.New("no RESUME_ACK from the hub")
|
||||
errResumeConnLost = errors.New("the conn carrying the reattach died")
|
||||
@@ -103,11 +103,15 @@ func (s *Stream) resumeLoop(grace time.Duration) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errResumeRaced) {
|
||||
// Another attempt owns the stream now; leaving it alone is the whole
|
||||
// point — tearing down here would kill a player the hub considers live.
|
||||
return
|
||||
}
|
||||
// errResumeRaced falls through to the retry below. The hub has this
|
||||
// stream bound to a conn that is not ours — a half-open conn whose
|
||||
// death the hub has not yet learned, or a bind left behind by a racing
|
||||
// attempt on a now-dead conn. There is no other live attempt: park is
|
||||
// the only resumeLoop starter and it refuses to double-start. Retrying
|
||||
// is safe precisely because nothing else owns the stream — the foreign
|
||||
// bind dies with its conn, the hub re-parks, and a later RESUME lands.
|
||||
// The grace deadline bounds the loop and expiry tears the stream down,
|
||||
// so a hub that never re-parks cannot hang us forever.
|
||||
if errors.Is(err, errResumeUnknown) || errors.Is(err, errResumeTooOld) {
|
||||
// Terminal: the hub has no state for this stream (it restarted, the
|
||||
// grace expired, or a load balancer sent us to a different instance).
|
||||
@@ -255,11 +259,6 @@ func (s *Stream) completeResume(wc *WorkerConn, sid int, res resumeResult) error
|
||||
s.parked = false
|
||||
s.resumeWait = nil
|
||||
owedFin := s.finToHub
|
||||
if s.stats != nil {
|
||||
s.stats.resumes++
|
||||
s.stats.hung += time.Since(s.parkedAt)
|
||||
s.stats.replayBytes += replayed
|
||||
}
|
||||
s.cond.Broadcast() // release acquireSendWnd and any parked writer
|
||||
s.mu.Unlock()
|
||||
|
||||
@@ -269,6 +268,17 @@ func (s *Stream) completeResume(wc *WorkerConn, sid int, res resumeResult) error
|
||||
n = s.client.chunk
|
||||
}
|
||||
if err := wc.sendData(sid, replay[:n]); err != nil {
|
||||
// The conn died mid-replay. The stream is still resumable, but not
|
||||
// from this leg — put it back in the parked state before returning
|
||||
// so the conn's teardown takes park()'s already-branch instead of
|
||||
// starting a second resumeLoop. The loop we came from keeps
|
||||
// retrying with the fresh CID, which the hub re-parked alongside
|
||||
// the stream when this conn died. Without this re-park the flag
|
||||
// cleared above would let two loops race one stream, and a racing
|
||||
// RST would then strand it with neither loop alive.
|
||||
s.mu.Lock()
|
||||
s.parked = true
|
||||
s.mu.Unlock()
|
||||
s.sendMu.Unlock()
|
||||
return err
|
||||
}
|
||||
@@ -276,6 +286,17 @@ func (s *Stream) completeResume(wc *WorkerConn, sid int, res resumeResult) error
|
||||
}
|
||||
s.sendMu.Unlock()
|
||||
|
||||
// Counted only once the replay actually landed: a failed reattach above
|
||||
// returns before this, so a conn dying mid-replay does not inflate the
|
||||
// resume count with an attempt that never completed.
|
||||
if s.stats != nil {
|
||||
s.mu.Lock()
|
||||
s.stats.resumes++
|
||||
s.stats.hung += time.Since(s.parkedAt)
|
||||
s.stats.replayBytes += replayed
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// A destination that closed while we were parked owed the hub a FIN that had
|
||||
// nowhere to go at the time.
|
||||
if owedFin {
|
||||
@@ -283,7 +304,7 @@ func (s *Stream) completeResume(wc *WorkerConn, sid int, res resumeResult) error
|
||||
s.teardown(false)
|
||||
return nil
|
||||
}
|
||||
log.Printf("stream %d resumed (%d bytes replayed, %d outstanding)", sid, replayed, outstanding)
|
||||
log.Printf("stream conn%d/sid%d resumed (%d bytes replayed, %d outstanding)", wc.id, sid, replayed, outstanding)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user