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

206 lines
7.3 KiB
Go

package wsapi
import (
"time"
noituv1 "github.com/tiennm99dev/noitu/server/gen/noitu/v1"
)
// Presence: a seat's reconnect window, opening it, closing it, and what a
// resume does once a connection comes back inside one.
// holdSeat opens a seat's reconnect window: the connection behind it is gone,
// and the seat waits graceFor for it to come back.
//
// A dropped connection is not a player leaving. The seat is kept whether a
// game is running or the room is sitting in its lobby, so refreshing the page
// does not cost somebody the room they are in.
//
// It is also what a seat gets the moment it is filled by a connection that
// turns out to have already torn down. The session can die between the hub
// handing this room the seating message and the room goroutine draining it,
// and nothing else ever learns that: leaveRoom only notifies a room the
// session was already attached to. Left seated as if connected,
// allConnected() would report true and quick match's own auto-start could
// begin a game against a socket nobody is behind.
func (r *room) holdSeat(s *seat) {
s.heldFor = s.sess
s.sess = nil
s.graceUntil = time.Now().Add(r.graceFor)
// Presence is part of the room's state, and the run loop is what sends it.
r.lobbyChanged = true
}
// handleDisconnect holds the seat open for the player who dropped out of it.
//
// The turn clock is deliberately not paused. A player who drops on their own
// turn loses it the way anybody else would; the window decides only whether
// they are still in the game afterwards.
func (r *room) handleDisconnect(m disconnectInput) {
s := r.seatOf(m.player)
// A stale notice from a connection the player already replaced. Acting on
// it would evict the seat the new socket is sitting in.
if s == nil || s.sess == nil || s.sess != m.sess {
return
}
r.holdSeat(s)
}
// nextGraceExpiry is the earliest reconnect window still open.
func (r *room) nextGraceExpiry() (time.Time, bool) {
var next time.Time
for _, s := range r.seats {
if s == nil || s.sess != nil || s.graceUntil.IsZero() {
continue
}
if next.IsZero() || s.graceUntil.Before(next) {
next = s.graceUntil
}
}
return next, !next.IsZero()
}
// handleGraceExpiry frees every seat whose reconnect window has run out.
//
// The engine goes first, while the seats are still here to be named: once one
// is vacated there is nobody left to attribute the elimination to, and the
// players who stayed would be told that somebody with no name went out.
func (r *room) handleGraceExpiry() {
now := time.Now()
var expired []*seat
for _, s := range r.seats {
if s == nil || s.sess != nil || s.graceUntil.IsZero() || s.graceUntil.After(now) {
continue
}
expired = append(expired, s)
}
if len(expired) == 0 {
return
}
before := r.mark()
for _, s := range expired {
r.eliminateAbsent(s, now)
}
r.applyEliminations(before)
for _, s := range expired {
r.vacate(s)
}
r.lobbyChanged = true
}
// eliminateAbsent takes a seat out of a live game once nobody is coming back
// to it.
//
// The engine is told this is a resignation, because that is the only shape it
// has for a player who stops playing. What the room reports is the transport
// fact instead: from everybody else's side this is somebody who left, not
// somebody who chose to give up.
func (r *room) eliminateAbsent(s *seat, now time.Time) {
if r.engine == nil || r.engine.Over() || !r.engine.Alive(s.id) {
return
}
r.outWire[s.id] = noituv1.GameEndReason_GAME_END_REASON_OPPONENT_LEFT
r.engine.Resign(s.id, now)
}
// handleResume rebinds a seat to a new connection and replays the position.
//
// The replay is built from the engine, never from stored copies of past
// messages: a recorded stream can drift from the real state, and the resumed
// client would then be shown a board the server does not believe in.
func (r *room) handleResume(m resumeInput) {
s := r.seatOf(m.player)
// The token is single-use, but two connections presenting it at once can
// both find it registered before either resume lands. Only the first may
// take the seat: the second would displace it and leave that connection
// attached to a seat that is no longer its own.
//
// A seat in its grace window is resumable only by the connection that
// dropped out of it. Seat ids are reused, so a seat vacated while its
// player was away and refilled since carries the same id but was held for
// somebody else; the old token must not open it.
if s == nil || (s.sess != m.prior && (s.sess != nil || s.heldFor != m.prior)) {
m.sess.send(errorMsg(codeSessionNotResumable))
return
}
// Accepted. Only now is the old connection finished: its token is spent and
// its socket is either gone or about to be, and leaving it registered would
// let a third connection claim the same seat.
metrics.resumesSucceeded.Add(1)
m.sess.attach(r, m.player)
// Every other way into a seat leaves the quick-match queue (takeSeat); a
// resume must too, or a seated player could be paired out of a game.
r.hub.cancelQuickMatch(m.sess)
if m.prior != nil {
r.hub.unregister(m.prior.resumeToken)
m.prior.close()
}
s.sess = m.sess
s.heldFor = nil
s.graceUntil = time.Time{}
// The seat keeps the name it was given. Re-reading it from the new
// connection would let a reconnect rename a player mid-game, including
// into somebody else's name.
// Everybody needs the room's state again: this player to render the lobby
// they came back to, the rest to stop watching a disconnect banner for
// somebody who is already back. The run loop sends it to all of them.
r.lobbyChanged = true
if m.sess.ctx.Err() != nil {
// The new connection can die between the client's Hello landing and
// this resume being drained off the room's queue, the same race
// handleCreate and handleJoin guard against. Reopening the window it
// just closed leaves the seat exactly as reachable as it was before
// this resume was ever attempted.
r.holdSeat(s)
return
}
// Before the lobby return below, not after it: a refresh in the lobby is
// the commonest resume there is, and it is exactly the one that would miss
// a replay hung off the end of this function.
r.sendChatHistory(s)
// Resumed between games, or before the first one. The lobby state above is
// the whole answer; there is no position to replay.
if r.inLobby() {
// A game that ended while this seat was away is owed its result: the
// lobby alone would leave the player unaware of how it went. Queued
// rather than sent, to follow the RoomState the run loop broadcasts.
if s.missedResult != nil {
r.resumeReplays = append(r.resumeReplays, s)
}
return
}
state := r.engine.Snapshot()
r.sendGameStarted(s, state)
if last, ok := r.engine.LastMove(); ok {
r.sendTurnUpdate(s, state, &last, r.moveMeanings(&last))
}
}
// flushResumeReplays sends each resumed seat the result it missed, and
// forgets it, so a result is replayed once. A seat whose connection has gone
// again since keeps the result for the next resume.
func (r *room) flushResumeReplays() {
for _, s := range r.resumeReplays {
if s.sess != nil && s.missedResult != nil {
s.sess.send(s.missedResult)
s.missedResult = nil
}
}
r.resumeReplays = r.resumeReplays[:0]
}
// detachAll releases every connection still bound to this room as it exits.
func (r *room) detachAll() {
for s := range r.connected() {
s.sess.release(r)
}
}