Arabesque time sync to prevent Firefox issues

This commit is contained in:
firestarsdog
2026-11-02 13:55:41 -05:00
parent d1f4a06744
commit f27f1f22c2
2 changed files with 105 additions and 0 deletions
@@ -1,7 +1,13 @@
from collections.abc import Callable
from datetime import UTC
from email.utils import parsedate_to_datetime
import subprocess
from threading import Event, Thread
from openpilot.starpilot.ui.raylib import rl
from openpilot.common.hardware import PC
from openpilot.common.time_helpers import MIN_DATE, MAX_DATE
from openpilot.selfdrive.ui.layouts.arabesque.theme import DEFAULT_THEME
from openpilot.selfdrive.ui.layouts.arabesque.tools.browser import Browser
from openpilot.selfdrive.ui.layouts.arabesque.tools.files import Files
@@ -27,6 +33,32 @@ class ArabesqueLayout(Widget):
self._pressed_rect: rl.Rectangle | None = None
self._active_tool: Widget | None = None
self._active_name = ""
self._time_sync_thread: Thread | None = None
self._time_sync_cancelled = Event()
def show_event(self):
super().show_event()
if PC or not self._permitted() or self._time_sync_thread is not None and self._time_sync_thread.is_alive():
return
self._time_sync_cancelled = Event()
self._time_sync_thread = Thread(target=self._sync_time, args=(self._time_sync_cancelled,), daemon=True)
self._time_sync_thread.start()
def _sync_time(self, cancelled: Event):
try:
# shortcut: HTTP recovery time is unauthenticated, replace when an authenticated bootstrap source is available.
headers = subprocess.run(
['curl', '-fsI', '-m', '10', '-H', 'Cache-Control: no-cache', 'http://www.google.com'],
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, check=True, timeout=11,
).stdout
date = next((line.partition(':')[2].strip() for line in headers.splitlines() if line.lower().startswith('date:')), '')
net_time = parsedate_to_datetime(date)
if net_time.tzinfo is None or not MIN_DATE < net_time.astimezone(UTC).replace(tzinfo=None) < MAX_DATE or cancelled.is_set():
return
subprocess.run(['sudo', '-n', 'date', '-u', '-s', net_time.isoformat()],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True, timeout=2)
except (OSError, ValueError, TypeError, OverflowError, subprocess.SubprocessError):
pass
def _update_layout_rects(self):
rect = self._rect
@@ -67,10 +99,14 @@ class ArabesqueLayout(Widget):
tool.hide_event()
def hide_event(self):
# MainLayout also hides this view when offroad access is revoked.
self._time_sync_cancelled.set()
super().hide_event()
self._close_tool()
def poll_runtime(self, awake: bool):
if self._time_sync_cancelled.is_set() and self._time_sync_thread is not None and not self._time_sync_thread.is_alive():
self.show_event()
if isinstance(self._active_tool, Browser):
self._active_tool.poll_runtime(awake)
@@ -5,6 +5,8 @@ from dataclasses import replace
from enum import IntEnum
import gc
from pathlib import Path
import subprocess
from threading import Event
from types import SimpleNamespace as NS
from unittest.mock import Mock
import weakref
@@ -35,6 +37,7 @@ def layout_classes(path, names, namespace):
@pytest.fixture
def shell(monkeypatch):
import openpilot.system.ui.widgets as widgets
monkeypatch.setattr(desktop, 'PC', True)
monkeypatch.setattr(widgets, 'device', NS(awake=True))
monkeypatch.setattr(gui_app, '_mouse_events', [])
monkeypatch.setattr(gui_app, '_show_touches', False)
@@ -425,6 +428,7 @@ def test_authority_revocation_closes_tool_without_rendering(main, probe):
main.layout.poll_arabesque()
assert main.layout._current_mode == main.state.ONROAD
assert view._active_tool is None
assert view._time_sync_cancelled.is_set()
probe.hide_event.assert_called_once()
@@ -492,3 +496,68 @@ def test_star_large_routes_shell_and_uses_confirmed_offroad(main, probe):
assert view._active_tool is None
layout.star.close.assert_called_once()
layout._native_onroad.close.assert_called_once()
def test_time_sync_startup_cancellation_and_reentry(shell, monkeypatch):
thread, run = Mock(), Mock(return_value=NS(stdout='dAtE: Mon, 02 Nov 2026 18:00:00 GMT\r\n'))
monkeypatch.setattr(desktop, 'Thread', thread)
monkeypatch.setattr(desktop.subprocess, 'run', run)
shell.show_event()
thread.assert_not_called()
monkeypatch.setattr(desktop, 'PC', False)
shell._permitted = Mock(return_value=False)
shell.show_event()
thread.assert_not_called()
shell._permitted.return_value = True
shell.show_event()
run.assert_not_called()
assert thread.call_args.kwargs['daemon']
thread.return_value.start.assert_called_once()
shell.show_event()
thread.assert_called_once()
shell.hide_event()
shell.show_event()
shell.poll_runtime(True)
thread.assert_called_once()
thread.call_args.kwargs['target'](*thread.call_args.kwargs['args'])
assert run.call_count == 1 # Closing cancels the correction after the request returns.
thread.return_value.is_alive.return_value = False
shell._permitted.return_value = False
shell.poll_runtime(True)
thread.assert_called_once()
shell._permitted.return_value = True
shell.poll_runtime(True)
assert thread.call_count == 2
thread.call_args.kwargs['target'](*thread.call_args.kwargs['args'])
assert run.call_count == 3
fetch, apply = run.call_args_list[-2:]
assert fetch.args[0] == ['curl', '-fsI', '-m', '10', '-H', 'Cache-Control: no-cache', 'http://www.google.com']
assert fetch.kwargs['timeout'] == 11
assert apply.args[0] == ['sudo', '-n', 'date', '-u', '-s', '2026-11-02T18:00:00+00:00']
assert apply.kwargs['timeout'] == 2
assert apply.kwargs['stdout'] == apply.kwargs['stderr'] == subprocess.DEVNULL
shell.poll_runtime(True)
assert thread.call_count == 2
@pytest.mark.parametrize('response', [
'', 'HTTP/1.1 200 OK\r\n', 'Date: invalid\r\n',
'Date: Sat, 01 Jan 2000 00:00:00 GMT\r\n', 'Date: Tue, 01 Jan 2036 00:00:00 GMT\r\n',
'Date: Mon, 02 Nov 2026 18:00:00\r\n',
subprocess.CalledProcessError(22, ['curl'], output='Date: Mon, 02 Nov 2026 18:00:00 GMT\r\n'),
subprocess.CalledProcessError(28, ['curl'], output='Date: Mon, 02 Nov 2026 18:00:00 GMT\r\n'),
subprocess.TimeoutExpired(['curl'], 11), FileNotFoundError('curl'),
])
def test_failed_or_invalid_time_sync_never_sets_clock(shell, monkeypatch, response):
run = Mock(side_effect=response) if isinstance(response, Exception) else Mock(return_value=NS(stdout=response))
monkeypatch.setattr(desktop.subprocess, 'run', run)
shell._sync_time(Event())
assert run.call_count == 1
@pytest.mark.parametrize('error', [subprocess.CalledProcessError(1, ['sudo']), subprocess.TimeoutExpired(['sudo'], 2), FileNotFoundError('sudo')])
def test_time_sync_permission_or_command_failures_stay_quiet(shell, monkeypatch, error):
run = Mock(side_effect=[NS(stdout='Date: Mon, 02 Nov 2026 18:00:00 GMT\r\n'), error])
monkeypatch.setattr(desktop.subprocess, 'run', run)
shell._sync_time(Event())
assert run.call_count == 2