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
+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 = []