v0.10.0 fix to engine game state mgmt, client and web ui updates to match
This commit is contained in:
+240
-205
@@ -2,7 +2,7 @@ package engine
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
@@ -11,13 +11,27 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const enginePath = "stockfish"
|
||||
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 {
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
stdout *bufio.Scanner
|
||||
mu sync.Mutex
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
lines chan string
|
||||
alive bool
|
||||
}
|
||||
|
||||
type SearchResult struct {
|
||||
@@ -28,225 +42,246 @@ type SearchResult struct {
|
||||
MateIn int
|
||||
}
|
||||
|
||||
func New() (*UCI, error) {
|
||||
cmd := exec.Command(enginePath)
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("failed to start engine: %v", err)
|
||||
}
|
||||
|
||||
uci := &UCI{
|
||||
cmd: cmd,
|
||||
stdin: stdin,
|
||||
stdout: bufio.NewScanner(stdout),
|
||||
}
|
||||
|
||||
if err := uci.initialize(); err != nil {
|
||||
uci.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return uci, nil
|
||||
type Diagnosis struct {
|
||||
FEN string
|
||||
InCheck bool
|
||||
}
|
||||
|
||||
// SetSkillLevel sets the Stockfish skill level (0-20)
|
||||
func (u *UCI) SetSkillLevel(level int) {
|
||||
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
|
||||
}
|
||||
u.sendCommand(fmt.Sprintf("setoption name Skill Level value %d", level))
|
||||
_, err := u.tx(barrierTimeout,
|
||||
[]string{fmt.Sprintf("setoption name Skill Level value %d", level), "isready"},
|
||||
"readyok", nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// Get FEN from Stockfish's debug ('d') command
|
||||
func (u *UCI) GetFEN() (string, error) {
|
||||
u.sendCommand("d")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
done := make(chan string, 1)
|
||||
go func() {
|
||||
for u.stdout.Scan() {
|
||||
line := u.stdout.Text()
|
||||
if strings.HasPrefix(line, "Fen: ") {
|
||||
done <- strings.TrimPrefix(line, "Fen: ")
|
||||
return
|
||||
}
|
||||
}
|
||||
done <- ""
|
||||
}()
|
||||
|
||||
select {
|
||||
case fen := <-done:
|
||||
if fen == "" {
|
||||
return "", fmt.Errorf("failed to get FEN from engine")
|
||||
}
|
||||
return fen, nil
|
||||
case <-ctx.Done():
|
||||
return "", fmt.Errorf("timeout getting FEN")
|
||||
}
|
||||
}
|
||||
|
||||
func (u *UCI) initialize() error {
|
||||
u.sendCommand("uci")
|
||||
|
||||
// Wait for uciok with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
done := make(chan bool)
|
||||
go func() {
|
||||
for u.stdout.Scan() {
|
||||
if u.stdout.Text() == "uciok" {
|
||||
done <- true
|
||||
return
|
||||
}
|
||||
}
|
||||
done <- false
|
||||
}()
|
||||
|
||||
select {
|
||||
case success := <-done:
|
||||
if !success {
|
||||
return fmt.Errorf("engine closed unexpectedly")
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("timeout waiting for uciok")
|
||||
}
|
||||
|
||||
u.sendCommand("isready")
|
||||
return u.waitReady()
|
||||
}
|
||||
|
||||
func (u *UCI) waitReady() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
done := make(chan error)
|
||||
go func() {
|
||||
for u.stdout.Scan() {
|
||||
if u.stdout.Text() == "readyok" {
|
||||
done <- nil
|
||||
return
|
||||
}
|
||||
}
|
||||
done <- fmt.Errorf("engine closed unexpectedly")
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("timeout waiting for readyok")
|
||||
}
|
||||
}
|
||||
|
||||
func (u *UCI) sendCommand(cmd string) {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
fmt.Fprintln(u.stdin, cmd)
|
||||
}
|
||||
|
||||
func (u *UCI) NewGame() {
|
||||
u.sendCommand("ucinewgame")
|
||||
u.sendCommand("isready")
|
||||
u.waitReady()
|
||||
}
|
||||
|
||||
func (u *UCI) SetPosition(fen string, moves []string) {
|
||||
cmd := fmt.Sprintf("position fen %s", fen)
|
||||
func (u *UCI) SetPosition(fen string, moves []string) error {
|
||||
cmd := "position fen " + fen
|
||||
if len(moves) > 0 {
|
||||
cmd += " moves " + strings.Join(moves, " ")
|
||||
}
|
||||
u.sendCommand(cmd)
|
||||
_, 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) {
|
||||
u.sendCommand(fmt.Sprintf("go movetime %d", timeMs))
|
||||
|
||||
result := &SearchResult{}
|
||||
|
||||
// Add timeout protection (2x the search time + buffer)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeMs*2+1000)*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
done := make(chan error)
|
||||
go func() {
|
||||
for u.stdout.Scan() {
|
||||
line := u.stdout.Text()
|
||||
|
||||
if strings.HasPrefix(line, "info ") {
|
||||
fields := strings.Fields(line)
|
||||
for i := 0; i < len(fields)-1; i++ {
|
||||
switch fields[i] {
|
||||
case "depth":
|
||||
fmt.Sscanf(fields[i+1], "%d", &result.Depth)
|
||||
case "cp":
|
||||
fmt.Sscanf(fields[i+1], "%d", &result.Score)
|
||||
result.IsMate = false
|
||||
case "mate":
|
||||
fmt.Sscanf(fields[i+1], "%d", &result.MateIn)
|
||||
result.IsMate = true
|
||||
// Convert mate score to centipawn equivalent for backwards compatibility
|
||||
if result.MateIn > 0 {
|
||||
result.Score = 100000 - result.MateIn
|
||||
} else {
|
||||
result.Score = -100000 - result.MateIn
|
||||
}
|
||||
}
|
||||
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 strings.HasPrefix(line, "bestmove ") {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 2 {
|
||||
result.BestMove = parts[1]
|
||||
}
|
||||
done <- nil
|
||||
return
|
||||
}
|
||||
}
|
||||
done <- fmt.Errorf("engine closed unexpectedly")
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("timeout waiting for bestmove")
|
||||
})
|
||||
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.sendCommand("quit")
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Try graceful shutdown first
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- u.cmd.Wait()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-time.After(1 * time.Second):
|
||||
// Force kill if doesn't exit gracefully
|
||||
return u.cmd.Process.Kill()
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user