Convert sensitive wall clock usages to monotonic (#24864)
CI / AMD64 Build (push) Waiting to run
CI / AMD64 Smoke Test (push) Blocked by required conditions
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions

This commit is contained in:
Nicolas Mowen
2026-11-02 16:33:55 -06:00
committed by GitHub
parent 9379f81edf
commit 2b4397bcba
12 changed files with 140 additions and 45 deletions
+9 -6
View File
@@ -7,6 +7,7 @@ import logging
import os import os
import queue import queue
import threading import threading
import time
from collections.abc import Callable, Iterator from collections.abc import Callable, Iterator
from dataclasses import dataclass from dataclasses import dataclass
from multiprocessing.synchronize import Event as MpEvent from multiprocessing.synchronize import Event as MpEvent
@@ -185,11 +186,13 @@ class WebPushClient(Communicator):
} }
self.suspension_broadcaster: Callable[[str, Any, bool], None] | None = None self.suspension_broadcaster: Callable[[str, Any, bool], None] | None = None
self.config_lock = threading.Lock() self.config_lock = threading.Lock()
# monotonic times, so a backward wall clock step can't hold every
# notification in cooldown. -inf means none has been sent yet
self.last_camera_notification_time: dict[str, float] = { self.last_camera_notification_time: dict[str, float] = {
c.name: 0 # type: ignore[misc] c.name: float("-inf") # type: ignore[misc]
for c in self.config.cameras.values() for c in self.config.cameras.values()
} }
self.last_notification_time: float = 0 self.last_notification_time: float = float("-inf")
self.user_cameras: dict[str, set[str]] = {} self.user_cameras: dict[str, set[str]] = {}
self.notification_queue: queue.Queue[PushNotification] = queue.Queue() self.notification_queue: queue.Queue[PushNotification] = queue.Queue()
self.notification_thread: threading.Thread | None = None self.notification_thread: threading.Thread | None = None
@@ -344,7 +347,7 @@ class WebPushClient(Communicator):
if "add" in updates: if "add" in updates:
for camera in updates["add"]: for camera in updates["add"]:
self.suspended_cameras[camera] = 0 self.suspended_cameras[camera] = 0
self.last_camera_notification_time[camera] = 0 self.last_camera_notification_time[camera] = float("-inf")
self._refresh_user_cameras() self._refresh_user_cameras()
@@ -499,7 +502,7 @@ class WebPushClient(Communicator):
return camera in allowed return camera in allowed
def _within_cooldown(self, camera: str) -> bool: def _within_cooldown(self, camera: str) -> bool:
now = datetime.datetime.now().timestamp() now = time.monotonic()
if now - self.last_notification_time < self.config.notifications.cooldown: if now - self.last_notification_time < self.config.notifications.cooldown:
logger.debug( logger.debug(
f"Skipping notification for {camera} - in global cooldown period" f"Skipping notification for {camera} - in global cooldown period"
@@ -544,7 +547,7 @@ class WebPushClient(Communicator):
camera_name: str = getattr( camera_name: str = getattr(
self.config.cameras[camera], "friendly_name", None self.config.cameras[camera], "friendly_name", None
) or titlecase(camera.replace("_", " ")) ) or titlecase(camera.replace("_", " "))
current_time = datetime.datetime.now().timestamp() current_time = time.monotonic()
if self._within_cooldown(camera): if self._within_cooldown(camera):
return return
@@ -657,7 +660,7 @@ class WebPushClient(Communicator):
camera_name: str = getattr( camera_name: str = getattr(
self.config.cameras[camera], "friendly_name", None self.config.cameras[camera], "friendly_name", None
) or titlecase(camera.replace("_", " ")) ) or titlecase(camera.replace("_", " "))
current_time = datetime.datetime.now().timestamp() current_time = time.monotonic()
if self._within_cooldown(camera): if self._within_cooldown(camera):
return return
@@ -3,6 +3,7 @@
import datetime import datetime
import logging import logging
import os import os
import time
from typing import Any from typing import Any
import cv2 import cv2
@@ -67,7 +68,8 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
else: else:
self.inference_speed = None self.inference_speed = None
self.last_run = datetime.datetime.now().timestamp() # monotonic so a backward wall clock step can't pause classification
self.last_run = time.monotonic()
self.__build_detector() self.__build_detector()
def __build_detector(self) -> None: def __build_detector(self) -> None:
@@ -215,11 +217,12 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
should_run = False should_run = False
now = datetime.datetime.now().timestamp() now = datetime.datetime.now().timestamp()
mono_now = time.monotonic()
if ( if (
self.model_config.state_config.interval self.model_config.state_config.interval
and now > self.last_run + self.model_config.state_config.interval and mono_now > self.last_run + self.model_config.state_config.interval
): ):
self.last_run = now self.last_run = mono_now
should_run = True should_run = True
if ( if (
@@ -228,8 +231,8 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
and any([box_overlaps(crop, mb) for mb in frame_data.get("motion", [])]) and any([box_overlaps(crop, mb) for mb in frame_data.get("motion", [])])
): ):
# classification should run at most once per second # classification should run at most once per second
if now > self.last_run + 1: if mono_now > self.last_run + 1:
self.last_run = now self.last_run = mono_now
should_run = True should_run = True
# Shortcut: always run if we have a pending state verification to complete # Shortcut: always run if we have a pending state verification to complete
@@ -237,9 +240,9 @@ class CustomStateClassificationProcessor(DeferredRealtimeProcessorApi):
not should_run not should_run
and camera in self.state_history and camera in self.state_history
and self.state_history[camera]["pending_state"] is not None and self.state_history[camera]["pending_state"] is not None
and now > self.last_run + 0.5 and mono_now > self.last_run + 0.5
): ):
self.last_run = now self.last_run = mono_now
should_run = True should_run = True
logger.debug( logger.debug(
f"Running verification check for pending state: {self.state_history[camera]['pending_state']} ({self.state_history[camera]['consecutive_count']}/3)" f"Running verification check for pending state: {self.state_history[camera]['pending_state']} ({self.state_history[camera]['consecutive_count']}/3)"
+7 -6
View File
@@ -80,11 +80,12 @@ class VLMWatchRunner(threading.Thread):
self._broadcast_status() self._broadcast_status()
self.conversation = [{"role": "system", "content": self._build_system_prompt()}] self.conversation = [{"role": "system", "content": self._build_system_prompt()}]
max_end_time = self.job.start_time + self.job.max_duration_minutes * 60 # monotonic so a wall clock step can't end the job early or let it overrun
max_end_time = time.monotonic() + self.job.max_duration_minutes * 60
try: try:
while not self.cancel_event.is_set(): while not self.cancel_event.is_set():
if time.time() > max_end_time: if time.monotonic() > max_end_time:
logger.debug( logger.debug(
"VLM watch job %s timed out after %d minutes", "VLM watch job %s timed out after %d minutes",
self.job.id, self.job.id,
@@ -236,18 +237,18 @@ class VLMWatchRunner(threading.Thread):
queue backup, but the loop only exits once a match has been seen queue backup, but the loop only exits once a match has been seen
*and* the cooldown period has elapsed. *and* the cooldown period has elapsed.
""" """
now = time.time() now = time.monotonic()
deadline = now + max_wait deadline = now + max_wait
use_cooldown = not self.job.zones use_cooldown = not self.job.zones
earliest_wake = now + _DETECTION_COOLDOWN_WITHOUT_ZONE if use_cooldown else 0 earliest_wake = now + _DETECTION_COOLDOWN_WITHOUT_ZONE if use_cooldown else 0
triggered = False triggered = False
while not self.cancel_event.is_set(): while not self.cancel_event.is_set():
remaining = deadline - time.time() remaining = deadline - time.monotonic()
if remaining <= 0: if remaining <= 0:
break break
if triggered and time.time() >= earliest_wake: if triggered and time.monotonic() >= earliest_wake:
break break
result = self.detection_subscriber.check_for_update( result = self.detection_subscriber.check_for_update(
@@ -287,7 +288,7 @@ class VLMWatchRunner(threading.Thread):
"VLM watch job %s: detection match on %s, draining for %.0fs", "VLM watch job %s: detection match on %s, draining for %.0fs",
self.job.id, self.job.id,
self.job.camera, self.job.camera,
max(0, earliest_wake - time.time()), max(0, earliest_wake - time.monotonic()),
) )
triggered = True triggered = True
+3 -3
View File
@@ -1,4 +1,3 @@
import datetime
import logging import logging
import queue import queue
import threading import threading
@@ -169,9 +168,10 @@ class DetectorRunner(FrigateProcess):
logger.warning(f"Failed to get frame {connection_id} from SHM") logger.warning(f"Failed to get frame {connection_id} from SHM")
continue continue
# detect and send the output # detect and send the output. start_time is monotonic so the watchdog
self.start_time.value = datetime.datetime.now().timestamp() # doesn't mistake a wall clock step for a stuck detector
mono_start = time.monotonic() mono_start = time.monotonic()
self.start_time.value = mono_start
detections = object_detector.detect_raw(input_frame) detections = object_detector.detect_raw(input_frame)
duration = time.monotonic() - mono_start duration = time.monotonic() - mono_start
+5 -4
View File
@@ -1,6 +1,5 @@
"""Handle outputting birdseye frames via jsmpeg and go2rtc.""" """Handle outputting birdseye frames via jsmpeg and go2rtc."""
import datetime
import glob import glob
import logging import logging
import math import math
@@ -8,6 +7,7 @@ import os
import queue import queue
import subprocess as sp import subprocess as sp
import threading import threading
import time
import traceback import traceback
from dataclasses import dataclass from dataclasses import dataclass
from multiprocessing.synchronize import Event as MpEvent from multiprocessing.synchronize import Event as MpEvent
@@ -492,7 +492,7 @@ class BirdsEyeFrameManager:
max_cameras = self.config.birdseye.layout.max_cameras max_cameras = self.config.birdseye.layout.max_cameras
max_camera_refresh = False max_camera_refresh = False
if max_cameras: if max_cameras:
now = datetime.datetime.now().timestamp() now = time.monotonic()
if ( if (
len(self.active_cameras) == max_cameras len(self.active_cameras) == max_cameras
@@ -800,7 +800,8 @@ class BirdsEyeFrameManager:
camera_state["live_active"] = self.camera_live_active(modes, activity) camera_state["live_active"] = self.camera_live_active(modes, activity)
now = datetime.datetime.now().timestamp() # monotonic so a backward wall clock step can't freeze the output
now = time.monotonic()
# limit output to 10 fps # limit output to 10 fps
if not force_update and (now - self.last_output_time) < 1 / 10: if not force_update and (now - self.last_output_time) < 1 / 10:
@@ -945,7 +946,7 @@ class Birdseye:
coordinates = self.birdseye_manager.get_camera_coordinates() coordinates = self.birdseye_manager.get_camera_coordinates()
self.requestor.send_data(UPDATE_BIRDSEYE_LAYOUT, coordinates) self.requestor.send_data(UPDATE_BIRDSEYE_LAYOUT, coordinates)
if self._idle_interval: if self._idle_interval:
now = datetime.datetime.now().timestamp() now = time.monotonic()
is_idle = len(self.birdseye_manager.camera_layout) == 0 is_idle = len(self.birdseye_manager.camera_layout) == 0
if ( if (
is_idle is_idle
+9 -1
View File
@@ -106,6 +106,14 @@ def stats_init(
return stats_tracking return stats_tracking
def _monotonic_to_wall(timestamp: float) -> float:
"""Convert a monotonic timestamp to wall clock time, keeping 0 as unset."""
if timestamp <= 0:
return 0.0
return time.time() - (time.monotonic() - timestamp)
def get_detector_stats( def get_detector_stats(
stats_tracking: StatsTrackingTypes, stats_tracking: StatsTrackingTypes,
) -> dict[str, dict[str, Any]]: ) -> dict[str, dict[str, Any]]:
@@ -128,7 +136,7 @@ def get_detector_stats(
"inference_speed": round(detector.avg_inference_speed.value * 1000, 2), # type: ignore[attr-defined] "inference_speed": round(detector.avg_inference_speed.value * 1000, 2), # type: ignore[attr-defined]
# issue https://github.com/python/typeshed/issues/8799 # issue https://github.com/python/typeshed/issues/8799
# from mypy 0.981 onwards # from mypy 0.981 onwards
"detection_start": detector.detection_start.value, # type: ignore[attr-defined] "detection_start": _monotonic_to_wall(detector.detection_start.value), # type: ignore[attr-defined]
# issue https://github.com/python/typeshed/issues/8799 # issue https://github.com/python/typeshed/issues/8799
# from mypy 0.981 onwards # from mypy 0.981 onwards
"pid": pid, "pid": pid,
+62
View File
@@ -0,0 +1,62 @@
"""Tests that watchdogs are not fooled by wall clock steps, such as an NTP sync at boot."""
import time
import unittest
from unittest.mock import MagicMock, patch
from frigate.stats.util import _monotonic_to_wall
from frigate.video.ffmpeg import CameraWatchdog
from frigate.watchdog import FrigateWatchdog
class TestDetectorWatchdogClock(unittest.TestCase):
def test_inference_that_just_started_is_not_stuck(self):
detector = MagicMock()
detector.detection_start.value = time.monotonic()
detector.detect_process.is_alive.return_value = True
watchdog = FrigateWatchdog({"ov": detector}, MagicMock())
with patch("frigate.watchdog.raise_notice") as raise_notice:
watchdog._check_detectors()
detector.start_or_restart.assert_not_called()
raise_notice.assert_not_called()
class TestDetectionStartStats(unittest.TestCase):
def test_idle_detector_reports_zero(self):
self.assertEqual(_monotonic_to_wall(0.0), 0.0)
def test_running_inference_reports_wall_clock_start(self):
start = _monotonic_to_wall(time.monotonic() - 5)
self.assertAlmostEqual(start, time.time() - 5, delta=0.5)
class TestCameraNoFramesCheck(unittest.TestCase):
def setUp(self):
self.watchdog = CameraWatchdog.__new__(CameraWatchdog)
self.watchdog.capture_thread = MagicMock()
self.watchdog.capture_thread.current_frame.value = 1000.0
self.watchdog.last_frame_value = 0.0
self.watchdog.last_frame_change = 0.0
def test_new_frame_read_is_not_stalled(self):
self.assertFalse(self.watchdog._no_frames_received(50.0))
def test_wall_clock_step_is_not_stalled(self):
self.watchdog._no_frames_received(50.0)
# the capture thread stamps frames with wall clock time, which just
# jumped 44 minutes forward
self.watchdog.capture_thread.current_frame.value = 1000.0 + 2640
self.assertFalse(self.watchdog._no_frames_received(51.0))
def test_unchanged_frame_for_timeout_is_stalled(self):
self.watchdog._no_frames_received(50.0)
self.assertFalse(self.watchdog._no_frames_received(70.0))
self.assertTrue(self.watchdog._no_frames_received(70.5))
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -18,10 +18,10 @@ class TestDetectorStuckNotice(unittest.TestCase):
watchdog = FrigateWatchdog({"ov": detector}, MagicMock()) watchdog = FrigateWatchdog({"ov": detector}, MagicMock())
with ( with (
patch("frigate.watchdog.datetime") as mock_datetime, patch("frigate.watchdog.time") as mock_time,
patch("frigate.watchdog.raise_notice") as raise_notice, patch("frigate.watchdog.raise_notice") as raise_notice,
): ):
mock_datetime.datetime.now.return_value.timestamp.return_value = 100.0 mock_time.monotonic.return_value = 100.0
watchdog._check_detectors() watchdog._check_detectors()
detector.start_or_restart.assert_called_once() detector.start_or_restart.assert_called_once()
+3 -3
View File
@@ -429,8 +429,8 @@ class FileLock:
try: try:
self._fd = os.open(self.lock_path, os.O_CREAT | os.O_RDWR) self._fd = os.open(self.lock_path, os.O_CREAT | os.O_RDWR)
start_time = time.time() start_time = time.monotonic()
while time.time() - start_time < timeout: while time.monotonic() - start_time < timeout:
try: try:
fcntl.flock(self._fd, fcntl.LOCK_EX | fcntl.LOCK_NB) fcntl.flock(self._fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
self._acquired = True self._acquired = True
@@ -438,7 +438,7 @@ class FileLock:
return True return True
except OSError: except OSError:
# Lock is held by another process # Lock is held by another process
if time.time() - start_time >= timeout: if time.monotonic() - start_time >= timeout:
logger.warning(f"Timeout waiting for lock: {self.lock_path}") logger.warning(f"Timeout waiting for lock: {self.lock_path}")
os.close(self._fd) os.close(self._fd)
self._fd = None self._fd = None
+2 -2
View File
@@ -292,10 +292,10 @@ def wait_for_conversion_completion(
Returns: Returns:
True if RKNN model appears, False if timeout True if RKNN model appears, False if timeout
""" """
start_time = time.time() start_time = time.monotonic()
lock = FileLock(lock_file_path, stale_timeout=600) lock = FileLock(lock_file_path, stale_timeout=600)
while time.time() - start_time < timeout: while time.monotonic() - start_time < timeout:
# Check if RKNN model appeared # Check if RKNN model appeared
if rknn_path.exists(): if rknn_path.exists():
logger.info(f"RKNN model appeared: {rknn_path}") logger.info(f"RKNN model appeared: {rknn_path}")
+25 -8
View File
@@ -171,6 +171,10 @@ class CameraWatchdog(threading.Thread):
self.stop_event = stop_event self.stop_event = stop_event
self.sleeptime = self.config.ffmpeg.retry_interval self.sleeptime = self.config.ffmpeg.retry_interval
self.reconnect_timestamps = deque() self.reconnect_timestamps = deque()
# current_frame is wall clock because it doubles as the frame time, so
# track the monotonic time it last changed for the no frames check
self.last_frame_value = 0.0
self.last_frame_change = 0.0
self.stalls = stalls self.stalls = stalls
self.reconnects = reconnects self.reconnects = reconnects
self.detection_frame = detection_frame self.detection_frame = detection_frame
@@ -293,6 +297,17 @@ class CameraWatchdog(threading.Thread):
return None return None
def _no_frames_received(self, now: float, timeout: float = 20) -> bool:
"""Return True if the capture thread hasn't started a new frame read in timeout seconds."""
frame_value = self.capture_thread.current_frame.value
if frame_value != self.last_frame_value:
self.last_frame_value = frame_value
self.last_frame_change = now
return False
return now - self.last_frame_change > timeout
def _recorded_streams(self, roles: list[Any]) -> list[str]: def _recorded_streams(self, roles: list[Any]) -> list[str]:
"""Record stream types the given roles cover that are currently recording.""" """Record stream types the given roles cover that are currently recording."""
return [ return [
@@ -360,7 +375,7 @@ class CameraWatchdog(threading.Thread):
self.record_enable_time = datetime.now().astimezone(UTC) self.record_enable_time = datetime.now().astimezone(UTC)
time.sleep(self.sleeptime) time.sleep(self.sleeptime)
last_restart_time = datetime.now().timestamp() last_restart_time = time.monotonic()
# 1 second watchdog loop # 1 second watchdog loop
while not self.stop_event.wait(1): while not self.stop_event.wait(1):
@@ -376,7 +391,7 @@ class CameraWatchdog(threading.Thread):
self.start_all_ffmpeg() self.start_all_ffmpeg()
self._reset_segment_times() self._reset_segment_times()
self.record_enable_time = datetime.now().astimezone(UTC) self.record_enable_time = datetime.now().astimezone(UTC)
last_restart_time = datetime.now().timestamp() last_restart_time = time.monotonic()
continue continue
enabled = self.config.enabled enabled = self.config.enabled
@@ -394,7 +409,7 @@ class CameraWatchdog(threading.Thread):
self.record_enable_time = None self.record_enable_time = None
# update camera status # update camera status
now = datetime.now().timestamp() now = time.monotonic()
self.detect_status.send("disabled", now) self.detect_status.send("disabled", now)
self.record_status[STREAM_TYPE_MAIN].send("disabled", now) self.record_status[STREAM_TYPE_MAIN].send("disabled", now)
# cameras without a sub stream never get a record_sub topic # cameras without a sub stream never get a record_sub topic
@@ -413,7 +428,7 @@ class CameraWatchdog(threading.Thread):
self.start_all_ffmpeg() self.start_all_ffmpeg()
self._reset_segment_times() self._reset_segment_times()
self.record_enable_time = datetime.now().astimezone(UTC) self.record_enable_time = datetime.now().astimezone(UTC)
last_restart_time = datetime.now().timestamp() last_restart_time = time.monotonic()
self.was_record_enabled_in_config = record_enabled_in_config self.was_record_enabled_in_config = record_enabled_in_config
continue continue
@@ -429,7 +444,7 @@ class CameraWatchdog(threading.Thread):
self.start_all_ffmpeg() self.start_all_ffmpeg()
self._reset_segment_times() self._reset_segment_times()
self.record_enable_time = datetime.now().astimezone(UTC) self.record_enable_time = datetime.now().astimezone(UTC)
last_restart_time = datetime.now().timestamp() last_restart_time = time.monotonic()
self.was_record_sub_enabled = record_sub_enabled self.was_record_sub_enabled = record_sub_enabled
continue continue
@@ -465,7 +480,8 @@ class CameraWatchdog(threading.Thread):
segment_time if segment_time is not None else 0 segment_time if segment_time is not None else 0
) )
now = datetime.now().timestamp() # monotonic so a wall clock step can't trigger or block restarts
now = time.monotonic()
# Check if enough time has passed to allow ffmpeg restart (backoff pacing) # Check if enough time has passed to allow ffmpeg restart (backoff pacing)
time_since_last_restart = now - last_restart_time time_since_last_restart = now - last_restart_time
@@ -495,7 +511,7 @@ class CameraWatchdog(threading.Thread):
) )
self.reset_capture_thread() self.reset_capture_thread()
last_restart_time = now last_restart_time = now
elif now - self.capture_thread.current_frame.value > 20: elif self._no_frames_received(now):
self.detect_status.send("offline", now) self.detect_status.send("offline", now)
self.camera_fps.value = 0 self.camera_fps.value = 0
if can_restart: if can_restart:
@@ -589,7 +605,8 @@ class CameraWatchdog(threading.Thread):
self.reset_capture_thread() self.reset_capture_thread()
last_restart_time = now last_restart_time = now
# Prune expired reconnect timestamps # Prune expired reconnect timestamps. wall clock from here on,
# since processed_ts below is a frame time
now = datetime.now().timestamp() now = datetime.now().timestamp()
while ( while (
self.reconnect_timestamps and self.reconnect_timestamps[0] < now - 3600 self.reconnect_timestamps and self.reconnect_timestamps[0] < now - 3600
+3 -3
View File
@@ -1,4 +1,3 @@
import datetime
import logging import logging
import threading import threading
import time import time
@@ -86,7 +85,7 @@ class FrigateWatchdog(threading.Thread):
exitcode, exitcode,
) )
now = datetime.datetime.now().timestamp() now = time.monotonic()
if entry.is_restarting_too_fast(now): if entry.is_restarting_too_fast(now):
logger.error( logger.error(
@@ -113,7 +112,8 @@ class FrigateWatchdog(threading.Thread):
logger.exception("Failed to restart %s", entry.name) logger.exception("Failed to restart %s", entry.name)
def _check_detectors(self) -> None: def _check_detectors(self) -> None:
now = datetime.datetime.now().timestamp() # detection_start is set from the monotonic clock
now = time.monotonic()
for name, detector in self.detectors.items(): for name, detector in self.detectors.items():
detection_start = detector.detection_start.value detection_start = detector.detection_start.value