Skip to content

Commit e59ad7e

Browse files
committed
Harden recording dispatcher shutdown flow
Refactors dispatcher lifecycle management to make stop/start behavior safer under load. Introduces explicit dispatcher state flags, bounded constants for queue size/stop timeout, and makes `_stop_dispatcher` return success instead of silently forcing teardown. Shutdown now only enqueues a sentinel once, preserves thread/queue state when sentinel enqueue or join times out, and logs these cases clearly. The dispatch loop now blocks on queue reads, handles full frame payloads consistently, logs unexpected dispatch errors, and warns on invalid `task_done` usage. `stop_all` now returns a boolean to report incomplete shutdown, and `write_frame` only enqueues while the dispatcher is actively accepting frames.
1 parent e4fb545 commit e59ad7e

1 file changed

Lines changed: 110 additions & 39 deletions

File tree

‎dlclivegui/services/recording_manager.py‎

Lines changed: 110 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717

1818
log = logging.getLogger(__name__)
1919

20+
DISPATCH_STOP_TIMEOUT = 2.0
21+
DISPATCH_QUEUE_MAXSIZE = 4096
22+
2023
_FRAME_SENTINEL = object()
2124

2225

@@ -31,7 +34,8 @@ def __init__(self):
3134
self._lock = threading.RLock()
3235
self._frame_queue: queue.Queue | None = None
3336
self._dispatch_thread: threading.Thread | None = None
34-
self._dispatch_stop = threading.Event()
37+
self._dispatch_accepting: bool = False
38+
self._dispatch_sentinel_enqueued: bool = False
3539

3640
@property
3741
def is_active(self) -> bool:
@@ -96,39 +100,79 @@ def pop(self, cam_id: str, default=None) -> VideoRecorder | None:
96100
return self._recorders.pop(cam_id, default)
97101

98102
def _start_dispatcher(self) -> None:
99-
if self._dispatch_thread is not None and self._dispatch_thread.is_alive():
100-
return
103+
with self._lock:
104+
if self._dispatch_thread is not None and self._dispatch_thread.is_alive():
105+
return
101106

102-
self._dispatch_stop.clear()
103-
self._frame_queue = queue.Queue(maxsize=4096)
104-
self._dispatch_thread = threading.Thread(
105-
target=self._dispatch_loop,
106-
name="RecordingManagerDispatcher",
107-
daemon=True,
108-
)
109-
self._dispatch_thread.start()
107+
self._frame_queue = queue.Queue(maxsize=DISPATCH_QUEUE_MAXSIZE)
108+
self._dispatch_accepting = True
109+
self._dispatch_sentinel_enqueued = False
110+
self._dispatch_thread = threading.Thread(
111+
target=self._dispatch_loop,
112+
name="RecordingManagerDispatcher",
113+
daemon=True,
114+
)
115+
self._dispatch_thread.start()
110116

111-
def _stop_dispatcher(self, timeout: float = 2.0) -> None:
117+
def _stop_dispatcher(
118+
self,
119+
timeout: float = DISPATCH_STOP_TIMEOUT,
120+
) -> bool:
112121
with self._lock:
113122
q = self._frame_queue
114123
t = self._dispatch_thread
115124

116-
if q is not None:
125+
if t is None:
126+
self._frame_queue = None
127+
self._dispatch_accepting = False
128+
self._dispatch_sentinel_enqueued = False
129+
return True
130+
131+
# Establish the stop boundary while holding the same lock used
132+
# by write_frame(). No new frame can be accepted after this.
133+
self._dispatch_accepting = False
134+
135+
should_enqueue_sentinel = not self._dispatch_sentinel_enqueued
136+
self._dispatch_sentinel_enqueued = True
137+
138+
if should_enqueue_sentinel and q is not None:
117139
try:
118-
q.put(_FRAME_SENTINEL, block=True, timeout=timeout)
140+
q.put(
141+
_FRAME_SENTINEL,
142+
block=True,
143+
timeout=timeout,
144+
)
119145
except queue.Full:
120-
log.warning("Recording frame queue full while stopping dispatcher; dispatcher may not stop promptly.")
146+
# No sentinel was inserted, so allow a later stop attempt
147+
# to retry once the dispatcher has freed queue capacity.
148+
with self._lock:
149+
if self._dispatch_thread is t:
150+
self._dispatch_sentinel_enqueued = False
121151

