mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-28 09:03:46 +08:00
ec8d7eb98b
Define persistent settings, typed schema fields and storage migrations before feature owners consume them. Keep calibration and parameter readers aligned with the shared data contracts. Co-authored-by: Adeeb Shihadeh <adeebshihadeh@gmail.com> Co-authored-by: Danny <dannyirwin31@yahoo.com> Co-authored-by: Harald Schäfer <harald.the.engineer@gmail.com> Co-authored-by: Prabhaav Pillai <prabhaav@msu.edu> Co-authored-by: dirwin31 <83434411+dirwin31@users.noreply.github.com> Co-authored-by: firestarsdog <229254897+firestarsdog@users.noreply.github.com> Co-authored-by: haraschax <6804392+haraschax@users.noreply.github.com> Co-authored-by: Dom <168790843+firestar5683@users.noreply.github.com> Co-authored-by: prabhaavp <prabhaav@msu.edu>
310 lines
16 KiB
Python
310 lines
16 KiB
Python
import copy
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
import shutil
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import Mock, patch
|
|
|
|
import capnp
|
|
|
|
from opendbc.car import structs as car
|
|
from openpilot.common.params import Params
|
|
from openpilot.starpilot import schema_cache as cache
|
|
|
|
|
|
class TestSchemaCache(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temporary = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.temporary.cleanup)
|
|
self.root = Path(self.temporary.name)
|
|
self.params = Params(str(self.root / "params"))
|
|
self.event_parsers = []
|
|
cache.prewarm_cache_contracts()
|
|
|
|
def message(self, key):
|
|
contract = cache.CONTRACTS[key]
|
|
message = contract.root.new_message()
|
|
if contract.service:
|
|
message.init(contract.service)
|
|
else:
|
|
message.carFingerprint = "CACHE_TEST_FIXTURE"
|
|
return message
|
|
|
|
def encoded(self, key="CarParamsPersistent"):
|
|
cache.put_cache(self.params, key, self.message(key), block=True)
|
|
return self.params.get(key)
|
|
|
|
@staticmethod
|
|
def header_and_payload(raw):
|
|
start = len(cache.MAGIC) + 4
|
|
size = struct.unpack_from(">I", raw, len(cache.MAGIC))[0]
|
|
return json.loads(raw[start:start + size]), raw[start + size:]
|
|
|
|
@staticmethod
|
|
def repack(header, payload):
|
|
raw = json.dumps(header, separators=(",", ":")).encode()
|
|
return cache.MAGIC + struct.pack(">I", len(raw)) + raw + payload
|
|
|
|
def test_all_key_service_contracts_roundtrip_native_params(self):
|
|
for key in cache.CACHE_KEYS:
|
|
with self.subTest(key=key):
|
|
raw = self.encoded(key)
|
|
inspected = cache.inspect_cache(key, raw)
|
|
self.assertEqual(inspected.status, "valid")
|
|
self.assertEqual(cache.get_cache(self.params, key), inspected.payload)
|
|
with cache.CONTRACTS[key].root.from_bytes(inspected.payload) as message:
|
|
if cache.CONTRACTS[key].service:
|
|
self.assertEqual(message.which(), cache.CONTRACTS[key].service)
|
|
else:
|
|
self.assertEqual(message.carFingerprint, "CACHE_TEST_FIXTURE")
|
|
|
|
def test_single_queued_write_preserves_block_argument(self):
|
|
params = Mock()
|
|
for block in (False, True):
|
|
params.reset_mock()
|
|
cache.put_cache(params, "CarParamsCache", self.message("CarParamsCache"), block=block)
|
|
self.assertEqual(params.put.call_count, 1)
|
|
self.assertEqual(params.put.call_args.kwargs, {"block": block})
|
|
key, value = params.put.call_args.args
|
|
self.assertEqual(cache.inspect_cache(key, value).status, "valid")
|
|
|
|
def test_native_async_write_and_capture_before_builder_mutation(self):
|
|
message = self.message("CarParamsCache")
|
|
cache.put_cache(self.params, "CarParamsCache", message, block=False)
|
|
message.carFingerprint = "CHANGED_AFTER_QUEUE"
|
|
raw = self.params.get("CarParamsCache", block=True)
|
|
result = cache.inspect_cache("CarParamsCache", raw)
|
|
with car.CarParams.from_bytes(result.payload) as restored:
|
|
self.assertEqual(restored.carFingerprint, "CACHE_TEST_FIXTURE")
|
|
|
|
def test_already_serialized_builder_and_reader_can_be_used(self):
|
|
message = self.message("CarParamsCache")
|
|
original = message.to_bytes()
|
|
cache.put_cache(self.params, "CarParamsCache", message, block=True)
|
|
cache.put_cache(self.params, "CarParamsPersistent", message, block=True)
|
|
with car.CarParams.from_bytes(original) as reader:
|
|
cache.put_cache(self.params, "CarParamsPrevRoute", reader, block=True)
|
|
self.assertEqual(cache.get_cache(self.params, "CarParamsCache"), original)
|
|
self.assertEqual(cache.get_cache(self.params, "CarParamsPrevRoute"), original)
|
|
|
|
def test_raw_bytes_cannot_be_blessed_by_typed_writer(self):
|
|
raw = self.message("CarParamsCache").to_bytes()
|
|
for invalid in (raw, bytearray(raw), memoryview(raw), {}, None):
|
|
with self.subTest(type=type(invalid)), self.assertRaises(TypeError):
|
|
cache.put_cache(self.params, "CarParamsCache", invalid)
|
|
self.assertIsNone(self.params.get("CarParamsCache"))
|
|
|
|
def test_wrong_root_and_service_rejected(self):
|
|
with self.assertRaises(ValueError):
|
|
cache.put_cache(self.params, "CalibrationParams", self.message("CarParamsCache"))
|
|
with self.assertRaises(ValueError):
|
|
cache.put_cache(self.params, "CalibrationParams", self.message("LiveDelay"))
|
|
|
|
def test_missing_and_unversioned_are_distinct_and_preserved(self):
|
|
self.assertEqual(cache.inspect_cache("CarParamsCache", None).status, "missing")
|
|
for raw in (b"", b"old bytes", self.message("CarParamsCache").to_bytes()):
|
|
self.params.put("CarParamsCache", raw, block=True)
|
|
self.assertEqual(cache.inspect_cache("CarParamsCache", raw).status, "incompatible")
|
|
self.assertIsNone(cache.get_cache(self.params, "CarParamsCache"))
|
|
# Native Params maps an empty value to None; inspect raw storage to prove
|
|
# that a cache miss did not remove or replace even an empty file.
|
|
self.assertEqual((Path(self.params.get_param_path()) / "CarParamsCache").read_bytes(), raw)
|
|
|
|
def test_cache_key_cannot_be_relabelled(self):
|
|
raw = self.encoded()
|
|
self.assertEqual(cache.inspect_cache("CarParamsPrevRoute", raw).status, "incompatible")
|
|
with self.assertRaises(ValueError):
|
|
cache.put_cache(self.params, "CarParams", self.message("CarParamsCache"))
|
|
|
|
def test_metadata_epoch_schema_and_digest_tampering_rejected(self):
|
|
header, payload = self.header_and_payload(self.encoded())
|
|
for key, value in (("version", 2), ("version", True), ("schema_sha256", "0" * 64),
|
|
("root_id", "0x1"), ("producer_contract", "other"), ("payload_bytes", False),
|
|
("fingerprint_format", "other"), ("extra", "field")):
|
|
changed = copy.deepcopy(header)
|
|
changed[key] = value
|
|
self.assertEqual(cache.inspect_cache("CarParamsPersistent", self.repack(changed, payload)).status, "incompatible")
|
|
damaged = payload[:-1] + bytes([payload[-1] ^ 1])
|
|
self.assertEqual(cache.inspect_cache("CarParamsPersistent", self.repack(header, damaged)).status, "incompatible")
|
|
|
|
def test_duplicate_json_member_rejected(self):
|
|
header, payload = self.header_and_payload(self.encoded())
|
|
text = json.dumps(header)[:-1] + ', "version":1}'
|
|
encoded = text.encode()
|
|
raw = cache.MAGIC + struct.pack(">I", len(encoded)) + encoded + payload
|
|
self.assertEqual(cache.inspect_cache("CarParamsPersistent", raw).status, "incompatible")
|
|
|
|
def test_wrong_event_service_rejected_even_with_consistent_digest(self):
|
|
header, _ = self.header_and_payload(self.encoded("CalibrationParams"))
|
|
payload = self.message("LiveDelay").to_bytes()
|
|
header.update(payload_bytes=len(payload), payload_sha256=hashlib.sha256(payload).hexdigest())
|
|
self.assertEqual(cache.inspect_cache("CalibrationParams", self.repack(header, payload)).status, "incompatible")
|
|
|
|
def test_malformed_capnp_rejected_even_with_consistent_digest(self):
|
|
header, _ = self.header_and_payload(self.encoded())
|
|
payload = b"not capnp"
|
|
header.update(payload_bytes=len(payload), payload_sha256=hashlib.sha256(payload).hexdigest())
|
|
self.assertEqual(cache.inspect_cache("CarParamsPersistent", self.repack(header, payload)).status, "incompatible")
|
|
|
|
def test_total_envelope_limit_includes_metadata(self):
|
|
raw = self.encoded()
|
|
params = Mock()
|
|
with patch.object(cache, "MAX_ENVELOPE_BYTES", len(raw) - 1):
|
|
self.assertEqual(cache.inspect_cache("CarParamsPersistent", raw).status, "incompatible")
|
|
with self.assertRaises(ValueError):
|
|
cache.put_cache(params, "CarParamsPersistent", self.message("CarParamsPersistent"))
|
|
params.put.assert_not_called()
|
|
self.assertLess(cache.MAX_PAYLOAD_BYTES + cache.MAX_HEADER_BYTES + len(cache.MAGIC) + 4, cache.MAX_ENVELOPE_BYTES + 1)
|
|
|
|
def test_invalid_or_truncated_header_length_rejected(self):
|
|
for size, body in ((0, b""), (cache.MAX_HEADER_BYTES + 1, b"x"), (200, b"x")):
|
|
raw = cache.MAGIC + struct.pack(">I", size) + body
|
|
self.assertEqual(cache.inspect_cache("CarParamsCache", raw).status, "incompatible")
|
|
|
|
def schemas(self, members):
|
|
parsers, modules = [], []
|
|
for index, member in enumerate(members):
|
|
directory = self.root / f"schema-{index}"
|
|
directory.mkdir()
|
|
path = directory / "fixture.capnp"
|
|
path.write_text('@0xabcdefabcdefabcd; struct Root @0xabcdefabcdefabce { child @0 :Choice; ' +
|
|
'enum Choice @0xabcdefabcdefabcf { ' + member + ' @0; } }')
|
|
parser = capnp.SchemaParser()
|
|
parsers.append(parser)
|
|
modules.append(parser.load(str(path)))
|
|
return parsers, modules
|
|
|
|
def test_same_root_id_and_bytes_different_nested_enum_differ(self):
|
|
parsers, modules = self.schemas(("firstMeaning", "otherMeaning"))
|
|
a, b = (module.Root.schema for module in modules)
|
|
self.assertEqual(a.node.id, b.node.id)
|
|
self.assertEqual(a.node.as_builder().to_bytes(), b.node.as_builder().to_bytes())
|
|
self.assertNotEqual(a, b)
|
|
self.assertNotEqual(cache.schema_fingerprint(a), cache.schema_fingerprint(b))
|
|
self.assertEqual(len(parsers), 2) # keep parser ownership alive through assertions
|
|
|
|
def test_fingerprint_stable_across_schema_locations(self):
|
|
parsers, modules = self.schemas(("sameMeaning", "sameMeaning"))
|
|
self.assertEqual(cache.schema_fingerprint(modules[0].Root.schema), cache.schema_fingerprint(modules[1].Root.schema))
|
|
self.assertEqual(len(parsers), 2)
|
|
|
|
def test_independent_copy_of_current_schema_is_not_current_producer(self):
|
|
source = Path(__file__).resolve().parents[3] / "opendbc_repo/opendbc/car"
|
|
destination = self.root / "separate-checkout"
|
|
destination.mkdir()
|
|
shutil.copy(source / "car.capnp", destination / "car.capnp")
|
|
shutil.copytree(source / "include", destination / "include")
|
|
parser = capnp.SchemaParser()
|
|
module = parser.load(str(destination / "car.capnp"), imports=[str(destination)])
|
|
self.assertEqual(cache.schema_fingerprint(module.CarParams.schema), cache.schema_fingerprint(car.CarParams.schema))
|
|
self.assertEqual(module.CarParams.schema.node.id, car.CarParams.schema.node.id)
|
|
with self.assertRaises(ValueError):
|
|
cache.put_cache(self.params, "CarParamsCache", module.CarParams.new_message())
|
|
|
|
def test_compiled_fingerprints_stable_across_fresh_processes(self):
|
|
code = 'import json; from openpilot.starpilot.schema_cache import prewarm_cache_contracts; print(json.dumps(prewarm_cache_contracts(), sort_keys=True))'
|
|
root = Path(__file__).resolve().parents[3]
|
|
outputs = [subprocess.check_output([sys.executable, "-c", code], cwd=root, text=True) for _ in range(2)]
|
|
self.assertEqual(outputs[0], outputs[1])
|
|
self.assertEqual(json.loads(outputs[0]), cache.prewarm_cache_contracts())
|
|
|
|
def test_prewarm_optional_keys_and_no_recomputation(self):
|
|
result = cache.prewarm_cache_contracts(("LiveDelay",))
|
|
self.assertEqual(set(result), {"LiveDelay"})
|
|
with patch.object(cache, "schema_fingerprint", side_effect=AssertionError("unexpected cold computation")):
|
|
self.encoded("LiveDelay")
|
|
self.assertIsNotNone(cache.get_cache(self.params, "LiveDelay"))
|
|
|
|
def event_schema(self, *, extra_arm="", extra_field="", unrelated="Text", selected="Float64", enum_name="first",
|
|
valid_default="true", swap_tags=False):
|
|
directory = Path(tempfile.mkdtemp(dir=self.root))
|
|
path = directory / "event.capnp"
|
|
first, second = (2, 1) if swap_tags else (1, 2)
|
|
path.write_text(f'''@0xabcdefabcdefabd0;
|
|
struct Event @0xabcdefabcdefabd1 {{
|
|
logMonoTime @0 :UInt64;
|
|
union {{ selected @{first} :Payload; unrelated @{second} :{unrelated}; {extra_arm} }}
|
|
valid @3 :Bool = {valid_default}; {extra_field}
|
|
}}
|
|
struct Payload @0xabcdefabcdefabd2 {{ value @0 :{selected}; nested @1 :Nested; }}
|
|
struct Nested @0xabcdefabcdefabd3 {{ values @0 :List(Float64); kind @1 :Kind; }}
|
|
enum Kind @0xabcdefabcdefabd4 {{ {enum_name} @0; }}
|
|
''')
|
|
parser = capnp.SchemaParser()
|
|
module = parser.load(str(path))
|
|
self.event_parsers.append(parser)
|
|
return module.Event
|
|
|
|
def event_contract(self, root):
|
|
return patch.dict(cache.CONTRACTS, CalibrationParams=cache.CacheContract(root, "selected", "calibration-state-v1"))
|
|
|
|
def test_service_fingerprint_ignores_unrelated_union_changes(self):
|
|
original = self.event_schema()
|
|
for changed in (self.event_schema(unrelated="Data"), self.event_schema(extra_arm="added @4 :Text;")):
|
|
self.assertNotEqual(cache.schema_fingerprint(original.schema), cache.schema_fingerprint(changed.schema))
|
|
self.assertEqual(cache.event_service_fingerprint(original.schema, "selected"),
|
|
cache.event_service_fingerprint(changed.schema, "selected"))
|
|
|
|
def test_service_fingerprint_rejects_selected_or_wrapper_changes(self):
|
|
original = self.event_schema()
|
|
for options in ({"selected": "Int64"}, {"enum_name": "otherMeaning"}, {"valid_default": "false"},
|
|
{"swap_tags": True}, {"extra_field": "extra @4 :UInt64;"}):
|
|
with self.subTest(options=options):
|
|
changed = self.event_schema(**options)
|
|
self.assertNotEqual(cache.event_service_fingerprint(original.schema, "selected"),
|
|
cache.event_service_fingerprint(changed.schema, "selected"))
|
|
|
|
def test_unrelated_upgrade_restores_nondefault_typed_event_values(self):
|
|
original, changed = self.event_schema(), self.event_schema(extra_arm="added @4 :Text;")
|
|
message = original.new_message(logMonoTime=12345, valid=False,
|
|
selected={"value": 1.25, "nested": {"values": [2.5, -3.0], "kind": "first"}})
|
|
with self.event_contract(original), patch.dict(cache._FINGERPRINTS, clear=True):
|
|
cache.put_cache(self.params, "CalibrationParams", message, block=True)
|
|
raw = self.params.get("CalibrationParams")
|
|
header, _ = self.header_and_payload(raw)
|
|
self.assertEqual((header["version"], header["fingerprint_format"]), (2, cache.EVENT_FINGERPRINT_FORMAT))
|
|
with self.event_contract(changed), patch.dict(cache._FINGERPRINTS, clear=True):
|
|
payload = cache.get_cache(self.params, "CalibrationParams")
|
|
self.assertIsNotNone(payload)
|
|
with changed.from_bytes(payload) as restored:
|
|
self.assertEqual(restored.to_dict(), message.to_dict())
|
|
self.assertEqual(self.params.get("CalibrationParams"), raw)
|
|
|
|
def test_legacy_event_requires_full_current_schema_match(self):
|
|
original, changed = self.event_schema(), self.event_schema(extra_arm="added @4 :Text;")
|
|
message = original.new_message(selected={"value": 1.25})
|
|
payload = message.to_bytes()
|
|
with self.event_contract(original), patch.dict(cache._FINGERPRINTS, clear=True):
|
|
header = cache._header(cache.CONTRACTS["CalibrationParams"], "CalibrationParams", payload, version=1)
|
|
raw = self.repack(header, payload)
|
|
self.assertEqual(cache.inspect_cache("CalibrationParams", raw).status, "valid")
|
|
with self.event_contract(changed), patch.dict(cache._FINGERPRINTS, clear=True):
|
|
self.assertEqual(cache.inspect_cache("CalibrationParams", raw).status, "incompatible")
|
|
|
|
def test_current_event_legacy_values_remain_readable_without_rewrite(self):
|
|
for key in (key for key, contract in cache.CONTRACTS.items() if contract.service is not None):
|
|
with self.subTest(key=key):
|
|
payload = self.message(key).to_bytes()
|
|
header = cache._header(cache.CONTRACTS[key], key, payload, version=1)
|
|
raw = self.repack(header, payload)
|
|
self.params.put(key, raw, block=True)
|
|
self.assertEqual(cache.get_cache(self.params, key), payload)
|
|
self.assertEqual(self.params.get(key), raw)
|
|
|
|
def test_service_cache_version_and_producer_cannot_be_relabelled(self):
|
|
header, payload = self.header_and_payload(self.encoded("LiveDelay"))
|
|
for key, value in (("version", 1), ("version", 3), ("version", True), ("producer_contract", "other"),
|
|
("fingerprint_format", cache.FINGERPRINT_FORMAT), ("service", "vehicleParameters")):
|
|
changed = {**header, key: value}
|
|
with self.subTest(key=key, value=value):
|
|
self.assertEqual(cache.inspect_cache("LiveDelay", self.repack(changed, payload)).status, "incompatible")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|