Skip to content

Commit 9b6e1c8

Browse files
committed
Improve camera thread cleanup and recorder finalization
Add cooperative shutdown timeouts and robust cleanup for camera threads: introduce QUIT_WAIT_MS and TERMINATE_WAIT_MS, connect thread.finished to a new _cleanup_camera that removes frame data and deletes worker/thread QObjects, and enhance stop() to wait, terminate, and log/retain references if threads refuse to terminate (avoids use-after-free/segfaults). Adjust per-camera shutdown checks and ensure frames/timestamps are cleared only after safe termination. In video_recorder, remove premature clearing of the queue and _writer_thread during finalization to avoid unsafe cleanup while writer thread may still be exiting.
1 parent 64f820e commit 9b6e1c8

2 files changed

Lines changed: 57 additions & 21 deletions

File tree

dlclivegui/services/multi_camera_controller.py

Lines changed: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import logging
66
import time
77
from dataclasses import dataclass
8+
from functools import partial
89
from threading import Event, Lock
910

1011
import cv2
@@ -20,6 +21,9 @@
2021

2122
LOGGER = logging.getLogger(__name__)
2223

24+
QUIT_WAIT_MS = 5000 # wait for cooperative quit (5s)
25+
TERMINATE_WAIT_MS = 1000 # wait after terminate (1s)
26+
2327

2428
@dataclass
2529
class MultiFrameData:
@@ -188,8 +192,25 @@ def _start_camera(self, settings: CameraSettings) -> None:
188192

189193
self._workers[cam_id] = worker
190194
self._threads[cam_id] = thread
195+
thread.finished.connect(partial(self._cleanup_camera, cam_id))
196+
worker.stopped.connect(thread.quit)
191197
thread.start()
192198

199+
def _cleanup_camera(self, camera_id: str) -> None:
200+
# remove stored frame data
201+
with self._frame_lock:
202+
self._frames.pop(camera_id, None)
203+
self._timestamps.pop(camera_id, None)
204+
205+
worker = self._workers.pop(camera_id, None)
206+
thread = self._threads.pop(camera_id, None)
207+
self._settings.pop(camera_id, None)
208+
209+
if worker is not None:
210+
worker.deleteLater()
211+
if thread is not None:
212+
thread.deleteLater()
213+
193214
def stop(self, wait: bool = True) -> None:
194215
"""Stop all cameras."""
195216
if not self._running:
@@ -203,22 +224,47 @@ def stop(self, wait: bool = True) -> None:
203224

204225
# Wait for threads to finish
205226
if wait:
227+
still_running: list[str] = []
206228
for cam_id, thread in list(self._threads.items()):
229+
if thread is None:
230+
continue
207231
if not thread.isRunning():
208232
continue
209233

210234
thread.quit()
211-
if not thread.wait(5000):
212-
LOGGER.error("Frozen camera thread %s; Forcing terminate()", cam_id)
213-
thread.terminate()
214-
thread.wait(1000)
215-
216-
self._workers.clear()
217-
self._threads.clear()
218-
self._settings.clear()
235+
if thread.wait(QUIT_WAIT_MS):
236+
continue # Clean exit
237+
238+
LOGGER.error(
239+
"Camera thread %s did not quit within %dms; forcing terminate()",
240+
cam_id,
241+
QUIT_WAIT_MS,
242+
)
243+
244+
thread.terminate()
245+
if thread.wait(TERMINATE_WAIT_MS):
246+
continue # Terminated successfully
247+
248+
LOGGER.critical(
249+
"Camera thread %s refused to terminate after terminate()+wait(%dms). "
250+
"Keeping references to avoid use-after-free/segfaults. "
251+
"Application restart may be required.",
252+
cam_id,
253+
TERMINATE_WAIT_MS,
254+
)
255+
still_running.append(cam_id)
256+
257+
if still_running:
258+
self._started_cameras.clear()
259+
return
260+
219261
self._started_cameras.clear()
220262
self._failed_cameras.clear()
263+
with self._frame_lock:
264+
self._frames.clear()
265+
self._timestamps.clear()
221266
self._expected_cameras = 0
267+
222268
self.all_stopped.emit()
223269

224270
def _on_frame_captured(self, camera_id: str, frame: np.ndarray, timestamp: float) -> None:
@@ -437,14 +483,9 @@ def _on_camera_stopped(self, camera_id: str) -> None:
437483

438484
# Cleanup thread
439485
if camera_id in self._threads:
440-
thread = self._threads[camera_id]
441-
if thread.isRunning():
486+
thread = self._threads.get(camera_id)
487+
if thread is not None and thread.isRunning():
442488
thread.quit()
443-
thread.wait(1000)
444-
del self._threads[camera_id]
445-
446-
if camera_id in self._workers:
447-
del self._workers[camera_id]
448489

449490
# Remove frame data
450491
with self._frame_lock:
@@ -463,7 +504,7 @@ def _on_camera_stopped(self, camera_id: str) -> None:
463504
return
464505

465506
# Check if all running cameras have stopped (normal shutdown)
466-
if not self._started_cameras and self._running and not self._workers:
507+
if not self._started_cameras and all(not t.isRunning() for t in self._threads.values() if t is not None):
467508
self._running = False
468509
self.all_stopped.emit()
469510

dlclivegui/services/video_recorder.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -309,11 +309,6 @@ def _writer_loop(self) -> None:
309309
self._finalize_writer()
310310
self._save_timestamps()
311311

312-
# Safe cleanup only once the thread is actually exiting
313-
self._queue = None
314-
if self._writer_thread is threading.current_thread():
315-
self._writer_thread = None
316-
317312
def _finalize_writer(self) -> None:
318313
writer = self._writer
319314
self._writer = None

0 commit comments

Comments
 (0)