Files
StarPilot/openpilot/system/updated/tests/test_fast_request.py
T

270 lines
13 KiB
Python

from pathlib import Path
import tempfile
import threading
import sys
from types import ModuleType
import unittest
from unittest import mock
from unittest.mock import Mock, patch
from openpilot.system.updated.tests.test_vendored_update import load_updater
from openpilot.starpilot.drive_state.evidence import PhysicalSource
class StopLoop(Exception):
pass
class TestFastRequest(unittest.TestCase):
def setUp(self):
self.updated = load_updater()
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
root = Path(self.directory.name)
self.updated.LOCK_FILE = str(root / 'lock')
self.updated.STAGING_ROOT = str(root / 'staging')
self.updated.OVERLAY_INIT = root / 'overlay'
self.params = Mock()
self.values = {'InstallDate': '2026-10-03', 'UpdateFailedCount': 2}
self.writes = []
self.params.get.side_effect = lambda key, **kwargs: self.values.get(key)
self.params.get_bool.side_effect = lambda key: bool(self.values.get(key, False))
def put(key, value, **kwargs):
self.writes.append((key, value))
self.values[key] = value
self.params.put.side_effect = put
self.params.put_bool.side_effect = put
self.params.remove.side_effect = lambda key: self.values.pop(key, None)
self.updated.Params = Mock(return_value=self.params)
self.helper = self.updated.WaitTimeHelper.__new__(self.updated.WaitTimeHelper)
self.helper.ready_event = threading.Event()
self.helper.request_lock = threading.RLock()
self.helper.request_generation = 0
self.helper.fast_target = None
self.helper.user_request = self.updated.UserRequest.NONE
self.helper._control_request('fast', branch='SecretGoodStarPilot', commit='a' * 40)
self.helper.sleep = Mock(side_effect=StopLoop)
self.updated.WaitTimeHelper = Mock(return_value=self.helper)
self.updater = Mock()
self.updater.fast_update.return_value = 'up-to-date'
self.updated.Updater = Mock(return_value=self.updater)
self.updated.init_overlay = Mock()
self.updated.set_consistent_flag = Mock()
def run_cycle(self):
with self.assertRaises(StopLoop):
self.updated.main()
self.updated.init_overlay.assert_not_called()
self.updater.check_for_update.assert_not_called()
self.updater.fetch_update.assert_not_called()
self.updater.set_params.assert_not_called()
self.updated.system_time_valid.assert_not_called()
self.updater.fast_update.assert_called_once_with('SecretGoodStarPilot', 'a' * 40, rollback=False)
def test_normal_fetch_finalization_success_and_failure_return_idle(self):
# Retain the real fetch implementation while replacing its external Git/filesystem operations.
module = load_updater()
actual = module.Updater()
actual.params = self.params
actual._branches_checked = True
actual.branches = {"SecretGoodStarPilot": "a" * 40}
self.values["UpdaterTargetBranch"] = "SecretGoodStarPilot"
module.run = Mock(return_value="a" * 40)
module.setup_git_options = Mock()
module.set_consistent_flag = Mock()
module.validate_revision = Mock()
module.AGNOS = False
for failure in (False, True):
with self.subTest(failure=failure):
self.writes.clear()
self.values.pop("LastUpdateTime", None)
self.helper._control_request("download")
self.helper.sleep = Mock(side_effect=[None, StopLoop])
self.updated.system_time_valid = Mock(return_value=True)
module.finalize_update = Mock(side_effect=RuntimeError("copy failed") if failure else None)
self.updater.fetch_update.side_effect = actual.fetch_update
def set_params(success, count, error):
self.params.put("UpdateFailedCount", count)
if success:
self.updated.write_time_to_param(self.params, "LastUpdateTime")
self.updater.set_params.side_effect = set_params
with self.assertRaises(StopLoop):
self.updated.main()
states = [value for key, value in self.writes if key == "UpdaterState"]
self.assertIn("finalizing update...", states)
self.assertEqual(states[-1], "idle")
self.assertEqual("LastUpdateTime" in self.values, not failure)
module.finalize_update.assert_called_once()
def test_fast_request_skips_staging_and_startup_delay(self):
self.run_cycle()
self.assertEqual(self.values['UpdaterState'], 'idle')
self.assertEqual(self.values['UpdateFailedCount'], 0)
self.assertIn('UpdaterLastFetchTime', self.values)
self.assertIn('LastUpdateTime', self.values)
self.assertNotIn('DoReboot', self.values)
self.assertLess(self.writes.index(('UpdateFailedCount', 0)), len(self.writes) - 1)
self.assertEqual(self.writes[-1], ('UpdaterState', 'idle'))
def test_fast_failure_reports_error_without_reboot(self):
self.updater.fast_update.side_effect = RuntimeError('Tracked source has local changes')
self.run_cycle()
self.assertEqual(self.values['UpdaterState'], 'idle')
self.assertEqual(self.values['UpdateFailedCount'], 3)
self.assertEqual(self.values['LastUpdateException'], 'Tracked source has local changes')
self.assertNotIn('DoReboot', self.values)
self.assertNotIn('LastUpdateTime', self.values)
def test_long_failure_remains_readable_by_galaxy(self):
self.updater.fast_update.side_effect = RuntimeError('download failed: ' + 'é' * 5000)
self.run_cycle()
self.assertLessEqual(len(self.values['LastUpdateException'].encode('utf-8')), 4096)
self.assertTrue(self.values['LastUpdateException'].startswith('download failed: '))
self.assertEqual(self.values['UpdaterState'], 'idle')
def test_success_waits_for_manager_restart(self):
def apply(branch, commit, *, rollback=False):
self.assertFalse(rollback)
self.params.put_bool('DoReboot', True, block=True)
return 'reboot-requested'
self.updater.fast_update.side_effect = apply
self.run_cycle()
self.assertEqual(self.values['UpdaterState'], 'updating...')
self.assertTrue(self.values['DoReboot'])
def test_versions_listing_is_not_installation_or_staging(self):
self.helper._control_request('versions', branch='Dom', commit='a' * 40)
self.updater.fast_update.return_value = 'versions-listed'
with self.assertRaises(StopLoop):
self.updated.main()
self.updater.fast_update.assert_called_once_with('Dom', 'a' * 40, versions_only=True)
self.updated.init_overlay.assert_not_called()
self.updater.set_params.assert_not_called()
self.assertIn('UpdaterLastFetchTime', self.values)
self.assertNotIn('LastUpdateTime', self.values)
self.assertNotIn('DoReboot', self.values)
self.assertEqual(self.values['UpdaterState'], 'idle')
def test_failed_listing_preserves_existing_staging_marker(self):
self.updated.OVERLAY_INIT.write_text('retained validated staging')
self.helper._control_request('versions', branch='Dom', commit='a' * 40)
self.updater.fast_update.side_effect = RuntimeError('version download unavailable')
with self.assertRaises(StopLoop):
self.updated.main()
self.assertEqual(self.updated.OVERLAY_INIT.read_text(), 'retained validated staging')
self.assertNotIn('DoReboot', self.values)
self.assertNotIn('LastUpdateTime', self.values)
def test_selected_version_dispatch_and_superseding_request(self):
self.helper._control_request('version', branch='Dom', commit='a' * 40, selected_commit='b' * 40)
_, generation, target = self.helper.current_request()
self.assertEqual(target, ('Dom', 'a' * 40, 'b' * 40))
with self.assertRaises(StopLoop):
self.updated.main()
self.updater.fast_update.assert_called_once_with('Dom', 'a' * 40, 'b' * 40, rollback=False)
self.helper._control_request('versions', branch='Dom', commit='a' * 40)
self.assertFalse(self.helper.finish_request(generation))
self.assertEqual(self.helper.current_request()[2], ('Dom', 'a' * 40))
def test_new_request_is_not_lost_when_previous_work_finishes(self):
_, generation, target = self.helper.current_request()
self.assertEqual(target, ('SecretGoodStarPilot', 'a' * 40))
self.helper._control_request('check')
self.assertFalse(self.helper.finish_request(generation))
self.assertTrue(self.helper.ready_event.is_set())
request, generation, target = self.helper.current_request()
self.assertIsNone(target)
self.assertEqual(request, self.updated.UserRequest.CHECK)
self.assertTrue(self.helper.finish_request(generation))
self.assertEqual(self.helper.user_request, self.updated.UserRequest.NONE)
self.assertFalse(self.helper.ready_event.is_set())
class TestFastAdapter(unittest.TestCase):
def test_source_admission_and_overlay_invalidation_are_owned_by_updater(self):
updated = load_updater()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
updated.FINALIZED = str(root / 'finalized')
Path(updated.FINALIZED).mkdir()
marker = Path(updated.FINALIZED, '.overlay_consistent')
marker.touch()
updated.OVERLAY_INIT = root / '.overlay_init'
updated.OVERLAY_INIT.touch()
updated.BASEDIR = str(root)
updated.dismount_overlay = Mock()
updated.HARDWARE.get_os_version.return_value = '19.8.1'
updater = updated.Updater()
updater.params = Mock()
def registered_flag(key):
if key != 'IsOffroad':
raise KeyError(key.encode())
return True
updater.params.get_bool.side_effect = registered_flag
source = Mock()
source.allowed.return_value = True
source.effective.return_value = False
source.status.side_effect = lambda *, updating_offroad=False, source=source: {'allowed': source.allowed(), 'reason': None}
source.update_status.side_effect = lambda manager_offroad, source=source: PhysicalSource.update_status(source, manager_offroad)
source.update_ready.side_effect = lambda manager_offroad, source=source: PhysicalSource.update_ready(source, manager_offroad)
physical = ModuleType('openpilot.starpilot.drive_state.evidence')
physical.PhysicalSource = Mock(return_value=source)
owner = ModuleType('openpilot.starpilot.software.fast_update')
def install(repo, branch, **options):
self.assertEqual((repo, branch, options['expected_commit'], options['current_os']),
(root, 'SecretGoodStarPilot', 'b' * 40, '19.8.1'))
self.assertTrue(options['parked']())
self.assertTrue(marker.exists())
options['invalidate']()
self.assertFalse(marker.exists())
self.assertFalse(updated.OVERLAY_INIT.exists())
updated.dismount_overlay.assert_called_once()
return 'reboot-requested'
owner.fast_update = install
with patch.dict(sys.modules, {physical.__name__: physical, owner.__name__: owner}):
self.assertEqual(updater.fast_update('SecretGoodStarPilot', 'b' * 40), 'reboot-requested')
source.close.assert_called_once()
source.update_ready.assert_called_once_with(True)
source.status.assert_called_once_with(updating_offroad=True)
self.assertEqual(updater.params.get_bool.call_args_list, [mock.call('IsOffroad')])
updater.params.put_bool.assert_called_once_with('UpdateAvailable', False, block=True)
def test_live_onroad_invalid_physical_or_offroad_false_deny_fast_admission(self):
for offroad, allowed, effective in ((False, True, False), (True, False, False), (True, True, True), (True, True, None)):
with self.subTest(offroad=offroad, allowed=allowed, effective=effective):
updated = load_updater()
updater = updated.Updater()
updater.params = Mock()
def registered_flag(key, offroad=offroad):
if key != "IsOffroad":
raise KeyError(key.encode())
return offroad
updater.params.get_bool.side_effect = registered_flag
source = Mock()
source.allowed.return_value = allowed
source.effective.return_value = effective
source.status.side_effect = lambda *, updating_offroad=False, source=source: {'allowed': source.allowed(), 'reason': None}
source.update_status.side_effect = lambda manager_offroad, source=source: PhysicalSource.update_status(source, manager_offroad)
source.update_ready.side_effect = lambda manager_offroad, source=source: PhysicalSource.update_ready(source, manager_offroad)
physical = ModuleType('openpilot.starpilot.drive_state.evidence')
physical.PhysicalSource = Mock(return_value=source)
owner = ModuleType('openpilot.starpilot.software.fast_update')
def admit(*args, **kwargs):
self.assertFalse(kwargs['parked']())
return 'denied'
owner.fast_update = admit
with patch.dict(sys.modules, {physical.__name__: physical, owner.__name__: owner}), \
patch.object(updated.time, 'monotonic', side_effect=(0, 1)):
self.assertEqual(updater.fast_update('SecretGoodStarPilot', 'b' * 40), 'denied')
source.close.assert_called_once()
source.update_ready.assert_called_once_with(offroad)
if not offroad:
source.status.assert_not_called()
else:
source.status.assert_called_once_with(updating_offroad=True)
if __name__ == '__main__':
unittest.main()