Cache immutable vehicle capability policies

This commit is contained in:
firestar5683
2026-10-14 21:20:00 -05:00
parent 9ba3e3667a
commit a8f3992c0a
3 changed files with 279 additions and 5 deletions
@@ -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)
+46 -1
View File
@@ -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())
+1 -4
View File
@@ -1,4 +1 @@
{
"schema_version": 1,
"protected_inventory_sha256": "b567c57ef61d9e04942364f375a09b1465d1ce6dc5fe3e128782d2a7da622071"
}
{"protected_inventory_sha256": "ec474989597c90246eef09acaacab24b8bbf68c04b1271dbac521e005b84082a", "schema_version": 1}