Skip to content

Commit 95c8647

Browse files
committed
Expose shared GenTL harvester diagnostics
Capture and surface CTI load diagnostics when acquiring the shared GenTL Harvester: record loaded and failed CTI files from the shared entry and from acquire-time exceptions, and slightly reformat the open() error message. Add a FakeSharedHarvesterPool test double (with FakeSharedEntry and custom acquire/release/refcount behavior) and integrate it into the test fixture patch_gentl_sdk so tests can exercise shared-harvester reuse, update counting and failure release semantics. Also adjust FakeImageAcquirer to clear its queue on start and to synthesize payloads when the queue is empty, wrap FakeHarvester.update to track update calls, and update tests to reflect new rebind/open behavior and to add coverage for shared harvester reuse and error propagation.
1 parent ab2598e commit 95c8647

3 files changed

Lines changed: 319 additions & 112 deletions

File tree

dlclivegui/cameras/backends/gentl_backend.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -563,23 +563,41 @@ def _acquire_shared_harvester(self, loaded: list[str]) -> list:
563563
try:
564564
self._shared_entry = cti_finder.SharedHarvesterPool.acquire(loaded)
565565
self._harvester = self._shared_entry.harvester
566+
567+
actual_loaded = list(getattr(self._shared_entry, "loaded_files", loaded))
568+
actual_failed = list(getattr(self._shared_entry, "failed_files", []))
569+
570+
ns["cti_files_loaded"] = actual_loaded
571+
if actual_failed:
572+
ns["cti_files_failed"] = [{"cti": str(cti), "error": str(error)} for cti, error in actual_failed]
573+
566574
with self._shared_entry.lock:
567575
infos = list(self._harvester.device_info_list or [])
568-
ns["cti_files_loaded"] = list(getattr(self._shared_entry, "loaded_files", loaded))
576+
569577
LOG.debug(
570578
"Using shared GenTL Harvester for %d device(s), refcount=%s",
571579
len(infos),
572580
cti_finder.SharedHarvesterPool.get_refcount(self._shared_entry),
573581
)
574582
return infos
583+
575584
except Exception as exc:
585+
exc_loaded = list(getattr(exc, "loaded_files", []))
586+
exc_failed = list(getattr(exc, "failed_files", []))
587+
588+
if exc_loaded or exc_failed:
589+
ns["cti_files_loaded"] = [str(p) for p in exc_loaded]
590+
ns["cti_files_failed"] = [{"cti": str(cti), "error": str(error)} for cti, error in exc_failed]
591+
576592
if self._shared_entry is not None:
577593
try:
578594
cti_finder.SharedHarvesterPool.release(self._shared_entry)
579595
except Exception:
580596
pass
597+
581598
self._shared_entry = None
582599
self._harvester = None
600+
583601
raise RuntimeError(
584602
f"Failed to initialize shared GenTL producer state.\n\nCTIs: {loaded}\nReason: {exc}"
585603
) from exc

tests/cameras/backends/conftest.py

Lines changed: 168 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import importlib
55
import logging
66
import os
7+
import threading
78
from dataclasses import dataclass
89
from typing import Any
910

@@ -641,6 +642,122 @@ def __exit__(self, exc_type, exc, tb):
641642
return False
642643

643644

