Files
mrambossekandClaude Fable 5 3c9af04e6f
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 32s
server-release / release (push) Successful in 33s
grant: replace the rate check with a token bucket
The live throughput test found it: a 3-second run delivered 104 packets and
stopped after 50 milliseconds.

The rate check exempted the first 50 ms entirely, meaning to be lenient at
startup. The effect was the opposite. A sender could dump an unbounded burst
into that free window, and the instant the check switched on it compared those
bytes against 50 ms worth of allowance and refused everything until real time
caught up. Every short test passed — downtrain sends 50 packets, big_send seven
— and every sustained send died about fifty milliseconds in.

A token bucket (allowance = burst + rate x elapsed) has no such cliff; it is
smooth from t=0. The burst is 100 ms of the allowed rate, floored at one
ordinary datagram so a single packet is never refused outright. The floor is
deliberately one datagram: at 8 kbps a 64 KB floor would be sixty-four seconds'
worth, which is precisely the instant dump the ceiling exists to prevent. The
existing rate test caught that when I first tried it, and it was right.

Second half of the same bug: callers treated any refusal as terminal. TryAllow
now says why, so a sender can pace through a transient "too fast just now" and
still stop dead on a spent budget or an expired grant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 14:10:30 +02:00

159 lines
5.1 KiB
Go

// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package session
import (
"net/netip"
"testing"
"time"
)
func sessionWithSource(t *testing.T) *Session {
t.Helper()
m := NewManager(time.Minute)
s, _, err := m.New("dev", "cred", netip.MustParseAddr("127.0.0.1"))
if err != nil {
t.Fatal(err)
}
s.NoteDataSource(netip.MustParseAddrPort("127.0.0.1:5000"))
return s
}
// Without an observed data-plane source there is nowhere verified to send, so no grant may exist.
func TestNoGrantWithoutObservedSource(t *testing.T) {
m := NewManager(time.Minute)
s, _, _ := m.New("dev", "cred", netip.MustParseAddr("127.0.0.1"))
if g := s.NewGrant("a1", 1000, 1000, DefaultGrantLimits); g != nil {
t.Fatal("granted a send with no verified destination")
}
}
func TestGrantIsBoundToObservedSourceAndClamped(t *testing.T) {
s := sessionWithSource(t)
g := s.NewGrant("a1", 1<<40, 1<<30, DefaultGrantLimits) // absurd request
if g == nil {
t.Fatal("expected a grant")
}
if g.Dest != "127.0.0.1:5000" {
t.Fatalf("grant destination = %s, want the observed source", g.Dest)
}
if g.MaxBytes != DefaultGrantLimits.MaxBytes || g.MaxKbps != DefaultGrantLimits.MaxKbps {
t.Fatalf("client request was not clamped to server limits: %d bytes / %d kbps",
g.MaxBytes, g.MaxKbps)
}
}
// The byte budget must actually stop sending — this is the anti-amplification guarantee.
func TestGrantStopsAtByteBudget(t *testing.T) {
s := sessionWithSource(t)
g := s.NewGrant("a1", 1000, 0, DefaultGrantLimits)
sent := 0
for i := 0; i < 100; i++ {
if !g.Allow(100) {
break
}
sent += 100
}
if sent != 1000 {
t.Fatalf("sent %d bytes, want exactly the 1000-byte budget", sent)
}
if g.Allow(1) {
t.Fatal("grant allowed a send after the budget was exhausted")
}
if g.Sent() != 1000 {
t.Fatalf("Sent() = %d, want 1000", g.Sent())
}
}
func TestExpiredGrantRefuses(t *testing.T) {
s := sessionWithSource(t)
g := s.NewGrant("a1", 10_000, 0, GrantLimits{MaxBytes: 10_000, MaxKbps: 1000, MaxHold: time.Millisecond})
time.Sleep(5 * time.Millisecond)
if g.Allow(10) {
t.Fatal("expired grant still allowed a send")
}
}
// The rate ceiling must throttle a burst that is well inside the byte budget.
func TestGrantEnforcesRate(t *testing.T) {
s := sessionWithSource(t)
// 8 kbps = 1000 bytes/s. A burst far beyond one second's worth must be refused.
g := s.NewGrant("a1", 1<<20, 8, DefaultGrantLimits)
time.Sleep(60 * time.Millisecond) // let the rate window open a little
sent := 0
for i := 0; i < 1000; i++ {
if !g.Allow(100) {
break
}
sent += 100
}
if sent > 5000 {
t.Fatalf("rate limit let %d bytes through in ~60ms at 8kbps", sent)
}
}
// The bug this pins: the rate check used to exempt the first 50 ms entirely, so a sender could
// dump an unbounded burst into that window and then be refused for as long as it took real time
// to catch up. Every short test passed; a sustained send died about fifty milliseconds in. A
// token bucket has no such cliff, and the property that matters is that a sender pacing *at* the
// allowed rate is never refused for long.
func TestSustainedSendAtTheAllowedRateIsNotCutOff(t *testing.T) {
s := sessionWithSource(t)
const kbps = 8000 // 1 MB/s
const packet = 1200 // bytes
g := s.NewGrant("a1", 8<<20, kbps, DefaultGrantLimits)
// Pace at the allowed rate for a short run and count how much got through. A correct
// limiter passes essentially all of it; the old one stopped almost immediately.
perPacket := time.Duration(float64(packet) / (float64(kbps) * 125) * float64(time.Second))
deadline := time.Now().Add(300 * time.Millisecond)
sent, refusals := 0, 0
for time.Now().Before(deadline) {
if ok, why := g.TryAllow(packet); ok {
sent += packet
} else if why == RefusalRate {
refusals++
} else {
t.Fatalf("unexpected terminal refusal %q after %d bytes", why, sent)
}
time.Sleep(perPacket)
}
// 300 ms at 1 MB/s is ~300 KB. Allow generous slack for scheduler granularity, but a run
// that delivered only a few packets means the limiter cut it off.
if sent < 100_000 {
t.Fatalf("a sender pacing at the allowed rate got only %d bytes through in 300ms "+
"(%d rate refusals) — the limiter is cutting off sustained sends", sent, refusals)
}
}
// The other half: a rate refusal must be distinguishable from a spent budget, because one is
// transient and one is terminal, and a caller that cannot tell them apart either gives up early
// or spins forever.
func TestRefusalReasonsAreDistinguishable(t *testing.T) {
s := sessionWithSource(t)
// Budget: tiny ceiling, plenty of rate.
g := s.NewGrant("a1", 1000, 100_000, DefaultGrantLimits)
for i := 0; i < 20; i++ {
g.TryAllow(100)
}
if ok, why := g.TryAllow(100); ok || why != RefusalBudget {
t.Errorf("spent budget reported as ok=%v why=%q, want %q", ok, why, RefusalBudget)
}
// Rate: huge ceiling, minimal rate, so only the bucket can refuse.
g2 := s.NewGrant("a2", 1<<20, 8, DefaultGrantLimits)
sawRate := false
for i := 0; i < 100; i++ {
if ok, why := g2.TryAllow(1000); !ok && why == RefusalRate {
sawRate = true
break
}
}
if !sawRate {
t.Error("a sender far above the rate ceiling never got a rate refusal")
}
}