Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion acad.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
62 changes: 62 additions & 0 deletions tests/test_bridge_timeout.py
Original file line number Diff line number Diff line change
@@ -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()
Loading