diff --git a/openpilot/starpilot/galaxy/software_operations.py b/openpilot/starpilot/galaxy/software_operations.py index 6ddb46d4d4..ccd8526110 100644 --- a/openpilot/starpilot/galaxy/software_operations.py +++ b/openpilot/starpilot/galaxy/software_operations.py @@ -271,7 +271,8 @@ class SoftwareOperations: status["updater"]["lastSuccessAt"] != self._baseline[0]: if request["action"] in SOURCE_ACTIONS: if status["updater"]["lastFetchAt"] != self._baseline[1]: - restarting = self._flag("DoReboot") is True or status["installed"]["commit"] != self._fast_commit + 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': request.update(state='complete', error=None) else: @@ -453,12 +454,11 @@ class SoftwareOperations: (branch != view["selectedTarget"] or not view["canDownload"])): raise SoftwareOperationError("Selected updater target changed", 409) if action in SOURCE_ACTIONS: - installed = self.status.snapshot()["installed"] identity = self.git_identity(self.installed) - if (not view["canFastUpdate"] or identity is None or identity != (installed["branch"], installed["commit"]) or - (action in ("update", "fast", "versions", "version") and branch != installed["branch"] and branch not in view["availableBranches"]) or - (action == "rollback" and (branch != installed["branch"] or not view["canRollback"]))): - raise SoftwareOperationError("Installed branch changed; refresh before updating", 409) + 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 == "rollback" and (branch != identity[0] or not view["canRollback"]))): + raise SoftwareOperationError("Installed checkout or update target is unavailable", 409) if action == 'version': target = payload['selectedCommit'] recent = view['history']['recent'] @@ -472,8 +472,7 @@ class SoftwareOperations: current = self.status.snapshot() if (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()) or - (action == "rollback" and current["installed"]["branch"] != branch)): + (action not in SOURCE_ACTIONS and branch is not None and branch not in self._branches())): raise SoftwareOperationError("Updater state changed", 409) if action == "select": self.params.put("UpdaterTargetBranch", branch, block=True) @@ -494,12 +493,12 @@ class SoftwareOperations: if not authorized() or not self._parked(): raise SoftwareOperationError("Session or parked state changed", 403) if action in SOURCE_ACTIONS: - identity = self.git_identity(self.installed) - if identity is None or identity != (status["installed"]["branch"], status["installed"]["commit"]): - raise SoftwareOperationError("Installed branch changed; refresh before updating", 409) + 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']: raise SoftwareOperationError('Installed revision changed before version request; refresh and try again', 409) - self._fast_commit = status["installed"]["commit"] + self._fast_commit = identity[1] if action in SOURCE_ACTIONS: if action == 'version': accepted_owner = self.process.send(action, branch=branch, commit=identity[1], selected_commit=payload['selectedCommit']) diff --git a/openpilot/starpilot/galaxy/tests/test_software_operations.py b/openpilot/starpilot/galaxy/tests/test_software_operations.py index e8710e25dc..4032509405 100644 --- a/openpilot/starpilot/galaxy/tests/test_software_operations.py +++ b/openpilot/starpilot/galaxy/tests/test_software_operations.py @@ -481,14 +481,60 @@ class SoftwareOperationsTest(unittest.TestCase): self.act("fast", "main") self.assertEqual(self.process.sent, ["fast"]) - def test_fast_rejects_stale_installed_identity(self): + def test_fast_refreshes_stale_cached_identity_without_rewriting_running_metadata(self): self.git[self.installed] = ("main", "b" * 40) - self.denied("fast", 409, "main") - self.git[self.installed] = ("release", "a" * 40) - self.denied("fast", 409, "main") - self.assertEqual(self.process.sent, []) + self.act("fast", "main") + self.assertEqual(self.process.fast_requests, [{"branch": "main", "commit": "b" * 40}]) + self.assertEqual(self.params.get("GitCommit"), "a" * 40) + self.assertEqual(self.owner._fast_commit, "b" * 40) + self.params.put("LastUpdateTime", datetime(2026, 10, 30, 12), block=True) + self.params.put("UpdaterLastFetchTime", datetime(2026, 10, 30, 12), block=True) + self.assertEqual(self.owner.snapshot()["request"]["outcome"], "up_to_date") self.assertFalse(self.params.get_bool("DoReboot")) + def test_changed_cached_branch_does_not_block_requested_live_branch(self): + self.git[self.installed] = ("release", "b" * 40) + self.params.remove("UpdaterAvailableBranches") + self.act("update", "release") + self.assertEqual(self.process.fast_requests, [{"branch": "release", "commit": "b" * 40}]) + self.assertEqual(self.params.get("GitBranch"), "main") + + def test_real_git_commit_after_manager_start_can_update(self): + import subprocess + from openpilot.starpilot.galaxy.software_operations import _git_identity + def git(*args): + return subprocess.check_output(["git", "-c", "user.name=Updater Test", "-c", "user.email=test@example.invalid", + "-c", "core.hooksPath=/dev/null", "-C", str(self.installed), *args], text=True).strip() + git("init", "-b", "main") + git("commit", "--allow-empty", "-m", "First") + previous = git("rev-parse", "HEAD") + self.params.put("GitCommit", previous, block=True) + git("commit", "--allow-empty", "-m", "Hotfix") + current = git("rev-parse", "HEAD") + self.owner.git_identity = _git_identity + self.act("fast", "main") + self.assertEqual(self.process.fast_requests, [{"branch": "main", "commit": current}]) + self.assertEqual(self.params.get("GitCommit"), previous) + + def test_actual_checkout_race_still_prevents_dispatch(self): + calls = 0 + def authorized(): + nonlocal calls + calls += 1 + if calls == 3: + self.git[self.installed] = ("main", "b" * 40) + return True + with self.assertRaises(SoftwareOperationError): + self.owner.action("fast", {"action": "fast", "branch": "main"}, authorized=authorized) + self.assertEqual(self.process.sent, []) + + def test_missing_live_checkout_and_unknown_target_still_reject(self): + self.git[self.installed] = None + self.denied("fast", 409, "main") + self.git[self.installed] = ("main", "b" * 40) + self.denied("fast", 409, "unknown-branch") + self.assertEqual(self.process.sent, []) + def test_fast_keeps_existing_admission_and_observes_failure(self): for key in ("DisableUpdates", "DoReboot"): self.params.put_bool(key, True, block=True)