Files
chess/internal/server/engine/engine.go
T

288 lines
6.5 KiB
Go

package engine
import (
"bufio"
"errors"
"fmt"
"io"
"os/exec"
"strings"
"sync"
"time"
)
const (
enginePath = "stockfish"
handshakeTimeout = 5 * time.Second
barrierTimeout = 5 * time.Second
diagnoseTimeout = 3 * time.Second
probeTimeout = 3 * time.Second
lineBuffer = 512
)
var ErrEngineTimeout = errors.New("engine timeout")
// UCI wraps a stockfish process. All engine dialogue is a serialized
// request/response transaction under mu; a single reader goroutine owns stdout
// for the life of the process. Any timeout/EOF kills and respawns the process:
// output desync cannot survive into the next call.
type UCI struct {
mu sync.Mutex
cmd *exec.Cmd
stdin io.WriteCloser
lines chan string
alive bool
}
type SearchResult struct {
BestMove string
Score int
Depth int
IsMate bool
MateIn int
}
type Diagnosis struct {
FEN string
InCheck bool
}
func New() (*UCI, error) {
u := &UCI{}
u.mu.Lock()
defer u.mu.Unlock()
if err := u.spawnLocked(); err != nil {
return nil, err
}
return u, nil
}
func (u *UCI) spawnLocked() error {
cmd := exec.Command(enginePath)
stdin, err := cmd.StdinPipe()
if err != nil {
return err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("start engine: %w", err)
}
lines := make(chan string, lineBuffer)
go func() {
sc := bufio.NewScanner(stdout)
sc.Buffer(make([]byte, 64*1024), 1<<20)
for sc.Scan() {
lines <- sc.Text()
}
close(lines) // EOF: process exited or was killed
}()
u.cmd, u.stdin, u.lines, u.alive = cmd, stdin, lines, true
if _, err := u.txLocked(handshakeTimeout, []string{"uci"}, "uciok", nil); err != nil {
u.killLocked()
return fmt.Errorf("uci handshake: %w", err)
}
if _, err := u.txLocked(handshakeTimeout, []string{"isready"}, "readyok", nil); err != nil {
u.killLocked()
return fmt.Errorf("uci handshake: %w", err)
}
return nil
}
// killLocked hard-stops the process. Reaping is deferred to a goroutine that
// first drains the line channel to completion, so cmd.Wait never races the
// reader's final reads on the stdout pipe.
func (u *UCI) killLocked() {
u.alive = false
if u.cmd != nil && u.cmd.Process != nil {
u.cmd.Process.Kill()
}
if u.stdin != nil {
u.stdin.Close()
}
if u.lines != nil {
go func(ch chan string, cmd *exec.Cmd) {
for range ch {
}
cmd.Wait()
}(u.lines, u.cmd)
}
}
func (u *UCI) restartLocked() {
u.killLocked()
_ = u.spawnLocked() // on failure alive stays false; next tx errors immediately
}
func (u *UCI) drainLocked() {
for {
select {
case _, ok := <-u.lines:
if !ok {
return
}
default:
return
}
}
}
// txLocked: drain stale lines, send commands, read to the terminal prefix.
// visit observes every line including the terminal one. Timeout is
// per-transaction total.
func (u *UCI) txLocked(timeout time.Duration, cmds []string, terminal string, visit func(string)) (string, error) {
if !u.alive {
return "", errors.New("engine not running")
}
u.drainLocked()
for _, c := range cmds {
if _, err := fmt.Fprintln(u.stdin, c); err != nil {
u.restartLocked()
return "", fmt.Errorf("engine write: %w", err)
}
}
deadline := time.NewTimer(timeout)
defer deadline.Stop()
for {
select {
case ln, ok := <-u.lines:
if !ok {
u.restartLocked()
return "", errors.New("engine closed unexpectedly")
}
if visit != nil {
visit(ln)
}
if strings.HasPrefix(ln, terminal) {
return ln, nil
}
case <-deadline.C:
u.restartLocked()
return "", fmt.Errorf("%w awaiting %q", ErrEngineTimeout, terminal)
}
}
}
func (u *UCI) tx(timeout time.Duration, cmds []string, terminal string, visit func(string)) (string, error) {
u.mu.Lock()
defer u.mu.Unlock()
return u.txLocked(timeout, cmds, terminal, visit)
}
func (u *UCI) NewGame() error {
_, err := u.tx(barrierTimeout, []string{"ucinewgame", "isready"}, "readyok", nil)
return err
}
func (u *UCI) SetSkillLevel(level int) error {
if level < 0 {
level = 0
} else if level > 20 {
level = 20
}
_, err := u.tx(barrierTimeout,
[]string{fmt.Sprintf("setoption name Skill Level value %d", level), "isready"},
"readyok", nil)
return err
}
func (u *UCI) SetPosition(fen string, moves []string) error {
cmd := "position fen " + fen
if len(moves) > 0 {
cmd += " moves " + strings.Join(moves, " ")
}
_, err := u.tx(barrierTimeout, []string{cmd, "isready"}, "readyok", nil)
return err
}
// Diagnose runs `d` and consumes its full output. Terminal line is "Checkers:"
// (last line of `d` in current Stockfish; verify against the jailed build —
// see context requests).
func (u *UCI) Diagnose() (Diagnosis, error) {
var d Diagnosis
last, err := u.tx(diagnoseTimeout, []string{"d"}, "Checkers:", func(ln string) {
if s, ok := strings.CutPrefix(ln, "Fen: "); ok {
d.FEN = strings.TrimSpace(s)
}
})
if err != nil {
return Diagnosis{}, err
}
if d.FEN == "" {
return Diagnosis{}, errors.New("d output missing Fen line")
}
d.InCheck = strings.TrimSpace(strings.TrimPrefix(last, "Checkers:")) != ""
return d, nil
}
// HasLegalMoves probes with a depth-1 search: deterministic, milliseconds.
func (u *UCI) HasLegalMoves() (bool, error) {
last, err := u.tx(probeTimeout, []string{"go depth 1"}, "bestmove ", nil)
if err != nil {
return false, err
}
f := strings.Fields(last)
return len(f) >= 2 && f[1] != "(none)", nil
}
func (u *UCI) Search(timeMs int) (*SearchResult, error) {
r := &SearchResult{}
timeout := time.Duration(timeMs)*time.Millisecond + 5*time.Second
last, err := u.tx(timeout, []string{fmt.Sprintf("go movetime %d", timeMs)}, "bestmove ", func(ln string) {
if !strings.HasPrefix(ln, "info ") {
return
}
f := strings.Fields(ln)
for i := 0; i < len(f)-1; i++ {
switch f[i] {
case "depth":
fmt.Sscanf(f[i+1], "%d", &r.Depth)
case "cp":
fmt.Sscanf(f[i+1], "%d", &r.Score)
r.IsMate = false
case "mate":
fmt.Sscanf(f[i+1], "%d", &r.MateIn)
r.IsMate = true
if r.MateIn > 0 {
r.Score = 100000 - r.MateIn
} else {
r.Score = -100000 - r.MateIn
}
}
}
})
if err != nil {
return nil, err
}
f := strings.Fields(last)
if len(f) >= 2 {
r.BestMove = f[1]
}
return r, nil
}
func (u *UCI) Close() error {
u.mu.Lock()
defer u.mu.Unlock()
if u.alive {
fmt.Fprintln(u.stdin, "quit")
done := make(chan struct{})
go func() { u.cmd.Wait(); close(done) }()
u.alive = false
select {
case <-done:
u.stdin.Close()
return nil
case <-time.After(1 * time.Second):
}
}
u.killLocked()
return nil
}