From 1cad5a78df75e0cb5f28126ef8c79b2d44dba6ed Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 21 May 2026 07:55:17 -0500 Subject: [PATCH 01/10] use monotonic clock for detector inference duration to prevent negative values from wall clock steps --- frigate/object_detection/base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frigate/object_detection/base.py b/frigate/object_detection/base.py index a62fe48431..f2336f3da8 100644 --- a/frigate/object_detection/base.py +++ b/frigate/object_detection/base.py @@ -167,8 +167,9 @@ class DetectorRunner(FrigateProcess): # detect and send the output self.start_time.value = datetime.datetime.now().timestamp() + mono_start = time.monotonic() detections = object_detector.detect_raw(input_frame) - duration = datetime.datetime.now().timestamp() - self.start_time.value + duration = time.monotonic() - mono_start frame_manager.close(connection_id) if connection_id not in self.outputs: From 2c147d835d04adec86a4f1da6e38b086a9f05ae3 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 21 May 2026 09:40:10 -0500 Subject: [PATCH 02/10] add ability to set camera's webui_url from camera management pane --- web/public/locales/en/views/settings.json | 14 +- .../views/settings/CameraManagementView.tsx | 225 +++++++++++++++--- 2 files changed, 205 insertions(+), 34 deletions(-) diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 65a3430269..5830b20555 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -484,11 +484,15 @@ "reorderHandle": "Drag to reorder", "saving": "Saving…", "saved": "Saved", - "friendlyName": { - "edit": "Edit camera display name", - "title": "Edit Display Name", - "description": "Set the friendly name shown for this camera throughout the Frigate UI. Leave blank to use the camera ID.", - "rename": "Rename" + "details": { + "edit": "Edit camera details", + "title": "Edit Camera Details", + "description": "Update the display name and external URL used for this camera throughout the Frigate UI.", + "friendlyNameLabel": "Display Name", + "friendlyNameHelp": "Friendly name shown for this camera throughout the Frigate UI. Leave blank to use the camera ID.", + "webuiUrlLabel": "Camera Web UI URL", + "webuiUrlHelp": "URL to visit the camera's web UI directly from the Debug view. Leave blank to disable the link.", + "webuiUrlInvalid": "Must be a valid URL (e.g., https://example.com)." } }, "cameraConfig": { diff --git a/web/src/views/settings/CameraManagementView.tsx b/web/src/views/settings/CameraManagementView.tsx index b43baf170f..212b32389a 100644 --- a/web/src/views/settings/CameraManagementView.tsx +++ b/web/src/views/settings/CameraManagementView.tsx @@ -36,7 +36,15 @@ import axios from "axios"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import RestartDialog from "@/components/overlay/dialog/RestartDialog"; import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator"; -import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; import { Tooltip, TooltipContent, @@ -53,6 +61,17 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; const REORDER_SAVED_INDICATOR_MS = 1500; @@ -482,7 +501,7 @@ function EnabledCameraRow({ - @@ -519,25 +538,91 @@ function CameraEnableSwitch({ cameraName }: CameraEnableSwitchProps) { ); } -type CameraFriendlyNameEditorProps = { +type CameraDetailsEditorProps = { cameraName: string; onConfigChanged: () => Promise; }; -function CameraFriendlyNameEditor({ +type CameraDetailsFormValues = { + friendlyName: string; + webuiUrl: string; +}; + +function CameraDetailsEditor({ cameraName, onConfigChanged, -}: CameraFriendlyNameEditorProps) { +}: CameraDetailsEditorProps) { const { t } = useTranslation(["views/settings", "common"]); const { data: config } = useSWR("config"); const [open, setOpen] = useState(false); const [isSaving, setIsSaving] = useState(false); const currentFriendlyName = config?.cameras?.[cameraName]?.friendly_name; + const currentWebuiUrl = config?.cameras?.[cameraName]?.webui_url; - const onSave = useCallback( - async (text: string) => { + const formSchema = useMemo( + () => + z.object({ + friendlyName: z.string(), + webuiUrl: z.string().refine( + (val) => { + const trimmed = val.trim(); + if (!trimmed) return true; + try { + new URL(trimmed); + return true; + } catch { + return false; + } + }, + { + message: t("cameraManagement.streams.details.webuiUrlInvalid", { + ns: "views/settings", + }), + }, + ), + }), + [t], + ); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + friendlyName: currentFriendlyName ?? "", + webuiUrl: currentWebuiUrl ?? "", + }, + }); + + // Reset form values from config whenever the dialog is opened. + useEffect(() => { + if (open) { + form.reset({ + friendlyName: currentFriendlyName ?? "", + webuiUrl: currentWebuiUrl ?? "", + }); + } + }, [open, currentFriendlyName, currentWebuiUrl, form]); + + const onSubmit = useCallback( + async (values: CameraDetailsFormValues) => { if (isSaving) return; + + // only send fields the user actually changed + const newFriendly = values.friendlyName.trim() || null; + const newWebui = values.webuiUrl.trim() || null; + const cameraUpdate: Record = {}; + if (newFriendly !== (currentFriendlyName ?? null)) { + cameraUpdate.friendly_name = newFriendly; + } + if (newWebui !== (currentWebuiUrl ?? null)) { + cameraUpdate.webui_url = newWebui; + } + + if (Object.keys(cameraUpdate).length === 0) { + setOpen(false); + return; + } + setIsSaving(true); try { @@ -545,9 +630,7 @@ function CameraFriendlyNameEditor({ requires_restart: 0, config_data: { cameras: { - [cameraName]: { - friendly_name: text.trim() || null, - }, + [cameraName]: cameraUpdate, }, }, }); @@ -573,10 +656,17 @@ function CameraFriendlyNameEditor({ setIsSaving(false); } }, - [cameraName, isSaving, onConfigChanged, t], + [ + cameraName, + currentFriendlyName, + currentWebuiUrl, + isSaving, + onConfigChanged, + t, + ], ); - const renameLabel = t("cameraManagement.streams.friendlyName.rename", { + const editLabel = t("cameraManagement.streams.details.edit", { ns: "views/settings", }); @@ -588,30 +678,107 @@ function CameraFriendlyNameEditor({ variant="ghost" size="icon" className="size-7" - aria-label={renameLabel} + aria-label={editLabel} onClick={() => setOpen(true)} disabled={isSaving} > - {renameLabel} + {editLabel} - + + + + + {t("cameraManagement.streams.details.title", { + ns: "views/settings", + })} + + + {t("cameraManagement.streams.details.description", { + ns: "views/settings", + })} + + + + + ( + + + {t("cameraManagement.streams.details.friendlyNameLabel", { + ns: "views/settings", + })} + + + + + + {t("cameraManagement.streams.details.friendlyNameHelp", { + ns: "views/settings", + })} + + + + )} + /> + ( + + + {t("cameraManagement.streams.details.webuiUrlLabel", { + ns: "views/settings", + })} + + + + + + {t("cameraManagement.streams.details.webuiUrlHelp", { + ns: "views/settings", + })} + + + + )} + /> + + setOpen(false)} + > + {t("button.cancel", { ns: "common" })} + + + {isSaving ? ( + + + {t("button.saving", { ns: "common" })} + + ) : ( + t("button.save", { ns: "common" }) + )} + + + + + + > ); } From 715efac181755c8556b57e3b8f1cc5a03d26c9c0 Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 21 May 2026 07:55:07 -0600 Subject: [PATCH 03/10] Gemini send thought signature --- frigate/genai/plugins/gemini.py | 65 +++++++++++++++++++++++++++++---- frigate/genai/utils.py | 8 ++++ 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/frigate/genai/plugins/gemini.py b/frigate/genai/plugins/gemini.py index 6e4b9283fb..3a0789312f 100644 --- a/frigate/genai/plugins/gemini.py +++ b/frigate/genai/plugins/gemini.py @@ -1,5 +1,7 @@ """Gemini Provider for Frigate AI.""" +import base64 +import binascii import json import logging from typing import Any, AsyncGenerator, Optional @@ -14,6 +16,27 @@ from frigate.genai import GenAIClient, register_genai_provider logger = logging.getLogger(__name__) +def _decode_thought_signature(value: Any) -> Optional[bytes]: + """Decode a base64-encoded thought_signature carried across conversation turns.""" + if not value: + return None + if isinstance(value, bytes): + return value + if isinstance(value, str): + try: + return base64.b64decode(value) + except (binascii.Error, ValueError): + return None + return None + + +def _encode_thought_signature(signature: Optional[bytes]) -> Optional[str]: + """Encode bytes thought_signature as base64 so it survives JSON-friendly transport.""" + if not signature: + return None + return base64.b64encode(signature).decode("ascii") + + def _stats_from_gemini_usage(usage: Any) -> Optional[dict[str, Any]]: """Build a stats dict from a Gemini usage_metadata object.""" prompt_tokens = getattr(usage, "prompt_token_count", None) @@ -169,11 +192,17 @@ class GeminiClient(GenAIClient): if not isinstance(tc_args, dict): tc_args = {} if tc_name: - parts.append( - types.Part.from_function_call( - name=tc_name, args=tc_args - ) + fc_part = types.Part.from_function_call( + name=tc_name, args=tc_args ) + # Thinking-capable Gemini models require the original + # thought_signature to be echoed back on functionCall + # parts after a tool response, or the next request + # fails with INVALID_ARGUMENT. + sig = _decode_thought_signature(tc.get("thought_signature")) + if sig: + fc_part.thought_signature = sig + parts.append(fc_part) if not parts: parts.append(types.Part.from_text(text=" ")) gemini_messages.append(types.Content(role="model", parts=parts)) @@ -310,6 +339,9 @@ class GeminiClient(GenAIClient): "id": part.function_call.name or "", "name": part.function_call.name or "", "arguments": arguments, + "thought_signature": _encode_thought_signature( + getattr(part, "thought_signature", None) + ), } ) @@ -415,11 +447,17 @@ class GeminiClient(GenAIClient): if not isinstance(tc_args, dict): tc_args = {} if tc_name: - parts.append( - types.Part.from_function_call( - name=tc_name, args=tc_args - ) + fc_part = types.Part.from_function_call( + name=tc_name, args=tc_args ) + # Thinking-capable Gemini models require the original + # thought_signature to be echoed back on functionCall + # parts after a tool response, or the next request + # fails with INVALID_ARGUMENT. + sig = _decode_thought_signature(tc.get("thought_signature")) + if sig: + fc_part.thought_signature = sig + parts.append(fc_part) if not parts: parts.append(types.Part.from_text(text=" ")) gemini_messages.append(types.Content(role="model", parts=parts)) @@ -585,6 +623,7 @@ class GeminiClient(GenAIClient): "id": tool_call_id, "name": tool_call_name, "arguments": "", + "thought_signature": None, } # Accumulate arguments @@ -595,6 +634,13 @@ class GeminiClient(GenAIClient): else str(arguments) ) + # Capture latest thought_signature for this call + chunk_sig = getattr(part, "thought_signature", None) + if chunk_sig: + tool_calls_by_index[found_index][ + "thought_signature" + ] = chunk_sig + # Build final message full_content = "".join(content_parts).strip() or None full_reasoning = "".join(reasoning_parts).strip() or None @@ -615,6 +661,9 @@ class GeminiClient(GenAIClient): "id": tc["id"], "name": tc["name"], "arguments": parsed_args, + "thought_signature": _encode_thought_signature( + tc.get("thought_signature") + ), } ) finish_reason = "tool_calls" diff --git a/frigate/genai/utils.py b/frigate/genai/utils.py index 44f982059b..a382647cb9 100644 --- a/frigate/genai/utils.py +++ b/frigate/genai/utils.py @@ -69,6 +69,14 @@ def build_assistant_message_for_conversation( "name": tc["name"], "arguments": json.dumps(tc.get("arguments") or {}), }, + # Gemini-only: opaque signature that must be echoed back on + # the same functionCall part in the next turn. Other providers + # do not set or read this. + **( + {"thought_signature": tc["thought_signature"]} + if tc.get("thought_signature") + else {} + ), } for tc in tool_calls_raw ] From 5320958120bb365e565704aa846c4c5d8c79875c Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Thu, 21 May 2026 09:26:13 -0600 Subject: [PATCH 04/10] Update docs --- docs/docs/configuration/genai/config.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/docs/configuration/genai/config.md b/docs/docs/configuration/genai/config.md index a512943c90..9f396d3ccc 100644 --- a/docs/docs/configuration/genai/config.md +++ b/docs/docs/configuration/genai/config.md @@ -49,15 +49,14 @@ You should have at least 8 GB of RAM available (or VRAM if running on GPU) to ru ### Model Types: Instruct vs Thinking -Most vision-language models are available as **instruct** models, which are fine-tuned to follow instructions and respond concisely to prompts. However, some models (such as certain Qwen-VL or minigpt variants) offer both **instruct** and **thinking** versions. +Vision-language models come in **instruct** variants (fine-tuned to follow instructions and respond concisely), **thinking** variants (fine-tuned for free-form, speculative reasoning), and **hybrid** variants that support both modes per request. Most modern vision-language models are hybrid. -- **Instruct models** are always recommended for use with Frigate. These models generate direct, relevant, actionable descriptions that best fit Frigate's object and event summary use case. -- **Reasoning / Thinking models** are fine-tuned for more free-form, open-ended, and speculative outputs, which are typically not concise and may not provide the practical summaries Frigate expects. For this reason, Frigate does **not** recommend or support using thinking models. +Frigate manages reasoning per task automatically: -Some models are labeled as **hybrid** (capable of both thinking and instruct tasks). In these cases, it is recommended to disable reasoning / thinking, which is generally model specific (see your models documentation). +- **Description tasks** (object descriptions, review descriptions, review summaries) are synthesis-only and benefit from concise, direct output, so Frigate disables thinking for these calls when the model exposes a per-request toggle. +- **Chat** lets you toggle thinking on or off from the composer when the configured model supports it. -**Recommendation:** -Always select the `-instruct` or documented instruct/tagged variant of any model you use in your Frigate configuration. If in doubt, refer to your model provider's documentation or model library for guidance on the correct model variant to use. +You can use a pure instruct, hybrid, or thinking-capable model with Frigate — no extra configuration is required to disable thinking for descriptions. ### llama.cpp From 88f7a3e9ce367ff5482af4ffdce7336ead449ba7 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 21 May 2026 15:11:42 -0500 Subject: [PATCH 05/10] copy face and lpr configs from source camera to replay camera --- frigate/debug_replay.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frigate/debug_replay.py b/frigate/debug_replay.py index b137e24b93..ea95e153c1 100644 --- a/frigate/debug_replay.py +++ b/frigate/debug_replay.py @@ -238,6 +238,10 @@ class DebugReplayManager: zone_dump.setdefault("coordinates", zone_config.coordinates) zones_dict[zone_name] = zone_dump + # Extract LPR and face recognition configs + lpr_dict = source_config.lpr.model_dump() + face_recognition_dict = source_config.face_recognition.model_dump() + # Extract motion config (exclude runtime fields) motion_dict = {} if source_config.motion is not None: @@ -287,8 +291,8 @@ class DebugReplayManager: }, "birdseye": {"enabled": False}, "audio": {"enabled": False}, - "lpr": {"enabled": False}, - "face_recognition": {"enabled": False}, + "lpr": lpr_dict, + "face_recognition": face_recognition_dict, } def _cleanup_db(self, camera_name: str) -> None: From 7c03047b10035e51f32957ebf463655e5e8e5e54 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 21 May 2026 17:10:32 -0500 Subject: [PATCH 06/10] unlink shm frames when camera is removed --- frigate/camera/maintainer.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/frigate/camera/maintainer.py b/frigate/camera/maintainer.py index c4ddc51e89..5fd4dc4aad 100644 --- a/frigate/camera/maintainer.py +++ b/frigate/camera/maintainer.py @@ -202,6 +202,25 @@ class CameraMaintainer(threading.Thread): capture_process.terminate() capture_process.join() + def __unlink_camera_frame_slots(self, camera: str) -> None: + """Drop the camera's per-frame YUV SHM segments from this + process's frame_manager and unlink them at the OS level. + + Safe to call after the camera's capture/processor subprocesses + have been joined — they no longer hold mappings, so unlink frees + the segments immediately. Other long-lived processes that opened + these slots will continue using their existing mappings until + they call frame_manager.get with a shape that no longer fits + (the get path drops and reopens stale refs). + """ + prefix = f"{camera}_frame" + names = [n for n in list(self.frame_manager.shm_store) if n.startswith(prefix)] + for name in names: + try: + self.frame_manager.delete(name) + except Exception as exc: + logger.debug("Could not unlink SHM %s: %s", name, exc) + def __stop_camera_process(self, camera: str) -> None: camera_process = self.camera_processes.get(camera) if camera_process is not None: @@ -253,6 +272,7 @@ class CameraMaintainer(threading.Thread): for camera in updated_cameras: self.__stop_camera_capture_process(camera) self.__stop_camera_process(camera) + self.__unlink_camera_frame_slots(camera) self.capture_processes.pop(camera, None) self.camera_processes.pop(camera, None) self.camera_stop_events.pop(camera, None) From 0495d995f50b376b41d990d49fe618e7f0969f65 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 21 May 2026 17:11:55 -0500 Subject: [PATCH 07/10] drop stale shm cache refs when cached segment is too small for requested shape --- frigate/util/image.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/frigate/util/image.py b/frigate/util/image.py index 2d2133c6b8..4054c54182 100644 --- a/frigate/util/image.py +++ b/frigate/util/image.py @@ -1089,10 +1089,32 @@ class SharedMemoryFrameManager(FrameManager): def get(self, name: str, shape) -> Optional[np.ndarray]: try: - if name in self.shm_store: - shm = self.shm_store[name] - else: + required = int(np.prod(shape)) + shm = self.shm_store.get(name) + if shm is not None and shm.size < required: + # Cached reference points at an older, smaller segment + # that was unlinked and recreated at a larger size in a + # camera add/remove cycle. Drop the stale ref so we + # reopen the current segment. + try: + shm.close() + except Exception: + pass + self.shm_store.pop(name, None) + shm = None + if shm is None: shm = UntrackedSharedMemory(name=name) + if shm.size < required: + # Transient mid-recreate state: the OS-level segment + # is still at the previous (smaller) size because the + # maintainer hasn't allocated the new one yet. Don't + # cache it (so the next call re-opens once the + # maintainer has caught up) and skip this frame. + try: + shm.close() + except Exception: + pass + return None self.shm_store[name] = shm return np.ndarray(shape, dtype=np.uint8, buffer=shm.buf) except FileNotFoundError: From 39f4bd61c87372045eff99afbf014c7e7184f273 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 21 May 2026 17:12:07 -0500 Subject: [PATCH 08/10] skip new-object frame cache write when current_frame is unavailable --- frigate/camera/state.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/frigate/camera/state.py b/frigate/camera/state.py index 8d0b586022..dc44e7a88e 100644 --- a/frigate/camera/state.py +++ b/frigate/camera/state.py @@ -332,14 +332,18 @@ class CameraState: current_detections[id], ) - # add initial frame to frame cache - logger.debug( - f"{self.name}: New object, adding {frame_time} to frame cache for {id}" - ) - self.frame_cache[frame_time] = { - "frame": np.copy(current_frame), # type: ignore[arg-type] - "object_id": id, - } + # Skip caching when the frame buffer isn't readable — e.g. + # frame_manager.get returned None because the SHM segment was + # unlinked or hasn't been recreated yet during a camera + # add/remove cycle. + if current_frame is not None: + logger.debug( + f"{self.name}: New object, adding {frame_time} to frame cache for {id}" + ) + self.frame_cache[frame_time] = { + "frame": np.copy(current_frame), + "object_id": id, + } # save initial thumbnail data and best object thumbnail_data = { From 85626c9476502ca2702ccf46b8aad2b3f03198d7 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 21 May 2026 17:13:04 -0500 Subject: [PATCH 09/10] add tests --- frigate/test/test_camera_maintainer.py | 79 ++++++++++ .../test/test_shared_memory_frame_manager.py | 137 ++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 frigate/test/test_camera_maintainer.py create mode 100644 frigate/test/test_shared_memory_frame_manager.py diff --git a/frigate/test/test_camera_maintainer.py b/frigate/test/test_camera_maintainer.py new file mode 100644 index 0000000000..c03d965784 --- /dev/null +++ b/frigate/test/test_camera_maintainer.py @@ -0,0 +1,79 @@ +"""Tests for CameraMaintainer SHM cleanup on camera remove. + +Regression coverage for the case where a camera is removed and then a +new camera is added with the same name. Without unlinking the per-frame +YUV SHM slots, the maintainer's frame_manager.create call hits +FileExistsError and falls back to reopening the existing segment at the +*old* size, which the new ffmpeg process then writes mismatched-size +frames into. +""" + +import unittest +from unittest.mock import MagicMock, patch + +from frigate.camera.maintainer import CameraMaintainer + + +class TestMaintainerUnlinkFrameSlotsOnRemove(unittest.TestCase): + def _make_maintainer(self) -> CameraMaintainer: + """Build a maintainer without invoking __init__ (avoids needing real + FrigateConfig, queues, multiprocessing manager, etc.). We're only + exercising the SHM-cleanup helper, so the surrounding init is + irrelevant.""" + maintainer = CameraMaintainer.__new__(CameraMaintainer) + maintainer.frame_manager = MagicMock() + return maintainer + + def test_unlinks_only_segments_with_matching_prefix(self) -> None: + maintainer = self._make_maintainer() + maintainer.frame_manager.shm_store = { + "front_frame0": object(), + "front_frame1": object(), + "front_frame2": object(), + # Different camera; must not be touched. + "side_frame0": object(), + # Detector input/output buffers are sized by the model and + # cached by the long-lived DetectorRunner — must not be + # touched even when their owning camera is removed. + "front": object(), + "out-front": object(), + } + + # __name-mangled access from outside the class. + maintainer._CameraMaintainer__unlink_camera_frame_slots("front") + + deleted = [c.args[0] for c in maintainer.frame_manager.delete.call_args_list] + self.assertEqual( + sorted(deleted), + ["front_frame0", "front_frame1", "front_frame2"], + ) + + def test_handles_camera_with_no_slots(self) -> None: + """Cameras that were removed before any frame slot was ever + created (e.g. cancelled during preparing_clip) should be a no-op.""" + maintainer = self._make_maintainer() + maintainer.frame_manager.shm_store = {"other_frame0": object()} + + maintainer._CameraMaintainer__unlink_camera_frame_slots("front") + + maintainer.frame_manager.delete.assert_not_called() + + def test_swallows_delete_errors(self) -> None: + """Unlink failures shouldn't abort the remove loop — best-effort.""" + maintainer = self._make_maintainer() + maintainer.frame_manager.shm_store = { + "front_frame0": object(), + "front_frame1": object(), + } + maintainer.frame_manager.delete.side_effect = OSError("simulated") + + # Both slots are attempted; the OSError on the first doesn't + # prevent the second from being tried. + with patch("frigate.camera.maintainer.logger"): + maintainer._CameraMaintainer__unlink_camera_frame_slots("front") + + self.assertEqual(maintainer.frame_manager.delete.call_count, 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/frigate/test/test_shared_memory_frame_manager.py b/frigate/test/test_shared_memory_frame_manager.py new file mode 100644 index 0000000000..352a0bc619 --- /dev/null +++ b/frigate/test/test_shared_memory_frame_manager.py @@ -0,0 +1,137 @@ +"""Tests for SharedMemoryFrameManager cache invalidation. + +Covers the case where a SHM segment is unlinked and recreated at a +different size across a camera add/remove cycle while a long-lived +in-process cache (e.g. TrackedObjectProcessor) still holds a ref to +the old, smaller segment. +""" + +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +import numpy as np + +from frigate.util.image import SharedMemoryFrameManager + + +def _fake_shm(size: int) -> SimpleNamespace: + """A minimal stand-in for UntrackedSharedMemory with .size and .buf.""" + return SimpleNamespace(size=size, buf=bytearray(size), close=lambda: None) + + +class TestSharedMemoryFrameManagerGet(unittest.TestCase): + def test_get_reopens_when_cached_segment_is_smaller_than_shape(self) -> None: + """A cached ref to an older smaller segment must be dropped and the + current (larger) segment reopened. Without this, np.ndarray would + raise "buffer is too small for requested array" when the in-memory + cache pointed at an old SHM after a same-name resize.""" + manager = SharedMemoryFrameManager() + + small = _fake_shm(size=100) + large = _fake_shm(size=10_000) + manager.shm_store["cam_frame0"] = small + + with patch("frigate.util.image.UntrackedSharedMemory", return_value=large): + arr = manager.get("cam_frame0", (50, 50)) + + self.assertIsNotNone(arr) + # Numpy now reports against the large segment, not the small one. + self.assertEqual(arr.shape, (50, 50)) + self.assertIs(manager.shm_store["cam_frame0"], large) + + def test_get_keeps_cached_segment_when_size_sufficient(self) -> None: + """Don't pay the reopen cost when the cached ref is fine.""" + manager = SharedMemoryFrameManager() + + cached = _fake_shm(size=10_000) + manager.shm_store["cam_frame0"] = cached + + with patch("frigate.util.image.UntrackedSharedMemory") as untracked_shm_cls: + arr = manager.get("cam_frame0", (50, 50)) + untracked_shm_cls.assert_not_called() + + self.assertIsNotNone(arr) + self.assertIs(manager.shm_store["cam_frame0"], cached) + + def test_get_opens_fresh_when_no_cache_entry(self) -> None: + manager = SharedMemoryFrameManager() + fresh = _fake_shm(size=10_000) + + with patch("frigate.util.image.UntrackedSharedMemory", return_value=fresh): + arr = manager.get("cam_frame0", (50, 50)) + + self.assertIsNotNone(arr) + self.assertIs(manager.shm_store["cam_frame0"], fresh) + + def test_get_returns_none_when_segment_missing(self) -> None: + manager = SharedMemoryFrameManager() + + with patch( + "frigate.util.image.UntrackedSharedMemory", + side_effect=FileNotFoundError, + ): + arr = manager.get("cam_frame0", (50, 50)) + + self.assertIsNone(arr) + + def test_get_returns_none_when_reopened_segment_is_still_too_small(self) -> None: + """Race during a same-name SHM recreate: cache is stale, we reopen + by name, but the maintainer hasn't allocated the new segment yet — + the reopened ref is also too small. Skip the frame (return None) + rather than crash on np.ndarray.""" + manager = SharedMemoryFrameManager() + + small_cached = _fake_shm(size=100) + still_small_after_reopen = _fake_shm(size=100) + manager.shm_store["cam_frame0"] = small_cached + + with patch( + "frigate.util.image.UntrackedSharedMemory", + return_value=still_small_after_reopen, + ): + arr = manager.get("cam_frame0", (50, 50)) + + self.assertIsNone(arr) + # Don't cache the too-small reopened ref — next call will re-open + # once the maintainer has finished recreating the segment. + self.assertNotIn("cam_frame0", manager.shm_store) + + def test_get_handles_n_dimensional_shape(self) -> None: + """np.prod must be used (not raw multiplication) for tuple shapes.""" + manager = SharedMemoryFrameManager() + # YUV-shaped frame: (height * 3/2, width) for 1920x1080 = 3,110,400 + big_enough = _fake_shm(size=3_110_400) + manager.shm_store["cam_frame0"] = big_enough + + with patch("frigate.util.image.UntrackedSharedMemory") as untracked_shm_cls: + arr = manager.get("cam_frame0", (1620, 1920)) + untracked_shm_cls.assert_not_called() + + self.assertIsNotNone(arr) + self.assertEqual(arr.shape, (1620, 1920)) + + +class TestSharedMemoryFrameManagerGetRecreatesLargerSegment(unittest.TestCase): + """End-to-end-style: simulates the full unlink-and-recreate cycle.""" + + def test_segment_grows_then_get_succeeds(self) -> None: + manager = SharedMemoryFrameManager() + + # Phase 1: existing camera at 320x240 YUV — 320 * 240 * 1.5 = 115_200 + small = _fake_shm(size=115_200) + manager.shm_store["cam_frame0"] = small + arr_small = np.ndarray((360, 320), dtype=np.uint8, buffer=small.buf) + self.assertEqual(arr_small.shape, (360, 320)) + + # Phase 2: restart at 1920x1080 — new SHM segment, larger size. + large = _fake_shm(size=3_110_400) + with patch("frigate.util.image.UntrackedSharedMemory", return_value=large): + arr_large = manager.get("cam_frame0", (1620, 1920)) + + self.assertIsNotNone(arr_large) + self.assertEqual(arr_large.shape, (1620, 1920)) + + +if __name__ == "__main__": + unittest.main() From df829dc395695997dd5e72b7820b33f3bac76fb6 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 21 May 2026 17:15:08 -0500 Subject: [PATCH 10/10] add guard --- frigate/ptz/autotrack.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frigate/ptz/autotrack.py b/frigate/ptz/autotrack.py index 1a45f619c2..fb76f6718d 100644 --- a/frigate/ptz/autotrack.py +++ b/frigate/ptz/autotrack.py @@ -1331,6 +1331,8 @@ class PtzAutoTracker: return self.tracked_object[camera]["region"] def autotrack_object(self, camera: str, obj: TrackedObject): + if camera not in self.config.cameras: + return camera_config = self.config.cameras[camera] if camera_config.onvif.autotracking.enabled:
+ {t("cameraManagement.streams.details.friendlyNameHelp", { + ns: "views/settings", + })} +
+ {t("cameraManagement.streams.details.webuiUrlHelp", { + ns: "views/settings", + })} +