mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-13 18:22:49 +03:00
drop a camera's cached frame mappings when it is removed or recreated
A reader that kept its mappings open stayed on a removed camera's segments, which pinned their shared memory until restart, and after a same-size recreate the ledger reopen only refreshed the slot being read while the other cached slots still returned the old camera's pixels. Readers now drop every cached slot for a camera on its remove update, and get() does the same when a ledger mismatch turns out to be a replaced ledger rather than a lapped slot.
This commit is contained in:
@@ -332,6 +332,9 @@ class EmbeddingMaintainer(threading.Thread):
|
|||||||
"""Apply camera config updates and register newly enabled processors."""
|
"""Apply camera config updates and register newly enabled processors."""
|
||||||
updated_topics = self.config_updater.check_for_updates()
|
updated_topics = self.config_updater.check_for_updates()
|
||||||
|
|
||||||
|
for camera in updated_topics.get("remove", []):
|
||||||
|
self.frame_manager.close_camera(camera)
|
||||||
|
|
||||||
if updated_topics.keys() & GENAI_UPDATE_TOPICS:
|
if updated_topics.keys() & GENAI_UPDATE_TOPICS:
|
||||||
self._sync_genai_processors()
|
self._sync_genai_processors()
|
||||||
|
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ class OutputProcess(FrigateProcess):
|
|||||||
CameraConfigUpdateEnum.birdseye,
|
CameraConfigUpdateEnum.birdseye,
|
||||||
CameraConfigUpdateEnum.enabled,
|
CameraConfigUpdateEnum.enabled,
|
||||||
CameraConfigUpdateEnum.record,
|
CameraConfigUpdateEnum.record,
|
||||||
|
CameraConfigUpdateEnum.remove,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
birdseye_config_subscriber = ConfigSubscriber("config/birdseye", exact=True)
|
birdseye_config_subscriber = ConfigSubscriber("config/birdseye", exact=True)
|
||||||
@@ -208,6 +209,10 @@ class OutputProcess(FrigateProcess):
|
|||||||
birdseye,
|
birdseye,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if CameraConfigUpdateEnum.remove in updates:
|
||||||
|
for camera in updates["remove"]:
|
||||||
|
frame_manager.close_camera(camera)
|
||||||
|
|
||||||
_result = detection_subscriber.check_for_update(timeout=1)
|
_result = detection_subscriber.check_for_update(timeout=1)
|
||||||
if _result is None:
|
if _result is None:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -504,6 +504,7 @@ class ReviewSegmentMaintainer(threading.Thread):
|
|||||||
def _handle_camera_removed(self, camera: str) -> None:
|
def _handle_camera_removed(self, camera: str) -> None:
|
||||||
"""Close out a deleted camera's segment so a reused name cannot inherit it."""
|
"""Close out a deleted camera's segment so a reused name cannot inherit it."""
|
||||||
self.forcibly_end_segment(camera)
|
self.forcibly_end_segment(camera)
|
||||||
|
self.frame_manager.close_camera(camera)
|
||||||
self.indefinite_events.pop(camera, None)
|
self.indefinite_events.pop(camera, None)
|
||||||
self.recent_classification_state_changes.pop(camera, None)
|
self.recent_classification_state_changes.pop(camera, None)
|
||||||
|
|
||||||
|
|||||||
@@ -183,10 +183,12 @@ class TestReviewMaintainerRemoval(unittest.TestCase):
|
|||||||
"deleted_cam": [{"model": "gate", "from": "a", "to": "b", "timestamp": 1.0}]
|
"deleted_cam": [{"model": "gate", "from": "a", "to": "b", "timestamp": 1.0}]
|
||||||
}
|
}
|
||||||
maintainer.forcibly_end_segment = MagicMock()
|
maintainer.forcibly_end_segment = MagicMock()
|
||||||
|
maintainer.frame_manager = MagicMock()
|
||||||
|
|
||||||
maintainer._handle_camera_removed("deleted_cam")
|
maintainer._handle_camera_removed("deleted_cam")
|
||||||
|
|
||||||
maintainer.forcibly_end_segment.assert_called_once_with("deleted_cam")
|
maintainer.forcibly_end_segment.assert_called_once_with("deleted_cam")
|
||||||
|
maintainer.frame_manager.close_camera.assert_called_once_with("deleted_cam")
|
||||||
self.assertNotIn("deleted_cam", maintainer.indefinite_events)
|
self.assertNotIn("deleted_cam", maintainer.indefinite_events)
|
||||||
self.assertNotIn("deleted_cam", maintainer.recent_classification_state_changes)
|
self.assertNotIn("deleted_cam", maintainer.recent_classification_state_changes)
|
||||||
|
|
||||||
|
|||||||
@@ -243,26 +243,56 @@ class TestSharedMemoryFrameManagerFrameTimes(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertIsNone(arr)
|
self.assertIsNone(arr)
|
||||||
|
|
||||||
def test_get_reopens_once_when_cached_ledger_is_stale(self) -> None:
|
def test_get_reopens_every_cached_slot_when_ledger_is_stale(self) -> None:
|
||||||
"""A camera recreated at the same resolution gets new segments under
|
"""A camera recreated at the same resolution gets new segments under
|
||||||
the old names. A long-lived reader still mapped to the unlinked
|
the old names. A long-lived reader still mapped to the unlinked ledger
|
||||||
ledger sees a mismatch, which must trigger one reopen by name before
|
sees a mismatch, which must drop every cached slot of that camera, not
|
||||||
the frame is declared gone."""
|
only the one being read, or later reads return the old pixels."""
|
||||||
manager = SharedMemoryFrameManager()
|
manager = SharedMemoryFrameManager()
|
||||||
stale = _segments(cam_frame0=2_500, cam_frame_times=8 * 2)
|
stale = _segments(cam_frame0=2_500, cam_frame1=2_500, cam_frame_times=8 * 2)
|
||||||
manager.shm_store.update(stale)
|
manager.shm_store.update(stale)
|
||||||
current = _segments(cam_frame0=2_500, cam_frame_times=8 * 2)
|
current = _segments(cam_frame0=2_500, cam_frame1=2_500, cam_frame_times=8 * 2)
|
||||||
np.frombuffer(current["cam_frame_times"].buf, dtype=np.float64)[0] = 100.25
|
current["cam_frame1"].buf[0] = 7
|
||||||
|
np.frombuffer(current["cam_frame_times"].buf, dtype=np.float64)[:] = (
|
||||||
|
100.25,
|
||||||
|
100.5,
|
||||||
|
)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"frigate.util.image.UntrackedSharedMemory",
|
"frigate.util.image.UntrackedSharedMemory",
|
||||||
side_effect=_open_from(current),
|
side_effect=_open_from(current),
|
||||||
|
):
|
||||||
|
first = manager.get("cam_frame0", (50, 50), frame_time=100.25)
|
||||||
|
second = manager.get("cam_frame1", (50, 50), frame_time=100.5)
|
||||||
|
|
||||||
|
self.assertIsNotNone(first)
|
||||||
|
self.assertEqual(second[0, 0], 7)
|
||||||
|
self.assertIs(manager.shm_store["cam_frame1"], current["cam_frame1"])
|
||||||
|
|
||||||
|
def test_get_keeps_cached_slots_when_only_lapped(self) -> None:
|
||||||
|
manager = SharedMemoryFrameManager()
|
||||||
|
segments = _segments(cam_frame0=2_500, cam_frame1=2_500, cam_frame_times=8 * 2)
|
||||||
|
manager.shm_store.update(segments)
|
||||||
|
np.frombuffer(segments["cam_frame_times"].buf, dtype=np.float64)[0] = 200.0
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"frigate.util.image.UntrackedSharedMemory",
|
||||||
|
side_effect=_open_from(segments),
|
||||||
):
|
):
|
||||||
arr = manager.get("cam_frame0", (50, 50), frame_time=100.25)
|
arr = manager.get("cam_frame0", (50, 50), frame_time=100.25)
|
||||||
|
|
||||||
self.assertIsNotNone(arr)
|
self.assertIsNone(arr)
|
||||||
self.assertIs(manager.shm_store["cam_frame0"], current["cam_frame0"])
|
self.assertIs(manager.shm_store["cam_frame1"], segments["cam_frame1"])
|
||||||
self.assertIs(manager.shm_store["cam_frame_times"], current["cam_frame_times"])
|
|
||||||
|
def test_close_camera_drops_only_that_camera(self) -> None:
|
||||||
|
manager = SharedMemoryFrameManager()
|
||||||
|
manager.shm_store.update(
|
||||||
|
_segments(cam_frame0=1, cam_frame1=1, cam_frame_times=8, other_frame0=1)
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.close_camera("cam")
|
||||||
|
|
||||||
|
self.assertEqual(set(manager.shm_store), {"other_frame0"})
|
||||||
|
|
||||||
def test_get_without_frame_time_skips_ledger(self) -> None:
|
def test_get_without_frame_time_skips_ledger(self) -> None:
|
||||||
manager = SharedMemoryFrameManager()
|
manager = SharedMemoryFrameManager()
|
||||||
|
|||||||
@@ -763,6 +763,7 @@ class TrackedObjectProcessor(threading.Thread):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
camera_state.shutdown()
|
camera_state.shutdown()
|
||||||
|
self.frame_manager.close_camera(camera)
|
||||||
|
|
||||||
with self.camera_states_lock:
|
with self.camera_states_lock:
|
||||||
self.camera_states.pop(camera)
|
self.camera_states.pop(camera)
|
||||||
|
|||||||
+18
-5
@@ -1003,6 +1003,10 @@ class FrameManager(ABC):
|
|||||||
def close(self, name: str):
|
def close(self, name: str):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def close_camera(self, camera: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def delete(self, name: str):
|
def delete(self, name: str):
|
||||||
pass
|
pass
|
||||||
@@ -1110,7 +1114,8 @@ class SharedMemoryFrameManager(FrameManager):
|
|||||||
"""Map a frame slot as an array without copying it.
|
"""Map a frame slot as an array without copying it.
|
||||||
|
|
||||||
The array points into shared memory, so it is only valid until the next
|
The array points into shared memory, so it is only valid until the next
|
||||||
close(), delete(), or get() of the same name. Copy it to keep it longer.
|
close(), delete(), or get() of the same name, and capture overwrites its
|
||||||
|
pixels once the ring wraps back to this slot. Copy it to keep it longer.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: Slot name, such as front_door_frame3
|
name: Slot name, such as front_door_frame3
|
||||||
@@ -1122,15 +1127,18 @@ class SharedMemoryFrameManager(FrameManager):
|
|||||||
a different frame than frame_time
|
a different frame than frame_time
|
||||||
"""
|
"""
|
||||||
if frame_time is not None and not self._holds(name, frame_time):
|
if frame_time is not None and not self._holds(name, frame_time):
|
||||||
# a mapping left over from a same-size recreate of the camera
|
# a ledger left over from a same-size recreate of the camera looks
|
||||||
# looks the same as a lapped slot, so reopen by name once
|
# the same as a lapped slot, so reopen it by name once
|
||||||
self.close(name)
|
camera = name.rpartition("_frame")[0]
|
||||||
self.close(f"{name.rpartition('_frame')[0]}_frame_times")
|
self.close(f"{camera}_frame_times")
|
||||||
|
|
||||||
if not self._holds(name, frame_time):
|
if not self._holds(name, frame_time):
|
||||||
logger.debug("%s no longer holds the frame from %s", name, frame_time)
|
logger.debug("%s no longer holds the frame from %s", name, frame_time)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# the ledger was replaced, so every cached slot maps the old segments
|
||||||
|
self.close_camera(camera)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
required = int(np.prod(shape))
|
required = int(np.prod(shape))
|
||||||
shm = self.shm_store.get(name)
|
shm = self.shm_store.get(name)
|
||||||
@@ -1161,6 +1169,11 @@ class SharedMemoryFrameManager(FrameManager):
|
|||||||
self.shm_store[name].close()
|
self.shm_store[name].close()
|
||||||
del self.shm_store[name]
|
del self.shm_store[name]
|
||||||
|
|
||||||
|
def close_camera(self, camera: str) -> None:
|
||||||
|
"""Drop every cached mapping for a camera's frame ring and ledger."""
|
||||||
|
for name in [n for n in self.shm_store if n.startswith(f"{camera}_frame")]:
|
||||||
|
self.close(name)
|
||||||
|
|
||||||
def delete(self, name: str):
|
def delete(self, name: str):
|
||||||
if name in self.shm_store:
|
if name in self.shm_store:
|
||||||
self.shm_store[name].close()
|
self.shm_store[name].close()
|
||||||
|
|||||||
Reference in New Issue
Block a user