Skip to content
Open
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
19 changes: 15 additions & 4 deletions faro/microscope/pertzlab/moench.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,17 +206,28 @@ 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:
try:
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.
Expand Down
47 changes: 47 additions & 0 deletions faro/microscope/pymmcore.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import atexit
import contextlib
import weakref

from faro.microscope.base import AbstractMicroscope


Expand All @@ -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"
Expand All @@ -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
# ------------------------------------------------------------------
Expand Down