From c683c567dcae2fbae1bda7eca8de9426b2a36657 Mon Sep 17 00:00:00 2001 From: Mukller Date: Sun, 23 Aug 2026 00:38:21 +0300 Subject: [PATCH 1/2] 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 b14f4e2c6ba45e36bb394047748b427c42dee8a9 Mon Sep 17 00:00:00 2001 From: Mukller Date: Sun, 23 Aug 2026 01:28:34 +0300 Subject: [PATCH 2/2] 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 | 40 ++++++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/test.py b/test.py index b3004f445..8da2ca4dd 100755 --- a/test.py +++ b/test.py @@ -3041,25 +3041,45 @@ 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_uci_option_map_equality(self): a = chess.engine.UciOptionMap()