333 lines
11 KiB
Go
333 lines
11 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("another reattach already bound this stream")
|
|
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
|
|
}
|
|
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
|
|
}
|
|
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
|
|
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()
|
|
|
|
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 {
|
|
s.sendMu.Unlock()
|
|
return err
|
|
}
|
|
replay = replay[n:]
|
|
}
|
|
s.sendMu.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 %d resumed (%d bytes replayed, %d outstanding)", 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
|
|
}
|