Skip to content

Commit

Permalink
Solve many bugs related to engines.
Browse files Browse the repository at this point in the history
  • Loading branch information
Tage Johansson committed Mar 18, 2024
1 parent dcbb201 commit 707a285
Show file tree
Hide file tree
Showing 9 changed files with 73 additions and 64 deletions.
3 changes: 2 additions & 1 deletion chess_cli/__init__.py
@@ -1,5 +1,6 @@
# Add the dlls directory to path:
import os

basepath = os.path.dirname(os.path.abspath(__file__))
dllspath = os.path.join(basepath, "..", "dlls")
os.environ['PATH'] = dllspath + os.pathsep + os.environ['PATH']
os.environ["PATH"] = dllspath + os.pathsep + os.environ["PATH"]
55 changes: 24 additions & 31 deletions chess_cli/analysis.py
@@ -1,4 +1,3 @@
import asyncio
from collections import defaultdict
from collections.abc import Mapping
from contextlib import suppress
Expand All @@ -9,8 +8,8 @@
import chess.engine
import chess.pgn

from .base import CommandFailure, InitArgs
from .engine import ENGINE_TIMEOUT, Engine
from .base import InitArgs
from .engine import Engine


@dataclass
Expand Down Expand Up @@ -68,36 +67,34 @@ async def start_analysis(
) -> None:
if engine in self._running_analyses:
return
try:
async with asyncio.timeout(ENGINE_TIMEOUT):
analysis: AnalysisInfo = AnalysisInfo(
result=await self.loaded_engines[engine].engine.analysis(
self.game_node.board(), limit=limit, multipv=number_of_moves, game="this"
),
engine=engine,
board=self.game_node.board(),
san=(
self.game_node.san()
if isinstance(self.game_node, chess.pgn.ChildNode)
else None
),
)
except TimeoutError:
raise chess.engine.EngineError(f"Timeout")
async with self.engine_timeout(engine, long=True):
analysis: AnalysisInfo = AnalysisInfo(
result=await self.loaded_engines[engine].engine.analysis(
self.game_node.board(), limit=limit, multipv=number_of_moves, game="this"
),
engine=engine,
board=self.game_node.board(),
san=(
self.game_node.san()
if isinstance(self.game_node, chess.pgn.ChildNode)
else None
),
)
self._analyses.add(analysis)
self._running_analyses[engine] = analysis
self._analysis_by_node[self.game_node][engine] = analysis

def stop_analysis(self, engine: str) -> None:
self._running_analyses[engine].result.stop()
del self._running_analyses[engine]
def stop_analysis(self, engine: str, remove_auto: bool = True) -> None:
with suppress(KeyError):
self._auto_analysis_engines.remove(engine)
self._running_analyses[engine].result.stop()
del self._running_analyses[engine]
if remove_auto:
with suppress(KeyError):
self._auto_analysis_engines.remove(engine)

@override
async def close_engine(self, name: str) -> None:
if name in self.running_analyses:
self.stop_analysis(name)
self.stop_analysis(name)
await super().close_engine(name)

async def update_auto_analysis(self) -> None:
Expand All @@ -106,12 +103,8 @@ async def update_auto_analysis(self) -> None:
engine in self._running_analyses
and self._running_analyses[engine].board != self.game_node.board()
):
self.stop_analysis(engine)
try:
await self.start_analysis(engine, self._auto_analysis_number_of_moves)
except chess.engine.EngineError as e:
self._auto_analysis_engines.remove(engine)
raise CommandFailure(f"Engine {engine} terminated unexpectedly: {e}")
self.stop_analysis(engine, remove_auto=False)
await self.start_analysis(engine, self._auto_analysis_number_of_moves)

