Files
StarPilot/openpilot/starpilot/models/receipt.py
T
2026-10-24 23:20:11 -05:00

365 lines
15 KiB
Python

"""Small volatile modeld load receipt; no model artifact is deserialized here."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import hashlib
import json
import os
from pathlib import Path
import re
import secrets
import stat
import time
from openpilot.common.hardware.hw import Paths
from openpilot.starpilot.models.catalog import BUNDLED_CURRENT, model_entries
from openpilot.starpilot.models.status import ModelLoad, ModelVariant
MAX_RECEIPT_BYTES = 1024
MAX_ARTIFACT_BYTES = 2 * 1024 * 1024 * 1024
_PREFIX = re.compile(r"[A-Za-z0-9_-]{1,32}\Z")
_DIGEST = re.compile(r"[0-9a-f]{64}\Z")
_REASONS = {None, "chestnut-load-failed", "chestnut-run-stalled", "selected-load-failed"}
class ReceiptUnavailable(ValueError):
pass
def receipt_path() -> Path:
prefix = os.environ.get("OPENPILOT_PREFIX", "d")
if _PREFIX.fullmatch(prefix) is None:
raise ReceiptUnavailable("invalid IPC namespace")
return Path(Paths.shm_path()) / f"starpilot_modeld_{prefix}" / "load-v1.json"
def _root_fd(path: Path, *, create: bool) -> int:
if create:
try:
os.mkdir(path.parent, 0o700)
except FileExistsError:
pass
fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC)
try:
info = os.fstat(fd)
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o700:
raise ReceiptUnavailable("model receipt directory unavailable")
return fd
except BaseException:
os.close(fd)
raise
def process_start_ticks(pid: int, *, proc_root: Path = Path("/proc")) -> int:
"""Return Linux's immutable per-process start ticks; zero means unavailable."""
if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0:
return 0
try:
raw = (proc_root / str(pid) / "stat").read_bytes()
tail = raw[raw.rfind(b")") + 2:].split()
ticks = int(tail[19])
return ticks if ticks > 0 else 0
except (OSError, ValueError, IndexError):
return 0
def _identity(info: os.stat_result) -> tuple[int, int, int, int, int]:
return info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns, info.st_ctime_ns
@dataclass(frozen=True)
class PreparedArtifact:
identity: tuple[int, int, int, int, int]
sha256: str
def artifact_identity(path: Path) -> tuple[int, int, int, int, int]:
"""Capture the installed file identity immediately before ModelState opens it."""
try:
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW | os.O_CLOEXEC)
except OSError as exc:
raise ReceiptUnavailable("compiled artifact unavailable") from exc
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or info.st_size <= 0 or info.st_size > MAX_ARTIFACT_BYTES:
raise ReceiptUnavailable("compiled artifact is not a bounded regular file")
return _identity(info)
finally:
os.close(fd)
def artifact_digest(path: Path, expected_identity: tuple[int, int, int, int, int]) -> str:
"""Hash a bounded installed compiled artifact once, without following a link or FIFO."""
try:
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW | os.O_CLOEXEC)
except OSError as exc:
raise ReceiptUnavailable("compiled artifact unavailable") from exc
try:
before = os.fstat(fd)
if (not stat.S_ISREG(before.st_mode) or before.st_size <= 0 or before.st_size > MAX_ARTIFACT_BYTES or
_identity(before) != expected_identity):
raise ReceiptUnavailable("compiled artifact is not a bounded regular file")
digest = hashlib.sha256()
remaining = before.st_size
while remaining:
chunk = os.read(fd, min(1024 * 1024, remaining))
if not chunk:
raise ReceiptUnavailable("compiled artifact changed during hash")
digest.update(chunk)
remaining -= len(chunk)
after = os.fstat(fd)
named = os.stat(path, follow_symlinks=False)
if _identity(before) != _identity(after) or _identity(before) != _identity(named):
raise ReceiptUnavailable("compiled artifact changed during hash")
return digest.hexdigest()
except OSError as exc:
raise ReceiptUnavailable("compiled artifact unavailable") from exc
finally:
os.close(fd)
def prepare_artifact(path: Path, expected_identity: tuple[int, int, int, int, int]) -> PreparedArtifact:
"""Complete bounded installed-file hashing before modeld's live loop starts."""
return PreparedArtifact(expected_identity, artifact_digest(path, expected_identity))
def _pairs(items: list[tuple[str, object]]) -> dict[str, object]:
result: dict[str, object] = {}
for key, value in items:
if key in result:
raise ReceiptUnavailable("duplicate model receipt field")
result[key] = value
return result
def _positive_int(value: object) -> bool:
return isinstance(value, int) and not isinstance(value, bool) and 0 < value <= (1 << 64) - 1
def _decode(raw: bytes) -> ModelLoad:
entries = model_entries()
try:
record = json.loads(raw.decode("utf-8"), object_pairs_hook=_pairs)
except (UnicodeDecodeError, json.JSONDecodeError, RecursionError) as exc:
raise ReceiptUnavailable("malformed model receipt") from exc
if not isinstance(record, dict) or set(record) != {"version", "pid", "processStartTicks", "loadedMonoNs",
"modelId", "variant", "artifactSha256", "fallbackReason"}:
raise ReceiptUnavailable("invalid model receipt fields")
if record["version"] != 1 or type(record["version"]) is not int:
raise ReceiptUnavailable("unsupported model receipt")
if not all(_positive_int(record[key]) for key in ("pid", "processStartTicks", "loadedMonoNs")):
raise ReceiptUnavailable("invalid model process identity")
if not isinstance(record["modelId"], str) or record["modelId"] not in entries:
raise ReceiptUnavailable("unknown loaded model")
try:
variant = ModelVariant(record["variant"])
except (ValueError, TypeError) as exc:
raise ReceiptUnavailable("unknown model variant") from exc
if record["modelId"] != BUNDLED_CURRENT and entries[record["modelId"]].uses_external_gpu != (variant is ModelVariant.CHESTNUT):
raise ReceiptUnavailable("model receipt hardware mismatch")
digest = record["artifactSha256"]
if not isinstance(digest, str) or _DIGEST.fullmatch(digest) is None or record["fallbackReason"] not in _REASONS:
raise ReceiptUnavailable("invalid model artifact identity")
return ModelLoad(record["pid"], record["processStartTicks"], record["loadedMonoNs"],
record["modelId"], variant, digest, record["fallbackReason"])
def read_receipt(path: Path | None = None) -> ModelLoad | None:
"""Missing or malformed volatile state is unavailable, never a default identity."""
try:
path = receipt_path() if path is None else path
root_fd = _root_fd(path, create=False)
except (OSError, ReceiptUnavailable):
return None
try:
fd = os.open(path.name, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=root_fd)
except (OSError, ReceiptUnavailable):
os.close(root_fd)
return None
try:
before = os.fstat(fd)
if not stat.S_ISREG(before.st_mode) or before.st_size > MAX_RECEIPT_BYTES:
return None
raw = os.read(fd, MAX_RECEIPT_BYTES + 1)
after = os.fstat(fd)
named = os.stat(path.name, dir_fd=root_fd, follow_symlinks=False)
if len(raw) > MAX_RECEIPT_BYTES or _identity(before) != _identity(after) or _identity(before) != _identity(named):
return None
return _decode(raw)
except (OSError, ReceiptUnavailable, TypeError):
return None
finally:
os.close(fd)
os.close(root_fd)
def write_receipt(load: ModelLoad, path: Path | None = None) -> None:
"""Replace one volatile record after the selected model has warmed successfully."""
path = receipt_path() if path is None else path
raw = json.dumps({"version": 1, "pid": load.pid, "processStartTicks": load.process_start_ticks,
"loadedMonoNs": load.loaded_mono_ns, "modelId": load.model_id,
"variant": load.variant.value, "artifactSha256": load.artifact_sha256,
"fallbackReason": load.fallback_reason}, separators=(",", ":"), sort_keys=True).encode()
if len(raw) > MAX_RECEIPT_BYTES or _decode(raw) != load:
raise ReceiptUnavailable("invalid model receipt")
root_fd = _root_fd(path, create=True)
temporary = f".load-{secrets.token_hex(8)}"
try:
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC,
0o600, dir_fd=root_fd)
try:
with os.fdopen(descriptor, "wb") as stream:
stream.write(raw)
except BaseException:
os.unlink(temporary, dir_fd=root_fd)
raise
os.replace(temporary, path.name, src_dir_fd=root_fd, dst_dir_fd=root_fd)
finally:
try:
os.unlink(temporary, dir_fd=root_fd)
except FileNotFoundError:
pass
os.close(root_fd)
def recorded_model_message(load: ModelLoad) -> str:
return json.dumps({'process': load.pid, 'msg': {'event': 'modeld.loaded', 'version': 1,
'pid': load.pid, 'processStartTicks': load.process_start_ticks,
'loadedMonoNs': load.loaded_mono_ns, 'modelId': load.model_id,
'variant': load.variant.value, 'artifactSha256': load.artifact_sha256,
'fallbackReason': load.fallback_reason}}, separators=(',', ':'))
def log_loaded_model(load: ModelLoad) -> None:
"""Record the actual initialized runner once per load or fallback in the rlog."""
from openpilot.common.swaglog import cloudlog
cloudlog.event("modeld.loaded", version=1, pid=load.pid, processStartTicks=load.process_start_ticks,
loadedMonoNs=load.loaded_mono_ns, modelId=load.model_id, variant=load.variant.value,
artifactSha256=load.artifact_sha256, fallbackReason=load.fallback_reason)
def logged_model_load(raw: str) -> ModelLoad | None:
"""Read our load event, never a requested selection or a free-form load message."""
if len(raw) > 8192:
return None
try:
record = json.loads(raw, object_pairs_hook=_pairs)
payload = record.get("msg") if isinstance(record, dict) else None
if not isinstance(payload, dict) or payload.get("event") != "modeld.loaded":
return None
payload = {key: value for key, value in payload.items() if key != "event"}
load = _decode(json.dumps(payload).encode())
return load if type(record.get("process")) is int and record["process"] == load.pid else None
except (ReceiptUnavailable, ValueError, TypeError, RecursionError):
return None
def clear_receipt(path: Path | None = None) -> None:
"""Invalidate a prior run without following the previous file or directory."""
path = receipt_path() if path is None else path
try:
root_fd = _root_fd(path, create=False)
except (OSError, ReceiptUnavailable):
return
try:
os.unlink(path.name, dir_fd=root_fd)
except FileNotFoundError:
pass
finally:
os.close(root_fd)
def record_prepared(prepared: PreparedArtifact, variant: ModelVariant, fallback_reason: str | None = None,
*, model_id: str = BUNDLED_CURRENT, receipt_file: Path | None = None, pid: int | None = None,
start_ticks: int | None = None, now_mono_ns: int | None = None) -> ModelLoad:
"""Write a tiny receipt from a startup-verified artifact; never hash on fallback."""
pid = os.getpid() if pid is None else pid
start_ticks = process_start_ticks(pid) if start_ticks is None else start_ticks
now_mono_ns = time.monotonic_ns() if now_mono_ns is None else now_mono_ns
if not _positive_int(start_ticks) or not _positive_int(now_mono_ns):
raise ReceiptUnavailable("process identity unavailable")
load = ModelLoad(pid, start_ticks, now_mono_ns, model_id, variant,
prepared.sha256, fallback_reason)
write_receipt(load, receipt_file)
return load
class ModelReceiptOwner:
"""Best-effort modeld hook: diagnostic failures never stop inference."""
def __init__(self, receipt_file: Path | None = None, report: Callable[[str], None] | None = None,
record: Callable[[ModelLoad], None] = log_loaded_model):
self.receipt_file = receipt_file
self.report = report if report is not None else lambda _reason: None
self.record = record
self.load = None
self._identity_since_ns = time.monotonic_ns()
self._last_publication_ns = 0
def start(self) -> None:
self.load = None
self._identity_since_ns = time.monotonic_ns()
self._last_publication_ns = 0
try:
clear_receipt(self.receipt_file)
except Exception:
self.report("clear-failed")
def capture(self, path: Path) -> tuple[int, int, int, int, int] | None:
try:
return artifact_identity(path)
except Exception:
self.report("artifact-unavailable")
return None
def prepare(self, path: Path, expected_identity: tuple[int, int, int, int, int] | None) -> PreparedArtifact | None:
if expected_identity is None:
self.report("artifact-identity-unavailable")
return None
try:
return prepare_artifact(path, expected_identity)
except Exception:
self.report("artifact-digest-unavailable")
return None
def publish(self, publisher, now_ns: int) -> bool:
"""Repeat actual load identity so routes started after warm-up retain it."""
if self._last_publication_ns and now_ns - self._last_publication_ns < 5_000_000_000:
return False
try:
from openpilot.cereal import messaging
message = messaging.new_message(None, valid=True, logMonoTime=now_ns)
message.logMessage = (recorded_model_message(self.load) if self.load is not None else
json.dumps({'msg': {'event': 'modeld.identity-unavailable',
'sinceMonoNs': self._identity_since_ns}}))
publisher.send('modelIdentity', message)
self._last_publication_ns = now_ns
return True
except Exception:
self.report('publication-failed')
return False
def loaded(self, prepared: PreparedArtifact | None, variant: ModelVariant,
fallback_reason: str | None = None, *, model_id: str = BUNDLED_CURRENT) -> bool:
# An unknown/failed replacement (including Jetlink handover) must retire
# the previous volatile identity as well as the in-memory heartbeat.
self.start()
if prepared is None:
self.report("prepared-artifact-unavailable")
return False
try:
load = record_prepared(prepared, variant, fallback_reason, model_id=model_id, receipt_file=self.receipt_file)
except Exception:
self.report("record-failed")
return False
self.load = load
self._last_publication_ns = 0
try:
self.record(load)
except Exception:
self.report("log-failed")
return True