Skip to content

Commit d0999a7

Browse files
committed
Make stop join timeout configurable and add test
Introduce STOP_JOIN_TIMEOUT (5s) and use it for the writer thread join timeout instead of a hardcoded value. Reorder lifecycle checks in VideoRecorder.start to raise if the recorder is marked _abandoned before short-circuiting on is_running, preventing accidental restarts of abandoned recorders. Add a BlockingWriteGear fake, fixture, and a test (test_stop_timeout_marks_abandoned_and_prevents_restart) that forces a stop timeout, verifies the recorder is marked abandoned, prevents restart, and performs cleanup of the blocked writer thread.
1 parent 2b0a359 commit d0999a7

2 files changed

Lines changed: 77 additions & 3 deletions

File tree

dlclivegui/services/video_recorder.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323

2424
logger = logging.getLogger(__name__)
2525

26+
STOP_JOIN_TIMEOUT = 5.0 # seconds
27+
2628

2729
@dataclass
2830
class RecorderStats:
@@ -88,10 +90,10 @@ def start(self) -> None:
8890
raise RuntimeError("vidgear is required for video recording. Install it with 'pip install vidgear'.")
8991

9092
with self._lifecycle_lock:
91-
if self.is_running:
92-
return
9393
if self._abandoned:
9494
raise RuntimeError("Cannot restart VideoRecorder, as a leftover thread is still running.")
95+
if self.is_running:
96+
return
9597
if self._writer is not None:
9698
# Best-effort cleanup of a stale writer to avoid leaking resources.
9799
logger.warning(
@@ -222,7 +224,7 @@ def stop(self) -> None:
222224

223225
t = self._writer_thread
224226
if t is not None:
225-
t.join(timeout=5.0)
227+
t.join(timeout=STOP_JOIN_TIMEOUT)
226228
if t.is_alive():
227229
with self._stats_lock:
228230
self._encode_error = RuntimeError(

tests/services/test_video_recorder.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import json
4+
import threading
45
import time
56
from pathlib import Path
67

@@ -75,6 +76,38 @@ def gray_frame():
7576
return np.zeros((48, 64), dtype=np.uint8)
7677

7778

79+
class BlockingWriteGear(FakeWriteGear):
80+
"""
81+
Fake WriteGear that blocks inside write() to simulate a hung encoder/IO stall.
82+
"""
83+
84+
instances = []
85+
86+
def __init__(self, output: str, **kwargs):
87+
super().__init__(output, **kwargs)
88+
self.entered_write = threading.Event()
89+
self.release_write = threading.Event()
90+
BlockingWriteGear.instances.append(self)
91+
92+
def write(self, frame):
93+
# Signal that the writer thread is now stuck inside write()
94+
self.entered_write.set()
95+
# Block until the test releases us (long timeout as safety, but test releases explicitly)
96+
self.release_write.wait(timeout=60.0)
97+
# If released, behave like normal FakeWriteGear
98+
return super().write(frame)
99+
100+
101+
@pytest.fixture
102+
def patch_blocking_writegear(monkeypatch):
103+
"""
104+
Patch module-level WriteGear to BlockingWriteGear for the hung-thread test.
105+
"""
106+
BlockingWriteGear.instances.clear()
107+
monkeypatch.setattr(vr_mod, "WriteGear", BlockingWriteGear)
108+
return BlockingWriteGear
109+
110+
78111
# ----------------------------
79112
# Tests
80113
# ----------------------------
@@ -295,3 +328,42 @@ def test_overlay_frame_size_mismatch_still_detected(patch_writegear, output_path
295328
rec.write(np.zeros((48, 64, 3), dtype=np.uint8), timestamp=2.0)
296329

297330
rec.stop()
331+
332+
333+
def test_stop_timeout_marks_abandoned_and_prevents_restart(
334+
patch_blocking_writegear, monkeypatch, output_path, rgb_frame
335+
):
336+
# Make stop timeout tiny so the test is fast.
337+
monkeypatch.setattr(vr_mod, "STOP_JOIN_TIMEOUT", 0.01)
338+
339+
rec = vr_mod.VideoRecorder(output_path, frame_size=(48, 64), buffer_size=10)
340+
rec.start()
341+
assert rec.is_running is True
342+
343+
# Enqueue one frame so writer thread enters BlockingWriteGear.write() and blocks.
344+
assert rec.write(rgb_frame, timestamp=1.0) is True
345+
346+
wg = patch_blocking_writegear.instances[0]
347+
wait_until(lambda: wg.entered_write.is_set(), timeout=1.0)
348+
349+
# Now stop: should time out and mark abandoned
350+
rec.stop()
351+
352+
assert rec._abandoned is True
353+
err = rec._current_error()
354+
assert err is not None
355+
assert "Failed to stop VideoRecorder within timeout" in str(err)
356+
357+
# Thread should still be alive (blocked in write)
358+
assert rec.is_running is True
359+
360+
# Restart should be prevented while abandoned
361+
with pytest.raises(RuntimeError, match="Cannot restart VideoRecorder"):
362+
rec.start()
363+
364+
# ---- Cleanup to avoid leaking a blocked daemon thread into other tests ----
365+
wg.release_write.set() # let write() return
366+
wait_until(lambda: not rec.is_running, timeout=2.0)
367+
368+
# Call stop again to clear resources / reset flags (now it can join cleanly)
369+
rec.stop()

0 commit comments

Comments
 (0)