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:
parent
d7ad3e3165
commit
c98806b08f
3 changed files with 118 additions and 21 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue