Files
tiennm99 81234b5ab9 fix(wsapi): stop a kicked token resuming into another seat, harden limits
A held seat now remembers which connection it waits for, so a token
from a kicked player is refused instead of landing in whoever took the
seat. A room answers every input left in its inbox when it exits,
disconnect notices no longer share the lossy inbox, quick-match no
longer leaves autoStart armed after a pairing that never started, only
lobby changes restart the idle window, and draining refuses new queue
entries.

Sockets that never send Hello close after ten seconds and the room
budget is charged per address, so one client cannot hold the global
caps. IPv6 limiter keys use the /64. Corpus log lines get a process-wide
rate limit with a suppressed counter. Responses carry nosniff,
frame-ancestors and referrer headers. Unicode spaces in a word become
spaces rather than vanishing, and blank-rendering letters are dropped
from names and chat. A resume into a lobby whose game ended during the
absence is replayed that seat's GameOver. Unknown payloads get
unknown_message.
2026-09-29 20:33:16 +07:00

56 lines
1.8 KiB
Go

package wsapi
import (
"log/slog"
"sync/atomic"
"time"
)
// Corpus log lines are word_rejected and word_reported: the feedback loop the
// dictionary is tuned by. Each session is already bounded on how many it can
// cause, but a session is free to reconnect, so the per-session bounds do not
// bound the process. This is the bound that does.
const (
// corpusLogPerSecond and corpusLogBurst are the process-wide budget for
// those lines. Far above what real play produces — a rejection is a typo
// or a missing word, a few a minute per room — so only a flood is sampled.
corpusLogPerSecond = 20
corpusLogBurst = 100
)
// corpusLogger writes the corpus lines through one process-wide bucket. The
// counters that mirror them stay exact; only the log is sampled, so a flood
// cannot fill the disk or rotate the real signal out.
type corpusLogger struct {
bucket *bucket
suppressed atomic.Int64
// out writes one line that got through; slog.Info outside of tests.
out func(msg string, args ...any)
}
func newCorpusLogger(now time.Time) *corpusLogger {
return &corpusLogger{
bucket: newBucket(corpusLogPerSecond, corpusLogBurst, now),
out: slog.Info,
}
}
// corpusLog is the one instance every corpus line goes through.
var corpusLog = newCorpusLogger(time.Now())
// info logs one corpus line, or counts it as suppressed when the budget is
// spent. The first line to get through after a suppressed run carries how many
// were dropped, so the gap is visible in the log itself and not only in the
// noitu_corpus_log_suppressed counter.
func (c *corpusLogger) info(now time.Time, msg string, args ...any) {
if !c.bucket.allow(now) {
c.suppressed.Add(1)
metrics.corpusLogSuppressed.Add(1)
return
}
if n := c.suppressed.Swap(0); n > 0 {
args = append(args, "suppressed_before", n)
}
c.out(msg, args...)
}