Files
StarPilot/tools/host_runtime.py
T
firestar5683 6c8966d044 Jetlink Client
2026-10-05 12:21:26 -05:00

324 lines
15 KiB
Python
Executable File

#!/usr/bin/env python3
"""StarPilot desktop commands with an independent build tree, index and Params."""
from __future__ import annotations
from contextlib import contextmanager
import fcntl
import hashlib
import json
import os
from pathlib import Path
import platform
import shutil
import signal
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[1]
HELP = """Usage: ./dev <command> [args...]
./tool <command> [args...] ./tools/host <command> [args...]
c3 / c4 Build and launch the large / compact StarPilot Raylib UI.
onroad Build replay and launch route UI(s); see ./onroad --help.
replay Build and run upstream replay.
cabana Build and run Cabana in its own concurrent cache.
plotjuggler Run PlotJuggler (alias: juggle).
galaxy Run authenticated Galaxy on loopback (optional --port).
python Run Python with current source and native extensions.
pytest Run pytest with current source and native extensions.
shell Open a shell in the isolated host worktree.
sync [bucket] Refresh all caches, or shared/cabana, without launching tools.
help Show this help without installing or building anything.
Build/launch commands accept an optional leading jobs count, e.g. ./c3 8.
The host cache is .host_runtime/<system>-<architecture>/<bucket>/.
Native build artifacts, editable installs and Params stay in that cache.
Shared commands wait while another shared command is running; Cabana is separate.
./build remains the Linux ARM64 device build, including ./build --panda.
"""
COMMANDS = {'c3', 'c4', 'onroad', 'replay', 'cabana', 'plotjuggler', 'galaxy', 'python', 'pytest', 'shell', 'sync'}
BUCKET_ALIASES = dict.fromkeys(('shared', 'default', 'ui', 'c3', 'c4', 'onroad', 'replay', 'shell', 'plotjuggler', 'juggle'), 'shared')
BUCKET_ALIASES['cabana'] = 'cabana'
VENDORS = ('msgq_repo', 'opendbc_repo', 'rednose_repo', 'teleoprtc_repo', 'tinygrad_repo', 'jetlink_repo')
# Never inherit a device/cross compiler or another project's editable imports.
REMOVE_ENV = ('PYTHONPATH', 'PYTHONHOME', 'VIRTUAL_ENV', 'CC', 'CXX', 'CFLAGS', 'CXXFLAGS', 'CPPFLAGS', 'LDFLAGS',
'LD_LIBRARY_PATH', 'DYLD_LIBRARY_PATH', 'PKG_CONFIG_PATH', 'CPATH', 'LIBRARY_PATH',
'GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR')
EXCLUDE_DIRS = {'.git', '.venv', '.venv-linux-arm64', '.host_runtime', '.comma_sysroot', '.cache', '__pycache__'}
def run(arguments, *, cwd, env=None, capture=False):
return subprocess.run([str(arg) for arg in arguments], cwd=cwd, env=env, check=True,
stdout=subprocess.PIPE if capture else None, text=capture)
def git(root, *arguments):
env = {key: value for key, value in os.environ.items() if key not in REMOVE_ENV}
return run(['git', *arguments], cwd=root, env=env, capture=True).stdout
def parse(arguments):
command = arguments[0] if arguments else 'help'
if command in ('help', '-h', '--help'):
return 'help', 0, []
if command == 'juggle':
command = 'plotjuggler'
if command not in COMMANDS:
raise ValueError(f'Unknown developer command: {command}. Run ./dev help.')
args = list(arguments[1:])
jobs = max(1, os.cpu_count() or 8)
if command not in ('python', 'pytest', 'shell', 'sync') and args and args[0].isdigit():
jobs = int(args.pop(0))
if jobs < 1:
raise ValueError('The jobs count must be positive.')
if command == 'sync' and (len(args) > 1 or (args and args[0] not in BUCKET_ALIASES)):
raise ValueError('Usage: ./dev sync [shared|cabana]')
return command, jobs, args
def inside(path, root):
"""Reject cache symlinks escaping the disposable tree before any mutation."""
if not path.resolve().is_relative_to(root.resolve()):
raise RuntimeError(f'Cache path escapes its root: {path}')
return path
def same_contents(source, destination):
if not destination.is_file() or source.stat().st_size != destination.stat().st_size:
return False
with source.open('rb') as left, destination.open('rb') as right:
while block := left.read(1024 * 1024):
if block != right.read(len(block)):
return False
return True
class HostRuntime:
def __init__(self, root, bucket, *, system=None, machine=None):
self.root = Path(root).resolve()
self.system = system or platform.system()
machine = machine or platform.machine()
if self.system not in ('Darwin', 'Linux'):
raise RuntimeError('Desktop tools support macOS and Linux.')
self.cache = self.root / '.host_runtime' / f'{self.system.lower()}-{machine}' / bucket
if (self.root / '.host_runtime').is_symlink():
raise RuntimeError('.host_runtime must be a local directory, not a symlink.')
inside(self.cache, self.root / '.host_runtime')
self.work = self.cache / 'worktree'
self.venv = self.cache / 'venv'
self.prefix = 'starpilot-dev-' + hashlib.sha256(str(self.cache).encode()).hexdigest()[:12]
self.lock_fd = None
@contextmanager
def locked(self):
self.cache.mkdir(parents=True, exist_ok=True)
lock_path = inside(self.cache / 'lock', self.cache)
with lock_path.open('a+') as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
print(f'Waiting for the {self.cache.name} host session to exit…', flush=True)
fcntl.flock(lock, fcntl.LOCK_EX)
lock.seek(0)
lock.truncate()
lock.write(str(os.getpid()))
lock.flush()
self.lock_fd = lock.fileno()
try:
yield
finally:
self.lock_fd = None
fcntl.flock(lock, fcntl.LOCK_UN)
def sync(self):
inside(self.work, self.cache).mkdir(parents=True, exist_ok=True)
# Include uncommitted development, exclude ignored native/device outputs.
listed = git(self.root, 'ls-files', '--cached', '--others', '--exclude-standard', '-z').split('\0')
paths = {name for name in listed if name and not (set(Path(name).parts) & EXCLUDE_DIRS)
and not (set(Path(name).suffixes) & {'.a', '.so', '.dylib', '.o', '.os'})
and (self.root / name).is_file()}
manifest = inside(self.cache / 'source-files.json', self.cache)
previous = set(json.loads(manifest.read_text())) if manifest.exists() else set()
for name in sorted(previous - paths):
path = inside(self.work / name, self.work)
if path.is_file() or path.is_symlink():
path.unlink()
for name in sorted(paths):
source = self.root / name
destination = inside(self.work / name, self.work)
destination.parent.mkdir(parents=True, exist_ok=True)
if source.is_symlink():
# Keep repository-relative links; never point the host cache at device builds.
inside(source, self.root)
target = os.readlink(source)
if os.path.isabs(target):
raise RuntimeError(f'Absolute source symlink is not portable: {name}')
if destination.is_symlink() and os.readlink(destination) == target:
continue
if destination.exists() or destination.is_symlink():
destination.unlink()
destination.symlink_to(target)
elif destination.is_symlink() or not same_contents(source, destination):
old_mtime = destination.stat().st_mtime_ns if destination.exists() else None
if destination.is_symlink():
destination.unlink()
elif destination.is_dir():
shutil.rmtree(destination)
shutil.copy2(source, destination)
# SCons' MD5-timestamp decider must notice edits even if an editor kept mtime.
if destination.stat().st_mtime_ns == old_mtime:
os.utime(destination, None)
elif source.stat().st_mode != destination.stat().st_mode:
destination.chmod(source.stat().st_mode)
self.sync_git()
manifest.write_text(json.dumps(sorted(paths)) + '\n')
def sync_git(self):
# Same read-only object sharing as `git clone --shared`, independent index/refs.
metadata = self.work / '.git'
if metadata.is_symlink() or (metadata.exists() and not metadata.is_dir()):
raise RuntimeError('Host worktree has shared Git metadata; remove this host cache and retry.')
if not metadata.exists():
git(self.work, 'init', '-q')
common = Path(git(self.root, 'rev-parse', '--git-common-dir').strip())
if not common.is_absolute():
common = self.root / common
(metadata / 'objects/info/alternates').write_text(str(common.resolve() / 'objects') + '\n')
head = git(self.root, 'rev-parse', 'HEAD').strip()
branch = git(self.root, 'rev-parse', '--abbrev-ref', 'HEAD').strip()
branch = 'host-detached' if branch == 'HEAD' else branch
git(self.work, 'symbolic-ref', 'HEAD', f'refs/heads/{branch}')
git(self.work, 'update-ref', 'HEAD', head)
git(self.work, 'read-tree', head)
config = git(self.root, 'config', '--list')
origin = next((line.split('=', 1)[1] for line in config.splitlines() if line.startswith('remote.origin.url=')), '')
if origin:
git(self.work, 'config', 'remote.origin.url', origin)
# A host helper must never accidentally push through this diagnostic clone.
git(self.work, 'config', 'remote.origin.pushurl', 'disabled://host-runtime')
git(self.work, 'config', 'core.hooksPath', '/dev/null')
def environment(self):
env = {key: value for key, value in os.environ.items() if key not in REMOVE_ENV}
paths = [self.work, *(self.work / name for name in VENDORS)]
env.update(PYTHONPATH=os.pathsep.join(map(str, paths)), PYTHONNOUSERSITE='1',
PATH=f'{self.venv}/bin' + os.pathsep + env.get('PATH', ''), VIRTUAL_ENV=str(self.venv),
BASEDIR=str(self.work), PWD=str(self.work), PARAMS_ROOT=str(inside(self.cache / 'params', self.cache)),
OPENPILOT_PREFIX=self.prefix, SP_HOST_PREFIX=self.prefix,
SP_HOST_PARAMS_ROOT=str(self.cache / 'params'), SP_HOST_RUNTIME='1',
SP_SCONS_CACHE_DIR=str(self.cache / 'scons-cache'),
SCONS_CACHE=str(self.cache / 'scons-cache'),
NOBOARD='1', SIMULATION='1', SKIP_FW_QUERY='1', STARPILOT_UI_DEV='1',
COMMA_CACHE=str(self.cache.parent / 'downloads'))
if self.system == 'Darwin':
env.update(CC='/usr/bin/clang', CXX='/usr/bin/clang++', ZMQ='1')
return env
def prepare(self):
uv = shutil.which('uv')
if uv is None:
candidate = Path.home() / '.local/bin/uv'
if candidate.is_file() and os.access(candidate, os.X_OK):
uv = str(candidate)
if uv is None:
raise RuntimeError('uv is required. Run tools/setup_dependencies.sh, then retry ./dev.')
inside(self.venv, self.cache)
env = self.environment()
env.update(UV_PROJECT_ENVIRONMENT=str(self.venv), UV_PYTHON='3.12')
run([uv, 'sync', '--frozen', '--extra', 'tools', '--extra', 'testing', '--extra', 'dev'], cwd=self.work, env=env)
link = self.work / '.venv'
if link.is_symlink():
if link.resolve() != self.venv.resolve():
raise RuntimeError('Host worktree .venv points outside its environment.')
elif link.exists():
raise RuntimeError('Host worktree .venv is not the managed environment; remove this host cache and retry.')
else:
link.symlink_to(self.venv, target_is_directory=True)
def build(self, command, jobs):
params = 'openpilot/common/libparams_c' + ('.dylib' if self.system == 'Darwin' else '.so')
targets = [params, 'msgq_repo/msgq/ipc_pyx.so', 'msgq_repo/msgq/visionipc/visionipc_pyx.so']
if command in ('c3', 'c4', 'onroad', 'galaxy', 'python', 'pytest', 'shell'):
targets += ['rednose_repo/rednose/helpers/ekf_sym_pyx.so', 'openpilot/selfdrive/locationd',
'openpilot/selfdrive/controls/lib/longitudinal_mpc_lib/c_generated_code/acados_ocp_solver_pyx.so']
if command in ('onroad', 'replay', 'cabana'):
targets += ['openpilot/tools/' + ('cabana/cabana' if command == 'cabana' else 'replay/replay')]
run([self.venv / 'bin/python', '-m', 'SCons', f'-j{jobs}', *targets], cwd=self.work, env=self.environment())
def launch(self, command, jobs, arguments):
env = self.environment()
python = self.venv / 'bin/python'
if command in ('c3', 'c4', 'onroad'):
script = 'launch_onroad_desktop.sh' if command == 'onroad' else f'launch_ui_{command}_desktop.sh'
argv = [self.work / 'scripts' / script, str(jobs), *arguments]
elif command in ('replay', 'cabana'):
argv = [self.work / 'openpilot/tools' / command / command, *arguments]
elif command == 'plotjuggler':
argv = [python, '-m', 'openpilot.tools.plotjuggler.juggle', *arguments]
elif command == 'galaxy':
argv = [python, '-m', 'openpilot.starpilot.galaxy.server', *arguments]
elif command == 'pytest':
argv = [python, '-m', 'pytest', *arguments]
env.setdefault('SCALE', '1') # UI imports must not probe a monitor in test workers.
if self.system == 'Darwin':
env['PYTEST_ADDOPTS'] = env.get('PYTEST_ADDOPTS', '') + ' -n0'
elif command == 'shell':
argv = [env.get('SHELL', '/bin/bash'), *arguments]
else:
argv = [python, *arguments]
ipc = Path('/tmp' if self.system == 'Darwin' else '/dev/shm') / f'msgq_{self.prefix}'
if ipc.is_symlink() or (ipc.exists() and (not ipc.is_dir() or ipc.stat().st_uid != os.getuid())):
raise RuntimeError(f'Private messaging path is not owned by this user: {ipc}')
ipc.mkdir(mode=0o700, exist_ok=True)
# Keep the bucket locked if this wrapper is killed while its child is alive.
inherited = () if self.lock_fd is None else (self.lock_fd,)
with subprocess.Popen([str(arg) for arg in argv], cwd=self.work, env=env, pass_fds=inherited) as child:
def forward(signum, _frame):
if child.poll() is None:
child.send_signal(signum)
previous = {sig: signal.signal(sig, forward) for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)}
try:
result = child.wait()
return result if result >= 0 else 128 - result
finally:
for sig, handler in previous.items():
signal.signal(sig, handler)
def main(arguments=None):
command, jobs, args = parse(sys.argv[1:] if arguments is None else arguments)
if command == 'help':
print(HELP)
return 0
if command in ('c3', 'c4', 'onroad') and args in (['--help'], ['-h']):
helper = ROOT / ('openpilot/tools/replay/onroad.py' if command == 'onroad' else 'openpilot/starpilot/ui/host_launch.py')
profile = [] if command == 'onroad' else ['large' if command == 'c3' else 'compact']
return subprocess.call([sys.executable, str(helper), *profile, '--help'], cwd=ROOT)
if command == 'sync':
buckets = [BUCKET_ALIASES[args[0]]] if args else ['shared', 'cabana']
else:
buckets = ['cabana' if command == 'cabana' else 'shared']
for bucket in buckets:
runtime = HostRuntime(ROOT, bucket)
with runtime.locked():
runtime.sync()
runtime.prepare()
if command != 'sync':
runtime.build(command, jobs)
return runtime.launch(command, jobs, args)
return 0
if __name__ == '__main__':
try:
raise SystemExit(main())
except (ValueError, RuntimeError, OSError) as error:
print(f'Host tools: {error}', file=sys.stderr)
raise SystemExit(1) from None
except subprocess.CalledProcessError as error:
raise SystemExit(error.returncode if error.returncode > 0 else 128 - error.returncode) from None
except KeyboardInterrupt:
raise SystemExit(130) from None