mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 05:23:41 +08:00
236 lines
10 KiB
Python
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()
|