Files
noitu/server/internal/wsapi/session.go
T
tiennm99 8f739e02ae refactor(wsapi): split room.go and session.go along their seams
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.
2026-09-21 16:25:20 +07:00

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)
}