From ac0f6c9dd3736d26336c5b85620dad2cd528c64b Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 17 Sep 2026 20:42:15 -0500 Subject: [PATCH] fix restart failing under non-root restart_frigate() called psutil.Process(1).terminate() to signal s6-svscan, but s6-svscan runs as root while frigate runs as uid 1000, so the call raised AccessDenied. That exception escaped every caller: the UI restart button dropped its websocket client, MQTT restart and Save & Restart just logged and did nothing, and the watchdog crashed its own monitoring thread on a dead detector. This catches AccessDenied and falls through to the existing SIGINT branch, which exits the process for s6 to restart it. --- frigate/test/test_restart_frigate.py | 55 ++++++++++++++++++++++++++++ frigate/util/services.py | 13 +++++-- 2 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 frigate/test/test_restart_frigate.py diff --git a/frigate/test/test_restart_frigate.py b/frigate/test/test_restart_frigate.py new file mode 100644 index 0000000000..5b8de5f90e --- /dev/null +++ b/frigate/test/test_restart_frigate.py @@ -0,0 +1,55 @@ +"""Tests for restarting frigate under s6.""" + +import signal +import unittest +from unittest.mock import MagicMock, patch + +import psutil + +from frigate.util.services import restart_frigate + + +class TestRestartFrigate(unittest.TestCase): + def _s6_process(self) -> MagicMock: + proc = MagicMock() + proc.name.return_value = "s6-svscan" + return proc + + @patch("frigate.util.services.os.kill") + @patch("frigate.util.services.psutil.Process") + def test_terminates_s6_when_permitted(self, mock_process, mock_kill): + proc = self._s6_process() + mock_process.return_value = proc + + restart_frigate() + + proc.terminate.assert_called_once() + mock_kill.assert_not_called() + + @patch("frigate.util.services.os.getpid", return_value=99) + @patch("frigate.util.services.os.kill") + @patch("frigate.util.services.psutil.Process") + def test_exits_self_when_s6_signal_is_denied( + self, mock_process, mock_kill, _mock_getpid + ): + """Running unprivileged, frigate cannot signal root's s6-svscan.""" + proc = self._s6_process() + proc.terminate.side_effect = psutil.AccessDenied(pid=1, name="s6-svscan") + mock_process.return_value = proc + + restart_frigate() + + mock_kill.assert_called_once_with(99, signal.SIGINT) + + @patch("frigate.util.services.os.getpid", return_value=99) + @patch("frigate.util.services.os.kill") + @patch("frigate.util.services.psutil.Process") + def test_exits_self_without_s6(self, mock_process, mock_kill, _mock_getpid): + proc = MagicMock() + proc.name.return_value = "init" + mock_process.return_value = proc + + restart_frigate() + + proc.terminate.assert_not_called() + mock_kill.assert_called_once_with(99, signal.SIGINT) diff --git a/frigate/util/services.py b/frigate/util/services.py index 7f39601851..eea71e62c3 100644 --- a/frigate/util/services.py +++ b/frigate/util/services.py @@ -35,12 +35,19 @@ logger = logging.getLogger(__name__) def restart_frigate(): proc = psutil.Process(1) + # if this is running via s6, sigterm pid 1 if proc.name() == "s6-svscan": - proc.terminate() + try: + proc.terminate() + return + except psutil.AccessDenied: + # frigate runs unprivileged, so it cannot signal root's s6-svscan. + # exiting this process instead runs frigate/finish, which halts s6 + logger.debug("Not permitted to signal s6-svscan, exiting instead") + # otherwise, just try and exit frigate - else: - os.kill(os.getpid(), signal.SIGINT) + os.kill(os.getpid(), signal.SIGINT) def print_stack(sig, frame):