|
21 | 21 | from dlclivegui.temp import Engine # type: ignore # TODO use main package enum when released |
22 | 22 |
|
23 | 23 | logger = logging.getLogger(__name__) |
| 24 | +STOP_WORKER_TIMEOUT = 10.0 # seconds to wait for worker thread to stop before marking as faulted |
24 | 25 |
|
25 | 26 | try: # pragma: no cover - optional dependency |
26 | 27 | from dlclive import ( |
@@ -155,6 +156,9 @@ def __init__(self) -> None: |
155 | 156 | self._lifecycle_lock = threading.Lock() |
156 | 157 | self._stop_event = threading.Event() |
157 | 158 | self._initialized = False |
| 159 | + ## Worker cleanup |
| 160 | + self._reaping = False |
| 161 | + self._pending_reset = False |
158 | 162 |
|
159 | 163 | # Statistics tracking |
160 | 164 | self._frames_enqueued = 0 |
@@ -184,6 +188,8 @@ def reset(self) -> None: |
184 | 188 | """Stop the worker thread and drop the current DLCLive instance.""" |
185 | 189 | stopped = self._stop_worker() |
186 | 190 | if not stopped: |
| 191 | + with self._lifecycle_lock: |
| 192 | + self._pending_reset = True |
187 | 193 | logger.warning( |
188 | 194 | "Reset requested but worker thread is still alive; skipping DLCLive reset to avoid potential issues." |
189 | 195 | ) |
@@ -330,21 +336,50 @@ def _stop_worker(self) -> bool: |
330 | 336 | self._state = WorkerState.STOPPING |
331 | 337 | self._stop_event.set() |
332 | 338 |
|
333 | | - t.join(timeout=2.0) |
| 339 | + t.join(timeout=STOP_WORKER_TIMEOUT) |
334 | 340 | if t.is_alive(): |
335 | 341 | qsize = self._queue.qsize() if self._queue is not None else -1 |
336 | 342 | logger.warning("DLC worker thread did not terminate cleanly (qsize=%s)", qsize) |
337 | | - with self._lifecycle_lock: |
338 | | - self._state = WorkerState.FAULTED |
| 343 | + self._schedule_reap(t) |
339 | 344 | return False |
340 | 345 |
|
| 346 | + # Normal cleanup |
341 | 347 | with self._lifecycle_lock: |
342 | 348 | self._worker_thread = None |
343 | 349 | self._queue = None |
344 | 350 | self._state = WorkerState.STOPPED |
345 | 351 | self._stop_event.clear() |
346 | 352 | return True |
347 | 353 |
|
| 354 | + def _schedule_reap(self, t: threading.Thread) -> None: |
| 355 | + with self._lifecycle_lock: |
| 356 | + if self._reaping: |
| 357 | + return |
| 358 | + self._reaping = True |
| 359 | + |
| 360 | + # ensure only one reaper |
| 361 | + def reap(): |
| 362 | + try: |
| 363 | + t.join() # wait without timeout in background |
| 364 | + with self._lifecycle_lock: |
| 365 | + # only clean if we're still stopping this thread |
| 366 | + if self._worker_thread is t: |
| 367 | + self._worker_thread = None |
| 368 | + self._queue = None |
| 369 | + self._state = WorkerState.STOPPED |
| 370 | + self._stop_event.clear() |
| 371 | + |
| 372 | + if self._pending_reset: |
| 373 | + self._dlc = None |
| 374 | + self._initialized = False |
| 375 | + self._pending_reset = False |
| 376 | + finally: |
| 377 | + with self._lifecycle_lock: |
| 378 | + self._reaping = False |
| 379 | + logger.debug("[Stop worker] DLC worker thread reaped; processor is STOPPED again") |
| 380 | + |
| 381 | + threading.Thread(target=reap, name="DLCLiveReaper", daemon=True).start() |
| 382 | + |
348 | 383 | @contextmanager |
349 | 384 | def _timed_processor(self): |
350 | 385 | """ |
|
0 commit comments