async def start_auto_analysis(self, engine: str, number_of_moves: int) -> None:
"""Start auto analysis on the current position."""
Expand Down
4 changes: 2 additions & 2 deletions chess_cli/analysis_cmds.py
Expand Up @@ -104,10 +104,10 @@ async def do_analysis(self, args) -> None:
async def analysis_start(self, args) -> None:
engine: str = self.get_selected_engine()
if engine in self.analysis_by_node[self.game_node]:
answer: bool = yes_no_dialog(
answer: bool = await yes_no_dialog(
title=f"Error: There's allready an analysis made by {engine} at this move.",
text="Do you want to remove it and restart the analysis?",
).run()
).run_async()
if answer:
await self.exec_cmd("analysis rm")
else:
Expand Down
55 changes: 33 additions & 22 deletions chess_cli/engine.py
Expand Up @@ -5,17 +5,19 @@
import queue
import shutil
from collections import deque
from collections.abc import Mapping, Sequence
from contextlib import suppress
from collections.abc import AsyncGenerator, Mapping, Sequence
from contextlib import asynccontextmanager, suppress
from dataclasses import dataclass
from typing import assert_never, override

import chess.engine
from prompt_toolkit.patch_stdout import StdoutProxy
from pydantic import BaseModel

from .base import Base, CommandFailure, InitArgs

ENGINE_TIMEOUT: int = 120 # Timeout for engine related operations.
ENGINE_TIMEOUT: int = 10 # Timeout for engine related operations.
ENGINE_LONG_TIMEOUT: int = 120 # Timeout when opening engine.


class EngineProtocol(enum.StrEnum):
Expand Down Expand Up @@ -70,9 +72,9 @@ def __init__(self, args: InitArgs) -> None:
## Setup logging:
self._engines_saved_log = deque()
self._engines_log_queue = queue.SimpleQueue()
log_handler = logging.handlers.QueueHandler(self._engines_log_queue)
log_handler = logging.StreamHandler(StdoutProxy())
log_handler.setLevel(logging.WARNING)
log_handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s"))
log_handler.setFormatter(logging.Formatter("%(message)s"))
chess.engine.LOGGER.addHandler(log_handler)

# Close engines when REPL is quit.
Expand Down Expand Up @@ -116,14 +118,30 @@ async def close_engine(self, name: str) -> None:
"""Stop and quit an engine."""
engine: LoadedEngine = self._loaded_engines.pop(name)
self.engine_confs[engine.config_name].loaded_as.remove(name)
async with asyncio.timeout(ENGINE_TIMEOUT):
await engine.engine.quit()
if self.selected_engine == engine:
if self.selected_engine == name:
try:
self.select_engine(next(iter(self.loaded_engines)))
except StopIteration:
self._selected_engine = None
self._selected_engine = None
async with self.engine_timeout(name, close=False, context="close_engine()"):
await engine.engine.quit()

@asynccontextmanager
async def engine_timeout(
self, engine: str, long: bool = False, close: bool = True, context: str | None = None
) -> AsyncGenerator[None, None]:
timeout = ENGINE_TIMEOUT if not long else ENGINE_LONG_TIMEOUT
try:
async with asyncio.timeout(timeout):
yield
except (TimeoutError, chess.engine.EngineError, chess.engine.EngineTerminatedError) as e:
if close:
await self.close_engine(engine)
raise CommandFailure(
f"Engine {engine} crashed"
+ (" in {context}" if context is not None else "")
+ f": {e}"
) from e

def get_engines_log(self) -> Sequence[str]:
"""Get log messages from all engines."""
Expand Down Expand Up @@ -272,7 +290,7 @@ async def set_engine_option(
)
else:
raise AssertionError(f"Unsupported option type: {option.type}")
async with asyncio.timeout(ENGINE_TIMEOUT):
async with self.engine_timeout(engine):
await self.loaded_engines[engine].engine.configure({option.name: value})

