Source code for semi_cr.core.station.lifecycle
"""Independent, retryable attachment state for live laboratory instruments."""
from __future__ import annotations
import threading
from dataclasses import dataclass
from enum import StrEnum
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
[docs]
class AttachmentState(StrEnum):
DETACHED = "detached"
ATTACHING = "attaching"
ATTACHED = "attached"
DETACHING = "detaching"
FAILED = "failed"
[docs]
@dataclass(frozen=True)
class InstrumentSnapshot:
name: str
state: AttachmentState
error: str | None
revision: int
[docs]
class InstrumentHandle[ResourceT]:
"""Own one live resource without allowing its failure to escape startup.
Calls can be synchronous for scripts/tests or run on daemon threads for a GUI.
A generation counter makes an attach result stale when detach is requested while
the connection attempt is still in flight.
"""
def __init__(
self,
name: str,
attach: Callable[[], ResourceT],
detach: Callable[[ResourceT], None],
) -> None:
self.name = name
self._attach_resource = attach
self._detach_resource = detach
self._state = AttachmentState.DETACHED
self._resource: ResourceT | None = None
self._error: str | None = None
self._generation = 0
self._revision = 0
self._lock = threading.Lock()
self._threads: set[threading.Thread] = set()
@property
def snapshot(self) -> InstrumentSnapshot:
with self._lock:
return self._snapshot_locked()
@property
def resource(self) -> ResourceT | None:
with self._lock:
return self._resource if self._state is AttachmentState.ATTACHED else None
@property
def has_resource(self) -> bool:
"""Whether a resource still needs releasing, including after a failed detach."""
with self._lock:
return self._resource is not None
[docs]
def require_resource(self) -> ResourceT:
resource = self.resource
if resource is None:
snapshot = self.snapshot
detail = f": {snapshot.error}" if snapshot.error else ""
raise RuntimeError(f"{self.name} is {snapshot.state.value}{detail}")
return resource
[docs]
def attach(self) -> InstrumentSnapshot:
generation = self._begin_attach()
if generation is None:
return self.snapshot
return self._complete_attach(generation)
[docs]
def detach(self) -> InstrumentSnapshot:
pending = self._begin_detach()
if pending is None:
return self.snapshot
generation, resource = pending
return self._complete_detach(generation, resource)
[docs]
def attach_async(self) -> bool:
generation = self._begin_attach()
if generation is None:
return False
self._start_worker(self._complete_attach, generation)
return True
[docs]
def detach_async(self) -> bool:
pending = self._begin_detach()
if pending is None:
return False
generation, resource = pending
self._start_worker(self._complete_detach, generation, resource)
return True
[docs]
def mark_failed(self, error: Exception | str) -> InstrumentSnapshot:
"""Invalidate a live resource after a runtime communication failure."""
with self._lock:
self._generation += 1
resource = self._resource
self._resource = None
self._transition_locked(AttachmentState.FAILED, str(error))
snapshot = self._snapshot_locked()
if resource is not None:
self._start_worker(self._release_stale_resource, resource)
return snapshot
[docs]
def wait(self, timeout: float | None = None) -> None:
"""Wait for workers that exist when this method is called."""
with self._lock:
threads = tuple(self._threads)
for thread in threads:
thread.join(timeout=timeout)
def _snapshot_locked(self) -> InstrumentSnapshot:
return InstrumentSnapshot(self.name, self._state, self._error, self._revision)
def _transition_locked(self, state: AttachmentState, error: str | None = None) -> None:
self._state = state
self._error = error
self._revision += 1
def _begin_attach(self) -> int | None:
with self._lock:
if self._resource is not None or self._state in {AttachmentState.ATTACHING, AttachmentState.DETACHING}:
return None
self._generation += 1
self._transition_locked(AttachmentState.ATTACHING)
return self._generation
def _complete_attach(self, generation: int) -> InstrumentSnapshot:
try:
resource = self._attach_resource()
except Exception as error:
with self._lock:
if generation == self._generation:
self._resource = None
self._transition_locked(AttachmentState.FAILED, str(error))
return self._snapshot_locked()
with self._lock:
stale = generation != self._generation
if not stale:
self._resource = resource
self._transition_locked(AttachmentState.ATTACHED)
return self._snapshot_locked()
# Detach was requested before the slow attach completed. Release the
# stale resource without changing the newer state.
self._release_stale_resource(resource)
return self.snapshot
def _begin_detach(self) -> tuple[int, ResourceT] | None:
with self._lock:
if self._state is AttachmentState.DETACHING:
return None
if self._resource is None and self._state is AttachmentState.DETACHED:
return None
self._generation += 1
generation = self._generation
resource = self._resource
self._resource = None
if resource is None:
self._transition_locked(AttachmentState.DETACHED)
return None
self._transition_locked(AttachmentState.DETACHING)
return generation, resource
def _complete_detach(self, generation: int, resource: ResourceT) -> InstrumentSnapshot:
try:
self._detach_resource(resource)
except Exception as error:
with self._lock:
if generation == self._generation:
self._resource = resource
self._transition_locked(AttachmentState.FAILED, f"detach failed: {error}")
return self._snapshot_locked()
with self._lock:
if generation == self._generation:
self._transition_locked(AttachmentState.DETACHED)
return self._snapshot_locked()
def _start_worker(self, target: Callable[..., object], *args: object) -> None:
def run() -> None:
try:
target(*args)
finally:
with self._lock:
self._threads.discard(thread)
thread = threading.Thread(target=run, name=f"{self.name}-lifecycle", daemon=True)
with self._lock:
self._threads.add(thread)
thread.start()
def _release_stale_resource(self, resource: ResourceT) -> None:
try:
self._detach_resource(resource)
except Exception:
pass