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>
143 lines
5.0 KiB
Go
143 lines
5.0 KiB
Go
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package session
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Grant is the spec §3.4 "asymmetric grant": the ONLY thing that lets the server send more than
|
|
// it receives. Without one, every response is capped at the request size, which is what keeps an
|
|
// unauthenticated observer from using this server as a reflector/amplifier.
|
|
//
|
|
// A grant is created by an authenticated control-plane action (§5) for one session, and is bounded
|
|
// three ways: total bytes, send rate, and wall-clock expiry. It is consumed as the data plane
|
|
// sends; when the budget runs out the send stops, so a bug in an action cannot turn into an
|
|
// unbounded flood.
|
|
type Grant struct {
|
|
ActionID string
|
|
// Destination is fixed at creation to the session's observed data-plane source — a grant can
|
|
// never be pointed somewhere else, so it cannot be used to attack a third party.
|
|
Dest string
|
|
MaxBytes int64
|
|
MaxKbps int
|
|
ExpiresAt time.Time
|
|
|
|
mu sync.Mutex
|
|
sentBytes int64
|
|
started time.Time
|
|
}
|
|
|
|
// GrantLimits are the server-side ceilings an action may not exceed, independent of what the
|
|
// client asks for.
|
|
type GrantLimits struct {
|
|
MaxBytes int64
|
|
MaxKbps int
|
|
MaxHold time.Duration
|
|
}
|
|
|
|
var DefaultGrantLimits = GrantLimits{
|
|
MaxBytes: 8 << 20, // 8 MiB per action
|
|
MaxKbps: 50_000, // matches the profile's advertised max_kbps
|
|
MaxHold: 30 * time.Second, // an action must finish inside this window
|
|
}
|
|
|
|
// NewGrant clamps the request to the server's limits and binds it to the session's observed
|
|
// data-plane source. Returns nil when the session has no observed source yet — refusing to send
|
|
// anywhere we have not verifiably received from is the whole point.
|
|
func (s *Session) NewGrant(actionID string, wantBytes int64, wantKbps int, lim GrantLimits) *Grant {
|
|
dest := s.DataSource()
|
|
if !dest.IsValid() {
|
|
return nil
|
|
}
|
|
if wantBytes <= 0 || wantBytes > lim.MaxBytes {
|
|
wantBytes = lim.MaxBytes
|
|
}
|
|
if wantKbps <= 0 || wantKbps > lim.MaxKbps {
|
|
wantKbps = lim.MaxKbps
|
|
}
|
|
g := &Grant{
|
|
ActionID: actionID, Dest: dest.String(),
|
|
MaxBytes: wantBytes, MaxKbps: wantKbps,
|
|
ExpiresAt: time.Now().Add(lim.MaxHold),
|
|
started: time.Now(),
|
|
}
|
|
s.mu.Lock()
|
|
s.grants = append(s.grants, g)
|
|
s.mu.Unlock()
|
|
return g
|
|
}
|
|
|
|
// Allow reports whether n more bytes may be sent now, consuming the budget when they may. It
|
|
// enforces the byte ceiling, the expiry, and the average rate (by refusing early sends rather
|
|
// than sleeping, so callers stay in control of pacing).
|
|
func (g *Grant) Allow(n int) bool {
|
|
ok, _ := g.TryAllow(n)
|
|
return ok
|
|
}
|
|
|
|
// Refusal reasons from TryAllow. The distinction is not cosmetic: "too fast just now" is
|
|
// transient and a caller should pace and carry on, while "budget" and "expired" are terminal and
|
|
// a caller that keeps trying is only wasting its own run.
|
|
const (
|
|
RefusalNone = ""
|
|
RefusalBudget = "budget"
|
|
RefusalExpired = "expired"
|
|
RefusalRate = "rate"
|
|
)
|
|
|
|
// TryAllow reports whether n more bytes may be sent now, consuming the budget when they may, and
|
|
// says why not when they may not.
|
|
//
|
|
// The rate limit is a token bucket: allowance = burst + rate x elapsed. An earlier version
|
|
// exempted the first 50 ms from the check entirely, meaning to be lenient at startup. The effect
|
|
// was the opposite - a sender could dump an unbounded burst into that window, and the moment the
|
|
// check switched on it compared those bytes against 50 ms worth of allowance and refused
|
|
// everything until real time caught up. A sustained send died about fifty milliseconds in, having
|
|
// looked perfectly fine in every short test. A bucket has no such cliff: it is smooth from t=0.
|
|
func (g *Grant) TryAllow(n int) (bool, string) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
if time.Now().After(g.ExpiresAt) {
|
|
return false, RefusalExpired
|
|
}
|
|
if g.sentBytes+int64(n) > g.MaxBytes {
|
|
return false, RefusalBudget
|
|
}
|
|
// kbps -> bytes/s is kbps*1000/8 = kbps*125.
|
|
bytesPerSec := float64(g.MaxKbps) * 125
|
|
elapsed := time.Since(g.started).Seconds()
|
|
allowed := burstBytes(bytesPerSec) + bytesPerSec*elapsed
|
|
if float64(g.sentBytes+int64(n)) > allowed {
|
|
return false, RefusalRate
|
|
}
|
|
g.sentBytes += int64(n)
|
|
return true, RefusalNone
|
|
}
|
|
|
|
// burstBytes is the bucket's depth: 100 ms of the allowed rate, floored at a single ordinary
|
|
// datagram.
|
|
//
|
|
// The floor exists only so that one packet is never refused outright by a very slow grant — it is
|
|
// deliberately one datagram and not more. A generous floor would undo the rate ceiling at low
|
|
// rates: at 8 kbps a 64 KB burst is sixty-four seconds' worth, which is exactly the instant dump
|
|
// the ceiling is there to prevent. One datagram is 1.5 seconds' worth at that rate and nothing at
|
|
// any realistic one.
|
|
func burstBytes(bytesPerSec float64) float64 {
|
|
const oneDatagram = 1500
|
|
b := bytesPerSec * 0.1
|
|
if b < oneDatagram {
|
|
b = oneDatagram
|
|
}
|
|
return b
|
|
}
|
|
|
|
// Sent returns how many bytes this grant has consumed.
|
|
func (g *Grant) Sent() int64 {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
return g.sentBytes
|
|
}
|