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}
|
return {'ok': ok, 'message': msg}
|
||||||
|
|
||||||
elif action == 'check_updates':
|
elif action == 'check_updates':
|
||||||
mgr = UpdateManager()
|
# Проверка выполняется СИНХРОННО и всегда возвращает данные.
|
||||||
if async_runner:
|
#
|
||||||
async_runner(lambda: mgr.check_for_updates(), 'CheckUpdates')
|
# В фоновом режиме действие отвечало «запущено» с пустым data, и
|
||||||
return {'ok': True, 'message': 'Проверка обновлений запущена'}
|
# результат до интерфейса не доходил вообще: проверено запросом,
|
||||||
else:
|
# ответ был {"ok":true,"message":"запущено","data":{}}. Кнопка
|
||||||
res = mgr.check_for_updates()
|
# обновления при этом не могла появиться никогда.
|
||||||
if res.error:
|
#
|
||||||
return {'ok': False, 'message': res.error, 'data': res.to_dict()}
|
# Это один HTTP-запрос к API релизов с таймаутом 10 секунд, а не
|
||||||
return {
|
# многоминутная установка, поэтому ждать его допустимо. Установка
|
||||||
'ok': True,
|
# (apply_update) по-прежнему уходит в фон.
|
||||||
'message': res.message or ('Доступно обновление' if res.update_available else 'Установлена последняя сборка'),
|
res = UpdateManager().check_for_updates()
|
||||||
'data': res.to_dict(),
|
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':
|
elif action == 'apply_update':
|
||||||
mgr = UpdateManager()
|
mgr = UpdateManager()
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import threading
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
|
|
@ -571,7 +572,20 @@ class UpdateManager:
|
||||||
if not expected_sha and check_result.manifest and check_result.manifest.sha256:
|
if not expected_sha and check_result.manifest and check_result.manifest.sha256:
|
||||||
expected_sha = check_result.manifest.sha256.lower()
|
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)
|
dest_file.unlink(missing_ok=True)
|
||||||
return (
|
return (
|
||||||
False,
|
False,
|
||||||
|
|
@ -592,22 +606,90 @@ class UpdateManager:
|
||||||
creation_flags = 0
|
creation_flags = 0
|
||||||
if hasattr(subprocess, "DETACHED_PROCESS") and hasattr(subprocess, "CREATE_NEW_PROCESS_GROUP"):
|
if hasattr(subprocess, "DETACHED_PROCESS") and hasattr(subprocess, "CREATE_NEW_PROCESS_GROUP"):
|
||||||
creation_flags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
creation_flags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
||||||
subprocess.Popen(cmd, creationflags=creation_flags)
|
proc = subprocess.Popen(cmd, creationflags=creation_flags)
|
||||||
return True, "Установщик запущен в фоновом режиме. Hermes Hub будет перезапущен."
|
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:
|
except Exception as exc:
|
||||||
return False, f"Не удалось запустить установщик: {exc}"
|
return False, f"Не удалось запустить установщик: {exc}"
|
||||||
|
|
||||||
elif chosen_asset_name.endswith(".sh"):
|
elif chosen_asset_name.endswith(".sh"):
|
||||||
try:
|
try:
|
||||||
os.chmod(dest_file, 0o755)
|
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:
|
except Exception as exc:
|
||||||
return False, f"Не удалось запустить скрипт установки: {exc}"
|
return False, f"Не удалось запустить скрипт установки: {exc}"
|
||||||
|
|
||||||
return True, "Файл обновления загружен и проверен"
|
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]:
|
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."""
|
"""Apply update package with automatic backup and rollback on failure."""
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
|
||||||
|
|
@ -218,14 +218,24 @@ def test_action_executor_update_actions(monkeypatch, tmp_path):
|
||||||
assert "data" in res
|
assert "data" in res
|
||||||
assert res["data"]["update_available"] is False
|
assert res["data"]["update_available"] is False
|
||||||
|
|
||||||
# 2. check_updates async
|
# 2. check_updates ВСЕГДА синхронна и всегда возвращает данные.
|
||||||
|
#
|
||||||
|
# Здесь раньше требовалось обратное — чтобы действие уходило в фон. Это
|
||||||
|
# закрепляло дефект: веб-сервер передаёт async_runner всегда, фоновая ветка
|
||||||
|
# отвечала «Проверка обновлений запущена» без data, и результат до
|
||||||
|
# интерфейса не доходил вовсе. Проверено запросом: кнопка обновления не
|
||||||
|
# могла появиться никогда. Проверка — один HTTP-запрос с таймаутом 10
|
||||||
|
# секунд, ждать её допустимо; в фон уходит только установка.
|
||||||
dispatched = []
|
dispatched = []
|
||||||
def mock_runner(fn, name):
|
def mock_runner(fn, name):
|
||||||
dispatched.append(name)
|
dispatched.append(name)
|
||||||
|
|
||||||
res_async = ActionExecutor.execute("check_updates", {}, async_runner=mock_runner)
|
res_async = ActionExecutor.execute("check_updates", {}, async_runner=mock_runner)
|
||||||
assert res_async["ok"] is True
|
assert res_async["ok"] is True
|
||||||
assert "CheckUpdates" in dispatched
|
assert "CheckUpdates" not in dispatched, "проверка обновлений не должна уходить в фон без данных"
|
||||||
|
# Значение здесь не проверяем: вызов вне заглушки и ходит в сеть по-настоящему.
|
||||||
|
# Важно ровно одно — данные пришли, а не пустой ответ «запущено».
|
||||||
|
assert res_async.get("data"), "ответ без данных: интерфейс не узнает о наличии обновления"
|
||||||
|
|
||||||
# 3. apply_update async
|
# 3. apply_update async
|
||||||
res_apply_async = ActionExecutor.execute("apply_update", {}, async_runner=mock_runner)
|
res_apply_async = ActionExecutor.execute("apply_update", {}, async_runner=mock_runner)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue