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 = []
|
||||
|
||||
@@ -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`、配置/数据目录、文档和原型目录,不新增正式业务模块。
|
||||
|
||||
+3
-1
@@ -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. 建议用户备份当前整个程序文件夹。
|
||||
|
||||
+6
-2
@@ -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环境的相同人工矩阵当前机器无法执行,仍是正式对外强制发布前的发布验收项,不能视为已有证据。
|
||||
|
||||
@@ -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/` 当业务数据目录。
|
||||
|
||||
@@ -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等本地环境需用户处理,保留新版且不误回滚。无已知标记、版本不符、早期退出或健康等待超时会恢复旧版并写失败版本熔断记录。
|
||||
|
||||
@@ -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=")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user