From c29a388ad6b82ac5ddd890e2dafeff31cc6cdb0d Mon Sep 17 00:00:00 2001 From: Hinderling Date: Thu, 14 May 2026 16:06:26 +0200 Subject: [PATCH] fix: release Micro-Manager devices on interpreter exit Add an atexit hook in PyMMCoreMicroscope that cancels any running MDA and calls mmc.unloadAllDevices() when the interpreter shuts down. Without this, device adapters can stay bound to the dying Python process and the next process trying to load the same configuration fails to acquire them. Subclasses with extra teardown (background threads, serial ports, etc.) should override _teardown_hardware() and chain to super() last, so subclass-owned resources are released before the device unload. Moench.shutdown() now delegates to _teardown_hardware() so explicit shutdown and the atexit path run the same code. The wakeup_dmd thread is stopped in Moench._teardown_hardware() before the base unload, to avoid racing the thread's next displaySLMImage call. --- faro/microscope/pertzlab/moench.py | 19 +++++++++--- faro/microscope/pymmcore.py | 47 ++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) 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 # ------------------------------------------------------------------