mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 13:33:42 +08:00
de40ad2ff1
Vendor only the comma payload jetlink commits to (jetlink/, LICENSE, pyproject.toml, README.md) with an include list, so the exclude list goes and upstream's README stays as published; StarPilot's notes live in JETLINK.md. The root helper now ships inside the package. The adapter moves to API 3 in the next commit; until then Jetlink stays off. d24328d stands in for the jetlink release tag commit: re-run tools/vendor/sync.py jetlink_repo <tag commit> --apply before merging and update the revision in CREDITS.md and JETLINK.md with it.
453 lines
20 KiB
Python
453 lines
20 KiB
Python
"""
|
|
Copyright (c) 2026-, Zeph Leggett.
|
|
|
|
This file is part of jetlink and is licensed under the MIT License.
|
|
See the LICENSE file in the root directory for more details.
|
|
|
|
A kit a fork runs in its own CI, against its real adapter and modelds, and
|
|
the fakes jetlink's own tests share.
|
|
|
|
check_adapter(module) the adapter module: every side of Openpilot, a top
|
|
level of the standard library alone, an owner_config()
|
|
that imports nothing heavy, an in_control() that
|
|
answers a bool at once
|
|
check_seam(path) a modeld's main(): prepare() before it goes realtime,
|
|
attach() once the small model is built and its
|
|
result run, handed_over() read
|
|
check_fallback(obj) the fork's own hooks for no usable jetlink: each
|
|
answers as Off does, over HOOK_CALLS
|
|
install_guard() an audit hook a conftest starts and stops around each
|
|
test, which records a reach for the machine's own
|
|
params, /dev/shm records, sysfs or sudo
|
|
FakeJoinedModel, FakeSubMaster, RecordingLog, and tinygrad over numpy
|
|
(fake_tinygrad)
|
|
|
|
The checks return their problems, [] when there are none, so one assertion
|
|
shows them all: assertEqual(check_adapter(adapter_module), []).
|
|
|
|
Not on the owner's path, and no test framework or numpy at module level: a
|
|
fork imports this from its tests only.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from collections.abc import Iterable
|
|
from pathlib import Path
|
|
from types import ModuleType
|
|
|
|
from jetlink.openpilot.interface import Openpilot, conformance
|
|
|
|
# jetlink's own heavy half, which the owner must not reach: numpy, and urllib
|
|
# with ssl and email behind it
|
|
HEAVY_JETLINK = ('jetlink.client', 'jetlink.spec', 'jetlink.queues', 'jetlink.registry')
|
|
|
|
# -- the adapter module ----------------------------------------------------------
|
|
|
|
_PROBE = '''
|
|
import importlib, json, sys
|
|
before = set(sys.modules)
|
|
module = importlib.import_module(sys.argv[1])
|
|
found = {'import': sorted(set(sys.modules) - before)}
|
|
before = set(sys.modules)
|
|
try:
|
|
config = module.owner_config()
|
|
found['owner_config'] = sorted(set(sys.modules) - before)
|
|
from jetlink.openpilot.interface import OwnerConfig
|
|
found['config'] = isinstance(config, OwnerConfig)
|
|
except Exception as e:
|
|
found['error'] = f'{type(e).__name__}: {e}'
|
|
print(json.dumps(found))
|
|
'''
|
|
|
|
|
|
def _foreign(names, own: str, heavy, allowed=()) -> list[str]:
|
|
"""What is neither the standard library nor jetlink's light half, nor of
|
|
the fork's package `own`, nor `allowed`: by the outermost module of each."""
|
|
found = {n for n in names if n not in allowed and (n.startswith((*heavy, *HEAVY_JETLINK)) or
|
|
n.split('.')[0] not in (*sys.stdlib_module_names, 'jetlink', own))}
|
|
return sorted(n for n in found if not any(n.startswith(f + '.') for f in found))
|
|
|
|
|
|
def check_adapter(module: ModuleType, *, heavy: tuple[str, ...] = ()) -> list[str]:
|
|
"""The adapter module's problems. Its top level imports the standard
|
|
library alone, as manager and the resident owner import it. owner_config()
|
|
may import more: anything of the fork's own package that pulls in no third
|
|
party, so 'openpilot.common.params' passes unless it is named in `heavy`. A
|
|
fork should name its heavy modules there (params, swaglog, cereal). Both
|
|
in a fresh interpreter on this one's path, so a module this process has
|
|
loaded already counts."""
|
|
name = module.__name__
|
|
problems = [f"{name}.{member}(): missing" for member in ('adapter', 'owner_config')
|
|
if not callable(getattr(module, member, None))]
|
|
if problems:
|
|
return problems
|
|
# on this interpreter's path, as a fork's CI has set it up
|
|
try:
|
|
run = subprocess.run([sys.executable, '-c', _PROBE, name], capture_output=True, text=True, timeout=120,
|
|
env={**os.environ, 'PYTHONPATH': os.pathsep.join(p for p in sys.path if p)})
|
|
# the probe's line is the last: the module may print as it imports
|
|
found = json.loads(run.stdout.strip().splitlines()[-1])
|
|
except subprocess.TimeoutExpired:
|
|
return [f"{name} did not import and build its owner_config() in a fresh interpreter within 120 s"]
|
|
except (IndexError, ValueError):
|
|
return [f"{name} does not import in a fresh interpreter: {run.stderr.strip()[-500:]}"]
|
|
# its own packages' __init__s, which run first
|
|
if top := _foreign(found['import'], '', heavy, {name.rsplit('.', i)[0] for i in range(name.count('.') + 1)}):
|
|
problems.append(f"{name}'s top level imports {top}: manager and the resident owner import it")
|
|
if 'error' in found:
|
|
problems.append(f"owner_config() raised {found['error']}")
|
|
elif owner := _foreign(found['owner_config'], name.split('.')[0], heavy):
|
|
problems.append(f"owner_config() imports {owner}: the resident owner calls it")
|
|
if found.get('config') is False:
|
|
problems.append("owner_config(): not an OwnerConfig")
|
|
try:
|
|
op = module.adapter()
|
|
except Exception as e:
|
|
return [*problems, f"adapter() raised {type(e).__name__}: {e}"]
|
|
problems += [f"adapter: {p}" for p in conformance(op, Openpilot)]
|
|
if not callable(getattr(op, 'in_control', None)):
|
|
return problems
|
|
try:
|
|
op.in_control() # the first may set up what it reads
|
|
start = time.monotonic()
|
|
answer, took = op.in_control(), time.monotonic() - start
|
|
except Exception as e:
|
|
return [*problems, f"in_control() raised {type(e).__name__}: {e}"]
|
|
if not isinstance(answer, bool):
|
|
problems.append(f"in_control() answered {answer!r}, not a bool")
|
|
if took > 0.05:
|
|
problems.append(f"in_control() took {took * 1e3:.0f} ms: modeld asks before every frame")
|
|
return problems
|
|
|
|
|
|
# -- the modeld seam ---------------------------------------------------------------
|
|
|
|
def check_seam(modeld_path: str | Path) -> list[str]:
|
|
"""The problems of a modeld's main() with the hooks, read off its source
|
|
(importing a modeld costs tinygrad and a vision stream). The hooks are
|
|
found as the receiver of attach(model, small, cam_w, cam_h): the fork's
|
|
module (jetlink_adapter.attach) or its Hooks (HOOKS.attach) alike. Calls
|
|
are ordered as the source reads, so one in a branch that never runs (an
|
|
`if False:`) counts as made. state() is the fork's to publish where it has
|
|
a message for it, so it is not asked for."""
|
|
path = Path(modeld_path)
|
|
main = next((n for n in ast.parse(path.read_text(), filename=str(path)).body
|
|
if isinstance(n, ast.FunctionDef) and n.name == 'main'), None)
|
|
if main is None:
|
|
return [f"{path.name}: no main()"]
|
|
|
|
def at(n: ast.AST) -> tuple[int, int]:
|
|
return n.lineno, n.col_offset
|
|
nodes = list(ast.walk(main))
|
|
calls = sorted((n for n in nodes if isinstance(n, ast.Call)), key=at)
|
|
|
|
def called(name: str, facade: str | None = None) -> list[tuple[int, int]]:
|
|
return [at(c) for c in calls if (c.func.attr if isinstance(c.func, ast.Attribute) else getattr(c.func, 'id', None)) == name
|
|
and (facade is None or isinstance(c.func, ast.Attribute) and ast.unparse(c.func.value) == facade)]
|
|
|
|
def stored(target: str) -> list[tuple[int, int]]:
|
|
return [at(n) for n in nodes if isinstance(getattr(n, 'ctx', None), ast.Store) and ast.unparse(n) == target]
|
|
|
|
attach = next((c for c in calls if isinstance(c.func, ast.Attribute) and c.func.attr == 'attach'
|
|
and len(c.args) + len(c.keywords) == 4), None)
|
|
if attach is None:
|
|
return [f"{path.name}: main() never calls attach(model, small, cam_w, cam_h)"]
|
|
facade = ast.unparse(attach.func.value)
|
|
problems, prepare, realtime = [], called('prepare', facade), called('config_realtime_process')
|
|
if not prepare:
|
|
problems.append(f"main() never calls {facade}.prepare()")
|
|
elif realtime and prepare[0] >= realtime[0]:
|
|
problems.append("prepare() comes after config_realtime_process(): the GPU's thread would inherit the realtime core")
|
|
elif prepare[0] >= at(attach):
|
|
problems.append("attach() comes before prepare(), so the link never joins")
|
|
if not realtime:
|
|
problems.append("main() never calls config_realtime_process()")
|
|
small = attach.args[1] if len(attach.args) > 1 else next((k.value for k in attach.keywords if k.arg == 'small'), None)
|
|
if isinstance(small, ast.Name) and not any(p < at(attach) for p in stored(small.id)):
|
|
problems.append(f"attach() comes before {small.id} is built")
|
|
# the name its result is bound to, which the frame loop must run
|
|
bound = next((t.id for n in nodes if isinstance(n, (ast.Assign, ast.AnnAssign)) and n.value is attach
|
|
for t in getattr(n, 'targets', [getattr(n, 'target', None)]) if isinstance(t, ast.Name)), None)
|
|
if bound is None:
|
|
problems.append("attach()'s result is dropped: modeld would never run the joined model")
|
|
elif not any(p > at(attach) for p in called('run', bound)):
|
|
problems.append(f"attach()'s result, {bound}, is never run: modeld would keep driving the model it had")
|
|
elif not any(p > at(attach) for p in stored(f'{bound}.frame_drop_ratio')):
|
|
problems.append(f"main() never writes {bound}.frame_drop_ratio: a host that drops camera frames would never hand back")
|
|
if not called('handed_over', facade):
|
|
problems.append(f"main() never calls {facade}.handed_over(model)")
|
|
return [f"{path.name}: {p}" for p in problems]
|
|
|
|
|
|
# -- the fork's fallback ------------------------------------------------------------------
|
|
|
|
_MODEL = object()
|
|
# every hook a fork's lines may call, and the arguments each is checked with:
|
|
# the one list of them (jetlink's tests hold Hooks to it)
|
|
HOOK_CALLS = {'should_run': [(False, None, None)], 'status': [()], 'reason': [()], 'prepare': [(False,), (True,)],
|
|
'attach': [(_MODEL, object(), 1928, 1208)], 'handed_over': [(_MODEL,)], 'state': [(_MODEL,)],
|
|
'shutdown_step': [(False,), (True, 'test')], 'request_shutdown': [('test',)], 'shutdown_pending': [()],
|
|
'model_state': [('f' * 40,)], 'should_extend_catalog': [()], 'extend_catalog': [({'bundles': []},)],
|
|
'runtime': [()], 'swap_events': [(0.01,)]}
|
|
|
|
|
|
def check_fallback(fallback, hooks: Iterable[str] | None = None, *, link_on=None) -> list[str]:
|
|
"""The problems of the fork's own hooks for a jetlink that is missing or
|
|
too old to have Off: each of `hooks`, the ones the fork's lines call (by
|
|
default every hook it has, so pass them to have a missing one named),
|
|
there and answering as Off does, the link off. reason() is the fork's to
|
|
word, a str or None, and None while `link_on()`, the fork's read of the
|
|
setting, says off: nobody who left the link off is nagged. swap_events()
|
|
may be None where the fork's lines check for it. link_on() reads the
|
|
params store, so the test sets PARAMS_ROOT or OPENPILOT_PREFIX first, or
|
|
it reads the machine's own."""
|
|
from jetlink.openpilot.events import Flags
|
|
from jetlink.openpilot.hooks import Off
|
|
|
|
def flags(tracker):
|
|
found = tracker.update(True, False, True, state='ready')
|
|
return tuple(bool(getattr(found, f)) for f in Flags._fields), bool(tracker.settling)
|
|
problems = []
|
|
for name in hooks if hooks is not None else [n for n in HOOK_CALLS if hasattr(fallback, n)]:
|
|
if name not in HOOK_CALLS or not callable(getattr(fallback, name, None)):
|
|
problems.append(f"{name}(): missing" if name in HOOK_CALLS else f"{name}(): not a hook")
|
|
continue
|
|
for args in HOOK_CALLS[name]:
|
|
try:
|
|
got, want = getattr(fallback, name)(*args), getattr(Off(), name)(*args)
|
|
if name == 'swap_events':
|
|
got, want = (want, want) if got is None else (flags(got), flags(want))
|
|
except Exception as e:
|
|
problems.append(f"{name}() raised {type(e).__name__}: {e}")
|
|
continue
|
|
if name == 'reason':
|
|
try:
|
|
off = link_on is not None and not link_on()
|
|
except Exception as e:
|
|
problems.append(f"link_on() raised {type(e).__name__}: {e}")
|
|
continue
|
|
same = got is None or (isinstance(got, str) and not off)
|
|
else:
|
|
same = got is want if any(want is a for a in args) else (type(got), got) == (type(want), want)
|
|
if not same:
|
|
problems.append(f"{name}() answered {got!r}, where the link off is {want!r}")
|
|
return problems
|
|
|
|
|
|
# -- the guard ------------------------------------------------------------------------
|
|
|
|
# the machine's own params store, jetlink's records, sysfs, FunctionFS and a
|
|
# PC's openpilot params
|
|
FORBIDDEN = ('/data/', '/dev/shm/', '/dev/ffs', '/sys/', str(Path.home() / '.comma') + os.sep)
|
|
# read by a library on import, never jetlink's own reach: tinygrad's CPU quota
|
|
ALLOWED = ('/sys/fs/cgroup/cpu.max',)
|
|
# the audit events Guard reads a path from, and the argument it is
|
|
_PATH_ARG = {'open': 0, 'os.listdir': 0, 'os.scandir': 0, 'socket.connect': 1}
|
|
|
|
|
|
class Guard:
|
|
"""Records, between start() and stop(), every open, listing, socket connect
|
|
or root-script run that reaches a forbidden path. Recorded, not refused:
|
|
many of the paths are read inside `except OSError`, so a refusal would only
|
|
hide the reach. A test that reaches the live params store can clear a
|
|
real setting such as JetlinkSpec."""
|
|
|
|
def __init__(self, forbidden: tuple[str, ...] = FORBIDDEN):
|
|
self.forbidden = tuple(forbidden)
|
|
self.touched: list[str] | None = None
|
|
|
|
def start(self) -> None:
|
|
self.touched = []
|
|
|
|
def stop(self) -> list[str]:
|
|
"""What was reached since start(), once each."""
|
|
touched, self.touched = self.touched or [], None
|
|
return sorted(set(touched))
|
|
|
|
def __call__(self, event: str, args) -> None:
|
|
touched = self.touched
|
|
if touched is None:
|
|
return
|
|
if event == 'subprocess.Popen':
|
|
# the root script is sudo on a comma
|
|
argv = args[1] if len(args) > 1 and isinstance(args[1], (list, tuple)) else []
|
|
if (hit := next((str(a) for a in argv if 'jetlink-root.sh' in str(a) or str(a) == 'sudo'), None)) is not None:
|
|
touched.append(f"{event} {hit}")
|
|
return
|
|
index = _PATH_ARG.get(event)
|
|
value = args[index] if index is not None and len(args) > index else None
|
|
if isinstance(value, (str, bytes, os.PathLike)):
|
|
path = os.path.abspath(os.fsdecode(value))
|
|
if (path + os.sep).startswith(self.forbidden) and path not in ALLOWED:
|
|
touched.append(f"{event} {path}")
|
|
|
|
|
|
def install_guard(forbidden: tuple[str, ...] = FORBIDDEN) -> Guard:
|
|
"""A Guard, installed as this process's audit hook (for good: Python has no
|
|
removing one). Install once, in a conftest, and start and stop it around
|
|
each test."""
|
|
guard = Guard(forbidden)
|
|
sys.addaudithook(guard)
|
|
return guard
|
|
|
|
|
|
# -- fakes ---------------------------------------------------------------------------
|
|
|
|
class FakeJoinedModel:
|
|
"""What attach() returns, for a fork's modeld tests: drives as `small`;
|
|
hand_over() is a swap or a hand-back."""
|
|
|
|
def __init__(self, small=None, *, state: str = 'joining', chestnut: bool = False, constants=None):
|
|
self.small = small
|
|
self.handovers = 0
|
|
self.big_model_state = state
|
|
self.chestnut = chestnut
|
|
self.constants = constants
|
|
self.LAT_SMOOTH_SECONDS = self.LONG_SMOOTH_SECONDS = self.frame_drop_ratio = self.lat_delay = 0.0
|
|
self.runs, self.closed = 0, False
|
|
|
|
def hand_over(self, state: str = 'running') -> None:
|
|
self.handovers += 1
|
|
self.big_model_state = state
|
|
|
|
def run(self, bufs, transforms, inputs, after_enqueue=None):
|
|
self.runs += 1
|
|
return self.small.run(bufs, transforms, inputs, after_enqueue) if self.small is not None else {}
|
|
|
|
def get_action_from_model(self, *args):
|
|
return ('action', args)
|
|
|
|
def close(self) -> None:
|
|
self.closed = True
|
|
|
|
|
|
class _Blank:
|
|
"""A message never received, as cereal's SubMaster holds one: every field
|
|
reads falsy, a nested one too."""
|
|
|
|
def __getattr__(self, name: str):
|
|
if name.startswith('__'):
|
|
raise AttributeError(name)
|
|
return self
|
|
|
|
def __bool__(self) -> bool:
|
|
return False
|
|
|
|
|
|
class FakeSubMaster:
|
|
"""cereal's SubMaster over fixed messages, for an adapter's engaged() and
|
|
in_control() tests: what Stock and StarPilot's adapter read of it. A
|
|
service given a message is seen, alive and valid; any other is none of
|
|
them until update() receives it, and reads as a blank message whose every
|
|
field is falsy. A test changes `data`, `alive` and `valid` as it goes.
|
|
update() keeps its timeouts: in_control() is on modeld's frame thread and
|
|
must never wait (all 0). In for the real one: mock.patch.object(messaging,
|
|
'SubMaster', lambda services: FakeSubMaster(services, **messages)). Unlike
|
|
cereal's: a service is seen from construction, not from its first receive;
|
|
a blank field is falsy but equals nothing (a capnp default equals 0 or
|
|
False); and there is no updated, frame, freq_ok or logMonoTime."""
|
|
|
|
def __init__(self, services: Iterable[str] = (), **messages):
|
|
self.services = list(dict.fromkeys([*services, *messages]))
|
|
self.data = dict(messages)
|
|
self.seen = {s: s in messages for s in self.services}
|
|
self.alive = dict(self.seen)
|
|
self.valid = dict(self.seen)
|
|
self.timeouts: list[float] = []
|
|
|
|
def update(self, timeout: float = 100) -> None:
|
|
self.timeouts.append(timeout)
|
|
for s in self.services:
|
|
self.seen[s] = self.seen[s] or (s in self.data and self.alive[s])
|
|
|
|
def all_alive(self, service_list=None) -> bool:
|
|
return all(self.alive[s] for s in service_list or self.services)
|
|
|
|
def all_valid(self, service_list=None) -> bool:
|
|
return all(self.valid[s] for s in service_list or self.services)
|
|
|
|
def __getitem__(self, service: str):
|
|
if service not in self.seen:
|
|
raise KeyError(service) # not subscribed, as cereal's
|
|
return self.data.get(service, _BLANK)
|
|
|
|
|
|
_BLANK = _Blank()
|
|
|
|
|
|
def _level(name: str):
|
|
def log(self, msg, *args, **kwargs):
|
|
try:
|
|
text = str(msg) % args if args else str(msg)
|
|
except (TypeError, ValueError):
|
|
text = f"{msg} {args}"
|
|
self.records.append((name, text))
|
|
return log
|
|
|
|
|
|
class RecordingLog:
|
|
"""cloudlog's face, keeping every line: (level, message with its arguments)."""
|
|
debug, info, warning, error, exception, critical = map(_level, ('debug', 'info', 'warning', 'error', 'exception', 'critical'))
|
|
|
|
def __init__(self):
|
|
self.records: list[tuple[str, str]] = []
|
|
|
|
def lines(self, level: str | None = None) -> list[str]:
|
|
return [text for lvl, text in self.records if level is None or lvl == level]
|
|
|
|
def has(self, fragment: str, level: str | None = None) -> bool:
|
|
return any(fragment in text for text in self.lines(level))
|
|
|
|
|
|
# The fork's tinygrad is not jetlink's to install. What jetlink calls of it is
|
|
# small, and these stand-ins do it with numpy; the fork's tests run the real one.
|
|
|
|
class FakeTensor:
|
|
def __init__(self, data=None, device=None, dtype=None):
|
|
import numpy as np
|
|
self.array = np.asarray(data) if data is not None else np.zeros(0)
|
|
self.device = device
|
|
|
|
def realize(self, *others):
|
|
return self
|
|
|
|
def assign(self, value):
|
|
self.array[...] = value
|
|
return self
|
|
|
|
def numpy(self):
|
|
return self.array
|
|
|
|
|
|
class FakeDevice:
|
|
DEFAULT = 'CPU'
|
|
|
|
|
|
def fake_jit(fn=None, prune=False):
|
|
"""TinyJit as a decorator or a wrapper: the function as it is."""
|
|
return (lambda f: f) if fn is None else fn
|
|
|
|
|
|
def fake_tinygrad() -> dict[str, ModuleType]:
|
|
"""sys.modules entries for what jetlink imports of tinygrad; patch them in
|
|
with mock.patch.dict(sys.modules, fake_tinygrad())."""
|
|
from types import SimpleNamespace
|
|
|
|
def module(name, **attrs):
|
|
m = ModuleType(name)
|
|
m.__dict__.update(attrs)
|
|
return m
|
|
return {
|
|
'tinygrad': module('tinygrad', Tensor=FakeTensor, TinyJit=fake_jit, Device=FakeDevice),
|
|
'tinygrad.tensor': module('tinygrad.tensor', Tensor=FakeTensor),
|
|
'tinygrad.device': module('tinygrad.device', Device=FakeDevice),
|
|
'tinygrad.helpers': module('tinygrad.helpers', PARALLEL=SimpleNamespace(value=8)),
|
|
'tinygrad.engine': module('tinygrad.engine'),
|
|
'tinygrad.engine.jit': module('tinygrad.engine.jit', TinyJit=fake_jit),
|
|
}
|