From 867ed373d6d3c6aa3eb8726888eff8896579b93c Mon Sep 17 00:00:00 2001 From: mtawata <24485504+mtawata@users.noreply.github.com> Date: Fri, 30 Oct 2026 00:51:22 -0700 Subject: [PATCH] fix fast updater hang; improve version selection list; schedule update install --- .../selfdrive/ui/layouts/settings/software.py | 17 +- .../ui/mici/layouts/settings/software.py | 18 + openpilot/starpilot/drive_state/control.py | 9 +- .../tests/test_galaxy_drive_eligibility.py | 16 + .../starpilot/external_preferences_reset.py | 4 +- openpilot/starpilot/galaxy/README.md | 17 +- .../starpilot/galaxy/software_operations.py | 162 +++++-- openpilot/starpilot/galaxy/software_status.py | 17 +- .../galaxy/tests/browser/design-system.cjs | 44 +- .../galaxy/tests/test_drive_state.mjs | 26 +- .../galaxy/tests/test_software_http.py | 22 +- .../galaxy/tests/test_software_operations.py | 190 ++++++++- .../galaxy/tests/test_software_status.mjs | 248 +++++++---- .../galaxy/tests/test_version_timeline.mjs | 173 ++++++++ openpilot/starpilot/galaxy/web/css/galaxy.css | 29 +- .../starpilot/galaxy/web/js/drive-state.js | 28 +- .../starpilot/galaxy/web/js/galaxy-select.js | 5 +- .../galaxy/web/js/software-status.js | 395 ++++++++++++----- openpilot/starpilot/software/auto_install.py | 169 ++++++++ openpilot/starpilot/software/fast_update.py | 60 ++- openpilot/starpilot/software/history.py | 97 ++++- openpilot/starpilot/software/preferences.py | 4 +- .../software/tests/test_auto_install.py | 134 ++++++ .../software/tests/test_fast_update.py | 32 +- .../starpilot/software/tests/test_history.py | 52 ++- .../software/tests/test_preferences.py | 6 +- .../software/tests/test_update_control.py | 21 + .../software/tests/test_update_process.py | 36 +- .../starpilot/software/update_control.py | 8 +- .../tests/test_external_preferences_reset.py | 11 + openpilot/starpilot/ui/runtime_snapshot.py | 9 + .../ui/tests/test_runtime_snapshot.py | 12 + .../updated/tests/test_vendored_update.py | 266 +++++++++++- openpilot/system/updated/updated.py | 400 ++++++++++++++---- 34 files changed, 2383 insertions(+), 354 deletions(-) create mode 100644 openpilot/starpilot/galaxy/tests/test_version_timeline.mjs create mode 100644 openpilot/starpilot/software/auto_install.py create mode 100644 openpilot/starpilot/software/tests/test_auto_install.py diff --git a/openpilot/selfdrive/ui/layouts/settings/software.py b/openpilot/selfdrive/ui/layouts/settings/software.py index 10a34fe020..e4ae065637 100644 --- a/openpilot/selfdrive/ui/layouts/settings/software.py +++ b/openpilot/selfdrive/ui/layouts/settings/software.py @@ -133,6 +133,13 @@ class SoftwareLayout(Widget): self._download_btn.set_visible(ui_state.is_offroad()) updater_state = ui_state.params.get("UpdaterState") or "idle" + fast = ui_state.params.get('UpdaterFastState') or {} + check = fast.get('check', {}) if isinstance(fast, dict) else {} + checking_metadata = check.get('state') == 'checking' + if checking_metadata: + updater_state = 'checking...' + if check.get('completedAt', 0) >= getattr(self, '_waiting_start_ts', float('inf')) and check.get('state') in ('complete', 'error'): + self._waiting_for_updater = False failed_count = ui_state.params.get("UpdateFailedCount") or 0 fetch_available = ui_state.params.get_bool("UpdaterFetchAvailable") update_available = ui_state.params.get_bool("UpdateAvailable") @@ -143,6 +150,14 @@ class SoftwareLayout(Widget): self._download_btn.action_item.set_enabled(False) # Use the mapping, with a fallback to the original state string display_text = STATE_TO_DISPLAY_TEXT.get(updater_state, updater_state) + if checking_metadata: + display_text = tr('Checking for new versions') + elif isinstance(fast, dict) and fast.get('detail'): + display_text = fast['detail'] + if fast.get('background'): + display_text = tr('Background download') + ': ' + display_text + self._download_btn.action_item.set_text(tr('CHECK')) + self._download_btn.action_item.set_enabled(True) self._download_btn.action_item.set_value(display_text) else: if failed_count > 0: @@ -152,7 +167,7 @@ class SoftwareLayout(Widget): self._download_btn.action_item.set_value(tr("update available")) self._download_btn.action_item.set_text(tr("DOWNLOAD")) else: - last_update = ui_state.params.get("LastUpdateTime") + last_update = ui_state.params.get("UpdaterLastCheckTime") or ui_state.params.get("LastUpdateTime") if last_update: formatted = time_ago(last_update) self._download_btn.action_item.set_value(tr("up to date, last checked {}").format(formatted)) diff --git a/openpilot/selfdrive/ui/mici/layouts/settings/software.py b/openpilot/selfdrive/ui/mici/layouts/settings/software.py index 5e30aa158d..4180a3c5dd 100644 --- a/openpilot/selfdrive/ui/mici/layouts/settings/software.py +++ b/openpilot/selfdrive/ui/mici/layouts/settings/software.py @@ -1,5 +1,6 @@ import subprocess import threading +import time import pyray as rl from enum import IntEnum from collections.abc import Callable @@ -83,6 +84,7 @@ class CheckUpdateButton(BigButton): self._signal_updater(self.CHECK_FOR_UPDATE) def _signal_updater(self, sig: str): + self._check_requested_at = time.monotonic() if sig == self.CHECK_FOR_UPDATE else None self.set_enabled(False) self._state = UpdaterState.WAITING_FOR_UPDATER self._hide_value_t = None @@ -109,6 +111,22 @@ class CheckUpdateButton(BigButton): return updater_state = ui_state.params.get("UpdaterState") or "" + fast = ui_state.params.get('UpdaterFastState') or {} + check = fast.get('check', {}) if isinstance(fast, dict) else {} + requested = getattr(self, '_check_requested_at', None) + if requested is not None and check.get('completedAt', 0) >= requested and check.get('state') in ('complete', 'error'): + self._state = UpdaterState.IDLE + self._check_requested_at = None + self._hide_value_t = rl.get_time() + if check.get('state') == 'checking': + self.set_rotate_icon(True) + self.set_value('checking version metadata...') + return + if isinstance(fast, dict) and fast.get('background') and updater_state in ('downloading...', 'finalizing update...'): + self.set_rotate_icon(False) + self.set_enabled(True) + self.set_value('Background download: ' + (fast.get('detail') or updater_state)) + return failed_count = ui_state.params.get("UpdateFailedCount") or 0 failed = int(failed_count) > 0 diff --git a/openpilot/starpilot/drive_state/control.py b/openpilot/starpilot/drive_state/control.py index 6972bff4f9..da714a1871 100644 --- a/openpilot/starpilot/drive_state/control.py +++ b/openpilot/starpilot/drive_state/control.py @@ -4,6 +4,7 @@ import threading import time from .resolver import Mode +from .owner import Rejected LABELS = {Mode.AUTO: 'Auto', Mode.OFFROAD: 'Offroad', Mode.ONROAD: 'Onroad'} @@ -39,8 +40,12 @@ class DriveStateControl: return result def change_galaxy(self, mode, revision, authorized): - result = self.owner.request(mode, expected_revision=revision, authorized=authorized, - override_allowed=lambda: self.physical.status(require_park=True)['allowed']) + def offroad_allowed(): + status = self.physical.status(require_park=True) + if not status['allowed']: + raise Rejected(status.get('reason') or 'Park the vehicle and disengage before forcing Offroad') + return True + result = self.owner.request(mode, expected_revision=revision, authorized=authorized, override_allowed=offroad_allowed) with self.lock: self.cached = None return result diff --git a/openpilot/starpilot/drive_state/tests/test_galaxy_drive_eligibility.py b/openpilot/starpilot/drive_state/tests/test_galaxy_drive_eligibility.py index e4fcc657f5..df119f6ef2 100644 --- a/openpilot/starpilot/drive_state/tests/test_galaxy_drive_eligibility.py +++ b/openpilot/starpilot/drive_state/tests/test_galaxy_drive_eligibility.py @@ -183,3 +183,19 @@ def test_local_force_offroad_with_ignition_on_keeps_park_authority_separate(fixt assert f.source.allowed() is (not moving and not engaged) if moving or engaged: assert not f.source.update_ready(True) + + +def test_galaxy_force_offroad_preserves_exact_rejection_reason_and_auto_recovery(tmp_path): + params = Params() + owner = DriveStateOwner(params, tmp_path / 'reason', alive=lambda _: True) + owner.initialize(pid=1, birth=2, boot=BOOT) + physical = NS(status=Mock(return_value={'allowed': False, 'reason': 'Current speed and gear are unavailable'})) + control = DriveStateControl(owner, physical, effective=lambda: False) + revision = owner.snapshot().revision + before = list(params.writes) + with pytest.raises(Rejected, match='Current speed and gear are unavailable'): + control.change_galaxy('offroad', revision, lambda: True) + assert params.writes == before + assert owner.snapshot().mode == Mode.AUTO + control.change_galaxy('auto', revision, lambda: True) + assert owner.snapshot().mode == Mode.AUTO diff --git a/openpilot/starpilot/external_preferences_reset.py b/openpilot/starpilot/external_preferences_reset.py index dc2ec31242..e6e24eef24 100644 --- a/openpilot/starpilot/external_preferences_reset.py +++ b/openpilot/starpilot/external_preferences_reset.py @@ -64,10 +64,12 @@ def reset_external_preferences(storage, model_root): locks.enter_context(_owner_lock(state, '.lock')) locks.enter_context(_owner_lock(model_root, '.state.lock')) locks.enter_context(_owner_lock(navigation_root, '.lock')) + locks.enter_context(_owner_lock(storage / 'software' / 'auto-install', '.lock')) if _read(state / 'complete.json') == identity: return () paths = {'model_preferences': model_root / 'preferences.json', - 'navigation_settings': navigation_root / 'settings.json'} + 'navigation_settings': navigation_root / 'settings.json', + 'software_install': storage / 'software' / 'auto-install' / 'settings.json'} journal_path = state / 'journal.json' journal_raw = _read(journal_path) if journal_raw is None: diff --git a/openpilot/starpilot/galaxy/README.md b/openpilot/starpilot/galaxy/README.md index 64e81ea212..a91e1b6e21 100644 --- a/openpilot/starpilot/galaxy/README.md +++ b/openpilot/starpilot/galaxy/README.md @@ -38,7 +38,7 @@ it labels its sample System Monitor data and cannot edit a device. | Home and Tools | Local status cards, navigation, theme, drawer, pinned tiles, and hash routes. | | System Monitor and Plots | Bounded local process/device and driving-message samples. Unavailable data stays unknown; leaving a page stops its polling. | | Logs and Routes | Crash-report previews and a shared route workspace for camera footage, driving UI replay, segment details, and scoped footage/replay/log downloads. | -| Software & Updates | Read-only installed and updater status. Refresh rereads reported Params; it does not check, download, switch branches, install, roll back, or reboot. | +| Software & Updates | Parked update controls and a newest-first version list. Installed and fully cached commits are marked; selecting a commit offers installation or download and installation. Initially shows up to two newer commits, the installed commit, and the previous installed commit. Collapsed gaps above and below the installed commit expand independently; older history remains available below the previous version. Each gap shows its own remaining count and reveals at most ten commits per click inside a scroll area bounded by the viewport. Release notes follow the selected installed or prepared version. | | Driving and Device Preferences | Saved settings through typed owners shared with native controls, including SLC, lateral and longitudinal tuning, conditional modes, display, cameras, and sounds. | | Vehicle Controls | One saved manual vehicle choice for the next start, or Auto for normal detection. | | Models and Model Laboratory | Verified model catalog management and parked Laboratory configuration. Paired inference is unavailable in the production model runner. | @@ -48,6 +48,21 @@ it labels its sample System Monitor data and cannot edit a device. | Sound packs | Catalog status, verified download and cancellation through the local sound owner. | | Galaxy Access & Install | Remote link pairing, Galaxy installation, and an opt-in Wi-Fi hotspot. Local addresses are on Home. | +`Refresh` and branch changes request filtered +history metadata in a separate Git cache and worker. They remain available during +background staging. `Check for updates` also wakes the background updater after the metadata check. When automatic downloads are enabled, a newer commit wakes +the separate download phase. Update and download actions retrieve the chosen +snapshot's missing file contents; version browsing uses `--filter=blob:none` with +bounded history depth. The branch selector lives in the version list and refreshes its metadata without changing the updater target. `Download only` prepares the selected commit without restarting; `Select previous version` selects the saved branch and commit without installing it. Downloads report Git object progress and finalization +reports copied files; Git garbage collection is outside the finalization path. + +Automatic installation is opt-in and defaults off. Its five numeric cron fields +(minute, hour, day, month, weekday) default to `0 0 * * *`: daily at midnight in +the saved local timezone shown in Advanced options. The timezone selector saves an IANA timezone with the schedule, including daylight-saving rules. The updater only restarts for a +validated, prepared update while idle and freshly confirmed offroad. It skips +onroad occurrences and reserves each scheduled minute before restarting to avoid +reboot loops. These preferences live in the StarPilot software state directory. + The Vue router in `web/js/app.js` and the route handlers in `server.py` are the current route map. API groups include `/api/auth`, `/api/system/monitor`, `/api/software/status`, `/api/settings`, `/api/models`, `/api/maps`, diff --git a/openpilot/starpilot/galaxy/software_operations.py b/openpilot/starpilot/galaxy/software_operations.py index ccd8526110..9a6ce0ebd8 100644 --- a/openpilot/starpilot/galaxy/software_operations.py +++ b/openpilot/starpilot/galaxy/software_operations.py @@ -11,6 +11,8 @@ import subprocess import threading import time from collections.abc import Callable +from datetime import datetime +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from openpilot.common.basedir import BASEDIR from openpilot.common.hardware import COMMA_HARDWARE @@ -20,7 +22,8 @@ from openpilot.starpilot.galaxy.software_status import SoftwareStatus, SoftwareU from openpilot.starpilot.saved_source import read_saved from openpilot.starpilot.software.update_control import UpdaterControlError from openpilot.starpilot.software.preferences import AUTOMATIC_DOWNLOADS, automatic_downloads -from openpilot.starpilot.software.history import commit_history, release_notes, recent_versions +from openpilot.starpilot.software.auto_install import DEFAULT_CRON, InstallSettings, cron_fields, next_restart +from openpilot.starpilot.software.history import METADATA_ROOT, commit_history, downloaded_versions, previous_version, release_notes, recent_versions FINALIZED = Path(os.environ.get("UPDATER_STAGING_ROOT", "/data/safe_staging")) / "finalized" @@ -28,7 +31,7 @@ BRANCH_PATTERN = re.compile(r"[A-Za-z0-9_][A-Za-z0-9._/+@-]{0,127}\Z") COMMIT_PATTERN = re.compile(r"[0-9a-f]{40}\Z") MAX_BRANCH_LIST = 32768 MAX_BRANCHES = 256 -SOURCE_ACTIONS = frozenset(("update", "fast", "rollback", "versions", "version")) +SOURCE_ACTIONS = frozenset(("update", "fast", "rollback", "versions", "version", "download-version")) PROCESS_TITLE = b"openpilot.system.updated.updated" @@ -103,7 +106,7 @@ class UpdaterProcess: return self.current_identity() is not None def send(self, action: str, *, branch=None, commit=None, selected_commit=None) -> tuple[int, int]: - if action not in ("check", "download", "update", "fast", "rollback", "versions", "version"): + if action not in ("check", "download", "update", "fast", "rollback", "versions", "version", "download-version"): raise SoftwareOperationError("Invalid updater command", 400) with self.lock: pid = self._manager_pid() @@ -111,7 +114,7 @@ class UpdaterProcess: if self._manager_pid() != pid or self._proc_identity(pid) != start: raise SoftwareOperationError("Updater process changed", 503) try: - if action == 'version': + if action in ('version', 'download-version'): self.control.send(pid, start, action, branch=branch, commit=commit, selected_commit=selected_commit) elif action in ("update", "fast", "rollback", "versions"): self.control.send(pid, start, action, branch=branch, commit=commit) @@ -146,7 +149,8 @@ def _git_identity(path: Path) -> tuple[str, str] | None: class SoftwareOperations: def __init__(self, params=None, status=None, parked=None, process=None, *, finalized=FINALIZED, - installed=BASEDIR, git_identity=_git_identity, clock=time.monotonic, history_reader=commit_history, refresh=None): + installed=BASEDIR, git_identity=_git_identity, clock=time.monotonic, history_reader=commit_history, refresh=None, + metadata=METADATA_ROOT, install_settings=None): self.params = params if params is not None else Params() self.status = status if status is not None else SoftwareStatus(self.params) if parked is None: @@ -160,6 +164,9 @@ class SoftwareOperations: self.process = process if process is not None else UpdaterProcess() self.refresh = refresh self.finalized = Path(finalized) + self.staging = self.finalized.parent / 'merged' + self.metadata = Path(metadata) + self.install_settings = install_settings if install_settings is not None else InstallSettings() self.installed = Path(installed) self.git_identity = git_identity self.clock = clock @@ -252,6 +259,20 @@ class SoftwareOperations: if self._baseline is None or request is None or request["state"] != "pending" or request["action"] == "install": return state = status["updater"]["state"] + check = (status['updater'].get('fast') or {}).get('check') + if request['action'] in ('check', 'versions') and check is not None: + completed = check.get('completedAt') + if (type(completed) in (int, float) and completed >= self._started and + (request['target'] is None or check['branch'] == request['target'])): + if check['state'] == 'complete': + request.update(state='complete', error=None) + elif check['state'] == 'error': + request.update(state='failed', error=check['detail']) + elif current_owner is not None and self._request_owner is not None and current_owner != self._request_owner: + request.update(state='failed', error='Metadata check was interrupted when the updater restarted. Retry the check.') + elif self.clock() - self._started > 90: + request.update(state='failed', error='Metadata check did not report completion. Retry the check.') + return if (request["action"] not in SOURCE_ACTIONS and request["target"] is not None and status["updater"]["targetBranch"] != request["target"]): request.update(state="failed", error="Updater target changed") @@ -273,7 +294,12 @@ class SoftwareOperations: if status["updater"]["lastFetchAt"] != self._baseline[1]: installed = self.git_identity(self.installed) restarting = self._flag("DoReboot") is True or installed is not None and installed[1] != self._fast_commit - if request['action'] == 'versions': + if request['action'] == 'download-version': + if self._ready(status, request['target']) and self.git_identity(self.finalized) == (request['target'], request['selectedCommit']): + request.update(state='complete', error=None) + else: + request.update(state='failed', error='Selected version was not prepared; refresh and try again') + elif request['action'] == 'versions': request.update(state='complete', error=None) else: request.update(state="complete", error=None, outcome="restarting" if restarting else "up_to_date") @@ -330,10 +356,15 @@ class SoftwareOperations: branches = self._branches() updater_idle = status["updater"]["state"] == "idle" pending = self.request is not None and self.request["state"] == "pending" + fast = status['updater'].get('fast') or {} + metadata_busy = fast.get('check', {}).get('state') == 'checking' or pending and self.request['action'] in ('check', 'versions') disabled_state = self._flag("DisableUpdates") disabled = disabled_state is not False reboot = self._flag("DoReboot") is not False - usable = parked and updater_idle and not disabled and not pending and not reboot and process + usable = parked and updater_idle and not disabled and not pending and not reboot and process and not metadata_busy + can_check = (parked and not disabled and not reboot and process and not metadata_busy and + status['updater']['state'] in ('idle', 'checking...', 'downloading...', 'finalizing update...') and + (not pending or self.request['action'] in ('download', 'download-version'))) target = self.selected_target or status["updater"]["targetBranch"] or status["installed"]["branch"] selected = target is not None and target in branches and status["updater"]["targetBranch"] == target reason = None @@ -345,28 +376,41 @@ class SoftwareOperations: reason = "Reboot is pending" elif not process: reason = "Getting the updater ready…" if COMMA_HARDWARE else "Updater status is unavailable" - elif pending or not updater_idle: + elif pending or not updater_idle or metadata_busy: reason = "Updater is busy" - fast = status["updater"].get("fast") - stage = fast["stage"] if fast and status["updater"]["state"] == "updating..." else status["updater"]["state"] + fast = status["updater"].get("fast") or {} + active = (status['updater']['state'] == 'updating...' or + status['updater']['state'] in ('downloading...', 'finalizing update...') and + fast.get('stage') not in ('complete', 'error')) + stage = fast["stage"] if fast and active else status["updater"]["state"] if stage == "idle": stage = None - detail = fast["detail"] if fast and status["updater"]["state"] == "updating..." else "" + detail = fast["detail"] if fast and active else "" if not process and not updater_idle: stage = "unavailable" detail = ("Waiting for the device updater service…" if COMMA_HARDWARE else "Update checks require the device updater service, which is unavailable when Galaxy runs alone on a development computer.") elif self.request is not None and self.request.get("error") == "Updater did not report completion": stage, detail = "timed_out", "Request timed out; the updater may still be running" + install = self.install_settings.read() + prepared = self.git_identity(self.finalized) if status["updater"]["finalizedUpdateReady"] else None + restart = next_restart(install["cron"], datetime.now(ZoneInfo(install.get("timezone", "UTC")))) if install and install["enabled"] and prepared else None return { "repository": self._repository(), - "updaterAvailable": process, "progress": {"stage": stage, "detail": detail, "percent": None}, + "updaterAvailable": process, "progress": {"stage": stage, "detail": detail, "percent": fast.get('percent') if active else None, + **({'background': fast['background']} if active and 'background' in fast else {})}, "parked": parked, "availableBranches": branches, "selectedTarget": target, - "canCheck": usable, "canFastUpdate": usable and self._valid_branch(status["installed"]["branch"]), "canSelect": usable and bool(branches), + "canCheck": can_check, "canFastUpdate": usable and self._valid_branch(status["installed"]["branch"]), "canSelect": usable and bool(branches), "canRollback": usable and self._rollback_available(), "canDownload": usable and selected, "canInstall": usable and selected and self._ready(status, target), "reason": reason, "request": dict(self.request) if self.request is not None else None, "automaticDownloads": automatic_downloads(self.params), "canConfigure": parked and not reboot, + "automaticInstalls": install['enabled'] if install else None, + "restartSchedule": install['cron'] if install else DEFAULT_CRON, + "installPreferenceRevision": install['revision'] if install else None, + "restartTimezone": install.get("timezone", "UTC") if install else "UTC", + "preparedCommit": prepared[1] if prepared else None, + "nextRestartAt": restart.isoformat() if restart else None, **({"history": self._history_snapshot(status)} if include_history else {}), } @@ -377,20 +421,32 @@ class SoftwareOperations: def _history_snapshot(self, status: dict) -> dict: branch = self.selected_target or status['updater']['targetBranch'] or status['installed']['branch'] - key = (status['installed']['commit'], self._param('UpdaterNewDescription'), branch, status['updater']['lastFetchAt']) + key = (status['installed']['commit'], self._param('UpdaterNewDescription'), branch, + status['updater']['lastFetchAt'], status['updater'].get('lastCheckedAt'), status['updater']['state']) now = self.clock() if self._history is not None and self._history_key == key and 0 <= now - self._history_at < 30.: return self._history recent = recent_versions(self.installed, branch) + staged_recent = recent_versions(self.staging, branch) + if staged_recent['head'] is not None: + recent = staged_recent + elif recent['head'] is None: + recent = recent_versions(self.finalized, branch) + metadata = recent_versions(self.metadata, branch) + if metadata['head'] is not None: + recent = metadata installed = self.git_identity(self.installed) downloaded = self.git_identity(self.finalized) - current_rows = self.history_reader(self.installed, installed[1]) if installed else [] + current_rows = self.history_reader(self.installed, installed[1], first_parent=True) if installed else [] + previous = previous_version(self.installed) downloaded_rows = (self.history_reader(self.finalized, downloaded[1]) if downloaded and downloaded != installed else []) # Readiness and installation still require _ready(); a history entry never # grants permission to boot or execute a revision. self._history = { - 'installed': current_rows, 'downloaded': downloaded_rows, 'recent': recent, + 'installed': current_rows, 'downloaded': downloaded_rows, 'recent': recent, 'previous': previous, + 'downloadedCommits': downloaded_versions((self.staging if self.staging.exists() else self.finalized, self.installed), + (row['hash'] for row in [*recent['entries'], *current_rows, *([previous] if previous else [])])), 'currentReleaseNotes': release_notes(self.params, 'UpdaterCurrentReleaseNotes'), 'downloadedReleaseNotes': release_notes(self.params, 'UpdaterNewReleaseNotes') if downloaded_rows else None, } @@ -419,9 +475,41 @@ class SoftwareOperations: raise SoftwareOperationError('Update preference could not be verified', 503) return self.snapshot() + def _install_preferences(self, payload: dict, authorized: Callable[[], bool]) -> dict: + if (set(payload) not in ({'action', 'automaticInstalls', 'restartSchedule', 'expectedRevision'}, + {'action', 'automaticInstalls', 'restartSchedule', 'expectedRevision', 'restartTimezone'}) or + type(payload['automaticInstalls']) is not bool or type(payload['restartSchedule']) is not str or + payload['expectedRevision'] is not None and + (type(payload['expectedRevision']) is not str or not re.fullmatch('[0-9a-f]{32}', payload['expectedRevision']))): + raise SoftwareOperationError('Invalid install preferences', 400) + try: + cron_fields(payload['restartSchedule']) + if 'restartTimezone' in payload: + if not isinstance(payload['restartTimezone'], str) or len(payload['restartTimezone']) > 128: + raise ValueError('Invalid timezone') + ZoneInfo(payload['restartTimezone']) + except (ValueError, ZoneInfoNotFoundError) as error: + raise SoftwareOperationError(str(error), 400) from error + with self.lock: + if not authorized(): + raise SoftwareOperationError('Session expired', 403) + if not self._parked() or self._flag('DoReboot') is not False: + raise SoftwareOperationError('Park the vehicle to change install preferences', 409) + def admitted(): + return authorized() and self._parked() and self._flag('DoReboot') is False + try: + self.install_settings.save(payload['automaticInstalls'], payload['restartSchedule'], payload['expectedRevision'], admitted, + timezone=payload.get('restartTimezone')) + except PermissionError as error: + raise SoftwareOperationError(str(error), 403) from error + except ValueError as error: + raise SoftwareOperationError(str(error), 409) from error + return self.snapshot() + def action(self, action: str, payload: object, *, authorized: Callable[[], bool]) -> dict: if (type(payload) is not dict or type(action) is not str or - action not in ("check", "download", "select", "install", "preferences", "update", "fast", "rollback", "versions", "version")): + action not in ("check", "download", "select", "install", "preferences", "install-preferences", "update", "fast", "rollback", + "versions", "version", "download-version")): raise SoftwareOperationError("Invalid software action", 400) assert isinstance(payload, dict) payload = dict(payload) @@ -429,24 +517,31 @@ class SoftwareOperations: raise SoftwareOperationError("Invalid software action", 400) if action == 'preferences': return self._preferences(payload, authorized) + if action == 'install-preferences': + return self._install_preferences(payload, authorized) fields = set(payload) - {"action"} if action == "check": if fields: raise SoftwareOperationError("Invalid software action", 400) branch = None else: - required_fields = {"branch", "expectedCommit", "selectedCommit"} if action == "version" else {"branch"} + pinned_install = action == "install" and fields != {"branch"} + required_fields = {"branch", "expectedCommit", "selectedCommit"} if action in ("version", "download-version") or pinned_install else {"branch"} if fields != required_fields or not self._valid_branch(payload.get("branch")): raise SoftwareOperationError("Invalid branch", 400) + if pinned_install and any(type(payload[key]) is not str or not COMMIT_PATTERN.fullmatch(payload[key]) + for key in ('expectedCommit', 'selectedCommit')): + raise SoftwareOperationError("Invalid selected version", 400) branch = payload["branch"] assert isinstance(branch, str) with self.lock: if not authorized(): raise SoftwareOperationError("Session expired", 403) - view = self.snapshot() + metadata_action = action in ('check', 'versions') + view = self.snapshot(include_history=action in ('version', 'download-version')) if not view["parked"]: raise SoftwareOperationError("Vehicle is not safely parked", 409) - if view["reason"] is not None: + if metadata_action and not view['canCheck'] or not metadata_action and view["reason"] is not None: raise SoftwareOperationError(view["reason"], 503 if view["reason"] == "Updater status is unavailable" else 409) if action not in SOURCE_ACTIONS and branch is not None and branch not in view["availableBranches"]: raise SoftwareOperationError("Branch is not in the current updater list", 409) @@ -456,35 +551,44 @@ class SoftwareOperations: if action in SOURCE_ACTIONS: identity = self.git_identity(self.installed) if (identity is None or not self._valid_branch(identity[0]) or - (action in ("update", "fast", "versions", "version") and branch != identity[0] and branch not in view["availableBranches"]) or + (action in ("update", "fast", "versions", "version", "download-version") and + branch != identity[0] and branch not in view["availableBranches"]) or (action == "rollback" and (branch != identity[0] or not view["canRollback"]))): raise SoftwareOperationError("Installed checkout or update target is unavailable", 409) - if action == 'version': + if action in ('version', 'download-version'): target = payload['selectedCommit'] recent = view['history']['recent'] + selectable = [*recent['entries'], *(view['history']['installed'] if branch == identity[0] else [])] if (payload['expectedCommit'] != identity[1] or type(target) is not str or not COMMIT_PATTERN.fullmatch(target) or - recent['branch'] != branch or not any(row['hash'] == target for row in recent['entries'])): + recent['branch'] != branch or not any(row['hash'] == target for row in selectable)): raise SoftwareOperationError('Selected version or installed revision changed; refresh and try again', 409) if action == "install" and not view["canInstall"]: raise SoftwareOperationError("Finalized update does not match selected branch", 409) if not authorized() or not self._parked(): raise SoftwareOperationError("Session or parked state changed", 403) current = self.status.snapshot() - if (current["updater"]["state"] != "idle" or self._flag("DisableUpdates") is not False or + if (not metadata_action and current["updater"]["state"] != "idle" or self._flag("DisableUpdates") is not False or self._flag("DoReboot") is not False or not self.process.available() or (action not in SOURCE_ACTIONS and branch is not None and branch not in self._branches())): raise SoftwareOperationError("Updater state changed", 409) + if action == 'versions': + self.selected_target = branch if action == "select": self.params.put("UpdaterTargetBranch", branch, block=True) self.selected_target = branch return self.snapshot() accepted_owner = None + request_started = self.clock() if action == "install": status = self.status.snapshot() if not self._ready(status, branch) or self._flag("DoReboot") is not False: raise SoftwareOperationError("Update is no longer ready", 409) if not authorized() or not self._parked(): raise SoftwareOperationError("Session or parked state changed", 403) + if 'selectedCommit' in payload and (status['installed']['commit'] != payload['expectedCommit'] or + self.git_identity(self.installed) != (status['installed']['branch'], payload['expectedCommit']) or + self.git_identity(self.finalized) != (branch, payload['selectedCommit'])): + raise SoftwareOperationError("Selected version changed; refresh before installing", 409) self.params.put_bool("DoReboot", True, block=True) else: status = self.status.snapshot() @@ -496,11 +600,11 @@ class SoftwareOperations: latest_identity = self.git_identity(self.installed) if latest_identity is None or latest_identity != identity: raise SoftwareOperationError("Installed checkout changed while preparing the update; retry when it finishes", 409) - if action == 'version' and identity[1] != payload['expectedCommit']: + if action in ('version', 'download-version') and identity[1] != payload['expectedCommit']: raise SoftwareOperationError('Installed revision changed before version request; refresh and try again', 409) self._fast_commit = identity[1] if action in SOURCE_ACTIONS: - if action == 'version': + if action in ('version', 'download-version'): accepted_owner = self.process.send(action, branch=branch, commit=identity[1], selected_commit=payload['selectedCommit']) else: accepted_owner = self.process.send(action, branch=branch, commit=identity[1]) @@ -509,12 +613,12 @@ class SoftwareOperations: self.request = {"id": secrets.token_hex(8), "action": action, "target": branch if branch is not None else status["updater"]["targetBranch"], "state": "pending", "error": None} - if action == 'version': + if action in ('version', 'download-version', 'install') and 'selectedCommit' in payload: self.request['selectedCommit'] = payload['selectedCommit'] self._baseline = baseline if action != "install" else None self._request_owner = accepted_owner - self._started = self.clock() - return self.snapshot() + self._started = request_started + return self.snapshot(include_history=not metadata_action) def close(self) -> None: if self.context is not None: diff --git a/openpilot/starpilot/galaxy/software_status.py b/openpilot/starpilot/galaxy/software_status.py index bead574d85..72bf6ebefa 100644 --- a/openpilot/starpilot/galaxy/software_status.py +++ b/openpilot/starpilot/galaxy/software_status.py @@ -27,12 +27,25 @@ def _fast_state(raw): try: value = json.loads(raw) if raw is not None else None if not isinstance(value, dict) or value.get("version") != 1 or value.get("stage") not in ( - "preparing", "fetching", "validating", "applying", "submodules", "rebooting", "complete", "error"): + "preparing", "fetching", "validating", "applying", "submodules", "rebooting", "complete", "error", "finalizing"): return None detail = value.get("detail") if not isinstance(detail, str) or len(detail) > 512: return None - return {"stage": value["stage"], "detail": detail} + result = {"stage": value["stage"], "detail": detail} + if type(value.get('background')) is bool: + result['background'] = value['background'] + percent = value.get('percent') + if type(percent) in (int, float) and 0 <= percent <= 100: + result['percent'] = percent + check = value.get('check') + if (isinstance(check, dict) and check.get('state') in ('checking', 'complete', 'error') and + isinstance(check.get('branch'), str) and len(check['branch']) <= 128 and + isinstance(check.get('detail'), str) and len(check['detail']) <= 512): + completed = check.get('completedAt') + if completed is None or type(completed) in (int, float) and 0 <= completed < float('inf'): + result['check'] = {key: check[key] for key in ('state', 'branch', 'detail', 'completedAt') if key in check} + return result except (TypeError, ValueError): return None diff --git a/openpilot/starpilot/galaxy/tests/browser/design-system.cjs b/openpilot/starpilot/galaxy/tests/browser/design-system.cjs index a74ce8aa2a..814d985e15 100644 --- a/openpilot/starpilot/galaxy/tests/browser/design-system.cjs +++ b/openpilot/starpilot/galaxy/tests/browser/design-system.cjs @@ -123,8 +123,11 @@ const assert = require('node:assert/strict') const url = new URL(route.request().url()) softwareRequests.push(url.search) await new Promise(resolve => setTimeout(resolve,250)) + if (route.request().method() === 'POST' && route.request().postDataJSON().action === 'versions') snapshot.operations.selectedTarget = route.request().postDataJSON().branch const operations = {...snapshot.operations} - if (route.request().method() === 'GET' && url.search !== '?history=0') operations.history = {installed:Array.from({length:20}, (_, index) => ({hash:index.toString(16).padStart(40,'0'),date:'2026-10-07T12:00:00Z',subject:index ? `Version ${index}: rendering and update fixes` : ''})),downloaded:[], + if (route.request().method() === 'GET' && url.search !== '?history=0') operations.history = {installed:[{hash:'a'.repeat(40),date:'2026-10-01T12:00:00Z',subject:'Installed fixture'}],downloaded:[], + previous:{hash:'b'.repeat(40),date:'2026-09-28T12:00:00Z',subject:'Previous fixture',branch:'Dom'}, + recent:{branch:operations.selectedTarget,head:'0'.repeat(40),entries:Array.from({length:20}, (_, index) => ({hash:index.toString(16).padStart(40,'0'),date:'2026-10-07T12:00:00Z',subject:index ? `Version ${index}: rendering and update fixes` : ''}))}, currentReleaseNotes:'Fixture release notes',downloadedReleaseNotes:null} await route.fulfill({json:{...snapshot,operations}}) }) @@ -166,25 +169,28 @@ const assert = require('node:assert/strict') await page.getByRole('dialog',{name:'Update software'}).waitFor() await page.getByRole('button',{name:'Cancel',exact:true}).click() await page.getByText('Advanced options',{exact:true}).click() - await page.getByRole('button',{name:'Previous version',exact:true}).click() - await page.getByRole('dialog').waitFor() - await page.getByRole('button',{name:'Cancel',exact:true}).click() - await page.getByRole('combobox',{name:'Target branch',exact:true}).click() - await page.getByRole('option',{name:/Development — Dom/}).click() - await page.getByRole('button',{name:'Update',exact:true}).click() - await page.getByRole('dialog',{name:'Update software'}).waitFor() - assert.equal(await page.getByRole('dialog').getByText('Install the latest version of Dom and restart?',{exact:true}).count(),1) - await page.getByRole('button',{name:'Cancel',exact:true}).click() - await page.getByRole('button',{name:'Save target',exact:true}).click() - await page.getByRole('dialog',{name:'Change target branch'}).waitFor() - await page.getByRole('button',{name:'Cancel',exact:true}).click() - await page.getByText('Versions & release notes',{exact:true}).click() - await page.getByRole('button',{name:'Installed',exact:true}).click() await page.getByText('Fixture release notes',{exact:true}).waitFor() - assert.equal(softwareRequests.at(-1),'','history is fetched only after opening release notes') - assert.equal(await page.locator('.gx-build-history li').count(),20) - assert.equal(await page.locator('.gx-build-history li strong').first().textContent(),'000000000000') - assert.equal(await page.locator('.gx-build-history').evaluate(element=>getComputedStyle(element).listStyleType),'none') + assert.equal(await page.locator('.gx-version-row').count(),3) + assert.equal(await page.locator('.gx-version-row strong').first().textContent(),'00000000') + assert.equal(await page.locator('.gx-version-list').evaluate(element=>getComputedStyle(element).listStyleType),'none') + assert.equal(await page.getByRole('button',{name:'No changes selected',exact:true}).isEnabled(),false) + await page.getByRole('button',{name:'Load 10 of 18 collapsed versions',exact:true}).click() + assert.equal(await page.locator('.gx-version-row').count(),13) + await page.getByRole('button',{name:'Load 8 collapsed versions',exact:true}).click() + assert.equal(await page.locator('.gx-version-row').count(),21) + await page.getByRole('button',{name:'Select previous version',exact:true}).click() + await page.getByRole('combobox',{name:'Version branch',exact:true}).filter({hasText:'Development — Dom'}).waitFor() + assert.equal(await page.getByRole('dialog').count(),0,'selecting previous does not install it') + await page.getByRole('button',{name:'Install selected version',exact:true}).click() + await page.getByRole('dialog',{name:'Previous version'}).waitFor() + await page.getByRole('button',{name:'Cancel',exact:true}).click() + await page.getByRole('combobox',{name:'Version branch',exact:true}).click() + await page.getByRole('option',{name:/Stable — StarPilot/}).click() + await page.getByText('Fixture release notes',{exact:true}).waitFor() + await page.getByRole('button',{name:'Load 10 of 18 collapsed versions',exact:true}).click() + await page.getByRole('button',{name:'Load 8 collapsed versions',exact:true}).click() + const scroll = await page.locator('.gx-version-scroll').evaluate(element=>({height:element.clientHeight,content:element.scrollHeight,viewport:innerHeight})) + assert.ok(scroll.content>scroll.height && scroll.height<=scroll.viewport*.65+1) assert.equal(await page.getByRole('button',{name:'Downloaded',exact:true}).count(),0) assert.equal(await check.isEnabled(),true,'history refresh keeps update controls enabled') const backup = {format:'galaxy-toggles',version:1,createdAt:'2026-10-07T12:00:00Z',metric:false,vehicle:null, diff --git a/openpilot/starpilot/galaxy/tests/test_drive_state.mjs b/openpilot/starpilot/galaxy/tests/test_drive_state.mjs index 61485cf47b..85ac900a65 100644 --- a/openpilot/starpilot/galaxy/tests/test_drive_state.mjs +++ b/openpilot/starpilot/galaxy/tests/test_drive_state.mjs @@ -26,7 +26,7 @@ last.resolve(response(snapshot('offroad')));await pending assert.equal(value.state.mode,'onroad') const denied=panel();const loading=denied.load();requests.at(-1).resolve(response({error:'Sign in to Galaxy'},401));await loading assert.equal(denied.state,null) -assert.equal(denied.error,'Drive state unavailable') +assert.equal(denied.statusError,'Drive state unavailable') console.log('PASS: strict status; captured revision; aborted/out-of-order status; requested versus effective; unmount; unauthorized state') const onroad=panel();onroad.state={...snapshot(),effective:'onroad',overrideAllowed:false} @@ -49,3 +49,27 @@ requests.at(-1).resolve(response(live));await forceOnroad;await flush() assert.equal(onroad.state.mode,'onroad') assert.equal(DriveStatePanel.computed.pending.call(onroad),false) console.log('PASS: Galaxy Offroad dispatch uses server admission; Force Onroad remains available') + +for (const [offroadOnly, lockout, mode, expected] of [ + [true,true,'auto',true], [true,false,'auto',false], [true,false,'offroad',true], [false,false,'auto',true], +]) assert.equal(DriveStatePanel.computed.visible.call({offroadOnly,lockout,state:snapshot(mode)}),expected) +assert.match(DriveStatePanel.template,/Force device offroad/) + +// A rejected enable must not look enabled or lose its error during status polling. +const rejectedToggle=panel();rejectedToggle.offroadOnly=true;rejectedToggle.lockout=false;rejectedToggle.state=snapshot() +const input={checked:true} +const attempt=rejectedToggle.toggleOffroad({target:input}) +assert.equal(input.checked,false,'wait for the server to accept the mode') +requests.at(-1).resolve(response({error:'Current speed and gear are unavailable'},409));await flush() +requests.at(-1).resolve(response(snapshot()));await attempt;await flush() +assert.equal(input.checked,false) +assert.equal(rejectedToggle.state.mode,'auto') +assert.equal(rejectedToggle.error,'Current speed and gear are unavailable') +assert.equal(DriveStatePanel.computed.visible.call(rejectedToggle),true,'keep the rejected action visible') +input.checked=true +const retry=rejectedToggle.toggleOffroad({target:input}) +assert.equal(rejectedToggle.error,'') +requests.at(-1).resolve(response(snapshot('offroad','e'.repeat(32))));await flush() +requests.at(-1).resolve(response(snapshot('offroad','e'.repeat(32))));await retry;await flush() +assert.equal(input.checked,true) +assert.equal(rejectedToggle.state.mode,'offroad') diff --git a/openpilot/starpilot/galaxy/tests/test_software_http.py b/openpilot/starpilot/galaxy/tests/test_software_http.py index 33cc170d92..431ebe3ded 100644 --- a/openpilot/starpilot/galaxy/tests/test_software_http.py +++ b/openpilot/starpilot/galaxy/tests/test_software_http.py @@ -15,6 +15,7 @@ from openpilot.starpilot.galaxy.server import make_remote_server, make_server from openpilot.starpilot.galaxy.software_operations import SoftwareOperations from openpilot.starpilot.galaxy.software_status import SoftwareStatus from openpilot.starpilot.galaxy.tests.test_software_operations import FakeProcess +from openpilot.starpilot.software.auto_install import InstallSettings class SoftwareHttpTest(unittest.TestCase): @@ -37,7 +38,8 @@ class SoftwareHttpTest(unittest.TestCase): self.process = FakeProcess() self.parked = True self.operations = SoftwareOperations(self.params, parked=lambda: self.parked, process=self.process, - installed=installed, finalized=root / 'finalized') + installed=installed, finalized=root / 'finalized', metadata=root / 'metadata.git', + install_settings=InstallSettings(root / 'install-settings')) self.access = GalaxyAccessOwner(root / 'access') self.pairing = RemotePairing(root / 'pairing') self.local = make_server(port=0, owner=self.access, remote_pairing=self.pairing, @@ -103,6 +105,24 @@ class SoftwareHttpTest(unittest.TestCase): self.assertEqual(self.request('/api/software/action', payload=payload, remote=remote, cookie=cookie)[0], 409) self.assertEqual(self.process.sent, []) + def test_local_and_remote_auto_install_schedule_roundtrip_requires_offroad(self): + for remote in (False, True): + cookie = self.remote_cookie if remote else self.local_cookie + status, value, _ = self.request('/api/software/status?history=0', remote=remote, cookie=cookie) + self.assertEqual(status, 200) + payload = {'action': 'install-preferences', 'automaticInstalls': True, 'restartSchedule': '30 2 * * 1-5', + 'expectedRevision': value['operations']['installPreferenceRevision']} + self.parked = False + self.assertEqual(self.request('/api/software/action', payload=payload, remote=remote, cookie=cookie)[0], 409) + self.parked = True + status, saved, _ = self.request('/api/software/action', payload=payload, remote=remote, cookie=cookie) + self.assertEqual(status, 200) + self.assertTrue(saved['operations']['automaticInstalls']) + self.assertEqual(saved['operations']['restartSchedule'], '30 2 * * 1-5') + self.assertEqual(self.request('/api/software/action', payload=payload, remote=remote, cookie=cookie)[0], 409) + self.assertEqual(self.process.sent, []) + self.assertFalse(self.params.get_bool('DoReboot')) + def test_remote_auth_origin_and_parked_rules_cannot_be_bypassed(self): payload = {'action': 'preferences', 'automaticDownloads': False, 'expectedAutomaticDownloads': True} self.assertEqual(self.request('/api/software/status', remote=True)[0], 401) diff --git a/openpilot/starpilot/galaxy/tests/test_software_operations.py b/openpilot/starpilot/galaxy/tests/test_software_operations.py index 4032509405..f5b155d59b 100644 --- a/openpilot/starpilot/galaxy/tests/test_software_operations.py +++ b/openpilot/starpilot/galaxy/tests/test_software_operations.py @@ -10,6 +10,7 @@ from openpilot.starpilot.galaxy.software_operations import ( SoftwareOperations, SoftwareOperationError, UpdaterProcess, PROCESS_TITLE, ) from openpilot.starpilot.software.update_control import UpdaterControlError +from openpilot.starpilot.software.auto_install import InstallSettings class FakeProcess: @@ -34,6 +35,12 @@ class FakeProcess: class SoftwareOperationsTest(unittest.TestCase): def setUp(self): + hardware = mock.patch('openpilot.starpilot.galaxy.software_operations.COMMA_HARDWARE', False) + hardware.start() + self.addCleanup(hardware.stop) + downloads = mock.patch('openpilot.starpilot.galaxy.software_operations.downloaded_versions', return_value=[]) + downloads.start() + self.addCleanup(downloads.stop) self.temp = tempfile.TemporaryDirectory() self.addCleanup(self.temp.cleanup) root = Path(self.temp.name) @@ -51,13 +58,15 @@ class SoftwareOperationsTest(unittest.TestCase): self.process = FakeProcess() self.parked = True self.authorized = True + self.metadata = root / "metadata.git" self.finalized = root / "finalized" self.finalized.mkdir() self.installed = root / "installed" self.installed.mkdir() self.git = {self.installed: ("main", "a" * 40), self.finalized: ("release", "b" * 40)} self.owner = SoftwareOperations(self.params, parked=lambda: self.parked, process=self.process, - finalized=self.finalized, installed=self.installed, + finalized=self.finalized, metadata=self.metadata, installed=self.installed, + install_settings=InstallSettings(root / 'install-settings'), git_identity=lambda path: self.git.get(path)) self.addCleanup(self.owner.close) @@ -86,7 +95,7 @@ class SoftwareOperationsTest(unittest.TestCase): self.parked = False self.owner = SoftwareOperations(self.params, parked=parked, process=self.process, refresh=refresh, - finalized=self.finalized, installed=self.installed, git_identity=lambda path: self.git.get(path)) + finalized=self.finalized, metadata=self.metadata, installed=self.installed, git_identity=lambda path: self.git.get(path)) self.addCleanup(self.owner.close) for expected in (True, False): order.clear() @@ -105,7 +114,7 @@ class SoftwareOperationsTest(unittest.TestCase): refresh = mock.Mock(side_effect=outcome) if isinstance(outcome, Exception) else mock.Mock(return_value=outcome) parked = mock.Mock(return_value=True) owner = SoftwareOperations(self.params, parked=parked, process=self.process, refresh=refresh, - finalized=self.finalized, installed=self.installed, git_identity=lambda path: self.git.get(path)) + finalized=self.finalized, metadata=self.metadata, installed=self.installed, git_identity=lambda path: self.git.get(path)) self.addCleanup(owner.close) view = owner.snapshot(include_history=False) refresh.assert_called_once_with() @@ -174,7 +183,7 @@ class SoftwareOperationsTest(unittest.TestCase): "detail": "Request timed out; the updater may still be running", "percent": None}) self.assertTrue(view["updaterAvailable"]) self.assertEqual(view["request"]["state"], "failed") - self.assertFalse(view["canCheck"]) + self.assertTrue(view["canCheck"]) self.assertEqual(self.params.get("UpdaterState"), "finalizing update...") def test_recent_version_admission_pins_installed_snapshot_and_listed_target(self): @@ -197,6 +206,47 @@ class SoftwareOperationsTest(unittest.TestCase): assert self.owner.request is not None self.assertEqual(self.owner.request['selectedCommit'], selected) + def test_branch_browsing_fetches_metadata_without_changing_updater_target(self): + result = self.act('versions', 'release') + self.assertEqual(self.params.get('UpdaterTargetBranch'), 'main') + self.assertEqual(result['selectedTarget'], 'release') + self.assertEqual(self.process.sent, ['versions']) + self.assertFalse(self.params.get_bool('DoReboot')) + + def test_download_selected_commit_pins_request_and_does_not_reboot(self): + selected = 'c' * 40 + recent = {'branch': 'main', 'head': selected, 'entries': [{'hash': selected, 'date': '', 'subject': 'Selected'}]} + payload = {'action': 'download-version', 'branch': 'main', 'expectedCommit': 'a' * 40, 'selectedCommit': selected} + with mock.patch('openpilot.starpilot.galaxy.software_operations.recent_versions', return_value=recent), \ + mock.patch.object(self.process, 'send') as send: + self.owner.action('download-version', payload, authorized=lambda: True) + send.assert_called_once_with('download-version', branch='main', commit='a' * 40, selected_commit=selected) + request = self.owner.request + assert request is not None + self.assertEqual(request['selectedCommit'], selected) + self.assertFalse(self.params.get_bool('DoReboot')) + self.git[self.finalized] = ('main', selected) + (self.finalized / '.overlay_consistent').touch() + self.params.put('UpdaterNewDescription', 'test / main / ccccccc / Oct 30', block=True) + self.params.put_bool('UpdateAvailable', True, block=True) + self.params.put('LastUpdateTime', datetime(2026, 10, 30), block=True) + self.params.put('UpdaterLastFetchTime', datetime(2026, 10, 30), block=True) + with mock.patch('openpilot.starpilot.galaxy.software_operations.validate_revision', return_value={}): + self.assertEqual(self.owner.snapshot()['request']['state'], 'complete') + self.assertFalse(self.params.get_bool('DoReboot')) + + def test_download_completion_handles_finalized_identity_disappearing(self): + request = {'action': 'download-version', 'state': 'pending', 'target': 'main', 'selectedCommit': 'c' * 40} + self.owner.request = request + self.owner._baseline = (None, None, 0) + self.params.put('LastUpdateTime', datetime(2026, 10, 30), block=True) + self.params.put('UpdaterLastFetchTime', datetime(2026, 10, 30), block=True) + with mock.patch.object(self.owner, '_ready', return_value=True), \ + mock.patch.object(self.owner, 'git_identity', return_value=None): + self.owner._refresh_request(self.owner.status.snapshot()) + self.assertEqual(request['state'], 'failed') + self.assertEqual(request['error'], 'Selected version was not prepared; refresh and try again') + def test_version_installed_snapshot_cannot_change_at_final_dispatch(self): selected = 'c' * 40 recent = {'branch': 'main', 'head': selected, @@ -308,6 +358,25 @@ class SoftwareOperationsTest(unittest.TestCase): self.denied("install", 409, "release") self.assertEqual(self.process.sent, []) + def test_metadata_check_is_fast_and_completes_during_background_finalization(self): + self.params.put('UpdaterState', 'finalizing update...', block=True) + fast = {'version': 1, 'stage': 'finalizing', 'detail': 'Copying files: 44%', 'percent': 44, 'background': True} + self.params.put('UpdaterFastState', fast, block=True) + with mock.patch.object(self.owner, '_history_snapshot') as history: + view = self.owner.snapshot(include_history=False) + self.assertTrue(view['canCheck']) + self.assertFalse(view['canFastUpdate']) + result = self.act('check') + history.assert_not_called() + self.assertEqual(result['request']['state'], 'pending') + fast['check'] = {'state': 'complete', 'branch': 'main', 'detail': 'Metadata ready', 'completedAt': self.owner._started + 1} + self.params.put('UpdaterFastState', fast, block=True) + result = self.owner.snapshot(include_history=False) + self.assertEqual(result['request']['state'], 'complete') + self.assertEqual(result['progress']['percent'], 44) + self.assertTrue(result['progress']['background']) + self.assertEqual(self.params.get('UpdaterState'), 'finalizing update...') + def test_target_change_or_failure_is_reported(self): self.act("select", "release") self.act("download", "release") @@ -318,6 +387,39 @@ class SoftwareOperationsTest(unittest.TestCase): self.params.put("UpdateFailedCount", 1, block=True) self.assertEqual(self.owner.snapshot()["request"]["state"], "failed") + def test_selected_install_rejects_a_different_downloaded_commit(self): + self.act('select', 'release') + self.params.put_bool('UpdateAvailable', True, block=True) + self.params.put('UpdaterNewDescription', 'test / release / bbbbbbb / Sep 27', block=True) + (self.finalized / '.overlay_consistent').touch() + payload = {'action': 'install', 'branch': 'release', 'expectedCommit': 'a' * 40, 'selectedCommit': 'c' * 40} + with mock.patch('openpilot.starpilot.galaxy.software_operations.validate_revision', return_value={}): + with self.assertRaisesRegex(SoftwareOperationError, 'Selected version changed'): + self.owner.action('install', payload, authorized=lambda: True) + self.assertFalse(self.params.get_bool('DoReboot')) + payload['selectedCommit'] = 'b' * 40 + result = self.owner.action('install', payload, authorized=lambda: True) + self.assertEqual(result['request']['selectedCommit'], 'b' * 40) + self.assertTrue(self.params.get_bool('DoReboot')) + + def test_selected_install_rechecks_commit_after_authorization(self): + self.act('select', 'release') + self.params.put_bool('UpdateAvailable', True, block=True) + self.params.put('UpdaterNewDescription', 'test / release / bbbbbbb / Sep 27', block=True) + (self.finalized / '.overlay_consistent').touch() + payload = {'action': 'install', 'branch': 'release', 'expectedCommit': 'a' * 40, 'selectedCommit': 'b' * 40} + calls = 0 + def authorized(): + nonlocal calls + calls += 1 + if calls == 3: + self.git[self.finalized] = ('release', 'c' * 40) + return True + with mock.patch('openpilot.starpilot.galaxy.software_operations.validate_revision', return_value={}): + with self.assertRaisesRegex(SoftwareOperationError, 'Selected version changed'): + self.owner.action('install', payload, authorized=authorized) + self.assertFalse(self.params.get_bool('DoReboot')) + def test_counter_reset_does_not_claim_a_completed_check(self): self.params.put("UpdateFailedCount", 2, block=True) self.act("check") @@ -417,11 +519,56 @@ class SoftwareOperationsTest(unittest.TestCase): history.assert_called_once() self.assertEqual(light, {key: value for key, value in full.items() if key != 'history'}) + def test_install_preferences_default_off_and_validate_cron_and_revision(self): + initial = self.owner.snapshot(include_history=False) + self.assertFalse(initial['automaticInstalls']) + self.assertEqual(initial['restartSchedule'], '0 0 * * *') + body = {'action': 'install-preferences', 'automaticInstalls': True, 'restartSchedule': '0 0 * * *', + 'expectedRevision': initial['installPreferenceRevision'], 'restartTimezone': 'America/Los_Angeles'} + result = self.owner.action('install-preferences', body, authorized=lambda: True) + self.assertTrue(result['automaticInstalls']) + self.assertEqual(result['restartTimezone'], 'America/Los_Angeles') + self.assertFalse(self.params.get_bool('DoReboot')) + self.assertEqual(self.process.sent, []) + with self.assertRaises(SoftwareOperationError) as caught: + self.owner.action('install-preferences', body, authorized=lambda: True) + self.assertEqual(caught.exception.status, 409) + body['expectedRevision'] = result['installPreferenceRevision'] + body['restartTimezone'] = 'Invalid/Timezone' + with self.assertRaises(SoftwareOperationError) as caught: + self.owner.action('install-preferences', body, authorized=lambda: True) + self.assertEqual(caught.exception.status, 400) + body['restartTimezone'] = 'America/Los_Angeles' + body['restartSchedule'] = '0 24 * * *' + with self.assertRaises(SoftwareOperationError) as caught: + self.owner.action('install-preferences', body, authorized=lambda: True) + self.assertEqual(caught.exception.status, 400) + saved = self.owner.install_settings.read() + assert saved is not None + self.assertEqual(saved['cron'], '0 0 * * *') + + def test_install_preferences_recheck_offroad_before_saving(self): + body = {'action': 'install-preferences', 'automaticInstalls': True, 'restartSchedule': '0 0 * * *', 'expectedRevision': '0' * 32} + with self.assertRaises(SoftwareOperationError) as caught: + self.owner.action('install-preferences', body, authorized=lambda: False) + self.assertEqual(caught.exception.status, 403) + checks = iter((True, False)) + with mock.patch.object(self.owner, '_parked', side_effect=lambda: next(checks)), self.assertRaises(SoftwareOperationError) as caught: + self.owner.action('install-preferences', body, authorized=lambda: True) + self.assertEqual(caught.exception.status, 403) + self.assertFalse(self.owner.install_settings.path.exists()) + self.assertFalse(self.params.get_bool('DoReboot')) + + def test_history_includes_previous_installed_version(self): + previous = {'hash': 'd' * 40, 'date': '2026-09-28T12:00:00+00:00', 'subject': 'Previous installed build'} + with mock.patch('openpilot.starpilot.galaxy.software_operations.previous_version', return_value=previous): + self.assertEqual(self.owner.snapshot()['history']['previous'], previous) + def test_history_caches_local_reads_and_refreshes_when_download_identity_changes(self): - reader = mock.Mock(side_effect=lambda path, sha: [{'hash': sha, 'date': '2026-09-29T12:00:00+00:00', 'subject': path.name}]) + reader = mock.Mock(side_effect=lambda path, sha, **kwargs: [{'hash': sha, 'date': '2026-09-29T12:00:00+00:00', 'subject': path.name}]) self.owner.history_reader = reader - self.params.put('UpdaterCurrentReleaseNotes', b'

