mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-28 00:53:45 +08:00
694b460f80
Provide laptop and device build entry points, cache compatibility checks and developer tooling. Keep distribution assets and model packaging available before runtime consumers. Co-authored-by: Danny <dannyirwin31@yahoo.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: prabhaavp <prabhaav@msu.edu>
403 lines
18 KiB
Python
403 lines
18 KiB
Python
import fcntl
|
|
import hashlib
|
|
import os
|
|
import py_compile
|
|
from pathlib import Path
|
|
import signal
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import Mock, patch
|
|
|
|
from tools.host_runtime import GALAXY, LIVE_RELOAD, LIVE_TAG, LIVE_VERSION, HostRuntime, file_lock, main, parse
|
|
|
|
|
|
class TestDeveloperRuntime(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.temp.cleanup)
|
|
self.root = Path(self.temp.name) / 'source with spaces'
|
|
self.root.mkdir()
|
|
self.git('init', '-q')
|
|
self.git('config', 'user.email', 'test@example.invalid')
|
|
self.git('config', 'user.name', 'Test')
|
|
(self.root / '.gitignore').write_text('.host_runtime/\n*.so\n')
|
|
(self.root / 'source.py').write_text('original\n')
|
|
self.git('add', '.')
|
|
self.git('commit', '-qm', 'fixture')
|
|
self.runtime = HostRuntime(self.root, 'shared', system='Darwin', machine='arm64')
|
|
|
|
def git(self, *args):
|
|
return subprocess.check_output(['git', *args], cwd=self.root, text=True)
|
|
|
|
def test_sync_includes_local_edits_and_untracked_but_not_device_artifacts(self):
|
|
(self.root / 'source.py').write_text('local edit\n')
|
|
(self.root / 'new.py').write_text('new\n')
|
|
(self.root / 'native.so').write_bytes(b'device binary')
|
|
index_before = hashlib.sha256((self.root / '.git/index').read_bytes()).hexdigest()
|
|
with self.runtime.locked():
|
|
self.runtime.sync()
|
|
self.assertEqual((self.runtime.work / 'source.py').read_text(), 'local edit\n')
|
|
self.assertTrue((self.runtime.work / 'new.py').is_file())
|
|
self.assertFalse((self.runtime.work / 'native.so').exists())
|
|
self.assertFalse((self.runtime.work / '.git').is_symlink())
|
|
self.assertEqual(hashlib.sha256((self.root / '.git/index').read_bytes()).hexdigest(), index_before)
|
|
subprocess.check_call(['git', 'add', 'source.py'], cwd=self.runtime.work)
|
|
self.assertEqual(hashlib.sha256((self.root / '.git/index').read_bytes()).hexdigest(), index_before)
|
|
|
|
def test_resync_removes_deleted_source_and_keeps_host_builds_incremental(self):
|
|
self.runtime.sync()
|
|
native = self.runtime.work / 'native.so'
|
|
native.write_bytes(b'host binary')
|
|
source = self.runtime.work / 'source.py'
|
|
before = source.stat().st_mtime_ns
|
|
self.runtime.sync()
|
|
self.assertEqual(source.stat().st_mtime_ns, before)
|
|
(self.root / 'source.py').unlink()
|
|
self.runtime.sync()
|
|
self.assertFalse(source.exists())
|
|
self.assertEqual(native.read_bytes(), b'host binary')
|
|
|
|
def test_sync_skips_tracked_native_outputs_and_preserves_host_builds(self):
|
|
artifacts = ('native.a', 'native.so', 'native.so.1', 'native.dylib', 'native.o', 'native.os', 'openpilot/tools/replay/replay')
|
|
for name in artifacts:
|
|
(self.root / name).parent.mkdir(parents=True, exist_ok=True)
|
|
(self.root / name).write_bytes(b'device binary')
|
|
self.git('add', '-f', *artifacts)
|
|
self.runtime.sync()
|
|
for name in artifacts:
|
|
self.assertFalse((self.runtime.work / name).exists(), name)
|
|
native = self.runtime.work / 'native.a'
|
|
native.write_bytes(b'host binary')
|
|
replay = self.runtime.work / 'openpilot/tools/replay/replay'
|
|
replay.parent.mkdir(parents=True, exist_ok=True)
|
|
replay.write_bytes(b'host replay')
|
|
self.runtime.sync()
|
|
self.assertEqual(native.read_bytes(), b'host binary')
|
|
self.assertEqual(replay.read_bytes(), b'host replay')
|
|
|
|
def test_command_build_prepares_mpc_import_dependency(self):
|
|
solver = 'openpilot/selfdrive/controls/lib/longitudinal_mpc_lib/c_generated_code/acados_ocp_solver_pyx.so'
|
|
for command in ('c3', 'c4', 'onroad', 'galaxy', 'python', 'pytest', 'shell'):
|
|
with self.subTest(command=command), patch('tools.host_runtime.run') as build:
|
|
self.runtime.build(command, 4)
|
|
self.assertIn(solver, build.call_args.args[0])
|
|
|
|
def test_cache_environment_ignores_external_imports_and_settings(self):
|
|
with patch.dict(os.environ, PYTHONPATH='/elsewhere', PARAMS_ROOT='/real/params', OPENPILOT_PREFIX='real', CC='cross-compiler'):
|
|
env = self.runtime.environment()
|
|
self.assertNotIn('/elsewhere', env['PYTHONPATH'])
|
|
self.assertEqual(env['CC'], '/usr/bin/clang')
|
|
self.assertTrue(Path(env['PARAMS_ROOT']).is_relative_to(self.runtime.cache))
|
|
self.assertNotEqual(env['OPENPILOT_PREFIX'], 'real')
|
|
self.assertEqual(Path(env['ANDROID_AUTO_DIR']), self.runtime.cache / 'android_auto')
|
|
second = HostRuntime(self.root, 'shared', system='Darwin', machine='arm64')
|
|
cabana = HostRuntime(self.root, 'cabana', system='Darwin', machine='arm64')
|
|
self.assertEqual(second.prefix, self.runtime.prefix)
|
|
self.assertNotEqual(cabana.prefix, self.runtime.prefix)
|
|
|
|
def test_cache_symlink_cannot_write_into_source(self):
|
|
self.runtime.cache.mkdir(parents=True)
|
|
self.runtime.work.symlink_to(self.root, target_is_directory=True)
|
|
with self.assertRaises(RuntimeError):
|
|
self.runtime.sync()
|
|
self.assertEqual((self.root / 'source.py').read_text(), 'original\n')
|
|
|
|
def test_cache_cannot_borrow_source_git_index(self):
|
|
self.runtime.work.mkdir(parents=True)
|
|
(self.runtime.work / '.git').symlink_to(self.root / '.git', target_is_directory=True)
|
|
with self.assertRaisesRegex(RuntimeError, 'shared Git'):
|
|
self.runtime.sync_git()
|
|
|
|
def test_command_parsing_preserves_tool_arguments(self):
|
|
self.assertEqual(parse(['c3', '8', '--demo']), ('c3', 8, ['--demo']))
|
|
self.assertEqual(parse(['juggle', '4', '--demo']), ('plotjuggler', 4, ['--demo']))
|
|
self.assertEqual(parse(['python', '-c', 'print(1)'])[2], ['-c', 'print(1)'])
|
|
self.assertEqual(parse(['pytest', '-n', '2'])[2], ['-n', '2'])
|
|
self.assertEqual(parse(['--help'])[0], 'help')
|
|
for args in (['unknown'], ['c4', '0'], ['sync', '../oops'], ['sync', 'shared', 'extra']):
|
|
with self.subTest(args=args), self.assertRaises(ValueError):
|
|
parse(args)
|
|
|
|
def galaxy_fixture(self):
|
|
galaxy = self.root / GALAXY
|
|
(galaxy / 'web').mkdir(parents=True)
|
|
(galaxy / 'server.py').write_text('server\n')
|
|
(galaxy / 'web/app.js').write_text('app\n')
|
|
(galaxy / 'web/index.html').write_text('<body>\n</body>\n')
|
|
self.runtime.sync()
|
|
return galaxy
|
|
|
|
def test_live_galaxy_mirrors_package_edits_without_bytecode(self):
|
|
galaxy = self.galaxy_fixture()
|
|
(galaxy / '__pycache__').mkdir()
|
|
(galaxy / '__pycache__/server.pyc').write_bytes(b'bytecode')
|
|
sources = self.runtime.galaxy_sources()
|
|
self.assertEqual(set(sources), {GALAXY / 'server.py', GALAXY / 'web/app.js', GALAXY / 'web/index.html'})
|
|
(galaxy / 'web/app.js').write_text('edited\n')
|
|
(galaxy / 'server.py').unlink()
|
|
self.runtime.mirror([GALAXY / 'web/app.js'], [GALAXY / 'server.py'])
|
|
self.assertEqual((self.runtime.work / GALAXY / 'web/app.js').read_text(), 'edited\n')
|
|
self.assertFalse((self.runtime.work / GALAXY / 'server.py').exists())
|
|
|
|
def run_live(self, edits, before_edit, **options):
|
|
"""Run live_galaxy with a fake server, applying one edit per poll, then Ctrl+C."""
|
|
edits = iter(edits)
|
|
children = []
|
|
|
|
class Child:
|
|
def __init__(self):
|
|
self.returncode = None
|
|
self.signals = []
|
|
children.append(self)
|
|
|
|
def poll(self):
|
|
return self.returncode
|
|
|
|
def send_signal(self, signum):
|
|
self.signals.append(signum)
|
|
self.returncode = 0
|
|
|
|
def terminate(self):
|
|
self.send_signal(signal.SIGTERM)
|
|
|
|
def kill(self):
|
|
self.send_signal(signal.SIGKILL)
|
|
|
|
def wait(self, timeout=None):
|
|
if self.returncode is None:
|
|
raise subprocess.TimeoutExpired('galaxy', timeout)
|
|
return self.returncode
|
|
|
|
real_popen = subprocess.Popen
|
|
|
|
def spawn(argv, *args, **kwargs):
|
|
# Only the server is faked; source selection must really run.
|
|
return real_popen(argv, *args, **kwargs) if argv[0] == 'git' else Child()
|
|
|
|
def tick(_interval):
|
|
before_edit()
|
|
edit = next(edits, None)
|
|
if edit is None:
|
|
raise KeyboardInterrupt
|
|
edit()
|
|
|
|
with patch('tools.host_runtime.subprocess.Popen', spawn), patch('tools.host_runtime.time.sleep', tick):
|
|
self.assertEqual(self.runtime.live_galaxy(['--port', '8099'], **options), 130)
|
|
return children
|
|
|
|
def test_live_galaxy_restarts_only_for_backend_edits(self):
|
|
galaxy = self.galaxy_fixture()
|
|
(galaxy / '.gitignore').write_text('.*.swp\n')
|
|
web = self.runtime.work / GALAXY / 'web'
|
|
versions = []
|
|
|
|
def before_edit():
|
|
versions.append((web / LIVE_VERSION).read_text())
|
|
self.assertIn(LIVE_TAG, (web / 'index.html').read_text())
|
|
|
|
children = self.run_live([lambda: (galaxy / 'web/app.js').write_text('frontend edit\n'),
|
|
lambda: (galaxy / '.server.py.swp').write_bytes(b'editor swap'),
|
|
lambda: (galaxy / '.server.py.swp').unlink(),
|
|
lambda: (galaxy / 'tests').mkdir(),
|
|
lambda: (galaxy / 'tests/test_offline_road_maps.mjs').write_text('test edit\n'),
|
|
lambda: (galaxy / 'tests/test_offline_road_maps.mjs').unlink(),
|
|
lambda: (galaxy / 'test_projection_layout.py').write_text('test edit\n'),
|
|
lambda: (galaxy / 'server.py').write_text('backend edit\n')], before_edit)
|
|
self.assertEqual(len(children), 2)
|
|
self.assertTrue(all(child.returncode == 0 for child in children))
|
|
self.assertEqual(children[0].signals, [signal.SIGINT])
|
|
self.assertEqual(children[1].signals, [signal.SIGTERM]) # Ctrl+C already reached it.
|
|
self.assertEqual((web / 'app.js').read_text(), 'frontend edit\n')
|
|
self.assertEqual((self.runtime.work / GALAXY / 'server.py').read_text(), 'backend edit\n')
|
|
self.assertFalse((self.runtime.work / GALAXY / '.server.py.swp').exists())
|
|
self.assertEqual(len(set(versions)), 3) # Start, frontend edit, backend restart; never the swap file.
|
|
self.assertEqual((web / 'index.html').read_text(), '<body>\n</body>\n')
|
|
self.assertFalse((web / LIVE_RELOAD).exists() or (web / LIVE_VERSION).exists())
|
|
|
|
def test_live_galaxy_without_autoreload_leaves_the_page_alone(self):
|
|
galaxy = self.galaxy_fixture()
|
|
web = self.runtime.work / GALAXY / 'web'
|
|
|
|
def before_edit():
|
|
self.assertEqual((web / 'index.html').read_text(), '<body>\n</body>\n')
|
|
self.assertFalse((web / LIVE_RELOAD).exists() or (web / LIVE_VERSION).exists())
|
|
|
|
children = self.run_live([lambda: (galaxy / 'web/app.js').write_text('frontend edit\n'),
|
|
lambda: (galaxy / 'server.py').write_text('backend edit\n')],
|
|
before_edit, autoreload=False)
|
|
self.assertEqual(len(children), 2) # Edits are still mirrored and Python still restarts.
|
|
self.assertEqual((web / 'app.js').read_text(), 'frontend edit\n')
|
|
|
|
def test_live_reload_tag_survives_resync_once(self):
|
|
galaxy = self.galaxy_fixture()
|
|
page = self.runtime.work / GALAXY / 'web/index.html'
|
|
self.runtime.live_reload(1)
|
|
self.runtime.live_reload()
|
|
self.assertEqual(page.read_text().count(LIVE_TAG), 1)
|
|
self.runtime.sync() # Another shared command restores the checkout's page.
|
|
self.assertNotIn(LIVE_TAG, page.read_text())
|
|
self.runtime.live_reload()
|
|
self.assertEqual(page.read_text().count(LIVE_TAG), 1)
|
|
self.assertNotIn(LIVE_TAG, (galaxy / 'web/index.html').read_text())
|
|
script = self.runtime.work / GALAXY / 'web' / LIVE_RELOAD
|
|
self.runtime.end_live_reload()
|
|
self.runtime.live_reload()
|
|
self.assertTrue(script.is_file())
|
|
self.assertTrue((page.parent / LIVE_VERSION).is_file())
|
|
|
|
def test_live_mirror_removes_stale_bytecode_and_detects_preserved_timestamps(self):
|
|
self.galaxy_fixture()
|
|
module = GALAXY / 'sample.py'
|
|
source = self.root / module
|
|
source.write_text('value = 1\n')
|
|
self.runtime.mirror([module], [])
|
|
cached = self.runtime.work / module
|
|
py_compile.compile(str(cached), doraise=True)
|
|
timestamp = source.stat().st_mtime_ns
|
|
before = self.runtime.galaxy_sources()
|
|
source.write_text('value = 2\n')
|
|
os.utime(source, ns=(timestamp, timestamp))
|
|
self.assertNotEqual(before[module], self.runtime.galaxy_sources()[module])
|
|
self.runtime.mirror([module], [])
|
|
result = subprocess.check_output([sys.executable, '-B', '-c', 'import sample; print(sample.value)'], cwd=cached.parent, text=True)
|
|
self.assertEqual(result.strip(), '2')
|
|
self.assertFalse(list((cached.parent / '__pycache__').glob('sample.*.pyc')))
|
|
|
|
def test_mirror_replaces_files_without_truncating_existing_readers(self):
|
|
galaxy = self.galaxy_fixture()
|
|
module = GALAXY / 'web/app.js'
|
|
with (self.runtime.work / module).open() as reader:
|
|
(galaxy / 'web/app.js').write_text('new contents\n')
|
|
self.runtime.mirror([module], [])
|
|
self.assertEqual(reader.read(), 'app\n')
|
|
self.assertEqual((self.runtime.work / module).read_text(), 'new contents\n')
|
|
|
|
def test_sync_removes_deleted_live_added_files(self):
|
|
galaxy = self.galaxy_fixture()
|
|
module = GALAXY / 'new.py'
|
|
(galaxy / 'new.py').write_text('new\n')
|
|
self.runtime.mirror([module], [])
|
|
(galaxy / 'new.py').unlink()
|
|
self.runtime.sync()
|
|
self.assertFalse((self.runtime.work / module).exists())
|
|
|
|
def test_live_sources_and_sync_agree_on_tracked_ignored_files_and_symlinks(self):
|
|
galaxy = self.galaxy_fixture()
|
|
(galaxy / '.gitignore').write_text('ignored.py\n')
|
|
(galaxy / 'ignored.py').write_text('tracked\n')
|
|
self.git('add', '-f', str(GALAXY / 'ignored.py'))
|
|
link = GALAXY / 'linked.py'
|
|
(self.root / link).symlink_to('ignored.py')
|
|
sources = self.runtime.galaxy_sources()
|
|
self.assertIn(GALAXY / 'ignored.py', sources)
|
|
self.assertIn(link, sources)
|
|
self.runtime.mirror([link, GALAXY / 'ignored.py'], [])
|
|
self.assertTrue((self.runtime.work / link).is_symlink())
|
|
self.assertEqual((self.runtime.work / link).read_text(), 'tracked\n')
|
|
|
|
def test_live_start_reconciles_edits_and_deletions_during_build(self):
|
|
galaxy = self.galaxy_fixture()
|
|
(galaxy / 'web/app.js').write_text('edited during build\n')
|
|
(galaxy / 'server.py').unlink()
|
|
|
|
def check():
|
|
self.assertEqual((self.runtime.work / GALAXY / 'web/app.js').read_text(), 'edited during build\n')
|
|
self.assertFalse((self.runtime.work / GALAXY / 'server.py').exists())
|
|
|
|
self.run_live([], check)
|
|
|
|
def test_live_retries_failed_copy_without_another_edit(self):
|
|
galaxy = self.galaxy_fixture()
|
|
module = GALAXY / 'web/app.js'
|
|
original = self.runtime.copy_source
|
|
failures = []
|
|
|
|
def copy(name):
|
|
if name == module and (galaxy / 'web/app.js').read_text() == 'edited\n' and not failures:
|
|
failures.append(name)
|
|
raise FileNotFoundError('editor replaced the file')
|
|
original(name)
|
|
|
|
with patch.object(self.runtime, 'copy_source', copy):
|
|
self.run_live([lambda: (galaxy / 'web/app.js').write_text('edited\n'), lambda: None], lambda: None)
|
|
self.assertEqual(failures, [module])
|
|
self.assertEqual((self.runtime.work / module).read_text(), 'edited\n')
|
|
|
|
def test_live_rejects_another_owner_of_the_same_cache(self):
|
|
self.galaxy_fixture()
|
|
with file_lock(self.runtime.cache / 'galaxy-live-lock'), patch('tools.host_runtime.subprocess.Popen') as spawn:
|
|
with self.assertRaisesRegex(RuntimeError, 'already owns'):
|
|
self.runtime.live_galaxy(['--port', '8099'])
|
|
spawn.assert_not_called()
|
|
|
|
def test_live_child_exit_and_start_failure_clean_up(self):
|
|
self.galaxy_fixture()
|
|
web = self.runtime.work / GALAXY / 'web'
|
|
for code in (0, 2, -signal.SIGTERM):
|
|
child = Mock(returncode=code)
|
|
child.poll.return_value = code
|
|
with patch.object(self.runtime, 'galaxy_sources', return_value=self.runtime.galaxy_sources()), \
|
|
patch('tools.host_runtime.subprocess.Popen', return_value=child):
|
|
self.assertEqual(self.runtime.live_galaxy([]), code if code >= 0 else 128 - code)
|
|
self.assertFalse((web / LIVE_VERSION).exists())
|
|
with patch.object(self.runtime, 'galaxy_sources', return_value=self.runtime.galaxy_sources()), \
|
|
patch('tools.host_runtime.subprocess.Popen', side_effect=OSError('spawn failed')):
|
|
with self.assertRaisesRegex(OSError, 'spawn failed'):
|
|
self.runtime.live_galaxy([])
|
|
self.assertFalse((web / LIVE_VERSION).exists())
|
|
self.assertNotIn(LIVE_TAG, (web / 'index.html').read_text())
|
|
|
|
def test_live_termination_stops_child_and_restores_handlers(self):
|
|
self.galaxy_fixture()
|
|
for signum in (signal.SIGTERM, signal.SIGHUP):
|
|
previous = signal.getsignal(signum)
|
|
child = Mock(returncode=None)
|
|
child.poll.return_value = None
|
|
|
|
def tick(_interval, signum=signum):
|
|
signal.getsignal(signum)(signum, None)
|
|
|
|
with patch.object(self.runtime, 'galaxy_sources', return_value=self.runtime.galaxy_sources()), \
|
|
patch('tools.host_runtime.subprocess.Popen', return_value=child), \
|
|
patch('tools.host_runtime.time.sleep', tick), patch('tools.host_runtime.stop') as stop:
|
|
self.assertEqual(self.runtime.live_galaxy([]), 128 + signum)
|
|
stop.assert_called_once_with(child, interrupted=False)
|
|
self.assertEqual(signal.getsignal(signum), previous)
|
|
self.assertFalse((self.runtime.work / GALAXY / 'web' / LIVE_VERSION).exists())
|
|
|
|
def test_live_galaxy_refuses_a_taken_port_before_syncing(self):
|
|
with socket.socket() as taken:
|
|
taken.bind(('127.0.0.1', 0))
|
|
taken.listen()
|
|
port = str(taken.getsockname()[1])
|
|
with patch('tools.host_runtime.ROOT', self.root), patch.object(HostRuntime, 'sync') as sync, \
|
|
self.assertRaisesRegex(RuntimeError, f'Port {port} is already in use'):
|
|
main(['galaxy', '--live', '--port', port])
|
|
sync.assert_not_called()
|
|
|
|
def test_no_autoreload_requires_live(self):
|
|
with self.assertRaisesRegex(ValueError, 'only applies'):
|
|
main(['galaxy', '--no-autoreload'])
|
|
|
|
def test_live_galaxy_serves_after_releasing_shared_lock(self):
|
|
def serve(runtime, arguments, *, autoreload):
|
|
with (runtime.cache / 'lock').open('a+') as lock:
|
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) # Raises if the build still holds it.
|
|
self.assertEqual(arguments, ['--port', '8099'])
|
|
self.assertTrue(autoreload)
|
|
return 0
|
|
|
|
with patch('tools.host_runtime.ROOT', self.root), patch('tools.host_runtime.galaxy_port_free'), \
|
|
patch.object(HostRuntime, 'prepare'), patch.object(HostRuntime, 'build') as build, patch.object(HostRuntime, 'launch') as launch, \
|
|
patch.object(HostRuntime, 'live_galaxy', serve):
|
|
self.assertEqual(main(['galaxy', '4', '--live', '--port', '8099']), 0)
|
|
build.assert_called_once_with('galaxy', 4)
|
|
launch.assert_not_called()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|