Files
StarPilot/openpilot/starpilot/conditional_mode/tests/test_card_settings.py
T
2026-10-20 12:14:47 -05:00

236 lines
10 KiB
Python

"""Live Card source snapshots: bounded age, coherent adoption and no main reads."""
from dataclasses import replace
import fcntl
import os
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest.mock import patch
import pytest
from openpilot.common.params import Params
from openpilot.starpilot.conditional_mode.card_settings import CardSettingsObservation, CardSettingsWorker
from openpilot.starpilot.conditional_mode.runtime_settings import ConditionalSettingsOwner, DocumentState, REVERIFY_NS
@pytest.fixture
def worker(tmp_path):
return CardSettingsWorker(Params(str(tmp_path)))
def ready(worker, now):
return worker.view.verdict(worker.view.refresh(now), now_mono_ns=now, drive_id=1).status == 'ready'
def test_startup_denies_and_verified_reads_retain_pre_io_clock(worker):
assert not ready(worker, 1_000_000_000)
worker.refresh(1_000_000_000)
assert worker.published.snapshot.verified_mono_ns == 1_000_000_000
assert not ready(worker, 1_000_000_000) # no adoption before frame boundary
worker.adopt()
assert ready(worker, 1_499_999_999)
assert not ready(worker, 1_500_000_000)
assert not ready(worker, 2_000_000_000)
assert not ready(worker, 999_999_999)
def test_proactive_read_reverifies_before_original_500ms_period(worker):
worker.refresh(1_000_000_000)
first = worker.published.snapshot
worker.refresh(1_100_000_000)
second = worker.published.snapshot
assert second is not first and second.revision == first.revision
assert second.observed_mono_ns == first.observed_mono_ns
assert second.verified_mono_ns == 1_100_000_000
def test_worker_publication_cannot_change_current_frame_and_main_never_reads(worker):
worker.refresh(1_000_000_000)
worker.adopt()
old = worker.view.current
Path(worker.reader.params.get_param_path('SafeMode')).write_bytes(b'1')
worker.refresh(1_100_000_000)
assert worker.view.current is old
assert ready(worker, 1_100_000_000)
with patch('openpilot.starpilot.conditional_mode.runtime_settings.read_mode_sources', side_effect=AssertionError('main I/O')):
for _ in range(100):
assert worker.view.refresh(1_100_000_000, force=True, wait=True) is old
assert ready(worker, 1_100_000_000)
worker.adopt()
assert not ready(worker, 1_100_000_000)
assert worker.reader is not worker.view
assert worker.view.current.safe_mode_raw == b'1'
def test_writer_busy_denies_without_retaining_one_second_permission(worker):
worker.refresh(1_000_000_000)
worker.adopt()
assert ready(worker, 1_000_000_000)
root = Path(worker.reader.params.get_param_path('ConditionalModeConfig')).parent.parent
fd = os.open(root / '.lock', os.O_RDONLY)
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
worker.refresh(1_100_000_000)
finally:
os.close(fd)
assert worker.published.status == 'busy'
worker.adopt()
assert not ready(worker, 1_100_000_000)
worker.refresh(1_200_000_000)
worker.adopt()
assert ready(worker, 1_200_000_000)
@pytest.mark.parametrize('key,raw', [('SafeMode', b'1'), ('SafeMode', b'bad'), ('ConditionalModeConfig', b'{')])
def test_restrictive_and_invalid_updates_revoke(worker, key, raw):
worker.refresh(1_000_000_000)
worker.adopt()
old = worker.view.current
Path(worker.reader.params.get_param_path(key)).write_bytes(raw)
worker.refresh(1_100_000_000)
worker.adopt()
assert not ready(worker, 1_100_000_000)
assert not worker.view.affirm(old, now_mono_ns=1_100_000_000)
def test_unreadable_and_unexpected_errors_recover_without_escaping(worker):
worker.refresh(1_000_000_000)
with patch.object(worker.reader, 'refresh', side_effect=RuntimeError('failed read')):
worker.refresh(1_100_000_000)
assert worker.published.status == 'error'
worker.adopt()
assert not ready(worker, 1_100_000_000)
worker.refresh(1_200_000_000)
worker.adopt()
assert ready(worker, 1_200_000_000)
with patch('openpilot.starpilot.conditional_mode.runtime_settings.read_mode_sources',
return_value=((b'', False), (b'', False), (b'', False))):
worker.refresh(1_300_000_000)
worker.adopt()
assert worker.view.current.document_state is DocumentState.READ_ERROR
assert not ready(worker, 1_300_000_000)
def test_slow_completed_read_is_stale_on_arrival_and_foreign_owner_denies(worker):
worker.refresh(1_000_000_000)
worker.adopt()
assert not ready(worker, 1_000_000_000 + REVERIFY_NS)
foreign = replace(worker.published.snapshot, owner_token='foreign')
worker.view.adopt(CardSettingsObservation(foreign, 'verified', 1_000_000_000))
assert not ready(worker, 1_000_000_000)
def test_clock_rollback_invalidates_and_never_renews_on_adoption(worker):
worker.refresh(1_000_000_000)
worker.refresh(999_999_999)
worker.adopt()
assert not ready(worker, 1_000_000_000)
worker.refresh(1_100_000_000)
worker.adopt()
snapshot = worker.view.current
for _ in range(50):
worker.adopt()
assert worker.view.current is snapshot and snapshot.verified_mono_ns == 1_100_000_000
@pytest.mark.parametrize('replay', [False, True])
def test_actual_card_installation_and_replay_owner_contract(worker, replay):
from openpilot.selfdrive.car import card as module
card: Any = module.Car.__new__(module.Car)
card.params = worker.reader.params
card.manual_settings_owner = ConditionalSettingsOwner(card.params)
card.switchback_settings_owner = ConditionalSettingsOwner(card.params)
old = (card.manual_settings_owner, card.switchback_settings_owner)
with patch.object(module, 'REPLAY', replay):
card.configure_conditional_settings_worker()
if replay:
assert card.conditional_settings_worker is None
assert (card.manual_settings_owner, card.switchback_settings_owner) == old
else:
assert card.manual_settings_owner is card.switchback_settings_owner is card.conditional_settings_worker.view
assert card.manual_settings_owner is not card.conditional_settings_worker.reader
def test_actual_params_thread_continues_after_new_reader_exception(worker):
from openpilot.selfdrive.car import card as module
card: Any = module.Car.__new__(module.Car)
calls = []
card.params = SimpleNamespace(get_bool=lambda key: calls.append(key) or False, get=worker.reader.params.get)
card.CP = SimpleNamespace(openpilotLongitudinalControl=True, pcmCruise=False)
card.CI = SimpleNamespace(CP=card.CP, CC=None)
card.aol_card_intent = None
card.conditional_settings_worker = worker
card.v_cruise_helper = SimpleNamespace()
card.read_slc_configuration = lambda: None
event = SimpleNamespace(is_set=lambda: bool(calls))
with patch.object(module, 'read_cruise_intervals'), patch.object(module.time, 'sleep'), \
patch.object(module.time, 'monotonic_ns', return_value=1_000_000_000), \
patch.object(worker.reader, 'refresh', side_effect=RuntimeError('reader failed')):
card.params_thread(event)
assert calls == ['IsMetric', 'ExperimentalMode']
assert worker.published.status == 'error' and worker.published.read_started_ns == 1_000_000_000
@pytest.mark.parametrize('full_op', [False, True])
def test_actual_card_stale_settings_do_not_clear_ordinary_or_aol_authority(full_op):
from openpilot.common.prefix import OpenpilotPrefix
from openpilot.selfdrive.car import card as module
from openpilot.starpilot.aol.tests.test_ioniq_availability import IoniqAvailabilityTests
from openpilot.starpilot.conditional_mode.manual import ButtonTracker, IoniqMediaMapCache
fixture = IoniqAvailabilityTests()
try:
with OpenpilotPrefix():
sd, _, drive, card, receive = fixture._ioniq_availability_callers(full_op=full_op)
card.conditional_replay = True
card.manual_button_tracker = ButtonTracker()
card.traffic_button_tracker = ButtonTracker()
card.ioniq_media_map = IoniqMediaMapCache()
card.manual_settings_owner = ConditionalSettingsOwner(card.params)
with patch.object(module, 'REPLAY', False):
card.configure_conditional_settings_worker()
worker = card.conditional_settings_worker
assert worker is not None
for tick, status in enumerate(('startup', 'ready', 'stale', 'busy', 'invalid'), start=2):
now = 10_000_000_000 + tick * 10_000_000
if status == 'ready':
worker.refresh(now)
elif status == 'stale':
assert worker.published.snapshot is not None
worker.published = replace(worker.published,
snapshot=replace(worker.published.snapshot, verified_mono_ns=now - REVERIFY_NS))
elif status == 'busy':
worker.published = CardSettingsObservation(None, 'busy', now)
elif status == 'invalid':
Path(card.params.get_param_path('ConditionalModeConfig')).write_bytes(b'{')
worker.refresh(now)
axis, _, command = drive(tick)
assert axis.lateralActive and command.latActive
assert bool(axis.longitudinalActive) is full_op and bool(command.longActive) is full_op
adopted = []
original_manual, original_traffic = module.conditional_manual_candidate, module.conditional_traffic_candidate
def manual(*args, adopted=adopted, original_manual=original_manual, **kwargs):
adopted.append(args[3].current)
return original_manual(*args, **kwargs)
def traffic(*args, adopted=adopted, original_traffic=original_traffic, **kwargs):
adopted.append(args[3].current)
return original_traffic(*args, **kwargs)
with patch.object(worker, 'adopt', wraps=worker.adopt) as adoption, \
patch.object(module, 'conditional_manual_candidate', side_effect=manual), \
patch.object(module, 'conditional_traffic_candidate', side_effect=traffic), \
patch('openpilot.starpilot.conditional_mode.runtime_settings.read_mode_sources',
side_effect=AssertionError('main settings I/O')):
assert receive(now, axis=sd.pm.messages['aolAxisState'], event=sd.pm.messages['onroadEvents'])
assert adoption.call_count == 1 and len(adopted) == 3
assert all(snapshot is adopted[0] for snapshot in adopted)
assert card.manual_receipt is None and card.traffic_receipt is None and card.switchback_receipt is None
axis, _, command = drive(7)
assert axis.lateralActive and command.latActive
assert bool(axis.longitudinalActive) is full_op and bool(command.longActive) is full_op
finally:
fixture.doCleanups()