From c683c567dcae2fbae1bda7eca8de9426b2a36657 Mon Sep 17 00:00:00 2001 From: Mukller Date: Sun, 23 Aug 2026 00:38:21 +0300 Subject: [PATCH 1/3] fix(engine): finish queued commands cleanly when replaced or cancelled Protocol.communicate() replaces a still-pending next_command by calling set_finished() on it. If that command had never left the NEW state (its awaiting task was cancelled before the previous command finished), the assertion in set_finished() raised AssertionError: CommandState.NEW, crashing the engine task (#1116). Allow CommandState.NEW in set_finished(): nothing was sent to the engine while the command was queued, so there is no active phase to unwind - surface the usual EngineError on its result instead. Also guard finished.set_result(), since communicate() cancels the finished future right before calling set_finished(). --- chess/engine.py | 9 +++++++-- test.py | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/chess/engine.py b/chess/engine.py index c66bc0c45..1d96d8c11 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1222,11 +1222,16 @@ def _handle_exception(self, exc: Exception) -> None: self._dispatch_finished() def set_finished(self) -> None: - assert self.state in [CommandState.ACTIVE, CommandState.CANCELLING], self.state + # A queued command may be finished while still NEW (replaced by a + # newer command in Protocol.communicate, or cancelled before it + # could start). Nothing was sent to the engine in that case, so + # there is no active phase to unwind. + assert self.state in [CommandState.NEW, CommandState.ACTIVE, CommandState.CANCELLING], self.state if not self.result.done(): self.result.set_exception(EngineError(f"engine command finished before returning result: {self!r}")) self.state = CommandState.DONE - self.finished.set_result(None) + if not self.finished.done(): + self.finished.set_result(None) self._dispatch_finished() def _cancel(self) -> None: diff --git a/test.py b/test.py index a481c2adb..b3004f445 100755 --- a/test.py +++ b/test.py @@ -3041,6 +3041,26 @@ def test_utf8_bom(self): @unittest.skipIf(sys.platform == "win32" and (3, 8, 0) <= sys.version_info < (3, 8, 1), "https://bugs.python.org/issue34679") class EngineTestCase(unittest.TestCase): + def test_command_set_finished_while_queued_cancelled(self): + # Regression test for issue #1116: a queued command whose task was + # cancelled before it could start (result and finished futures + # already cancelled by Protocol.communicate) must finish cleanly + # instead of raising AssertionError on CommandState.NEW. + cmd = chess.engine.BaseCommand(None) + cmd.result.cancel() + cmd.finished.cancel() + cmd.set_finished() + self.assertEqual(cmd.state, chess.engine.CommandState.DONE) + + def test_command_set_finished_while_queued_pending(self): + # Replacing a still-queued command that was never awaited should + # surface an EngineError on its result rather than crash. + cmd = chess.engine.BaseCommand(None) + cmd.set_finished() + self.assertEqual(cmd.state, chess.engine.CommandState.DONE) + self.assertTrue(cmd.finished.done()) + self.assertIsInstance(cmd.result.exception(), chess.engine.EngineError) + def test_uci_option_map_equality(self): a = chess.engine.UciOptionMap() b = chess.engine.UciOptionMap() From cd123ebdb15f494968fa178e3f8b231b99a5b9df Mon Sep 17 00:00:00 2001 From: Mukller Date: Sun, 23 Aug 2026 00:52:36 +0300 Subject: [PATCH 2/3] fix(engine): ignore lines received after command is finished Engines may emit trailing output around the finishing line (e.g. info lines sent just before or after bestmove). When such a line arrives after the command already transitioned to DONE, BaseCommand._line_received raised AssertionError instead of discarding it (#1161). --- chess/engine.py | 5 +++++ test.py | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/chess/engine.py b/chess/engine.py index 1d96d8c11..e16c47023 100644 --- a/chess/engine.py +++ b/chess/engine.py @@ -1250,6 +1250,11 @@ def _start(self) -> None: self._handle_exception(err) def _line_received(self, line: str) -> None: + if self.state == CommandState.DONE: + # Some engines send trailing output (e.g. info lines around + # bestmove). The command is already finished, so there is + # nothing left to feed the line into - ignore it. + return assert self.state in [CommandState.ACTIVE, CommandState.CANCELLING], self.state try: self.line_received(line) diff --git a/test.py b/test.py index b3004f445..d8da41f2d 100755 --- a/test.py +++ b/test.py @@ -3061,6 +3061,16 @@ def test_command_set_finished_while_queued_pending(self): self.assertTrue(cmd.finished.done()) self.assertIsInstance(cmd.result.exception(), chess.engine.EngineError) + def test_command_line_received_after_done_is_ignored(self): + # Regression test for issue #1161: engines may send trailing + # output (e.g. info lines around bestmove) after the finishing + # line was processed. Feeding a line to a DONE command used to + # raise AssertionError; it should be ignored instead. + cmd = chess.engine.BaseCommand(None) + cmd.set_finished() + self.assertEqual(cmd.state, chess.engine.CommandState.DONE) + cmd._line_received("info depth 1 nodes 42") + def test_uci_option_map_equality(self): a = chess.engine.UciOptionMap() b = chess.engine.UciOptionMap() From dde2b1da1146ea92d269387984a944b8804f9c1d Mon Sep 17 00:00:00 2001 From: Mukller Date: Sun, 23 Aug 2026 01:28:34 +0300 Subject: [PATCH 3/3] fix(test): provide explicit event loop for BaseCommand unit tests Python 3.14 removed implicit event-loop creation, so constructing BaseCommand outside a running loop raised RuntimeError. --- test.py | 51 +++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/test.py b/test.py index d8da41f2d..71e9d58ae 100755 --- a/test.py +++ b/test.py @@ -3041,35 +3041,58 @@ def test_utf8_bom(self): @unittest.skipIf(sys.platform == "win32" and (3, 8, 0) <= sys.version_info < (3, 8, 1), "https://bugs.python.org/issue34679") class EngineTestCase(unittest.TestCase): + @staticmethod + def _with_event_loop(fn): + # BaseCommand builds asyncio Futures in __init__; Python 3.14 + # removed implicit event-loop creation, so provide one explicitly. + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + fn() + finally: + asyncio.set_event_loop(None) + loop.close() + def test_command_set_finished_while_queued_cancelled(self): # Regression test for issue #1116: a queued command whose task was # cancelled before it could start (result and finished futures # already cancelled by Protocol.communicate) must finish cleanly # instead of raising AssertionError on CommandState.NEW. - cmd = chess.engine.BaseCommand(None) - cmd.result.cancel() - cmd.finished.cancel() - cmd.set_finished() - self.assertEqual(cmd.state, chess.engine.CommandState.DONE) + def scenario(): + cmd = chess.engine.BaseCommand(None) + cmd.result.cancel() + cmd.finished.cancel() + cmd.set_finished() + self.assertEqual(cmd.state, chess.engine.CommandState.DONE) + + self._with_event_loop(scenario) def test_command_set_finished_while_queued_pending(self): # Replacing a still-queued command that was never awaited should # surface an EngineError on its result rather than crash. - cmd = chess.engine.BaseCommand(None) - cmd.set_finished() - self.assertEqual(cmd.state, chess.engine.CommandState.DONE) - self.assertTrue(cmd.finished.done()) - self.assertIsInstance(cmd.result.exception(), chess.engine.EngineError) + def scenario(): + cmd = chess.engine.BaseCommand(None) + cmd.set_finished() + self.assertEqual(cmd.state, chess.engine.CommandState.DONE) + self.assertTrue(cmd.finished.done()) + self.assertIsInstance( + cmd.result.exception(), chess.engine.EngineError + ) + + self._with_event_loop(scenario) def test_command_line_received_after_done_is_ignored(self): # Regression test for issue #1161: engines may send trailing # output (e.g. info lines around bestmove) after the finishing # line was processed. Feeding a line to a DONE command used to # raise AssertionError; it should be ignored instead. - cmd = chess.engine.BaseCommand(None) - cmd.set_finished() - self.assertEqual(cmd.state, chess.engine.CommandState.DONE) - cmd._line_received("info depth 1 nodes 42") + def scenario(): + cmd = chess.engine.BaseCommand(None) + cmd.set_finished() + self.assertEqual(cmd.state, chess.engine.CommandState.DONE) + cmd._line_received("info depth 1 nodes 42") + + self._with_event_loop(scenario) def test_uci_option_map_equality(self): a = chess.engine.UciOptionMap()