fix(updater): проверка обновлений не доходила до интерфейса; непроверенный файл запускался

Проверка 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 <noreply@anthropic.com>
This commit is contained in:
Hermes Team 2026-08-25 18:31:52 +07:00
parent d7ad3e3165
commit c98806b08f
3 changed files with 118 additions and 21 deletions

View file

@ -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()

View file

@ -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

View file

@ -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)