Files
JoshHeaps.Net/JoshHeaps.Net/Services/Implementations/Stockfish.cs
T
jheapsandClaude Sonnet 5 2f53c2f0ed Time out Stockfish process exit so finished self-play games can't wedge in the store
DisposeAsync awaited WaitForExitAsync with no timeout; a Stockfish process that
doesn't exit on "quit" blocked GameStore's removal task forever, leaving the
finished game stuck in the store and cluttering the live-watch list indefinitely.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018CmAti1baCjidbJM2HeJfk
2026-08-31 11:23:59 -06:00

126 lines
3.8 KiB
C#

using JoshHeaps.Net.Services.Interfaces;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading.Channels;
namespace JoshHeaps.Net.Services.Implementations;
public sealed class Stockfish : IChessEngine
{
private readonly Process _p;
private readonly StreamWriter _stdin;
private readonly Channel<string> _stdout = Channel.CreateUnbounded<string>();
private readonly int _skill;
public int Skill => _skill;
public Stockfish(int skill = 20, int hash = 256)
{
_skill = skill;
string baseDir = AppContext.BaseDirectory; // always points to the folder that holds your DLL / EXE
string fileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? "stockfish-windows-x86-64-avx2.exe"
: RuntimeInformation.IsOSPlatform(OSPlatform.OSX)
? "stockfish-mac-x86-64-avx2"
: "stockfish-ubuntu-x86-64-sse41-popcnt"; // default: Linux
string exePath = Path.Combine(baseDir, "Resources", fileName);
if (!File.Exists(exePath))
{
throw new FileNotFoundException($"Stockfish executable not found at {exePath}. " +
"Ensure the file is present in the Resources folder of your project.");
}
Console.Write(exePath);
_p = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = exePath,
RedirectStandardInput = true,
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
try
{
_p.Start();
}
catch (Exception ex)
{
Console.WriteLine($"Error starting process: {ex.Message}");
throw;
}
_stdin = _p.StandardInput;
_ = Task.Run(async () =>
{
string? line;
while ((line = await _p.StandardOutput.ReadLineAsync()) is not null)
await _stdout.Writer.WriteAsync(line);
});
Send("uci");
WaitFor("uciok").GetAwaiter().GetResult();
Send($"setoption name Skill Level value {skill}");
Send($"setoption name Hash value {hash}");
Send("isready");
WaitFor("readyok").GetAwaiter().GetResult();
}
public async Task<string> GetBestMoveAsync(string fen, IReadOnlyList<string> historyFens)
{
// historyFens is unused: Stockfish tracks repetition from the position it's given.
Send($"position fen {fen}");
Send($"go depth {_skill}");
string? best = null;
await foreach (var line in _stdout.Reader.ReadAllAsync())
{
if (line.StartsWith("bestmove"))
{
best = line.Split(' ')[1];
break;
}
}
return best ?? throw new InvalidOperationException("Engine returned no move");
}
private void Send(string cmd) => _stdin.WriteLine(cmd);
private async Task WaitFor(string token)
{
await foreach (var line in _stdout.Reader.ReadAllAsync())
if (line == token) break;
}
// If Stockfish doesn't exit on "quit" (crashed, zombie, ignores stdin close), don't hang
// here forever — that would block GameStore's removal task and leave the finished game
// stuck in the store, cluttering the watch page indefinitely.
private static readonly TimeSpan _exitTimeout = TimeSpan.FromSeconds(5);
public async ValueTask DisposeAsync()
{
Send("quit");
_stdin.Close();
try
{
await _p.WaitForExitAsync().WaitAsync(_exitTimeout);
}
catch (TimeoutException)
{
if (!_p.HasExited)
_p.Kill(entireProcessTree: true);
}
_p.Dispose();
}
}