break: replace muxed workers with 1:1 tunnels
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.
This commit is contained in:
+19
-47
@@ -40,24 +40,25 @@ func stalledHub(t *testing.T) string {
|
||||
return ln.Addr().String()
|
||||
}
|
||||
|
||||
// TestAllocateDoesNotWedgePoolOnStalledHub is the regression guard for the
|
||||
// worst failure mode found in the stability audit: Allocate used to dial while
|
||||
// holding the pool mutex, and the handshake read had no deadline. One
|
||||
// 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, so no player could be served again until the process restarted.
|
||||
func TestAllocateDoesNotWedgePoolOnStalledHub(t *testing.T) {
|
||||
// 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",
|
||||
MaxConn: 8,
|
||||
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.Allocate(); done <- err }()
|
||||
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.Allocate(); done <- err }()
|
||||
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)
|
||||
@@ -65,50 +66,21 @@ func TestAllocateDoesNotWedgePoolOnStalledHub(t *testing.T) {
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
t.Fatal("Allocate succeeded against a hub that never answers")
|
||||
t.Fatal("Dial succeeded against a hub that never answers")
|
||||
}
|
||||
case <-limit:
|
||||
t.Fatalf("Allocate #%d never returned: the pool is wedged again", i+1)
|
||||
t.Fatalf("Dial #%d never returned: a stalled hub wedged the other caller", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllocateSpreadsAcrossConns guards the allocation rule: the pool must fan
|
||||
// out to maxConn before stacking streams, so a single worker connection is
|
||||
// never the shared point of failure for every player. Seven players used to all
|
||||
// land on one conn, which meant one dead TCP connection dropped everybody.
|
||||
func TestAllocateSpreadsAcrossConns(t *testing.T) {
|
||||
p := &WorkerPool{maxConn: 4}
|
||||
p.cond = sync.NewCond(&p.mu)
|
||||
|
||||
newConn := func() *WorkerConn {
|
||||
return &WorkerConn{pool: p, streams: make(map[int]*Stream), nextSid: 1, done: make(chan struct{})}
|
||||
}
|
||||
p.conns = []*WorkerConn{newConn()}
|
||||
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()
|
||||
if bestCount < StreamsBeforeGrowing {
|
||||
t.Fatalf("a conn with %d stream(s) should trigger growth", bestCount)
|
||||
}
|
||||
|
||||
// Once the pool is at maxConn, growth stops and streams stack on the
|
||||
// least-loaded conn instead.
|
||||
for len(p.conns) < p.maxConn {
|
||||
p.conns = append(p.conns, newConn())
|
||||
}
|
||||
best, bestCount := p.leastLoadedLocked()
|
||||
if bestCount != 0 {
|
||||
t.Fatalf("expected an empty conn to be least-loaded, got %d streams", bestCount)
|
||||
}
|
||||
p.maybeGrowLocked(bestCount)
|
||||
if p.dialing != 0 {
|
||||
t.Fatalf("pool dialed past maxConn=%d", p.maxConn)
|
||||
}
|
||||
if best == nil {
|
||||
t.Fatal("no conn selected")
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user