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
126 lines
3.8 KiB
C#
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();
|
|
}
|
|
}
|