Skip to content

Commit 91dff83

Browse files
committed
Improve lifecycle handling and stale-writer cleanup
dlclivegui/services/dlc_processor.py: Move the frame copy and enqueue timestamp into the lifecycle lock to avoid unnecessary copying when the processor is stopped; on initialization errors set WorkerState.FAULTED under the lifecycle lock before emitting the error. dlclivegui/services/multi_camera_controller.py: Reformat the running-cameras shutdown check into a multi-line condition (keeps the same intent of detecting normal shutdown when no cameras are started and all threads are stopped). dlclivegui/services/video_recorder.py: Add best-effort cleanup for a stale video writer in start() by attempting to call its close() and logging any errors before clearing writer/queue/thread references. Wrap stop() logic with the lifecycle lock to avoid races when stopping, preserve timeout/abandon behavior for a non-terminating writer thread, and ensure writer closure and timestamp saving are handled with proper error logging.
1 parent 369908e commit 91dff83

3 files changed

Lines changed: 60 additions & 43 deletions

File tree

dlclivegui/services/dlc_processor.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -216,17 +216,15 @@ def shutdown(self) -> None:
216216
self._initialized = False
217217

218218
def enqueue_frame(self, frame: np.ndarray, timestamp: float) -> None:
219-
frame_c = frame.copy()
220-
enq_time = time.perf_counter()
221-
222219
with self._lifecycle_lock:
223220
if self._state in (WorkerState.STOPPING, WorkerState.FAULTED) or self._stop_event.is_set():
224221
return
222+
frame_c = frame.copy()
223+
enq_time = time.perf_counter()
225224
t = self._worker_thread
226225
if t is None or not t.is_alive():
227226
self._start_worker_locked(frame_c, timestamp)
228227
return
229-
230228
q = self._queue # snapshot under lock
231229

232230
if q is None:
@@ -492,6 +490,8 @@ def _worker_loop(self, init_frame: np.ndarray, init_timestamp: float) -> None:
492490

493491
except Exception as exc:
494492
logger.exception("Failed to initialize DLCLive", exc_info=exc)
493+
with self._lifecycle_lock:
494+
self._state = WorkerState.FAULTED
495495
self.error.emit(str(exc))
496496
self.initialized.emit(False)
497497
return

dlclivegui/services/multi_camera_controller.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -504,7 +504,11 @@ def _on_camera_stopped(self, camera_id: str) -> None:
504504
return
505505

506506
# Check if all running cameras have stopped (normal shutdown)
507-
if not self._started_cameras and all(not t.isRunning() for t in self._threads.values() if t is not None):
507+
if (
508+
not self._started_cameras
509+
and not self._started_cameras
510+
and all(not t.isRunning() for t in self._threads.values() if t is not None)
511+
):
508512
self._running = False
509513
self.all_stopped.emit()
510514

dlclivegui/services/video_recorder.py

Lines changed: 51 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,21 @@ def start(self) -> None:
9393
if self._abandoned:
9494
raise RuntimeError("Cannot restart VideoRecorder, as a leftover thread is still running.")
9595
if self._writer is not None:
96-
self._writer = None
97-
self._queue = None
98-
self._writer_thread = None
96+
# Best-effort cleanup of a stale writer to avoid leaking resources.
97+
logger.warning(
98+
"VideoRecorder.start() found an existing writer while not running; "
99+
"attempting to close the stale writer before restarting."
100+
)
101+
try:
102+
close_method = getattr(self._writer, "close", None)
103+
if callable(close_method):
104+
close_method()
105+
except Exception:
106+
logger.exception("Error while closing stale video writer in start().")
107+
finally:
108+
self._writer = None
109+
self._queue = None
110+
self._writer_thread = None
99111

100112
fps_value = float(self._frame_rate) if self._frame_rate else 30.0
101113

@@ -195,46 +207,47 @@ def write(self, frame: np.ndarray, timestamp: float | None = None) -> bool:
195207
return True
196208

197209
def stop(self) -> None:
198-
if self._writer is None and not self.is_running:
199-
return
210+
with self._lifecycle_lock:
211+
if self._writer is None and not self.is_running:
212+
return
200213

201-
self._stop_event.set()
214+
self._stop_event.set()
202215

203-
q = self._queue
204-
if q is not None:
205-
try:
206-
q.put_nowait(_SENTINEL)
207-
except queue.Full:
208-
pass
209-
210-
t = self._writer_thread
211-
if t is not None:
212-
t.join(timeout=5.0)
213-
if t.is_alive():
214-
with self._stats_lock:
215-
self._encode_error = RuntimeError(
216-
"Failed to stop VideoRecorder within timeout; thread is still alive."
217-
)
218-
self._abandoned = True
219-
logger.critical(
220-
"Failed to stop VideoRecorder within timeout; thread is still alive. "
221-
"Marking recorder as abandoned to prevent restart."
222-
)
223-
return
216+
q = self._queue
217+
if q is not None:
218+
try:
219+
q.put_nowait(_SENTINEL)
220+
except queue.Full:
221+
pass
222+
223+
t = self._writer_thread
224+
if t is not None:
225+
t.join(timeout=5.0)
226+
if t.is_alive():
227+
with self._stats_lock:
228+
self._encode_error = RuntimeError(
229+
"Failed to stop VideoRecorder within timeout; thread is still alive."
230+
)
231+
self._abandoned = True
232+
logger.critical(
233+
"Failed to stop VideoRecorder within timeout; thread is still alive. "
234+
"Marking recorder as abandoned to prevent restart."
235+
)
236+
return
224237

225-
if self._writer is not None:
226-
try:
227-
self._writer.close()
228-
except Exception:
229-
logger.exception("Failed to close WriteGear cleanly")
238+
if self._writer is not None:
239+
try:
240+
self._writer.close()
241+
except Exception:
242+
logger.exception("Failed to close WriteGear cleanly")
230243

231-
self._save_timestamps()
244+
self._save_timestamps()
232245

233-
self._writer = None
234-
self._writer_thread = None
235-
self._queue = None
236-
self._stop_event.clear()
237-
self._abandoned = False
246+
self._writer = None
247+
self._writer_thread = None
248+
self._queue = None
249+
self._stop_event.clear()
250+
self._abandoned = False
238251

239252
def get_stats(self) -> RecorderStats | None:
240253
if (

0 commit comments

Comments
 (0)