645+
class FakeSharedHarvesterPoolAcquireError(RuntimeError):
646+
"""Raised by the fake shared pool when no CTI can be loaded."""
647+
648+
def __init__(self, message: str, *, loaded_files=None, failed_files=None):
649+
super().__init__(message)
650+
self.loaded_files = list(loaded_files or [])
651+
self.failed_files = list(failed_files or [])
652+
653+
654+
class FakeSharedEntry:
655+
def __init__(self, harvester, loaded_files, failed_files=None):
656+
self.harvester = harvester
657+
self.loaded_files = list(loaded_files or [])
658+
self.failed_files = list(failed_files or [])
659+
self.lock = threading.RLock()
660+
661+
662+
class FakeSharedHarvesterPool:
663+
"""
664+
Test double for cti_finder.SharedHarvesterPool.
665+
666+
Important behavior:
667+
- Reuses one Harvester per normalized CTI set.
668+
- Calls update() only when creating the shared Harvester.
669+
- Does not call update() when reusing an existing shared Harvester.
670+
- Tracks loaded_files/failed_files so backend diagnostics can be tested.
671+
"""
672+
673+
_entries: dict[tuple[str, ...], FakeSharedEntry] = {}
674+
_refcounts: dict[tuple[str, ...], int] = {}
675+
_harvester_factory = None
676+
677+
@classmethod
678+
def configure(cls, harvester_factory):
679+
cls.reset()
680+
cls._harvester_factory = harvester_factory
681+
682+
@staticmethod
683+
def _key(cti_files) -> tuple[str, ...]:
684+
# Stable across case/path spelling on Windows while preserving loaded_files separately.
685+
return tuple(os.path.normcase(os.path.abspath(str(p))) for p in cti_files)
686+
687+
@classmethod
688+
def acquire(cls, cti_files):
689+
key = cls._key(cti_files)
690+
691+
if key in cls._entries:
692+
cls._refcounts[key] += 1
693+
return cls._entries[key]
694+
695+
if cls._harvester_factory is None:
696+
raise RuntimeError("FakeSharedHarvesterPool is not configured")
697+
698+
h = cls._harvester_factory()
699+
700+
loaded: list[str] = []
701+
failed: list[tuple[str, str]] = []
702+
703+
for cti in cti_files:
704+
cti_str = str(cti)
705+
try:
706+
h.add_file(cti_str)
707+
loaded.append(cti_str)
708+
except Exception as exc:
709+
failed.append((cti_str, str(exc)))
710+
711+
if not loaded:
712+
try:
713+
h.reset()
714+
except Exception:
715+
pass
716+
raise FakeSharedHarvesterPoolAcquireError(
717+
"No fake CTIs could be loaded",
718+
loaded_files=[],
719+
failed_files=failed,
720+
)
721+
722+
h.update()
723+
724+
entry = FakeSharedEntry(h, loaded_files=loaded, failed_files=failed)
725+
cls._entries[key] = entry
726+
cls._refcounts[key] = 1
727+
return entry
728+
729+
@classmethod
730+
def release(cls, entry):
731+
for key, value in list(cls._entries.items()):
732+
if value is entry:
733+
cls._refcounts[key] -= 1
734+
if cls._refcounts[key] <= 0:
735+
try:
736+
entry.harvester.reset()
737+
except Exception:
738+
pass
739+
del cls._entries[key]
740+
del cls._refcounts[key]
741+
return
742+
743+
@classmethod
744+
def get_refcount(cls, entry):
745+
for key, value in cls._entries.items():
746+
if value is entry:
747+
return cls._refcounts[key]
748+
return 0
749+
750+
@classmethod
751+
def reset(cls):
752+
for entry in list(cls._entries.values()):
753+
try:
754+
entry.harvester.reset()
755+
except Exception:
756+
pass
757+
cls._entries.clear()
758+
cls._refcounts.clear()
759+
760+
644761
@dataclass
645762
class FakeImageAcquirer:
646763
"""
@@ -691,6 +808,7 @@ def _enqueue_default_frame(self):
691808
def start(self):
692809
self.start_calls += 1
693810
self._started = True
811+
self._queue.clear()
694812

695813
def stop(self):
696814
self.stop_calls += 1
@@ -707,10 +825,28 @@ def fetch(self, timeout: float = 2.0):
707825
if not self._started:
708826
raise FakeGenTLTimeoutException("fetch called while not started")
709827

710-
if not self._queue:
711-
raise FakeGenTLTimeoutException(f"timeout after {timeout}s")
828+
if self._queue:
829+
payload = self._queue.pop(0)
830+
else:
831+
# Generate from the current node map, because backend may have changed
832+
# PixelFormat/Width/Height during open().
833+
pf = str(self.node_map.PixelFormat.value or "Mono8")
834+
if pf in ("RGB8", "BGR8"):
835+
channels, dtype = 3, np.uint8
836+
elif pf in ("Mono16", "Mono12", "Mono10"):
837+
channels, dtype = 1, np.uint16
838+
else:
839+
# Mono8 and Bayer*8 are single-channel uint8
840+
channels, dtype = 1, np.uint8
841+
842+
comp = _FakeComponent(
843+
int(self.node_map.Width.value),
844+
int(self.node_map.Height.value),
845+
channels,
846+
dtype=dtype,
847+
)
848+
payload = _FakePayload(comp)
712849

713-
payload = self._queue.pop(0)
714850
return _FakeFetchedBufferCtx(payload)
715851

716852

@@ -854,29 +990,49 @@ def gentl_fail_add_file_for():
854990
def patch_gentl_sdk(monkeypatch, fake_harvester_factory, gentl_fail_add_file_for, tmp_path):
855991
"""
856992
Patch dlclivegui.cameras.backends.gentl_backend to use FakeHarvester + Fake timeout.
857-
Ensure CTI discovery succeeds for classmethods by creating a real dummy .cti and
858-
exposing it via GENICAM_GENTL64_PATH.
993+
994+
Important:
995+
The production backend now uses cti_finder.SharedHarvesterPool.acquire()
996+
during open(), so tests must patch that pool too.
859997
"""
860998
import dlclivegui.cameras.backends.gentl_backend as gb
861999

862-
# Patch Harvester symbol (the backend calls Harvester() directly)
1000+
# Reset and expose test counters/state.
1001+
gb.update_count = 0
1002+
gb.fail_add_file_for = gentl_fail_add_file_for
1003+
1004+
# Patch Harvester symbol for discovery/rebind paths.
8631005
monkeypatch.setattr(gb, "Harvester", lambda: fake_harvester_factory(), raising=False)
8641006

865-
# Keep timeout contract
1007+
# Count all fake update() calls.
1008+
original_update = FakeHarvester.update
1009+
1010+
def update_with_count(self):
1011+
gb.update_count += 1
1012+
return original_update(self)
1013+
1014+
monkeypatch.setattr(FakeHarvester, "update", update_with_count, raising=True)
1015+
1016+
# Keep timeout contract.
8661017
monkeypatch.setattr(gb, "HarvesterTimeoutError", FakeGenTLTimeoutException, raising=False)
8671018

868-
# Create a real CTI file and advertise it via env var
1019+
# Patch the shared pool used by open().
1020+
FakeSharedHarvesterPool.configure(fake_harvester_factory)
1021+
monkeypatch.setattr(gb.cti_finder, "SharedHarvesterPool", FakeSharedHarvesterPool, raising=False)
1022+
1023+
# Create a real CTI file and advertise it via env var.
8691024
cti_file = tmp_path / "dummy.cti"
8701025
if not cti_file.exists():
8711026
cti_file.write_text("fake", encoding="utf-8")
8721027

8731028
monkeypatch.setenv("GENICAM_GENTL64_PATH", str(tmp_path))
8741029
monkeypatch.delenv("GENICAM_GENTL32_PATH", raising=False)
8751030

876-
# OPTIONAL: expose failure control so tests can do gb.fail_add_file_for.add(...)
877-
gb.fail_add_file_for = gentl_fail_add_file_for
878-
879-
return gb
1031+
try:
1032+
yield gb
1033+
finally:
1034+
FakeSharedHarvesterPool.reset()
1035+
gb.fail_add_file_for = set()
8801036

8811037

8821038
@pytest.fixture()

0 commit comments

Comments
 (0)