Compare commits

...
Author SHA1 Message Date
Josh Hawkins c89ce88afc fix birdseye camera overrides being clobbered by a global mode change
A global birdseye save published only the global object, leaving the output process to infer which cameras were inheriting by comparing against the previous global mode. That cannot tell an inherited value from an explicit one that happens to match, so it overwrote the override until a restart. Publish the per-camera values the config parse already resolved instead.
2026-07-28 17:19:38 -05:00
Josh Hawkins 4d52a3d40d fix watchdog process restarts reverting to the boot config
/api/config/set parses a new FrigateConfig and swaps the API and dispatcher onto it, but FrigateApp.config was never rebound, so the watchdog factories rebuilt a crashed process from the config as of startup. Fix is to read through a ConfigHolder that the swap updates.
2026-07-28 16:10:54 -05:00
8 changed files with 251 additions and 8 deletions
+15 -1
View File
@@ -31,7 +31,10 @@ from frigate.api.auth import (
get_allowed_cameras_for_filter, get_allowed_cameras_for_filter,
require_role, require_role,
) )
from frigate.api.config_util import swap_runtime_config from frigate.api.config_util import (
publish_camera_section_updates,
swap_runtime_config,
)
from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters from frigate.api.defs.query.app_query_parameters import AppTimelineHourlyQueryParameters
from frigate.api.defs.request.app_body import ( from frigate.api.defs.request.app_body import (
AppConfigSetBody, AppConfigSetBody,
@@ -963,6 +966,17 @@ def config_set(request: Request, body: AppConfigSetBody):
body.update_topic, settings body.update_topic, settings
) )
# a config/cameras/* topic publishes camera copies, a
# global topic the global object. FrigateConfig.parse
# folds some global sections down into every camera,
# and workers read both objects, so any such section
# needs its camera copies sent alongside the global
# publish above.
if body.update_topic == "config/birdseye":
publish_camera_section_updates(
request.app, config, CameraConfigUpdateEnum.birdseye
)
return JSONResponse( return JSONResponse(
content=( content=(
{ {
+28
View File
@@ -3,6 +3,30 @@
from fastapi import FastAPI from fastapi import FastAPI
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.config.camera.updater import (
CameraConfigUpdateEnum,
CameraConfigUpdateTopic,
)
def publish_camera_section_updates(
app: FastAPI, config: FrigateConfig, update_type: CameraConfigUpdateEnum
) -> None:
"""Broadcast every camera's re-resolved value for a global section.
Global sections are folded into each camera at parse time and the camera
copies are what workers read, so send them rather than leave a worker to
guess which cameras were inheriting.
"""
for camera_name, camera_config in config.cameras.items():
settings = getattr(camera_config, update_type.name, None)
if settings is None:
continue
app.config_publisher.publish_update(
CameraConfigUpdateTopic(update_type, camera_name), settings
)
def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None: def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None:
@@ -16,6 +40,10 @@ def swap_runtime_config(app: FastAPI, config: FrigateConfig) -> None:
camera the user turned off would silently come back on. camera the user turned off would silently come back on.
""" """
app.frigate_config = config app.frigate_config = config
if app.config_holder is not None:
app.config_holder.set(config)
app.genai_manager.update_config(config) app.genai_manager.update_config(config)
if app.profile_manager is not None: if app.profile_manager is not None:
+3
View File
@@ -35,6 +35,7 @@ from frigate.comms.event_metadata_updater import (
) )
from frigate.config import FrigateConfig from frigate.config import FrigateConfig
from frigate.config.camera.updater import CameraConfigUpdatePublisher from frigate.config.camera.updater import CameraConfigUpdatePublisher
from frigate.config.holder import ConfigHolder
from frigate.config.profile_manager import ProfileManager from frigate.config.profile_manager import ProfileManager
from frigate.debug_replay import DebugReplayManager, debug_replay_auto_stop_watchdog from frigate.debug_replay import DebugReplayManager, debug_replay_auto_stop_watchdog
from frigate.embeddings import EmbeddingsContext from frigate.embeddings import EmbeddingsContext
@@ -74,6 +75,7 @@ def create_fastapi_app(
dispatcher: Dispatcher | None = None, dispatcher: Dispatcher | None = None,
profile_manager: ProfileManager | None = None, profile_manager: ProfileManager | None = None,
enforce_default_admin: bool = True, enforce_default_admin: bool = True,
config_holder: ConfigHolder | None = None,
): ):
logger.info("Starting FastAPI app") logger.info("Starting FastAPI app")
app = FastAPI( app = FastAPI(
@@ -162,6 +164,7 @@ def create_fastapi_app(
app.replay_manager = replay_manager app.replay_manager = replay_manager
app.dispatcher = dispatcher app.dispatcher = dispatcher
app.profile_manager = profile_manager app.profile_manager = profile_manager
app.config_holder = config_holder
if frigate_config.auth.enabled: if frigate_config.auth.enabled:
secret = get_jwt_secret() secret = get_jwt_secret()
+14 -1
View File
@@ -30,6 +30,7 @@ from frigate.comms.ws import WebSocketClient
from frigate.comms.zmq_proxy import ZmqProxy from frigate.comms.zmq_proxy import ZmqProxy
from frigate.config.camera.updater import CameraConfigUpdatePublisher from frigate.config.camera.updater import CameraConfigUpdatePublisher
from frigate.config.config import FrigateConfig from frigate.config.config import FrigateConfig
from frigate.config.holder import ConfigHolder
from frigate.config.profile_manager import ProfileManager from frigate.config.profile_manager import ProfileManager
from frigate.const import ( from frigate.const import (
CACHE_DIR, CACHE_DIR,
@@ -122,7 +123,18 @@ class FrigateApp:
self.processes: dict[str, int] = {} self.processes: dict[str, int] = {}
self.embeddings: EmbeddingsContext | None = None self.embeddings: EmbeddingsContext | None = None
self.profile_manager: ProfileManager | None = None self.profile_manager: ProfileManager | None = None
self.config = config self.config_holder = ConfigHolder(config)
@property
def config(self) -> FrigateConfig:
"""The current config, not the one Frigate booted with.
Read through the holder so the deferred watchdog factories below build
a replacement process from the config as it is now. There is no setter
on purpose: a plain attribute would let a caller pin this back to a
single object and reintroduce the staleness.
"""
return self.config_holder.config
def ensure_dirs(self) -> None: def ensure_dirs(self) -> None:
dirs = [ dirs = [
@@ -645,6 +657,7 @@ class FrigateApp:
self.replay_manager, self.replay_manager,
self.dispatcher, self.dispatcher,
self.profile_manager, self.profile_manager,
config_holder=self.config_holder,
), ),
host="127.0.0.1", host="127.0.0.1",
port=5001, port=5001,
+34
View File
@@ -0,0 +1,34 @@
"""Shared handle on the config object that is current for this instance."""
from .config import FrigateConfig
__all__ = ["ConfigHolder"]
class ConfigHolder:
"""Indirection for the most recently parsed config.
/api/config/set re-parses yaml into a brand new FrigateConfig instead of
mutating the old one, so any reference captured during startup goes stale
the first time a user saves. Anything that has to build something after
startup, most importantly the watchdog factories that rebuild a crashed
process, must read through a holder rather than close over a config
object, or the rebuilt process comes back with the config as it was at
boot and silently discards every change made since.
There is deliberately no setter on the read side: the swap runs in exactly
one place (frigate.api.config_util.swap_runtime_config) and everyone else
only reads.
"""
def __init__(self, config: FrigateConfig) -> None:
self._config = config
@property
def config(self) -> FrigateConfig:
"""The config as of the most recent successful save."""
return self._config
def set(self, config: FrigateConfig) -> None:
"""Install a freshly parsed config as the current one."""
self._config = config
+3 -6
View File
@@ -178,13 +178,10 @@ class OutputProcess(FrigateProcess):
) )
if update_topic is not None and birdseye_config is not None: if update_topic is not None and birdseye_config is not None:
previous_global_mode = self.config.birdseye.mode # only the global-only fields are applied here; the per-camera
# enabled and mode arrive on config/cameras/<name>/birdseye,
# already resolved against yaml by the config parse
self.config.birdseye = birdseye_config self.config.birdseye = birdseye_config
for camera_config in self.config.cameras.values():
if camera_config.birdseye.mode == previous_global_mode:
camera_config.birdseye.mode = birdseye_config.mode
logger.debug("Applied dynamic birdseye config update") logger.debug("Applied dynamic birdseye config update")
# check if there is an updated config # check if there is an updated config
@@ -13,6 +13,7 @@ from frigate.config.camera.updater import (
CameraConfigUpdatePublisher, CameraConfigUpdatePublisher,
CameraConfigUpdateTopic, CameraConfigUpdateTopic,
) )
from frigate.config.holder import ConfigHolder
from frigate.models import Event, Recordings, ReviewSegment from frigate.models import Event, Recordings, ReviewSegment
from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp from frigate.test.http_api.base_http_test import AuthTestClient, BaseTestHttp
@@ -373,6 +374,128 @@ class TestConfigSetWildcardPropagation(BaseTestHttp):
finally: finally:
os.unlink(config_path) os.unlink(config_path)
@patch("frigate.api.app.find_config_file")
def test_global_birdseye_save_fans_out_resolved_camera_configs(
self, mock_find_config
):
"""A global birdseye save must also publish the per-camera values.
Global birdseye only seeds enabled and mode; the camera copies are what
the output process actually reads. Sending just the global object makes
a worker guess which cameras were inheriting, and the only available
guess (mode still equals the previous global) wrongly claims a camera
whose explicit yaml mode happens to match.
"""
self.minimal_config["birdseye"] = {"enabled": True, "mode": "motion"}
# explicit override that matches the global value being replaced
self.minimal_config["cameras"]["front_door"]["birdseye"] = {"mode": "motion"}
config_path = self._write_config_file()
mock_find_config.return_value = config_path
try:
app, mock_publisher = self._create_app_with_publisher()
with AuthTestClient(app) as client:
resp = client.put(
"/config/set",
json={
"config_data": {"birdseye": {"mode": "continuous"}},
"update_topic": "config/birdseye",
"requires_restart": 0,
},
)
self.assertEqual(resp.status_code, 200)
# the global object still goes out on its own topic
mock_publisher.publisher.publish.assert_called_once()
topic, settings = mock_publisher.publisher.publish.call_args[0]
self.assertEqual(topic, "config/birdseye")
self.assertEqual(settings.mode.value, "continuous")
published = {
call[0][0].camera: call[0][1]
for call in mock_publisher.publish_update.call_args_list
}
self.assertEqual(set(published), {"front_door", "back_yard"})
for call in mock_publisher.publish_update.call_args_list:
self.assertEqual(
call[0][0].update_type, CameraConfigUpdateEnum.birdseye
)
# the override survives, the inheriting camera follows global
self.assertEqual(published["front_door"].mode.value, "motion")
self.assertEqual(published["back_yard"].mode.value, "continuous")
finally:
os.unlink(config_path)
@patch("frigate.api.app.find_config_file")
def test_save_updates_the_config_holder(self, mock_find_config):
"""A save must move the holder onto the freshly parsed config.
FrigateApp reads the holder when the watchdog rebuilds a crashed
process; if the save leaves it on the boot config, that process comes
back having lost every change made since Frigate started.
"""
from fastapi import Request
from frigate.api.auth import get_allowed_cameras_for_filter, get_current_user
from frigate.api.fastapi_app import create_fastapi_app
config_path = self._write_config_file()
mock_find_config.return_value = config_path
mock_publisher = Mock(spec=CameraConfigUpdatePublisher)
mock_publisher.publisher = MagicMock()
boot_config = FrigateConfig(**self.minimal_config)
holder = ConfigHolder(boot_config)
try:
app = create_fastapi_app(
boot_config,
self.db,
None,
None,
None,
None,
None,
None,
mock_publisher,
None,
enforce_default_admin=False,
config_holder=holder,
)
async def mock_get_current_user(request: Request):
return {"username": "admin", "role": "admin"}
async def mock_get_allowed_cameras_for_filter(request: Request):
return list(self.minimal_config.get("cameras", {}).keys())
app.dependency_overrides[get_current_user] = mock_get_current_user
app.dependency_overrides[get_allowed_cameras_for_filter] = (
mock_get_allowed_cameras_for_filter
)
with AuthTestClient(app) as client:
resp = client.put(
"/config/set",
json={
"config_data": {"birdseye": {"inactivity_threshold": 5}},
"update_topic": "config/birdseye",
"requires_restart": 0,
},
)
self.assertEqual(resp.status_code, 200)
self.assertIsNot(holder.config, boot_config)
self.assertIs(holder.config, app.frigate_config)
self.assertEqual(holder.config.birdseye.inactivity_threshold, 5)
finally:
os.unlink(config_path)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+31
View File
@@ -4,6 +4,7 @@ import unittest
from unittest.mock import MagicMock from unittest.mock import MagicMock
from frigate.api.config_util import swap_runtime_config from frigate.api.config_util import swap_runtime_config
from frigate.config.holder import ConfigHolder
class TestSwapRuntimeConfig(unittest.TestCase): class TestSwapRuntimeConfig(unittest.TestCase):
@@ -12,6 +13,7 @@ class TestSwapRuntimeConfig(unittest.TestCase):
def _make_app(self) -> MagicMock: def _make_app(self) -> MagicMock:
app = MagicMock() app = MagicMock()
app.dispatcher.comms = [MagicMock(), MagicMock()] app.dispatcher.comms = [MagicMock(), MagicMock()]
app.config_holder = ConfigHolder(MagicMock(name="boot_config"))
return app return app
def test_rebinds_all_references(self) -> None: def test_rebinds_all_references(self) -> None:
@@ -37,11 +39,40 @@ class TestSwapRuntimeConfig(unittest.TestCase):
# the swap rebuilds cameras from yaml, so overrides must be re-layered # the swap rebuilds cameras from yaml, so overrides must be re-layered
app.dispatcher.reapply_runtime_state_to_config.assert_called_once_with() app.dispatcher.reapply_runtime_state_to_config.assert_called_once_with()
def test_updates_the_config_holder(self) -> None:
app = self._make_app()
holder = app.config_holder
config = MagicMock(name="new_config")
swap_runtime_config(app, config)
self.assertIs(holder.config, config)
def test_deferred_factory_builds_from_the_swapped_config(self) -> None:
"""A watchdog-style factory must not rebuild from the boot config.
The factories in FrigateApp are lambdas evaluated when a process is
restarted, long after a user may have saved. Reading through the
holder is what keeps a rebuilt process from reverting every change
made since Frigate started.
"""
app = self._make_app()
holder = app.config_holder
boot_config = holder.config
factory = lambda: holder.config # noqa: E731
self.assertIs(factory(), boot_config)
config = MagicMock(name="new_config")
swap_runtime_config(app, config)
self.assertIs(factory(), config)
def test_tolerates_missing_optional_collaborators(self) -> None: def test_tolerates_missing_optional_collaborators(self) -> None:
app = MagicMock() app = MagicMock()
app.profile_manager = None app.profile_manager = None
app.stats_emitter = None app.stats_emitter = None
app.dispatcher = None app.dispatcher = None
app.config_holder = None
config = MagicMock(name="new_config") config = MagicMock(name="new_config")
# must not raise when the optional collaborators are absent # must not raise when the optional collaborators are absent