async def load_engine(self, config_name: str, name: str) -> None:
Expand All @@ -283,27 +301,20 @@ async def load_engine(self, config_name: str, name: str) -> None:
engine_conf: EngineConf = self.engine_confs[config_name]
engine: chess.engine.Protocol
try:
async with asyncio.timeout(ENGINE_TIMEOUT):
async with self.engine_timeout(name, long=True, close=False):
match engine_conf.protocol:
case EngineProtocol.UCI:
_, engine = await chess.engine.popen_uci(engine_conf.path)
case EngineProtocol.XBOARD:
_, engine = await chess.engine.popen_xboard(engine_conf.path)
case x:
assert_never(x)
except chess.engine.EngineError as e:
self.poutput(
f"Engine Terminated Error: The engine {engine_conf.path} didn't behaved as it"
" should. Either it is broken, or this program containes a bug. It might also be"
" that you've specified wrong path to the engine executable."
)
raise e
except FileNotFoundError as e:
self.poutput(f"Error: Couldn't find the engine executable {engine_conf.path}: {e}")
raise e
raise CommandFailure(
f"Error: Couldn't find the engine executable {engine_conf.path}: {e}"
) from e
except OSError as e:
self.poutput(f"Error: While loading engine executable {engine_conf.path}: {e}")
raise e
raise CommandFailure(f"While loading engine executable {engine_conf.path}: {e}") from e
self._loaded_engines[name] = LoadedEngine(config_name, engine)
engine_conf.fullname = engine.id.get("name")
engine_conf.loaded_as.add(name)
Expand Down
2 changes: 0 additions & 2 deletions chess_cli/engine_cmds.py
Expand Up @@ -420,8 +420,6 @@ def get_engine_opt_name(self, engine: str, name: str) -> str:
Raises CommandFailure if not found.
"""
options: Mapping[str, chess.engine.Option] = self.loaded_engines[engine].engine.options
if name in options:
return name
try:
return next(name for name in options if name.lower() == name.lower())
except StopIteration:
Expand Down
4 changes: 2 additions & 2 deletions chess_cli/game_cmds.py
Expand Up @@ -6,10 +6,10 @@
import chess
import chess.pgn

from .base import CommandFailure
from .game_utils import GameUtils
from .repl import argparse_command, command
from .utils import MoveNumber
from .base import CommandFailure


class GameCmds(GameUtils):
Expand Down Expand Up @@ -399,7 +399,7 @@ def do_setup(self, args) -> None:
try:
board = chess.Board(args.fen)
except ValueError as e:
raise CommandFailure(f"Bad FEN: {e}")
raise CommandFailure(f"Bad FEN: {e}") from None
elif args.empty:
board = chess.Board.empty()
elif args.start:
Expand Down
2 changes: 1 addition & 1 deletion chess_cli/record.py
Expand Up @@ -8,7 +8,7 @@
import time
import traceback
from asyncio import subprocess
from collections.abc import Iterable, Mapping
from collections.abc import Iterable
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path, PurePath
Expand Down
2 changes: 2 additions & 0 deletions chess_cli/repl.py
Expand Up @@ -260,6 +260,8 @@ async def cmd_loop(self) -> None:
self.perror(f"Error: {e}")
except _CommandException as ex:
traceback.print_exception(ex.inner_exc)
except asyncio.exceptions.CancelledError:
self.perror("CancelledException thrown!")


def command[T: ReplBase](
Expand Down
10 changes: 7 additions & 3 deletions scripts/query_dll_dependencies.py
@@ -1,11 +1,14 @@
#!/usr/bin/python3
"""This is a short script to parse the output of dependencies.exe from
<https://github.com/lucasg/Dependencies>. The purpose is to get all dependent (non-system) DLLs for a DLL-file. I used it to query the dependencies for libcairo-2.dll:
"""A short script to parse the output of dependencies.exe from
<https://github.com/lucasg/Dependencies>.
The purpose is to get all dependent (non-system) DLLs for a DLL-file. I used it
to query the dependencies for libcairo-2.dll:
- Download and install GTK+ from <https://www.gtk.org>.
- Download and unpack dependencies from <https://github.com/lucasg/Dependencies>.
- Open a terminal and navigate to the lib directory in the GTK installation folder.
- Run dependencies.exe with an appropriate depth and pipe the JSON output to this script:
Dependencies.exe libcairo-2.dll -chain -json -depth 4 | python query_dll_dependencies.py
Dependencies.exe libcairo-2.dll -chain -json -depth 4 | python query_dll_dependencies.py.
"""

import json
Expand All @@ -32,5 +35,6 @@ def main() -> None:
for dll in dlls:
print(dll)


if __name__ == "__main__":
main()

0 comments on commit 707a285

Please sign in to comment.