diff --git a/faro/microscope/pertzlab/moench.py b/faro/microscope/pertzlab/moench.py index 88e261e..83be133 100644 --- a/faro/microscope/pertzlab/moench.py +++ b/faro/microscope/pertzlab/moench.py @@ -206,6 +206,20 @@ def shutdown(self): Python process alive after the main thread exits, leaving a zombie that blocks the next session with ``Error in device "COM3"`` when MM tries to initialize. + + Idempotent with the atexit hook registered in + :class:`PyMMCoreMicroscope`: calling ``shutdown`` explicitly just + runs the same teardown earlier; if it's never called, the hook + runs it at interpreter exit. + """ + self._teardown_hardware() + + def _teardown_hardware(self) -> None: + """Stop the DMD wakeup thread, then delegate to the base teardown. + + The wakeup thread keeps a reference to the SLM device; stopping + it before ``unloadAllDevices`` avoids the unload racing the + thread's next ``displaySLMImage`` call. """ wakeup = getattr(self, "wakeup_dmd", None) if wakeup is not None: @@ -213,10 +227,7 @@ def shutdown(self): wakeup.stop() except Exception: pass - try: - self.mmc.unloadAllDevices() - except Exception: - pass + super()._teardown_hardware() def register_engine(self, force: bool = False) -> None: """Create and register the microscope-specific MDA engine. diff --git a/faro/microscope/pymmcore.py b/faro/microscope/pymmcore.py index b14a457..62740bc 100644 --- a/faro/microscope/pymmcore.py +++ b/faro/microscope/pymmcore.py @@ -1,3 +1,7 @@ +import atexit +import contextlib +import weakref + from faro.microscope.base import AbstractMicroscope @@ -13,6 +17,14 @@ class PyMMCoreMicroscope(AbstractMicroscope): are auto-detected from the loaded Micro-Manager config. Subclasses may set ``POWER_PROPERTIES`` to override or supplement the auto-detected values. + + On construction, an atexit hook is registered that cancels any running + MDA and unloads every Micro-Manager device when the interpreter shuts + down. Without this, device adapters can stay bound to the dying Python + process and prevent the next process from acquiring the same + configuration. Subclasses with extra teardown (background threads, + serial ports, etc.) should override :meth:`_teardown_hardware` rather + than re-register their own hooks. """ MICROMANAGER_PATH = "C:\\Program Files\\Micro-Manager-2.0" @@ -24,6 +36,41 @@ def __init__(self): self._detected_power_properties: dict[str, tuple[str, str]] | None = None self._current_group: str | None = None + # Register cleanup via a weakref so the hook doesn't pin the + # microscope instance. self.mmc is checked at fire time because + # subclasses set it after super().__init__() returns. + weak_self = weakref.ref(self) + + def _atexit_teardown() -> None: + scope = weak_self() + if scope is None: + return + scope._teardown_hardware() + + atexit.register(_atexit_teardown) + self._atexit_teardown = _atexit_teardown + + def _teardown_hardware(self) -> None: + """Release all hardware held by this microscope. + + Called from the atexit hook registered in :meth:`__init__`, and + also reusable as an explicit teardown step from subclasses that + expose a public ``shutdown`` API. Override in subclasses to add + extra cleanup (stopping background threads, closing serial ports, + etc.) — call ``super()._teardown_hardware()`` last so device + unload happens after subclass-owned threads have stopped. + + Suppresses exceptions so a flaky device can't prevent the rest + of the teardown (or, in the atexit path, the interpreter's + finalization) from running. + """ + if self.mmc is None: + return + with contextlib.suppress(Exception): + self.mmc.mda.cancel() + with contextlib.suppress(Exception): + self.mmc.unloadAllDevices() + # ------------------------------------------------------------------ # MDA interface implementation # ------------------------------------------------------------------