mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-05 18:14:35 +00:00
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.
100 lines
2.4 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|