diff --git a/openpilot/starpilot/aol/tests/test_vehicle_policy_cache.py b/openpilot/starpilot/aol/tests/test_vehicle_policy_cache.py new file mode 100644 index 0000000000..3912ee2285 --- /dev/null +++ b/openpilot/starpilot/aol/tests/test_vehicle_policy_cache.py @@ -0,0 +1,232 @@ +"""Exact CarParams snapshots may cache policy; live authority remains current.""" + +from dataclasses import FrozenInstanceError +from types import SimpleNamespace +from unittest.mock import Mock, patch +import warnings + +import pytest +from capnp import KjException + +from opendbc.car import gen_empty_fingerprint, structs +from opendbc.car.car_helpers import interfaces +from opendbc.car.ford.values import CAR as FORD +from opendbc.car.gm.values import CAR as GM +from opendbc.car.honda.values import CAR as HONDA +from opendbc.car.hyundai.values import CAR as HYUNDAI +from opendbc.car.mazda.values import CAR as MAZDA +from opendbc.car.tesla.fingerprints import FINGERPRINTS as TESLA_FINGERPRINTS +from opendbc.car.tesla.values import CAR as TESLA +from opendbc.car.toyota.values import CAR as TOYOTA +from openpilot.starpilot.aol import vehicle + + +@pytest.fixture(autouse=True) +def clear_policy_cache(): + vehicle._policy_for_bytes.cache_clear() + yield + vehicle._policy_for_bytes.cache_clear() + + +def profile(brand): + """Factory inputs from each port's existing positive admission fixtures.""" + models = {'honda': HONDA.HONDA_CIVIC_BOSCH, 'hyundai': HYUNDAI.HYUNDAI_SONATA, + 'gm': GM.CHEVROLET_BOLT_CC_2018_2021, 'ford': FORD.FORD_ESCAPE_MK4, + 'mazda': MAZDA.MAZDA_CX5_2022, 'tesla': TESLA.TESLA_MODEL_S_PREAP, + 'toyota': TOYOTA.TOYOTA_COROLLA_TSS2} + fp = gen_empty_fingerprint() + if brand == 'gm': + fp[0][0x201] = 6 + fp[2].update({0x320: 8, 0x180: 4}) + elif brand == 'ford': + fp[0][0x5A] = 8 + fp[2].update({0x3D6: 8, 0x186: 8}) + elif brand == 'tesla': + fp[0] = dict(TESLA_FINGERPRINTS[models[brand]][0]) + return interfaces[models[brand]].get_params(models[brand], fp, [], brand == 'honda', False, False) + + +def assert_equivalent(cp): + expected = vehicle._policy_for_uncached(cp) + assert vehicle.policy_for(cp) == expected + assert vehicle.policy_for(cp.as_reader()) == expected + return expected + + +@pytest.mark.parametrize('brand', ('honda', 'hyundai', 'gm', 'ford', 'mazda', 'tesla', 'toyota')) +def test_actual_port_profile_and_startup_marking_match(brand): + cp = profile(brand) + initial = assert_equivalent(cp) + assert initial.intent_supported, brand + cp.safetyConfigs[-1].safetyParam |= initial.safety_param_addition + cp.alternativeExperience |= initial.alternative_experience_addition + marked = assert_equivalent(cp) + assert marked.intent_supported and marked.runtime_supported + assert vehicle.native_matches_cp(cp, int(cp.safetyConfigs[-1].safetyModel.raw), cp.safetyConfigs[-1].safetyParam) + + +@pytest.mark.parametrize('control', ('torque', 'pid', 'angle')) +def test_controller_variants_match(control): + cp = profile('toyota') + cp.steerControlType = 'angle' if control == 'angle' else 'torque' + cp.lateralTuning.init('pid' if control == 'pid' else 'torque') + assert_equivalent(cp) + + +@pytest.mark.parametrize('field', ('brand', 'carFingerprint', 'flags', 'passive', 'notCar', 'dashcamOnly', + 'pcmCruise', 'openpilotLongitudinalControl', 'alternativeExperience', + 'safetyParam', 'safetyModel', 'safetyConfigs', 'lateralTuning', 'carVin')) +def test_mutable_builder_and_alias_reader_never_reuse_a_prior_snapshot(field): + cp = profile('toyota') + alias = cp.as_reader() + accepted = vehicle.policy_for(cp) + assert accepted.intent_supported + before_key = vehicle._car_params_bytes(cp) + before = vehicle._policy_for_bytes.cache_info() + if field == 'safetyParam': + cp.safetyConfigs[0].safetyParam ^= 1 + elif field == 'safetyModel': + cp.safetyConfigs[0].safetyModel = 'noOutput' + elif field == 'safetyConfigs': + cp.safetyConfigs = [] + elif field == 'lateralTuning': + cp.lateralTuning.init('pid') + elif field in ('passive', 'notCar', 'dashcamOnly', 'pcmCruise', 'openpilotLongitudinalControl'): + setattr(cp, field, not getattr(cp, field)) + else: + setattr(cp, field, {'brand': 'unregistered', 'carFingerprint': 'unregistered', 'flags': int(cp.flags) ^ 1, + 'alternativeExperience': 1, 'carVin': 'CACHE-MUTATION'}[field]) + assert vehicle._car_params_bytes(cp) != before_key + expected = vehicle._policy_for_uncached(cp) + assert vehicle.policy_for(cp) == vehicle.policy_for(alias) == expected + after = vehicle._policy_for_bytes.cache_info() + assert after.misses == before.misses + 1 + assert after.hits == before.hits + 1 + if field in ('brand', 'carFingerprint', 'passive', 'notCar', 'dashcamOnly', 'safetyModel', 'safetyConfigs'): + assert not expected.intent_supported + + +def test_duck_types_and_schema_spoofs_bypass_serialization_untouched(monkeypatch): + sentinel = vehicle.AolVehiclePolicy(settings_supported=True) + owner = SimpleNamespace(policy_for=Mock(return_value=sentinel)) + monkeypatch.setitem(vehicle._PORTS, 'cache-test', owner) + for schema in (None, structs.CarParams.schema): + cp = SimpleNamespace(brand='cache-test', schema=schema, to_bytes=Mock(side_effect=AssertionError), + copy=Mock(side_effect=AssertionError), as_builder=Mock(side_effect=AssertionError)) + assert vehicle._car_params_bytes(cp) is None + assert vehicle.policy_for(cp) is sentinel + owner.policy_for.assert_called_with(cp) + cp.to_bytes.assert_not_called() + cp.copy.assert_not_called() + cp.as_builder.assert_not_called() + + class HostileMeta(type): + def __eq__(cls, other): + raise AssertionError('Unknown type equality must not run') + + class Unknown(metaclass=HostileMeta): + brand = 'cache-test' + + @property + def schema(self): + raise AssertionError('Unknown schema must not be read') + + assert vehicle._car_params_bytes(Unknown()) is None + assert vehicle.policy_for(Unknown()) is sentinel + assert vehicle._policy_for_bytes.cache_info().misses == 0 + + +def outcome(call, cp): + try: + return ('result', call(cp)) + except Exception as exc: + return ('exception', type(exc), str(exc)) + + +def test_malformed_wrong_schema_and_original_exceptions_are_preserved(monkeypatch): + wrong = structs.CarState(canValid=True) + for cp in (None, b'not a CarParams message', object(), SimpleNamespace(brand=[]), wrong, wrong.as_reader()): + assert vehicle._car_params_bytes(cp) is None + assert outcome(vehicle.policy_for, cp) == outcome(vehicle._policy_for_uncached, cp) + owner = SimpleNamespace(policy_for=Mock(side_effect=RuntimeError('original dispatcher exception'))) + monkeypatch.setitem(vehicle._PORTS, 'cache-test', owner) + cp = SimpleNamespace(brand='cache-test') + assert outcome(vehicle.policy_for, cp) == outcome(vehicle._policy_for_uncached, cp) + with pytest.raises((ValueError, KjException)): + vehicle._policy_for_bytes(b'not a CarParams message') + + +def test_serialization_and_decode_failure_fall_back_to_original_builder(monkeypatch): + cp = profile('toyota') + expected = vehicle._policy_for_uncached(cp) + + class BrokenSchema: + @property + def schema(self): + raise RuntimeError('schema unavailable') + + with monkeypatch.context() as broken: + broken.setattr(vehicle, 'CarParams', BrokenSchema()) + assert vehicle._car_params_bytes(cp) is None + assert vehicle.policy_for(cp) == expected + decoder = SimpleNamespace(schema=structs.CarParams.schema, from_bytes=Mock(side_effect=RuntimeError('decode unavailable'))) + monkeypatch.setattr(vehicle, 'CarParams', decoder) + with patch.object(vehicle, '_policy_for_uncached', wraps=vehicle._policy_for_uncached) as uncached: + assert vehicle.policy_for(cp) == expected + uncached.assert_called_once_with(cp) + decoder.from_bytes.assert_called_once() + + +def test_original_builder_write_state_and_repeat_warning_are_unchanged(): + cp = profile('toyota') + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter('always') + for _ in range(4): + assert_equivalent(cp) + assert not caught + cp.to_bytes() + assert not caught + cp.to_bytes() + assert any('already been written' in str(item.message) for item in caught) + + +def test_lru_bound_oversize_bypass_and_frozen_result(): + cp = profile('toyota') + for index in range(9): + cp.carVin = str(index) + assert_equivalent(cp) + info = vehicle._policy_for_bytes.cache_info() + assert info.maxsize == info.currsize == 8 + cp.carVin = '0' + vehicle.policy_for(cp) + assert vehicle._policy_for_bytes.cache_info().misses == info.misses + 1 + cp.carVin = 'v' * (64 * 1024) + before = vehicle._policy_for_bytes.cache_info() + assert vehicle._car_params_bytes(cp) is None + result = vehicle.policy_for(cp) + assert result == vehicle._policy_for_uncached(cp) + assert vehicle._policy_for_bytes.cache_info() == before + with pytest.raises(FrozenInstanceError): + result.__setattr__('intent_supported', False) + + +def test_warm_policy_does_not_cache_current_state_or_native_permission(): + cp = profile('mazda') + cp.alternativeExperience = 32 + assert vehicle.policy_for(cp).ordinary_axis_ack_required + assert vehicle.policy_for(cp).intent_supported + cs = structs.CarState(canValid=True, canTimeout=False, gearShifter='drive', vEgo=20) + native = SimpleNamespace(safetyModel=int(cp.safetyConfigs[0].safetyModel.raw), + safetyParam=cp.safetyConfigs[0].safetyParam, requestedLateral=True, lateralAllowed=True) + assert vehicle.ordinary_axis_request_allowed(cp, cs) + assert not vehicle.native_latch_rejected(cp, native, state=cs) + native.lateralAllowed = False + assert vehicle.native_latch_rejected(cp, native, state=cs) + cs.canValid = False + assert not vehicle.ordinary_axis_request_allowed(cp, cs) + cs.canValid, cs.steerFaultPermanent = True, True + assert not vehicle.ordinary_axis_request_allowed(cp, cs) + cs.steerFaultPermanent, cs.steerFaultTemporary = False, True + assert not vehicle.native_latch_rejected(cp, native, state=cs) + native.safetyParam ^= 1 + assert not vehicle.native_matches_cp(cp, native.safetyModel, native.safetyParam) diff --git a/openpilot/starpilot/aol/vehicle.py b/openpilot/starpilot/aol/vehicle.py index 093c775ff0..b23708f3a5 100644 --- a/openpilot/starpilot/aol/vehicle.py +++ b/openpilot/starpilot/aol/vehicle.py @@ -1,5 +1,10 @@ """Fail-closed dispatch to vehicle-owned AOL capabilities.""" +from functools import lru_cache + +from capnp.lib.capnp import _DynamicStructBuilder, _DynamicStructReader +from opendbc.car.structs import CarParams + from openpilot.starpilot.aol.policy import AolVehiclePolicy from openpilot.starpilot.aol.intent import AolCardIntent from openpilot.starpilot.car.honda import aol as honda @@ -11,9 +16,11 @@ from openpilot.starpilot.car.tesla import aol as tesla from openpilot.starpilot.car.toyota import aol as toyota _PORTS = {'honda': honda, 'hyundai': hyundai, 'gm': gm, 'ford': ford, 'mazda': mazda, 'tesla': tesla, 'toyota': toyota} +_POLICY_CACHE_SIZE = 8 +_POLICY_CACHE_MAX_BYTES = 64 * 1024 -def policy_for(CP) -> AolVehiclePolicy: +def _policy_for_uncached(CP) -> AolVehiclePolicy: try: port = _PORTS.get(CP.brand) return port.policy_for(CP) if port is not None else AolVehiclePolicy() @@ -21,6 +28,44 @@ def policy_for(CP) -> AolVehiclePolicy: return AolVehiclePolicy() +def _car_params_bytes(CP) -> bytes | None: + # Unknown ducktypes keep the original dispatch behavior without serialization. + cp_type = type(CP) + if cp_type is not _DynamicStructBuilder and cp_type is not _DynamicStructReader: + return None + try: + if CP.schema != CarParams.schema: + return None + # Never serialize the original builder: to_bytes changes its write state. + snapshot = CP.copy() if cp_type is _DynamicStructBuilder else CP.as_builder() + if type(snapshot) is not _DynamicStructBuilder: + return None + key = snapshot.to_bytes() + return key if len(key) <= _POLICY_CACHE_MAX_BYTES else None + except Exception: + return None + + +@lru_cache(maxsize=_POLICY_CACHE_SIZE) +def _policy_for_bytes(key: bytes) -> AolVehiclePolicy: + # Evaluate the same immutable bytes as the key, never a mutable caller alias. + with CarParams.from_bytes(key) as CP: + if type(CP) is not _DynamicStructReader or CP.schema != CarParams.schema: + raise TypeError('Unsupported CarParams snapshot') + return _policy_for_uncached(CP) + + +def policy_for(CP) -> AolVehiclePolicy: + key = _car_params_bytes(CP) + if key is not None: + try: + # Only the frozen static policy is reused. Live authority stays uncached. + return _policy_for_bytes(key) + except Exception: + pass + return _policy_for_uncached(CP) + + def native_profile_supported(model: int, param: int) -> bool: return any(port.native_profile_supported(model, param) for port in _PORTS.values()) diff --git a/prebuilt.json b/prebuilt.json index c0dd89ddab..cbd114bc53 100644 --- a/prebuilt.json +++ b/prebuilt.json @@ -1,4 +1 @@ -{ - "schema_version": 1, - "protected_inventory_sha256": "b567c57ef61d9e04942364f375a09b1465d1ce6dc5fe3e128782d2a7da622071" -} +{"protected_inventory_sha256": "ec474989597c90246eef09acaacab24b8bbf68c04b1271dbac521e005b84082a", "schema_version": 1}