Files
chess/internal/server/service/waiter.go
T

168 lines
3.9 KiB
Go

package service
import (
"chess/internal/server/core"
"context"
"fmt"
"sync"
"time"
)
const (
// WaitTimeout is the maximum time a client can wait for notifications
WaitTimeout = 30 * time.Second
// WaitChannelBuffer size for notification channels
WaitChannelBuffer = 1
)
// 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
closed bool
shutdownOnce sync.Once
}
// WaitRequest represents a single client waiting for game updates
type WaitRequest struct {
MoveCount int // Last known move count
Notify chan struct{} // Buffered channel for notifications
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
func NewWaitRegistry() *WaitRegistry {
return &WaitRegistry{
waiters: make(map[string][]*WaitRequest),
shutdown: make(chan struct{}),
}
}
// 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()
if w.closed {
w.mu.Unlock()
notify := make(chan struct{})
close(notify)
return notify
}
// Create wait request
req := &WaitRequest{
MoveCount: moveCount,
Notify: make(chan struct{}, WaitChannelBuffer),
Context: ctx,
GameID: gameID,
done: make(chan struct{}),
}
// Setup timeout timer
req.Timer = time.AfterFunc(WaitTimeout, func() {
w.complete(req)
})
// Add to waiters map
w.waiters[gameID] = append(w.waiters[gameID], req)
// Setup cleanup on context cancellation
w.wg.Add(1)
w.mu.Unlock()
go func() {
defer w.wg.Done()
select {
case <-ctx.Done():
w.complete(req)
case <-w.shutdown:
w.complete(req)
case <-req.done:
}
}()
return req.Notify
}
// 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 := append([]*WaitRequest(nil), w.waiters[gameID]...)
w.mu.RUnlock()
if len(waitList) == 0 {
return
}
settled := state != core.StateOngoing && state != core.StatePending
for _, req := range waitList {
if settled || req.MoveCount != currentMoveCount {
w.complete(req)
}
}
}
// RemoveGame removes all waiters for a game (called before game deletion)
func (w *WaitRegistry) RemoveGame(gameID string) {
w.mu.RLock()
waitList := append([]*WaitRequest(nil), w.waiters[gameID]...)
w.mu.RUnlock()
// Notify all waiters that game is gone
for _, req := range waitList {
w.complete(req)
}
}
// Shutdown gracefully shuts down the wait registry
func (w *WaitRegistry) Shutdown(timeout time.Duration) error {
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{})
go func() {
w.wg.Wait()
close(done)
}()
select {
case <-done:
return nil
case <-time.After(timeout):
return fmt.Errorf("http wait registry shutdown failed")
}
}
// 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()
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()
close(req.done)
close(req.Notify)
})
}