From 48aaafba3c84ec1b23f175df9e93befb5f1a9e2e Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:30:41 -0500 Subject: [PATCH] re-apply runtime overrides to the config without re-broadcasting them (#23739) /api/config/set and camera deletion re-parse yaml into a fresh FrigateConfig and swap it in, then re-layered the persisted runtime toggle overrides so a camera the user turned off wouldn't come back on. That re-layer ran apply_runtime_state, which replays each override through the command handlers, so every save re-published a ZMQ config update, a retained MQTT state message, and a runtime-state disk write for every camera with a stored toggle. All of it was redundant: the worker processes were never swapped and still hold the live toggle values, so only the in-process config object the API and dispatcher read was out of date. The extra traffic churned the retained MQTT topics, amplified disk writes, and co-drained enabled updates with other topics on the config socket. Add Dispatcher.reapply_runtime_state_to_config, which corrects only the swapped-in config object, mirroring the field mutations and gates of the _on_*_command handlers with no ZMQ, MQTT, or disk writes. swap_runtime_config now calls it instead of apply_runtime_state; apply_runtime_state is unchanged and still used at startup, where the workers genuinely must be told. --- frigate/api/config_util.py | 4 +- frigate/comms/dispatcher.py | 42 +++++++ frigate/test/http_api/test_http_camera.py | 2 +- frigate/test/http_api/test_http_config_set.py | 11 +- frigate/test/test_config_util.py | 2 +- frigate/test/test_dispatcher_runtime_state.py | 110 ++++++++++++++++++ 6 files changed, 162 insertions(+), 9 deletions(-) diff --git a/frigate/api/config_util.py b/frigate/api/config_util.py index 6a95a4ab01..5d963ad725 100644 --- a/frigate/api/config_util.py +++ b/frigate/api/config_util.py @@ -30,4 +30,6 @@ def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None: for comm in app.dispatcher.comms: comm.config = config - app.dispatcher.apply_runtime_state() + # workers still hold the live toggle values, so correct only the + # config object here rather than re-broadcasting every override + app.dispatcher.reapply_runtime_state_to_config() diff --git a/frigate/comms/dispatcher.py b/frigate/comms/dispatcher.py index 29f5fd97bc..ec16f9744e 100644 --- a/frigate/comms/dispatcher.py +++ b/frigate/comms/dispatcher.py @@ -492,6 +492,48 @@ class Dispatcher: """ self._runtime_state.clear_camera(camera) + def reapply_runtime_state_to_config(self) -> None: + """Re-apply persisted runtime overrides to the swapped-in config object. + + After config/set (or a camera delete) parses fresh yaml and swaps the + config, the worker processes still hold the live toggle values and the + overrides are already on disk, so only the in-process config object is + out of date. Unlike apply_runtime_state (used at startup, where workers + must be told), this makes no ZMQ, MQTT, or disk writes, it just corrects + the config the API and dispatcher read. + + The field mutations and gates mirror the _on_*_command handlers; keep + the two in sync if a tracked toggle is added or its gate changes. + """ + state = self._runtime_state.load() + + for camera_name, features in state.items(): + camera = self.config.cameras.get(camera_name) + + if camera is None: + continue + + for topic, value in features.items(): + if topic == "enabled": + if value and not camera.enabled_in_config: + continue + camera.enabled = value + elif topic == "detect": + camera.detect.enabled = value + # detection requires motion, mirror the handler coupling + if value and not camera.motion.enabled: + camera.motion.enabled = True + elif topic == "snapshots": + camera.snapshots.enabled = value + elif topic == "recordings": + if value and not camera.record.enabled_in_config: + continue + camera.record.enabled = value + elif topic == "audio": + if value and not camera.audio.enabled_in_config: + continue + camera.audio.enabled = value + def _on_detect_command(self, camera_name: str, payload: str) -> None: """Callback for detect topic.""" detect_settings = self.config.cameras[camera_name].detect diff --git a/frigate/test/http_api/test_http_camera.py b/frigate/test/http_api/test_http_camera.py index afeac1bde2..cab2d16675 100644 --- a/frigate/test/http_api/test_http_camera.py +++ b/frigate/test/http_api/test_http_camera.py @@ -118,7 +118,7 @@ class TestDeleteCameraRuntimeConfig(BaseTestHttp): self.assertIn("back_yard", dispatcher.config.cameras) # surviving cameras' overrides are re-layered onto the new object - dispatcher.apply_runtime_state.assert_called_once_with() + dispatcher.reapply_runtime_state_to_config.assert_called_once_with() # the deleted camera's persisted overrides are pruned dispatcher.clear_runtime_state_for_camera.assert_called_once_with( diff --git a/frigate/test/http_api/test_http_config_set.py b/frigate/test/http_api/test_http_config_set.py index cd249c6fa6..0540a50818 100644 --- a/frigate/test/http_api/test_http_config_set.py +++ b/frigate/test/http_api/test_http_config_set.py @@ -143,11 +143,10 @@ class TestConfigSetWildcardPropagation(BaseTestHttp): # front_door was turned off via the UI: the override is on disk, and # yaml still says enabled: true. Stand in for the real replay, which # reads dispatcher.config - the object the endpoint just swapped in. - def fake_apply(): + def fake_reapply(): dispatcher.config.cameras["front_door"].enabled = False - return {"front_door": {"enabled": False}} - dispatcher.apply_runtime_state.side_effect = fake_apply + dispatcher.reapply_runtime_state_to_config.side_effect = fake_reapply try: app, _ = self._create_app_with_dispatcher(dispatcher) @@ -168,7 +167,7 @@ class TestConfigSetWildcardPropagation(BaseTestHttp): # the swap must be repaired: the new config object the API and # dispatcher now share has to still show front_door as off - dispatcher.apply_runtime_state.assert_called_once_with() + dispatcher.reapply_runtime_state_to_config.assert_called_once_with() self.assertFalse(app.frigate_config.cameras["front_door"].enabled) self.assertIs(dispatcher.config, app.frigate_config) @@ -178,7 +177,7 @@ class TestConfigSetWildcardPropagation(BaseTestHttp): call_names = [name for name, _, _ in dispatcher.mock_calls] self.assertLess( call_names.index("clear_runtime_state_for_yaml_keys"), - call_names.index("apply_runtime_state"), + call_names.index("reapply_runtime_state_to_config"), ) finally: os.unlink(config_path) @@ -205,7 +204,7 @@ class TestConfigSetWildcardPropagation(BaseTestHttp): ) self.assertEqual(resp.status_code, 200) - dispatcher.apply_runtime_state.assert_not_called() + dispatcher.reapply_runtime_state_to_config.assert_not_called() finally: os.unlink(config_path) diff --git a/frigate/test/test_config_util.py b/frigate/test/test_config_util.py index 310b233aaa..300b0b0c51 100644 --- a/frigate/test/test_config_util.py +++ b/frigate/test/test_config_util.py @@ -35,7 +35,7 @@ class TestSwapRuntimeConfig(unittest.TestCase): swap_runtime_config(app, config) # the swap rebuilds cameras from yaml, so overrides must be re-layered - app.dispatcher.apply_runtime_state.assert_called_once_with() + app.dispatcher.reapply_runtime_state_to_config.assert_called_once_with() def test_tolerates_missing_optional_collaborators(self) -> None: app = MagicMock() diff --git a/frigate/test/test_dispatcher_runtime_state.py b/frigate/test/test_dispatcher_runtime_state.py index e0a91536cb..dc6bb0bf2d 100644 --- a/frigate/test/test_dispatcher_runtime_state.py +++ b/frigate/test/test_dispatcher_runtime_state.py @@ -253,5 +253,115 @@ class TestClearPassthrough(unittest.TestCase): dispatcher._runtime_state.clear_camera.assert_called_once_with("front_door") +class TestReapplyRuntimeStateToConfig(unittest.TestCase): + """The silent re-apply corrects the config object with no side effects.""" + + def _dispatcher_with( + self, cameras: dict[str, MagicMock], state: dict + ) -> Dispatcher: + dispatcher = _build_dispatcher(cameras) + dispatcher._runtime_state = MagicMock(spec=RuntimeStatePersistence) + dispatcher._runtime_state.load.return_value = state + dispatcher.publish = MagicMock() + return dispatcher + + def test_mutates_every_tracked_field(self) -> None: + cameras = {"front_door": _make_camera_mock()} + dispatcher = self._dispatcher_with( + cameras, + { + "front_door": { + "enabled": False, + "detect": False, + "snapshots": False, + "recordings": False, + "audio": False, + } + }, + ) + + dispatcher.reapply_runtime_state_to_config() + + cam = cameras["front_door"] + self.assertFalse(cam.enabled) + self.assertFalse(cam.detect.enabled) + self.assertFalse(cam.snapshots.enabled) + self.assertFalse(cam.record.enabled) + self.assertFalse(cam.audio.enabled) + + def test_makes_no_zmq_mqtt_or_disk_writes(self) -> None: + dispatcher = self._dispatcher_with( + {"front_door": _make_camera_mock()}, + {"front_door": {"enabled": False}}, + ) + + dispatcher.reapply_runtime_state_to_config() + + dispatcher.config_updater.publish_update.assert_not_called() + dispatcher._runtime_state.set.assert_not_called() + dispatcher.publish.assert_not_called() + + def test_respects_enabled_in_config_gate(self) -> None: + # an ON override for a camera disabled in yaml must not enable it + cameras = { + "front_door": _make_camera_mock(enabled=False, enabled_in_config=False) + } + dispatcher = self._dispatcher_with(cameras, {"front_door": {"enabled": True}}) + + dispatcher.reapply_runtime_state_to_config() + + self.assertFalse(cameras["front_door"].enabled) + + def test_respects_recordings_and_audio_gates(self) -> None: + # ON overrides for recordings/audio not enabled in yaml must be ignored + cameras = { + "front_door": _make_camera_mock( + record_enabled=False, + record_enabled_in_config=False, + audio_enabled=False, + audio_enabled_in_config=False, + ) + } + dispatcher = self._dispatcher_with( + cameras, {"front_door": {"recordings": True, "audio": True}} + ) + + dispatcher.reapply_runtime_state_to_config() + + self.assertFalse(cameras["front_door"].record.enabled) + self.assertFalse(cameras["front_door"].audio.enabled) + + def test_applies_on_override_when_gate_passes(self) -> None: + # a camera off in yaml but enabled_in_config keeps its runtime-on state + cameras = { + "front_door": _make_camera_mock(enabled=False, enabled_in_config=True) + } + dispatcher = self._dispatcher_with(cameras, {"front_door": {"enabled": True}}) + + dispatcher.reapply_runtime_state_to_config() + + self.assertTrue(cameras["front_door"].enabled) + + def test_detect_on_couples_motion(self) -> None: + cam = _make_camera_mock(detect_enabled=False) + cam.motion.enabled = False + dispatcher = self._dispatcher_with( + {"front_door": cam}, {"front_door": {"detect": True}} + ) + + dispatcher.reapply_runtime_state_to_config() + + self.assertTrue(cam.detect.enabled) + self.assertTrue(cam.motion.enabled) + + def test_skips_camera_not_in_config(self) -> None: + dispatcher = self._dispatcher_with( + {"front_door": _make_camera_mock()}, {"ghost": {"enabled": False}} + ) + + # a stale entry for a deleted camera must be ignored, not raise + dispatcher.reapply_runtime_state_to_config() + + if __name__ == "__main__": unittest.main()