148 lines
4.7 KiB
Go
148 lines
4.7 KiB
Go
package e2e
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/iceBear67/redapricot/client"
|
|
)
|
|
|
|
// 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 {
|
|
t.Helper()
|
|
cfg := &client.Config{
|
|
Server: hubAddr,
|
|
PSK: psk,
|
|
MaxConn: maxConn,
|
|
PingIntervalMs: 20000,
|
|
MaxBandwidth: bandwidth,
|
|
Mappings: mappings,
|
|
}
|
|
c := client.New(cfg)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(func() {
|
|
cancel()
|
|
c.Close()
|
|
})
|
|
if err := c.Start(ctx); err != nil {
|
|
t.Fatalf("client start: %v", err)
|
|
}
|
|
time.Sleep(200 * time.Millisecond)
|
|
return c
|
|
}
|
|
|
|
// drain reads a connection until it fails, so a greedy player keeps pulling
|
|
// bytes instead of stalling on its own receive window.
|
|
func drain(conn net.Conn) {
|
|
go func() { _, _ = io.Copy(io.Discard, conn) }()
|
|
}
|
|
|
|
// TestBandwidthCapIsEnforced pushes a payload that cannot fit in the burst and
|
|
// asserts the transfer takes at least as long as the configured rate implies.
|
|
// This is the first timing assertion in the suite, so the bounds are wide: the
|
|
// unshaped path completes this in well under a second, making the lower bound
|
|
// an unambiguous signal rather than a tight measurement.
|
|
func TestBandwidthCapIsEnforced(t *testing.T) {
|
|
const psk = "e2e-bwcap"
|
|
port := freePort(t)
|
|
hubAddr := fmt.Sprintf("127.0.0.1:%d", port)
|
|
startHub(t, port, psk)
|
|
dest := newMockDest(t, modeEcho)
|
|
startClientWithBandwidth(t, hubAddr, psk, 1, "1MB/s", []client.Mapping{
|
|
{Pattern: "mc.local", Destination: dest.addr},
|
|
})
|
|
|
|
pc := dialPlayer(t, hubAddr, "mc.local")
|
|
defer pc.Close()
|
|
|
|
payload := make([]byte, 2*1024*1024)
|
|
for i := range payload {
|
|
payload[i] = byte(i*31 + 7)
|
|
}
|
|
|
|
start := time.Now()
|
|
writeErr := make(chan error, 1)
|
|
go func() {
|
|
_, err := pc.Write(payload)
|
|
writeErr <- err
|
|
}()
|
|
got := make([]byte, len(payload))
|
|
_ = pc.SetReadDeadline(time.Now().Add(60 * time.Second))
|
|
if _, err := io.ReadFull(pc, got); err != nil {
|
|
t.Fatalf("read echo: %v", err)
|
|
}
|
|
elapsed := time.Since(start)
|
|
if err := <-writeErr; err != nil {
|
|
t.Fatalf("write payload: %v", err)
|
|
}
|
|
|
|
// 2 MiB at 1 MiB/s, minus the 200 KiB the bucket has banked, is ~1.8 s.
|
|
const floor = 1200 * time.Millisecond
|
|
if elapsed < floor {
|
|
t.Errorf("2 MiB echoed back in %v under a 1MB/s cap; expected at least %v, so the cap is not taking effect", elapsed, floor)
|
|
}
|
|
if elapsed > 30*time.Second {
|
|
t.Errorf("transfer took %v, far beyond the ~1.8s the rate implies", elapsed)
|
|
}
|
|
}
|
|
|
|
// TestCappedBandwidthDoesNotStarveLightStreams is the point of the fair queue:
|
|
// with the link deliberately capped and three players saturating it, a fourth
|
|
// player exchanging small messages must keep round-tripping promptly. A plain
|
|
// FIFO token bucket would leave it queued behind the heavy players' backlog,
|
|
// which for a real Minecraft client means a keepalive timeout — the exact
|
|
// failure this feature exists to prevent.
|
|
func TestCappedBandwidthDoesNotStarveLightStreams(t *testing.T) {
|
|
const psk = "e2e-bwfair"
|
|
port := freePort(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{
|
|
{Pattern: "mc.local", Destination: dest.addr},
|
|
})
|
|
|
|
// Three greedy players: each writes megabytes and keeps draining the echo,
|
|
// so they compete for the cap for the whole test rather than parking on
|
|
// their own flow-control windows.
|
|
for i := 0; i < 3; i++ {
|
|
heavy := dialPlayer(t, hubAddr, "mc.local")
|
|
defer heavy.Close()
|
|
drain(heavy)
|
|
go func() { _, _ = heavy.Write(make([]byte, 8*1024*1024)) }()
|
|
}
|
|
time.Sleep(500 * time.Millisecond) // let them saturate the shaper
|
|
|
|
light := dialPlayer(t, hubAddr, "mc.local")
|
|
defer light.Close()
|
|
payload := make([]byte, 4*1024)
|
|
for i := range payload {
|
|
payload[i] = byte(i*13 + 5)
|
|
}
|
|
|
|
var worst time.Duration
|
|
for i := 0; i < 10; i++ {
|
|
start := time.Now()
|
|
playerEcho(t, light, payload)
|
|
if d := time.Since(start); d > worst {
|
|
worst = d
|
|
}
|
|
}
|
|
|
|
// Fair sharing puts a 4 KiB round at roughly 4 KiB / (1 MiB/s ÷ 4) ≈ 16 ms of
|
|
// link time. The bound is two orders of magnitude looser so only genuine
|
|
// starvation trips it.
|
|
const limit = 3 * time.Second
|
|
if worst > limit {
|
|
t.Errorf("slowest small round-trip took %v (limit %v); heavy streams are crowding out the light one", worst, limit)
|
|
}
|
|
}
|