impl connection recovery

This commit is contained in:
iceBear67
2026-08-15 17:31:35 +08:00
parent e63a34d53a
commit 7bd84af48d
33 changed files with 3858 additions and 200 deletions
+107 -18
View File
@@ -25,7 +25,9 @@ type Client struct {
mappings map[string]Mapping // normalized pattern -> mapping
pool *WorkerPool
streamWnd int // our advertised per-stream receive window (bytes)
streamWnd int // our advertised per-stream receive window (bytes)
shaper *Shaper // caps aggregate egress to the hub; nil when unlimited
chunk int // DATA payload cap; shrinks below DataChunkSize at low rates
mu sync.Mutex
ctrl *wire.FramedConn
@@ -48,6 +50,19 @@ func New(cfg *Config) *Client {
c.mappings[NormalizeAddress(m.Pattern)] = m
}
c.streamWnd = clampWindow(cfg.StreamWindowBytes)
// Parsed here rather than in LoadConfig because a Config may also be built
// directly (tests). LoadConfig has already rejected a malformed value on the
// file path, so a failure here can only come from a hand-built Config.
bps, err := parseBandwidth(cfg.MaxBandwidth)
if err != nil {
log.Printf("client: %v; continuing without a bandwidth limit", err)
}
c.shaper = NewShaper(bps)
c.chunk = c.shaper.chunkSize()
if c.shaper != nil {
log.Printf("egress shaped to %d B/s (burst %d B, chunk %d B)",
bps, int64(c.shaper.burst), c.shaper.chunk)
}
c.pool = newWorkerPool(c, cfg.MaxConn)
return c
}
@@ -71,6 +86,10 @@ type session struct {
fc *wire.FramedConn
peerWnd int // hub's advertised per-stream receive window
heartbeat bool // hub accepted mux-level PING/PONG on worker conns
resume bool // hub accepted stream resumption (§7.5)
// hubGrace is how long the hub will hang a parked player, as advertised in
// SessionReady. Zero when resumption was not negotiated.
hubGrace time.Duration
}
// dialSession opens a TCP connection, performs the Intent-17 handshake, the
@@ -120,6 +139,9 @@ func (c *Client) dialSession(magic byte) (sess *session, err error) {
}
ts := time.Now().UnixMilli()
offered := FlagStreamFC | FlagWorkerHeartbeat
if c.cfg.resumeEnabled() {
offered |= FlagStreamResume
}
rekeyMsg := wire.NewWriter().U8(magic).VarInt(len(rnd)).Bytes(rnd).I64(ts).
VarInt(offered).VarInt(c.streamWnd).Out()
if err := fc.WriteFrame(rekeyMsg); err != nil {
@@ -155,6 +177,22 @@ func (c *Client) dialSession(magic byte) (sess *session, err error) {
if hubWnd > MaxStreamWindow {
hubWnd = MaxStreamWindow
}
// Resumption is negotiated per connection, and the hub's grace period rides
// along when it accepts. Our own grace is clamped strictly under the hub's:
// the client must always give up first, or the hub drops a hanging player
// while we are still mid-reattach. A hub that accepts the flag but omits the
// grace is treated as not supporting it at all rather than guessed at.
resume := flags&FlagStreamResume != 0
var hubGrace time.Duration
if resume {
graceMs, gerr := r.VarInt()
if gerr != nil || graceMs <= 0 {
log.Printf("hub accepted stream resume without advertising a grace period; disabling resume")
resume = false
} else {
hubGrace = time.Duration(graceMs) * time.Millisecond
}
}
// The session is live: drop the establishment deadline. From here on
// liveness is the heartbeat's job (and WriteFrame bounds each write).
@@ -162,14 +200,30 @@ func (c *Client) dialSession(magic byte) (sess *session, err error) {
return nil, err
}
ok = true
return &session{fc: fc, peerWnd: hubWnd, heartbeat: flags&FlagWorkerHeartbeat != 0}, nil
return &session{
fc: fc,
peerWnd: hubWnd,
heartbeat: flags&FlagWorkerHeartbeat != 0,
resume: resume,
hubGrace: hubGrace,
}, nil
}
// statsOn reports whether performance diagnostics are enabled. When off, no
// counter struct is ever allocated and the instrumentation is a single branch.
func (c *Client) statsOn() bool { return c.cfg.StatsIntervalMs > 0 }
// Start establishes the control session and registers all patterns. It returns
// once the initial connection succeeds; subsequent drops are handled in the
// background with reconnect.
func (c *Client) Start(ctx context.Context) error {
return c.connectControl(ctx)
if err := c.connectControl(ctx); err != nil {
return err
}
if c.statsOn() {
go c.statsLoop(ctx.Done())
}
return nil
}
func (c *Client) connectControl(ctx context.Context) error {
@@ -220,17 +274,33 @@ func (c *Client) serveControl(ctx context.Context, ctrl *ctrlSession) {
if ctx.Err() != nil {
return
}
// Reconnect with backoff.
for backoff := 500 * time.Millisecond; ctx.Err() == nil; backoff *= 2 {
if backoff > 10*time.Second {
backoff = 10 * time.Second
// Reconnect with backoff, but try immediately first. While the control
// session is down the hub has no live route for this client, so every
// millisecond of delay is a player arriving to be told there is no such
// server — and a session usually dies to a transient blip that the very next
// dial would have survived. Sleeping first spent that window unconditionally.
//
// The wait is on ctx rather than time.Sleep so shutdown is not held up by a
// backoff that has grown to the cap.
for backoff := time.Duration(0); ctx.Err() == nil; {
if backoff > 0 {
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
}
time.Sleep(backoff)
if err := c.connectControl(ctx); err == nil {
return
} else {
log.Printf("control reconnect failed: %v", err)
}
switch {
case backoff == 0:
backoff = 500 * time.Millisecond
case backoff < maxControlBackoff:
backoff = min(backoff*2, maxControlBackoff)
}
}
}
@@ -299,20 +369,38 @@ func (c *Client) handleControlRequest(cid []byte, pattern, ip string, port int)
return
}
log.Printf("player %s:%d joined via pattern %q -> %s", ip, port, pattern, mapping.Destination)
wc, sid, err := c.pool.Allocate()
if err != nil {
log.Printf("worker allocate failed: %v", err)
// Allocate and publish must agree on a live conn: Allocate hands out a
// (conn, sid) pair that can die before we register on it, which would strand
// the stream in a map nothing iterates. registerStream reports that, and we
// simply pick another conn.
var st *Stream
var lg *leg
for attempt := 0; attempt < allocateAttempts; attempt++ {
wc, sid, err := c.pool.Allocate()
if err != nil {
log.Printf("worker allocate failed: %v", err)
return
}
st = newStream(c, wc, sid, cid, mapping, ip, port)
// Register before SYN so inbound DATA can never race ahead of the table,
// and start the pumps before the (bounded) SYN write so a failed or slow
// SYN cannot strand a stream that nothing would ever tear down.
if wc.registerStream(sid, st) {
lg = st.conn()
break
}
st = nil
}
if st == nil {
log.Printf("worker allocate failed: no live conn after %d attempts", allocateAttempts)
return
}
st := newStream(wc, sid, cid, mapping, ip, port)
// Register before SYN so inbound DATA can never race ahead of the table,
// and start the pumps before the (bounded) SYN write so a failed or slow
// SYN cannot strand a stream that nothing would ever tear down.
wc.registerStream(sid, st)
go st.writeLoop()
go st.run()
if err := wc.sendSyn(sid, cid); err != nil {
log.Printf("stream %d: SYN failed: %v", sid, err)
if err := lg.wc.sendSyn(lg.sid, cid); err != nil {
log.Printf("stream %d: SYN failed: %v", lg.sid, err)
st.teardown(false)
}
}
@@ -330,4 +418,5 @@ func (c *Client) Close() {
_ = fc.Close()
}
c.pool.closeAll()
c.shaper.Stop()
}