v0.11.0 harden persistence and prepare game replays
This commit is contained in:
+205
-63
@@ -1,7 +1,9 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"chess/internal/server/core"
|
||||
@@ -11,8 +13,32 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrGameNotFound = errors.New("game not found")
|
||||
ErrGameChanged = errors.New("game changed while move was being validated")
|
||||
ErrSlotOwner = errors.New("player slot is owned by another user")
|
||||
)
|
||||
|
||||
type MoveCommit struct {
|
||||
ExpectedFEN string
|
||||
ExpectedState core.State
|
||||
ExpectedTurn core.Color
|
||||
ActorUserID string
|
||||
MoveUCI string
|
||||
NewFEN string
|
||||
State core.State
|
||||
Result *game.MoveResult
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// CreateGame registers a new game with pre-constructed players
|
||||
func (s *Service) CreateGame(id string, whitePlayer, blackPlayer *core.Player, initialFEN string, startingTurn core.Color) error {
|
||||
func (s *Service) CreateGame(
|
||||
id string,
|
||||
whitePlayer, blackPlayer *core.Player,
|
||||
initialFEN string,
|
||||
startingTurn core.Color,
|
||||
initialState core.State,
|
||||
) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -29,11 +55,14 @@ func (s *Service) CreateGame(id string, whitePlayer, blackPlayer *core.Player, i
|
||||
s.computerGames.Add(1)
|
||||
}
|
||||
|
||||
// Store game with provided players
|
||||
s.games[id] = game.New(initialFEN, whitePlayer, blackPlayer, startingTurn)
|
||||
now := time.Now().UTC()
|
||||
g := game.New(initialFEN, whitePlayer, blackPlayer, startingTurn)
|
||||
g.SetStateAt(initialState, now)
|
||||
s.games[id] = g
|
||||
|
||||
// Persist if storage enabled
|
||||
if s.store != nil {
|
||||
result, _ := initialState.Result()
|
||||
record := storage.GameRecord{
|
||||
GameID: id,
|
||||
InitialFEN: initialFEN,
|
||||
@@ -41,14 +70,21 @@ func (s *Service) CreateGame(id string, whitePlayer, blackPlayer *core.Player, i
|
||||
WhiteType: int(whitePlayer.Type),
|
||||
WhiteLevel: whitePlayer.Level,
|
||||
WhiteSearchTime: whitePlayer.SearchTime,
|
||||
WhiteClaimedBy: whitePlayer.ClaimedBy,
|
||||
BlackPlayerID: blackPlayer.ID,
|
||||
BlackType: int(blackPlayer.Type),
|
||||
BlackLevel: blackPlayer.Level,
|
||||
BlackSearchTime: blackPlayer.SearchTime,
|
||||
StartTimeUTC: time.Now().UTC(),
|
||||
BlackClaimedBy: blackPlayer.ClaimedBy,
|
||||
Result: result,
|
||||
StartTimeUTC: now,
|
||||
EndTimeUTC: g.EndTimeUTC(),
|
||||
}
|
||||
if err := s.store.RecordNewGame(record); err != nil {
|
||||
slog.Error("failed to queue game persistence", "game_id", id, "error", err)
|
||||
}
|
||||
s.store.RecordNewGame(record)
|
||||
}
|
||||
slog.Debug("game created", "game_id", id, "state", initialState.String(), "persistent", s.store != nil)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -62,23 +98,85 @@ func (s *Service) UpdatePlayers(gameID string, whitePlayer, blackPlayer *core.Pl
|
||||
if !ok {
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
}
|
||||
if g.State() == core.StatePending {
|
||||
return errors.New("cannot change players while computer is calculating")
|
||||
}
|
||||
|
||||
oldWhite := g.GetPlayer(core.ColorWhite)
|
||||
oldBlack := g.GetPlayer(core.ColorBlack)
|
||||
oldHasComputer := g.HasComputerPlayer()
|
||||
newHasComputer := whitePlayer.Type == core.PlayerComputer || blackPlayer.Type == core.PlayerComputer
|
||||
if !oldHasComputer && newHasComputer && s.computerGames.Load() >= MaxComputerGames {
|
||||
return fmt.Errorf("computer game limit reached (%d/%d)", s.computerGames.Load(), MaxComputerGames)
|
||||
}
|
||||
|
||||
// Player configuration is mutable, but historical user association is not.
|
||||
// Preserve a human ID while the slot remains human, and preserve any claim
|
||||
// even if the slot later becomes computer-controlled.
|
||||
if oldWhite != nil {
|
||||
if oldWhite.Type == core.PlayerHuman && whitePlayer.Type == core.PlayerHuman {
|
||||
whitePlayer.ID = oldWhite.ID
|
||||
}
|
||||
whitePlayer.ClaimedBy = oldWhite.ClaimedBy
|
||||
}
|
||||
if oldBlack != nil {
|
||||
if oldBlack.Type == core.PlayerHuman && blackPlayer.Type == core.PlayerHuman {
|
||||
blackPlayer.ID = oldBlack.ID
|
||||
}
|
||||
blackPlayer.ClaimedBy = oldBlack.ClaimedBy
|
||||
}
|
||||
|
||||
// Update the game's players
|
||||
g.UpdatePlayers(whitePlayer, blackPlayer)
|
||||
if oldHasComputer != newHasComputer {
|
||||
if newHasComputer {
|
||||
s.computerGames.Add(1)
|
||||
} else {
|
||||
s.computerGames.Add(-1)
|
||||
}
|
||||
}
|
||||
if s.store != nil {
|
||||
err := s.store.RecordPlayers(gameID, playerRecord(whitePlayer), playerRecord(blackPlayer))
|
||||
if err != nil {
|
||||
slog.Error("failed to queue player persistence", "game_id", gameID, "error", err)
|
||||
}
|
||||
}
|
||||
slog.Debug("game players updated", "game_id", gameID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetGame retrieves a game by ID
|
||||
func (s *Service) GetGame(gameID string) (*game.Game, error) {
|
||||
// GetGameView retrieves an immutable game snapshot by ID.
|
||||
func (s *Service) GetGameView(gameID string) (game.View, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("game not found: %s", gameID)
|
||||
return game.View{}, fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
|
||||
}
|
||||
return g, nil
|
||||
return g.View(), nil
|
||||
}
|
||||
|
||||
// BeginComputerMove is an optimistic state transition: only the request that
|
||||
// observed the current ongoing position may enqueue engine work.
|
||||
func (s *Service) BeginComputerMove(gameID, expectedFEN string, expectedTurn core.Color) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
|
||||
}
|
||||
if g.State() != core.StateOngoing || g.CurrentFEN() != expectedFEN || g.NextTurnColor() != expectedTurn {
|
||||
return ErrGameChanged
|
||||
}
|
||||
if player := g.NextPlayer(); player == nil || player.Type != core.PlayerComputer {
|
||||
return errors.New("current player is not a computer")
|
||||
}
|
||||
g.SetStateAt(core.StatePending, time.Now().UTC())
|
||||
s.waiter.NotifyGame(gameID, len(g.Moves()), core.StatePending)
|
||||
slog.Debug("computer move started", "game_id", gameID, "turn", expectedTurn.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateGameID creates a new unique game ID
|
||||
@@ -95,70 +193,86 @@ func (s *Service) GenerateGameID() string {
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyMove adds a validated move to the game history
|
||||
func (s *Service) ApplyMove(gameID, moveUCI, newFEN string) error {
|
||||
// ApplyMoveWithState verifies that the position validated by the processor is
|
||||
// still current, then commits the move, optional first-move claim, and result as
|
||||
// one in-memory transition and one SQLite transaction.
|
||||
func (s *Service) ApplyMoveWithState(gameID string, commit MoveCommit) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
|
||||
}
|
||||
|
||||
// Determine whose turn it was before this move
|
||||
currentTurn := g.NextTurnColor()
|
||||
nextTurn := core.OppositeColor(currentTurn)
|
||||
if g.CurrentFEN() != commit.ExpectedFEN ||
|
||||
g.State() != commit.ExpectedState ||
|
||||
currentTurn != commit.ExpectedTurn {
|
||||
return ErrGameChanged
|
||||
}
|
||||
|
||||
// Add the new position to game history
|
||||
g.AddSnapshot(newFEN, moveUCI, nextTurn)
|
||||
|
||||
// Notify waiting clients about the state change
|
||||
s.waiter.NotifyGame(gameID, len(g.Moves()), g.State())
|
||||
|
||||
// Persist if storage enabled
|
||||
if s.store != nil {
|
||||
moveNumber := len(g.Moves())
|
||||
record := storage.MoveRecord{
|
||||
GameID: gameID,
|
||||
MoveNumber: moveNumber,
|
||||
MoveUCI: moveUCI,
|
||||
FENAfterMove: newFEN,
|
||||
PlayerColor: currentTurn.String(),
|
||||
MoveTimeUTC: time.Now().UTC(),
|
||||
currentPlayer := g.NextPlayer()
|
||||
claimUserID := ""
|
||||
if currentPlayer == nil {
|
||||
return errors.New("current player is missing")
|
||||
}
|
||||
if currentPlayer.Type == core.PlayerHuman {
|
||||
owner := g.GetSlotOwner(currentTurn)
|
||||
switch {
|
||||
case owner != "" && commit.ActorUserID == "":
|
||||
return ErrSlotOwner
|
||||
case owner != "" && owner != commit.ActorUserID:
|
||||
return ErrSlotOwner
|
||||
case owner == "" && commit.ActorUserID != "":
|
||||
claimUserID = commit.ActorUserID
|
||||
}
|
||||
s.store.RecordMove(record)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyMoveWithState atomically records a move, its resulting state, and move
|
||||
// metadata, then notifies waiters exactly once with the settled state.
|
||||
func (s *Service) ApplyMoveWithState(gameID, moveUCI, newFEN string, state core.State, result *game.MoveResult) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
at := commit.At.UTC()
|
||||
if commit.At.IsZero() {
|
||||
at = time.Now().UTC()
|
||||
}
|
||||
|
||||
currentTurn := g.NextTurnColor()
|
||||
g.AddSnapshot(newFEN, moveUCI, core.OppositeColor(currentTurn))
|
||||
g.SetState(state)
|
||||
if result != nil {
|
||||
g.SetLastResult(result)
|
||||
if claimUserID != "" {
|
||||
if err := g.ClaimSlot(currentTurn, claimUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
g.AddSnapshot(commit.NewFEN, commit.MoveUCI, core.OppositeColor(currentTurn))
|
||||
g.SetStateAt(commit.State, at)
|
||||
if commit.Result != nil {
|
||||
g.SetLastResult(commit.Result)
|
||||
}
|
||||
|
||||
s.waiter.NotifyGame(gameID, len(g.Moves()), state)
|
||||
|
||||
if s.store != nil {
|
||||
s.store.RecordMove(storage.MoveRecord{
|
||||
GameID: gameID, MoveNumber: len(g.Moves()), MoveUCI: moveUCI,
|
||||
FENAfterMove: newFEN, PlayerColor: currentTurn.String(),
|
||||
MoveTimeUTC: time.Now().UTC(),
|
||||
})
|
||||
result, _ := commit.State.Result()
|
||||
persistence := storage.MovePersistence{
|
||||
Move: storage.MoveRecord{
|
||||
GameID: gameID, MoveNumber: len(g.Moves()), MoveUCI: commit.MoveUCI,
|
||||
FENAfterMove: commit.NewFEN, PlayerColor: currentTurn.String(), MoveTimeUTC: at,
|
||||
},
|
||||
ClaimColor: currentTurn.String(),
|
||||
ClaimedBy: claimUserID,
|
||||
Result: result,
|
||||
EndTimeUTC: g.EndTimeUTC(),
|
||||
}
|
||||
if claimUserID == "" {
|
||||
persistence.ClaimColor = ""
|
||||
}
|
||||
if err := s.store.RecordMove(persistence); err != nil {
|
||||
slog.Error("failed to queue move persistence",
|
||||
"game_id", gameID, "move_number", len(g.Moves()), "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
s.waiter.NotifyGame(gameID, len(g.Moves()), commit.State)
|
||||
slog.Debug("game move applied",
|
||||
"game_id", gameID,
|
||||
"move_number", len(g.Moves()),
|
||||
"move", commit.MoveUCI,
|
||||
"state", commit.State.String(),
|
||||
"slot_claimed", claimUserID != "",
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -169,12 +283,21 @@ func (s *Service) UpdateGameState(gameID string, state core.State) error {
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
|
||||
}
|
||||
|
||||
g.SetState(state)
|
||||
previousState := g.State()
|
||||
now := time.Now().UTC()
|
||||
g.SetStateAt(state, now)
|
||||
if s.store != nil && state.IsTerminal() && !previousState.IsTerminal() {
|
||||
result, _ := state.Result()
|
||||
if err := s.store.RecordGameResult(gameID, result, now); err != nil {
|
||||
slog.Error("failed to queue game result persistence", "game_id", gameID, "error", err)
|
||||
}
|
||||
}
|
||||
// Notify unconditionally; the registry decides.
|
||||
s.waiter.NotifyGame(gameID, len(g.Moves()), state)
|
||||
slog.Debug("game state updated", "game_id", gameID, "from", previousState.String(), "to", state.String())
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -186,7 +309,7 @@ func (s *Service) SetLastMoveResult(gameID string, result *game.MoveResult) erro
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
|
||||
}
|
||||
|
||||
g.SetLastResult(result)
|
||||
@@ -200,7 +323,10 @@ func (s *Service) UndoMoves(gameID string, count int) error {
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
|
||||
}
|
||||
if g.State() == core.StatePending {
|
||||
return errors.New("cannot undo while computer move is in progress")
|
||||
}
|
||||
|
||||
originalMoveCount := len(g.Moves())
|
||||
@@ -215,8 +341,11 @@ func (s *Service) UndoMoves(gameID string, count int) error {
|
||||
// Delete undone moves from storage if enabled
|
||||
if s.store != nil {
|
||||
remainingMoves := originalMoveCount - count
|
||||
s.store.DeleteUndoneMoves(gameID, remainingMoves)
|
||||
if err := s.store.RewindGame(gameID, remainingMoves); err != nil {
|
||||
slog.Error("failed to queue game rewind persistence", "game_id", gameID, "error", err)
|
||||
}
|
||||
}
|
||||
slog.Debug("game moves undone", "game_id", gameID, "count", count, "remaining_moves", len(g.Moves()))
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -228,7 +357,10 @@ func (s *Service) DeleteGame(gameID string) error {
|
||||
|
||||
g, ok := s.games[gameID]
|
||||
if !ok {
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
return fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
|
||||
}
|
||||
if g.State() == core.StatePending {
|
||||
return errors.New("cannot delete game while computer move is in progress")
|
||||
}
|
||||
|
||||
// Decrement computer game count if applicable
|
||||
@@ -240,6 +372,16 @@ func (s *Service) DeleteGame(gameID string) error {
|
||||
s.waiter.RemoveGame(gameID)
|
||||
|
||||
delete(s.games, gameID)
|
||||
slog.Debug("game unloaded from memory", "game_id", gameID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func playerRecord(player *core.Player) storage.PlayerRecord {
|
||||
return storage.PlayerRecord{
|
||||
PlayerID: player.ID,
|
||||
Type: int(player.Type),
|
||||
Level: player.Level,
|
||||
SearchTime: player.SearchTime,
|
||||
ClaimedBy: player.ClaimedBy,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chess/internal/server/core"
|
||||
"chess/internal/server/game"
|
||||
"chess/internal/server/storage"
|
||||
)
|
||||
|
||||
func TestMoveCommitClaimsSlotPersistsResultAndRejectsStalePosition(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
||||
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
||||
if err := svc.CreateGame(
|
||||
"game-1", white, black, "initial", core.ColorWhite, core.StateOngoing,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ended := time.Date(2026, 9, 7, 2, 3, 4, 0, time.UTC)
|
||||
commit := MoveCommit{
|
||||
ExpectedFEN: "initial", ExpectedState: core.StateOngoing, ExpectedTurn: core.ColorWhite,
|
||||
ActorUserID: "user-1", MoveUCI: "e2e4", NewFEN: "after", State: core.StateWhiteWins, At: ended,
|
||||
Result: &game.MoveResult{Move: "e2e4", PlayerColor: core.ColorWhite, GameState: core.StateWhiteWins},
|
||||
}
|
||||
if err := svc.ApplyMoveWithState("game-1", commit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
view, err := svc.GetGameView("game-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if view.WhitePlayer.ClaimedBy != "user-1" || view.State != core.StateWhiteWins {
|
||||
t.Fatalf("in-memory move did not settle atomically: %+v", view)
|
||||
}
|
||||
history, err := svc.GetGameHistory("game-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if history.Players.White.ClaimedBy != "user-1" || history.Result != "white_wins" || len(history.Moves) != 1 {
|
||||
t.Fatalf("history did not settle atomically: %+v", history)
|
||||
}
|
||||
|
||||
if err := svc.ApplyMoveWithState("game-1", commit); !errors.Is(err, ErrGameChanged) {
|
||||
t.Fatalf("stale move error = %v, want ErrGameChanged", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndoClearsDurableTerminalResult(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
||||
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
||||
if err := svc.CreateGame(
|
||||
"game-1", white, black, "initial", core.ColorWhite, core.StateOngoing,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.ApplyMoveWithState("game-1", MoveCommit{
|
||||
ExpectedFEN: "initial", ExpectedState: core.StateOngoing, ExpectedTurn: core.ColorWhite,
|
||||
MoveUCI: "e2e4", NewFEN: "after", State: core.StateStalemate,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.UndoMoves("game-1", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
history, err := svc.GetGameHistory("game-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if history.Result != "" || history.EndTimeUTC != nil || len(history.Moves) != 0 {
|
||||
t.Fatalf("undo left terminal persistence: %+v", history)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayerReconfigurationPreservesClaimAndPersistsConfiguration(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
||||
white.ID = "user-1"
|
||||
white.ClaimedBy = "user-1"
|
||||
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
||||
if err := svc.CreateGame(
|
||||
"game-1", white, black, "initial", core.ColorWhite, core.StateOngoing,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
replacementWhite := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
||||
replacementBlack := core.NewPlayer(
|
||||
core.PlayerConfig{Type: core.PlayerComputer, Level: 12, SearchTime: 500}, core.ColorBlack,
|
||||
)
|
||||
if err := svc.UpdatePlayers("game-1", replacementWhite, replacementBlack); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
history, err := svc.GetGameHistory("game-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if history.Players.White.ID != "user-1" || history.Players.White.ClaimedBy != "user-1" {
|
||||
t.Fatalf("white ownership was replaced: %+v", history.Players.White)
|
||||
}
|
||||
if history.Players.Black.Type != core.PlayerComputer || history.Players.Black.Level != 12 {
|
||||
t.Fatalf("black configuration was not persisted: %+v", history.Players.Black)
|
||||
}
|
||||
|
||||
computerWhite := core.NewPlayer(
|
||||
core.PlayerConfig{Type: core.PlayerComputer, Level: 8, SearchTime: 300}, core.ColorWhite,
|
||||
)
|
||||
if err := svc.UpdatePlayers("game-1", computerWhite, replacementBlack); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
history, err = svc.GetGameHistory("game-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if history.Players.White.ClaimedBy != "user-1" {
|
||||
t.Fatalf("player-type change discarded historical ownership: %+v", history.Players.White)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupEvictsOnlyMemoryCopyOfTerminalGame(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
svc.SetFinishedGameTTL(time.Nanosecond)
|
||||
white := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorWhite)
|
||||
black := core.NewPlayer(core.PlayerConfig{Type: core.PlayerHuman}, core.ColorBlack)
|
||||
if err := svc.CreateGame(
|
||||
"game-1", white, black, "terminal", core.ColorWhite, core.StateStalemate,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc.cleanupFinishedGames(time.Now().UTC().Add(time.Second))
|
||||
if _, err := svc.GetGameView("game-1"); !errors.Is(err, ErrGameNotFound) {
|
||||
t.Fatalf("terminal game remains in memory: %v", err)
|
||||
}
|
||||
if history, err := svc.GetGameHistory("game-1"); err != nil || history.Result != "stalemate" {
|
||||
t.Fatalf("durable history was removed: history=%+v err=%v", history, err)
|
||||
}
|
||||
}
|
||||
|
||||
func newPersistentTestService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
store, err := storage.NewStore(filepath.Join(t.TempDir(), "chess.db"), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.InitDB(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := New(store, []byte("test-secret-test-secret-test-secret"))
|
||||
t.Cleanup(func() {
|
||||
if err := svc.Shutdown(time.Second); err != nil {
|
||||
t.Errorf("shutdown: %v", err)
|
||||
}
|
||||
})
|
||||
return svc
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"chess/internal/server/core"
|
||||
"chess/internal/server/storage"
|
||||
)
|
||||
|
||||
// GetGameHistory returns a durable replay even after the live game has been
|
||||
// evicted from memory or the server has restarted.
|
||||
func (s *Service) GetGameHistory(gameID string) (*core.GameHistoryResponse, error) {
|
||||
if s.store == nil {
|
||||
return nil, ErrStorageDisabled
|
||||
}
|
||||
record, moves, err := s.store.GetGameHistory(gameID)
|
||||
if err != nil {
|
||||
if storage.IsGameNotFound(err) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrGameNotFound, gameID)
|
||||
}
|
||||
if isStorageUnavailable(err) {
|
||||
return nil, fmt.Errorf("%w: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
return nil, fmt.Errorf("get game history: %w", err)
|
||||
}
|
||||
|
||||
history := &core.GameHistoryResponse{
|
||||
GameID: record.GameID,
|
||||
InitialFEN: record.InitialFEN,
|
||||
Result: record.Result,
|
||||
StartTimeUTC: record.StartTimeUTC,
|
||||
EndTimeUTC: record.EndTimeUTC,
|
||||
Players: playersResponse(*record),
|
||||
Moves: make([]core.HistoryMove, 0, len(moves)),
|
||||
}
|
||||
for _, move := range moves {
|
||||
history.Moves = append(history.Moves, core.HistoryMove{
|
||||
MoveNumber: move.MoveNumber,
|
||||
MoveUCI: move.MoveUCI,
|
||||
FENAfterMove: move.FENAfterMove,
|
||||
PlayerColor: move.PlayerColor,
|
||||
MoveTimeUTC: move.MoveTimeUTC,
|
||||
})
|
||||
}
|
||||
return history, nil
|
||||
}
|
||||
|
||||
// GetUserGames returns a bounded page of games associated either at creation
|
||||
// or by a later slot claim.
|
||||
func (s *Service) GetUserGames(userID string, limit, offset int) (*core.GameListResponse, error) {
|
||||
if s.store == nil {
|
||||
return nil, ErrStorageDisabled
|
||||
}
|
||||
records, err := s.store.QueryGamesForUser(userID, limit+1, offset)
|
||||
if err != nil {
|
||||
if isStorageUnavailable(err) {
|
||||
return nil, fmt.Errorf("%w: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
return nil, fmt.Errorf("get user games: %w", err)
|
||||
}
|
||||
|
||||
hasNext := len(records) > limit
|
||||
if hasNext {
|
||||
records = records[:limit]
|
||||
}
|
||||
response := &core.GameListResponse{
|
||||
Games: make([]core.GameSummary, 0, len(records)),
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
if hasNext {
|
||||
next := offset + limit
|
||||
response.NextOffset = &next
|
||||
}
|
||||
for _, record := range records {
|
||||
response.Games = append(response.Games, core.GameSummary{
|
||||
GameID: record.GameID,
|
||||
InitialFEN: record.InitialFEN,
|
||||
Result: record.Result,
|
||||
StartTimeUTC: record.StartTimeUTC,
|
||||
EndTimeUTC: record.EndTimeUTC,
|
||||
MoveCount: record.MoveCount,
|
||||
Players: playersResponse(record.GameRecord),
|
||||
})
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func isStorageUnavailable(err error) bool {
|
||||
return errors.Is(err, storage.ErrStorageDegraded) ||
|
||||
errors.Is(err, storage.ErrStoreClosed) ||
|
||||
errors.Is(err, storage.ErrWriteQueueFull) ||
|
||||
errors.Is(err, context.DeadlineExceeded)
|
||||
}
|
||||
|
||||
func playersResponse(record storage.GameRecord) core.PlayersResponse {
|
||||
return core.PlayersResponse{
|
||||
White: &core.Player{
|
||||
ID: record.WhitePlayerID, Color: core.ColorWhite, Type: core.PlayerType(record.WhiteType),
|
||||
Level: record.WhiteLevel, SearchTime: record.WhiteSearchTime, ClaimedBy: record.WhiteClaimedBy,
|
||||
},
|
||||
Black: &core.Player{
|
||||
ID: record.BlackPlayerID, Color: core.ColorBlack, Type: core.PlayerType(record.BlackType),
|
||||
Level: record.BlackLevel, SearchTime: record.BlackSearchTime, ClaimedBy: record.BlackClaimedBy,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -20,28 +21,41 @@ const (
|
||||
TempUserTTL = 24 * time.Hour
|
||||
SessionTTL = 7 * 24 * time.Hour
|
||||
CleanupJobInterval = 1 * time.Hour
|
||||
FinishedGameTTL = 1 * time.Hour
|
||||
)
|
||||
|
||||
// Service coordinates game state, user management, and storage
|
||||
type Service struct {
|
||||
games map[string]*game.Game
|
||||
mu sync.RWMutex
|
||||
userMu sync.Mutex
|
||||
store *storage.Store
|
||||
jwtSecret []byte
|
||||
waiter *WaitRegistry
|
||||
computerGames atomic.Int32 // Active games with computer players
|
||||
finishedTTL time.Duration
|
||||
}
|
||||
|
||||
// New creates a new service instance with optional storage
|
||||
func New(store *storage.Store, jwtSecret []byte) *Service {
|
||||
return &Service{
|
||||
games: make(map[string]*game.Game),
|
||||
store: store,
|
||||
jwtSecret: jwtSecret,
|
||||
waiter: NewWaitRegistry(),
|
||||
games: make(map[string]*game.Game),
|
||||
store: store,
|
||||
jwtSecret: jwtSecret,
|
||||
waiter: NewWaitRegistry(),
|
||||
finishedTTL: FinishedGameTTL,
|
||||
}
|
||||
}
|
||||
|
||||
// SetFinishedGameTTL configures how long terminal games remain in memory.
|
||||
// Durable rows and moves are never removed by this cleanup. A non-positive
|
||||
// duration disables terminal-game eviction.
|
||||
func (s *Service) SetFinishedGameTTL(ttl time.Duration) {
|
||||
s.mu.Lock()
|
||||
s.finishedTTL = ttl
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// GetStorageHealth returns the storage component status
|
||||
func (s *Service) GetStorageHealth() string {
|
||||
if s.store == nil {
|
||||
@@ -55,6 +69,13 @@ func (s *Service) GetStorageHealth() string {
|
||||
|
||||
// RegisterWait registers a client to wait for game state changes
|
||||
func (s *Service) RegisterWait(gameID string, moveCount int, ctx context.Context) <-chan struct{} {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if _, ok := s.games[gameID]; !ok {
|
||||
notify := make(chan struct{})
|
||||
close(notify)
|
||||
return notify
|
||||
}
|
||||
return s.waiter.RegisterWait(gameID, moveCount, ctx)
|
||||
}
|
||||
|
||||
@@ -80,6 +101,9 @@ func (s *Service) GetComputerGameCount() int32 {
|
||||
|
||||
// ClaimGameSlot claims a player slot for a user
|
||||
func (s *Service) ClaimGameSlot(gameID string, color core.Color, userID string) error {
|
||||
if userID == "" {
|
||||
return errors.New("claimant user ID is required")
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -88,7 +112,17 @@ func (s *Service) ClaimGameSlot(gameID string, color core.Color, userID string)
|
||||
return fmt.Errorf("game not found: %s", gameID)
|
||||
}
|
||||
|
||||
return g.ClaimSlot(color, userID)
|
||||
if err := g.ClaimSlot(color, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.store != nil {
|
||||
if err := s.store.RecordSlotClaim(gameID, color.String(), userID); err != nil {
|
||||
slog.Error("failed to queue slot claim persistence",
|
||||
"game_id", gameID, "color", color.String(), "error", err)
|
||||
}
|
||||
}
|
||||
slog.Debug("game slot claimed", "game_id", gameID, "color", color.String(), "user_id", userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSlotOwner returns the user who claimed a slot
|
||||
@@ -113,9 +147,8 @@ func (s *Service) Shutdown(timeout time.Duration) error {
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.games = make(map[string]*game.Game)
|
||||
s.mu.Unlock()
|
||||
|
||||
if s.store != nil {
|
||||
if err := s.store.Close(); err != nil {
|
||||
@@ -128,6 +161,7 @@ func (s *Service) Shutdown(timeout time.Duration) error {
|
||||
|
||||
// RunCleanupJob runs periodic cleanup of expired users and sessions
|
||||
func (s *Service) RunCleanupJob(ctx context.Context, interval time.Duration) {
|
||||
s.cleanupExpired()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -142,22 +176,49 @@ func (s *Service) RunCleanupJob(ctx context.Context, interval time.Duration) {
|
||||
}
|
||||
|
||||
func (s *Service) cleanupExpired() {
|
||||
if s.store == nil {
|
||||
if s.store != nil {
|
||||
if deleted, err := s.store.DeleteExpiredTempUsers(); err != nil {
|
||||
slog.Error("cleanup failed to delete expired users", "error", err)
|
||||
} else if deleted > 0 {
|
||||
slog.Info("cleanup deleted expired temporary users", "count", deleted)
|
||||
}
|
||||
|
||||
if deleted, err := s.store.DeleteExpiredSessions(); err != nil {
|
||||
slog.Error("cleanup failed to delete expired sessions", "error", err)
|
||||
} else if deleted > 0 {
|
||||
slog.Info("cleanup deleted expired sessions", "count", deleted)
|
||||
}
|
||||
}
|
||||
|
||||
s.cleanupFinishedGames(time.Now().UTC())
|
||||
}
|
||||
|
||||
func (s *Service) cleanupFinishedGames(now time.Time) {
|
||||
s.mu.Lock()
|
||||
if s.finishedTTL <= 0 {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Cleanup expired temp users
|
||||
if deleted, err := s.store.DeleteExpiredTempUsers(); err != nil {
|
||||
// Log but don't fail
|
||||
fmt.Printf("cleanup: failed to delete expired users: %v\n", err)
|
||||
} else if deleted > 0 {
|
||||
fmt.Printf("cleanup: deleted %d expired temp users\n", deleted)
|
||||
cutoff := now.Add(-s.finishedTTL)
|
||||
removed := make([]string, 0)
|
||||
for gameID, g := range s.games {
|
||||
ended := g.EndTimeUTC()
|
||||
if !g.State().IsTerminal() || ended == nil || ended.After(cutoff) {
|
||||
continue
|
||||
}
|
||||
if g.HasComputerPlayer() {
|
||||
s.computerGames.Add(-1)
|
||||
}
|
||||
delete(s.games, gameID)
|
||||
removed = append(removed, gameID)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
// Cleanup expired sessions
|
||||
if deleted, err := s.store.DeleteExpiredSessions(); err != nil {
|
||||
fmt.Printf("cleanup: failed to delete expired sessions: %v\n", err)
|
||||
} else if deleted > 0 {
|
||||
fmt.Printf("cleanup: deleted %d expired sessions\n", deleted)
|
||||
for _, gameID := range removed {
|
||||
s.waiter.RemoveGame(gameID)
|
||||
}
|
||||
}
|
||||
if len(removed) > 0 {
|
||||
slog.Info("cleanup evicted terminal games from memory",
|
||||
"count", len(removed), "retention", s.finishedTTL)
|
||||
}
|
||||
}
|
||||
|
||||
+100
-59
@@ -1,8 +1,10 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -14,6 +16,7 @@ import (
|
||||
|
||||
var (
|
||||
ErrStorageDisabled = errors.New("storage disabled")
|
||||
ErrStorageUnavailable = errors.New("storage unavailable")
|
||||
ErrAtCapacity = errors.New("at capacity")
|
||||
ErrPermanentSlotsFull = errors.New("permanent slots full")
|
||||
)
|
||||
@@ -30,14 +33,25 @@ type User struct {
|
||||
|
||||
// CreateUser creates new user with registration limits enforcement
|
||||
func (s *Service) CreateUser(username, email, password string, permanent bool) (*User, error) {
|
||||
if s.store == nil {
|
||||
return nil, ErrStorageDisabled
|
||||
}
|
||||
user, _, err := s.createUser(username, email, password, permanent, false)
|
||||
return user, err
|
||||
}
|
||||
|
||||
// Check registration limits
|
||||
total, permCount, _, err := s.store.GetUserCounts()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user count: %w", err)
|
||||
// RegisterUser creates the account and its initial session in one SQLite
|
||||
// transaction, so a successful registration always returns a usable account.
|
||||
func (s *Service) RegisterUser(username, email, password string, permanent bool) (*User, string, error) {
|
||||
return s.createUser(username, email, password, permanent, true)
|
||||
}
|
||||
|
||||
func (s *Service) createUser(
|
||||
username, email, password string,
|
||||
permanent, withSession bool,
|
||||
) (*User, string, error) {
|
||||
s.userMu.Lock()
|
||||
defer s.userMu.Unlock()
|
||||
|
||||
if s.store == nil {
|
||||
return nil, "", ErrStorageDisabled
|
||||
}
|
||||
|
||||
// Determine account type
|
||||
@@ -45,32 +59,22 @@ func (s *Service) CreateUser(username, email, password string, permanent bool) (
|
||||
var expiresAt *time.Time
|
||||
|
||||
if permanent {
|
||||
if permCount >= PermanentSlots {
|
||||
return nil, fmt.Errorf("%w (%d/%d)", ErrPermanentSlotsFull, permCount, PermanentSlots)
|
||||
}
|
||||
accountType = "permanent"
|
||||
} else {
|
||||
expiry := time.Now().UTC().Add(TempUserTTL)
|
||||
expiresAt = &expiry
|
||||
}
|
||||
|
||||
// Handle capacity - remove oldest temp user if at max
|
||||
if total >= MaxUsers {
|
||||
if err := s.removeOldestTempUser(); err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrAtCapacity, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Hash password
|
||||
passwordHash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to hash password: %w", err)
|
||||
return nil, "", fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
// Generate unique user ID
|
||||
userID, err := s.generateUniqueUserID()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate unique ID: %w", err)
|
||||
return nil, "", fmt.Errorf("failed to generate unique ID: %w", err)
|
||||
}
|
||||
|
||||
// Create user record
|
||||
@@ -93,35 +97,46 @@ func (s *Service) CreateUser(username, email, password string, permanent bool) (
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
|
||||
if err = s.store.CreateUser(record); err != nil {
|
||||
return nil, err
|
||||
var sessionID string
|
||||
var session *storage.SessionRecord
|
||||
if withSession {
|
||||
sessionID = uuid.New().String()
|
||||
session = &storage.SessionRecord{
|
||||
SessionID: sessionID,
|
||||
UserID: userID,
|
||||
CreatedAt: user.CreatedAt,
|
||||
ExpiresAt: user.CreatedAt.Add(SessionTTL),
|
||||
}
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// removeOldestTempUser removes the oldest temporary user to make room
|
||||
func (s *Service) removeOldestTempUser() error {
|
||||
oldest, err := s.store.GetOldestTempUser()
|
||||
if err != nil {
|
||||
return fmt.Errorf("no temp users to remove: %w", err)
|
||||
limits := storage.UserLimits{
|
||||
MaxUsers: MaxUsers,
|
||||
PermanentSlots: PermanentSlots,
|
||||
}
|
||||
|
||||
// Delete their session first
|
||||
_ = s.store.DeleteSessionByUserID(oldest.UserID)
|
||||
|
||||
// Delete the user
|
||||
if err := s.store.DeleteUserByID(oldest.UserID); err != nil {
|
||||
return fmt.Errorf("failed to remove oldest user: %w", err)
|
||||
if err = s.store.CreateUserWithinLimits(record, session, limits); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrUserAlreadyExists):
|
||||
return nil, "", fmt.Errorf("username or email already exists: %w", err)
|
||||
case errors.Is(err, storage.ErrPermanentCapacity):
|
||||
return nil, "", fmt.Errorf("%w (%d maximum)", ErrPermanentSlotsFull, PermanentSlots)
|
||||
case errors.Is(err, storage.ErrUserCapacity):
|
||||
return nil, "", fmt.Errorf("%w: no temporary account can be replaced", ErrAtCapacity)
|
||||
default:
|
||||
return nil, "", fmt.Errorf("%w: create user: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
}
|
||||
slog.Debug("user created",
|
||||
"user_id", userID,
|
||||
"account_type", accountType,
|
||||
"initial_session", withSession,
|
||||
)
|
||||
|
||||
return nil
|
||||
return user, sessionID, nil
|
||||
}
|
||||
|
||||
// AuthenticateUser verifies credentials and creates a new session
|
||||
func (s *Service) AuthenticateUser(identifier, password string) (*User, string, error) {
|
||||
if s.store == nil {
|
||||
return nil, "", fmt.Errorf("storage disabled")
|
||||
return nil, "", ErrStorageDisabled
|
||||
}
|
||||
|
||||
var userRecord *storage.UserRecord
|
||||
@@ -136,6 +151,9 @@ func (s *Service) AuthenticateUser(identifier, password string) (*User, string,
|
||||
|
||||
if err != nil {
|
||||
auth.HashPassword(password) // Timing attack prevention
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, "", fmt.Errorf("%w: look up user: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
return nil, "", fmt.Errorf("invalid credentials")
|
||||
}
|
||||
|
||||
@@ -161,11 +179,14 @@ func (s *Service) AuthenticateUser(identifier, password string) (*User, string,
|
||||
}
|
||||
|
||||
if err := s.store.CreateSession(sessionRecord); err != nil {
|
||||
return nil, "", fmt.Errorf("failed to create session: %w", err)
|
||||
return nil, "", fmt.Errorf("%w: create session: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
slog.Debug("user authenticated", "user_id", userRecord.UserID)
|
||||
|
||||
// Update last login
|
||||
_ = s.store.UpdateUserLastLoginSync(userRecord.UserID, time.Now().UTC())
|
||||
if err := s.store.UpdateUserLastLoginSync(userRecord.UserID, time.Now().UTC()); err != nil {
|
||||
slog.Warn("failed to record user login time", "user_id", userRecord.UserID, "error", err)
|
||||
}
|
||||
|
||||
return &User{
|
||||
UserID: userRecord.UserID,
|
||||
@@ -180,28 +201,38 @@ func (s *Service) AuthenticateUser(identifier, password string) (*User, string,
|
||||
// ValidateSession checks if a session is valid
|
||||
func (s *Service) ValidateSession(sessionID string) (bool, error) {
|
||||
if s.store == nil {
|
||||
return false, fmt.Errorf("storage disabled")
|
||||
return false, ErrStorageDisabled
|
||||
}
|
||||
return s.store.IsSessionValid(sessionID)
|
||||
valid, err := s.store.IsSessionValid(sessionID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("%w: validate session: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
return valid, nil
|
||||
}
|
||||
|
||||
// InvalidateSession removes a session (logout)
|
||||
func (s *Service) InvalidateSession(sessionID string) error {
|
||||
if s.store == nil {
|
||||
return fmt.Errorf("storage disabled")
|
||||
return ErrStorageDisabled
|
||||
}
|
||||
return s.store.DeleteSession(sessionID)
|
||||
if err := s.store.DeleteSession(sessionID); err != nil {
|
||||
return fmt.Errorf("%w: invalidate session: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserByID retrieves user information by user ID
|
||||
func (s *Service) GetUserByID(userID string) (*User, error) {
|
||||
if s.store == nil {
|
||||
return nil, fmt.Errorf("storage disabled")
|
||||
return nil, ErrStorageDisabled
|
||||
}
|
||||
|
||||
userRecord, err := s.store.GetUserByID(userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("user not found")
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("user not found")
|
||||
}
|
||||
return nil, fmt.Errorf("%w: get user: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
|
||||
return &User{
|
||||
@@ -237,12 +268,19 @@ func (s *Service) ValidateToken(token string) (string, map[string]any, error) {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
// Validate session is still active
|
||||
if sessionID, ok := claims["session_id"].(string); ok && s.store != nil {
|
||||
valid, err := s.store.IsSessionValid(sessionID)
|
||||
if err != nil || !valid {
|
||||
return "", nil, fmt.Errorf("session invalidated")
|
||||
}
|
||||
if s.store == nil {
|
||||
return "", nil, ErrStorageDisabled
|
||||
}
|
||||
sessionID, ok := claims["session_id"].(string)
|
||||
if !ok || sessionID == "" {
|
||||
return "", nil, fmt.Errorf("token has no persisted session")
|
||||
}
|
||||
valid, err := s.store.IsSessionValidForUser(sessionID, userID)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("%w: validate token session: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
if !valid {
|
||||
return "", nil, fmt.Errorf("session invalidated")
|
||||
}
|
||||
|
||||
return userID, claims, nil
|
||||
@@ -254,19 +292,22 @@ func (s *Service) generateUniqueUserID() (string, error) {
|
||||
|
||||
for i := 0; i < maxAttempts; i++ {
|
||||
id := uuid.New().String()
|
||||
if _, err := s.store.GetUserByID(id); err != nil {
|
||||
if _, err := s.store.GetUserByID(id); errors.Is(err, sql.ErrNoRows) {
|
||||
return id, nil
|
||||
} else if err != nil {
|
||||
return "", fmt.Errorf("%w: check generated user ID: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("failed to generate unique user ID")
|
||||
}
|
||||
|
||||
// CreateUserSession creates a session for a user without re-authenticating
|
||||
// Used after registration to avoid redundant password hashing
|
||||
// CreateUserSession creates a session for a trusted internal caller without
|
||||
// re-authenticating. Public registration uses RegisterUser so account and
|
||||
// initial session creation remain atomic.
|
||||
func (s *Service) CreateUserSession(userID string) (string, error) {
|
||||
if s.store == nil {
|
||||
return "", fmt.Errorf("storage disabled")
|
||||
return "", ErrStorageDisabled
|
||||
}
|
||||
|
||||
sessionID := uuid.New().String()
|
||||
@@ -278,9 +319,9 @@ func (s *Service) CreateUserSession(userID string) (string, error) {
|
||||
}
|
||||
|
||||
if err := s.store.CreateSession(sessionRecord); err != nil {
|
||||
return "", fmt.Errorf("failed to create session: %w", err)
|
||||
return "", fmt.Errorf("%w: create session: %v", ErrStorageUnavailable, err)
|
||||
}
|
||||
slog.Debug("user session created", "user_id", userID)
|
||||
|
||||
return sessionID, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/lixenwraith/auth"
|
||||
)
|
||||
|
||||
func TestValidateTokenRequiresSessionBoundToSubject(t *testing.T) {
|
||||
svc := newPersistentTestService(t)
|
||||
user, sessionID, err := svc.RegisterUser("alice", "", "Password1", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
validToken, err := svc.GenerateUserToken(user.UserID, sessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotUserID, _, err := svc.ValidateToken(validToken); err != nil || gotUserID != user.UserID {
|
||||
t.Fatalf("valid token rejected: user=%q err=%v", gotUserID, err)
|
||||
}
|
||||
|
||||
missingSession, err := auth.GenerateHS256Token(svc.jwtSecret, user.UserID, nil, SessionTTL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.ValidateToken(missingSession); err == nil {
|
||||
t.Fatal("token without a persisted session was accepted")
|
||||
}
|
||||
|
||||
wrongSubject, err := auth.GenerateHS256Token(svc.jwtSecret, "other-user", map[string]any{
|
||||
"session_id": sessionID,
|
||||
}, SessionTTL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.ValidateToken(wrongSubject); err == nil {
|
||||
t.Fatal("session was accepted for a different JWT subject")
|
||||
}
|
||||
|
||||
if err := svc.InvalidateSession(sessionID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.ValidateToken(validToken); err == nil {
|
||||
t.Fatal("invalidated session was accepted")
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,12 @@ const (
|
||||
|
||||
// WaitRegistry manages clients waiting for game state changes via long-polling
|
||||
type WaitRegistry struct {
|
||||
mu sync.RWMutex
|
||||
waiters map[string][]*WaitRequest // gameID → waiting clients
|
||||
shutdown chan struct{}
|
||||
wg sync.WaitGroup
|
||||
mu sync.RWMutex
|
||||
waiters map[string][]*WaitRequest // gameID → waiting clients
|
||||
shutdown chan struct{}
|
||||
wg sync.WaitGroup
|
||||
closed bool
|
||||
shutdownOnce sync.Once
|
||||
}
|
||||
|
||||
// WaitRequest represents a single client waiting for game updates
|
||||
@@ -31,6 +33,8 @@ type WaitRequest struct {
|
||||
Timer *time.Timer // Timeout timer
|
||||
Context context.Context // Client connection context
|
||||
GameID string // Game being watched
|
||||
done chan struct{}
|
||||
finish sync.Once
|
||||
}
|
||||
|
||||
// NewWaitRegistry creates a new wait registry
|
||||
@@ -44,7 +48,12 @@ func NewWaitRegistry() *WaitRegistry {
|
||||
// RegisterWait registers a client to wait for game state changes
|
||||
func (w *WaitRegistry) RegisterWait(gameID string, moveCount int, ctx context.Context) <-chan struct{} {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.closed {
|
||||
w.mu.Unlock()
|
||||
notify := make(chan struct{})
|
||||
close(notify)
|
||||
return notify
|
||||
}
|
||||
|
||||
// Create wait request
|
||||
req := &WaitRequest{
|
||||
@@ -52,11 +61,12 @@ func (w *WaitRegistry) RegisterWait(gameID string, moveCount int, ctx context.Co
|
||||
Notify: make(chan struct{}, WaitChannelBuffer),
|
||||
Context: ctx,
|
||||
GameID: gameID,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Setup timeout timer
|
||||
req.Timer = time.AfterFunc(WaitTimeout, func() {
|
||||
w.handleTimeout(req)
|
||||
w.complete(req)
|
||||
})
|
||||
|
||||
// Add to waiters map
|
||||
@@ -64,20 +74,15 @@ func (w *WaitRegistry) RegisterWait(gameID string, moveCount int, ctx context.Co
|
||||
|
||||
// Setup cleanup on context cancellation
|
||||
w.wg.Add(1)
|
||||
w.mu.Unlock()
|
||||
go func() {
|
||||
defer w.wg.Done()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Client disconnected
|
||||
w.removeWaiter(gameID, req)
|
||||
case <-req.Notify:
|
||||
// Notification received
|
||||
req.Timer.Stop()
|
||||
w.removeWaiter(gameID, req)
|
||||
w.complete(req)
|
||||
case <-w.shutdown:
|
||||
// Server shutting down
|
||||
req.Timer.Stop()
|
||||
close(req.Notify)
|
||||
w.complete(req)
|
||||
case <-req.done:
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -87,7 +92,7 @@ func (w *WaitRegistry) RegisterWait(gameID string, moveCount int, ctx context.Co
|
||||
// NotifyGame notifies all clients waiting on a game about state change
|
||||
func (w *WaitRegistry) NotifyGame(gameID string, currentMoveCount int, state core.State) {
|
||||
w.mu.RLock()
|
||||
waitList := w.waiters[gameID]
|
||||
waitList := append([]*WaitRequest(nil), w.waiters[gameID]...)
|
||||
w.mu.RUnlock()
|
||||
if len(waitList) == 0 {
|
||||
return
|
||||
@@ -95,33 +100,31 @@ func (w *WaitRegistry) NotifyGame(gameID string, currentMoveCount int, state cor
|
||||
settled := state != core.StateOngoing && state != core.StatePending
|
||||
for _, req := range waitList {
|
||||
if settled || req.MoveCount != currentMoveCount {
|
||||
select {
|
||||
case req.Notify <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
w.complete(req)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveGame removes all waiters for a game (called before game deletion)
|
||||
func (w *WaitRegistry) RemoveGame(gameID string) {
|
||||
w.mu.Lock()
|
||||
waitList := w.waiters[gameID]
|
||||
delete(w.waiters, gameID)
|
||||
w.mu.Unlock()
|
||||
w.mu.RLock()
|
||||
waitList := append([]*WaitRequest(nil), w.waiters[gameID]...)
|
||||
w.mu.RUnlock()
|
||||
|
||||
// Notify all waiters that game is gone
|
||||
for _, req := range waitList {
|
||||
select {
|
||||
case req.Notify <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
w.complete(req)
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the wait registry
|
||||
func (w *WaitRegistry) Shutdown(timeout time.Duration) error {
|
||||
close(w.shutdown)
|
||||
w.shutdownOnce.Do(func() {
|
||||
w.mu.Lock()
|
||||
w.closed = true
|
||||
close(w.shutdown)
|
||||
w.mu.Unlock()
|
||||
})
|
||||
|
||||
// Wait for all goroutines with timeout
|
||||
done := make(chan struct{})
|
||||
@@ -138,36 +141,27 @@ func (w *WaitRegistry) Shutdown(timeout time.Duration) error {
|
||||
}
|
||||
}
|
||||
|
||||
// handleTimeout handles wait request timeout
|
||||
func (w *WaitRegistry) handleTimeout(req *WaitRequest) {
|
||||
// Send timeout notification
|
||||
select {
|
||||
case req.Notify <- struct{}{}:
|
||||
// Timeout notification sent
|
||||
default:
|
||||
// Channel full or closed
|
||||
}
|
||||
}
|
||||
// complete removes a waiter and closes its notification channel exactly once.
|
||||
// The registry never consumes Notify itself: the HTTP handler is its sole
|
||||
// consumer, so a state-change signal cannot be lost to a cleanup goroutine.
|
||||
func (w *WaitRegistry) complete(req *WaitRequest) {
|
||||
req.finish.Do(func() {
|
||||
req.Timer.Stop()
|
||||
|
||||
// removeWaiter removes a specific waiter from the registry
|
||||
func (w *WaitRegistry) removeWaiter(gameID string, req *WaitRequest) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
waitList := w.waiters[gameID]
|
||||
for i, waiter := range waitList {
|
||||
if waiter == req {
|
||||
// Remove from slice
|
||||
w.waiters[gameID] = append(waitList[:i], waitList[i+1:]...)
|
||||
break
|
||||
w.mu.Lock()
|
||||
waitList := w.waiters[req.GameID]
|
||||
for i, waiter := range waitList {
|
||||
if waiter == req {
|
||||
w.waiters[req.GameID] = append(waitList[:i], waitList[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(w.waiters[req.GameID]) == 0 {
|
||||
delete(w.waiters, req.GameID)
|
||||
}
|
||||
w.mu.Unlock()
|
||||
|
||||
// Clean up empty entries
|
||||
if len(w.waiters[gameID]) == 0 {
|
||||
delete(w.waiters, gameID)
|
||||
}
|
||||
|
||||
// Stop timer if still running
|
||||
req.Timer.Stop()
|
||||
close(req.done)
|
||||
close(req.Notify)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chess/internal/server/core"
|
||||
)
|
||||
|
||||
func TestWaitRegistryDeliversNotificationToCallerAndRemovesWaiter(t *testing.T) {
|
||||
registry := NewWaitRegistry()
|
||||
notify := registry.RegisterWait("game-1", 0, context.Background())
|
||||
|
||||
registry.NotifyGame("game-1", 1, core.StateOngoing)
|
||||
select {
|
||||
case <-notify:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("game update was consumed before reaching the caller")
|
||||
}
|
||||
select {
|
||||
case _, ok := <-notify:
|
||||
if ok {
|
||||
t.Fatal("completed notification channel returned another value")
|
||||
}
|
||||
default:
|
||||
t.Fatal("completed notification channel was not closed")
|
||||
}
|
||||
|
||||
registry.mu.RLock()
|
||||
remaining := len(registry.waiters["game-1"])
|
||||
registry.mu.RUnlock()
|
||||
if remaining != 0 {
|
||||
t.Fatalf("completed waiter remains registered: %d", remaining)
|
||||
}
|
||||
if err := registry.Shutdown(time.Second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitRegistryCompletionAndShutdownAreConcurrentAndIdempotent(t *testing.T) {
|
||||
registry := NewWaitRegistry()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
notify := registry.RegisterWait("game-1", 0, ctx)
|
||||
|
||||
var callers sync.WaitGroup
|
||||
callers.Add(3)
|
||||
go func() {
|
||||
defer callers.Done()
|
||||
registry.NotifyGame("game-1", 1, core.StateOngoing)
|
||||
}()
|
||||
go func() {
|
||||
defer callers.Done()
|
||||
registry.RemoveGame("game-1")
|
||||
}()
|
||||
go func() {
|
||||
defer callers.Done()
|
||||
cancel()
|
||||
}()
|
||||
callers.Wait()
|
||||
|
||||
select {
|
||||
case <-notify:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("concurrent completion did not notify caller")
|
||||
}
|
||||
if err := registry.Shutdown(time.Second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := registry.Shutdown(time.Second); err != nil {
|
||||
t.Fatalf("second shutdown failed: %v", err)
|
||||
}
|
||||
|
||||
afterShutdown := registry.RegisterWait("game-2", 0, context.Background())
|
||||
select {
|
||||
case <-afterShutdown:
|
||||
default:
|
||||
t.Fatal("registration after shutdown did not return a closed signal")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user