fix stop button disabled during connection

This commit is contained in:
ykcheungab
2026-09-12 20:27:34 +08:00
parent 904c27125f
commit 85d700bc06
15 changed files with 750 additions and 65 deletions
+226
View File
@@ -0,0 +1,226 @@
"""Cancellation semantics for the acquisition workers.
The GUI must never block on a device open that hangs: ``stop()`` returns
immediately, the worker thread owns its own teardown, and a new session waits
for the previous one *in its own thread* instead of opening the device twice.
These tests drive the real worker loop with a fake ``orisys`` module, so they
need no camera and no sensor bundle beyond a ``sensor.json``.
"""
from __future__ import annotations
import sys
import threading
import time
import types
from pathlib import Path
APP_ROOT = Path(__file__).resolve().parents[2]
BOX_CONFIG = APP_ROOT / "apps" / "studio" / "config" / "sensors" / "box" / "sensor.json"
STOP_BUDGET_S = 0.5
class FakeSensor:
"""Blocks in the constructor until ``release`` is set."""
def __init__(self, release: threading.Event, tracker: "OpenTracker", **kwargs):
tracker.opened(self)
if not release.wait(timeout=10.0):
raise TimeoutError("fake sensor was never released")
self.configured: dict = {}
self.connected = True
def configure(self, **kwargs):
self.configured = kwargs
def get_img(self):
return None
def disconnect(self):
self.connected = False
class OpenTracker:
"""Records how many devices are open at the same time."""
def __init__(self) -> None:
self.lock = threading.Lock()
self.open_count = 0
self.peak_open = 0
self.instances: list[FakeSensor] = []
def opened(self, sensor: FakeSensor) -> None:
with self.lock:
self.instances.append(sensor)
self.open_count += 1
self.peak_open = max(self.peak_open, self.open_count)
def closed(self, sensor: FakeSensor) -> None:
with self.lock:
if sensor.connected:
sensor.connected = False
self.open_count -= 1
def _install_fake_orisys(tracker: OpenTracker, release: threading.Event) -> object:
"""Replace ``orisys`` (and ``orisys.finger``) with blocking fakes."""
saved = {name: sys.modules.get(name) for name in ("orisys", "orisys.finger")}
class _Sensor(FakeSensor):
def __init__(self, *args, **kwargs):
super().__init__(release, tracker, **kwargs)
def disconnect(self):
tracker.closed(self)
super().disconnect()
module = types.ModuleType("orisys")
module.__version__ = "fake"
module.Sensor = _Sensor # type: ignore[attr-defined]
class _Runtime:
def __init__(self, *args, **kwargs):
self._door = _Sensor()
self.tactile = types.SimpleNamespace(raw_sensor=None)
@classmethod
def open(cls, **kwargs):
return cls(**kwargs)
def warmup(self, **_kwargs):
return None
def close(self):
self._door.disconnect()
finger_module = types.ModuleType("orisys.finger")
finger_module.FingerRuntime = _Runtime # type: ignore[attr-defined]
module.finger = finger_module # type: ignore[attr-defined]
sys.modules["orisys"] = module
sys.modules["orisys.finger"] = finger_module
return saved
def _restore(saved) -> None:
for name, original in saved.items():
if original is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = original
def _new_worker(kind: str):
from orisys_app_shared.acquisition.finger_worker import FingerAcquisitionWorker
from orisys_app_shared.acquisition.sdk_worker import SdkAcquisitionWorker
if kind == "finger":
return FingerAcquisitionWorker()
return SdkAcquisitionWorker()
def _wait_until(predicate, timeout=5.0, interval=0.01) -> bool:
deadline = time.perf_counter() + timeout
while time.perf_counter() < deadline:
if predicate():
return True
time.sleep(interval)
return predicate()
def _stop_is_immediate_and_keeps_ownership(kind: str) -> None:
tracker = OpenTracker()
release = threading.Event()
saved = _install_fake_orisys(tracker, release)
try:
worker = _new_worker(kind)
worker.start("0", config_ref=BOX_CONFIG)
assert _wait_until(lambda: tracker.peak_open >= 1), "session never opened the device"
started = time.perf_counter()
worker.stop()
elapsed = time.perf_counter() - started
assert elapsed < STOP_BUDGET_S, f"stop() blocked the caller for {elapsed:.2f}s"
assert worker._thread is not None, "stop() abandoned a still-running thread"
assert worker.consume_end_reason() == "stopped"
release.set()
assert _wait_until(lambda: worker._thread is None), "thread never released ownership"
assert _wait_until(lambda: tracker.open_count == 0), "device was not closed"
finally:
release.set()
_restore(saved)
def test_sdk_stop_is_immediate_and_keeps_ownership():
_stop_is_immediate_and_keeps_ownership("sdk")
def test_finger_stop_is_immediate_and_keeps_ownership():
_stop_is_immediate_and_keeps_ownership("finger")
def test_second_session_waits_instead_of_double_opening():
tracker = OpenTracker()
release = threading.Event()
saved = _install_fake_orisys(tracker, release)
try:
worker = _new_worker("sdk")
worker.start("0", config_ref=BOX_CONFIG)
assert _wait_until(lambda: tracker.peak_open == 1)
worker.stop()
worker.start("0", config_ref=BOX_CONFIG) # user hits Run again while stuck
time.sleep(0.3)
assert tracker.peak_open == 1, "two sessions opened the same device at once"
release.set()
assert _wait_until(lambda: len(tracker.instances) == 2, timeout=5.0), (
"the second session never opened after the stale one released the device"
)
assert tracker.peak_open == 1, "the device was open twice concurrently"
assert tracker.open_count <= 1, "stale session kept its device open"
worker.stop(wait=True)
assert _wait_until(lambda: tracker.open_count == 0), "device leaked"
finally:
release.set()
_restore(saved)
def test_stop_wait_is_bounded_and_closes_the_device():
tracker = OpenTracker()
release = threading.Event()
saved = _install_fake_orisys(tracker, release)
try:
worker = _new_worker("sdk")
worker.start("0", config_ref=BOX_CONFIG)
assert _wait_until(lambda: tracker.peak_open == 1)
release.set()
started = time.perf_counter()
worker.stop(wait=True)
elapsed = time.perf_counter() - started
assert elapsed < 5.0, f"stop(wait=True) took {elapsed:.2f}s"
assert _wait_until(lambda: tracker.open_count == 0), "device was not closed"
assert not worker.is_running
finally:
release.set()
_restore(saved)
if __name__ == "__main__": # pragma: no cover - manual runner
failures = 0
for name, function in sorted(globals().items()):
if name.startswith("test_") and callable(function):
try:
function()
print(f" ok {name}")
except AssertionError as exc:
failures += 1
print(f" FAIL {name}: {exc}")
except Exception as exc: # noqa: BLE001
failures += 1
print(f" ERR {name}: {type(exc).__name__}: {exc}")
print(f"failures: {failures}")
raise SystemExit(1 if failures else 0)