mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
fix(games): reject duplicate queue entries (#117)
* test(games): reproduce duplicate queue deadlock * fix(games): reject duplicate queue entries
This commit is contained in:
parent
be75744248
commit
5a1f5d90db
4 changed files with 56 additions and 2 deletions
|
|
@ -70,6 +70,9 @@ func (a *app) handleGame(s ssh.Session) {
|
||||||
case errors.Is(err, games.ErrNoOpponent):
|
case errors.Is(err, games.ErrNoOpponent):
|
||||||
_ = conn.Send(errEnvelope("no opponent found — try again later"))
|
_ = conn.Send(errEnvelope("no opponent found — try again later"))
|
||||||
_ = s.Exit(1)
|
_ = s.Exit(1)
|
||||||
|
case errors.Is(err, games.ErrAlreadyQueued):
|
||||||
|
_ = conn.Send(errEnvelope("this account is already queued for that game"))
|
||||||
|
_ = s.Exit(1)
|
||||||
case err != nil && !errors.Is(err, games.ErrUnknownGame):
|
case err != nil && !errors.Is(err, games.ErrUnknownGame):
|
||||||
// Unknown-game is already handled above; anything else is a wait abort
|
// Unknown-game is already handled above; anything else is a wait abort
|
||||||
// (e.g. the agent disconnected) and needs no message.
|
// (e.g. the agent disconnected) and needs no message.
|
||||||
|
|
|
||||||
|
|
@ -77,9 +77,13 @@ func (a *app) handleGameWS(w http.ResponseWriter, r *http.Request) {
|
||||||
defer func() { _ = a.st.EndSession(sessID) }()
|
defer func() { _ = a.st.EndSession(sessID) }()
|
||||||
|
|
||||||
_ = p.Send(map[string]any{"type": "queued", "game": gameID})
|
_ = p.Send(map[string]any{"type": "queued", "game": gameID})
|
||||||
if err := a.mm.Play(context.Background(), gameID, p); errors.Is(err, games.ErrNoOpponent) {
|
err = a.mm.Play(context.Background(), gameID, p)
|
||||||
|
if errors.Is(err, games.ErrNoOpponent) {
|
||||||
_ = p.Send(errEnvelope("no opponent found — try again later"))
|
_ = p.Send(errEnvelope("no opponent found — try again later"))
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, games.ErrAlreadyQueued) {
|
||||||
|
_ = p.Send(errEnvelope("this account is already queued for that game"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// wsPlayer adapts a gorilla WebSocket connection to games.PlayerIO. The match
|
// wsPlayer adapts a gorilla WebSocket connection to games.PlayerIO. The match
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,9 @@ var ErrNoOpponent = errors.New("no opponent found")
|
||||||
// ErrUnknownGame means the requested game id is not in the registry.
|
// ErrUnknownGame means the requested game id is not in the registry.
|
||||||
var ErrUnknownGame = errors.New("unknown game")
|
var ErrUnknownGame = errors.New("unknown game")
|
||||||
|
|
||||||
|
// ErrAlreadyQueued means the same player already has a connection waiting for this game.
|
||||||
|
var ErrAlreadyQueued = errors.New("player already queued")
|
||||||
|
|
||||||
// Store persists finished matches and tracks per-game ELO ratings. The SQLite
|
// Store persists finished matches and tracks per-game ELO ratings. The SQLite
|
||||||
// store implements it; the matchmaker stays storage-agnostic.
|
// store implements it; the matchmaker stays storage-agnostic.
|
||||||
type Store interface {
|
type Store interface {
|
||||||
|
|
@ -77,7 +80,11 @@ func (mm *Matchmaker) Play(ctx context.Context, gameID string, io PlayerIO) erro
|
||||||
}
|
}
|
||||||
|
|
||||||
mm.mu.Lock()
|
mm.mu.Lock()
|
||||||
if w, waiting := mm.queue[gameID]; waiting && w.io.Name() != io.Name() {
|
if w, waiting := mm.queue[gameID]; waiting {
|
||||||
|
if w.io.Name() == io.Name() {
|
||||||
|
mm.mu.Unlock()
|
||||||
|
return ErrAlreadyQueued
|
||||||
|
}
|
||||||
// An opponent is waiting — pair up and run the match.
|
// An opponent is waiting — pair up and run the match.
|
||||||
delete(mm.queue, gameID)
|
delete(mm.queue, gameID)
|
||||||
mm.mu.Unlock()
|
mm.mu.Unlock()
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package games
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"math"
|
"math"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -133,3 +134,42 @@ func TestMatchmakerUnknownGame(t *testing.T) {
|
||||||
t.Fatalf("want ErrUnknownGame, got %v", err)
|
t.Fatalf("want ErrUnknownGame, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMatchmakerRejectsDuplicateWaiter(t *testing.T) {
|
||||||
|
mm := NewMatchmaker(Catalog(), nil, time.Second, time.Minute)
|
||||||
|
firstCtx, cancelFirst := context.WithCancel(context.Background())
|
||||||
|
firstDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
firstDone <- mm.Play(firstCtx, "ttt", &firstLegalPlayer{name: "same-agent"})
|
||||||
|
}()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(time.Second)
|
||||||
|
for {
|
||||||
|
mm.mu.Lock()
|
||||||
|
queued := mm.queue["ttt"] != nil
|
||||||
|
mm.mu.Unlock()
|
||||||
|
if queued {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("first player never entered the queue")
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
duplicateCtx, cancelDuplicate := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||||
|
defer cancelDuplicate()
|
||||||
|
if err := mm.Play(duplicateCtx, "ttt", &firstLegalPlayer{name: "same-agent"}); err == nil || errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatalf("duplicate Play error = %v, want immediate already-queued rejection", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelFirst()
|
||||||
|
select {
|
||||||
|
case err := <-firstDone:
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("first Play error = %v, want context.Canceled", err)
|
||||||
|
}
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
t.Fatal("first player remained blocked after its context was canceled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue