mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-07 04:53:47 +08:00
365 lines
15 KiB
Python
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
|