bugfixes and refactoring in game-server

This commit is contained in:
2026-10-27 15:55:48 +03:00
parent 3629411030
commit 31ef12b5f2
9 changed files with 732 additions and 66 deletions
+140 -30
View File
@@ -4,9 +4,9 @@ package ws
import (
"context"
"errors"
"log/slog"
"net/http"
"sync"
"time"
"github.com/coder/websocket"
@@ -24,22 +24,109 @@ const (
pingTimeout = 10 * time.Second
)
// Conn adapts a websocket connection to the game.Sender interface.
type Conn struct {
ws *websocket.Conn
wmu sync.Mutex // serializes writes
type writeOp struct {
data []byte
isClose bool
code websocket.StatusCode
reason string
}
func (c *Conn) Send(_ context.Context, data []byte) error {
ctx, cancel := context.WithTimeout(context.Background(), writeTimeout)
defer cancel()
c.wmu.Lock()
defer c.wmu.Unlock()
return c.ws.Write(ctx, websocket.MessageText, data)
// Conn adapts a websocket connection to the game.Sender interface,
// delegating all writes to a dedicated goroutine to prevent locking
// game mutexes during write timeouts.
type Conn struct {
ws *websocket.Conn
sendCh chan writeOp
done chan struct{} // closed to abort writeLoop
closed chan struct{} // closed when writeLoop exits
}
func newConn(ws *websocket.Conn) *Conn {
c := &Conn{
ws: ws,
sendCh: make(chan writeOp, 64),
done: make(chan struct{}),
closed: make(chan struct{}),
}
go c.writeLoop()
return c
}
func (c *Conn) writeLoop() {
defer close(c.closed)
for {
select {
case <-c.done:
return
case op, ok := <-c.sendCh:
if !ok {
return
}
if op.isClose {
_ = c.ws.Close(op.code, op.reason)
return
}
ctx, cancel := context.WithTimeout(context.Background(), writeTimeout)
err := c.ws.Write(ctx, websocket.MessageText, op.data)
cancel()
if err != nil {
_ = c.ws.Close(websocket.StatusGoingAway, "write error")
return
}
}
}
}
func (c *Conn) Send(ctx context.Context, data []byte) error {
select {
case <-c.done:
return errors.New("connection closed")
default:
}
msg := make([]byte, len(data))
copy(msg, data)
select {
case <-c.done:
return errors.New("connection closed")
case <-ctx.Done():
return ctx.Err()
case c.sendCh <- writeOp{data: msg}:
return nil
}
}
func (c *Conn) Close(code int, reason string) error {
return c.ws.Close(websocket.StatusCode(code), reason)
select {
case <-c.closed:
return nil
default:
}
op := writeOp{
isClose: true,
code: websocket.StatusCode(code),
reason: reason,
}
select {
case c.sendCh <- op:
select {
case <-c.closed:
case <-time.After(writeTimeout):
_ = c.ws.Close(websocket.StatusCode(code), reason)
}
default:
select {
case <-c.done:
default:
close(c.done)
}
_ = c.ws.Close(websocket.StatusCode(code), reason)
}
return nil
}
// Handler is the http.Handler of the websocket endpoint.
@@ -104,22 +191,40 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// Validate the game token and make sure the token was issued for this
// particular server instance (strict comparison with the public address).
gc, err := auth.ParseGameToken(h.JWTSecret, gameToken)
if err != nil {
h.log.Debug("bad game token", "err", err)
wsc.Close(protocol.CloseCodeBadGameToken, "invalid game token")
return
// If player is reconnecting with the same game token used when creating the match,
// skip token validation completely.
var gc *auth.GameClaims
unverified, _ := auth.ParseGameTokenUnverified(gameToken)
if unverified != nil {
if existing := h.Manager.GetMatch(unverified.MatchID); existing != nil && existing.GameToken() == gameToken {
gc = &auth.GameClaims{
MatchID: existing.ID,
Player1ID: existing.PlayerIDs()[0],
Player2ID: existing.PlayerIDs()[1],
RawToken: gameToken,
}
}
}
if gc.GameServer != h.PublicAddr {
h.log.Debug("game token issued for another server", "want", h.PublicAddr, "got", gc.GameServer)
wsc.Close(protocol.CloseCodeBadGameToken, "game server mismatch")
return
}
if gc.Player1ID == gc.Player2ID {
wsc.Close(protocol.CloseCodeBadGameToken, "invalid match")
return
if gc == nil {
// Validate the game token and make sure the token was issued for this
// particular server instance (strict comparison with the public address).
var err error
gc, err = auth.ParseGameToken(h.JWTSecret, gameToken)
if err != nil {
h.log.Debug("bad game token", "err", err)
wsc.Close(protocol.CloseCodeBadGameToken, "invalid game token")
return
}
if gc.GameServer != h.PublicAddr {
h.log.Debug("game token issued for another server", "want", h.PublicAddr, "got", gc.GameServer)
wsc.Close(protocol.CloseCodeBadGameToken, "game server mismatch")
return
}
if gc.Player1ID <= 0 || gc.Player2ID <= 0 || gc.Player1ID == gc.Player2ID {
wsc.Close(protocol.CloseCodeBadGameToken, "invalid match")
return
}
}
// Validate the user token and make sure the user is a participant.
@@ -129,12 +234,12 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
wsc.Close(protocol.CloseCodeBadUserToken, "invalid user token")
return
}
if uc.UserID != gc.Player1ID && uc.UserID != gc.Player2ID {
if uc.UserID <= 0 || (uc.UserID != gc.Player1ID && uc.UserID != gc.Player2ID) {
wsc.Close(protocol.CloseCodeBadUserToken, "not a participant of this match")
return
}
conn := &Conn{ws: wsc}
conn := newConn(wsc)
match, slotIdx, err := h.Manager.Join(ctx, conn, gc, uc.UserID)
if err != nil {
// ErrMatchFinished: the END message has already been replayed and
@@ -160,13 +265,18 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
continue
}
switch opcode {
case protocol.OpJoin:
// Duplicate JOIN on an established connection is ignored.
case protocol.OpAck:
seq, err := protocol.ParseAck(args)
if err != nil {
h.log.Debug("malformed ACK", "match", match.ID, "user", uc.UserID, "err", err)
continue
}
h.Manager.Ack(match, slotIdx, seq)
if err := h.Manager.Ack(match, slotIdx, seq); err != nil {
h.log.Debug("invalid ACK seq", "match", match.ID, "user", uc.UserID, "seq", seq, "err", err)
continue
}
default:
// Unknown opcodes are ignored for forward compatibility.
h.log.Debug("unknown opcode", "match", match.ID, "user", uc.UserID, "opcode", opcode)