mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
room.go was 1919 lines with every room concern in one file. Pure moves, no signature or behaviour changes: room.go keeps the struct, its constructor, the input loop and the small seat-authority helpers; room_inputs.go the message types; room_lobby.go seating and the lobby between games; room_game.go everything that touches a running game; room_presence.go the reconnect window and resume; room_chat.go the room's own conversation; bot_board.go the bot's frozen view of a position. session.go was two unrelated halves in one 762-line file: the socket (session.go, kept) and the protocol (dispatch.go, new) — dispatch and the handshake/resume flow it routes into. Verified with go build, go vet, golangci-lint and go test -race, and by counting: every one of the 89 room.go and 27 session.go top-level declarations appears in the split exactly once.
446 lines
14 KiB
Go
446 lines
14 KiB
Go
package wsapi
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"errors"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/coder/websocket"
|
|
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
|
|
"github.com/tiennm99dev/noitu/server/internal/game"
|
|
)
|
|
|
|
// The socket half of a connection: reading and writing frames, the
|
|
// keepalive that detects a dead peer, and the identity — nickname, room,
|
|
// seat — the protocol half below reads and writes through the same mutex.
|
|
|
|
const (
|
|
// outboxCap buffers writes. A client that cannot keep up with this many
|
|
// pending frames is not going to catch up, so the session is closed rather
|
|
// than grown without bound.
|
|
outboxCap = 32
|
|
|
|
// pingEvery / pingTimeout are the liveness check. Reads carry no deadline
|
|
// of their own: a player idling in the lobby between games is healthy and
|
|
// silent, and a read timeout cannot tell that apart from a dead socket.
|
|
// A ping can.
|
|
pingEvery = 20 * time.Second
|
|
pingTimeout = 10 * time.Second
|
|
pingMisses = 2
|
|
|
|
// writeTimeout bounds a single frame write, and drainTimeout the final
|
|
// flush of whatever is still queued when the session ends.
|
|
writeTimeout = 10 * time.Second
|
|
drainTimeout = 2 * time.Second
|
|
|
|
// submitsPerSecond bounds word submissions. Each one is a dictionary
|
|
// lookup and a possible engine mutation; a human types far below this.
|
|
submitsPerSecond = 5
|
|
submitBurst = 10
|
|
|
|
// Chat gets its own budget so talking never costs a move. It can afford to
|
|
// be humane about a burst — two people typing at each other is normal —
|
|
// because the danger a limiter would otherwise be holding down is handled
|
|
// where it actually lives: chat is delivered with trySend, so a recipient
|
|
// who cannot keep up drops a line rather than losing their session.
|
|
chatsPerSecond = 2.0
|
|
chatBurst = 5
|
|
|
|
// joinsPerSecond and joinBurst bound how many rooms one address may join
|
|
// or attempt to join. The limiter exists to slow a brute-force walk of the
|
|
// room-code space (31 characters over 6 places, ~8.9e8 codes) to
|
|
// centuries even at this rate — it is not meant to ration ordinary play.
|
|
// A single NAT/CGNAT egress (a café, a school, a mobile carrier) can be
|
|
// many real players sharing one address, so the budget has to be generous
|
|
// enough for a whole one of those, not just one person.
|
|
joinsPerSecond = 5
|
|
joinBurst = 20
|
|
|
|
// maxWordReportsPerSession bounds how many distinct words one session may
|
|
// file with ReportWord. A duplicate report of a word already filed does
|
|
// not count against it — it costs nothing new to acknowledge again — but
|
|
// an unbounded stream of distinct ones would turn the corpus feedback loop
|
|
// into a log-filling vector.
|
|
maxWordReportsPerSession = 20
|
|
|
|
// Room creation is far more expensive than a join: each one is a
|
|
// goroutine, an engine and a registry entry held until the game ends.
|
|
roomsPerSecond = 0.2
|
|
roomBurst = 5
|
|
limiterIdleFor = 5 * time.Minute
|
|
|
|
// framesPerSecond bounds every frame a connection sends, before it is
|
|
// routed. The per-action limiters above only meter the actions they know
|
|
// about; a Ping, or a ClientMessage with no payload set, matched none of
|
|
// them and cost the reader a decode at line rate. A client past this is
|
|
// not a player typing, so the connection is closed rather than throttled.
|
|
framesPerSecond = 20
|
|
frameBurst = 40
|
|
)
|
|
|
|
var (
|
|
errHandshake = errors.New("wsapi: first message must be Hello")
|
|
errFlood = errors.New("wsapi: frame rate exceeded")
|
|
)
|
|
|
|
// session is one WebSocket connection.
|
|
//
|
|
// Exactly one reader goroutine and one writer goroutine touch the socket. The
|
|
// mutex below guards only the small mutable identity — nickname, room, seat —
|
|
// that the reader sets and the room goroutine reads.
|
|
type session struct {
|
|
id string
|
|
resumeToken string
|
|
conn *websocket.Conn
|
|
hub *hub
|
|
|
|
// remoteIP keys the join limiter. A session id is minted per connection,
|
|
// so keying on it would let anyone brute-force room codes by reconnecting
|
|
// between attempts — the limiter has to outlive the socket.
|
|
remoteIP string
|
|
|
|
// ctx is the teardown signal for everything except the read.
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
|
|
// readCtx is cancelled only after the writer has finished flushing.
|
|
//
|
|
// coder/websocket arms a context.AfterFunc on the context passed to Read
|
|
// that hard-closes the underlying socket when it fires, so cancelling the
|
|
// read context is the same as destroying the connection. Every frame
|
|
// queued at teardown — the shutdown notice above all — would be written
|
|
// into a socket that is already gone.
|
|
//
|
|
// It is rooted at Background rather than at the server context on purpose.
|
|
// A child of the server context would be cancelled by Shutdown at the same
|
|
// instant as ctx, which is precisely the ordering this exists to prevent.
|
|
// Nothing leaks: run always signals ctx, and the goroutine watching it
|
|
// always cancels this one.
|
|
readCtx context.Context
|
|
cancelRead context.CancelFunc
|
|
|
|
out chan []byte
|
|
|
|
// flushed closes when the writer has drained, so teardown can wait for the
|
|
// last frames to leave before tearing the socket down.
|
|
flushed chan struct{}
|
|
|
|
mu sync.Mutex
|
|
nick string
|
|
room *room
|
|
playerID game.PlayerID
|
|
|
|
submitLimiter *bucket
|
|
roomLimiter *bucket
|
|
chatLimiter *bucket
|
|
frameLimiter *bucket
|
|
|
|
// reportedWords is every distinct word this session has filed with
|
|
// ReportWord, capped at maxWordReportsPerSession. Touched only from
|
|
// dispatch, which is the sole reader of this connection's frames, so it
|
|
// needs no lock of its own — unlike nick/room/playerID above, nothing else
|
|
// ever reads or writes it.
|
|
reportedWords map[string]struct{}
|
|
|
|
// greeted marks the handshake done. It is a one-shot transition: a second
|
|
// Hello would re-register the session and rewrite its nickname mid-game.
|
|
greeted bool
|
|
|
|
closeOnce sync.Once
|
|
}
|
|
|
|
func newSession(ctx context.Context, conn *websocket.Conn, h *hub, remoteIP string) *session {
|
|
readCtx, cancelRead := context.WithCancel(context.Background())
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
return &session{
|
|
readCtx: readCtx,
|
|
cancelRead: cancelRead,
|
|
flushed: make(chan struct{}),
|
|
id: randomToken(),
|
|
resumeToken: randomToken(),
|
|
remoteIP: remoteIP,
|
|
conn: conn,
|
|
hub: h,
|
|
ctx: ctx,
|
|
cancel: cancel,
|
|
out: make(chan []byte, outboxCap),
|
|
submitLimiter: newBucket(submitsPerSecond, submitBurst, time.Now()),
|
|
roomLimiter: newBucket(roomsPerSecond, roomBurst, time.Now()),
|
|
chatLimiter: newBucket(chatsPerSecond, chatBurst, time.Now()),
|
|
frameLimiter: newBucket(framesPerSecond, frameBurst, time.Now()),
|
|
reportedWords: make(map[string]struct{}),
|
|
}
|
|
}
|
|
|
|
func (s *session) nickname() string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.nick
|
|
}
|
|
|
|
func (s *session) setNickname(n string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.nick = n
|
|
}
|
|
|
|
// attach binds this connection to a room seat.
|
|
func (s *session) attach(r *room, seatName string) {
|
|
s.mu.Lock()
|
|
previous, previousID := s.room, s.playerID
|
|
s.room = r
|
|
s.playerID = playerIDFor(seatName)
|
|
s.mu.Unlock()
|
|
|
|
// Releasing the old room is not tidiness. Nothing else tells it this
|
|
// connection has gone: leaveRoom only ever notifies the current room, so an
|
|
// unreleased room parks in select forever, holding a goroutine and a room
|
|
// code for the life of the process. One connection asking for several rooms
|
|
// is all it takes.
|
|
if previous != nil && previous != r {
|
|
previous.send(disconnectInput{player: previousID, sess: s})
|
|
}
|
|
}
|
|
|
|
// release forgets a room this connection is no longer seated in, because it
|
|
// left or was kicked. The connection itself stays open.
|
|
//
|
|
// Guarded by identity: a release from a room the connection has already moved
|
|
// on from must not detach it from the one it is sitting in now.
|
|
func (s *session) release(r *room) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.room == r {
|
|
s.room = nil
|
|
s.playerID = ""
|
|
}
|
|
}
|
|
|
|
func (s *session) currentRoom() (*room, game.PlayerID) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.room, s.playerID
|
|
}
|
|
|
|
// send queues a message for the writer goroutine.
|
|
//
|
|
// Never blocks: the room goroutine calls this, and one unresponsive client
|
|
// must not be able to stall the game its opponent is still playing. A full
|
|
// outbox closes the session instead.
|
|
func (s *session) send(m *noituv1.ServerMessage) {
|
|
raw, err := Encode(m)
|
|
if err != nil {
|
|
slog.Error("encode failed", "session", s.id, "err", err)
|
|
return
|
|
}
|
|
|
|
select {
|
|
case s.out <- raw:
|
|
case <-s.ctx.Done():
|
|
default:
|
|
slog.Warn("outbox full, closing session", "session", s.id)
|
|
s.close()
|
|
}
|
|
}
|
|
|
|
// close signals teardown. It does not cancel the read context: that is done by
|
|
// run once the writer has flushed, so a client is told why it is being
|
|
// disconnected before the socket goes.
|
|
func (s *session) close() {
|
|
s.closeOnce.Do(func() {
|
|
s.cancel()
|
|
})
|
|
}
|
|
|
|
// trySend queues a message and reports whether it fit.
|
|
//
|
|
// The difference from send is what a full outbox means: send closes the
|
|
// session, on the grounds that a client this far behind will not catch up.
|
|
// That is right for a game frame and wrong for a chat line, because it hands
|
|
// one player a way to disconnect the other into losing by abandonment.
|
|
//
|
|
// A line is droppable because the next replay carries it. A ChatHistory is
|
|
// not — it is the frame that corrects a whole panel, and there is nothing
|
|
// behind it — so that one still goes through send. This is for ChatMessage.
|
|
func (s *session) trySend(m *noituv1.ServerMessage) bool {
|
|
raw, err := Encode(m)
|
|
if err != nil {
|
|
slog.Error("encode failed", "session", s.id, "err", err)
|
|
return false
|
|
}
|
|
|
|
select {
|
|
case s.out <- raw:
|
|
return true
|
|
case <-s.ctx.Done():
|
|
return false
|
|
default:
|
|
slog.Warn("outbox full, dropping chat", "session", s.id)
|
|
return false
|
|
}
|
|
}
|
|
|
|
// run drives the connection until it closes.
|
|
func (s *session) run() {
|
|
defer s.close()
|
|
defer s.leaveRoom()
|
|
// A connection that ends while queued must not leave a ghost in line: the
|
|
// next two strangers to ask are paired with each other, not with a socket
|
|
// that is already gone.
|
|
defer s.hub.cancelQuickMatch(s)
|
|
|
|
s.conn.SetReadLimit(maxFrameBytes)
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(3)
|
|
go func() { defer wg.Done(); s.writeLoop() }()
|
|
go func() { defer wg.Done(); s.keepalive() }()
|
|
|
|
// Teardown ordering lives in its own goroutine because readLoop below is
|
|
// blocked in Read and cannot run it. Whoever signals the close — a
|
|
// protocol error here, a dead peer in keepalive, or Shutdown cancelling
|
|
// the server context — gets the same sequence: flush, then drop the
|
|
// socket.
|
|
go func() {
|
|
defer wg.Done()
|
|
<-s.ctx.Done()
|
|
select {
|
|
case <-s.flushed:
|
|
case <-time.After(drainTimeout):
|
|
}
|
|
s.cancelRead()
|
|
}()
|
|
|
|
err := s.readLoop()
|
|
s.close()
|
|
wg.Wait()
|
|
|
|
// readLoop only ever returns an error; a cancelled context is the one
|
|
// that means the server chose to close.
|
|
status, reason := websocket.StatusNormalClosure, ""
|
|
if !errors.Is(err, context.Canceled) {
|
|
status, reason = websocket.StatusPolicyViolation, "protocol error"
|
|
}
|
|
_ = s.conn.Close(status, reason)
|
|
}
|
|
|
|
// readLoop is the only reader of the socket.
|
|
func (s *session) readLoop() error {
|
|
for {
|
|
typ, raw, err := s.conn.Read(s.readCtx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !s.frameLimiter.allow(time.Now()) {
|
|
s.send(errorMsg("too_fast"))
|
|
return errFlood
|
|
}
|
|
|
|
msg, err := Decode(typ, raw)
|
|
if err != nil {
|
|
s.send(errorMsg("bad_frame"))
|
|
return err
|
|
}
|
|
if err := s.dispatch(msg); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
// writeLoop is the only writer of the socket. A single owner keeps frame order
|
|
// deterministic even though coder/websocket tolerates concurrent writes.
|
|
func (s *session) writeLoop() {
|
|
defer close(s.flushed)
|
|
|
|
for {
|
|
select {
|
|
case <-s.ctx.Done():
|
|
s.drain()
|
|
return
|
|
case raw := <-s.out:
|
|
if !s.write(raw) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// write sends one frame.
|
|
//
|
|
// The deadline is its own, not derived from the session context. A frame that
|
|
// has already been dequeued must still reach the peer even when the session is
|
|
// ending — refusals are sent immediately before a close, and inheriting the
|
|
// cancelled context would fail every one of them. The timeout is what protects
|
|
// against a peer that has stopped reading.
|
|
func (s *session) write(raw []byte) bool {
|
|
ctx, cancel := context.WithTimeout(context.Background(), writeTimeout)
|
|
defer cancel()
|
|
|
|
if err := s.conn.Write(ctx, websocket.MessageBinary, raw); err != nil {
|
|
s.close()
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// drain flushes what is already queued after the session is cancelled.
|
|
//
|
|
// Refusals are the reason this exists: the server answers a bad handshake with
|
|
// a UI key and then closes, and without this the close wins the race and the
|
|
// client is left to guess why it was dropped. The context is fresh because
|
|
// s.ctx is by definition already cancelled here, and bounded because a peer
|
|
// that is not reading must not delay teardown.
|
|
func (s *session) drain() {
|
|
for {
|
|
select {
|
|
case raw := <-s.out:
|
|
ctx, cancel := context.WithTimeout(context.Background(), drainTimeout)
|
|
err := s.conn.Write(ctx, websocket.MessageBinary, raw)
|
|
cancel()
|
|
if err != nil {
|
|
return
|
|
}
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// keepalive is what actually detects a dead peer, since reads have no deadline.
|
|
func (s *session) keepalive() {
|
|
ticker := time.NewTicker(pingEvery)
|
|
defer ticker.Stop()
|
|
|
|
misses := 0
|
|
for {
|
|
select {
|
|
case <-s.ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
ctx, cancel := context.WithTimeout(s.ctx, pingTimeout)
|
|
err := s.conn.Ping(ctx)
|
|
cancel()
|
|
|
|
if err == nil {
|
|
misses = 0
|
|
continue
|
|
}
|
|
if misses++; misses >= pingMisses {
|
|
slog.Info("peer unresponsive, closing", "session", s.id)
|
|
s.close()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func randomToken() string {
|
|
raw := make([]byte, 16)
|
|
_, _ = rand.Read(raw)
|
|
return base64.RawURLEncoding.EncodeToString(raw)
|
|
}
|