From 444af0db4ac14b2aed0128ec9a8743db3d88825c Mon Sep 17 00:00:00 2001 From: Erfouni Date: Wed, 30 Sep 2026 10:50:58 +0330 Subject: [PATCH] Withdraw queued AutoCAD calls when their caller times out When AutoCAD sits at a dialog, the call in progress hangs and run() gives up, but anything the model sends next (usually the same drawing again) stays in the COM queue and runs once the dialog is closed, even though its caller was already told it failed. run() now cancels a call that has not started, and the timeout message says whether the request was withdrawn or may still finish, so the model knows to check before resending. Co-Authored-By: Claude Opus 5.5 --- acad.py | 16 +++++++++- tests/test_bridge_timeout.py | 62 ++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 tests/test_bridge_timeout.py diff --git a/acad.py b/acad.py index a5ea914..81d7c61 100644 --- a/acad.py +++ b/acad.py @@ -213,10 +213,24 @@ def run(self, fn: Callable[[_Acad], Any], timeout: float = 120.0) -> Any: try: return future.result(timeout=timeout) except TimeoutError: + # A call still waiting in the queue is withdrawn, so it cannot fire + # once AutoCAD is free and duplicate whatever the model sends next. + # One already running on the COM thread cannot be stopped. + if future.cancel(): + fate = "The request was not sent to AutoCAD." + elif future.done(): + return future.result() + else: + fate = ( + "The request was already running and may still complete once " + "AutoCAD is free, so check with list_entities before sending " + "it again." + ) raise AcadError( f"AutoCAD did not respond within {timeout:.0f}s. It is most likely " "showing a dialog box or waiting for input at the command line. " - "Switch to AutoCAD, press Escape, close any dialog, then retry." + "Switch to AutoCAD, press Escape, close any dialog, then retry. " + f"{fate}" ) from None def reset(self) -> None: diff --git a/tests/test_bridge_timeout.py b/tests/test_bridge_timeout.py new file mode 100644 index 0000000..90de7ec --- /dev/null +++ b/tests/test_bridge_timeout.py @@ -0,0 +1,62 @@ +"""AcadBridge.run gives up on a call without leaving it behind to run later. + +All AutoCAD work goes through one COM thread, one call at a time. When AutoCAD +sits at a dialog, the call in progress hangs, run() times out, and whatever the +model sends next (usually the same drawing again) queues behind it. Those +queued calls must not fire once the dialog is closed: their callers were +already told they failed. Needs no AutoCAD: the calls here never touch COM. + + python -m unittest discover -s tests -t . +""" + +import threading +import unittest + +from acad import AcadBridge, AcadError + + +class BridgeTimeoutTest(unittest.TestCase): + def setUp(self): + self.bridge = AcadBridge() + self.release = threading.Event() + self.started = threading.Event() + + def tearDown(self): + self.release.set() + self.bridge._queue.put((None, None)) + self.bridge._thread.join(5) + + def _stuck(self, _acad): + # Stands in for a COM call blocked behind a modal dialog. + self.started.set() + self.release.wait(10) + return "late" + + def _hold_worker(self): + # The stuck call times out for its caller but keeps the COM thread busy. + with self.assertRaises(AcadError): + self.bridge.run(self._stuck, timeout=0.1) + self.assertTrue(self.started.is_set()) + + def test_timed_out_call_that_never_started_does_not_run_later(self): + self._hold_worker() + ran = threading.Event() + + with self.assertRaises(AcadError) as caught: + self.bridge.run(lambda _acad: ran.set(), timeout=0.2) + + self.release.set() + # Something queued after the timeout still runs, so the worker is alive + # and has had its chance to pick up the abandoned call. + self.assertEqual(self.bridge.run(lambda _acad: "next", timeout=5), "next") + self.assertFalse(ran.is_set(), "the abandoned call ran after its timeout") + self.assertIn("was not sent to AutoCAD", str(caught.exception)) + + def test_timed_out_call_that_already_started_says_it_may_still_finish(self): + with self.assertRaises(AcadError) as caught: + self.bridge.run(self._stuck, timeout=0.2) + self.assertIn("may still complete", str(caught.exception)) + + +if __name__ == "__main__": + unittest.main()