From f6db4f0d629e887cd700cdc0ceadafe2d942994e Mon Sep 17 00:00:00 2001 From: chengma Date: Mon, 13 Jul 2026 12:23:46 +0800 Subject: [PATCH] feat(update): add startup health rollback fuse --- app/gui/__init__.py | 31 +++++++++- app/gui/update_dialog.py | 6 ++ app/update_check.py | 1 + app/update_health.py | 109 ++++++++++++++++++++++++++++++++ app/update_installer.py | 8 ++- app/updater_entry.py | 110 +++++++++++++++++++++++++++++++-- docs/04-architecture.md | 2 + docs/packaging.md | 4 +- docs/tasks/T-619.md | 8 ++- docs/troubleshooting.md | 6 ++ docs/update-check.md | 7 +++ scripts/build_exe.ps1 | 2 +- tests/test_gui.py | 34 ++++++++++ tests/test_update_health.py | 90 +++++++++++++++++++++++++++ tests/test_update_installer.py | 1 + tests/test_updater_entry.py | 76 ++++++++++++++++++++++- 16 files changed, 481 insertions(+), 14 deletions(-) create mode 100644 app/update_health.py create mode 100644 tests/test_update_health.py diff --git a/app/gui/__init__.py b/app/gui/__init__.py index e1d6886..0c4bf01 100644 --- a/app/gui/__init__.py +++ b/app/gui/__init__.py @@ -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() diff --git a/app/gui/update_dialog.py b/app/gui/update_dialog.py index f81c55c..253ff9a 100644 --- a/app/gui/update_dialog.py +++ b/app/gui/update_dialog.py @@ -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) diff --git a/app/update_check.py b/app/update_check.py index 1e6bb3b..da9ad13 100644 --- a/app/update_check.py +++ b/app/update_check.py @@ -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 = "" diff --git a/app/update_health.py b/app/update_health.py new file mode 100644 index 0000000..b40a177 --- /dev/null +++ b/app/update_health.py @@ -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)) diff --git a/app/update_installer.py b/app/update_installer.py index 05bcc73..7353091 100644 --- a/app/update_installer.py +++ b/app/update_installer.py @@ -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("*"): diff --git a/app/updater_entry.py b/app/updater_entry.py index 2a6e1f0..9f9ddee 100644 --- a/app/updater_entry.py +++ b/app/updater_entry.py @@ -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 = [] diff --git a/docs/04-architecture.md b/docs/04-architecture.md index a8475e4..28fd57f 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -502,6 +502,7 @@ cmshopee/ │ ├── update_installer.py # 自动升级安全下载、解压、manifest校验与同盘暂存 │ ├── updater_entry.py # 独立更新器入口、事务根项目切换、journal与回滚 │ ├── gui/update_dialog.py # 强制升级模态进度、下载worker与重启编排 +│ ├── update_health.py # 新版启动健康标记与失败版本熔断 ├── main.py # GUI 启动入口:from app.gui import main ├── shopee待处理任务模板.xlsx # 标准空 Excel 模板,可提交;业务填写后的副本不提交 ├── data/ # 用户本地数据根(整体 gitignore;打包更新时保留) @@ -533,6 +534,7 @@ cmshopee/ - T-616 的下载暂存根固定为安装目录下 `.cmshopee-update/`,与 `data/` 完全隔离。远程zip必须经过HTTPS/受信任域名、声明大小、整包SHA-256、安全zip路径和包内manifest逐文件校验,才写 `pending.json`;此阶段不替换任何运行中程序文件。 - T-617 的独立 `cmshopee-updater.exe` 必须先复制到系统临时目录运行,并等待主程序退出。替换粒度是manifest允许的程序根项目,旧根先整体移动到同盘backup,新根再整体移入;事务锁防止并发更新,journal记录每次移动,任一步失败逆序恢复。`data/`、更新管理目录与未知安装根项目永不进入替换清单。 - T-618 在创建 `MainWindow` 前显示强制升级 `QDialog`;下载与校验只能在 `QObject + QThread` worker中执行,线程结束前保留引用,取消时等待part清理。独立更新器进程成功创建后才退出旧主程序;强制版本后续失败保持阻断,只有版本接口本身不可达/非法继续失败放行。 +- T-619 要求新版按事务写 `process_started`、`main_window_ready`、`environment_blocked` 健康标记。更新器只在主窗口就绪后认定升级成功并清理成功备份;无标记/早期退出/超时则事务回滚并按目标版本+包hash熔断。环境阻断保留新版,不把本地数据目录、配置或Chrome问题误判成坏发布包。 - CDP 交互事实变化同步第七节。 - 正式代码只放 `app/` 包;根目录只保留 `main.py`、配置/数据目录、文档和原型目录,不新增正式业务模块。 diff --git a/docs/packaging.md b/docs/packaging.md index d46e451..aa8d643 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -182,6 +182,8 @@ T-617 增加独立 `cmshopee-updater.exe`。构建脚本用 `cmshopee-updater.sp T-618 已把安全暂存和独立更新器接回启动门禁。强制升级窗口使用QThread执行下载、hash、解压和manifest校验,主线程持续显示中文阶段、进度和字节数;协作式取消会等待线程清理,避免线程仍运行时销毁。确认独立更新器进程创建成功后旧主程序退出;强制响应后的任何准备失败都保持阻断,只能重试或退出。版本接口完全不可达/非法仍按T-544策略失败放行。 +T-619 增加启动健康确认与失败熔断。新版带事务参数启动,在 `.cmshopee-update/transactions/<事务>/health.json` 原子写 `process_started`、`main_window_ready` 或 `environment_blocked`。更新器在主窗口就绪前保留旧根项目;早期退出/超时会回滚并按版本+zip hash写 `failed-versions.json`,防止同一坏包无限循环。主窗口就绪后清理pending、zip、staging和本次成功备份;环境阻断保留新版与备份。备份清理只处理 `.cmshopee-update/backup/` 且保留最近两份,不扫描 `data/`。 + ## 四、绝不打包的本地数据 发布包里不能包含以下本地数据、密钥、业务数据或登录态: @@ -240,7 +242,7 @@ cmshopee\ ## 六、用户后续更新方式 -第一版不做自动覆盖升级。给用户发新版本时,或 T-544 启动检查弹出“必须升级”时: +尚未安装自动升级引导版本的旧用户仍按以下方式人工覆盖;安装T-619及之后版本后,强制升级窗口可自动下载、替换和重启: 1. 让用户先关闭 cmshopee。 2. 建议用户备份当前整个程序文件夹。 diff --git a/docs/tasks/T-619.md b/docs/tasks/T-619.md index d374c8b..7eb2fba 100644 --- a/docs/tasks/T-619.md +++ b/docs/tasks/T-619.md @@ -3,7 +3,7 @@ id: T-619 title: 自动升级启动健康确认熔断与发布验收 phase: 8 deps: [T-618] -status: TODO +status: DONE created: 2026-07-13 --- @@ -48,4 +48,8 @@ created: 2026-07-13 ## 执行记录 -(完成后记录实现、验证命令与结果。) +- 2026-07-13:新增新版启动健康上下文与原子标记,更新器等待 `main_window_ready`/`environment_blocked`;无健康标记、早期退出或超时会回滚,并按目标版本+zip hash记录熔断。 +- 2026-07-13:成功后清理pending、zip、staging和本次成功备份;环境阻断保留新版与旧备份;备份清理只限更新管理目录且保留最近两份,`data/` 不参与扫描或移动。 +- 2026-07-13:真实release验证发现并修复“manifest中0字节文件被误判为-1字节”的阻断缺陷,下载暂存与独立更新器两层均增加0字节回归用例。 +- 2026-07-13:Windows 10 / Python 3.10完成正式build与隔离目录打包EXE端到端事务升级:更新器退出码0、健康状态 `main_window_ready`、journal为 `healthy`、目标版本0.1.4、旧依赖移除、data哨兵不变;zip大小/hash与元数据一致,manifest覆盖220个程序文件且包含更新器。 +- 2026-07-13:干净worktree验证通过:ruff、compileall、完整unittest(419项)和 `git diff --check`。Windows 11无Python环境的相同人工矩阵当前机器无法执行,仍是正式对外强制发布前的发布验收项,不能视为已有证据。 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 18fe559..7198ffd 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -319,3 +319,9 @@ cmshopee 的 cmhub 请求默认**绕过系统代理**(`ai.cmhub.use_system_pro - 若你的机器**必须走代理**才能上网,编辑 `data/config.json` 把 `ai.cmhub.use_system_proxy` 改为 `true`(注意:慢代理仍可能拖累图片下载)。 - 若下载仍慢且 curl 也慢,则是 cmhub **媒体服务器本身慢**(如 Django 直接服媒体、单线程),属服务端问题,需在 cmhub 侧用 nginx/对象存储服 `/generated/images/`。 - 调试期可临时把 ⑤「图片并发数」设 1 复测单张,排除并发因素。 +## 自动升级排障 + +- 强制升级窗口显示“该版本自动升级曾失败”:本机已经自动回滚过相同版本和zip hash,为避免循环不会再次自动安装。等待管理员发布修复包/更高版本,或退出后人工覆盖可信发布包;不要删除 `data/`。 +- 升级后提示数据迁移冲突、数据目录不可写或Chrome配置错误:这是本地环境阻断,新版不会自动回滚。按原提示修复目录或Chrome配置后重新启动。 +- 自动升级失败的本地记录位于安装目录 `.cmshopee-update/logs/`、`transactions/` 和 `failed-versions.json`。提供排障材料前先检查并脱敏;程序不会自动上传日志。 +- 人工恢复时只覆盖 `cmshopee.exe`、`_internal/`、版本/说明/manifest和更新器,必须保留 `data/`。不要把 `.cmshopee-update/backup/` 当业务数据目录。 diff --git a/docs/update-check.md b/docs/update-check.md index 8d142b1..37fe8c0 100644 --- a/docs/update-check.md +++ b/docs/update-check.md @@ -45,6 +45,7 @@ - **强制且自动安装元数据完整**:弹模态进度窗口,显示中文阶段、下载百分比/字节数和发布说明;点击「立即升级」后在工作线程下载、校验和暂存,再启动独立更新器并退出旧程序。更新器完成事务替换后自动启动新版。 - **强制但元数据不完整,或下载/校验/更新器启动失败**:继续阻断主窗口,只允许「重试」或「退出程序」,不能降级放行旧版,也不再打开浏览器让用户手工覆盖。 +- **同一版本和zip hash曾因新版早期崩溃回滚**:命中本地失败版本熔断,不重复自动安装;仍保持强制阻断,提示等待管理员发布不同hash的修复包/更高版本,或手动安装。 - **非强制**:**不弹任何提示**,直接进主界面(当前无"温和可跳过提示"分支;如需另立任务)。 - **失败放行**:接口断网、超时、返回非法 JSON、缺 `latest_version`/`min_supported_version` 时,客户端记诊断日志(`data/logs/cmshopee.log`,`step=startup_update_check`「已允许继续使用」)并**放行**,不因服务器故障导致全员打不开。 @@ -121,3 +122,9 @@ T-617 已提供独立无控制台更新器和事务回滚能力:更新器从 - 若将来要"非强制也温和提示(可跳过、不阻断)",需在客户端加一个非强制分支,另立任务。 - 空 `sha256` 只兼容 T-544 的人工下载引导,绝不能进入自动安装。自动安装还必须同时校验 HTTPS、`size_bytes`、包格式和更新器协议。 - `manifest_signature` 与 `signature_algorithm` 是预留字段;当前未启用数字签名,不能将 SHA-256 描述为发布者身份认证。 + +## 八、引导版本与灰度发布 + +仍只有T-544“浏览器下载”能力的旧客户端无法凭空获得独立更新器,必须先人工覆盖一个同时包含T-615至T-619代码和 `cmshopee-updater.exe` 的引导版本。后续自动发布先以非强制方式灰度确认接口字段、下载和manifest,再开启 `force_update` 或提高 `min_supported_version`。发布包的 `updater_protocol` / `min_updater_protocol` 必须与引导版本兼容。 + +更新器启动新版后等待健康标记:`process_started` 表示基础导入、Qt和版本核对完成;`main_window_ready` 表示数据目录、必要初始化和主窗口显示完成,此时才清理成功备份;`environment_blocked` 表示数据目录、配置或Chrome等本地环境需用户处理,保留新版且不误回滚。无已知标记、版本不符、早期退出或健康等待超时会恢复旧版并写失败版本熔断记录。 diff --git a/scripts/build_exe.ps1 b/scripts/build_exe.ps1 index 3fc2d99..0833e40 100644 --- a/scripts/build_exe.ps1 +++ b/scripts/build_exe.ps1 @@ -208,5 +208,5 @@ if ($LASTEXITCODE -ne 0) { Write-Host "$(Decode-Utf8Base64 "5p6E5bu65a6M5oiQOg==") $exePath" Write-Host "$(Decode-Utf8Base64 "5Y+R5biD55uu5b2VOg==") $releaseDir" Write-Host "$(Decode-Utf8Base64 "5L6/5pC65Y6L57yp5YyFOg==") $portableZip" -Write-Host "发布元数据: $releaseMetadata" +Write-Host "$(Decode-Utf8Base64 "5Y+R5biD5YWD5pWw5o2uOg==") $releaseMetadata" Write-Host (Decode-Utf8Base64 "5LiN6KaB5oqK5pys5ZywIGRhdGHjgIHphY3nva7jgIHmlbDmja7lupPjgIHlm77niYfjgIHml6Xlv5fmiJbnmbvlvZXmgIHlpI3liLbov5vlj5HluIPljIXjgII=") diff --git a/tests/test_gui.py b/tests/test_gui.py index f61554c..acedb23 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -1646,6 +1646,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): staged = SimpleNamespace( version="1.2.0", staging_dir=os.path.abspath(staging_root), + sha256="a" * 64, ) def prepare(_metadata, _install_root, **callbacks): @@ -1738,6 +1739,39 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assertEqual("重试", dialog.action_button.text()) self.assertIsNone(dialog.thread) + def test_startup_update_gate_fuses_a_previously_failed_release(self): + captured = [] + + class FakeDialog: + def __init__(self, result, parent=None): + captured.append(result) + + def exec(self): + return 0 + + result = update_check.UpdateCheckResult( + current_version="1.0.0", + checked=True, + forced=True, + latest_version="1.2.0", + download_url="https://cm.833729.com/cmshopee.zip", + sha256="a" * 64, + size_bytes=100, + package_format="cmshopee-portable-v1", + updater_protocol=1, + ) + with mock.patch( + "app.gui.update_health.get_failed_release", + return_value={"reason": "新版未就绪"}, + ): + allowed = gui._run_startup_update_gate( + checker=lambda: result, + dialog_factory=FakeDialog, + ) + + self.assertFalse(allowed) + self.assertIn("已停止重复安装", captured[0].automatic_update_error) + def test_startup_update_gate_check_failure_allows_entry_and_logs(self): result = update_check.UpdateCheckResult( current_version="1.0.0", diff --git a/tests/test_update_health.py b/tests/test_update_health.py new file mode 100644 index 0000000..dde51d9 --- /dev/null +++ b/tests/test_update_health.py @@ -0,0 +1,90 @@ +import json +import tempfile +import unittest +from pathlib import Path + +from app import update_health + + +class UpdateHealthTests(unittest.TestCase): + def test_context_and_atomic_health_markers(self): + with tempfile.TemporaryDirectory() as temp_dir: + context = update_health.context_from_argv( + [ + "cmshopee.exe", + "--update-transaction", + "transaction-1234", + "--update-target-version", + "2.0.0", + ], + temp_dir, + "2.0.0", + ) + self.assertIsNotNone(context) + update_health.write_health(context, "process_started") + update_health.write_health(context, "main_window_ready") + + payload = json.loads(context.health_path.read_text(encoding="utf-8")) + self.assertEqual("main_window_ready", payload["status"]) + self.assertEqual("2.0.0", payload["target_version"]) + + def test_context_rejects_wrong_version_or_transaction(self): + self.assertIsNone( + update_health.context_from_argv( + ["app", "--update-transaction", "bad", "--update-target-version", "2.0.0"], + ".", + "2.0.0", + ) + ) + self.assertIsNone( + update_health.context_from_argv( + [ + "app", + "--update-transaction", + "transaction-1234", + "--update-target-version", + "9.0.0", + ], + ".", + "2.0.0", + ) + ) + + def test_failed_release_fuse_is_version_and_hash_specific(self): + with tempfile.TemporaryDirectory() as temp_dir: + first = update_health.record_failed_release( + temp_dir, + "2.0.0", + "a" * 64, + "新版程序未就绪", + ) + second = update_health.record_failed_release( + temp_dir, + "2.0.0", + "a" * 64, + "再次失败", + ) + + self.assertEqual(1, first["attempts"]) + self.assertEqual(2, second["attempts"]) + self.assertIsNotNone( + update_health.get_failed_release(temp_dir, "2.0.0", "a" * 64) + ) + self.assertIsNone( + update_health.get_failed_release(temp_dir, "2.0.0", "b" * 64) + ) + self.assertIsNone( + update_health.get_failed_release(temp_dir, "2.0.1", "a" * 64) + ) + + def test_failed_release_file_does_not_touch_data(self): + with tempfile.TemporaryDirectory() as temp_dir: + data_file = Path(temp_dir) / "data" / "cmshopee.db" + data_file.parent.mkdir() + data_file.write_bytes(b"business") + update_health.record_failed_release(temp_dir, "2.0.0", "a" * 64, "失败") + self.assertEqual(b"business", data_file.read_bytes()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_update_installer.py b/tests/test_update_installer.py index bb36da8..5d5a507 100644 --- a/tests/test_update_installer.py +++ b/tests/test_update_installer.py @@ -44,6 +44,7 @@ class UpdateInstallerTests(unittest.TestCase): (package / "_internal").mkdir(parents=True) (package / "cmshopee.exe").write_bytes(b"new-exe") (package / "_internal" / "runtime.dll").write_bytes(b"runtime") + (package / "_internal" / "empty.marker").write_bytes(b"") (package / "version.txt").write_text(version, encoding="ascii") (package / "README.txt").write_text("说明", encoding="utf-8") release_manifest.write_package_manifest(package, version) diff --git a/tests/test_updater_entry.py b/tests/test_updater_entry.py index cd41c69..59d743d 100644 --- a/tests/test_updater_entry.py +++ b/tests/test_updater_entry.py @@ -27,6 +27,7 @@ class UpdaterEntryTests(unittest.TestCase): (staging / "_internal").mkdir(parents=True) (staging / "cmshopee.exe").write_bytes(b"new-exe") (staging / "_internal" / "new.dll").write_bytes(b"new") + (staging / "_internal" / "empty.marker").write_bytes(b"") (staging / "version.txt").write_text("2.0.0", encoding="ascii") (staging / "README.txt").write_text("新说明", encoding="utf-8") (staging / "cmshopee-updater.exe").write_bytes(b"new-updater") @@ -38,6 +39,7 @@ class UpdaterEntryTests(unittest.TestCase): target_version="2.0.0", transaction_id="transaction-1234", log_path=(install / ".cmshopee-update/logs/update.log").resolve(), + package_sha256="b" * 64, ) return install, staging, plan @@ -51,6 +53,7 @@ class UpdaterEntryTests(unittest.TestCase): plan, wait_parent=lambda _pid: None, launcher=lambda executable, args: launched.append((executable, args)), + health_waiter=lambda _plan, _process: "main_window_ready", ) self.assertEqual(b"new-exe", (install / "cmshopee.exe").read_bytes()) @@ -61,7 +64,7 @@ class UpdaterEntryTests(unittest.TestCase): before_hash, hashlib.sha256((install / "data/cmshopee.db").read_bytes()).hexdigest(), ) - self.assertTrue((backup / "cmshopee.exe").is_file()) + self.assertFalse(backup.exists()) self.assertEqual(1, len(launched)) def test_move_failure_rolls_back_old_program(self): @@ -113,6 +116,41 @@ class UpdaterEntryTests(unittest.TestCase): self.assertEqual(b"old-exe", (install / "cmshopee.exe").read_bytes()) self.assertTrue((install / "_internal/old.dll").is_file()) + def test_health_failure_rolls_back_and_records_failed_release(self): + with tempfile.TemporaryDirectory() as temp_dir: + install, _staging, plan = self.make_trees(temp_dir) + + def fail_health(_plan, _process): + raise updater_entry.UpdaterError("新版程序在主窗口就绪前退出") + + with self.assertRaisesRegex(updater_entry.UpdaterError, "已恢复旧版"): + updater_entry.apply_update( + plan, + wait_parent=lambda _pid: None, + launcher=lambda *_args: object(), + health_waiter=fail_health, + ) + + self.assertEqual(b"old-exe", (install / "cmshopee.exe").read_bytes()) + failed = json.loads( + (install / ".cmshopee-update/failed-versions.json").read_text(encoding="utf-8") + ) + self.assertIn("2.0.0:%s" % ("b" * 64), failed["releases"]) + + def test_environment_block_keeps_new_program_and_backup(self): + with tempfile.TemporaryDirectory() as temp_dir: + install, _staging, plan = self.make_trees(temp_dir) + backup = updater_entry.apply_update( + plan, + wait_parent=lambda _pid: None, + launcher=lambda *_args: object(), + health_waiter=lambda _plan, _process: "environment_blocked", + ) + + self.assertEqual(b"new-exe", (install / "cmshopee.exe").read_bytes()) + self.assertTrue((backup / "cmshopee.exe").is_file()) + self.assertFalse((install / ".cmshopee-update/pending.json").exists()) + def test_lock_blocks_concurrent_transaction(self): with tempfile.TemporaryDirectory() as temp_dir: install, _staging, plan = self.make_trees(temp_dir) @@ -139,14 +177,48 @@ class UpdaterEntryTests(unittest.TestCase): def test_create_and_load_plan_round_trip(self): with tempfile.TemporaryDirectory() as temp_dir: install, staging, _plan = self.make_trees(temp_dir) - plan_path = updater_entry.create_plan(install, staging, "2.0.0", 4321) + plan_path = updater_entry.create_plan( + install, + staging, + "2.0.0", + 4321, + "c" * 64, + ) loaded = updater_entry.load_plan(plan_path) payload = json.loads(plan_path.read_text(encoding="utf-8")) self.assertEqual("2.0.0", loaded.target_version) self.assertEqual(4321, payload["parent_pid"]) + self.assertEqual("c" * 64, loaded.package_sha256) self.assertNotIn("data", payload) + def test_health_waiter_accepts_ready_and_environment_markers(self): + with tempfile.TemporaryDirectory() as temp_dir: + install, _staging, plan = self.make_trees(temp_dir) + health_path = ( + install + / ".cmshopee-update" + / "transactions" + / plan.transaction_id + / "health.json" + ) + health_path.parent.mkdir(parents=True) + for status in ("main_window_ready", "environment_blocked"): + health_path.write_text( + json.dumps( + { + "transaction_id": plan.transaction_id, + "target_version": plan.target_version, + "status": status, + } + ), + encoding="utf-8", + ) + self.assertEqual( + status, + updater_entry.wait_for_health(plan, process=None, timeout=0.1), + ) + if __name__ == "__main__": unittest.main()