diff --git a/frigate/embeddings/maintainer.py b/frigate/embeddings/maintainer.py index 391c344ef9..e95a78212b 100644 --- a/frigate/embeddings/maintainer.py +++ b/frigate/embeddings/maintainer.py @@ -332,6 +332,9 @@ class EmbeddingMaintainer(threading.Thread): """Apply camera config updates and register newly enabled processors.""" 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: self._sync_genai_processors() diff --git a/frigate/output/output.py b/frigate/output/output.py index 940ed9caf7..3a76478da4 100644 --- a/frigate/output/output.py +++ b/frigate/output/output.py @@ -145,6 +145,7 @@ class OutputProcess(FrigateProcess): CameraConfigUpdateEnum.birdseye, CameraConfigUpdateEnum.enabled, CameraConfigUpdateEnum.record, + CameraConfigUpdateEnum.remove, ], ) birdseye_config_subscriber = ConfigSubscriber("config/birdseye", exact=True) @@ -208,6 +209,10 @@ class OutputProcess(FrigateProcess): birdseye, ) + if CameraConfigUpdateEnum.remove in updates: + for camera in updates["remove"]: + frame_manager.close_camera(camera) + _result = detection_subscriber.check_for_update(timeout=1) if _result is None: continue diff --git a/frigate/review/maintainer.py b/frigate/review/maintainer.py index 08fa4f32e9..d9c31871cb 100644 --- a/frigate/review/maintainer.py +++ b/frigate/review/maintainer.py @@ -504,6 +504,7 @@ class ReviewSegmentMaintainer(threading.Thread): def _handle_camera_removed(self, camera: str) -> None: """Close out a deleted camera's segment so a reused name cannot inherit it.""" self.forcibly_end_segment(camera) + self.frame_manager.close_camera(camera) self.indefinite_events.pop(camera, None) self.recent_classification_state_changes.pop(camera, None) diff --git a/frigate/test/test_camera_lifecycle.py b/frigate/test/test_camera_lifecycle.py index 1b5b8920ac..6917a7bccd 100644 --- a/frigate/test/test_camera_lifecycle.py +++ b/frigate/test/test_camera_lifecycle.py @@ -183,10 +183,12 @@ class TestReviewMaintainerRemoval(unittest.TestCase): "deleted_cam": [{"model": "gate", "from": "a", "to": "b", "timestamp": 1.0}] } maintainer.forcibly_end_segment = MagicMock() + maintainer.frame_manager = MagicMock() maintainer._handle_camera_removed("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.recent_classification_state_changes) diff --git a/frigate/test/test_shared_memory_frame_manager.py b/frigate/test/test_shared_memory_frame_manager.py index 456ec488d6..908ca9f3c0 100644 --- a/frigate/test/test_shared_memory_frame_manager.py +++ b/frigate/test/test_shared_memory_frame_manager.py @@ -243,26 +243,56 @@ class TestSharedMemoryFrameManagerFrameTimes(unittest.TestCase): 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 - the old names. A long-lived reader still mapped to the unlinked - ledger sees a mismatch, which must trigger one reopen by name before - the frame is declared gone.""" + the old names. A long-lived reader still mapped to the unlinked ledger + sees a mismatch, which must drop every cached slot of that camera, not + only the one being read, or later reads return the old pixels.""" 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) - current = _segments(cam_frame0=2_500, cam_frame_times=8 * 2) - np.frombuffer(current["cam_frame_times"].buf, dtype=np.float64)[0] = 100.25 + current = _segments(cam_frame0=2_500, cam_frame1=2_500, cam_frame_times=8 * 2) + current["cam_frame1"].buf[0] = 7 + np.frombuffer(current["cam_frame_times"].buf, dtype=np.float64)[:] = ( + 100.25, + 100.5, + ) with patch( "frigate.util.image.UntrackedSharedMemory", 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) - self.assertIsNotNone(arr) - self.assertIs(manager.shm_store["cam_frame0"], current["cam_frame0"]) - self.assertIs(manager.shm_store["cam_frame_times"], current["cam_frame_times"]) + self.assertIsNone(arr) + self.assertIs(manager.shm_store["cam_frame1"], segments["cam_frame1"]) + + 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: manager = SharedMemoryFrameManager() diff --git a/frigate/track/object_processing.py b/frigate/track/object_processing.py index 53bf152e79..1183811651 100644 --- a/frigate/track/object_processing.py +++ b/frigate/track/object_processing.py @@ -763,6 +763,7 @@ class TrackedObjectProcessor(threading.Thread): continue camera_state.shutdown() + self.frame_manager.close_camera(camera) with self.camera_states_lock: self.camera_states.pop(camera) diff --git a/frigate/util/image.py b/frigate/util/image.py index 35d516ed4e..11173c36dc 100644 --- a/frigate/util/image.py +++ b/frigate/util/image.py @@ -1003,6 +1003,10 @@ class FrameManager(ABC): def close(self, name: str): pass + @abstractmethod + def close_camera(self, camera: str) -> None: + pass + @abstractmethod def delete(self, name: str): pass @@ -1110,7 +1114,8 @@ class SharedMemoryFrameManager(FrameManager): """Map a frame slot as an array without copying it. 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: name: Slot name, such as front_door_frame3 @@ -1122,15 +1127,18 @@ class SharedMemoryFrameManager(FrameManager): a different frame than 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 - # looks the same as a lapped slot, so reopen by name once - self.close(name) - self.close(f"{name.rpartition('_frame')[0]}_frame_times") + # a ledger left over from a same-size recreate of the camera looks + # the same as a lapped slot, so reopen it by name once + camera = name.rpartition("_frame")[0] + self.close(f"{camera}_frame_times") if not self._holds(name, frame_time): logger.debug("%s no longer holds the frame from %s", name, frame_time) return None + # the ledger was replaced, so every cached slot maps the old segments + self.close_camera(camera) + try: required = int(np.prod(shape)) shm = self.shm_store.get(name) @@ -1161,6 +1169,11 @@ class SharedMemoryFrameManager(FrameManager): self.shm_store[name].close() 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): if name in self.shm_store: self.shm_store[name].close()