fix: harden resume/shutdown paths, tighten Intent-18 and PSK handshake handling
Client (Go) — resume correctness - C1: completeResume now re-parks the stream when replay fails mid-conn-loss. parked was cleared before the replay loop, so the dying conn's teardown would start a second resumeLoop and the two loops could strand the stream with neither alive. Resume stats are counted only after the replay lands. - C2: RST(ALREADY_BOUND) is retryable instead of terminating the loop. With C1 fixed there is never a genuine second attempt, so "already bound" means the hub still holds the stream on a half-open conn; the retry waits out that bind (bounded by the grace deadline, teardown on expiry) instead of returning and leaving the destination socket hung forever. Client (Go) — shutdown semantics - C3: Close() sets a closing flag and cancels an internal context; dialSession takes a ctx (DialContext + AfterFunc so shutdown aborts in-flight handshakes); the worker pool refuses new conns after closeAll (Allocate, background growth, cond waiters); serveControl's reconnect loop is gated by closing so Close works even when the caller's Start context is not cancelled; conn-loss teardown closes streams outright during shutdown instead of parking them for a reattach that will never come. Client (Go) — hygiene - C4: pingInterval() clamps at the single point a duration is derived, so a hand-built Config with PingIntervalMs <= 0 can no longer panic time.NewTicker (added DefaultPingIntervalMs). - E6: shaperStall is sampled right after shaper.Acquire, before the socket write, so a hub that is not reading is no longer charged to the bandwidth cap in the stats. - E7: stream log lines now carry conn%d/sid%d (leg.String()), making streams traceable across reattaches. - P5: mirror constants IntentReserved/RegisterOk/RegisterErrPattern added; RegisterAck dispatch logs rejection reasons via the named codes. Hub (Java) + PROTOCOL.md - P3: Intent 18 replies with a Minecraft status-response packet ([Len: VarInt][0x00][JSON: String]) and closes (socket.end, so the write always lands) instead of closing silently; documented in PROTOCOL.md §2. - P4: PSK address check is strict equality with the lowercase hex address; an uppercase/case-folded variant is now rejected per PROTOCOL.md §2. - P7: PROTOCOL.md §5 SessionReady row lists its real fields (Flags/RecvWindow/ResumeGraceMs) instead of "(none)". Verified: go vet, go test -race ./client/..., gradle test, full e2e suite (twice), resume e2e 3x, plus live probes of the hub with the real client codec (Intent-18 status reply, strict-lowercase PSK acceptance/rejection).%
This commit is contained in:
+51
-9
@@ -25,6 +25,18 @@ type Client struct {
|
||||
mappings map[string]Mapping // normalized pattern -> mapping
|
||||
pool *WorkerPool
|
||||
|
||||
// ctx/cancel own every pool dial: Close cancels it so an in-flight dial
|
||||
// aborts instead of holding a goroutine for the whole handshake timeout.
|
||||
// The control path uses the caller's context from Start, which is the same
|
||||
// shutdown signal by convention.
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
// closing is set by Close; a conn-loss teardown checks it and closes
|
||||
// streams outright rather than parking them for a reattach that is never
|
||||
// coming. Allocate also consults it through the pool's own flag.
|
||||
closing atomic.Bool
|
||||
|
||||
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
|
||||
@@ -35,11 +47,14 @@ type Client struct {
|
||||
|
||||
// New builds a client from config.
|
||||
func New(cfg *Config) *Client {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
c := &Client{
|
||||
cfg: cfg,
|
||||
pskBytes: []byte(cfg.PSK),
|
||||
pskAddr: wire.PSKAddress([]byte(cfg.PSK)),
|
||||
mappings: make(map[string]Mapping),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
if _, portStr, err := net.SplitHostPort(cfg.Server); err == nil {
|
||||
if p, err := net.LookupPort("tcp", portStr); err == nil {
|
||||
@@ -99,11 +114,22 @@ type session struct {
|
||||
// The whole exchange is bounded by HandshakeTimeout. A hub that accepts the
|
||||
// socket but never answers (wedged event loop, a load balancer accepting on its
|
||||
// behalf) must fail fast rather than park the caller forever.
|
||||
func (c *Client) dialSession(magic byte) (sess *session, err error) {
|
||||
conn, err := net.DialTimeout("tcp", c.cfg.Server, HandshakeTimeout)
|
||||
//
|
||||
// ctx bounds the dial: the control path passes the caller's context so a
|
||||
// shutdown mid-handshake aborts the attempt, and the pool passes the client's
|
||||
// own context so Close cancels in-flight dials. After the dial, a cancelled
|
||||
// ctx keeps aborting by closing the conn underneath the deadline-bounded
|
||||
// handshake I/O.
|
||||
func (c *Client) dialSession(ctx context.Context, magic byte) (sess *session, err error) {
|
||||
d := &net.Dialer{Timeout: HandshakeTimeout}
|
||||
conn, err := d.DialContext(ctx, "tcp", c.cfg.Server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// When ctx ends (shutdown), close the conn so the handshake below fails
|
||||
// immediately instead of waiting out its deadline.
|
||||
stop := context.AfterFunc(ctx, func() { _ = conn.Close() })
|
||||
defer stop()
|
||||
if tcp, ok := conn.(*net.TCPConn); ok {
|
||||
_ = tcp.SetNoDelay(true)
|
||||
_ = tcp.SetKeepAlive(true)
|
||||
@@ -227,7 +253,7 @@ func (c *Client) Start(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (c *Client) connectControl(ctx context.Context) error {
|
||||
sess, err := c.dialSession(MagicControl)
|
||||
sess, err := c.dialSession(ctx, MagicControl)
|
||||
if err != nil {
|
||||
return fmt.Errorf("control connect: %w", err)
|
||||
}
|
||||
@@ -271,7 +297,7 @@ func (c *Client) serveControl(ctx context.Context, ctrl *ctrlSession) {
|
||||
c.dispatchControl(ctrl, payload)
|
||||
}
|
||||
_ = ctrl.fc.Close()
|
||||
if ctx.Err() != nil {
|
||||
if ctx.Err() != nil || c.closing.Load() {
|
||||
return
|
||||
}
|
||||
// Reconnect with backoff, but try immediately first. While the control
|
||||
@@ -281,8 +307,10 @@ func (c *Client) serveControl(ctx context.Context, ctrl *ctrlSession) {
|
||||
// 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; {
|
||||
// backoff that has grown to the cap. Close() is authoritative on its own:
|
||||
// it gates the loop directly because a caller may shut the client down by
|
||||
// calling Close() without cancelling the context it passed to Start.
|
||||
for backoff := time.Duration(0); ctx.Err() == nil && !c.closing.Load(); {
|
||||
if backoff > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -290,6 +318,9 @@ func (c *Client) serveControl(ctx context.Context, ctrl *ctrlSession) {
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
}
|
||||
if c.closing.Load() {
|
||||
return
|
||||
}
|
||||
if err := c.connectControl(ctx); err == nil {
|
||||
return
|
||||
} else {
|
||||
@@ -316,7 +347,14 @@ func (c *Client) dispatchControl(ctrl *ctrlSession, payload []byte) {
|
||||
case CtlRegisterAck:
|
||||
pattern, _ := r.String()
|
||||
status, _ := r.U8()
|
||||
log.Printf("register ack %q status=%d", pattern, status)
|
||||
switch status {
|
||||
case RegisterOk:
|
||||
log.Printf("pattern %q registered", pattern)
|
||||
case RegisterErrPattern:
|
||||
log.Printf("pattern %q rejected: not a valid regular expression", pattern)
|
||||
default:
|
||||
log.Printf("pattern %q rejected: status=%d", pattern, status)
|
||||
}
|
||||
case CtlControlRequest:
|
||||
cid, err := r.Bytes(CIDLen)
|
||||
if err != nil {
|
||||
@@ -400,7 +438,7 @@ func (c *Client) handleControlRequest(cid []byte, pattern, ip string, port int)
|
||||
go st.writeLoop()
|
||||
go st.run()
|
||||
if err := lg.wc.sendSyn(lg.sid, cid); err != nil {
|
||||
log.Printf("stream %d: SYN failed: %v", lg.sid, err)
|
||||
log.Printf("stream %s: SYN failed: %v", lg, err)
|
||||
st.teardown(false)
|
||||
}
|
||||
}
|
||||
@@ -409,8 +447,12 @@ func (c *Client) handleControlRequest(cid []byte, pattern, ip string, port int)
|
||||
// (exposed for tests/observability).
|
||||
func (c *Client) WorkerConnCount() int { return c.pool.count() }
|
||||
|
||||
// Close tears down the control session and all worker connections.
|
||||
// Close tears down the control session and all worker connections. Idempotent:
|
||||
// a second call (or a Close racing a reconnect) only re-closes what is still
|
||||
// open.
|
||||
func (c *Client) Close() {
|
||||
c.closing.Store(true)
|
||||
c.cancel() // aborts in-flight pool dials
|
||||
c.mu.Lock()
|
||||
fc := c.ctrl
|
||||
c.mu.Unlock()
|
||||
|
||||
Reference in New Issue
Block a user