v0.11.0 harden persistence and prepare game replays
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -185,14 +186,11 @@ func (p *Processor) handleCreateGame(cmd Command) ProcessorResponse {
|
||||
}
|
||||
}
|
||||
|
||||
if err = p.svc.CreateGame(gameID, whitePlayer, blackPlayer, validatedFEN, b.Turn()); err != nil {
|
||||
if err = p.svc.CreateGame(gameID, whitePlayer, blackPlayer, validatedFEN, b.Turn(), initialState); err != nil {
|
||||
return p.errorResponse(fmt.Sprintf("failed to create game: %v", err), core.ErrInternalError)
|
||||
}
|
||||
if initialState != core.StateOngoing {
|
||||
p.svc.UpdateGameState(gameID, initialState)
|
||||
}
|
||||
|
||||
g, err := p.svc.GetGame(gameID)
|
||||
g, err := p.svc.GetGameView(gameID)
|
||||
if err != nil {
|
||||
return p.errorResponse("game creation failed", core.ErrInternalError)
|
||||
}
|
||||
@@ -217,13 +215,13 @@ func (p *Processor) handleConfigurePlayers(cmd Command) ProcessorResponse {
|
||||
args.Black.SearchTime = minSearchTime
|
||||
}
|
||||
|
||||
g, err := p.svc.GetGame(cmd.GameID)
|
||||
g, err := p.svc.GetGameView(cmd.GameID)
|
||||
if err != nil {
|
||||
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||
}
|
||||
|
||||
// Block configuration changes during computer move
|
||||
if g.State() == core.StatePending {
|
||||
if g.State == core.StatePending {
|
||||
return p.errorResponse("cannot change players while computer is calculating", core.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
@@ -237,7 +235,7 @@ func (p *Processor) handleConfigurePlayers(cmd Command) ProcessorResponse {
|
||||
}
|
||||
|
||||
// Get updated game
|
||||
g, _ = p.svc.GetGame(cmd.GameID)
|
||||
g, _ = p.svc.GetGameView(cmd.GameID)
|
||||
response := p.buildGameResponse(cmd.GameID, g)
|
||||
|
||||
return ProcessorResponse{
|
||||
@@ -248,7 +246,7 @@ func (p *Processor) handleConfigurePlayers(cmd Command) ProcessorResponse {
|
||||
|
||||
// handleGetGame retrieves game state and triggers computer move if needed
|
||||
func (p *Processor) handleGetGame(cmd Command) ProcessorResponse {
|
||||
g, err := p.svc.GetGame(cmd.GameID)
|
||||
g, err := p.svc.GetGameView(cmd.GameID)
|
||||
if err != nil {
|
||||
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||
}
|
||||
@@ -272,27 +270,30 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
|
||||
return p.errorResponse("invalid arguments", core.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
g, err := p.svc.GetGame(cmd.GameID)
|
||||
g, err := p.svc.GetGameView(cmd.GameID)
|
||||
if err != nil {
|
||||
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||
}
|
||||
|
||||
// Validate game state
|
||||
switch g.State() {
|
||||
switch g.State {
|
||||
case core.StatePending:
|
||||
return p.errorResponse("computer move in progress", core.ErrInvalidRequest)
|
||||
case core.StateStuck:
|
||||
return p.errorResponse("game is stuck due to engine error", core.ErrGameOver)
|
||||
case core.StateWhiteWins, core.StateBlackWins, core.StateDraw, core.StateStalemate:
|
||||
return p.errorResponse(fmt.Sprintf("game is over: %s", g.State()), core.ErrGameOver)
|
||||
return p.errorResponse(fmt.Sprintf("game is over: %s", g.State), core.ErrGameOver)
|
||||
case core.StateOngoing:
|
||||
break
|
||||
default:
|
||||
return p.errorResponse("game is in invalid state", core.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
currentColor := g.NextTurnColor()
|
||||
currentColor := g.NextTurnColor
|
||||
currentPlayer := g.NextPlayer()
|
||||
if currentPlayer == nil {
|
||||
return p.errorResponse("current player is missing", core.ErrInternalError)
|
||||
}
|
||||
|
||||
// Handle computer move trigger
|
||||
if strings.TrimSpace(args.Move) == "cccc" {
|
||||
@@ -300,10 +301,18 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
|
||||
return p.errorResponse("not computer player's turn", core.ErrNotHumanTurn)
|
||||
}
|
||||
|
||||
p.svc.UpdateGameState(cmd.GameID, core.StatePending)
|
||||
p.triggerComputerMove(cmd.GameID, g)
|
||||
if err := p.svc.BeginComputerMove(cmd.GameID, g.FEN, currentColor); err != nil {
|
||||
if errors.Is(err, service.ErrGameChanged) {
|
||||
return p.errorResponse("game changed; refresh and retry", core.ErrConflict)
|
||||
}
|
||||
return p.errorResponse(fmt.Sprintf("failed to start computer move: %v", err), core.ErrInternalError)
|
||||
}
|
||||
if err := p.triggerComputerMove(cmd.GameID, g); err != nil {
|
||||
p.svc.UpdateGameState(cmd.GameID, core.StateStuck)
|
||||
return p.errorResponse(fmt.Sprintf("failed to queue computer move: %v", err), core.ErrResourceLimit)
|
||||
}
|
||||
|
||||
g, _ = p.svc.GetGame(cmd.GameID)
|
||||
g, _ = p.svc.GetGameView(cmd.GameID)
|
||||
response := p.buildGameResponse(cmd.GameID, g)
|
||||
response.LastMove = &core.MoveInfo{
|
||||
PlayerColor: currentColor.String(),
|
||||
@@ -322,16 +331,11 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
|
||||
}
|
||||
|
||||
// Authorization: first-move-claims-slot model
|
||||
slotOwner := g.GetSlotOwner(currentColor)
|
||||
slotOwner := currentPlayer.ClaimedBy
|
||||
|
||||
if slotOwner == "" {
|
||||
// Slot unclaimed - claim it with this move
|
||||
if cmd.UserID != "" {
|
||||
if err := p.svc.ClaimGameSlot(cmd.GameID, currentColor, cmd.UserID); err != nil {
|
||||
return p.errorResponse(fmt.Sprintf("failed to claim slot: %v", err), core.ErrInternalError)
|
||||
}
|
||||
}
|
||||
// Anonymous users can also claim by making a move (slot remains "unclaimed" but move proceeds)
|
||||
// An authenticated user claims only when the validated move commits.
|
||||
// Anonymous moves deliberately leave the slot unclaimed.
|
||||
} else if cmd.UserID != "" && slotOwner != cmd.UserID {
|
||||
return p.errorResponse("not your turn - slot claimed by another player", core.ErrUnauthorized)
|
||||
}
|
||||
@@ -345,7 +349,7 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
|
||||
return p.errorResponse("invalid move format", core.ErrInvalidMove)
|
||||
}
|
||||
|
||||
currentFEN := g.CurrentFEN()
|
||||
currentFEN := g.FEN
|
||||
|
||||
// Validate move and classify the resulting position in one engine session
|
||||
p.mu.Lock()
|
||||
@@ -365,16 +369,22 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
|
||||
}
|
||||
|
||||
// Atomic commit: move + state + metadata, single notification
|
||||
if err = p.svc.ApplyMoveWithState(cmd.GameID, move, newFEN, finalState, &game.MoveResult{
|
||||
Move: move,
|
||||
PlayerColor: currentColor,
|
||||
GameState: finalState,
|
||||
if err = p.svc.ApplyMoveWithState(cmd.GameID, service.MoveCommit{
|
||||
ExpectedFEN: currentFEN, ExpectedState: core.StateOngoing, ExpectedTurn: currentColor,
|
||||
ActorUserID: cmd.UserID, MoveUCI: move, NewFEN: newFEN, State: finalState,
|
||||
Result: &game.MoveResult{Move: move, PlayerColor: currentColor, GameState: finalState},
|
||||
}); err != nil {
|
||||
if errors.Is(err, service.ErrSlotOwner) {
|
||||
return p.errorResponse("not your turn - slot claimed by another player", core.ErrUnauthorized)
|
||||
}
|
||||
if errors.Is(err, service.ErrGameChanged) {
|
||||
return p.errorResponse("game changed while move was being validated; refresh and retry", core.ErrConflict)
|
||||
}
|
||||
return p.errorResponse(fmt.Sprintf("failed to apply move: %v", err), core.ErrInternalError)
|
||||
}
|
||||
|
||||
// buildGameResponse populates LastMove from the committed LastResult
|
||||
g, _ = p.svc.GetGame(cmd.GameID)
|
||||
g, _ = p.svc.GetGameView(cmd.GameID)
|
||||
return ProcessorResponse{
|
||||
Success: true,
|
||||
Data: p.buildGameResponse(cmd.GameID, g),
|
||||
@@ -387,12 +397,12 @@ func (p *Processor) handleMakeMove(cmd Command) ProcessorResponse {
|
||||
// snapshot had legal moves made from it, so resetting to Ongoing is sound
|
||||
// without re-classification.
|
||||
func (p *Processor) handleUndoMove(cmd Command) ProcessorResponse {
|
||||
g, err := p.svc.GetGame(cmd.GameID)
|
||||
g, err := p.svc.GetGameView(cmd.GameID)
|
||||
if err != nil {
|
||||
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||
}
|
||||
|
||||
if g.State() == core.StatePending {
|
||||
if g.State == core.StatePending {
|
||||
return p.errorResponse("cannot undo while computer move is in progress", core.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
@@ -410,25 +420,22 @@ func (p *Processor) handleUndoMove(cmd Command) ProcessorResponse {
|
||||
return p.errorResponse(err.Error(), core.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
// Reset game state to ongoing after undo
|
||||
p.svc.UpdateGameState(cmd.GameID, core.StateOngoing)
|
||||
|
||||
g, _ = p.svc.GetGame(cmd.GameID)
|
||||
g, _ = p.svc.GetGameView(cmd.GameID)
|
||||
return ProcessorResponse{
|
||||
Success: true,
|
||||
Data: p.buildGameResponse(cmd.GameID, g),
|
||||
}
|
||||
}
|
||||
|
||||
// handleDeleteGame removes a game
|
||||
// handleDeleteGame unloads a game from live memory.
|
||||
func (p *Processor) handleDeleteGame(cmd Command) ProcessorResponse {
|
||||
g, err := p.svc.GetGame(cmd.GameID)
|
||||
g, err := p.svc.GetGameView(cmd.GameID)
|
||||
if err != nil {
|
||||
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||
}
|
||||
|
||||
// Only block deletion if actively computing
|
||||
if g.State() == core.StatePending {
|
||||
if g.State == core.StatePending {
|
||||
return p.errorResponse("cannot delete game while computer move is in progress", core.ErrInvalidRequest)
|
||||
}
|
||||
|
||||
@@ -443,12 +450,12 @@ func (p *Processor) handleDeleteGame(cmd Command) ProcessorResponse {
|
||||
|
||||
// handleGetBoard returns board visualization
|
||||
func (p *Processor) handleGetBoard(cmd Command) ProcessorResponse {
|
||||
g, err := p.svc.GetGame(cmd.GameID)
|
||||
g, err := p.svc.GetGameView(cmd.GameID)
|
||||
if err != nil {
|
||||
return p.errorResponse("game not found", core.ErrGameNotFound)
|
||||
}
|
||||
|
||||
b, err := board.ParseFEN(g.CurrentFEN())
|
||||
b, err := board.ParseFEN(g.FEN)
|
||||
if err != nil {
|
||||
return p.errorResponse("error parsing FEN", core.ErrInvalidFEN)
|
||||
}
|
||||
@@ -457,7 +464,7 @@ func (p *Processor) handleGetBoard(cmd Command) ProcessorResponse {
|
||||
return ProcessorResponse{
|
||||
Success: true,
|
||||
Data: core.BoardResponse{
|
||||
FEN: g.CurrentFEN(),
|
||||
FEN: g.FEN,
|
||||
Board: ascii,
|
||||
},
|
||||
}
|
||||
@@ -467,18 +474,18 @@ func (p *Processor) handleGetBoard(cmd Command) ProcessorResponse {
|
||||
// re-classifies via the validation engine: worker output is never trusted for
|
||||
// end-state determination, and no-move results are verified against the
|
||||
// position rather than the IsMate info-line byproduct.
|
||||
func (p *Processor) triggerComputerMove(gameID string, g *game.Game) {
|
||||
fen := g.CurrentFEN()
|
||||
color := g.NextTurnColor()
|
||||
func (p *Processor) triggerComputerMove(gameID string, g game.View) error {
|
||||
fen := g.FEN
|
||||
color := g.NextTurnColor
|
||||
player := g.NextPlayer()
|
||||
|
||||
p.queue.SubmitAsync(gameID, fen, color, player, func(result EngineResult) {
|
||||
currentGame, err := p.svc.GetGame(gameID)
|
||||
if err != nil || currentGame.State() != core.StatePending {
|
||||
return p.queue.SubmitAsync(gameID, fen, color, player, func(result EngineResult) {
|
||||
currentGame, err := p.svc.GetGameView(gameID)
|
||||
if err != nil || currentGame.State != core.StatePending || currentGame.FEN != fen {
|
||||
return // Deleted, or state resolved elsewhere
|
||||
}
|
||||
if result.Error != nil {
|
||||
log.Printf("engine error for game %s: %v", gameID, result.Error)
|
||||
slog.Error("computer engine failed", "game_id", gameID, "error", result.Error)
|
||||
p.svc.UpdateGameState(gameID, core.StateStuck)
|
||||
return
|
||||
}
|
||||
@@ -508,10 +515,19 @@ func (p *Processor) triggerComputerMove(gameID string, g *game.Game) {
|
||||
return
|
||||
}
|
||||
|
||||
p.svc.ApplyMoveWithState(gameID, result.Move, newFEN, finalState, &game.MoveResult{
|
||||
Move: result.Move, PlayerColor: color,
|
||||
Score: result.Score, Depth: result.Depth, GameState: finalState,
|
||||
})
|
||||
if err := p.svc.ApplyMoveWithState(gameID, service.MoveCommit{
|
||||
ExpectedFEN: fen, ExpectedState: core.StatePending, ExpectedTurn: color,
|
||||
MoveUCI: result.Move, NewFEN: newFEN, State: finalState,
|
||||
Result: &game.MoveResult{
|
||||
Move: result.Move, PlayerColor: color,
|
||||
Score: result.Score, Depth: result.Depth, GameState: finalState,
|
||||
},
|
||||
}); err != nil {
|
||||
slog.Error("failed to apply computer move", "game_id", gameID, "error", err)
|
||||
if !errors.Is(err, service.ErrGameChanged) && !errors.Is(err, service.ErrGameNotFound) {
|
||||
p.svc.UpdateGameState(gameID, core.StateStuck)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -583,27 +599,28 @@ func (p *Processor) checkGameEnd(gameID, fen string) {
|
||||
}
|
||||
return
|
||||
}
|
||||
log.Printf("game %s: end-state check attempt %d failed: %v", gameID, attempt+1, err)
|
||||
slog.Warn("game end-state check failed",
|
||||
"game_id", gameID, "attempt", attempt+1, "error", err)
|
||||
}
|
||||
p.svc.UpdateGameState(gameID, core.StateStuck)
|
||||
}
|
||||
|
||||
// buildGameResponse constructs standard game response
|
||||
func (p *Processor) buildGameResponse(gameID string, g *game.Game) core.GameResponse {
|
||||
func (p *Processor) buildGameResponse(gameID string, g game.View) core.GameResponse {
|
||||
resp := core.GameResponse{
|
||||
GameID: gameID,
|
||||
FEN: g.CurrentFEN(),
|
||||
Turn: g.NextTurnColor().String(),
|
||||
State: g.State().String(),
|
||||
Moves: g.Moves(),
|
||||
FEN: g.FEN,
|
||||
Turn: g.NextTurnColor.String(),
|
||||
State: g.State.String(),
|
||||
Moves: g.Moves,
|
||||
Players: core.PlayersResponse{
|
||||
White: g.GetPlayer(core.ColorWhite),
|
||||
Black: g.GetPlayer(core.ColorBlack),
|
||||
White: g.WhitePlayer,
|
||||
Black: g.BlackPlayer,
|
||||
},
|
||||
}
|
||||
|
||||
// Include last move if available
|
||||
if result := g.LastResult(); result != nil {
|
||||
if result := g.LastResult; result != nil {
|
||||
resp.LastMove = &core.MoveInfo{
|
||||
Move: result.Move,
|
||||
PlayerColor: result.PlayerColor.String(),
|
||||
@@ -628,6 +645,9 @@ func (p *Processor) errorResponse(message, code string) ProcessorResponse {
|
||||
|
||||
// Close cleans up resources
|
||||
func (p *Processor) Close() error {
|
||||
p.queue.Shutdown(5 * time.Second)
|
||||
return p.validationEng.Close()
|
||||
queueErr := p.queue.Shutdown(5 * time.Second)
|
||||
p.mu.Lock()
|
||||
engineErr := p.validationEng.Close()
|
||||
p.mu.Unlock()
|
||||
return errors.Join(queueErr, engineErr)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package processor
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -33,11 +33,15 @@ type EngineResult struct {
|
||||
|
||||
// EngineQueue manages async engine computations
|
||||
type EngineQueue struct {
|
||||
tasks chan EngineTask
|
||||
workers int
|
||||
wg sync.WaitGroup
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
tasks chan EngineTask
|
||||
workers int
|
||||
wg sync.WaitGroup
|
||||
callbackWG sync.WaitGroup
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
submitMu sync.RWMutex
|
||||
closed bool
|
||||
shutdownOnce sync.Once
|
||||
}
|
||||
|
||||
// NewEngineQueue creates a queue with specified worker count
|
||||
@@ -76,7 +80,7 @@ func (q *EngineQueue) worker(id int) {
|
||||
if eng, err = engine.New(); err == nil {
|
||||
break
|
||||
}
|
||||
log.Printf("worker %d: engine init failed: %v; retrying", id, err)
|
||||
slog.Warn("engine worker initialization failed; retrying", "worker", id, "error", err)
|
||||
select {
|
||||
case <-q.ctx.Done():
|
||||
return
|
||||
@@ -84,6 +88,7 @@ func (q *EngineQueue) worker(id int) {
|
||||
}
|
||||
}
|
||||
defer eng.Close()
|
||||
slog.Debug("engine worker started", "worker", id)
|
||||
for {
|
||||
select {
|
||||
case task, ok := <-q.tasks:
|
||||
@@ -99,6 +104,10 @@ func (q *EngineQueue) worker(id int) {
|
||||
|
||||
// processTask executes a single engine calculation
|
||||
func (q *EngineQueue) processTask(eng *engine.UCI, task EngineTask) EngineResult {
|
||||
started := time.Now()
|
||||
defer func() {
|
||||
slog.Debug("engine task completed", "game_id", task.GameID, "duration", time.Since(started))
|
||||
}()
|
||||
result := EngineResult{GameID: task.GameID}
|
||||
if err := eng.NewGame(); err != nil {
|
||||
result.Error = err
|
||||
@@ -134,8 +143,18 @@ func (q *EngineQueue) processTask(eng *engine.UCI, task EngineTask) EngineResult
|
||||
|
||||
// Submit adds a task to the queue
|
||||
func (q *EngineQueue) Submit(task EngineTask) error {
|
||||
q.submitMu.RLock()
|
||||
defer q.submitMu.RUnlock()
|
||||
return q.submitLocked(task)
|
||||
}
|
||||
|
||||
func (q *EngineQueue) submitLocked(task EngineTask) error {
|
||||
if q.closed {
|
||||
return fmt.Errorf("queue is shutting down")
|
||||
}
|
||||
select {
|
||||
case q.tasks <- task:
|
||||
slog.Debug("engine task queued", "game_id", task.GameID, "queue_depth", len(q.tasks))
|
||||
return nil
|
||||
case <-q.ctx.Done():
|
||||
return fmt.Errorf("queue is shutting down")
|
||||
@@ -146,8 +165,24 @@ func (q *EngineQueue) Submit(task EngineTask) error {
|
||||
|
||||
// SubmitAsync submits a task without blocking for result
|
||||
func (q *EngineQueue) SubmitAsync(gameID, fen string, color core.Color, player *core.Player, callback func(EngineResult)) error {
|
||||
if player == nil {
|
||||
return fmt.Errorf("computer player is missing")
|
||||
}
|
||||
if callback == nil {
|
||||
return fmt.Errorf("engine callback is missing")
|
||||
}
|
||||
respChan := make(chan EngineResult, 1)
|
||||
if err := q.Submit(EngineTask{GameID: gameID, FEN: fen, Color: color, Player: player, Response: respChan}); err != nil {
|
||||
q.submitMu.RLock()
|
||||
err := q.submitLocked(EngineTask{
|
||||
GameID: gameID, FEN: fen, Color: color, Player: player, Response: respChan,
|
||||
})
|
||||
if err == nil {
|
||||
// Registered while the submit lock is held, so Shutdown cannot begin
|
||||
// waiting between a successful send and this Add.
|
||||
q.callbackWG.Add(1)
|
||||
}
|
||||
q.submitMu.RUnlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
budget := 1000
|
||||
@@ -156,11 +191,18 @@ func (q *EngineQueue) SubmitAsync(gameID, fen string, color core.Color, player *
|
||||
}
|
||||
wait := time.Duration(budget)*time.Millisecond*2 + 30*time.Second // search budget + queue-wait headroom
|
||||
go func() {
|
||||
defer q.callbackWG.Done()
|
||||
timer := time.NewTimer(wait)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case result := <-respChan:
|
||||
callback(result)
|
||||
case <-time.After(wait):
|
||||
case <-timer.C:
|
||||
callback(EngineResult{GameID: gameID, Error: fmt.Errorf("engine timeout")})
|
||||
case <-q.ctx.Done():
|
||||
// Live state is intentionally abandoned during server shutdown. The
|
||||
// last fully committed position remains the durable replay boundary.
|
||||
return
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
@@ -168,12 +210,18 @@ func (q *EngineQueue) SubmitAsync(gameID, fen string, color core.Color, player *
|
||||
|
||||
// Shutdown gracefully stops the queue
|
||||
func (q *EngineQueue) Shutdown(timeout time.Duration) error {
|
||||
q.cancel()
|
||||
close(q.tasks)
|
||||
q.shutdownOnce.Do(func() {
|
||||
q.submitMu.Lock()
|
||||
q.closed = true
|
||||
q.cancel()
|
||||
close(q.tasks)
|
||||
q.submitMu.Unlock()
|
||||
})
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
q.wg.Wait()
|
||||
q.callbackWG.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chess/internal/server/core"
|
||||
)
|
||||
|
||||
func TestEngineQueueShutdownCancelsAndWaitsForCallback(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
queue := &EngineQueue{
|
||||
tasks: make(chan EngineTask, 1),
|
||||
workers: 1,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
player := &core.Player{Type: core.PlayerComputer, SearchTime: 10_000}
|
||||
var callbackCalled atomic.Bool
|
||||
if err := queue.SubmitAsync("game-1", "fen", core.ColorWhite, player, func(EngineResult) {
|
||||
callbackCalled.Store(true)
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := queue.Shutdown(time.Second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if callbackCalled.Load() {
|
||||
t.Fatal("shutdown callback mutated live state after cancellation")
|
||||
}
|
||||
if err := queue.Submit(EngineTask{}); err == nil {
|
||||
t.Fatal("submit succeeded after shutdown")
|
||||
}
|
||||
if err := queue.Shutdown(time.Second); err != nil {
|
||||
t.Fatalf("second shutdown was not idempotent: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user