Current

', block=True) - self.params.put('UpdaterNewReleaseNotes', b'

Next

', block=True) + self.params.put('UpdaterCurrentReleaseNotes', b'

Current

', block=True) + self.params.put('UpdaterNewReleaseNotes', b'

Next

', block=True) with mock.patch('openpilot.starpilot.galaxy.software_operations.recent_versions', return_value={'branch': 'main', 'head': 'c' * 40, 'entries': []}) as recent: first = self.owner.snapshot()['history'] @@ -432,15 +579,18 @@ class SoftwareOperationsTest(unittest.TestCase): self.assertEqual(reader.call_count, 2) self.assertEqual(self.owner.snapshot()['history'], first) self.assertEqual(reader.call_count, 2) - self.assertEqual(recent.call_count, 1) + self.assertEqual(recent.call_count, 3) self.params.put('UpdaterNewDescription', 'new description', block=True) self.owner.snapshot() self.assertEqual(reader.call_count, 4) - self.assertEqual(recent.call_count, 2) + self.assertEqual(recent.call_count, 6) self.params.put('UpdaterLastFetchTime', datetime(2026, 10, 4, 12), block=True) self.owner.snapshot() - self.assertEqual(recent.call_count, 3) + self.assertEqual(recent.call_count, 9) self.assertEqual(reader.call_count, 6) + self.params.put('UpdaterLastCheckTime', datetime(2026, 10, 4, 13), block=True) + self.owner.snapshot() + self.assertEqual(recent.call_count, 12) def test_unified_update_pins_source_and_tracks_completion(self): result = self.act("update", "release") @@ -455,6 +605,26 @@ class SoftwareOperationsTest(unittest.TestCase): self.assertEqual(result["state"], "complete") self.assertEqual(result["outcome"], "restarting") + def test_version_list_uses_checked_history_before_download_finishes(self): + old = {'branch': 'main', 'head': 'b' * 40, 'entries': []} + latest = {'branch': 'main', 'head': 'c' * 40, + 'entries': [{'hash': 'c' * 40, 'date': '2026-10-29T12:00:00+00:00', 'subject': 'Not downloaded yet'}]} + with mock.patch('openpilot.starpilot.galaxy.software_operations.recent_versions', + side_effect=lambda path, branch: latest if path == self.metadata else old): + result = self.owner.snapshot()['history'] + self.assertEqual(result['recent'], latest) + self.assertEqual(result['downloaded'], []) + + def test_older_installed_history_is_selectable_without_being_in_recent_list(self): + older = {'hash': 'b' * 40, 'date': '2026-10-01T12:00:00+00:00', 'subject': 'Older installed history'} + self.owner.history_reader = lambda path, sha, **kwargs: [older] if path == self.installed else [] + recent = {'branch': 'main', 'head': 'c' * 40, 'entries': []} + with mock.patch('openpilot.starpilot.galaxy.software_operations.recent_versions', return_value=recent): + result = self.owner.action('version', {'action': 'version', 'branch': 'main', + 'expectedCommit': 'a' * 40, 'selectedCommit': older['hash']}, authorized=lambda: True) + self.assertEqual(result['request']['selectedCommit'], older['hash']) + self.assertEqual(self.process.sent, ['version']) + def test_fast_current_branch_without_branch_list_or_selected_target(self): self.params.remove("UpdaterAvailableBranches") self.params.put("UpdaterTargetBranch", "release", block=True) diff --git a/openpilot/starpilot/galaxy/tests/test_software_status.mjs b/openpilot/starpilot/galaxy/tests/test_software_status.mjs index 093874177b..f8d43bc166 100644 --- a/openpilot/starpilot/galaxy/tests/test_software_status.mjs +++ b/openpilot/starpilot/galaxy/tests/test_software_status.mjs @@ -313,76 +313,34 @@ await revoked.reply(0, { error: "Sign in" }, 401) assert.equal(revoked.unauthorized, 1) assert.equal(revoked.states.at(-1).status, "idle") -const page = (selectedTarget = "Dom", availableBranches = ["StarPilot", "Dom", "beta"], installedBranch = "Dom") => { - const state = { operations: { ...operations, selectedTarget, availableBranches }, data: { - installed: { ...installed, branch: installedBranch } }, actionDisabled: false, - draftBranch: "", primaryChoice: "", draftTouched: false, dialog: null } - Object.defineProperty(state,"branchOptions",{get:()=>SoftwarePage.computed.branchOptions.call(state)}) - Object.defineProperty(state,"otherBranches",{get:()=>SoftwarePage.computed.otherBranches.call(state)}) - Object.defineProperty(state,"primaryBranchHelp",{get:()=>SoftwarePage.computed.primaryBranchHelp.call(state)}) - Object.defineProperty(state,"canStageBranch",{get:()=>SoftwarePage.computed.canStageBranch.call(state)}) +const page = (selectedTarget = 'Dom', availableBranches = ['StarPilot', 'Dom', 'beta'], installedBranch = 'Dom') => { + const state = { operations: { ...operations, selectedTarget, availableBranches }, + data: { installed: { ...installed, branch: installedBranch } }, actionDisabled: false, + draftBranch: '', draftTouched: false, dialog: null, version: '', calls: [], + async loadVersions() { this.calls.push(this.draftBranch) } } + for (const key of ['branchOptions', 'previousVersion', 'otherBranches']) + Object.defineProperty(state, key, {get:()=>SoftwarePage.computed[key].call(state)}) return state } const staged = page() -SoftwarePage.methods.syncDraftBranch.call(staged,"Dom") -assert.equal(staged.draftBranch,"Dom") -staged.draftBranch="beta" -SoftwarePage.methods.changeBranch.call(staged) -assert.equal(staged.draftTouched,true) -assert.equal(staged.canStageBranch,true) -SoftwarePage.methods.chooseBranch.call(staged) -assert.equal(staged.dialog.action,"select") // Normal updates can still stage a target without restarting. -assert.equal(staged.dialog.branch,"beta") -SoftwarePage.methods.askUpdate.call({...staged, operations:{...staged.operations,canFastUpdate:true}}) -const switchPage={...staged,operations:{...staged.operations,canFastUpdate:true}} +SoftwarePage.methods.syncDraftBranch.call(staged, 'Dom') +staged.draftBranch = 'beta' +await SoftwarePage.methods.changeBranch.call(staged) +assert.equal(staged.draftTouched, true) +assert.deepEqual(staged.calls, ['beta'], 'branch changes automatically refresh metadata') +const switchPage = {...staged, operations: {...staged.operations, canFastUpdate:true}} SoftwarePage.methods.askUpdate.call(switchPage) -assert.equal(switchPage.dialog.action,"update") -assert.equal(switchPage.dialog.branch,"beta") -assert.equal(switchPage.dialog.label,"Update & restart") -const alternateTarget=page("legacy",["StarPilot","Dom","beta"],"installed-only") -SoftwarePage.methods.syncDraftBranch.call(alternateTarget,"legacy") -assert.equal(alternateTarget.canStageBranch,false) -assert.equal(alternateTarget.draftBranch,"installed-only") -assert.deepEqual(alternateTarget.branchOptions.map(branch=>branch.name),["legacy","installed-only","StarPilot","Dom","beta"]) -assert.equal(alternateTarget.branchOptions[0].available,false) -assert.equal(alternateTarget.branchOptions[1].available,true) -const sentinel = fixture() -sentinel.feed.start() -await sentinel.reply(0, snapshot({ operations: { ...operations, availableBranches: ["Dom", "other:"] } })) -sentinel.feed.action("select", "other:") -assert.equal(sentinel.requests.length, 1) -assert.deepEqual(page("Dom", ["Dom", "other:"]).branchOptions.map(branch=>branch.name), ["Dom"]) -sentinel.feed.stop() - -assert.equal(SoftwarePage.methods.branchLabel("StarPilot"), "Stable — StarPilot") -assert.equal(SoftwarePage.methods.branchLabel("Dom"), "Development — Dom") - -const namedBranches = page("StarPilot", ["StarPilot", "Dom", "beta"], "StarPilot") -SoftwarePage.methods.syncDraftBranch.call(namedBranches, "StarPilot") -assert.equal(namedBranches.primaryChoice, "StarPilot") -assert.equal(namedBranches.draftBranch, "StarPilot") -const otherBranches = page("beta", ["StarPilot", "Dom", "beta"], "Dom") -SoftwarePage.methods.syncDraftBranch.call(otherBranches, "beta") -assert.equal(otherBranches.primaryChoice, "other:") -assert.equal(otherBranches.draftBranch, "beta") -assert.deepEqual(otherBranches.otherBranches.map(branch => branch.name), ["beta"]) -otherBranches.primaryChoice = "Dom" -assert.equal(otherBranches.primaryBranchHelp, "Latest features and fixes under development. Updates regularly and may introduce bugs.") -otherBranches.draftBranch = "beta" -otherBranches.primaryChoice = "other:" -SoftwarePage.methods.onPrimaryBranchChange.call(otherBranches) -assert.equal(otherBranches.draftBranch, "beta") -assert.match(SoftwarePage.template, /Stable — StarPilot/) -assert.match(SoftwarePage.template, /Development — Dom/) -assert.match(SoftwarePage.template, /Other branches…/) -assert.match(SoftwarePage.template, /onPrimaryBranchChange/) -assert.match(SoftwarePage.template, /Check for updates/) +assert.equal(switchPage.dialog.action, 'update') +assert.equal(switchPage.dialog.branch, 'beta') +assert.equal(SoftwarePage.methods.branchLabel('StarPilot'), 'Stable — StarPilot') +assert.equal(SoftwarePage.methods.branchLabel('Dom'), 'Development — Dom') +assert.deepEqual(page('Dom', ['Dom', 'other:']).branchOptions.map(branch=>branch.name), ['Dom']) +assert.match(SoftwarePage.template, /aria-label="Version branch"/) +assert.match(SoftwarePage.template, /@change="changeBranch"/) assert.match(SoftwarePage.template, /Download only/) -assert.match(SoftwarePage.template, /@click="askUpdate">Update<\/button>/) +assert.match(SoftwarePage.template, /Select previous version/) +assert.doesNotMatch(SoftwarePage.template, /Save target/) assert.match(SoftwarePage.template, /displayVersion \?\? data\.installed\.version/) -assert.match(SoftwarePage.template, /@click="askRollback">Previous version/ ) -assert.match(SoftwarePage.template, /operations\.canRollback !== true/) -assert.doesNotMatch(SoftwarePage.template, /historical version|status only/i) const preferences = fixture() preferences.feed.start() @@ -492,7 +450,7 @@ assert.equal(validSoftwareSnapshot(snapshot({ operations: { ...operations, canRo assert.equal(softwareProgress(updater), null) assert.equal(softwareProgress(updater, {progress: {stage: null, detail: "", percent: null}}), null) assert.deepEqual(softwareProgress({...updater, state: "finalizing update..."}), - {active: true, failed: false, percent: null, label: "Finalizing update", detail: ""}) + {active: true, failed: false, percent: null, label: "Preparing update", detail: ""}) assert.equal(softwareProgress({...updater, state: "downloading..."}, {progress: {stage: "Downloading", detail: "", percent: 37}}).percent, 37) for (const percent of [NaN, Infinity, -1, 101]) { assert.equal(validSoftwareSnapshot(snapshot({operations: {...operations, progress: {stage: "fetching", detail: "", percent}}})), false) @@ -501,7 +459,7 @@ assert.equal(softwareProgress({...updater, state: "finalizing update..."}, {prog assert.ok(validSoftwareSnapshot(snapshot({updater: {...updater, lastCheckedAt: null}}))) assert.equal(validSoftwareSnapshot(snapshot({updater: {...updater, lastCheckedAt: 12}})), false) assert.match(SoftwarePage.template, /Advanced options<\/summary>/) -assert.match(SoftwarePage.template, /Versions & release notes<\/summary>/) +assert.doesNotMatch(SoftwarePage.template, /Versions & release notes<\/summary>/) assert.match(SoftwarePage.template, /data\.updater\.lastCheckedAt/) for (const progress of [{stage: "idle", detail: "", percent: null}, {stage: null, detail: null, percent: null}, {stage: "unknown", detail: "", percent: null}]) { @@ -514,7 +472,7 @@ for (const stage of ["complete", "error"]) { assert.equal(softwareProgress(state, {progress}).active, false) assert.equal(softwareProgress(state, {progress}).percent, null) } -assert.equal(softwareProgress({...updater, state: "finalizing update..."}, {progress: {stage: "finalizing", detail: null, percent: null}}).label, "Finalizing update") +assert.equal(softwareProgress({...updater, state: "finalizing update..."}, {progress: {stage: "finalizing", detail: null, percent: null}}).label, "Preparing update") const lightweight = fixture(false) lightweight.feed.start() @@ -528,6 +486,11 @@ await lightweight.reply(1, snapshot({ operations: { ...operations, history } })) assert.deepEqual(lightweight.states.at(-1).data.operations.history, history) lightweight.feed.stop() +const versionRefreshActions = [] +await SoftwarePage.methods.loadVersions.call({ actionDisabled: false, draftBranch: 'Dom', + operations: { selectedTarget: 'Dom' }, feed: { action: (...args) => versionRefreshActions.push(args) } }) +assert.deepEqual(versionRefreshActions, [['versions', 'Dom']], 'version refresh must use the metadata-only operation') + const directBranch = fixture() directBranch.feed.start() await directBranch.reply(0,snapshot({operations:{...operations,availableBranches:['Dom','beta'],canFastUpdate:true}})) @@ -544,7 +507,7 @@ directBranch.feed.stop() // A visit checks immediately, without interrupting an update already in progress. for (const [canCheck, pending, state, expected] of [ [true, false, 'idle', ['check']], [false, false, 'idle', []], - [true, true, 'idle', []], [true, false, 'downloading...', []], + [true, true, 'idle', []], [true, false, 'downloading...', ['check']], ]) { const actions = [] const view = { mode: 'local', initialChecking: true, operations: { canCheck }, pending, @@ -556,7 +519,7 @@ for (const [canCheck, pending, state, expected] of [ const updateView = { ...SoftwarePage.data(), data: snapshot(), operations: { ...operations }, requestMessage: SoftwarePage.methods.requestMessage } -for (const key of ['pending', 'reportedProgress', 'updateProgress', 'updateAvailable', 'osUpdateRequired', 'updateBanner', 'repositoryUrl', 'commitUrl', 'commitsUrl']) { +for (const key of ['pending', 'reportedProgress', 'metadataProgress', 'backgroundProgress', 'updateProgress', 'updateAvailable', 'osUpdateRequired', 'updateBanner', 'repositoryUrl', 'commitUrl', 'commitsUrl']) { Object.defineProperty(updateView, key, { get: () => SoftwarePage.computed[key].call(updateView) }) } assert.equal(updateView.updateProgress.label, 'Checking for updates') @@ -576,7 +539,7 @@ updateView.data = snapshot({ updater: { ...updater, targetChangeFound: false } } updateView.operations.request = request('check', 'complete') assert.equal(updateView.updateBanner.message, 'No Update Available') updateView.operations.request = request('check') -assert.equal(updateView.updateProgress.label, 'Checking for updates') +assert.equal(updateView.updateProgress.label, 'Checking for new versions') assert.equal(updateView.updateBanner, null) updateView.operations.request = request('check', 'failed', null, 'Network unavailable') assert.equal(updateView.updateProgress, null) @@ -652,7 +615,7 @@ for (const action of ['update', 'fast', 'rollback', 'install', 'version']) { const f = fixture(true, baseline => restarts.push(baseline)) f.feed.start() await f.reply(0, snapshot({ operations: { ...operations, canFastUpdate: true, canRollback: true, canInstall: true, - history: { installed: [], downloaded: [], currentReleaseNotes: null, downloadedReleaseNotes: null, + history: { installed: [], downloaded: [{ hash: 'b'.repeat(40), date: '', subject: '' }], currentReleaseNotes: null, downloadedReleaseNotes: null, recent: { branch: 'Dom', head: installed.commit, entries: [{ hash: 'b'.repeat(40), date: '', subject: '' }] } } } })) f.feed.action(action, 'Dom', { expectedCommit: installed.commit, selectedCommit: 'b'.repeat(40) }) assert.equal(f.requests.length, 2) @@ -697,3 +660,146 @@ for (const [status, payload, unauthorized] of [ f.feed.stop() } console.log('Software status and tunnel-envelope regressions passed') + +// Metadata checks remain separate from a background finalization and its real progress. +updateView.error = '' +updateView.operations = { ...operations, reason: 'Updater is busy', request: request('versions'), + progress: { stage: 'finalizing', detail: 'Copying update files: 44/100 (44%)', percent: 44, background: true } } +updateView.data = snapshot({ updater: { ...updater, state: 'finalizing update...', + fast: { stage: 'finalizing', check: { state: 'checking', detail: 'Fetching version metadata...' } } } }) +assert.equal(updateView.updateProgress.label, 'Checking for new versions') +assert.equal(updateView.backgroundProgress.percent, 44) +assert.match(updateView.backgroundProgress.detail, /44\/100/) +assert.equal(SoftwarePage.computed.metadataActionDisabled.call(updateView), false) +updateView.data.updater.fast.check = { state: 'complete' } +updateView.operations.request = request('versions', 'complete') +assert.equal(updateView.updateProgress, null) +assert.equal(updateView.backgroundProgress.percent, 44) + +// Clicking Check immediately replaces the previous Refresh versions result, before its POST returns. +const nextCheck = fixture() +nextCheck.feed.start() +await nextCheck.reply(0, snapshot({ operations: { ...operations, request: request('versions', 'complete') } })) +nextCheck.feed.action('check') +assert.equal(nextCheck.states.at(-1).submittingAction, 'check') +updateView.operations = { ...operations, request: request('versions', 'complete') } +updateView.data = snapshot() +updateView.busy = true +updateView.submittingAction = nextCheck.states.at(-1).submittingAction +assert.equal(updateView.updateProgress.label, 'Checking for new versions') +assert.equal(updateView.updateBanner, null) +await nextCheck.reply(1, snapshot({ operations: { ...operations, request: request('check') } })) +assert.equal(nextCheck.states.at(-1).submittingAction, null) +nextCheck.feed.stop() + +updateView.busy = false +updateView.submittingAction = null +updateView.operations = { ...operations, request: null } +updateView.draftBranch = 'Dom' +updateView.data = snapshot({ updater: { ...updater, finalizedUpdateReady: true } }) +assert.deepEqual(updateView.updateBanner, { tone: 'success', message: 'Downloaded · Ready to install' }) + +// Downloading a selected version pins it without entering restart recovery. +const selectedDownload = fixture() +const selectedHash = 'b'.repeat(40) +const selectionHistory = { installed: [], downloaded: [], currentReleaseNotes: null, downloadedReleaseNotes: null, + recent: { branch: 'beta', head: selectedHash, entries: [{hash:selectedHash,date:'',subject:'Beta build'}] } } +selectedDownload.feed.start() +await selectedDownload.reply(0, snapshot({operations:{...operations, canFastUpdate:true, history:selectionHistory}})) +selectedDownload.feed.action('download-version', 'beta', {expectedCommit:installed.commit,selectedCommit:selectedHash}) +assert.deepEqual(JSON.parse(selectedDownload.requests[1].options.body), {action:'download-version',branch:'beta',expectedCommit:installed.commit,selectedCommit:selectedHash}) +assert.equal(selectedDownload.feed.installBaseline, null) +await selectedDownload.reply(1, snapshot({operations:{...operations,request:request('download-version','pending','beta')}})) +assert.equal(selectedDownload.feed.installBaseline, null) +selectedDownload.feed.stop() + +const previousSelection = page('Dom',['Dom','beta']) +previousSelection.operations.history = {previous:{hash:selectedHash,date:'',subject:'Previous',branch:'beta'}} +previousSelection.syncDraftBranch = SoftwarePage.methods.syncDraftBranch.bind(previousSelection) +await SoftwarePage.methods.selectPreviousVersion.call(previousSelection) +assert.equal(previousSelection.draftBranch, 'beta') +assert.equal(previousSelection.version, selectedHash) +assert.deepEqual(previousSelection.calls, [], 'selecting a saved previous version must not refresh metadata') +assert.equal(previousSelection.dialog,null,'selecting previous must not install it') + +// A finished metadata refresh must leave the actual update status visible. +updateView.error = '' +updateView.busy = false +updateView.submittingAction = null +updateView.initialChecking = false +updateView.draftBranch = 'Dom' +updateView.operations = { ...operations, request: request('versions', 'complete') } +updateView.data = snapshot({ updater: { ...updater, finalizedUpdateReady: true } }) +assert.equal(updateView.updateBanner.message, 'Downloaded · Ready to install') +updateView.data.updater.finalizedUpdateReady = false +assert.equal(updateView.updateBanner.message, 'Update Available') +updateView.data.updater.targetChangeFound = false +assert.equal(updateView.updateBanner.message, 'No Update Available') +assert.equal(SoftwarePage.methods.requestMessage(request('versions', 'complete')), '') + +const branchLayout = page() +branchLayout.changeBranch = SoftwarePage.methods.changeBranch.bind(branchLayout) +SoftwarePage.methods.syncDraftBranch.call(branchLayout, 'Dom') +assert.equal(branchLayout.primaryChoice, 'Dom') +branchLayout.primaryChoice = 'other:' +await SoftwarePage.methods.onPrimaryBranchChange.call(branchLayout) +assert.equal(branchLayout.draftBranch, '') +assert.deepEqual(branchLayout.calls, [], 'opening Other does not refresh metadata without a branch') +assert.deepEqual(branchLayout.otherBranches.map(branch => branch.name), ['beta']) +branchLayout.draftBranch = 'beta' +await branchLayout.changeBranch() +assert.deepEqual(branchLayout.calls, ['beta']) +branchLayout.primaryChoice = 'StarPilot' +await SoftwarePage.methods.onPrimaryBranchChange.call(branchLayout) +assert.equal(branchLayout.draftBranch, 'StarPilot') +assert.deepEqual(branchLayout.calls, ['beta', 'StarPilot']) +assert.equal(previousSelection.primaryChoice, 'other:', 'previous selection synchronizes both selectors without refreshing') + +assert.doesNotMatch(SoftwarePage.template, /Refresh versions/) +assert.match(SoftwarePage.template, /@click="loadVersions">Refresh/ ) +assert.match(SoftwarePage.template, /feed.action\('check'\)">Check for updates/) + +assert.match(SoftwarePage.template, /GalaxySelect show-description v-model="primaryChoice"/) +assert.doesNotMatch(SoftwarePage.template, /

