Files
noitu/server/internal/wsapi/ratelimit.go
T
tiennm99 5935f5c879 feat(wsapi): add websocket server with rooms, turn timers, and bot play
One goroutine owns each room and its engine. The room goroutine starts before
anyone is seated and seating is itself a message, so reading run() is a complete
proof of the concurrency contract rather than a convention to uphold. The bot
searches a frozen copy of the board instead of the live engine.

Reads carry no deadline; liveness is ping-based, because a read timeout cannot
distinguish a healthy player idling in the lobby from a dead socket.

The hub no longer binds a joiner to a seat before the room decides whether to
seat them. Anyone holding a room code could previously resign or play on a
seated player's behalf, and the room code is the only credential online 1v1 has.

cmd/noitu-server serves the API and, when NOITU_WEB_DIR is set, the built
frontend, with unknown paths falling back to index.html for client routes. All
configuration is environment-only and every variable has a working default.
2026-09-05 12:07:57 +07:00

100 lines
2.4 KiB
Go

package wsapi
import (
"sync"
"time"
)
// bucket is a token bucket.
//
// Time is a parameter rather than read from the clock inside, so the tests
// exercise refill behaviour directly instead of sleeping through it.
type bucket struct {
mu sync.Mutex
tokens float64
capacity float64
perSec float64
last time.Time
}
func newBucket(perSec float64, capacity int, now time.Time) *bucket {
return &bucket{
tokens: float64(capacity),
capacity: float64(capacity),
perSec: perSec,
last: now,
}
}
// allow spends a token if one is available.
func (b *bucket) allow(now time.Time) bool {
b.mu.Lock()
defer b.mu.Unlock()
if elapsed := now.Sub(b.last); elapsed > 0 {
b.tokens = min(b.capacity, b.tokens+elapsed.Seconds()*b.perSec)
b.last = now
}
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
// keyedLimiter is one bucket per key, used for per-IP limits where the set of
// keys is open-ended and outlives any single connection.
//
// Idle buckets are swept rather than kept forever: without that, the map is a
// slow memory leak driven by whoever connects, which is exactly the wrong
// party to let control its size.
type keyedLimiter struct {
mu sync.Mutex
buckets map[string]*bucket
perSec float64
capacity int
idleFor time.Duration
}
func newKeyedLimiter(perSec float64, capacity int, idleFor time.Duration) *keyedLimiter {
return &keyedLimiter{
buckets: map[string]*bucket{},
perSec: perSec,
capacity: capacity,
idleFor: idleFor,
}
}
func (l *keyedLimiter) allow(key string, now time.Time) bool {
l.mu.Lock()
b, ok := l.buckets[key]
if !ok {
b = newBucket(l.perSec, l.capacity, now)
l.buckets[key] = b
}
l.mu.Unlock()
return b.allow(now)
}
// sweep drops buckets untouched for idleFor. A full bucket carries no state
// worth keeping, so recreating it later is equivalent.
func (l *keyedLimiter) sweep(now time.Time) {
l.mu.Lock()
defer l.mu.Unlock()
for key, b := range l.buckets {
// Idleness alone is the test. Tokens refill lazily — only when allow
// is called — so a bucket that was ever used still reads as partly
// spent no matter how long ago that was. Requiring a full bucket here
// meant nothing was ever collected, which is precisely the leak this
// sweep exists to prevent.
b.mu.Lock()
idle := now.Sub(b.last) > l.idleFor
b.mu.Unlock()
if idle {
delete(l.buckets, key)
}
}
}