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()
}
+4
View File
@@ -4,6 +4,10 @@
"maxConn": 4,
"pingIntervalMs": 20000,
"streamWindowBytes": 262144,
"maxBandwidth": "",
"streamResume": true,
"resumeGraceMs": 15000,
"statsIntervalMs": 0,
"mappings": [
{
"pattern": "mc\\.example\\.com",
+163 -6
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"time"
)
@@ -33,11 +34,26 @@ const (
MuxWnd = 0x04
MuxPing = 0x05
MuxPong = 0x06
// MuxResume reattaches a parked stream to this conn (CID + our accepted
// offset); MuxResumeAck carries the hub's accepted offset and a fresh CID.
MuxResume = 0x07
MuxResumeAck = 0x08
// MuxCtlSid is the reserved stream id carrying connection-scoped mux frames
// (PING/PONG). Real streams are numbered from 1.
MuxCtlSid = 0
// 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.
RstUnspecified = 0x00
RstUnknownStream = 0x01
RstAlreadyBound = 0x02
RstResumeAbandoned = 0x03
RstFlowControl = 0x04
RstDialFailed = 0x05
FrameError = 0x7F
// SaturationThreshold caps how many streams share one worker conn once the
@@ -52,6 +68,12 @@ const (
// firewall) is never detected: the read loop parks forever, the dead conn
// stays in the pool, and no player can be served until the client restarts.
FlagWorkerHeartbeat = 0x02
// FlagStreamResume enables stream resumption (PROTOCOL.md §7.5): a worker
// conn drop parks its streams instead of killing them, the hub hangs the
// player sockets, and the client reattaches each stream byte-exactly over a
// fresh conn. Negotiated, so either side may decline and get today's
// behaviour (immediate teardown) unchanged.
FlagStreamResume = 0x04
// Per-stream flow-control window bounds (bytes). The advertised window is the
// receiver's promise of how much un-credited DATA it will buffer per stream.
@@ -64,6 +86,32 @@ const (
DataChunkSize = 32 * 1024
)
// Egress bandwidth shaping (see shaper.go and docs/architecture.md §6). These
// are entirely client-local: nothing here appears on the wire.
const (
// MinBandwidth floors a configured cap. Below this the tunnel cannot carry a
// Minecraft session at all, so such a value is a unit typo ("20bps" for
// "20mbps") and is rejected rather than silently clamped.
MinBandwidth = 8 * 1024
// ShaperBurstSeconds is how much transmission time the token bucket banks
// while idle. Big enough to absorb a chunk-load spike; small enough that
// releasing it cannot overrun the physical uplink and rebuild the standing
// queue the cap exists to prevent.
ShaperBurstSeconds = 0.2
// MinShaperBurst must exceed DataChunkSize: a request larger than the bucket
// could never be afforded and would park forever.
MinShaperBurst = 64 * 1024
MaxShaperBurst = 4 << 20
// ShaperSliceSeconds bounds how long one stream holds the link before the
// scheduler can switch, by sizing the send chunk to that much transmission
// time. Above ~13 Mbps this yields DataChunkSize and nothing changes.
ShaperSliceSeconds = 0.02
MinShaperChunk = 4 * 1024
)
// Timeouts. Every tunnel socket is covered by one of these: without them a
// silently dropped path (no FIN/RST) leaves the client parked forever.
const (
@@ -85,6 +133,32 @@ const (
// heartbeat timeout can never be short enough to cause spurious drops.
// Applied in LoadConfig, i.e. to configs that come from disk.
MinPingIntervalMs = 1000
// DefaultResumeGraceMs is how long a parked stream keeps trying to reattach
// before giving up and closing the destination.
//
// Chosen against the backend, not the tunnel: a hung player stops answering
// the game server's KeepAlive, and vanilla disconnects a silent client at
// 30s. A longer grace would resume sessions the backend then kicks anyway.
DefaultResumeGraceMs = 15000
// MinResumeGraceMs floors the grace so it can always fit at least one dial;
// a grace shorter than HandshakeTimeout could never complete an attempt.
MinResumeGraceMs = 2000
// ResumeRetryDelay paces reattach attempts after a failure. Short, because
// the player is hanging for the whole grace period.
ResumeRetryDelay = 500 * time.Millisecond
// maxControlBackoff caps the control-session reconnect delay. The hub holds
// this client's routes only for its own registration grace, so a backoff that
// grew past that would strand players it is hanging on our behalf.
maxControlBackoff = 10 * time.Second
// ResumeAckTimeout bounds the wait for RESUME_ACK on a conn that completed
// its handshake but then went quiet, so a wedged hub does not consume the
// entire grace budget in one attempt.
ResumeAckTimeout = 10 * time.Second
)
// heartbeatTimeout is how long a session may go without a reply before it is
@@ -111,12 +185,87 @@ type Mapping struct {
// Config is the client configuration (PROTOCOL.md §9.2).
type Config struct {
Server string `json:"server"`
PSK string `json:"psk"`
MaxConn int `json:"maxConn"`
PingIntervalMs int `json:"pingIntervalMs"`
StreamWindowBytes int `json:"streamWindowBytes"` // per-stream receive window; 0 = default
Mappings []Mapping `json:"mappings"`
Server string `json:"server"`
PSK string `json:"psk"`
MaxConn int `json:"maxConn"`
PingIntervalMs int `json:"pingIntervalMs"`
StreamWindowBytes int `json:"streamWindowBytes"` // per-stream receive window; 0 = default
// MaxBandwidth caps what the client sends to the hub, aggregated over every
// stream on every worker conn — the direction that carries the game server's
// output to the players, and the one a residential uplink runs out of first.
// Empty means no limit. See parseBandwidth for the accepted syntax.
MaxBandwidth string `json:"maxBandwidth"`
// StreamResume enables stream resumption (PROTOCOL.md §7.5). A pointer so an
// absent key means "on" while an explicit false disables it: with it off the
// client never offers the flag, allocates no retransmit buffers, and behaves
// exactly as a pre-resume client.
StreamResume *bool `json:"streamResume"`
// ResumeGraceMs bounds how long a parked stream keeps trying to reattach.
// Clamped below the hub's advertised grace so the client always gives up
// first and the hub is never left holding a player nobody will claim.
ResumeGraceMs int `json:"resumeGraceMs"`
// StatsIntervalMs enables the periodic performance summary; 0 (the default)
// disables it and costs nothing.
StatsIntervalMs int `json:"statsIntervalMs"`
Mappings []Mapping `json:"mappings"`
}
// resumeEnabled reports whether stream resumption is configured on.
func (c *Config) resumeEnabled() bool { return c.StreamResume == nil || *c.StreamResume }
// resumeGrace is how long a parked stream may keep trying to reattach.
func (c *Config) resumeGrace() time.Duration {
ms := c.ResumeGraceMs
if ms <= 0 {
ms = DefaultResumeGraceMs
}
if ms < MinResumeGraceMs {
ms = MinResumeGraceMs
}
return time.Duration(ms) * time.Millisecond
}
// bandwidthUnits maps a rate suffix to its value in bytes per second. Bit units
// are decimal because that is what ISPs quote; byte units are binary to match
// streamWindowBytes. Ordered longest-suffix-first so "kbps" is not read as
// "bps", nor "gb/s" as "b/s".
var bandwidthUnits = []struct {
suffix string
mul float64
}{
{"gbps", 1e9 / 8}, {"gbit", 1e9 / 8},
{"mbps", 1e6 / 8}, {"mbit", 1e6 / 8},
{"kbps", 1e3 / 8}, {"kbit", 1e3 / 8},
{"gb/s", 1 << 30}, {"mb/s", 1 << 20}, {"kb/s", 1 << 10},
{"bps", 1.0 / 8},
{"b/s", 1},
}
// parseBandwidth converts a human-readable rate to bytes per second. The empty
// string means "no limit" and yields 0.
//
// "20mbps" 20 megabits/s = 2500000 B/s
// "512kbps" 512 kilobits/s = 64000 B/s
// "2MB/s" 2 mebibytes/s = 2097152 B/s
// "1500000" a bare number is already bytes per second
func parseBandwidth(s string) (int64, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, nil
}
lower := strings.ToLower(s)
num, mul := lower, 1.0
for _, u := range bandwidthUnits {
if strings.HasSuffix(lower, u.suffix) {
num, mul = strings.TrimSpace(lower[:len(lower)-len(u.suffix)]), u.mul
break
}
}
v, err := strconv.ParseFloat(num, 64)
if err != nil || v <= 0 {
return 0, fmt.Errorf(`maxBandwidth: cannot read %q as a rate (try "20mbps", "2MB/s", or bytes per second)`, s)
}
return int64(v * mul), nil
}
// LoadConfig reads and validates a JSON config file.
@@ -147,6 +296,14 @@ func LoadConfig(path string) (*Config, error) {
if c.PingIntervalMs < MinPingIntervalMs {
c.PingIntervalMs = MinPingIntervalMs
}
// Parsed here only to fail fast on a bad value; New does the real conversion.
bps, err := parseBandwidth(c.MaxBandwidth)
if err != nil {
return nil, err
}
if bps > 0 && bps < MinBandwidth {
return nil, fmt.Errorf("maxBandwidth %q is only %d B/s; the minimum is %d B/s", c.MaxBandwidth, bps, MinBandwidth)
}
if len(c.Mappings) == 0 {
return nil, fmt.Errorf("at least one mapping is required")
}
+3 -1
View File
@@ -85,7 +85,9 @@ func TestAllocateSpreadsAcrossConns(t *testing.T) {
return &WorkerConn{pool: p, streams: make(map[int]*Stream), nextSid: 1, done: make(chan struct{})}
}
p.conns = []*WorkerConn{newConn()}
p.conns[0].registerStream(1, &Stream{sid: 1})
if !p.conns[0].registerStream(1, &Stream{}) {
t.Fatal("registerStream refused on a live conn")
}
// One conn holding a stream, pool below maxConn: growth is warranted.
_, bestCount := p.leastLoadedLocked()
+332
View File
@@ -0,0 +1,332 @@
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
}
+104
View File
@@ -0,0 +1,104 @@
package client
import (
"testing"
)
// The retained region is the one real cost stream resumption adds to the send
// path: a chunk has to survive past the frame write, so it is copied. These
// pin both halves of that claim — that the copy is the only cost, and that
// disabling the feature removes it entirely rather than merely shrinking it.
func benchStream(resumable bool) *Stream {
s := &Stream{resumable: resumable}
return s
}
// BenchmarkRetainChunk measures what emit adds over a bare frame write: the
// trim-and-append into the retained region. Compare the two variants; the delta
// is the per-byte copy the feature costs.
func BenchmarkRetainChunk(b *testing.B) {
chunk := make([]byte, DataChunkSize)
window := int64(DefaultStreamWindow)
b.Run("resume-on", func(b *testing.B) {
s := benchStream(true)
b.SetBytes(int64(len(chunk)))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
// Model the steady state: credit trails one window behind, so the
// buffer trims about as fast as it grows and stays bounded.
acked := s.un.end() - window
if acked < 0 {
acked = 0
}
s.un.advance(acked)
s.un.append(chunk)
}
if got := int64(s.un.length()); got > window+int64(len(chunk)) {
b.Fatalf("retained region grew past one window: %d", got)
}
})
b.Run("resume-off", func(b *testing.B) {
s := benchStream(false)
b.SetBytes(int64(len(chunk)))
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if s.resumable {
s.un.advance(0)
s.un.append(chunk)
}
}
})
}
// TestResumeDisabledAllocatesNothing pins the off switch at the level that
// matters. It is easy for a feature flag to stop the wire behaviour while
// leaving the bookkeeping running, which would keep the memory cost and the
// per-byte copy for a user who explicitly turned it off — a partial revert that
// nobody would notice.
func TestResumeDisabledAllocatesNothing(t *testing.T) {
chunk := make([]byte, DataChunkSize)
s := benchStream(false)
allocs := testing.AllocsPerRun(1000, func() {
if s.resumable {
s.un.advance(0)
s.un.append(chunk)
}
})
if allocs != 0 {
t.Fatalf("resume disabled still allocated %.1f times per send", allocs)
}
if s.un.buf != nil {
t.Fatalf("resume disabled still allocated a retained region of %d bytes", cap(s.un.buf))
}
}
// TestRetainedRegionStaysWithinWindow is the memory bound the design rests on:
// flow control already caps outstanding bytes at one window, so the retained
// region needs no cap of its own. If that ever stopped holding, a busy stream
// would grow without limit and the hub would be the first to notice.
func TestRetainedRegionStaysWithinWindow(t *testing.T) {
const window = DefaultStreamWindow
chunk := make([]byte, DataChunkSize)
var u unackedBuf
for i := 0; i < 5000; i++ {
// A sender may never have more than one window outstanding, which is
// exactly what acquireSendWnd enforces before emit is ever reached.
if u.length()+len(chunk) > window {
u.advance(u.base() + int64(len(chunk)))
}
u.append(chunk)
if u.length() > window {
t.Fatalf("round %d: retained %d bytes for a %d-byte window", i, u.length(), window)
}
}
if cap(u.buf) > 4*window {
t.Fatalf("backing array grew to %d for a %d-byte window", cap(u.buf), window)
}
}
+268
View File
@@ -0,0 +1,268 @@
package client
import (
"sync"
"time"
)
// minShaperWait floors the dispatcher's sleep so floating-point dust in the
// token arithmetic cannot spin it.
const minShaperWait = time.Millisecond
// Shaper caps the aggregate rate at which the client writes DATA to the hub and
// divides that budget across streams.
//
// The credit windows of PROTOCOL.md §7.3 bound how many bytes may be *in flight*
// per stream; they say nothing about bytes per *second*. That is the gap this
// fills. On a residential uplink one player loading chunks will otherwise
// saturate the line and push every other player's keepalive past its timeout.
//
// Two mechanisms are layered:
//
// - A token bucket sets the long-run rate and the size of the burst that may
// be spent after an idle period.
// - Start-time fair queueing decides who spends those tokens. A global virtual
// clock advances with each grant; every stream remembers the virtual time at
// which its last request finished. A request is stamped
// max(share.vfinish, vclock) and the lowest stamp is served first, so a
// stream that keeps sending pushes its own stamp further out and yields to
// quieter streams. The clamp to vclock is what keeps bursts cheap: a stream
// returning from idle is pulled back to the head of the clock, so it cannot
// hoard credit while it was idle, but it is not punished for the idleness
// either. One stream alone gets the whole rate.
//
// A nil *Shaper means "no limit"; every method short-circuits, so call sites do
// not branch.
type Shaper struct {
rate float64 // bytes per second
burst float64 // token bucket capacity, bytes
chunk int // how much a caller should request at a time
mu sync.Mutex
tokens float64
last time.Time
vclock float64 // virtual time, in bytes of service granted
waiting []*shaperReq // unordered; the dispatcher scans for the lowest vstart
wake chan struct{} // cap 1, non-blocking: nudges the dispatcher
done chan struct{}
once sync.Once
}
// shaperShare is one stream's position in the fair queue. It lives on the
// Stream and dies with it; a fresh share starts at zero and is clamped up to
// the current virtual clock on its first request.
type shaperShare struct{ vfinish float64 }
// shaperReq is one pending Acquire. granted and membership in Shaper.waiting
// are both guarded by Shaper.mu.
type shaperReq struct {
n int
vstart float64
grant chan struct{}
granted bool
}
// NewShaper builds a shaper for the given rate. A non-positive rate returns nil,
// which every method treats as "unlimited".
func NewShaper(bytesPerSec int64) *Shaper {
if bytesPerSec <= 0 {
return nil
}
rate := float64(bytesPerSec)
burst := rate * ShaperBurstSeconds
// The floor is a correctness constraint, not a preference: a request larger
// than the bucket could never be afforded and would park forever.
if burst < MinShaperBurst {
burst = MinShaperBurst
}
if burst > MaxShaperBurst {
burst = MaxShaperBurst
}
chunk := int(rate * ShaperSliceSeconds)
if chunk < MinShaperChunk {
chunk = MinShaperChunk
}
if chunk > DataChunkSize {
chunk = DataChunkSize
}
sh := &Shaper{
rate: rate,
burst: burst,
chunk: chunk,
tokens: burst,
last: time.Now(),
wake: make(chan struct{}, 1),
done: make(chan struct{}),
}
go sh.dispatch()
return sh
}
// chunkSize is how many bytes a sender should offer per request. It is sized to
// ShaperSliceSeconds of transmission so no stream holds the link for long before
// the scheduler can switch: at 1 Mbps a full 32 KiB chunk takes ~256 ms, which is
// enough dead air to drag other players towards a keepalive timeout.
func (sh *Shaper) chunkSize() int {
if sh == nil {
return DataChunkSize
}
return sh.chunk
}
// Acquire blocks until n bytes of bandwidth budget are available for the stream
// owning share. It returns false only when cancel fires first, in which case
// nothing was charged.
//
// cancel is the stream's done channel: a stream torn down while parked here must
// not keep a goroutine (and its Stream) alive waiting for tokens it will never
// use.
func (sh *Shaper) Acquire(share *shaperShare, n int, cancel <-chan struct{}) bool {
if sh == nil || n <= 0 {
return true
}
req := &shaperReq{n: n, grant: make(chan struct{})}
sh.mu.Lock()
// Stamp the request and reserve this stream's slot in virtual time up front,
// so a stream cannot queue many requests at the same cheap stamp.
req.vstart = share.vfinish
if req.vstart < sh.vclock {
req.vstart = sh.vclock
}
share.vfinish = req.vstart + float64(n)
sh.waiting = append(sh.waiting, req)
sh.mu.Unlock()
sh.nudge()
select {
case <-req.grant:
return true
case <-sh.done:
// Shaping stopped: let live traffic through rather than stalling it.
sh.mu.Lock()
sh.removeLocked(req)
sh.mu.Unlock()
return true
case <-cancel:
sh.mu.Lock()
granted := req.granted
if !granted {
sh.removeLocked(req)
}
sh.mu.Unlock()
return granted
}
}
// Stop shuts the dispatcher down and releases everyone parked in Acquire.
func (sh *Shaper) Stop() {
if sh == nil {
return
}
sh.once.Do(func() { close(sh.done) })
}
// dispatch is the single goroutine that hands out tokens. It sleeps exactly as
// long as the next waiter needs rather than polling on a fixed tick, so an idle
// shaper costs nothing.
func (sh *Shaper) dispatch() {
for {
wait := sh.grantReady()
var tick <-chan time.Time
var timer *time.Timer
if wait > 0 {
timer = time.NewTimer(wait)
tick = timer.C
}
select {
case <-tick:
case <-sh.wake:
case <-sh.done:
if timer != nil {
timer.Stop()
}
return
}
if timer != nil {
timer.Stop()
}
}
}
// grantReady refills the bucket and grants every waiter it can afford, lowest
// virtual start time first. It returns how long until the next waiter becomes
// affordable, or 0 when nothing is pending.
func (sh *Shaper) grantReady() time.Duration {
sh.mu.Lock()
defer sh.mu.Unlock()
now := time.Now()
if elapsed := now.Sub(sh.last); elapsed > 0 {
sh.tokens += sh.rate * elapsed.Seconds()
if sh.tokens > sh.burst {
sh.tokens = sh.burst
}
sh.last = now
}
for {
req := sh.headLocked()
if req == nil {
return 0
}
// Callers stay under chunkSize, which NewShaper keeps below the bucket.
// Should a future caller not, wait for a full bucket rather than for a
// token count that can never be reached, and let the balance go negative:
// the debt is repaid by the next refill, so the long-run rate still holds.
need := min(float64(req.n), sh.burst)
if need > sh.tokens {
wait := time.Duration((need - sh.tokens) / sh.rate * float64(time.Second))
if wait < minShaperWait {
wait = minShaperWait
}
return wait
}
sh.tokens -= float64(req.n)
// The clock follows the request being served, never runs ahead of it.
if req.vstart > sh.vclock {
sh.vclock = req.vstart
}
req.granted = true
sh.removeLocked(req)
close(req.grant)
}
}
// headLocked returns the pending request with the lowest virtual start time.
// A linear scan is deliberate: the queue holds at most one entry per live
// stream (tens, not thousands), so a heap would cost more in complexity than it
// saves in comparisons.
func (sh *Shaper) headLocked() *shaperReq {
var best *shaperReq
for _, w := range sh.waiting {
if best == nil || w.vstart < best.vstart {
best = w
}
}
return best
}
func (sh *Shaper) removeLocked(req *shaperReq) {
for i, w := range sh.waiting {
if w == req {
sh.waiting = append(sh.waiting[:i], sh.waiting[i+1:]...)
return
}
}
}
func (sh *Shaper) nudge() {
select {
case sh.wake <- struct{}{}:
default:
}
}
+231
View File
@@ -0,0 +1,231 @@
package client
import (
"sync"
"sync/atomic"
"testing"
"time"
)
func TestParseBandwidth(t *testing.T) {
cases := []struct {
in string
want int64
}{
{"", 0},
{"20mbps", 2_500_000},
{"20Mbps", 2_500_000},
{"20 mbps", 2_500_000},
{"1.5mbit", 187_500},
{"512kbps", 64_000},
{"1gbps", 125_000_000},
{"2MB/s", 2 << 20},
{"500kb/s", 500 << 10},
{"1GB/s", 1 << 30},
{"8bps", 1},
{"4096b/s", 4096},
{"1500000", 1_500_000}, // bare number is already bytes/sec
}
for _, c := range cases {
got, err := parseBandwidth(c.in)
if err != nil {
t.Errorf("parseBandwidth(%q): unexpected error %v", c.in, err)
continue
}
if got != c.want {
t.Errorf("parseBandwidth(%q) = %d, want %d", c.in, got, c.want)
}
}
for _, bad := range []string{"fast", "20megabits", "-5mbps", "0", "0mbps", "mbps", "20 mb ps"} {
if _, err := parseBandwidth(bad); err == nil {
t.Errorf("parseBandwidth(%q): expected an error", bad)
}
}
}
// A nil shaper is the "unlimited" case and must be safe on every path, because
// call sites deliberately do not branch on it.
func TestNilShaperIsUnlimited(t *testing.T) {
var sh *Shaper
if sh = NewShaper(0); sh != nil {
t.Fatal("NewShaper(0) should return nil")
}
if got := sh.chunkSize(); got != DataChunkSize {
t.Errorf("nil chunkSize = %d, want %d", got, DataChunkSize)
}
if !sh.Acquire(&shaperShare{}, 1<<20, nil) {
t.Error("nil Acquire should always succeed")
}
sh.Stop() // must not panic
}
// The virtual-time bookkeeping is what makes the shaper fair, so assert it
// directly. The rate is high enough that tokens never bind, leaving only the
// stamping under test — no timing, no flakiness.
func TestShaperIdleStreamCannotHoardCredit(t *testing.T) {
sh := NewShaper(1 << 30)
defer sh.Stop()
var heavy, light shaperShare
for i := 0; i < 10; i++ {
if !sh.Acquire(&heavy, 1000, nil) {
t.Fatal("acquire failed")
}
}
if heavy.vfinish != 10000 {
t.Errorf("heavy.vfinish = %v, want 10000", heavy.vfinish)
}
sh.mu.Lock()
vclock := sh.vclock
sh.mu.Unlock()
if vclock != 9000 {
t.Errorf("vclock = %v, want 9000 (the stamp of the last request served)", vclock)
}
// light was idle for all of it. Its stale vfinish of 0 must be clamped up to
// the current clock: it may not bank the virtual time it never spent, which
// is what would let it starve heavy on return.
if !sh.Acquire(&light, 1000, nil) {
t.Fatal("acquire failed")
}
if light.vfinish != vclock+1000 {
t.Errorf("light.vfinish = %v, want %v (clamped to the clock, not 1000)", light.vfinish, vclock+1000)
}
}
func TestShaperEnforcesRate(t *testing.T) {
const rate = 1 << 20 // 1 MiB/s
sh := NewShaper(rate)
defer sh.Stop()
var share shaperShare
const total = 512 << 10
const chunk = 8 << 10
start := time.Now()
for sent := 0; sent < total; sent += chunk {
if !sh.Acquire(&share, chunk, nil) {
t.Fatal("acquire failed")
}
}
elapsed := time.Since(start)
// The bucket starts full, so the burst is free and only the remainder is
// paced: (512 KiB - 200 KiB) / 1 MiB/s ≈ 300 ms. Bounds are wide on purpose.
if elapsed < 200*time.Millisecond {
t.Errorf("sent %d bytes at %d B/s in only %v; the cap is not being enforced", total, rate, elapsed)
}
if elapsed > time.Second {
t.Errorf("took %v, far longer than the ~300ms the rate implies", elapsed)
}
}
// An idle stream must be able to spend the banked burst at once, otherwise a
// player joining pays for the cap in visible chunk-loading latency.
func TestShaperAllowsBurst(t *testing.T) {
sh := NewShaper(1 << 20)
defer sh.Stop()
var share shaperShare
start := time.Now()
for i := 0; i < 6; i++ {
if !sh.Acquire(&share, 32<<10, nil) { // 192 KiB, inside the 200 KiB bucket
t.Fatal("acquire failed")
}
}
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
t.Errorf("burst of 192 KiB took %v; the bucket should have covered it instantly", elapsed)
}
}
// The point of the whole exercise: a stream that never stops asking must not
// crowd another one out.
func TestShaperSharesFairlyBetweenStreams(t *testing.T) {
sh := NewShaper(1 << 20)
defer sh.Stop()
stop := make(chan struct{})
var counts [2]atomic.Int64
var wg sync.WaitGroup
for i := range counts {
wg.Add(1)
go func(i int) {
defer wg.Done()
var share shaperShare
for {
if !sh.Acquire(&share, 4<<10, stop) {
return
}
counts[i].Add(4 << 10)
}
}(i)
}
// Spend the token bucket before measuring, the way the idle-credit test
// raises the rate so tokens never bind: isolate the property under test.
//
// While the bucket has tokens there is no queue to arbitrate — every request
// is granted the moment it arrives, and being the stream that is owed service
// only helps when both are enqueued at the same instant. The burst is
// therefore first-come-first-served by construction, and at 0.2s of
// transmission it is a quarter of a 600ms window, enough to swamp the result:
// a run where one goroutine happened to win the bucket landed at 520192 vs
// 315392, which is exactly "one took the whole burst, then the two split the
// remainder evenly".
//
// Fairness here is a steady-state property, and that is what matters in
// practice — the bucket is empty whenever the link is actually busy.
time.Sleep(250 * time.Millisecond)
counts[0].Store(0)
counts[1].Store(0)
time.Sleep(600 * time.Millisecond)
close(stop)
wg.Wait()
a, b := counts[0].Load(), counts[1].Load()
if a == 0 || b == 0 {
t.Fatalf("one stream was starved entirely: %d vs %d", a, b)
}
lo, hi := min(a, b), max(a, b)
t.Logf("steady-state split: %d vs %d bytes (%.3fx)", a, b, float64(hi)/float64(lo))
if float64(hi) > 1.35*float64(lo) {
t.Errorf("unfair split: %d vs %d bytes (>35%% apart)", a, b)
}
}
// A stream torn down while parked must release immediately and leave no trace
// in the queue, or its goroutine (and the Stream it closes over) leaks.
func TestShaperAcquireCancels(t *testing.T) {
sh := NewShaper(MinBandwidth) // 8 KiB/s: a parked request would wait seconds
defer sh.Stop()
var share shaperShare
if !sh.Acquire(&share, MinShaperBurst, nil) { // drain the bucket
t.Fatal("acquire failed")
}
cancel := make(chan struct{})
result := make(chan bool, 1)
go func() { result <- sh.Acquire(&share, 32<<10, cancel) }()
time.Sleep(50 * time.Millisecond)
close(cancel)
select {
case ok := <-result:
if ok {
t.Error("Acquire returned true after cancellation")
}
case <-time.After(time.Second):
t.Fatal("Acquire did not return after its cancel channel closed")
}
sh.mu.Lock()
n := len(sh.waiting)
sh.mu.Unlock()
if n != 0 {
t.Errorf("%d cancelled request(s) left in the queue", n)
}
}
+223
View File
@@ -0,0 +1,223 @@
package client
import (
"fmt"
"log"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
)
// Performance diagnostics.
//
// The question an operator actually has is "why is this tunnel slow?", and
// nothing here could answer it before. A stream that is not moving bytes is
// blocked on exactly one of three things:
//
// - the flow-control window — the peer is not draining to its terminal
// socket, so the bottleneck is past the tunnel (a struggling game server, a
// player on a bad link);
// - the shaper — the configured bandwidth cap is the binding constraint, and
// raising it is the fix;
// - the peer socket itself — bytes move, but slowly, which points at the path
// rather than at either end.
//
// Those three are indistinguishable from throughput alone and call for
// completely different responses, so they are counted apart.
//
// Cost. Both structs are nil unless statsIntervalMs is set, so the default is a
// single predictable branch per event and no allocation at all. When enabled,
// counters sit under locks the code already holds; only the frame counters use
// atomics, because the read loop must never queue behind a send. A clock is read
// only when a goroutine is about to block, never per chunk — if nothing stalls,
// nothing is timed.
// streamStats accumulates one stream's lifetime. Guarded by Stream.mu.
type streamStats struct {
opened time.Time
bytesUp int64 // destination -> hub
bytesDown int64 // hub -> destination
windowStall time.Duration // blocked with no send credit
shaperStall time.Duration // blocked on the bandwidth cap
qPeak int // high-water mark of the receive queue
resumes int
hung time.Duration // total time parked awaiting a reattach
replayBytes int64
}
// connStats accumulates one worker conn's lifetime.
type connStats struct {
opened time.Time
framesIn atomic.Int64
framesOut atomic.Int64
writeErrs atomic.Int64
// Round-trip time of the mux heartbeat. The probe already carries a
// timestamp that the peer echoes and both sides currently throw away, so
// this measures tunnel latency for no added cost — and it is the best signal
// available for head-of-line blocking, where one stream's backlog delays
// every other stream sharing the connection.
mu sync.Mutex
rttLast time.Duration
rttMin time.Duration
rttMax time.Duration
rttSum time.Duration
rttN int64
}
func (cs *connStats) observeRTT(d time.Duration) {
if cs == nil || d < 0 {
return // a nonce we cannot read as one of our own timestamps
}
cs.mu.Lock()
defer cs.mu.Unlock()
cs.rttLast = d
if cs.rttN == 0 || d < cs.rttMin {
cs.rttMin = d
}
if d > cs.rttMax {
cs.rttMax = d
}
cs.rttSum += d
cs.rttN++
}
func (cs *connStats) rtt() (last, min, avg, max time.Duration) {
cs.mu.Lock()
defer cs.mu.Unlock()
if cs.rttN == 0 {
return 0, 0, 0, 0
}
return cs.rttLast, cs.rttMin, cs.rttSum / time.Duration(cs.rttN), cs.rttMax
}
// stallClock times a block without charging the path that does not block: the
// clock is read only once a wait is actually about to happen.
type stallClock struct{ start time.Time }
func (t *stallClock) begin(on bool) {
if on && t.start.IsZero() {
t.start = time.Now()
}
}
func (t *stallClock) elapsed() time.Duration {
if t.start.IsZero() {
return 0
}
return time.Since(t.start)
}
// statsLoop prints one aggregate line per interval. Never started when
// statsIntervalMs is 0, which is the default.
func (c *Client) statsLoop(stop <-chan struct{}) {
ticker := time.NewTicker(time.Duration(c.cfg.StatsIntervalMs) * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ticker.C:
log.Print(c.StatsLine())
}
}
}
// StatsLine renders the current pool and per-conn state as one greppable line.
// Exported so tests and embedders can sample it without waiting for the ticker.
func (c *Client) StatsLine() string {
conns := c.pool.snapshot()
var b strings.Builder
fmt.Fprintf(&b, "stats conns=%d", len(conns))
live, parked := 0, 0
for _, wc := range conns {
wc.mu.Lock()
streams := make([]*Stream, 0, len(wc.streams))
for _, s := range wc.streams {
streams = append(streams, s)
}
wc.mu.Unlock()
live += len(streams)
for _, s := range streams {
s.mu.Lock()
if s.parked {
parked++
}
s.mu.Unlock()
}
fmt.Fprintf(&b, " | conn%d streams=%d", wc.id, len(streams))
if cs := wc.stats; cs != nil {
_, mn, avg, mx := cs.rtt()
fmt.Fprintf(&b, " frames=%d/%d rtt=%s/%s/%s",
cs.framesIn.Load(), cs.framesOut.Load(), round(mn), round(avg), round(mx))
if n := cs.writeErrs.Load(); n > 0 {
fmt.Fprintf(&b, " writeErrs=%d", n)
}
}
}
fmt.Fprintf(&b, " | streams=%d parked=%d", live, parked)
return b.String()
}
// logSummary reports a stream's lifetime as it closes. This is the artifact that
// answers a specific complaint after the fact, once the periodic line has
// scrolled away.
func (s *Stream) logSummary() {
s.mu.Lock()
st := s.stats
if st == nil {
s.mu.Unlock()
return
}
line := fmt.Sprintf("stream closed after %s: up=%s down=%s stalled(window=%s shaper=%s) qPeak=%s",
round(time.Since(st.opened)), bytesHuman(st.bytesUp), bytesHuman(st.bytesDown),
round(st.windowStall), round(st.shaperStall), bytesHuman(int64(st.qPeak)))
if st.resumes > 0 {
line += fmt.Sprintf(" resumes=%d hung=%s replayed=%s",
st.resumes, round(st.hung), bytesHuman(st.replayBytes))
}
s.mu.Unlock()
log.Print(line)
}
func (p *WorkerPool) snapshot() []*WorkerConn {
p.mu.Lock()
conns := append([]*WorkerConn(nil), p.conns...)
p.mu.Unlock()
sort.Slice(conns, func(i, j int) bool { return conns[i].id < conns[j].id })
return conns
}
// round trims a duration to something readable in a log line.
func round(d time.Duration) time.Duration {
switch {
case d <= 0:
return 0
case d < time.Millisecond:
return d.Round(time.Microsecond)
case d < time.Second:
return d.Round(time.Millisecond)
default:
return d.Round(10 * time.Millisecond)
}
}
func bytesHuman(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%dB", n)
}
div, exp := int64(unit), 0
for v := n / unit; v >= unit; v /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f%ciB", float64(n)/float64(div), "KMGT"[exp])
}
+64
View File
@@ -0,0 +1,64 @@
package client
// unackedBuf holds the bytes a stream has sent but the peer has not yet
// credited — exactly the region a reattach may have to retransmit
// (PROTOCOL.md §7.5).
//
// It needs no cap of its own: credit is only granted as bytes reach the peer's
// terminal socket, so flow control already bounds the outstanding region to one
// window. That is what makes byte-exact resumption affordable at all.
//
// A read offset rather than a copy-down on every trim. Credit arrives once per
// half-window, and copying the live remainder each time would add a second
// per-byte copy to the whole send path; compacting only once the dead prefix
// dominates makes it amortized O(1).
type unackedBuf struct {
buf []byte
head int // bytes at the front already credited, awaiting reclamation
baseOff int64 // stream offset of buf[head]
}
// length is how many bytes are still outstanding.
func (u *unackedBuf) length() int { return len(u.buf) - u.head }
// base is the offset of the first byte still held.
func (u *unackedBuf) base() int64 { return u.baseOff }
// end is the offset one past the last byte sent.
func (u *unackedBuf) end() int64 { return u.baseOff + int64(u.length()) }
func (u *unackedBuf) append(p []byte) { u.buf = append(u.buf, p...) }
// advance drops everything the peer has credited up to off.
func (u *unackedBuf) advance(off int64) {
drop := int(off - u.baseOff)
if drop <= 0 {
return
}
if n := u.length(); drop > n {
drop = n // only reachable from a peer crediting bytes it was never sent
}
u.head += drop
u.baseOff += int64(drop)
switch {
case u.head == len(u.buf):
u.buf, u.head = u.buf[:0], 0 // fully drained: restart at the front
case u.head > len(u.buf)/2:
u.buf = append(u.buf[:0], u.buf[u.head:]...)
u.head = 0
}
}
// from returns the outstanding bytes at and after off, or nil when off falls
// outside what is still held — which means the peer reported an offset we can no
// longer satisfy, and the stream cannot be resumed.
func (u *unackedBuf) from(off int64) []byte {
skip := off - u.baseOff
if skip < 0 || skip > int64(u.length()) {
return nil
}
return u.buf[u.head+int(skip):]
}
// reset releases the buffer once a stream can no longer be resumed.
func (u *unackedBuf) reset() { u.buf, u.head = nil, 0 }
+112
View File
@@ -0,0 +1,112 @@
package client
import (
"bytes"
"testing"
)
// The retained region is what a reattach replays from, so an off-by-one here is
// not a dropped byte but a spliced stream: the peer resumes mid-packet and the
// session dies in a way no round-trip test would attribute to this code.
func TestUnackedTracksOffsets(t *testing.T) {
var u unackedBuf
u.append([]byte("hello"))
u.append([]byte("world"))
if got := u.length(); got != 10 {
t.Fatalf("length = %d, want 10", got)
}
if got := u.end(); got != 10 {
t.Fatalf("end = %d, want 10", got)
}
if got := u.from(0); !bytes.Equal(got, []byte("helloworld")) {
t.Fatalf("from(0) = %q", got)
}
// A reattach replays from wherever the peer got to, which lands anywhere —
// including the middle of a chunk boundary.
if got := u.from(3); !bytes.Equal(got, []byte("loworld")) {
t.Fatalf("from(3) = %q", got)
}
if got := u.from(10); len(got) != 0 {
t.Fatalf("from(end) = %q, want empty", got)
}
}
func TestUnackedAdvanceDropsCreditedBytes(t *testing.T) {
var u unackedBuf
u.append([]byte("abcdefghij"))
u.advance(4)
if got := u.length(); got != 6 {
t.Fatalf("length after advance = %d, want 6", got)
}
if got := u.end(); got != 10 {
t.Fatalf("end must not move when bytes are dropped: got %d, want 10", got)
}
if got := u.from(4); !bytes.Equal(got, []byte("efghij")) {
t.Fatalf("from(4) = %q", got)
}
// Below the retained region: the peer named an offset we can no longer
// satisfy, which must be reported rather than silently clamped — replaying
// the wrong range is worse than refusing to replay.
if got := u.from(3); got != nil {
t.Fatalf("from(3) below base = %q, want nil", got)
}
if got := u.from(11); got != nil {
t.Fatalf("from(11) past end = %q, want nil", got)
}
}
// Interleaving appends and advances is the steady-state pattern: credit arrives
// every half window while the sender keeps writing. The buffer must stay exact
// across the compaction that eventually triggers.
func TestUnackedSurvivesInterleavedAppendAndAdvance(t *testing.T) {
var u unackedBuf
var sent []byte
var acked int64
for i := 0; i < 200; i++ {
chunk := bytes.Repeat([]byte{byte(i)}, 97)
sent = append(sent, chunk...)
// emit's order: reclaim what has been credited so far, then retain the
// new chunk. The base therefore trails the credit that arrived since.
base := acked
u.advance(base)
u.append(chunk)
if got, want := u.end(), int64(len(sent)); got != want {
t.Fatalf("round %d: end = %d, want %d", i, got, want)
}
if got, want := u.length(), len(sent)-int(base); got != want {
t.Fatalf("round %d: length = %d, want %d", i, got, want)
}
if got, want := u.from(base), sent[base:]; !bytes.Equal(got, want) {
t.Fatalf("round %d: retained region diverges from what was sent", i)
}
// The peer can only ever credit bytes it has actually received.
if i%3 == 0 {
if acked += 61; acked > int64(len(sent)) {
acked = int64(len(sent))
}
}
}
}
// Compaction reuses the backing array, so a stream that runs for hours must not
// grow one: this is a full window per stream, on both sides.
func TestUnackedReclaimsBackingArray(t *testing.T) {
var u unackedBuf
chunk := bytes.Repeat([]byte{7}, 4096)
for i := 0; i < 500; i++ {
u.advance(u.end()) // fully credited every round
u.append(chunk)
}
if u.length() != len(chunk) {
t.Fatalf("length = %d, want %d", u.length(), len(chunk))
}
if cap(u.buf) > 8*len(chunk) {
t.Fatalf("backing array grew to %d bytes for a %d-byte window", cap(u.buf), len(chunk))
}
}
+305 -39
View File
@@ -17,16 +17,24 @@ import (
// exactly how a whole server's worth of players used to drop at once.
const StreamsBeforeGrowing = 1
// allocateAttempts bounds how many times a caller retries Allocate when the
// conn it was handed dies before the stream could be registered on it. The race
// is narrow and each retry picks a different conn, so a small bound is enough;
// an unbounded loop would spin against a hub that is refusing every connection.
const allocateAttempts = 3
// WorkerPool manages up to maxConn worker connections and allocates streams
// using the least-loaded strategy (PROTOCOL.md §7.1).
type WorkerPool struct {
client *Client
maxConn int
connSeq atomic.Int64 // conn ids, for log correlation
mu sync.Mutex
cond *sync.Cond
conns []*WorkerConn
dialing int // dials currently in flight (foreground + background)
dialing int // dials currently in flight (foreground + background)
dialGen uint64
dialErr error // most recent dial failure
}
@@ -147,11 +155,16 @@ func (p *WorkerPool) dialWorker() (*WorkerConn, error) {
pool: p,
fc: sess.fc,
sendWndInit: sess.peerWnd,
recvWndInit: p.client.streamWnd,
resume: sess.resume,
grace: p.client.resumeGrace(sess.hubGrace),
id: int(p.connSeq.Add(1)),
streams: make(map[int]*Stream),
nextSid: 1,
done: make(chan struct{}),
}
if p.client.statsOn() {
wc.stats = &connStats{opened: time.Now()}
}
wc.lastPong.Store(time.Now().UnixMilli())
go wc.readLoop()
if sess.heartbeat {
@@ -160,8 +173,8 @@ func (p *WorkerPool) dialWorker() (*WorkerConn, error) {
log.Printf("worker conn: hub does not support the mux heartbeat; " +
"a silently dropped path will only be caught by TCP keepalive")
}
log.Printf("opened worker conn (send window %d, recv window %d, heartbeat %v)",
wc.sendWndInit, wc.recvWndInit, sess.heartbeat)
log.Printf("opened worker conn (send window %d, recv window %d, heartbeat %v, resume %v)",
wc.sendWndInit, p.client.streamWnd, sess.heartbeat, wc.resume)
return wc, nil
}
@@ -195,19 +208,34 @@ func (p *WorkerPool) closeAll() {
}
// WorkerConn is one multiplexed worker connection to the hub.
//
// Only genuinely per-connection state lives here. Client-wide values (the
// shaper, the advertised receive window, the DATA chunk cap) belong to Client:
// reading them through a connection pointer would make every such read a
// re-parenting hazard once a stream can migrate between conns.
type WorkerConn struct {
pool *WorkerPool
fc *wire.FramedConn
sendWndInit int // hub's advertised per-stream receive window (our send budget)
recvWndInit int // our advertised per-stream receive window (bounds each recv queue)
// resume is whether this conn negotiated stream resumption, and grace how
// long a stream parked from it may keep trying to reattach. Both are
// per-conn: a reattach may land on a different (or restarted) hub, so the
// flag must be re-checked on the conn that will carry the RESUME.
resume bool
grace time.Duration
done chan struct{} // closed when readLoop exits
lastPong atomic.Int64 // unix ms of the most recent PONG
id int // for log correlation only
stats *connStats // nil unless diagnostics are enabled
mu sync.Mutex
streams map[int]*Stream
nextSid int
closed bool // readLoop has exited; registerStream must refuse
}
// heartbeatLoop proves the worker conn is still carrying frames end to end. TCP
@@ -251,10 +279,23 @@ func (wc *WorkerConn) newSid() int {
return sid
}
func (wc *WorkerConn) registerStream(sid int, st *Stream) {
// registerStream publishes a stream in the conn's table, or reports false if
// the conn has already died.
//
// The check is not advisory. Allocate hands out a (conn, sid) pair under the
// pool lock, and the conn's readLoop can exit before the caller gets here — it
// has then already swapped the stream map, so a blind insert would land in a map
// nothing iterates and the stream would never be torn down. That normally hides
// behind a failing SYN, but not on a half-open conn whose readLoop died on a
// framing error while the socket is still writable. Callers must re-Allocate.
func (wc *WorkerConn) registerStream(sid int, st *Stream) bool {
wc.mu.Lock()
defer wc.mu.Unlock()
if wc.closed {
return false
}
wc.streams[sid] = st
wc.mu.Unlock()
return true
}
func (wc *WorkerConn) getStream(sid int) *Stream {
@@ -280,6 +321,9 @@ func (wc *WorkerConn) readLoop() {
if err != nil {
break
}
if wc.stats != nil {
wc.stats.framesIn.Add(1)
}
r := wire.NewReader(payload)
ftype, err := r.U8()
if err != nil {
@@ -306,14 +350,37 @@ func (wc *WorkerConn) readLoop() {
st.gracefulFin()
}
case MuxRst:
// The reason is an optional trailing byte; older peers send none.
reason := RstUnspecified
if b, err := r.U8(); err == nil {
reason = int(b)
}
if st := wc.removeStream(sid); st != nil {
st.teardown(false)
st.onRst(reason)
}
case MuxResumeAck:
accepted, aerr := r.I64()
delivered, derr := r.I64()
cid, cerr := r.Bytes(CIDLen)
if aerr != nil || derr != nil || cerr != nil {
continue
}
if st := wc.getStream(sid); st != nil {
st.deliverResume(resumeResult{accepted: accepted, delivered: delivered, cid: cid})
}
case MuxPing:
nonce, _ := r.I64()
_ = wc.fc.WriteFrame(wire.NewWriter().U8(MuxPong).VarInt(MuxCtlSid).I64(nonce).Out())
case MuxPong:
wc.lastPong.Store(time.Now().UnixMilli())
now := time.Now()
wc.lastPong.Store(now.UnixMilli())
// The probe's nonce is the timestamp we sent, echoed back, so the
// round trip is free to measure and nobody was reading it.
if wc.stats != nil {
if sent, err := r.I64(); err == nil {
wc.stats.observeRTT(now.Sub(time.UnixMilli(sent)))
}
}
default:
log.Printf("worker: unknown mux type %d", ftype)
}
@@ -322,14 +389,22 @@ func (wc *WorkerConn) readLoop() {
close(wc.done)
wc.pool.remove(wc)
wc.mu.Lock()
// Marked before the map is swapped, under the same lock, so a concurrent
// registerStream either lands in the map we are about to drain or is refused.
wc.closed = true
streams := make([]*Stream, 0, len(wc.streams))
for _, st := range wc.streams {
streams = append(streams, st)
}
wc.streams = make(map[int]*Stream)
wc.mu.Unlock()
// Only the tunnel leg died. Where the session negotiated resumption the
// destination sockets are kept open and each stream reattaches over a fresh
// conn (§7.5); otherwise this is the old, unconditional teardown.
for _, st := range streams {
st.teardown(false)
if !st.park(wc.grace) {
st.teardown(false)
}
}
}
@@ -338,7 +413,14 @@ func (wc *WorkerConn) sendSyn(sid int, cid []byte) error {
}
func (wc *WorkerConn) sendData(sid int, data []byte) error {
return wc.fc.WriteFrame(wire.NewWriter().U8(MuxData).VarInt(sid).Bytes(data).Out())
err := wc.fc.WriteFrame(wire.NewWriter().U8(MuxData).VarInt(sid).Bytes(data).Out())
if wc.stats != nil {
wc.stats.framesOut.Add(1)
if err != nil {
wc.stats.writeErrs.Add(1)
}
}
return err
}
func (wc *WorkerConn) sendFin(sid int) {
@@ -353,6 +435,16 @@ func (wc *WorkerConn) sendWndUpdate(sid, delta int) {
_ = wc.fc.WriteFrame(wire.NewWriter().U8(MuxWnd).VarInt(sid).VarInt(delta).Out())
}
// leg binds a stream to one worker conn. The two fields are only meaningful
// together: stream ids are per-conn and restart at 1, so conn A's sid 3 and
// conn B's sid 3 belong to different players. A torn read across the two would
// credit, reset or FIN a stranger's stream, so the pair is swapped as one
// immutable value rather than as two fields.
type leg struct {
wc *WorkerConn
sid int
}
// Stream bridges one player (via the hub) to one destination connection.
//
// Data from the hub is queued and written to the destination by a dedicated
@@ -360,24 +452,74 @@ func (wc *WorkerConn) sendWndUpdate(sid, delta int) {
// the hub never sends more un-credited bytes, so overflow is a protocol
// violation and resets the stream.
type Stream struct {
wc *WorkerConn
sid int
cid []byte
client *Client
leg atomic.Pointer[leg]
mapping Mapping
srcIP string
srcPort int
vel *velocityForwarder // non-nil when the mapping sets velocitySecret
share shaperShare // this stream's position in the egress fair queue
done chan struct{} // closed on teardown; unparks a shaper wait
// resumable is fixed at creation from the conn's negotiated flag. With it
// false none of the bookkeeping below runs and no buffer is ever allocated,
// so a client with resumption disabled pays exactly what it used to.
resumable bool
stats *streamStats // nil unless diagnostics are enabled
// ackedOffset is the running sum of WND deltas received. Credit is granted
// only as bytes reach the peer's terminal socket, so it is a sound lower
// bound on what has been delivered. Atomic because the worker readLoop
// advances it and must never block behind the send path.
ackedOffset atomic.Int64
// sendMu serializes the send path — buffer the chunk, advance the offset,
// write the frame — against a reattach's retransmit, so replayed bytes can
// never interleave with live ones.
//
// Deliberately not s.mu: deliverFromHub takes s.mu from the worker readLoop,
// and holding s.mu across a WriteFrame would stall frame dispatch for every
// other stream on the connection.
sendMu sync.Mutex
un unackedBuf // guarded by sendMu
mu sync.Mutex
cond *sync.Cond
cid []byte // takeover capability; re-minted by the hub on each resume
dest net.Conn
connected bool
closed bool
finPending bool // hub sent FIN; close the destination once the queue drains
q []qentry // hub/local -> destination, waiting for writeLoop
qBytes int // hub bytes only: bounds the peer against its window
sendWnd int // flow control: budget for destination -> hub DATA
consumed int // flow control: drained bytes not yet credited back to the hub
parked bool // worker conn died; awaiting reattach on a fresh one
parkedAt time.Time // when the current hang began; diagnostics only
finPending bool // hub sent FIN; close the destination once the queue drains
finToHub bool // destination closed while parked; FIN owed once reattached
q []qentry // hub/local -> destination, waiting for writeLoop
qBytes int // hub bytes only: bounds the peer against its window
// acceptedOffset counts hub bytes enqueued toward the destination. This, not
// "bytes written", is what a reattach reports: acceptance is synchronous and
// stable at park time, whereas delivery is signalled asynchronously and goes
// silent exactly when the connection dies — which would under-report and make
// the hub retransmit bytes the player already has.
acceptedOffset int64
// deliveredOffset counts hub bytes actually written to the destination.
// Distinct from acceptedOffset and needed for a different job: a reattach
// replays from what the peer *accepted*, but sizes the window from what it
// *delivered*, because the window is a promise about undelivered bytes.
deliveredOffset int64
sendWnd int // flow control: budget for destination -> hub DATA
consumed int // flow control: drained bytes not yet credited back to the hub
resumeWait chan resumeResult
}
// resumeResult is the hub's answer to a RESUME: how far it got in both senses,
// plus a fresh CID — or the reason the reattach was refused.
type resumeResult struct {
accepted int64
delivered int64
cid []byte
err error
}
// qentry is one queued write towards the destination. Only hub-originated
@@ -389,8 +531,13 @@ type qentry struct {
fromHub bool
}
func newStream(wc *WorkerConn, sid int, cid []byte, m Mapping, ip string, port int) *Stream {
s := &Stream{wc: wc, sid: sid, cid: cid, mapping: m, srcIP: ip, srcPort: port, sendWnd: wc.sendWndInit}
func newStream(c *Client, wc *WorkerConn, sid int, cid []byte, m Mapping, ip string, port int) *Stream {
s := &Stream{client: c, cid: cid, mapping: m, srcIP: ip, srcPort: port,
resumable: wc.resume, sendWnd: wc.sendWndInit, done: make(chan struct{})}
s.leg.Store(&leg{wc: wc, sid: sid})
if c.statsOn() {
s.stats = &streamStats{opened: time.Now()}
}
if m.VelocitySecret != "" {
s.vel = newVelocityForwarder(m.VelocitySecret, ip)
}
@@ -398,14 +545,20 @@ func newStream(wc *WorkerConn, sid int, cid []byte, m Mapping, ip string, port i
return s
}
// conn returns the stream's current binding. Every caller must take exactly one
// snapshot and use both fields from it; re-loading mid-operation reintroduces
// the torn-pair hazard the leg exists to prevent.
func (s *Stream) conn() *leg { return s.leg.Load() }
// run dials the destination, optionally writes the PROXY v2 header, then pumps
// destination -> hub (respecting the stream send window when negotiated).
func (s *Stream) run() {
dest, err := net.DialTimeout("tcp", s.mapping.Destination, 10*time.Second)
if err != nil {
log.Printf("stream %d: dial %s failed: %v", s.sid, s.mapping.Destination, err)
s.wc.removeStream(s.sid)
s.wc.sendRst(s.sid)
lg := s.conn()
log.Printf("stream %d: dial %s failed: %v", lg.sid, s.mapping.Destination, err)
lg.wc.removeStream(lg.sid)
lg.wc.sendRst(lg.sid)
s.teardown(false)
return
}
@@ -416,7 +569,7 @@ func (s *Stream) run() {
if s.mapping.ProxyProtocol {
if hdr := s.buildProxyHeader(dest); hdr != nil {
if _, err := dest.Write(hdr); err != nil {
log.Printf("stream %d: proxy header write: %v", s.sid, err)
log.Printf("stream %d: proxy header write: %v", s.conn().sid, err)
}
}
}
@@ -462,26 +615,88 @@ func (s *Stream) run() {
s.teardown(true)
}
// sendToHub forwards destination bytes to the hub in DATA frames of at most
// DataChunkSize, honoring the stream send window. Returns false once the
// stream closed or the worker conn failed.
// sendToHub forwards destination bytes to the hub in bounded DATA frames,
// honoring both the stream send window and the client-wide bandwidth cap.
// Returns false once the stream closed or the worker conn failed.
func (s *Stream) sendToHub(data []byte) bool {
for len(data) > 0 {
n := len(data)
if n > DataChunkSize {
n = DataChunkSize
if n > s.client.chunk {
n = s.client.chunk
}
// Credit first, bandwidth second. The reverse order would charge the
// budget for bytes still parked on an exhausted window, so the client
// would throttle itself below the configured rate. Holding credit while
// waiting for tokens is free — credit is per-stream, and the hub returns
// it as it drains data to the player, independent of our pacing.
if !s.acquireSendWnd(n) {
return false
}
if err := s.wc.sendData(s.sid, data[:n]); err != nil {
var shaperStall stallClock
shaperStall.begin(s.stats != nil)
if !s.client.shaper.Acquire(&s.share, n, s.done) {
return false
}
if !s.emit(data[:n]) {
return false
}
if s.stats != nil {
s.mu.Lock()
// Separated from the window stall on purpose: this one says the
// configured cap is the binding constraint, and raising maxBandwidth
// is the fix. The window stall says the opposite.
s.stats.shaperStall += shaperStall.elapsed()
s.stats.bytesUp += int64(n)
s.mu.Unlock()
}
data = data[n:]
}
return true
}
// emit records a chunk for possible retransmission and writes it to the current
// worker conn. Returns false once the stream is finished with.
//
// The record is taken first and unconditionally. A write that fails on a dying
// connection has already spent window and may have put part of the frame on the
// wire, so the only trustworthy account of what the peer still owes us is the
// one taken before the attempt. That also makes a failure survivable: while the
// stream can still be resumed the bytes are already safe, and the reattach
// replays them from wherever the hub says it got to.
//
// Note the old code let a failed write drop the rest of the chunk on the floor —
// the caller's slice advance sat after the error return.
func (s *Stream) emit(chunk []byte) bool {
s.sendMu.Lock()
if s.resumable {
// Reclaim what the hub has credited before growing the buffer, so the
// outstanding region stays bounded by one window.
s.un.advance(s.ackedOffset.Load())
s.un.append(chunk)
}
lg := s.conn()
err := lg.wc.sendData(lg.sid, chunk)
s.sendMu.Unlock()
if err == nil {
return true
}
if !s.resumable {
return false
}
// The conn is gone but the stream is not: readLoop parks it and a reattach
// replays the buffer. Keep pumping the destination — acquireSendWnd stops us
// once a full window is outstanding, so nothing is lost and nothing grows
// without bound.
return !s.isClosed()
}
func (s *Stream) isClosed() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.closed
}
// writeLoop is the only writer to the destination. It drains the receive queue,
// credits the hub as bytes land on the destination socket, and performs the
// deferred graceful close when a FIN arrived with data still queued.
@@ -513,6 +728,12 @@ func (s *Stream) writeLoop() {
return
}
if e.fromHub {
s.mu.Lock()
s.deliveredOffset += int64(len(e.data))
if s.stats != nil {
s.stats.bytesDown += int64(len(e.data))
}
s.mu.Unlock()
s.credit(len(e.data))
}
}
@@ -530,16 +751,26 @@ func (s *Stream) deliverFromHub(data []byte) {
s.mu.Unlock()
return
}
if s.qBytes+len(data) > s.wc.recvWndInit {
if s.qBytes+len(data) > s.client.streamWnd {
s.mu.Unlock()
log.Printf("stream %d: peer exceeded flow-control window; resetting", s.sid)
s.wc.removeStream(s.sid)
s.wc.sendRst(s.sid)
lg := s.conn()
log.Printf("stream %d: peer exceeded flow-control window; resetting", lg.sid)
lg.wc.removeStream(lg.sid)
lg.wc.sendRst(lg.sid)
s.teardown(false)
return
}
s.q = append(s.q, qentry{data: data, fromHub: true})
s.qBytes += len(data)
// Accepted, not delivered: from here the bytes are ours to write, and the
// only way we fail to is by destroying the stream — which also ends any
// prospect of resuming it. That makes this a sound reattach coordinate.
s.acceptedOffset += int64(len(data))
if s.stats != nil && s.qBytes > s.stats.qPeak {
// How close the receive queue came to the advertised window: near it
// means the destination is the slow party.
s.stats.qPeak = s.qBytes
}
s.cond.Broadcast()
s.mu.Unlock()
}
@@ -562,9 +793,18 @@ func (s *Stream) injectToDest(data []byte) {
func (s *Stream) acquireSendWnd(n int) bool {
s.mu.Lock()
defer s.mu.Unlock()
// Timed only when it actually blocks, so a stream that never runs out of
// credit never reads the clock. A large windowStall is the signal that the
// peer is not draining to its terminal socket — the bottleneck is past the
// tunnel, not in it.
var stall stallClock
for !s.closed && s.sendWnd < n {
stall.begin(s.stats != nil)
s.cond.Wait()
}
if s.stats != nil {
s.stats.windowStall += stall.elapsed()
}
if s.closed {
return false
}
@@ -573,6 +813,11 @@ func (s *Stream) acquireSendWnd(n int) bool {
}
func (s *Stream) grantSendWnd(delta int) {
// The running sum doubles as the acked offset: the hub grants credit exactly
// as bytes reach the player socket, so a byte that has been credited can
// never need retransmitting. Advanced without a lock so the worker readLoop
// never blocks behind a send in progress.
s.ackedOffset.Add(int64(delta))
s.mu.Lock()
s.sendWnd += delta
s.cond.Broadcast()
@@ -581,17 +826,22 @@ func (s *Stream) grantSendWnd(delta int) {
// credit accounts bytes drained to the destination and grants the hub more
// window once half of our receive window has been consumed.
// While parked the grant is only withheld, never dropped: consumed keeps
// accumulating and a reattach flushes it on the new conn. Resetting it would
// destroy up to half a window of credit per outage, and after a few flaps the
// stream would throttle to a crawl.
func (s *Stream) credit(n int) {
s.mu.Lock()
s.consumed += n
if s.closed || s.consumed*2 < s.wc.recvWndInit {
if s.closed || s.parked || s.consumed*2 < s.client.streamWnd {
s.mu.Unlock()
return
}
delta := s.consumed
s.consumed = 0
s.mu.Unlock()
s.wc.sendWndUpdate(s.sid, delta)
lg := s.conn()
lg.wc.sendWndUpdate(lg.sid, delta)
}
func (s *Stream) buildProxyHeader(dest net.Conn) []byte {
@@ -629,6 +879,13 @@ func (s *Stream) gracefulFin() {
// teardown closes the stream immediately; notifyHub sends a FIN when true.
// Idempotent; wakes every goroutine parked on the stream.
func (s *Stream) teardown(notifyHub bool) {
// A parked stream owes the hub a FIN it cannot send: the only conn it has is
// the one that just failed. Keep it alive so the reattach can deliver it and
// the player gets a clean disconnect, rather than hanging until the hub's
// grace expires.
if notifyHub && s.noteFinWhileParked() {
return
}
s.mu.Lock()
if s.closed {
s.mu.Unlock()
@@ -636,14 +893,23 @@ func (s *Stream) teardown(notifyHub bool) {
}
s.closed = true
dest := s.dest
close(s.done) // guarded by the idempotence check above, so exactly once
s.cond.Broadcast()
s.mu.Unlock()
// The stream can no longer be resumed, so the retransmit buffer is dead
// weight — up to a full window of it per stream.
s.sendMu.Lock()
s.un.reset()
s.sendMu.Unlock()
if dest != nil {
_ = dest.Close()
}
s.wc.removeStream(s.sid)
lg := s.conn()
lg.wc.removeStream(lg.sid)
if notifyHub {
s.wc.sendFin(s.sid)
lg.wc.sendFin(lg.sid)
}
s.logSummary()
}