feat(update): add startup health rollback fuse

This commit is contained in:
chengma
2026-07-13 12:23:46 +08:00
parent 04124060fc
commit f6db4f0d62
16 changed files with 481 additions and 14 deletions
+29 -2
View File
@@ -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()
+6
View File
@@ -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)
+1
View File
@@ -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 = ""
+109
View File
@@ -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))
+7 -1
View File
@@ -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
View File
@@ -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 = []