{toggleSettings=args}}},true) +assert.deepEqual(toggleSettings,[true,'0 0 * * *','UTC'],'toggle preserves the saved timezone') diff --git a/openpilot/starpilot/galaxy/tests/test_version_timeline.mjs b/openpilot/starpilot/galaxy/tests/test_version_timeline.mjs new file mode 100644 index 0000000000..14ac7e6fd6 --- /dev/null +++ b/openpilot/starpilot/galaxy/tests/test_version_timeline.mjs @@ -0,0 +1,173 @@ +import assert from 'node:assert/strict' +import { compile } from '../web/vendor/vue/vue.esm-browser.js' +import { SoftwarePage } from '../web/js/software-status.js' + +const rows = Array.from({ length: 20 }, (_, i) => ({ hash: (i + 1).toString(16).padStart(40, '0'), + date: new Date(Date.UTC(2026, 9, 29 - i)).toISOString(), subject: `Version ${20 - i}` })) +const vm = { ...SoftwarePage.data(), initialChecking: false, mode: 'local', + draftBranch: 'Dom', data: { installed: { branch: 'Dom', commit: rows[15].hash }, + updater: { state: 'idle', targetBranch: 'Dom', finalizedUpdateReady: true }, operations: { + parked: true, canFastUpdate: true, canInstall: true, selectedTarget: 'Dom', availableBranches: ['Dom'], + history: { installed: rows.slice(15), downloaded: rows.slice(3), currentReleaseNotes: 'Installed notes', + downloadedReleaseNotes: 'Downloaded notes', downloadedCommits: [rows[3].hash, rows[4].hash], recent: { branch: 'Dom', head: rows[0].hash, entries: rows } } } } } +for (const [name, getter] of Object.entries(SoftwarePage.computed)) + Object.defineProperty(vm, name, { get: () => getter.call(vm) }) +for (const [name, method] of Object.entries(SoftwarePage.methods)) vm[name] = method.bind(vm) + +assert.equal(vm.shortCommit('1234567890abcdef'), '12345678') +assert.equal(vm.versionEntries.length, 20, 'merge overlapping sources without duplicates') +assert.equal(vm.visibleVersions.length, 4) +assert.equal(vm.visibleVersions[0].hash, rows[0].hash, 'keep newest at the top') +assert.equal(vm.visibleVersions.at(-2).hash, rows[15].hash, 'keep installed visible') +assert.equal(vm.visibleVersions.at(-1).hash, rows[16].hash, 'show one older version') +assert.equal(vm.hiddenNewerCount, 13) +assert.equal(vm.hiddenOlderCount, 3) +assert.equal(vm.selectedVersion.hash, rows[15].hash) +assert.equal(vm.selectedVersionAction, 'No changes selected') +assert.equal(vm.canInstallSelection, false) +assert.equal(vm.selectedReleaseNotes, 'Installed notes') + +vm.version = rows[3].hash +assert.equal(vm.selectedVersionAction, 'Install selected version') +assert.equal(vm.selectedReleaseNotes, 'Downloaded notes') +assert.equal(vm.canInstallSelection, true) +vm.askSelectedVersion() +assert.deepEqual([vm.dialog.action, vm.dialog.selectedCommit, vm.dialog.expectedCommit], + ['install', rows[3].hash, rows[15].hash]) + +vm.version = rows[0].hash +assert.equal(vm.selectedVersionAction, 'Download and install selected version') +assert.equal(vm.canInstallSelection, true) +assert.equal(vm.selectedReleaseNotes, null) +vm.askSelectedVersion() +assert.equal(vm.dialog.action, 'version') +assert.equal(vm.dialog.selectedCommit, rows[0].hash) + +vm.data.updater.finalizedUpdateReady = false +vm.version = rows[3].hash +assert.equal(vm.selectedVersionAction, 'Install selected version', 'complete cached files remain downloaded when staging is not ready') +vm.version = rows[4].hash +assert.equal(vm.selectedVersionAction, 'Install selected version', 'cached commits need not be the prepared head') +vm.askSelectedVersion() +assert.equal(vm.dialog.action, 'version', 'cached versions still pass through pinned validation') +vm.newerVersionLimit += 10 +assert.equal(vm.hiddenNewerCount, 3) +assert.equal(vm.visibleVersions.length, 14, 'newer paging must not reveal older rows') +assert.equal(vm.hiddenOlderCount, 3) +vm.newerVersionLimit += 10 +vm.olderVersionLimit += 10 +assert.equal(vm.visibleVersions.length, 20) +assert.equal(vm.hiddenOlderCount, 0) +vm.newerVersionLimit = 2 +vm.olderVersionLimit = 1 +assert.equal(vm.visibleVersions.length, 4) + +// Separate gaps remain exact across multiple pages of ten. +const longRows = Array.from({length: 60}, (_, i) => ({hash:(i + 100).toString(16).padStart(40, '0'),date:'',subject:`Build ${i}`})) +vm.data.operations.history.recent.entries = longRows +vm.data.operations.history.installed = longRows.slice(35) +vm.data.installed.commit = longRows[35].hash +assert.equal(vm.hiddenNewerCount, 33) +assert.equal(vm.hiddenOlderCount, 23) +for (const remaining of [23, 13, 3, 0]) { + const before = vm.visibleVersions.length + const count = Math.min(10, vm.hiddenNewerCount) + vm.newerVersionLimit += 10 + assert.equal(vm.visibleVersions.length - before, count) + assert.equal(vm.hiddenNewerCount, remaining) + assert.equal(vm.hiddenOlderCount, 23) +} +for (const remaining of [13, 3, 0]) { + const before = vm.visibleVersions.length + const count = Math.min(10, vm.hiddenOlderCount) + vm.olderVersionLimit += 10 + assert.equal(vm.visibleVersions.length - before, count) + assert.equal(vm.hiddenOlderCount, remaining) +} +vm.data.operations.history.recent.entries = rows +vm.data.operations.history.installed = rows.slice(15) +vm.data.installed.commit = rows[15].hash +vm.data.operations.history.recent.entries = rows.slice(0, 10) +vm.version = rows[19].hash +assert.equal(vm.canInstallSelection, true, 'older installed history remains selectable outside the recent list') +vm.data.operations.history.recent.entries = rows +vm.newerVersionLimit = 2 +vm.olderVersionLimit = 1 +vm.data.installed.commit = 'f'.repeat(40) +vm.version = '' +assert.equal(vm.visibleVersions.at(-1).hash, 'f'.repeat(40), 'retain installed outside fetched history on its own branch') +assert.equal(vm.selectedVersionAction, 'No changes selected') +vm.busy = true +vm.version = rows[0].hash +assert.equal(vm.canInstallSelection, false) +vm.busy = false +vm.data.operations.parked = false +assert.equal(vm.canInstallSelection, false) + +compile(SoftwarePage.template, { decodeEntities: value => value }) +console.log('Version timeline selection, limits, expansion, and template passed') + +// Pin the actual previous installed commit with independent gaps above and below it. +vm.data.operations.parked = true +vm.data.operations.canRollback = true +vm.data.installed.commit = longRows[15].hash +vm.data.operations.history.recent.entries = longRows +vm.data.operations.history.installed = longRows.slice(15) +vm.data.operations.history.previous = longRows[40] +vm.newerVersionLimit = 2 +vm.olderVersionLimit = 1 +vm.collapsedOlderLimit = 0 +vm.version = '' +assert.deepEqual(vm.visibleVersions.map(row => row.hash), [...longRows.slice(0, 2), longRows[15], longRows[40]].map(row => row.hash)) +assert.equal(vm.hiddenNewerCount, 13) +assert.equal(vm.hiddenCollapsedOlderCount, 24) +assert.equal(vm.hiddenOlderCount, 19) +for (const remaining of [14, 4, 0]) { + const before = vm.visibleVersions.length + const count = Math.min(10, vm.hiddenCollapsedOlderCount) + vm.collapsedOlderLimit += count + assert.equal(vm.visibleVersions.length - before, count) + assert.equal(vm.hiddenCollapsedOlderCount, remaining) + assert.equal(vm.hiddenOlderCount, 19) +} +vm.olderVersionLimit += 10 +assert.equal(vm.hiddenOlderCount, 9) +assert.equal(vm.visibleVersions.at(-1).hash, longRows[50].hash) +vm.version = longRows[40].hash +assert.equal(vm.selectedVersionAction, 'Install selected version') +assert.equal(vm.canInstallSelection, true) +vm.askSelectedVersion() +assert.equal(vm.dialog.action, 'rollback') +vm.data.operations.history.previous = longRows[0] +assert.equal(vm.visibleVersions.filter(row => row.hash === longRows[0].hash).length, 1, 'previous version may be newer after a rollback') + +// Diverged branches contain neither installed history nor a previous-version anchor. +vm.data.operations.history.previous = { ...longRows[40], branch:'Dom' } +vm.draftBranch = 'StarPilot' +vm.data.operations.history.recent = {branch:'StarPilot',head:rows[0].hash,entries:rows.slice(0,10)} +vm.newerVersionLimit = 2 +vm.version = '' +assert.equal(vm.hasInstalledVersion, false) +assert.equal(vm.listedPreviousVersion, null) +assert.deepEqual(vm.visibleVersions.map(row => row.hash), rows.slice(0,2).map(row => row.hash)) +assert.equal(vm.hiddenNewerCount,8) +vm.newerVersionLimit += 10 +assert.equal(vm.visibleVersions.length,10,'diverged history remains pageable without installed anchors') +vm.syncDraftBranch(vm.previousVersion.branch) +vm.version = vm.previousVersion.hash +assert.equal(vm.listedPreviousVersion.hash,vm.previousVersion.hash,'explicit local previous selection needs no metadata refresh') + +// Glow only while a different selectable commit is waiting to be installed. +assert.equal(vm.selectionNeedsInstall, true) +vm.data.updater.state = 'updating...' +assert.equal(vm.selectionNeedsInstall, false) +vm.data.updater.state = 'idle' +vm.feed = {installBaseline:{}} +assert.equal(vm.selectionNeedsInstall, false) +vm.feed.installBaseline = null +assert.equal(vm.selectionNeedsInstall, true) +vm.busy = true +assert.equal(vm.selectionNeedsInstall, false) +vm.busy = false +vm.version = vm.data.installed.commit +assert.equal(vm.selectionNeedsInstall, false) diff --git a/openpilot/starpilot/galaxy/web/css/galaxy.css b/openpilot/starpilot/galaxy/web/css/galaxy.css index e11a4fd8c0..6eb1491c66 100644 --- a/openpilot/starpilot/galaxy/web/css/galaxy.css +++ b/openpilot/starpilot/galaxy/web/css/galaxy.css @@ -1228,16 +1228,37 @@ button.gx-chip:hover { .gx-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} .gx-release-notes{white-space:pre-wrap;font:inherit;line-height:1.6;overflow-wrap:anywhere} -.gx-build-history { display: grid; gap: var(--sp-3); max-height: 320px; overflow-y: auto; } -.gx-build-history li { min-width: 0; padding: var(--sp-3); border: 1px solid var(--glass-border); border-radius: var(--radius-sm); overflow-wrap: anywhere; } -.gx-build-history strong { display: block; color: var(--on-surface); } -.gx-build-history small { display: block; margin-top: var(--sp-1); color: var(--on-surface-variant); } .gx-software-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); gap: var(--sp-3); margin: 0; } .gx-software-summary dt { font-size: var(--fs-sm); color: var(--on-surface-variant); } .gx-software-summary dd { margin: var(--sp-1) 0 0; font-weight: var(--fw-bold); overflow-wrap: anywhere; } .gx-software-options { border-top: 1px solid var(--glass-border); } .gx-software-options > .gx-stack { padding-top: var(--sp-4); } .gx-software-card details{padding:12px 0}.gx-software-card summary{cursor:pointer;font-weight:600} +.gx-version-action--pending { animation: gx-version-heartbeat 1.8s ease-in-out infinite; } +@keyframes gx-version-heartbeat { + 0%, 45%, 100% { box-shadow: 0 0 0 0 transparent; } + 12%, 32% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 25%, transparent), 0 0 20px color-mix(in srgb, var(--primary) 55%, transparent); } + 22% { box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 15%, transparent), 0 0 10px color-mix(in srgb, var(--primary) 30%, transparent); } +} +@media (prefers-reduced-motion: reduce) { + .gx-version-action--pending { animation: none; box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 35%, transparent); } +} +.gx-restart-cron { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)) minmax(180px, 2fr); gap: var(--sp-3); } +@media (max-width: 800px) { .gx-restart-cron { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +.gx-version-scroll { max-height: min(760px, 65dvh); overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; padding: 3px; } +.gx-version-list { display: grid; gap: var(--sp-2); list-style: none; margin: 0; padding: 0; } +.gx-version-row { display: flex; align-items: center; gap: var(--sp-3); min-height: 64px; padding: var(--sp-3); border: 2px solid var(--outline-variant); border-radius: var(--radius-sm); cursor: pointer; } +.gx-version-row:hover { background: color-mix(in srgb, var(--primary) 6%, transparent); } +.gx-version-row--installed { background: color-mix(in srgb, var(--primary) 9%, transparent); } +.gx-version-row--selected { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); } +.gx-version-row:focus-within { outline: 2px solid var(--primary); outline-offset: 2px; } +.gx-version-row input { flex: 0 0 auto; width: 20px; height: 20px; accent-color: var(--primary); } +.gx-version-content { min-width: 0; display: grid; gap: var(--sp-1); overflow-wrap: anywhere; } +.gx-version-content small { color: var(--on-surface-variant); } +.gx-version-meta .gx-version-badge { display: inline-block; margin-inline-start: var(--sp-2); white-space: nowrap; } +.gx-version-badge { padding: 2px 8px; border-radius: 999px; font-size: var(--fs-sm); color: var(--on-surface-variant); background: var(--surface-container-high); } +.gx-version-badge--installed, .gx-version-badge--downloaded { color: var(--primary); background: color-mix(in srgb, var(--primary) 14%, transparent); } +.gx-version-gap { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); padding: var(--sp-2); } .gx-navigation-map { position:relative; border-radius:12px; overflow:hidden; background:#20303b; margin-bottom:16px; } .gx-navigation-map canvas { display:block; width:100%; height:360px; touch-action:none; cursor:grab; } diff --git a/openpilot/starpilot/galaxy/web/js/drive-state.js b/openpilot/starpilot/galaxy/web/js/drive-state.js index e6e14ea86a..e0330b1e82 100644 --- a/openpilot/starpilot/galaxy/web/js/drive-state.js +++ b/openpilot/starpilot/galaxy/web/js/drive-state.js @@ -7,9 +7,10 @@ export const validDriveState = (value) => !!value && ["auto", "offroad", "onroad export const DriveStatePanel = { components: { GxNotice }, - props: { offroadOnly: { type: Boolean, default: false } }, - data: () => ({ state: null, error: "", busy: false }), + props: { offroadOnly: { type: Boolean, default: false }, lockout: { type: Boolean, default: true } }, + data: () => ({ state: null, error: "", statusError: "", busy: false }), computed: { + visible() { return !this.offroadOnly || this.lockout || this.state?.mode === 'offroad' || !!this.error }, pending() { return this.state?.mode !== "auto" && this.state?.effective !== this.state?.mode }, }, mounted() { @@ -31,13 +32,21 @@ export const DriveStatePanel = { const response = await fetch("./api/drive-state/status", { credentials: "same-origin", cache: "no-store", signal: this.controller.signal }) const value = await response.json() if (!response.ok || !validDriveState(value)) throw new Error(value.error || "Drive state unavailable") - if (this.active && generation === this.generation) { this.state = value; this.error = "" } - } catch (error) { if (this.active && generation === this.generation) { this.state = null; this.error = "Drive state unavailable" } } + if (this.active && generation === this.generation) { this.state = value; this.statusError = "" } + } catch (error) { if (this.active && generation === this.generation) { this.state = null; this.statusError = "Drive state unavailable" } } finally { clearTimeout(deadline); if (generation === this.generation) this.loading = false } }, + async toggleOffroad(event) { + const input = event.target + const mode = input.checked ? 'offroad' : 'auto' + input.checked = this.state?.mode === 'offroad' + await this.change(mode) + input.checked = this.state?.mode === 'offroad' + }, async change(mode) { if (this.busy || !this.state?.available) return if (!["onroad", "offroad", "auto"].includes(mode)) return + this.error = "" const feedback = beginSave() const revision = this.state.revision this.busy = true @@ -57,16 +66,15 @@ export const DriveStatePanel = { finally { clearTimeout(deadline); this.busy = false; if (this.active && generation === this.generation) this.load() } }, }, - template: `

