Files
redapricot/client/shaper_test.go
T
2026-08-15 17:31:35 +08:00

232 lines
6.6 KiB
Go

package client
import (
"sync"
"sync/atomic"
"testing"
"time"
)
func TestParseBandwidth(t *testing.T) {
cases := []struct {
in string
want int64
}{
{"", 0},
{"20mbps", 2_500_000},
{"20Mbps", 2_500_000},
{"20 mbps", 2_500_000},
{"1.5mbit", 187_500},
{"512kbps", 64_000},
{"1gbps", 125_000_000},
{"2MB/s", 2 << 20},
{"500kb/s", 500 << 10},
{"1GB/s", 1 << 30},
{"8bps", 1},
{"4096b/s", 4096},
{"1500000", 1_500_000}, // bare number is already bytes/sec
}
for _, c := range cases {
got, err := parseBandwidth(c.in)
if err != nil {
t.Errorf("parseBandwidth(%q): unexpected error %v", c.in, err)
continue
}
if got != c.want {
t.Errorf("parseBandwidth(%q) = %d, want %d", c.in, got, c.want)
}
}
for _, bad := range []string{"fast", "20megabits", "-5mbps", "0", "0mbps", "mbps", "20 mb ps"} {
if _, err := parseBandwidth(bad); err == nil {
t.Errorf("parseBandwidth(%q): expected an error", bad)
}
}
}
// A nil shaper is the "unlimited" case and must be safe on every path, because
// call sites deliberately do not branch on it.
func TestNilShaperIsUnlimited(t *testing.T) {
var sh *Shaper
if sh = NewShaper(0); sh != nil {
t.Fatal("NewShaper(0) should return nil")
}
if got := sh.chunkSize(); got != DataChunkSize {
t.Errorf("nil chunkSize = %d, want %d", got, DataChunkSize)
}
if !sh.Acquire(&shaperShare{}, 1<<20, nil) {
t.Error("nil Acquire should always succeed")
}
sh.Stop() // must not panic
}
// The virtual-time bookkeeping is what makes the shaper fair, so assert it
// directly. The rate is high enough that tokens never bind, leaving only the
// stamping under test — no timing, no flakiness.
func TestShaperIdleStreamCannotHoardCredit(t *testing.T) {
sh := NewShaper(1 << 30)
defer sh.Stop()
var heavy, light shaperShare
for i := 0; i < 10; i++ {
if !sh.Acquire(&heavy, 1000, nil) {
t.Fatal("acquire failed")
}
}
if heavy.vfinish != 10000 {
t.Errorf("heavy.vfinish = %v, want 10000", heavy.vfinish)
}
sh.mu.Lock()
vclock := sh.vclock
sh.mu.Unlock()
if vclock != 9000 {
t.Errorf("vclock = %v, want 9000 (the stamp of the last request served)", vclock)
}
// light was idle for all of it. Its stale vfinish of 0 must be clamped up to
// the current clock: it may not bank the virtual time it never spent, which
// is what would let it starve heavy on return.
if !sh.Acquire(&light, 1000, nil) {
t.Fatal("acquire failed")
}
if light.vfinish != vclock+1000 {
t.Errorf("light.vfinish = %v, want %v (clamped to the clock, not 1000)", light.vfinish, vclock+1000)
}
}
func TestShaperEnforcesRate(t *testing.T) {
const rate = 1 << 20 // 1 MiB/s
sh := NewShaper(rate)
defer sh.Stop()
var share shaperShare
const total = 512 << 10
const chunk = 8 << 10
start := time.Now()
for sent := 0; sent < total; sent += chunk {
if !sh.Acquire(&share, chunk, nil) {
t.Fatal("acquire failed")
}
}
elapsed := time.Since(start)
// The bucket starts full, so the burst is free and only the remainder is
// paced: (512 KiB - 200 KiB) / 1 MiB/s ≈ 300 ms. Bounds are wide on purpose.
if elapsed < 200*time.Millisecond {
t.Errorf("sent %d bytes at %d B/s in only %v; the cap is not being enforced", total, rate, elapsed)
}
if elapsed > time.Second {
t.Errorf("took %v, far longer than the ~300ms the rate implies", elapsed)
}
}
// An idle stream must be able to spend the banked burst at once, otherwise a
// player joining pays for the cap in visible chunk-loading latency.
func TestShaperAllowsBurst(t *testing.T) {
sh := NewShaper(1 << 20)
defer sh.Stop()
var share shaperShare
start := time.Now()
for i := 0; i < 6; i++ {
if !sh.Acquire(&share, 32<<10, nil) { // 192 KiB, inside the 200 KiB bucket
t.Fatal("acquire failed")
}
}
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
t.Errorf("burst of 192 KiB took %v; the bucket should have covered it instantly", elapsed)
}
}
// The point of the whole exercise: a stream that never stops asking must not
// crowd another one out.
func TestShaperSharesFairlyBetweenStreams(t *testing.T) {
sh := NewShaper(1 << 20)
defer sh.Stop()
stop := make(chan struct{})
var counts [2]atomic.Int64
var wg sync.WaitGroup
for i := range counts {
wg.Add(1)
go func(i int) {
defer wg.Done()
var share shaperShare
for {
if !sh.Acquire(&share, 4<<10, stop) {
return
}
counts[i].Add(4 << 10)
}
}(i)
}
// Spend the token bucket before measuring, the way the idle-credit test
// raises the rate so tokens never bind: isolate the property under test.
//
// While the bucket has tokens there is no queue to arbitrate — every request
// is granted the moment it arrives, and being the stream that is owed service
// only helps when both are enqueued at the same instant. The burst is
// therefore first-come-first-served by construction, and at 0.2s of
// transmission it is a quarter of a 600ms window, enough to swamp the result:
// a run where one goroutine happened to win the bucket landed at 520192 vs
// 315392, which is exactly "one took the whole burst, then the two split the
// remainder evenly".
//
// Fairness here is a steady-state property, and that is what matters in
// practice — the bucket is empty whenever the link is actually busy.
time.Sleep(250 * time.Millisecond)
counts[0].Store(0)
counts[1].Store(0)
time.Sleep(600 * time.Millisecond)
close(stop)
wg.Wait()
a, b := counts[0].Load(), counts[1].Load()
if a == 0 || b == 0 {
t.Fatalf("one stream was starved entirely: %d vs %d", a, b)
}
lo, hi := min(a, b), max(a, b)
t.Logf("steady-state split: %d vs %d bytes (%.3fx)", a, b, float64(hi)/float64(lo))
if float64(hi) > 1.35*float64(lo) {
t.Errorf("unfair split: %d vs %d bytes (>35%% apart)", a, b)
}
}
// A stream torn down while parked must release immediately and leave no trace
// in the queue, or its goroutine (and the Stream it closes over) leaks.
func TestShaperAcquireCancels(t *testing.T) {
sh := NewShaper(MinBandwidth) // 8 KiB/s: a parked request would wait seconds
defer sh.Stop()
var share shaperShare
if !sh.Acquire(&share, MinShaperBurst, nil) { // drain the bucket
t.Fatal("acquire failed")
}
cancel := make(chan struct{})
result := make(chan bool, 1)
go func() { result <- sh.Acquire(&share, 32<<10, cancel) }()
time.Sleep(50 * time.Millisecond)
close(cancel)
select {
case ok := <-result:
if ok {
t.Error("Acquire returned true after cancellation")
}
case <-time.After(time.Second):
t.Fatal("Acquire did not return after its cancel channel closed")
}
sh.mu.Lock()
n := len(sh.waiting)
sh.mu.Unlock()
if n != 0 {
t.Errorf("%d cancelled request(s) left in the queue", n)
}
}