From 35c96937408764c27c0cb641dc3ddd88bbf427ee Mon Sep 17 00:00:00 2001 From: Danil Iarosh Date: Thu, 17 Sep 2026 10:30:18 +0300 Subject: [PATCH] fix: stabilize OTA updates in v2.0.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Исправлен перезапуск приложения после замены файлов и добавлено подтверждение запуска новой версии. - Обеспечены откат при ошибке и корректное завершение фоновых задач. - Исключены посторонние DLL из Windows-сборки, обновлены тесты и инструкция ручного перехода с v2.0.1. --- README.md | 11 ++- app/__init__.py | 2 +- app/entry.py | 3 + app/services/update_service.py | 118 +++++++++++++++++++++++++++++---- app/widgets/start_tab.py | 5 +- build_exe.ps1 | 24 ++++++- tests/test_start_tab.py | 10 ++- tests/test_update_service.py | 48 ++++++++++---- 8 files changed, 177 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index f16eb25..737efde 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,10 @@ Приложение скачивает ZIP-пакет текущей платформы, проверяет его целостность и безопасность, завершает старый процесс, заменяет бинарник и все каталоги из -пакета, а затем запускается заново. Перед заменой создаётся резервная копия для -автоматического восстановления при ошибке. Файл `settings.json` и другие файлы, +пакета, а затем запускается заново. Перед заменой создаётся резервная копия; +она удаляется только после подтверждения запуска новой версии. Если запуск +не удался, предыдущая версия восстанавливается, а сообщение записывается +в `update-error.log`. Файл `settings.json` и другие файлы, которых нет в архиве, сохраняются. Для работы проверки релизы репозитория должны быть доступны без авторизации. @@ -71,6 +73,11 @@ - Windows x86_64: `plate_config_app-windows-x86_64.zip`; - Linux x86_64: `plate_config_app-linux-x86_64.zip`. +Для перехода с уже установленной v2.0.1 на v2.0.2 обновите приложение вручную: +закройте его, скачайте ZIP-пакет своей платформы и распакуйте содержимое в +каталог приложения с заменой прежних файлов. `settings.json` удалять не нужно. +Начиная с v2.0.2 последующие обновления можно устанавливать кнопкой в приложении. + --- ## Интерфейс приложения diff --git a/app/__init__.py b/app/__init__.py index 29f49db..e32fa06 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,3 +1,3 @@ """Plate Config App package.""" -__version__ = "2.0.1" +__version__ = "2.0.2" diff --git a/app/entry.py b/app/entry.py index c0d2714..e2866a2 100644 --- a/app/entry.py +++ b/app/entry.py @@ -2,11 +2,13 @@ from __future__ import annotations import sys +from PySide6.QtCore import QTimer from PySide6.QtGui import QFont from PySide6.QtWidgets import QApplication from .main_window import MainWindow from .services import settings_service +from .services.update_service import signal_update_ready from .ui.icons import ICON_COLOR_PRIMARY, icon from .ui.theme import build_stylesheet, get_palette, create_qpalette @@ -27,4 +29,5 @@ def main() -> None: if not app_icon.isNull(): window.setWindowIcon(app_icon) window.show() + QTimer.singleShot(0, signal_update_ready) sys.exit(app.exec()) diff --git a/app/services/update_service.py b/app/services/update_service.py index a4b8e01..f1c5944 100644 --- a/app/services/update_service.py +++ b/app/services/update_service.py @@ -31,6 +31,7 @@ DOWNLOAD_CHUNK_SIZE = 1024 * 1024 MAX_DOWNLOAD_BYTES = 300 * 1024 * 1024 MAX_UNPACKED_BYTES = 600 * 1024 * 1024 MAX_ARCHIVE_MEMBERS = 10_000 +UPDATE_READY_ENV = "PLATE_CONFIG_UPDATE_READY_FILE" VERSION_PATTERN = re.compile( r"[vV]?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" ) @@ -353,18 +354,29 @@ def current_executable() -> Path: return Path(sys.executable).resolve() +def signal_update_ready() -> None: + ready_file = os.environ.pop(UPDATE_READY_ENV, None) + if ready_file: + try: + Path(ready_file).write_text(str(os.getpid()), encoding="ascii") + except OSError: + pass + + def _write_windows_updater(script_path: Path) -> None: script = r'''param( [int]$ApplicationProcessId, [string]$Staging, [string]$Target, - [string]$Backup + [string]$Backup, + [string]$ReadyFile ) $ErrorActionPreference = "Stop" $applicationDirectory = Split-Path -Parent $Target $errorLog = Join-Path $applicationDirectory "update-error.log" $installed = [System.Collections.Generic.List[string]]::new() $backedUp = [System.Collections.Generic.List[string]]::new() +$restarted = $null try { Wait-Process -Id $ApplicationProcessId -ErrorAction SilentlyContinue Remove-Item -LiteralPath $Backup -Recurse -Force -ErrorAction SilentlyContinue @@ -373,35 +385,81 @@ try { foreach ($entry in $entries) { $destination = Join-Path $applicationDirectory $entry.Name if (Test-Path -LiteralPath $destination) { - Move-Item -LiteralPath $destination -Destination $Backup -Force + $deadline = (Get-Date).AddSeconds(30) + while ($true) { + try { + Move-Item -LiteralPath $destination -Destination $Backup -Force -ErrorAction Stop + break + } catch { + if ((Get-Date) -ge $deadline) { throw } + Start-Sleep -Milliseconds 250 + } + } $backedUp.Add($entry.Name) } - Move-Item -LiteralPath $entry.FullName -Destination $destination -Force + Move-Item -LiteralPath $entry.FullName -Destination $destination -Force -ErrorAction Stop $installed.Add($entry.Name) } if (-not (Test-Path -LiteralPath $Target -PathType Leaf)) { throw "В пакете обновления отсутствует исполняемый файл" } - Start-Process -FilePath $Target -WorkingDirectory $applicationDirectory + Remove-Item -LiteralPath $ReadyFile -Force -ErrorAction SilentlyContinue + $previousReadyFile = $env:PLATE_CONFIG_UPDATE_READY_FILE + $previousResetEnvironment = $env:PYINSTALLER_RESET_ENVIRONMENT + try { + $env:PLATE_CONFIG_UPDATE_READY_FILE = $ReadyFile + $env:PYINSTALLER_RESET_ENVIRONMENT = "1" + $restarted = Start-Process -FilePath $Target -WorkingDirectory $applicationDirectory -PassThru + } finally { + $env:PLATE_CONFIG_UPDATE_READY_FILE = $previousReadyFile + $env:PYINSTALLER_RESET_ENVIRONMENT = $previousResetEnvironment + } + $deadline = (Get-Date).AddSeconds(60) + while (-not (Test-Path -LiteralPath $ReadyFile -PathType Leaf)) { + if ($restarted.HasExited) { throw "Новая версия завершилась до запуска интерфейса" } + if ((Get-Date) -ge $deadline) { throw "Новая версия не подтвердила запуск" } + Start-Sleep -Milliseconds 250 + } + if ($restarted.HasExited) { throw "Новая версия завершилась сразу после запуска" } + Remove-Item -LiteralPath $ReadyFile -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $Backup -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $Staging -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $MyInvocation.MyCommand.Path -Force } catch { + $failure = $_ + if ($restarted -and -not $restarted.HasExited) { + & taskkill.exe /PID $restarted.Id /T /F *> $null + Start-Sleep -Milliseconds 500 + } + $rollbackErrors = [System.Collections.Generic.List[string]]::new() foreach ($name in $installed) { $destination = Join-Path $applicationDirectory $name - Remove-Item -LiteralPath $destination -Recurse -Force -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $destination) { + try { + Remove-Item -LiteralPath $destination -Recurse -Force -ErrorAction Stop + } catch { + $rollbackErrors.Add($_.ToString()) + } + } } foreach ($name in $backedUp) { $backupItem = Join-Path $Backup $name $destination = Join-Path $applicationDirectory $name - Remove-Item -LiteralPath $destination -Recurse -Force -ErrorAction SilentlyContinue if (Test-Path -LiteralPath $backupItem) { - Move-Item -LiteralPath $backupItem -Destination $destination -Force + try { + Move-Item -LiteralPath $backupItem -Destination $destination -Force -ErrorAction Stop + } catch { + $rollbackErrors.Add($_.ToString()) + } } } + Remove-Item -LiteralPath $ReadyFile -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $Staging -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -LiteralPath $Backup -Recurse -Force -ErrorAction SilentlyContinue - $_ | Out-File -LiteralPath $errorLog -Encoding utf8 + if ($rollbackErrors.Count -eq 0) { + Remove-Item -LiteralPath $Backup -Recurse -Force -ErrorAction SilentlyContinue + } + @($failure.ToString()) + @($rollbackErrors) | Out-File -LiteralPath $errorLog -Encoding utf8 + Remove-Item -LiteralPath $MyInvocation.MyCommand.Path -Force -ErrorAction SilentlyContinue exit 1 } ''' @@ -415,28 +473,40 @@ application_pid="$1" staging_path="$2" target_path="$3" backup_path="$4" +ready_path="$5" application_dir="$(dirname -- "$target_path")" error_log="$application_dir/update-error.log" installed_file="$backup_path/.installed" backed_up_file="$backup_path/.backed_up" +new_pid="" rollback() { exit_code="$?" trap - EXIT HUP INT TERM + set +e + if [ -n "$new_pid" ]; then + kill "$new_pid" 2>/dev/null + wait "$new_pid" 2>/dev/null + fi + rollback_failed=0 if [ -f "$installed_file" ]; then while IFS= read -r name; do - rm -rf -- "$application_dir/$name" + rm -rf -- "$application_dir/$name" || rollback_failed=1 done < "$installed_file" fi if [ -f "$backed_up_file" ]; then while IFS= read -r name; do - rm -rf -- "$application_dir/$name" if [ -e "$backup_path/$name" ]; then - mv -f -- "$backup_path/$name" "$application_dir/$name" + mv -f -- "$backup_path/$name" "$application_dir/$name" || rollback_failed=1 fi done < "$backed_up_file" fi - rm -rf -- "$staging_path" "$backup_path" + rm -f -- "$ready_path" + rm -rf -- "$staging_path" + if [ "$rollback_failed" -eq 0 ]; then + rm -rf -- "$backup_path" + fi printf '%s\n' "Не удалось заменить приложение" >"$error_log" + rm -f -- "$0" if [ "$exit_code" -eq 0 ]; then exit_code=1 fi @@ -462,8 +532,25 @@ for source_path in "$staging_path"/*; do done test -f "$target_path" chmod 755 -- "$target_path" -"$target_path" >/dev/null 2>&1 & +rm -f -- "$ready_path" +PLATE_CONFIG_UPDATE_READY_FILE="$ready_path" PYINSTALLER_RESET_ENVIRONMENT=1 "$target_path" >/dev/null 2>&1 & +new_pid=$! +attempts=0 +while [ ! -f "$ready_path" ]; do + if ! kill -0 "$new_pid" 2>/dev/null; then + exit 1 + fi + attempts=$((attempts + 1)) + if [ "$attempts" -ge 240 ]; then + exit 1 + fi + sleep 0.25 +done +if ! kill -0 "$new_pid" 2>/dev/null; then + exit 1 +fi trap - EXIT HUP INT TERM +rm -f -- "$ready_path" rm -rf -- "$backup_path" "$staging_path" rm -f -- "$0" ''' @@ -494,6 +581,7 @@ def launch_update( staging_path = target.parent / f".plate_config_update-{updater_id}" backup_path = target.parent / f".plate_config_backup-{updater_id}" + ready_path = target.parent / f".plate_config_ready-{updater_id}" script_path: Path | None = None try: _extract_update_archive(archive_path, staging_path, target.name) @@ -522,6 +610,7 @@ def launch_update( str(staging_path), str(target), str(backup_path), + str(ready_path), ], close_fds=True, creationflags=creation_flags, @@ -543,6 +632,7 @@ def launch_update( str(staging_path), str(target), str(backup_path), + str(ready_path), ], cwd=str(target.parent), close_fds=True, diff --git a/app/widgets/start_tab.py b/app/widgets/start_tab.py index 24ac97b..d38cf36 100644 --- a/app/widgets/start_tab.py +++ b/app/widgets/start_tab.py @@ -5,7 +5,6 @@ from pathlib import Path from PySide6.QtCore import QThread, QTimer, Qt, Signal from PySide6.QtGui import QColor from PySide6.QtWidgets import ( - QApplication, QFrame, QGraphicsDropShadowEffect, QHBoxLayout, @@ -230,9 +229,7 @@ class StartTab(QWidget): path.unlink(missing_ok=True) self._on_update_failed(str(exc)) return - app = QApplication.instance() - if app is not None: - app.quit() + self.window().close() def _on_update_failed(self, detail: str) -> None: self._update_btn.setText("Скачать обновление") diff --git a/build_exe.ps1 b/build_exe.ps1 index 4ba840d..eec18fe 100644 --- a/build_exe.ps1 +++ b/build_exe.ps1 @@ -54,10 +54,28 @@ if ($InstallBuildDeps) { # ── Сборка ───────────────────────────────────────────────────────── Write-Host "" Write-Host "=== Сборка EXE ===" -& $venvPython -m PyInstaller --noconfirm --clean plate_config_app.spec +$pythonHome = (& $venvPython -c "import sys; print(sys.base_prefix)").Trim() +if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $pythonHome -PathType Container)) { + throw "Не удалось определить каталог Python для сборки." +} +$buildPath = @( + (Split-Path -Parent $venvPython), + $pythonHome, + (Join-Path $pythonHome "DLLs"), + (Join-Path $env:SystemRoot "System32"), + $env:SystemRoot +) -join ";" +$originalPath = $env:PATH +try { + $env:PATH = $buildPath + & $venvPython -m PyInstaller --noconfirm --clean plate_config_app.spec + $buildExitCode = $LASTEXITCODE +} finally { + $env:PATH = $originalPath +} -if ($LASTEXITCODE -ne 0) { - throw "PyInstaller завершился с ошибкой (код $LASTEXITCODE)." +if ($buildExitCode -ne 0) { + throw "PyInstaller завершился с ошибкой (код $buildExitCode)." } $builtExe = Join-Path $projectRoot "dist\plate_config_app.exe" diff --git a/tests/test_start_tab.py b/tests/test_start_tab.py index d1779ce..223d417 100644 --- a/tests/test_start_tab.py +++ b/tests/test_start_tab.py @@ -4,7 +4,7 @@ import os import tempfile import unittest from pathlib import Path -from unittest.mock import Mock, patch +from unittest.mock import patch os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") @@ -98,20 +98,18 @@ class StartTabUpdateTests(unittest.TestCase): warning.assert_called_once() tab.shutdown() - def test_successful_download_launches_update_and_quits(self) -> None: + def test_successful_download_launches_update_and_closes_window(self) -> None: tab = StartTab(lambda: None) downloaded = Path("downloaded-update") - application = Mock() with ( patch("app.widgets.start_tab.launch_update") as launcher, - patch("app.widgets.start_tab.QApplication") as application_class, + patch.object(tab, "close") as close_window, ): - application_class.instance.return_value = application tab._on_update_downloaded(downloaded) launcher.assert_called_once_with(downloaded) - application.quit.assert_called_once_with() + close_window.assert_called_once_with() self.assertEqual("Перезапуск…", tab._update_btn.text()) tab.shutdown() diff --git a/tests/test_update_service.py b/tests/test_update_service.py index 5303718..7d87d7f 100644 --- a/tests/test_update_service.py +++ b/tests/test_update_service.py @@ -29,6 +29,7 @@ from app.services.update_service import ( parse_version, platform_asset_name, platform_executable_name, + signal_update_ready, ) @@ -199,6 +200,17 @@ class UpdateServiceTests(unittest.TestCase): with self.assertRaisesRegex(UpdateError, "Неизвестное"): archive_executable_name("update.zip") + def test_signals_restart_only_when_ready_file_is_requested(self) -> None: + with tempfile.TemporaryDirectory() as directory: + marker = Path(directory) / "ready" + with patch.dict(os.environ, {update_service.UPDATE_READY_ENV: str(marker)}): + signal_update_ready() + self.assertEqual(str(os.getpid()), marker.read_text(encoding="ascii")) + self.assertNotIn(update_service.UPDATE_READY_ENV, os.environ) + marker.unlink() + signal_update_ready() + self.assertFalse(marker.exists()) + def test_downloads_and_validates_windows_zip(self) -> None: info = self._update_info() data = make_update_archive( @@ -412,6 +424,10 @@ class UpdateServiceTests(unittest.TestCase): script = scripts[0].read_text(encoding="utf-8-sig") self.assertIn("$Staging", script) self.assertIn("$backedUp", script) + self.assertIn("AddSeconds(30)", script) + self.assertIn("$restarted.HasExited", script) + self.assertIn("$ReadyFile", script) + self.assertIn('$env:PYINSTALLER_RESET_ENVIRONMENT = "1"', script) popen.assert_called_once() @unittest.skipUnless(sys.platform.startswith("linux"), "Тест предназначен для Linux") @@ -445,6 +461,8 @@ class UpdateServiceTests(unittest.TestCase): script = scripts[0].read_text(encoding="utf-8") self.assertIn("staging_path", script) self.assertIn("rollback", script) + self.assertIn("PLATE_CONFIG_UPDATE_READY_FILE", script) + self.assertIn("PYINSTALLER_RESET_ENVIRONMENT=1", script) popen.assert_called_once() def test_extracts_external_archive_next_to_executable(self) -> None: @@ -561,7 +579,7 @@ class ReplacementIntegrationTests(unittest.TestCase): return hashlib.sha256(path.read_bytes()).hexdigest() @staticmethod - def _wait_for_replacement( + def _wait_for_rollback( target: Path, expected_digest: str, replaced_resource: Path, @@ -571,17 +589,19 @@ class ReplacementIntegrationTests(unittest.TestCase): if ( target.is_file() and ReplacementIntegrationTests._digest(target) == expected_digest - and replaced_resource.read_text(encoding="utf-8") == "new" + and replaced_resource.is_file() + and replaced_resource.read_text(encoding="utf-8") == "old" and not list(target.parent.glob(".plate_config_updater-*")) and not list(target.parent.glob(".plate_config_update-*")) and not list(target.parent.glob(".plate_config_backup-*")) + and (target.parent / "update-error.log").is_file() ): return time.sleep(0.1) - raise AssertionError("Внешний helper не заменил пакет за 20 секунд") + raise AssertionError("Внешний helper не откатил пакет за 20 секунд") @unittest.skipUnless(sys.platform == "win32", "Тест предназначен для Windows") - def test_windows_helper_replaces_full_package(self) -> None: + def test_windows_helper_rolls_back_when_new_app_exits_early(self) -> None: system_root = Path(os.environ.get("SystemRoot", r"C:\Windows")) with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -597,20 +617,20 @@ class ReplacementIntegrationTests(unittest.TestCase): extra_files={"commands/version.txt": b"new"}, ) ) - expected_digest = hashlib.sha256(new_executable).hexdigest() + expected_digest = self._digest(target) processes = self._launch_and_capture(archive, target, "Windows") - self._wait_for_replacement( + self._wait_for_rollback( target, expected_digest, root / "commands/version.txt", ) processes[0].wait(timeout=5) - self._assert_package_replaced(root) + self._assert_package_preserved(root) self._wait_for_windows_unlock(target) @unittest.skipUnless(sys.platform.startswith("linux"), "Тест предназначен для Linux") - def test_linux_helper_replaces_full_package(self) -> None: + def test_linux_helper_rolls_back_when_new_app_exits_early(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) target = root / "plate_config_app-linux-x86_64" @@ -625,16 +645,16 @@ class ReplacementIntegrationTests(unittest.TestCase): extra_files={"commands/version.txt": b"new"}, ) ) - expected_digest = hashlib.sha256(new_executable).hexdigest() + expected_digest = self._digest(target) processes = self._launch_and_capture(archive, target, "Linux") - self._wait_for_replacement( + self._wait_for_rollback( target, expected_digest, root / "commands/version.txt", ) processes[0].wait(timeout=5) - self._assert_package_replaced(root) + self._assert_package_preserved(root) self.assertTrue(target.stat().st_mode & stat.S_IXUSR) @staticmethod @@ -646,12 +666,12 @@ class ReplacementIntegrationTests(unittest.TestCase): (root / "settings.json").write_text("preserve", encoding="utf-8") @staticmethod - def _assert_package_replaced(root: Path) -> None: + def _assert_package_preserved(root: Path) -> None: self_settings = (root / "settings.json").read_text(encoding="utf-8") if self_settings != "preserve": raise AssertionError("settings.json не сохранён") - if (root / "commands/obsolete.txt").exists(): - raise AssertionError("Старое содержимое каталога не удалено") + if not (root / "commands/obsolete.txt").exists(): + raise AssertionError("Старое содержимое каталога не восстановлено") @staticmethod def _launch_and_capture(