Files
redapricot/client/resume.go
T
iceBear67 da17140583 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).%
2026-08-15 17:47:39 +08:00

354 lines
12 KiB
Go

package client
import (
"errors"
"log"
"time"
"github.com/iceBear67/redapricot/client/wire"
)
// Stream resumption (PROTOCOL.md §7.5).
//
// A worker conn carries many players but is only the middle leg of each: when
// it dies, both terminal sockets are usually still perfectly healthy. Tearing
// the streams down therefore throws away working connections because a
// replaceable transport failed — one conntrack expiry disconnects everyone on
// that conn.
//
// Instead the stream parks: the destination socket stays open, the hub hangs the
// player socket, and the client reattaches over a fresh conn. Correctness rests
// on retransmission being byte-exact. Bytes handed to a dying socket are lost
// with no notification, and the frame cipher cannot be resynchronized, so each
// side replays from the offset the other reports it accepted.
var (
errResumeUnknown = errors.New("hub does not know 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")
errResumeTooOld = errors.New("hub accepted past what we still hold")
errResumeNoResume = errors.New("hub does not support stream resumption")
)
// resumeGrace is how long a stream parked from a conn may keep trying, clamped
// under what the hub advertised. The client must always give up first: a hub
// that drops the player while we are still reattaching would leave us pumping a
// destination nobody is reading.
func (c *Client) resumeGrace(hubGrace time.Duration) time.Duration {
grace := c.cfg.resumeGrace()
if hubGrace > 0 && hubGrace < grace {
grace = hubGrace
}
return grace
}
// park suspends a stream whose worker conn died instead of destroying it, and
// starts trying to reattach. Reports false when the stream cannot be parked, in
// which case the caller tears it down as before.
//
// A stream already closing (finPending) is not parked: the hub has said the
// player is gone, so there is nothing left to preserve.
func (s *Stream) park(grace time.Duration) bool {
if !s.resumable {
return false
}
s.mu.Lock()
if s.closed || s.finPending {
s.mu.Unlock()
return false
}
already := s.parked
s.parked = true
if s.stats != nil && !already {
s.parkedAt = time.Now()
}
s.mu.Unlock()
if already {
// A reattach was already in flight and had registered this stream on the
// conn that just died — which is how it got here at all. That attempt
// still owns the stream, so reporting failure would have the caller tear
// down a player that is mid-recovery. Fail its wait immediately rather
// than let it sit out the ack timeout: the grace budget is small, and
// spending ten seconds of it waiting on a socket that is already gone is
// the difference between reattaching and dropping the player.
s.deliverResume(resumeResult{err: errResumeConnLost})
return true
}
go s.resumeLoop(grace)
return true
}
// resumeLoop reattaches the stream, retrying until it succeeds or the grace
// period runs out.
//
// Attempts are started right up to the deadline rather than reserving a whole
// dial's worth of budget for the last one. Reserving it would be self-defeating
// — the grace and HandshakeTimeout are the same order of magnitude, so the
// reservation can consume the entire budget and leave no attempt at all — and
// overshooting is safe: an attempt that lands after the hub has dropped the
// player is answered with RST(unknown stream) and tears down cleanly.
func (s *Stream) resumeLoop(grace time.Duration) {
deadline := time.Now().Add(grace)
for {
if s.isClosed() {
return
}
if !time.Now().Before(deadline) {
break
}
err := s.tryResume()
if err == nil {
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).
log.Printf("stream resume abandoned: %v", err)
s.teardown(false)
return
}
select {
case <-s.done:
return
case <-time.After(ResumeRetryDelay):
}
}
log.Printf("stream resume gave up after %s; closing destination", grace)
// No FIN: the only conns we could send it on are the ones that just failed
// us. The hub drops the hanging player when its own grace expires.
s.teardown(false)
}
// tryResume performs one reattach attempt: find a live conn, claim a stream id
// on it, send RESUME, and replay from wherever the hub says it got to.
func (s *Stream) tryResume() error {
wc, sid, err := s.allocateForResume()
if err != nil {
return err
}
s.mu.Lock()
cid := s.cid
accepted := s.acceptedOffset
delivered := s.deliveredOffset
wait := make(chan resumeResult, 1)
s.resumeWait = wait
s.mu.Unlock()
msg := wire.NewWriter().U8(MuxResume).VarInt(sid).Bytes(cid).
I64(accepted).I64(delivered).Out()
if err := wc.fc.WriteFrame(msg); err != nil {
s.abandonAttempt(wc, sid)
return err
}
var res resumeResult
select {
case res = <-wait:
case <-s.done:
// Torn down while waiting. teardown only deregisters the leg the stream
// was bound to, which is not this one, so the claim made above has to be
// withdrawn here or it stays in the new conn's table forever.
s.abandonAttempt(wc, sid)
return errResumeRefused
case <-time.After(ResumeAckTimeout):
s.abandonAttempt(wc, sid)
return errResumeTimeout
}
if res.err != nil {
s.abandonAttempt(wc, sid)
return res.err
}
return s.completeResume(wc, sid, res)
}
// allocateForResume picks a live conn that will honour a reattach.
func (s *Stream) allocateForResume() (*WorkerConn, int, error) {
for attempt := 0; attempt < allocateAttempts; attempt++ {
wc, sid, err := s.client.pool.Allocate()
if err != nil {
return nil, 0, err
}
// Re-checked per conn, not assumed from the dead one: this may be a
// different or restarted hub. Sending RESUME to a hub that does not know
// the frame type would hang the player for the rest of the grace waiting
// for an answer that is never coming.
if !wc.resume {
return nil, 0, errResumeNoResume
}
if wc.registerStream(sid, s) {
return wc, sid, nil
}
}
return nil, 0, errResumeRefused
}
// abandonAttempt withdraws a failed attempt from the conn it was made on, so a
// retry can never leave two RESUMEs outstanding for one stream.
func (s *Stream) abandonAttempt(wc *WorkerConn, sid int) {
wc.removeStream(sid)
s.mu.Lock()
s.resumeWait = nil
s.mu.Unlock()
}
// completeResume rebinds the stream to its new conn and replays what the hub is
// missing, holding sendMu throughout so live traffic cannot overtake the replay.
func (s *Stream) completeResume(wc *WorkerConn, sid int, res resumeResult) error {
s.sendMu.Lock()
// Delivery is a strictly stronger fact than credit — the hub only credits what
// it has delivered — so the reported offset can be adopted wholesale. Doing so
// also repairs the ledger: the grants destroyed by the outage are exactly the
// gap between the two, and without this the retained region would carry that
// dead prefix for the rest of the stream's life.
s.ackedOffset.Store(res.delivered)
s.un.advance(res.delivered)
replay := s.un.from(res.accepted)
if replay == nil {
s.sendMu.Unlock()
s.abandonAttempt(wc, sid)
return errResumeTooOld
}
// Three offsets, three jobs, and conflating any two of them breaks something
// different.
//
// What to replay is measured from what the hub *accepted* — the bytes it
// never received. What the window should be is measured from what it
// *delivered*, because the window is a promise about undelivered bytes.
// It cannot be measured from what it *credited*: credit arrives as deltas,
// and the grants in flight when the connection died are gone for good, so a
// window derived from them would be permanently short — and, when a full
// window was outstanding at the drop, permanently zero. That is a deadlock,
// not a slowdown: no credit can arrive because nothing can be sent.
outstanding := s.un.length()
replayed := s.un.end() - res.accepted
// Publish the new binding before any frame goes out on it, and as one value:
// stream ids restart at 1 per conn, so a half-updated pair would address a
// different player's stream.
s.leg.Store(&leg{wc: wc, sid: sid})
s.mu.Lock()
// Restated, not patched. The window is a delta ledger and the outage tore a
// hole in it; deriving it afresh from the delivered offset closes the hole
// exactly, whatever was lost.
s.sendWnd = wc.sendWndInit - int(outstanding)
if s.sendWnd < 0 {
s.sendWnd = 0
}
// Symmetrically, our own pending credit is discarded rather than flushed:
// the delivered offset we reported already tells the hub everything those
// deltas would have, and sending both would grant the same bytes twice.
// Counting resumes from this baseline.
s.consumed = 0
if len(res.cid) == CIDLen {
s.cid = res.cid // fresh capability, so a CID is never reusable twice
}
s.parked = false
s.resumeWait = nil
owedFin := s.finToHub
s.cond.Broadcast() // release acquireSendWnd and any parked writer
s.mu.Unlock()
for len(replay) > 0 {
n := len(replay)
if n > s.client.chunk {
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
}
replay = replay[n:]
}
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 {
wc.sendFin(sid)
s.teardown(false)
return nil
}
log.Printf("stream conn%d/sid%d resumed (%d bytes replayed, %d outstanding)", wc.id, sid, replayed, outstanding)
return nil
}
// deliverResume hands an answer to a reattach that is waiting for one. Reports
// false when no attempt was in flight, so the caller can treat the frame as it
// would on any live stream.
func (s *Stream) deliverResume(res resumeResult) bool {
s.mu.Lock()
ch := s.resumeWait
s.resumeWait = nil
s.mu.Unlock()
if ch == nil {
return false
}
ch <- res // buffered, and read at most once per attempt
return true
}
// onRst applies an RST, using the reason to tell a stream that is genuinely gone
// from one that a racing reattach has taken over.
func (s *Stream) onRst(reason int) {
err := errResumeRefused
switch reason {
case RstUnknownStream:
err = errResumeUnknown
case RstAlreadyBound:
err = errResumeRaced
}
if s.deliverResume(resumeResult{err: err}) {
return
}
s.teardown(false)
}
// noteFinWhileParked records a FIN the stream owes the hub but cannot send,
// because the only conn it has is the one that just died. Reports false when the
// stream is not parked and the caller should send it normally.
func (s *Stream) noteFinWhileParked() bool {
s.mu.Lock()
defer s.mu.Unlock()
if !s.parked {
return false
}
s.finToHub = true
return true
}