Worker frames are now FrameType + payload; there is no stream id. Each player gets its own worker conn. maxTunnels (default 256) caps concurrent tunnels. The old maxConn pool size is ignored so existing configs do not silently admit only a handful of players. Resume, per-direction windows, the control session, and the DATA-only shaper stay. A dropped worker still hangs that one player and reattaches over a fresh conn. Add a hub-side per-IP limiter for player intents only (default 8/s, burst 16, 64 concurrent). Unmatched hostnames consume a token; Intent 17 is never counted. 0 disables each knob.
87 lines
2.4 KiB
Go
87 lines
2.4 KiB
Go
package client
|
|
|
|
import (
|
|
"net"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// stalledHub accepts connections and then says nothing: it never answers the
|
|
// Rekey frame with SessionReady, and never closes. This models a hub with a
|
|
// wedged event loop, or a load balancer accepting on behalf of a dead backend.
|
|
func stalledHub(t *testing.T) string {
|
|
t.Helper()
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var mu sync.Mutex
|
|
var held []net.Conn
|
|
t.Cleanup(func() {
|
|
_ = ln.Close()
|
|
mu.Lock()
|
|
for _, c := range held {
|
|
_ = c.Close()
|
|
}
|
|
mu.Unlock()
|
|
})
|
|
go func() {
|
|
for {
|
|
c, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
mu.Lock()
|
|
held = append(held, c)
|
|
mu.Unlock()
|
|
}
|
|
}()
|
|
return ln.Addr().String()
|
|
}
|
|
|
|
// TestDialDoesNotWedgeOnStalledHub is the regression guard for the worst
|
|
// failure mode found in the stability audit: Dial used to share a single
|
|
// in-flight handshake, and the handshake read had no deadline. One
|
|
// unresponsive hub therefore parked every present and future allocation
|
|
// forever. 1:1 dials independently, but each must still fail on its own
|
|
// HandshakeTimeout rather than block the other.
|
|
func TestDialDoesNotWedgeOnStalledHub(t *testing.T) {
|
|
c := New(&Config{
|
|
Server: stalledHub(t),
|
|
PSK: "pool-test",
|
|
MaxTunnels: 8,
|
|
PingIntervalMs: 20000,
|
|
Mappings: []Mapping{{Pattern: "mc.local", Destination: "127.0.0.1:1"}},
|
|
})
|
|
|
|
done := make(chan error, 2)
|
|
go func() { _, err := c.pool.Dial(); done <- err }()
|
|
time.Sleep(200 * time.Millisecond) // let the first caller get into the dial
|
|
go func() { _, err := c.pool.Dial(); done <- err }()
|
|
|
|
// Both must give up on their own; neither may be stuck behind the other.
|
|
limit := time.After(HandshakeTimeout + 15*time.Second)
|
|
for i := 0; i < 2; i++ {
|
|
select {
|
|
case err := <-done:
|
|
if err == nil {
|
|
t.Fatal("Dial succeeded against a hub that never answers")
|
|
}
|
|
case <-limit:
|
|
t.Fatalf("Dial #%d never returned: a stalled hub wedged the other caller", i+1)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDialRespectsMaxTunnels pins the concurrency cap: once live+dialing
|
|
// equals maxTunnels, further Dial calls fail immediately rather than stacking.
|
|
func TestDialRespectsMaxTunnels(t *testing.T) {
|
|
p := newWorkerPool(&Client{}, 2)
|
|
p.conns[&WorkerConn{id: 1}] = struct{}{}
|
|
p.conns[&WorkerConn{id: 2}] = struct{}{}
|
|
if _, err := p.Dial(); err != errTooManyTunnels {
|
|
t.Fatalf("Dial at cap: got %v, want %v", err, errTooManyTunnels)
|
|
}
|
|
}
|