Force Drive State
- {{ error }} + template: `
Force Drive State
+ {{ error || statusError }} The drive-state manager is unavailable. Reconnect to check again. Waiting for the device to switch {{ state.mode }}… -

Device: {{ state?.effective === "onroad" ? "Onroad" : state?.effective === "offroad" ? "Offroad" : "Checking…" }}

+

Device: {{ state?.effective === "onroad" ? "Onroad" : state?.effective === "offroad" ? "Offroad" : "Checking…" }}

Auto follows the car. Force Onroad starts driving services at any time. Return to Auto restores normal operation.

- -

Park the vehicle and disengage controls to enable. Turn off to restore automatic drive state.

+ @change="toggleOffroad" />
diff --git a/openpilot/starpilot/galaxy/web/js/software-status.js b/openpilot/starpilot/galaxy/web/js/software-status.js index 9828d38620..dbd5fa5e26 100644 --- a/openpilot/starpilot/galaxy/web/js/software-status.js +++ b/openpilot/starpilot/galaxy/web/js/software-status.js @@ -7,10 +7,9 @@ import { GalaxySelect } from "./galaxy-select.js" import { GalaxySettingRow, switchRow } from "./galaxy-setting-row.js" import { RouteCleanup } from "./route-cleanup.js" -const ACTIONS = new Set(["check", "download", "select", "install", "preferences", "update", "fast", "rollback", "versions", "version"]) +const ACTIONS = new Set(["check", "download", "select", "install", "preferences", "install-preferences", "update", "fast", "rollback", "versions", "version", "download-version"]) const REQUEST_STATES = new Set(["pending", "complete", "failed"]) const UPDATER_ACTIVE = new Set(["checking...", "downloading...", "finalizing update...", "updating..."]) -const PRIMARY_BRANCHES = ["StarPilot", "Dom"] const PRIMARY_BRANCH_LABELS = { StarPilot: "Stable — StarPilot", Dom: "Development — Dom" } const PRIMARY_BRANCH_HELP = { StarPilot: "Stable releases. Recommended for most users.", @@ -36,6 +35,7 @@ export function validSoftwareSnapshot(data) { typeof branch === "string" && branch.length > 0 && branch.length <= 128) && text(operations.selectedTarget) && text(operations.reason) && (operations.progress === undefined || operations.progress === null || typeof operations.progress === "object" && + (operations.progress.background === undefined || typeof operations.progress.background === 'boolean') && text(operations.progress.stage) && (operations.progress.stage === null || operations.progress.stage.length <= 128) && text(operations.progress.detail) && (operations.progress.detail === null || operations.progress.detail.length <= 512) && (operations.progress.percent === null || Number.isFinite(operations.progress.percent) && @@ -44,6 +44,12 @@ export function validSoftwareSnapshot(data) { (request?.selectedCommit === undefined || /^[0-9a-f]{40}$/.test(request.selectedCommit)) && (operations.canRollback === undefined || typeof operations.canRollback === "boolean") && (operations.automaticDownloads === undefined || flag(operations.automaticDownloads)) && + (operations.automaticInstalls === undefined || flag(operations.automaticInstalls)) && + (operations.preparedCommit === undefined || operations.preparedCommit === null || /^[0-9a-f]{40}$/.test(operations.preparedCommit)) && + (operations.nextRestartAt === undefined || text(operations.nextRestartAt)) && + (operations.restartSchedule === undefined || typeof operations.restartSchedule === 'string' && operations.restartSchedule.length <= 128) && + (operations.installPreferenceRevision === undefined || operations.installPreferenceRevision === null || /^[0-9a-f]{32}$/.test(operations.installPreferenceRevision)) && + (operations.restartTimezone === undefined || text(operations.restartTimezone)) && (operations.canConfigure === undefined || typeof operations.canConfigure === "boolean") && (operations.history === undefined || validHistory(operations.history)) && [operations.canCheck, operations.canDownload, operations.canSelect, operations.canInstall].every((value) => typeof value === "boolean") && @@ -59,8 +65,8 @@ export function softwareProgress(updater, operations = {}) { const stage = progress?.stage || fast?.stage const active = UPDATER_ACTIVE.has(updater.state) && !["unavailable", "timed_out", "error", "complete"].includes(stage) const labels = { "checking...": "Checking for updates", "downloading...": "Downloading update", - "finalizing update...": "Finalizing update", "updating...": "Updating software", - checking: "Checking for updates", downloading: "Downloading update", finalizing: "Finalizing update", + "finalizing update...": "Preparing update", "updating...": "Updating software", + checking: "Checking for updates", downloading: "Downloading update", finalizing: "Preparing update", preparing: "Preparing update", fetching: "Downloading update", validating: "Validating update", applying: "Applying update", submodules: "Preparing dependencies", rebooting: "Restarting device", complete: "Update complete", error: "Update failed", unavailable: "Updater status unavailable", timed_out: "Update is taking longer than expected" } @@ -71,10 +77,13 @@ export function softwareProgress(updater, operations = {}) { } export function validHistory(value) { - const rows = (items) => Array.isArray(items) && items.length <= 20 && items.every((row) => + const rows = (items) => Array.isArray(items) && items.length <= 100 && items.every((row) => /^[0-9a-f]{40}$/.test(row?.hash) && typeof row.date === "string" && row.date.length <= 64 && typeof row.subject === "string" && row.subject.length <= 512) return !!value && rows(value.installed) && rows(value.downloaded) && + (value.previous === undefined || value.previous === null || rows([value.previous])) && + (value.downloadedCommits === undefined || Array.isArray(value.downloadedCommits) && value.downloadedCommits.length <= 201 && + value.downloadedCommits.every(hash => /^[0-9a-f]{40}$/.test(hash))) && (value.recent === undefined || !!value.recent && (value.recent.branch === null || typeof value.recent.branch === "string") && (value.recent.head === null || /^[0-9a-f]{40}$/.test(value.recent.head)) && rows(value.recent.entries)) && [value.currentReleaseNotes, value.downloadedReleaseNotes].every((notes) => notes === null || typeof notes === "string" && notes.length <= 65536) @@ -108,7 +117,7 @@ export class SoftwareStatusFeed { emit(status) { if (this.installBaseline && this.connectionLost) this.restarting(this.installBaseline) this.publish({ status, data: this.data, busy: this.busy, uncertain: this.uncertain, - notice: this.notice, error: this.error }) } + notice: this.notice, error: this.error, submittingAction: this.mutating ? this.submittingAction : null }) } stop() { this.active = false @@ -153,6 +162,7 @@ export class SoftwareStatusFeed { this.controller = controller this.busy = body !== null || this.data === null this.mutating = body !== null + this.submittingAction = body?.action ?? null if (body !== null) { this.error = ""; this.notice = "" } this.emit(this.data ? "ready" : this.attempted ? "unavailable" : "loading") this.timeout = this.later(() => { @@ -205,7 +215,11 @@ export class SoftwareStatusFeed { error.connectionLost = payload === null throw error } + const previous = this.data this.data = payload.operations === undefined ? { ...payload, operations: unavailableOperations() } : payload + if (this.data.operations.history === undefined && previous?.operations?.history && + previous.installed.commit === this.data.installed.commit && previous.operations.selectedTarget === this.data.operations.selectedTarget) + this.data.operations = { ...this.data.operations, history: previous.operations.history } this.attempted = true this.blocked = false this.startupFailures = 0 @@ -220,7 +234,8 @@ export class SoftwareStatusFeed { this.installBaseline = this.installBaseline || { commit: payload.installed.commit, branch: payload.installed.branch, target: body.branch } this.notice = "Restart requested. Waiting to reconnect and verify the installed build." } else this.notice = body.action === "select" ? `Target branch set to ${body.branch}. Check and download the update when ready.` : - body.action === "preferences" ? "Automatic download preference saved." : "" + body.action === "preferences" ? "Automatic download preference saved." : + body.action === "install-preferences" ? "Automatic install preferences saved." : "" } else { const observed = payload.operations.request const requestObserved = !!observed && !!this.uncertainAction && observed.action === this.uncertainAction.action && @@ -231,7 +246,12 @@ export class SoftwareStatusFeed { payload.operations.selectedTarget === this.uncertainAction.branch const preferenceObserved = this.uncertainAction?.action === "preferences" && payload.operations.automaticDownloads === this.uncertainAction.automaticDownloads - if (this.uncertain && (requestObserved || selectionObserved || preferenceObserved)) { + const installPreferenceObserved = this.uncertainAction?.action === 'install-preferences' && + payload.operations.automaticInstalls === this.uncertainAction.automaticInstalls && + payload.operations.restartSchedule === this.uncertainAction.restartSchedule && + payload.operations.restartTimezone === this.uncertainAction.restartTimezone && + payload.operations.installPreferenceRevision !== this.uncertainAction.expectedRevision + if (this.uncertain && (requestObserved || selectionObserved || preferenceObserved || installPreferenceObserved)) { this.uncertain = false this.uncertainAction = null this.actionPriorRequestId = null @@ -305,26 +325,39 @@ export class SoftwareStatusFeed { expectedAutomaticDownloads: this.data.operations.automaticDownloads }) } + configureInstallPreferences(enabled, schedule, timezone = Intl.DateTimeFormat().resolvedOptions().timeZone) { + if (!this.active || this.mutating || this.blocked || this.uncertain || this.data?.operations?.canConfigure !== true || + typeof enabled !== 'boolean' || typeof schedule !== 'string') return null + return this.run({action:'install-preferences', automaticInstalls:enabled, restartSchedule:schedule.trim().split(/\s+/).join(' '), + expectedRevision:this.data.operations.installPreferenceRevision, restartTimezone:timezone}) + } + action(action, branch = null, selection = null) { const operations = this.data?.operations - const capability = { check: "canCheck", download: "canDownload", select: "canSelect", install: "canInstall", update: "canFastUpdate", fast: "canFastUpdate", rollback: "canRollback", versions: "canFastUpdate", version: "canFastUpdate" }[action] + const capability = { check: "canCheck", download: "canDownload", select: "canSelect", install: "canInstall", update: "canFastUpdate", fast: "canFastUpdate", rollback: "canRollback", versions: "canCheck", version: "canFastUpdate", "download-version": "canFastUpdate" }[action] if (!this.active || !capability || this.mutating || this.blocked || this.uncertain || !operations?.parked || - operations[capability] !== true || operations.request?.state === "pending") return null + operations[capability] !== true || operations.request?.state === "pending" && + !(["check", "versions"].includes(action) && ["download", "download-version"].includes(operations.request.action))) return null if (action === "select" && (branch === "other:" || !operations.availableBranches.includes(branch) || branch === operations.selectedTarget)) return null if (["download", "install"].includes(action) && (!branch || branch !== operations.selectedTarget)) return null - if (["update", "fast", "versions", "version"].includes(action) && (!branch || branch !== this.data.installed.branch && !operations.availableBranches.includes(branch))) return null + if (["update", "fast", "versions", "version", "download-version"].includes(action) && (!branch || branch !== this.data.installed.branch && !operations.availableBranches.includes(branch))) return null if (action === "rollback" && branch !== this.data.installed.branch) return null - if (action === "version") { + if (["version", "download-version"].includes(action)) { if (selection?.expectedCommit !== this.data.installed.commit || !/^[0-9a-f]{40}$/.test(selection?.selectedCommit) || operations.history?.recent?.branch !== branch || - !operations.history.recent.entries.some((row) => row.hash === selection.selectedCommit)) return null + ![...operations.history.recent.entries, ...(branch === this.data.installed.branch ? operations.history.installed : [])] + .some((row) => row.hash === selection.selectedCommit)) return null } + if (action === "install" && selection?.selectedCommit && + (selection.expectedCommit !== this.data.installed.commit || + operations.history?.downloaded?.[0]?.hash !== selection.selectedCommit)) return null this.actionPriorRequestId = operations.request?.id ?? null if (action === "install") this.installBaseline = { commit: this.data.installed.commit, - branch: this.data.installed.branch, target: branch, action } + branch: this.data.installed.branch, target: branch, action, + ...(selection?.selectedCommit ? { selectedCommit: selection.selectedCommit } : {}) } if (["update", "fast", "rollback", "version"].includes(action)) this.installBaseline = { commit: this.data.installed.commit, branch: this.data.installed.branch, target: branch, action, ...(action === "version" ? { selectedCommit: selection.selectedCommit } : {}) } if (this.installBaseline) this.preparing(this.installBaseline) - if (action === "version") return this.run({ action, branch, expectedCommit: selection.expectedCommit, selectedCommit: selection.selectedCommit }) + if (["version", "download-version"].includes(action) || action === "install" && selection?.selectedCommit) return this.run({ action, branch, expectedCommit: selection.expectedCommit, selectedCommit: selection.selectedCommit }) return this.run(action === "check" ? { action } : { action, branch }) } } @@ -334,11 +367,15 @@ export const SoftwarePage = { name: "SoftwarePage", props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, restarting: { type: Function, default: () => {} }, preparing: { type: Function, default: () => {} }, cancelPreparation: { type: Function, default: () => {} } }, - data: () => ({ status: "idle", data: null, busy: false, uncertain: false, notice: "", error: "", - initialChecking: true, draftBranch: "", primaryChoice: "", draftTouched: false, dialog: null, historyKind: "recent", version: "" }), + data: () => ({ status: "idle", data: null, busy: false, uncertain: false, notice: "", error: "", submittingAction: null, + initialChecking: true, draftBranch: "", primaryChoice: "", draftTouched: false, dialog: null, newerVersionLimit: 2, olderVersionLimit: 1, collapsedOlderLimit: 0, version: "", restartCronFields: ["0", "0", "*", "*", "*"], scheduleTouched: false, restartTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone }), created() { this.feed = new SoftwareStatusFeed({ includeHistory: false, publish: (update) => { Object.assign(this.$data, update) + if (update.data?.operations.restartSchedule && !this.scheduleTouched) { + this.restartCronFields = update.data.operations.restartSchedule.split(' ') + this.restartTimezone = update.data.operations.restartTimezone || Intl.DateTimeFormat().resolvedOptions().timeZone + } if (update.data && !this.draftTouched) this.syncDraftBranch(update.data.operations.selectedTarget) }, unauthorized: this.unauthorized, restarting: this.restarting, preparing: this.preparing, cancelPreparation: this.cancelPreparation }) @@ -347,16 +384,34 @@ export const SoftwarePage = { if (this.mode !== "local") return try { await this.feed.start() - if (this.feed.active && this.operations?.canCheck && !this.pending && !UPDATER_ACTIVE.has(this.data?.updater.state)) await this.feed.action("check") + if (this.feed.active && this.operations?.canCheck && (!this.pending || ['download', 'download-version'].includes(this.operations.request?.action)) && + this.data?.updater.state !== 'updating...') await this.feed.action("check") } finally { this.initialChecking = false } }, beforeUnmount() { this.feed.stop() }, computed: { operations() { return this.data?.operations }, + restartScheduleChanged() { return this.restartCronFields.join(' ') !== this.operations?.restartSchedule || + this.restartTimezone !== this.operations?.restartTimezone }, + restartTimezones() { + return [...new Set(["UTC", this.restartTimezone, this.operations?.restartTimezone, + Intl.DateTimeFormat().resolvedOptions().timeZone, ...(Intl.supportedValuesOf?.("timeZone") || [])].filter(Boolean))].sort() + }, reportedProgress() { return softwareProgress(this.data?.updater, this.operations) }, + metadataProgress() { + const check = this.data?.updater.fast?.check + if (['check', 'versions'].includes(this.submittingAction) || check?.state === 'checking' || this.pending && ['check', 'versions'].includes(this.operations?.request?.action)) + return { active: true, percent: null, label: 'Checking for new versions', detail: check?.state === 'checking' ? check.detail : '' } + return null + }, + backgroundProgress() { + return this.reportedProgress?.active && this.operations?.progress?.background ? this.reportedProgress : null + }, updateProgress() { if (this.error || this.operations?.request?.state === "failed") return null + if (this.metadataProgress) return this.metadataProgress const progress = this.reportedProgress + if (this.backgroundProgress) return null if (progress?.active) return progress if (progress?.failed) return null if (this.initialChecking || this.busy || this.pending || this.uncertain) return { @@ -379,12 +434,13 @@ export const SoftwarePage = { (progress?.failed ? progress.detail || progress.label : "") if (error) return { tone: "danger", message: error } if (this.updateProgress) return null - if (this.operations?.reason) return { tone: "warn", message: this.operations.reason } + if (this.operations?.reason && !(this.backgroundProgress && this.operations.reason === 'Updater is busy')) return { tone: "warn", message: this.operations.reason } if (this.updateAvailable && this.osUpdateRequired) return { tone: "warn", message: this.data.updater.finalizedUpdateReady ? "Update ready — includes an operating system update. Restart to install." : "Update available — operating system update required. This is a large download and may take a while. Keep the device powered and connected to Wi-Fi." } + if (this.data?.updater.finalizedUpdateReady && this.data.updater.targetBranch === (this.draftBranch || this.operations?.selectedTarget)) return { tone: "success", message: "Downloaded · Ready to install" } if (this.notice) return { tone: "info", message: this.notice } - if (request && request.action !== "check") return { tone: "info", message: this.requestMessage(request) } + if (request && !["check", "versions"].includes(request.action)) return { tone: "info", message: this.requestMessage(request) } if (this.updateAvailable) return { tone: "success", message: "Update Available" } if (this.data?.updater.targetChangeFound === false) return { tone: "success", message: "No Update Available" } return { tone: "warn", message: "Update status unavailable. Check for updates to try again." } @@ -397,66 +453,171 @@ export const SoftwarePage = { pending() { return this.operations?.request?.state === "pending" }, actionUnavailable() { return this.uncertain || this.pending || !!this.error || !this.operations?.parked }, actionDisabled() { return this.busy || this.actionUnavailable }, + metadataActionDisabled() { return this.busy || this.uncertain || !!this.error || !this.operations?.parked }, branchOptions() { const available = this.operations?.availableBranches || [] - return [...new Set([this.operations?.selectedTarget, this.data?.installed.branch, ...available].filter(branch => branch && branch !== "other:"))] + return [...new Set([this.operations?.selectedTarget, this.data?.installed.branch, this.previousVersion?.branch, ...available].filter(branch => branch && branch !== "other:"))] .map(name => ({ name, available: available.includes(name) || name === this.data?.installed.branch })) }, - primaryBranchHelp() { return PRIMARY_BRANCH_HELP[this.primaryChoice] || "" }, - otherBranches() { - const available = this.operations?.availableBranches || [] - const selected = this.operations?.selectedTarget - const installed = this.data?.installed?.branch - return [...new Set([selected, installed, ...available].filter(branch => branch && branch !== "other:" && !PRIMARY_BRANCHES.includes(branch)))] - .map(name => ({ name, listed: available.includes(name), current: name === installed })) + otherBranches() { return this.branchOptions.filter(branch => !['StarPilot', 'Dom'].includes(branch.name)) }, + hasInstalledVersion() { + const recent = this.operations?.history?.recent + return this.draftBranch === this.data?.installed.branch || + recent?.branch === this.draftBranch && recent.entries.some(entry => entry.hash === this.data?.installed.commit) + }, + listedPreviousVersion() { + const previous = this.previousVersion + if (!previous) return null + const history = this.operations?.history + const recent = history?.recent?.branch === this.draftBranch ? history.recent.entries : [] + const known = recent.some(entry => entry.hash === previous.hash) || + this.hasInstalledVersion && history.installed.some(entry => entry.hash === previous.hash) + const locallySelected = this.version === previous.hash && this.draftBranch === (previous.branch || this.data?.installed.branch) + return known || locallySelected ? previous : null }, - canStageBranch() { return !!this.draftBranch && this.draftBranch !== "other:" && this.operations?.availableBranches.includes(this.draftBranch) && this.draftBranch !== this.operations.selectedTarget }, - branchChanging() { return !!this.draftBranch && this.draftBranch !== this.data?.installed.branch }, versionEntries() { const history = this.operations?.history - return this.historyKind === "recent" ? (history?.recent?.branch === this.draftBranch ? history.recent.entries : []) : history?.[this.historyKind] || [] + if (!history) return [] + const rows = new Map() + const recent = history.recent?.branch === this.draftBranch ? history.recent.entries : [] + const downloaded = this.data.updater.finalizedUpdateReady && this.data.updater.targetBranch === this.draftBranch && this.operations.selectedTarget === this.draftBranch ? history.downloaded.slice(0, 1) : [] + const installed = this.hasInstalledVersion ? history.installed : [] + for (const entry of [...recent, ...downloaded, ...installed, ...(this.listedPreviousVersion ? [this.listedPreviousVersion] : [])]) rows.set(entry.hash, entry) + const current = this.data.installed.commit + if (this.hasInstalledVersion && current && !rows.has(current)) rows.set(current, { hash: current, date: "", subject: "Installed version" }) + return [...rows.values()] + }, + newerVersions() { + const index = this.versionEntries.findIndex(entry => entry.hash === this.data?.installed.commit) + return (index < 0 ? this.versionEntries : this.versionEntries.slice(0, index)) + .filter(entry => entry.hash !== this.listedPreviousVersion?.hash) + }, + olderVersions() { + const index = this.versionEntries.findIndex(entry => entry.hash === this.data?.installed.commit) + return index < 0 ? [] : this.versionEntries.slice(index + 1) + }, + previousVersion() { + const previous = this.operations?.history?.previous + return previous && previous.hash !== this.data?.installed.commit ? previous : null + }, + beforePrevious() { + const index = this.olderVersions.findIndex(entry => entry.hash === this.listedPreviousVersion?.hash) + return this.listedPreviousVersion && index >= 0 ? this.olderVersions.slice(0, index) : [] + }, + afterPrevious() { + const index = this.olderVersions.findIndex(entry => entry.hash === this.listedPreviousVersion?.hash) + return this.listedPreviousVersion && index >= 0 ? this.olderVersions.slice(index + 1) : this.olderVersions + }, + visibleVersions() { + const installed = this.versionEntries.find(entry => entry.hash === this.data?.installed.commit) + const previous = this.listedPreviousVersion + return [...this.newerVersions.slice(0, this.newerVersionLimit), ...(installed ? [installed] : []), + ...(previous ? [...this.beforePrevious.slice(0, this.collapsedOlderLimit), previous] : []), + ...this.afterPrevious.slice(0, Math.max(0, this.olderVersionLimit - (previous ? 1 : 0)))] + }, + hiddenNewerCount() { return Math.max(0, this.newerVersions.length - this.newerVersionLimit) }, + hiddenCollapsedOlderCount() { return Math.max(0, this.beforePrevious.length - this.collapsedOlderLimit) }, + hiddenOlderCount() { return Math.max(0, this.afterPrevious.length - Math.max(0, this.olderVersionLimit - (this.listedPreviousVersion ? 1 : 0))) }, + downloadedVersions() { + return new Set([...(this.operations?.history?.downloadedCommits || []), this.downloadedCommit].filter(Boolean)) + }, + selectedVersion() { return this.versionEntries.find(entry => entry.hash === (this.version || this.data?.installed.commit)) }, + downloadedCommit() { + return this.data?.updater.finalizedUpdateReady && this.data.updater.targetBranch === this.draftBranch && this.draftBranch === this.operations?.selectedTarget ? + this.operations?.history?.downloaded?.[0]?.hash : null + }, + selectedVersionAction() { + if (!this.selectedVersion || this.selectedVersion.hash === this.data?.installed.commit) return "No changes selected" + if (this.selectedVersion.hash === this.previousVersion?.hash) return "Install selected version" + return this.downloadedVersions.has(this.selectedVersion.hash) ? "Install selected version" : "Download and install selected version" + }, + canInstallSelection() { + if (this.actionDisabled || !this.selectedVersion || this.selectedVersion.hash === this.data?.installed.commit) return false + if (this.selectedVersion.hash === this.previousVersion?.hash) return this.operations?.canRollback === true + if (this.selectedVersion.hash === this.downloadedCommit) return this.operations?.canInstall === true + return this.operations?.canFastUpdate === true && this.operations.history?.recent?.branch === this.draftBranch && + [...this.operations.history.recent.entries, ...(this.draftBranch === this.data.installed.branch ? this.operations.history.installed : [])] + .some(entry => entry.hash === this.selectedVersion.hash) + }, + selectionNeedsInstall() { + return this.canInstallSelection && this.data?.updater.state !== 'updating...' && !this.feed?.installBaseline + }, + canDownloadSelection() { + return !this.actionDisabled && this.operations?.canFastUpdate === true && !!this.selectedVersion && + this.selectedVersion.hash !== this.data?.installed.commit && !this.downloadedVersions.has(this.selectedVersion.hash) && + this.operations.history?.recent?.branch === this.draftBranch && + [...this.operations.history.recent.entries, ...(this.draftBranch === this.data.installed.branch ? this.operations.history.installed : [])] + .some(entry => entry.hash === this.selectedVersion.hash) + }, + selectedReleaseNotes() { + if (this.selectedVersion?.hash === this.data?.installed.commit) return this.operations?.history?.currentReleaseNotes + if (this.selectedVersion?.hash === this.downloadedCommit) return this.operations?.history?.downloadedReleaseNotes + return null }, commitsUrl() { return this.data?.installed.branch ? `https://github.com/StarPilotHQ/StarPilot/commits/${encodeURIComponent(this.data.installed.branch)}/` : "" }, }, methods: { switchRow, + configureAutomaticInstalls(enabled) { + return this.feed.configureInstallPreferences(enabled, this.operations.restartSchedule || '0 0 * * *', this.operations.restartTimezone || this.restartTimezone) + }, + async saveRestartSchedule() { + const result = await this.feed.configureInstallPreferences(this.operations.automaticInstalls === true, this.restartCronFields.join(' '), this.restartTimezone) + if (result) { + this.scheduleTouched = false + this.restartCronFields = result.operations.restartSchedule.split(' ') + this.restartTimezone = result.operations.restartTimezone + } + }, syncDraftBranch(branch) { - this.draftBranch = this.operations?.availableBranches.includes(branch) || branch === this.data?.installed.branch ? branch : this.data?.installed.branch || "" - this.primaryChoice = PRIMARY_BRANCHES.includes(this.draftBranch) ? this.draftBranch : this.draftBranch ? "other:" : "" + this.draftBranch = branch || this.data?.installed.branch || "" + this.primaryChoice = ['StarPilot', 'Dom'].includes(this.draftBranch) ? this.draftBranch : this.draftBranch ? 'other:' : '' }, - changeBranch() { this.draftTouched = true; this.version = ""; this.primaryChoice = PRIMARY_BRANCHES.includes(this.draftBranch) ? this.draftBranch : this.draftBranch ? "other:" : "" }, onPrimaryBranchChange() { this.draftTouched = true + if (this.primaryChoice === 'other:') { + if (['StarPilot', 'Dom'].includes(this.draftBranch)) this.draftBranch = '' + return + } + this.draftBranch = this.primaryChoice + return this.changeBranch() + }, + async changeBranch() { + this.primaryChoice = ['StarPilot', 'Dom'].includes(this.draftBranch) ? this.draftBranch : 'other:' + this.draftTouched = true this.version = "" - if (this.primaryChoice === "other:") { - if (!this.otherBranches.some(option => option.name === this.draftBranch)) { - const selected = this.operations?.selectedTarget - this.draftBranch = this.otherBranches.some(option => option.name === selected) ? selected : "" - } - } else this.draftBranch = this.primaryChoice + this.newerVersionLimit = 2; this.olderVersionLimit = 1; this.collapsedOlderLimit = 0 + return this.loadVersions() + }, + selectPreviousVersion() { + if (this.actionDisabled || !this.previousVersion) return + const previous = this.previousVersion + this.draftTouched = true + this.syncDraftBranch(previous.branch || this.data.installed.branch) + this.version = previous.hash + this.newerVersionLimit = 2; this.olderVersionLimit = 1; this.collapsedOlderLimit = 0 + }, + downloadSelectedVersion() { + if (!this.canDownloadSelection) return + return this.feed.action('download-version', this.draftBranch, + { expectedCommit: this.data.installed.commit, selectedCommit: this.selectedVersion.hash }) }, - onOtherBranchChange() { this.draftTouched = true; this.version = "" }, branchLabel(branch) { return PRIMARY_BRANCH_LABELS[branch] || branch }, primaryBranchHelpFor(branch) { return PRIMARY_BRANCH_HELP[branch] || "" }, - versionLabel(entry) { return `${entry.subject.trim() || this.shortCommit(entry.hash)} · ${entry.date ? new Date(entry.date).toLocaleDateString() : this.shortCommit(entry.hash)}` }, shown(value) { return value ?? "Unavailable" }, reported(value) { return value ? new Date(value).toLocaleString() : "Unavailable" }, - shortCommit(value) { return value ? value.slice(0, 12) : "Unavailable" }, + shortCommit(value) { return value ? value.slice(0, 8) : "Unavailable" }, requestMessage(request) { if (!request) return "" if (request.state === "failed") return request.error || "Update request failed. Refresh and try again." if (["update", "fast", "rollback", "version"].includes(request.action)) return request.state === "pending" ? `Updating ${request.target}…` : request.outcome === "up_to_date" ? "Installed branch is already up to date. No restart needed." : "Update installed. Waiting to reconnect and verify the installed build." - if (request.action === "versions") return request.state === "pending" ? "Loading recent versions…" : "Recent versions loaded." + if (request.action === "download-version") return request.state === "pending" ? "Downloading selected version…" : "Downloaded · Ready to install" + if (request.action === "versions") return request.state === "pending" ? "Loading recent versions…" : "" if (request.action === "install") return "Restart requested. Waiting to reconnect and verify the installed build." if (request.state === "pending") return request.action === "check" ? "Checking for updates…" : "Downloading and preparing update…" - return request.action === "check" ? "Update check finished." : request.action === "download" ? "Download finished." : "Target branch saved." - }, - chooseBranch() { - if (!this.operations?.canSelect || this.actionDisabled || !this.canStageBranch) return - this.dialog = { action: "select", branch: this.draftBranch, title: "Change target branch", - message: `Set ${this.draftBranch} as the target branch? This only stages the choice. Check for updates and download separately.`, label: "Set target branch" } + return request.action === "check" ? "Update check finished." : ["download", "download-version"].includes(request.action) ? "Downloaded · Ready to install" : "Target branch saved." }, askUpdate() { const branch = this.draftBranch || this.operations?.selectedTarget || this.data?.installed?.branch @@ -473,18 +634,19 @@ export const SoftwarePage = { }, async loadVersions() { const branch = this.draftBranch || this.operations?.selectedTarget || this.data?.installed?.branch - if (this.actionDisabled || !branch) return - if (branch !== this.operations.selectedTarget) { - const result = await this.feed.action("select", branch) - if (!result) return - } + if (this.metadataActionDisabled || !branch) return + this.feed.includeHistory = true return this.feed.action("versions", branch) }, - askVersion(entry) { - const branch = this.operations?.history?.recent?.branch - if (!entry || this.actionDisabled || this.operations?.canFastUpdate !== true || !branch) return - this.dialog = { action: "version", branch, selectedCommit: entry.hash, expectedCommit: this.data.installed.commit, - title: "Install recent version", message: `Install ${entry.subject} (${this.shortCommit(entry.hash)}) on ${branch} and restart?`, label: "Install" } + askSelectedVersion() { + if (!this.canInstallSelection) return + const entry = this.selectedVersion + if (entry.hash === this.listedPreviousVersion?.hash) return this.askRollback() + const downloaded = entry.hash === this.downloadedCommit + this.dialog = { action: downloaded ? "install" : "version", branch: this.draftBranch, + selectedCommit: entry.hash, expectedCommit: this.data.installed.commit, + title: this.selectedVersionAction, label: this.selectedVersionAction, + message: `${downloaded ? "Install" : "Download and install"} ${entry.subject} (${this.shortCommit(entry.hash)}) and restart the device?` } }, askRollback() { const branch = this.data?.installed?.branch @@ -533,62 +695,103 @@ export const SoftwarePage = {

