232 lines
6.6 KiB
Go
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)
|
|
}
|
|
}
|