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:
@@ -14,12 +14,12 @@ import (
|
||||
// startClientWithBandwidth is startClient with an egress cap, for the shaping
|
||||
// tests. Only the client→hub direction is shaped, which in this harness is the
|
||||
// leg carrying the mock destination's echo back to the player.
|
||||
func startClientWithBandwidth(t *testing.T, hubAddr, psk string, maxConn int, bandwidth string, mappings []client.Mapping) *client.Client {
|
||||
func startClientWithBandwidth(t *testing.T, hubAddr, psk string, maxTunnels int, bandwidth string, mappings []client.Mapping) *client.Client {
|
||||
t.Helper()
|
||||
cfg := &client.Config{
|
||||
Server: hubAddr,
|
||||
PSK: psk,
|
||||
MaxConn: maxConn,
|
||||
MaxTunnels: maxTunnels,
|
||||
PingIntervalMs: 20000,
|
||||
MaxBandwidth: bandwidth,
|
||||
Mappings: mappings,
|
||||
@@ -104,9 +104,9 @@ func TestCappedBandwidthDoesNotStarveLightStreams(t *testing.T) {
|
||||
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
|
||||
startHub(t, port, psk)
|
||||
dest := newMockDest(t, modeEcho)
|
||||
// maxConn=1 forces every stream onto one worker conn, so nothing but the
|
||||
// shaper is deciding who gets the link.
|
||||
startClientWithBandwidth(t, hubAddr, psk, 1, "1MB/s", []client.Mapping{
|
||||
// Each player has its own worker conn; the shaper is still the only thing
|
||||
// splitting the shared uplink budget.
|
||||
startClientWithBandwidth(t, hubAddr, psk, 4, "1MB/s", []client.Mapping{
|
||||
{Pattern: "mc.local", Destination: dest.addr},
|
||||
})
|
||||
|
||||
|
||||
+17
-20
@@ -13,19 +13,19 @@ import (
|
||||
|
||||
// startClient builds and starts an in-process client against the hub, with the
|
||||
// given mappings, returning the running client.
|
||||
func startClient(t *testing.T, hubAddr, psk string, maxConn int, mappings []client.Mapping) *client.Client {
|
||||
func startClient(t *testing.T, hubAddr, psk string, maxTunnels int, mappings []client.Mapping) *client.Client {
|
||||
t.Helper()
|
||||
return startClientWithPing(t, hubAddr, psk, maxConn, 20000, mappings)
|
||||
return startClientWithPing(t, hubAddr, psk, maxTunnels, 20000, mappings)
|
||||
}
|
||||
|
||||
// startClientWithPing is startClient with an explicit heartbeat interval, for
|
||||
// tests that need liveness detection to trigger quickly.
|
||||
func startClientWithPing(t *testing.T, hubAddr, psk string, maxConn, pingMs int, mappings []client.Mapping) *client.Client {
|
||||
func startClientWithPing(t *testing.T, hubAddr, psk string, maxTunnels, pingMs int, mappings []client.Mapping) *client.Client {
|
||||
t.Helper()
|
||||
return startClientCfg(t, &client.Config{
|
||||
Server: hubAddr,
|
||||
PSK: psk,
|
||||
MaxConn: maxConn,
|
||||
MaxTunnels: maxTunnels,
|
||||
PingIntervalMs: pingMs,
|
||||
Mappings: mappings,
|
||||
})
|
||||
@@ -107,7 +107,7 @@ func TestRegexPatternMatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLargeTransfer pushes a multi-megabyte payload both ways to exercise mux
|
||||
// TestLargeTransfer pushes a multi-megabyte payload both ways to exercise
|
||||
// framing and back-pressure.
|
||||
func TestLargeTransfer(t *testing.T) {
|
||||
const psk = "e2e-large"
|
||||
@@ -147,18 +147,17 @@ func TestLargeTransfer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestConcurrentStreamsUseMultipleConns confirms the least-loaded allocator
|
||||
// grows the pool breadth-first: concurrent streams spread over several worker
|
||||
// connections rather than stacking on one, without ever exceeding maxConn.
|
||||
func TestConcurrentStreamsUseMultipleConns(t *testing.T) {
|
||||
// TestEachPlayerGetsOwnWorker confirms the 1:1 rule: N concurrent players
|
||||
// produce N worker connections, and the maxTunnels cap is honoured.
|
||||
func TestEachPlayerGetsOwnWorker(t *testing.T) {
|
||||
const psk = "e2e-concurrent"
|
||||
const n = 20
|
||||
const maxConn = 4
|
||||
const n = 8
|
||||
const maxTunnels = 16
|
||||
port := freePort(t)
|
||||
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
|
||||
startHub(t, port, psk)
|
||||
dest := newMockDest(t, modeEcho)
|
||||
c := startClient(t, hubAddr, psk, maxConn, []client.Mapping{
|
||||
c := startClient(t, hubAddr, psk, maxTunnels, []client.Mapping{
|
||||
{Pattern: "mc.local", Destination: dest.addr},
|
||||
})
|
||||
|
||||
@@ -168,8 +167,6 @@ func TestConcurrentStreamsUseMultipleConns(t *testing.T) {
|
||||
_ = pc.Close()
|
||||
}
|
||||
}()
|
||||
// Establish streams sequentially so allocation is deterministic; keep them
|
||||
// all open to hold streams active.
|
||||
for i := 0; i < n; i++ {
|
||||
pc := dialPlayer(t, hubAddr, "mc.local")
|
||||
playerEcho(t, pc, []byte(fmt.Sprintf("hello-%d", i)))
|
||||
@@ -177,13 +174,13 @@ func TestConcurrentStreamsUseMultipleConns(t *testing.T) {
|
||||
}
|
||||
|
||||
got := c.WorkerConnCount()
|
||||
if got < 2 {
|
||||
t.Fatalf("expected >=2 worker conns for %d concurrent streams, got %d", n, got)
|
||||
if got != n {
|
||||
t.Fatalf("expected %d worker conns for %d players, got %d", n, n, got)
|
||||
}
|
||||
if got > maxConn {
|
||||
t.Fatalf("worker conns %d exceed maxConn %d", got, maxConn)
|
||||
if got > maxTunnels {
|
||||
t.Fatalf("worker conns %d exceed maxTunnels %d", got, maxTunnels)
|
||||
}
|
||||
t.Logf("%d concurrent streams spread over %d worker conn(s)", n, got)
|
||||
t.Logf("%d players on %d worker conn(s)", n, got)
|
||||
}
|
||||
|
||||
// TestProxyProtocol checks that the client prepends a correct HAProxy v2 header
|
||||
@@ -278,7 +275,7 @@ func TestBadPSK(t *testing.T) {
|
||||
cfg := &client.Config{
|
||||
Server: hubAddr,
|
||||
PSK: "totally-wrong",
|
||||
MaxConn: 2,
|
||||
MaxTunnels: 2,
|
||||
PingIntervalMs: 20000,
|
||||
Mappings: []client.Mapping{{Pattern: "mc.local", Destination: dest.addr}},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/redapricot/client"
|
||||
)
|
||||
|
||||
// expectClosedSoon fails unless conn is closed (or reset) within d. A player
|
||||
// that entered pending stays open until pendingTimeoutMs, so a fast EOF is
|
||||
// how we tell the limiter dropped the socket before match-side bookkeeping.
|
||||
func expectClosedSoon(t *testing.T, conn net.Conn, d time.Duration) {
|
||||
t.Helper()
|
||||
_ = conn.SetReadDeadline(time.Now().Add(d))
|
||||
n, err := conn.Read(make([]byte, 16))
|
||||
if err == nil {
|
||||
t.Fatalf("expected the hub to close the player, read %d bytes", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlayerBurstDropsExtraHandshakes: more arrivals than playerBurst from the
|
||||
// same IP are closed after the handshake and never become pending.
|
||||
func TestPlayerBurstDropsExtraHandshakes(t *testing.T) {
|
||||
const psk = "e2e-rate-burst"
|
||||
port := freePort(t)
|
||||
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
|
||||
startHubCfg(t, port, psk, map[string]any{
|
||||
"playerRatePerSec": 1,
|
||||
"playerBurst": 2,
|
||||
"maxPlayersPerIp": 64,
|
||||
})
|
||||
dest := newMockDest(t, modeEcho)
|
||||
startClient(t, hubAddr, psk, 8, []client.Mapping{
|
||||
{Pattern: "mc.local", Destination: dest.addr},
|
||||
})
|
||||
|
||||
kept := make([]net.Conn, 0, 2)
|
||||
defer func() {
|
||||
for _, c := range kept {
|
||||
_ = c.Close()
|
||||
}
|
||||
}()
|
||||
for i := 0; i < 2; i++ {
|
||||
pc := dialPlayer(t, hubAddr, "mc.local")
|
||||
playerEcho(t, pc, []byte(fmt.Sprintf("ok-%d", i)))
|
||||
kept = append(kept, pc)
|
||||
}
|
||||
|
||||
// Tokens spent, first two still held: extras must die well inside pendingTimeout.
|
||||
for i := 0; i < 2; i++ {
|
||||
extra := dialPlayer(t, hubAddr, "mc.local")
|
||||
expectClosedSoon(t, extra, 1500*time.Millisecond)
|
||||
_ = extra.Close()
|
||||
}
|
||||
|
||||
// The admitted players are unaffected.
|
||||
playerEcho(t, kept[0], []byte("still-here"))
|
||||
}
|
||||
|
||||
// TestMaxPlayersPerIpCapsConcurrentSockets: a second player from the same IP
|
||||
// is refused while the first is still open, and admitted again after it closes.
|
||||
func TestMaxPlayersPerIpCapsConcurrentSockets(t *testing.T) {
|
||||
const psk = "e2e-rate-conc"
|
||||
port := freePort(t)
|
||||
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
|
||||
startHubCfg(t, port, psk, map[string]any{
|
||||
"playerRatePerSec": 0,
|
||||
"playerBurst": 16,
|
||||
"maxPlayersPerIp": 1,
|
||||
})
|
||||
dest := newMockDest(t, modeEcho)
|
||||
startClient(t, hubAddr, psk, 4, []client.Mapping{
|
||||
{Pattern: "mc.local", Destination: dest.addr},
|
||||
})
|
||||
|
||||
first := dialPlayer(t, hubAddr, "mc.local")
|
||||
defer first.Close()
|
||||
playerEcho(t, first, []byte("first"))
|
||||
|
||||
second := dialPlayer(t, hubAddr, "mc.local")
|
||||
expectClosedSoon(t, second, 1500*time.Millisecond)
|
||||
_ = second.Close()
|
||||
|
||||
playerEcho(t, first, []byte("still-first"))
|
||||
_ = first.Close()
|
||||
// The closeHandler runs on the hub event loop; give it a beat to release.
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
third := dialPlayer(t, hubAddr, "mc.local")
|
||||
defer third.Close()
|
||||
playerEcho(t, third, []byte("after-release"))
|
||||
}
|
||||
|
||||
// TestUnmatchedHostConsumesRateBudget: a hostname scan is not a free flood.
|
||||
// Two unmatched handshakes spend the burst, so a later matching player is
|
||||
// also dropped.
|
||||
func TestUnmatchedHostConsumesRateBudget(t *testing.T) {
|
||||
const psk = "e2e-rate-scan"
|
||||
port := freePort(t)
|
||||
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
|
||||
startHubCfg(t, port, psk, map[string]any{
|
||||
"playerRatePerSec": 1,
|
||||
"playerBurst": 2,
|
||||
"maxPlayersPerIp": 64,
|
||||
})
|
||||
dest := newMockDest(t, modeEcho)
|
||||
startClient(t, hubAddr, psk, 4, []client.Mapping{
|
||||
{Pattern: "mc.local", Destination: dest.addr},
|
||||
})
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
miss := dialPlayer(t, hubAddr, "no.such.host")
|
||||
expectClosedSoon(t, miss, 1500*time.Millisecond)
|
||||
_ = miss.Close()
|
||||
}
|
||||
|
||||
matched := dialPlayer(t, hubAddr, "mc.local")
|
||||
expectClosedSoon(t, matched, 1500*time.Millisecond)
|
||||
_ = matched.Close()
|
||||
}
|
||||
|
||||
// TestIntent17IgnoresPlayerLimiter: the control session and worker conns are
|
||||
// Intent 17, so a limiter tight enough to refuse a second player must not
|
||||
// prevent the client from connecting or taking over the first player.
|
||||
func TestIntent17IgnoresPlayerLimiter(t *testing.T) {
|
||||
const psk = "e2e-rate-intent17"
|
||||
port := freePort(t)
|
||||
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
|
||||
startHubCfg(t, port, psk, map[string]any{
|
||||
"playerRatePerSec": 1,
|
||||
"playerBurst": 1,
|
||||
"maxPlayersPerIp": 1,
|
||||
})
|
||||
dest := newMockDest(t, modeEcho)
|
||||
startClient(t, hubAddr, psk, 4, []client.Mapping{
|
||||
{Pattern: "mc.local", Destination: dest.addr},
|
||||
})
|
||||
|
||||
pc := dialPlayer(t, hubAddr, "mc.local")
|
||||
defer pc.Close()
|
||||
playerEcho(t, pc, []byte("intent17-ok"))
|
||||
}
|
||||
|
||||
// TestPlayerLimiterOffSwitch: both knobs at 0 restore phase-1 behaviour —
|
||||
// many players from 127.0.0.1 all get through.
|
||||
func TestPlayerLimiterOffSwitch(t *testing.T) {
|
||||
const psk = "e2e-rate-off"
|
||||
const n = 6
|
||||
port := freePort(t)
|
||||
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
|
||||
startHubCfg(t, port, psk, map[string]any{
|
||||
"playerRatePerSec": 0,
|
||||
"maxPlayersPerIp": 0,
|
||||
})
|
||||
dest := newMockDest(t, modeEcho)
|
||||
startClient(t, hubAddr, psk, n, []client.Mapping{
|
||||
{Pattern: "mc.local", Destination: dest.addr},
|
||||
})
|
||||
|
||||
conns := make([]net.Conn, 0, n)
|
||||
defer func() {
|
||||
for _, c := range conns {
|
||||
_ = c.Close()
|
||||
}
|
||||
}()
|
||||
for i := 0; i < n; i++ {
|
||||
pc := dialPlayer(t, hubAddr, "mc.local")
|
||||
playerEcho(t, pc, []byte(fmt.Sprintf("off-%d", i)))
|
||||
conns = append(conns, pc)
|
||||
}
|
||||
}
|
||||
+6
-7
@@ -131,10 +131,9 @@ func TestResumePreservesByteStream(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestResumeWithConcurrentStreams covers the failure the single-stream test
|
||||
// cannot reach: stream ids restart at 1 on every conn, so after a reattach two
|
||||
// players can hold the same id on different conns. A binding that updates the
|
||||
// conn and the id separately will credit or reset the wrong player's stream, and
|
||||
// that only shows up when a second stream is there to be corrupted.
|
||||
// cannot reach: two independent tunnels resume at once. A torn rebind that
|
||||
// writes a frame onto the wrong conn would credit or reset the other player's
|
||||
// tunnel, and that only shows up when a second player is there to be corrupted.
|
||||
func TestResumeWithConcurrentStreams(t *testing.T) {
|
||||
const psk = "e2e-resume-multi"
|
||||
hubPort := freePort(t)
|
||||
@@ -143,7 +142,7 @@ func TestResumeWithConcurrentStreams(t *testing.T) {
|
||||
dest := newMockDest(t, modeEcho)
|
||||
|
||||
relay := newBlackholeRelay(t, hubAddr)
|
||||
startClientWithPing(t, relay.addr, psk, 2, 400, []client.Mapping{
|
||||
startClientWithPing(t, relay.addr, psk, 4, 400, []client.Mapping{
|
||||
{Pattern: "mc.local", Destination: dest.addr},
|
||||
})
|
||||
|
||||
@@ -197,7 +196,7 @@ func TestResumeDisabledClosesImmediately(t *testing.T) {
|
||||
startClientCfg(t, &client.Config{
|
||||
Server: relay.addr,
|
||||
PSK: psk,
|
||||
MaxConn: 1,
|
||||
MaxTunnels: 1,
|
||||
PingIntervalMs: 400,
|
||||
StreamResume: &off,
|
||||
Mappings: []client.Mapping{{Pattern: "mc.local", Destination: dest.addr}},
|
||||
@@ -237,7 +236,7 @@ func TestResumeGraceExpiryClosesPlayer(t *testing.T) {
|
||||
startClientCfg(t, &client.Config{
|
||||
Server: relay.addr,
|
||||
PSK: psk,
|
||||
MaxConn: 1,
|
||||
MaxTunnels: 1,
|
||||
PingIntervalMs: 400,
|
||||
ResumeGraceMs: 2000,
|
||||
Mappings: []client.Mapping{{Pattern: "mc.local", Destination: dest.addr}},
|
||||
|
||||
@@ -27,17 +27,15 @@ func echoRounds(t *testing.T, hubAddr string, rounds, size int) {
|
||||
}
|
||||
|
||||
// TestSlowPlayerDoesNotStallOthers: a player that stops reading while megabytes
|
||||
// are echoed back to it must not stall another stream on the same worker
|
||||
// connection (maxConn=1 forces sharing). Before per-stream flow control, the
|
||||
// hub paused the whole worker socket once that player's write queue filled,
|
||||
// freezing every other stream's downstream data.
|
||||
// are echoed back to it must not stall another player. Flow control and the
|
||||
// 1:1 worker model keep a jammed tunnel from taking anyone else with it.
|
||||
func TestSlowPlayerDoesNotStallOthers(t *testing.T) {
|
||||
const psk = "e2e-slowplayer"
|
||||
port := freePort(t)
|
||||
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
|
||||
startHub(t, port, psk)
|
||||
dest := newMockDest(t, modeEcho)
|
||||
startClient(t, hubAddr, psk, 1, []client.Mapping{
|
||||
startClient(t, hubAddr, psk, 2, []client.Mapping{
|
||||
{Pattern: "mc.local", Destination: dest.addr},
|
||||
})
|
||||
|
||||
@@ -52,7 +50,7 @@ func TestSlowPlayerDoesNotStallOthers(t *testing.T) {
|
||||
// Let the slow stream jam: its flow-control window fills and stays full.
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// The fast player shares the single worker conn and must still round-trip.
|
||||
// The fast player has its own worker conn and must still round-trip.
|
||||
echoRounds(t, hubAddr, 10, 8*1024)
|
||||
}
|
||||
|
||||
@@ -67,7 +65,7 @@ func TestSlowDestinationDoesNotStallOthers(t *testing.T) {
|
||||
startHub(t, port, psk)
|
||||
dest := newMockDest(t, modeEcho)
|
||||
hole := newMockDest(t, modeBlackhole)
|
||||
startClient(t, hubAddr, psk, 1, []client.Mapping{
|
||||
startClient(t, hubAddr, psk, 2, []client.Mapping{
|
||||
{Pattern: "mc.local", Destination: dest.addr},
|
||||
{Pattern: "hole.local", Destination: hole.addr},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user