{{ updateProgress.detail }}

- +
+
+
Background download · {{ backgroundProgress.label }}{{ Math.round(backgroundProgress.percent) }}%
+
+
+

{{ backgroundProgress.detail }}

+

Checked {{ reported(data.updater.lastCheckedAt) }} · Downloaded {{ reported(data.updater.lastFetchAt) }}

+

+ {{ shortCommit(operations.preparedCommit) }}{{ shortCommit(operations.preparedCommit) }} is ready for install. Click Update when ready. + +

The download preference could not be read. Choose a setting while parked to repair it.

-
Advanced options
-
diff --git a/openpilot/starpilot/software/auto_install.py b/openpilot/starpilot/software/auto_install.py new file mode 100644 index 0000000000..eeadce274d --- /dev/null +++ b/openpilot/starpilot/software/auto_install.py @@ -0,0 +1,169 @@ +"""Opt-in, offroad-only restarts on a numeric five-field cron schedule.""" + +from contextlib import contextmanager +from datetime import UTC, datetime, timedelta +import fcntl +import json +import os +from pathlib import Path +import re +import stat +import uuid +from zoneinfo import ZoneInfo + +from openpilot.starpilot.state_migration import _atomic_write, _private_dir + +DEFAULT_CRON = '0 0 * * *' +_LIMITS = ((0, 59), (0, 23), (1, 31), (1, 12), (0, 7)) + + +def cron_fields(expression: str) -> list[set[int]]: + if not isinstance(expression, str) or len(expression) > 128 or len(expression.split()) != 5: + raise ValueError('Use five cron fields: minute hour day month weekday') + fields = [] + for field, (low, high) in zip(expression.split(), _LIMITS, strict=True): + values = set() + for item in field.split(','): + parts = item.split('/') + if len(parts) > 2: + raise ValueError('Invalid cron step') + step = int(parts[1]) if len(parts) == 2 else 1 + base = parts[0] + if base == '*': + start, end = low, high + elif '-' in base: + start, end = map(int, base.split('-')) + else: + start = int(base) + end = high if len(parts) == 2 else start + if not low <= start <= end <= high or not 1 <= step <= high - low + 1: + raise ValueError('Cron field is outside its allowed range') + values.update(range(start, end + 1, step)) + fields.append(values) + return fields + + +def cron_matches(expression: str, now: datetime) -> bool: + minute, hour, day, month, weekday = cron_fields(expression) + dow = (now.weekday() + 1) % 7 + day_matches, weekday_matches = now.day in day, dow in weekday or dow == 0 and 7 in weekday + parts = expression.split() + restricted = not parts[2].startswith('*') and not parts[4].startswith('*') + calendar_matches = (day_matches or weekday_matches) if restricted else day_matches and weekday_matches + return now.minute in minute and now.hour in hour and now.month in month and calendar_matches + + +def next_restart(expression: str, now: datetime) -> datetime | None: + minutes, hours, days, months, weekdays = cron_fields(expression) + restricted = not expression.split()[2].startswith('*') and not expression.split()[4].startswith('*') + # Eight years includes the longest gap between leap days. + for offset in range(366 * 8): + day = now + timedelta(days=offset) + dow = (day.weekday() + 1) % 7 + day_matches = day.day in days + weekday_matches = dow in weekdays or dow == 0 and 7 in weekdays + if day.month not in months or not ((day_matches or weekday_matches) if restricted else day_matches and weekday_matches): + continue + for hour in sorted(hours): + for minute in sorted(minutes): + candidate = day.replace(hour=hour, minute=minute, second=0, microsecond=0) + if candidate > now and candidate.astimezone(UTC).astimezone(now.tzinfo) == candidate: + return candidate + return None + + +class InstallSettings: + def __init__(self, root: Path | None = None): + self._root = Path(root) if root is not None else None + + @property + def root(self) -> Path: + if self._root is None: + from openpilot.starpilot.storage import starpilot_storage_root + self._root = starpilot_storage_root() / 'software' / 'auto-install' + return self._root + + @property + def path(self) -> Path: + return self.root / 'settings.json' + + def read(self) -> dict | None: + try: + fd = os.open(self.path, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW) + with os.fdopen(fd, 'rb') as source: + if not stat.S_ISREG(os.fstat(source.fileno()).st_mode): + return None + raw = source.read(4097) + if len(raw) > 4096: + return None + value = json.loads(raw) + keys = {'version', 'enabled', 'cron', 'revision', 'lastAttempt'} + if (not isinstance(value, dict) or set(value) not in (keys, keys | {'timezone'}) or + type(value['version']) is not int or value['version'] != 1 or type(value['enabled']) is not bool or not isinstance(value['revision'], str) or + not re.fullmatch('[0-9a-f]{32}', value['revision']) or + value['lastAttempt'] is not None and (not isinstance(value['lastAttempt'], str) or len(value['lastAttempt']) > 64)): + return None + ZoneInfo(value.get('timezone', 'UTC')) + cron_fields(value['cron']) + return value + except FileNotFoundError: + return {'version': 1, 'enabled': False, 'cron': DEFAULT_CRON, 'revision': '0' * 32, 'lastAttempt': None} + except (OSError, ValueError, TypeError, KeyError): + return None + + @contextmanager + def locked(self): + _private_dir(self.root) + fd = os.open(self.root / '.lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + try: + fcntl.flock(fd, fcntl.LOCK_EX) + yield + finally: + os.close(fd) + + def write(self, value: dict) -> None: + _atomic_write(self.path, json.dumps(value, sort_keys=True).encode()) + + def save(self, enabled: bool, expression: str, expected_revision: str | None, admitted, timezone: str | None = None) -> dict: + if type(enabled) is not bool: + raise ValueError('Choose whether to install updates automatically') + cron_fields(expression) + if timezone is not None: + ZoneInfo(timezone) + with self.locked(): + current = self.read() + if (current['revision'] if current else None) != expected_revision: + raise ValueError('Install preferences changed; refresh before saving') + if not admitted(): + raise PermissionError('Park the vehicle before changing install preferences') + value = {'version': 1, 'enabled': enabled, 'cron': ' '.join(expression.split()), 'revision': uuid.uuid4().hex, + 'lastAttempt': current['lastAttempt'] if current else None} + value['timezone'] = timezone or (current.get('timezone', 'UTC') if current else 'UTC') + self.write(value) + if self.read() != value: + raise OSError('Install preferences could not be verified') + return value + + +def scheduled_restart(params, settings: InstallSettings, ready, parked, now: datetime) -> bool: + value = settings.read() + if value: + now = now.astimezone(ZoneInfo(value.get('timezone', 'UTC'))) + if not value or not value['enabled'] or not cron_matches(value['cron'], now): + return False + occurrence = now.strftime('%Y-%m-%dT%H:%M%z') + + def admitted(): + return (params.get_bool('IsOffroad') and not params.get_bool('DisableUpdates') and + not params.get_bool('DoReboot') and ready() and parked()) + + with settings.locked(): + current = settings.read() + if current is None or current != value or current['lastAttempt'] == occurrence or not admitted(): + return False + # Reserve the occurrence before rebooting so a failed restart cannot loop within the same minute. + settings.write({**current, 'lastAttempt': occurrence}) + if not admitted(): + return False + params.put_bool('DoReboot', True, block=True) + return True diff --git a/openpilot/starpilot/software/fast_update.py b/openpilot/starpilot/software/fast_update.py index 3602c66545..0233d5aed0 100644 --- a/openpilot/starpilot/software/fast_update.py +++ b/openpilot/starpilot/software/fast_update.py @@ -5,8 +5,10 @@ import subprocess import time import selectors import signal +import tempfile from openpilot.starpilot.galaxy.companion import phone_uplink_active +from openpilot.starpilot.software.history import MAX_COMMITS from openpilot.common.vendor_manifest import validate_revision from openpilot.common.prebuilt_manifest import revision_digest, valid_receipt @@ -72,26 +74,34 @@ def _stop_process(process): process.wait(timeout=5) -def run(command, cwd, progress=None, *, timeout=None): - downloading = 'fetch' in command or 'pull' in command or 'clone' in command +def run(command, cwd, progress=None, *, timeout=None, download=False, cancelled=None, stdin=None): + downloading = download or 'fetch' in command or 'pull' in command or 'clone' in command if downloading and phone_uplink_active(): raise FastUpdateError('Software downloads are disabled on the phone uplink') environment = dict(os.environ, GIT_LFS_SKIP_SMUDGE='1', GIT_TERMINAL_PROMPT='0', GIT_ASKPASS='/bin/false', SSH_ASKPASS='/bin/false') if timeout is None: timeout = 120 if "fetch" in command or "reset" in command or "checkout" in command else 30 output = bytearray() - with subprocess.Popen(command, cwd=cwd, env=environment, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True) as process: + with subprocess.Popen(command, cwd=cwd, env=environment, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True) as process: assert process.stdout is not None with selectors.DefaultSelector() as selector: selector.register(process.stdout, selectors.EVENT_READ) deadline = time.monotonic() + timeout pending = b"" + progress_at = float('-inf') + pending_progress = None try: while selector.get_map(): + if cancelled is not None and cancelled(): + raise FastUpdateError('Updater operation stopped') if downloading and phone_uplink_active(): raise FastUpdateError('Phone uplink activated; software download stopped') if time.monotonic() >= deadline: raise subprocess.TimeoutExpired(command, timeout, output=bytes(output)) + if progress is not None and pending_progress is not None and time.monotonic() - progress_at >= 0.25: + progress(pending_progress) + pending_progress = None + progress_at = time.monotonic() for key, _ in selector.select(min(0.1, max(0.0, deadline - time.monotonic()))): chunk = os.read(key.fileobj.fileno(), 4096) if not chunk: @@ -105,8 +115,18 @@ def run(command, cwd, progress=None, *, timeout=None): detail = line.decode('utf-8', errors='replace') pattern = r'(?:remote: )?(?:Enumerating objects|Counting objects|Compressing objects|Receiving objects|Resolving deltas|Total|Updating files):' if progress is not None and re.match(pattern, detail): - progress(detail[-512:]) + # Progress consumers persist Params synchronously. Git can emit + # hundreds of lines in a burst; keep draining its pipe instead + # of forcing a disk sync for each percentage point. + pending_progress = detail[-512:] + now = time.monotonic() + if now - progress_at >= 0.25: + progress(pending_progress) + pending_progress = None + progress_at = now result = process.wait(timeout=max(0.01, deadline - time.monotonic())) + if progress is not None and pending_progress is not None: + progress(pending_progress) if result: raise subprocess.CalledProcessError(result, command, output=bytes(output)) except BaseException: @@ -118,6 +138,18 @@ def run(command, cwd, progress=None, *, timeout=None): _DEFAULT_RUN = run +def fetch_files(git, revision): + missing = [line[1:] for line in git('rev-list', '--objects', '--no-object-names', '--missing=print', + '--no-walk', revision, '--').splitlines() if line.startswith('?')] + if missing: + # Match Git's partial-clone bulk fetch, with visible progress and no argv-size limit. + with tempfile.TemporaryFile() as requested: + requested.write(('\n'.join(missing) + '\n').encode()) + requested.seek(0) + git('-c', 'fetch.negotiationAlgorithm=noop', 'fetch', '--progress', '--no-tags', '--no-write-fetch-head', + '--no-recurse-submodules', '--filter=blob:none', 'origin', '--stdin', stdin=requested) + + def fast_update(repo, branch, *, params, parked, expected_commit, current_os=None, invalidate=None, run=run, rollback=False, progress=None, sleep=None, selected_commit=None, versions_only=False): repo = Path(repo) @@ -132,7 +164,7 @@ def fast_update(repo, branch, *, params, parked, expected_commit, current_os=Non notify('preparing', 'Resolving active branch...') - def git(*arguments): + def git(*arguments, stdin=None): command = [ 'git', '-c', @@ -152,8 +184,9 @@ def fast_update(repo, branch, *, params, parked, expected_commit, current_os=Non *arguments, ] if run is _DEFAULT_RUN: - return run(command, cwd=str(repo), progress=(lambda detail: notify('fetching', detail)) if 'fetch' in arguments else None) - return run(command, cwd=str(repo)) + return run(command, cwd=str(repo), download=not rollback and any(action in arguments for action in ('show', 'checkout', 'reset')), + progress=(lambda detail: notify('fetching', detail)) if 'fetch' in arguments else None, stdin=stdin) + return run(command, cwd=str(repo), **({'stdin': stdin} if stdin is not None else {})) def admitted(): if not parked(): @@ -177,13 +210,16 @@ def fast_update(repo, branch, *, params, parked, expected_commit, current_os=Non notify('fetching', 'Previous installed version is available locally.') else: recent = versions_only or selected_commit is not None + history_limit = MAX_COMMITS if selected_commit is not None else RECENT_VERSION_LIMIT notify('fetching', f'Fetching {"recent versions" if recent else "latest shallow commit"} on {branch}...') - git('fetch', '--progress', f'--depth={RECENT_VERSION_LIMIT if recent else 1}', '--no-tags', '--no-recurse-submodules', 'origin', f'refs/heads/{branch}') + git('fetch', '--progress', f'--depth={history_limit if recent else 1}', '--filter=blob:none', + '--no-tags', '--no-recurse-submodules', 'origin', f'refs/heads/{branch}') target = git('rev-parse', 'FETCH_HEAD^{commit}').strip() if recent: - members = git('log', '--first-parent', f'-{RECENT_VERSION_LIMIT}', '--format=%H', target, '--').splitlines() + members = git('log', '--first-parent', f'-{history_limit}', '--format=%H', target, '--').splitlines() if selected_commit is not None: - if selected_commit not in members: + installed_history = git('log', '--first-parent', f'-{MAX_COMMITS}', '--format=%H', previous, '--').splitlines() if branch == initial_branch else [] + if selected_commit not in members and selected_commit not in installed_history: raise FastUpdateError('Selected version is no longer in recent branch history; refresh and try again') target = selected_commit else: @@ -201,6 +237,9 @@ def fast_update(repo, branch, *, params, parked, expected_commit, current_os=Non version = validate_legacy_return(git, target, branch) notify('validating', f'Returning to {branch} {version}; checking the legacy prebuilt release...') else: + # Validation reads the manifest directly, so guard any initial blob download. + if git('ls-tree', target, '--', 'upstream-sync.json').strip().split()[:1] == ['100644']: + git('show', f'{target}:upstream-sync.json') validate_revision(repo, target) launch = git('show', f'{target}:launch_env.sh') required_os = required_os_version(launch) @@ -237,6 +276,7 @@ def fast_update(repo, branch, *, params, parked, expected_commit, current_os=Non for name in untracked: if name in tracked or name in directories or any(str(parent) in tracked for parent in Path(name).parents): raise FastUpdateError('Update would overwrite untracked data') + fetch_files(git, target) admitted() if git('rev-parse', 'HEAD^{commit}').strip() != previous: raise FastUpdateError('Source changed during download') diff --git a/openpilot/starpilot/software/history.py b/openpilot/starpilot/software/history.py index 9b533208d2..2eb0785a0f 100644 --- a/openpilot/starpilot/software/history.py +++ b/openpilot/starpilot/software/history.py @@ -2,6 +2,7 @@ from datetime import datetime from html.parser import HTMLParser +import os from pathlib import Path import re import subprocess @@ -9,8 +10,9 @@ import subprocess from openpilot.starpilot.saved_source import read_saved COMMIT = re.compile(r'[0-9a-f]{40}\Z') -MAX_COMMITS = 20 +MAX_COMMITS = 100 MAX_NOTES_BYTES = 32768 +METADATA_ROOT = Path(os.environ.get('UPDATER_METADATA_ROOT', '/data/starpilot/software/metadata.git')) def commit_history(path: Path, commit: str, *, first_parent: bool = False) -> list[dict]: @@ -21,7 +23,7 @@ def commit_history(path: Path, commit: str, *, first_parent: bool = False) -> li ['git', '-C', str(path), '--no-pager', 'log', '--no-decorate', '--no-color', '--no-show-signature', *(['--first-parent'] if first_parent else []), f'-{MAX_COMMITS}', '--format=%H%x00%cI%x00%<(240,trunc)%s%x00', commit, '--'], check=True, capture_output=True, text=True, timeout=2).stdout - if len(output.encode()) > 32768: + if len(output.encode()) > MAX_COMMITS * 800: return [] fields = output.split('\0') rows = [] @@ -38,19 +40,106 @@ def commit_history(path: Path, commit: str, *, first_parent: bool = False) -> li return [] +def previous_version(path: Path) -> dict | None: + try: + commit = subprocess.run(['git', '-C', str(path), 'rev-parse', '--verify', 'refs/starpilot/previous^{commit}'], + check=True, capture_output=True, text=True, timeout=2).stdout.strip() + rows = commit_history(path, commit) + if not rows: + return None + result = rows[0] + saved = subprocess.run(['git', '-C', str(path), 'config', '--local', '--get', 'starpilot.previousVersion'], + capture_output=True, text=True, timeout=2).stdout.strip().split(' ', 1) + if len(saved) == 2 and saved[0] == commit and re.fullmatch(r'[A-Za-z0-9_][A-Za-z0-9._/+@-]{0,127}', saved[1]): + result['branch'] = saved[1] + return result + except (OSError, subprocess.SubprocessError): + return None + + +def downloaded_versions(paths, commits): + """Check local trees in batches, sharing checks of unchanged subtrees.""" + requested = list(dict.fromkeys(commit for commit in commits if COMMIT.fullmatch(commit))) + available = set() + for path in paths: + def git(*args, data=None, path=path): + return subprocess.run(['git', '-C', str(path), *args], input=data, check=True, + capture_output=True, timeout=2).stdout + + def objects(hashes): + output = git('cat-file', '--batch', data=''.join(sha + '\n' for sha in hashes).encode()) + result = {} + offset = 0 + while offset < len(output): + end = output.index(b'\n', offset) + sha, kind, size = output[offset:end].split() + offset = end + 1 + size = int(size) + result[sha.decode()] = (kind, output[offset:offset + size]) + offset += size + 1 + return result + + try: + # Enumerate local objects first. Never ask cat-file for a missing object, + # which could cause a partial clone to fetch it just to render a badge. + existing = set(git('cat-file', '--batch-all-objects', '--batch-check=%(objectname)').decode().splitlines()) + pending = [sha for sha in requested if sha not in available and sha in existing] + if not pending: + continue + roots = {sha: body.split(b'\n', 1)[0][5:].decode() for sha, (kind, body) in objects(pending).items() + if kind == b'commit' and body.startswith(b'tree ')} + trees = {} + todo = set(roots.values()) & existing + while todo: + following = set() + for sha, (kind, body) in objects(todo).items(): + if kind != b'tree': + continue + children = [] + offset = 0 + while offset < len(body): + space = body.index(b' ', offset) + end = body.index(b'\0', space) + child = body[end + 1:end + 21].hex() + is_tree = body[offset:space] == b'40000' + children.append((child, is_tree)) + if is_tree and child in existing: + following.add(child) + offset = end + 21 + trees[sha] = children + todo = following - trees.keys() + complete = {} + def has_tree(sha, complete=complete, trees=trees, existing=existing): + if sha not in complete: + children = trees.get(sha) + complete[sha] = children is not None and all(child in existing and (not tree or has_tree(child)) + for child, tree in children) + return complete[sha] + available.update(sha for sha, root in roots.items() if has_tree(root)) + except (OSError, ValueError, subprocess.SubprocessError): + continue + return [sha for sha in requested if sha in available] + + class _NotesText(HTMLParser): def __init__(self): super().__init__(convert_charrefs=True) self.parts = [] self.hidden = 0 + self.heading = 0 + self.content = [] def handle_starttag(self, tag, attrs): + if tag in ('h1', 'h2', 'h3', 'h4', 'h5', 'h6'): + self.heading += 1 if tag in ('script', 'style'): self.hidden += 1 elif not self.hidden and tag in ('br', 'p', 'li', 'h1', 'h2', 'h3'): self.parts.append('\n') def handle_endtag(self, tag): + if tag in ('h1', 'h2', 'h3', 'h4', 'h5', 'h6'): + self.heading = max(0, self.heading - 1) if tag in ('script', 'style'): self.hidden = max(0, self.hidden - 1) elif not self.hidden and tag in ('p', 'li', 'h1', 'h2', 'h3'): @@ -59,6 +148,8 @@ class _NotesText(HTMLParser): def handle_data(self, data): if not self.hidden: self.parts.append(data) + if not self.heading: + self.content.append(data) def release_notes(params, key: str) -> str | None: @@ -69,6 +160,8 @@ def release_notes(params, key: str) -> str | None: parser = _NotesText() parser.feed(raw.decode('utf-8')) parser.close() + if not re.sub(r'^\s*#{1,6}\s+.*$', '', ''.join(parser.content), flags=re.MULTILINE).strip(): + return None return re.sub(r'\n{3,}', '\n\n', ''.join(parser.parts)).strip() except (UnicodeError, ValueError): return None diff --git a/openpilot/starpilot/software/preferences.py b/openpilot/starpilot/software/preferences.py index 94a7ebb9ea..2b5a0e51a9 100644 --- a/openpilot/starpilot/software/preferences.py +++ b/openpilot/starpilot/software/preferences.py @@ -15,5 +15,5 @@ def automatic_downloads(params) -> bool | None: return raw != b'0' # Preserve the upstream schedule for existing installations. -def download_permitted(params, *, manual: bool, check_only: bool) -> bool: - return not check_only and (manual or automatic_downloads(params) is True) +def download_permitted(params, *, manual: bool) -> bool: + return manual or automatic_downloads(params) is True diff --git a/openpilot/starpilot/software/tests/test_auto_install.py b/openpilot/starpilot/software/tests/test_auto_install.py new file mode 100644 index 0000000000..bf529d87af --- /dev/null +++ b/openpilot/starpilot/software/tests/test_auto_install.py @@ -0,0 +1,134 @@ +from datetime import UTC, datetime, timedelta +from zoneinfo import ZoneInfo +from pathlib import Path +import tempfile +import unittest +from unittest.mock import Mock + +from openpilot.starpilot.software.auto_install import DEFAULT_CRON, InstallSettings, cron_fields, cron_matches, next_restart, scheduled_restart + + +class Params: + def __init__(self): + self.values = {'IsOffroad': True} + self.writes = [] + + def get_bool(self, key): + return bool(self.values.get(key)) + + def put_bool(self, key, value, **kwargs): + self.values[key] = value + self.writes.append((key, value)) + + +class AutoInstallTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.settings = InstallSettings(Path(temporary.name) / 'install') + self.params = Params() + self.now = datetime(2026, 10, 30, 0, 0, tzinfo=UTC) + + def enable(self, expression=DEFAULT_CRON): + current = self.settings.read() + assert current is not None + return self.settings.save(True, expression, current['revision'], lambda: True) + + def test_local_midnight_tracks_daylight_savings(self): + current = self.settings.read() + assert current is not None + self.settings.save(True, DEFAULT_CRON, current['revision'], lambda: True, timezone='America/Los_Angeles') + for month, utc_hour in [(7, 7), (12, 8)]: + with self.subTest(month=month): + now = datetime(2026, month, 15, utc_hour, tzinfo=UTC) + self.params.values['DoReboot'] = False + self.assertFalse(scheduled_restart(self.params, self.settings, lambda: True, lambda: True, now - timedelta(hours=1))) + self.assertTrue(scheduled_restart(self.params, self.settings, lambda: True, lambda: True, now)) + zone = ZoneInfo('America/Los_Angeles') + before = datetime(2026, 3, 8, 0, tzinfo=zone) + self.assertEqual(next_restart('30 2 * * *', before), datetime(2026, 3, 9, 2, 30, tzinfo=zone)) + + def test_next_restart(self): + now = datetime(2026, 10, 30, 12, tzinfo=UTC) + self.assertEqual(next_restart(DEFAULT_CRON, now), datetime(2026, 10, 31, tzinfo=UTC)) + self.assertEqual(next_restart('15 2 * * 1-5', now), datetime(2026, 11, 2, 2, 15, tzinfo=UTC)) + self.assertEqual(next_restart('0 0 29 2 *', now), datetime(2028, 2, 29, tzinfo=UTC)) + self.assertIsNone(next_restart('0 0 31 2 *', now)) + + def test_default_is_disabled_and_daily_at_midnight(self): + value = self.settings.read() + assert value is not None + self.assertFalse(value['enabled']) + self.assertEqual(value['cron'], '0 0 * * *') + self.assertFalse(scheduled_restart(self.params, self.settings, lambda: True, lambda: True, self.now)) + self.assertEqual(self.params.writes, []) + + def test_numeric_cron_ranges_lists_steps_and_calendar_semantics(self): + self.assertTrue(cron_matches(DEFAULT_CRON, self.now)) + self.assertFalse(cron_matches(DEFAULT_CRON, self.now + timedelta(minutes=1))) + self.assertTrue(cron_matches('*/15 1-3 * * 1-5', self.now.replace(hour=2, minute=30))) + self.assertFalse(cron_matches('*/15 1-3 * * 1-5', (self.now + timedelta(days=1)).replace(hour=2, minute=30))) + sunday = datetime(2026, 11, 1, tzinfo=UTC) + self.assertTrue(cron_matches('0 0 * * 0,7', sunday)) + self.assertTrue(cron_matches('0 0 1 * 5', self.now)) + self.assertFalse(cron_matches('0 0 1 * */1', self.now)) + + def test_invalid_cron_is_rejected(self): + for value in ('@midnight', '* * * *', '60 0 * * *', '0 24 * * *', '0 0 0 * *', '0 0 * 13 *', + '0 0 * * 8', '*/0 * * * *', '10-2 * * * *', '0, * * * *', '1/100 * * * *', 'x' * 129): + with self.subTest(value=value), self.assertRaises(ValueError): + cron_fields(value) + + def test_settings_revision_and_last_moment_authorization(self): + saved = self.enable(' 0 0 * * * ') + self.assertEqual(saved['cron'], DEFAULT_CRON) + with self.assertRaises(ValueError): + self.settings.save(False, DEFAULT_CRON, '0' * 32, lambda: True) + with self.assertRaises(PermissionError): + self.settings.save(False, DEFAULT_CRON, saved['revision'], lambda: False) + self.assertEqual(self.settings.read(), saved) + + def test_corrupt_or_symlink_settings_never_enable_restarts(self): + self.enable() + for raw in (b'not json', b'{}', b'x' * 4097): + self.settings.path.write_bytes(raw) + self.assertIsNone(self.settings.read()) + self.assertFalse(scheduled_restart(self.params, self.settings, lambda: True, lambda: True, self.now)) + self.settings.path.unlink() + self.settings.path.symlink_to(Path(__file__)) + self.assertIsNone(self.settings.read()) + + def test_onroad_disabled_updater_no_update_and_no_park_proof_block_restart(self): + self.enable() + for flags, ready, parked in (({'IsOffroad': False}, True, True), ({'DisableUpdates': True}, True, True), + ({'DoReboot': True}, True, True), ({}, False, True), ({}, True, False)): + with self.subTest(flags=flags, ready=ready, parked=parked): + params = Params() + params.values.update(flags) + self.assertFalse(scheduled_restart(params, self.settings, lambda ready=ready: ready, lambda parked=parked: parked, self.now)) + self.assertEqual(params.writes, []) + + def test_restart_once_per_scheduled_minute_is_durable(self): + self.enable() + self.assertTrue(scheduled_restart(self.params, self.settings, lambda: True, lambda: True, self.now)) + self.params.values['DoReboot'] = False + other = InstallSettings(self.settings.root) + self.assertFalse(scheduled_restart(self.params, other, lambda: True, lambda: True, self.now)) + self.assertEqual(self.params.writes, [('DoReboot', True)]) + self.assertTrue(scheduled_restart(self.params, other, lambda: True, lambda: True, self.now + timedelta(days=1))) + + def test_offroad_and_prepared_update_are_rechecked_before_reboot(self): + for ready, parked in ((Mock(side_effect=[True, False]), Mock(return_value=True)), + (Mock(return_value=True), Mock(side_effect=[True, False]))): + with self.subTest(ready=ready, parked=parked): + self.settings.path.unlink(missing_ok=True) + self.enable() + self.assertFalse(scheduled_restart(self.params, self.settings, ready, parked, self.now)) + self.assertEqual(self.params.writes, []) + + def test_no_catchup_restart_after_the_scheduled_minute(self): + self.enable() + ready, parked = Mock(return_value=True), Mock(return_value=True) + self.assertFalse(scheduled_restart(self.params, self.settings, ready, parked, self.now + timedelta(minutes=1))) + ready.assert_not_called() + parked.assert_not_called() diff --git a/openpilot/starpilot/software/tests/test_fast_update.py b/openpilot/starpilot/software/tests/test_fast_update.py index 7571d0ef4b..d1b27b28ba 100644 --- a/openpilot/starpilot/software/tests/test_fast_update.py +++ b/openpilot/starpilot/software/tests/test_fast_update.py @@ -206,6 +206,7 @@ class TestFastUpdate(unittest.TestCase): self.assertEqual(self.params.calls, []) self.assertEqual(self.invalidations, []) self.assertIn('--depth=20', next(command for command in commands if 'fetch' in command)) + self.assertIn('--filter=blob:none', next(command for command in commands if 'fetch' in command)) def test_selected_older_version_uses_same_install_and_rollback_transaction(self): self.update() @@ -225,7 +226,7 @@ class TestFastUpdate(unittest.TestCase): self.assertEqual(self.params.calls, []) self.assertEqual(self.invalidations, []) rejected('f' * 40) - for index in range(21): + for index in range(owner.MAX_COMMITS + 1): (self.remote / 'openpilot/starpilot/ui/source.py').write_text(str(index)) self.git(self.remote, 'commit', '-am', 'recent build') rejected(self.target) @@ -235,6 +236,35 @@ class TestFastUpdate(unittest.TestCase): self.git(self.remote, 'commit', '-am', 'replacement') rejected(abandoned) + def test_installed_parent_remains_selectable_after_leaving_recent_window(self): + self.git(self.repo, 'fetch', 'origin') + self.git(self.repo, 'reset', '--hard', self.target) + for index in range(owner.MAX_COMMITS + 1): + (self.remote / 'openpilot/starpilot/ui/source.py').write_text(str(index)) + self.git(self.remote, 'commit', '-am', 'newer build') + self.assertEqual(self.update(selected_commit=self.previous), 'reboot-requested') + self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous) + + def test_selected_version_fetches_only_its_artifacts(self): + self.git(self.remote, 'config', 'uploadpack.allowFilter', 'true') + self.git(self.remote, 'config', 'uploadpack.allowReachableSHA1InWant', 'true') + self.git(self.repo, 'remote', 'set-url', 'origin', self.remote.as_uri()) + artifact = self.remote / 'openpilot/starpilot/galaxy/web/assets/artifact.png' + artifact.parent.mkdir(parents=True) + artifact.write_bytes(b'chosen artifact' * 10000) + self.git(self.remote, 'add', '.') + self.git(self.remote, 'commit', '-m', 'Chosen version') + selected = self.git(self.remote, 'rev-parse', 'HEAD') + selected_blob = self.git(self.remote, 'rev-parse', 'HEAD:openpilot/starpilot/galaxy/web/assets/artifact.png') + artifact.write_bytes(b'unselected newer artifact' * 10000) + self.git(self.remote, 'commit', '-am', 'Unselected version') + unselected_blob = self.git(self.remote, 'rev-parse', 'HEAD:openpilot/starpilot/galaxy/web/assets/artifact.png') + self.assertEqual(self.update(selected_commit=selected), 'reboot-requested') + objects = set(self.git(self.repo, 'cat-file', '--batch-all-objects', '--batch-check=%(objectname)').splitlines()) + self.assertIn(selected_blob, objects) + self.assertNotIn(unselected_blob, objects) + self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), selected) + def test_selected_version_rechecks_os_and_native_artifact_contracts(self): (self.remote / 'launch_env.sh').write_text('export AGNOS_VERSION="different"\n') self.git(self.remote, 'commit', '-am', 'different OS') diff --git a/openpilot/starpilot/software/tests/test_history.py b/openpilot/starpilot/software/tests/test_history.py index 7fee220827..a80a755ac0 100644 --- a/openpilot/starpilot/software/tests/test_history.py +++ b/openpilot/starpilot/software/tests/test_history.py @@ -1,8 +1,9 @@ from pathlib import Path from types import SimpleNamespace import subprocess +import shutil -from openpilot.starpilot.software.history import commit_history, release_notes, recent_versions +from openpilot.starpilot.software.history import MAX_COMMITS, commit_history, downloaded_versions, release_notes, recent_versions, previous_version def test_history_is_bounded_and_pinned_to_the_requested_commit(tmp_path): @@ -11,12 +12,12 @@ def test_history_is_bounded_and_pinned_to_the_requested_commit(tmp_path): '-c', 'user.name=Test', '-c', 'user.email=test@example.com', *args], check=True, capture_output=True, text=True).stdout.strip() git('init', '-q') - for index in range(23): + for index in range(MAX_COMMITS + 3): git('commit', '--allow-empty', '-qm', f'Change {index:02d}') commit = git('rev-parse', 'HEAD~1') rows = commit_history(tmp_path, commit) - assert len(rows) == 20 - assert rows[0]['hash'] == commit and rows[0]['subject'] == 'Change 21' + assert len(rows) == MAX_COMMITS + assert rows[0]['hash'] == commit and rows[0]['subject'] == f'Change {MAX_COMMITS + 1}' assert rows[-1]['subject'] == 'Change 02' assert all(row['date'] for row in rows) assert commit_history(tmp_path, '--all') == [] @@ -27,6 +28,44 @@ def test_history_is_bounded_and_pinned_to_the_requested_commit(tmp_path): assert recent['head'] == commit and recent['branch'] == 'Dom' and recent['entries'] == rows assert recent_versions(tmp_path, '../escape')['entries'] == [] assert recent_versions(tmp_path, 'Dom')['entries'][0]['hash'] == commit + assert previous_version(tmp_path) is None + git('update-ref', 'refs/starpilot/previous', commit) + assert previous_version(tmp_path) == rows[0] + git('config', '--local', 'starpilot.previousVersion', f'{commit} old-branch') + previous = previous_version(tmp_path) + assert previous is not None + assert previous['branch'] == 'old-branch' + git('update-ref', '-d', 'refs/starpilot/previous') + assert previous_version(tmp_path) is None + + +def test_downloaded_versions_require_all_files_and_accept_a_staging_cache(tmp_path): + repo = tmp_path / 'repo' + repo.mkdir() + def git(*args): + return subprocess.check_output(['git', '-C', str(repo), '-c', 'user.name=Test', + '-c', 'user.email=test@example.com', *args], text=True).strip() + git('init', '-q') + (repo / 'file').write_text('old') + (repo / 'nested').mkdir() + (repo / 'nested/shared').write_text('shared subtree') + git('add', '.') + git('commit', '-qm', 'Old') + old = git('rev-parse', 'HEAD') + (repo / 'file').write_text('new') + git('commit', '-qam', 'New') + new = git('rev-parse', 'HEAD') + cache = tmp_path / 'cache' + shutil.copytree(repo, cache) + assert downloaded_versions((repo,), [old, new, '--all']) == [old, new] + blob = git('rev-parse', 'HEAD:file') + (repo / '.git/objects' / blob[:2] / blob[2:]).unlink() + assert downloaded_versions((repo,), [old, new]) == [old] + assert downloaded_versions((repo, cache), [old, new]) == [old, new] + (repo / '.git/objects' / blob[:2] / blob[2:]).write_bytes((cache / '.git/objects' / blob[:2] / blob[2:]).read_bytes()) + tree = git('rev-parse', 'HEAD^{tree}') + (repo / '.git/objects' / tree[:2] / tree[2:]).unlink() + assert downloaded_versions((repo,), [old, new]) == [old] def test_release_notes_are_bounded_plain_text(tmp_path): @@ -39,3 +78,8 @@ def test_release_notes_are_bounded_plain_text(tmp_path): assert release_notes(params, path.name) is None path.write_bytes(b'\xff') assert release_notes(params, path.name) is None + for empty in ('', ' \n\t', '# StarPilot 7.0 — Domathon development\n', '

