From c98806b08fdd8ba1d4473e806efc113751ac2112 Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Tue, 25 Aug 2026 18:31:52 +0700 Subject: [PATCH] =?UTF-8?q?fix(updater):=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BA=D0=B0=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9=20=D0=BD=D0=B5=20=D0=B4=D0=BE=D1=85=D0=BE?= =?UTF-8?q?=D0=B4=D0=B8=D0=BB=D0=B0=20=D0=B4=D0=BE=20=D0=B8=D0=BD=D1=82?= =?UTF-8?q?=D0=B5=D1=80=D1=84=D0=B5=D0=B9=D1=81=D0=B0;=20=D0=BD=D0=B5?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=B5=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D1=84=D0=B0=D0=B9=D0=BB=20=D0=B7=D0=B0=D0=BF=D1=83?= =?UTF-8?q?=D1=81=D0=BA=D0=B0=D0=BB=D1=81=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проверка A27 исполнением. Основа сделана верно — сравнение по коммитам, чтение релизов основного репозитория, честный показ отказа проверки. Три дефекта закрыты. 1. В вебе функция была нерабочей целиком. Веб-сервер передаёт async_runner всегда, а check_updates в этой ветке отвечал «Проверка обновлений запущена» без data. Результат до клиента не доходил, кнопка обновления не могла появиться никогда. Проверка — один HTTP-запрос с таймаутом 10 секунд, поэтому выполняется синхронно и всегда возвращает данные; в фон уходит только установка. 2. Контрольная сумма пропускалась молча. При недоступном checksums.txt expected_sha оставался пустым, проверка не выполнялась, и скачанный установщик запускался. Здесь исполняется загруженный из сети код — непроверенный файл теперь не запускается вовсе, с внятной причиной. 3. Перезапуска не было, но он обещался. Ни install-linux.sh, ни виндовый установщик в тихом режиме приложение не поднимают, а сообщение гласило «Hermes Hub будет перезапущен»: владелец остался бы со старым процессом и решил, что обновление не сработало. Добавлен schedule_restart — отсоединённый помощник ждёт освобождения порта, текущий процесс выходит раньше. Установка теперь дожидается завершения установщика и проверяет код возврата, вместо того чтобы обещать успех сразу после запуска. Тест test_action_executor_update_actions требовал, чтобы check_updates уходил в фон, то есть закреплял дефект как требование — переведён на желаемое поведение. Проверено: сумма отсутствует — отказ; сумма не совпала — отказ; совпала — запуск и перезапуск. Через HTTP приходят installed_commit, latest_commit, release_tag и время публикации. В интерфейсе видно «Доступно обновление (a1e1db7)», «Сборка: d7ad3e3» и время последней проверки; при отказе сети — «Ошибка проверки» с причиной, а не «Актуально». 450 passed, ruff чисто. Co-Authored-By: Claude Opus 5 --- .../router/action_handler.py | 31 +++--- .../updater/update_manager.py | 94 +++++++++++++++++-- tests/test_in_app_updates_a27.py | 14 ++- 3 files changed, 118 insertions(+), 21 deletions(-) diff --git a/src/antigravity_provider/router/action_handler.py b/src/antigravity_provider/router/action_handler.py index 940c042..1366dc8 100644 --- a/src/antigravity_provider/router/action_handler.py +++ b/src/antigravity_provider/router/action_handler.py @@ -580,19 +580,24 @@ class ActionExecutor: return {'ok': ok, 'message': msg} elif action == 'check_updates': - mgr = UpdateManager() - if async_runner: - async_runner(lambda: mgr.check_for_updates(), 'CheckUpdates') - return {'ok': True, 'message': 'Проверка обновлений запущена'} - else: - res = mgr.check_for_updates() - if res.error: - return {'ok': False, 'message': res.error, 'data': res.to_dict()} - return { - 'ok': True, - 'message': res.message or ('Доступно обновление' if res.update_available else 'Установлена последняя сборка'), - 'data': res.to_dict(), - } + # Проверка выполняется СИНХРОННО и всегда возвращает данные. + # + # В фоновом режиме действие отвечало «запущено» с пустым data, и + # результат до интерфейса не доходил вообще: проверено запросом, + # ответ был {"ok":true,"message":"запущено","data":{}}. Кнопка + # обновления при этом не могла появиться никогда. + # + # Это один HTTP-запрос к API релизов с таймаутом 10 секунд, а не + # многоминутная установка, поэтому ждать его допустимо. Установка + # (apply_update) по-прежнему уходит в фон. + res = UpdateManager().check_for_updates() + if res.error: + return {'ok': False, 'message': res.error, 'data': res.to_dict()} + return { + 'ok': True, + 'message': res.message or ('Доступно обновление' if res.update_available else 'Установлена последняя сборка'), + 'data': res.to_dict(), + } elif action == 'apply_update': mgr = UpdateManager() diff --git a/src/antigravity_provider/updater/update_manager.py b/src/antigravity_provider/updater/update_manager.py index 28dbeed..f15c4e4 100644 --- a/src/antigravity_provider/updater/update_manager.py +++ b/src/antigravity_provider/updater/update_manager.py @@ -17,6 +17,7 @@ import os import re import shutil import subprocess +import threading import sys import time import urllib.error @@ -571,7 +572,20 @@ class UpdateManager: if not expected_sha and check_result.manifest and check_result.manifest.sha256: expected_sha = check_result.manifest.sha256.lower() - if expected_sha and calc_sha != expected_sha: + # Отсутствие суммы — не разрешение. Раньше при недоступном checksums.txt + # expected_sha оставался пустым, проверка молча пропускалась и скачанный + # файл всё равно запускался. Здесь запускается загруженный из сети + # исполняемый код, поэтому непроверенный файл не запускаем вовсе. + if not expected_sha: + dest_file.unlink(missing_ok=True) + return ( + False, + f"Не удалось получить контрольную сумму для {chosen_asset_name}: " + "в релизе нет checksums.txt или файл не скачался. " + "Установка отменена — непроверенный файл не запускается." + ) + + if calc_sha != expected_sha: dest_file.unlink(missing_ok=True) return ( False, @@ -592,22 +606,90 @@ class UpdateManager: creation_flags = 0 if hasattr(subprocess, "DETACHED_PROCESS") and hasattr(subprocess, "CREATE_NEW_PROCESS_GROUP"): creation_flags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP - subprocess.Popen(cmd, creationflags=creation_flags) - return True, "Установщик запущен в фоновом режиме. Hermes Hub будет перезапущен." + proc = subprocess.Popen(cmd, creationflags=creation_flags) + try: + rc = proc.wait(timeout=600) + except subprocess.TimeoutExpired: + return False, "Установщик не завершился за 10 минут. Проверьте состояние вручную." + if rc != 0: + return False, f"Установщик завершился с кодом {rc}. Обновление не применено." + ok_r, msg_r = self.schedule_restart() + if not ok_r: + return True, f"Обновление установлено. {msg_r}" + return True, "Обновление установлено, Hermes Hub перезапускается." except Exception as exc: return False, f"Не удалось запустить установщик: {exc}" elif chosen_asset_name.endswith(".sh"): try: os.chmod(dest_file, 0o755) - cmd = ["bash", str(dest_file), "--silent"] - subprocess.Popen(cmd) - return True, "Скрипт установки запущен. Hermes Hub будет перезапущен." + # Ждём завершения: без этого перезапуск начался бы прямо во + # время распаковки, а владелец получил бы обещание перезапуска + # при неизвестном исходе установки. + res_i = subprocess.run( + ["bash", str(dest_file)], + capture_output=True, text=True, timeout=600, + ) + if res_i.returncode != 0: + tail = (res_i.stderr or res_i.stdout or "").strip().splitlines()[-3:] + return False, "Установка не удалась: " + " / ".join(tail) + ok_r, msg_r = self.schedule_restart() + if not ok_r: + return True, f"Обновление установлено. {msg_r}" + return True, "Обновление установлено, Hermes Hub перезапускается." except Exception as exc: return False, f"Не удалось запустить скрипт установки: {exc}" return True, "Файл обновления загружен и проверен" + + def schedule_restart(self, delay_sec: float = 3.0) -> Tuple[bool, str]: + """Перезапустить веб-хаб после установки обновления. + + Ни install-linux.sh, ни виндовый установщик в тихом режиме приложение не + поднимают, а сообщение обещало перезапуск. Владелец оставался со старым + процессом, продолжавшим отдавать старый код, и делал вывод, что + обновление не сработало. + + Порядок именно такой: сначала отсоединённый помощник, потом выход + текущего процесса. Лаунчер считает хаб работающим, если порт отвечает, + поэтому поднимать новый, не освободив порт, бесполезно. + """ + home = paths.get_hermes_home() + if sys.platform == "win32": + launcher = home / "HermesHubWeb.exe" + else: + launcher = Path.home() / ".local" / "bin" / "hermes-hub-web" + + if not launcher.exists(): + return False, f"Лаунчер не найден: {launcher}. Запустите Hermes Hub вручную." + + try: + if sys.platform == "win32": + flags = 0 + if hasattr(subprocess, "DETACHED_PROCESS") and hasattr(subprocess, "CREATE_NEW_PROCESS_GROUP"): + flags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP + subprocess.Popen( + ["cmd", "/c", f"timeout /t {int(delay_sec)} >nul & \"{launcher}\""], + creationflags=flags, + ) + else: + subprocess.Popen( + ["nohup", "sh", "-c", f"sleep {delay_sec}; exec '{launcher}'"], + start_new_session=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except Exception as exc: + return False, f"Не удалось запланировать перезапуск: {exc}. Запустите Hermes Hub вручную." + + def _exit_soon() -> None: + time.sleep(max(0.5, delay_sec - 1.5)) + os._exit(0) + + threading.Thread(target=_exit_soon, daemon=True, name="hub-restart").start() + return True, "Перезапуск запланирован" + def apply_update_sync(self, package_zip: Path, target_dir: Optional[Path] = None) -> Tuple[bool, str]: """Apply update package with automatic backup and rollback on failure.""" import zipfile diff --git a/tests/test_in_app_updates_a27.py b/tests/test_in_app_updates_a27.py index 0c2f0ae..3f09cbc 100644 --- a/tests/test_in_app_updates_a27.py +++ b/tests/test_in_app_updates_a27.py @@ -218,14 +218,24 @@ def test_action_executor_update_actions(monkeypatch, tmp_path): assert "data" in res assert res["data"]["update_available"] is False - # 2. check_updates async + # 2. check_updates ВСЕГДА синхронна и всегда возвращает данные. + # + # Здесь раньше требовалось обратное — чтобы действие уходило в фон. Это + # закрепляло дефект: веб-сервер передаёт async_runner всегда, фоновая ветка + # отвечала «Проверка обновлений запущена» без data, и результат до + # интерфейса не доходил вовсе. Проверено запросом: кнопка обновления не + # могла появиться никогда. Проверка — один HTTP-запрос с таймаутом 10 + # секунд, ждать её допустимо; в фон уходит только установка. dispatched = [] def mock_runner(fn, name): dispatched.append(name) res_async = ActionExecutor.execute("check_updates", {}, async_runner=mock_runner) assert res_async["ok"] is True - assert "CheckUpdates" in dispatched + assert "CheckUpdates" not in dispatched, "проверка обновлений не должна уходить в фон без данных" + # Значение здесь не проверяем: вызов вне заглушки и ходит в сеть по-настоящему. + # Важно ровно одно — данные пришли, а не пустой ответ «запущено». + assert res_async.get("data"), "ответ без данных: интерфейс не узнает о наличии обновления" # 3. apply_update async res_apply_async = ActionExecutor.execute("apply_update", {}, async_runner=mock_runner)