feat(update): add startup health rollback fuse
This commit is contained in:
+29
-2
@@ -4,9 +4,10 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import replace
|
||||
|
||||
from .. import appconfig, chrome, diagnostics, update_check
|
||||
from ..version import APP_NAME, display_name
|
||||
from .. import appconfig, chrome, diagnostics, update_check, update_health
|
||||
from ..version import APP_NAME, APP_VERSION, display_name
|
||||
from . import widgets as _widgets
|
||||
from .widgets import *
|
||||
|
||||
@@ -91,6 +92,18 @@ def _run_startup_update_gate(*, checker=None, dialog_factory=None) -> bool:
|
||||
if result.error:
|
||||
_write_update_check_diagnostic("启动版本检查失败,已允许继续使用", result=result)
|
||||
if result.forced:
|
||||
failed = update_health.get_failed_release(
|
||||
appconfig.app_base_dir(),
|
||||
result.latest_version,
|
||||
result.sha256,
|
||||
)
|
||||
if failed:
|
||||
result = replace(
|
||||
result,
|
||||
automatic_update_error=(
|
||||
"该版本自动升级曾失败,已停止重复安装。请等待管理员发布修复版本,或手动安装新版。"
|
||||
),
|
||||
)
|
||||
return _show_forced_update_dialog(result, dialog_factory=dialog_factory)
|
||||
return True
|
||||
|
||||
@@ -101,15 +114,24 @@ def main() -> int:
|
||||
return 1
|
||||
_ensure_offscreen_for_headless_tests()
|
||||
app = QApplication.instance() or QApplication(sys.argv)
|
||||
health_context = update_health.context_from_argv(
|
||||
sys.argv,
|
||||
appconfig.app_base_dir(),
|
||||
APP_VERSION,
|
||||
)
|
||||
update_health.write_health(health_context, "process_started")
|
||||
try:
|
||||
appconfig.prepare_data_dir()
|
||||
except appconfig.DataMigrationConflictError as exc:
|
||||
update_health.write_health(health_context, "environment_blocked", str(exc))
|
||||
QMessageBox.critical(None, "数据迁移冲突", str(exc))
|
||||
return 1
|
||||
except appconfig.DataDirectoryWriteError as exc:
|
||||
update_health.write_health(health_context, "environment_blocked", str(exc))
|
||||
QMessageBox.critical(None, "数据目录不可写", str(exc))
|
||||
return 1
|
||||
except appconfig.ConfigError as exc:
|
||||
update_health.write_health(health_context, "environment_blocked", str(exc))
|
||||
QMessageBox.critical(None, "启动配置错误", str(exc))
|
||||
return 1
|
||||
if not _run_startup_update_gate():
|
||||
@@ -117,6 +139,7 @@ def main() -> int:
|
||||
try:
|
||||
startup = chrome.ensure_configured_chrome_path()
|
||||
except (OSError, appconfig.ConfigError) as exc:
|
||||
update_health.write_health(health_context, "environment_blocked", str(exc))
|
||||
QMessageBox.critical(None, "启动配置错误", str(exc))
|
||||
return 1
|
||||
window = MainWindow(
|
||||
@@ -124,4 +147,8 @@ def main() -> int:
|
||||
startup_status=startup["message"],
|
||||
)
|
||||
window.show()
|
||||
QTimer.singleShot(
|
||||
0,
|
||||
lambda: update_health.write_health(health_context, "main_window_ready"),
|
||||
)
|
||||
return app.exec()
|
||||
|
||||
@@ -136,6 +136,11 @@ class ForcedUpdateDialog(QDialog):
|
||||
layout.addLayout(buttons)
|
||||
|
||||
def _validate_available_update(self):
|
||||
if self.result.automatic_update_error:
|
||||
self.stage_label.setText(self.result.automatic_update_error)
|
||||
self.action_button.setText("等待修复版本")
|
||||
self.action_button.setEnabled(False)
|
||||
return
|
||||
try:
|
||||
update_installer.metadata_from_update_info(self.result)
|
||||
except update_installer.UpdateInstallError as exc:
|
||||
@@ -178,6 +183,7 @@ class ForcedUpdateDialog(QDialog):
|
||||
staged.staging_dir,
|
||||
staged.version,
|
||||
os.getpid(),
|
||||
staged.sha256,
|
||||
)
|
||||
updater_path = self.install_root / "cmshopee-updater.exe"
|
||||
self.updater_launcher(updater_path, plan_path)
|
||||
|
||||
@@ -51,6 +51,7 @@ class UpdateCheckResult:
|
||||
min_updater_protocol: int = 0
|
||||
signature_algorithm: str = ""
|
||||
manifest_signature: str = ""
|
||||
automatic_update_error: str = ""
|
||||
message: str = ""
|
||||
error: str = ""
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""自动升级后的启动健康标记与失败版本熔断记录。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
HEALTH_STATUSES = {"process_started", "main_window_ready", "environment_blocked"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UpdateHealthContext:
|
||||
install_root: Path
|
||||
transaction_id: str
|
||||
target_version: str
|
||||
|
||||
@property
|
||||
def transaction_dir(self):
|
||||
return self.install_root / ".cmshopee-update" / "transactions" / self.transaction_id
|
||||
|
||||
@property
|
||||
def health_path(self):
|
||||
return self.transaction_dir / "health.json"
|
||||
|
||||
|
||||
def _atomic_json(path, payload):
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
os.replace(str(temporary), str(path))
|
||||
|
||||
|
||||
def context_from_argv(argv, install_root, app_version):
|
||||
values = list(argv or [])
|
||||
try:
|
||||
transaction_index = values.index("--update-transaction")
|
||||
version_index = values.index("--update-target-version")
|
||||
transaction_id = values[transaction_index + 1]
|
||||
target_version = values[version_index + 1]
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
if not re.fullmatch(r"[A-Za-z0-9-]{8,64}", transaction_id):
|
||||
return None
|
||||
if target_version != app_version:
|
||||
return None
|
||||
return UpdateHealthContext(Path(install_root).resolve(), transaction_id, target_version)
|
||||
|
||||
|
||||
def write_health(context, status, message=""):
|
||||
if context is None:
|
||||
return None
|
||||
if status not in HEALTH_STATUSES:
|
||||
raise ValueError("未知升级健康状态")
|
||||
payload = {
|
||||
"schema_version": 1,
|
||||
"transaction_id": context.transaction_id,
|
||||
"target_version": context.target_version,
|
||||
"status": status,
|
||||
"message": str(message or "")[:300],
|
||||
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
||||
}
|
||||
_atomic_json(context.health_path, payload)
|
||||
return context.health_path
|
||||
|
||||
|
||||
def failed_versions_path(install_root):
|
||||
return Path(install_root).resolve() / ".cmshopee-update" / "failed-versions.json"
|
||||
|
||||
|
||||
def load_failed_versions(install_root):
|
||||
path = failed_versions_path(install_root)
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {"schema_version": 1, "releases": {}}
|
||||
releases = payload.get("releases")
|
||||
if not isinstance(releases, dict):
|
||||
releases = {}
|
||||
return {"schema_version": 1, "releases": releases}
|
||||
|
||||
|
||||
def failed_release_key(version, sha256):
|
||||
return "%s:%s" % (str(version or "").strip(), str(sha256 or "").strip().lower())
|
||||
|
||||
|
||||
def record_failed_release(install_root, version, sha256, reason):
|
||||
payload = load_failed_versions(install_root)
|
||||
key = failed_release_key(version, sha256)
|
||||
previous = payload["releases"].get(key) or {}
|
||||
payload["releases"][key] = {
|
||||
"version": str(version),
|
||||
"sha256": str(sha256).lower(),
|
||||
"reason": str(reason or "自动升级后新版未能正常启动")[:300],
|
||||
"attempts": int(previous.get("attempts") or 0) + 1,
|
||||
"failed_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
||||
}
|
||||
_atomic_json(failed_versions_path(install_root), payload)
|
||||
return payload["releases"][key]
|
||||
|
||||
|
||||
def get_failed_release(install_root, version, sha256):
|
||||
payload = load_failed_versions(install_root)
|
||||
return payload["releases"].get(failed_release_key(version, sha256))
|
||||
@@ -299,7 +299,13 @@ def validate_staging(staging_dir, metadata):
|
||||
key = relative.as_posix().casefold()
|
||||
if key in declared:
|
||||
raise UpdateInstallError("新版安装包清单包含重复路径")
|
||||
declared[key] = (relative, int(item.get("size_bytes") or -1), str(item.get("sha256") or ""))
|
||||
try:
|
||||
size_bytes = int(item["size_bytes"])
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise UpdateInstallError("新版安装包清单文件大小无效") from exc
|
||||
if size_bytes < 0:
|
||||
raise UpdateInstallError("新版安装包清单文件大小无效")
|
||||
declared[key] = (relative, size_bytes, str(item.get("sha256") or ""))
|
||||
|
||||
actual = {}
|
||||
for path in staging_dir.rglob("*"):
|
||||
|
||||
+105
-5
@@ -15,6 +15,8 @@ import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
from app import update_health
|
||||
|
||||
|
||||
PACKAGE_FORMAT = "cmshopee-portable-v1"
|
||||
UPDATER_PROTOCOL = 1
|
||||
@@ -41,9 +43,10 @@ class UpdatePlan:
|
||||
target_version: str
|
||||
transaction_id: str
|
||||
log_path: Path
|
||||
package_sha256: str = ""
|
||||
|
||||
|
||||
def create_plan(install_root, staging_root, target_version, parent_pid):
|
||||
def create_plan(install_root, staging_root, target_version, parent_pid, package_sha256=""):
|
||||
install_root = Path(install_root).resolve()
|
||||
transaction_id = uuid.uuid4().hex
|
||||
update_root = install_root / ".cmshopee-update"
|
||||
@@ -54,6 +57,7 @@ def create_plan(install_root, staging_root, target_version, parent_pid):
|
||||
target_version=str(target_version),
|
||||
transaction_id=transaction_id,
|
||||
log_path=(update_root / "logs" / (transaction_id + ".log")).resolve(),
|
||||
package_sha256=str(package_sha256 or "").lower(),
|
||||
)
|
||||
validate_plan(plan)
|
||||
plan_path = update_root / "plans" / (transaction_id + ".json")
|
||||
@@ -66,6 +70,7 @@ def create_plan(install_root, staging_root, target_version, parent_pid):
|
||||
"target_version": plan.target_version,
|
||||
"transaction_id": plan.transaction_id,
|
||||
"log_path": str(plan.log_path),
|
||||
"package_sha256": plan.package_sha256,
|
||||
},
|
||||
)
|
||||
return plan_path
|
||||
@@ -104,6 +109,7 @@ def load_plan(plan_path):
|
||||
target_version=str(payload["target_version"]),
|
||||
transaction_id=str(payload["transaction_id"]),
|
||||
log_path=Path(payload["log_path"]).resolve(),
|
||||
package_sha256=str(payload.get("package_sha256") or "").lower(),
|
||||
)
|
||||
except (OSError, ValueError, KeyError, TypeError) as exc:
|
||||
raise UpdaterError("更新计划无效") from exc
|
||||
@@ -124,6 +130,8 @@ def validate_plan(plan, plan_path=None):
|
||||
raise UpdaterError("目标版本号格式不正确")
|
||||
if not re.fullmatch(r"[A-Za-z0-9-]{8,64}", plan.transaction_id):
|
||||
raise UpdaterError("更新事务编号无效")
|
||||
if plan.package_sha256 and not re.fullmatch(r"[0-9a-f]{64}", plan.package_sha256):
|
||||
raise UpdaterError("更新安装包校验值无效")
|
||||
if not _is_child(plan.log_path, update_root / "logs"):
|
||||
raise UpdaterError("更新日志路径越界")
|
||||
if plan_path is not None and not _is_child(plan_path, update_root):
|
||||
@@ -175,7 +183,13 @@ def validate_staging(plan):
|
||||
key = relative.as_posix().casefold()
|
||||
if key in declared:
|
||||
raise UpdaterError("新版程序清单包含重复文件")
|
||||
declared[key] = (relative, int(item.get("size_bytes") or -1), str(item.get("sha256") or ""))
|
||||
try:
|
||||
size_bytes = int(item["size_bytes"])
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise UpdaterError("新版程序清单文件大小无效") from exc
|
||||
if size_bytes < 0:
|
||||
raise UpdaterError("新版程序清单文件大小无效")
|
||||
declared[key] = (relative, size_bytes, str(item.get("sha256") or ""))
|
||||
actual = {}
|
||||
for path in plan.staging_root.rglob("*"):
|
||||
if path.is_symlink():
|
||||
@@ -248,7 +262,75 @@ def _default_launch(executable, args):
|
||||
return subprocess.Popen([str(executable), *args], close_fds=True, creationflags=creationflags)
|
||||
|
||||
|
||||
def apply_update(plan, *, wait_parent=wait_for_parent_exit, move=os.replace, launcher=_default_launch):
|
||||
def wait_for_health(plan, process, timeout=90, poll_seconds=0.25):
|
||||
deadline = time.monotonic() + timeout
|
||||
health_path = (
|
||||
plan.install_root
|
||||
/ ".cmshopee-update"
|
||||
/ "transactions"
|
||||
/ plan.transaction_id
|
||||
/ "health.json"
|
||||
)
|
||||
last_status = ""
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
payload = json.loads(health_path.read_text(encoding="utf-8"))
|
||||
if (
|
||||
payload.get("transaction_id") == plan.transaction_id
|
||||
and payload.get("target_version") == plan.target_version
|
||||
):
|
||||
last_status = str(payload.get("status") or "")
|
||||
if last_status in {"main_window_ready", "environment_blocked"}:
|
||||
return last_status
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
if process is not None and hasattr(process, "poll") and process.poll() is not None:
|
||||
raise UpdaterError("新版程序在主窗口就绪前退出")
|
||||
time.sleep(poll_seconds)
|
||||
if last_status == "process_started":
|
||||
raise UpdaterError("新版程序启动后未能显示主窗口")
|
||||
raise UpdaterError("等待新版程序启动确认超时")
|
||||
|
||||
|
||||
def _cleanup_after_health(plan, backup_root, remove_backup):
|
||||
update_root = plan.install_root / ".cmshopee-update"
|
||||
for path in (
|
||||
update_root / "pending.json",
|
||||
update_root / "downloads" / (plan.target_version + ".zip"),
|
||||
update_root / "downloads" / (plan.target_version + ".zip.part"),
|
||||
update_root / "plans" / (plan.transaction_id + ".json"),
|
||||
):
|
||||
try:
|
||||
path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
if plan.staging_root.exists():
|
||||
shutil.rmtree(str(plan.staging_root), ignore_errors=True)
|
||||
if remove_backup and backup_root.exists():
|
||||
shutil.rmtree(str(backup_root), ignore_errors=True)
|
||||
|
||||
|
||||
def prune_backups(update_root, keep=2):
|
||||
backup_parent = Path(update_root) / "backup"
|
||||
if not backup_parent.is_dir():
|
||||
return
|
||||
backups = sorted(
|
||||
(path for path in backup_parent.iterdir() if path.is_dir()),
|
||||
key=lambda path: path.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
for path in backups[max(0, int(keep)) :]:
|
||||
shutil.rmtree(str(path), ignore_errors=True)
|
||||
|
||||
|
||||
def apply_update(
|
||||
plan,
|
||||
*,
|
||||
wait_parent=wait_for_parent_exit,
|
||||
move=os.replace,
|
||||
launcher=_default_launch,
|
||||
health_waiter=wait_for_health,
|
||||
):
|
||||
validate_plan(plan)
|
||||
roots = validate_staging(plan)
|
||||
update_root = plan.install_root / ".cmshopee-update"
|
||||
@@ -256,7 +338,7 @@ def apply_update(plan, *, wait_parent=wait_for_parent_exit, move=os.replace, lau
|
||||
old_version = old_version_path.read_text(encoding="utf-8-sig").strip() if old_version_path.exists() else "unknown"
|
||||
backup_root = update_root / "backup" / (old_version + "-" + plan.transaction_id)
|
||||
failed_root = update_root / "failed" / plan.transaction_id
|
||||
journal_path = update_root / "transactions" / (plan.transaction_id + ".json")
|
||||
journal_path = update_root / "transactions" / plan.transaction_id / "journal.json"
|
||||
operations = []
|
||||
|
||||
def journal(status, error=""):
|
||||
@@ -292,14 +374,32 @@ def apply_update(plan, *, wait_parent=wait_for_parent_exit, move=os.replace, lau
|
||||
move(str(source), str(destination))
|
||||
operations.append({"kind": "new_to_install", "root": root})
|
||||
journal("moving_new")
|
||||
launcher(
|
||||
process = launcher(
|
||||
plan.install_root / "cmshopee.exe",
|
||||
["--update-transaction", plan.transaction_id, "--update-target-version", plan.target_version],
|
||||
)
|
||||
journal("launched")
|
||||
_append_log(plan.log_path, "新版程序已替换并启动:%s" % plan.target_version)
|
||||
health_status = health_waiter(plan, process)
|
||||
if health_status == "environment_blocked":
|
||||
journal("environment_blocked")
|
||||
_append_log(plan.log_path, "新版程序已启动,但本地环境需要用户处理")
|
||||
_cleanup_after_health(plan, backup_root, remove_backup=False)
|
||||
prune_backups(update_root)
|
||||
return backup_root
|
||||
journal("healthy")
|
||||
_append_log(plan.log_path, "新版主窗口已就绪:%s" % plan.target_version)
|
||||
_cleanup_after_health(plan, backup_root, remove_backup=True)
|
||||
prune_backups(update_root)
|
||||
return backup_root
|
||||
except Exception as exc:
|
||||
if any(operation["kind"] == "new_to_install" for operation in operations):
|
||||
update_health.record_failed_release(
|
||||
plan.install_root,
|
||||
plan.target_version,
|
||||
plan.package_sha256,
|
||||
str(exc),
|
||||
)
|
||||
journal("rolling_back", str(exc))
|
||||
failed_root.mkdir(parents=True, exist_ok=True)
|
||||
rollback_errors = []
|
||||
|
||||
Reference in New Issue
Block a user