StarPilot 7.0

', '

Version

'): + path.write_text(empty) + assert release_notes(params, path.name) is None + path.write_text('# Version\n\n* Fixed updates') + assert release_notes(params, path.name) == '# Version\n\n* Fixed updates' diff --git a/openpilot/starpilot/software/tests/test_preferences.py b/openpilot/starpilot/software/tests/test_preferences.py index 11f14aae29..6a4380691d 100644 --- a/openpilot/starpilot/software/tests/test_preferences.py +++ b/openpilot/starpilot/software/tests/test_preferences.py @@ -12,10 +12,8 @@ def test_default_saved_and_invalid_values_preserve_manual_requests(tmp_path, raw if raw is not None: Path(params.get_param_path(AUTOMATIC_DOWNLOADS)).write_bytes(raw) assert automatic_downloads(params) is expected - assert download_permitted(params, manual=False, check_only=False) is (expected is True) - assert download_permitted(params, manual=True, check_only=False) - assert not download_permitted(params, manual=False, check_only=True) - assert not download_permitted(params, manual=True, check_only=True) + assert download_permitted(params, manual=False) is (expected is True) + assert download_permitted(params, manual=True) def test_nonregular_and_symlink_preferences_do_not_enable_automatic_downloads(tmp_path): diff --git a/openpilot/starpilot/software/tests/test_update_control.py b/openpilot/starpilot/software/tests/test_update_control.py index 1353dd2a51..84d4fb75a4 100644 --- a/openpilot/starpilot/software/tests/test_update_control.py +++ b/openpilot/starpilot/software/tests/test_update_control.py @@ -52,11 +52,22 @@ class UpdaterControlTests(unittest.TestCase): with self.assertRaises(update_control.UpdaterControlError): update_control._command('fast', 'Dom', expected, selected) + def test_download_version_pins_both_commits_without_becoming_an_install(self): + expected, selected = 'a' * 40, 'b' * 40 + command = update_control._command('download-version', 'Dom', expected, selected) + self.assertEqual(update_control._parse(command + b'\n'), ('download-version', 'Dom', expected, selected)) + self.assertLessEqual(len(update_control._command('download-version', 'x' * 128, expected, selected)) + 1, + update_control.MAX_COMMAND) + with self.assertRaises(update_control.UpdaterControlError): + update_control._command('download-version', 'Dom', expected, 'HEAD') + def test_server_delivers_fast_identity_and_legacy_callback_shape(self): for raw, expected in ((b'update Dom ' + b'a' * 40 + b'\n', mock.call('update', branch='Dom', commit='a' * 40)), (b'fast Dom ' + b'a' * 40 + b'\n', mock.call('fast', branch='Dom', commit='a' * 40)), (b'version Dom ' + b'a' * 40 + b' ' + b'b' * 40 + b'\n', mock.call('version', branch='Dom', commit='a' * 40, selected_commit='b' * 40)), + (b'download-version Dom ' + b'a' * 40 + b' ' + b'b' * 40 + b'\n', + mock.call('download-version', branch='Dom', commit='a' * 40, selected_commit='b' * 40)), (b'versions Dom ' + b'a' * 40 + b'\n', mock.call('versions', branch='Dom', commit='a' * 40)), (b'check\n', mock.call('check')), (b'download\n', mock.call('download'))): with self.subTest(raw=raw): @@ -88,6 +99,16 @@ class UpdaterControlTests(unittest.TestCase): self.assertEqual(request, updated.UserRequest.ROLLBACK) self.assertEqual(target, ('Dom', 'a' * 40)) + def test_check_requests_background_work_but_versions_only_requests_metadata(self): + from openpilot.system.updated import updated + callback = mock.Mock() + helper = updated.WaitTimeHelper.__new__(updated.WaitTimeHelper) + helper.metadata = callback + helper._control_request('check') + callback.assert_called_with(branch=None, commit=None, download_after=True) + helper._control_request('versions', branch='Dom', commit='a' * 40) + callback.assert_called_with(branch='Dom', commit='a' * 40, download_after=False) + def test_same_process_control_and_stale_identity(self): actions = [] accepted = threading.Event() diff --git a/openpilot/starpilot/software/tests/test_update_process.py b/openpilot/starpilot/software/tests/test_update_process.py index 8a734c79b2..c63f80ddbe 100644 --- a/openpilot/starpilot/software/tests/test_update_process.py +++ b/openpilot/starpilot/software/tests/test_update_process.py @@ -5,8 +5,9 @@ import sys import tempfile import time import unittest +from unittest.mock import patch -from openpilot.starpilot.software.fast_update import run +from openpilot.starpilot.software.fast_update import FastUpdateError, run class TestUpdateProcess(unittest.TestCase): @@ -56,6 +57,39 @@ finally: self.assertEqual(error.exception.output, b'locked\n') self.assertFalse((self.directory / 'shallow.lock').exists()) + def test_progress_burst_is_coalesced_without_losing_output(self): + command = self.command("for i in range(1000): print(f'Receiving objects: {i}\\r', end='')\n") + progress = [] + result = run(command, self.directory, progress.append) + self.assertIn('Receiving objects: 500\r', result) + self.assertEqual(progress[0], 'Receiving objects: 0') + self.assertEqual(progress[-1], 'Receiving objects: 999') + self.assertLess(len(progress), 20) + + def test_phone_uplink_interrupts_implicit_downloads(self): + command = self.command("from pathlib import Path\nimport time\nPath('started').touch()\ntime.sleep(60)\n") + with patch('openpilot.starpilot.software.fast_update.phone_uplink_active', + side_effect=lambda: (self.directory / 'started').exists()): + with self.assertRaisesRegex(FastUpdateError, 'software download stopped'): + run(command, self.directory, download=True, timeout=5) + + def test_pending_progress_is_delivered_while_command_is_quiet(self): + command = self.command("""import time +from pathlib import Path +print('Receiving objects: 1%\\rReceiving objects: 99%', flush=True) +deadline = time.monotonic() + 2 +while not Path('progress_received').exists() and time.monotonic() < deadline: + time.sleep(0.01) +print('received while running' if Path('progress_received').exists() else 'progress stalled') +""") + + def progress(detail): + if detail == 'Receiving objects: 99%': + (self.directory / 'progress_received').touch() + + result = run(command, self.directory, progress) + self.assertIn('received while running', result) + def test_stubborn_command_is_killed(self): command = self.command("""import os import signal diff --git a/openpilot/starpilot/software/update_control.py b/openpilot/starpilot/software/update_control.py index 9a411a5b1e..4dd3074d7a 100644 --- a/openpilot/starpilot/software/update_control.py +++ b/openpilot/starpilot/software/update_control.py @@ -82,9 +82,9 @@ def available(pid: int, start: int) -> bool: def _command(action: str, branch=None, commit=None, selected_commit=None) -> bytes: - if action == 'version' and type(selected_commit) is str and _COMMIT.fullmatch(selected_commit): + if action in ('version', 'download-version') and type(selected_commit) is str and _COMMIT.fullmatch(selected_commit): base = _command('fast', branch, commit).decode('ascii').split(' ', 1)[1] - return f'version {base} {selected_commit}'.encode('ascii') + return f'{action} {base} {selected_commit}'.encode('ascii') if selected_commit is not None: raise UpdaterControlError('Invalid updater version target') if action in ('check', 'download') and branch is None and commit is None: @@ -100,7 +100,7 @@ def _parse(command: bytes): return command[:-1].decode('ascii'), None, None try: parts = command[:-1].decode('ascii').split(' ') - if len(parts) == 4 and parts[0] == 'version' and command.endswith(b'\n'): + if len(parts) == 4 and parts[0] in ('version', 'download-version') and command.endswith(b'\n'): action, branch, commit, selected = parts if _command(action, branch, commit, selected) + b'\n' == command: return action, branch, commit, selected @@ -160,7 +160,7 @@ class UpdaterControlServer: else: parsed = _parse(command) action, branch, commit = parsed[:3] - if action == 'version': + if action in ('version', 'download-version'): self.request(action, branch=branch, commit=commit, selected_commit=parsed[3]) elif action in ('update', 'fast', 'rollback', 'versions'): self.request(action, branch=branch, commit=commit) diff --git a/openpilot/starpilot/tests/test_external_preferences_reset.py b/openpilot/starpilot/tests/test_external_preferences_reset.py index 60ffe7895b..b64d523f00 100644 --- a/openpilot/starpilot/tests/test_external_preferences_reset.py +++ b/openpilot/starpilot/tests/test_external_preferences_reset.py @@ -177,3 +177,14 @@ class TestExternalPreferencesReset(unittest.TestCase): if __name__ == '__main__': unittest.main() + + +def test_fresh_preferences_disable_scheduled_automatic_installation(tmp_path): + from openpilot.starpilot.software.auto_install import InstallSettings + settings = InstallSettings(tmp_path / 'software' / 'auto-install') + value = settings.read() + assert value is not None + settings.save(True, '0 0 * * *', value['revision'], lambda: True) + assert reset.reset_external_preferences(tmp_path, tmp_path / 'models') == () + value = settings.read() + assert value is not None and not value['enabled'] diff --git a/openpilot/starpilot/ui/runtime_snapshot.py b/openpilot/starpilot/ui/runtime_snapshot.py index 1cd82bc5cc..ed186ae219 100644 --- a/openpilot/starpilot/ui/runtime_snapshot.py +++ b/openpilot/starpilot/ui/runtime_snapshot.py @@ -801,6 +801,10 @@ class RuntimeSnapshotAdapter: offroad=confirmed_offroad, available_actions=frozenset(device_actions)) updater_state = _text(params, "UpdaterState") + fast = params.get('UpdaterFastState') + fast = fast if isinstance(fast, dict) else {} + checking_metadata = fast.get('check', {}).get('state') == 'checking' + background = fast.get('background') and updater_state in ('downloading...', 'finalizing update...') available = _flag(params, "UpdaterFetchAvailable") status = "updater status unavailable" if not updater_state else updater_state if updater_state != "idle" else ( "update available" if available else "up to date, last checked " + ("previously" if _text(params, "LastUpdateTime") else "never")) @@ -810,6 +814,11 @@ class RuntimeSnapshotAdapter: if updater_state == "idle": software_actions.update((SoftwareRequest.FAST_UPDATE, SoftwareRequest.ROLLBACK)) software_actions.add(SoftwareRequest.DOWNLOAD_UPDATE if available else SoftwareRequest.CHECK_FOR_UPDATES) + elif background and not checking_metadata: + software_actions.add(SoftwareRequest.CHECK_FOR_UPDATES) + if checking_metadata: + software_actions.discard(SoftwareRequest.CHECK_FOR_UPDATES) + software_actions.discard(SoftwareRequest.DOWNLOAD_UPDATE) if not _flag(params, "IsTestedBranch") and _text(params, "UpdaterAvailableBranches"): software_actions.add(SoftwareRequest.OPEN_BRANCH_CHOOSER) software = SoftwareState(current_version=_text(params, "UpdaterCurrentDescription"), diff --git a/openpilot/starpilot/ui/tests/test_runtime_snapshot.py b/openpilot/starpilot/ui/tests/test_runtime_snapshot.py index 11df3dcf3f..cef1dc6e96 100644 --- a/openpilot/starpilot/ui/tests/test_runtime_snapshot.py +++ b/openpilot/starpilot/ui/tests/test_runtime_snapshot.py @@ -423,6 +423,18 @@ class TestRuntimeSnapshot(unittest.TestCase): self.assertEqual(settings.software, first.software) self.assertEqual(adapter.build(ShellMode.ONROAD, now_ns=NOW + 1_000_000_002).software, first.software) + def test_background_finalization_allows_metadata_check(self): + from openpilot.starpilot.ui.software_state import SoftwareRequest + ui = ui_fake() + ui.params.values['UpdaterState'] = 'finalizing update...' + ui.params.values['UpdaterFastState'] = {'background': True} + with patch.object(RuntimeSnapshotAdapter, '_parked', return_value=True): + state = RuntimeSnapshotAdapter(ui).build(ShellMode.SETTINGS, now_ns=NOW).software + self.assertIn(SoftwareRequest.CHECK_FOR_UPDATES, state.available_actions) + ui.params.values['UpdaterFastState']['check'] = {'state': 'checking'} + state = RuntimeSnapshotAdapter(ui).build(ShellMode.SETTINGS, now_ns=NOW).software + self.assertNotIn(SoftwareRequest.CHECK_FOR_UPDATES, state.available_actions) + def test_customization_uses_existing_refresh_and_invalidation(self): adapter = RuntimeSnapshotAdapter(ui_fake()) first = default_document() diff --git a/openpilot/system/updated/tests/test_vendored_update.py b/openpilot/system/updated/tests/test_vendored_update.py index 4715312584..057643ceb9 100644 --- a/openpilot/system/updated/tests/test_vendored_update.py +++ b/openpilot/system/updated/tests/test_vendored_update.py @@ -12,6 +12,8 @@ import shutil import subprocess import sys import tempfile +import threading +import time import unittest from pathlib import Path from types import ModuleType @@ -19,6 +21,7 @@ from unittest.mock import Mock, patch from openpilot.common.vendor_manifest import DEPENDENCIES +from openpilot.starpilot.software.auto_install import InstallSettings BRANCH = "test-vendored" @@ -30,6 +33,9 @@ class MemoryParams: def get(self, key): return self.values.get(key) + def get_bool(self, key): + return bool(self.values.get(key)) + def put(self, key, value, **kwargs): self.values[key] = value @@ -100,14 +106,16 @@ class TestVendoredUpdate(unittest.TestCase): self.updated = load_updater() self.updated.OVERLAY_MERGED = str(self.merged) + self.updated.METADATA_ROOT = self.root / 'metadata.git' self.updated.FINALIZED = str(self.finalized) self.updated.BASEDIR = str(self.merged) self.updated.set_consistent_flag = self.set_consistent_flag self.updated.handle_agnos_update = Mock() + self.updated.init_overlay = Mock() self.commands = [] real_run = self.updated.run - def bounded_run(command, cwd=None): + def bounded_run(command, cwd=None, **kwargs): if cwd is None: raise AssertionError("Every updater command must name its isolated working directory") self.assertTrue(Path(cwd).resolve().is_relative_to(self.root.resolve())) @@ -115,10 +123,11 @@ class TestVendoredUpdate(unittest.TestCase): self.commands.append(command) if command in (["git", "gc"], ["git", "lfs", "prune"]): return "" - return real_run(command, cwd) + return real_run(command, cwd, **kwargs) self.updated.run = bounded_run - self.updater = self.updated.Updater() + self.updater = self.updated.Updater(install_settings=InstallSettings(self.root / 'install-settings')) + self.addCleanup(self.updater.close) @staticmethod def git(repo, *args): @@ -166,10 +175,89 @@ class TestVendoredUpdate(unittest.TestCase): self.updater.fast_update(BRANCH, self.initial_commit) normal.assert_not_called() # Explicit fast/rollback/version operations keep their contract. + @patch('openpilot.starpilot.drive_state.evidence.PhysicalSource', return_value=Mock(update_ready=Mock(return_value=True))) + def test_download_selected_version_stages_exact_commit_without_installing(self, _physical): + installed = self.root / 'installed' + self.git(self.root, 'clone', '--no-local', str(self.merged), str(installed)) + self.updated.BASEDIR = str(installed) + self.git(installed, 'remote', 'set-url', 'origin', str(self.source)) + (self.source / 'version.txt').write_text('selected') + selected = self.commit_source('Selected version') + (self.source / 'version.txt').write_text('newest') + latest = self.commit_source('Newer version') + with patch.object(self.updater, 'set_params') as publish: + self.assertEqual(self.updater.fast_update(BRANCH, self.initial_commit, selected, download_only=True), 'downloaded') + publish.assert_called_once_with(True, 0, None) + self.assertEqual(self.git(installed, 'rev-parse', 'HEAD'), self.initial_commit) + self.assertEqual(self.git(self.finalized, 'rev-parse', 'HEAD'), selected) + self.assertEqual(self.git(self.finalized, 'rev-parse', 'refs/starpilot/previous'), self.initial_commit) + self.assertEqual(self.updater.branches[BRANCH], latest) + self.assertTrue(self.updater.update_ready) + self.assertFalse(self.updater.latest_update_ready) + self.assertFalse(self.updater.params.get_bool('DoReboot')) + + @patch('openpilot.starpilot.drive_state.evidence.PhysicalSource', return_value=Mock(update_ready=Mock(return_value=True))) + def test_download_selected_version_rejects_changed_source_and_unlisted_target(self, _physical): + for expected, selected in (('f' * 40, self.initial_commit), (self.initial_commit, 'f' * 40)): + with self.subTest(expected=expected), self.assertRaises(self.updated.FastUpdateError): + self.updater.fast_update(BRANCH, expected, selected, download_only=True) + self.updated.init_overlay.assert_not_called() + self.assertEqual(self.git(self.merged, 'rev-parse', 'HEAD'), self.initial_commit) + self.assertFalse(self.updater.params.get_bool('DoReboot')) + + def test_metadata_refresh_does_not_wake_background_but_check_does(self): + (self.source / 'new.txt').write_text('available') + self.commit_source('New update') + self.updater.wake_automatic = Mock() + with patch.object(self.updated, 'download_permitted', return_value=True): + self.updater.request_check(branch=BRANCH) + worker = self.updater._check_thread + if worker is not None: + worker.join(timeout=5) + self.updater.wake_automatic.assert_not_called() + self.updated.init_overlay.assert_not_called() + self.updater.request_check(branch=BRANCH, download_after=True) + worker = self.updater._check_thread + if worker is not None: + worker.join(timeout=5) + self.updater.wake_automatic.assert_called_once_with() + + def test_scheduled_install_requires_clock_idle_and_fresh_offroad_evidence(self): + installed = self.root / 'installed' + self.git(self.root, 'clone', '--no-local', str(self.merged), str(installed)) + self.updated.BASEDIR = str(installed) + self.git(installed, 'remote', 'set-url', 'origin', str(self.source)) + (self.source / 'scheduled.txt').write_text('prepared') + self.commit_source('Prepared update for scheduling') + self.assertTrue(self.updater.fetch_update()) + self.updater.params.put('UpdaterState', 'idle') + self.updater.params.put_bool('IsOffroad', True) + settings = self.updater.install_settings + current = settings.read() + assert current is not None + settings.save(True, '* * * * *', current['revision'], lambda: True) + physical = Mock(update_ready=Mock(return_value=True)) + with patch('openpilot.starpilot.drive_state.evidence.PhysicalSource', return_value=physical), \ + patch.object(self.updated, 'system_time_valid', return_value=False): + self.assertFalse(self.updater.maybe_install()) + with patch('openpilot.starpilot.drive_state.evidence.PhysicalSource', return_value=physical), \ + patch.object(self.updated, 'system_time_valid', return_value=True): + self.assertFalse(self.updater.maybe_install(lambda: False)) + self.updater.params.put_bool('IsOffroad', False) + self.assertFalse(self.updater.maybe_install()) + self.assertFalse(self.updater.params.get_bool('DoReboot')) + physical.update_ready.assert_not_called() + self.updater.params.put_bool('IsOffroad', True) + self.assertTrue(self.updater.maybe_install()) + self.assertTrue(self.updater.params.get_bool('DoReboot')) + self.assertEqual(self.git(installed, 'rev-parse', 'HEAD'), self.initial_commit) + self.assertEqual(physical.update_ready.call_count, 2) + def test_normal_fallback_stages_flashes_and_requests_reboot_with_fresh_parked_state(self): installed = self.root / "installed" self.git(self.root, "clone", "--no-local", str(self.merged), str(installed)) self.updated.BASEDIR = str(installed) + self.git(installed, 'remote', 'set-url', 'origin', str(self.source)) (self.source / "panda/README.md").write_text("New native revision") target = self.commit_source("Full update") self.updated.AGNOS = True @@ -187,6 +275,7 @@ class TestVendoredUpdate(unittest.TestCase): installed = self.root / "installed" self.git(self.root, "clone", "--no-local", str(self.merged), str(installed)) self.updated.BASEDIR = str(installed) + self.git(installed, 'remote', 'set-url', 'origin', str(self.source)) (self.source / "panda/README.md").write_text("New native revision") self.commit_source("Full update") parked = iter((True, False)) @@ -215,6 +304,7 @@ class TestVendoredUpdate(unittest.TestCase): self.assertEqual(self.git(self.merged, "rev-parse", "HEAD"), target) self.assertEqual(self.git(self.finalized, "rev-parse", "HEAD"), target) self.assertEqual((self.finalized / "panda" / "README.md").read_text(), "Updated ordinary tracked source\n") + self.assertEqual(self.git(self.finalized, "rev-parse", f"refs/starpilot/recent/{BRANCH}"), target) self.assertTrue((self.finalized / ".overlay_consistent").is_file()) for name in DEPENDENCIES: self.assertFalse((self.finalized / name / ".git").exists()) @@ -223,6 +313,176 @@ class TestVendoredUpdate(unittest.TestCase): self.assertEqual(self.git(self.merged, "config", "fetch.recurseSubmodules"), "false") self.updated.handle_agnos_update.assert_not_called() + def test_filtered_fetch_preserves_history_without_obsolete_artifact_contents(self): + self.git(self.source, 'config', 'uploadpack.allowFilter', 'true') + self.git(self.source, 'config', 'uploadpack.allowReachableSHA1InWant', 'true') + self.git(self.merged, 'remote', 'set-url', 'origin', self.source.as_uri()) + artifact = self.source / 'artifact.bin' + artifact.write_bytes(os.urandom(128 * 1024)) + middle = self.commit_source('Superseded large artifact') + obsolete_blob = self.git(self.source, 'rev-parse', 'HEAD:artifact.bin') + artifact.write_bytes(os.urandom(128 * 1024)) + target = self.commit_source('Chosen large artifact') + target_blob = self.git(self.source, 'rev-parse', 'HEAD:artifact.bin') + self.updater.check_for_update() + objects = set(self.git(self.updated.METADATA_ROOT, 'cat-file', '--batch-all-objects', '--batch-check=%(objectname)').splitlines()) + self.assertIn(target, objects) + self.assertIn(middle, objects) + self.assertNotIn(target_blob, objects) + self.assertNotIn(obsolete_blob, objects) + metadata_fetches = sum('fetch' in command for command in self.commands) + self.assertTrue(self.updater.fetch_update()) + self.assertEqual(sum('fetch' in command for command in self.commands), metadata_fetches + 2) + objects = set(self.git(self.merged, 'cat-file', '--batch-all-objects', '--batch-check=%(objectname)').splitlines()) + self.assertIn(target, objects) + self.assertNotIn(middle, objects) + self.assertIn(target_blob, objects) + self.assertNotIn(obsolete_blob, objects) + self.assertIn(middle, self.git(self.updated.METADATA_ROOT, 'log', '--first-parent', '--format=%H', f'refs/starpilot/recent/{BRANCH}').splitlines()) + self.assertEqual((self.finalized / 'artifact.bin').read_bytes(), artifact.read_bytes()) + + def test_refresh_versions_fetches_only_metadata_in_staging(self): + self.git(self.source, 'config', 'uploadpack.allowFilter', 'true') + self.git(self.merged, 'remote', 'set-url', 'origin', self.source.as_uri()) + artifact = self.source / 'artifact.bin' + artifact.write_bytes(os.urandom(128 * 1024)) + target = self.commit_source('Version metadata to refresh') + blob = self.git(self.source, 'rev-parse', 'HEAD:artifact.bin') + installed = self.root / 'installed' + self.git(self.root, 'clone', '--no-local', str(self.merged), str(installed)) + self.updated.BASEDIR = str(installed) + self.git(installed, 'remote', 'set-url', 'origin', str(self.source)) + self.git(self.root, 'clone', '--no-local', str(self.merged), str(self.finalized)) + marker = self.finalized / '.overlay_consistent' + marker.touch() + self.updater.params.values['IsOffroad'] = True + with patch.object(self.updated, 'init_overlay'), \ + patch('openpilot.starpilot.drive_state.evidence.PhysicalSource', return_value=Mock(update_ready=Mock(return_value=True))): + self.assertEqual(self.updater.fast_update(BRANCH, self.initial_commit, versions_only=True), 'versions-listed') + objects = set(self.git(self.updated.METADATA_ROOT, 'cat-file', '--batch-all-objects', '--batch-check=%(objectname)').splitlines()) + self.assertIn(target, objects) + self.assertNotIn(blob, objects) + self.assertEqual(self.git(installed, 'rev-parse', 'HEAD'), self.initial_commit) + self.assertFalse((installed / '.git/FETCH_HEAD').exists()) + self.assertTrue(marker.exists()) + self.assertNotIn('DoReboot', self.updater.params.values) + self.assertTrue(self.updater.params.values['UpdaterFetchAvailable']) + self.assertFalse(self.updater.params.values['UpdateAvailable']) + + def test_scheduled_check_stages_new_commit_when_automatic_downloads_are_enabled(self): + (self.source / "panda/README.md").write_text("New automatic download\n") + target = self.commit_source("Automatic download after checking") + params = self.updater.params + params.values["InstallDate"] = "already installed" + preference = self.root / "UpdaterAutomaticDownloads" + params.get_param_path = lambda key: str(self.root / key) + for enabled in (False, True): + with self.subTest(enabled=enabled): + preference.write_bytes(b"1" if enabled else b"0") + helper = Mock() + helper.current_request.side_effect = [ + (self.updated.UserRequest.NONE, 0, None), + (self.updated.UserRequest.NONE, 1, None), + RuntimeError("test loop complete"), + ] + with patch.object(self.updated, "Params", return_value=params), \ + patch.object(self.updated, "Updater", return_value=self.updater), \ + patch.object(self.updated, "WaitTimeHelper", return_value=helper), \ + patch.object(self.updated, "init_overlay"), \ + patch.object(self.updated, "LOCK_FILE", str(self.root / "updater.lock")), \ + patch.object(self.updater, "set_params"), \ + self.assertRaisesRegex(RuntimeError, "test loop complete"): + self.updated.main() + self.assertEqual((self.finalized / ".overlay_consistent").exists(), enabled) + self.assertEqual("UpdaterLastFetchTime" in params.values, enabled) + self.assertNotIn("DoReboot", params.values) + if enabled: + self.assertEqual(self.git(self.finalized, "rev-parse", "HEAD"), target) + else: + self.assertEqual(self.git(self.merged, "rev-parse", "HEAD"), self.initial_commit) + + def test_up_to_date_check_does_not_repeat_automatic_staging(self): + params = self.updater.params + params.values['InstallDate'] = 'already installed' + params.get_param_path = lambda key: str(self.root / key) + helper = Mock() + helper.current_request.side_effect = [ + (self.updated.UserRequest.NONE, 0, None), + (self.updated.UserRequest.CHECK, 1, None), + RuntimeError('test loop complete'), + ] + with patch.object(self.updated, 'Params', return_value=params), \ + patch.object(self.updated, 'Updater', return_value=self.updater), \ + patch.object(self.updated, 'WaitTimeHelper', return_value=helper), \ + patch.object(self.updated, 'init_overlay'), \ + patch.object(self.updated, 'LOCK_FILE', str(self.root / 'updater.lock')), \ + patch.object(self.updater, 'set_params'), \ + patch.object(self.updater, 'fetch_update') as download, \ + self.assertRaisesRegex(RuntimeError, 'test loop complete'): + self.updated.main() + download.assert_not_called() + self.assertNotIn('UpdaterLastFetchTime', params.values) + self.assertFalse(self.finalized.exists()) + + def test_metadata_refresh_completes_while_background_finalization_is_blocked(self): + installed = self.root / 'installed' + self.git(self.root, 'clone', '--no-local', str(self.merged), str(installed)) + self.git(installed, 'remote', 'set-url', 'origin', str(self.source)) + self.updated.BASEDIR = str(installed) + (self.source / 'panda/README.md').write_text('New background version') + self.commit_source('Background download') + entered, release = threading.Event(), threading.Event() + finalize = self.updated.finalize_update + failures = [] + def blocked_finalize(*args, **kwargs): + entered.set() + if not release.wait(8): + raise RuntimeError('Test did not release finalization') + return finalize(*args, **kwargs) + def download(): + try: + self.updater.fetch_update(background=True) + except Exception as error: + failures.append(error) + with patch.object(self.updated, 'finalize_update', side_effect=blocked_finalize): + worker = threading.Thread(target=download) + worker.start() + try: + self.assertTrue(entered.wait(4)) + started = time.monotonic() + self.updater.request_check(branch=BRANCH, commit=self.initial_commit) + deadline = started + 4 + while time.monotonic() < deadline: + check = self.updater.params.values['UpdaterFastState'].get('check', {}) + if check.get('completedAt', 0) >= started: + break + time.sleep(0.01) + self.assertEqual(check['state'], 'complete') + self.assertGreaterEqual(check['completedAt'], started) + self.assertEqual(self.updater.params.values['UpdaterState'], 'finalizing update...') + self.assertTrue(self.updater.params.values['UpdaterFastState']['background']) + finally: + release.set() + worker.join(5) + self.assertFalse(worker.is_alive()) + self.assertEqual(failures, []) + + def test_finalized_runtime_files_are_independent_and_git_objects_are_shared(self): + (self.source / 'panda/README.md').write_text('Prepared source') + self.commit_source('New release') + self.updater.fetch_update() + source = self.merged / 'panda/README.md' + copied = self.finalized / 'panda/README.md' + self.assertNotEqual(source.stat().st_ino, copied.stat().st_ino) + copied.write_text('Local edit in prepared tree') + self.assertEqual(source.read_text(), 'Prepared source') + objects = list((self.merged / '.git/objects/pack').glob('*.pack')) + self.assertTrue(objects) + for obj in objects: + final = self.finalized / obj.relative_to(self.merged) + self.assertEqual(obj.stat().st_ino, final.stat().st_ino) + self.assertFalse(any(command[:2] == ['git', 'gc'] for command in self.commands)) + def test_invalid_manifest_rejected_before_checkout(self): self.manifest["schema_version"] = 999 self.write_manifest() diff --git a/openpilot/system/updated/updated.py b/openpilot/system/updated/updated.py index 73be579e50..0747694107 100755 --- a/openpilot/system/updated/updated.py +++ b/openpilot/system/updated/updated.py @@ -24,7 +24,11 @@ from openpilot.common.version import get_build_metadata from openpilot.common.vendor_manifest import validate_revision from openpilot.starpilot.software.update_control import UpdaterControlError, UpdaterControlServer from openpilot.starpilot.software.preferences import AUTOMATIC_DOWNLOADS, download_permitted -from openpilot.starpilot.software.fast_update import FastUpdateError, FastUpdateIncompatible, required_os_version, run as run_git +from openpilot.starpilot.software.fast_update import ( + FastUpdateError, FastUpdateIncompatible, fetch_files, required_os_version, run as run_git, +) +from openpilot.starpilot.software.history import MAX_COMMITS, METADATA_ROOT, commit_history +from openpilot.starpilot.software.auto_install import InstallSettings, cron_matches, scheduled_restart LOCK_FILE = os.getenv("UPDATER_LOCK_FILE", "/tmp/safe_staging_overlay.lock") STAGING_ROOT = os.getenv("UPDATER_STAGING_ROOT", "/data/safe_staging") @@ -51,16 +55,20 @@ class UserRequest: FAST = 3 ROLLBACK = 4 VERSIONS = 5 + DOWNLOAD_VERSION = 8 VERSION = 6 UPDATE = 7 class WaitTimeHelper: - def __init__(self): + def __init__(self, metadata=None, idle_tick=None): self.ready_event = threading.Event() self.user_request = UserRequest.NONE self.request_lock = threading.RLock() self.request_generation = 0 self.fast_target: tuple[str, str] | tuple[str, str, str] | None = None + self.metadata = metadata + self.idle_tick = idle_tick + self.automatic_pending = False signal.signal(signal.SIGHUP, self.update_now) signal.signal(signal.SIGUSR1, self.check_now) self.control = UpdaterControlServer(self._control_request) @@ -74,10 +82,10 @@ class WaitTimeHelper: def _request(self, request: int, *, branch=None, commit=None, selected_commit=None) -> None: with self.request_lock: self.user_request = request - if request in (UserRequest.UPDATE, UserRequest.FAST, UserRequest.ROLLBACK, UserRequest.VERSIONS, UserRequest.VERSION): + if request in (UserRequest.UPDATE, UserRequest.FAST, UserRequest.ROLLBACK, UserRequest.VERSIONS, UserRequest.VERSION, UserRequest.DOWNLOAD_VERSION): if not isinstance(branch, str) or not isinstance(commit, str): raise ValueError("Update source identity is required") - if request == UserRequest.VERSION: + if request in (UserRequest.VERSION, UserRequest.DOWNLOAD_VERSION): if not isinstance(selected_commit, str): raise ValueError("Selected version is required") self.fast_target = (branch, commit, selected_commit) @@ -89,9 +97,12 @@ class WaitTimeHelper: self.ready_event.set() def _control_request(self, action: str, *, branch=None, commit=None, selected_commit=None) -> None: + if self.metadata is not None and action in ('check', 'versions'): + self.metadata(branch=branch, commit=commit, download_after=action == 'check') + return request = {'update': UserRequest.UPDATE, 'check': UserRequest.CHECK, 'download': UserRequest.FETCH, 'fast': UserRequest.FAST, 'rollback': UserRequest.ROLLBACK, - 'versions': UserRequest.VERSIONS, 'version': UserRequest.VERSION}.get(action) + 'versions': UserRequest.VERSIONS, 'version': UserRequest.VERSION, 'download-version': UserRequest.DOWNLOAD_VERSION}.get(action) if request is not None: self._request(request, branch=branch, commit=commit, selected_commit=selected_commit) @@ -101,32 +112,48 @@ class WaitTimeHelper: def check_now(self, signum: int, frame) -> None: cloudlog.info("caught SIGUSR1, checking for updates") - self._request(UserRequest.CHECK) + self._control_request('check') def current_request(self) -> tuple[int, int, tuple[str, str] | tuple[str, str, str] | None]: with self.request_lock: self.ready_event.clear() + self.automatic_pending = False return self.user_request, self.request_generation, self.fast_target + def wake_automatic(self): + with self.request_lock: + self.automatic_pending = True + self.ready_event.set() + def finish_request(self, generation: int) -> bool: with self.request_lock: if self.request_generation != generation: return False self.user_request = UserRequest.NONE self.fast_target = None + if self.automatic_pending: + return False self.ready_event.clear() return True def sleep(self, t: float) -> None: - self.ready_event.wait(timeout=t) + deadline = time.monotonic() + t + while not self.ready_event.is_set(): + tick = getattr(self, 'idle_tick', None) + if tick is not None: + tick() + remaining = deadline - time.monotonic() + if remaining <= 0 or self.ready_event.wait(timeout=min(30, remaining)): + return def write_time_to_param(params, param) -> None: t = datetime.datetime.now(datetime.UTC).replace(tzinfo=None) params.put(param, t, block=True) -def run(cmd: list[str], cwd: str | None = None) -> str: +def run(cmd: list[str], cwd: str | None = None, *, download: bool = False, progress=None, cancelled=None, stdin=None, timeout=None) -> str: if cmd and cmd[0] == 'git': - return run_git(cmd, cwd, timeout=300 if 'fetch' in cmd or 'gc' in cmd else 120) + return run_git(cmd, cwd, timeout=timeout or (300 if download or 'fetch' in cmd or 'gc' in cmd else 120), + download=download, progress=progress, cancelled=cancelled, stdin=stdin) return subprocess.check_output(cmd, cwd=cwd, stderr=subprocess.STDOUT, encoding='utf8') @@ -234,7 +261,7 @@ def init_overlay() -> None: cloudlog.info(f"git diff output:\n{git_diff}") -def finalize_update() -> None: +def finalize_update(progress=None) -> None: """Take the current OverlayFS merged view and finalize a copy outside of OverlayFS, ready to be swapped-in at BASEDIR. Copy using shutil.copytree""" @@ -246,19 +273,45 @@ def finalize_update() -> None: # Copy the merged overlay view and set the update ready flag if os.path.exists(FINALIZED): shutil.rmtree(FINALIZED) - shutil.copytree(OVERLAY_MERGED, FINALIZED, symlinks=True) + total = sum(not os.path.islink(os.path.join(root, name)) for root, _, files in os.walk(OVERLAY_MERGED) for name in files) + copied = 0 + reported = 0.0 + roots = (Path(OVERLAY_UPPER), Path(BASEDIR)) if os.path.ismount(OVERLAY_MERGED) else (Path(OVERLAY_MERGED),) + def copy_file(source, destination): + nonlocal copied, reported + relative = Path(source).relative_to(OVERLAY_MERGED) + immutable = re.fullmatch(r'\.git/objects/(?:[0-9a-f]{2}/[0-9a-f]{38}|pack/pack-[0-9a-f]{40}\.(?:pack|idx))', relative.as_posix()) + linked = False + if immutable: + # Git objects are immutable; worktree files, refs and indexes remain independent copies. + for root in roots: + candidate = root / relative + try: + if not candidate.is_symlink(): + os.link(candidate, destination) + linked = True + break + except OSError: + pass + if not linked: + shutil.copy2(source, destination) + copied += 1 + now = time.monotonic() + if progress is not None and (now - reported >= 0.25 or copied == total): + progress(f'Copying update files: {copied}/{total} ({copied * 100 // max(1, total)}%)', copied * 100 / max(1, total)) + reported = now + return destination + started = time.monotonic() + shutil.copytree(OVERLAY_MERGED, FINALIZED, symlinks=True, copy_function=copy_file) + cloudlog.event('update copy complete', duration=time.monotonic() - started, files=copied) + if progress is not None: + progress('Verifying the prepared update...', None) run(["git", "reset", "--hard", "--no-recurse-submodules"], FINALIZED) validate_revision(FINALIZED, "HEAD") - cloudlog.info("Starting git cleanup in finalized update") - t = time.monotonic() - try: - run(["git", "gc"], FINALIZED) - cloudlog.event("Done git cleanup", duration=time.monotonic() - t) - except subprocess.CalledProcessError: - cloudlog.exception(f"Failed git cleanup, took {time.monotonic() - t:.3f} s") - + if progress is not None: + progress('Syncing the prepared update to storage...', None) set_consistent_flag(True) cloudlog.info("done finalizing overlay") @@ -292,11 +345,96 @@ def handle_agnos_update() -> None: class Updater: - def __init__(self): + def __init__(self, install_settings=None): self.params = Params() self.branches: dict[str, str] = {} self._branches_checked = False self._has_internet: bool = False + self._progress_lock = threading.RLock() + self._metadata_lock = threading.Lock() + self._request_lock = threading.Lock() + self._check_thread = None + self._pending_check = None + self._closed = threading.Event() + self._checked_at = 0.0 + self._checked_branch = None + self.wake_automatic = None + self.install_settings = install_settings if install_settings is not None else InstallSettings() + + def maybe_install(self, idle=lambda: True) -> bool: + from zoneinfo import ZoneInfo + value = self.install_settings.read() + now = datetime.datetime.now(ZoneInfo(value.get('timezone', 'UTC') if value else 'UTC')) + if not value or not value['enabled'] or not system_time_valid() or not cron_matches(value['cron'], now): + return False + from openpilot.starpilot.drive_state.evidence import PhysicalSource + source = PhysicalSource() + def parked(): + deadline = time.monotonic() + 1.5 + while True: + if source.update_ready(self.params.get_bool('IsOffroad')): + return True + if time.monotonic() >= deadline: + return False + time.sleep(0.02) + def ready(): + if (not idle() or self.params.get('UpdaterState') != 'idle' or + self._check_thread is not None or not self.update_ready or Path(FINALIZED, '.overlay_consistent').is_symlink()): + return False + validate_revision(FINALIZED, 'HEAD') + return True + try: + return scheduled_restart(self.params, self.install_settings, ready, parked, now) + except (OSError, ValueError, subprocess.SubprocessError): + cloudlog.exception('Scheduled update restart was not admitted') + return False + finally: + source.close() + + def _write_progress(self, **values): + if not hasattr(self, '_progress_lock'): + self._progress_lock = threading.RLock() + with self._progress_lock: + previous = self.params.get('UpdaterFastState') + state = dict(previous) if isinstance(previous, dict) else {'version': 1, 'stage': 'complete', 'detail': ''} + state.update(values) + self.params.put('UpdaterFastState', state, block=True) + + def progress(self, stage, detail, *, background=False, percent=None): + if percent is None and (match := re.search(r'(\d+)%', detail)): + percent = min(100, int(match[1])) + self._write_progress(version=1, stage=stage, detail=detail[:512], background=background, percent=percent) + + def request_check(self, *, branch=None, commit=None, download_after=False): + with self._request_lock: + download_after = download_after or bool(self._pending_check and self._pending_check[2]) + self._pending_check = (branch or self.target_branch, commit, download_after) + if self._check_thread is None: + self._check_thread = threading.Thread(target=self._run_checks, name='updater-metadata', daemon=True) + self._check_thread.start() + + def _run_checks(self): + while not self._closed.is_set(): + with self._request_lock: + request, self._pending_check = self._pending_check, None + if request is None: + self._check_thread = None + return + branch, commit, download_after = request + try: + if commit is not None and self.get_commit_hash(BASEDIR) != commit: + raise FastUpdateError('Installed revision changed; refresh and try again') + self.check_for_update(branch) + if (download_after and self.wake_automatic is not None and not self.params.get_bool('DoReboot') and + download_permitted(self.params, manual=False) and self.update_available and not self.latest_update_ready): + self.wake_automatic() + except Exception as error: + self._write_progress(check={'state': 'error', 'branch': branch, 'detail': str(error)[:512], 'completedAt': time.monotonic()}) + + def close(self): + self._closed.set() + if self._check_thread is not None: + self._check_thread.join(timeout=6) @property def has_internet(self) -> bool: @@ -318,19 +456,28 @@ class Updater: @property def update_ready(self) -> bool: consistent_file = Path(os.path.join(FINALIZED, ".overlay_consistent")) - if self._branches_checked and consistent_file.is_file() and self.target_branch in self.branches: - hash_mismatch = self.get_commit_hash(BASEDIR) != self.branches[self.target_branch] - branch_mismatch = self.get_branch(BASEDIR) != self.target_branch - on_target_branch = self.get_branch(FINALIZED) == self.target_branch - finalized_at_remote_head = self.get_commit_hash(FINALIZED) == self.branches[self.target_branch] - return ((hash_mismatch or branch_mismatch) and on_target_branch and finalized_at_remote_head) + branch, branches = self.target_branch, self.branches + if self._branches_checked and consistent_file.is_file() and branch in branches: + branch_mismatch = self.get_branch(BASEDIR) != branch + on_target_branch = self.get_branch(FINALIZED) == branch + staged = getattr(self, '_staged_target', None) + expected = staged[1] if staged is not None and staged[0] == branch else branches[branch] + hash_mismatch = self.get_commit_hash(BASEDIR) != expected + finalized_at_target = self.get_commit_hash(FINALIZED) == expected + return ((hash_mismatch or branch_mismatch) and on_target_branch and finalized_at_target) return False + @property + def latest_update_ready(self) -> bool: + staged = getattr(self, '_staged_target', None) + return self.update_ready and (staged is None or staged == (self.target_branch, self.branches.get(self.target_branch))) + @property def update_available(self) -> bool: - if self._branches_checked and os.path.isdir(OVERLAY_MERGED) and self.target_branch in self.branches: - hash_mismatch = self.get_commit_hash(OVERLAY_MERGED) != self.branches[self.target_branch] - branch_mismatch = self.get_branch(OVERLAY_MERGED) != self.target_branch + branch, branches = self.target_branch, self.branches + if self._branches_checked and branch in branches: + hash_mismatch = self.get_commit_hash(BASEDIR) != branches[branch] + branch_mismatch = self.get_branch(BASEDIR) != branch return hash_mismatch or branch_mismatch return False @@ -410,41 +557,70 @@ class Updater: remaining = max(HOURS_NO_CONNECTIVITY_MAX - dt_uptime_onroad, 1) set_offroad_alert("Offroad_ConnectivityNeededPrompt", True, extra_text=f"{remaining} hour{'' if remaining == 1 else 's'}.") - def check_for_update(self) -> None: + def check_for_update(self, branch: str | None = None) -> None: + branch = branch or self.target_branch + with self._metadata_lock: + self._write_progress(check={'state': 'checking', 'branch': branch, 'detail': 'Fetching version metadata...'}) + try: + self._check_for_update(branch) + self._checked_at, self._checked_branch = time.monotonic(), branch + self._write_progress(check={'state': 'complete', 'branch': branch, 'detail': 'Version metadata refreshed.', + 'completedAt': time.monotonic()}) + except Exception as error: + self._write_progress(check={'state': 'error', 'branch': branch, 'detail': str(error)[:512], 'completedAt': time.monotonic()}) + raise + + def _check_for_update(self, branch) -> None: cloudlog.info("checking for updates") excluded_branches = ('release2', 'release2-staging') self._branches_checked = False self.params.put("UpdaterOSUpdate", {}, block=True) - setup_git_options(OVERLAY_MERGED) + metadata = Path(METADATA_ROOT) + metadata.parent.mkdir(parents=True, exist_ok=True) + if not (metadata / 'HEAD').is_file(): + run(['git', 'init', '--bare', str(metadata)], str(metadata.parent)) + remote = run(['git', 'remote', 'get-url', 'origin'], BASEDIR).strip() + run(['git', 'config', 'remote.origin.url', remote], str(metadata)) + setup_git_options(str(metadata)) try: - output = run(["git", "ls-remote", "--heads", "origin"], OVERLAY_MERGED) + output = run(["git", "ls-remote", "--heads", "origin"], str(metadata), cancelled=self._closed.is_set, timeout=30) except subprocess.CalledProcessError: self._has_internet = False raise self._has_internet = True - self.branches.clear() + branches = {} for line in output.split('\n'): ls_remotes_re = r'(?P\b[0-9a-f]{5,40}\b)(\s+)(refs\/heads\/)(?P.*$)' x = re.fullmatch(ls_remotes_re, line.strip()) if x is not None and x.group('branch_name') not in excluded_branches: - self.branches[x.group('branch_name')] = x.group('commit_sha') - cur_branch = self.get_branch(OVERLAY_MERGED) - cur_commit = self.get_commit_hash(OVERLAY_MERGED) + branches[x.group('branch_name')] = x.group('commit_sha') + self.branches = branches + cur_branch = self.get_branch(BASEDIR) + cur_commit = self.get_commit_hash(BASEDIR) self._branches_checked = True - write_time_to_param(self.params, "UpdaterLastCheckTime") - new_branch = self.target_branch + new_branch = branch or self.target_branch new_commit = self.branches.get(new_branch) if new_commit is None: cloudlog.info("target branch %s is not published on origin; no update available", new_branch) + self.params.put('UpdaterAvailableBranches', ','.join(self.branches), block=True) + self.params.put_bool('UpdaterFetchAvailable', False, block=True) + self.params.put_bool('UpdateAvailable', False, block=True) + write_time_to_param(self.params, 'UpdaterLastCheckTime') return + # Fetch history and trees, not artifact contents, for the version list. + def metadata_progress(detail): + self._write_progress(check={'state': 'checking', 'branch': branch, 'detail': detail[:512]}) + run(["git", "fetch", '--progress', f"--depth={MAX_COMMITS}", "--filter=blob:none", "--no-tags", "--no-recurse-submodules", + "origin", f"+{new_commit}:refs/remotes/origin/{new_branch}"], str(metadata), + progress=metadata_progress, cancelled=self._closed.is_set, timeout=30) + run(["git", "update-ref", f"refs/starpilot/recent/{new_branch}", new_commit], str(metadata)) if AGNOS: try: - if cur_commit != new_commit: - run(["git", "fetch", "--depth=1", "--filter=blob:none", "--no-tags", "--no-recurse-submodules", "origin", new_commit], OVERLAY_MERGED) - version = required_os_version(run(["git", "show", f"{new_commit}:launch_env.sh"], OVERLAY_MERGED)) + version = required_os_version(run(["git", "show", f"{new_commit}:launch_env.sh"], str(metadata), + download=True, cancelled=self._closed.is_set, timeout=30)) current_os = HARDWARE.get_os_version() if version and current_os: self.params.put("UpdaterOSUpdate", {"branch": new_branch, "required": version != current_os}, block=True) @@ -454,8 +630,12 @@ class Updater: cloudlog.info(f"update available, {cur_branch} ({str(cur_commit)[:7]}) -> {new_branch} ({str(new_commit)[:7]})") else: cloudlog.info(f"up to date on {cur_branch} ({str(cur_commit)[:7]})") + self.params.put("UpdaterAvailableBranches", ','.join(self.branches), block=True) + self.params.put_bool("UpdaterFetchAvailable", self.update_available, block=True) + self.params.put_bool("UpdateAvailable", self.update_ready, block=True) + write_time_to_param(self.params, 'UpdaterLastCheckTime') - def fast_update(self, branch: str, commit: str, selected_commit=None, *, rollback=False, versions_only=False, automatic=False) -> str: + def fast_update(self, branch: str, commit: str, selected_commit=None, *, rollback=False, versions_only=False, automatic=False, download_only=False) -> str: from openpilot.starpilot.drive_state.evidence import PhysicalSource from openpilot.starpilot.software.fast_update import fast_update @@ -478,11 +658,47 @@ class Updater: self._branches_checked = False def progress(stage, detail): - self.params.put("UpdaterFastState", {"version": 1, "stage": stage, "detail": detail[:512], "branch": branch}, block=True) + self.progress(stage, detail) try: + if versions_only: + initial_branch = self.get_branch(BASEDIR) + def admitted(): + if not parked() or self.get_commit_hash(BASEDIR) != commit or self.get_branch(BASEDIR) != initial_branch: + raise FastUpdateError("Update source or parked state changed") + admitted() + run(["git", "check-ref-format", "--branch", branch], BASEDIR) + progress('fetching', 'Fetching version metadata...') + self.check_for_update(branch) + admitted() + if branch not in self.branches: + raise FastUpdateError("Target branch is no longer available") + progress('complete', 'Recent versions are ready to review.') + return 'versions-listed' + if download_only: + def admitted(): + if not parked() or self.get_commit_hash(BASEDIR) != commit: + raise FastUpdateError('Installed revision or parked state changed; refresh and try again') + admitted() + self.check_for_update(branch) + members = commit_history(Path(METADATA_ROOT), self.branches.get(branch, ''), first_parent=True) + if branch == self.get_branch(BASEDIR): + members += commit_history(Path(BASEDIR), commit, first_parent=True) + if not any(row['hash'] == selected_commit for row in members): + raise FastUpdateError('Selected version is no longer in branch history; refresh and try again') + admitted() + self.params.put('UpdaterTargetBranch', branch, block=True) + if not self.fetch_update(selected_commit=selected_commit, admitted=admitted): + raise FastUpdateError('Selected version could not be downloaded') + admitted() + self.set_params(True, 0, None) + return 'downloaded' + def installed_progress(stage, detail): + if stage == 'complete': + self.params.put('UpdaterTargetBranch', self.get_branch(BASEDIR), block=True) + progress(stage, detail) return fast_update(Path(BASEDIR), branch, expected_commit=commit, params=self.params, parked=parked, - current_os=HARDWARE.get_os_version(), invalidate=invalidate, rollback=rollback, progress=progress, + current_os=HARDWARE.get_os_version(), invalidate=invalidate, rollback=rollback, progress=installed_progress, selected_commit=selected_commit, versions_only=versions_only) except FastUpdateIncompatible: if not automatic: @@ -499,7 +715,6 @@ class Updater: admitted() progress("preparing", "Preparing full software update") self.params.put("UpdaterTargetBranch", branch, block=True) - init_overlay() self._branches_checked = False self.params.put("UpdaterState", "checking...", block=True) if phone_uplink_active(): @@ -513,10 +728,10 @@ class Updater: self.params.put_bool("DoReboot", True, block=True) return "reboot-requested" - def fetch_update(self) -> bool: + def fetch_update(self, *, background=False, selected_commit=None, admitted=None) -> bool: if phone_uplink_active(): return False - if not self._branches_checked: + if not self._branches_checked or self._checked_branch != self.target_branch: self.check_for_update() if self.target_branch not in self.branches: cloudlog.info("skipping fetch of unpublished target branch %s", self.target_branch) @@ -525,6 +740,11 @@ class Updater: cloudlog.info("attempting git fetch inside staging overlay") self.params.put("UpdaterState", "downloading...", block=True) + self.progress('preparing', 'Preparing download staging...', background=background) + init_overlay() + self.progress('fetching', 'Downloading the selected version...', background=background) + def git_progress(detail): + self.progress('fetching', detail, background=background) # TODO: cleanly interrupt this and invalidate old update set_consistent_flag(False) @@ -535,22 +755,37 @@ class Updater: run(["git", "config", "--replace-all", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"], OVERLAY_MERGED) branch = self.target_branch - git_fetch_output = run(["git", "fetch", "--no-recurse-submodules", "origin", branch], OVERLAY_MERGED) - cloudlog.info("git fetch success: %s", git_fetch_output) - # Validate using the running updater before checking out or executing any update. # A branch containing gitlinks or an invalid dependency manifest is not installable. - fetched_commit = run(["git", "rev-parse", "FETCH_HEAD^{commit}"], OVERLAY_MERGED).strip() + fetched_commit = selected_commit or self.branches[branch] + run(['git', 'fetch', '--progress', '--depth=1', '--filter=blob:none', '--no-tags', '--no-recurse-submodules', + 'origin', f'+{fetched_commit}:refs/remotes/origin/{branch}'], OVERLAY_MERGED, progress=git_progress) + # Fetch the manifest under the download guard before read-only validation. + if run(["git", "ls-tree", fetched_commit, "--", "upstream-sync.json"], OVERLAY_MERGED).split()[:1] == ['100644']: + run(["git", "show", f"{fetched_commit}:upstream-sync.json"], OVERLAY_MERGED, download=True) validate_revision(OVERLAY_MERGED, fetched_commit) + def git(*args, stdin=None): + return run(['git', *args], OVERLAY_MERGED, progress=git_progress, stdin=stdin) + fetch_files(git, fetched_commit) + run(["git", "update-ref", f"refs/starpilot/recent/{branch}", fetched_commit], OVERLAY_MERGED) + if admitted is not None: + admitted() cloudlog.info("git reset in progress") + self.progress('applying', 'Writing downloaded files...', background=background) + def apply_progress(detail): + self.progress('applying', detail, background=background) + installed_commit = self.get_commit_hash(BASEDIR) + installed_branch = self.get_branch(BASEDIR) + run(['git', 'update-ref', 'refs/starpilot/previous', installed_commit], OVERLAY_MERGED) + run(['git', 'config', '--local', 'starpilot.previousVersion', f'{installed_commit} {installed_branch}'], OVERLAY_MERGED) cmds = [ - ["git", "checkout", "--force", "--no-recurse-submodules", "-B", branch, fetched_commit], + ["git", "checkout", '--progress', "--force", "--no-recurse-submodules", "-B", branch, fetched_commit], ["git", "branch", "--set-upstream-to", f"origin/{branch}"], ["git", "reset", "--hard", "--no-recurse-submodules"], ["git", "clean", "-xdff"], ] - r = [run(cmd, OVERLAY_MERGED) for cmd in cmds] + r = [run(cmd, OVERLAY_MERGED, download=cmd[1] in ('checkout', 'reset'), progress=apply_progress) for cmd in cmds] validate_revision(OVERLAY_MERGED, "HEAD") cloudlog.info("git reset success: %s", '\n'.join(r)) @@ -560,18 +795,27 @@ class Updater: # Create the finalized, ready-to-swap update self.params.put("UpdaterState", "finalizing update...", block=True) - finalize_update() + self.progress('finalizing', 'Preparing the downloaded update...', background=background) + finalize_update(progress=lambda detail, percent: self.progress('finalizing', detail, background=background, percent=percent)) + self._staged_target = (branch, fetched_commit) + self.progress('complete', 'Downloaded update is ready to install.', background=background) cloudlog.info("finalize success!") return True def recover_fast_state(params): previous = params.get("UpdaterFastState") - if isinstance(previous, dict) and previous.get("stage") not in ("complete", "error"): - params.put("UpdaterFastState", { - "version": 1, "stage": "error", - "detail": "Update process restarted before completion; check the installed version and retry.", - }, block=True) + if not isinstance(previous, dict): + return + state = dict(previous) + if state.get('check', {}).get('state') == 'checking': + state['check'] = {**state['check'], 'state': 'error', 'completedAt': time.monotonic(), + 'detail': 'Metadata check was interrupted when the updater restarted. Retry the check.'} + if state.get('stage') not in ('complete', 'error'): + state.update(version=1, stage='error', + detail='Update process restarted before completion; check the installed version and retry.') + if state != previous: + params.put('UpdaterFastState', state, block=True) def main() -> None: @@ -597,7 +841,10 @@ def main() -> None: updater = Updater() update_failed_count = max(0, params.get("UpdateFailedCount") or 0) - wait_helper = WaitTimeHelper() + wait_helper = WaitTimeHelper(metadata=updater.request_check) + wait_helper.idle_tick = lambda: updater.maybe_install(lambda: not wait_helper.ready_event.is_set()) + updater.wake_automatic = wait_helper.wake_automatic + atexit.register(updater.close) # invalidate old finalized update set_consistent_flag(False) @@ -613,14 +860,18 @@ def main() -> None: # Attempt an update exception = None - fast_requested = requested in (UserRequest.UPDATE, UserRequest.FAST, UserRequest.ROLLBACK, UserRequest.VERSIONS, UserRequest.VERSION) + fast_requested = requested in ( + UserRequest.UPDATE, UserRequest.FAST, UserRequest.ROLLBACK, UserRequest.VERSIONS, UserRequest.VERSION, UserRequest.DOWNLOAD_VERSION, + ) try: if fast_requested: update_failed_count += 1 - params.put("UpdaterState", "updating...", block=True) + params.put("UpdaterState", "downloading..." if requested == UserRequest.DOWNLOAD_VERSION else "updating...", block=True) if fast_target is None: raise ValueError("Update source identity is required") - if requested == UserRequest.VERSIONS: + if requested == UserRequest.DOWNLOAD_VERSION: + updater.fast_update(*fast_target, download_only=True) + elif requested == UserRequest.VERSIONS: updater.fast_update(*fast_target, versions_only=True) elif requested == UserRequest.UPDATE: updater.fast_update(*fast_target, automatic=True) @@ -632,9 +883,6 @@ def main() -> None: if requested != UserRequest.VERSIONS: write_time_to_param(params, "LastUpdateTime") else: - # TODO: reuse overlay from previous updated instance if it looks clean - init_overlay() - # ensure we have some params written soon after startup updater.set_params(False, update_failed_count, exception) @@ -646,21 +894,27 @@ def main() -> None: update_failed_count += 1 # check for update - params.put("UpdaterState", "checking...", block=True) - updater.check_for_update() + if requested == UserRequest.CHECK or updater._checked_branch != updater.target_branch or time.monotonic() - updater._checked_at > 60: + updater.check_for_update() # download update last_fetch = params.get("UpdaterLastFetchTime") timed_out = last_fetch is None or (datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - last_fetch > datetime.timedelta(days=3)) user_requested_fetch = requested == UserRequest.FETCH - if phone_uplink_active(): + if params.get_bool('DoReboot'): + cloudlog.info('skipping fetch, restart pending') + elif requested == UserRequest.CHECK: + cloudlog.info('metadata-only check complete') + elif phone_uplink_active(): cloudlog.info("skipping fetch, phone uplink forbids updates") elif params.get_bool("NetworkMetered") and not timed_out and not user_requested_fetch: cloudlog.info("skipping fetch, connection metered") - elif not download_permitted(params, manual=user_requested_fetch, check_only=requested == UserRequest.CHECK): - cloudlog.info("skipping fetch, check-only request or automatic downloads disabled") + elif not download_permitted(params, manual=user_requested_fetch): + cloudlog.info("skipping fetch, automatic downloads disabled") + elif not user_requested_fetch and (updater.latest_update_ready or not updater.update_available): + cloudlog.info("skipping fetch, checked version is already installed or staged") else: - if updater.fetch_update(): + if updater.fetch_update(background=not user_requested_fetch): write_time_to_param(params, "UpdaterLastFetchTime") update_failed_count = 0 except subprocess.CalledProcessError as e: @@ -683,7 +937,7 @@ def main() -> None: update_successful = (update_failed_count == 0) if fast_requested: if exception is not None: - params.put("UpdaterFastState", {"version": 1, "stage": "error", "detail": exception[:512]}, block=True) + updater.progress('error', exception) params.put("UpdateFailedCount", update_failed_count, block=True) if exception is None: params.remove("LastUpdateException")