122-
if t is not None:
123-
t.join(timeout=timeout)
124-
if t.is_alive():
125-
log.warning("Recording frame dispatcher did not stop within %.1fs", timeout)
152+
log.warning(
153+
"Could not enqueue recording dispatcher sentinel within %.1fs; preserving live dispatcher state",
154+
timeout,
155+
)
156+
return False
157+
158+
t.join(timeout=timeout)
159+
160+
if t.is_alive():
161+
log.warning(
162+
"Recording frame dispatcher did not stop within %.1fs; preserving its queue and thread state",
163+
timeout,
164+
)
165+
return False
126166

127167
with self._lock:
168+
# Do not clear a newer dispatcher if one was somehow started.
128169
if self._dispatch_thread is t:
129170
self._dispatch_thread = None
130171
self._frame_queue = None
131-
self._dispatch_stop.clear()
172+
self._dispatch_accepting = False
173+
self._dispatch_sentinel_enqueued = False
174+
175+
return True
132176

133177
def _dispatch_loop(self) -> None:
134178
with self._lock:
@@ -142,16 +186,30 @@ def _dispatch_loop(self) -> None:
142186

143187
try:
144188
if item is _FRAME_SENTINEL:
145-
break
189+
return
190+
191+
(
192+
cam_id,
193+
frame,
194+
timestamp,
195+
timestamp_metadata,
196+
) = item
146197

147-
cam_id, frame, timestamp, timestamp_metadata = item
148-
self._write_frame_now(cam_id, frame, timestamp, timestamp_metadata)
198+
self._write_frame_now(
199+
cam_id,
200+
frame,
201+
timestamp,
202+
timestamp_metadata,
203+
)
204+
205+
except Exception:
206+
log.exception("Unhandled error dispatching a recording frame")
149207

150208
finally:
151209
try:
152210
q.task_done()
153211
except ValueError:
154-
pass
212+
log.warning("Recording dispatcher called task_done() too many times")
155213

156214
def start_all(
157215
self,
@@ -257,8 +315,10 @@ def start_all(
257315

258316
return run_dir
259317

260-
def stop_all(self) -> None:
261-
self._stop_dispatcher()
318+
def stop_all(self) -> bool:
319+
if not self._stop_dispatcher():
320+
log.warning("Recording stop is incomplete, frame dispatcher is still draining.")
321+
return False
262322

263323
with self._lock:
264324
recorders = list(self._recorders.items())
@@ -275,6 +335,8 @@ def stop_all(self) -> None:
275335
self._session_dir = None
276336
self._run_dir = None
277337

338+
return True
339+
278340
def _write_frame_now(
279341
self, cam_id: str, frame: np.ndarray, timestamp: float | None = None, timestamp_metadata: object | None = None
280342
) -> None:
@@ -316,28 +378,37 @@ def write_frame(
316378
timestamp: float | None = None,
317379
timestamp_metadata: object | None = None,
318380
) -> None:
381+
payload = (
382+
cam_id,
383+
frame,
384+
timestamp if timestamp is not None else time.time(),
385+
timestamp_metadata,
386+
)
387+
319388
with self._lock:
320-
active = cam_id in self._recorders
321-
if not active:
389+
if cam_id not in self._recorders:
322390
return
323391

324392
if self._frame_queue is None or self._dispatch_thread is None or not self._dispatch_thread.is_alive():
325393
self._start_dispatcher()
326394

327395
q = self._frame_queue
328396

329-
if q is None:
330-
return
397+
if q is None or not self._dispatch_accepting:
398+
return
331399

332-
try:
333-
q.put_nowait((cam_id, frame, timestamp if timestamp is not None else time.time(), timestamp_metadata))
334-
except queue.Full:
335-
log.warning(
336-
"Recording manager frame queue full; dropping frame for %s. frame_shape=%s dtype=%s",
337-
cam_id,
338-
getattr(frame, "shape", None),
339-
getattr(frame, "dtype", None),
340-
)
400+
try:
401+
q.put_nowait(payload)
402+
return
403+
except queue.Full:
404+
pass
405+
406+
log.warning(
407+
"Recording manager frame queue full; dropping frame for %s. frame_shape=%s dtype=%s",
408+
cam_id,
409+
getattr(frame, "shape", None),
410+
getattr(frame, "dtype", None),
411+
)
341412

342413
def flush(self, timeout: float = 2.0) -> bool:
343414
"""Wait until all currently queued recording frames have been dispatched.

0 commit comments

Comments
 (0)